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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/changes/unreleased/1357-plan-digest-on-speak.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ pr: 1357
surface: [chat, engine, docs]
invalidates:
- "Nothing told the conversation what was in flight when a person spoke. A person who said 'skip the migration' while a task was doing the migration was answered, and the task carried on: the chat could have asked with `tasks`, and the turn where somebody changes direction is exactly the turn where nothing suggests asking. While a run is live, each of the person's messages now opens with a bounded digest of the run's rows — id, title, state, newest note — and the plan-road hand-off facts say to act on the row that the person's sentence just made wrong before answering them."
- "The chat-coordination design's channel table names `revise_assignment` as the door that revises a task's brief. It is a WORKER'S verb and never the conversation's (Config.mayRevise is `InTask && tasker != nil && taskID != 0`), so the chat cannot rewrite what a running task was asked for. What it holds over a live run is `tasks` with `stop`, which stopBeltRow and stopJoinedRow resolve to the run's own row or to one hand-off that joined it, and `tasks` with `note`. It has no door at all onto an internal split part such as #1.2. The prompt and the manual say what it can do and not what the design assumed."
- "The chat-coordination design's channel table names `revise_assignment` as the door that revises a task's brief. It is a WORKER'S verb and never the conversation's (Config.mayRevise is `InTask && tasker != nil && taskID != 0`), so the chat cannot rewrite what a running task was asked for. What it holds over a live run is `tasks` with `stop`, which stopBeltRow and stopJoinedRow resolve to the run's own row or to one hand-off that joined it, and `tasks` with `note`. Both of those reach an internal split part such as #1.2 too, by the label the listing and the digest print: `stop` ends it through the store the way the task page's own stop does, and `note` writes onto it. The prompt and the manual say what it can do and not what the design assumed."
---

The row bound is `planDigestRows` in `internal/session/plandigest.go`, with
Expand Down
20 changes: 20 additions & 0 deletions docs/changes/unreleased/1393-chat-manages-a-live-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
kind: fixed
title: the chat's run digest leads with the live run, in the side list's words, on every message
pr: 1393
surface: [chat, engine, docs]
invalidates:
- "The digest in front of the person's sentence took the first eight rows in the order the store read them, oldest ended run first, so a conversation with eight rows of history saw eight `cancelled` rows and none of the live run. It now leads with the live run, and every row says the side list's word (queued, running, done, stopped, incomplete, your call) through `PlanTaskRow.StateWord`, which the `tasks` listing of a run's rows now says too. The store's words (pending, ready, claimed, failed, cancelled, paused) are no longer what the model reads about a row."
- "Only a plain sentence carried the digest. A message with pictures and a draft marked standing now open on it as well, and what a message's words are to the rest of the engine (the recall, the owed answer, the ask a `forward` carries) is the person's sentence, never the digest or the standing instruction in front of it."
- "`tasks` with `say` or `forward` on a row of a run answered `No task \"2\" in this project`. `say` is now written onto the row as a note and its answer says so; `forward` refuses and points at `note`."
- "The `note` field's description and its receipt said `revise_assignment` changes what a task was asked for. The chat never carries that verb; both now say nothing the conversation holds changes a run task's brief, and name `stop` and a fresh hand-off."
- "Saving the `provider` row wrote `lane.talk.borrow`, which no list of consumed keys held, so every later launch warned that it was ignored. It is consumed now, and `TestProfileKeyLedgerLaw` drives every settings row's writer, so a key a writer puts down that nothing registers fails the law."
- "`Assisted-by` named the model the conversation was launched on, even after `/model`. A switch now re-renders the page at the clock stamp it already had, so the line names the live model, and switching back is byte for byte the page the earlier model has cached."
- "#1209 (rolled into v0.4.0 with `invalidates: []`) dropped two things from the answer section without saying so. It cut the ban's examples to `Want me to…` alone — no `Sure`, `Great question`, `You're right` or `Say the word and I'll…` — while keeping both bans; that was the price of its byte budget, and it stands. It also dropped `never hand back half-solved work` from what done means, which was not intended: the page says `never a compiling scaffold, a narrowed test or half-solved work` again, paid for inside the same section. A test that used `Say the word and I'll` as a sentence only the chat's page carries went on passing without testing anything; it now checks its sentinels are still on the page."
- "The manual said a note reaches its worker between steps, after the command it is running. It is handed over as a steer the moment a step ends: a reply being written is cut and asked again, and a foreground command of three seconds or more is moved to the background."
---

