@dumbmice/pi-todo-loop 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -7,21 +7,22 @@ does and how to install it, read [README.md](README.md).
7
7
 
8
8
  ```
9
9
  src/index.ts pi wiring: the context hook, the turn_end hook, /todoloop
10
- src/loop.ts the pure decision policy (rounds, strikes, anchor reset)
10
+ src/loop.ts the pure decision policy (rounds, stalls, hint, anchor reset)
11
11
  src/state.ts reads rpiv-todo state off the session branch
12
- src/inject.ts renders the per-request <todo_loop_state> block
13
12
  src/config.ts config load and coercion, defaults on any failure
14
- test/loop.test.ts decision policy: caps, strikes, anchor reset, error outcomes
13
+ test/loop.test.ts decision policy: cap, stalls, hint, anchor reset, error outcomes
15
14
  test/state.test.ts branch reader: both shapes, recency preference, malformed input
16
- test/inject.test.ts renderer and config coercion
15
+ test/config.test.ts config coercion: clamps, garbage, below-1 values
17
16
  test/index.test.ts the pi-facing half through a stub `pi`: tail handling, kill switch, caps
18
17
  test/state-machine.test.ts the counters as a table of event sequences (the suite to extend)
19
18
  test/helpers/harness.ts the stub `pi`, mutable branch and scripted context those two share
20
19
  test/fixture.test.ts the same hooks over real captured lines (test/fixtures/branch.jsonl)
21
20
  test/docs.test.ts pins the version table, invariant map, reason table, config table, links
22
21
  scripts/check-pack.mjs asserts the published tarball contains what package.json promises
22
+ scripts/pack-json.mjs reads `npm pack --json` in both shapes (array, object)
23
+ test/pack-json.test.ts pins both shapes
23
24
  docs/pi-api-alignment.md every deliberate workaround over pi's documented extension API
24
- docs/behaviour.md how it behaves, precisely: markers, counters, the block, cost
25
+ docs/behaviour.md how it behaves, precisely: markers, counters, stalls, the block tool, cost
25
26
  README.md user-facing documentation (what it is, install, config, troubleshooting)
26
27
  AGENTS.md this file
27
28
  CONTRIBUTING.md four-line pointer to this file (GitHub convention)
@@ -64,45 +65,51 @@ These are the invariants the suite protects. Changing one means changing its tes
64
65
  README text, the matching `docs/pi-api-alignment.md` item (or marking that item inapplicable), and
65
66
  adding a `CHANGELOG.md` line — all in the same commit.
66
67
 
67
- - **Merge only into a `user` or `custom` tail.** pi rebuilds `bashExecution`/`branchSummary`/
68
- `compactionSummary` content from their own fields and ignores `content`, so merging into those would
69
- delete the block; they get a separate message and a documented adjacency (register item 15).
70
68
  - **A `todo`-only clear is announced, not policed.** The loop's success and failure states are the same
71
69
  observable; a warning is the most it may do about it.
72
- - **Task text is model-authored, so it is sanitised, not interpolated.** `sanitizeLabel` collapses
73
- flattens every Unicode line terminator, escapes `<`, drops controls/bidi/unpaired surrogates and
74
- truncates by code point, because a subject can come from a fetched page and is rendered at the end of
75
- a request inside a tag. **`status` is task-authored too** and gets the same treatment. Never render a
76
- task field raw.
77
- - **A marker is kept only where it separates two assistant turns.** There it is the alternation glue;
78
- removing it exposes consecutive assistant messages that alternation-requiring providers reject. Any
79
- other marker — at the tail, or followed by a human message — is dropped, because it would duplicate a
80
- real user turn. The test `no returned request ever contains two adjacent user-role messages` is the
81
- general statement; if you change the filter, run it first.
82
- - **The marker body is constant** (`<todo_loop_continue/>`). It carries no round counter, because a
83
- persisted marker must not claim the loop is on round `n` long after that chain ended. Counters live in
84
- memory and are reported by `/todoloop`.
85
- - **The injection is conditional; the marker is not.** The block is appended only when `injectState` is
86
- on and tasks are open. The non-assistant constraint is pi's `turn_end` projection check, not the
87
- request (register item 3) — do not re-derive a coupling between the two.
70
+ - **The extension never writes task text into a request.** The list reaches the model only through
71
+ the `todo` tool's own results. Nothing here renders a subject, a status or an id, so there is no
72
+ escaping to get wrong and no way for a task subject to forge an instruction. Keep it that way: a
73
+ future "show the list in the prompt" feature reintroduces the whole sanitiser.
74
+ - **The `context` hook may remove a marker and must never add anything.** A marker is dropped when
75
+ either non-marker neighbour is user-role (next to a human message, or in a run), because there it
76
+ would project to `user → user`. A marker at the tail is the nudge and always stays: dropping it
77
+ would leave the continuation ending on an assistant turn, which pi refuses to continue from. The
78
+ test `no returned request ever contains two adjacent user-role messages` is the general statement;
79
+ if you change the filter, run it first.
80
+ - **The marker body is one of two constants** — `MARKER_BODY` (plain) and `MARKER_BODY_HINT` (plain
81
+ plus the `todo_loop_block` clause), both in `src/index.ts`. A third variant is a design decision, not
82
+ a convenience. Neither carries a round counter, because a persisted marker must not claim the loop is
83
+ on round `n` long after that chain ended. Counters live in memory and are reported by `/todoloop`.
84
+ - **The only *variable* agent-facing text is the nudge.** One of two fixed sentences, appended once
85
+ per continuation. No state block, no reminder, no per-request rewrite: the list is the agent's to
86
+ read with the todo tool. The tool declaration (`todo_loop_block`) is constant and carries no task
87
+ state, but it is in every request — count it in cost claims: the declaration plus the one-line
88
+ `<tools>` snippet and the `<rules>` guideline it earns (a few hundred characters together). It
89
+ declares a `promptSnippet` and a guideline that names it, because pi only shows a tool in the
90
+ prompt's `<tools>` list when a snippet exists; without one the exit is in the tool array but absent
91
+ from that summary. The non-assistant constraint is pi's `turn_end` projection check, not the request (register
92
+ item 3) — do not re-derive a coupling between the two.
88
93
  - **One entry per continuation is a floor, not a choice.** pi has no `resume()` that appends nothing.
89
94
  Do not try to reach zero entries; keep the single `custom_message` with `display: false`.
90
95
  - **The marker `customType` is a stable literal.** The tail check must keep recognising markers written
91
96
  by earlier builds; do not derive it from the package name or version.
92
- - **Task state is read from the branch on every request.** No in-memory cache of the list, no
97
+ - **Task state is read from the branch on every turn.** No in-memory cache of the list, no
93
98
  `session_compact` resync handler: memory would drift across forks and compaction. Loop state
94
99
  (counters, anchor, pause) *is* in memory, and a restart necessarily starts a new budget.
95
100
  - **The decision is a pure function.** `decide()` in `src/loop.ts` takes a value and returns a value.
96
101
  It must stay free of pi, filesystem and time so the caps can be tested exhaustively.
97
102
  - **Only a settle spends a round.** `turn_end` fires for every assistant response, including one that
98
103
  ended in a tool call; those already cause pi's next request, so the hook returns before deciding. A
99
- round, a marker and a strike are therefore always about a turn that would have ended the run.
100
- - **A tool turn clears a strike and spends no round** — except a tool turn that ends `error`/`aborted`,
101
- which records the stop and leaves the strike budget alone. Tool turns already cause pi's next request, so
102
- they must not consume the budget; but they are work, so they reset `strikes`. Without that, a model
103
- that runs tools and never updates the list collects two strikes across its settles and stops claiming
104
- "no progress" while it was working.
105
- - **A stop keeps the counters; only a new human message resets them.** Clearing `rounds` / `strikes` on
104
+ round, a marker and a stall are therefore always about a turn that would have ended the run.
105
+ - **A tool turn clears the stall and spends no round** — except a tool turn that ends `error`/`aborted`,
106
+ which records the stop and leaves the counter alone. Tool turns already cause pi's next request, so
107
+ they must not consume a round; but they are work, so they reset `stalled` and re-arm the block hint.
108
+ - **Nothing stops the loop for being quiet.** There is no strike limit: `stalled` decides only when the
109
+ nudge offers `todo_loop_block`, once per stall, and tool work re-arms it. A blocked agent and a lazy
110
+ one are indistinguishable from here, so the agent is asked rather than judged; the only stops are a
111
+ clear list, the round cap, an error, and the human's own `todo_loop_block` call.
112
+ - **A stop keeps the counters; only a new human message resets them.** Clearing `rounds` / `stalled` on
106
113
  stop re-arms a full budget on the next `turn_end` under the same anchor (another extension's
107
114
  continuation, a provider retry). The anchor comparison is the only *automatic* reset;
108
115
  `/todoloop reset` and process start clear the counters explicitly. A queued human message is a
@@ -120,7 +127,9 @@ adding a `CHANGELOG.md` line — all in the same commit.
120
127
  continues, but the loop would then stop silently — inert is better than loud.
121
128
  - **Nothing is written.** No files, no network, no process execution. The only reads are the optional
122
129
  config, the two kill-switch paths and the session branch.
123
- - **The kill switch is checked in both hooks.** Disabling takes effect on the next request, without
130
+ - **The kill switch is checked in both hooks and in `todo_loop_block`'s `execute`.** It cannot remove
131
+ the tool from the prompt: pi builds `<tools>`/`<rules>` from the registration, so a disabled session
132
+ still advertises an exit that will be refused. Disabling takes effect on the next request, without
124
133
  `/reload`.
125
134
  - **No pi type import; `pi: any` at the registration boundary.** Contexts the file reads are typed
126
135
  structurally (`SessionContext`). Adding the pi types would make a dependency out of a package that
@@ -135,9 +144,9 @@ introduces an edge.
135
144
  |---|---|
136
145
  | continues only while items are open | `continues while work is open and the turn did work`, `stops when the list is clear`, `stops when there is no list at all` |
137
146
  | error and aborted outcomes never continue | `never continues on an error or aborted turn` |
138
- | a no-progress turn spends a strike, the second stops | `a text-only turn that changes nothing spends a strike, then stops` |
139
- | the first sighting of a list is progress, not a strike | `the first sighting of a list counts as progress, not a strike` |
140
- | a list change resets strikes even with no tool call | `a list change counts as progress even with no tool results` |
147
+ | a no-progress settle never stops the loop; the block hint is offered once per stall | `a no-progress settle continues, and offers the block hint once` |
148
+ | the first sighting of a list is progress, not a stall | `the first sighting of a list counts as progress, not a stall` |
149
+ | a list change clears the stall and re-arms the hint | `a list change clears the stall and re-arms the hint` |
141
150
  | the round cap stops the loop | `round cap stops the loop`, `config caps are honoured` |
142
151
  | a new human message resets the budget | `a new human message resets the budget` |
143
152
  | a stop keeps the counters for the anchor, and a new message clears them | `a stop keeps the counters, so a cap stays shut for the same anchor`, `a new human message after a stop starts a fresh budget`, `the round cap is sticky for one human message` |
@@ -146,10 +155,6 @@ introduces an edge.
146
155
  | the anchor is the newest `role:"user"` message without a `customType` — not assistant, toolResult, our own marker, or another extension's custom user message | `anchor is the newest human message id, ignoring assistant and toolResult`, `our own projected marker never becomes the anchor`, `a user message without an id anchors on its position` |
147
156
  | `deleted` tasks leave list, open set and signature | `deleted tasks are excluded from tasks, open and signature` |
148
157
  | malformed entries degrade to the empty snapshot | `malformed or missing state degrades to the empty snapshot` |
149
- | config defaults survive garbage; fields are coerced | `missing or malformed config falls back to defaults`, `valid fields override, invalid ones are ignored` |
150
- | the reconciliation clause appears only after one continuation | `the reconciliation clause is off on the first nudge`, `the reconciliation clause appears after one continuation, update before remove`, `the block asks for reconciliation only from the second request of a chain` |
151
- | model-authored task text is sanitised before it enters a request | `a hostile task subject cannot break out of the tag` |
152
- | the injection shows open tasks only, bounded, `activeForm` for in_progress | `injection lists open tasks with their status`, `injection is bounded and reports the overflow`, `injection uses activeForm only for in_progress`, `injection tolerates an empty subject` |
153
158
  | a status change moves the signature | `signature changes when a status changes` |
154
159
  | a record with neither a label nor a status is not a task, and arrays are not tasks | `a record with neither a label nor a status is not a task`, `an array element is not a task` |
155
160
  | only a `todo` tool's own shapes are read | `a foreign custom entry or toolResult carrying tasks is ignored` |
@@ -157,23 +162,37 @@ introduces an edge.
157
162
  | ids are made distinct: a synthetic or colliding id moves to a free number, a unique real id is never renumbered | `a positional id never shadows a real one`, `duplicate ids in the source are made distinct`, `a synthetic id never renumbers a real one, even when it comes first` |
158
163
  | a task without a usable id falls back to its position | `a task without a usable id falls back to its position, not to being dropped`, `a null or empty id falls back to the position instead of colliding on 0` |
159
164
  | the newer shape wins, in either order | `the newer of a custom entry and a toolResult wins, in either order` |
160
- | the block is appended only when injecting, replacing a tail marker | `context strips the marker and appends the state block when tasks are open`, `a tail marker is replaced by the block, never followed by it`, `the block is appended when the tail is an assistant turn`, `the request never ends with two user-role messages` |
161
- | history markers are left in place; they are the alternation glue | `history markers are left alone when nothing is injected`, `history markers are left alone when injection is disabled` |
162
165
  | a malformed context payload is not rewritten | `context leaves a malformed payload alone instead of rewriting it` |
163
- | a task subject cannot break out of the injection tag | `a hostile task subject cannot break out of the tag` |
164
- | the block ends on the concession, not on a call to act | `the block ends on the concession, not on a call to act` |
165
- | pause stops the block as well as the continuation | `pause stops both the continuation and the steering block` |
166
166
  | either kill switch disables both hooks | `the global kill switch disables both hooks`, `the project-local kill switch disables both hooks` |
167
167
  | only a settle spends a round: tool turns cost nothing | `a turn that ran tools spends no round and appends no marker` |
168
- | a tool turn clears a pending strike | `tool turns clear a pending strike but never spend a round` |
168
+ | a tool turn clears the stall and re-arms the hint | `tool turns clear the stall but never spend a round` |
169
+ | the block hint is offered once per stall, on the repeat stop | `the nudge offers todo_loop_block once, after a repeat stop` |
170
+ | `todo_loop_block` stops the loop and reports the reason | `todo_loop_block stops the loop for this message and reports the reason` |
171
+ | a block without a reason is refused | `a block without a reason is refused and does not stop the loop` |
172
+ | the human's next message clears the block | `the human's next message clears the block` |
173
+ | the block stops the loop and the next human message resumes it, end to end | `state machine: the agent asks for a person: the loop stops until the human answers` |
174
+ | a block survives pause and resume, and reset clears it | `state machine: a block survives pause and resume, and reset clears it`, `/todoloop reset clears a pending block` |
175
+ | the block's tool turn costs no round, and no marker is written after it | `state machine: the block call's tool turn costs no round, and the settle after it stops` |
176
+ | a model-authored reason is flattened and bounded | `a reason is flattened to one line and bounded` |
177
+ | the kill switch silences the block tool too | `the kill switch also silences todo_loop_block` |
178
+ | the block tool declares a prompt snippet and a self-naming guideline, so pi lists it in `<tools>`/`<rules>` | `the block tool declares a prompt snippet and a self-naming guideline` |
179
+ | a paused loop refuses the block tool and stays silent | `a paused loop refuses todo_loop_block and stays silent` |
180
+ | a stall clause that lands on the cap round is reported, not dropped | `state machine: cap stays shut across a repeated settle under the same message`, `a stale list stops on the cap with a reason that says so` |
181
+ | the next human message clears the block and the stale `last:` | `the human's next message clears the block` |
169
182
  | a continuation appends one hidden `custom_message` with a constant body and preserves prior proposals | `turn_end requests one turn with a hidden marker when work is open` |
