pi-goal-list-loop-audit 0.34.50 → 0.34.79

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/README.md CHANGED
@@ -248,33 +248,49 @@ Activity is otherwise intentionally honest:
248
248
  | `QUEUED` | A continuation is waiting to start; no work is fabricated. |
249
249
  | `IDLE` | The durable item remains active, but no recent work is observed. |
250
250
  | `auditor …` | A detached, extension-less verifier is queued, running, quiet, or waiting for its verdict. |
251
- | `QUOTA WALL` | The provider rejected the request for a quota/plan window; saved work is waiting for a durable probe. |
252
-
253
- Quota walls deliberately do **not** get more blind request retries. A bare
254
- 429/rate-limit response is treated as a transient throttle; explicit plan,
255
- usage, billing, reset, and provider-code language is classified more strongly.
256
- For example, MiniMax's `Token Plan rate limit reached … (2062)` asks for an
257
- upgrade or pay-as-you-go billing and is not the same thing as a per-minute
258
- throttle. Output/context-token stops are handled separately and never become a
259
- quota wall. pi's request-local retry counter is bounded; glla owns the longer
260
- recovery window: generic throttles use `15m → 30m → 1h → 2h → 4h → 5h`; a
261
- plan wall with no reset hint starts at `1h → 2h → 4h → 5h`. Automatic probes
262
- stop after 24h.
263
- A provider hint is honored when it is within the five-hour probe budget; a
264
- week-long hint is shown and held for manual action instead of scheduling a
265
- hidden week-long timer. With global `autoResume=on`, pending probes survive a
266
- session reload. After the safety horizon, `/list resume`, `/goal resume`, or
267
- `/loop resume` explicitly starts a fresh bounded window. For continuous work,
268
- configure ordered **Main model backups** in `/glla` using a model from a
269
- different provider or billing/quota pool — another model on the same exhausted
270
- plan is not a real fallback.
271
-
272
- Classification is conservative: explicit 429/rate-limit/plan-limit/token-plan
273
- signals are quota walls; ordinary `503 temporarily unavailable`, `403
274
- forbidden`, auth failures, and ambiguous provider prose are not relabeled as
275
- quota. Credit/billing exhaustion gets a manual-action hold. The raw provider
276
- message remains in the ledger/durable state for diagnosis, while the card
277
- shows the classified reason and recovery action.
251
+ | `QUOTA WALL` | The provider rejected the request for a quota/plan window (by its own wording); saved work is waiting for a durable probe. |
252
+
253
+ ## Provider failures: one retry envelope, bounded (v0.34.51)
254
+
255
+ Error text is **not trusted** to pick a retry policy: we only know that an
256
+ error came, and provider messages vary. Every main-model failure — quota,
257
+ rate limit, billing/credits, auth, transient, or unclassifiable — rides the
258
+ same durable recovery envelope `15m → 30m → 1h → 2h → 4h → 5h` (probe cap
259
+ 5h, automatic window 24h, then an explicit `/goal resume`/`/list resume`/
260
+ `/loop resume` starts a fresh window). The only failures that do not auto-retry
261
+ are the ones identified by *positive evidence* as futile: context/output-token
262
+ limits and user aborts (`non-recoverable`), plus auditor watchdog timeouts
263
+ (a hanging verification command will hang again — the stored claim waits for
264
+ an explicit resume).
265
+ A provider hint (`retry_after`/`reset_at`) is honored when it fits the
266
+ five-hour probe budget; an over-budget hint (e.g. a week-long reset) never
267
+ parks the goal — the bounded cadence owns the wait, and only the 24h horizon
268
+ ends automatic probes (a `/goal resume`/`/list resume`/`/loop resume` then
269
+ starts a fresh window). With global `autoResume=on`, pending
270
+ probes survive a session reload. For continuous work, configure ordered
271
+ **Main model backups** in `/glla` using a model from a different provider or
272
+ billing/quota pool — another model on the same exhausted plan is not a real
273
+ fallback.
274
+
275
+ **Quota walls engage fast** (v0.34.57): a surfaced long-lived failure
276
+ (quota / billing / auth) records a 30-minute knowledge window; a send-rearm
277
+ storm inside that window escalates into the recovery envelope after **3
278
+ minutes** of failed sends instead of the generic 15 — a wedge right after a
279
+ quota wall is almost always the same wall. Transient (5xx/stream/network)
280
+ failures never record the signal and keep the fast error ladder. The
281
+ envelope is armed by configuration: an empty `mainModelFallbacks` list means
282
+ "park and probe the same model" rather than switching pools — the
283
+ never-switch posture is a first-class policy, not an accident.
284
+
285
+ Classification still exists, but it only *labels*: the card and badge show
286
+ what the provider said (quota wall, billing, rate limit) so the reason is
287
+ diagnosable, and `QUOTA WALL` is only shown when the provider's own words say
288
+ quota — ambiguous prose is never relabeled. The raw provider message stays in
289
+ the ledger/durable state; the card shows the classified reason and recovery
290
+ action. Detached-auditor failures get the same treatment: any infrastructure
291
+ error on a stored completion claim pauses the goal with a durable bounded
292
+ one-shot retry (`auditor retry: …`), and only the plan's 24h horizon stops
293
+ automatic probes.
278
294
 
