@dumbmice/pi-todo-loop 0.0.0-stage → 0.1.0

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 ADDED
@@ -0,0 +1,365 @@
1
+ # Working on this repository
2
+
3
+ Instructions for anyone — human or coding agent — changing `@dumbmice/pi-todo-loop`. For what the extension
4
+ does and how to install it, read [README.md](README.md).
5
+
6
+ ## Layout
7
+
8
+ ```
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)
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
+ src/config.ts config load and coercion, defaults on any failure
14
+ test/loop.test.ts decision policy: caps, strikes, anchor reset, error outcomes
15
+ test/state.test.ts branch reader: both shapes, recency preference, malformed input
16
+ test/inject.test.ts renderer and config coercion
17
+ test/index.test.ts the pi-facing half through a stub `pi`: tail handling, kill switch, caps
18
+ test/state-machine.test.ts the counters as a table of event sequences (the suite to extend)
19
+ test/helpers/harness.ts the stub `pi`, mutable branch and scripted context those two share
20
+ test/fixture.test.ts the same hooks over real captured lines (test/fixtures/branch.jsonl)
21
+ test/docs.test.ts pins the version table, invariant map, reason table, config table, links
22
+ scripts/check-pack.mjs asserts the published tarball contains what package.json promises
23
+ 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
+ README.md user-facing documentation (what it is, install, config, troubleshooting)
26
+ AGENTS.md this file
27
+ CONTRIBUTING.md four-line pointer to this file (GitHub convention)
28
+ CHANGELOG.md Keep a Changelog format, released and unreleased entries
29
+ package.json scripts, engines, the files allow-list, pi.extensions entry point
30
+ tsconfig.json the strict / noEmit settings used by typecheck
31
+ .github/workflows/ test.yml (Node 22.6, 22 and 24: ci, test, typecheck, pack)
32
+ ```
33
+
34
+ ## Requirements and commands
35
+
36
+ Node >= 22.19 (`engines`, the floor pi itself requires; the entry point is TypeScript and the test
37
+ runner uses `--experimental-strip-types`, which exists from 22.6). **If `npm test` fails with
38
+ `node: bad option: --experimental-strip-types`, a pre-22 node is first in `PATH`** — nvm's v20 shadows
39
+ pi's own node in a typical shell. `pretest` now says so instead of leaking the raw node error. Run the
40
+ publish (and the tests) with pi's node first:
41
+
42
+ ```bash
43
+ export PATH="$HOME/.local/share/pi-node/current/bin:$PATH"
44
+ npm publish --access public
45
+ ``` devDependencies stay limited to `typescript` and `@types/node`; the test
46
+ workflow installs with `npm ci`.
47
+
48
+ ```bash
49
+ npm install # devDependencies only: typescript, @types/node
50
+ npm test # node:test, no model, no key, no network
51
+ npm run typecheck # tsc -p tsconfig.json, strict
52
+ npm run pack:check # asserts src, docs, tsconfig, README, AGENTS, CONTRIBUTING, LICENSE, CHANGELOG ship
53
+
54
+ # one file, while iterating
55
+ node --experimental-strip-types --no-warnings --test test/loop.test.ts
56
+ ```
57
+
58
+ There is no pi install and no build step: the source is TypeScript that pi loads directly, and
59
+ `npm run typecheck` resolves no pi types because the extension imports none.
60
+
61
+ ## Design decisions worth preserving
62
+
63
+ These are the invariants the suite protects. Changing one means changing its test, any affected
64
+ README text, the matching `docs/pi-api-alignment.md` item (or marking that item inapplicable), and
65
+ adding a `CHANGELOG.md` line — all in the same commit.
66
+
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
+ - **A `todo`-only clear is announced, not policed.** The loop's success and failure states are the same
71
+ 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.
88
+ - **One entry per continuation is a floor, not a choice.** pi has no `resume()` that appends nothing.
89
+ Do not try to reach zero entries; keep the single `custom_message` with `display: false`.
90
+ - **The marker `customType` is a stable literal.** The tail check must keep recognising markers written
91
+ 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
93
+ `session_compact` resync handler: memory would drift across forks and compaction. Loop state
94
+ (counters, anchor, pause) *is* in memory, and a restart necessarily starts a new budget.
95
+ - **The decision is a pure function.** `decide()` in `src/loop.ts` takes a value and returns a value.
96
+ It must stay free of pi, filesystem and time so the caps can be tested exhaustively.
97
+ - **Only a settle spends a round.** `turn_end` fires for every assistant response, including one that
98
+ 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
106
+ stop re-arms a full budget on the next `turn_end` under the same anchor (another extension's
107
+ continuation, a provider retry). The anchor comparison is the only *automatic* reset;
108
+ `/todoloop reset` and process start clear the counters explicitly. A queued human message is a
109
+ new anchor, so it resets the budget the ordinary way.
110
+ - **Open means "not finished".** `CLOSED_STATUSES` is an exclusion list (`completed`, `deleted`), not an
111
+ allow-list: a status the todo tool adds later must not make a task vanish and the loop report the
112
+ list clear while work remains.
113
+ - **The newer of the two shapes wins.** Custom entry and toolResult fallback are compared by branch
114
+ position, not by type preference; preference by type would serve a stale list forever if the tool
115
+ stopped writing custom entries partway through a session.
116
+ - **No runtime dependencies.** The only imports are `node:` builtins. Argument validation, config
117
+ parsing and rendering are hand-written for that reason.
118
+ - **Fail soft at the boundary.** Parsing a branch, a config file or an entry never throws: malformed
119
+ input degrades to "no list" or to defaults. A thrown boundary handler is reported and the turn
120
+ continues, but the loop would then stop silently — inert is better than loud.
121
+ - **Nothing is written.** No files, no network, no process execution. The only reads are the optional
122
+ 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
124
+ `/reload`.
125
+ - **No pi type import; `pi: any` at the registration boundary.** Contexts the file reads are typed
126
+ structurally (`SessionContext`). Adding the pi types would make a dependency out of a package that
127
+ deliberately has none.
128
+
129
+ ## Invariant → test map
130
+
131
+ Find the invariant you are touching, run the case that pins it, and add a neighbour if your change
132
+ introduces an edge.
133
+
134
+ | Invariant | Pinned by |
135
+ |---|---|
136
+ | 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
+ | 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` |
141
+ | the round cap stops the loop | `round cap stops the loop`, `config caps are honoured` |
142
+ | a new human message resets the budget | `a new human message resets the budget` |
143
+ | 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` |
144
+ | the newest custom entry wins over older ones | `reads the newest rpiv-todo custom entry` |
145
+ | the toolResult fallback works and loses to the custom entry | `falls back to todo toolResult details`, `prefers the custom entry over the toolResult fallback` |
146
+ | 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
+ | `deleted` tasks leave list, open set and signature | `deleted tasks are excluded from tasks, open and signature` |
148
+ | 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
+ | model-authored task text is sanitised before it enters a request | `a hostile task subject cannot break out of the tag` |
151
+ | 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` |
152
+ | a status change moves the signature | `signature changes when a status changes` |
153
+ | 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` |
154
+ | only a `todo` tool's own shapes are read | `a foreign custom entry or toolResult carrying tasks is ignored` |
155
+ | an unknown status counts as open, and status text is normalised | `an unknown status counts as open, not as done`, `status is normalised, so a differently-cased value still closes` |
156
+ | 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` |
157
+ | 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` |
158
+ | the newer shape wins, in either order | `the newer of a custom entry and a toolResult wins, in either order` |
159
+ | 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` |
160
+ | 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` |
161
+ | a malformed context payload is not rewritten | `context leaves a malformed payload alone instead of rewriting it` |
162
+ | a task subject cannot break out of the injection tag | `a hostile task subject cannot break out of the tag` |
163
+ | the block ends on the concession, not on a call to act | `the block ends on the concession, not on a call to act` |
164
+ | pause stops the block as well as the continuation | `pause stops both the continuation and the steering block` |
165
+ | either kill switch disables both hooks | `the global kill switch disables both hooks`, `the project-local kill switch disables both hooks` |
166
+ | only a settle spends a round: tool turns cost nothing | `a turn that ran tools spends no round and appends no marker` |
167
+ | a tool turn clears a pending strike | `tool turns clear a pending strike but never spend a round` |
168
+ | 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` |
169
+ | 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` |
170
+ | a malformed event payload does not throw | `turn_end tolerates a malformed event payload instead of throwing` |
171
+ | 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` |
172
+ | an errored or aborted turn is never continued | `turn_end stops on an errored or aborted turn and does not spend a round` |
173
+ | 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` |
174
+ | 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` |
175
+
176
+ ## Adding or changing a continuation rule
177
+
178
+ 1. Put the logic in `decide()` (`src/loop.ts`), not in the hook. The hook only adapts events and
179
+ applies the returned state. Add a row to `SCENARIOS` in `test/state-machine.test.ts` for the new
180
+ behaviour before touching prose.
181
+ 2. Add the field to `LoopState` or `DecisionInput` if new information is needed, and make it
182
+ default-safe: a session recorded by an older build must still decide sensibly.
183
+ 3. Add cases to `test/loop.test.ts`: the new rule, its boundary, and the ordering against the reset
184
+ and cap rules. Order matters — the anchor reset runs before every other check.
185
+ 4. Add a user-visible line to the `/todoloop` status if an operator needs to see it.
186
+ 5. Update the README *Behaviour to expect* list and `CHANGELOG.md` under `[Unreleased]`.
187
+
188
+ ## Fragile areas
189
+
190
+ Ranked by how easily a small change breaks them.
191
+
192
+ 1. **Two different tails.** A continuation whose *persisted projection* ends on an assistant message is
193
+ rejected by pi (`canContinue`); the *request* has no such constraint but must keep role alternation.
194
+ Conflating the two produced the round-2 marker leak. `test/index.test.ts` pins both shapes.
195
+ 2. **Counter lifecycle.** `rounds`, `strikes` and `anchor` are mutated in one place and reset in three
196
+ (anchor change, `/todoloop reset`, process start). A missing reset either loops forever or refuses to
197
+ continue after one bad turn; a reset in the wrong place (inside `stop()`) silently re-arms the cap.
198
+ 3. **The anchor.** It must be human-authored and never empty. `#<index>` is the fallback when an entry
199
+ carries no id; returning `""` would make "no anchor yet" and "this entry had no id"
200
+ indistinguishable, freezing the per-message reset. A branch switch that lands on a sibling branch
201
+ with the same newest user entry keeps the spent budget — deliberate, so a fork cannot refill it.
202
+ 4. **State shape drift in the todo tool.** Both shapes are read defensively; keep `normalizeTask`
203
+ tolerant of renamed fields (`content` vs `subject`) and of unusable ids, and keep `withUniqueIds` in
204
+ `snapshotFrom` — it moves only synthetic or *colliding* ids, never a unique one the payload supplied,
205
+ because that number is what the model must use to update the task.
206
+ 5. **Branch walking.** The reader stops once it holds a list in *either* shape and the anchor. It walks
207
+ newest-first, so a toolResult newer than the custom entry is already in hand; the extra walking is to
208
+ reach the anchor, which is older than the newest list entry. Breaking out on the list alone leaves
209
+ `anchor` empty, which makes every turn look like a new human message and re-arms the full budget each
210
+ time.
211
+
212
+ ## Tests
213
+
214
+ `test/*.test.ts` uses `node:test` and imports the source directly; nothing needs a model, a key or a
215
+ network. `test/state-machine.test.ts` is the one to extend when you touch the counters: it replays sequences in
216
+ the vocabulary pi actually produces — `settle` (a response with no tool calls), `tool` (a response that
217
+ ended in tool calls), `edit` (the list changed), `human` (a new message), plus pause/resume/reset/
218
+ error/abort/kill-switch — and asserts the whole outcome at once: continuations, markers, rounds,
219
+ strikes, the reason `/todoloop` prints and its header. Every release-blocking defect in this repository's
220
+ history lived in that interaction, so a new rule belongs there as a row in `SCENARIOS` before it belongs
221
+ in prose.
222
+
223
+ `test/index.test.ts` fakes pi's surface (an object with `on` and `registerCommand`, plus a
224
+ `SessionContext` over a hand-built branch) and asserts the hook *return values*; it is the only place
225
+ the tail coupling is checked. `test/docs.test.ts` reads the repository's own Markdown, which is how the
226
+ tested-version table is enforced. When fixing a bug, add the failing case first. Run `npm test` before every commit; CI repeats
227
+ it on Node 22 and 24 together with `npm run typecheck` and `npm run pack:check`.
228
+
229
+ ### Try a change locally
230
+
231
+ There is no build step: pi loads `src/index.ts` through `package.json` → `pi.extensions`.
232
+
233
+ ```bash
234
+ pi -e /absolute/path/to/pi-todo-loop # one session, straight from the working copy
235
+ ```
236
+
237
+ A live check needs `rpiv-todo` installed and an agent that creates a task list. Ask for two tasks and
238
+ then tell it to stop; with the loop active you should see it continue, and `/todoloop` should report
239
+ `round 1/12`. To watch the hidden machinery:
240
+
241
+ ```bash
242
+ grep -c 'todo-loop:continue' ~/.pi/agent/sessions/<project>/*.jsonl # one per continuation
243
+ ```
244
+
245
+ ## Documentation conventions
246
+
247
+ - `README.md` is for users of the extension, read top-down on npm: what it does, quick start, one
248
+ example, when it helps and when it does not, configuration, commands, "is it working", troubleshooting,
249
+ uninstall, requirements. No implementation inventories, no counter mechanics, no CI or release steps.
250
+ - `docs/behaviour.md` is the depth the README links to: marker lifetime, role alternation, the injected
251
+ block, counter semantics, reason strings, where the list is read from, scope, cost. Technical, but
252
+ written for a user debugging behaviour rather than a contributor.
253
+ - `docs/pi-api-alignment.md` is the register of deliberate workarounds over pi's documented extension
254
+ API, and the record of which versions those were established against. Add an item when behaviour
255
+ deviates; items with no output signal are marked _(silent)_. The tested-version table at the top,
256
+ `PI_API_SNAPSHOT` in `src/index.ts`, and the README *Tested against* table move together in the
257
+ same commit as the change that depends on them, and `test/docs.test.ts` fails if the constant, the
258
+ date or the tested rpiv-todo version goes missing from either file. Note that `@juicesharp/rpiv-todo`
259
+ is a *shape* dependency only (register item 12): it is never imported, so it has no `package.json`
260
+ entry, and its tested version lives in that table rather than in a constant.
261
+ - `CHANGELOG.md` follows Keep a Changelog. Put unreleased work under `[Unreleased]` with
262
+ Added/Changed/Fixed headings; add the version heading when the release is tagged. `test/docs.test.ts`
263
+ fails if the published version has no entry, so this one is enforced.
264
+ - `AGENTS.md` (this file) holds contributor, architecture and release material.
265
+
266
+ ## Release canary
267
+
268
+ The one failure this suite cannot see is pi changing how a `display: false` `custom_message` projects,
269
+ or tightening `canContinue`. Drafts are committed before the continuation is validated, so that change
270
+ would persist a marker, drop the continuation, and leave `/todoloop` reporting `continuing: round n/12`
271
+ until it reports `round cap` with zero continuations — while every test here stays green, because the
272
+ harness drives a stub `pi`.
273
+
274
+ Before every version bump, on the *new* pi, run the documented live check once:
275
+
276
+ ```bash
277
+ pi -e . -p "create two todos 'a' and 'b', then reply ok" --tools todo
278
+ grep -c 'todo-loop:continue' ~/.pi/agent/sessions/--<project>--/*.jsonl # 0 is a failure
279
+ ```
280
+
281
+ and read the pi changelog for `custom_message`, `display`, `canContinue`, plus rpiv-todo's for
282
+ `reminder` (upstream PR #190 would put a second task block in the request).
283
+
284
+ ## Known limitations to revisit
285
+
286
+ - **The decision is at `turn_end`, one boundary early.** pi 1.5.0 documents `agent_before_settle` as
287
+ the final actionable boundary that can append entries and request one continuation, and at
288
+ `turn_end` the hook has to reconstruct "the run would otherwise end" from `toolResults.length === 0`.
289
+ The consequence is a bounded wasted round when a human message is queued mid-turn, and the strike
290
+ reset lives in the adapter rather than in `decide()`. Moving the decision is the 0.2.0 change; it must
291
+ ship with a CHANGELOG line because it changes which turns spend a round.
292
+ - **`getBranch()` is not the model-visible context** (register item 16): after compaction the panel can
293
+ show work the model has no list for.
294
+
295
+ ## Release process
296
+
297
+ Publishing runs in `.github/workflows/publish.yml` on a pushed `v*` tag, using npm
298
+ [trusted publishing](https://docs.npmjs.com/trusted-publishers): the job exchanges its OIDC token for a
299
+ short-lived publish credential, so the repository holds no `NPM_TOKEN`, and npm attaches a provenance
300
+ attestation automatically because the repository is public.
301
+
302
+ **The first version is published from the CLI**, because a package that does not exist yet cannot have
303
+ a trusted publisher configured from a workflow (npm also offers a *pending publisher* for that case):
304
+
305
+ ```bash
306
+ npm login # or refresh the token in ~/.npmrc
307
+ npm publish --access public # scoped: --access public is required
308
+ ```
309
+
310
+ Then **configure the publisher once** at <https://www.npmjs.com/package/@dumbmice/pi-todo-loop/access>:
311
+
312
+ | Field | Value |
313
+ |---|---|
314
+ | Provider | GitHub Actions |
315
+ | Organization or user | `DumbMice` |
316
+ | Repository | `pi-todo-loop` |
317
+ | Workflow filename | `publish.yml` |
318
+ | Environment | leave empty |
319
+
320
+ Or from the CLI (npm >= 11.15):
321
+
322
+ ```bash
323
+ npx npm@latest trust github @dumbmice/pi-todo-loop \
324
+ --file publish.yml --repo DumbMice/pi-todo-loop --allow-publish
325
+ npx npm@latest trust list @dumbmice/pi-todo-loop
326
+ ```
327
+
328
+ The workflow must keep that filename: npm pins the publisher to it. It skips the publish when the tag's
329
+ version already exists on npm, so the manual first release and a re-run of the same tag both work; the
330
+ GitHub release is still created. It runs `npm ci`, the suite, the
331
+ typecheck and `npm run pack:check`, refuses to publish when the tag and `package.json` disagree, packs
332
+ the tarball and asserts the packed manifest still declares `pi.extensions`, then publishes and creates
333
+ the GitHub release from the matching `CHANGELOG.md` section. It has no inputs: a manual `workflow_dispatch` is a
334
+ `dry_run` — it verifies everything and publishes nothing.
335
+
336
+ Cutting a release:
337
+
338
+ ```bash
339
+ npm version patch --no-git-tag-version # or minor/major; the tree must be clean
340
+ VERSION="$(node -p "require('./package.json').version")"
341
+ # move the CHANGELOG entry from [Unreleased] to "## [$VERSION] — YYYY-MM-DD"
342
+ git add -A
343
+ git commit -m "chore: release v$VERSION"
344
+ git tag -a "v$VERSION" -m "v$VERSION"
345
+ git push origin main --follow-tags
346
+ ```
347
+
348
+ For the **first** release only: `npm publish --access public` before pushing the tag. Push the tag
349
+ anyway — the workflow notices the version already exists, skips the upload, and creates the GitHub
350
+ release.
351
+
352
+ `--follow-tags` pushes only **annotated** tags, which is why the recipe uses `git tag -a`. A pushed tag
353
+ starts the publish; nothing else does.
354
+
355
+ Until the trusted publisher is configured, `publish.yml` fails at the publish step by design (the OIDC
356
+ exchange is rejected) — and that is the safe failure: the checks still run, nothing is uploaded.
357
+ ## Commits and security
358
+
359
+ - Commits follow Conventional Commits (`feat:`, `fix:`, `docs:`, `ci:`, `chore:`); release commits are
360
+ `chore: release vX.Y.Z`.
361
+ - Style: tabs and double quotes, no formatter or linter in the repository. Do not reformat untouched
362
+ lines.
363
+ - Report vulnerabilities through GitHub's private advisory form (Security → Report a vulnerability),
364
+ not a public issue.
365
+ - Contributions are accepted under the MIT license (see [LICENSE](LICENSE)).
package/CHANGELOG.md ADDED
@@ -0,0 +1,66 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.0] - 2026-11-04
8
+
9
+ Initial release.
10
+
11
+ ### Added
12
+
13
+ - `turn_end` continuation while a todo list has open tasks — anything not `completed` or
14
+ `deleted` — with a per-human-message round cap (`maxRounds`, default 12) and a
15
+ no-progress strike budget (`maxStrikes`, default 2). Only a settle spends a round: a turn
16
+ that ended in a tool call already causes pi's next request and costs nothing — but it clears a
17
+ pending strike, because it is work. A stop keeps the counters: only a new human message starts a
18
+ new budget.
19
+ - Per-request injection of the authoritative open-task list through the `context` hook,
20
+ never persisted, closing the post-compaction context hole
21
+ ([rpiv-mono#222](https://github.com/juicesharp/rpiv-mono/issues/222)) and the "model
22
+ never calls the todo tool's `list` action" failure ([rpiv-mono#159](https://github.com/juicesharp/rpiv-mono/issues/159)).
23
+ - Hidden continuation marker (`custom_message`, `display: false`, constant 21-character body
24
+ `<todo_loop_continue/>`, ~200 bytes as a session entry). Markers are kept in history: they sit
25
+ between assistant turns and are what keeps the conversation alternating. A marker at the tail is
26
+ replaced by the state block, so a request never ends on two user-role messages.
27
+ - Task state read from `rpiv-todo-state` custom entries with a `todo` toolResult
28
+ `details.tasks` fallback, the newer of the two winning; never throws inside a turn
29
+ boundary, and a task with an unusable id falls back to its position instead of vanishing.
30
+ - `/todoloop` command: `status`, `pause`, `resume`, `reset`.
31
+ - Kill switch: `~/.pi/agent/todo-loop-off` or `.pi/todo-loop-off`.
32
+ - Config at `~/.pi/agent/todo-loop.json`; invalid values fall back to defaults, and numbers are
33
+ clamped (`maxRounds` ≤ 1000, `maxStrikes` ≤ 100, `maxInjectedTasks` ≤ 50).
34
+ - The documentation is split: `README.md` is a user-facing page (what it does, quick start, one example,
35
+ configuration, commands, troubleshooting, uninstall), and `docs/behaviour.md` holds the mechanics it
36
+ used to inline — marker lifetime, role alternation, counter semantics, reason strings, scope and cost.
37
+ - `scripts/pack-json.mjs` normalises `npm pack --json`, whose shape differs between npm 10
38
+ (array) and npm 11 (object keyed by package name); the publish workflow upgrades npm, so
39
+ both are exercised.
40
+ - Tests, runnable with no dependencies: decision policy, state reader, injection renderer, config
41
+ loader, the pi-facing hooks through a stub `pi`, a captured real branch, and documentation tests
42
+ that pin the tested-version table, the invariant map, the reason table, the configuration table and
43
+ README link targets.
44
+ - `test/state-machine.test.ts`: the counters as a table of event sequences in pi's own vocabulary
45
+ (`settle`, `tool`, `edit`, `human`, pause/resume/reset, error/abort, kill switch), asserting
46
+ continuations, markers, rounds, strikes, the `/todoloop` reason and header at once. Re-introducing any
47
+ of the three historical budget defects fails it.
48
+ - The continuation marker's constant body states the instruction ("Continue with the open todo
49
+ tasks…") instead of a self-closing tag, so `injectState: false` no longer continues with no
50
+ instruction at all, and a marker left in history is self-explanatory.
51
+ - A list that goes open → closed in a turn whose only tool results were `todo` is announced once: the
52
+ loop's success state and its failure state are the same observable.
53
+ - A stale-list burn now says so: the round-cap reason appends `, list unchanged for N settles`.
54
+ - Task text is sanitised before it enters a request — subject, `activeForm` **and `status`**: every
55
+ Unicode line terminator flattens, `<` is escaped, controls/bidi/zero-width and unpaired surrogates are
56
+ dropped, and labels truncate by code point, so a task subject cannot close the injection tag or forge a directive. Verified against a
57
+ crafted subject that closed the tag and inserted a `SYSTEM:` line — the structure broke before the
58
+ fix and the model refused both times.
59
+ - `.github/workflows/publish.yml`: tag-triggered publish through npm Trusted Publishing (OIDC, no
60
+ `NPM_TOKEN`, automatic provenance), gated on the suite, the typecheck, `pack:check`, a tag/version
61
+ match and a packed-manifest assertion; creates the GitHub release from the CHANGELOG section.
62
+ - `scripts/check-pack.mjs` as `npm run pack:check`: asserts the published tarball really contains the
63
+ entry point, sources, docs, licence and `tsconfig.json`, and that no dev-only path (`test/`,
64
+ `.github/`) leaks into it (`npm pack --dry-run` exits 0 otherwise).
65
+
66
+ [0.1.0]: https://github.com/DumbMice/pi-todo-loop/releases/tag/v0.1.0
@@ -0,0 +1,4 @@
1
+ # Contributing
2
+
3
+ See [AGENTS.md](AGENTS.md) for the repository layout, tests, design invariants, documentation
4
+ conventions and release process.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DumbMice
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,183 @@
1
- # Temporary Holding Version
1
+ # @dumbmice/pi-todo-loop
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/@dumbmice/pi-todo-loop)](https://www.npmjs.com/package/@dumbmice/pi-todo-loop)
4
+ [![license](https://img.shields.io/npm/l/@dumbmice/pi-todo-loop)](https://github.com/DumbMice/pi-todo-loop/blob/main/LICENSE)
5
+ [![node](https://img.shields.io/node/v/@dumbmice/pi-todo-loop)](https://github.com/DumbMice/pi-todo-loop/blob/main/package.json)
6
+ [![tests](https://github.com/DumbMice/pi-todo-loop/actions/workflows/test.yml/badge.svg)](https://github.com/DumbMice/pi-todo-loop/actions/workflows/test.yml)
7
+
8
+ Keeps the [pi coding agent](https://pi.dev) working while its todo list still has open tasks.
9
+
10
+ `rpiv-todo` gives the agent a task list and shows it to you. It never asks pi for another turn, so the
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.
14
+
15
+ ## Quick start
16
+
17
+ ```bash
18
+ pi install npm:@juicesharp/rpiv-todo # the list this reads (skip if installed)
19
+ pi install npm:@dumbmice/pi-todo-loop
20
+ ```
21
+
22
+ Then start a session and ask for something with several steps:
23
+
24
+ ```text
25
+ Use the todo tool to plan this, then work through it: make the reports page
26
+ export CSV, add tests, and update the docs.
27
+ ```
28
+
29
+ There is nothing else to configure. `/todoloop` shows what it is doing; `touch
30
+ ~/.pi/agent/todo-loop-off` turns it off.
31
+
32
+ ## What it looks like
33
+
34
+ ```text
35
+ you: make the whole sequence web-editable, then expose it in the browser
36
+
37
+ pi: [todo: 12 tasks created, first marked in_progress]
38
+ pi: T5 backend is in and verified. Committing a report of the state …
39
+ ← without this extension the run ends here, with 11 tasks open.
40
+
41
+ pi: [todo: T5 completed, T6 in_progress]
42
+ pi: T6 audio/face lanes wired. T7 per-camera PNG export next …
43
+
44
+ pi: [todo: all 12 completed]
45
+ pi: All 12 tasks are complete; here is what was verified …
46
+ ← the list is clear, so the loop stops and stays stopped.
47
+ ```
48
+
49
+ It stops for three reasons, and `/todoloop` always says which:
50
+
51
+ | Why it stopped | What you see |
52
+ |---|---|
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 |
56
+
57
+ ## When it helps
58
+
59
+ - 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
+ - A model that runs tools but rarely updates its list. It will still be nudged; it may keep working to
62
+ the round cap.
63
+
64
+ ## When it does not
65
+
66
+ - **No todo tool installed.** The extension is inert: nothing continues, nothing is injected.
67
+ - **You want an autonomous agent that invents its own goals.** This drives the list you have; it stops
68
+ after 12 continuations per message by default.
69
+ - **You want the continuations visible.** They are silent by design.
70
+ - **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.
72
+
73
+ It buys *liveness*, not work. A model can satisfy the loop by closing tasks, including deleting
74
+ placeholders, and a list that is never updated looks the same as one that is finished. Clearing the list
75
+ is the only thing that stops it, so the list has to mean something to you.
76
+
77
+ ## Configuration
78
+
79
+ Optional, at `~/.pi/agent/todo-loop.json`:
80
+
81
+ ```json
82
+ {
83
+ "maxRounds": 12,
84
+ "maxStrikes": 2,
85
+ "injectState": true,
86
+ "maxInjectedTasks": 6
87
+ }
88
+ ```
89
+
90
+ | Key | Default | What it does |
91
+ |---|---|---|
92
+ | `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
+ A missing or broken file falls back to these values; a bad config never fails a turn.
98
+
99
+ ## Commands
100
+
101
+ | Command | What it does |
102
+ |---|---|
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 |
105
+ | `/todoloop resume` | allow the next turn to continue again |
106
+ | `/todoloop reset` | clear the counters and start the budget over |
107
+
108
+ **Kill switch** — checked before every decision:
109
+
110
+ ```bash
111
+ touch ~/.pi/agent/todo-loop-off # everywhere
112
+ touch .pi/todo-loop-off # this project only
113
+ ```
114
+
115
+ ## Is it working?
116
+
117
+ After a response that made no tool calls while tasks are open, `/todoloop` should read something like:
118
+
119
+ ```
120
+ todo-loop active
121
+ rounds 1/12 · strikes 0/2
122
+ open 2/5 tracked · inject on
123
+ anchor 4f3c1a2e
124
+ last: continuing: round 1/12, 2 open
125
+ config ~/.pi/agent/todo-loop.json
126
+ ```
127
+
128
+ To count continuations in a session file:
129
+
130
+ ```bash
131
+ grep -c 'todo-loop:continue' ~/.pi/agent/sessions/<project>/*.jsonl
132
+ ```
133
+
134
+ ## Troubleshooting
135
+
136
+ | Symptom | What to do |
137
+ |---|---|
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 |
141
+ | Rounds jump by two, or continuations appear twice | Two continuation extensions are installed; `pi list` shows them, remove one, then `/reload` |
142
+ | The list is stale | The extension nudges, it does not write statuses. Nothing here fabricates progress |
143
+ | 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 |
144
+
145
+ ## Uninstall
146
+
147
+ ```bash
148
+ pi remove npm:@dumbmice/pi-todo-loop
149
+ rm -f ~/.pi/agent/todo-loop.json
150
+ rm -f ~/.pi/agent/todo-loop-off .pi/todo-loop-off
151
+ ```
152
+
153
+ Then `/reload`. Nothing was ever written to your working tree, and old session files keep only the
154
+ hidden continuation entries.
155
+
156
+ ## Requirements
157
+
158
+ - **pi 1.5.0** (tested; needs the `turn_end` boundary).
159
+ - **A todo tool** named `todo` that stores its list in the session — tested with
160
+ `@juicesharp/rpiv-todo` 2.15.0.
161
+ - **Node 22.19+**, the floor pi itself requires. The extension has no dependencies.
162
+
163
+ Last verified 2026-11-04; the exact table and what was checked is in
164
+ [docs/pi-api-alignment.md](docs/pi-api-alignment.md).
165
+
166
+ Install only one continuation extension: a second one that also requests turns makes the counters and
167
+ the budget meaningless.
168
+
169
+ ## Further reading
170
+
171
+ - **How it behaves, precisely** — counters, markers, the injected block, cost:
172
+ [docs/behaviour.md](docs/behaviour.md)
173
+ - **pi API alignment** — the deliberate workarounds: [docs/pi-api-alignment.md](docs/pi-api-alignment.md)
174
+ - **Working on the repository**: [AGENTS.md](AGENTS.md)
175
+ - **Changes by version**: [CHANGELOG.md](CHANGELOG.md)
176
+ - **Why it exists** — the upstream issues:
177
+ [rpiv-mono#159](https://github.com/juicesharp/rpiv-mono/issues/159),
178
+ [#222](https://github.com/juicesharp/rpiv-mono/issues/222),
179
+ [#259](https://github.com/juicesharp/rpiv-mono/issues/259)
180
+
181
+ ## License
182
+
183
+ MIT. Source and issues: <https://github.com/DumbMice/pi-todo-loop>.