pi-goal-list-loop-audit 0.29.16 → 0.29.18

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.
@@ -427,7 +427,7 @@ export function auditMeasureCmd(): string {
427
427
  * identity, on the current branch — no invented identities or branches).
428
428
  */
429
429
  export function auditTarget(): string {
430
- return `Audit the project for real problems and fix them, iteration by iteration. Every iteration: (1) run a FRESH audit pass over the codebase — spawn Explore subagents for breadth — hunting real issues: bugs, broken flows, regressions, drift between docs and code, dead code, security holes. Not style nits, not speculative refactors. (2) Append every NEW finding as one checkbox line "- [ ] SEVERITY: short description (file:line)" to ${AUDIT_FINDINGS_REL} (create the file on the first finding; append-only — never delete, rewrite, or reorder existing lines; never re-report a finding already listed). (3) Fix the highest-severity OPEN finding(s) — real fixes, committed — then check the box: "- [x] … — fixed in <commit>". (4) Honesty law: never fabricate findings to look busy; never mark a finding fixed without the fix commit existing. The orchestrator counts CLOSED findings every iteration (direction=max): discovery alone does not move the metric — landing fixes does. When a full audit pass surfaces nothing new AND no open findings remain, say so plainly — the plateau stop ends the loop when the well is dry.`;
430
+ return `Audit the project for real problems and fix them, iteration by iteration — FIX-FIRST: the open backlog comes down before new hunting (user design 2026-07-30: "audit to fix then audit then fix again" — not find-and-present). Every iteration: (1) FIX the highest-severity OPEN finding(s) in ${AUDIT_FINDINGS_REL} — real fixes, committed — then check the box: "- [x] … — fixed in <commit>". An iteration that closes nothing while OPEN findings remain is a wasted iteration: if the top findings are genuinely blocked, say what blocks them in one line and work the first unblocked one — "no new action this turn" is never an acceptable iteration while open boxes exist. (2) RE-AUDIT on cadence, not every iteration — run a fresh audit pass (spawn Explore subagents for breadth; hunting real issues: bugs, broken flows, regressions, drift between docs and code, dead code, security holes; not style nits, not speculative refactors) ONLY when no OPEN findings remain, when roughly ten iterations have passed since the last pass, or when your own fixes plausibly broke something. (3) Append every NEW finding as one checkbox line "- [ ] SEVERITY: short description (file:line)" to ${AUDIT_FINDINGS_REL} (create the file on the first finding; append-only — never delete, rewrite, or reorder existing lines; never re-report a finding already listed). (4) Honesty law: never fabricate findings to look busy; never mark a finding fixed without the fix commit existing. The orchestrator counts CLOSED findings every iteration (direction=max): discovery alone does not move the metric — landing fixes does. When a full audit pass surfaces nothing new AND no open findings remain, say so plainly — the plateau stop ends the loop when the well is dry.`;
431
431
  }
432
432
 
433
433
  // ---- /goal audit-project (v0.29.8) ----
@@ -152,6 +152,11 @@ import {
152
152
  SettingsMenuComponent,
153
153
  type SettingsRow,
154
154
  } from "../settings-menu.js";
