pi-goal-list-loop-audit 0.38.5 → 0.38.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.38.7 — session visibility: recovery banner + durable verdict tally (2026-09-03)
4
+
5
+ ### Added
6
+ Load-hold recovery banner: a consent-less cold load that engages the load hold now also paints objective + next pending task + verdict tally + resume command (`/goal`/`/list`/`/loop` by policy), all from durable disk state — the transcript is empty in exactly the sessions that need this. Fires once with the fresh hold; ledger `load_hold_recovery_banner`. Durable verdict tally (`auditorVerdictTally` via `auditVerdictLabel`): disapproval count + last-verdict age on the auditing status-line footer and the `/goal status` `Audits:` line, silent when history is empty — a reloaded session answers "are we progressing?" from stored verdicts.
7
+
8
+ ### Fixed
9
+ Reload invisibility: resumed-but-empty sessions no longer look goal-less, and capped/queued auditor sessions show stored-verdict evidence instead of a dead surface. Hold mechanics, hold text, auditor phase machine, and auditor card untouched. `tests/session-visibility.test.ts` (5 tests) pins tally classification, banner text, status-line tally, and a behavioral reload.
10
+
11
+ ## 0.38.6 — over-cap starvation ladder: compact first, then 5 ordered recoveries (2026-09-03)
12
+
13
+ ### Added
14
+ Compact-first nudge: at 85% context (below the 90% starvation line) a one-shot-per-episode notify to run `/compact` while summarization still fits (episode resets below 80% or on compaction; ledger `context_compact_first_nudge`). The agent_end starvation yield notify now carries the full over-cap ladder — `/compact` retry after GLLA's deterministic trim, larger-context model (GLLA already auto-rotates its fallback chain after a failed compact-and-retry, v0.34.116), `/new` + `/goal resume` from durable disk state with no summarization needed — and states that automatic turns stay parked.
15
+
16
+ ### Fixed
17
+ Over-cap spin: `sendContinuation` now refuses while `isContextStarvedRefused()` — the single choke point every automatic path funnels through — so a 119–243% session stops queueing truncating turns (ledger `continuation_send_refused_context_starved`, silent; heartbeat one-shot + yield ladder own messaging). The refuse stays sticky while the last-known percent is ≥90% instead of lapsing 90s after the last yield. Heartbeat refuse branch/ledger/text unchanged (pinned). `tests/starvation-ladder.test.ts` (6 tests) pins band, ladder text, nudge episodes, sticky matrix, wiring, and a behavioral starved boot.
18
+
3
19
  ## 0.38.5 — delta-only goal continuation: marker-only steady-state (2026-09-03)
4
20
 
5
21
  ### Fixed
package/docs/INDEX.md CHANGED
@@ -18,7 +18,7 @@ For shipped docs, the relevant entry points are:
18
18
  failback; v0.35.9 hardened cross-version npm tarball checks; v0.35.10
19
19
  handles multi-entry npm dry-run reports; v0.35.11 accepts both npm report
20
20
  shapes; v0.35.12 supports npm 12's keyed pack reports; v0.35.13 fixes stale-API recovery loops.
21
- v0.35.14–v0.38.5 continue through the supervisor freeze (`/glla pause`),
21
+ v0.35.14–v0.38.7 continue through the supervisor freeze (`/glla pause`),
22
22
  load hold, auditor picker parity, Windows launch fix, zombie-watchdog
23
23
  subagent carve-out, due-wait backstop, the `/glla agents` visibility panel,
24
24
  durable state-root selection, blank-until-resume auditor context, frozen
@@ -23,7 +23,7 @@ import {
23
23
  } from "./goal-loop-core.js";
24
24
  import { clearDispatchRecord, dispatchRecordExists } from "./goal-loop-dispatch.js";
25
25
  import type { AuditDisplayProgress } from "./goal-loop-display.js";
