pi-goal-x 0.26.1 → 0.26.3

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
@@ -129,6 +129,8 @@ Start a guided regular goal:
129
129
 
130
130
  The agent can ask questions and propose a complete objective and task plan. Confirm the proposal to create the goal and begin work.
131
131
 
132
+ The questionnaire dialog never truncates the question; when it is taller than the terminal it stays within the height and scrolls — `PgUp`/`PgDn` page and `Ctrl+↑/↓` line-scroll without moving the selection, `↑/↓` selection auto-follows into view, and a `▲`/`… +N more` edge indicator shows what is clipped.
133
+
132
134
  Start a guided Sisyphus goal:
133
135
 
134
136
  ```text
@@ -175,6 +177,12 @@ Show the focused goal:
175
177
  /goal-status
176
178
  ```
177
179
 
180
+ Run read-only storage/runtime health checks:
181
+
182
+ ```text
183
+ /goal-status health
184
+ ```
185
+
178
186
  Select an open goal for the current session:
179
187
 
180
188
  ```text
@@ -339,6 +347,7 @@ Each session can focus on one goal while the project keeps other goals open.
339
347
  /goal-list List open goals
340
348
  /goal-status Show the focused goal (unified dashboard)
341
349
  /goal-status verbose Show the focused goal with full diagnostic detail
350
+ /goal-status health Check goal storage/runtime health (read-only)
342
351
  /goal-focus Select an open goal
343
352
  /goal-unfocus Remove the session’s focus
344
353
  /goal-tweak <change> Revise the focused goal
@@ -99,7 +99,7 @@ The curated fourteen-command palette:
99
99
  | `/goal-direct <objective>` | Direct regular-goal creation without drafting |
100
100
  | `/sisyphus-direct <objective>` | Direct Sisyphus creation without drafting |
101
101
  | `/goal-list` | List all open goals and the current focus |
102
- | `/goal-status` | Read-only focused-goal summary plus other-open-goal count |
102
+ | `/goal-status` | Read-only focused-goal summary plus other-open-goal count; append `verbose` for diagnostics or `health` for storage/runtime checks |
103
103
  | `/goal-focus` | Choose this session's focused goal |
104
104
  | `/goal-unfocus` | Detach the session without modifying the shared goal |
105
105
  | `/goal-settings` | Fully operable settings editor for all eight persisted fields |
@@ -182,6 +182,7 @@ user confirms.
182
182
  - `/sisyphus [seed]` starts a Sisyphus guided draft.
183
183
  - `/goal-direct <objective>` and `/sisyphus-direct <objective>` create directly without drafting.
184
184
  - `/goal-list` prints all open goals with id, status, mode, usage, objective title, path, and a focus marker.
185
+ - `/goal-status health` performs a read-only coherence check for focus, lifecycle, goal-file presence, malformed ledger entries, task progress, and token-budget pressure; it never acts as a completion verdict.
185
186
  - `/goal-focus` uses `ctx.ui.select` when multiple goals are open and updates only session focus.
186
187
  - `/goal-unfocus` writes a null session focus entry, clears continuation/runtime state, aborts in-flight work and audits for that session, and leaves the shared active goal file and project-global focus ledger unchanged. Focus revision tokens prevent pending completion and task-list results from mutating a goal after detachment.
187
188
  - `/goal-resume` resumes the focused paused goal; when unfocused with multiple open goals, it asks the user to choose. Choosing an already active goal only focuses it.
@@ -1,3 +1,5 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
1
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
4
  import { extractVerificationContract, sisyphusObjectiveSufficient } from "./goal-contract.ts";
3
5
  import { detailedSummary, oneLineSummary } from "./goal-format.ts";
@@ -161,12 +163,19 @@ export function registerGoalCommands(core: GoalCore): void {
161
163
  const view = core.goalForDisplay() ?? core.state.goal;
162
164
  const otherCount = otherOpenGoalCount(core.goalsById, core.focusedGoalId);
163
165
  const verbose = /^\s*verbose\b/i.test(rawArgs);
166
+ const health = /^\s*health\b/i.test(rawArgs);
167
+ const ledger = readGoalLedger(ctx);
164
168
  const text = buildGoalStatusText({
165
169
  goal: view,
166
170
  focused: view !== null && core.focusedGoalId === view.id,
167
171
  otherOpenGoals: otherCount,
168
- ledgerEvents: readGoalLedger(ctx).events,
172
+ ledgerEvents: ledger.events,
173
+ ledgerMalformed: ledger.malformed,
169
174
  verbose,
175
+ health,
176
+ activeFilePresent: health && view?.activePath
177
+ ? existsSync(path.resolve(ctx.cwd, view.activePath))
178
+ : undefined,
170
179
  // §13.2: effective settings with provenance appear only in verbose mode;
171
180
  // the standard mode stays free of settings noise (§13.1).
172
181
  settingsReport: verbose ? effectiveSettingsReport(ctx.cwd) : [],
@@ -481,8 +490,8 @@ export function registerGoalCommands(core: GoalCore): void {
481
490
  ctx.ui.notify("Provide the replacement objective: /goal-tweak <new objective>", "info");
482
491
  return;
483
492
  }
484
- if (trimmed.length > (loadGoalSettings(ctx.cwd).objectiveMaxChars ?? 0)) {
485
- const max = loadGoalSettings(ctx.cwd).objectiveMaxChars;
493
+ const max = loadGoalSettings(ctx.cwd).objectiveMaxChars;
494
+ if (trimmed.length > (max ?? 0)) {
486
495
  if (max !== undefined && max > 0) {
487
496
  ctx.ui.notify(`Replacement objective exceeds ${max} characters (${trimmed.length}).`, "warning");
488
497
  return;
@@ -533,7 +542,7 @@ export function registerGoalCommands(core: GoalCore): void {
533
542
  },
534
543
  });
535
544
  pi.registerCommand("goal-status", {
536
- description: "Show the unified goal dashboard for the focused goal (read-only). Append \"verbose\" for full diagnostic detail (goal id, revision, objective, task tree with evidence and contracts, ledger history, budget, pause/blocker, paths, audit report, effective settings).",
545
+ description: "Show the unified goal dashboard (read-only). Append \"verbose\" for full detail or \"health\" for storage/runtime checks.",
537
546
  handler: async (rawArgs, ctx) => {
538
547
  await showGoalStatus(rawArgs ?? "", ctx);
539
548
  },
@@ -300,6 +300,11 @@ export function registerGoalEvents(core: GoalCore): void {
300
300
  core.advanceTurnSeq();
301
301
  const currentSystemPrompt = () => ctx.getSystemPrompt?.() || event.systemPrompt;
302
302
  const incomingGoalId = extractGoalIdFromInjectedMessage(event.prompt ?? "");
303
+ // Several prompt enrichments may need the same ledger snapshot. Keep one
304
+ // local read for this hook instead of repeatedly traversing the cached
305
+ // ledger when rejection and post-compaction steering overlap.
306
+ let promptLedger: ReturnType<typeof readGoalLedger> | undefined;
307
+ const getPromptLedger = () => promptLedger ??= readGoalLedger(ctx);
303
308
 
304
309
  // If this turn was triggered by a hidden goal checkpoint that no longer
305
310
  // matches the active goal, abort the whole turn instead of letting the
@@ -353,10 +358,10 @@ export function registerGoalEvents(core: GoalCore): void {
353
358
  pauseExtras.push(`Pause reason: ${current.pauseReason ?? "(unknown)"}`);
354
359
  if (current.pauseSuggestedAction) pauseExtras.push(`Suggested action: ${current.pauseSuggestedAction}`);
355
360
  }
356
- // Inject durable auditor feedback if available
357
- let auditorExtra = "";
358
- try {
359
- const ledger = readGoalLedger(ctx);
361
+ // Inject durable auditor feedback if available
362
+ let auditorExtra = "";
363
+ try {
364
+ const ledger = getPromptLedger();
360
365
  const auditorResult = latestAuditorResultForGoal(ledger.events, current.id);
361
366
  if (auditorResult && auditorResult.verdict === "disapproved") {
362
367
  auditorExtra = `\n\n[AUDITOR REJECTION] An independent auditor previously rejected a completion request for this goal. Reason: ${auditorResult.report.slice(0, 300)}\nAddress the auditor's objections before requesting completion again.`;
