commit f6d23a783ce042cf2604e509228742b408d71994
Author: Jesse Gall <dev@jessegall.nl>
Date:   Tue Oct 6 16:12:21 2026 +0200

    2.253.0: to-dos handed to a helper are its alone; the review findings on pull request 8 are fixed
    
    Journal: todos done 3024,3025,3026,3027,3028,3029,3030,3031,3032,3033,3034,3035

diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index a8fee3524..b0afd66c9 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -4,6 +4,17 @@ Newest first. Each entry is what changed, what it makes possible, and what to do
 `journal upgrade` prints the entries since the version you had; a session started on a
 newer version than the last one it saw is handed the same.
 
+## 2.253.0 — to-dos handed to a helper are its alone
+
+**A helper can be handed rows of your list.** `journal helper dispatch … --todos 3001,3002` gives those to-dos to the
+helper, and its kickoff names them. Nobody else may start, close, strike or reassign them; you still can, and
+`journal helper stop <n>` gives them back. The helper marks one with `journal helper done <n> --how "<what landed>"`,
+and with a worktree the row shows as done, waiting for its merge: it sits in the board's Done lane and closes by itself
+when `journal worktree take` lands the work. Pulling it back out of Done unmarks it. Stopping the helper, or its turn
+ending in an error, gives back the rows it has not marked; dropping its worktree untaken gives back the rows that
+waited for it, and finishing the helper gives back the rest. `journal todo assign <n> --to` hands a row to one agent
+and `journal todo unassign <n>` gives it back.
+
 ## 2.252.0 — the overnight refactor lands, and the phone tunnel keeps itself up
 
 **This release brings the overnight refactor into main**: everything listed under 2.251.0 and 2.250.0 below, released
diff --git a/src/controllers/todos.py b/src/controllers/todos.py
index 7eae1e05a..6d5aa6eb2 100644
--- a/src/controllers/todos.py
+++ b/src/controllers/todos.py
@@ -20,10 +20,23 @@ class Todos(Prioritised, Controller):
     resource = types.Todo
 
     @action
-    def assign(self, n: int, to: str = "", off: bool = False):
-        if not to and not off:
-            raise Refused("assign names an agent with --to, or --off to put the row back")
-        return self.update(n, assigned="" if off else to)
+    def assign(self, n: int, to: str):
+        return self.update(n, assigned=to, pending=None)
+
+    @action
+    def unassign(self, n: int, **data):
+        return self.update(n, assigned="", pending=None, **data)
+
+    @action
+    def complete(self, n: int, how: str = "", **data):
+        return super().complete(n, how, **{"pending": None, **data})
+
+    @action
+    def reopen(self, n: int, why: str):
+        row = self.load(n)
+        if row.pending and not row.completed:
+            return self.update(n, pending=None)
+        return super().reopen(n, why)
 
     @action
     def task(self, agent: str, title: str, brief: str = ""):
diff --git a/src/features/agent_sessions/handlers.py b/src/features/agent_sessions/handlers.py
index b6efdf9e6..43316c05e 100644
--- a/src/features/agent_sessions/handlers.py
+++ b/src/features/agent_sessions/handlers.py
@@ -128,7 +128,7 @@ class ClearLapsedAssignments(Handler):
             who = t.assigned
             if not who or who not in subagents or time.time() - float(subagents[who].active) < limit:
                 continue