279
295
  The quota-specific card hides raw provider JSON while preserving it in durable
280
296
  state and the ledger:
@@ -337,6 +353,16 @@ warning says no replacement arrived, restart pi normally and let the saved
337
353
  mux dependency. The legacy `autoReloadOnStale` and `autoRecovery` fields remain
338
354
  only as deprecated settings-file compatibility fields; they are ignored.
339
355
 
356
+ **A stale handle never mutates** (v0.34.51–v0.34.54): every `/list` mutation
357
+ (add/remove/next/clear/cancel) and the bare `/glla` settings surface probe at
358
+ entry and refuse with the standard recovery message on a stale extension
359
+ context — a session that cannot announce or run its writes must not make them.
360
+ Mutating `/glla` actions (wipe/cancel/reviewer/postaudit/tooloverride) leave a
361
+ `settings_mutation_refused_stale` ledger trail; read-only surfaces (`/list
362
+ show`, `/goal status`) stay usable with the warning. Once the replacement
363
+ `session_start` arrives, both surfaces render cleanly with no stale residue
364
+ (the lifecycle-recovery harness proves the two-phase contract).
365
+
340
366
  **User aborts mean STOP** (v0.29.4): Esc-aborting a turn stands the chain
341
367
  down with a named notify (`/goal resume` to continue) — it does NOT count
342
368
  toward stall warnings, does NOT auto re-fire, and the stand-down survives
package/docs/DESIGN.md CHANGED
@@ -197,6 +197,63 @@ architectural decisions that changed the SHAPE of the system:
197
197
  cycle. Manual model selection cancels it; goal/list/loop cancellation clears
198
198
  its timer and durable state.
199
199
 