170
- | a user-role tail receives the block instead of a second message (marker first dropped) | `a human-role tail receives the block instead of a second user message`, `a marker after a human message still yields one user-role tail` |
171
183
  | a malformed event payload does not throw | `turn_end tolerates a malformed event payload instead of throwing` |
172
- | clamped and invalid config values fall back | `numbers are clamped so a typo cannot defeat the cap it configures`, `a value below 1 is treated as invalid, not as zero` |
184
+ | clamped and invalid config values fall back | `numbers are clamped so a typo cannot defeat the cap it configures`, `a value below 1 is treated as invalid, not as zero`, `valid fields override, invalid ones are ignored` |
173
185
  | an errored or aborted turn is never continued | `turn_end stops on an errored or aborted turn and does not spend a round` |
174
- | the cap and pause gate the loop at the hook level | `the round cap is sticky for one human message`, `pause stops both the continuation and the steering block` |
186
+ | the cap and pause gate the loop at the hook level | `the round cap is sticky for one human message`, `pause stops the continuation and the marker cleanup` |
187
+ | the prompt is never rewritten with task state | `the prompt is never rewritten with task state`, `context removes a marker next to a human message and adds nothing` |
188
+ | an ordinary request comes back untouched | `an ordinary request is not touched at all`, `a request with no markers and no continuation is never rewritten` |
189
+ | a human's own message is never edited | `the human's own message is left exactly as sent` |
190
+ | a marker next to a human message is removed; markers between assistant turns stay | `a marker after a human message is dropped, not merged`, `a marker between an assistant turn and a human message is dropped`, `a marker between two assistant turns is left alone` |
191
+ | the tail marker survives as the nudge | `the tail marker is the nudge and is left alone`, `a run of markers at the tail keeps one, so the nudge survives` |
192
+ | a real continuation request ends on a marker and gains nothing | `the context hook keeps real markers and adds nothing to the request`, `the captured markers are the shape the context hook looks for` |
175
193
  | the docs match the code | `the pi snapshot constant appears in the README and the register`, `the verification date appears in the README and the register`, `the tested rpiv-todo version is recorded in both places`, `the CHANGELOG documents the version in package.json`, `every README link target inside the repository exists`, `every test name the invariant map cites actually exists`, `every stop reason the code can report appears in the README table`, `the README configuration table states every default, not just the numbers` |
176
194
 
195
+
177
196
  ## Adding or changing a continuation rule
178
197
 
179
198
  1. Put the logic in `decide()` (`src/loop.ts`), not in the hook. The hook only adapts events and
@@ -193,7 +212,7 @@ Ranked by how easily a small change breaks them.
193
212
  1. **Two different tails.** A continuation whose *persisted projection* ends on an assistant message is
194
213
  rejected by pi (`canContinue`); the *request* has no such constraint but must keep role alternation.
195
214
  Conflating the two produced the round-2 marker leak. `test/index.test.ts` pins both shapes.
196
- 2. **Counter lifecycle.** `rounds`, `strikes` and `anchor` are mutated in one place and reset in three
215
+ 2. **Counter lifecycle.** `rounds`, `stalled` and `anchor` are mutated in one place and reset in three
197
216
  (anchor change, `/todoloop reset`, process start). A missing reset either loops forever or refuses to
198
217
  continue after one bad turn; a reset in the wrong place (inside `stop()`) silently re-arms the cap.
199
218
  3. **The anchor.** It must be human-authored and never empty. `#<index>` is the fallback when an entry
