pi-goal-list-loop-audit 0.34.49 → 0.34.57

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,47 @@ 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; a week-long hint is shown and held instead of
267
+ scheduling a hidden week-long timer. With global `autoResume=on`, pending
268
+ probes survive a session reload. For continuous work, configure ordered
269
+ **Main model backups** in `/glla` using a model from a different provider or
270
+ billing/quota pool — another model on the same exhausted plan is not a real
271
+ fallback.
272
+
273
+ **Quota walls engage fast** (v0.34.57): a surfaced long-lived failure
274
+ (quota / billing / auth) records a 30-minute knowledge window; a send-rearm
275
+ storm inside that window escalates into the recovery envelope after **3
276
+ minutes** of failed sends instead of the generic 15 a wedge right after a
277
+ quota wall is almost always the same wall. Transient (5xx/stream/network)
278
+ failures never record the signal and keep the fast error ladder. The
279
+ envelope is armed by configuration: an empty `mainModelFallbacks` list means
280
+ "park and probe the same model" rather than switching pools — the
281
+ never-switch posture is a first-class policy, not an accident.
282
+
283
+ Classification still exists, but it only *labels*: the card and badge show
284
+ what the provider said (quota wall, billing, rate limit) so the reason is
285
+ diagnosable, and `QUOTA WALL` is only shown when the provider's own words say
286
+ quota — ambiguous prose is never relabeled. The raw provider message stays in
287
+ the ledger/durable state; the card shows the classified reason and recovery
288
+ action. Detached-auditor failures get the same treatment: any infrastructure
289
+ error on a stored completion claim pauses the goal with a durable bounded
290
+ one-shot retry (`auditor retry: …`), and only the plan's 24h horizon stops
291
+ automatic probes.
278
292
 
279
293
  The quota-specific card hides raw provider JSON while preserving it in durable
280
294
  state and the ledger:
@@ -337,6 +351,16 @@ warning says no replacement arrived, restart pi normally and let the saved
337
351
  mux dependency. The legacy `autoReloadOnStale` and `autoRecovery` fields remain
338
352
  only as deprecated settings-file compatibility fields; they are ignored.
339
353
 
354
+ **A stale handle never mutates** (v0.34.51–v0.34.54): every `/list` mutation
355
+ (add/remove/next/clear/cancel) and the bare `/glla` settings surface probe at
356
+ entry and refuse with the standard recovery message on a stale extension
357
+ context — a session that cannot announce or run its writes must not make them.
358
+ Mutating `/glla` actions (wipe/cancel/reviewer/postaudit/tooloverride) leave a
359
+ `settings_mutation_refused_stale` ledger trail; read-only surfaces (`/list
360
+ show`, `/goal status`) stay usable with the warning. Once the replacement
361
+ `session_start` arrives, both surfaces render cleanly with no stale residue
362
+ (the lifecycle-recovery harness proves the two-phase contract).
363
+
340
364
  **User aborts mean STOP** (v0.29.4): Esc-aborting a turn stands the chain
341
365
  down with a named notify (`/goal resume` to continue) — it does NOT count
342
366
  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
@@ -39,7 +39,15 @@ export interface AuditorProgress {
39
39
  currentTool?: string;
40
40
  currentToolArgs?: string;
41
41
  currentToolStartedAt?: number;
42
+ /** v0.34.56: the toolCallId of the open start (undefined when the start
43
+ * event carried none — the missing-toolCallId shape). */
44
+ currentToolId?: string;
42
45
  toolCalls: Array<{ name: string; argsPrefix: string; finishedAt: number }>;
46
+ /** v0.34.56: explicitly unmatched tool starts/ends — see
47
+ * applyToolExecutionEvent (goal-loop-auditor.ts) and the worker's mirror
48
+ * in scripts/goal-auditor-worker.mjs. Never dropped, never falsely paired. */
49
+ unmatchedToolStarts: Array<{ name: string; argsPrefix: string; startedAt: number; toolCallId?: string }>;
50
+ unmatchedToolEnds: Array<{ toolCallId?: string; toolName?: string; at: number }>;
43
51
  }
44
52
 
45
53
  export type AuditorModel = string | { provider: string; id: string };