200
+ ## Addendum v0.34.48–v0.34.56 (lifecycle/recovery hardening — the stale-handle era)
201
+
202
+ Between the v0.34.31 recovery envelope and the v0.34.57 quota fast-engagement, the
203
+ recovery story hardened around the **stale extension handle** — the field-observed
204
+ shape (hegemon/polis 2026-07-26+) where pi invalidates the extension API on session
205
+ replacement without delivering a successor `session_start`:
206
+
207
+ - **Honest stale entry everywhere** (v0.34.51/52): `warnIfStaleAtEntry` probes at
208
+ entry (since v0.28.1), but the probe's return value used to be discarded by the
209
+ mutation paths. Now every `/list` mutation (add/remove/next/clear/cancel) and the
210
+ bare `/glla` settings surface refuse on a stale handle with the standard recovery
211
+ message — a session that cannot announce or run its writes must not make them.
212
+ Mutating `/glla` actions (wipe/cancel/reviewer/postaudit/tooloverride) refuse with
213
+ a `settings_mutation_refused_stale` ledger trail; read-only surfaces stay usable
214
+ with the warning. This is the plugin side of the missing-replacement contract: fail
215
+ closed, never guess, never pretend success.
216
+ - **Settings routing clarity** (v0.34.53): `/list settings` is a verb, handled
217
+ explicitly BEFORE the natural-language dump fallthrough — a ledgered redirect to
218
+ `/glla`, never a drafting seed. `/list add settings …` remains the only way an item
219
+ literally named "settings" enters the queue.
220
+ - **Lifecycle-recovery harness** (v0.34.54): a behavioral suite proves the two-phase
221
+ contract — stale handle: `/list show` warns and does not pretend, settings refuse
222
+ wholesale; fresh `session_start`: both render cleanly with no stale residue.
223
+ - **Command-registration collision model** (v0.34.55): pi's `resolveRegisteredCommands`
224
+ flattens extensions in load order and suffixes EVERY registration of a duplicated
225
+ command name (`name:1`, `name:2`, …) — the bare name becomes owned by nobody and
226
+ dispatch stops routing it while a collision exists. The model is read from the
227
+ installed pi core (hermetic, never modified) and auto-records the routing table to
228
+ `audit/command-registration-routing.md`, making collisions reproducible and
229
+ diagnosable without touching pi.
230
+ - **Unmatched telemetry stays unmatched** (v0.34.56): tool starts/ends without a
231
+ counterpart are represented as explicitly unmatched facts, never falsely paired —
232
+ the report surface stays truthful (the AuditProgress/AuditorProgress dual-interface
233
+ rule: display evidence-gates on `unmatchedStarts + unmatchedEnds > 0`).
234
+ - **Uniform retry envelope, no text-trust** (v0.34.51): error text is not trusted to
235
+ pick a retry policy. Quota, billing, auth, transient, and unknown failures all ride
236
+ ONE bounded durable envelope `15m → 30m → 1h → 2h → 4h → 5h` (cap 5h, automatic
237
+ window 24h); classification only labels the display. The billing-hold special case
238
+ is removed (`main_model_billing_hold` is legacy). Positive-evidence futile classes
239
+ (context/output-token limits, user aborts) plus auditor watchdog timeouts never
240
+ auto-retry; provider hints are honored only within the 5h probe budget.
241
+
242
+ ## Addendum v0.34.57 (quota walls engage recovery fast)
243
+
244
+ - **Knowledge-window escalation**: a surfaced long-lived failure (quota /
245
+ billing / auth) records a 30-minute knowledge window. A send-rearm storm
246
+ inside that window escalates into the recovery envelope after 3 minutes of
247
+ failed sends (plus the unchanged 5-minute activity silence gate) instead of
248
+ the generic 15 minutes — a wedge right after a quota wall is almost always
249
+ the same wall, and blind re-sends into it are pure waste.
250
+ - **Transient failures stay fast**: 5xx/stream/network failures are
251
+ short-lived by definition and never record the knowledge signal; they keep
252
+ the 5s→3m error ladder and the pi-core retry budget.
253
+ - **Armed by configuration**: the envelope is inert without
254
+ `mainModelFallbacks` (rotation) — an empty list means "park and probe the
255
+ same model" instead of switching pools.
256
+
200
257
  ## Addendum v0.4.0 (completion)
201
258
 