155
+ import {
156
+ buildModelPickItems,
157
+ ModelPickerComponent,
158
+ type ModelPickItem,
159
+ } from "../model-picker.js";
155
160
  import {
156
161
  applyMeasurement,
157
162
  applyMetriclessTick,
@@ -3943,19 +3948,37 @@ function getSessionThinkingLevel(): "off" | "minimal" | "low" | "medium" | "high
3943
3948
  function resolveAuditorModel(ctx: ExtensionContext, ref?: string): { model: any; error?: string; via?: string } {
3944
3949
  if (ref && ref.trim()) {
3945
3950
  const trimmed = ref.trim();
3951
+ // v0.29.17: an unavailable configured model (unknown id, or a provider
3952
+ // with no configured auth) falls back LOUDLY to the session model —
3953
+ // user request: "fall back to the session if unavailable". The v0.9.12
3954
+ // no-SILENT-substitution law stands: the fallback notifies + ledgers.
3955
+ // (Quota-exhausted keys stay on the quota-retry path — the model IS
3956
+ // available there; the key's window is the failure, not the model.)
3957
+ const fail = (reason: string) => {
3958
+ const sessionModel = ctx.model as any;
3959
+ if (sessionModel) {
3960
+ appendLedger(ctx.cwd, "auditor_model_fallback", { configured: trimmed, reason });
3961
+ ctx.ui.notify(`Auditor model "${trimmed}" is unavailable (${reason}) — falling back to the session model. Fix via /glla → Auditor model.`, "warning");
3962
+ return { model: sessionModel, via: "session-fallback" };
3963
+ }
3964
+ return { model: undefined, error: `${reason}: ${trimmed}` };
3965
+ };
3946
3966
  const slash = trimmed.indexOf("/");
3947
3967
  if (slash > 0) {
3948
3968
  const provider = trimmed.slice(0, slash);
3949
3969
  const id = trimmed.slice(slash + 1);
3950
3970
  const model = ctx.modelRegistry.find(provider, id);
3951
- return model ? { model, via: "setting" } : { model: undefined, error: `model not found: ${trimmed}` };
3971
+ if (!model) return fail("model not found");
3972
+ if (!ctx.modelRegistry.hasConfiguredAuth(model)) return fail(`no configured auth for ${provider}`);
3973
+ return { model, via: "setting" };
3952
3974
  }
3953
3975
  const matches = ctx.modelRegistry.getAvailable().filter((m: any) => m.id === trimmed || m.name === trimmed);
3954
- return matches[0] ? { model: matches[0], via: "setting" } : { model: undefined, error: `no available model matching: ${trimmed}` };
3976
+ if (matches[0]) return { model: matches[0], via: "setting" };
3977
+ return fail("no available model matching");
3955
3978
  }
3956
3979
  const sessionModel = ctx.model as any;
3957
3980
  if (sessionModel) return { model: sessionModel, via: "session" };
3958
- return { model: undefined, error: "no session model and no auditorModel configured — set one with /glla model=provider/id" };
3981
+ return { model: undefined, error: "no session model and no auditorModel configured — set one with /glla → Auditor model" };
3959
3982
  }
3960
3983
 
3961
3984
  // (v0.9.12) The auto-fallback apparatus was REMOVED: no tier ranking, no
@@ -4032,6 +4055,43 @@ async function promptSettingsMenu(
4032
4055
  */
4033
4056
  // v0.28.7 (T4): exported for the behavioral settings-editor tests
4034
4057
  // (tests/settings-editors.test.ts drives each editor class end-to-end).
4058
+ /**
4059
+ * v0.29.17: /model-style fuzzy picker for model-valued settings. Builds the
4060
+ * item list from the registry (configured-auth providers only — a pick
4061
+ * from this list can never be a dead provider) and hosts the picker via
4062
+ * ctx.ui.custom. Falls back to the typed input when the runtime has no
4063
+ * custom shard (headless) — typing stays the emergency hatch there.
4064
+ * Returns { kind: "session" } to clear the override, { kind: "ref" } with
4065
+ * provider/id, or undefined for cancel.
4066
+ */
4067
+ async function promptModelRef(
4068
+ ctx: ExtensionContext,
4069
+ title: string,
4070
+ emptyLabel: string,
4071
+ ): Promise<{ kind: "session" } | { kind: "ref"; ref: string } | undefined> {
4072
+ if (typeof (ctx.ui as { custom?: unknown }).custom !== "function" || !ctx.modelRegistry) {
4073
+ const v = await ctx.ui.input(title, "provider/model-id — empty keeps the default");
4074
+ if (v === undefined) return undefined;
4075
+ return v.trim() ? { kind: "ref", ref: v.trim() } : { kind: "session" };
4076
+ }
4077
+ const sessionModel = ctx.model as any;
4078
+ const sessionLabel = sessionModel ? `${sessionModel.provider}/${sessionModel.id}` : "pi session model";
4079
+ const models = ctx.modelRegistry
4080
+ .getAvailable()
4081
+ .filter((m: any) => ctx.modelRegistry.hasConfiguredAuth(m));
4082
+ const items = buildModelPickItems(models, sessionLabel);
4083
+ const pick = await ctx.ui.custom<ModelPickItem | undefined>((tui, theme, keybindings, done) => {
4084
+ return new ModelPickerComponent({ title, items }, () => tui.requestRender(), theme, keybindings, done);
4085
+ });
4086
+ if (!pick) return undefined;
4087
+ if (pick.kind === "session") return { kind: "session" };
4088
+ if (pick.kind === "model" && pick.ref) return { kind: "ref", ref: pick.ref };
4089
+ // manual escape hatch — typed provider/model, validated like before
4090
+ const v = await ctx.ui.input(title, emptyLabel);
4091
+ if (v === undefined) return undefined;
4092
+ return v.trim() ? { kind: "ref", ref: v.trim() } : { kind: "session" };
4093
+ }
4094
+
4035
4095
  export async function handleSettingChoice(id: string, ctx: ExtensionContext): Promise<void> {
4036
4096
  switch (id) {
4037
4097
  case "autoResume": {
@@ -4071,8 +4131,10 @@ export async function handleSettingChoice(id: string, ctx: ExtensionContext): Pr
4071
4131
  return;
4072
4132
  }
4073
4133
  case "auditorModel": {
4074
- const v = await ctx.ui.input("Auditor model override", "provider/model-id — empty keeps the pi session model");
4075
- if (v !== undefined) saveSettings("global", ctx.cwd, { auditorModel: v.trim() || undefined });
4134
+ const pick = await promptModelRef(ctx, "Auditor model override", "provider/model-id — empty keeps the pi session model");
4135
+ if (pick === undefined) return;
4136
+ saveSettings("global", ctx.cwd, { auditorModel: pick.kind === "session" ? undefined : pick.ref });
4137
+ if (pick.kind === "session") ctx.ui.notify("Auditor model override cleared — the auditor follows the pi session model.", "info");
4076
4138
  return;
4077
4139
  }
4078
4140
  case "auditorThinkingLevel": {
@@ -4177,15 +4239,14 @@ export async function handleSettingChoice(id: string, ctx: ExtensionContext): Pr
4177
4239
  case "subagentModelOverrides.Plan":
4178
4240
  case "subagentModelOverrides.general-purpose": {
4179
4241
  const agentType = id.slice("subagentModelOverrides.".length);
4180
- const v = await ctx.ui.input(`Model pin for ${agentType} subagents`, "provider/model-id e.g. minimax/MiniMax-M3 — always wins over strategy; empty = follow strategy");
4181
- if (v !== undefined) {
4182
- const current = loadSettings(ctx.cwd).subagentModelOverrides ?? {};
4183
- const next = { ...current };
4184
- if (v.trim()) next[agentType] = v.trim();
4185
- else delete next[agentType];
4186
- saveSettings("global", ctx.cwd, { subagentModelOverrides: Object.keys(next).length > 0 ? next : undefined });
4187
- ctx.ui.notify(`${agentType} model pin saved — applies to NEW pi sessions.`, "info");
4188
- }
4242
+ const pick = await promptModelRef(ctx, `Model pin for ${agentType} subagents`, "provider/model-id e.g. minimax/MiniMax-M3 — always wins over strategy; empty = follow strategy");
4243
+ if (pick === undefined) return;
4244
+ const current = loadSettings(ctx.cwd).subagentModelOverrides ?? {};
4245
+ const next = { ...current };
4246
+ if (pick.kind === "ref") next[agentType] = pick.ref;
4247
+ else delete next[agentType];
4248
+ saveSettings("global", ctx.cwd, { subagentModelOverrides: Object.keys(next).length > 0 ? next : undefined });
4249
+ ctx.ui.notify(`${agentType} model pin saved — applies to NEW pi sessions.`, "info");
4189
4250
  return;
4190
4251
  }
4191
4252
  case "subagentResolved":
@@ -5410,6 +5471,17 @@ export default function (pi: ExtensionAPI): void {
5410
5471
  persistState(ctx);
5411
5472
  appendLedger(ctx.cwd, "audit_loop_metric_migrated", { from: "open-count/min", to: "closed-count/max" });
5412
5473
  }
5474
+ // v0.29.18: migrate live/held audit loops to the FIX-FIRST target —
5475
+ // the audit-every-iteration template made discovery (8-12/iter)
5476
+ // outpace fixes (1/iter) and allowed "no new action" iterations with
5477
+ // open boxes (field: hegemon iter 26 — the user watched it find and
5478
+ // present instead of fix). Target-only swap: the metric (closed
5479
+ // count/max) is unchanged, so best/stall stay.
5480
+ if (state.loop?.kind === "audit" && state.loop.target?.includes("Every iteration: (1) run a FRESH audit pass")) {
5481
+ state.loop = { ...state.loop, target: auditTarget() };
5482
+ persistState(ctx);
5483
+ appendLedger(ctx.cwd, "audit_loop_target_migrated", { from: "audit-every-iteration", to: "fix-first" });
5484
+ }
5413
5485
  if (isLoopActive()) {
5414
5486
  const l = state.loop!;
5415
5487
  if (autoResume) {
@@ -0,0 +1,208 @@
1
+ // pi-goal-list-loop-audit — v0.29.17
2
+ // extensions/model-picker.ts
3
+ //
4
+ // A /model-style fuzzy picker for model-valued settings (Auditor model,
5
+ // subagent model pins). Why this exists:
6
+ // • ctx.ui.select renders EVERY option unsorted with no search — a full
7
+ // model registry is hundreds of rows; unusable (field: the auditor
8
+ // model was left on a quota-dead openrouter key partly because fixing
9
+ // it meant hand-typing provider/model into a bare input).
10
+ // • pi's own /model dialog (ModelSelectorComponent) needs ModelRuntime
11
+ // and SettingsManager internals that extensions never receive.
12
+ // So we rebuild the same interaction shape — a search line with a
13
+ // fuzzy-filtered list — from pi-tui primitives (fuzzyFilter) over
14
+ // ctx.modelRegistry, hosted via ctx.ui.custom, exactly like the v0.28.0
15
+ // settings table. Unit-testable via synthetic handleInput calls.
16
+ //
17
+ // Item order: "session model" (clear override) first, then every
18
+ // configured-auth model sorted by provider/id, then "type manually…" last.
19
+
20
+ import { fuzzyFilter, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
21
+ import type { SettingsMenuTheme, KeybindingsManagerLike } from "./settings-menu.ts";
22
+
23
+ export type ModelPickKind = "session" | "model" | "manual";
24
+
25
+ export interface ModelPickItem {
26
+ kind: ModelPickKind;
27
+ /** provider/model-id for kind === "model". */
28
+ ref?: string;
29
+ /** Row label shown in the list. */
30
+ label: string;
31
+ /** Text the fuzzy filter matches against. */
32
+ searchText: string;
33
+ }
34
+
35
+ export interface RegistryModelLike {
36
+ provider: string;
37
+ id: string;
38
+ name?: string;
39
+ }
40
+
41
+ /** Build the picker's static item list from registry models (already
42
+ * filtered to configured-auth providers by the caller). Session row first,
43
+ * manual-entry row last; models sorted by provider then id. */
44
+ export function buildModelPickItems(models: RegistryModelLike[], sessionLabel: string): ModelPickItem[] {
45
+ const sorted = [...models].sort((a, b) =>
46
+ a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider),
47
+ );
48
+ return [
49
+ {
50
+ kind: "session",
51
+ label: `session model (${sessionLabel}) — clear the override`,
52
+ searchText: "session model default clear override follow",
53
+ },
54
+ ...sorted.map((m) => {
55
+ const ref = `${m.provider}/${m.id}`;
56
+ return {
57
+ kind: "model" as const,
58
+ ref,
59
+ label: m.name && m.name !== m.id ? `${ref} — ${m.name}` : ref,
60
+ searchText: `${ref} ${m.name ?? ""}`,
61
+ };
62
+ }),
63
+ {
64
+ kind: "manual",
65
+ label: "type provider/model manually…",
66
+ searchText: "manual type custom provider model id",
67
+ },
68
+ ];
69
+ }
70
+
71
+ export interface ModelPickerFactoryDeps {
72
+ title: string;
73
+ items: ModelPickItem[];
74
+ /** Cap on visible list rows (window scrolls with the selection). */
75
+ maxVisibleRows?: number;
76
+ }
77
+
78
+ export class ModelPickerComponent {
79
+ private readonly title: string;
80
+ private readonly items: ModelPickItem[];
81
+ private readonly maxRows: number;
82
+ private readonly requestRender: () => void;
83
+ private readonly theme: SettingsMenuTheme;
84
+ private readonly keybindings: KeybindingsManagerLike;
85
+ private readonly done: (item: ModelPickItem | undefined) => void;
86
+
87
+ private query = "";
88
+ private selectedIdx = 0;
89
+
90
+ constructor(
91
+ deps: ModelPickerFactoryDeps,
92
+ requestRender: () => void,
93
+ theme: SettingsMenuTheme,
94
+ keybindings: KeybindingsManagerLike,
95
+ done: (item: ModelPickItem | undefined) => void,
96
+ ) {
97
+ this.title = deps.title;
98
+ this.items = deps.items;
99
+ this.maxRows = deps.maxVisibleRows ?? 12;
100
+ this.requestRender = requestRender;
101
+ this.theme = theme;
102
+ this.keybindings = keybindings;
103
+ this.done = done;
104
+ }
105
+
106
+ /** Current search query. Exposed for tests. */
107
+ getQuery(): string {
108
+ return this.query;
109
+ }
110
+
111
+ /** Index into the filtered list. Exposed for tests. */
112
+ getSelectedIdx(): number {
113
+ return this.selectedIdx;
114
+ }
115
+
116
+ /** Filtered items for the current query. Exposed for tests. */
117
+ filteredItems(): ModelPickItem[] {
118
+ if (!this.query.trim()) return this.items;
119
+ return fuzzyFilter(this.items, this.query.trim(), (it) => it.searchText);
120
+ }
121
+
122
+ private refresh(): void {
123
+ this.requestRender();
124
+ }
125
+
126
+ private move(delta: number): void {
127
+ const n = this.filteredItems().length;
128
+ if (n === 0) return;
129
+ this.selectedIdx = ((this.selectedIdx + delta) % n + n) % n;
130
+ this.refresh();
131
+ }
132
+
133
+ render(width: number): string[] {
134
+ const w = Math.max(20, width - 2);
135
+ const lines: string[] = [];
136
+ lines.push(this.theme.fg("accent", this.theme.bold(this.title)));
137
+ lines.push("");
138
+ const searchLine = `search: ${this.query}`;
139
+ lines.push(this.theme.fg("muted", truncateToWidth(searchLine, w, "…") + "▏"));
140
+ lines.push("");
141
+ const filtered = this.filteredItems();
142
+ if (filtered.length === 0) {
143
+ lines.push(this.theme.fg("warning", " no matches — keep typing, or Esc to cancel"));
144
+ } else {
145
+ const sel = Math.min(this.selectedIdx, filtered.length - 1);
146
+ const half = Math.floor(this.maxRows / 2);
147
+ const start = Math.max(0, Math.min(sel - half, filtered.length - this.maxRows));
148
+ const window = filtered.slice(start, start + this.maxRows);
149
+ if (start > 0) lines.push(this.theme.fg("dim", ` ↑ ${start} more`));
150
+ for (let i = 0; i < window.length; i++) {
151
+ const idx = start + i;
152
+ const it = window[i]!;
153
+ const row = truncateToWidth(it.label, w - 2, "…");
154
+ lines.push(idx === sel ? this.theme.fg("accent", `→ ${row}`) : ` ${row}`);
155
+ }
156
+ const remaining = filtered.length - (start + window.length);
157
+ if (remaining > 0) lines.push(this.theme.fg("dim", ` ↓ ${remaining} more`));
158
+ }
159
+ lines.push("");
160
+ lines.push(this.theme.fg("dim", "type to filter · ↑/↓ move · enter select · esc cancel"));
161
+ return lines;
162
+ }
163
+
164
+ handleInput(data: string): void {
165
+ if (this.keybindings.matches(data, "tui.select.confirm")) {
166
+ const it = this.filteredItems()[this.selectedIdx];
167
+ this.done(it);
168
+ return;
169
+ }
170
+ if (this.keybindings.matches(data, "tui.select.cancel") || data === "\x1b") {
171
+ this.done(undefined);
172
+ return;
173
+ }
174
+ if (this.keybindings.matches(data, "tui.select.up")) {
175
+ this.move(-1);
176
+ return;
177
+ }
178
+ if (this.keybindings.matches(data, "tui.select.down")) {
179
+ this.move(+1);
180
+ return;
181
+ }
182
+ if (data === "\x7f" || data === "\b") {
183
+ if (this.query.length > 0) {
184
+ this.query = this.query.slice(0, -1);
185
+ this.selectedIdx = 0;
186
+ this.refresh();
187
+ }
188
+ return;
189
+ }
190
+ // Printable input (single keystrokes and pasted runs alike). Ignore
191
+ // escape/CSI sequences — they start with \x1b and were handled above.
192
+ if (!data.startsWith("\x1b")) {
193
+ const printable = [...data].filter((ch) => ch >= " ").join("");
194
+ if (printable.length > 0) {
195
+ this.query += printable;
196
+ this.selectedIdx = 0;
197
+ this.refresh();
198
+ }
199
+ }
200
+ }
201
+
202
+ invalidate(): void {
203
+ // Stateless beyond the query/selection — nothing to clear.
204
+ }
205
+ }
206
+
207
+ // Re-export for callers that only need the width helper's type signature.
208
+ export { visibleWidth };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.29.16",
3
+ "version": "0.29.18",
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",