diff --git a/CLAUDE.md b/CLAUDE.md
index 26a544aea1..20b1b27ebe 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -146,9 +146,9 @@ and the rule that changing any cap changes the doc in the same commit — are in
[PERF.md](PERF.md).
`make demo-home` builds a **throwaway home with something on every place** — three
-projects, twelve conversations, standing orders, memories, a fourteen-day spending
+projects, twelve conversations, automations, memories, a fourteen-day spending
ledger — and opens `bin/codeaf` against it with `HOME` pointed there. Use it when you
-want to SEE a page full: on a machine that has just started using codeaf the standing,
+want to SEE a page full: on a machine that has just started using codeaf the automations,
memory and spend pages correctly draw nothing, which is indistinguishable from a page
that is broken. It never touches `~/.codeaf`. The seeder is `cmd/codeaf-demo-home` and
`docs/design/home-rethink/HANDOFF.md` says what is in the fixture and how to add to it.
@@ -356,14 +356,18 @@ terminal against a real model, and it is how a wave verifies that the surface
still behaves:
```sh
-make test-e2e-tui # TestTUIE2E alone, ~17m, 40m ceiling
+make test-e2e-tui # TestTUIE2E alone, 40m ceiling
make test-e2e # whole tagged package, 120m ceiling
go test -tags e2e -run TestTUIE2E -count=1 -timeout 40m -v ./internal/e2e/
```
-It needs a provider key and `tmux`, costs a few cents. `TestTUIE2E` alone is
-about **seventeen minutes** (most of it one subtest waiting out a five-minute
-standing pass). The whole tagged package does not fit in forty minutes —
+It needs a provider key and `tmux`, costs a few cents. `TestTUIE2E` alone was
+about **seventeen minutes**, most of it one subtest waiting out a five-minute
+standing pass; that subtest went with standing orders on 2026-10-10, and the
+suite has not been timed since. Automations have a keyless proof of their own —
+`go test -tags e2e -run TestAutomationsE2E -count=1 -v ./internal/e2e/` drives
+the real binary against a scripted model endpoint, so it needs only `tmux` and
+`bin/codeaf`. The whole tagged package does not fit in forty minutes —
ManualOnTheWire, QuestionsE2E and the roomfeed twins run first and eat the
budget — so `make test-e2e` gives it two hours. It SKIPS
rather than fails with no key, no tmux or no `bin/codeaf`, so run `make build`
diff --git a/Makefile b/Makefile
index 7f5278cb46..c1a7810fd8 100644
--- a/Makefile
+++ b/Makefile
@@ -310,15 +310,16 @@ test-packed-manual: embed
test-remote:
go test -tags docker_e2e -count=1 -run TestRemoteTwoMachines -timeout 20m ./internal/e2e/
-# THE AMBIENT SURFACE, ALONE. TestTUIE2E fits in about seventeen minutes; the
-# full tagged package does not fit in forty (ManualOnTheWire, QuestionsE2E and
-# the roomfeed twins run first and eat the budget). This is the door for the
-# seventeen-minute ambient proof. Needs OPENROUTER_API_KEY, tmux and bin/codeaf.
+# THE CHAT SURFACE, ALONE. TestTUIE2E fits in forty minutes where the full
+# tagged package does not (ManualOnTheWire, QuestionsE2E and the roomfeed twins
+# run first and eat the budget). It used to take about seventeen, most of them
+# one subtest waiting out a standing pass that went with standing orders on
+# 2026-10-10. Needs OPENROUTER_API_KEY, tmux and bin/codeaf.
test-e2e-tui: build
go test -tags e2e -count=1 -timeout 40m -v -run '^TestTUIE2E$$' ./internal/e2e/
# THE WHOLE TAGGED PACKAGE. Two hours is the measured fit on Spark once every
-# live-model lane is included; prefer test-e2e-tui when only the ambient surface
+# live-model lane is included; prefer test-e2e-tui when only the chat surface
# is under change.
test-e2e: build
go test -tags e2e -count=1 -timeout 120m -v ./internal/e2e/
@@ -326,12 +327,13 @@ test-e2e: build
# ── the demo home ───────────────────────────────────────────────────────────
#
# A HOME WITH SOMETHING ON EVERY PLACE, FOR LOOKING AT. On a machine that has
-# just started using codeaf the standing store, the memory store and the
+# just started using codeaf the automations store, the memory store and the
# spending ledger are empty, and every one of those pages correctly draws
# nothing — which is the emptiness law working and is also indistinguishable
# from a page that is broken. This builds a THROWAWAY home somewhere else and
# opens the real binary against it, so all of it can be seen full without a
-# single invented row landing in ~/.codeaf.
+# single invented row landing in ~/.codeaf. The window it opens starts no
+# automations clock (CODEAF_NO_AUTOMATIONS), so nothing invented ever runs.
#
# It prints the directory it built and the command to open it again, so the same
# home can be returned to:
diff --git a/PERF.md b/PERF.md
index 3dff833578..b045a43d7a 100644
--- a/PERF.md
+++ b/PERF.md
@@ -1516,12 +1516,12 @@ that pays some back lowers it in the same commit. It replaced a `t.Logf`, which
nothing in the Makefile passes `-v` to — the one line saying the prefix was
thousands of bytes over was printed where nobody would ever see it.
-The bill is not spread thin: `stand` is **9,607 bytes**, more than a quarter of
-the full tool block and nearly twice the next heaviest, and on a
-sixteen-thousand-token window it is roughly one token in six of everything that
-person has before they have said anything. Trimming it is a change to what the
-model is told, so it is owed by whoever owns the standing belt and not by this
-gate.
+The bill was not spread thin: `stand` was **9,607 bytes**, more than a quarter
+of the full tool block and nearly twice the next heaviest, and on a
+sixteen-thousand-token window it was roughly one token in six of everything that
+person had before they had said anything. Trimming it was a change to what the
+model is told, so it was owed by whoever owned the standing belt and not by this
+gate — and it was paid when automations replaced it (2026-10-10, below).
**And the prefix is not the same size on every machine, which is why both
figures are the widest machine's.** `grep` says a longer sentence about itself
@@ -1562,6 +1562,14 @@ both arms, and the caps rise by exactly that over sec's: measured on dev
`baa2d7f0f` with sec (2026-10-07), the full cap is **57,817** bytes and the lean
cap **50,189**, both dated in `prefixWaivers`.
+**Automations replace standing orders, and the `stand` bill is paid (2026-10-10).**
+`stand` and its **9,607** bytes leave every belt that carried them; `automation`,
+a smaller tool for the same sentences, takes its place, and the page's one
+sentence about leaving something behind stays the same length. The full prefix
+measures **52,713** bytes and the lean **44,935**, and both waivers fall to sit
+exactly on the measurement: the full cap is **52,713** (4,713 over its target)
+and the lean cap **44,935** (13,435 over).
+
## Following through on a completion claim
A turn may decline handoff once per request when its own continuation says no
@@ -2917,6 +2925,18 @@ cost grows with that partition, rather than losing punctuation or older duplicat
behind a retrieval ceiling. The canonical event journal is not periodically pruned;
projection/context work is bounded, while durable history continues to grow.
+Rules — memories marked always — ride in message[0] and close every task brief, so
+their block is bounded twice: at most **12 rules** (`alwaysBlockMost`) and **2,400
+runes** of rule lines (`alwaysBlockRunes`, half the routed block's 4,800), whole rules
+only, the newest waiting behind a `…N more` line. The block carries no age and no
+clock-derived byte, so it is byte-identical from turn to turn until a rule changes,
+which is what keeps the cached prefix warm (`prefixcache_test.go`). The read is one
+statement per turn start through the partial index `memories_always_active`, which
+holds only active rules, so it is a seek over the rule count; a failed read keeps the
+last block rather than re-pricing the prefix to say nothing. Rules are excluded from
+the router's shortlist, the binding reads and the memory tidy, so no line is paid for
+twice.
+
## Conversation deletion completion
Permanent deletion joins the task run's completion channel with
diff --git a/README.md b/README.md
index 207d1a593f..8db5a4c6c0 100644
--- a/README.md
+++ b/README.md
@@ -207,14 +207,15 @@ that includes running your own.
[](https://github.com/Agent-Field/CodeAF/tree/model-pool)
-## Standing orders
+## Automations
-Rules, reminders and watches are one thing, and you set them up by saying them.
-"Never commit straight to main here." "Every Monday, draft the weekly update."
-"Tell me when CI goes red." A card asks once; `1` and it stands, in this project
-or everywhere, on the same daily spend limit as the rest.
-
-
+Reminders, scheduled work and watches are one thing, and you set them up by
+saying them. "Remind me at 6 to leave." "Every Monday at 9, draft the weekly
+update." "Tell me when CI on main goes red." A card shows exactly what will run,
+when, and what one run may spend; `1` saves it. They run while codeaf is open,
+anything missed while it was closed runs once when it opens, and `/automations`
+shows each one with its history. A rule for all future work — "never commit
+straight to main here" — is a memory marked always.
## Headless is the other front door
@@ -235,7 +236,7 @@ best answer and records the assumption. `exec` runs one worker with no plan;
Most agents on a remote box mean ssh, tmux, and a terminal that lags on every
key. CodeAF splits in two instead. The screen runs on the machine in front of
you. The conversation runs on the machine that owns the work, and `home` shows
-that machine: its projects, tasks and standing orders.
+that machine: its projects, tasks and automations.
@@ -269,8 +270,8 @@ when the hosted relay does. [How it works](docs/REMOTE.md).
| where you work | one window, one repository | every project on the machine, from one control room |
| what you do | watch it type | describe work, answer what needs you, decide what lands |
| what runs | one model, one thread | six seats chosen per call, and subharnesses built for one job |
-| how long it lasts | one session | conversations, tasks and standing orders that outlive the window |
-| without you | it stops | headless, standing orders, a phone in your pocket |
+| how long it lasts | one session | conversations and tasks that outlive the window, automations on a clock |
+| without you | it stops | headless, automations, a phone in your pocket |
## Performance benchmark
diff --git a/assets/readme/screens/standing.webp b/assets/readme/screens/standing.webp
deleted file mode 100644
index 5e07ec837d..0000000000
Binary files a/assets/readme/screens/standing.webp and /dev/null differ
diff --git a/cmd/codeaf-demo-home/main.go b/cmd/codeaf-demo-home/main.go
index 5403916917..53a168b5f0 100644
--- a/cmd/codeaf-demo-home/main.go
+++ b/cmd/codeaf-demo-home/main.go
@@ -8,9 +8,9 @@ package main
// owner opened the standing page, the memory page and the spend page on their
// own machine and saw nothing, because on their machine those stores are new
// and empty — which is the emptiness law working exactly as designed. The
-// answer is not to write invented standing orders, invented memories and
-// invented spending into ~/.codeaf, which would make a person's own record a
-// lie; it is a SECOND home, somewhere else, that the surface can be pointed at.
+// answer is not to write invented automations, invented memories and invented
+// spending into ~/.codeaf, which would make a person's own record a lie; it is
+// a SECOND home, somewhere else, that the surface can be pointed at.
// So this program never touches the real state root: it writes one directory,
// the one it was given, and the launcher hands that directory to the binary as
// HOME.
@@ -22,8 +22,8 @@ package main
// its own main package and `bin/codeaf` does not change by a byte.
//
// EVERYTHING IS WRITTEN THROUGH THE ENGINE'S OWN WRITERS — session.SaveMeta,
-// session.RecordUsage, session.RecordArtifact, standing.Store, store.Store — so
-// what the surface reads back is what the product itself produces. The one
+// session.RecordUsage, session.RecordArtifact, automation.Store, store.Store —
+// so what the surface reads back is what the product itself produces. The one
// exception is stated where it is made ([writeTranscript]).
import (
@@ -84,8 +84,8 @@ func run(args []string) error {
fmt.Fprintf(os.Stderr, "\ncome back to this one with:\n make demo-home DEMO_HOME=%s KEEP=1\n", dir)
if *launch == "" {
- fmt.Fprintf(os.Stderr, "\nor open it by hand with:\n cd %s && HOME=%s %s\n",
- filepath.Join(dir, firstProjectName), dir, surfaceBeside())
+ fmt.Fprintf(os.Stderr, "\nor open it by hand with:\n cd %s && HOME=%s %s=1 %s\n",
+ filepath.Join(dir, firstProjectName), dir, demoNoClock, surfaceBeside())
return nil
}
return launchAgainst(dir, *launch)
@@ -114,8 +114,9 @@ const usageText = `codeaf-demo-home — build a home with something on every pla
--into where to build it; a fresh temporary directory when not given
--keep reuse a directory that already holds a demo home rather than refusing
to write over it, so a second launch sees what the first one left
- --launch run that binary against the demo home, with HOME pointed at it and a
- presence heartbeat running beside it, and return when it exits
+ --launch run that binary against the demo home, with HOME pointed at it, the
+ automations clock off and a presence heartbeat running beside it,
+ and return when it exits
It writes inside --into and nowhere else. The real state root (~/.codeaf) is
never opened.
@@ -135,8 +136,8 @@ never opened.
// spellings are two PROJECT BUCKETS for one folder — the fixture's conversations
// under `-tmp-…-codeaf` and the window's own under `-private-tmp-…-codeaf` — so
// home drew the demo project twice, and `enter` on a standing order refused
-// because the conversation that asked for it was "elsewhere"
-// ([app.homeItemEnter]'s bucket guard). Every path this fixture writes is the
+// because the conversation that asked for it was "elsewhere" (the bucket guard
+// in internal/tui3's homeItemEnter). Every path this fixture writes is the
// resolved one, so the fixture and the binary agree about what the folder is
// called.
func demoDir(into string, reuse bool) (dir string, fresh bool, err error) {
@@ -213,13 +214,29 @@ func launchAgainst(dir, binary string) error {
}
// demoEnviron is this process's environment with the codeaf pins that would
-// point the surface back at the real machine removed. HOME is set by the
-// caller; CODEAF_HOME would override it outright, which is the one variable
-// that could silently send a demo launch at the owner's own state root.
+// point the surface back at the real machine removed, and the automations
+// clock switched off. HOME is set by the caller; CODEAF_HOME would override it
+// outright, which is the one variable that could silently send a demo launch at
+// the owner's own state root.
func demoEnviron() []string {
- return env.EnvironWithout("HOME", home.EnvVar)
+ return append(env.EnvironWithout("HOME", home.EnvVar, demoNoClock), demoNoClock+"=1")
}
+// demoNoClock keeps every window opened on the demo home from starting the
+// automations clock ([automation.Startable] reads it): the window still reads
+// the store, draws the lines and counts itself open, and nothing runs.
+//
+// THE FIXTURE'S AUTOMATIONS ARE INVENTED AND THE CLOCK WOULD RUN THEM FOR REAL.
+// A window starts `codeaf clock` against the home it was opened on, and the
+// clock takes every slot that is due — the morning sweep and the dependency
+// bump are unattended sessions on the person's own key, and the watch on CI
+// runs a command and asks a model every fifteen minutes. So without this the
+// first look at the demo would spend real money on invented work, which is the
+// rule the task graph already keeps for the same reason (seed_room.go). The
+// cost is that an automation made INSIDE the demo is saved and never runs; the
+// demo is for looking at, and its history is the thing on show.
+const demoNoClock = "CODEAF_NO_AUTOMATIONS"
+
// resolveDemoDir is the directory with its symlinks followed, and the directory
// itself where they cannot be — a path that does not resolve is not a reason to
// refuse to build a fixture in it.
diff --git a/cmd/codeaf-demo-home/seed.go b/cmd/codeaf-demo-home/seed.go
index 22f7c0419f..f7468b9621 100644
--- a/cmd/codeaf-demo-home/seed.go
+++ b/cmd/codeaf-demo-home/seed.go
@@ -4,10 +4,10 @@ package main
//
// The order below is the order the surface reads things in: the projects on the
// disk, the conversations in their buckets, the work each project ran, the
-// standing orders and what they have cost, what the machine remembers, what it
-// has spent, and the messages a search ranks. Every one of them goes through
-// the writer the engine itself uses, so the surface is reading its own output
-// and not a second author's idea of the shape.
+// automations and the fortnight of runs behind them, what the machine
+// remembers, what it has spent, and the messages a search ranks. Every one of
+// them goes through the writer the engine itself uses, so the surface is
+// reading its own output and not a second author's idea of the shape.
import (
"fmt"
@@ -33,11 +33,14 @@ type builtHome struct {
Projects int
Conversations int
Tasks int
- Standing int
- Memories int
- UsageLines int
- Messages int
- Artifacts int
+ // Automations is how many automations the store holds, and AutomationRuns
+ // how many runs their history holds between them.
+ Automations int
+ AutomationRuns int
+ Memories int
+ UsageLines int
+ Messages int
+ Artifacts int
// Nodes and Jobs are the ONE conversation's own record of what it had out:
// the checkpointed task graph and the background jobs beside it
// (seed_room.go). They are counted apart from Tasks because they are a
@@ -49,10 +52,10 @@ type builtHome struct {
func (b builtHome) line() string {
return fmt.Sprintf("built a demo home in %s: %d projects, %d conversations, %d pieces of work, "+
- "%d standing orders, %d memories, %d spending lines, %d messages, %d made things, "+
+ "%d automations with %d runs, %d memories, %d spending lines, %d messages, %d made things, "+
"%d nodes in one graph, %d background jobs",
- b.Dir, b.Projects, b.Conversations, b.Tasks, b.Standing, b.Memories, b.UsageLines, b.Messages,
- b.Artifacts, b.Nodes, b.Jobs)
+ b.Dir, b.Projects, b.Conversations, b.Tasks, b.Automations, b.AutomationRuns, b.Memories,
+ b.UsageLines, b.Messages, b.Artifacts, b.Nodes, b.Jobs)
}
// seedDemoHome writes a whole v3 state into dir and answers what it wrote.
@@ -123,11 +126,16 @@ func seedDemoHome(dir string, now time.Time) (builtHome, error) {
}
built.Nodes, built.Jobs = nodes, jobs
- orders, err := writeStanding(filepath.Join(root, "v3", "standing"), projects, ids, now)
+ // THE AUTOMATIONS STORE IS WHERE A WINDOW OPENED ON THIS HOME LOOKS FOR IT:
+ // v3/automations under the state root, the two names cmd/codeaf's
+ // automationsRoot joins onto internal/home. It is spelled from this home's
+ // own root and never through internal/home, which would follow CODEAF_HOME
+ // to the person's real one.
+ automations, err := writeAutomations(filepath.Join(root, "v3", "automations"), projects, ids, now)
if err != nil {
return built, err
}
- built.Standing = orders
+ built.Automations, built.AutomationRuns = automations.automations, automations.runs
memories, err := writeMemories(brain, projects[firstProjectName].dir)
if err != nil {
@@ -135,7 +143,7 @@ func seedDemoHome(dir string, now time.Time) (builtHome, error) {
}
built.Memories = memories
- lines, err := writeUsage(filepath.Join(root, "v3", session.UsageLedgerName), projects, ids, now)
+ lines, err := writeUsage(filepath.Join(root, "v3", session.UsageLedgerName), projects, ids, automations.bills, now)
if err != nil {
return built, err
}
@@ -294,14 +302,14 @@ func initRepository(dir string, now time.Time) error {
// encodeWorkspace is the bucket name for a workspace: the path with its
// separators turned into dashes.
//
-// IT IS A COPY, DELIBERATELY, AND IT IS THE THIRD ONE. The encoder lives in
-// cmd/codeaf's chatv3_layout.go and is unexported there, and internal/standing's
-// inbox.go and internal/e2e's tmux test already spell it for themselves. What
-// makes a copy safe here is that the encoding is ONE-WAY and nothing reads it
-// back: the world layer takes a project's real path out of each session's
-// meta.json and never decodes a bucket (internal/session's world.go). A drift
-// would show up as a duplicate row on home the moment the surface opened one of
-// these projects, which is what the test beside this program looks for.
+// IT IS A COPY, DELIBERATELY. The encoder lives in cmd/codeaf's
+// chatv3_layout.go and is unexported there, and internal/e2e's tmux test
+// already spells it for itself. What makes a copy safe here is that the
+// encoding is ONE-WAY and nothing reads it back: the world layer takes a
+// project's real path out of each session's meta.json and never decodes a
+// bucket (internal/session's world.go). A drift would show up as a duplicate
+// row on home the moment the surface opened one of these projects, which is
+// what the test beside this program looks for.
func encodeWorkspace(workspace string) string {
encoded := strings.ReplaceAll(filepath.Clean(workspace), string(filepath.Separator), "-")
encoded = strings.ReplaceAll(encoded, ":", "-")
diff --git a/cmd/codeaf-demo-home/seed_automations.go b/cmd/codeaf-demo-home/seed_automations.go
new file mode 100644
index 0000000000..ac11c2cb1c
--- /dev/null
+++ b/cmd/codeaf-demo-home/seed_automations.go
@@ -0,0 +1,829 @@
+package main
+
+// seed_automations.go is the seven automations on the demo machine and the
+// fortnight they have had.
+//
+// EVERY ROW GOES THROUGH internal/automation's OWN STORE, AND SO DOES EVERY RUN.
+// [automation.Store.Create] makes each automation exactly as a card's yes does,
+// and the history is the clock's own sequence of doors: Take turns a slot into a
+// run and moves the automation on, Start and Finish stamp the run, SetSeen
+// records what a watch decided, FinishWatch ends a once-watch that spoke, and
+// SetStatus is the person's pause. The one thing a fixture adds is WHEN —
+// [automation.Store.SetClock] tells those doors what time it was, because a
+// history written through them at today's wall clock would be a fortnight of
+// runs that all happened this second.
+//
+// THE CLOCK IS SIMULATED, NOT SCRIPTED SLOT BY SLOT. An automation runs only
+// while a codeaf window is open, so this file says when windows were open —
+// working days, a weekend away, a morning started late, an afternoon left early
+// — and walks every automation through them the way the clock does: a slot that
+// comes while a window is open is taken on time, and whatever passed while none
+// was is caught up ONCE, late, when the next one opens. So which runs are late,
+// by how much, and when each automation next wakes are the schedule's own
+// arithmetic and never a figure typed here. Only what each run CAME TO is
+// written down, newest first.
+//
+// NOTHING HERE MAY BE LEFT IN HAND. Every run is over before the fixture's now,
+// and every active automation next wakes after it — a run left `running` would
+// make the quit question ask about work nobody is doing, and the demo window
+// starts no clock to finish it (main.go's [demoNoClock]).
+
+import (
+ "fmt"
+ "math"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/automation"
+ "github.com/Agent-Field/codeaf/internal/roles"
+ "github.com/Agent-Field/codeaf/internal/session"
+)
+
+// demoAutomation is one automation, in the two halves the product writes it in:
+// what the person agreed to, and what the clock did with it afterwards.
+type demoAutomation struct {
+ project string
+ // asked is the TITLE of the conversation it was said in, which becomes its
+ // [automation.Origin] — the conversation a finished run's line lands in, and
+ // the door "why did this happen?" opens. Its Words must be a turn the person
+ // said there, verbatim, and it is made just after that turn.
+ //
+ // Empty is one typed on home: no conversation and no words of its own, which
+ // is a real shape, and the only one an automation older than every
+ // conversation on this machine can honestly have.
+ asked string
+ // made is when one typed on home was made.
+ made demoWhen
+ item automation.Automation
+ // at is a one-time automation's moment, worked out from when it was made:
+ // "tomorrow at four" is tomorrow of the day it was said.
+ at func(made time.Time) time.Time
+ // pause is when the person paused it, if they did.
+ pause *demoWhen
+ // speaksAfter names the piece of work (a [demoTasks] title) whose landing
+ // changed what a watch looks at: its first look after that speaks, and a
+ // once-watch finishes there.
+ speaksAfter string
+ // took is how long one run usually takes.
+ took time.Duration
+ // newest is what its runs came to, newest first. first is the oldest run,
+ // when that one is not like the rest — a watch's first look never is — and
+ // every run between them takes rest in turn.
+ newest []demoRun
+ first *demoRun
+ rest []demoRun
+}
+
+// demoWhen is a wall time some days back.
+type demoWhen struct {
+ daysAgo int
+ clock time.Duration
+}
+
+func (w demoWhen) on(now time.Time) time.Time { return wallOn(now, w.daysAgo, w.clock) }
+
+// demoRun is what one run came to. A reminder needs none: it is always done,
+// and its line is its own.
+type demoRun struct {
+ outcome automation.Outcome
+ // judged is a watch's decided answer for the look, "yes" or "no", and empty
+ // for a look that could not be decided — which changes nothing the watch has
+ // seen, exactly as the clock records it.
+ judged string
+ // line is the one sentence a person reads. A piece of work that reported
+ // done leaves it empty: its line is the first line of its report, which is
+ // how the runner spells it.
+ line string
+ // detail is the longer account: the work's report, or — for work cut off
+ // before it reported — the last thing it said, which is what the runner
+ // keeps; or what a watch that spoke saw.
+ detail string
+ // said is what a piece of work said last when its account keeps none of it
+ // — a run stopped on a permission, or on a fault — so that its transcript
+ // still ends on what it was doing.
+ said string
+ usd float64
+ took time.Duration
+}
+
+// The line the clock puts on a run the person stopped. It is unexported in
+// internal/automation (clock.go's lineStopped), so it is spelled here as the
+// person reads it — and so, inline where it is used, is the runner's `needed
+// your ok to run …` (internal/session's automationNeedsLine), which names the
+// tool and then the command.
+//
+// THE FIXTURE HAS NO RUN CODEAF CLOSING CUT OFF. Such a run is paused rather
+// than over: the store makes its automation due again at the moment it was cut
+// off ([automation.Store.Finish]), so the replay's next take would be that
+// pickup rather than the rhythm's next slot, and this replay walks the rhythm.
+const demoStoppedLine = "stopped by you"
+
+// ciRedSight is what the watch on CI saw the time it spoke: gh's own table, as
+// it prints one without a terminal.
+const ciRedSight = "completed\tfailure\ttui3: fold the quiet rows\tci\tmaster\tpush\t18734\t4m12s\n" +
+ "completed\tsuccess\tsession: read Root on the fold\tci\tmaster\tpush\t18731\t3m58s\n" +
+ "completed\tsuccess\tmanual: the page about reading a task\tci\tmaster\tpush\t18725\t4m3s\n" +
+ "completed\tsuccess\ttui3: one reading of the world\tci\tmaster\tpush\t18719\t4m20s\n" +
+ "completed\tsuccess\tpicker: walk the list's rows\tci\tmaster\tpush\t18712\t3m51s"
+
+var demoAutomations = []demoAutomation{
+ {
+ // A REMINDER ON A RHYTHM, typed on home before anything else here. No
+ // model is called when it speaks, so it never costs a cent, and on an
+ // evening nobody was at a window it waited for the next morning.
+ project: firstProjectName,
+ made: demoWhen{daysAgo: 13, clock: 10*time.Hour + 20*time.Minute},
+ item: automation.Automation{
+ Title: "Write tomorrow's plan",
+ Schedule: automation.Schedule{Every: "30 17 * * 1-5"},
+ Action: automation.Action{Say: "Write tomorrow's plan before you close the laptop."},
+ },
+ },
+ {
+ // A REMINDER THAT HAS NOT HAPPENED YET — the list's row with a moment to
+ // come and no last result.
+ project: "pricing-site",
+ asked: "Pricing Research",
+ item: automation.Automation{
+ Title: "Send the ladder numbers",
+ Words: "and remind me tomorrow at four to send the ladder numbers to sales",
+ Action: automation.Action{Say: "Send the ladder numbers to sales."},
+ },
+ at: func(made time.Time) time.Time {
+ year, month, day := made.Date()
+ return time.Date(year, month, day+1, 16, 0, 0, 0, made.Location())
+ },
+ },
+ {
+ // SCHEDULED WORK ON A CRON LINE, in the live checkout, for the whole
+ // fortnight. It carries most of the outcomes a piece of work can come to,
+ // and its newest three are clean, which is what the conversation about
+ // it says.
+ project: firstProjectName,
+ made: demoWhen{daysAgo: 13, clock: 10*time.Hour + 12*time.Minute},
+ item: automation.Automation{
+ Title: "The morning sweep",
+ Schedule: automation.Schedule{Every: "0 9 * * *"},
+ Action: automation.Action{Do: "Read what landed on this repository since yesterday morning and tell me what changed under me: the branches, the files, and anything that touches what I have uncommitted."},
+ Limits: automation.Limits{Time: 20 * time.Minute, USD: 1},
+ },
+ took: 6 * time.Minute,
+ newest: []demoRun{
+ {outcome: automation.OutcomeDone, usd: 0.21,
+ detail: "Nothing landed under you since yesterday morning.\nmaster is where you left it, and nothing upstream touches README.md or scratch.md."},
+ {outcome: automation.OutcomeDone, usd: 0.34, took: 8 * time.Minute,
+ detail: "Two branches landed, both in internal/tui3.\nThe picker port and the lexer port; neither touches what you have uncommitted."},
+ {outcome: automation.OutcomeDone, usd: 0.29,
+ detail: "One branch landed: the tab bar's counts, three files under internal/tui3.\nIt reads the world once for the bar and the list, and nothing of yours is in its way."},
+ {outcome: automation.OutcomeYourCall, usd: 0.04, took: time.Minute,
+ line: "needed your ok to run bash git fetch origin",
+ said: "Fetching first, so the comparison is against what is really on the remote."},
+ {outcome: automation.OutcomeDone, usd: 0.38, took: 9 * time.Minute,
+ detail: "Three branches landed; one of them rewrites docs/HOME.md, which you have open.\nThe other two are under internal/session and change nothing you are holding."},
+ {outcome: automation.OutcomeStopped, usd: 0.12, took: 3 * time.Minute, line: demoStoppedLine,
+ detail: "Reading the eleven commits on the release branch, oldest first."},
+ {outcome: automation.OutcomeIncomplete, usd: 0.61, took: 20 * time.Minute, line: "ran out of its 20m",
+ detail: "The suite is still running against the third branch; there is nothing to report until it finishes."},
+ {outcome: automation.OutcomeDone, usd: 0.27,
+ detail: "One branch landed: the manual's page about reading a task.\nIt is all under internal/manual/chat and touches nothing you have uncommitted."},
+ {outcome: automation.OutcomeIncomplete, usd: 0.03, took: time.Minute,
+ line: "a fault: the provider answered 529: overloaded",
+ said: "Reading the reflog first, to see where you were yesterday morning."},
+ },
+ rest: []demoRun{
+ {outcome: automation.OutcomeDone, usd: 0.19, detail: "Nothing landed under you since yesterday morning."},
+ {outcome: automation.OutcomeDone, usd: 0.26,
+ detail: "One branch landed: the picker on the new list widget.\nTwo files under internal/tui3; your scratch.md is untouched."},
+ },
+ },
+ {
+ // SCHEDULED WORK IN A SEPARATE WORKTREE, on an interval rather than a cron
+ // line, so both dialects of a rhythm are on the list. Every run's account
+ // ends by saying where its branch was kept, which the runner does for every
+ // run that cut one.
+ project: firstProjectName,
+ made: demoWhen{daysAgo: 12, clock: 11*time.Hour + 5*time.Minute},
+ item: automation.Automation{
+ Title: "Try the dependency bump",
+ Schedule: automation.Schedule{Every: "2d"},
+ Action: automation.Action{Do: "Bump the Go dependencies with go get -u ./..., run go build and go test, and keep the branch for me to review. Merge nothing."},
+ Worktree: true,
+ Limits: automation.Limits{Time: 45 * time.Minute, USD: 2},
+ },
+ took: 14 * time.Minute,
+ newest: []demoRun{
+ {outcome: automation.OutcomeDone, usd: 0.88,
+ detail: "Bumped four modules; the build and the tests pass.\ngolang.org/x/sys, golang.org/x/term, golang.org/x/text and modernc.org/sqlite moved, and nothing else needed a change."},
+ {outcome: automation.OutcomeIncomplete, usd: 1.37, took: 45 * time.Minute, line: "ran out of its 45m",
+ detail: "go test ./... is on its fourth package; the race detector makes the suite slow."},
+ {outcome: automation.OutcomeStopped, usd: 0.22, took: 6 * time.Minute, line: demoStoppedLine,
+ detail: "Bumping golang.org/x/sys first; the other three wait on it."},
+ },
+ rest: []demoRun{
+ {outcome: automation.OutcomeDone, usd: 0.31,
+ detail: "Nothing to bump: every module is already at its newest release.\nThe branch is kept anyway, empty, so there is one place to look."},
+ {outcome: automation.OutcomeDone, usd: 0.64,
+ detail: "Bumped two modules; the build and the tests pass.\ngolang.org/x/net and golang.org/x/crypto moved, and nothing else needed a change."},
+ },
+ },
+ {
+ // A WATCH ON A COMMAND, said in a conversation five hours ago. It has
+ // looked every fifteen minutes since, nearly always quietly — and its
+ // newest looks hold the red run it spoke about and the look it could not
+ // decide, which are the lines waiting in the conversation that made it.
+ project: firstProjectName,
+ asked: "Standing Up the Watches",
+ item: automation.Automation{
+ Title: "CI on master",
+ Words: "keep an eye on CI and tell me when master goes red",
+ Schedule: automation.Schedule{Every: "15m"},
+ Look: &automation.Look{
+ Command: "gh run list --branch master --limit 5",
+ Condition: "any of the last five runs on master failed",
+ },
+ Action: automation.Action{Say: "CI is red on master."},
+ Limits: automation.Limits{USD: 0.25},
+ },
+ took: 7 * time.Second,
+ newest: []demoRun{
+ {outcome: automation.OutcomeQuiet, judged: "no", usd: 0.0012, line: "the last five runs on master are green"},
+ {outcome: automation.OutcomeQuiet, judged: "no", usd: 0.0013, line: "green again: the re-run of the typecheck job passed"},
+ {outcome: automation.OutcomeQuiet, judged: "yes", usd: 0.0012, line: "the typecheck job is still red on master"},
+ {outcome: automation.OutcomeDone, judged: "yes", usd: 0.0014,
+ detail: "the latest run on master failed in the typecheck job\n\n" + ciRedSight},
+ {outcome: automation.OutcomeQuiet, judged: "no", usd: 0.0012, line: "the last five runs on master are green"},
+ {outcome: automation.OutcomeUnchecked, took: 30 * time.Second, line: "couldn't decide: the provider did not answer in time"},
+ },
+ first: &demoRun{outcome: automation.OutcomeUnchecked, usd: 0.0011,
+ line: "gh printed an authentication error, so there was nothing to judge"},
+ rest: []demoRun{
+ {outcome: automation.OutcomeQuiet, judged: "no", usd: 0.0012, line: "the last five runs on master are green"},
+ },
+ },
+ {
+ // A WATCH ON FILES THAT SPOKE ONCE AND FINISHED. It looked every hour from
+ // the evening it was said, caught up once the morning after the night,
+ // spoke on the first look after the annual toggle rewrote pricing.json,
+ // and is in the list now as finished, with its history.
+ project: "pricing-site",
+ asked: "What the Discount Means",
+ item: automation.Automation{
+ Title: "pricing.json changes",
+ Words: "tell me once pricing.json changes, so I read the new numbers before anyone ships them",
+ Schedule: automation.Schedule{Every: "1h"},
+ Look: &automation.Look{
+ Files: "pricing.json",
+ Condition: "pricing.json changed since the last look",
+ Once: true,
+ },
+ Action: automation.Action{Say: "pricing.json changed: read the new numbers before they ship."},
+ },
+ speaksAfter: "Put the annual toggle on the pricing page",
+ took: 5 * time.Second,
+ newest: []demoRun{
+ {outcome: automation.OutcomeDone, judged: "yes", usd: 0.0010,
+ detail: "the annual prices were added beside the monthly ones; the enterprise seat is still 12\n\n" +
+ "1 file(s) match pricing.json\n1 change(s) since the last look:\n~ pricing.json (changed)"},
+ {outcome: automation.OutcomeQuiet, judged: "no", usd: 0.0009, line: "pricing.json has not changed since the last look"},
+ {outcome: automation.OutcomeQuiet, judged: "no", usd: 0.0009, line: "pricing.json has not changed since the last look"},
+ {outcome: automation.OutcomeUnchecked, took: 12 * time.Second, line: "couldn't decide: the provider answered 503: service unavailable"},
+ },
+ first: &demoRun{outcome: automation.OutcomeQuiet, judged: "no", usd: 0.0009,
+ line: "this is the first look, so there is nothing earlier to compare with"},
+ rest: []demoRun{
+ {outcome: automation.OutcomeQuiet, judged: "no", usd: 0.0009, line: "pricing.json has not changed since the last look"},
+ },
+ },
+ {
+ // THE PAUSED ONE. Create only ever makes an active automation — a card's
+ // yes is the only way one is made — so the pause is SetStatus afterwards,
+ // exactly as the person's own `p` writes it, and nothing has run since.
+ project: "infra",
+ made: demoWhen{daysAgo: 13, clock: 15*time.Hour + 40*time.Minute},
+ item: automation.Automation{
+ Title: "The Monday advisory check",
+ Schedule: automation.Schedule{Every: "0 10 * * 1"},
+ Action: automation.Action{Do: "Read the provider advisories published this week against terraform's lock file, and tell me which of them apply to what this repository uses."},
+ Limits: automation.Limits{Time: 15 * time.Minute, USD: 0.5},
+ },
+ pause: &demoWhen{daysAgo: 4, clock: 15*time.Hour + 5*time.Minute},
+ took: 4 * time.Minute,
+ newest: []demoRun{
+ {outcome: automation.OutcomeDone, usd: 0.18,
+ detail: "Two advisories against the aws provider; neither touches what this repository uses.\nBoth are in resources this lock file never loads."},
+ },
+ rest: []demoRun{
+ {outcome: automation.OutcomeDone, usd: 0.11, detail: "Nothing new against this lock file this week."},
+ },
+ },
+}
+
+// savedAfter is how long after the person's turn an automation said in it is
+// saved: the answer and its card, then one yes.
+const savedAfter = 2 * time.Minute
+
+// ── when a window was open ──────────────────────────────────────────────────
+
+// The working day on the demo machine, as wall times. A window opened at the
+// first and closed at the second is the only time the clock ran anything.
+const (
+ deskOpens = 8*time.Hour + 40*time.Minute
+ deskCloses = 18*time.Hour + 20*time.Minute
+ // sittingFor is how long the window the person is in NOW has been open. It
+ // reaches back past every automation said in a conversation today, so the
+ // watch saved five hours ago has looked on every slot since, whatever hour
+ // the fixture is built at.
+ sittingFor = 6 * time.Hour
+)
+
+// deskDays are the days, by days back, that were not ordinary working days,
+// and what each was instead. A zero pair is a day no window was opened at all.
+// They are what make the late runs: every slot that passed on them is caught
+// up once, late, by the first window after.
+var deskDays = map[int]struct{ opens, closes time.Duration }{
+ // A WEEKEND AWAY, so the morning sweep catches up ONCE when the person is
+ // back rather than three times.
+ 9: {}, 8: {},
+ // Back late after it.
+ 7: {opens: 10*time.Hour + 15*time.Minute, closes: deskCloses},
+ // Left early, so the evening reminder waited for the next morning.
+ 4: {opens: deskOpens, closes: 16*time.Hour + 10*time.Minute},
+ // Started late, so the nine o'clock sweep ran at half past eleven.
+ 3: {opens: 11*time.Hour + 30*time.Minute, closes: deskCloses},
+}
+
+// demoSpan is one stretch with a codeaf window open.
+type demoSpan struct{ from, to time.Time }
+
+// deskWindows is every stretch a window was open over the fortnight, merged and
+// in order, none of it after now: the working days, the window the person is
+// sitting in, and the moments automations were made — somebody making one was,
+// by definition, at a window.
+func deskWindows(now time.Time, made []time.Time) []demoSpan {
+ var spans []demoSpan
+ for day := usageDays - 1; day >= 0; day-- {
+ opens, closes := deskOpens, deskCloses
+ if odd, ok := deskDays[day]; ok {
+ opens, closes = odd.opens, odd.closes
+ }
+ if closes > opens {
+ spans = append(spans, demoSpan{wallOn(now, day, opens), wallOn(now, day, closes)})
+ }
+ }
+ spans = append(spans, demoSpan{now.Add(-sittingFor), now})
+ for _, at := range made {
+ spans = append(spans, demoSpan{at.Add(-time.Minute), at.Add(10 * time.Minute)})
+ }
+ var clipped []demoSpan
+ for _, span := range spans {
+ if span.to.After(now) {
+ span.to = now
+ }
+ if span.from.Before(span.to) {
+ clipped = append(clipped, span)
+ }
+ }
+ sort.Slice(clipped, func(i, j int) bool { return clipped[i].from.Before(clipped[j].from) })
+ var merged []demoSpan
+ for _, span := range clipped {
+ if last := len(merged) - 1; last >= 0 && !span.from.After(merged[last].to) {
+ if span.to.After(merged[last].to) {
+ merged[last].to = span.to
+ }
+ continue
+ }
+ merged = append(merged, span)
+ }
+ return merged
+}
+
+// wallOn is a wall time on the day daysAgo days before now, read in now's zone.
+// It is built from the clock's hours and minutes rather than added to midnight,
+// so a day the clocks change on still opens at twenty to nine.
+func wallOn(now time.Time, daysAgo int, clock time.Duration) time.Time {
+ day := now.AddDate(0, 0, -daysAgo)
+ return time.Date(day.Year(), day.Month(), day.Day(),
+ int(clock/time.Hour), int(clock%time.Hour/time.Minute), 0, 0, now.Location())
+}
+
+// ── the clock, replayed ─────────────────────────────────────────────────────
+
+// demoClock is the store with a fixture's hand on its clock.
+type demoClock struct {
+ store *automation.Store
+ at time.Time // what the store's writes read as now
+ now time.Time // the fixture's now: nothing here happens after it
+ windows []demoSpan
+}
+
+// demoTake is one slot the clock took: the slot, the moment it took it, and
+// when the window it was taken in closed.
+type demoTake struct {
+ slot, at, closes time.Time
+}
+
+// when is the moment the clock took a slot: just after it, when a window was
+// open then — the clock looks every two seconds — or the moment the next window
+// opened, when none was. ok is false for a slot nothing has taken yet.
+func (c *demoClock) when(slot time.Time) (at, closes time.Time, ok bool) {
+ for _, span := range c.windows {
+ if slot.After(span.to) {
+ continue
+ }
+ if slot.Before(span.from) {
+ return span.from, span.to, true
+ }
+ at = slot.Add(time.Second)
+ if at.After(span.to) {
+ at = span.to
+ }
+ return at, span.to, true
+ }
+ return time.Time{}, time.Time{}, false
+}
+
+// madeAutomations is what the seeding came to: the counts, and what the runs
+// cost, which the ledger books (seed_spend.go) so the spend page and each run's
+// own figure are one account.
+type madeAutomations struct {
+ automations, runs int
+ bills []session.UsageLine
+}
+
+// writeAutomations makes every automation and drives it through the fortnight,
+// in the store under root, and answers what it wrote.
+func writeAutomations(root string, projects map[string]*demoProject, ids map[string]string, now time.Time) (madeAutomations, error) {
+ var made madeAutomations
+ // WHOLE SECONDS. The store keeps milliseconds, so every moment here is one
+ // it can hold exactly, and what is read back is what was written: the
+ // replay below checks the slot the store took against the one it expected,
+ // and that check should only ever be about the schedule, never rounding.
+ now = now.Truncate(time.Second)
+ store, err := automation.Open(root)
+ if err != nil {
+ return made, fmt.Errorf("open the automations store: %w", err)
+ }
+ defer store.Close()
+
+ // When each was made comes first, because the windows depend on all of
+ // them: nobody makes an automation without a window open.
+ moments := make([]time.Time, len(demoAutomations))
+ origins := make([]automation.Origin, len(demoAutomations))
+ for index, spec := range demoAutomations {
+ moment, origin, err := spec.madeAt(projects, ids, now)
+ if err != nil {
+ return made, err
+ }
+ moments[index], origins[index] = moment, origin
+ }
+ clock := &demoClock{store: store, now: now, windows: deskWindows(now, moments)}
+ store.SetClock(func() time.Time { return clock.at })
+
+ // THE ZONE IS CAPTURED THE WAY THE automation TOOL CAPTURES IT, so a rhythm
+ // here reads as one said on this machine.
+ zone := session.LocalZone()
+ for index, spec := range demoAutomations {
+ project, ok := projects[spec.project]
+ if !ok {
+ return made, fmt.Errorf("the automation %q names no project %q", spec.item.Title, spec.project)
+ }
+ item := spec.item
+ item.Workspace = project.dir
+ item.Origin = origins[index]
+ if item.Schedule.Repeats() {
+ item.Schedule.Zone = zone
+ }
+ if spec.at != nil {
+ item.Schedule.At = spec.at(moments[index])
+ }
+ clock.at = moments[index]
+ saved, err := store.Create(item)
+ if err != nil {
+ return made, fmt.Errorf("make the automation %q: %w", item.Title, err)
+ }
+ runs, bills, err := clock.drive(spec, saved)
+ if err != nil {
+ return made, fmt.Errorf("run the automation %q: %w", item.Title, err)
+ }
+ if spec.pause != nil {
+ clock.at = spec.pause.on(now)
+ if _, err := store.SetStatus(saved.ID, automation.StatusPaused); err != nil {
+ return made, fmt.Errorf("pause the automation %q: %w", item.Title, err)
+ }
+ }
+ made.automations++
+ made.runs += runs
+ made.bills = append(made.bills, bills...)
+ }
+ return made, nil
+}
+
+// madeAt is when an automation was made and the conversation it was made in.
+//
+// ONE SAID IN A CONVERSATION IS MADE JUST AFTER THE TURN THAT ASKED FOR IT, and
+// that turn must be the person's own words verbatim — a Words that is not in
+// the journal it points at would be provenance pointing at nothing.
+func (spec demoAutomation) madeAt(projects map[string]*demoProject, ids map[string]string, now time.Time) (time.Time, automation.Origin, error) {
+ if spec.asked == "" {
+ return spec.made.on(now), automation.Origin{}, nil
+ }
+ talk, ok := talkNamed(spec.asked)
+ if !ok {
+ return time.Time{}, automation.Origin{}, fmt.Errorf("the automation %q was said in %q, which is not a conversation this fixture writes", spec.item.Title, spec.asked)
+ }
+ if talk.project != spec.project {
+ return time.Time{}, automation.Origin{}, fmt.Errorf("the automation %q is in %q but was said in a conversation in %q", spec.item.Title, spec.project, talk.project)
+ }
+ turn := -1
+ for index, spoken := range talk.turns {
+ if spoken.said == spec.item.Words {
+ turn = index
+ }
+ }
+ if turn < 0 {
+ return time.Time{}, automation.Origin{}, fmt.Errorf("the automation %q keeps words %q that nobody said in %q", spec.item.Title, spec.item.Words, spec.asked)
+ }
+ id, project := ids[talk.title], projects[talk.project]
+ if id == "" || project == nil {
+ return time.Time{}, automation.Origin{}, fmt.Errorf("the conversation %q was not written", talk.title)
+ }
+ moment := turnAt(now.Add(-talk.ago), len(talk.turns), turn).Add(savedAfter)
+ if moment.After(now) {
+ moment = now
+ }
+ return moment, automation.Origin{
+ SessionID: id,
+ Transcript: filepath.Join(project.bucket, id, "transcript.jsonl"),
+ }, nil
+}
+
+// drive replays the clock over one automation: the slots it took, in order,
+// each one taken, started and finished through the store at the moment it
+// happened. It answers how many runs it wrote and what they cost.
+func (c *demoClock) drive(spec demoAutomation, a automation.Automation) (int, []session.UsageLine, error) {
+ var paused, speaks time.Time
+ if spec.pause != nil {
+ paused = spec.pause.on(c.now)
+ }
+ if spec.speaksAfter != "" {
+ ago, ok := taskLanded(spec.speaksAfter)
+ if !ok {
+ return 0, nil, fmt.Errorf("it speaks after %q, which is not work this fixture lands", spec.speaksAfter)
+ }
+ speaks = c.now.Add(-ago)
+ }
+
+ // FIRST THE SLOTS, worked out with the schedule's own arithmetic, because
+ // what each run came to is written newest first and so the count comes
+ // before the first write. The replay below then asks the store for each one,
+ // and a store that disagrees about the slot is a failure, not a fixup.
+ var takes []demoTake
+ for next := a.Next; !next.IsZero(); {
+ at, closes, ok := c.when(next)
+ if !ok || at.After(c.now) || (!paused.IsZero() && !at.Before(paused)) {
+ break
+ }
+ takes = append(takes, demoTake{slot: next, at: at, closes: closes})
+ if !a.Schedule.Repeats() || (!speaks.IsZero() && !at.Before(speaks)) {
+ break
+ }
+ var err error
+ if next, err = a.Schedule.Next(at); err != nil {
+ return 0, nil, err
+ }
+ }
+ scripts := make([]demoRun, len(takes))
+ for index := range takes {
+ scripts[index] = spec.script(len(takes)-1-index, index)
+ }
+ if err := checkJudgments(a, scripts); err != nil {
+ return 0, nil, err
+ }
+
+ var bills []session.UsageLine
+ for index, take := range takes {
+ script := scripts[index]
+ current, err := c.store.Get(a.ID)
+ if err != nil {
+ return index, bills, err
+ }
+ c.at = take.at
+ run, err := c.store.Take(current, take.at)
+ if err != nil {
+ return index, bills, fmt.Errorf("take the slot at %s: %w", take.slot, err)
+ }
+ if !run.Due.Equal(take.slot.Truncate(time.Millisecond)) {
+ return index, bills, fmt.Errorf("the store took the slot at %s where the replay expected %s", run.Due, take.slot)
+ }
+ if err := c.store.Start(run.ID); err != nil {
+ return index, bills, err
+ }
+ took := script.took
+ if took == 0 {
+ took = spec.took
+ }
+ finished := take.at.Add(took)
+ if finished.After(take.closes) {
+ finished = take.closes
+ }
+ if err := c.record(current, &run, script, take.at, finished); err != nil {
+ return index, bills, err
+ }
+ if script.judged != "" {
+ if err := c.store.SetSeen(a.ID, current.Revision, script.judged, ""); err != nil {
+ return index, bills, err
+ }
+ }
+ if current.Look != nil && current.Look.Once && script.outcome == automation.OutcomeDone {
+ if err := c.store.FinishWatch(a.ID, current.Revision); err != nil {
+ return index, bills, err
+ }
+ }
+ c.at = finished
+ if err := c.store.Finish(run); err != nil {
+ return index, bills, err
+ }
+ if bill, ok := demoBill(current, run, finished); ok {
+ bills = append(bills, bill)
+ }
+ }
+ return len(takes), bills, nil
+}
+
+// script is what the run at newest-first position k, chronological position i,
+// came to.
+func (spec demoAutomation) script(k, i int) demoRun {
+ switch {
+ case k < len(spec.newest):
+ return spec.newest[k]
+ case i == 0 && spec.first != nil:
+ return *spec.first
+ case len(spec.rest) > 0:
+ return spec.rest[i%len(spec.rest)]
+ }
+ return demoRun{outcome: automation.OutcomeDone}
+}
+
+// checkJudgments holds a watch's history to the clock's own rule (clock.go's
+// watch): it speaks on the CHANGE to yes, is quiet while the condition stays
+// true and whenever it is false, and a look it could not decide changes nothing
+// it has seen. A history that broke the rule would be a fixture drawing a watch
+// the product cannot have, so it fails here rather than on somebody's screen.
+func checkJudgments(a automation.Automation, scripts []demoRun) error {
+ seen := ""
+ for index, run := range scripts {
+ if a.Look == nil {
+ if run.judged != "" {
+ return fmt.Errorf("run %d judges a condition, and only a watch has one", index)
+ }
+ continue
+ }
+ var want automation.Outcome
+ switch {
+ case run.judged == "":
+ want = automation.OutcomeUnchecked
+ case run.judged == "no", seen == "yes":
+ want = automation.OutcomeQuiet
+ default:
+ want = automation.OutcomeDone
+ }
+ if run.outcome != want {
+ return fmt.Errorf("look %d judged %q after %q and came to %q; the clock would record %q", index, run.judged, seen, run.outcome, want)
+ }
+ if run.judged != "" {
+ seen = run.judged
+ }
+ }
+ return nil
+}
+
+// record writes what one run came to onto it, in the words the clock and the
+// runner use, and lays down the session folder a piece of work ran in.
+func (c *demoClock) record(a automation.Automation, run *automation.Run, script demoRun, started, finished time.Time) error {
+ run.Outcome, run.USD = script.outcome, round(script.usd)
+ run.Line, run.Detail = script.line, script.detail
+ switch a.Kind() {
+ case automation.KindReminder:
+ run.Line = strings.TrimSpace(a.Action.Say)
+ case automation.KindWatch:
+ if script.outcome == automation.OutcomeDone {
+ run.Line = strings.TrimSpace(a.Action.Say)
+ }
+ default:
+ if run.Line == "" {
+ run.Line, _, _ = strings.Cut(script.detail, "\n")
+ }
+ folder, err := c.writeRunFolder(a, *run, script, started, finished)
+ if err != nil {
+ return err
+ }
+ run.Transcript = folder
+ if a.Worktree {
+ run.Detail = strings.TrimSpace(run.Detail + "\n\n" + worktreeNote(folder, a, *run))
+ }
+ }
+ return nil
+}
+
+// writeRunFolder lays down the session folder one run of work ran in, where
+// the runner puts it — runs// under the store — with the
+// identity file the runner writes and a journal of the brief and how it ended,
+// so the transcript a run's history points at is one that is there.
+func (c *demoClock) writeRunFolder(a automation.Automation, run automation.Run, script demoRun, started, finished time.Time) (string, error) {
+ id := strconv.FormatInt(run.ID, 10)
+ folder := filepath.Join(c.store.Root(), "runs", a.ID, id)
+ if err := os.MkdirAll(folder, 0o700); err != nil {
+ return "", fmt.Errorf("make %s: %w", folder, err)
+ }
+ worker := demoModels[1]
+ if err := session.SaveMeta(folder, session.Meta{
+ ID: id, Title: a.Title, Workspace: a.Workspace, Model: worker.slug, Created: started,
+ }); err != nil {
+ return "", fmt.Errorf("write the identity of %s: %w", folder, err)
+ }
+ words := script.said
+ if words == "" {
+ words = script.detail
+ }
+ // THE JOURNAL ENDS WHEN THE RUN DID. Its one turn is stamped back from the
+ // finish, so nothing in it is later than the run it records.
+ talk := demoTalk{
+ title: a.Title, model: worker.slug, spent: round(script.usd),
+ tokens: billTokens(script.usd, worker.rate),
+ turns: []demoTurn{{said: a.Action.Do, answered: words}},
+ }
+ if err := writeTranscript(filepath.Join(folder, "transcript.jsonl"), id, a.Workspace, talk, finished.Add(-savedAfter)); err != nil {
+ return "", err
+ }
+ return folder, nil
+}
+
+// worktreeNote is the line the runner adds to the account of every run it cut a
+// worktree for: the branch it kept, and where. The name is the runner's own
+// shape — automation-- — with the hex taken from the run so a
+// demo built twice names the same branch.
+func worktreeNote(folder string, a automation.Automation, run automation.Run) string {
+ name := fmt.Sprintf("automation-%s-%06x", session.TaskSlug(a.Title), (run.ID*0x9e3779)&0xffffff)
+ return "work kept on automation/" + name + " in " + filepath.Join(folder, "trees", name)
+}
+
+// demoBill is the ledger line one run's money is booked under, and false for a
+// run that cost nothing — a reminder never does, and a look the provider never
+// answered was never priced.
+//
+// IT IS THE SHAPE THE PRODUCT WRITES. A watch's judgment is one call on the low
+// tier's model — here the cheapest of [demoModels] — made as the sentinel role
+// and booked against the automation with no conversation (internal/session's
+// automation_run.go). A run of work is a session of its own whose journal is
+// the run's folder, so its lines name that session — the run's id — beside the
+// automation; its calls are booked on one line, the way this fixture books
+// every turn.
+func demoBill(a automation.Automation, run automation.Run, at time.Time) (session.UsageLine, bool) {
+ if run.USD <= 0 {
+ return session.UsageLine{}, false
+ }
+ line := session.UsageLine{At: at, USD: run.USD, Automation: a.ID, Workspace: a.Workspace}
+ if a.Kind() == automation.KindWatch {
+ judge := demoModels[len(demoModels)-1]
+ tokens := billTokens(run.USD, judge.rate)
+ line.Model, line.Role, line.Calls = judge.slug, string(roles.RoleSentinel), 1
+ line.Input, line.Output = tokens-tokens/40, tokens/40
+ return line, true
+ }
+ worker := demoModels[1]
+ tokens := billTokens(run.USD, worker.rate)
+ line.Model, line.Calls = worker.slug, 3+int(run.ID%6)
+ line.Input, line.Output = tokens*4/5, tokens/5
+ line.Session = strconv.FormatInt(run.ID, 10)
+ return line, true
+}
+
+// billTokens is how many tokens a figure buys at a model's rough rate, so the
+// ledger's tokens and its money agree with each other.
+func billTokens(usd, rate float64) int {
+ return int(math.Round(usd / rate * 1000))
+}
+
+// talkNamed is the conversation with that title.
+func talkNamed(title string) (demoTalk, bool) {
+ for _, talk := range demoConversations {
+ if talk.title == title {
+ return talk, true
+ }
+ }
+ return demoTalk{}, false
+}
+
+// taskLanded is how long ago the piece of work with that title landed.
+func taskLanded(title string) (time.Duration, bool) {
+ for _, task := range demoTasks {
+ if task.entry.Title == title && task.ago > 0 {
+ return task.ago, true
+ }
+ }
+ return 0, false
+}
diff --git a/cmd/codeaf-demo-home/seed_room.go b/cmd/codeaf-demo-home/seed_room.go
index 22b9f08216..c5d5147dd3 100644
--- a/cmd/codeaf-demo-home/seed_room.go
+++ b/cmd/codeaf-demo-home/seed_room.go
@@ -419,7 +419,7 @@ var roomPlanJournal = []journalLine{
answer("c1", "tui3 12ms\ntui2 6ms\nsession 4ms\nstanding 3ms\n"),
{Type: "message", Role: "assistant", Content: "Now the measurement itself, once per package.",
ToolCalls: []ai.ToolCall{call("c2", "bash", map[string]any{"command": "go test -run TestFrameBudget -bench . ./internal/... | tee /tmp/budget.txt"})}},
- answer("c2", "internal/tui3 14.2ms/frame OVER\ninternal/tui2 5.9ms/frame\ninternal/session 4.1ms/frame\ninternal/standing 3.0ms/frame\ninternal/head 11.8ms/frame OVER\ninternal/resident 10.4ms/frame OVER\ninternal/remote 9.7ms/frame OVER\nok\n"),
+ answer("c2", "internal/tui3 14.2ms/frame OVER\ninternal/tui2 5.9ms/frame\ninternal/session 4.1ms/frame\ninternal/automation 3.0ms/frame\ninternal/head 11.8ms/frame OVER\ninternal/resident 10.4ms/frame OVER\ninternal/remote 9.7ms/frame OVER\nok\n"),
{Type: "message", Role: "assistant", Content: "Four are over. Writing the order down: the tab bar first, because every other count on the surface is read from it.",
ToolCalls: []ai.ToolCall{call("c3", "write", map[string]any{"path": "docs/design/polish/budget.md"})}},
answer("c3", "wrote docs/design/polish/budget.md (48 lines)"),
@@ -491,7 +491,7 @@ func roomDevServerLog() string {
out.WriteString(" VITE v5.4.2 ready in 412 ms\n\n")
out.WriteString(" ➜ Local: http://localhost:5173/\n")
out.WriteString(" ➜ Network: http://192.168.1.24:5173/\n\n")
- routes := []string{"/", "/home", "/tasks", "/tasks/3", "/jobs", "/jobs/1", "/spend", "/standing", "/memory", "/search"}
+ routes := []string{"/", "/home", "/tasks", "/tasks/3", "/jobs", "/jobs/1", "/spend", "/automations", "/memory", "/search"}
for tick := 0; tick < 24; tick++ {
for _, route := range routes {
fmt.Fprintf(&out, "%02d:%02d:%02d [vite] GET %s 200 in %dms\n",
diff --git a/cmd/codeaf-demo-home/seed_spend.go b/cmd/codeaf-demo-home/seed_spend.go
index 842d552ba3..873bb2a061 100644
--- a/cmd/codeaf-demo-home/seed_spend.go
+++ b/cmd/codeaf-demo-home/seed_spend.go
@@ -6,9 +6,9 @@ package main
// Every line goes through [session.RecordUsage], which is the same door the
// engine writes through, and the ids on it JOIN — a line about a conversation
// names a conversation on the disk, a line about a piece of work names a row in
-// that project's index, a line about a standing order names an item in the
-// standing store. That is what the spend page's `what it was for` column reads,
-// and a ledger of orphan ids would draw rows a person cannot follow anywhere.
+// that project's index, a line about an automation names one in the automations
+// store. That is what the spend page's `what it was for` column reads, and a
+// ledger of orphan ids would draw rows a person cannot follow anywhere.
//
// A LINE THAT SPENT NOTHING IS NOT WRITTEN — the ledger's own law, kept by its
// writer — so nothing here is priced at zero.
@@ -20,7 +20,6 @@ import (
"github.com/Agent-Field/codeaf/internal/roles"
"github.com/Agent-Field/codeaf/internal/session"
- "github.com/Agent-Field/codeaf/internal/standing"
)
// demoModels are the three the demo machine has spent money on. They are real
@@ -78,16 +77,16 @@ const demoDayAnchor = 9 * time.Hour
// demoMoment is when a fixture line meant for daysAgo days ago is stamped, and
// IT NEVER LEAVES THE DAY IT NAMES.
//
-// The offsets the seeders add — a firing seventeen minutes before the last one,
-// a turn eleven minutes after the one before it — used to be added to `now`
-// itself, so a demo home built at ten past midnight stamped TODAY's standing
-// firing fifty minutes earlier, which is yesterday, and the spend page's
-// cost-per-firing clause then drew nothing on a fixture whose entire purpose is
-// to have something on every place. One built at half past eleven at night put
-// today's turns on tomorrow's row of the day axis. The day is chosen first, the
-// offset is applied inside it, and the result is clamped to the day at both
-// ends — and to `now` as well, because money spent in the future reads as a
-// broken fixture rather than a full one.
+// The offsets the seeders add — a turn eleven minutes after the one before it,
+// and in the standing orders this fixture used to seed, a firing seventeen
+// minutes before the last one — used to be added to `now` itself, so a demo
+// home built at ten past midnight stamped TODAY's firing fifty minutes earlier,
+// which is yesterday, and the page that read today's figure drew nothing on a
+// fixture whose entire purpose is to have something on every place. One built
+// at half past eleven at night put today's turns on tomorrow's row of the day
+// axis. The day is chosen first, the offset is applied inside it, and the
+// result is clamped to the day at both ends — and to `now` as well, because
+// money spent in the future reads as a broken fixture rather than a full one.
func demoMoment(now time.Time, daysAgo int, offset time.Duration) time.Time {
day := now.AddDate(0, 0, -daysAgo)
start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, day.Location())
@@ -113,9 +112,11 @@ const usageDays = 14
// The three SUBJECTS a line can name are all here, because the page's binding
// column is only interesting when it has more than one thing to say: a
// conversation (a turn somebody took), a piece of work (a task node's own
-// calls), and a standing order (a firing, which is unattended and which a person
-// recognises long before they recognise the run it spawned).
-func writeUsage(path string, projects map[string]*demoProject, ids map[string]string, now time.Time) (int, error) {
+// calls), and an automation (a run nobody was watching, which a person
+// recognises by the automation long before they recognise the run). The
+// automations' lines are bills, made by the runs that spent the money
+// (seed_automations.go), so they are written as they came.
+func writeUsage(path string, projects map[string]*demoProject, ids map[string]string, bills []session.UsageLine, now time.Time) (int, error) {
written := 0
// A stable, boring rhythm rather than a random one: the same fixture on two
// machines is the same fixture, and a demo that looked different every time
@@ -191,32 +192,6 @@ func writeUsage(path string, projects map[string]*demoProject, ids map[string]st
}
}
- // One standing check a day. A firing is cheap and unattended, so it runs
- // on the smallest model and names its ITEM rather than the run it
- // spawned — which is the id a person recognises.
- if firing := spendingOrders(); len(firing) > 0 {
- order := firing[next()%len(firing)]
- if project := projects[order.project]; project != nil {
- // A check names the role it was made for; internal/roles is the
- // vocabulary, and it is NOT the five router slots — nothing in the
- // program records which slot a call ran under.
- if err := record(path, session.UsageLine{
- At: demoMoment(now, day, spread+time.Duration(next()%5)*19*time.Minute),
- Model: demoModels[len(demoModels)-1].slug,
- Role: string(roles.RoleIntake),
- Calls: 1,
- Input: 1_400 + 200*(next()%4),
- Output: 300,
- USD: round(0.004 + 0.001*float64(next()%5)),
- Standing: order.item.ID,
- Workspace: project.dir,
- }); err != nil {
- return written, err
- }
- written++
- }
- }
-
// The naming calls beside the turns — the one auxiliary role a person
// meets by name, and the reason the page's role column has a second word
// on it.
@@ -290,6 +265,18 @@ func writeUsage(path string, projects map[string]*demoProject, ids map[string]st
}
}
+ // AND WHAT THE AUTOMATIONS' RUNS COST, one line per run that spent anything,
+ // stamped when the run ended. They are not a rhythm made up here: each is the
+ // figure its own run records, so the spend page and an automation's history
+ // add up to the same money. They are written last because they are already
+ // whole — no pick, no rotation, nothing for the lines above to share.
+ for _, bill := range bills {
+ if err := record(path, bill); err != nil {
+ return written, err
+ }
+ written++
+ }
+
// The writer is a background goroutine per ledger — it never blocks a turn —
// so nothing is on the disk until this returns.
session.FlushUsage()
@@ -325,16 +312,3 @@ func aliveOn(talks []demoTalk, daysAgo int) []demoTalk {
}
return alive
}
-
-// spendingOrders is the standing orders that can cost money. A hold never wakes,
-// so it can never spend, and a ledger line against one would be a fact about
-// this fixture and about nothing the product does.
-func spendingOrders() []demoOrder {
- var spending []demoOrder
- for _, order := range demoOrders {
- if order.item.When.Kind != standing.WhenHold {
- spending = append(spending, order)
- }
- }
- return spending
-}
diff --git a/cmd/codeaf-demo-home/seed_standing.go b/cmd/codeaf-demo-home/seed_standing.go
deleted file mode 100644
index dc49b438b6..0000000000
--- a/cmd/codeaf-demo-home/seed_standing.go
+++ /dev/null
@@ -1,218 +0,0 @@
-package main
-
-// seed_standing.go is the four standing orders and what they have cost.
-//
-// Every one of them goes through internal/standing's own store, so the document
-// on disk is the one the ticker and the surface both read: [standing.Store.Create]
-// validates it, stamps it and works out when it is next due, and
-// [standing.Store.Save] is how the quiet half — what the last check said, how
-// many firings in a row came back clean — is written afterwards. The daily
-// ledger beside them is [standing.Store.Append], one line per firing and per
-// check that cost money, which is where the cost-per-firing on the page is
-// summed from.
-
-import (
- "fmt"
- "path/filepath"
- "time"
-
- "github.com/Agent-Field/codeaf/internal/standing"
-)
-
-// demoOrder is one standing order, in two halves: the item as it is created,
-// and the quiet half a check or a firing writes onto it afterwards.
-type demoOrder struct {
- project string
- item standing.Item
- // after is what has happened to it since. It is applied with a Save, exactly
- // as the ticker applies it, because Create refuses to make anything that is
- // not active and has never run.
- after func(item *standing.Item, now time.Time)
- // asked is the TITLE of the conversation this order was made in, and it
- // becomes the item's [standing.Origin] — the door home opens on `enter`,
- // which answers "why did I get this?".
- //
- // EVERY ORDER NEEDS ONE. An item with no origin is a row whose `enter`
- // refuses, and a fixture full of them is a fixture that never exercises the
- // door at all — which is the join discipline seed.go already states for
- // tasks and ledger lines: a join that points at nothing draws a row a person
- // cannot open.
- asked string
- // spent is the ledger this order has left behind: one entry per element,
- // oldest first, counted back in days from today.
- spent []demoFiring
-}
-
-// entryCheck names a ledger line that is a probe and its sentinel rather than a
-// firing: it costs money, so it counts against the day's rail, and it did not
-// fire, so it counts against nothing else. The word is internal/standing's own
-// (ledger.go's entryCheck) and is unexported there; "say" and "task" beside it
-// are [standing.ActionKind]'s two values and are spelled through the package.
-const entryCheck = "check"
-
-// demoFiring is one line of a day's ledger: what it was, what it cost, and how
-// many days back it happened.
-type demoFiring struct {
- kind string
- usd float64
- daysAgo int
-}
-
-var demoOrders = []demoOrder{
- {
- // The one that is ASKING. Its latest run stopped on a question, which is
- // the field home sorts on and the reason this row is drawn first.
- project: firstProjectName,
- asked: "Standing Up the Watches",
- item: standing.Item{
- ID: "watch-ci",
- Words: "tell me when CI goes red on master",
- Altitude: standing.AltitudeProject,
- Brief: standing.Brief{Title: "CI on master"},
- When: standing.When{Kind: standing.WhenProbe, Words: "every five minutes", ProbeEvery: 5 * time.Minute, Hint: "yes when any run on master shows conclusion=failure", Probe: standing.Probe{Command: "gh run list --branch master --limit 5"}},
- Does: standing.Action{Kind: standing.ActionSay, Say: "CI is red on master: {{evidence}}"},
- Rails: standing.Rails{PerRunUSD: 0.25, MaxPerDay: 12},
- Grant: "It may read the repository and the run log, and it asks before anything else.",
- },
- after: func(item *standing.Item, now time.Time) {
- item.LastChecked = now.Add(-3 * time.Minute)
- item.LastCheckLine = "the last five runs on master are green"
- item.Previous = []string{"three runs green, one cancelled → nothing", "all green → nothing"}
- item.Runs = 4
- item.LastFired = now.Add(-31 * time.Hour)
- item.LastOutcome = "said: CI is red on master (the typecheck job)"
- item.SpentUSD = 1.18
- item.NeedsPerson = "May I re-run the typecheck job to see whether it is flaky?"
- },
- spent: []demoFiring{
- {entryCheck, 0.01, 0}, {entryCheck, 0.01, 0}, {entryCheck, 0.02, 0},
- {string(standing.ActionSay), 0.21, 1}, {entryCheck, 0.02, 1},
- {entryCheck, 0.01, 2}, {string(standing.ActionSay), 0.19, 3},
- },
- },
- {
- // The one that FIRED TODAY and has come back clean three times running,
- // which is the count the rope column's middle rung is drawn from.
- project: firstProjectName,
- asked: "Standing Up the Watches",
- item: standing.Item{
- ID: "morning-sweep",
- Words: "sweep the repo every morning at nine and tell me what changed under me",
- Altitude: standing.AltitudeProject,
- Brief: standing.Brief{Title: "the morning sweep"},
- When: standing.When{Kind: standing.WhenEvery, Words: "every morning at nine", Every: "0 9 * * *"},
- Does: standing.Action{Kind: standing.ActionTask, MaxSteps: 40,
- Brief: "Read what landed on this repository since yesterday morning and say what changed under me.",
- Acceptance: "One paragraph naming the branches and the files, or the sentence that nothing landed."},
- Rails: standing.Rails{PerRunUSD: 0.40, MaxPerDay: 2},
- Grant: "It may read the repository and run the test suite; it writes nothing.",
- },
- after: func(item *standing.Item, now time.Time) {
- morning := time.Date(now.Year(), now.Month(), now.Day(), 9, 0, 0, 0, now.Location())
- if morning.After(now) {
- morning = morning.AddDate(0, 0, -1)
- }
- item.LastChecked = morning
- item.LastCheckLine = "nothing landed under you since yesterday morning"
- item.Runs = 12
- item.CleanRuns = 3
- item.LastFired = morning
- item.LastOutcome = "done: two branches landed, both in internal/tui3"
- item.SpentUSD = 3.94
- },
- spent: []demoFiring{
- {string(standing.ActionTask), 0.33, 0}, {string(standing.ActionTask), 0.29, 1},
- {string(standing.ActionTask), 0.31, 2}, {string(standing.ActionTask), 0.36, 3},
- {string(standing.ActionTask), 0.28, 4},
- },
- },
- {
- // The PAUSED one. Create only ever makes an active item — a proposal is a
- // card in a conversation and only a yes makes an item — so the pause is a
- // Save afterwards, exactly as the surface's own `p` writes it.
- project: "infra",
- asked: "The Backup Window",
- item: standing.Item{
- ID: "deps-weekly",
- Words: "check the dependency advisories every Monday morning",
- Altitude: standing.AltitudeProject,
- Brief: standing.Brief{Title: "the Monday advisory check"},
- When: standing.When{Kind: standing.WhenEvery, Words: "every Monday at ten", Every: "0 10 * * 1"},
- Does: standing.Action{Kind: standing.ActionSay, Say: "New advisories against what this repo depends on: {{evidence}}"},
- Rails: standing.Rails{PerRunUSD: 0.20, MaxPerDay: 1},
- },
- after: func(item *standing.Item, now time.Time) {
- item.Status = standing.StatusPaused
- item.Runs = 6
- item.LastFired = now.Add(-9 * 24 * time.Hour)
- item.LastOutcome = "said: nothing new against this lockfile"
- item.SpentUSD = 0.72
- },
- spent: []demoFiring{{string(standing.ActionSay), 0.11, 9}},
- },
- {
- // The RULE. A hold never wakes, so it can never spend, which is why it
- // alone needs no rails and no action — and why it leaves no ledger line.
- project: firstProjectName,
- asked: "The Manual's Missing Page",
- item: standing.Item{
- ID: "public-api-hold",
- Words: "never change the public API without telling me first",
- Altitude: standing.AltitudeProject,
- Brief: standing.Brief{Title: "the public API"},
- When: standing.When{Kind: standing.WhenHold, Words: "always"},
- },
- },
-}
-
-// writeStanding creates the four orders, writes the quiet half onto them, and
-// leaves the day ledgers their cost-per-firing is summed from.
-func writeStanding(root string, projects map[string]*demoProject, ids map[string]string, now time.Time) (int, error) {
- orders, err := standing.Open(root)
- if err != nil {
- return 0, fmt.Errorf("open the standing store: %w", err)
- }
- written := 0
- for _, order := range demoOrders {
- project, ok := projects[order.project]
- if !ok {
- return written, fmt.Errorf("the standing order %q names no project %q", order.item.ID, order.project)
- }
- item := order.item
- item.Workspace = project.dir
- // THE ORDER REMEMBERS WHERE IT WAS ASKED FOR, so `enter` on its home row
- // has a conversation to open ([app.homeItemEnter]).
- id, ok := ids[order.asked]
- if !ok {
- return written, fmt.Errorf("the standing order %q was asked for in %q, which is not a conversation this fixture writes", item.ID, order.asked)
- }
- item.Origin = standing.Origin{
- SessionID: id,
- Transcript: filepath.Join(project.bucket, id, "transcript.jsonl"),
- }
- made, err := orders.Create(item)
- if err != nil {
- return written, fmt.Errorf("create the standing order %q: %w", item.ID, err)
- }
- if order.after != nil {
- order.after(&made, now)
- if err := orders.Save(made); err != nil {
- return written, fmt.Errorf("write what has happened to %q: %w", item.ID, err)
- }
- }
- for _, firing := range order.spent {
- if err := orders.Append(standing.Entry{
- // Inside the day it names, whatever hour the fixture is built
- // at — see [demoMoment].
- At: demoMoment(now, firing.daysAgo, -time.Duration(written+1)*17*time.Minute),
- ItemID: made.ID,
- Kind: firing.kind,
- USD: firing.usd,
- }); err != nil {
- return written, fmt.Errorf("write the ledger for %q: %w", item.ID, err)
- }
- }
- written++
- }
- return written, nil
-}
diff --git a/cmd/codeaf-demo-home/seed_talk.go b/cmd/codeaf-demo-home/seed_talk.go
index 6a409ab48c..3716b8b75f 100644
--- a/cmd/codeaf-demo-home/seed_talk.go
+++ b/cmd/codeaf-demo-home/seed_talk.go
@@ -138,9 +138,12 @@ var demoConversations = []demoTalk{
{
project: firstProjectName, title: "Standing Up the Watches", ago: 5 * time.Hour,
spent: 0.29, tokens: 22_400, model: "anthropic/claude-sonnet-4",
+ // THE FIRST TURN IS AN AUTOMATION'S OWN WORDS. The watch on CI was saved
+ // here (seed_automations.go), and its Words are this sentence verbatim,
+ // so the provenance it carries is a line this journal really has.
turns: []demoTurn{
- {"keep an eye on CI and tell me when master goes red", "Set up. It looks every five minutes, spends at most twenty-five cents a look, and asks before it does anything but tell you."},
- {"and sweep the repo every morning at nine", "That one is standing too. It has come back clean three mornings running."},
+ {"keep an eye on CI and tell me when master goes red", "Saved as a watch. Every fifteen minutes while codeaf is open it reads the last five runs on master, and it tells you once when one turns red — not on every look while it stays red."},
+ {"and sweep the repo every morning at nine", "That one is already an automation — you typed it on home two weeks ago — and it has come back clean three mornings running."},
},
},
{
@@ -190,6 +193,9 @@ var demoConversations = []demoTalk{
turns: []demoTurn{
{"what does the enterprise ladder actually beat per-seat at?", "Above forty users, on the numbers in pricing.json. Below that per-seat is cheaper for the buyer and better for us."},
{"write that down where I will find it again", "Remembered as a project decision, so the next conversation about the ladder starts knowing it."},
+ // A REMINDER THAT HAS NOT HAPPENED YET was saved here, and this is
+ // the sentence it keeps as its Words.
+ {"and remind me tomorrow at four to send the ladder numbers to sales", "Saved as a reminder for tomorrow at 16:00. It says \"Send the ladder numbers to sales.\" and nothing more — no model is called when it does."},
},
},
{
@@ -204,6 +210,9 @@ var demoConversations = []demoTalk{
spent: 0.31, tokens: 24_300, model: "openai/gpt-5-mini",
turns: []demoTurn{
{"is the annual discount two months free or seventeen percent off?", "They are the same number said two ways. Two months free is the one buyers read fastest."},
+ // The watch on pricing.json was saved here; it has since spoken once
+ // and finished, and its lines land in this conversation.
+ {"tell me once pricing.json changes, so I read the new numbers before anyone ships them", "Saved as a watch on pricing.json. It looks every hour while codeaf is open, tells you the first time the file changes, and then it is finished."},
},
},
}
@@ -228,7 +237,7 @@ func writeConversation(project *demoProject, talk demoTalk, now time.Time, brain
Workspace: project.dir,
LaunchDir: project.dir,
Model: talk.model,
- Created: spoke.Add(-time.Duration(len(talk.turns)) * 12 * time.Minute),
+ Created: spoke.Add(-time.Duration(len(talk.turns)) * turnSpacing),
LastUserAt: spoke,
SpentUSD: talk.spent,
Tokens: talk.tokens,
@@ -251,7 +260,7 @@ func writeConversation(project *demoProject, talk demoTalk, now time.Time, brain
// THIS IS THE ONE PLACE THIS PROGRAM SPELLS A FILE FORMAT, and it is worth
// saying why rather than hiding it. Every other shape here has an exported
// writer — session.SaveMeta, session.RecordUsage, session.RecordArtifact, the
-// standing store, the memory store — and this one does not: a journal is
+// automations store, the memory store — and this one does not: a journal is
// written by a live agent holding a flock, through internal/session's
// unexported sessionEntry, and there is no seam for "write me a conversation
// that already happened". Two things keep the copy honest. The fields are the
@@ -295,6 +304,20 @@ type journalSpend struct {
DurationMS int64 `json:"durationMs,omitempty"`
}
+// turnSpacing is how far apart a seeded conversation's turns are, spread
+// backwards from when the person last spoke, so a journal reads as something
+// that took a while.
+const turnSpacing = 12 * time.Minute
+
+// turnAt is when the person said turn `turn` of `turns`, in a conversation
+// they last spoke in at spoke. It is the ONE spelling of a turn's moment: the
+// journal is stamped with it, and an automation saved in a conversation is
+// made just after the turn that asked for it (seed_automations.go), so the two
+// can never disagree about when that was.
+func turnAt(spoke time.Time, turns, turn int) time.Time {
+ return spoke.Add(-time.Duration(turns-1-turn) * turnSpacing)
+}
+
// writeTranscript writes one conversation's journal: the header, the turns in
// the order they happened with a seal after each, and the name the session
// settled on.
@@ -303,11 +326,7 @@ func writeTranscript(path, id, workspace string, talk demoTalk, spoke time.Time)
if turns == 0 {
return fmt.Errorf("conversation %q has no turns", talk.title)
}
- // The turns are spread backwards from when the person last spoke, twelve
- // minutes apart, so a journal reads as something that took a while.
- at := func(turn int) time.Time {
- return spoke.Add(-time.Duration(turns-1-turn) * 12 * time.Minute)
- }
+ at := func(turn int) time.Time { return turnAt(spoke, turns, turn) }
lines := []journalLine{{
Type: "session", Version: 1, ID: id, Cwd: workspace, Model: talk.model,
Timestamp: at(0).Add(-time.Minute).Format(time.RFC3339Nano),
diff --git a/cmd/codeaf-demo-home/seed_test.go b/cmd/codeaf-demo-home/seed_test.go
index 794bf1db09..36c8195a75 100644
--- a/cmd/codeaf-demo-home/seed_test.go
+++ b/cmd/codeaf-demo-home/seed_test.go
@@ -21,9 +21,10 @@ import (
"testing"
"time"
+ "github.com/Agent-Field/codeaf/internal/automation"
+ "github.com/Agent-Field/codeaf/internal/env"
"github.com/Agent-Field/codeaf/internal/plandb"
"github.com/Agent-Field/codeaf/internal/session"
- "github.com/Agent-Field/codeaf/internal/standing"
"github.com/Agent-Field/codeaf/internal/store"
teamstore "github.com/Agent-Field/codeaf/internal/teams"
)
@@ -35,8 +36,8 @@ func TestTheDemoHomeFillsEveryPlace(t *testing.T) {
// believed for three heartbeats measured against the wall — so a fixture
// seeded at a pinned instant is a fixture whose conversations are all dead
// before the first assertion. What must NOT depend on where in the day this
- // runs is the day-bucketed half — the standing ledger's today and the spend
- // page's fourteen-day axis — and that is
+ // runs is the day-bucketed half — the automations' fortnight of runs and the
+ // spend page's fourteen-day axis — and that is
// [TestTheDemoHomesLedgersStayInsideTheirOwnDays], which pins both sides of
// midnight instead.
now := time.Now()
@@ -199,43 +200,8 @@ func TestTheDemoHomeFillsEveryPlace(t *testing.T) {
t.Fatalf("the record holds %d running and %d landed rows; the page wants both sections", running, landed)
}
- // ── standing: the four states the page draws ──────────────────────────────
- orders, err := standing.Open(filepath.Join(dir, ".codeaf", "v3", "standing"))
- if err != nil {
- t.Fatalf("open the standing store: %v", err)
- }
- items, err := orders.List()
- if err != nil {
- t.Fatalf("list the standing orders: %v", err)
- }
- if len(items) != built.Standing {
- t.Fatalf("the standing store holds %d items out of %d written", len(items), built.Standing)
- }
- asking, fired, paused, held := 0, 0, 0, 0
- for _, item := range items {
- switch {
- case item.NeedsPerson != "":
- asking++
- case item.Status == standing.StatusPaused:
- paused++
- case item.When.Kind == standing.WhenHold:
- held++
- }
- if item.CleanRuns > 0 && item.LastCheckLine != "" {
- fired++
- }
- }
- if asking == 0 || fired == 0 || paused == 0 || held == 0 {
- t.Fatalf("the standing page wants one of each and has asking=%d fired=%d paused=%d rule=%d",
- asking, fired, paused, held)
- }
- spend, err := orders.Today("", now)
- if err != nil {
- t.Fatalf("read today's standing ledger: %v", err)
- }
- if spend.USD <= 0 {
- t.Fatal("nothing standing has spent anything today, so cost per firing draws nothing")
- }
+ // ── automations: every kind and state, and a fortnight of runs ────────────
+ automationIDs := assertAutomations(t, dir, now, built, rows)
// ── memory: three shelves, both counters, and one let go ──────────────────
brain, err := store.Open(filepath.Join(dir, ".codeaf", "graph.db"))
@@ -283,13 +249,17 @@ func TestTheDemoHomeFillsEveryPlace(t *testing.T) {
t.Fatalf("the ledger holds %d lines out of %d written", len(lines), built.UsageLines)
}
models, days := map[string]bool{}, map[string]bool{}
- conversations, work, standingSpend := 0, 0, 0
+ conversations, work, automationSpend := 0, 0, 0
for _, line := range lines {
models[line.Model] = true
days[line.Day] = true
switch {
- case line.Standing != "":
- standingSpend++
+ case line.Automation != "":
+ automationSpend++
+ // The id joins: a line about an automation names one the store holds.
+ if !automationIDs[line.Automation] {
+ t.Fatalf("a ledger line names the automation %q, which the store does not hold", line.Automation)
+ }
case line.Task != "":
work++
case line.Session != "":
@@ -305,9 +275,9 @@ func TestTheDemoHomeFillsEveryPlace(t *testing.T) {
if len(days) < usageDays-1 {
t.Fatalf("the spend page's day axis has %d days on it, want about %d", len(days), usageDays)
}
- if conversations == 0 || work == 0 || standingSpend == 0 {
- t.Fatalf("the `what it was for` column wants all three subjects and has conversations=%d work=%d standing=%d",
- conversations, work, standingSpend)
+ if conversations == 0 || work == 0 || automationSpend == 0 {
+ t.Fatalf("the `what it was for` column wants all three subjects and has conversations=%d work=%d automations=%d",
+ conversations, work, automationSpend)
}
// ── search: a query a person would actually type finds a conversation ─────
@@ -337,6 +307,138 @@ func TestTheDemoHomeFillsEveryPlace(t *testing.T) {
}
}
+// assertAutomations reads the automations store back the way a window does and
+// insists on everything the list, a history and a conversation's lines need:
+// every kind and state, every outcome, a late run, an origin whose lines land,
+// and nothing left in hand. It answers the ids, which the ledger's lines must
+// name.
+func assertAutomations(t *testing.T, dir string, now time.Time, built builtHome, rows []session.SessionRow) map[string]bool {
+ t.Helper()
+ autos, err := automation.Open(filepath.Join(dir, ".codeaf", "v3", "automations"))
+ if err != nil {
+ t.Fatalf("open the automations store: %v", err)
+ }
+ defer autos.Close()
+ list, err := autos.List()
+ if err != nil {
+ t.Fatalf("list the automations: %v", err)
+ }
+ if len(list) != built.Automations {
+ t.Fatalf("the automations store holds %d automations out of %d written", len(list), built.Automations)
+ }
+ byTranscript := map[string]session.SessionRow{}
+ for _, row := range rows {
+ byTranscript[row.Transcript] = row
+ }
+ kinds := map[automation.Kind]int{}
+ outcomes := map[automation.Outcome]int{}
+ automationIDs := map[string]bool{}
+ cron, worktree, command, files, once, paused, origins, landing, late, runs := 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
+ for _, item := range list {
+ automationIDs[item.ID] = true
+ kinds[item.Kind()]++
+ if item.Schedule.Repeats() && item.Schedule.Interval() == 0 {
+ cron++
+ }
+ if item.Worktree {
+ worktree++
+ }
+ if look := item.Look; look != nil {
+ if look.Command != "" {
+ command++
+ }
+ if look.Files != "" {
+ files++
+ }
+ if look.Once {
+ once++
+ }
+ }
+ if item.Status == automation.StatusPaused {
+ paused++
+ }
+ // AN ACTIVE AUTOMATION WAKES AFTER NOW. One that was due already would
+ // be a slot no window ever took, and the demo starts no clock to take it.
+ if item.Status == automation.StatusActive && !item.Next.After(now) {
+ t.Fatalf("%q is active and was due at %s, before the home was built", item.Title, item.Next)
+ }
+ history, err := autos.Runs(item.ID, 1000)
+ if err != nil {
+ t.Fatalf("read the history of %q: %v", item.Title, err)
+ }
+ for _, run := range history {
+ runs++
+ outcomes[run.Outcome]++
+ if run.Late() > 0 {
+ late++
+ }
+ if run.Phase != automation.PhaseOver || run.Started.Before(run.Due) || run.Finished.Before(run.Started) || run.Finished.After(now) {
+ t.Fatalf("a run of %q is not one finished run before now: %+v", item.Title, run)
+ }
+ // A run's transcript is the folder its session ran in, and a folder
+ // that is not there is a door that opens on nothing.
+ if run.Transcript != "" {
+ if _, spoken := session.Peek(filepath.Join(run.Transcript, "transcript.jsonl")); !spoken {
+ t.Fatalf("a run of %q names a transcript session.Peek cannot read: %s", item.Title, run.Transcript)
+ }
+ }
+ }
+ // THE ORIGIN IS A CONVERSATION THAT IS REALLY THERE, and its lines land:
+ // what a window draws on opening it is the delivered runs among the
+ // newest ten that ended after the person was last in it
+ // (internal/tui3's automationsAway).
+ if item.Origin.Transcript == "" {
+ continue
+ }
+ origins++
+ row, ok := byTranscript[item.Origin.Transcript]
+ if !ok || row.ID != item.Origin.SessionID {
+ t.Fatalf("%q was made in %s, which home does not read as a conversation", item.Title, item.Origin.Transcript)
+ }
+ meta, err := session.LoadMeta(row.Dir)
+ if err != nil {
+ t.Fatalf("read the conversation %q made in: %v", item.Title, err)
+ }
+ newest, err := autos.Runs(item.ID, 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, run := range newest {
+ if run.Outcome.Delivered() && run.Finished.After(meta.LastUserAt) {
+ landing++
+ }
+ }
+ }
+ if kinds[automation.KindReminder] == 0 || kinds[automation.KindWork] < 2 || kinds[automation.KindWatch] < 2 {
+ t.Fatalf("the list wants every kind and has %v", kinds)
+ }
+ if cron == 0 || worktree == 0 || command == 0 || files == 0 || once == 0 || paused == 0 {
+ t.Fatalf("the list wants a cron rhythm, worktree work, a command watch, a files watch, a once-watch and a pause, and has "+
+ "cron=%d worktree=%d command=%d files=%d once=%d paused=%d", cron, worktree, command, files, once, paused)
+ }
+ if runs != built.AutomationRuns {
+ t.Fatalf("the store holds %d runs out of %d written", runs, built.AutomationRuns)
+ }
+ for _, outcome := range []automation.Outcome{
+ automation.OutcomeDone, automation.OutcomeQuiet, automation.OutcomeYourCall,
+ automation.OutcomeIncomplete, automation.OutcomeStopped, automation.OutcomeUnchecked,
+ } {
+ if outcomes[outcome] == 0 {
+ t.Fatalf("no run came to %q, so a history never draws %q: %v", outcome, outcome.Word(), outcomes)
+ }
+ }
+ if late == 0 {
+ t.Fatal("no run is late, so a history never draws `late`")
+ }
+ if origins == 0 || landing == 0 {
+ t.Fatalf("%d automations name the conversation they were made in and %d runs have a line waiting there", origins, landing)
+ }
+ if active, err := autos.Active(); err != nil || len(active) != 0 {
+ t.Fatalf("the demo home has runs in hand that no clock will ever finish: %+v, %v", active, err)
+ }
+ return automationIDs
+}
+
// A demo home must be safe to build, which means it writes inside the directory
// it was given and nowhere else — least of all into the state root of the person
// running it, which is the whole reason this program exists.
@@ -358,6 +460,36 @@ func TestTheDemoHomeSeedsNothingOutsideTheDirectoryItWasGiven(t *testing.T) {
}
}
+// NO AUTOMATION RUNS IN A DEMO HOME. Its automations are invented and the clock
+// would run them for real — unattended sessions on the person's own key — so
+// every way into the demo carries the variable that keeps a window from
+// starting one. The second half is the contract with internal/automation: if
+// that package stops reading this variable, the demo starts spending again, and
+// this is where that is caught rather than on somebody's bill.
+func TestTheDemoHomeNeverStartsTheAutomationsClock(t *testing.T) {
+ found := false
+ for _, entry := range demoEnviron() {
+ if entry == demoNoClock+"=1" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("the demo launch's environment does not carry %s=1", demoNoClock)
+ }
+ // The variable, and nothing else, is what decides: with it empty — in both
+ // of the spellings internal/env reads — the same codeaf may start a clock.
+ binary := filepath.Join(t.TempDir(), "codeaf")
+ t.Setenv(demoNoClock, "")
+ t.Setenv(env.Legacy(demoNoClock), "")
+ if !automation.Startable(binary) {
+ t.Fatalf("a codeaf with %s unset may not start a clock either, so this test proves nothing", demoNoClock)
+ }
+ t.Setenv(demoNoClock, "1")
+ if automation.Startable(binary) {
+ t.Fatalf("%s=1 no longer keeps a window from starting the clock, so opening the demo would run its invented work", demoNoClock)
+ }
+}
+
// The presence files are what draw `1 want you` and `1 moving`, and they are
// believed for three heartbeats and no longer. The beat is what keeps them true
// while somebody is looking at the demo; without it the two clauses vanish
@@ -455,6 +587,12 @@ func TestTheDemoHomeRefusesADirectoryThatIsNotItsOwn(t *testing.T) {
// this test chooses rather than instants it happens to run at. Nothing here
// reads a presence file: liveness is the other test's business and is the one
// thing that genuinely needs the wall clock.
+//
+// The automations' history is the other thing the hour moves. Which slots fell
+// before now — today's nine o'clock sweep, the evening reminder — depends on
+// when the home is built, and every run's outcome is assigned newest first, so
+// an hour that left a run out would shift every outcome along by one. The same
+// three instants hold it to the same bar as noon.
func TestTheDemoHomesLedgersStayInsideTheirOwnDays(t *testing.T) {
day := time.Now()
for _, moment := range []struct {
@@ -472,17 +610,12 @@ func TestTheDemoHomesLedgersStayInsideTheirOwnDays(t *testing.T) {
t.Fatalf("seed the demo home: %v", err)
}
- orders, err := standing.Open(filepath.Join(dir, ".codeaf", "v3", "standing"))
- if err != nil {
- t.Fatalf("open the standing store: %v", err)
- }
- spend, err := orders.Today("", moment.at)
- if err != nil {
- t.Fatalf("read today's standing ledger: %v", err)
- }
- if spend.USD <= 0 {
- t.Fatal("nothing standing has spent anything today, so cost per firing draws nothing")
- }
+ // THE AUTOMATIONS' FORTNIGHT IS MEASURED FROM THE SAME NOW, so it is
+ // held to the same instants: every run over before the home was built,
+ // every active automation waking after it, and the whole set of
+ // outcomes still there whichever runs the hour of the day left out.
+ assertAutomations(t, dir, moment.at, built,
+ session.ReadWorld(filepath.Join(dir, ".codeaf", "v3", "projects")).Sessions())
lines, err := session.ReadUsage(filepath.Join(dir, ".codeaf", "v3", session.UsageLedgerName), time.Time{})
if err != nil {
diff --git a/cmd/codeaf-demo-home/seed_work.go b/cmd/codeaf-demo-home/seed_work.go
index a31d8f37c9..53b16de9d7 100644
--- a/cmd/codeaf-demo-home/seed_work.go
+++ b/cmd/codeaf-demo-home/seed_work.go
@@ -66,8 +66,8 @@ var demoTasks = []demoTask{
project: firstProjectName, talk: "Standing Up the Watches", ago: 5 * time.Hour,
entry: session.TaskIndexEntry{
ID: "4", Title: "Write the morning sweep down", Status: string(session.TaskDone),
- Outcome: "The sweep is a standing order now, with a cap of a dollar a day.",
- Files: []string{"docs/STANDING-ORDERS.md"},
+ Outcome: "The sweep's brief is written down beside the automation that runs it, with its twenty-minute and one-dollar limits.",
+ Files: []string{"docs/MORNING-SWEEP.md"},
FilesChanged: 1, Cost: 0.14, Model: "anthropic/claude-sonnet-4", Tokens: 16_800,
DurationMS: 3 * 60 * 1000,
},
diff --git a/cmd/codeaf-replay/request.go b/cmd/codeaf-replay/request.go
index da5e15ffd6..a1e0890be9 100644
--- a/cmd/codeaf-replay/request.go
+++ b/cmd/codeaf-replay/request.go
@@ -279,12 +279,19 @@ var callSiteRoles = map[string]lane.Role{
// `consolidate`, already had a row below because internal/roles has that
// word: the tidy-up and the role are the same errand and the same reading.
//
- // internal/session/standing_run.go: one standing item's yes-or-no, on every
- // check of every item forever. It is spelled `standing-check` and NOT
- // `sentinel`, which is already the resident's own quorum errand above and is
- // judged: this one sets lane.RoleStanding on its own context, and pricing it
- // as a judge would put a wait nobody is having into the table.
+ // The retired standing check: one standing item's yes-or-no, made by
+ // internal/session's standing_run.go (now gone) on every check of every
+ // item. It was spelled `standing-check` and NOT `sentinel`, which is already
+ // the resident's own quorum errand above and is judged: it set
+ // lane.RoleStanding on its own context, and pricing it as a judge would put
+ // a wait nobody is having into the table.
"standing-check": lane.RoleStanding,
+ // internal/session/automation_run.go: one watch's yes-or-no on what it
+ // looked at, the errand that replaced the standing check above and is
+ // priced the same way for the same reason — nobody is waiting on it, and
+ // it sets lane.RoleStanding on its own context. The older tag stays so a
+ // log written before the change still reads.
+ "automation-check": lane.RoleStanding,
// internal/session/tools_doc.go: the model's own eyes on a document the
// `read` tool cannot open as text. A TOOL CALL INSIDE A TURN, so somebody IS
// waiting — the one of the three whose seconds are a person's.
diff --git a/cmd/codeaf/answer_test.go b/cmd/codeaf/answer_test.go
index 7a2cfbcbcd..43e35ac068 100644
--- a/cmd/codeaf/answer_test.go
+++ b/cmd/codeaf/answer_test.go
@@ -217,7 +217,6 @@ func newAnswerBrain(t *testing.T) *answerBrain {
t.Setenv("OPENROUTER_API_KEY", "test-key")
t.Setenv("CODEAF_PROFILE_DIR", brain.dir)
t.Setenv("CODEAF_DAILY_BUDGET", "0")
- t.Setenv("CODEAF_PRACTICE_BUDGET", "0")
t.Setenv("CODEAF_PLAN_CONSENT", "0")
return brain
}
diff --git a/cmd/codeaf/beltdoors_test.go b/cmd/codeaf/beltdoors_test.go
index 2f0c7af047..db2c4139db 100644
--- a/cmd/codeaf/beltdoors_test.go
+++ b/cmd/codeaf/beltdoors_test.go
@@ -33,14 +33,17 @@ const helpLineCap = 117
// deliberately does NOT name, so the check below does not demand a line for
// them:
//
-// - `engine` and `tick` are machinery a surface dials, not things a person
+// - `engine` and `clock` are machinery a window starts, not things a person
// runs by hand; main.go says so where it dispatches them and
// internal/manual's running-from-the-terminal page says so again.
+// - `tick` and `wake` do nothing: they are kept only for the
+// operating-system timers an older build installed, which run them until
+// this build removes them (main.go, wake.go).
// - `show` and `revise` are the old top-level spellings of `plan show` and
// `plan revise`; the page names the commands they became.
// - `help` is the alias for `--help` itself.
var doorsOffThePage = map[string]bool{
- "engine": true, "tick": true, "show": true, "revise": true, "help": true,
+ "engine": true, "tick": true, "clock": true, "wake": true, "show": true, "revise": true, "help": true,
}
// THE FIRST GESTURE ON AN UNFAMILIAR DOOR IS `-h`, and it must not be a
diff --git a/cmd/codeaf/chat.go b/cmd/codeaf/chat.go
index bb24b413f0..0f34f0a23c 100644
--- a/cmd/codeaf/chat.go
+++ b/cmd/codeaf/chat.go
@@ -400,16 +400,6 @@ func buildBrain(w *chatWindow, session string, opts brainOptions) (*chatBrain, e
func(words head.ModelWords) head.WorkModelChoice {
return resolveWorkModelWords(words, modelCatalog, taskClient.Model)
}, true)
- // Self-practice is curiosity spent on a notebook, and a one-shot has no
- // business buying any. An ephemeral store's notebook is deleted with it, so
- // the practice would be paid for and unreadable; and on a durable store
- // named with --db it is worse, because the charter and its work land in
- // somebody's own journal as the residue of an errand that was asked for one
- // thing. Either way it competes with the single job this process was
- // started to run. So every `codeaf do` schedules nothing, whatever its
- // store, and the resident that owns that store keeps practising on its own
- // time.
- reconciler = reconciler.WithPracticeLoop(0, 0)
if craftRunner != nil {
reconciler = reconciler.WithCraftRunner(craftRunner)
}
@@ -422,8 +412,8 @@ func buildBrain(w *chatWindow, session string, opts brainOptions) (*chatBrain, e
return choice.Model, choice.Model != ""
})
// The lease's flock proves the process exists; the heartbeat proves it is
- // doing the work. Without this stamp a wedged TUI holds the resident role
- // while every wake pass defers to it, and the standing watches go blind.
+ // doing the work. Without this stamp a wedged process holds the resident
+ // role while every other one defers to it, and nothing it owes is done.
reconciler = reconciler.WithHeartbeat(func(at time.Time) {
_ = lease.NoteResidentTick(path, at)
})
@@ -3991,9 +3981,9 @@ type jobPlans struct {
// worth of them; losing a reading costs one journal field and never the job.
readings map[string]string
// journal persists a job's structure, and hydrate reads it back. Both are
- // nil on surfaces with no store to write to — `codeaf wake` builds a
- // registry for one bounded pass and never outlives it — and a nil pair
- // leaves the registry exactly the memory-only map it used to be.
+ // nil on surfaces with no store to write to — a registry built for one
+ // bounded pass that never outlives it — and a nil pair leaves the registry
+ // exactly the memory-only map it used to be.
journal func(prefix string, entry plannedJob)
hydrate func(prefix string) (plannedJob, bool)
}
@@ -4490,13 +4480,13 @@ func isJobNode(id, prefix string) bool {
}
// The resident is the half of the surface that keeps working while nobody is
-// typing: it announces settled work, distills the notebook, fires charters, and
-// resumes deferred overruns. Its loop used to be launched as `_ = Serve(ctx)` —
-// the error thrown away, the goroutine gone, and no one told. A single
-// transient failure inside one pass therefore ended the resident silently for
-// the lifetime of the terminal, while the lease went on saying the role was
-// taken, so `codeaf wake` stepped aside for a process that had stopped serving
-// hours ago. Standing watches, charters and practice simply never fired again.
+// typing: it announces settled work, distills the notebook, and resumes
+// deferred overruns. Its loop used to be launched as `_ = Serve(ctx)` — the
+// error thrown away, the goroutine gone, and no one told. A single transient
+// failure inside one pass therefore ended the resident silently for the
+// lifetime of the terminal, while the lease went on saying the role was taken,
+// so every other process stepped aside for one that had stopped serving hours
+// ago, and nothing the resident owed was ever done again.
//
// So the loop gets a supervisor. Restarting is the right default because the
// store is the truth and Serve holds nothing across a pass — a fresh call
@@ -4543,7 +4533,7 @@ func superviseResident(ctx context.Context, serve func(context.Context) error,
log.Printf("resident loop abandoned after %d restarts; background work has stopped", consecutive)
if announce != nil {
announce("my background half has stopped and I could not restart it — " +
- "standing rules, watching and follow-up work are paused until codeaf is restarted. " +
+ "follow-up work is paused until codeaf is restarted. " +
"The reason is in the log: " + firstLine(fmt.Sprint(err)))
}
return
@@ -6142,57 +6132,6 @@ func digestTerritory(settings config.Config, client *liveClient) resident.Territ
}
}
-const sentinelSystemPrompt = `You are a cheap standing-watch sentinel. Decide only whether the supplied condition occurred or the invariant is threatened now. Answer exactly "yes — " or "no — ". No markdown, no qualifications, no suggested work.`
-
-// checkSentinel reuses the resident talk client just like consolidation. The
-// store, not this parser, decides whether a yes may spend or fire.
-func checkSentinel(settings config.Config, client *liveClient) resident.SentinelFunc {
- return func(ctx context.Context, prompt resident.SentinelPrompt) (resident.SentinelVerdict, error) {
- input := fmt.Sprintf("Invariant (verbatim):\n%s\n\nSentinel hint:\n%s\n\nWake evidence:\n%s",
- prompt.Invariant, prompt.SentinelHint, prompt.Evidence)
- // The charter's own history, last: it is the only part of this prompt
- // that moves between wakes, and for a poll charter it is the only part
- // that moves at all. Without it the same judgment was made against the
- // same bytes an hour later, however the last one turned out.
- if len(prompt.Previous) > 0 {
- input += "\n\nYour last judgments on this same charter, newest first, and how each turned out:\n"
- for _, line := range prompt.Previous {
- input += "- " + line + "\n"
- }
- }
- system := sentinelSystemPrompt + prompt.Voice
- // NO CEILING TRAVELS. This call used to carry one — sixty tokens, then a
- // thousand and twenty-four once a model that reasons first spent the
- // whole sixty thinking and left "sentinel returned no clear yes" on every
- // wake forever. Both figures were the same mistake in different sizes:
- // guessing how much room somebody else's model needs to say yes. The
- // prompt asks for a verdict and a line of reason, and the model's own
- // default is what bounds it.
- response, err := client.CompleteWithMessages(errandContext(ctx, settings, "sentinel", lane.RoleJudge), []ai.Message{
- {Role: "system", Content: []ai.ContentPart{{Type: "text", Text: system}}},
- {Role: "user", Content: []ai.ContentPart{{Type: "text", Text: input}}},
- })
- if err != nil {
- return resident.SentinelVerdict{}, err
- }
- if response == nil {
- return resident.SentinelVerdict{}, fmt.Errorf("sentinel returned no response")
- }
- answer := strings.TrimSpace(response.Text())
- lower := strings.ToLower(answer)
- yes := strings.HasPrefix(lower, "yes")
- if !yes && !strings.HasPrefix(lower, "no") {
- return resident.SentinelVerdict{Line: "sentinel returned no clear yes"}, nil
- }
- line := answer
- if fields := strings.Fields(answer); len(fields) > 1 {
- line = strings.TrimSpace(strings.Join(fields[1:], " "))
- }
- line = strings.TrimSpace(strings.TrimLeft(line, "—:- "))
- return resident.SentinelVerdict{Yes: yes, Line: line}, nil
- }
-}
-
// distillFacts wires the reconciler's notebook to the talk model.
// titleGoalPrompt earns its own call by what it is not: not a summary, not a
// restatement, a NAME. The rail has ~30 characters per node; a name that
diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go
index 424f955ce5..9085169514 100644
--- a/cmd/codeaf/chatv3.go
+++ b/cmd/codeaf/chatv3.go
@@ -357,15 +357,16 @@ func openChatV3(name string, args []string, pickSession bool) error {
// neither of them is a gate that quietly opens because the terminal
// happens to be a pipe.
cfg.AskConsent = false
- // AND NOTHING MAY BE SET UP HERE. A standing item spends money at times
+ // AND NOTHING MAY BE SET UP HERE. An automation spends money at times
// nobody chose, so it needs a yes; a headless run has nobody to give
// one, and a clock that armed it anyway would be the harness agreeing on
// somebody's behalf. The tool is simply absent (internal/session's
// tools.go), which is the same law --once already applies to consent.
- cfg.Standing = nil
+ cfg.Automations = nil
// AND THE SUBHARNESS SEAMS ARE LEFT ALONE, which is not an oversight
- // beside the line above it. Standing has to be taken away here because
- // nothing else asks whether anybody is watching before it arms a clock. A
+ // beside the line above it. Automations have to be taken away here
+ // because nothing else asks whether anybody is watching before it arms a
+ // clock. A
// subharness cannot start without somebody confirming an intake card, and
// internal/session already reads that in one place — its own gate is
// `AskConsent && there is something to offer` (tools_subharness.go), and
@@ -483,6 +484,13 @@ func openChatV3(name string, args []string, pickSession bool) error {
settled.SessionFile, settled.Resumed = transcript, resumed
seam := &v3Seam{proc: proc, boot: &settled, seed: seed}
+ // THIS IS A WINDOW ON THIS MACHINE, and its automations run only while one
+ // is open: it holds its presence for exactly as long as the surface runs,
+ // and keeps a clock going meanwhile (chatv3_clock.go). The two doors to
+ // another machine hold presence THERE instead, through the engine they
+ // attach to, because the automations such a window shows are that
+ // machine's.
+ defer keepAutomationsWindow("window")()
// The byte meter and the logger redirect both belong to the surface rather
// than to this door, and [runSurface] (chatv3_surface.go) is where every
// door gets them.
@@ -556,10 +564,10 @@ func openChatV3(name string, args []string, pickSession bool) error {
AnchorWorkspace: func(path string) (string, error) {
return seam.anchor(agent, path)
},
- // The ambient side as this surface reads it: home's item band, the
- // pause and stop keys, and /status's keeping-watch line, all off the
- // same store the conversation proposes into (chatv3_standing.go).
- Standing: v3StandingSeam(cfg.Standing),
+ // Automations, read and changed off the same store the conversation
+ // proposes into (chatv3_clock.go).
+ Automations: v3AutomationsSeam(),
+ Notify: v3NotifyRoute(),
Fresh: func() (tui3.Agent, string, error) {
conv, err := seam.start("")
if err != nil {
@@ -567,12 +575,11 @@ func openChatV3(name string, args []string, pickSession bool) error {
}
return conv.Agent, conv.SessionFile, nil
},
- // ── lane errand, for the merge: this pair and nothing else ──────────
// Home's `ask here` — the errand answered in the right pane, whose
- // transcript lives under the standing root and never under v3/projects
+ // transcript lives under the errands root and never under v3/projects
// (chatv3_exchange.go, tui3's homeexchange.go).
- Errand: v3Errand(cfg, workspace, settings.ProfileDir, *yolo),
- StandingRoot: v3StandingRoot(),
+ Errand: v3Errand(cfg, workspace, settings.ProfileDir, *yolo),
+ ErrandsRoot: v3ErrandsRoot(),
// Answering another window's question from home: the answer is left in
// that session's own folder and it picks it up on its heartbeat
// (internal/session's answers.go, tui3's homeband_answer.go).
@@ -757,21 +764,6 @@ type v3Options struct {
// posture every session has always had, where the model stopping is the
// session stopping.
Budget session.Budget
- // NoStandingTicks says THIS PROCESS IS NOT THE ONE THAT KEEPS TIME for the
- // machine's standing items, however complete the config it is about to build.
- //
- // It exists for exactly one caller, and the zero value is the posture every
- // other door has always had. On the engine road the screen and the sessions
- // live in two processes: the ENGINE holds this project's conversations, and a
- // firing is delivered into an open conversation of the same project through
- // the live registry of the process that ran the pass (internal/standing, and
- // internal/manual/chat/keeping-an-eye.md's three roads). So a client that
- // ticked would win the store's lock every so often and fire an item into a
- // registry holding nothing but its own errand — filing the words in the
- // project's inbox for the next launch while the person sat in front of the
- // conversation they were meant to land in. One process keeps time, and on
- // that road it is the one holding the conversations.
- NoStandingTicks bool
}
// joinV3Notices puts the launch's dim lines on one row, in the order they were
@@ -896,6 +888,77 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) {
}
}
+ // The model pool's judge is the launch's own: it reads every landing of THIS
+ // conversation, so it is built here and handed to the shared assembly.
+ // The Model Pool's judge reads every landing through the engine's one hook.
+ // It is built here, where the seats above are wired, so the answer to
+ // "may this process read the pool at all" is one posture for the whole
+ // door: a mode that forbids reading builds no hook, and a nil hook is the
+ // engine's own nothing. The ask is built once and a client is made from it
+ // per call, each billed to the judge's own seat.
+ var taskLanded func(session.TaskLanding)
+ // AN INDEPENDENT JUDGE CANNOT SHARE THE CREW MODEL. A one-model
+ // launch therefore leaves this optional scoring to an ordinary launch;
+ // it must neither judge new landings nor sweep earlier pending work.
+ if !opts.OneModel {
+ taskLanded = poolJudgeHook(settings, settings.ProfileDir, workspace,
+ config.CrewCatalog, poolJudgeAsk(proc.liveSettings(settings), settings.ProfileDir), time.Now, "task")
+ // The runs a live process would have judged but a process death left unjudged,
+ // and the headless doors that never had this hook: at start, on a goroutine
+ // nobody waits on, judge the resumed session's own final-state nodes and the
+ // pending file's rows, each exactly once, bounded so it never holds the prompt. The
+ // process tracker cancels and joins it at close.
+ poolErrandGoCtx(settings.ProfileDir, "pool/judge-sweep", func(ctx context.Context) {
+ poolJudgeSweepRun(ctx, settings, settings.ProfileDir, found.Place.Tasks(),
+ config.CrewCatalog, poolJudgeAsk(proc.liveSettings(settings), settings.ProfileDir), time.Now)
+ })
+ }
+
+ cfg, activeModels, subharnesses, err := v3ConfigFor(proc, opts, workspace, found.Place, transcript, taskLanded)
+ if err != nil {
+ return nil, err
+ }
+ harnesses := proc.Harnesses
+
+ // THE SHELF IS IMPORTED BEFORE THE FIRST MESSAGE. A person's skills for
+ // other harnesses — Claude Code, Codex, any agentskills.io reader — reach
+ // the shelf when the graph opens, not when some later tick finds the time:
+ // this door claims no residency (runChatV3's header), so the pass the
+ // resident reconciler runs on its own clock is run here, synchronously,
+ // after [v3Memory]'s graph is open and before the first prompt is built.
+ // The pass is idempotent — an unchanged disk journals nothing — so an open
+ // costs one scan and no writes, and a skill edited since the last open is
+ // re-read before the model ever sees the shelf.
+ importForeignSkillsBeforeFirstMessage(proc.skillShelf(), workspace)
+
+ return &v3Launch{
+ Settings: settings,
+ Models: activeModels,
+ Harnesses: harnesses,
+ Config: cfg,
+ Model: chosen,
+ Workspace: workspace,
+ Project: project,
+ SessionFile: transcript,
+ Resumed: resumed,
+ Place: found.Place,
+ Bucket: found.Bucket,
+ Subharnesses: subharnesses,
+ }, nil
+}
+
+// v3ConfigFor assembles the session configuration for work in workspace: the
+// person's models, keys, governance, connected accounts, memory, media and
+// harness seams, from what the process already resolved at the door.
+//
+// IT IS THE ONE ASSEMBLY, shared by a conversation's launch and an automation's
+// run (chatv3_clock.go). It finds no conversation and starts nothing — which is
+// why the launch keeps those parts and an automation can call this: the launch
+// resumes the person's newest conversation in a project, and an automation
+// that did that would be writing into somebody's chat.
+func v3ConfigFor(proc *v3Process, opts v3Options, workspace string, place session.Place, transcript string, taskLanded func(session.TaskLanding)) (session.Config, *catalog.Catalog, v3Subharness, error) {
+ settings := proc.Settings
+ chosen := v3TalkModel(opts.Model, settings)
// The model catalog and the sub-harness registry, borrowed from the process
// rather than opened here (chatv3_process.go states why each must be one).
// The catalog's warm is already running and is waited for NOWHERE on this
@@ -932,30 +995,6 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) {
media = v3ImageGen(mediaSettings)
}
- // The Model Pool's judge reads every landing through the engine's one hook.
- // It is built here, where the seats above are wired, so the answer to
- // "may this process read the pool at all" is one posture for the whole
- // door: a mode that forbids reading builds no hook, and a nil hook is the
- // engine's own nothing. The ask is built once and a client is made from it
- // per call, each billed to the judge's own seat.
- var taskLanded func(session.TaskLanding)
- // AN INDEPENDENT JUDGE CANNOT SHARE THE CREW MODEL. A one-model
- // launch therefore leaves this optional scoring to an ordinary launch;
- // it must neither judge new landings nor sweep earlier pending work.
- if !opts.OneModel {
- taskLanded = poolJudgeHook(settings, settings.ProfileDir, workspace,
- config.CrewCatalog, poolJudgeAsk(proc.liveSettings(settings), settings.ProfileDir), time.Now, "task")
- // The runs a live process would have judged but a process death left unjudged,
- // and the headless doors that never had this hook: at start, on a goroutine
- // nobody waits on, judge the resumed session's own final-state nodes and the
- // pending file's rows, each exactly once, bounded so it never holds the prompt. The
- // process tracker cancels and joins it at close.
- poolErrandGoCtx(settings.ProfileDir, "pool/judge-sweep", func(ctx context.Context) {
- poolJudgeSweepRun(ctx, settings, settings.ProfileDir, found.Place.Tasks(),
- config.CrewCatalog, poolJudgeAsk(proc.liveSettings(settings), settings.ProfileDir), time.Now)
- })
- }
-
cfg := session.Config{
Workspace: workspace,
Model: chosen,
@@ -970,14 +1009,14 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) {
// The folder this conversation keeps everything in (Decision 26). It is
// the zero Place for a launch opened on a flat legacy transcript, which
// is what keeps that session deriving its sidecars the way it always did.
- Place: found.Place,
+ Place: place,
// Where an adaptive run's write-capable nodes get their isolated
// worktrees (internal/orchestrate): the session folder's trees/ —
// Decision 26's own law, one per running node, swept with the session.
// The zero Place answers "" and the run degrades to sharing the
// workspace, which is the seam's honest answer for a legacy flat
// session.
- WorktreeRoot: found.Place.Trees(),
+ WorktreeRoot: place.Trees(),
// The durable memory, on the store this time (internal/session's
// memory.go). It is opened once, here, because where a person's state
// lives is the door's decision — and it is opened AT ALL only when the
@@ -1114,12 +1153,11 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) {
// generation verb off the belt. The RESOLVER (MediaModel) is wired
// after governance lands, because its pin rung reads RolesSource.
Media: media,
- // THE AMBIENT SIDE (chatv3_standing.go). It is filled for every door
- // that is a CONVERSATION — chat, resume, engine — and taken away again
- // on the --once path below, because nothing unwatched may set up
- // something that spends forever. A task node and a firing's own session
- // never see it: neither copies this config.
- Standing: v3Standing(settings.ProfileDir),
+ // The automations store, opened once per process (chatv3_clock.go). It
+ // is filled for every door that is a CONVERSATION — chat, resume,
+ // engine — and taken away again on the --once path below, because
+ // nothing unwatched may set up something that runs on a clock.
+ Automations: v3Automations(),
// THE DIVISION ROAD, on by default (internal/config's DefaultSwarm). A
// task that turns out to hold more than one worker's share may split
// itself into parts and stay to fold them back together
@@ -1134,9 +1172,9 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) {
// What this session may do without asking, which model answers its
// auxiliary calls, and what it may spend. All three are settings rows, and a
// row that cannot be read stops the launch here rather than downstream.
- cfg, err = applyV3Governance(cfg, settings.ProfileDir, opts.Yolo, opts.OneModel)
+ cfg, err := applyV3Governance(cfg, settings.ProfileDir, opts.Yolo, opts.OneModel)
if err != nil {
- return nil, err
+ return session.Config{}, nil, v3Subharness{}, err
}
// AND WHO THIS SESSION IS WORKING FOR (internal/session's
// principal.go): the unattended flag and its ceiling, plus the door's own
@@ -1150,7 +1188,7 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) {
cfg.ApprovalGate = gate
cfg.ApprovalPolicy, cfg.Guardian, err = gate.Build(cfg.ApprovalPosture)
if err != nil {
- return nil, err
+ return session.Config{}, nil, v3Subharness{}, err
}
}
cfg.Budget = opts.Budget
@@ -1195,41 +1233,7 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) {
// (chatv3_subharness.go's UsePages). It is wired here because the runner it
// needs is the line above.
subharnesses.UsePages(harnesses, cfg.RunHarness)
-
- // THE SHELF IS IMPORTED BEFORE THE FIRST MESSAGE. A person's skills for
- // other harnesses — Claude Code, Codex, any agentskills.io reader — reach
- // the shelf when the graph opens, not when some later tick finds the time:
- // this door claims no residency (runChatV3's header), so the pass the
- // resident reconciler runs on its own clock is run here, synchronously,
- // after [v3Memory]'s graph is open and before the first prompt is built.
- // The pass is idempotent — an unchanged disk journals nothing — so an open
- // costs one scan and no writes, and a skill edited since the last open is
- // re-read before the model ever sees the shelf.
- importForeignSkillsBeforeFirstMessage(proc.skillShelf(), workspace)
-
- // AND THIS PROCESS STARTS KEEPING TIME. Any open window takes the store's
- // lock and runs the pass; the OS timer is the backup for "no terminal open"
- // (chatv3_standing.go). It is here, beside [startPlaceSweep], because every
- // v3 door assembles through this function — and the first pass is a whole
- // interval away, so a launch that exits immediately has ticked nothing.
- if cfg.Standing != nil && !opts.NoStandingTicks {
- proc.startStandingTicks(cfg.Standing.Store)
- }
-
- return &v3Launch{
- Settings: settings,
- Models: activeModels,
- Harnesses: harnesses,
- Config: cfg,
- Model: chosen,
- Workspace: workspace,
- Project: project,
- SessionFile: transcript,
- Resumed: resumed,
- Place: found.Place,
- Bucket: found.Bucket,
- Subharnesses: subharnesses,
- }, nil
+ return cfg, activeModels, subharnesses, nil
}
// importForeignSkillsBeforeFirstMessage runs the foreign-skill import pass
@@ -1741,7 +1745,7 @@ func applyV3Governance(cfg session.Config, profileDir string, yolo, oneModel boo
// AND WHETHER THE SIGNATURE ON THE GIT WORK codeaf DOES NAMES THE MODEL,
// read here for the reason the audit row above it is read here: every v3
// door comes through this function, and a row honoured in the conversation
- // but not in a standing firing is a row the person cannot trust. The
+ // but not in an automation's run is a row the person cannot trust. The
// signature itself has no row: codeaf always signs. PROFILE-ONLY — a
// repository that could change what a visitor's commits say about them by
// being cloned would be writing into somebody else's provenance. The
@@ -1807,10 +1811,9 @@ func applyV3Governance(cfg session.Config, profileDir string, yolo, oneModel boo
// (internal/provider's lanepin.go says why that is not a Config field).
//
// IT IS THE RESOLVER'S ENTRANCE AND NOT A PERSON'S. This function reads the
- // row and hands the answer down, and it runs again on every standing tick
- // for as long as the window lives ([v3StandingTicker], five minutes apart) —
- // so it must not be able to forget what the wire said about the row while
- // nobody has touched it. A person's own act goes to [provider.RepinLane]
+ // row and hands the answer down, and it runs again for every automation run
+ // the clock assembles a config for — so it must not be able to forget what
+ // the wire said about the row while nobody has touched it. A person's own act goes to [provider.RepinLane]
// (internal/tui3's laneRowChanged), which forgets unconditionally.
// The speed guard travels with the pin because the adjacent rows are one
// routing posture and two readers of that posture would drift.
@@ -2216,6 +2219,13 @@ func (s v3Brain) UpdateMemory(id, title, text string, tags []string) error {
func (s v3Brain) ForgetMemory(id string) error { return s.brain.ForgetMemory(id) }
func (s v3Brain) RestoreMemory(id string) error { return s.brain.RestoreMemory(id) }
+// SetMemoryAlways is the place's `a` — by the raw id the page is showing, which
+// is the person's own surface managing a row it drew (internal/store's raw-door
+// contract on [store.Store.UpdateMemory]).
+func (s v3Brain) SetMemoryAlways(id string, always bool) error {
+ return s.brain.SetMemoryAlways(id, always)
+}
+
func (s v3Brain) MemoryProvenance(id string) (string, string, time.Time, error) {
return s.brain.MemoryProvenance(id)
}
diff --git a/cmd/codeaf/chatv3_at.go b/cmd/codeaf/chatv3_at.go
index 3df5fe2c13..73f8a76af5 100644
--- a/cmd/codeaf/chatv3_at.go
+++ b/cmd/codeaf/chatv3_at.go
@@ -173,6 +173,11 @@ func openChatV3At(launch atLaunch) error {
Session: launch.session,
Model: launch.model,
Level: launch.level,
+ // A WINDOW IS A WINDOW OPEN ON THE FAR MACHINE, said on this boot
+ // connection alone, exactly as the ssh door says it
+ // (chatv3_host.go's [openChatV3Host]): the far machine's automations
+ // clock runs while it is attached. A headless `--once` is not a window.
+ Window: launch.once == "",
})
if err != nil {
return err
@@ -203,6 +208,9 @@ func openChatV3At(launch atLaunch) error {
if err != nil {
return nil, err
}
+ // NO [remote.Hello.Window] HERE, for [besideHello]'s reason: this is the
+ // same window on another tunnel, and the boot connection already holds
+ // the far machine's presence for it.
beside, err := remote.Dial(next, name, remote.Hello{
Workspace: ask.workspace,
Session: ask.session,
diff --git a/cmd/codeaf/chatv3_beside.go b/cmd/codeaf/chatv3_beside.go
index ba50557fd7..10ea01135e 100644
--- a/cmd/codeaf/chatv3_beside.go
+++ b/cmd/codeaf/chatv3_beside.go
@@ -50,7 +50,7 @@ package main
// ── AND THE BOOT CONNECTION IS THE WINDOW'S DOOR ONTO THE MACHINE ───────────
//
// The places, the ledger, the memory store, the search index, the recent list
-// and the standing items are facts about the ENGINE'S MACHINE rather than about
+// and the automations are facts about the ENGINE'S MACHINE rather than about
// any conversation, but the protocol dispatches every call against a session. So
// they ride the boot connection, and THE BOOT CONNECTION IS NEVER RETIRED BY A
// CONVERSATION ENDING: closing the conversation on it ends that session and
diff --git a/cmd/codeaf/chatv3_clock.go b/cmd/codeaf/chatv3_clock.go
new file mode 100644
index 0000000000..ef500bd7f7
--- /dev/null
+++ b/cmd/codeaf/chatv3_clock.go
@@ -0,0 +1,449 @@
+package main
+
+// chatv3_clock.go is the process side of automations (internal/automation;
+// docs/design/automations/DESIGN.md): `codeaf clock`, the one process that runs
+// them while a codeaf window is open, and the window's half — holding presence
+// for as long as it is open and starting a clock when none is running.
+//
+// AND THE TWO SEAMS A WINDOW READS AUTOMATIONS THROUGH, side by side: this
+// machine's store read directly ([v3AutomationsSeam]), and another machine's
+// read over a connection ([hostAutomationsSeam]) — whose clock is kept running
+// by the engine there, holding presence for the attached window
+// ([engineAutomations] in engine.go).
+//
+// `codeaf clock` IS MACHINERY, NOT A COMMAND, and like `codeaf engine` it is
+// absent from the usage text: a window starts it, it draws nothing, and it
+// leaves on its own when the last window has been closed for half a minute.
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "log"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "runtime"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/automation"
+ "github.com/Agent-Field/codeaf/internal/catalog"
+ "github.com/Agent-Field/codeaf/internal/config"
+ "github.com/Agent-Field/codeaf/internal/enginehost"
+ "github.com/Agent-Field/codeaf/internal/env"
+ "github.com/Agent-Field/codeaf/internal/guard"
+ "github.com/Agent-Field/codeaf/internal/home"
+ "github.com/Agent-Field/codeaf/internal/remote"
+ "github.com/Agent-Field/codeaf/internal/session"
+ "github.com/Agent-Field/codeaf/internal/store"
+ "github.com/Agent-Field/codeaf/internal/subharness"
+ "github.com/Agent-Field/codeaf/internal/tui3"
+)
+
+// automationsRoot is the folder the store, the presence files and the clock's
+// lock live in. It follows CODEAF_HOME, so a demo home or a test home has a
+// clock of its own.
+func automationsRoot() string { return home.Join("v3", "automations") }
+
+var (
+ automationsOnce sync.Once
+ automationsStore *automation.Store
+)
+
+// v3Automations is the door a conversation proposes through, or nil.
+//
+// THE STORE IS OPENED ONCE PER PROCESS: every conversation a session host holds
+// shares one handle on the database rather than opening its own. A store that
+// cannot be opened is automations off for this process — a capability that
+// cannot work is absent, not broken.
+func v3Automations() *session.Automations {
+ automationsOnce.Do(func() {
+ opened, err := automation.Open(automationsRoot())
+ if err != nil {
+ log.Printf("automations are off in this process: %v", err)
+ return
+ }
+ automationsStore = opened
+ })
+ if automationsStore == nil {
+ return nil
+ }
+ return &session.Automations{Store: automationsStore, Zone: session.LocalZone()}
+}
+
+// v3AutomationsSeam is [tui3.Options.Automations] for a window on the machine
+// its automations run on: the store itself, read and written directly. A store
+// that cannot be opened is the zero seam, which the surface reads as
+// automations absent.
+func v3AutomationsSeam() tui3.AutomationsSeam {
+ autos := v3Automations()
+ if autos == nil || autos.Store == nil {
+ return tui3.AutomationsSeam{}
+ }
+ store := autos.Store
+ return tui3.AutomationsSeam{
+ List: store.List,
+ Runs: store.Runs,
+ Changes: store.Changes,
+ Cursor: store.Cursor,
+ Active: store.Active,
+ Create: store.Create,
+ Update: store.Update,
+ SetStatus: store.SetStatus,
+ Delete: store.Delete,
+ RunNow: func(id string) error {
+ _, err := store.QueueNow(id)
+ return err
+ },
+ StopRun: store.RequestStop,
+ Claim: func(id int64) bool {
+ first, err := store.Claim(id)
+ return err == nil && first
+ },
+ Zone: autos.Zone,
+ }
+}
+
+// v3NotifyRoute is [tui3.Options.Notify]: how a banner reaches the screen from
+// the terminal this process was started in, with this machine's codeaf home as
+// the place its notifier keeps the logo and the mark that the line about
+// notification settings has been said (internal/tui3's desktopnotify.go).
+func v3NotifyRoute() tui3.NotifyRoute {
+ return tui3.DetectNotifyRoute(env.Value, runtime.GOOS, home.Join("v3", "notify"))
+}
+
+// hostAutomationsSeam is [tui3.Options.Automations] for a window on ANOTHER
+// machine — over --host or --at — and it is [v3AutomationsSeam] function for
+// function, every one of them a call to the engine instead of a read of this
+// disk (internal/remote's wire_automations.go). The conversation over there
+// proposes into that machine's store and that machine's clock runs what it
+// keeps, so the far store is the one this window reads, and the zone a typed
+// rhythm is read in is the far machine's, as its welcome said.
+//
+// AN ENGINE THAT DOES NOT SAY IT HAS THE DOORS GETS THE ZERO SEAM, which the
+// surface reads as automations absent over this connection — no list, no run
+// lines, no notifications. It never gets this laptop's store instead: those
+// automations run on a different machine, beside conversations this window
+// cannot see.
+func hostAutomationsSeam(client *remote.Client, welcome remote.Welcome) tui3.AutomationsSeam {
+ if client == nil || !welcome.Automations {
+ return tui3.AutomationsSeam{}
+ }
+ return tui3.AutomationsSeam{
+ List: client.AutomationsList,
+ Runs: client.AutomationsRuns,
+ Changes: client.AutomationsChanges,
+ Cursor: client.AutomationsCursor,
+ Active: client.AutomationsActive,
+ Create: client.AutomationsCreate,
+ Update: client.AutomationsUpdate,
+ SetStatus: client.AutomationsSetStatus,
+ Delete: client.AutomationsDelete,
+ RunNow: client.AutomationsRunNow,
+ StopRun: client.AutomationsStopRun,
+ // A CLAIM THAT DID NOT ANSWER IS NOT THIS WINDOW'S, the same reading the
+ // local seam takes of a store that failed: one missed banner is better
+ // than the same banner raised by every window that could not be sure.
+ Claim: func(id int64) bool {
+ first, err := client.AutomationsClaim(id)
+ return err == nil && first
+ },
+ Zone: welcome.AutomationsZone,
+ }
+}
+
+// keepAutomationsWindow registers this process as an open window and keeps a
+// clock running while it is. The release is the window closing.
+//
+// IT IS ALSO WHAT AN ENGINE HOLDS FOR A WINDOW ATTACHED FROM ANOTHER MACHINE
+// ([engineAutomations]), once per connection whose hello says it is a window,
+// released when that connection ends. One door for both kinds of window is what
+// keeps them one kind of thing to the clock that counts them.
+//
+// AND IT SAYS WHAT ENVIRONMENT IT WAS STARTED IN ([clockEnvironment]), so the
+// clock runs with the newest window's keys: a window starts a clock only when
+// it is the newest window or shares that window's environment, and a running
+// clock started elsewhere leaves for it once nothing is in hand
+// (internal/automation's [automation.Clock.Env]). A key exported in one shell
+// and not another used to decide, by which window happened to open first,
+// whether every watch on the machine could be checked at all.
+func keepAutomationsWindow(label string) func() {
+ root := automationsRoot()
+ window := automation.Window{Label: label, Env: clockEnvironment(root)}
+ binary, err := os.Executable()
+ if err != nil || !automation.Startable(binary) {
+ // A process that cannot start a clock still counts as an open window:
+ // another window's clock runs while it is here.
+ release, err := automation.KeepAs(root, window, nil)
+ if err != nil {
+ return func() {}
+ }
+ return release
+ }
+ presence := automation.NewPresence(root)
+ release, err := automation.KeepAs(root, window, func() error {
+ if newest, ok := presence.Newest(); ok && window.Env != "" && newest.Env != window.Env {
+ return nil
+ }
+ return automation.StartClock(root, binary, "clock")
+ })
+ if err != nil {
+ return func() {}
+ }
+ return release
+}
+
+// clockEnvironment is this process's environment as the clock compares it: the
+// provider keys and codeaf's own variables, under a salt kept beside the store,
+// with no value in it (internal/enginehost's [enginehost.EnvironmentMAC]). "" is
+// a salt that could not be kept, which says nothing and is never followed.
+func clockEnvironment(root string) string {
+ salt := clockSalt(root)
+ if len(salt) == 0 {
+ return ""
+ }
+ workspace, err := os.UserHomeDir()
+ if err != nil || workspace == "" {
+ workspace = os.TempDir()
+ }
+ return hex.EncodeToString(enginehost.EnvironmentMAC(salt, workspace))
+}
+
+// clockSaltName is the salt's file under the automations root.
+const clockSaltName = "environment.salt"
+
+// clockSalt is the salt every process on this home fingerprints with, made by
+// the first one to ask. TWO PROCESSES ASKING AT ONCE AGREE: the salt is written
+// to a file of its own and linked into place, and whoever loses the race reads
+// the winner's.
+func clockSalt(root string) []byte {
+ path := filepath.Join(root, clockSaltName)
+ if salt, err := os.ReadFile(path); err == nil && len(salt) == sha256.Size {
+ return salt
+ }
+ if err := os.MkdirAll(root, 0o700); err != nil {
+ return nil
+ }
+ salt := make([]byte, sha256.Size)
+ if _, err := rand.Read(salt); err != nil {
+ return nil
+ }
+ temp, err := os.CreateTemp(root, ".salt-")
+ if err != nil {
+ return nil
+ }
+ defer os.Remove(temp.Name())
+ _, werr := temp.Write(salt)
+ cerr := temp.Close()
+ if werr != nil || cerr != nil {
+ return nil
+ }
+ if err := os.Link(temp.Name(), path); err != nil && !os.IsExist(err) {
+ return nil
+ }
+ if kept, err := os.ReadFile(path); err == nil && len(kept) == sha256.Size {
+ return kept
+ }
+ return nil
+}
+
+// runClock is `codeaf clock`.
+func runClock(args []string) error {
+ if len(args) > 0 {
+ return fmt.Errorf("codeaf clock takes no arguments")
+ }
+ root := automationsRoot()
+ if automation.Held(root) {
+ return nil
+ }
+ store, err := automation.Open(root)
+ if err != nil {
+ return err
+ }
+ defer store.Close()
+ proc, stop, err := openV3ClockProcess()
+ if err != nil {
+ return err
+ }
+ defer stop()
+ logf := clockLogger(root)
+ // ONE ASSEMBLY AT A TIME, because each one re-reads the keys first and two
+ // runs start side by side.
+ var assemble sync.Mutex
+ runner := session.NewAutomationRunner(func(workspace string) (session.Config, error) {
+ assemble.Lock()
+ defer assemble.Unlock()
+ // THE KEYS AS THEY ARE NOW, not as they were when the clock started. A
+ // clock lives for as long as any window is open, and a key pasted into
+ // a window, or a service connected from one, since then is on disk and
+ // nowhere else this process would look.
+ clockRereadKeys(proc)
+ // THE PERSON'S OWN ASSEMBLY, read the way their conversations read it:
+ // Interactive is what picks their own approval rules rather than the
+ // headless default. The runner then takes the person away — nobody is
+ // there to ask — and nothing it does may arm another automation.
+ cfg, _, _, err := v3ConfigFor(proc, v3Options{Interactive: true}, workspace, session.Place{}, "", nil)
+ return cfg, err
+ }, store.Root())
+ clock := &automation.Clock{Store: store, Presence: automation.NewPresence(root), Runner: runner, Log: logf, Env: clockEnvironment(root)}
+
+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer cancel()
+ ctx, retire := retireWhenReplaced(ctx, store, logf)
+ defer retire()
+ err = clock.Run(ctx)
+ if errors.Is(err, automation.ErrHeld) {
+ return nil
+ }
+ return err
+}
+
+// clockRereadKeys puts the profile's key and connected services, as they are on
+// disk now, under the clock's process. A key the environment carries is still
+// read first, exactly as [config.Load] orders them.
+func clockRereadKeys(proc *v3Process) {
+ key := config.APIKeyAt(proc.ProfileDir)
+ if key == "" {
+ key = proc.Settings.APIKey
+ }
+ proc.Settings.APIKey = key
+ proc.Settings.Sources = config.ResolveSources(proc.ProfileDir, key, proc.Settings.BaseURL)
+}
+
+// v3MemoryPath is the brain's file when the memory row is on, and the empty
+// string when it is off. `codeaf do` reads the rules in force through it
+// (do_always.go).
+//
+// IT READS THE SAME ROW [v3Memory] READS and answers a path rather than a
+// handle, which is the difference between the conversation's need and a
+// one-shot read's: a window opens one store and keeps it for the session, while
+// a single read wants one briefly and wants it closed again afterwards.
+func v3MemoryPath(profileDir string) string {
+ if !config.MemoryEnabledAt(profileDir) {
+ return ""
+ }
+ return defaultChatDB()
+}
+
+// openV3ClockProcess is the clock's process: the person's settings, models,
+// connected accounts, memory and harnesses — what a run's configuration is
+// assembled from — and none of the launch's side effects. It returns its own
+// release.
+//
+// A MACHINE WITH NO KEY STILL HAS A CLOCK. A reminder needs no model, so the
+// clock runs keyless; a watch or a piece of work then says plainly that it has
+// no key, on its own row.
+func openV3ClockProcess() (*v3Process, func(), error) {
+ settings, err := config.Load()
+ if err != nil {
+ keyless, keylessErr := config.LoadKeyless()
+ if keylessErr != nil {
+ return nil, nil, err
+ }
+ settings = keyless
+ }
+ workspace, err := os.UserHomeDir()
+ if err != nil || workspace == "" {
+ workspace = os.TempDir()
+ }
+ discovery := catalog.Options{
+ BaseURL: settings.BaseURL, APIKey: settings.APIKey, Dir: settings.ProfileDir,
+ HTTPClient: config.CatalogHTTPClient(settings.Sources.Default()),
+ }
+ processCtx, processStop := context.WithCancel(context.Background())
+ models := catalog.LoadLazy(processCtx, discovery)
+ seatCrewRows(models.ModelsNow)
+ shelf := newV3ModelShelf(models, discovery)
+ shelf.setSources(settings.Sources)
+ memory := v3Memory(settings.ProfileDir)
+ var search *store.Store
+ if memory != nil {
+ search = memory
+ } else {
+ search = v3SearchStore(settings.ProfileDir)
+ }
+ proc := &v3Process{
+ Settings: settings,
+ ProfileDir: settings.ProfileDir,
+ UnreadProfileKeys: append([]string(nil), settings.UnreadProfileKeys...),
+ Models: models,
+ processCtx: processCtx,
+ processStop: processStop,
+ Shelf: shelf,
+ Harnesses: subharness.Default(),
+ Memory: memory,
+ Search: search,
+ Artifacts: artifactsIndexPath(),
+ Conns: v3Connect(settings.ProfileDir),
+ LaunchDir: workspace,
+ }
+ proc.Skills, proc.skillsDir = v3SkillShelf(proc.Memory)
+ stop := func() {
+ processStop()
+ if memory != nil {
+ _ = memory.Close()
+ }
+ if search != nil && search != memory {
+ _ = search.Close()
+ }
+ }
+ return proc, stop, nil
+}
+
+// retireWhenReplaced ends the clock when its own binary has been replaced and
+// nothing is running, so the next window's check starts the new build. A clock
+// in the middle of a run finishes it first.
+func retireWhenReplaced(parent context.Context, store *automation.Store, logf func(string)) (context.Context, context.CancelFunc) {
+ ctx, cancel := context.WithCancel(parent)
+ binary, err := os.Executable()
+ if err != nil {
+ return ctx, cancel
+ }
+ started, err := os.Stat(binary)
+ if err != nil {
+ return ctx, cancel
+ }
+ guard.Go("automations/retire", func() {
+ ticker := time.NewTicker(time.Minute)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ }
+ now, err := os.Stat(binary)
+ if err == nil && os.SameFile(started, now) && now.ModTime().Equal(started.ModTime()) {
+ continue
+ }
+ if active, err := store.Active(); err == nil && len(active) > 0 {
+ continue
+ }
+ logf("this build was replaced; leaving so the new one takes over")
+ cancel()
+ return
+ }
+ })
+ return ctx, cancel
+}
+
+// clockLogger appends the clock's own lines to clock.log under root.
+func clockLogger(root string) func(string) {
+ var mu sync.Mutex
+ path := filepath.Join(root, automation.LogName)
+ return func(line string) {
+ mu.Lock()
+ defer mu.Unlock()
+ file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
+ if err != nil {
+ return
+ }
+ defer file.Close()
+ _, _ = fmt.Fprintf(file, "%s %s\n", time.Now().Format(time.RFC3339), line)
+ }
+}
diff --git a/cmd/codeaf/chatv3_clock_env_test.go b/cmd/codeaf/chatv3_clock_env_test.go
new file mode 100644
index 0000000000..b95555cf1e
--- /dev/null
+++ b/cmd/codeaf/chatv3_clock_env_test.go
@@ -0,0 +1,46 @@
+package main
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// EVERY PROCESS ON ONE HOME FINGERPRINTS WITH ONE SALT, made by the first to
+// ask and read by the rest, kept where only its owner can read it.
+func TestTheClockSaltIsMadeOnceAndShared(t *testing.T) {
+ root := t.TempDir()
+ first, second := clockSalt(root), clockSalt(root)
+ if len(first) != 32 || !bytes.Equal(first, second) {
+ t.Fatalf("two asks answered %x and %x", first, second)
+ }
+ info, err := os.Stat(filepath.Join(root, clockSaltName))
+ if err != nil || info.Mode().Perm() != 0o600 {
+ t.Fatalf("the salt is kept as %v, %v", info, err)
+ }
+}
+
+// THE FINGERPRINT FOLLOWS THE KEY AND NEVER CARRIES IT. Two shells with
+// different keys are told apart; the presence file a clock reads holds no
+// part of either.
+func TestTheClockFingerprintFollowsTheKeyWithoutCarryingIt(t *testing.T) {
+ root := t.TempDir()
+ t.Setenv("OPENROUTER_API_KEY", "sk-or-first-secret")
+ first := clockEnvironment(root)
+ t.Setenv("OPENROUTER_API_KEY", "sk-or-second-secret")
+ second := clockEnvironment(root)
+ if first == "" || second == "" || first == second {
+ t.Fatalf("the fingerprints are %q and %q", first, second)
+ }
+ for _, fingerprint := range []string{first, second} {
+ if strings.Contains(fingerprint, "secret") {
+ t.Fatalf("the fingerprint carries the key: %q", fingerprint)
+ }
+ }
+ t.Setenv("OPENROUTER_API_KEY", "sk-or-first-secret")
+ if again := clockEnvironment(root); again != first {
+ t.Fatalf("the same key fingerprinted %q and then %q", first, again)
+ }
+}
diff --git a/cmd/codeaf/chatv3_clock_test.go b/cmd/codeaf/chatv3_clock_test.go
new file mode 100644
index 0000000000..97d06cf18b
--- /dev/null
+++ b/cmd/codeaf/chatv3_clock_test.go
@@ -0,0 +1,84 @@
+package main
+
+import (
+ "os"
+ osexec "os/exec"
+ "path/filepath"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/automation"
+ "github.com/Agent-Field/codeaf/internal/home"
+)
+
+// THE REAL BINARY IS THE CLOCK. A reminder due a moment ago, one open window,
+// and `codeaf clock` started the way a window starts it: the run is taken,
+// said and recorded, and the clock leaves when asked to — with this package's
+// HOME and CODEAF_HOME already moved to throwaway folders, so the old-timer
+// removal every start does finds nothing of the developer's to touch.
+func TestTheClockProcessSaysADueReminder(t *testing.T) {
+ binary := buildCodeafStamped(t, "")
+ state := t.TempDir()
+ t.Setenv(home.EnvVar, state)
+ root := automationsRoot()
+ store, err := automation.Open(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ reminder, err := store.Create(automation.Automation{
+ Title: "leave", Workspace: t.TempDir(),
+ Schedule: automation.Schedule{At: time.Now().Add(-time.Second)},
+ Action: automation.Action{Say: "time to leave"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ release, err := automation.NewPresence(root).Hold("test window")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+
+ clock := osexec.Command(binary, "clock")
+ clock.Env = append(os.Environ(), home.EnvVar+"="+state, "HOME="+filepath.Join(state, "home"))
+ if err := clock.Start(); err != nil {
+ t.Fatal(err)
+ }
+ exited := make(chan error, 1)
+ go func() { exited <- clock.Wait() }()
+ defer func() {
+ _ = clock.Process.Signal(syscall.SIGTERM)
+ select {
+ case <-exited:
+ case <-time.After(20 * time.Second):
+ _ = clock.Process.Kill()
+ t.Error("the clock did not leave when asked")
+ }
+ }()
+
+ deadline := time.Now().Add(30 * time.Second)
+ for time.Now().Before(deadline) {
+ runs, err := store.Runs(reminder.ID, 5)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(runs) == 1 && runs[0].Phase == automation.PhaseOver {
+ if runs[0].Outcome != automation.OutcomeDone || runs[0].Line != "time to leave" {
+ t.Fatalf("the reminder's run reads %+v", runs[0])
+ }
+ if !automation.Held(root) {
+ t.Fatal("the clock that said it does not hold the lock")
+ }
+ return
+ }
+ select {
+ case err := <-exited:
+ log, _ := os.ReadFile(filepath.Join(root, automation.LogName))
+ t.Fatalf("the clock left before saying it: %v\n%s", err, log)
+ case <-time.After(100 * time.Millisecond):
+ }
+ }
+ t.Fatal("the reminder was never said")
+}
diff --git a/cmd/codeaf/chatv3_exchange.go b/cmd/codeaf/chatv3_exchange.go
index 050f989fb4..71ac50f3b1 100644
--- a/cmd/codeaf/chatv3_exchange.go
+++ b/cmd/codeaf/chatv3_exchange.go
@@ -7,7 +7,7 @@ package main
//
// ───────────────────────────────────────────────────────────────────────────
// MERGE NOTE (lane errand). Everything in this file is new, and the only line
-// this lane added outside it is the `Errand:` / `StandingRoot:` pair in
+// this lane added outside it is the `Errand:` / `ErrandsRoot:` pair in
// chatv3.go's tui3.Options literal. If that literal has moved under another
// lane, the pair is the whole of what has to be carried across.
// ───────────────────────────────────────────────────────────────────────────
@@ -16,13 +16,14 @@ package main
// session folder in THIS project's bucket and hands back where it put it, which
// is exactly the thing an errand must not have: a folder under v3/projects is a
// row on home, and asking from home exists so that an errand is not one. So the
-// surface names the folder — under the standing root, where nothing scans — and
+// surface names the folder — under the errands root, where nothing scans — and
// this points the same config at it. The model, the roles, the rail and the
// gate are all properties of the LAUNCH and are inherited unchanged; the gate is
// re-read AS IT STANDS NOW for the reason chatv3_approval.go gives.
import (
"fmt"
+ "github.com/Agent-Field/codeaf/internal/home"
"strings"
"github.com/Agent-Field/codeaf/internal/session"
@@ -70,10 +71,10 @@ func v3Errand(cfg session.Config, workspace, profileDir string, yolo bool) func(
fresh.SpendRailUSD = orders.CapUSD
}
// ── lane time ── AND IT SAYS WHAT IT IS. An exchange is a pane that
- // closes with home, not a room somebody sits in, so it is never a
- // steering target for a standing item that fires (internal/session's
- // standing_run.go). Its own card still ratifies: that goes through the
- // agent the surface is holding and not through the live registry.
+ // closes with home, not a room somebody sits in, so it is never counted
+ // as a live window (internal/session's livesessions.go). Its own card
+ // still ratifies: that goes through the agent the surface is holding and
+ // not through the live registry.
fresh.Errand = true
// A BORROWED PLACE ON A FOLDER NOBODY BORROWED IT FOR. The exchange is
// not owned — it has no work/ of its own and litters nothing, because it
@@ -89,3 +90,7 @@ func v3Errand(cfg session.Config, workspace, profileDir string, yolo bool) func(
return v3OpenSession(fresh)
}
}
+
+// v3ErrandsRoot is where home's `ask here` keeps its errands, one folder each:
+// a sibling of the projects root, so home never lists one as a conversation.
+func v3ErrandsRoot() string { return home.Join("v3", "errands") }
diff --git a/cmd/codeaf/chatv3_guard_class_test.go b/cmd/codeaf/chatv3_guard_class_test.go
index e293825c4b..b4175c289b 100644
--- a/cmd/codeaf/chatv3_guard_class_test.go
+++ b/cmd/codeaf/chatv3_guard_class_test.go
@@ -23,19 +23,17 @@ func TestV3ProcessGuardGoClass(t *testing.T) {
kind, stop, join, owner string
}
want := map[string]class{
- "chatv3/standing": {kind: "joined writer", stop: "v3Process.standingStop", join: "v3Process.closeAll calls stopStandingTicks"},
- "chatv3/standing-stop": {kind: "joined helper", stop: "standingStop is closed", join: "chatv3/standing closes standingDone after this helper returns"},
"pool/judge-sweep": {kind: "joined writer", stop: "pool errand context", join: "v3Process.closeAll calls stopPoolErrands"},
"chatv3/models": {kind: "joined writer", stop: "warmModels waiter on the pool errand context, and the catalog's own warm context", join: "v3Process.closeAll calls stopPoolErrands for the waiter (since #1179) and Models.Close, which cancels and joins the internal/catalog warm"},
"engine/models": {kind: "joined writer", stop: "warmModels waiter on the pool errand context, and the catalog's own warm context", join: "v3Process.closeAll calls stopPoolErrands for the waiter (since #1179) and Models.Close, which cancels and joins the internal/catalog warm"},
"chatv3/sweep-home": {kind: "joined writer", stop: "v3Process.sweepCancel cancels the walk's context, checked between entries and before every destructive operation", join: "v3Process.closeAll calls stopPlaceSweep, which waits on sweepDone (since #1276)"},
- "chatv3/background": {kind: "one-shot", stop: "repairBackgroundChecks returns after one bounded Drift/Install pass"},
"chatv3/once-questions": {kind: "one-shot", stop: "agent.Close closes the WatchQuestions channel consumed by the range"},
"chatv3/close-agent": {kind: "joined one-shot", stop: "Agent.Close is bounded", join: "v3Process.closeAll waits on waiting"},
"chatv3/payment-credits": {kind: "joined one-shot", stop: "processCtx is canceled and credits.Read is bounded", join: "v3Process.closeAll calls creditWatcher.close and waits"},
"chatv3/host-reap": {kind: "one-shot", stop: "exec.Cmd.Wait returns when the replaced ssh child exits"},
"chatv3/host-follow": {kind: "one-shot", stop: "remote Follow channel closes with the host connection"},
"chatv3/telemetry-events": {kind: "one-shot", stop: "source event channel closes and countedEvents returns"},
+ "automations/retire": {kind: "one-shot", stop: "the clock's own context ends when `codeaf clock` leaves, or the watcher cancels it on finding the binary replaced"},
}
got := processGuardScopes(t)
diff --git a/cmd/codeaf/chatv3_host.go b/cmd/codeaf/chatv3_host.go
index ab21d77550..f88eb8e35b 100644
--- a/cmd/codeaf/chatv3_host.go
+++ b/cmd/codeaf/chatv3_host.go
@@ -23,7 +23,6 @@ import (
"github.com/Agent-Field/codeaf/internal/leave"
"github.com/Agent-Field/codeaf/internal/remote"
"github.com/Agent-Field/codeaf/internal/session"
- "github.com/Agent-Field/codeaf/internal/standing"
"github.com/Agent-Field/codeaf/internal/store"
"github.com/Agent-Field/codeaf/internal/tui3"
codeupdate "github.com/Agent-Field/codeaf/internal/update"
@@ -356,6 +355,13 @@ func launchHello(session, model, level string) remote.Hello {
// already has. It carries the launch's model and level for the same reason the
// launch's own does — a conversation opened an hour later is the same launch —
// and it carries the ask's own intention (chatv3_beside.go's [engineAsk]).
+//
+// IT DOES NOT SAY [remote.Hello.Window], deliberately: this is the same window
+// on another connection, and the boot connection already holds the far
+// machine's presence for it. Saying it here would count one window once per
+// conversation it has open — a window with three tabs reading itself as three
+// windows, which is the very count that says whether anybody else is still
+// there when this one closes.
func besideHello(ask engineAsk, model, level string) remote.Hello {
return remote.Hello{Session: ask.session, New: ask.mint, Model: model, Level: level}
}
@@ -536,6 +542,13 @@ func openChatV3Host(launch hostLaunch) error {
}
hello := launchHello(launch.session, launch.model, launch.level)
hello.Headless = launch.once != ""
+ // AND A WINDOW IS A WINDOW OPEN ON THE FAR MACHINE, so that machine's
+ // automations clock runs while it is attached (internal/remote's
+ // [remote.Hello.Window]). It is said on THIS connection and on no other: the
+ // boot connection lives exactly as long as the window does, and every
+ // conversation opened beside it is the same window on another pipe
+ // ([besideHello] says nothing). A headless `--once` is a script, not a window.
+ hello.Window = launch.once == ""
link, err := dialEngine(dest, workspace, hello)
if err != nil {
return err
@@ -678,9 +691,8 @@ func hostOptions(fleet *engineFleet, welcome remote.Welcome, pick bool) (tui3.Op
news := &hostNews{}
seams.Notice = news.join(seams.Notice)
far := hostFar{client: client, tell: news.say}
- stands := newHostStanding(far)
// THE PLACES FOLLOW THE SESSION'S MACHINE. The world behind home, tasks,
- // standing, spend and search is asked of the ENGINE and kept warm here, and
+ // spend and search is asked of the ENGINE and kept warm here, and
// it is asked once now so the first frame after launch already has it
// ([hostWorld] holds both laws).
world := newHostWorld(far)
@@ -758,46 +770,25 @@ func hostOptions(fleet *engineFleet, welcome remote.Welcome, pick bool) (tui3.Op
// on the screen until a later beat fetched it back ([hostWorld.archive]
// carries the whole of why).
Archive: world.archive,
- // THE AMBIENT SIDE, AS THE ENGINE MACHINE HOLDS IT. The items belong to
- // the machine that runs them, so both halves go over the wire and
- // neither reads a store on this laptop — the far end answers about the
- // far end's own disk, keyed by a workspace path that means something
- // there ([standing.Item.Workspace] is always the engine's own path,
- // which is exactly what welcome.Workspace is too).
- //
- // WHAT THIS LIGHTS UP HERE is both home's item band and the status line's
- // `◦ N standing orders` segment. Home reads the far world, so its project
- // paths are paths this far store can answer, while the segment asks about this
- // window's workspace — which over --host is the engine's own path, so
- // the count is about the right machine ([hostStanding] has the longer
- // version, and internal/tui3's host.go states it in the honesty table).
- //
- // Items therefore answers from a cache and refreshes behind itself,
- // which is not an optimization but the seam's stated law, because that
- // segment is asked on the frame. Save travels synchronously: it is a
- // keystroke, it is rare, and somebody is waiting for its answer.
// THE TEAMS, AS THE ENGINE MACHINE KEEPS THEM. The far session's team
// tools write the teams file and each team's Traffic into the engine's
// profile, so the window reads and writes those, over the wire
// ([hostTeams]); an engine without the doors hands no seam and the
// surface turns teams off rather than keeping them on this laptop.
Teams: hostTeamsSeam(far, welcome),
- Standing: tui3.StandingSeam{
- Items: stands.list,
- Save: stands.save,
- Watch: client.StandingWatch,
- // Running stays nil; Watch reads the engine's own scheduler over the
- // wire and therefore never substitutes this laptop's timer.
- //
- // Running: nothing on the far machine's disk says "firing at this
- // instant" — a run is in flight inside whichever process holds the
- // tick lock — so there is no question to put on the wire and no
- // answer a frame could carry. Nil answers no for everything, and a
- // home where no row ever wears `●` is the truth (cmd/codeaf's
- // [v3StandingSeam] declines it for the same reason on this machine's
- // own store).
- //
- },
+ // THE AUTOMATIONS, AS THE ENGINE MACHINE KEEPS AND RUNS THEM. The
+ // conversation over there proposes into that machine's store and that
+ // machine's clock runs what it keeps — while this window is attached,
+ // because the boot hello says this connection is a window
+ // ([openChatV3Host]) — so the list, the run lines and the notifications
+ // are read over the wire ([hostAutomationsSeam]). An engine without the
+ // doors hands no seam and this window shows no automations, never this
+ // laptop's, which run somewhere else. The linked-local road puts its own
+ // machine's store back on top ([openChatV3Local]).
+ Automations: hostAutomationsSeam(client, welcome),
+ // AND THE BANNERS ARE THIS MACHINE'S: they are raised by this window, in
+ // the terminal in front of the person, whichever machine ran the run.
+ Notify: v3NotifyRoute(),
// ── WHAT THE CONNECTION ITSELF SAYS ─────────────────────────────────
//
// The four facts only a connection has: the sentence to draw while a
@@ -842,8 +833,8 @@ func hostOptions(fleet *engineFleet, welcome remote.Welcome, pick bool) (tui3.Op
// the wire, and its row says "allowed" rather than "saved", which is
// exactly what happened (internal/tui3's consent.go).
//
- // StandingRoot: the local errand and exchange folder, which is where a
- // question asked from home leaves its short transcript. It is a path on
+ // ErrandsRoot: the local errands folder, which is where a question asked
+ // from home leaves its short transcript. It is a path on
// THIS machine and the exchange it hosts is a session this surface would
// have to open here, so it stays unset over a connection — the same
// posture the errand pair takes.
@@ -1175,161 +1166,6 @@ func hostHeld(seams hostSeams) func() ([]tui3.HeldQuestion, error) {
}
}
-// ── the ambient side over a connection ──────────────────────────────────────
-
-// hostStandingEvery is how stale a cached list of items is allowed to be before
-// the next reading kicks a fresh one. It is deliberately LOOSER than the
-// surface's own three-second beat (internal/tui3's homeEvery): every refresh
-// here is a round trip down an ssh pipe rather than a directory read, and
-// nothing standing changes faster than this — an item's cadence is measured in
-// minutes ([standing.Interval] is five of them), so a list a few seconds old is
-// a list that is right.
-const hostStandingEvery = 5 * time.Second
-
-// hostStanding is the engine machine's items, kept here so the surface can have
-// them without waiting.
-//
-// WHO ACTUALLY READS THIS OVER A CONNECTION, because it is not the reader the
-// seam's own doc comment leads with: /home does not open over --host at all
-// (internal/tui3's homeRemoteWord), so home's item band and its `p` and `s` keys
-// are unreachable here and homestanding.go's per-project standItems is never
-// called. The live reader is THE TASK COLUMN'S FOOT AND /status —
-// [app.keepingCount] feeds the `◦ 2 standing orders` line at the foot of the
-// column (and the same words under `watching` on /status and the phone sheet),
-// and it asks about the window's own workspace, which over --host is the
-// ENGINE's path. That makes the count a true sentence about the right machine,
-// and it is a real thing to have: a remote window says how many things are
-// keeping an eye on the project it is sitting in.
-//
-// AND IT EXISTS FOR ONE LAW, which is [tui3.StandingSeam.Items]': it MUST NOT
-// BLOCK. That segment is asked on EVERY FRAME and twice per frame while a turn
-// runs. The surface already keeps its own three-second answer for it, so this is
-// the second of two guards rather than the only one — and it is not redundant,
-// because the one reading that does get through is on the draw path. Locally
-// that reading is a directory of small documents. Over a connection it is a call
-// with a ten-second deadline (internal/remote's callDeadline), so a surface that
-// made one of those while drawing would be a terminal that stopped repainting
-// for as long as the far machine took to answer — on a link that had just died,
-// ten seconds per frame.
-//
-// So the reading and the fetching are pulled apart. THE READ ANSWERS FROM WHAT
-// IS HELD, ALWAYS AND IMMEDIATELY; a list that has gone stale kicks ONE
-// background fetch and still answers with what it had. The first read of a
-// workspace answers nothing at all and starts the fetch, so the segment is
-// absent for one beat and then true — the honest order, because a surface that
-// guessed would have to guess wrong first.
-//
-// ONE FETCH AT A TIME PER WORKSPACE, never a pile: the surface is drawn many
-// times a second and every one of those readings would otherwise start its own
-// goroutine and its own frame on the wire, which is a queue of identical
-// questions behind a link that is already slow.
-type hostStanding struct {
- // ask and put are the two wire doors, held as CLOSURES rather than as the
- // client for [tui3.StandingSeam]'s own reason said one seam further along:
- // what this type does is a policy about staleness and blocking, and a test
- // of that policy should be able to hand it a slow answer without opening a
- // pipe.
- ask func(workspace string) ([]standing.Item, error)
- put func(item standing.Item) error
-
- // duty is the latch, the clock and the door, kept per workspace
- // (chatv3_host_duty.go).
- duty hostDuty
-
- mu sync.Mutex
- // items is the last list each workspace answered with.
- items map[string][]standing.Item
-}
-
-func newHostStanding(far hostFar) *hostStanding {
- h := &hostStanding{
- ask: far.client.StandingItems,
- put: far.client.SaveStanding,
- items: map[string][]standing.Item{},
- }
- far.arm(&h.duty, "reading the standing items")
- return h
-}
-
-// list is [tui3.StandingSeam.Items]: what is held, right now, with a refresh
-// started behind it when what is held has aged.
-func (h *hostStanding) list(workspace string) []standing.Item {
- workspace = strings.TrimSpace(workspace)
- if workspace == "" {
- return nil
- }
- held := h.snapshot(workspace)
- if h.duty.due(workspace, hostStandingEvery) {
- h.duty.run("chatv3/host-standing", workspace, func() { h.fetch(workspace) })
- }
- return held
-}
-
-// snapshot is what is held for one workspace, under the lock held from a defer.
-func (h *hostStanding) snapshot(workspace string) []standing.Item {
- h.mu.Lock()
- defer h.mu.Unlock()
- return h.items[workspace]
-}
-
-// fetch is the round trip, on a goroutine of its own.
-//
-// A FAILED CALL KEEPS THE LAST LIST rather than emptying the band. The two ways
-// this fails are a link that has died and an engine with no ambient side at all;
-// neither of them is the news "the things you set up are gone", and a band that
-// blanked itself on a dropped connection would be the screen reporting a loss
-// that did not happen. The clock is still stamped, so a link that is failing is
-// asked again on the next beat and not on every frame.
-func (h *hostStanding) fetch(workspace string) {
- items, err := h.ask(workspace)
- if err != nil {
- return
- }
- h.mu.Lock()
- defer h.mu.Unlock()
- h.items[workspace] = items
-}
-
-// save is [tui3.StandingSeam.Save], and it is ALLOWED TO BLOCK where the read is
-// not: it is a keystroke on one row — pause, stop — so it happens once and a
-// person is waiting for the answer to it. The error travels back unchanged
-// because home prints it: a row redrawn as paused over a store that refused the
-// write would be this screen lying about the other machine's disk.
-//
-// NOTHING CAN PRESS THOSE KEYS OVER --host TODAY, and that is worth saying out
-// loud rather than leaving for somebody to discover: the only callers are home's
-// `p` and `s`, and home does not open over a connection. It is wired anyway
-// because the alternative is a seam that is half absent for a reason that is not
-// its own — the door and the wire are correct and proved, and the day home opens
-// on a remote session the keys work rather than saying the change cannot be made
-// here. A nil would have been a second thing to undo on that day, and a claim
-// about this store that is not true.
-//
-// A WRITE THAT LANDED IS PUT STRAIGHT INTO WHAT IS HELD, and that is what keeps
-// the row from redrawing stale in the beat before the next fetch returns: the
-// engine accepted this exact document, so the held list is corrected with it
-// rather than left showing the version the key was pressed on. The entry is
-// aged out at the same time, so the next reading also asks the store what it
-// really thinks.
-func (h *hostStanding) save(item standing.Item) error {
- if err := h.put(item); err != nil {
- return err
- }
- h.mu.Lock()
- defer h.mu.Unlock()
- workspace := strings.TrimSpace(item.Workspace)
- held := h.items[workspace]
- for i, existing := range held {
- if existing.ID == item.ID {
- held[i] = item
- break
- }
- }
- h.items[workspace] = held
- h.duty.age(workspace)
- return nil
-}
-
// hostSessions is [tui3.Options.RecentSessions] over the wire.
//
// IT IS ASKED ON THE KEYSTROKE, like the local one, and it is a round trip
@@ -1408,21 +1244,20 @@ func runHostOnce(agent *remote.Agent, text string) error {
// hostWorldEvery is how stale a held world is allowed to be before the next
// reading kicks a fresh one.
//
-// IT IS SHORTER THAN [hostStandingEvery] AND LONGER THAN NOTHING. The surface
-// re-reads its places every three seconds (internal/tui3's homeEvery), and a
-// staleness of two means every one of those beats finds the held world old
-// enough to refresh — so what a person looks at is at most one beat behind the
-// far machine, which is the same lag a local window has against the next
-// terminal on its own disk. Standing can afford five because an item's cadence
-// is measured in minutes; a conversation in the next window over there answers
-// in seconds, and home exists to show that happening.
+// IT IS SHORT AND LONGER THAN NOTHING. The surface re-reads its places every
+// three seconds (internal/tui3's homeEvery), and a staleness of two means every
+// one of those beats finds the held world old enough to refresh — so what a
+// person looks at is at most one beat behind the far machine, which is the same
+// lag a local window has against the next terminal on its own disk. A
+// conversation in the next window over there answers in seconds, and home
+// exists to show that happening.
const hostWorldEvery = 2 * time.Second
// hostWorld is the ENGINE machine's world, kept here so the places can have it
// without waiting.
//
-// It is [hostStanding] applied to the one reading five of the seven places are
-// built from, and it keeps that type's two laws for that type's two reasons:
+// It is the one reading five of the seven places are built from, and it keeps
+// two laws for two reasons:
//
// - THE READ ANSWERS FROM WHAT IS HELD, ALWAYS AND IMMEDIATELY. A place may
// read on its open and on its beat, and over a connection both of those are
@@ -1434,19 +1269,19 @@ const hostWorldEvery = 2 * time.Second
// each asking the same question, is a queue of identical frames behind a link
// that is already slow.
//
-// AND IT SAYS WHETHER IT HAS AN ANSWER, which [hostStanding] does not have to.
-// An empty list of standing items and no answer yet are the same thing on a
-// screen — the emptiness law draws both as nothing. An empty WORLD is not: it is
+// AND IT SAYS WHETHER IT HAS AN ANSWER. For most lists an empty one and no
+// answer yet are the same thing on a screen — the emptiness law draws both as
+// nothing. An empty WORLD is not: it is
// a machine with no projects on it, and `nothing here yet — say something and
// this fills up` drawn over a server full of work is the one wrong sentence this
// screen can say about somebody else's disk. So the seam answers a second value
// and the surface draws nothing at all until the far machine has spoken once
// (internal/tui3's [app.worldKnown]).
type hostWorld struct {
- // ask and put are the two wire doors, held as closures for
- // [hostStanding.ask]'s reason: what this type does is a policy about
- // staleness and blocking, and a test of that policy should be able to hand it
- // a slow answer without opening a pipe.
+ // ask and put are the two wire doors, held as CLOSURES rather than as the
+ // client: what this type does is a policy about staleness and blocking, and
+ // a test of that policy should be able to hand it a slow answer without
+ // opening a pipe.
ask func() (session.World, error)
put func(dir string, archived bool) error
@@ -1514,10 +1349,9 @@ func (h *hostWorld) fetch() {
// went when a later beat's fetch happened to return and a later beat still
// happened to rebuild: [hostWorldEvery] of staleness plus up to two of the
// surface's own three-second beats, which is the several seconds a person sees
-// between pressing the key and watching the row go. This is [hostStanding.save]'s
-// correction applied to the one write home makes against the world, for the same
-// reason and in the same two halves — the held copy is patched, and the entry is
-// aged out so the next reading still asks the engine what it really thinks.
+// between pressing the key and watching the row go. So the correction comes in
+// two halves — the held copy is patched, and the entry is aged out so the next
+// reading still asks the engine what it really thinks.
//
// A REFUSED WRITE CHANGES NOTHING HERE. Home prints the engine's own refusal
// ("could not put it away") and a cache that had already moved the row would be
@@ -1588,8 +1422,7 @@ func newHostLedger(far hostFar) *hostLedger {
// the one from before the call. The local seam re-reads the file on home's own
// three-second beat (place_spend.go), and this is that beat said over a wire.
//
-// It matches [hostWorldEvery] rather than [hostStandingEvery] because it answers
-// the same kind of question — a page a person is looking at right now, about
+// It matches [hostWorldEvery] because it answers the same kind of question — a page a person is looking at right now, about
// facts that move while they look — and because both are read on the frame.
const hostLedgerEvery = 2 * time.Second
@@ -1703,6 +1536,11 @@ func (h *hostMemory) RestoreMemory(id string) error {
h.refresh(500, time.Time{})
return err
}
+func (h *hostMemory) SetMemoryAlways(id string, always bool) error {
+ err := h.client.SetMemoryAlways(id, always)
+ h.refresh(500, time.Time{})
+ return err
+}
func (h *hostMemory) MemoryProvenance(id string) (string, string, time.Time, error) {
return h.client.MemoryProvenance(id)
}
diff --git a/cmd/codeaf/chatv3_host_automations_test.go b/cmd/codeaf/chatv3_host_automations_test.go
new file mode 100644
index 0000000000..df7549043d
--- /dev/null
+++ b/cmd/codeaf/chatv3_host_automations_test.go
@@ -0,0 +1,122 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/automation"
+ "github.com/Agent-Field/codeaf/internal/home"
+ "github.com/Agent-Field/codeaf/internal/remote"
+)
+
+// THE HOST DOOR HANDS THE WINDOW THE FAR MACHINE'S AUTOMATIONS, AND NOTHING WHEN
+// THE FAR ENGINE HAS NONE. The engine below keeps a store of the test's own in a
+// zone that is nobody's local one: the seam hostOptions builds reads that store
+// over the wire, carries that zone, and claims a run's notification once; an
+// engine whose welcome does not say it has the doors hands the zero seam, which
+// the surface reads as automations absent — never this laptop's store.
+func TestTheHostDoorReadsTheFarMachinesAutomations(t *testing.T) {
+ t.Setenv("HOME", t.TempDir())
+ store, err := automation.Open(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = store.Close() })
+ loop, err := remote.Loopback(remote.Hello{Version: remote.Version, Workspace: "/srv/app"}, remote.Options{
+ Boot: func(remote.Hello) (*remote.Engine, error) {
+ return &remote.Engine{Agent: &quietAgent{}, Workspace: "/srv/app", SessionFile: "/srv/app/j.jsonl",
+ Automations: &remote.EngineAutomations{Store: store, Zone: "Pacific/Chatham"}}, nil
+ },
+ })
+ if err != nil {
+ t.Fatalf("dial the loopback: %v", err)
+ }
+ t.Cleanup(func() { _ = loop.Close() })
+
+ options, _ := hostOptions(onePipeFleet("devbox", loop.Client), loop.Client.Welcome(), false)
+ seam := options.Automations
+ if seam.List == nil || seam.Changes == nil || seam.Claim == nil || seam.Active == nil {
+ t.Fatal("the door hands a window over --host no automations seam, so it can draw none of that machine's automations")
+ }
+ if seam.Zone != "Pacific/Chatham" {
+ t.Fatalf("a rhythm typed over --host is read in %q, want the far machine's own zone", seam.Zone)
+ }
+ made, err := store.Create(automation.Automation{
+ Title: "leave", Workspace: "/srv/app",
+ Schedule: automation.Schedule{At: time.Now().Add(time.Hour)},
+ Action: automation.Action{Say: "time to leave"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ list, err := seam.List()
+ if err != nil || len(list) != 1 || list[0].ID != made.ID {
+ t.Fatalf("the window's list of the far machine answered %+v, %v", list, err)
+ }
+ run, err := store.QueueNow(made.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !seam.Claim(run.ID) {
+ t.Fatal("the first window to claim a run's notification was refused it")
+ }
+ if seam.Claim(run.ID) {
+ t.Fatal("a run's notification was claimed twice")
+ }
+
+ // A FOLDER UNDER ~ CROSSES AS ~ AND IS READ ON THE FAR MACHINE. The line is
+ // typed on the laptop, whose home is not the home of the machine that keeps
+ // and runs the automation, so the window sends it unexpanded and the far
+ // store reads it there.
+ far, err := seam.Create(automation.Automation{
+ Title: "plants", Workspace: "~/app",
+ Schedule: automation.Schedule{At: time.Now().Add(time.Hour)},
+ Action: automation.Action{Say: "water the plants"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ house, _ := os.UserHomeDir()
+ if far.Workspace != filepath.Join(house, "app") {
+ t.Fatalf("a ~ folder typed over --host was kept as %q, want the engine machine's %q", far.Workspace, filepath.Join(house, "app"))
+ }
+
+ bare, _ := hostOptions(onePipeFleet("devbox", hostedClient(t)), remote.Welcome{Version: remote.Version, Workspace: "/srv/app"}, false)
+ if !reflect.ValueOf(bare.Automations).IsZero() {
+ t.Fatalf("an engine without the automations doors was handed a seam: %+v", bare.Automations)
+ }
+}
+
+// THE ENGINE HANDS THE WIRE THIS MACHINE'S AUTOMATIONS, AND A WINDOW HELD
+// THROUGH IT IS A WINDOW THIS MACHINE'S CLOCK COUNTS. The hook carries the store
+// and the zone this process opened once, and its HoldWindow is the very door a
+// local window holds presence through — so a window attached from another
+// machine is counted in the folder the clock reads, and stops being counted the
+// moment it is let go.
+func TestTheEngineHandsTheWireThisMachinesAutomations(t *testing.T) {
+ // THE STORE IS OPENED ONCE PER PROCESS, under the home this test binary
+ // already moved to a throwaway folder (testenv_test.go) — the same place
+ // every test that builds a conversation's config opens it. Only the window
+ // held below goes to a folder this test owns, so the count starts at zero.
+ autos, hook := v3Automations(), engineAutomations()
+ if autos == nil || hook == nil {
+ t.Fatalf("this process opened automations %v and handed the wire %v; both must be there under a writable home", autos, hook)
+ }
+ if hook.Store != autos.Store || hook.Zone != autos.Zone || hook.HoldWindow == nil {
+ t.Fatalf("the wire was handed %+v, not this process's own store %p in %q", hook, autos.Store, autos.Zone)
+ }
+ t.Setenv(home.EnvVar, t.TempDir())
+ presence := automation.NewPresence(automationsRoot())
+ release := hook.HoldWindow("attached from laptop")
+ if open, err := presence.Count(); err != nil || open != 1 {
+ release()
+ t.Fatalf("a window held through the engine counts %d, %v", open, err)
+ }
+ release()
+ if open, err := presence.Count(); err != nil || open != 0 {
+ t.Fatalf("a window the engine let go is still counted: %d, %v", open, err)
+ }
+}
diff --git a/cmd/codeaf/chatv3_host_duty.go b/cmd/codeaf/chatv3_host_duty.go
index be5e34bfe8..1ef4e48142 100644
--- a/cmd/codeaf/chatv3_host_duty.go
+++ b/cmd/codeaf/chatv3_host_duty.go
@@ -13,9 +13,8 @@ import (
// hostFar is the far machine as the background duties are given it: the
// connection, and the one place a sentence about a duty goes.
//
-// IT IS THE ONE SEAM A DUTY IS ARMED THROUGH. The four readings hostOptions keeps
-// warm behind the surface — the places, spending, memory, the standing items —
-// each hold their wire door as a closure ([hostStanding] says why), and a closure
+// IT IS THE ONE SEAM A DUTY IS ARMED THROUGH. The readings hostOptions keeps
+// warm behind the surface — the places, spending, memory — each hold their wire door as a closure ([hostWorld] says why), and a closure
// made from a nil client is a perfectly good function right up until it is
// called, on a goroutine, where the nil dereference is a panic the guard swallows
// and nobody sees. So the fact "there is a connection behind this" is established
diff --git a/cmd/codeaf/chatv3_host_duty_test.go b/cmd/codeaf/chatv3_host_duty_test.go
index 90f9e56a03..11e25bb72e 100644
--- a/cmd/codeaf/chatv3_host_duty_test.go
+++ b/cmd/codeaf/chatv3_host_duty_test.go
@@ -323,7 +323,6 @@ func (q *quietAgent) ReasoningLevels() map[string]string
func (q *quietAgent) SetReasoningFor(string, string) {}
func (q *quietAgent) ResolveConsent(uint64, bool) {}
func (q *quietAgent) ResolveConsentRemember(uint64, bool, session.ConsentScope) {}
-func (q *quietAgent) ResolveStanding(uint64, session.StandingAnswer) {}
func (q *quietAgent) ResolveHarness(uint64, bool, string) {}
func (q *quietAgent) ResolveConnect(string, bool) {}
func (q *quietAgent) ResolveConnectKey(string, string) {}
diff --git a/cmd/codeaf/chatv3_host_teams.go b/cmd/codeaf/chatv3_host_teams.go
index 9882464ad6..bc89028646 100644
--- a/cmd/codeaf/chatv3_host_teams.go
+++ b/cmd/codeaf/chatv3_host_teams.go
@@ -13,7 +13,7 @@ import (
// and Traffic logs, which are where the far session's team tools keep them
// (internal/remote's wire_teams.go).
//
-// IT IS BUILT LIKE [hostStanding], for the one call the surface makes on its
+// IT IS BUILT LIKE [hostWorld], for the one call the surface makes on its
// loop. [tui3.TeamsSeam.Load] is asked at an opening and must not block, so it
// answers what is held and nothing else; when nothing is held yet it says so,
// and the surface asks [tui3.TeamsSeam.ReadSince] off its loop, once, which
@@ -28,7 +28,7 @@ import (
// falling back to the file on this disk, which the far session never reads.
type hostTeams struct {
// read, write and traffic are the three wire doors, as closures for
- // [hostStanding]'s reason: a test hands them a conflict without a pipe.
+ // [hostWorld]'s reason: a test hands them a conflict without a pipe.
read func(stamp string, reserved []float64) (remote.TeamsReading, error)
write func(base string, teams []teamstore.Team) (remote.TeamsReading, error)
traffic func(team, after string, limit int) (remote.TeamsTraffic, error)
diff --git a/cmd/codeaf/chatv3_host_test.go b/cmd/codeaf/chatv3_host_test.go
index af6d282dc8..c23fa218d7 100644
--- a/cmd/codeaf/chatv3_host_test.go
+++ b/cmd/codeaf/chatv3_host_test.go
@@ -13,7 +13,6 @@ import (
"github.com/Agent-Field/codeaf/internal/remote"
"github.com/Agent-Field/codeaf/internal/session"
- "github.com/Agent-Field/codeaf/internal/standing"
"github.com/Agent-Field/codeaf/internal/tui3"
)
@@ -228,7 +227,7 @@ func TestHostFlagIsOnTheChatUsage(t *testing.T) {
// The two wire doors are asserted beside it because they are the same fact from
// the surface's side — the band and the pause key read the ENGINE machine's
// store, and a closure that was never filled is a refusal on the wire.
-func TestTheEngineDoorKeepsTheAmbientSideOnOverAConnection(t *testing.T) {
+func TestTheEngineDoorKeepsAutomationsOnOverAConnection(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("CODEAF_HOME", filepath.Join(home, "state"))
@@ -243,11 +242,11 @@ func TestTheEngineDoorKeepsTheAmbientSideOnOverAConnection(t *testing.T) {
}
defer func() { _ = engine.Agent.Close() }()
- // The doors are built from the config's own seam ([engineStandingItems]
- // hands back nil for a nil one), so a door that is here is proof that
- // bootEngine's Config.Standing was filled and `stand` reached the belt.
- if engine.StandingItems == nil || engine.StandingSave == nil {
- t.Fatal("the engine serves no standing doors, so a remote surface has no band and no pause key")
+ // The wire's automations are this machine's store ([engineAutomations]
+ // hands back nil when it cannot be opened), so a seam here is proof a
+ // remote surface can list and change them.
+ if engine.Automations == nil || engine.Automations.Store == nil {
+ t.Fatal("the engine serves no automations, so a remote surface has no list and no card")
}
// These are production bindings on the engine returned by bootEngine, not
// callbacks supplied by a test fixture. Removing either binding makes a
@@ -268,48 +267,25 @@ func TestTheEngineDoorKeepsTheAmbientSideOnOverAConnection(t *testing.T) {
if err != nil {
t.Fatalf("the shared assembly did not open: %v", err)
}
- if launch.Config.Standing == nil || launch.Config.Standing.Store == nil {
- t.Fatal("the engine door built no standing seam, so `stand` is off the belt over --host")
+ if launch.Config.Automations == nil || launch.Config.Automations.Store == nil {
+ t.Fatal("the engine door built no automations seam, so `automation` is off the belt over --host")
}
- // AND THE STORE IS THE ENGINE MACHINE'S OWN, under its state root and not
- // under any path a surface could have sent it.
- if root := launch.Config.Standing.Store.Root(); root != v3StandingRoot() {
- t.Fatalf("the engine's store is at %q, want this machine's own %q", root, v3StandingRoot())
- }
- // The doors answer that same store: an item written through Save comes back
- // out of Items for its own workspace.
- item := standing.Item{
- Words: "tell me when the build breaks",
- Workspace: home,
- When: standing.When{Kind: standing.WhenEvery, Words: "every hour", Every: "1h"},
- Does: standing.Action{Kind: standing.ActionSay, Say: "the build broke"},
- Rails: standing.Rails{PerRunUSD: 0.02, MaxPerDay: 4},
- }
- stood, err := launch.Config.Standing.Store.Create(item)
- if err != nil {
- t.Fatalf("create: %v", err)
- }
- found, err := engine.StandingItems(home)
- if err != nil || len(found) != 1 || found[0].ID != stood.ID {
- t.Fatalf("the engine's items door answered %+v, %v", found, err)
- }
- stood.Status = standing.StatusPaused
- if err := engine.StandingSave(stood); err != nil {
- t.Fatalf("the engine's save door refused a paused item: %v", err)
- }
- if again, err := engine.StandingItems(home); err != nil || len(again) != 1 || again[0].Status != standing.StatusPaused {
- t.Fatalf("the write did not land: %+v, %v", again, err)
+ // AND IT IS THE ONE STORE THIS PROCESS HOLDS, the one the wire serves, so
+ // what the session proposes is what a surface lists.
+ if engine.Automations.Store != launch.Config.Automations.Store {
+ t.Fatal("the wire serves a different store from the one the session proposes into")
}
}
// AND THE SURFACE IS HANDED IT, with the live firing field that nobody can
// answer left out. This is the door's half: what hostOptions wires is
// what a person over --host actually gets.
-func TestTheHostDoorWiresTheStandingSeamAndNothingAboutThisMachine(t *testing.T) {
+func TestTheHostDoorWiresTheFarMachineAndNothingAboutThisMachine(t *testing.T) {
t.Setenv("HOME", t.TempDir())
welcome := remote.Welcome{
Version: remote.Version, Workspace: "/srv/app",
Build: "1265feda built 2026-08-27 13:28", BashBackgroundAfterSeconds: 47,
+ Automations: true,
}
// A REAL CLIENT, not a nil: the door primes three readings on goroutines
// of their own the moment it is built, and a nil here used to be three
@@ -323,150 +299,15 @@ func TestTheHostDoorWiresTheStandingSeamAndNothingAboutThisMachine(t *testing.T)
if options.BashBackgroundAfterSeconds != welcome.BashBackgroundAfterSeconds {
t.Fatalf("the surface countdown is %d, want the engine's %d", options.BashBackgroundAfterSeconds, welcome.BashBackgroundAfterSeconds)
}
- if options.Standing.Items == nil || options.Standing.Save == nil {
- t.Fatal("the door hands over no standing seam, so a remote surface has no rows and no pause key")
- }
- // Running: nothing on the far machine's disk says "firing at this instant",
- // so nil answers no for everything and no row ever wears `●`.
- if options.Standing.Running != nil {
- t.Fatal("the door claims it can tell whether an item is firing on another machine")
- }
- // Watch crosses to the engine, so /status can answer from the right timer.
- if options.Standing.Watch == nil {
- t.Fatal("the door does not ask the engine for its background timer")
- }
- // StandingRoot is the LOCAL errand and exchange folder, and there is no
- // errand over a connection.
- if options.StandingRoot != "" {
- t.Fatalf("the door pointed the surface at %q, a folder on the wrong machine", options.StandingRoot)
- }
-}
-
-// THE READ MUST NOT BLOCK, and that is the whole reason [hostStanding] exists.
-// The reader over --host is the status line's `◦ N standing orders` segment — home
-// does not open on a remote session — and that segment is asked on every frame,
-// while a wire call has a ten-second deadline behind it. So this holds the far
-// end still and asks anyway.
-func TestTheHostStandingSeamAnswersFromItsCacheWithoutBlocking(t *testing.T) {
- held := make(chan struct{})
- asked := make(chan string, 8)
- stands := &hostStanding{
- ask: func(workspace string) ([]standing.Item, error) {
- asked <- workspace
- <-held
- return []standing.Item{{ID: "01HQ", Words: "watch CI", Workspace: workspace}}, nil
- },
- put: func(standing.Item) error { return nil },
- items: map[string][]standing.Item{},
- }
-
- // The first reading has nothing to answer with and answers nothing, at once.
- if items := stands.list("/srv/app"); items != nil {
- t.Fatalf("the first reading answered %+v, want nothing yet", items)
- }
- // An absent segment for one beat is the honest order: a surface that guessed
- // a count would have to guess wrong first.
- if !waitFor(func() bool { return len(asked) == 1 }) {
- t.Fatal("the first reading started no fetch at all")
- }
- // AND SO DOES EVERY READING WHILE THAT FETCH IS STILL OUT THERE. This is the
- // frame that would otherwise be a terminal that stopped repainting.
- start := time.Now()
- for range 50 {
- stands.list("/srv/app")
- }
- if waited := time.Since(start); waited > 2*time.Second {
- t.Fatalf("fifty readings took %s while the far end said nothing", waited)
- }
- // ONE FETCH AND NEVER A PILE. Fifty more readings of a workspace nobody has
- // answered for yet is still one question on the wire.
- if len(asked) != 1 {
- t.Fatalf("%d fetches were started for one workspace", len(asked))
- }
- close(held)
- if !waitFor(func() bool { return len(stands.list("/srv/app")) == 1 }) {
- t.Fatal("the answer never reached the cache")
- }
-
- // A workspace nobody asked about gets its own fetch, and an empty workspace
- // gets none: there is no such place to ask about.
- if items := stands.list(" "); items != nil {
- t.Fatalf("an empty workspace answered %+v", items)
+ // The automations are the FAR machine's, read over the wire, when the far
+ // engine serves them.
+ if options.Automations.List == nil || options.Automations.Create == nil {
+ t.Fatal("the door hands over no automations seam, so a remote surface has no list and no card")
}
-}
-
-// A WRITE THAT LANDED IS NOT READ BACK STALE. The engine accepted this exact
-// document, so what is held is corrected with it rather than left showing the
-// version the key was pressed on — and the entry is aged out so the next beat
-// still asks the store what it really thinks. (The keys that call this are
-// home's, and home does not open over --host today; the door is proved here so
-// that it is the surface's opening that is missing and not this.)
-func TestSavingOneItemOverTheWireRefreshesWhatHomeDraws(t *testing.T) {
- item := standing.Item{ID: "01HQ", Words: "watch CI", Workspace: "/srv/app", Status: standing.StatusActive}
- var written []standing.Item
- stands := &hostStanding{
- ask: func(string) ([]standing.Item, error) { return []standing.Item{item}, nil },
- put: func(saved standing.Item) error { written = append(written, saved); return nil },
- items: map[string][]standing.Item{"/srv/app": {item}},
- }
- paused := item
- paused.Status = standing.StatusPaused
- if err := stands.save(paused); err != nil {
- t.Fatalf("save: %v", err)
- }
- if len(written) != 1 || written[0].Status != standing.StatusPaused {
- t.Fatalf("the item did not travel: %+v", written)
- }
- held := stands.list("/srv/app")
- if len(held) != 1 || held[0].Status != standing.StatusPaused {
- t.Fatalf("the row would have redrawn as %+v", held)
- }
-}
-
-// A REFUSED WRITE IS THE STORE'S OWN SENTENCE AND NOTHING IS CHANGED HERE. Home
-// prints it on its message line, and a cache that had already recorded the pause
-// would be this screen disagreeing with the other machine's disk.
-func TestARefusedRemoteWriteLeavesTheRowAsItWas(t *testing.T) {
- item := standing.Item{ID: "01HQ", Words: "watch CI", Workspace: "/srv/app", Status: standing.StatusActive}
- stands := &hostStanding{
- ask: func(string) ([]standing.Item, error) { return []standing.Item{item}, nil },
- put: func(standing.Item) error { return errors.New("an item needs a per-run budget") },
- items: map[string][]standing.Item{"/srv/app": {item}},
- }
- paused := item
- paused.Status = standing.StatusPaused
- err := stands.save(paused)
- if err == nil || err.Error() != "an item needs a per-run budget" {
- t.Fatalf("save = %v, want the store's own refusal", err)
- }
- if held := stands.list("/srv/app"); len(held) != 1 || held[0].Status != standing.StatusActive {
- t.Fatalf("a refused write changed the row anyway: %+v", held)
- }
-}
-
-// A FAILING LINK KEEPS THE LAST LIST. The two ways a fetch fails are a
-// connection that died and an engine with no ambient side; neither of them is
-// the news "the things you set up are gone", so the count holds what it had
-// rather than dropping to nothing and taking the segment off the line.
-func TestALostConnectionDoesNotEmptyTheItemBand(t *testing.T) {
- item := standing.Item{ID: "01HQ", Words: "watch CI", Workspace: "/srv/app"}
- stands := &hostStanding{
- ask: func(string) ([]standing.Item, error) { return nil, errors.New("the connection to devbox is gone") },
- put: func(standing.Item) error { return nil },
- items: map[string][]standing.Item{"/srv/app": {item}},
- }
- if held := stands.list("/srv/app"); len(held) != 1 {
- t.Fatalf("the seam answered %+v before the fetch even failed", held)
- }
- if !waitFor(func() bool {
- stands.duty.mu.Lock()
- defer stands.duty.mu.Unlock()
- return !stands.duty.out["/srv/app"] && !stands.duty.ended["/srv/app"].IsZero()
- }) {
- t.Fatal("the failing fetch never finished")
- }
- if held := stands.list("/srv/app"); len(held) != 1 || held[0].ID != "01HQ" {
- t.Fatalf("a failed round trip emptied the list: %+v", held)
+ // ErrandsRoot is the LOCAL errands folder, and there is no errand over a
+ // connection.
+ if options.ErrandsRoot != "" {
+ t.Fatalf("the door pointed the surface at %q, a folder on the wrong machine", options.ErrandsRoot)
}
}
diff --git a/cmd/codeaf/chatv3_local.go b/cmd/codeaf/chatv3_local.go
index 6d83eb5d4d..a56baa88ac 100644
--- a/cmd/codeaf/chatv3_local.go
+++ b/cmd/codeaf/chatv3_local.go
@@ -311,7 +311,7 @@ func openChatV3Local(launch localLaunch) error {
options.Notice = joinNotice(options.Notice, link.said())
// AND HOME CAN ASK SOMETHING WITHOUT OPENING A CONVERSATION. `ask here` is
// the second action row on home, and it is answered by an agent this process
- // builds against a folder under the standing root ([localErrandDoor],
+ // builds against a folder under the errands root ([localErrandDoor],
// chatv3_exchange.go) — so before this line, every launch that took the
// engine road met `this window cannot ask from home`, which since #653 made
// this road the ordinary one was every ordinary launch.
@@ -319,16 +319,21 @@ func openChatV3Local(launch localLaunch) error {
// IT IS BOUND ON THIS ROAD AND NO OTHER, like [tui3.Options.EngineAnswers]
// above it, and for the same fact: the engine here is a process on THIS
// machine, so a session opened in this terminal writes to the disk the
- // errand's own folder and standing store live on. Over --host the folder
- // would be made on the laptop and the work would run against the wrong
- // machine, so there the seam stays absent and home says so once.
+ // errand's own folder and the automations store live on. Over --host the
+ // folder would be made on the laptop and the work would run against the
+ // wrong machine, so there the seam stays absent and home says so once.
errand, closeErrands := localErrandDoor(launch, welcome)
// AND WHATEVER IT OPENED IS CLOSED HOWEVER THIS SURFACE RETURNS, beside the
// fleet's own close above: an errand is a real session with a real journal,
// and a Run that leaves by any other road than /quit still owes it a flush.
defer closeErrands()
options.Errand = errand
- options.StandingRoot = v3StandingRoot()
+ options.ErrandsRoot = v3ErrandsRoot()
+ // AND THE AUTOMATIONS ARE THIS MACHINE'S, read straight off their store:
+ // the engine here is a process on this machine, and the store it proposes
+ // into is the one this window reads (chatv3_clock.go). Over --host the
+ // seam goes over the connection instead ([hostOptions]).
+ options.Automations = v3AutomationsSeam()
// AND A QUESTION IN ANOTHER WINDOW CAN BE ANSWERED FROM HOME. The answer is
// left in that session's OWN FOLDER and the session picks it up on its
// presence heartbeat (internal/session's answers.go), and home draws the chips
@@ -367,6 +372,11 @@ func openChatV3Local(launch localLaunch) error {
if dir, err := v3Dir(); err == nil {
options.DraftFile = tui3.DraftFile(dir, welcome.Workspace)
}
+ // THIS IS A WINDOW ON THIS MACHINE, whose automations it shows: it holds
+ // its own presence here rather than asking the engine to, because the
+ // engine is a process of this machine and would count the same window
+ // twice (chatv3_clock.go).
+ defer keepAutomationsWindow("window")()
return runSurface(context.Background(), options)
}
@@ -701,12 +711,6 @@ func localErrandDoor(launch localLaunch, welcome remote.Welcome) (func(tui3.Erra
Interactive: true,
Workspace: welcome.Workspace,
Session: welcome.SessionFile,
- // AND THIS PROCESS DOES NOT KEEP TIME. The engine on the other end
- // of the socket holds this project's conversations, so it is the
- // one whose pass can put a firing into the conversation somebody is
- // sitting in rather than into the project's inbox
- // ([v3Options.NoStandingTicks] carries the whole of why).
- NoStandingTicks: true,
})
if err != nil {
fault = err
@@ -719,9 +723,10 @@ func localErrandDoor(launch localLaunch, welcome remote.Welcome) (func(tui3.Erra
// program (chatv3_lanes.go's [v3LanesHere]), and the gate may ASK,
// because there is a surface and it answers — chatv3.go states both
// beside the same pair of lines. Without the second the errand's
- // `stand` refused every proposal with `nobody is here to say yes —
- // this can only be set up in a conversation`, which is the honest
- // answer for a headless run and a lie about a card on somebody's home.
+ // `automation` refuses every proposal with `nobody is here to say yes
+ // — an automation can only be set up in a conversation somebody is
+ // in`, which is the honest answer for a headless run and a lie about
+ // a card on somebody's home.
cfg, _ := v3Shape(boot.Config, v3LanesHere())
cfg.AskConsent = true
open = v3Errand(cfg, welcome.Workspace, boot.Settings.ProfileDir, launch.shape != nil && launch.shape.Yolo)
diff --git a/cmd/codeaf/chatv3_process.go b/cmd/codeaf/chatv3_process.go
index 17bf59f444..c3dd0c137f 100644
--- a/cmd/codeaf/chatv3_process.go
+++ b/cmd/codeaf/chatv3_process.go
@@ -132,14 +132,11 @@ type v3Process struct {
// mu guards everything below: the lazily opened history file and the list of
// agents this process has built. Both are touched from the surface's
// goroutine and from the door's defer, which are not the same one.
- mu sync.Mutex
- recall *history.Store
- agents []*session.Agent
- standingStarted bool
- standingStop chan struct{}
- standingDone chan struct{}
- sweepCancel context.CancelFunc
- sweepDone chan struct{}
+ mu sync.Mutex
+ recall *history.Store
+ agents []*session.Agent
+ sweepCancel context.CancelFunc
+ sweepDone chan struct{}
// catalogs are the lazy catalogs this process opened beside Models — a
// direct service's own listing, asked for when a conversation is opened on
// one of its models ([v3Process.ownCatalog]). closeAll cancels and joins each.
@@ -235,11 +232,6 @@ func openV3ProcessWith(door string, askKey bool) (*v3Process, error) {
}
return nil, err
}
- // AND THE BACKGROUND CHECKS ARE PUT BACK IF THEY DRIFTED, once per process,
- // in the background, saying nothing on screen (chatv3_standing.go). A timer
- // naming a program that has moved is a timer that runs nothing, and this is
- // the one moment this build knows where the program actually is.
- startBackgroundRepair(settings.ProfileDir)
// WHERE THE PERSON IS STANDING, which is not the same fact as which project
// this is: `codeaf` typed in repo/cmd/ is a conversation about the
// repository, and the subdirectory is recorded rather than resolved away
@@ -660,11 +652,6 @@ func (p *v3Process) closeAll() {
// it is the process's own errand and not a conversation's.
stopPoolErrands(p.ProfileDir)
- // Stop the standing clock before closing anything it may borrow. Waiting
- // for its loop also waits for a pass already in flight, so no standing
- // writer can outlive this process close.
- p.stopStandingTicks()
-
var waiting sync.WaitGroup
for _, agent := range agents {
waiting.Add(1)
diff --git a/cmd/codeaf/chatv3_process_test.go b/cmd/codeaf/chatv3_process_test.go
index 4fe9cff6de..6f61437fc0 100644
--- a/cmd/codeaf/chatv3_process_test.go
+++ b/cmd/codeaf/chatv3_process_test.go
@@ -1,11 +1,9 @@
package main
import (
- "context"
"os"
"path/filepath"
"strings"
- "sync"
"testing"
"time"
@@ -13,7 +11,6 @@ import (
"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/modelsource"
"github.com/Agent-Field/codeaf/internal/session"
- "github.com/Agent-Field/codeaf/internal/standing"
"github.com/Agent-Field/codeaf/internal/tui3"
)
@@ -372,43 +369,6 @@ func TestOpeningAWorkspaceThatIsNotThereIsRefusedInASentence(t *testing.T) {
}
}
-// Closing a process stops and joins its standing clock. Removing the store after
-// Close makes any late pass observable: taking the tick lock recreates the root.
-func TestCloseAllJoinsStandingTickerAndLaterProcessCanStart(t *testing.T) {
- const interval = 10 * time.Millisecond
- oldInterval := standingTickInterval
- standingTickInterval = interval
- t.Cleanup(func() { standingTickInterval = oldInterval })
-
- proc := v3TestProcess(t)
- root := filepath.Join(t.TempDir(), "v3", "standing")
- store, err := standing.Open(root)
- if err != nil {
- t.Fatal(err)
- }
- proc.startStandingTicks(store)
- waitForStandingTick(t, root, interval)
-
- proc.closeAll()
- if err := os.RemoveAll(root); err != nil {
- t.Fatal(err)
- }
- time.Sleep(4 * interval)
- if _, err := os.Stat(root); !os.IsNotExist(err) {
- t.Fatalf("standing created files after Close returned: %v", err)
- }
-
- later := v3TestProcess(t)
- laterRoot := filepath.Join(t.TempDir(), "v3", "standing")
- laterStore, err := standing.Open(laterRoot)
- if err != nil {
- t.Fatal(err)
- }
- later.startStandingTicks(laterStore)
- waitForStandingTick(t, laterRoot, interval)
- later.closeAll()
-}
-
func waitForStandingTick(t *testing.T, root string, interval time.Duration) {
t.Helper()
deadline := time.Now().Add(20 * interval)
@@ -420,48 +380,3 @@ func waitForStandingTick(t *testing.T, root string, interval time.Duration) {
}
t.Fatal("standing ticker did not take its lock")
}
-
-// TestCloseAllCancelsAnInFlightStandingPass proves a quit never waits out a
-// running pass's TickWindow: closeAll cancels the pass's context, so a pass
-// blocked on nothing but its own ctx ends at once and Close returns promptly.
-// The ordering is forced by channels, with only a generous sanity bound.
-func TestCloseAllCancelsAnInFlightStandingPass(t *testing.T) {
- oldInterval := standingTickInterval
- standingTickInterval = 5 * time.Millisecond
- t.Cleanup(func() { standingTickInterval = oldInterval })
-
- started := make(chan struct{})
- sawCancel := make(chan struct{})
- var once sync.Once
- oldPass := standingTickPass
- standingTickPass = func(ctx context.Context, _ *standing.Store) {
- once.Do(func() { close(started) })
- <-ctx.Done()
- close(sawCancel)
- }
- t.Cleanup(func() { standingTickPass = oldPass })
-
- proc := v3TestProcess(t)
- store, err := standing.Open(filepath.Join(t.TempDir(), "v3", "standing"))
- if err != nil {
- t.Fatal(err)
- }
- proc.startStandingTicks(store)
- <-started // a pass is in flight, blocked on its ctx
-
- closed := make(chan struct{})
- go func() {
- proc.closeAll()
- close(closed)
- }()
- select {
- case <-closed:
- case <-time.After(30 * time.Second):
- t.Fatal("closeAll did not return while a standing pass was in flight; the pass's ctx was not cancelled")
- }
- select {
- case <-sawCancel:
- default:
- t.Fatal("the in-flight standing pass did not see its ctx cancelled")
- }
-}
diff --git a/cmd/codeaf/chatv3_standing.go b/cmd/codeaf/chatv3_standing.go
deleted file mode 100644
index a45f1e148a..0000000000
--- a/cmd/codeaf/chatv3_standing.go
+++ /dev/null
@@ -1,552 +0,0 @@
-package main
-
-// The door's half of the ambient side: where the standing store lives, who
-// ticks it, and what a firing is allowed to do.
-//
-// internal/standing owns the files and the pass; internal/session owns what a
-// firing DOES (standing_run.go). This file is the seam between them and the
-// person's own machine — the one place that says which directory, which daily
-// rail, and which posture a firing runs under.
-//
-// ── WHO TICKS ──
-//
-// Any window that is open takes the store's lock and runs a pass every
-// [standing.Interval]; the OS timer running `codeaf tick` is the backup for
-// "no terminal open". Both build the ticker through [v3StandingTicker], so
-// there is exactly one answer to "what does a pass do" and it cannot drift
-// between the two callers.
-//
-// ── WHAT A FIRING MAY DO ──
-//
-// A firing runs under the person's PROFILE rules and not under a repository's.
-// The gate a conversation runs behind is resolved per project (chatv3.go), and
-// a checked-in file that could widen what runs while nobody is watching would
-// be a repository granting itself permissions its author never met. So the
-// posture below is resolved against the person's home directory: their own
-// banked rules, and nothing a clone can add to them. Anything those rules would
-// have asked about refuses, the run stops, and the item says it needs them.
-
-import (
- "context"
- "fmt"
- "os"
- "path/filepath"
- "sync"
- "sync/atomic"
- "time"
-
- "github.com/Agent-Field/codeaf/internal/catalog"
- "github.com/Agent-Field/codeaf/internal/config"
- "github.com/Agent-Field/codeaf/internal/guard"
- "github.com/Agent-Field/codeaf/internal/home"
- "github.com/Agent-Field/codeaf/internal/session"
- "github.com/Agent-Field/codeaf/internal/standing"
- "github.com/Agent-Field/codeaf/internal/tui3"
-)
-
-// standingLogName is where a failed pass says so.
-//
-// IT IS A FILE AND NEVER THE SCREEN, for [sweepLogName]'s reason: the pass runs
-// while a surface owns the terminal, and a line on stderr is either scrolled
-// past or painted through a frame somebody is typing into. A pass that could not
-// read one item is not a person's problem — the next pass reads it — so the
-// honest destination is a file an operator can read afterwards.
-const standingLogName = "standing.log"
-
-// v3StandingRoot is where everything standing lives: ~/.codeaf/v3/standing,
-// resolved through internal/home so CODEAF_HOME moves it with the rest
-// (Decision 26 — one home, one seam).
-func v3StandingRoot() string { return home.Join("v3", "standing") }
-
-// v3Standing builds the seam a conversation proposes through, or nil.
-//
-// NIL IS THE AMBIENT SIDE OFF, and every caller reads it that way: no belt
-// tool, no card, nothing armed. A store that cannot be opened is exactly that
-// case — a capability that cannot work is absent, not broken — so the error is
-// swallowed here rather than failing somebody's launch over a folder.
-func v3Standing(profileDir string) *session.Standing {
- store, err := standing.Open(v3StandingRoot())
- if err != nil {
- return nil
- }
- return &session.Standing{
- Store: store,
- Watch: standingWatch(store),
- // 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.
- DailyRail: func() float64 { return v3StandingDailyRail(profileDir) },
- }
-}
-
-// v3StandingDailyRail is the daily ceiling on everything standing spends. It is
-// the person's existing daily budget row and NOT a new setting: a second number
-// beside it would be two answers to one question, and the first day they
-// disagreed the honest one would be whichever the card did not show.
-func v3StandingDailyRail(profileDir string) float64 {
- rail, err := config.DailyBudgetUSDAt(profileDir)
- if err != nil || rail < 0 {
- return 0
- }
- return rail
-}
-
-// standingWatch is the OS timer that keeps checking with no window open: a
-// launchd agent or a systemd user timer running `codeaf tick` every
-// [standing.Interval] (internal/standing's watch.go). Nil is the honest answer
-// on a host the package cannot arrange one for, and every caller reads nil as
-// "there are no background checks here" — nothing is installed, nothing is
-// said, and the settings row that would turn it is absent.
-func standingWatch(store *standing.Store) standing.Watch {
- watch, err := standing.NewWatch(standing.WatchOptions{WakeLog: store.WakeLogPath()})
- if err != nil {
- return nil
- }
- return watch
-}
-
-// v3StandingTicker builds one pass. IT IS THE ONE CONSTRUCTOR: a window's
-// goroutine below and `codeaf tick` both call exactly this, so the two can
-// never disagree about what a pass is allowed to do.
-//
-// IT READS THE PROFILE KEYLESS, and that is the law rather than a convenience:
-// A PASS THAT WILL DO NOTHING COSTS NOTHING AND NEEDS NOTHING. Most passes do
-// nothing at all — another window already holds the tick lock, or every item is
-// asleep — and [config.Load] refusing without a key made building the pass the
-// moment credentials were demanded, five minutes apart, forever, on a machine
-// that has never been set up. Nothing below builds a client: the sentinel makes
-// its own lazily on the first judgment that actually needs one
-// (internal/session's NewStandingSentinel) and the runner is a plain struct. So
-// the key is carried through and asked for at the one moment a model is called,
-// where a machine that has none says so on that item's own row — `could not
-// check: no API key: this session has not been given one yet` — and the rest of
-// the walk goes on.
-//
-// IT ANSWERS A RELEASE BESIDE THE PASS, which the caller runs when the pass is
-// over: the posture's model catalog warms in the background and writes a cache
-// when it lands, and the release cancels and joins it (#1274). It is never nil
-// when the error is.
-func v3StandingTicker(store *standing.Store) (*standing.Ticker, func(), error) {
- if store == nil {
- return nil, nil, fmt.Errorf("standing: no store")
- }
- settings, err := config.LoadKeyless()
- if err != nil {
- return nil, nil, err
- }
- posture, models, err := v3StandingPosture(settings)
- if err != nil {
- return nil, nil, err
- }
- idle := session.StandingIdle()
- return &standing.Ticker{
- Store: store,
- // THE THREE-WAY READER IS WHAT A REAL TICK USES. An ambiguous, refused
- // or timed-out sentinel reply is [standing.VerdictUnknown] and the item
- // stays due; the binary reader below folds every non-yes into "no" and
- // is kept only for callers that still speak in yes/no. A per-item pinned
- // model is read off the item inside the sentinel and the runner
- // ([session.standingPinnedConfig]), so a pass reloaded from the profile
- // cannot route an item the person ratified under `--one-model` onto a
- // different model (the pinned-model boundary this file's posture note
- // describes).
- SentinelVerdict: session.NewStandingSentinelVerdict(posture),
- Sentinel: session.NewStandingSentinel(posture),
- Runner: session.NewStandingRunnerWithMemory(posture, store.Root(), v3MemoryPath(settings.ProfileDir)),
- Idle: idle,
- // The dreaming pass over what is remembered, which rides this pass
- // because it wants exactly what this pass already has: one process
- // elected among every window and the OS timer, and a machine nobody is
- // sitting at. It is handed the store's PATH rather than an open store,
- // so a ticker rebuilt every five minutes does not open a database
- // connection every five minutes (internal/session's
- // memory_consolidate.go), and a blank path — memory off — leaves the
- // seam nil and the pass absent.
- Tidy: session.NewMemoryTidy(posture, v3MemoryPath(settings.ProfileDir), store.Root(), idle),
- DailyRailUSD: v3StandingDailyRail(settings.ProfileDir),
- }, models.Close, nil
-}
-
-// v3MemoryPath is the brain's file when the memory row is on, and the empty
-// string when it is off.
-//
-// IT READS THE SAME ROW [v3Memory] READS and answers a path rather than a
-// handle, which is the difference between the conversation's need and the
-// tick's: a window opens one store and keeps it for the session, while a pass
-// wants one a few times a day and wants it closed again afterwards.
-func v3MemoryPath(profileDir string) string {
- if !config.MemoryEnabledAt(profileDir) {
- return ""
- }
- return defaultChatDB()
-}
-
-// v3StandingPosture is the config a firing inherits: the person's models, keys,
-// accounts and approval rules, resolved against their HOME rather than against
-// any project (see this file's header).
-//
-// The workspace here is only where the rows are read from. Every firing runs in
-// its own item's workspace, which the runner sets before it opens anything.
-//
-// It answers the lazy catalog it opened as well, which the caller closes when
-// the pass is over; closing it does not take the rows it has already read.
-func v3StandingPosture(settings config.Config) (session.Config, *catalog.Catalog, error) {
- root, err := os.UserHomeDir()
- if err != nil || root == "" {
- root = os.TempDir()
- }
- cfg := session.Config{
- Workspace: root,
- Model: v3TalkModel("", settings),
- APIKey: settings.APIKey,
- BaseURL: settings.BaseURL,
- Sources: settings.Sources,
- CompactEnabled: true,
- ProfileDir: settings.ProfileDir,
- ArtifactsIndex: artifactsIndexPath(),
- // THE DIVISION ROAD, from the same row a conversation reads it off
- // (chatv3.go). A firing is the one piece of work nobody is watching, so
- // it is the one place where "this brief holds eleven jobs" has to be
- // answerable by the work itself rather than by a person noticing at
- // breakfast — and it is free where it does not apply, because a firing
- // whose brief enumerates nothing is never armed and carries no verb
- // (internal/session's standingWideWork). `CODEAF_SWARM=0` takes it away
- // here exactly as it does everywhere else.
- Divide: settings.Swarm,
- }
- // NOT yolo, ever, whatever a window was started with: --yolo is a posture
- // somebody took for a session they were sitting in front of, and carrying it
- // into work that runs while they sleep would be reading a flag as a standing
- // promise about calls nobody has written yet.
- // NOT one-model either, and for the same shape of reason: it is a posture
- // for a run somebody is measuring, and a standing item fires on its own
- // clock long after that run ended. The tier rows answer here as they always
- // have.
- cfg, err = applyV3Governance(cfg, settings.ProfileDir, false, false)
- if err != nil {
- return session.Config{}, nil, err
- }
- // The media pair, resolved the way a conversation resolves it (chatv3.go):
- // a firing briefed to draw a diagram needs the hand that draws it, and the
- // resolver is what says which model does. The catalog is LAZY and is never
- // waited for — a pass whose catalog has not resolved simply has no media
- // verbs on its belt, which is the same absence a cold conversation has.
- //
- // THE CATALOG IS THE PASS'S TO CLOSE (#1274). Its warm writes a cache when it
- // lands, and a pass is rebuilt every five minutes; a warm nobody joined could
- // write after the window had closed. So it is handed back to the one caller,
- // [v3StandingTicker], whose release closes it when the pass is over.
- models := catalog.LoadLazy(context.Background(), catalog.Options{
- BaseURL: settings.BaseURL, APIKey: settings.APIKey, Dir: settings.ProfileDir,
- })
- cfg.Media = v3MediaClient(settings)
- cfg.MediaModel = v3MediaModel(models, settings.ProfileDir, cfg.RolesSource)
- cfg.SupportsParameter = models.SupportsParameter
- cfg.ModelPrice = models.PriceNow
- cfg.NearestModels = v3NearestModels(models)
- // AskConsent stays false and Standing stays nil: nobody is watching a
- // firing, and nothing that fires may arm anything else.
- return cfg, models, nil
-}
-
-// startStandingTicks runs a pass every [standing.Interval] for as long as this
-// process lives, once per process.
-//
-// IT NEVER BLOCKS A LAUNCH and it never says anything on screen. The first pass
-// is one interval away, so a process that opens and exits — a --once run, a
-// smoke test — has ticked nothing at all, which is the correct behaviour for a
-// door nobody is sitting at.
-//
-// [standing.ErrHeld] is SILENT: another window is ticking, which is the design
-// working rather than a fault, and a log line per five minutes per window would
-// be a file nobody could read.
-var standingTickInterval = standing.Interval
-
-// standingTickPass is the seam the ticker calls; tests replace it to hold a
-// pass in flight. The default is the real pass.
-var standingTickPass = runStandingTick
-
-func (p *v3Process) startStandingTicks(store *standing.Store) {
- if p == nil || store == nil {
- return
- }
- stop, done, started := p.takeStandingStart()
- if !started {
- return
- }
-
- guard.Go("chatv3/standing", func() {
- standingTicks.Store(true)
- // One ctx for the whole ticker. Closing stop cancels it, which ends an
- // in-flight pass promptly (runStandingTick derives the pass ceiling from
- // it), so a quit never waits out a running pass's TickWindow.
- ctx, cancel := context.WithCancel(context.Background())
- ticker := time.NewTicker(standingTickInterval)
- defer func() {
- ticker.Stop()
- cancel()
- standingTicks.Store(false)
- close(done)
- }()
- guard.Go("chatv3/standing-stop", func() {
- <-stop
- cancel()
- })
- for {
- select {
- case <-ticker.C:
- standingTickPass(ctx, store)
- case <-stop:
- return
- }
- }
- })
-}
-
-func (p *v3Process) takeStandingStart() (stop, done chan struct{}, started bool) {
- p.mu.Lock()
- defer p.mu.Unlock()
- if p.closed || p.standingStarted {
- return nil, nil, false
- }
- p.standingStarted = true
- p.standingStop = make(chan struct{})
- p.standingDone = make(chan struct{})
- return p.standingStop, p.standingDone, true
-}
-
-func (p *v3Process) stopStandingTicks() {
- stop, done := p.takeStandingStop()
- if stop == nil {
- return
- }
- close(stop)
- <-done
-}
-
-func (p *v3Process) takeStandingStop() (stop, done chan struct{}) {
- p.mu.Lock()
- defer p.mu.Unlock()
- stop, done = p.standingStop, p.standingDone
- p.standingStop, p.standingDone = nil, nil
- return stop, done
-}
-
-// standingTicks is whether the loop above is actually running in this process,
-// and [standingTicking] is how the surface asks ([tui3.StandingSeam.Ticking]).
-//
-// IT IS SET INSIDE THE GOROUTINE AND NOT BESIDE THE Do, so it is true exactly
-// when there is something keeping time. /status says `while a window is open`
-// on the strength of this flag, and a flag set by the intention to start a
-// goroutine would be the screen vouching for a pass that never began.
-var standingTicks atomic.Bool
-
-func standingTicking() bool { return standingTicks.Load() }
-
-// runStandingTick is one pass, bounded, with everything it can say written to a
-// file.
-func runStandingTick(ctx context.Context, store *standing.Store) {
- // A PANIC HERE MUST NOT END THE TICKING. The loop above is this process's
- // whole contribution to the ambient side, and a goroutine that unwound out
- // of it would leave a window that looks like it is keeping watch and is not.
- defer guard.Recover("standing tick")
- pass, release, err := v3StandingTicker(store)
- if err != nil {
- noteStanding("could not start a pass: " + err.Error())
- return
- }
- defer release()
- // The pass's ceiling is a CHILD of the caller's ctx, so closing the ticker
- // (which cancels that ctx) ends an in-flight pass at once, and the 120s
- // TickWindow stays the pass's own upper bound when nobody is quitting.
- passCtx, cancel := context.WithTimeout(ctx, standing.TickWindow)
- defer cancel()
- if _, err := pass.Tick(passCtx); err != nil {
- if err == standing.ErrHeld {
- return
- }
- noteStanding(err.Error())
- }
-}
-
-// noteStanding writes one line, and opens the file only when there is a line to
-// write: a pass that behaved leaves nothing behind at all ([noteSweep]).
-func noteStanding(line string) {
- path := home.Join("v3", standingLogName)
- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
- return
- }
- file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
- if err != nil {
- return
- }
- defer file.Close()
- fmt.Fprintf(file, "%s %s\n", time.Now().Format(time.RFC3339), line)
-}
-
-// v3StandingSeam is the surface's reading of the same seam the session
-// proposes through. A nil seam is a zero StandingSeam, which internal/tui3 reads
-// as the ambient side absent: no band on home, no segment, no /status line.
-//
-// Running IS supplied, and what makes that honest is that something on disk now
-// says it. A firing runs inside whichever process holds the tick lock — a live
-// window, or the operating system's timer running `codeaf tick` with nobody
-// sitting anywhere — and internal/standing's running.go is that process leaving
-// a marker in the item's folder for the length of the pass it is doing. Every
-// other window reads it, doubts it (a dead pid, an age past one pass) and draws
-// `●` only on what survives, so the glyph is derived rather than asserted.
-func v3StandingSeam(seam *session.Standing) tui3.StandingSeam {
- if seam == nil || seam.Store == nil {
- return tui3.StandingSeam{}
- }
- store, watch := seam.Store, seam.Watch
- out := tui3.StandingSeam{
- Items: func(workspace string) []standing.Item {
- items, err := store.ForWorkspace(workspace)
- if err != nil {
- return nil
- }
- return items
- },
- // AND EVERY ORDER ON THE MACHINE IN ONE READ, which is the store's own
- // List and is what Items is a filtered copy of. A page wanting the whole
- // set asks this once rather than asking Items once per project, which
- // walked the standing root once per project to build one map.
- All: func() []standing.Item {
- items, err := store.List()
- if err != nil {
- return nil
- }
- return items
- },
- Save: store.Save,
- // AND THE RUNG ONE ITEM THINKS AT, through the store's own door and never
- // through Save above: the rung is a read-modify-write under the item's
- // lock, so a card that had been on screen for a beat cannot write back the
- // check results and the next-due the ticker has moved since
- // (internal/standing's SetStandingEffort says the whole of why).
- SetEffort: store.SetStandingEffort,
- // WHETHER THIS PROCESS IS KEEPING TIME, asked at the moment the line is
- // drawn rather than latched when the seam was built: the ticking starts
- // during the launch (startStandingTicks) and a boolean captured here
- // would be a claim about the order of two lines in this file.
- Ticking: standingTicking,
- // And why nobody is, when nobody is: has this machine ever been told
- // that background checks are on. The marker lives under the store root
- // and internal/session owns its shape (tools_standing.go).
- BackgroundTold: func() bool { return session.BackgroundTold(store.Root()) },
- // The ledger's last days, per item, for the `this week` line on a card.
- // A read that fails answers nothing rather than a wrong figure — the
- // card simply has one less true thing to say.
- Runs: func(since time.Time) map[string]standing.Spend {
- runs, err := store.RunsSince(since)
- if err != nil {
- return nil
- }
- return runs
- },
- // The marker in the item's own folder, doubted by the store before it
- // answers. This is the whole of "some other process is on this item
- // right now" — the surface's `●`, the card's `checking now`, and the
- // one segment on the status line that moves.
- Running: store.Running,
- }
- if watch != nil {
- out.Watch = func() (standing.WatchStatus, bool) {
- status, err := watch.Status()
- return status, err == nil
- }
- // The same timer as the hand the `background checks` settings row turns.
- // It is the reading above and the switch under it, and they are one
- // object so the sheet cannot read one timer and turn another.
- out.Background = watch
- }
- return out
-}
-
-// ── the launch's one look at the background checks ──────────────────────────
-
-// backgroundTimer is the slice of [standing.Timer] the repair below needs,
-// named as an interface so a test can hand it a definition pointing at a dead
-// path without going anywhere near this machine's launchd.
-type backgroundTimer interface {
- Repair(ctx context.Context) (standing.WatchDrift, error)
-}
-
-// repairBackgroundChecks puts a drifted timer back, and says one line about it
-// in the standing log.
-//
-// THE DEFINITION EMBEDS THE PROGRAM'S PATH, which is what makes this necessary:
-// a person who moves the binary, or deletes the one the definition names, still
-// has a timer — it simply runs nothing. Nothing on screen could say so, because
-// the honest reading of that timer is `off` and off is what they would see in
-// /settings; so the launch repairs it instead of reporting it.
-//
-// IT ONLY EVER REPAIRS, NEVER INSTALLS. A definition that is not there at all is
-// somebody who has never had one or who turned the row off, and writing one for
-// either of them would make the row a suggestion. And the row outranks all of
-// it: `wanted` is [config.BackgroundChecksWantedAt], and a person who turned
-// background checks off is left exactly as they left their machine.
-//
-// AND IT SPEAKS ONLY FOR ITS OWN PAIR. The machine has one timer per login and
-// it is a (home, program) pair; a timer naming another home, or another program
-// that can still run, is not drift and is left exactly as it is
-// (internal/standing's WatchDrift says why). So a launch under an isolated
-// CODEAF_HOME has nothing to say about the machine's timer, and two builds on
-// one machine no longer take it from each other on every launch.
-func repairBackgroundChecks(watch backgroundTimer, wanted bool) string {
- if watch == nil || !wanted {
- return ""
- }
- drift, err := watch.Repair(context.Background())
- if !drift.Present {
- return ""
- }
- if err != nil {
- return "could not put the background check back: " + err.Error()
- }
- if drift.Gone && drift.Executable != "" {
- return "the background check ran " + drift.Executable + ", which is no longer there: installed it again"
- }
- return "the background check was not as this program writes it: installed it again"
-}
-
-// startBackgroundRepair runs that once per process, off the launch's own
-// thread.
-//
-// ONCE PER LAUNCH AND NEVER ON SCREEN. It is housekeeping about the person's
-// machine rather than news for them — the checks were meant to be running and
-// now are — so it goes to the standing log where a pass's own complaints go
-// ([noteStanding]), and the [sync.Once] is what keeps a process that opens two
-// conversations from writing the line twice.
-func startBackgroundRepair(profileDir string) {
- backgroundOnce.Do(func() {
- guard.Go("chatv3/background", func() {
- watch, err := standing.NewWatch(standing.WatchOptions{})
- if err != nil {
- // No timer on this host. Nothing to repair and nothing to say.
- return
- }
- if line := repairBackgroundChecks(watch, config.BackgroundChecksWantedAt(profileDir)); line != "" {
- noteStanding(line)
- }
- })
- })
-}
-
-var backgroundOnce sync.Once
-
-// doStanding is the standing section a headless `codeaf do` run's workers close
-// on, resolved against the workspace the run edits and NO conversation: a
-// headless errand is a place with no conversation ([standing.Item.Reaches]),
-// so only the project and the machine's orders reach it — which is exactly what
-// the resolver answers for the place. The ambient side off, or a store that
-// cannot be opened, is no section, the same emptiness law every caller reads
-// ([v3Standing]). Empty is what a run with nothing standing over it gets.
-func doStanding(workspace string) string {
- store, err := standing.Open(v3StandingRoot())
- if err != nil {
- return ""
- }
- return session.StandingWorld(store, workspace, "")
-}
diff --git a/cmd/codeaf/chatv3_standing_test.go b/cmd/codeaf/chatv3_standing_test.go
deleted file mode 100644
index f4db9bc889..0000000000
--- a/cmd/codeaf/chatv3_standing_test.go
+++ /dev/null
@@ -1,227 +0,0 @@
-package main
-
-import (
- "context"
- "errors"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/Agent-Field/codeaf/internal/home"
- "github.com/Agent-Field/codeaf/internal/standing"
-)
-
-// THE STORE IS UNDER THE STATE ROOT AND NOWHERE ELSE, so CODEAF_HOME moves the
-// ambient side with everything else it moves. A second spelling of this path
-// would be two stores with half a person's reminders in each.
-func TestStandingLivesUnderTheStateRoot(t *testing.T) {
- root := filepath.Join(t.TempDir(), "state")
- t.Setenv("CODEAF_HOME", root)
- if got, want := v3StandingRoot(), home.Join("v3", "standing"); got != want {
- t.Fatalf("standing root = %q, want %q", got, want)
- }
- if !strings.HasPrefix(v3StandingRoot(), root) {
- t.Fatalf("standing root %q is outside the state root %q", v3StandingRoot(), root)
- }
-}
-
-// THE SEAM IS BUILT FOR A CONVERSATION AND THE STORE IS THE ONE AT THAT PATH.
-// A door that could not open it hands over nil, which every caller reads as the
-// ambient side being off — the absence law, not a broken tool.
-func TestStandingSeamOpensTheStoreAtThatPath(t *testing.T) {
- root := filepath.Join(t.TempDir(), "state")
- t.Setenv("CODEAF_HOME", root)
- profile := t.TempDir()
- seam := v3Standing(profile)
- if seam == nil || seam.Store == nil {
- t.Fatal("the door built no standing seam")
- }
- if seam.Store.Root() != v3StandingRoot() {
- t.Fatalf("the seam's store is at %q, want %q", seam.Store.Root(), v3StandingRoot())
- }
- // The daily rail is the person's own daily budget row and never a second
- // number invented for this.
- 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)
- }
- }
-}
-
-// THE OFFER IS NEVER MADE WHILE THERE IS NO TIMER TO INSTALL. standingWatch is
-// the core lane's seam and answers nil until it lands; a card that offered to
-// keep checking with nothing behind the yes would be a promise this build
-// cannot keep.
-
-// ── the launch's repair ─────────────────────────────────────────────────────
-
-// driftedTimer is a definition on disk, stood in for. Nothing here goes near
-// this machine's launchd.
-type driftedTimer struct {
- drift standing.WatchDrift
- err error
- installs int
- fail error
-}
-
-func (d *driftedTimer) Repair(context.Context) (standing.WatchDrift, error) {
- if d.err != nil {
- return standing.WatchDrift{}, d.err
- }
- if !d.drift.Present || !d.drift.Stale {
- return standing.WatchDrift{}, nil
- }
- d.installs++
- return d.drift, d.fail
-}
-
-// A TIMER POINTING AT A PROGRAM THAT MOVED RUNS NOTHING, and nothing on screen
-// could say so — the honest reading of it is `off`, which is what /settings
-// would show. So the launch puts it back, quietly, and says one line in the
-// standing log.
-func TestTheLaunchPutsABackgroundCheckBackWhenItsProgramMoved(t *testing.T) {
- timer := &driftedTimer{drift: standing.WatchDrift{
- Present: true, Stale: true, Gone: true, Executable: "/old/bin/codeaf",
- }}
- line := repairBackgroundChecks(timer, true)
- if timer.installs != 1 {
- t.Fatalf("a drifted timer was installed %d times", timer.installs)
- }
- if !strings.Contains(line, "/old/bin/codeaf") || !strings.Contains(line, "installed it again") {
- t.Fatalf("the log line does not say what happened: %q", line)
- }
-}
-
-// AND IT ONLY EVER REPAIRS. A machine with no definition has never had one or
-// had it turned off, and a row the person turned off is a machine left exactly
-// as they left it — writing one for either would make the row a suggestion.
-func TestTheLaunchInstallsNothingItWasNotAlreadyAskedFor(t *testing.T) {
- cases := []struct {
- name string
- timer *driftedTimer
- wanted bool
- }{
- {"nothing installed", &driftedTimer{drift: standing.WatchDrift{}}, true},
- {"already right", &driftedTimer{drift: standing.WatchDrift{Present: true}}, true},
- {"the row is off", &driftedTimer{drift: standing.WatchDrift{Present: true, Stale: true}}, false},
- {"cannot be read", &driftedTimer{err: errors.New("no")}, true},
- }
- for _, one := range cases {
- if line := repairBackgroundChecks(one.timer, one.wanted); line != "" {
- t.Fatalf("%s: said %q", one.name, line)
- }
- if one.timer.installs != 0 {
- t.Fatalf("%s: installed a timer anyway", one.name)
- }
- }
- if line := repairBackgroundChecks(nil, true); line != "" {
- t.Fatalf("a machine with no timer said %q", line)
- }
-}
-
-// AND A REPAIR THAT DID NOT TAKE SAYS SO IN THE LOG rather than claiming the
-// checks are back.
-func TestARepairThatFailedSaysSoInTheLog(t *testing.T) {
- timer := &driftedTimer{
- drift: standing.WatchDrift{Present: true, Stale: true},
- fail: errors.New("launchctl bootstrap failed"),
- }
- line := repairBackgroundChecks(timer, true)
- if !strings.Contains(line, "could not put the background check back") {
- t.Fatalf("a failed repair said %q", line)
- }
-}
-
-// AND THE WHOLE THING OVER A REAL DEFINITION, with this machine's scheduler
-// stood in for: a plist naming a program that is not there, repaired into one
-// naming the program that is.
-func TestTheRepairRewritesADefinitionThatNamesADeadPath(t *testing.T) {
- homeDir := t.TempDir()
- gone := filepath.Join(t.TempDir(), "codeaf-that-moved")
- if err := os.WriteFile(gone, []byte("#!/bin/sh\n"), 0o755); err != nil {
- t.Fatalf("seed: %v", err)
- }
- stale, err := standing.NewWatch(standing.WatchOptions{
- Platform: "darwin", HomeDir: homeDir, Executable: gone, UID: 501, Runner: quietHost{},
- })
- if err != nil {
- t.Fatalf("NewWatch: %v", err)
- }
- if err := stale.Install(context.Background()); err != nil {
- t.Fatalf("Install: %v", err)
- }
- if err := os.Remove(gone); err != nil {
- t.Fatalf("remove: %v", err)
- }
-
- here := filepath.Join(t.TempDir(), "codeaf")
- if err := os.WriteFile(here, []byte("#!/bin/sh\n"), 0o755); err != nil {
- t.Fatalf("seed: %v", err)
- }
- live, err := standing.NewWatch(standing.WatchOptions{
- Platform: "darwin", HomeDir: homeDir, Executable: here, UID: 501, Runner: quietHost{},
- })
- if err != nil {
- t.Fatalf("NewWatch: %v", err)
- }
- if status, err := live.Status(); err != nil || status.Installed {
- t.Fatalf("a definition naming a dead program read as installed: %+v (%v)", status, err)
- }
- if line := repairBackgroundChecks(live, true); line == "" {
- t.Fatal("the launch left a timer pointing at nothing")
- }
- if status, err := live.Status(); err != nil || !status.Installed {
- t.Fatalf("the repair did not take: %+v (%v)", status, err)
- }
- // And a second launch over a definition that is now right does nothing.
- if line := repairBackgroundChecks(live, true); line != "" {
- t.Fatalf("a healthy timer was repaired anyway: %q", line)
- }
-}
-
-// quietHost is launchctl, stood in for.
-type quietHost struct{}
-
-func (quietHost) Run(context.Context, string, ...string) error { return nil }
-
-// THE ONE TICK CONSTRUCTOR WIRES THE THREE-WAY SENTINEL. v3StandingTicker is
-// the only place a pass is built, for a window and for the OS timer alike; a
-// pass that carried only the binary reader folded an ambiguous or refused reply
-// into "no" and closed an opportunity nobody decided. It must carry
-// SentinelVerdict, and the per-item pinned model is read inside the sentinel and
-// the runner from the item's own origin (session.standingPinnedConfig), so a
-// posture reloaded from the profile cannot re-route an item the person happened
-// to ratify under `--one-model`.
-func TestTheTickConstructorWiresTheThreeWaySentinel(t *testing.T) {
- state := t.TempDir()
- t.Setenv(home.EnvVar, state)
- keyless(t)
-
- store, err := standing.Open(home.Join("v3", "standing"))
- if err != nil {
- t.Fatalf("Open: %v", err)
- }
- ticker, release, err := v3StandingTicker(store)
- if err != nil {
- t.Fatalf("v3StandingTicker: %v", err)
- }
- if release != nil {
- defer release()
- }
- if ticker.SentinelVerdict == nil {
- t.Fatal("the real tick has no three-way sentinel")
- }
- if ticker.Runner == nil || ticker.Store == nil {
- t.Fatal("the real tick is missing its runner or store")
- }
- if ticker.DailyRailUSD != v3StandingDailyRail(state) {
- t.Fatal("the pass quotes a different daily rail than the door")
- }
-}
diff --git a/cmd/codeaf/chatv3_sweep.go b/cmd/codeaf/chatv3_sweep.go
index ecb52af996..0c38d8c1d6 100644
--- a/cmd/codeaf/chatv3_sweep.go
+++ b/cmd/codeaf/chatv3_sweep.go
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "strings"
"time"
"github.com/Agent-Field/codeaf/internal/guard"
@@ -36,12 +37,12 @@ const sweepLogName = "sweep.log"
// The call sits after successful openV3Process construction, so failed doors do
// not start housekeeping and every later process in the same binary gets a pass.
//
-// THE STANDING ROOT IS HANDED OVER RATHER THAN FOUND. The same pass reaps the
-// ambient side's own litter, an errand that came to nothing, a run that
-// delivered nothing, and internal/session is given the directory to walk so
-// that the rule can be pointed at a temp directory and proved. [v3StandingRoot]
-// is the one answer to where that is.
-var sweepHome func(context.Context, string, func(string)) = session.SweepHomeContext
+// THE ERRANDS ROOT IS HANDED OVER RATHER THAN FOUND. The same pass reaps home's
+// own litter, an errand that came to nothing, and internal/session is given the
+// directory to walk so that the rule can be pointed at a temp directory and
+// proved. [v3ErrandsRoot] is the one answer to where that is, and
+// [errandHeldByAutomation] keeps the errands an automation was saved from.
+var sweepHome func(context.Context, string, func(string) bool, func(string)) = session.SweepHomeContext
func (p *v3Process) startPlaceSweep() {
ctx, cancel := context.WithCancel(context.Background())
@@ -50,7 +51,7 @@ func (p *v3Process) startPlaceSweep() {
p.sweepDone = done
guard.Go("chatv3/sweep-home", func() {
defer close(done)
- sweepHome(ctx, v3StandingRoot(), func(line string) {
+ sweepHome(ctx, v3ErrandsRoot(), errandHeldByAutomation(), func(line string) {
if ctx.Err() == nil {
noteSweep(line)
}
@@ -66,6 +67,28 @@ func (p *v3Process) stopPlaceSweep() {
<-p.sweepDone
}
+// errandHeldByAutomation answers whether an errand's folder is where an
+// automation was asked: "open where it was asked" reads that conversation for
+// as long as the automation exists, so the sweep must not reap it. A store that
+// cannot be read holds every folder — when unsure, keep.
+func errandHeldByAutomation() func(dir string) bool {
+ autos := v3Automations()
+ if autos == nil || autos.Store == nil {
+ return func(string) bool { return true }
+ }
+ list, err := autos.Store.List()
+ if err != nil {
+ return func(string) bool { return true }
+ }
+ origins := make(map[string]bool, len(list))
+ for _, item := range list {
+ if transcript := strings.TrimSpace(item.Origin.Transcript); transcript != "" {
+ origins[filepath.Clean(filepath.Dir(transcript))] = true
+ }
+ }
+ return func(dir string) bool { return origins[filepath.Clean(dir)] }
+}
+
// noteSweep writes one line, and opens the file only when there is a line to
// write: a clean sweep, which is every sweep on a machine that is behaving,
// leaves nothing behind at all.
diff --git a/cmd/codeaf/chatv3_sweep_test.go b/cmd/codeaf/chatv3_sweep_test.go
index 618a3712fd..d4b6f6ee8f 100644
--- a/cmd/codeaf/chatv3_sweep_test.go
+++ b/cmd/codeaf/chatv3_sweep_test.go
@@ -28,7 +28,7 @@ func TestCloseJoinsPlaceSweepAndNextProcessStillSweeps(t *testing.T) {
t.Cleanup(func() { sweepHome = previousSweep })
var starts atomic.Int32
var destructive atomic.Int32
- sweepHome = func(ctx context.Context, _ string, _ func(string)) {
+ sweepHome = func(ctx context.Context, _ string, _ func(string) bool, _ func(string)) {
i := int(starts.Add(1)) - 1
if i >= len(passes) {
t.Errorf("unexpected sweep start %d", i+1)
diff --git a/cmd/codeaf/chatv3_window_law_test.go b/cmd/codeaf/chatv3_window_law_test.go
new file mode 100644
index 0000000000..aae72c8cae
--- /dev/null
+++ b/cmd/codeaf/chatv3_window_law_test.go
@@ -0,0 +1,95 @@
+package main
+
+import (
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "testing"
+)
+
+// ── THE LAW: ONLY A WINDOW ON ANOTHER MACHINE SAYS IT IS A WINDOW THERE ─────
+//
+// [remote.Hello.Window] makes the far machine hold automations presence for a
+// connection, and presence is a COUNT: the far clock runs while it is above
+// zero, and a window asks it whether anybody else is still open before it lets
+// a run in progress stop. So the field is said exactly once per window, on the
+// one connection that lives as long as the window does:
+//
+// - the ssh door and the relay door say it on their BOOT connection
+// (openChatV3Host, openChatV3At), and only there — a conversation opened
+// beside the first is the same window on another pipe, and saying it again
+// would count one window once per tab ([besideHello]);
+// - the local engine road says it NOWHERE. That window is a process on this
+// very machine and holds its own presence (chatv3_local.go's
+// keepAutomationsWindow), so the engine holding one too would count it
+// twice — and a window asking on its way out whether anybody else is still
+// open would find itself, and let a run stop without a word.
+//
+// The reading is the source, because the mistake this refuses — one more
+// `Window: true` in a hello literal — compiles, runs, and fails only as a clock
+// that keeps going with nobody in front of it.
+func TestOnlyTheDoorsToAnotherMachineSayTheirWindowIsAWindowThere(t *testing.T) {
+ for _, tc := range []struct {
+ file, function string
+ says int
+ }{
+ {"chatv3_host.go", "openChatV3Host", 1},
+ {"chatv3_host.go", "launchHello", 0},
+ {"chatv3_host.go", "besideHello", 0},
+ // The relay door dials twice inside one function: the boot connection,
+ // which says it, and the closure that opens a conversation beside it,
+ // which does not.
+ {"chatv3_at.go", "openChatV3At", 1},
+ {"chatv3_local.go", "", 0},
+ {"chatv3_beside.go", "", 0},
+ {"chatv3_taskowner.go", "", 0},
+ {"team_resume.go", "", 0},
+ } {
+ file, err := parser.ParseFile(token.NewFileSet(), tc.file, nil, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ said, found := 0, tc.function == ""
+ for _, decl := range file.Decls {
+ fn, ok := decl.(*ast.FuncDecl)
+ if !ok || fn.Body == nil || (tc.function != "" && fn.Name.Name != tc.function) {
+ continue
+ }
+ found = true
+ said += windowSaid(fn.Body)
+ }
+ if !found {
+ t.Errorf("%s has no function %s; this law names the door that says Window — move the law with it", tc.file, tc.function)
+ continue
+ }
+ where := tc.file
+ if tc.function != "" {
+ where += " " + tc.function
+ }
+ if said != tc.says {
+ t.Errorf("%s says Hello.Window %d times, want %d: a window is counted on the far machine once, on its boot connection, and only when that machine is another one", where, said, tc.says)
+ }
+ }
+}
+
+// windowSaid counts the places a body sets a hello's Window: a `Window:` key in
+// a composite literal, or an assignment to a `.Window` selector.
+func windowSaid(body *ast.BlockStmt) int {
+ said := 0
+ ast.Inspect(body, func(node ast.Node) bool {
+ switch n := node.(type) {
+ case *ast.KeyValueExpr:
+ if key, ok := n.Key.(*ast.Ident); ok && key.Name == "Window" {
+ said++
+ }
+ case *ast.AssignStmt:
+ for _, lhs := range n.Lhs {
+ if sel, ok := lhs.(*ast.SelectorExpr); ok && sel.Sel.Name == "Window" {
+ said++
+ }
+ }
+ }
+ return true
+ })
+ return said
+}
diff --git a/cmd/codeaf/collections.go b/cmd/codeaf/collections.go
index 5ae4bff53e..1361d1d8df 100644
--- a/cmd/codeaf/collections.go
+++ b/cmd/codeaf/collections.go
@@ -21,7 +21,7 @@ const collectionsUsage = collectionsSummary + `
codeaf collections show
codeaf collections add|remove
codeaf collections find
- kinds: collection, conversation, task, standing, artifact (a file path)
+ kinds: collection, conversation, task, artifact (a file path)
tasks need --session ; all accept --db and --json
organize references without moving files or starting work`
@@ -79,6 +79,12 @@ func runCollectionsTo(args []string, output io.Writer) error {
if err := ref.Validate(); err != nil {
return err
}
+ // A STANDING ITEM CAN BE FOUND AND TAKEN OUT, AND NEVER ADDED. The
+ // kind names nothing codeaf runs any more; a row that already holds one
+ // keeps it until somebody removes it ([workspace.StandingKind]).
+ if verb == "add" && ref.Kind == workspace.StandingKind {
+ return fmt.Errorf("%w: standing items are gone, so a collection cannot gain one", workspace.ErrInvalid)
+ }
} else if *sessionID != "" {
return fmt.Errorf("%w: --session belongs to a task reference in add, remove or find", workspace.ErrInvalid)
}
diff --git a/cmd/codeaf/collections_test.go b/cmd/codeaf/collections_test.go
index 0dcc859960..40c0eb3ac6 100644
--- a/cmd/codeaf/collections_test.go
+++ b/cmd/codeaf/collections_test.go
@@ -2,6 +2,7 @@ package main
import (
"bytes"
+ "context"
"encoding/json"
"errors"
"os"
@@ -291,3 +292,38 @@ func TestCollectionsNestedMembershipRoundTrip(t *testing.T) {
t.Fatal(members)
}
}
+
+// A STANDING ITEM IS NOT A KIND A COLLECTION GAINS ANY MORE. A row an older
+// build wrote that names one is still a row: it can be found and taken out.
+func TestCollectionsOnlyLetGoOfStandingItems(t *testing.T) {
+ root := t.TempDir()
+ t.Setenv("CODEAF_HOME", root)
+ db := filepath.Join(root, "v3", "collections.db")
+ ops := collectionCreate(t, db, "Ops")
+
+ var out bytes.Buffer
+ err := runCollectionsTo([]string{"add", ops.ID, "standing", "item-6am", "--db", db, "--json"}, &out)
+ if !errors.Is(err, workspace.ErrInvalid) || !strings.Contains(err.Error(), "standing items are gone") {
+ t.Fatalf("adding a standing item answered %v", err)
+ }
+
+ // The row an older build left behind.
+ store, err := workspace.Open(db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := store.Add(context.Background(), ops.ID, workspace.Ref{Kind: workspace.StandingKind, ID: "item-6am"}); err != nil {
+ t.Fatal(err)
+ }
+ _ = store.Close()
+
+ var holding []workspace.Collection
+ if err := json.Unmarshal([]byte(collectionCommand(t, db, "find", "standing", "item-6am")), &holding); err != nil || len(holding) != 1 {
+ t.Fatalf("the older row could not be found: %v %v", holding, err)
+ }
+ collectionCommand(t, db, "remove", ops.ID, "standing", "item-6am")
+ holding = nil
+ if err := json.Unmarshal([]byte(collectionCommand(t, db, "find", "standing", "item-6am")), &holding); err != nil || len(holding) != 0 {
+ t.Fatalf("the older row could not be taken out: %v %v", holding, err)
+ }
+}
diff --git a/cmd/codeaf/do.go b/cmd/codeaf/do.go
index ec567bfb9a..300a8858d4 100644
--- a/cmd/codeaf/do.go
+++ b/cmd/codeaf/do.go
@@ -1643,8 +1643,8 @@ func (w *settlementWatch) moved() (bool, error) {
// check answers the only question the watcher has: is this errand over?
//
// A command that was rejected is over immediately — the compiler asked
-// something, or drafted a charter, and neither has an answer coming in a
-// process with no one at the keyboard. Otherwise the errand is over when every
+// something, and no answer is coming in a process with no one at the
+// keyboard. Otherwise the errand is over when every
// node this session owns has stopped. Extensions are covered without a special
// case: a gate that buys another round splices before the node it is extending
// lands, so there is no instant at which the graph looks finished and is not.
@@ -1713,8 +1713,8 @@ func (w *settlementWatch) check() (headlessOutcome, bool, error) {
}
// refusalWords is what a rejected command has to say for itself. The receipt
-// the reconciler posted is the real answer — a compiler question, a charter
-// awaiting ratification — and the command's own result is the summary of it.
+// the reconciler posted is the real answer — a compiler question, say — and
+// the command's own result is the summary of it.
//
// Whatever it is, it goes through [plainWords] on the way out. A refusal that
// happened deep in the stack arrives here as everything that wrapped it, and
@@ -3706,6 +3706,11 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome,
// AN INTERRUPT MUST LAND THE RUN, NOT VANISH IT, the same way it must on the
// resident road: routed through the context, the supervisor stops launching,
// drains what is in flight, and what it reached is composed and printed.
+ // THE RULES ARE READ BEFORE THE RUN'S CLOCK STARTS. They are a birth fact
+ // of the run, and opening the store to read them is setup, not work: under
+ // a short --timeout the open alone could spend the whole deadline before a
+ // single node had started.
+ rules := doAlways(settings.ProfileDir, workspace)
signalled, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stopSignals()
ctx, cancel := context.WithTimeout(signalled, request.timeout)
@@ -3731,7 +3736,7 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome,
Work: seats.Work.Model,
Plan: seats.Plan.Model,
Check: seats.Check.Model,
- }, doStanding(workspace), completerFor, settings.Sources),
+ }, rules, completerFor, settings.Sources),
})
errand := runErrandOutcome(summary, workspace, bound)
// THE RUN'S OWN CLOCK, read off the context because the summary's word is
diff --git a/cmd/codeaf/do_always.go b/cmd/codeaf/do_always.go
new file mode 100644
index 0000000000..2ca6789500
--- /dev/null
+++ b/cmd/codeaf/do_always.go
@@ -0,0 +1,32 @@
+package main
+
+import (
+ "github.com/Agent-Field/codeaf/internal/session"
+ "github.com/Agent-Field/codeaf/internal/store"
+)
+
+// doAlways is the rules section a headless `codeaf do` run's workers close on:
+// the person's memories marked always that hold for the workspace the run edits
+// — their own, this machine's, and that project's — exactly the rules a
+// conversation opened in that folder would carry (internal/session's
+// memory_always.go).
+//
+// IT OPENS THE STORE FOR THE READ AND CLOSES IT AFTER. A headless run holds no
+// brain of its own, and one read at the start of the run is the whole of what it
+// needs from one: the rules are a birth fact of the run, read once.
+//
+// MEMORY OFF IS RULES OFF, and a store that will not open is no section either,
+// which is the same emptiness law every other birth section reads: a run with no
+// rule over it gets a brief with no section, never an empty heading.
+func doAlways(profileDir, workspace string) string {
+ path := v3MemoryPath(profileDir)
+ if path == "" {
+ return ""
+ }
+ brain, err := store.Open(path)
+ if err != nil {
+ return ""
+ }
+ defer brain.Close()
+ return session.AlwaysWorld(brain, session.MemoryOwners(v3ProjectKey(workspace)))
+}
diff --git a/cmd/codeaf/do_test.go b/cmd/codeaf/do_test.go
index 1e076b466e..ca53f468e2 100644
--- a/cmd/codeaf/do_test.go
+++ b/cmd/codeaf/do_test.go
@@ -1000,9 +1000,10 @@ func TestProductHomeMovesTheDefaultStore(t *testing.T) {
// sitting in the JSON `deliverable` field where a caller reads the answer.
//
// `codeaf do` IS the answer to that question. A person who typed the verb has
-// already chosen "once, not standing", so the classification is settled by the
-// surface before a model reads a word: the temporal route is never taken, and
-// the ask compiles, plans, runs and delivers exactly like any other errand.
+// already chosen one run, so the classification is settled by the surface
+// before a model reads a word — and since the v1 scheduler went, there is no
+// temporal route left to take at all: the ask compiles, plans, runs and
+// delivers exactly like any other errand.
func TestDoRunsAStandingSoundingAskOnceInsteadOfAskingToRatifyIt(t *testing.T) {
script := newScriptedBrain(t)
defer script.close()
@@ -1020,8 +1021,8 @@ func TestDoRunsAStandingSoundingAskOnceInsteadOfAskingToRatifyIt(t *testing.T) {
err, stdout.String(), stderr.String())
}
// The structural pin: the temporal compiler was never reached at all. Its
- // prompt is the only door to a charter draft on this path, and a headless
- // errand does not have that door.
+ // prompt was the only door to a charter draft on this path, and no build
+ // has that door any more.
if got := script.count("standing"); got != 0 {
t.Fatalf("the temporal compiler ran %d times on a one-shot errand, want 0", got)
}
@@ -1042,17 +1043,15 @@ func TestDoRunsAStandingSoundingAskOnceInsteadOfAskingToRatifyIt(t *testing.T) {
assertErrandIsHonest(t, outcome, nil)
}
-// Defense in depth for the same law. The surface fact settles the classification
-// in the compiler, but a charter draft can still arrive from a provider that
-// emitted the key uninvited or a compiler that is not the head's. A draft that
-// reaches a process with no keyboard is auto-resolved the way the caller already
-// chose — once, not standing — journaled as retired, and then the work RUNS.
-// The failure mode this replaces is the one that matters: exiting having done
+// Defense in depth for the same law. A provider can still emit a charter key
+// nobody asked for — a model that remembers the old answer shape — and there is
+// nothing left to read it: the compile decodes without it, and the work RUNS.
+// The failure mode this guards is the one that matters: exiting having done
// nothing.
-func TestDoResolvesAnUnexpectedCharterDraftAsOnceAndRunsTheWork(t *testing.T) {
+func TestDoIgnoresAnUninvitedCharterKeyAndRunsTheWork(t *testing.T) {
script := newScriptedBrain(t)
defer script.close()
- script.compileDraftsCharter = true
+ script.compileAddsCharterKey = true
script.gatePasses = true
var stdout, stderr strings.Builder
@@ -1063,15 +1062,15 @@ func TestDoResolvesAnUnexpectedCharterDraftAsOnceAndRunsTheWork(t *testing.T) {
stdout: &stdout, stderr: &stderr, newClient: script.client,
})
if err != nil {
- t.Fatalf("a charter draft ended the errand instead of being resolved: %v\nstderr:\n%s",
+ t.Fatalf("an uninvited charter key ended the errand: %v\nstderr:\n%s",
err, stderr.String())
}
outcome := decodeErrand(t, stdout.String())
if outcome.BlockedOn != "" {
- t.Fatalf("the ratification card reached the caller anyway: %q", outcome.BlockedOn)
+ t.Fatalf("a question reached the caller: %q", outcome.BlockedOn)
}
if script.count("draft") == 0 {
- t.Fatalf("the draft was resolved and the work still never ran:\n%s", stderr.String())
+ t.Fatalf("the work never ran:\n%s", stderr.String())
}
if !strings.Contains(outcome.Deliverable, firstDraftAnswer) {
t.Fatalf("the deliverable is not the work product: %q", outcome.Deliverable)
@@ -1393,10 +1392,11 @@ const (
unanswerableQuestion = "Which ledger is authoritative when the two disagree?"
)
-// scriptedCharter is what a temporal compiler answers with — and what the
+// scriptedCharter is what the temporal compiler answered with — and what the
// benchmark's two failing cells were handed for asks that were nothing of the
-// kind. The two-minute cadence is not invented here for colour; it is the
-// literal default standingWatch supplies when nobody stated a rhythm.
+// kind. The two-minute cadence was not invented for colour; it was the literal
+// default the head supplied when nobody stated a rhythm. No build asks for this
+// shape any more; it stays as what a provider remembering it would send.
const scriptedCharter = `{"invariant":"Reconcile bank_export.csv against ledger.csv for June 2026 and flag every discrepancy",` +
`"watch":{"kind":"poll","cadence":"about every 2 minutes","schedule":""},` +
`"sentinel":"Decide whether the ledgers have diverged.",` +
@@ -1445,10 +1445,10 @@ type scriptedBrain struct {
// leafCost is what each call reports spending, which is what the consent
// desk's estimate is built from.
leafCost float64
- // compileDraftsCharter makes the ORDINARY intent compiler hand back a
- // charter, which is the shape a provider emitting an uninvited key produces
- // — the case the structural pin cannot catch and the reconciler must.
- compileDraftsCharter bool
+ // compileAddsCharterKey makes the ORDINARY intent compiler hand back an
+ // ordinary brief with a charter key beside it — the shape a provider that
+ // remembers the old answer produces, which nothing may read any more.
+ compileAddsCharterKey bool
// compilerAsks makes the compiler stop on a question instead of compiling.
compilerAsks string
// compileBlankGoal makes the compiler answer a well-formed object with no
@@ -1517,7 +1517,6 @@ func newScriptedBrain(t *testing.T) *scriptedBrain {
t.Setenv("OPENROUTER_API_KEY", "test-key")
t.Setenv("CODEAF_PROFILE_DIR", script.dir)
t.Setenv("CODEAF_DAILY_BUDGET", "0")
- t.Setenv("CODEAF_PRACTICE_BUDGET", "0")
if os.Getenv("CODEAF_PLAN_CONSENT") == "" {
t.Setenv("CODEAF_PLAN_CONSENT", "0")
}
@@ -1615,8 +1614,9 @@ func (s *scriptedBrain) serve(writer http.ResponseWriter, request *http.Request)
func (s *scriptedBrain) reply(body string) string {
switch {
case strings.Contains(body, "You compile durable intent into one inert charter draft"):
- // Reaching this at all on a headless errand is the defect. The count is
- // the assertion; the answer is what the benchmark actually received.
+ // The temporal compiler's prompt. No build sends it any more, so
+ // reaching this at all is the defect coming back; the count is the
+ // assertion, and the answer is what the benchmark actually received.
s.tally("standing")
return s.say(scriptedCharter)
@@ -1630,11 +1630,9 @@ func (s *scriptedBrain) reply(body string) string {
return s.say(`{"title":"Release note and migration","scale":"task",` +
`"builds_on":[],"assumptions":[],"question":"","trial_of":0}`)
}
- if s.compileDraftsCharter {
- return s.say(`{"goal":"","scale":"task","builds_on":[],"assumptions":[],` +
- `"question":"Stand this rule up?","question_options":[` +
- `{"label":"yes, stand this up","value":"ratify"},` +
- `{"label":"once, not standing","value":"once"}],` +
+ if s.compileAddsCharterKey {
+ return s.say(`{"goal":"Reconcile the two ledgers and flag every discrepancy.","scale":"task",` +
+ `"builds_on":[],"assumptions":[],"question":"",` +
`"trial_of":0,"charter":` + scriptedCharter + `}`)
}
if s.plansAPipeline {
@@ -2451,18 +2449,15 @@ func TestASettledErrandLeavesNoNodeOfItsOwnStillRunning(t *testing.T) {
}
}
-// A one-shot schedules nothing for later. Self-practice is the resident's own
-// curiosity, and it was firing inside every headless run — writing a
-// practice-loop charter and its work into whatever store the run was pointed
-// at, including a person's own with --db. It costs almost nothing and it is not
-// the errand's, which is reason enough.
+// A one-shot schedules nothing for later. Self-practice was the resident's own
+// curiosity, and it once fired inside every headless run — writing its work
+// into whatever store the run was pointed at, including a person's own with
+// --db. The practice loop went with the v1 scheduler; this keeps the store an
+// errand leaves behind holding the errand and nothing of codeaf's own.
func TestAHeadlessErrandSchedulesNoPractice(t *testing.T) {
script := newScriptedBrain(t)
script.gatePasses = true
defer script.close()
- // The harness zeroes the practice budget for every other test here. This is
- // the one run that must prove the gate rather than the setting.
- t.Setenv("CODEAF_PRACTICE_BUDGET", "2")
database := filepath.Join(t.TempDir(), "graph.db")
var stdout, stderr strings.Builder
@@ -2478,16 +2473,6 @@ func TestAHeadlessErrandSchedulesNoPractice(t *testing.T) {
t.Fatal(err)
}
defer graph.Close()
- charters, err := graph.Charters()
- if err != nil {
- t.Fatal(err)
- }
- for _, charter := range charters {
- if strings.Contains(charter.ID, "practice") ||
- charter.ProposalShape == store.PracticeCharterShape {
- t.Fatalf("a one-shot errand stood up %q in the person's own store", charter.ID)
- }
- }
nodes, err := graph.SubtreeNodes(store.RootID)
if err != nil {
t.Fatal(err)
diff --git a/cmd/codeaf/doctor.go b/cmd/codeaf/doctor.go
index 76dd2823ca..ef1d939b3f 100644
--- a/cmd/codeaf/doctor.go
+++ b/cmd/codeaf/doctor.go
@@ -8,31 +8,31 @@ import (
"path/filepath"
"strconv"
"strings"
- "time"
"github.com/Agent-Field/codeaf/internal/calllog"
"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/env"
"github.com/Agent-Field/codeaf/internal/lease"
"github.com/Agent-Field/codeaf/internal/store"
- "github.com/Agent-Field/codeaf/internal/watchdog"
)
-type standingWatchStatus interface {
- Status() (watchdog.Status, error)
-}
-
+// doctorSnapshot is everything doctor reports.
+//
+// Two rows it used to carry are gone with the v1 resident's scheduler
+// (docs/design/automations/DESIGN.md): `background timer`, which read the
+// operating-system timer that scheduler installed, and `standing`, which
+// counted its charters beside the questions still waiting on an answer.
+// Nothing installs a timer any more and nothing makes a charter, so a row
+// about either could only ever read "not installed" and nothing — a reading
+// of machinery that is not there.
type doctorSnapshot struct {
- BrainPath string
- BrainSize int64
- BrainExists bool
- Resident string
- Watch watchdog.Status
- Spend float64
- Rail float64
- RailUnlimited bool
- ActiveCharters int
- PendingQuestions int
+ BrainPath string
+ BrainSize int64
+ BrainExists bool
+ Resident string
+ Spend float64
+ Rail float64
+ RailUnlimited bool
// CallLog is where the model-call log is and how big it has got
// (internal/calllog), or nothing when nothing has ever been written to it.
CallLog callLogReport
@@ -41,7 +41,6 @@ type doctorSnapshot struct {
// worth changing: the command exists to tell somebody why nothing works,
// and a missing key is the most common answer there is.
Key keyReport
- Now time.Time
}
// fallbackKeyEnv is the compatibility variable [config.Load] accepts for an
@@ -77,12 +76,12 @@ func runDoctor(args []string) error {
if err != nil {
return err
}
- return runDoctorWith(args, os.Stdout, dailyBudget, nil)
+ return runDoctorWith(args, os.Stdout, dailyBudget)
}
-// runDoctorWith is doctor with its one outside reading injectable: the standing
-// watch.
-func runDoctorWith(args []string, output io.Writer, dailyBudget float64, override standingWatchStatus) error {
+// runDoctorWith is doctor with where it writes and the day's rail handed in,
+// so a test reads the page without a profile behind it.
+func runDoctorWith(args []string, output io.Writer, dailyBudget float64) error {
flags := commandFlags("doctor")
database := flags.String("db", defaultChatDB(), storeFlagHelp)
if err := parseCommandFlags(flags, reorder(flags, args)); err != nil {
@@ -114,15 +113,7 @@ func runDoctorWith(args []string, output io.Writer, dailyBudget float64, overrid
return fmt.Errorf("inspect brain file: %w", statErr)
}
- watch := override
- if watch == nil {
- manager, managerErr := newStandingWatchManager(graph)
- if managerErr != nil {
- return managerErr
- }
- watch = manager
- }
- snapshot, err := collectDoctorSnapshot(path, graph, watch, dailyBudget, time.Now())
+ snapshot, err := collectDoctorSnapshot(path, graph, dailyBudget)
if err != nil {
return err
}
@@ -192,33 +183,17 @@ func readCallLogReport(path string) callLogReport {
return callLogReport{Path: path, Size: info.Size()}
}
-func newStandingWatchManager(graph *store.Store) (*watchdog.Manager, error) {
- var lastWake watchdog.LastWakeFunc
- if graph != nil {
- lastWake = graph.LastStandingWake
- }
- return watchdog.New(watchdog.Options{LastWake: lastWake})
-}
-
// collectDoctorSnapshot reads everything doctor reports.
-func collectDoctorSnapshot(path string, graph *store.Store, watch standingWatchStatus, dailyBudget float64,
- now time.Time) (doctorSnapshot, error) {
+func collectDoctorSnapshot(path string, graph *store.Store, dailyBudget float64) (doctorSnapshot, error) {
snapshot := doctorSnapshot{
BrainPath: path, Resident: readResident(residentLockFor(path)),
- Rail: dailyBudget, RailUnlimited: dailyBudget <= 0, Now: now,
+ Rail: dailyBudget, RailUnlimited: dailyBudget <= 0,
}
if info, err := os.Stat(path); err == nil {
snapshot.BrainExists, snapshot.BrainSize = true, info.Size()
} else if !os.IsNotExist(err) {
return doctorSnapshot{}, fmt.Errorf("inspect brain file: %w", err)
}
- if watch != nil {
- status, err := watch.Status()
- if err != nil {
- return doctorSnapshot{}, fmt.Errorf("the background timer's state is unavailable")
- }
- snapshot.Watch = status
- }
if graph == nil {
return snapshot, nil
}
@@ -227,16 +202,6 @@ func collectDoctorSnapshot(path string, graph *store.Store, watch standingWatchS
return doctorSnapshot{}, err
}
snapshot.Spend, snapshot.Rail, snapshot.RailUnlimited = rail.Spend, rail.Ceiling, rail.Unlimited
- charters, err := graph.ActiveCharters()
- if err != nil {
- return doctorSnapshot{}, err
- }
- snapshot.ActiveCharters = len(charters)
- questions, err := graph.UnresolvedQuestions(10000)
- if err != nil {
- return doctorSnapshot{}, err
- }
- snapshot.PendingQuestions = len(questions)
return snapshot, nil
}
@@ -245,25 +210,6 @@ func formatDoctor(snapshot doctorSnapshot) string {
if snapshot.BrainExists {
brain = snapshot.BrainPath + " · " + humanBytes(snapshot.BrainSize)
}
- watch := "not installed"
- if snapshot.Watch.Installed {
- watch = "installed"
- }
- if snapshot.Watch.LastWake.IsZero() {
- watch += " · last wake not yet"
- } else {
- watch += " · last wake " + relativePast(snapshot.Watch.LastWake, snapshot.Now)
- }
- if snapshot.Watch.Installed && !snapshot.Watch.NextDue.IsZero() {
- watch += " · next check " + relativeFuture(snapshot.Watch.NextDue, snapshot.Now)
- }
- // An arranged watch whose checks stopped landing is the one failure the
- // user cannot see from the outside, so doctor says it rather than reading
- // healthy while nothing has woken for several cadences.
- if snapshot.Watch.Installed && !snapshot.Watch.LastWake.IsZero() &&
- snapshot.Now.Sub(snapshot.Watch.LastWake) > 3*watchdog.Interval {
- watch += " · checks look stalled"
- }
// THE EMPTINESS LAW ON THE ONE PAGE PEOPLE OPEN WHEN NOTHING WORKS. A
// machine that has not spent anything today has not measured zero — it has
// not measured — and `$0.00 today` beside a rail reads as a machine that
@@ -276,32 +222,12 @@ func formatDoctor(snapshot doctorSnapshot) string {
if today := config.SpentFigure(snapshot.Spend); today != "" {
spend = today + " today · " + rail
}
- // The same law on the counts beside it: no charters and no questions is
- // nothing to say, not two zeros.
- var standingParts []string
- if snapshot.ActiveCharters > 0 {
- standingParts = append(standingParts, fmt.Sprintf("%d active %s",
- snapshot.ActiveCharters, pluralWord(snapshot.ActiveCharters, "charter")))
- }
- if snapshot.PendingQuestions > 0 {
- standingParts = append(standingParts, fmt.Sprintf("%d pending %s",
- snapshot.PendingQuestions, pluralWord(snapshot.PendingQuestions, "question")))
- }
- // ── TWO LABELS THAT NAMED THE MACHINERY AND NOT THE MEASUREMENT ─────────
+ // ── A LABEL THAT NAMED THE MACHINERY AND NOT THE MEASUREMENT ─────────────
//
// `brain` was this row's word for the file the journal and every derived
// table live in. Nobody looking for where their data is searches for
// *brain*, and `--db`'s own help already called the same file a store.
//
- // `standing watch` was the row about the background timer, and it is the
- // RESIDENT's vocabulary — a different product in this binary, with a corpus
- // of its own. A test forbids the chat's manual from speaking that word, so
- // the manual could not quote doctor's own output and stay legal: the page
- // had to describe the row in other words and hope a reader recognised it.
- // The label moved and the ban stayed. What this row measures, in a
- // developer's words, is what is running, since when, and whether it still
- // answers — which is a background timer, and says so.
- //
// ── AND THE ROW THAT WAS NOT THERE AT ALL ───────────────────────────────
//
// Doctor is the command somebody runs when nothing works, and on a machine
@@ -309,15 +235,11 @@ func formatDoctor(snapshot doctorSnapshot) string {
// with 0 — saying nothing about the single most common reason nothing
// works. The key row goes FIRST because it is the answer to the question
// the command was opened with.
- block := fmt.Sprintf("%-16s %s\n%-16s %s\n%-16s %s\n%-16s %s\n%-16s %s\n",
+ block := fmt.Sprintf("%-16s %s\n%-16s %s\n%-16s %s\n%-16s %s\n",
"key", formatKey(snapshot.Key),
"store", brain,
"resident", snapshot.Resident,
- "background timer", watch,
"spend", spend)
- if standing := strings.Join(standingParts, " · "); standing != "" {
- block += fmt.Sprintf("%-16s %s\n", "standing", standing)
- }
if line := formatCallLog(snapshot.CallLog); line != "" {
block += fmt.Sprintf("%-16s %s\n", "model calls", line)
}
@@ -409,31 +331,3 @@ func humanBytes(size int64) string {
}
return fmt.Sprintf("%d B", size)
}
-
-func relativePast(then, now time.Time) string {
- if then.After(now) {
- return "just now"
- }
- delta := now.Sub(then)
- switch {
- case delta < time.Minute:
- return "just now"
- case delta < time.Hour:
- return fmt.Sprintf("%dm ago", int(delta/time.Minute))
- case delta < 24*time.Hour:
- return fmt.Sprintf("%dh ago", int(delta/time.Hour))
- default:
- return then.Local().Format("2006-01-02 15:04")
- }
-}
-
-func relativeFuture(then, now time.Time) string {
- if !then.After(now) {
- return "now"
- }
- delta := then.Sub(now)
- if delta < time.Minute {
- return "in less than a minute"
- }
- return fmt.Sprintf("in %dm", int((delta+time.Minute-1)/time.Minute))
-}
diff --git a/cmd/codeaf/doctor_test.go b/cmd/codeaf/doctor_test.go
index c18c009468..960f2ec6e2 100644
--- a/cmd/codeaf/doctor_test.go
+++ b/cmd/codeaf/doctor_test.go
@@ -6,20 +6,13 @@ import (
"path/filepath"
"strings"
"testing"
- "time"
"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/env"
"github.com/Agent-Field/codeaf/internal/home"
"github.com/Agent-Field/codeaf/internal/store"
- "github.com/Agent-Field/codeaf/internal/watchdog"
)
-type fakeDoctorWatch struct {
- status watchdog.Status
- err error
-}
-
// H8: help env lists the current names and says exactly once that legacy
// spellings remain a one-release fallback; doctor names the resolved root and
// never recommends the former HOME variable.
@@ -36,7 +29,7 @@ func TestH8EnvironmentHelpAndDoctorUseCurrentNames(t *testing.T) {
root := t.TempDir()
t.Setenv(home.EnvVar, root)
var output bytes.Buffer
- if err := runDoctorWith(nil, &output, 0, fakeDoctorWatch{}); err != nil {
+ if err := runDoctorWith(nil, &output, 0); err != nil {
t.Fatal(err)
}
if !strings.Contains(output.String(), root) {
@@ -47,8 +40,6 @@ func TestH8EnvironmentHelpAndDoctorUseCurrentNames(t *testing.T) {
}
}
-func (watch fakeDoctorWatch) Status() (watchdog.Status, error) { return watch.status, watch.err }
-
func TestDoctorShowsSharedCalmStatusRows(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "graph.db")
@@ -56,17 +47,6 @@ func TestDoctorShowsSharedCalmStatusRows(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- charter, err := store.NewCharter("doctor-charter", "Keep releases documented.", store.WatchSpec{
- Kind: store.WatchPoll, Poll: &store.PollWatch{Condition: "look for releases", Cadence: time.Hour},
- }, "Did a release land?", store.CharterAction{Template: "Update release notes"},
- store.CharterRails{PerFiringBudgetUSD: 0.1, MaxFiringsPerDay: 3}, store.CharterActive,
- store.Ratification{Origin: store.OriginUser, SessionID: "doctor", Evidence: "yes"})
- if err != nil {
- t.Fatal(err)
- }
- if err := graph.CreateCharter(charter); err != nil {
- t.Fatal(err)
- }
if _, err := graph.AskQuestion(store.AgentQuestion{
SessionID: "doctor", Text: "Which release?", Urgency: store.QuestionWhenever,
}); err != nil {
@@ -84,30 +64,29 @@ func TestDoctorShowsSharedCalmStatusRows(t *testing.T) {
t.Fatal(err)
}
- now := time.Now()
- watch := fakeDoctorWatch{status: watchdog.Status{
- Installed: true, LastWake: now.Add(-2 * time.Minute), NextDue: now.Add(3 * time.Minute),
- }}
var output bytes.Buffer
- if err := runDoctorWith([]string{"--db", path}, &output, 20, watch); err != nil {
+ if err := runDoctorWith([]string{"--db", path}, &output, 20); err != nil {
t.Fatal(err)
}
text := output.String()
for _, want := range []string{
- // `store` and `background timer` were `brain` and `standing watch`.
- // Nobody looking for where their data lives searches for a brain, and
- // `standing watch` is the RESIDENT's vocabulary — a word the chat's own
- // manual is forbidden to use, so the manual could not quote this row
- // and stay legal. What the row measures, in a developer's words, is
- // what is running, since when, and whether it still answers.
- "store", path, "desktop · pid 4321", "background timer", "installed",
- "last wake 2m ago", "next check in 3m", "$3.40 today · rail $20.00",
- "1 active charter · 1 pending question",
+ // `store` was `brain`: nobody looking for where their data lives
+ // searches for a brain.
+ "store", path, "desktop · pid 4321", "$3.40 today · rail $20.00",
} {
if !strings.Contains(text, want) {
t.Fatalf("doctor output missing %q:\n%s", want, text)
}
}
+ // The two rows that read the v1 scheduler are gone with it: the timer it
+ // installed (`background timer`, once `standing watch`) and the count of
+ // its charters (`standing`). A row about machinery that is not there could
+ // only ever have said so.
+ for _, gone := range []string{"background timer", "last wake", "next check", "standing", "charter"} {
+ if strings.Contains(text, gone) {
+ t.Fatalf("doctor still reports the removed scheduler (%q):\n%s", gone, text)
+ }
+ }
for _, forbidden := range []string{"daemon", "launchd", "systemd", "service", "brain", "standing watch"} {
if strings.Contains(strings.ToLower(text), forbidden) {
t.Fatalf("doctor output contains %q:\n%s", forbidden, text)
@@ -115,41 +94,10 @@ func TestDoctorShowsSharedCalmStatusRows(t *testing.T) {
}
}
-func TestDoctorSaysWhenAnArrangedWatchStoppedWaking(t *testing.T) {
- path := filepath.Join(t.TempDir(), "graph.db")
- graph, err := store.Open(path)
- if err != nil {
- t.Fatal(err)
- }
- if err := graph.Close(); err != nil {
- t.Fatal(err)
- }
- now := time.Now()
- var output bytes.Buffer
- if err := runDoctorWith([]string{"--db", path}, &output, 0, fakeDoctorWatch{status: watchdog.Status{
- Installed: true, LastWake: now.Add(-4 * time.Hour), NextDue: now.Add(time.Minute),
- }}); err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(output.String(), "checks look stalled") {
- t.Fatalf("stalled watch read as healthy:\n%s", output.String())
- }
-
- output.Reset()
- if err := runDoctorWith([]string{"--db", path}, &output, 0, fakeDoctorWatch{status: watchdog.Status{
- Installed: true, LastWake: now.Add(-2 * time.Minute), NextDue: now.Add(3 * time.Minute),
- }}); err != nil {
- t.Fatal(err)
- }
- if strings.Contains(output.String(), "look stalled") {
- t.Fatalf("healthy watch reported as stalled:\n%s", output.String())
- }
-}
-
func TestDoctorDoesNotCreateMissingBrainAndDegradesResidentCalmly(t *testing.T) {
path := filepath.Join(t.TempDir(), "missing", "graph.db")
var output bytes.Buffer
- if err := runDoctorWith([]string{"--db", path}, &output, 0, fakeDoctorWatch{}); err != nil {
+ if err := runDoctorWith([]string{"--db", path}, &output, 0); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
@@ -157,7 +105,7 @@ func TestDoctorDoesNotCreateMissingBrainAndDegradesResidentCalmly(t *testing.T)
}
text := output.String()
for _, want := range []string{
- path + " · not created", "this terminal while open", "not installed",
+ path + " · not created", "this terminal while open",
// A machine that has spent nothing and holds nothing standing says so
// by leaving those figures out: the rail is the only claim here that
// anybody made (the emptiness law, emptiness_test.go).
@@ -181,7 +129,7 @@ func TestDoctorSaysThereIsNoKeyAndWhatToTypeAboutIt(t *testing.T) {
path := filepath.Join(t.TempDir(), "graph.db")
var output bytes.Buffer
- if err := runDoctorWith([]string{"--db", path}, &output, 0, fakeDoctorWatch{}); err != nil {
+ if err := runDoctorWith([]string{"--db", path}, &output, 0); err != nil {
t.Fatal(err)
}
text := output.String()
@@ -230,7 +178,7 @@ func TestDoctorNamesWhereTheKeyCameFrom(t *testing.T) {
var output bytes.Buffer
if err := runDoctorWith([]string{"--db", filepath.Join(t.TempDir(), "graph.db")},
- &output, 0, fakeDoctorWatch{}); err != nil {
+ &output, 0); err != nil {
t.Fatal(err)
}
line := rowSaying(output.String(), "key")
diff --git a/cmd/codeaf/emptiness_test.go b/cmd/codeaf/emptiness_test.go
index 64706b4f43..de948f0042 100644
--- a/cmd/codeaf/emptiness_test.go
+++ b/cmd/codeaf/emptiness_test.go
@@ -9,7 +9,6 @@ import (
"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/store"
- "github.com/Agent-Field/codeaf/internal/watchdog"
)
// THE EMPTINESS LAW ON THE LAST LINE OF A HEADLESS RUN.
@@ -68,8 +67,8 @@ func TestTheFooterWritesASpendTheWayEverythingElseDoes(t *testing.T) {
// `codeaf doctor` is what somebody runs when nothing works, and on a fresh
// machine it read as a machine that had measured zero rather than one that had
-// not measured: `$0.00 today · rail $500.00` and `0 active charters · 0 pending
-// questions`.
+// not measured: `$0.00 today · rail $500.00` (and, beside it then, a count of
+// charters and questions that has since gone with the v1 scheduler).
func TestDoctorDoesNotPrintAZeroItNeverMeasured(t *testing.T) {
path := filepath.Join(t.TempDir(), "graph.db")
graph, err := store.Open(path)
@@ -80,7 +79,7 @@ func TestDoctorDoesNotPrintAZeroItNeverMeasured(t *testing.T) {
t.Fatal(err)
}
var output bytes.Buffer
- if err := runDoctorWith([]string{"--db", path}, &output, 500, fakeDoctorWatch{status: watchdog.Status{}}); err != nil {
+ if err := runDoctorWith([]string{"--db", path}, &output, 500); err != nil {
t.Fatal(err)
}
printed := output.String()
diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go
index 2577e8011c..206c9efc25 100644
--- a/cmd/codeaf/engine.go
+++ b/cmd/codeaf/engine.go
@@ -57,7 +57,6 @@ import (
"github.com/Agent-Field/codeaf/internal/enginehost"
"github.com/Agent-Field/codeaf/internal/remote"
"github.com/Agent-Field/codeaf/internal/session"
- "github.com/Agent-Field/codeaf/internal/standing"
"github.com/Agent-Field/codeaf/internal/store"
)
@@ -1008,27 +1007,22 @@ func bootEngine(hello remote.Hello, workspaceFlag, sessionFlag string) (*remote.
// assembly fills for every door.
cfg, open := v3Shape(cfg, v3LanesOverWire())
- // AND THE AMBIENT SIDE IS ON, which is the one capability on this list that
- // a connection does not take away. It arrives already filled, from the
+ // AND AUTOMATIONS ARE ON, which is the one capability on this list that a
+ // connection does not take away. The store arrives already filled, from the
// shared assembly every v3 door goes through (chatv3.go's [openV3Launch]
- // sets Config.Standing and starts this process ticking), and it is left
- // alone here rather than rebuilt — one source of truth about where the store
- // lives and what a pass may do.
+ // sets Config.Automations), and it is left alone here rather than rebuilt —
+ // one source of truth about where the store lives.
//
- // IT IS SAFE BECAUSE THE ENGINE IS THE MACHINE. Everything the two
- // capabilities above lack is present here: the store is a directory under
- // THIS machine's CODEAF_HOME ([v3StandingRoot]), a firing runs under THIS
- // machine's profile rules (chatv3_standing.go's header states that law), the
- // OS timer a first yes offers to install is THIS machine's timer, and the
- // work an item does happens where the workspace is. And the card travels a
- // road the two above do not: the standing proposal crosses as an ordinary
- // event on the turn's own stream (internal/remote's EventWire) and the
- // answer crosses back as ResolveStanding, so the person sitting on the other
- // end of this wire is the person who says yes. Nobody being there at that
- // moment no longer loses it either — a proposal raised with no surface
- // attached is held and handed to the next one (internal/remote's held.go).
- // A session that could leave nothing behind over --host would have made the
- // ambient side a property of which terminal somebody happened to open.
+ // IT IS SAFE BECAUSE THE ENGINE IS THE MACHINE. The store is a directory
+ // under THIS machine's CODEAF_HOME ([automationsRoot]), a run happens under
+ // THIS machine's profile rules and in the workspace that is here, and the
+ // clock that runs it is THIS machine's, kept going while a window is
+ // attached ([engineAutomations]). The card travels as an ordinary event on
+ // the turn's own stream (internal/remote's EventWire) and the answer crosses
+ // back through the one question door, so the person sitting on the other end
+ // of this wire is the person who says yes. A session that could leave
+ // nothing behind over --host would have made automations a property of
+ // which terminal somebody happened to open.
// The builder came out of the shape above with the adaptive runner already
// decided: "runs are on" and "build it through [v3OpenSession]" are one fact
@@ -1173,18 +1167,6 @@ func bootEngine(hello remote.Hello, workspaceFlag, sessionFlag string) (*remote.
proc.track(replacement)
return replacement, statErr == nil, nil
},
- // The engine machine's ambient side, as a remote surface reads it, off
- // the SAME store this session proposes into. What a surface does with
- // them is the surface's business and is stated where it wires them
- // (chatv3_host.go's [hostStanding]: over --host the live reader is the
- // status line and home's far project bands). They are closures on the store rather
- // than the store itself for [tui3.StandingSeam]'s own reason — the door
- // owns where it lives and how it is opened — and they are absent
- // entirely when the ambient side could not be built, which the surface
- // reads as nothing to show rather than as an empty list.
- StandingItems: engineStandingItems(cfg.Standing),
- StandingSave: engineStandingSave(cfg.Standing),
- StandingWatch: engineStandingWatch(cfg.Standing),
// ── THE PLACES, AS THIS MACHINE HOLDS THEM ──────────────────────
//
// The world under THIS machine's state root, and the root it was walked
@@ -1251,9 +1233,35 @@ func bootEngine(hello remote.Hello, workspaceFlag, sessionFlag string) (*remote.
// over a connection would be a second law about one layout.
return session.RecentSessions(launch.Bucket, v3RecentSessionSlots)
},
+ // ── THE AUTOMATIONS, AS THIS MACHINE KEEPS AND RUNS THEM ────────────
+ //
+ // The store this conversation's `automation` tool proposes into, read
+ // and changed by a window over --host or --at, and the presence such a
+ // window holds HERE while it is attached — which is what keeps this
+ // machine's clock running for somebody sitting at another one
+ // ([engineAutomations]).
+ Automations: engineAutomations(),
}, nil
}
+// engineAutomations is this machine's automations as the wire serves them to a
+// window on another machine: the store this process opened once, the zone its
+// conversations read a rhythm in, and the same door a local window holds its own
+// presence through, so an attached window and a window on this machine are one
+// kind of thing to the clock that counts them (chatv3_clock.go).
+//
+// NIL IS A STORE THAT COULD NOT BE OPENED, and it is never a hook whose doors
+// fail: the welcome then says nothing, a window over the connection draws no
+// automations, and the conversation never had the `automation` verb either — a
+// capability that cannot work is absent, not broken.
+func engineAutomations() *remote.EngineAutomations {
+ autos := v3Automations()
+ if autos == nil || autos.Store == nil {
+ return nil
+ }
+ return &remote.EngineAutomations{Store: autos.Store, Zone: autos.Zone, HoldWindow: keepAutomationsWindow}
+}
+
// engineLaunchOptions is the hello, as the shared assembly takes it.
//
// A NIL SHAPE IS THE ENGINE'S DEFAULTS, which is what every remote surface
@@ -1317,37 +1325,6 @@ func closeEngineProcess() {
}
}
-// engineStandingItems and engineStandingSave are the two standing doors, or nil.
-//
-// NIL IS THE AMBIENT SIDE OFF AND IT IS NEVER A CLOSURE THAT FAILS, which is
-// the same reading [v3Standing] already asks every caller for: an engine with no
-// store hands the surface nothing, the surface draws no band, and the model
-// never had the `stand` verb either. A pair of closures that answered an error
-// on every call would be a capability that is present and broken.
-func engineStandingItems(seam *session.Standing) func(string) ([]standing.Item, error) {
- if seam == nil || seam.Store == nil {
- return nil
- }
- return seam.Store.ForWorkspace
-}
-
-func engineStandingSave(seam *session.Standing) func(standing.Item) error {
- if seam == nil || seam.Store == nil {
- return nil
- }
- return seam.Store.Save
-}
-
-func engineStandingWatch(seam *session.Standing) func() (standing.WatchStatus, bool) {
- if seam == nil || seam.Watch == nil {
- return nil
- }
- return func() (standing.WatchStatus, bool) {
- status, err := seam.Watch.Status()
- return status, err == nil
- }
-}
-
// engineWorkspace resolves the directory the surface asked for.
//
// EMPTY IS THE HOME DIRECTORY and a relative path is relative to it — NOT to the
diff --git a/cmd/codeaf/exec_test.go b/cmd/codeaf/exec_test.go
index 7a72c0c6a4..fb1064d879 100644
--- a/cmd/codeaf/exec_test.go
+++ b/cmd/codeaf/exec_test.go
@@ -439,7 +439,8 @@ func TestExecJSONSaysWhyTheRunFailed(t *testing.T) {
// A WALL UNDER A SECOND IS STILL A WALL. `--timeout` is a duration on every
// door that has one, and two of them used to fold it down to a whole number of
-// seconds and multiply it back up. `--timeout 500ms` truncated to zero, and a
+// seconds and multiply it back up (the other was `codeaf wake`, whose pass went
+// with the v1 scheduler). `--timeout 500ms` truncated to zero, and a
// zero wall is no wall at all — so the run was handed the FULL DEFAULT, the
// opposite of what was typed, and `1500ms` quietly became one second.
//
@@ -449,7 +450,7 @@ func TestExecJSONSaysWhyTheRunFailed(t *testing.T) {
// not a guard, so the comparison is made on a form the author's formatting
// cannot vary.
func TestAWallUnderASecondIsNotRoundedAwayOnAnyDoor(t *testing.T) {
- for _, source := range []string{"exec.go", "wake.go"} {
+ for _, source := range []string{"exec.go"} {
parsed, err := parser.ParseFile(token.NewFileSet(), source, nil, 0)
if err != nil {
t.Fatal(err)
diff --git a/cmd/codeaf/helpwidth_test.go b/cmd/codeaf/helpwidth_test.go
index 8fc26378f4..3d8a78421d 100644
--- a/cmd/codeaf/helpwidth_test.go
+++ b/cmd/codeaf/helpwidth_test.go
@@ -123,7 +123,6 @@ func helpPages(t *testing.T) []helpPage {
{"chat", runChatV3},
{"logs", runLogs},
{"doctor", runDoctor},
- {"wake", runWake},
{"why", runWhy},
{"cache", runCache},
{"devices", runDevices},
diff --git a/cmd/codeaf/jobplans_journal_test.go b/cmd/codeaf/jobplans_journal_test.go
index 1468445b48..f4fe080b43 100644
--- a/cmd/codeaf/jobplans_journal_test.go
+++ b/cmd/codeaf/jobplans_journal_test.go
@@ -266,8 +266,9 @@ func TestASettledEndingReachesTheStoreAsAnEvent(t *testing.T) {
}
}
-// The bounded wake pass has no store behind its registry. Losing that journal
-// must lose only a later restart's accuracy, never the in-memory leaf ending.
+// A registry built for one bounded pass has no store behind it. Losing that
+// journal must lose only a later restart's accuracy, never the in-memory leaf
+// ending.
func TestARegistryWithNoStoreStillSettlesTheNode(t *testing.T) {
plans := newJobPlans(nil)
document := &plan.Graph{Goal: "build the thing", NextID: 2, Nodes: []plan.Node{{
diff --git a/cmd/codeaf/lanepin_doors_test.go b/cmd/codeaf/lanepin_doors_test.go
index fa9e68b666..6b82888e2a 100644
--- a/cmd/codeaf/lanepin_doors_test.go
+++ b/cmd/codeaf/lanepin_doors_test.go
@@ -181,14 +181,13 @@ func TestNoDoorResolvesTheLaneRowForItself(t *testing.T) {
// somebody measures where their work went.
func TestEveryDoorReachesTheLaneRowsThroughTheProfileLoad(t *testing.T) {
doors := map[string]string{
- "chat.go": "the brain `codeaf do` builds",
- "exec.go": "codeaf exec",
- "main.go": "codeaf plan new and codeaf plan revise",
- "run.go": "codeaf run and codeaf plan run",
- "subharness_run.go": "codeaf run subharness",
- "wake.go": "codeaf wake, the resident's own pass",
- "chatv3_standing.go": "the standing pass a codeaf window and `codeaf tick` both run",
- "chatv3_process.go": "the conversation",
+ "chat.go": "the brain `codeaf do` builds",
+ "exec.go": "codeaf exec",
+ "main.go": "codeaf plan new and codeaf plan revise",
+ "run.go": "codeaf run and codeaf plan run",
+ "subharness_run.go": "codeaf run subharness",
+ "chatv3_process.go": "the conversation",
+ "chatv3_clock.go": "codeaf clock, the process that runs automations",
}
for name, door := range doors {
raw, err := os.ReadFile(name)
diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go
index 289f3036db..c15f419fcd 100644
--- a/cmd/codeaf/main.go
+++ b/cmd/codeaf/main.go
@@ -26,6 +26,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
+ "github.com/Agent-Field/codeaf/internal/automation"
"github.com/Agent-Field/codeaf/internal/calllog"
"github.com/Agent-Field/codeaf/internal/codexauth"
"github.com/Agent-Field/codeaf/internal/config"
@@ -44,6 +45,10 @@ import (
func main() {
home.Adopt(log.Printf)
+ // CODEAF SCHEDULES NOTHING ON THE MACHINE. The timers the feature that
+ // automations replaced installed are taken off this login on every start,
+ // whoever installed them (internal/automation's legacy.go).
+ automation.RemoveOldTimers()
registerRunningCLI()
os.Exit(execute())
}
@@ -353,14 +358,23 @@ func run() error {
case "services":
return runServices(os.Args[2:])
case "wake":
+ // A verb that does nothing, kept for the operating-system timers an
+ // older build installed, which go on running it until this build's
+ // first start removes them (wake.go). It is DELIBERATELY ABSENT from
+ // the usage text below for the same reason `engine` is: nobody types
+ // it, and the one thing it must never do is exit 1.
return runWake(os.Args[2:])
case "tick":
- // One bounded pass over the standing items — the reminders, watches and
- // routines a conversation left behind (tick.go). It is what the OS
- // timer runs, and it is DELIBERATELY ABSENT from the usage text below
- // for the same reason `engine` is: it draws nothing, asks nothing, and
- // on an ordinary machine prints nothing at all.
- return runTick(os.Args[2:])
+ // THE OLD TIMER'S VERB, KEPT SO IT ENDS QUIETLY. An operating-system
+ // timer installed by an older build still runs `codeaf tick` until this
+ // build's first start takes it off the machine (main above does that
+ // first); it is answered with nothing rather than an unknown verb.
+ return nil
+ case "clock":
+ // The one process that runs automations while a window is open
+ // (chatv3_clock.go). A window starts it; it is machinery, and like
+ // `engine` it is absent from the usage text.
+ return runClock(os.Args[2:])
case "doctor":
return runDoctor(os.Args[2:])
case "logs":
@@ -559,8 +573,6 @@ Housekeeping — changes state on disk or on the network
codeaf services [--db path]
long-running processes it was asked to keep
codeaf services stop [--db path]
- codeaf wake [--db path] [--timeout 2m]
- run one full background pass by hand and exit
codeaf patch FILE --old TEXT --new TEXT | codeaf doc PATH [--pages A-B]
codeaf web fetch URL | web search QUERY | codeaf image "PROMPT" --out PATH
codeaf plandb [--db path] [--json]
@@ -663,10 +675,6 @@ A variable that pins a ` + "`/settings`" + ` row wins over that row.
CODEAF_VIDEO_MODEL video-generation model (catalog-resolved by default)
CODEAF_VISION_MODEL image-inspection proxy (talk, work, catalog-resolved)
CODEAF_DOC_ENGINE auto (default), local, free or ocr document reading
- CODEAF_PRACTICE_BUDGET
- ` + usageDollars(config.DefaultPracticeBudgetUSD) + ` daily self-practice carve-out (0 = disabled)
- CODEAF_PRACTICE_IDLE 20m quiet period before self-practice
- CODEAF_BRIEF_AFTER 4h minimum absence before an arrival brief (0 = always)
CODEAF_MAX_HOURS how many hours an unattended chat --yolo session may
carry its own work on (default none: it stops when the
model stops); the window closes itself ` + strconv.Itoa(int(launchWallGrace/time.Minute)) + ` minutes
diff --git a/cmd/codeaf/resident_build.go b/cmd/codeaf/resident_build.go
index 32465efb0f..c9ec84cd65 100644
--- a/cmd/codeaf/resident_build.go
+++ b/cmd/codeaf/resident_build.go
@@ -13,10 +13,10 @@ import (
"github.com/Agent-Field/codeaf/internal/store"
)
-// newResidentReconciler assembles the store-driven resident role shared by an
-// interactive chat owner and a bounded wake pass. Surface concerns are added
-// by chat after this returns; wake deliberately has no session-open or arrival
-// brief side effects.
+// newResidentReconciler assembles the store-driven resident role `codeaf do`
+// runs its errand on. Surface concerns are added by the caller after this
+// returns. (A bounded `codeaf wake` pass shared it once; that pass went with
+// the v1 scheduler.)
// The three clients divide by the kind of call: chatClient talks (compiling,
// distilling, titling, narrating — the resident's own small verdicts),
// planClient structures (the task graph, replans, contracts, the delivery
@@ -25,8 +25,8 @@ import (
// oneShotErrand says this reconciler serves `codeaf do`: one errand, run once,
// with nobody who could answer a question about it. It is a fact about the
// surface, not a judgement about the work, and it travels to both halves that
-// would otherwise have to guess it — the compiler's temporal classification and
-// the reconciler's charter draft.
+// would otherwise have to guess it — the compiler's prompt and the
+// reconciler's handling of the ask's own words.
//
// It is the only signal the verbatim law needs, and it is the right one: the
// compile stage is unchanged for everybody, and the reconciler that applies the
@@ -70,12 +70,9 @@ func newResidentReconciler(settings config.Config, graph *store.Store,
WithConsolidator(consolidateFacts(settings, chatClient, graph)).
WithTitler(titleGoal(settings, chatClient)).
WithReflector(reflectAcrossJobs(settings, chatClient, graph)).
- WithCharterProposals().
WithTerritoryDigester(digestTerritory(settings, chatClient)).
- WithWatchEngine(settings.DailyBudgetUSD, checkSentinel(settings, chatClient)).
WithOverrunPlanner(settings.DailyBudgetUSD, replanRemainder(settings, planClient, taskClient, plans, graph, terrainRoot)).
- WithCancelRethink(rethinkAfterCancel(settings, planClient, plans, graph)).
- WithPracticeLoop(settings.PracticeBudgetUSD, settings.PracticeIdle)
+ WithCancelRethink(rethinkAfterCancel(settings, planClient, plans, graph))
}
// rethinkAfterCancel is the settle watcher's half of the cancel journey: work
@@ -107,7 +104,6 @@ func rethinkAfterCancel(settings config.Config, planClient *liveClient,
// scattered compiles across providers, and the 5.5 KB compiler prompt was
// written cold every single time. One constant key keeps it warm from compile
// to compile, exactly as "distill", "gate", "narrate" and the rest already do.
-// The standing-charter compiler runs on this same context and inherits it.
//
// plans is here for one reason and it is not planning: the compiler's structural
// reading of the ask — the thing scale is reconciled from — has no seat on
@@ -155,7 +151,6 @@ func compileIntent(settings config.Config, compiler *head.Compiler, taskClient,
BuildsOn: brief.BuildsOn,
Question: brief.Question,
QuestionOptions: brief.QuestionOptions,
- Charter: brief.Charter,
ServiceIntent: brief.ServiceIntent,
WorkModel: brief.WorkModel,
ModelNote: brief.ModelNote,
diff --git a/cmd/codeaf/subharness.go b/cmd/codeaf/subharness.go
index 4f3d243101..5fd2914641 100644
--- a/cmd/codeaf/subharness.go
+++ b/cmd/codeaf/subharness.go
@@ -400,7 +400,7 @@ func registerSubharnessRunners(registry *exec.Registry, build leafBuild) {
// It is also where model identity and the lane ledger's fold are seated, and
// that is not a coincidence: this is the one function every surface that records
// anything calls before it reads or writes a profile — the plan command, the
-// headless run, chat, and the wake pass. A history keyed on the operator's
+// headless run, and chat. A history keyed on the operator's
// spelling instead of on the model is two histories and two rulers for one
// executor, which is the thing this function exists to prevent one file at a
// time.
diff --git a/cmd/codeaf/supervisor_test.go b/cmd/codeaf/supervisor_test.go
index 335d5ab6eb..636a46d7f5 100644
--- a/cmd/codeaf/supervisor_test.go
+++ b/cmd/codeaf/supervisor_test.go
@@ -12,9 +12,9 @@ import (
// The resident loop was launched as `_ = Serve(ctx)`. One transient failure
// inside one pass ended the background half of the surface silently and
// permanently: no announcement, no restart, and a lease still claiming the role
-// so every `codeaf wake` stepped aside for a process that had stopped serving
-// hours before. Standing watches, charters and practice simply never fired
-// again, and nothing anywhere said so.
+// so every other process stepped aside for one that had stopped serving hours
+// before. Settled work went unannounced, nothing the resident owed was ever
+// done again, and nothing anywhere said so.
func TestTheResidentSupervisorRestartsAndThenGivesUpLoudly(t *testing.T) {
var mu sync.Mutex
calls := 0
diff --git a/cmd/codeaf/telemetry_events.go b/cmd/codeaf/telemetry_events.go
index 78baadecda..5f8fb5167b 100644
--- a/cmd/codeaf/telemetry_events.go
+++ b/cmd/codeaf/telemetry_events.go
@@ -86,15 +86,6 @@ func (c *countingAgent) SubmitBash(ctx context.Context, text string) (<-chan ses
return countedEvents(events), nil
}
-// SubmitStanding is [tui3.Agent.SubmitStanding] with the stream counted.
-func (c *countingAgent) SubmitStanding(ctx context.Context, text string) (<-chan session.Event, error) {
- events, err := c.Agent.SubmitStanding(ctx, text)
- if err != nil {
- return events, err
- }
- return countedEvents(events), nil
-}
-
// SubmitImage is [tui3.Agent.SubmitImage] with the stream counted.
func (c *countingAgent) SubmitImage(ctx context.Context, text string, images []session.Image) (<-chan session.Event, error) {
events, err := c.Agent.SubmitImage(ctx, text, images)
diff --git a/cmd/codeaf/terminalwords_test.go b/cmd/codeaf/terminalwords_test.go
index 3c039fed08..0f28947d2a 100644
--- a/cmd/codeaf/terminalwords_test.go
+++ b/cmd/codeaf/terminalwords_test.go
@@ -7,7 +7,6 @@ import (
"testing"
"time"
- "github.com/Agent-Field/codeaf/internal/resident"
"github.com/Agent-Field/codeaf/internal/store"
)
@@ -98,63 +97,3 @@ func TestTheRebuildReceiptCountsStepsAndNotNodes(t *testing.T) {
" want a receipt reading `rebuilt N steps from M journaled events`", receipt)
}
}
-
-// ── THE EMPTINESS LAW REACHES THE WAKE RECEIPT ─────────────────────────────
-//
-// `codeaf wake` printed all eight of its figures every time, so an ordinary
-// quiet pass read `examined 3, checked 2, fired 1, no 0, errors 0, rail waits 0,
-// practice 0, learning 2` — four numbers asserting a measurement where nothing
-// had happened. Unknown or zero draws NOTHING; the one sanctioned exception is
-// the live status line's `$0.00`, which exists so a status segment does not jump
-// sideways as it redraws, and a receipt printed once is not that.
-
-func TestTheWakeReceiptSaysOnlyWhatHappened(t *testing.T) {
- said := wakePassWords(resident.WatchPass{
- Examined: 3, Checked: 2, Fired: 1,
- }, 0, 2)
- if said != "examined 3, checked 2, fired 1, learning 2" {
- t.Fatalf("a quiet pass reported %q\n want %q\n"+
- " every clause it dropped was a zero, and a zero is a measurement nobody took",
- said, "examined 3, checked 2, fired 1, learning 2")
- }
-
- // EVERY CLAUSE STILL APPEARS WHEN IT HAS SOMETHING TO SAY. A receipt that
- // dropped a count it should have printed would pass the row above, so each of
- // the eight is driven on its own with one thing in it.
- for _, only := range []struct {
- what string
- pass resident.WatchPass
- // practice and learning are counted off the journal rather than the
- // pass, so they are their own two arguments and their own two rows.
- practice, learning int
- want string
- }{
- {what: "examined", pass: resident.WatchPass{Examined: 7}, want: "examined 7"},
- {what: "checked", pass: resident.WatchPass{Checked: 7}, want: "checked 7"},
- {what: "fired", pass: resident.WatchPass{Fired: 7}, want: "fired 7"},
- {what: "no", pass: resident.WatchPass{No: 7}, want: "no 7"},
- {what: "errors", pass: resident.WatchPass{Errors: 7}, want: "errors 7"},
- {what: "rail waits", pass: resident.WatchPass{RailWaits: 7}, want: "rail waits 7"},
- {what: "practice", practice: 7, want: "practice 7"},
- {what: "learning", learning: 7, want: "learning 7"},
- } {
- if got := wakePassWords(only.pass, only.practice, only.learning); got != only.want {
- t.Fatalf("a pass whose only count was %s reported %q, want %q", only.what, got, only.want)
- }
- }
-}
-
-// AND A PASS ON WHICH NOTHING HAPPENED SAYS SO IN A SENTENCE.
-//
-// Eight zeroes and a reader that failed look identical, which is why the
-// emptiness law asks for the sentence rather than for the line to disappear.
-func TestAWakePassThatFoundNothingSaysSoRatherThanPrintingZeroes(t *testing.T) {
- said := wakePassWords(resident.WatchPass{}, 0, 0)
- if strings.ContainsRune(said, '0') {
- t.Fatalf("a pass on which nothing happened printed a figure: %q", said)
- }
- if said != "nothing was waiting to be looked at." {
- t.Fatalf("a pass on which nothing happened said %q\n want %q",
- said, "nothing was waiting to be looked at.")
- }
-}
diff --git a/cmd/codeaf/tick.go b/cmd/codeaf/tick.go
deleted file mode 100644
index dc2ee90c9f..0000000000
--- a/cmd/codeaf/tick.go
+++ /dev/null
@@ -1,54 +0,0 @@
-package main
-
-// tick.go is the door the operating system's timer knocks on: one bounded pass
-// over everything standing, then exit. `codeaf tick` is what the launchd agent
-// and the systemd user timer in internal/standing/watch.go both run.
-//
-// IT IS MACHINERY, NOT A COMMAND, and so — exactly like `codeaf engine` — it is
-// deliberately absent from the usage text. A person accomplishes nothing by
-// typing it: it draws nothing, asks nothing, and on an ordinary machine with
-// nothing due it prints not one character. Quiet is the design.
-//
-// IT REFUSES RATHER THAN RACES. Any open codeaf window runs the same pass on
-// the same cadence and holds the same lock, so a timer that wakes into a live
-// window says so on stderr and leaves with a zero: the work is already being
-// done by somebody, and that is a success, not a failure.
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
-
- "github.com/Agent-Field/codeaf/internal/home"
- "github.com/Agent-Field/codeaf/internal/standing"
-)
-
-func runTick(args []string) error {
- if len(args) > 0 {
- return fmt.Errorf("codeaf tick takes no arguments")
- }
- store, err := standing.Open(home.Join("v3", "standing"))
- if err != nil {
- return err
- }
- ticker, release, err := v3StandingTicker(store)
- if err != nil {
- return err
- }
- // The pass's model catalog is joined before this command returns, so its
- // warm cannot write a cache after the process has said it is done.
- defer release()
- ctx, cancel := context.WithTimeout(context.Background(), standing.TickWindow)
- defer cancel()
- if _, err := ticker.Tick(ctx); err != nil {
- if errors.Is(err, standing.ErrHeld) {
- // Somebody else is already doing this pass. Saying so on stderr and
- // leaving with a zero is the honest answer: nothing went wrong.
- fmt.Fprintln(os.Stderr, "a codeaf window is already keeping watch")
- return nil
- }
- return err
- }
- return nil
-}
diff --git a/cmd/codeaf/tick_test.go b/cmd/codeaf/tick_test.go
index 926dee625c..4e79e6455f 100644
--- a/cmd/codeaf/tick_test.go
+++ b/cmd/codeaf/tick_test.go
@@ -1,21 +1,18 @@
package main
import (
- "os"
- "path/filepath"
"strings"
"testing"
"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/home"
- "github.com/Agent-Field/codeaf/internal/standing"
- "golang.org/x/sys/unix"
)
-// The door is machinery, not a command, so it must not appear in the list of
-// things a person can usefully type — for the same reason `engine` does not.
-func TestTickIsAbsentFromTheUsageText(t *testing.T) {
- for _, word := range []string{"codeaf tick", " tick "} {
+// The old timer's verb and the clock are machinery, not commands, so neither
+// appears in the list of things a person can usefully type — for the same
+// reason `engine` does not.
+func TestTickAndClockAreAbsentFromTheUsageText(t *testing.T) {
+ for _, word := range []string{"codeaf tick", " tick ", "codeaf clock"} {
if strings.Contains(usageText, word) {
t.Fatalf("the usage text offers %q", word)
}
@@ -27,15 +24,8 @@ func TestTickIsAbsentFromTheUsageText(t *testing.T) {
//
// THERE ARE EXACTLY THREE PLACES A KEY CAN COME FROM and all three are shut
// here, because the alternative is a test that quietly buys a real judgment on
-// somebody's account. [config.LoadKeyless] resolves the key through
-// [config.APIKeyAt], which reads OPENROUTER_API_KEY, then OPENAI_API_KEY, then
-// the `api_key` field of `config.json` in the profile — and nothing else: there
-// is no keychain seam and no second file. Empty reads as unset for the two
-// variables. The profile is the one that hides: it is [config.ProfileDirEnv],
-// taken straight from the environment rather than from the state root this test
-// already moved, so a developer with CODEAF_PROFILE_DIR exported would have
-// reached their own persisted key past every temporary directory here. It is
-// pointed at an empty one, which has no config.json and therefore no key.
+// somebody's account: OPENROUTER_API_KEY, OPENAI_API_KEY, and the `api_key`
+// field of `config.json` in the profile, which is pointed at an empty folder.
func keyless(t *testing.T) {
t.Helper()
t.Setenv(config.APIKeyEnv, "")
@@ -43,108 +33,11 @@ func keyless(t *testing.T) {
t.Setenv(config.ProfileDirEnv, t.TempDir())
}
-// The whole door, on a machine that has never been given a key: `codeaf tick`
-// builds its pass, takes the lock, walks the one item there is, looks at the
-// world, and cannot buy the judgment that would decide whether to fire. THE
-// WALK STILL HAPPENS AND THE WAKE LINE STILL SAYS SO — a pass that will mostly
-// do nothing is not allowed to demand credentials before it finds out
-// (chatv3_standing.go's v3StandingTicker builds keyless).
-//
-// THE MACHINE IS KEYLESS ON PURPOSE AND THE TEST DOES NOT SKIP. Both variables
-// are emptied rather than merely left alone, so this is the same walk on a
-// developer's shell as on the CI runner — and so a key that IS exported can
-// never turn the last step below into a real model call somebody pays for.
-//
-// The probe is a command every machine has rather than the `gh run list` this
-// was written with, so that what the walk reports is this build's own doing and
-// not whether the box has the GitHub CLI installed.
-func TestTickWalksTheItemsAndWritesAWakeLine(t *testing.T) {
- state := t.TempDir()
- t.Setenv(home.EnvVar, state)
- keyless(t)
-
- store, err := standing.Open(home.Join("v3", "standing"))
- if err != nil {
- t.Fatal(err)
- }
- made, err := store.Create(standing.Item{
- Words: "remind me at 6 to leave",
- Workspace: state,
- When: standing.When{Kind: standing.WhenProbe, Probe: standing.Probe{Command: "echo nothing to report"}},
- Does: standing.Action{Kind: standing.ActionSay, Say: "leave now"},
- Rails: standing.Rails{PerRunUSD: 0.05, MaxPerDay: 3},
- })
- if err != nil {
- t.Fatal(err)
- }
-
- if err := runTick(nil); err != nil {
- t.Fatalf("a pass on a machine with no key should still walk: %v", err)
- }
- raw, err := os.ReadFile(store.WakeLogPath())
- if err != nil {
- t.Fatalf("the pass wrote no wake line: %v", err)
- }
- line := strings.TrimSpace(string(raw))
- // ONE ITEM WAS REACHED AND ITS LOOK WAS UNDECIDED, not failed.
- //
- // A MACHINE WITH NO KEY IS A PROVIDER THAT COULD NOT ANSWER, which the
- // three-way reader reads as `unknown`: the look is counted as CHECKED (the
- // walk did reach the world's question) and NOT as an error, and NOTHING IS
- // WRITTEN on the item, so it stays due and the next pass faces the same
- // question. Reading it as an error would publish a failure that did not
- // happen; reading it as a `no` would consume the opportunity the person is
- // waiting on. The cost is that a machine with no key shows `checked` and
- // keeps trying rather than a row that says why — stated, not hidden.
- for _, field := range []string{"examined=1", "checked=1", "errors=0", "fired=0", "said=0"} {
- if !strings.Contains(line, field) {
- t.Fatalf("the wake line does not say %s: %q", field, line)
- }
- }
- // Nothing fired: a firing needs a yes, and nobody was able to say one.
- if _, err := os.Stat(filepath.Join(store.RunsDir(made.ID), "0001")); !os.IsNotExist(err) {
- t.Fatalf("a pass that could not judge ran something anyway: %v", err)
- }
- // AND THE ITEM IS STILL ACTIVE AND STILL DUE, because an unknown writes
- // nothing at all ([Ticker.one]'s stateUndecided).
- after, err := store.Get(made.ID)
- if err != nil {
- t.Fatal(err)
- }
- if after.Status != standing.StatusActive {
- t.Fatalf("an undecided look changed the item's status to %q", after.Status)
- }
-}
-
-// The pass that does the least there is: a window already holds the tick lock,
-// so this one declines it and leaves. NOTHING IS READ AND NOTHING IS SPENT, and
-// again no key is set — a pass that will do nothing costs nothing and needs
-// nothing, which is why the door builds keyless.
-func TestTickLeavesQuietlyWhenAWindowIsAlreadyKeepingWatch(t *testing.T) {
- state := t.TempDir()
- t.Setenv(home.EnvVar, state)
- keyless(t)
- store, err := standing.Open(home.Join("v3", "standing"))
- if err != nil {
- t.Fatal(err)
- }
- held, err := os.OpenFile(store.LockPath(), os.O_CREATE|os.O_RDWR, 0o600)
- if err != nil {
- t.Fatal(err)
- }
- defer held.Close()
- if err := unix.Flock(int(held.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
- t.Fatal(err)
- }
- // A pass somebody else is already running is a success, not a failure.
- if err := runTick(nil); err != nil {
- t.Fatalf("a held lock was reported as an error: %v", err)
- }
-}
-
-func TestTickTakesNoArguments(t *testing.T) {
+// THE CLOCK TAKES NO ARGUMENTS, and one that finds another clock holding the
+// lock leaves without a word.
+func TestClockTakesNoArguments(t *testing.T) {
t.Setenv(home.EnvVar, t.TempDir())
- if err := runTick([]string{"--now"}); err == nil {
+ if err := runClock([]string{"--now"}); err == nil {
t.Fatal("an argument was accepted by a door that has none")
}
}
diff --git a/cmd/codeaf/usage.go b/cmd/codeaf/usage.go
index 2a6912cfd2..bf15ad8e07 100644
--- a/cmd/codeaf/usage.go
+++ b/cmd/codeaf/usage.go
@@ -518,12 +518,13 @@ func commandWords() []string {
}
// knownCommands is every word the dispatch answers to, in the order the table
-// introduces them. `engine` and `tick` are deliberately absent for the same
-// reason they are absent from the usage text: nothing types them. The programs
-// this build carries are joined to it where it is read ([commandWords]).
+// introduces them. `engine`, `tick` and `wake` are deliberately absent for the
+// same reason they are absent from the usage text: nothing types them. The
+// programs this build carries are joined to it where it is read
+// ([commandWords]).
var knownCommands = []string{
"chat", "resume", "serve", "devices", "do", "plan", "revise", "run", "exec",
- "show", "models", "pool", "notebook", "collections", "competence", "services", "wake", "patch",
+ "show", "models", "pool", "notebook", "collections", "competence", "services", "patch",
"doc", "web", "image",
"doctor",
"logs", "cache", "rebuild", "why", "manual", "version", "help",
diff --git a/cmd/codeaf/usage_test.go b/cmd/codeaf/usage_test.go
index af49269236..ec3303d4b9 100644
--- a/cmd/codeaf/usage_test.go
+++ b/cmd/codeaf/usage_test.go
@@ -65,7 +65,6 @@ func TestAskingForHelpIsNotAFailure(t *testing.T) {
{"logs", runLogs},
{"why", runWhy},
{"competence", runCompetence},
- {"wake", runWake},
{"rebuild", runRebuild},
{"notebook", runNotebook},
{"cache", runCache},
diff --git a/cmd/codeaf/vocabulary_test.go b/cmd/codeaf/vocabulary_test.go
index 495e164f2c..a283ac4dd4 100644
--- a/cmd/codeaf/vocabulary_test.go
+++ b/cmd/codeaf/vocabulary_test.go
@@ -267,11 +267,6 @@ func TestAnOldSpellingReachesTheSamePlaceAndSaysWhatItIsCalledNow(t *testing.T)
old: "--ensemble", now: "--passes",
wasTyped: typed("plan", "new", "a goal", "--ensemble", "3"),
nowTyped: typed("plan", "new", "a goal", "--passes", "3"),
- }, {
- name: "wake --max-seconds is --timeout",
- old: "--max-seconds", now: "--timeout",
- wasTyped: typed("wake", "--max-seconds", "30"),
- nowTyped: typed("wake", "--timeout", "30"),
}} {
t.Run(spelling.name, func(t *testing.T) {
wasParsed, said, wasEnding := watchParses(t, spelling.wasTyped)
@@ -343,7 +338,6 @@ func TestNoOldSpellingIsPrintedByHelp(t *testing.T) {
{"plan new", func(args []string) error { return runPlanNew("plan new", args) }},
{"plan run", func(args []string) error { return runGraph("plan run", args) }},
{"plan revise", func(args []string) error { return runRevise("plan revise", args) }},
- {"wake", runWake},
} {
out, _ := captureUsage(t)
if code := exitCodeOf(door.run([]string{"--help"})); code != 0 {
@@ -540,7 +534,7 @@ func TestOneConceptIsSpelledOneWayOnEveryDoor(t *testing.T) {
{"model", "modelFlagHelp", []string{"runDo", "runExec", "runGraph", "runPlanNew", "runRevise", "runSubharnessCommand"}},
{"json", "jsonFlagHelp", []string{"runDo", "runExec", "runSubharnessCommand"}},
{"yes-spend", "yesSpendFlagHelp", []string{"runDo", "runGraph"}},
- {"db", "storeFlagHelp", []string{"runDoctorWith", "runWhyTo", "runNotebookTo", "runServices", "runCompetenceTo", "runRebuildWith", "runWakeWith"}},
+ {"db", "storeFlagHelp", []string{"runDoctorWith", "runWhyTo", "runNotebookTo", "runServices", "runCompetenceTo", "runRebuildWith"}},
} {
for _, door := range concept.doors {
said, declared := sentenceFor[concept.flag][door]
diff --git a/cmd/codeaf/wake.go b/cmd/codeaf/wake.go
index 2d1eccac84..4e4f320921 100644
--- a/cmd/codeaf/wake.go
+++ b/cmd/codeaf/wake.go
@@ -1,271 +1,23 @@
package main
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "log"
- "os"
- "path/filepath"
- "strings"
- "time"
-
- "github.com/Agent-Field/codeaf/internal/config"
- "github.com/Agent-Field/codeaf/internal/craft"
- "github.com/Agent-Field/codeaf/internal/lease"
- "github.com/Agent-Field/codeaf/internal/provider/pool"
- "github.com/Agent-Field/codeaf/internal/resident"
- "github.com/Agent-Field/codeaf/internal/store"
-)
-
-const (
- defaultWakeMaxSeconds = 120
- maxWakeTicks = 32
-)
-
-type wakeBuilder func(graph *store.Store, path string) (*resident.Reconciler, error)
-
-// runWake opens the resident store, serves the full resident role for one
-// bounded pass, and exits. It starts no permanent process or worker runner.
-func runWake(args []string) error {
- return runWakeWith(args, os.Stdout, func(graph *store.Store, path string) (*resident.Reconciler, error) {
- settings, err := config.Load()
- if err != nil {
- return nil, err
- }
- prefs := loadChatPrefs(filepath.Dir(path))
- chatClient, err := newLiveClient(settings, firstNonEmptyString(prefs.ChatModel, settings.Model))
- if err != nil {
- return nil, err
- }
- taskClient, err := newLiveClient(settings, firstNonEmptyString(prefs.TaskModel, settings.Model))
- if err != nil {
- return nil, err
- }
- // Same resolution as the chat surface: an explicit plan choice splits
- // structuring from execution, empty follows the work model.
- planClient, err := newLiveClient(settings, firstNonEmptyString(prefs.PlanModel, settings.PlanModel, taskClient.Model()))
- if err != nil {
- return nil, err
- }
- // A wake pass is unattended by definition, which makes an unbounded
- // structuring call worse here than in chat: there is nobody to notice.
- chatClient.WithCallWall(pool.DefaultCallWall)
- planClient.WithCallWall(pool.DefaultCallWall)
- installMeasuredRulers(settings, taskClient.Model())
- plans := &jobPlans{graphs: map[string]plannedJob{}}
- // A wake pass has no live boost slot to resolve model words against;
- // jobs born here run on the configured work model.
- // A wake pass is the standing half of the product doing its job, not a
- // one-shot errand: charters are exactly what it exists to serve.
- // A wake pass has no shared workspace to show the planner: the jobs it
- // admits work in per-job directories that do not exist yet, so terrain
- // renders nothing and every prompt it sends is the prompt it always sent.
- reconciler := newResidentReconciler(settings, graph, chatClient, taskClient, planClient, plans, "", nil, false)
- // Craft is not a chat ornament. Without it an overnight charter plans
- // from scratch a job that has a proven learned workflow, and any craft
- // that overnight job would have taught is discarded before it can even
- // be filed as a lesson — the forge returns early for want of a mind.
- // The repository lives beside the graph it serves; a missing or broken
- // one leaves craft dormant, exactly as it does in chat.
- if craftRepo, craftErr := craft.Open(filepath.Join(filepath.Dir(path), "craft")); craftErr == nil {
- reconciler = reconciler.
- WithCraftRunner(resident.NewCraftRunner(graph, craftRepo, craftRepo.Dir())).
- WithCraftMind(resident.NewCraftMind(craftRepo, craftRepo.Dir(),
- fillCraftParams(settings, chatClient), repairCraft(settings, chatClient)))
- } else {
- log.Printf("note: craft repository unavailable: %v", craftErr)
- }
- return reconciler, nil
- })
-}
-
-func runWakeWith(args []string, output io.Writer, build wakeBuilder) error {
- flags := commandFlags("wake")
- database := flags.String("db", defaultChatDB(), storeFlagHelp)
- // A WALL IS `--timeout` ON EVERY DOOR THAT HAS ONE. This was
- // `--max-seconds`, which is the same concept spelled a third way and in the
- // unit rather than in the quantity — so `codeaf wake --max-seconds 5m` was a
- // parse error on a machine where `codeaf do --timeout 5m` works (wall.go).
- wall := wallFlag{wall: time.Duration(defaultWakeMaxSeconds) * time.Second}
- flags.Var(&wall, "timeout", "hard wall on the pass, as a duration such as 2m (a bare number is seconds)")
- renamedFlag(flags, "max-seconds", "timeout")
- if err := parseCommandFlags(flags, reorder(flags, args)); err != nil {
- return err
- }
- noteRenamedFlags(flags)
- if flags.NArg() != 0 {
- return fmt.Errorf("usage: codeaf wake [--db path] [--timeout 2m]")
- }
- path, err := expandHome(strings.TrimSpace(*database))
- if err != nil {
- return err
- }
- holder, err := lease.ProbeResident(path)
- if err != nil {
- return err
- }
- // A holder that is alive but has not completed a pass in several intervals
- // is not serving the role, only occupying it. Deferring to it forever is how
- // a resident whose loop died silently stopped every standing watch on the
- // machine while still printing "alive" at anyone who asked.
- if holder != nil && !holder.Stuck {
- _, err = fmt.Fprintf(output, "resident alive (pid %d) — skipping wake\n", holder.PID)
- return err
- }
- info, err := os.Stat(path)
- if err != nil {
- return fmt.Errorf("open wake store: %w", err)
- }
- if !info.Mode().IsRegular() {
- return fmt.Errorf("open wake store: %s is not a regular database file", path)
- }
- releaseResident, heldBy, err := lease.AcquireResident(path, "wake")
- if err != nil {
- return err
- }
- if releaseResident == nil {
- if !heldBy.Stuck {
- _, err = fmt.Fprintf(output, "resident alive (pid %d) — skipping wake\n", heldBy.PID)
- return err
- }
- // The flock cannot be taken from a live process, so the pass runs
- // without it. Two residents against one store is the risk this lease
- // exists to prevent; a holder that has stopped ticking is not the
- // second one, and saying so out loud is how an operator finds out.
- if _, err := fmt.Fprintf(output, "resident (pid %d) has not ticked since %s — waking anyway\n",
- heldBy.PID, heldBy.LastTick.Format(time.RFC3339)); err != nil {
- return err
- }
- } else {
- defer releaseResident()
- }
-
- graph, err := store.Open(path)
- if err != nil {
- return err
- }
- defer graph.Close()
- reconciler, err := build(graph, path)
- if err != nil {
- return err
- }
- startSeq, err := graph.LatestEventSeq()
- if err != nil {
- return err
- }
- // THE WALL IS THE WALL, TO THE NANOSECOND. `--timeout` is a duration on
- // every door that has one, and this one used to fold it down to a whole
- // number of seconds and multiply it back up — so `--timeout 500ms`
- // truncated to zero, and a zero wall is no wall at all, which handed the
- // pass the full default instead of the half second that was asked for.
- ctx, cancel := context.WithTimeout(context.Background(), wall.wall)
- defer cancel()
- var pass resident.WatchPass
- for tick := 0; tick < maxWakeTicks; tick++ {
- before, watermarkErr := graph.LatestEventSeq()
- if watermarkErr != nil {
- return watermarkErr
- }
- tickErr := reconciler.Tick(ctx)
- addWatchPass(&pass, reconciler.LastWatchPass())
- if tickErr == nil {
- // Stamping liveness is what makes the role reclaimable: a pass that
- // completes says so on the lease, and a holder that stops saying it
- // stops being deferred to.
- _ = lease.NoteResidentTick(path, time.Now())
- }
- if tickErr != nil {
- if errors.Is(tickErr, context.DeadlineExceeded) || errors.Is(tickErr, context.Canceled) {
- break
- }
- return tickErr
- }
- after, watermarkErr := graph.LatestEventSeq()
- if watermarkErr != nil {
- return watermarkErr
- }
- if after == before || ctx.Err() != nil {
- break
- }
- }
- events, err := graph.Events(startSeq, 0)
- if err != nil {
- return err
- }
- practice, learning := 0, 0
- for _, event := range events {
- switch event.Kind {
- case store.EventQuestionPracticeStarted:
- practice++
- case store.EventFactLearned, store.EventFactActivated:
- learning++
- }
- }
- if err := graph.RecordStandingWatchPass(store.StandingWatchPass{
- Examined: pass.Examined, Woken: pass.Woken, Checked: pass.Checked,
- Fired: pass.Fired, Proposed: pass.Proposed, No: pass.No, Errors: pass.Errors,
- Quota: pass.Quota, Expired: pass.Expired, RailWaits: pass.RailWaits,
- }); err != nil {
- return err
- }
- _, err = fmt.Fprintln(output, wakePassWords(pass, practice, learning))
- return err
-}
-
-// wakePassWords is the one line a wake pass leaves behind, and it says only
-// what happened.
+// runWake is `codeaf wake`, and it does nothing.
//
-// THE EMPTINESS LAW REACHES A RECEIPT PRINTED ONCE. It used to print all eight
-// figures unconditionally, so an ordinary quiet pass read `examined 3, checked
-// 2, fired 1, no 0, errors 0, rail waits 0, practice 0, learning 2` — four
-// numbers asserting a measurement where nothing had happened, and a reader has
-// to spend a moment on each of them to find that out. The one sanctioned
-// exception to the law is the live status line's `$0.00`, which is there so a
-// status segment does not jump sideways as it redraws; a line printed once and
-// never redrawn is not that.
+// It used to run one bounded pass of the v1 resident — charters woken and
+// judged, practice, tenure — against the resident's store, and an operating
+// system timer an older build installed ran it every five minutes. That
+// scheduler is gone (docs/design/automations/DESIGN.md): nothing in this
+// build schedules anything on the machine, and automations run only while a
+// codeaf window is open.
//
-// And a pass on which NOTHING happened says so in a sentence rather than in
-// eight zeroes, because a receipt of zeroes and a reader that failed look
-// identical, which is the same reason `why self` answers an empty day with one
-// sentence instead of a column header over nothing.
-func wakePassWords(pass resident.WatchPass, practice, learning int) string {
- counted := []struct {
- word string
- count int
- }{
- {"examined", pass.Examined},
- {"checked", pass.Checked},
- {"fired", pass.Fired},
- {"no", pass.No},
- {"errors", pass.Errors},
- {"rail waits", pass.RailWaits},
- {"practice", practice},
- {"learning", learning},
- }
- clauses := make([]string, 0, len(counted))
- for _, row := range counted {
- if row.count == 0 {
- continue
- }
- clauses = append(clauses, fmt.Sprintf("%s %d", row.word, row.count))
- }
- if len(clauses) == 0 {
- return "nothing was waiting to be looked at."
- }
- return strings.Join(clauses, ", ")
-}
-
-func addWatchPass(total *resident.WatchPass, pass resident.WatchPass) {
- total.Examined += pass.Examined
- total.Woken += pass.Woken
- total.Checked += pass.Checked
- total.Fired += pass.Fired
- total.Proposed += pass.Proposed
- total.No += pass.No
- total.Errors += pass.Errors
- total.Quota += pass.Quota
- total.Expired += pass.Expired
- total.RailWaits += pass.RailWaits
+// THE VERB STAYS, HIDDEN, BECAUSE THE TIMERS OUTLIVE THE BINARY THAT MADE
+// THEM. A launchd or systemd unit already installed keeps running
+// ` wake` until this build's first start removes it, and an unknown
+// verb exits 1 — which every one of those timers would report as a failure,
+// every five minutes, to whoever reads the system log. So the word is still
+// dispatched, answers 0, and says nothing; it is off the help page
+// (beltdoors_test.go's doorsOffThePage) because nobody should ever type it.
+// The arguments are ignored on purpose: the timers passed --db and --timeout,
+// and refusing a flag would be the same failure by another route.
+func runWake([]string) error {
+ return nil
}
diff --git a/cmd/codeaf/wake_test.go b/cmd/codeaf/wake_test.go
index 87274da4c5..33ec4164de 100644
--- a/cmd/codeaf/wake_test.go
+++ b/cmd/codeaf/wake_test.go
@@ -1,293 +1,27 @@
package main
import (
- "bytes"
- "context"
- "fmt"
"path/filepath"
"strings"
"testing"
- "time"
-
- "github.com/Agent-Field/codeaf/internal/config"
- "github.com/Agent-Field/codeaf/internal/lease"
- "github.com/Agent-Field/codeaf/internal/resident"
- "github.com/Agent-Field/codeaf/internal/store"
)
-func TestSentinelClientCallParsesYesAndNo(t *testing.T) {
- for _, test := range []struct {
- response string
- yes bool
- line string
- }{
- {response: "yes — a release landed", yes: true, line: "a release landed"},
- {response: "no — nothing changed", line: "nothing changed"},
+// The operating-system timers an older build installed run ` wake`
+// every five minutes, with the flags that build gave them, until this build's
+// first start removes them. Every one of those runs has to be a quiet success:
+// an exit status of 1 is what a timer reports as a failure, every five
+// minutes, to whoever reads the system log — and run() hands a nil straight
+// back to the one exit as status 0.
+func TestWakeIsAQuietSuccessWhateverAnOlderTimerPasses(t *testing.T) {
+ for _, args := range [][]string{
+ nil,
+ {"--db", filepath.Join(t.TempDir(), "graph.db")},
+ {"--db", "/nowhere/graph.db", "--timeout", "2m"},
+ {"--max-seconds", "120"},
+ {"--a-flag-nobody-ever-wrote"},
} {
- capture := &gateCaptureClient{model: "talk/model", response: test.response}
- settings := config.Config{Model: capture.model}
- client := adoptLiveClient(settings, capture.model, capture)
- verdict, err := checkSentinel(settings, client)(context.Background(), resident.SentinelPrompt{
- CharterID: "release-watch", Invariant: "Keep release notes current.",
- SentinelHint: "Did a release land?", Evidence: "poll due",
- })
- if err != nil {
- t.Fatal(err)
- }
- if verdict.Yes != test.yes || verdict.Line != test.line {
- t.Fatalf("verdict = %+v", verdict)
- }
- if len(capture.messages) != 2 || capture.messages[0].Content[0].Text != sentinelSystemPrompt ||
- !strings.Contains(capture.messages[1].Content[0].Text, "Keep release notes current.") {
- t.Fatalf("sentinel messages = %+v", capture.messages)
- }
- }
-}
-
-func TestWakeCommandRunsOnePassAndExits(t *testing.T) {
- path := filepath.Join(t.TempDir(), "wake.db")
- graph, err := store.Open(path)
- if err != nil {
- t.Fatal(err)
- }
- charter, err := store.NewCharter("wake-command", "Keep release notes current.", store.WatchSpec{
- Kind: store.WatchPoll, Poll: &store.PollWatch{Condition: "look for a release", Cadence: time.Hour},
- }, "Did a release land?", store.CharterAction{Template: "Update the release notes"},
- store.CharterRails{PerFiringBudgetUSD: 0.1, MaxFiringsPerDay: 2}, store.CharterActive,
- store.Ratification{Origin: store.OriginUser, SessionID: "wake-session", Evidence: "yes"})
- if err != nil {
- t.Fatal(err)
- }
- if err := graph.CreateCharter(charter); err != nil {
- t.Fatal(err)
- }
- if err := graph.PromoteCharter(charter.ID, "test fixture exercises tenured wake behavior", false); err != nil {
- t.Fatal(err)
- }
- if err := graph.Close(); err != nil {
- t.Fatal(err)
- }
-
- calls := 0
- builder := func(graph *store.Store, _ string) (*resident.Reconciler, error) {
- return resident.New(graph,
- func(_ context.Context, instruction, _ string) (resident.Compiled, error) {
- return resident.Compiled{Goal: "Re-grounded: " + instruction}, nil
- }, nil,
- ).WithWatchEngine(0, func(_ context.Context, _ resident.SentinelPrompt) (resident.SentinelVerdict, error) {
- calls++
- return resident.SentinelVerdict{Yes: true, Line: "release found"}, nil
- }), nil
- }
- var output bytes.Buffer
- if err := runWakeWith([]string{"--db", path}, &output, builder); err != nil {
- t.Fatal(err)
- }
- if calls != 1 || !strings.Contains(output.String(), "checked 1, fired 1") {
- t.Fatalf("wake output = %q calls=%d", output.String(), calls)
- }
-
- graph, err = store.Open(path)
- if err != nil {
- t.Fatal(err)
- }
- wake, wakeFound, err := graph.LastStandingWake()
- if err != nil || !wakeFound || wake.IsZero() {
- t.Fatalf("wake pass was not journaled: %s found=%t err=%v", wake, wakeFound, err)
- }
- nodes, err := graph.Nodes()
- if err != nil {
- t.Fatal(err)
- }
- found := false
- for _, node := range nodes {
- if node.Provenance.CharterID != charter.ID {
- continue
- }
- found = true
- if node.Provenance.Origin != store.OriginTrigger || node.Provenance.Intent != "Re-grounded: "+charter.Action.Template {
- t.Fatalf("wake job provenance = %+v", node.Provenance)
- }
- }
- if err := graph.Close(); err != nil {
- t.Fatal(err)
- }
- if !found {
- t.Fatal("wake command did not splice trigger work")
- }
-
- output.Reset()
- if err := runWakeWith([]string{"--db", path}, &output, builder); err != nil {
- t.Fatal(err)
- }
- // A SECOND PASS OVER THE SAME STORE FINDS NOTHING, AND SAYS SO IN A
- // SENTENCE. It used to read `examined 0, checked 0, …` — eight figures
- // asserting a measurement where nothing happened (wakePassWords).
- if calls != 1 || !strings.Contains(output.String(), "nothing was waiting to be looked at.") {
- t.Fatalf("second one-pass output = %q calls=%d", output.String(), calls)
- }
-}
-
-func TestWakeFastExitsWhenResidentLeaseHeld(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "wake.db")
- graph, err := store.Open(path)
- if err != nil {
- t.Fatal(err)
- }
- if err := graph.Close(); err != nil {
- t.Fatal(err)
- }
- release, heldBy, err := lease.AcquireResident(path, "chat")
- if err != nil || release == nil || heldBy != nil {
- t.Fatalf("hold resident lease = release %v, held %+v, err %v", release != nil, heldBy, err)
- }
- defer release()
-
- built := false
- started := time.Now()
- var output bytes.Buffer
- err = runWakeWith([]string{"--db", path}, &output, func(*store.Store, string) (*resident.Reconciler, error) {
- built = true
- return nil, nil
- })
- if err != nil {
- t.Fatal(err)
- }
- if built {
- t.Fatal("wake built a reconciler while another resident was alive")
- }
- if elapsed := time.Since(started); elapsed >= time.Second {
- t.Fatalf("fast exit took %s", elapsed)
- }
- if !strings.Contains(output.String(), "resident alive (pid ") || !strings.Contains(output.String(), "— skipping wake") {
- t.Fatalf("fast-exit output = %q", output.String())
- }
-}
-
-func TestWakeFullPassFiresDuePracticeCandidate(t *testing.T) {
- path := filepath.Join(t.TempDir(), "practice-wake.db")
- graph, err := store.Open(path)
- if err != nil {
- t.Fatal(err)
- }
- seedWakePracticeCandidate(t, graph, "repo:/work/parser", "build parser and run go test")
- if err := graph.Close(); err != nil {
- t.Fatal(err)
- }
-
- var output bytes.Buffer
- builder := func(graph *store.Store, _ string) (*resident.Reconciler, error) {
- return resident.New(graph, nil, nil).WithPracticeLoop(2, 0), nil
- }
- if err := runWakeWith([]string{"--db", path}, &output, builder); err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(output.String(), "practice 1") {
- t.Fatalf("wake summary = %q", output.String())
- }
-
- graph, err = store.Open(path)
- if err != nil {
- t.Fatal(err)
- }
- defer graph.Close()
- nodes, err := graph.Nodes()
- if err != nil {
- t.Fatal(err)
- }
- for _, node := range nodes {
- if node.Group == store.PracticeGroup && node.Status == store.Pending &&
- node.Provenance.Origin == store.OriginSelf {
- return
- }
- }
- t.Fatalf("wake did not admit a pending self-origin practice node: %+v", nodes)
-}
-
-func seedWakePracticeCandidate(t *testing.T, graph *store.Store, scope, intent string) {
- t.Helper()
- for job := 1; job <= 2; job++ {
- rootID := fmt.Sprintf("wake-history-%d", job)
- nodes := []store.NodeSpec{{ID: rootID, Brief: intent, Stage: 2}}
- for leaf := 1; leaf <= 3; leaf++ {
- nodes = append(nodes, store.NodeSpec{
- ID: fmt.Sprintf("%s-leaf-%d", rootID, leaf), Parent: rootID,
- Brief: intent, Stage: 1,
- })
- }
- if err := graph.Splice(store.RootID, store.Subtree{Nodes: nodes}, store.Provenance{
- Origin: store.OriginUser, SessionID: "wake-practice", Intent: intent,
- }); err != nil {
- t.Fatal(err)
+ if err := runWake(args); err != nil {
+ t.Fatalf("codeaf wake %s = %v, want a quiet success", strings.Join(args, " "), err)
}
- if _, err := graph.RecordFact(rootID, scope, store.FactLesson, intent); err != nil {
- t.Fatal(err)
- }
- // Improving residuals across the two jobs: the learning-progress
- // allocator funds curiosity only where practice is paying off.
- surprise := 2.0
- if job == 2 {
- surprise = 1.0
- }
- for leaf := 1; leaf <= 3; leaf++ {
- completeWakePracticeNode(t, graph, fmt.Sprintf("%s-leaf-%d", rootID, leaf), surprise)
- }
- completeWakePracticeNode(t, graph, rootID, surprise)
- }
-}
-
-func completeWakePracticeNode(t *testing.T, graph *store.Store, id string, surprise float64) {
- t.Helper()
- claim, won, err := graph.Claim(id, "wake-practice-history")
- if err != nil || !won {
- t.Fatalf("claim %s: won=%t err=%v", id, won, err)
- }
- if err := graph.RecordSurprise(store.NodeSurprise{
- NodeID: id, ActualTokens: 200, ExpectedTokens: 100, Surprise: surprise,
- }); err != nil {
- t.Fatal(err)
- }
- if err := graph.Complete(claim, "go test passed"); err != nil {
- t.Fatal(err)
- }
-}
-
-// A resident whose loop died silently keeps its lease for as long as its
-// process lives, and every wake used to defer to it forever — so no standing
-// watch, charter or practice ever fired again while it printed "alive".
-func TestWakeRunsAnywayWhenTheHolderStoppedTicking(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "wake.db")
- graph, err := store.Open(path)
- if err != nil {
- t.Fatal(err)
- }
- if err := graph.Close(); err != nil {
- t.Fatal(err)
- }
- release, heldBy, err := lease.AcquireResident(path, "chat")
- if err != nil || release == nil || heldBy != nil {
- t.Fatalf("hold resident lease = release %v, held %+v, err %v", release != nil, heldBy, err)
- }
- defer release()
- if err := lease.NoteResidentTick(path, time.Now().Add(-2*lease.StuckAfter)); err != nil {
- t.Fatal(err)
- }
-
- built := false
- var output bytes.Buffer
- err = runWakeWith([]string{"--db", path}, &output, func(graph *store.Store, _ string) (*resident.Reconciler, error) {
- built = true
- return resident.New(graph, nil, nil), nil
- })
- if err != nil {
- t.Fatal(err)
- }
- if !built {
- t.Fatalf("wake deferred to a resident that had stopped ticking: %q", output.String())
- }
- if !strings.Contains(output.String(), "has not ticked since") {
- t.Fatalf("wake took the role back silently: %q", output.String())
}
}
diff --git a/cmd/codeaf/wall.go b/cmd/codeaf/wall.go
index 8898b5c7c4..578b43bae7 100644
--- a/cmd/codeaf/wall.go
+++ b/cmd/codeaf/wall.go
@@ -9,10 +9,9 @@ import (
// wallFlag is a hard wall typed the way every other duration in the product is
// typed: with a unit, `5m` or `2h`. It exists because `codeaf do` took its
-// wall as a bare integer of seconds while `CODEAF_PRACTICE_IDLE 20m`,
-// `CODEAF_BRIEF_AFTER 4h` and `--max-hours` all take units, so a person who
-// had typed `20m` anywhere else typed `5m` here and was refused with a parse
-// error (#376).
+// wall as a bare integer of seconds while `--max-hours` and every duration
+// setting of the day took units, so a person who had typed `20m` anywhere else
+// typed `5m` here and was refused with a parse error (#376).
//
// A BARE NUMBER IS STILL SECONDS, FOR ONE RELEASE. Every script that passes
// `-timeout 900` keeps working exactly as it did; the grace is written on the
diff --git a/docs/AMBIENT.md b/docs/AMBIENT.md
index e22ba9da98..7b1ce0bf00 100644
--- a/docs/AMBIENT.md
+++ b/docs/AMBIENT.md
@@ -1,5 +1,16 @@
# The ambient side — what codeaf does when you are not asking
+> **Superseded, 2026-10-10.** Standing items are gone from the code:
+> `internal/standing`, the `stand` tool, the `/standing` page, the standing
+> card, the inbox, the operating-system timer and `codeaf tick` (now a hidden
+> verb that does nothing, kept for timers an older build installed). Existing
+> items were not migrated; `~/.codeaf/v3/standing/` is left on disk and no
+> longer read. What replaces them is automations —
+> docs/design/automations/DESIGN.md — which run only while a codeaf window is
+> open, and rules, which are memories marked always. Home's `ask here` stayed,
+> with its errands under `~/.codeaf/v3/errands`. The rest of this page is
+> history.
+
*Design doc, 2026-08-20; brought level with the code 2026-08-21. Status: BUILT —
`internal/standing` (the files, the pass, the OS timer), `internal/session`
(the card, the sentinel, the runner) and `internal/tui3` (home's band, the
diff --git a/docs/FEATURES.md b/docs/FEATURES.md
index c511974783..9c94949667 100644
--- a/docs/FEATURES.md
+++ b/docs/FEATURES.md
@@ -17,11 +17,11 @@ one-line hint a user would actually thank you for).
### Just talk — the compiler classifies, you don't
**What**: There are no mode commands. Plain language becomes an answer, a
-reflex, a task, a project, or a standing goal based on the *shape* of the
+reflex, a task, a project, or an automation based on the *shape* of the
ask — scale is read from structure, duration from temporal language.
**Where**: the chat input, always.
**How**: "open that file" → instant reflex; "fix the flaky test" → task;
-"whenever a PR opens, review it" → standing-goal ratification card.
+"every morning at 9, check the open PRs" → an automation's card.
**Tip seed**: You never need to tell codeaf how big a job is — say what
you want; it decides ceremony, and misreads are corrected by just saying so.
@@ -40,8 +40,8 @@ conversationally; the head resolves which node you mean, asks back with
options only when ambiguous, and gates consequential surgery behind a
confirm question.
**Where**: chat, any time work is on the graph.
-**How**: "cancel the audio job", "make the PR watcher hourly", "stop
-watching PRs".
+**How**: "cancel the audio job", "stop that test run", "make it check
+only the api package".
**Tip seed**: You can redirect running work by talking about it — no need
to find an id or a kill switch.
@@ -136,68 +136,44 @@ run `codeaf plan|revise|run --model --plan-model `.
`work` — the graph, the replans, and the gate get the judgment while the
leaves stay cheap.
-## 3. Standing goals — it acts without being asked
-
-### Say it once, it stands forever
-**What**: Durable language ("whenever…", "every morning…", "keep the
-suite green", "remind me tomorrow at 9") is *recognized*, never declared —
-no /goal command exists. What comes back is the one ceremony in the
-system: a ratification card quoting the watch, cost (from measured
-history when available), and rails, with `▸ 1 yes · ▸ 2 change cadence ·
-▸ 3 once, not standing`.
-**Where**: chat; the card appears before anything stands.
-**Tip seed**: "Remind me Friday at 3" or "watch this folder" just works —
-you'll approve the standing cost once, then it's furniture.
-
-### It keeps watch when you're not here
-**What**: The first time a charter is ratified, and only then, one
-question is asked in the resident's voice: "Should I keep watching this
-when you're not here? `▸ 1 yes, always · ▸ 2 only while I'm around`".
-Yes arranges a quiet five-minute check that keeps running with no
-terminal open, and answers with a single line. Either answer is durable
-and the question is never asked twice — not after a restart, not on the
-next charter.
-**Where**: chat, immediately after the first ratification.
-**Tip seed**: Say "yes, always" once and your standing goals keep firing
-with every window closed; say "only while I'm around" and they wait for
-you — either way you're asked exactly once.
-
-### The standing rail — felt, not seen
-**What**: One dim line per charter above tasks (`⏱ pr-watch · last fired
-2h · 3 today`), breathing only while a sentinel evaluates or a firing
-runs. Routine "checked, nothing to do" firings never touch the thread —
-only questions, deliveries, and failures earn a card.
-**Where**: the rail (alt+g or header ⟨tasks⟩).
-**Tip seed**: Quiet standing lines are good news — a goal only speaks
-when it has something worth saying.
-
-### Zoom from goal to everything it ever did
-**What**: Click a standing line → the charter card: invariant in your
-words, watch, rails, and firing history (when, outcome, cost). Click a
-firing → its full job graph, the same flight recorder every task has.
-Esc walks back up: graph → card → rail.
-**Where**: rail → charter card → firing drill-in.
-**Tip seed**: Wondering what a goal has been doing? Click its line — the
-history with costs is one click deep, the full graphs two.
-
-### Manage goals by talking (or clicking)
-**What**: "pause the PR watcher", "make it hourly", "stop watching PRs" —
-references resolve by searching the invariants; ambiguity asks back with
-options. The charter card offers the same verbs as clicks, and offers only
-the ones this charter's state can perform: pause or resume, retire, and edit
-cadence — which turns the row itself into a field rather than opening a
-dialog. A proposal's first row stands it up. Click and sentence journal the
-identical typed charter command. `/standing` lists all charters.
-**Tip seed**: There's no goals settings page — say "pause it" or click
-the goal's line; both end in the same journaled event.
-
-### It may propose — you always dispose
-**What**: When the retrospective notices the same-shaped ask ≥3 times, it
-may propose one charter per reflection — the same ratification card,
-marked `proposed · noticed you ask this most mornings`, default-declined.
-Declining is remembered and never re-asked.
-**Tip seed**: If codeaf notices you asking for the same thing most
-mornings, it will offer — once — to just do it every morning.
+## 3. Automations — work on a clock
+
+### Say it once, it runs on its own
+**What**: A reminder ("remind me at 6 to leave"), scheduled work ("every
+Monday at 9, draft the weekly update") or a watch ("tell me when CI on main
+goes red") is *recognized* from what the sentence is, and comes back as a
+card: the schedule in words and exactly, what a watch looks at and the
+condition it is held to, the line or the brief, where work runs, and what
+one run may spend. Nothing is saved until you answer `1`.
+**Where**: chat; or typed exactly with `/automations add …`.
+**Tip seed**: "Remind me Friday at 3" just works — the card shows exactly
+when, and nothing is saved until you say yes.
+
+### Only while codeaf is open
+**What**: Automations run while a codeaf window is open, in one clock
+process every window shares. Anything that fell due while it was closed
+runs once when it opens, marked late. A run's result is one dim line in
+the conversation that made it — the model is not woken — and a desktop
+notification.
+**Where**: automatic; `/automations` lists them.
+**Tip seed**: A watch on CI tells you when it goes red and then stays
+quiet while it stays red.
+
+### Every run, with its cost
+**What**: `/automations` shows each automation's kind, schedule, next run
+and last result; `enter` opens its history — every run, how late, what it
+came to and what it cost — and `enter` on a run opens its transcript. `→`
+shows the row's keys: run now, pause, edit, delete, open where it was asked.
+**Where**: `/automations`.
+**Tip seed**: Wondering what a watch has been doing? Its history is one
+key deep, costs included.
+
+### Rules are memories, not automations
+**What**: "Always run the tests before a commit" has no moment and nothing
+to run: it is a memory marked always, in front of every conversation and
+task from then on. `/always ` keeps one; `/always` lists them.
+**Where**: chat, `/always`, the memory place.
+**Tip seed**: A rule you keep repeating can be said once with `/always`.
## 4. Money, not tokens
@@ -213,10 +189,9 @@ Headless: `CODEAF_DAILY_BUDGET`, `--yes-spend` to preauthorize.
dollars run out, work pauses and asks; `/budget 50` resumes it.
### Every modality is railed
-**What**: Image/speech/music/video generation, charter firings,
-transcription — all draw admission from the same daily rail and record
-real cost from the provider's usage data. Charter firings carry
-per-firing quotes and daily caps on top.
+**What**: Image/speech/music/video generation and transcription all draw
+admission from the same daily rail and record real cost from the
+provider's usage data.
**Tip seed**: One number governs everything codeaf spends today — check
it any time with /budget.
@@ -270,35 +245,20 @@ permanent and searchable, including packed history.
### The retrospective — territories, proposals, self-knowledge
**What**: A background reflection packs settled jobs into territories
-(folds of folds), audits beliefs, proposes charters, and updates
-per-model self-knowledge — attending first to whatever surprised it.
+(folds of folds), audits beliefs, and updates per-model self-knowledge —
+attending first to whatever surprised it.
**Tip seed**: Old jobs get packed into territories — the rail stays calm
at any history size, and nothing is deleted, only folded.
## 6. The curious employee — self-directed life on rails
-### The practice loop — it studies its own weaknesses
-**What**: Real work leaves behind *questions* (gaps revealed by failures,
-corrections, and scopes where its predictions stay wrong past enough
-samples). When you're away and nothing user-origin is in flight, a
-practice charter with its own small dollar carve-out picks the highest
-value question — relevance × execution-verifiability × measured learning
-progress — and splices an `origin: self` practice job. Results feed the
-surprise ledger and the skill forge, never your thread. Questions whose
-scope shows no surprise reduction after two rounds retire automatically.
-**Where**: automatic when idle; `CODEAF_PRACTICE_BUDGET` (default $2/day,
-0 disables), `CODEAF_PRACTICE_IDLE` (default 20m).
-**Tip seed**: The quiet hours aren't idle — codeaf practices what it got
-wrong last week, on its own budget, and stops when practice stops paying.
-
### The morning brief — arrival, folded
**What**: Open chat after a real absence and the first thing in the
thread is one collapsed card: "While you were away — N things done, a
-question, $X." Enter unfolds slim rows (done, failed, fired charters,
-facts learned, spend), each anchored to durable provenance. Short breaks
-stay silent; nothing happened means nothing appears.
-**Where**: automatic on session open; `CODEAF_BRIEF_AFTER` (default 4h,
-0 = always).
+question, $X." Enter unfolds slim rows (done, failed, facts learned,
+spend), each anchored to durable provenance. Short breaks stay silent;
+nothing happened means nothing appears.
+**Where**: automatic on session open, after an absence of 4h or more.
**Tip seed**: You don't owe the scrollback anything — the arrival fold is
the complete account of what happened while you were gone.
@@ -313,11 +273,11 @@ journaled reason.
in the dock is its saved-up 1:1 list, never a popup.
### Curiosity receipts — self-spend must pay learning rent
-**What**: Every dollar spent on itself (practice, retrospectives, folds)
-produces a journaled receipt: what it tried, what it cost, what was
-learned (facts, skills, surprise delta) — or explicitly "nothing." Two
-consecutive nothing-receipts on an inquiry line auto-retire it and pause
-its charter, with the reason journaled as self-origin evidence.
+**What**: Every dollar spent on itself (retrospectives, folds) produces a
+journaled receipt: what it tried, what it cost, what was learned (facts,
+skills, surprise delta) — or explicitly "nothing." Two consecutive
+nothing-receipts on an inquiry line auto-retire it, with the reason
+journaled as self-origin evidence.
**Where**: `codeaf why self` prints today's receipts; surfaces read
`SelfSpendToday` as one number.
**Tip seed**: Ask `codeaf why self` — every cent it spent on itself comes
@@ -328,36 +288,23 @@ themselves.
**What**: From the surprise ledger, failure rates, installed skills, and
territories, every scope is classified strong / frontier (the 25–75%
learnable band) / weak / stale. The head answers "what are you good at?"
-from this evidence, and the frontier list is exactly what the practice
-loop targets.
+from this evidence.
**Where**: `codeaf competence` for the calm rows; in chat, just ask.
**Tip seed**: "What are you actually good at now?" gets a measured answer
— strong scopes, the learning frontier, and where it still struggles.
-### Probation → tenure — autonomy is earned, not granted
-**What**: Every new charter starts on probation: a firing produces a
-proposal ("I would have done X now — approve?") instead of work. Three
-consecutive approved, green firings promote it — one line in the thread,
-"I'll handle this on my own now" — and it fires autonomously. A failure,
-budget breach, or rejected output demotes it back; two demotions pause it.
-"Always allow" promotes immediately; "never" declines and pauses.
-**Where**: automatic on all charters; `CODEAF_TENURE_AFTER` tunes the
-threshold.
-**Tip seed**: New standing goals ask before acting — approve a few good
-runs and they earn tenure; one bad run and they're back to asking.
-
## 7. The surface
### Three places: thread · board · self
**What**: The header's left side is the whole map — thread (the conversation,
-home), board (live jobs, the graph, standing goals — the old tasks rail
-promoted), self (the employee's file: today's receipts, the competence map,
-newest beliefs, and each standing goal's tenure grade). Attention dots light
-per place from that place's own sources; esc always walks home to thread.
+home), board (live jobs and the graph — the old tasks rail promoted), self
+(the employee's file: today's receipts, the competence map and newest
+beliefs). Attention dots light per place from that place's own sources; esc
+always walks home to thread.
**Where**: click the words, or alt+1 / alt+2 / alt+3 (alt+g and ⟨tasks⟩ still
open the board); `/self`.
**Tip seed**: Wondering what it's learned or earned lately? alt+3 opens its
-file — receipts, competence, beliefs, and tenure, one calm column.
+file — receipts, competence and beliefs, one calm column.
### The presence line — you can hear it working
**What**: One dim line in the rail (and dock hint) while the resident works on
@@ -430,22 +377,12 @@ plan, run it with the atomic linear harness. Learning surfaces
**Tip seed**: CI and scripts use codeaf plan/run — same tools, same
rails, no chat needed.
-### `codeaf wake`
-**What**: One watch pass over due charters — evaluate sentinels, fire
-what's due, journal the pass, exit. This is the command the standing
-watch runs for you every five minutes; running it by hand is the same
-pass, now.
-**Tip seed**: codeaf wake runs one standing pass right now instead of
-waiting for the next check.
-
### `codeaf doctor`
-**What**: Five calm rows and nothing else: the brain file and its size,
-who is resident right now, whether the standing watch is on with its last
-wake and next check, today's spend against the rail, and how many goals
-are active with how many questions pending. The same rows ground the
-answer when you ask "who's keeping watch?" in chat.
-**Tip seed**: codeaf doctor answers "is it actually running, and what has
-it cost me today?" in five lines.
+**What**: A few calm rows and nothing else: whether a key is set and
+where it came from, the store and its size, who is resident right now,
+today's spend against the rail, and where the model-call log is.
+**Tip seed**: codeaf doctor answers "can it reach a model, and what has
+it cost me today?" in a handful of lines.
### `codeaf competence` / `codeaf why self`
**What**: The self-knowledge pair from the CLI: measured per-scope
@@ -461,8 +398,7 @@ each belief stands on.
### Environment
**What**: `CODEAF_DAILY_BUDGET`, `CODEAF_PREAUTHORIZE_SPEND`/`--yes-spend`,
-`CODEAF_PRACTICE_BUDGET`, `CODEAF_PRACTICE_IDLE`, `CODEAF_BRIEF_AFTER`,
-`CODEAF_TENURE_AFTER`, `CODEAF_VOICE_MODEL`, `CODEAF_IMAGE_MODEL`,
+`CODEAF_VOICE_MODEL`, `CODEAF_IMAGE_MODEL`,
`CODEAF_SPEECH_MODEL`, `CODEAF_MUSIC_MODEL`, `CODEAF_VIDEO_MODEL`,
profile dir config at `~/.codeaf/config.json`.
diff --git a/docs/GUIDE.md b/docs/GUIDE.md
index 407623250e..612d66646a 100644
--- a/docs/GUIDE.md
+++ b/docs/GUIDE.md
@@ -156,8 +156,9 @@ codeaf chat --once "summarize the changes" # print one reply and exit
codeaf resume
```
-`enter` sends a message or steers a running answer. `ctrl+enter` makes a standing
-order. `esc` interrupts or, pressed twice at rest, opens rewind. `ctrl+c` interrupts
+`enter` sends a message or steers a running answer. `ctrl+enter` mid-answer queues
+the draft to run after the current turn. `esc` interrupts or, pressed twice at rest,
+opens rewind. `ctrl+c` interrupts
mid-turn; twice within 1.5 seconds while idle quits.
@@ -188,12 +189,12 @@ mid-turn; twice within 1.5 seconds while idle quits.
Once the conversation has begun, a column stands down the right-hand side of a wide
-terminal — the tasks and standing orders over this conversation, or, while there are
-none, `+ /task` and `+ /standing` with `❯ ctrl+g hide` under them.
+terminal — the tasks and automations of this conversation, or, while there are none,
+`+ /task` and `+ /automations` with `❯ ctrl+g hide` under them.
Slash commands cover models, conversations, work, memory, permissions, and spending.
Start with `/help`. Common doors are `/model`, `/new`, `/resume`, `/workspace`, `/task`,
-`/history`, `/standing`, `/memory`, `/remember`, `/permissions`, `/status`, `/cost`,
+`/history`, `/automations`, `/memory`, `/remember`, `/permissions`, `/status`, `/cost`,
`/spend`, `/budget`, `/compact`, `/rewind`, `/manual`, `/help`, and `/quit`. `/drafts`
keeps the last ten cleared drafts; `enter` restores one and `d` lets one go.
@@ -208,7 +209,8 @@ keeps the last ten cleared drafts; `enter` restores one and `d` lets one go.
| `/workspace ` | `anchor this conversation to a project` |
| `/task ` | `start work you can walk away from` |
| `/history` | `every task this project has run · ctrl+.` |
-| `/standing ` | `keep this true · a card, never work done once` |
+| `/automations` | `what runs on a clock · run, pause, edit or delete one` |
+| `/automations add …` | `…or add one exactly, read back on a card first` |
| `/memory` | `inspect and change what is remembered` |
| `/remember ` | `keep one thing across conversations` |
| `/permissions` | `what runs without asking · drop one with d` |
@@ -262,7 +264,7 @@ capability that cannot work is left off it rather than offered and failing.
| --- | --- |
| Files and shell | `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls` |
| Documents and jobs | `read_document`, `jobs`, `manual` |
-| Interaction and work | `ask`, `watch`, `tasks`, `propose_task`, `quick_task`, `items`, `divide_work`, `stand` |
+| Interaction and work | `ask`, `watch`, `tasks`, `propose_task`, `quick_task`, `items`, `divide_work`, `automation` |
| Memory and workspace | `remember`, `recall`, `track`, `commit`, `workspace`, `workspace_snapshots`, `workspace_restore`, `workspace_fork`, `workspace_merge`, `search_conversations` |
| Web | `web_search`, `web_fetch` |
| Settings and accounts | `settings`, `change_setting`, `services`, `use_service`, `gmail_search`, `gmail_read`, `gmail_send`, `calendar_list`, `calendar_create`, `slack_search`, `slack_read_thread`, `slack_list_channels`, `slack_send` |
@@ -289,9 +291,18 @@ A task page shows the instruction, folded work and calls, steering, the report,
pinned line with activity, duration, cost and call count. `/task ` is the direct
door; `/history` opens the project's task history.
-Standing orders use `/standing ` or `ctrl+enter`. They become cards and stay true
-after the conversation. Memory keeps person-, project-, or machine-scoped records in
-`graph.db`; `/memory`, `/memories`, `/remember` and `/forget` change them.
+Automations are work codeaf does on a clock: a reminder (`remind me at 6 to leave`),
+scheduled work (`every Monday at 9, draft the weekly update`), or a watch (`tell me when
+CI on main goes red`). Say one in a conversation, or type it exactly with
+`/automations add …`; either way it is read back on a card and nothing is saved until
+you say yes. They run only while a codeaf window is open, and anything that fell due
+while it was closed runs once when it opens, marked late. `/automations` lists them,
+with every run's history.
+
+Memory keeps person-, project-, or machine-scoped records in `graph.db`; `/memory`,
+`/memories`, `/remember` and `/forget` change them. A rule for all future work —
+`always run the tests before a commit` — is a memory marked always: `/always `
+keeps one, and it is in front of every conversation and task from then on.
## Models, keys, and spending
@@ -384,7 +395,7 @@ but it reaches the network for the catalog and stops with
-See [the headless contract](docs/HEADLESS.md) and [ambient work](docs/AMBIENT.md).
+See [the headless contract](docs/HEADLESS.md) and [automations](docs/design/automations/DESIGN.md).
## Work on another machine
@@ -426,4 +437,4 @@ and [AGENTS.md](AGENTS.md) before contributing.
Start with [the documentation map](docs/README.md); the enduring references are the
[design language](docs/DESIGN-LANGUAGE.md), [remote access](docs/REMOTE.md), the
-[headless contract](docs/HEADLESS.md) and [ambient work](docs/AMBIENT.md).
+[headless contract](docs/HEADLESS.md) and [automations](docs/design/automations/DESIGN.md).
diff --git a/docs/LIMITS.md b/docs/LIMITS.md
index 58ad6f0e0e..5555eeb98d 100644
--- a/docs/LIMITS.md
+++ b/docs/LIMITS.md
@@ -16,36 +16,33 @@ owes, and a question asked every time is a question nobody reads.
**Zero means no limit.** On every rail where a ceiling can be lifted, `0` is the
person's own instruction and the reader honours it — including a `0` written
down, which survives a restart rather than reverting to the default in the
-morning. The two exceptions are named below, and both are exceptions because
+morning. The exceptions are named below, and each is an exception because
zero already meant something narrower and truer.
---
-## The four rows a person turns
+## The three rows a person turns
`/settings` → **spending**. These are the whole of what most people ever touch.
| Row | Key | Was | Now | What it does when it fires | `0` |
| --- | --- | --- | --- | --- | --- |
-| **daily budget** | `daily_budget_usd` | $20 | **$500** | posts a blocking question — *"Daily budget reached -- $x spent of $y. Say the word and I'll continue"*. Nothing dies. | no limit |
+| **daily budget** | `daily_budget_usd` | $20 | **$500** | posts a blocking question — *"Daily budget reached -- $x spent of $y. Say the word and I'll continue"*. Nothing dies. An automation's run of work does not start, and reads `your call`. | no limit |
| **ask before spending** | `plan_consent_usd` | $3 | **$100** | a planned job above this estimate quotes its step count and price and waits | never asks |
-| **practice budget** | `practice_budget_usd` | $2 | **$50** | codeaf's own self-practice stops for the day | **practice off** — see below |
| **session ceiling** | `session.spendRailUSD` | $0 | **$0** (unchanged) | refuses the NEXT turn; the turn in flight always finishes; the refused message is never journaled | no ceiling |
Each row grows a **receipt** — the dim line beside its value — that says what a
-bare `$0` cannot: `no limit`, `never asks`, `practice off`. The daily rail keeps
-today's spend beside the word: `no limit · $4.25 today`.
+bare `$0` cannot: `no limit`, `never asks`. The daily rail keeps today's spend
+beside the word: `no limit · $4.25 today`.
-**The practice carve-out is the one row where `0` is not "no limit".** It is a
-*carve-out* and not a ceiling: zero switches self-practice off entirely
-(`internal/resident`'s `WithPracticeLoop`), and there is deliberately no way to
-spell "practice without a bound" — self-origin work runs while nobody is
-watching, so it is the one pocket that always has a bottom.
+**The practice budget row is gone (2026-10-10)** with the v1 resident's practice
+loop it bounded; its key is retired and an older profile's value is read by
+nothing.
**The crew's two limits live on `/crew`, not on this tab.** The **per task** row
here reads the per-task limit and opens nothing new: it is set with
`/crew cap task ` or on the panel's cap row (`per task $5 · crew daily cap none`).
-It is the second rail where `0` is not "no limit" — a task always has a limit, so
+It is a rail where `0` is not "no limit" — a task always has a limit, so
`0` and `none` are refused and an emptied box is $5 again. The crew's **daily
cap** beside it is unset until somebody sets it with `/crew cap `, and
`/crew cap off` takes it away.
@@ -56,10 +53,9 @@ cap** beside it is unset until somebody sets it with `/crew cap `, and
| --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | daily budget | `internal/config/config.go` `DefaultDailyBudgetUSD` | 20 | **500** | USD/day | asks; nothing dies | yes |
| 2 | plan consent | `internal/config/settings.go` `DefaultPlanConsentUSD` | 3 | **100** | USD estimate | plan waits for a yes | yes (never asks) |
-| 3 | practice carve-out | `internal/config/config.go` `DefaultPracticeBudgetUSD` | 2 | **50** | USD/day | self-practice stops | **no — 0 is off** |
| 4 | session ceiling | `internal/config/settings.go` `DefaultSpendRailUSD` | 0 | **0** | USD/session | refuses the next turn | yes |
| 5 | lifted-tier cap | `internal/taxonomy/limits.go` `DefaultTierCapUSD` | 2 | **25** | USD per piece of work | stops buying a stronger model; the verdict comes back as work | yes |
-| 6 | standing per-firing | `internal/standing/standing.go` `DefaultPerRunUSD` | 0.15 | **5** | USD per firing | interrupts that firing | **yes — newly so** |
+| 6 | automation per run | `internal/automation/automation.go` `DefaultUSD` (and `DefaultTime`, 30m) | 0.15 (a standing firing) | **5** | USD per run | stops that run: `incomplete · reached its $5.00 cap` | no — 0 reads as the default |
| 7 | adaptive-run tank | `internal/session/orchestrate.go` `DefaultRunCapUSD` | 10 | **100** | USD per run | parks at a gate and asks for a top-up; running nodes finish | yes |
| 8 | workflow run bound | `internal/craft/types.go` `DefaultRunBudgetUSD` | 2.50 | **50** | USD per run | *"Cost check -- … Say the word and I'll keep going"* | no — 0 reads as the default |
| 9 | workflow ceiling | `internal/craft/types.go` `MaxRunBudgetUSD` | 10 | **500** | USD | the most a workflow file may grant itself | n/a |
@@ -97,10 +93,9 @@ explicit instruction and still errors.
| --- | --- | --- |
| daily budget | `CODEAF_DAILY_BUDGET` | yes |
| plan consent | `CODEAF_PLAN_CONSENT` | yes |
-| practice carve-out | `CODEAF_PRACTICE_BUDGET` | yes |
| session ceiling | — | yes (also per-project) |
| lifted-tier cap | `CODEAF_RESPONSE_LIFT_CAP` | **no** — plumbing, turned when a provider misbehaves or a run is held to a price |
-| standing per-firing | — | `per_run_usd` on the `stand` tool, per item |
+| automation per run | — | the card's limits, per automation (`usd` and `time` in a typed `/automations` line) |
| adaptive-run tank | — | the composer's third line, per run |
| unattended budget | `CODEAF_MAX_COST` / `CODEAF_MAX_HOURS` | no — flags |
| per-task limit | — | `/crew cap task` (`models.crew.task_cap`) |
diff --git a/docs/README.md b/docs/README.md
index 31eba2ff6d..594d194bdc 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -18,7 +18,7 @@ One page that says where everything is. Start here.
| Doc | What it covers |
| --- | --- |
-| [STANDING.md](STANDING.md) | Standing goals — recognition not declaration, ratification, the ambient rail, the watch |
+| [STANDING.md](STANDING.md) | Standing goals — history: the v1 machinery is removed; see [automations](design/automations/DESIGN.md) |
| [LEARNING.md](LEARNING.md) | Learning that compounds — the notebook, the skill forge, experiments, playbooks, the router |
| [META.md](META.md) | Meta-learning — the journal as training data about the learning itself |
| [JOBS.md](JOBS.md) | Background jobs — two primitives, turn-boundary reporting, nothing outlives its leaf |
diff --git a/docs/REMOTE.md b/docs/REMOTE.md
index 247a7d9ad8..45ce65519c 100644
--- a/docs/REMOTE.md
+++ b/docs/REMOTE.md
@@ -133,6 +133,15 @@ leading `!` as model text, including automated submissions. Output events carry
literal stdout/stderr in `Text`, paired by `CallID`, before the final tool result.
A version mismatch refuses this feature rather than interpreting it as a chat.
+**Version 21 adds the automations doors and `Hello.Window`.** A window over `--host`
+or `--at` reads and changes the far machine's automations through thirteen
+`Automations.*` calls on that machine's store (`internal/remote/wire_automations.go`),
+advertised by `Welcome.Automations`, with the far machine's zone in
+`Welcome.AutomationsZone`. `Hello.Window`, said only on the boot connection of those
+two doors, makes the engine hold that machine's automations presence for the
+connection until it ends, so the far clock runs while a window is attached. The local
+engine road never says it: that window already holds presence on its own machine.
+
## Decision 4 — Version 2 separates a conversation's life from a pipe's
**Decision.** In version 1 the engine *was* the ssh command: it read frames on
@@ -189,7 +198,7 @@ that makes a promise about detaching reads it.
## Decision 6 — The engine machine is the authority, always
**Decision.** The tool gate, the spend rail, the API key, the harness registry,
-connected accounts, the session file, standing items, and every path on the
+connected accounts, the session file, automations, and every path on the
wire belong to the machine that runs the work. The surface owns the screen, the
keyboard, the draft, and the input history.
@@ -202,7 +211,7 @@ ignored, so compaction cannot be sized by a stale or different laptop cache.
honesty bug this lane has had came from a screen answering for the wrong
machine. The YOLO badge is drawn from the engine's own profile because a badge
read off the laptop would be a safety claim about a machine nobody consulted.
-Standing items are counted on the engine's disk because they run there.
+Automations are listed off the engine's disk because they run there.
`/export` said `· on this machine` because that was the truth about where the
bytes landed. A surface that resolved any of these locally would be
comprehensible, fast, and wrong.
@@ -314,15 +323,15 @@ through the same fetch door as a path in a reply; it never joins a far session
id to the surface machine's index. `/export` remains deliberately local and
records into the surface machine's index because that is where its file lands.
-**Why one door and not one per place.** Five of the seven are built from that
-single walk — home lists it, tasks reads the task rows inside it, standing walks
-its projects to ask the far store what else stands there, spend joins its titles
-onto the ledger's ids, and search opens a hit's conversation out of it. A method
-per place would have been five round trips answering one question, and five
-chances for two screens to disagree about which machine they were describing.
+**Why one door and not one per place.** Four of the places are built from that
+single walk — home lists it, tasks reads the task rows inside it, spend joins its
+titles onto the ledger's ids, and search opens a hit's conversation out of it. A
+method per place would have been four round trips answering one question, and
+four chances for two screens to disagree about which machine they were
+describing. (Automations have doors of their own, `Automations.*`.)
-**Why the seam answers whether it has an answer.** An empty list of standing
-items and no answer yet are the same thing on a screen — the emptiness law draws
+**Why the seam answers whether it has an answer.** An empty list of
+automations and no answer yet are the same thing on a screen — the emptiness law draws
both as nothing. An empty WORLD is not: it is a machine with no projects, and
`nothing here yet — say something and this fills up` drawn over a server full of
work is the one wrong sentence home can say about somebody else's disk. So the
@@ -333,7 +342,7 @@ far machine has spoken once.
three-second beat, and over a wire both are moments a person is waiting through:
a call carries a ten-second deadline, so an open that made one would be a
terminal that stopped answering keys for as long as the far machine took. This
-is `hostStanding`'s law applied to the reading five screens share.
+is the automations seam's law applied to the reading four screens share.
**What is still local, and says so.** Spend reads the ledger file, search reads
the conversation index, memory reads the memory store — three files on the
diff --git a/docs/STANDING-ORDERS.md b/docs/STANDING-ORDERS.md
index befb32afae..0575d98aec 100644
--- a/docs/STANDING-ORDERS.md
+++ b/docs/STANDING-ORDERS.md
@@ -1,5 +1,16 @@
# Standing orders — the ambient side grows a spine
+> **Superseded, 2026-10-10.** Standing items are gone from the code:
+> `internal/standing`, the `stand` tool, the `/standing` page, the standing
+> card, the inbox, the operating-system timer and `codeaf tick` (now a hidden
+> verb that does nothing, kept for timers an older build installed). Existing
+> items were not migrated; `~/.codeaf/v3/standing/` is left on disk and no
+> longer read. What replaces them is automations —
+> docs/design/automations/DESIGN.md — which run only while a codeaf window is
+> open, and rules, which are memories marked always. Home's `ask here` stayed,
+> with its errands under `~/.codeaf/v3/errands`. The rest of this page is
+> history.
+
*Design doc for the `standing/v0` line of work. Written before any lane started.
`docs/AMBIENT.md` described the ambient side as it shipped; this document is the
next generation of it. Where this doc and the code disagree, the code wins once
diff --git a/docs/STANDING.md b/docs/STANDING.md
index cb191cca5f..ec6026b991 100644
--- a/docs/STANDING.md
+++ b/docs/STANDING.md
@@ -1,5 +1,15 @@
# Standing goals — recognition, ratification, and ambient presence
+> **Removed, 2026-10-10.** The v1 machinery this document describes is gone from
+> the code: charters and their watches, sentinels, probation and tenure, the
+> practice loop, the operating-system timer (`internal/watchdog`), `codeaf wake`
+> as a pass (it is now a hidden verb that does nothing, kept for timers an older
+> build installed) and the doctor rows that read them. A store an older build
+> wrote still opens and rebuilds — the journal keeps its events, which replay
+> passes over, and the `charters` table is left where it is, read only to name
+> an older build's work in a self receipt. What replaces all of it is
+> docs/design/automations/DESIGN.md. The rest of this page is history.
+
*This is the v1-era design doc, and it is the ANCESTOR rather than the account:
what v3 built is docs/AMBIENT.md, and where the two disagree AMBIENT.md and the
code are right. Three decisions below have been overtaken by what shipped and
diff --git a/docs/changes/unreleased/1831-automations.md b/docs/changes/unreleased/1831-automations.md
new file mode 100644
index 0000000000..6e1b88d463
--- /dev/null
+++ b/docs/changes/unreleased/1831-automations.md
@@ -0,0 +1,24 @@
+---
+kind: changed
+title: automations replace standing orders, and rules become memories marked always
+pr: 1831
+surface: [chat, engine, remote, resident, docs]
+invalidates:
+ - "Something to happen later was a standing item, made with `stand` and listed on `/standing`. It is an automation now: the `automation` tool, the `/automations` place, and `/automations add …` for an exact line."
+ - "Standing items fired from an OS timer running `codeaf tick` with no window open. Nothing is installed on the machine any more: automations run only while a codeaf window is open, one `codeaf clock` process runs them, and the first start removes the old timers. Closing codeaf pauses them: a run it cut off runs again, once, when a window next opens."
+ - "A rule (always use tabs here) was a standing order. It is a memory marked always now: `/always `, or `remember` with always, which asks first."
+ - "`internal/standing` held the ambient side. It is deleted; `internal/automation` holds the store, the schedule and the clock."
+ - "The settings sheet had a `background checks` row. It is gone, and `standing.background` is a retired key."
+ - "Standing items were kept across builds. They are not migrated; `~/.codeaf/v3/standing/` is left on disk and no longer read."
+ - "Home's `ask here` errands lived under the standing root. They live in `~/.codeaf/v3/errands`."
+ - "The remote wire carried `Standing.*`, `ResolveStanding` and a marked submit. They are gone; automations cross as `Automations.*` on wire version 21."
+ - "A plan-born worker's brief closed on the standing orders and the rules. It closes on the rules alone."
+ - "An unattended goal owner said yes to a standing card on the person's behalf. An automation card waits for the person."
+ - "The v1 resident's charters, watches, practice loop and `codeaf wake` pass ran on a timer. They are removed; `codeaf wake` and `codeaf tick` are hidden verbs that do nothing."
+ - "The tab bar had seven words. It has eight: `Automations` stands between `Memory` and `Spend` (still `alt+7`), and under 84 columns `Spend` and `Settings` fold behind `more ▾`."
+ - "A turn's banner was always OSC 777, which iTerm2 ignores. In iTerm2 it is OSC 9 now, and an automation's banner rides the terminal there and in Ghostty, kitty, WezTerm and foot, so clicking it brings back the tab."
+ - "The memory tidy ran on the standing pass. Nothing runs it now; the clock does not."
+ - "Home's `since you left` panel whispered `what watches and tasks did while the terminal was shut`. It whispers `what happened while you were away`, because nothing runs while codeaf is shut."
+---
+
+The design is docs/design/automations/DESIGN.md.
diff --git a/docs/contextual-memory.md b/docs/contextual-memory.md
index 522944b68c..a63faf53a1 100644
--- a/docs/contextual-memory.md
+++ b/docs/contextual-memory.md
@@ -87,9 +87,9 @@ and that retention is not an automatic reuse path because a history receipt cann
become independent observation. The notice/receipt attention state is bounded and
evictable; the immutable events_no_delete journal still grows, and that raw audit
growth is an explicit, honest limit rather than something pruned away. Completed/abandoned user-declared work can expire from
-recall. Existing idle maintenance and standing orders remain the only maintenance and
-scheduling machinery. Deferred intention retention alone does not guarantee a standing
-order was proposed or ratified.
+recall. Existing idle maintenance (the memory tidy, which rides the automations clock)
+and automations remain the only maintenance and scheduling machinery. Deferred intention
+retention alone does not guarantee an automation was proposed or saved.
A recorded failure can carry one later observed successful alternative from the same
turn and goal: the same tool class, an action sharing a meaningful token, taken at the
diff --git a/docs/design/automations/DESIGN.md b/docs/design/automations/DESIGN.md
new file mode 100644
index 0000000000..10709f22d0
--- /dev/null
+++ b/docs/design/automations/DESIGN.md
@@ -0,0 +1,368 @@
+# Automations — work codeaf does on a clock
+
+Design doc, 2026-10-10. The owner's decisions of 2026-10-09 are the contract,
+taken after an audit of the standing machinery on 2026-10-09 (its findings that
+matter are restated where they bear: the timer left behind, the drift, the
+repeated hour). This replaces standing items (`internal/standing`, `/standing`, the OS timer) and the
+v1 resident's scheduler. Where this document and the code disagree once the
+build has landed, the code is right and this document is the bug.
+
+## The one-paragraph design
+
+An **automation** is a schedule, an optional look, and an action. Three kinds
+fall out of that: a **reminder** says a fixed line at a time; **scheduled work**
+runs a brief at a time or on a rhythm; a **watch** looks at something on a
+rhythm, asks the model whether a condition is met, and on the change to "yes"
+says a line or runs work. They run **only while a codeaf window is open**, in one
+clock process that any window starts and the last window's closing stops. Every
+run is a row in a small database before it starts and the same row when it ends,
+so every window reads the same truth. **Rules** — "always use tabs here" — are not
+automations: they are memories marked *always*.
+
+## What the person gets
+
+| Kind | Said like | Card heading | Answers |
+|---|---|---|---|
+| reminder | "remind me at 6 to leave" | `wants to remind you` | `1 Save` · `0 Don't save` · `o Change…` |
+| scheduled work | "every Monday at 9, draft the weekly update" | `wants to schedule work` | `1 Save` · `2 Save and run it now` · `0 Don't save` · `o Change…` |
+| watch | "tell me when CI on main goes red" | `wants to watch for something` | `1 Save` · `2 Save and check it now` · `0 Don't save` · `o Change…` |
+
+`esc` puts a card off for later. Nothing is saved until the person says yes.
+
+**The card shows the real thing, not a paraphrase:** the title and the person's
+own sentence; the schedule in words *and* exactly (`every Monday at 09:00 ·
+America/Toronto · cron 0 9 * * 1`) with the next time it will run; for a watch,
+what it looks at (the command, the files or the tool) and the condition it is
+held to; the line it says or the brief it runs; where work runs (`in your
+checkout` or `in a separate worktree, kept for review`); and the limits (`30m ·
+$5 a run`). A watch also shows its estimated cost (`about $0.40 a day while
+codeaf is open`), because the model judges every look; with no known price for
+the judging model it shows nothing.
+
+**Three ways in, one card.** Said in a conversation (the model calls the
+`automation` tool); typed with exact syntax (`/automations add …`, no model in
+between); or edited on the list (`e` puts the automation's own
+`/automations edit …` line in the box). All three end on a card, and nothing is
+saved before the person says yes on it. A typed line's card is the surface's
+own (`save this automation?`, `save this change?`), because no model asked.
+
+**The typed grammar** is a word and one value per clause, a value with a space
+in it quoted (`internal/automation`'s `command.go`):
+
+```
+/automations add "weekly update" every "0 9 * * 1" do "draft the weekly update"
+/automations add leave at 18:00 say "time to leave"
+/automations add "ci on main" every 15m look "gh run list -b main -L 1" until "the latest run failed" once say "CI on main is red"
+/automations edit 3f2a every 30m
+/automations run 3f2a · pause 3f2a · resume 3f2a · delete 3f2a
+```
+
+The clauses are `title`, `at` (`18:00`, `tomorrow 18:00`, `2026-10-12T09:00`),
+`in` (`20m`), `every` (a cron line or an interval), `zone`, `say`, `do`, `look`
+(a command), `files` (a glob), `until` (the condition), `time` and `usd` (the
+limits), `folder`, and the flags `once`, `worktree` and `checkout`. An id may be
+any unique prefix. A moment that has already passed is refused. Every
+automation writes back out as the `edit` line that would make it what it is, so
+the line `e` puts in the box changes nothing when it is saved unedited.
+
+**`/automations`** opens the list: every automation with its mark, title, kind,
+schedule, next run and last result. `enter` opens its history (every run: when,
+how late, what it came to, what it cost; `enter` on a run opens its transcript),
+and `esc` steps back out. The row's own keys are drawn by `→`, the verb strip
+every place uses: `r` run now (`check now` on a watch, `say it now` on a
+reminder), `p` pause or resume, `s` stop a run in progress (drawn only then),
+`e` edit, `d` delete (pressed twice), `o` open the conversation that made it.
+The letters work while the strip is drawn.
+
+**When a run ends** the conversation that made it gets one dim line — not in
+the transcript, and the model is not woken — and the person gets a desktop
+notification. Opening a conversation shows the lines for runs that ended while
+it was closed. A watch's quiet looks ("nothing new") are never news; its row
+says when it last looked.
+
+**Closing codeaf pauses automations** (the owner's ruling, 2026-10-14). Quitting
+never asks: the clock stops a run in progress, records it as stopped, and makes
+its automation due again, so the run picks up once when a window next opens —
+late, like a missed slot. The cut-off attempt is told to nobody. (The first
+build warned `1 automation is running — quitting stops it · ctrl+c again to
+quit` and recorded the run as over; the warning was taken out as one more thing
+to read for a consequence that no longer loses anything.)
+
+### The words
+
+The run words are the task-state words (`docs/design/task-states/DESIGN.md`):
+
+| Outcome | Reads | Delivered? |
+|---|---|---|
+| done | `done` — a reminder said, work reported finished, a watch spoke | yes |
+| your call | `your call · needed your ok to run gh pr list` | yes |
+| incomplete | `incomplete · ran out of its 30m` / `· a fault: …` / `· it ended without saying how it went` | yes |
+| stopped | `stopped · by you` | yes |
+| stopped, cut off | `stopped · codeaf closed before it finished` — runs again on reopen | no |
+| couldn't check | `couldn't check · no API key` (a watch) | yes |
+| nothing new | `nothing new` (a watch looked; condition not met, or still met) | no |
+
+A run that started more than two minutes after its slot reads `late · 2h` beside
+its outcome. `failed`, `fired`, `standing`, `keeping an eye` and the banned
+machinery words do not appear. The feature's name is **automations**, everywhere.
+
+## The object
+
+`internal/automation` owns it. An `Automation` is: an id; a **title**; the
+person's **words** (verbatim, when it was said); a **schedule** (one moment, or a
+rhythm — a five-field cron line or an interval — read in a **zone** captured at
+creation, with an **anchor** for intervals); an optional **look** (exactly one of
+a command, a file glob, or a tool with arguments; a **condition** sentence; and
+**once**); an **action** (exactly one of `say` text or `do` brief); a
+**workspace**; **worktree** (work only); **limits** (time, default 30m; money,
+default $5); its **origin** conversation; a **status** (active, paused,
+finished); and the clock's own state (next slot, what a watch last saw,
+revision). Its kind is derived: a look makes a watch, a line makes a reminder, a
+brief makes work.
+
+A **run** is one waking: the slot it was for, why (`on time`, `late`, `now`),
+its phase (queued, running, over), when it started and ended, its outcome, one
+line, the longer detail (the work's report, what a look saw), its cost, and its
+transcript folder.
+
+## Storage
+
+One SQLite database, `~/.codeaf/v3/automations/automations.db` (WAL, FULL sync,
+its own application id, refused if it is anybody else's). Two tables:
+`automations` (the person's half as a JSON document, the clock's state as
+columns) and `runs`, and a change counter every write stamps rows with.
+
+Why a database, when v3 prefers folders: the feature this replaces kept one JSON
+file per item rewritten by several processes, and most of its code was fences
+against those writers disagreeing. Here:
+
+- a slot becomes a run and the automation moves on **in one transaction**;
+- `UNIQUE (automation, slot, why)` makes a double run impossible;
+- the clock's writes are **column updates guarded by the person's revision**, so
+ an edit made while a run was in flight is never written over, and the clock
+ never rewrites the person's half from a stale copy;
+- every window reads new runs with `WHERE seq > cursor`.
+
+## Schedules
+
+- **Cron** is five numeric fields (ranges, lists, steps; Sunday is 0 or 7;
+ day-of-month and day-of-week OR when both are restricted), read in the
+ automation's zone. The search steps in absolute time and reads the wall clock
+ off each candidate, and **the wall clock may never go backwards**: on the
+ night the clocks fall back a 01:30 line runs once (the old schedule ran it on
+ every pass for an hour); in spring the missing hour has no minutes, so a 02:30
+ line waits a day. A line that can never match is refused at the door. The
+ same rule has a cost the other way: `*/15 * * * *` runs four times in the
+ repeated hour of the autumn night, not eight, because the wall clock's second
+ pass over 01:00–02:00 is a time it has already been.
+- **Intervals** are a grid, `anchor + k × interval`, so a late run never moves
+ the next one later (the old schedule drifted: a `15m` rhythm averaged 17.1
+ minutes). `d` is a day; the minimum is a minute. **An interval is absolute
+ time, not wall time**: `1d` is twenty-four hours, so a `1d` rhythm saved at
+ 09:00 runs at 08:00 or 10:00 after the clocks change. A rhythm that must keep
+ its hour is a cron line (`0 9 * * *`). A count of days that would overflow a
+ duration is refused.
+- **Missed slots catch up once.** However many slots passed while codeaf was
+ closed or the machine slept, ONE run is taken, marked late, and the next slot
+ is the first after now. A one-time reminder is never dropped for being late.
+- **One run at a time per automation.** A slot that comes while the last run is
+ still going is passed over, not queued.
+- **A pause is not a miss.** Resuming a rhythm starts at its next slot.
+
+## The clock
+
+**Presence.** Every open window holds an operating-system lock on a file of its
+own under `~/.codeaf/v3/automations/windows/`. The kernel drops it when the
+process ends, however it ends, so "is a window open?" is "can I take any of
+these locks?" — no heartbeats, no stale entries. Two kinds of holder:
+
+- every **local window** holds its own presence for as long as its surface runs —
+ the in-process door (`cmd/codeaf/chatv3.go`) and the ordinary road
+ (`chatv3_local.go`) alike;
+- a **window attached over `--host`/`--at`** is registered on the far machine
+ by the far engine when the hello says it is a window (`Hello.Window`, set
+ only by those doors, and only on the boot connection that lives as long as
+ the window), and released when that connection ends, however it ends. It
+ holds no presence on the machine it is sitting at: the automations it shows
+ are the far machine's.
+
+**The clock is its own process**, `codeaf clock` (hidden machinery, like
+`codeaf engine`). Not a window: a run must not die with whichever window
+happened to hold it while another is open, and a remote machine has no window
+process. Not a session host: hosts linger thirty minutes, belong to one
+project, and retire on their own schedule.
+
+- Any window that registers presence starts one if `clock.lock` is free (the
+ lock-then-spawn rule `enginehost.Attach` uses), and checks again every 30s so
+ a clock that exited is replaced while windows remain.
+- It holds `clock.lock` for its life. A second one finds it held and exits.
+- Every two seconds it counts windows, takes due slots, starts queued runs (at
+ most two pieces of work and four looks at once), and honours stop requests.
+- With no window open it waits a 30s grace (a window restarting onto a new build
+ closes and reopens its presence in that time), then stops every run — recorded
+ `stopped · codeaf closed before it finished`, with its automation due again at
+ that moment — and exits.
+- On start it closes runs a previous clock left `running` (that process is gone:
+ the lock proved it), the same way.
+- It follows the newest window's environment. Every window writes a salted
+ fingerprint of the environment codeaf reads for keys into its presence file
+ (never a value); a window starts a clock only when it is the newest or shares
+ its fingerprint, and a running clock whose fingerprint differs from the
+ newest window's leaves when nothing is in hand. A key exported in one shell
+ and not another used to decide, by which window opened first, whether every
+ watch could be judged. It also re-reads the profile's key and connected
+ services before every run.
+- It retires when its binary has been replaced and nothing is running; the next
+ window check starts the new build.
+
+**Nothing is installed on the operating system.** The first run of this build
+removes the timers the old feature installed — launchd `ai.agentfield.codeaf.tick`
+and its legacy-named twin, the v1 `ai.agentfield.codeaf.wake`, and the systemd
+units — whoever installed them.
+
+## A run
+
+**A reminder** records `done` with its line. No model is called.
+
+**A watch** looks, then judges:
+
+- *command*: `bash -c` in the workspace, 60s, its process group killed on the
+ deadline, the environment stripped of provider keys (`bare.StreamingEnv`);
+ output and exit status, tail-clipped to 8 KiB, are the sight.
+- *files*: a bounded listing of the glob (names, sizes, a content digest) and
+ what changed since the last look.
+- *tool*: one call, made through the same configuration a conversation has —
+ connected accounts included, the service armed first.
+- **The model judges every look** (the owner's choice), on the low tier: the
+ condition, the sight and what it last decided; it answers yes or no and one
+ line. An answer it cannot give, a provider error, or no key is `couldn't
+ check` with the reason, and changes nothing about what the watch has seen.
+- It speaks on the change to yes. While the condition stays true it is quiet; a
+ no re-arms it. `once` finishes it after it speaks.
+
+**Work** runs as a headless session built exactly like one of the person's
+conversations, in the clock process:
+
+- the configuration comes from the same assembly as a conversation's
+ (`v3ConfigFor`, the half of `openV3Launch` that builds a config; the launch
+ itself is never called, because it resumes the person's newest
+ conversation): models, keys, governance, **connected accounts**, media, and
+ the brain lent **read-only** — its *always* rules are read before the first
+ action, and nothing the run turns up is remembered;
+- **the person's own approval rules** (the interactive reading, never the
+ headless default and never allow-all), with nobody to ask: a call that would
+ ask is refused, and the first such refusal stops the run as `your call`
+ naming the call;
+- `InTask` (no new tasks, no settings, no watches, no automations from inside
+ an automation);
+- in the live checkout, or in a worktree cut for the run whose branch is kept;
+- its own deadline and money cap, checked after every call;
+- **it reports how it went** with the `automation_report` tool (`done` or
+ `incomplete`, and a summary). A run that ends without a report is
+ `incomplete · it ended without saying how it went`; a cut turn is read from
+ its context's cause, never from its text.
+
+**"Save and run it now"** saves the automation, then the tool hands the action
+back to the model to do in the same turn, attended: approval cards reach the
+person, and an "always" answer banks the rule so later unattended runs go
+through. It is not offered for reminders (nothing to try) or for worktree work
+(an attended turn runs in the checkout).
+
+## Delivery
+
+The store is the truth; nothing is pushed between processes. Every window keeps
+a cursor and reads `runs` changed since it, every two seconds — local windows
+from the database, windows on another machine through one wire call,
+`Automations.Changes`. From what it reads a window:
+
+- draws the one line in the origin conversation, if that conversation is open
+ in it;
+- refreshes the list and the counts;
+- raises a desktop notification for a delivered run — once per run per machine
+ (the run's `told` column is claimed in the store, and only the window whose
+ claim lands raises it), focused or not. It goes through the terminal where the
+ terminal raises one — iTerm2's OSC 9; OSC 777 for Ghostty, kitty, WezTerm and
+ foot — because that banner brings back the tab when clicked; otherwise through
+ `osascript` on macOS (as Script Editor, with a sound) and `notify-send` on
+ Linux (with the logo), and the terminal's escape where neither answers. A
+ codeaf-branded banner on macOS needs a Developer ID-signed helper: macOS 26
+ silently refuses notification permission to an ad-hoc-signed applet or
+ bundle (tried 2026-10-14). The first banner on a machine is followed, once,
+ by a line naming the sending application, linked to System Settings →
+ Notifications;
+- says the run's line on the message line of home or a place in front.
+
+## Rules become memories marked always
+
+A rule is a memory with `always` set. It is put in front of every conversation
+turn (in the system prompt, beside where standing orders were) and every task
+brief, at its scope — you, this machine, or this project — with no ages, in a
+stable order, capped. A recalled memory is unchanged: retrieved when relevant.
+
+- `/always ` saves one for this project (for you, from home); the bare
+ command lists the ones in force here.
+- The `remember` tool gains `always`. Because an always rule is in front of
+ every future turn, the model's own `remember` with `always` asks the person
+ first; the person's typed `/always` does not.
+- The memory place marks always rows and `a` toggles the flag.
+- Extraction never sets, removes or supersedes always; a duplicate always write
+ promotes the recalled copy, and a recalled write never demotes an always one.
+- There is no rule for one conversation only and no "not here" exception.
+
+## What is deleted
+
+- `internal/standing`, the `stand` tool and every standing door in
+ `internal/session`, `internal/tui3` (the page, home's standing band and panel,
+ the margin section, the threshold line, the foot count) and `cmd/codeaf`
+ (`chatv3_standing.go`, the ticker, the background repair, the host's
+ standing cache); `/standing` and `/orders`; the remote wire's `Standing.*`
+ methods, `ResolveStanding` and the marked submit; the background-checks
+ setting, whose key is retired.
+- The v1 resident's scheduler: its charters, watches, sentinels, tenure and
+ practice loop, `internal/watchdog` and the doctor's timer rows. `codeaf tick`
+ and `codeaf wake` survive only as hidden verbs that do nothing and exit 0, for
+ a timer an older build installed until this build's first start removes it.
+- The manual pages `keeping-an-eye` and `standing-orders`, and every standing
+ passage elsewhere.
+
+**Existing standing items are not migrated** (the owner's call). The folder
+`~/.codeaf/v3/standing/` is left on disk and no longer read. Collections that
+named a standing item keep the row; it resolves to nothing.
+
+**Re-homed, not deleted:**
+
+- The memory tidy, which rode the standing pass, rode the clock until
+ 2026-10-14: asked every five minutes while a window was open, one at a time
+ and bounded to two minutes, behind its own gates. The memory rework ruled out
+ automatic changes to what is remembered that nobody sees, so the clock stopped
+ asking for it, and the session's half of the pass goes with that rework.
+- Home's `ask here` errands, which lived under the standing root, live in
+ `~/.codeaf/v3/errands`. The launch sweep reaps one that went nowhere after a
+ week, unless an automation was saved from it, because "open where it was
+ asked" reads that conversation.
+- Spend attribution names automations, and an older ledger line's standing item
+ is read as one.
+- `codeaf do` and plan workers read *always* memories where they read standing
+ orders.
+- An unattended goal owner no longer says yes to a card on the person's behalf:
+ an automation needs the person's own yes.
+
+## Build order
+
+1. `internal/automation`: the object, schedule, store, presence, clock (done
+ first, with its tests).
+2. Memory *always*: column and migration, the store door, `remember`, `/always`,
+ injection into turns, task briefs and `codeaf do`, the memory place, the
+ manual.
+3. The session side: `v3SessionConfig`, the runner (look, judge, work,
+ `automation_report`), the `automation` tool and its card event.
+4. The process side: `codeaf clock`, presence in `runSurface` and the far
+ host, the spawn check, old-timer removal.
+5. The surface: the card, `/automations`, the typed grammar, the lines, the
+ notifications, the quit question, the wire calls.
+6. Delete standing and the v1 scheduler; rewrite the manual; satisfy the gates.
+
+## Not in this design
+
+Push triggers (a webhook, a file event), running with codeaf closed, phone
+notifications, and automations that make automations.
diff --git a/docs/design/home-rethink/HANDOFF.md b/docs/design/home-rethink/HANDOFF.md
index 23426539be..364bd85ba0 100644
--- a/docs/design/home-rethink/HANDOFF.md
+++ b/docs/design/home-rethink/HANDOFF.md
@@ -41,9 +41,10 @@ comes back to the same home. It writes inside that directory and nowhere else
| tasks | 13 rows across the three buckets — one running, one needing your look with a 101-character title, eight landed (one of them a `saved shape`), three failed |
| a conversation's own work | `Sweeping the Frame Budget` keeps a checkpointed task graph of 6 nodes and 3 background jobs in its session folder — see below |
| belt runs | the conversation's own session folder (`plandb.db` beside its transcript) holds three runs tied to `Sweeping the Frame Budget`: a running auth-flow rewrite with done, running, pending, dependent, nested and check rows; an older completed poem run; and an older index run with a failed child. Every task has a trajectory, the live rows have commands, and notes and spend make their rows draw. |
-| standing | 4 orders in `v3/standing/*.json` — one needing your look, one fired today with a check line and `earning trust 3/5`, one paused, one rule (`holds`) — plus day ledgers so cost per firing draws |
+| automations | 7 in `v3/automations/automations.db` across the three projects — a weekday reminder, a reminder still to come, the morning sweep (work on a cron line, in the checkout), a dependency bump every two days in a separate worktree, a watch on CI (a command, every fifteen minutes), a once-watch on `pricing.json` that spoke and finished, and one paused — with about sixty runs over the fortnight: every outcome (`done`, `nothing new`, `your call`, `incomplete`, `stopped`, `couldn't check`), catch-ups marked late after a weekend away, a late start and an early finish, and every run of work with its session folder on disk under `runs/` |
+| a conversation's automation lines | three of the seven were said in conversations, and their `Words` are turns those journals really have — so opening `Standing Up the Watches` draws the red CI run and the look it could not decide, and `What the Discount Means` draws the look the provider never answered and the one where its watch spoke |
| memory | 12 memories over all three shelves in `graph.db`, with varied use and miss counts and one let go |
-| spend | ~57 lines over 14 days across three models, bound to conversations, work and standing orders — every id joins to a row that is really there |
+| spend | ~100 lines over 14 days across three models, bound to conversations, work and automations — an automation's lines are its own runs' costs, so the spend page and its history add up to the same money, and every id joins to a row that is really there |
| search | every turn of every conversation in the message index |
| home's `since you left` | a `.last-look` stamp twelve hours old under `v3/projects`, which is the ORIGIN news is measured from — without it that panel whispers on a fixture full of landed work, because a machine home has never been closed on has no origin and "the first look marks NOTHING as news" (`internal/session/look.go`) |
| made for you | 3 deliverables, with the files behind them on the disk |
@@ -95,10 +96,41 @@ the engine replays every restored job onto the lane the surface is listening on,
page draw nothing on a resumed conversation however much the fixture seeds, and that is a
surface defect rather than a hole in this file.
+### The automations — a fortnight of history, and a clock that never starts
+
+`seed_automations.go` makes every automation through `automation.Store.Create`, exactly as
+a card's yes does, and writes its history through the clock's own doors — `Take`, `Start`,
+`Finish`, `SetSeen`, `FinishWatch` and the person's `SetStatus`. The one thing it adds is
+WHEN: `Store.SetClock` (`internal/automation`, written for this and called by nothing in
+the product) tells those doors what time it was.
+
+**The history is the clock replayed, not typed.** The fixture says when a window was open —
+working days from 08:40 to 18:20, a weekend away nine and eight days back, a late start, an
+early finish, and the window the person is sitting in now — and walks every automation
+through those windows the way the clock does: a slot is taken on time while a window is
+open, and whatever passed while none was is caught up once, late, when the next one opens.
+Which runs are late, and when each automation next wakes, is the schedule's own arithmetic;
+only what each run came to is written down, newest first, and a watch's history is held to
+the clock's rule for speaking (on the change to yes, quiet while it stays true).
+
+**No automation runs in a demo home.** A window starts `codeaf clock` against the home it was
+opened on, and the clock would run the seeded work for real — the sweep and the dependency
+bump are unattended sessions on the person's own key, and the watch on CI asks a model every
+fifteen minutes. So `--launch` and the hand-open line both set `CODEAF_NO_AUTOMATIONS=1`: the
+window still reads the store, lists the automations and draws their lines, and nothing runs.
+The cost is that an automation made inside the demo is saved and never runs.
+`TestTheDemoHomeNeverStartsTheAutomationsClock` holds the launcher and
+`automation.Startable` to that.
+
+**And nothing is left in hand.** Every run is over before the fixture's now and every active
+automation next wakes after it — at noon and on both sides of midnight
+(`TestTheDemoHomesLedgersStayInsideTheirOwnDays`) — because a run left `running` would make
+the quit question ask about work nobody is doing.
+
### How it is built, and the two rules it keeps
`cmd/codeaf-demo-home` writes through **the engine's own writers** — `session.SaveMeta`,
-`session.RecordUsage`, `session.RecordArtifact`, `standing.Store`, `store.Store` — so what
+`session.RecordUsage`, `session.RecordArtifact`, `automation.Store`, `store.Store` — so what
the surface reads back is what the product itself produces. There is exactly one place
that spells a file shape for itself, and it says so: a session journal is written by a
live agent through an unexported type and there is no seam for "write me a conversation
@@ -124,11 +156,11 @@ and the panel fills. A screenshot of the demo with an empty `running` is a scree
the missing heartbeat, not of the panel.
`TestTheDemoHomeFillsEveryPlace` reads all of it back through `session.ReadWorld`,
-`session.Peek`, `session.ReadTaskIndex`, the standing store, `store.MemorySnapshot`,
+`session.Peek`, `session.ReadTaskIndex`, the automations store, `store.MemorySnapshot`,
`session.ReadUsage`, `Store.SearchConversations` and `session.ReadArtifacts`, and insists
every place has rows — so the fixture cannot rot in silence.
-### Two things the demo makes visible that are not the fixture's fault
+### Three things the demo makes visible that are not the fixture's fault
- Every memory reads `now` and `new today`, because the memory store stamps an event with
its own clock and there is no door that backdates one. A demo built this morning
@@ -138,3 +170,10 @@ every place has rows — so the fixture cannot rot in silence.
joins on `TaskIndexEntry.Name`, which is the kebab-cased mention handle, rather than on
`Label`, which is the title as a row draws it. Worth a one-line fix in a lane that owns
that page.
+- The spend page's `what it was for` column cannot name an automation yet.
+ `session.UsageBySubject` has no automation subject, so a ledger line carrying
+ `UsageLine.Automation` is grouped as a conversation: a watch's judgments as one row with
+ no id, and each run of work under its own run number. The lines are the ones the product
+ writes; the reader that turns them into one row per automation, titled, is part of
+ re-homing spend attribution onto automations
+ (`docs/design/automations/DESIGN.md`, "Re-homed, not deleted").
diff --git a/docs/memory-architecture.md b/docs/memory-architecture.md
index f54a88726a..ffda143c0f 100644
--- a/docs/memory-architecture.md
+++ b/docs/memory-architecture.md
@@ -54,9 +54,11 @@ Three product constraints shape the redesign:
edge. This makes replication convergent and the history complete.
5. **Every behavior-changing injection carries its why.** Trust is a schema
property.
-6. **Memory, skills, and standing orders stay separate.** Memory is what is
- true; a skill is how to do something; a standing order is a rule that
- fires. Memory may reference either; it never becomes one.
+6. **Memory, skills, and automations stay separate.** Memory is what is
+ true, and a rule for all future work is a memory marked always; a skill is
+ how to do something; an automation is work on a clock. Memory may reference
+ either; it never becomes one. (Until 2026-10-10 rules were standing orders;
+ docs/design/automations/DESIGN.md says why they moved.)
7. **Failures are journaled and visible.** "Just works" includes "tells you
when it didn't."
diff --git a/internal/approval/approval.go b/internal/approval/approval.go
index 42f1ab0f23..cea1689cd0 100644
--- a/internal/approval/approval.go
+++ b/internal/approval/approval.go
@@ -203,7 +203,7 @@ func (p Policy) Check(tool string, args json.RawMessage) Decision {
tool = strings.TrimSpace(tool)
base := p.base(tool, args)
if tool != ToolBash {
- return p.maybeAllowSelfAsking(tool, p.maybeAllowReadOnly(tool, args, base))
+ return keepsARuleFloor(tool, args, p.maybeAllowSelfAsking(tool, p.maybeAllowReadOnly(tool, args, base)))
}
command, ok := bashCommand(args)
if !ok {
diff --git a/internal/approval/rule.go b/internal/approval/rule.go
new file mode 100644
index 0000000000..64e3c19997
--- /dev/null
+++ b/internal/approval/rule.go
@@ -0,0 +1,56 @@
+package approval
+
+import (
+ "encoding/json"
+ "strings"
+)
+
+// ToolRemember is the memory tool, named here because one shape of its call is
+// a floor of its own ([KeepsARule]). It is a wire token the session's belt
+// registers under this exact name; internal/session's tool is built from it.
+const ToolRemember = "remember"
+
+// KeepsARuleReason is what the person reads on the card a rule-keeping call
+// raises, and what the model is told when nobody was there to answer it. It is
+// one sentence spelled once, for [Decision.Rule]'s reason: every surface says the
+// same words about the same floor rather than each deriving its own.
+const KeepsARuleReason = "an always rule is put in front of every conversation and task here"
+
+// KeepsARule reports whether a call would keep a RULE: `remember` with `always`
+// set, which puts its line in front of every later turn and task at its scope
+// rather than recalling it when it seems relevant.
+//
+// IT IS A FLOOR UNDER EVERY ALLOW, INCLUDING ONE THAT NAMES THE TOOL. The
+// shipped rows allow `remember` by name because writing itself a note is the
+// agent's own bookkeeping, which nobody should be asked about. A rule is not
+// bookkeeping: it is an instruction to every conversation the person will have
+// here, and the person is the only one who can give that. So the floor does not
+// yield to a named allow the way [ActsInThePersonsName]'s does — the named allow
+// was written about notes, and this is not a note. A deny still refuses: a rule
+// that refuses outright is a refusal, and a floor only ever turns a yes into a
+// question.
+//
+// AN UNREADABLE CALL IS NOT A RULE. The arguments say whether `always` is set;
+// if they cannot be read the call cannot run either, and the tool's own refusal
+// is the honest answer rather than a question about nothing.
+func KeepsARule(tool string, args json.RawMessage) bool {
+ if strings.TrimSpace(tool) != ToolRemember {
+ return false
+ }
+ var fields struct {
+ Always bool `json:"always"`
+ }
+ if err := json.Unmarshal(args, &fields); err != nil {
+ return false
+ }
+ return fields.Always
+}
+
+// keepsARuleFloor turns an allow into a question for a call that keeps a rule,
+// and leaves every other answer exactly as it came.
+func keepsARuleFloor(tool string, args json.RawMessage, decision Decision) Decision {
+ if decision.Action != ActionAllow || !KeepsARule(tool, args) {
+ return decision
+ }
+ return Decision{Action: ActionPrompt, Rule: KeepsARuleReason}
+}
diff --git a/internal/approval/rule_test.go b/internal/approval/rule_test.go
new file mode 100644
index 0000000000..c8416f7f96
--- /dev/null
+++ b/internal/approval/rule_test.go
@@ -0,0 +1,54 @@
+package approval
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+// A RULE IS THE PERSON'S TO GIVE. `remember` with `always` set asks under
+// every allow — the blanket one, and the shipped row that names the tool for
+// the agent's own notes — and a deny still refuses; the same tool writing an
+// ordinary note is untouched.
+func TestARuleAsksUnderEveryAllowAndANoteDoesNot(t *testing.T) {
+ rule := json.RawMessage(`{"text":"always use tabs here","always":true}`)
+ note := json.RawMessage(`{"text":"prefers tabs"}`)
+ for name, policy := range map[string]Policy{
+ "blanket allow": {Default: ActionAllow},
+ "the row names it": {Default: ActionPrompt, Tools: map[string]Action{ToolRemember: ActionAllow}},
+ "allow and named, too": {Default: ActionAllow, Tools: map[string]Action{ToolRemember: ActionAllow}},
+ } {
+ got := policy.Check(ToolRemember, rule)
+ if got.Action != ActionPrompt || got.Rule != KeepsARuleReason {
+ t.Errorf("%s: a rule = %v, want a question saying %q", name, got, KeepsARuleReason)
+ }
+ if got := policy.Check(ToolRemember, note); got.Action != ActionAllow {
+ t.Errorf("%s: an ordinary note = %v, want it allowed", name, got)
+ }
+ }
+ denied := Policy{Default: ActionAllow, Tools: map[string]Action{ToolRemember: ActionDeny}}
+ if got := denied.Check(ToolRemember, rule); got.Action != ActionDeny {
+ t.Errorf("a rule under a deny = %v, want the deny to stand", got)
+ }
+}
+
+// ONLY THE MEMORY TOOL'S OWN FIELD MAKES A RULE. Another tool carrying the same
+// word, an `always` that is false and arguments that cannot be read are none of
+// them a rule.
+func TestOnlyRememberWithAlwaysKeepsARule(t *testing.T) {
+ for _, call := range []struct {
+ tool string
+ args string
+ want bool
+ }{
+ {ToolRemember, `{"text":"x","always":true}`, true},
+ {" " + ToolRemember + " ", `{"text":"x","always":true}`, true},
+ {ToolRemember, `{"text":"x","always":false}`, false},
+ {ToolRemember, `{"text":"x"}`, false},
+ {ToolRemember, `not json`, false},
+ {"write", `{"always":true}`, false},
+ } {
+ if got := KeepsARule(call.tool, json.RawMessage(call.args)); got != call.want {
+ t.Errorf("KeepsARule(%q, %s) = %v, want %v", call.tool, call.args, got, call.want)
+ }
+ }
+}
diff --git a/internal/automation/automation.go b/internal/automation/automation.go
new file mode 100644
index 0000000000..dd6493059d
--- /dev/null
+++ b/internal/automation/automation.go
@@ -0,0 +1,361 @@
+// Package automation is the work codeaf does on a clock: a reminder at six, the
+// weekly update every Monday at nine, a watch on CI that speaks when it goes
+// red. docs/design/automations/DESIGN.md is the design; this package is the
+// object, the store, the schedule and the clock. What a run actually DOES —
+// a look, a judgment, a piece of unattended work — is the session's business
+// and arrives through [Runner].
+//
+// ── THE LAWS ──
+//
+// - THREE KINDS, ONE MECHANISM. A reminder, scheduled work and a watch are
+// all an [Automation]: a schedule, an optional look, and an action. A watch
+// is a schedule whose action waits on a look; a reminder is a schedule whose
+// action is a fixed line. Nothing else wakes.
+//
+// - NOTHING RUNS WHILE CODEAF IS CLOSED. The clock lives inside codeaf and
+// runs only while at least one window is open ([Presence]). There is no
+// operating-system timer, no daemon and nothing to install. What fell due
+// while codeaf was closed catches up ONCE when it opens, marked late.
+//
+// - ONE CLOCK AT A TIME. Every window's process may hold the clock, and an
+// operating-system file lock decides which one does ([Clock]). The lock dies
+// with its process, so a crash never leaves the machine without a clock.
+//
+// - THE STORE IS THE TRUTH. Every run is a row before it starts and the same
+// row when it ends. A window learns what happened by reading the store, never
+// by being the process that happened to run it, so news reaches every window
+// whichever process held the clock.
+//
+// - AN OUTCOME IS A FACT, NEVER A GUESS FROM PROSE. Work says how it went by
+// reporting it ([Report]); a run that ends without a report, runs out of
+// time, hits its cap or loses its provider is `incomplete` with the reason.
+// The words are the task-state words (docs/design/task-states/DESIGN.md):
+// done, stopped, incomplete, your call — never "failed".
+//
+// - NOTHING IS SAVED UNTIL THE PERSON SAYS YES. [Store.Create] is called after
+// a card was answered or a typed command was confirmed; nothing arms itself.
+package automation
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// The limits one run gets when nobody named any. They are shown on every card
+// and edited there, never applied silently.
+const (
+ DefaultTime = 30 * time.Minute
+ DefaultUSD = 5.0
+)
+
+// Kind is what an automation is, derived from what it holds: a look makes a
+// watch, a fixed line makes a reminder, and a brief makes scheduled work.
+type Kind string
+
+const (
+ KindReminder Kind = "reminder"
+ KindWork Kind = "work"
+ KindWatch Kind = "watch"
+)
+
+// Status is where an automation is in its life.
+type Status string
+
+const (
+ // StatusActive wakes on its schedule.
+ StatusActive Status = "active"
+ // StatusPaused keeps everything and wakes for nothing until it is resumed.
+ StatusPaused Status = "paused"
+ // StatusFinished is a one-time automation that has run, or a once-watch
+ // that has spoken. It stays in the list with its history.
+ StatusFinished Status = "finished"
+)
+
+// Automation is one thing codeaf does on a clock. The top half is what the
+// person agreed to; [Automation.Next] and [Automation.Seen] are the clock's
+// own present, rewritten as it works.
+type Automation struct {
+ ID string `json:"id"`
+ // Title is the short name a row leads with.
+ Title string `json:"title"`
+ // Words is the person's own sentence when it was said in a conversation,
+ // kept verbatim as provenance. A typed automation has none.
+ Words string `json:"words,omitempty"`
+ Schedule Schedule `json:"schedule"`
+ // Look is set for a watch and only for a watch.
+ Look *Look `json:"look,omitempty"`
+ Action Action `json:"action"`
+ // Workspace is where the automation runs: a project's root, or the
+ // person's home for one that belongs to no project.
+ Workspace string `json:"workspace"`
+ // Worktree runs work in a separate git worktree whose branch is kept for
+ // review, rather than in the live checkout.
+ Worktree bool `json:"worktree,omitempty"`
+ Limits Limits `json:"limits"`
+ Origin Origin `json:"origin,omitempty"`
+
+ Status Status `json:"status"`
+ Created time.Time `json:"created"`
+ Updated time.Time `json:"updated"`
+ // Revision counts the person's changes. The clock's own writes are guarded
+ // by it, so an edit made while a run was in flight is never written over.
+ Revision int64 `json:"revision"`
+
+ // Next is when it next wakes; zero when it never will again.
+ Next time.Time `json:"next,omitempty"`
+ // Seen is a watch's last decided judgment, "yes" or "no", and empty before
+ // its first. A watch speaks on the change from anything else to "yes".
+ Seen string `json:"seen,omitempty"`
+ // Memo is what a watch's look keeps between looks — a file watch's listing,
+ // so the next look can say what changed. It is the look's own business and
+ // opaque to everything else.
+ Memo string `json:"memo,omitempty"`
+
+ // Last is the most recent run that ended, filled in by [Store.List] and
+ // [Store.Get] from the runs table so a list can say what each one last came
+ // to without asking once per row. IT IS NEVER STORED IN THE DOCUMENT: the
+ // runs table is the one record of a run.
+ Last *Run `json:"last,omitempty"`
+}
+
+// Look is what a watch looks at, and the sentence its judgment is held to.
+// Exactly one of Command, Files or Tool is set.
+type Look struct {
+ // Command is a shell command run in the workspace.
+ Command string `json:"command,omitempty"`
+ // Files is a glob, relative to the workspace, whose files are read.
+ Files string `json:"files,omitempty"`
+ // Tool and Args are one call to a tool on the belt — a connected
+ // account's, most often.
+ Tool string `json:"tool,omitempty"`
+ Args json.RawMessage `json:"args,omitempty"`
+ // Service is the connected account a Tool belongs to, when it is one: its
+ // tools are only on the belt once the service is put to use, so the look
+ // puts it to use first.
+ Service string `json:"service,omitempty"`
+ // Condition is what the model is asked of every look: "the latest run on
+ // main failed", "an invoice from Hetzner arrived".
+ Condition string `json:"condition"`
+ // Once finishes the watch after the first time it speaks.
+ Once bool `json:"once,omitempty"`
+}
+
+// Action is what happens when an automation wakes, or when its watch speaks.
+// Exactly one of Say or Do is set.
+type Action struct {
+ // Say is a fixed line delivered as it is. No model is called.
+ Say string `json:"say,omitempty"`
+ // Do is a brief carried out as unattended work.
+ Do string `json:"do,omitempty"`
+}
+
+// Limits bound one run.
+type Limits struct {
+ // Time is the most one run may take. Zero is [DefaultTime].
+ Time time.Duration `json:"time,omitempty"`
+ // USD is the most one run may spend, a watch's judgment included. Zero is
+ // [DefaultUSD].
+ USD float64 `json:"usd,omitempty"`
+}
+
+// Effective is the limits with defaults applied — the figures a card quotes and
+// a run is held to.
+func (l Limits) Effective() Limits {
+ if l.Time <= 0 {
+ l.Time = DefaultTime
+ }
+ if l.USD <= 0 {
+ l.USD = DefaultUSD
+ }
+ return l
+}
+
+// Origin is the conversation an automation was made in: where its one line
+// goes when a run ends, and the door "why did this happen?" opens.
+type Origin struct {
+ SessionID string `json:"sessionId,omitempty"`
+ Transcript string `json:"transcript,omitempty"`
+}
+
+// Kind is what this automation is.
+func (a Automation) Kind() Kind {
+ switch {
+ case a.Look != nil:
+ return KindWatch
+ case strings.TrimSpace(a.Action.Say) != "":
+ return KindReminder
+ default:
+ return KindWork
+ }
+}
+
+// Validate is the whole admission law in one place.
+func (a Automation) Validate() error {
+ if strings.TrimSpace(a.Title) == "" {
+ return errors.New("an automation needs a title")
+ }
+ if strings.TrimSpace(a.Workspace) == "" {
+ return errors.New("an automation needs a workspace")
+ }
+ if err := a.Schedule.Validate(); err != nil {
+ return err
+ }
+ say, do := strings.TrimSpace(a.Action.Say), strings.TrimSpace(a.Action.Do)
+ switch {
+ case say == "" && do == "":
+ return errors.New("an automation needs something to say or something to do")
+ case say != "" && do != "":
+ return errors.New("an automation says a line or does work, not both")
+ }
+ if a.Worktree && do == "" {
+ return errors.New("only work can run in a separate worktree")
+ }
+ if look := a.Look; look != nil {
+ set := 0
+ for _, field := range []string{look.Command, look.Files, look.Tool} {
+ if strings.TrimSpace(field) != "" {
+ set++
+ }
+ }
+ if set != 1 {
+ return errors.New("a watch looks at exactly one of a command, files or a tool")
+ }
+ if strings.TrimSpace(look.Condition) == "" {
+ return errors.New("a watch needs the condition it is looking for")
+ }
+ if !a.Schedule.Repeats() {
+ return errors.New("a watch needs a rhythm to look on")
+ }
+ if len(look.Args) > 0 && !json.Valid(look.Args) {
+ return errors.New("a watch's tool arguments are not valid JSON")
+ }
+ }
+ if a.Limits.Time < 0 || a.Limits.USD < 0 {
+ return errors.New("a limit cannot be negative")
+ }
+ switch a.Status {
+ case "", StatusActive, StatusPaused, StatusFinished:
+ default:
+ return fmt.Errorf("unknown status %q", a.Status)
+ }
+ return nil
+}
+
+// ── runs ────────────────────────────────────────────────────────────────────
+
+// Phase is where a run is: waiting for a slot, in hand, or over.
+type Phase string
+
+const (
+ PhaseQueued Phase = "queued"
+ PhaseRunning Phase = "running"
+ PhaseOver Phase = "over"
+)
+
+// Outcome is what a run came to. The words a person reads are [Outcome.Word].
+type Outcome string
+
+const (
+ // OutcomeDone is work that reported it finished, a reminder said, or a
+ // watch whose condition turned true.
+ OutcomeDone Outcome = "done"
+ // OutcomeQuiet is a watch that looked and found its condition not met, or
+ // still met since it last spoke. Nothing is delivered for it.
+ OutcomeQuiet Outcome = "quiet"
+ // OutcomeYourCall is a run that stopped on something only the person can
+ // allow or answer.
+ OutcomeYourCall Outcome = "your-call"
+ // OutcomeIncomplete is a run that ended without finishing; Line says why.
+ OutcomeIncomplete Outcome = "incomplete"
+ // OutcomeStopped is a run the person stopped, or one codeaf closing ended.
+ OutcomeStopped Outcome = "stopped"
+ // OutcomeUnchecked is a watch whose look or judgment could not be made.
+ // It changes nothing about what the watch has seen.
+ OutcomeUnchecked Outcome = "unchecked"
+)
+
+// Word is the outcome as a person reads it.
+func (o Outcome) Word() string {
+ switch o {
+ case OutcomeDone:
+ return "done"
+ case OutcomeQuiet:
+ return "nothing new"
+ case OutcomeYourCall:
+ return "your call"
+ case OutcomeIncomplete:
+ return "incomplete"
+ case OutcomeStopped:
+ return "stopped"
+ case OutcomeUnchecked:
+ return "couldn't check"
+ }
+ return ""
+}
+
+// Delivered reports whether a run with this outcome is news: it gets a line in
+// its conversation and a notification. A quiet look is the one that is not —
+// QUIET IS THE DESIGN, and a watch that checked faithfully all day says so on
+// its row, not in a stream of "nothing new".
+func (o Outcome) Delivered() bool { return o != OutcomeQuiet && o != "" }
+
+// Why says what woke a run.
+type Why string
+
+const (
+ // WhyOnTime is a slot taken when it fell due.
+ WhyOnTime Why = "on time"
+ // WhyLate is a slot taken after it fell due — codeaf was closed or the
+ // machine was asleep. However many slots were missed, one run catches up.
+ WhyLate Why = "late"
+ // WhyNow is a run somebody asked for outside the schedule.
+ WhyNow Why = "now"
+)
+
+// LateAfter is how far behind its slot a run may start and still be on time.
+// The clock looks every few seconds, so anything past this was really missed.
+const LateAfter = 2 * time.Minute
+
+// Run is one waking of one automation.
+type Run struct {
+ ID int64 `json:"id"`
+ AutomationID string `json:"automationId"`
+ // Due is the slot this run was for; for a run asked for now, the moment
+ // it was asked.
+ Due time.Time `json:"due"`
+ Why Why `json:"why"`
+ Phase Phase `json:"phase"`
+ Started time.Time `json:"started,omitempty"`
+ Finished time.Time `json:"finished,omitempty"`
+ Outcome Outcome `json:"outcome,omitempty"`
+ // Line is the one sentence a person reads for this run: what it said, what
+ // it found, or why it stopped.
+ Line string `json:"line,omitempty"`
+ // Detail is the longer account — the work's report, what a look saw.
+ Detail string `json:"detail,omitempty"`
+ USD float64 `json:"usd,omitempty"`
+ // Transcript is the folder of the session a piece of work ran in.
+ Transcript string `json:"transcript,omitempty"`
+}
+
+// News reports whether this run is something to tell a person: over, with an
+// outcome worth a line, and not one codeaf closing cut off — that one is
+// paused, and the run that picks up after it is the one that is told.
+func (r Run) News() bool { return r.Phase == PhaseOver && r.Outcome.Delivered() && !r.Closed() }
+
+// Closed reports whether codeaf closing cut this run off. Such a run is paused
+// rather than over: nobody is told about it, and its automation runs again,
+// once, when a window next opens ([Store.Finish]).
+func (r Run) Closed() bool { return r.Outcome == OutcomeStopped && r.Line == LineClosed }
+
+// Late is how far behind its slot the run started, and zero when it was on
+// time or asked for now.
+func (r Run) Late() time.Duration {
+ if r.Why != WhyLate || r.Started.IsZero() {
+ return 0
+ }
+ return r.Started.Sub(r.Due)
+}
diff --git a/internal/automation/clock.go b/internal/automation/clock.go
new file mode 100644
index 0000000000..d614aa34a2
--- /dev/null
+++ b/internal/automation/clock.go
@@ -0,0 +1,614 @@
+package automation
+
+// clock.go is the one loop that runs automations: hold the lock, look every few
+// seconds, take what is due, run it, and stop when the last window closes.
+//
+// IT IS A PROCESS OF ITS OWN (`codeaf clock`, started by any window that finds
+// no clock running) and not a goroutine in a window or a session host. A run
+// must not die with whichever window happened to hold the clock while another
+// window is still open, a remote machine has no window process at all, and a
+// session host lingers half an hour after its last window and is tied to one
+// project. The clock is tied to none of those lifetimes: it lives exactly as
+// long as some window is open, plus a short grace.
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/filelock"
+)
+
+// ErrHeld means another process holds the clock. It is the ordinary answer for
+// every clock but one, and not a failure.
+var ErrHeld = errors.New("another codeaf already holds the automations clock")
+
+// The clock's own pace. They are variables so a test can run a day in a
+// second; nothing in the product writes them.
+var (
+ // pollEvery is how often the clock looks: for due slots, for runs somebody
+ // asked for, for stop requests and for open windows.
+ pollEvery = 2 * time.Second
+ // closingGrace is how long the clock waits, with no window open, before it
+ // stops runs and leaves. It covers a window restarting itself onto a new
+ // build, which closes and reopens its presence in the same second.
+ closingGrace = 30 * time.Second
+ // stopWait bounds how long the clock waits for a stopped run to record
+ // itself before it records the run for it.
+ stopWait = 15 * time.Second
+)
+
+// MaxWork is how many pieces of work run at once. A Monday morning after a
+// weekend away can make every routine due in the same second; they queue and
+// two run at a time rather than all at once.
+const MaxWork = 2
+
+// maxLooks bounds watches looking at once. A look is short, so more run side
+// by side than pieces of work do.
+const maxLooks = 4
+
+// The lines a run carries when the clock, and not the run, decided its end.
+//
+// [LineClosed] IS EXPORTED BECAUSE IT IS A FACT A WINDOW ACTS ON: a run codeaf
+// closing cut off is paused rather than over, so it is told to nobody and runs
+// again when a window next opens ([Run.Closed], [Store.Finish]).
+const (
+ LineClosed = "codeaf closed before it finished"
+ lineStopped = "stopped by you"
+ lineDeleted = "the automation was deleted"
+)
+
+// Runner does what a run does. internal/session implements it over the
+// person's own configuration: their models, keys, connected accounts and
+// approval rules.
+type Runner interface {
+ // Look takes one look for a watch and says what it saw.
+ Look(ctx context.Context, a Automation) (Sight, error)
+ // Judge asks the model whether what a look saw meets the watch's condition.
+ Judge(ctx context.Context, a Automation, sight Sight) (Judgment, error)
+ // Work carries out the automation's brief as unattended work. evidence is
+ // what a watch saw when it spoke, and empty otherwise.
+ Work(ctx context.Context, a Automation, run Run, evidence string) (Report, error)
+}
+
+// Sight is what one look saw.
+type Sight struct {
+ // Text is the output, clipped to what a judgment can read.
+ Text string
+ // Memo is what the look wants back next time ([Automation.Memo]). It is
+ // kept once the look's judgment is decided.
+ Memo string
+ // USD is what the look itself cost — a tool on a paid service may.
+ USD float64
+}
+
+// Judgment is the model's answer to "does this meet the condition?".
+type Judgment struct {
+ // Met is the answer, read only when Sure.
+ Met bool
+ // Sure is false when the model could not decide; an unsure judgment
+ // changes nothing about what the watch has seen.
+ Sure bool
+ // Line is the model's one sentence of reasoning.
+ Line string
+ USD float64
+}
+
+// Report is how a piece of work says it went.
+type Report struct {
+ // Outcome is done, incomplete or your call. A run that ends because its
+ // context ended is recorded by the clock, whatever it reports.
+ Outcome Outcome
+ Line string
+ Detail string
+ USD float64
+ Transcript string
+}
+
+// Clock runs automations while windows are open.
+type Clock struct {
+ Store *Store
+ Presence *Presence
+ Runner Runner
+ // Now is the clock's time. Nil is the wall clock.
+ Now func() time.Time
+ // Log receives one line for anything that went wrong outside a run — a
+ // store error, a run that could not be recorded. Nil discards.
+ Log func(string)
+ // Env is the fingerprint of the environment this clock was started in
+ // ([Window.Env]). When the newest open window says a different one and no
+ // run is in hand, the clock leaves, and that window starts the next: a
+ // clock started from a shell without the person's key would otherwise
+ // fail every check and every piece of work for as long as any window
+ // stayed open. Empty never leaves for this reason.
+ Env string
+
+ mu sync.Mutex
+ inflight map[int64]*flight
+ work int
+ looks int
+ wg sync.WaitGroup
+}
+
+// flight is one run in hand.
+type flight struct {
+ cancel context.CancelCauseFunc
+ work bool
+ automation string
+}
+
+// errStopRequested and errClosing are the causes the clock cancels a run with,
+// so the run's record says which.
+var (
+ errStopRequested = errors.New(lineStopped)
+ errClosing = errors.New(LineClosed)
+ errDeleted = errors.New(lineDeleted)
+)
+
+// LockName is the clock's lock file inside the store's root.
+const LockName = "clock.lock"
+
+// Held reports whether some process holds the clock right now. A window asks
+// before it starts one.
+func Held(root string) bool {
+ file, err := os.OpenFile(filepath.Join(root, LockName), os.O_CREATE|os.O_RDWR, 0o600)
+ if err != nil {
+ return false
+ }
+ defer file.Close()
+ if err := filelock.Lock(file, true, true); err != nil {
+ return filelock.IsBusy(err)
+ }
+ _ = filelock.Unlock(file)
+ return false
+}
+
+// Run holds the clock until ctx ends or no window has been open for the grace,
+// then stops what is running and returns. It returns [ErrHeld] at once when
+// another process holds the clock.
+func (c *Clock) Run(ctx context.Context) error {
+ if c.Store == nil || c.Runner == nil || c.Presence == nil {
+ return errors.New("automations: a clock needs a store, a runner and a presence")
+ }
+ release, err := c.lock()
+ if err != nil {
+ return err
+ }
+ defer release()
+ c.inflight = map[int64]*flight{}
+ // A CLOCK IS ONLY EVER TAKEN FROM A PROCESS THAT IS GONE, so a run still
+ // recorded as running is one nobody is running any more.
+ if n, err := c.Store.Abandoned(LineClosed); err != nil {
+ c.log("could not close runs a previous clock left: " + err.Error())
+ } else if n > 0 {
+ c.log(fmt.Sprintf("closed %d run(s) a previous clock left running", n))
+ }
+ var empty time.Time
+ ticker := time.NewTicker(pollEvery)
+ defer ticker.Stop()
+ for {
+ now := c.now()
+ windows, err := c.Presence.Count()
+ if err != nil {
+ c.log("could not count open windows: " + err.Error())
+ windows = 1 // A FOLDER WE CANNOT READ IS NOT A CLOSED CODEAF.
+ }
+ switch {
+ case windows > 0:
+ empty = time.Time{}
+ c.pass(ctx, now)
+ if c.outgrown() {
+ c.log("a window started with a different environment is open; leaving so it starts the clock")
+ return nil
+ }
+ case empty.IsZero():
+ empty = now
+ case now.Sub(empty) >= closingGrace:
+ c.stopAll(errClosing)
+ return nil
+ }
+ select {
+ case <-ctx.Done():
+ c.stopAll(errClosing)
+ return nil
+ case <-ticker.C:
+ }
+ }
+}
+
+// outgrown reports whether the newest open window says a different environment
+// from this clock's, with nothing in hand to stop by leaving.
+func (c *Clock) outgrown() bool {
+ if strings.TrimSpace(c.Env) == "" {
+ return false
+ }
+ c.mu.Lock()
+ busy := len(c.inflight) > 0
+ c.mu.Unlock()
+ if busy {
+ return false
+ }
+ newest, ok := c.Presence.Newest()
+ return ok && newest.Env != c.Env
+}
+
+// lock takes the clock's file lock, or reports [ErrHeld].
+func (c *Clock) lock() (func(), error) {
+ path := filepath.Join(c.Store.Root(), LockName)
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
+ if err != nil {
+ return nil, fmt.Errorf("automations: %w", err)
+ }
+ if err := filelock.Lock(file, true, true); err != nil {
+ _ = file.Close()
+ if filelock.IsBusy(err) {
+ return nil, ErrHeld
+ }
+ return nil, fmt.Errorf("automations: take the clock: %w", err)
+ }
+ _ = file.Truncate(0)
+ _, _ = file.WriteString(fmt.Sprintf("%d\n", os.Getpid()))
+ return func() {
+ _ = filelock.Unlock(file)
+ _ = file.Close()
+ }, nil
+}
+
+// pass is one look: stop what somebody asked to stop, take what is due, and
+// start what is queued as far as the limits allow.
+func (c *Clock) pass(ctx context.Context, now time.Time) {
+ if stops, err := c.Store.StopRequested(); err == nil {
+ for id := range stops {
+ c.cancel(id, errStopRequested)
+ }
+ }
+ c.stopOrphans()
+ due, err := c.Store.Due(now)
+ if err != nil {
+ c.log("could not read what is due: " + err.Error())
+ return
+ }
+ busy := c.busyAutomations()
+ for _, a := range due {
+ if busy[a.ID] {
+ // ONE RUN AT A TIME PER AUTOMATION. A slot that comes while the
+ // last run is still going is passed over, not queued behind it: a
+ // fifteen-minute routine whose run takes twenty would otherwise
+ // queue forever.
+ if err := c.skip(a, now); err != nil && !errors.Is(err, errChanged) {
+ c.log("could not move " + a.Title + " on: " + err.Error())
+ }
+ continue
+ }
+ if _, err := c.Store.Take(a, now); err != nil && !errors.Is(err, errChanged) {
+ c.log("could not take " + a.Title + ": " + err.Error())
+ }
+ }
+ queued, err := c.Store.Queued()
+ if err != nil {
+ c.log("could not read queued runs: " + err.Error())
+ return
+ }
+ for _, run := range queued {
+ c.start(ctx, run)
+ }
+}
+
+// stopOrphans stops every run in hand whose automation was deleted. Deleting an
+// automation takes its history with it — its run rows too — so a stop request
+// written on the row would be deleted with it; the clock asks the store whether
+// each run's automation still exists instead, and a run of one that does not
+// is spending the person's money on nothing.
+func (c *Clock) stopOrphans() {
+ c.mu.Lock()
+ held := make(map[int64]string, len(c.inflight))
+ for id, f := range c.inflight {
+ held[id] = f.automation
+ }
+ c.mu.Unlock()
+ for id, automation := range held {
+ if _, err := c.Store.Get(automation); errors.Is(err, ErrNotFound) {
+ c.cancel(id, errDeleted)
+ }
+ }
+}
+
+// busyAutomations is the set of automations with a run queued or in hand.
+func (c *Clock) busyAutomations() map[string]bool {
+ busy := map[string]bool{}
+ active, err := c.Store.Active()
+ if err != nil {
+ return busy
+ }
+ for _, run := range active {
+ busy[run.AutomationID] = true
+ }
+ return busy
+}
+
+// skip moves a busy automation past a slot without queuing a run for it.
+func (c *Clock) skip(a Automation, now time.Time) error {
+ if !a.Schedule.Repeats() {
+ return nil
+ }
+ next, err := a.Schedule.Next(now)
+ if err != nil {
+ return err
+ }
+ return c.Store.advance(a, next)
+}
+
+// start begins a queued run if the limits allow, and leaves it queued if not.
+func (c *Clock) start(ctx context.Context, run Run) {
+ a, err := c.Store.Get(run.AutomationID)
+ c.mu.Lock()
+ if _, already := c.inflight[run.ID]; already {
+ c.mu.Unlock()
+ return
+ }
+ if err != nil {
+ c.mu.Unlock()
+ run.Outcome, run.Line = OutcomeStopped, lineDeleted
+ if errors.Is(err, ErrNotFound) {
+ _ = c.Store.Start(run.ID)
+ c.finish(run)
+ }
+ return
+ }
+ isWork := a.Action.Do != ""
+ switch {
+ case isWork && c.work >= MaxWork:
+ c.mu.Unlock()
+ return
+ case !isWork && a.Look != nil && c.looks >= maxLooks:
+ c.mu.Unlock()
+ return
+ }
+ runCtx, cancel := context.WithCancelCause(context.WithoutCancel(ctx))
+ f := &flight{cancel: cancel, work: isWork, automation: a.ID}
+ c.inflight[run.ID] = f
+ if isWork {
+ c.work++
+ } else if a.Look != nil {
+ c.looks++
+ }
+ c.mu.Unlock()
+ if err := c.Store.Start(run.ID); err != nil {
+ c.log("could not start a run of " + a.Title + ": " + err.Error())
+ }
+ run.Started = c.now()
+ c.wg.Add(1)
+ go func() {
+ defer c.wg.Done()
+ defer c.landed(run.ID)
+ c.execute(runCtx, a, run)
+ }()
+}
+
+// landed takes a run out of hand.
+func (c *Clock) landed(id int64) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ f, ok := c.inflight[id]
+ if !ok {
+ return
+ }
+ f.cancel(nil)
+ if f.work {
+ c.work--
+ } else if c.looks > 0 {
+ c.looks--
+ }
+ delete(c.inflight, id)
+}
+
+// cancel stops one run in hand with a cause its record will name.
+func (c *Clock) cancel(id int64, cause error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if f, ok := c.inflight[id]; ok {
+ f.cancel(cause)
+ }
+}
+
+// stopAll stops every run in hand, waits a bounded time for each to record
+// itself, and records any that did not.
+func (c *Clock) stopAll(cause error) {
+ c.mu.Lock()
+ for _, f := range c.inflight {
+ f.cancel(cause)
+ }
+ c.mu.Unlock()
+ done := make(chan struct{})
+ go func() {
+ c.wg.Wait()
+ close(done)
+ }()
+ select {
+ case <-done:
+ case <-time.After(stopWait):
+ c.log("runs did not stop in time; recording them as stopped")
+ if _, err := c.Store.Abandoned(LineClosed); err != nil {
+ c.log("could not record stopped runs: " + err.Error())
+ }
+ }
+}
+
+// execute carries out one run and records what it came to.
+func (c *Clock) execute(ctx context.Context, a Automation, run Run) {
+ limits := a.Limits.Effective()
+ ctx, cancel := context.WithTimeout(ctx, limits.Time)
+ defer cancel()
+ switch a.Kind() {
+ case KindReminder:
+ run.Outcome, run.Line = OutcomeDone, strings.TrimSpace(a.Action.Say)
+ case KindWatch:
+ run = c.watch(ctx, a, run)
+ default:
+ report, err := c.Runner.Work(ctx, a, run, "")
+ run = c.reported(ctx, run, report, err, limits)
+ }
+ c.finish(run)
+}
+
+// watch is one look and its judgment, and — on the change to "yes" — the
+// action. A watch speaks on the CHANGE: while its condition stays true it is
+// quiet, and a "no" re-arms it so the next "yes" speaks again.
+func (c *Clock) watch(ctx context.Context, a Automation, run Run) Run {
+ sight, err := c.Runner.Look(ctx, a)
+ run.USD += sight.USD
+ if err != nil {
+ return c.ended(ctx, run, OutcomeUnchecked, "couldn't look: "+oneLine(err.Error()), OutcomeUnchecked)
+ }
+ judgment, err := c.Runner.Judge(ctx, a, sight)
+ run.USD += judgment.USD
+ switch {
+ case err != nil:
+ return c.ended(ctx, run, OutcomeUnchecked, "couldn't decide: "+oneLine(err.Error()), OutcomeUnchecked)
+ case !judgment.Sure:
+ return c.ended(ctx, run, OutcomeUnchecked, firstNonEmpty(judgment.Line, "the model could not tell"), OutcomeUnchecked)
+ case !judgment.Met:
+ c.setSeen(a, "no", sight.Memo)
+ run.Outcome, run.Line = OutcomeQuiet, firstNonEmpty(judgment.Line, "not yet")
+ return run
+ case a.Seen == "yes":
+ c.setSeen(a, "yes", sight.Memo)
+ run.Outcome, run.Line = OutcomeQuiet, firstNonEmpty(judgment.Line, "still true")
+ return run
+ }
+ c.setSeen(a, "yes", sight.Memo)
+ if a.Look.Once {
+ if err := c.Store.FinishWatch(a.ID, a.Revision); err != nil && !errors.Is(err, errChanged) {
+ c.log("could not finish " + a.Title + ": " + err.Error())
+ }
+ }
+ run.Detail = sight.Text
+ if say := strings.TrimSpace(a.Action.Say); say != "" {
+ run.Outcome, run.Line = OutcomeDone, say
+ if line := strings.TrimSpace(judgment.Line); line != "" {
+ run.Detail = line + "\n\n" + sight.Text
+ }
+ return run
+ }
+ report, err := c.Runner.Work(ctx, a, run, sight.Text)
+ return c.reported(ctx, run, report, err, a.Limits.Effective())
+}
+
+// reported turns a piece of work's report, or the reason it has none, into the
+// run's record. The context's end is read FIRST: a run that was stopped or ran
+// out of time is that, whatever the work managed to say on its way out.
+func (c *Clock) reported(ctx context.Context, run Run, report Report, err error, limits Limits) Run {
+ run.USD += report.USD
+ run.Transcript = firstNonEmpty(report.Transcript, run.Transcript)
+ run.Detail = firstNonEmpty(report.Detail, run.Detail)
+ if ctx.Err() != nil {
+ ended := c.ended(ctx, run, "", "", OutcomeIncomplete)
+ if ended.Outcome == OutcomeIncomplete {
+ ended.Line = "ran out of its " + durationWords(limits.Time)
+ }
+ return ended
+ }
+ switch {
+ case err != nil:
+ run.Outcome, run.Line = OutcomeIncomplete, "a fault: "+oneLine(err.Error())
+ case report.Outcome == "":
+ run.Outcome, run.Line = OutcomeIncomplete, firstNonEmpty(report.Line, "it ended without saying how it went")
+ default:
+ run.Outcome, run.Line = report.Outcome, strings.TrimSpace(report.Line)
+ }
+ return run
+}
+
+// ended records a run whose context ended — stopped by the person, by codeaf
+// closing, or out of time, which reads as late (out of time) — and otherwise
+// the outcome it was given. late is what running out of time means for this
+// kind of run: an unfinished piece of work, or a watch that couldn't check.
+func (c *Clock) ended(ctx context.Context, run Run, outcome Outcome, line string, late Outcome) Run {
+ if ctx.Err() != nil {
+ switch cause := context.Cause(ctx); {
+ case errors.Is(cause, errStopRequested):
+ run.Outcome, run.Line = OutcomeStopped, lineStopped
+ case errors.Is(cause, errClosing):
+ run.Outcome, run.Line = OutcomeStopped, LineClosed
+ case errors.Is(cause, errDeleted):
+ run.Outcome, run.Line = OutcomeStopped, lineDeleted
+ default:
+ run.Outcome, run.Line = late, "ran out of time"
+ }
+ return run
+ }
+ run.Outcome, run.Line = outcome, line
+ return run
+}
+
+// setSeen records a watch's judgment, unless the person changed the watch
+// while it was looking — their change is newer, and it reset what was seen.
+func (c *Clock) setSeen(a Automation, seen, memo string) {
+ if err := c.Store.SetSeen(a.ID, a.Revision, seen, memo); err != nil && !errors.Is(err, errChanged) {
+ c.log("could not record what " + a.Title + " saw: " + err.Error())
+ }
+}
+
+// finish records a run's end.
+func (c *Clock) finish(run Run) {
+ if run.Outcome == "" {
+ run.Outcome, run.Line = OutcomeIncomplete, firstNonEmpty(run.Line, "it ended without an outcome")
+ }
+ run.Line = clipRunes(oneLine(run.Line), 400)
+ run.Detail = clipRunes(run.Detail, 16*1024)
+ if err := c.Store.Finish(run); err != nil {
+ c.log(fmt.Sprintf("could not record run %d: %v", run.ID, err))
+ }
+}
+
+func (c *Clock) now() time.Time {
+ if c.Now != nil {
+ return c.Now()
+ }
+ return time.Now()
+}
+
+func (c *Clock) log(line string) {
+ if c.Log != nil {
+ c.Log(line)
+ }
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, v := range values {
+ if v = strings.TrimSpace(v); v != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+func oneLine(text string) string {
+ return strings.Join(strings.Fields(text), " ")
+}
+
+func clipRunes(text string, limit int) string {
+ runes := []rune(text)
+ if len(runes) <= limit {
+ return text
+ }
+ return string(runes[:limit-1]) + "…"
+}
+
+// durationWords is a limit as a person says it: "30m", "1h", "1h30m".
+func durationWords(d time.Duration) string {
+ d = d.Round(time.Minute)
+ hours, minutes := int(d/time.Hour), int(d%time.Hour/time.Minute)
+ switch {
+ case hours > 0 && minutes > 0:
+ return fmt.Sprintf("%dh%dm", hours, minutes)
+ case hours > 0:
+ return fmt.Sprintf("%dh", hours)
+ }
+ return fmt.Sprintf("%dm", minutes)
+}
diff --git a/internal/automation/clock_test.go b/internal/automation/clock_test.go
new file mode 100644
index 0000000000..f5f65dcf51
--- /dev/null
+++ b/internal/automation/clock_test.go
@@ -0,0 +1,504 @@
+package automation
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// fast runs the clock at a test's pace for the length of one test.
+func fast(t *testing.T) {
+ t.Helper()
+ poll, grace, wait := pollEvery, closingGrace, stopWait
+ pollEvery, closingGrace, stopWait = 5*time.Millisecond, 60*time.Millisecond, 2*time.Second
+ t.Cleanup(func() { pollEvery, closingGrace, stopWait = poll, grace, wait })
+}
+
+// fakeRunner scripts what looks, judgments and work come to.
+type fakeRunner struct {
+ mu sync.Mutex
+ judgments []Judgment
+ judgeErr error
+ report Report
+ workErr error
+ block bool // work waits for its context to end
+ working atomic.Int32
+ peak atomic.Int32
+ worked atomic.Int32
+ evidence []string
+}
+
+func (f *fakeRunner) Look(ctx context.Context, a Automation) (Sight, error) {
+ return Sight{Text: "conclusion: failure", USD: 0.001}, nil
+}
+
+func (f *fakeRunner) Judge(ctx context.Context, a Automation, s Sight) (Judgment, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if f.judgeErr != nil {
+ return Judgment{}, f.judgeErr
+ }
+ if len(f.judgments) == 0 {
+ return Judgment{Sure: true, Met: false, Line: "fine", USD: 0.002}, nil
+ }
+ j := f.judgments[0]
+ f.judgments = f.judgments[1:]
+ return j, nil
+}
+
+func (f *fakeRunner) Work(ctx context.Context, a Automation, run Run, evidence string) (Report, error) {
+ now := f.working.Add(1)
+ defer f.working.Add(-1)
+ for {
+ peak := f.peak.Load()
+ if now <= peak || f.peak.CompareAndSwap(peak, now) {
+ break
+ }
+ }
+ f.worked.Add(1)
+ f.mu.Lock()
+ f.evidence = append(f.evidence, evidence)
+ f.mu.Unlock()
+ if f.block {
+ <-ctx.Done()
+ return Report{Line: "partial", USD: 0.3}, nil
+ }
+ time.Sleep(20 * time.Millisecond)
+ return f.report, f.workErr
+}
+
+// rig is a store, a held window and a running clock.
+type rig struct {
+ store *Store
+ runner *fakeRunner
+ window func()
+ done chan error
+ cancel context.CancelFunc
+}
+
+func startClock(t *testing.T, runner *fakeRunner) *rig {
+ t.Helper()
+ fast(t)
+ s, err := Open(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = s.Close() })
+ presence := NewPresence(s.Root())
+ window, err := presence.Hold("test window")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ r := &rig{store: s, runner: runner, window: window, done: make(chan error, 1), cancel: cancel}
+ clock := &Clock{Store: s, Presence: presence, Runner: runner, Log: func(line string) { t.Log("clock: " + line) }}
+ go func() { r.done <- clock.Run(ctx) }()
+ t.Cleanup(func() {
+ cancel()
+ window()
+ select {
+ case <-r.done:
+ case <-time.After(5 * time.Second):
+ t.Error("the clock did not stop")
+ }
+ })
+ return r
+}
+
+// settle waits until a run of the automation is over and returns the newest.
+func (r *rig) settle(t *testing.T, id string, count int) []Run {
+ t.Helper()
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ runs, err := r.store.Runs(id, 50)
+ if err != nil {
+ t.Fatal(err)
+ }
+ over := 0
+ for _, run := range runs {
+ if run.Phase == PhaseOver {
+ over++
+ }
+ }
+ if over >= count {
+ return runs
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ runs, _ := r.store.Runs(id, 50)
+ t.Fatalf("waited for %d run(s) of %s; have %+v", count, id, runs)
+ return nil
+}
+
+func (r *rig) now(t *testing.T, id string) {
+ t.Helper()
+ deadline := time.Now().Add(5 * time.Second)
+ for {
+ _, err := r.store.QueueNow(id)
+ if err == nil {
+ return
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("could not queue a run: %v", err)
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+}
+
+func TestClockSaysADueReminder(t *testing.T) {
+ r := startClock(t, &fakeRunner{})
+ a, err := r.store.Create(reminder(time.Now().Add(-time.Second)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ runs := r.settle(t, a.ID, 1)
+ if runs[0].Outcome != OutcomeDone || runs[0].Line != "time to leave" || runs[0].Why != WhyOnTime {
+ t.Fatalf("run %+v", runs[0])
+ }
+ if got, _ := r.store.Get(a.ID); got.Status != StatusFinished {
+ t.Fatalf("a said reminder is %s", got.Status)
+ }
+}
+
+// A REMINDER MISSED WHILE CODEAF WAS CLOSED IS SAID LATE, NOT DROPPED.
+func TestClockSaysAMissedReminderLate(t *testing.T) {
+ r := startClock(t, &fakeRunner{})
+ a, err := r.store.Create(reminder(time.Now().Add(-26 * time.Hour)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ runs := r.settle(t, a.ID, 1)
+ if runs[0].Why != WhyLate || runs[0].Outcome != OutcomeDone || runs[0].Late() < 25*time.Hour {
+ t.Fatalf("run %+v (late %v)", runs[0], runs[0].Late())
+ }
+}
+
+// NOTHING RUNS WHILE CODEAF IS CLOSED. With the last window gone the clock waits
+// out its grace, stops the work in hand — recording it as stopped, not lost —
+// and leaves.
+func TestClockStopsWorkWhenTheLastWindowCloses(t *testing.T) {
+ runner := &fakeRunner{block: true}
+ r := startClock(t, runner)
+ a, _ := r.store.Create(routine("1h"))
+ r.now(t, a.ID)
+ deadline := time.Now().Add(5 * time.Second)
+ for runner.working.Load() == 0 && time.Now().Before(deadline) {
+ time.Sleep(2 * time.Millisecond)
+ }
+ r.window()
+ select {
+ case err := <-r.done:
+ if err != nil {
+ t.Fatalf("the clock ended with %v", err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("the clock kept running with no window open")
+ }
+ runs, _ := r.store.Runs(a.ID, 5)
+ if runs[0].Outcome != OutcomeStopped || runs[0].Line != LineClosed || runs[0].USD != 0.3 {
+ t.Fatalf("the interrupted run reads %+v", runs[0])
+ }
+ // AND IT IS PAUSED, NOT OVER: the automation is due again, for the next
+ // window's clock to run once.
+ if got, err := r.store.Get(a.ID); err != nil || got.Status != StatusActive || got.Next.After(time.Now()) {
+ t.Fatalf("after the window closed on a run the automation is %+v, %v", got, err)
+ }
+ r.done <- nil // the cleanup waits on it
+}
+
+func TestOnlyOneClockAtATime(t *testing.T) {
+ r := startClock(t, &fakeRunner{})
+ time.Sleep(20 * time.Millisecond)
+ second := &Clock{Store: r.store, Presence: NewPresence(r.store.Root()), Runner: &fakeRunner{}}
+ if err := second.Run(context.Background()); !errors.Is(err, ErrHeld) {
+ t.Fatalf("a second clock = %v, want ErrHeld", err)
+ }
+ if !Held(r.store.Root()) {
+ t.Fatal("Held says nobody holds the clock")
+ }
+}
+
+func watchOn(say string, once bool) Automation {
+ return Automation{
+ Title: "ci", Schedule: Schedule{Every: "1h"}, Workspace: "/tmp/p",
+ Look: &Look{Command: "gh run list -L1", Condition: "the latest run failed", Once: once},
+ Action: Action{Say: say},
+ }
+}
+
+// A WATCH SPEAKS ON THE CHANGE. Red, still red, green, red again: it speaks on
+// the first and the last, and is quiet in between.
+func TestWatchSpeaksOnTheChangeAndIsQuietWhileItHolds(t *testing.T) {
+ runner := &fakeRunner{judgments: []Judgment{
+ {Sure: true, Met: true, Line: "red"},
+ {Sure: true, Met: true, Line: "still red"},
+ {Sure: true, Met: false, Line: "green"},
+ {Sure: true, Met: true, Line: "red again"},
+ }}
+ r := startClock(t, runner)
+ a, _ := r.store.Create(watchOn("CI is red", false))
+ want := []Outcome{OutcomeDone, OutcomeQuiet, OutcomeQuiet, OutcomeDone}
+ for i := range want {
+ r.now(t, a.ID)
+ r.settle(t, a.ID, i+1)
+ }
+ runs, _ := r.store.Runs(a.ID, 10)
+ for i, w := range want {
+ got := runs[len(runs)-1-i]
+ if got.Outcome != w {
+ t.Fatalf("look %d came to %s (%q), want %s", i+1, got.Outcome, got.Line, w)
+ }
+ }
+ if runs[len(runs)-1].Line != "CI is red" || !strings.Contains(runs[len(runs)-1].Detail, "conclusion: failure") {
+ t.Fatalf("the first speaking run reads %+v", runs[len(runs)-1])
+ }
+}
+
+func TestOnceWatchFinishesAfterItSpeaks(t *testing.T) {
+ r := startClock(t, &fakeRunner{judgments: []Judgment{{Sure: true, Met: true, Line: "ready"}}})
+ a, _ := r.store.Create(watchOn("it is ready", true))
+ r.now(t, a.ID)
+ r.settle(t, a.ID, 1)
+ if got, _ := r.store.Get(a.ID); got.Status != StatusFinished {
+ t.Fatalf("a once-watch that spoke is %s", got.Status)
+ }
+}
+
+// A MODEL THAT CANNOT DECIDE CHANGES NOTHING, AND SAYS SO. The run reads
+// "couldn't check" with the reason; what the watch had seen is untouched.
+func TestUnsureOrFailedJudgmentIsCouldntCheck(t *testing.T) {
+ runner := &fakeRunner{judgments: []Judgment{{Sure: true, Met: true}, {Sure: false, Line: "the output was empty"}}}
+ r := startClock(t, runner)
+ a, _ := r.store.Create(watchOn("CI is red", false))
+ r.now(t, a.ID)
+ r.settle(t, a.ID, 1)
+ r.now(t, a.ID)
+ runs := r.settle(t, a.ID, 2)
+ if runs[0].Outcome != OutcomeUnchecked || runs[0].Line != "the output was empty" {
+ t.Fatalf("an unsure look reads %+v", runs[0])
+ }
+ if got, _ := r.store.Get(a.ID); got.Seen != "yes" {
+ t.Fatalf("an unsure look changed what was seen to %q", got.Seen)
+ }
+ runner.mu.Lock()
+ runner.judgeErr = errors.New("no API key")
+ runner.mu.Unlock()
+ r.now(t, a.ID)
+ runs = r.settle(t, a.ID, 3)
+ if runs[0].Outcome != OutcomeUnchecked || !strings.Contains(runs[0].Line, "no API key") {
+ t.Fatalf("a failed judgment reads %+v", runs[0])
+ }
+}
+
+func TestWorkOutcomesAreFactsNotGuesses(t *testing.T) {
+ cases := []struct {
+ name string
+ report Report
+ err error
+ outcome Outcome
+ line string
+ }{
+ {"reported done", Report{Outcome: OutcomeDone, Line: "drafted"}, nil, OutcomeDone, "drafted"},
+ {"needs the person", Report{Outcome: OutcomeYourCall, Line: "needed your ok to run gh"}, nil, OutcomeYourCall, "needed your ok to run gh"},
+ {"said something but never reported", Report{Line: "I'll start by…"}, nil, OutcomeIncomplete, "I'll start by…"},
+ {"said nothing at all", Report{}, nil, OutcomeIncomplete, "it ended without saying how it went"},
+ {"a fault", Report{}, errors.New("provider refused"), OutcomeIncomplete, "a fault: provider refused"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ r := startClock(t, &fakeRunner{report: c.report, workErr: c.err})
+ a, _ := r.store.Create(routine("1h"))
+ r.now(t, a.ID)
+ runs := r.settle(t, a.ID, 1)
+ if runs[0].Outcome != c.outcome || runs[0].Line != c.line {
+ t.Fatalf("came to %s %q, want %s %q", runs[0].Outcome, runs[0].Line, c.outcome, c.line)
+ }
+ })
+ }
+}
+
+func TestWorkThatRunsOutOfTimeIsIncomplete(t *testing.T) {
+ r := startClock(t, &fakeRunner{block: true})
+ a := routine("1h")
+ a.Limits.Time = 30 * time.Millisecond
+ a, _ = r.store.Create(a)
+ r.now(t, a.ID)
+ runs := r.settle(t, a.ID, 1)
+ if runs[0].Outcome != OutcomeIncomplete || !strings.HasPrefix(runs[0].Line, "ran out of its") {
+ t.Fatalf("a run past its limit reads %+v", runs[0])
+ }
+}
+
+func TestStopRequestStopsARun(t *testing.T) {
+ runner := &fakeRunner{block: true}
+ r := startClock(t, runner)
+ a, _ := r.store.Create(routine("1h"))
+ r.now(t, a.ID)
+ deadline := time.Now().Add(5 * time.Second)
+ var run Run
+ for time.Now().Before(deadline) {
+ active, _ := r.store.Active()
+ if len(active) == 1 && active[0].Phase == PhaseRunning {
+ run = active[0]
+ break
+ }
+ time.Sleep(2 * time.Millisecond)
+ }
+ if err := r.store.RequestStop(run.ID); err != nil {
+ t.Fatal(err)
+ }
+ runs := r.settle(t, a.ID, 1)
+ if runs[0].Outcome != OutcomeStopped || runs[0].Line != lineStopped {
+ t.Fatalf("a stopped run reads %+v", runs[0])
+ }
+}
+
+// DELETING AN AUTOMATION STOPS ITS RUN. The delete takes the run rows with it,
+// so no stop request can be written there; the clock notices the automation is
+// gone and stops the run rather than let it spend to its limit.
+func TestDeletingAnAutomationStopsItsRun(t *testing.T) {
+ runner := &fakeRunner{block: true}
+ r := startClock(t, runner)
+ a, _ := r.store.Create(routine("1h"))
+ r.now(t, a.ID)
+ deadline := time.Now().Add(5 * time.Second)
+ for runner.working.Load() == 0 && time.Now().Before(deadline) {
+ time.Sleep(2 * time.Millisecond)
+ }
+ if runner.working.Load() == 0 {
+ t.Fatal("the run never started")
+ }
+ if err := r.store.Delete(a.ID); err != nil {
+ t.Fatal(err)
+ }
+ deadline = time.Now().Add(5 * time.Second)
+ for runner.working.Load() != 0 && time.Now().Before(deadline) {
+ time.Sleep(2 * time.Millisecond)
+ }
+ if runner.working.Load() != 0 {
+ t.Fatal("the run of a deleted automation kept going")
+ }
+}
+
+// TWO PIECES OF WORK AT ONCE, the rest wait their turn.
+func TestWorkRunsTwoAtATime(t *testing.T) {
+ runner := &fakeRunner{report: Report{Outcome: OutcomeDone, Line: "ok"}}
+ r := startClock(t, runner)
+ var ids []string
+ for i := 0; i < 5; i++ {
+ a, _ := r.store.Create(routine("1h"))
+ ids = append(ids, a.ID)
+ r.now(t, a.ID)
+ }
+ for _, id := range ids {
+ r.settle(t, id, 1)
+ }
+ if peak := runner.peak.Load(); peak > MaxWork {
+ t.Fatalf("%d pieces of work ran at once, the limit is %d", peak, MaxWork)
+ }
+ if runner.worked.Load() != 5 {
+ t.Fatalf("worked %d times, want 5", runner.worked.Load())
+ }
+}
+
+// A SLOT THAT COMES WHILE THE LAST RUN IS STILL GOING IS PASSED OVER.
+func TestASlotIsSkippedWhileItsLastRunIsStillGoing(t *testing.T) {
+ runner := &fakeRunner{block: true}
+ r := startClock(t, runner)
+ a, _ := r.store.Create(routine("1m"))
+ r.now(t, a.ID)
+ deadline := time.Now().Add(5 * time.Second)
+ for runner.working.Load() == 0 && time.Now().Before(deadline) {
+ time.Sleep(2 * time.Millisecond)
+ }
+ // Force the slot due while the run holds it.
+ if _, err := r.store.db.Exec(`UPDATE automations SET next_ms = ? WHERE id = ?`, ms(time.Now().Add(-time.Second)), a.ID); err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(60 * time.Millisecond)
+ active, _ := r.store.Active()
+ if len(active) != 1 {
+ t.Fatalf("a busy automation queued another run: %+v", active)
+ }
+ if got, _ := r.store.Get(a.ID); !got.Next.After(time.Now()) {
+ t.Fatalf("the skipped slot was not moved on: next %v", got.Next)
+ }
+}
+
+func TestWatchWorkGetsTheEvidence(t *testing.T) {
+ runner := &fakeRunner{judgments: []Judgment{{Sure: true, Met: true, Line: "red", USD: 0.002}}, report: Report{Outcome: OutcomeDone, Line: "fixed"}}
+ r := startClock(t, runner)
+ a := watchOn("", false)
+ a.Action = Action{Do: "find out why CI failed"}
+ a, err := r.store.Create(a)
+ if err != nil {
+ t.Fatal(err)
+ }
+ r.now(t, a.ID)
+ runs := r.settle(t, a.ID, 1)
+ if runs[0].Outcome != OutcomeDone || runs[0].Line != "fixed" {
+ t.Fatalf("watch work reads %+v", runs[0])
+ }
+ runner.mu.Lock()
+ defer runner.mu.Unlock()
+ if len(runner.evidence) != 1 || runner.evidence[0] != "conclusion: failure" {
+ t.Fatalf("the work was handed %q", runner.evidence)
+ }
+ if want := 0.001 + 0.002; runs[0].USD < want-1e-9 || runs[0].USD > want+1e-9 {
+ // the fake work reports no cost; the look and the judgment do
+ t.Fatalf("usd = %v, want %v", runs[0].USD, want)
+ }
+}
+
+// A CLOCK FOLLOWS THE NEWEST WINDOW'S ENVIRONMENT. Started from a shell without
+// the person's key, it would fail every check for as long as any window stayed
+// open; when a window started in a different environment opens and nothing is
+// in hand, it leaves so that window starts the next one. A window that says
+// nothing about its environment is never followed.
+func TestAClockLeavesForANewerWindowsEnvironment(t *testing.T) {
+ fast(t)
+ s, err := Open(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close()
+ presence := NewPresence(s.Root())
+ old, err := presence.HoldAs(Window{Label: "the shell without the key", Env: "aaaa"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer old()
+ quiet, _ := presence.Hold("a window that says nothing")
+ defer quiet()
+ done := make(chan error, 1)
+ clock := &Clock{Store: s, Presence: presence, Runner: &fakeRunner{}, Env: "aaaa", Log: func(line string) { t.Log("clock: " + line) }}
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go func() { done <- clock.Run(ctx) }()
+ select {
+ case err := <-done:
+ t.Fatalf("the clock left with its own environment newest (%v)", err)
+ case <-time.After(100 * time.Millisecond):
+ }
+ time.Sleep(5 * time.Millisecond) // a later Started than the first window's
+ newer, err := presence.HoldAs(Window{Label: "the shell with the key", Env: "bbbb"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer newer()
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatalf("the clock left with %v", err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("the clock kept running under the older window's environment")
+ }
+ if newest, ok := presence.Newest(); !ok || newest.Env != "bbbb" {
+ t.Fatalf("the newest window is %+v, %v", newest, ok)
+ }
+}
diff --git a/internal/automation/command.go b/internal/automation/command.go
new file mode 100644
index 0000000000..ab9ec54c60
--- /dev/null
+++ b/internal/automation/command.go
@@ -0,0 +1,541 @@
+package automation
+
+// command.go is the typed door: `/automations add …` and its siblings, read
+// exactly and with no model in between, and the same line written back out of
+// an automation so the list can put it in the box to be edited.
+//
+// IT IS A GRAMMAR OF WORDS AND VALUES, AND A VALUE IS ONE TOKEN. Every clause is
+// a word followed by one value — `every 15m`, `say "time to leave"` — and a
+// value with a space in it is quoted. That is less forgiving than a sentence,
+// which is the point: the conversation is where a sentence goes, and this is
+// the road for somebody who wants exactly what they typed, read back on a card
+// before anything is saved.
+//
+// add "weekly update" every "0 9 * * 1" do "draft the weekly update"
+// add leave at 18:00 say "time to leave"
+// add "ci on main" every 15m look "gh run list -b main -L 1" until "the latest run failed" once say "CI on main is red"
+// edit 3f2a… every 30m
+// run 3f2a… · pause 3f2a… · resume 3f2a… · delete 3f2a…
+//
+// AND IT ROUND-TRIPS. [CommandLine] writes any automation as the `edit` line
+// that would make it what it is, so the line a person edits is the line they
+// would have had to type, and reading it back changes nothing.
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Verb is what a typed line asks for.
+type Verb string
+
+const (
+ // VerbList is the bare command: open the list.
+ VerbList Verb = ""
+ VerbAdd Verb = "add"
+ VerbEdit Verb = "edit"
+ VerbRun Verb = "run"
+ VerbPause Verb = "pause"
+ VerbResume Verb = "resume"
+ VerbDelete Verb = "delete"
+)
+
+// Command is one typed line, read but not yet applied.
+type Command struct {
+ Verb Verb
+ // ID is the automation every verb but add acts on.
+ ID string
+ // Title is add's name for the new automation.
+ Title string
+ clauses []clause
+}
+
+type clause struct {
+ word string
+ value string
+}
+
+// The clause words, each taking one value unless it is a flag.
+var (
+ clauseValued = []string{"title", "at", "in", "every", "zone", "say", "do", "look", "files", "until", "time", "usd", "folder"}
+ clauseFlags = []string{"once", "worktree", "checkout"}
+)
+
+// ClauseWords is every word a typed line may use, in the order the help names
+// them, for an error that has to say what would have been understood.
+func ClauseWords() []string {
+ return append(append([]string(nil), clauseValued...), clauseFlags...)
+}
+
+// ParseCommand reads what follows `/automations`. An empty line is the list.
+func ParseCommand(line string) (Command, error) {
+ tokens, err := tokenize(line)
+ if err != nil {
+ return Command{}, err
+ }
+ if len(tokens) == 0 {
+ return Command{Verb: VerbList}, nil
+ }
+ verb := Verb(strings.ToLower(tokens[0]))
+ rest := tokens[1:]
+ cmd := Command{Verb: verb}
+ switch verb {
+ case VerbAdd:
+ if len(rest) == 0 {
+ return Command{}, errors.New(`add needs a title first: add "weekly update" every "0 9 * * 1" do "…"`)
+ }
+ cmd.Title, rest = strings.TrimSpace(rest[0]), rest[1:]
+ if cmd.Title == "" {
+ return Command{}, errors.New("add needs a title first")
+ }
+ case VerbEdit, VerbRun, VerbPause, VerbResume, VerbDelete:
+ if len(rest) == 0 || strings.TrimSpace(rest[0]) == "" {
+ return Command{}, fmt.Errorf("%s needs the automation's id — → then e on its row in /automations puts its line, id and all, in the box", verb)
+ }
+ cmd.ID, rest = strings.TrimSpace(rest[0]), rest[1:]
+ if verb != VerbEdit {
+ if len(rest) > 0 {
+ return Command{}, fmt.Errorf("%s takes only the id, and %q came after it", verb, rest[0])
+ }
+ return cmd, nil
+ }
+ default:
+ return Command{}, fmt.Errorf("unknown %q — the forms are add, edit, run, pause, resume and delete", tokens[0])
+ }
+ for len(rest) > 0 {
+ word := strings.ToLower(rest[0])
+ rest = rest[1:]
+ switch {
+ case contains(clauseFlags, word):
+ cmd.clauses = append(cmd.clauses, clause{word: word})
+ case contains(clauseValued, word):
+ if word == "title" && verb != VerbEdit {
+ return Command{}, errors.New("the title comes straight after add")
+ }
+ if len(rest) == 0 {
+ return Command{}, fmt.Errorf("%s needs a value after it", word)
+ }
+ value, used := rest[0], 1
+ if word == "at" {
+ // A MOMENT MAY BE SEVERAL WORDS WITHOUT QUOTES — `at tomorrow
+ // 6pm`, `at monday 9am` — so `at` reads up to the next word
+ // this line knows. A moment never contains one of them.
+ for used < len(rest) && !contains(clauseValued, strings.ToLower(rest[used])) && !contains(clauseFlags, strings.ToLower(rest[used])) {
+ value += " " + rest[used]
+ used++
+ }
+ }
+ cmd.clauses = append(cmd.clauses, clause{word: word, value: value})
+ rest = rest[used:]
+ default:
+ return Command{}, fmt.Errorf("unknown word %q — the words are %s", word, strings.Join(ClauseWords(), ", "))
+ }
+ }
+ if verb == VerbEdit && len(cmd.clauses) == 0 {
+ return Command{}, errors.New("edit needs at least one thing to change")
+ }
+ return cmd, nil
+}
+
+// Apply makes the command's clauses true of base and validates the result. For
+// add, base is the new automation's defaults — the window's workspace and zone;
+// for edit, it is the automation as it stands, and what the line does not
+// mention stays as it was.
+//
+// THE ZONE IS READ FIRST, wherever it was typed, because it is what `at 18:00`
+// and a cron line are read in.
+func (c Command) Apply(base Automation, now time.Time) (Automation, error) {
+ a := base
+ if c.Verb == VerbAdd {
+ a.Title = c.Title
+ }
+ for _, cl := range c.clauses {
+ if cl.word != "zone" {
+ continue
+ }
+ zone := strings.TrimSpace(cl.value)
+ if _, err := time.LoadLocation(zone); err != nil {
+ return Automation{}, fmt.Errorf("unknown zone %q", zone)
+ }
+ a.Schedule.Zone = zone
+ }
+ loc, err := a.Schedule.location()
+ if err != nil {
+ return Automation{}, err
+ }
+ for _, cl := range c.clauses {
+ value := strings.TrimSpace(cl.value)
+ switch cl.word {
+ case "zone":
+ case "title":
+ a.Title = value
+ case "at":
+ at, err := parseMoment(value, now, loc)
+ if err != nil {
+ return Automation{}, err
+ }
+ a.Schedule.At, a.Schedule.Every, a.Schedule.Anchor = at, "", time.Time{}
+ case "in":
+ distance, err := parseDistance(value)
+ if err != nil {
+ return Automation{}, err
+ }
+ a.Schedule.At, a.Schedule.Every, a.Schedule.Anchor = now.Add(distance).Truncate(time.Second), "", time.Time{}
+ case "every":
+ a.Schedule.At, a.Schedule.Every = time.Time{}, value
+ a.Schedule.Anchor = time.Time{}
+ if _, err := ParseInterval(value); err == nil {
+ a.Schedule.Anchor = now.Truncate(time.Second)
+ }
+ case "say":
+ a.Action = Action{Say: value}
+ case "do":
+ a.Action = Action{Do: value}
+ case "look":
+ a.Look = lookWith(a.Look, func(l *Look) { *l = Look{Command: value, Condition: l.Condition, Once: l.Once} })
+ case "files":
+ a.Look = lookWith(a.Look, func(l *Look) { *l = Look{Files: value, Condition: l.Condition, Once: l.Once} })
+ case "until":
+ a.Look = lookWith(a.Look, func(l *Look) { l.Condition = value })
+ case "once":
+ a.Look = lookWith(a.Look, func(l *Look) { l.Once = true })
+ case "worktree":
+ a.Worktree = true
+ case "checkout":
+ a.Worktree = false
+ case "time":
+ limit, err := parseDistance(value)
+ if err != nil {
+ return Automation{}, fmt.Errorf("cannot read the time limit %q", value)
+ }
+ a.Limits.Time = limit
+ case "usd":
+ usd, err := strconv.ParseFloat(strings.TrimPrefix(value, "$"), 64)
+ if err != nil || usd <= 0 {
+ return Automation{}, fmt.Errorf("cannot read the spending limit %q", value)
+ }
+ a.Limits.USD = usd
+ case "folder":
+ folder, err := expandFolder(value)
+ if err != nil {
+ return Automation{}, err
+ }
+ a.Workspace = folder
+ }
+ }
+ if !a.Schedule.Repeats() && !a.Schedule.At.IsZero() && !a.Schedule.At.After(now) && c.touchesWhen() {
+ return Automation{}, fmt.Errorf("%s has already passed", a.Schedule.At.In(loc).Format("Mon 2 Jan 15:04"))
+ }
+ if err := a.Validate(); err != nil {
+ return Automation{}, err
+ }
+ return a, nil
+}
+
+// touchesWhen reports whether the line set the schedule, which is the only time
+// a moment in the past is the person's mistake rather than the automation's
+// history.
+func (c Command) touchesWhen() bool {
+ for _, cl := range c.clauses {
+ switch cl.word {
+ case "at", "in", "every", "zone":
+ return true
+ }
+ }
+ return c.Verb == VerbAdd
+}
+
+// CommandLine is the `edit` line that makes an automation exactly what it is —
+// what the list puts in the box for `e`, without the leading `/automations`.
+//
+// A WATCH THAT LOOKS THROUGH A TOOL IS WRITTEN WITHOUT ITS LOOK. A tool call's
+// arguments are a JSON object nobody types on one line, so the typed door leaves
+// that look as it is; the conversation is where it is changed.
+func CommandLine(a Automation) string {
+ parts := []string{string(VerbEdit), a.ID, "title", quote(a.Title)}
+ if zone := strings.TrimSpace(a.Schedule.Zone); zone != "" {
+ parts = append(parts, "zone", quote(zone))
+ }
+ if every := strings.TrimSpace(a.Schedule.Every); every != "" {
+ parts = append(parts, "every", quote(every))
+ } else if !a.Schedule.At.IsZero() {
+ loc, err := a.Schedule.location()
+ if err != nil {
+ loc = time.Local
+ }
+ parts = append(parts, "at", a.Schedule.At.In(loc).Format(momentLayout))
+ }
+ if look := a.Look; look != nil {
+ switch {
+ case strings.TrimSpace(look.Command) != "":
+ parts = append(parts, "look", quote(look.Command))
+ case strings.TrimSpace(look.Files) != "":
+ parts = append(parts, "files", quote(look.Files))
+ }
+ parts = append(parts, "until", quote(look.Condition))
+ if look.Once {
+ parts = append(parts, "once")
+ }
+ }
+ switch {
+ case strings.TrimSpace(a.Action.Say) != "":
+ parts = append(parts, "say", quote(a.Action.Say))
+ case strings.TrimSpace(a.Action.Do) != "":
+ parts = append(parts, "do", quote(a.Action.Do))
+ if a.Worktree {
+ parts = append(parts, "worktree")
+ }
+ }
+ if a.Limits.Time > 0 {
+ parts = append(parts, "time", durationWord(a.Limits.Time))
+ }
+ if a.Limits.USD > 0 {
+ parts = append(parts, "usd", strconv.FormatFloat(a.Limits.USD, 'f', -1, 64))
+ }
+ if folder := strings.TrimSpace(a.Workspace); folder != "" {
+ parts = append(parts, "folder", quote(folder))
+ }
+ return strings.Join(parts, " ")
+}
+
+// momentLayout is how a one-time moment is typed and written back: the wall
+// clock in the schedule's zone, to the minute.
+const momentLayout = "2006-01-02T15:04"
+
+// parseMoment reads a moment the way a person types one: a time of day —
+// `18:00`, `6pm`, `6:30pm`, `18` — today, or after `today`, `tomorrow` or a
+// day of the week (`monday 9am`, `fri 17:30`, the next one to come); a date and
+// a time, `2026-10-12T09:00` or `2026-10-12 09:00`; or a full RFC 3339 time
+// with its own offset.
+func parseMoment(text string, now time.Time, loc *time.Location) (time.Time, error) {
+ text = strings.TrimSpace(text)
+ if at, err := time.Parse(time.RFC3339, text); err == nil {
+ return at, nil
+ }
+ for _, layout := range []string{momentLayout, "2006-01-02 15:04"} {
+ if at, err := time.ParseInLocation(layout, text, loc); err == nil {
+ return at, nil
+ }
+ }
+ refuse := fmt.Errorf("cannot read the moment %q — say 18:00, 6pm, tomorrow 9am, monday 9am or 2026-10-12T09:00", text)
+ words := strings.Fields(strings.ToLower(text))
+ if len(words) == 0 || len(words) > 3 {
+ return time.Time{}, refuse
+ }
+ today := now.In(loc)
+ day, ahead, named := today, 0, false
+ switch first := words[0]; {
+ case first == "today":
+ named = true
+ case first == "tomorrow":
+ day, named = today.AddDate(0, 0, 1), true
+ default:
+ if weekday, ok := weekdayNamed(first); ok {
+ ahead = (int(weekday) - int(today.Weekday()) + 7) % 7
+ day, named = today.AddDate(0, 0, ahead), true
+ }
+ }
+ clockWords := words
+ if named {
+ clockWords = words[1:]
+ }
+ hour, minute, ok := clockOf(strings.Join(clockWords, ""))
+ if !ok {
+ return time.Time{}, refuse
+ }
+ at := time.Date(day.Year(), day.Month(), day.Day(), hour, minute, 0, 0, loc)
+ // A DAY OF THE WEEK IS THE NEXT ONE TO COME: `monday 9am` said on a Monday
+ // afternoon is next Monday, not a moment that has already passed.
+ if named && words[0] != "today" && words[0] != "tomorrow" && ahead == 0 && !at.After(today) {
+ at = at.AddDate(0, 0, 7)
+ }
+ return at, nil
+}
+
+// clockOf reads a time of day with no spaces in it — `18:00`, `6pm`, `6:30am`,
+// `18` — as an hour and a minute.
+func clockOf(text string) (int, int, bool) {
+ half := ""
+ if rest, ok := strings.CutSuffix(text, "am"); ok {
+ text, half = rest, "am"
+ } else if rest, ok := strings.CutSuffix(text, "pm"); ok {
+ text, half = rest, "pm"
+ }
+ hourText, minuteText, hasMinute := strings.Cut(text, ":")
+ hour, err := strconv.Atoi(hourText)
+ if err != nil || hourText == "" {
+ return 0, 0, false
+ }
+ minute := 0
+ if hasMinute {
+ if len(minuteText) != 2 {
+ return 0, 0, false
+ }
+ if minute, err = strconv.Atoi(minuteText); err != nil || minute < 0 || minute > 59 {
+ return 0, 0, false
+ }
+ }
+ switch half {
+ case "am", "pm":
+ if hour < 1 || hour > 12 {
+ return 0, 0, false
+ }
+ hour %= 12
+ if half == "pm" {
+ hour += 12
+ }
+ default:
+ if hour < 0 || hour > 23 {
+ return 0, 0, false
+ }
+ }
+ return hour, minute, true
+}
+
+// weekdayNamed reads a day of the week, whole or by its first three letters.
+func weekdayNamed(word string) (time.Weekday, bool) {
+ for day := time.Sunday; day <= time.Saturday; day++ {
+ name := strings.ToLower(day.String())
+ if word == name || word == name[:3] {
+ return day, true
+ }
+ }
+ return 0, false
+}
+
+// parseDistance reads a positive duration, with "d" for a day.
+func parseDistance(text string) (time.Duration, error) {
+ spec := strings.TrimSpace(text)
+ if days, ok := strings.CutSuffix(spec, "d"); ok {
+ count, err := strconv.Atoi(days)
+ if err != nil || count < 1 || int64(count) > maxIntervalDays {
+ return 0, fmt.Errorf("cannot read %q", text)
+ }
+ return time.Duration(count) * 24 * time.Hour, nil
+ }
+ distance, err := time.ParseDuration(spec)
+ if err != nil || distance <= 0 {
+ return 0, fmt.Errorf("cannot read %q", text)
+ }
+ return distance, nil
+}
+
+// durationWord writes a limit the way it would be typed: whole days, hours or
+// minutes where it is one, and Go's own spelling otherwise.
+func durationWord(d time.Duration) string {
+ switch {
+ case d%(24*time.Hour) == 0:
+ return strconv.Itoa(int(d/(24*time.Hour))) + "d"
+ case d%time.Hour == 0:
+ return strconv.Itoa(int(d/time.Hour)) + "h"
+ case d%time.Minute == 0:
+ return strconv.Itoa(int(d/time.Minute)) + "m"
+ }
+ return d.String()
+}
+
+// expandFolder reads a folder as a person types one: absolute, or under `~`.
+//
+// A FOLDER UNDER ~ IS KEPT AS ~. The line may be typed in a window on another
+// machine, whose home is not the home of the machine that keeps and runs the
+// automation, so the store expands it where it lands ([ExpandHome]).
+func expandFolder(text string) (string, error) {
+ text = strings.TrimSpace(text)
+ if rest, ok := strings.CutPrefix(text, "~"); ok {
+ if rest != "" && !strings.HasPrefix(rest, "/") {
+ return "", fmt.Errorf("the folder %q has to be a full path, or start with ~/", text)
+ }
+ return "~" + strings.TrimSuffix(filepath.ToSlash(filepath.Clean("/"+rest)), "/"), nil
+ }
+ if !filepath.IsAbs(text) {
+ return "", fmt.Errorf("the folder %q has to be a full path, or start with ~", text)
+ }
+ return filepath.Clean(text), nil
+}
+
+// ExpandHome is a workspace with a leading ~ read as THIS process's home — the
+// machine that keeps the store — and anything else exactly as it was.
+func ExpandHome(path string) (string, error) {
+ rest, ok := strings.CutPrefix(strings.TrimSpace(path), "~")
+ if !ok {
+ return path, nil
+ }
+ if rest != "" && !strings.HasPrefix(rest, "/") {
+ return path, nil
+ }
+ house, err := os.UserHomeDir()
+ if err != nil || strings.TrimSpace(house) == "" {
+ return "", fmt.Errorf("cannot find the home folder %q is under", path)
+ }
+ return filepath.Join(house, rest), nil
+}
+
+// lookWith is the watch's look with one change made, creating it when the
+// automation had none.
+func lookWith(look *Look, change func(*Look)) *Look {
+ next := Look{}
+ if look != nil {
+ next = *look
+ }
+ change(&next)
+ return &next
+}
+
+// tokenize splits a line into words, keeping a double-quoted value whole. A
+// backslash escapes a quote or a backslash inside one.
+func tokenize(line string) ([]string, error) {
+ var tokens []string
+ var current strings.Builder
+ inQuote, have := false, false
+ runes := []rune(line)
+ for i := 0; i < len(runes); i++ {
+ r := runes[i]
+ switch {
+ case inQuote && r == '\\' && i+1 < len(runes) && (runes[i+1] == '"' || runes[i+1] == '\\'):
+ current.WriteRune(runes[i+1])
+ i++
+ case r == '"':
+ inQuote, have = !inQuote, true
+ case !inQuote && (r == ' ' || r == '\t' || r == '\n'):
+ if have {
+ tokens = append(tokens, current.String())
+ current.Reset()
+ have = false
+ }
+ default:
+ current.WriteRune(r)
+ have = true
+ }
+ }
+ if inQuote {
+ return nil, errors.New("a quote was opened and never closed")
+ }
+ if have {
+ tokens = append(tokens, current.String())
+ }
+ return tokens, nil
+}
+
+// quote writes a value so [tokenize] reads it back as one token.
+func quote(value string) string {
+ if value != "" && !strings.ContainsAny(value, " \t\n\"\\") {
+ return value
+ }
+ escaped := strings.ReplaceAll(value, `\`, `\\`)
+ escaped = strings.ReplaceAll(escaped, `"`, `\"`)
+ return `"` + escaped + `"`
+}
+
+func contains(list []string, word string) bool {
+ for _, item := range list {
+ if item == word {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/automation/command_test.go b/internal/automation/command_test.go
new file mode 100644
index 0000000000..771ddb9f7f
--- /dev/null
+++ b/internal/automation/command_test.go
@@ -0,0 +1,259 @@
+package automation
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func commandNow(t *testing.T) (time.Time, *time.Location) {
+ t.Helper()
+ loc, err := time.LoadLocation("America/Toronto")
+ if err != nil {
+ t.Skip("no zone database")
+ }
+ return time.Date(2026, 10, 10, 14, 30, 0, 0, loc), loc
+}
+
+func commandBase() Automation {
+ return Automation{Workspace: "/work/project", Schedule: Schedule{Zone: "America/Toronto"}}
+}
+
+// THE THREE KINDS, TYPED. Each form a person would type reads into the
+// automation it names, with the schedule read in the window's zone.
+func TestTypedLinesReadIntoTheThreeKinds(t *testing.T) {
+ now, loc := commandNow(t)
+ cases := []struct {
+ line string
+ kind Kind
+ check func(t *testing.T, a Automation)
+ }{
+ {`add leave at 18:00 say "time to leave"`, KindReminder, func(t *testing.T, a Automation) {
+ if want := time.Date(2026, 10, 10, 18, 0, 0, 0, loc); !a.Schedule.At.Equal(want) || a.Action.Say != "time to leave" {
+ t.Fatalf("reminder = %+v", a)
+ }
+ }},
+ {`add "weekly update" every "0 9 * * 1" do "draft the weekly update" worktree time 45m usd 2.5`, KindWork, func(t *testing.T, a Automation) {
+ if a.Title != "weekly update" || a.Schedule.Every != "0 9 * * 1" || !a.Worktree || a.Limits.Time != 45*time.Minute || a.Limits.USD != 2.5 {
+ t.Fatalf("work = %+v", a)
+ }
+ }},
+ {`add "ci on main" every 15m look "gh run list -b main -L 1" until "the latest run failed" once say "CI on main is red"`, KindWatch, func(t *testing.T, a Automation) {
+ if a.Look == nil || a.Look.Command != "gh run list -b main -L 1" || a.Look.Condition != "the latest run failed" || !a.Look.Once {
+ t.Fatalf("watch = %+v", a.Look)
+ }
+ if !a.Schedule.Anchor.Equal(now.Truncate(time.Second)) {
+ t.Fatalf("an interval's grid starts when it was typed, got %v", a.Schedule.Anchor)
+ }
+ }},
+ {`add stretch in 20m say stretch`, KindReminder, func(t *testing.T, a Automation) {
+ if !a.Schedule.At.Equal(now.Add(20 * time.Minute)) {
+ t.Fatalf("in 20m = %v", a.Schedule.At)
+ }
+ }},
+ {`add "standup" at "tomorrow 09:15" say "standup in 15"`, KindReminder, func(t *testing.T, a Automation) {
+ if want := time.Date(2026, 10, 11, 9, 15, 0, 0, loc); !a.Schedule.At.Equal(want) {
+ t.Fatalf("tomorrow = %v", a.Schedule.At)
+ }
+ }},
+ }
+ for _, c := range cases {
+ cmd, err := ParseCommand(c.line)
+ if err != nil {
+ t.Fatalf("%s: %v", c.line, err)
+ }
+ a, err := cmd.Apply(commandBase(), now)
+ if err != nil {
+ t.Fatalf("%s: %v", c.line, err)
+ }
+ if a.Kind() != c.kind {
+ t.Fatalf("%s: kind %s, want %s", c.line, a.Kind(), c.kind)
+ }
+ c.check(t, a)
+ }
+}
+
+// EVERY MISTAKE IS SAID, NOT GUESSED AT. A typed line is exact, so anything it
+// cannot read is a sentence naming what it could not read.
+func TestATypedLineThatCannotBeReadSaysWhy(t *testing.T) {
+ now, _ := commandNow(t)
+ cases := map[string]string{
+ `add`: "needs a title",
+ `add leave at 18:00 say`: "needs a value",
+ `add leave evry 15m say hi`: `unknown word "evry"`,
+ `add leave at 12:00 say "lunch"`: "has already passed",
+ `add leave at 25:99 say x`: "cannot read the moment",
+ `add "unterminated`: "never closed",
+ `add x every 15m say a do b`: "",
+ `pause`: "needs the automation's id",
+ `pause abc extra`: "takes only the id",
+ `edit abc`: "at least one thing",
+ `frobnicate`: "the forms are",
+ `add x every 30s say hi`: "under a minute",
+ `add x every 15m until "it broke" say y`: "exactly one of a command, files or a tool",
+ `add x at 18:00 say y zone Mars/Base`: "unknown zone",
+ }
+ for line, want := range cases {
+ cmd, err := ParseCommand(line)
+ if err == nil {
+ _, err = cmd.Apply(commandBase(), now)
+ }
+ if want == "" {
+ // `say a do b` keeps the last action, which is the edit semantics:
+ // a later clause replaces an earlier one of the same kind.
+ if err != nil {
+ t.Fatalf("%s: %v", line, err)
+ }
+ continue
+ }
+ if err == nil || !strings.Contains(err.Error(), want) {
+ t.Fatalf("%s: error %v, want it to say %q", line, err, want)
+ }
+ }
+}
+
+// THE LINE THE LIST PUTS IN THE BOX IS THE LINE THAT MAKES IT. Writing an
+// automation out and reading the line back onto nothing but its id gives the
+// same automation.
+func TestCommandLineRoundTrips(t *testing.T) {
+ now, loc := commandNow(t)
+ automations := []Automation{
+ {ID: "a1", Title: "leave", Workspace: "/work/project", Schedule: Schedule{At: time.Date(2026, 10, 11, 18, 0, 0, 0, loc), Zone: "America/Toronto"}, Action: Action{Say: `time to "leave"`}},
+ {ID: "a2", Title: "weekly update", Workspace: "/work/project", Schedule: Schedule{Every: "0 9 * * 1", Zone: "America/Toronto"}, Action: Action{Do: "draft it"}, Worktree: true, Limits: Limits{Time: 2 * time.Hour, USD: 3}},
+ {ID: "a3", Title: "ci", Workspace: "/work/project", Schedule: Schedule{Every: "15m", Zone: "America/Toronto", Anchor: now}, Look: &Look{Files: "logs/**/*.log", Condition: "an error appears", Once: true}, Action: Action{Say: "look at the logs"}},
+ }
+ for _, original := range automations {
+ line := CommandLine(original)
+ cmd, err := ParseCommand(line)
+ if err != nil {
+ t.Fatalf("%s: %v", line, err)
+ }
+ if cmd.Verb != VerbEdit || cmd.ID != original.ID {
+ t.Fatalf("%s read as %s %s", line, cmd.Verb, cmd.ID)
+ }
+ back, err := cmd.Apply(Automation{ID: original.ID}, now)
+ if err != nil {
+ t.Fatalf("%s: %v", line, err)
+ }
+ // An interval's anchor is when the line was typed; nothing else moves.
+ back.Schedule.Anchor, original.Schedule.Anchor = time.Time{}, time.Time{}
+ if CommandLine(back) != CommandLine(original) || back.Title != original.Title || back.Kind() != original.Kind() {
+ t.Fatalf("round trip changed it:\n %s\n %s", CommandLine(original), CommandLine(back))
+ }
+ if !back.Schedule.At.Equal(original.Schedule.At) {
+ t.Fatalf("the moment moved: %v → %v", original.Schedule.At, back.Schedule.At)
+ }
+ }
+}
+
+// AN EDIT CHANGES WHAT IT NAMES AND NOTHING ELSE.
+func TestAnEditKeepsWhatTheLineDoesNotMention(t *testing.T) {
+ now, _ := commandNow(t)
+ base := Automation{ID: "w", Title: "ci", Workspace: "/work/project", Schedule: Schedule{Every: "15m", Zone: "America/Toronto"},
+ Look: &Look{Command: "make test", Condition: "it fails"}, Action: Action{Say: "red"}, Limits: Limits{USD: 1}}
+ cmd, err := ParseCommand(`edit w every 30m`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ edited, err := cmd.Apply(base, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if edited.Schedule.Every != "30m" || edited.Look.Command != "make test" || edited.Look.Condition != "it fails" || edited.Action.Say != "red" || edited.Limits.USD != 1 {
+ t.Fatalf("edit touched more than the rhythm: %+v %+v", edited, edited.Look)
+ }
+ for _, verb := range []Verb{VerbRun, VerbPause, VerbResume, VerbDelete} {
+ cmd, err := ParseCommand(string(verb) + " w")
+ if err != nil || cmd.Verb != verb || cmd.ID != "w" {
+ t.Fatalf("%s w = %+v, %v", verb, cmd, err)
+ }
+ }
+ if cmd, err := ParseCommand(" "); err != nil || cmd.Verb != VerbList {
+ t.Fatalf("a bare command is the list, got %+v, %v", cmd, err)
+ }
+}
+
+// A MOMENT IS TYPED THE WAY A PERSON SAYS IT, with or without quotes: `6pm`,
+// `tomorrow 9am`, `monday 9am` and `today 18:00` all read, and a day of the
+// week is the next one to come. The clock is Saturday 10 October, 14:30.
+func TestAMomentReadsTheWayItIsSaid(t *testing.T) {
+ now, loc := commandNow(t)
+ cases := map[string]time.Time{
+ `add leave at 6pm say x`: time.Date(2026, 10, 10, 18, 0, 0, 0, loc),
+ `add leave at 6:30pm say x`: time.Date(2026, 10, 10, 18, 30, 0, 0, loc),
+ `add leave at 18 say x`: time.Date(2026, 10, 10, 18, 0, 0, 0, loc),
+ `add leave at tomorrow 9am say x`: time.Date(2026, 10, 11, 9, 0, 0, 0, loc),
+ `add leave at "tomorrow 18:00" say x`: time.Date(2026, 10, 11, 18, 0, 0, 0, loc),
+ `add leave at today 11:59pm say x`: time.Date(2026, 10, 10, 23, 59, 0, 0, loc),
+ `add leave at monday 9am say x`: time.Date(2026, 10, 12, 9, 0, 0, 0, loc),
+ `add leave at sat 9am say x`: time.Date(2026, 10, 17, 9, 0, 0, 0, loc),
+ `add leave at sat 4pm say x`: time.Date(2026, 10, 10, 16, 0, 0, 0, loc),
+ `add leave at 12am say x zone America/Toronto`: time.Date(2026, 10, 10, 0, 0, 0, 0, loc),
+ `add leave at 2026-10-15 9:00 say x`: time.Date(2026, 10, 15, 9, 0, 0, 0, loc),
+ `add leave at 2026-10-15T09:00 say x`: time.Date(2026, 10, 15, 9, 0, 0, 0, loc),
+ `add leave at someday say x`: {},
+ `add leave at 13pm say x`: {},
+ }
+ for line, want := range cases {
+ cmd, err := ParseCommand(line)
+ if err == nil {
+ var a Automation
+ a, err = cmd.Apply(commandBase(), now)
+ if err == nil {
+ if want.IsZero() {
+ t.Errorf("%s: read as %v, want it refused", line, a.Schedule.At)
+ continue
+ }
+ if !a.Schedule.At.Equal(want) {
+ t.Errorf("%s: read as %v, want %v", line, a.Schedule.At, want)
+ }
+ continue
+ }
+ }
+ if !want.IsZero() && !strings.Contains(err.Error(), "already passed") {
+ t.Errorf("%s: refused with %v", line, err)
+ }
+ }
+}
+
+// A FOLDER UNDER ~ IS SENT AS ~, and the store reads it as its own machine's
+// home: a line typed over --host names the far machine's folder, not the
+// laptop's.
+func TestAFolderUnderHomeIsKeptForTheStoreToRead(t *testing.T) {
+ now, _ := commandNow(t)
+ for line, want := range map[string]string{
+ `add x at 18:00 say y folder ~/app`: "~/app",
+ `add x at 18:00 say y folder ~`: "~",
+ `add x at 18:00 say y folder ~/a/../b`: "~/b",
+ } {
+ cmd, err := ParseCommand(line)
+ if err != nil {
+ t.Fatal(err)
+ }
+ a, err := cmd.Apply(commandBase(), now.Add(-time.Hour))
+ if err != nil {
+ t.Fatalf("%s: %v", line, err)
+ }
+ if a.Workspace != want {
+ t.Fatalf("%s: folder %q, want %q", line, a.Workspace, want)
+ }
+ }
+ if cmd, err := ParseCommand(`add x at 18:00 say y folder ~bob/app`); err == nil {
+ if _, err := cmd.Apply(commandBase(), now.Add(-time.Hour)); err == nil {
+ t.Fatal("another person's ~ was taken")
+ }
+ }
+ s, _ := openStore(t)
+ item := reminder(time.Date(2026, 10, 9, 18, 0, 0, 0, time.UTC))
+ item.Workspace = "~/app"
+ made, err := s.Create(item)
+ if err != nil {
+ t.Fatal(err)
+ }
+ house, _ := os.UserHomeDir()
+ if made.Workspace != filepath.Join(house, "app") {
+ t.Fatalf("the store kept %q, want this machine's %q", made.Workspace, filepath.Join(house, "app"))
+ }
+}
diff --git a/internal/automation/legacy.go b/internal/automation/legacy.go
new file mode 100644
index 0000000000..6c886955e6
--- /dev/null
+++ b/internal/automation/legacy.go
@@ -0,0 +1,129 @@
+package automation
+
+// legacy.go removes the operating-system timers the feature this replaces
+// installed. Automations run only while codeaf is open, so nothing of codeaf's
+// may be left scheduled on the machine — and a timer left behind goes on
+// running `codeaf tick` against a store that no longer exists, or, on a machine
+// where a test once claimed it, fails forever with nobody told (the 2026-10-09
+// audit of the standing machinery found exactly that).
+//
+// IT RUNS ON EVERY START, NOT ONCE. An older build on the same machine — another
+// worktree's binary, a session host that has not retired yet — could put a
+// timer back, and four stat calls are the whole cost of finding none.
+//
+// IT REMOVES THEM WHOEVER INSTALLED THEM. The old timer was one per login,
+// shared by every home and build, so "ours" was never a safe question to ask of
+// it; the answer now is that codeaf schedules nothing.
+//
+// IT ONLY EVER ACTS ON A FILE IT FOUND. Each timer is unloaded through the
+// definition file under this login's home, never by its label alone, so a
+// process whose home was redirected — a test — finds nothing and touches
+// nothing. launchctl and systemctl are found on the PATH like any command.
+
+import (
+ "context"
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "testing"
+ "time"
+)
+
+// The labels and units the old features installed.
+var (
+ legacyDarwinLabels = []string{
+ "ai.agentfield.codeaf.tick",
+ "ai.agentfield.aforge.tick", // legacy-name
+ "ai.agentfield.codeaf.wake",
+ "ai.agentfield.aforge.wake", // legacy-name
+ }
+ legacyLinuxTimers = []string{
+ "codeaf-tick.timer",
+ "aforge-tick.timer", // legacy-name
+ "codeaf-wake.timer",
+ "aforge-wake.timer", // legacy-name
+ }
+)
+
+// RemoveOldTimers takes the old timers off this login. It is quiet: a person
+// who never had one is told nothing, and one that could not be removed is tried
+// again on the next start.
+func RemoveOldTimers() {
+ if testing.Testing() {
+ return
+ }
+ home, err := os.UserHomeDir()
+ if err != nil || home == "" {
+ return
+ }
+ switch runtime.GOOS {
+ case "darwin":
+ removeDarwinTimers(home)
+ case "linux":
+ removeLinuxTimers(home)
+ }
+}
+
+func removeDarwinTimers(home string) {
+ domain := "gui/" + strconv.Itoa(os.Getuid())
+ for _, label := range legacyDarwinLabels {
+ plist := filepath.Join(home, "Library", "LaunchAgents", label+".plist")
+ if _, err := os.Stat(plist); err != nil {
+ continue
+ }
+ // THE FILE FIRST, THE JOB SECOND, and the job named by its label. This
+ // can be running inside `codeaf tick` that launchd itself started, and
+ // unloading the job kills that process: done first, it would end the
+ // removal before the file went, and the timer would be loaded again at
+ // the next login. The label form names the job without its file.
+ _ = os.Remove(plist)
+ _ = os.Remove(plist + ".lock")
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ _ = exec.CommandContext(ctx, "launchctl", "bootout", domain+"/"+label).Run()
+ cancel()
+ }
+}
+
+func removeLinuxTimers(home string) {
+ units := filepath.Join(home, ".config", "systemd", "user")
+ touched := false
+ for _, timer := range legacyLinuxTimers {
+ service := timer[:len(timer)-len(".timer")] + ".service"
+ paths := []string{
+ filepath.Join(units, timer),
+ filepath.Join(units, service),
+ filepath.Join(units, "timers.target.wants", timer),
+ }
+ found := false
+ for _, path := range paths {
+ if _, err := os.Lstat(path); err == nil {
+ found = true
+ }
+ }
+ if !found {
+ continue
+ }
+ touched = true
+ // THE FILES FIRST, for the same reason as on macOS: a removal running
+ // inside the service the timer started must not be stopped before the
+ // files that would start it again are gone. The timer is then stopped by
+ // name, and the reload forgets the units.
+ for _, path := range paths {
+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ }
+ _ = os.Remove(filepath.Join(units, timer) + ".lock")
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ _ = exec.CommandContext(ctx, "systemctl", "--user", "stop", timer).Run()
+ cancel()
+ }
+ if touched {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ _ = exec.CommandContext(ctx, "systemctl", "--user", "daemon-reload").Run()
+ cancel()
+ }
+}
diff --git a/internal/automation/presence.go b/internal/automation/presence.go
new file mode 100644
index 0000000000..3e07df7279
--- /dev/null
+++ b/internal/automation/presence.go
@@ -0,0 +1,193 @@
+package automation
+
+// presence.go answers the one question the clock may never get wrong: is any
+// codeaf window open on this machine right now?
+//
+// A WINDOW IS A LOCK, NOT A HEARTBEAT. Each open window holds an operating-
+// system lock on a file of its own for as long as it is open; the kernel drops
+// the lock the moment its process ends, however it ends. So "is it still open?"
+// is answered by trying to take the lock — a file whose lock can be taken is a
+// window that is gone — and there is no clock to drift, no timeout to tune and
+// no stale entry that looks alive. The same shape already keeps one clock at a
+// time ([Clock]).
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/filelock"
+)
+
+// windowsDir is the folder, inside the store's root, that holds one lock file
+// per open window.
+const windowsDir = "windows"
+
+// Presence is the set of windows open on this machine for one codeaf home.
+type Presence struct {
+ dir string
+}
+
+// NewPresence is the presence folder under root, the store's own folder.
+func NewPresence(root string) *Presence {
+ return &Presence{dir: filepath.Join(root, windowsDir)}
+}
+
+// Window is what one open window says about itself. Liveness is the lock,
+// never these fields.
+type Window struct {
+ PID int `json:"pid"`
+ Label string `json:"label,omitempty"`
+ Started time.Time `json:"started"`
+ // Env is a salted fingerprint of the environment the window was started
+ // in — the provider keys and codeaf's own variables, never their values —
+ // so the clock can tell that the newest window would hand a run a
+ // different key than the one it was started with ([Clock.Env]). Empty says
+ // nothing, and a window that says nothing is never followed.
+ Env string `json:"env,omitempty"`
+}
+
+// Hold registers one open window until release is called or the process ends.
+// label says what it is ("window", "attached from laptop.local") for anybody
+// reading the folder.
+func (p *Presence) Hold(label string) (release func(), err error) {
+ return p.HoldAs(Window{Label: label})
+}
+
+// HoldAs is [Presence.Hold] with everything the window says about itself.
+func (p *Presence) HoldAs(w Window) (release func(), err error) {
+ if p == nil {
+ return func() {}, errors.New("automations: no presence folder")
+ }
+ if err := os.MkdirAll(p.dir, 0o700); err != nil {
+ return func() {}, fmt.Errorf("automations: %w", err)
+ }
+ var raw [6]byte
+ if _, err := rand.Read(raw[:]); err != nil {
+ return func() {}, fmt.Errorf("automations: %w", err)
+ }
+ // THE FILE IS LOCKED AND WRITTEN UNDER A NAME NOBODY COUNTS, and only then
+ // renamed into place. Created straight under its `.lock` name, it stood
+ // unlocked for an instant, and a counter that looked in that instant took
+ // the lock, read a window that was gone, and removed it — leaving an open
+ // window invisible, and no clock running for it, for as long as it stayed
+ // open. A lock is on the file, not the name, so it survives the rename.
+ name := fmt.Sprintf("%d-%s", os.Getpid(), hex.EncodeToString(raw[:]))
+ staging := filepath.Join(p.dir, "."+name+".opening")
+ path := filepath.Join(p.dir, name+".lock")
+ file, err := os.OpenFile(staging, os.O_CREATE|os.O_RDWR|os.O_EXCL, 0o600)
+ if err != nil {
+ return func() {}, fmt.Errorf("automations: %w", err)
+ }
+ if err := filelock.Lock(file, true, true); err != nil {
+ _ = file.Close()
+ _ = os.Remove(staging)
+ return func() {}, fmt.Errorf("automations: hold a window: %w", err)
+ }
+ body, _ := json.Marshal(Window{PID: os.Getpid(), Label: strings.TrimSpace(w.Label), Started: time.Now(), Env: strings.TrimSpace(w.Env)})
+ _, _ = file.Write(body)
+ if err := os.Rename(staging, path); err != nil {
+ _ = os.Remove(staging)
+ _ = filelock.Unlock(file)
+ _ = file.Close()
+ return func() {}, fmt.Errorf("automations: hold a window: %w", err)
+ }
+ done := false
+ return func() {
+ if done {
+ return
+ }
+ done = true
+ // REMOVED BEFORE IT IS UNLOCKED, so a counter never takes the lock of a
+ // file that is still going to be removed by its owner.
+ _ = os.Remove(path)
+ _ = filelock.Unlock(file)
+ _ = file.Close()
+ }, nil
+}
+
+// Count is how many windows are open now. A file whose lock can be taken
+// belongs to a window that is gone, and it is removed as it is found.
+func (p *Presence) Count() (int, error) {
+ if p == nil {
+ return 0, nil
+ }
+ entries, err := os.ReadDir(p.dir)
+ if errors.Is(err, os.ErrNotExist) {
+ return 0, nil
+ }
+ if err != nil {
+ return 0, err
+ }
+ open := 0
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".lock") {
+ continue
+ }
+ if p.alive(filepath.Join(p.dir, entry.Name())) {
+ open++
+ }
+ }
+ return open, nil
+}
+
+// alive reports whether a window file's owner still holds it, and removes the
+// file when it does not.
+func (p *Presence) alive(path string) bool {
+ file, err := os.OpenFile(path, os.O_RDWR, 0)
+ if err != nil {
+ // Gone between the listing and the open: its owner released it.
+ return false
+ }
+ defer file.Close()
+ err = filelock.Lock(file, true, true)
+ if err == nil {
+ _ = os.Remove(path)
+ _ = filelock.Unlock(file)
+ return false
+ }
+ return filelock.IsBusy(err)
+}
+
+// Newest is the most recently opened window that said what environment it was
+// started in, and false when no open window did. The clock follows it
+// ([Clock.Env]), and a window starts a clock only when it is that window or
+// shares its environment, so two windows from two shells never take turns.
+func (p *Presence) Newest() (Window, bool) {
+ if p == nil {
+ return Window{}, false
+ }
+ entries, err := os.ReadDir(p.dir)
+ if err != nil {
+ return Window{}, false
+ }
+ var newest Window
+ found := false
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".lock") {
+ continue
+ }
+ path := filepath.Join(p.dir, entry.Name())
+ if !p.alive(path) {
+ continue
+ }
+ body, err := os.ReadFile(path)
+ if err != nil {
+ continue
+ }
+ var w Window
+ if json.Unmarshal(body, &w) != nil || strings.TrimSpace(w.Env) == "" {
+ continue
+ }
+ if !found || w.Started.After(newest.Started) {
+ newest, found = w, true
+ }
+ }
+ return newest, found
+}
diff --git a/internal/automation/presence_test.go b/internal/automation/presence_test.go
new file mode 100644
index 0000000000..7b6eb673ef
--- /dev/null
+++ b/internal/automation/presence_test.go
@@ -0,0 +1,116 @@
+package automation
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/filelock"
+)
+
+func TestPresenceCountsTheWindowsThatAreHeld(t *testing.T) {
+ p := NewPresence(t.TempDir())
+ if n, err := p.Count(); err != nil || n != 0 {
+ t.Fatalf("an empty machine counts %d, %v", n, err)
+ }
+ first, err := p.Hold("window")
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := p.Hold("attached")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n, _ := p.Count(); n != 2 {
+ t.Fatalf("two held windows count %d", n)
+ }
+ first()
+ first() // releasing twice is harmless
+ if n, _ := p.Count(); n != 1 {
+ t.Fatalf("after one release, %d", n)
+ }
+ second()
+ if n, _ := p.Count(); n != 0 {
+ t.Fatalf("after both releases, %d", n)
+ }
+}
+
+// A WINDOW WHOSE PROCESS IS GONE IS NOT A WINDOW. A file left behind by a crash
+// has nobody holding its lock, so the count takes the lock, removes the file
+// and does not count it.
+func TestPresenceSweepsAWindowNobodyHolds(t *testing.T) {
+ root := t.TempDir()
+ p := NewPresence(root)
+ if err := os.MkdirAll(filepath.Join(root, windowsDir), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ stale := filepath.Join(root, windowsDir, "999999-dead.lock")
+ if err := os.WriteFile(stale, []byte(`{"pid":999999}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if n, err := p.Count(); err != nil || n != 0 {
+ t.Fatalf("a crashed window counts %d, %v", n, err)
+ }
+ if _, err := os.Stat(stale); !os.IsNotExist(err) {
+ t.Fatalf("the stale file is still there: %v", err)
+ }
+}
+
+// A lock held through a different open file — another process, in life — is a
+// window that is open.
+func TestPresenceCountsALockHeldElsewhere(t *testing.T) {
+ root := t.TempDir()
+ p := NewPresence(root)
+ if err := os.MkdirAll(filepath.Join(root, windowsDir), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(root, windowsDir, "1-other.lock")
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer file.Close()
+ if err := filelock.Lock(file, true, true); err != nil {
+ t.Fatal(err)
+ }
+ if n, _ := p.Count(); n != 1 {
+ t.Fatalf("a window held elsewhere counts %d", n)
+ }
+}
+
+// A WINDOW BEING OPENED IS NEVER COUNTED AWAY. Counters look all the time, and
+// a window's file must never stand under its counted name before its owner
+// holds it — or a counter takes it for a window that is gone and removes it.
+func TestAWindowBeingOpenedIsNeverCountedAway(t *testing.T) {
+ p := NewPresence(t.TempDir())
+ stop := make(chan struct{})
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ for {
+ select {
+ case <-stop:
+ return
+ default:
+ _, _ = p.Count()
+ }
+ }
+ }()
+ const windows = 40
+ var releases []func()
+ for i := 0; i < windows; i++ {
+ release, err := p.Hold("window")
+ if err != nil {
+ t.Fatal(err)
+ }
+ releases = append(releases, release)
+ }
+ close(stop)
+ <-done
+ if n, err := p.Count(); err != nil || n != windows {
+ t.Fatalf("%d windows are open and %d are counted (%v)", windows, n, err)
+ }
+ for _, release := range releases {
+ release()
+ }
+}
diff --git a/internal/automation/process.go b/internal/automation/process.go
new file mode 100644
index 0000000000..c2c0d5a346
--- /dev/null
+++ b/internal/automation/process.go
@@ -0,0 +1,117 @@
+package automation
+
+// process.go is a window's half of the clock: hold presence for as long as the
+// window is open, and make sure a clock is running — now, and every so often
+// after, so a clock that exited (a replaced build, a crash) is replaced while
+// any window lives.
+
+import (
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/env"
+ "github.com/Agent-Field/codeaf/internal/processgroup"
+)
+
+// keepEvery is how often an open window checks that a clock is running. A
+// variable so a test need not wait; nothing in the product writes it.
+var keepEvery = 30 * time.Second
+
+// LogName is the clock's own log inside the store's root: what a clock that
+// died at birth said on its way out, and what a running clock could not do.
+const LogName = "clock.log"
+
+// StartClock starts `binary args…` — the clock command — detached, unless a
+// clock already holds the lock. Two windows racing both start one; the second
+// finds the lock held and leaves, which is the lock doing its job.
+//
+// THE CLOCK INHERITS THIS WINDOW'S ENVIRONMENT — its keys, its PATH, its zone —
+// because it is this person's work done while this window is open.
+func StartClock(root, binary string, args ...string) error {
+ if Held(root) {
+ return nil
+ }
+ if !Startable(binary) {
+ return errors.New("automations: not a codeaf that can run a clock")
+ }
+ if err := os.MkdirAll(root, 0o700); err != nil {
+ return err
+ }
+ null, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
+ if err != nil {
+ return err
+ }
+ defer null.Close()
+ stderr := any(null)
+ if log, err := os.OpenFile(filepath.Join(root, LogName), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600); err == nil {
+ defer log.Close()
+ stderr = log
+ }
+ command := exec.Command(binary, args...)
+ command.Stdin, command.Stdout = null, null
+ command.Stderr = stderr.(*os.File)
+ processgroup.ConfigureDetached(command)
+ if err := command.Start(); err != nil {
+ return err
+ }
+ // The clock is nobody's to wait for: it outlives the window that started it
+ // whenever another window is still open.
+ return command.Process.Release()
+}
+
+// Startable reports whether binary is a codeaf that can run a clock. A test
+// binary is not: started with the clock's arguments it would run its tests,
+// detached, in the background.
+func Startable(binary string) bool {
+ if strings.TrimSpace(env.Get("CODEAF_NO_AUTOMATIONS")) != "" {
+ return false
+ }
+ base := filepath.Base(binary)
+ return base != "" && !strings.HasSuffix(base, ".test") && !strings.HasSuffix(base, ".test.exe")
+}
+
+// Keep holds one open window until release is called, and keeps a clock
+// running while it does: start is called now and every keepEvery after.
+func Keep(root, label string, start func() error) (release func(), err error) {
+ return KeepAs(root, Window{Label: label}, start)
+}
+
+// KeepAs is [Keep] with everything the window says about itself
+// ([Window.Env] above all).
+func KeepAs(root string, w Window, start func() error) (release func(), err error) {
+ presence := NewPresence(root)
+ letGo, err := presence.HoldAs(w)
+ if err != nil {
+ return func() {}, err
+ }
+ stop := make(chan struct{})
+ var once sync.Once
+ go func() {
+ if start != nil {
+ _ = start()
+ }
+ ticker := time.NewTicker(keepEvery)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-stop:
+ return
+ case <-ticker.C:
+ if start != nil {
+ _ = start()
+ }
+ }
+ }
+ }()
+ return func() {
+ once.Do(func() {
+ close(stop)
+ letGo()
+ })
+ }, nil
+}
diff --git a/internal/automation/schedule.go b/internal/automation/schedule.go
new file mode 100644
index 0000000000..521c2ad982
--- /dev/null
+++ b/internal/automation/schedule.go
@@ -0,0 +1,412 @@
+package automation
+
+// schedule.go is the whole of "when": one moment, or a rhythm read in a named
+// zone, and one function from a moment to the next moment after it.
+//
+// IT IS WRITTEN HERE AND NOT TAKEN FROM A LIBRARY. The dialect is small on
+// purpose — a five-field numeric cron line, or a duration — because the person
+// never has to type either: the model or the typed command writes the spec, and
+// the card reads it back in words beside the exact line. A spec nobody types
+// gains nothing from @yearly, seconds or month names; it gains a great deal
+// from being readable in one sitting and right across a daylight-saving change,
+// which is the one place the schedule this replaces was wrong.
+
+import (
+ "errors"
+ "fmt"
+ "math"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// MinInterval is the shortest rhythm an automation may have. A minute is the
+// resolution of the clock itself; anything shorter would be a promise the clock
+// cannot keep.
+const MinInterval = time.Minute
+
+// Schedule is when an automation wakes. Exactly one of At or Every is set.
+type Schedule struct {
+ // At is the one moment of a one-time automation.
+ At time.Time `json:"at,omitempty"`
+ // Every is a rhythm: a five-field cron line ("0 9 * * 1") or a duration of
+ // at least [MinInterval] ("15m", "2h", "1d").
+ Every string `json:"every,omitempty"`
+ // Zone is the IANA zone a cron line's wall clock is read in. It is captured
+ // when the automation is made, so a rhythm said in Toronto stays a Toronto
+ // rhythm on a machine — or a remote host — whose own zone is different.
+ // Empty is the machine's zone.
+ Zone string `json:"zone,omitempty"`
+ // Anchor is where a duration rhythm's grid starts. Every slot is
+ // Anchor + k×interval, so a run that started late never moves the next one
+ // later: the rhythm is the grid, not the gap since the last run.
+ Anchor time.Time `json:"anchor,omitempty"`
+}
+
+// Validate refuses a schedule that could never wake, or could not be read.
+func (s Schedule) Validate() error {
+ every := strings.TrimSpace(s.Every)
+ switch {
+ case s.At.IsZero() && every == "":
+ return errors.New("a schedule needs a moment or a rhythm")
+ case !s.At.IsZero() && every != "":
+ return errors.New("a schedule is a moment or a rhythm, not both")
+ }
+ if _, err := s.location(); err != nil {
+ return err
+ }
+ if every == "" {
+ return nil
+ }
+ rhythm, err := parseRhythm(every)
+ if err != nil {
+ return err
+ }
+ if rhythm.cron != nil {
+ // A LINE THAT NEVER MATCHES IS REFUSED HERE, at the door, rather than
+ // accepted and left "waiting for its time" forever. "0 0 30 2 *" is a
+ // legal cron line and a schedule that never happens.
+ loc, _ := s.location()
+ if rhythm.cron.next(time.Now(), loc).IsZero() {
+ return fmt.Errorf("the rhythm %q never happens", every)
+ }
+ }
+ return nil
+}
+
+// Repeats reports whether this schedule is a rhythm rather than one moment.
+func (s Schedule) Repeats() bool { return strings.TrimSpace(s.Every) != "" }
+
+// First is when a new automation first wakes, made at now.
+//
+// A MOMENT IS ITS OWN MOMENT. A rhythm's first slot is the first one strictly
+// after now, never now itself: "every Monday at 9" agreed to on a Monday at 10
+// does not mean today, and "every 15m" means fifteen minutes from now.
+func (s Schedule) First(now time.Time) (time.Time, error) {
+ if err := s.Validate(); err != nil {
+ return time.Time{}, err
+ }
+ if !s.Repeats() {
+ return s.At, nil
+ }
+ return s.Next(now)
+}
+
+// Next is the first slot strictly after after. A one-time schedule has none.
+func (s Schedule) Next(after time.Time) (time.Time, error) {
+ if !s.Repeats() {
+ return time.Time{}, nil
+ }
+ rhythm, err := parseRhythm(s.Every)
+ if err != nil {
+ return time.Time{}, err
+ }
+ if rhythm.cron != nil {
+ loc, err := s.location()
+ if err != nil {
+ return time.Time{}, err
+ }
+ next := rhythm.cron.next(after, loc)
+ if next.IsZero() {
+ return time.Time{}, fmt.Errorf("the rhythm %q never happens", s.Every)
+ }
+ return next, nil
+ }
+ return gridNext(s.Anchor, rhythm.interval, after), nil
+}
+
+// Interval is a duration rhythm's length, and zero for a cron line or a moment.
+// The card uses it to estimate how often a watch looks.
+func (s Schedule) Interval() time.Duration {
+ if !s.Repeats() {
+ return 0
+ }
+ rhythm, err := parseRhythm(s.Every)
+ if err != nil || rhythm.cron != nil {
+ return 0
+ }
+ return rhythm.interval
+}
+
+// location is the zone the schedule is read in.
+func (s Schedule) location() (*time.Location, error) {
+ zone := strings.TrimSpace(s.Zone)
+ if zone == "" || zone == "Local" {
+ return time.Local, nil
+ }
+ loc, err := time.LoadLocation(zone)
+ if err != nil {
+ return nil, fmt.Errorf("unknown time zone %q", zone)
+ }
+ return loc, nil
+}
+
+// gridNext is the first slot of anchor + k×interval strictly after after. An
+// anchor in the future is itself the next slot once after is before it.
+func gridNext(anchor time.Time, interval time.Duration, after time.Time) time.Time {
+ if anchor.IsZero() {
+ return after.Add(interval)
+ }
+ if after.Before(anchor) {
+ return anchor
+ }
+ steps := after.Sub(anchor)/interval + 1
+ return anchor.Add(steps * interval)
+}
+
+// rhythm is a parsed Every: a cron line or an interval, never both.
+type rhythm struct {
+ cron *cronLine
+ interval time.Duration
+}
+
+// parseRhythm tells the two dialects apart by a space, which a duration never
+// has and a cron line always does.
+func parseRhythm(every string) (rhythm, error) {
+ spec := strings.TrimSpace(every)
+ if spec == "" {
+ return rhythm{}, errors.New("an empty rhythm")
+ }
+ if !strings.ContainsAny(spec, " \t") {
+ interval, err := ParseInterval(spec)
+ if err != nil {
+ return rhythm{}, err
+ }
+ return rhythm{interval: interval}, nil
+ }
+ line, err := parseCron(spec)
+ if err != nil {
+ return rhythm{}, err
+ }
+ return rhythm{cron: &line}, nil
+}
+
+// maxIntervalDays is the most days a rhythm or a distance may count before the
+// duration it makes would overflow.
+const maxIntervalDays = int64(math.MaxInt64 / int64(24*time.Hour))
+
+// ParseInterval reads a duration of at least [MinInterval]. It is Go's own
+// syntax with one addition, "d" for a day, because "every 2d" is a thing people
+// say and Go's parser refuses it.
+func ParseInterval(text string) (time.Duration, error) {
+ spec := strings.TrimSpace(text)
+ if days, ok := strings.CutSuffix(spec, "d"); ok {
+ count, err := strconv.Atoi(days)
+ // A COUNT OF DAYS THAT WOULD OVERFLOW A DURATION IS REFUSED, not
+ // wrapped: 107000d wrapped round to a negative rhythm, which passed every
+ // other check and woke on every pass forever.
+ if err != nil || count < 1 || int64(count) > maxIntervalDays {
+ return 0, fmt.Errorf("cannot read the rhythm %q", text)
+ }
+ return time.Duration(count) * 24 * time.Hour, nil
+ }
+ interval, err := time.ParseDuration(spec)
+ if err != nil {
+ return 0, fmt.Errorf("cannot read the rhythm %q", text)
+ }
+ if interval < MinInterval {
+ return 0, fmt.Errorf("the rhythm %q is under a minute", text)
+ }
+ return interval, nil
+}
+
+// ── the cron line ───────────────────────────────────────────────────────────
+
+// cronLine is one parsed line: five sets of allowed numbers, each of which
+// remembers whether it was a bare star, because the day-of-month and
+// day-of-week fields mean different things when one of them is unrestricted.
+type cronLine struct {
+ minute, hour, dom, month, dow cronField
+}
+
+type cronField struct {
+ star bool
+ allowed map[int]bool
+}
+
+func (f cronField) has(value int) bool { return f.star || f.allowed[value] }
+
+// parseCron reads "minute hour day-of-month month day-of-week". Ranges, lists
+// and steps are supported; names are not.
+func parseCron(spec string) (cronLine, error) {
+ parts := strings.Fields(spec)
+ if len(parts) != 5 {
+ return cronLine{}, fmt.Errorf("a rhythm needs five fields, or a duration: %q", spec)
+ }
+ var line cronLine
+ var err error
+ if line.minute, err = parseCronField(parts[0], 0, 59, "minute"); err != nil {
+ return cronLine{}, err
+ }
+ if line.hour, err = parseCronField(parts[1], 0, 23, "hour"); err != nil {
+ return cronLine{}, err
+ }
+ if line.dom, err = parseCronField(parts[2], 1, 31, "day of the month"); err != nil {
+ return cronLine{}, err
+ }
+ if line.month, err = parseCronField(parts[3], 1, 12, "month"); err != nil {
+ return cronLine{}, err
+ }
+ if line.dow, err = parseCronField(parts[4], 0, 7, "day of the week"); err != nil {
+ return cronLine{}, err
+ }
+ // SUNDAY IS BOTH 0 AND 7, as every cron agrees; the rest of this file only
+ // ever asks about 0.
+ if line.dow.allowed[7] {
+ line.dow.allowed[0] = true
+ delete(line.dow.allowed, 7)
+ }
+ return line, nil
+}
+
+func parseCronField(text string, low, high int, name string) (cronField, error) {
+ field := cronField{allowed: map[int]bool{}}
+ if text == "*" {
+ field.star = true
+ return field, nil
+ }
+ for _, piece := range strings.Split(text, ",") {
+ piece = strings.TrimSpace(piece)
+ if piece == "" {
+ return cronField{}, fmt.Errorf("an empty %s in the rhythm", name)
+ }
+ step := 1
+ if slash := strings.IndexByte(piece, '/'); slash >= 0 {
+ parsed, err := strconv.Atoi(piece[slash+1:])
+ if err != nil || parsed < 1 {
+ return cronField{}, fmt.Errorf("cannot read the %s step in %q", name, piece)
+ }
+ step = parsed
+ piece = piece[:slash]
+ }
+ first, last := low, high
+ switch {
+ case piece == "*":
+ // Already the whole range.
+ case strings.ContainsRune(piece, '-'):
+ ends := strings.SplitN(piece, "-", 2)
+ start, startErr := strconv.Atoi(strings.TrimSpace(ends[0]))
+ stop, stopErr := strconv.Atoi(strings.TrimSpace(ends[1]))
+ if startErr != nil || stopErr != nil {
+ return cronField{}, fmt.Errorf("cannot read the %s range %q", name, piece)
+ }
+ if start > stop {
+ return cronField{}, fmt.Errorf("a backwards %s range %q", name, piece)
+ }
+ first, last = start, stop
+ default:
+ value, err := strconv.Atoi(piece)
+ if err != nil {
+ return cronField{}, fmt.Errorf("cannot read the %s %q", name, piece)
+ }
+ first = value
+ // A bare number with a step means "from here on", which is what
+ // "5/15" has meant since Vixie cron.
+ if step == 1 {
+ last = value
+ }
+ }
+ if first < low || last > high {
+ return cronField{}, fmt.Errorf("a %s outside %d-%d in %q", name, low, high, text)
+ }
+ for value := first; value <= last; value += step {
+ field.allowed[value] = true
+ }
+ }
+ if len(field.allowed) == 0 {
+ return cronField{}, fmt.Errorf("nothing matches the %s in %q", name, text)
+ }
+ return field, nil
+}
+
+// cronHorizon bounds the search for a line that matches nothing at all —
+// "0 0 30 2 *" is legal and never happens, and a search with no floor would
+// spin forever on it. Four years covers every leap day.
+const cronHorizon = 4 * 366 * 24 * 60
+
+// next is the first minute strictly after after, read in loc, that the line
+// matches.
+//
+// IT STEPS IN ABSOLUTE TIME AND READS THE WALL CLOCK OFF EACH CANDIDATE. The
+// line this replaces built its first candidate with time.Date from the wall
+// clock, and on an autumn night Go resolves an ambiguous wall time to the
+// EARLIER of its two instants — so during the repeated hour its "next" was in
+// the past, and an item due at 01:30 fired on every pass until 02:00. Here the
+// first candidate is after's own instant plus a minute, so it can never be
+// behind; time.Date is used only for the day and hour jumps, and [forward]
+// refuses any jump that does not move.
+//
+// AND THE WALL CLOCK MAY NEVER GO BACKWARDS. A candidate whose wall time is not
+// later than after's own is skipped, which is how a 01:30 line fires once on
+// the night the clocks fall back rather than at 01:30 twice — the behaviour
+// every cron daemon settles on. In spring the missing hour simply has no
+// matching minutes, so a 02:30 line waits for the next day, as the line this
+// replaces was already pinned to do.
+func (c cronLine) next(after time.Time, loc *time.Location) time.Time {
+ floor := wallOf(after.In(loc))
+ t := after.Truncate(time.Minute).Add(time.Minute)
+ for step := 0; step < cronHorizon; step++ {
+ wall := t.In(loc)
+ switch {
+ case !c.matchesDate(wall):
+ t = forward(t, time.Date(wall.Year(), wall.Month(), wall.Day()+1, 0, 0, 0, 0, loc))
+ case !c.hour.has(wall.Hour()):
+ t = forward(t, time.Date(wall.Year(), wall.Month(), wall.Day(), wall.Hour()+1, 0, 0, 0, loc))
+ case !c.minute.has(wall.Minute()):
+ t = t.Add(time.Minute)
+ case !wallOf(wall).after(floor):
+ t = t.Add(time.Minute)
+ default:
+ return t
+ }
+ }
+ return time.Time{}
+}
+
+// forward keeps the search honest: a jump built from wall-clock parts can land
+// on or before where it started when a zone shifts underneath it, and a search
+// that does not move is a search that does not end.
+func forward(from, to time.Time) time.Time {
+ if to.After(from) {
+ return to
+ }
+ return from.Add(time.Minute)
+}
+
+// matchesDate applies the one rule of cron that surprises everybody: when BOTH
+// the day of the month and the day of the week are restricted, a day matching
+// either is a match. When only one is restricted, only that one is read.
+func (c cronLine) matchesDate(moment time.Time) bool {
+ if !c.month.has(int(moment.Month())) {
+ return false
+ }
+ day, weekday := moment.Day(), int(moment.Weekday())
+ switch {
+ case c.dom.star && c.dow.star:
+ return true
+ case c.dom.star:
+ return c.dow.has(weekday)
+ case c.dow.star:
+ return c.dom.has(day)
+ default:
+ return c.dom.has(day) || c.dow.has(weekday)
+ }
+}
+
+// wall is a moment's wall clock to the minute, comparable as a tuple.
+type wall struct{ year, month, day, hour, minute int }
+
+func wallOf(t time.Time) wall {
+ return wall{t.Year(), int(t.Month()), t.Day(), t.Hour(), t.Minute()}
+}
+
+func (w wall) after(other wall) bool {
+ a := [5]int{w.year, w.month, w.day, w.hour, w.minute}
+ b := [5]int{other.year, other.month, other.day, other.hour, other.minute}
+ for i := range a {
+ if a[i] != b[i] {
+ return a[i] > b[i]
+ }
+ }
+ return false
+}
diff --git a/internal/automation/schedule_test.go b/internal/automation/schedule_test.go
new file mode 100644
index 0000000000..b56505028d
--- /dev/null
+++ b/internal/automation/schedule_test.go
@@ -0,0 +1,217 @@
+package automation
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func zone(t *testing.T, name string) *time.Location {
+ t.Helper()
+ loc, err := time.LoadLocation(name)
+ if err != nil {
+ t.Skipf("no zone data for %s: %v", name, err)
+ }
+ return loc
+}
+
+// THE NIGHT THE CLOCKS FALL BACK, A 01:30 LINE FIRES ONCE. The schedule this
+// replaces returned a moment in the past for every instant of the repeated
+// hour, so a pass every five minutes fired a daily item eight times (found in
+// the 2026-10-09 audit of the standing machinery).
+func TestCronFiresOnceOnTheNightTheClocksFallBack(t *testing.T) {
+ loc := zone(t, "America/Toronto")
+ s := Schedule{Every: "30 1 * * *", Zone: "America/Toronto"}
+ due, err := s.Next(time.Date(2026, 10, 31, 12, 0, 0, 0, loc))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var fired []string
+ for now := time.Date(2026, 11, 1, 0, 0, 0, 0, loc); now.Before(time.Date(2026, 11, 1, 4, 0, 0, 0, loc)); now = now.Add(time.Minute) {
+ if !now.Before(due) {
+ fired = append(fired, now.Format("15:04 MST"))
+ if due, err = s.Next(now); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ if len(fired) != 1 || fired[0] != "01:30 EDT" {
+ t.Fatalf("fired %v on 2026-11-01, want exactly [01:30 EDT]", fired)
+ }
+}
+
+// NEXT IS ALWAYS LATER, BY THE CLOCK AND BY THE WALL. Walked minute by minute
+// across both of a year's transitions in zones north and south of the equator,
+// one with a forty-five-minute offset and one with no daylight saving at all.
+func TestCronNextIsAlwaysLaterAcrossEveryTransition(t *testing.T) {
+ specs := []string{"30 1 * * *", "0 * * * *", "*/15 * * * *", "30 2 * * *", "0 0 * * *", "45 23 * * 0"}
+ windows := []struct {
+ zone string
+ start, end time.Time
+ }{
+ {"America/Toronto", time.Date(2026, 3, 7, 22, 0, 0, 0, time.UTC), time.Date(2026, 3, 9, 4, 0, 0, 0, time.UTC)},
+ {"America/Toronto", time.Date(2026, 10, 31, 22, 0, 0, 0, time.UTC), time.Date(2026, 11, 2, 4, 0, 0, 0, time.UTC)},
+ {"Europe/London", time.Date(2026, 10, 24, 22, 0, 0, 0, time.UTC), time.Date(2026, 10, 26, 4, 0, 0, 0, time.UTC)},
+ {"Australia/Sydney", time.Date(2026, 4, 4, 10, 0, 0, 0, time.UTC), time.Date(2026, 4, 5, 22, 0, 0, 0, time.UTC)},
+ {"Pacific/Chatham", time.Date(2026, 4, 4, 10, 0, 0, 0, time.UTC), time.Date(2026, 4, 5, 22, 0, 0, 0, time.UTC)},
+ {"Asia/Kolkata", time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)},
+ }
+ for _, w := range windows {
+ loc := zone(t, w.zone)
+ for _, spec := range specs {
+ s := Schedule{Every: spec, Zone: w.zone}
+ for now := w.start; now.Before(w.end); now = now.Add(7 * time.Minute) {
+ next, err := s.Next(now)
+ if err != nil {
+ t.Fatalf("%s %q at %v: %v", w.zone, spec, now, err)
+ }
+ if !next.After(now) {
+ t.Fatalf("%s %q: next(%v) = %v is not after it", w.zone, spec, now.In(loc), next.In(loc))
+ }
+ if !wallOf(next.In(loc)).after(wallOf(now.In(loc))) {
+ t.Fatalf("%s %q: next(%v) = %v went backwards on the wall clock", w.zone, spec, now.In(loc), next.In(loc))
+ }
+ }
+ }
+ }
+}
+
+// In spring the missing hour has no minutes, so a 02:30 line waits a day rather
+// than firing an hour early.
+func TestCronSkipsTheHourThatDoesNotExist(t *testing.T) {
+ loc := zone(t, "America/Toronto")
+ s := Schedule{Every: "30 2 * * *", Zone: "America/Toronto"}
+ next, err := s.Next(time.Date(2026, 3, 7, 12, 0, 0, 0, loc))
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The 7th's 02:30 is already behind noon and the 8th has no 02:30 at all,
+ // so the honest answer is the 9th.
+ if got := next.In(loc).Format("2006-01-02 15:04 MST"); got != "2026-03-09 02:30 EDT" {
+ t.Fatalf("next = %s, want 2026-03-09 02:30 EDT", got)
+ }
+}
+
+// A RHYTHM SAID IN ONE ZONE STAYS IN IT. The machine — or a remote host — may
+// be anywhere; nine in Toronto is nine in Toronto.
+func TestCronIsReadInTheAutomationsOwnZone(t *testing.T) {
+ loc := zone(t, "America/Toronto")
+ s := Schedule{Every: "0 9 * * 1", Zone: "America/Toronto"}
+ next, err := s.Next(time.Date(2026, 10, 9, 15, 0, 0, 0, time.UTC))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := next.In(loc).Format("Mon 15:04"); got != "Mon 09:00" {
+ t.Fatalf("next = %s", got)
+ }
+}
+
+func TestCronEitherDayRuleWhenBothAreRestricted(t *testing.T) {
+ line, err := parseCron("0 9 13 * 5")
+ if err != nil {
+ t.Fatal(err)
+ }
+ friday := time.Date(2026, 10, 9, 9, 0, 0, 0, time.UTC) // a Friday, the 9th
+ thirteenth := time.Date(2026, 10, 13, 9, 0, 0, 0, time.UTC) // a Tuesday
+ tuesday := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
+ if !line.matchesDate(friday) || !line.matchesDate(thirteenth) || line.matchesDate(tuesday) {
+ t.Fatal("day-of-month and day-of-week must OR when both are restricted")
+ }
+}
+
+// THE GRID IS THE RHYTHM. A run that started late, or a clock that was away,
+// never moves the next slot later: every slot is anchor + k×interval.
+func TestIntervalStaysOnItsGrid(t *testing.T) {
+ anchor := time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)
+ s := Schedule{Every: "15m", Anchor: anchor}
+ cases := []struct {
+ after time.Time
+ want time.Time
+ }{
+ {anchor.Add(-time.Hour), anchor},
+ {anchor, anchor.Add(15 * time.Minute)},
+ {anchor.Add(17 * time.Minute), anchor.Add(30 * time.Minute)},
+ {anchor.Add(30 * time.Minute), anchor.Add(45 * time.Minute)},
+ {anchor.Add(5*time.Hour + 2*time.Second), anchor.Add(5*time.Hour + 15*time.Minute)},
+ }
+ for _, c := range cases {
+ got, err := s.Next(c.after)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got.Equal(c.want) {
+ t.Fatalf("next(%v) = %v, want %v", c.after, got, c.want)
+ }
+ }
+}
+
+func TestFirstIsAfterNowForARhythmAndTheMomentForAOneOff(t *testing.T) {
+ now := time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)
+ every := Schedule{Every: "1h", Anchor: now}
+ if first, _ := every.First(now); !first.Equal(now.Add(time.Hour)) {
+ t.Fatalf("first = %v", first)
+ }
+ at := now.Add(3 * time.Hour)
+ once := Schedule{At: at}
+ if first, _ := once.First(now); !first.Equal(at) {
+ t.Fatalf("first = %v", first)
+ }
+ if next, _ := once.Next(at.Add(time.Minute)); !next.IsZero() {
+ t.Fatalf("a one-time schedule has no next, got %v", next)
+ }
+}
+
+func TestParseIntervalReadsDaysAndRefusesUnderAMinute(t *testing.T) {
+ if d, err := ParseInterval("2d"); err != nil || d != 48*time.Hour {
+ t.Fatalf("2d = %v, %v", d, err)
+ }
+ if d, err := ParseInterval("90m"); err != nil || d != 90*time.Minute {
+ t.Fatalf("90m = %v, %v", d, err)
+ }
+ for _, bad := range []string{"30s", "0d", "-1h", "soon", ""} {
+ if _, err := ParseInterval(bad); err == nil {
+ t.Fatalf("%q was accepted", bad)
+ }
+ }
+}
+
+func TestValidateRefusesWhatCouldNeverWake(t *testing.T) {
+ cases := map[string]Schedule{
+ "never happens": {Every: "0 0 30 2 *"},
+ "no moment": {},
+ "both": {At: time.Now(), Every: "1h"},
+ "unknown zone": {Every: "0 9 * * *", Zone: "Mars/Olympus"},
+ "bad field count": {Every: "0 9 * *"},
+ "backwards range": {Every: "0 17-9 * * *"},
+ }
+ for name, s := range cases {
+ if err := s.Validate(); err == nil {
+ t.Errorf("%s: accepted %+v", name, s)
+ }
+ }
+ if err := (Schedule{Every: "0 9 * * 1-5", Zone: "America/Toronto"}).Validate(); err != nil {
+ t.Fatalf("a weekday line was refused: %v", err)
+ }
+ if err := (Schedule{Every: "0 9 * * 1-5", Zone: "Mars/Olympus"}).Validate(); err == nil || !strings.Contains(err.Error(), "zone") {
+ t.Fatalf("an unknown zone should be named, got %v", err)
+ }
+}
+
+// A RHYTHM OF DAYS THAT WOULD OVERFLOW IS REFUSED, never wrapped round to a
+// negative one that wakes on every pass.
+func TestAnOverflowingRhythmIsRefused(t *testing.T) {
+ for _, spec := range []string{"107000d", "999999999999d"} {
+ if got, err := ParseInterval(spec); err == nil {
+ t.Fatalf("%s read as %v", spec, got)
+ }
+ if err := (Schedule{Every: spec, Zone: "UTC"}).Validate(); err == nil {
+ t.Fatalf("a schedule every %s validated", spec)
+ }
+ if got, err := parseDistance(spec); err == nil {
+ t.Fatalf("in %s read as %v", spec, got)
+ }
+ }
+ if got, err := ParseInterval("36500d"); err != nil || got <= 0 {
+ t.Fatalf("a hundred years reads as %v, %v", got, err)
+ }
+}
diff --git a/internal/automation/store.go b/internal/automation/store.go
new file mode 100644
index 0000000000..5b2b61c2bc
--- /dev/null
+++ b/internal/automation/store.go
@@ -0,0 +1,1024 @@
+package automation
+
+// store.go is the one place automations and their runs are kept: a small SQLite
+// database beside the presence folder and the clock's lock.
+//
+// IT IS A DATABASE AND NOT A FOLDER OF FILES, and that is the lesson of the
+// feature this replaces. Its state lived in one JSON file per item, rewritten by
+// whichever of several processes was looking, and most of its 6,000 lines were
+// fences against those writers disagreeing — revision guards, pending-delivery
+// markers, four schema barriers. Here a run is inserted and its slot advanced
+// in ONE transaction, a slot can be dispatched at most once because the table
+// says so, and every window reads the same rows.
+
+import (
+ "context"
+ "crypto/rand"
+ "database/sql"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+// applicationID marks the file as this store's, so a database belonging to
+// anything else is refused rather than read as empty. "AUTO".
+const applicationID = 0x4155544f
+
+// schemaVersion is the shape below. A newer file is refused, never rewritten.
+const schemaVersion = 1
+
+// busyTimeout bounds how long a write waits behind another process's. Every
+// write here is small, so ten seconds is a stuck peer, not a slow one.
+const busyTimeout = 10 * time.Second
+
+// ErrNotFound is an automation or a run that is not in the store.
+var ErrNotFound = errors.New("no such automation")
+
+// errChanged is a clock write refused because the person changed the
+// automation while the clock was holding an older copy. Their act is newer.
+var errChanged = errors.New("the automation changed while it was being run")
+
+// Store is the automations database.
+type Store struct {
+ db *sql.DB
+ root string
+ now func() time.Time
+}
+
+// DatabaseName is the file inside the root.
+const DatabaseName = "automations.db"
+
+// Open opens, or creates, the store under root (normally
+// ~/.codeaf/v3/automations).
+func Open(root string) (*Store, error) {
+ root = strings.TrimSpace(root)
+ if root == "" {
+ return nil, errors.New("automations: no folder")
+ }
+ absolute, err := filepath.Abs(root)
+ if err != nil {
+ return nil, err
+ }
+ if err := os.MkdirAll(absolute, 0o700); err != nil {
+ return nil, fmt.Errorf("automations: %w", err)
+ }
+ path := filepath.Join(absolute, DatabaseName)
+ // THE FILE IS MADE PRIVATE BEFORE SQLITE OPENS IT, so SQLite never creates
+ // it with its own default permissions.
+ if f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600); err != nil {
+ return nil, fmt.Errorf("automations: %w", err)
+ } else if err := f.Close(); err != nil {
+ return nil, fmt.Errorf("automations: %w", err)
+ }
+ u := url.URL{Scheme: "file", Path: path}
+ q := u.Query()
+ q.Set("mode", "rw")
+ q.Add("_pragma", fmt.Sprintf("busy_timeout(%d)", busyTimeout.Milliseconds()))
+ // WAL lets every window read while the clock writes; FULL keeps a run that
+ // was recorded recorded across a power cut.
+ q.Add("_pragma", "journal_mode(WAL)")
+ q.Add("_pragma", "synchronous(FULL)")
+ q.Set("_txlock", "immediate")
+ u.RawQuery = q.Encode()
+ db, err := sql.Open("sqlite", u.String())
+ if err != nil {
+ return nil, fmt.Errorf("automations: %w", err)
+ }
+ db.SetMaxOpenConns(1)
+ s := &Store{db: db, root: absolute, now: time.Now}
+ if err := s.ensureSchema(context.Background()); err != nil {
+ _ = db.Close()
+ return nil, err
+ }
+ return s, nil
+}
+
+// Root is the folder the store, the presence files and the clock lock live in.
+func (s *Store) Root() string { return s.root }
+
+// SetClock makes every write read its time from now instead of the wall clock:
+// when an automation was made or changed, and when a run was asked for, started
+// and ended. nil puts the wall clock back.
+//
+// IT IS FOR WRITING A HISTORY THAT ALREADY HAPPENED, AND NOTHING IN THE PRODUCT
+// CALLS IT. The clock process and every window read the wall clock. The one
+// caller is the demo home (cmd/codeaf-demo-home), which builds a fortnight of
+// runs out of this store's own doors — Create, Take, Start, Finish, SetStatus —
+// and has no other way to say that a run started on a Tuesday: Start and Finish
+// stamp the time themselves, and a fixture that wrote those columns with its
+// own SQL would be a second author of this table, which is the drift this store
+// exists to end. Take already takes its moment as an argument; this is the same
+// courtesy for the doors that do not.
+//
+// It is not safe while another goroutine is using the store. Call it between
+// writes, from the one goroutine that owns the store.
+func (s *Store) SetClock(now func() time.Time) {
+ if now == nil {
+ now = time.Now
+ }
+ s.now = now
+}
+
+// Close releases the database.
+func (s *Store) Close() error {
+ if s == nil || s.db == nil {
+ return nil
+ }
+ return s.db.Close()
+}
+
+func (s *Store) ensureSchema(ctx context.Context) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("automations: %w", err)
+ }
+ defer tx.Rollback()
+ var app, version int
+ if err := tx.QueryRowContext(ctx, "PRAGMA application_id").Scan(&app); err != nil {
+ return fmt.Errorf("automations: %w", err)
+ }
+ if err := tx.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
+ return fmt.Errorf("automations: %w", err)
+ }
+ switch {
+ case app == applicationID && version == schemaVersion:
+ return tx.Commit()
+ case app != 0 || version != 0:
+ return fmt.Errorf("automations: %s is not an automations database this build can read (application %d, version %d)", filepath.Join(s.root, DatabaseName), app, version)
+ }
+ var tables int
+ if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'").Scan(&tables); err != nil {
+ return fmt.Errorf("automations: %w", err)
+ }
+ if tables != 0 {
+ return fmt.Errorf("automations: %s belongs to something else", filepath.Join(s.root, DatabaseName))
+ }
+ _, err = tx.ExecContext(ctx, `
+CREATE TABLE counter (id INTEGER PRIMARY KEY CHECK (id = 1), seq INTEGER NOT NULL);
+INSERT INTO counter (id, seq) VALUES (1, 0);
+CREATE TABLE automations (
+ id TEXT PRIMARY KEY,
+ doc TEXT NOT NULL,
+ status TEXT NOT NULL,
+ next_ms INTEGER NOT NULL DEFAULT 0,
+ seen TEXT NOT NULL DEFAULT '',
+ memo TEXT NOT NULL DEFAULT '',
+ revision INTEGER NOT NULL DEFAULT 1,
+ seq INTEGER NOT NULL DEFAULT 0
+);
+CREATE INDEX automations_due ON automations(status, next_ms);
+CREATE TABLE runs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ automation_id TEXT NOT NULL,
+ due_ms INTEGER NOT NULL,
+ why TEXT NOT NULL,
+ phase TEXT NOT NULL,
+ started_ms INTEGER NOT NULL DEFAULT 0,
+ finished_ms INTEGER NOT NULL DEFAULT 0,
+ outcome TEXT NOT NULL DEFAULT '',
+ line TEXT NOT NULL DEFAULT '',
+ detail TEXT NOT NULL DEFAULT '',
+ usd REAL NOT NULL DEFAULT 0,
+ transcript TEXT NOT NULL DEFAULT '',
+ stop INTEGER NOT NULL DEFAULT 0,
+ told INTEGER NOT NULL DEFAULT 0,
+ seq INTEGER NOT NULL DEFAULT 0,
+ UNIQUE (automation_id, due_ms, why)
+);
+CREATE INDEX runs_by_automation ON runs(automation_id, id);
+CREATE INDEX runs_by_phase ON runs(phase);
+CREATE INDEX runs_by_seq ON runs(seq);
+`)
+ if err != nil {
+ return fmt.Errorf("automations: %w", err)
+ }
+ if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA application_id=%d; PRAGMA user_version=%d", applicationID, schemaVersion)); err != nil {
+ return fmt.Errorf("automations: %w", err)
+ }
+ return tx.Commit()
+}
+
+// ── automations ─────────────────────────────────────────────────────────────
+
+// Create saves a new automation the person agreed to, and works out when it
+// first wakes.
+func (s *Store) Create(a Automation) (Automation, error) {
+ now := s.now()
+ a.ID = strings.TrimSpace(a.ID)
+ if a.ID == "" {
+ a.ID = newID()
+ }
+ a.Status = StatusActive
+ a.Created, a.Updated = now, now
+ a.Revision = 1
+ a.Seen, a.Memo = "", ""
+ if a.Schedule.Repeats() && a.Schedule.Anchor.IsZero() && a.Schedule.Interval() > 0 {
+ a.Schedule.Anchor = now
+ }
+ // A FOLDER UNDER ~ IS THIS MACHINE'S ~, wherever the line was typed.
+ workspace, err := ExpandHome(a.Workspace)
+ if err != nil {
+ return Automation{}, err
+ }
+ a.Workspace = workspace
+ if err := a.Validate(); err != nil {
+ return Automation{}, err
+ }
+ first, err := a.Schedule.First(now)
+ if err != nil {
+ return Automation{}, err
+ }
+ a.Next = first
+ a.Last = nil
+ doc, err := json.Marshal(a)
+ if err != nil {
+ return Automation{}, err
+ }
+ err = s.write(func(tx *sql.Tx, seq int64) error {
+ _, err := tx.Exec(`INSERT INTO automations (id, doc, status, next_ms, seen, revision, seq) VALUES (?, ?, ?, ?, '', 1, ?)`,
+ a.ID, string(doc), string(a.Status), ms(a.Next), seq)
+ return err
+ })
+ if err != nil {
+ return Automation{}, err
+ }
+ return a, nil
+}
+
+// Get reads one automation, with the last run that ended.
+func (s *Store) Get(id string) (Automation, error) {
+ row := s.db.QueryRow(`SELECT doc, status, next_ms, seen, memo, revision FROM automations WHERE id = ?`, id)
+ a, err := scanAutomation(row)
+ if errors.Is(err, sql.ErrNoRows) {
+ return Automation{}, ErrNotFound
+ }
+ if err != nil {
+ return Automation{}, err
+ }
+ last, err := s.queryRuns(`WHERE automation_id = ? AND phase = ? ORDER BY id DESC LIMIT 1`, id, string(PhaseOver))
+ if err != nil {
+ return Automation{}, err
+ }
+ if len(last) == 1 {
+ a.Last = &last[0]
+ }
+ return a, nil
+}
+
+// List is every automation, the ones waiting on the person first, then the
+// soonest to wake, then the rest.
+func (s *Store) List() ([]Automation, error) {
+ rows, err := s.db.Query(`SELECT doc, status, next_ms, seen, memo, revision FROM automations`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var all []Automation
+ for rows.Next() {
+ a, err := scanAutomation(rows)
+ if err != nil {
+ return nil, err
+ }
+ all = append(all, a)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ // THE LAST RUN OF EVERY AUTOMATION IN ONE QUERY, not one per row: a list
+ // is read on every window's beat, and over --host every query is a trip.
+ last, err := s.queryRuns(`WHERE id IN (SELECT MAX(id) FROM runs WHERE phase = ? GROUP BY automation_id)`, string(PhaseOver))
+ if err != nil {
+ return nil, err
+ }
+ byAutomation := make(map[string]*Run, len(last))
+ for i := range last {
+ byAutomation[last[i].AutomationID] = &last[i]
+ }
+ for i := range all {
+ all[i].Last = byAutomation[all[i].ID]
+ }
+ sortAutomations(all)
+ return all, nil
+}
+
+// Update saves the person's change to an automation's own half — its title,
+// schedule, look, action, place and limits. Its status is left alone (that is
+// [Store.SetStatus]'s door). A changed schedule is worked out again from now,
+// and a finished one-time automation given a new moment wakes again. A watch
+// whose look or condition changed forgets what it had seen, so its first new
+// "yes" speaks.
+func (s *Store) Update(a Automation) (Automation, error) {
+ now := s.now()
+ var saved Automation
+ err := s.write(func(tx *sql.Tx, seq int64) error {
+ current, err := scanAutomation(tx.QueryRow(`SELECT doc, status, next_ms, seen, memo, revision FROM automations WHERE id = ?`, a.ID))
+ if errors.Is(err, sql.ErrNoRows) {
+ return ErrNotFound
+ }
+ if err != nil {
+ return err
+ }
+ a.Created, a.Updated = current.Created, now
+ a.Revision = current.Revision + 1
+ workspace, err := ExpandHome(a.Workspace)
+ if err != nil {
+ return err
+ }
+ a.Workspace = workspace
+ a.Status, a.Next, a.Seen, a.Memo = current.Status, current.Next, current.Seen, current.Memo
+ if lookChanged(current.Look, a.Look) {
+ a.Seen, a.Memo = "", ""
+ }
+ if scheduleChanged(current.Schedule, a.Schedule) {
+ if a.Schedule.Repeats() && a.Schedule.Interval() > 0 {
+ a.Schedule.Anchor = now
+ }
+ first, err := a.Schedule.First(now)
+ if err != nil {
+ return err
+ }
+ a.Next = first
+ if a.Status == StatusFinished {
+ a.Status = StatusActive
+ }
+ } else {
+ a.Schedule.Anchor = current.Schedule.Anchor
+ }
+ if err := a.Validate(); err != nil {
+ return err
+ }
+ a.Last = nil
+ doc, err := json.Marshal(a)
+ if err != nil {
+ return err
+ }
+ _, err = tx.Exec(`UPDATE automations SET doc = ?, status = ?, next_ms = ?, seen = ?, memo = ?, revision = ?, seq = ? WHERE id = ?`,
+ string(doc), string(a.Status), ms(a.Next), a.Seen, a.Memo, a.Revision, seq, a.ID)
+ saved = a
+ return err
+ })
+ return saved, err
+}
+
+// SetStatus pauses or resumes an automation, as the person's change. Resuming
+// a rhythm starts from its next slot after now: a pause is not a missed run, so
+// nothing catches up for the time it was paused. A one-time automation resumed
+// after its moment runs once, late.
+func (s *Store) SetStatus(id string, status Status) (Automation, error) {
+ if status != StatusActive && status != StatusPaused {
+ return Automation{}, fmt.Errorf("a person cannot set the status %q", status)
+ }
+ now := s.now()
+ var saved Automation
+ err := s.write(func(tx *sql.Tx, seq int64) error {
+ a, err := scanAutomation(tx.QueryRow(`SELECT doc, status, next_ms, seen, memo, revision FROM automations WHERE id = ?`, id))
+ if errors.Is(err, sql.ErrNoRows) {
+ return ErrNotFound
+ }
+ if err != nil {
+ return err
+ }
+ if a.Status == status || a.Status == StatusFinished {
+ saved = a
+ return nil
+ }
+ a.Status = status
+ if status == StatusActive {
+ if a.Schedule.Repeats() {
+ if a.Next, err = a.Schedule.Next(now); err != nil {
+ return err
+ }
+ } else {
+ a.Next = a.Schedule.At
+ }
+ }
+ a.Revision++
+ a.Updated = now
+ a.Last = nil
+ doc, err := json.Marshal(a)
+ if err != nil {
+ return err
+ }
+ _, err = tx.Exec(`UPDATE automations SET doc = ?, status = ?, next_ms = ?, revision = ?, seq = ? WHERE id = ?`,
+ string(doc), string(a.Status), ms(a.Next), a.Revision, seq, id)
+ saved = a
+ return err
+ })
+ return saved, err
+}
+
+// Delete removes an automation and its history: the rows, and every run's
+// folder that holds nothing but what the run said ([Store.forgetRunFolders]).
+func (s *Store) Delete(id string) error {
+ err := s.write(func(tx *sql.Tx, seq int64) error {
+ res, err := tx.Exec(`DELETE FROM automations WHERE id = ?`, id)
+ if err != nil {
+ return err
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ return ErrNotFound
+ }
+ _, err = tx.Exec(`DELETE FROM runs WHERE automation_id = ?`, id)
+ return err
+ })
+ if err != nil {
+ return err
+ }
+ s.forgetRunFolders(id, nil)
+ return nil
+}
+
+// KeepRuns is how many runs of one automation the store keeps: the history the
+// place draws, and no more. A watch on a fifteen-minute rhythm runs ninety-six
+// times a day, and a history nobody can scroll to is only disk.
+const KeepRuns = 100
+
+// prune forgets the runs of one automation past the newest [KeepRuns], and
+// their folders. It runs after a run is recorded, so a history never grows
+// past what the place can show.
+func (s *Store) prune(id string) {
+ var gone []int64
+ err := s.write(func(tx *sql.Tx, seq int64) error {
+ rows, err := tx.Query(`SELECT id FROM runs WHERE automation_id = ? AND phase = ? ORDER BY id DESC LIMIT -1 OFFSET ?`, id, string(PhaseOver), KeepRuns)
+ if err != nil {
+ return err
+ }
+ for rows.Next() {
+ var run int64
+ if err := rows.Scan(&run); err != nil {
+ rows.Close()
+ return err
+ }
+ gone = append(gone, run)
+ }
+ rows.Close()
+ if err := rows.Err(); err != nil || len(gone) == 0 {
+ return err
+ }
+ _, err = tx.Exec(`DELETE FROM runs WHERE automation_id = ? AND phase = ? AND id <= ?`, id, string(PhaseOver), gone[0])
+ return err
+ })
+ if err == nil && len(gone) > 0 {
+ s.forgetRunFolders(id, gone)
+ }
+}
+
+// runFolderTrees is the folder inside a run's own folder that holds the
+// separate worktree a run of work cut (internal/session's Place.Trees).
+const runFolderTrees = "trees"
+
+// forgetRunFolders removes the folders of one automation's runs — the named
+// runs, or with none named every run it has — under `/runs//`.
+//
+// A FOLDER HOLDING A WORKTREE IS KEPT. A run of work in a separate worktree
+// leaves its changes there for the person to review, committed or not, and
+// removing the checkout would lose them; such a folder stays until the person
+// lands or discards the work. Everything else in a run's folder is what the run
+// said, and goes with its row.
+func (s *Store) forgetRunFolders(id string, runs []int64) {
+ base := filepath.Join(s.root, "runs", id)
+ if strings.TrimSpace(id) == "" || strings.ContainsAny(id, `/\`) {
+ return
+ }
+ var dirs []string
+ if runs == nil {
+ entries, err := os.ReadDir(base)
+ if err != nil {
+ return
+ }
+ for _, entry := range entries {
+ if entry.IsDir() {
+ dirs = append(dirs, filepath.Join(base, entry.Name()))
+ }
+ }
+ } else {
+ for _, run := range runs {
+ dirs = append(dirs, filepath.Join(base, strconv.FormatInt(run, 10)))
+ }
+ }
+ for _, dir := range dirs {
+ if entries, err := os.ReadDir(filepath.Join(dir, runFolderTrees)); err == nil && len(entries) > 0 {
+ continue
+ }
+ _ = os.RemoveAll(dir)
+ }
+ _ = os.Remove(base) // only when nothing is left in it
+}
+
+// ── the clock's own writes ──────────────────────────────────────────────────
+//
+// THE CLOCK NEVER REWRITES THE DOCUMENT. Its copy of an automation can be
+// minutes old by the time a look or a piece of work ends, and writing that copy
+// back would put an older slot over the one [Store.Take] already advanced. So
+// the clock writes columns, by name, and only while the person's revision is
+// still the one it read.
+
+// SetSeen records a watch's newest decided judgment, and what its look keeps
+// for the next one.
+func (s *Store) SetSeen(id string, revision int64, seen, memo string) error {
+ return s.write(func(tx *sql.Tx, seq int64) error {
+ res, err := tx.Exec(`UPDATE automations SET seen = ?, memo = ?, seq = ? WHERE id = ? AND revision = ?`, seen, memo, seq, id, revision)
+ return changedUnless(res, err)
+ })
+}
+
+// FinishWatch ends a once-watch that has spoken.
+func (s *Store) FinishWatch(id string, revision int64) error {
+ return s.write(func(tx *sql.Tx, seq int64) error {
+ res, err := tx.Exec(`UPDATE automations SET status = ?, next_ms = 0, seq = ? WHERE id = ? AND revision = ?`,
+ string(StatusFinished), seq, id, revision)
+ return changedUnless(res, err)
+ })
+}
+
+// changedUnless turns a guarded update that matched no row into errChanged:
+// the person's revision moved, or the automation is gone.
+func changedUnless(res sql.Result, err error) error {
+ if err != nil {
+ return err
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ return errChanged
+ }
+ return nil
+}
+
+// ── runs ────────────────────────────────────────────────────────────────────
+
+// Due is every active automation whose slot has come by now.
+func (s *Store) Due(now time.Time) ([]Automation, error) {
+ rows, err := s.db.Query(`SELECT doc, status, next_ms, seen, memo, revision FROM automations WHERE status = ? AND next_ms > 0 AND next_ms <= ? ORDER BY next_ms`,
+ string(StatusActive), ms(now))
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var due []Automation
+ for rows.Next() {
+ a, err := scanAutomation(rows)
+ if err != nil {
+ return nil, err
+ }
+ due = append(due, a)
+ }
+ return due, rows.Err()
+}
+
+// NextWake is the earliest slot of any active automation, and zero when none.
+func (s *Store) NextWake() (time.Time, error) {
+ var next sql.NullInt64
+ err := s.db.QueryRow(`SELECT min(next_ms) FROM automations WHERE status = ? AND next_ms > 0`, string(StatusActive)).Scan(&next)
+ if err != nil || !next.Valid {
+ return time.Time{}, err
+ }
+ return fromMS(next.Int64), nil
+}
+
+// Take turns a due automation's slot into a queued run and moves the
+// automation on, in one transaction: its next slot after now, or — for a
+// one-time automation — finished. However many slots were missed, ONE run is
+// queued and it is marked late; the rest are not run, because a person coming
+// back after a weekend wants the weekly report once, not twice.
+//
+// The take is refused (errChanged) when the person changed the automation since
+// the clock read it, and the UNIQUE slot makes a second take of the same slot
+// impossible however the clock is interrupted.
+func (s *Store) Take(a Automation, now time.Time) (Run, error) {
+ why := WhyOnTime
+ if now.Sub(a.Next) > LateAfter {
+ why = WhyLate
+ }
+ run := Run{AutomationID: a.ID, Due: a.Next, Why: why, Phase: PhaseQueued}
+ next := time.Time{}
+ status := a.Status
+ if a.Schedule.Repeats() {
+ var err error
+ if next, err = a.Schedule.Next(now); err != nil {
+ return Run{}, err
+ }
+ } else {
+ status = StatusFinished
+ }
+ err := s.write(func(tx *sql.Tx, seq int64) error {
+ var revision, nextMS int64
+ var current string
+ if err := tx.QueryRow(`SELECT revision, next_ms, status FROM automations WHERE id = ?`, a.ID).Scan(&revision, &nextMS, ¤t); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return ErrNotFound
+ }
+ return err
+ }
+ if revision != a.Revision || nextMS != ms(a.Next) || current != string(StatusActive) {
+ return errChanged
+ }
+ res, err := tx.Exec(`INSERT INTO runs (automation_id, due_ms, why, phase, seq) VALUES (?, ?, ?, ?, ?)`,
+ a.ID, ms(run.Due), string(run.Why), string(PhaseQueued), seq)
+ if err != nil {
+ return err
+ }
+ run.ID, _ = res.LastInsertId()
+ _, err = tx.Exec(`UPDATE automations SET next_ms = ?, status = ?, seq = ? WHERE id = ?`,
+ ms(next), string(status), seq, a.ID)
+ return err
+ })
+ if err != nil {
+ return Run{}, err
+ }
+ return run, nil
+}
+
+// advance moves an automation past a slot without queuing a run for it, as the
+// clock's own guarded write.
+func (s *Store) advance(a Automation, next time.Time) error {
+ return s.write(func(tx *sql.Tx, seq int64) error {
+ res, err := tx.Exec(`UPDATE automations SET next_ms = ?, seq = ? WHERE id = ? AND revision = ? AND next_ms = ? AND status = ?`,
+ ms(next), seq, a.ID, a.Revision, ms(a.Next), string(StatusActive))
+ return changedUnless(res, err)
+ })
+}
+
+// QueueNow asks for a run outside the schedule. Any window may ask; the clock
+// takes it on its next look, within seconds.
+func (s *Store) QueueNow(id string) (Run, error) {
+ if _, err := s.Get(id); err != nil {
+ return Run{}, err
+ }
+ now := s.now()
+ run := Run{AutomationID: id, Due: now, Why: WhyNow, Phase: PhaseQueued}
+ err := s.write(func(tx *sql.Tx, seq int64) error {
+ var waiting int
+ if err := tx.QueryRow(`SELECT count(*) FROM runs WHERE automation_id = ? AND phase != ?`, id, string(PhaseOver)).Scan(&waiting); err != nil {
+ return err
+ }
+ if waiting > 0 {
+ return errors.New("it is already running")
+ }
+ res, err := tx.Exec(`INSERT INTO runs (automation_id, due_ms, why, phase, seq) VALUES (?, ?, ?, ?, ?)`,
+ id, ms(now), string(WhyNow), string(PhaseQueued), seq)
+ if err != nil {
+ return err
+ }
+ run.ID, _ = res.LastInsertId()
+ return nil
+ })
+ return run, err
+}
+
+// Queued is every run waiting for the clock, oldest first.
+func (s *Store) Queued() ([]Run, error) {
+ return s.queryRuns(`WHERE phase = ? ORDER BY id`, string(PhaseQueued))
+}
+
+// Active is every run queued or in hand.
+func (s *Store) Active() ([]Run, error) {
+ return s.queryRuns(`WHERE phase != ? ORDER BY id`, string(PhaseOver))
+}
+
+// Start marks a run as in hand.
+func (s *Store) Start(id int64) error {
+ return s.write(func(tx *sql.Tx, seq int64) error {
+ _, err := tx.Exec(`UPDATE runs SET phase = ?, started_ms = ?, seq = ? WHERE id = ?`,
+ string(PhaseRunning), ms(s.now()), seq, id)
+ return err
+ })
+}
+
+// Finish records what a run came to.
+//
+// A RUN CODEAF CLOSING CUT OFF IS PAUSED, NOT OVER ([Run.Closed]). In the same
+// write its automation is made due again at the moment it was cut off, so the
+// next clock takes it once when a window next opens — marked late, like a slot
+// missed while codeaf was shut, unless codeaf was back within [LateAfter].
+//
+// THE MOMENT IS A NEW SLOT AND NOT THE OLD ONE. A slot is taken once and the
+// store refuses a second take of it, so the run that picks up after a cut-off
+// one is a run of its own, and the history keeps both.
+func (s *Store) Finish(run Run) error {
+ err := s.write(func(tx *sql.Tx, seq int64) error {
+ _, err := tx.Exec(`UPDATE runs SET phase = ?, finished_ms = ?, outcome = ?, line = ?, detail = ?, usd = ?, transcript = ?, seq = ? WHERE id = ?`,
+ string(PhaseOver), ms(s.now()), string(run.Outcome), run.Line, run.Detail, run.USD, run.Transcript, seq, run.ID)
+ if err != nil || !run.Closed() {
+ return err
+ }
+ return dueAgain(tx, seq, run.AutomationID, s.now())
+ })
+ if err == nil {
+ s.prune(run.AutomationID)
+ }
+ return err
+}
+
+// dueAgain makes an automation due at the moment a run of it was cut off: a
+// one-time one that [Store.Take] marked finished wakes again, and an active
+// rhythm whose next slot is later is brought back to it. A paused one is left paused — resuming it is
+// the person's word — and a deleted one has nothing to wake.
+func dueAgain(tx *sql.Tx, seq int64, id string, at time.Time) error {
+ var status string
+ var next int64
+ err := tx.QueryRow(`SELECT status, next_ms FROM automations WHERE id = ?`, id).Scan(&status, &next)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if at.IsZero() {
+ return nil
+ }
+ switch Status(status) {
+ case StatusFinished:
+ status = string(StatusActive)
+ case StatusActive:
+ default:
+ return nil
+ }
+ if next != 0 && next <= ms(at) {
+ return nil
+ }
+ _, err = tx.Exec(`UPDATE automations SET status = ?, next_ms = ?, seq = ? WHERE id = ?`, status, ms(at), seq, id)
+ return err
+}
+
+// RequestStop asks the clock to stop a run. Any window may ask; the clock sees
+// it within seconds and the run ends as stopped.
+func (s *Store) RequestStop(id int64) error {
+ return s.write(func(tx *sql.Tx, seq int64) error {
+ _, err := tx.Exec(`UPDATE runs SET stop = 1, seq = ? WHERE id = ? AND phase != ?`, seq, id, string(PhaseOver))
+ return err
+ })
+}
+
+// StopRequested is the set of run ids somebody asked to stop.
+func (s *Store) StopRequested() (map[int64]bool, error) {
+ rows, err := s.db.Query(`SELECT id FROM runs WHERE stop = 1 AND phase != ?`, string(PhaseOver))
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ stops := map[int64]bool{}
+ for rows.Next() {
+ var id int64
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ stops[id] = true
+ }
+ return stops, rows.Err()
+}
+
+// Abandoned closes runs a previous clock left in hand. A clock is only ever
+// taken from a process that is gone — the lock dies with it — so a run still
+// "running" is one nobody is running; it is recorded as stopped, and when the
+// line says codeaf closed it, its automation is due again and runs once, late,
+// which is the rule for any run codeaf closing cut off ([Store.Finish]).
+func (s *Store) Abandoned(line string) (int, error) {
+ var closed int
+ err := s.write(func(tx *sql.Tx, seq int64) error {
+ // THE RUNS ARE READ BEFORE THEY ARE CLOSED, because each one closed with
+ // codeaf's own line makes its automation due again ([Store.Finish]).
+ rows, err := tx.Query(`SELECT automation_id FROM runs WHERE phase = ?`, string(PhaseRunning))
+ if err != nil {
+ return err
+ }
+ var cut []string
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ rows.Close()
+ return err
+ }
+ cut = append(cut, id)
+ }
+ rows.Close()
+ if err := rows.Err(); err != nil {
+ return err
+ }
+ res, err := tx.Exec(`UPDATE runs SET phase = ?, finished_ms = ?, outcome = ?, line = ?, seq = ? WHERE phase = ?`,
+ string(PhaseOver), ms(s.now()), string(OutcomeStopped), line, seq, string(PhaseRunning))
+ if err != nil {
+ return err
+ }
+ n, _ := res.RowsAffected()
+ closed = int(n)
+ if line != LineClosed {
+ return nil
+ }
+ // A GONE CLOCK SAYS NOTHING ABOUT WHEN IT WENT, so the runs it left are
+ // due from the hour before this one: late, as they are.
+ at := s.now().Add(-time.Hour)
+ for _, id := range cut {
+ if err := dueAgain(tx, seq, id, at); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+ return closed, err
+}
+
+// Runs is an automation's history, newest first, at most limit long.
+func (s *Store) Runs(id string, limit int) ([]Run, error) {
+ if limit <= 0 {
+ limit = 50
+ }
+ return s.queryRuns(`WHERE automation_id = ? ORDER BY id DESC LIMIT ?`, id, limit)
+}
+
+// Run reads one run.
+func (s *Store) Run(id int64) (Run, error) {
+ runs, err := s.queryRuns(`WHERE id = ?`, id)
+ if err != nil {
+ return Run{}, err
+ }
+ if len(runs) == 0 {
+ return Run{}, ErrNotFound
+ }
+ return runs[0], nil
+}
+
+// Changes is every run that changed after the cursor, in the order it changed,
+// and the cursor to ask from next time. A window keeps the cursor and reads
+// what is new; nobody has to be the process that ran it.
+//
+// THE COUNTER IS READ FIRST AND THE RUNS ARE READ UP TO IT. Every write stamps
+// its rows and moves the counter in one transaction, so every run at or below
+// the counter just read is already committed; read the other way round, a
+// write landing between the two reads would move the cursor past a run that
+// was never returned, and its news would be lost for good.
+func (s *Store) Changes(after int64) ([]Run, int64, error) {
+ var seq int64
+ if err := s.db.QueryRow(`SELECT seq FROM counter WHERE id = 1`).Scan(&seq); err != nil {
+ return nil, after, err
+ }
+ if seq <= after {
+ return nil, after, nil
+ }
+ runs, err := s.queryRuns(`WHERE seq > ? AND seq <= ? ORDER BY seq`, after, seq)
+ if err != nil {
+ return nil, after, err
+ }
+ return runs, seq, nil
+}
+
+// Claim reports whether the caller is the first to ask for a run — the one
+// window on the machine that raises its desktop notification, when several are
+// open and every one of them read the same news.
+//
+// IT MOVES NO CURSOR. The run's news has already been read by every window, and
+// a claim that stamped it again would hand it to all of them a second time.
+func (s *Store) Claim(id int64) (bool, error) {
+ res, err := s.db.Exec(`UPDATE runs SET told = 1 WHERE id = ? AND told = 0`, id)
+ if err != nil {
+ return false, err
+ }
+ n, err := res.RowsAffected()
+ return n == 1, err
+}
+
+// Cursor is the store's change counter now: a window that wants only what
+// happens from here on starts from it.
+func (s *Store) Cursor() (int64, error) {
+ var seq int64
+ err := s.db.QueryRow(`SELECT seq FROM counter WHERE id = 1`).Scan(&seq)
+ return seq, err
+}
+
+func (s *Store) queryRuns(where string, args ...any) ([]Run, error) {
+ rows, err := s.db.Query(`SELECT id, automation_id, due_ms, why, phase, started_ms, finished_ms, outcome, line, detail, usd, transcript FROM runs `+where, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var runs []Run
+ for rows.Next() {
+ var r Run
+ var due, started, finished int64
+ var why, phase, outcome string
+ if err := rows.Scan(&r.ID, &r.AutomationID, &due, &why, &phase, &started, &finished, &outcome, &r.Line, &r.Detail, &r.USD, &r.Transcript); err != nil {
+ return nil, err
+ }
+ r.Due, r.Started, r.Finished = fromMS(due), fromMS(started), fromMS(finished)
+ r.Why, r.Phase, r.Outcome = Why(why), Phase(phase), Outcome(outcome)
+ runs = append(runs, r)
+ }
+ return runs, rows.Err()
+}
+
+// write runs change inside one immediate transaction with a fresh value of the
+// change counter, which every row it touches is stamped with.
+func (s *Store) write(change func(tx *sql.Tx, seq int64) error) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return fmt.Errorf("automations: %w", err)
+ }
+ defer tx.Rollback()
+ var seq int64
+ if err := tx.QueryRow(`UPDATE counter SET seq = seq + 1 WHERE id = 1 RETURNING seq`).Scan(&seq); err != nil {
+ return fmt.Errorf("automations: %w", err)
+ }
+ if err := change(tx, seq); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+type scanner interface{ Scan(dest ...any) error }
+
+func scanAutomation(row scanner) (Automation, error) {
+ var doc, status, seen, memo string
+ var next, revision int64
+ if err := row.Scan(&doc, &status, &next, &seen, &memo, &revision); err != nil {
+ return Automation{}, err
+ }
+ var a Automation
+ if err := json.Unmarshal([]byte(doc), &a); err != nil {
+ return Automation{}, fmt.Errorf("automations: a damaged row: %w", err)
+ }
+ // THE COLUMNS ARE THE TRUTH FOR THE CLOCK'S STATE; the document carries the
+ // person's half and is rewritten with them, so it can only lag, never lead.
+ a.Status = Status(status)
+ a.Next = fromMS(next)
+ a.Seen = seen
+ a.Memo = memo
+ a.Revision = revision
+ a.Last = nil
+ return a, nil
+}
+
+func sortAutomations(all []Automation) {
+ rank := func(a Automation) int {
+ switch a.Status {
+ case StatusActive:
+ return 0
+ case StatusPaused:
+ return 1
+ }
+ return 2
+ }
+ for i := 1; i < len(all); i++ {
+ for j := i; j > 0; j-- {
+ a, b := all[j-1], all[j]
+ ra, rb := rank(a), rank(b)
+ swap := ra > rb
+ if ra == rb {
+ switch {
+ case a.Next.IsZero() && !b.Next.IsZero():
+ swap = true
+ case !a.Next.IsZero() && !b.Next.IsZero():
+ swap = a.Next.After(b.Next)
+ case a.Next.IsZero() && b.Next.IsZero():
+ swap = a.Created.Before(b.Created)
+ }
+ }
+ if !swap {
+ break
+ }
+ all[j-1], all[j] = b, a
+ }
+ }
+}
+
+func lookChanged(a, b *Look) bool {
+ if (a == nil) != (b == nil) {
+ return true
+ }
+ if a == nil {
+ return false
+ }
+ return a.Command != b.Command || a.Files != b.Files || a.Tool != b.Tool || string(a.Args) != string(b.Args) || a.Condition != b.Condition
+}
+
+func scheduleChanged(a, b Schedule) bool {
+ return !a.At.Equal(b.At) || strings.TrimSpace(a.Every) != strings.TrimSpace(b.Every) || a.Zone != b.Zone
+}
+
+func newID() string {
+ var raw [8]byte
+ if _, err := rand.Read(raw[:]); err != nil {
+ return fmt.Sprintf("%x", time.Now().UnixNano())
+ }
+ return hex.EncodeToString(raw[:])
+}
+
+func ms(t time.Time) int64 {
+ if t.IsZero() {
+ return 0
+ }
+ return t.UnixMilli()
+}
+
+func fromMS(v int64) time.Time {
+ if v == 0 {
+ return time.Time{}
+ }
+ return time.UnixMilli(v)
+}
diff --git a/internal/automation/store_test.go b/internal/automation/store_test.go
new file mode 100644
index 0000000000..07ee4c3805
--- /dev/null
+++ b/internal/automation/store_test.go
@@ -0,0 +1,541 @@
+package automation
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strconv"
+ "testing"
+ "time"
+)
+
+func openStore(t *testing.T) (*Store, *time.Time) {
+ t.Helper()
+ s, err := Open(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = s.Close() })
+ now := time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)
+ s.now = func() time.Time { return now }
+ return s, &now
+}
+
+func reminder(at time.Time) Automation {
+ return Automation{Title: "leave", Schedule: Schedule{At: at}, Action: Action{Say: "time to leave"}, Workspace: "/tmp/project"}
+}
+
+func routine(every string) Automation {
+ return Automation{Title: "weekly update", Schedule: Schedule{Every: every, Zone: "UTC"}, Action: Action{Do: "draft the weekly update"}, Workspace: "/tmp/project"}
+}
+
+func TestStoreRefusesAForeignDatabase(t *testing.T) {
+ root := t.TempDir()
+ s, err := Open(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.db.Exec(`PRAGMA application_id=1`); err != nil {
+ t.Fatal(err)
+ }
+ _ = s.Close()
+ if _, err := Open(root); err == nil {
+ t.Fatal("a database that is not ours was opened")
+ }
+}
+
+func TestCreateWorksOutTheFirstWake(t *testing.T) {
+ s, now := openStore(t)
+ at := now.Add(6 * time.Hour)
+ r, err := s.Create(reminder(at))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !r.Next.Equal(at) || r.Status != StatusActive || r.Revision != 1 {
+ t.Fatalf("created %+v", r)
+ }
+ w, err := s.Create(routine("15m"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := now.Add(15 * time.Minute); !w.Next.Equal(want) {
+ t.Fatalf("a 15m rhythm first wakes %v, want %v", w.Next, want)
+ }
+ got, err := s.Get(w.ID)
+ if err != nil || got.Title != "weekly update" || !got.Schedule.Anchor.Equal(*now) {
+ t.Fatalf("read back %+v, %v", got, err)
+ }
+}
+
+// ONE SLOT, ONE RUN, HOWEVER MANY WERE MISSED. A routine due every fifteen
+// minutes that was away for a day catches up once, marked late, and its next
+// slot is the next one after now.
+func TestTakeCatchesUpOnceAndMarksItLate(t *testing.T) {
+ s, now := openStore(t)
+ a, err := s.Create(routine("15m"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ *now = now.Add(24 * time.Hour)
+ due, err := s.Due(*now)
+ if err != nil || len(due) != 1 {
+ t.Fatalf("due = %v, %v", due, err)
+ }
+ run, err := s.Take(due[0], *now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if run.Why != WhyLate || !run.Due.Equal(a.Next) {
+ t.Fatalf("run %+v", run)
+ }
+ after, _ := s.Get(a.ID)
+ if !after.Next.After(*now) || after.Next.Sub(*now) > 15*time.Minute {
+ t.Fatalf("next %v is not the next slot after %v", after.Next, *now)
+ }
+ if due, _ := s.Due(*now); len(due) != 0 {
+ t.Fatalf("still due after the take: %v", due)
+ }
+ // The same slot cannot be taken twice: a is the copy read before the take,
+ // and its slot has moved on.
+ if _, err := s.Take(a, *now); err == nil {
+ t.Fatal("a slot was taken twice")
+ }
+}
+
+// THE PERSON'S CHANGE BEATS THE CLOCK'S STALE COPY. A pause made after the clock
+// read the automation refuses the clock's take.
+func TestTakeIsRefusedWhenThePersonChangedIt(t *testing.T) {
+ s, now := openStore(t)
+ a, err := s.Create(routine("1h"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ *now = now.Add(2 * time.Hour)
+ due, _ := s.Due(*now)
+ if _, err := s.SetStatus(a.ID, StatusPaused); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Take(due[0], *now); !errors.Is(err, errChanged) {
+ t.Fatalf("take after a pause = %v, want errChanged", err)
+ }
+}
+
+// A ONE-TIME AUTOMATION FINISHES WHEN TAKEN, and a pause is not a missed run:
+// a rhythm resumed after a week starts from its next slot, with nothing queued
+// for the week.
+func TestOneTimeFinishesAndResumeDoesNotCatchUpAPause(t *testing.T) {
+ s, now := openStore(t)
+ r, err := s.Create(reminder(now.Add(time.Hour)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ *now = now.Add(2 * time.Hour)
+ due, _ := s.Due(*now)
+ if _, err := s.Take(due[0], *now); err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := s.Get(r.ID); got.Status != StatusFinished || !got.Next.IsZero() {
+ t.Fatalf("a taken reminder is %+v", got)
+ }
+
+ w, _ := s.Create(routine("1h"))
+ if _, err := s.SetStatus(w.ID, StatusPaused); err != nil {
+ t.Fatal(err)
+ }
+ *now = now.Add(7 * 24 * time.Hour)
+ resumed, err := s.SetStatus(w.ID, StatusActive)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !resumed.Next.After(*now) {
+ t.Fatalf("a resumed rhythm wakes at %v, before now %v", resumed.Next, *now)
+ }
+ if due, _ := s.Due(*now); len(due) != 0 {
+ t.Fatalf("a resumed rhythm caught up its pause: %v", due)
+ }
+}
+
+func TestUpdateKeepsStateAndRestartsAChangedSchedule(t *testing.T) {
+ s, now := openStore(t)
+ a, err := s.Create(Automation{
+ Title: "ci", Schedule: Schedule{Every: "5m"}, Workspace: "/tmp/p",
+ Look: &Look{Command: "gh run list -L1", Condition: "the latest run failed"},
+ Action: Action{Say: "CI is red"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SetSeen(a.ID, a.Revision, "yes", "listing"); err != nil {
+ t.Fatal(err)
+ }
+ a.Title = "CI on main"
+ edited, err := s.Update(a)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if edited.Seen != "yes" || edited.Memo != "listing" || edited.Revision != 2 || !edited.Next.Equal(a.Next) {
+ t.Fatalf("a title edit disturbed state: %+v", edited)
+ }
+ *now = now.Add(time.Minute)
+ edited.Look.Condition = "any run failed"
+ edited.Schedule.Every = "10m"
+ again, err := s.Update(edited)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if again.Seen != "" || again.Memo != "" {
+ t.Fatal("a changed condition kept what the old one had seen")
+ }
+ if want := now.Add(10 * time.Minute); !again.Next.Equal(want) {
+ t.Fatalf("a changed rhythm wakes at %v, want %v", again.Next, want)
+ }
+ // The clock's write with the old revision is refused.
+ if err := s.SetSeen(a.ID, a.Revision, "no", ""); !errors.Is(err, errChanged) {
+ t.Fatalf("a stale clock write = %v", err)
+ }
+}
+
+func TestRunsRecordAndChangesReachEveryReader(t *testing.T) {
+ s, _ := openStore(t)
+ a, _ := s.Create(routine("1h"))
+ cursor, err := s.Cursor()
+ if err != nil {
+ t.Fatal(err)
+ }
+ run, err := s.QueueNow(a.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.QueueNow(a.ID); err == nil {
+ t.Fatal("a second run was queued while one was waiting")
+ }
+ if err := s.Start(run.ID); err != nil {
+ t.Fatal(err)
+ }
+ run.Outcome, run.Line, run.USD = OutcomeDone, "drafted it", 0.12
+ if err := s.Finish(run); err != nil {
+ t.Fatal(err)
+ }
+ changed, next, err := s.Changes(cursor)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(changed) != 1 || changed[0].Outcome != OutcomeDone || changed[0].Phase != PhaseOver || next <= cursor {
+ t.Fatalf("changes = %+v, cursor %d → %d", changed, cursor, next)
+ }
+ if more, _, _ := s.Changes(next); len(more) != 0 {
+ t.Fatalf("nothing changed, yet %v", more)
+ }
+ history, _ := s.Runs(a.ID, 10)
+ if len(history) != 1 || history[0].Line != "drafted it" || history[0].USD != 0.12 {
+ t.Fatalf("history %+v", history)
+ }
+ // AND THE LIST SAYS WHAT EACH ONE LAST CAME TO, read from the runs and
+ // never kept in the document.
+ listed, _ := s.List()
+ if len(listed) != 1 || listed[0].Last == nil || listed[0].Last.Line != "drafted it" {
+ t.Fatalf("the list's last run = %+v", listed)
+ }
+ got, _ := s.Get(a.ID)
+ if got.Last == nil || got.Last.ID != run.ID {
+ t.Fatalf("get's last run = %+v", got.Last)
+ }
+ saved, err := s.Update(got)
+ if err != nil || saved.Last != nil {
+ t.Fatalf("an update kept a last run in the document: %+v, %v", saved.Last, err)
+ }
+}
+
+// ONE WINDOW TELLS THE PERSON. Every open window reads the same finished run;
+// the first to claim it raises the notification, and claiming it moves nothing
+// any window reads, so no window draws the run's line twice.
+func TestOneClaimPerRunAndTheClaimIsNotNews(t *testing.T) {
+ s, _ := openStore(t)
+ a, _ := s.Create(routine("1h"))
+ run, _ := s.QueueNow(a.ID)
+ _ = s.Start(run.ID)
+ run.Outcome = OutcomeDone
+ _ = s.Finish(run)
+ _, cursor, err := s.Changes(0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first, err := s.Claim(run.ID); err != nil || !first {
+ t.Fatalf("the first claim = %v, %v", first, err)
+ }
+ if again, err := s.Claim(run.ID); err != nil || again {
+ t.Fatalf("a second window claimed the same run: %v, %v", again, err)
+ }
+ if more, next, _ := s.Changes(cursor); len(more) != 0 || next != cursor {
+ t.Fatalf("the claim read as news: %v, cursor %d → %d", more, cursor, next)
+ }
+}
+
+// A HISTORY THAT ALREADY HAPPENED IS WRITTEN THROUGH THE STORE'S OWN DOORS.
+// SetClock is the one thing a fixture adds: every stamp a write makes reads it,
+// so a run taken three hours after its slot reads late by exactly that, and nil
+// hands every later write back to the wall clock.
+func TestSetClockStampsEveryWrite(t *testing.T) {
+ s, err := Open(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = s.Close() })
+ clock := time.Date(2026, 9, 28, 10, 12, 0, 0, time.UTC)
+ s.SetClock(func() time.Time { return clock })
+
+ a, err := s.Create(routine("1h"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !a.Created.Equal(clock) || !a.Schedule.Anchor.Equal(clock) || !a.Next.Equal(clock.Add(time.Hour)) {
+ t.Fatalf("made at %v: created %v, anchor %v, next %v", clock, a.Created, a.Schedule.Anchor, a.Next)
+ }
+
+ slot := a.Next
+ clock = slot.Add(3 * time.Hour)
+ started := clock
+ run, err := s.Take(a, started)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Start(run.ID); err != nil {
+ t.Fatal(err)
+ }
+ clock = started.Add(4 * time.Minute)
+ run.Outcome, run.Line = OutcomeDone, "drafted it"
+ if err := s.Finish(run); err != nil {
+ t.Fatal(err)
+ }
+ got, err := s.Run(run.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got.Due.Equal(slot) || !got.Started.Equal(started) || !got.Finished.Equal(clock) || got.Late() != 3*time.Hour {
+ t.Fatalf("the run reads due %v, started %v, finished %v, late %v", got.Due, got.Started, got.Finished, got.Late())
+ }
+
+ clock = clock.Add(time.Hour)
+ paused, err := s.SetStatus(a.ID, StatusPaused)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !paused.Updated.Equal(clock) {
+ t.Fatalf("paused at %v, the change reads %v", clock, paused.Updated)
+ }
+
+ s.SetClock(nil)
+ before := time.Now().Add(-time.Second)
+ asked, err := s.QueueNow(a.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if asked.Due.Before(before) {
+ t.Fatalf("with the wall clock back, a run asked for now is due at %v", asked.Due)
+ }
+}
+
+func TestAbandonedClosesWhatAGoneClockLeftRunning(t *testing.T) {
+ s, _ := openStore(t)
+ a, _ := s.Create(routine("1h"))
+ run, _ := s.QueueNow(a.ID)
+ _ = s.Start(run.ID)
+ n, err := s.Abandoned(LineClosed)
+ if err != nil || n != 1 {
+ t.Fatalf("abandoned %d, %v", n, err)
+ }
+ got, _ := s.Run(run.ID)
+ if got.Phase != PhaseOver || got.Outcome != OutcomeStopped || got.Line != LineClosed {
+ t.Fatalf("left %+v", got)
+ }
+}
+
+func TestDeleteTakesTheHistoryWithIt(t *testing.T) {
+ s, _ := openStore(t)
+ a, _ := s.Create(routine("1h"))
+ _, _ = s.QueueNow(a.ID)
+ if err := s.Delete(a.ID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Get(a.ID); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("get after delete = %v", err)
+ }
+ if runs, _ := s.Runs(a.ID, 10); len(runs) != 0 {
+ t.Fatalf("history outlived its automation: %v", runs)
+ }
+ if err := s.Delete(a.ID); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("second delete = %v", err)
+ }
+}
+
+func TestValidateRefusesWhatCannotRun(t *testing.T) {
+ cases := map[string]Automation{
+ "no title": {Schedule: Schedule{Every: "1h"}, Action: Action{Say: "x"}, Workspace: "/p"},
+ "no action": {Title: "t", Schedule: Schedule{Every: "1h"}, Workspace: "/p"},
+ "both actions": {Title: "t", Schedule: Schedule{Every: "1h"}, Action: Action{Say: "x", Do: "y"}, Workspace: "/p"},
+ "say in worktree": {Title: "t", Schedule: Schedule{Every: "1h"}, Action: Action{Say: "x"}, Workspace: "/p", Worktree: true},
+ "watch, no rhythm": {Title: "t", Schedule: Schedule{At: time.Now().Add(time.Hour)}, Look: &Look{Command: "x", Condition: "y"}, Action: Action{Say: "x"}, Workspace: "/p"},
+ "watch, two looks": {Title: "t", Schedule: Schedule{Every: "5m"}, Look: &Look{Command: "x", Files: "*.go", Condition: "y"}, Action: Action{Say: "x"}, Workspace: "/p"},
+ "watch, no cond": {Title: "t", Schedule: Schedule{Every: "5m"}, Look: &Look{Command: "x"}, Action: Action{Say: "x"}, Workspace: "/p"},
+ "no workspace": {Title: "t", Schedule: Schedule{Every: "1h"}, Action: Action{Say: "x"}},
+ }
+ for name, a := range cases {
+ if err := a.Validate(); err == nil {
+ t.Errorf("%s: accepted", name)
+ }
+ }
+}
+
+// A RUN CODEAF CLOSING CUT OFF IS PAUSED, NOT OVER. A one-time automation the
+// take marked finished wakes again at its own slot, and a rhythm whose next slot
+// had moved on comes back to the one that was cut off, so the next clock runs
+// it once, late. A run the person stopped is over, and wakes nothing.
+func TestACutOffRunIsDueAgain(t *testing.T) {
+ s, now := openStore(t)
+ once, _ := s.Create(reminder(now.Add(time.Minute)))
+ rhythm, _ := s.Create(routine("1h"))
+ *now = now.Add(2 * time.Hour)
+ due, _ := s.Due(*now)
+ if len(due) != 2 {
+ t.Fatalf("due = %v", due)
+ }
+ var runs []Run
+ for _, a := range due {
+ run, err := s.Take(a, *now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = s.Start(run.ID)
+ runs = append(runs, run)
+ }
+ if got, _ := s.Get(once.ID); got.Status != StatusFinished {
+ t.Fatalf("the take left the one-time automation %s", got.Status)
+ }
+ for _, run := range runs {
+ run.Outcome, run.Line = OutcomeStopped, LineClosed
+ if !run.Closed() {
+ t.Fatal("a run codeaf closing ended does not read as closed")
+ }
+ if err := s.Finish(run); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, id := range []string{once.ID, rhythm.ID} {
+ got, _ := s.Get(id)
+ if got.Status != StatusActive || got.Next.After(*now) {
+ t.Fatalf("%s after its run was cut off: %s, next %v (now %v)", got.Title, got.Status, got.Next, *now)
+ }
+ }
+ // CODEAF IS BACK AN HOUR LATER, and the run that picks up is late.
+ *now = now.Add(time.Hour)
+ again, _ := s.Due(*now)
+ if len(again) != 2 {
+ t.Fatalf("due again = %v", again)
+ }
+ run, err := s.Take(again[0], *now)
+ if err != nil || run.Why != WhyLate {
+ t.Fatalf("the run after the cut-off one is %+v, %v", run, err)
+ }
+ // A STOP THE PERSON ASKED FOR IS NOT A PAUSE.
+ stopped, _ := s.Create(reminder(now.Add(time.Minute)))
+ *now = now.Add(time.Hour)
+ for _, a := range mustDue(t, s, *now) {
+ if a.ID != stopped.ID {
+ continue
+ }
+ run, _ := s.Take(a, *now)
+ run.Outcome, run.Line = OutcomeStopped, "stopped by you"
+ _ = s.Finish(run)
+ }
+ if got, _ := s.Get(stopped.ID); got.Status != StatusFinished {
+ t.Fatalf("a run the person stopped woke its automation: %s", got.Status)
+ }
+}
+
+// AND A RUN A GONE CLOCK LEFT IN HAND IS DUE AGAIN THE SAME WAY.
+func TestAbandonedRunsAreDueAgain(t *testing.T) {
+ s, now := openStore(t)
+ a, _ := s.Create(reminder(now.Add(time.Minute)))
+ *now = now.Add(time.Hour)
+ due, _ := s.Due(*now)
+ run, err := s.Take(due[0], *now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = s.Start(run.ID)
+ if n, err := s.Abandoned(LineClosed); err != nil || n != 1 {
+ t.Fatalf("abandoned %d, %v", n, err)
+ }
+ got, _ := s.Get(a.ID)
+ if got.Status != StatusActive || got.Next.After(*now) {
+ t.Fatalf("after a gone clock: %s next %v, want active and due by %v", got.Status, got.Next, *now)
+ }
+ again, err := s.Take(got, *now)
+ if err != nil || again.Why != WhyLate {
+ t.Fatalf("the run after a gone clock is %+v, %v", again, err)
+ }
+}
+
+func mustDue(t *testing.T, s *Store, now time.Time) []Automation {
+ t.Helper()
+ due, err := s.Due(now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return due
+}
+
+// A HISTORY KEEPS ITS NEWEST HUNDRED RUNS, and the folders of the runs it
+// forgets go with them — except a folder holding a worktree, whose changes are
+// the person's to review. Deleting the automation forgets the rest the same way.
+func TestAHistoryKeepsItsNewestRunsAndTheirFolders(t *testing.T) {
+ s, now := openStore(t)
+ a, _ := s.Create(routine("1h"))
+ folder := func(run int64) string {
+ return filepath.Join(s.Root(), "runs", a.ID, strconv.FormatInt(run, 10))
+ }
+ var first, kept Run
+ for i := 0; i < KeepRuns+5; i++ {
+ *now = now.Add(time.Minute)
+ run, err := s.QueueNow(a.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(folder(run.ID), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ switch i {
+ case 0:
+ first = run
+ case 1:
+ kept = run
+ if err := os.MkdirAll(filepath.Join(folder(run.ID), "trees", "automation-x"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ }
+ _ = s.Start(run.ID)
+ run.Outcome, run.Line = OutcomeDone, "done"
+ if err := s.Finish(run); err != nil {
+ t.Fatal(err)
+ }
+ }
+ runs, _ := s.Runs(a.ID, KeepRuns*2)
+ if len(runs) != KeepRuns {
+ t.Fatalf("the history keeps %d runs, want %d", len(runs), KeepRuns)
+ }
+ if _, err := os.Stat(folder(first.ID)); !os.IsNotExist(err) {
+ t.Fatalf("a forgotten run's folder is still there: %v", err)
+ }
+ if _, err := os.Stat(folder(kept.ID)); err != nil {
+ t.Fatalf("a forgotten run's worktree was removed: %v", err)
+ }
+ if err := s.Delete(a.ID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(folder(runs[0].ID)); !os.IsNotExist(err) {
+ t.Fatalf("deleting the automation left a run's folder: %v", err)
+ }
+ if _, err := os.Stat(folder(kept.ID)); err != nil {
+ t.Fatalf("deleting the automation removed a worktree: %v", err)
+ }
+}
diff --git a/internal/automation/tidy.go b/internal/automation/tidy.go
new file mode 100644
index 0000000000..f517765f52
--- /dev/null
+++ b/internal/automation/tidy.go
@@ -0,0 +1,63 @@
+package automation
+
+// tidy.go is the vocabulary of the pass over what codeaf remembers that merges
+// duplicates and retires lines a newer one replaced (internal/session's
+// memory_consolidate.go).
+//
+// THE CLOCK NO LONGER RUNS IT. An automatic change to what is remembered that
+// nobody sees is what the memory overhaul rules out, so the clock stopped
+// asking for the pass on 2026-10-14; these types stay only until the session's
+// half of the pass is deleted with that overhaul, and go with it.
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Idle answers whether the machine has been quiet for at least the given span:
+// nothing working right now, and the newest thing anybody said older than it.
+// The session supplies it from its reading of every conversation.
+type Idle func(quiet time.Duration) bool
+
+// Tidied is what one pass over what is remembered came to: how many lines were
+// merged into one clearer line, how many were retired in favour of one that
+// replaced them, and what the single call cost.
+type Tidied struct {
+ Merged int
+ Superseded int
+ USD float64
+}
+
+// Changed is how many remembered lines the pass actually moved. Zero is a pass
+// that read fifty lines and decided every one of them was already right, which
+// is the ordinary answer.
+func (t Tidied) Changed() int { return t.Merged + t.Superseded }
+
+// Line is the pass's own account of a tidy, THE EMPTINESS LAW APPLIED: a part
+// that is zero is absent rather than printed as a zero, and a pass that changed
+// nothing and spent nothing is no line at all.
+func (t Tidied) Line() string {
+ parts := make([]string, 0, 3)
+ if t.Merged > 0 {
+ parts = append(parts, strconv.Itoa(t.Merged)+" merged")
+ }
+ if t.Superseded > 0 {
+ parts = append(parts, strconv.Itoa(t.Superseded)+" superseded")
+ }
+ if t.USD > 0 {
+ parts = append(parts, fmt.Sprintf("$%.3f", t.USD))
+ }
+ if len(parts) == 0 {
+ return ""
+ }
+ return "consolidated · " + strings.Join(parts, " · ")
+}
+
+// Tidy is the pass itself. A NIL Tidy is memory off on this machine — a
+// capability that cannot work is absent rather than present and refusing — and
+// the pass keeps its own gates (how quiet the machine must be, how long since
+// the last pass), so the clock may ask it often and it answers cheaply.
+type Tidy func(ctx context.Context) (Tidied, error)
diff --git a/internal/automation/tidy_test.go b/internal/automation/tidy_test.go
new file mode 100644
index 0000000000..945e2d4b4d
--- /dev/null
+++ b/internal/automation/tidy_test.go
@@ -0,0 +1,24 @@
+package automation
+
+import "testing"
+
+// A TIDY THAT CHANGED NOTHING AND SPENT NOTHING IS NO LINE AT ALL, and every
+// part that is zero is absent rather than printed as a zero.
+func TestATidysLineSaysOnlyWhatHappened(t *testing.T) {
+ for _, c := range []struct {
+ tidied Tidied
+ want string
+ }{
+ {Tidied{}, ""},
+ {Tidied{Merged: 2}, "consolidated · 2 merged"},
+ {Tidied{Superseded: 1, USD: 0.0123}, "consolidated · 1 superseded · $0.012"},
+ {Tidied{Merged: 1, Superseded: 2, USD: 0.5}, "consolidated · 1 merged · 2 superseded · $0.500"},
+ } {
+ if got := c.tidied.Line(); got != c.want {
+ t.Fatalf("%+v says %q, want %q", c.tidied, got, c.want)
+ }
+ }
+ if (Tidied{Merged: 2, Superseded: 3}).Changed() != 5 {
+ t.Fatal("changed does not count both kinds of move")
+ }
+}
diff --git a/internal/automation/words.go b/internal/automation/words.go
new file mode 100644
index 0000000000..ef2ac2aee2
--- /dev/null
+++ b/internal/automation/words.go
@@ -0,0 +1,199 @@
+package automation
+
+// words.go says a schedule the way a person would, and exactly. Every surface
+// that shows an automation — the card, the list, the tool's own answers — reads
+// these, so "every Monday at 09:00" is spelled one way everywhere.
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Describe is the schedule in a person's words: "every Monday at 09:00",
+// "every 15 minutes", "today at 18:00". A cron line with no plain reading is
+// said as itself.
+func Describe(s Schedule, now time.Time) string {
+ if !s.Repeats() {
+ if s.At.IsZero() {
+ return ""
+ }
+ return Moment(s.At, now)
+ }
+ rhythm, err := parseRhythm(s.Every)
+ if err != nil {
+ return strings.TrimSpace(s.Every)
+ }
+ if rhythm.cron == nil {
+ return "every " + intervalWords(rhythm.interval)
+ }
+ return cronWords(*rhythm.cron, strings.TrimSpace(s.Every))
+}
+
+// Exact is the schedule exactly: the moment with its zone, or the rhythm's own
+// spelling with the zone a cron line is read in.
+func Exact(s Schedule) string {
+ if !s.Repeats() {
+ if s.At.IsZero() {
+ return ""
+ }
+ return s.At.Format("2006-01-02 15:04 MST")
+ }
+ every := strings.TrimSpace(s.Every)
+ if !strings.ContainsAny(every, " \t") {
+ return "every " + every
+ }
+ zone := strings.TrimSpace(s.Zone)
+ if zone == "" {
+ zone = "this machine's zone"
+ }
+ return "cron " + every + " · " + zone
+}
+
+// Moment is one moment said from now: "today at 18:00", "tomorrow at 09:00",
+// "Mon 13 Oct at 09:00", with the year only when it is not this one.
+func Moment(t, now time.Time) string {
+ t = t.In(now.Location())
+ clock := t.Format("15:04")
+ y1, m1, d1 := now.Date()
+ y2, m2, d2 := t.Date()
+ today := time.Date(y1, m1, d1, 0, 0, 0, 0, now.Location())
+ day := time.Date(y2, m2, d2, 0, 0, 0, 0, now.Location())
+ switch days := int(day.Sub(today).Round(time.Hour) / (24 * time.Hour)); {
+ case days == 0:
+ return "today at " + clock
+ case days == 1:
+ return "tomorrow at " + clock
+ case days == -1:
+ return "yesterday at " + clock
+ case y1 == y2:
+ return t.Format("Mon 2 Jan") + " at " + clock
+ }
+ return t.Format("Mon 2 Jan 2006") + " at " + clock
+}
+
+func intervalWords(d time.Duration) string {
+ switch {
+ case d%(24*time.Hour) == 0:
+ return countWords(int(d/(24*time.Hour)), "day")
+ case d%time.Hour == 0:
+ return countWords(int(d/time.Hour), "hour")
+ case d%time.Minute == 0:
+ return countWords(int(d/time.Minute), "minute")
+ }
+ return d.String()
+}
+
+func countWords(n int, unit string) string {
+ if n == 1 {
+ return unit
+ }
+ return strconv.Itoa(n) + " " + unit + "s"
+}
+
+var weekdayNames = []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
+
+// cronWords reads the common shapes of a cron line as a sentence, and says the
+// line itself for anything else.
+func cronWords(line cronLine, spec string) string {
+ minute, minuteOK := single(line.minute)
+ hour, hourOK := single(line.hour)
+ everyDay := line.dom.star && line.month.star && line.dow.star
+ switch {
+ case minuteOK && hourOK && everyDay:
+ return fmt.Sprintf("every day at %02d:%02d", hour, minute)
+ case minuteOK && hourOK && line.dom.star && line.month.star:
+ if days := dayWords(line.dow); days != "" {
+ return fmt.Sprintf("%s at %02d:%02d", days, hour, minute)
+ }
+ case minuteOK && hourOK && line.dow.star && line.month.star:
+ if day, ok := single(line.dom); ok {
+ return fmt.Sprintf("on the %s of every month at %02d:%02d", ordinal(day), hour, minute)
+ }
+ case minuteOK && line.hour.star && everyDay:
+ return fmt.Sprintf("every hour at :%02d", minute)
+ }
+ if step, ok := stepOf(line.minute, 60); ok && line.hour.star && everyDay {
+ return "every " + countWords(step, "minute")
+ }
+ return "on the cron line " + spec
+}
+
+// dayWords says a day-of-week field: "every Monday", "every weekday",
+// "every Monday and Thursday".
+func dayWords(field cronField) string {
+ if field.star {
+ return ""
+ }
+ var days []int
+ for day := 0; day <= 6; day++ {
+ if field.allowed[day] {
+ days = append(days, day)
+ }
+ }
+ if len(days) == 5 && days[0] == 1 && days[4] == 5 {
+ return "every weekday"
+ }
+ if len(days) == 2 && days[0] == 0 && days[1] == 6 {
+ return "every weekend day"
+ }
+ names := make([]string, len(days))
+ for i, day := range days {
+ names[i] = weekdayNames[day]
+ }
+ switch len(names) {
+ case 0:
+ return ""
+ case 1:
+ return "every " + names[0]
+ }
+ return "every " + strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1]
+}
+
+func single(field cronField) (int, bool) {
+ if field.star || len(field.allowed) != 1 {
+ return 0, false
+ }
+ for value := range field.allowed {
+ return value, true
+ }
+ return 0, false
+}
+
+// stepOf reports a field that is exactly 0, n, 2n … within its range.
+func stepOf(field cronField, span int) (int, bool) {
+ if field.star || len(field.allowed) < 2 || !field.allowed[0] {
+ return 0, false
+ }
+ step := 0
+ for value := 1; value < span; value++ {
+ if field.allowed[value] {
+ step = value
+ break
+ }
+ }
+ if step == 0 {
+ return 0, false
+ }
+ for value := 0; value < span; value++ {
+ if field.allowed[value] != (value%step == 0) {
+ return 0, false
+ }
+ }
+ return step, true
+}
+
+func ordinal(n int) string {
+ suffix := "th"
+ switch {
+ case n%100 >= 11 && n%100 <= 13:
+ case n%10 == 1:
+ suffix = "st"
+ case n%10 == 2:
+ suffix = "nd"
+ case n%10 == 3:
+ suffix = "rd"
+ }
+ return strconv.Itoa(n) + suffix
+}
diff --git a/internal/automation/words_test.go b/internal/automation/words_test.go
new file mode 100644
index 0000000000..ebe6223f4d
--- /dev/null
+++ b/internal/automation/words_test.go
@@ -0,0 +1,43 @@
+package automation
+
+import (
+ "testing"
+ "time"
+)
+
+func TestDescribeSaysTheCommonShapesInWords(t *testing.T) {
+ now := time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)
+ cases := map[string]string{
+ "0 9 * * *": "every day at 09:00",
+ "0 9 * * 1": "every Monday at 09:00",
+ "30 8 * * 1-5": "every weekday at 08:30",
+ "0 18 * * 1,4": "every Monday and Thursday at 18:00",
+ "0 9 1 * *": "on the 1st of every month at 09:00",
+ "15 * * * *": "every hour at :15",
+ "*/15 * * * *": "every 15 minutes",
+ "0 9 13 * 5": "on the cron line 0 9 13 * 5",
+ "15m": "every 15 minutes",
+ "2h": "every 2 hours",
+ "1d": "every day",
+ }
+ for every, want := range cases {
+ if got := Describe(Schedule{Every: every}, now); got != want {
+ t.Errorf("%q reads %q, want %q", every, got, want)
+ }
+ }
+ if got := Describe(Schedule{At: now.Add(6 * time.Hour)}, now); got != "today at 18:00" {
+ t.Errorf("a moment today reads %q", got)
+ }
+ if got := Describe(Schedule{At: now.Add(21 * time.Hour)}, now); got != "tomorrow at 09:00" {
+ t.Errorf("a moment tomorrow reads %q", got)
+ }
+}
+
+func TestExactCarriesTheZoneOfACronLine(t *testing.T) {
+ if got := Exact(Schedule{Every: "0 9 * * 1", Zone: "America/Toronto"}); got != "cron 0 9 * * 1 · America/Toronto" {
+ t.Fatalf("exact = %q", got)
+ }
+ if got := Exact(Schedule{Every: "15m"}); got != "every 15m" {
+ t.Fatalf("exact = %q", got)
+ }
+}
diff --git a/internal/config/apikey.go b/internal/config/apikey.go
index 4a14530d75..6ede54afc2 100644
--- a/internal/config/apikey.go
+++ b/internal/config/apikey.go
@@ -25,9 +25,9 @@ const KeyAPIKey = "api_key"
const APIKeyEnv = "OPENROUTER_API_KEY"
// PersistedAPIKey reads the api_key stored in the profile config file. It is
-// the durable rung of Load's key resolution: a timer-driven `codeaf wake` runs
-// with no shell environment, so the profile file is the only place a key can
-// survive to reach it.
+// the durable rung of Load's key resolution: a key pasted into the first-run
+// setup is kept there, where a process that never saw the shell environment
+// that set a variable can still find one.
func PersistedAPIKey(profileDir string) string {
values, err := readProfileConfig(profileDir)
if err != nil {
@@ -169,8 +169,10 @@ func LooksLikeAPIKey(key string) bool {
}
// EnsurePersistedAPIKey copies the session's environment key into the profile
-// config exactly once, so the standing watch can authenticate after the shell
-// that ratified it is gone. It never overwrites a key already on disk, and the
+// config exactly once, so a process started after the shell that held the key
+// is gone can still authenticate. (Its one caller was the standing watch, which
+// is gone; the automations clock reads the key from the newest window's
+// environment instead.) It never overwrites a key already on disk, and the
// file ends owner-readable only. Returns whether a key was newly persisted and
// the path that holds it.
func EnsurePersistedAPIKey(profileDir string) (bool, string, error) {
diff --git a/internal/config/config.go b/internal/config/config.go
index afe7120eb5..6fb49d5d10 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -183,24 +183,6 @@ const (
// answers in words, which is exactly the shape both slots ask for.
preferredPerceptionModel = "google/gemini-2.5-flash"
- // DefaultPracticeBudgetUSD is the daily carve-out reserved for self-origin
- // curiosity work. The global rail remains an additional ceiling.
- //
- // IT IS A CARVE-OUT AND NOT A RAIL, which is why 0 reads the opposite way
- // here than it does on every other money row in this file: 0 is no practice
- // at all (internal/resident's WithPracticeLoop switches the loop off), never
- // unbounded practice. There is no way to spell "practice without a ceiling"
- // and that is deliberate — self-origin work runs while nobody is watching,
- // so it is the one pocket that always has a bottom. Raised with the rail
- // above so the carve-out is a real slice of a real day.
- DefaultPracticeBudgetUSD = 50.0
-
- DefaultPracticeIdle = 20 * time.Minute
-
- // DefaultBriefAfter keeps ordinary short breaks silent. A longer absence
- // earns one folded arrival summary when background life actually happened.
- DefaultBriefAfter = 4 * time.Hour
-
// DefaultSwarm is whether cooperative decomposition is armed with nobody
// having said anything about it. It is TRUE from the swarm-road wave on;
// [Config.Swarm] carries the whole argument, and `CODEAF_SWARM=0` is the
@@ -241,25 +223,22 @@ type Config struct {
VoiceModel string
// Media model fields are capability slots. Empty means resolve at use
// from the live catalog, rather than trusting a floating default slug.
- ImageModel string
- SpeechModel string
- MusicModel string
- VideoModel string
- VisionModel string
- DocumentEngine string
- Timeout time.Duration
- Reasoning provider.Effort
- ExecReasoning provider.Effort
- SpineSamples int
- MaxDepth int
- NodeBudget int
- DailyBudgetUSD float64
- PracticeBudgetUSD float64
+ ImageModel string
+ SpeechModel string
+ MusicModel string
+ VideoModel string
+ VisionModel string
+ DocumentEngine string
+ Timeout time.Duration
+ Reasoning provider.Effort
+ ExecReasoning provider.Effort
+ SpineSamples int
+ MaxDepth int
+ NodeBudget int
+ DailyBudgetUSD float64
// PlanConsentUSD is the estimate above which a planned job asks before it
// starts. Zero never asks.
PlanConsentUSD float64
- PracticeIdle time.Duration
- BriefAfter time.Duration
// Swarm is the cooperative-decomposition mode, and it is ON by default
// ([DefaultSwarm]). On, a worker gains a verb for handing work back when
@@ -342,14 +321,12 @@ func Load() (Config, error) { return load(true) }
// key (internal/tui3's firstrun.go). Everything else resolves exactly as Load
// resolves it, and APIKey is simply empty until the person hands one over.
//
-// AND FOR A PASS THAT WILL PROBABLY DO NOTHING, which is the second legitimate
-// caller and the one nobody expects: the standing tick (cmd/codeaf's
-// v3StandingTicker), run every five minutes by whichever of a window or the
-// operating system's timer gets there first. Most passes decline the lock or
-// find every item asleep, and a pass that will do nothing costs nothing and
-// needs nothing — so it builds keyless and carries whatever key was there
-// through to the one moment a judgment actually calls a model, where a machine
-// with none says so on that item's own row.
+// AND FOR A CLOCK THAT MAY NEVER NEED A MODEL, which is the second legitimate
+// caller and the one nobody expects: `codeaf clock` (cmd/codeaf's
+// openV3ClockProcess), which runs automations while a window is open. A
+// reminder needs no model, so the clock builds keyless and carries whatever key
+// was there through to the one moment a judgment or a piece of work actually
+// calls a model, where a machine with none says so on that automation's own row.
//
// A door that has nobody to ask AND something to spend — --once, an engine, a
// pipe — still has no business calling this: it would fail on its first request
@@ -367,7 +344,6 @@ var nonSettingProfileFields = []string{
// was told at launch that a key codeaf reads was unread.
LaneBorrowKey(LaneSlotTalk),
KeySplitPct,
- KeyStandingBackground,
KeyResponseAttempts,
KeyResponseLiftAfter,
KeyResponseLiftCap,
@@ -413,6 +389,18 @@ var retiredProfileKeys = map[string]bool{
"practice_demand_pct": true, // reader removed by 84ba8503e
"propose_new_skills": true, // reader removed by 84ba8503e
"attribution": true, // reader removed on 2026-09-23: signing has no off
+ // The v1 resident's scheduler knobs: the practice carve-out and its quiet
+ // period, the arrival brief's absence (whose value Load copied onto a field
+ // nothing read), and the clean-firing count that earned a charter tenure.
+ // The scheduler went on 2026-10-10 (docs/design/automations/DESIGN.md).
+ "practice_budget_usd": true, // reader removed on 2026-10-10: the practice loop is gone
+ "practice_idle": true, // reader removed on 2026-10-10: the practice loop is gone
+ "brief_after": true, // reader removed on 2026-10-10: nothing read the field
+ "tenure_after": true, // reader removed on 2026-10-10: charters are gone
+ // The OS timer that ran standing items with no window open. Automations
+ // run only while a window is open, so there is nothing left to switch, and
+ // off — the one answer anybody chose on purpose — is now simply true.
+ "standing.background": true, // reader removed on 2026-10-10: the OS timer is gone
// The retired crew rows are read once more, by the migration that removes
// them and says so in its own line (crewmigrate.go's [MigrateCrew]); the
// unread check must not say it a second time in a worse sentence.
@@ -522,9 +510,6 @@ func load(requireKey bool) (Config, error) {
MaxDepth: DefaultMaxDepth,
NodeBudget: DefaultNodeBudget,
DailyBudgetUSD: DefaultDailyBudgetUSD,
- PracticeBudgetUSD: DefaultPracticeBudgetUSD,
- PracticeIdle: DefaultPracticeIdle,
- BriefAfter: DefaultBriefAfter,
Swarm: DefaultSwarm,
ProfileDir: profileDir,
}
@@ -590,15 +575,6 @@ func load(requireKey bool) (Config, error) {
// the reserve goes on doing the one job it was written for: keeping the
// prompt small enough that the answer and its reasoning still fit.
ctxbudget.Configure(contextLaw(config.ProfileDir))
- if config.PracticeIdle, err = PracticeIdleAt(config.ProfileDir); err != nil {
- return Config{}, err
- }
- if config.BriefAfter, err = BriefAfterAt(config.ProfileDir); err != nil {
- return Config{}, err
- }
- if config.PracticeBudgetUSD, err = PracticeBudgetUSDAt(config.ProfileDir); err != nil {
- return Config{}, err
- }
if config.DailyBudgetUSD, err = DailyBudgetUSDAt(config.ProfileDir); err != nil {
return Config{}, err
}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 9ea3e63474..3aa3261556 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -13,7 +13,6 @@ import (
"strings"
"sync"
"testing"
- "time"
"github.com/Agent-Field/codeaf/internal/catalog"
"github.com/Agent-Field/codeaf/internal/provider"
@@ -28,7 +27,6 @@ func settings(t *testing.T) Config {
t.Setenv("CODEAF_BASE_URL", "http://127.0.0.1:1")
t.Setenv("CODEAF_PROFILE_DIR", t.TempDir())
t.Setenv("CODEAF_DAILY_BUDGET", "")
- t.Setenv("CODEAF_BRIEF_AFTER", "")
config, err := Load()
if err != nil {
t.Fatal(err)
@@ -230,55 +228,21 @@ func TestMusicAndVideoPreferenceOrders(t *testing.T) {
}
}
-func TestPracticeBudgetAndIdleDefaultsAndOverrides(t *testing.T) {
- t.Setenv("CODEAF_PRACTICE_BUDGET", "")
- t.Setenv("CODEAF_PRACTICE_IDLE", "")
- got := settings(t)
- if got.PracticeBudgetUSD != DefaultPracticeBudgetUSD || got.PracticeIdle != DefaultPracticeIdle {
- t.Fatalf("practice defaults = $%v/%s", got.PracticeBudgetUSD, got.PracticeIdle)
- }
-
+// The v1 scheduler's four variables are read by nothing now — the practice
+// carve-out and its quiet period, the arrival brief's absence and the tenure
+// count all went with the scheduler — so a shell that still exports one, even
+// spelled so badly the old reader refused to start, starts like any other.
+func TestTheRemovedSchedulerVariablesNoLongerStopALoad(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "test-key")
+ t.Setenv("CODEAF_BASE_URL", "http://127.0.0.1:1")
t.Setenv("CODEAF_PROFILE_DIR", t.TempDir())
- t.Setenv("CODEAF_PRACTICE_BUDGET", "3.5")
- t.Setenv("CODEAF_PRACTICE_IDLE", "45m")
- got, err := Load()
- if err != nil {
- t.Fatal(err)
- }
- if got.PracticeBudgetUSD != 3.5 || got.PracticeIdle != 45*time.Minute {
- t.Fatalf("practice overrides = $%v/%s", got.PracticeBudgetUSD, got.PracticeIdle)
- }
-
- for _, test := range []struct{ budget, idle string }{
- {budget: "-1"}, {budget: "NaN"}, {idle: "-1m"}, {idle: "later"},
- } {
- t.Setenv("CODEAF_PRACTICE_BUDGET", test.budget)
- t.Setenv("CODEAF_PRACTICE_IDLE", test.idle)
- if _, err := Load(); err == nil {
- t.Fatalf("invalid practice settings budget=%q idle=%q were accepted", test.budget, test.idle)
- }
- }
-}
-
-func TestBriefAfterConfiguration(t *testing.T) {
- config := settings(t)
- if config.BriefAfter != 4*time.Hour {
- t.Fatalf("default brief threshold = %s, want 4h", config.BriefAfter)
- }
-
- t.Setenv("CODEAF_BRIEF_AFTER", "90m")
- config, err := Load()
- if err != nil {
- t.Fatal(err)
- }
- if config.BriefAfter != 90*time.Minute {
- t.Fatalf("configured brief threshold = %s, want 90m", config.BriefAfter)
- }
-
+ t.Setenv("CODEAF_DAILY_BUDGET", "")
+ t.Setenv("CODEAF_PRACTICE_BUDGET", "-1")
+ t.Setenv("CODEAF_PRACTICE_IDLE", "later")
t.Setenv("CODEAF_BRIEF_AFTER", "-1h")
- if _, err := Load(); err == nil {
- t.Fatal("negative CODEAF_BRIEF_AFTER was accepted")
+ t.Setenv("CODEAF_TENURE_AFTER", "many")
+ if _, err := Load(); err != nil {
+ t.Fatalf("a variable nothing reads stopped the load: %v", err)
}
}
@@ -587,7 +551,7 @@ func TestNoShippedWriterKeyIsReportedUnread(t *testing.T) {
values[row.Key] = json.RawMessage(`"x"`)
}
for _, key := range []string{
- KeySetupSeen, KeySplitPct, KeyStandingBackground,
+ KeySetupSeen, KeySplitPct,
KeyResponseAttempts, KeyResponseLiftAfter, KeyResponseLiftCap,
keyModelSources,
} {
@@ -698,9 +662,6 @@ func TestTheLaneBorrowRowIsNotReportedUnread(t *testing.T) {
// pinned row refuses every write and this law is about what the writer does.
func profileKeysWrittenByRows(t *testing.T) map[string]string {
t.Helper()
- // writeTenure hands its value to the process environment as well as the
- // file; this puts the variable back when the law is done.
- t.Setenv("CODEAF_TENURE_AFTER", os.Getenv("CODEAF_TENURE_AFTER"))
written := map[string]string{}
for _, listed := range registry(t, t.TempDir()).Rows() {
if listed.Key == "" {
diff --git a/internal/config/derivation_test.go b/internal/config/derivation_test.go
index 7d57759f67..1b40f861c1 100644
--- a/internal/config/derivation_test.go
+++ b/internal/config/derivation_test.go
@@ -119,10 +119,6 @@ var settingReaders = map[string]string{
KeyIcons: "iconMode",
KeyDailyBudget: "DailyBudgetUSD",
KeyPlanConsent: "PlanConsentUSD",
- KeyPracticeBudget: "PracticeBudgetUSD",
- KeyPracticeIdle: "PracticeIdle",
- KeyBriefAfter: "BriefAfter",
- KeyTenureAfter: "CODEAF_TENURE_AFTER",
KeyDocumentEngine: "DocumentEngine",
KeyVisionModel: "VisionModel",
// The model-name row names the one resolver every door turns it into a
@@ -168,9 +164,8 @@ var settingReaders = map[string]string{
KeyDraftPersist: "KeyDraftPersist",
// The telemetry row is answered by the package the events come from:
// internal/telemetry reads the CODEAF_TELEMETRY pin itself (its off
- // state), so the row names the pin the far side touches — the same
- // spelling [KeyTenureAfter] uses for its pin. cmd/codeaf's door will
- // tighten this to its own accessor when it wires the package in.
+ // state), so the row names the pin the far side touches. cmd/codeaf's
+ // door will tighten this to its own accessor when it wires the package in.
KeyTelemetry: "CODEAF_TELEMETRY",
// The v3 session's rows name what READS the value on the far side, which
// for these six is not a function in this package: the two approval rows
@@ -293,8 +288,8 @@ var settingReaders = map[string]string{
KeyGoogleOAuthSecret: "GoogleOAuthClientAt",
KeySlackOAuthClient: "SlackOAuthClientAt",
// The context law's knobs are read live by ctxbudget on every call — the
- // environment name is the reader, as with tenure; Load seeds the
- // persisted half through ctxbudget.Configure.
+ // environment name is the reader; Load seeds the persisted half through
+ // ctxbudget.Configure.
KeyContextFill: "CODEAF_CONTEXT_FILL_PCT",
KeyCompletionReserve: "CODEAF_COMPLETION_RESERVE",
KeyWorkingSet: "CODEAF_WORKING_SET",
diff --git a/internal/config/selfservice.go b/internal/config/selfservice.go
index ec0fe2c835..ef5f6bbd79 100644
--- a/internal/config/selfservice.go
+++ b/internal/config/selfservice.go
@@ -85,12 +85,11 @@ var selfServiceGuards = map[string]string{
KeyConsentTimeout: guardConsent,
KeyTaskAutoApprove: guardConsent,
- // The rails on money, coarse to fine: the day, the ask-first threshold, the
- // practice carve-out, one conversation, one node's retries, and the two caps
- // on how much context a single worker may send and re-send at full price.
+ // The rails on money, coarse to fine: the day, the ask-first threshold, one
+ // conversation, one node's retries, and the two caps on how much context a
+ // single worker may send and re-send at full price.
KeyDailyBudget: guardSpending,
KeyPlanConsent: guardSpending,
- KeyPracticeBudget: guardSpending,
KeySpendRail: guardSpending,
KeyTaskRepairRounds: guardSpending,
KeyWorkingSet: guardSpending,
diff --git a/internal/config/selfservice_test.go b/internal/config/selfservice_test.go
index 446faf836b..32c33c79e4 100644
--- a/internal/config/selfservice_test.go
+++ b/internal/config/selfservice_test.go
@@ -36,7 +36,7 @@ func TestTheRestraintRowsAreNotSelfService(t *testing.T) {
KeyToolApprovalMode, KeyToolApprovals, KeyBashApprovals,
KeyGuardian, KeyConsentTimeout, KeyTaskAutoApprove,
// What may be spent without asking.
- KeyDailyBudget, KeyPlanConsent, KeyPracticeBudget, KeySpendRail,
+ KeyDailyBudget, KeyPlanConsent, KeySpendRail,
KeyTaskRepairRounds, KeyWorkingSet, KeyContextReuse,
// How hard the machine may be worked.
KeyTaskParallel, KeyTaskMaxLoad, KeyTaskMinFreeMB, KeyBashBackgroundAfter,
diff --git a/internal/config/settings.go b/internal/config/settings.go
index 0fde5e2bb3..6140da4c1d 100644
--- a/internal/config/settings.go
+++ b/internal/config/settings.go
@@ -1,14 +1,12 @@
package config
import (
- "context"
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
- "time"
"github.com/Agent-Field/codeaf/internal/crewroute"
"github.com/Agent-Field/codeaf/internal/ctxbudget"
@@ -17,7 +15,6 @@ import (
"github.com/Agent-Field/codeaf/internal/provider"
"github.com/Agent-Field/codeaf/internal/roles"
"github.com/Agent-Field/codeaf/internal/search"
- "github.com/Agent-Field/codeaf/internal/standing"
"github.com/Agent-Field/codeaf/internal/store"
"github.com/Agent-Field/codeaf/internal/taxonomy"
"github.com/Agent-Field/codeaf/internal/telemetry"
@@ -112,10 +109,6 @@ var SettingCategories = []string{
const (
KeyDailyBudget = "daily_budget_usd"
KeyPlanConsent = "plan_consent_usd"
- KeyPracticeBudget = "practice_budget_usd"
- KeyPracticeIdle = "practice_idle"
- KeyBriefAfter = "brief_after"
- KeyTenureAfter = "tenure_after"
KeyDocumentEngine = "document_engine"
KeyVisionModel = "vision_model"
// KeyModelPool is the stored word the pool's resolver takes: the same three
@@ -338,22 +331,6 @@ const (
// knowing nothing about you, and that makes not one extra call.
KeyMemoryEnabled = "memory.enabled"
- // KeyStandingBackground is whether this machine's own scheduler keeps
- // standing items current when no codeaf window is open (internal/standing's
- // watch.go). It is dotted with the other v3 keys, under `standing.` because
- // that is the thing it is about, and it is PROFILE-ONLY — deliberately
- // absent from [ProjectKeys]: installing a timer is a change to somebody's
- // machine, and a checked-in file that could make one is a repository
- // arranging to run a program on every laptop that clones it.
- //
- // ON IS THE DEFAULT and the row is the only place it is ever asked. What
- // the row READS is derived from the timer itself rather than from this
- // value, so the sheet cannot say `on` over a machine where nothing is
- // installed; this value is the person's INTENT, which is what the launch
- // repair reads before it puts a drifted timer back
- // ([BackgroundChecksWantedAt]).
- KeyStandingBackground = "standing.background"
-
KeyModelRoles = "models.roles"
// KeyModelFallbacks is the ordered list of models a conversation moves to
// when no endpoint serving the one it is on will accept the request at all
@@ -729,21 +706,6 @@ const DefaultPromptProfile = PromptProfileAuto
// engine obeys must be one string or they drift.
const EnvPromptProfile = "CODEAF_PROMPT_PROFILE"
-// The background-checks row's two answers.
-const (
- BackgroundOff = "off"
- BackgroundOn = "on"
-)
-
-// BackgroundModes lists them, on first — which is the default. Something you
-// asked to happen every morning is something you asked to happen on the
-// mornings you do not open a terminal, and a person who wanted it only while
-// they were sitting there says so here.
-var BackgroundModes = []string{BackgroundOn, BackgroundOff}
-
-// DefaultBackground is on.
-const DefaultBackground = BackgroundOn
-
// The routing row's four answers. They are spelled here rather than imported
// from internal/provider for the reason [DocumentEngines] is: a settings key's
// vocabulary is a string on disk, and it must not change because a package
@@ -1115,6 +1077,11 @@ var OperatorEnvPins = []string{
"CODEAF_NO_UPDATE_CHECK",
"CODEAF_GITHUB_API",
"CODEAF_GITHUB_DOWNLOAD",
+ // CODEAF_NO_AUTOMATIONS keeps a window from starting the clock that runs
+ // automations (internal/automation's Startable). The demo home sets it so a
+ // look at the fixture spends nothing on its invented automations; it is one
+ // launch's posture rather than a preference, so plumbing.
+ "CODEAF_NO_AUTOMATIONS",
// CODEAF_INSTALL_NAME belongs to the shell installer and not to this
// program: it chooses the file name an install writes, and codeaf never
// reads it. It is spelled in Go at all only because the curl line codeaf
@@ -1373,10 +1340,6 @@ const (
// would genuinely want to see priced first. 0 never asks.
DefaultPlanConsentUSD = 100.0
- // DefaultTenureAfter is the clean-firing count a standing charter needs
- // before it earns tenure.
- DefaultTenureAfter = 3
-
// DefaultAttributionModel names the model in the `Assisted-by` line by
// default, because the line is provenance and the model is the part of it
// somebody auditing the history later actually wants. The signature itself
@@ -1532,9 +1495,9 @@ type Setting struct {
// renders it ([Setting.Reading]).
//
// THE SYMBOLS ATTACH AND THE WORDS DO NOT. `300s`, `60%`, `20m` are one
- // token in every terminal font, the way the two SettingDuration rows beside
- // them already read; `1536 MB` and `65536 tok` are two words and read as
- // two ([unitAttaches] is the whole list).
+ // token in every terminal font, the way a duration reading already reads;
+ // `1536 MB` and `65536 tok` are two words and read as two ([unitAttaches]
+ // is the whole list).
//
// A ROW WHOSE READING ALREADY CARRIES ITS UNIT LEAVES THIS EMPTY — a
// duration writes `20m`, a dollar row writes `$5`, [formatPercent] writes
@@ -1544,8 +1507,8 @@ type Setting struct {
// the completeness test can tell them apart.
Unit string
- // UnitOne is [Setting.Unit] at exactly one — `1 clean firing` rather than
- // `1 clean firings`. Empty means the unit reads the same at every number,
+ // UnitOne is [Setting.Unit] at exactly one — `1 level` rather than
+ // `1 levels`. Empty means the unit reads the same at every number,
// which is true of every symbol and of `tok`, `MB` and `per core`.
UnitOne string
@@ -1789,19 +1752,6 @@ type SettingsOptions struct {
// wiring lane hands in.
ModelCost func(slug string) string
- // BackgroundChecks is this machine's own scheduler as internal/standing
- // drives it: the launchd agent or systemd user timer that runs `codeaf tick`
- // every [standing.Interval] so standing items are checked with no window
- // open. It is what the `standing.background` row reads and writes.
- //
- // NIL LEAVES THE ROW OUT ALTOGETHER, which is this codebase's law about a
- // capability that cannot work rather than a shortcut: a Windows host, a
- // build with no store, a registry built to read defaults out of a profile
- // nobody has — none of them has a timer to turn on, and a row present and
- // refusing every write would be a switch wired to nothing. The divider row
- // is absent for the same reason ([Settings.build]).
- BackgroundChecks standing.Watch
-
// Applied fires after a row is successfully written, so a process holding
// its own copy of a value can honor the change without waiting for a
// relaunch.
@@ -1897,8 +1847,8 @@ func ModelSettingKey(slot string) string { return "model." + slot }
//
// Within a group the order is how often a person touches a row, not the order
// the rows were written or the order they happen to persist in: the day's
-// ceiling before the ask-first threshold before the practice carve-out, the
-// conversation model before the two nobody has ever changed. Between groups the
+// ceiling before the ask-first threshold, the conversation model before the two
+// nobody has ever changed. Between groups the
// order is [SettingCategories]. Nothing here announces either — 15 — the
// sequence is the sequence.
func (s *Settings) build() []Setting {
@@ -1929,8 +1879,7 @@ func (s *Settings) build() []Setting {
Key: KeyEffort, Category: CategoryModels, Kind: SettingChoice,
Label: "thinking", Choices: EffortChoices,
Hint: "how hard the model thinks about your turns and the work you hand out. Auto leaves reasoning to the model. " +
- "xhigh and max ask for a deeper pass than high, and cost the time they take. " +
- "A standing item's firings and checks never take this row; only the rung on the item's own card reaches a firing.",
+ "xhigh and max ask for a deeper pass than high, and cost the time they take.",
read: func() string { return EffortWord(DefaultEffortAt(dir)) },
write: func(raw string) error { return writeChoice(dir, KeyEffort, raw, EffortChoices) },
},
@@ -2092,17 +2041,6 @@ func (s *Settings) build() []Setting {
read: func() string { return moneyValue(resolvedDollars(PlanConsentUSDAt(dir))) },
write: func(raw string) error { return writeDollars(dir, KeyPlanConsent, raw) },
},
- Setting{
- Key: KeyPracticeBudget, Category: CategorySpending, Kind: SettingDollars,
- Label: "practice budget", Env: "CODEAF_PRACTICE_BUDGET",
- EmptyLabel: "practice off",
- Hint: "the slice of the day reserved for codeaf practicing on itself. When it " +
- "is spent, practice stops until tomorrow and your own work is untouched. " +
- "0 is the one money row that does not mean no limit: it turns practice " +
- "off. A change lands the next time codeaf starts.",
- read: func() string { return moneyValue(resolvedDollars(PracticeBudgetUSDAt(dir))) },
- write: func(raw string) error { return writeDollars(dir, KeyPracticeBudget, raw) },
- },
// The two consent rows sit with spending because they answer the same
// question about a different currency: what may codeaf do without
@@ -2409,20 +2347,6 @@ func (s *Settings) build() []Setting {
receipt: s.spentThisSessionReceipt,
},
- Setting{
- Key: KeyPracticeIdle, Category: CategoryPractice, Kind: SettingDuration,
- Label: "quiet before practice", Env: "CODEAF_PRACTICE_IDLE",
- Hint: "how long the room stays quiet before codeaf starts practicing.",
- read: func() string { return formatDuration(resolvedDuration(PracticeIdleAt(dir))) },
- write: func(raw string) error { return writeDuration(dir, KeyPracticeIdle, raw) },
- },
- Setting{
- Key: KeyBriefAfter, Category: CategoryPractice, Kind: SettingDuration,
- Label: "arrival brief after", Env: "CODEAF_BRIEF_AFTER",
- Hint: "how long you have to be away before codeaf greets you with a summary. 0 always briefs.",
- read: func() string { return formatDuration(resolvedDuration(BriefAfterAt(dir))) },
- write: func(raw string) error { return writeDuration(dir, KeyBriefAfter, raw) },
- },
// THE TWO ROWS THAT ARE NOT THE CREW, AND THEN THE CREW'S THREE PINS.
// The tiers are what a person configures for the calls codeaf makes on
// its own — the name it gives a session, the check on work a task says is
@@ -2558,18 +2482,11 @@ func (s *Settings) build() []Setting {
read: func() string { return ReplyGuardAt(dir) },
write: func(raw string) error { return writeChoice(dir, KeyReplyGuard, raw, ReplyGuardModes) },
},
- Setting{
- Key: KeyTenureAfter, Category: CategoryPractice, Kind: SettingCount,
- Label: "tenure after", Unit: "clean firings", UnitOne: "clean firing", Env: "CODEAF_TENURE_AFTER",
- Hint: "how many clean firings a standing charter needs before it earns tenure.",
- read: func() string { return strconv.Itoa(TenureAfterAt(dir)) },
- write: func(raw string) error { return writeTenure(dir, raw) },
- },
// THE ROW READS THE SAME WAY UP AS ITS KEY, unlike the hints row above:
// `update.auto` on means codeaf keeps itself current, and `auto update` on
- // is that sentence. It is the second row about what this machine does on
- // its own, so it stands beside `background checks` rather than among the
- // rows about how the surface draws itself. The offer's own "stop asking"
+ // is that sentence. It is a row about what this machine does on its own,
+ // so it stands with the practice rows rather than among the rows about
+ // how the surface draws itself. The offer's own "stop asking"
// writes through [SaveUpdateAuto], which is this row's writer by another
// door.
Setting{
@@ -2584,16 +2501,6 @@ func (s *Settings) build() []Setting {
},
)
- // THE SECOND ROW THAT IS NOT ALWAYS BUILT, on the divider's law below and
- // for a plainer reason: on a machine with no timer to install there is
- // nothing for this switch to switch. A Windows host, a registry built over
- // a profile that exists only to read defaults out of, a caller that never
- // opened a standing store — each of them gets a sheet without the row
- // rather than a row that reads nothing and refuses every write.
- if s.options.BackgroundChecks != nil {
- rows = append(rows, s.backgroundRow(s.options.BackgroundChecks))
- }
-
// THE ONE ROW THAT IS NOT ALWAYS BUILT, and it is the exception
// [NewSettings] names: a missing seam makes a row read-only, except where
// read-only would mean a control that cannot do the one thing it is for.
@@ -2771,9 +2678,8 @@ const noLimitWord = NoLimitWord
// A row whose figure is zero returns NOTHING, which hands the reading to
// [Setting.EmptyLabel] — the mechanism every other kind of row already uses for
// its off state, and the one place each rail's own word for zero is written
-// down: `no limit` on the day and the conversation, `never asks` on the consent
-// gate, `practice off` on the carve-out, whose zero switches practice off rather
-// than uncapping it. Two things follow for free, and both are the reason it is
+// down: `no limit` on the day and the conversation, and `never asks` on the
+// consent gate. Two things follow for free, and both are the reason it is
// spelled this way rather than as a second receipt:
//
// - THE EDIT BOX OPENS EMPTY on a row that holds nothing. Every surface
@@ -3014,65 +2920,6 @@ func modelSlotReading(options SettingsOptions, slot ModelSlot) string {
return strings.TrimSpace(value)
}
-// backgroundRow is the `background checks` switch.
-//
-// WHAT IT READS IS DERIVED FROM THE MACHINE AND NEVER FROM THE FILE. The value
-// on disk is what the person last asked for; whether anything is actually
-// checking is a definition on this host, and [standing.Timer.Status] answers it
-// by reading that definition's own bytes. A row that read the file would say
-// `on` over a timer somebody removed with launchctl, in the one place a person
-// went to check — so the file's answer is used for exactly one thing, the
-// launch repair, and never for the sentence in front of you.
-//
-// A status that cannot be read renders as NOTHING, which is the emptiness law:
-// a switch that guessed would be worse than a switch that admits it does not
-// know.
-func (s *Settings) backgroundRow(watch standing.Watch) Setting {
- dir := s.options.ProfileDir
- return Setting{
- Key: KeyStandingBackground, Category: CategoryPractice, Kind: SettingChoice,
- Label: "background checks", Choices: BackgroundModes,
- Hint: "whether reminders, watches and routines are checked when no codeaf window " +
- "is open. On installs one small timer under your own login that runs " +
- "`codeaf tick` every " + standing.IntervalWords() + " — a launchd agent called " +
- standing.DarwinTickLabel + " on a Mac, a systemd user timer called " +
- standing.LinuxTickTimer + " on Linux — and on is the default. Off removes it, and " +
- "then things are checked only while a window is open. Either way nothing runs " +
- "while the machine is asleep, and nothing runs when you are not logged in.",
- read: func() string {
- status, err := watch.Status()
- if err != nil {
- return ""
- }
- if status.Installed {
- return BackgroundOn
- }
- return BackgroundOff
- },
- write: func(raw string) error {
- value := strings.ToLower(strings.TrimSpace(raw))
- if value == "" {
- value = DefaultBackground
- }
- if value != BackgroundOn && value != BackgroundOff {
- return fmt.Errorf("pick one of: %s", strings.Join(BackgroundModes, ", "))
- }
- // THE ANSWER IS WRITTEN DOWN BEFORE THE MACHINE IS TOUCHED, which is
- // the law the first-item notice keeps too (internal/session's
- // tools_standing.go): a person whose install half-worked is somebody
- // this build knows what they asked for, rather than somebody it
- // quietly does the opposite for at the next launch.
- if err := writeProfileValue(dir, KeyStandingBackground, value); err != nil {
- return err
- }
- if value == BackgroundOff {
- return watch.Uninstall(context.Background())
- }
- return watch.Install(context.Background())
- },
- }
-}
-
func (s *Settings) splitRow() Setting {
options := s.options
row := Setting{
@@ -3201,57 +3048,10 @@ func PlanConsentUSDAt(profileDir string) (float64, error) {
return DefaultPlanConsentUSD, nil
}
-// PracticeBudgetUSDAt resolves the daily self-practice carve-out.
-func PracticeBudgetUSDAt(profileDir string) (float64, error) {
- if raw := strings.TrimSpace(env.Get("CODEAF_PRACTICE_BUDGET")); raw != "" {
- return validateDailyBudgetValue(raw, "CODEAF_PRACTICE_BUDGET")
- }
- if value, ok := persistedFloat(profileDir, KeyPracticeBudget); ok && value >= 0 {
- return value, nil
- }
- return DefaultPracticeBudgetUSD, nil
-}
-
-// PracticeIdleAt resolves the quiet period before self-practice.
-func PracticeIdleAt(profileDir string) (time.Duration, error) {
- return durationAt(profileDir, "CODEAF_PRACTICE_IDLE", KeyPracticeIdle, DefaultPracticeIdle)
-}
-
-// BriefAfterAt resolves the absence that earns an arrival brief.
-func BriefAfterAt(profileDir string) (time.Duration, error) {
- return durationAt(profileDir, "CODEAF_BRIEF_AFTER", KeyBriefAfter, DefaultBriefAfter)
-}
-
-func durationAt(profileDir, envName, key string, fallback time.Duration) (time.Duration, error) {
- if raw := strings.TrimSpace(env.Value(envName)); raw != "" {
- value, err := time.ParseDuration(raw)
- if err != nil || value < 0 {
- return 0, fmt.Errorf("%s: want a non-negative duration, got %q", envName, raw)
- }
- return value, nil
- }
- if value, ok := persistedDuration(profileDir, key); ok {
- return value, nil
- }
- return fallback, nil
-}
-
-// TenureAfterAt resolves the clean-firing count that earns a charter tenure.
-func TenureAfterAt(profileDir string) int {
- if raw := strings.TrimSpace(env.Get("CODEAF_TENURE_AFTER")); raw != "" {
- if value, err := strconv.Atoi(raw); err == nil && value > 0 {
- return value
- }
- return DefaultTenureAfter
- }
- if value, ok := persistedInt(profileDir, KeyTenureAfter); ok && value > 0 {
- return value
- }
- return DefaultTenureAfter
-}
-
-// The two resolvers that used to sit here — practice_demand_pct and
-// propose_new_skills — are gone with their rows. Nothing in this build read
+// The resolvers that used to sit here — practice_demand_pct and
+// propose_new_skills, and later the v1 scheduler's four (the practice budget
+// and its quiet period, the arrival brief's absence, and tenure) — are gone
+// with their rows. Nothing in this build read
// either one: the value was resolved, copied onto a Config field, and never
// looked at again, so the sheet was offering a person a dial wired to nothing.
// 5.20 is that an affordance which does nothing must not be offered as though
@@ -3411,19 +3211,6 @@ func VisionModelAt(profileDir string) string {
return ""
}
-// InstallPersistedEnv exports the persisted value of every knob whose only
-// reader is the process environment, so a choice made in the sheet survives a
-// relaunch without a second lookup path. A variable the user actually set is
-// never overwritten — the environment still wins.
-func InstallPersistedEnv(profileDir string) {
- if strings.TrimSpace(env.Get("CODEAF_TENURE_AFTER")) != "" {
- return
- }
- if value, ok := persistedInt(profileDir, KeyTenureAfter); ok && value > 0 {
- _ = os.Setenv("CODEAF_TENURE_AFTER", strconv.Itoa(value))
- }
-}
-
// ── the web-search rows ─────────────────────────────────────────────────────
//
// The three key rows ARE environment-pinned, where the v3 rows below are not,
@@ -4011,27 +3798,6 @@ func MemoryEnabledAt(profileDir string) bool {
return MemoryAt(profileDir) == MemoryOn
}
-// BackgroundChecksAt resolves the background-checks row to its word, default
-// on. It is the person's INTENT and not a reading of the machine: what the
-// settings row shows is derived from the timer ([Settings.build]), and this is
-// what the row was last told.
-func BackgroundChecksAt(profileDir string) string {
- if value, ok := persistedString(profileDir, KeyStandingBackground); ok {
- if value = strings.TrimSpace(value); value != "" {
- return value
- }
- }
- return DefaultBackground
-}
-
-// BackgroundChecksWantedAt is [BackgroundChecksAt] as the bool the launch reads
-// before it repairs a timer that has drifted off a program that moved
-// (cmd/codeaf's chatv3_process.go). A person who turned the row off is a person
-// whose machine must stay as they left it.
-func BackgroundChecksWantedAt(profileDir string) bool {
- return BackgroundChecksAt(profileDir) == BackgroundOn
-}
-
// ToolApprovalsAt resolves the per-tool exceptions as the person wrote them.
// The text is the record; [ParseToolApprovals] is how a caller reads it.
func ToolApprovalsAt(profileDir string) string {
@@ -4638,14 +4404,6 @@ func writeDollars(profileDir, key, raw string) error {
return writeProfileValue(profileDir, key, value)
}
-func writeDuration(profileDir, key, raw string) error {
- value, err := parseDuration(raw)
- if err != nil {
- return err
- }
- return writeProfileValue(profileDir, key, formatDuration(value))
-}
-
func writeBool(profileDir, key, raw string) error {
value, err := parseBool(raw)
if err != nil {
@@ -4918,23 +4676,6 @@ func writeContextReuse(profileDir, raw string) error {
return nil
}
-// writeTenure persists the count and exports it, because the standing watch
-// reads the variable at each check: the change lands in this process too.
-func writeTenure(profileDir, raw string) error {
- value, err := parseCount(raw)
- if err != nil {
- return err
- }
- if value <= 0 {
- return fmt.Errorf("that needs to be at least 1")
- }
- if err := writeProfileValue(profileDir, KeyTenureAfter, value); err != nil {
- return err
- }
- _ = os.Setenv("CODEAF_TENURE_AFTER", strconv.Itoa(value))
- return nil
-}
-
// Parsers. Their errors are the words the row shows under itself, so they read
// like a person talking rather than a validator.
@@ -4964,18 +4705,6 @@ func parseDollars(raw string) (float64, error) {
return value, nil
}
-func parseDuration(raw string) (time.Duration, error) {
- text := strings.TrimSpace(raw)
- if text == "0" {
- return 0, nil
- }
- value, err := time.ParseDuration(text)
- if err != nil || value < 0 {
- return 0, fmt.Errorf("that's not a length of time — try 20m or 4h")
- }
- return value, nil
-}
-
func parsePercent(raw string, low, high int) (int, error) {
text := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(raw), "%"))
value, err := strconv.Atoi(text)
@@ -5035,27 +4764,6 @@ func formatBool(value bool) string {
return "off"
}
-// formatDuration writes the shortest honest form: 4h, 20m, 1h30m.
-func formatDuration(value time.Duration) string {
- if value <= 0 {
- return "0"
- }
- var text strings.Builder
- if hours := int(value / time.Hour); hours > 0 {
- fmt.Fprintf(&text, "%dh", hours)
- }
- if minutes := int(value % time.Hour / time.Minute); minutes > 0 {
- fmt.Fprintf(&text, "%dm", minutes)
- }
- if seconds := int(value % time.Minute / time.Second); seconds > 0 {
- fmt.Fprintf(&text, "%ds", seconds)
- }
- if text.Len() == 0 {
- return value.String()
- }
- return text.String()
-}
-
// A row still has to read while the environment holds an unparsable value.
// The sheet shows the pin and refuses the edit; the reading falls back rather
// than blanking.
@@ -5067,13 +4775,6 @@ func resolvedDollars(value float64, err error) float64 {
return value
}
-func resolvedDuration(value time.Duration, err error) time.Duration {
- if err != nil {
- return 0
- }
- return value
-}
-
func resolvedEngine(value string, err error) string {
if err != nil {
return DefaultDocumentEngine
@@ -5132,18 +4833,6 @@ func persistedString(profileDir, key string) (string, bool) {
return value, true
}
-func persistedDuration(profileDir, key string) (time.Duration, bool) {
- text, ok := persistedString(profileDir, key)
- if !ok {
- return 0, false
- }
- value, err := parseDuration(text)
- if err != nil {
- return 0, false
- }
- return value, true
-}
-
func persistedValue(profileDir, key string) (json.RawMessage, bool) {
values, err := readProfileConfig(profileDir)
if err != nil {
diff --git a/internal/config/settings_presentation.go b/internal/config/settings_presentation.go
index 7116aecc05..5019b3f13f 100644
--- a/internal/config/settings_presentation.go
+++ b/internal/config/settings_presentation.go
@@ -41,15 +41,12 @@ func (s Setting) ChatPresentation() SettingPresentation {
p.Scope = "This chat and the profile default"
}
switch s.Key {
- case KeyPracticeIdle, KeyPracticeBudget, KeyBriefAfter, KeyTenureAfter, KeySplitPct:
+ case KeySplitPct:
p.Hidden = true // Resident-only controls have no live chat consumer.
case KeyMemoryEnabled:
p.Category, p.Label = "Memory", "remember across chats"
p.Description = "Learns useful preferences and corrections from conversations and recalls relevant memories. /memories lets you inspect what is saved. Turning this off does not delete saved memories."
p.Activation = "Restart the CLI to apply this to already-open chats"
- case KeyStandingBackground:
- p.Label = "background reminders"
- p.Description = "Check reminders, watches and routines while the CLI is closed. Uses a timer under your login; the machine must be awake and you must be logged in."
case KeyUpdateAuto:
p.Label = "automatic updates"
case KeyMouse:
diff --git a/internal/config/settings_presentation_test.go b/internal/config/settings_presentation_test.go
index 2683d24106..1f3f05dfed 100644
--- a/internal/config/settings_presentation_test.go
+++ b/internal/config/settings_presentation_test.go
@@ -63,12 +63,18 @@ func TestChatPresentationLeavesExistingProfileUntouched(t *testing.T) {
}
}
-func TestChatPresentationPreservesHiddenResidentSettings(t *testing.T) {
+// The v1 resident's scheduler rows — the practice budget and its quiet period,
+// the arrival brief's absence, and tenure — used to stay registered and hidden
+// from chat. They went with the scheduler, so they are not rows at all: a
+// profile still holding one is a retired key ([retiredProfileKeys]).
+func TestChatPresentationHasNoRowsForTheRemovedScheduler(t *testing.T) {
r := registry(t, t.TempDir())
- for _, key := range []string{KeyPracticeIdle, KeyPracticeBudget, KeyBriefAfter, KeyTenureAfter} {
- row, ok := r.Row(key)
- if !ok || !row.ChatPresentation().Hidden {
- t.Fatalf("%s must remain registered but hidden from chat", key)
+ for _, key := range []string{"practice_budget_usd", "practice_idle", "brief_after", "tenure_after"} {
+ if _, ok := r.Row(key); ok {
+ t.Fatalf("%s is still a settings row; nothing reads it", key)
+ }
+ if !retiredProfileKeys[key] {
+ t.Fatalf("%s is not retired, so a profile holding it would be told it is ignored", key)
}
}
for _, slot := range ModelSlots() {
diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go
index 97d5433adb..f18d51d60e 100644
--- a/internal/config/settings_test.go
+++ b/internal/config/settings_test.go
@@ -1,17 +1,15 @@
package config
import (
- "context"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
- "time"
+ "github.com/Agent-Field/codeaf/internal/automation"
"github.com/Agent-Field/codeaf/internal/pool/poolcfg"
- "github.com/Agent-Field/codeaf/internal/standing"
"github.com/Agent-Field/codeaf/internal/taxonomy"
)
@@ -241,8 +239,7 @@ func TestRegistryGroupsEveryCategoryAndEveryModelSlot(t *testing.T) {
func TestSettingsResolveEnvironmentThenFileThenDefault(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{
- "CODEAF_DAILY_BUDGET", "CODEAF_PRACTICE_BUDGET", "CODEAF_PRACTICE_IDLE",
- "CODEAF_BRIEF_AFTER", "CODEAF_TENURE_AFTER", "CODEAF_DOC_ENGINE", "CODEAF_VISION_MODEL",
+ "CODEAF_DAILY_BUDGET", "CODEAF_DOC_ENGINE", "CODEAF_VISION_MODEL",
} {
t.Setenv(name, "")
}
@@ -250,10 +247,6 @@ func TestSettingsResolveEnvironmentThenFileThenDefault(t *testing.T) {
rows := registry(t, dir)
defaults := map[string]string{
KeyDailyBudget: formatDollars(DefaultDailyBudgetUSD),
- KeyPracticeBudget: formatDollars(DefaultPracticeBudgetUSD),
- KeyPracticeIdle: "20m",
- KeyBriefAfter: "4h",
- KeyTenureAfter: "3",
KeyDocumentEngine: "auto",
KeyVisionModel: "automatic",
}
@@ -269,10 +262,6 @@ func TestSettingsResolveEnvironmentThenFileThenDefault(t *testing.T) {
changes := map[string]string{
KeyDailyBudget: "35.50",
- KeyPracticeBudget: "$4",
- KeyPracticeIdle: "45m",
- KeyBriefAfter: "90m",
- KeyTenureAfter: "5",
KeyDocumentEngine: "local",
KeyVisionModel: "seer/vision",
}
@@ -285,10 +274,6 @@ func TestSettingsResolveEnvironmentThenFileThenDefault(t *testing.T) {
reread := registry(t, dir)
persisted := map[string]string{
KeyDailyBudget: "$35.5",
- KeyPracticeBudget: "$4",
- KeyPracticeIdle: "45m",
- KeyBriefAfter: "1h30m",
- KeyTenureAfter: "5",
KeyDocumentEngine: "local",
KeyVisionModel: "seer/vision",
}
@@ -312,11 +297,10 @@ func TestSettingsResolveEnvironmentThenFileThenDefault(t *testing.T) {
}
t.Setenv("CODEAF_DOC_ENGINE", "ocr")
- t.Setenv("CODEAF_TENURE_AFTER", "9")
t.Setenv("CODEAF_VISION_MODEL", "pinned/vision")
pinned := registry(t, dir)
for key, want := range map[string]string{
- KeyDocumentEngine: "ocr", KeyTenureAfter: "9", KeyVisionModel: "pinned/vision",
+ KeyDocumentEngine: "ocr", KeyVisionModel: "pinned/vision",
} {
row, _ := pinned.Row(key)
if got := row.Value(); got != want {
@@ -338,7 +322,7 @@ func TestLoadReadsPersistedSettings(t *testing.T) {
dir := t.TempDir()
rows := registry(t, dir)
for key, raw := range map[string]string{
- KeyPracticeBudget: "6", KeyPracticeIdle: "5m", KeyBriefAfter: "30m",
+ KeyDailyBudget: "6",
KeyDocumentEngine: "free",
KeyVisionModel: "seer/vision",
} {
@@ -351,8 +335,7 @@ func TestLoadReadsPersistedSettings(t *testing.T) {
t.Setenv("CODEAF_BASE_URL", "http://127.0.0.1:1")
t.Setenv("CODEAF_PROFILE_DIR", dir)
for _, name := range []string{
- "CODEAF_PRACTICE_BUDGET", "CODEAF_PRACTICE_IDLE", "CODEAF_BRIEF_AFTER",
- "CODEAF_DOC_ENGINE", "CODEAF_VISION_MODEL",
+ "CODEAF_DAILY_BUDGET", "CODEAF_DOC_ENGINE", "CODEAF_VISION_MODEL",
} {
t.Setenv(name, "")
}
@@ -360,20 +343,19 @@ func TestLoadReadsPersistedSettings(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if loaded.PracticeBudgetUSD != 6 || loaded.PracticeIdle != 5*time.Minute ||
- loaded.BriefAfter != 30*time.Minute || loaded.DocumentEngine != "free" ||
+ if loaded.DailyBudgetUSD != 6 || loaded.DocumentEngine != "free" ||
loaded.VisionModel != "seer/vision" {
t.Fatalf("persisted settings did not reach Load: %+v", loaded)
}
// The environment still wins over everything written here.
t.Setenv("CODEAF_DOC_ENGINE", "ocr")
- t.Setenv("CODEAF_BRIEF_AFTER", "2h")
+ t.Setenv("CODEAF_DAILY_BUDGET", "2")
loaded, err = Load()
if err != nil {
t.Fatal(err)
}
- if loaded.DocumentEngine != "ocr" || loaded.BriefAfter != 2*time.Hour {
+ if loaded.DocumentEngine != "ocr" || loaded.DailyBudgetUSD != 2 {
t.Fatalf("environment lost to the persisted file: %+v", loaded)
}
}
@@ -382,8 +364,7 @@ func TestSettingEditorsRefuseNonsenseInPlainLanguage(t *testing.T) {
rows := registry(t, t.TempDir())
for key, raw := range map[string]string{
KeyDailyBudget: "twenty dollars",
- KeyBriefAfter: "soonish",
- KeyTenureAfter: "many",
+ KeyTaskParallel: "many",
KeyDocumentEngine: "tesseract",
} {
row, ok := rows.Row(key)
@@ -400,32 +381,6 @@ func TestSettingEditorsRefuseNonsenseInPlainLanguage(t *testing.T) {
}
}
-func TestTenurePersistsAndReachesTheProcessEnvironment(t *testing.T) {
- dir := t.TempDir()
- t.Setenv("CODEAF_TENURE_AFTER", "")
- rows := registry(t, dir)
- row, _ := rows.Row(KeyTenureAfter)
- if err := row.Apply("6"); err != nil {
- t.Fatal(err)
- }
- if got := os.Getenv("CODEAF_TENURE_AFTER"); got != "6" {
- t.Fatalf("tenure did not reach the running process: %q", got)
- }
- t.Setenv("CODEAF_TENURE_AFTER", "")
- if got := TenureAfterAt(dir); got != 6 {
- t.Fatalf("persisted tenure = %d", got)
- }
- InstallPersistedEnv(dir)
- if got := os.Getenv("CODEAF_TENURE_AFTER"); got != "6" {
- t.Fatalf("relaunch did not reinstall the persisted tenure: %q", got)
- }
- t.Setenv("CODEAF_TENURE_AFTER", "2")
- InstallPersistedEnv(dir)
- if got := os.Getenv("CODEAF_TENURE_AFTER"); got != "2" {
- t.Fatalf("a set environment was overwritten: %q", got)
- }
-}
-
// THE MODEL'S NAME IN THE `Assisted-by` LINE is on until someone says
// otherwise, off leaves the line bare, and the shell still wins. The signature
// itself is not a row at all.
@@ -895,57 +850,24 @@ func mustRow(t *testing.T, rows *Settings, key string) Setting {
return row
}
-// ── background checks ───────────────────────────────────────────────────────
-
-// THE ROW READS THE MACHINE AND NEVER THE FILE. What it shows is derived from
-// the timer's own definition on disk, so a person who removed the agent by hand
-// is told `off` in the one place they went to check — and turning the row is
-// what installs and removes it.
-// THE AUTO UPDATE ROW IS READ NEXT TO BACKGROUND CHECKS, on the same
-// machine-and-project reading and the same category: both answer what codeaf
-// does HERE on its own, rather than how the surface draws itself. It used to be
-// filed under the interface group and drawn on the Display tab, which is about
-// appearance and typing; a person keeping an installation current looks where
-// the machine's own work is kept.
-func TestTheAutoUpdateRowReadsBesideBackgroundChecks(t *testing.T) {
- dir := t.TempDir()
- home := t.TempDir()
- program := filepath.Join(t.TempDir(), "codeaf")
- if err := os.WriteFile(program, []byte("#!/bin/sh\n"), 0o755); err != nil {
- t.Fatalf("seed: %v", err)
- }
- timer, err := standing.NewWatch(standing.WatchOptions{
- Platform: "darwin", HomeDir: home, Executable: program, UID: 501,
- Runner: quietRunner{},
- })
- if err != nil {
- t.Fatalf("NewWatch: %v", err)
- }
- registry := NewSettings(SettingsOptions{ProfileDir: dir, BackgroundChecks: timer})
- updateAt, checksAt := -1, -1
- for at, row := range registry.Rows() {
- switch row.Key {
- case KeyUpdateAuto:
- updateAt = at
- if row.Category != CategoryPractice {
- t.Fatalf("auto update is filed under %q, want %q", row.Category, CategoryPractice)
- }
- case KeyStandingBackground:
- checksAt = at
- }
- }
- if updateAt < 0 || checksAt < 0 {
- t.Fatalf("both rows must be in the registry: auto update at %d, background checks at %d", updateAt, checksAt)
- }
- if updateAt+1 != checksAt {
- t.Fatalf("auto update stands at %d and background checks at %d, want them adjacent", updateAt, checksAt)
- }
- // THE FIRST SENTENCE SAYS WHAT THE ROW DOES, and the rest says what off
- // means: manual installation, while the one launch check still runs.
+// ── auto update ─────────────────────────────────────────────────────────────
+
+// THE AUTO UPDATE ROW IS FILED WITH WHAT THE MACHINE DOES ON ITS OWN, rather
+// than with how the surface draws itself. It used to be filed under the
+// interface group and drawn on the Display tab, which is about appearance and
+// typing; a person keeping an installation current looks where the machine's
+// own work is kept.
+func TestTheAutoUpdateRowIsFiledWithThePracticeRows(t *testing.T) {
+ registry := NewSettings(SettingsOptions{ProfileDir: t.TempDir()})
row, ok := registry.Row(KeyUpdateAuto)
if !ok {
t.Fatal("the registry has no auto update row")
}
+ if row.Category != CategoryPractice {
+ t.Fatalf("auto update is filed under %q, want %q", row.Category, CategoryPractice)
+ }
+ // THE FIRST SENTENCE SAYS WHAT THE ROW DOES, and the rest says what off
+ // means: manual installation, while the one launch check still runs.
for _, want := range []string{
"keeps this codeaf installation current in the background.",
"Off, installation is manual",
@@ -957,95 +879,6 @@ func TestTheAutoUpdateRowReadsBesideBackgroundChecks(t *testing.T) {
}
}
-func TestTheBackgroundChecksRowReadsTheTimerAndTurnsIt(t *testing.T) {
- dir := t.TempDir()
- home := t.TempDir()
- program := filepath.Join(t.TempDir(), "codeaf")
- if err := os.WriteFile(program, []byte("#!/bin/sh\n"), 0o755); err != nil {
- t.Fatalf("seed: %v", err)
- }
- timer, err := standing.NewWatch(standing.WatchOptions{
- Platform: "darwin", HomeDir: home, Executable: program, UID: 501,
- Runner: quietRunner{},
- })
- if err != nil {
- t.Fatalf("NewWatch: %v", err)
- }
- registry := NewSettings(SettingsOptions{ProfileDir: dir, BackgroundChecks: timer})
- row, ok := registry.Row(KeyStandingBackground)
- if !ok {
- t.Fatal("a machine with a timer has no background checks row")
- }
- if row.Category != CategoryPractice || row.Kind != SettingChoice {
- t.Fatalf("row = %+v", row)
- }
- // Nothing installed yet, so the row says so however the file reads.
- if row.Value() != BackgroundOff {
- t.Fatalf("an uninstalled timer reads %q", row.Value())
- }
- if err := row.Apply(BackgroundOn); err != nil {
- t.Fatalf("turning it on: %v", err)
- }
- if row.Value() != BackgroundOn {
- t.Fatalf("an installed timer reads %q", row.Value())
- }
- if BackgroundChecksAt(dir) != BackgroundOn {
- t.Fatalf("the intent on disk = %q", BackgroundChecksAt(dir))
- }
- if err := row.Apply(BackgroundOff); err != nil {
- t.Fatalf("turning it off: %v", err)
- }
- if row.Value() != BackgroundOff {
- t.Fatalf("a removed timer reads %q", row.Value())
- }
- if BackgroundChecksWantedAt(dir) {
- t.Fatal("the launch repair would put back a timer the person turned off")
- }
- // AND THE HINT NAMES THE THING IT INSTALLS. "codeaf installs a launchd
- // agent" is a sentence nobody can check.
- for _, want := range []string{standing.DarwinTickLabel, standing.LinuxTickTimer, standing.IntervalWords()} {
- if !strings.Contains(row.Hint, want) {
- t.Fatalf("the hint does not name %q: %s", want, row.Hint)
- }
- }
- // And a model may turn it: this is a preference, not a rail on the model.
- if !row.SelfService() {
- t.Fatal("the chat cannot turn off the background checks somebody asked it to")
- }
-}
-
-// A CAPABILITY THAT CANNOT WORK IS ABSENT, NOT BROKEN. On a machine with no
-// timer to install there is nothing for this switch to switch, so the sheet has
-// no row rather than a row that reads nothing and refuses every write.
-func TestTheBackgroundChecksRowIsAbsentWithNoTimer(t *testing.T) {
- registry := NewSettings(SettingsOptions{ProfileDir: t.TempDir()})
- if _, ok := registry.Row(KeyStandingBackground); ok {
- t.Fatal("a machine with no timer offered a switch for one")
- }
- for _, row := range registry.Rows() {
- if row.Key == KeyStandingBackground {
- t.Fatal("the row is in the sheet after all")
- }
- }
-}
-
-// A REPOSITORY MAY NOT TURN THIS ON. Installing a timer is a change to
-// somebody's machine, and a checked-in file that could make one is a clone
-// arranging to run a program on every laptop it lands on.
-func TestBackgroundChecksAreProfileOnly(t *testing.T) {
- for _, key := range ProjectKeys {
- if key == KeyStandingBackground {
- t.Fatal("a repository can install a timer on the reader's machine")
- }
- }
-}
-
-// quietRunner is this machine's scheduler, stood in for. Nothing in these tests
-// goes near launchctl.
-type quietRunner struct{}
-
-func (quietRunner) Run(context.Context, string, ...string) error { return nil }
-
// ── the retired task-start word ─────────────────────────────────────────────
// retiredTaskStartWord is the spelling a profile written before the ONE ROAD
@@ -1169,9 +1002,8 @@ func TestSpendRailsShipLargeEnoughNotToHinder(t *testing.T) {
}{
{"the daily rail", DefaultDailyBudgetUSD, 500},
{"the plan consent gate", DefaultPlanConsentUSD, 100},
- {"the practice carve-out", DefaultPracticeBudgetUSD, 50},
{"the lifted-tier cap", taxonomy.DefaultTierCapUSD, 25},
- {"a standing order's per-firing rail", standing.DefaultPerRunUSD, 5},
+ {"an automation's per-run rail", automation.DefaultUSD, 5},
}
for _, floor := range floors {
if floor.value < floor.floor {
@@ -1187,8 +1019,8 @@ func TestSpendRailsShipLargeEnoughNotToHinder(t *testing.T) {
}
}
-// TestEveryMoneyRowReadsItsNewDefaultAndSaysWhatZeroMeans walks the four rows a
-// person actually turns. For each it checks the shipped reading against the
+// TestEveryMoneyRowReadsItsNewDefaultAndSaysWhatZeroMeans walks the three rows
+// a person actually turns. For each it checks the shipped reading against the
// constant that owns it — one source of truth, so a raise that forgot the row
// fails here — and then writes 0 and reads the row back.
//
@@ -1199,7 +1031,7 @@ func TestSpendRailsShipLargeEnoughNotToHinder(t *testing.T) {
// already uses for its off state — and `$0` appears nowhere at all.
func TestEveryMoneyRowReadsItsNewDefaultAndSaysWhatZeroMeans(t *testing.T) {
for _, name := range []string{
- "CODEAF_DAILY_BUDGET", "CODEAF_PRACTICE_BUDGET", "CODEAF_PLAN_CONSENT",
+ "CODEAF_DAILY_BUDGET", "CODEAF_PLAN_CONSENT",
} {
t.Setenv(name, "")
}
@@ -1210,10 +1042,6 @@ func TestEveryMoneyRowReadsItsNewDefaultAndSaysWhatZeroMeans(t *testing.T) {
}{
{KeyDailyBudget, DefaultDailyBudgetUSD, noLimitWord},
{KeyPlanConsent, DefaultPlanConsentUSD, "never asks"},
- // The carve-out is the deliberate exception: zero switches practice off
- // rather than uncapping it, and the receipt has to say THAT and not
- // "no limit", or the row would be lying in the calmest possible voice.
- {KeyPracticeBudget, DefaultPracticeBudgetUSD, "practice off"},
{KeySpendRail, DefaultSpendRailUSD, noLimitWord},
}
for _, rail := range rails {
@@ -1332,7 +1160,6 @@ func TestTheThreeCategoriesSpendingLeftBehind(t *testing.T) {
want := map[string]string{
KeyDailyBudget: CategorySpending,
KeyPlanConsent: CategorySpending,
- KeyPracticeBudget: CategorySpending,
KeySpendRail: CategorySpending,
KeyToolApprovalMode: CategorySafety,
KeyGuardian: CategorySafety,
diff --git a/internal/e2e/automations_e2e_test.go b/internal/e2e/automations_e2e_test.go
new file mode 100644
index 0000000000..0c0a075edf
--- /dev/null
+++ b/internal/e2e/automations_e2e_test.go
@@ -0,0 +1,568 @@
+//go:build e2e
+
+package e2e
+
+// automations_e2e_test.go is AUTOMATIONS ON A REAL SCREEN: the real binary, a
+// real terminal, the real card, the real store and the real clock process
+// (docs/design/automations/DESIGN.md).
+//
+// IT REPLACED THREE SUBTESTS OF [TestTUIE2E] THAT WERE ABOUT STANDING ORDERS,
+// and it says which. `ask here` still exists, and an errand that asks for a
+// reminder now ends on an automation card (ask_here_end_to_end); a reminder now
+// runs from the clock a window keeps, not from a five-minute operating-system
+// timer (the_firing_reaches_the_person); and a narrow window still stacks the
+// exchange over the list (narrow_window_ask_here). Each of those waited for
+// standing words, and each is a subtest here that waits for the words the
+// surface says now.
+//
+// IT NEEDS NO KEY AND SPENDS NOTHING, which is what makes it a gate rather than
+// a drive. The one stand-in is the model: an errand's model is scripted to call
+// the `automation` tool with exactly one proposal (the [automationBrain] below),
+// because no real model can be asked to propose the same reminder twice — and a
+// reminder, once saved, needs no model at all: the clock says its line itself.
+// Everything between the keystroke and the screen is the product.
+//
+// go test -tags e2e -count=1 -run TestAutomationsE2E -v ./internal/e2e/
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/automation"
+ "github.com/Agent-Field/codeaf/internal/config"
+)
+
+// The scripted model's own words. They are the SCENARIO's and not the
+// surface's — what an automation is called and what it says are the model's
+// to choose, and the brain below chooses these — so they are spelled here and
+// not in tuiwords_test.go, whose table is a gate on what internal/tui3 spells.
+const (
+ automationStubModel = "stub/automations"
+ // autoAskWords is what the person types on home, verbatim.
+ autoAskWords = "remind me in 10 minutes to drink water"
+ // autoAskRow is how the exchange's row on home begins: the ask-here mark
+ // and the person's own words (homeexchange.go's exchangeRowLine).
+ autoAskRow = "? remind me in 10 minutes"
+ // autoTitle and autoSay are the proposal the brain makes: a reminder ten
+ // minutes out, so nothing in the ask-here scenario ever comes due.
+ autoTitle = "drink water"
+ autoSay = "Time to drink some water."
+ // autoReply is the one line the brain says once the card is answered.
+ autoReply = "Saved: a reminder in ten minutes."
+)
+
+// TestAutomationsE2E is the three scenarios. Each opens its own state root and
+// its own repository, and none of them needs a provider key.
+func TestAutomationsE2E(t *testing.T) {
+ if _, err := exec.LookPath("tmux"); err != nil {
+ t.Skip("no tmux on PATH: this suite drives the real binary in a real terminal")
+ }
+ t.Run("ask_here_saves_a_reminder", func(t *testing.T) { autosAskHere(t, tuiWide, 45) })
+ t.Run("narrow_window_ask_here", func(t *testing.T) { autosAskHere(t, 60, 30) })
+ t.Run("the_clock_says_a_reminder_into_its_conversation", autosClockSaysAReminder)
+}
+
+// ── ask here, and the card it ends on ─────────────────────────────────────────
+
+// autosAskHere is home's `ask here` asking for a reminder, end to end: the two
+// action rows, the exchange's own row and its three tails, the automation card
+// and its answers, the saved card read back, and the saved automation on home's
+// panel and on the automations place.
+//
+// THE EXCHANGE IS THE SCREEN WHILE IT HOLDS THE KEYBOARD, AT EVERY WIDTH (home's
+// homeStacked), and its row on the list is what `esc` puts back — so every tail
+// is read on the list after the keyboard has left the pane, and every word of
+// the pane is read while it holds it. At sixty cells the pane is the whole
+// screen, which the narrow run checks by the seeded conversation being gone
+// while the pane is up.
+//
+// AND AT SIXTY CELLS THE FOOT KEEPS LESS. The hint under the box is fitted to
+// the width and gives clauses up whole (hintFit): the card's answers stay and
+// `enter sends a follow-up` and the way back to the list go. So the narrow run
+// waits for what the pane itself draws and for the answers, and reads the
+// foot's other clauses only where a frame has room for them.
+func autosAskHere(t *testing.T, cols, rows int) {
+ wide := cols >= tuiPlain
+ brain := startAutomationBrain(t, true)
+ // THE APPROVAL POSTURE IS PINNED, because newHome copies whoever runs this
+ // suite's own profile: a profile that asks before every call would put a
+ // permission question in front of the card this scenario is about.
+ home := newHome(t, map[string]any{"model.talk": automationStubModel, "tools.approvalMode": "allow"})
+ // A conversation elsewhere on the machine is what makes the launch open on
+ // home rather than straight into a first conversation.
+ seedProject(t, home, "alpha", 0, time.Minute)
+ ws := newWorkspace(t, "autoaskws", false)
+ r := startWithEnv(t, brain.env(), "afe2e_autos_ask_"+strconv.Itoa(cols), home, ws, cols, rows,
+ "chat", "--one-model")
+ r.skipSetup(t)
+ r.waitFor(25*time.Second, say(t, "placeRestWord"))
+
+ // THE TWO ACTION ROWS, while something is typed: `ask here` directly over
+ // `start a new conversation`, and the cursor resting on the second.
+ r.lit(autoAskWords)
+ typed := r.waitFor(15*time.Second, say(t, "homeAskHereWord")+": ", say(t, "homeStartWord")+": ")
+ t.Logf("the action rows while typing:\n%s", typed)
+
+ // ONE ↑ IS THE ASK: the hint under the box says so before enter is pressed,
+ // which is how this suite knows the cursor is on the row it means.
+ r.keys("Up")
+ r.waitFor(10*time.Second, say(t, "homeAskHereHint"))
+ r.keys("Enter")
+
+ // THE PANE HOLDS THE KEYBOARD AND THE TURN HAS NOT ANSWERED YET. The brain
+ // holds its first answer until the scenario lets it go, so the strip's
+ // `thinking ·` is a fact to wait for here rather than a glimpse.
+ inPane := []string{say(t, "homeAskHereWord"), say(t, "homeAskThinkWord")}
+ if wide {
+ inPane = append(inPane, say(t, "exchangeFollowUp"), say(t, "exchangeBack"))
+ }
+ pane := r.waitFor(25*time.Second, inPane...)
+ t.Logf("the exchange took the screen and is thinking:\n%s", pane)
+ if !wide && strings.Contains(pane, "Seed Alpha") {
+ t.Errorf("the narrow exchange did not take the whole screen — the list is still drawn:\n%s", pane)
+ }
+
+ // THE TAILS ARE THE LIST'S. One esc over an empty follow-up box hands the
+ // keyboard back, and the errand is a row of its own wearing `working`.
+ r.keys("Escape")
+ working := waitForRow(t, r, 25*time.Second, autoAskRow, say(t, "homeAskWorkingWord"))
+ t.Logf("the exchange's row while its turn is in flight:\n%s", working)
+
+ // THE CARD ARRIVES and the row says it is waiting on somebody.
+ brain.release()
+ waiting := waitForRow(t, r, 30*time.Second, autoAskRow, say(t, "notifyAskWord"))
+ t.Logf("the card is up and the row says so:\n%s", waiting)
+
+ // AN EXCHANGE OUTLIVES THE SCREEN IT WAS ASKED ON. It is the window's and not
+ // home's ([app.exchanges]), so closing home with the card unanswered — one
+ // esc, the keyboard being on the list — and opening it again finds the row
+ // still there and still waiting on somebody.
+ r.keys("Escape")
+ if !waitUntil(15*time.Second, func() bool { return !strings.Contains(r.capture(), say(t, "placeRestWord")) }) {
+ t.Fatalf("esc with the keyboard on the list did not close home:\n%s", r.capture())
+ }
+ openHome(t, r)
+ again := waitForRow(t, r, 20*time.Second, autoAskRow, say(t, "notifyAskWord"))
+ t.Logf("home reopened and the exchange is still waiting on somebody:\n%s", again)
+
+ // ENTER ON THE ROW HANDS THE PANE THE KEYBOARD, with the card in it. The
+ // hint under the box is the oracle for where the cursor stands — the
+ // switcher's rows carry no lead of their own.
+ if !walkTo(r, say(t, "homeAnswerHint"), "Down") {
+ t.Fatalf("could not put the cursor on the exchange's row:\n%s", r.capture())
+ }
+ r.keys("Enter")
+ // THE ANSWERS ARE READ OFF THE FOOT, which builds them from the question's
+ // own options: a reminder offers no `2`, because there is nothing to try.
+ // They are the clauses the foot keeps longest, so they are there at sixty
+ // cells too.
+ onCard := []string{say(t, "autoRemindHead"), say(t, "exchangeAnswerHint")}
+ if wide {
+ onCard = append(onCard, say(t, "exchangeFollowUp"), say(t, "exchangeBack"))
+ }
+ card := r.waitFor(20*time.Second, onCard...)
+ t.Logf("the automation card, with the pane holding the keyboard:\n%s", card)
+ if !strings.Contains(card, autoTitle) {
+ t.Errorf("the card does not carry the proposal's own title %q:\n%s", autoTitle, card)
+ }
+
+ // A YES HANDS THE KEYBOARD BACK TO THE LIST BY ITSELF, so the row's tail is
+ // what says it landed. The digit waits out the question's settle first: a
+ // key that reaches a question in its first quarter-second on screen is
+ // dropped as aimed at the screen before it (question.go's questionSettle).
+ time.Sleep(questionSettlePause)
+ r.lit("1")
+ saved := waitForRow(t, r, 30*time.Second, autoAskRow, say(t, "homeAskSavedTail"))
+ t.Logf("answered `1` and the row says it saved:\n%s", saved)
+
+ // AND THE SAVED CARD IS ONE ENTER AWAY: the answer and its verdict on the
+ // card, and the pane's own note saying where the automation now lives.
+ r.keys("Enter")
+ settled := r.waitFor(20*time.Second, say(t, "homeAskSavedWord"), autoReply)
+ t.Logf("the saved exchange, reopened:\n%s", settled)
+ if answered := say(t, "autoSaveAnswer") + " · " + say(t, "autoSavedVerdict"); !strings.Contains(settled, answered) {
+ t.Errorf("the settled card does not carry %q:\n%s", answered, settled)
+ }
+
+ // esc puts the list back with the settled row on it.
+ r.keys("Escape")
+ waitForRow(t, r, 20*time.Second, autoAskRow, say(t, "homeAskSavedTail"))
+
+ // HOME'S AUTOMATIONS PANEL CARRIES IT where a frame has room for the panel:
+ // it is the first to give way on a short or narrow one (homegrid.go's order
+ // table), so it is read at the wide frame only. The title is read inside the
+ // panel's own columns, because the exchange's row quotes the same words.
+ if cols >= tuiWide {
+ openHome(t, r)
+ if !waitUntil(20*time.Second, func() bool {
+ return strings.Contains(panelBlock(r.capture(), say(t, "homePanelNext")), autoTitle)
+ }) {
+ t.Errorf("home's %q panel does not carry the saved %q:\n%s", say(t, "homePanelNext"), autoTitle, r.capture())
+ } else {
+ t.Logf("home's automations panel carries the saved reminder:\n%s", panelBlock(r.capture(), say(t, "homePanelNext")))
+ }
+ }
+
+ // AND THE AUTOMATIONS PLACE LISTS IT, by its title and its kind.
+ r.lit("/" + say(t, "homePanelNext"))
+ time.Sleep(700 * time.Millisecond)
+ r.keys("Enter")
+ listed := waitForRow(t, r, 20*time.Second, autoTitle, say(t, "autoKindReminderWord"))
+ t.Logf("the automations place lists the reminder:\n%s", listed)
+ r.quit()
+}
+
+// ── the clock ───────────────────────────────────────────────────────────────
+
+// autosClockSaysAReminder is a reminder run by the clock a window keeps, end
+// to end: typed with the exact grammar (no model in between), saved on its
+// card, run by `codeaf clock` while the window is open, and said as one line in
+// the conversation that made it, as a desktop notification, and as the row's
+// last result on the automations place. Then the window closes and the clock
+// leaves on its own.
+//
+// IT IS THE ONE SCENARIO THAT LETS THE CLOCK RUN, so it names the guard's
+// switch itself (`-u CODEAF_NO_AUTOMATIONS`), and it waits for the clock to be
+// gone before the state root it runs against is removed.
+//
+// AND IT IS THE PROOF THAT A LAUNCH SCHEDULES NOTHING ON THE MACHINE. Every
+// start of this binary takes the old timers off its login, and the guard's
+// login holds none, so the stand-ins for launchctl and systemctl must be asked
+// nothing at all — and the developer's own old-timer files are hashed around
+// the whole run.
+func autosClockSaysAReminder(t *testing.T) {
+ requireMachineTimerUntouched(t)
+ // NO MODEL IS ASKED ANYTHING HERE. The endpoint is there so that what a
+ // launch fetches — the catalog — reaches this test and not the network.
+ brain := startAutomationBrain(t, false)
+ home := newHome(t, map[string]any{"model.talk": automationStubModel})
+ ws := newWorkspace(t, "autoclockws", false)
+ root := filepath.Join(home, "v3", "automations")
+ // THE CLOCK IS DETACHED AND OUTLIVES THE TERMINAL by its grace, so it is
+ // waited out before the state root it runs against is removed. Registered
+ // here, before the rig, so it runs after the rig's own cleanup has closed
+ // the window — on a failure as much as on a pass.
+ t.Cleanup(func() {
+ if !waitUntil(90*time.Second, func() bool { return !automation.Held(root) }) {
+ t.Logf("the clock still held %s when the test's state root was removed", root)
+ }
+ })
+ env := append([]string{"-u", noClockEnv}, brain.env()...)
+ r := startWithEnv(t, env, "afe2e_autos_clock", home, ws, tuiWide, 40, "chat", "--one-model")
+ statesPastTheDoor(t, r)
+
+ // THE WINDOW KEEPS A CLOCK. Presence is taken as the surface starts, and a
+ // clock is started at once if none holds the lock.
+ if !waitUntil(30*time.Second, func() bool { return automation.Held(root) }) {
+ t.Fatalf("no clock holds %s while a window is open:\n%s", filepath.Join(root, automation.LockName), r.capture())
+ }
+
+ // THE TYPED DOOR: the exact grammar, read with no model in between, ending
+ // on the same card a conversation's proposal ends on.
+ const title, said = "water", "drink water"
+ r.lit(`/` + say(t, "homePanelNext") + ` add ` + title + ` in 30s say "` + said + `"`)
+ time.Sleep(700 * time.Millisecond)
+ r.keys("Enter")
+ card := r.waitFor(20*time.Second, say(t, "autoTypedHead"), said)
+ t.Logf("the typed automation's card:\n%s", card)
+ time.Sleep(questionSettlePause)
+ r.lit("1")
+ receipt := r.waitFor(20*time.Second, say(t, "autoTypedSavedWord")+title)
+ t.Logf("saved:\n%s", receipt)
+
+ // THE CLOCK SAYS IT. A reminder calls no model: the run records its own
+ // line, and the window that holds the conversation it was made in reads the
+ // run off the store and draws it as one dim line — the title, what it came
+ // to, and what it said.
+ line := title + " · " + say(t, "autoRunDoneWord") + " · " + said
+ ran := r.waitFor(90*time.Second, line)
+ t.Logf("the reminder's line in the conversation that made it:\n%s", ran)
+
+ // AND THE PERSON IS TOLD, through the operating system's own notifier —
+ // which the guard stands in for, so the banner is a line in its log and not
+ // a banner on the screen of whoever runs this suite.
+ notifier := "osascript"
+ if runtime.GOOS == "linux" {
+ notifier = "notify-send"
+ }
+ if runtime.GOOS == "darwin" || runtime.GOOS == "linux" {
+ raised := ""
+ if !waitUntil(20*time.Second, func() bool { raised = hostCall(t, r.host, notifier, title); return raised != "" }) {
+ t.Errorf("no %s notification named %q; the stand-ins were asked %v", notifier, title, r.host.calls(t))
+ } else {
+ t.Logf("the notification went to the guard's %s: %s", notifier, raised)
+ }
+ }
+
+ // THE PLACE SAYS HOW IT LAST WENT.
+ r.lit("/" + say(t, "homePanelNext"))
+ time.Sleep(700 * time.Millisecond)
+ r.keys("Enter")
+ listed := waitForRow(t, r, 20*time.Second, title, say(t, "autoKindReminderWord"), say(t, "autoLastWord")+say(t, "autoRunDoneWord"))
+ t.Logf("the automations place after the run:\n%s", listed)
+
+ // NOTHING WAS SCHEDULED ON THE MACHINE: not by the window, not by the
+ // clock, not by the old-timer removal every start runs.
+ for _, call := range r.host.calls(t) {
+ for _, scheduler := range []string{"launchctl ", "systemctl ", "crontab "} {
+ if strings.HasPrefix(call, scheduler) {
+ t.Errorf("a launch asked the machine's scheduler %q", call)
+ }
+ }
+ }
+
+ // AND THE CLOCK LEAVES WHEN THE LAST WINDOW HAS BEEN SHUT FOR ITS GRACE.
+ // It is detached, so killing the terminal does not kill it; it counts the
+ // windows, finds none for thirty seconds, and goes.
+ r.quit()
+ if !waitUntil(90*time.Second, func() bool { return !automation.Held(root) }) {
+ t.Errorf("the clock was still holding %s ninety seconds after the last window closed", root)
+ }
+}
+
+// ── reading the screen ──────────────────────────────────────────────────────
+
+// questionSettlePause is how long a scenario lets a question stand on the
+// screen before it presses an answer. internal/tui3 DROPS a key that reaches a
+// question in its first quarter-second on screen (question.go's
+// questionSettle), as a key aimed at the screen that was there before it; a
+// wait that saw the card arrive can be inside that quarter-second, so the press
+// waits a whole second more. It is a floor under a fact, not a guess at a
+// duration: nothing is waited out but the product's own stated guard.
+const questionSettlePause = time.Second
+
+// waitForRow polls until ONE LINE of the screen carries every part, and
+// answers that line. A screen-wide search is satisfied by a title on one row
+// and a tail on another — `saved` is on the pane's note as well as on the
+// row — so a claim about one row is read off one row.
+func waitForRow(t *testing.T, r *rig, within time.Duration, parts ...string) string {
+ t.Helper()
+ deadline := time.Now().Add(within)
+ for {
+ screen := r.capture()
+ for _, line := range strings.Split(screen, "\n") {
+ whole := true
+ for _, part := range parts {
+ if !strings.Contains(line, part) {
+ whole = false
+ break
+ }
+ }
+ if whole {
+ return strings.TrimSpace(line)
+ }
+ }
+ if time.Now().After(deadline) {
+ t.Errorf("waited %s for one line carrying %q and saw none. the screen was:\n%s", within, parts, screen)
+ return ""
+ }
+ time.Sleep(pollEvery)
+ }
+}
+
+// waitUntil polls a fact until it is true or the time is up.
+func waitUntil(within time.Duration, fact func() bool) bool {
+ deadline := time.Now().Add(within)
+ for {
+ if fact() {
+ return true
+ }
+ if time.Now().After(deadline) {
+ return false
+ }
+ time.Sleep(pollEvery)
+ }
+}
+
+// hostCall is the first thing a stand-in was asked that names what, or "".
+func hostCall(t *testing.T, g hostGuard, standIn, what string) string {
+ t.Helper()
+ for _, call := range g.calls(t) {
+ if strings.HasPrefix(call, standIn+" ") && strings.Contains(call, what) {
+ return call
+ }
+ }
+ return ""
+}
+
+// ── the scripted model ──────────────────────────────────────────────────────
+
+// automationBrain stands in for every model call a scenario makes. A request
+// that offers the `automation` tool and has no tool result in it yet is
+// answered with ONE proposal; the same conversation's next request, carrying
+// the card's answer as the tool's result, is answered with one line; anything
+// else — a title, a summary — is answered with a word.
+type automationBrain struct {
+ server *httptest.Server
+ // hold, when the scenario asked for one, keeps the proposal back until
+ // [automationBrain.release], so a turn that has not answered yet is a state
+ // the screen can be read in rather than a moment that may already be over.
+ hold chan struct{}
+ once sync.Once
+}
+
+func startAutomationBrain(t *testing.T, hold bool) *automationBrain {
+ t.Helper()
+ brain := &automationBrain{}
+ if hold {
+ brain.hold = make(chan struct{})
+ }
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/v1/models", brain.serveCatalog)
+ mux.HandleFunc("POST /api/v1/chat/completions", brain.serveCompletion)
+ brain.server = httptest.NewServer(mux)
+ t.Cleanup(func() {
+ brain.release()
+ brain.server.Close()
+ })
+ return brain
+}
+
+// env is what a rig needs to talk to this endpoint and nothing else. The key
+// is a string and not a secret: config.Load refuses to build a session with
+// none at all, and nothing behind this endpoint checks it.
+func (b *automationBrain) env() []string {
+ return []string{config.APIKeyEnv + "=stub-key", "CODEAF_BASE_URL=" + b.server.URL + "/api/v1", "CODEAF_PROFILE_DIR="}
+}
+
+// release lets a held proposal through. It is idempotent.
+func (b *automationBrain) release() {
+ b.once.Do(func() {
+ if b.hold != nil {
+ close(b.hold)
+ }
+ })
+}
+
+func (b *automationBrain) serveCatalog(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = fmt.Fprintf(w, `{"data":[{"id":%q,"canonical_slug":%q,"name":"Scripted automations stub",
+ "context_length":200000,
+ "architecture":{"input_modalities":["text"],"output_modalities":["text"]},
+ "pricing":{"prompt":"0","completion":"0","request":"0","input_cache_read":"0"},
+ "supported_parameters":["tools","tool_choice","max_tokens"]}]}`, automationStubModel, automationStubModel)
+}
+
+func (b *automationBrain) serveCompletion(w http.ResponseWriter, r *http.Request) {
+ raw, _ := io.ReadAll(r.Body)
+ var body struct {
+ Messages []struct {
+ Role string `json:"role"`
+ } `json:"messages"`
+ Tools []struct {
+ Function struct {
+ Name string `json:"name"`
+ } `json:"function"`
+ } `json:"tools"`
+ }
+ _ = json.Unmarshal(raw, &body)
+ answered := false
+ for _, message := range body.Messages {
+ if strings.EqualFold(message.Role, "tool") {
+ answered = true
+ }
+ }
+ offered := false
+ for _, tool := range body.Tools {
+ if tool.Function.Name == "automation" {
+ offered = true
+ }
+ }
+ send, ok := automationStream(w)
+ if !ok {
+ return
+ }
+ switch {
+ case offered && !answered:
+ // A HELD ANSWER STILL KEEPS THE LINE ALIVE. A stream that says nothing
+ // at all for ten seconds is read as a dead path and asked again
+ // (internal/provider's armwatch.go), so the hold writes the same comment
+ // a real router writes while it is busy, once a second.
+ if b.hold != nil {
+ beat := time.NewTicker(time.Second)
+ defer beat.Stop()
+ stop := time.After(45 * time.Second)
+ held:
+ for {
+ select {
+ case <-b.hold:
+ break held
+ case <-stop:
+ break held
+ case <-r.Context().Done():
+ return
+ case <-beat.C:
+ keepalive(w)
+ }
+ }
+ }
+ args, _ := json.Marshal(map[string]any{
+ "op": "propose", "title": autoTitle, "words": autoAskWords,
+ "when": map[string]string{"in": "10m"}, "say": autoSay,
+ })
+ quoted, _ := json.Marshal(string(args))
+ send(`{"tool_calls":[{"index":0,"id":"call_auto","type":"function","function":{"name":"automation","arguments":`+string(quoted)+`}}]}`, "")
+ send(`{}`, "tool_calls")
+ case offered:
+ quoted, _ := json.Marshal(autoReply)
+ send(`{"content":`+string(quoted)+`}`, "")
+ send(`{}`, "stop")
+ default:
+ send(`{"content":"ok"}`, "")
+ send(`{}`, "stop")
+ }
+ send("", "[DONE]")
+}
+
+// keepalive writes one SSE comment, the line a router sends while it is still
+// working on an answer (internal/provider's sse.go reads it as the stream being
+// alive and as nothing else).
+func keepalive(w http.ResponseWriter) {
+ _, _ = io.WriteString(w, ": OPENROUTER PROCESSING\n\n")
+ if flusher, ok := w.(http.Flusher); ok {
+ flusher.Flush()
+ }
+}
+
+// automationStream writes a stream's head and hands back its frame writer —
+// internal/provider's sse.go read backwards, as [checkerBrain.open] does.
+func automationStream(w http.ResponseWriter) (func(delta, reason string), bool) {
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ http.Error(w, "the script needs a flushable writer", http.StatusInternalServerError)
+ return nil, false
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.WriteHeader(http.StatusOK)
+ id := fmt.Sprintf("auto-%d", time.Now().UnixNano())
+ send := func(delta, reason string) {
+ if reason == "[DONE]" {
+ _, _ = fmt.Fprint(w, "data: [DONE]\n\n")
+ flusher.Flush()
+ return
+ }
+ finish := "null"
+ if reason != "" {
+ finish = strconv.Quote(reason)
+ }
+ _, _ = fmt.Fprintf(w, "data: {\"id\":%q,\"object\":\"chat.completion.chunk\",\"created\":%d,"+
+ "\"model\":%q,\"provider\":\"stub\",\"choices\":[{\"index\":0,\"delta\":%s,\"finish_reason\":%s}],"+
+ "\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2,\"cost\":0}}\n\n",
+ id, time.Now().Unix(), automationStubModel, delta, finish)
+ flusher.Flush()
+ }
+ send(`{"role":"assistant","content":""}`, "")
+ return send, true
+}
diff --git a/internal/e2e/contracts_e2e_test.go b/internal/e2e/contracts_e2e_test.go
index 6fac93f60b..3c65b44ff9 100644
--- a/internal/e2e/contracts_e2e_test.go
+++ b/internal/e2e/contracts_e2e_test.go
@@ -166,7 +166,7 @@ func runWorktreeContract(t *testing.T, w *world, attempt int) *contractAttempt {
defer source.stop()
started := time.Now()
- said := w.say(agent, widgetAsk(ground), answerYes)
+ said := w.say(agent, widgetAsk(ground))
t.Logf("the chat called: %v", said.names())
run := &familyRun{t: t, w: w, agent: agent, place: place, ground: ground}
diff --git a/internal/e2e/conversation_search_e2e_test.go b/internal/e2e/conversation_search_e2e_test.go
index 6f035f48dd..31739e634d 100644
--- a/internal/e2e/conversation_search_e2e_test.go
+++ b/internal/e2e/conversation_search_e2e_test.go
@@ -68,7 +68,7 @@ func TestConversationSearchLive(t *testing.T) {
if agent.Model() != e2eModel {
t.Fatalf("wrong model: %s", agent.Model())
}
- out := w.say(agent, sc.ask, answerNo)
+ out := w.say(agent, sc.ask)
searches := 0
queries := 0
evidence := ""
diff --git a/internal/e2e/families_e2e_test.go b/internal/e2e/families_e2e_test.go
index d5020839b5..8686bfef4d 100644
--- a/internal/e2e/families_e2e_test.go
+++ b/internal/e2e/families_e2e_test.go
@@ -140,9 +140,9 @@ var textRoles = func() []roles.Role {
func familyConfig(w *world) func(*session.Config) {
profile := w.settings.ProfileDir
return func(cfg *session.Config) {
- // THE DIVISION ROAD ITSELF. Nil here is the whole feature off — no
+ // THE DIVISION ROAD ITSELF. Off here is the whole feature off — no
// divide_work on any worker's belt — so every scenario below depends on
- // this line, exactly as the ambient lane depends on Standing.
+ // this line.
cfg.Divide = true
cfg.TaskModel = config.TaskModelAt(profile)
cfg.TaskParallel = config.TaskParallelAt(profile)
@@ -162,10 +162,10 @@ func familyConfig(w *world) func(*session.Config) {
// isolation or landing. Repair rounds go with it for the same reason.
cfg.TaskAudit = false
cfg.TaskRepairRounds = 0
- // AND THE WORKERS MAY WRITE. The ambient lane's policy allows the
- // reading tools only, which is right for a conversation that reads and
- // remembers; a task worker writes files and runs git, and a node's own
- // consent question reaches no drain loop out here.
+ // AND THE WORKERS MAY WRITE. A conversation's own policy ([world.policy])
+ // allows the reading tools only, which is right for a conversation that
+ // reads and remembers; a task worker writes files and runs git, and a
+ // node's own consent question reaches no drain loop out here.
policy, err := approval.Load(map[string]any{"default": "allow"})
if err != nil {
panic("approval.Load: " + err.Error())
@@ -527,16 +527,16 @@ func TestFamilies(t *testing.T) {
t.Setenv("CODEAF_SPLITGATE", "0")
t.Run("a three-section report on a folder ground", func(t *testing.T) {
- threeSectionsOnAFolder(t, &world{t: t, home: w.home, settings: w.settings, store: w.store})
+ threeSectionsOnAFolder(t, &world{t: t, home: w.home, settings: w.settings})
})
t.Run("a two-part write-up on a repository ground", func(t *testing.T) {
- twoPartsOnARepository(t, &world{t: t, home: w.home, settings: w.settings, store: w.store})
+ twoPartsOnARepository(t, &world{t: t, home: w.home, settings: w.settings})
})
t.Run("two parts that claim one file", func(t *testing.T) {
- oneFileClaimedTwice(t, &world{t: t, home: w.home, settings: w.settings, store: w.store})
+ oneFileClaimedTwice(t, &world{t: t, home: w.home, settings: w.settings})
})
t.Run("an edit made in the folder while it ran", func(t *testing.T) {
- anEditMadeWhileItRan(t, &world{t: t, home: w.home, settings: w.settings, store: w.store})
+ anEditMadeWhileItRan(t, &world{t: t, home: w.home, settings: w.settings})
})
}
diff --git a/internal/e2e/harness_test.go b/internal/e2e/harness_test.go
index b9b3b0956f..aeefbf4f5e 100644
--- a/internal/e2e/harness_test.go
+++ b/internal/e2e/harness_test.go
@@ -1,28 +1,30 @@
//go:build e2e
-// Package e2e drives the ambient side — internal/session's `stand` tool and
-// internal/standing's store, ticker, sentinel and runner — against a REAL
-// model, assembled the way cmd/codeaf assembles it.
+// Package e2e drives the engine — internal/session's Agent and the work it
+// hands out — against a REAL model, assembled the way cmd/codeaf assembles it.
//
// WHY IT IS A PACKAGE OF ITS OWN AND NOT ANOTHER FILE IN internal/session.
// Everything below drives the engine through the doors a surface has: New,
-// Submit, ResolveStanding, Wakes, Transcript, Close. Nothing here touches an
+// Submit, StartTask, ResolveConsent, Transcript, Close. Nothing here touches an
// unexported field, so a scenario that passes here is a scenario the product
// can actually reach — which is the whole point of an end-to-end lane, and the
// one thing an in-package test cannot promise.
//
+// The lanes that run on this file's [world] are the manual (manual_e2e_test.go
+// and manual_wire_test.go), conversation search, task families, the worktree
+// contract, custody and the parked job. The tmux suite beside them drives the
+// built binary instead and has its own fixture (tmux_test.go).
+//
// WHAT IT COSTS. Every turn rides deepseek/deepseek-v4-flash through
-// OpenRouter, and so does the sentinel: the throwaway profile below pins the
-// LOW tier to the same model, so a run of the whole file is a few cents. The
-// build tag keeps it out of `go test ./...` and the t.Skip keeps it out of a
-// machine with no key.
+// OpenRouter: the throwaway profile below pins the talk row and both auxiliary
+// tiers to the same model, so a lane is a few cents. The build tag keeps it
+// out of `go test ./...` and the key gate keeps it out of a machine with none.
//
-// go test -tags e2e -run TestStandingE2E -v -timeout 30m ./internal/e2e/
+// go test -tags e2e -run TestManual -v -timeout 30m ./internal/e2e/
package e2e
import (
"context"
- "encoding/json"
"os"
"path/filepath"
"strings"
@@ -35,7 +37,6 @@ import (
"github.com/Agent-Field/codeaf/internal/home"
"github.com/Agent-Field/codeaf/internal/roles"
"github.com/Agent-Field/codeaf/internal/session"
- "github.com/Agent-Field/codeaf/internal/standing"
)
// e2eModel is the model the person asked for. The transcripts spell it
@@ -60,11 +61,11 @@ func liveVerificationModel() string {
// because it holds the person's key.
//
// IT IS RESOLVED, NOT SPELLED. It was a constant naming one machine's home
-// directory, and on every other machine the whole lane — families, standing,
-// the phase clock — skipped with "no provider credentials", which reads as a
-// key that was never set rather than as a path that was never yours. Resolving
-// through [home.InheritedDir] honours the same override the binary does, so a
-// person who runs codeaf out of CODEAF_HOME runs this lane out of it too.
+// directory, and on every other machine every lane skipped with "no provider
+// credentials", which reads as a key that was never set rather than as a path
+// that was never yours. Resolving through [home.InheritedDir] honours the same
+// override the binary does, so a person who runs codeaf out of CODEAF_HOME
+// runs this lane out of it too.
//
// It is the INHERITED root and not [home.Dir], which hands a test binary a
// throwaway root of its own so that a suite cannot write into a person's state
@@ -79,22 +80,25 @@ func personConfig() string {
// ── the throwaway machine ───────────────────────────────────────────────────
// world is one disposable codeaf home with the person's provider credentials
-// in it: the settings the door loads, the standing store the door opens, and
-// the roles source the door resolves auxiliary models through.
+// in it: the settings the door loads and the roles source the door resolves
+// auxiliary models through.
+//
+// IT KEEPS NO AMBIENT STORE. It held the standing orders' store until
+// automations replaced them, and no lane on it is about automations: those are
+// read off a real screen, store and clock in automations_e2e_test.go.
type world struct {
t *testing.T
home string
settings config.Config
- store *standing.Store
- // spent is what every agent and every sentinel call in this run has cost,
- // summed by [world.bill] so the report can quote one figure.
+ // spent is what every agent in this run has cost, summed by [world.bill]
+ // so the report can quote one figure.
mu sync.Mutex
spent float64
}
// newWorld builds it. CODEAF_HOME is the one seam that moves every path
-// (internal/home), so the store, the profile and the artifacts index all land
-// under a directory the test owns.
+// (internal/home), so the profile, the projects and the artifacts index all
+// land under a directory the test owns.
func newWorld(t *testing.T) *world {
t.Helper()
// THE KEY IS RESOLVED THE WAY THE PRODUCT RESOLVES IT (#576): the two
@@ -131,9 +135,9 @@ func newWorld(t *testing.T) *world {
// The model, chosen the way a person chooses one: the model.talk row, which
// is what cmd/codeaf's v3TalkModel reads before it falls back to the
- // environment. The LOW tier is pinned to the same model so the sentinel —
- // registered at roles.TierLow in standing_run.go — rides the model this run
- // is about rather than whatever the person happens to have there.
+ // environment. The LOW and HIGH tiers are pinned to the same model, so every
+ // auxiliary call a lane's turn makes rides the model this run is about
+ // rather than whatever the person happens to have there.
// The talk row goes through its own writer: the registry's model slots
// refuse without a live SetModel seam, because changing the model a RUNNING
// conversation rides is /model's job and not a settings write.
@@ -177,12 +181,8 @@ func newWorld(t *testing.T) *world {
if chosen := config.ChatModelAt(settings.ProfileDir); chosen != e2eModel {
t.Fatalf("the door would open on %q, not %q", chosen, e2eModel)
}
- store, err := standing.Open(home.Join("v3", "standing"))
- if err != nil {
- t.Fatalf("standing.Open: %v", err)
- }
- t.Logf("CODEAF_HOME=%s model=%s standing root=%s", dir, e2eModel, store.Root())
- return &world{t: t, home: dir, settings: settings, store: store}
+ t.Logf("CODEAF_HOME=%s model=%s", dir, e2eModel)
+ return &world{t: t, home: dir, settings: settings}
}
// bill adds one agent's spend to the run's total.
@@ -274,11 +274,18 @@ func (w *world) place(bucket, workspace string) session.Place {
return place
}
-// projectBucket is where an ordinary conversation of one workspace lives. The
-// exact spelling of the bucket name is cmd/codeaf's business and nothing here
-// reads it back, so a stable one-way key is enough.
+// projectBucket is where an ordinary conversation of one workspace lives: the
+// projects root, and the workspace with its separators turned to dashes —
+// cmd/codeaf's encodeWorkspace, spelled again here because that one is in
+// package main. Nothing in this package reads the name back; it is the
+// product's so that the throwaway home is laid out like a real one.
func (w *world) projectBucket(workspace string) string {
- bucket := filepath.Join(home.Join("v3", "projects"), standing.ProjectKey(workspace))
+ key := strings.ReplaceAll(filepath.Clean(strings.TrimSpace(workspace)), string(filepath.Separator), "-")
+ key = strings.ReplaceAll(key, ":", "-")
+ if !strings.HasPrefix(key, "-") {
+ key = "-" + key
+ }
+ bucket := filepath.Join(home.Join("v3", "projects"), key)
if err := os.MkdirAll(bucket, 0o700); err != nil {
w.t.Fatalf("make a project bucket: %v", err)
}
@@ -287,13 +294,13 @@ func (w *world) projectBucket(workspace string) string {
// conversationConfig is one live conversation, assembled the way openV3Launch
// plus applyV3Governance assemble one: the person's models and keys, their
-// approval rules, their roles, the ambient seam, and AskConsent — the door's
-// own fact that somebody is watching.
+// approval rules, their roles, and AskConsent — the door's own fact that
+// somebody is watching.
+//
+// THE AUTOMATIONS SEAM IS LEFT OFF. Nil is no `automation` tool on the belt,
+// and nothing a lane on this file asks is about one; a scenario that wanted the
+// card would set the seam through its own mutate and answer the card itself.
func (w *world) conversationConfig(workspace string, place session.Place) session.Config {
- rail, err := config.DailyBudgetUSDAt(w.settings.ProfileDir)
- if err != nil || rail < 0 {
- rail = 0
- }
return session.Config{
Workspace: workspace,
Model: config.ChatModelAt(w.settings.ProfileDir),
@@ -307,26 +314,22 @@ func (w *world) conversationConfig(workspace string, place session.Place) sessio
ProfileDir: w.settings.ProfileDir,
ApprovalPolicy: w.policy(workspace),
RolesSource: w.rolesSource(workspace),
- // THE AMBIENT SEAM. Nil here is the whole feature off — no `stand` on
- // the belt — so every scenario below depends on this line.
- Standing: &session.Standing{Store: w.store, DailyRailUSD: rail},
// Somebody is watching: this is what makes a card a question rather
- // than a refusal (tools_standing.go's askStanding).
+ // than a refusal.
AskConsent: true,
}
}
// open builds one live conversation in a fresh folder of the project's bucket,
-// and answers the folder too: the folder's name IS the session id every firing
-// is addressed to (place.go, and openSessionFile's header), which is the only
-// way a test outside this package can name a conversation.
+// and answers the folder too: the folder's name IS the session id (place.go,
+// and openSessionFile's header), which is the only way a test outside this
+// package can name a conversation.
func (w *world) open(workspace string, mutate func(*session.Config)) (*session.Agent, session.Place) {
place := w.place(w.projectBucket(workspace), workspace)
return w.openAt(workspace, place, mutate), place
}
// openAt builds one live conversation on a folder the caller already made —
-// what home's `ask here` does, whose folder is under the standing root, and
// what reopening a conversation does.
func (w *world) openAt(workspace string, place session.Place, mutate func(*session.Config)) *session.Agent {
t := w.t
@@ -347,74 +350,6 @@ func (w *world) openAt(workspace string, place session.Place, mutate func(*sessi
return agent
}
-// ── the ticker, as the door builds one ──────────────────────────────────────
-
-// posture mirrors cmd/codeaf's v3StandingPosture: the person's models, keys and
-// banked rules, resolved against their HOME rather than any project, with
-// AskConsent false and Standing nil — nobody is watching a firing and nothing
-// that fires may arm anything else.
-func (w *world) posture() session.Config {
- root := w.home
- return session.Config{
- Workspace: root,
- Model: config.ChatModelAt(w.settings.ProfileDir),
- APIKey: w.settings.APIKey,
- BaseURL: w.settings.BaseURL,
- CompactEnabled: true,
- ProfileDir: w.settings.ProfileDir,
- ArtifactsIndex: home.Join("v3", "artifacts.jsonl"),
- ApprovalPolicy: w.policy(root),
- RolesSource: w.rolesSource(root),
- }
-}
-
-// ticker mirrors cmd/codeaf's v3StandingTicker: one store, one sentinel, one
-// runner, one idle reader. now is the clock a test holds still or pushes past a
-// due moment.
-func (w *world) ticker(now func() time.Time) *standing.Ticker {
- posture := w.posture()
- rail, err := config.DailyBudgetUSDAt(w.settings.ProfileDir)
- if err != nil || rail < 0 {
- rail = 0
- }
- return &standing.Ticker{
- Store: w.store,
- Sentinel: w.billedSentinel(session.NewStandingSentinel(posture)),
- Runner: session.NewStandingRunner(posture, w.store.Root()),
- Idle: session.StandingIdle(),
- DailyRailUSD: rail,
- Now: now,
- }
-}
-
-// billedSentinel is the sentinel with a log line around it, so the report can
-// quote what the judgment actually said and what it cost.
-func (w *world) billedSentinel(inner standing.Sentinel) standing.Sentinel {
- return func(ctx context.Context, judgment standing.Judgment) (bool, string, float64, error) {
- yes, line, usd, err := inner(ctx, judgment)
- w.t.Logf("SENTINEL %q → yes=%v line=%q err=%v", shorten(judgment.Item.Words, 60), yes, line, err)
- if usd > 0 {
- w.bill("sentinel", usd)
- }
- return yes, line, usd, err
- }
-}
-
-// tick runs one pass at a given moment and logs what it decided.
-func (w *world) tick(at time.Time) standing.Pass {
- t := w.t
- ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
- defer cancel()
- pass, err := w.ticker(func() time.Time { return at }).Tick(ctx)
- if err != nil {
- t.Fatalf("tick at %s: %v", at.Format(time.RFC3339), err)
- }
- t.Logf("TICK at %s → examined=%d checked=%d fired=%d said=%d needs=%d skipped=%d errors=%d notes=%v",
- at.Format("15:04:05"), pass.Examined, pass.Checked, pass.Fired, pass.Said,
- pass.NeedsYou, pass.Skipped, pass.Errors, pass.Notes)
- return pass
-}
-
// ── driving a turn ──────────────────────────────────────────────────────────
// call is one tool call as a surface saw it.
@@ -427,21 +362,9 @@ type call struct {
// turn is everything one turn put in front of a person.
type turn struct {
- Reply string
- Calls []call
- Proposals []session.StandingNotice
- Updates []session.StandingNotice
- Err error
-}
-
-// stood answers the tool call named, or the zero value.
-func (r turn) named(name string) (call, bool) {
- for _, one := range r.Calls {
- if one.Name == name {
- return one, true
- }
- }
- return call{}, false
+ Reply string
+ Calls []call
+ Err error
}
// names is the call order, for a log line and for "did it shell out first".
@@ -453,22 +376,9 @@ func (r turn) names() []string {
return out
}
-// answerYes is the surface pressing yes on every card it is shown.
-func answerYes(session.StandingNotice) session.StandingAnswer {
- return session.StandingAnswer{Approved: true}
-}
-
-// answerNo is what a WOKEN turn's cards get. A turn the session started on its
-// own is a firing being read out, and nobody asked for a second standing thing:
-// a surface that said yes there would arm a duplicate off a line the model
-// misread. Saying no is what a person would do, and it keeps the store's
-// contents a fact the scenario can still assert on.
-func answerNo(session.StandingNotice) session.StandingAnswer {
- return session.StandingAnswer{Approved: false}
-}
-
-// say drives one turn: submit, answer whatever card comes up, log everything.
-func (w *world) say(agent *session.Agent, text string, answer func(session.StandingNotice) session.StandingAnswer) turn {
+// say drives one turn: submit, answer whatever question comes up, log
+// everything.
+func (w *world) say(agent *session.Agent, text string) turn {
t := w.t
t.Logf("YOU → %s", text)
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
@@ -477,12 +387,17 @@ func (w *world) say(agent *session.Agent, text string, answer func(session.Stand
if err != nil {
t.Fatalf("submit %q: %v", text, err)
}
- return w.drain(agent, "turn", events, answer)
+ return w.drain(agent, "turn", events)
}
-// drain reads one event stream to its end. It is shared by a submitted turn and
-// by a WOKEN one ([Agent.Wakes]), because the two are the same shape.
-func (w *world) drain(agent *session.Agent, what string, events <-chan session.Event, answer func(session.StandingNotice) session.StandingAnswer) turn {
+// drain reads one event stream to its end.
+//
+// NOBODY IS AT A KEYBOARD, AND A QUESTION NOBODY ANSWERS IS A HANG. So every
+// question a turn raises is answered here, the way the person's own rules
+// already would: a consent request is allowed, and an automation card — which
+// no conversation on this file has the seam to raise unless a scenario gave it
+// one — saves nothing.
+func (w *world) drain(agent *session.Agent, what string, events <-chan session.Event) turn {
t := w.t
var out turn
var reply strings.Builder
@@ -497,27 +412,15 @@ func (w *world) drain(agent *session.Agent, what string, events <-chan session.E
out.Calls = append(out.Calls, call{Name: event.Tool, Args: event.Args, Output: event.Output, Failed: true})
t.Logf(" CALL(failed) %s %s\n → %s", event.Tool, shorten(event.Args, 400), shorten(event.Output, 600))
case session.EventConsentRequest:
- // Nobody is at a keyboard, and a question nobody answers is a hang.
// The person's own rules allow everything this lane does, so this is
// belt and braces rather than a policy the test invented.
t.Logf(" CONSENT asked about %s (%s) — allowing", event.Tool, event.Rule)
agent.ResolveConsent(event.ID, true)
- case session.EventStandingProposal:
- notice := *event.Standing
- out.Proposals = append(out.Proposals, notice)
- t.Logf(" CARD id=%d words=%q when=%q cost=%q guessed=%v kind=%s does=%s rails=%.2f/%d",
- notice.ID, notice.Item.Words, notice.WhenWords, notice.CostWords, notice.Guessed,
- notice.Item.When.Kind, notice.Item.Does.Kind,
- notice.Item.Rails.PerRunUSD, notice.Item.Rails.MaxPerDay)
- reply := session.StandingAnswer{Approved: true}
- if answer != nil {
- reply = answer(notice)
+ case session.EventAutomationProposal:
+ if card := event.Automation; card != nil {
+ t.Logf(" AUTOMATION card id=%d %q — saving nothing", card.ID, card.Automation.Title)
+ agent.ResolveAutomation(card.ID, session.AutomationAnswer{})
}
- t.Logf(" CARD answered approved=%v once=%v change=%q", reply.Approved, reply.Once, reply.Change)
- agent.ResolveStanding(notice.ID, reply)
- case session.EventStandingUpdate:
- out.Updates = append(out.Updates, *event.Standing)
- t.Logf(" UPDATE %s: %s", event.Standing.Update, event.Standing.Item.Words)
case session.EventError:
out.Err = event.Err
t.Logf(" ERROR %v", event.Err)
@@ -529,85 +432,8 @@ func (w *world) drain(agent *session.Agent, what string, events <-chan session.E
return out
}
-// wokenTurn waits for a turn the SESSION started on its own — what a firing
-// steered into a live conversation produces — and drains it.
-func (w *world) wokenTurn(agent *session.Agent, lane <-chan (<-chan session.Event), wait time.Duration) (turn, bool) {
- select {
- case events, open := <-lane:
- if !open {
- return turn{}, false
- }
- return w.drain(agent, "woken turn", events, answerNo), true
- case <-time.After(wait):
- return turn{}, false
- }
-}
-
// ── small readers ───────────────────────────────────────────────────────────
-// onlyItem is the one item in the store, or a failure naming what is there.
-func (w *world) onlyItem() standing.Item {
- t := w.t
- items, err := w.store.List()
- if err != nil {
- t.Fatalf("list: %v", err)
- }
- if len(items) != 1 {
- t.Fatalf("the store holds %d items, want 1: %+v", len(items), items)
- }
- return items[0]
-}
-
-// itemFile is the document as it is ON DISK, read raw, so an assertion about
-// the file is about the file and not about a value the test kept in a variable.
-func (w *world) itemFile(id string) standing.Item {
- t := w.t
- raw, err := os.ReadFile(w.store.ItemPath(id))
- if err != nil {
- t.Fatalf("read %s: %v", w.store.ItemPath(id), err)
- }
- var item standing.Item
- if err := json.Unmarshal(raw, &item); err != nil {
- t.Fatalf("parse %s: %v", w.store.ItemPath(id), err)
- }
- return item
-}
-
-// wakeLog is every line the passes have written.
-func (w *world) wakeLog() []string {
- raw, err := os.ReadFile(w.store.WakeLogPath())
- if err != nil {
- return nil
- }
- var out []string
- for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
- if strings.TrimSpace(line) != "" {
- out = append(out, line)
- }
- }
- return out
-}
-
-// ledgerLines is today's ledger, one decoded entry per line.
-func (w *world) ledgerLines(day time.Time) []standing.Entry {
- raw, err := os.ReadFile(w.store.LedgerPath(day))
- if err != nil {
- return nil
- }
- var out []standing.Entry
- for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
- if strings.TrimSpace(line) == "" {
- continue
- }
- var entry standing.Entry
- if err := json.Unmarshal([]byte(line), &entry); err != nil {
- continue
- }
- out = append(out, entry)
- }
- return out
-}
-
// transcriptHas answers whether any entry of the conversation carries the text.
func transcriptHas(agent *session.Agent, needle string) (session.DisplayEntry, bool) {
for _, entry := range agent.Transcript() {
@@ -625,15 +451,3 @@ func shorten(text string, limit int) string {
}
return text[:limit] + "…"
}
-
-// lastLine is the final non-empty line of a reply — the sentence a person's eye
-// actually lands on, which is what a closing line has to be.
-func lastLine(text string) string {
- lines := strings.Split(strings.TrimSpace(text), "\n")
- for at := len(lines) - 1; at >= 0; at-- {
- if line := strings.TrimSpace(lines[at]); line != "" {
- return line
- }
- }
- return ""
-}
diff --git a/internal/e2e/home_restore_e2e_test.go b/internal/e2e/home_restore_e2e_test.go
index 9440d2db0f..06d0b432df 100644
--- a/internal/e2e/home_restore_e2e_test.go
+++ b/internal/e2e/home_restore_e2e_test.go
@@ -41,9 +41,9 @@ func TestHomeRestoredNavigationNoModel(t *testing.T) {
}
if width > 44 {
r.keys("Up")
- r.waitFor(10*time.Second, "enter asks this here")
+ r.waitFor(10*time.Second, say(t, "homeAskHereHint"))
r.keys("Down")
- r.waitFor(10*time.Second, "enter starts a new conversation")
+ r.waitFor(10*time.Second, say(t, "homeStartHint"))
}
t.Logf("restored choices at %d columns:\n%s", width, screen)
@@ -87,7 +87,7 @@ func TestHomeRestoredSubmissionDoorsWithStub(t *testing.T) {
r.lit("answer in the home pane")
r.waitFor(10*time.Second, say(t, "homeStartWord"))
r.keys("Up")
- r.waitFor(10*time.Second, "enter asks this here")
+ r.waitFor(10*time.Second, say(t, "homeAskHereHint"))
r.keys("Enter")
pane := r.waitFor(20*time.Second, stopStubDone, say(t, "exchangeBack"))
t.Logf("ask here received the endpoint's answer in its own pane:\n%s", pane)
diff --git a/internal/e2e/hostguard_test.go b/internal/e2e/hostguard_test.go
index b686531670..bef612ef8f 100644
--- a/internal/e2e/hostguard_test.go
+++ b/internal/e2e/hostguard_test.go
@@ -1,7 +1,29 @@
package e2e
-// THE TEST PROCESS'S HOME IS THE MACHINE WE PROTECT. Tests may later change
-// HOME, so capture it before any fixture starts moving the environment.
+// hostguard_test.go is THE WALL BETWEEN THIS SUITE AND THE MACHINE IT RUNS ON.
+//
+// A codeaf this suite starts is a real codeaf, and three things a real codeaf
+// does on its own reach past its state root into the machine:
+//
+// - EVERY START TAKES THE OLD TIMERS OFF THE LOGIN. The feature automations
+// replaced installed one launchd agent or systemd timer per login, and
+// internal/automation's legacy.go removes every definition it finds under
+// HOME on every start — `launchctl bootout` and `systemctl --user stop`
+// included. A launch pointed at the developer's HOME would boot the
+// developer's own timers out from under them.
+// - EVERY WINDOW STARTS A CLOCK. `codeaf clock` is a detached process that
+// outlives the window that started it by half a minute, so a test window
+// that started one would leave it running after the test had gone.
+// - A RUN THE CLOCK FINISHES RAISES A DESKTOP NOTIFICATION, through
+// `osascript` on macOS and `notify-send` on Linux, which would put a banner
+// on the screen of whoever is running the suite.
+//
+// So every launch stands behind the guard: a login folder of its own for HOME,
+// stand-ins for launchctl, systemctl, crontab and both notifiers first on PATH
+// (each one writes what it was asked into a log a scenario can read), and the
+// clock switched off unless the scenario says it is about the clock.
+// hostguardgate_test.go is the law that every launcher in this package comes
+// through here (#1631).
import (
"context"
@@ -11,21 +33,37 @@ import (
"os/exec"
"path/filepath"
"runtime"
+ "strconv"
"strings"
"testing"
-
- "github.com/Agent-Field/codeaf/internal/standing"
)
+// developerHome is THE MACHINE WE PROTECT. Tests may later change HOME, so it
+// is captured before any fixture starts moving the environment.
var developerHome = func() string {
home, _ := os.UserHomeDir()
return home
}()
+// hostStandIns are the commands the guard answers in the machine's place: the
+// three schedulers a timer could be put on, and the two notifiers an
+// automation's news is raised through (internal/tui3's desktopnotify.go).
+var hostStandIns = []string{"systemctl", "launchctl", "crontab", "osascript", "notify-send"}
+
+// noClockEnv is the variable that keeps a launched window from starting the
+// automations clock (internal/automation's Startable). The guard sets it on
+// every launch whose scenario does not name it, so the one scenario that is
+// about the clock says so by naming it — normally `-u CODEAF_NO_AUTOMATIONS`.
+const noClockEnv = "CODEAF_NO_AUTOMATIONS"
+
+// hostGuard is one state root's wall: the folder beside the root, the stand-ins
+// in its bin, the throwaway login HOME points at, and the log the stand-ins
+// write what they were asked into.
type hostGuard struct{ dir, bin, login, log string }
-// guardHost gives each state root a sibling login and scheduler stand-ins. The
-// sibling keeps a fresh-install state root empty until the product writes to it.
+// guardHost gives each state root a sibling login and the machine's stand-ins.
+// The sibling keeps a fresh-install state root empty until the product writes
+// to it.
func guardHost(t testing.TB, stateRoot string) hostGuard {
t.Helper()
if stateRoot == "" {
@@ -36,7 +74,7 @@ func guardHost(t testing.TB, stateRoot string) hostGuard {
t.Fatalf("host guard state root: %v", err)
}
dir := filepath.Clean(root) + ".host"
- g := hostGuard{dir: dir, bin: filepath.Join(dir, "bin"), login: filepath.Join(dir, "login"), log: filepath.Join(dir, "timer.log")}
+ g := hostGuard{dir: dir, bin: filepath.Join(dir, "bin"), login: filepath.Join(dir, "login"), log: filepath.Join(dir, "host.log")}
if err := os.Mkdir(dir, 0o700); err == nil {
t.Cleanup(func() { _ = os.RemoveAll(dir) })
} else if !os.IsExist(err) {
@@ -50,7 +88,7 @@ func guardHost(t testing.TB, stateRoot string) hostGuard {
t.Fatalf("host guard directory %s: %v", path.name, err)
}
}
- for _, name := range []string{"systemctl", "launchctl", "crontab"} {
+ for _, name := range hostStandIns {
stub := "#!/bin/sh\nprintf '%s\\n' \"" + name + " $*\" >> " + shellQuote(g.log) + "\nexit 0\n"
if err := os.WriteFile(filepath.Join(g.bin, name), []byte(stub), 0o755); err != nil {
t.Fatalf("host guard stub %s: %v", name, err)
@@ -63,10 +101,11 @@ func guardHost(t testing.TB, stateRoot string) hostGuard {
// log argument or the tmux launch command.
func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" }
-// env folds duplicate assignments before placing the stubs first on PATH. A
-// scenario's deliberate throwaway HOME remains its own.
+// env folds duplicate assignments before placing the stand-ins first on PATH.
+// A scenario's deliberate throwaway HOME remains its own, and so does a
+// scenario's own word about the clock.
func (g hostGuard) env(base []string) []string {
- result := make([]string, 0, len(base)+2)
+ result := make([]string, 0, len(base)+3)
positions := map[string]int{}
for _, row := range base {
name, _, ok := strings.Cut(row, "=")
@@ -92,7 +131,11 @@ func (g hostGuard) env(base []string) []string {
if home == "" || filepath.Clean(home) == filepath.Clean(developerHome) {
home = g.login
}
- for name, value := range map[string]string{"PATH": g.bin + ":" + path, "HOME": home} {
+ guarded := map[string]string{"PATH": g.bin + ":" + path, "HOME": home}
+ if _, named := positions[noClockEnv]; !named {
+ guarded[noClockEnv] = "1"
+ }
+ for name, value := range guarded {
row := name + "=" + value
if at, ok := positions[name]; ok {
result[at] = row
@@ -104,18 +147,23 @@ func (g hostGuard) env(base []string) []string {
}
// tokens appends assignments after a scenario's env argv, so a preceding -u
-// pair or assignment cannot undo the guard at the tmux process boundary.
+// pair or assignment cannot undo the guard at the tmux process boundary. The
+// clock's switch is the one exception, and on purpose: a scenario that names
+// it, with an assignment or with -u, has said what it wants of the clock.
func (g hostGuard) tokens(scenario []string) []string {
values := map[string]string{}
+ named := map[string]bool{}
for index := 0; index < len(scenario); index++ {
if scenario[index] == "-u" && index+1 < len(scenario) {
index++
delete(values, scenario[index])
+ named[scenario[index]] = true
continue
}
name, value, ok := strings.Cut(scenario[index], "=")
if ok {
values[name] = value
+ named[name] = true
}
}
path, ok := values["PATH"]
@@ -126,9 +174,13 @@ func (g hostGuard) tokens(scenario []string) []string {
if home := values["HOME"]; home == "" || filepath.Clean(home) == filepath.Clean(developerHome) {
result = append(result, "HOME="+g.login)
}
+ if !named[noClockEnv] {
+ result = append(result, noClockEnv+"=1")
+ }
return result
}
+// calls is everything the stand-ins were asked, oldest first.
func (g hostGuard) calls(t testing.TB) []string {
t.Helper()
raw, err := os.ReadFile(g.log)
@@ -144,8 +196,8 @@ func (g hostGuard) calls(t testing.TB) []string {
return strings.Split(strings.TrimSuffix(string(raw), "\n"), "\n")
}
-// guardedCommand is the one exec door for a child codeaf. A guarded HOME
-// catches timer definitions written by Go before the scheduler command runs.
+// guardedCommand is the one exec door for a child codeaf. A guarded HOME is
+// what the old-timer removal looks under, and the stand-ins are what it calls.
func guardedCommand(t testing.TB, ctx context.Context, stateRoot string, env []string, program string, args ...string) *exec.Cmd {
t.Helper()
command := exec.CommandContext(ctx, program, args...)
@@ -153,49 +205,105 @@ func guardedCommand(t testing.TB, ctx context.Context, stateRoot string, env []s
return command
}
-func machineTimerFiles() []string {
- var pattern string
- switch runtime.GOOS {
- case "linux":
- pattern = filepath.Join(developerHome, ".config", "systemd", "user", strings.TrimSuffix(standing.LinuxTickTimer, ".timer")+".*")
- case "darwin":
- pattern = filepath.Join(developerHome, "Library", "LaunchAgents", standing.DarwinTickLabel+".plist")
- default:
- return nil
+// ── the old timers, named the way the machine names them ───────────────────
+
+// oldTimerLabels and oldTimerUnits are every timer the feature automations
+// replaced ever installed: the five-minute tick and the v1 resident's wake,
+// under both of the product's names.
+//
+// THEY ARE SPELLED HERE AND NOT BORROWED FROM internal/automation, whose lists
+// are the code under test. A guard that asked the uninstaller which files it
+// might touch could only ever watch the files the uninstaller already agrees
+// it touches; this list is the machine's own account, and a removal that
+// reached a file outside it would still be caught by the hashes below.
+var (
+ oldTimerLabels = []string{
+ "ai.agentfield.codeaf.tick",
+ "ai.agentfield.aforge.tick", // legacy-name
+ "ai.agentfield.codeaf.wake",
+ "ai.agentfield.aforge.wake", // legacy-name
+ }
+ oldTimerUnits = []string{
+ "codeaf-tick",
+ "aforge-tick", // legacy-name
+ "codeaf-wake",
+ "aforge-wake", // legacy-name
+ }
+)
+
+// oldTimerFiles is every file under one login that holds or locks an old
+// timer, on either platform: a launchd agent's plist and its lock, and a
+// systemd timer, its service, its enable link and its lock.
+func oldTimerFiles(login string) []string {
+ var paths []string
+ agents := filepath.Join(login, "Library", "LaunchAgents")
+ for _, label := range oldTimerLabels {
+ plist := filepath.Join(agents, label+".plist")
+ paths = append(paths, plist, plist+".lock")
+ }
+ units := filepath.Join(login, ".config", "systemd", "user")
+ for _, unit := range oldTimerUnits {
+ paths = append(paths,
+ filepath.Join(units, unit+".timer"),
+ filepath.Join(units, unit+".service"),
+ filepath.Join(units, "timers.target.wants", unit+".timer"),
+ filepath.Join(units, unit+".timer.lock"))
}
- paths, _ := filepath.Glob(pattern)
return paths
}
-func timerHashes(t testing.TB) map[string]string {
+// machineTimerHashes is what the developer's own old-timer files hold right
+// now. AN ENABLE LINK IS HASHED AS A LINK, by where it points, so a link whose
+// target has gone is still a file this guard watches rather than a read error.
+func machineTimerHashes(t testing.TB) map[string]string {
t.Helper()
hashes := map[string]string{}
- for _, path := range machineTimerFiles() {
+ if developerHome == "" {
+ return hashes
+ }
+ for _, path := range oldTimerFiles(developerHome) {
+ info, err := os.Lstat(path)
+ if os.IsNotExist(err) {
+ continue
+ }
+ if err != nil {
+ t.Fatalf("look at machine timer %s: %v", path, err)
+ }
+ if info.Mode()&os.ModeSymlink != 0 {
+ target, err := os.Readlink(path)
+ if err != nil {
+ t.Fatalf("read machine timer link %s: %v", path, err)
+ }
+ hashes[path] = "link to " + target
+ continue
+ }
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read machine timer %s: %v", path, err)
}
- hashes[path] = fmt.Sprintf("%x", sha256.Sum256(raw))
+ hashes[path] = fmt.Sprintf("sha256 %x", sha256.Sum256(raw))
}
return hashes
}
// requireMachineTimerUntouched registers first so its cleanup runs after every
-// rig has stopped. It catches a timer file that appeared, vanished or changed.
+// rig has stopped. It catches an old-timer file of the developer's that
+// appeared, vanished or changed while the test ran — tick and wake, under both
+// names, on either platform.
func requireMachineTimerUntouched(t testing.TB) {
t.Helper()
- before := timerHashes(t)
+ before := machineTimerHashes(t)
if len(before) == 0 {
- t.Log("this machine has no timer definition")
+ t.Log("this machine has no old timer definition")
}
for path, hash := range before {
- t.Logf("machine timer %s sha256 %s", path, hash)
+ t.Logf("machine timer %s: %s", path, hash)
}
t.Cleanup(func() {
- after := timerHashes(t)
+ after := machineTimerHashes(t)
for path, oldHash := range before {
if newHash, ok := after[path]; !ok || newHash != oldHash {
- t.Errorf("machine timer %s changed: before %s, after %s", path, oldHash, newHash)
+ t.Errorf("machine timer %s changed: before %s, after %q", path, oldHash, newHash)
}
}
for path, hash := range after {
@@ -206,9 +314,72 @@ func requireMachineTimerUntouched(t testing.TB) {
})
}
+// seedOldTimers writes, under one throwaway login, every definition the
+// uninstaller looks for on this platform, and answers the paths it wrote.
+func seedOldTimers(t testing.TB, login string) []string {
+ t.Helper()
+ write := func(path, body string) {
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ t.Fatalf("seed %s: %v", path, err)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
+ t.Fatalf("seed %s: %v", path, err)
+ }
+ }
+ var seeded []string
+ switch runtime.GOOS {
+ case "darwin":
+ agents := filepath.Join(login, "Library", "LaunchAgents")
+ for _, label := range oldTimerLabels {
+ plist := filepath.Join(agents, label+".plist")
+ write(plist, "Label"+label+"\n")
+ write(plist+".lock", "")
+ seeded = append(seeded, plist, plist+".lock")
+ }
+ case "linux":
+ units := filepath.Join(login, ".config", "systemd", "user")
+ for _, unit := range oldTimerUnits {
+ timer := filepath.Join(units, unit+".timer")
+ write(timer, "[Timer]\nOnUnitActiveSec=5min\n")
+ write(filepath.Join(units, unit+".service"), "[Service]\nExecStart=/bin/true\n")
+ write(timer+".lock", "")
+ link := filepath.Join(units, "timers.target.wants", unit+".timer")
+ if err := os.MkdirAll(filepath.Dir(link), 0o700); err != nil {
+ t.Fatalf("seed %s: %v", link, err)
+ }
+ if err := os.Symlink(timer, link); err != nil {
+ t.Fatalf("seed %s: %v", link, err)
+ }
+ seeded = append(seeded, timer, filepath.Join(units, unit+".service"), link, timer+".lock")
+ }
+ }
+ return seeded
+}
+
+// oldTimerRemover is internal/automation's RemoveOldTimers alone, built into a
+// program of its own from testdata/oldtimers.
+//
+// IT IS A BUILT PROGRAM AND NOT A CALL IN THIS PROCESS, because the removal
+// refuses to act inside a test binary (testing.Testing) — which is the right
+// refusal for every unit test in the tree and exactly the one this gate cannot
+// accept: what has to be proved is what the code does in a process that is NOT
+// a test, the one every launch of `bin/codeaf` is. The program is the same
+// first act main() performs, run through the same exec door as every other
+// launch here.
+func oldTimerRemover(t *testing.T) string {
+ t.Helper()
+ program := filepath.Join(t.TempDir(), "oldtimers")
+ build := exec.Command("go", "build", "-o", program, "./internal/e2e/testdata/oldtimers")
+ build.Dir = moduleRoot(t)
+ if out, err := build.CombinedOutput(); err != nil {
+ t.Fatalf("build the old-timer remover: %v\n%s", err, out)
+ }
+ return program
+}
+
func TestTheHostGuardStandsInForTheMachinesScheduler(t *testing.T) {
if runtime.GOOS == "windows" {
- t.Skip("the scheduler stubs are POSIX shell")
+ t.Skip("the stand-ins are POSIX shell")
}
root := filepath.Join(t.TempDir(), "state")
g := guardHost(t, root)
@@ -227,15 +398,17 @@ func TestTheHostGuardStandsInForTheMachinesScheduler(t *testing.T) {
if got := value(env, "PATH"); !strings.HasPrefix(got, g.bin+":") {
t.Errorf("PATH = %q, want guard first", got)
}
- command := exec.Command("sh", "-c", "command -v systemctl; systemctl --user enable --now codeaf-tick.timer; launchctl bootstrap gui/501 /x.plist; crontab -l")
+ command := exec.Command("sh", "-c", "command -v systemctl; systemctl --user enable --now codeaf-tick.timer; "+
+ "launchctl bootstrap gui/501 /x.plist; crontab -l; osascript -e 'display notification'; notify-send codeaf hello")
command.Env = env
out, err := command.CombinedOutput()
if err != nil || !strings.HasPrefix(string(out), filepath.Join(g.bin, "systemctl")+"\n") {
t.Fatalf("scheduler stubs: %v, output %q", err, out)
}
- wantCalls := []string{"systemctl --user enable --now codeaf-tick.timer", "launchctl bootstrap gui/501 /x.plist", "crontab -l"}
+ wantCalls := []string{"systemctl --user enable --now codeaf-tick.timer", "launchctl bootstrap gui/501 /x.plist",
+ "crontab -l", "osascript -e display notification", "notify-send codeaf hello"}
if got := g.calls(t); strings.Join(got, "\n") != strings.Join(wantCalls, "\n") {
- t.Errorf("scheduler calls = %v, want %v", got, wantCalls)
+ t.Errorf("stand-in calls = %v, want %v", got, wantCalls)
}
other := filepath.Join(t.TempDir(), "login")
if got := value(g.env([]string{"HOME=" + other}), "HOME"); got != other {
@@ -247,72 +420,103 @@ func TestTheHostGuardStandsInForTheMachinesScheduler(t *testing.T) {
if got := value(g.env([]string{"PATH=/opt/x:/usr/bin"}), "PATH"); got != g.bin+":/opt/x:/usr/bin" {
t.Errorf("scenario PATH = %q", got)
}
- if got := g.tokens([]string{"-u", "HOME", "PATH=/opt/x:/usr/bin", "HOME=" + developerHome}); strings.Join(got, "\n") != "PATH="+g.bin+":/opt/x:/usr/bin\nHOME="+g.login {
+ // THE CLOCK IS OFF UNLESS THE SCENARIO SAID OTHERWISE, through either door.
+ if got := value(env, noClockEnv); got != "1" {
+ t.Errorf("%s = %q on a launch that said nothing about the clock, want 1", noClockEnv, got)
+ }
+ if rows := g.env([]string{noClockEnv + "="}); value(rows, noClockEnv) != "" || strings.Count(strings.Join(rows, "\n"), noClockEnv+"=") != 1 {
+ t.Errorf("a scenario's own %s was overridden: %v", noClockEnv, rows)
+ }
+ want := "PATH=" + g.bin + ":/opt/x:/usr/bin\nHOME=" + g.login + "\n" + noClockEnv + "=1"
+ if got := g.tokens([]string{"-u", "HOME", "PATH=/opt/x:/usr/bin", "HOME=" + developerHome}); strings.Join(got, "\n") != want {
t.Errorf("guard tokens = %v", got)
}
- if got := g.tokens([]string{"-u", "PATH", "HOME=" + other}); strings.Join(got, "\n") != "PATH="+g.bin+":"+os.Getenv("PATH") {
+ want = "PATH=" + g.bin + ":" + os.Getenv("PATH") + "\n" + noClockEnv + "=1"
+ if got := g.tokens([]string{"-u", "PATH", "HOME=" + other}); strings.Join(got, "\n") != want {
t.Errorf("scenario-owned HOME tokens = %v", got)
}
+ want = "PATH=" + g.bin + ":" + os.Getenv("PATH") + "\nHOME=" + g.login
+ if got := g.tokens([]string{"-u", noClockEnv}); strings.Join(got, "\n") != want {
+ t.Errorf("a scenario that is about the clock still had it switched off: %v", got)
+ }
_ = guardHost(t, root)
if got := g.calls(t); strings.Join(got, "\n") != strings.Join(wantCalls, "\n") {
- t.Errorf("second guard erased scheduler calls: %v", got)
+ t.Errorf("second guard erased stand-in calls: %v", got)
}
}
-func TestAnInstallUnderTheGuardNeverReachesTheMachinesTimer(t *testing.T) {
+// TestTheOldTimersComeOffOnlyTheThrowawayLogin is the guarantee the guard
+// exists for, held against the code that would break it.
+//
+// Every launch of `bin/codeaf` takes the old timers off whatever login it runs
+// as (internal/automation's legacy.go), so the one thing this suite must never
+// do is run that removal as the developer. This runs it as a real program under
+// the guard and proves three things: a login with nothing on it is left alone;
+// a login holding every old timer is cleared, through the stand-ins and only
+// at its own paths; and the developer's own tick and wake files, under both
+// names, are byte for byte what they were.
+func TestTheOldTimersComeOffOnlyTheThrowawayLogin(t *testing.T) {
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
- t.Skip("this machine has no supported timer platform")
+ t.Skip("the old timers were launchd agents and systemd units, and this machine has neither")
}
requireMachineTimerUntouched(t)
root := filepath.Join(t.TempDir(), "state")
g := guardHost(t, root)
- env := g.env(os.Environ())
- for _, row := range env {
- if strings.HasPrefix(row, "HOME=") {
- t.Setenv("HOME", strings.TrimPrefix(row, "HOME="))
- }
- if strings.HasPrefix(row, "PATH=") {
- t.Setenv("PATH", strings.TrimPrefix(row, "PATH="))
+ remover := oldTimerRemover(t)
+ run := func(what string) {
+ t.Helper()
+ command := guardedCommand(t, context.Background(), root, append(os.Environ(), "CODEAF_HOME="+root), remover)
+ if out, err := command.CombinedOutput(); err != nil {
+ t.Fatalf("the remover failed on %s: %v\n%s", what, err, out)
}
}
- // THE INSTALL MUST NOT RUN UNTIL BOTH WALLS ARE IN PLACE. The product
- // writes a definition in Go before it calls the scheduler.
- for _, name := range []string{"systemctl", "launchctl"} {
- resolved, err := exec.LookPath(name)
- if err != nil || resolved != filepath.Join(g.bin, name) {
- t.Fatalf("unsafe scheduler resolution for %s: %q, %v", name, resolved, err)
- }
- }
- if got, err := os.UserHomeDir(); err != nil || got != g.login {
- t.Fatalf("unsafe HOME for install: %q, %v", got, err)
- }
- watch, err := standing.NewWatch(standing.WatchOptions{StateRoot: root, Executable: filepath.Join(t.TempDir(), "codeaf")})
- if err != nil {
- t.Fatal(err)
- }
- if err := watch.Install(context.Background()); err != nil {
- t.Fatal(err)
+
+ // IT ONLY EVER ACTS ON A FILE IT FOUND: a login with no old timer on it is
+ // a login nothing is asked about.
+ run("an empty login")
+ if calls := g.calls(t); len(calls) != 0 {
+ t.Fatalf("the remover asked the scheduler about a login holding no old timer: %v", calls)
}
+
+ seeded := seedOldTimers(t, g.login)
+ run("a login holding every old timer")
calls := g.calls(t)
- t.Logf("scheduler calls: %v", calls)
- if runtime.GOOS == "linux" {
- for _, want := range []string{"systemctl --user daemon-reload", "systemctl --user enable --now " + standing.LinuxTickTimer} {
- if !strings.Contains(strings.Join(calls, "\n"), want) {
- t.Errorf("scheduler calls %v lack %q", calls, want)
- }
+ t.Logf("stand-in calls: %v", calls)
+ for _, path := range seeded {
+ if _, err := os.Lstat(path); !os.IsNotExist(err) {
+ t.Errorf("the throwaway login's %s survived the removal (%v)", path, err)
}
- definition := filepath.Join(g.login, ".config", "systemd", "user", strings.TrimSuffix(standing.LinuxTickTimer, ".timer")+".service")
- raw, err := os.ReadFile(definition)
- if err != nil || !strings.Contains(string(raw), "CODEAF_HOME="+root) {
- t.Errorf("guarded service definition %s: %v, content %q", definition, err, raw)
+ }
+ var want []string
+ switch runtime.GOOS {
+ case "darwin":
+ domain := "gui/" + strconv.Itoa(os.Getuid())
+ for _, label := range oldTimerLabels {
+ want = append(want, "launchctl bootout "+domain+"/"+label)
}
- } else {
- definition := filepath.Join(g.login, "Library", "LaunchAgents", standing.DarwinTickLabel+".plist")
- if _, err := os.Stat(definition); err != nil {
- t.Errorf("guarded plist %s: %v", definition, err)
+ case "linux":
+ for _, unit := range oldTimerUnits {
+ want = append(want, "systemctl --user stop "+unit+".timer")
}
- if !strings.Contains(strings.Join(calls, "\n"), "launchctl bootstrap gui/") || !strings.Contains(strings.Join(calls, "\n"), definition) {
- t.Errorf("scheduler calls %v lack plist bootstrap", calls)
+ want = append(want, "systemctl --user daemon-reload")
+ }
+ if strings.Join(calls, "\n") != strings.Join(want, "\n") {
+ t.Errorf("the stand-ins were asked:\n %s\nwant:\n %s", strings.Join(calls, "\n "), strings.Join(want, "\n "))
+ }
+ // AND NOTHING IT NAMED IS THE DEVELOPER'S. Every path the removal handed a
+ // scheduler is under the throwaway login; a call naming any other folder is
+ // a call that would have reached a real one.
+ for _, call := range calls {
+ for _, field := range strings.Fields(call) {
+ if filepath.IsAbs(field) && !strings.HasPrefix(field, g.login+string(filepath.Separator)) {
+ t.Errorf("the removal named %s, which is outside the throwaway login %s: %q", field, g.login, call)
+ }
}
}
+
+ // IT RUNS ON EVERY START, and a start after the clean one finds nothing.
+ run("the same login again")
+ if again := g.calls(t); len(again) != len(calls) {
+ t.Errorf("a second start asked the scheduler again: %v", again[len(calls):])
+ }
}
diff --git a/internal/e2e/hostguardgate_test.go b/internal/e2e/hostguardgate_test.go
index 966498b5bd..0097f9a498 100644
--- a/internal/e2e/hostguardgate_test.go
+++ b/internal/e2e/hostguardgate_test.go
@@ -1,9 +1,13 @@
package e2e
// THE HOST GUARD IS A LAW FOR EVERY LAUNCHER. A codeaf process started by this
-// suite can install a background timer, so an unguarded launch can replace the
-// developer's timer with a unit pointing at a deleted checkout. Every five-minute
-// pass then fails (#1631). This untagged gate reads tagged sources as well.
+// suite takes the old feature's timers off whatever login it runs as, and a
+// window it opens starts the automations clock (hostguard_test.go says both
+// in full). An unguarded launch would do the first to the developer's own
+// launchd or systemd and the second against their home. It began as #1631, when
+// a launch could still INSTALL a timer and replaced the developer's with a unit
+// pointing at a deleted checkout. This untagged gate reads tagged sources as
+// well.
import (
"go/ast"
@@ -69,7 +73,7 @@ func TestEveryLaunchOfCodeafStandsBehindTheHostGuard(t *testing.T) {
return true
})
if !guarded {
- t.Errorf("%s:%d door no longer calls guardHost; use the temporary HOME and scheduler stubs before launching codeaf (#1631)", name, fset.Position(fn.Pos()).Line)
+ t.Errorf("%s:%d door no longer calls guardHost; use the temporary HOME and the machine's stand-ins before launching codeaf (#1631)", name, fset.Position(fn.Pos()).Line)
if key == "tmux_test.go/startWithEnv" {
ast.Inspect(fn.Body, func(node ast.Node) bool {
call, ok := node.(*ast.CallExpr)
@@ -78,7 +82,7 @@ func TestEveryLaunchOfCodeafStandsBehindTheHostGuard(t *testing.T) {
if literal && program == "tmux" && hostTmuxLaunch(call.Args[1:]) {
last, literal := hostString(call.Args[len(call.Args)-1])
if !literal || strings.Contains(last, "codeaf") {
- t.Errorf("%s:%d tmux respawn-window can start codeaf without the host guard; the developer's timer then points at a deleted checkout and every five-minute pass fails (#1631)", name, fset.Position(call.Pos()).Line)
+ t.Errorf("%s:%d tmux respawn-window can start codeaf without the host guard; it would take the developer's old timers off their login and start a clock against their home (#1631)", name, fset.Position(call.Pos()).Line)
}
}
}
@@ -108,7 +112,7 @@ func TestEveryLaunchOfCodeafStandsBehindTheHostGuard(t *testing.T) {
}
for key := range hostGuardDoors {
if !found[key] {
- t.Errorf("%s host guard door is missing; every codeaf launch needs a temporary HOME and scheduler stubs (#1631)", key)
+ t.Errorf("%s host guard door is missing; every codeaf launch needs a temporary HOME and the machine's stand-ins (#1631)", key)
}
}
}
@@ -127,7 +131,7 @@ func hostGuardViolations(fset *token.FileSet, file *ast.File, name string) []str
_, allowed := hostGuardAllowlist[key]
guardedVars := map[string]bool{}
report := func(node ast.Node, reason string) {
- problems = append(problems, name+":"+strconv.Itoa(fset.Position(node.Pos()).Line)+" "+reason+"; an unguarded codeaf can replace the developer's timer with a deleted checkout and break every five-minute pass (#1631). Use guardedCommand or startWithEnv")
+ problems = append(problems, name+":"+strconv.Itoa(fset.Position(node.Pos()).Line)+" "+reason+"; an unguarded codeaf takes the developer's old timers off their login and starts a clock against their home (#1631). Use guardedCommand or startWithEnv")
}
ast.Inspect(fn.Body, func(node ast.Node) bool {
switch n := node.(type) {
@@ -226,7 +230,7 @@ func TestTheHostGuardGateCatchesAnUnguardedLauncher(t *testing.T) {
source := `package e2e
import "os/exec"
func fake(t any) {
-exec.Command(binary(t), "tick")
+exec.Command(binary(t), "clock")
exec.Command("codeaf", "chat")
exec.Command("tmux", "respawn-window", buildCommand())
cmd := guardedCommand(t, nil, "", nil, "", "")
diff --git a/internal/e2e/manual_e2e_test.go b/internal/e2e/manual_e2e_test.go
index fe379282c1..ec9c0f7db3 100644
--- a/internal/e2e/manual_e2e_test.go
+++ b/internal/e2e/manual_e2e_test.go
@@ -527,8 +527,12 @@ func TestManualOpensWithNoKeyAndNoModel(t *testing.T) {
if !strings.Contains(out, label) {
t.Fatalf("%q did not return the corrected home section labelled %s; got %v", asked, label, labelsOf(renderedSections(out)))
}
- if !strings.Contains(out, "Home does not show when a waiting order will next go off") {
- t.Errorf("%q returned the home page without its corrected due-time guidance", asked)
+ // STANDING ORDERS ARE AUTOMATIONS NOW, and home's `automations` panel says
+ // when each one goes off next, in the sentence `/automations` draws
+ // (internal/manual's TestHomeAutomationQuestionFindsWhenItGoesOffNext asks
+ // the same question without the binary).
+ if !strings.Contains(out, "which says when it goes off next") {
+ t.Errorf("%q returned the home page without saying where an automation's next time is shown", asked)
}
t.Logf("`codeaf manual %q` reached %s and explained where the due time is shown", asked, label)
})
@@ -628,11 +632,11 @@ func aPlainWorkspace(t *testing.T) string {
return dir
}
-// manualConfig is the departure this lane makes from the ambient one: nothing
-// here is about standing orders, so the seam is off and there is no card for
-// anybody to press. The person's own approval rules already allow `manual`.
+// manualConfig is the departure this lane makes from an ordinary conversation:
+// nobody is watching, so nothing a turn does may stop on a question — a call
+// that would ask is refused instead (AskConsent false), and there is no card
+// for anybody to press. The person's own approval rules already allow `manual`.
func manualConfig(cfg *session.Config) {
- cfg.Standing = nil
cfg.AskConsent = false
}
@@ -653,7 +657,7 @@ func runManualScenario(t *testing.T, w *world, name, question string, check func
for attempt := 1; attempt <= manualAttempts; attempt++ {
started := time.Now()
agent, place := w.open(aPlainWorkspace(t), manualConfig)
- out := w.say(agent, question, answerYes)
+ out := w.say(agent, question)
wall := time.Since(started)
usd, models := ledgerSince(t, started)
diff --git a/internal/e2e/manual_wire_test.go b/internal/e2e/manual_wire_test.go
index 01ed19a910..79d98caec6 100644
--- a/internal/e2e/manual_wire_test.go
+++ b/internal/e2e/manual_wire_test.go
@@ -251,7 +251,7 @@ func askOneOnTheWire(t *testing.T, w *world, question asked.Question) (wireAsk,
for attempt := 1; attempt <= wireAttempts; attempt++ {
started := time.Now()
agent, place := w.open(aPlainWorkspace(t), manualConfig)
- out := w.say(agent, question.Ask, answerYes)
+ out := w.say(agent, question.Ask)
usd, models := ledgerSince(t, started)
spent += usd
found.broke = out.Err != nil
diff --git a/internal/e2e/questions_e2e_test.go b/internal/e2e/questions_e2e_test.go
index b071ebf639..718a07b9b6 100644
--- a/internal/e2e/questions_e2e_test.go
+++ b/internal/e2e/questions_e2e_test.go
@@ -34,9 +34,9 @@
// tuiwords_test.go, whose untagged gate reads the surface's own sources back —
// that is the law this package has carried since #184. Sentences the MODEL
// spells are steered by the prompt in each scenario and are typed here as
-// literals, exactly as the standing lane types the words it asked a model for:
-// a table of product sentences is a source of truth, and a table of things a
-// model happened to say is a table of coincidences.
+// literals, exactly as automations_e2e_test.go types the words its scripted
+// model chose: a table of product sentences is a source of truth, and a table
+// of things a model happened to say is a table of coincidences.
//
// AND AN ANSWER IS BOTH, SO IT IS COMPOSED RATHER THAN PASTED. `1 delete it` is
// the scenario's own word with the PRODUCT's key grammar in front of it, so it
diff --git a/internal/e2e/standing_e2e_test.go b/internal/e2e/standing_e2e_test.go
deleted file mode 100644
index 669b6e629f..0000000000
--- a/internal/e2e/standing_e2e_test.go
+++ /dev/null
@@ -1,777 +0,0 @@
-//go:build e2e
-
-package e2e
-
-// standing_e2e_test.go is the ambient side driven end to end against a real
-// model: a sentence becomes a card, a yes becomes a document on disk, a pass
-// wakes it, and the news reaches a person — in a live conversation, in a
-// conversation that was shut, or in a project nobody had open.
-//
-// EVERY SUBTEST LOGS WHAT THE MODEL ACTUALLY DID. The tool call, its result and
-// the closing line are all in the -v output, because the point of this lane is
-// evidence and not an assertion that something unspecified happened.
-//
-// go test -tags e2e -run TestStandingE2E -v -timeout 30m ./internal/e2e/
-
-import (
- "context"
- "encoding/json"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-
- "github.com/Agent-Field/codeaf/internal/roles"
- "github.com/Agent-Field/codeaf/internal/session"
- "github.com/Agent-Field/codeaf/internal/standing"
-)
-
-func TestStandingE2E(t *testing.T) {
- liveKey(t)
-
- t.Run("1 a reminder is proposed, ratified and written down", standingReminderStands)
- t.Run("2 a tick fires it into the live conversation", standingFiresIntoTheRoom)
- t.Run("3 a closed conversation gets one while-you-were-away fold", standingInboxFold)
- t.Run("4 an errand's firing waits on the project", standingErrandReachesTheProject)
- t.Run("5 a probe watch checks, says nothing, then fires", standingProbeWatch)
- t.Run("6 a rhythm is paused, resumed, stopped and listed", standingRhythmLifecycle)
- t.Run("7 a task firing runs headless and bills", standingTaskFiring)
- t.Run("8 the daily rail skips the second firing", standingDailyRail)
- t.Run("9 an unwatched session cannot set one up", standingUnwatchedRefuses)
- t.Run("10 a moment that has passed", standingPastMoment)
- t.Run("11 the sweep reaps a run that came to nothing", standingSweep)
-}
-
-// ── 1 ───────────────────────────────────────────────────────────────────────
-
-// standWords is the sentence every reminder scenario starts from. It is one
-// sentence a person would actually say, and it is the anchor every surface
-// leads with afterwards ([standing.Item.Words]).
-const standWords = "remind me in 1 minute to drink water"
-
-func standingReminderStands(t *testing.T) {
- w := newWorld(t)
- workspace := t.TempDir()
- agent, _ := w.open(workspace, nil)
-
- before := time.Now()
- turn := w.say(agent, standWords, answerYes)
-
- // THE CALL. op=propose, and the moment said as a distance rather than
- // shelled out for.
- stand, found := turn.named("stand")
- if !found {
- t.Fatalf("the model never called stand; it called %v and said %q", turn.names(), turn.Reply)
- }
- var args struct {
- Op string `json:"op"`
- When struct {
- Kind string `json:"kind"`
- At string `json:"at"`
- In string `json:"in"`
- } `json:"when"`
- Does struct {
- Kind string `json:"kind"`
- } `json:"does"`
- }
- if err := json.Unmarshal([]byte(stand.Args), &args); err != nil {
- t.Fatalf("the stand call did not parse: %v\n%s", err, stand.Args)
- }
- if args.Op != "propose" {
- t.Fatalf("stand op = %q, want propose", args.Op)
- }
- if args.When.Kind != string(standing.WhenAt) {
- t.Fatalf("when.kind = %q, want at", args.When.Kind)
- }
- switch {
- case strings.TrimSpace(args.When.In) != "":
- t.Logf("the model used when.in = %q — the moment is codeaf's to resolve", args.When.In)
- case strings.TrimSpace(args.When.At) != "":
- moment, err := time.ParseInLocation(time.RFC3339, args.When.At, time.Local)
- if err != nil {
- t.Fatalf("when.at %q did not parse: %v", args.When.At, err)
- }
- if !moment.After(before) {
- t.Fatalf("when.at %s is not in the future (now was %s)", moment, before)
- }
- t.Logf("the model wrote the stamp itself: when.at = %q", args.When.At)
- default:
- t.Fatalf("the call carried neither when.at nor when.in: %s", stand.Args)
- }
- // AND IT DID NOT SHELL OUT FOR THE CLOCK. The Now line in the system
- // prompt is what the schema tells it to read; a bash call before the stand
- // call is that instruction going unread.
- for _, name := range turn.names() {
- if name == "stand" {
- break
- }
- if name == "bash" {
- t.Errorf("the model shelled out before proposing: calls were %v", turn.names())
- }
- }
-
- // THE CARD.
- if len(turn.Proposals) != 1 {
- t.Fatalf("cards drawn = %d, want 1", len(turn.Proposals))
- }
- notice := turn.Proposals[0]
- if strings.TrimSpace(notice.WhenWords) == "" {
- t.Errorf("the card carried no WhenWords; a card that cannot say when is a card nobody can check")
- }
- if !notice.Deadline.IsZero() {
- t.Errorf("the card carried a clock (%s) — a watched card has none", notice.Deadline)
- }
-
- // THE DOCUMENT.
- item := w.onlyItem()
- onDisk := w.itemFile(item.ID)
- if onDisk.Status != standing.StatusActive {
- t.Errorf("status = %q, want active", onDisk.Status)
- }
- if onDisk.Words != standWords {
- t.Errorf("words = %q, want the person's own sentence %q", onDisk.Words, standWords)
- }
- want := before.Add(time.Minute)
- if drift := onDisk.NextDue.Sub(want); drift < -30*time.Second || drift > 90*time.Second {
- t.Errorf("nextDue = %s, want about %s (drift %s)", onDisk.NextDue, want, drift)
- }
- if onDisk.Rails.PerRunUSD <= 0 || onDisk.Rails.MaxPerDay <= 0 {
- t.Errorf("rails are not bounded: %+v", onDisk.Rails)
- }
- t.Logf("ITEM %s status=%s nextDue=%s when.words=%q does=%s/%q rails=$%.2f×%d",
- onDisk.ID, onDisk.Status, onDisk.NextDue.Format(time.RFC3339), onDisk.When.Words,
- onDisk.Does.Kind, onDisk.Does.Say, onDisk.Rails.PerRunUSD, onDisk.Rails.MaxPerDay)
-
- // WHAT THE MODEL WAS TOLD, AND WHAT IT SAID BACK.
- if !strings.Contains(stand.Output, "set up") {
- t.Errorf("the tool result did not say it was set up: %q", stand.Output)
- }
- if turn.Reply == "" {
- t.Fatalf("the model said nothing after the card was answered")
- }
- // ONE LINE IS WHAT THE ENGINE ASKS FOR, and the two halves of that are not
- // the same kind of fact, so they are not the same kind of assertion.
- //
- // THE HARD HALF is that the last thing the person's eye lands on is a
- // sentence FOR THEM. deepseek-v4-flash has been seen answering a ratified
- // card with nothing but its own working out — "This is a `stand` with
- // op=propose, when.kind=at, when.in=\"1m\"" and then silence — and a person
- // who said yes and was told only that was never told what now stands. That
- // is a failure whatever else is true.
- //
- // THE SOFT HALF is a preamble in FRONT of a good sentence. It is the same
- // defect one degree less bad, and it is reported rather than failed for one
- // honest reason: the engine has already done everything the engine can. The
- // instruction is in the tool result the model reads
- // (session.standingRatifiedLine, pinned by its own unit test), it was
- // strengthened after this lane caught the behaviour, and what is left is the
- // model's own compliance — measured here at roughly three runs in four, and
- // worth reading in the log rather than turning this lane red at random.
- final := lastLine(turn.Reply)
- for _, machinery := range []string{"op=propose", "`stand`", "when.kind", "when.in", "does.kind"} {
- if strings.Contains(final, machinery) {
- t.Errorf("the person's last line is the model's working out, not an answer (%q):\n%q", machinery, turn.Reply)
- break
- }
- }
- if strings.Contains(final, "?") {
- t.Errorf("the model asked again after the card was answered: %q", final)
- }
- if strings.Contains(turn.Reply, "\n") {
- t.Logf("MODEL DISCIPLINE: the reply carries a preamble the engine asked it not to write:\n%q", turn.Reply)
- }
- if len(final) > 240 {
- t.Errorf("the closing line is %d bytes, which is a paragraph: %q", len(final), final)
- }
-}
-
-// ── 2 ───────────────────────────────────────────────────────────────────────
-
-func standingFiresIntoTheRoom(t *testing.T) {
- w := newWorld(t)
- workspace := t.TempDir()
- agent, _ := w.open(workspace, nil)
-
- w.say(agent, standWords, answerYes)
- item := w.onlyItem()
-
- // The conversation is still open, so this is road 1 of the delivery order:
- // the room the person is sitting in.
- wakes := agent.Wakes()
- pass := w.tick(item.NextDue.Add(5 * time.Second))
- if pass.Fired != 1 || pass.Said != 1 {
- t.Fatalf("the pass fired=%d said=%d, want 1 and 1 (notes %v)", pass.Fired, pass.Said, pass.Notes)
- }
-
- woken, ok := w.wokenTurn(agent, wakes, 4*time.Minute)
- if !ok {
- t.Fatalf("the firing never woke the conversation")
- }
- steered := "◦ " + item.Words + ":"
- entry, found := transcriptHas(agent, steered)
- if !found {
- t.Fatalf("the conversation never saw %q; transcript tail: %v", steered, tailOf(agent, 4))
- }
- if entry.Role != "aside" {
- t.Errorf("the steering line arrived as role %q, want aside — a firing is not the person typing", entry.Role)
- }
- t.Logf("STEERED IN → %q", entry.Text)
- t.Logf("THE ROOM ANSWERED → %q", woken.Reply)
-
- // The ledger, the wake log, and the item's own end.
- lines := w.ledgerLines(item.NextDue)
- if len(lines) == 0 {
- t.Fatalf("ledger-%s.jsonl has no line", item.NextDue.Format("2006-01-02"))
- }
- t.Logf("LEDGER %+v", lines)
- if wake := w.wakeLog(); len(wake) == 0 {
- t.Errorf("wake.log was never written")
- } else {
- t.Logf("WAKE LOG %v", wake)
- }
- after := w.itemFile(item.ID)
- if after.Status != standing.StatusRetired || after.RetiredWhy != "fired" {
- t.Errorf("after firing status=%q why=%q, want retired/fired", after.Status, after.RetiredWhy)
- }
- if after.Runs != 1 {
- t.Errorf("runs = %d, want 1", after.Runs)
- }
-}
-
-// ── 3 ───────────────────────────────────────────────────────────────────────
-
-func standingInboxFold(t *testing.T) {
- w := newWorld(t)
- workspace := t.TempDir()
- agent, place := w.open(workspace, nil)
-
- w.say(agent, standWords, answerYes)
- item := w.onlyItem()
-
- // The window goes away. Nothing of this project is open, so the firing has
- // only the origin's own folder to write into.
- if err := agent.Close(); err != nil {
- t.Fatalf("close: %v", err)
- }
- w.tick(item.NextDue.Add(5 * time.Second))
-
- inbox := standing.InboxPath(place.Dir)
- raw, err := os.ReadFile(inbox)
- if err != nil {
- t.Fatalf("no inbox at %s: %v", inbox, err)
- }
- t.Logf("INBOX %s\n%s", inbox, strings.TrimSpace(string(raw)))
-
- // And it is folded in exactly once, by the next agent opened on that folder.
- reopened := w.openAt(workspace, place, nil)
- if _, err := os.Stat(inbox); !os.IsNotExist(err) {
- t.Errorf("the inbox survived the drain (%v)", err)
- }
- back := w.say(reopened, "in one short sentence: anything I missed?", nil)
- entry, found := transcriptHas(reopened, "while you were away")
- if !found {
- t.Fatalf("no while-you-were-away fold in the transcript; tail: %v", tailOf(reopened, 4))
- }
- if !strings.Contains(entry.Text, item.Words) {
- t.Errorf("the fold does not lead with the person's words: %q", entry.Text)
- }
- if entry.Role != "aside" {
- t.Errorf("the fold arrived as role %q, want aside", entry.Role)
- }
- t.Logf("FOLD → %q", entry.Text)
- t.Logf("THE MODEL READ IT BACK → %q", back.Reply)
-}
-
-// ── 4 ───────────────────────────────────────────────────────────────────────
-
-func standingErrandReachesTheProject(t *testing.T) {
- w := newWorld(t)
- workspace := t.TempDir()
-
- // Home's `ask here`: a folder under the standing root's exchanges/, so home
- // never lists it, and Config.Errand, so it is never a steering target.
- exchanges := standing.ExchangesRoot(w.store.Root())
- if err := os.MkdirAll(exchanges, 0o700); err != nil {
- t.Fatalf("make exchanges/: %v", err)
- }
- errandPlace := w.place(exchanges, workspace)
- errand := w.openAt(workspace, errandPlace, func(cfg *session.Config) { cfg.Errand = true })
-
- w.say(errand, standWords, answerYes)
- item := w.onlyItem()
- if strings.TrimSpace(item.Origin.Exchange) == "" {
- t.Fatalf("the item made at home has no Origin.Exchange: %+v", item.Origin)
- }
- t.Logf("ORIGIN exchange=%s transcript=%s session=%s",
- item.Origin.Exchange, item.Origin.Transcript, item.Origin.SessionID)
-
- // Nothing open at all: the exchange's own folder is a dead letter office, so
- // the note has to land on the PROJECT.
- if err := errand.Close(); err != nil {
- t.Fatalf("close the errand: %v", err)
- }
- w.tick(item.NextDue.Add(5 * time.Second))
-
- projectInbox := standing.ProjectInboxPath(w.store.Root(), workspace)
- raw, err := os.ReadFile(projectInbox)
- if err != nil {
- t.Fatalf("no project inbox at %s: %v", projectInbox, err)
- }
- t.Logf("PROJECT INBOX %s\n%s", projectInbox, strings.TrimSpace(string(raw)))
-
- // A fresh ordinary conversation of that project folds it in.
- room, _ := w.open(workspace, nil)
- if _, err := os.Stat(projectInbox); !os.IsNotExist(err) {
- t.Errorf("the project inbox survived the drain (%v)", err)
- }
- back := w.say(room, "in one short sentence: anything I missed?", nil)
- if _, found := transcriptHas(room, "while you were away"); !found {
- t.Fatalf("the project's news never reached the conversation; tail: %v", tailOf(room, 4))
- }
- t.Logf("THE PROJECT'S NEWS WAS READ BACK → %q", back.Reply)
-
- // AND WITH BOTH OPEN, IT STEERS INTO THE ROOM AND NEVER INTO THE ERRAND.
- // This is the firing that wrote the rule (standing_run.go's header), so it
- // is driven directly through the runner the door builds.
- secondErrandPlace := w.place(exchanges, workspace)
- secondErrand := w.openAt(workspace, secondErrandPlace, func(cfg *session.Config) { cfg.Errand = true })
- live, _ := w.open(workspace, nil)
- wakes := live.Wakes()
-
- addressed := standing.Item{
- Words: "remind me in 1 min to eat medicines",
- Workspace: workspace,
- Origin: standing.Origin{
- // The folder's name IS the session id (place.go), so this note is
- // addressed to the errand and to nothing else.
- SessionID: secondErrandPlace.ID(),
- Exchange: filepath.Join(w.store.ItemDir("nothing"), "exchange"),
- Transcript: filepath.Join(w.store.ItemDir("nothing"), "exchange", "transcript.jsonl"),
- },
- }
- runner := session.NewStandingRunner(w.posture(), w.store.Root())
- if _, err := runner.Say(context.Background(), addressed, "Time to eat your medicines."); err != nil {
- t.Fatalf("Say: %v", err)
- }
- if _, ok := w.wokenTurn(live, wakes, 3*time.Minute); !ok {
- t.Fatalf("the room the person is sitting in was never told")
- }
- steered := "◦ " + addressed.Words + ": Time to eat your medicines."
- if _, found := transcriptHas(live, steered); !found {
- t.Errorf("the room never saw %q; tail: %v", steered, tailOf(live, 4))
- }
- if entry, found := transcriptHas(secondErrand, "◦ "); found {
- t.Errorf("the errand was steered into: %q", entry.Text)
- }
- // AND NOTHING NEW STOOD. The woken turn declines whatever card it raises
- // ([answerNo]), so the store still holds exactly the item this scenario
- // made — a firing read out in a room must not arm a second one by itself.
- if items, err := w.store.List(); err != nil {
- t.Fatalf("list: %v", err)
- } else if len(items) != 1 {
- t.Errorf("the store holds %d items after a firing was read out, want 1", len(items))
- }
- t.Logf("the errand's transcript stayed clean; the room heard the news")
-}
-
-// ── 5 ───────────────────────────────────────────────────────────────────────
-
-func standingProbeWatch(t *testing.T) {
- w := newWorld(t)
- workspace := t.TempDir()
- flag := filepath.Join(workspace, "flag")
- agent, _ := w.open(workspace, nil)
-
- words := "tell me when the file " + flag + " says red"
- turn := w.say(agent, words, answerYes)
-
- stand, found := turn.named("stand")
- if !found {
- t.Fatalf("the model never called stand; it called %v and said %q", turn.names(), turn.Reply)
- }
- item := w.onlyItem()
- if item.When.Kind != standing.WhenProbe {
- t.Fatalf("when.kind = %q, want probe (call was %s)", item.When.Kind, stand.Args)
- }
- if strings.TrimSpace(item.When.Probe.Command) == "" {
- t.Fatalf("the probe carries no command: %+v", item.When.Probe)
- }
- t.Logf("PROBE command=%q every=%s hint=%q says=%q",
- item.When.Probe.Command, item.When.ProbeEvery, item.When.Hint, item.Does.Say)
-
- // THE SENTINEL'S OWN ROUTING, before a call is made: the role is registered
- // at the low tier and resolves through the person's settings.
- source := w.rolesSource(workspace)
- resolved, err := roles.Resolve(source, roles.Role("sentinel"), w.posture().Model)
- if err != nil {
- t.Fatalf("resolve the sentinel role: %v", err)
- }
- tier, _ := roles.TierModel(source, roles.TierLow)
- if resolved != tier {
- t.Errorf("the sentinel resolved to %q but the low tier holds %q", resolved, tier)
- }
- t.Logf("SENTINEL ROLE → tier=low model=%q", resolved)
-
- // The flag is not there: the check is quiet and nothing is written anywhere
- // but on the item.
- first := time.Now()
- pass := w.tick(first)
- if pass.Fired != 0 {
- t.Fatalf("a check with no flag fired %d times", pass.Fired)
- }
- quiet := w.itemFile(item.ID)
- if strings.TrimSpace(quiet.LastCheckLine) == "" {
- t.Errorf("the item does not remember that it looked: %+v", quiet)
- }
- if quiet.Runs != 0 {
- t.Errorf("runs = %d after a quiet check, want 0", quiet.Runs)
- }
- t.Logf("QUIET CHECK → lastChecked=%s line=%q", quiet.LastChecked.Format(time.RFC3339), quiet.LastCheckLine)
-
- // The world changes.
- if err := os.WriteFile(flag, []byte("red\n"), 0o600); err != nil {
- t.Fatalf("write the flag: %v", err)
- }
- next := quiet.NextDue.Add(5 * time.Second)
- pass = w.tick(next)
- if pass.Fired != 1 || pass.Said != 1 {
- t.Fatalf("the flag turned red and the pass fired=%d said=%d", pass.Fired, pass.Said)
- }
- fired := w.itemFile(item.ID)
- if fired.Runs != 1 {
- t.Errorf("runs = %d, want 1", fired.Runs)
- }
- if fired.LastOutcome != "said" {
- t.Errorf("lastOutcome = %q, want said", fired.LastOutcome)
- }
- t.Logf("FIRED → line=%q outcome=%q previous=%v", fired.LastCheckLine, fired.LastOutcome, fired.Previous)
-}
-
-// ── 6 ───────────────────────────────────────────────────────────────────────
-
-func standingRhythmLifecycle(t *testing.T) {
- w := newWorld(t)
- workspace := t.TempDir()
- agent, _ := w.open(workspace, nil)
-
- turn := w.say(agent, "every 2 minutes tell me the time", answerYes)
- if _, found := turn.named("stand"); !found {
- t.Fatalf("the model never called stand; it called %v and said %q", turn.names(), turn.Reply)
- }
- item := w.onlyItem()
- if item.When.Kind != standing.WhenEvery {
- t.Fatalf("when.kind = %q, want every", item.When.Kind)
- }
- if strings.TrimSpace(item.When.Every) == "" {
- t.Fatalf("when.every is empty: %+v", item.When)
- }
- t.Logf("RHYTHM every=%q words=%q nextDue=%s", item.When.Every, item.When.Words, item.NextDue.Format(time.RFC3339))
-
- for _, step := range []struct {
- say string
- want standing.Status
- }{
- {"pause that", standing.StatusPaused},
- {"resume it", standing.StatusActive},
- {"stop it", standing.StatusRetired},
- } {
- turn := w.say(agent, step.say, answerYes)
- if _, found := turn.named("stand"); !found {
- t.Errorf("%q did not reach the stand tool; calls were %v, reply %q", step.say, turn.names(), turn.Reply)
- }
- got := w.itemFile(item.ID)
- if got.Status != step.want {
- t.Errorf("after %q status = %q, want %q (reply %q)", step.say, got.Status, step.want, turn.Reply)
- } else {
- t.Logf("%-10q → status=%s reply=%q", step.say, got.Status, turn.Reply)
- }
- }
-
- listing := w.say(agent, "list what stands here", answerYes)
- call, found := listing.named("stand")
- if !found {
- t.Fatalf("listing never reached the tool; calls were %v", listing.names())
- }
- if !strings.Contains(call.Args, `"list"`) {
- t.Errorf("op was not list: %s", call.Args)
- }
- if !strings.Contains(call.Output, item.Words) {
- t.Errorf("op=list did not name the item: %q", call.Output)
- }
- t.Logf("LIST → %s", strings.TrimSpace(call.Output))
-}
-
-// ── 7 ───────────────────────────────────────────────────────────────────────
-
-func standingTaskFiring(t *testing.T) {
- w := newWorld(t)
- workspace := t.TempDir()
- agent, _ := w.open(workspace, nil)
-
- words := "in 2 minutes, actually run `echo hi > done.txt` in this folder — do the work, don't just remind me"
- turn := w.say(agent, words, answerYes)
- if _, found := turn.named("stand"); !found {
- t.Fatalf("the model never called stand; it called %v and said %q", turn.names(), turn.Reply)
- }
- item := w.onlyItem()
- if item.Does.Kind != standing.ActionTask {
- t.Fatalf("does.kind = %q, want task — the brief was %q", item.Does.Kind, item.Does.Brief)
- }
- t.Logf("TASK ITEM brief=%q acceptance=%q maxSteps=%d rails=$%.2f×%d",
- item.Does.Brief, item.Does.Acceptance, item.Does.MaxSteps, item.Rails.PerRunUSD, item.Rails.MaxPerDay)
-
- at := item.NextDue.Add(5 * time.Second)
- pass := w.tick(at)
- if pass.Fired != 1 {
- t.Fatalf("the pass fired=%d, want 1 (notes %v)", pass.Fired, pass.Notes)
- }
- fired := w.itemFile(item.ID)
- if fired.Runs != 1 {
- t.Errorf("runs = %d, want 1", fired.Runs)
- }
- runs, err := os.ReadDir(w.store.RunsDir(item.ID))
- if err != nil || len(runs) == 0 {
- t.Fatalf("no run folder under %s: %v", w.store.RunsDir(item.ID), err)
- }
- runDir := filepath.Join(w.store.RunsDir(item.ID), runs[0].Name())
- entries, _ := os.ReadDir(runDir)
- names := make([]string, 0, len(entries))
- for _, entry := range entries {
- names = append(names, entry.Name())
- }
- t.Logf("RUN %s holds %v", runDir, names)
- if came, err := os.ReadFile(filepath.Join(runDir, standing.CameTo)); err == nil {
- t.Logf("CAME TO → %s", strings.TrimSpace(string(came)))
- }
- t.Logf("OUTCOME %q needsPerson=%q lastRun=%s", fired.LastOutcome, fired.NeedsPerson, fired.LastRun)
-
- var spend float64
- for _, line := range w.ledgerLines(at) {
- t.Logf("LEDGER %s %s $%.6f %s", line.At.Format("15:04:05"), line.Kind, line.USD, line.Run)
- spend += line.USD
- }
- if spend <= 0 {
- t.Errorf("the ledger recorded no spend for a firing that ran a whole session")
- }
- w.bill("firing", spend)
- if made, err := os.ReadFile(filepath.Join(workspace, "done.txt")); err == nil {
- t.Logf("THE WORK LANDED: done.txt = %q", strings.TrimSpace(string(made)))
- } else {
- t.Logf("done.txt was not written in the workspace (%v) — the run's own account is above", err)
- }
-}
-
-// ── 8 ───────────────────────────────────────────────────────────────────────
-
-// standingDailyRail is the one scenario with no model in it, deliberately: the
-// rail is arithmetic over the ledger and putting a model in front of it would
-// only make the failure harder to read.
-func standingDailyRail(t *testing.T) {
- w := newWorld(t)
- workspace := t.TempDir()
-
- item, err := w.store.Create(standing.Item{
- Words: "every minute, say the word",
- Workspace: workspace,
- When: standing.When{Kind: standing.WhenEvery, Every: "1m", Words: "every minute"},
- Does: standing.Action{Kind: standing.ActionSay, Say: "the word"},
- Rails: standing.Rails{PerRunUSD: 0.01, MaxPerDay: 1},
- })
- if err != nil {
- t.Fatalf("create: %v", err)
- }
-
- first := w.tick(item.NextDue.Add(5 * time.Second))
- if first.Fired != 1 {
- t.Fatalf("the first pass fired=%d, want 1 (notes %v)", first.Fired, first.Notes)
- }
- after := w.itemFile(item.ID)
- second := w.tick(after.NextDue.Add(5 * time.Second))
- if second.Fired != 0 {
- t.Errorf("the second pass fired=%d, want 0 — max_per_day is 1", second.Fired)
- }
- if second.Skipped != 1 {
- t.Errorf("the second pass skipped=%d, want 1", second.Skipped)
- }
- lines := w.wakeLog()
- if len(lines) < 2 {
- t.Fatalf("wake.log has %d lines, want 2: %v", len(lines), lines)
- }
- last := lines[len(lines)-1]
- if !strings.Contains(last, "skipped=1") || !strings.Contains(last, "fired=0") {
- t.Errorf("the second wake line is %q, want fired=0 and skipped=1", last)
- }
- t.Logf("WAKE LOG\n %s", strings.Join(lines, "\n "))
- held := w.itemFile(item.ID)
- t.Logf("HELD BACK → %q", held.LastCheckLine)
- if !strings.Contains(held.LastCheckLine, "already run today") {
- t.Errorf("the item does not say why it was held: %q", held.LastCheckLine)
- }
-}
-
-// ── 9 ───────────────────────────────────────────────────────────────────────
-
-func standingUnwatchedRefuses(t *testing.T) {
- w := newWorld(t)
- workspace := t.TempDir()
- // The ambient seam is still there — the tool is on the belt — and the ONE
- // thing missing is somebody to answer. That is the belt-and-braces half of
- // the law tools_standing.go states.
- agent, _ := w.open(workspace, func(cfg *session.Config) { cfg.AskConsent = false })
-
- turn := w.say(agent, standWords, answerYes)
- stand, found := turn.named("stand")
- if !found {
- t.Fatalf("the model never called stand; it called %v and said %q", turn.names(), turn.Reply)
- }
- if !strings.Contains(stand.Output, "nobody is here to say yes") {
- t.Fatalf("the engine did not refuse in its own words: %q", stand.Output)
- }
- if len(turn.Proposals) != 0 {
- t.Errorf("a card was drawn into an empty room: %+v", turn.Proposals)
- }
- items, err := w.store.List()
- if err != nil {
- t.Fatalf("list: %v", err)
- }
- if len(items) != 0 {
- t.Errorf("something stands after a refusal: %+v", items)
- }
- t.Logf("REFUSAL → %q", stand.Output)
- t.Logf("THE MODEL REPORTED → %q", turn.Reply)
- if turn.Reply == "" {
- t.Errorf("the model said nothing about a refusal the person needs to hear")
- }
-}
-
-// ── 10 ──────────────────────────────────────────────────────────────────────
-
-// standingPastMoment is the card's past-guard, and it SKIPS when that lane has
-// not landed: `in` and `at` are both accepted as written in this build, so a
-// moment already gone is armed and fires on the next pass. The assertion is
-// written out so the lane's own merge turns it on rather than needing writing.
-func standingPastMoment(t *testing.T) {
- if !pastGuardLanded() {
- t.Skip("the past-guard lane has not merged: nothing in tools_standing.go refuses a moment that has passed")
- }
- w := newWorld(t)
- workspace := t.TempDir()
- agent, _ := w.open(workspace, nil)
-
- turn := w.say(agent, "remind me at 00:01 today to drink water", answerYes)
- // TWO HONEST ROADS, AND THE TEST TAKES EITHER. A model that reads the Now
- // line can see for itself that 00:01 has gone and say so without touching
- // the tool; one that does call stand is refused with the time it is now and
- // may re-propose a moment ahead. What is NOT allowed is the third road the
- // person's own transcript showed: a card for a moment already behind them.
- stand, called := turn.named("stand")
- if !called {
- lower := strings.ToLower(turn.Reply)
- if !strings.Contains(lower, "passed") && !strings.Contains(lower, "already") && !strings.Contains(lower, "ago") {
- t.Fatalf("the model neither called stand nor said the moment has gone: %q", turn.Reply)
- }
- if items, _ := w.store.List(); len(items) != 0 {
- t.Fatalf("nothing should stand for a moment that has gone; found %d", len(items))
- }
- t.Logf("DECLINED BEFORE THE TOOL → %q", turn.Reply)
- return
- }
- if !strings.Contains(strings.ToLower(stand.Output), "passed") {
- // The model may already have computed a future moment from the Now
- // line (tomorrow 00:01); that is the guard working upstream of itself.
- item := w.onlyItem()
- if !item.When.At.After(time.Now()) {
- t.Fatalf("the engine took a moment that has gone: %q (at %s)", stand.Output, item.When.At)
- }
- t.Logf("PROPOSED A FUTURE MOMENT OUTRIGHT → %s", item.When.At.Format(time.RFC3339))
- return
- }
- if !strings.Contains(stand.Output, "now") {
- t.Errorf("the refusal did not carry the now line: %q", stand.Output)
- }
- t.Logf("REFUSAL → %q", stand.Output)
- if items, _ := w.store.List(); len(items) == 1 {
- if !items[0].When.At.After(time.Now()) {
- t.Errorf("the model re-proposed a moment that is still in the past: %s", items[0].When.At)
- }
- t.Logf("RE-PROPOSED → %s", items[0].When.At.Format(time.RFC3339))
- }
-}
-
-// pastGuardLanded reads the engine's own refusal out of the source it would be
-// written in, so this file needs no edit when the lane merges.
-func pastGuardLanded() bool {
- raw, err := os.ReadFile("../session/tools_standing.go")
- if err != nil {
- return false
- }
- return strings.Contains(string(raw), "moment that has passed")
-}
-
-// ── 11 ──────────────────────────────────────────────────────────────────────
-
-func standingSweep(t *testing.T) {
- w := newWorld(t)
- workspace := t.TempDir()
-
- item, err := w.store.Create(standing.Item{
- Words: "keep an eye on the build",
- Workspace: workspace,
- When: standing.When{Kind: standing.WhenEvery, Every: "1h", Words: "hourly"},
- Does: standing.Action{Kind: standing.ActionSay, Say: "the build changed"},
- Rails: standing.Rails{PerRunUSD: 0.01, MaxPerDay: 4},
- })
- if err != nil {
- t.Fatalf("create: %v", err)
- }
-
- // Two run folders: one that came to nothing, one that said something. The
- // marker is the sweep's whole licence (standing.RunCameToNothing).
- empty := filepath.Join(w.store.RunsDir(item.ID), "0001")
- kept := filepath.Join(w.store.RunsDir(item.ID), "0002")
- for dir, came := range map[string]string{empty: standing.OutcomeNothing, kept: "said"} {
- if err := os.MkdirAll(dir, 0o700); err != nil {
- t.Fatalf("make %s: %v", dir, err)
- }
- if err := os.WriteFile(filepath.Join(dir, standing.CameTo), []byte(came+"\n"), 0o600); err != nil {
- t.Fatalf("mark %s: %v", dir, err)
- }
- }
-
- // Past RunKeep, asked as a moment rather than by backdating the tree: the
- // sweep takes its now as an argument for exactly this reason.
- var notes []string
- session.SweepStanding(w.store.Root(), time.Now().Add(standing.RunKeep+24*time.Hour),
- func(line string) { notes = append(notes, line) })
- t.Logf("SWEEP notes=%v", notes)
-
- if _, err := os.Stat(empty); !os.IsNotExist(err) {
- t.Errorf("the run that came to nothing survived the sweep (%v)", err)
- }
- if _, err := os.Stat(kept); err != nil {
- t.Errorf("the run that said something was reaped: %v", err)
- }
- if _, err := os.Stat(w.store.ItemPath(item.ID)); err != nil {
- t.Errorf("the sweep took the item itself: %v", err)
- }
- still, err := w.store.Get(item.ID)
- if err != nil {
- t.Fatalf("the item is gone: %v", err)
- }
- t.Logf("KEPT item %s status=%s; runs/ now holds the run that delivered something", still.ID, still.Status)
-}
-
-// tailOf is the last few display rows of a conversation, for a failure message
-// that has to say what the transcript actually holds.
-func tailOf(agent *session.Agent, count int) []string {
- entries := agent.Transcript()
- if len(entries) > count {
- entries = entries[len(entries)-count:]
- }
- out := make([]string, 0, len(entries))
- for _, entry := range entries {
- out = append(out, entry.Role+": "+shorten(entry.Text, 160))
- }
- return out
-}
diff --git a/internal/e2e/standing_recognition_e2e_test.go b/internal/e2e/standing_recognition_e2e_test.go
deleted file mode 100644
index 0364f39f81..0000000000
--- a/internal/e2e/standing_recognition_e2e_test.go
+++ /dev/null
@@ -1,127 +0,0 @@
-//go:build e2e
-
-package e2e
-
-// standing_recognition_e2e_test.go asks the real model which ordinary
-// sentences deserve a standing-order card. It asserts only the proposal event
-// and the structured shape on that event; the model's prose is not the
-// recognition contract.
-//
-// go test -tags e2e -run TestStandingRecognitionE2E -count=1 -v ./internal/e2e/
-
-import (
- "testing"
-
- "github.com/Agent-Field/codeaf/internal/session"
- "github.com/Agent-Field/codeaf/internal/standing"
-)
-
-func TestStandingRecognitionE2E(t *testing.T) {
- liveKey(t)
-
- tests := []struct {
- name string
- sentence string
- wantProposal bool
- wantKinds []standing.WhenKind
- wantAltitude standing.Altitude
- }{
- {
- name: "1 a reminder is standing",
- sentence: "remind me in two minutes to stretch",
- wantProposal: true,
- wantKinds: []standing.WhenKind{standing.WhenAt},
- },
- {
- name: "2 a weekly rhythm is standing",
- sentence: "every monday at 9 summarize where this project stands",
- wantProposal: true,
- wantKinds: []standing.WhenKind{standing.WhenEvery},
- },
- {
- name: "3 a file watch is standing",
- sentence: "tell me when the file README.md changes",
- wantProposal: true,
- wantKinds: []standing.WhenKind{standing.WhenFile, standing.WhenProbe},
- },
- {
- // This case depends on the sibling lane's doctrine recognizing a persistent finishing habit.
- name: "4 a finishing habit is standing",
- sentence: "from now on, whenever you finish a task here, run gofmt before landing",
- wantProposal: true,
- },
- {
- name: "5 immediate work is not standing",
- sentence: "run the tests",
- },
- {
- // This discharge test depends on the sibling lane's doctrine separating current-work acceptance from persistence.
- name: "6 current work acceptance is not standing",
- sentence: "make sure the website you are building has exactly 3 pages",
- },
- {
- name: "7 a question is not standing",
- sentence: "what time is it?",
- },
- {
- name: "8 everywhere promotes the altitude",
- sentence: "everywhere, not just this project: tell me when disk usage gets above 90%",
- wantProposal: true,
- wantAltitude: standing.AltitudeMachine,
- },
- }
-
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- w := newWorld(t)
- for attempt := 1; attempt <= 2; attempt++ {
- agent, _ := w.open(t.TempDir(), nil)
- got, notice := waitForStandingProposal(w, agent, test.sentence)
- problem := standingRecognitionMismatch(test.wantProposal, test.wantKinds, test.wantAltitude, got, notice)
- if problem == "" {
- return
- }
- t.Logf("attempt %d did not match: %s", attempt, problem)
- if attempt == 2 {
- t.Fatal(problem)
- }
- }
- })
- }
-}
-
-// waitForStandingProposal submits one sentence through the shared bounded turn
-// driver and declines any card so recognition alone cannot alter later facts.
-func waitForStandingProposal(w *world, agent *session.Agent, sentence string) (bool, session.StandingNotice) {
- w.t.Helper()
- turn := w.say(agent, sentence, answerNo)
- if len(turn.Proposals) == 0 {
- return false, session.StandingNotice{}
- }
- return true, turn.Proposals[0]
-}
-
-func standingRecognitionMismatch(wantProposal bool, wantKinds []standing.WhenKind, wantAltitude standing.Altitude, got bool, notice session.StandingNotice) string {
- if got != wantProposal {
- return "standing proposal presence did not match the sentence"
- }
- if !got {
- return ""
- }
- if len(wantKinds) > 0 {
- matched := false
- for _, kind := range wantKinds {
- if notice.Item.When.Kind == kind {
- matched = true
- break
- }
- }
- if !matched {
- return "standing proposal carried the wrong waking kind"
- }
- }
- if wantAltitude != "" && notice.Item.Level() != wantAltitude {
- return "standing proposal carried the wrong altitude"
- }
- return ""
-}
diff --git a/internal/e2e/standing_sentinel_grounding_e2e_test.go b/internal/e2e/standing_sentinel_grounding_e2e_test.go
deleted file mode 100644
index 067478e00e..0000000000
--- a/internal/e2e/standing_sentinel_grounding_e2e_test.go
+++ /dev/null
@@ -1,158 +0,0 @@
-//go:build e2e
-
-package e2e
-
-// standing_sentinel_grounding_e2e_test.go is the LIVE regression for the
-// readiness false positive the independent review recorded on d179d5aa1: a
-// status-only probe against a readiness ask fired "yes" 10/10 times, including
-// with no hint.
-//
-// IT DRIVES THE PRODUCTION SEAM, NOT A RECONSTRUCTION. [session.NewStandingSentinelVerdict]
-// composes the real sentinel prompt and the real question and sends them to the
-// real provider; nothing here copies a prompt, fakes a verdict, or reads a
-// canned answer. The model is the exact slug the review used,
-// deepseek/deepseek-v4.1-flash, pinned through the item's own OneModel origin so
-// there is no role pin, no tier and no fallback ladder to rescue a bad slug.
-//
-// IT IS OPT-IN BY THE e2e TAG AND THE USUAL KEY GATE. No key on the machine is a
-// DOCUMENTED SKIP (the skip line names every road the product reads) -- a skip
-// is never acceptance. Root reruns this after the safe matrix; the recorded
-// failing shape is the one case that must have moved.
-//
-// A DOUBLE CANNOT STAND IN FOR THIS. The in-package witnesses read strings out
-// of the composed prompt; only a call through the provider shows what the model
-// does with it, which is exactly the gap the review found.
-
-import (
- "context"
- "fmt"
- "strings"
- "testing"
- "time"
-
- "github.com/Agent-Field/codeaf/internal/session"
- "github.com/Agent-Field/codeaf/internal/standing"
-)
-
-// groundingSlug is the exact model every call in this lane is pinned to.
-const groundingSlug = "deepseek/deepseek-v4.1-flash"
-
-// groundingAsk is the person's own sentence from the recorded failure: an
-// application readiness condition, NOT a status condition.
-const groundingAsk = "Notify me once when the health endpoint at http://127.0.0.1:18777/ready becomes ready. While it is not ready, check it every five minutes."
-
-// statusOnlyCurl is the probe the failure reduced the ask to: it throws the body
-// away and prints only the status code, so 200 is printed in both states.
-const statusOnlyCurl = "curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://127.0.0.1:18777/ready"
-
-// bodyCurl is the probe that reads what the person named: the response body
-// itself, where a readiness endpoint reports its real state.
-const bodyCurl = "curl -s --max-time 10 http://127.0.0.1:18777/ready"
-
-// groundingCase is one row of the matrix.
-type groundingCase struct {
- name string
- words string
- hint string
- command string
- evidence string
- expect standing.SentinelReading
- // decisive marks the recorded failing shape: a wrong answer here is a
- // failure of the fix, not a wobble to note.
- decisive bool
- samples int
-}
-
-func groundingVerdictName(v standing.SentinelReading) string {
- switch v {
- case standing.VerdictYes:
- return "yes"
- case standing.VerdictNo:
- return "no"
- }
- return "unknown"
-}
-
-// groundingMatrix is the whole recorded shape plus the states the fix must not
-// break: the person's own status criterion (legitimate bodyless contract), a
-// body that contradicts the hint both ways, the first look finding it already
-// true, and the fail-closed readings.
-func groundingMatrix() []groundingCase {
- return []groundingCase{
- {name: "bare200_vs_readiness", words: groundingAsk, hint: "yes when the output is 200", command: statusOnlyCurl, evidence: "200", expect: standing.VerdictUnknown, decisive: true, samples: 3},
- {name: "bare200_nohint", words: groundingAsk, hint: "", command: statusOnlyCurl, evidence: "200", expect: standing.VerdictUnknown, decisive: true, samples: 2},
- {name: "explicit_user_http200", words: "Notify me once when the endpoint answers HTTP 200.", hint: "yes when the output is 200", command: statusOnlyCurl, evidence: "200", expect: standing.VerdictYes, samples: 2},
- {name: "jsonfalse_contradicts_hint", words: groundingAsk, hint: "yes when the output is 200", command: bodyCurl, evidence: `{"ready": false}`, expect: standing.VerdictNo, samples: 1},
- {name: "jsontrue_contradicts_hint", words: groundingAsk, hint: "yes when the output is 200", command: bodyCurl, evidence: `{"ready": true}`, expect: standing.VerdictYes, samples: 1},
- {name: "currently_ready_first_look", words: groundingAsk, hint: "yes when the output is 200", command: bodyCurl, evidence: `{"ready": true}`, expect: standing.VerdictYes, samples: 1},
- {name: "missing_body", words: groundingAsk, hint: "yes when the output is 200", command: statusOnlyCurl, evidence: "", expect: standing.VerdictUnknown, samples: 1},
- {name: "failed_command", words: groundingAsk, hint: "yes when the output is 200", command: statusOnlyCurl, evidence: "200\n(the command failed: exit status 7)", expect: standing.VerdictUnknown, samples: 1},
- {name: "clipped_body", words: groundingAsk, hint: "yes when the output is 200", command: "tail -c 65536 /var/log/app.log", evidence: "\u2026\n" + strings.Repeat("noise line with no readiness in it\n", 200), expect: standing.VerdictUnknown, samples: 1},
- }
-}
-
-// groundingJudgment is the exact judgment the live ticker would hand the
-// sentinel: the person's words, the proposer's hint, the probe command, the
-// evidence, and the one-model origin that pins the model.
-func groundingJudgment(c groundingCase) standing.Judgment {
- return standing.Judgment{
- Item: standing.Item{
- Words: c.words,
- When: standing.When{
- Kind: standing.WhenProbe,
- Hint: c.hint,
- Probe: standing.Probe{Command: c.command},
- },
- Origin: standing.Origin{OneModel: true, PinnedModel: groundingSlug},
- },
- Evidence: c.evidence,
- }
-}
-
-// TestStandingSentinelGroundingE2E is the live matrix. It fails when a case
-// answers other than the recorded truth, and it fails the whole run when the
-// recorded failing shape does not answer unknown.
-func TestStandingSentinelGroundingE2E(t *testing.T) {
- key := liveKey(t)
- w := newWorld(t)
- cfg := w.posture()
- cfg.Model = groundingSlug
- cfg.APIKey = key
- sentinel := session.NewStandingSentinelVerdict(cfg)
-
- ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
- defer cancel()
-
- t.Logf("model: %s (pinned OneModel, no fallback); %s is the recorded failure shape", groundingSlug, "bare200_vs_readiness")
- spent, wrong, decided := 0.0, 0, true
- for _, c := range groundingMatrix() {
- for i := 0; i < c.samples; i++ {
- verdict, line, usd, err := sentinel(ctx, groundingJudgment(c))
- spent += usd
- if err != nil {
- if c.decisive {
- decided = false
- }
- t.Errorf("%s sample %d: sentinel error: %v", c.name, i+1, err)
- continue
- }
- got := groundingVerdictName(verdict)
- t.Logf("%s sample %d: expect %s got %s -- %s", c.name, i+1, groundingVerdictName(c.expect), got, line)
- if verdict != c.expect {
- wrong++
- if c.decisive {
- decided = false
- }
- t.Errorf("%s sample %d: want %s got %s -- %s", c.name, i+1, groundingVerdictName(c.expect), got, line)
- }
- }
- }
- t.Logf("matrix cost: $%.6f", spent)
- if !decided {
- t.Fatalf("the recorded failing shape (bare200_vs_readiness) did not answer unknown: the readiness false positive is live")
- }
- if wrong > 0 {
- t.Fatalf("%d of the matrix's samples answered other than the recorded truth", wrong)
- }
- fmt.Println("standing sentinel grounding matrix: all cases answered the recorded truth")
-}
diff --git a/internal/e2e/taskcustody_e2e_test.go b/internal/e2e/taskcustody_e2e_test.go
index 02a336d54b..62f929b78a 100644
--- a/internal/e2e/taskcustody_e2e_test.go
+++ b/internal/e2e/taskcustody_e2e_test.go
@@ -209,7 +209,7 @@ func runCustody(t *testing.T, w *world, attempt int) *custodyRun {
run.pieces = run.awaitHeldPieces()
t.Logf("ATTEMPT %d: %d pieces out under the conversation: %s", attempt, len(run.pieces), run.pieceLog())
- w.say(agent, custodyAsk, answerNo)
+ w.say(agent, custodyAsk)
run.wall = time.Since(started)
run.rows = readCustodyRows(t, place.Tasks())
@@ -700,9 +700,10 @@ func custodyConfig(w *world) func(*session.Config) {
// SETTLED for the whole of the turn — which is the entire fact
// [Agent.piecesStillOut] reads — without a worktree, a worker or a call.
cfg.TaskMinFreeMB = custodyMemoryFloor
- // AND THE TURN MAY WRITE. The ambient lane's policy allows the reading tools
- // only, which is right for a conversation that reads and remembers; this one
- // has to cross the write allowance to reach the road at all.
+ // AND THE TURN MAY WRITE. A conversation's own policy ([world.policy])
+ // allows the reading tools only, which is right for a conversation that
+ // reads and remembers; this one has to cross the write allowance to reach
+ // the road at all.
policy, err := approval.Load(map[string]any{"default": "allow"})
if err != nil {
panic("approval.Load: " + err.Error())
diff --git a/internal/e2e/testdata/oldtimers/main.go b/internal/e2e/testdata/oldtimers/main.go
new file mode 100644
index 0000000000..916d1bf04b
--- /dev/null
+++ b/internal/e2e/testdata/oldtimers/main.go
@@ -0,0 +1,16 @@
+// Command oldtimers is the first thing every codeaf does on start, and nothing
+// else: it takes the old feature's timers off the login it runs as
+// (internal/automation's legacy.go).
+//
+// IT IS A PROGRAM OF ITS OWN FOR ONE TEST. The removal refuses to act inside a
+// test binary, so internal/e2e's host guard cannot prove what it does by
+// calling it; it builds this, runs it behind the guard with a throwaway HOME
+// and stand-ins for launchctl and systemctl first on PATH, and reads what the
+// stand-ins were asked (hostguard_test.go's
+// TestTheOldTimersComeOffOnlyTheThrowawayLogin). It lives under testdata so
+// that `go build ./...` and `go vet ./...` never meet it.
+package main
+
+import "github.com/Agent-Field/codeaf/internal/automation"
+
+func main() { automation.RemoveOldTimers() }
diff --git a/internal/e2e/tmux_test.go b/internal/e2e/tmux_test.go
index 3a4649ba63..096646ce40 100644
--- a/internal/e2e/tmux_test.go
+++ b/internal/e2e/tmux_test.go
@@ -22,12 +22,14 @@
// stood by accident" and gets an owned session in a private work directory
// (cmd/codeaf's chatv3_layout.go), which is not the shape any of these
// scenarios are about, so the workspace is always a repository.
-// Every run also gets scheduler stand-ins and a separate login folder. The
-// machine's own background timer is never this suite's timer.
+// AND EVERY RUN STANDS BEHIND THE HOST GUARD (hostguard_test.go): a login
+// folder of its own, stand-ins for the machine's schedulers and notifiers, and
+// the automations clock off unless the scenario is about it. Every start of the
+// product takes the old timers off the login it runs as, and every window
+// starts a clock, so none of that may ever be the developer's.
package e2e
import (
- "context"
"encoding/json"
"fmt"
"os"
@@ -361,9 +363,11 @@ func startFresh(t *testing.T, name, home, ws string, cols, rows int, args ...str
// front of the assignments, then the state root and the terminal.
func startWithEnv(t *testing.T, env []string, name, home, ws string, cols, rows int, args ...string) *rig {
t.Helper()
- // THE PRODUCT WRITES THE TIMER DEFINITION UNDER HOME BEFORE IT ASKS
- // SYSTEMCTL OR LAUNCHCTL TO LOAD IT. Both must belong to this rig, or
- // #1631 replaces the developer's timer with a deleted checkout.
+ // EVERY START TAKES THE OLD TIMERS OFF THE LOGIN IT RUNS AS, AND EVERY
+ // WINDOW STARTS A CLOCK. The login, the schedulers it would ask and the
+ // clock's switch all belong to this rig, or a test run boots the
+ // developer's own timers out of launchd or systemd (#1631 is where the
+ // guard began, when a launch could still install one).
g := guardHost(t, home)
// EVERY RUN ON THIS HOST NAMES ITS OWN RIG. Several checkouts run this
// suite at once on one machine, and with a fixed session name each start()
@@ -680,16 +684,13 @@ func (r *rig) dump() {
}
fmt.Fprintf(&b, " %s (%d bytes)\n", rel, size)
switch filepath.Base(path) {
- case "transcript.jsonl", "inbox.jsonl", "log", "wake.log", "usage.jsonl", "calls.jsonl":
+ // clock.log is the automations clock's own account of what it could
+ // not do (internal/automation's LogName), which is the first thing a
+ // failed clock scenario needs read.
+ case "transcript.jsonl", "log", "clock.log", "usage.jsonl", "calls.jsonl":
if raw, err := os.ReadFile(path); err == nil && len(raw) > 0 {
fmt.Fprintf(&b, "%s\n", clip(string(raw), 4000))
}
- default:
- if strings.HasSuffix(path, ".json") && strings.Contains(path, "standing") {
- if raw, err := os.ReadFile(path); err == nil {
- fmt.Fprintf(&b, "%s\n", clip(string(raw), 2000))
- }
- }
}
return nil
})
@@ -703,18 +704,6 @@ func clip(s string, n int) string {
return s[:n] + "\n… clipped"
}
-// tick runs one pass of the ambient side from outside every window, exactly as
-// the launchd agent and the systemd timer do.
-func tick(t *testing.T, home string) string {
- t.Helper()
- command := guardedCommand(t, context.Background(), home, append(os.Environ(), "CODEAF_HOME="+home), binary(t), "tick")
- out, err := command.CombinedOutput()
- if err != nil {
- t.Fatalf("codeaf tick: %v\n%s", err, out)
- }
- return string(out)
-}
-
// ── seeding ─────────────────────────────────────────────────────────────────
// seedProject writes the smallest thing world.go will read as a project: a
@@ -857,66 +846,6 @@ func writeJSON(t *testing.T, path string, value any) {
}
}
-// seedNews puts notes in one conversation's inbox — what a firing left when no
-// window was open. It is the [standing.Note] shape, written the way
-// standing.Deliver writes it.
-func seedNews(t *testing.T, sessionDir string, texts ...string) {
- t.Helper()
- var b strings.Builder
- for i, text := range texts {
- note := map[string]any{
- "at": time.Now().Add(-time.Duration(i+1) * time.Minute).Format(time.RFC3339Nano),
- "item": fmt.Sprintf("%016x", 0x2000000000000000+i),
- "words": fmt.Sprintf("watch number %d", i+1),
- "kind": "said",
- "text": text,
- }
- raw, err := json.Marshal(note)
- if err != nil {
- t.Fatalf("seed news: %v", err)
- }
- b.Write(raw)
- b.WriteString("\n")
- }
- if err := os.WriteFile(filepath.Join(sessionDir, "inbox.jsonl"), []byte(b.String()), 0o644); err != nil {
- t.Fatalf("seed news: %v", err)
- }
-}
-
-// projectInbox is what the project's own inbox holds — road 4 of a firing's
-// delivery, and the only address an `ask here` errand has when no window is up.
-func projectInbox(t *testing.T, home, workspace string) string {
- t.Helper()
- root := filepath.Join(home, "v3", "standing")
- var found string
- _ = filepath.WalkDir(filepath.Join(root, "inbox"), func(path string, entry os.DirEntry, err error) error {
- if err != nil || entry.IsDir() || filepath.Base(path) != "inbox.jsonl" {
- return nil
- }
- found = path
- return nil
- })
- if found == "" {
- // The layout is standing.ProjectInboxDir's; walk the whole standing root
- // rather than repeating its encoding here.
- _ = filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
- if err != nil || entry.IsDir() || filepath.Base(path) != "inbox.jsonl" {
- return nil
- }
- found = path
- return nil
- })
- }
- if found == "" {
- return ""
- }
- raw, err := os.ReadFile(found)
- if err != nil {
- return ""
- }
- return string(raw)
-}
-
// sessionTranscripts is every conversation this run wrote, so a test can prove
// what reached the journal when the screen is the thing in question.
func sessionTranscripts(t *testing.T, home string) map[string]string {
@@ -933,137 +862,3 @@ func sessionTranscripts(t *testing.T, home string) map[string]string {
})
return out
}
-
-// ── what stood, as the store wrote it ───────────────────────────────────────
-
-// standingRecord is the part of one standing item's file this suite reads.
-//
-// THE MODEL OWNS THIS VOCABULARY AND THE TEST MAY NOT ASSUME IT. What a reminder
-// is CALLED and what it SAYS when it fires are written by the model on the day —
-// one run named the same order `drink water reminder` and fired `💧 Time to
-// drink water!` — so a test that waited for the person's own sentence would be
-// waiting for words nothing promised. The record on disk is the one place both
-// are stated, so the needles come off it, exactly as every needle about the
-// SURFACE comes off internal/tui3's own sources (tuiwords_test.go).
-type standingRecord struct {
- ID string `json:"id"`
- Words string `json:"words"`
- Status string `json:"status"`
- RetiredWhy string `json:"retiredWhy"`
- LastCheckLine string `json:"lastCheckLine"`
- When struct {
- Kind string `json:"kind"`
- At time.Time `json:"at"`
- } `json:"when"`
- Does struct {
- Kind string `json:"kind"`
- Say string `json:"say"`
- } `json:"does"`
- Rails struct {
- Expires time.Time `json:"expires"`
- } `json:"rails"`
- Brief struct {
- Title string `json:"title"`
- } `json:"brief"`
-}
-
-// standingRecords is every item this run stood, read from the store's own
-// directory. The layout is internal/standing's: one `.json` beside a folder
-// of the same name.
-func standingRecords(t *testing.T, home string) []standingRecord {
- t.Helper()
- dir := filepath.Join(home, "v3", "standing")
- entries, err := os.ReadDir(dir)
- if err != nil {
- return nil
- }
- var out []standingRecord
- for _, entry := range entries {
- if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
- continue
- }
- raw, err := os.ReadFile(filepath.Join(dir, entry.Name()))
- if err != nil {
- continue
- }
- var record standingRecord
- // A file in this directory that is not an item — the watch offer is one —
- // simply has none of these fields, and is told apart by having no id.
- if err := json.Unmarshal(raw, &record); err != nil || strings.TrimSpace(record.ID) == "" {
- continue
- }
- out = append(out, record)
- }
- return out
-}
-
-// standingRecordOtherThan is the one record whose id is not id, when exactly one
-// is; two or more is not an answer, so it reports none.
-func standingRecordOtherThan(t *testing.T, home, id string) (standingRecord, bool) {
- t.Helper()
- var others []standingRecord
- for _, record := range standingRecords(t, home) {
- if record.ID != id {
- others = append(others, record)
- }
- }
- if len(others) != 1 {
- return standingRecord{}, false
- }
- return others[0], true
-}
-
-// standingRecordAbout is the item whose words hold a given word, which is how a
-// test names the one it asked for without knowing what the model called it.
-func standingRecordAbout(t *testing.T, home, word string) (standingRecord, bool) {
- t.Helper()
- for _, record := range standingRecords(t, home) {
- // The model can shorten the request in Words while keeping its subject
- // in the title or the sentence the reminder will say.
- if strings.Contains(strings.ToLower(record.Words), strings.ToLower(word)) ||
- strings.Contains(strings.ToLower(record.Brief.Title), strings.ToLower(word)) ||
- strings.Contains(strings.ToLower(record.Does.Say), strings.ToLower(word)) {
- return record, true
- }
- }
- return standingRecord{}, false
-}
-
-// standingRecordByID is that item read again, after the pass has had its say
-// about it.
-func standingRecordByID(t *testing.T, home, id string) standingRecord {
- t.Helper()
- for _, record := range standingRecords(t, home) {
- if record.ID == id {
- return record
- }
- }
- return standingRecord{}
-}
-
-// standingRecordsDump is every record in one paragraph, for a failure that has
-// to say what the store actually holds.
-func standingRecordsDump(t *testing.T, home string) string {
- t.Helper()
- var b strings.Builder
- for _, record := range standingRecords(t, home) {
- fmt.Fprintf(&b, " %s %q — %s/%s · due %s · expires %s · says %q · %s\n",
- record.ID, record.Words, record.Status, record.RetiredWhy,
- record.When.At.Format(time.RFC3339), record.Rails.Expires.Format(time.RFC3339),
- record.Does.Say, record.LastCheckLine)
- }
- if b.Len() == 0 {
- return " (the store holds no items)"
- }
- return b.String()
-}
-
-// firstWords is the first n words of a sentence, which is the needle a screen
-// can be searched for when the whole sentence would be cut by a column edge.
-func firstWords(said string, n int) string {
- fields := strings.Fields(said)
- if len(fields) > n {
- fields = fields[:n]
- }
- return strings.Join(fields, " ")
-}
diff --git a/internal/e2e/tui_e2e_test.go b/internal/e2e/tui_e2e_test.go
index 47d1b39401..908c43d87a 100644
--- a/internal/e2e/tui_e2e_test.go
+++ b/internal/e2e/tui_e2e_test.go
@@ -2,8 +2,8 @@
package e2e
-// TestTUIE2E is the ambient side of v3, driven end to end: the real binary, a
-// real terminal, a real model, and the screen read back with capture-pane.
+// TestTUIE2E is v3's own surface, driven end to end: the real binary, a real
+// terminal, a real model, and the screen read back with capture-pane.
//
// Every subtest builds its own CODEAF_HOME and its own repository, and NOT ONE
// STRING IS WRITTEN DOWN HERE. Every needle comes through [say] out of the table
@@ -18,7 +18,7 @@ package e2e
//
// go test -tags e2e -count=1 -timeout 120m -v ./internal/e2e/
//
-// Forty minutes is enough for TestTUIE2E alone (about seventeen). The FULL
+// Forty minutes is enough for TestTUIE2E alone. The FULL
// tagged package — ManualOnTheWire, QuestionsE2E, roomfeed, families, custody,
// contracts — does not fit in forty: Spark's #807 run hit the ceiling before
// TestTUIE2E started. Prefer `make test-e2e-tui` for the ambient surface, or
@@ -27,9 +27,14 @@ package e2e
// OPENROUTER_API_KEY, OPENAI_API_KEY, then the profile's api_key row).
// CLAUDE.md's Tests section says the same thing.
//
+// AUTOMATIONS ARE NOT HERE. `ask here`, the reminder a window's clock says and
+// the narrow window's stacked exchange were subtests of this test while what
+// they made was a standing order; they need no key now, and they are
+// automations_e2e_test.go's [TestAutomationsE2E].
+//
// ── TWO WIDTHS, AND THE REASON IS IN THE PRODUCT ────────────────────────────
//
-// Home at rest is seven panels, and the width chooses the columns
+// Home at rest is its panels, and the width chooses the columns
// (internal/tui3's homegrid.go): two from a hundred and ten cells, three from a
// hundred and seventy. There is NO CARD AT REST at any width; the one card left
// stands beside a SEARCH, from a hundred and thirty-six cells (homebridge.go).
@@ -44,19 +49,17 @@ import (
"os/exec"
"path/filepath"
"regexp"
- "runtime"
"strings"
"testing"
"time"
"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/connect"
- "github.com/Agent-Field/codeaf/internal/standing"
)
// modelPatience is how long any one real turn is given. deepseek-v4-flash
-// answers a one-line question in seconds; a reminder that has to reach for the
-// `stand` tool takes longer, and a machine under load takes longer again.
+// answers a one-line question in seconds; a turn that has to reach for a tool
+// takes longer, and a machine under load takes longer again.
const modelPatience = 90 * time.Second
// runPatience is how long a RUN is given, and it is not [modelPatience] because
@@ -99,12 +102,9 @@ func TestTUIE2E(t *testing.T) {
t.Run("home_opens_on_launch_with_recent_sessions", testHomeShape)
t.Run("a_real_conversation_on_the_panels_and_its_search_card", testRealConversation)
- t.Run("ask_here_end_to_end", testAskHere)
- t.Run("the_firing_reaches_the_person", testFiringReachesThePerson)
t.Run("answer_from_home_across_two_windows", testAnswerFromHome)
t.Run("hover_previews_the_match_under_the_pointer", testHover)
t.Run("a_panel_folds_and_typing_sees_through_it", testFold)
- t.Run("narrow_window_ask_here", testNarrow)
t.Run("the_projects_panel_is_the_view_by_project", testGrouped)
t.Run("one_figure_on_every_spend_surface", testOneSpendFigure)
t.Run("plain_launch_opens_connections_and_harnesses", testPlainLaunchConnectionsAndHarnesses)
@@ -375,8 +375,10 @@ func waitForLandingReceipt(t *testing.T, seedDir, receipt string) {
// ── 1 ───────────────────────────────────────────────────────────────────────
-// testHomeShape reads Home's current sessions, projects, spend, activity and
-// scheduled panels, then drives the command and double-space routes back to it.
+// testHomeShape reads home at rest — the `activity` list of conversations, and
+// the `projects`, `spend`, `since you left` and `automations` panels — and the
+// seven places on the wordmark row, then drives the command and double-space
+// routes back to it.
func testHomeShape(t *testing.T) {
home := newHome(t, nil)
for i, name := range []string{"alpha", "beta", "gamma", "delta", "epsilon"} {
@@ -390,22 +392,28 @@ func testHomeShape(t *testing.T) {
// EVERY PANEL IS ON THE PAGE. Forty rows is room for all five at their
// floors in two columns, so a heading missing here is a panel the grid lost
- // rather than one a short frame squeezed out.
+ // rather than one a short frame squeezed out. A heading is read where a
+ // heading stands — opening its column ([headingColumn]) — because `spend`
+ // and `activity` are also words of the bar above them.
for _, name := range []string{"homePanelProjects",
"homePanelRunning", "switcherSinceLeft", "homePanelSpend", "homePanelNext"} {
- if !strings.Contains(screen, say(t, name)) {
+ if panelBlock(screen, say(t, name)) == "" {
t.Errorf("home has no %q panel:\n%s", say(t, name), screen)
}
}
if strings.Contains(screen, say(t, "homeRunningWhisper")) {
t.Errorf("populated sessions still show the empty-panel hint:\n%s", screen)
}
- // THE BAR IS SIX WORDS on the wordmark row. Standing, memory and search are
- // reached by command and by alt+7…9; teams and chats stand before sessions.
- want := []string{say(t, "barHomeWord"), say(t, "barTeamsWord"), say(t, "barChatsWord"),
- say(t, "barSessionsWord"), say(t, "homePanelSpend"), say(t, "barSettingsWord")}
- if got := barWords(screen, want[0], want[len(want)-1]); strings.Join(got, " ") != strings.Join(want, " ") {
- t.Errorf("the tab bar reads %q, want %q:\n%s", got, want, screen)
+ // THE BAR IS EIGHT PLACES on the wordmark row, each by its label, in
+ // placeBarOrder's order, automations between memory and spend.
+ want := []string{say(t, "barHomeWord"), say(t, "barChatsWord"), say(t, "barTeamsWord"),
+ say(t, "barSessionsWord"), say(t, "barMemoryWord"), say(t, "barAutomationsWord"),
+ say(t, "barSpendWord"), say(t, "barSettingsWord")}
+ if row, ok := barInOrder(screen, want); !ok {
+ t.Errorf("the tab bar reads %q, want the labels %q in that order:\n%s", row, want, screen)
+ }
+ if row, _ := barInOrder(screen, want); strings.Contains(row, strings.ToUpper(say(t, "homePanelNext")[:1])+say(t, "homePanelNext")[1:]) {
+ t.Errorf("the tab bar carries the automations place while nobody stands in it: %q", row)
}
// Home includes recent saved conversations, even before a tab opens them.
@@ -574,188 +582,6 @@ func testRealConversation(t *testing.T) {
r.waitFor(10*time.Second, cardWant, say(t, "homeFactsActive"), say(t, "homeCardMoreWord"))
}
-// ── 3 ───────────────────────────────────────────────────────────────────────
-
-// testAskHere is the whole `ask here` flow against the real model: the two
-// action rows, the errand's own row and its tails, the card, the answer, and the
-// row still being there after the screen it was asked on has been closed and
-// reopened.
-//
-// THE EXCHANGE IS THE SCREEN WHILE IT HOLDS THE KEYBOARD, AT EVERY WIDTH. It
-// sat beside the list at [tuiWide] once; the grid has no pane column at any
-// width, so an errand stacks over the panels exactly as it always did on a
-// narrow frame (internal/tui3's homeStacked), and its row on `threads` —
-// with the `waiting on you` / `stood` tails this subtest is really about — is
-// what `esc` puts back. So every tail is read on the list after the keyboard
-// has left the pane, and every word of the pane is read while it holds it.
-//
-// WHAT WENT. The old subtest ended by looking for a `◦ remind me …` row under
-// the project on home, drawn by the standing band a project used to carry. There
-// is no such band any more: the resting list is what wants you now, and what
-// stands lives on the standing place (internal/manual/chat/home.md says so in as
-// many words). What replaced the assertion is the settled card's own words in
-// the pane, which say the same thing about the same act.
-func testAskHere(t *testing.T) {
- home := newHome(t, nil)
- ws := newWorkspace(t, "askws", false)
- r := start(t, "afe2e_ask", home, ws, tuiWide, 45)
-
- // One ordinary conversation first, so this project has something on home for
- // the exchange row to sit above.
- r.lit("say ok and nothing else")
- r.keys("Enter")
- r.waitForAny(modelPatience, "ok", "OK", "Ok")
-
- r.lit("/home")
- time.Sleep(700 * time.Millisecond)
- r.keys("Enter")
- r.waitFor(20*time.Second, say(t, "placeRestWord"))
-
- r.lit("remind me in 1 minute to drink water")
- time.Sleep(700 * time.Millisecond)
- typed := r.capture()
- if !strings.Contains(typed, say(t, "homeAskHereWord")+": ") ||
- !strings.Contains(typed, say(t, "homeStartWord")+": ") {
- t.Errorf("the two action rows are not both drawn while something is typed:\n%s", typed)
- }
- t.Logf("the action rows while typing:\n%s", typed)
-
- // ctrl+enter, sent as the CSI 13;5u a kitty-protocol terminal sends. It
- // hands the keyboard straight to the pane, and a pane holding the keyboard
- // always names both ways back out of it.
- r.ctrlEnter()
- pane := r.waitFor(25*time.Second, say(t, "homeAskHereWord"), say(t, "exchangeBack"))
- t.Logf("the exchange took the screen:\n%s", pane)
-
- // The pane's own clock, caught in flight. It lives for seconds, so this is
- // a fast poll and it is a finding rather than a failure when it is missed.
- // Everything the pane draws is kept so the tool rows can be read afterwards.
- seen := []string{pane}
- if caught, ok := r.glimpse(25*time.Second,
- say(t, "homeAskThinkWord"), say(t, "homeAskWriteWord"), say(t, "homeAskRunWord")); ok {
- t.Logf("the live strip was caught mid-turn:\n%s", caught)
- seen = append(seen, caught)
- } else {
- t.Logf("FINDING: never caught the live strip in the pane")
- }
-
- // THE TAILS ARE THE LIST'S, so the keyboard goes back to it: one esc over an
- // empty follow-up box hands it over, and the errand stays as the first row of
- // `threads`. Its tail says `working` while the turn is in flight and
- // `waiting on you` once the card is up — and a model that reaches for the
- // card inside a second or two can beat the first read, which is a fast reply
- // and not a missing tail.
- r.keys("Escape")
- hit, listed := r.waitForAny(25*time.Second, say(t, "homeAskWorkingWord"), say(t, "notifyAskWord"))
- if !strings.Contains(listed, "? remind me in 1 minute") {
- t.Errorf("esc did not put the list back with the exchange row on it:\n%s", listed)
- }
- if hit == say(t, "homeAskWorkingWord") {
- t.Logf("the exchange row is working:\n%s", listed)
- } else {
- t.Logf("FINDING: the card was up before the list was read, so the `%s` tail was not seen",
- say(t, "homeAskWorkingWord"))
- }
- waiting := r.waitFor(modelPatience, "? remind me in 1 minute", say(t, "notifyAskWord"))
- t.Logf("the card arrived and the row tail says it is waiting on somebody:\n%s", waiting)
-
- // AN EXCHANGE OUTLIVES THE SCREEN IT WAS ASKED ON. This one is holding a
- // card, which is the case asking-from-home.md states outright: closing home
- // does not touch it, and neither does opening another conversation. The
- // keyboard is already on the list, so ONE esc closes home.
- r.keys("Escape") // home closes into the conversation underneath
- time.Sleep(2500 * time.Millisecond)
- r.lit("/home")
- time.Sleep(700 * time.Millisecond)
- r.keys("Enter")
- again := r.waitFor(20*time.Second, say(t, "placeRestWord"))
- if !strings.Contains(again, "? remind me in 1 minute") || !strings.Contains(again, say(t, "notifyAskWord")) {
- t.Errorf("the exchange did not outlive the screen it was asked on:\n%s", again)
- }
- t.Logf("home reopened and the exchange is still waiting on somebody:\n%s", again)
-
- // Walk onto the row and hand the keyboard to the pane. THE HINT UNDER THE BOX
- // IS THE ORACLE for where the cursor is standing: the switcher's rows carry
- // no `›` lead of their own, and the one line that changes with the cursor is
- // the hint (internal/tui3's homeHint).
- // The exchange follows the conversation row selected when Home opens.
- if !walkTo(r, say(t, "homeAnswerHint"), "Down") {
- t.Fatalf("could not put the cursor back on the exchange row:\n%s", r.capture())
- }
- r.keys("Enter")
- card := r.waitFor(20*time.Second, say(t, "exchangeAnswerHint"))
- // The card is drawn over several frames; give it one before reading the
- // answers off it, or this reads a half-painted row.
- time.Sleep(2 * time.Second)
- card = r.capture()
- seen = append(seen, card)
- t.Logf("enter on the row gave the pane the keyboard, with the card on it:\n%s", card)
-
- // THE ANSWERS ARE READ OFF THE FOOT AND NOT OFF THE CHIPS. The chips give
- // their words up to fit whatever room the card has — `[ 1 yes ] [ 2 change
- // ] [ 0 no ]` — while the hint under the box spells every answer in full at
- // every width (internal/tui3's exchangeHint). So the foot is where this suite
- // reads what a person is being offered.
- //
- // AND THE FOOT NAMES WHAT THE CARD DREW AND NOTHING MORE (#189, fixed). It
- // used to say `3 just once` over a one-off reminder whose card offers no
- // such chip, because the line was a third hardcoded copy of a sentence
- // internal/tui3 already kept two correct spellings of. It is built from the
- // chips now, so the reminder this subtest asks for is offered three answers
- // and the needle spells three.
- if !strings.Contains(card, say(t, "exchangeFollowUp")) {
- t.Errorf("the foot does not say what enter does in the pane:\n%s", card)
- }
- if !strings.Contains(card, say(t, "exchangeBack")) {
- t.Errorf("the foot does not name the way back to the list:\n%s", card)
- }
-
- // WHAT THE MODEL ACTUALLY DID. No tool row may say `unknown`
- // (asking-from-home.md states it), and the instructions forbid running
- // `date` to learn the time (keeping-an-eye.md).
- all := strings.Join(seen, "\n")
- if strings.Contains(all, "unknown") {
- t.Errorf("a tool row said `unknown`:\n%s", firstMatch(all, "unknown"))
- }
- if strings.Contains(all, "bash · date") || strings.Contains(all, "bash date") {
- t.Logf("FINDING: the model still ran `date` before setting the reminder: %s",
- firstMatch(all, "date"))
- } else {
- t.Logf("the model did not run `date` — it used the Now line in its instructions")
- }
- t.Logf("tool rows drawn in the pane: %s", strings.Join(toolRows(seen), " | "))
-
- // A YES HANDS THE KEYBOARD BACK TO THE LIST BY ITSELF (internal/tui3's
- // homeKey, "the two zones"), so the row's tail is what says it landed.
- r.lit("1")
- stood := r.waitFor(30*time.Second, "? remind me in 1 minute", say(t, "homeAskStoodTail"))
- t.Logf("answered `1` — the row says something stands:\n%s", stood)
-
- // AND THE SETTLED CARD IS ONE ENTER AWAY. The pane keeps the exchange as it
- // ended — the answer, what it set up, and where the exchange is filed — and
- // enter on the row is how a person reads it again.
- r.keys("Enter")
- settled := r.waitFor(20*time.Second, say(t, "exchangeBack"))
- time.Sleep(1500 * time.Millisecond)
- settled = r.capture()
- t.Logf("the settled exchange, reopened:\n%s", settled)
- if !strings.Contains(settled, say(t, "standRemindYes")) || !strings.Contains(settled, say(t, "standSetWord")) {
- t.Errorf("the settled card does not carry the answer and its verdict:\n%s", settled)
- }
- if !strings.Contains(settled, say(t, "homeAskStoodWord")) {
- t.Errorf("the pane does not say the exchange is filed under what it made:\n%s", settled)
- }
-
- // esc puts the list back, and the exchange is still a row on it: a settled
- // errand stays where it was asked until it is put away.
- r.keys("Escape")
- back := r.waitFor(20*time.Second, say(t, "homePanelProjects"), "? remind me in 1 minute")
- if strings.Contains(back, "› remind me in 1 minute to drink water") {
- t.Errorf("esc left the pane drawn over the list:\n%s", back)
- }
- t.Logf("esc brought the list back with the settled row on it:\n%s", back)
-}
-
// walkTo steps the cursor along the list, one press of `key` at a time, until
// the hint under the box says it is standing on the row this test wants, and
// answers whether it got there.
@@ -777,339 +603,6 @@ func walkTo(r *rig, hint, key string) bool {
return strings.Contains(r.capture(), hint)
}
-// ── 4 ───────────────────────────────────────────────────────────────────────
-
-// testFiringReachesThePerson stands a one-minute reminder, sits in an ordinary
-// conversation of the same project, and waits for the window's own pass to
-// fire it. Then it quits, fires a second one from outside every window with
-// `codeaf tick`, and reopens to read what was left waiting.
-//
-// WHAT WENT. The second half used to reopen from ANOTHER project, put the
-// pointer on a folded `▸ firews` line and read the news band off the project
-// card that only such a line drew. Folded project lines went with the home
-// rethink and nothing on the desktop builds one now, so what a person actually
-// meets when they come back is read instead: the project's own inbox, which is
-// the road a firing takes with no window open, and home's `since you left`
-// block, which is where that firing surfaces.
-func testFiringReachesThePerson(t *testing.T) {
- if testing.Short() {
- t.Skip("this one waits for the five-minute standing pass")
- }
- requireMachineTimerUntouched(t)
- // THE TASK COLUMN IS PINNED OPEN, because the standing count below is drawn
- // at its foot and nowhere else on the frame (internal/tui3's railFootRows).
- // newHome copies the profile of whoever runs this, and a machine whose owner
- // put the column away with ctrl+g — this one's does — hides the very line
- // under test; secondwindow_e2e_test.go pins it for the same reason.
- home := newHome(t, map[string]any{config.KeyTaskColumn: true, config.KeyStandingBackground: config.BackgroundOn})
- ws := newWorkspace(t, "firews", false)
- r := start(t, "afe2e_fire", home, ws, tuiWide, 45)
- started := time.Now()
-
- // An ordinary conversation to sit in. A firing whose origin is an `ask
- // here` exchange has no room of its own, so road 2 of the delivery — any
- // other open conversation of the same project — is the one under test.
- r.lit("say ok and nothing else")
- r.keys("Enter")
- r.waitForAny(modelPatience, "ok", "OK", "Ok")
-
- openHome(t, r)
- standReminder(t, r, "remind me in 1 minute to drink water")
- // THE APPROVAL MUST LOAD ONLY THIS RIG'S TIMER. A definition under the
- // developer's HOME would point every later five-minute pass at a checkout
- // this suite will delete (#1631).
- installDeadline := time.Now().Add(30 * time.Second)
- var calls []string
- installed := false
- for {
- calls = r.host.calls(t)
- for _, call := range calls {
- if runtime.GOOS == "linux" && call == "systemctl --user enable --now "+standing.LinuxTickTimer {
- installed = true
- }
- if runtime.GOOS == "darwin" && strings.HasPrefix(call, "launchctl bootstrap ") && strings.HasSuffix(call, standing.DarwinTickLabel+".plist") {
- installed = true
- }
- }
- if installed || time.Now().After(installDeadline) {
- break
- }
- time.Sleep(pollEvery)
- }
- t.Logf("rig scheduler calls: %v", calls)
- if !installed {
- t.Errorf("the rig never loaded its timer; scheduler calls: %v", calls)
- }
- if runtime.GOOS == "linux" {
- definition := filepath.Join(r.host.login, ".config", "systemd", "user", strings.TrimSuffix(standing.LinuxTickTimer, ".timer")+".service")
- raw, err := os.ReadFile(definition)
- if err != nil || !strings.Contains(string(raw), "Environment=\"CODEAF_HOME="+r.home+"\"") {
- t.Errorf("rig service definition %s must name CODEAF_HOME=%s: %v, content %q", definition, r.home, err, raw)
- }
- } else if runtime.GOOS == "darwin" {
- definition := filepath.Join(r.host.login, "Library", "LaunchAgents", standing.DarwinTickLabel+".plist")
- if _, err := os.Stat(definition); err != nil {
- t.Errorf("rig plist %s: %v", definition, err)
- }
- }
-
- // Back into the conversation and wait. The window runs the same pass the
- // timer runs, every standing.Interval (five minutes), the first one an
- // interval after launch. Answering the card hands the keyboard back to its
- // settled exchange row, immediately below the conversation we opened. Walk
- // onto that conversation and open it: ctrl+t acts only on a conversation
- // row, so sending it from the exchange row silently left this test on Home
- // while it looked there for a conversation-only firing row (#1344).
- r.keys("Up")
- r.keys("Enter")
- // The conversation's foot says `esc interrupt` while the reminder's turn
- // is still ending; its home gesture returns when that work is quiet.
- r.waitForAny(20*time.Second, say(t, "homeDoorWord"), say(t, "chatWorkingFootWord"))
-
- // /status, while something stands: the derived `keeping watch` line, and the
- // `◦ 1 standing order` line at the foot of the task column. That count was a
- // segment of the STATUS ROW until #747 (10800e6ee) moved it under the
- // column's tally — the words are the same, the place is not, and this
- // subtest went on waiting on the status row with the column put away.
- // IT IS WAITED FOR AND NOT READ IN THE SAME INSTANT. The count behind that
- // line is asked on the frame, so over a connection it is answered from a
- // cache that refreshes behind itself — the seam's stated law rather than an
- // optimization (cmd/codeaf's hostStanding) — and an item that stood a second
- // ago reaches it on the next beat.
- //
- // AND THERE ARE THREE BEATS BETWEEN THE DISK AND THAT SEGMENT, not one, so
- // the wait is a minute rather than the twenty seconds that caught it on a
- // quiet machine and missed it on a loaded one: the item is written by the
- // ERRAND's process, read back by the ENGINE, held by the surface's own
- // far-side cache on hostStandingEvery, and read off THAT by a count the
- // column keeps for keepEvery. How long it actually took is logged, because
- // a count that takes half a minute to appear is a papercut worth having a
- // number for.
- keepingAt := time.Now()
- if _, ok := r.glimpse(time.Minute, say(t, "homeKeepingWord")); !ok {
- t.Errorf("the task column never grew a `◦ N standing orders` line while an item stands:\n%s", r.capture())
- } else {
- t.Logf("the `◦ N standing orders` line arrived %s after the item stood", time.Since(keepingAt).Round(time.Second))
- }
- r.lit("/status")
- time.Sleep(700 * time.Millisecond)
- r.keys("Enter")
- status := r.waitFor(20*time.Second, say(t, "homeWatchLabel"))
- t.Logf("/status while something stands:\n%s", status)
- if !strings.Contains(status, say(t, "homeKeepingWord")) {
- t.Errorf("/status says nothing about the orders standing here:\n%s", status)
- }
-
- // WHAT THE ITEM ITSELF SAYS IT WILL SAY. The model names the standing order
- // and writes the sentence it fires, and neither is anything this test may
- // assume: one run called it `drink water reminder` and fired
- // `💧 Time to drink water!`. So the record on disk is read and the needles
- // are taken off it — which is the same law the rest of this suite follows
- // for the surface's own words, applied to the one vocabulary the MODEL owns.
- item, ok := standingRecordAbout(t, home, "water")
- if !ok {
- t.Fatalf("nothing stood for the water reminder at all. records:\n%s", standingRecordsDump(t, home))
- }
- t.Logf("the item that stood: %q, due %s, saying %q",
- item.Brief.Title, item.When.At.Format(time.RFC3339), item.Does.Say)
-
- // Now wait for the pass. It is one interval from launch plus the minute the
- // reminder asked for, with room for a slow machine.
- wait := 6*time.Minute + 30*time.Second - time.Since(started)
- if wait < time.Minute {
- wait = time.Minute
- }
- t.Logf("waiting %s for the window's own standing pass", wait.Round(time.Second))
- // TWO NEEDLES, BECAUSE THE JOURNAL AND THE SCREEN SAY IT DIFFERENTLY. The
- // steering line the engine injects carries what the item fires; the ROW the
- // surface draws wears the item's own short name and then what the firing said
- // (internal/tui3's standName, pinned by TestAStandingUpdateIsExactlyOneLine).
- // So the transcript is searched for the sentence and the screen for the row.
- said := say(t, "standSaidTag")
- // THE ROW WEARS THE PERSON'S OWN WORDS, CUT SHORT — internal/tui3's standName
- // takes the head of what was asked for, not the name the model gave the item
- // (which is often empty), so the needle is the head of the item's `words`.
- drawnRow := firstWords(item.Words, 5)
- if drawnRow == "" {
- t.Fatalf("the item that stood has no words to look for:\n%s", standingRecordsDump(t, home))
- }
- // AND THE ROAD IT TAKES IS THE MACHINE'S TO CHOOSE, WHICH IS WHY BOTH ARE
- // WATCHED FOR. Roads 1 and 2 are "is it open HERE" — a map from session id to
- // agent inside the process that ran the pass (session's standing_run.go) — so
- // only a pass in the process holding this project's conversations can take
- // one. On this machine that is the engine, and another pass can beat it to
- // the item: the OS timer's `codeaf tick` is a process of its own with an empty
- // registry, and it reaches a person down road 4 instead, through the project's
- // inbox. Both ends AT A PERSON, which is what this subtest is named for, and
- // the second half below proves road 4 the whole way to the next window's
- // screen. A subtest that demanded road 2 would be asserting which of two
- // correct passes woke up first.
- deadline := time.Now().Add(wait)
- drawn := false
- delivered, filed := false, false
- journal := ""
- for time.Now().Before(deadline) {
- if screen := r.capture(); strings.Contains(screen, drawnRow) && strings.Contains(screen, said) {
- drawn = true
- }
- for path, raw := range sessionTranscripts(t, home) {
- if strings.Contains(raw, firstWords(item.Does.Say, 4)) {
- delivered, journal = true, path
- }
- }
- if strings.Contains(projectInbox(t, home, ws), firstWords(item.Does.Say, 4)) {
- filed = true
- }
- if drawn || delivered || filed {
- break
- }
- time.Sleep(2 * time.Second)
- }
- screen := r.capture()
- switch {
- case delivered:
- t.Logf("the firing reached the conversation's journal: %s", journal)
- case filed:
- t.Logf("the firing was filed under the project by a pass with no conversation open in it — "+
- "road 4, which the second half below follows to the screen:\n%s", projectInbox(t, home, ws))
- }
- if !delivered && !filed {
- // AND THE SUITE SAYS WHY, RATHER THAN JUST THAT. An item whose expiry is
- // not after its own moment is retired by rail one of the pass before
- // anything is ever due (internal/standing/tick.go), so it can never fire —
- // and the `stand` tool accepts such a proposal today (issue #188). That is
- // a product defect and not this test's, and a failure that did not name it
- // costs somebody the hour it cost to find.
- later := standingRecordByID(t, home, item.ID)
- if !later.Rails.Expires.IsZero() && !later.Rails.Expires.After(later.When.At) {
- t.Fatalf("DEFECT (issue #188): the item stood with an expiry at or before its own moment — "+
- "expires %s, due %s — so the pass retired it (%q) instead of ever firing it.\nscreen:\n%s",
- later.Rails.Expires.Format(time.RFC3339), later.When.At.Format(time.RFC3339),
- later.LastCheckLine, screen)
- }
- t.Fatalf("the firing never reached the person at all — nothing in any transcript and nothing "+
- "filed under the project.\nrecord:\n%s\nscreen:\n%s", standingRecordsDump(t, home), screen)
- }
- switch {
- case drawn:
- t.Logf("the firing is drawn in the conversation:\n%s", screen)
- case delivered:
- t.Errorf("DEFECT: the firing reached the conversation but was never DRAWN in it.\n"+
- "The journal holds the line as a session-authored note, and the model answered it, "+
- "but no `%s … %s` row appears on the screen the person is looking at:\n%s", drawnRow, said, screen)
- default:
- t.Logf("FINDING: the pass that fired it was not the one holding this project's conversations, " +
- "so there was no room for a row — the words went to the project's inbox instead")
- }
-
- // ── the second half: nobody is here when it fires ──
- //
- // A firing wakes the conversation it lands in, so the model may still be
- // answering it. esc ends whatever is in flight before the next errand.
- r.keys("Escape")
- time.Sleep(2 * time.Second)
- openHome(t, r)
- standReminder(t, r, "remind me in 1 minute to stretch")
- r.keys("Escape")
- time.Sleep(2 * time.Second)
- r.quit()
-
- time.Sleep(75 * time.Second)
- out := tick(t, home)
- t.Logf("`codeaf tick` said %q", strings.TrimSpace(out))
-
- second, ok := standingRecordAbout(t, home, "stretch")
- if !ok {
- // THE MODEL OWNS A REMINDER'S WORDS, and a cheap one can drop the subject:
- // it saved "remind me in 1 minute", saying "Your 1-minute reminder has
- // arrived." This half proves the firing reaches the person, so the record
- // is the one that stood after the first; a missing one still fails.
- if second, ok = standingRecordOtherThan(t, home, item.ID); ok {
- t.Logf("FINDING: the model saved the stretch reminder without its subject: %q, saying %q",
- second.Words, second.Does.Say)
- }
- }
- if !ok {
- t.Fatalf("nothing stood for the stretch reminder. records:\n%s", standingRecordsDump(t, home))
- }
- inbox := projectInbox(t, home, ws)
- if !strings.Contains(inbox, firstWords(second.Does.Say, 4)) {
- t.Errorf("nothing was left waiting for the person after the tick — the item fires %q. project inbox:\n%s",
- second.Does.Say, inbox)
- } else {
- t.Logf("the firing was filed under the project for the next window:\n%s", inbox)
- }
-
- // AND THE NEXT WINDOW IS TOLD, TWICE OVER. A launch in a project that already
- // holds a conversation resumes it rather than opening home, so the first thing
- // the person meets is the firing itself, drained out of the inbox into the
- // conversation it belongs to and drawn as its own row.
- said2 := firstWords(second.Does.Say, 3)
- if said2 == "" {
- t.Fatalf("the second item says nothing when it fires:\n%s", standingRecordsDump(t, home))
- }
- r2 := start(t, "afe2e_fire_back", home, ws, tuiWide, 45)
- // THE INBOX BEING EMPTIED IS THE ORACLE, AND THE ROW IS A GLIMPSE. Road 4
- // ends when the next ordinary conversation in the project drains the file
- // whole on its way in (session's [Agent.drainStandingInbox]) — one `while
- // you were away` fold for the model, one dim row per firing for the person —
- // and the drain is the half that is a fact rather than a frame.
- //
- // THE ROW IS NOT WAITED FOR, because the same firing WAKES the conversation
- // it lands in: the reply that wake produces closes the work fold over
- // everything behind it (workfold.go), and the row can be gone before anybody
- // looks. That is a real hole and it is recorded as a finding here rather than
- // asserted, exactly as the `since you left` block below it is — the delivery
- // itself is proved twice over above and by the drain below.
- drained := false
- for deadline := time.Now().Add(40 * time.Second); time.Now().Before(deadline); {
- if strings.TrimSpace(projectInbox(t, home, ws)) == "" {
- drained = true
- break
- }
- time.Sleep(pollEvery)
- }
- if !drained {
- t.Errorf("the project's inbox still holds the firing after the next window opened it:\n%s",
- projectInbox(t, home, ws))
- } else {
- t.Logf("the window that came back drained the project's inbox")
- }
- if caught, ok := r2.glimpse(30*time.Second, said2, say(t, "standSaidTag")); ok {
- t.Logf("the window that came back was told what happened while it was shut:\n%s", caught)
- } else {
- t.Logf("FINDING: the firing was drained into the conversation but its own row was never on the "+
- "frame — the wake it carries starts a reply, and the work fold closes over what is behind "+
- "one (workfold.go):\n%s", r2.capture())
- }
-
- // AND HOME'S OWN ACCOUNT OF IT IS DEMANDED. What happened while nobody was
- // looking is the `since you left` panel, built from every standing item
- // whose last firing is later than the look stamp — the ones that still stand
- // AND the ones that stood down (internal/tui3/switcher.go's addLedger).
- //
- // THIS WAS A FINDING IN THIS SUBTEST'S OWN LOG FOR A WHILE, AND IT WAS A REAL
- // HOLE. A one-off reminder retires in the pass that fires it, so by the time
- // this window is up there is no live item left; a ledger that walked only the
- // bands drew nothing at all about the reminder that had just gone off with the
- // terminal shut — which is the commonest thing that happens while nobody is
- // looking. The reading carries the retired firings out now
- // (internal/tui3/homestanding.go's standItems), so the log is an assertion.
- openHome(t, r2)
- time.Sleep(3 * time.Second)
- news := r2.capture()
- // THE HEADING ALONE PROVES NOTHING NOW: an empty `since you left` keeps its
- // heading and its whisper (DESIGN §4), so the firing's own line is the claim.
- if !strings.Contains(news, say(t, "switcherSinceLeft")) {
- t.Errorf("home drew no `%s` panel at all:\n%s", say(t, "switcherSinceLeft"), news)
- } else if !strings.Contains(news, say(t, "standingFiredWord")) {
- t.Errorf("a watch fired while no window was open and home's `%s` panel does not say so:\n%s",
- say(t, "switcherSinceLeft"), news)
- } else {
- t.Logf("home's `since you left` block names the firing:\n%s", firstMatch(news, say(t, "standingFiredWord")))
- }
-}
-
// openHome opens the screen with the slash command, which is the door that
// works whatever the machine holds.
func openHome(t *testing.T, r *rig) {
@@ -1120,30 +613,6 @@ func openHome(t *testing.T, r *rig) {
r.waitFor(20*time.Second, say(t, "placeRestWord"))
}
-// standReminder types one sentence into home's box, asks it there, waits for
-// the card and says yes. It is scenario 3's flow reduced to what the scenarios
-// after it need from it.
-func standReminder(t *testing.T, r *rig, words string) {
- t.Helper()
- r.lit(words)
- time.Sleep(600 * time.Millisecond)
- r.ctrlEnter()
- // ctrl+enter HANDS THE KEYBOARD STRAIGHT TO THE PANE, so the digit reaches
- // the card with nothing walked onto — and the foot naming the card's own
- // answers is how this helper knows the card is up and the pane has it. A
- // reminder's card draws three of them and the foot names three (#189).
- //
- // IT IS NOT THE ROW'S `waiting on you` TAIL ANY MORE. The pane stacks over
- // the grid while it holds the keyboard (internal/tui3's homeStacked), so the
- // row is not on the screen to wear a tail until the keyboard leaves. (Subtest
- // 3 takes the other road on purpose: it hands the keyboard back, reads the
- // tail on the list, and walks onto the row.)
- r.waitFor(modelPatience, say(t, "exchangeAnswerHint"))
- r.lit("1")
- r.waitFor(30*time.Second, say(t, "homeAskStoodTail"))
- t.Logf("stood: %q\n%s", words, r.capture())
-}
-
// ── 5 ───────────────────────────────────────────────────────────────────────
// testAnswerFromHome stops one window on a consent card and answers it from
@@ -1174,9 +643,10 @@ func testAnswerFromHome(t *testing.T) {
// from there. So B opens its own project, and A's conversation is a row on
// B's list like any other.
b := start(t, "afe2e_b", home, newWorkspace(t, "consentws-b", false), tuiPlain, 40)
- // A's QUESTION ARRIVES ON ITS EXISTING CONVERSATION ROW. Home shows the
- // question and answers in that row's description while it is selected. A
- // digit still answers the top question from anywhere on home.
+ // A's QUESTION ARRIVES ON ITS EXISTING CONVERSATION ROW, on home's
+ // `activity` list. Home shows the question and answers in that row's
+ // description while it is selected. A digit still answers the top question
+ // from anywhere on home.
b.waitFor(40*time.Second, say(t, "questionOtherWindowWord"))
if !walkTo(b, say(t, "answersAllowOnce"), "Up") {
t.Fatalf("could not select the question on its session row:\n%s", b.capture())
@@ -1201,7 +671,8 @@ func testAnswerFromHome(t *testing.T) {
t.Errorf("home's row wrote its own grammar around the gate's sentence:\n%s", firstMatch(row, say(t, "consentRowLine")))
}
- // THE QUESTION BELONGS TO THE SESSIONS ROW, not a duplicate attention row.
+ // THE QUESTION BELONGS TO THE CONVERSATION'S OWN ROW under `activity`, and
+ // is not drawn a second time as a row of its own.
if !strings.Contains(row, say(t, "homePanelRunning")) ||
strings.Count(row, say(t, "consentRowLine")) != 1 {
t.Errorf("the selected session does not carry exactly one question:\n%s", row)
@@ -1356,49 +827,6 @@ func testFold(t *testing.T) {
// count, the word, and nothing after it but the row's own padding.
var foldCounts = regexp.MustCompile(`\b\d+ more\s*$`)
-// ── 8 ───────────────────────────────────────────────────────────────────────
-
-// testNarrow is the phone-shaped window: sixty cells, where home has no room
-// for two columns and the exchange takes the whole screen.
-func testNarrow(t *testing.T) {
- home := newHome(t, nil)
- seedProject(t, home, "narrowseed", 9, 20*time.Minute)
- ws := newWorkspace(t, "narrowws", false)
- r := start(t, "afe2e_narrow", home, ws, 60, 30)
-
- screen := r.waitFor(25*time.Second, "home")
- t.Logf("home at 60x30:\n%s", screen)
-
- r.lit("what day is it")
- time.Sleep(600 * time.Millisecond)
- typed := r.capture()
- t.Logf("typing at 60 cells:\n%s", typed)
- r.ctrlEnter()
-
- stacked := r.waitFor(30*time.Second, "what day is it")
- t.Logf("the exchange at 60 cells:\n%s", stacked)
- if strings.Contains(stacked, "narrowseed") || strings.Contains(stacked, "Seed Narrowseed") {
- t.Errorf("the narrow exchange did not take the whole screen — the list is still drawn:\n%s", stacked)
- }
- if caught, ok := r.glimpse(20*time.Second,
- say(t, "homeAskThinkWord"), say(t, "homeAskWriteWord"), say(t, "homeAskRunWord")); ok {
- t.Logf("the live strip on a narrow window:\n%s", caught)
- } else {
- t.Logf("FINDING: no live strip caught on the narrow window")
- }
-
- hit, replied := r.waitForAny(modelPatience, "2026", "today", "Today")
- t.Logf("the reply arrived on the narrow window (%q):\n%s", hit, replied)
-
- r.keys("Escape")
- back := r.waitFor(20*time.Second, "? what day is it")
- t.Logf("esc brought the list back with the exchange row on it:\n%s", back)
-
- r.keys("Enter")
- reopened := r.waitFor(20*time.Second, "› what day is it")
- t.Logf("enter reopened the exchange:\n%s", reopened)
-}
-
// ── 9 ───────────────────────────────────────────────────────────────────────
// testGrouped reads the machine's work arranged by project, which is the
@@ -1557,27 +985,33 @@ func nextColumn(runes []rune, from int) int {
return -1
}
-// barWords reads the places between home and settings on the wordmark row.
-// The wordmark stands before home and the machine's pulse stands after settings.
-func barWords(screen, first, last string) []string {
+// barInOrder finds the wordmark row — the first line carrying the first label
+// — and reports whether every label stands on it in the order given, each
+// after the one before. It answers the row for the log either way.
+//
+// IT READS LABELS AND NOT FIELDS. `AI teams` is one place in two words, so a
+// reader that split the row on spaces would count a place that is not there;
+// the wordmark before the first label and the pulse after the last are simply
+// not asked about.
+func barInOrder(screen string, labels []string) (string, bool) {
+ if len(labels) == 0 {
+ return "", false
+ }
for _, line := range strings.Split(screen, "\n") {
- fields := strings.Fields(line)
- for i, field := range fields {
- if strings.Trim(field, "[]") != first {
- continue
- }
- for j := i + 1; j < len(fields); j++ {
- if strings.Trim(fields[j], "[]") == last {
- words := append([]string(nil), fields[i:j+1]...)
- for k := range words {
- words[k] = strings.Trim(words[k], "[]")
- }
- return words
- }
+ if !strings.Contains(line, labels[0]) {
+ continue
+ }
+ at := 0
+ for _, label := range labels {
+ found := strings.Index(line[at:], label)
+ if found < 0 {
+ return strings.TrimSpace(line), false
}
+ at += found + len(label)
}
+ return strings.TrimSpace(line), true
}
- return nil
+ return "", false
}
// headingRow answers whether a project's name stands ALONE on a line of the
@@ -1659,34 +1093,6 @@ func firstMatch(screen, sub string) string {
return ""
}
-// toolRows is every `tool · something` row the pane drew, deduplicated, so a
-// report can say what the model actually reached for.
-func toolRows(screens []string) []string {
- seen := map[string]bool{}
- var out []string
- for _, screen := range screens {
- for _, line := range strings.Split(screen, "\n") {
- // A running row leads with one of the braille spinner's eight
- // frames, so the leading glyph is stripped before the tool name is
- // read rather than every frame being spelled out here.
- trimmed := strings.TrimLeft(strings.TrimSpace(line), "⠁⠂⠄⠈⠐⠠⠆⠇⠋⠙⠸⠼⠴⠦⠧⠹⢀⡀✗ ")
- for _, tool := range []string{"bash ", "read ", "stand ", "ls ", "write ", "manual ", "watch "} {
- if strings.HasPrefix(trimmed, tool) && !seen[trimmed] {
- seen[trimmed] = true
- out = append(out, trimmed)
- }
- }
- }
- }
- if len(out) == 0 {
- return []string{"(none seen)"}
- }
- if len(out) > 12 {
- out = append(out[:12], fmt.Sprintf("… and %d more", len(out)-12))
- }
- return out
-}
-
// ── 10 ──────────────────────────────────────────────────────────────────────
// testOneSpendFigure is issue #269 end to end: run a turn, press escape in the
@@ -1713,20 +1119,23 @@ func testOneSpendFigure(t *testing.T) {
// A machine with no conversations on it opens straight into one, which is
// the state this scenario wants: one conversation, so the machine's day and
// this conversation's total are the same money read two ways. The wait is
- // on the WORKSPACE's own name in the status line rather than on a product
- // sentence, because the welcome screen a fresh machine opens on draws none
- // of the rules the other subtests wait for.
- r.waitFor(20*time.Second, "spendws")
+ // on the greeting's own starter line, which that first conversation stands
+ // on. It was on the workspace's name in the status line, which a frame of
+ // this width now cuts from the foot's `project:` clause.
+ r.waitFor(20*time.Second, say(t, "starterTaskWord"))
// A question with a long answer, so there is a middle to interrupt.
r.lit("count slowly from one to two hundred, one number per line")
time.Sleep(600 * time.Millisecond)
r.keys("Enter")
- if caught, ok := r.glimpse(modelPatience,
- say(t, "homeAskThinkWord"), say(t, "homeAskWriteWord")); ok {
+ // THE CONVERSATION'S OWN FOOT SAYS A TURN IS IN FLIGHT: `esc interrupt`
+ // stands where the home gesture stands at rest. It is glimpsed rather than
+ // waited for, because a turn that finished first is a fast reply and the
+ // figures below must agree all the same.
+ if caught, ok := r.glimpse(modelPatience, say(t, "chatWorkingFootWord")); ok {
t.Logf("the turn was in flight when escape was pressed:\n%s", caught)
} else {
- t.Logf("FINDING: the live strip was never caught — the turn may have finished first")
+ t.Logf("FINDING: the working foot was never caught — the turn may have finished first")
}
r.keys("Escape")
// The ledger's writer is a background goroutine and both places read the
diff --git a/internal/e2e/tuiphase_test.go b/internal/e2e/tuiphase_test.go
index 00e216a924..6294616494 100644
--- a/internal/e2e/tuiphase_test.go
+++ b/internal/e2e/tuiphase_test.go
@@ -12,8 +12,9 @@ package e2e
// stall would be a subtest that fails on a good day, which is the one kind of
// red nobody chases twice. So the turn is real, the screen is real, and the
// sentences are caught when the run is slow enough to say them and written down
-// as a FINDING when it is not. That is exactly what the live strip's three
-// words already do next door in tui_e2e_test.go, and for the same reason.
+// as a FINDING when it is not. That is what this file does with the live
+// strip's three words too, below, and for the same reason: a fast reply is not
+// a defect.
//
// AND WHAT IS CAUGHT IS READ WHOLE. A glimpse that only logged would be a
// waiter in name: the moment either sentence IS on the screen, this file
@@ -54,8 +55,14 @@ func TestTUIPhaseClock(t *testing.T) {
// time to read, let alone one the controller has time to weigh.
ask := "plan a week of evening meals for four people and say why each night works"
r.lit(ask)
- time.Sleep(600 * time.Millisecond)
- r.ctrlEnter()
+ r.waitFor(15*time.Second, say(t, "homeStartWord"))
+ // IT IS ASKED HERE, ON HOME: one ↑ off the action row is `ask here`, and
+ // the hint under the box says so before enter is pressed. That is the door
+ // every terminal can reach — the chord it once took is bound but no longer
+ // offered, because most terminals cannot send it.
+ r.keys("Up")
+ r.waitFor(10*time.Second, say(t, "homeAskHereHint"))
+ r.keys("Enter")
r.waitFor(30*time.Second, "plan a week of evening meals")
// THE LIVE STRIP FIRST, so that a run which caught no wait can be told from
diff --git a/internal/e2e/tuiwords_test.go b/internal/e2e/tuiwords_test.go
index 25594117ce..5a60d40aa2 100644
--- a/internal/e2e/tuiwords_test.go
+++ b/internal/e2e/tuiwords_test.go
@@ -206,48 +206,78 @@ var tuiWords = map[string]tuiWord{
why: "the panel of folders, never empty; clicking a project selects the next message’s folder",
},
"homePanelRunning": {
- screen: "sessions",
- why: "the recent conversation section, newest first. It was " +
- "`running` until 2026-09-17, and now shares its word with the bar's second tab, which it folds into",
+ screen: "activity",
+ why: "the recent conversation section, newest first, and the row a question raised in another window " +
+ "arrives on. It was `running` until 2026-09-17 and `sessions` after that, and it shares its word " +
+ "(tasksplace.go's sessionsWord) with the bar's fourth place, which its heading opens",
},
"switcherSinceLeft": {
screen: "since you left",
- why: "the panel over what happened while nobody was looking",
+ why: "the panel over what happened while nobody was looking — work that landed, files a conversation " +
+ "made, and every automation run that ended while the person was away",
},
"homePanelSpend": {
screen: "spend",
- why: "the day and the fortnight on home, and the third word of the four-place bar",
+ why: "the day and the fortnight on home: a panel pinned to the rail whatever it holds",
},
"homePanelNext": {
- screen: "standing",
- why: "every standing order this machine will act on — reminders, routines, watches, rules — soonest first",
+ screen: "automations",
+ why: "every automation this machine will run on the clock — reminders, scheduled work and watches — soonest " +
+ "first. It is the place's own name (place_automations.go's placeAutomationsWord: the tab, the heading " +
+ "and the command), so the suite also types it after a `/` to open the place. It was `standing` until " +
+ "automations replaced standing orders",
},
"homeRunningWhisper": {
screen: "your recent conversations appear here",
- why: "what `sessions` says with nothing under it — the whisper law (DESIGN §4): an empty panel names " +
+ why: "what `activity` says with nothing under it — the whisper law (DESIGN §4): an empty panel names " +
"what arrives there and never announces that it is empty. It is short enough to stand on one line " +
"at a hundred and twenty cells, which is why it is the whisper the suite waits for",
},
+ // THE BAR DRAWS EVERY PLACE BY ITS WORD WITH THE FIRST LETTER RAISED
+ // (topnav.go's navLabel), so each row below names the word the source
+ // spells and the label the screen shows. The order is placeBarOrder's.
"barHomeWord": {
- screen: "home",
- why: "the first of the six places on the wordmark row",
+ screen: "Home",
+ source: "home",
+ why: "the first of the seven places on the wordmark row",
},
- "barTeamsWord": {
- screen: "teams",
+ "barChatsWord": {
+ screen: "Chats",
+ source: "chats",
why: "the second place on the wordmark row",
},
- "barChatsWord": {
- screen: "chats",
- why: "the third place on the wordmark row",
+ "barTeamsWord": {
+ screen: "AI teams",
+ why: "the third place on the wordmark row. It is TWO WORDS ON ONE LABEL, which is why the suite reads the " +
+ "bar as labels in order and not as a row of fields",
},
"barSessionsWord": {
- screen: "sessions",
- why: "the fourth place on the wordmark row, and the place the sessions heading opens",
+ screen: "Activity",
+ source: "activity",
+ why: "the fourth place on the wordmark row — the one home's `activity` heading opens. It was `sessions` " +
+ "until the place was named for what is in it",
+ },
+ "barMemoryWord": {
+ screen: "Memory",
+ source: "memory",
+ why: "the fifth place on the wordmark row, which stands on the bar before the first memory is saved",
+ },
+ "barAutomationsWord": {
+ screen: "Automations",
+ source: "automations",
+ why: "the sixth place on the wordmark row, between memory and spend, on the bar before anything is set up",
+ },
+ "barSpendWord": {
+ screen: "Spend",
+ source: "spend",
+ why: "the seventh place on the wordmark row, and the place home's `spend` heading opens",
},
"barSettingsWord": {
- screen: "settings",
- why: "the last of the six — standing, memory and search are off the bar and reached by command",
+ screen: "Settings",
+ source: "settings",
+ why: "the last of the eight, after automations and spend — eight words need 84 columns, so a narrower " +
+ "frame folds it behind `more ▾`",
},
"chatFootEffortWord": {
screen: "alt+e effort · alt+a approvals",
@@ -257,7 +287,8 @@ var tuiWords = map[string]tuiWord{
},
"chatWorkingFootWord": {
screen: "esc interrupt",
- why: "the conversation's working foot while a reminder's turn is ending, before the home gesture returns",
+ why: "the conversation's foot while a turn is in flight: the key that ends it, standing where the home " +
+ "gesture stands at rest. It is how a scenario knows there is a middle of a turn to interrupt",
},
"pulseWantWord": {
screen: " want you",
@@ -298,9 +329,20 @@ var tuiWords = map[string]tuiWord{
screen: "working",
why: "the tail an errand's row wears while its turn is in flight",
},
- "homeAskStoodTail": {
- screen: "stood",
- why: "the tail an errand's row wears once something stands because of it",
+ "homeAskSavedTail": {
+ screen: "saved",
+ why: "the tail an errand's row wears once an automation it asked for was saved (homeexchange.go's " +
+ "homeAskSavedTail). It was `stood` while what an errand made was a standing order",
+ },
+ "homeAskHereHint": {
+ screen: "enter asks this here",
+ why: "the hint under the box while the cursor is on the `ask here` row — one ↑ off the action row — which " +
+ "is how a scenario knows the enter it is about to press asks rather than opens",
+ },
+ "homeStartHint": {
+ screen: "enter starts a new conversation",
+ why: "the same hint over the other action row, where the cursor rests while something is typed: the " +
+ "enter that opens a conversation for what is in the box",
},
// The three words of the live strip. They are GLIMPSED and never waited for —
// a spinner missed on a fast reply is a fast reply — but they are needles all
@@ -320,20 +362,20 @@ var tuiWords = map[string]tuiWord{
source: "running",
why: "the pane while a call of this turn is executing",
},
- "homeAskStoodWord": {
- screen: "kept · this exchange is filed under it",
- why: "the pane saying the exchange is filed under what it made",
+ "homeAskSavedWord": {
+ screen: "saved · /automations lists it",
+ why: "the pane's own note once an automation the exchange asked for is saved, and where to find it. It was " +
+ "`kept · this exchange is filed under it` while what an errand made was a standing order",
},
"exchangeAnswerHint": {
- screen: "Don't remind me",
- source: "Don't remind me",
+ screen: "Don't save",
pkg: "internal/session",
- why: "the answers a ONE-OFF REMINDER's card offers, spelled in full under the box at every width. " +
- "There are two rows and the key that asks for the box: a reminder has no `3 just once` to give, " +
- "and since #189 the line is built from the answers the question carries rather than typed out, so " +
- "it cannot name one. Since the standing card moved onto the question block (#780) the correction " +
- "is `o other` — the key table's own word — rather than a `2` row. The source is the first " +
- "answer's own constant, because the sentence is no longer a literal anywhere",
+ why: "the answers an AUTOMATION card offers, named under the box while the pane holds the keyboard. Since " +
+ "#189 the line is built from the answers the question carries rather than typed out " +
+ "(homeexchange.go's exchangeAnswerWords), so a reminder's card — `1 Save · 0 Don't save`, with no " +
+ "`2 Save and run it now` because a reminder has nothing to try — is offered exactly those. The source " +
+ "is the decline's own label (internal/session's AutomationOptions), because the sentence is no longer " +
+ "a literal anywhere. It was `Don't remind me` while the card was a standing order's",
},
"exchangeFollowUp": {
screen: "enter sends a follow-up",
@@ -348,38 +390,59 @@ var tuiWords = map[string]tuiWord{
why: "the hint while the cursor stands on an errand row with an empty box. It said `enter or tab` " +
"until `tab` on the row had long been the next place; `→` is the way into the pane that replaced it",
},
- "standRemindYes": {
- screen: "Remind me",
+ "consentRowLine": {
+ screen: "needs your ok to run ",
pkg: "internal/session",
- why: "the yes on a one-off reminder, which is what the errand in this suite asks for",
- },
- "standSetWord": {
- screen: "set up",
- why: "the other half — what a settled card keeps as its verdict",
+ why: "the whole sentence a consent gate hands another window — written once by the engine, repeated on home's row with nothing added to it",
},
- // ── what stands, and what it costs ───────────────────────────────────────
- "homeKeepingWord": {
- screen: " standing order",
- why: "the count at the foot of the task column while something stands, and a door onto the standing place — `◦ 2 standing orders`, respelled from `keeping an eye on 2` on 2026-09-09 because that named nothing a person could type, and moved off the status row on the same day; /status and the phone sheet keep the same words under `watching`",
- },
- "homeWatchLabel": {
- screen: "keeping watch",
- why: "/status's line about whether anything checks the world with no window open",
+ // ── automations: the card, the place and the clock ────────────────────────
+ //
+ // docs/design/automations/DESIGN.md is the design and automations_e2e_test.go
+ // reads it off a real screen. What an automation is CALLED and what it SAYS
+ // are the scenario's own words (its scripted model chose them, or its typed
+ // command did), so they are typed there; what is here is what the surface
+ // says around them.
+ "autoRemindHead": {
+ screen: "wants to remind you",
+ pkg: "internal/session",
+ why: "the head of a reminder's card — the KIND of thing being asked, built once by the engine " +
+ "(automation_contract.go's AutomationHead) and drawn by every surface",
},
- "consentRowLine": {
- screen: "needs your ok to run ",
+ "autoSaveAnswer": {
+ screen: "Save",
pkg: "internal/session",
- why: "the whole sentence a consent gate hands another window — written once by the engine, repeated on home's row with nothing added to it",
+ why: "the yes on an automation's card, which the settled card keeps as its answer — `drink water · Save · " +
+ "saved`. It was `Remind me` while the card was a standing order's",
+ },
+ "autoSavedVerdict": {
+ screen: "saved",
+ why: "the other half — what a settled card keeps as its verdict (automation.go's autoSavedWord). It was " +
+ "`set up` while the card was a standing order's",
},
- "standSaidTag": {
- screen: "said: ",
- why: "the clause a firing's own row wears in the conversation it lands in",
+ "autoTypedHead": {
+ screen: "save this automation?",
+ why: "the head of the card a TYPED `/automations add …` ends on (automationscmd.go's typedAddHead): the " +
+ "exact grammar, read with no model in between, still saves nothing until somebody says yes",
},
- "standingFiredWord": {
- screen: "fired ",
- pkg: "internal/standing",
- why: "what the `since you left` block says about a watch that went off while nobody was here",
+ "autoTypedSavedWord": {
+ screen: "saved ",
+ why: "the receipt a typed card's yes leaves on the message line, ahead of the automation's own title",
+ },
+ "autoKindReminderWord": {
+ screen: "reminder",
+ why: "the kind a row of the automations place names first, for an automation that says a fixed line",
+ },
+ "autoLastWord": {
+ screen: "last ",
+ why: "the clause on an automation's row that says how its last run went (automationsplace.go's autoLastWord)",
+ },
+ "autoRunDoneWord": {
+ screen: "done",
+ pkg: "internal/automation",
+ why: "what a run that ended well came to — the task-state word, spelled once by internal/automation's " +
+ "Outcome.Word — on the one dim line a finished run writes into the conversation that made it, and " +
+ "after `last ` on its row of the place. A reminder's run is always this: it calls no model",
},
// ── the money, said the same way wherever it is read ─────────────────────
@@ -397,8 +460,11 @@ var tuiWords = map[string]tuiWord{
why: "the receipt beside the per-conversation ceiling — the ENGINE's registry writes it and the tab only relays it",
},
"spendConversationRow": {
- screen: "per conversation",
- why: "the Spending row the `this one` receipt hangs off",
+ screen: "per-chat spending limit",
+ pkg: "internal/config",
+ why: "the Spending row the `this one` receipt hangs off. The tab draws the registry's own label for the " +
+ "row (internal/config's settings_presentation.go), not the `per conversation` internal/tui3 still " +
+ "keeps beside it — which is why this row names the package that writes what the screen shows",
},
// ── waiting on a machine that has gone quiet ─────────────────────────────
@@ -406,7 +472,7 @@ var tuiWords = map[string]tuiWord{
// THE PHASE CLOCK COMPOSES BOTH OF ITS SENTENCES AT THE DRAW, out of halves
// two packages own (internal/tui3's phase.go, and the clock that feeds it in
// internal/provider). So each half is its own row and the suite asserts the
- // join, which is the shape `standRemindYes` and `standSetWord` already have.
+ // join, which is the shape `autoSaveAnswer` and `autoSavedVerdict` already have.
// What varies is not a needle: the machine that went quiet is whatever this
// run pinned, and the provider a rescue would go to is whatever the frontier
// named. What stands still is the clause around them.
diff --git a/internal/effort/resolve.go b/internal/effort/resolve.go
index ab9953e56e..b7ec5dad19 100644
--- a/internal/effort/resolve.go
+++ b/internal/effort/resolve.go
@@ -42,17 +42,14 @@ const (
// thought about would be an odd thing to spend on titling it.
RoleErrand Role = "errand"
- // RoleStanding is one firing of a standing item, running unattended. It
- // carries no floor of its own: what keeps a firing from inheriting a depth
- // nobody meant it to have is that the conversation's dial does not reach it
- // (see [roleFloor]).
+ // RoleStanding was one firing of a standing item, running unattended. No
+ // call names it now: an automation's run is the person's own conversation
+ // done later, on the [RoleChat] seat (internal/session's automation_run.go).
RoleStanding Role = "standing"
- // RoleSentinel is the yes-or-no in front of a firing: has the thing the
- // person asked about happened? It is a judgment on evidence already
- // gathered and it runs on every check of every item forever — so the item's
- // own rung is the only thing that ever asks it to think, and an item that
- // asked for nothing sends nothing.
+ // RoleSentinel was the yes-or-no in front of a firing. No call names it now
+ // either: an automation watch's judgment never asks this ladder for a rung
+ // (internal/session's automation_run.go).
RoleSentinel Role = "sentinel"
)
@@ -72,9 +69,9 @@ const (
// every item, to models whose own defaults it knew nothing about; and it was a
// floor rather than a cap, so on a model that thinks less than "low" by default
// it bought deliberation nobody wanted. The half of it that was worth keeping
-// is kept where it belongs and by a different mechanism: a standing run does
-// not inherit the conversation's dial at all (internal/session's standing_run.go
-// sets DefaultEffort to None), so the only rung that reaches a firing is the one
+// was kept by a different mechanism: a standing run did not inherit the
+// conversation's dial at all (internal/session's standing_run.go set
+// DefaultEffort to None), so the only rung that reached a firing was the one
// written on the item's own card.
//
// ERRAND STAYS, and it is the one entry that is not a choice about depth: [None]
@@ -107,7 +104,7 @@ type Scope struct {
Conversation Rung
// Task is the rung set on the piece of work this call belongs to
- // (the task checkpoint's `effort`, a standing item's `does.effort`).
+ // (the task checkpoint's `effort`).
Task Rung
// Role is what the call is for. It decides nothing when a scope above it
diff --git a/internal/enginehost/enginehost.go b/internal/enginehost/enginehost.go
index 0c34b3b7dc..8974302f78 100644
--- a/internal/enginehost/enginehost.go
+++ b/internal/enginehost/enginehost.go
@@ -175,11 +175,11 @@ const spawnWait = 10 * time.Second
//
// THE SPAWN RACE IS GUARDED BY THE LOCK THE HOST ITSELF HOLDS, which is the
// discipline this tree already uses for anything one machine may only do once
-// (internal/filelock, and the standing tick's own lock). Whoever takes the lock
-// spawns; whoever finds it busy knows a host is alive or being born and simply
-// waits for the socket. Two spawns that race are not a fault either — the
-// second host to start finds the lock held and exits without a word — so the
-// worst case here is one wasted process, never two hosts on one workspace.
+// (internal/filelock, and the automations clock's own lock). Whoever takes the
+// lock spawns; whoever finds it busy knows a host is alive or being born and
+// simply waits for the socket. Two spawns that race are not a fault either —
+// the second host to start finds the lock held and exits without a word — so
+// the worst case here is one wasted process, never two hosts on one workspace.
//
// Every failure answers the same way: no connection and a reason, which the
// caller reads as "serve this one on the pipe".
diff --git a/internal/enginehost/enginehost_test.go b/internal/enginehost/enginehost_test.go
index f7cccc02cb..a0c0b334ca 100644
--- a/internal/enginehost/enginehost_test.go
+++ b/internal/enginehost/enginehost_test.go
@@ -29,9 +29,6 @@ import (
type stubAgent struct{}
func (stubAgent) Submit(context.Context, string) (<-chan session.Event, error) { return nil, nil }
-func (stubAgent) SubmitStanding(context.Context, string) (<-chan session.Event, error) {
- return nil, nil
-}
func (stubAgent) SubmitImage(context.Context, string, []session.Image) (<-chan session.Event, error) {
return nil, nil
}
@@ -49,7 +46,6 @@ func (stubAgent) ReasoningLevels() map[string]string { re
func (stubAgent) SetReasoningFor(string, string) {}
func (stubAgent) ResolveConsent(uint64, bool) {}
func (stubAgent) ResolveConsentRemember(uint64, bool, session.ConsentScope) {}
-func (stubAgent) ResolveStanding(uint64, session.StandingAnswer) {}
func (stubAgent) ResolveHarness(uint64, bool, string) {}
func (stubAgent) ResolveConnect(string, bool) {}
func (stubAgent) ResolveConnectKey(string, string) {}
diff --git a/internal/enginehost/environment.go b/internal/enginehost/environment.go
index 38e599f8f6..6802ec072a 100644
--- a/internal/enginehost/environment.go
+++ b/internal/enginehost/environment.go
@@ -57,6 +57,12 @@ func canonicalEnvironment(workspace string) string {
return strings.Join(lines, "\n")
}
+// EnvironmentMAC is the fingerprint of the environment codeaf reads for
+// providers and settings, under a caller's salt: no value is ever in it, and
+// two processes with the same salt and the same keys answer the same bytes.
+// The automations clock compares windows with it (cmd/codeaf's chatv3_clock.go).
+func EnvironmentMAC(salt []byte, workspace string) []byte { return environmentMAC(salt, workspace) }
+
func environmentMAC(salt []byte, workspace string) []byte {
mac := hmac.New(sha256.New, salt)
_, _ = mac.Write([]byte(canonicalEnvironment(workspace)))
diff --git a/internal/enginehost/host.go b/internal/enginehost/host.go
index 6dbce943b8..a222f01f28 100644
--- a/internal/enginehost/host.go
+++ b/internal/enginehost/host.go
@@ -47,15 +47,12 @@ var ErrHostRunning = errors.New("engine host: another host already holds this wo
// there is nothing to be the host of, so it exits and the next `codeaf engine`
// starts a new one — which costs one process spawn and is invisible.
//
-// THIS DOES NOT PUT THE AMBIENT SIDE TO SLEEP, and that is the one interaction
-// worth stating. Standing items are not held by this process: a firing runs
-// inside whichever process holds the store's tick lock — any live window, or
-// the operating system's timer running `codeaf tick` with nobody sitting
-// anywhere (cmd/codeaf's chatv3_standing.go states that law). A host that exits
-// hands the tick back to that timer exactly as a closed terminal does. So the
-// two lifetimes are deliberately NOT married: standing work already keeps a
-// machine warm on its own terms, and a host that stayed up forever to guard it
-// would be a second answer to a question that already has one.
+// AND THE AUTOMATIONS CLOCK IS NOT MARRIED TO IT, which is the one interaction
+// worth stating. Automations run in `codeaf clock` while a window is open
+// (cmd/codeaf's chatv3_clock.go), and this process's only part in that is to
+// count an attached window as open for as long as it stays attached
+// (internal/remote's automations.go). A host with nobody attached holds no
+// window, so whether it stays up or leaves changes nothing the clock counts.
// sessionIdle is a var rather than a const so a test can ask what the policy
// DECIDES without waiting half an hour to find out. Nothing in the product
// writes it.
@@ -302,8 +299,8 @@ func (h *Host) attach(conn net.Conn) {
},
}); err != nil {
// A host has no terminal and no person to tell, so a broken connection
- // goes to a file an operator can read afterwards — the same destination
- // and the same reason the standing pass's failures have.
+ // goes to a file an operator can read afterwards — the destination, and
+ // the reason, the automations clock's failures have too.
h.note("connection: " + err.Error())
}
}
diff --git a/internal/exec/bare/grep_runtime.go b/internal/exec/bare/grep_runtime.go
index 0abdfea40f..6f2957e839 100644
--- a/internal/exec/bare/grep_runtime.go
+++ b/internal/exec/bare/grep_runtime.go
@@ -19,7 +19,7 @@ func grepRuntimePatterns() []string {
"v3/runs/*/*/tasks", "v3/tasks/*/*.jsonl", "v3/usage.jsonl", "v3/fixes.json",
"v3/history.jsonl", "v3/projects/*/tasks.jsonl",
}
- for _, place := range []string{"v3/projects/*/*", "v3/standing/*/runs/*", "v3/standing/exchanges/*"} {
+ for _, place := range []string{"v3/projects/*/*", "v3/errands/*", "v3/automations/runs/*/*"} {
for _, output := range []string{"logs", "tasks", "transcript.jsonl", "state.json", "tasks.json"} {
patterns = append(patterns, place+"/"+output)
}
diff --git a/internal/exec/bare/grep_runtime_test.go b/internal/exec/bare/grep_runtime_test.go
index 0f7dab6a47..554b5f9ad6 100644
--- a/internal/exec/bare/grep_runtime_test.go
+++ b/internal/exec/bare/grep_runtime_test.go
@@ -82,8 +82,8 @@ func TestRuntimeSearchExclusionsBothEngines(t *testing.T) {
"state[custom]/v3/projects/p/s/work/source-session-work.go",
"state[custom]/v3/projects/p/s/trees/1/logs/source-tree-log.go",
"state[custom]/runs/codeaf-do-test/work/source-do-work.go",
- "state[custom]/v3/standing/task/runs/run/trees/1/source-standing.go",
- "state[custom]/v3/standing/exchanges/chat/work/source-exchange.go",
+ "state[custom]/v3/automations/runs/task/run/trees/1/source-automation.go",
+ "state[custom]/v3/errands/chat/work/source-errand.go",
}
runtime := []string{
"state[custom]/v3/history.jsonl",
@@ -94,8 +94,8 @@ func TestRuntimeSearchExclusionsBothEngines(t *testing.T) {
"state[custom]/v3/projects/p/s/tasks/runtime-task.jsonl",
"state[custom]/v3/projects/p/s/transcript.jsonl",
"state[custom]/runs/codeaf-do-test/tasks/root/runtime-do.jsonl",
- "state[custom]/v3/standing/task/runs/run/logs/runtime-standing.log",
- "state[custom]/v3/standing/exchanges/chat/tasks/runtime-exchange.jsonl",
+ "state[custom]/v3/automations/runs/task/run/logs/runtime-automation.log",
+ "state[custom]/v3/errands/chat/tasks/runtime-errand.jsonl",
}
for _, name := range append(append([]string{}, source...), runtime...) {
runtimeSearchWrite(t, filepath.Join(root, name), "needle\n")
diff --git a/internal/head/activity.go b/internal/head/activity.go
index 8618115e08..987805db5b 100644
--- a/internal/head/activity.go
+++ b/internal/head/activity.go
@@ -132,8 +132,6 @@ func toolGloss(name, arguments string) string {
return "opening what was found"
case beltToolCompetence:
return "checking what it is good at"
- case beltToolStanding:
- return "checking what is standing"
case beltToolSpending:
return "checking the spend"
case beltToolHistory:
diff --git a/internal/head/acts.go b/internal/head/acts.go
index c87a8cea3e..ea2c5fea1b 100644
--- a/internal/head/acts.go
+++ b/internal/head/acts.go
@@ -12,36 +12,11 @@ import (
// The acts the cue ladder used to reach terminally, as tools.
//
// Every one of these was a recognizer's payload: a prefix test decided the
-// sentence was a correction, or a rule edit, or a service command, and then
-// journaled and spoke without anything with judgment seeing the message. The
-// machinery underneath is untouched — the same correction block with the same
-// dispute line, the same charter transition table, the same service kinds, the
-// same gates. What moved is who decides, and the difference shows up on exactly
-// the sentences the prefix tests were never going to cover.
-
-// charterAcknowledgement is acknowledgeCharterCommand's sentence without the
-// posting. The question-answer path still posts it directly, because an answer
-// to a durable question is settled where it is answered; a tool hands it back
-// for the loop to say in its own words.
-func charterAcknowledgement(kind store.CommandKind) string {
- switch kind {
- case store.CommandCharterRatify:
- return "Standing it up."
- case store.CommandCharterPause:
- return "Pausing that rule."
- case store.CommandCharterRetire:
- return "Retiring that rule."
- case store.CommandCharterOnce:
- return "Keeping it one-time."
- case store.CommandCharterCadence:
- return "Changing when that runs."
- case store.CommandCharterWording:
- return "Changing what it says."
- case store.CommandCharterProbation:
- return "Asking before firing again."
- }
- return "Updating that standing rule."
-}
+// sentence was a correction, or a service command, and then journaled and spoke
+// without anything with judgment seeing the message. The machinery underneath
+// is untouched — the same correction block with the same dispute line, the same
+// service kinds, the same gates. What moved is who decides, and the difference
+// shows up on exactly the sentences the prefix tests were never going to cover.
// retirementReason is why something was taken off the shelf: their own sentence
// when there is one, and the plain fact of it when they clicked instead.
@@ -194,8 +169,8 @@ func (run *beltRun) say(args map[string]any) (string, bool) {
}
// controlCandidates answers a verb that knows what it wants to do and not what
-// to do it to. It is a READ: the union of the live jobs and the standing rules
-// the words reach, handed back for the loop to name or to ask about. The old
+// to do it to. It is a READ: the live jobs the words reach, handed back for the
+// loop to name or to ask about. The old
// path chose for the user when exactly one thing matched, which is how a request
// to withdraw fourteen queued tasks became "Cancelling line-scan."
func (run *beltRun) controlCandidates(kind store.CommandKind, describes string) (string, bool) {
@@ -208,18 +183,13 @@ func (run *beltRun) controlCandidates(kind store.CommandKind, describes string)
return "that could not be looked up: " + err.Error(), true
}
if len(candidates) == 0 {
- return "nothing on the board and no standing rule matches those words", false
+ return "nothing on the board matches those words", false
}
if len(candidates) > describedTargetCap {
candidates = candidates[:describedTargetCap]
}
lines := make([]string, 0, len(candidates))
for _, candidate := range candidates {
- if candidate.isRule() {
- lines = append(lines, "- standing rule "+candidate.rule.ID+" | "+firstLine(candidate.rule.Invariant)+
- " | a standing rule — stopping it retires it")
- continue
- }
lines = append(lines, "- "+candidate.job.Node.ID+" | "+surgeryTargetLabel(candidate.job.Node)+
" | "+surgeryTargetHint(candidate.job))
}
diff --git a/internal/head/answer_test.go b/internal/head/answer_test.go
index 337b53f2ca..17b63c0045 100644
--- a/internal/head/answer_test.go
+++ b/internal/head/answer_test.go
@@ -254,7 +254,7 @@ func TestTheBeltAndTheRouterBothStateWhatTheyCanActuallyDo(t *testing.T) {
// there, and that the few verbs it names are real tools.
for _, name := range []string{
beltToolBoard, beltToolResult, beltToolPlan, beltToolRead, beltToolManual,
- beltToolCompetence, beltToolStanding, beltToolSpending, beltToolHistory, beltToolSearch,
+ beltToolCompetence, beltToolSpending, beltToolHistory, beltToolSearch,
beltToolTask, beltToolChange, beltToolStop, beltToolBash, beltToolNote, beltToolWrite,
beltToolAnswerQuestion, beltToolSay, beltToolAsk, beltToolInterrupt,
} {
diff --git a/internal/head/answers.go b/internal/head/answers.go
new file mode 100644
index 0000000000..e51cc27545
--- /dev/null
+++ b/internal/head/answers.go
@@ -0,0 +1,338 @@
+package head
+
+import (
+ "context"
+ "strconv"
+ "strings"
+
+ "github.com/Agent-Field/codeaf/internal/store"
+)
+
+// THE ANSWER SIDE OF THE DURABLE QUESTION QUEUE. A reply reaches a question here
+// and is turned into what the question was for: a compiler askback continued,
+// a surgery or class confirmation applied, a service or craft decision written
+// down. Standing rules had their own half of this file — ratifying, retiming
+// and approving a rule's firings, and the offer to keep watching with no
+// terminal open — and it went with the v1 scheduler; a question an older build
+// asked on a rule's behalf lapses in the resident before anything here sees it.
+
+// craftContinueOption and craftStopOption are the two answers a craft run's
+// money stop offers. The words are written here as well as where the question
+// is minted because they are a wire form between two halves of the system, the
+// same way every other option value in this file is — and because a decoder
+// that imported its own encoder would only be able to read questions this
+// binary happened to write.
+const (
+ craftContinueOption = "craft:continue:"
+ craftStopOption = "craft:stop:"
+)
+
+// answerAgentQuestion routes replies to the durable reverse-direction queue.
+// An explicit QuestionSeq wins — every surface that knows which question is on
+// screen carries it, and a reply that names its question can never hit another
+// one. Without it the store applies the same no-intervening-user-turn recency
+// rule as ordinary conversational askbacks, and aimAgentQuestion stands between
+// that rule and a silent wrong answer when more than one question is open.
+func (h *Head) answerAgentQuestion(ctx context.Context, user store.Message) (bool, error) {
+ question, found, err := h.store.QuestionForAnswer(user.SessionID, user.Seq, user.QuestionSeq)
+ if err != nil {
+ return false, err
+ }
+ if !found {
+ // Nothing is answerable, which is exactly the state the ambiguity ask
+ // leaves behind: the reply it was asking about is itself an intervening
+ // user turn for every question it named. So this is where the answer to
+ // that ask is read, and nowhere else pays for the lookup.
+ return h.answerQuestionChoice(ctx, user)
+ }
+ if user.QuestionSeq == 0 {
+ aimed, asked, aimErr := h.aimAgentQuestion(user, question)
+ if asked || aimErr != nil {
+ return asked, aimErr
+ }
+ question = aimed
+ }
+ return h.resolveAgentQuestion(ctx, user, question, user.Body)
+}
+
+// resolveAgentQuestion settles one identified question with one body of words.
+// The words are a parameter rather than user.Body because the turn that names
+// which question was meant is not the turn that answered it.
+func (h *Head) resolveAgentQuestion(ctx context.Context, user store.Message,
+ question store.AgentQuestion, body string) (bool, error) {
+ if question.Status == store.QuestionAnswered || question.Status == store.QuestionExpired {
+ return true, nil
+ }
+ if isAskQuestion(question.Options) {
+ // A categorized ask is a durable row so that the meta loop can count it,
+ // not so that the gates can apply it. This path applies answers — it would
+ // resolve the row, post "Got it", and end the turn, leaving the loop that
+ // asked the question never told what came back. So: settle the row for the
+ // measurement, then decline, and the reply travels on to the loop with the
+ // question beside it.
+ if err := h.settleLearnedAsk(question.Seq, question.Options, body, user.Seq); err != nil {
+ return false, err
+ }
+ // One categorized ask is settled HERE rather than declined, and it is the
+ // only one: a yes to the split offer moves the person's window, and a
+ // window move is not something a model can be asked to say. The turn ends
+ // with it — their pivot is reposted into the new room and answered there
+ // by the ordinary poll, so a reply in this room would land behind them.
+ if splitAnswered(question, body) {
+ split, err := h.settleThreadSplit(user)
+ if err != nil {
+ return false, err
+ }
+ if split {
+ return true, nil
+ }
+ }
+ return false, nil
+ }
+ answer := strings.TrimSpace(body)
+ if option, selected := selectQuestionOption(body, question.Options); selected {
+ answer = strings.TrimSpace(option.Label)
+ if answer == "" {
+ answer = strings.TrimSpace(option.Value)
+ }
+ if handled, err := h.answerCraftBudgetQuestion(user, question.Seq, option); handled {
+ return true, err
+ }
+ if err := h.store.ResolveQuestion(question.Seq, store.QuestionAnswered, answer, user.Seq); err != nil {
+ return true, err
+ }
+ return true, h.applyAgentQuestionOption(ctx, user, question, option)
+ }
+ if answer == "" {
+ return true, h.postAgent(user.SessionID, "Tell me what you want me to use for that question.", 0)
+ }
+ if err := h.store.ResolveQuestion(question.Seq, store.QuestionAnswered, answer, user.Seq); err != nil {
+ return true, err
+ }
+ if question.OriginCommandSeq != 0 {
+ return true, h.continueAgentCompilerQuestion(user, question, answer)
+ }
+ return true, h.postAgent(user.SessionID, "Got it — I’ll use that.", 0)
+}
+
+func (h *Head) applyAgentQuestionOption(ctx context.Context, user store.Message, question store.AgentQuestion, option store.QuestionOption) error {
+ if handled, err := h.applyRedirectOption(ctx, user, option); handled {
+ return err
+ }
+ if action, kind, target, instruction, ok := decodeSurgeryOption(option.Value); ok {
+ if handled, err := h.applyClassOption(user, action, kind, target, instruction); handled {
+ return err
+ }
+ switch action {
+ case "apply":
+ return h.resolveSurgery(user, kind, target, instruction, true)
+ case "keep":
+ return h.postAgent(user.SessionID, "Keeping it as-is.", 0)
+ }
+ }
+ // Only the hygiene nudge acts from the durable queue. A leaf's promotion
+ // consent is read back by the waiting runner, not applied here.
+ if parts := strings.Split(option.Value, ":"); len(parts) == 3 && parts[0] == "service" &&
+ strings.HasPrefix(parts[1], "hygiene-") {
+ if handled, err := h.applyServiceOption(user, option); handled {
+ return err
+ }
+ }
+ answer := compilerAnswerFromOption(option)
+ if question.OriginCommandSeq != 0 {
+ return h.continueAgentCompilerQuestion(user, question, answer)
+ }
+ return h.postAgent(user.SessionID, "Got it — I’ll use that.", 0)
+}
+
+// compilerAnswerFromOption is the exact words a chosen option sends back to
+// the compiler. A value the label merely wraps — "moonshotai/kimi-k2" inside
+// "use moonshotai/kimi-k2" — is the precise form and wins, so resolution is
+// exact rather than re-parsed out of a sentence. Anywhere else the value is an
+// internal code and the label is the answer a person would have typed.
+func compilerAnswerFromOption(option store.QuestionOption) string {
+ label := strings.TrimSpace(option.Label)
+ value := strings.TrimSpace(option.Value)
+ if value != "" && (label == "" || strings.Contains(strings.ToLower(label), strings.ToLower(value))) {
+ return value
+ }
+ return label
+}
+
+func (h *Head) continueAgentCompilerQuestion(user store.Message, question store.AgentQuestion, answer string) error {
+ source, found, err := h.store.CommandBySeq(question.OriginCommandSeq)
+ if err != nil {
+ return err
+ }
+ if !found || source.Kind != store.CommandSplice || strings.TrimSpace(answer) == "" {
+ return h.postAgent(user.SessionID, "Tell me which option you want, or answer in your own words.", 0)
+ }
+ instruction := SpliceCompilerAnswer(source.Instruction, answer)
+ command, err := h.store.RequestCommand(store.Command{
+ SessionID: user.SessionID, Kind: store.CommandSplice, Instruction: instruction,
+ // The answer joins the ASK, which is what it is an answer to. The
+ // conversation the original ask came out of travels beside it, still in
+ // its own field: a continuation that dropped it would plan the second
+ // half of the job with less than the first half had.
+ Context: source.Context,
+ // The answer continues the original ask, and what the user attached to
+ // it is part of that ask. Dropping the files here is how a question
+ // about a PDF turns into a job that never sees it.
+ Attachments: append([]string(nil), source.Attachments...),
+ })
+ if err != nil {
+ return err
+ }
+ return h.postAgent(user.SessionID, "Got it — proceeding with that choice.", command.Seq)
+}
+
+func (h *Head) answerPendingQuestion(ctx context.Context, user store.Message) (bool, error) {
+ question, pending, err := h.store.PendingQuestion(user.SessionID, user.Seq)
+ if err != nil || !pending {
+ return false, err
+ }
+ if isAskQuestion(question.Options) {
+ // The loop asked it, so the loop settles it. Declining here is what sends
+ // the message on with both halves — the question and the choice — in the
+ // thread the loop is about to read, which is the only place the answer
+ // means anything.
+ //
+ // A categorized ask carries a durable row as well, and that row is the
+ // only thing the meta loop can count: an ask nobody settles teaches the
+ // gate nothing, so it would go on asking a question the person has now
+ // answered the same way a dozen times. Settle the row here and still hand
+ // the message on — recording the answer is a measurement, not a reply.
+ return false, h.settleLearnedAsk(question.QuestionSeq, question.Options, user.Body, user.Seq)
+ }
+ option, selected := selectQuestionOption(user.Body, question.Options)
+ if selected {
+ return true, h.applyQuestionOption(ctx, user, question, option)
+ }
+ if question.CommandSeq == 0 {
+ return false, nil
+ }
+ return true, h.continueCompilerQuestion(user, question, strings.TrimSpace(user.Body))
+}
+
+func selectQuestionOption(reply string, options []store.QuestionOption) (store.QuestionOption, bool) {
+ normalized := strings.ToLower(strings.Trim(strings.TrimSpace(reply), " .,!?:;\t\n\r"))
+ if number, err := strconv.Atoi(normalized); err == nil && number > 0 && number <= len(options) {
+ return options[number-1], true
+ }
+ for _, option := range options {
+ if normalized == strings.ToLower(strings.TrimSpace(option.Label)) ||
+ (option.Value != "" && normalized == strings.ToLower(strings.TrimSpace(option.Value))) {
+ return option, true
+ }
+ }
+ for _, option := range options {
+ label := strings.ToLower(strings.TrimSpace(option.Label))
+ value := strings.ToLower(strings.TrimSpace(option.Value))
+ if affirmativeRailReply(normalized) &&
+ (strings.HasPrefix(label, "yes") || strings.HasPrefix(value, craftContinueOption)) {
+ return option, true
+ }
+ if negativeReply(normalized) &&
+ (strings.HasPrefix(value, craftStopOption) ||
+ strings.HasPrefix(label, "keep ") || strings.Contains(value, "surgery:keep:")) {
+ return option, true
+ }
+ }
+ return store.QuestionOption{}, false
+}
+
+func negativeReply(reply string) bool {
+ switch reply {
+ case "n", "no", "no thanks", "decline", "never":
+ return true
+ default:
+ return false
+ }
+}
+
+func (h *Head) applyQuestionOption(ctx context.Context, user store.Message, question store.Message, option store.QuestionOption) error {
+ if handled, err := h.applyServiceOption(user, option); handled {
+ return err
+ }
+ if handled, err := h.applyRedirectOption(ctx, user, option); handled {
+ return err
+ }
+ if action, kind, target, instruction, ok := decodeSurgeryOption(option.Value); ok {
+ if handled, err := h.applyClassOption(user, action, kind, target, instruction); handled {
+ return err
+ }
+ switch action {
+ case "select":
+ return h.resolveSurgery(user, kind, target, instruction, false)
+ case "apply":
+ return h.resolveSurgery(user, kind, target, instruction, true)
+ case "keep":
+ return h.postAgent(user.SessionID, "Keeping it as-is.", 0)
+ }
+ }
+ return h.continueCompilerQuestion(user, question, compilerAnswerFromOption(option))
+}
+
+func (h *Head) continueCompilerQuestion(user store.Message, question store.Message, answer string) error {
+ source, found, err := h.store.CommandBySeq(question.CommandSeq)
+ if err != nil {
+ return err
+ }
+ if !found || source.Kind != store.CommandSplice || strings.TrimSpace(answer) == "" {
+ return h.postAgent(user.SessionID, "Tell me which option you want, or answer in your own words.", 0)
+ }
+ instruction := SpliceCompilerAnswer(source.Instruction, answer)
+ command, err := h.store.RequestCommand(store.Command{
+ SessionID: user.SessionID, Kind: store.CommandSplice, Instruction: instruction,
+ // Same law on the conversational path: the continuation is the same ask
+ // carrying the same conversation and the same files.
+ Context: source.Context,
+ Attachments: append([]string(nil), source.Attachments...),
+ })
+ if err != nil {
+ return err
+ }
+ return h.postAgent(user.SessionID, "Got it — proceeding with that choice.", command.Seq)
+}
+
+// answerCraftBudgetQuestion settles a craft run's money stop. The run itself
+// applies the decision — it is the only thing that knows what it has spent and
+// what it still has to do — so the whole job here is to write the choice down
+// in a form the run can read without guessing: the option's own value becomes
+// the question's durable resolution, and the run polls it exactly as a waiting
+// leaf polls for service consent. Without this branch the answer fell through
+// to "Got it — I'll use that", which acknowledged a decision nothing acted on.
+func (h *Head) answerCraftBudgetQuestion(user store.Message, questionSeq int64, option store.QuestionOption) (bool, error) {
+ value, keepGoing, ok := decodeCraftBudgetOption(option.Value)
+ if !ok {
+ return false, nil
+ }
+ if err := h.store.ResolveQuestion(questionSeq, store.QuestionAnswered, value, user.Seq); err != nil {
+ return true, err
+ }
+ reply := "Okay — it'll deliver what already landed."
+ if keepGoing {
+ reply = "Keeping it going."
+ }
+ return true, h.postAgent(user.SessionID, reply, 0)
+}
+
+// decodeCraftBudgetOption reads one craft money answer, fail-closed like every
+// other option decoder here: the run's own id namespace has to be there, or
+// this is not a craft consent and must not be treated as one.
+func decodeCraftBudgetOption(value string) (resolution string, keepGoing bool, ok bool) {
+ value = strings.TrimSpace(value)
+ var prefix string
+ switch {
+ case strings.HasPrefix(value, craftContinueOption):
+ prefix, keepGoing = strings.TrimPrefix(value, craftContinueOption), true
+ case strings.HasPrefix(value, craftStopOption):
+ prefix, keepGoing = strings.TrimPrefix(value, craftStopOption), false
+ default:
+ return "", false, false
+ }
+ if strings.TrimSpace(prefix) == "" {
+ return "", false, false
+ }
+ return value, keepGoing, true
+}
diff --git a/internal/head/brief.go b/internal/head/brief.go
index 3a477ff19a..09321ba78b 100644
--- a/internal/head/brief.go
+++ b/internal/head/brief.go
@@ -43,7 +43,7 @@ import (
const (
// threadGap is how long a room has to have been silent before its next
// message is a RE-ENTRY rather than the next line of a conversation. It
- // matches the arrival brief's own default (config.DefaultBriefAfter) on
+ // matches the arrival brief's own default (resident.DefaultBriefAfter) on
// purpose: the two answer the same question about the same person, and a
// window that briefed on arrival while the head carried on mid-sentence
// would be two components disagreeing about whether they had been away.
diff --git a/internal/head/change.go b/internal/head/change.go
index a1ac02dd51..b6fd5a9d2b 100644
--- a/internal/head/change.go
+++ b/internal/head/change.go
@@ -31,12 +31,12 @@ import (
// what the work IS — goes as CommandRedirect with the words untouched.
//
// Every consent gate and legality check stays where it is: the store's legality
-// table, the charter transition table, the craft repository's own refusals.
+// table and the craft repository's own refusals.
func (run *beltRun) change(args map[string]any) (string, bool) {
target := strings.TrimSpace(beltString(args, "target"))
if target == "" {
- return "target must name one id from a read — a job, a standing rule, a service, or a learned way of working", true
+ return "target must name one id from a read — a job, a service, or a learned way of working", true
}
words := strings.TrimSpace(beltString(args, "words"))
if words == "" {
@@ -60,8 +60,6 @@ func (run *beltRun) change(args map[string]any) (string, bool) {
return run.changeJob(found.job, words)
case targetThread:
return run.changeThread(found.thread.ID, words)
- case targetRule:
- return run.changeRule(found.rule, words)
case targetService:
return run.changeService(found.service, words)
case targetCraft:
@@ -149,48 +147,6 @@ func changeSays(lower string, phrases ...string) bool {
return false
}
-// changeRule resolves the person's words onto the charter transitions that
-// change a rule without withdrawing it: when it runs, what it says, and whether
-// it asks first. The resolution is charterManagement's — the same reading the
-// deterministic charter path has always used — and where it cannot tell, the
-// candidates come back exactly as every other ambiguity in this package does.
-func (run *beltRun) changeRule(rule store.Charter, words string) (string, bool) {
- intent, read := charterManagement(words)
- kind := intent.Kind
- switch {
- case !read, kind == store.CommandCharterRetire, kind == store.CommandCharterPause:
- // Retiring and holding are withdrawal and belong to stop; an unreadable
- // sentence is not one to guess at. Both come back as choices.
- return "those words do not say plainly which change to " + firstLine(rule.Invariant) +
- " they mean. Ask which, with these as the options:\n" +
- "- when it runs (say the new rhythm)\n" +
- "- what it says (say the new message)\n" +
- "- going back to asking before each run\n" +
- "- retiring or holding it — that is stop, not change", false
- }
- instruction := words
- switch kind {
- case store.CommandCharterCadence:
- if intent.Cadence != "" {
- instruction = intent.Cadence
- }
- case store.CommandCharterWording:
- if intent.Wording != "" {
- instruction = intent.Wording
- }
- default:
- instruction = string(kind)
- }
- command, err := run.head.store.RequestCommand(store.Command{
- SessionID: run.user.SessionID, Kind: kind, Target: rule.ID, Instruction: instruction,
- })
- if err != nil {
- return "that could not be queued: " + err.Error(), true
- }
- run.record(command.Seq, charterAcknowledgement(kind))
- return "queued: " + charterAcknowledgement(kind), false
-}
-
// changeService reads the two things a person changes about something they are
// running — start it over, or stop it deciding for itself whether to. Stopping
// it is withdrawal and belongs to stop.
@@ -263,7 +219,6 @@ const (
targetNone targetKind = iota
targetForeign
targetJob
- targetRule
targetService
targetCraft
// targetThread is the conversation itself. It joined the list when threads
@@ -276,25 +231,24 @@ const (
type resolvedTarget struct {
kind targetKind
job store.Node
- rule store.Charter
service store.Service
craft string
thread store.Session
}
-// resolveTarget reads one id against everything the person owns. All four kinds
-// are confirmed before anything is journaled: a graph node, a charter, a service
-// by id or by name, and a way of working by the name the journal recorded when
-// it was forged.
+// resolveTarget reads one id against everything the person owns. Every kind is
+// confirmed before anything is journaled: a graph node, a service by id or by
+// name, a way of working by the name the journal recorded when it was forged,
+// and a conversation.
func (h *Head) resolveTarget(id string) resolvedTarget {
id = strings.TrimSpace(id)
if h == nil || h.store == nil || id == "" {
return resolvedTarget{}
}
// The node read runs first and its NEGATIVE answer is held rather than
- // returned: a standing rule has a node of its own in the graph, and that node
- // is deliberately outside the membrane the board sits behind. Answering
- // "not yours" there would have made every rule unreachable by its own id.
+ // returned: an id outside the membrane the board sits behind may still name
+ // a service, a way of working or a conversation, and answering "not yours"
+ // there would make those unreachable by their own ids.
foreign := store.Node{}
if node, found, err := h.store.Node(id); err == nil && found && node.ID != store.RootID {
if beltAddressable(node) {
@@ -302,9 +256,6 @@ func (h *Head) resolveTarget(id string) resolvedTarget {
}
foreign = node
}
- if rule, found, err := h.store.Charter(id); err == nil && found {
- return resolvedTarget{kind: targetRule, rule: rule}
- }
if service, found, err := h.store.Service(id); err == nil && found {
return resolvedTarget{kind: targetService, service: service}
}
@@ -330,9 +281,9 @@ func (h *Head) resolveTarget(id string) resolvedTarget {
}
// unknownTarget is the one sentence every verb says about an id nothing answers
-// to. It names all four places that were looked in, so the loop can tell a
-// mistyped id from a thing that never existed.
+// to. It names the places that were looked in, so the loop can tell a mistyped
+// id from a thing that never existed.
func unknownTarget(id string) string {
- return "nothing on the board, no standing rule, no service and no learned way of working is called " +
+ return "nothing on the board, no service and no learned way of working is called " +
quoted(id) + " — read again for the id"
}
diff --git a/internal/head/charters.go b/internal/head/charters.go
deleted file mode 100644
index b6d407628f..0000000000
--- a/internal/head/charters.go
+++ /dev/null
@@ -1,735 +0,0 @@
-package head
-
-import (
- "context"
- "sort"
- "strconv"
- "strings"
- "time"
- "unicode"
-
- "github.com/Agent-Field/codeaf/internal/store"
-)
-
-// craftContinueOption and craftStopOption are the two answers a craft run's
-// money stop offers. The words are written here as well as where the question
-// is minted because they are a wire form between two halves of the system, the
-// same way every other option value in this file is — and because a decoder
-// that imported its own encoder would only be able to read questions this
-// binary happened to write.
-const (
- craftContinueOption = "craft:continue:"
- craftStopOption = "craft:stop:"
-)
-
-// answerAgentQuestion routes replies to the durable reverse-direction queue.
-// An explicit QuestionSeq wins — every surface that knows which question is on
-// screen carries it, and a reply that names its question can never hit another
-// one. Without it the store applies the same no-intervening-user-turn recency
-// rule as ordinary conversational askbacks, and aimAgentQuestion stands between
-// that rule and a silent wrong answer when more than one question is open.
-func (h *Head) answerAgentQuestion(ctx context.Context, user store.Message) (bool, error) {
- question, found, err := h.store.QuestionForAnswer(user.SessionID, user.Seq, user.QuestionSeq)
- if err != nil {
- return false, err
- }
- if !found {
- // Nothing is answerable, which is exactly the state the ambiguity ask
- // leaves behind: the reply it was asking about is itself an intervening
- // user turn for every question it named. So this is where the answer to
- // that ask is read, and nowhere else pays for the lookup.
- return h.answerQuestionChoice(ctx, user)
- }
- if user.QuestionSeq == 0 {
- aimed, asked, aimErr := h.aimAgentQuestion(user, question)
- if asked || aimErr != nil {
- return asked, aimErr
- }
- question = aimed
- }
- return h.resolveAgentQuestion(ctx, user, question, user.Body)
-}
-
-// resolveAgentQuestion settles one identified question with one body of words.
-// The words are a parameter rather than user.Body because the turn that names
-// which question was meant is not the turn that answered it.
-func (h *Head) resolveAgentQuestion(ctx context.Context, user store.Message,
- question store.AgentQuestion, body string) (bool, error) {
- if question.Status == store.QuestionAnswered || question.Status == store.QuestionExpired {
- return true, nil
- }
- if isAskQuestion(question.Options) {
- // A categorized ask is a durable row so that the meta loop can count it,
- // not so that the gates can apply it. This path applies answers — it would
- // resolve the row, post "Got it", and end the turn, leaving the loop that
- // asked the question never told what came back. So: settle the row for the
- // measurement, then decline, and the reply travels on to the loop with the
- // question beside it.
- if err := h.settleLearnedAsk(question.Seq, question.Options, body, user.Seq); err != nil {
- return false, err
- }
- // One categorized ask is settled HERE rather than declined, and it is the
- // only one: a yes to the split offer moves the person's window, and a
- // window move is not something a model can be asked to say. The turn ends
- // with it — their pivot is reposted into the new room and answered there
- // by the ordinary poll, so a reply in this room would land behind them.
- if splitAnswered(question, body) {
- split, err := h.settleThreadSplit(user)
- if err != nil {
- return false, err
- }
- if split {
- return true, nil
- }
- }
- return false, nil
- }
- answer := strings.TrimSpace(body)
- if option, selected := selectQuestionOption(body, question.Options); selected {
- answer = strings.TrimSpace(option.Label)
- if answer == "" {
- answer = strings.TrimSpace(option.Value)
- }
- if handled, err := h.answerCharterFiringQuestion(user, question.Seq, option); handled {
- return true, err
- }
- if handled, err := h.answerCraftBudgetQuestion(user, question.Seq, option); handled {
- return true, err
- }
- if err := h.store.ResolveQuestion(question.Seq, store.QuestionAnswered, answer, user.Seq); err != nil {
- return true, err
- }
- return true, h.applyAgentQuestionOption(ctx, user, question, option)
- }
- if standingWatchQuestion(question.Options) {
- // Unattended presence is decided once and never asked again, so only an
- // explicit choice may cross it. Free text stays ordinary conversation
- // rather than silently spending the single question.
- return false, nil
- }
- if charterID, ok := charterQuestionID(question.Options); ok {
- if cadence := extractCadence(body); cadence != "" {
- if err := h.store.ResolveQuestion(question.Seq, store.QuestionAnswered, cadence, user.Seq); err != nil {
- return true, err
- }
- return true, h.requestCharterCommand(user, store.CommandCharterCadence, charterID, cadence)
- }
- // Preserve the existing charter behavior: unrelated free text remains
- // ordinary conversation rather than accidentally ratifying spend.
- return false, nil
- }
- if answer == "" {
- return true, h.postAgent(user.SessionID, "Tell me what you want me to use for that question.", 0)
- }
- if err := h.store.ResolveQuestion(question.Seq, store.QuestionAnswered, answer, user.Seq); err != nil {
- return true, err
- }
- if question.OriginCommandSeq != 0 {
- return true, h.continueAgentCompilerQuestion(user, question, answer)
- }
- return true, h.postAgent(user.SessionID, "Got it — I’ll use that.", 0)
-}
-
-func (h *Head) applyAgentQuestionOption(ctx context.Context, user store.Message, question store.AgentQuestion, option store.QuestionOption) error {
- if handled, err := h.applyRedirectOption(ctx, user, option); handled {
- return err
- }
- if action, kind, target, instruction, ok := decodeSurgeryOption(option.Value); ok {
- if handled, err := h.applyClassOption(user, action, kind, target, instruction); handled {
- return err
- }
- switch action {
- case "apply":
- return h.resolveSurgery(user, kind, target, instruction, true)
- case "keep":
- return h.postAgent(user.SessionID, "Keeping it as-is.", 0)
- }
- }
- // Only the hygiene nudge acts from the durable queue. A leaf's promotion
- // consent is read back by the waiting runner, not applied here.
- if parts := strings.Split(option.Value, ":"); len(parts) == 3 && parts[0] == "service" &&
- strings.HasPrefix(parts[1], "hygiene-") {
- if handled, err := h.applyServiceOption(user, option); handled {
- return err
- }
- }
- parts := strings.Split(option.Value, ":")
- if len(parts) == 2 && parts[0] == "standing-watch" {
- switch parts[1] {
- case "enable":
- return h.requestStandingWatchCommand(user, store.CommandStandingWatchEnable, option.Label)
- case "decline":
- return h.requestStandingWatchCommand(user, store.CommandStandingWatchDecline, option.Label)
- }
- }
- if len(parts) >= 3 && parts[0] == "charter" {
- id := parts[2]
- switch parts[1] {
- case "ratify":
- return h.requestCharterCommand(user, store.CommandCharterRatify, id, option.Label)
- case "pause":
- return h.requestCharterCommand(user, store.CommandCharterPause, id, option.Label)
- case "retire":
- return h.requestCharterCommand(user, store.CommandCharterRetire, id, option.Label)
- case "once":
- return h.requestCharterCommand(user, store.CommandCharterOnce, id, option.Label)
- case "cadence":
- if len(parts) > 3 {
- return h.requestCharterCommand(user, store.CommandCharterCadence, id,
- strings.Join(parts[3:], ":"))
- }
- return h.askForCadence(user.SessionID, id)
- case "wording":
- if len(parts) > 3 {
- return h.requestCharterCommand(user, store.CommandCharterWording, id,
- strings.Join(parts[3:], ":"))
- }
- }
- }
- answer := compilerAnswerFromOption(option)
- if question.OriginCommandSeq != 0 {
- return h.continueAgentCompilerQuestion(user, question, answer)
- }
- return h.postAgent(user.SessionID, "Got it — I’ll use that.", 0)
-}
-
-// compilerAnswerFromOption is the exact words a chosen option sends back to
-// the compiler. A value the label merely wraps — "moonshotai/kimi-k2" inside
-// "use moonshotai/kimi-k2" — is the precise form and wins, so resolution is
-// exact rather than re-parsed out of a sentence. Anywhere else the value is an
-// internal code and the label is the answer a person would have typed.
-func compilerAnswerFromOption(option store.QuestionOption) string {
- label := strings.TrimSpace(option.Label)
- value := strings.TrimSpace(option.Value)
- if value != "" && (label == "" || strings.Contains(strings.ToLower(label), strings.ToLower(value))) {
- return value
- }
- return label
-}
-
-func (h *Head) continueAgentCompilerQuestion(user store.Message, question store.AgentQuestion, answer string) error {
- source, found, err := h.store.CommandBySeq(question.OriginCommandSeq)
- if err != nil {
- return err
- }
- if !found || source.Kind != store.CommandSplice || strings.TrimSpace(answer) == "" {
- return h.postAgent(user.SessionID, "Tell me which option you want, or answer in your own words.", 0)
- }
- instruction := SpliceCompilerAnswer(source.Instruction, answer)
- command, err := h.store.RequestCommand(store.Command{
- SessionID: user.SessionID, Kind: store.CommandSplice, Instruction: instruction,
- // The answer joins the ASK, which is what it is an answer to. The
- // conversation the original ask came out of travels beside it, still in
- // its own field: a continuation that dropped it would plan the second
- // half of the job with less than the first half had.
- Context: source.Context,
- // The answer continues the original ask, and what the user attached to
- // it is part of that ask. Dropping the files here is how a question
- // about a PDF turns into a job that never sees it.
- Attachments: append([]string(nil), source.Attachments...),
- })
- if err != nil {
- return err
- }
- return h.postAgent(user.SessionID, "Got it — proceeding with that choice.", command.Seq)
-}
-
-func (h *Head) answerPendingQuestion(ctx context.Context, user store.Message) (bool, error) {
- question, pending, err := h.store.PendingQuestion(user.SessionID, user.Seq)
- if err != nil || !pending {
- return false, err
- }
- if isAskQuestion(question.Options) {
- // The loop asked it, so the loop settles it. Declining here is what sends
- // the message on with both halves — the question and the choice — in the
- // thread the loop is about to read, which is the only place the answer
- // means anything.
- //
- // A categorized ask carries a durable row as well, and that row is the
- // only thing the meta loop can count: an ask nobody settles teaches the
- // gate nothing, so it would go on asking a question the person has now
- // answered the same way a dozen times. Settle the row here and still hand
- // the message on — recording the answer is a measurement, not a reply.
- return false, h.settleLearnedAsk(question.QuestionSeq, question.Options, user.Body, user.Seq)
- }
- option, selected := selectQuestionOption(user.Body, question.Options)
- if selected {
- if question.QuestionSeq != 0 {
- if handled, err := h.answerCharterFiringQuestion(user, question.QuestionSeq, option); handled {
- return true, err
- }
- }
- return true, h.applyQuestionOption(ctx, user, question, option)
- }
-
- if charterID, ok := charterQuestionID(question.Options); ok {
- if cadence := extractCadence(user.Body); cadence != "" {
- return true, h.requestCharterCommand(user, store.CommandCharterCadence, charterID, cadence)
- }
- // Ratification keeps free text available to the conversational router;
- // only an explicit choice or cadence phrase crosses a durable transition.
- return false, nil
- }
- if question.CommandSeq == 0 {
- return false, nil
- }
- return true, h.continueCompilerQuestion(user, question, strings.TrimSpace(user.Body))
-}
-
-func selectQuestionOption(reply string, options []store.QuestionOption) (store.QuestionOption, bool) {
- normalized := strings.ToLower(strings.Trim(strings.TrimSpace(reply), " .,!?:;\t\n\r"))
- if number, err := strconv.Atoi(normalized); err == nil && number > 0 && number <= len(options) {
- return options[number-1], true
- }
- for _, option := range options {
- if normalized == strings.ToLower(strings.TrimSpace(option.Label)) ||
- (option.Value != "" && normalized == strings.ToLower(strings.TrimSpace(option.Value))) {
- return option, true
- }
- }
- for _, option := range options {
- label := strings.ToLower(strings.TrimSpace(option.Label))
- value := strings.ToLower(strings.TrimSpace(option.Value))
- if affirmativeRailReply(normalized) &&
- (strings.HasPrefix(label, "yes") || strings.Contains(value, ":ratify:") ||
- strings.Contains(value, ":fire:") || strings.HasPrefix(value, craftContinueOption)) {
- return option, true
- }
- if negativeReply(normalized) &&
- (strings.Contains(label, "not standing") || strings.Contains(value, ":once:") ||
- strings.Contains(value, ":decline:") || strings.HasPrefix(value, craftStopOption) ||
- strings.HasPrefix(label, "only while") || value == "standing-watch:decline" ||
- strings.HasPrefix(label, "keep ") || strings.Contains(value, "surgery:keep:")) {
- return option, true
- }
- }
- return store.QuestionOption{}, false
-}
-
-func negativeReply(reply string) bool {
- switch reply {
- case "n", "no", "no thanks", "decline", "never", "not standing", "once", "just once":
- return true
- default:
- return false
- }
-}
-
-func standingWatchQuestion(options []store.QuestionOption) bool {
- for _, option := range options {
- if strings.HasPrefix(strings.TrimSpace(option.Value), "standing-watch:") {
- return true
- }
- }
- return false
-}
-
-func charterQuestionID(options []store.QuestionOption) (string, bool) {
- for _, option := range options {
- parts := strings.Split(option.Value, ":")
- if len(parts) >= 3 && parts[0] == "charter" && strings.TrimSpace(parts[2]) != "" {
- return parts[2], true
- }
- }
- return "", false
-}
-
-func (h *Head) applyQuestionOption(ctx context.Context, user store.Message, question store.Message, option store.QuestionOption) error {
- if handled, err := h.applyServiceOption(user, option); handled {
- return err
- }
- if handled, err := h.applyRedirectOption(ctx, user, option); handled {
- return err
- }
- if action, kind, target, instruction, ok := decodeSurgeryOption(option.Value); ok {
- if handled, err := h.applyClassOption(user, action, kind, target, instruction); handled {
- return err
- }
- switch action {
- case "select":
- return h.resolveSurgery(user, kind, target, instruction, false)
- case "apply":
- return h.resolveSurgery(user, kind, target, instruction, true)
- case "keep":
- return h.postAgent(user.SessionID, "Keeping it as-is.", 0)
- }
- }
- parts := strings.Split(option.Value, ":")
- if len(parts) == 2 && parts[0] == "standing-watch" {
- switch parts[1] {
- case "enable":
- return h.requestStandingWatchCommand(user, store.CommandStandingWatchEnable, option.Label)
- case "decline":
- return h.requestStandingWatchCommand(user, store.CommandStandingWatchDecline, option.Label)
- }
- }
- if len(parts) >= 3 && parts[0] == "charter" {
- id := parts[2]
- switch parts[1] {
- case "ratify":
- return h.requestCharterCommand(user, store.CommandCharterRatify, id, option.Label)
- case "pause":
- return h.requestCharterCommand(user, store.CommandCharterPause, id, option.Label)
- case "retire":
- return h.requestCharterCommand(user, store.CommandCharterRetire, id, option.Label)
- case "once":
- return h.requestCharterCommand(user, store.CommandCharterOnce, id, option.Label)
- case "cadence":
- if len(parts) > 3 {
- return h.requestCharterCommand(user, store.CommandCharterCadence, id,
- strings.Join(parts[3:], ":"))
- }
- return h.askForCadence(user.SessionID, id)
- case "wording":
- if len(parts) > 3 {
- return h.requestCharterCommand(user, store.CommandCharterWording, id,
- strings.Join(parts[3:], ":"))
- }
- case "fire":
- if len(parts) < 4 {
- return h.postAgent(user.SessionID, "That firing approval is stale.", 0)
- }
- return h.requestCharterCommand(user, store.CommandCharterFire, id, "wake:"+parts[3])
- case "decline":
- if len(parts) < 4 {
- return h.postAgent(user.SessionID, "That firing proposal is stale.", 0)
- }
- return h.requestCharterCommand(user, store.CommandCharterDecline, id, "wake:"+parts[3])
- case "always":
- if len(parts) < 4 {
- return h.postAgent(user.SessionID, "That firing approval is stale.", 0)
- }
- return h.requestCharterCommand(user, store.CommandCharterAlways, id, "wake:"+parts[3])
- case "never":
- if len(parts) < 4 {
- return h.postAgent(user.SessionID, "That firing approval is stale.", 0)
- }
- return h.requestCharterCommand(user, store.CommandCharterNever, id, "wake:"+parts[3])
- case "probation":
- return h.requestCharterCommand(user, store.CommandCharterProbation, id, "back to asking")
- }
- }
- return h.continueCompilerQuestion(user, question, compilerAnswerFromOption(option))
-}
-
-func (h *Head) requestStandingWatchCommand(user store.Message, kind store.CommandKind, instruction string) error {
- _, err := h.store.RequestCommand(store.Command{
- SessionID: user.SessionID, Kind: kind, Instruction: instruction,
- })
- return err
-}
-
-func (h *Head) continueCompilerQuestion(user store.Message, question store.Message, answer string) error {
- source, found, err := h.store.CommandBySeq(question.CommandSeq)
- if err != nil {
- return err
- }
- if !found || source.Kind != store.CommandSplice || strings.TrimSpace(answer) == "" {
- return h.postAgent(user.SessionID, "Tell me which option you want, or answer in your own words.", 0)
- }
- instruction := SpliceCompilerAnswer(source.Instruction, answer)
- command, err := h.store.RequestCommand(store.Command{
- SessionID: user.SessionID, Kind: store.CommandSplice, Instruction: instruction,
- // Same law on the conversational path: the continuation is the same ask
- // carrying the same conversation and the same files.
- Context: source.Context,
- Attachments: append([]string(nil), source.Attachments...),
- })
- if err != nil {
- return err
- }
- return h.postAgent(user.SessionID, "Got it — proceeding with that choice.", command.Seq)
-}
-
-func (h *Head) askForCadence(sessionID, charterID string) error {
- return h.postQuestion(sessionID, "When should I do it?", 0, []store.QuestionOption{
- {Label: "every hour", Value: "charter:cadence:" + charterID + ":hourly"},
- {Label: "every day", Value: "charter:cadence:" + charterID + ":daily"},
- {Label: "every week", Value: "charter:cadence:" + charterID + ":weekly"},
- })
-}
-
-func (h *Head) requestCharterCommand(user store.Message, kind store.CommandKind, id, instruction string) error {
- command, err := h.store.RequestCommand(store.Command{
- SessionID: user.SessionID, Kind: kind, Target: id, Instruction: instruction,
- })
- if err != nil {
- return err
- }
- return h.acknowledgeCharterCommand(user, command)
-}
-
-func (h *Head) answerCharterFiringQuestion(user store.Message, questionSeq int64, option store.QuestionOption) (bool, error) {
- kind, id, instruction, ok := charterFiringCommand(option)
- if !ok {
- return false, nil
- }
- resolution := strings.TrimSpace(option.Label)
- if resolution == "" {
- resolution = strings.TrimSpace(option.Value)
- }
- command, requested, err := h.store.ResolveQuestionWithCommand(questionSeq, resolution, user.Seq, store.Command{
- SessionID: user.SessionID, Kind: kind, Target: id, Instruction: instruction,
- })
- if err != nil || !requested {
- return true, err
- }
- return true, h.acknowledgeCharterCommand(user, command)
-}
-
-// answerCraftBudgetQuestion settles a craft run's money stop. The run itself
-// applies the decision — it is the only thing that knows what it has spent and
-// what it still has to do — so the whole job here is to write the choice down
-// in a form the run can read without guessing: the option's own value becomes
-// the question's durable resolution, and the run polls it exactly as a waiting
-// leaf polls for service consent. Without this branch the answer fell through
-// to "Got it — I'll use that", which acknowledged a decision nothing acted on.
-func (h *Head) answerCraftBudgetQuestion(user store.Message, questionSeq int64, option store.QuestionOption) (bool, error) {
- value, keepGoing, ok := decodeCraftBudgetOption(option.Value)
- if !ok {
- return false, nil
- }
- if err := h.store.ResolveQuestion(questionSeq, store.QuestionAnswered, value, user.Seq); err != nil {
- return true, err
- }
- reply := "Okay — it'll deliver what already landed."
- if keepGoing {
- reply = "Keeping it going."
- }
- return true, h.postAgent(user.SessionID, reply, 0)
-}
-
-// decodeCraftBudgetOption reads one craft money answer, fail-closed like every
-// other option decoder here: the run's own id namespace has to be there, or
-// this is not a craft consent and must not be treated as one.
-func decodeCraftBudgetOption(value string) (resolution string, keepGoing bool, ok bool) {
- value = strings.TrimSpace(value)
- var prefix string
- switch {
- case strings.HasPrefix(value, craftContinueOption):
- prefix, keepGoing = strings.TrimPrefix(value, craftContinueOption), true
- case strings.HasPrefix(value, craftStopOption):
- prefix, keepGoing = strings.TrimPrefix(value, craftStopOption), false
- default:
- return "", false, false
- }
- if strings.TrimSpace(prefix) == "" {
- return "", false, false
- }
- return value, keepGoing, true
-}
-
-func charterFiringCommand(option store.QuestionOption) (store.CommandKind, string, string, bool) {
- parts := strings.Split(option.Value, ":")
- if len(parts) != 4 || parts[0] != "charter" || strings.TrimSpace(parts[2]) == "" || strings.TrimSpace(parts[3]) == "" {
- return "", "", "", false
- }
- var kind store.CommandKind
- switch parts[1] {
- case "fire":
- kind = store.CommandCharterFire
- case "decline":
- kind = store.CommandCharterDecline
- case "always":
- kind = store.CommandCharterAlways
- case "never":
- kind = store.CommandCharterNever
- default:
- return "", "", "", false
- }
- return kind, parts[2], "wake:" + parts[3], true
-}
-
-func (h *Head) acknowledgeCharterCommand(user store.Message, command store.Command) error {
- reply := "Updating that standing rule."
- switch command.Kind {
- case store.CommandCharterRatify:
- reply = "Standing it up."
- case store.CommandCharterPause:
- reply = "Pausing that rule."
- case store.CommandCharterRetire:
- reply = "Retiring that rule."
- case store.CommandCharterOnce:
- reply = "Keeping it one-time."
- case store.CommandCharterCadence:
- reply = "Changing when that runs."
- case store.CommandCharterWording:
- reply = "Changing what it says."
- case store.CommandCharterFire:
- reply = "Approved for this time."
- case store.CommandCharterDecline:
- reply = "Okay — I won’t do this firing. I’ll ask again next time."
- case store.CommandCharterAlways:
- reply = "I’ll take this one and handle future firings on my own."
- case store.CommandCharterNever:
- reply = "I won’t do that, and I’m pausing that rule."
- case store.CommandCharterProbation:
- reply = "I’ll ask before firing again."
- }
- return h.postAgent(user.SessionID, reply, command.Seq)
-}
-
-// charterIntent is one recognized instruction about a standing rule: which
-// durable transition it asks for, how the user pointed at the rule, and the new
-// words — a rhythm or a message — the transition carries.
-type charterIntent struct {
- Kind store.CommandKind
- Reference string
- Cadence string
- Wording string
-}
-
-// charterAskbackCap bounds the rules one askback offers. A question with more
-// rows than this is a list to be searched rather than a choice to be made, and
-// the freshest handful is what a sentence with no description can plausibly
-// mean.
-const charterAskbackCap = 4
-
-// charterReferenceWindow is how far back a rule stays a plausible referent for
-// an utterance that describes nothing — "change it to Tuesday". A rule nobody
-// has touched or run in a month is not what "it" means.
-const charterReferenceWindow = 30 * 24 * time.Hour
-
-// charterCandidates resolves what the user pointed at. A description is matched
-// against the rules themselves; a sentence that describes nothing — "change it
-// to Tuesday", the one the audit found unanswerable — falls back to the rules
-// recently enough alive to be what "it" means, newest first.
-//
-// The one thing it must not do is guess between equals. Two plausible rules is
-// one plain question, which is the same answer the design filter gives
-// everywhere else ambiguity shows up.
-func (h *Head) charterCandidates(reference string) ([]store.Charter, error) {
- matches, err := h.store.SearchActiveCharters(reference)
- if err != nil {
- return nil, err
- }
- if strings.TrimSpace(reference) == "" && len(matches) > 1 {
- matches = freshCharters(matches, time.Now())
- }
- if len(matches) > charterAskbackCap {
- matches = matches[:charterAskbackCap]
- }
- return matches, nil
-}
-
-// freshCharters keeps the rules that have been created, woken, or checked
-// inside the reference window, newest first. If nothing is fresh the whole set
-// comes back rather than nothing: an old rule is still a rule, and the question
-// that follows is the same one.
-func freshCharters(charters []store.Charter, now time.Time) []store.Charter {
- fresh := make([]store.Charter, 0, len(charters))
- for _, charter := range charters {
- if now.Sub(charterFreshness(charter)) <= charterReferenceWindow {
- fresh = append(fresh, charter)
- }
- }
- if len(fresh) == 0 {
- return charters
- }
- sort.SliceStable(fresh, func(i, j int) bool {
- return charterFreshness(fresh[i]).After(charterFreshness(fresh[j]))
- })
- return fresh
-}
-
-func charterFreshness(charter store.Charter) time.Time {
- latest := charter.CreatedAt
- for _, at := range []time.Time{charter.LastWake, charter.LastChecked} {
- if at.After(latest) {
- latest = at
- }
- }
- return latest
-}
-
-// charterWordingCue is how a person says "keep the rule, change what it says".
-// The words after it are the new message, verbatim.
-const charterWordingCue = " to say "
-
-// charterVerbs open an edit to an existing rule. They are the verbs people
-// actually reach for when they move a reminder — "push the reminder to 8pm",
-// "move it to tuesday" — and before they were here those sentences were claimed
-// by node surgery, resolved to nothing, and answered with a list of jobs.
-var charterVerbs = []string{"make ", "change ", "set ", "move ", "push ",
- "switch ", "shift ", "reword ", "rephrase "}
-
-func charterManagement(message string) (charterIntent, bool) {
- trimmed := strings.TrimSpace(message)
- lower := strings.ToLower(trimmed)
- intent := charterIntent{}
- switch {
- case strings.Contains(lower, "back to asking"):
- intent.Kind = store.CommandCharterProbation
- case strings.Contains(lower, "stop watching") || strings.Contains(lower, "stop monitoring") ||
- strings.HasPrefix(lower, "retire "):
- intent.Kind = store.CommandCharterRetire
- case strings.HasPrefix(lower, "pause ") || strings.Contains(lower, " pause the "):
- intent.Kind = store.CommandCharterPause
- case charterVerbPresent(lower):
- // Wording is read before rhythm: "change the sunday reminder to say
- // water the plants" names a day and is not about the day.
- if cue := strings.Index(lower, charterWordingCue); cue >= 0 {
- intent.Wording = strings.TrimSpace(trimmed[cue+len(charterWordingCue):])
- if intent.Wording != "" {
- intent.Kind = store.CommandCharterWording
- intent.Reference = charterReference(lower[:cue], "")
- return intent, true
- }
- }
- if intent.Cadence = extractCadence(message); intent.Cadence != "" {
- intent.Kind = store.CommandCharterCadence
- }
- }
- if intent.Kind == "" {
- return charterIntent{}, false
- }
- intent.Reference = charterReference(lower, intent.Cadence)
- return intent, true
-}
-
-func charterVerbPresent(lower string) bool {
- for _, verb := range charterVerbs {
- if strings.HasPrefix(lower, verb) {
- return true
- }
- }
- return false
-}
-
-func charterReference(message, cadence string) string {
- if cadence != "" {
- message = strings.ReplaceAll(message, strings.ToLower(cadence), " ")
- }
- stop := map[string]bool{
- "please": true, "stop": true, "watching": true, "watch": true,
- "monitoring": true, "monitor": true, "retire": true, "pause": true,
- "make": true, "change": true, "set": true, "cadence": true,
- "move": true, "push": true, "switch": true, "shift": true,
- "reword": true, "rephrase": true, "my": true, "that": true,
- "back": true, "asking": true, "the": true, "it": true, "to": true,
- }
- var kept []string
- for _, word := range strings.FieldsFunc(message, func(r rune) bool {
- return !unicode.IsLetter(r) && !unicode.IsNumber(r)
- }) {
- if !stop[word] {
- kept = append(kept, word)
- }
- }
- return strings.Join(kept, " ")
-}
-
-func charterOptionAction(kind store.CommandKind) string {
- switch kind {
- case store.CommandCharterPause:
- return "pause"
- case store.CommandCharterRetire:
- return "retire"
- case store.CommandCharterProbation:
- return "probation"
- case store.CommandCharterWording:
- return "wording"
- default:
- return "cadence"
- }
-}
diff --git a/internal/head/compiler.go b/internal/head/compiler.go
index d86958a1a3..c9d6c68e34 100644
--- a/internal/head/compiler.go
+++ b/internal/head/compiler.go
@@ -196,10 +196,9 @@ type Brief struct {
// brief deliberately compares. Zero means no experiment was shaped.
TrialOf int64 `json:"trial_of"`
- // QuestionOptions is the generic selectable askback surface. Charter is set
- // only by the temporal compiler; both omit cleanly for ordinary work.
+ // QuestionOptions is the generic selectable askback surface; it omits
+ // cleanly for ordinary work.
QuestionOptions []store.QuestionOption `json:"question_options,omitempty"`
- Charter *store.CharterSpec `json:"charter,omitempty"`
// ServiceIntent is deterministic consent provenance; the provider never
// gets to infer whether a process may outlive its leaf.
ServiceIntent bool `json:"service_intent,omitempty"`
@@ -236,16 +235,15 @@ func NewCompiler(client Client) *Compiler {
// surface that runs exactly one errand and then exits — `codeaf do`.
//
// This is the surface stating a fact about itself, not an opinion about the
-// work. "Once, not standing" is an option on the ratification card because a
-// person may want it; a person who typed `codeaf do ""` has already
-// chosen it, in the verb, before the compiler read a word. Asking them again
-// is asking a question into a process with nobody at the keyboard, and the
-// live defect it caused was total: "flag every discrepancy" tripped the
-// temporal recognizer's `every ` cue, a plain reconciliation of two CSVs
-// was drafted as a standing rule with an invented two-minute cadence, and the
-// run exited in three seconds having done none of the work it was sent to do.
+// work: a person who typed `codeaf do ""` has chosen one run, in the
+// verb, before the compiler read a word, and there is nobody at the keyboard to
+// ask anything of. The live defect that taught it was total: "flag every
+// discrepancy" read as recurring, a plain reconciliation of two CSVs was drafted
+// as a standing rule with an invented two-minute cadence, and the run exited in
+// three seconds having done none of the work it was sent to do.
//
-// So the temporal route is not taken here at all, and the ordinary compiler is
+// The deterministic temporal route that drafted those rules is gone with the
+// v1 scheduler, so what is left is the reasoning half: the ordinary compiler is
// told what surface it is compiling for. Nothing about the judgement of the
// WORK changes; the classification that changes is the one the surface already
// answered.
@@ -254,10 +252,9 @@ func (c *Compiler) WithOneShotErrands() *Compiler {
return c
}
-// oneShotErrandBrief is that fact, in the prompt, for the reasoning half of
-// the rail. The deterministic route above is gated structurally; this is the
-// same law said to the model, which would otherwise be free to draft a
-// standing rule out of an ask that merely sounds recurrent.
+// oneShotErrandBrief is that fact, in the prompt: the law said to the model,
+// which would otherwise be free to read an ask that merely sounds recurrent as
+// something other than one piece of work to do now.
const oneShotErrandBrief = "\n\nSurface: this instruction arrived as a single headless errand — one run, " +
"start to finish, with nobody at a keyboard. It is never a standing rule, a schedule, a watch or a " +
"recurring routine, however recurrent its wording sounds; compile it as work to be done once, now. " +
@@ -278,9 +275,6 @@ func (c *Compiler) Compile(ctx context.Context, instruction string, graphContext
if c == nil || c.client == nil {
return Brief{}, errors.New("compile intent: nil client")
}
- if !c.oneShot && RecognizesStandingIntent(instruction) {
- return c.compileStanding(ctx, instruction, graphContext)
- }
serviceIntent := RecognizesServiceIntent(instruction)
words, wanted := RecognizeModelWords(instruction)
var choice WorkModelChoice
@@ -368,6 +362,27 @@ func (c *Compiler) Compile(ctx context.Context, instruction string, graphContext
return brief, nil
}
+// normalizeQuestionOptions keeps the options an askback can actually offer: a
+// label is what a person picks, so an option without one is dropped, and a
+// question left with none offers free text alone.
+func normalizeQuestionOptions(options []store.QuestionOption) []store.QuestionOption {
+ if len(options) == 0 {
+ return nil
+ }
+ normalized := make([]store.QuestionOption, 0, len(options))
+ for _, option := range options {
+ option.Label = strings.TrimSpace(option.Label)
+ option.Value = strings.TrimSpace(option.Value)
+ if option.Label != "" {
+ normalized = append(normalized, option)
+ }
+ }
+ if len(normalized) == 0 {
+ return nil
+ }
+ return normalized
+}
+
// surfaceBrief is what the surface knows about itself and the model cannot
// guess. Empty for a chat window, which is every other caller: a conversation
// has a mouth and may be asked anything.
diff --git a/internal/head/floor_test.go b/internal/head/floor_test.go
index d21a62a305..3c67f71fbb 100644
--- a/internal/head/floor_test.go
+++ b/internal/head/floor_test.go
@@ -130,27 +130,6 @@ func TestNothingAboveTheFloorIsEverSilentlySteered(t *testing.T) {
}
}
-// TestReminderActionKeepsTheWholeMessage is the logic report's L7 sibling. The
-// action split on the LAST " to ", so "remind me to submit the report to
-// finance" promised to say "finance" — the reminder destroyed by the parse
-// meant to extract it. The message begins after the cue and runs to the end,
-// including every "to" inside it.
-func TestReminderActionKeepsTheWholeMessage(t *testing.T) {
- for _, test := range []struct{ instruction, want string }{
- {"remind me to submit the report to finance", "Say: submit the report to finance"},
- {"remind me tomorrow to call Mom", "Say: call Mom"},
- {"notify me at 9 to send the invoice to accounting and to legal",
- "Say: send the invoice to accounting and to legal"},
- {"alert me to move the deploy to Friday", "Say: move the deploy to Friday"},
- // No " to " after the cue at all: the whole sentence is the reminder.
- {"remind me about standup", "Say this reminder: remind me about standup"},
- } {
- if got := reminderAction(test.instruction); got != test.want {
- t.Errorf("reminderAction(%q) = %q, want %q", test.instruction, got, test.want)
- }
- }
-}
-
// TestRailConsentCoversTheStepTheQuestionQuoted is L19's head half. The posted
// question names an item the journal has not seen — "and the next step costs
// $15.00" — and promises "I'll continue"; consent then recomputed the raise from
diff --git a/internal/head/head.go b/internal/head/head.go
index 6f4b93cb18..42e2d55801 100644
--- a/internal/head/head.go
+++ b/internal/head/head.go
@@ -60,7 +60,7 @@ const (
// noSuchTargetReply is the same honesty when the description was clear and
// matched nothing. There is no third option here: either it acts, or it
// asks, or it says this.
- noSuchTargetReply = "I don't see any work or standing rule like that."
+ noSuchTargetReply = "I don't see any work like that."
// manualRouteSections is the router's grounding read. It is smaller than
// the belt's because the router carries the snapshot, the notebook and the
// thread in the same prompt, and the manual must not crowd them out.
@@ -93,7 +93,6 @@ type Head struct {
store *store.Store
knowledge func() string
competence func() string
- standingWatch func() string
dailyBudgetUSD float64
modalities interface {
Supports(string, string, string) bool
@@ -236,13 +235,6 @@ func (h *Head) WithCompetenceMap(competence func() string) *Head {
return h
}
-// WithStandingWatch registers the same calm status block used by doctor. It
-// is read only for presence-shaped questions.
-func (h *Head) WithStandingWatch(status func() string) *Head {
- h.standingWatch = status
- return h
-}
-
// WithDailyBudgetUSD lets the head render policy state and consume a pending
// rail question deterministically. Zero is unlimited.
func (h *Head) WithDailyBudgetUSD(amount float64) *Head {
diff --git a/internal/head/head_test.go b/internal/head/head_test.go
index 0c3c5c9b11..e9048250be 100644
--- a/internal/head/head_test.go
+++ b/internal/head/head_test.go
@@ -14,7 +14,6 @@ import (
"github.com/Agent-Field/agentfield/sdk/go/ai"
"github.com/Agent-Field/codeaf/internal/resident"
- "github.com/Agent-Field/codeaf/internal/standing"
"github.com/Agent-Field/codeaf/internal/store"
)
@@ -880,103 +879,25 @@ func TestAffirmativeRailReplyVocabulary(t *testing.T) {
}
}
-func TestStandingRecognitionTable(t *testing.T) {
- tests := []struct {
- instruction string
- standing bool
- }{
- {"Whenever a PR opens, review it.", true},
- {"Every morning send me a digest.", true},
- {"Each time the build fails, summarize it.", true},
- {"Keep the suite green.", true},
- {"Watch this folder for new PDFs.", true},
- {"Remind me when the deployment finishes.", true},
- {"Remind me in 20 minutes to stretch.", true},
- {"Make sure the release branch stays green.", true},
- {"Summarize this file.", false},
- {"Build the widget and keep the API name.", false},
- {"When I say go, do X once.", false},
- {"Review every file in this directory once.", false},
- }
- for _, test := range tests {
- t.Run(test.instruction, func(t *testing.T) {
- if got := RecognizesStandingIntent(test.instruction); got != test.standing {
- t.Fatalf("standing = %t, want %t", got, test.standing)
- }
- })
+// The temporal route that drafted a standing rule out of a recurring-sounding
+// ask went with the v1 scheduler. Such an ask is compiled like any other — by
+// the one compile call, into ordinary work that carries its sentence verbatim —
+// and nothing is held back for a ratification card that no longer exists.
+func TestARecurringSoundingAskCompilesAsOrdinaryWork(t *testing.T) {
+ const ask = "remind me every monday at 9 to water the plants"
+ client := &fakeClient{responses: []string{`{"goal":"Water the plants."}`}}
+ brief, err := NewCompiler(client).Compile(context.Background(), ask, "")
+ if err != nil {
+ t.Fatal(err)
}
-}
-
-func TestCompilerBuildsStandingCharterDraftFields(t *testing.T) {
- tests := []struct {
- name string
- instruction string
- context string
- wantKind store.WatchKind
- wantCadence string
- wantCron func(t *testing.T, schedule store.CronSchedule)
- wantExpiry string
- wantMax int
- wantCost float64
- }{
- {
- name: "measured recurring invariant", instruction: "Every morning review new PRs.",
- context: "reflex: avg cost=$0.07", wantKind: store.WatchCron,
- wantCadence: "Every morning",
- wantCron: func(t *testing.T, schedule store.CronSchedule) {
- if schedule.Kind != store.CronDaily || schedule.Hour != 9 || schedule.Minute != 0 {
- t.Fatalf("morning cadence compiled to %+v, want daily 09:00", schedule)
- }
- },
- wantExpiry: "never", wantMax: 10, wantCost: 0.07,
- },
- {
- name: "reminder degenerate charter", instruction: "Remind me tomorrow at 9 to call Mom.",
- wantKind: store.WatchCron, wantCadence: "tomorrow at 9",
- wantCron: func(t *testing.T, schedule store.CronSchedule) {
- tomorrow := time.Now().AddDate(0, 0, 1)
- if schedule.Kind != store.CronAt || schedule.At.Hour() != 9 ||
- schedule.At.Minute() != 0 || schedule.At.Day() != tomorrow.Day() {
- t.Fatalf("reminder cadence compiled to %+v, want at tomorrow 09:00", schedule)
- }
- },
- wantExpiry: "once", wantMax: 1, wantCost: standing.DefaultPerRunUSD,
- },
- }
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- client := &fakeClient{responses: []string{
- `{"invariant":"model rewrite","watch":{},"sentinel":"Has the condition occurred?","action":"Carry out the requested action.","rails":{}}`,
- }}
- brief, err := NewCompiler(client).Compile(context.Background(), test.instruction, test.context)
- if err != nil {
- t.Fatal(err)
- }
- if brief.Charter == nil || brief.Question == "" || len(brief.QuestionOptions) != 3 {
- t.Fatalf("standing brief = %+v", brief)
- }
- charter := brief.Charter
- if charter.Invariant != test.instruction || charter.Watch.Kind != test.wantKind ||
- charter.Watch.Cadence != test.wantCadence {
- t.Fatalf("charter watch/invariant = %+v", charter)
- }
- if charter.Watch.Spec.Kind != store.WatchCron || charter.Watch.Spec.Cron == nil {
- t.Fatalf("cadence words did not compile to a typed cron spec: %+v", charter.Watch.Spec)
- }
- test.wantCron(t, *charter.Watch.Spec.Cron)
- if charter.Rails.Expiry != test.wantExpiry || charter.Rails.MaxPerDay != test.wantMax ||
- charter.Rails.EstimatedCostUSD != test.wantCost ||
- strings.TrimSpace(charter.Rails.MaxPerDayJustification) == "" {
- t.Fatalf("charter rails = %+v", charter.Rails)
- }
- if charter.Sentinel == "" || charter.Action == "" ||
- (test.name == "reminder degenerate charter" && charter.Action != "Say: call Mom.") {
- t.Fatalf("charter judgment/action missing: %+v", charter)
- }
- if reminder := test.name == "reminder degenerate charter"; charter.SayOnly != reminder {
- t.Fatalf("say-only = %t, want %t", charter.SayOnly, reminder)
- }
- })
+ if strings.TrimSpace(brief.Question) != "" || len(brief.QuestionOptions) != 0 {
+ t.Fatalf("a recurring-sounding ask was asked about: %+v", brief)
+ }
+ if !strings.Contains(brief.Goal, ask) {
+ t.Fatalf("goal = %q, want the ask carried verbatim", brief.Goal)
+ }
+ if client.calls != 1 {
+ t.Fatalf("the ask took %d model calls, want the one compile", client.calls)
}
}
@@ -1031,97 +952,6 @@ func TestCompilerQuestionOptionsPreserveOrder(t *testing.T) {
}
}
-func TestRatificationOptionRoundTrip(t *testing.T) {
- for _, test := range []struct {
- name string
- reply string
- wantStatus store.CharterStatus
- wantOnce bool
- }{
- {name: "affirmative activates", reply: "yes", wantStatus: store.CharterActive},
- {name: "numeric activates", reply: "1", wantStatus: store.CharterActive},
- {name: "decline stays disarmed", reply: "3", wantStatus: store.CharterRetired, wantOnce: true},
- } {
- t.Run(test.name, func(t *testing.T) {
- graph := openHeadStore(t)
- client := &fakeClient{responses: []string{
- `{"watch":{},"sentinel":"Is there a new PR?","action":"Review the new PR.","rails":{}}`,
- }}
- compiler := NewCompiler(client)
- compile := func(ctx context.Context, instruction, graphContext string) (resident.Compiled, error) {
- brief, err := compiler.Compile(ctx, instruction, graphContext)
- if err != nil {
- return resident.Compiled{}, err
- }
- return resident.Compiled{
- Goal: brief.Goal, Assumptions: brief.Assumptions, Scale: brief.Scale,
- BuildsOn: brief.BuildsOn, Question: brief.Question,
- QuestionOptions: brief.QuestionOptions, Charter: brief.Charter,
- }, nil
- }
- reconciler := resident.New(graph, compile, nil)
- command, err := graph.RequestCommand(store.Command{
- SessionID: "ratify", Kind: store.CommandSplice,
- Instruction: "Whenever a PR opens, review it.",
- })
- if err != nil {
- t.Fatal(err)
- }
- if err := reconciler.Tick(context.Background()); err != nil {
- t.Fatal(err)
- }
- question := waitForAgentReply(t, graph, "ratify", command.Seq)
- if len(question.Options) != 3 || question.Options[0].Label != "yes, stand this up" {
- t.Fatalf("ratification question = %+v", question)
- }
- user, err := graph.PostMessage(store.Message{
- SessionID: "ratify", Role: store.RoleUser, Body: test.reply,
- })
- if err != nil {
- t.Fatal(err)
- }
- if err := New(&fakeClient{}, graph).answer(context.Background(), user); err != nil {
- t.Fatal(err)
- }
- if err := reconciler.Tick(context.Background()); err != nil {
- t.Fatal(err)
- }
- charters, err := graph.Charters()
- if err != nil || len(charters) != 1 || charters[0].Status != test.wantStatus {
- t.Fatalf("charters = %+v err=%v", charters, err)
- }
- if test.wantStatus == store.CharterActive {
- // The point of the whole seam: a recognized then ratified ask
- // lands in the one canonical table the watch engine reads.
- due, err := graph.DueCharters(time.Now().Add(time.Minute), 10)
- if err != nil {
- t.Fatal(err)
- }
- visible := false
- for _, charter := range due {
- visible = visible || charter.ID == charters[0].ID
- }
- if !visible {
- t.Fatalf("ratified charter is invisible to the watch engine: due=%+v", due)
- }
- }
- if test.wantOnce {
- nodes, err := graph.Nodes()
- if err != nil {
- t.Fatal(err)
- }
- found := false
- for _, node := range nodes {
- found = found || (node.Parent == store.RootID && node.Provenance.Intent == charters[0].Invariant)
- }
- if !found {
- t.Fatal("declining standing did not splice the requested action once")
- }
- }
- })
- }
-}
-
func TestGenericQuestionNumericSelectionContinuesCompile(t *testing.T) {
graph := openHeadStore(t)
compile := func(context.Context, string, string) (resident.Compiled, error) {
@@ -1162,159 +992,6 @@ func TestGenericQuestionNumericSelectionContinuesCompile(t *testing.T) {
}
}
-// Managing a standing rule was a prefix test that journaled and spoke without
-// anything with judgment seeing the sentence. It is the verb triad now: the
-// same transition table, the same store commands, the same one-line receipts —
-// and the transition is read off the person's own words against a rule id a
-// read handed over, rather than off the words a sentence happened to open with.
-// Withdrawal is stop's; every other edit is change's.
-func TestConversationalCharterManagement(t *testing.T) {
- tests := []struct {
- name string
- message string
- tool string
- wantStatus store.CharterStatus
- wantCadence string
- }{
- {name: "pause", message: "pause the morning digest", tool: beltToolStop,
- wantStatus: store.CharterPaused},
- {name: "retire", message: "stop watching the morning digest", tool: beltToolStop,
- wantStatus: store.CharterRetired},
- {name: "edit cadence", message: "make the morning digest hourly", tool: beltToolChange,
- wantStatus: store.CharterActive, wantCadence: "hourly"},
- }
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- graph := openHeadStore(t)
- charter := activateHeadCharter(t, graph, "digest",
- "Every morning send the release digest.")
- args := map[string]any{"target": charter.ID, "words": test.message}
- if test.tool == beltToolStop {
- args = map[string]any{"targets": []string{charter.ID}, "words": test.message}
- }
- client := &beltClient{turns: []beltTurn{
- {calls: []ai.ToolCall{beltCall("r1", test.tool, args)}},
- {text: "Done — that rule is updated."},
- }}
- user, err := graph.PostMessage(store.Message{
- SessionID: "manage", Role: store.RoleUser, Body: test.message,
- })
- if err != nil {
- t.Fatal(err)
- }
- if err := New(client, graph).answer(context.Background(), user); err != nil {
- t.Fatal(err)
- }
- if err := resident.New(graph, nil, nil).Tick(context.Background()); err != nil {
- t.Fatal(err)
- }
- updated, found, err := graph.Charter(charter.ID)
- if err != nil || !found || updated.Status != test.wantStatus {
- t.Fatalf("updated charter = %+v found=%t err=%v", updated, found, err)
- }
- if test.wantCadence != "" {
- if updated.Watch.Cadence != test.wantCadence {
- t.Fatalf("cadence = %q, want %q", updated.Watch.Cadence, test.wantCadence)
- }
- if updated.Watch.Cron == nil || updated.Watch.Cron.Kind != store.CronEveryHours ||
- updated.Watch.Cron.Interval != 1 {
- t.Fatalf("cadence edit did not produce a typed hourly schedule: %+v", updated.Watch)
- }
- }
- messages, err := graph.Messages("manage", user.Seq, 0)
- if err != nil || len(messages) < 2 || messages[len(messages)-1].Role != store.RoleSystem ||
- strings.Contains(messages[len(messages)-1].Body, "\n") {
- t.Fatalf("management receipts = %+v err=%v", messages, err)
- }
- })
- }
-}
-
-// Two rules the words reach equally is ambiguity, and ambiguity is never picked
-// for the person. The disambiguation used to be a durable question minted by the
-// recognizer itself; the rule tool now hands the candidates back as a tool
-// RESULT — acting on neither — and the ask tool puts the choice to the person as
-// the same durable numbered options. Nothing is journaled either way, which is
-// the property that mattered.
-func TestAmbiguousCharterManagementProducesOptions(t *testing.T) {
- graph := openHeadStore(t)
- activateHeadCharter(t, graph, "frontend-prs", "Whenever frontend PRs open, review them.")
- activateHeadCharter(t, graph, "backend-prs", "Whenever backend PRs open, review them.")
- client := &beltClient{turns: []beltTurn{
- {calls: []ai.ToolCall{beltCall("r1", beltToolStop, map[string]any{
- "words": "stop watching PRs"})}},
- {calls: []ai.ToolCall{beltCall("a1", beltToolAsk, map[string]any{
- "question": "Which rule do you mean?",
- "options": []string{"the frontend PR reviews", "the backend PR reviews"}})}},
- // The ask tool's own result tells the loop the question IS the reply, so
- // the turn that follows it says nothing. See the assertion below: a
- // second voice over a numbered question is the thread answering itself.
- {text: ""},
- }}
- user, err := graph.PostMessage(store.Message{
- SessionID: "ambiguous-charter", Role: store.RoleUser, Body: "stop watching PRs",
- })
- if err != nil {
- t.Fatal(err)
- }
- if err := New(client, graph).answer(context.Background(), user); err != nil {
- t.Fatal(err)
- }
- // The tool that could have acted refused to choose, and said so in a
- // sentence the loop had to read before it could ask anything.
- candidates := ""
- for _, message := range client.seen {
- if message.Role == "tool" && len(message.Content) > 0 && candidates == "" {
- candidates = message.Content[0].Text
- }
- }
- if !strings.Contains(candidates, "more than one thing matches") {
- t.Fatalf("the withdrawal picked for the user instead of handing back candidates: %q", candidates)
- }
-
- reply := waitForAgentReply(t, graph, "ambiguous-charter", user.Seq)
- if !strings.HasPrefix(reply.Body, "Which rule do you mean?") || len(reply.Options) != 2 {
- t.Fatalf("ambiguous reply = %+v", reply)
- }
- // It is a row the person can click, and it says so in types rather than in
- // a JSON blob inside its own prose (13.3 bug 1).
- assertClickableAsk(t, reply, "Which rule do you mean?", "the frontend PR reviews", "the backend PR reviews")
- // The question IS the reply: a second voice over the top of it would be the
- // thread answering its own question.
- if replies := agentRepliesAfter(t, graph, "ambiguous-charter", user.Seq); len(replies) != 1 {
- t.Fatalf("the ask was spoken over: %+v", replies)
- }
- pending, err := graph.PendingCommands(0)
- if err != nil || len(pending) != 0 {
- t.Fatalf("ambiguous management emitted commands: %+v err=%v", pending, err)
- }
-}
-
-func activateHeadCharter(t *testing.T, graph *store.Store, id, invariant string) store.Charter {
- t.Helper()
- charter, err := graph.DraftCharter(id, "manage", 0, store.CharterSpec{
- Invariant: invariant,
- Watch: store.CharterWatch{
- Kind: store.WatchCron, Cadence: "every morning", Schedule: "0 9 * * *",
- },
- Sentinel: "Is a delivery due?", Action: "Send the digest.",
- Rails: store.CharterSpecRails{
- EstimatedCostUSD: 0.05, MaxPerDay: 1,
- MaxPerDayJustification: "one scheduled delivery", Expiry: "never",
- },
- })
- if err != nil {
- t.Fatal(err)
- }
- if err := graph.SetCharterStatus(id, store.CharterActive, store.Ratification{
- Origin: store.OriginUser, SessionID: "manage", Evidence: "yes, stand this up",
- }); err != nil {
- t.Fatal(err)
- }
- charter.Status = store.CharterActive
- return charter
-}
-
// A document is not something the talk model can see. It must stay off the
// wire as a content part and still reach the command, because the whole point
// is that a worker reads it from the workspace with read_document.
diff --git a/internal/head/hints.go b/internal/head/hints.go
index bc7a1e08b7..b07c27bf6d 100644
--- a/internal/head/hints.go
+++ b/internal/head/hints.go
@@ -92,13 +92,6 @@ func (h *Head) renderHints(user store.Message, active []store.SurgeryTarget) str
add("mentions a status word without naming a set; read the board before assuming which work it means")
}
- // Durable intent. The compiler turns a splice carrying this shape into a
- // standing rule plus a ratification card, so the loop needs to know that
- // commissioning it is not the same as commissioning one errand.
- if RecognizesStandingIntent(message) && !selfQuestionPhrased(strings.ToLower(message)) {
- add("reads as DURABLE intent — work spawned from it becomes a standing rule the person is asked to ratify, not a one-off errand")
- }
-
// The five redirect cue classes, including impatience. `cued` also decides
// how much a bare pronoun is worth further down: with a cue in hand "it" is
// doing referential work and the only open question is which job, and
@@ -146,12 +139,6 @@ func (h *Head) renderHints(user store.Message, active []store.SurgeryTarget) str
}
}
- // A standing rule addressed by name or by verb.
- if intent, managing := charterManagement(message); managing {
- add("reads as an edit to a STANDING RULE (%s), described as %q",
- charterOptionAction(intent.Kind), truncateBytes(intent.Reference, hintPhraseBytes))
- }
-
// A service the person is running. recognizesShutdownAll is the one total
// phrasing, and it is kept separate because its blast radius is everything.
if recognizesShutdownAll(message) {
diff --git a/internal/head/issue_test.go b/internal/head/issue_test.go
index 29d0787203..f687299430 100644
--- a/internal/head/issue_test.go
+++ b/internal/head/issue_test.go
@@ -6,7 +6,6 @@ import (
"testing"
"github.com/Agent-Field/agentfield/sdk/go/ai"
- "github.com/Agent-Field/codeaf/internal/resident"
"github.com/Agent-Field/codeaf/internal/store"
)
@@ -16,160 +15,6 @@ import (
// visible unless the journal has been made to agree with it.
const theFalseClaim = "Done — it won't fire anymore."
-// The reproduced J8 failure, five runs out of five: the person says "stand down
-// the stretch reminder", the head emits a cancel with no target, the store
-// refuses a targetless cancel, nothing is journaled, and the reply says it
-// won't fire anymore while the rule stays active. The head made a claim about
-// the world that the store contradicted.
-//
-// What must happen instead is not an error message. The thread named the thing,
-// the store can address it, and the honest outcome is the act: a command row
-// against that rule, a receipt tied to that row, and a status that has moved by
-// the time the reconciler has run. The verb no longer arrives as a router's
-// terminal decision — it is the stop tool, aimed at the rule id a read handed
-// over — and the sentence is allowed on screen for exactly one reason: the row
-// underneath it makes it true.
-func TestStandingDownARuleResolvesTheRuleTheThreadNamed(t *testing.T) {
- graph := openHeadStore(t)
- charter := activateHeadCharter(t, graph, "stretch",
- "Every hour, the stretch reminder fires.")
- user := postUser(t, graph, "j8", "stand down the stretch reminder")
- head, _ := beltHead(graph,
- beltTurn{calls: []ai.ToolCall{beltCall("c1", beltToolStop, map[string]any{
- "targets": []string{charter.ID}, "words": user.Body})}},
- beltTurn{text: theFalseClaim})
- if err := head.answer(context.Background(), user); err != nil {
- t.Fatal(err)
- }
-
- commands, err := graph.PendingCommands(0)
- if err != nil || len(commands) != 1 {
- t.Fatalf("commands = %+v err=%v", commands, err)
- }
- if commands[0].Target != charter.ID || commands[0].Kind != store.CommandCharterRetire {
- t.Fatalf("journaled command = %+v, want a retirement of %q", commands[0], charter.ID)
- }
- reply := waitForAgentReply(t, graph, "j8", user.Seq)
- if reply.CommandSeq != commands[0].Seq {
- t.Fatalf("reply is not the receipt for the journaled row: %+v", reply)
- }
- if reply.Body != theFalseClaim {
- t.Fatalf("reply = %q, want the claim the row underneath it makes true", reply.Body)
- }
-
- if err := resident.New(graph, nil, nil).Tick(context.Background()); err != nil {
- t.Fatal(err)
- }
- settled, found, err := graph.Charter(charter.ID)
- if err != nil || !found || settled.Status != store.CharterRetired {
- t.Fatalf("charter after the reconciler = %+v found=%t err=%v", settled, found, err)
- }
-}
-
-// The same shape with two rules a description could equally mean. One plain
-// question naming both is the right answer; acting on a coin flip and saying it
-// is done is the same lie by another route, so nothing may be journaled here.
-//
-// The question is no longer posted by the recognizer that failed to choose — the
-// tool hands back the rules the words reach and refuses to pick, and the ask
-// tool posts the durable numbered question. Both halves of the old law survive:
-// the candidates are named the way the person would recognise them, and the
-// journal is empty until they answer.
-func TestStandingDownAnAmbiguousRuleAsksInsteadOfClaiming(t *testing.T) {
- graph := openHeadStore(t)
- activateHeadCharter(t, graph, "stretch", "Every hour, the stretch reminder fires.")
- activateHeadCharter(t, graph, "water", "Every evening, the water reminder fires.")
- user := postUser(t, graph, "j8-ambiguous", "stand down the reminder")
- head, _ := beltHead(graph,
- beltTurn{calls: []ai.ToolCall{beltCall("c1", beltToolStop, map[string]any{
- "words": user.Body})}},
- beltTurn{calls: []ai.ToolCall{beltCall("c2", beltToolAsk, map[string]any{
- "question": "Which rule do you mean?",
- "options": []string{"Every hour, the stretch reminder fires.",
- "Every evening, the water reminder fires."}})}})
- if err := head.answer(context.Background(), user); err != nil {
- t.Fatal(err)
- }
-
- reply := waitForAgentReply(t, graph, "j8-ambiguous", user.Seq)
- if !strings.HasPrefix(reply.Body, "Which rule do you mean?") || len(reply.Options) != 2 {
- t.Fatalf("ambiguous reply = %+v", reply)
- }
- assertNothingClaimed(t, graph, "j8-ambiguous", user.Seq)
- commands, err := graph.PendingCommands(0)
- if err != nil || len(commands) != 0 {
- t.Fatalf("an ambiguous stand-down journaled work: %+v err=%v", commands, err)
- }
-
- // And the refusal the loop was handed says why, with both rules in the words
- // they were ratified in — the model cannot ask well about rules it was never
- // shown.
- run := &beltRun{head: New(nil, graph), user: user}
- candidates, failed := run.execute(beltToolStop, beltArguments(t, map[string]any{
- "words": user.Body}))
- if failed {
- t.Fatalf("a targetless withdrawal errored instead of offering candidates: %s", candidates)
- }
- if !strings.Contains(candidates, "more than one thing matches") ||
- !strings.Contains(candidates, "stretch reminder") || !strings.Contains(candidates, "water reminder") {
- t.Fatalf("the candidate list is not both rules by name:\n%s", candidates)
- }
- if run.acted {
- t.Fatal("offering candidates journaled something")
- }
-}
-
-// A job and a standing rule that both answer to the description. The head
-// genuinely does not know which was meant, and the two are ranked by machinery
-// that shares no scale, so guessing is the one thing it must not do — one
-// description reaches both halves of the world, and both come back.
-func TestATargetlessStopReachesJobsAndRulesAlike(t *testing.T) {
- graph := openHeadStore(t)
- spliceSurgeryJob(t, graph, "stretch-report", "Stretch goals report", "write the stretch goals report")
- activateHeadCharter(t, graph, "stretch", "Every hour, the stretch reminder fires.")
- user := postUser(t, graph, "j8-mixed", "stand down the stretch one")
-
- run := &beltRun{head: New(nil, graph), user: user}
- candidates, failed := run.execute(beltToolStop, beltArguments(t, map[string]any{
- "words": user.Body}))
- if failed {
- t.Fatalf("a targetless stop errored instead of offering candidates: %s", candidates)
- }
- sawJob := strings.Contains(candidates, "stretch-report") &&
- strings.Contains(candidates, "Stretch goals report")
- sawRule := strings.Contains(candidates, "standing rule stretch") &&
- strings.Contains(candidates, "the stretch reminder fires")
- if !sawJob || !sawRule {
- t.Fatalf("the candidate list dropped one half of the world:\n%s", candidates)
- }
- // A rule is not cancelled by the verb aimed at work, and the list says so
- // rather than offering a candidate that would have to be refused.
- if !strings.Contains(candidates, "a standing rule — stopping it retires it") {
- t.Fatalf("the rule half does not say how it is acted on:\n%s", candidates)
- }
- if run.acted {
- t.Fatal("a targetless stop acted on a description")
- }
-
- head, _ := beltHead(graph,
- beltTurn{calls: []ai.ToolCall{beltCall("c1", beltToolStop, map[string]any{
- "words": user.Body})}},
- beltTurn{calls: []ai.ToolCall{beltCall("c2", beltToolAsk, map[string]any{
- "question": "Which one do you mean?",
- "options": []string{"Stretch goals report", "the hourly stretch reminder"}})}})
- if err := head.answer(context.Background(), user); err != nil {
- t.Fatal(err)
- }
- reply := waitForAgentReply(t, graph, "j8-mixed", user.Seq)
- if !strings.HasPrefix(reply.Body, "Which one do you mean?") || len(reply.Options) != 2 {
- t.Fatalf("mixed reply = %+v", reply)
- }
- assertNothingClaimed(t, graph, "j8-mixed", user.Seq)
- if commands, err := graph.PendingCommands(0); err != nil || len(commands) != 0 {
- t.Fatalf("a mixed stand-down journaled work: %+v err=%v", commands, err)
- }
-}
-
// Nothing to point at. The person still gets a sentence, and the sentence says
// what is true: there is no such thing running. The tool is what makes that
// answer available — it reports the honest miss, and only then can the loop say
@@ -181,7 +26,7 @@ func TestStandingDownSomethingThatIsNotRunningSaysSoPlainly(t *testing.T) {
run := &beltRun{head: New(nil, graph), user: user}
missed, failed := run.execute(beltToolStop, beltArguments(t, map[string]any{
"words": user.Body}))
- if failed || !strings.Contains(missed, "nothing on the board and no standing rule matches those words") {
+ if failed || !strings.Contains(missed, "nothing on the board matches those words") {
t.Fatalf("the miss was not reported plainly: %q", missed)
}
if run.acted {
diff --git a/internal/head/lens.go b/internal/head/lens.go
index f1919f23e8..56ca10c327 100644
--- a/internal/head/lens.go
+++ b/internal/head/lens.go
@@ -55,14 +55,14 @@ const (
)
// The kind words recall filters on. They are the person's vocabulary for the
-// six things memory is made of, not the store's names for its tables — a person
-// asking "only the rules" has never heard of a charter.
+// five things memory is made of, not the store's names for its tables — a person
+// asking "only the beliefs" has never heard of a fact row. Standing rules were a
+// sixth, and went with the v1 scheduler.
const (
lensKindMessage = "message"
lensKindJob = "job"
lensKindResult = "result"
lensKindBelief = "belief"
- lensKindRule = "rule"
lensKindService = "service"
)
@@ -138,8 +138,7 @@ type lensHit struct {
// through three separate tools that each knew about some of them, so the answer
// to "what do we know about pricing?" depended on which tool the model happened
// to pick. Here they are one question: the conversation, the notebook, the
-// graph, the standing rules, the services, and the settled rows a territory has
-// packed away. A person asking about the past cannot know which of those holds
+// graph, the services, and the settled rows a territory has packed away. A person asking about the past cannot know which of those holds
// their answer and has never had to.
func (run *beltRun) recall(args map[string]any) (string, bool) {
if run.head == nil || run.head.store == nil {
@@ -151,7 +150,7 @@ func (run *beltRun) recall(args map[string]any) (string, bool) {
}
kind := strings.ToLower(strings.TrimSpace(beltString(args, "kind")))
if kind != "" && !lensKnownKind(kind) {
- return "kind must be one of message, job, result, belief, rule, service", true
+ return "kind must be one of message, job, result, belief, service", true
}
since, ok := parseHistoryBound(beltString(args, "since"), false)
if !ok {
@@ -166,14 +165,14 @@ func (run *beltRun) recall(args map[string]any) (string, bool) {
}
hits := run.head.lensGather(query, kind, strings.TrimSpace(beltString(args, "session")), since, until)
if len(hits) == 0 {
- return "nothing remembered matches those words — not in the conversation, not in the notebook, not in work, not in the standing rules or services. Say that plainly rather than reconstructing it.", false
+ return "nothing remembered matches those words — not in the conversation, not in the notebook, not in work, not in the services. Say that plainly rather than reconstructing it.", false
}
return lensRenderHits(query, kind, hits), false
}
func lensKnownKind(kind string) bool {
switch kind {
- case lensKindMessage, lensKindJob, lensKindResult, lensKindBelief, lensKindRule, lensKindService:
+ case lensKindMessage, lensKindJob, lensKindResult, lensKindBelief, lensKindService:
return true
}
return false
@@ -182,7 +181,7 @@ func lensKnownKind(kind string) bool {
// lensGather asks every surface the kind filter admits and blends what comes
// back. Each surface is asked for lensSourceCap and failures are misses: an
// index that cannot answer must never turn a read into an error, because the
-// other five still have something true to say.
+// others still have something true to say.
func (h *Head) lensGather(query, kind, session string, since, until time.Time) []lensHit {
now := time.Now()
wants := func(candidate string) bool { return kind == "" || kind == candidate }
@@ -197,9 +196,6 @@ func (h *Head) lensGather(query, kind, session string, since, until time.Time) [
if wants(lensKindBelief) {
hits = append(hits, h.lensBeliefHits(query)...)
}
- if wants(lensKindRule) {
- hits = append(hits, h.lensRuleHits(query)...)
- }
if wants(lensKindService) {
hits = append(hits, h.lensServiceHits(query)...)
}
@@ -394,24 +390,6 @@ func (h *Head) lensBeliefHits(query string) []lensHit {
return hits
}
-func (h *Head) lensRuleHits(query string) []lensHit {
- charters, err := h.store.SearchActiveCharters(query)
- if err != nil || len(charters) == 0 {
- return nil
- }
- hits := make([]lensHit, 0, len(charters))
- for index, charter := range charters {
- if index == lensSourceCap {
- break
- }
- hits = append(hits, lensHit{
- kind: lensKindRule, id: charter.ID, at: charter.CreatedAt, rank: index,
- snippet: lensSnippet(charter.Invariant) + " | " + lensWatchPhrase(charter.Watch),
- })
- }
- return hits
-}
-
func (h *Head) lensServiceHits(query string) []lensHit {
services, err := h.store.SearchServices(query)
if err != nil || len(services) == 0 {
@@ -587,9 +565,6 @@ func (h *Head) lensOpen(id, job string, raw bool, part int) (string, error) {
}
return lensPage(id, body, part, h.budget.lensPage), nil
}
- if charter, found, err := h.store.Charter(id); err == nil && found {
- return lensPage(id, lensCharterRecord(charter), part, h.budget.lensPage), nil
- }
if service, found, err := h.store.ServiceByName(id); err == nil && found {
return lensPage(id, lensServiceRecord(service), part, h.budget.lensPage), nil
}
@@ -599,7 +574,7 @@ func (h *Head) lensOpen(id, job string, raw bool, part int) (string, error) {
if rendered, resolved, err := h.lensAnyFile(id, part); resolved {
return rendered, err
}
- return "", fmt.Errorf("nothing here is called %q — ids come from a board or recall read: a job id, a file a job wrote (pass job: as well when two jobs wrote the same name), a standing rule's id, a service's name, or a #number from the notebook. A learned way of working is not reachable from this conversation", id)
+ return "", fmt.Errorf("nothing here is called %q — ids come from a board or recall read: a job id, a file a job wrote (pass job: as well when two jobs wrote the same name), a service's name, or a #number from the notebook. A learned way of working is not reachable from this conversation", id)
}
// lensJob is the state-aware half. The line above both branches is the same,
@@ -1159,59 +1134,6 @@ func (h *Head) lensFact(seq int64) (string, error) {
return strings.TrimSpace(rendered.String()), nil
}
-// lensCharterRecord is a standing rule whole: what it watches for, how often it
-// looks, what it does when it fires, and — the half that is usually the actual
-// question — whether it has been looking at all.
-func lensCharterRecord(charter store.Charter) string {
- var rendered strings.Builder
- fmt.Fprintf(&rendered, "%s | standing rule | %s | %s\n", charter.ID, charter.Status, charter.Autonomy)
- rendered.WriteString("watches for: " + strings.TrimSpace(charter.Invariant) + "\n")
- rendered.WriteString("how often: " + lensWatchPhrase(charter.Watch) + "\n")
- if action := strings.TrimSpace(charter.Action.Template); action != "" {
- word := "does"
- if charter.Action.SayOnly {
- word = "says"
- }
- rendered.WriteString(word + ": " + action + "\n")
- }
- if hint := strings.TrimSpace(charter.SentinelHint); hint != "" {
- rendered.WriteString("what to look at: " + hint + "\n")
- }
- if !charter.CreatedAt.IsZero() {
- rendered.WriteString("agreed: " + charter.CreatedAt.Local().Format(nowLineLayout) + "\n")
- }
- if !charter.LastChecked.IsZero() {
- rendered.WriteString("last looked: " + charter.LastChecked.Local().Format(nowLineLayout) + "\n")
- }
- if line := strings.TrimSpace(charter.LastCheckLine); line != "" {
- rendered.WriteString("and found: " + line + "\n")
- }
- if !charter.LastWake.IsZero() {
- rendered.WriteString("last fired: " + charter.LastWake.Local().Format(nowLineLayout) + "\n")
- }
- if !charter.NextDue.IsZero() {
- rendered.WriteString("next check: " + charter.NextDue.Local().Format(nowLineLayout) + "\n")
- }
- fmt.Fprintf(&rendered, "fired cleanly %d %s, stood down %d %s\n",
- charter.GreenFirings, pluralWord(charter.GreenFirings, "time", "times"),
- charter.Demotions, pluralWord(charter.Demotions, "time", "times"))
- return strings.TrimSpace(rendered.String())
-}
-
-// lensWatchPhrase says a watch's rhythm in words. A guessed cadence is marked
-// as a guess, because a rule that says "about every 2 minutes" in the same
-// voice a person's own "every Monday" is said in is a rule nobody can audit.
-func lensWatchPhrase(watch store.WatchSpec) string {
- phrase := string(watch.Kind)
- if cadence := strings.TrimSpace(watch.Cadence); cadence != "" {
- phrase += " " + cadence
- if watch.CadenceGuessed {
- phrase += " (a guess — nobody said a rhythm)"
- }
- }
- return strings.TrimSpace(phrase)
-}
-
func lensServiceRecord(service store.Service) string {
var rendered strings.Builder
fmt.Fprintf(&rendered, "%s | service | %s\n", service.Name, service.Status)
@@ -1438,7 +1360,6 @@ func (h *Head) lensStatus(sessionID string) string {
// that claims to be the whole system on one page.
rendered.WriteString("OPEN THREADS\n" + h.openThreads(sessionID, now) + "\n\n")
rendered.WriteString("MONEY\n" + h.lensMoney() + "\n\n")
- rendered.WriteString("WATCH\n" + h.lensWatch() + "\n\n")
rendered.WriteString("SERVICES\n" + h.lensServices() + "\n\n")
rendered.WriteString("WAYS OF WORKING\n" + h.lensWays() + "\n\n")
rendered.WriteString("COMPETENCE\n" + h.lensCompetence() + "\n\n")
@@ -1557,40 +1478,6 @@ func (h *Head) lensJobSpend() string {
return rendered.String()
}
-func (h *Head) lensWatch() string {
- var rendered strings.Builder
- if h.standingWatch == nil {
- rendered.WriteString("standing-watch status is not wired into this surface.\n")
- } else if status := strings.TrimSpace(h.standingWatch()); status == "" {
- rendered.WriteString("nothing is on watch and no standing check is arranged.\n")
- } else {
- rendered.WriteString(status + "\n")
- }
- // The next checks come off the charters directly, because "what happens
- // overnight" is a question about times and the watch block is a question
- // about whether anything is watching at all.
- charters, err := h.store.ActiveCharters()
- if err != nil || len(charters) == 0 {
- return strings.TrimSpace(rendered.String())
- }
- sort.SliceStable(charters, func(first, second int) bool {
- return charters[first].NextDue.Before(charters[second].NextDue)
- })
- if len(charters) > lensStatusListCap {
- charters = charters[:lensStatusListCap]
- }
- rendered.WriteString("standing rules and their next checks:\n")
- for _, charter := range charters {
- next := "no next check arranged"
- if !charter.NextDue.IsZero() {
- next = "next " + charter.NextDue.Local().Format(nowLineLayout)
- }
- fmt.Fprintf(&rendered, "- %s | %s | %s | %s\n", charter.ID,
- lensSnippet(charter.Invariant), lensWatchPhrase(charter.Watch), next)
- }
- return strings.TrimSpace(rendered.String())
-}
-
func (h *Head) lensServices() string {
services, err := h.store.ActiveServices()
if err != nil {
diff --git a/internal/head/lens_test.go b/internal/head/lens_test.go
index 64eadc3e9a..78c51b3d9a 100644
--- a/internal/head/lens_test.go
+++ b/internal/head/lens_test.go
@@ -6,7 +6,6 @@ import (
"strconv"
"strings"
"testing"
- "time"
"github.com/Agent-Field/codeaf/internal/store"
)
@@ -46,18 +45,6 @@ func seedRecallMemory(t *testing.T, graph *store.Store) store.Fact {
if err != nil {
t.Fatal(err)
}
- // A standing rule.
- charter, charterErr := store.NewCharter("pricing-watch", "Pricing pages never disagree with the pricing model.",
- store.WatchSpec{Kind: store.WatchPoll, Poll: &store.PollWatch{Condition: "check the pricing page", Cadence: time.Hour}},
- "Did the pricing page change?", store.CharterAction{Template: "Reconcile the pricing page"},
- store.CharterRails{PerFiringBudgetUSD: 0.1, MaxFiringsPerDay: 3}, store.CharterActive,
- store.Ratification{Origin: store.OriginUser, SessionID: "lens", Evidence: "yes"})
- if charterErr != nil {
- t.Fatal(charterErr)
- }
- if err := graph.CreateCharter(charter); err != nil {
- t.Fatal(err)
- }
// A service.
headServiceFixture(t, graph, "svc-pricing", "pricing-api", "pricing-leaf", 71)
return belief
@@ -80,7 +67,6 @@ func TestRecallBlendsEveryKindOfMemoryInOneRead(t *testing.T) {
lensKindResult: "what finished work concluded",
lensKindJob: "work that has not concluded anything yet",
lensKindBelief: "the notebook",
- lensKindRule: "the standing rules",
lensKindService: "the services",
} {
if !strings.Contains(read, "- "+kind+" | ") {
@@ -96,7 +82,7 @@ func TestRecallBlendsEveryKindOfMemoryInOneRead(t *testing.T) {
t.Fatalf("the result hit carried no actual finding:\n%s", read)
}
// Every hit carries an id, and the ids are the ones open takes.
- if !strings.Contains(read, "| pricing-study |") || !strings.Contains(read, "| pricing-watch |") {
+ if !strings.Contains(read, "| pricing-study |") || !strings.Contains(read, "| pricing-api |") {
t.Fatalf("recall handed back hits with no openable id:\n%s", read)
}
// Nothing was clipped, so nothing claims it was.
@@ -414,20 +400,11 @@ func TestOpenRefusesAPartThatIsNotThere(t *testing.T) {
// The other things a person owns open as their full record, by the ids and
// names they were shown rather than by a second naming scheme.
-func TestOpenReachesRulesServicesAndNotebookLines(t *testing.T) {
+func TestOpenReachesServicesAndNotebookLines(t *testing.T) {
graph := openHeadStore(t)
belief := seedRecallMemory(t, graph)
run := lensRun(t, graph, "lens")
- rule, failed := run.execute(beltToolOpen, mustJSON(map[string]any{"id": "pricing-watch"}))
- if failed {
- t.Fatalf("open on a standing rule failed: %s", rule)
- }
- if !strings.Contains(rule, "watches for: Pricing pages never disagree") ||
- !strings.Contains(rule, "how often:") {
- t.Fatalf("a standing rule opened without its record:\n%s", rule)
- }
-
service, failed := run.execute(beltToolOpen, mustJSON(map[string]any{"id": "pricing-api"}))
if failed {
t.Fatalf("open on a service failed: %s", service)
@@ -470,7 +447,6 @@ func TestStatusRendersTheWholeSystemOnOnePage(t *testing.T) {
head := New(&beltClient{}, graph).
WithCompetenceMap(func() string { return "strong on research, weak on long-running builds" }).
- WithStandingWatch(func() string { return "checks continue with no terminal open; last wake 2h ago" }).
WithDailyBudgetUSD(5)
run := &beltRun{head: head, user: postUser(t, graph, "lens", "how are things?")}
@@ -478,7 +454,7 @@ func TestStatusRendersTheWholeSystemOnOnePage(t *testing.T) {
if failed {
t.Fatalf("status failed: %s", read)
}
- for _, section := range []string{"WORK", "MONEY", "WATCH", "SERVICES", "WAYS OF WORKING", "COMPETENCE", "HEALTH"} {
+ for _, section := range []string{"WORK", "MONEY", "SERVICES", "WAYS OF WORKING", "COMPETENCE", "HEALTH"} {
if !strings.Contains(read, section+"\n") {
t.Fatalf("status is missing its %s section:\n%s", section, read)
}
@@ -489,11 +465,8 @@ func TestStatusRendersTheWholeSystemOnOnePage(t *testing.T) {
if !strings.Contains(read, "daily rail") {
t.Fatalf("status never said what today cost against the rail:\n%s", read)
}
- if !strings.Contains(read, "checks continue with no terminal open") {
- t.Fatalf("status never read the standing watch:\n%s", read)
- }
- if !strings.Contains(read, "pricing-watch") || !strings.Contains(read, "next check") {
- t.Fatalf("status never said when the next check is:\n%s", read)
+ if strings.Contains(read, "WATCH\n") {
+ t.Fatalf("status still has a section for the removed background watch:\n%s", read)
}
if !strings.Contains(read, "pricing-api") {
t.Fatalf("status never named the services:\n%s", read)
@@ -520,9 +493,6 @@ func TestStatusSaysWhatIsNotWiredRatherThanLeavingItOut(t *testing.T) {
if !strings.Contains(read, "no daily rail is configured on this surface") {
t.Fatalf("an unset rail was passed off as a rail:\n%s", read)
}
- if !strings.Contains(read, "standing-watch status is not wired into this surface") {
- t.Fatalf("an unwired watch was left out rather than named:\n%s", read)
- }
if !strings.Contains(read, "no competence measurement is wired into this surface") {
t.Fatalf("an unwired competence map was left out rather than named:\n%s", read)
}
diff --git a/internal/head/manual_test.go b/internal/head/manual_test.go
index 74cfd9b5cc..2f0b780192 100644
--- a/internal/head/manual_test.go
+++ b/internal/head/manual_test.go
@@ -164,8 +164,7 @@ func TestManualCoversEveryCapabilityTheHeadDispatchesOn(t *testing.T) {
store.CommandSplice, store.CommandAmend, store.CommandCancel,
store.CommandPause, store.CommandResume, store.CommandRestart,
store.CommandReprioritize, store.CommandRedirect, store.CommandExpedite,
- store.CommandCharterRatify, store.CommandServiceRestart,
- store.CommandStandingWatchEnable,
+ store.CommandServiceRestart,
} {
for _, word := range strings.Split(string(kind), "_") {
wanted[word] = "command kind"
@@ -174,7 +173,7 @@ func TestManualCoversEveryCapabilityTheHeadDispatchesOn(t *testing.T) {
wanted[urgencyCue] = "recognizer"
wanted[ModelSlotBoost] = "model slot"
wanted[routeReflexKind] = "routing"
- for _, word := range []string{"charter", "service", "notebook", "practice", "vision"} {
+ for _, word := range []string{"service", "notebook", "practice", "vision"} {
wanted[word] = "recognizer"
}
for term, source := range wanted {
diff --git a/internal/head/money.go b/internal/head/money.go
index b90c162248..46f935e361 100644
--- a/internal/head/money.go
+++ b/internal/head/money.go
@@ -3,13 +3,9 @@ package head
import (
"fmt"
"math"
- "regexp"
- "sort"
- "strconv"
"strings"
"time"
- "github.com/Agent-Field/codeaf/internal/standing"
"github.com/Agent-Field/codeaf/internal/store"
)
@@ -86,104 +82,6 @@ func dimeUSD(cost float64) string {
return fmt.Sprintf("$%.2f", math.Round(cost*10)/10)
}
-// measuredCostPattern reads a per-run cost out of the measured self-knowledge
-// block. The spellings are the ones that block actually writes ("avg cost
-// $0.0017"), plus the bare "cost: $x" a briefer rendering uses.
-var measuredCostPattern = regexp.MustCompile(`(?i)(?:avg(?:erage)?\s+cost|cost)\s*[:=]?\s*\$([0-9]+(?:\.[0-9]+)?)`)
-
-// measuredStandingCost is what one firing of a standing rule should be priced
-// at, read off the measured history rather than guessed.
-//
-// It takes the MEDIAN of every measurement in the block rather than the first,
-// and the reason is what the block contains: one line per measured population,
-// each with a history of its own, because a figure that averaged a long
-// research leaf with a lookup would describe neither. Reading only the first
-// line meant the price of every standing rule was set by whichever line
-// happened to sort first.
-func measuredStandingCost(context string) (float64, bool) {
- matches := measuredCostPattern.FindAllStringSubmatch(context, -1)
- costs := make([]float64, 0, len(matches))
- for _, match := range matches {
- cost, err := strconv.ParseFloat(match[1], 64)
- if err != nil || cost < 0 || math.IsNaN(cost) || math.IsInf(cost, 0) {
- continue
- }
- costs = append(costs, cost)
- }
- if len(costs) == 0 {
- return 0, false
- }
- sort.Float64s(costs)
- return costs[len(costs)/2], true
-}
-
-// standingRails is the whole of what a charter proposal may claim about money,
-// and the model contributes none of it.
-//
-// The compiler is asked for rails and answers with them, and what it answers is
-// a guess dressed as a measurement: it has been shown the measured block and it
-// writes a number that looks plausible beside it. Before this, that number
-// survived whenever the block carried no measurement the pattern could read, and
-// its justification — free prose, with a dollar figure in it — survived always.
-// Both are now discarded and recomputed, which is the only arrangement under
-// which the sentence a person consents to is true.
-//
-// What the model is still trusted with is everything that is not arithmetic: the
-// invariant, the sentinel, the action, the cadence words.
-func standingRails(proposed store.CharterSpecRails, reminder bool, graphContext string) store.CharterSpecRails {
- rails := proposed
- measured, isMeasured := measuredStandingCost(graphContext)
- switch {
- case isMeasured:
- rails.EstimatedCostUSD = measured
- default:
- // No measurement means no measurement. The default is the store's own
- // backstop rather than whatever the model wrote, because a rail is a
- // promise about spending and an unmeasured guess is not one.
- rails.EstimatedCostUSD = defaultStandingCostUSD
- }
- if rails.MaxPerDay <= 0 {
- rails.MaxPerDay = defaultStandingMaxPerDay
- if reminder {
- rails.MaxPerDay = 1
- }
- }
- rails.MaxPerDayJustification = standingRailSentence(rails, reminder, isMeasured)
- return rails
-}
-
-const (
- // defaultStandingCostUSD backstops a rule proposed before anything has been
- // measured. It IS the store's own per-firing backstop rather than a copy of
- // it, so the number a proposal says and the number a charter is created
- // with are one number that cannot drift.
- defaultStandingCostUSD = standing.DefaultPerRunUSD
- // defaultStandingMaxPerDay is the ordinary daily ceiling. A reminder gets
- // one, because one a day is all a reminder is.
- defaultStandingMaxPerDay = 10
-)
-
-// standingRailSentence is the money sentence itself, templated.
-//
-// It is the sentence the ratification question puts in front of the person, so
-// it says three things and each of them is computed: what one run costs, how
-// many runs a day are allowed, and what the worst day therefore is. It also says
-// whether the rate was MEASURED or is a standing default, because "about $0.15 a
-// run" read as a measurement when nothing has run yet is the same lie in a
-// smaller size.
-func standingRailSentence(rails store.CharterSpecRails, reminder, isMeasured bool) string {
- rate := moneyUSD(rails.EstimatedCostUSD)
- if !isMeasured {
- rate += " (nothing measured yet)"
- }
- if reminder && rails.MaxPerDay == 1 {
- return "one a day is all a reminder needs, at " + rate + " a run"
- }
- worst := rails.EstimatedCostUSD * float64(rails.MaxPerDay)
- return fmt.Sprintf("%s a run, at most %d a day, so the worst day is about %s",
- rate, rails.MaxPerDay, moneyUSD(worst))
-}
-
// spendRateLines are the computed figures behind a money answer: what has
// actually been spent, what one run of it costs, and what the same rate comes to
// over the windows people ask about.
diff --git a/internal/head/money_test.go b/internal/head/money_test.go
index 12ad0c52c0..e76c1f0946 100644
--- a/internal/head/money_test.go
+++ b/internal/head/money_test.go
@@ -1,7 +1,6 @@
package head
import (
- "fmt"
"strings"
"testing"
"time"
@@ -9,102 +8,6 @@ import (
"github.com/Agent-Field/codeaf/internal/store"
)
-// measuredCents is the shape of the measured self-knowledge block the compiler
-// is given: one line per measured population, each with a history of its own,
-// each ending in the per-run cost that block actually writes.
-func measuredCents(costs ...float64) string {
- lines := make([]string, 0, len(costs))
- for index, cost := range costs {
- lines = append(lines, fmt.Sprintf(
- "worker-%d: median 1200 tokens, 3 turns over 40 runs; 95%% succeeded; avg cost $%.4f",
- index+1, cost))
- }
- return "Measured execution costs (this system's own measured history):\n" + strings.Join(lines, "\n")
-}
-
-// THE FAILURE, EXACTLY (13.3's head edge).
-//
-// A charter proposal offered to run something at "$20.00 a run" against a
-// measured cost of $0.0017 — ten thousand times the truth, in the one sentence
-// a person is asked to consent to spending on. Nothing had computed $20.00: a
-// model wrote it, in prose, beside numbers it had been shown, and the
-// normalizer let it stand because it only ever replaced a rail the model had
-// left empty.
-//
-// What this pins is not "that number is gone". It is that the model has no
-// route to any number at all: whatever it writes into the rails is discarded,
-// the rate comes off the measurement, and the sentence beside it is assembled
-// from the rate rather than accepted as prose.
-func TestACharterProposalNeverPricesAtTenThousandTimesTheMeasurement(t *testing.T) {
- fabricated := store.CharterSpec{
- Rails: store.CharterSpecRails{
- EstimatedCostUSD: 20.00,
- MaxPerDay: 4,
- MaxPerDayJustification: "each run is about $20.00 a run, so four a day is $80.00",
- },
- }
- spec := normalizeCharterSpec(fabricated,
- "every morning check whether the deploy queue is clear", measuredCents(0.0017))
-
- if spec.Rails.EstimatedCostUSD != 0.0017 {
- t.Fatalf("the rate is %v, want the measured 0.0017 — the model's guess survived", spec.Rails.EstimatedCostUSD)
- }
- if strings.Contains(spec.Rails.MaxPerDayJustification, "20.00") ||
- strings.Contains(spec.Rails.MaxPerDayJustification, "80.00") {
- t.Fatalf("the model's arithmetic rode into the sentence a person consents to: %q",
- spec.Rails.MaxPerDayJustification)
- }
- // And the sentence is not merely free of the lie — it is assembled from the
- // measurement, at a precision that does not round a real cost to nothing.
- if !strings.Contains(spec.Rails.MaxPerDayJustification, "$0.0017") {
- t.Fatalf("the money sentence does not quote the measured rate: %q", spec.Rails.MaxPerDayJustification)
- }
- if !strings.Contains(spec.Rails.MaxPerDayJustification, "at most 4 a day") {
- t.Fatalf("the ceiling the model DID legitimately choose was dropped: %q", spec.Rails.MaxPerDayJustification)
- }
- // Four runs at $0.0017 is under a cent, and saying "$0.00" would read as
- // free rather than as small.
- if !strings.Contains(spec.Rails.MaxPerDayJustification, "$0.0068") {
- t.Fatalf("the worst day was not computed from the measured rate: %q", spec.Rails.MaxPerDayJustification)
- }
-}
-
-// With nothing measured, the answer is the standing backstop AND the fact that
-// nothing has been measured. A default quoted as a measurement is the same
-// fabrication in a smaller size.
-func TestAnUnmeasuredProposalSaysItIsUnmeasured(t *testing.T) {
- spec := normalizeCharterSpec(store.CharterSpec{
- Rails: store.CharterSpecRails{EstimatedCostUSD: 12.5, MaxPerDay: 0},
- }, "keep an eye on the build", "")
-
- if spec.Rails.EstimatedCostUSD != defaultStandingCostUSD {
- t.Fatalf("an unmeasured rail is %v, want the backstop %v", spec.Rails.EstimatedCostUSD, defaultStandingCostUSD)
- }
- if !strings.Contains(spec.Rails.MaxPerDayJustification, "nothing measured yet") {
- t.Fatalf("a backstop was quoted as a measurement: %q", spec.Rails.MaxPerDayJustification)
- }
- if strings.Contains(spec.Rails.MaxPerDayJustification, "12.5") {
- t.Fatalf("the model's guess survived into an unmeasured proposal: %q", spec.Rails.MaxPerDayJustification)
- }
-}
-
-// One line per measured population means several measurements, and reading only
-// the first meant the price of every standing rule was set by whichever line
-// sorted first. The middle one is the honest answer to "what does one run
-// cost here".
-func TestTheMeasuredRateIsTheMiddleOfEveryMeasurement(t *testing.T) {
- cost, ok := measuredStandingCost(measuredCents(0.0017, 0.4200, 0.0900))
- if !ok {
- t.Fatal("a block full of measurements read as unmeasured")
- }
- if cost != 0.09 {
- t.Fatalf("measured rate = %v, want the median 0.09", cost)
- }
- if _, ok := measuredStandingCost("nothing measurable here"); ok {
- t.Fatal("a block with no measurement in it produced one")
- }
-}
-
// A real cost of a fraction of a cent must never render as "$0.00": a figure
// that rounds a true measurement to nothing reads as free, and a model told the
// measurement is meaningless reaches for one that is not.
@@ -193,14 +96,6 @@ func TestTheOnePromptForbidsArithmeticInProse(t *testing.T) {
t.Fatalf("the law lost the clause %q, which is the shape the failure took", clause)
}
}
- // And the compiler is not asked for money it cannot know, because asking is
- // the invitation the failure accepted.
- if strings.Contains(standingCompilerPrompt, "estimated_cost_usd") {
- t.Fatal("the standing compiler still asks a model to price a firing")
- }
- if !strings.Contains(standingCompilerPrompt, "Never write a cost, a rate or any figure in dollars") {
- t.Fatal("the standing compiler does not forbid the figure it used to invite")
- }
}
// The spending tool is the door those figures come through, so the whole loop
diff --git a/internal/head/prompt.go b/internal/head/prompt.go
index ecd86cfc8d..9a16eb33f8 100644
--- a/internal/head/prompt.go
+++ b/internal/head/prompt.go
@@ -30,11 +30,11 @@ const orchestratorPitch = `What codeaf is — your own account of yourself, and
const orchestratorHands = `Your tools are your only hands, and the only thing that makes anything true.
-Reads are always safe and never need permission: the board; one job's result, plan or files; search and history over everything ever said or done; another of their rooms; the manual; spending, standing watches, and your own measured competence. Read before you act.
+Reads are always safe and never need permission: the board; one job's result, plan or files; search and history over everything ever said or done; another of their rooms; the manual; spending, and your own measured competence. Read before you act.
-Three of the reads are total, and they are the ones to reach for first. recall searches everything settled or said at once — the conversation, the notebook, work live and finished, standing rules, services — and hands back real content with an id for each hit. open takes one of those ids and gives you the whole thing: running work as its plan with every step's state and what its workers are saying right now, finished work as its whole result and files, a file as its actual bytes, a rule or service or notebook line as its full record, and with raw:true the journal's own rows underneath it. open pages rather than truncates: when it says which part of how many you are holding, you are holding a fragment, and reading on is one more call. status is the whole system on one page — what is running, what today cost, what you are watching, what is running as a service, what has been measured about you.
+Three of the reads are total, and they are the ones to reach for first. recall searches everything settled or said at once — the conversation, the notebook, work live and finished, services — and hands back real content with an id for each hit. open takes one of those ids and gives you the whole thing: running work as its plan with every step's state and what its workers are saying right now, finished work as its whole result and files, a file as its actual bytes, a service or notebook line as its full record, and with raw:true the journal's own rows underneath it. open pages rather than truncates: when it says which part of how many you are holding, you are holding a fragment, and reading on is one more call. status is the whole system on one page — what is running, what today cost, what is running as a service, what has been measured about you.
-Three verbs cover work, and they take everything the person owns — jobs, standing rules, services, learned ways of working. task commissions one new piece of it. change hands their words, verbatim, to something already under way or already standing; you never pick a verb for them and never split their sentence into halves, because what the words mean for the work is worked out by whoever holds the plan and told back to you. stop withdraws things. Your own hands are bash, one shell command in their workspace, and write, a document on disk. note and forget keep the notebook; ask puts one numbered question to the person and ends the turn; say puts one line to them now without ending it.
+Three verbs cover work, and they take everything the person owns — jobs, services, learned ways of working. task commissions one new piece of it. change hands their words, verbatim, to something already under way; you never pick a verb for them and never split their sentence into halves, because what the words mean for the work is worked out by whoever holds the plan and told back to you. stop withdraws things. Your own hands are bash, one shell command in their workspace, and write, a document on disk. note and forget keep the notebook; ask puts one numbered question to the person and ends the turn; say puts one line to them now without ending it.
You hear back. Work you commission speaks to you here when it lands, and a change you put in hand speaks to you when the workforce has settled what it made of it — so telling them you will come back with what it decides is a thing you can say and then do. The turn does not have to hold its breath for either: say what is true now, and say the rest when it is.
diff --git a/internal/head/question_identity.go b/internal/head/question_identity.go
index 2a322ed48f..3c9b90c8ab 100644
--- a/internal/head/question_identity.go
+++ b/internal/head/question_identity.go
@@ -37,11 +37,10 @@ const questionChoiceLabelBytes = 64
// reply and must stop.
//
// Only questions that would actually take this reply compete, which is the same
-// test the resolution path applies a moment later: a standing-watch decision or
-// an unratified charter lets ordinary words go by, so words those questions
-// would ignore are not a choice between them. A reply exactly one open question
-// would take is aimed at that one even when a newer question sits on top —
-// that is the evidence a person would use, and the newest-wins rule has no
+// test the resolution path applies a moment later, so words a question would
+// ignore are not a choice between it and another. A reply exactly one open
+// question would take is aimed at that one even when a newer question sits on
+// top — that is the evidence a person would use, and the newest-wins rule has no
// claim on it.
func (h *Head) aimAgentQuestion(user store.Message, newest store.AgentQuestion) (store.AgentQuestion, bool, error) {
candidates, err := h.store.QuestionsForAnswer(user.SessionID, user.Seq)
@@ -261,12 +260,6 @@ func questionAcceptsReply(reply string, question store.AgentQuestion) bool {
if _, selected := selectQuestionOption(reply, question.Options); selected {
return true
}
- if standingWatchQuestion(question.Options) {
- return false
- }
- if _, isCharter := charterQuestionID(question.Options); isCharter {
- return strings.TrimSpace(extractCadence(reply)) != ""
- }
return strings.TrimSpace(reply) != ""
}
diff --git a/internal/head/rulevoice_test.go b/internal/head/rulevoice_test.go
deleted file mode 100644
index 4e149e76ee..0000000000
--- a/internal/head/rulevoice_test.go
+++ /dev/null
@@ -1,285 +0,0 @@
-package head
-
-import (
- "context"
- "strings"
- "testing"
- "time"
-
- "github.com/Agent-Field/agentfield/sdk/go/ai"
- "github.com/Agent-Field/codeaf/internal/resident"
- "github.com/Agent-Field/codeaf/internal/store"
-)
-
-// activateReminder stands up one say-only weekly rule, the one the everyday
-// simulation's user set on Monday morning.
-func activateReminder(t *testing.T, graph *store.Store, id, invariant, cadence, says string) store.Charter {
- t.Helper()
- watch := store.CadenceWatchSpec(store.WatchCron, cadence, "", invariant, time.Now())
- charter, err := graph.DraftCharter(id, "rules", 0, store.CharterSpec{
- Invariant: invariant,
- Watch: store.CharterWatch{Kind: store.WatchCron, Cadence: cadence, Spec: watch},
- Sentinel: "Is it time for the reminder?", Action: "Say: " + says, SayOnly: true,
- Rails: store.CharterSpecRails{
- EstimatedCostUSD: 0.02, MaxPerDay: 1,
- MaxPerDayJustification: "one a day is all a reminder needs", Expiry: "never",
- },
- })
- if err != nil {
- t.Fatal(err)
- }
- if err := graph.SetCharterStatus(id, store.CharterActive, store.Ratification{
- Origin: store.OriginUser, SessionID: "rules", Evidence: "yes, stand this up",
- }); err != nil {
- t.Fatal(err)
- }
- return charter
-}
-
-// TestChangeItToTuesdayReachesTheRule is the everyday simulation's Wednesday
-// 08:50. "change it to tuesday" was declined by charter management (no cadence
-// pattern knew a weekday), claimed by node surgery, resolved to an empty
-// reference, and answered with eight rows of work — from which standing rules
-// are explicitly excluded. There was no sentence that moved a reminder to
-// another day.
-//
-// The reading that knows better is now evidence rather than a verdict: it says
-// the sentence edits a standing rule and what it reads the new rhythm as, above
-// the message, and the rule tool is what journals it. A live job on the board
-// must still not be the thing that gets changed.
-func TestChangeItToTuesdayReachesTheRule(t *testing.T) {
- graph := openHeadStore(t)
- charter := activateReminder(t, graph, "plants",
- "remind me every sunday to water the plants", "every sunday", "water the plants")
- // A live job is on the board: surgery must not be the arm that claims this.
- spliceSurgeryJob(t, graph, "lisbon", "Lisbon trip research", "look into flights and hotels")
-
- user := postUser(t, graph, "rules", "change it to tuesday")
- reading := deterministicReading(t, New(nil, graph), user)
- if !strings.Contains(reading, "reads as an edit to a STANDING RULE (cadence)") {
- t.Fatalf("the rule reading never reached the loop:\n%s", reading)
- }
-
- head, _ := beltHead(graph,
- beltTurn{calls: []ai.ToolCall{beltCall("c1", beltToolChange, map[string]any{
- "target": charter.ID, "words": user.Body})}},
- beltTurn{text: "The plant reminder moves to Tuesdays."})
- if err := head.answer(context.Background(), user); err != nil {
- t.Fatal(err)
- }
- commands, err := graph.PendingCommands(10)
- if err != nil || len(commands) != 1 || commands[0].Kind != store.CommandCharterCadence ||
- commands[0].Target != charter.ID || commands[0].Instruction != "tuesday" {
- t.Fatalf("commands = %+v err=%v", commands, err)
- }
- if err := resident.New(graph, nil, nil).Tick(context.Background()); err != nil {
- t.Fatal(err)
- }
- updated, found, err := graph.Charter(charter.ID)
- if err != nil || !found {
- t.Fatalf("charter = %+v found=%t err=%v", updated, found, err)
- }
- if updated.Watch.Cron == nil || updated.Watch.Cron.Kind != store.CronWeekly ||
- updated.Watch.Cron.Weekday != time.Tuesday {
- t.Fatalf("retimed watch = %+v, want a weekly Tuesday rule", updated.Watch)
- }
- if updated.NextDue.Weekday() != time.Tuesday || !updated.NextDue.After(time.Now()) {
- t.Fatalf("next due = %s, want the coming Tuesday", updated.NextDue)
- }
- // One visible reply, and the change is journaled rather than written into
- // a row: it survives a rebuild from the events alone.
- reply := waitForAgentReply(t, graph, "rules", user.Seq)
- if strings.TrimSpace(reply.Body) == "" {
- t.Fatal("re-timing a rule said nothing")
- }
- if err := graph.Rebuild(); err != nil {
- t.Fatal(err)
- }
- replayed, _, err := graph.Charter(charter.ID)
- if err != nil || replayed.Watch.Cron == nil || replayed.Watch.Cron.Weekday != time.Tuesday {
- t.Fatalf("replayed charter = %+v err=%v", replayed.Watch, err)
- }
-}
-
-// TestPushTheReminderToEightPmReachesTheRule is the second sentence of the same
-// friction: a verb the cue list never had, and a clock instead of a day.
-func TestPushTheReminderToEightPmReachesTheRule(t *testing.T) {
- graph := openHeadStore(t)
- charter := activateReminder(t, graph, "plants",
- "remind me every sunday to water the plants", "every sunday", "water the plants")
-
- user := postUser(t, graph, "rules", "push the reminder to 8pm")
- head, _ := beltHead(graph,
- beltTurn{calls: []ai.ToolCall{beltCall("c1", beltToolChange, map[string]any{
- "target": charter.ID, "words": user.Body})}},
- beltTurn{text: "It moves to 8pm."})
- if err := head.answer(context.Background(), user); err != nil {
- t.Fatal(err)
- }
- commands, err := graph.PendingCommands(10)
- if err != nil || len(commands) != 1 || commands[0].Kind != store.CommandCharterCadence ||
- commands[0].Target != charter.ID {
- t.Fatalf("commands = %+v err=%v", commands, err)
- }
- if err := resident.New(graph, nil, nil).Tick(context.Background()); err != nil {
- t.Fatal(err)
- }
- updated, _, err := graph.Charter(charter.ID)
- if err != nil || updated.Watch.Cron == nil || updated.Watch.Cron.Hour != 20 {
- t.Fatalf("retimed watch = %+v err=%v, want 20:00", updated.Watch, err)
- }
-}
-
-// TestRewordingARuleIsSayable covers the other verb a rule needs: changing what
-// it says without touching when it runs.
-func TestRewordingARuleIsSayable(t *testing.T) {
- graph := openHeadStore(t)
- charter := activateReminder(t, graph, "plants",
- "remind me every sunday to water the plants", "every sunday", "water the plants")
-
- user := postUser(t, graph, "rules",
- "change the plant reminder to say water the plants and take the bins out")
- // The wording cue is read before the rhythm: the sentence names a day and is
- // not about the day, and the reading says which of the two it is.
- reading := deterministicReading(t, New(nil, graph), user)
- if !strings.Contains(reading, "reads as an edit to a STANDING RULE (wording)") {
- t.Fatalf("a rewording was read as a retiming:\n%s", reading)
- }
-
- head, _ := beltHead(graph,
- beltTurn{calls: []ai.ToolCall{beltCall("c1", beltToolChange, map[string]any{
- "target": charter.ID, "words": user.Body})}},
- beltTurn{text: "It will say that from now on."})
- if err := head.answer(context.Background(), user); err != nil {
- t.Fatal(err)
- }
- commands, err := graph.PendingCommands(10)
- if err != nil || len(commands) != 1 || commands[0].Kind != store.CommandCharterWording ||
- commands[0].Target != charter.ID ||
- commands[0].Instruction != "water the plants and take the bins out" {
- t.Fatalf("commands = %+v err=%v", commands, err)
- }
- if err := resident.New(graph, nil, nil).Tick(context.Background()); err != nil {
- t.Fatal(err)
- }
- updated, _, err := graph.Charter(charter.ID)
- if err != nil || updated.Action.Template != "water the plants and take the bins out" {
- t.Fatalf("reworded action = %+v err=%v", updated.Action, err)
- }
- // The rhythm is untouched, and so is the sentence she consented to.
- if updated.Watch.Cron == nil || updated.Watch.Cron.Weekday != time.Sunday {
- t.Fatalf("rewording moved the schedule: %+v", updated.Watch)
- }
- if updated.Invariant != charter.Invariant {
- t.Fatalf("rewording rewrote the ratified sentence: %q", updated.Invariant)
- }
- if err := graph.Rebuild(); err != nil {
- t.Fatal(err)
- }
- replayed, _, err := graph.Charter(charter.ID)
- if err != nil || replayed.Action.Template != "water the plants and take the bins out" {
- t.Fatalf("replayed action = %+v err=%v", replayed.Action, err)
- }
-}
-
-// TestAnUndescribedRuleReferenceAsksOnePlainQuestion is the ambiguity rule from
-// the design filter: never pick for the user, never a picker, one short
-// question in plain words. The tool refuses to choose and hands back both rules
-// in the words they were ratified in; the question is the ask tool's, and the
-// day survives the round trip because the answer comes back to the loop with
-// the question and the choice both in front of it.
-func TestAnUndescribedRuleReferenceAsksOnePlainQuestion(t *testing.T) {
- graph := openHeadStore(t)
- activateReminder(t, graph, "plants",
- "remind me every sunday to water the plants", "every sunday", "water the plants")
- activateReminder(t, graph, "bins",
- "remind me every friday to put the bins out", "every friday", "put the bins out")
-
- user := postUser(t, graph, "rules", "change it to tuesday")
- head, client := beltHead(graph,
- beltTurn{calls: []ai.ToolCall{beltCall("c1", beltToolStanding, map[string]any{})}},
- beltTurn{calls: []ai.ToolCall{beltCall("c2", beltToolAsk, map[string]any{
- "question": "Which rule do you mean?",
- "options": []string{"remind me every sunday to water the plants",
- "remind me every friday to put the bins out"}})}})
- if err := head.answer(context.Background(), user); err != nil {
- t.Fatal(err)
- }
- reply := waitForAgentReply(t, graph, "rules", user.Seq)
- if !strings.HasPrefix(reply.Body, "Which rule do you mean?") || len(reply.Options) != 2 {
- t.Fatalf("askback = %+v", reply)
- }
- for _, option := range reply.Options {
- if !strings.Contains(option.Label, "remind me every") {
- t.Fatalf("option is not named by its plain description: %+v", option)
- }
- }
- commands, err := graph.PendingCommands(10)
- if err != nil || len(commands) != 0 {
- t.Fatalf("an ambiguous reference changed something: %+v err=%v", commands, err)
- }
- // The words the model asked with are the rules' own, because that is what the
- // read handed it rather than a pair of ids. Naming the candidates is a READ
- // now — the verb tools take an id and never search for one — so the machinery
- // under the question is the charter candidate reader itself.
- rules, err := New(nil, graph).charterCandidates("")
- if err != nil {
- t.Fatal(err)
- }
- named := ""
- for _, rule := range rules {
- named += rule.Invariant + "\n"
- }
- if !strings.Contains(named, "water the plants") || !strings.Contains(named, "put the bins out") {
- t.Fatalf("the candidate list is not both rules by name:\n%s", named)
- }
-
- // Answering the question by number carries the day through to the rule: the
- // choice is not applied by the question machinery, it comes back as an
- // ordinary turn with both halves of the exchange in the prompt.
- client.turns = append(client.turns, beltTurn{calls: []ai.ToolCall{
- beltCall("c3", beltToolChange, map[string]any{
- "target": "plants", "words": "change it to tuesday"})}},
- beltTurn{text: "Moved to Tuesdays."})
- answer := postUser(t, graph, "rules", "1")
- if err := head.answer(context.Background(), answer); err != nil {
- t.Fatal(err)
- }
- settling := latestPrompt(client)
- if !strings.Contains(settling, "change it to tuesday") ||
- !strings.Contains(settling, "Which rule do you mean?") {
- t.Fatalf("the answering turn was not given the exchange it settles:\n%s", settling)
- }
- commands, err = graph.PendingCommands(10)
- if err != nil || len(commands) != 1 || commands[0].Kind != store.CommandCharterCadence ||
- commands[0].Target != "plants" || commands[0].Instruction != "tuesday" {
- t.Fatalf("answered askback = %+v err=%v", commands, err)
- }
-}
-
-// TestRuleVerbsStillFallThroughWhenNoRuleExists keeps the shared vocabulary
-// shared: with no standing rule to mean, "change it to tuesday" is about work,
-// and nothing invents a rule to edit. The reading may still say the sentence
-// looks like a rule edit — it is evidence, never an instruction — and with no
-// rule on the board the loop has no id to name and asks instead.
-func TestRuleVerbsStillFallThroughWhenNoRuleExists(t *testing.T) {
- graph := openHeadStore(t)
- spliceSurgeryJob(t, graph, "lisbon", "Lisbon trip research", "look into flights and hotels")
- user := postUser(t, graph, "no-rules", "change it to tuesday")
- head, _ := beltHead(graph,
- beltTurn{calls: []ai.ToolCall{beltCall("c1", beltToolStanding, map[string]any{})}},
- beltTurn{text: "Which one do you mean?"})
- if err := head.answer(context.Background(), user); err != nil {
- t.Fatal(err)
- }
- charters, err := graph.ActiveCharters()
- if err != nil || len(charters) != 0 {
- t.Fatalf("charters = %+v err=%v", charters, err)
- }
- if commands, err := graph.PendingCommands(10); err != nil || len(commands) != 0 {
- t.Fatalf("a rule edit with no rule journaled something: %+v err=%v", commands, err)
- }
- if reply := waitForAgentReply(t, graph, "no-rules", user.Seq); strings.TrimSpace(reply.Body) == "" {
- t.Fatal("the sentence dead-ended in charter management")
- }
-}
diff --git a/internal/head/standing.go b/internal/head/standing.go
deleted file mode 100644
index b578b85088..0000000000
--- a/internal/head/standing.go
+++ /dev/null
@@ -1,307 +0,0 @@
-package head
-
-import (
- "context"
- "fmt"
- "regexp"
- "strings"
- "time"
-
- "github.com/Agent-Field/agentfield/sdk/go/ai"
- "github.com/Agent-Field/codeaf/internal/shaped"
- "github.com/Agent-Field/codeaf/internal/store"
-)
-
-const standingCompilerPrompt = `You compile durable intent into one inert charter draft.
-
-Return exactly one JSON object with this shape and no text outside it:
-{"invariant":"the user's exact words","watch":{"kind":"cron|file|graph|poll","cadence":"human cadence words","schedule":"structured schedule"},"sentinel":"cheap wake-time judgment","action":"what a firing does after re-grounding","rails":{"max_per_day":0,"expiry":""}}
-
-Rules:
-- Preserve the instruction exactly in invariant.
-- Keep the sentinel to one cheap judgment: whether the invariant is threatened or its condition occurred.
-- State an action that can be re-grounded when it fires; do not freeze today's world into it.
-- Never write a cost, a rate or any figure in dollars. What a firing costs is measured from this system's own journaled runs after you have answered, and a number written here would be a guess presented to the user as a measurement.
-- max_per_day is how many firings a day the intent could reasonably need; leave it 0 when the words do not settle it and a default is applied after this reading.
-- Reminders are degenerate charters: cron watch, one firing, action = say the reminder.
-- Return a draft only. Never claim it is active or ratified.`
-
-var (
- standingEveryPattern = regexp.MustCompile(`(?i)\b(?:whenever|each\s+time|every\s+(?:\w+|\d+\s+(?:minutes?|hours?|days?|weeks?)))\b`)
- standingStatePattern = regexp.MustCompile(`(?i)\bmake\s+sure\b.+\b(?:stays?|remains?)\b`)
- standingReminderPattern = regexp.MustCompile(`(?i)\b(?:remind|notify|alert)\s+me\s+(?:when|whenever|at|on|in|tomorrow|next)\b`)
- standingWhenOncePattern = regexp.MustCompile(`(?i)\bwhen\s+i\s+say\b.*\bonce\b`)
- // standingOnceClausePattern separates the two words spelled "once". One is
- // a count — "run the benchmark once" — and it is the reason the exclusion
- // below exists at all: an ask that happens a single time is not durable
- // intent. The other is a temporal conjunction — "once the deploy is green",
- // "once it lands" — which is the exact shape a sentinel is FOR, and the
- // plain substring check was reading it as the first and killing it.
- //
- // The discriminator is grammatical rather than semantic: the conjunction is
- // followed by a clause, the count is followed by nothing or by punctuation.
- // So a subject pronoun or determiner after the word, or any word followed
- // by a verb of completion, means the sentence is naming a condition and not
- // a quantity.
- standingOnceClausePattern = regexp.MustCompile(`(?i)\bonce\s+(?:it|its|it'?s|they|the|that|this|there|we|you|[a-z]+\s+(?:is|are|has|have|was|were|finish|finishes|lands|completes|passes))\b`)
- // clockTail is the time-of-day a cadence phrase may carry with it: "at 9",
- // "at 8pm", "at 6:30 pm", "in the morning". It is spelled once and appended
- // to every pattern that can be qualified by one, because the clock is the
- // half of "every Sunday at 9am" that used to fall on the floor.
- clockTail = `(?:\s+(?:morning|afternoon|evening|night))?(?:\s+at\s+\d{1,2}(?::\d{2})?\s*(?:am|pm)?|\s+\d{1,2}(?::\d{2})?\s*(?:am|pm))?`
- weekdayName = `(?:sun|mon|tues|wednes|thurs|fri|satur)days?`
- cadencePatterns = []*regexp.Regexp{
- regexp.MustCompile(`(?i)\bevery\s+weekday` + clockTail),
- regexp.MustCompile(`(?i)\bevery\s+\d+\s+(?:minutes?|hours?|days?|weeks?)\b`),
- // A named day is the commonest standing rule there is and was the one
- // shape no pattern here knew. It reads with or without a leading word,
- // so "every sunday", "on sundays" and "sunday mornings" all land.
- regexp.MustCompile(`(?i)\b(?:every|each|on)\s+` + weekdayName + clockTail),
- regexp.MustCompile(`(?i)\b` + weekdayName + clockTail),
- regexp.MustCompile(`(?i)\bevery\s+(?:morning|afternoon|evening|night|day|week|hour)` + clockTail),
- regexp.MustCompile(`(?i)\b(?:hourly|daily|weekly)` + clockTail),
- regexp.MustCompile(`(?i)\b(?:whenever|each\s+time)\b`),
- regexp.MustCompile(`(?i)\btomorrow` + clockTail),
- regexp.MustCompile(`(?i)\bin\s+\d+\s+(?:minutes?|hours?|days?)\b`),
- // A bare clock is a cadence on its own: "remind me at 6pm to leave".
- regexp.MustCompile(`(?i)\bat\s+\d{1,2}(?::\d{2})?\s*(?:am|pm)?\b`),
- regexp.MustCompile(`(?i)\b\d{1,2}(?::\d{2})?\s*(?:am|pm)\b`),
- }
-)
-
-// RecognizesStandingIntent is the compiler's temporal reading: true means the
-// ask survives one completion and therefore must cross ratification.
-func RecognizesStandingIntent(instruction string) bool {
- trimmed := strings.TrimSpace(instruction)
- lower := strings.ToLower(trimmed)
- if lower == "" || standingWhenOncePattern.MatchString(lower) {
- return false
- }
- // "once the deploy is green" is a condition to be watched for, which is what
- // a sentinel is; "run the benchmark once" is a count, which is not durable
- // intent at all. The exclusion below used to read both as the count and so
- // no temporal ask could ever become a sentinel — the whole class of "do this
- // when that happens" fell through to an ordinary one-shot job that runs now.
- if standingOnceClausePattern.MatchString(lower) {
- return true
- }
- if strings.Contains(lower, " once") &&
- !strings.Contains(lower, "whenever") && !strings.Contains(lower, "each time") &&
- !standingReminderPattern.MatchString(lower) && !standingStatePattern.MatchString(lower) &&
- !strings.HasPrefix(lower, "keep ") && !strings.HasPrefix(lower, "please keep ") &&
- !strings.HasPrefix(lower, "watch ") && !strings.HasPrefix(lower, "please watch ") {
- return false
- }
- if standingEveryPattern.MatchString(lower) || standingStatePattern.MatchString(lower) ||
- standingReminderPattern.MatchString(lower) {
- return true
- }
- if strings.HasPrefix(lower, "keep ") || strings.HasPrefix(lower, "please keep ") ||
- strings.HasPrefix(lower, "watch ") || strings.HasPrefix(lower, "please watch ") ||
- strings.HasPrefix(lower, "monitor ") || strings.HasPrefix(lower, "please monitor ") {
- return true
- }
- return false
-}
-
-func (c *Compiler) compileStanding(ctx context.Context, instruction, graphContext string) (Brief, error) {
- user := "Current graph context and measured self-knowledge:\n" + graphContext +
- "\n\nUser instruction (verbatim; preserve exactly):\n" + instruction
- // The room a charter needs, and the repair when it does not fit, are the
- // shared seam's business (internal/shaped). What was here was a flat 800 that
- // a reasoning model spent entirely on deliberation — `finish_reason:"length"`,
- // `content:null`, and the whole standing route failed — followed by a
- // hand-rolled retry at double the room. Both were the ordinary compiler's
- // mistakes made a second time in a second file, which is the argument for
- // there being one seam at all. The echo term is the same one the compile has:
- // a charter restates the rule it is standing up.
- var spec store.CharterSpec
- if _, err := shaped.Answer(ctx, c.client, shaped.Ask{
- Lane: "compile",
- Messages: []ai.Message{
- textMessage("system", standingCompilerPrompt),
- textMessage("user", user),
- },
- Echo: instruction,
- }, &spec); err != nil {
- return Brief{}, fmt.Errorf("compile standing intent: %w", err)
- }
- spec = normalizeCharterSpec(spec, instruction, graphContext)
- return Brief{
- Question: "Stand this rule up?",
- QuestionOptions: []store.QuestionOption{
- {Label: "yes, stand this up", Value: "ratify"},
- {Label: "change when it runs", Value: "cadence"},
- {Label: "once, not standing", Value: "once"},
- },
- Charter: &spec,
- }, nil
-}
-
-func normalizeCharterSpec(spec store.CharterSpec, instruction, graphContext string) store.CharterSpec {
- spec.Invariant = instruction
- spec.Watch = standingWatch(instruction, spec.Watch)
- reminder := isReminder(instruction)
- spec.SayOnly = reminder
- if strings.TrimSpace(spec.Sentinel) == "" {
- spec.Sentinel = "Decide whether the standing condition occurred or the invariant is threatened."
- }
- if reminder {
- spec.Action = reminderAction(instruction)
- } else if strings.TrimSpace(spec.Action) == "" {
- spec.Action = "Re-ground the request at firing time, then carry it out: " + instruction
- }
- // Every figure in the rails is computed from journaled measurement, and the
- // model's own numbers are discarded rather than defaulted around. 13.3's
- // head edge is exactly this line: a proposal that said "$20.00 a run" over a
- // measured $0.0017 because the guess survived whenever the measurement could
- // not be read, and because the justification beside it was free prose with a
- // dollar figure in it. money.go argues the whole rule.
- spec.Rails = standingRails(spec.Rails, reminder, graphContext)
- recurring := store.RecurringWatch(spec.Watch.Spec)
- if strings.TrimSpace(spec.Rails.Expiry) == "" {
- spec.Rails.Expiry = "never"
- // A reminder ends when its one moment passes — and only then. Reading
- // every reminder as one-shot is what killed "remind me every sunday"
- // inside a day; the schedule already knows which kind this is.
- if reminder && !recurring {
- spec.Rails.Expiry = "once"
- }
- }
- if recurring && strings.EqualFold(strings.TrimSpace(spec.Rails.Expiry), "once") {
- spec.Rails.Expiry = "never"
- }
- return spec
-}
-
-// standingWatch keeps the head's human-language reading — which watch family
-// the words imply and the cadence words themselves — and compiles it straight
-// into the engine's typed WatchSpec. The compiler's schedule string survives
-// only as a structured hint (a file glob, a threshold sketch); it is never
-// executed.
-func standingWatch(instruction string, proposed store.CharterWatch) store.CharterWatch {
- lower := strings.ToLower(instruction)
- cadence, guessed := standingCadence(instruction, proposed)
- kind := store.WatchPoll
- switch {
- case strings.Contains(lower, "folder") || strings.Contains(lower, "directory") ||
- strings.Contains(lower, " file") || strings.HasPrefix(lower, "file "):
- kind = store.WatchFile
- if cadence == "" {
- // A file watch has a natural rhythm — the file changing — so this
- // is the family answering, not a rhythm nobody chose.
- cadence, guessed = "on change", false
- }
- case strings.Contains(lower, "node settled") || strings.Contains(lower, "node failed") ||
- strings.Contains(lower, "spend threshold") || strings.Contains(lower, "budget threshold"):
- kind = store.WatchGraph
- if cadence == "" {
- cadence, guessed = "on graph change", false
- }
- case isReminder(instruction) || store.WeekdayNamed(lower) || strings.Contains(lower, "morning") ||
- strings.Contains(lower, "weekday") || strings.Contains(lower, "hourly") ||
- strings.Contains(lower, "daily") || strings.Contains(lower, "weekly"):
- kind = store.WatchCron
- }
- if cadence == "" {
- cadence = "about every 2 minutes"
- if strings.HasPrefix(lower, "keep ") || strings.HasPrefix(lower, "please keep ") {
- cadence = "about every 15 minutes"
- }
- guessed = true
- }
- hint := strings.TrimSpace(proposed.Schedule)
- watch := store.CharterWatch{Kind: kind, Cadence: cadence, Schedule: hint}
- watch.Spec = store.CadenceWatchSpec(kind, cadence, hint, instruction, time.Now())
- watch.Spec.Cadence = cadence
- watch.Spec.CadenceGuessed = guessed
- // The typed derivation degrades underdetermined file and graph watches to
- // a poll; the spec records what will actually run.
- watch.Kind = watch.Spec.Kind
- return watch
-}
-
-// standingCadence reads the rhythm in three descending degrees of authority:
-// the user's own words, then the temporal compiler's reading of them, then
-// nothing.
-//
-// The middle one is the repair. The compiler is asked for "human cadence
-// words" and answers with them, and standingWatch used to throw that answer
-// away — only the structured schedule hint survived, which cron never reads —
-// and substitute a literal two minutes. So a model that had correctly
-// understood "every sunday" watched the sentence say "about every 2 minutes"
-// on the card. The deterministic patterns stay in front of it because they are
-// free and exact; the model catches everything a pattern list never will.
-//
-// The second return is whether the rhythm is a guess. Nothing here invents one
-// silently: an unreadable cadence comes back empty and the caller both defaults
-// AND says that it did.
-func standingCadence(instruction string, proposed store.CharterWatch) (string, bool) {
- if cadence := extractCadence(instruction); cadence != "" {
- return cadence, false
- }
- if cadence := strings.TrimSpace(proposed.Cadence); cadence != "" && store.RecognizedCadence(cadence) {
- return cadence, false
- }
- return "", true
-}
-
-func extractCadence(instruction string) string {
- for _, pattern := range cadencePatterns {
- if cadence := strings.TrimSpace(pattern.FindString(instruction)); cadence != "" {
- return cadence
- }
- }
- return ""
-}
-
-func isReminder(instruction string) bool {
- lower := strings.ToLower(instruction)
- return strings.Contains(lower, "remind me") || strings.Contains(lower, "notify me") ||
- strings.Contains(lower, "alert me")
-}
-
-func normalizeQuestionOptions(options []store.QuestionOption) []store.QuestionOption {
- if len(options) == 0 {
- return nil
- }
- normalized := make([]store.QuestionOption, 0, len(options))
- for _, option := range options {
- option.Label = strings.TrimSpace(option.Label)
- option.Value = strings.TrimSpace(option.Value)
- if option.Label != "" {
- normalized = append(normalized, option)
- }
- }
- if len(normalized) == 0 {
- return nil
- }
- return normalized
-}
-
-// reminderAction lifts the thing to be said out of "remind me to X".
-//
-// The separator is the FIRST " to " after the cue, not the last one. Taking the
-// last one is a plausible-looking mistake that quietly destroys the reminder:
-// "remind me to submit the report to finance" split on the trailing " to " and
-// promised to say "finance". The word after "remind me" begins the message and
-// everything from there is the message, including any further "to" inside it.
-func reminderAction(instruction string) string {
- lower := strings.ToLower(instruction)
- cue := -1
- for _, phrase := range []string{"remind me", "notify me", "alert me"} {
- if index := strings.Index(lower, phrase); index >= 0 && (cue < 0 || index < cue) {
- cue = index + len(phrase)
- }
- }
- if cue < 0 {
- cue = 0
- }
- if separator := strings.Index(lower[cue:], " to "); separator >= 0 {
- message := strings.TrimSpace(instruction[cue+separator+len(" to "):])
- if message != "" {
- return "Say: " + message
- }
- }
- return "Say this reminder: " + strings.TrimSpace(instruction)
-}
diff --git a/internal/head/standing_test.go b/internal/head/standing_test.go
deleted file mode 100644
index 3c2787986a..0000000000
--- a/internal/head/standing_test.go
+++ /dev/null
@@ -1,255 +0,0 @@
-package head
-
-import (
- "context"
- "strings"
- "testing"
- "time"
-
- "github.com/Agent-Field/agentfield/sdk/go/ai"
- "github.com/Agent-Field/codeaf/internal/resident"
- "github.com/Agent-Field/codeaf/internal/store"
-)
-
-// theObservedFailure is the message that shipped the bug, typos and trailing
-// ellipsis included. The routing model read it as a durable preference and
-// wrote a notebook fact; no command, no charter, no ratification card.
-const theObservedFailure = "whenever a new pr comes to agentfield org, make sure to check for security scan and vulnerability..."
-
-// The reading survives; its authority does not. "whenever a new PR comes in…"
-// is still recognized as durable intent, and that reading is now put in front of
-// the loop as evidence — with the consequence spelled out, because commissioning
-// durable intent is not commissioning an errand. What must not happen is what
-// happened: the ask filed as a notebook line, with no command, no charter and no
-// ratification card behind it.
-func TestRecognizedStandingLanguageDraftsACharterFromTheVerbatimAsk(t *testing.T) {
- graph := openHeadStore(t)
- user, err := graph.PostMessage(store.Message{
- SessionID: "standing", Role: store.RoleUser, Body: theObservedFailure,
- })
- if err != nil {
- t.Fatal(err)
- }
- if !RecognizesStandingIntent(theObservedFailure) {
- t.Fatal("the durable ask is no longer recognized as standing intent")
- }
- reading := deterministicReading(t, New(nil, graph), user)
- if !strings.Contains(reading, "reads as DURABLE intent") ||
- !strings.Contains(reading, "standing rule the person is asked to ratify") {
- t.Fatalf("the loop was not told this is durable intent:\n%s", reading)
- }
-
- // The words travel verbatim, because the compiler behind the splice reads
- // the sentence itself: an improved paraphrase is a different rule.
- head, _ := beltHead(graph, beltTurn{calls: []ai.ToolCall{
- beltCall("c1", beltToolTask, map[string]any{"instruction": theObservedFailure})}},
- beltTurn{text: "I'll set that up as a standing rule and check with you before it stands."})
- if err := head.answer(context.Background(), user); err != nil {
- t.Fatal(err)
- }
-
- facts, err := graph.RecentFacts(10)
- if err != nil {
- t.Fatal(err)
- }
- if len(facts) != 0 {
- t.Fatalf("durable ask was noted as a fact instead of drafted: %+v", facts)
- }
- commands, err := graph.PendingCommands(10)
- if err != nil || len(commands) != 1 || commands[0].Kind != store.CommandSplice ||
- commands[0].Instruction != theObservedFailure {
- t.Fatalf("commands = %+v err=%v", commands, err)
- }
- messages, err := graph.Messages("standing", user.Seq, 0)
- if err != nil {
- t.Fatal(err)
- }
- if len(messages) != 1 || messages[0].Role != store.RoleAgent ||
- messages[0].CommandSeq != commands[0].Seq {
- t.Fatalf("thread after recognition = %+v", messages)
- }
-
- // The rest of the path is the compiler's, exactly as it would have been had
- // the routing model chosen splice: one charter draft and one ratification
- // card carrying its options.
- compilerClient := &fakeClient{responses: []string{
- `{"watch":{},"sentinel":"Did a new PR open in the agentfield org?",` +
- `"action":"Check the new PR for security scan and vulnerability findings.","rails":{}}`,
- }}
- compiler := NewCompiler(compilerClient)
- reconciler := resident.New(graph, func(ctx context.Context, instruction, graphContext string) (resident.Compiled, error) {
- brief, err := compiler.Compile(ctx, instruction, graphContext)
- if err != nil {
- return resident.Compiled{}, err
- }
- return resident.Compiled{
- Goal: brief.Goal, Assumptions: brief.Assumptions, Scale: brief.Scale,
- BuildsOn: brief.BuildsOn, Question: brief.Question,
- QuestionOptions: brief.QuestionOptions, Charter: brief.Charter,
- }, nil
- }, nil)
- if err := reconciler.Tick(context.Background()); err != nil {
- t.Fatal(err)
- }
-
- charters, err := graph.Charters()
- if err != nil || len(charters) != 1 {
- t.Fatalf("charters = %+v err=%v", charters, err)
- }
- if charters[0].Invariant != theObservedFailure || charters[0].Status != store.CharterProposed {
- t.Fatalf("charter = %+v", charters[0])
- }
- card := waitForAgentReply(t, graph, "standing", messages[0].Seq)
- if len(card.Options) != 3 || card.Options[0].Value != "charter:ratify:"+charters[0].ID ||
- card.Options[0].Label != "yes, stand this up" {
- t.Fatalf("ratification card = %+v", card)
- }
- if !strings.Contains(card.Body, theObservedFailure) || !strings.Contains(card.Body, "What should I do?") {
- t.Fatalf("ratification question body = %q", card.Body)
- }
-}
-
-func TestNonStandingPhrasingStaysOnTheOrdinaryPath(t *testing.T) {
- // The cue set is unchanged, so the negative case is phrasing the cues
- // already exclude: a one-shot trigger definition, and plain work. ("whenever
- // I say X I mean Y" does still fire the cues; that costs one ratification
- // card, which is the deliberate side of the trade.)
- for _, message := range []string{
- "when i say ship it i mean run the deploy script once",
- "check the last pr for a security scan",
- } {
- t.Run(message, func(t *testing.T) {
- if RecognizesStandingIntent(message) {
- t.Fatalf("cues fired on non-standing phrasing")
- }
- graph := openHeadStore(t)
- client := &fakeClient{responses: []string{"On it."}}
- user, err := graph.PostMessage(store.Message{
- SessionID: "ordinary", Role: store.RoleUser, Body: message,
- })
- if err != nil {
- t.Fatal(err)
- }
- // Nothing here says the ask is durable, so nothing in front of the
- // loop suggests a rule — and one ordinary turn is one call.
- if reading := New(nil, graph).renderHints(user, nil); strings.Contains(reading, "DURABLE intent") {
- t.Fatalf("a one-shot ask was reported as durable:\n%s", reading)
- }
- if err := New(client, graph).answer(context.Background(), user); err != nil {
- t.Fatal(err)
- }
- if calls := client.callCount(); calls != 1 {
- t.Fatalf("provider calls = %d, want 1", calls)
- }
- reply := waitForAgentReply(t, graph, "ordinary", user.Seq)
- if reply.Body != "On it." {
- t.Fatalf("reply = %q, want the model's own answer", reply.Body)
- }
- charters, err := graph.Charters()
- if err != nil || len(charters) != 0 {
- t.Fatalf("charters = %+v err=%v", charters, err)
- }
- })
- }
-}
-
-// The word "once" is two words. As a count it ends an ask — the thing happens
-// a single time and there is nothing durable about it. As a temporal
-// conjunction it OPENS one: the ask does not run now, it runs when a condition
-// the system has to watch for becomes true, which is the definition of a
-// sentinel. The reading here used to be a substring check for " once" that
-// treated every occurrence as the count, so the entire "do this when that
-// happens" class could never reach ratification and was spent immediately as an
-// ordinary one-shot job instead.
-func TestOnceIsReadAsAConjunctionOnlyWhenAClauseFollowsIt(t *testing.T) {
- for _, tc := range []struct {
- said string
- standing bool
- }{
- // The conjunction: a condition follows, so there is something to watch.
- {"remind me once the deploy is green", true},
- {"once it lands, tell me", true},
- {"once its build passes, ship the release notes", true},
- {"once we have the numbers, write them up", true},
- {"tell me once staging is stable", true},
- // The count: nothing follows it but the end of the ask.
- {"run the benchmark once", false},
- {"just do it once", false},
- // Still excluded by the trigger-definition rule, which reads first: the
- // user is defining a phrase, not asking for anything durable.
- {"when i say ship it i mean run the deploy script once", false},
- } {
- t.Run(tc.said, func(t *testing.T) {
- if got := RecognizesStandingIntent(tc.said); got != tc.standing {
- t.Fatalf("RecognizesStandingIntent(%q) = %v, want %v", tc.said, got, tc.standing)
- }
- })
- }
-}
-
-// TestEverySundayCompilesToAWeeklyRuleThatOutlivesItsFirstFiring is the
-// everyday simulation's Monday 08:14, at the layer that read the sentence.
-// "remind me every sunday …" produced no cadence at all, so the head
-// substituted a literal two minutes, and the reminder fired once — two minutes
-// after ratification — and expired before the Sunday it named.
-func TestEverySundayCompilesToAWeeklyRuleThatOutlivesItsFirstFiring(t *testing.T) {
- const said = "remind me every sunday to water the plants and check my mom called"
- if !RecognizesStandingIntent(said) {
- t.Fatal("a weekly reminder is not recognized as standing intent")
- }
- if cadence := extractCadence(said); cadence != "every sunday" {
- t.Fatalf("extracted cadence = %q, want the words she said", cadence)
- }
- spec := normalizeCharterSpec(store.CharterSpec{}, said, "")
- if spec.Watch.Kind != store.WatchCron || spec.Watch.Spec.Cron == nil {
- t.Fatalf("weekly reminder watch = %+v, want a clock rule", spec.Watch)
- }
- schedule := *spec.Watch.Spec.Cron
- if schedule.Kind != store.CronWeekly || schedule.Weekday != time.Sunday || schedule.Hour != 9 {
- t.Fatalf("schedule = %+v, want Sunday at 9", schedule)
- }
- if spec.Watch.Spec.CadenceGuessed {
- t.Fatal("a cadence the user said was recorded as a guess")
- }
- if spec.Rails.Expiry != "never" {
- t.Fatalf("recurring reminder expiry = %q, want never", spec.Rails.Expiry)
- }
- // The stated clock survives, and so does the day.
- timed := normalizeCharterSpec(store.CharterSpec{}, "remind me every sunday at 8pm to take the bins out", "")
- if got := *timed.Watch.Spec.Cron; got.Kind != store.CronWeekly ||
- got.Weekday != time.Sunday || got.Hour != 20 || got.Minute != 0 {
- t.Fatalf("schedule = %+v, want Sunday at 20:00", got)
- }
- // A reminder that genuinely happens once still expires once.
- once := normalizeCharterSpec(store.CharterSpec{}, "remind me tomorrow at 9 to call the dentist", "")
- if once.Rails.Expiry != "once" {
- t.Fatalf("one-shot reminder expiry = %q, want once", once.Rails.Expiry)
- }
-}
-
-// TestTheCompilersOwnCadenceIsUsedRatherThanReplaced covers the discarded
-// reading: the temporal compiler is asked for human cadence words, answers with
-// them, and the head threw the answer away in favour of a default.
-func TestTheCompilersOwnCadenceIsUsedRatherThanReplaced(t *testing.T) {
- // A sentence no deterministic pattern reads, with a model that read it.
- watch := standingWatch("keep an eye on the release queue",
- store.CharterWatch{Cadence: "every 30 minutes", Schedule: "queue depth"})
- if watch.Cadence != "every 30 minutes" {
- t.Fatalf("cadence = %q, want the compiler's own reading", watch.Cadence)
- }
- if watch.Spec.CadenceGuessed {
- t.Fatal("a cadence the compiler read was recorded as a guess")
- }
- if watch.Spec.Poll == nil || watch.Spec.Poll.Cadence != 30*time.Minute {
- t.Fatalf("poll watch = %+v, want a 30 minute cadence", watch.Spec.Poll)
- }
-
- // A compiler reading that says nothing about time cannot rescue anything,
- // and the default that follows announces itself as a guess.
- guessed := standingWatch("watch for when the dyson v15 drops under 500",
- store.CharterWatch{Cadence: "when the price drops"})
- if guessed.Cadence != "about every 2 minutes" || !guessed.Spec.CadenceGuessed {
- t.Fatalf("unreadable cadence = %q guessed=%t, want an announced default",
- guessed.Cadence, guessed.Spec.CadenceGuessed)
- }
-}
diff --git a/internal/head/standing_watch_test.go b/internal/head/standing_watch_test.go
deleted file mode 100644
index 9cfdea131c..0000000000
--- a/internal/head/standing_watch_test.go
+++ /dev/null
@@ -1,184 +0,0 @@
-package head
-
-import (
- "context"
- "os"
- "strings"
- "testing"
- "time"
-
- "github.com/Agent-Field/codeaf/internal/store"
-)
-
-func TestStandingWatchQuestionChoicesBecomeGlobalCommandsWithoutProviderCall(t *testing.T) {
- for _, test := range []struct {
- reply string
- kind store.CommandKind
- }{
- {reply: "yes", kind: store.CommandStandingWatchEnable},
- {reply: "no", kind: store.CommandStandingWatchDecline},
- } {
- t.Run(test.reply, func(t *testing.T) {
- graph := openHeadStore(t)
- charter, err := store.NewCharter("watch-choice", "Keep releases documented.", store.WatchSpec{
- Kind: store.WatchPoll, Poll: &store.PollWatch{Condition: "look for a release", Cadence: time.Hour},
- }, "Did a release land?", store.CharterAction{Template: "Update the release notes"},
- store.CharterRails{PerFiringBudgetUSD: 0.1, MaxFiringsPerDay: 3}, store.CharterActive,
- store.Ratification{Origin: store.OriginUser, SessionID: "watch-head", Evidence: "yes"})
- if err != nil {
- t.Fatal(err)
- }
- if err := graph.CreateCharter(charter); err != nil {
- t.Fatal(err)
- }
- if posted, err := graph.OfferStandingWatch("watch-head", charter.ID); err != nil || !posted {
- t.Fatalf("offer posted=%t err=%v", posted, err)
- }
- user, err := graph.PostMessage(store.Message{SessionID: "watch-head", Role: store.RoleUser, Body: test.reply})
- if err != nil {
- t.Fatal(err)
- }
- client := &fakeClient{}
- if err := New(client, graph).answer(context.Background(), user); err != nil {
- t.Fatal(err)
- }
- if client.callCount() != 0 {
- t.Fatal("standing-watch choice fell through to provider")
- }
- commands, err := graph.PendingCommands(0)
- if err != nil || len(commands) != 1 || commands[0].Kind != test.kind || commands[0].Target != "" {
- t.Fatalf("commands = %+v err=%v", commands, err)
- }
- })
- }
-}
-
-func TestUnrelatedReplyNeverSpendsTheSingleStandingWatchQuestion(t *testing.T) {
- graph := openHeadStore(t)
- charter, err := store.NewCharter("watch-freetext", "Keep releases documented.", store.WatchSpec{
- Kind: store.WatchPoll, Poll: &store.PollWatch{Condition: "look for a release", Cadence: time.Hour},
- }, "Did a release land?", store.CharterAction{Template: "Update the release notes"},
- store.CharterRails{PerFiringBudgetUSD: 0.1, MaxFiringsPerDay: 3}, store.CharterActive,
- store.Ratification{Origin: store.OriginUser, SessionID: "watch-head", Evidence: "yes"})
- if err != nil {
- t.Fatal(err)
- }
- if err := graph.CreateCharter(charter); err != nil {
- t.Fatal(err)
- }
- if posted, err := graph.OfferStandingWatch("watch-head", charter.ID); err != nil || !posted {
- t.Fatalf("offer posted=%t err=%v", posted, err)
- }
- questions, err := graph.UnresolvedQuestions(10)
- if err != nil || len(questions) != 1 {
- t.Fatalf("unresolved questions = %+v err=%v", questions, err)
- }
- user, err := graph.PostMessage(store.Message{
- SessionID: "watch-head", Role: store.RoleUser, Body: "what does that actually change?",
- })
- if err != nil {
- t.Fatal(err)
- }
- client := &fakeClient{responses: []string{
- `{"reply":"It decides whether checks continue with no terminal open.","command":null}`,
- }}
- if err := New(client, graph).answer(context.Background(), user); err != nil {
- t.Fatal(err)
- }
- commands, err := graph.PendingCommands(0)
- if err != nil || len(commands) != 0 {
- t.Fatalf("free text produced commands = %+v err=%v", commands, err)
- }
- decision, err := graph.StandingWatchDecisionState()
- if err != nil || decision != store.StandingWatchOffered {
- t.Fatalf("decision = %q err=%v", decision, err)
- }
- still, err := graph.UnresolvedQuestions(10)
- if err != nil || len(still) != 1 {
- t.Fatalf("standing-watch question was spent by free text: %+v err=%v", still, err)
- }
-}
-
-// TestStandingWatchIsABeltReadNotAPhraseList is the values assertion for the
-// deletion. Two substring gates decided whether the measured competence map and
-// the standing-watch status reached the prompt at all — twelve phrases and
-// fourteen — and neither list contained "am I asking too much of you lately?"
-// or any of the thousand other ways a person asks it. The law lives in the tool
-// description now; the gates must stay gone rather than come back one phrase at
-// a time, so this test asserts about the package rather than about a sentence.
-func TestStandingWatchIsABeltReadNotAPhraseList(t *testing.T) {
- source := readHeadSource(t, "head.go")
- for _, gate := range []string{"asksForCompetence", "asksForStandingWatch"} {
- if strings.Contains(source, "func "+gate) {
- t.Errorf("%s is back: presence is a substring match again", gate)
- }
- }
- // And what replaced them is reachable: both reads are on the belt the loop
- // is handed, described well enough that the model knows when to spend a call.
- named := map[string]bool{}
- for _, definition := range beltDefinitions() {
- named[definition.Function.Name] = true
- if definition.Function.Name == beltToolCompetence || definition.Function.Name == beltToolStanding {
- if len(definition.Function.Description) < 80 {
- t.Errorf("%s has no description worth reading", definition.Function.Name)
- }
- }
- }
- for _, tool := range []string{beltToolCompetence, beltToolStanding, beltToolSpending} {
- if !named[tool] {
- t.Errorf("the %s read never reached the belt", tool)
- }
- }
-}
-
-// The reads themselves: what the surface registered is what the model is handed,
-// byte-capped, and a surface that registered nothing says so rather than erroring.
-func TestSelfReadsReturnTheirGroundTruthAndRecordNothing(t *testing.T) {
- graph := openHeadStore(t)
- head := New(nil, graph).
- WithCompetenceMap(func() string {
- return `- {"scope":"tool:go","class":"strong","samples":8,"failure_rate":0}`
- }).
- WithStandingWatch(func() string {
- return "standing watch installed · last wake 2m ago · next check in 3m\n" +
- "active charters (id · cadence · what it watches for):\n" +
- "charter-1 · every weekday at 9 · keep the release notes current"
- }).
- WithDailyBudgetUSD(20)
- run := &beltRun{head: head, user: store.Message{SessionID: "self", Body: "how are you doing lately?"}}
-
- competence, failed := run.competence()
- if failed || !strings.Contains(competence, `"scope":"tool:go"`) {
- t.Fatalf("competence read failed=%t: %s", failed, competence)
- }
- standing, failed := run.standing()
- if failed || !strings.Contains(standing, "next check in 3m") ||
- !strings.Contains(standing, "keep the release notes current") {
- t.Fatalf("standing read failed=%t: %s", failed, standing)
- }
- spending, failed := run.spending(nil)
- if failed || !strings.Contains(spending, "daily rail") ||
- !strings.Contains(spending, "your own upkeep today: $0.00") {
- t.Fatalf("spending read failed=%t: %s", failed, spending)
- }
- if run.acted {
- t.Fatal("reading the employee's own account recorded an action")
- }
-
- bare := &beltRun{head: New(nil, graph), user: run.user}
- if answer, failed := bare.competence(); failed || strings.Contains(answer, "error") {
- t.Fatalf("an unregistered competence map errored instead of answering: %s", answer)
- }
- if answer, failed := bare.standing(); failed || strings.Contains(answer, "error") {
- t.Fatalf("an unregistered watch status errored instead of answering: %s", answer)
- }
-}
-
-func readHeadSource(t *testing.T, name string) string {
- t.Helper()
- raw, err := os.ReadFile(name)
- if err != nil {
- t.Fatalf("read %s: %v", name, err)
- }
- return string(raw)
-}
diff --git a/internal/head/stop.go b/internal/head/stop.go
index 4c53940518..7590f9708b 100644
--- a/internal/head/stop.go
+++ b/internal/head/stop.go
@@ -15,12 +15,12 @@ import (
// deserves an unambiguous door, and a gate that fires on a sentence the judge
// was still interpreting would be a gate on nothing.
//
-// One tool covers four kinds because the person's intent is one intent: they
+// One tool covers three kinds because the person's intent is one intent: they
// point at things they own and say stop. What "stop" means for each kind is the
-// store's own vocabulary — cancel work, retire a rule or a learned way, stop a
-// service — and the only reading taken from the words is whether they want it
-// HELD rather than ended, which the two kinds that support holding honour and
-// the two that do not ignore.
+// store's own vocabulary — cancel work, retire a learned way, stop a service —
+// and the only reading taken from the words is whether they want it HELD rather
+// than ended, which work honours and the other two ignore. (Standing rules were
+// a fourth kind, and went with the v1 scheduler.)
//
// The consent machinery is untouched and reached by the same road control used:
// beltSet resolves the ids over their open subtrees under the store's legality
@@ -43,10 +43,10 @@ func (run *beltRun) stop(args map[string]any) (string, bool) {
}
if len(targets) == 0 {
// A withdrawal that knows what it wants to do and not what to do it to is
- // an ordinary thing to say. The union of the live jobs and the standing
- // rules the words reach is the honest answer — handed back as candidates
- // rather than acted on, because choosing for the person is how a request
- // to withdraw fourteen queued tasks became "Cancelling line-scan."
+ // an ordinary thing to say. The live jobs the words reach are the honest
+ // answer — handed back as candidates rather than acted on, because
+ // choosing for the person is how a request to withdraw fourteen queued
+ // tasks became "Cancelling line-scan."
return run.controlCandidates(store.CommandCancel, strings.TrimSpace(beltString(args, "words")))
}
if len(targets) > beltControlIDCap {
@@ -58,7 +58,6 @@ func (run *beltRun) stop(args map[string]any) (string, bool) {
holding := stopHolds(words)
var jobs []string
- var rules []store.Charter
var services []store.Service
var crafts []string
for _, target := range targets {
@@ -81,8 +80,6 @@ func (run *beltRun) stop(args map[string]any) (string, bool) {
switch found := run.head.resolveTarget(target); found.kind {
case targetJob:
jobs = append(jobs, found.job.ID)
- case targetRule:
- rules = append(rules, found.rule)
case targetService:
services = append(services, found.service)
case targetCraft:
@@ -105,20 +102,6 @@ func (run *beltRun) stop(args map[string]any) (string, bool) {
}
said = append(said, answer)
}
- for _, rule := range rules {
- kind := store.CommandCharterRetire
- if holding {
- kind = store.CommandCharterPause
- }
- command, err := run.head.store.RequestCommand(store.Command{
- SessionID: run.user.SessionID, Kind: kind, Target: rule.ID, Instruction: string(kind),
- })
- if err != nil {
- return "that could not be queued: " + err.Error(), true
- }
- run.record(command.Seq, charterAcknowledgement(kind))
- said = append(said, strings.ToLower(strings.TrimSuffix(charterAcknowledgement(kind), ".")))
- }
for _, service := range services {
command, err := run.head.store.RequestCommand(store.Command{
SessionID: run.user.SessionID, Kind: store.CommandServiceStop,
diff --git a/internal/head/surgery_test.go b/internal/head/surgery_test.go
index 77bae69aeb..b5b4c3c696 100644
--- a/internal/head/surgery_test.go
+++ b/internal/head/surgery_test.go
@@ -24,7 +24,6 @@ func TestNodeSurgeryRecognitionTable(t *testing.T) {
{"do the tests first", store.CommandReprioritize, "surgery"},
{"restart the failed one", store.CommandRestart, "surgery"},
{"resume the migration", store.CommandResume, "surgery"},
- {"stop watching PRs", store.CommandCharterRetire, "charter"},
{"write the tests before the implementation", "", "new"},
{"create a pause button", "", "new"},
{"explain how to cancel a subscription", "", "new"},
@@ -32,19 +31,15 @@ func TestNodeSurgeryRecognitionTable(t *testing.T) {
}
for _, test := range tests {
t.Run(test.message, func(t *testing.T) {
- charterIntent, charter := charterManagement(test.message)
intent, surgery := nodeSurgery(test.message)
route := "new"
kind := store.CommandKind("")
- if charter && (strings.Contains(strings.ToLower(test.message), "watch") ||
- strings.Contains(strings.ToLower(test.message), "monitor")) {
- route, kind = "charter", charterIntent.Kind
- } else if surgery {
+ if surgery {
route, kind = "surgery", intent.Kind
}
if route != test.route || kind != test.kind {
- t.Fatalf("route/kind = %s/%s, want %s/%s (charter=%t surgery=%t)",
- route, kind, test.route, test.kind, charter, surgery)
+ t.Fatalf("route/kind = %s/%s, want %s/%s (surgery=%t)",
+ route, kind, test.route, test.kind, surgery)
}
})
}
@@ -172,10 +167,9 @@ func TestSurgeryReferentResolutionUniqueAmbiguousAndNone(t *testing.T) {
})
}
-// "pause the migration while I think" is about work, and the standing-rule
-// reader must not claim it: a rule edit and a job pause share their verb, and
-// the difference is whether a rule exists to mean.
-func TestOrdinaryPauseFallsThroughCharterManagementToSurgery(t *testing.T) {
+// "pause the migration while I think" is about work: the verb reading reaches
+// the loop, and the stop tool aimed at the job journals the pause.
+func TestOrdinaryPauseReachesSurgery(t *testing.T) {
graph := openHeadStore(t)
spliceSurgeryJob(t, graph, "migration", "Schema migration", "migrate the schema")
user := postUser(t, graph, "pause-work", "pause the migration while I think")
@@ -184,9 +178,6 @@ func TestOrdinaryPauseFallsThroughCharterManagementToSurgery(t *testing.T) {
if !strings.Contains(reading, `carries the verb "pause" aimed at existing work`) {
t.Fatalf("the pause reading never reached the loop:\n%s", reading)
}
- if candidates, err := New(nil, graph).charterCandidates(charterReference("pause the migration while i think", "")); err != nil || len(candidates) != 0 {
- t.Fatalf("charter management claimed a pause aimed at work: %+v err=%v", candidates, err)
- }
head, _ := beltHead(graph, beltTurn{calls: []ai.ToolCall{
beltCall("c1", beltToolStop, map[string]any{
diff --git a/internal/head/targets.go b/internal/head/targets.go
index 76768bcbfb..861a6af2e6 100644
--- a/internal/head/targets.go
+++ b/internal/head/targets.go
@@ -15,35 +15,24 @@ import (
// that did returns what it did. What is gone is the door's monopoly.
//
// The candidate reader below survives too. A verb that knows what it wants to do
-// and not what to do it to is still an ordinary thing to say, and the union of
-// "live jobs the words reach" and "standing rules the words reach" is still the
-// only honest answer to it. It is a READ now: the control tool hands the list
-// back to the loop, which names the ids, rather than posting a question the loop
-// would then talk over.
+// and not what to do it to is still an ordinary thing to say, and the live jobs
+// the words reach are the only honest answer to it. It is a READ now: the
+// control tool hands the list back to the loop, which names the ids, rather than
+// posting a question the loop would then talk over. Standing rules used to join
+// the list beside the jobs; they went with the v1 scheduler.
// describedTarget is one thing a verb with no target could have meant: a job on
-// the board, or a standing rule. The two are matched by machinery that already
-// exists and answers the same question in its own half of the world; this only
-// puts the two answers in one list, because the person asking "stand down the
-// stretch reminder" did not say which half they were pointing at and should not
-// have to.
+// the board.
type describedTarget struct {
- job store.SurgeryTarget
- rule store.Charter
+ job store.SurgeryTarget
}
-func (candidate describedTarget) isRule() bool { return candidate.rule.ID != "" }
+// describedTargetCap bounds one candidate list: past a handful, this is a list
+// to search rather than a choice to make.
+const describedTargetCap = 4
-// describedTargetCap bounds one candidate list. It is the charter askback's cap
-// for the same reason: past a handful, this is a list to search rather than a
-// choice to make.
-const describedTargetCap = charterAskbackCap
-
-// describedTargets is the union of the two candidate readers the head already
-// trusts: the surgery search over live work, and the charter search over
-// standing rules. A rule joins the list only for the verbs a rule can actually
-// answer — the mapping below is the store's own table, not a guess — so a
-// candidate offered here is always a candidate that can be journaled.
+// describedTargets is the surgery search over live work, the candidate reader
+// the head already trusts, capped to a choice a person can make.
func (h *Head) describedTargets(kind store.CommandKind, description string) ([]describedTarget, error) {
lower := strings.ToLower(strings.TrimSpace(description))
jobs, err := h.surgeryMatches(surgeryIntent{
@@ -57,35 +46,9 @@ func (h *Head) describedTargets(kind store.CommandKind, description string) ([]d
if len(jobs) > describedTargetCap {
jobs = jobs[:describedTargetCap]
}
- candidates := make([]describedTarget, 0, len(jobs)+describedTargetCap)
+ candidates := make([]describedTarget, 0, len(jobs))
for _, job := range jobs {
candidates = append(candidates, describedTarget{job: job})
}
- if _, ok := charterTransition(kind); !ok {
- return candidates, nil
- }
- rules, err := h.charterCandidates(charterReference(lower, ""))
- if err != nil {
- return nil, err
- }
- for _, rule := range rules {
- candidates = append(candidates, describedTarget{rule: rule})
- }
return candidates, nil
}
-
-// charterTransition maps a verb aimed at work onto the durable transition the
-// same verb means for a standing rule. Only these two exist: standing a rule
-// down is retiring it, and holding it is pausing it. A verb with no entry here
-// simply cannot be about a rule, and the candidate list says so by leaving
-// rules out rather than by offering one it would have to refuse.
-func charterTransition(kind store.CommandKind) (store.CommandKind, bool) {
- switch kind {
- case store.CommandCancel:
- return store.CommandCharterRetire, true
- case store.CommandPause:
- return store.CommandCharterPause, true
- default:
- return "", false
- }
-}
diff --git a/internal/head/tenure_test.go b/internal/head/tenure_test.go
deleted file mode 100644
index 3d0798b8eb..0000000000
--- a/internal/head/tenure_test.go
+++ /dev/null
@@ -1,284 +0,0 @@
-package head
-
-import (
- "context"
- "strconv"
- "sync"
- "testing"
- "time"
-
- "github.com/Agent-Field/codeaf/internal/resident"
- "github.com/Agent-Field/codeaf/internal/store"
-)
-
-func TestProbationApprovalFromQuestionDockFiresCharter(t *testing.T) {
- graph := openHeadStore(t)
- charter, _, _ := proposeHeadTestFiring(t, graph, "dock-approval", "charter-origin")
- questions, err := graph.PendingQuestions("live-dock", 0)
- if err != nil || len(questions) != 1 {
- t.Fatalf("pending questions = %+v err=%v", questions, err)
- }
- question := questions[0]
- if surfaced, err := graph.SurfaceQuestionForSession(question.Seq, "live-dock"); err != nil {
- t.Fatal(err)
- } else if surfaced.SessionID != "live-dock" {
- t.Fatalf("dock surfaced question into %q", surfaced.SessionID)
- }
- user, err := graph.PostMessage(store.Message{
- SessionID: "live-dock", Role: store.RoleUser, Body: "yes", QuestionSeq: question.Seq,
- })
- if err != nil {
- t.Fatal(err)
- }
- client := &fakeClient{}
- if err := New(client, graph).answer(context.Background(), user); err != nil {
- t.Fatal(err)
- }
- if client.callCount() != 0 {
- t.Fatal("dock approval fell through to provider")
- }
- resolved, found, err := graph.AgentQuestionBySeq(question.Seq)
- if err != nil || !found || resolved.Status != store.QuestionAnswered {
- t.Fatalf("resolved question = %+v found=%t err=%v", resolved, found, err)
- }
- if err := headTestFiringReconciler(graph, charter.ID).Tick(context.Background()); err != nil {
- t.Fatal(err)
- }
- if got := countHeadTenureEvents(t, graph, store.EventCharterFired); got != 1 {
- t.Fatalf("charter firing events = %d, want 1", got)
- }
-}
-
-func TestProbationMessageOptionResolvesDockQuestion(t *testing.T) {
- graph := openHeadStore(t)
- _, _, proposal := proposeHeadTestFiring(t, graph, "message-approval", "charter-origin")
- user, err := graph.PostMessage(store.Message{
- SessionID: "live-message", Role: store.RoleUser, Body: "approve this time",
- QuestionSeq: proposal.QuestionSeq,
- })
- if err != nil {
- t.Fatal(err)
- }
- if err := New(&fakeClient{}, graph).answer(context.Background(), user); err != nil {
- t.Fatal(err)
- }
- resolved, found, err := graph.AgentQuestionBySeq(proposal.QuestionSeq)
- if err != nil || !found || resolved.Status != store.QuestionAnswered || resolved.AnswerMessageSeq != user.Seq {
- t.Fatalf("resolved question = %+v found=%t err=%v", resolved, found, err)
- }
- if pending, err := graph.PendingQuestions("live-message", 0); err != nil || len(pending) != 0 {
- t.Fatalf("resolved proposal remained in dock: %+v err=%v", pending, err)
- }
- commands, err := graph.PendingCommands(0)
- if err != nil || len(commands) != 1 || commands[0].Kind != store.CommandCharterFire {
- t.Fatalf("approval commands = %+v err=%v", commands, err)
- }
-}
-
-func TestEmptySessionProbationProposalIsAnswerableFromLiveSession(t *testing.T) {
- graph := openHeadStore(t)
- charter, wakeSeq, proposal := proposeHeadTestFiring(t, graph, "neutral-proposal", "")
- if proposal.SessionID != "" {
- t.Fatalf("proposal session = %q, want neutral", proposal.SessionID)
- }
- user, err := graph.PostMessage(store.Message{SessionID: "live-session", Role: store.RoleUser, Body: "yes"})
- if err != nil {
- t.Fatal(err)
- }
- client := &fakeClient{}
- if err := New(client, graph).answer(context.Background(), user); err != nil {
- t.Fatal(err)
- }
- if client.callCount() != 0 {
- t.Fatal("neutral proposal fell through to provider")
- }
- commands, err := graph.PendingCommands(0)
- if err != nil || len(commands) != 1 || commands[0].Target != charter.ID ||
- commands[0].Instruction != "wake:"+strconv.FormatInt(wakeSeq, 10) {
- t.Fatalf("neutral proposal commands = %+v err=%v", commands, err)
- }
- resolved, found, err := graph.AgentQuestionBySeq(proposal.QuestionSeq)
- if err != nil || !found || resolved.Status != store.QuestionAnswered {
- t.Fatalf("neutral question = %+v found=%t err=%v", resolved, found, err)
- }
-}
-
-func TestRacingDockAndMessageAnswersFireProbationWakeExactlyOnce(t *testing.T) {
- graph := openHeadStore(t)
- charter, _, proposal := proposeHeadTestFiring(t, graph, "racing-approval", "charter-origin")
- if _, err := graph.SurfaceQuestionForSession(proposal.QuestionSeq, "dock-session"); err != nil {
- t.Fatal(err)
- }
- messageAnswer, err := graph.PostMessage(store.Message{
- SessionID: "message-session", Role: store.RoleUser, Body: "approve this time",
- QuestionSeq: proposal.QuestionSeq,
- })
- if err != nil {
- t.Fatal(err)
- }
- dockAnswer, err := graph.PostMessage(store.Message{
- SessionID: "dock-session", Role: store.RoleUser, Body: "yes", QuestionSeq: proposal.QuestionSeq,
- })
- if err != nil {
- t.Fatal(err)
- }
-
- head := New(&fakeClient{}, graph)
- start := make(chan struct{})
- errs := make(chan error, 2)
- var wait sync.WaitGroup
- for _, answer := range []store.Message{messageAnswer, dockAnswer} {
- answer := answer
- wait.Add(1)
- go func() {
- defer wait.Done()
- <-start
- errs <- head.answer(context.Background(), answer)
- }()
- }
- close(start)
- wait.Wait()
- close(errs)
- for err := range errs {
- if err != nil {
- t.Fatalf("racing answer: %v", err)
- }
- }
- commands, err := graph.PendingCommands(0)
- if err != nil || len(commands) != 1 || commands[0].Kind != store.CommandCharterFire {
- t.Fatalf("racing approval commands = %+v err=%v", commands, err)
- }
- if err := headTestFiringReconciler(graph, charter.ID).Tick(context.Background()); err != nil {
- t.Fatal(err)
- }
- if got := countHeadTenureEvents(t, graph, store.EventCharterFired); got != 1 {
- t.Fatalf("racing charter firing events = %d, want 1", got)
- }
- resolved, found, err := graph.AgentQuestionBySeq(proposal.QuestionSeq)
- if err != nil || !found || resolved.Status != store.QuestionAnswered {
- t.Fatalf("racing question = %+v found=%t err=%v", resolved, found, err)
- }
-}
-
-func TestProbationQuestionChoicesUsePendingCharterCommands(t *testing.T) {
- for _, test := range []struct {
- reply string
- wantKind store.CommandKind
- }{
- {reply: "yes", wantKind: store.CommandCharterFire},
- {reply: "no", wantKind: store.CommandCharterDecline},
- {reply: "always allow", wantKind: store.CommandCharterAlways},
- {reply: "never", wantKind: store.CommandCharterNever},
- } {
- t.Run(test.reply, func(t *testing.T) {
- graph := openHeadStore(t)
- charter, err := store.NewCharter("probation-choice", "Keep releases documented.", store.WatchSpec{
- Kind: store.WatchPoll, Poll: &store.PollWatch{Condition: "look for a release", Cadence: time.Hour},
- }, "Did a release land?", store.CharterAction{Template: "Update the release notes"},
- store.CharterRails{PerFiringBudgetUSD: 0.1, MaxFiringsPerDay: 3}, store.CharterActive,
- store.Ratification{Origin: store.OriginUser, SessionID: "tenure-head", Evidence: "yes"})
- if err != nil {
- t.Fatal(err)
- }
- if err := graph.CreateCharter(charter); err != nil {
- t.Fatal(err)
- }
- wakeSeq, err := graph.BeginCharterWake(charter.ID, time.Now(), "release found", store.CharterWatchState{
- NextDue: time.Now().Add(time.Hour),
- })
- if err != nil {
- t.Fatal(err)
- }
- if err := graph.RecordSentinelCheck(charter.ID, store.SentinelCheck{WakeSeq: wakeSeq, Yes: true}); err != nil {
- t.Fatal(err)
- }
- if posted, err := graph.ProposeCharterFiring(charter.ID, wakeSeq, charter.Action.Template); err != nil || !posted {
- t.Fatalf("proposal posted=%t err=%v", posted, err)
- }
- user, err := graph.PostMessage(store.Message{SessionID: "tenure-head", Role: store.RoleUser, Body: test.reply})
- if err != nil {
- t.Fatal(err)
- }
- client := &fakeClient{}
- if err := New(client, graph).answer(context.Background(), user); err != nil {
- t.Fatal(err)
- }
- if client.callCount() != 0 {
- t.Fatal("structured probation choice fell through to provider")
- }
- pending, err := graph.PendingCommands(0)
- if err != nil || len(pending) != 1 {
- t.Fatalf("pending commands=%+v err=%v", pending, err)
- }
- if pending[0].Kind != test.wantKind || pending[0].Target != charter.ID ||
- pending[0].Instruction != "wake:"+strconv.FormatInt(wakeSeq, 10) {
- t.Fatalf("probation command = %+v", pending[0])
- }
- })
- }
-}
-
-func proposeHeadTestFiring(t *testing.T, graph *store.Store, id, charterSession string) (store.Charter, int64, store.Message) {
- t.Helper()
- charter, err := store.NewCharter(id, "Keep releases documented.", store.WatchSpec{
- Kind: store.WatchPoll, Poll: &store.PollWatch{Condition: "look for a release", Cadence: time.Hour},
- }, "Did a release land?", store.CharterAction{Template: "Update the release notes"},
- store.CharterRails{PerFiringBudgetUSD: 0.1, MaxFiringsPerDay: 3}, store.CharterActive,
- store.Ratification{Origin: store.OriginUser, SessionID: charterSession, Evidence: "yes"})
- if err != nil {
- t.Fatal(err)
- }
- if err := graph.CreateCharter(charter); err != nil {
- t.Fatal(err)
- }
- wakeSeq, err := graph.BeginCharterWake(charter.ID, time.Now(), "release found", store.CharterWatchState{
- NextDue: time.Now().Add(time.Hour),
- })
- if err != nil {
- t.Fatal(err)
- }
- if err := graph.RecordSentinelCheck(charter.ID, store.SentinelCheck{WakeSeq: wakeSeq, Yes: true}); err != nil {
- t.Fatal(err)
- }
- if posted, err := graph.ProposeCharterFiring(charter.ID, wakeSeq, charter.Action.Template); err != nil || !posted {
- t.Fatalf("proposal posted=%t err=%v", posted, err)
- }
- messages, err := graph.Messages("", 0, 0)
- if err != nil {
- t.Fatal(err)
- }
- for _, message := range messages {
- if message.NodeID == charter.ID && message.QuestionSeq != 0 && len(message.Options) == 4 {
- return charter, wakeSeq, message
- }
- }
- t.Fatal("proposal message not found")
- return store.Charter{}, 0, store.Message{}
-}
-
-func headTestFiringReconciler(graph *store.Store, charterID string) *resident.Reconciler {
- return resident.New(graph,
- func(_ context.Context, instruction, _ string) (resident.Compiled, error) {
- return resident.Compiled{Goal: instruction}, nil
- },
- func(_ context.Context, compiled resident.Compiled) (store.Subtree, error) {
- return store.Subtree{Nodes: []store.NodeSpec{{
- ID: charterID + "-approved-job", Brief: compiled.Goal, Stage: 1,
- }}}, nil
- })
-}
-
-func countHeadTenureEvents(t *testing.T, graph *store.Store, kind store.EventKind) int {
- t.Helper()
- events, err := graph.Events(0, 0)
- if err != nil {
- t.Fatal(err)
- }
- count := 0
- for _, event := range events {
- if event.Kind == kind {
- count++
- }
- }
- return count
-}
diff --git a/internal/head/toolbelt.go b/internal/head/toolbelt.go
index f3e7818a36..14e786d675 100644
--- a/internal/head/toolbelt.go
+++ b/internal/head/toolbelt.go
@@ -83,16 +83,17 @@ const (
// shortens the first because the bytes are a means; it stays away from the
// second because here the bytes are the answer.
beltToolRead = "read"
- // beltToolCompetence, beltToolStanding and beltToolSpending are the three
- // reads that are about the employee rather than the work. Each was already
- // measured, journalled and rendered somewhere else — the competence map for
- // the router, the watch status for doctor, self-spend for the rail — and
- // each reached a conversation through a hardcoded list of substrings or not
- // at all. They are tools for the reason everything else here is a tool: the
- // question "am I asking too much of you lately?" matches no list anybody
- // will ever finish writing, and a model holding the read can recognise it.
+ // beltToolCompetence and beltToolSpending are the reads that are about the
+ // employee rather than the work. Each was already measured, journalled and
+ // rendered somewhere else — the competence map for the router, self-spend
+ // for the rail — and each reached a conversation through a hardcoded list
+ // of substrings or not at all. They are tools for the reason everything else
+ // here is a tool: the question "am I asking too much of you lately?" matches
+ // no list anybody will ever finish writing, and a model holding the read can
+ // recognise it. A third, `standing`, read the background timer's watch; it
+ // went with the v1 scheduler, because a read with nothing behind it is
+ // absent, not broken.
beltToolCompetence = "competence"
- beltToolStanding = "standing"
beltToolSpending = "spending"
// beltToolHistory is the read that makes time a dimension of this belt
// rather than a word in it. Every other read is topical — board is BM25 over
@@ -292,7 +293,7 @@ func beltProp(kind, description string) map[string]any {
func beltReadOnly(name string) bool {
switch strings.TrimSpace(name) {
case beltToolBoard, beltToolResult, beltToolPlan, beltToolRead, beltToolManual,
- beltToolCompetence, beltToolStanding, beltToolSpending, beltToolHistory,
+ beltToolCompetence, beltToolSpending, beltToolHistory,
beltToolSearch, beltToolThread,
// The lens (lens.go). All three change nothing: one searches, one opens,
// one reports. They join the closed set for the same reason the others
@@ -336,11 +337,11 @@ func beltDefinitions() []ai.ToolDefinition {
"separate": beltProp("boolean", `true ONLY when they say in so many words that this is a job BESIDE work already waiting — "as well as", "a separate one"`),
"model": beltProp("string", "the model they named for this job, in their own words; omit unless they named one"),
}, "instruction"),
- beltTool(beltToolChange, "Change something already under way or already standing, by handing over their words: a live job, a standing rule, a service, or a learned way of working. Pass what they said VERBATIM and never pick a verb — what the words mean for the work is decided by whoever holds the plan, and you are told what it came to.", map[string]any{
- "target": beltProp("string", "one id from a read: a job id, a standing rule's id, a service's name, or a learned way's own name"),
+ beltTool(beltToolChange, "Change something already under way, by handing over their words: a live job, a service, or a learned way of working. Pass what they said VERBATIM and never pick a verb — what the words mean for the work is decided by whoever holds the plan, and you are told what it came to.", map[string]any{
+ "target": beltProp("string", "one id from a read: a job id, a service's name, or a learned way's own name"),
"words": beltProp("string", "the user's message, verbatim"),
}, "target"),
- beltTool(beltToolStop, "Withdraw things: cancel live work, retire a standing rule or a learned way of working, stop a service. Name the ids from a read; \"everything\" is the total one. Call it with no targets and their words to get the candidates back.", map[string]any{
+ beltTool(beltToolStop, "Withdraw things: cancel live work, retire a learned way of working, stop a service. Name the ids from a read; \"everything\" is the total one. Call it with no targets and their words to get the candidates back.", map[string]any{
"targets": map[string]any{"type": "array", "description": `ids from a read, or the single word "everything"`,
"items": map[string]any{"type": "string"}},
"words": beltProp("string", `their own words for it; say pause or hold in them when they want it held rather than ended`),
@@ -360,7 +361,6 @@ func beltDefinitions() []ai.ToolDefinition {
"file": beltProp("string", "the path or filename as that job recorded it; omit it when the job wrote one file"),
}, "job"),
beltTool(beltToolCompetence, "Read the measured view of your own strengths, weak spots and learning frontier; a self-assessment given without it is invention.", map[string]any{}),
- beltTool(beltToolStanding, "Read what you keep watch over: the last wake, the next check, and every standing rule with what it watches for and how often.", map[string]any{}),
beltTool(beltToolSpending, "Read what has been spent: today against the daily limit, your own upkeep separately, or any window you bound with since and until.", map[string]any{
"since": beltProp("string", `where the window starts, "2026-08-06" or "2026-08-06T09:00"`),
"until": beltProp("string", "where it ends, same spelling"),
@@ -372,20 +372,20 @@ func beltDefinitions() []ai.ToolDefinition {
beltTool(beltToolSearch, "Search everything you remember — this conversation, the notebook, jobs long finished — the only read that reaches what was merely said.", map[string]any{
"q": beltProp("string", "the words to look for, in the user's own terms"),
}, "q"),
- beltTool(beltToolRecall, "Search everything settled or said at once — the conversation, the notebook, work live and finished, standing rules and services — and get back real content with an id for each hit that open takes. Always safe. Narrow with kind, since/until, or session; leave them off to search everything. If it comes back empty, say so plainly.", map[string]any{
+ beltTool(beltToolRecall, "Search everything settled or said at once — the conversation, the notebook, work live and finished, and services — and get back real content with an id for each hit that open takes. Always safe. Narrow with kind, since/until, or session; leave them off to search everything. If it comes back empty, say so plainly.", map[string]any{
"q": beltProp("string", "the words to look for, in the user's own terms"),
- "kind": beltProp("string", "message, job, result, belief, rule, or service; omit to search all six"),
+ "kind": beltProp("string", "message, job, result, belief, or service; omit to search all five"),
"since": beltProp("string", `local date or time the window starts, "2026-08-06" or "2026-08-06T09:00"`),
"until": beltProp("string", "local date or time the window ends, same spelling"),
"session": beltProp("string", "one conversation id, to look only in that room"),
}, "q"),
- beltTool(beltToolOpen, "Open one thing whole: running work as its plan with every step's state, its workers' own progress, files and spend so far; finished work as its whole result, files, spend and how parts ended; a file as its actual bytes; a rule, service or #notebook line as its full record. Always safe. Longer than one page is PAGED, never cut — read on with part when it says so.", map[string]any{
- "id": beltProp("string", "what to open: a job id from a board or recall read, a file's name, a standing rule's id, a service's name, or a #number from the notebook"),
+ beltTool(beltToolOpen, "Open one thing whole: running work as its plan with every step's state, its workers' own progress, files and spend so far; finished work as its whole result, files, spend and how parts ended; a file as its actual bytes; a service or #notebook line as its full record. Always safe. Longer than one page is PAGED, never cut — read on with part when it says so.", map[string]any{
+ "id": beltProp("string", "what to open: a job id from a board or recall read, a file's name, a service's name, or a #number from the notebook"),
"job": beltProp("string", "the job that wrote the file, when two jobs wrote a file of the same name"),
"part": beltProp("integer", "which page to read, from a part-of line you have been shown; omit for the first"),
"raw": beltProp("boolean", "true for the journal's own rows — every event and message anchored to it, unredacted"),
}, "id"),
- beltTool(beltToolStatus, "Read the whole system on one page: running, queued and failed counts, today's cost against the daily limit, standing watches and next checks, services, measured competence, and what waits on an answer. Always safe.", map[string]any{}),
+ beltTool(beltToolStatus, "Read the whole system on one page: running, queued and failed counts, today's cost against the daily limit, services, measured competence, and what waits on an answer. Always safe.", map[string]any{}),
beltTool(beltToolThread, "Read another of the person's conversations; call it with no arguments to list the rooms. This conversation is already in front of you.", map[string]any{
"room": beltProp("string", "one conversation id, from this tool's own list"),
}),
@@ -518,8 +518,6 @@ func (run *beltRun) execute(name, arguments string) (string, bool) {
return run.read(args)
case beltToolCompetence:
return run.competence()
- case beltToolStanding:
- return run.standing()
case beltToolSpending:
return run.spending(args)
case beltToolHistory:
@@ -646,8 +644,8 @@ func (run *beltRun) read(args map[string]any) (string, bool) {
return rendered, false
}
-// competence, standing and spending are the employee's account of itself, and
-// they are reads in the same sense board and manual are: nothing is journalled,
+// competence and spending are the employee's account of itself, and they are
+// reads in the same sense board and manual are: nothing is journalled,
// nothing acts, and a message that only asked how the work has been going
// carries no command seq. Each returns the honest empty answer rather than an
// error when the surface never registered it — a chat that has no competence
@@ -663,17 +661,6 @@ func (run *beltRun) competence() (string, bool) {
return truncateBytes(measured, run.head.budget.grounding), false
}
-func (run *beltRun) standing() (string, bool) {
- if run.head == nil || run.head.standingWatch == nil {
- return "standing-watch status is not available on this surface.", false
- }
- status := strings.TrimSpace(run.head.standingWatch())
- if status == "" {
- return "nothing is on watch and no standing check is arranged.", false
- }
- return truncateBytes(status, run.head.budget.grounding), false
-}
-
// spending answers the money question the head could not answer at all. The
// daily rail was its only cost signal above one job, so "what have you been
// spending on yourself?" had no reachable ground truth while the prompt forbade
diff --git a/internal/head/triad_test.go b/internal/head/triad_test.go
index d3c924a57c..ddf83daadc 100644
--- a/internal/head/triad_test.go
+++ b/internal/head/triad_test.go
@@ -116,36 +116,6 @@ func TestATaskThatAmendsSettledWorkTakesTheCorrectionPath(t *testing.T) {
}
}
-// A rule id and a sentence that could mean three different edits. Nothing is
-// guessed: the candidates come back in the person's terms, and the journal is
-// untouched — the same shape every ambiguity in this package has.
-func TestAChangeToARuleWithAmbiguousWordsHandsBackCandidates(t *testing.T) {
- graph := openHeadStore(t)
- charter := activateReminder(t, graph, "plants",
- "remind me every sunday to water the plants", "every sunday", "water the plants")
-
- user := postUser(t, graph, "rules", "sort that one out for me")
- run := &beltRun{head: New(nil, graph), user: user}
-
- answer, failed := run.execute(beltToolChange, beltArguments(t, map[string]any{
- "target": charter.ID, "words": user.Body,
- }))
- if failed {
- t.Fatalf("an unreadable rule change errored instead of offering candidates: %s", answer)
- }
- for _, want := range []string{"when it runs", "what it says", "going back to asking"} {
- if !strings.Contains(answer, want) {
- t.Fatalf("the candidates do not offer %q:\n%s", want, answer)
- }
- }
- if run.acted {
- t.Fatal("offering candidates counted as acting")
- }
- if commands := pendingCommandsOf(t, graph); len(commands) != 0 {
- t.Fatalf("an ambiguous rule change journaled %+v", commands)
- }
-}
-
// The consent gate is reached by the same road control used, so a withdrawal
// wide enough to cross it stops at the question with nothing journaled.
func TestStoppingAWideSetRaisesTheConsentGate(t *testing.T) {
diff --git a/internal/head/vocabulary_test.go b/internal/head/vocabulary_test.go
index 22423fc239..32d35ee0b1 100644
--- a/internal/head/vocabulary_test.go
+++ b/internal/head/vocabulary_test.go
@@ -54,12 +54,6 @@ func TestTheDeterministicRepliesSpeakPlainly(t *testing.T) {
// the worst possible place to say "charter" or "node".
assertPlain(t, "the honest refusal", unclearCommandReply, noSuchTargetReply,
commandErrorReply, providerErrorReply)
- // The which-one question is the ask tool's now and its words are the model's,
- // so what is pinned here is the sentence the belt hands it to ask FROM.
- assertPlain(t, "the standing-rule receipt",
- charterAcknowledgement(store.CommandCharterRetire),
- charterAcknowledgement(store.CommandCharterPause),
- charterAcknowledgement(store.CommandCharterCadence))
}
// TestTheBackstageListsNameTheWordsThatActuallyLeak pins the second half of the
diff --git a/internal/head/wake_test.go b/internal/head/wake_test.go
index 398375ab81..1fde6c57bf 100644
--- a/internal/head/wake_test.go
+++ b/internal/head/wake_test.go
@@ -220,7 +220,7 @@ func TestTheWakeOnlyCoversKindsTheHeadAlreadySpokeFor(t *testing.T) {
}
for _, kind := range []store.CommandKind{
store.CommandPause, store.CommandResume, store.CommandCancel,
- store.CommandReprioritize, store.CommandCharterRetire,
+ store.CommandReprioritize,
} {
if receiptInterprets(kind) {
t.Fatalf("%s did exactly what the verb says and still buys a turn", kind)
diff --git a/internal/lane/roles.go b/internal/lane/roles.go
index c9410073f1..77799c202c 100644
--- a/internal/lane/roles.go
+++ b/internal/lane/roles.go
@@ -50,7 +50,8 @@ const (
// argument in internal/session's turnLambda.
RoleLeafAttached Role = "leaf.attached"
RoleLeafUnattended Role = "leaf.unattended"
- // RoleStanding is a standing order's run: unattended by construction.
+ // RoleStanding is an automation watch's judgment: unattended by
+ // construction. It keeps the name of the standing orders it first served.
RoleStanding Role = "standing"
// RoleMemory is the memory reflex and the consolidation pass.
RoleMemory Role = "memory"
@@ -398,7 +399,7 @@ func (r Role) Ceiling() time.Duration {
// this is when we stop acting at all ([TurnGiveUp] × the same patience), so the
// two scale together off one column and a role cannot be patient about one and
// impatient about the other. Talk is ninety seconds, a task node's four and a
-// half minutes, a standing pass's nine, a probe's forty-five seconds.
+// half minutes, an automation check's nine, a probe's forty-five seconds.
//
// WHAT IT REPLACED, and why none of those numbers is missed: six attempts and
// two minutes for a watched call, sixty attempts and ten minutes for a patient
diff --git a/internal/lane/watch.go b/internal/lane/watch.go
index 52d02abb41..2590606b65 100644
--- a/internal/lane/watch.go
+++ b/internal/lane/watch.go
@@ -178,7 +178,7 @@ func PlanFor(choice Choice, pace Pace, role Role, now time.Time) control.Plan {
// WHAT THIS DOES AND DOES NOT ORDER. It orders the RATE: no second of a watched
// wait is ever worth less than a second of an unwatched one, which is the law
// beside this in watch_test.go. It does not order the total, and must not —
-// a standing pass really does tolerate nine minutes where a conversation
+// an automation check really does tolerate nine minutes where a conversation
// tolerates ninety seconds, and six times the wait at a quarter the rate is
// worth more than one times the wait at the full rate. What the wait is worth is
// the product; what nobody is allowed to say is that an unwatched SECOND is
diff --git a/internal/manual/asked/asked.go b/internal/manual/asked/asked.go
index 976ba08488..535eb29e21 100644
--- a/internal/manual/asked/asked.go
+++ b/internal/manual/asked/asked.go
@@ -38,12 +38,12 @@ type Question struct{ Ask, Want string }
// because a heading that rescues one question can bury another.
var Plain = []Question{
{"how do I share a file with it", "attaching-files"},
- {"how do I check on it later", "keeping-an-eye"},
+ {"how do I check on it later", "automations"},
{"what is codeaf", "starting-codeaf,what-i-can-do"},
{"how do I get started", "getting-started"},
{"what does it remember", "what-i-remember"},
{"how do I set a spending limit", "models-and-cost,commands"},
- {"how do I keep it working after I close the lid", "staying-on-that-machine,keeping-an-eye"},
+ {"how do I keep it working after I close the lid", "staying-on-that-machine,automations"},
{"who can see my files", "permissions"},
{"how do I let it run things without asking", "permissions"},
{"how do I split a job into parts", "tasks"},
@@ -52,7 +52,7 @@ var Plain = []Question{
{"can I run it on another computer", "running-on-another-machine"},
{"what do I do when it loses connection", "when-the-connection-drops"},
{"how do I save a way of working and reuse it", "saved-shapes-of-work,saved-programs"},
- {"how do I make a rule it always follows", "standing-orders"},
+ {"how do I make a rule it always follows", "what-i-remember"},
{"how do I choose which folder it works in", "choosing-a-folder"},
{"what do all the keys do", "keys"},
{"how do I see everything at once", "home"},
@@ -80,10 +80,10 @@ var HeldOut = []Question{
{"how much is this costing me", "models-and-cost"},
{"how do I send it a photo", "attaching-files"},
{"is there a list of shortcuts", "keys"},
- {"what happened while I was away", "keeping-an-eye"},
+ {"what happened while I was away", "home,automations"},
{"why did it forget what we were talking about", "compacting-over-and-over,what-i-remember"},
{"how do I open something on the other machine", "opening-files-from-that-machine"},
- {"I want it to always write in british english", "standing-orders"},
+ {"I want it to always write in british english", "what-i-remember"},
{"can I go back to how it was before", "sessions-and-rewind"},
{"where did it put the files it wrote", "places,how-tasks-run"},
{"nothing is on the screen", "empty-screen"},
diff --git a/internal/manual/chat/accounts.md b/internal/manual/chat/accounts.md
index 5bdfcd681d..3a85e4c44e 100644
--- a/internal/manual/chat/accounts.md
+++ b/internal/manual/chat/accounts.md
@@ -508,9 +508,12 @@ codeaf reads immediately before it calls one, so an unanswered message does not
chase on its own.
What you can ask for is a **follow-up**, and then it is a new message you approved: "chase
-that on Friday if there is no reply" becomes a standing order with a moment on it, and the
-approval question comes round again when it fires. See the keeping-an-eye page for how a
-moment or a rhythm is set up.
+that on Friday if there is no reply" becomes an automation with a moment on it, saved only
+once you answer its card, and it runs only while a codeaf window is open. Its run has
+nobody to answer the send's approval question, so unless the tool is already allowed by
+name — `gmail_send:allow`, the account's capability set to `yes`, or an "always" you gave —
+the run stops there: `your call · needed your ok to run gmail_send`. *Automations* has how
+a moment or a rhythm is set up.
If an outgoing call genuinely did not happen — the approval question was declined, or the
account answered with an error — codeaf says so in its reply rather than quietly trying
diff --git a/internal/manual/chat/asking-from-home.md b/internal/manual/chat/asking-from-home.md
index 1cdbba1160..11b28d182d 100644
--- a/internal/manual/chat/asking-from-home.md
+++ b/internal/manual/chat/asking-from-home.md
@@ -3,48 +3,88 @@
## Can I set a reminder from home
Yes. Type it on the home screen, press `↑` once — which lands on the row spelled
-`ask here: "…"` — and press `enter`.
+`ask here: "…"` — and press `enter`. On a terminal that can tell `ctrl+enter` from a plain
+`enter`, `ctrl+enter` asks it without moving the cursor.
```
? ask here: "remind me at 6 to leave"
+ start a new conversation: "remind me at 6 to leave"
─ glm-5.3-flash:auto · ◇ asks ──────────────────────────────────────────────
› remind me at 6 to leave
- enter starts a new conversation and sends this · ↑ ask here · esc clear project: ~/codeaf
+ enter starts a new conversation and sends this · ↑ ask here · ↑↑ pick a match · esc clear
```
-What you get is **a row in home's conversation list and a pane holding the exchange**. The row
-stays there — with what the errand is doing written in its tail — until the errand is
-finished and you have read what it came to. The pane is the exchange itself: what you said,
-the reply as it streams, one line per tool call, and the card when one arrives. On an
-ordinary width the pane takes the frame while it has the keyboard, and the list comes back
-with `esc`.
+What you get is **a row on home and a pane holding the exchange** — a short conversation of
+its own, headed `ask here`, that is not added to your conversation list. The pane is what
+you said, the reply as it streams, one line per tool call, and the automation's card when
+one arrives — `wants to remind you`, answered right there with `1 Save`, `0 Don't save` or
+`o Change…`. Once it is saved the pane says `saved · /automations lists it`, and from then
+on it is an ordinary automation (*Automations*).
+
+Scheduled work ("every Monday at 9, draft the weekly update") and watches ("tell me when CI
+on main goes red") are asked the same way, and so is a plain question: an exchange that
+sets nothing up simply answers.
The cursor lands on the new row and the keyboard goes into the pane, so you can answer
-straight away. `tab` or `esc` puts the keyboard back on the column; the row stays, and `→`
-on it takes the keyboard back in.
+straight away. On home at rest the pane takes the whole screen while it has the keyboard;
+`tab` or `esc` hands the keyboard back and the list returns with the row on it, and `enter`
+or `→` on the row goes back in.
+
+## What is ask here — and how is it different from starting a conversation
+
+They send the same words to the same kind of model. The difference is **what is left behind
+afterwards**.
+
+- `start a new conversation` opens a session in this project. It gets a folder under
+ `~/.codeaf/v3/projects/`, a row on home's conversation list, and it stays on that list.
+- `ask here` opens a session too — a real one, with a real transcript — but its folder is
+ made under `~/.codeaf/v3/errands/` instead. Home lists what is under `projects/`, so an
+ errand that is finished can never fill up the screen it was typed at.
+
+It is not an unstored chat. The record is the point: an automation saved from an exchange
+names that exchange as where it was asked, so `→` then `o` — `open where it was asked` — on
+its row in `/automations` opens this transcript, and "why did I get this reminder?" has an
+answer.
+
+The exchange talks on the model this window was launched with, under your approval rules as
+they stand when you ask, and works in the project it belongs to (*Which project an ask here
+belongs to*). The task composer's second `alt+enter` sends a sentence through this same
+door, with the project, model and spending cap the layer settled (*Places*, the composer
+layer).
+
+## Which project an ask here belongs to — where its automation runs
+
+The project **under the cursor**. While you type, walk `↑` past `ask here` onto one of a
+project's rows among the matches and press `ctrl+enter`: the errand belongs to that project.
+With the cursor on the typing rows — `ask here` itself, or `start a new conversation` — it
+is the project this window is in, and your home folder `~` when the window is in no project
+at all. The composer layer's `alt+p` chooses one too.
+
+That project is where the exchange works, and where an automation saved from it runs —
+exactly as one said in a conversation runs in that conversation's project. A reminder
+belongs to no repository; a watch on CI belongs to one.
-## The exchange row on home — working, waiting on you, stood
+## The exchange row on home — working, waiting on you, answered, saved
-Every `ask here` is one row, marked `?`, named with the first line of what you asked:
+Every `ask here` is one row, marked `?`, named with the first line of what you asked, and
+saying at its right what it is doing:
```
? remind me at 6 to leave ? waiting on you
? what did we decide about pricing ⠹ working · 4s
- ? tell me when CI goes red ∙ stood
+ ? tell me when CI goes red ✓ saved
```
-- **`⠹ working · 4s`** — a turn is in flight. The spinner turns and the clock counts up.
-- **`? waiting on you`** — a card, or a command's approval, is up and nobody has answered
- it. It is the only tail
- drawn in the bright ink, and it is the same `?` a conversation stopped on a question
- wears, because it is the same fact: this row costs one keystroke to unblock.
-- **`∙ stood`** — something now stands because of it.
-- **`∙ answered`** — it finished and nothing standing came of it.
+- **`working`** — a turn is in flight, with its clock.
+- **`waiting on you`** — a card, or a command's approval, is up and nobody has answered
+ it. It is the only tail drawn in amber, and it is the same `?` a conversation stopped on a
+ question wears: this row costs one keystroke to unblock.
+- **`saved`** — an automation was saved from it.
+- **`answered`** — it is over and nothing was saved: a plain answer, or a card you declined.
-The rows sort among themselves: **what wants you first, then what is moving, then what is
-done**. They sit at the very **top of home's `threads` panel**, above every
-conversation — an errand is a thing you asked for a minute ago.
+On home at rest the rows are a short list of their own in the left column, under the
+conversations, sorted **what wants you first, then what is moving, then what is done**.
+While you type, a row is drawn among the matches, in the block of the project it belongs to.
`enter` or `→` on the row hands the keyboard to the pane. The hint under the box says so:
`↑↓ move · enter or → answer this ask here · esc close`. With something typed in home's box
@@ -59,7 +99,7 @@ the pane is drawn in, which is where the gesture went. The hint went on saying
## How do I know ask here is doing something — the spinner, the clock and the live strip
While a turn is running the pane says what is happening, in the words the conversation
-itself uses, and it says it for the first turn and for every follow-up alike:
+itself uses, for the first turn and for every follow-up alike:
```
⠹ thinking · 4s nothing has come back yet
@@ -74,70 +114,109 @@ once the reply is coming, `running` while a call is out. The clock is the age of
and it appears after one second.
Under it is the **live strip**: at most **two lines**, always the newest two things that
-have happened this turn, scrolling as they arrive — a call starting, a call finishing with
-its own time, and the growing tail of the reply so a long answer visibly moves. A call that
-is running carries the braille spinner; one that succeeded carries nothing at all, which is
-how the conversation draws a finished call; one that failed carries `✕` and what went wrong.
-
-**The whole block disappears when the turn ends.** It is a window onto the moment, not a
-second copy of the transcript — everything in it is already a row above it.
+have happened this turn — a call starting, a call finishing with its own time, and the
+growing tail of the reply, so a long answer visibly moves. A running call carries the
+spinner; one that succeeded carries nothing at all; one that failed carries `✕` and what
+went wrong. **The whole block goes when the turn ends**: it is a window onto the moment, and
+everything in it is already a row above it.
**It also gives way while the turn is stopped on an approval.** Nothing is happening until
you answer, so no spinner turns: the call's row wears the waiting `?` instead, with the
question under it (*Ask here needs your permission to run a command*, below).
-A tool row says what the call is pointed at and never says `unknown`: `bash` shows the first
-line of the command, `read` shows the path, `stand` shows what it is doing
-(`stand · proposing remind me at 6 to leave`). A call that came back with nothing to say
-says nothing.
+A tool row says what the call is pointed at and never says `unknown`: `bash` shows the
+first line of the command, `read` shows the path, and the `automation` tool says what it is
+doing — `automation · proposing leave`, `automation · reading the automations`. A call that
+came back with nothing to say says nothing.
## Why is the answer from home showing asterisks and hashes — the ask here pane formats markdown
-Yes. A settled answer in the `ask here` pane goes through **the same markdown renderer the
-conversation uses** — one parser, one set of colours, one answer to what your terminal can
-draw. You never see `**bold**`, a leading `##`, or backticks around `codeaf status`; you see
-bold, a heading and a code span.
+A settled answer in the `ask here` pane is formatted: it goes through **the same markdown
+renderer the conversation uses** — one parser, one set of colours, one answer to what your
+terminal can draw. You never see `**bold**`, a leading `##`, or backticks around
+`/automations`; you see bold, a heading and a code span.
```
-Two reminders
+Two automations
-You have two of them. Run codeaf status to see them:
+You have two of them. /automations lists them:
-· one at 6
-· one at 9
+· leave, today at 18:00
+· weekly update, every Monday at 09:00
```
-**While the reply is still arriving it is plain wrapped text**, and that is the transcript's
+**While the reply is still arriving it is plain wrapped text**, which is the transcript's
behaviour rather than a difference: formatting a half-written sentence re-flows it under
-your eye. The whole block formats the instant the turn is over. In a conversation the same
-thing happens in two steps, because an answer there is long enough to be worth catching up
-every 1500ms; the pane's answers are short, so the block simply settles.
+your eye. The whole block formats the instant the turn is over.
+
+Beside the list the pane is 36 to 56 columns wide, the renderer's **phone tier**: a fenced
+code block wraps rather than being cut, with a dim `↳ ` on each continued row, and a table
+stacks as one `key: value` line per cell. Taking the whole screen, it renders at that width
+as a conversation would.
+
+What it does not give you is what hangs off an answer in a conversation — the offer to open
+a wide table, task references as links, an image. Take the exchange there with
+`continue as a conversation` for those.
+
+## How do I answer the card in the ask here pane, or say no to it
+
+When the exchange proposes an automation its card appears in the pane, headed
+`wants to remind you`, `wants to schedule work` or `wants to watch for something`, with the
+facts a conversation's card shows (*Automations*, *The card*). Nothing is saved until you
+answer, and each answer is a row of its own, **taken by its key or by a click anywhere
+along it**:
+
+- `1 Save` — saved as shown.
+- `2 Save and run it now` — saved, then done once, right away, in the exchange while you
+ watch; `2 Save and check it now` on a watch. Not offered on a reminder, or on work kept
+ in a separate worktree.
+- `0 Don't save` — `Nothing is saved and nothing runs.`
+- `o Change…` — the pane says `type the change and press enter` and its box
+ `type the change`; write the correction in your own words ("make it 8pm", "every
+ weekday") and the model proposes again, on a new card that replaces this one. While you
+ write it every key is text, digits included. (It was `2`, and this page once called it `c`
+ long after the key became `o`.)
+
+**`esc` is not an answer here.** In the pane it hands the keyboard back to the list, and the
+card goes on waiting — there is no clock on it — with its row saying `waiting on you`. `0`
+is the only way to say no in this pane.
+
+The hint starts with exactly the answers the card drew, and never one more —
+`1 Save · 2 Save and run it now · 0 Don't save · o Change…` on work,
+`1 Save · 0 Don't save · o Change…` on a reminder — and goes on `enter sends a follow-up`
+and `tab or esc back to the list`. While a card is asking, `1`, `2`, `3`, `0` and `o` are its
+keys, and one with no answer under it — `2` on a reminder — does nothing.
-The pane is under sixty columns, which is the **phone tier** of the renderer: a fenced code
-block wraps rather than being cut, with a dim `↳ ` on each continued row, and a table stacks
-as one `key: value` line per cell instead of being squeezed. That is the only difference
-between an answer read here and the same answer read in the conversation.
+## What the card says after you answer it — saved, not saved, you asked for something different
-What it does not do is give you the affordances that hang off a rendered answer in the
-conversation — the offer to open a wide table, task references becoming links, an image.
-Those are the conversation's screen; take the exchange there with `continue as a
-conversation` if you need them.
+**The card stays after you answer it.** It settles in place, greys out, and its foot says
+what was decided in the words a conversation's card uses:
+
+- `leave · Save · saved`, or `weekly update · Save and run it now · saved`
+- `leave · not saved`
+- `leave · Change… · you asked for something different`
+
+Answering `1`, `2` or `0` hands the keyboard back to the list, and once the automation is
+saved the pane adds `saved · /automations lists it`. A change keeps the keyboard in the
+pane, because the model is answering it.
+
+The digits belong to a card only while it is still a question. With no card up, or with an
+answered one on screen, `3` in the middle of "make it 3pm" is just a `3`.
## Why did my reminder card disappear when I opened another chat — it does not any more
It used to, and that was a defect. The exchange lived on the home screen, so closing home —
-which is what opening another conversation does — closed the errand's session with it. The
-card you had not answered yet was answered for you: the engine recorded
-`the card was left unanswered — nothing was set up`.
+which is what opening another conversation does — closed the errand's session with it, and
+the card you had not answered yet was answered for you: nothing was saved.
**An exchange now outlives the screen it was asked on.** Closing home does not touch it. Nor
does opening another conversation, walking the list, looking at a different project, or
-letting the card sit there overnight. There is **no clock on the card** and never was: it
-waits while you read it, and while you do anything else.
+letting the card sit there overnight. There is **no clock on the card**: it waits while you
+read it, and while you do anything else.
-Open home again and the row is where you left it, still `? waiting on you`, still
-answerable with `1`, `2` or `3`. The only two things that end an exchange are described
-under *When does an ask here exchange go away* below.
+Open home again and the row is where you left it, still `waiting on you`, still answerable
+with its own keys. What ends an exchange is under *When does an ask here exchange go away*;
+quitting the window that holds it is the one thing that does not wait for you.
## Can I ask two things from home at once — yes, one row each
@@ -145,63 +224,30 @@ Yes. A second `ask here` **adds** an exchange; it does not replace the first. Ea
its own folder, its own session and its own row, and they sort against each other by what
they are doing — the one holding a card sits above the one still working.
-The pane on the right is always **about the row under the cursor**. Walk onto another
-exchange and you get that exchange; walk onto a conversation and you get that
-conversation's ordinary preview card. Nothing takes the column hostage.
-
-## When does an ask here exchange go away
-
-Never while it is working, and never while it is waiting on you.
-
-An exchange is filed when **all three** of these are true:
-
-1. **it is over** — the card was answered (`stood`, `once`, or declined), or there was no
- card and the turn finished;
-2. **you have seen it that way** — its pane has been drawn at least once since it settled,
- so what it came to was on your screen before its row went;
-3. **you have moved off it** — the cursor is on some other row.
-
-Filing means the session is closed and the folder is moved where it belongs. Nothing is
-deleted. An exchange that made something standing leaves an ordinary item row on the same
-screen, under the same project, so the row going is not the fact going.
-
-**Quitting codeaf files every open exchange**, including one that is still working — the
-session is interrupted and closed, and a stood one's folder still reaches the item it made.
-
-**The reminder itself does not fire into the pane.** The exchange is a pane on a screen you
-close, not a room you sit in, so it is never a delivery target: when the reminder goes off it
-lands in whichever conversation of that project you have open — the chat you are actually
-working in. If none is open, the news waits under that project on home as
-`◆ N things since you left`, and the next conversation you open in that project folds it into
-its own `while you were away`. The full order is on the keeping-an-eye page under "Where a
-reminder arrives".
-
+The pane is always **about the row under the cursor**. Walk onto another exchange and you
+get that exchange; walk onto a conversation and, where the frame has a pane beside the
+list, you get that conversation's ordinary card. Nothing takes the screen hostage.
## How do I get back to the list from ask here — tab, esc, → and clicking a row
While the cursor is on an exchange row, home has **two zones**: the list, and the pane the
-exchange is drawn in. Exactly one of them has the keyboard, and there are five ways to move
-it:
+exchange is drawn in. Exactly one of them has the keyboard:
- **`tab` inside the pane** hands the keyboard back to the list, from any state the pane is
- in; **`→` on the exchange's row** takes it back in. (Both directions used to be `tab`; a
- place's `tab` is the way to the next place, and a box that has taken the keyboard keeps
- its own keys, which is why one half of the toggle stayed and the other moved to the arrow
- that points at the pane.) It works from the box, the `continue as a conversation` row
- and an open card alike. It never loses what is in the pane.
-- **`esc`** in the pane hands the keyboard to the list. One layer at a time: if you have
- half a follow-up typed, the first `esc` clears that and the second one leaves.
-- **`enter`** on the exchange's row in the list hands the keyboard to its pane.
-- **clicking** puts the keyboard where the pointer is. A click on a list row opens that
- row, as `enter` would, *and* takes the keyboard to the column; a click anywhere in the
- pane brings it back.
-- **answering `1`** on a card hands it back by itself. The thing you asked for is being
- made, and the list is where you go next.
-
-With the keyboard on the list, `↑`/`↓`, `ctrl+p`/`ctrl+n` and `pgup`/`pgdown` all walk the
-column exactly as they do with no exchange on screen, and `enter` opens the row under the
-cursor. **The pane follows the cursor** — walk off the exchange and the row you land on
-draws its own card again, where the frame is wide enough to have one.
+ in — the box, `continue as a conversation`, a card waiting — and never loses what is in
+ the pane.
+- **`esc`** in the pane hands it back too, one layer at a time: if you have half a follow-up
+ typed, the first `esc` clears that and the second one leaves.
+- **`enter`** or **`→`** on the exchange's row in the list takes the keyboard back in.
+- **clicking** puts the keyboard where the pointer is: a click on a list row opens that row,
+ as `enter` would, and takes the keyboard to the list; a click in the pane brings it back.
+- **answering the card** with `1`, `2` or `0` hands it back by itself.
+
+With the keyboard on the list, `↑`/`↓`, `ctrl+p`/`ctrl+n` and `pgup`/`pgdown` walk it
+exactly as they do with no exchange on screen. **The pane follows the cursor.**
+
+`continue as a conversation` is reached with `↓` inside the pane and left again with `↑`,
+`tab` or `esc`.
## What the line under the ask here pane says — the box, the answers, a follow-up and the way back
@@ -220,47 +266,31 @@ change is being typed after `o`, when the line is `type the change and press ent
list has the keyboard. The pane's box used to wear home's sentence, which promised a search
and a new conversation that typing in the pane does not do.
-**The answers in that line are the ones the card actually drew, and never one more.** A
-card that offers all three reads
-`1 Set it up · Mondays at 9am · 3 Only now, don't repeat · 0 Don't set it up · o Change…`;
-a one-off reminder's card, which has no once to give, reads
-`1 Remind me at 6 · 0 Don't remind me · o Change…`. The once button used to say
-`just once`, which also read as "set it up once".
-The line is built from the question rather than written out, so it cannot name a digit that
-would do nothing.
-
-`continue as a conversation` is reached with `↓` inside the pane and left again with `↑`,
-`tab` or `esc`. It also lights up under the pointer and takes one click.
-
## Ask here on a narrow window — the exchange takes the whole screen
-Home's panels have no right-hand pane to put an exchange in **at any width**, and a search
-under **136 columns** has none either. It does not refuse. The two are **stacked** instead
-of sat side by side:
+Home's panels have no pane beside them **at any width**, and a search under **136 columns**
+has none either. It does not refuse: the two zones are **stacked** instead of sat side by
+side.
-- the **panels** (or the list, while you type) are the screen until you enter an exchange;
+- the **list** is the screen until you enter an exchange;
- the **exchange** is the screen while it holds the keyboard — the same pane, the same card,
- the same live strip, drawn at the full width;
+ the same live strip, drawn wide;
- **`esc`** or **`tab`** brings the list back, with the exchange's row still on it wearing
its tail, and **`enter`** or **`→`** on that row opens it again.
**The way back is always on the line under the box**, at the narrowest width this layout
has, sixty columns, except while a change is being typed after `o` (that line ends
-`esc clear`). `tab or esc back to the list` is the last clause of that line and the
-one that stays; the clauses in front of it give way first, nearest first — home's chords
-such as `alt+e effort`, then `↓ continue as a conversation`, then `enter sends a follow-up`,
-then the card's answers from the last one back — so a card at sixty columns reads
-`1 Remind me at 6 today · tab or esc back to the list`, and its other answers are still on
-the card's own rows. It used to be the other way round: the way back
-was the first thing a narrow window dropped, and the stacked pane, which is the whole
-screen, never said how to leave it.
+`esc clear`). `tab or esc back to the list` is the last clause of that line and the one
+that stays; the clauses in front of it give way first, nearest first — home's chords such as
+`alt+e effort`, then `↓ continue as a conversation`, then `enter sends a follow-up`, then
+the card's answers from the last one back — so a card at sixty columns reads
+`1 Save · tab or esc back to the list`, and its other answers are still on the card's own
+rows. It used to be the other way round: the way back was the first thing a narrow window
+dropped, and the stacked pane, which is the whole screen, never said how to leave it.
`ask here` itself opens straight into the stacked pane, because it selects the new row and
-gives it the keyboard. Everything answers the same keys it answers on a wide frame.
-
-Resizing between the two shapes costs nothing: it is the same exchange and the same
-keyboard, drawn in whichever geometry fits. Drag a window narrow with the pane open and the
-pane fills the frame; drag it wide again and it goes back beside the list.
+gives it the keyboard. Resizing between the shapes costs nothing: it is the same exchange
+and the same keyboard, drawn in whichever geometry fits.
## Ask here on a phone-width window — the errand is a sheet
@@ -277,110 +307,65 @@ that still said `type to search or start something new`.
## Why can't I click a row while asking — you can, and it opens it
-You can, and it does. A click on any row of the list puts the cursor on it, gives
-the keyboard to the column and opens the row, exactly as `enter` on it would — one click,
-the same as on every place. A click on the exchange's own row gives its pane the keyboard,
-and a click on `ask here` or on the row of what you typed only puts the cursor there,
-because a click never starts a paid turn.
-
-A click on one of the card's answers in the pane answers the card, the same way clicking one
-answers it in a conversation. Each answer owns its whole row, so there is no gap between two
-of them to miss.
+You can, and it does. A click on any row of the list puts the cursor on it, gives the
+keyboard to the list and opens the row, exactly as `enter` on it would — one click, the
+same as on every place. A click on an exchange's own row gives its pane the keyboard, and a
+click on `ask here` or on the row of what you typed only puts the cursor there, because a
+click never starts a paid turn.
-If this window was launched with no way to open a second session, the row refuses in one
-line: `this window cannot ask from home`, and nothing is created. The window that gets that
-line is one attached to another machine — `--host` or `--at`: the errand's folder and the
-standing store live on the machine that runs the errand, and a laptop cannot make either of
-them on a server's disk. An ordinary `codeaf` in a folder asks from home whether or not this
-project's engine is holding the conversation, because that engine is a process on the same
-machine as the folder.
+A click on one of the card's answers in the pane answers the card. Each answer owns its
+whole row, so there is no gap between two of them to miss.
## Is there a shortcut for ask here — ctrl+enter, and what alt+enter does on home
-**`ctrl+enter` asks what you typed here without walking to the row**, and the foot does not
-name it: it only reaches codeaf on a terminal that can tell it apart from a plain `enter` —
-the kitty keyboard protocol, Windows terminals. Elsewhere it usually arrives as a plain
-`enter`, which on the typing rows starts a new conversation. So the gesture the foot names
-is `↑ ask here`, because every terminal has the arrow.
+**`ctrl+enter` is the chord for `ask here`, and the foot does not name it.** It only reaches
+codeaf on a terminal that can tell it apart from a plain `enter` — the kitty keyboard
+protocol, Windows terminals — so the foot names the arrow every terminal has. Elsewhere it
+usually arrives as a plain `enter`, which on the typing rows starts a new conversation.
+`alt+enter` is **not** a second spelling of it: on home it opens the task composer layer
+(*Places*), whose own second `alt+enter` sends the sentence through this same door with the
+project, model and spending cap the layer settled. This page once said `alt+enter` was bound
+to `ask here` itself, and home's own key handler listed it beside `ctrl+enter` — but the
+layer always took the key first, so that line never ran, and it is gone.
-**`alt+enter` is not a second spelling of it.** On home it opens the **composer layer**,
-where you check the project, the model the work runs on and the spending cap; a second
-`alt+enter` sends it off as a task, and the task lands as the same kind of `?` row with its
-pane, carrying those three settings (the places page). On every other place the chord does
-nothing. This page once said `alt+enter` was bound to `ask here` itself, and home's own key
-handler listed it beside `ctrl+enter` — but the layer always took the key first, so that
-line never ran, and it is gone.
+## Asking from another machine — this window cannot ask from home
-## What is ask here — and how is it different from starting a conversation
-
-They send the same words to the same kind of model. The difference is **what is left behind
-afterwards**.
+If this window has no way to open a second session, the row answers in one line —
+`this window cannot ask from home` — and nothing is created. That window is one attached to
+another machine with `--host` or `--at`: the exchange would be made on this laptop while
+the work and its automations belong to the machine over there. An ordinary `codeaf` asks
+from home whether or not this project's engine is holding the conversation.
-- `start a new conversation` opens a session in this project. It gets a folder under
- `~/.codeaf/v3/projects/`, a row on home, and it stays on that list.
-- `ask here` opens a session too — a real one, with a real transcript — but its folder is
- made under `~/.codeaf/v3/standing/exchanges/` instead. Home lists what is under
- `projects/`, so an errand that is finished can never fill up the screen it was typed at.
+## When does an ask here exchange go away
-It is not an unstored chat. The record is the point: "why did I get this reminder?" has to
-be able to open the conversation that made it.
+Never while it is working, and never while it is waiting on you.
-Which project the errand belongs to is the project **under the cursor** — walk `↑` onto one
-of its rows and press `enter` on the `ask here` row to say "this one". With the cursor still
-on the typing rows it is the project this window is in, and the home directory `~` when this
-window is in no project at all. A reminder belongs to no repository; a watch on CI belongs to one. Its
-row is drawn at the top of the list whichever project it ended up in, and the project it
-belongs to is what the errand's own record says.
+An exchange's row goes when **all three** of these are true:
-## How do I answer the card, or say no to it. 1 yes, o Change, 3 once, 0 no
+1. **it is over** — the card, if there was one, was answered, and no turn is in flight;
+2. **you have seen it that way** — its pane was on screen at least once after it ended; on
+ home at rest the pane shows only while it has the keyboard, so that is `enter` or `→` on
+ its row;
+3. **you have moved off it** — the cursor is on some other row, after a key or a click on
+ home.
-When the exchange gets far enough to propose something that keeps working, a card appears in
-the pane with your own words, when it would wake, and what it would cost per run. Nothing is
-created until you answer it:
+Going means its session is closed. **Nothing is deleted and the folder does not move**
+(*Where did that exchange go*). An automation saved from it is not the row: it is listed in
+`/automations`, and in home's `automations` panel while it has a next time.
-- `1` — yes. It stands as proposed, and the keyboard goes back to the list.
-- `o` — change it. The pane says `type the change and press enter` and its box
- `type the change`; write the correction in your own words ("make it 8pm", "every
- weekday") and the model proposes again. Nothing is created by a change. While you write
- it every key is text, digits included. (It was `2`, and this page called it `c` long
- after the key became `o`.)
-- `3`. Once. The action runs now and nothing standing is created. On a repeating
- check the button reads `Only now, don't repeat`. **Not every card offers it**: a
- one-off reminder draws no once chip, because doing "remind me at six" now says the
- wrong thing hours early. It used to say `just once`, which also read as "set it up
- once". The hint under the box names the digit only where the chip is on the card.
-- `0` — no. Nothing is created and nothing is run, the card settles as `not set up`, and the
- keyboard goes back to the list. **This is the only way to say no in this pane**: `esc` here
- hands the keyboard back to the list without answering anything, and a card left standing on
- the column is not an answer. It is the same key on home's answer row and in a conversation,
- where `esc` also declines.
-
-Those four answers are the only four, and a card draws three of them where `3` is not one
-it can offer. Each answer is a row of its own and **a click anywhere along it takes that
-answer**. There is no default: a card nobody answers creates
-nothing — and nothing answers it for you. **There is no clock on it.** It waits, and its row
-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 · 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
-replaces it is the new one the model sends after a change.
+**Quitting the window that holds it ends every exchange**, including one still working — the
+turn is interrupted and the session closed — and a card still waiting is then never
+answered, so nothing is saved from it.
## Why didn't my key answer the card in ask here — the quarter-second guard
**A key pressed within a quarter of a second of the card's answers being drawn is
ignored**, and so is a click. It was aimed at whatever was there before — usually a
follow-up you were typing, or a command's approval you had just answered — so a `1` meant
-for something else cannot set something up. Press it again. A card that arrived while you
+for something else cannot save something. Press it again. A card that arrived while you
were in another conversation counts from when you first see it, and one whose answers an
approval hid counts again from when they come back.
-The digits belong to a card only while it is still a question. With no card up, or with an
-answered one on screen, `3` in the middle of "make it 3pm" is just a `3`.
-
**Your answer is kept on the card itself.** Nothing is added above the message box of the
conversation you open next — it used to show there for half a minute as a decision line,
about a conversation that never asked.
@@ -392,8 +377,8 @@ into the box whole and `enter` sends it; until then neither the card nor a comma
approval beside it takes a digit. Send or clear the sentence, and their keys are theirs
again.
-It used to answer: the `1` was taken as yes, the reminder was set up as first proposed and
-the rest of the sentence was lost — and with an approval up beside the card, the `1`
+It used to answer: the `1` was taken as `Save`, the reminder was saved as first proposed
+and the rest of the sentence was lost — and with an approval up beside the card, the `1`
allowed the command.
## Ask here needs your permission to run a command — approve or deny it in the pane
@@ -448,68 +433,61 @@ conversation does not carry the question across; ask again there if you still wa
## Where did that exchange go — the folder, at every stage
-There is one folder and it only ever moves. Nothing here copies a transcript and nothing
-here deletes one.
+There is one folder and it moves at most once. Nothing here copies a transcript.
| What happened | Where the folder is |
| --- | --- |
-| you asked | `~/.codeaf/v3/standing/exchanges//transcript.jsonl` |
-| something now stands | `~/.codeaf/v3/standing//exchange/`, **when the exchange is filed** |
+| you asked | `~/.codeaf/v3/errands//transcript.jsonl` |
+| an automation was saved from it | the same folder: it does not move, and it is never cleared away, because the automation names it as where it was asked |
| you continued it as a conversation | the project's own folder, with a `meta.json` |
-| it came to nothing | it stays in `exchanges/`, and the sweep clears it after 7 days |
+| it came to nothing | it stays where it was made for a week; once nothing in it has changed for seven days, the sweep a launch runs removes it |
-When something stands, the exchange is filed **under the thing it made** — that is what
-makes "why did I get this?" a door — and the pane says `kept · this exchange is filed under
-it`.
+The sweep never touches one that is open in a window, one an automation was saved from,
+or anything you continued as a conversation.
-**The move waits until the exchange is finished with.** The news that something now stands
-arrives while the turn that made it is still running, so the folder is not touched then:
-codeaf remembers where it belongs, and moves it when the exchange is filed — after you have
-seen it settled and walked off its row, or when you quit — and after the session has been
-closed. Until then **the exchange is still alive**: `→` back into the pane and a follow-up
-goes to the same conversation. Nothing is copied and nothing is deleted; the folder only
-ever moves, once.
+So "why did I get this?" is a door: `→` then `o` — `open where it was asked` — on the
+automation's row in `/automations` opens this exchange's transcript.
-Changing what now stands is still a card of its own — pause it with `p` or stop it with `s`
-on its row on home.
+To change the automation itself, use `/automations`: `→` on its row, then `p` to pause,
+`e` to edit or `d` to delete (*Automations*). Home's `automations` panel has no verbs;
+`enter` on it opens the place.
## Turn it into a conversation — continue as a conversation
Once the first reply has landed, a row appears under the exchange:
-`+ continue as a conversation`. Press `↓` to reach it and `enter` to take it.
+`+ continue as a conversation`. Press `↓` to reach it and `enter` to take it; it also
+**lights up under the pointer and takes one click**.
-That moves the same folder into this project's own directory, writes the `meta.json` a
+That moves the same folder into the project's own directory, writes the `meta.json` a
picker reads, closes home, and opens the conversation — with everything that was already
said in it. It is the same conversation, filed differently; nothing is replayed and nothing
-is lost. The exchange's row on home goes with it, because the conversation now has a row of
-its own.
-
-It is reached with `↓` inside the pane, and it also **lights up under the pointer and takes
-one click**.
+is lost. A turn still running is interrupted first. The exchange's row on home goes with
+it, because the conversation now has a row of its own.
-It is offered only while the exchange is still an errand. Once something stands, the folder
-belongs to that thing and the pane says `this exchange is already a conversation` rather
-than moving it a second time.
+It is offered only while the exchange is still an errand. **Once an automation has been
+saved from it, the offer goes**: the automation names this folder as where it was asked,
+and a folder moved after it was pointed at would be a door onto nothing. A promotion home
+will not make is answered with `this exchange is already a conversation`.
## What it will not do
-- **It will not put the errand on the list as a conversation.** It gets an exchange row
- while it is live, and that row goes when the errand is finished and read. If you want a
- conversation on the list, use `continue as a conversation`.
-- **It will not deliver the firing back into the pane.** News from something you set up here
- goes to a chat you have open in that project, or waits under the project on home. Nothing
- is ever written only into the exchange's own folder, which no screen reads.
-- **It will not run the full conversation surface in the pane.** The right pane is forty
- cells wide, so it draws a reduced reading: what you said, what came back **rendered as
- markdown**, one line per tool call, the live strip, the card, and a command's approval
- with its two answers. Slash commands, the task column, `/rewind`, images and an approval's
- `always` are all the conversation's own screen — take the exchange there with
- `continue as a conversation` if you need them.
-- **It will not create anything without the card.** Typing a sentence at home does not arm
- a reminder; a card does, and only after you press `1`.
+- **It will not put the errand on home's conversation list.** It gets an exchange row while
+ it is live, and that row goes once the errand is over and seen. For a conversation on the
+ list, use `continue as a conversation`.
+- **It will not deliver an automation's runs into the pane.** A run's news is a desktop
+ notification and a line on home's `since you left` panel, and `/automations` keeps the
+ history. The one dim line a run leaves belongs to the conversation that made it — here,
+ the exchange's transcript — and is drawn only while you have that open.
+- **It will not run the full conversation surface in the pane.** It draws what you said, the
+ reply **rendered as markdown**, one line per tool call, the live strip and the automation
+ card, and a command's approval with its two answers. Slash commands, the task column,
+ `/rewind`, images and an approval's `always` are the conversation's own screen — take the
+ exchange there with `continue as a conversation`.
+- **It will not save anything without the card.** Typing a sentence at home sets nothing up;
+ a card does, and only after you press `1` or `2`.
- **It will not end an errand because you looked away.** Closing home, opening another
conversation and quitting a *different* window all leave it running.
-- **It will not work over `--host`.** Home lists the far machine's projects there, but an
- errand is a short conversation this surface opens **on this computer**, in a folder of its
- own beside the projects — and over a connection that is the wrong machine to run the work
- on. So the row is not offered. Everything else on a remote home works.
+- **It will not work over `--host` or `--at`.** Home lists the far machine's projects and
+ automations there, but `ask here` answers `this window cannot ask from home`. Say it in the
+ conversation instead: its card crosses the connection like any other, and the automation
+ is saved on that machine (*Running on another machine*).
diff --git a/internal/manual/chat/automations.md b/internal/manual/chat/automations.md
new file mode 100644
index 0000000000..02dedb052f
--- /dev/null
+++ b/internal/manual/chat/automations.md
@@ -0,0 +1,656 @@
+# Automations — reminders, scheduled work and watches that run on a clock
+
+## What is an automation — a reminder, scheduled work or a watch
+
+An **automation** is something codeaf does on a clock **while it is open**. It is a
+schedule and an action, and a watch adds a look. What it holds decides which of three
+kinds it is, and `/automations` names the kind in these words:
+
+| Kind | Said like | What happens when it runs |
+| --- | --- | --- |
+| `reminder` | "remind me at 6 to leave" | it says its line; no model is called |
+| `scheduled work` | "every Monday at 9, draft the weekly update from the git log" | a piece of work runs on its own, unattended, and says how it went |
+| `watch` | "tell me when CI on main goes red" | it looks on a rhythm, the model judges what it saw against your condition, and when that turns true it says a line or runs work |
+
+You make one by **saying it** in a conversation, by **typing** `/automations add …`, or
+from home with **`ask here`** — and every road ends on a card: **nothing is saved until
+you say yes**. `/automations` lists them, with what each one last came to.
+
+They run **only while a codeaf window is open** (*Does it run when codeaf is closed*). A
+rule such as "always use tabs here" is not an automation: it has no time and nothing to
+run, and it is kept as a memory marked always (*Rules are not automations*).
+
+## How do I check on it later — have codeaf look again at a time, or keep watching and tell me
+
+Three ways, and each is a sentence in the conversation:
+
+- **"Remind me at 5 to check the deploy"** — a reminder: at 5, one line in this
+ conversation and a desktop notification, and you do the checking.
+- **"At 5, check whether the deploy finished and tell me what you find"** — scheduled work:
+ at 5 the check runs on its own and its one-line result arrives here.
+- **"Keep an eye on the deploy and tell me when it finishes"** — a watch: it looks on a
+ rhythm (every 15 minutes, say) and speaks once the condition turns true.
+
+Each is proposed on a card first and saved only when you say so; `/automations` lists them
+afterwards. All three need a codeaf window open at the time — they run only while one is.
+Work you hand off with `/task` is different: it runs now, on its own, and reports back when
+it lands, whatever you are doing meanwhile.
+
+## Remind me at 6 — set a reminder by saying it
+
+Say it in any conversation — "remind me at 6 to leave", "remind me in 20 minutes to check
+the oven", "tomorrow at 9 remind me to call the bank" — and I propose it on a card headed
+`wants to remind you`. The card shows the line it will say (`says · time to leave`) and
+when, in words and exactly: `today at 18:00 · first run Sat 10 Oct 18:00`. Its answers are
+`1 Save` and `0 Don't save`, and typing a change under it corrects it in your own words.
+
+At the time, one dim line appears in the conversation you made it in —
+`leave · done · time to leave` — and a desktop notification is raised (*Where does the
+news arrive*). **A reminder calls no model and costs nothing.**
+
+A time that has already passed is refused rather than moved, and I propose again from the
+real time. "In 20 minutes" is sent as the distance itself, so it needs no clock; a time of
+day is worked out from the clock line I am given, and every answer the `automation` tool
+gives ends with the time now — there is no need to run `date` first. If
+codeaf is closed when the moment comes, the reminder is not lost: it is said once when a
+codeaf window is next open, marked `late`. A reminder made with nobody there to
+answer the card — inside a task, or a headless `codeaf chat --once` — is not set up at all.
+
+## Do something every Monday — scheduled work at a time or on a rhythm
+
+"Every Monday at 9, draft the weekly update from the git log", "at 7 tomorrow run the full
+test suite and tell me what broke", "every night at 2 bump the dependencies" — each is
+proposed on a card headed `wants to schedule work`, showing what it does (`does · …`),
+where (`in your checkout` or `in a separate worktree, kept for review`) and what one run
+may take and spend: `up to 30m and $5.00 a run`.
+
+**A run is your own conversation, done later.** It has your models, keys, connected
+accounts, memory, the rules you keep with `/always`, and **your own approval rules**.
+Nobody is there to ask, so a call your rules would ask you about is refused, and the first
+such refusal stops the run, naming the call:
+`weekly update · your call · needed your ok to run bash git push`. Allow that call with
+`always` in a conversation — or answer it during `2 Save and run it now` — and later runs
+go through.
+
+**The work says how it went.** It finishes by reporting `done` or `incomplete` with a short
+summary, and that summary is the line you read. A run that ends without saying is
+`incomplete · it ended without saying how it went`. Each run's transcript is kept, and
+opens from the automation's history. A run cannot start a task, change settings, or set up
+another automation.
+
+## Tell me when CI goes red, or when a new invoice arrives in my mail — a watch
+
+A watch looks at one thing on a rhythm: **a command** run in the project
+(`gh run list -b main -L 1`), **files** matching a pattern (`docs/**/*.md`), or **a tool** —
+often a connected account's. Every look is put to the model with your condition — "the
+latest run on main failed" — and it answers yes or no. **It speaks on the change to yes**:
+it says its line, or runs its work with what it saw, then stays quiet while the condition
+stays true. A no re-arms it, so the next yes speaks again. Made with `once`, the card says
+`tells you once, then stops`, and it finishes after it first speaks.
+
+The card is headed `wants to watch for something` and shows `looks · …` and
+`until · `, the rhythm, and — because the model judges every look — an
+estimate when the model's price is known: `about $0.40 a day while codeaf is open`.
+
+A look whose condition is not met is `nothing new`, and **that is never news**: no line, no
+notification. Its row says `last nothing new` and its history keeps every look. A look that
+cannot be judged — no API key, a provider error, an answer the model could not give — is
+`couldn't check` with the reason, and changes nothing about what the watch has seen.
+
+A command look runs for at most a minute with the provider keys taken out of its
+environment, and the model reads the last 8 KiB of what it printed and its exit status. A
+files look stays inside the project, lists at most 2,000 files, and says which are new,
+changed or gone since the last look. Prefer a command whose output carries the thing
+itself — a status field, a file's contents — rather than a bare exit code.
+
+## Will a watch retry if my API key is missing or the provider is disconnected — couldn't check, no API key
+
+Every look a watch takes is judged by a model — the low-tier one codeaf uses for its own
+small checks (the `sentinel` role), the same for every watch — through your own keys and
+connected accounts. **It uses the key your conversations use.** A model on a service that
+has no key is passed over, down to the model you chat with, so a cheap model on a service
+you never connected does not stop a check your own model can answer.
+
+The clock reads the key the way a conversation does: `OPENROUTER_API_KEY` (or
+`OPENAI_API_KEY`) from the environment, then the key saved in your profile, then your
+connected services, read again before every run. A key exported in one shell and not in
+another follows the **newest open window**: when a window started in a different
+environment opens, the clock hands over to it as soon as nothing is running.
+
+With no key anywhere, or with every provider that could judge it disconnected or failing,
+the look still runs and cannot be judged: it ends `couldn't check` with the reason —
+`couldn't decide: no key for : connect , or set a key in /settings` — and **it changes
+nothing about what the watch has seen**, so it neither speaks nor forgets.
+
+Nothing is retried in between. The next look on its rhythm simply tries again, so setting
+a key or connecting the provider again (`/connect`) is all it takes: the watch carries on
+from what it last decided, and speaks if the condition has turned true since. Each
+`couldn't check` is news — a line and a desktop notification — so a watch that cannot be
+judged does not go quiet without telling you.
+
+A reminder calls no model and is never affected. Scheduled work that cannot reach a model
+ends `incomplete · a fault: …`, and its next run tries again.
+
+## The card — what kind of card this is, what its options mean, what o Change does, how long you have to answer it, and how to answer, cancel or decline it
+
+Every automation reaches you as a card first, and **nothing is saved until you say so**. It
+is the card the standing card became, and its heading says which kind it proposes:
+`wants to remind you`, `wants to schedule work` or `wants to watch for something`. It shows
+the real thing, one fact per line, each only when there is something to say:
+
+- the title, and your own sentence in quotes when it differs from the title;
+- when, in words, and its first run: `every Monday at 09:00 · first run Mon 12 Oct 09:00`;
+- a rhythm exactly, with the zone it is read in: `cron 0 9 * * 1 · America/Toronto`, or
+ `every 15m`;
+- for a watch, `looks · …` and `until · …`;
+- `says · …` for a line, or `does · …` for work with `in your checkout` or
+ `in a separate worktree, kept for review`;
+- for anything but a reminder, the limits: `up to 30m and $5.00 a run`.
+
+The options: **`1 Save`**; **`2 Save and run it now`** — `2 Save and check it now` on a
+watch; and **`0 Don't save`**, the no, which reads `Nothing is saved and nothing runs.`
+**To change it, type the change and press enter** — the line under the card says
+`say what to change — when, what it does, where it runs` — and nothing is saved: I
+propose it again with your correction. **`o Change…`** takes a fresh request instead: the
+box reads `other: write an updated request, then enter`, and what you write replaces the
+request the card came from. **`esc` is not the no**: it puts the card off for later, and
+`alt+y` brings it back. My turn waits on the card for as long as it takes; to cancel it
+outright, press `0`.
+
+Answered, the card folds to its heading and one line saying what it came to:
+`weekly update · Save · saved`, or `weekly update · not saved`.
+
+## Save and run it now — try it once while you watch; what became of just once
+
+`2 Save and run it now` saves the automation, and then I do its work **once, right away, in
+this conversation**, while you watch. Any approval it needs is asked of you there, and an
+`always` you give is kept, so the unattended runs after it go through. On a watch the answer
+is `2 Save and check it now`: I take one look myself and tell you whether the condition
+holds now.
+
+It is not offered on a reminder — there is nothing to try; its whole content is the moment
+— nor on work kept in a separate worktree, because a turn in this conversation works in
+your checkout. `r` on its row in `/automations` runs any of them once, unattended, later.
+
+There is no "just once" answer that does the thing now without keeping it: to have it done
+once and not again, ask for it as ordinary work, or answer `0` and say so.
+
+## Schedules and cron — every 15 minutes, weekdays at 8:30, a cron line, time zones
+
+A schedule is **one moment** or **a rhythm**. A moment is a time ("at 6", "tomorrow at 9")
+or a distance from now ("in 20 minutes"). A rhythm is an interval or a cron line:
+
+- **An interval**, a minute or more: `15m`, `2h`, `1d` — `d` is a day. It is a grid laid
+ from when it was saved, so a late run never pushes the next one later. It counts real
+ time, so a `1d` saved at 09:00 runs at 08:00 or 10:00 after the clocks change; for a
+ fixed hour use a cron line, `0 9 * * *`.
+- **A cron line**, five numeric fields — minute, hour, day of the month, month, day of the
+ week — with ranges, lists and steps: `0 9 * * 1` is Mondays at 09:00, `30 8 * * 1-5`
+ weekdays at 08:30, `*/15 * * * *` every 15 minutes. Sunday is `0` or `7`. Names
+ (`MON`), `@daily` and seconds are not read. When both day fields are restricted, a day
+ matching either one runs.
+
+The list says a rhythm in words — `every day at 09:00`, `every weekday at 08:30`,
+`every Monday and Thursday at 09:00`, `on the 1st of every month at 09:00`,
+`every hour at :15`, `every 15 minutes` — and a line with no plain reading as
+`on the cron line 0 9 1-7 * 1`.
+
+**A cron line keeps the time zone it was made in**, so a Toronto rhythm stays a Toronto
+rhythm on a machine set to another zone. On the night the clocks go back a 01:30 line runs
+once; in spring a 02:30 line waits for the next day. A line that can never happen is
+refused: `the rhythm "0 0 30 2 *" never happens`. A rhythm under a minute is refused too.
+
+## /automations — see every automation, when it runs next, how it last went, and what its mark or dot means
+
+`/automations` opens the list — so does `alt+7`, the `Automations` word on the tab bar
+(between `Memory` and `Spend`), or the `automations` heading on home. One row per
+automation: its mark, its title, and one dim sentence — its kind, its schedule, where it is
+now, and its last run:
+
+```
+weekly update scheduled work · every Monday at 09:00 · next Mon 12 Oct at 09:00 · last done
+ci on main watch · every 15 minutes · running now · last nothing new
+leave reminder · today at 18:00
+```
+
+Where it is now reads `next …` for a rhythm waiting for its time, `running now`,
+`about to run`, `paused`, or `finished` — a one-time automation that has run, or a `once`
+watch that has spoken, kept with its history. The mark says the same. In plain glyphs:
+`○` waiting for its time, `◐` — the half-filled dot — a run in hand right now, `=` paused,
+`✓` finished, `?` when the last run needs you (`your call`, `couldn't check`) and `✕` when
+it was `incomplete`; a terminal with an icon font draws icons in the same places.
+
+Active ones come first, soonest first, then paused, then finished. The count beside
+`automations` on the tab bar and the map is how many of them have news — a last run worth
+telling you about that ended since you last looked. With none saved, the place keeps its
+heading and
+`reminders, scheduled work and watches · "remind me at 6" or "every morning at 9"`.
+
+## Pause, resume, run now, stop a run that is going, or delete an automation — stop the thing that runs every hour — the → strip
+
+On a row of `/automations`, `→` draws the row's verbs, and their letters work while it is
+drawn:
+
+| Key | Verb | What it does |
+| --- | --- | --- |
+| `r` | `run now` · `check now` on a watch · `say it now` on a reminder | one run outside its schedule, within seconds |
+| `s` | `stop the run` | only while a run is in hand; it ends as `stopped` |
+| `p` | `pause` / `resume` | a paused one wakes for nothing until it is resumed |
+| `e` | `edit` | puts its own `/automations edit …` line in the message box |
+| `d` | `delete` | asks again, then removes it and its whole history |
+| `o` | `open where it was asked` | the conversation that made it |
+
+Each says what it did on the message line: `asked to run now · weekly update`,
+`asked to stop · weekly update`, `paused · weekly update`, `going again · weekly update`,
+`deleted · weekly update`. The first `d` only asks:
+`d again deletes it and its history · esc keeps it`. **There is no undo for delete.** A run in
+progress is stopped. A separate worktree a run of work left is kept, because its changes are
+yours to review.
+
+**A pause is not a miss**: a resumed rhythm starts at its next slot after now and nothing
+catches up for the paused time; a one-time reminder resumed after its moment runs once,
+late. A finished one offers no pause. The foot names the row's verbs:
+`enter its history · → verbs: run now, pause, edit, delete, open where it was asked · tab next place · esc`.
+
+## Change an automation — edit its time, what it does or where it runs
+
+Press `→` then `e` on its row in `/automations`, and the automation's own line lands in the
+message box, exactly as it would have to be typed:
+
+```
+/automations edit 3f2a9c1e5b7d4a60 title "weekly update" zone America/Toronto every "0 9 * * 1" do "draft the weekly update from the git log" folder /home/you/code/app
+```
+
+Change what you want and press enter. Nothing is saved yet: a card headed
+`save this change?` shows the automation as it would be, and `1 Save` saves it —
+`saved weekly update · next Mon 12 Oct at 09:00`. Any clause you leave out stays as it
+was. A new schedule is worked out again from now, and a finished one-time automation given
+a new moment wakes again. A watch whose look or condition changed forgets what it had
+seen, so its next yes speaks.
+
+A watch that looks through a tool is written without its look — a tool's arguments are not
+something anybody types on one line — and the edit leaves that look as it was.
+
+**I have no verb that changes an existing automation in place.** In a conversation I can
+pause, resume, run, delete, list it and read its history; changing one is `e`, or a new
+proposal that replaces it.
+
+## History — every run, read a run's whole result, why did I get this reminder, and open where it was asked
+
+`enter` on a row of `/automations` opens its history. The top says what it is — its title
+and schedule, the rhythm exactly, what it looks at and its condition, what it says or does —
+and under it every run, newest first — the last hundred are kept, and older runs and their
+transcripts are let go:
+
+```
+today at 09:00 done · drafted the update from 14 commits · $0.42
+Mon 5 Oct at 11:20 done · late 2h · drafted the update from 9 commits · $0.38
+Mon 28 Sep at 09:00 your call · needed your ok to run bash git push
+```
+
+Each row is when it started, what it came to, how late it was, its one line and what it
+cost. **The run under the cursor shows its whole result under its row**, in ordinary text —
+a piece of work's full report, a watch's sighting, a reminder's line — and the history opens
+on the newest run, so the latest result is the first thing you read; `↑`/`↓` move to
+another. `enter` on a run of work opens that run's own transcript, read like any
+conversation; a run that only said a line keeps none: `this run kept no transcript`.
+`esc` goes back to the list. With nothing run yet it says `it has not run yet`.
+
+**Why did I get this?** `→` then `o` on its row — `open where it was asked` — opens the
+conversation the automation was made in, where your own sentence and the card you answered
+are. From inside that conversation it says `you are already in it`. You can also ask me —
+"what did the weekly update do last week" — and I read the same history.
+
+## What a run came to — what done, nothing new, your call, incomplete, stopped, couldn't check and late mean
+
+| Reads | When |
+| --- | --- |
+| `done` | a reminder said its line, a watch's condition turned true, or work reported it finished |
+| `nothing new` | a watch looked and the condition was not met, or still holds since it last spoke |
+| `your call` | work stopped on a call only you could allow: `needed your ok to run bash git push`; or did not start because today's spending limit is reached |
+| `incomplete` | work did not finish: `ran out of its 30m`, `reached its $5.00 cap`, `a fault: …`, its own summary, or `it ended without saying how it went` |
+| `stopped` | `stopped by you`, `the automation was deleted`, or `codeaf closed before it finished` — and that last one runs again when a window next opens |
+| `couldn't check` | a watch could not look or be judged: `couldn't look: …`, `couldn't decide: …`, `ran out of time`, or why the model could not tell |
+
+A run that started more than two minutes after its time adds `late` and how late it was:
+`late 2h`, `late 3d`. A run you asked for with `r` is never late.
+
+Every one but `nothing new` is news: a line in the conversation that made it, and — except
+`stopped`, which you caused — a desktop notification. A run closing codeaf cut off is not
+news at all: it is paused, and the run that picks it up is the one you hear about. There is
+no `failed`: a run that did not finish says why in its line.
+
+## Where does the news arrive — the line in the conversation, on the page you are on, and the desktop notification
+
+**In the conversation that made it.** When a run ends, one dim line appears there:
+`weekly update · done · drafted it`, `ci on main · done · CI on main is red`. It is not part
+of the transcript, it does not wake me, and it costs nothing. A window draws it only while
+that conversation is the one in front. One that was not in front gets its lines when it
+next comes to the front — a window starting with it, or a switch to it in a window already
+open, within a couple of seconds — every run worth telling you about that ended since you
+last wrote there, up to the last ten of each automation. A line a window has already drawn
+is not drawn again when you switch back. Its history on `/automations` has every run.
+
+**On the page you are on.** While home or a place is in front — `/automations` after `r`,
+say — the same line is said on that page's message line, so `r` is answered where you
+pressed it. Another conversation in front gets the notification, not the line: its
+transcript is not where another conversation's news belongs.
+
+**On the desktop.** A run that is news raises one notification, titled `codeaf · `,
+saying what it came to and as much of the result as a banner shows — up to about 180
+characters of a work run's report, not only its first line — **whether or not codeaf is the window you are looking at**. It is raised **once per run per machine**, by
+whichever open window claims it first. A `stopped` run and a watch's `nothing new` raise
+none, and neither does a run closing codeaf cut off. There is no setting for it.
+*Desktop notifications* says which application sends them and how to turn them on.
+
+**Elsewhere.** Home's `since you left` panel has a line for each automation whose last run
+was news and ended while you were away, opening `/automations`; `/status` has an
+`automations` line; and a conversation's side column lists the automations it set up.
+
+## Desktop notifications — no notification, which app sends them, the sound, the logo, and what clicking one opens
+
+**Through your terminal, where it can raise one.** In iTerm2 the banner comes from iTerm2
+(its own `OSC 9`), and in Ghostty, kitty, WezTerm and foot from that terminal (`OSC 777`):
+it carries the terminal's icon and sound, and **clicking it brings back the window and the
+tab codeaf is in**. Every other terminal — Apple's Terminal among them, and anything inside
+tmux — sends it through the operating system: on macOS through `osascript`, so it comes
+from **Script Editor**, plays the `Glass` sound when your settings allow notification
+sounds, and clicking it opens Script Editor, which codeaf cannot change; on Linux through
+`notify-send`, with codeaf's logo, when it is installed.
+
+**No notification?** macOS keeps a new sender quiet until you allow it. The first banner a
+machine raises is followed, once, by a line naming the application —
+`desktop notifications come from iTerm2 · not seeing them? allow iTerm2 in notification settings`
+— and on home or a place a click on that line (`cmd`-click in most terminals) opens System
+Settings → Notifications. In iTerm2, the profile's *Terminal → Notifications* setting must
+also let escape sequences send them.
+
+**No codeaf logo on macOS.** macOS 26 lets only an application signed with an Apple
+Developer ID ask to send notifications, so a banner of codeaf's own waits on a signed
+helper. Windows has no desktop banner: the lines are the news there.
+
+## Automations on home — when does my reminder go off, the automations panel, /status and since you left
+
+**Home's `automations` panel** lists every automation that will run again, soonest first —
+its title, and under the cursor the sentence `/automations` draws, which says when it goes
+off next: `reminder · today at 18:00`,
+`scheduled work · every Monday at 09:00 · next Mon 12 Oct at 09:00`. The heading and every
+row open `/automations`; nothing is paused or changed from home itself. With nothing
+on the clock the panel keeps its heading and
+`reminders, scheduled work and watches · "remind me at 6" or "every morning at 9"`, and on
+a short terminal it is the first panel to give way.
+
+**`since you left`** has one line per automation whose **last** run was worth telling you
+about and ended while you were away — `weekly update · done · drafted it` — and `enter` on
+it opens `/automations`. It reads only that last run: a watch whose newest look was
+`nothing new` has no line there, and the history (`enter` on its row in `/automations`)
+has every run.
+
+**`/status`** has one line while anything is on the clock:
+`automations 3 on the clock · next weekly update Mon 12 Oct at 09:00 · 1 running`. With
+nothing on the clock there is no line at all. Over `--host` or `--at`, the panel, the line
+and `/automations` all read the far machine's automations.
+
+On home, typing `aut` offers the place, with `2 automations on the clock` beside it.
+
+## The automations in a conversation's side column — the list on the right, and + /automations
+
+The column on the right of a conversation lists, under its tasks, the automations **this
+conversation** set up that have not finished: the label `automations`, then at most three
+rows — each its mark and its title — with `automations · 2 more` on the label when there
+are more. A row opens `/automations` with the cursor on that automation.
+
+Under them is the door `+ /automations`. Pressing it puts `/automations ` at the start of
+the message box, keeping what you had typed after it, and hands the keyboard back: send it
+bare to open the list, or finish it as `/automations add …`. Pressing it twice does nothing
+the second time. The section is there whenever the window can read automations — before
+there are any it is the door alone.
+
+## Does it run in the background when codeaf is closed — keep working with the terminal or the app shut, is there a helper, will a reminder fire, and a missed run or check while the laptop or computer was off or asleep
+
+**Automations run only while a codeaf window is open.** Nothing is installed on the
+operating system — no launchd agent, no systemd timer, no helper or background service — and
+nothing runs with every window or the whole app closed. One small process, `codeaf clock`, does the running: a window
+starts it when none is running, every window checks every half minute that it still is, and
+it leaves by itself half a minute after the last window closes.
+
+**What fell due while codeaf was closed, or while the machine slept, runs once** when a
+window is next open, marked `late`. A rhythm that missed five slots runs one run, and its
+next slot is the first after now: a weekly report after a fortnight away is written once,
+not twice. A one-time reminder whose moment passed is never dropped; it is said when you
+come back.
+
+**Closing the last window pauses a run in progress.** After that half minute — the grace
+that lets a window restart onto a new build — the run is stopped, and it **runs again,
+once, when a window is next open**, marked `late` when that is more than two minutes later.
+Its history keeps both: `stopped · codeaf closed before it finished`, then the run that
+picked it up. Nothing asks before you close, and the cut-off attempt raises no line and no
+notification.
+
+To keep automations running, keep a codeaf window open: on this machine, or attached to
+another machine with `--host` or `--at`, which keeps that machine's automations running
+(*Automations on another machine*). A conversation its engine goes on holding after you
+closed its window is not an open window, and does not keep them running.
+
+## Quitting while an automation is running — does closing codeaf stop it
+
+**Quitting never asks.** `ctrl+c` at rest and the last `/quit` leave at once, an automation
+running or not. Closing codeaf **pauses** automations: a run it cut off runs again, once,
+when a window is next open (*Does it run in the background when codeaf is closed*), and
+what falls due while it is closed catches up the same way. With another codeaf window still
+open, closing this one stops nothing at all.
+
+The cut-off attempt says nothing — no line, no notification. Its history on `/automations`
+keeps it as `stopped · codeaf closed before it finished`, beside the run that picked it up.
+(Until 2026-10-14 the first `ctrl+c` warned `1 automation is running — quitting stops it`
+and only the second left.)
+
+## Several windows, codeaf clock, CODEAF_HOME and CODEAF_NO_AUTOMATIONS — which window runs them, and where they are kept
+
+There is **one clock per codeaf home on a machine**, whichever window started it, and every
+window reads the same database, `~/.codeaf/v3/automations/automations.db` — so the list,
+the lines and the notifications agree in all of them. Two windows racing to start a clock end with one,
+and a run does not belong to the window that happened to start it: it stops only when the
+last window is gone.
+
+`CODEAF_HOME` moves all of it: a home of its own has its own automations and its own clock.
+Two builds of codeaf under one home share the one clock and the one store, and a slot can be
+taken only once, so nothing runs twice.
+
+**`CODEAF_NO_AUTOMATIONS`**, set to anything in a window's environment, keeps that window
+from starting the clock. The window still shows the list, draws the lines and counts as
+open, so a clock another window started keeps running while it is there; with no other
+clock, nothing runs and saved automations wait.
+
+The clock inherits the environment — keys, `PATH`, time zone — of the window that started
+it. When its own build is replaced it leaves as soon as nothing is running, and the next
+window's check starts the new one. What it could not do outside a run goes to
+`~/.codeaf/v3/automations/clock.log`.
+
+## What does an automation cost — 30 minutes and $5 a run, and where the spend shows
+
+**A reminder costs nothing**: no model is called. **Scheduled work** costs what its run
+spends on your models. **A watch** costs its judgment — a low-tier model reads every look —
+plus any work it runs, and its card estimates the judging:
+`about $0.40 a day while codeaf is open`, or `under a cent a day while codeaf is open`.
+
+Every run of a watch or of work is held to two limits, and the card shows both: **30
+minutes** and **$5 a run** unless you named others — "give it an hour and at most $2" — or
+typed `time` and `usd`. Work that reaches its money stops as
+`incomplete · reached its $5.00 cap`; a run out of time is `incomplete · ran out of its 30m`
+(`couldn't check · ran out of time` for a watch).
+
+**Your daily spending limit holds over work too.** Once today's spending has reached it, a
+run of work does not start, and nothing is spent on it: it reads `your call · today's
+spending limit is reached · $512 spent of $500 · /budget day changes it`. A watch's looks are
+not held to it — each one costs a fraction of a cent, and a refused look would be news every
+few minutes — but work a watch would run is.
+
+On `/spend` the automations have a table of their own, `by automation`: each one with how
+many calls it made, what one call cost (`$0.01 a call`) and its total. The Spending tab of
+`/settings` shows the default as a reading rather than a setting: its `per automation` row
+reads `$5 a run`, with `each automation may name its own` beside it.
+
+## Where does scheduled work run — in your checkout, or in a separate worktree kept for review
+
+An automation runs in the project of the conversation that made it — or in your home folder
+when it belongs to no project. A typed one runs in its window's project unless it names a
+`folder`.
+
+**Work runs in your checkout** by default, as a turn of your own does: what it changes, it
+changes in the live files. Ask for it to be kept for review — "and keep it on a branch for
+me to look at" — and the card says `in a separate worktree, kept for review`: each run is
+cut a git worktree of its own from your last commit — uncommitted changes in the checkout
+are not in it — on a branch named `automation/automation--`. The branch is kept
+after the run, and nothing merges or deletes it for you. That needs a git repository with a
+commit — `a separate worktree needs a git repository with a commit` — and only work can have
+one: `only work can run in a separate worktree`. It is a separate copy, not a sandbox.
+
+`2 Save and run it now` is not offered for worktree work, because a turn in this
+conversation works in the checkout.
+
+## /automations add — the exact typed form, with examples
+
+`/automations add` makes one with **no model in between**: you type exactly what you want,
+and it is read back on a card — `save this automation?` — before anything is saved. Every
+clause is a word and one value; a value with a space is in double quotes, and `\"` puts a
+quote inside one.
+
+```
+/automations add leave at 18:00 say "time to leave"
+/automations add stretch in 2h say "stand up and stretch"
+/automations add "weekly update" every "0 9 * * 1" do "draft the weekly update from the git log"
+/automations add "ci on main" every 15m look "gh run list -b main -L 1" until "the latest run failed" once say "CI on main is red"
+/automations add docs every 1h files "docs/**/*.md" until "a page was added" say "new docs page"
+/automations add nightly every "0 2 * * *" do "bump the dependencies and run the tests" worktree time 1h usd 2
+```
+
+The title comes straight after `add`. The words, each with one value:
+
+- **when** — `at` (`18:00` or `6pm` today, `tomorrow 9am`, `monday 9am` — the next one —
+ `2026-10-12T09:00`; no quotes needed), `in` (`20m`, `2h`, `1d`), or `every` (an interval,
+ or a cron line in quotes); `zone` (`America/Toronto`) is read first;
+- **what** — `say ""` or `do ""`;
+- **a watch** — `look ""` or `files ""`, `until ""`, and `once`;
+- **where and how much** — `worktree` or `checkout`, `time 45m`, `usd 2.50`, `folder ~/code/app`
+ (`~` is the home of the machine that keeps and runs it, which over `--host` is that machine).
+
+`1 Save` on its card — `It runs on this schedule while codeaf is open.` — saves it and says
+when it first runs: `saved stretch · next today at 16:30`. `0 Don't save` saves nothing.
+
+## /automations edit, run, pause, resume and delete — by id, and what a typed line says when it cannot be read
+
+The other forms take the automation's id — sixteen hexadecimal characters, of which the
+first four or more are enough when no other id starts the same way:
+
+```
+/automations edit 3f2a9c every 30m
+/automations run 3f2a9c
+/automations pause 3f2a9c
+/automations resume 3f2a9c
+/automations delete 3f2a9c
+```
+
+The list's rows do not print the id: `→` then `e` on a row puts its full line, id and all,
+in the box, and I can list them with their ids. `edit` raises a `save this change?` card
+like `add`; **`run`, `pause`, `resume` and `delete` do not ask** — typing the id is the
+confirmation — and answer on the message line as the row's own keys do. An id nothing
+matches says `no automation has the id 3f2a9c`.
+
+A line that cannot be read says why and saves nothing:
+
+- `add needs a title first: add "weekly update" every "0 9 * * 1" do "…"`
+- `unknown word "daily" — the words are title, at, in, every, zone, say, do, look, files, until, time, usd, folder, once, worktree, checkout`
+- `cannot read the moment "someday" — say 18:00, 6pm, tomorrow 9am, monday 9am or 2026-10-12T09:00`
+- `Sat 10 Oct 09:00 has already passed`
+- `a watch needs a rhythm to look on` · `a watch needs the condition it is looking for`
+- `an automation needs something to say or something to do`
+
+## Ask here — set one up from home without opening a conversation
+
+On home, type the sentence — "remind me at 6 to leave", "tell me when CI on main goes red"
+— and press `↑` onto `ask here` and `enter`, or `ctrl+enter` on a terminal that can send
+it. A short exchange opens instead of a new conversation, and while it has the keyboard it
+is home's whole screen — `esc` puts the list back, with the exchange as a row on it. Its
+card arrives there: `1`, `2` and `0` answer it as they do in a conversation, and `o` opens
+the pane's own box for a correction. Once it is saved the pane says
+`saved · /automations lists it`.
+
+The exchange is kept in a folder of its own, under `~/.codeaf/v3/errands/`, and it is the
+conversation `open where it was asked` opens, so "why did I get this?" still has an answer.
+The automation runs in the project the exchange belongs to: the project row the cursor was
+on, else the project this window is in, else your home folder. *Asking from home* has the
+whole of it.
+
+## Rules are not automations — always use tabs, never push to main
+
+"Always use tabs in this repository", "never force-push a shared branch", "our commits are
+written in the imperative" — these have no time and nothing to run, so they are not
+automations. **They are rules: memories marked always**, put in front of every turn of
+every conversation where they hold, and in the brief of every task started there.
+
+`/always ` keeps one; bare `/always` lists the rules in force here. Said in a
+conversation, I keep one with `remember` set to always, on a card that asks you first.
+*What I remember* — its `Always` sections — has the rest: where a rule holds, how many ride
+along, and switching one off.
+
+## How I set one up when you say it — the automation tool, and automation_report
+
+You never name a tool. When what you say is a reminder, scheduled work or a watch, I call
+the **`automation`** tool to propose it, and its card is what you see. The same tool
+**lists** the automations with their ids, **pauses**, **resumes**, **deletes** and **runs**
+one now, and reads one's **history** — so "pause the CI watch", "delete the weekly update"
+and "what did the nightly bump do on Tuesday" work in any conversation. A change made that
+way needs no card and leaves one dim line in the conversation — `ci on main · paused`,
+`weekly update · deleted` — while making a new one always goes through its card.
+
+A run of scheduled work ends by calling **`automation_report`** with `done` or `incomplete`
+and a short summary: that summary is the line you read, and a run that never calls it is
+`incomplete · it ended without saying how it went`.
+
+The `automation` tool is not there inside a task, inside an automation's own run, or where
+nothing can keep automations — so an automation can never set up another one.
+
+## Where did standing orders go — what happened to /standing, the standing orders page, keeping an eye, and turning off background checks
+
+**Standing orders are gone, and automations replaced them.** `/standing` and `/orders` are
+not commands any more — they answer `there is no command called /standing · / lists them`
+— and the standing orders page, the `stand` tool, the keeping-an-eye line on the status
+row and in `/status`, home's standing panel and the background checks setting went with
+them. There are no background checks left to turn off, on this machine or a remote one.
+What a standing order did is now one of two things:
+
+- **something on a clock** — a reminder, a routine, a watch — is an **automation**:
+ `/automations`, or say it;
+- **a rule** — "always run the tests before you commit" — is a **memory marked always**:
+ `/always`.
+
+**Standing orders were not carried over.** None of them is listed or changed anywhere in
+codeaf any more: make again, as automations or rules, the ones you still want. Their old
+folder, `~/.codeaf/v3/standing/`, is left on disk. The standing card's
+answers went too: its `3 just once` is `2 Save and run it now`, which saves it as well, its
+`2 change when or where` is typing the change under the card, and every no is
+`0 Don't save`.
+
+**Nothing is installed on the machine any more.** Earlier versions installed a background
+timer — `ai.agentfield.codeaf.tick` under launchd, `codeaf-tick.timer` under systemd, and
+the older `ai.agentfield.codeaf.wake` — that ran `codeaf tick` with no window open. This
+version removes them, whoever installed them, on every start.
+
+## What automations cannot do
+
+- **No — they do not run while codeaf is closed.** Keep a window open, here or attached to
+ another machine; anything missed runs once, late, when one is.
+- **No triggers but the clock.** A watch looks on a rhythm; nothing wakes on a push, a
+ webhook or a file event.
+- **No phone notifications** — the desktop notification of the machine you are sitting at,
+ and the line in the conversation.
+- **Nothing an automation runs can set up another automation, start a task or change your
+ settings.**
+- **One run at a time per automation.** A slot that comes while the last run is still going
+ is passed over, not queued; at most two pieces of work and four looks run at once.
+- **No rhythm under a minute**, no cron names, `@daily` or seconds.
+- **No thinking rung of its own** — `alt+e` has nothing to move on an automation.
+- **No exception for one conversation or one project** — an automation is one thing in one
+ place, and pausing it pauses it everywhere.
+- **No undo for delete**, and no moving an automation to another machine.
diff --git a/internal/manual/chat/choosing-a-folder.md b/internal/manual/chat/choosing-a-folder.md
index 64188321a4..5a0447baf1 100644
--- a/internal/manual/chat/choosing-a-folder.md
+++ b/internal/manual/chat/choosing-a-folder.md
@@ -672,6 +672,20 @@ A folder I worked out for myself — the project a task turned out to stand in,
one you chose — is not called attached and is never quoted to me as instructions you gave;
it is only where work went.
+## Which folder an automation works in — the conversation's own folder, not the ones you chose
+
+An automation runs in the folder of the conversation that made it — the directory the
+status line shows — or in your home folder when that conversation belongs to no project.
+A typed `/automations add` runs in its window's project unless it names a `folder`. A
+watch's command runs there, and a files look stays inside it. The folders you attached
+with `/folder` do not go with it: each run of work is a conversation of its own, opened
+on that one folder and told about none of the others.
+
+Work changes that folder directly, as a turn of your own does, so nothing waits for
+`/land` — or, when you asked for it to be kept for review, each run gets a separate git
+worktree whose branch is kept for you to merge
+(*Automations*, *Where does scheduled work run*).
+
## Attach a folder — /attach with a directory after it
`/attach ` with a **folder** after it used to refuse with
diff --git a/internal/manual/chat/collections.md b/internal/manual/chat/collections.md
index 876a6dbfbe..2ca637cac6 100644
--- a/internal/manual/chat/collections.md
+++ b/internal/manual/chat/collections.md
@@ -3,7 +3,7 @@
## How do I group chats in logical folders
`codeaf collections` is a local command for organizing references to conversations,
-tasks, ongoing items and files. A collection is a logical folder with a stable ID
+tasks and files. A collection is a logical folder with a stable ID
and name. Several collections can reference the same record. Collections can
contain other collections, including one child shared by several parents;
circular membership is refused. Renaming a collection preserves its ID.
@@ -64,7 +64,10 @@ belong to collections.
## Adding, removing and finding a chat or work reference
Use `codeaf collections add `.
-The kinds are `collection`, `conversation`, `task`, `standing` and `artifact`.
+The kinds are `collection`, `conversation`, `task` and `artifact`. There is no kind
+for an automation. `standing` named a standing item, which codeaf no longer has: a
+row that already holds one can still be found and removed, and adding a new one is
+refused with `standing items are gone, so a collection cannot gain one`.
A task also requires `--session ` because task numbers repeat
in different conversations. For example, `codeaf collections add
task 1 --session ` records that specific task.
diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md
index 5e6f5201ff..073c6325ec 100644
--- a/internal/manual/chat/commands.md
+++ b/internal/manual/chat/commands.md
@@ -4,9 +4,9 @@
`ctrl+enter` over a `/command` takes plain enter's command action, even while a turn
is running. With no turn running or on the new-chat start page it also acts as plain
-enter. A live `/standing`, `/orders` or `/task` tag inside a sentence also keeps
-plain enter's door. It queues only non-empty words mid-turn from this conversation's
-own composer, with no live send-door tag.
+enter. A live `/task` tag inside a sentence also keeps plain enter's door. It queues
+only non-empty words mid-turn from this conversation's own composer, with no live
+send-door tag.
The decoded chord works wherever the terminal sends it; the queue hint and tip are
advertised only after the terminal's key-support reply. A terminal that cannot send
it delivers plain enter or, on some keyboards, a newline.
@@ -14,8 +14,7 @@ it delivers plain enter or, on some keyboards, a newline.
Only messages queued from this window can be clicked back out of the queue. Their
words return to this conversation's composer. If a draft is already there, the
returned message is appended on a new line, keeping the draft and tray and renumbering
-its paste chips. `/standing ` sends marked words even with pictures or a picked
-shape on the tray, leaving those on the tray; the standing hint's absence is separate.
+its paste chips.
## Bash mode — run a shell command with ! and keep its output in context
@@ -82,7 +81,7 @@ A filter with no matches shows `no commands match` and keeps unrelated results h
The list follows the caret as well as edits. The box remains editable while it is open.
A command chosen inside a sentence completes its token rather than running on its own.
-Tab never submits that sentence or a finished `/task` or `/standing` tag; Enter retains
+Tab never submits that sentence or a finished `/task` tag; Enter retains
its send behavior. With no matching command, Tab leaves the draft and screen unchanged.
On home, command rows describe what they will do there, including commands that open a
@@ -139,42 +138,44 @@ tag and its chip so it is clear why that door acted.
Below the 256-colour rung there is no background to draw, and the command reads as
**bold** instead. On a NO_COLOR terminal there is no mark at all.
-## Use /standing or /task in the middle of a sentence
+## Use /task in the middle of a sentence — the one command that acts away from the start
-A slash command at the start still runs normally. Two commands are also **send-door
-tags** anywhere else in a draft:
+A slash command at the start still runs normally. One command is also a **send-door
+tag** anywhere else in a draft:
-- `keep the tests green /standing` removes `/standing` from the sentence and sends
- `keep the tests green` through the standing-order door. `/orders` is the same tag.
- `please investigate the flaky test /task` removes `/task` and sends the remaining
brief through the same door `/task ` opens.
-Both roads end at something you can see: standing raises its ratification card, and task
-starts one worker in the open — its started row, and its row on the roster, where it can be
-stopped. A pasted tag does not silently do work: a live send-door tag is named on
-the hint line under the box, and it only ever opens the door you can see. With no other words, each tag behaves like that command's existing
-bare form. With two live tags codeaf sends nothing, leaves the draft in the box, and says
+It ends at something you can see: the task starts one worker in the open — its started
+row, and its row on the roster, where it can be stopped. A pasted tag does not silently do
+work: a live tag is named on the hint line, `enter sizes this task`, and it only ever opens
+the door you can see. With no other words, the tag behaves like a bare `/task`. With two
+live tags codeaf sends nothing, leaves the draft in the box, and says
`one tag per send — backspace one to make it plain words`.
+`/standing` and `/orders` were tags too, and went with standing orders: in the middle of a
+sentence they are plain words now, and at the start they answer
+`there is no command called /standing · / lists them`. Something to do on a clock is an
+automation — say it, or use `/automations` (*Automations*).
+
Other commands remain ordinary prose away from the start. `later I will run /compact on
this` is sent literally, and codeaf still chips `/compact` there — the mark says the word
is recognised, not that enter will run it.
-## Asking what a quoted /task or /standing command does
+## Asking what a quoted /task command does
`What does '/task' do?` sends that whole sentence as an ordinary message. A command
named in straight single or double quotes, or curly quotes, never acts as a send-door
-tag. `What does “/task” do?` starts no task; quoting `/standing` raises no standing
-order. The quoted command still wears the composer's chip in explanations and help,
-and a quoted path stays plain. In the sent message, a quoted send-door name is plain
-because no door acted on it.
+tag. `What does “/task” do?` starts no task. The quoted command still wears the
+composer's chip in explanations and help, and a quoted path stays plain. In the sent
+message, a quoted send-door name is plain because no door acted on it.
Without quotes, `What does /task do?` still contains a live task tag: Enter starts a
task with the remaining words. Quote the command when you want to ask about it.
## Backspace after a slash tag makes it plain words
-With the caret immediately after a live `/standing`, `/orders`, or `/task` tag, the first
+With the caret immediately after a live `/task` tag, the first
backspace removes its chip but deletes no letter. The word is now plain prose and Enter
sends it to the conversation normally. A second backspace edits the word as usual.
@@ -186,10 +187,9 @@ all demotions.
## Slash command did nothing
-A command in the middle of a sentence acts only when it is `/standing`, `/orders`, or
-`/task`. Every other command there is still highlighted — `/compact`, `/clear` and
-`/model` wear the same chip as any recognized word — but enter sends it as ordinary
-words. Put one of those commands at the start if you want to run it. If a send-door word
+A command in the middle of a sentence acts only when it is `/task`. Every other command
+there is still highlighted — `/compact`, `/clear` and `/model` wear the same chip as any
+recognized word — but enter sends it as ordinary words. Put one of those commands at the start if you want to run it. If a send-door word
is plain, it was demoted with backspace; edit it or type it again to make it live.
The command list follows that rule when you choose a row from it:
@@ -197,8 +197,8 @@ The command list follows that rule when you choose a row from it:
- If the word is at the very start of the box and there is nothing else in it, enter runs
the command — or, for a row that takes an argument, writes `/model ` into the box.
- Anywhere else, choosing a non-door row replaces just that word with the command's name,
- parks the caret after it, and runs nothing. Choosing `/standing` or `/task` completes a
- live tag; Enter on the finished tag routes the send as described above.
+ parks the caret after it, and runs nothing. Choosing `/task` completes a live tag;
+ Enter on the finished tag routes the send as described above.
So the list can never send a message you did not send yourself, and choosing a row mid
sentence is a way of spelling a word rather than a second way of running a command.
@@ -262,16 +262,20 @@ Canonical word, the other words it answers to, its argument form, and what it do
| `/land` | — | `now` | …puts it in: a branch merged for a repository, files copied back for a plain folder |
| `/land` | — | `` | …when more than one folder is waiting; `/land now` puts that one in |
-## Rewind, approvals and standing commands
+## Rewind, approvals and automations commands
| Command | Aliases | Argument | Effect |
|---|---|---|---|
| `/rewind` | `/undo`, `/back` | — | opens the rewind timeline — the whole conversation as a list (esc esc is the quick inline version) |
| `/permissions` | `/perms` | — | lists what runs without asking; `d` drops a line |
+| `/automations` | — | — | opens the automations place (also `alt+7`): every reminder, piece of scheduled work and watch, with its schedule and how its last run went; `→` then `r` runs, `s` stops, `p` pauses, `e` edits, `d` deletes, `o` opens where it was asked |
+| `/automations` | — | `add …` | adds one exactly as typed, read back on a `save this automation?` card first; `edit`, `run`, `pause`, `resume` and `delete` take its id |
| `/autonomy` | — | — | prints this project's rules for questions while you are away; refuses when the conversation has no project to keep them in |
| `/autonomy` | — | `` | changes one project question rule; refuses an unknown kind or rule, a bad duration, and changes to confirmation or clarification that their limits forbid |
-| `/standing` | `/orders` | `` | makes those words a standing order — a card to answer, never work done once |
-| `/standing` | `/orders` | — | what stands over this conversation; `p` pauses, `s` stops, `n` excepts this place |
+
+`/standing` and `/orders` are not commands any more: they answer
+`there is no command called /standing · / lists them`. What runs on a clock is
+`/automations`, and a rule is a memory marked always, kept with `/always`.
## Programs, skills and memory commands
@@ -290,6 +294,8 @@ Canonical word, the other words it answers to, its argument form, and what it do
| `/memories` | — | — | opens the memory place |
| `/remember` | — | `` | keeps one thing across conversations |
| `/forget` | — | `` | forgets the best matching memory |
+| `/always` | — | — | lists the rules in force here — memories marked always, put in front of every conversation and task |
+| `/always` | — | `` | keeps one as a rule, without asking: for this project, or for you everywhere when typed on home |
## Crew and task commands
@@ -324,7 +330,7 @@ Canonical word, the other words it answers to, its argument form, and what it do
| `/effort` | `/think`, `/thinking` | `` | sets this conversation's thinking level; an unknown rung lists the accepted levels and changes nothing |
| `/budget` | `/limits` | — | what codeaf may spend · every limit on one tab |
| `/budget` | `/limits` | `` | sets the day's limit · `none` removes it |
-| `/budget` | `/limits` | `` | sets one by name: `day`, `conversation`, `plan`, `practice` |
+| `/budget` | `/limits` | `` | sets one by name: `day`, `conversation`, `plan` |
## Cache, display and export commands
@@ -370,13 +376,15 @@ Under the table `/help` prints the keys that have no slash command, including
rows, directly under the `tab` row:
```
-alt+1…8 go to a place · 1 home · 2 AI teams · 3 chats · 4 activity · 5 spend · 6 settings · 7 standing · 8 memory
+alt+1…8 go to a place · 1 home · 2 AI teams · 3 chats · 4 activity · 5 spend · 6 settings · 7 automations · 8 memory
alt+. on a place: what else is here · every key that place has, drawn
on a place, tab is the next place · esc back
```
On a Mac those read `opt+1…8` and `opt+.`; the substitution happens once, at the moment of
-drawing, and the words are the same.
+drawing, and the words are the same. The digits follow that list, not the bar's
+left-to-right order: `alt+8` is memory, and `alt+7` is automations, which is on the bar
+only while you are in it.
**One gesture, one spelling.** Wherever the sheet names the escape key it writes `esc
back` — the places row, the task roster on `alt+t`, the conversation switcher on `alt+k`,
@@ -387,8 +395,8 @@ open to find out how many there are. The longer `esc leaves it as it was` is a d
sentence and stays where it is: it is said over a value you were editing, and it means the
edit is discarded, not that you moved.
-Two rows say what their key DOES rather than naming the thing it reaches: `ctrl+,` is
-`open settings`, and `n` on the `/standing` row is `keep it out of here`.
+A row may say what its key DOES rather than naming the thing it reaches: `ctrl+,` is
+`open settings`.
The last line of `/help` is `session · `, and it appears **only when the session
has a file**. The path is written against your home — `~/.codeaf/v3/…` — so that it fits
@@ -412,11 +420,13 @@ The key is named on the first row of `/help` itself, and on the line every sessi
with — `esc interrupts · ctrl+c quits · ? for help`. `/?` is also an alias of
`/help`, and has been all along.
-`/quit` (or `/exit`, `/q`) **closes the conversation in front**, and it does it at once —
-it is typed out on purpose, so it is not asked twice. Your draft is written to disk
-synchronously first, with any message still waiting for an answer folded in underneath it,
-so nothing typed in the last moment is lost. Then that conversation's running turn is
-interrupted and its agent is closed for real.
+## /quit, /exit, /q and ctrl+c — leaving one conversation or all of them, and the warning when an automation is running
+
+`/quit` (or `/exit`, `/q`) **closes the conversation in front**, at once — it is typed out
+on purpose, so it is not asked twice. Your draft is written to disk synchronously first,
+with any message still waiting for an answer folded in underneath it, so nothing typed in
+the last moment is lost. Then that conversation's running turn is interrupted and its agent
+is closed for real.
**If this terminal is holding another conversation, codeaf stays up** and the most recently
open one comes forward, saying `closed · `. `/quit` leaves
@@ -424,12 +434,17 @@ the program only when the conversation it closed was the last one. Home's page h
whole arrangement under *Switch between projects without leaving*.
`ctrl+c` is the other road out, it takes **one press**, and it closes **everything** —
-every conversation this terminal holds, not just the one in front. Nothing is asked and
-nothing is named first: your draft and anything waiting for an answer are written to disk,
-and codeaf exits. Work the codeaf service is running keeps going and is there when you open
-the workspace again; work running inside this terminal stops with it.
-While a turn is running `ctrl+c` interrupts the turn instead, and that press does not leave.
-The keys page has the whole rule under "Quitting codeaf".
+every conversation this terminal holds, not just the one in front. Your draft and anything
+waiting for an answer are written to disk, and codeaf exits. Work the codeaf service is
+running keeps going and is there when you open the workspace again; work running inside
+this terminal stops with it. While a turn is running `ctrl+c` interrupts the turn instead,
+and that press does not leave.
+
+**A running automation is no reason to ask.** Automations run only while a codeaf window is
+open, and closing the last one pauses them: a run it cuts off runs again, once, when a
+window is next open, and nothing is said about the cut-off attempt. Nothing is asked or
+named first. The keys page has the whole rule under "Quitting codeaf", and *Automations*
+the rest.
## /new — start another conversation in this project
@@ -813,15 +828,16 @@ The labels come in this order, and each is dropped when its value is empty: `ses
`task` (only inside a task room), `model` (the full routing address, with `:level` when a
reasoning level is set), `crew`, `task model` (only in a room), `served`, `search`,
`approvals`, then the telemetry words — `spend`, `cache`, `context`, `compacts at`,
-`compaction`, `background`, `changes`, `rate`, `open`, `watching`, `speed`, `connection`,
-`state` — then `tasks`, `keeping watch`, `place`, `keys`, and last `build` and `file`.
+`compaction`, `background`, `changes`, `rate`, `open`, `speed`, `connection`,
+`state` — then `tasks`, `automations`, `place`, `keys`, and last `build` and `file`.
Labels are padded into two aligned columns.
-Four other words are facts this command and the phone's sheet carry and the status row
+Three other words are facts this command and the phone's sheet carry and the status row
does not: `changes` (`Σ +128 −14`), `rate` (`1.2k tok/s avg`, this turn's output over its
-whole wall time — the right edge of the row shows the live `38 tok/s` instead), `open`
-(`2 open · 1 waiting`) and `watching` (the standing count, which is drawn at the foot of the
-task column). `crew` is a fifth and has its own line above.
+whole wall time — the right edge of the row shows the live `38 tok/s` instead) and `open`
+(`2 open · 1 waiting`). `crew` is a fourth and has its own line above. `automations` is
+there only while something is on the clock or running —
+`3 on the clock · next weekly update Mon 12 Oct at 09:00 · 1 running` (*Automations*).
The `crew` line sits directly under `model` and says the crew is auto, with any seat you
pinned after it:
@@ -913,7 +929,7 @@ and by what it was for. It is the same place `alt+5` opens.
**`/spend` used to be an alias of `/cost` and is not any more.** The two answer different
questions: `/cost` is *this conversation's* bill, printed into the conversation, and the
-spend place is *the whole machine* — every window, every task and every standing run,
+spend place is *the whole machine* — every window, every task and every automation's run,
including a session opened from another machine over `--host` whose calls are still made
here. The word `spend` belongs to the bigger reading, so the one guess most people make
now lands on the place. `/cost` keeps `/usage` and `/tokens`.
@@ -978,15 +994,16 @@ With no amount, either command opens settings on the corresponding row. A failed
live update says `saved for the next conversation · this one still has its previous limit`.
The row names it takes are **`day`** (`daily`, `today`), **`conversation`** (`chat`,
-`session`), **`plan`** (`plans`, `ask`) and **`practice`** — the four rows that can be
-edited. There is **no `/budget task`**: the per-task limit for an ordinary `/task` is set in `/crew`, with
+`session`) and **`plan`** (`plans`, `ask`) — the three rows that can be edited. `/budget
+practice` answers `There is no practice limit any more — codeaf no longer practises on its
+own. Use /budget day, /budget conversation or /budget plan.` There is **no `/budget task`**: the per-task limit for an ordinary `/task` is set in `/crew`, with
`/crew cap task <$>`. senior-dev has a separate ceiling for each run; see its page
for the shell flags and conversation limits that can lower it.
A write says back what it landed, in the tab's own words for that row — `per day · $50`,
or `per day · no limit`. A figure it cannot read is refused in the row's own words with
the rows listed after it: `that's not a dollar amount — a number, or none for no limit ·
-rows: day, conversation, plan, practice`. A row this machine does not have answers `that
+rows: day, conversation, plan`. A row this machine does not have answers `that
limit is not on this machine`.
The `$` is optional, and the amount can be a word — see the next section.
@@ -1299,7 +1316,7 @@ box, opens it on a one-conversation machine and on an empty one alike, and over
it opens the far machine's.
There is no argument form. There are three other ways in: **`alt+1`**, home being the first
-of the seven words on the tab bar; **`space` twice** on an empty box; and **`tab`** from any
+of the eight words on the tab bar; **`space` twice** on an empty box; and **`tab`** from any
other place.
**It is seven panels**, in one column under 110 cells, two from 110 and three from 170,
@@ -1307,7 +1324,8 @@ always in one order: an unheaded list of open tabs followed by up to three dimme
closed conversations, `needs you` (every question waiting on you, a digit answers the
top one from anywhere), `projects` (click a folder to select it for the next message),
`tasks` (the last day's tasks, running or landed, newest first), `since you left` (what landed while you were
-away), `spend` (today and the fortnight) and `standing` (standing orders, soonest first).
+away), `spend` (today and the fortnight) and `automations` (every automation that will run
+again, soonest first; it and every row open `/automations`).
Which column a panel stands in follows what it holds: the panels with rows fill the **field**
at the left, and the **rail** at the right holds `projects` and `spend` at its top with the
quiet panels under them. An empty panel keeps its heading and one dim line naming what
@@ -1491,44 +1509,40 @@ That one sentence covers every way of having none — no registry wired, a regis
nothing in it, and **every `--host` session**, because the registry lives on the far
machine. No list opens behind it. See the *Subharnesses* page for the card and its keys.
-## /standing — the command's two forms, bare and with words after it
+## /automations — the list of what runs on a clock, and the typed forms add, edit, run, pause, resume and delete
-`/standing` (or `/orders`) has two forms, and they do different things. The words form
-leads — making an order is what the command exists for; the page is the follow-up.
+**Bare, `/automations` opens the automations place** — the same place `alt+7` opens: every
+reminder, piece of scheduled work and watch codeaf runs on a clock while it is open, one
+row each with its kind, its schedule, where it is now and how its last run went. `enter`
+opens a row's history; `→` draws its verbs — `r` run now, `s` stop the run, `p` pause or
+resume, `e` edit, `d` delete, `o` open where it was asked. With none saved it opens all the
+same, on its heading and
+`reminders, scheduled work and watches · "remind me at 6" or "every morning at 9"`.
-**With words after it, those words become a new standing order.**
+**With words after it, it is one of six exact forms**, read with no model in between:
```
-/standing always run the tests before you say you are done
+/automations add leave at 18:00 say "time to leave"
+/automations edit 3f2a9c every 30m
+/automations run 3f2a9c
```
-The tag form works in the middle or at the end too: `always run the tests /standing` and
-`always /orders run the tests` hand the remaining sentence through the same door. Press
-backspace immediately after the tag to make it plain words instead.
-
-They go through the deliberate marked door: codeaf is told to shape the
-sentence into a standing order's card — when it wakes, what it does, how far it reaches —
-and it never carries the sentence out as one-off work as well. Nothing stands until you
-answer the card. A sentence that cannot stand at all gets one short line saying so and
-nothing else. Typed while an answer is still arriving it waits above the box and goes
-through the marked door when its turn comes. On a build with no ambient side it says
-`nothing here can hold a standing order` and sends nothing.
-
-**Bare, it opens a page.** A short list under the message box of what stands over this
-conversation, on up to three shelves, with `p` to pause one, `s` to stop one, `n` to except
-this place and `enter` to open the conversation that asked for it. With nothing standing it
-opens all the same, on its heading `standing orders` and one dim line:
-`reminders, watches and routines · "remind me at 6" or "every morning at 9"`
-Nothing is written into the conversation either way.
+`pause`, `resume` and `delete` take the id the same way; its first few characters are
+enough when no other id starts the same way. `add` and `edit` end on a card —
+`save this automation?` or `save this change?` — answered `1 Save` or `0 Don't save`, and
+nothing is saved before it. `run`, `pause`, `resume` and `delete` do not ask: typing the id
+is the confirmation. Any other first word says
+`unknown "" — the forms are add, edit, run, pause, resume and delete`, and a window
+that cannot keep automations says `this window cannot change automations`.
+*Automations* has every clause and every refusal.
-Nothing on the page is ever named at the command line — the words are always a new order,
-never a query, because the only way to name one is to read it off the page first.
+**The command list carries two rows**: `/automations`, whose tail reads
+`what runs on a clock · run, pause, edit or delete one`, and `/automations add …`,
+whose tail reads `…or add one exactly, read back on a card first`. The `+ /automations` row
+in the column on the right puts `/automations ` in your box.
-**The command list carries both rows, the words form first**: `/standing ` whose
-tail reads `keep this true · a card, never work done once`, and under it `/standing` on
-its own, whose tail reads `…or what stands over this conversation · stop, pause or not
-here`. Pressing the words row puts the command in your box rather than running it. The
-`+ /standing` row at the foot of the column on the right does the same thing.
+You rarely need the typed forms: say it — "remind me at 6", "every Monday at 9, draft the
+weekly update" — and it is proposed on a card. `/standing` and `/orders` are gone.
## /task — start work you can walk away from
@@ -1800,7 +1814,7 @@ input keeps the draft and shows its error beside it. Escape cancels an open edit
or choice list without saving.
esc backs out one layer at a time: the search first, then anything the `Connections` tab
-has standing open, then the panel. The head line says `esc close` on the right. The
+has open, then the panel. The head line says `esc close` on the right. The
bottom legend normally reads
`↑↓ move · ←→ tabs · enter change · type to search · esc close`.
@@ -1908,8 +1922,8 @@ Connections holds accounts, keys, web search and remote connection settings.
Privacy holds history, drafts and data-sharing choices.
Old saved keys and values are unchanged. Search accepts old labels and keys,
-including settings inside Advanced. Resident-only practice, arrival-brief and
-tenure controls no longer appear in chat settings. See **Settings** for scope,
+including settings inside Advanced. The resident's practice, arrival-brief and
+tenure controls are gone, with the background routine they tuned. See **Settings** for scope,
activation timing and how an existing profile carries forward.
## Settings categories on a narrow terminal
diff --git a/internal/manual/chat/empty-screen.md b/internal/manual/chat/empty-screen.md
index 190c4d06c9..ba925baf50 100644
--- a/internal/manual/chat/empty-screen.md
+++ b/internal/manual/chat/empty-screen.md
@@ -27,7 +27,7 @@ over about a second and a quarter, and is then still.
**Nothing else is drawn.** No column of tasks on the right, no rule with the legend in
it — so no conversation name and no model above the box yet — no `+ /task` or
-`+ /standing` doors, no `❯ ctrl+g hide`, no `$0.00` and no token count on the status
+`+ /automations` doors, no `❯ ctrl+g hide`, no `$0.00` and no token count on the status
line — only `idle` at the right of the status row. Every one of those arrives with the
conversation rather than before it (see the other headings on this page).
@@ -89,7 +89,7 @@ box and the picker's filter stays at the foot of the frame where its list is.
## Where is the task column, the sidebar, the right rail on a new conversation — what happened to the sidebar, the column on the right is missing
**The column on the right is absent while it has nothing to say.** On an untouched
-empty conversation there is no column, no border, no `+ /task`, no `+ /standing`, no
+empty conversation there is no column, no border, no `+ /task`, no `+ /automations`, no
`❯ ctrl+g hide`, and no two-column edge from a column you closed in an earlier session.
The conversation is laid out at the full width of the terminal.
@@ -97,11 +97,11 @@ It appears the moment there is something true to put on it:
- **your first keystroke** — the conversation has begun, and the column stands from then
on exactly as the tasks page describes, doors and all, at 100 columns or more;
-- **a task** — a standing task landing on this conversation raises it;
-- **a standing order** reaching this conversation — a session with orders over it meets
- the column on its very first frame, with the orders on it.
+- **a task** — a task landing on this conversation raises it.
-The `+ /task` and `+ /standing` doors are not lost: `/` lists every command, and the
+Automations do not raise it: the column lists only the automations a conversation set up
+itself, and a new one has set up none — every automation is in `/automations`. The
+`+ /task` and `+ /automations` doors are not lost: `/` lists every command, and the
line under the message box says `/task starts work`. `ctrl+g` on the empty
screen counts as a first keystroke like any other — the greeting goes, and the key then
does what it always does, which is to close the column it would just have raised; press
diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md
index efdea84f40..128d2e4cf8 100644
--- a/internal/manual/chat/hints-and-tips.md
+++ b/internal/manual/chat/hints-and-tips.md
@@ -140,18 +140,20 @@ build if the two disagree), so a tip you saw is on it word for word.
when you run `/files`.
- `/resume opens an earlier conversation` — when you start in a directory that already has
a conversation. Retired when you run `/resume`.
-- `/standing turns a message into a rule work must follow` — once this directory has three or more earlier
- conversations. Retired when a standing order is made or the standing page opened.
+- `/automations lists what codeaf does on a clock while it is open` — once this directory
+ has three or more earlier conversations. Retired the first time the automations place
+ opens, by any door (`/automations`, `alt+7`, a row that leads there), or the first time
+ an automation is proposed to you on a card in a conversation.
- `space space takes you back to home` — after the first exchange, in a conversation only
(never on home itself). Retired the first time two spaces in an empty box open home,
from a conversation or from a place; reaching home by `/home` or the tab does not retire
it. It is the first tip a conversation says, ahead of `/task`.
- `/task starts a single-shot task on the side` — after the first exchange. Retired when
`/task` is typed, bare or with a brief.
-- `/standing makes your message a rule instead of a request` — retired when a standing
- order is made or the standing page opened. It teaches the same door as the `/standing`
- row above and retires with it, so the two say a rule in the same words. (It named the
- `ctrl+enter` chord until 2026-09-30, when queueing took that chord over.)
+- `/automations add sets one up exactly · or just say when, like every Monday at 9` — from
+ the first minute on home and after the first exchange in a conversation. It is the other
+ half of the `/automations` row above — the place lists them, and this is how one is made,
+ typed exactly or simply said — and it retires with that row, on the same two gestures.
- `/manual answers any question about codeaf` — retired when
`/manual` is typed, bare or with a question.
- `ctrl+shift+t reopens the last conversation tab` — retired the first time the chord is
@@ -201,9 +203,8 @@ build if the two disagree), so a tip you saw is on it word for word.
**Memory, accounts and the rest**
- `/remember carries a fact forward, /forget drops it` — retired when `/remember` is typed.
- It is the only row that names two commands as a pair, because the two rows about keeping
- something used to be told apart by nothing: a standing order is a condition the work has
- to honour and a memory is a fact carried forward.
+ It is the only row that names two commands as a pair: the fact, and the way back out of
+ it.
- `/connect links Notion, Slack and other accounts` — retired when the connect panel
is reached for.
- `/autonomy sets how questions are handled while you are away` — after the first
diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md
index cf1deda73d..bf007284de 100644
--- a/internal/manual/chat/home.md
+++ b/internal/manual/chat/home.md
@@ -34,8 +34,8 @@ answering one question you would ask walking up to a colleague's desk:
Spark Fleet Ssh Audit · 2 hosts up, 1 not
made apartments-minto-street.md
- standing
- the 6am repo watch
+ automations
+ weekly update
```
Everything with rows in it is on the **left**, in the fixed order of the seven panels.
@@ -59,7 +59,7 @@ There is no argument form. The screen is how you name what you want; a command t
project name would be asking you to type out the very thing home exists to show you.
Home is the **first place on the top line**, right after the `codeaf` wordmark:
-`home chats AI teams activity memory spend settings`.
+`home chats AI teams activity memory automations spend settings`.
The **teams page**, right after it, is where your teams and their managers live.
It still does nothing on its own: no notifications and no alerts. You open it, you see where
things stand, and you either act on something or leave.
@@ -79,7 +79,7 @@ panels below them, which are in the rail only because they are quiet today.
**The rank never moves, only the side.** Within the field and within the rail the order is
always `sessions`, question rows, `projects`, `since you left`, `spend`,
-`standing` — so two panels that both fill never swap places.
+`automations` — so two panels that both fill never swap places.
One column under 110 cells, where every panel is in that one order and there is no rail;
two columns from 110; three from 170, where the rail is the third and the field fills the
@@ -93,9 +93,9 @@ from home stands there as a card. Nothing else stands in it.
| --- | --- | --- | --- |
| `projects` | a folder with conversations | no keyboard stop; click selects the next message’s project | never empty — the folder this window opened in is always a row |
| `sessions` | one of the fifteen most recent conversations | opens the conversation | `your recent conversations appear here` |
-| `since you left` | what landed while you were away | opens the record, the file or the place | `what watches and tasks did while the terminal was shut` |
+| `since you left` | what landed while you were away | opens the record, the file or the place | `what happened while you were away` |
| `spend` | today, the fortnight, who it went to | nothing — its lines are read, never stood on or pressed; the heading opens the spend place | `every chat and task is priced here` |
-| `standing` | a standing order — reminder, routine, watch or rule — soonest first | opens the standing place | `reminders, routines, watches and rules · "remind me at 6" or "every morning at 9"` |
+| `automations` | an automation that will run again — a reminder, scheduled work or a watch — soonest first | opens the automations place | `reminders, scheduled work and watches · "remind me at 6" or "every morning at 9"` |
**An empty headed panel keeps its heading and that one dim line** — it names what arrives there and
the one thing that puts it there, and it never says the panel is empty. On a narrow column
@@ -115,12 +115,11 @@ The selected row's description shows the question and its available answers. The
same digit keys answer it. Once the question is resolved and Home refreshes, the
question mark disappears. Questions belonging to items outside the visible lists
remain reachable as unheaded rows; they are not dropped because their owner is absent.
-Standing items that need an answer also keep an unheaded question row.
**Every description opens with the thread the row belongs to**, as a title line of its
own — `thread: Searching for Apartments Near Minto`, spelled exactly as the conversation list spells
that conversation — then a blank line, and then the rest: the question, or a landing's
-files and sentence, with the answers at the right. A watch made from home's own box belongs
+files and sentence, with the answers at the right. A row of the `automations` panel belongs
to no thread and has no title line. On a frame with no description column the read row
grows three lines under itself for this; a terminal too short to keep those lines free
keeps one, and while such a row is read the rows under it move down two for the moment.
@@ -153,8 +152,8 @@ landing is the one still in your head.
**A landing is one line at rest**: its name and how long ago it landed. **The right margin
of every row of the field is a time** — how long a question has waited, how long ago a
-landing landed, when you last spoke in a conversation, how long a task has run, when an
-order is due — and nothing else stands there.
+landing landed, when you last spoke in a conversation, how long a task has run — and nothing
+else stands there; an `automations` row has nothing at its right at all.
**The row under the pointer or the cursor grows its description** — the thread's title
line, a blank, then how many files it wrote, the first sentence of what the work came to,
@@ -181,8 +180,8 @@ work has already finished.
**Only a landing ages.** A `your call` is a row for two days after it landed; after that the
panel's last line counts it with the rest it hides — `3 more` — and `enter` there opens the
-place, where every one of them still is. A question a conversation is stopped on, and a
-watch that needs somebody, never age off home.
+place, where every one of them still is. A question a conversation is stopped on never
+ages off home.
**A landing on `unread` is not repeated by `since you left`.** It goes back to that panel
as an ordinary line once it has been answered or has aged out of the group.
@@ -323,7 +322,7 @@ Two reasons, and they are the same reason:
Home wears two marks and only two: the amber `?` on a row waiting for a person, and that
spinner. In screen-reader (linear) mode nothing turns at all.
-## What did it do while I was away — the since you left panel, what happened while the terminal was shut
+## What did it do while I was away — the since you left panel, what landed while the terminal was shut
**What happened on its own while you were not looking**, under `activity` — in the field
when it holds anything and in the rail when it does not — headed with how long you were
@@ -331,9 +330,9 @@ away:
```
since you left · 12h
- Spark Fleet Ssh Audit · 2 hosts up, 1 not $0.42
- made apartments-minto.md Pricing Site
- fired at 6am — nothing had changed
+ weekly update · done · drafted it 1h
+ Spark Fleet Ssh Audit · 2 hosts up, 1 not 3h
+ made apartments-minto.md 5h
```
Four kinds of line, newest first, four of them — eight in a tall window — then
@@ -345,15 +344,17 @@ Four kinds of line, newest first, four of them — eight in a tall window — th
`went in circles`, `out of steps`;
- **a file a conversation made** — `made `, with how long ago at the right and that
conversation's name under the cursor;
-- **a standing item that fired** — its own last-look line, in its own words; a one-off that
- fired and stood down reads `fired 3 minutes ago — it told you`;
+- **an automation's run** — the line its own conversation got,
+ `weekly update · done · drafted it`: the last run of each automation, when it ended
+ while you were away and is news. A watch's `nothing new` is never a line;
- **what memory learned or let go** — `learned 2 things, let go of 1`.
**Every line is a door.** `enter` on a task opens that task's record, on a file opens the
-file, on a firing opens standing and on memory's line opens memory.
+file, on an automation's run opens the automations place (`/automations`) and on memory's
+line opens memory.
It is measured from when you last **closed** home; the very first look has no "since" and
-the panel keeps its heading and `what watches and tasks did while the terminal was shut`.
+the panel keeps its heading and `what happened while you were away`.
The memory line is not wired yet: its figures read zero on this surface, and a zero draws
nothing.
@@ -488,12 +489,13 @@ inside it. The conversation and question rows have no heading. The row you are o
wears the same ground and its title goes bold; the row under your mouse pointer wears it too
while the pointer is on it.
-**`→` opens a row's verbs, on every row that has any and at every width** — on an `ask here`
-row it hands the errand's pane the keyboard instead — and the chords work without the
-strip: `ctrl+o` opens a conversation's workspace, the workspace a standing order stands
-over, or the conversation a `since you left` line happened in; `ctrl+y` copies the path,
-`ctrl+e` asks to delete a conversation or pauses a standing item, `ctrl+x` stops. The foot does not change from row to row and
-names none of them; `alt+.` draws the map when you want the rest.
+**`→` opens a row's verbs, on every row that has any and at every width** — an
+`automations` row has none, and on an `ask here` row it hands the errand's pane the
+keyboard instead — and the chords work without the strip: `ctrl+o` opens a conversation's
+workspace, the folder an automation runs in, or the folder of the conversation a
+`since you left` line happened in; `ctrl+y` copies the path, `ctrl+e` asks to delete a
+conversation, `ctrl+x` asks to stop a task this window runs. The foot does not change from
+row to row and names none of them; `alt+.` draws the map when you want the rest.
A digit answers the question row drawing its answers, wherever you are standing. `enter` acts on
the row under the cursor. `alt+.` draws the map.
@@ -543,7 +545,7 @@ the conversation list has this conversation from its first minute.
says what would put something there. It goes the moment the first row arrives.
A quiet morning on a busy machine is the same screen with fewer rows: `needs you` whispering,
-`sessions` whispering, the conversation list full, `since you left` holding what fired overnight.
+`sessions` whispering, the conversation list full, `since you left` holding what ran overnight.
There is no accent anywhere when nothing is waiting on you.
Typing works exactly as it does anywhere: `? ask here: "…"` and `+ start a new
@@ -563,19 +565,19 @@ folds it. One panel is open at a time; opening a second folds the first. An open
taller than the window shows what fits and its line still counts the rest — `3 fewer · 40
more` — and names no place, because `enter` on it folds rather than opens. The way to
those rows is the panel's **heading**: `sessions` opens sessions, `since you left` opens memory,
-and `standing` opens standing. Conversations and extra question rows have no heading;
+and `automations` opens automations. Conversations and extra question rows have no heading;
`projects` opens nothing. The foot under a fold says
which way it will go: `enter shows the rest`, then `enter folds them`. Opening lasts as long
as the window; a relaunch starts folded. The fold wears no mark: home spends its two marks
on the amber `?` and the one moving cell.
**A tall terminal grows the panels**, once every panel has what it naturally shows:
-additional question rows, `since you left` and `projects` to eight rows; `sessions` keeps at most fifteen recent conversations; `standing` from three to five. `spend` never grows. What is left over is air
+additional question rows, `since you left` and `projects` to eight rows; `sessions` keeps at most fifteen recent conversations; `automations` from three to five. `spend` never grows. What is left over is air
under the shorter column.
-**A short terminal squeezes them in a fixed order**: `standing` gives way first, then `spend`,
-then `since you left`, then `sessions`, then `projects`; the conversation list and additional question rows
-shrink last. A squeezed panel keeps its heading, the rows that fit and its `N more` line;
+**A short terminal squeezes them in a fixed order**: `automations` gives way first, then
+`spend`, then `since you left`, then `projects`; the conversation list, the additional
+question rows and `sessions` shrink last. A squeezed panel keeps its heading, the rows that fit and its `N more` line;
only when every panel is down to that is a panel dropped — and the panels that are only
whispering go before any panel with rows, whatever their rank, so a very short window
(fourteen rows) still shows a conversation you can open rather than a sentence about what
@@ -592,9 +594,10 @@ panels, and `enter` again folds it. The box at the foot still searches every con
the machine as you type — a project's name, a folder's name or a word from what a task came
to all find them — whether or not a panel is drawing the row.
-Every panel's fold works the same way: `N more` under `activity`, `since you left`, `standing`
-and `projects` opens that panel. The places themselves — tasks, standing, spend — are on the
-tab bar and their slash commands, not behind the folds.
+Every panel's fold works the same way: `N more` under `activity`, `since you left`,
+`automations` and `projects` opens that panel. The places themselves — activity, spend,
+automations — open from a panel's heading, their `alt+` keys and their slash commands, not
+from the folds.
Per-project folds — `▸ 13 more, quiet since 1d` under a project's own heading — belong only
to the **phone shape**, under 60 columns, where home is still an inbox with the projects
@@ -610,7 +613,7 @@ there is no strip. The rule and a blank come next, three rows, and the page star
row higher. **Inside a chat** and on every place but home the top line reads:
```
- >● codeaf home chats AI teams activity memory spend settings 2 want you · 4 moving · $0.55 / $500 · tue 1:11pm
+ >● codeaf home chats AI teams activity memory automations spend settings 2 want you · 4 moving · $0.55 / $500 · tue 1:11pm
```
**On home it drops the two counts** and keeps the budget and the clock, because the
@@ -624,13 +627,12 @@ every width, because it is the one number you must not have to go looking for. T
this manual has the whole order and the `more ▾` menu.
- `2 want you` — how many things have **stopped on you**: a conversation waiting for an
- answer, a standing order that will not fire until you say so, an errand holding a
- question. It is drawn in **amber**, which means one thing only: someone is waiting for a
- person.
+ answer, work that landed as your call, an errand holding a question. It is drawn in
+ **amber**, which means one thing only: someone is waiting for a person.
- `4 moving` — how many things this machine has **in flight right now**: task nodes out,
- conversations mid-turn in another window, an `ask here` errand answering, a standing
- order firing. A conversation with three tasks out counts as three. It is drawn in
- **cyan**, shows from one, and disappears at nothing.
+ conversations mid-turn in another window, an `ask here` errand answering. A conversation
+ with three tasks out counts as three; an automation's run is not counted (`/status` has
+ it). It is drawn in **cyan**, shows from one, and disappears at nothing.
- `$0.55 / $500` — every model call written down **since midnight**, against the day's
allowance, in **green**, the money colour. A machine with no allowance draws the figure
alone.
@@ -644,20 +646,24 @@ another window asks can take up to ten seconds to reach a chat's top line.
**The money on this line is the money on the spend place and the `spend` panel**, to the
cent — one reading of one file, wherever you are standing.
-## Where did standing and memory go: the seven words on the tab bar
+## Where did standing and memory go on the tab bar — automations is alt+7, memory alt+8, and the eight words
-The top line reads `home chats AI teams activity memory spend settings` — memory is the
-fifth word, and standing is the one place not on it. **The numbers are not the bar's order**:
-each place kept the number it had before the bar was regrouped — `alt+1` home, `alt+2`
-AI teams, `alt+3` chats (back to your conversation), `alt+4` activity, `alt+5` spend,
-`alt+6` settings, `alt+7` standing, `alt+8` memory.
+The top line reads `home chats AI teams activity memory automations spend settings`. Each place
+has a digit of its own: `alt+1` home, `alt+2` AI teams, `alt+3` chats (back to your
+conversation), `alt+4` activity, `alt+5` spend, `alt+6` settings, `alt+7` automations and
+`alt+8` memory. **The numbers are not the bar's order**: each place kept the number it had
+before the bar was regrouped, so memory, the fifth word, is `alt+8`.
-- `/standing` (or `/orders`) and `alt+7` open Standing. Its word appears after the seven
- while it is open, and `tab` from there returns to Home.
-- `/memory` (or `/memories`), `alt+8` and the word `memory` on the bar open Memory.
+- **Standing is gone, and automations replaced it.** `/automations` and `alt+7` open the
+ automations place, and its word is always on the bar, between `memory` and `spend`. On a
+ frame under 84 columns the bar has no room for all eight words, and `spend` and
+ `settings` fold behind `more ▾`. `/standing` and `/orders` are not commands any more — `/standing` answers
+ `there is no command called /standing · / lists them`.
+- **Memory is on the bar**, the fifth word. `/memory` (or `/memories`) and `alt+8` open it.
-`alt+.` shows all eight numbered destinations. Search is on Home: type into its box to find
-a conversation. There is no separate Search tab or `/search` command.
+`tab` returns to Home; `alt+.` shows all eight numbered destinations. Search is on Home:
+type into its box to find a conversation. There is no separate Search tab or `/search`
+command.
## Why did a dashboard open when I started codeaf — home greets you
@@ -746,7 +752,7 @@ opens the row's verbs and `←` closes them, and with something typed they move
place opens that place. A `projects` row is the one exception: a click on it picks that
folder for the next message and opens nothing. A click on a panel's **heading** opens the place the heading names:
`sessions` opens sessions, `since you left` opens memory, `spend` opens spend, and
-`standing` opens standing. There is no `needs you` heading. The conversation list has no heading. The `projects` heading opens nothing
+`automations` opens automations. There is no `needs you` heading. The conversation list has no heading. The `projects` heading opens nothing
and stays dim. **A heading that opens somewhere underlines on mouse-over.** The
pointer on a heading moves neither the cursor nor the marked heading.
A click on a `/` command, `ask here` or the new-conversation row only selects it;
@@ -1034,20 +1040,16 @@ because there is nowhere to continue it.
refresh (home re-checks every three seconds); or start a new conversation in a project that
exists — `ctrl+t` on one of its rows.
-## Why does it say elsewhere — on a standing item, and nowhere else
+## Why does it say elsewhere — not on a conversation, and not on an automation
**Almost never, and never on a conversation.** Every project but the one this window
launched in used to carry a dim `elsewhere`, and `enter` on one of its rows opened nothing.
That is gone: `enter` opens any conversation on home, in any project.
-**Home never says it on a standing item either, any more.** Pressing `enter` on a
-**standing item** — a reminder, a watch, a rule — opens the conversation that set it up,
-whichever project it belongs to; it used to refuse with `elsewhere · `
-at the foot when that project was not this window's, and that refusal is gone. An item that
-was set up from home and never became a conversation opens the **standing place with the
-cursor on that item** — its own page is the honest answer to "show me this thing". The one
-place the sentence `made from home — no conversation to open` is still said is the standing
-place itself, when `enter` there asks for the conversation behind such an item.
+**Nor on an automation.** Standing items used to refuse with
+`elsewhere · ` when their project was not this window's; standing
+items are gone, and a row of the `automations` panel opens the automations place whichever
+project it runs in.
A second project is a second **conversation**, built the way the first one was, on its own
workspace, with its own gate. A conversation still never moves between projects — though it
@@ -1335,11 +1337,11 @@ one behind your back. This is every fate, in the words the drop-up draws them in
| --- | --- | --- |
| **`pins the next conversation's model`** | `/model` · `/model ` | The list opens in home's own body; the pinned model appears on the rule above the box. Nothing behind home is touched. |
| **`next conversation's folder`** | `/project` · `/project ` | Bare, opens the folder browser **aimed at the next conversation**; picking a folder pins it, with no duplicate footer message. With a path, pins that folder at once, opens nothing and says nothing. Either way `project: ~/src/parser` at the right of the keys row shows the selection, and neither road writes a second sentence over the keys. |
-| **`opens the page`** | `/settings` `/set` `/config` · `/home` · `/spend` · `/standing` · `/memory` `/memories` · `/history` · `/task` (bare) | A place replaces a place, exactly as before. |
+| **`opens the page`** | `/settings` `/set` `/config` · `/home` · `/spend` · `/memory` `/memories` · `/history` · `/task` (bare) | A place replaces a place, exactly as before. |
| **`this list is /resume`** | `/resume` `/sessions` | Says `this list is /resume · enter opens a row` — home *is* that list. |
| **`onto home's tray`** | `/attach ` | The file — or picture — rides on home's own tray into the conversation you open next. Home says `attached · notes.md · rides with the next conversation`. A bare `/attach` opens the browser aimed at the next conversation's folder, and a file chosen there lands on the tray. |
-| **`opens a conversation here first`** | `/files` · `/folder` `/place` `/dir` · `/manual` · `/permissions` `/perms` · `/connect` · `/harness` · `/subharness` · `/skill` `/skills` · `/copy` · `/select` · `/rewind` `/undo` `/back` · `/compact` · `/export` `/save` · `/standing ` · `/task ` | Opens a conversation at the target — the folder at the right of the keys row and the model on the rule above the box — then runs there. Home closes, exactly as `enter` closes it. `/manual` is on this road since 2026-09-22: it is a question put to the model, so it needs a conversation to be asked in. `/folder` joined it the same day — it gives THIS conversation a folder, and home has no this; the pin it used to be here is `/project`. |
-| **`answers here`** | `/help` · `/status` · `/cost` · `/cache` · `/budget` · `/debug` · `/stop` · `/remember` · `/forget` · a word nobody defined | Answers with a note, and the first line of that note is put on home's own line under the box. `there is no command called /pricing · / lists them` is now something you can read. |
+| **`opens a conversation here first`** | `/files` · `/folder` `/place` `/dir` · `/manual` · `/permissions` `/perms` · `/connect` · `/harness` · `/subharness` · `/skill` `/skills` · `/copy` · `/select` · `/rewind` `/undo` `/back` · `/compact` · `/export` `/save` · `/task ` | Opens a conversation at the target — the folder at the right of the keys row and the model on the rule above the box — then runs there. Home closes, exactly as `enter` closes it. `/manual` is on this road since 2026-09-22: it is a question put to the model, so it needs a conversation to be asked in. `/folder` joined it the same day — it gives THIS conversation a folder, and home has no this; the pin it used to be here is `/project`. |
+| **`answers here`** | `/help` · `/status` · `/cost` · `/cache` · `/budget` · `/debug` · `/stop` · `/remember` · `/forget` · `/always` · `/always ` · a word nobody defined | Answers with a note, and the first line of that note is put on home's own line under the box. `there is no command called /pricing · / lists them` is now something you can read. |
| **`opens the crew panel`** | `/crew` and every `/crew` shortcut | Home steps aside and the crew panel opens over the conversation behind it; `esc` on the panel brings you back to home. A shortcut opens it with a tick on the row it changed. |
| **`runs on the conversation behind home`** | `/land` · `/land ` · `/workspace ` · `/delete` | `/delete` asks to permanently delete the conversation behind Home; it opens its confirmation here and never starts a new conversation. The other commands act on the conversation this window is holding behind the screen — not on the one `enter` would open — and its answer is echoed onto home's line. |
| **`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. |
@@ -1353,6 +1355,24 @@ 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.
+## Typing /automations on home — open the list, or add a reminder from the home screen
+
+**`/automations` has no fate on its rows of the `/` list yet** — they carry only their own
+description — so this is what `enter` does with it on home:
+
+- **bare**, it opens the automations place, as `alt+7` does;
+- **`/automations add …`** and **`/automations edit …`** close home and raise their
+ `save this automation?` or `save this change?` card in the conversation behind home.
+ A typed add runs in that conversation's project unless it names a `folder`;
+- **`run`, `pause`, `resume` and `delete`** with an id act at once, with no card, and their
+ receipt — `paused · weekly update` — is written in the conversation behind home.
+
+A line that cannot be read says why on home's own line and saves nothing.
+
+To set one up from home without leaving it, type the sentence and use `ask here` instead:
+its card is answered in home's own pane (*Ask here — a reminder or a watch without opening
+a conversation*). `/standing` is not a command any more; *Automations* has the typed forms.
+
## Attach a file before starting — /attach on home, the tray rides into the new conversation
**`/attach ~/logs/server.log` typed on home puts the file on HOME's tray, and starts no
@@ -1426,8 +1446,7 @@ selected project. A new window starts with its own default.
**`alt+p` is the same gesture for the folder** — press it, or press the path at the right of
the keys row, and
-the target walks through the projects in the panel's order, including projects with only
-standing work, and wraps after the last. Clicking a project name in the panel selects it directly. All three controls share one
+the target walks through the projects in the panel's order and wraps after the last. Clicking a project name in the panel selects it directly. All three controls share one
selection, shown only as `project: ` at the right of the keys row. If `/project` selected a destination outside the panel,
the next cycle starts at its first project. With just one destination already selected,
`alt+p project` is absent.
@@ -1442,8 +1461,8 @@ asks, never onto `refuses`: home says `approvals · YOLO · tools run without as
critical commands still ask · for the next conversation you start here`, and the cell
wears the warning hue while the gate is open. Both are carried onto the conversation
`enter` opens. **The rung lasts as long as this window does, like the model; the gate is
-spent** — after the conversation opens, Home's approvals cell returns to the
-standing choice. The selected project remains pinned. Neither cell is drawn on a window whose session has no dial for it, and over
+spent** — after the conversation opens, Home's approvals cell returns to its
+unpinned choice. The selected project remains pinned. Neither cell is drawn on a window whose session has no dial for it, and over
`--host` the far machine's rows decide.
**Home and conversations share the model, effort and approvals controls.**
@@ -1560,7 +1579,7 @@ and opens home. So a space you actually wanted is never eaten: space then `x` le
lines, from a `ctrl+j` or an `alt+enter` you did not mean. It also includes the other
places: the same two spaces, typed into a place's own empty box — the tasks roster's filter,
the memory filter — open home from there. Places without a box,
-such as spend and standing, count the two spaces directly. The door still
+such as spend and automations, count the two spaces directly. The door still
loses to a space that already means something where you are standing: on the settings panel
space is the row's `activate` verb, inside a task's record `space` pages the card, and on
home itself two spaces type into home's own box.
@@ -1632,10 +1651,10 @@ this folder as the first row of `projects`.
## Is there a key for home?
-Three of them. **`alt+1`** goes straight there from anywhere — home is the first of the
-seven words on the tab bar. The other numbers do not follow the bar: each still opens the
-place it opened before the bar was regrouped, so memory, the fifth word, is `alt+8`, and
-standing, which is off the bar, is `alt+7`; `alt+.` draws every place with its number.
+Three of them. **`alt+1`** goes straight there from anywhere — home is the first word on
+the tab bar, and every place answers to a digit of its own, `alt+1` through `alt+8`
+(automations is `alt+7` and memory `alt+8`, though they stand in the other order on the
+bar).
**Space twice on an empty box** goes there from inside a conversation, and **`tab`** walks to
it from any other place. `/home` opens it too.
@@ -1652,12 +1671,13 @@ activity page, `/history`).
Home remembers when you last **closed** it, and the `since you left` panel says what
finished after that — a line per task that landed, a line per file a conversation made, the
-watches that fired and what memory learned (*What did it do while I was away*).
+automations whose runs were news and what memory learned (*What did it do while I was away*).
That is the whole mechanism **on home**: no badge, no list of unread things, and no mark of
its own on a row. (A session's own window does send a desktop notification when its turn
finishes or it stops on a question while you are looking elsewhere — see "Why a session says
-it needs you" below. Home itself never does.)
+it needs you" below — and an automation's run raises one of its own (*Automations*, *Where
+does the news arrive*). Home itself adds none.)
Looking at the screen is what counts as seeing, so nothing is marked seen the instant it
appears. The panel holds while the screen is open — a refresh does not silently unmark the
@@ -1711,13 +1731,12 @@ The chips are the ones the question has:
- It is **waiting for permission to run something**: `1 allow once 2 always 3 deny`.
- It is **asking whether to start a task**: `1 yes 2 no`.
-- It is **asking whether to keep something going**. The chips are that card's own
- words: a repeating check is `1 Set it up · `, `3 Only now, don't repeat`,
- `0 Don't set it up`. A one-off reminder has no once, and its no is `Don't remind me`.
- A watch's no is `Don't watch`. A rule's no is `Don't keep it`. **`0` is how you say
- no from home**: nothing is set up, nothing is run, and the card settles as `not set up`.
- There is no change chip here, on purpose: that answer is a request for a text box. Open
- the conversation to say a different time or place.
+- It is **asking whether to save an automation** — a reminder, scheduled work or a watch:
+ `1 Save 2 Save and run it now 0 Don't save`, with `2 Save and check it now` on a watch
+ and no `2` on a reminder or on work kept in a separate worktree. **`0` is how you say no
+ from home**: nothing is saved and nothing runs, and the card settles as
+ `weekly update · not saved`. There is no change chip here, on purpose: `o Change…` asks
+ for words. Open the conversation to say a different time or place.
`2 always` means what it means in the window: **that session stops asking about that
tool** for the rest of its life. It does not write a permission rule into your settings. The
@@ -1778,8 +1797,8 @@ about somebody else's disk.
Yes, every few seconds, by reading the folders again. A task landing in another window,
work somebody starts in a second terminal, or a session stopping to ask a question all
show up without you doing anything. There is no file watcher: home reads, and closing the
-screen stops the reading. (Standing items — reminders, watches, rules — are a different
-mechanism and do keep going; see the keeping-an-eye page.)
+screen stops the reading. (Automations are a different mechanism: the clock runs them while
+any codeaf window is open, whether home is up or not — see *Automations*.)
The resting screen does not animate, except the one spinner on the first working conversation row
(*Why does only one row spin*); the ages simply change on the next reading. In screen-reader
@@ -1789,191 +1808,106 @@ The cursor stays on the row it was on rather than on the line number — the ord
changes when work starts or finishes, and a cursor that stayed put would move you onto
something else between two glances.
-## What is the ◦ row on home — the little circle, and when does a standing order go off?
-
-**A standing thing — a reminder, a watch, a rule, an overnight job — is on home's panels
-three ways:**
-
-- **while it is asking you something**, it is a row of `needs you`, with the amber `?` and
- what it is asking under it;
-- **while it is firing**, it is a row of `standing` like any other order — it is not a
- task and has no row on `sessions`. It is still the item — `ctrl+e` pauses it, `ctrl+x` stops
- it, and `alt+e` raises how hard it thinks;
-- **while it is simply waiting for its time**, it is a row of `standing`, soonest first.
- Home does not show when a waiting order will next go off. Press `enter` on the row, or
- open the standing place with `alt+7` or `/standing`, to read when it is due.
-
-`enter` on a question row opens the conversation that asked for it; on a `sessions` row it
-opens the conversation; on a `standing` row it opens the standing place.
+## The little circle on home, and when does a standing order go off now — the automations panel
-The `◦` mark itself belongs to the standing place and to a conversation's own lines —
-`◦ leave for the train · in 4m`. `∙` is a paused item there, and `◆` means the thing went off
-after the last time you spoke in the conversation behind it. The standing place (`alt+7`,
-`/standing`) is every promise this machine has made, with how much rope each has.
+**The `◦` rows went with standing orders.** In their place home has the `automations`
+panel, the last of the seven: one row for each automation that will run again — a
+reminder, scheduled work or a watch — soonest first, each its title with nothing at its
+right.
-**Retired items are nowhere on home.** Something that fired once and finished, or that you
-stopped, is a thing that happened — the `since you left` panel says so the morning after.
+**When it goes off is in the row's description**, under the cursor or the pointer:
+the sentence `/automations` draws, which says when it goes off next — its kind, its
+schedule, where it is now and how its last run went. Under the cursor `weekly update` reads
-**Typing does not find them.** The box at the foot searches conversations, and a standing
-row riding along under a query would be a row the query never considered; the standing
-place is where every one of them is.
+```
+scheduled work · every Monday at 09:00 · next Mon 12 Oct at 09:00 · last done
+```
-## How do I pause a reminder from home — the → strip, and ctrl+e / ctrl+x
+and a one-time reminder reads `reminder · today at 18:00`. A rhythm says `next …`; a
+one-time automation's schedule is its moment; a run in hand reads `running now` or
+`about to run`. `enter` on the row, or a click on the `automations` heading, opens
+`/automations` (`alt+7`), which also keeps the paused and finished ones.
-Put the cursor on the item's row (or point at it). **`ctrl+e` pauses it** and **`ctrl+x`
-stops it for good**, from any column, with no strip (on a `sessions` row this window holds,
-`ctrl+x` asks to stop that task instead). **`→`** shows the row's options, including
-`p pause it` or `r resume it` for a paused item. They appear below its description in the
-middle column on a wide home, or under the row on a narrower layout. While they are
-drawn those letters are the verbs. `esc` or `←` closes them.
+The `◦` lines in a conversation went too: a run now leaves one dim line in the conversation
+that made it, `weekly update · done · drafted it`, and home's `since you left` lists it
+(*Automations*, *Where does the news arrive*). Typing on home searches conversations, not
+automations; typing `aut` offers the automations place itself.
-A third chord, **`alt+e`**, raises how hard that item thinks — see "Make a reminder think
-harder" below. Home says `paused · ` or `stopped · ` at the foot and
-redraws the row from the store, so what you see is what is on disk rather than what the
-keypress hoped for.
+## How do I pause a reminder from home — not on home itself: /automations, then → and p
-Home's box takes every letter, always, so a `p` is a `p` in your sentence wherever the
-cursor rests — *unless the strip is on screen*. That visible strip is what buys the two bare
-letters, and it is the only state on this surface where a printable key is not a character.
+**Home's `automations` rows have no verbs of their own.** `→` opens no strip on them, and
+`ctrl+e` and `ctrl+x` do nothing there; `ctrl+o` opens the folder the automation runs in.
+Pausing, running and deleting belong to the automations place:
-A window whose build cannot write to the store says `this window cannot change
-it` rather than pretending.
+1. `enter` on the row — or a click on the `automations` heading, `alt+7`, or
+ `/automations` — opens the automations place;
+2. on the automation's row there, `→` then `p` pauses it and says `paused · `; `p`
+ again resumes it, `going again · `.
-`enter` on the row **opens the conversation that asked for it** — that is the answer to
-"why did I get this?", whatever project it belongs to. Something you set up from home that
-never became a conversation opens the standing place with the cursor on it instead.
+The same strip has `r` to run it now, `s` to stop a run in hand, `e` to edit it and `d` to
+delete it (*Automations*, *Pause, resume, run now, stop a run that is going, or delete an
+automation*). You can also say it in any conversation — "pause the CI watch" — or type
+`/automations pause `.
-## Make a reminder think harder — how hard a standing item thinks, and alt+e on its row
+A paused automation leaves home's panel, because it is no longer coming up; `/automations`
+still lists it, marked `paused`.
-The machine's **thinking** row does not reach a standing firing at all. An install
-dialled to `max` still does not turn every check on the machine into a deep pass,
-and an item nobody has dialled asks for nothing.
+## How hard does a reminder think, and can I make one think harder — no rung of its own
-**`alt+e` on an item's row is how you raise the one that deserves it.** Put the cursor on
-the standing item — on home at rest it has a row under `standing`, or under `needs you`
-while it is asking you something — and press it: the rung climbs one step each
-press — `low`, `medium`, `high`, `xhigh`, `max`, then back to `low` — and home says
-`thinking high · ` at the foot. The item's sheet on a phone then carries a dim
-`thinking high`, read straight back from the item's own document.
+**An automation has no thinking rung of its own, and nothing on home raises one.** `alt+e`
+on home walks how hard **the next conversation you start here** thinks, whichever row the
+cursor is on, and says `thinking · high · for the next conversation you start here`. It
+used to raise a standing item's own rung; that went with standing orders.
-An item nobody has dialled says **nothing** at all about it, which is not the same as
-`low`: it means nobody chose and no rung is sent. The rung is kept with the item, so it
-survives closing codeaf, and it is what that item's firings **and** its checks ask for
-from then on.
+What each kind thinks with:
-`alt+e` does nothing on a conversation's row: that rung belongs to the window that
-conversation is open in, and the machine's own default is the `thinking` row of `/settings`.
-A window that cannot write to the store says `this window cannot change it`.
+- **a reminder** calls no model at all — it only says its line;
+- **scheduled work** is put together the way your own conversations are, so it thinks as
+ hard as an untouched conversation of yours: the `thinking` row of `/settings`, or a level
+ set on the model it runs on;
+- **a watch** has every look judged by a low-tier model, and any work it then runs is
+ scheduled work.
-## Keeping an eye on — the status line, and /status
+There is no way to make one automation think harder than the others. Moving the `thinking`
+row moves every conversation that has not chosen for itself, and every run of work with
+them.
-When the project this window is in has something standing, the status row at the
-foot of the frame grows one dim segment:
+## Is the ambient side off — keeping an eye on 2 and keeping watch are gone; what runs on the clock now
-```
-◦ keeping an eye on 2
-```
+**They went with standing orders.** The `◦ keeping an eye on 2` segment at the foot of the
+frame, `/status`'s `keeping watch` row, the `background checks` setting and the background
+timer the machine used to install are all gone. Nothing is installed on the operating
+system any more, and codeaf removes the old timer on every start.
-**Nothing at all when there is nothing** — a line that permanently read
-`keeping an eye on 0` would be a permanent reminder of the absence of a thing.
-The glyph **breathes** — it becomes the same spinner every running thing here
-wears — only while one of them is actually firing. The rest of the time it is
-still.
+What runs on a clock now is an **automation**, and **only while a codeaf window is open**:
+one small process per codeaf home, `codeaf clock`, runs them for every window. There is
+nothing to switch on: with a window open they run; with none open nothing runs, and what
+fell due runs once, marked `late`, when one is. A window started with
+`CODEAF_NO_AUTOMATIONS` set starts no clock of its own (*Automations*, *Does it run when
+codeaf is closed*).
-`/status` adds one line under the same heading:
+**`/status` says what is on the clock**, in one line while anything is:
```
-keeping watch installed · last check 4m
-keeping watch while a window is open · last check 4m
-keeping watch nothing is checking · say "remind me…" to start
-keeping watch nothing is checking · background checks are off · /settings
+automations 3 on the clock · next weekly update Mon 12 Oct at 09:00 · 1 running
```
-It says **`installed`** when the machine's own timer is set up, so the checking
-happens with no terminal open at all. It says **`while a window is open`** when
-there is no timer but this window is running the pass itself, every five
-minutes. When neither is true it says **`nothing is checking`** — nothing was
-switched off, and the items are still there and still due; there is simply
-nothing running the checks right now. The tail says which way it got there:
-`say "remind me…" to start`, because setting up the first standing thing is what
-installs the timer, or `background checks are off · /settings`, because something
-has stood here before — so the timer went on once — and it is not on now.
+With nothing on the clock there is no line at all. Home's `automations` panel lists the
+same automations, soonest first, and `/automations` (`alt+7`) has every one.
-The `last check` half is dropped when nothing has ever run. A window with no
-ambient side at all — `--host`, a build without it — prints **no line**, and so
-does one whose timer could not be read: an absent answer is left absent rather
-than reported as "off".
+## How do I say no to a reminder from home — 0 Don't save, from the conversation's row
-## Why did a card appear asking me about a reminder — saying yes to something standing
+**Nothing is saved until somebody answers.** A reminder, scheduled work or a watch is
+proposed under `wants to remind you`, `wants to schedule work` or
+`wants to watch for something`, and a conversation waiting on one is a row with the amber
+`?` on home. Its answers are in the row's description — `1 Save 0 Don't save` on a
+reminder; scheduled work adds `2 Save and run it now` (except when it is kept in a separate
+worktree), and a watch `2 Save and check it now` — and **`0` answers it `Don't save` from
+home**: nothing is saved and nothing runs, and it settles as `weekly update · not saved`.
-When you say something that would keep working after this window closes — "remind
-me at 6 to leave", "tell me when CI on main goes red", "every Monday post the
-standup note" — a card appears in the conversation and **nothing is set up until
-you answer it**:
-
-```
-╭─ ? wants to set up a repeating check ──────────────────────────────────────
-│ every Monday at 9, post the standup note from the git log
-│ Mondays at 9am · about $0.02 a run, at most once a day
-│ where · for this project
-╰────────────────────────────────────────────────────────────────────────────
-```
-
-The head says what kind of thing it is. The next line is what it does. The line
-after that is when, and what one time costs. **`where ·`** is how far it reaches.
-A watch that has to look at something adds `checked every 5 minutes` on that
-cost line. If it made the timing up rather than reading it off what you said,
-the line asks instead of stating:
-`Mondays at 9am. You didn't say, so that's my guess. Right?`
-
-**The answers**, by key, by `←`/`→` and `enter`, or by clicking one:
-
-- `1` sets it up. On a repeating check the button reads `Set it up · `.
-- `o Change…` turns the box into a place to say the time or the place you want
- instead: "make it 8", "only in this project", "everywhere". Nothing is set up
- until a new card comes with them in it. On a rule the button reads `Change where…`.
-- `3` does it now and leaves nothing behind. On a check it reads `Only now, don't repeat`.
- This used to say `just once`.
-- `0` sets nothing up. The button's words depend on the kind: `Don't set it up`,
- `Don't remind me`, `Don't watch`, or `Don't keep it`. The row settles as `not set up`.
-
-**The line under the answers says what the one you are on will actually do**, written
-out of this card's own facts. A **one-off reminder's card draws no `3`**: "do it now" for
-a line meant for six o'clock is the wrong thing at the wrong moment. The `0` is on every
-one of them.
-
-**There is no clock on this one**: no countdown, and no moment where it answers on your
-behalf. If the turn ends with the card still up it says `ended · nothing was set up`. An
-answered card settles in place, grey, with the answer and what it came to on its bottom
-edge — in a conversation and in home's `ask here` pane alike.
-
-**The first time you ever set one up** the machine's own timer goes on, and one dim line
-says so, once, ever:
-`checks every 5 minutes, window or not · background checks under /settings`.
-
-## What does a ◦ line in the middle of my conversation mean — news from something standing
-
-Once something is set up it writes **one line and never more** into a
-conversation you have open — the one that asked for it when that is open, and
-otherwise whichever one of that project you are sitting in (the keeping-an-eye
-page has the whole order):
-
-```
-◦ every Monday at 9 · set up
-◦ every Monday at 9 · said: the standup note is in notes/standup.md
-? keep main green · your call: the fix touches migrations
-∙ remind me at 6 to leave · stopped
-```
-
-**When the line appears.** If the chat is open when the thing fires, the line is
-drawn **at once**, the moment the firing arrives. If the chat was **shut** when it
-fired, the same line is drawn when you open it: one row for each thing that was
-waiting, oldest first, above the first thing you type.
-
-That is the whole of it. **A check that found nothing writes nothing** — a watch
-that ran faithfully for thirty mornings and found nothing leaves your
-conversation exactly as quiet as it was, and home's `since you left` panel is where
-you go to confirm it really did look.
+`o Change…` asks for words, so home offers only the digits; open the conversation to say a
+different time or place. One that `ask here` raised is answered in home's own pane with the
+same keys. *Automations* (*The card*) has every line one can show.
## Ask here — a reminder or a watch without opening a conversation
@@ -2002,6 +1936,11 @@ another conversation; the row is still here, still waiting, when home opens agai
filed only once it is over, you have seen what it came to, and you have moved off its row —
or when you quit.
+**A reminder, scheduled work or a watch asked for here arrives as its card in the pane**:
+`1`, `2` and `0` answer it as they do in a conversation, and `o` opens the pane's own box
+for a correction. Once it is saved the pane says `saved · /automations lists it`, and the
+row's tail reads `saved`.
+
The whole of it — where the record goes, how the card is answered, how to turn the exchange
into an ordinary conversation — is on its own page: *Asking from home*.
@@ -2026,8 +1965,9 @@ search*). Under 60 columns a row opens a full-frame sheet instead (*Opening a ro
phone*).
**The machine's own card is gone too.** `↑` off the top of the column stays on the top row,
-and each thing that card said has a place: `keeping an eye on` is the standing place
-(`alt+7`), `today` is the `spend` panel and the pulse line, `agents` is the pulse's
+and each thing that card said has a place: `keeping an eye on` went with standing orders,
+and what runs on a clock is the `automations` panel and `/automations` (`alt+7`), `today` is
+the `spend` panel and the pulse line, `agents` is the pulse's
`4 moving`, and `thinking` is the `thinking` row of `/settings`.
## The card beside a search — the preview on the right while you type
@@ -2046,16 +1986,16 @@ drawn only when it has something to say:
4. **the work** — each task and what it came to, three of them, then `▸ N more tasks`;
5. **the files it made**, as links;
6. **where you left off**, the last exchange;
-7. **what is scheduled** and **news since you last looked**;
-8. a dim line of **facts** — `touched 12 files · spent $1.25 · 34k tokens · last active
+7. a dim line of **facts** — `touched 12 files · spent $1.25 · 34k tokens · last active
12m`;
-9. one dim line naming the strip: `→ verbs: delete, copy name, new in project,
+8. one dim line naming the strip: `→ verbs: delete, copy name, new in project,
open folder`.
It never moves while the list lifts under your typing, and it goes empty on the
`start a new conversation` row, because that chat does not exist yet. A frame too short for
all of it drops bands from the bottom and never touches the name. Nothing that is zero is
-drawn.
+drawn. The **what is scheduled** and **news since you last looked** bands went with
+standing orders; what is coming up is home's `automations` panel.
## Copy a conversation name from Home
@@ -2080,9 +2020,9 @@ are looking at: the row under your pointer when there is one, the cursor's row o
| `ctrl+t` | a fresh conversation **in that row's own folder** — the one you were in keeps running |
| `ctrl+o` | asks the machine to open that conversation's workspace folder |
| `ctrl+y` | copies the workspace path |
-| `ctrl+e` | confirms conversation deletion, or pauses a standing item |
-| `ctrl+x` | stops a standing item for good |
-| `alt+e` | raises how hard a standing item thinks; nothing on a conversation row |
+| `ctrl+e` | confirms conversation deletion |
+| `ctrl+x` | on a task this window runs, asks to stop it; nothing on a conversation row |
+| `alt+e` | walks how hard the next conversation you start here thinks, whatever the row |
`y` and `n` are a question's own first two answers on the `→` strip — `y let it send`,
`n not this time` — and exist only while that strip is drawn.
@@ -2110,10 +2050,10 @@ pushes the rows under it down. The options are shown only while their shortcuts
**The verbs are the row's own:** a question's first two option words on its own answer keys; a
conversation's `x delete`, `c copy name`, then, where it has a folder, `n new in project`
-and `o open folder`; a standing item's `p pause it` or `r resume it`; a task this
-window runs, `s stop`.
+and `o open folder`; a task this window runs, `s stop`. A row of the `automations` panel
+has none: `enter` opens `/automations`, where its verbs are.
-**`→` opens the strip on every row of the field, at every width** — the arrows never
+**`→` opens the strip on every row of the field that has verbs, at every width** — the arrows never
leave the field (owner, 2026-09-17) — and a conversation's verbs are on their chords too:
`ctrl+e`, `ctrl+o`, `ctrl+y`, `ctrl+t`. The foot names none of them and says the same
sentence on every row. A project's row has no strip: clicking it selects the next message's project.
@@ -2224,14 +2164,18 @@ until the title would be down to twelve cells.
## What happened in this conversation while I was away — news since I last looked
**On the resting home, the `since you left` panel is where that lives** — one line per task
-that landed, per file made, per watch that fired, each a door (*What did it do while I was
-away*).
+that landed, per file made, per automation whose run was news, each a door (*What did it do
+while I was away*).
-A conversation's own **news band** — `◆ N things since you left`, with each item underneath
-reading ` · · `, for example `4m · keep main green · the tests passed` —
-is drawn on the **phone sheet** under 60 columns and on the **card beside a search**. It
-shows three items, then a `▸ …N more things` door. An absent or empty inbox draws no news
-band at all, and looking at the band does not consume the news.
+**Inside the conversation**, a run of an automation it set up leaves one dim line —
+`weekly update · done · drafted it` — and a run that ended while the conversation was shut
+is drawn when a codeaf window next starts with it in front, not when you switch to it in a
+window already open (*Automations*, *Where does the news arrive*). Its tasks
+say what they came to on their own rows.
+
+There is no news band on a conversation's card any more: `◆ N things since you left`, on the
+card beside a search and the phone sheet, carried what its standing orders had left, and it
+went with them.
## Where are the files it produced — deliverables on a conversation
@@ -2270,49 +2214,38 @@ timed-out Git check draws nothing. The reading arrives a moment after home opens
that is no longer on this disk says `that folder is gone` in place of the branch it cannot
have.
-## Standing on home — what is scheduled, reminders, routines, watches and rules, what is next up and when
+## Automations on home — reminders, scheduled work and watches, what is scheduled, what is next up and when
-**The `standing` panel, last of the seven**: every standing order this machine will act
-on, from every project, **soonest first**, with the rules that simply hold at the end.
-Four kinds of order stand on it — a **reminder** (`remind me at 6`), a **routine** (`every
-morning at nine`), a **watch** (`tell me when CI goes red`, `when go.sum changes`) and a
-**rule** (`never change the public API without telling me`). Each row is your own words
-and **nothing at its right**:
+**The `automations` panel, last of the seven**: every automation on this machine that will
+run again, from every project, **soonest first** — a **reminder** ("remind me at 6"),
+**scheduled work** ("every morning at 9, draft the standup note") or a **watch** ("tell me
+when CI goes red"). Each row is the automation's title and **nothing at its right**:
```
- standing
- the 6am repo watch
- top movers before the open
- tell me when CI goes red on master
- never change the public API without telling me first
+ automations
+ leave
+ weekly update
+ ci on main
```
-**When it happens is said once, in the row's description under the cursor, and each kind
-says it one way** — a fixed sentence you learn once, the kind word first. It is there at
-every width: beside the row in the middle column from 170 cells, and as the line under the
-cursor's row on a narrower frame.
-
-- a reminder: `reminder · goes off tomorrow 9:00am`
-- a routine: `routine · every morning at nine · next tomorrow 9:00am · last: done, two branches landed`
-- a watch: `watch · every five minutes · last looked 3m ago · found: the last five runs are green`
- — or `found nothing`, which for a watch is the commonest finding and a real one
-- a rule: `rule · always`
-
-Moments read `today 6:00pm`, `tomorrow 9:00am`, a weekday inside the week (`mon 9:00am`),
-a date beyond it (`21 sep 9:00am`), and `now` once they have arrived. A routine that has
-never fired has no `last:`; a watch that has never looked has no `last looked`. An order in
-the middle of a pass says what the pass is doing instead of its clock.
-
-**An order stopped on you is not on `standing`** — it is a row of `needs you`, with its
-question, and comes back here the moment you answer. Paused, stopped and retired orders are
-not on it either.
-
-Three rows show, five in a tall window, then `N more`, which `enter` opens. **Every row is a
-door into the standing place**, where the orders are kept and changed.
-
-With nothing standing it keeps its heading and
-`reminders, routines, watches and rules · "remind me at 6" or "every morning at 9"` — the
-words that set one up. On a short terminal `standing` is the first panel to give way.
+**When it happens is said once, in the row's description under the cursor** — beside the
+row in the middle column from 170 cells, and as the line under the row on a narrower frame.
+It is the sentence `/automations` draws: the kind, the schedule, where it is now and how its
+last run went — `scheduled work · every Monday at 09:00 · next Mon 12 Oct at 09:00 · last done`,
+`watch · every 15 minutes · running now · last nothing new`, `reminder · today at 18:00`.
+Moments read `today at 18:00`, `tomorrow at 09:00` or `Mon 12 Oct at 09:00`.
+
+Paused and finished automations are not on it; `/automations` keeps them. Three rows show,
+five in a tall window, then `N more`, which `enter` unfolds. **Every row opens the
+automations place** (`/automations`, `alt+7`), and so does a click on the heading: that is
+where one is run, paused, changed or deleted, because the row itself has no verbs. `ctrl+o`
+on a row opens the folder it runs in.
+
+With nothing on the clock the panel keeps its heading and
+`reminders, scheduled work and watches · "remind me at 6" or "every morning at 9"` — the
+words that set one up. On a short terminal `automations` is the first panel to give way.
+The card beside a search and the phone sheet no longer carry a `next up` band; this panel is
+what is next up (*Automations*, *Automations on home*).
## How much did today cost — the spend panel on home
@@ -2373,10 +2306,11 @@ phone-width frame is walked one row at a time rather than scanned.
Top to bottom:
1. `sessions`: the fifteen most recent conversations, combining open tabs and saved history without duplicates.
-2. Additional question rows, without a heading — conversations not already listed above,
- standing items that need a look, and ask-here panes holding a card, from **any** project.
+2. Additional question rows, without a heading — conversations not already listed above
+ and ask-here panes holding a card, from **any** project.
3. Home ask exchanges retain their own answer rows.
-4. `since you left` — what landed while you were not in the room.
+4. `since you left` — what landed while you were not in the room, the runs of automations
+ among it. There is no `automations` section at this width; `/automations` lists them.
5. **The projects.** This window's own project is drawn open with its remaining rows; every
other project is one folded line — `▸ wisp 6 · 2d` — that `enter`, `→` or a tap opens
in place, and `←` folds back.
@@ -2404,10 +2338,9 @@ There is no second column under 60 columns, so `enter` — or a **tap**, in one
rather than two — opens the row's card over the **whole frame**. The top row reads
`‹ back`, the title and the place are under it, and everything below is the card's bands.
-The order is what you can act on first: the answers, then the state, then
-`since you left`, then the work, then what is next, then where it left off, then the
-files it produced. The repository, the keys and the spend are behind one `▸ more` at the
-foot; `→` or `m`, or a tap on that line, opens it.
+The order is what you can act on first: the answers, then the state, then the work, then
+where it left off, then the files it produced. The repository, the keys and the spend are
+behind one `▸ more` at the foot; `→` or `m`, or a tap on that line, opens it.
Keys on the sheet:
@@ -2418,17 +2351,14 @@ Keys on the sheet:
| `↑` `↓` `PgUp` `PgDn` `g` `G` | scroll the card |
| a digit | answer the question the card is showing |
| `→` or `m` | open everything behind `▸ more` |
-| `→` then `p` / `s`, or `ctrl+e` / `ctrl+x` | on a standing item: pause it, stop it |
An `ask here` errand's row opens a sheet too — the exchange and its box — and it also opens
by itself whenever the errand takes the keyboard: `ask here`, its chord, `→` on the row. Its
keys are the pane's own: `tab` or `esc` back to the inbox, and `enter` sends what you typed
(*Asking from home*).
-A standing item's sheet carries your words, where it is, when it goes off, what it has
-done, `4 runs · spent $0.08`, `ran 3 times this week · $0.04`, and how hard it thinks when
-you have said. A frame too short for all the bands **drops them from the bottom** — never
-the title and never the answers.
+A frame too short for all the bands **drops them from the bottom** — never the title and
+never the answers.
**Rotating the phone costs nothing.** Crossing 60 columns swaps the inbox for the panels
and back, and the cursor, what you typed and any errands are all still there.
@@ -2477,6 +2407,9 @@ are said where they happen: in the conversation that met them, and on `/status`.
**Group by project or hide the quiet chats.** `alt+g` and `alt+q` are unbound; the
`projects` panel and the panels themselves do those jobs.
+**Pause, run or change an automation.** A row of the `automations` panel has no verbs;
+`enter` opens `/automations`, where `→` on its row has them.
+
**Show a preview card at rest.** The card is only beside a search, on a wide frame.
**Change a panel's place or turn one off.** The seven panels and their order are fixed, and
diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md
index f61d35e26d..5d94ec84c7 100644
--- a/internal/manual/chat/how-tasks-run.md
+++ b/internal/manual/chat/how-tasks-run.md
@@ -647,17 +647,19 @@ started with `inherit`, or a reply the ceiling carried on — and that one opens
this conversation exactly as it stands, every result in it, reading none of it again
(*tasks*, under *Can it keep what it read*). Every other task inherits **not the
transcript**: one assembled brief is its whole world, and your own message is the first
-part of it (below, under *What the task actually reads*). The
-one thing it is given of what codeaf remembers about you is the handful of lines its own
-brief needs: the conversation asks the router once, against that brief, and puts the
-answer at the top of the task's instructions (what-i-remember). The task itself never
-writes a memory — a family of eight tasks would be eight writers on one brain, each blind
-to the others. If it runs on a **different model from the conversation**, its context
+part of it (below, under *What the task actually reads*).
+Of what codeaf remembers about you it is given two things: the handful of lines its own
+brief needs — the conversation asks the router once, against that brief, and puts the
+answer at the top of the task's instructions — and your rules, the memories marked always
+that hold where it starts, which close its brief (what-i-remember, under *Always*). The
+task itself never writes a memory — a family of eight tasks would be eight writers on one
+brain, each blind to the others. If it runs on a **different model from the conversation**, its context
window is set to 0 rather than reusing a window measured for another model.
-**Three tools are missing from its belt:** `watch`, and the settings pair `settings` and
-`change_setting` — a task works in a copy of its own with nobody watching it, so a watch's news
-would arrive in a conversation it does not have, and a permanent change to your machine
+**Four tools are missing from its belt:** `watch`, `automation`, and the settings pair
+`settings` and `change_setting` — a task works in a copy of its own with nobody watching it,
+so a watch's news would arrive in a conversation it does not have, an automation's card
+would have nobody to answer it (*Automations*), and a permanent change to your machine
that no transcript ever showed you is exactly what a task must not be able to make.
**A piece is told it owns the piece.** Your message travels to every task and sub-task
@@ -715,8 +717,8 @@ cost-oriented working set. With an unknown context size no larger line is invent
## Can a task change my settings — can a task look up an old conversation, can a task start a watch, my task said it cannot do that from here
A task can search earlier conversations when its parent has history access.
-Settings changes and watches still require the conversation. The worker's
-instructions describe the tools it actually carries:
+Settings changes, watches and automations still require the conversation. The
+worker's instructions describe the tools it actually carries:
- **Change a setting.** `settings` and `change_setting` are off inside a task. A
worker runs in a copy of its own with nobody watching, and a permanent change
@@ -735,6 +737,9 @@ instructions describe the tools it actually carries:
require an extra file unless the request or the work's own claim requires one.
- **Start a watch.** `watch` delivers its news into a conversation and a task has
none. A worker waits with an ordinary foreground `bash` call.
+- **Set up a reminder, scheduled work or a watch on a clock.** `automation` is off
+ inside a task, on either road, so a task cannot leave anything behind to run later.
+ Ask for it in the conversation, where its card can be answered (*Automations*).
- **See the work that already ran** — but only at the bottom of the tree. A task
keeps `tasks` and `propose_task` while it may still hand pieces out; a piece
that was handed out by a piece is standing on the floor and has neither. Its
@@ -3178,9 +3183,8 @@ bytes. A long or nuanced ask can be cut, and the cut is marked with `…`. The p
recourse: the worker may open that path at that line and read what you actually typed. What
it must ship is still what the brief says.
-**An empty pointer draws nothing.** A standing order that fired with no person turn behind
-it, or a task restored from a checkpoint written before this existed, has no origin
-section. Unknown is absent, not a guessed path.
+**An empty pointer draws nothing.** A task restored from a checkpoint written before this
+existed has no origin section. Unknown is absent, not a guessed path.
A task handed out from inside another task, and every part of a division, inherit the same
pointer — they still point at your turn, never at the parent task's own journal.
diff --git a/internal/manual/chat/keeping-an-eye.md b/internal/manual/chat/keeping-an-eye.md
deleted file mode 100644
index af62807679..0000000000
--- a/internal/manual/chat/keeping-an-eye.md
+++ /dev/null
@@ -1,1072 +0,0 @@
-# Keeping an eye on things — reminders, watches, rules, overnight work, and checking on it later
-
-Some things you say are not work for right now. "Remind me at 6 to leave", "tell
-me when CI on main goes red", "every Monday draft the weekly update", "keep main
-green", "tonight run the full suite" — each of those asks codeaf to leave
-something behind that keeps working after this window is closed.
-
-The tool behind all of them is `stand`. You never type it; codeaf recognises the
-words. Nothing is ever set up without a card you answer.
-
-## It asked while I was away
-
-**Ten minutes with nobody touching the keyboard** makes a window away — measured from the
-last key, not from which window is in front.
-
-After that, a question this project has a rule for may take its own recommended answer,
-and its receipt says `codeaf, on your settings` decided it. Only a **reversible** question
-with a recommended answer can go that way. Everything else stays open: it is on home, the
-desktop notification says the conversation is `waiting on you`, and the terminal bell rings
-**once** — only for a question something is blocked on, and never twice for the same one.
-
-Two kinds never run on a clock however you set it up: **confirmation always asks**, because
-it is what is asked before something destructive, and **clarification never runs on a
-clock**, because the answer is something only you have.
-
-`/autonomy` shows and changes those rules, per project. A question that is about to be
-taken by one says `your rule` on its own row while the clock runs — there are no hidden
-rules.
-
-## Remind me about something
-
-Say it the way you would say it to a person: "remind me at 6 to leave", "remind
-me on Friday to send the invoice". A card comes up with your own sentence on it,
-when it will fire, and that it shares the day's allowance. Say yes and it stands.
-The card has **no countdown on it** — it waits for you — and nothing is set up
-until you answer.
-
-A reminder fires once, says one line to you, and retires.
-
-The line looks like this, wherever it reaches you:
-
-```
-◦ remind me at 6 to leave: time to leave
-```
-
-Which chat it lands in is the next section.
-
-## Where a reminder arrives — the chat you are in, or the next one you open
-
-A firing reaches **you**, not one particular window. codeaf tries four addresses
-in this order and stops at the first one that ends at a person:
-
-1. **The conversation that asked for it, if it is open.** The line arrives in it
- as it happens, exactly as a finished task's news does.
-2. **Any other conversation of the same project that is open**, the one you most
- recently opened or spoke in first. This is what happens when the chat that set
- the reminder up has been closed — and it is always what happens when you asked
- from home with `ask here`, because that exchange is a pane on the home screen
- and not a room you sit in. It is never steered into.
-3. **The conversation's own inbox**, when nothing is open. The next time you open
- that conversation it is folded into one note beginning `while you were away`.
-4. **The project's inbox**, when nothing is open *and* it was an `ask here`
- errand. An errand has no conversation to come back to, so the news is filed
- under the project instead, and the next ordinary conversation you open in that
- project folds it into its own `while you were away`. At phone width home shows
- it under `since you left`; on a wider frame the conversation is where it
- surfaces.
-
-So a reminder set from home while you are working in a chat in the same window
-arrives **in the chat you are working in**. Nothing is ever delivered only into
-the `ask here` pane, and nothing is ever left in a file no screen reads.
-
-**What is drawn, and when.** In whichever chat it reaches, a firing draws one dim
-row of its own:
-
-```
-◦ remind me at 6 to leave · said: time to leave
-? keep main green · your call: the fix touches migrations
-```
-
-Live — roads 1 and 2, a chat that is open — the row is drawn **the moment the
-firing arrives**. It does not wait for the chat to answer it, and it does not
-wait for you to type: it appears while you are sitting there. The chat is woken
-by the same firing, so a reply usually follows underneath a few seconds later,
-and that reply is an ordinary turn you can read, scroll and interrupt.
-
-Waiting — roads 3 and 4, nothing was open — the rows are drawn **when you open
-the conversation**, one for each thing that was waiting, oldest first, before
-anything you type. The chat is also handed the same news as one `while you were
-away` note so it can answer questions about it.
-
-The row leads with your own sentence cut to its first six words, so
-`remind me in 1 minute to drink water` draws as
-`◦ remind me in 1 minute to · said: 💧 Time to drink water!`. The whole sentence
-is in the transcript, and on the item's own row on home.
-
-There is no phone, no email and no desktop notification — see the last section of
-this page.
-
-## Why did it run date before setting the reminder — it knows the clock now
-
-It does not any more. codeaf tells the model the time in its own instructions, as
-one line:
-
-```
-- Now: 2026-08-21 06:52 -04:00 (America/New_York, Friday)
-```
-
-The local time to the minute, the numeric offset, the zone by name and the day of
-the week. Before that line existed, "remind me in 2 minutes" began with a
-`bash date +"%Y-%m-%dT%H:%M:%S%z"` — a tool row you could see, spending a step to
-read a clock the program already had. The instructions now say plainly: never run
-`date` to learn the time.
-
-For a moment you name — "at 6", "tomorrow at 9am" — codeaf works the stamp out
-from that line itself. For a distance from now — "remind me in 2 minutes" — it
-sends the **duration** instead and codeaf resolves it against the real clock at
-that instant, then says back the moment it landed on. That is what the card
-shows:
-
-```
-in 2 minutes · 06:54
-```
-
-so you can check the time it settled on without reading a timestamp.
-
-A session that has been open for hours cannot drift on this. The `Now` line is
-stamped when a turn opens and **re-stamped** on any turn that opens more than ten
-minutes after the last stamp, anything relative is resolved against the real clock
-when you ask rather than from that line, and every `stand` result ends with
-`now: 07:34 -04:00` whatever else it said. A moment that has already passed is
-refused outright — see "Why did it set my reminder for a time that already
-passed".
-
-## Why is there no once on my reminder card — a one-off reminder has two answers
-
-Because "once" would not be a smaller version of what you asked for; it would be
-a different thing at the wrong moment.
-
-`3` means **do the action now, as an ordinary turn, and leave nothing
-behind**. On a repeating check the button reads `Only now, don't repeat`. On a
-watch it reads `Check once now`. It used to say `just once`. For "remind me at 6
-to leave" the whole content of the request is the **6**. Doing it now says
-`time to leave` hours early, or says nothing at all. A rule never runs. So
-neither of those cards offers `3`:
-
-```
-? wants to remind you
- 1 Remind me at 6 Reminds you then. Nothing repeats.
- 0 Don't remind me You are not reminded.
-```
-
-Two answers, `1` and `0`, and `3` does nothing. A rule is the same shape: it
-never runs, so there is no once. A repeating check and a watch keep `3`. The
-**way out is still drawn**, and it is last, so a narrow row does not drop it.
-
-## Why did it set my reminder for a time that already passed — it cannot any more
-
-It cannot. A moment that has already gone is **refused before the card is ever
-drawn**, and the refusal tells codeaf what time it is now so it can work the
-stamp out again:
-
-```
-Invalid arguments: when.at 05:42 -04:00 has already passed — it is now 07:34
--04:00 (Friday 2026-08-21). For a distance from now send when.in ("1m"); for a
-clock time compute it from now.
-```
-
-A stamp up to **30 seconds** behind the clock is still taken: that is arithmetic
-that was right when it was done, and firing at once is what you asked for. Two
-hours behind is not. An `expires` in the past is refused the same way — and so
-is an `expires` that stands before the reminder's own moment, which the next
-section is about.
-
-This used to be possible. A session that had been open for hours carried the time
-it *opened* with in its instructions, so "remind me in 1 minute" was worked out
-from a stale stamp and landed in the past. Two things changed:
-
-- The `Now` line in codeaf's instructions is **re-stamped** whenever a turn opens
- more than ten minutes after the last one, so the clock does not drift over a
- long session.
-- **Every `stand` result ends with the real time**, on its own line:
- `now: 07:34 -04:00`. Whatever the model was told when the turn opened, it is
- told the true time at the moment it matters.
-
-And "in 1 minute" no longer needs a stamp at all: codeaf sends the **duration**
-and the engine resolves it against the real clock. A one-minute reminder is an
-ordinary standing one-off — if you are ever told codeaf "cannot hold a
-1-minute timer", that is wrong, and this is the page that says so.
-
-## Can I give one an end date — expires, and why an end before the first firing is refused
-
-Yes. Anything that stands can carry an **end**, and the check that runs it
-retires it on that day: "watch the build until Friday", "never touch the public
-API until the release lands". Leave it out and it stands until you stop it.
-
-**The end has to be later than the first time the thing would fire**, and codeaf
-refuses one that is not, before any card is drawn:
-
-```
-Invalid arguments: rails.expires 23:11:00 -04:00 is not after when.at 23:11:11
--04:00, so it would retire before it ever fired. Put it after that moment, or
-leave it out — a one-off retires as it fires and needs no end at all.
-```
-
-For a routine the same sentence names `its first firing` instead of `when.at`,
-because a rhythm has no single moment to edit.
-
-This is not fussiness about a second. The check asks **"has it run out of
-time?" first**, before it asks whether anything is due, so an end at or before
-the moment kills the item by every road there is — the window's own pass, the
-background timer, `codeaf tick`, all of them. It would have run **0 times** and
-been marked `expired`, and the only trace would be a line in its own log:
-`its time ran out — no longer watching`.
-
-The mistake it exists to catch is arithmetic, not carelessness: a reminder said
-as "in 1 minute · 23:11" lands at 23:11:11, and an end taken from the same words
-lands at 23:11:00 — eleven seconds too early. That is why the refusal spells
-both stamps out to the second.
-
-**And the end has to outlive a check, not just the moment.** Nothing watches an
-item continuously: a check runs every five minutes, so an end that falls between
-the moment and the next check is found expired at the same instant it would have
-been found due. An end twenty-five seconds after a one-minute reminder is after
-the moment and still dead, and the second refusal says so:
-
-```
-Invalid arguments: rails.expires 21:05:00 -04:00 is less than one check after
-when.at 21:04:35 -04:00, so a check can find it out of time at the same moment
-it would have found it due. Checks are 5m0s apart. Put the end at least that far
-after the moment, or leave it out — a one-off retires as it fires and needs no
-end at all.
-```
-
-**A one-off reminder never needs an end.** It retires the moment it fires, and
-if nothing ever picks it up it stops being watched a day after its moment
-anyway.
-
-## Tell me when something happens — watch for it, and tell me when the build breaks
-
-"Tell me when CI on main goes red." "Tell me when the docs site stops returning
-200." "Tell me when the cert on api.example.com has under 14 days."
-
-A watch takes a look at the world on a timer and a small, cheap model reads what
-it found against your own words. It speaks **only when the answer is yes** — a
-check that found nothing writes nothing anywhere, which is why a watch that ran
-faithfully for thirty mornings and found nothing reads differently from one that
-never ran at all.
-
-The look is either a shell command run in this project, or one of codeaf's own
-tools — including a tool one of your connected accounts brought (see the
-accounts page). One look is bounded at **60 seconds**; what it printed is
-clipped to the last **8KB**, because the news in a command's output is at the
-end of it.
-
-The judgement is one sentence in plain words: "the last run on main failed". It
-carries the last few things it said, so something you have already been told
-about is not raised again every five minutes. A watch speaks the change: it
-stays quiet while the same reading still holds, and it tells you when the
-answer turns true. When the answer is neither a clear yes nor a clear no, codeaf
-writes nothing at all and looks again next time, so an unclear reading never
-consumes the moment you are waiting on.
-
-## Will a once watch pop a notification or toast
-
-"Notify me once when ready becomes true" is a watch that speaks **once**: it
-tells you the first time the condition turns true, delivers that one line, and
-retires. It cannot tell you again if the condition later goes false and true
-again, and a reading that merely repeats is not a second notice.
-
-With no window open there is no banner, toast, phone alert or email. Where the
-one line waits depends on the chat that set the watch up: one set from an
-ordinary conversation waits in **that conversation's own inbox**, folded under
-one `while you were away` the next time you open *that* chat; one set from home's
-`ask here` box has no conversation to come back to, so its line waits under the
-project instead — home shows it, and the next ordinary conversation you open in
-that project folds it into its own `while you were away`. An ordinary chat's note
-is **not** carried into some other new chat of the project. It is delivered once
-and never a second time, so a later reopen adds nothing — the line already in
-that transcript stays visible. If a window of that project is open when it fires,
-the line is drawn there at once.
-
-Drop the word `once` and a watch stays armed: it speaks each change, so
-false-then-true is another notice and a condition that is still true is not. A
-rhythm is `every` ("every Monday at 9"), which speaks on its schedule whether or
-not the answer changed.
-
-## How long do I have to answer the card — the card does not time out
-
-As long as it takes. The card carries **no clock**: no countdown, no bar, and no
-moment when it answers on somebody's behalf. It waits until you press the yes
-(`1`), the no (`0`), `o` to change it, or, where the card offers it,
-`3` to do it once. `3` used to read `just once`.
-
-`esc` **puts it off and does not answer it**. The question folds away so you can
-type, the work stays waiting on it, and the count beside the box goes on
-counting it. Press the chip beside that count to bring it back. That is a
-change: `esc` used to be the outright no on this card, and the outright no is
-`0 no` now — one visible answer rather than a key you had to know.
-
-A **one-off reminder's card has only two answers**; the next section says why. It
-still takes the decline.
-
-It does not have to be answered in that window either. A window sitting on this
-card says so on **home**, and the row there carries the same answers the card is
-offering. A repeating check reads `Set it up · `, `Only now, don't repeat`
-and `Don't set it up`. A reminder reads `Remind me ` and `Don't remind me`.
-The card can be **answered or declined** from the dashboard without opening the
-conversation (home's own page has the whole rule). `o Change…` stays here, where
-there is a box to say the new time or place into.
-
-## I don't understand these options. What kind of standing card is this, how do I cancel this card, where did 2 change when or where go
-
-The card in the conversation shows what is being proposed — your own words, when it would
-wake, where it reaches, what it costs. The **question sits above the message box**, where
-every decision on this screen is put, and every answer carries what it costs beside it:
-
-```
-? wants to set up a repeating check
- every Monday at 9, post the standup note from the git log
- Mondays at 9am · about $0.02 a run
- 1 Set it up · Mondays at 9am It repeats on that cadence until you stop it.
- 3 Only now, don't repeat Runs the check one time now. Nothing repeats.
- 0 Don't set it up Nothing is set up, and nothing runs.
- esc later · o Change… · ? clarify
-```
-
-The first line says what kind of thing it is. The second line is what it does.
-The third is when, and what one time costs.
-
-- **A reminder** says `wants to remind you`. `1` is `Remind me ` (reminds you then, nothing repeats), `0` is `Don't remind me`. There is no once.
-- **A repeating check** says `wants to set up a repeating check`. `1` is `Set it up · ` (it repeats until you stop it), `3` is `Only now, don't repeat` (runs once now, nothing repeats), `0` is `Don't set it up`. On a narrow row the cadence drops off the yes before any word is cut. `3` used to say `just once`.
-- **A watch** says `wants to watch for something`. `1` is `Watch for it`, `3` is `Check once now`, `0` is `Don't watch`.
-- **A rule** says `wants to keep a rule`. `1` is `Keep this rule`, `o` is `Change where…`, `0` is `Don't keep it`. There is no once.
-
-`o Change…` turns the box into a place to say the time or the place, and `enter` sends your words back. Nothing is set up until a new card comes with them in it. That is where `2 change when or where` went: it is `o` now, and it does not resolve the card. `0` is how you cancel this card: nothing is set up and nothing runs, and the no is never dropped when the row is narrow. **`esc`** is later. Nothing is decided, the rows come off the screen so you can type, and the question is still counted beside the box.
-
-Each answer is a row of its own and **a click anywhere along it takes that answer**. The
-digit takes it too.
-
-Answering leaves a line where the question was, the kind and the button you pressed,
-and the card in the conversation settles with that button and what it came to:
-`Set it up · Mondays at 9am · set up`.
-
-## How do I decline a standing card or say no to a reminder — 0, esc, or the no on the card
-
-`0` is the way out, and it is one keystroke and **one visible answer** everywhere
-a card like this is drawn: in the conversation, on home's answer row, and in
-home's `ask here` pane. Nothing is created, nothing is run, and the card settles
-as `not set up`. The chip's words are the kind's own no: `Don't set it up`,
-`Don't remind me`, `Don't watch`, or `Don't keep it`.
-
-**`esc` is not the no.** It used to be, in the conversation, and it is *later*
-now: the question folds away, nothing is decided, and the count beside the box
-goes on counting it. That is the same thing `esc` does to every question this
-program asks, and it is why the no had to become something you can see and click.
-
-It is a `0` and not a `4` because the answers are numbered by where they sit.
-`1` is yes and `3` is once, so a fourth digit would move under your hand on a card
-that drew one answer fewer. `0` is off the end of that numbering, on every card,
-always the same answer.
-
-That is the opposite of a task proposal's card, which does count down and starts
-the work on silence. A task is bounded work somebody is watching; a standing
-item spends money on its own, at times nobody chose, so silence may never arm
-one.
-
-What ends a card unanswered is the turn ending — `esc`, or the window closing.
-Then **nothing is set up**, and codeaf is told exactly that:
-`the card was left unanswered — nothing was set up`. That is not a refusal
-anybody made; it is a card nobody reached.
-
-A session nobody is watching — a headless `--once` run, a task, a firing's own
-run — cannot draw the card at all, and answers
-`nobody is here to say yes — this can only be set up in a conversation`.
-
-## Every morning, every Monday — routines
-
-"Every Monday at 9, draft the weekly update from the git log." "Every night, one
-line on what changed in this repo." "At the end of each day, write two lines on
-what I committed."
-
-A rhythm is written as a cron line (`0 9 * * 1`) or as a plain interval (`20m`,
-`2h`) — never shorter than a minute. Whatever the spec says, the card shows you
-the cadence **in words** ("Mondays at 9am"), because a cron line read back is
-something nobody can check.
-
-If codeaf invented a cadence because you did not give one, the card **asks**
-rather than states: "about every 2 minutes — you didn't say, so that's my guess.
-Right?"
-
-## Keep main green — rules you stop thinking about
-
-"Keep main green." "Whenever a PR opens, review it and leave a summary."
-"Whenever I push a branch, run the tests before I open the PR."
-
-A rule's firing is real work: a brief, run headless in this project, in a session
-folder of its own with its own transcript and its own cost row. It runs under
-**the approval rules you have already banked in your profile** — not under a
-repository's own rules, because a checked-in file must not be able to widen what
-runs while you are asleep.
-
-Anything that would have stopped to ask you refuses instead, the run stops, and
-the item is marked as needing you. That is the whole of "unattended": what you
-already allowed is what runs, and nothing else.
-
-One firing's work is bounded at **60 tool calls** unless the card said otherwise,
-and by a quiet per-run backstop. Crossing either cuts the turn where it stands;
-whatever it already did stands with it.
-
-**And a firing that turns out to be wide can split itself.** If the brief you
-banked already names many separate items — "every night, bring the eleven
-adapters up to the new interface" — the firing starts as one worker that is
-allowed to hand the parts out under itself, exactly as a task you typed can (the
-tasks page, *a task that splits itself*). The same tests apply and none is
-skipped because nobody is watching: there has to be a lane free for the parts,
-and any width floor you turned on still counts. A brief that names nothing to
-count never gets the option, and a run that is refused simply carries on as one worker — unless the reading
-finds that what is left is work only a person can do, which stops it and leaves the firing
-needing your look in the morning (the tasks page, *a task that landed needing your look
-without doing anything*). The firing
-does not finish until its parts are home, their work is folded into the one
-account you read in the morning, and everything they spent is on this run's cost
-row.
-
-What a firing gets is the read of its own words and nothing else. There is no
-sizing call before an unattended run: asking a model every night whether a
-sentence that has not changed since yesterday is wide would be a bill you never
-agreed to.
-
-## Always, never, we do it this way — a rule with nothing to wake it
-
-The rules above all have a trigger in them: a PR opening, a push, main going red.
-Plenty of rules have none. "Always run the tests before you tell me it works."
-"Never touch the public API here." "We use tabs." "Prefer small commits."
-
-A rule like that is not checked and never fires — it **holds**. It costs nothing
-and it has no cadence, so its card carries no `when ·` line and no `costs ·`
-line: your sentence and how far it reaches is the whole of it. Instead of waking,
-it rides into the work it governs — every new conversation and every task that
-starts where it reaches opens already knowing it.
-
-On any screen that lists what is standing, one of these says `holds` where a
-watch would say what it last found, and on the home screen it sits after
-everything that has an appointment. The standing-orders page has the rest: how to
-say one, and how codeaf decides an "always" is a rule rather than an instruction
-for the job in hand.
-
-## Tonight — overnight and long-horizon work
-
-"Tonight, run the full suite and the benchmark and have a report for me in the
-morning." "Later when it's idle, look into the flaky test."
-
-`tonight` and `later when idle` are both real shapes. An idle item waits until
-nobody is working anywhere on this machine and nobody has typed for the span you
-named — the machine being quiet, not a clock.
-
-The result is waiting for you — in the chat that asked for it if it is open, and
-otherwise wherever the delivery rules put it (see "Where a reminder arrives").
-Close the lid.
-
-## When will it run next, and how often does it check — every hour, every five minutes
-
-Two different clocks, and mixing them up is why an item can look late when it is not.
-
-**Everything standing is checked every five minutes.** That is the pass: whichever of an
-open codeaf window or this machine's own timer gets there first walks everything you have
-standing and asks which of them the world has something to say about. The two run exactly
-the same work, so it does not matter which one is awake — and with no window open at all,
-the machine's timer still does it (*Does it keep working when I close the terminal or shut
-the laptop?* below).
-
-**An item fires on the cadence you gave it, not on that pass.** "Every hour", "every
-Monday at 9", "at 6 tonight", "whenever these files change" — the pass is only how often
-codeaf looks; your own words are what decides whether looking finds anything. So an hourly
-item is checked twelve times an hour and fires once, and a rule with no cadence at all is
-never woken by the clock — something has to happen first.
-
-**Which means a firing can be up to five minutes late.** A window's first check is a whole
-five minutes after it opened — nothing is checked at the moment you launch — so a reminder
-set for one minute from now arrives on the check after it comes due, not on the second.
-Nothing is checked while the machine is **asleep**, and nothing is checked when **you are
-not logged in**: the timer runs under your own login and is not a system service. The
-installed timer asks the operating system to run **one** check after the machine was
-off, and only while background checks are on — but that catch-up is a request, not a
-guarantee. Nothing runs while the machine is off or asleep, and whether a missed
-interval is run at all, and when, is the operating system's to decide: it can simply
-be skipped. What a pass never does is hand you a burst — when checking resumes, a due
-item is looked at once, not once for every check it slept through. What was actually
-missed is on the item itself: its row says when it last ran.
-
-The card you said yes to states the cadence back to you in words — "Mondays at 9am" — and
-an item's own row says when it last ran. Those two together are the honest answer to "when
-will it run next".
-
-## How far does it reach — just this chat, this project, or everywhere
-
-Everything you set up has one **reach**, and the card names it before you answer:
-
-| Reach | What it governs |
-| --- | --- |
-| this conversation | this chat alone, and it goes when the chat does |
-| this project | every conversation and every task in this project |
-| everywhere | everything you do on this machine |
-
-**Where you said it is the default.** Said in a project, it stands for that
-project — which is what everything set up before reaches were spelled already
-did, so nothing you already have has changed. Said in a chat that belongs to no
-project at all, it stands everywhere.
-
-**Your own words move it.** "Just this chat" and "only here" keep it to the
-conversation; "everywhere", "all my projects" and "on this machine" lift it to
-the whole machine. Nothing is ever widened quietly — the card states the reach,
-and "make it just this project" on the card is a correction like any other.
-
-Two smaller things ride the same card. A short **title** — three or four words,
-like `main stays green` — is what a row too narrow for your sentence falls back
-to; your own sentence is still what every screen leads with, and it is never
-rewritten. And if you say what acting on it may do without asking — "open a pull
-request but never merge it" — that sentence is written down and shown on the
-card. It is a record you can read: what actually bounds a firing today is still
-the shared allowance, quiet backstops and the permissions you have already banked.
-
-## What does it cost — one daily allowance, not money per order
-
-Everything standing shares one machine-wide daily allowance: the same
-`daily_budget_usd` row `/budget` and the settings sheet already write. When that
-setting has a dollar figure, the card says `shares the day's $… allowance`; when
-it is unlimited, the card says `shares the day's allowance` without inventing a
-figure.
-
-There are quiet per-run and per-day backstops inside each item so one noisy order
-cannot consume the pool unchecked, but codeaf does not ask the model to negotiate
-them and does not quote them on an ordinary card. The real protection is the
-shared allowance and the approval rules you already banked. If you said "spend at
-most a dollar" or named a daily firing limit yourself, that is different: the
-card says your limit back exactly, and the item keeps it.
-
-A check that fires nothing still costs the look and the one small judgement call,
-so it shares the allowance even when it has nothing to say.
-
-## How many times did my watch run this week, and what did it spend?
-
-Every firing and every check writes one line to a ledger — one file per local
-day, under the standing folder — and that is where these counts come from.
-
-On **home**, put the cursor on the item. Its card says both figures:
-
-```
-4 runs · spent $0.08
-ran 3 times this week · $0.04
-```
-
-The first line is everything it has ever done. The second is **the last seven
-days**, and a cursor on the whole project sums the same seven days over all of
-its items: `4 runs this week · $0.06`, at the foot of that card's `keeping an
-eye` band.
-
-A **check that found nothing does not count as a run** — it costs the look, so it
-counts in the money and never in the runs, which is the same rule the daily rail
-uses. An item that has done nothing in the last seven days shows **no weekly
-line at all**, and nothing anywhere reads `0 runs this week`.
-
-In a conversation, asking "what do you have standing?" lists what stands here
-with its rails; the standing place, and an item's card on a frame wide enough to
-draw one, are where the counts are.
-
-## Does it keep working when the app is closed or the laptop is shut?
-
-**Yes — background checks are on out of the box, and nobody asks you first.**
-
-While any codeaf window is open, one of them runs the pass every **5 minutes** —
-whichever window takes the lock first; the others do nothing and say nothing. One
-other piece of quiet work rides that same pass: at most once every six hours, and
-only while nobody has typed anywhere for a quarter of an hour, it tidies the notes
-kept across sessions (`what I remember`).
-For "no terminal open at all", the **first thing you ever set up** installs one
-small timer under your own login that runs `codeaf tick` every 5 minutes: a
-launchd agent called `ai.agentfield.codeaf.tick` on a Mac, a systemd user timer
-called `codeaf-tick.timer` on Linux. Nothing else is installed, ever — no
-server, no port, no account, and no separate helper to install.
-
-You are told, once, in one dim line under the card you just said yes to:
-
-```
-checks every 5 minutes, window or not · background checks under /settings
-```
-
-If the install did not take, the line says that instead, with the reason:
-
-```
-could not install the background check · · background checks under /settings
-```
-
-Either way it is said **once, ever**. The fact is written down beside your items
-**before** your machine is touched, so an install that half-worked is still one
-you were told about rather than one you are told about again tomorrow.
-
-**`on` means installed, not that a pass has run.** The row and `/status` read the
-timer's definition on disk; to see it actually waking, `/status` carries
-`last check …`, and `codeaf doctor` prints the `background timer` row and says
-`checks look stalled` when an installed one has stopped waking.
-
-**The limits are real.** Nothing runs while the machine is **asleep**, and nothing
-runs when **you are not logged in**: it is a per-user timer, not a system service.
-The installed timer asks the operating system to run one check after the machine was
-off — and only while background checks are on. That catch-up is requested rather than
-guaranteed: nothing runs while the computer is off or asleep, and whether a missed
-interval is run at all, and when, is the operating system's to decide, so it can
-simply be skipped. A pass never hands you a burst, though: when checking resumes a
-due item is looked at once, not once for every check it slept through. A clock-only
-reminder needs no key to fire; a watch that has to be judged needs one. On any host
-that is neither macOS nor Linux there is no timer to install, so things are checked
-only while a window is open and the settings row is not there at all.
-
-## Will a watch retry if my API key is missing or disconnected
-
-**Yes, the pass still walks.** On a machine that has never been given a key, the
-5-minute pass behaves exactly as it would with one: whichever window or timer takes the
-lock first goes through every reminder, watch and routine you have, and any other that
-wakes at the same moment finds the lock held and leaves without doing anything. What
-changed is that leaving quietly, and walking at all, no longer need a key first — a pass
-that will do nothing costs nothing and needs nothing.
-
-**A reminder at a time still fires.** "Remind me at 6 to leave" is a question about the
-clock and not about the world, so nothing has to be judged: at 6 the line is delivered
-to the window you are sitting in, or waits for you on home.
-
-**A watch that has to judge something stops on its own row.** "Tell me when the build
-goes red" runs its command first — that part costs nothing and needs nobody — and then
-needs a model to say whether what came back means yes. When no key is held for the
-model that item is judged on, its `last look` reads
-
-```
-could not check: no API key: this session has not been given one yet
-```
-
-the walk carries straight on to the next item, and nothing fires, because a firing
-needs a yes and nobody was able to say one. The item still records that it looked, so
-the count of what was examined is honest and the record of the pass carries one error.
-That look queues no inbox note and leaves the item active and due, so it is
-retried next pass rather than lost.
-
-**It is the key for the model that watch judges on, not any connected account.**
-Every judged watch is read by a small, cheap model — the judging model your settings
-choose, falling back to the model codeaf is set up to talk with when nothing is pinned
-(and, for an item you ratified with `--one-model`, the model that item was frozen to).
-A check only runs when the service **that** model lives on holds a credential. So
-reconnect the one that serves your judge model: if your watch is judged on an
-OpenRouter model,
-reconnect **OpenRouter** — `/connect` → **providers**, say "set up my api key", or
-`codeaf connect openrouter`. If it is judged somewhere else, set the model you want
-it judged on and connect the matching service, because connecting something
-unrelated leaves the watch exactly where it was. Once your judge model's own
-service has the key, the next pass judges normally: nothing has to be re-made and
-nothing was lost while there was no key.
-
-## Turn background checks off
-
-`/settings` → **General** → **background reminders**, or just say "turn off the
-background checks" and the chat will do it. It is a two-word row — `on` or
-`off` — and `on` is the default.
-
-- **on** installs the timer named above and things are checked with no window
- open.
-- **off** removes it. Nothing is lost: your reminders, watches and routines are
- all still there, still due, and still checked every 5 minutes by any window you
- have open. What stops is the checking that happens when you have none.
-
-**The row reads your machine, not a file.** It says `on` when the timer's own
-definition is actually on disk, so if something removed the agent by hand the row
-says `off` — it cannot tell you the checks are running when they are not.
-
-`/status` says the same thing in a line: `keeping watch installed`,
-`keeping watch while a window is open`, or
-`keeping watch nothing is checking · background checks are off · /settings`.
-
-## Does codeaf keep running when my terminal is closed — if I close this window does the chat stop, closing the terminal app, is it still running in the other shell
-
-**Yes.** Your conversation does not live inside the terminal you started it in.
-`codeaf chat` runs the conversation in this folder's **engine** — a process with
-no terminal of its own — and the window you type into is a view onto it. Close the
-window, kill the shell, `ctrl+c` out of it: the reply goes on arriving, the tasks
-go on running, and the engine keeps the transcript.
-
-**To get back to it, open codeaf again and press `enter` on that conversation's
-row on home.** It comes up here, mid-reply, in well under a second. The row says
-`open in the engine` when the engine has it with no window anywhere, and
-`another window` when a terminal is sitting in it; either way one `enter` brings
-it here, and the terminal that had it steps back and says so. See
-*Continue a conversation from another terminal* on the home page.
-
-**A plain `codeaf` in that folder sits straight down in it**, with nothing to press
-at all. A launch that names no conversation is asking for this folder's latest,
-and the engine hands back the one it is already holding — mid-reply, with its
-tasks still running. It never opens a second chat on top of one it holds, and
-never says `open in another window` about its own conversation.
-
-The engine lets go on its own when there is nothing left to hold: a conversation
-with no window, no turn and no waiting question is kept for half an hour and then
-closed, and an engine holding nothing at all exits. `codeaf engine --stop` in the
-folder ends it now.
-
-Three launches keep the old arrangement, where the conversation really does live
-in the window and ends with it: `--no-host`, `--debug`, and `--once`.
-
-The other half of running with the terminal closed is the **standing side** —
-reminders, watches, rules, overnight work — and it is separate machinery. Every 5 minutes, terminal closed
-or not, the timer runs `codeaf tick`, which takes a few seconds, does whatever is
-due, and exits. There is no daemon sitting in memory between those moments, and
-closing the terminal app changes nothing about it.
-
-When something fires with nothing open, it waits for you: an item born in an
-ordinary conversation waits in that conversation's own inbox, folded under one
-"while you were away" the next time you open *that* chat; an item born in home's
-`ask here` exchange has no conversation to come back to, so it waits under the
-project — shown on home — and the next ordinary conversation you open there folds
-it in. An ordinary chat's item is not moved into a different, newly opened chat
-of the same project. Nothing reaches your phone, your email, or a notification —
-there is no outward lane at all.
-
-If the program itself moves — you rebuild it somewhere else and delete the old
-one, or an upgrade leaves the old path empty — the timer would be pointing at a
-program that is gone. Every launch checks for exactly that and quietly puts the
-timer back on the program you are actually running. You are not asked and nothing
-is said on screen; one line goes to `v3/standing.log` under your codeaf home.
-
-## I have two copies of codeaf and my reminders fired twice, or stopped firing — which build runs the background checks, and does CODEAF_HOME move the timer
-
-There is one timer per login and it runs one build, so nothing ever fires twice
-from two copies — if a reminder arrived twice, it was not two timers. And if your
-reminders stopped firing after you installed a second codeaf and removed the
-first, the timer was naming a program that is gone; the next launch of any build
-under that home puts it back.
-
-There is **one timer per login**, and it is a pair: the home it checks and the
-program it runs. Its definition carries both — `CODEAF_HOME` and the path to the
-program — and a launch speaks only for its own pair.
-
-- **Two builds on one machine** (a release beside one you built, two versions side
- by side): the timer keeps running whichever build installed it, for as long as
- that program is still there. A launch of the other build leaves it alone and says
- nothing. It steps in only when the program the timer names is **gone**, or the
- definition is not one this build would have written. So the timer never flips
- between builds on every launch, and `/settings` says `on` while it is running the
- other build — something is checking, and that is what the row reads.
-- **To move the timer to the codeaf you are running**, turn **background checks**
- off and on again under `/settings`. That is the one deliberate hand that moves
- it; a launch never does.
-- **`CODEAF_HOME`**: the timer checks the home that turned it on, and `codeaf tick`
- runs with that `CODEAF_HOME` set. If you export it permanently, your background
- checks run against that home and not against `~/.codeaf`. A launch under some
- other `CODEAF_HOME` — a test, a throwaway home — reads the timer as somebody
- else's: it neither claims it nor rewrites it, and its `/status` says nothing is
- checking that home.
-
-Approving the first standing item follows the same ownership rule. It installs a
-missing timer or repairs this home's stale timer, but never takes another home's
-or live build's timer. If another home owns it, the item is saved and codeaf says
-that the background check could not be installed and the existing timer was left
-unchanged. The confirmation also says the item was saved but needs a codeaf window
-open for this home while no background timer checks it. A non-waking permission
-rule does not need a timer. This home is not being checked in the background; use the background
-checks row in `/settings` only when you deliberately want to move the shared timer.
-Ownership checks and changes are serialized across processes, including that
-explicit settings action.
-
-## Do reminders work over --host — yes, on the far machine
-
-Yes, and this is the one ambient thing a connection does not take away. Over
-`codeaf chat --host devbox` the conversation runs on devbox, and so does
-everything you set up from it:
-
-- The `stand` tool is on the belt, so "remind me at 6", "tell me when CI goes
- red" and "every Monday post the standup" all work exactly as they do locally.
-- The card is drawn on **your** screen and answered with **your** keys — the
- proposal crosses the connection like every other event, and `1`, `2`, `3` and
- `0` cross back.
-- The item is created, checked and fired **on devbox**: its store is devbox's,
- the workspace it runs in is devbox's, and the rules a firing runs under are
- devbox's own profile rules, not this laptop's.
-- It keeps working after this window closes **and after the connection drops**.
- Nothing about it needs the terminal you are sitting at.
-- Background checks belong to devbox: the first thing you set up over the
- connection installs the timer **on devbox**, and turning the `background
- checks` row turns devbox's. Neither ever touches this machine.
-
-**Home and the standing place both work over a connection**, and both are about the far
-machine: home's panels are that machine's — its questions, its running work, its
-`standing` — and the
-standing place lists both what stands on this conversation and what stands anywhere else on
-that machine. `p` and `s` write to the far machine's store and the refusal, if the store
-refuses, is that store's own. The count at the foot of the task column — and the
-same words under `watching` on `/status` — says `◦ 2 standing orders` about the
-workspace this window is on, which over `--host` is a path on the far machine.
-
-One live detail is still missing over a connection, and it says nothing rather than
-guessing:
-
-- **`/status` reads devbox's background timer.** Its `keeping watch` line says
- `installed` or `nothing is checking` from the far machine's own scheduler. If
- that engine has no scheduler to ask, the whole line is absent.
-- **No row ever shows the firing mark `◐`.** Nothing on disk says an item is
- firing at this instant, so nothing claims it — the same silence a local window
- keeps.
-
-## How do I stop one? — stop the thing that runs every hour, stop a routine, turn off a recurring check
-
-Say so: "stop the CI one", "stop reminding me about the plants". You can name it
-by its id or by any part of your own sentence — nobody remembers an id.
-
-Stopping is permanent. codeaf answers:
-
-```
-stopped: tell me when CI on main goes red
-It will not fire again. Setting it up afresh is a new card.
-```
-
-If your words match more than one, it will not guess — pausing the wrong watch is
-a silence you would not notice until it mattered — so it answers
-`"CI" matches more than one — say which:` and lists them.
-
-`s` on the `/standing` page does the same thing to the row under the cursor, and
-`n` there is the third answer: keep it, but not in this place. The
-standing-orders page has both.
-
-## How do I pause one and start it again?
-
-"Pause the weekly update for now" and "start the weekly update again". A paused
-item is not checked and not fired, and keeps everything else it knows: what it
-has cost, when it last fired, what it last saw.
-
-To see what stands here, ask what you have set up, or type `/standing` for the page
-of it with `p` and `s` on its rows. Each row leads with the glyph every codeaf
-screen uses — `?` needs you, `◐` being checked or fired right now, `◦` waiting
-for its time, `∙` paused or stopped — then your own words, then the cadence in
-words. A project with nothing set up answers
-`Nothing stands in this project yet.`
-
-Where an order applies — this conversation, this project, or everywhere — and how
-to except one place from it are on the standing-orders page.
-
-## Why does my watch show a filled dot right now
-
-`◐` on an item means a pass has that item in its hands **at this moment** — not
-that something is wrong, and not that it has news for you. It is one of two
-things, and the item's card says which:
-
-```
-◐ checking now · since 4s
-◐ firing now
-```
-
-**Checking** is the look: a probe command running, the files you are watching
-being fingerprinted, the cheap yes/no judgment deciding whether there is
-anything to say. **Firing** is the work that followed a yes — the line being
-delivered, or the overnight job running.
-
-The dot is not guessed from anything the item remembers. While a pass is working
-on an item, whichever process is doing it leaves a small marker in that item's
-own folder — `~/.codeaf/v3/standing//running` — naming the process, the
-moment it started and which of the two halves it is in, and removes it when that
-item's pass ends. Every codeaf screen reads that file. So a firing started by
-the OS timer with no window open at all, or by a window in another terminal,
-still shows `◐` on your home and in your status line.
-
-A marker is always doubted before it is believed. If the process that wrote it
-is gone, or the marker is older than the **2 minutes** one pass may last, it is
-read as a leftover and no dot is drawn — a machine that lost power mid-firing
-never leaves a watch reading "checking now" for the rest of the week.
-
-Most passes are over in far less than a second, so the ordinary state of a
-healthy watch is `◦`. Seeing `◐` means you caught one working. On the status
-line the `◦ N standing orders` segment is still while nothing is running and
-its mark turns while something is.
-
-## Where is the record of a reminder I made from home
-
-If you set it up from the home screen with `ask here`, the short exchange that
-produced it is a real conversation with a real transcript — home just does not
-list it, so finished errands cannot fill up the screen you typed them at.
-
-The moment something stands, that folder is filed **under the thing it made**:
-
-```
-~/.codeaf/v3/standing//exchange/transcript.jsonl
-```
-
-and the pane says `kept · this exchange is filed under it`. The item's own
-record points there, so "why did I get this?" opens the exchange that made it,
-the same way an item made in a conversation opens that conversation. Runs of the
-same item are numbered folders beside it, under
-`~/.codeaf/v3/standing//runs/`.
-
-An errand that came to nothing stays where it was made,
-`~/.codeaf/v3/standing/exchanges//`, until the sweep clears it. See the
-asking-from-home page for the rest of that door.
-
-## What gets cleaned up, and when
-
-Once per launch codeaf sweeps its own folders. On the ambient side it removes
-exactly two things, and only after **7 days** with nothing touching them:
-
-- an **errand that came to nothing** — a folder under
- `~/.codeaf/v3/standing/exchanges/` that never became a standing thing and was
- never continued as a conversation;
-- a **run that came to nothing** — a firing whose work saved no file, left
- nothing waiting for you and had nothing to say when it finished. Nothing is
- delivered for one of those: no line in your chat, no row on home, because the
- whole of the news would have been that there was no news. Each run writes down
- what it came to, and only the word `nothing` may be swept.
-
-Nothing else is ever removed. Not your items, not the ledgers, not a run that
-said something, landed something, failed, or is waiting for you; not a run whose
-folder never said what it came to; and not a folder something still holds open.
-
-**Conversations are swept in exactly one case**, and it is the same week and the
-same judgement: one you started **in a temp directory**, seven days after you
-last said anything to it. Everything it holds goes with it — the transcript, and
-the `work/` workspace if it owned one. If a task of that conversation was still
-running when you last closed it, furrow is told to forget the copy of your
-folder that task was working in, including its timeline, before the files are
-removed. If retirement fails, the conversation and copy stay for a later retry. Every other conversation under
-`~/.codeaf/v3/projects/` stays whatever its age. If you work in a temp directory
-and want to keep what a conversation makes, anchor it with `/workspace `
-or copy the files out; the starting-codeaf page has both under *I deleted my chat
-and lost the files the task made*.
-
-**Sweeping a run removes the folder and nothing else.** The money line stays: the
-day's ledger row for that firing is never reaped, and the item goes on
-remembering that it ran, when, and what it came to — so `4 runs · spent $0.08`
-on its card is still right long after the folders behind those runs are gone.
-
-## A reminder fired but nothing showed up in my chat
-
-It should show up, and in this build it does. A firing that reaches an open chat
-draws its own row there as it arrives:
-`◦ remind me at 6 to leave · said: time to leave`. If you were away, the rows are
-drawn when you open the conversation. "Where a reminder arrives" above has the
-whole order and what each road draws.
-
-If a firing really did leave nothing on your screen, these are the ordinary
-reasons, in the order worth checking:
-
-- **It went to a different chat.** A firing reaches *you*, not one window. If the
- chat that set it up was closed, the line went to another chat of the same
- project that was open — including one in a different pane or a different
- terminal. It is never delivered into the `ask here` pane on home.
-- **Nothing was open when it fired**, so it is waiting, and the rows appear the
- moment you open a conversation in that project. Home's `since you left` panel names
- the firing
- — `fired 3 minutes ago — it told you` — and it does so for a one-off too, which
- is over and off every list by the time you get back.
-- **It has not fired yet.** A window checks its items every five minutes, the
- first check five minutes after it opened, so a one-minute reminder can arrive
- up to five minutes late. The task column's foot (and `/status` under `watching`)
- shows `◦ N standing orders` while something stands.
-- **The check found nothing to say.** A watch or a rule that looked and found
- nothing writes nothing at all — that is the design, not a fault. The item's own
- record on home says when it last ran.
-- **Nothing was ever set up.** A card left unanswered sets nothing up:
- `the card was left unanswered — nothing was set up`.
-
-Ask "what is standing?" in any chat to see the list, when each last ran, and what
-it came to.
-
-## Why did I get this?
-
-Every standing item remembers the conversation that made it, so the answer to
-"why did I get this?" is always a conversation you can open.
-
-If the conversation was open when it fired, the line arrived in it as it
-happened. If it was not, it went to whichever chat of that project you did have
-open; and if none was, the news waited in the inbox of the conversation that made
-it — or, for an item born in home's `ask here`, under the project — and the next
-time you open that conversation, or an ordinary chat of that project for an
-errand, it is folded into **one** note that begins `while you were away` — one
-line per thing, with when, your own words, what happened, and the run folder to
-open for the whole story. Two hours away with nothing to report is nothing at all:
-silence is the design. The four addresses in order are under "Where a reminder
-arrives".
-
-A firing's run folder is a normal session folder outside your projects, so its
-transcript reads with the same tools as any other conversation.
-
-## Why did it say the reminder was never set up after I said just once
-
-The completion check reads a short account of the turn, and a long turn clips
-that account. Your answer on a card this turn is kept on the account anyway,
-after the clip, as one line. For `3 just once` that line is `the person
-answered the card "": only now, don't repeat`. Any
-other card you answered is the same shape, with the words that were on the
-chip. The check is deciding what is still owed. An answer you already gave is
-in front of it, so "nothing was set up" is the choice on the card and not a
-piece of the ask still to do.
-
-## What it will not do
-
-- **It will not set anything up when nobody is watching.** A headless `--once`
- run, a task, and a firing's own session all answer
- `nobody is here to say yes — this can only be set up in a conversation`, and
- the tool is not even on the list there.
-- **Silence arms nothing.** The card waits with no clock on it, and a turn that
- ended with it still up leaves nothing behind:
- `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 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.
-- **It will not fire while the machine is asleep**, and it will not fire when you
- are not logged in. A missed check is caught up once when the machine comes
- back, and the item's own row says when it last ran.
-- **It will not reach your phone.** There is no notification, no email, no
- outward lane at all. News lands in a chat you have open, or waits on home and
- in the next chat you open in that project ("Where a reminder arrives").
-- **It will not spend past the shared allowance or its quiet backstops without asking.**
-- **It cannot do anything unattended that you have not already allowed.** Nobody
- is there to answer a permission card, so the run stops and says so.
-- **A firing cannot set up another standing item.** The tool is absent inside
- 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:`.
-
-## 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 ·