26
- import { fmtElapsed } from "./goal-loop-display.js";
26
+ import { auditorVerdictTally, fmtElapsed, formatVerdictTallySegment } from "./goal-loop-display.js";
27
27
  import { AUDIT_FINDINGS_REL, HELD_ON_RESTORE, LOOP_AUDIT_MARKER, listAuditCollectTarget, projectAuditTarget } from "./goal-loop-forever.js";
28
28
  import { buildLoopCompletionSummary, compactCompletionSummary, compactTerminalCompletionSummary } from "./completion-summary.js";
29
29
  import { ProjectRollup, discoverGllaProjects, filterPremature, formatRollupJson, formatRollupTable, rollupProject } from "./goal-loop-stats.js";
@@ -381,8 +381,12 @@ async function cmdStatus(ctx: ExtensionContext): Promise<void> {
381
381
  `Tokens: ${(g.usage?.tokensUsed ?? 0).toLocaleString()}${(g.usage?.tokensLimit ?? 0) > 0 ? ` / ${(g.usage!.tokensLimit).toLocaleString()}` : " (no cap — set Token limit in /glla settings)"}`,
382
382
  ...formatMainModelRecoveryStatus(state.mainModelRecovery, normalizeMainModelFallbackRefs(loadSettings(ctx.cwd).mainModelFallbacks)),
383
383
  ];