-            todos.update(t.n, assigned="", lapsed=who)
+            todos.unassign(t.n, lapsed=who)
             dispatcher = context.journal.get(Agents).by_session(subagents[who].dispatcher)
             context.speaking_to(dispatcher).agent.say("lapsed", who=who, n=t.n, minutes=limit // MINUTE)
 
diff --git a/src/features/helper_worktrees/controller.py b/src/features/helper_worktrees/controller.py
index 3977bcdd3..f6ed32603 100644
--- a/src/features/helper_worktrees/controller.py
+++ b/src/features/helper_worktrees/controller.py
@@ -112,10 +112,13 @@ class Worktrees(Controller):
 
     def _land(self, row) -> list[int]:
         listed = Todos(self.record, actor=SYSTEM)
-        pending = [t for t in listed.rows.standing() if t.pending and t.pending.get("worktree") == str(row.n)]
-        for todo in pending:
-            listed.complete(todo.n, f"{todo.pending['how']} (taken from {row.branch})", assigned="", pending=None)
-        return [t.n for t in pending]
+        waiting = self._waiting(row)
+        for todo in waiting:
+            listed.complete(todo.n, f"{todo.merge_wait.how} (taken from {row.branch})")
+        return [t.n for t in waiting]
+
+    def _waiting(self, row) -> list:
+        return [t for t in Todos(self.record, actor=SYSTEM).rows.standing() if t.merge_wait.worktree == str(row.n)]
 
     @action(network=True)
     def complete(self, n: int, how: str = "", **data):
@@ -131,6 +134,9 @@ class Worktrees(Controller):
         if row.branch and present(project, f"refs/heads/{row.branch}"):
             git(project, "update-ref", f"{KEPT}/{row.title}", f"refs/heads/{row.branch}")
             git(project, "branch", "-D", row.branch)
+        listed = Todos(self.record, actor=SYSTEM)
+        for todo in self._waiting(row):
+            listed.unassign(todo.n)
         return super().complete(n, how or f"dropped; its last commit is kept at {KEPT}/{row.title}", **data)
 
     def _drift(self, row) -> Drift:
diff --git a/src/features/helpers/controller.py b/src/features/helpers/controller.py
index 939bbfb8b..b2afc5f8a 100644
--- a/src/features/helpers/controller.py
+++ b/src/features/helpers/controller.py
@@ -1,4 +1,3 @@
-import time
 from pathlib import Path
 
 import controllers.types as types_module
@@ -11,12 +10,12 @@ from features.agent_sessions.launch import launched, prepared, tell_in
 from features.helper_worktrees.controller import Worktrees
 from features.helpers.resource import Helper
 from resources.base import AGENT, SYSTEM, USER, Refused, titled
-from resources.types import HELPER
+from resources.types import HELPER, MergeWait, Todo
 from controllers.marks import action
 from engine.wording import slugged
 
 
-def kickoff(row, folder: Path, todo: int, handed: list) -> str:
+def kickoff(row, folder: Path, todo: int, handed: list[Todo]) -> str:
     return (f"You are {row.name}, a helper dispatched for one bounded job. The job: {row.title}\n\n{row.brief}\n\n"
             f"It is to-do {todo} on your own list: take it with journal todo start {todo}, keep its work log as you go, "
             f"and close it with journal todo done {todo} --how \"<what landed>\" before you report. "
@@ -27,7 +26,25 @@ def kickoff(row, folder: Path, todo: int, handed: list) -> str:
             f"that is the only way your answer reaches the agent that dispatched you.")
 
 
-def handed_over(todos: list) -> str:
+def held(record, helper) -> list[Todo]:
+    return [t for t in Todos(record, actor=SYSTEM).rows.standing() if t.assigned == helper.ref]
+
+
+def unmarked(record, helper) -> list[Todo]:
+    return [t for t in held(record, helper) if not t.pending]
+
+
+def give_back(record, todos: list[Todo]) -> None:
+    listed = Todos(record, actor=SYSTEM)
+    for todo in todos:
+        listed.unassign(todo.n)
+
+
+def given_back(todos: list[Todo]) -> str:
+    return f"; given back: to-do {', '.join(str(t.n) for t in todos)}" if todos else ""
+
+
+def handed_over(todos: list[Todo]) -> str:
     if not todos:
         return ""
     listed = "\n".join(f"- to-do {t.n}: {t.title}" + (f" ({t.brief})" if t.brief else "") for t in todos)
@@ -42,11 +59,11 @@ class Helpers(Controller):
     @action(network=True)
     def dispatch(self, name: str, job: str, provider: str = "", model: str = "", brief: str = "", worktree: bool = False, checkout: str = "",
                  todos: str = "") -> str:
-        row = self._dispatched(name, job, provider, model, brief, worktree, checkout, todos)
+        row = self._dispatched(name, job, provider, model, brief, worktree, checkout, tuple(int(n) for n in str(todos).replace(",", " ").split()))
         return f"helper {row.n}, {name}, started on {provider} {model}; you are told when it reports"
 
     def _dispatched(self, name: str, job: str, provider: str, model: str, brief: str = "", worktree: bool = False, checkout: str = "",
-                    todos: str = ""):
+                    todos: tuple[int, ...] = ()):
         from providers import DRIVERS, PROVIDERS
         if provider not in DRIVERS:
             raise Refused(f"a helper runs on one of {', '.join(DRIVERS)}, not {provider!r}")
