pi-goal-list-loop-audit 0.27.2 → 0.27.4
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.
|
@@ -180,3 +180,77 @@ export function shouldHeartbeatRefire(input: HeartbeatInput): boolean {
|
|
|
180
180
|
export function accountTurnForNudges(toolCalls: number, currentNudges: number): number {
|
|
181
181
|
return toolCalls > 0 ? 0 : currentNudges + 1;
|
|
182
182
|
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* v0.27.3: the pure nudge detector and richer accounting. A supervising turn
|
|
186
|
+
* is a nudge (no real progress) iff it has NO tool calls AND its text is
|
|
187
|
+
* either short (< DEFAULT_STALL_SHORT_WORDS words) OR highly similar to
|
|
188
|
+
* the prior assistant turn (3-gram Jaccard > DEFAULT_STALL_SIM_THRESHOLD).
|
|
189
|
+
* Substantive analytical replies (≥ 15 words, novel) reset the counter
|
|
190
|
+
* even with no tool calls — the polis-session incident ("3 consecutive
|
|
191
|
+
* turns with no tool calls" tripped the brake on real investigation work,
|
|
192
|
+
* screenshot 2026-07-27) showed the simple tool-only check is too coarse.
|
|
193
|
+
*
|
|
194
|
+
* Word-count rather than char-count: "Working…" (1 word) is a nudge;
|
|
195
|
+
* "state-pump-dom.ts has zero references to hud." (8 words, one sentence)
|
|
196
|
+
* is not. A paragraph with at least one real sentence is > 15 words.
|
|
197
|
+
*
|
|
198
|
+
* Pure: no side effects, no state. Safe to unit-test with crafted inputs.
|
|
199
|
+
*/
|
|
200
|
+
export const DEFAULT_STALL_SHORT_WORDS = 15;
|
|
201
|
+
export const DEFAULT_STALL_SIM_THRESHOLD = 0.6;
|
|
202
|
+
|
|
203
|
+
export function trigramSimilarity(a: string, b: string): number {
|
|
204
|
+
if (!a && !b) return 1;
|
|
205
|
+
if (!a || !b) return 0;
|
|
206
|
+
const grams = (s: string) => {
|
|
207
|
+
const g = new Map<string, number>();
|
|
208
|
+
const t = s.toLowerCase();
|
|
209
|
+
for (let i = 0; i <= t.length - 3; i++) {
|
|
210
|
+
const k = t.slice(i, i + 3);
|
|
211
|
+
g.set(k, (g.get(k) ?? 0) + 1);
|
|
212
|
+
}
|
|
213
|
+
return g;
|
|
214
|
+
};
|
|
215
|
+
const ga = grams(a);
|
|
216
|
+
const gb = grams(b);
|
|
217
|
+
let inter = 0;
|
|
218
|
+
let uni = 0;
|
|
219
|
+
const keys = new Set([...ga.keys(), ...gb.keys()]);
|
|
220
|
+
for (const k of keys) {
|
|
221
|
+
const va = ga.get(k) ?? 0;
|
|
222
|
+
const vb = gb.get(k) ?? 0;
|
|
223
|
+
inter += Math.min(va, vb);
|
|
224
|
+
uni += Math.max(va, vb);
|
|
225
|
+
}
|
|
226
|
+
return uni === 0 ? 0 : inter / uni;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function isNudgeTurn(opts: {
|
|
230
|
+
toolCalls: number;
|
|
231
|
+
text: string;
|
|
232
|
+
priorText: string;
|
|
233
|
+
shortWords?: number;
|
|
234
|
+
simThreshold?: number;
|
|
235
|
+
}): boolean {
|
|
236
|
+
if (opts.toolCalls > 0) return false;
|
|
237
|
+
const shortThr = opts.shortWords ?? DEFAULT_STALL_SHORT_WORDS;
|
|
238
|
+
const simThr = opts.simThreshold ?? DEFAULT_STALL_SIM_THRESHOLD;
|
|
239
|
+
const wordCount = (opts.text.trim().match(/\S+/g) ?? []).length;
|
|
240
|
+
if (wordCount < shortThr) return true;
|
|
241
|
+
if (!opts.priorText) return false; // first turn in a streak — no similarity to compare to
|
|
242
|
+
return trigramSimilarity(opts.text, opts.priorText) > simThr;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function accountTurnForNudgesRich(
|
|
246
|
+
opts: {
|
|
247
|
+
toolCalls: number;
|
|
248
|
+
text: string;
|
|
249
|
+
priorText: string;
|
|
250
|
+
shortWords?: number;
|
|
251
|
+
simThreshold?: number;
|
|
252
|
+
},
|
|
253
|
+
currentNudges: number,
|
|
254
|
+
): number {
|
|
255
|
+
return isNudgeTurn(opts) ? currentNudges + 1 : 0;
|
|
256
|
+
}
|
|
@@ -52,6 +52,13 @@ export interface Settings {
|
|
|
52
52
|
/** v0.26.1: consecutive heartbeat refires without a real turn before
|
|
53
53
|
* the goal pauses / loop stops (default 5; 0 = never escalate). */
|
|
54
54
|
stallEscalationRefires?: number;
|
|
55
|
+
/** v0.27.3: a turn with no tool calls AND fewer words than this is a
|
|
56
|
+
* nudge. Default 15 words. Higher = stricter (more pauses). */
|
|
57
|
+
stallShortWords?: number;
|
|
58
|
+
/** v0.27.3: a turn with no tool calls whose text trigram-similarity to
|
|
59
|
+
* the prior assistant turn exceeds this is a nudge. Default 0.6. Higher
|
|
60
|
+
* = stricter (more pauses). */
|
|
61
|
+
stallSimilarityThreshold?: number;
|
|
55
62
|
/** on → propose_* drafts activate WITHOUT the Confirm dialog and the
|
|
56
63
|
* interview floor is skipped — the seed carries the intent (unattended
|
|
57
64
|
* rigs). Default off: nothing activates before the user confirms. */
|
|
@@ -131,6 +138,8 @@ export const SETTINGS_KEYS: Array<keyof Settings> = [
|
|
|
131
138
|
"quotaRetryMinutes",
|
|
132
139
|
"stuckMaxInterventions",
|
|
133
140
|
"stallEscalationRefires",
|
|
141
|
+
"stallShortWords",
|
|
142
|
+
"stallSimilarityThreshold",
|
|
134
143
|
];
|
|
135
144
|
|
|
136
145
|
/** Where each effective setting comes from (for the /glla display). */
|
package/extensions/loops/goal.ts
CHANGED
|
@@ -156,8 +156,10 @@ import {
|
|
|
156
156
|
type LoopState,
|
|
157
157
|
} from "../goal-loop-forever.js";
|
|
158
158
|
import {
|
|
159
|
-
|
|
159
|
+
accountTurnForNudgesRich,
|
|
160
160
|
BACKOFF_IDLE_RETRY_MS,
|
|
161
|
+
DEFAULT_STALL_SIM_THRESHOLD,
|
|
162
|
+
DEFAULT_STALL_SHORT_WORDS,
|
|
161
163
|
HEARTBEAT_INTERVAL_MS,
|
|
162
164
|
HEARTBEAT_MAX_NUDGES,
|
|
163
165
|
HEARTBEAT_STALL_MS,
|
|
@@ -2900,6 +2902,8 @@ async function openSettingsUI(ctx: ExtensionContext): Promise<void> {
|
|
|
2900
2902
|
`Wedge alert minutes — ${show("wedgeAlertMinutes", `(${WEDGE_ALERT_DEFAULT_MINUTES})`)} — hung-command alert while the session is busy (0 = off)`,
|
|
2901
2903
|
`Stuck max interventions — ${show("stuckMaxInterventions", "(5)")} — consecutive stuck interventions before a loop stops`,
|
|
2902
2904
|
`Stall escalation refires — ${show("stallEscalationRefires", "(5)")} — heartbeat refires with no turn before the goal pauses / loop stops (0 = never)`,
|
|
2905
|
+
`Stall short words — ${show("stallShortWords", `(${DEFAULT_STALL_SHORT_WORDS})`)} — turns with no tools AND fewer words than this count as a nudge`,
|
|
2906
|
+
`Stall similarity threshold — ${show("stallSimilarityThreshold", `(${DEFAULT_STALL_SIM_THRESHOLD})`)} — no-tool turns whose text is > this similar to the prior turn count as a nudge (0–1)`,
|
|
2903
2907
|
"── Subagents ──",
|
|
2904
2908
|
`Subagent model strategy — ${show("subagentModelStrategy", "(inherit-parent)")} — inherit-parent shares your session model+quota; agent-default pins haiku for Explore`,
|
|
2905
2909
|
`Subagent Explore pin — ${settings.subagentModelOverrides?.Explore ?? "(follows strategy)"} — provider/model pin; always wins over strategy`,
|
|
@@ -3239,6 +3243,8 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
|
|
|
3239
3243
|
fmt("stuckMaxInterventions", "stuckMaxInterventions"),
|
|
3240
3244
|
fmt("stallEscalationRefires", "stallEscalation"),
|
|
3241
3245
|
fmt("wedgeAlertMinutes", "wedgeAlert"),
|
|
3246
|
+
fmt("stallShortWords", "stallShortWords"),
|
|
3247
|
+
fmt("stallSimilarityThreshold", "stallSimilarityThreshold"),
|
|
3242
3248
|
// v0.25.6: effective per-type subagent model resolution.
|
|
3243
3249
|
...["Explore", "Plan", "general-purpose"].map(
|
|
3244
3250
|
(t) => `subagent ${t}: ${resolveEffectiveSubagentModel(t, loadSettings(ctx.cwd), (ctx.model as any)?.id ? `${(ctx.model as any).provider}/${(ctx.model as any).id}` : undefined)}`,
|
|
@@ -3391,6 +3397,32 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
|
|
|
3391
3397
|
ctx.ui.notify(`stuckmax must be a positive integer, got: ${value}`, "warning");
|
|
3392
3398
|
}
|
|
3393
3399
|
}
|
|
3400
|
+
} else if (key === "stallshortwords" || key === "stallshort") {
|
|
3401
|
+
if (["unset", "default"].includes(value)) {
|
|
3402
|
+
patch.stallShortWords = undefined;
|
|
3403
|
+
changed = true;
|
|
3404
|
+
} else {
|
|
3405
|
+
const n = Number.parseInt(value, 10);
|
|
3406
|
+
if (Number.isInteger(n) && n >= 1) {
|
|
3407
|
+
patch.stallShortWords = n;
|
|
3408
|
+
changed = true;
|
|
3409
|
+
} else {
|
|
3410
|
+
ctx.ui.notify(`stallshortwords must be a positive integer, got: ${value}`, "warning");
|
|
3411
|
+
}
|
|
3412
|
+
}
|
|
3413
|
+
} else if (key === "stallsim" || key === "stallsimilaritythreshold") {
|
|
3414
|
+
if (["unset", "default"].includes(value)) {
|
|
3415
|
+
patch.stallSimilarityThreshold = undefined;
|
|
3416
|
+
changed = true;
|
|
3417
|
+
} else {
|
|
3418
|
+
const n = Number.parseFloat(value);
|
|
3419
|
+
if (Number.isFinite(n) && n >= 0 && n <= 1) {
|
|
3420
|
+
patch.stallSimilarityThreshold = n;
|
|
3421
|
+
changed = true;
|
|
3422
|
+
} else {
|
|
3423
|
+
ctx.ui.notify(`stallsimilaritythreshold must be between 0 and 1, got: ${value}`, "warning");
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3394
3426
|
} else if (key === "thinking" || key === "auditorthinkinglevel") {
|
|
3395
3427
|
if (["off", "minimal", "low", "medium", "high", "xhigh"].includes(value)) {
|
|
3396
3428
|
patch.auditorThinkingLevel = value as Settings["auditorThinkingLevel"];
|
|
@@ -3497,10 +3529,21 @@ export default function (pi: ExtensionAPI): void {
|
|
|
3497
3529
|
// /loop — the metric loop (draft|start|status|stop)
|
|
3498
3530
|
// /glla — the settings UI (+ scriptable key=value)
|
|
3499
3531
|
// v0.22.5: subcommand autocomplete for the /-menu.
|
|
3532
|
+
// v0.27.4: pi's applyCompletion does NOT add a trailing space for argument
|
|
3533
|
+
// completions (it does for the top-level /goal itself). Without a trailing
|
|
3534
|
+
// space the user has to press Space before typing — and if they forget
|
|
3535
|
+
// they end up with `/goal startasdahlasf` (goal.ts:3545 area). Items whose
|
|
3536
|
+
// value ends in `=` (key=value pairs — the user types the value right
|
|
3537
|
+
// after the `=`) get no space; everything else gets a single trailing
|
|
3538
|
+
// space. `label` stays clean for the picker display.
|
|
3500
3539
|
const completions = (items: Array<[string, string]>) => (prefix: string) =>
|
|
3501
3540
|
items
|
|
3502
3541
|
.filter(([value]) => value.startsWith(prefix))
|
|
3503
|
-
.map(([value, description]) => ({
|
|
3542
|
+
.map(([value, description]) => ({
|
|
3543
|
+
value: value.endsWith("=") ? value : value + " ",
|
|
3544
|
+
label: value,
|
|
3545
|
+
description,
|
|
3546
|
+
}));
|
|
3504
3547
|
|
|
3505
3548
|
pi.registerCommand("goal", {
|
|
3506
3549
|
description: "Set/draft a goal, or /goal status|pause|resume|cancel|tweak <text>|archive|start <objective>. Objectives without a 'Done when:' clause are grilled into a contract first; include the clause or use /goal start to skip the interview and activate instantly.",
|
|
@@ -3811,7 +3854,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
3811
3854
|
// a response — re-trigger immediately with split-smaller guidance and
|
|
3812
3855
|
// skip ALL turn bookkeeping; the NEXT agent_end processes the run.
|
|
3813
3856
|
// Works with no goal active (plain sessions truncate too).
|
|
3814
|
-
|
|
3857
|
+
// v0.27.3: enrich lastA with text + priorText for the smarter nudge
|
|
3858
|
+
// accounting below.
|
|
3859
|
+
const assistants = (event.messages as any[]).filter((m: any) => m.role === "assistant");
|
|
3860
|
+
const rawLastA = assistants.length ? assistants[assistants.length - 1] : null;
|
|
3861
|
+
const rawPriorA = assistants.length >= 2 ? assistants[assistants.length - 2] : null;
|
|
3862
|
+
const extractText = (m: any): string => (m && Array.isArray(m.content)) ? m.content.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n") : "";
|
|
3863
|
+
const lastA = rawLastA ? { stopReason: rawLastA.stopReason, text: extractText(rawLastA), priorText: extractText(rawPriorA) } : null;
|
|
3815
3864
|
const lc = tickLengthContinue(lastA?.stopReason === "length");
|
|
3816
3865
|
if (lc.giveUpNow) {
|
|
3817
3866
|
ctx.ui.notify(`glla: response hit the output-token cap ${LENGTH_CONTINUE_MAX}× in a row — stepping aside. Ask the model to split the work into smaller pieces.`, "warning");
|
|
@@ -3832,27 +3881,34 @@ export default function (pi: ExtensionAPI): void {
|
|
|
3832
3881
|
registeredCtx = ctx;
|
|
3833
3882
|
}
|
|
3834
3883
|
ensureAgentToolsActive(pi, ctx);
|
|
3835
|
-
//
|
|
3836
|
-
//
|
|
3884
|
+
// v0.27.3: nudge accounting — substantive analytical turns (long, novel
|
|
3885
|
+
// text) reset the counter even with no tool calls. Polis-session
|
|
3886
|
+
// incident showed the tool-only check fired on real investigation work.
|
|
3837
3887
|
if (isSupervising()) {
|
|
3838
|
-
|
|
3888
|
+
const s = loadSettings(ctx.cwd);
|
|
3889
|
+
const shortWordsThr = s.stallShortWords ?? DEFAULT_STALL_SHORT_WORDS;
|
|
3890
|
+
const simThr = s.stallSimilarityThreshold ?? DEFAULT_STALL_SIM_THRESHOLD;
|
|
3891
|
+
heartbeatNudges = accountTurnForNudgesRich(
|
|
3892
|
+
{ toolCalls: toolCallsThisTurn, text: lastA?.text ?? "", priorText: lastA?.priorText ?? "", shortWords: shortWordsThr, simThreshold: simThr },
|
|
3893
|
+
heartbeatNudges,
|
|
3894
|
+
);
|
|
3839
3895
|
if (heartbeatNudges >= HEARTBEAT_MAX_NUDGES) {
|
|
3840
3896
|
heartbeatNudges = 0;
|
|
3841
3897
|
if (isLoopActive()) {
|
|
3842
3898
|
clearLoopTimer();
|
|
3843
|
-
state.loop = { ...state.loop!, active: false, stopReason: `stalled: ${HEARTBEAT_MAX_NUDGES} consecutive turns
|
|
3899
|
+
state.loop = { ...state.loop!, active: false, stopReason: `stalled: ${HEARTBEAT_MAX_NUDGES} consecutive unproductive turns (no tools, short or repetitive)` };
|
|
3844
3900
|
persistState(ctx);
|
|
3845
|
-
ctx.ui.notify(`Loop stopped: stalled (${HEARTBEAT_MAX_NUDGES} turns
|
|
3901
|
+
ctx.ui.notify(`Loop stopped: stalled (${HEARTBEAT_MAX_NUDGES} unproductive turns). /loop start to begin a new one.`, "warning");
|
|
3846
3902
|
notifyExternal(ctx, "Loop stopped: stalled (no tool calls).");
|
|
3847
3903
|
return;
|
|
3848
3904
|
}
|
|
3849
3905
|
if (state.goal) {
|
|
3850
3906
|
updateGoal({
|
|
3851
3907
|
status: "paused",
|
|
3852
|
-
pauseReason: `stalled: ${HEARTBEAT_MAX_NUDGES} consecutive turns
|
|
3908
|
+
pauseReason: `stalled: ${HEARTBEAT_MAX_NUDGES} consecutive unproductive turns (no tools, short or repetitive)`,
|
|
3853
3909
|
pauseSuggestedAction: "Inspect the goal — /goal resume to retry, /goal tweak to narrow it, /goal cancel to abort.",
|
|
3854
3910
|
}, ctx);
|
|
3855
|
-
ctx.ui.notify(`Goal paused: stalled (${HEARTBEAT_MAX_NUDGES} turns
|
|
3911
|
+
ctx.ui.notify(`Goal paused: stalled (${HEARTBEAT_MAX_NUDGES} unproductive turns).`, "warning");
|
|
3856
3912
|
notifyExternal(ctx, "Goal paused: stalled (no tool calls).");
|
|
3857
3913
|
return;
|
|
3858
3914
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-goal-list-loop-audit",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.4",
|
|
4
4
|
"description": "Goal. Loop. Audit. Done. — 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 — only the read tools needed to verify your goal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "dracon",
|