384
- if (g.auditHistory && g.auditHistory.length > 0) {
385
- lines.push(`Audits: ${g.auditHistory.length} (${g.auditHistory.filter((v) => v.approved).length} approved)`);
384
+ // v0.38.7: /goal status names disapprovals + last-verdict age, not just
385
+ // the approval count — a capped/queued session must show what unblocks.
386
+ const statusTally = auditorVerdictTally(g.auditHistory);
387
+ if (statusTally.total > 0) {
388
+ const tallyText = formatVerdictTallySegment(statusTally);
389
+ lines.push(`Audits: ${tallyText} (${statusTally.approvals} approved)`);
386
390
  }
387
391
  if (g.status === "auditing") {
388
392
  lines.push(`Completion audit: ${isCompletionAuditRecoveryPending(g) ? `recovery pending — ${activeGoalSurfaceCommand("resume")} retries the stored claim` : flags.completionAuditInFlight && flags.latestAuditProgress?.label === "queued" ? "detached auditor queued" : flags.completionAuditInFlight ? "detached auditor running" : "awaiting lifecycle recovery"}`);
@@ -146,6 +146,7 @@ export interface ContinuationDeps {
146
146
  isForeignCtx(ctx: ExtensionContext): boolean;
147
147
  sessionManagerId(ctx: ExtensionContext): string;
148
148
  isActionableGoal(): boolean;
149
+ isContextStarvedRefused(): boolean;
149
150
  isSupervising(): boolean;
150
151
  goalNoun(): string;
151
152
  activeGoalSurfaceCommand(command: string): string;
@@ -170,6 +171,7 @@ let goStaleTerminal: ContinuationDeps["goStaleTerminal"];
170
171
  let isForeignCtx: ContinuationDeps["isForeignCtx"];
171
172
  let sessionManagerId: ContinuationDeps["sessionManagerId"];
172
173
  let isActionableGoal: ContinuationDeps["isActionableGoal"];
174
+ let isContextStarvedRefused: ContinuationDeps["isContextStarvedRefused"];
173
175
  let isSupervising: ContinuationDeps["isSupervising"];
174
176
  let goalNoun: ContinuationDeps["goalNoun"];
175
177
  let activeGoalSurfaceCommand: ContinuationDeps["activeGoalSurfaceCommand"];
@@ -194,6 +196,7 @@ export function createGoalContinuation(flagsArg: ContinuationFlags, d: Continuat
194
196
  isForeignCtx = d.isForeignCtx;
195
197
  sessionManagerId = d.sessionManagerId;
196
198
  isActionableGoal = d.isActionableGoal;
199
+ isContextStarvedRefused = d.isContextStarvedRefused;
197
200
  isSupervising = d.isSupervising;
198
201
  goalNoun = d.goalNoun;
199
202
  activeGoalSurfaceCommand = d.activeGoalSurfaceCommand;
@@ -1140,6 +1143,15 @@ export function sendContinuation(goalId: string): void {
1140
1143
  continuationTimer = scheduleSessionTimeout(() => sendContinuation(goalId), BACKOFF_IDLE_RETRY_MS);
1141
1144
  return;
1142
1145
  }
1146
+ // v0.38.6: the starvation choke point — EVERY automatic send path funnels
1147
+ // through here (agent_end, heartbeat rearm, loop tick, recovery). While
1148
+ // the session is starved with no compaction, no turn can land, so refuse
1149
+ // SILENTLY (ledger only): the heartbeat one-shot + agent_end yield ladder
1150
+ // own user messaging, and a refused send must not rearm a timer spin.
1151
+ if (isContextStarvedRefused()) {
1152
+ appendLedger(ctx.cwd, "continuation_send_refused_context_starved", { goalId, generation: flags.sessionGeneration });
1153
+ return;
1154
+ }
1143
1155
  if (!guardGoalBeforeContinuation(ctx, "dispatch", goalId)) return;
1144
1156
  if (!isActionableGoal()) return;
1145
1157
  if (!ctx.isIdle() || ctx.hasPendingMessages()) {
@@ -13,7 +13,7 @@
13
13
  import { truncateToWidth as tuiTruncateToWidth, visibleWidth as tuiVisibleWidth } from "@earendil-works/pi-tui";
14
14
 
15
15
  import type { DurableDeferRecommendationInput, Goal, MainModelRecovery, State } from "./goal-loop-core.js";
16
- import { buildDurableDeferRecommendation, compactDisplayText, formatMainModelRecoveryStatus, isMonitorGoal, isPersistenceDegraded, lastPersistenceFailure, sanitizeDisplayText, sanitizeProviderAuditReport, sanitizeProviderDisplayText, stripThinkBlocks } from "./goal-loop-core.js";
16
+ import { auditVerdictLabel, buildDurableDeferRecommendation, compactDisplayText, formatMainModelRecoveryStatus, isMonitorGoal, isPersistenceDegraded, lastPersistenceFailure, sanitizeDisplayText, sanitizeProviderAuditReport, sanitizeProviderDisplayText, stripThinkBlocks } from "./goal-loop-core.js";
17
17
 
18
18
  export { isMonitorGoal };
19
19
  import { HELD_ON_RESTORE, type LoopState } from "./goal-loop-forever.js";
@@ -432,6 +432,68 @@ function latestAuditFeedback(g: Goal): LatestAuditFeedback | undefined {
432
432
  };
433
433
  }
434
434
 
435
+ /** v0.38.7 (note.md Next: reload recovery + progress signal): durable
436
+ * verdict tally — disapproval count + last-verdict age from auditHistory.
437
+ * After a reload the in-memory auditor progress is gone; the stored
438
+ * verdicts are what answers "are we progressing?". Classification goes
439
+ * through auditVerdictLabel so a shield-blocked approval is never counted
440
+ * as a disapproval and an infra error is never counted as a verdict. */
441
+ export interface AuditorVerdictTally {
442
+ total: number;
443
+ approvals: number;
444
+ disapprovals: number;
445
+ lastAt: number | null;
446
+ lastLabel: string | null;
447
+ }
448
+ export function auditorVerdictTally(history: Goal["auditHistory"], now = Date.now()): AuditorVerdictTally {
449
+ void now;
450
+ const entries = Array.isArray(history) ? history : [];
451
+ let approvals = 0;
452
+ let disapprovals = 0;
453
+ for (const v of entries) {
454
+ const label = auditVerdictLabel(v);
455
+ if (label === "approved") approvals++;
456
+ else if (label === "disapproved") disapprovals++;
457
+ }
458
+ const last = entries[entries.length - 1];
459
+ const lastMs = last ? Date.parse(last.at) : Number.NaN;
460
+ return {
461
+ total: entries.length,
462
+ approvals,
463
+ disapprovals,
464
+ lastAt: last && Number.isFinite(lastMs) ? lastMs : null,
465
+ lastLabel: last ? auditVerdictLabel(last) : null,
466
+ };
467
+ }
468
+ /** Compact tally segment for the always-on surfaces. "" when no verdicts. */
469
+ export function formatVerdictTallySegment(t: AuditorVerdictTally, now = Date.now()): string {
470
+ if (t.total <= 0) return "";
471
+ const dis = t.disapprovals > 0 ? ` · ${t.disapprovals} disapproved` : "";
472
+ const age = t.lastAt !== null && t.lastLabel ? ` · last ${t.lastLabel} ${fmtElapsed(now - t.lastAt)} ago` : "";
473
+ return `${t.total} verdict${t.total === 1 ? "" : "s"}${dis}${age}`;
474
+ }
475
+ /** v0.38.7: the load-hold recovery banner — objective + next task + verdict
476
+ * tally + resume command, all from durable disk state (never transcript
477
+ * memory, which is empty in exactly the sessions that need this). */
478
+ export interface LoadHoldRecoverySummary {
479
+ objective?: string | null;
480
+ status?: string;
481
+ nextTask?: string | null;
482
+ tally: AuditorVerdictTally;
483
+ resumeCommand: string;
484
+ listWaiting?: number;
485
+ }
486
+ export function buildLoadHoldRecoveryLines(s: LoadHoldRecoverySummary, now = Date.now()): string[] {
487
+ const lines = [
488
+ `glla: recovered from disk — "${truncate((s.objective ?? "").trim() || "(no objective recorded)", 120)}" (${s.status ?? "held"})`,
489
+ ];
490
+ lines.push(s.nextTask ? `next: ${truncate(s.nextTask, 100)}` : `next: no pending tasks recorded`);
491
+ const tally = formatVerdictTallySegment(s.tally, now);
492
+ lines.push(tally ? `audits: ${tally}` : `audits: none yet`);
493
+ if ((s.listWaiting ?? 0) > 0) lines.push(`list: ${s.listWaiting} waiting — /list to manage`);
494
+ lines.push(`run ${s.resumeCommand} to continue`);
495
+ return lines;
496
+ }
435
497
  function activeAttention(g: Goal): ActiveAttention | undefined {
436
498
  if (g.status !== "active" || !g.pauseReason) return undefined;
437
499
  if (/regression shield/i.test(g.pauseReason)) {
@@ -954,7 +1016,12 @@ function buildStatusTextBase(state: State, audit?: AuditDisplayProgress | null,
954
1016
  const quietSuffix = quietAge !== undefined ? ` · silent ${fmtElapsed(quietAge)}` : "";
955
1017
  const next = ` · next: ${auditorNextTransition(phase)}`;
956
1018
  const detachedSuffix = live ? "" : " · detached worker";
957
- return `glla: ${host} · ${label}${quietSuffix}${next}${detachedSuffix}${heldSuffix}`;
1019
+ // v0.38.7: durable verdict tally on the always-on footer — after a
1020
+ // reload there is no live auditor evidence, so the stored verdicts
1021
+ // (disapproval count + last-verdict age) answer "are we progressing?".
1022
+ const tallyText = formatVerdictTallySegment(auditorVerdictTally(g.auditHistory, now), now);
1023
+ const verdictSuffix = tallyText ? ` · ${tallyText}` : "";
1024
+ return `glla: ${host} · ${label}${quietSuffix}${next}${detachedSuffix}${verdictSuffix}${heldSuffix}`;
958
1025
  }
959
1026
  if (g.status === "paused") {
960
1027
  // v0.28.22: the status line names the ACTIONABILITY, not the reason —
@@ -238,7 +238,7 @@ import {
238
238
  textFingerprint,
239
239
  pushCapped as pushRepetitionCapped,
240
240
  } from "../goal-loop-repetition.js";
241
- import { buildStatusText, buildWidgetLines, type AuditDisplayProgress } from "../goal-loop-display.js";
241
+ import { auditorVerdictTally, buildLoadHoldRecoveryLines, buildStatusText, buildWidgetLines, type AuditDisplayProgress } from "../goal-loop-display.js";
242
242
  import { compactLoopCompletionSummary } from "../completion-summary.js";
243
243
  import {
244
244
  defaultAgentDir,
@@ -1864,6 +1864,34 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
1864
1864
  "Loaded without starting: your goal/list/loop state is restored and shown below, but automation is HELD for your decision. /goal resume, /list resume, or /list next starts work; enable Auto-resume in /glla settings to restore load-time automation.",
1865
1865
  "warning",
1866
1866
  );
1867
+ // v0.38.7 (note.md Next: objectives seemingly lost on reload) —
1868
+ // the recovery banner: objective + next task + verdict tally +
1869
+ // resume command, all from durable disk state. The transcript is
1870
+ // empty in exactly the sessions that need this, so nothing here
1871
+ // may depend on in-memory progress. Fires with the fresh hold
1872
+ // (guarded above), never as a timer re-arm.
1873
+ const tasks = state.goal?.taskList?.tasks ?? [];
1874
+ const pendingTasks = tasks.filter((t) => t.status === "pending" || t.status === "in_progress");
1875
+ const tally = auditorVerdictTally(state.goal?.auditHistory);
1876
+ const resumeCommand = state.goal
1877
+ ? (state.goal.policy === "list" ? "/list resume" : "/goal resume")
1878
+ : (state.list?.length ?? 0) > 0 ? "/list resume" : state.loop ? "/loop resume" : "/goal resume";
1879
+ const banner = buildLoadHoldRecoveryLines({
1880
+ objective: state.goal?.objective ?? ((state.list?.length ?? 0) > 0 ? `${state.list!.length} queued list items` : null),
1881
+ status: state.goal?.status ?? (state.loop ? "loop" : "held"),
1882
+ nextTask: pendingTasks[0]?.title ?? null,
1883
+ tally,
1884
+ resumeCommand,
1885
+ listWaiting: state.goal?.policy === "list" ? undefined : state.list?.length ?? 0,
1886
+ });
1887
+ appendLedger(ctx.cwd, "load_hold_recovery_banner", {
1888
+ goalId: state.goal?.id ?? null,
1889
+ status: state.goal?.status ?? null,
1890
+ pendingTasks: pendingTasks.length,
1891
+ totalVerdicts: tally.total,
1892
+ disapprovals: tally.disapprovals,
1893
+ });
1894
+ ctx.ui.notify(banner.join("\n"), "info");
1867
1895
  }
1868
1896
  } else if ((autoResume || explicitRecovery) && typeof state.loadHoldAt === "number") {
1869
1897
  // v0.35.28 (issue #16): a hold persisted by a PREVIOUS process must
@@ -1980,7 +2008,11 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
1980
2008
  return;
1981
2009
  }
1982
2010
  }
1983
- ctx.ui.notify("glla: output-token stop was context starvation (tiny output at a nearly full context) — yielding to pi auto-compaction instead of re-sending.", "info");
2011
+ // v0.38.6: the yield now carries the full over-cap ladder (retry
2012
+ // /compact after GLLA's deterministic trim, larger-context model,
2013
+ // /new + resume from durable state) instead of only the auto-compact
2014
+ // pointer — field 2026-09-03: summarization itself was failing.
2015
+ ctx.ui.notify(buildStarvationLadderMessage({ percent: typeof contextUsage?.percent === "number" ? contextUsage.percent : null, streak: starved.streak }), "info");
1984
2016
  return;
1985
2017
  }
1986
2018
  if (lc.giveUpNow) {
@@ -2187,6 +2219,20 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
2187
2219
  if (!state.goal) return;
2188
2220
  if (state.goal.status !== "active") return;
2189
2221
  clearContinuationTimer();
2222
+ // v0.38.6: feed the sticky refuse gate + compact-first nudge with the
2223
+ // live percent. Placed AFTER the goal gate: the length-continue order
2224
+ // pin requires the length path first, and starved sessions already
2225
+ // returned via the yield ladder above. Recording null clears a stale
2226
+ // reading (host estimate gone) so the refuse can lapse instead of
2227
+ // sticking forever.
2228
+ noteContextPercent(typeof contextUsage?.percent === "number" ? contextUsage.percent : null);
2229
+ if (shouldCompactFirstNudge(contextUsage?.percent)) {
2230
+ appendLedger(ctx.cwd, "context_compact_first_nudge", {
2231
+ goalId: state.goal?.id ?? null,
2232
+ contextPercent: contextUsage?.percent ?? null,
2233
+ });
2234
+ ctx.ui.notify(`glla: context at ${contextUsage!.percent!.toFixed(1)}% — run /compact now while summarization still fits. Below 90% the compact succeeds; past it you get the over-cap ladder (retry /compact, larger model, or /new + resume).`, "info");
2235
+ }
2190
2236
 
2191
2237
  const last = [...(event.messages as any[])].reverse().find((m) => m.role === "assistant");
2192
2238
  const text = last && Array.isArray(last.content) ? last.content.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n") : "";
@@ -150,6 +150,11 @@ export const GOAL_RUNTIME_GLOBAL_NAMES = [
150
150
  "contextStarvedStreak",
151
151
  "lastContextStarvedAt",
152
152
  "noteContextStarvedYield",
153
+ "noteContextPercent",
154
+ "shouldCompactFirstNudge",
155
+ "buildStarvationLadderMessage",
156
+ "COMPACT_FIRST_NUDGE_PERCENT",
157
+ "lastContextPercent",
153
158
  "clearContextStarvedStreak",
154
159
  "onCompactionLanded",
155
160
  "isContextStarvedRefused",
@@ -310,6 +315,11 @@ interface GoalRuntimeDataTypes {
310
315
  CONTEXT_STARVATION_RECENT_WINDOW_MS: number;
311
316
  contextStarvedStreak: number;
312
317
  lastContextStarvedAt: number;
318
+ noteContextPercent: (pct: number | null | undefined) => void;
319
+ shouldCompactFirstNudge: (percent: number | null | undefined) => boolean;
320
+ buildStarvationLadderMessage: (input?: { percent?: number | null; streak?: number }) => string;
321
+ COMPACT_FIRST_NUDGE_PERCENT: number;
322
+ lastContextPercent: number | null;
313
323
  postCompactResumeOwed: boolean;
314
324
  postCompactResyncPending: boolean;
315
325
  COMPACTION_GRACE_MS: number;
@@ -487,6 +497,11 @@ declare global {
487
497
  var contextStarvedStreak: GoalRuntimeGlobals["contextStarvedStreak"];
488
498
  var lastContextStarvedAt: GoalRuntimeGlobals["lastContextStarvedAt"];
489
499
  var noteContextStarvedYield: GoalRuntimeGlobals["noteContextStarvedYield"];
500
+ var noteContextPercent: GoalRuntimeGlobals["noteContextPercent"];
501
+ var shouldCompactFirstNudge: GoalRuntimeGlobals["shouldCompactFirstNudge"];
502
+ var buildStarvationLadderMessage: GoalRuntimeGlobals["buildStarvationLadderMessage"];
503
+ var COMPACT_FIRST_NUDGE_PERCENT: GoalRuntimeGlobals["COMPACT_FIRST_NUDGE_PERCENT"];
504
+ var lastContextPercent: GoalRuntimeGlobals["lastContextPercent"];
490
505
  var clearContextStarvedStreak: GoalRuntimeGlobals["clearContextStarvedStreak"];
491
506
  var onCompactionLanded: GoalRuntimeGlobals["onCompactionLanded"];
492
507
  var isContextStarvedRefused: GoalRuntimeGlobals["isContextStarvedRefused"];
@@ -171,6 +171,7 @@ import {
171
171
  } from "../goal-continuation.js";
172
172
  export { __testOnlySetContinuationStartTimeout, __testOnlySetContinuationRetryBackoff } from "../goal-continuation.js";
173
173
  import {
174
+ LENGTH_CONTINUE_CONTEXT_STARVED_PERCENT,
174
175
  LENGTH_CONTINUE_MAX,
175
176
  LENGTH_CONTINUE_TEXT,
176
177
  isContextStarvedLengthStop,
@@ -963,8 +964,45 @@ let lastCompactionAt = 0;
963
964
  // immediately clears the streak.
964
965
  const CONTEXT_STARVATION_REFUSE_THRESHOLD = 2;
965
966
  const CONTEXT_STARVATION_RECENT_WINDOW_MS = 90_000;
967
+ // v0.38.6: compact-first band — heavy but still compactable. One proactive
968
+ // nudge per episode here avoids the over-cap ladder entirely (summarization
969
+ // still fits below the 90% starvation line).
970
+ export const COMPACT_FIRST_NUDGE_PERCENT = 85;
971
+ export const COMPACT_FIRST_RESET_PERCENT = 80;
966
972
  let contextStarvedStreak = 0;
967
973
  let lastContextStarvedAt = 0;
974
+ // v0.38.6: last-known context percent (sampled at agent_end). Keeps the
975
+ // refuse sticky while the session is physically over cap even after the 90s
976
+ // recency window lapses — a slow-truncating 120%+ session must not re-arm
977
+ // the spin (field 2026-09-03: 119–243% truncation loops).
978
+ let lastContextPercent: number | null = null;
979
+ let compactFirstNudged = false;
980
+ export function noteContextPercent(pct: number | null | undefined): void {
981
+ lastContextPercent = typeof pct === "number" && Number.isFinite(pct) ? pct : null;
982
+ }
983
+ /** v0.38.6: compact-first decision — pure. True exactly once per episode
984
+ * while percent sits at/above the nudge band; resets below 80% or on
985
+ * compaction. Returns false for missing/non-finite input. */
986
+ export function shouldCompactFirstNudge(percent: number | null | undefined): boolean {
987
+ if (typeof percent !== "number" || !Number.isFinite(percent)) return false;
988
+ if (percent < COMPACT_FIRST_RESET_PERCENT) { compactFirstNudged = false; return false; }
989
+ if (percent < COMPACT_FIRST_NUDGE_PERCENT || compactFirstNudged) return false;
990
+ compactFirstNudged = true;
991
+ return true;
992
+ }
993
+ /** v0.38.6: the over-cap ladder, user-facing. Three recoveries in order
994
+ * (retry /compact after GLLA's deterministic trim, larger-context model,
995
+ * /new + resume from durable state) plus the no-LLM backstop: the
996
+ * post-compact resync re-anchors a fresh session with no summarization. */
997
+ export function buildStarvationLadderMessage(input: { percent?: number | null; streak?: number } = {}): string {
998
+ const at = typeof input.percent === "number" && Number.isFinite(input.percent)
999
+ ? `${input.percent.toFixed(1)}%` : "nearly full";
1000
+ return `glla: output-token stop was context starvation (tiny output at ${at} context) — yielding to pi auto-compaction instead of re-sending. ` +
1001
+ `If compaction fails or context is already over cap, in order: (1) run /compact again — GLLA already trimmed repeat payloads to a checkpoint, so the retry may now fit; ` +
1002
+ `(2) switch to a larger-context model, then /compact (GLLA auto-rotates its fallback chain after a failed compact-and-retry); ` +
1003
+ `(3) /new, then /goal resume — the goal, tasks, ledger and audits are durable on disk, and the post-compact resync re-anchors the fresh session with no summarization needed. ` +
1004
+ `Automatic turns stay parked until a real compaction lands.`;
1005
+ }
968
1006
  function noteContextStarvedYield(): { streak: number; shouldRefuse: boolean } {
969
1007
  const now = Date.now();
970
1008
  if (now - lastContextStarvedAt > CONTEXT_STARVATION_RECENT_WINDOW_MS) {
@@ -981,6 +1019,7 @@ function clearContextStarvedStreak(): void {
981
1019
  /** Public: a session_compact landed — clear the starvation refuse gate. */
982
1020
  function onCompactionLanded(): void {
983
1021
  clearContextStarvedStreak();
1022
+ compactFirstNudged = false; // v0.38.6: fresh episode after real room
984
1023
  }
985
1024
  /** Public: has the yield path declared compaction absent? The heartbeat
986
1025
  * consults this to refuse to schedule a new continuation while the
@@ -988,7 +1027,10 @@ function onCompactionLanded(): void {
988
1027
  function isContextStarvedRefused(): boolean {
989
1028
  if (contextStarvedStreak < CONTEXT_STARVATION_REFUSE_THRESHOLD) return false;
990
1029
  if (lastCompactionAt > lastContextStarvedAt) return false; // a compact happened after the streak — fresh slate
991
- return Date.now() - lastContextStarvedAt <= CONTEXT_STARVATION_RECENT_WINDOW_MS;
1030
+ if (Date.now() - lastContextStarvedAt <= CONTEXT_STARVATION_RECENT_WINDOW_MS) return true;
1031
+ // v0.38.6: sticky while physically over cap — recency lapse must not
1032
+ // re-arm the spin against a session that never got room (field: 243%).
1033
+ return lastContextPercent !== null && lastContextPercent >= LENGTH_CONTINUE_CONTEXT_STARVED_PERCENT;
992
1034
  }
993
1035
  /** Test-only: simulate a compaction event at a controlled time without firing
994
1036
  * the full session_compact plumbing. Pass null to clear. */
@@ -1132,6 +1174,11 @@ defineGoalRuntimeGlobal("CONTEXT_STARVATION_RECENT_WINDOW_MS", { get: () => CONT
1132
1174
  defineGoalRuntimeGlobal("contextStarvedStreak", { get: () => contextStarvedStreak, set: (v) => { contextStarvedStreak = v as any; } });
1133
1175
  defineGoalRuntimeGlobal("lastContextStarvedAt", { get: () => lastContextStarvedAt, set: (v) => { lastContextStarvedAt = v as any; } });
1134
1176
  defineGoalRuntimeGlobal("noteContextStarvedYield", { get: () => noteContextStarvedYield });
1177
+ defineGoalRuntimeGlobal("noteContextPercent", { get: () => noteContextPercent });
1178
+ defineGoalRuntimeGlobal("shouldCompactFirstNudge", { get: () => shouldCompactFirstNudge });
1179
+ defineGoalRuntimeGlobal("buildStarvationLadderMessage", { get: () => buildStarvationLadderMessage });
1180
+ defineGoalRuntimeGlobal("COMPACT_FIRST_NUDGE_PERCENT", { get: () => COMPACT_FIRST_NUDGE_PERCENT });
1181
+ defineGoalRuntimeGlobal("lastContextPercent", { get: () => lastContextPercent, set: (v) => { lastContextPercent = v as any; } });
1135
1182
  defineGoalRuntimeGlobal("clearContextStarvedStreak", { get: () => clearContextStarvedStreak });
1136
1183
  defineGoalRuntimeGlobal("onCompactionLanded", { get: () => onCompactionLanded });
1137
1184
  defineGoalRuntimeGlobal("isContextStarvedRefused", { get: () => isContextStarvedRefused });
@@ -288,6 +288,7 @@ const continuationDeps: ContinuationDeps = {
288
288
  isForeignCtx,
289
289
  sessionManagerId,
290
290
  isActionableGoal,
291
+ isContextStarvedRefused,
291
292
  isSupervising,
292
293
  goalNoun,
293
294
  activeGoalSurfaceCommand,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.38.5",
3
+ "version": "0.38.7",
4
4
  "description": "Mission control for autonomous pi: interview-drafted goals, an audited task queue, and forever-loops (metric, spec, project-audit) that run for hours. A detached extension-less auditor process re-verifies every completion with raw evidence without holding the main pi turn; confirmed drafts, decision pauses and consent gates keep you in charge.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "dracon",