pi-goal-list-loop-audit 0.28.5 → 0.28.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.
@@ -498,10 +498,60 @@ export function ensureDirs(cwd: string): void {
498
498
  fs.mkdirSync(archiveDir(cwd), { recursive: true });
499
499
  }
500
500
 
501
+ // =================================================================
502
+ // Persistence degradation (v0.28.6, audit E1)
503
+ // =================================================================
504
+ // A disk failure (ENOSPC, EACCES, a wedged mount) used to THROW out of
505
+ // appendLedger/writeGoalMd mid-handler — killing the orchestrator turn and
506
+ // silently diverging RAM from disk. Now every persistence step runs
507
+ // through runPersistStep: failures are caught, the session-wide degraded
508
+ // flag latches (the TUI shows it; the first failure notifies loudly), RAM
509
+ // state stays authoritative, and the next SUCCESSFUL step auto-clears the
510
+ // flag (self-healing — the "dirty" marker that write-then-mutate ordering
511
+ // cannot otherwise provide).
512
+
513
+ export interface PersistenceFailure {
514
+ what: string;
515
+ error: string;
516
+ at: string;
517
+ }
518
+
519
+ let persistenceDegraded = false;
520
+ let lastFailure: PersistenceFailure | null = null;
521
+
522
+ export function isPersistenceDegraded(): boolean {
523
+ return persistenceDegraded;
524
+ }
525
+
526
+ export function lastPersistenceFailure(): PersistenceFailure | null {
527
+ return lastFailure;
528
+ }
529
+
530
+ /** Run one persistence step. On failure: latch the degraded flag, remember
531
+ * the error, return undefined (NEVER throw into an orchestrator handler).
532
+ * On success: clear the flag — a landing write means the disk is back. */
533
+ export function runPersistStep<T>(what: string, fn: () => T): T | undefined {
534
+ try {
535
+ const out = fn();
536
+ if (persistenceDegraded) {
537
+ persistenceDegraded = false;
538
+ lastFailure = null;
539
+ }
540
+ return out;
541
+ } catch (err) {
542
+ persistenceDegraded = true;
543
+ lastFailure = { what, error: err instanceof Error ? err.message : String(err), at: new Date().toISOString() };
544
+ return undefined;
545
+ }
546
+ }
547
+
501
548
  export function readState(cwd: string): State {
502
549
  const file = ledgerPath(cwd);
503
- if (!fs.existsSync(file)) return { ...DEFAULT_STATE };
504
- const lines = fs.readFileSync(file, "utf-8").split("\n").filter(Boolean);
550
+ // v0.28.6 (E1): an unreadable ledger (EACCES, EIO) degrades loudly
551
+ // instead of throwing out of session_start.
552
+ const raw = runPersistStep("readState", () => (fs.existsSync(file) ? fs.readFileSync(file, "utf-8") : ""));
553
+ if (raw === undefined || raw === "") return { ...DEFAULT_STATE };
554
+ const lines = raw.split("\n").filter(Boolean);
505
555
  if (lines.length === 0) return { ...DEFAULT_STATE };
506
556
  let parsed: Partial<State> = {};
507
557
  for (const line of lines) {
@@ -509,7 +559,8 @@ export function readState(cwd: string): State {
509
559
  const evt = JSON.parse(line);
510
560
  if (evt.type === "state") parsed = { ...parsed, ...evt.value };
511
561
  } catch {
512
- // skip malformed lines
562
+ // skip malformed lines — a truncated trailing line (mid-write kill)
563
+ // must not lose the rest of the state
513
564
  }
514
565
  }
515
566
  return {
@@ -520,16 +571,23 @@ export function readState(cwd: string): State {
520
571
  }
521
572
 
522
573
  export function appendLedger(cwd: string, type: string, value: unknown): void {
523
- ensureDirs(cwd);
524
- const line = JSON.stringify({ type, value, at: new Date().toISOString() });
525
- fs.appendFileSync(ledgerPath(cwd), line + "\n");
574
+ // v0.28.6 (E1): guarded — a disk failure degrades loudly, never throws
575
+ // into an orchestrator handler.
576
+ runPersistStep("appendLedger", () => {
577
+ ensureDirs(cwd);
578
+ const line = JSON.stringify({ type, value, at: new Date().toISOString() });
579
+ fs.appendFileSync(ledgerPath(cwd), line + "\n");
580
+ });
526
581
  }
527
582
 
528
583
  export function writeGoalMd(cwd: string, goal: Goal): string {
529
- ensureDirs(cwd);
530
584
  const file = goalMdPath(cwd, goal.id);
531
- const md = renderGoalMarkdown(goal);
532
- fs.writeFileSync(file, md);
585
+ runPersistStep("writeGoalMd", () => {
586
+ ensureDirs(cwd);
587
+ fs.writeFileSync(file, renderGoalMarkdown(goal));
588
+ });
589
+ // Return the intended path even on failure so activePath stays sane —
590
+ // the degraded flag carries the truth that the write did not land.
533
591
  return file;
534
592
  }
535
593
 
@@ -839,7 +897,12 @@ export interface EffectiveAggressiveSettings {
839
897
  stuckMaxInterventions: number;
840
898
  /** 0 = wedge alerts off. */
841
899
  wedgeAlertMinutes: number;
842
- autoResume: boolean;
900
+ /** Tri-state: true = always auto-resume; false = never; undefined =
901
+ * DEFAULT (hold on human session loads, resume on reload/fork).
902
+ * v0.28.7: must stay tri-state here — coercing unset→false broke the
903
+ * restore gate's default branch (the 0.28.3 regression the behavioral
904
+ * harness caught). */
905
+ autoResume: boolean | undefined;
843
906
  aggressiveMode: boolean;
844
907
  }
845
908
 
@@ -860,7 +923,7 @@ export function resolveEffectiveAggressiveSettings(s: {
860
923
  stuckMaxInterventions:
861
924
  s.stuckMaxInterventions ?? (aggressiveMode ? AGGRESSIVE_STUCK_MAX_INTERVENTIONS : BASE_STUCK_MAX_INTERVENTIONS),
862
925
  wedgeAlertMinutes: s.wedgeAlertMinutes ?? (aggressiveMode ? 0 : 30),
863
- autoResume: s.autoResume ?? aggressiveMode,
926
+ autoResume: s.autoResume ?? (aggressiveMode ? true : undefined),
864
927
  };
865
928
  }
866
929
 
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { Goal, State } from "./goal-loop-core.js";
14
+ import { isPersistenceDegraded, lastPersistenceFailure } from "./goal-loop-core.js";
14
15
  import type { LoopState } from "./goal-loop-forever.js";
15
16
 
16
17
  // ---- formatters ----
@@ -185,6 +186,17 @@ function countTotal(g: Goal): number {
185
186
  * Returns undefined when nothing is worth showing.
186
187
  */
187
188
  export function buildWidgetLines(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme, width?: number, extras?: { stalls?: number }): string[] | undefined {
189
+ const inner = buildWidgetLinesInner(state, audit, now, theme, width, extras);
190
+ // v0.28.6 (E1): a persistence failure outranks everything — first line,
191
+ // on every render, until a write lands again.
192
+ if (inner && isPersistenceDegraded()) {
193
+ const err = lastPersistenceFailure();
194
+ return [paint(theme, "error", `⚠ persistence degraded — .pi-glla writes failing (${truncate(err?.error ?? "disk error", 40)}); state in RAM`), ...inner];
195
+ }
196
+ return inner;
197
+ }
198
+
199
+ function buildWidgetLinesInner(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme, width?: number, extras?: { stalls?: number }): string[] | undefined {
188
200
  if (state.loop?.active) return loopLines(state.loop, now, theme, width, extras);
189
201
  const g = state.goal;
190
202
  if (!g) return undefined;
@@ -85,6 +85,9 @@ import {
85
85
  statusLabel,
86
86
  writeGoalMd,
87
87
  missingGllaTools,
88
+ runPersistStep,
89
+ isPersistenceDegraded,
90
+ lastPersistenceFailure,
88
91
  } from "../goal-loop-core.js";
89
92
  import {
90
93
  LENGTH_CONTINUE_MAX,
@@ -221,6 +224,13 @@ function goStaleTerminal(ctx: ExtensionContext, where: string): void {
221
224
  notifyExternal(ctx, `glla: extension api stale — restart pi. (${where})`);
222
225
  }
223
226
 
227
+ /** TEST-ONLY hook (tests/harness): the stale flag is process-terminal in
228
+ * production — only a pi restart clears it — so behavioral tests reset it
229
+ * between stale scenarios. Never called by production code. */
230
+ export function __testOnlyResetStaleFlag(): void {
231
+ extensionApiStale = false;
232
+ }
233
+
224
234
  /** v0.28.1 (S3): side-effect-free staleness probe — getSessionName()
225
235
  * routes through pi's assertActive() and throws the stale signature iff
226
236
  * pi invalidated this factory handle (session replacement). A positive
@@ -740,9 +750,29 @@ function createGoal(objective: string, ctx: ExtensionContext, policy: "goal" | "
740
750
 
741
751
  function persistState(ctx: ExtensionContext): void {
742
752
  appendLedger(ctx.cwd, "state", { goal: state.goal, list: state.list ?? [], loop: state.loop ?? null });
753
+ notifyPersistenceState(ctx); // v0.28.6 (E1): loud on the first failure, all-clear on recovery
743
754
  refreshUI(ctx); // every state transition flows through here → the TUI is always current
744
755
  }
745
756
 
757
+ // v0.28.6 (E1): persistence-degradation notify — once per failure streak,
758
+ // once per recovery. The TUI flag (buildWidgetLines) carries the standing
759
+ // state; these notifies are the LOUD part.
760
+ let persistenceDegradedNotified = false;
761
+ function notifyPersistenceState(ctx: ExtensionContext): void {
762
+ if (isPersistenceDegraded() && !persistenceDegradedNotified) {
763
+ persistenceDegradedNotified = true;
764
+ const err = lastPersistenceFailure();
765
+ ctx.ui.notify(
766
+ `⚠ Persistence degraded: ${err?.what ?? "disk write"} failed (${(err?.error ?? "unknown").slice(0, 80)}). State lives in RAM and re-syncs on the next successful write — .pi-glla may be missing recent entries. Fix the disk (space/permissions) and it self-heals.`,
767
+ "warning",
768
+ );
769
+ notifyExternal(ctx, "pi-goal-list-loop-audit: persistence degraded — .pi-glla writes failing.");
770
+ } else if (!isPersistenceDegraded() && persistenceDegradedNotified) {
771
+ persistenceDegradedNotified = false;
772
+ ctx.ui.notify("Persistence recovered — .pi-glla writes are landing again.", "info");
773
+ }
774
+ }
775
+
746
776
  function setGoal(goal: Goal, ctx: ExtensionContext): void {
747
777
  state = { goal, list: state.list ?? [] }; // preserve the list!
748
778
  const file = writeGoalMd(ctx.cwd, goal);
@@ -765,9 +795,16 @@ function archiveCurrentGoal(ctx: ExtensionContext, status: Status, stopReason?:
765
795
  ensureDirs(ctx.cwd);
766
796
  const target = archivedGoalPath(ctx.cwd, goal.id);
767
797
  const md = renderGoalMarkdown({ ...goal, status, stopReason });
768
- fs.writeFileSync(target, md);
769
- // Remove active md file
770
- try { fs.unlinkSync(goalMdPath(ctx.cwd, goal.id)); } catch {}
798
+ // v0.28.6 (E1): guarded — and the active md is only removed when the
799
+ // archive actually LANDED (degraded mode must not destroy the only copy).
800
+ const archived = runPersistStep("archiveCurrentGoal", () => {
801
+ ensureDirs(ctx.cwd);
802
+ fs.writeFileSync(target, md);
803
+ return true;
804
+ }) === true;
805
+ if (archived) {
806
+ try { fs.unlinkSync(goalMdPath(ctx.cwd, goal.id)); } catch {}
807
+ }
771
808
  state = { goal: { ...goal, status, archivedPath: path.relative(ctx.cwd, target) || target, stopReason }, list: state.list ?? [] };
772
809
  appendLedger(ctx.cwd, "goal_archived", { goalId: goal.id, status, stopReason });
773
810
  persistState(ctx);
@@ -2493,11 +2530,12 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2493
2530
  pi.registerTool(defineTool({
2494
2531
  name: "complete_task",
2495
2532
  label: "Complete task",
2496
- description: "Mark a task in the active goal's task list as complete (does not stop the turn).",
2497
- parameters: Type.Object({
2533
+ description: "Mark a task in the active goal's task list as complete (does not stop the turn).", parameters: Type.Object({
2498
2534
  id: Type.String({ description: "Task id to complete" }),
2499
2535
  }),
2500
- async execute(_id, params) {
2536
+ async execute(_id, params, _signal, _onUpdate, execCtx) {
2537
+ const foreign7 = foreignToolGuard(execCtx);
2538
+ if (foreign7) return { content: [{ type: "text", text: foreign7 }], details: {} };
2501
2539
  const p = params as { id: string };
2502
2540
  if (!state.goal || !state.goal.taskList) {
2503
2541
  return { content: [{ type: "text", text: "No task list in this goal." }], details: {} };
@@ -2525,7 +2563,9 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2525
2563
  id: Type.String(),
2526
2564
  status: Type.Union([Type.Literal("pending"), Type.Literal("in_progress"), Type.Literal("complete")]),
2527
2565
  }),
2528
- async execute(_id, params) {
2566
+ async execute(_id, params, _signal, _onUpdate, execCtx) {
2567
+ const foreign8 = foreignToolGuard(execCtx);
2568
+ if (foreign8) return { content: [{ type: "text", text: foreign8 }], details: {} };
2529
2569
  const p = params as { id: string; status: "pending" | "in_progress" | "complete" };
2530
2570
  if (!state.goal || !state.goal.taskList) {
2531
2571
  return { content: [{ type: "text", text: "No task list in this goal." }], details: {} };
@@ -2982,6 +3022,8 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2982
3022
  })),
2983
3023
  }),
2984
3024
  async execute(_id, params, _signal, _onUpdate, execCtx) {
3025
+ const foreign9 = foreignToolGuard(execCtx);
3026
+ if (foreign9) return { content: [{ type: "text", text: foreign9 }], details: {} };
2985
3027
  if (!state.goal || state.goal.status !== "active") {
2986
3028
  return { content: [{ type: "text", text: "No active goal to break down." }], details: {} };
2987
3029
  }
@@ -3149,7 +3191,9 @@ async function promptSettingsMenu(
3149
3191
  * Same handlers as v0.27.0's if/else chain — only the trigger changed from
3150
3192
  * `startsWith(label)` strings to stable ids.
3151
3193
  */
3152
- async function handleSettingChoice(id: string, ctx: ExtensionContext): Promise<void> {
3194
+ // v0.28.7 (T4): exported for the behavioral settings-editor tests
3195
+ // (tests/settings-editors.test.ts drives each editor class end-to-end).
3196
+ export async function handleSettingChoice(id: string, ctx: ExtensionContext): Promise<void> {
3153
3197
  switch (id) {
3154
3198
  case "autoResume": {
3155
3199
  const v = await ctx.ui.select("Auto-resume goals/loops on session start", [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.28.5",
3
+ "version": "0.28.7",
4
4
  "description": "Goal. Loop. Audit. Done. \u2014 a pi-coding-agent extension that supervises long-running work, with isolated auditor on each completion. Beat bamboozling by design: the auditor runs in a fresh session with no extensions, no skills, no editor \u2014 only the read tools needed to verify your goal.",
5
5
  "license": "MIT",
6
6
  "author": "dracon",