@@ -395,7 +400,7 @@ export function registerGoalEvents(core: GoalCore): void {
395
400
  if (stalledNote) prompt += stalledNote;
396
401
  // Inject durable auditor feedback if the latest result was a rejection
397
402
  try {
398
- const ledger = readGoalLedger(ctx);
403
+ const ledger = getPromptLedger();
399
404
  const auditorResult = latestAuditorResultForGoal(ledger.events, activeGoal.id);
400
405
  if (auditorResult && auditorResult.verdict === "disapproved" && ledger.events.some((e) => e.type === "completion_requested" && e.goalId === activeGoal.id)) {
401
406
  prompt = `${prompt}\n\n[AUDITOR REJECTION goalId=${activeGoal.id}]\nAn independent auditor previously rejected a completion request for this goal. Reason: ${auditorResult.report.slice(0, 300)}\nAddress the auditor's objections before requesting completion again.`;
@@ -405,9 +410,9 @@ export function registerGoalEvents(core: GoalCore): void {
405
410
  }
406
411
  if (core.runtime.isPostCompactReminderPending() && shouldInjectPostCompactReminder({ pending: true, goal: activeGoal })) {
407
412
  core.runtime.clearPostCompactReminder();
408
- // Use deterministic compaction summary instead of generic reminder
409
- try {
410
- const ledger = readGoalLedger(ctx);
413
+ // Use deterministic compaction summary instead of generic reminder
414
+ try {
415
+ const ledger = getPromptLedger();
411
416
  const compaction = buildCompactionSummary({ goalsById: core.goalsById, focusedGoalId: core.focusedGoalId, ledgerEvents: ledger.events });
412
417
  prompt = `${prompt}\n\n[POST-COMPACTION RESYNC goalId=${activeGoal.id}]\n${compaction}`;
413
418
  } catch {
@@ -473,4 +478,3 @@ export function registerGoalEvents(core: GoalCore): void {
473
478
  if (core.state.goal) core.persist(ctx);
474
479
  });
475
480
  }
476
-
@@ -108,54 +108,108 @@ export function findProposalPresentationSegments(lines: string[], tailStart: num
108
108
  * question. The protected head (top border + tabs + question line) is always
109
109
  * kept; the remaining budget is spent on the tail — options/footer/bottom
110
110
  * border — so long context blocks (proposal confirmations) are sliced from
111
- * their head exactly as the pre-fix tail-slice did (383ae52 surface). When the
112
- * options block starts immediately after the head (plain agent questions with
113
- * no context block), the TOP options are kept instead of the tail so the
114
- * recommended/first option stays visible; the footer hint and bottom border
115
- * are always the last rendered lines. Never returns more than maxDialogLines.
111
+ * their head exactly as the pre-fix tail-slice did (383ae52 surface). The
112
+ * footer hint and bottom border are always the last rendered lines. Never
113
+ * returns more than maxDialogLines.
116
114
  *
117
115
  * Proposal confirmations (proposal segments given) keep the head, the tasks
118
116
  * section (header + every task line), and the options/footer/bottom border in
119
117
  * frame; only the objective-box middle is sacrificed in-frame — the full
120
118
  * objective is always present in the scrollable transcript presentation
121
119
  * (propose_goal_draft renderCall), so nothing of the goal is ever omitted.
120
+ *
121
+ * §options-scroll: when a `scroll` state is given (select-mode question tabs
122
+ * and the submit summary), the dialog becomes a `less`-style viewport over the
123
+ * FULL content — nothing is truncated, nothing is sliced away — and the
124
+ * options are always reachable by scrolling (PageUp/PageDown, Ctrl+↑/↓, and
125
+ * ↑/↓ selection auto-follow). See fitDialogViewport.
122
126
  */
127
+ export interface DialogScrollState {
128
+ /** Current viewport offset over the full content lines. */
129
+ scrollTop: number;
130
+ /** When true, the fitter nudges scrollTop so the selection range is visible. */
131
+ needsFollow: boolean;
132
+ /** Line ranges [start, end] of each selectable option in the full content. */
133
+ optionRanges: Array<[number, number]>;
134
+ /** Index of the current selection in optionRanges. */
135
+ followIndex: number;
136
+ }
137
+
123
138
  export function fitDialogLines(
124
139
  lines: string[],
125
140
  maxDialogLines: number,
126
141
  protectedHead: number,
127
- optionsImmediatelyAfterHead = false,
128
142
  proposal: ProposalPresentationSegments | null = null,
143
+ scroll: DialogScrollState | null = null,
144
+ dimStyle: (s: string) => string = (s) => s,
129
145
  ): string[] {
130
146
  if (maxDialogLines <= 0 || lines.length <= maxDialogLines) return lines;
131
147
  const keepHead = Math.min(protectedHead, maxDialogLines);
148
+ if (proposal) {
149
+ return fitProposalPresentation(lines, maxDialogLines, keepHead, proposal);
150
+ }
151
+ if (scroll) {
152
+ return fitDialogViewport(lines, maxDialogLines, scroll, dimStyle);
153
+ }
154
+ // Context-heavy / input / submit dialogs without a scroll state: keep the
155
+ // head and the tail (options/footer/bottom border) exactly as the pre-fix
156
+ // tail-slice did.
132
157
  const budget = maxDialogLines - keepHead;
133
158
  if (budget <= 0) return lines.slice(0, keepHead);
134
159
  const rest = lines.slice(protectedHead);
135
160
  if (rest.length <= budget) return [...lines.slice(0, keepHead), ...rest];
136
- if (proposal) {
137
- return fitProposalPresentation(lines, maxDialogLines, keepHead, proposal);
161
+ return [...lines.slice(0, keepHead), ...rest.slice(rest.length - budget)];
162
+ }
163
+
164
+ /**
165
+ * §options-scroll viewport fit: never truncate or drop content — window the
166
+ * full dialog lines with a scrollTop offset so every line (question, context,
167
+ * options, footer, border) stays reachable. The last viewport row is reserved
168
+ * as the bottom edge: the bottom border when the end is reached, otherwise a
169
+ * themed `… +N more · PgUp/PgDn scroll` indicator (mirrors the dashboard's
170
+ * `… +N more task` rows). A themed `▲ N more` indicator replaces the first
171
+ * content row when scrolled down. Selection auto-follow (needsFollow) nudges
172
+ * the window so the selected option's line range is inside it. Output length
173
+ * is exactly maxDialogLines when clipped, never more (churn-guard invariant).
174
+ */
175
+ function fitDialogViewport(
176
+ lines: string[],
177
+ maxDialogLines: number,
178
+ scroll: DialogScrollState,
179
+ dimStyle: (s: string) => string,
180
+ ): string[] {
181
+ const contentWindow = Math.max(0, maxDialogLines - 1);
182
+ const contentEnd = Math.max(0, lines.length - 1);
183
+ // Selection auto-follow: nudge scrollTop so the selected option's range is
184
+ // inside the content window (minimal scroll; clamp below).
185
+ if (scroll.needsFollow && scroll.optionRanges[scroll.followIndex]) {
186
+ const [os, oe] = scroll.optionRanges[scroll.followIndex];
187
+ if (os < scroll.scrollTop || oe >= scroll.scrollTop + contentWindow) {
188
+ scroll.scrollTop = oe >= scroll.scrollTop + contentWindow
189
+ ? Math.min(os, Math.max(0, oe - contentWindow + 1))
190
+ : os;
191
+ }
192
+ scroll.needsFollow = false;
138
193
  }
139
- if (optionsImmediatelyAfterHead) {
140
- // Plain select-mode question: keep the footer hint + bottom border (as
141
- // many as fit), then spend the remaining room on the TOP options so the
142
- // recommended/first option stays visible; leading blank separators are
143
- // dropped first when room is short. Never exceeds maxDialogLines.
144
- const headLines = lines.slice(0, keepHead);
145
- const firstContent = rest.findIndex((l) => l.trim().length > 0);
146
- const lead = firstContent > 0 ? rest.slice(0, firstContent) : [];
147
- const contentStart = firstContent > 0 ? firstContent : 0;
148
- const tailBudget = Math.min(2, budget); // footer hint + bottom border
149
- const tail = rest.slice(-tailBudget);
150
- const room = budget - tailBudget;
151
- const keepContent = Math.max(0, Math.min(rest.length - contentStart, room));
152
- const keepLead = Math.max(0, Math.min(lead.length, room - keepContent));
153
- const content = rest.slice(contentStart, contentStart + keepContent);
154
- return [...headLines, ...lead.slice(0, keepLead), ...content, ...tail];
194
+ scroll.scrollTop = Math.max(0, Math.min(scroll.scrollTop, Math.max(0, contentEnd - contentWindow)));
195
+ const s = scroll.scrollTop;
196
+ const hiddenAbove = s;
197
+ const hiddenBelow = contentEnd - (s + contentWindow);
198
+ const viewport = lines.slice(s, s + contentWindow);
199
+ if (hiddenAbove > 0 && viewport.length > 0) {
200
+ viewport[0] = dimStyle(`▲ ${hiddenAbove} more`);
155
201
  }
156
- // Context-heavy / input / submit dialogs: keep the head and the tail
157
- // (options, footer, bottom border) exactly as the pre-fix tail-slice did.
158
- return [...lines.slice(0, keepHead), ...rest.slice(rest.length - budget)];
202
+ if (hiddenBelow > 0) {
203
+ // The reserved bottom edge row advertises the scroll affordance — it
204
+ // replaces the border while content is clipped below (mirrors the
205
+ // dashboard's `… +N more task` rows) and gives way to the border at the
206
+ // end. The footer hint sits directly above the border, so it is only
207
+ // visible when the end is reached (never while clipped — geometry).
208
+ viewport.push(dimStyle(`… +${hiddenBelow} more · PgUp/PgDn scroll`));
209
+ } else {
210
+ viewport.push(lines[lines.length - 1]);
211
+ }
212
+ return viewport;
159
213
  }
160
214
 
161
215
  /**
@@ -291,6 +345,10 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
291
345
  let inputQuestionId: string | null = null;
292
346
  let cachedLines: string[] | undefined;
293
347
  let optionsStartIndex = -1;
348
+ // §options-scroll viewport state (select-mode + submit tabs only).
349
+ let scrollTop = 0;
350
+ let needsFollow = false;
351
+ let optionRanges: Array<[number, number]> = [];
294
352
  let auditorEnabled = auditorToggleInit?.defaultEnabled ?? true;
295
353
  const answers = new Map<string, GoalQuestionnaireAnswer>();
296
354
  const drafts = new Map<string, string>();
@@ -340,6 +398,9 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
340
398
  function enterQuestion(q: GoalQuestionnaireQuestion) {
341
399
  const existing = answers.get(q.id);
342
400
  const draft = drafts.get(q.id);
401
+ // §options-scroll: start each question tab from the top.
402
+ scrollTop = 0;
403
+ needsFollow = false;
343
404
  if (q.options.length === 0) {
344
405
  inputMode = true;
345
406
  inputQuestionId = q.id;
@@ -354,7 +415,14 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
354
415
  }
355
416
  }
356
417
 
357
- function advanceAfterAnswer() {
418
+ function enterSubmitTab() {
419
+ // §options-scroll: the submit summary also opens from the top.
420
+ optionIndex = 0;
421
+ scrollTop = 0;
422
+ needsFollow = false;
423
+ }
424
+
425
+ function advanceAfterAnswer() {
358
426
  if (!isMulti) {
359
427
  submit(false);
360
428
  return;
@@ -363,7 +431,7 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
363
431
  else currentTab = questions.length;
364
432
  const nextQ = currentQuestion();
365
433
  if (nextQ) enterQuestion(nextQ);
366
- else optionIndex = 0;
434
+ else enterSubmitTab();
367
435
  refresh();
368
436
  }
369
437
 
@@ -416,7 +484,7 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
416
484
  currentTab = matchesKey(data, Key.tab) ? (currentTab + 1) % totalTabs : (currentTab - 1 + totalTabs) % totalTabs;
417
485
  const nextQ = currentQuestion();
418
486
  if (nextQ) enterQuestion(nextQ);
419
- else optionIndex = 0;
487
+ else enterSubmitTab();
420
488
  refresh();
421
489
  return;
422
490
  }
@@ -433,7 +501,7 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
433
501
  currentTab = (currentTab + 1) % totalTabs;
434
502
  const nextQ = currentQuestion();
435
503
  if (nextQ) enterQuestion(nextQ);
436
- else optionIndex = 0;
504
+ else enterSubmitTab();
437
505
  refresh();
438
506
  return;
439
507
  }
@@ -441,12 +509,41 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
441
509
  currentTab = (currentTab - 1 + totalTabs) % totalTabs;
442
510
  const nextQ = currentQuestion();
443
511
  if (nextQ) enterQuestion(nextQ);
444
- else optionIndex = 0;
512
+ else enterSubmitTab();
445
513
  refresh();
446
514
  return;
447
515
  }
448
516
  }
449
517
 
518
+ // §options-scroll viewport keys (select-mode question tabs AND the
519
+ // submit summary): page and line scroll without moving the selection.
520
+ // Handled before the submit-tab early return so the summary scrolls
521
+ // too. ↑/↓ still select and auto-follow into view on the next render.
522
+ if (matchesKey(data, Key.pageUp)) {
523
+ scrollTop -= Math.max(1, (maxDialogLines ?? 10) - 1);
524
+ needsFollow = false;
525
+ refresh();
526
+ return;
527
+ }
528
+ if (matchesKey(data, Key.pageDown)) {
529
+ scrollTop += Math.max(1, (maxDialogLines ?? 10) - 1);
530
+ needsFollow = false;
531
+ refresh();
532
+ return;
533
+ }
534
+ if (matchesKey(data, Key.ctrl("up"))) {
535
+ scrollTop -= 1;
536
+ needsFollow = false;
537
+ refresh();
538
+ return;
539
+ }
540
+ if (matchesKey(data, Key.ctrl("down"))) {
541
+ scrollTop += 1;
542
+ needsFollow = false;
543
+ refresh();
544
+ return;
545
+ }
546
+
450
547
  if (currentTab === questions.length) {
451
548
  if (matchesKey(data, Key.enter) && allAnswered()) submit(false);
452
549
  else if (matchesKey(data, Key.escape)) submit(true);
@@ -455,11 +552,13 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
455
552
 
456
553
  if (matchesKey(data, Key.up)) {
457
554
  optionIndex = Math.max(0, optionIndex - 1);
555
+ needsFollow = true; // §options-scroll: keep the selection visible
458
556
  refresh();
459
557
  return;
460
558
  }
461
559
  if (matchesKey(data, Key.down)) {
462
560
  optionIndex = Math.min(opts.length - 1, optionIndex + 1);
561
+ needsFollow = true;
463
562
  refresh();
464
563
  return;
465
564
  }
@@ -633,11 +732,13 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
633
732
  function renderOptions() {
634
733
  optionsStartIndex = lines.length;
635
734
  for (let i = 0; i < opts.length; i++) {
735
+ const start = lines.length;
636
736
  const opt = opts[i];
637
737
  const selected = i === optionIndex;
638
738
  const prefix = selected ? theme.fg("accent", "> ") : " ";
639
739
  const recTag = !opt.isCustom && q?.recommended === i ? theme.fg("success", " ★") : "";
640
740
  addWrapped(prefix + theme.fg(selected ? "accent" : "text", `${i + 1}. ${opt.label}`) + recTag);
741
+ optionRanges[i] = [start, lines.length - 1];
641
742
  }
642
743
  }
643
744
 
@@ -705,11 +806,21 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
705
806
  // objective-box middle is sacrificed there because the full objective
706
807
  // is always in the scrollable transcript presentation (renderCall).
707
808
  if (maxDialogLines !== undefined && lines.length > maxDialogLines) {
708
- const optionsImmediatelyAfterHead = !inputMode && currentTab !== questions.length && !!q && !q.context && opts.length > 0;
709
809
  const proposalSegments = !inputMode && currentTab !== questions.length && !!q && q.context
710
810
  ? findProposalPresentationSegments(lines, optionsStartIndex)
711
811
  : null;
712
- lines = fitDialogLines(lines, maxDialogLines, protectedCount, optionsImmediatelyAfterHead, proposalSegments);
812
+ // §options-scroll: select-mode question tabs and the submit summary
813
+ // are a viewport over the full content (never truncated); input
814
+ // mode keeps the legacy tail-keep so the editor stays the priority.
815
+ let scrollState: DialogScrollState | null = null;
816
+ if (!inputMode) {
817
+ scrollState = { scrollTop, needsFollow, optionRanges, followIndex: optionIndex };
818
+ }
819
+ lines = fitDialogLines(lines, maxDialogLines, protectedCount, proposalSegments, scrollState, (s) => theme.fg("dim", s));
820
+ if (scrollState) {
821
+ scrollTop = scrollState.scrollTop;
822
+ needsFollow = scrollState.needsFollow;
823
+ }
713
824
  }
714
825
  cachedLines = lines;
715
826
  return lines;
@@ -1,5 +1,5 @@
1
1
  import { cloneGoal, nowIso, type GoalFocusReason, type GoalRecord, type GoalTask, type GoalUsage } from "./goal-record.ts";
2
- import { appendGoalEvent, type GoalLedgerEvent , appendGoalEvents } from "./goal-ledger.ts";
2
+ import { appendGoalEvent, appendGoalEvents, type GoalLedgerEvent } from "./goal-ledger.ts";
3
3
  import { findTaskInTree, updateTaskInTree } from "./goal-policy.ts";
4
4
  import {
5
5
  GOALS_DIR,
@@ -175,7 +175,12 @@ export class GoalService {
175
175
 
176
176
  /** Open (or reopen) the per-turn transaction for the focused goal. */
177
177
  beginTurn(ctx: GoalServiceContext, goalId: string | null): void {
178
- if (this.turn.active) this.flushTurn(ctx);
178
+ if (this.turn.active) {
179
+ this.flushTurn(ctx);
180
+ // Lock contention must not discard the previous transaction. Keep it
181
+ // open so the next turn boundary retries the same buffered write.
182
+ if (this.turn.active) return;
183
+ }
179
184
  this.turn = { active: true, goalId, goal: null, archive: false, ledger: [] };
180
185
  }
181
186
 
@@ -186,16 +191,20 @@ export class GoalService {
186
191
  */
187
192
  flushTurn(ctx: GoalServiceContext): GoalRecord | null {
188
193
  if (!this.turn.active) return null;
189
- this.turn.active = false;
190
194
  const goal = this.turn.goal;
191
- if (!goal) return null;
195
+ if (!goal) {
196
+ this.turn.active = false;
197
+ return null;
198
+ }
192
199
  let lock: GoalLock;
193
200
  try {
194
201
  lock = acquireGoalLock(ctx, goal.id);
195
202
  } catch {
196
- // Another writer holds the lock; skip this flush (same policy as persist).
203
+ // Another writer holds the lock; preserve the buffer so a later turn
204
+ // boundary can retry it instead of silently losing the mutation.
197
205
  return null;
198
206
  }
207
+ this.turn.active = false;
199
208
  try {
200
209
  const freshDisk = this.readFreshDiskGoal(ctx, goal);
201
210
  const base = freshDisk ?? goal;
@@ -203,25 +212,7 @@ export class GoalService {
203
212
  const written = this.turn.archive || next.status === "complete"
204
213
  ? archiveGoalFile(ctx, next)
205
214
  : writeActiveGoalFile(ctx, next);
206
- if (this.turn.ledger.length > 0) {
207
- const batch = appendGoalEvents(ctx, this.turn.ledger);
208
- if (!batch.ok) {
209
- // Fall back to per-event appends so each failure still surfaces
210
- // through the diagnostic hook with its original granularity.
211
- for (const event of this.turn.ledger) {
212
- const append = appendGoalEvent(ctx, event);
213
- if (!append.ok) {
214
- this.ref.onDiagnostic({
215
- severity: "warning",
216
- source: "ledger",
217
- goalId: "goalId" in event ? event.goalId : undefined,
218
- eventType: event.type,
219
- message: `Ledger append failed for ${event.type}${("goalId" in event) ? ` (goal ${event.goalId})` : ""}: ${String(append.error)}`,
220
- });
221
- }
222
- }
223
- }
224
- }
215
+ this.appendLedgerEventsBestEffort(ctx, this.turn.ledger);
225
216
  this.trackBaseline(written.id, written.usage);
226
217
  this.ref.setFocused(written);
227
218
  return written;
@@ -264,6 +255,28 @@ export class GoalService {
264
255
  };
265
256
  }
266
257
 
258
+ /**
259
+ * Append ledger events without making them part of the authoritative state
260
+ * transaction. A batch is the common path; individual retries preserve the
261
+ * existing best-effort behavior and keep failures observable per event.
262
+ */
263
+ private appendLedgerEventsBestEffort(ctx: GoalServiceContext, events: GoalLedgerEvent[]): void {
264
+ if (events.length === 0) return;
265
+ if (events.length > 1 && appendGoalEvents(ctx, events).ok) return;
266
+ for (const event of events) {
267
+ const append = appendGoalEvent(ctx, event);
268
+ if (!append.ok) {
269
+ this.ref.onDiagnostic({
270
+ severity: "warning",
271
+ source: "ledger",
272
+ goalId: "goalId" in event ? event.goalId : undefined,
273
+ eventType: event.type,
274
+ message: `Ledger append failed for ${event.type}${"goalId" in event ? ` (goal ${event.goalId})` : ""}: ${String(append.error)}`,
275
+ });
276
+ }
277
+ }
278
+ }
279
+
267
280
  /** Safe focused record reconciliation from disk. */
268
281
  reconcileFocused(ctx: GoalServiceContext, opts: { preserveMemoryUsage?: boolean } = {}): boolean {
269
282
  const current = this.ref.getFocused();
@@ -426,20 +439,7 @@ export class GoalService {
426
439
  } catch {
427
440
  events = [];
428
441
  }
429
- for (const event of events) {
430
- const append = appendGoalEvent(ctx, event);
431
- if (!append.ok) {
432
- // Ledger append failure after the authoritative write keeps the
433
- // successful state transition; surface an observable diagnostic.
434
- this.ref.onDiagnostic({
435
- severity: "warning",
436
- source: "ledger",
437
- goalId: "goalId" in event ? event.goalId : undefined,
438
- eventType: event.type,
439
- message: `Ledger append failed for ${event.type}${("goalId" in event) ? ` (goal ${event.goalId})` : ""}: ${String(append.error)}`,
440
- });
441
- }
442
- }
442
+ this.appendLedgerEventsBestEffort(ctx, events);
443
443
  }
444
444
 
445
445
  // 6. in-memory pool/focus commit.
@@ -594,18 +594,7 @@ export class GoalService {
594
594
  const written = writeActiveGoalFile(ctx, mutated);
595
595
  if (spec.ledger) {
596
596
  try {
597
- for (const event of spec.ledger(written, updatedTask)) {
598
- const append = appendGoalEvent(ctx, event);
599
- if (!append.ok) {
600
- this.ref.onDiagnostic({
601
- severity: "warning",
602
- source: "ledger",
603
- goalId: "goalId" in event ? event.goalId : undefined,
604
- eventType: event.type,
605
- message: `Ledger append failed for ${event.type}${("goalId" in event) ? ` (goal ${event.goalId})` : ""}: ${String(append.error)}`,
606
- });
607
- }
608
- }
597
+ this.appendLedgerEventsBestEffort(ctx, spec.ledger(written, updatedTask));
609
598
  } catch (err) {
610
599
  // Unexpected ledger-spec error after the authoritative write keeps
611
600
  // the successful state transition.
@@ -689,25 +678,7 @@ export class GoalService {
689
678
  const previousGoalId = this.ref.getFocused()?.id ?? null;
690
679
  const written = writeActiveGoalFile(ctx, sanitizeGoalPaths(ctx, spec.goal));
691
680
  if (spec.ledger && spec.ledger.length > 0) {
692
- // Batch append (P1-8): one write+append for the whole event block.
693
- const batch = appendGoalEvents(ctx, spec.ledger);
694
- if (!batch.ok) {
695
- // Fall back to per-event appends so each failure still surfaces
696
- // through the diagnostic hook with its original granularity.
697
- for (const event of spec.ledger) {
698
- const append = appendGoalEvent(ctx, event);
699
- if (!append.ok) {
700
- // Best-effort ledger; creation still succeeds.
701
- this.ref.onDiagnostic({
702
- severity: "warning",
703
- source: "ledger",
704
- goalId: "goalId" in event ? event.goalId : undefined,
705
- eventType: event.type,
706
- message: `Ledger append failed for ${event.type}${"goalId" in event ? ` (goal ${event.goalId})` : ""}: ${String(append.error)}`,
707
- });
708
- }
709
- }
710
- }
681
+ this.appendLedgerEventsBestEffort(ctx, spec.ledger);
711
682
  }
712
683
  this.ref.setFocused(written);
713
684
  this.trackBaseline(written.id, written.usage);
@@ -723,25 +694,7 @@ export class GoalService {
723
694
  this.turn.ledger.push(...events);
724
695
  return;
725
696
  }
726
- if (events.length > 1) {
727
- // Batch append (P1-8): one write+append for the whole event block.
728
- const batch = appendGoalEvents(ctx, events);
729
- if (batch.ok) return;
730
- }
731
- for (const event of events) {
732
- const append = appendGoalEvent(ctx, event);
733
- if (!append.ok) {
734
- // Ledger append failure must not crash the surrounding flow, but it
735
- // stays observable through the diagnostic hook.
736
- this.ref.onDiagnostic({
737
- severity: "warning",
738
- source: "ledger",
739
- goalId: "goalId" in event ? event.goalId : undefined,
740
- eventType: event.type,
741
- message: `Ledger append failed for ${event.type}${"goalId" in event ? ` (goal ${event.goalId})` : ""}: ${String(append.error)}`,
742
- });
743
- }
744
- }
697
+ this.appendLedgerEventsBestEffort(ctx, events);
745
698
  }
746
699
 
747
700
  /** Diagnostic write for the debug widget toggle (separate debug dir; not a goal mutation). */
@@ -508,7 +508,6 @@ export function createGoalCore(
508
508
  // goal budget_limited exactly once (status no longer active, so accounting
509
509
  // stops and the transition cannot re-fire), emit the ledger event, arm the
510
510
  // one-time wrap-up steering, and cancel pending continuations.
511
- const budgetGoal2 = state.goal;
512
511
  if (budgetGoal && budgetGoal.status === "active" && typeof budgetGoal.tokenBudget === "number" && budgetReached(budgetGoal)) {
513
512
  const transition = goalService.apply(ctx, {
514
513
  reconcile: false,
@@ -34,7 +34,11 @@ export interface GoalStatusTextOptions {
34
34
  focused: boolean;
35
35
  otherOpenGoals: number;
36
36
  ledgerEvents?: GoalLedgerEvent[];
37
+ ledgerMalformed?: number;
37
38
  verbose?: boolean;
39
+ health?: boolean;
40
+ /** Supplied by the command layer only for the explicit health check. */
41
+ activeFilePresent?: boolean;
38
42
  /** Effective settings lines with provenance (verbose only). */
39
43
  settingsReport?: string[];
40
44
  width?: number;
@@ -55,6 +59,7 @@ export interface GoalStatusTextOptions {
55
59
  */
56
60
  export function buildGoalStatusText(options: GoalStatusTextOptions): string {
57
61
  const width = options.width ?? GOAL_STATUS_WIDTH;
62
+ if (options.health) return buildHealthStatus(options, width);
58
63
  if (!options.goal) {
59
64
  return options.otherOpenGoals > 0
60
65
  ? renderUnfocusedDashboard(options.otherOpenGoals, PLAIN_THEME, width).join("\n")
@@ -87,6 +92,101 @@ export function buildGoalStatusText(options: GoalStatusTextOptions): string {
87
92
  return parts.join("\n\n");
88
93
  }
89
94
 
95
+ interface HealthCheck {
96
+ label: string;
97
+ value: string;
98
+ severity: "ok" | "warn" | "error";
99
+ }
100
+
101
+ /**
102
+ * Render a concise, read-only integrity report. This deliberately checks
103
+ * storage/runtime coherence only; it does not infer that the work itself is
104
+ * complete from task counts or contracts.
105
+ */
106
+ function buildHealthStatus(options: GoalStatusTextOptions, width: number): string {
107
+ const goal = options.goal;
108
+ if (!goal) {
109
+ const focus = options.otherOpenGoals > 0
110
+ ? `WARN — no goal is focused; ${options.otherOpenGoals} open goal${options.otherOpenGoals === 1 ? "" : "s"} remain.`
111
+ : "OK — no goal is focused and there are no open goals.";
112
+ return ["Goal health: " + (options.otherOpenGoals > 0 ? "WARN" : "OK"), `Focus: ${focus}`, "Run /goal-status for the normal dashboard."].join("\n");
113
+ }
114
+
115
+ const checks: HealthCheck[] = [];
116
+ checks.push({
117
+ label: "Focus",
118
+ value: options.focused ? "focused" : "not focused",
119
+ severity: options.focused ? "ok" : "warn",
120
+ });
121
+ checks.push({
122
+ label: "Lifecycle",
123
+ value: `${statusLabelForHealth(goal)}${goal.autoContinue ? " · auto-continue on" : " · auto-continue off"}`,
124
+ severity: goal.status === "active" || goal.status === "paused" || goal.status === "blocked" || goal.status === "budget_limited" || goal.status === "complete" ? "ok" : "error",
125
+ });
126
+
127
+ if (goal.status !== "complete") {
128
+ const fileSeverity = options.activeFilePresent === false ? "error" : options.activeFilePresent === true ? "ok" : "warn";
129
+ checks.push({
130
+ label: "Goal file",
131
+ value: goal.activePath ? (options.activeFilePresent === false ? `missing · ${goal.activePath}` : goal.activePath) : "missing active path",
132
+ severity: fileSeverity,
133
+ });
134
+ }
135
+
136
+ const malformed = options.ledgerMalformed ?? 0;
137
+ checks.push({
138
+ label: "Ledger",
139
+ value: malformed > 0 ? `${malformed} malformed entr${malformed === 1 ? "y" : "ies"}` : "valid",
140
+ severity: malformed > 0 ? "warn" : "ok",
141
+ });
142
+
143
+ if (goal.taskList) {
144
+ const tasks = flattenHealthTasks(goal.taskList.tasks);
145
+ const completed = tasks.filter((task) => task.status === "complete" || task.status === "skipped").length;
146
+ const pending = tasks.length - completed;
147
+ const contractedPending = tasks.filter((task) => task.status === "pending" && Boolean(task.verificationContract?.trim())).length;
148
+ checks.push({
149
+ label: "Tasks",
150
+ value: `${completed}/${tasks.length} terminal · ${pending} pending${contractedPending > 0 ? ` · ${contractedPending} contracted` : ""}`,
151
+ severity: goal.taskList.blockCompletion && pending > 0 ? "warn" : "ok",
152
+ });
153
+ }
154
+
155
+ if (typeof goal.tokenBudget === "number" && goal.tokenBudget > 0) {
156
+ const percentage = Math.round((goal.usage.tokensUsed / goal.tokenBudget) * 100);
157
+ checks.push({
158
+ label: "Budget",
159
+ value: `${formatCompactTokensForHealth(goal.usage.tokensUsed)} / ${formatCompactTokensForHealth(goal.tokenBudget)} (${Math.max(0, percentage)}%)`,
160
+ severity: goal.status === "budget_limited" || percentage >= 100 ? "warn" : percentage >= 90 ? "warn" : "ok",
161
+ });
162
+ }
163
+
164
+ const overall = checks.some((check) => check.severity === "error") ? "ERROR" : checks.some((check) => check.severity === "warn") ? "WARN" : "OK";
165
+ const lines = [`Goal health: ${overall}`, `Goal: ${truncateText(goal.objective, Math.max(20, width - 12))}`];
166
+ for (const check of checks) lines.push(`${check.severity === "error" ? "ERROR" : check.severity === "warn" ? "WARN" : "OK"} ${check.label}: ${check.value}`);
167
+ lines.push("", "This is a storage/runtime health check, not a completion verdict.");
168
+ return lines.join("\n");
169
+ }
170
+
171
+ function flattenHealthTasks(tasks: GoalTask[]): GoalTask[] {
172
+ const result: GoalTask[] = [];
173
+ for (const task of tasks) {
174
+ result.push(task);
175
+ if (task.subtasks) result.push(...flattenHealthTasks(task.subtasks));
176
+ }
177
+ return result;
178
+ }
179
+
180
+ function statusLabelForHealth(goal: GoalRecord): string {
181
+ return goal.status === "budget_limited" ? "budget limited" : goal.status;
182
+ }
183
+
184
+ function formatCompactTokensForHealth(value: number): string {
185
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
186
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1).replace(/\.0$/, "")}K`;
187
+ return String(Math.max(0, Math.floor(value)));
188
+ }
189
+
90
190
  function lastAuditBlock(
91
191
  audit: { verdict: "approved" | "disapproved" | "error"; report: string; at: string },
92
192
  width: number,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-x",
3
- "version": "0.26.1",
3
+ "version": "0.26.3",
4
4
  "description": "Goal mode extension for pi: durable long-running objectives, five model tools, structured tasks, independent completion audit, Sisyphus mode, auto-continue, and a status overlay.",
5
5
  "license": "MIT",
6
6
  "author": "pi-goal-x contributors",