@@ -129,6 +137,10 @@ interface AuditorProgressFile {
129
137
  currentTool?: string;
130
138
  currentToolArgs?: string;
131
139
  currentToolStartedAt?: number;
140
+ /** v0.34.56: explicitly unmatched tool telemetry facts (see
141
+ * applyToolExecutionEvent in goal-loop-auditor.ts). */
142
+ unmatchedToolStarts?: AuditorProgress["unmatchedToolStarts"];
143
+ unmatchedToolEnds?: AuditorProgress["unmatchedToolEnds"];
132
144
  }
133
145
 
134
146
  export interface AuditorProcessRuntime {
@@ -224,9 +236,13 @@ function asProgress(file: AuditorProgressFile, startedAt: number): AuditorProgre
224
236
  ...(file.lastActivityAt !== undefined ? { lastActivityAt: file.lastActivityAt } : {}),
225
237
  recentOutput: file.recentOutput,
226
238
  toolCalls: file.toolCalls,
239
+ unmatchedToolStarts: file.unmatchedToolStarts ?? [],
240
+ unmatchedToolEnds: file.unmatchedToolEnds ?? [],
227
241
  ...(file.currentTool ? { currentTool: file.currentTool } : {}),
228
242
  ...(file.currentToolArgs ? { currentToolArgs: file.currentToolArgs } : {}),
229
243
  ...(file.currentToolStartedAt ? { currentToolStartedAt: file.currentToolStartedAt } : {}),
244
+ ...(file.unmatchedToolStarts ? { unmatchedToolStarts: file.unmatchedToolStarts } : {}),
245
+ ...(file.unmatchedToolEnds ? { unmatchedToolEnds: file.unmatchedToolEnds } : {}),
230
246
  };
231
247
  }
232
248
 
