@hank-warren/pi-loop 0.5.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 +29 -0
- package/README.md +60 -49
- package/package.json +5 -1
- package/skills/pi-loop/SKILL.md +112 -0
- package/src/command.ts +16 -7
- package/src/complete-tool.ts +56 -15
- package/src/decide.ts +30 -81
- package/src/index.ts +24 -15
- package/src/inline-command.ts +159 -0
- package/src/inline-invocation.ts +109 -0
- package/src/ledger.ts +17 -4
- package/src/loop.ts +124 -200
- package/src/manager.ts +18 -28
- package/src/messages.ts +21 -38
- package/src/objective.ts +5 -7
- package/src/render.ts +3 -7
- package/src/settings.ts +83 -21
- package/src/start-tool.ts +199 -0
- package/src/state.ts +56 -116
- package/src/wait-tool.ts +2 -2
- package/src/widget.ts +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
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
|
+
|
|
14
|
+
## 0.6.0
|
|
15
|
+
|
|
16
|
+
### Minor Changes
|
|
17
|
+
|
|
18
|
+
- b642391: Removing goal-bound loops is breaking for anyone holding one; it lands as a 0.x minor per this repo's convention.
|
|
19
|
+
|
|
20
|
+
**Start a loop from an inline `/loop` invocation.** Pi dispatches `/loop` only when it is the first thing in the message, so `quick check /loop 10m get CI green` used to arrive as prose and vanish. A new `loop_start` tool, pointed at by a one-turn `<system-reminder>`, now starts the loop and begins working in the same turn. `loop: <objective>` at line start works the same way. The message itself is never rewritten, split, re-sent, or annotated: an `input` handler only records it, and `before_agent_start` appends the hint when the starting prompt is that same message.
|
|
21
|
+
|
|
22
|
+
`loop_start` **refuses unless the inline hint armed for that turn.** A loop is self-continuing, so a spurious start does not produce one unwanted answer, it produces turns until a cap — that gate is enforcement rather than prompt guidance. It also refuses when a loop is already active, when `loop_complete` is missing from the active tool set, and on an unparsable objective, interval, or expiry.
|
|
23
|
+
|
|
24
|
+
New settings: `inlineInvocation` (default `true`) and `defaultInterval` (default `"10m"`, used when an invocation names no interval).
|
|
25
|
+
|
|
26
|
+
**Goal-bound loops are gone,** along with every `goal-state` reader, the completion-race clear-scan, and the goal outcomes in the tick and continuation decisions. A loop owns its objective, ledger, post-compaction re-anchor, and evidence-gated completion, so delegating completion to a second extension bought nothing and cost a coupling. `plan-mode-state` is now the only sibling entry pi-loop reads.
|
|
27
|
+
|
|
28
|
+
A loop persisted before this release with no objective of its own adopts its focus text as one on restore, and pauses with an explanation when there is nothing to adopt.
|
|
29
|
+
|
|
30
|
+
`@hank-warren/pi-goal` is deprecated in favour of this package. Its published versions stay installable.
|
|
31
|
+
|
|
3
32
|
## 0.5.0
|
|
4
33
|
|
|
5
34
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -2,30 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
Inspired by Claude Code's `/loop`, adapted to Pi: keep work moving across many turns, and keep long loops coherent across context compaction.
|
|
4
4
|
|
|
5
|
-
A loop is a **pacemaker
|
|
5
|
+
A loop is a **pacemaker** that owns its own work: it carries an objective and completion criteria, and ends when the model calls `loop_complete` with cited evidence, a cap is reached, its expiry arrives, or you stop it. **No other extension is required.**
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- **Goal-bound** (**deprecated**) — an active [pi-goal](../pi-goal) goal is present, so the loop binds to it and pi-goal owns *whether the work is done*. Coupling stays read-only: fail-open reads of pi-goal's `goal-state` entries.
|
|
7
|
+
Earlier versions could instead bind a loop to a [pi-goal](https://www.npmjs.com/package/@hank-warren/pi-goal) goal and let that extension own *whether the work is done*. That mode is gone as of 0.6.0, along with every `goal-state` reader: a loop now does everything the pairing did — objective, durable ledger, post-compaction re-anchor, evidence-gated completion — so the coupling bought nothing. A loop persisted before 0.6.0 that carries no objective adopts its focus text as one on restore, and pauses with an explanation when there is nothing to adopt.
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
### Goal-bound loops are going away
|
|
13
|
-
|
|
14
|
-
A standalone loop now does everything the pairing did — it owns its objective, keeps a durable ledger, re-anchors itself after compaction, and gates completion on cited evidence — so delegating "is the work done" to a second extension buys nothing and costs a coupling. [pi-goal](../pi-goal) carries the matching deprecation banner.
|
|
15
|
-
|
|
16
|
-
This release keeps the goal-bound path working, warns when you start one, and **migrates a restored goal-bound loop to standalone** by adopting the goal's objective text (falling back to the loop's own focus text). The one case it deliberately does not migrate is a **still-active** goal: pi-goal is driving that session's continuations, and a standalone loop driving them too would send two messages at every settle — that loop keeps its old behaviour and gets the warning instead. If there is nothing to adopt at all, the loop pauses and says so rather than pretending.
|
|
17
|
-
|
|
18
|
-
The following release removes the goal-bound branch, the `goal-state` readers, and their fixtures.
|
|
19
|
-
|
|
20
|
-
pi-plan-mode's `plan-mode-state` is read the same fail-open way in both modes, so a loop never injects into a planning conversation.
|
|
9
|
+
pi-plan-mode's `plan-mode-state` is read fail-open — the only sibling state left — so a loop never injects into a planning conversation.
|
|
21
10
|
|
|
22
11
|
## Usage
|
|
23
12
|
|
|
24
13
|
```
|
|
25
|
-
/loop 30m until all tests pass and CI is green #
|
|
26
|
-
/goal get CI green # ...or bind to a goal
|
|
27
|
-
/loop 30m # poke that goal every 30m if the session stalls
|
|
28
|
-
/loop 10m recheck the pipeline # trailing text is a per-wake focus when goal-bound
|
|
14
|
+
/loop 30m until all tests pass and CI is green # the trailing text is the objective
|
|
29
15
|
/loop # manager TUI (status, pause/resume, edit, settings, stop)
|
|
30
16
|
/loop status | pause | resume | stop | settings
|
|
31
17
|
/loop --max 20 --compact-at 60% --expires 3d 10m # per-loop overrides
|
|
@@ -33,7 +19,27 @@ pi-plan-mode's `plan-mode-state` is read the same fail-open way in both modes, s
|
|
|
33
19
|
```
|
|
34
20
|
|
|
35
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).
|
|
36
|
-
-
|
|
22
|
+
- **`--max`** caps the turns the loop causes — continuations and pokes alike (see [One cap](#one-cap)); `--max unlimited` opts out.
|
|
23
|
+
- Loops are **user-driven only**: the model may start one *you* invoked inline (below), and never on its own initiative.
|
|
24
|
+
|
|
25
|
+
### Inline invocation
|
|
26
|
+
|
|
27
|
+
Pi dispatches `/loop` only when it is the first thing in the message, so `quick check /loop 10m get CI green` arrives as ordinary prose and nothing happens. That is the common way to lose an invocation, so pi-loop detects it:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
quick check /loop 10m get CI green on main, verified by a passing run
|
|
31
|
+
loop: get CI green on main, verified by a passing run
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Both make the model call the `loop_start` tool with the objective and then begin working, in the same turn.
|
|
35
|
+
|
|
36
|
+
**Your message is never touched** — not cut, split, re-sent, or annotated. An `input` handler only *records* the text; `before_agent_start` appends a one-turn `<system-reminder>` when the starting prompt is that same message. The hint is a per-turn system-prompt append, never a stored message, so "call loop_start now" cannot linger and fire on a later turn.
|
|
37
|
+
|
|
38
|
+
Detection is deliberately narrow. A token at position 0 is ignored (that is Pi's own dispatch), as is one inside backticks or a quoted span — `` `/loop` `` and `"/loop 5m ship it"` are how people *discuss* the command — and a bare trailing `/loop` with no remainder. The `loop:` form needs to start a line, so "my loop: is unclear" is prose. Extension-sent prompts never arm, and neither does steered or queued input, whose delivery order would leave a stale arm behind.
|
|
39
|
+
|
|
40
|
+
**`loop_start` refuses unless the turn was armed.** This is enforcement, not guidance. A loop is *self-continuing*: a spurious start does not produce one unwanted answer, it produces turns until a cap. So the tool checks a flag the inline hooks set for exactly one turn, and refuses outright otherwise, along with a loop already being active, a missing `loop_complete`, or an unparsable interval or expiry.
|
|
41
|
+
|
|
42
|
+
An invocation that names no interval uses the `defaultInterval` setting (`10m`). In a settle-paced loop the interval is only a fallback heartbeat, so the value matters far less than it looks. Turn the whole feature off with `"inlineInvocation": false`.
|
|
37
43
|
|
|
38
44
|
## What paces a loop
|
|
39
45
|
|
|
@@ -55,43 +61,37 @@ A reply that starts or ends with `LOOP_OK` and carries at most **300 characters*
|
|
|
55
61
|
|
|
56
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.
|
|
57
63
|
|
|
58
|
-
|
|
64
|
+
### One cap
|
|
59
65
|
|
|
60
|
-
|
|
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.
|
|
61
67
|
|
|
62
|
-
|
|
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.
|
|
63
69
|
|
|
64
|
-
-
|
|
65
|
-
- `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.
|
|
66
|
-
|
|
67
|
-
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.
|
|
68
71
|
|
|
69
72
|
## What a wakeup does
|
|
70
73
|
|
|
71
74
|
Each tick — fallback heartbeat or settled boundary — evaluates, in order:
|
|
72
75
|
|
|
73
|
-
1. **Expired?** Loops hard-expire after `maxLoopDuration` (default 7 days, or per loop with `--expires 3d`, echoed at start) — a forgotten loop is bounded.
|
|
76
|
+
1. **Expired?** Loops hard-expire after `maxLoopDuration` (default 7 days, or per loop with `--expires 3d`, echoed at start) — a forgotten loop is bounded. The loop gets **one final turn** first: "write the current state into the ledger, start no new work, claim no completion", and the settle after it stops the loop. A loop that simply vanished at its deadline would leave its most recent state only in a conversation about to be closed. If that final wake cannot be delivered, the loop stops immediately rather than living past its deadline.
|
|
74
77
|
2. **Plan mode active?** Skip quietly; never inject prompts into a planning conversation.
|
|
75
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.
|
|
76
|
-
4. **
|
|
77
|
-
5. **
|
|
78
|
-
6. **Settled boundary
|
|
79
|
-
|
|
80
|
-
6. **Poke**: a goal wake message — the wake header, why it fired (stalled or the external wake for a waiting goal), 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.
|
|
81
|
-
|
|
82
|
-
**A poke never restates the objective**, in either mode — the objective always 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. The two modes differ only in who provides that append: pi-goal's, on every active goal turn, for a goal-bound loop; this extension's own, for a standalone one. Both work because pokes are delivered as ordinary user messages that pass through `before_agent_start`.
|
|
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. **The turn cap** (see [One cap](#one-cap)): stop.
|
|
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
|
+
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
|
|
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
|
|
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 ·
|
|
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
|
|
|
94
|
-
A
|
|
94
|
+
A loop puts its own objective in front of the model, under a cache-safe split:
|
|
95
95
|
|
|
96
96
|
- **Static per loop — the system prompt.** The objective, `loop_id`, ledger contract, and loop-mode rules are appended to the system prompt, **byte-identically on every turn of that loop**. Anthropic caches `tools → system → messages` as one prefix, so a moving value there (iteration, next wake) would invalidate the cache for the whole conversation every wake. It changes only when the loop does.
|
|
97
97
|
|
|
@@ -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",
|
|
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
|
-
|
|
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
|
|
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.
|
|
@@ -151,7 +153,7 @@ The system append carries the objective only while the loop is *active*, and con
|
|
|
151
153
|
Long loops die by context exhaustion, not by failing. pi-loop owns the compaction path:
|
|
152
154
|
|
|
153
155
|
- **Proactive compact at a threshold** (default 70% of the context window, `--compact-at` / settings): at an idle boundary, pi-loop triggers `/compact` itself with loop-specific instructions — preserve the objective and acceptance criteria verbatim, **every failed approach and the reason it failed**, decisions and rationale, files modified, commands and unresolved errors, and the next 1-3 actions. The instructions explicitly **stop carrying prior summaries forward wholesale** and tell the next turn to re-derive status from the ledger and authoritative state instead: cumulative carry-forward grows the text while the information in it decays. Pending pokes are held until the compaction completes. Pi's reserve-token auto-compaction remains as the fault handler.
|
|
154
|
-
- **Loop-owned re-anchor**: when a compaction completes mid-loop, pi-loop dispatches one pointer-sized continuation at the next settle — re-read `PROGRESS.md` and `criteria.json`, continue from authoritative state, plus the next 1-3 actions lifted out of the summary that just replaced the conversation.
|
|
156
|
+
- **Loop-owned re-anchor**: when a compaction completes mid-loop, pi-loop dispatches one pointer-sized continuation at the next settle — re-read `PROGRESS.md` and `criteria.json`, continue from authoritative state, plus the next 1-3 actions lifted out of the summary that just replaced the conversation. The loop no longer goes quiet until the next wake. A re-anchor supersedes an ordinary continuation already queued: after a compaction, "re-read the ledger" is strictly the better instruction.
|
|
155
157
|
- Loop state itself lives in custom session entries, which compaction never touches, and survives session restarts (the timer re-arms on resume; expired loops are dropped with a notice).
|
|
156
158
|
|
|
157
159
|
## `/schedule`: recurring prompts and headless runs
|
|
@@ -196,10 +198,11 @@ Flags: `--run` (headless instead of in-session), `--cwd <path>`, `--max <n|unlim
|
|
|
196
198
|
|
|
197
199
|
```json
|
|
198
200
|
{
|
|
199
|
-
"
|
|
200
|
-
"automaticTurns": 25,
|
|
201
|
+
"maxTurns": 25,
|
|
201
202
|
"noProgressTurns": 3,
|
|
202
203
|
"maxLoopDuration": "7d",
|
|
204
|
+
"inlineInvocation": true,
|
|
205
|
+
"defaultInterval": "10m",
|
|
203
206
|
"compaction": {
|
|
204
207
|
"enabled": true,
|
|
205
208
|
"threshold": 0.7,
|
|
@@ -208,7 +211,7 @@ Flags: `--run` (headless instead of in-session), `--cwd <path>`, `--max <n|unlim
|
|
|
208
211
|
}
|
|
209
212
|
```
|
|
210
213
|
|
|
211
|
-
`
|
|
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.
|
|
212
215
|
|
|
213
216
|
## Deliberate omissions
|
|
214
217
|
|
|
@@ -218,13 +221,21 @@ These were considered and cut, and the reasoning is recorded so they are not sil
|
|
|
218
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.
|
|
219
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.
|
|
220
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
|
+
|
|
221
232
|
## Install
|
|
222
233
|
|
|
223
234
|
```bash
|
|
224
235
|
pi install npm:@hank-warren/pi-loop
|
|
225
236
|
```
|
|
226
237
|
|
|
227
|
-
**No other extension is required.**
|
|
238
|
+
**No other extension is required.** The only sibling state pi-loop reads is [pi-plan-mode](https://www.npmjs.com/package/@hank-warren/pi-plan-mode)'s, fail-open, so a loop never injects into a planning conversation — and it works fine without it.
|
|
228
239
|
|
|
229
240
|
## License
|
|
230
241
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-loop",
|
|
3
|
-
"version": "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
|
-
|
|
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 {
|
|
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
|
-
...(
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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)." };
|
package/src/complete-tool.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `loop_complete`: the
|
|
2
|
+
* `loop_complete`: the loop's terminal tool.
|
|
3
3
|
*
|
|
4
4
|
* It used to be deliberately thin, on the argument that stopping a pacemaker
|
|
5
5
|
* has a small blast radius: the user just restarts it. That argument does not
|
|
@@ -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
|
-
/**
|
|
35
|
-
|
|
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
|
-
"
|
|
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({
|
|
@@ -94,7 +120,7 @@ export function registerLoopCompleteTool(pi: ExtensionAPI, controller: LoopContr
|
|
|
94
120
|
if (!loop || loop.objective === undefined) {
|
|
95
121
|
return {
|
|
96
122
|
content: toolContent(
|
|
97
|
-
"No
|
|
123
|
+
"No /loop with an objective is active, so there is nothing to complete. Start one with /loop <interval> <objective>.",
|
|
98
124
|
),
|
|
99
125
|
details: { loopId: params.loop_id },
|
|
100
126
|
isError: true,
|
|
@@ -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
|
|
197
|
-
|
|
198
|
-
|
|
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) {
|