@@ -57,7 +74,7 @@ class Helpers(Controller):
             raise Refused(f"{provider} does not offer {model}; choose one of {', '.join(offered.models())}")
         if worktree and checkout:
             raise Refused("a helper works either in a worktree of its own or in a checkout you name: give --worktree or --checkout, not both")
-        handed = self._handable([int(n) for n in str(todos).replace(",", " ").split()])
+        handed = self._handable(todos)
         project = self.record.root.resolve().parent
         folder = self._checkout(project, checkout) if checkout else project
         slug = slugged(name, limit=30)
@@ -75,13 +92,13 @@ class Helpers(Controller):
         driver = DRIVERS[provider]
         home = prepared(self.record, place, f"Where helper {row.name} works on {job}", row.ref, folder)
         todo = Todos(home, actor=SYSTEM).create(job, brief=brief)
+        launched(self.record, place, provider, driver.prompted(["--model", model], kickoff(row, folder, todo.n, handed)), folder)
         listed = Todos(self.record, actor=SYSTEM)
         for given in handed:
             listed.assign(given.n, to=row.ref)
-        launched(self.record, place, provider, driver.prompted(["--model", model], kickoff(row, folder, todo.n, handed)), folder)
         return row
 
-    def _handable(self, numbers: list[int]) -> list:
+    def _handable(self, numbers: tuple[int, ...]) -> list[Todo]:
         listed = Todos(self.record, actor=SYSTEM)
         rows = [listed.load(n) for n in numbers]
         for row in rows:
@@ -91,18 +108,6 @@ class Helpers(Controller):
                 raise Refused(f"todo {row.n} is already assigned to {row.assigned}")
         return rows
 
-    def _held(self, row) -> list:
-        return [t for t in Todos(self.record, actor=SYSTEM).rows.standing() if t.assigned == row.ref]
-
-    def _unmarked(self, row) -> list:
-        return [t for t in self._held(row) if not t.pending]
-
-    def _give_back(self, todos: list) -> str:
-        listed = Todos(self.record, actor=SYSTEM)
-        for todo in todos:
-            listed.update(todo.n, assigned="", pending=None)
-        return f"; given back: to-do {', '.join(str(t.n) for t in todos)}" if todos else ""
-
     @action
     def done(self, todo: int, how: str) -> str:
         place = self._helping()
@@ -117,7 +122,7 @@ class Helpers(Controller):
         if not helper.worktree:
             listed.complete(row.n, how)
             return f"todo {row.n} is done"
-        listed.update(row.n, pending={"how": how, "worktree": helper.worktree, "at": time.time()})
+        listed.update(row.n, pending=MergeWait(how, helper.worktree).to_json())
         return f"todo {row.n} shows as done; it closes once your work is taken"
 
     @staticmethod
@@ -148,10 +153,12 @@ class Helpers(Controller):
         place = self._helping()
         text = f"My turn ended in an error, so I stopped: {failure}"
         helper = self._helper(place) if place else None
-        if not helper or helper.report.startswith(text):
+        if not helper or helper.report == text:
             return
