@hank-warren/pi-loop 0.6.0 → 0.7.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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # @hank-warren/pi-loop
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2ca10d6: Companion skill, one turn cap, model-proposed criteria, and a fairer evidence gate.
8
+
9
+ - The package is now a hybrid: it ships a `pi-loop` skill (`skills/pi-loop/SKILL.md`) alongside the extension, loaded on demand via one prompt-guideline line in each of `loop_start` and `loop_complete`. It carries the judgment the engine cannot encode — writing objectives that become falsifiable criteria, what the evidence gate accepts, when to `loop_wait`, and when work belongs in no loop at all. Nothing skill-related enters any stored loop message or the system append.
10
+ - **Breaking:** the two caps collapse into one. `maxIterations` (delivered fallback wakes) and `automaticTurns` (loop-caused turns) become a single `maxTurns` (default 25) counting every turn the loop causes — continuations and pokes. `--max` and the `loop_start` `max` parameter now set this turn cap instead of the wake cap. Settings files and persisted in-flight loops carrying the old keys migrate automatically, adopting the tighter of the pair; the next settings save rewrites them as `maxTurns`. The poke header drops its `n/cap` denominator (wakes are still counted and shown in `/loop status`; they cap nothing).
11
+ - `loop_start` accepts an optional `criteria` array (at most 12 entries of at most 500 characters; a malformed list refuses the start). It replaces the deterministic grammar split when that split would misfire — context sentences becoming gate criteria, or several requirements packed into one sentence. Accepted only at start, echoed to the user, and frozen afterwards exactly like a derived set. Typed `/loop` starts are unchanged, and a restored loop now keeps the `criteria.json` it finds on disk instead of re-deriving it.
12
+ - `loop_complete` no longer refuses terse-but-real evidence: the twelve-character floor rejected citations like `404 → 200`. Evidence is now refused only when every word in it is a claim word ("done", "verified", "passes", …) or the value is under four characters.
13
+
3
14
  ## 0.6.0
4
15
 
5
16
  ### Minor Changes
package/README.md CHANGED
@@ -19,6 +19,7 @@ pi-plan-mode's `plan-mode-state` is read fail-open — the only sibling state le
19
19
  ```
20
20
 
21
21
  - **Intervals** are `<number><unit>` with unit `s`/`m`/`h`/`d`, parsed by the extension (never the model), minimum 1 minute (smaller values clamp, and the effective value is echoed).
22
+ - **`--max`** caps the turns the loop causes — continuations and pokes alike (see [One cap](#one-cap)); `--max unlimited` opts out.
22
23
  - Loops are **user-driven only**: the model may start one *you* invoked inline (below), and never on its own initiative.
23
24
 
24
25
  ### Inline invocation
@@ -60,14 +61,13 @@ A reply that starts or ends with `LOOP_OK` and carries at most **300 characters*
60
61
 
61
62
  The chip is **display-only**. The stored message keeps its exact bytes, because rewriting them would break the prompt cache this whole design is built around.
62
63
 
63
- ### Two counters
64
+ ### One cap
64
65
 
65
- One wake now yields many turns, so a single counter cannot bound a loop:
66
+ `maxTurns` (default 25, `--max`, settings) counts **every turn the loop caused** settle continuations plus fallback pokes — and stops the loop when it is reached. `null` means unlimited.
66
67
 
67
- - `maxIterations` (default 25, `--max`, settings) counts **delivered wakes**fallback pokes only.
68
- - `automaticTurns` (default 25, settings) counts **turns the loop caused** — continuations plus pokes. This is the cap that actually bounds a settle-paced loop, which can run its whole life without a single wake.
68
+ There used to be two caps: this one, and a `maxIterations` counter of **delivered wakes**. The wake cap bounded nothing the turn cap did not, because a settle-paced loop can run its whole life without delivering a single wake so in practice the turn cap was always the one that tripped, and the second cap cost a settings field, a decision branch, a state field, and a paragraph of documentation to say so. Wakes are still counted and shown (`/loop status`); they just cap nothing.
69
69
 
70
- Either cap trips independently and stops the loop; `null` on either means unlimited.
70
+ A settings file or an in-flight loop still carrying the old pair keeps the **tighter** of the two: that is the bound it was already running under, and nobody has to rewrite settings to keep a cap they already chose.
71
71
 
72
72
  ## What a wakeup does
73
73
 
@@ -77,17 +77,17 @@ Each tick — fallback heartbeat or settled boundary — evaluates, in order:
77
77
  2. **Plan mode active?** Skip quietly; never inject prompts into a planning conversation.
78
78
  3. **Agent busy?** Never interrupt: coalesce into a single pending wake delivered at the next fully-settled idle boundary. N missed ticks collapse into one poke.
79
79
  4. **A declared wait?** A `loop_wait` whose deadline has not passed holds both drivers: the loop is not stalled, it is waiting on the world.
80
- 5. **Caps** (see [Two counters](#two-counters)): stop.
80
+ 5. **The turn cap** (see [One cap](#one-cap)): stop.
81
81
  6. **Settled boundary:** dispatch the recorded continuation — a pointer-sized message (`⟳ loop continue #6`) that points at the system prompt for the objective, exactly as the pokes do.
82
82
  7. **Fallback heartbeat: poke.** The wake header, why it fired (stalled, or a wait that has elapsed), and the loop focus when set. Every poke carries a marker (`<!-- pi-loop-poke:<id>:<n> -->`) so a wakeup is identifiable as loop-injected rather than user-typed. The marker is **provenance only** — pi-loop coalesces wakes in its own state and never reads the marker back to drop a delivery.
83
83
 
84
84
  **A poke never restates the objective.** The objective reaches the model through a byte-stable system append on the same turn, and duplicating it in the message would store another copy on every wake. That works because pokes are delivered as ordinary user messages, which pass through `before_agent_start`; a delivery path that bypassed it (for example `pi.sendMessage({triggerTurn})`, which calls the agent directly) would arrive with no append and would have to carry the objective again. The token-lean contract is pinned in `test/messages.test.ts`.
85
85
 
86
- In the transcript, a poke renders as a one-line chip (`⏰ loop wake 4/25 · stalled`) via a markdown transformer. That hook is display-only by Pi's contract — the stored message and the model's context are untouched.
86
+ In the transcript, a poke renders as a one-line chip (`⏰ loop wake 4 · stalled`) via a markdown transformer. That hook is display-only by Pi's contract — the stored message and the model's context are untouched.
87
87
 
88
- Expiry and the caps are evaluated whenever the session settles, so a loop ends as soon as the work does rather than at the next scheduled tick. Only the fallback heartbeat pokes; a settle continues.
88
+ Expiry and the cap are evaluated whenever the session settles, so a loop ends as soon as the work does rather than at the next scheduled tick. Only the fallback heartbeat pokes; a settle continues.
89
89
 
90
- The footer status shows `loop 5m · 3/25 · next 14:32`, and a widget above the editor shows the same state with the loop focus beneath it; `/loop status` shows the full card including the last tick's decision and reason.
90
+ The footer status shows `loop 5m · 5/25 · next 14:32` (loop turns against the cap), and a widget above the editor shows the same state with the loop focus beneath it; `/loop status` shows the full card including the last tick's decision and reason.
91
91
 
92
92
  ## Objective, injection, and `loop_complete`
93
93
 
@@ -106,7 +106,7 @@ So completion is gated on the loop's own `criteria.json`. The tool takes a requi
106
106
 
107
107
  - a criterion has no entry (the refusal names each one, and marks those `criteria.json` still records as unmet);
108
108
  - an entry cites an id that is not in the file (inventing ids does not satisfy the gate);
109
- - an entry asserts completion instead of citing it ("done", "verified", anything under a dozen characters).
109
+ - an entry asserts completion instead of citing it: every word in it is a claim word ("done", "ok", "verified", "passes", "green", …), punctuation and case ignored, or the whole value is under four characters. One word the blocklist does not know — a command, a number, a filename — makes it specific, so terse citations like `404 → 200` and `tests: 0 fail` pass. (The floor used to be twelve characters, which refused both of those.)
110
110
 
111
111
  The gate is deliberately **mechanical**: it cannot judge whether evidence is *good*, only that the model was made to look at every requirement and say something specific about each. The rules that make the citation worth anything — audit requirement by requirement, authoritative state over transcript, weak or merely consistent evidence is not enough, **effort exhaustion is not completion** — live in the tool description and the system append. With no readable `criteria.json` the gate degrades to "cite at least one specific thing", because the ledger is fail-open everywhere else too.
112
112
 
@@ -124,20 +124,22 @@ Without it a loop has exactly one answer to "progress depends on something outsi
124
124
 
125
125
  A wait **holds both drivers** — no settle continuation, no fallback poke — but does **not** pause the loop and does **not** cancel the pacemaker: it supersedes the next fallback wake, so a wait whose event never arrives still ends in a wake rather than in silence. The deadline timer is generation-guarded and re-armed on session start, so a deadline that passed while the session was away is due immediately.
126
126
 