@@ -217,7 +236,7 @@ network. `test/state-machine.test.ts` is the one to extend when you touch the co
217
236
  the vocabulary pi actually produces — `settle` (a response with no tool calls), `tool` (a response that
218
237
  ended in tool calls), `edit` (the list changed), `human` (a new message), plus pause/resume/reset/
219
238
  error/abort/kill-switch — and asserts the whole outcome at once: continuations, markers, rounds,
220
- strikes, the reason `/todoloop` prints and its header. Every release-blocking defect in this repository's
239
+ stalls, the block tool, the reason `/todoloop` prints and its header. Every release-blocking defect in this repository's
221
240
  history lived in that interaction, so a new rule belongs there as a row in `SCENARIOS` before it belongs
222
241
  in prose.
223
242
 
@@ -248,13 +267,13 @@ grep -c 'todo-loop:continue' ~/.pi/agent/sessions/<project>/*.jsonl # one per
248
267
  - `README.md` is for users of the extension, read top-down on npm: what it does, quick start, one
249
268
  example, when it helps and when it does not, configuration, commands, "is it working", troubleshooting,
250
269
  uninstall, requirements. No implementation inventories, no counter mechanics, no CI or release steps.
251
- - `docs/behaviour.md` is the depth the README links to: marker lifetime, role alternation, the injected
252
- block, counter semantics, reason strings, where the list is read from, scope, cost. Technical, but
270
+ - `docs/behaviour.md` is the depth the README links to: marker lifetime, role alternation, stalls,
271
+ counter semantics, reason strings, where the list is read from, scope, cost. Technical, but
253
272
  written for a user debugging behaviour rather than a contributor.
254
273
  - `docs/pi-api-alignment.md` is the register of deliberate workarounds over pi's documented extension
255
274
  API, and the record of which versions those were established against. Add an item when behaviour
256
275
  deviates; items with no output signal are marked _(silent)_. The tested-version table at the top,
257
- `PI_API_SNAPSHOT` in `src/index.ts`, and the README *Tested against* table move together in the
276
+ `PI_API_SNAPSHOT` in `src/index.ts`, and the README *Requirements* section move together in the
258
277
  same commit as the change that depends on them, and `test/docs.test.ts` fails if the constant, the
259
278
  date or the tested rpiv-todo version goes missing from either file. Note that `@juicesharp/rpiv-todo`
260
279
  is a *shape* dependency only (register item 12): it is never imported, so it has no `package.json`
@@ -266,8 +285,10 @@ grep -c 'todo-loop:continue' ~/.pi/agent/sessions/<project>/*.jsonl # one per
266
285
 
267
286
  ## Release canary
268
287
 
269
- The one failure this suite cannot see is pi changing how a `display: false` `custom_message` projects,
270
- or tightening `canContinue`. Drafts are committed before the continuation is validated, so that change
288
+ Two failures this suite cannot see. The first is pi changing how a `display: false` `custom_message`
289
+ projects, or tightening `canContinue`; the second is how pi renders a custom tool into the system
290
+ prompt, since the harness stub never builds one — a tool without a `promptSnippet` was silently dropped
291
+ from `<tools>` in pi 1.5.0. Drafts are committed before the continuation is validated, so that change
271
292
  would persist a marker, drop the continuation, and leave `/todoloop` reporting `continuing: round n/12`
272
293
  until it reports `round cap` with zero continuations — while every test here stays green, because the
273
294
  harness drives a stub `pi`.
@@ -275,19 +296,28 @@ harness drives a stub `pi`.
275
296
  Before every version bump, on the *new* pi, run the documented live check once:
276
297
 
277
298
  ```bash
278
- pi -e . -p "create two todos 'a' and 'b', then reply ok" --tools todo
299
+ # `-ne` because the released copy registers the same tool name: `pi -e .` next to the installed
300
+ # version fails with `Tool "todo_loop_block" conflicts with …`.
301
+ pi -ne -e . -p "create two todos 'a' and 'b', then reply ok" --tools todo
279
302
  grep -c 'todo-loop:continue' ~/.pi/agent/sessions/--<project>--/*.jsonl # 0 is a failure
280
303
  ```
281
304
 
282
- and read the pi changelog for `custom_message`, `display`, `canContinue`, plus rpiv-todo's for
283
- `reminder` (upstream PR #190 would put a second task block in the request).
305
+ For the prompt half, run with `--mode json` and confirm the tool reached the system prompt section:
306
+
307
+ ```bash
308
+ pi -ne -e . --mode json -p "reply ok" | grep -o 'todo_loop_block: Stop the todo loop'
309
+ ```
310
+
311
+ and read the pi changelog for `custom_message`, `display`, `canContinue`, `promptSnippet`,
312
+ `promptGuidelines`, plus rpiv-todo's for `reminder` (upstream PR #190 would put a second task block in
313
+ the request).
284
314
 
285
315
  ## Known limitations to revisit
286
316
 
287
317
  - **The decision is at `turn_end`, one boundary early.** pi 1.5.0 documents `agent_before_settle` as
288
318
  the final actionable boundary that can append entries and request one continuation, and at
289
319
  `turn_end` the hook has to reconstruct "the run would otherwise end" from `toolResults.length === 0`.
290
- The consequence is a bounded wasted round when a human message is queued mid-turn, and the strike
320
+ The consequence is a bounded wasted round when a human message is queued mid-turn, and the stall
291
321
  reset lives in the adapter rather than in `decide()`. Moving the decision is the 0.2.0 change; it must
292
322
  ship with a CHANGELOG line because it changes which turns spend a round.
293
323
  - **`getBranch()` is not the model-visible context** (register item 16): after compaction the panel can
package/CHANGELOG.md CHANGED
@@ -4,14 +4,79 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.2.1] - 2026-11-05
8
+
9
+ ### Changed
10
+
11
+ - `todo_loop_block` now declares a `promptSnippet` and a `promptGuidelines` bullet, so pi lists it in the
12
+ system prompt's `<tools>` section and its guideline in `<rules>`. Before this the tool was in the
13
+ request's tool array but absent from the prompt summary, so an agent could only find it through the
14
+ nudge. The guideline also tells the model to pick a reasonable option and state its assumption when a
15
+ choice is its own, rather than asking a person.
16
+
17
+ ## [0.2.0] - 2026-11-05
18
+
19
+ ### Added
20
+
21
+ - `todo_loop_block`, a tool the agent can call when it cannot proceed without a person. It takes a
22
+ reason, stops continuations for the rest of that message, notifies the human, and shows
23
+ `waiting for you: <reason>` in `/todoloop`. The human's next message resumes the loop; there is no
24
+ unblock call. The nudge offers this tool once per stall — on the first settle since progress that
25
+ did not move the list — which is the shape a blocked agent has. If that settle is the one the round
26
+ cap ends, the stop reason says `the agent stalled and may need a person`, so the hint is not lost.
27
+ - `MARKER_BODY_HINT`: the same nudge plus that one clause. Both variants end on the concession that the
28
+ human outranks the loop.
29
+
30
+ ### Removed
31
+
32
+ - **The injected state block is gone.** The extension no longer writes task state into a request: no
33
+ `<todo_loop_state>` list, no reconciliation clause, no truncation variant, no merge into a `user` or
34
+ `custom` tail. The only variable text it gives the model is the one-sentence nudge appended with each
35
+ continuation, and that nudge now points at the todo tool instead of restating the list. Reading and
36
+ updating the list is the agent's job; the prompt stays the human's.
37
+ - `injectState` and `maxInjectedTasks` are gone with it. A config file that still carries them loads
38
+ fine — unknown keys are ignored — so upgrading needs no edit.
39
+ - `maxStrikes`. The loop no longer stops for being quiet: two no-progress settles used to end a run, and
40
+ a stalled agent that was waiting for a person was abandoned with "no progress". The counter is now
41
+ `stalled`, uncapped, and it only decides when to offer `todo_loop_block`. A `maxStrikes` key in an
42
+ existing config is ignored.
43
+ - The renderer and its sanitiser (`src/inject.ts`, `test/inject.test.ts`). Config coercion moved to
44
+ `test/config.test.ts`, because it outlived the renderer.
45
+
46
+ ### Upgrade notes
47
+
48
+ - `maxRounds` is now the only cost knob: a model that keeps stopping without moving the list runs to
49
+ the cap instead of stopping at two, and each continuation re-sends the conversation.
50
+ - A running session keeps the old build until `/reload` or a restart, and that starts a fresh budget.
51
+ - 0.1.1 re-injected the open-task list into every request after a compaction. That is gone: the model
52
+ now sees the list only when it calls `todo` or is nudged, so a long session right after a compaction
53
+ may take one extra turn before the agent knows what is left.
54
+
55
+ ### Changed
56
+
57
+ - `context` may only *remove* a marker, never add one. It drops a marker whose neighbour is user-role
58
+ (next to a human message, or in a run of markers) and returns `undefined` for every other request, so
59
+ an ordinary turn is passed through as the object pi already built. A marker at the tail always
60
+ survives: it is the nudge, and dropping it would leave the continuation ending on an assistant turn.
61
+ - `staleSettles` no longer drives injected wording. It still counts settles since the list last
62
+ changed and still decorates the cap reason (`, list unchanged for N settles`).
63
+
64
+ ### Fixed
65
+
66
+ - A run of adjacent continuation markers keeps its first marker. Testing only the literal neighbours
67
+ dropped the whole run, leaving two assistant turns adjacent — the alternation the filter exists to
68
+ prevent.
69
+ - The publish workflow's release step is idempotent: re-running a tag edits the existing release
70
+ instead of failing on "release already exists".
71
+
7
72
  ## [0.1.1] - 2026-11-04
8
73
 
9
74
  ### Added
10
75
 
11
- - The injected block escalates once: from the first continuation of a chain it asks the model to
12
- reconcile a drifted list — update statuses, add what is missing, remove what no longer applies, in
13
- that order — so a stale list has an exit short of burning the round cap. The first request of a
14
- chain still only asks for status moves.
76
+ - The injected block escalates once, from the first continuation of a chain: it asks the model to
77
+ reconcile a drifted list — update statuses, add what is missing, remove what no longer applies — so a
78
+ stale list has an exit short of burning the round cap. (Known issue, fixed in the entry above: the
79
+ clause was armed by the round counter, so it also reached the first request of a new human message.)
15
80
 
16
81
  ### Fixed
17
82
 
@@ -36,8 +101,8 @@ Initial release.
36
101
  never persisted, closing the post-compaction context hole
37
102
  ([rpiv-mono#222](https://github.com/juicesharp/rpiv-mono/issues/222)) and the "model
38
103
  never calls the todo tool's `list` action" failure ([rpiv-mono#159](https://github.com/juicesharp/rpiv-mono/issues/159)).
39
- - Hidden continuation marker (`custom_message`, `display: false`, constant 21-character body
40
- `<todo_loop_continue/>`, ~200 bytes as a session entry). Markers are kept in history: they sit
104
+ - Hidden continuation marker (`custom_message`, `display: false`, a constant instruction body,
105
+ ~220 bytes as a session entry). Markers are kept in history: they sit
41
106
  between assistant turns and are what keeps the conversation alternating. A marker at the tail is
42
107
  replaced by the state block, so a request never ends on two user-role messages.
43
108
  - Task state read from `rpiv-todo-state` custom entries with a `todo` toolResult
@@ -61,12 +126,6 @@ Initial release.
61
126
  (`settle`, `tool`, `edit`, `human`, pause/resume/reset, error/abort, kill switch), asserting
62
127
  continuations, markers, rounds, strikes, the `/todoloop` reason and header at once. Re-introducing any
63
128
  of the three historical budget defects fails it.
64
- - The continuation marker's constant body states the instruction ("Continue with the open todo
65
- tasks…") instead of a self-closing tag, so `injectState: false` no longer continues with no
66
- instruction at all, and a marker left in history is self-explanatory.
67
- - The injected block escalates once: from the first continuation onwards it asks the model to
68
- reconcile a drifted list (update statuses, add what is missing, remove what no longer applies —
69
- in that order), which gives a stale list a deterministic exit short of burning the round cap.
70
129
  - A list that goes open → closed in a turn whose only tool results were `todo` is announced once: the
71
130
  loop's success state and its failure state are the same observable.
72
131
  - A stale-list burn now says so: the round-cap reason appends `, list unchanged for N settles`.
@@ -83,3 +142,5 @@ Initial release.
83
142
  `.github/`) leaks into it (`npm pack --dry-run` exits 0 otherwise).
84
143
 
85
144
  [0.1.0]: https://github.com/DumbMice/pi-todo-loop/releases/tag/v0.1.0
145
+ [0.1.1]: https://github.com/DumbMice/pi-todo-loop/releases/tag/v0.1.1
146
+ [Unreleased]: https://github.com/DumbMice/pi-todo-loop/compare/v0.1.1...HEAD
package/README.md CHANGED
@@ -9,8 +9,8 @@ Keeps the [pi coding agent](https://pi.dev) working while its todo list still ha
9
9
 
10
10
  `rpiv-todo` gives the agent a task list and shows it to you. It never asks pi for another turn, so the
11
11
  agent still stops halfway and hands control back. This extension adds the turn: while tasks are open it
12
- keeps the agent going, puts the list back in front of the model after a compaction, and stops when you
13
- clear the list.
12
+ keeps the agent going, and stops when you clear the list — or when the agent tells you it needs a
13
+ person. It never writes the list into your prompt: the agent reads it with its own todo tool.
14
14
 
15
15
  ## Quick start
16
16
 
@@ -46,53 +46,54 @@ pi: All 12 tasks are complete; here is what was verified …
46
46
  ← the list is clear, so the loop stops and stays stopped.
47
47
  ```
48
48
 
49
- It stops for three reasons, and `/todoloop` always says which:
49
+ It stops for one of these reasons, and `/todoloop` always says which:
50
50
 
51
51
  | Why it stopped | What you see |
52
52
  |---|---|
53
53
  | the list is clear | `stopped: list is clear` — the normal ending |
54
- | the model stopped working the list | `stopped: 2 consecutive turns with no progress, 3 open` — two quiet turns in a row |
55
- | the budget is spent | `stopped: round cap 12 reached, 3 open` — 12 continuations for your one message |
54
+ | the model said it needs a person | `waiting for you: waiting on Dana's review` — it called `todo_loop_block`; your next message resumes |
55
+ | the budget is spent | `stopped: round cap 12 reached, 3 open` — 12 continuations for your one message; if it ends with `the agent stalled and may need a person`, ask it what it is waiting on |
56
+ | the turn failed | `stopped: turn outcome was error` — the provider failed, or you aborted it |
56
57
 
57
58
  ## When it helps
58
59
 
59
60
  - Long, multi-step work where the agent summarises and hands back halfway.
60
- - Sessions long enough to be compacted — the list survives on disk, but the model loses it.
61
+ - Sessions long enough to be compacted — the list survives on disk; the nudge is what gets the model to
62
+ read it again.
61
63
  - A model that runs tools but rarely updates its list. It will still be nudged; it may keep working to
62
64
  the round cap.
63
65
 
64
66
  ## When it does not
65
67
 
66
- - **No todo tool installed.** The extension is inert: nothing continues, nothing is injected.
68
+ - **No todo tool installed.** The extension is inert: nothing continues.
67
69
  - **You want an autonomous agent that invents its own goals.** This drives the list you have; it stops
68
70
  after 12 continuations per message by default.
69
71
  - **You want the continuations visible.** They are silent by design.
70
72
  - **You want the agent to keep going after you said stop.** Your instruction wins; a model that reads
71
- "stop" as final will hold, and those refusals cost up to 12 turns.
73
+ "stop" as final can say so with `todo_loop_block`, and that stops the loop immediately.
72
74
 
73
75
  It buys *liveness*, not work. A model can satisfy the loop by closing tasks, including deleting
74
76
  placeholders, and a list that is never updated looks the same as one that is finished. Clearing the list
75
77
  is the only thing that stops it, so the list has to mean something to you.
76
78
 
79
+ The extension never edits your prompt. The only *variable* text it gives the model is a one-line nudge —
80
+ *continue, and read or update the list with the todo tool* — plus one constant tool declaration and the
81
+ prompt entry pi derives from it (`todo_loop_block`; neither carries task state), so the list stays the
82
+ agent's to maintain and the conversation stays yours.
83
+
77
84
  ## Configuration
78
85
 
79
86
  Optional, at `~/.pi/agent/todo-loop.json`:
80
87
 
81
88
  ```json
82
89
  {
83
- "maxRounds": 12,
84
- "maxStrikes": 2,
85
- "injectState": true,
86
- "maxInjectedTasks": 6
90
+ "maxRounds": 12
87
91
  }
88
92
  ```
89
93
 
90
94
  | Key | Default | What it does |
91
95
  |---|---|---|
92
96
  | `maxRounds` | `12` | continuations allowed per message to the agent |
93
- | `maxStrikes` | `2` | turns in a row that leave the list unchanged before stopping |
94
- | `injectState` | `true` | put the open-task list into each request; `false` leaves the model to read the list itself |
95
- | `maxInjectedTasks` | `6` | rows shown before `… and N more` |
96
97
 
97
98
  A missing or broken file falls back to these values; a bad config never fails a turn.
98
99
 
@@ -100,10 +101,10 @@ A missing or broken file falls back to these values; a bad config never fails a
100
101
 
101
102
  | Command | What it does |
102
103
  |---|---|
103
- | `/todoloop` | status: mode, rounds, strikes, open tasks, why it last stopped, config path |
104
- | `/todoloop pause` | stop continuing and stop injecting, until you resume or restart |
104
+ | `/todoloop` | status: mode, rounds, stalls, open tasks, whether it waits for you, why it last stopped, config path |
105
+ | `/todoloop pause` | stop continuing until you resume or restart |
105
106
  | `/todoloop resume` | allow the next turn to continue again |
106
- | `/todoloop reset` | clear the counters and start the budget over |
107
+ | `/todoloop reset` | clear the counters, the list signature and any pending `todo_loop_block` |
107
108
 
108
109
  **Kill switch** — checked before every decision:
109
110
 
@@ -118,8 +119,8 @@ After a response that made no tool calls while tasks are open, `/todoloop` shoul
118
119
 
119
120
  ```
120
121
  todo-loop active
121
- rounds 1/12 · strikes 0/2
122
- open 2/5 tracked · inject on
122
+ rounds 1/12 · stalled 0
123
+ open 2/5 tracked
123
124
  anchor 4f3c1a2e
124
125
  last: continuing: round 1/12, 2 open
125
126
  config ~/.pi/agent/todo-loop.json
@@ -135,9 +136,9 @@ grep -c 'todo-loop:continue' ~/.pi/agent/sessions/<project>/*.jsonl
135
136
 
136
137
  | Symptom | What to do |
137
138
  |---|---|
138
- | Nothing continues | Check `/todoloop`: `DISABLED` means a kill-switch file exists, `PAUSED` means you paused it, `last: stopped: no list` means no todo tool is writing a list |
139
- | It stopped with `no progress` | The model answered twice without running a tool or changing the list. Raise `maxStrikes` if you want more attempts |
140
- | It stopped at the round cap | The budget for that message is spent; send another message. If the reason ends with `list unchanged for N settles`, the model was working without updating the list |
139
+ | Nothing continues | Check `/todoloop`: `DISABLED` means a kill-switch file exists, `PAUSED` means you paused it. `last: stopped: no list` with `open 0/0 tracked` means the model has not created a list in this session; if the open count is non-zero the tool is writing a shape this extension does not read |
140
+ | The model asked for a person | It called `todo_loop_block` with a reason, so the loop is stopped until your next message. The reason appears in `/todoloop` as `waiting for you: …` |
141
+ | It stopped at the round cap | The budget for that message is spent; send another message. `list unchanged for N settles` means the model was working without updating the list; `the agent stalled and may need a person` means it stopped without moving the list on the last round — ask what it is waiting on |
141
142
  | Rounds jump by two, or continuations appear twice | Two continuation extensions are installed; `pi list` shows them, remove one, then `/reload` |
142
143
  | The list is stale | The extension nudges, it does not write statuses. Nothing here fabricates progress |
143
144
  | A warning says the list was cleared by todo alone | Every task closed in a turn with no other tool work. Check the work before trusting it |
@@ -168,7 +169,7 @@ the budget meaningless.
168
169
 
169
170
  ## Further reading
170
171
 
171
- - **How it behaves, precisely** — counters, markers, the injected block, cost:
172
+ - **How it behaves, precisely** — counters, markers, stalls, cost:
172
173
  [docs/behaviour.md](docs/behaviour.md)
173
174
  - **pi API alignment** — the deliberate workarounds: [docs/pi-api-alignment.md](docs/pi-api-alignment.md)
174
175
  - **Working on the repository**: [AGENTS.md](AGENTS.md)