-        dispatcher = Helpers(Record(self.record.root, place.launched_from), actor=SYSTEM)
-        self._told(place, f"{text}{dispatcher._give_back(dispatcher._unmarked(helper))}")
+        home = Record(self.record.root, place.launched_from)
+        rows = unmarked(home, helper)
+        give_back(home, rows)
+        self._told(place, text, given_back(rows))
 
     def _helping(self):
         place = Environments(self.record, actor=SYSTEM).rows.by_title(self.record.env)
@@ -160,10 +167,10 @@ class Helpers(Controller):
     def _helper(self, place) -> Helper:
         return Helpers(Record(self.record.root, place.launched_from), actor=SYSTEM).load(place.owned_by(HELPER))
 
-    def _told(self, place, text: str) -> None:
+    def _told(self, place, text: str, added: str = "") -> None:
         home = Record(self.record.root, place.launched_from)
         row = Helpers(home, actor=SYSTEM).update(self._helper(place).n, report=text)
-        told = Messages(home, actor=AGENT).create(titled(text), brief=text, peer=row.name)
+        told = Messages(home, actor=AGENT).create(titled(text), brief=f"{text}{added}", peer=row.name)
         Nudges(home, actor=SYSTEM).to_primary(titled(f"helper {row.n}, {row.name}, reported in message {told.n}"),
                                                f"read it, then journal helper finish {row.n} once its work is taken or dropped")
 
@@ -176,7 +183,10 @@ class Helpers(Controller):
         place = places.rows.by_title(row.environment)
         if not place:
             raise Refused(f"helper {n}, {row.name}, has no environment left to stop")
-        return f"{places.stop(place.n)}{self._give_back(self._unmarked(row))}"
+        rows = unmarked(self.record, row)
+        stopped = places.stop(place.n)
+        give_back(self.record, rows)
+        return f"{stopped}{given_back(rows)}"
 
     @action(network=True)
     def complete(self, n: int, how: str = "", **data):
@@ -190,8 +200,10 @@ class Helpers(Controller):
         cut = Worktrees(self.record, actor=SYSTEM)
         if row.worktree and not cut.load(int(row.worktree)).completed:
             cut.complete(int(row.worktree))
-        self._give_back(self._held(row))
-        return super().complete(n, how or "finished; its environment is packed away", **data)
+        rows = held(self.record, row)
+        finished = super().complete(n, f"{how or 'finished; its environment is packed away'}{given_back(rows)}", **data)
+        give_back(self.record, rows)
+        return finished
 
 
 resources_module.register(Helper)
diff --git a/src/features/helpers/feature.py b/src/features/helpers/feature.py
index 6e485a29b..ed54d089e 100644
--- a/src/features/helpers/feature.py
+++ b/src/features/helpers/feature.py
@@ -2,7 +2,7 @@ from features.base import Feature
 from features.helpers.controller import Helpers
 from features.helpers.details import HelpersDetails
 from features.helpers.handlers import TellAFailedTurnOnChange
-from features.helpers.interceptors import OnlyItsHelperClosesARow
+from features.helpers.interceptors import HandedRowsCloseOnlyThroughTheirHelper, HandedRowsStayAssigned
 from features.journal import Journal
 
 __all__ = ["Helpers"]
@@ -13,4 +13,5 @@ class HelpersFeature(Feature):
 
     def register(self, journal: Journal) -> None:
         journal.events.handler(TellAFailedTurnOnChange())
-        journal.commands.intercept("todo.complete", OnlyItsHelperClosesARow())
+        journal.commands.intercept("todo.complete", HandedRowsCloseOnlyThroughTheirHelper())
+        journal.commands.intercept("todo.update", HandedRowsStayAssigned())
diff --git a/src/features/helpers/interceptors.py b/src/features/helpers/interceptors.py
index 8450b352d..d98317e98 100644
--- a/src/features/helpers/interceptors.py
+++ b/src/features/helpers/interceptors.py
@@ -1,12 +1,23 @@
+from features.helpers.resource import held_by_helper
 from features.parts import ActionInterceptor, Context
 from resources.base import AGENT, Ref
 
 