127
- A wake delivered for an elapsed wait **counts against `maxIterations`**, so a model that keeps re-arming a wait cannot run forever.
127
+ The turn a wake delivers for an elapsed wait **counts against `maxTurns`** like any other, so a model that keeps re-arming a wait cannot run forever.
128
128
 
129
129
  There is deliberately **no cancel tool**. The events that legitimately cancel a wait (you typing, an earlier wake arriving) are not the model's to report — so when one of them ends a wait, its reason rides along once on the next loop message as `Previous wait (cancelled): …` and is then dropped.
130
130
 
131
131
  ## Breakers
132
132
 
133
133
  - **No progress.** The characteristic failure of an autonomous loop is not crashing, it is *restating*: the same paragraph of "here is what I would do next", turn after turn, calling no tools. pi-loop fingerprints the visible assistant text (SHA-256 over NFKC-normalised, case- and whitespace-folded text) of every tool-free loop-caused turn; `noProgressTurns` consecutive repeats (default 3, settings-tunable, `null` disables) **pause** the loop rather than stopping it — it stays configured, the widget says why, and `/loop resume` or your next message continues it with a fresh safety epoch. A turn that called **any** tool, including `loop_wait`, is progress by definition and resets the counter; counting a declared wait is the false positive that made this class of breaker infamous.
134
- - **Interruption classification.** A loop that answers every provider failure with "continue" retries into exhausted quotas and re-sends requests too large to succeed. So each class gets its own answer: usage/billing exhaustion **pauses** (retrying a quota window that has not reset just burns the caps), an unrecoverable auth error **pauses**, an aborted loop turn (`Esc`) **pauses**, a context overflow **compacts and then continues** regardless of what the usage gauge says — the failed request just disproved that reading — and a transient error simply continues, because the next continuation *is* the retry.
134
+ - **Interruption classification.** A loop that answers every provider failure with "continue" retries into exhausted quotas and re-sends requests too large to succeed. So each class gets its own answer: usage/billing exhaustion **pauses** (retrying a quota window that has not reset just burns the cap), an unrecoverable auth error **pauses**, an aborted loop turn (`Esc`) **pauses**, a context overflow **compacts and then continues** regardless of what the usage gauge says — the failed request just disproved that reading — and a transient error simply continues, because the next continuation *is* the retry.
135
135
 
136
136
  ## The loop ledger
137
137
 
138
138
  A multi-day loop cannot keep its state in the conversation: compaction is lossy by construction, and a summary of a summary drifts further from what happened every time. So the conversation stays the working memory, and two files become the record — under `~/.pi/agent/loop/<loop-id>/` (keyed by **loop id**: session ids are not stably exposed to extensions, and one session can run several loops in sequence):
139
139
 
140
- - **`criteria.json`** — this loop's completion criteria, derived from the objective when the loop starts (bullets if you wrote a list, otherwise sentences, otherwise one implicit criterion) and echoed back to you so you can see what `loop_complete` will answer for. JSON deliberately, not Markdown: models rewrite prose they are asked to maintain far more readily than they rewrite a structured file. The model may change **only** the `passes` field, only with cited evidence, and may never add, remove, or reword an entry — a model allowed to rewrite its own acceptance criteria eventually rewrites them into something it has already achieved.
140
+ - **`criteria.json`** — this loop's completion criteria, derived from the objective when the loop starts (bullets if you wrote a list, otherwise sentences, otherwise one implicit criterion) and echoed back to you so you can see what `loop_complete` will answer for. JSON deliberately, not Markdown: models rewrite prose they are asked to maintain far more readily than they rewrite a structured file. The model may change **only** the `passes` field, only with cited evidence, and may never add, remove, or reword an entry — a model allowed to rewrite its own acceptance criteria eventually rewrites them into something it has already achieved. A restore keeps the file it finds: re-deriving it on every session start would reset the flips the loop had earned.
141
+
142
+ **The one exception is at start.** The derivation is grammar, not comprehension, so `/loop fix CI. it has been red since Tuesday.` yields a `c2` demanding cited evidence for a piece of background. So `loop_start` takes an optional `criteria` array (≤ 12 entries, ≤ 500 characters each, no blank entries — a malformed list is refused rather than quietly ignored), and when the model passes one it replaces the split. That is the *only* place a model may write criteria: at start there is no work yet to grade, the criteria are echoed to you immediately, and the incentive to write an easy gate is at its weakest — which is exactly untrue at completion time, where they stay immutable. The typed `/loop` command never takes them; there is no model in that path.
141
143
  - **`PROGRESS.md`** — the agent-maintained ledger, created with a fixed four-section schema (current status / completed / **failed approaches and why** / next actions) so "update the ledger" means the same thing on every turn. Failed approaches matter most: nothing else remembers them once the conversation is compacted.
142
144
 
143
145
  Both are **best-effort**. An unwritable home directory, a full disk, or a file hand-edited into invalid JSON degrades the loop to "no ledger" with a single warning; it never breaks the loop. `PROGRESS.md` is created and then never overwritten, so a session restart cannot erase days of ledger.
@@ -196,8 +198,7 @@ Flags: `--run` (headless instead of in-session), `--cwd <path>`, `--max <n|unlim
196
198
 
197
199
  ```json