The digest's mapping and the side list's are one table by law:
`TestTheRowsWordIsTheRailsWord` reads internal/tui3's `planStateWord` out of
the tree and fails on any word the two say differently, until the side list
calls `StateWord` itself.
22 changes: 14 additions & 8 deletions docs/design/chat-coordination/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Each of these is a missing reader on a channel that is already written to.
| one task's page | store | chat's `tasks #N` (brief, result, checks, last steps) | `planTaskText` |
| **notes** | worker `plandb task note`, engine (`AddNote`) | **screen only** (`internal/tui3` via `PlanTaskPage.Notes`); `planTasksText` does not print them | `internal/session/plandb_tasks.go:672` |
| steer into a running worker | engine only: the same-step sentence, carried by `noteOwed` so a race with the turn's end cannot drop it | the worker's next round | `internal/run/bashworker.go:140-160`, `sameStepNote` |
| revise a task's brief | `revise_assignment`, written through with a version so a stale direction is refused | store; worker on its next read | `planReviseThrough`, `internal/session/plandb_plan.go:842` |
| revise a task's brief | `revise_assignment`, written through with a version so a stale direction is refused. **A worker's verb only** (`Config.mayRevise` is `InTask && tasker != nil && taskID != 0`): the chat never carries it, so the chat has no door that revises a brief | store; worker on its next read | `planReviseThrough`, `internal/session/plandb_plan.go:842` |
| dependencies | `propose_task depends_on` | supervisor; the dependent's brief is given its dependencies' reports | `internal/run` |
| the run's money | `CostUSD` on the run spec; a dollar limit holds while a worker is working (#1268) | supervisor | `internal/session/task_run_belt.go:81` |
| what a check reads | `"Acceptance: " + leaf.Description` plus the task's declared `Checks` | the check worker | `internal/run/run.go:799`, #1220 |
Expand All @@ -62,9 +62,12 @@ The chat's `tasks` tool prints each task's notes: the last one on the row, all
of them (bounded) on the task's page. The data is already in `PlanTaskPage`.

**Who may write.** The person, from the task sheet (exists). The chat, through
`revise_assignment` for a redirection (exists) and through a plain note for
information that is not a redirection (new: a `note` field on the unified door
in change 4, or a small tool until then). Workers, through `plandb task note`
a plain note for information that is not a redirection (new: a `note` field on
the unified door in change 4, or a small tool until then). *Correction,
2026-09-23:* this line first said the chat redirects through
`revise_assignment` "(exists)". It does not exist for the chat — the verb is a
worker's — and the chat's only answer to a row whose work is wrong is `tasks`
with `stop` and a fresh hand-off. Workers, through `plandb task note`
(exists). Sibling workers thereby reach each other, which is the case in
problem 1.

Expand Down Expand Up @@ -120,14 +123,17 @@ without a card. The `needs you` count and the cards' wording are unchanged.

### 4. One door for the chat, light doors for workers

**Today.** Four chat verbs over one store: `propose_task`, `revise_assignment`,
`tasks`, cancel. Workers use `plandb add/split/note/done` through their shell.
**Today.** Three chat verbs over one store: `propose_task`, `tasks` (which reads,
stops and, on the plan road, notes a row) and cancel. `revise_assignment` is a
worker's verb and is never on the chat's belt, so the chat cannot revise a
brief today. Workers use `plandb add/split/note/done` through their shell.

**Change.** A `plan` tool for the chat with actions `add`, `steer`, `note`,
`stop`, `show`. `add` keeps `propose_task`'s five required fields (title,
summary, brief, deliverable, acceptance) and its optional ones; that schema is
what forces a good brief and it stays at the chat's door. `steer` is
`revise_assignment`. `note` is change 1's writer. `show` is `tasks`. The old
what forces a good brief and it stays at the chat's door. `steer` would be
the chat's first door onto `revise_assignment`'s road — a new capability, not
a rename, since the chat holds no such verb today. `note` is change 1's writer. `show` is `tasks`. The old
names remain as aliases for one release and then go, with the manual and
`system.md` updated in the same change.

Expand Down
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,12 @@ var nonSettingProfileFields = []string{
KeyResponseLiftAfter,
KeyResponseLiftCap,
keyModelSources,
// THE TALK LANE'S BORROW FLAG. It is no row of its own — it rides the
// `provider` row's words (`pinned: cloudflare, borrow when slow`) — but
// [WriteLaneRow] writes it on EVERY save of that row, `false` included, and
// [LaneBorrowAt] reads it. Left off this list, every launch after the row
// was saved told the person their profile carried an ignored key.
LaneBorrowKey(LaneSlotTalk),
}

