pi-goal-list-loop-audit 0.35.4 → 0.35.13

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.
@@ -39,6 +39,7 @@ import {
39
39
  workCommandRoot,
40
40
  appendLedger,
41
41
  claimRecoveryNotice,
42
+ assignQueueOrder,
42
43
  providerErrorPresentation,
43
44
  sanitizeProviderAuditReport,
44
45
  sanitizeProviderDisplayText,
@@ -59,10 +60,9 @@ import {
59
60
  computeListDepth,
60
61
  formatAuditLog,
61
62
  formatGoalAuditHistory,
62
- runWithInfraRetry,
63
- isRetriableInfraError,
64
63
  readAuditLog,
65
64
  bumpGoalRevision,
65
+ captureGoalRevision,
66
66
  stripThinkBlocks,
67
67
  type AuditLogEntry,
68
68
  ledgerPath,
@@ -177,13 +177,10 @@ import {
177
177
  tickLengthContinue,
178
178
  } from "../length-continue.js";
179
179
  import {
180
- classifyMainModelFailure,
181
180
  mainModelAutoRetryUntil,
182
- mainModelFailureDelayMs,
183
181
  mainModelRetryDelayMs,
184
182
  MAIN_MODEL_AUTO_RETRY_HORIZON_MS,
185
183
  modelRef,
186
- nextUntriedModelRef,
187
184
  normalizeModelRefs,
188
185
  sendStormEscalateMs,
189
186
  splitModelRef,
@@ -221,6 +218,10 @@ import {
221
218
  runDetachedGoalCompletionAuditor,
222
219
  type AuditorProgress,
223
220
  } from "../goal-loop-auditor-process.js";
221
+ import {
222
+ extractMechanicalCheckCommands,
223
+ runMechanicalPreAuditChecks,
224
+ } from "../goal-loop-shield.js";
224
225
  import {
225
226
  REPETITION,
226
227
  isActuallyStuck,
@@ -339,6 +340,7 @@ import {
339
340
  import {
340
341
  addSingleItem,
341
342
  autoNotifyCmd,
343
+ hydrateListQueueFromDisk,
342
344
  cmdGoal,
343
345
  cmdList,
344
346
  cmdReview,
@@ -414,8 +416,26 @@ function registerAgentTools(pi: any): void {
414
416
  label: "Complete goal",
415
417
  description: "Mark the active goal as complete. Queues a detached auditor worker to verify without holding the main pi turn. Use only when the objective is genuinely satisfied.",
416
418
  parameters: Type.Object({
417
- completionSummary: Type.Optional(Type.String({ description: "1-paragraph completion claim" })),
418
- verificationSummary: Type.Optional(Type.String({ description: "Per-item evidence for the verification contract" })),
419
+ // v0.34.136: completionSummary adopts the six-label recap from
420
+ // audit/COMPLETION-SUMMARY-POLICY-2026-08-19.md (Outcome / Changed
421
+ // / Evidence / Tests / Unresolved / Next). Every label is present
422
+ // even when its value is `none`; the durable archive preserves all
423
+ // six lines verbatim and the display/widget projection does NOT
424
+ // fabricate an auditor verdict.
425
+ completionSummary: Type.Optional(Type.String({
426
+ description:
427
+ "Six-label recap — one line per label, every label required (use `none` when empty): " +
428
+ "Outcome: <what was delivered> · Changed: <files/behavior/decision> · Evidence: <key commit/report/result> · " +
429
+ "Tests: <bounded commands + pass/fail, or `not run — <reason>`> · Unresolved: <remaining risk, or `none`> · " +
430
+ "Next: <one follow-up hint, or `none`>. " +
431
+ "See audit/COMPLETION-SUMMARY-POLICY-2026-08-19.md. Free-form prose is allowed but discouraged.",
432
+ })),
433
+ verificationSummary: Type.Optional(Type.String({
434
+ description:
435
+ "Per-item evidence for the verification contract. " +
436
+ "Stays independent of completionSummary: the detached auditor receives both fields separately " +
437
+ "and cross-checks the verification claim against real artifacts (see audit/COMPLETION-SUMMARY-POLICY-2026-08-19.md).",
438
+ })),
419
439
  newObjective: Type.Optional(Type.String({ description: "v0.25.0 (contract item 15): when the work has legitimately shifted, pass the new objective here — it atomically replaces the goal objective AND the audit proceeds against the NEW objective in this same call. Do not use to dodge a legitimate disapproval; the auditor sees the change." })),
420
440
  }),
421
441
  async execute(_id, params, signal, _onUpdate, execCtx) {
@@ -649,11 +669,22 @@ function registerAgentTools(pi: any): void {
649
669
  // every state/UI access below rebinds through the generation guard.
650
670
  completionAuditInFlight = true;
651
671
  completionAuditGeneration = auditGeneration;
652
- latestAuditProgress = { label: "queued", lastEventAt: Date.now() };
672
+ latestAuditProgress = {
673
+ label: "queued",
674
+ model: modelRef(auditorModel),
675
+ via: via ?? "unset",
676
+ lastEventAt: Date.now(),
677
+ };
653
678
  refreshUI(ctx);
654
679
  void (async () => {
655
- const runAudit = (candidate: AuditorModelCandidate) =>
656
- runDetachedGoalCompletionAuditor({
680
+ const runAudit = (candidate: AuditorModelCandidate) => {
681
+ latestAuditProgress = {
682
+ ...(latestAuditProgress ?? {}),
683
+ model: modelRef(candidate.model),
684
+ via: candidate.via,
685
+ };
686
+ refreshUI(ctx);
687
+ return runDetachedGoalCompletionAuditor({
657
688
  cwd: ctx.cwd,
658
689
  goal: auditGoal,
659
690
  completionSummary: finalSummary,
@@ -673,6 +704,7 @@ function registerAgentTools(pi: any): void {
673
704
  appendLedger(current.cwd, "auditor_stalled", { goalId: auditGoalId, attemptId: auditAttemptId, ...info });
674
705
  },
675
706
  });
707
+ };
676
708
  // v0.25.4 (post-audit fix): a retriable infra failure (stream error,
677
709
  // auth blip — NOT user abort, NOT missing model) gets ONE automatic
678
710
  // retry with backoff before we report "auditor infrastructure error
@@ -681,29 +713,53 @@ function registerAgentTools(pi: any): void {
681
713
  let result: Awaited<ReturnType<typeof runAudit>>;
682
714
  let retriedOnce = false;
683
715
  let fallbackUsed = false;
684
- try {
685
- ({ result, retriedOnce, fallbackUsed } = await runDetachedCompletionWithFallback(auditorCandidates, runAudit, {
686
- shouldRetry: () => detachedAuditContext(auditGeneration, auditGoalId, auditAttemptId) !== null,
687
- onRetry: (candidate: AuditorModelCandidate, err: string) => {
688
- const current = detachedAuditContext(auditGeneration, auditGoalId, auditAttemptId);
689
- if (!current) return;
690
- const failureCopy = providerErrorPresentation(err, "completion");
691
- latestAuditProgress = { label: `${failureCopy.display} — retrying once`, lastEventAt: Date.now() };
692
- refreshUI(current);
693
- appendLedger(current.cwd, "audit_infra_retry", { goalId: auditGoalId, model: auditorCandidateLabel(candidate), error: failureCopy.diagnostic.slice(0, 200), diagnostic: failureCopy.diagnostic });
694
- },
695
- onFallback: (from: AuditorModelCandidate, to: AuditorModelCandidate, err: string) => {
696
- const current = detachedAuditContext(auditGeneration, auditGoalId, auditAttemptId);
697
- if (!current) return;
698
- appendLedger(current.cwd, "auditor_runtime_model_fallback", { goalId: auditGoalId, from: auditorCandidateLabel(from), to: auditorCandidateLabel(to), error: err.slice(0, 200) });
699
- current.ui.notify(`Detached auditor failed on ${auditorCandidateLabel(from)} — retrying with ${auditorCandidateLabel(to)}. This is infrastructure, not a verdict.`, "warning");
700
- },
701
- }));
702
- } finally {
703
- if (ownsDetachedAudit(auditGeneration, auditGoalId, auditAttemptId)) {
704
- clearDetachedAuditProgress(auditGeneration, auditGoalId, auditAttemptId);
705
- completionAuditInFlight = false;
706
- completionAuditGeneration = null;
716
+ // v0.35.7: Deterministic Fast-Fail Pre-Audit — if mechanical contract checks fail,
717
+ // fail in 200ms with raw output rather than burning 45s on an LLM audit pass.
718
+ const mechanicalCmds = extractMechanicalCheckCommands(auditGoal.verificationContract ?? "");
719
+ const mechanicalResult = runMechanicalPreAuditChecks(ctx.cwd, mechanicalCmds);
720
+ if (!mechanicalResult.passed) {
721
+ result = {
722
+ approved: false,
723
+ disapproved: true,
724
+ impossible: false,
725
+ output: `<disapproved/>\n\nDeterministic Pre-Audit Fast-Fail: Mechanical contract check failed: \`${mechanicalResult.failedCommand}\` (exit code ${mechanicalResult.exitCode})\n\n<evidence>\n${mechanicalResult.output}\n</evidence>`,
726
+ model: "deterministic-pre-audit",
727
+ regressionShieldPassed: true,
728
+ goalRevision: captureGoalRevision(auditGoal) ?? undefined,
729
+ };
730
+ } else {
731
+ try {
732
+ ({ result, retriedOnce, fallbackUsed } = await runDetachedCompletionWithFallback(auditorCandidates, runAudit, {
733
+ shouldRetry: () => detachedAuditContext(auditGeneration, auditGoalId, auditAttemptId) !== null,
734
+ forbiddenRefs: settings.forbiddenModels,
735
+ retryBaseMinutes: settings.mainModelRetryMinutes,
736
+ onRetry: (candidate: AuditorModelCandidate, err: string) => {
737
+ const current = detachedAuditContext(auditGeneration, auditGoalId, auditAttemptId);
738
+ if (!current) return;
739
+ const failureCopy = providerErrorPresentation(err, "completion");
740
+ latestAuditProgress = {
741
+ ...(latestAuditProgress ?? {}),
742
+ model: modelRef(candidate.model),
743
+ via: candidate.via,
744
+ label: `${failureCopy.display} — retrying once`,
745
+ lastEventAt: Date.now(),
746
+ };
747
+ refreshUI(current);
748
+ appendLedger(current.cwd, "audit_infra_retry", { goalId: auditGoalId, model: auditorCandidateLabel(candidate), error: failureCopy.diagnostic.slice(0, 200), diagnostic: failureCopy.diagnostic });
749
+ },
750
+ onFallback: (from: AuditorModelCandidate, to: AuditorModelCandidate, err: string) => {
751
+ const current = detachedAuditContext(auditGeneration, auditGoalId, auditAttemptId);
752
+ if (!current) return;
753
+ appendLedger(current.cwd, "auditor_runtime_model_fallback", { goalId: auditGoalId, from: auditorCandidateLabel(from), to: auditorCandidateLabel(to), error: err.slice(0, 200) });
754
+ current.ui.notify(`Detached auditor failed on ${auditorCandidateLabel(from)} — retrying with ${auditorCandidateLabel(to)}. This is infrastructure, not a verdict.`, "warning");
755
+ },
756
+ }));
757
+ } finally {
758
+ if (ownsDetachedAudit(auditGeneration, auditGoalId, auditAttemptId)) {
759
+ clearDetachedAuditProgress(auditGeneration, auditGoalId, auditAttemptId);
760
+ completionAuditInFlight = false;
761
+ completionAuditGeneration = null;
762
+ }
707
763
  }
708
764
  }
709
765
  const auditContextAfterRun = freshCtxForGeneration(auditGeneration);
@@ -1376,6 +1432,19 @@ function registerAgentTools(pi: any): void {
1376
1432
  while (queue.length > 0) {
1377
1433
  const t = queue.shift();
1378
1434
  if (t.id === p.id && t.status !== "complete") {
1435
+ if (t.verificationContract) {
1436
+ const checkCmds = extractMechanicalCheckCommands(t.verificationContract);
1437
+ const checkRes = runMechanicalPreAuditChecks(ctx.cwd, checkCmds);
1438
+ if (!checkRes.passed) {
1439
+ return {
1440
+ content: [{
1441
+ type: "text",
1442
+ text: `Task ${p.id} milestone verification FAILED for command \`${checkRes.failedCommand}\` (exit code ${checkRes.exitCode}):\n\n${checkRes.output}\n\nTask ${p.id} remains in_progress. Fix the failure before marking complete.`,
1443
+ }],
1444
+ details: {},
1445
+ };
1446
+ }
1447
+ }
1379
1448
  t.status = "complete";
1380
1449
  updateGoal({ taskList: tl }, ctx);
1381
1450
  return { content: [{ type: "text", text: `Task ${p.id} marked complete.` }], details: {} };
@@ -1603,20 +1672,24 @@ function registerAgentTools(pi: any): void {
1603
1672
  };
1604
1673
  }
1605
1674
  }
1675
+ hydrateListQueueFromDisk(liveCtx);
1606
1676
  const extracted = parseListItemDeclaration(full);
1607
- const item = {
1677
+ const item = assignQueueOrder([{
1608
1678
  id: newGoalId(),
1609
1679
  objective: extracted.objective,
1610
1680
  ...(extracted.agentRole ? { agentRole: extracted.agentRole } : {}),
1611
1681
  verificationContract: extracted.verificationContract || undefined,
1612
1682
  ...(extracted.parallelSafe === undefined ? {} : { parallelSafe: extracted.parallelSafe }),
1613
1683
  addedAt: nowIso(),
1614
- };
1684
+ }], listQueue())[0]!;
1615
1685
  // v0.34.61: disk-first — same invariant as addSingleItem. The list
1616
1686
  // draft path was the second-missed place: previously the in-memory
1617
1687
  // state mutated without a sidecar, so a torn-rename or post-mutation
1618
1688
  // crash could drop the drafted item.
1619
- writeQueueItemFile(liveCtx.cwd, item);
1689
+ const written = writeQueueItemFile(liveCtx.cwd, item);
1690
+ if (written.failed) {
1691
+ return { content: [{ type: "text", text: "The list draft could not be persisted; no in-memory queue mutation was applied. Fix disk access and retry." }], details: {} };
1692
+ }
1620
1693
  replaceState({ ...state, list: [...listQueue(), item] });
1621
1694
  persistState(liveCtx);
1622
1695
  appendLedger(liveCtx.cwd, "list_added", { id: item.id, objective: item.objective, drafted: true });
@@ -2016,6 +2089,7 @@ function registerAgentTools(pi: any): void {
2016
2089
  tasks: Type.Array(Type.Object({
2017
2090
  title: Type.String(),
2018
2091
  agentRole: Type.Optional(Type.Literal("designer", { description: "Route this task through the read-only Designer subagent before implementation." })),
2092
+ verificationContract: Type.Optional(Type.String({ description: "Optional verification gate command for this milestone before it can be marked complete." })),
2019
2093
  subtasks: Type.Optional(Type.Array(Type.String())),
2020
2094
  })),
2021
2095
  }),
@@ -224,7 +224,7 @@ import {
224
224
  textFingerprint,
225
225
  pushCapped as pushRepetitionCapped,
226
226
  } from "../goal-loop-repetition.js";
227
- import { buildStatusText, buildWidgetLines, type AuditDisplayProgress } from "../goal-loop-display.js";
227
+ import { buildStatusText, buildWidgetLines, type AuditDisplayProgress, type ModelProvenanceDisplay } from "../goal-loop-display.js";
228
228
  import {
229
229
  defaultAgentDir,
230
230
  resolveEffectiveSubagentModel,
@@ -474,6 +474,8 @@ function isSupervising(): boolean {
474
474
 
475
475
  let latestAuditProgress: AuditDisplayProgress | null = null;
476
476
  let uiTicker: NodeJS.Timeout | null = null;
477
+ let deferredUIRefresh: NodeJS.Timeout | null = null;
478
+ const UI_REFRESH_SETTLE_MS = 50;
477
479
  const LIVE_STREAM_PROOF_MS = 15_000;
478
480
 
479
481
  /**
@@ -502,6 +504,11 @@ function publishDetachedAuditProgress(
502
504
  const current = detachedAuditContext(generation, goalId, attemptId);
503
505
  if (!current) return false;
504
506
  latestAuditProgress = {
507
+ // Candidate identity is marked by the parent before the worker starts;
508
+ // preserve it across progress-file snapshots so the card never loses
509
+ // which model actually handled the audit.
510
+ model: latestAuditProgress?.model,
511
+ via: latestAuditProgress?.via,
505
512
  currentTool: progress.currentTool,
506
513
  currentToolArgs: progress.currentToolArgs,
507
514
  currentToolStartedAt: progress.currentToolStartedAt,
@@ -634,7 +641,36 @@ function refreshUI(ctx: ExtensionContext): void {
634
641
  const width = process.stdout.columns || 80;
635
642
  const activity = displayActivityFor(ctx);
636
643
  const settings = loadSettings(ctx.cwd);
637
- const extras = { stalls: consecutiveStalls, recent: recentActions, ...activity, turnPending: pendingContinuationDispatchRef() !== null, auditorSilent: loadSettings(ctx.cwd).auditorSilent !== false, auditorProgressSignals: settings.auditorProgressSignals !== false, mainModelFallbacks: settings.mainModelFallbacks ?? [] };
644
+ const sessionRef = modelRef(ctx.model);
645
+ const recovery = state.mainModelRecovery;
646
+ const fallbackRefs = settings.mainModelFallbacks ?? [];
647
+ const skippedForbiddenRefs = [
648
+ ...fallbackRefs.filter((ref) => isForbiddenModel(ref, settings.forbiddenModels)),
649
+ ...(recovery?.skipped ?? []).filter((entry) => entry.reason === "forbidden").map((entry) => entry.ref),
650
+ ];
651
+ const modelProvenance: ModelProvenanceDisplay = {
652
+ // Main-model pins are session-owned today; an explicit goal pin will be
653
+ // able to set primarySource:"pinned" without changing the renderer.
654
+ primary: recovery?.primary ?? sessionRef,
655
+ primarySource: "inherited",
656
+ fallbackRefs,
657
+ skippedForbiddenRefs,
658
+ handledTurn: state.lastModelRef ?? recovery?.active ?? sessionRef,
659
+ ...(latestAuditProgress?.model ? {
660
+ handledAudit: latestAuditProgress.model,
661
+ handledAuditSource: latestAuditProgress.via,
662
+ } : {}),
663
+ };
664
+ const extras = {
665
+ stalls: consecutiveStalls,
666
+ recent: recentActions,
667
+ ...activity,
668
+ turnPending: pendingContinuationDispatchRef() !== null,
669
+ auditorSilent: loadSettings(ctx.cwd).auditorSilent !== false,
670
+ auditorProgressSignals: settings.auditorProgressSignals !== false,
671
+ mainModelFallbacks: fallbackRefs,
672
+ modelProvenance,
673
+ };
638
674
  // Settings are loaded once for the remaining projections; the explicit
639
675
  // auditorSilent expression preserves the source-level contract.
640
676
  ctx.ui.setStatus("pi-glla", buildStatusText(state, latestAuditProgress, Date.now(), theme, extras));
@@ -644,6 +680,26 @@ function refreshUI(ctx: ExtensionContext): void {
644
680
  }
645
681
  }
646
682
 
683
+ /**
684
+ * Repaint once more after the current event/tool turn yields back to pi.
685
+ *
686
+ * `refreshUI()` is called synchronously when state is persisted, but pi can
687
+ * repaint the transcript/editor after that callback and restore the previous
688
+ * extension widget. The regular ticker is intentionally best-effort and can
689
+ * also be between session contexts at that boundary. A debounced next-turn
690
+ * repaint makes the durable state transition win without making the detached
691
+ * auditor or the main turn wait for UI work.
692
+ */
693
+ function scheduleUIRefresh(): void {
694
+ if (deferredUIRefresh) return;
695
+ deferredUIRefresh = setTimeout(() => {
696
+ deferredUIRefresh = null;
697
+ const ctx = freshCtx();
698
+ if (ctx) refreshUI(ctx);
699
+ }, UI_REFRESH_SETTLE_MS);
700
+ deferredUIRefresh.unref?.();
701
+ }
702
+
647
703
  function startUITicker(): void {
648
704
  if (uiTicker) return;
649
705
  uiTicker = setInterval(() => {
@@ -815,6 +871,7 @@ defineGoalRuntimeGlobal("noteToolCall", { get: () => noteToolCall });
815
871
  defineGoalRuntimeGlobal("noteToolResult", { get: () => noteToolResult });
816
872
  defineGoalRuntimeGlobal("displayActivityFor", { get: () => displayActivityFor });
817
873
  defineGoalRuntimeGlobal("refreshUI", { get: () => refreshUI });
874
+ defineGoalRuntimeGlobal("scheduleUIRefresh", { get: () => scheduleUIRefresh });
818
875
  defineGoalRuntimeGlobal("startUITicker", { get: () => startUITicker });
819
876
  defineGoalRuntimeGlobal("loopRearmStreak", { get: () => loopRearmStreak, set: (v) => { loopRearmStreak = v as any; } });
820
877
  defineGoalRuntimeGlobal("compactionGraceUntil", { get: () => compactionGraceUntil, set: (v) => { compactionGraceUntil = v as any; } });
@@ -7,11 +7,30 @@
7
7
 
8
8
  export const MAIN_MODEL_MAX_RETRY_DELAY_MS = 5 * 60 * 60_000;
9
9
  export const MAIN_MODEL_AUTO_RETRY_HORIZON_MS = 24 * 60 * 60_000;
10
+ export const DEFAULT_MAIN_MODEL_PRIMARY_PROBE_MINUTES = 15;
10
11
  /** Keep a fallback chain useful and bounded even when settings are edited
11
12
  * outside the UI. Ten alternatives is enough to cross providers/model pools
12
13
  * without turning one failure into an unbounded registry walk. */
13
14
  export const MAX_MAIN_MODEL_FALLBACKS = 10;
14
15
 
16
+ export type MainModelFailbackPolicy = "auto" | "sticky";
17
+
18
+ /** Failback is deliberately opt-out: a configured fallback is temporary cover
19
+ * unless the user explicitly keeps it sticky. Invalid/missing JSON values use
20
+ * the safe default rather than disabling recovery by accident. */
21
+ export function isMainModelFailbackAuto(policy: unknown): boolean {
22
+ return policy !== "sticky";
23
+ }
24
+
25
+ /** Convert the preferred-primary probe cadence to a timer delay. The settings
26
+ * UI accepts positive minutes; the runtime still clamps hand-edited values to
27
+ * a useful bounded default. */
28
+ export function mainModelPrimaryProbeDelayMs(minutes: unknown = DEFAULT_MAIN_MODEL_PRIMARY_PROBE_MINUTES): number {
29
+ const value = typeof minutes === "number" ? minutes : Number(minutes);
30
+ const safeMinutes = Number.isFinite(value) && value > 0 ? value : DEFAULT_MAIN_MODEL_PRIMARY_PROBE_MINUTES;
31
+ return Math.max(1_000, Math.round(safeMinutes * 60_000));
32
+ }
33
+
15
34
  export type MainModelFailureKind = "rate-limit" | "quota" | "billing" | "auth" | "transient" | "unknown" | "non-recoverable" | "context-overflow";
16
35
 
17
36
  export interface MainModelFailure {
@@ -109,7 +128,11 @@ export function classifyMainModelFailure(error: string | undefined, opts?: { isC
109
128
  const raw = typeof error === "string" ? error.trim() : "";
110
129
  const text = raw.toLowerCase();
111
130
  if (!raw) return { kind: "unknown", raw };
112
- if (/aborted|cancelled|canceled|user interrupt/.test(text)) {
131
+ // Auditor timeouts / watchdog stalls are transient infrastructure failures, not user aborts.
132
+ if (/^(?:auditor (?:exceeded|stalled)|.*(?:timed?\s*out|timeout|inactivity|no session activity))/i.test(raw)) {
133
+ return { kind: "transient", raw };
134
+ }
135
+ if (/^(?:auditor aborted\.?$|user (?:interrupt|abort)|cancelled by user)/i.test(raw) || /user interrupt/.test(text)) {
113
136
  return { kind: "non-recoverable", raw };
114
137
  }
115
138
  if (/context|output[ -]?token|max_?tokens|length limit|too many tokens|prompt too large|context window/.test(text)) {
@@ -21,7 +21,7 @@
21
21
  import { fuzzyFilter, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
22
22
  import type { SettingsMenuTheme, KeybindingsManagerLike } from "./settings-menu.ts";
23
23
 
24
- export type ModelPickKind = "session" | "model" | "manual";
24
+ export type ModelPickKind = "session" | "inherit" | "model" | "manual";
25
25
 
26
26
  export interface ModelPickItem {
27
27
  kind: ModelPickKind;
@@ -33,6 +33,8 @@ export interface ModelPickItem {
33
33
  searchText: string;
34
34
  /** A preserved configured ref that cannot currently be selected. */
35
35
  disabledReason?: string;
36
+ /** True for the explicit dynamic session-inheritance choice. */
37
+ inheritFromSession?: boolean;
36
38
  }
37
39
 
38
40
  export interface RegistryModelLike {
@@ -41,7 +41,8 @@ import {
41
41
  export type ModelScope =
42
42
  | { kind: "session" }
43
43
  | { kind: "subagent"; agentName: string }
44
- | { kind: "drafter" };
44
+ | { kind: "drafter" }
45
+ | { kind: "auditor" };
45
46
 
46
47
  export interface ModelSelectorDeps {
47
48
  /** Read the fallback chain for a scope (returns the configured list of
@@ -14,7 +14,8 @@
14
14
  // UX:
15
15
  // • Space (" ") toggles the highlighted item. Models are toggleable;
16
16
  // session-row and manual-row both render but pressing space on them
17
- // is a no-op (they're not model refs to pick).
17
+ // is a no-op (they're not model refs to pick). An optional explicit
18
+ // inherit-from-session row is a dynamic choice, distinct from both.
18
19
  // • Tab enters/exits ORDER MODE: while active, ↑/↓ moves the highlighted
19
20
  // chain row earlier/later in the try order (membership unchanged). The
20
21
  // active chain row is highlighted in the summary pane. Browsing/search
@@ -47,13 +48,26 @@ export interface MultiModelPickerDeps {
47
48
  maxVisibleRows?: number;
48
49
  /** Maximum number of model refs that may be selected. Undefined = no cap. */
49
50
  maxSelections?: number;
51
+ /** Add a distinct dynamic "inherit from session" choice row. */
52
+ includeInheritOption?: boolean;
53
+ /** Initial state for the explicit inherit choice. */
54
+ initialInheritFromSession?: boolean;
50
55
  }
51
56
 
52
- export type MultiModelPickerResult = string[] | undefined;
57
+ export interface MultiModelPickerSelection {
58
+ refs: string[];
59
+ inheritFromSession: boolean;
60
+ }
61
+
62
+ /** The legacy string[] result remains the default; enabling the inherit row
63
+ * returns a selection object so callers cannot confuse inheritance with the
64
+ * old session-clear or manual-entry rows. */
65
+ export type MultiModelPickerResult = string[] | MultiModelPickerSelection | undefined;
53
66
 
54
67
  export class MultiModelPickerComponent {
55
68
  private readonly title: string;
56
69
  private readonly items: ModelPickItem[];
70
+ private readonly includeInheritOption: boolean;
57
71
  private readonly maxRows: number;
58
72
  private readonly maxSelections: number | undefined;
59
73
  private readonly currentRef: string | undefined;
@@ -66,6 +80,8 @@ export class MultiModelPickerComponent {
66
80
  private selectedIdx = 0;
67
81
  /** Ordered list of selected refs — toggle order, not list order. */
68
82
  private readonly selection: string[];
83
+ /** Explicit dynamic choice; it never becomes a fake provider/model ref. */
84
+ private inheritFromSession: boolean;
69
85
  /** Order mode: ↑/↓ move the chain instead of navigating the list. */
70
86
  private orderMode = false;
71
87
  /** Chain index the order-mode cursor sits on (follows the moved row). */
@@ -79,7 +95,16 @@ export class MultiModelPickerComponent {
79
95
  done: (result: MultiModelPickerResult) => void,
80
96
  ) {
81
97
  this.title = deps.title;
82
- this.items = deps.items;
98
+ this.includeInheritOption = deps.includeInheritOption === true || deps.items.some((item) => item.kind === "inherit");
99
+ const inheritItem: ModelPickItem = {
100
+ kind: "inherit",
101
+ label: "inherit from session — use the live session model",
102
+ searchText: "inherit from session live session model",
103
+ inheritFromSession: true,
104
+ };
105
+ this.items = this.includeInheritOption && !deps.items.some((item) => item.kind === "inherit")
106
+ ? [inheritItem, ...deps.items]
107
+ : deps.items;
83
108
  this.maxRows = deps.maxVisibleRows ?? 12;
84
109
  this.maxSelections = deps.maxSelections !== undefined && Number.isInteger(deps.maxSelections) && deps.maxSelections >= 0
85
110
  ? deps.maxSelections
@@ -106,6 +131,7 @@ export class MultiModelPickerComponent {
106
131
  initial.push(itemRef.get(key) ?? ref);
107
132
  }
108
133
  this.selection = this.maxSelections === undefined ? initial : initial.slice(0, this.maxSelections);
134
+ this.inheritFromSession = this.includeInheritOption && deps.initialInheritFromSession === true;
109
135
  }
110
136
 
111
137
  /** Current search query. Exposed for tests. */
@@ -123,6 +149,11 @@ export class MultiModelPickerComponent {
123
149
  return [...this.selection];
124
150
  }
125
151
 
152
+ /** Whether the explicit inherit choice is enabled. Exposed for tests. */
153
+ getInheritFromSession(): boolean {
154
+ return this.inheritFromSession;
155
+ }
156
+
126
157
  /** Filtered items for the current query. Exposed for tests. */
127
158
  filteredItems(): ModelPickItem[] {
128
159
  if (!this.query.trim()) return this.items;
@@ -162,7 +193,15 @@ export class MultiModelPickerComponent {
162
193
 
163
194
  private toggle(): void {
164
195
  const it = this.selectedItem();
165
- if (!it || it.kind !== "model" || !it.ref) return;
196
+ if (!it) return;
197
+ if (it.kind === "inherit") {
198
+ if (this.includeInheritOption) {
199
+ this.inheritFromSession = !this.inheritFromSession;
200
+ this.refresh();
201
+ }
202
+ return;
203
+ }
204
+ if (it.kind !== "model" || !it.ref) return;
166
205
  const idx = this.selectionIndex(it.ref);
167
206
  if (idx >= 0) {
168
207
  // Removing a stale/blocked ref is always allowed: the user is fixing
@@ -234,6 +273,16 @@ export class MultiModelPickerComponent {
234
273
  return idx >= 0 ? `[${idx + 1}]` : "[ ]";
235
274
  }
236
275
 
276
+ private itemMarker(item: ModelPickItem): string {
277
+ if (item.kind === "inherit") return this.inheritFromSession ? "[inherit]" : "[ ]";
278
+ return this.orderLabel(item.ref);
279
+ }
280
+
281
+ private result(): MultiModelPickerResult {
282
+ if (!this.includeInheritOption) return [...this.selection];
283
+ return { refs: [...this.selection], inheritFromSession: this.inheritFromSession };
284
+ }
285
+
237
286
  private itemForRef(ref: string): ModelPickItem | undefined {
238
287
  const key = ref.toLowerCase();
239
288
  return this.items.find((item) => item.kind === "model" && item.ref?.toLowerCase() === key);
@@ -249,6 +298,11 @@ export class MultiModelPickerComponent {
249
298
  } else {
250
299
  lines.push(this.theme.fg("muted", "configured try order (first eligible ref wins):"));
251
300
  }
301
+ if (this.includeInheritOption) {
302
+ const inheritedRef = this.currentRef ?? "current session model";
303
+ const state = this.inheritFromSession ? "on" : "off";
304
+ lines.push(truncateToWidth(` inherit session ${inheritedRef} · ${state}`, w, "…"));
305
+ }
252
306
  if (this.selection.length === 0) {
253
307
  lines.push(this.theme.fg("dim", this.currentRef
254
308
  ? " — no backups; keep probing the current model"
@@ -276,6 +330,9 @@ export class MultiModelPickerComponent {
276
330
  const count = `${this.selection.length}/${this.maxSelections}`;
277
331
  lines.push(this.theme.fg(this.selection.length >= this.maxSelections ? "warning" : "muted", `selected: ${count}`));
278
332
  }
333
+ if (this.includeInheritOption) {
334
+ lines.push(this.theme.fg("muted", `inherit from session: ${this.inheritFromSession ? "selected" : "not selected"}`));
335
+ }
279
336
  lines.push(this.theme.fg("dim", this.orderMode
280
337
  ? "↑/↓ moves the highlighted backup · tab returns to browsing"
281
338
  : "selected rows are tried top-to-bottom · tab = order mode"));
@@ -292,7 +349,7 @@ export class MultiModelPickerComponent {
292
349
  for (let i = 0; i < window.length; i++) {
293
350
  const idx = start + i;
294
351
  const it = window[i]!;
295
- const marker = this.orderLabel(it.ref);
352
+ const marker = this.itemMarker(it);
296
353
  const disabledReason = this.effectiveDisabledReason(it);
297
354
  const disabled = disabledReason && !this.isSelected(it.ref) ? ` · ${disabledReason}` : "";
298
355
  const row = truncateToWidth(`${marker} ${it.label}${disabled}`, w - 2, "…");
@@ -346,7 +403,7 @@ export class MultiModelPickerComponent {
346
403
  return;
347
404
  }
348
405
  if (this.keybindings.matches(data, "tui.select.confirm")) {
349
- this.done([...this.selection]);
406
+ this.done(this.result());
350
407
  return;
351
408
  }
352
409
  if (this.keybindings.matches(data, "tui.select.cancel") || data === "\x1b") {
@@ -356,7 +413,7 @@ export class MultiModelPickerComponent {
356
413
  return;
357
414
  }
358
415
  if (this.keybindings.matches(data, "tui.select.confirm")) {
359
- this.done([...this.selection]);
416
+ this.done(this.result());
360
417
  return;
361
418
  }
362
419
  if (this.keybindings.matches(data, "tui.select.cancel") || data === "\x1b") {
@@ -263,6 +263,22 @@ export function buildSettingsRows(
263
263
  sourceText: src("hourlyRetryProbe"),
264
264
  description: "adds a probe at :00:30 while any main-model recovery is parked; off disables only this extra ticker, not the configured retry ladder"
265
265
  },
266
+ {
267
+ id: "mainModelFailback",
268
+ section: "main-agent",
269
+ label: "Primary failback policy",
270
+ valueText: show("mainModelFailback", "auto"),
271
+ sourceText: src("mainModelFailback"),
272
+ description: "auto: a healthy fallback periodically tests the preferred primary · sticky: stay on the fallback until manually changed",
273
+ },
274
+ {
275
+ id: "mainModelPrimaryProbeMinutes",
276
+ section: "main-agent",
277
+ label: "Primary probe minutes",
278
+ valueText: show("mainModelPrimaryProbeMinutes", "15"),
279
+ sourceText: src("mainModelPrimaryProbeMinutes"),
280
+ description: "minutes between preferred-primary failback probes while the fallback is serving",
281
+ },
266
282
  );
267
283
 
268
284
  // ── Drafter ──
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.35.4",
3
+ "version": "0.35.13",
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",
@@ -33,7 +33,10 @@
33
33
  "schemas",
34
34
  "scripts",
35
35
  "docs",
36
+ "examples",
36
37
  "README.md",
38
+ "INSTALL.md",
39
+ "CHANGELOG.md",
37
40
  "LICENSE"
38
41
  ],
39
42
  "pi": {