198
200
  {
199
- "maxIterations": 25,
200
- "automaticTurns": 25,
201
+ "maxTurns": 25,
201
202
  "noProgressTurns": 3,
202
203
  "maxLoopDuration": "7d",
203
204
  "inlineInvocation": true,
@@ -210,7 +211,7 @@ Flags: `--run` (headless instead of in-session), `--cwd <path>`, `--max <n|unlim
210
211
  }
211
212
  ```
212
213
 
213
- `maxIterations: null` and `automaticTurns: null` mean unlimited; `noProgressTurns: null` disables the breaker. `inlineInvocation: false` turns off mid-prompt `/loop` detection; `defaultInterval` is the fallback heartbeat an inline invocation gets when it names none.
214
+ `maxTurns: null` means unlimited; `noProgressTurns: null` disables the breaker. A file still carrying the superseded `maxIterations`/`automaticTurns` pair loads, keeping the tighter of them, and the next save rewrites it as `maxTurns`. `inlineInvocation: false` turns off mid-prompt `/loop` detection; `defaultInterval` is the fallback heartbeat an inline invocation gets when it names none.
214
215
 
215
216
  ## Deliberate omissions
216
217
 
@@ -220,6 +221,14 @@ These were considered and cut, and the reasoning is recorded so they are not sil
220
221
  - **No judge model.** Grading completion with a second model is a larger, more expensive change than the criteria/evidence gate; the gate is the rung that ships.
221
222
  - **No `loop_blocked` tool.** `loop_wait` covers a real external dependency, and the no-progress breaker covers an impasse the model does not recognise as one. A third "I give up" tool mostly gives a model a way to stop early. `compaction.instructions` overrides the built-in template.
222
223
 
224
+ ## The companion skill
225
+
226
+ The package is a hybrid: it ships the extension **and** a `pi-loop` skill (`skills/pi-loop/SKILL.md`), which carries the judgment the engine cannot encode — how an objective becomes falsifiable criteria, what the evidence gate accepts as a citation, when to declare a `loop_wait` instead of polling, what `PROGRESS.md` is worth, and when the work belongs in `/schedule` or in no loop at all.
227
+
228
+ It is **loaded on demand**: one line in each of `loop_start` and `loop_complete`'s prompt guidelines points at it by name, exactly as `pi-processes` does, and the model reads the body when it judges it needs it. Nothing about the guidance enters a stored loop message or the system append — those bytes are the cache prefix, and `test/bytes.test.ts` fails if any of them so much as mentions a skill.
229
+
230
+ Skill and extension version as one artifact on purpose: a skill describing an engine the installed extension does not have is the coupling failure this repository already learned once.
231
+
223
232
  ## Install
224
233
 
225
234
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-loop",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Long-running work for Pi: settle-paced loops with a durable ledger, adaptive waits, no-progress breakers, evidence-gated completion, and a task scheduler.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -29,11 +29,15 @@
29
29
  "pi": {
30
30
  "extensions": [
31
31
  "./index.ts"
32
+ ],
33
+ "skills": [
34
+ "./skills/pi-loop"
32
35
  ]
33
36
  },
34
37
  "files": [
35
38
  "index.ts",
36
39
  "src",
40
+ "skills",
37
41
  "README.md",
38
42
  "LICENSE",
39
43
  "CHANGELOG.md"
@@ -0,0 +1,112 @@
1
+ ---
2
+ name: pi-loop
3
+ description: Write loop objectives that become falsifiable criteria, and complete a loop with evidence the gate accepts. Use before the first loop_start of a session, before assembling loop_complete evidence, and when deciding whether work belongs in a loop at all.
4
+ license: MIT
5
+ ---
6
+
7
+ # pi-loop
8
+
9
+ The engine handles pacing, wakes, the cap, compaction, and the ledger. It cannot handle the two things that decide whether a loop is worth running: **what end state the objective names**, and **what evidence proves it**. That is this file.
10
+
11
+ Nothing here changes how you invoke a loop. `loop_start` still refuses unless the user's message carried an inline `/loop` or `loop:` token — that gate is enforcement, not guidance.
12
+
13
+ ## The objective is not a prompt, it is an acceptance test
14
+
15
+ At `loop_start` the extension splits the objective into `criteria.json` by default, deterministically and without a model:
16
+
17
+ - **Bullets or a numbered list** (2+) → one criterion per item.
18
+ - Otherwise **sentences** → one criterion per sentence.
19
+ - Otherwise **the whole objective** → one implicit criterion.
20
+
21
+ Up to 12 criteria; each gets an id (`c1`, `c2`, …) and `passes: false`. `loop_complete` later demands **one cited piece of evidence per id**. So the objective's grammar decides the shape of the gate — and a conjunction *inside a sentence* does **not** split. "Fix the flaky test and update the docs" is a single criterion whose evidence must cover both halves; nothing will remind you of the second half. When the work has several requirements, write them as separate sentences or bullets on purpose — or pass the criteria yourself (below).
22
+
23
+ **You pass the user's objective text verbatim** — never a cleaned-up version of it. So the criteria are only as falsifiable as what was typed, and the evidence gate cannot rescue them later: it checks that you said something specific about every criterion, not that the criterion was worth checking.
24
+
25
+ What you can do is say so **at the start**, in the same turn as `loop_start`. When the objective names no observable end state, start the loop as invoked and then, in one or two lines, name the end state you will hold yourself to and the check that will prove it — and tell the user that `/loop` can edit the objective if that is not what they meant. Record the same statement in `PROGRESS.md`. Do not silently invent an easier target and discover the disagreement at completion.
26
+
27
+ | Vague as typed | The end state to name back |
28
+ | --- | --- |
29
+ | `make the tests better` | raise `packages/foo` line coverage above 80%, proven by `npm run coverage` |
30
+ | `fix CI` | the CI workflow green on this branch, proven by `gh pr checks` reporting all checks passed |
31
+ | `clean up the parser` | the duplicated token table gone from `src/parse.ts`, with `npm test` still passing |
32
+ | `investigate the memory leak` | no end state exists yet — see *When not to start a loop* |
33
+
34
+ Two rules follow from the split:
35
+
36
+ 1. **The evidence is best named in the objective itself.** "…, verified by `npm test` passing" pre-commits the citation, so completion is a lookup instead of an argument. Where the objective names no check, the criterion's `check` field is empty and you must audit against authoritative current state. When you suggest a `/loop` invocation to the user, write it that way.
37
+ 2. **Keep each criterion checkable by one observation.** A criterion that needs three different commands to prove is three criteria — that is a reason to suggest bullets.
38
+
39
+ The criteria are echoed back at start. If they came out wrong, that is visible *then*: say so, rather than discovering it at completion.
40
+
41
+ ### When the split would misfire: propose the criteria
42
+
43
+ The split is grammar, not comprehension. `/loop fix CI. it has been red since Tuesday.` gives you a `c2` demanding cited evidence that CI has been red since Tuesday — background, not a requirement. So `loop_start` takes an optional **`criteria`** array (at most 12 entries, at most 500 characters each, no blank entries; a malformed list is refused, not ignored). When you pass one it *replaces* the split, with ids `c1`…`cN` exactly as if it had been derived.
44
+
45
+ Use it only when the split would misfire:
46
+
47
+ - the objective **mixes requirements with context** ("fix CI. it has been red since Tuesday." → one criterion: get CI green on main);
48
+ - the objective **packs several requirements into one sentence** ("fix the flaky test and update the docs" → two criteria, so neither half can be forgotten).
49
+
50
+ Every criterion you write must be a **faithful restatement of something the user asked for** — never fewer, never weaker, never easier than the objective as typed, and never a requirement they did not state. **When in doubt, omit the parameter** and let the deterministic split stand: an awkward criterion you must answer for is safer than a tidy one you wrote for yourself.
51
+
52
+ This is the only point where you may write criteria at all. Once the loop starts they are frozen — you may flip `passes`, nothing else — and the user has already seen them echoed.
53
+
54
+ ## Completing: cite, do not assert
55
+
56
+ `loop_complete` refuses when:
57
+
58
+ - a criterion id is missing from `evidence` (the refusal names each one, and flags those `criteria.json` still records as unmet);
59
+ - an id appears that is not in `criteria.json`;
60
+ - an entry asserts rather than cites — *every* word in it is a claim word ("done", "ok", "verified", "passes", "green", "n/a", …), with punctuation and case ignored, so "Done." and "verified, passed" are refused too; or the whole value is under four characters.
61
+
62
+ One word the blocklist does not know — a command, a number, a filename — makes the value specific, so a terse citation is fine: `404 → 200` and `tests: 0 fail` both pass. Terseness is not the problem; asserting is.
63
+
64
+ Evidence is **the command and what it printed**, **the file and what it now contains**, **the URL and its state**. Previous conversation, your own plan, and a summary of a summary are context, not proof — and after a compaction they are a summary of a summary.
65
+
66
+ ```
67
+ c1: "npm test → 214 passing, 0 failing (packages/pi-loop/test, full run at 14:02)"
68
+ c2: "gh pr checks 171 → all 3 checks passed (ci/gate, ci/typecheck, ci/scan-secrets)"
69
+ ```
70
+
71
+ Audit requirement by requirement before calling it, and match the verification scope to the requirement scope: a criterion about the whole suite is not proven by one test file. **Effort exhaustion is not completion** — running long, running out of ideas, or nearing a cap is never a reason to call the tool. If the criteria are not met, keep working; another continuation is coming.
72
+
73
+ ## Waiting on the world
74
+
75
+ When progress depends on something outside the session — a CI run, a deploy, a human reply — call `loop_wait` with a one-sentence reason. Do not spend continuations re-checking, and never sleep in a shell to pass time.
76
+
77
+ - `resume_after_ms` is clamped to **[60s, 1h]** and the clamped value is echoed back. Omit it to stay quiet until something else wakes the session.
78
+ - Avoid ~300s: that is the prompt-cache dead zone, where the cache has just expired and the next turn re-reads the whole conversation at full price. Use ≤270s only when actively polling external state nothing else reports; otherwise commit to 1200s or more.
79
+ - The turn a wake delivers for an elapsed wait counts against the cap like any other, so re-arming a wait forever is not free.
80
+ - Never wait for what Pi already notifies you about: background processes, subagents, and tool completions wake the session on their own.
81
+
82
+ `loop_wait` is for a genuine external event, never a way to end a turn early with work outstanding.
83
+
84
+ ## PROGRESS.md is what a stopped loop is worth
85
+
86
+ The ledger lives at `~/.pi/agent/loop/<loop-id>/`, and `PROGRESS.md` has four fixed sections: current status, completed, **failed approaches and why**, next actions. It is created from a template; **write your first real update in the turn that starts the loop**, and keep updating it as you work rather than at the end. A ledger still holding the template is a loop with no memory.
87
+
88
+ The failed-approaches section carries the most value, because it is the only thing that survives compaction and the only thing that stops the next continuation — or the next engineer — from re-running an experiment that already failed. "Tried X, it failed because Y" is the whole point; "tried several things" is worth nothing.
89
+
90
+ `criteria.json` sits next to it. You may flip an entry's `passes` field when authoritative evidence proves it, citing that evidence in `PROGRESS.md`. You may **never** add, remove, reword, or re-id an entry: a model that can rewrite its acceptance criteria eventually rewrites them into something it has already achieved.
91
+
92
+ Both files are best-effort. If the ledger could not be created the loop still runs; it just has no memory outside the conversation.
93
+
94
+ ## What actually ends a loop
95
+
96
+ - **The turn cap** (`maxTurns`, default 25, `--max`) counts every turn the loop caused — continuations *and* pokes. It is the only cap: a settle-paced loop can run its whole life without delivering a single fallback wake, so counting wakes bounded nothing. Wakes are still counted and shown in `/loop status`; they cap nothing.
97
+ - **Expiry** (default 7 days, `--expires`) grants one final turn to write state into `PROGRESS.md` — no new work, no completion claim.
98
+ - **The no-progress breaker** pauses the loop after 3 consecutive tool-free turns with the same visible text. Restating a plan is the characteristic failure of an autonomous loop; calling any tool, including `loop_wait`, resets it.
99
+
100
+ The interval is a fallback heartbeat, not the pacemaker — the loop advances when the session settles. So an interval argument is worth much less thought than the objective.
101
+
102
+ ## When not to start a loop
103
+
104
+ An inline invocation *permits* a loop; it does not oblige you to start one. When the work is a bad fit, say so in one line and offer the alternative instead:
105
+
106
+ - **Recurring cadence** ("check the release queue every morning") → `/schedule`, which owns cron, one-shots, and headless runs. A loop pursues one objective until it is done; it is not a timer.
107
+ - **Genuine open exploration** ("figure out why memory grows") → do the investigation in the conversation. A loop's value is the gate at the end; an investigation has no end state to gate on, so the loop only supplies unwanted turns. Once the investigation names a fix, *that* is a loop objective.
108
+ - **Work that finishes this turn** → just do it, and say why no loop was needed.
109
+
110
+ A vague-but-real objective is not in this list: start it, and name the end state back (above).
111
+
112
+ A loop is self-continuing: starting one wrongly does not produce one bad answer, it produces turns until a cap.
package/src/command.ts CHANGED
@@ -23,13 +23,19 @@ export interface LoopStartArguments {
23
23
  requestedMs: number;
24
24
  intervalMs: number;
25
25
  clamped: boolean;
26
- /** undefined = use settings default; null = unlimited. */
27
- maxIterations?: number | null;
26
+ /** Loop-caused-turn cap: undefined = use settings default; null = unlimited. */
27
+ maxTurns?: number | null;
28
28
  /** undefined = use settings default; null = disabled for this loop. */
29
29
  compactAt?: number | null;
30
30
  /** Per-loop lifetime in ms; undefined = use the settings default. */
31
31
  expiresInMs?: number;
32
32
  prompt?: string;
33
+ /**
34
+ * Completion criteria proposed at `loop_start`, replacing the deterministic
35
+ * split of the objective. Only the tool sets this: a typed `/loop` always
36
+ * splits, because there is no model in that path to propose anything.
37
+ */
38
+ criteria?: string[];
33
39
  }
34
40
 
35
41
  export type LoopCommand =
@@ -70,7 +76,7 @@ export function parseLoopCommand(args: string): LoopCommand {
70
76
  // that quietly did not apply.
71
77
  const afterInterval = scanFlags(tokens, beforeInterval + 1, flags);
72
78
  if (typeof afterInterval !== "number") return afterInterval;
73
- const { maxIterations, compactAt, expiresInMs } = flags;
79
+ const { maxTurns, compactAt, expiresInMs } = flags;
74
80
 
75
81
  const promptToken = tokens[afterInterval];
76
82
  const prompt = promptToken === undefined ? undefined : args.slice(promptToken.index).trim();
@@ -79,7 +85,7 @@ export function parseLoopCommand(args: string): LoopCommand {
79
85
  requestedMs: interval.requestedMs,
80
86
  intervalMs: interval.effectiveMs,
81
87
  clamped: interval.clamped,
82
- ...(maxIterations === undefined ? {} : { maxIterations }),
88
+ ...(maxTurns === undefined ? {} : { maxTurns }),
83
89
  ...(compactAt === undefined ? {} : { compactAt }),
84
90
  ...(expiresInMs === undefined ? {} : { expiresInMs }),
85
91
  ...(prompt ? { prompt } : {}),
@@ -87,7 +93,7 @@ export function parseLoopCommand(args: string): LoopCommand {
87
93
  }
88
94
 
89
95
  interface LoopFlags {
90
- maxIterations?: number | null;
96
+ maxTurns?: number | null;
91
97
  compactAt?: number | null;
92
98
  expiresInMs?: number;
93
99
  }
@@ -109,7 +115,10 @@ function scanFlags(
109
115
  const consumed = inlineValue !== undefined ? 1 : 2;
110
116
  if (flag === "--max") {
111
117
  if (value === undefined) {
112
- return { kind: "error", message: "--max needs a value (a positive number, or unlimited)." };
118
+ return {
119
+ kind: "error",
120
+ message: "--max needs a value (a positive number of loop turns, or unlimited).",
121
+ };
113
122
  }
114
123
  const parsed = parseMax(value);
115
124
  if (parsed === undefined) {
@@ -118,7 +127,7 @@ function scanFlags(
118
127
  message: `Invalid --max value: ${value}. Use a positive whole number or unlimited.`,
119
128
  };
120
129
  }
121
- flags.maxIterations = parsed;
130
+ flags.maxTurns = parsed;
122
131
  } else if (flag === "--compact-at") {
123
132
  if (value === undefined) {
124
133
  return { kind: "error", message: "--compact-at needs a value (e.g. 60% or off)." };
@@ -31,22 +31,47 @@ export const LOOP_COMPLETE_TOOL = "loop_complete";
31
31
 
32
32
  const MAX_SUMMARY_LENGTH = 4_000;
33
33
  const MAX_EVIDENCE_LENGTH = 4_000;
34
- /** Shorter than this is an assertion, not a citation. */
35
- const MIN_EVIDENCE_LENGTH = 12;
34
+ /**
35
+ * Shorter than this cannot be a citation of anything. Deliberately tiny: the
36
+ * floor used to be twelve characters, which refused `404 → 200` and
37
+ * `tests: 0 fail` — terse, specific, and exactly the evidence the gate wants.
38
+ * Length was never the signal; the claim words below are.
39
+ */
40
+ const MIN_EVIDENCE_LENGTH = 4;
41
+ /**
42
+ * Words that assert rather than cite. A value made of nothing but these is a
43
+ * claim of completion, however many of them are strung together.
44
+ */
36
45
  const EMPTY_EVIDENCE = new Set([
37
- "done",
38
- "ok",
39
- "okay",
40
- "yes",
41
- "true",
42
- "passes",
43
- "passed",
46
+ "all",
44
47
  "complete",
45
48
  "completed",
46
- "verified",
47
49
  "confirmed",
50
+ "done",
51
+ "everything",
52
+ "fine",
53
+ "fixed",
54
+ "good",
55
+ "green",
56
+ "it",
57
+ "is",
58
+ "met",
48
59
  "n/a",
49
60
  "none",
61
+ "nothing",
62
+ "now",
63
+ "okay",
64
+ "ok",
65
+ "passed",
66
+ "passes",
67
+ "passing",
68
+ "success",
69
+ "successful",
70
+ "true",
71
+ "verified",
72
+ "working",
73
+ "works",
74
+ "yes",
50
75
  ]);
51
76
 
52
77
  export function registerLoopCompleteTool(pi: ExtensionAPI, controller: LoopController) {
@@ -67,6 +92,7 @@ export function registerLoopCompleteTool(pi: ExtensionAPI, controller: LoopContr
67
92
  "Pass the exact loop_id from the active /loop objective in the system prompt. A mismatched id means the loop changed and the call is refused.",
68
93
  "loop_complete stops scheduled wakeups only. It does not mean an unrelated goal or task is complete.",
69
94
  "If the criteria are not met, do not call it: keep working and expect another continuation.",
95
+ "Before your first loop_complete this session, read the pi-loop skill: it describes what the evidence gate accepts as a citation and what it refuses.",
70
96
  ],
71
97
  parameters: Type.Object({
72
98
  loop_id: Type.String({
@@ -192,10 +218,25 @@ export function auditEvidence(
192
218
  return undefined;
193
219
  }
194
220
 
221
+ /**
222
+ * Does this evidence value cite something, or merely assert it?
223
+ *
224
+ * Mechanical and predictable on purpose — the per-criterion-id enumeration is
225
+ * the real gate, and a heuristic that guesses at quality would refuse real
226
+ * citations. So exactly two things are refused: a value too short to say
227
+ * anything, and a value in which *every* word is one of the claim words, with
228
+ * punctuation and case ignored ("Done.", "verified, passed"). One word the
229
+ * blocklist does not know — a command, a number, a filename — is enough to
230
+ * make the value specific, which is what the gate is asking for.
231
+ */
195
232
  function isSubstantive(value: string): boolean {
196
- const trimmed = value.trim();
197
- if (trimmed.length < MIN_EVIDENCE_LENGTH) return false;
198
- return !EMPTY_EVIDENCE.has(trimmed.toLowerCase().replace(/[.!]+$/u, ""));
233
+ const words = value
234
+ .trim()
235
+ .split(/\s+/u)
236
+ .map((word) => word.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, ""))
237
+ .filter(Boolean);
238
+ if (words.length === 0 || value.trim().length < MIN_EVIDENCE_LENGTH) return false;
239
+ return !words.every((word) => EMPTY_EVIDENCE.has(word.toLowerCase()));
199
240
  }
200
241
 
201
242
  function toolContent(text: string) {
package/src/decide.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * handler for a lost continuation or an external wait, not the pacemaker.
10
10
  *
11
11
  * Both share one precedence prefix: loop liveness → expiry → plan mode →
12
- * compaction → busy → wait → caps → act.
12
+ * compaction → busy → wait → the turn cap → act.
13
13
  *
14
14
  * A loop owns its own objective and reads no other extension's state: it ends
15
15
  * only through `loop_complete`, a cap, an expiry, or the user.
@@ -49,14 +49,14 @@ export type TickDecision =
49
49
  | { action: "none"; reason: "loop-not-active" }
50
50
  | { action: "expire"; reason: ExpiryReason }
51
51
  | { action: "skip"; reason: SkipReason }
52
- | { action: "stop"; reason: "max-iterations" | "max-automatic-turns" }
52
+ | { action: "stop"; reason: "max-turns" }
53
53
  | { action: "poke"; reason: "objective-stalled" | "wait-elapsed" };
54
54
 
55
55
  export type ContinuationDecision =
56
56
  | { action: "none"; reason: "loop-not-active" }
57
57
  | { action: "expire"; reason: ExpiryReason }
58
58
  | { action: "skip"; reason: SkipReason }
59
- | { action: "stop"; reason: "max-iterations" | "max-automatic-turns" }
59
+ | { action: "stop"; reason: "max-turns" }
60
60
  | { action: "continue"; reason: "settled-idle" };
61
61
 
62
62
  /** Shared prefix: everything that holds or ends a loop before caps matter. */
@@ -80,17 +80,14 @@ function decideCommonPrefix(
80
80
  }
81
81
 
82
82
  /**
83
- * Caps are checked in a fixed order so a loop that trips both reports the
84
- * wake cap first it is the one the user configured with `--max`.
83
+ * The one cap, counting every turn the loop caused: continuations and pokes
84
+ * alike. A delivered-wake cap sat next to it until it was collapsed into
85
+ * this one — in a settle-paced loop the wake counter can stay at zero for the
86
+ * loop's whole life, so it was never the ceiling that held.
85
87
  */
86
- function decideCaps(
87
- loop: LoopState,
88
- ): { action: "stop"; reason: "max-iterations" | "max-automatic-turns" } | undefined {
89
- if (loop.maxIterations !== null && loop.iteration >= loop.maxIterations) {
90
- return { action: "stop", reason: "max-iterations" };
91
- }
92
- if (loop.maxAutomaticTurns !== null && loop.automaticTurns >= loop.maxAutomaticTurns) {
93
- return { action: "stop", reason: "max-automatic-turns" };
88
+ function decideCap(loop: LoopState): { action: "stop"; reason: "max-turns" } | undefined {
89
+ if (loop.maxTurns !== null && loop.automaticTurns >= loop.maxTurns) {
90
+ return { action: "stop", reason: "max-turns" };
94
91
  }
95
92
  return undefined;
96
93
  }
@@ -103,7 +100,7 @@ function decideCaps(
103
100
  export function decideContinuation(loop: LoopState, env: TickEnvironment): ContinuationDecision {
104
101
  const prefix = decideCommonPrefix(loop, env);
105
102
  if (prefix) return prefix;
106
- const capped = decideCaps(loop);
103
+ const capped = decideCap(loop);
107
104
  if (capped) return capped;
108
105
  return { action: "continue", reason: "settled-idle" };
109
106
  }
@@ -111,10 +108,10 @@ export function decideContinuation(loop: LoopState, env: TickEnvironment): Conti
111
108
  export function decideTick(loop: LoopState, env: TickEnvironment): TickDecision {
112
109
  const prefix = decideCommonPrefix(loop, env);
113
110
  if (prefix) return prefix;
114
- const capped = decideCaps(loop);
111
+ const capped = decideCap(loop);
115
112
  if (capped) return capped;
116
113
  // The prefix already let a still-waiting loop skip, so a wait surviving to
117
114
  // here is one whose deadline has come due: this wake is the wake it asked
118
- // for, and it counts against the wake cap like any other.
115
+ // for, and the turn it starts counts against the cap like any other.
119
116
  return { action: "poke", reason: loop.waiting ? "wait-elapsed" : "objective-stalled" };
120
117
  }
package/src/ledger.ts CHANGED
@@ -30,9 +30,9 @@ export const LEDGER_DIR_NAME = "loop";
30
30
  export const CRITERIA_FILE = "criteria.json";
31
31
  export const PROGRESS_FILE = "PROGRESS.md";
32
32
 
33
- /** Cap on derived criteria: an objective is a paragraph, not a backlog. */
34
- const MAX_CRITERIA = 12;
35
- const MAX_DESCRIPTION_LENGTH = 500;
33
+ /** Cap on a loop's criteria: an objective is a paragraph, not a backlog. */
34
+ export const MAX_CRITERIA = 12;
35
+ export const MAX_DESCRIPTION_LENGTH = 500;
36
36
 
37
37
  export interface LoopCriterion {
38
38
  id: string;
@@ -69,7 +69,20 @@ export function deriveCriteria(objective: string): LoopCriterion[] {
69
69
  .filter(Boolean);
70
70
  const parts = bullets.length > 1 ? bullets : splitSentences(trimmed);
71
71
  if (parts.length < 2) return [implicitCriterion(trimmed)];
72
- return parts.slice(0, MAX_CRITERIA).map((description, index) => ({
72
+ return criteriaFromDescriptions(parts);
73
+ }
74
+
75
+ /**
76
+ * Number a list of descriptions into criteria.
77
+ *
78
+ * Shared by the deterministic split and by the criteria a model may propose
79
+ * at `loop_start`, so nothing downstream — the echo at start, the evidence
80
+ * gate, the immutability rule — can tell the two apart. The extension still
81
+ * writes every field but the description: ids are positional, `check` is
82
+ * empty (audit against authoritative state), and a criterion starts unmet.
83
+ */
84
+ export function criteriaFromDescriptions(descriptions: readonly string[]): LoopCriterion[] {
85
+ return descriptions.slice(0, MAX_CRITERIA).map((description, index) => ({
73
86
  id: `c${index + 1}`,
74
87
  description: truncate(description),
75
88
  check: "",
package/src/loop.ts CHANGED
@@ -37,9 +37,11 @@ import {
37
37
  import { formatClock, formatDuration, MAX_INTERVAL_MS, parseDuration } from "./interval.js";
38
38
  import {
39
39
  createLedger,
40
+ criteriaFromDescriptions,
40
41
  deriveCriteria,
41
42
  type LedgerPaths,
42
43
  ledgerPaths,
44
+ type LoopCriterion,
43
45
  readCriteria,
44
46
  } from "./ledger.js";
45
47
  import {
@@ -570,14 +572,21 @@ export class LoopController {
570
572
  * Create (or adopt) the loop's ledger. Best-effort by design: a loop with
571
573
  * no writable ledger still runs, it just loses the durable record, so the
572
574
  * failure is warned once and never repeated.
575
+ *
576
+ * `criteria` is passed at start: the criteria proposed at `loop_start`, or
577
+ * the deterministic split of the objective. On restore it is omitted, and
578
+ * the criteria already on disk are authoritative — they are the ones the
579
+ * user saw echoed, and re-deriving them would both discard a proposed set
580
+ * and reset whatever `passes` flips the loop has earned.
573
581
  */
574
- private openLedger(loop: LoopState): void {
582
+ private openLedger(loop: LoopState, criteria?: LoopCriterion[]): void {
575
583
  if (loop.objective === undefined) {
576
584
  this.ledger = undefined;
577
585
  return;
578
586
  }
579
587
  const paths = ledgerPaths(loop.id, this.agentDir);
580
- const failure = createLedger(paths, loop.objective, deriveCriteria(loop.objective));
588
+ const contents = criteria ?? readCriteria(paths) ?? deriveCriteria(loop.objective);
589
+ const failure = createLedger(paths, loop.objective, contents);
581
590
  if (failure) {
582
591
  this.ledger = undefined;
583
592
  if (!this.ledgerWarned) {
@@ -757,12 +766,7 @@ export class LoopController {
757
766
  this.transition("stopped", "loop expired (the expiry was reached)");
758
767
  return;
759
768
  case "stop":
760
- this.transition(
761
- "stopped",
762
- decision.reason === "max-automatic-turns"
763
- ? `the ${loop.maxAutomaticTurns}-automatic-turn cap was reached`
764
- : `the ${loop.maxIterations}-iteration cap was reached`,
765
- );
769
+ this.transition("stopped", `the ${loop.maxTurns}-turn cap was reached`);
766
770
  return;
767
771
  }
768
772
  }
@@ -809,9 +813,9 @@ export class LoopController {
809
813
 
810
814
  /**
811
815
  * Send first, then account. Pi can refuse the delivery (a busy or compacting
812
- * session), and an iteration persisted before the send would burn the
813
- * maxIterations cap on a poke that never arrived; on a throw the loop re-arms
814
- * on the same cadence and retries at the next wake.
816
+ * session), and a turn persisted before the send would burn the cap on a
817
+ * poke that never arrived; on a throw the loop re-arms on the same cadence
818
+ * and retries at the next wake.
815
819
  */
816
820
  private deliverPoke(now: number, reason: "objective-stalled" | "wait-elapsed"): void {
817
821
  const loop = this.state;
@@ -961,7 +965,7 @@ export class LoopController {
961
965
  );
962
966
  return;
963
967
  }
964
- const cap = loop.maxIterations === null ? "∞" : `${loop.maxIterations}`;
968
+ const cap = loop.maxTurns === null ? "∞" : `${loop.maxTurns}`;
965
969
  const next = this.wakePending
966
970
  ? "next on idle"
967
971
  : this.nextWakeAt
@@ -969,7 +973,7 @@ export class LoopController {
969
973
  : "next unscheduled";
970
974
  ui.setStatus(
971
975
  LOOP_STATUS_KEY,
972
- `loop ${formatDuration(loop.intervalMs)} · ${loop.iteration}/${cap} · ${next}`,
976
+ `loop ${formatDuration(loop.intervalMs)} · ${loop.automaticTurns}/${cap} · ${next}`,
973
977
  );
974
978
  }
975
979
 
@@ -991,8 +995,8 @@ export class LoopController {
991
995
  ? [`Cancelled wait (reported on the next wake): ${loop.cancelledWaitReason}`]
992
996
  : []),
993
997
  `Interval: every ${formatDuration(loop.intervalMs)}`,
994
- `Wakes: ${loop.iteration}${loop.maxIterations === null ? " (unlimited)" : ` of ${loop.maxIterations}`}`,
995
- `Automatic turns: ${loop.automaticTurns}${loop.maxAutomaticTurns === null ? " (unlimited)" : ` of ${loop.maxAutomaticTurns}`}`,
998
+ `Loop turns: ${loop.automaticTurns}${loop.maxTurns === null ? " (unlimited)" : ` of ${loop.maxTurns}`}`,
999
+ `Fallback wakes delivered: ${loop.iteration}`,
996
1000
  `Started: ${new Date(loop.startedAt).toLocaleString()}`,
997
1001
  `Expires: ${new Date(loop.expiresAt).toLocaleString()}`,
998
1002
  `Proactive compaction: ${loop.compactAt === null ? "off" : `at ${Math.round(loop.compactAt * 100)}% of context`}`,
@@ -1079,9 +1083,7 @@ export class LoopController {
1079
1083
  status: "active",
1080
1084
  objective,
1081
1085
  intervalMs: start.intervalMs,
1082
- maxIterations:
1083
- start.maxIterations !== undefined ? start.maxIterations : this.settings.maxIterations,
1084
- maxAutomaticTurns: this.settings.automaticTurns,
1086
+ maxTurns: start.maxTurns !== undefined ? start.maxTurns : this.settings.maxTurns,
1085
1087
  compactAt,
1086
1088
  iteration: 0,
1087
1089
  automaticTurns: 0,
@@ -1093,7 +1095,10 @@ export class LoopController {
1093
1095
  this.continuationIntent = undefined;
1094
1096
  this.noOpStreak = 0;
1095
1097
  this.ledgerWarned = false;
1096
- this.openLedger(this.state);
1098
+ this.openLedger(
1099
+ this.state,
1100
+ start.criteria ? criteriaFromDescriptions(start.criteria) : deriveCriteria(objective),
1101
+ );
1097
1102
  this.persist();
1098
1103
  this.scheduleTick(start.intervalMs);
1099
1104
  this.updateWidget();
package/src/manager.ts CHANGED
@@ -154,8 +154,7 @@ export async function showLoopSettings(
154
154
  for (;;) {
155
155
  const s = controller.settings;
156
156
  const items = [
157
- `Max wakes: ${s.maxIterations === null ? "Unlimited" : s.maxIterations}`,
158
- `Max automatic turns: ${s.automaticTurns === null ? "Unlimited" : s.automaticTurns}`,
157
+ `Max loop turns: ${s.maxTurns === null ? "Unlimited" : s.maxTurns}`,
159
158
  `No-progress breaker: ${s.noProgressTurns === null ? "Off" : `after ${s.noProgressTurns} repeats`}`,
160
159
  `Max loop duration: ${s.maxLoopDuration}`,
161
160
  `Proactive compaction: ${s.compaction.enabled ? `On at ${Math.round(s.compaction.threshold * 100)}%` : "Off"}`,
@@ -167,14 +166,10 @@ export async function showLoopSettings(
167
166
  if (index === 0) {
168
167
  // Unlimited is a first-class choice, not a magic word typed into a free
169
168
  // text box: it is only reachable by discovery otherwise.
170
- const cap = await editCap(ctx, "Max wakes", "no wake cap", s.maxIterations);
169
+ const cap = await editCap(ctx, "Max loop turns", "no turn cap", s.maxTurns);
171
170
  if (cap === undefined) continue;
172
- next.maxIterations = cap === "unlimited" ? null : cap;
171
+ next.maxTurns = cap === "unlimited" ? null : cap;
173
172
  } else if (index === 1) {
174
- const cap = await editCap(ctx, "Max automatic turns", "no turn cap", s.automaticTurns);
175
- if (cap === undefined) continue;
176
- next.automaticTurns = cap === "unlimited" ? null : cap;
177
- } else if (index === 2) {
178
173
  const cap = await editCap(
179
174
  ctx,
180
175
  "No-progress breaker",
@@ -183,7 +178,7 @@ export async function showLoopSettings(
183
178
  );
184
179
  if (cap === undefined) continue;
185
180
  next.noProgressTurns = cap === "unlimited" ? null : cap;
186
- } else if (index === 3) {
181
+ } else if (index === 2) {
187
182
  const value = await ctx.ui.input("Max loop duration (e.g. 7d)", s.maxLoopDuration);
188
183
  if (value === undefined) continue;
189
184
  if (parseDuration(value.trim()) === undefined) {
@@ -191,7 +186,7 @@ export async function showLoopSettings(
191
186
  continue;
192
187
  }
193
188
  next.maxLoopDuration = value.trim();
194
- } else if (index === 4) {
189
+ } else if (index === 3) {
195
190
  if (s.compaction.enabled) next.compaction.enabled = false;
196
191
  else {
197
192
  const value = await ctx.ui.input(
@@ -216,7 +211,7 @@ export async function showLoopSettings(
216
211
  }
217
212
 
218
213
  /**
219
- * One cap editor for both counters. Unlimited is a first-class choice, not a
214
+ * One cap editor for every cap. Unlimited is a first-class choice, not a
220
215
  * magic word typed into a free text box: it is only reachable by discovery
221
216
  * otherwise. The typed word still works, so the /loop --max vocabulary and
222
217
  * muscle memory keep working.
package/src/messages.ts CHANGED
@@ -15,23 +15,30 @@ import type { LoopState } from "./state.js";
15
15
  /** Why the loop is talking: the first turn, an ordinary turn, or after a compaction. */
16
16
  export type ContinuationKind = "kickoff" | "continue" | "reanchor";
17
17
 
18
- function formatIteration(loop: LoopState): string {
19
- const cap = loop.maxIterations === null ? "unlimited" : `${loop.maxIterations}`;
20
- return `${loop.iteration + 1}/${cap}`;
18
+ /**
19
+ * The wake ordinal, and only the ordinal. It used to read `4/25`, the wake
20
+ * counter against the delivered-wake cap; that cap is gone, collapsed into
21
+ * the single loop-turn cap, and pairing a wake number with a turn cap would
22
+ * have been a number that reads as a budget and is not one. The cap is shown
23
+ * to the *user*, in the widget and `/loop status`, which is who it is for.
24
+ */
25
+ function formatWakeOrdinal(loop: LoopState): string {
26
+ return `${loop.iteration + 1}`;
21
27
  }
22
28
 
23
29
  /**
24
30
  * The poke. Deliberately slim: the loop's own objective injection puts the
25
31
  * objective and loop-mode rules in the system prompt of every turn, so
26
32
  * restating them here would store a duplicate copy on every wake. Only the
27
- * dynamic per-wake state (iteration, reason) belongs in this tail message.
33
+ * dynamic per-wake state (the wake ordinal, the reason) belongs in this tail
34
+ * message.
28
35
  */
29
36
  export function buildObjectivePoke(
30
37
  loop: LoopState,
31
38
  reason: "objective-stalled" | "wait-elapsed" = "objective-stalled",
32
39
  ): string {
33
40
  const lines = [
34
- `Scheduled loop wakeup ${formatIteration(loop)} (every ${formatDuration(loop.intervalMs)}).`,
41
+ `Scheduled loop wakeup ${formatWakeOrdinal(loop)} (every ${formatDuration(loop.intervalMs)}).`,
35
42
  reason === "wait-elapsed"
36
43
  ? "The wait you asked for has elapsed. Re-check the external state it depended on and continue — the objective and loop-mode rules are in the system prompt."
37
44
  : "The session went idle but the loop objective's completion criteria are not met. Continue working it — the objective and loop-mode rules are in the system prompt.",
package/src/settings.ts CHANGED
@@ -21,15 +21,22 @@ export interface LoopCompactionSettings {
21
21
  instructions: string | null;
22
22
  }
23
23
 
24
+ /**
25
+ * The cap fields this one replaced: a delivered-wake cap (`maxIterations`,
26
+ * `--max`) and a loop-caused-turn cap (`automaticTurns`). A settle-paced loop
27
+ * can run its whole life without delivering a single fallback wake, so the
28
+ * wake cap bounded nothing the turn cap did not already bound.
29
+ */
30
+ const LEGACY_CAP_KEYS = ["maxIterations", "automaticTurns"] as const;
31
+
24
32
  export interface LoopSettings {
25
- /** Delivered-wake cap; null means unlimited (explicit opt-in). */
26
- maxIterations: number | null;
27
33
  /**
28
- * Cap on turns the loop itself causes (settle continuations plus fallback
29
- * pokes); null means unlimited. A settle-paced loop can run many turns per
30
- * wake, so this is the bound that actually holds it.
34
+ * Cap on the turns the loop itself causes (settle continuations plus
35
+ * fallback pokes); null means unlimited (explicit opt-in). The only cap
36
+ * there is: one wake can yield many turns, so counting turns is what
37
+ * actually bounds a loop.
31
38
  */
32
- automaticTurns: number | null;
39
+ maxTurns: number | null;
33
40
  /**
34
41
  * Consecutive tool-free loop turns with identical output that pause the
35
42
  * loop; null disables the breaker.
@@ -54,8 +61,7 @@ export interface LoopSettings {
54
61
  }
55
62
 
56
63
  export const DEFAULT_LOOP_SETTINGS: LoopSettings = {
57
- maxIterations: 25,
58
- automaticTurns: 25,
64
+ maxTurns: 25,
59
65
  noProgressTurns: 3,
60
66
  maxLoopDuration: "7d",
61
67
  inlineInvocation: true,
@@ -76,11 +82,8 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
76
82
  const record = ownRecord(value);
77
83
  if (!record) return undefined;
78
84
 
79
- const maxIterations = normalizeCap(record.maxIterations, DEFAULT_LOOP_SETTINGS.maxIterations);
80
- if (maxIterations === false) return undefined;
81
-
82
- const automaticTurns = normalizeCap(record.automaticTurns, DEFAULT_LOOP_SETTINGS.automaticTurns);
83
- if (automaticTurns === false) return undefined;
85
+ const maxTurns = normalizeTurnCap(record);
86
+ if (maxTurns === false) return undefined;
84
87
 
85
88
  const noProgressTurns = normalizeCap(
86
89
  record.noProgressTurns,
@@ -134,8 +137,7 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
134
137
  }
135
138
 
136
139
  return {
137
- maxIterations,
138
- automaticTurns,
140
+ maxTurns,
139
141
  noProgressTurns,
140
142
  maxLoopDuration,
141
143
  inlineInvocation,
@@ -151,6 +153,32 @@ function normalizeCap(value: unknown, fallback: number | null): number | null |
151
153
  return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : false;
152
154
  }
153
155
 
156
+ /**
157
+ * The turn cap, accepting the two caps it replaced.
158
+ *
159
+ * A settings file written by an older version names no `maxTurns`, and asking
160
+ * users to rewrite their settings to keep a cap they already chose is not a
161
+ * trade worth making. So a file carrying only the legacy keys keeps the
162
+ * tighter of the two: that is the bound their loops were already running
163
+ * under. An invalid value in either key still fails the whole file closed,
164
+ * exactly as it did when the key was current.
165
+ */
166
+ function normalizeTurnCap(record: Record<string, unknown>): number | null | false {
167
+ if (Object.hasOwn(record, "maxTurns")) {
168
+ return normalizeCap(record.maxTurns, DEFAULT_LOOP_SETTINGS.maxTurns);
169
+ }
170
+ let adopted: number | null | undefined;
171
+ for (const key of LEGACY_CAP_KEYS) {
172
+ if (!Object.hasOwn(record, key)) continue;
173
+ const cap = normalizeCap(record[key], DEFAULT_LOOP_SETTINGS.maxTurns);
174
+ if (cap === false) return false;
175
+ // null is unlimited, so it only wins when every legacy cap is unlimited.
176
+ if (adopted === undefined || adopted === null) adopted = cap;
177
+ else if (cap !== null) adopted = Math.min(adopted, cap);
178
+ }
179
+ return adopted === undefined ? DEFAULT_LOOP_SETTINGS.maxTurns : adopted;
180
+ }
181
+
154
182
  function readBoolean(record: Record<string, unknown>, key: string, fallback: boolean): unknown {
155
183
  return Object.hasOwn(record, key) ? record[key] : fallback;
156
184
  }
@@ -221,11 +249,14 @@ export function saveLoopSettings(settings: LoopSettings, settingsPath = loopSett
221
249
  }
222
250
 
223
251
  const compaction = ownRecord(raw.compaction) ?? {};
252
+ // Unknown fields are preserved, but the two caps `maxTurns` replaced are not
253
+ // unknown: leaving them next to a cap that supersedes them would show the
254
+ // user two numbers where only one applies.
255
+ for (const key of LEGACY_CAP_KEYS) delete raw[key];
224
256
  const document = `${JSON.stringify(
225
257
  {
226
258
  ...raw,
227
- maxIterations: normalized.maxIterations,
228
- automaticTurns: normalized.automaticTurns,
259
+ maxTurns: normalized.maxTurns,
229
260
  noProgressTurns: normalized.noProgressTurns,
230
261
  maxLoopDuration: normalized.maxLoopDuration,
231
262
  inlineInvocation: normalized.inlineInvocation,
package/src/start-tool.ts CHANGED
@@ -15,6 +15,14 @@
15
15
  * until a cap. So the tool refuses outright unless the inline hint armed for
16
16
  * the turn that is calling it.
17
17
  *
18
+ * The one thing this path may decide that the `/loop` command cannot is the
19
+ * loop's completion criteria. They are otherwise a deterministic split of the
20
+ * objective's grammar, which turns a context sentence into a gate criterion;
21
+ * a model that read the objective can do better. It is accepted only *here*,
22
+ * at start, before any work exists to grade and with the user seeing the
23
+ * criteria echoed back — the point where the incentive to write an easy gate
24
+ * is weakest. After start they are immutable, exactly as a derived set is.
25
+ *
18
26
  * Registered unconditionally, like the other loop tools: the tool set is part
19
27
  * of the cached request prefix, so it never changes with loop state.
20
28
  */
@@ -23,6 +31,7 @@ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
23
31
  import { Type } from "typebox";
24
32
  import { LOOP_COMPLETE_TOOL } from "./complete-tool.js";
25
33
  import { formatDuration, parseDuration, parseInterval } from "./interval.js";
34
+ import { MAX_CRITERIA, MAX_DESCRIPTION_LENGTH } from "./ledger.js";
26
35
  import type { InlineInvocationState } from "./inline-invocation.js";
27
36
  import type { LoopController } from "./loop.js";
28
37
 
@@ -49,7 +58,11 @@ export function registerLoopStartTool(
49
58
  "If the user is discussing, quoting, or documenting the /loop command rather than invoking it — asking how it works, pasting a transcript, or editing text that mentions it — do not call loop_start.",
50
59
  "Pass the objective text that follows the token, without the token itself. A leading interval (`10m`, `2h`) and flags like `--max 5` or `--expires 3d` become the interval, max, and expires parameters, not part of the objective.",
51
60
  "Call loop_start before doing any of the objective's work, then continue working toward it in the same turn.",
61
+ "Leave the criteria parameter out by default: the extension splits the objective into completion criteria on its own (bullets, else sentences, else the whole objective). Propose criteria only when that split would misfire — when the objective mixes requirements with context sentences (`fix CI. it has been red since Tuesday.`), or packs several requirements into one sentence.",
62
+ "Every criterion you propose must be a faithful restatement of something the user asked for: never fewer, weaker, or easier than the objective as typed, and never a requirement they did not state. They are echoed back to the user at start and frozen afterwards — you may only ever flip a criterion's passes field.",
63
+ "When in doubt, omit criteria and let the deterministic split stand.",
52
64
  "Never call loop_complete in the same turn as loop_start: the starting turn has not done the work, and completion needs cited evidence per criterion.",
65
+ "Before your first loop_start this session, read the pi-loop skill: the objective is split into the completion criteria this loop will be gated on, so its wording is the leverage point.",
53
66
  ],
54
67
  parameters: Type.Object({
55
68
  objective: Type.String({
@@ -67,7 +80,8 @@ export function registerLoopStartTool(
67
80
  max: Type.Optional(
68
81
  Type.Integer({
69
82
  minimum: 1,
70
- description: "Wake cap from a --max flag in the invocation.",
83
+ description:
84
+ "Cap on the turns the loop causes (continuations and pokes), from a --max flag in the invocation.",
71
85
  }),
72
86
  ),
73
87
  expires: Type.Optional(
@@ -75,6 +89,17 @@ export function registerLoopStartTool(
75
89
  description: "Loop lifetime from an --expires flag in the invocation, e.g. '3d'.",
76
90
  }),
77
91
  ),
92
+ criteria: Type.Optional(
93
+ Type.Array(
94
+ Type.String({ minLength: 1, maxLength: MAX_DESCRIPTION_LENGTH }),
95
+ {
96
+ minItems: 1,
97
+ maxItems: MAX_CRITERIA,
98
+ description:
99
+ "Optional completion criteria for this loop, each one checkable requirement restated faithfully from the user's objective. Replaces the deterministic split of the objective, so omit it unless that split would misfire.",
100
+ },
101
+ ),
102
+ ),
78
103
  }),
79
104
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
80
105
  // The gate. Everything below is ordinary validation; this is the
@@ -89,6 +114,14 @@ export function registerLoopStartTool(
89
114
  if (!objective) {
90
115
  return refusal("Loop not started: the objective is empty.", {});
91
116
  }
117
+ const criteria = params.criteria?.map((description) => description.trim());
118
+ const badCriteria = criteria && describeBadCriteria(criteria);
119
+ if (badCriteria) {
120
+ return refusal(
121
+ `Loop not started: ${badCriteria}. Pass one short checkable requirement per entry, or omit criteria to split the objective deterministically.`,
122
+ { objective },
123
+ );
124
+ }
92
125
  const existing = controller.state;
93
126
  if (existing && existing.status !== "stopped") {
94
127
  return refusal(
@@ -119,8 +152,9 @@ export function registerLoopStartTool(
119
152
  requestedMs: interval.requestedMs,
120
153
  intervalMs: interval.effectiveMs,
121
154
  clamped: interval.clamped,
122
- ...(params.max === undefined ? {} : { maxIterations: params.max }),
155
+ ...(params.max === undefined ? {} : { maxTurns: params.max }),
123
156
  ...(expiresInMs === undefined ? {} : { expiresInMs }),
157
+ ...(criteria === undefined ? {} : { criteria }),
124
158
  prompt: objective,
125
159
  });
126
160
  if (!result.ok) return refusal(`Loop not started: ${result.message}`, { objective });
@@ -136,6 +170,26 @@ export function registerLoopStartTool(
136
170
  );
137
171
  }
138
172
 
173
+ /**
174
+ * Why a proposed criteria list cannot be used, or undefined when it can.
175
+ *
176
+ * A list that says nothing is worse than no list: it would replace the
177
+ * deterministic split with a gate the model wrote and can pass by saying
178
+ * anything. So a malformed list refuses the start rather than falling back
179
+ * silently, which would leave the model believing its criteria were accepted.
180
+ */
181
+ function describeBadCriteria(criteria: readonly string[]): string | undefined {
182
+ if (criteria.length === 0) return "the criteria list is empty";
183
+ if (criteria.length > MAX_CRITERIA) {
184
+ return `a loop takes at most ${MAX_CRITERIA} criteria and ${criteria.length} were given`;
185
+ }
186
+ if (criteria.some((description) => !description)) return "one of the criteria is blank";
187
+ if (criteria.some((description) => description.length > MAX_DESCRIPTION_LENGTH)) {
188
+ return `a criterion may be at most ${MAX_DESCRIPTION_LENGTH} characters`;
189
+ }
190
+ return undefined;
191
+ }
192
+
139
193
  function refusal(text: string, details: Record<string, unknown>) {
140
194
  return { content: toolContent(text), details, isError: true };
141
195
  }
package/src/state.ts CHANGED
@@ -30,19 +30,17 @@ export interface LoopState {
30
30
  */
31
31
  objective?: string;
32
32
  intervalMs: number;
33
- /** Delivered-poke cap; null means unlimited. */
34
- maxIterations: number | null;
35
- /** Proactive-compaction threshold fraction, or null when disabled per loop. */
36
- compactAt: number | null;
37
33
  /**
38
- * Turns this loop caused; null means unlimited. Counted separately from
39
- * `maxIterations` because one wake now yields many turns: a settle-driven
40
- * continuation chain runs without any wake at all.
34
+ * Cap on the turns this loop causes (continuations plus pokes); null means
35
+ * unlimited. The only cap: a settle-driven continuation chain runs without
36
+ * any wake at all, so a wake cap bounded nothing this one does not.
41
37
  */
42
- maxAutomaticTurns: number | null;
43
- /** Delivered wakes so far (fallback pokes only). */
38
+ maxTurns: number | null;
39
+ /** Proactive-compaction threshold fraction, or null when disabled per loop. */
40
+ compactAt: number | null;
41
+ /** Delivered wakes so far (fallback pokes only); uncapped, and displayed. */
44
42
  iteration: number;
45
- /** Loop-caused turns so far (continuations + pokes). */
43
+ /** Loop-caused turns so far (continuations + pokes): what `maxTurns` caps. */
46
44
  automaticTurns: number;
47
45
  startedAt: number;
48
46
  expiresAt: number;
@@ -91,8 +89,8 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
91
89
  }
92
90
  const intervalMs = record.intervalMs;
93
91
  if (!isPositiveSafeInteger(intervalMs)) return undefined;
94
- const maxIterations = record.maxIterations;
95
- if (maxIterations !== null && !isPositiveSafeInteger(maxIterations)) return undefined;
92
+ const maxTurns = readTurnCap(record);
93
+ if (maxTurns === false) return undefined;
96
94
  const compactAt = record.compactAt;
97
95
  if (
98
96
  compactAt !== null &&
@@ -100,16 +98,12 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
100
98
  ) {
101
99
  return undefined;
102
100
  }
103
- const maxAutomaticTurns = Object.hasOwn(record, "maxAutomaticTurns")
104
- ? record.maxAutomaticTurns
105
- : null;
106
- if (maxAutomaticTurns !== null && !isPositiveSafeInteger(maxAutomaticTurns)) return undefined;
107
101
  const iteration = record.iteration;
108
102
  if (typeof iteration !== "number" || !Number.isSafeInteger(iteration) || iteration < 0) {
109
103
  return undefined;
110
104
  }
111
- // Loops persisted before the two-counter split carry no automaticTurns; an
112
- // absent counter restores as zero rather than rejecting the whole state.
105
+ // A loop persisted before the turn counter existed carries no automaticTurns;
106
+ // an absent counter restores as zero rather than rejecting the whole state.
113
107
  const automaticTurns = Object.hasOwn(record, "automaticTurns") ? record.automaticTurns : 0;
114
108
  if (
115
109
  typeof automaticTurns !== "number" ||
@@ -147,8 +141,7 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
147
141
  ...(prompt === undefined ? {} : { prompt }),
148
142
  ...(objective === undefined ? {} : { objective }),
149
143
  intervalMs,
150
- maxIterations: maxIterations as number | null,
151
- maxAutomaticTurns: maxAutomaticTurns as number | null,
144
+ maxTurns,
152
145
  compactAt: compactAt as number | null,
153
146
  iteration,
154
147
  automaticTurns,
@@ -164,6 +157,33 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
164
157
  };
165
158
  }
166
159
 
160
+ /**
161
+ * The turn cap, adopting the caps a loop persisted by an older version
162
+ * carries: `maxAutomaticTurns` (turns) and `maxIterations` (wakes). An
163
+ * in-flight loop restored mid-upgrade keeps the tighter of them rather than
164
+ * having its bound widened or being dropped as unparsable; its wake *counter*
165
+ * is kept for display but no longer caps anything. Returns the cap, or false
166
+ * when a present value is invalid.
167
+ */
168
+ function readTurnCap(record: Record<string, unknown>): number | null | false {
169
+ if (Object.hasOwn(record, "maxTurns")) {
170
+ const value = record.maxTurns;
171
+ if (value === null) return null;
172
+ return isPositiveSafeInteger(value) ? value : false;
173
+ }
174
+ let adopted: number | null | undefined;
175
+ for (const key of ["maxAutomaticTurns", "maxIterations"]) {
176
+ if (!Object.hasOwn(record, key)) continue;
177
+ const value = record[key];
178
+ if (value !== null && !isPositiveSafeInteger(value)) return false;
179
+ const cap = value as number | null;
180
+ // null is unlimited, so it only wins when every legacy cap is unlimited.
181
+ if (adopted === undefined || adopted === null) adopted = cap;
182
+ else if (cap !== null) adopted = Math.min(adopted, cap);
183
+ }
184
+ return adopted === undefined ? null : adopted;
185
+ }
186
+
167
187
  // --- session-branch entry readers ---
168
188
 
169
189
  interface SessionEntryLike {
package/src/widget.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The loop widget: a compact themed line above the editor mirroring the
3
- * footer status (interval · iteration/cap · next wake), with the loop focus
3
+ * footer status (interval · loop turns/cap · next wake), with the loop focus
4
4
  * dimmed below it when set.
5
5
  *
6
6
  * Presentation only: every entry point tolerates a host without setWidget
@@ -61,13 +61,15 @@ export function clearLoopWidget(ui: WidgetHost) {
61
61
  export function loopWidgetLine(view: LoopWidgetView) {
62
62
  const loop = view.loop;
63
63
  if (loop.status === "paused") return "⏸ loop paused";
64
- const cap = loop.maxIterations === null ? "∞" : `${loop.maxIterations}`;
64
+ const cap = loop.maxTurns === null ? "∞" : `${loop.maxTurns}`;
65
65
  const next = view.wakePending
66
66
  ? "next on idle"
67
67
  : view.nextWakeAt !== undefined
68
68
  ? `next ${formatClock(view.nextWakeAt)}`
69
69
  : "next unscheduled";
70
- return `⟳ loop every ${formatDuration(loop.intervalMs)} · ${loop.iteration}/${cap} · ${next}`;
70
+ // The turn counter, not the wake counter: the cap counts turns, so a
71
+ // progress line against that cap has to count the same thing.
72
+ return `⟳ loop every ${formatDuration(loop.intervalMs)} · ${loop.automaticTurns}/${cap} · ${next}`;
71
73
  }
72
74
 
73
75
  function identity(text: string) {