-class OnlyItsHelperClosesARow(ActionInterceptor):
-    def intercept(self, feature_context: Context, controller, n: int, how: str = "", **data) -> None:
-        held = controller.load(n).assigned
-        if controller.actor != AGENT or not held.startswith("helper:"):
-            return None
-        helper = Ref.parse(held)
-        controller._refuse(f"todo {n} is handed to {helper.spoken}: only it marks it done, and taking its work closes it; "
-                           f"journal helper stop {helper.n} gives it back")
+def keep_with_its_helper(controller, n: int) -> None:
+    row = controller.load(n)
+    if controller.actor != AGENT or not held_by_helper(row):
+        return
+    helper = Ref.parse(row.assigned)
+    controller._refuse(f"todo {n} is handed to {helper.spoken}: only it marks it done, and taking its work closes it; "
+                       f"to close, strike or reassign it yourself, journal helper stop {helper.n} gives it back first")
+
+
+class HandedRowsCloseOnlyThroughTheirHelper(ActionInterceptor):
+    def intercept(self, feature_context: Context, controller, n: int, **args) -> None:
+        keep_with_its_helper(controller, n)
+
+
+class HandedRowsStayAssigned(ActionInterceptor):
+    def intercept(self, feature_context: Context, controller, n: int, **args) -> None:
+        if "assigned" in args:
+            keep_with_its_helper(controller, n)
diff --git a/src/features/helpers/resource.py b/src/features/helpers/resource.py
index 1d48f785e..8826cb276 100644
--- a/src/features/helpers/resource.py
+++ b/src/features/helpers/resource.py
@@ -29,3 +29,7 @@ class Helper(Shape, Resource):
     subagent_writable = False
     takes_comments = False
     command_names = {"complete": "finish"}
+
+
+def held_by_helper(todo) -> bool:
+    return todo.assigned.startswith(f"{HELPER}:")
diff --git a/src/features/kanban/lanes.py b/src/features/kanban/lanes.py
index 16c62ec86..f990739c4 100644
--- a/src/features/kanban/lanes.py
+++ b/src/features/kanban/lanes.py
@@ -3,6 +3,7 @@ from dataclasses import dataclass, field
 from features.plans.resource import Placement
 from resources.base import Ref
 from features.kanban.shapes import Lane
+from features.helpers.resource import held_by_helper
 
 TODO, HELD, DOING, ASKED, DONE = "todo", "held", "doing", "asked", "done"
 
@@ -37,10 +38,6 @@ def lane_of(sources: Sources, todo) -> str:
     return TODO
 
 
-def held_by_helper(todo) -> bool:
-    return todo.assigned.startswith("helper:")
-
-
 def reason_of(sources: Sources, todo) -> str:
     if todo.pending:
         return f"done by {Ref.parse(todo.assigned).spoken}, waits for its merge"
diff --git a/src/resources/types.py b/src/resources/types.py
index 9c200fd2f..3e8743bf2 100644
--- a/src/resources/types.py
+++ b/src/resources/types.py
@@ -1,7 +1,10 @@
 import time
+from dataclasses import asdict, dataclass
 from pathlib import Path
 from typing import ClassVar
 
+from engine.fields import Loaded
+
 from resources.base import AGENT, CLOSED, COMMISSIONED, COMPLETED, DOCUMENT, LAZY, OPEN, OPENED, PROJECT, REQUESTED, RESULTS, REVISED, SIDEBAR, SYSTEM, UNLISTED, UPDATES, USER, Pruned, Resource, ResourceDetails
 from resources.shapes import FLAG, NUMBER, TEXT, Field, Options, Placed, Ranked, Reasoned, Shape, Traced, rows
 
@@ -35,6 +38,15 @@ class Message(Shape, Resource):
     command_names = {"complete": "processed"}
 
 
+@dataclass(frozen=True)
+class MergeWait(Loaded):
+    how: str = ""
+    worktree: str = ""
+
+    def to_json(self) -> dict:
+        return asdict(self)
+
+
 class Todo(Ranked, Placed, Resource):
     data_fields: ClassVar[list[Field]] = [
.....................................................................    [100%]
69 passed in 52.96s