@@ -355,17 +371,19 @@ export async function runDetachedGoalCompletionAuditor(args: {
355
371
  if (parsed.approved && args.goal.verificationContract?.trim()) {
356
372
  const shield = checkRegressionShield(output, args.goal.verificationContract);
357
373
  if (!shield.passed) {
358
- const why = !shield.hasEvidenceBlock ? "report has no <evidence> block" : `report's evidence does not address: ${shield.missingItems.join("; ")}`;
374
+ // The auditor's semantic verdict was approval; the separate
375
+ // regression shield blocked acceptance because the report did
376
+ // not cite every contract item. Keep that outcome distinct from
377
+ // both a work disapproval and infrastructure failure.
359
378
  return {
360
- approved: false, disapproved: true, output, model, thinkingLevel,
361
- error: `regression_shield: approved but ${why}`,
379
+ approved: true, disapproved: false, output, model, thinkingLevel,
362
380
  regressionShieldPassed: false, regressionShieldMissing: shield.missingItems,
363
381
  };
364
382
  }
365
- args.onProgress?.({ phase: "complete", elapsedMs: now() - startedAt, recentOutput: output.split("\n").filter(Boolean).slice(-8), toolCalls: result.toolCalls });
383
+ args.onProgress?.({ phase: "complete", elapsedMs: now() - startedAt, recentOutput: output.split("\n").filter(Boolean).slice(-8), toolCalls: result.toolCalls, unmatchedToolStarts: [], unmatchedToolEnds: [] });
366
384
  return { approved: true, disapproved: false, output, model, thinkingLevel, regressionShieldPassed: true };
367
385
  }
368
- args.onProgress?.({ phase: "complete", elapsedMs: now() - startedAt, recentOutput: output.split("\n").filter(Boolean).slice(-8), toolCalls: result.toolCalls });
386
+ args.onProgress?.({ phase: "complete", elapsedMs: now() - startedAt, recentOutput: output.split("\n").filter(Boolean).slice(-8), toolCalls: result.toolCalls, unmatchedToolStarts: [], unmatchedToolEnds: [] });
369
387
  return { approved: parsed.approved, disapproved: parsed.disapproved, impossible: parsed.impossible, impossibleReason: parsed.impossibleReason, output, model, thinkingLevel };
370
388
  } catch (error) {
371
389
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") return infra(model, thinkingLevel, `invalid auditor result: ${error instanceof Error ? error.message : String(error)}`);
@@ -67,8 +67,80 @@ export interface AuditProgress {
67
67
  currentTool?: string;
68
68
  currentToolArgs?: string;
69
69
  currentToolStartedAt?: number;
70
+ /** v0.34.56: the toolCallId of the open start (undefined when the start
71
+ * event carried none — the missing-toolCallId shape). */
72
+ currentToolId?: string;
70
73
  // Tool-call history for regression_shield:
71
74
  toolCalls: Array<{ name: string; argsPrefix: string; finishedAt: number }>;
75
+ /** v0.34.56: tool_execution_start events whose end never arrived (the
76
+ * start was replaced by a later start with a different/absent id). These
77
+ * are EXPLICIT unmatched facts — never silently re-paired with a wrong
78
+ * end. */
79
+ unmatchedToolStarts: Array<{ name: string; argsPrefix: string; startedAt: number; toolCallId?: string }>;
80
+ /** v0.34.56: tool_execution_end events that provably do not close the
81
+ * open start (wrong/absent id, or no open start at all). Represented
82
+ * explicitly instead of being dropped or falsely paired. */
83
+ unmatchedToolEnds: Array<{ toolCallId?: string; toolName?: string; at: number }>;
84
+ }
85
+
86
+ export type AuditorToolExecutionEvent =
87
+ | { type: "tool_execution_start"; toolCallId?: string; toolName: string; args?: unknown }
88
+ | { type: "tool_execution_end"; toolCallId?: string; toolName?: string };
89
+
90
+ /** v0.34.56: tool-execution pairing, extracted pure so the telemetry is
91
+ * regression-testable. Truth rules:
92
+ *
93
+ * - A start while a start is open with a different (or absent) id means the
94
+ * open start never received its end — record it in unmatchedToolStarts as
95
+ * an explicitly unmatched fact, then adopt the new start.
96
+ * - An end pairs ONLY when: both sides carry the SAME id, or both sides are
97
+ * anonymous (the id-less serial stream — the only pairing an id-less
98
+ * stream can support).
99
+ * - An end with a different id than the open start, an id'd end against an
100
+ * anonymous start, an anonymous end against an id'd start, or an end with
101
+ * no open start at all: recorded in unmatchedToolEnds. NEVER paired with
102
+ * the wrong start, NEVER silently dropped.
103
+ */
104
+ export function applyToolExecutionEvent(
105
+ telemetry: AuditProgress,
106
+ event: AuditorToolExecutionEvent,
107
+ now: number,
108
+ ): AuditProgress {
109
+ if (event.type === "tool_execution_start") {
110
+ const sameId = event.toolCallId !== undefined && event.toolCallId === telemetry.currentToolId;
111
+ if (telemetry.currentTool !== undefined && !sameId) {
112
+ telemetry.unmatchedToolStarts.push({
113
+ name: telemetry.currentTool,
114
+ argsPrefix: telemetry.currentToolArgs ?? "",
115
+ startedAt: telemetry.currentToolStartedAt ?? now,
116
+ toolCallId: telemetry.currentToolId,
117
+ });
118
+ }
119
+ telemetry.currentTool = event.toolName;
120
+ telemetry.currentToolArgs = typeof event.args === "object" && event.args !== null
121
+ ? JSON.stringify(event.args).slice(0, 120)
122
+ : String(event.args ?? "").slice(0, 120);
123
+ telemetry.currentToolStartedAt = now;
124
+ telemetry.currentToolId = event.toolCallId;
125
+ return telemetry;
126
+ }
127
+ const open = telemetry.currentTool;
128
+ if (open !== undefined) {
129
+ const idMatches = event.toolCallId !== undefined && telemetry.currentToolId !== undefined && event.toolCallId === telemetry.currentToolId;
130
+ const bothAnonymous = event.toolCallId === undefined && telemetry.currentToolId === undefined;
131
+ if (idMatches || bothAnonymous) {
132
+ telemetry.toolCalls.push({ name: open, argsPrefix: telemetry.currentToolArgs ?? "", finishedAt: now });
133
+ telemetry.currentTool = undefined;
134
+ telemetry.currentToolArgs = undefined;
135
+ telemetry.currentToolStartedAt = undefined;
136
+ telemetry.currentToolId = undefined;
137
+ return telemetry;
138
+ }
139
+ // The end provably does not close the open start — the start stays open
140
+ // (it has not ended) and the end is recorded as an unmatched fact.
141
+ }
142
+ telemetry.unmatchedToolEnds.push({ toolCallId: event.toolCallId, toolName: event.toolName, at: now });
143
+ return telemetry;
72
144
  }
73
145
 
74
146
  export type AuditorProgressCallback = (progress: AuditProgress) => void;
@@ -223,6 +295,8 @@ export async function runGoalCompletionAuditor(args: {
223
295
  return { approved: false, disapproved: false, output: "", model: "(unset)", thinkingLevel, error: "no model (session model also unset)" };
224
296
  }
225
297
  const toolCalls: AuditProgress["toolCalls"] = [];
298
+ const unmatchedToolStarts: AuditProgress["unmatchedToolStarts"] = [];
299
+ const unmatchedToolEnds: AuditProgress["unmatchedToolEnds"] = [];
226
300
 
227
301
  try {
228
302
  const startedAt = Date.now();
@@ -231,6 +305,8 @@ export async function runGoalCompletionAuditor(args: {
231
305
  phase: "running",
232
306
  elapsedMs: 0,
233
307
  toolCalls,
308
+ unmatchedToolStarts,
309
+ unmatchedToolEnds,
234
310
  };
235
311
  function emitProgress(): void {
236
312
  progress.elapsedMs = Date.now() - startedAt;
@@ -291,26 +367,24 @@ export async function runGoalCompletionAuditor(args: {
291
367
  if (typeof msg === "string") streamError = msg.slice(0, 300);
292
368
  }
293
369
  if (event.type === "tool_execution_start") {
294
- progress.currentTool = event.toolName;
295
- progress.currentToolArgs = typeof event.args === "object" && event.args !== null
296
- ? JSON.stringify(event.args).slice(0, 120)
297
- : String(event.args ?? "").slice(0, 120);
298
- progress.currentToolStartedAt = Date.now();
370
+ // v0.34.56: pairing lives in the pure applyToolExecutionEvent — the
371
+ // telemetry regression test drives it directly.
372
+ applyToolExecutionEvent(progress, {
373
+ type: "tool_execution_start",
374
+ toolCallId: (anyEvent.toolCallId as string | undefined) ?? undefined,
375
+ toolName: event.toolName,
376
+ args: anyEvent.args,
377
+ }, Date.now());
299
378
  progress.phase = "tool_executing";
300
379
  emitProgress();
301
380
  return;
302
381
  }
303
382
  if (event.type === "tool_execution_end") {
304
- if (progress.currentTool) {
305
- toolCalls.push({
306
- name: progress.currentTool,
307
- argsPrefix: progress.currentToolArgs ?? "",
308
- finishedAt: Date.now(),
309
- });
310
- }
311
- progress.currentTool = undefined;
312
- progress.currentToolArgs = undefined;
313
- progress.currentToolStartedAt = undefined;
383
+ applyToolExecutionEvent(progress, {
384
+ type: "tool_execution_end",
385
+ toolCallId: (anyEvent.toolCallId as string | undefined) ?? undefined,
386
+ toolName: anyEvent.toolName as string | undefined,
387
+ }, Date.now());
314
388
  progress.phase = "running";
315
389
  emitProgress();
316
390
  return;
@@ -429,16 +503,15 @@ export async function runGoalCompletionAuditor(args: {
429
503
  if (approved && args.goal.verificationContract?.trim()) {
430
504
  const shield = checkRegressionShield(output, args.goal.verificationContract);
431
505
  if (!shield.passed) {
432
- const why = !shield.hasEvidenceBlock
433
- ? "report has no <evidence> block"
434
- : `report's evidence does not address: ${shield.missingItems.join("; ")}`;
506
+ // Keep the auditor's semantic approval distinct from the
507
+ // orchestrator-side shield block. This is neither a disapproval of
508
+ // the work nor an infrastructure failure.
435
509
  return {
436
- approved: false,
437
- disapproved: true,
510
+ approved: true,
511
+ disapproved: false,
438
512
  output,
439
513
  model: modelLabel(model),
440
514
  thinkingLevel,
441
- error: `regression_shield: approved but ${why}`,
442
515
  regressionShieldPassed: false,
443
516
  regressionShieldMissing: shield.missingItems,
444
517
  };
@@ -35,6 +35,26 @@ export type Status =
35
35
 
36
36
  export type Policy = "goal" | "list"; // v0.3.0: "loop".
37
37
 
38
+ /** User-facing controls whose command root follows the active goal policy. */
39
+ export type ModeCommand = "pause" | "tweak" | "resume";
40
+
41
+ /** Return the command root for a supervised work surface. */
42
+ export function workCommandRoot(mode: Policy | "loop" | undefined): "/goal" | "/list" | "/loop" {
43
+ if (mode === "list") return "/list";
44
+ if (mode === "loop") return "/loop";
45
+ return "/goal";
46
+ }
47
+
48
+ /** Build a mode-correct pause/tweak/resume command for a goal or list item. */
49
+ export function modeCommand(mode: Policy | undefined, command: ModeCommand): string {
50
+ return `${workCommandRoot(mode)} ${command}`;
51
+ }
52
+
53
+ /** Build a command for a goal, list item, or metric loop. */
54
+ export function workCommand(mode: Policy | "loop" | undefined, command: string): string {
55
+ return `${workCommandRoot(mode)} ${command}`;
56
+ }
57
+
38
58
  export interface Task {
39
59
  id: string;
40
60
  title: string;
@@ -114,6 +134,26 @@ export interface AuditVerdict {
114
134
  regressionShieldMissing?: string[];
115
135
  }
116
136
 
137
+ /** The display classification for one stored auditor result. Keep semantic
138
+ * verdicts separate from operational failures: a shield-blocked approval is
139
+ * not a disapproval, and an infrastructure error is not a verdict at all. */
140
+ export type AuditVerdictLabel =
141
+ | "approved"
142
+ | "disapproved"
143
+ | "impossible"
144
+ | "shield-blocked"
145
+ | "infrastructure failure"
146
+ | "no verdict";
147
+
148
+ export function auditVerdictLabel(v: Pick<AuditVerdict, "approved" | "disapproved" | "impossible" | "error" | "regressionShieldPassed">): AuditVerdictLabel {
149
+ if (v.approved && v.regressionShieldPassed === false) return "shield-blocked";
150
+ if (v.approved) return "approved";
151
+ if (v.impossible) return "impossible";
152
+ if (v.disapproved) return "disapproved";
153
+ if (v.error) return "infrastructure failure";
154
+ return "no verdict";
155
+ }
156
+
117
157
  /**
118
158
  * Sum token usage across assistant messages, counting each message once.
119
159
  * `agent_end` events may include already-seen history, so callers pass a
@@ -307,6 +347,45 @@ export function listMutationBlocked(draftingTarget: string | null): boolean {
307
347
  export const LIST_DRAFTING_BLOCK_MESSAGE =
308
348
  "LIST DRAFTING IN PROGRESS — do not add items one by one. Decompose the request into an items[] array and call propose_goal_draft ONCE: the user confirms the whole batch in a single dialog. list_add / list_activate work again after the drafting session ends.";
309
349
 
350
+ /**
351
+ * v0.34.51: /list subcommands that persist or activate work — refused on a
352
+ * stale extension handle by cmdList's entry probe. Read-only verbs (show,
353
+ * depth, status) stay available so the user can still inspect the queue
354
+ * while the lifecycle replacement is pending. The natural-language dump
355
+ * fallthrough is always mutating and is gated separately at the call site.
356
+ */
357
+ export const LIST_MUTATING_SUBCOMMANDS = new Set([
358
+ "audit",
359
+ "tweak",
360
+ "pause",
361
+ "resume",
362
+ "add",
363
+ "import",
364
+ "clear",
365
+ "cancel",
366
+ "next",
367
+ "remove",
368
+ "rm",
369
+ ]);
370
+
371
+ /**
372
+ * v0.34.52: /glla verbs that open a settings surface or persist config —
373
+ * refused on a stale extension handle by cmdSettings' entry probe, mirroring
374
+ * the /list gate. Bare /glla (verb "ui") is the settings entry itself: every
375
+ * table choice writes state. wipe/reset/cancel/resume/reviewer/postaudit/
376
+ * tooloverride mutate directly. Read-only surfaces (status, log, stats,
377
+ * audits) and the unknown-action notice stay available for inspection.
378
+ */
379
+ export const SETTINGS_MUTATING_ACTIONS = new Set([
380
+ "wipe",
381
+ "reset",
382
+ "cancel",
383
+ "resume",
384
+ "reviewer",
385
+ "postaudit",
386
+ "tooloverride",
387
+ ]);
388
+
310
389
  /**
311
390
  * Route natural-language text handed to `/list` with no subcommand verb
312
391
  * (v0.18.0). The user typed a dump — "fix x, do y, write docs" — not a
@@ -725,7 +804,7 @@ export function renderGoalMarkdown(goal: Goal): string {
725
804
  lines.push("## Audit history");
726
805
  lines.push("");
727
806
  for (const v of goal.auditHistory) {
728
- lines.push(`- ${v.at} — ${v.approved ? "approved" : v.impossible ? "impossible" : "disapproved"} — \`${v.model}\``);
807
+ lines.push(`- ${v.at} — ${auditVerdictLabel(v)} — \`${v.model}\``);
729
808
  }
730
809
  lines.push("");
731
810
  }
@@ -899,15 +978,27 @@ export function draftContractItemCount(normalized: string): number {
899
978
  * module so tests exercise THIS function, not a copy (the pre-0.23.7 test
900
979
  * re-implemented it and silently went stale).
901
980
  */
902
- export function extractVerificationContract(raw: string): { objective: string; verificationContract: string } {
981
+ export function extractVerificationContract(raw: string): { objective: string; verificationContract: string; explicitClear: boolean } {
903
982
  // Line-based first: a marker at line start begins the contract block.
904
983
  const lines = raw.split("\n");
905
984
  let mode: "obj" | "verify" = "obj";
906
985
  const objParts: string[] = [];
907
986
  const verifyParts: string[] = [];
987
+ // v0.34.51: a BARE contract marker ("Done when:" with nothing after it) is
988
+ // an explicit CLEAR signal — the caller wipes the stored contract instead
989
+ // of preserving or replacing it. The bare marker line is consumed, never
990
+ // kept as contract text; later lines after a bare marker still belong to
991
+ // the contract block.
992
+ let explicitClear = false;
993
+ const MARKER_START = /^\s*(?:done when|verified when|verify|verification|done)\b[^:]*:/i;
908
994
  for (const line of lines) {
909
- if (line.match(/^\s*(?:done when|verified when|verify|verification|done)\b[^:]*:/i)) {
995
+ const m = line.match(MARKER_START);
996
+ if (m) {
910
997
  mode = "verify";
998
+ if (!line.slice(m[0].length).trim()) {
999
+ explicitClear = true;
1000
+ continue;
1001
+ }
911
1002
  }
912
1003
  if (mode === "obj") objParts.push(line);
913
1004
  else verifyParts.push(line);
@@ -923,9 +1014,16 @@ export function extractVerificationContract(raw: string): { objective: string; v
923
1014
  if (m) {
924
1015
  objective = (m[1] ?? "").trim().replace(/[.;]\s*$/, "");
925
1016
  verificationContract = (m[2] ?? "").trim();
1017
+ } else if (!explicitClear) {
1018
+ // Trailing bare marker mid-line: "Do x. Done when:" — explicit clear.
1019
+ const empty = raw.match(/^(.*?)\s+(?:done when|verified when|verify|verification)\b[^:]*:\s*$/is);
1020
+ if (empty) {
1021
+ objective = (empty[1] ?? "").trim().replace(/[.;]\s*$/, "");
1022
+ explicitClear = true;
1023
+ }
926
1024
  }
927
1025
  }
928
- return { objective, verificationContract };
1026
+ return { objective, verificationContract, explicitClear };
929
1027
  }
930
1028
 
931
1029
  /**
@@ -1440,7 +1538,8 @@ export function formatGoalAuditHistory(goal: { id: string; auditHistory?: Array<
1440
1538
  if (history.length === 0) return "(no audits on this goal yet)";
1441
1539
  return history
1442
1540
  .map((v) => {
1443
- const glyph = v.approved ? (v.regressionShieldPassed === false ? "🛡" : "✔") : v.impossible ? "⛔" : v.disapproved ? "✖" : "⚠";
1541
+ const verdict = auditVerdictLabel(v);
1542
+ const glyph = verdict === "approved" ? "✔" : verdict === "shield-blocked" ? "🛡" : verdict === "impossible" ? "⛔" : verdict === "disapproved" ? "✖" : "⚠";
1444
1543
  const day = String(v.at ?? "").slice(5, 16).replace("T", " ");
1445
1544
  const elapsed = v.durationMs ? ` · ${Math.round(v.durationMs / 60000)}m` : "";
1446
1545
  const firstLine = (String(v.report ?? "").split("\n").find((l: string) => l.trim()) ?? "").trim().slice(0, 80);