202
259
  - **Auditor compaction enabled** (flaw #3 — the last open one). Safety:
package/docs/RELEASING.md CHANGED
@@ -17,6 +17,12 @@ the repository.
17
17
 
18
18
  ## Release checklist
19
19
 
20
+ Accumulated changes since the last release live under an `## Unreleased`
21
+ section at the top of `CHANGELOG.md` (with the in-repo milestone labels such
22
+ as `### 0.34.51`); the release commit renames that section to the released
23
+ version. Do not invent version headers for work that was never tagged —
24
+ untagged work stays under `Unreleased` until the release commit.
25
+
20
26
  ```bash
21
27
  npm version <major.minor.patch> --no-git-tag-version
22
28
  npm run release:check
@@ -0,0 +1,73 @@
1
+ # Vision Assist — see with mmx, not a model switch
2
+
3
+ **v0.34.72** · note.md 2026-08-07: *"the agent is too eager when couldnt see it
4
+ tried to use expensive mdoels. we need to special a vision setting where it
5
+ called another model or cli like mmx vision to see if stuck. but not just this
6
+ we need to specify that it cant be too eager to switch only preapproved."*
7
+
8
+ ## Policy
9
+
10
+ The executor (pi's main agent) has no eyes. When a task needs it to **look**
11
+ at something — a screenshot, a UI state, an error dialog, a rendered mockup —
12
+ it must NOT switch models to get vision. The check routes to the **mmx vision
13
+ CLI** (the `mmx-cli` skill, MiniMax VLM):
14
+
15
+ ```bash
16
+ mmx vision describe --image <path-or-url> --prompt "<question>" --quiet --non-interactive
17
+ ```
18
+
19
+ - The image is usually a screenshot the user already pasted into the
20
+ conversation (e.g. `/home/dracon/Pictures/Screenshots/...`). Pass its path
21
+ straight through.
22
+ - Keep the question short and specific: *"What does this screenshot show?"*,
23
+ *"Is there an error dialog?"*, *"What is the terminal output?"*.
24
+ - Reading the returned description is the agent's job — no model switch
25
+ needed. (Verified 2026-08-07: `mmx vision describe` returns clean JSON/text
26
+ with `status_code: 0`.)
27
+
28
+ ## The preapproval gate (model switches)
29
+
30
+ A model switch is sanctioned **only when the target is preapproved** — i.e.
31
+ NOT in the `forbiddenModels` policy:
32
+
33
+ - Default forbidden list: `gpt-5.5`, `sonnet`, `opus` (matched
34
+ case-insensitively as a substring against the `provider/id` ref).
35
+ - `/glla forbiddenModels=...` edits the list; `blockForbiddenModelSwitches`
36
+ (default on) reverts a forbidden selection to the previous model.
37
+ - Every switch to a forbidden model is ledgered as `forbidden_model_switch`
38
+ (with `blocked: true|false`).
39
+ - With vision assist on (default), the same event also appends a
40
+ `vision_assist` ledger entry — the routing alternative: `{ route:
41
+ "mmx-vision", blockedSwitch: <ref>, reason: "forbidden_model_switch" }`.
42
+
43
+ Even a preapproved vision-capable model is a second choice: mmx vision is the
44
+ default for every vision check.
45
+
46
+ ## The setting
47
+
48
+ `visionAssist` (default **on** — opt-out):
49
+
50
+ - **on** → every continuation prompt carries the `## VISION-ASSIST — SEE WITH
51
+ MMX, NOT A MODEL SWITCH` directive (`extensions/vision-assist.ts`
52
+ `VISION_ASSIST_GUIDANCE`), and a forbidden switch also records the
53
+ `vision_assist` routing entry.
54
+ - **off** → no vision guidance is injected; the `forbiddenModels` gate still
55
+ stands (forbidden switches remain blocked/ledgered).
56
+
57
+ Edit: `/glla` → Keep-going → Vision assist, or `/glla visionAssist=off`.
58
+
59
+ ## Implementation map
60
+
61
+ | Piece | Where |
62
+ |---|---|
63
+ | Guidance block (single source of truth) | `extensions/vision-assist.ts` → `VISION_ASSIST_GUIDANCE` |
64
+ | Command builder | `visionDescribeCommand(imagePath, question?)` |
65
+ | Routing rule (pure) | `routeVisionCheck(request)` — mmx by default; forbidden target → mmx + `blockedSwitch`; preapproved target → `model-switch` allowed |
66
+ | Ledger payload builder | `visionAssistLedger(route, request)` |
67
+ | Continuation injection | `extensions/loops/goal.ts` `continuationPrompt()` (gated on `visionAssist !== false`) |
68
+ | Forbidden-switch hook | `observeModelChange()` forbidden branch → `vision_assist` entry |
69
+ | Setting | `extensions/goal-settings.ts` (default true), menu row in `extensions/settings-menu.ts`, editor + `/glla` row in `extensions/loops/goal.ts` |
70
+ | Tests | `tests/vision-assist.test.ts` |
71
+
72
+ The `vision_assist` ledger type is the audit trail: every entry says where the
73
+ check routed and (when a switch was blocked) which model was refused.
@@ -0,0 +1,130 @@
1
+ // pi-goal-list-loop-audit — v0.2.0
2
+ // extensions/confirm-draft.ts
3
+ //
4
+ // v0.34.78 (GitHub #4): the draft-class confirm dialog as a real TUI
5
+ // component. ctx.ui.select renders plain text with no wrapping; this
6
+ // component renders the SAME title/body as Markdown (objective + contract
7
+ // readable at full width) with a SelectList for the Yes / Yes-and-always /
8
+ // No choices. Kept in its own file so tests can construct and render it
9
+ // without dragging in the whole goal loop.
10
+
11
+ import {
12
+ type Component,
13
+ Container,
14
+ Markdown,
15
+ type MarkdownTheme,
16
+ type SelectItem,
17
+ SelectList,
18
+ type SelectListTheme,
19
+ Spacer,
20
+ Text,
21
+ } from "@earendil-works/pi-tui";
22
+ import { DynamicBorder, type Theme } from "@earendil-works/pi-coding-agent";
23
+
24
+ export interface ConfirmDraftFactoryDeps {
25
+ title: string;
26
+ body: string;
27
+ options: string[];
28
+ }
29
+
30
+ /** Structural type for the KeybindingsManager — mirrors settings-menu.ts. */
31
+ export interface KeybindingsManagerLike {
32
+ matches(data: string, key: string): boolean;
33
+ }
34
+
35
+ /** Pure: the markdown rendered in the dialog. The title is the H1, the
36
+ * body (objective + verification contract) is the content. */
37
+ export function buildConfirmDraftMarkdown(title: string, body: string): string {
38
+ return `# ${title}\n\n${body}`;
39
+ }
40
+
41
+ /** Build a MarkdownTheme from the runtime Theme's fg()/bold() primitives.
42
+ * Uses the theme's own md* colors so the dialog follows the active theme. */
43
+ function markdownTheme(theme: Theme): MarkdownTheme {
44
+ const fg = (color: Parameters<Theme["fg"]>[0]) => (t: string) => theme.fg(color, t);
45
+ return {
46
+ heading: (t) => theme.bold(fg("mdHeading")(t)),
47
+ link: (t) => fg("mdLink")(t),
48
+ linkUrl: (t) => fg("mdLinkUrl")(t),
49
+ code: (t) => fg("mdCode")(t),
50
+ codeBlock: (t) => fg("mdCodeBlock")(t),
51
+ codeBlockBorder: (t) => fg("mdCodeBlockBorder")(t),
52
+ quote: (t) => fg("mdQuote")(t),
53
+ quoteBorder: (t) => fg("mdQuoteBorder")(t),
54
+ hr: (t) => fg("mdHr")(t),
55
+ listBullet: (t) => fg("mdListBullet")(t),
56
+ bold: (t) => theme.bold(t),
57
+ italic: (t) => t,
58
+ strikethrough: (t) => t,
59
+ underline: (t) => t,
60
+ codeBlockIndent: " ",
61
+ };
62
+ }
63
+
64
+ function selectListTheme(theme: Theme): SelectListTheme {
65
+ return {
66
+ selectedPrefix: (t) => theme.fg("accent", t),
67
+ selectedText: (t) => theme.fg("accent", t),
68
+ description: (t) => theme.fg("muted", t),
69
+ scrollInfo: (t) => theme.fg("dim", t),
70
+ noMatch: (t) => theme.fg("warning", t),
71
+ };
72
+ }
73
+
74
+ /**
75
+ * The confirm dialog: DynamicBorder frame, markdown title+body, spacer, the
76
+ * three-choice SelectList, and a help line. Exported so tests can construct
77
+ * it with a fake theme and assert the rendered lines.
78
+ */
79
+ export class ConfirmDraftComponent implements Component {
80
+ private readonly md: Markdown;
81
+ private readonly selectList: SelectList;
82
+ private readonly requestRender: () => void;
83
+ private readonly theme: Theme;
84
+ private readonly keybindings: KeybindingsManagerLike;
85
+
86
+ constructor(
87
+ deps: ConfirmDraftFactoryDeps,
88
+ requestRender: () => void,
89
+ theme: Theme,
90
+ keybindings: KeybindingsManagerLike,
91
+ done: (value: string | undefined) => void,
92
+ ) {
93
+ this.requestRender = requestRender;
94
+ this.theme = theme;
95
+ this.keybindings = keybindings;
96
+ this.md = new Markdown(buildConfirmDraftMarkdown(deps.title, deps.body), 1, 1, markdownTheme(theme));
97
+ const items: SelectItem[] = deps.options.map((o) => ({ value: o, label: o }));
98
+ this.selectList = new SelectList(items, Math.min(items.length, 10), selectListTheme(theme));
99
+ this.selectList.onSelect = (item) => done(item.value);
100
+ this.selectList.onCancel = () => done(undefined);
101
+ }
102
+
103
+ render(width: number): string[] {
104
+ const container = new Container();
105
+ container.addChild(new DynamicBorder((s: string) => this.theme.fg("borderAccent", s)));
106
+ container.addChild(this.md);
107
+ container.addChild(new Spacer(1));
108
+ container.addChild(this.selectList);
109
+ container.addChild(new Spacer(1));
110
+ container.addChild(new Text(this.theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));
111
+ container.addChild(new DynamicBorder((s: string) => this.theme.fg("borderAccent", s)));
112
+ return container.render(width);
113
+ }
114
+
115
+ invalidate(): void {
116
+ this.md.invalidate();
117
+ this.selectList.invalidate();
118
+ this.requestRender();
119
+ }
120
+
121
+ handleInput(data: string): void {
122
+ this.selectList.handleInput(data);
123
+ this.requestRender();
124
+ }
125
+
126
+ /** Exposed for tests. */
127
+ getSelectedItem(): string | null {
128
+ return this.selectList.getSelectedItem()?.value ?? null;
129
+ }
130
+ }