@hank-warren/pi-loop 0.7.0 → 0.8.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 +49 -0
- package/README.md +1 -37
- package/package.json +1 -1
- package/skills/pi-loop/SKILL.md +45 -2
- package/src/index.ts +38 -84
- package/src/interval.ts +25 -0
- package/src/ledger.ts +187 -6
- package/src/loop.ts +163 -38
- package/src/manager.ts +74 -0
- package/src/objective.ts +13 -2
- package/src/planning.ts +98 -0
- package/src/progress-tool.ts +162 -0
- package/src/propose-tool.ts +119 -0
- package/src/widget.ts +102 -12
- package/src/schedule/command.ts +0 -255
- package/src/schedule/cron.ts +0 -182
- package/src/schedule/manager.ts +0 -129
- package/src/schedule/model.ts +0 -237
- package/src/schedule/runner.ts +0 -351
- package/src/schedule/store.ts +0 -183
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# @hank-warren/pi-loop
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 2a04a46: Plan mode, a ledger write tool, and a widget that shows progress.
|
|
8
|
+
|
|
9
|
+
**Breaking: `/schedule` is removed.** It lived here because the machinery was the same
|
|
10
|
+
machinery — an idle-gated delivery path, coalescing, caps, an expiry. True, and still the
|
|
11
|
+
wrong home: a loop pursues one objective until it is done and then stops, while a schedule
|
|
12
|
+
fires forever on a clock. Persisted tasks in `~/.pi/agent/loop/schedules.json` are no longer
|
|
13
|
+
read and the `schedules.lease` lockfile is no longer taken.
|
|
14
|
+
|
|
15
|
+
**`/loop` now opens planning.** With nothing running it starts a drafting conversation
|
|
16
|
+
instead of printing "No loop in this session". The model drafts the objective as an
|
|
17
|
+
acceptance test, then calls the new `loop_propose` tool, which renders an approval card
|
|
18
|
+
showing the exact criteria the split will produce, the cadence and the caps — and starts
|
|
19
|
+
nothing. You start it, change the cadence, keep editing, or cancel. The criteria used to be
|
|
20
|
+
frozen before anyone had ever seen them; the card is the moment they can still be fixed.
|
|
21
|
+
`/loop 30m <objective>` and inline `loop:` are unchanged.
|
|
22
|
+
|
|
23
|
+
**Fixed: a wrapped bullet silently truncated its criterion.** `deriveCriteria` kept lines
|
|
24
|
+
carrying a bullet marker and discarded the rest, so a bullet longer than a terminal width
|
|
25
|
+
lost everything after its first line — while the criterion still looked well-formed. The
|
|
26
|
+
acceptance gate was quietly weakened to whatever survived, with no signal.
|
|
27
|
+
|
|
28
|
+
**New `loop_progress` tool, the only supported ledger write path.** The ledger is the one
|
|
29
|
+
thing that survives compaction, and the model was told to maintain it with no tool to do so,
|
|
30
|
+
so it reached for a shell heredoc — and one `cat > PROGRESS.md` replaced the objective line,
|
|
31
|
+
the other sections and every failed-approach note. `loop_progress` edits one section and
|
|
32
|
+
leaves every other byte alone, and marks a criterion met with the citation that justified
|
|
33
|
+
it, stored alongside it. Only `passes` ever changes, now by construction rather than by
|
|
34
|
+
prose in a skill file.
|
|
35
|
+
|
|
36
|
+
**A blocking prompt no longer deadlocks the loop invisibly.** A session waiting on a modal
|
|
37
|
+
is `busy`, which makes every continuation and every fallback tick skip; no turn completes,
|
|
38
|
+
so the cap never trips and the no-progress breaker never fires. Expiry was the only thing
|
|
39
|
+
left — up to seven days — while the widget showed a next-wake time throughout. Loops now
|
|
40
|
+
carry an autonomy posture (decide and record rather than ask, `loop_wait` as the only
|
|
41
|
+
non-deadlocking way to ask, never reshape a command to get past a permission prompt, prefer
|
|
42
|
+
the undoable), and a run left open past fifteen minutes flips the widget to an attention
|
|
43
|
+
state. The loop never answers the prompt.
|
|
44
|
+
|
|
45
|
+
**The widget was redesigned around progress.** It led with the interval — a fallback
|
|
46
|
+
heartbeat a settle-paced loop may never deliver — and reported turns against the cap, which
|
|
47
|
+
is budget burn. Criteria met over total now leads, with the turn budget, the loop's age and
|
|
48
|
+
the next wake after it. The widget and the footer render the same function: they had drifted,
|
|
49
|
+
so a loop waiting on CI showed an ordinary next-wake time above the editor while the footer
|
|
50
|
+
said it was waiting, and a paused loop dropped the cause that `/loop status` printed.
|
|
51
|
+
|
|
3
52
|
## 0.7.0
|
|
4
53
|
|
|
5
54
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -156,42 +156,6 @@ Long loops die by context exhaustion, not by failing. pi-loop owns the compactio
|
|
|
156
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.
|
|
157
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).
|
|
158
158
|
|
|
159
|
-
## `/schedule`: recurring prompts and headless runs
|
|
160
|
-
|
|
161
|
-
The same extension also schedules work, because the machinery is the same machinery: an idle-gated delivery path, coalescing, caps, and an expiry.
|
|
162
|
-
|
|
163
|
-
```
|
|
164
|
-
/schedule manager TUI
|
|
165
|
-
/schedule list
|
|
166
|
-
/schedule every 30m check the release queue in-session prompt, every 30 minutes
|
|
167
|
-
/schedule at +2h remind me to cut the RC once, two hours from now
|
|
168
|
-
/schedule at 2026-01-31T09:00 monthly report once, at an ISO timestamp
|
|
169
|
-
/schedule cron "0 9 * * 1" weekly triage Monday mornings
|
|
170
|
-
/schedule every 6h --run --cwd /srv/app sync headless `pi -p` run
|
|
171
|
-
/schedule pause|resume|run|status|delete <id>
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
Flags: `--run` (headless instead of in-session), `--cwd <path>`, `--max <n|unlimited>`, `--wake always|failure|success|never`, `--name <text>`.
|
|
175
|
-
|
|
176
|
-
**Two task kinds, deliberately different lifetimes:**
|
|
177
|
-
|
|
178
|
-
- **`prompt`** injects a prompt into the owning session, delivered exactly like a loop wake — only at a settled idle boundary, queued while the agent is busy. It is **session-scoped**: it lives in memory and dies with the session, because a prompt with no session to arrive in is not a task, it is a leak.
|
|
179
|
-
- **`run`** spawns a headless `pi -p "<prompt>"` in a working directory, tees stdout and stderr to `~/.pi/agent/loop/runs/<task-id>/<timestamp>.log`, and records the exit code. It never touches the conversation unless `wakeOn` says to report back (default: only failures). These are the only tasks persisted, in `~/.pi/agent/loop/schedules.json`.
|
|
180
|
-
|
|
181
|
-
A headless run is a **fresh `pi` invocation**, so it uses your *default* model and settings, not the model the scheduling session happens to be using. If a run needs a specific model, say so in the prompt's environment — or check the run log, which records the command, cwd, prompt, and exit code precisely so a surprise like this is one `cat` away.
|
|
182
|
-
|
|
183
|
-
**One fire per occurrence, not one per open session.** Headless firing is arbitrated by a lockfile lease (`schedules.lease`) holding a pid and a heartbeat: without it, a task scheduled for 09:00 fires once in every Pi session that happens to be open. A holder that dies stops renewing and the next session takes over after 90 seconds. It is not a distributed lock — the failure it must prevent is duplicate work, and the worst it can produce is one skipped tick.
|
|
184
|
-
|
|
185
|
-
**Missed occurrences coalesce into a single fire.** A laptop asleep for a weekend wakes to one catch-up, never one turn per missed interval.
|
|
186
|
-
|
|
187
|
-
**Every task is bounded twice**: `maxRuns` (default 25, `--max unlimited` is an explicit opt-in) and a hard 90-day expiry.
|
|
188
|
-
|
|
189
|
-
**Cron** is five numeric fields at minute granularity (`minute hour day-of-month month day-of-week`), supporting `*`, `n`, `a-b`, `a,b`, and `/step`. No names, no `@daily`, no seconds, no timezones beyond the host's local clock — each of those is a place where two implementations disagree, and a scheduler with debatable semantics is worse than one that refuses the expression. When both day fields are restricted, a day matching *either* fires, as in every crontab in the world.
|
|
190
|
-
|
|
191
|
-
`/schedule` is **user-typed only**, exactly like `/loop`: the model gets no scheduling tools. A model that can schedule its own future turns can schedule its way around every limit the loop imposes.
|
|
192
|
-
|
|
193
|
-
> **Never co-install [`@jl1990/pi-scheduler`](https://www.npmjs.com/package/@jl1990/pi-scheduler).** Both register `/schedule`; the commands and the concepts collide.
|
|
194
|
-
|
|
195
159
|
## Settings
|
|
196
160
|
|
|
197
161
|
`~/.pi/agent/pi-loop.json` (absent file = defaults, never created implicitly; saves are atomic and preserve unknown fields), or `/loop settings`:
|
|
@@ -223,7 +187,7 @@ These were considered and cut, and the reasoning is recorded so they are not sil
|
|
|
223
187
|
|
|
224
188
|
## The companion skill
|
|
225
189
|
|
|
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
|
|
190
|
+
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 no loop at all.
|
|
227
191
|
|
|
228
192
|
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
193
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-loop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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": [
|
package/skills/pi-loop/SKILL.md
CHANGED
|
@@ -70,6 +70,47 @@ c2: "gh pr checks 171 → all 3 checks passed (ci/gate, ci/typecheck, ci/scan-se
|
|
|
70
70
|
|
|
71
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
72
|
|
|
73
|
+
## A loop runs unattended, so a question is a deadlock
|
|
74
|
+
|
|
75
|
+
Outside a loop, asking the user costs a few seconds of their attention. Inside one they are not
|
|
76
|
+
there by construction, and the cost is not a slow answer — it is the loop's death.
|
|
77
|
+
|
|
78
|
+
A session blocked on a modal prompt is **busy**, and `busy` is the third test in the decision
|
|
79
|
+
prefix both drivers share:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
loop liveness -> expiry -> plan mode -> compaction -> busy -> wait -> the turn cap -> act
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
So `agent_settled` never fires and no continuation is queued; every fallback tick returns
|
|
86
|
+
`skip: agent-busy`; `automaticTurns` never increments, so the turn cap never trips; and the
|
|
87
|
+
no-progress breaker needs tool-free *turns*, of which there are none. **Expiry is the only thing
|
|
88
|
+
left, up to seven days later** — and the widget shows the next wake time the whole time.
|
|
89
|
+
|
|
90
|
+
This is why the guidance is a posture rather than a list of forbidden tools. Your other
|
|
91
|
+
extensions are unknowable and a blacklist goes stale the moment one of them ships a new prompt.
|
|
92
|
+
The rule follows from the mechanics instead:
|
|
93
|
+
|
|
94
|
+
1. **Decide, do not ask.** Take the reversible option, write the decision *and the reasoning*
|
|
95
|
+
into `PROGRESS.md`, and keep going. The user reads it when they return and the loop never
|
|
96
|
+
stopped. A decision recorded beats a question unanswered.
|
|
97
|
+
2. **`loop_wait` is the loop-safe form of asking.** It is the one way to say "I need a human"
|
|
98
|
+
that does not deadlock: continuations stop, the reason shows in the widget and `/loop status`,
|
|
99
|
+
and any wake resumes it. Use it exactly where you would otherwise open a modal, and put the
|
|
100
|
+
options in the ledger first so the answer can be one word.
|
|
101
|
+
3. **Never reshape a command to get past a permission prompt.** This is the dangerous one. An
|
|
102
|
+
agent under a turn cap has real incentive to rewrite a blocked command into something the
|
|
103
|
+
guardian waves through, and that failure *looks like progress*. A blocked command means stop
|
|
104
|
+
and ask through `loop_wait` — never find another way around it.
|
|
105
|
+
4. **Prefer the undoable.** Nobody is watching to catch a bad call, so when two paths are close,
|
|
106
|
+
take the one that is cheap to reverse: a worktree over the clone, additive over destructive, a
|
|
107
|
+
draft pull request over a merge.
|
|
108
|
+
5. **Ask only when proceeding is irreversible *and* the choice is load-bearing.** Then use
|
|
109
|
+
`loop_wait`, not a prompt.
|
|
110
|
+
|
|
111
|
+
Autonomy is not permission to be reckless. It is the opposite: the absence of a human in the loop
|
|
112
|
+
is exactly why the reversible path is the right default.
|
|
113
|
+
|
|
73
114
|
## Waiting on the world
|
|
74
115
|
|
|
75
116
|
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.
|
|
@@ -87,7 +128,9 @@ The ledger lives at `~/.pi/agent/loop/<loop-id>/`, and `PROGRESS.md` has four fi
|
|
|
87
128
|
|
|
88
129
|
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
130
|
|
|
90
|
-
|
|
131
|
+
Write both files with the **`loop_progress`** tool, never with the file or shell tools. `loop_progress` edits one section and leaves every other byte alone; a whole-file write takes out the objective line, the other three sections, and however many days of failed-approach notes were in them. That is not a hypothetical — it is what a `cat > PROGRESS.md <<EOF` does on the first ledger update, and `createLedger` opens the file with `flag: "wx"` precisely so the engine can never do it.
|
|
132
|
+
|
|
133
|
+
`criteria.json` sits next to it. Mark an entry met with `loop_progress`, which flips `passes` and stores the citation that justified it alongside the criterion, where `loop_complete` can be held to it later. Only `passes` ever changes: you may **never** add, remove, reword, or re-id an entry, and never hand-edit the file. A model that can rewrite its acceptance criteria eventually rewrites them into something it has already achieved.
|
|
91
134
|
|
|
92
135
|
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
136
|
|
|
@@ -103,7 +146,7 @@ The interval is a fallback heartbeat, not the pacemaker — the loop advances wh
|
|
|
103
146
|
|
|
104
147
|
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
148
|
|
|
106
|
-
- **Recurring cadence** ("check the release queue every morning") →
|
|
149
|
+
- **Recurring cadence** ("check the release queue every morning") → not a loop. A loop pursues one objective until it is done and then stops; it is not a timer, and an objective that is never "done" only produces turns until a cap. Use whatever scheduling your setup provides.
|
|
107
150
|
- **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
151
|
- **Work that finishes this turn** → just do it, and say why no loop was needed.
|
|
109
152
|
|
package/src/index.ts
CHANGED
|
@@ -11,23 +11,18 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
|
|
|
11
11
|
import { completeLoopArguments, parseLoopCommand } from "./command.js";
|
|
12
12
|
import { registerLoopCompleteTool } from "./complete-tool.js";
|
|
13
13
|
import { InlineInvocationState, registerInlineInvocation } from "./inline-invocation.js";
|
|
14
|
+
import { registerLoopProgressTool } from "./progress-tool.js";
|
|
15
|
+
import { registerLoopProposeTool } from "./propose-tool.js";
|
|
16
|
+
import { LOOP_PLANNING_HINT } from "./planning.js";
|
|
14
17
|
import { registerLoopStartTool } from "./start-tool.js";
|
|
15
18
|
import { registerLoopWaitTool } from "./wait-tool.js";
|
|
16
19
|
import { LoopController, type LoopControllerOptions } from "./loop.js";
|
|
17
|
-
import { showLoopManager, showLoopSettings } from "./manager.js";
|
|
20
|
+
import { showLoopApproval, showLoopManager, showLoopSettings } from "./manager.js";
|
|
18
21
|
import { buildLoopObjectivePrompt } from "./objective.js";
|
|
19
22
|
import { registerLoopMessageRendering } from "./render.js";
|
|
20
|
-
import { completeScheduleArguments, parseScheduleCommand } from "./schedule/command.js";
|
|
21
|
-
import { describeTask, listTasks, showScheduleManager } from "./schedule/manager.js";
|
|
22
|
-
import { describeSchedule } from "./schedule/model.js";
|
|
23
|
-
import { Scheduler } from "./schedule/runner.js";
|
|
24
23
|
|
|
25
24
|
export default function loop(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
|
|
26
25
|
const controller = new LoopController(pi, options);
|
|
27
|
-
const scheduler = new Scheduler(pi, {
|
|
28
|
-
...(options.agentDir === undefined ? {} : { agentDir: options.agentDir }),
|
|
29
|
-
...(options.now === undefined ? {} : { now: options.now }),
|
|
30
|
-
});
|
|
31
26
|
// Registered unconditionally and never toggled with loop state: tools are
|
|
32
27
|
// part of the cached request prefix, so mutating the tool set mid-session
|
|
33
28
|
// would invalidate the whole conversation cache. It refuses when no loop is
|
|
@@ -36,6 +31,8 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
36
31
|
// Registered on the same terms and for the same reason: the tool set is
|
|
37
32
|
// part of the cached prefix, so it never changes with loop state.
|
|
38
33
|
registerLoopWaitTool(pi, controller);
|
|
34
|
+
registerLoopProgressTool(pi, controller);
|
|
35
|
+
registerLoopProposeTool(pi, controller);
|
|
39
36
|
// Inline invocation: an `input` handler arms a one-turn system-prompt hint
|
|
40
37
|
// for a mid-prompt `/loop` token, `before_agent_start` appends it, and
|
|
41
38
|
// loop_start is the model-invoked start it points at — refused on any turn
|
|
@@ -54,9 +51,34 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
54
51
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
55
52
|
const command = parseLoopCommand(args);
|
|
56
53
|
switch (command.kind) {
|
|
57
|
-
case "show":
|
|
58
|
-
|
|
54
|
+
case "show": {
|
|
55
|
+
// Bare /loop is the front door. With a loop running it is the
|
|
56
|
+
// manager, as before. With a draft awaiting approval it is the
|
|
57
|
+
// card. With neither it opens planning, which is what used to be
|
|
58
|
+
// a one-line "no loop in this session" dead end.
|
|
59
|
+
const running = controller.state && controller.state.status !== "stopped";
|
|
60
|
+
if (running) {
|
|
61
|
+
await showLoopManager(controller, ctx);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (controller.planning.proposal) {
|
|
65
|
+
await showLoopApproval(controller, ctx);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (controller.planning.active) {
|
|
69
|
+
ctx.ui.notify(
|
|
70
|
+
"Still planning: describe the objective, and the agent will put a loop up for approval.",
|
|
71
|
+
"info",
|
|
72
|
+
);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
controller.beginPlanning();
|
|
76
|
+
ctx.ui.notify(
|
|
77
|
+
"Loop planning. Describe what you want the loop to achieve and how you will know it is done; the agent drafts it and puts it up for approval. Nothing starts until you approve it.",
|
|
78
|
+
"info",
|
|
79
|
+
);
|
|
59
80
|
return;
|
|
81
|
+
}
|
|
60
82
|
case "status":
|
|
61
83
|
ctx.ui.notify(controller.statusLines(ctx).join("\n"), "info");
|
|
62
84
|
return;
|
|
@@ -101,83 +123,11 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
101
123
|
},
|
|
102
124
|
});
|
|
103
125
|
|
|
104
|
-
// The scheduler is user-typed only, exactly like /loop: the model gets no
|
|
105
|
-
// scheduling tools, because a model that can schedule its own future turns
|
|
106
|
-
// can schedule its way around every limit the loop imposes.
|
|
107
|
-
pi.registerCommand("schedule", {
|
|
108
|
-
description:
|
|
109
|
-
'Schedule prompts and headless runs: /schedule [every <dur>|at <time>|cron "<expr>"] [--run] <prompt>, or list/pause/resume/run/status/delete',
|
|
110
|
-
getArgumentCompletions: (prefix: string) => completeScheduleArguments(prefix),
|
|
111
|
-
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
112
|
-
const command = parseScheduleCommand(args, { cwd: ctx.cwd });
|
|
113
|
-
switch (command.kind) {
|
|
114
|
-
case "show":
|
|
115
|
-
await showScheduleManager(scheduler, ctx);
|
|
116
|
-
return;
|
|
117
|
-
case "list":
|
|
118
|
-
ctx.ui.notify(listTasks(scheduler).join("\n"), "info");
|
|
119
|
-
return;
|
|
120
|
-
case "error":
|
|
121
|
-
ctx.ui.notify(command.message, "error");
|
|
122
|
-
return;
|
|
123
|
-
case "create": {
|
|
124
|
-
const { task, warning } = scheduler.create(command);
|
|
125
|
-
if (warning) {
|
|
126
|
-
ctx.ui.notify(`Scheduled task not persisted: ${warning}`, "warning");
|
|
127
|
-
}
|
|
128
|
-
if (command.clampedFrom !== undefined) {
|
|
129
|
-
ctx.ui.notify("Intervals below 1 minute are raised to the minimum.", "warning");
|
|
130
|
-
}
|
|
131
|
-
ctx.ui.notify(
|
|
132
|
-
[
|
|
133
|
-
`Scheduled "${task.name}" (${task.id}): ${describeSchedule(task.schedule)}.`,
|
|
134
|
-
task.task.kind === "run"
|
|
135
|
-
? `Runs headlessly in ${task.task.cwd}; wakes this session on ${task.task.wakeOn}.`
|
|
136
|
-
: "Injects a prompt into this session at an idle boundary; it dies with the session.",
|
|
137
|
-
`Runs: ${task.maxRuns === null ? "unlimited" : `at most ${task.maxRuns}`}; expires ${new Date(task.expiresAt).toLocaleDateString()}.`,
|
|
138
|
-
].join("\n"),
|
|
139
|
-
"info",
|
|
140
|
-
);
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
default: {
|
|
144
|
-
const task = scheduler.find(command.id);
|
|
145
|
-
if (!task) {
|
|
146
|
-
ctx.ui.notify(
|
|
147
|
-
`No scheduled task matches ${command.id}. Run /schedule list to see them.`,
|
|
148
|
-
"error",
|
|
149
|
-
);
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
if (command.kind === "status") {
|
|
153
|
-
ctx.ui.notify(describeTask(task).join("\n"), "info");
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
if (command.kind === "pause" || command.kind === "resume") {
|
|
157
|
-
const status = command.kind === "pause" ? "paused" : "active";
|
|
158
|
-
scheduler.update({ ...task, status });
|
|
159
|
-
ctx.ui.notify(`Task "${task.name}" is now ${status}.`, "info");
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
if (command.kind === "run") {
|
|
163
|
-
scheduler.fireNow(task);
|
|
164
|
-
ctx.ui.notify(`Running "${task.name}" now.`, "info");
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
scheduler.remove(task.id);
|
|
168
|
-
ctx.ui.notify(`Deleted "${task.name}".`, "info");
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
},
|
|
172
|
-
});
|
|
173
|
-
|
|
174
126
|
pi.on("session_start", async (_event, ctx) => {
|
|
175
127
|
controller.onSessionStart(ctx);
|
|
176
|
-
scheduler.onSessionStart(ctx);
|
|
177
128
|
});
|
|
178
129
|
pi.on("session_shutdown", async () => {
|
|
179
130
|
controller.onSessionShutdown();
|
|
180
|
-
scheduler.onSessionShutdown();
|
|
181
131
|
});
|
|
182
132
|
// The pacemaker: agent_end records the intent to continue, agent_settled
|
|
183
133
|
// delivers it once Pi will accept a message.
|
|
@@ -189,12 +139,16 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
189
139
|
});
|
|
190
140
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
191
141
|
controller.onAgentSettled(ctx);
|
|
192
|
-
scheduler.onAgentSettled(ctx);
|
|
193
142
|
});
|
|
194
143
|
// A loop carries its own objective and injects it as a byte-stable system
|
|
195
144
|
// append, which is what lets the poke and continuation messages stay
|
|
196
145
|
// pointer-sized.
|
|
197
146
|
pi.on("before_agent_start", (event) => {
|
|
147
|
+
// Planning precedes any loop, so its guidance is injected on the same hook
|
|
148
|
+
// and is mutually exclusive with the objective append below.
|
|
149
|
+
if (controller.planning.active) {
|
|
150
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${LOOP_PLANNING_HINT}` };
|
|
151
|
+
}
|
|
198
152
|
const loop = controller.state;
|
|
199
153
|
if (!loop || loop.status !== "active") return;
|
|
200
154
|
const objectivePrompt = buildLoopObjectivePrompt(loop, controller.ledger);
|
package/src/interval.ts
CHANGED
|
@@ -54,6 +54,31 @@ export function formatDuration(ms: number): string {
|
|
|
54
54
|
return `${Math.round(ms / 1_000)}s`;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Render an elapsed span approximately, for display only.
|
|
59
|
+
*
|
|
60
|
+
* `formatDuration` renders the *canonical token* for a configured interval and
|
|
61
|
+
* only ever emits one unit on an exact multiple, so an arbitrary elapsed span
|
|
62
|
+
* falls through it to seconds — 2h12m comes back as "7920s". An age needs the
|
|
63
|
+
* opposite trade: two units at most, truncated, never exact.
|
|
64
|
+
*/
|
|
65
|
+
export function formatElapsed(ms: number): string {
|
|
66
|
+
const clamped = Math.max(0, ms);
|
|
67
|
+
if (clamped < UNIT_MS.m) return `${Math.floor(clamped / 1_000)}s`;
|
|
68
|
+
for (const [big, small] of [
|
|
69
|
+
["d", "h"],
|
|
70
|
+
["h", "m"],
|
|
71
|
+
] as const) {
|
|
72
|
+
const bigMs = UNIT_MS[big];
|
|
73
|
+
const smallMs = UNIT_MS[small];
|
|
74
|
+
if (bigMs === undefined || smallMs === undefined || clamped < bigMs) continue;
|
|
75
|
+
const whole = Math.floor(clamped / bigMs);
|
|
76
|
+
const rest = Math.floor((clamped % bigMs) / smallMs);
|
|
77
|
+
return rest > 0 ? `${whole}${big}${rest}${small}` : `${whole}${big}`;
|
|
78
|
+
}
|
|
79
|
+
return `${Math.floor(clamped / UNIT_MS.m)}m`;
|
|
80
|
+
}
|
|
81
|
+
|
|
57
82
|
/** Render a wall-clock time as HH:MM for the status widget. */
|
|
58
83
|
export function formatClock(timestamp: number): string {
|
|
59
84
|
const date = new Date(timestamp);
|
package/src/ledger.ts
CHANGED
|
@@ -44,8 +44,37 @@ export interface LoopCriterion {
|
|
|
44
44
|
*/
|
|
45
45
|
check: string;
|
|
46
46
|
passes: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* The citation given when `passes` was flipped, recorded by the extension
|
|
49
|
+
* at flip time. Absent on a criterion still unmet, and on one flipped by a
|
|
50
|
+
* hand-edit rather than through `loop_progress`.
|
|
51
|
+
*/
|
|
52
|
+
evidence?: string;
|
|
53
|
+
/** Epoch ms of the flip that recorded `evidence`. */
|
|
54
|
+
evidenceAt?: number;
|
|
47
55
|
}
|
|
48
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The fixed headings of `PROGRESS.md`. The schema is the point: "update the
|
|
59
|
+
* ledger" means the same thing on every turn only while the sections are the
|
|
60
|
+
* same on every turn, so writes are section-scoped and a section that is not
|
|
61
|
+
* one of these is refused rather than created.
|
|
62
|
+
*/
|
|
63
|
+
export const PROGRESS_SECTIONS = [
|
|
64
|
+
"current status",
|
|
65
|
+
"completed",
|
|
66
|
+
"failed approaches and why",
|
|
67
|
+
"next actions",
|
|
68
|
+
] as const;
|
|
69
|
+
export type ProgressSection = (typeof PROGRESS_SECTIONS)[number];
|
|
70
|
+
|
|
71
|
+
/** Cap on one ledger write: a progress note is a paragraph, not a transcript. */
|
|
72
|
+
export const MAX_PROGRESS_TEXT_LENGTH = 4000;
|
|
73
|
+
export const MAX_EVIDENCE_LENGTH = 4000;
|
|
74
|
+
|
|
75
|
+
/** The template's placeholders, replaced rather than appended to on first write. */
|
|
76
|
+
const PLACEHOLDERS = new Set(["not started.", "- (nothing yet)"]);
|
|
77
|
+
|
|
49
78
|
export function loopLedgerDir(loopId: string, agentDir = getAgentDir()): string {
|
|
50
79
|
return join(agentDir, LEDGER_DIR_NAME, loopId);
|
|
51
80
|
}
|
|
@@ -61,12 +90,7 @@ export function loopLedgerDir(loopId: string, agentDir = getAgentDir()): string
|
|
|
61
90
|
export function deriveCriteria(objective: string): LoopCriterion[] {
|
|
62
91
|
const trimmed = objective.trim();
|
|
63
92
|
if (!trimmed) return [implicitCriterion(objective)];
|
|
64
|
-
const bullets = trimmed
|
|
65
|
-
.split(/\r?\n/)
|
|
66
|
-
.map((line) => line.trim())
|
|
67
|
-
.filter((line) => /^([-*+]|\d+[.)])\s+/.test(line))
|
|
68
|
-
.map((line) => line.replace(/^([-*+]|\d+[.)])\s+/, "").trim())
|
|
69
|
-
.filter(Boolean);
|
|
93
|
+
const bullets = collectBullets(trimmed);
|
|
70
94
|
const parts = bullets.length > 1 ? bullets : splitSentences(trimmed);
|
|
71
95
|
if (parts.length < 2) return [implicitCriterion(trimmed)];
|
|
72
96
|
return criteriaFromDescriptions(parts);
|
|
@@ -90,6 +114,46 @@ export function criteriaFromDescriptions(descriptions: readonly string[]): LoopC
|
|
|
90
114
|
}));
|
|
91
115
|
}
|
|
92
116
|
|
|
117
|
+
const BULLET_MARKER = /^([-*+]|\d+[.)])\s+/;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Bullets, each folded back together with the lines it wrapped onto.
|
|
121
|
+
*
|
|
122
|
+
* A bullet longer than the terminal width is typed — or pasted — across
|
|
123
|
+
* several lines, and only the first carries the marker. Matching markers and
|
|
124
|
+
* discarding everything else silently truncated such a bullet at its first
|
|
125
|
+
* line, which is worse than mis-splitting it: the criterion still looked
|
|
126
|
+
* well-formed, so a requirement could vanish out of the gate with no signal.
|
|
127
|
+
* A non-blank line that starts no new bullet therefore continues the previous
|
|
128
|
+
* one. Text before the first bullet is still ignored (it is a preamble, not a
|
|
129
|
+
* requirement), and a blank line ends the bullet it follows so a trailing
|
|
130
|
+
* paragraph cannot be glued onto the last item.
|
|
131
|
+
*/
|
|
132
|
+
function collectBullets(text: string): string[] {
|
|
133
|
+
const bullets: string[] = [];
|
|
134
|
+
let open = false;
|
|
135
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
136
|
+
const line = raw.trim();
|
|
137
|
+
if (!line) {
|
|
138
|
+
open = false;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (BULLET_MARKER.test(line)) {
|
|
142
|
+
const body = line.replace(BULLET_MARKER, "").trim();
|
|
143
|
+
if (body) {
|
|
144
|
+
bullets.push(body);
|
|
145
|
+
open = true;
|
|
146
|
+
} else {
|
|
147
|
+
// A bare marker has no body to continue.
|
|
148
|
+
open = false;
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (open) bullets[bullets.length - 1] += ` ${line}`;
|
|
153
|
+
}
|
|
154
|
+
return bullets;
|
|
155
|
+
}
|
|
156
|
+
|
|
93
157
|
function implicitCriterion(objective: string): LoopCriterion {
|
|
94
158
|
return {
|
|
95
159
|
id: "c1",
|
|
@@ -197,6 +261,119 @@ export function progressTemplate(objective: string): string {
|
|
|
197
261
|
].join("\n");
|
|
198
262
|
}
|
|
199
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Append to (or replace) one section of `PROGRESS.md`, leaving every other
|
|
266
|
+
* section byte-identical.
|
|
267
|
+
*
|
|
268
|
+
* This exists because the alternative the model reaches for otherwise is a
|
|
269
|
+
* whole-file overwrite, which takes out the objective line and the other
|
|
270
|
+
* three sections along with it. `createLedger` already refuses to overwrite
|
|
271
|
+
* this file for exactly that reason; the agent's write path has to honour the
|
|
272
|
+
* same rule or the protection is decorative.
|
|
273
|
+
*
|
|
274
|
+
* Returns the failure reason, or undefined on success.
|
|
275
|
+
*/
|
|
276
|
+
export function writeProgressSection(
|
|
277
|
+
paths: LedgerPaths,
|
|
278
|
+
section: ProgressSection,
|
|
279
|
+
text: string,
|
|
280
|
+
): string | undefined {
|
|
281
|
+
const entry = text.trim();
|
|
282
|
+
if (!entry) return "the text to record was empty";
|
|
283
|
+
let contents: string;
|
|
284
|
+
try {
|
|
285
|
+
contents = readFileSync(paths.progress, "utf8");
|
|
286
|
+
} catch (error) {
|
|
287
|
+
return formatError(error);
|
|
288
|
+
}
|
|
289
|
+
const lines = contents.split(/\r?\n/);
|
|
290
|
+
const start = lines.findIndex((line) => headingText(line) === section);
|
|
291
|
+
if (start === -1) {
|
|
292
|
+
return `PROGRESS.md has no "## ${section}" section (it was renamed or removed by hand)`;
|
|
293
|
+
}
|
|
294
|
+
let end = start + 1;
|
|
295
|
+
while (end < lines.length && headingText(lines[end]) === undefined) end += 1;
|
|
296
|
+
const body = lines.slice(start + 1, end);
|
|
297
|
+
while (body.length > 0 && !body[0].trim()) body.shift();
|
|
298
|
+
while (body.length > 0 && !body[body.length - 1].trim()) body.pop();
|
|
299
|
+
const placeholder =
|
|
300
|
+
body.length === 1 && PLACEHOLDERS.has(body[0].trim().toLowerCase()) ? true : body.length === 0;
|
|
301
|
+
// Whether a write replaces or extends is a property of the section, not a
|
|
302
|
+
// choice: "current status" is a single current value and the other three are
|
|
303
|
+
// running lists. Deriving it keeps the decision out of the tool schema,
|
|
304
|
+
// where the model could get it wrong on a file nothing else can repair.
|
|
305
|
+
const next =
|
|
306
|
+
section === "current status" || placeholder
|
|
307
|
+
? entry.split("\n")
|
|
308
|
+
: [...body, "", ...entry.split("\n")];
|
|
309
|
+
const rebuilt = [...lines.slice(0, start + 1), "", ...next, "", ...lines.slice(end)];
|
|
310
|
+
try {
|
|
311
|
+
writeFileSync(paths.progress, `${rebuilt.join("\n").replace(/\n{3,}$/u, "\n")}`, "utf8");
|
|
312
|
+
return undefined;
|
|
313
|
+
} catch (error) {
|
|
314
|
+
return formatError(error);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function headingText(line: string | undefined): string | undefined {
|
|
319
|
+
const match = /^##\s+(.+?)\s*$/u.exec(line ?? "");
|
|
320
|
+
return match ? match[1].toLowerCase() : undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export interface MarkCriterionResult {
|
|
324
|
+
ok: boolean;
|
|
325
|
+
message: string;
|
|
326
|
+
criteria?: LoopCriterion[];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Flip one criterion's `passes` and record the citation that justified it.
|
|
331
|
+
*
|
|
332
|
+
* The only mutation `criteria.json` accepts. Descriptions, ids, checks and the
|
|
333
|
+
* set of entries are rewritten by nothing here, so "a model may not rewrite
|
|
334
|
+
* its own acceptance criteria" stops being a rule in a skill file and becomes
|
|
335
|
+
* a property of the only available write path.
|
|
336
|
+
*/
|
|
337
|
+
export function markCriterion(
|
|
338
|
+
paths: LedgerPaths,
|
|
339
|
+
id: string,
|
|
340
|
+
evidence: string,
|
|
341
|
+
passes: boolean,
|
|
342
|
+
now: number,
|
|
343
|
+
): MarkCriterionResult {
|
|
344
|
+
const criteria = readCriteria(paths);
|
|
345
|
+
if (!criteria) return { ok: false, message: "criteria.json is absent or unreadable" };
|
|
346
|
+
const target = criteria.find((criterion) => criterion.id === id);
|
|
347
|
+
if (!target) {
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
message: `no criterion ${id}; this loop has ${criteria.map((c) => c.id).join(", ")}`,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
const cited = evidence.trim();
|
|
354
|
+
if (passes && !cited) return { ok: false, message: "marking a criterion met requires evidence" };
|
|
355
|
+
const updated = criteria.map((criterion) =>
|
|
356
|
+
criterion.id === id
|
|
357
|
+
? {
|
|
358
|
+
...criterion,
|
|
359
|
+
passes,
|
|
360
|
+
...(passes ? { evidence: cited, evidenceAt: now } : {}),
|
|
361
|
+
}
|
|
362
|
+
: criterion,
|
|
363
|
+
);
|
|
364
|
+
try {
|
|
365
|
+
writeFileSync(paths.criteria, `${JSON.stringify(updated, null, 2)}\n`, "utf8");
|
|
366
|
+
} catch (error) {
|
|
367
|
+
return { ok: false, message: formatError(error) };
|
|
368
|
+
}
|
|
369
|
+
const met = updated.filter((criterion) => criterion.passes).length;
|
|
370
|
+
return {
|
|
371
|
+
ok: true,
|
|
372
|
+
message: `${id} marked ${passes ? "met" : "unmet"} (${met}/${updated.length} now passing)`,
|
|
373
|
+
criteria: updated,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
200
377
|
/** Read the criteria back, fail-open: undefined when absent or unreadable. */
|
|
201
378
|
export function readCriteria(paths: LedgerPaths): LoopCriterion[] | undefined {
|
|
202
379
|
let contents: string;
|
|
@@ -226,11 +403,15 @@ function normalizeCriterion(value: unknown): LoopCriterion | undefined {
|
|
|
226
403
|
const id = typeof record.id === "string" ? record.id.trim() : "";
|
|
227
404
|
const description = typeof record.description === "string" ? record.description.trim() : "";
|
|
228
405
|
if (!id || !description) return undefined;
|
|
406
|
+
const evidence = typeof record.evidence === "string" ? record.evidence.trim() : "";
|
|
407
|
+
const evidenceAt = record.evidenceAt;
|
|
229
408
|
return {
|
|
230
409
|
id,
|
|
231
410
|
description,
|
|
232
411
|
check: typeof record.check === "string" ? record.check : "",
|
|
233
412
|
passes: record.passes === true,
|
|
413
|
+
...(evidence ? { evidence } : {}),
|
|
414
|
+
...(typeof evidenceAt === "number" && Number.isSafeInteger(evidenceAt) ? { evidenceAt } : {}),
|
|
234
415
|
};
|
|
235
416
|
}
|
|
236
417
|
|