// retiredProfileKeys are top-level config.json keys that a shipped version once
Expand Down
93 changes: 93 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -622,9 +622,21 @@ func loadProfileKeyLedger(t *testing.T) map[string]bool {
// in the ledger. Half (b): every ledger key is either consumed at head or in
// retiredProfileKeys. Adding a settings row forces a ledger line; removing one
// leaves its line behind and turns this red until the key is retired on purpose.
//
// THE WRITER SET IS WHAT THE WRITERS ACTUALLY WRITE, and not only the consumed
// set restated. It used to be derived from [consumedProfileKeys] alone, which
// made half (a) a check of the consumed set against itself: a writer that put
// down a key nothing registered was invisible to it. `lane.talk.borrow` was
// exactly that — [WriteLaneRow] writes it on every save of the `provider` row
// and [LaneBorrowAt] reads it, and because no list held it, every launch after
// the row was saved warned that it was ignored. So every settings row is now
// driven through its own writer in an empty profile ([profileKeysWrittenByRows])
// and every key that lands in the file is held to the same two halves, plus a
// third: a key the product writes must be one the unread check knows.
func TestProfileKeyLedgerLaw(t *testing.T) {
ledger := loadProfileKeyLedger(t)
consumed := consumedProfileKeys(t.TempDir())
written := profileKeysWrittenByRows(t)

for key := range consumed {
if key == "" {
Expand All @@ -634,6 +646,15 @@ func TestProfileKeyLedgerLaw(t *testing.T) {
t.Errorf("writer key %q is not in testdata/profile-keys.ledger; add it", key)
}
}
for key, row := range written {
if !ledger[key] {
t.Errorf("the %q row writes %q, which is not in testdata/profile-keys.ledger; add it", row, key)
}
if !consumed[key] && !retiredProfileKeys[key] {
t.Errorf("the %q row writes %q, which consumedProfileKeys does not hold, so every launch after the row is saved "+
"reports it unread: register it (a settings row, or nonSettingProfileFields when a reader consumes it)", row, key)
}
}
for key := range ledger {
if !consumed[key] && !retiredProfileKeys[key] {
t.Errorf("ledger key %q is neither consumed at head nor in retiredProfileKeys; if its writer was removed, retire the key on purpose", key)
Expand All @@ -649,6 +670,78 @@ func TestProfileKeyLedgerLaw(t *testing.T) {
}
}

// profileKeysWrittenByRows drives every settings row through its own writer, one
// row to one empty profile, and answers every top-level key that landed in the
// file with the row that wrote it.
//
// THE VALUE WRITTEN IS THE ROW'S OWN READING FIRST — what an untouched profile
// shows for it, which every writer must take back, because that is what a
// person saving the row unchanged sends. A row that refuses it is offered the
// few other shapes a row takes (blank, its choices, on and off, a small count),
// and a row that takes none of them fails here rather than being skipped: a row
// this law cannot write is a row whose keys it cannot see.
//
// Only one thing is kept out of it, and by the property rather than by name:
// a row pinned by an environment variable is unpinned for the drive, since a
// 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 == "" {
continue
}
if listed.Env != "" {
t.Setenv(listed.Env, "")
}
dir := t.TempDir()
row := mustRow(t, registry(t, dir), listed.Key)
candidates := append([]string{row.Value(), ""}, row.Choices...)
candidates = append(candidates, "on", "off", "auto", "1", "10", "none")
accepted := false
for _, value := range candidates {
if row.Apply(value) == nil {
accepted = true
break
}
}
if !accepted {
if name, pinned := row.PinnedBy(); pinned {
t.Logf("the %q row is pinned by %s and cannot be driven here", row.Key, name)
continue
}
t.Errorf("the %q row refused every value this law offers it (%q), so the keys its writer puts down are "+
"invisible here: add a value it takes", row.Key, candidates)
continue
}
data, err := os.ReadFile(BudgetConfigPath(dir))
if os.IsNotExist(err) {
// A row that keeps its value somewhere else — the chat prefs, a
// callback the surface owns — writes nothing to this file.
continue
}
if err != nil {
t.Fatalf("read what the %q row wrote: %v", row.Key, err)
}
var values map[string]json.RawMessage
if err := json.Unmarshal(data, &values); err != nil {
t.Fatalf("the %q row left a profile that is not a JSON object: %v", row.Key, err)
}
for key := range values {
if _, seen := written[key]; !seen {
written[key] = row.Key
}
}
}
if len(written) == 0 {
t.Fatal("no settings row wrote any key, so this law would pass on nothing")
}
return written
}

// TestRetiredProfileKeysAreNotReportedUnread pins that a profile carrying a
// retired key is silent, and that a genuinely unknown key is still named.
func TestRetiredProfileKeysAreNotReportedUnread(t *testing.T) {
Expand Down
1 change: 1 addition & 0 deletions internal/config/testdata/profile-keys.ledger
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ google_oauth_secret
history.enabled
lane.guard
lane.talk
lane.talk.borrow
linear_mode
memory.consolidation
memory.enabled
Expand Down
4 changes: 3 additions & 1 deletion internal/manual/chat/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -945,7 +945,9 @@ Co-Authored-By: CodeAF <267109073+agentfield-bot@users.noreply.github.com>
```

The parenthesised id on the first line is the model that wrote the commit —
the session's configured model, whatever it is that day.
the model the conversation is talking to at that moment. Switch with `/model`
and the next commit names the model you switched to, not the one the
conversation started on.

**A pull request or an issue** ends its body with a line holding an em dash, and
then one sentence:
Expand Down
Loading
Loading