pi-goal-list-loop-audit 0.23.7 → 0.24.0

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
@@ -44,7 +44,7 @@ Four top-level commands, that's all:
44
44
  /list fix the login bug, add dark mode, write docs # dump it — the agent shapes it into items, one Confirm
45
45
  /list plan.md # file detected → bulk import, one Confirm (sisyphus/Ralph style)
46
46
  /list <paste a checklist> # multi-line paste → same batch flow
47
- /list "fix the flaky test. Done when: npm test green" # explicit contract → queues directly, no interview
47
+ /list "fix the flaky test. Done when: npm test green" # explicit contract → added directly, no interview
48
48
  /list # show the list (add/import are optional no-op aliases — detection routes everything)
49
49
 
50
50
  (Or just say it: "queue these 10 things…" — the agent manages the list too.)
@@ -53,10 +53,10 @@ Four top-level commands, that's all:
53
53
  `/list next <n>` or the agent's `list_activate` tool picks any item — with
54
54
  subagents, what gets worked next is a choice, not a position. Numbering always
55
55
  matches `/list show`.
56
- /list # show active + queue
56
+ /list # show active + waiting items
57
57
  /list next # skip current, activate next
58
- /list remove <n> # drop item n from the queue
59
- /list clear # empty the queue
58
+ /list remove <n> # drop item n from the list
59
+ /list clear # empty the list
60
60
  /loop # draft the loop (agent grills; measure is test-run before you confirm)
61
61
  /loop start "keep polishing the UI" # infinite metricless loop (v0.23.6): no plateau, no cap — ends at time=/tokens= or /loop stop
62
62
  /loop start "reduce TODOs" measure="grep -c TODO src.txt | head -1" direction=min
@@ -77,6 +77,17 @@ inspectable change; cosmetic churn is the known failure mode
77
77
  number, and tells you the trade-off before you confirm. Work with a finish
78
78
  line is still a `/goal`.
79
79
 
80
+ **Anti-repetition** (v0.24.0, both loop flavors): the plateau stop watches
81
+ the *number*; the stuck ladder watches the *work*. Every iteration is
82
+ classified — exact/near-duplicate replies, A-B-A-B alternation, same
83
+ tool-same-result three times, narration-only streaks, degenerate
84
+ single-reply repetition — and a stuck iteration swaps the next prompt for
85
+ a rotating intervention (different approach → different subtask →
86
+ PROGRESS.md → fix one test failure → review your own diff). Three stuck in
87
+ a row escalates to a hard reset (banned openings, tool-call-first); five
88
+ stops the loop with the reason — bounded and surfaced, like plateau.
89
+ Continuation lines also rotate: identical prompts invite identical answers.
90
+
80
91
  Subcommands match **exactly** — `/goal pause the pipeline` sets an objective
81
92
  about a pipeline; only bare `/goal pause` pauses. (Same rule everywhere, so
82
93
  your objectives can start with any verb.)
@@ -91,7 +102,7 @@ sisyphus-style plan file (checklists, bullets, numbered, plain lines) imports
91
102
  as-is — headings become nothing, items become goals. And the drafter itself
92
103
  batches: asking for "these 50 tasks" in a `/list` drafting session produces
93
104
  ONE confirmed batch, not 50 dialogs.
94
- Note: every queue item is audited individually, so at hundreds of items the
105
+ Note: every list item is audited individually, so at hundreds of items the
95
106
  audit cost per item is the thing to think about.
96
107
 
97
108
  **Drafting is the default for long-running things.** `/goal` and
@@ -144,7 +155,7 @@ redirect you to `/goal`.
144
155
  | Loop | Command | Status |
145
156
  |---|---|---|
146
157
  | 1. Single ordered goal | `/goal "<objective>"` | **shipped v0.1.0** |
147
- | 2. Queue of goals | `/list [show\|next\|remove\|clear]` | **shipped v0.2.0** |
158
+ | 2. List of goals (a pool, not a FIFO) | `/list [show\|next\|remove\|clear]` | **shipped v0.2.0** |
148
159
  | 3. Metric-driven process loop | `/loop start\|status\|stop` | **shipped v0.3.0** |
149
160
 
150
161
  Each loop is a different policy class on the same status machine.
@@ -186,6 +197,8 @@ No external watchdog plugin needed.
186
197
  /glla tokenlimit=10000000 # per-goal token budget (default: off) → GLOBAL
187
198
  /glla tokenlimit=0 # explicitly no cap (the default)
188
199
  /glla wedgealert=30 # hung-command alert minutes (default: 30, 0 = off)
200
+ /glla autoresume=on # held goals/loops auto-resume in fresh sessions (unattended rigs)
201
+ /glla autoaccept=on # drafts ACTIVATE without the Confirm dialog (unattended rigs)
189
202
  /glla project tokenlimit=500 # rare per-project override
190
203
  ```
191
204
 
@@ -195,6 +208,29 @@ auditor can't auth it — you're told once (info level) with the fix:
195
208
  `/glla model=provider/id`, set once, rarely touched again. The plugin never
196
209
  picks a model itself. Thinking follows the session too (floor `high`).
197
210
 
211
+ `autoaccept=on` skips BOTH the Confirm dialog and the drafting interview
212
+ floor — every `propose_*` draft (goal, list batch, loop, task list)
213
+ activates the moment the agent proposes it, with a notification and a
214
+ `draft_autoaccepted` ledger entry (auto-accept is never silent). The seed
215
+ carries the intent. Pair with `autoresume=on` for fully unattended rigs.
216
+
217
+ ## Subagents (`@tintinweb/pi-subagents`)
218
+
219
+ Subagent sessions bind extensions too, so glla loads there — by design the
220
+ **main session owns the goal/loop/list; subagents are workers** (v0.23.8):
221
+
222
+ - Read-only agents (Explore, Plan) get no glla tools (pi-subagents gates
223
+ them); general-purpose agents see them but state-mutating calls
224
+ (`complete_goal`, `propose_*`, `list_add`, `pause_goal`, …) are refused
225
+ with "report back to the main agent".
226
+ - A subagent session never clobbers the loop's session handle, never runs
227
+ the restore gate, and never drives continuation — so the heartbeat,
228
+ wedge alert, and auto-resume machinery always act on the main session.
229
+ (pi hands a fresh ctx wrapper per event; `ctx.sessionManager` identity
230
+ is the discriminator.)
231
+ - Subagent tool activity counts as activity for the wedge clock — a long
232
+ subagent run is work, not a hang.
233
+
198
234
  ## Token guard
199
235
 
200
236
  Every goal tracks real token usage; crossing the budget pauses the goal.
@@ -670,3 +670,21 @@ export function extractVerificationContract(raw: string): { objective: string; v
670
670
  }
671
671
  return { objective, verificationContract };
672
672
  }
673
+
674
+ /**
675
+ * v0.23.8: subagent-session ownership. pi-subagents binds extensions in
676
+ * subagent sessions too, so glla's session_start/handlers fire there with
677
+ * the same module state. The MAIN session owns the goal/loop; subagent
678
+ * sessions are workers — they must never clobber the loop's ctx handle
679
+ * (a headless subagent ctx would silently kill the heartbeat/wedge
680
+ * machinery), never receive continuation injection, and never mutate goal
681
+ * state. pi hands a FRESH ctx wrapper per event (verified in
682
+ * dist/core/extensions/runner.js — createContext() per emit), so object
683
+ * identity is useless; ctx.sessionManager is the stable per-session
684
+ * discriminator (each subagent gets its own SessionManager).
685
+ */
686
+ export type OwnerClaim = "claim" | "refresh" | "foreign";
687
+ export function classifySessionCtx(ownerSession: unknown, ownerLive: boolean, sessionManager: unknown): OwnerClaim {
688
+ if (!ownerSession || !ownerLive) return "claim";
689
+ return sessionManager === ownerSession ? "refresh" : "foreign";
690
+ }
@@ -58,6 +58,20 @@ export interface LoopState {
58
58
  branchName?: string;
59
59
  /** branch=1 mode: the branch to return to on stop. */
60
60
  originalBranch?: string;
61
+ /** v0.24.0 anti-repetition: rolling fingerprints of iteration replies. */
62
+ recentPrints?: string[];
63
+ /** v0.24.0: last few iteration texts (near-duplicate check + banned openings). */
64
+ recentTexts?: string[];
65
+ /** v0.24.0: rolling tool-result fingerprints {tool, hash, isError}. */
66
+ recentToolResults?: { tool: string; hash: string; isError: boolean }[];
67
+ /** v0.24.0: tool calls seen since the last completed iteration. */
68
+ toolsThisTurn?: number;
69
+ /** v0.24.0: consecutive iterations with zero tool calls. */
70
+ toollessStreak?: number;
71
+ /** v0.24.0: consecutive stuck interventions (resets on a clean iteration). */
72
+ consecutiveStuck?: number;
73
+ /** v0.24.0: the last stuck reason (for the intervention directive + ledger). */
74
+ lastStuckReason?: string;
61
75
  }
62
76
 
63
77
  /** Scratch-branch name for branch=1 mode. Format pinned by tests. */
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Loop anti-repetition (v0.24.0) — pure, stateless detectors for the failure
3
+ * mode the plateau stop cannot see: the loop keeps "working" but the work is
4
+ * the SAME work. Plateau watches the number; this watches the behavior.
5
+ *
6
+ * Clean-room implementation of standard techniques (rolling text
7
+ * fingerprints, word-trigram Jaccard similarity, repeated-n-gram detection)
8
+ * for pi-loop-mode's AGPL-licensed repertoire — no code shared.
9
+ *
10
+ * Everything here is a pure function; LoopState holds the rolling windows and
11
+ * runLoopTick (loops/goal.ts) feeds them in. One mechanical predicate per
12
+ * behavior — no fuzzy heuristics.
13
+ */
14
+
15
+ import { createHash } from "node:crypto";
16
+
17
+ export const REPETITION = {
18
+ /** Jaccard similarity at which two consecutive iterations count as a near-duplicate. */
19
+ similarityThreshold: 0.8,
20
+ /** Minimum normalized length before exact/near-duplicate checks apply (short replies repeat innocently). */
21
+ minExactLength: 80,
22
+ minSimilarLength: 60,
23
+ /** Same fingerprint seen this often inside the rolling window = alternating repetition (A-B-A-B). */
24
+ windowRepeat: 3,
25
+ /** Rolling window sizes held on LoopState. */
26
+ printWindow: 12,
27
+ textWindow: 3,
28
+ toolWindow: 6,
29
+ /** Last N identical tool results (same tool, same output) = no new information. */
30
+ toolResultRepeat: 3,
31
+ /** Consecutive iterations with zero tool calls = narration only. */
32
+ toollessIterations: 2,
33
+ /** Degenerate single-response repetition (one sentence/word/phrase looping inside ONE reply). */
34
+ degenerateMinLength: 150,
35
+ degenerateSentenceRepeats: 4,
36
+ degenerateWordRepeats: 16,
37
+ degeneratePhraseRepeats: 8,
38
+ degenerateMaxPhraseWords: 4,
39
+ /** Escalation ladder: hard-reset directive after this many consecutive stuck turns… */
40
+ hardResetAfter: 3,
41
+ /** …and the loop STOPS after this many (bounded, surfaced — same philosophy as plateau). */
42
+ maxInterventions: 5,
43
+ } as const;
44
+
45
+ /** Strip ANSI, collapse whitespace, lowercase — the canonical form all checks use. */
46
+ export function normalizeForPrint(text: string): string {
47
+ return text.replace(/\x1b\[[0-9;]*m/g, "").replace(/\s+/g, " ").trim().toLowerCase();
48
+ }
49
+
50
+ /** Stable short fingerprint of one reply's canonical form. */
51
+ export function textFingerprint(text: string): string {
52
+ return createHash("sha256").update(normalizeForPrint(text).slice(0, 4000)).digest("hex").slice(0, 16);
53
+ }
54
+
55
+ /** Digits are volatile (counters, timestamps, PIDs) — blank them so "try port 8081" ≈ "try port 8082". */
56
+ function canonical(text: string): string {
57
+ return normalizeForPrint(text).replace(/\d+/g, "#");
58
+ }
59
+
60
+ function wordTrigrams(text: string): Set<string> {
61
+ const words = canonical(text).split(" ").filter(Boolean);
62
+ const out = new Set<string>();
63
+ if (words.length < 3) {
64
+ if (words.length) out.add(words.join(" "));
65
+ return out;
66
+ }
67
+ for (let i = 0; i + 3 <= words.length; i++) out.add(`${words[i]} ${words[i + 1]} ${words[i + 2]}`);
68
+ return out;
69
+ }
70
+
71
+ /** Jaccard similarity over word trigrams: 0 = nothing shared, 1 = same shingle set. */
72
+ export function trigramSimilarity(a: string, b: string): number {
73
+ const sa = wordTrigrams(a);
74
+ const sb = wordTrigrams(b);
75
+ if (sa.size === 0 || sb.size === 0) return 0;
76
+ let shared = 0;
77
+ for (const t of sa) if (sb.has(t)) shared++;
78
+ return shared / (sa.size + sb.size - shared);
79
+ }
80
+
81
+ export interface DegenerateRepeat {
82
+ kind: "sentence" | "word" | "phrase";
83
+ unit: string;
84
+ count: number;
85
+ }
86
+
87
+ function tokenRun(text: string): DegenerateRepeat | undefined {
88
+ const tokens = normalizeForPrint(text).match(/[\p{L}\p{N}_'-]+/gu) ?? [];
89
+ for (let width = 1; width <= REPETITION.degenerateMaxPhraseWords; width++) {
90
+ const needed = width === 1 ? REPETITION.degenerateWordRepeats : REPETITION.degeneratePhraseRepeats;
91
+ for (let start = 0; start + width * needed <= tokens.length; start++) {
92
+ let run = 1;
93
+ while (
94
+ start + (run + 1) * width <= tokens.length &&
95
+ tokens.slice(start, start + width).join("") === tokens.slice(start + run * width, start + (run + 1) * width).join("")
96
+ ) {
97
+ run++;
98
+ }
99
+ if (run >= needed) {
100
+ return { kind: width === 1 ? "word" : "phrase", unit: tokens.slice(start, start + width).join(" "), count: run };
101
+ }
102
+ }
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ /** One sentence/word/phrase looping inside a SINGLE response (degenerate generation). */
108
+ export function findDegenerateRepeat(text: string): DegenerateRepeat | undefined {
109
+ const canon = canonical(text);
110
+ if (canon.length < REPETITION.degenerateMinLength) return undefined;
111
+ const sentences = canon
112
+ .split(/(?<=[.!?])\s+|\n+/)
113
+ .map((s) => s.trim())
114
+ .filter((s) => s.length >= 15);
115
+ if (sentences.length >= REPETITION.degenerateSentenceRepeats) {
116
+ const counts = new Map<string, number>();
117
+ for (const s of sentences) counts.set(s, (counts.get(s) ?? 0) + 1);
118
+ let unit = "";
119
+ let best = 0;
120
+ for (const [s, n] of counts) if (n > best) { unit = s; best = n; }
121
+ if (best >= REPETITION.degenerateSentenceRepeats && best / sentences.length >= 0.5) {
122
+ return { kind: "sentence", unit, count: best };
123
+ }
124
+ }
125
+ return tokenRun(text);
126
+ }
127
+
128
+ export interface ToolResultPrint {
129
+ tool: string;
130
+ hash: string;
131
+ isError: boolean;
132
+ }
133
+
134
+ export interface LoopStuckInput {
135
+ /** Last assistant text of the finished iteration. */
136
+ assistantText: string;
137
+ /** Rolling fingerprints INCLUDING the current iteration's (appended last). */
138
+ recentPrints: string[];
139
+ /** Previous iteration's assistant text (for the near-duplicate check). */
140
+ previousText?: string;
141
+ /** Rolling tool-result prints (most recent last). */
142
+ recentToolResults: ToolResultPrint[];
143
+ /** Consecutive iterations with zero tool calls, including this one. */
144
+ toollessStreak: number;
145
+ }
146
+
147
+ function clip(text: string, n: number): string {
148
+ const flat = text.replace(/\s+/g, " ").trim();
149
+ return flat.length <= n ? flat : `${flat.slice(0, n)}…`;
150
+ }
151
+
152
+ /**
153
+ * The one classifier: given the rolling windows, WHY is the loop stuck —
154
+ * or undefined when it's working normally. Checks run cheapest-and-most-
155
+ * certain first; the first hit wins so the reason stays specific.
156
+ */
157
+ export function detectLoopStuck(input: LoopStuckInput): string | undefined {
158
+ const { assistantText, recentPrints, previousText, recentToolResults, toollessStreak } = input;
159
+
160
+ // Narration only: iterations that never touch tools produce nothing inspectable.
161
+ if (toollessStreak >= REPETITION.toollessIterations) {
162
+ return `no tool calls for ${toollessStreak} iterations (narration only)`;
163
+ }
164
+
165
+ // Degenerate generation inside the current response.
166
+ const degenerate = findDegenerateRepeat(assistantText);
167
+ if (degenerate) {
168
+ return `response degenerated: same ${degenerate.kind} repeated ${degenerate.count}× ("${clip(degenerate.unit, 60)}")`;
169
+ }
170
+
171
+ // Exact repeat of the immediately previous iteration.
172
+ const lastTwo = recentPrints.slice(-2);
173
+ if (lastTwo.length === 2 && lastTwo[0] === lastTwo[1] && normalizeForPrint(assistantText).length > REPETITION.minExactLength) {
174
+ return "repeated the previous response exactly";
175
+ }
176
+
177
+ // Near-duplicate of the previous iteration (slight rephrasing defeats fingerprints).
178
+ if (previousText && normalizeForPrint(assistantText).length > REPETITION.minSimilarLength) {
179
+ const sim = trigramSimilarity(assistantText, previousText);
180
+ if (sim >= REPETITION.similarityThreshold) {
181
+ return `response ~${Math.round(sim * 100)}% similar to the previous iteration`;
182
+ }
183
+ }
184
+
185
+ // Alternating repetition (A-B-A-B): the current fingerprint keeps recurring in the window.
186
+ const current = recentPrints[recentPrints.length - 1];
187
+ if (current && recentPrints.filter((p) => p === current).length >= REPETITION.windowRepeat) {
188
+ return `same response ${REPETITION.windowRepeat}+ times in recent iterations`;
189
+ }
190
+
191
+ // Same tool, same output, three times running: the loop is re-reading a result it already has.
192
+ const recentTools = recentToolResults.slice(-REPETITION.toolResultRepeat);
193
+ if (
194
+ recentTools.length === REPETITION.toolResultRepeat &&
195
+ recentTools.every((r) => r.tool === recentTools[0]!.tool && r.hash === recentTools[0]!.hash)
196
+ ) {
197
+ return recentTools.every((r) => r.isError)
198
+ ? `same ${recentTools[0]!.tool} error ${REPETITION.toolResultRepeat}× in a row`
199
+ : `same ${recentTools[0]!.tool} result ${REPETITION.toolResultRepeat}× in a row (no new information)`;
200
+ }
201
+
202
+ return undefined;
203
+ }
204
+
205
+ /**
206
+ * Rotating stuck interventions — a repeated identical nudge gets filtered as
207
+ * noise, so each consecutive stuck turn gets a DIFFERENT instruction.
208
+ * consecutiveStuck is 1-based (1 = first intervention).
209
+ */
210
+ export function loopInterventionDirective(consecutiveStuck: number, reason: string, recentTexts: string[]): string {
211
+ const strategies = [
212
+ "Abandon the current angle entirely. Pick a genuinely different approach — different file, different technique — and execute it now.",
213
+ "Switch to a part of the target you have NOT touched in recent iterations and make one concrete, inspectable change there.",
214
+ "Write a short PROGRESS.md: current state, what was tried, what keeps failing, the next 3 concrete steps. Then execute step 1.",
215
+ "Run the project's build/tests, pick exactly ONE failure or warning, and fix only that.",
216
+ "Review your recent changes (git diff / git log), find one real problem in them, and fix it.",
217
+ ];
218
+ const strategy = strategies[(consecutiveStuck - 1) % strategies.length]!;
219
+ let escalation = "";
220
+ if (consecutiveStuck >= REPETITION.hardResetAfter) {
221
+ const banned = recentTexts
222
+ .map((t) => clip(normalizeForPrint(t), 40))
223
+ .filter(Boolean)
224
+ .map((t) => `"${t}"`)
225
+ .join(", ");
226
+ escalation =
227
+ ` HARD RESET (stuck intervention #${consecutiveStuck} in a row): forget your previous phrasing entirely.` +
228
+ (banned ? ` Banned openings: ${banned}.` : "") +
229
+ " Your FIRST action this turn must be a tool call that changes a file or produces new information — zero preamble text before it.";
230
+ }
231
+ return `⚠ STUCK — ${reason}.${escalation} ${strategy}`;
232
+ }
233
+
234
+ /**
235
+ * Varied continuation lines for metricless iterations: identical prompts
236
+ * invite identical answers, so the base instruction rotates by iteration.
237
+ */
238
+ export function continueVariant(iteration: number): string {
239
+ const variants = [
240
+ "Advance the target with the next concrete, inspectable change.",
241
+ "Continue: pick the next unit of work from your plan and do it now.",
242
+ "Keep going — one real change that moves the target forward.",
243
+ "Proceed with the next focused step toward the target.",
244
+ "Make the next improvement; something you can point at in a diff.",
245
+ ];
246
+ return variants[iteration % variants.length]!;
247
+ }
248
+
249
+ /** Cap helper for the rolling windows on LoopState. */
250
+ export function pushCapped<T>(arr: T[], item: T, cap: number): T[] {
251
+ const next = [...arr, item];
252
+ return next.length > cap ? next.slice(next.length - cap) : next;
253
+ }
@@ -56,6 +56,7 @@ import {
56
56
  normalizeDraftContract,
57
57
  draftContractItemCount,
58
58
  extractVerificationContract,
59
+ classifySessionCtx,
59
60
  readState,
60
61
  renderGoalMarkdown,
61
62
  shouldAutoResumeOnSessionStart,
@@ -63,6 +64,14 @@ import {
63
64
  writeGoalMd,
64
65
  } from "../goal-loop-core.js";
65
66
  import { runGoalCompletionAuditor } from "../goal-loop-auditor.js";
67
+ import {
68
+ REPETITION,
69
+ detectLoopStuck,
70
+ loopInterventionDirective,
71
+ continueVariant,
72
+ textFingerprint,
73
+ pushCapped as pushRepetitionCapped,
74
+ } from "../goal-loop-repetition.js";
66
75
  import { buildStatusText, buildWidgetLines, type AuditDisplayProgress } from "../goal-loop-display.js";
67
76
  import {
68
77
  applyMeasurement,
@@ -105,11 +114,37 @@ let extensionApi: ExtensionAPI | null = null;
105
114
  // pi replaces sessions (newSession/fork/reload) and stale ctx throws on use,
106
115
  // so timers must never capture a ctx — they read lastCtx at fire time.
107
116
  let lastCtx: ExtensionContext | null = null;
117
+ // v0.23.8: the session that OWNS the loop (its sessionManager). Subagent
118
+ // sessions (pi-subagents binds extensions there too) fire our handlers
119
+ // with their own ctx — they must never take over lastCtx (a headless
120
+ // subagent ctx would silently kill the heartbeat/wedge machinery).
121
+ let ownerSession: unknown = null;
108
122
 
109
123
  function rememberCtx(ctx: ExtensionContext): void {
124
+ let ownerLive = false;
125
+ if (ownerSession && lastCtx) {
126
+ try { lastCtx.isIdle(); ownerLive = true; } catch { /* owner went stale (session replaced) */ }
127
+ }
128
+ const claim = classifySessionCtx(ownerSession, ownerLive, ctx.sessionManager);
129
+ if (claim === "foreign") return;
130
+ ownerSession = ctx.sessionManager;
110
131
  lastCtx = ctx;
111
132
  }
112
133
 
134
+ /** True when ctx belongs to a subagent/foreign session, not the loop owner. */
135
+ function isForeignCtx(ctx: ExtensionContext): boolean {
136
+ return ownerSession !== null && ctx.sessionManager !== ownerSession;
137
+ }
138
+
139
+ const FOREIGN_SESSION_TOOL_MESSAGE =
140
+ "This tool changes goal/loop/list state, which only the MAIN session owns — you are running in a subagent session. Report back to the main agent; it owns the goal and can call this tool.";
141
+
142
+ /** Refusal message when a state-mutating tool is called from a subagent session, else null. */
143
+ function foreignToolGuard(execCtx: unknown): string | null {
144
+ const c = execCtx as ExtensionContext | undefined;
145
+ return c && isForeignCtx(c) ? FOREIGN_SESSION_TOOL_MESSAGE : null;
146
+ }
147
+
113
148
  let state: State = { goal: null };
114
149
 
115
150
  // Drafting mode: a no-arg loop command starts a clarification turn; the agent
@@ -361,7 +396,7 @@ function persistState(ctx: ExtensionContext): void {
361
396
  }
362
397
 
363
398
  function setGoal(goal: Goal, ctx: ExtensionContext): void {
364
- state = { goal, list: state.list ?? [] }; // preserve the queue!
399
+ state = { goal, list: state.list ?? [] }; // preserve the list!
365
400
  const file = writeGoalMd(ctx.cwd, goal);
366
401
  state.goal!.activePath = path.relative(ctx.cwd, file) || file;
367
402
  persistState(ctx);
@@ -453,7 +488,7 @@ async function startDrafting(ctx: ExtensionContext, target: "goal" | "list" | "l
453
488
  if (target === "list") {
454
489
  tmpl = tmpl.replace(
455
490
  "[GOAL DRAFTING]",
456
- "[GOAL DRAFTING — the confirmed goal goes into the /list LIST, it does not activate immediately. If the user wants MANY things queued (a plan, a checklist, 'these 50 tasks'), propose them ALL AT ONCE with the items[] parameter — one Confirm for the whole batch, never 50 separate proposals.]",
491
+ "[GOAL DRAFTING — the confirmed goal goes into the /list LIST, it does not activate immediately. If the user wants MANY things added at once (a plan, a checklist, 'these 50 tasks'), propose them ALL AT ONCE with the items[] parameter — one Confirm for the whole batch, never 50 separate proposals.]",
457
492
  );
458
493
  }
459
494
  } catch {
@@ -567,7 +602,7 @@ async function cmdPause(ctx: ExtensionContext): Promise<void> {
567
602
  // v0.22.7: name WHAT was paused — a list item resumes through /list.
568
603
  if (state.goal.policy === "list") {
569
604
  const queued = listQueue().length;
570
- ctx.ui.notify(`List item ${state.goal.id} paused${queued > 0 ? ` (${queued} queued in the list)` : ""}. /list resume to continue.`, "info");
605
+ ctx.ui.notify(`List item ${state.goal.id} paused${queued > 0 ? ` (${queued} waiting in the list)` : ""}. /list resume to continue.`, "info");
571
606
  return;
572
607
  }
573
608
  ctx.ui.notify(`Goal ${state.goal.id} paused. /goal resume to continue.`, "info");
@@ -590,8 +625,8 @@ async function cmdResume(ctx: ExtensionContext): Promise<void> {
590
625
  const isListItem = state.goal.policy === "list";
591
626
  ctx.ui.notify(
592
627
  isListItem
593
- ? `Resumed list item [${state.goal.id}]: ${state.goal.objective.replace(/\s+/g, " ").slice(0, 70)}${queued > 0 ? ` (+${queued} queued in the list)` : ""}`
594
- : `Resumed goal [${state.goal.id}]: ${state.goal.objective.replace(/\s+/g, " ").slice(0, 70)}${queued > 0 ? ` (+${queued} queued in the list — resuming the list's head)` : ""}`,
628
+ ? `Resumed list item [${state.goal.id}]: ${state.goal.objective.replace(/\s+/g, " ").slice(0, 70)}${queued > 0 ? ` (+${queued} waiting in the list)` : ""}`
629
+ : `Resumed goal [${state.goal.id}]: ${state.goal.objective.replace(/\s+/g, " ").slice(0, 70)}${queued > 0 ? ` (+${queued} waiting in the list — resuming the list's head)` : ""}`,
595
630
  "info",
596
631
  );
597
632
  scheduleContinuation(ctx, true);
@@ -654,8 +689,8 @@ async function cmdTweak(args: string, ctx: ExtensionContext): Promise<void> {
654
689
  try {
655
690
  confirmed = await ctx.ui.confirm(
656
691
  "Tweak goal?",
657
- `CURRENT:\n${current.objective.slice(0, 400)}\n\nNEW:\n${newObjective.slice(0, 400)}` +
658
- (newContract ? `\n\nNew contract:\n${newContract.slice(0, 200)}` : "\n\n(New text carries no contract; old contract is dropped.)"),
692
+ `CURRENT:\n${current.objective}\n\nNEW:\n${newObjective}` +
693
+ (newContract ? `\n\nNew contract:\n${newContract}` : "\n\n(New text carries no contract; old contract is dropped.)"),
659
694
  );
660
695
  } catch {
661
696
  confirmed = false;
@@ -700,13 +735,15 @@ async function bulkAddItems(ctx: ExtensionContext, parsed: string[], sourceName:
700
735
  ctx.ui.notify("No items found (headings/blank lines don't count).", "warning");
701
736
  return;
702
737
  }
703
- const preview = parsed.slice(0, 5).map((t, i) => ` ${i + 1}. ${t.slice(0, 70)}`).join("\n");
738
+ // v0.23.7: show ALL items in full a Confirm the user can't fully
739
+ // read is not a gate (same rule as the draft dialog, v0.23.5).
740
+ const preview = parsed.map((t, i) => ` ${i + 1}. ${t}`).join("\n");
704
741
  let confirmed = true;
705
742
  if (ctx.hasUI) {
706
743
  try {
707
744
  confirmed = await ctx.ui.confirm(
708
- "Import into queue?",
709
- `${parsed.length} items from ${sourceName}:\n${preview}${parsed.length > 5 ? `\n … and ${parsed.length - 5} more` : ""}`,
745
+ "Import into list?",
746
+ `${parsed.length} items from ${sourceName}:\n${preview}`,
710
747
  );
711
748
  } catch {
712
749
  confirmed = false;
@@ -718,7 +755,7 @@ async function bulkAddItems(ctx: ExtensionContext, parsed: string[], sourceName:
718
755
  }
719
756
  const n = enqueueItems(ctx, parsed, sourceName);
720
757
  if (state.goal && state.goal.status === "active") {
721
- ctx.ui.notify(`Imported ${n} items (${listQueue().length} queued).`, "info");
758
+ ctx.ui.notify(`Imported ${n} items (${listQueue().length} waiting in the list).`, "info");
722
759
  }
723
760
  }
724
761
 
@@ -744,7 +781,7 @@ async function cmdList(args: string, ctx: ExtensionContext): Promise<void> {
744
781
  // is the same motion as /goal resume — named for the surface the user is
745
782
  // looking at (v0.22.7: "we would just unpause, and that is next").
746
783
  if (!state.goal || state.goal.status !== "paused") {
747
- ctx.ui.notify("No paused list item to resume. /list show to see the queue.", "info");
784
+ ctx.ui.notify("No paused list item to resume. /list show to see the list.", "info");
748
785
  return;
749
786
  }
750
787
  if (state.goal.policy !== "list") {
@@ -885,7 +922,7 @@ function addSingleItem(ctx: ExtensionContext, raw: string): void {
885
922
  if (!state.goal || state.goal.status === "complete" || state.goal.status === "aborted") {
886
923
  activateNextListItem(ctx);
887
924
  } else {
888
- ctx.ui.notify(`Queued (${listQueue().length} waiting): ${objective.slice(0, 80)}`, "info");
925
+ ctx.ui.notify(`Added to the list (${listQueue().length} waiting): ${objective.slice(0, 80)}`, "info");
889
926
  }
890
927
  }
891
928
 
@@ -953,7 +990,7 @@ async function runGit(ctx: ExtensionContext, args: string[]): Promise<{ ok: bool
953
990
  }
954
991
  }
955
992
 
956
- function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: string, boundsNote: string): string {
993
+ function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: string, boundsNote: string, interventionNote = "", variantNote = ""): string {
957
994
  // v0.23.0: metricless loops get their own prompt — no metric section,
958
995
  // anti-doorknob rules instead of anti-gaming rules.
959
996
  const metricless = !loop.measureCmd;
@@ -963,8 +1000,8 @@ function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: strin
963
1000
  tmpl = fs.readFileSync(tmplPath, "utf-8");
964
1001
  } catch {
965
1002
  tmpl = metricless
966
- ? `[LOOP ITERATION ${loop.iteration + 1}] Target: ${loop.target}. Metricless spec loop — make ONE real, inspectable change advancing the target. No cosmetic churn.`
967
- : `[LOOP ITERATION ${loop.iteration + 1}] Target: ${loop.target}. Measure: ${loop.measureCmd} (${loop.direction}). Make ONE small change to improve the metric.`;
1003
+ ? `[LOOP ITERATION ${loop.iteration + 1}] Target: ${loop.target}. Metricless spec loop — make ONE real, inspectable change advancing the target. No cosmetic churn. ${variantNote} ${interventionNote}`
1004
+ : `[LOOP ITERATION ${loop.iteration + 1}] Target: ${loop.target}. Measure: ${loop.measureCmd} (${loop.direction}). Make ONE small change to improve the metric. ${interventionNote}`;
968
1005
  }
969
1006
  return tmpl
970
1007
  .replace(/\$\{ITERATION\}/g, String(loop.iteration + 1))
@@ -978,7 +1015,9 @@ function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: strin
978
1015
  .replace(/\$\{PLATEAU_WINDOW\}/g, String(loop.plateauWindow))
979
1016
  .replace(/\$\{REGRESSION_NOTE\}/g, regressionNote)
980
1017
  .replace(/\$\{STRATEGY_NOTE\}/g, strategyNote)
981
- .replace(/\$\{BOUNDS_NOTE\}/g, boundsNote);
1018
+ .replace(/\$\{BOUNDS_NOTE\}/g, boundsNote)
1019
+ .replace(/\$\{INTERVENTION_NOTE\}/g, interventionNote)
1020
+ .replace(/\$\{VARIANT_NOTE\}/g, variantNote);
982
1021
  }
983
1022
 
984
1023
  function scheduleLoopTick(ctx: ExtensionContext): void {
@@ -1031,10 +1070,18 @@ function sendLoopTurn(): void {
1031
1070
  } else if (bounds.length) {
1032
1071
  boundsNote = `\n- Arbitrary bounds: the loop also stops after ${bounds.join(" or ")}`;
1033
1072
  }
1073
+ // v0.24.0: a stuck intervention REPLACES the pep talk — the rotating
1074
+ // directive names why the loop is stuck and what rung of the ladder it's on.
1075
+ const interventionNote = (loop.consecutiveStuck ?? 0) > 0 && loop.lastStuckReason
1076
+ ? loopInterventionDirective(loop.consecutiveStuck!, loop.lastStuckReason, loop.recentTexts ?? [])
1077
+ : "";
1078
+ // v0.24.0: identical prompts invite identical answers — rotate the base
1079
+ // instruction (metricless loops; metric loops already vary via values).
1080
+ const variantNote = metricless ? continueVariant(loop.iteration) : "";
1034
1081
  try {
1035
1082
  extensionApi.sendMessage({
1036
1083
  customType: GOAL_EVENT_ENTRY,
1037
- content: loopPrompt(loop, regressionNote, strategyNote, boundsNote),
1084
+ content: loopPrompt(loop, regressionNote, strategyNote, boundsNote, interventionNote, variantNote),
1038
1085
  display: false,
1039
1086
  }, { triggerTurn: true, deliverAs: "followUp" });
1040
1087
  } catch {
@@ -1054,10 +1101,40 @@ async function runLoopTick(ctx: ExtensionContext, event?: any): Promise<void> {
1054
1101
  // Hypothesis line (pi-autoresearch's good idea): the agent's stated intent
1055
1102
  // for the turn goes into the ledger, making loop history auditable.
1056
1103
  let hypothesis: string | undefined;
1104
+ let lastAssistantText = "";
1057
1105
  if (event) {
1058
1106
  const last = [...(event.messages as any[])].reverse().find((m) => m.role === "assistant");
1059
- const text = last && Array.isArray(last.content) ? last.content.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n") : "";
1060
- hypothesis = text.match(/^HYPOTHESIS:\s*(.+)$/m)?.[1]?.trim().slice(0, 200);
1107
+ lastAssistantText = last && Array.isArray(last.content) ? last.content.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n") : "";
1108
+ hypothesis = lastAssistantText.match(/^HYPOTHESIS:\s*(.+)$/m)?.[1]?.trim().slice(0, 200);
1109
+ }
1110
+ // v0.24.0 anti-repetition: roll the behavior windows, then classify. The
1111
+ // plateau stop watches the NUMBER; this watches the WORK — a metricless
1112
+ // loop (no number) has no other defense against doorknob-polishing.
1113
+ const toolsUsed = loop.toolsThisTurn ?? 0;
1114
+ loop.toolsThisTurn = 0;
1115
+ loop.toollessStreak = toolsUsed === 0 ? (loop.toollessStreak ?? 0) + 1 : 0;
1116
+ const previousText = loop.recentTexts && loop.recentTexts.length > 0 ? loop.recentTexts[loop.recentTexts.length - 1] : undefined;
1117
+ if (lastAssistantText) {
1118
+ loop.recentPrints = pushRepetitionCapped(loop.recentPrints ?? [], textFingerprint(lastAssistantText), REPETITION.printWindow);
1119
+ loop.recentTexts = pushRepetitionCapped(loop.recentTexts ?? [], lastAssistantText, REPETITION.textWindow);
1120
+ }
1121
+ const stuckReason = detectLoopStuck({
1122
+ assistantText: lastAssistantText,
1123
+ recentPrints: loop.recentPrints ?? [],
1124
+ previousText,
1125
+ recentToolResults: loop.recentToolResults ?? [],
1126
+ toollessStreak: loop.toollessStreak ?? 0,
1127
+ });
1128
+ if (stuckReason) {
1129
+ loop.consecutiveStuck = (loop.consecutiveStuck ?? 0) + 1;
1130
+ loop.lastStuckReason = stuckReason;
1131
+ appendLedger(ctx.cwd, "loop_stuck", { iteration: loop.iteration, reason: stuckReason, consecutive: loop.consecutiveStuck });
1132
+ if (loop.consecutiveStuck === 1 || loop.consecutiveStuck >= REPETITION.hardResetAfter) {
1133
+ ctx.ui.notify(`Loop stuck (${loop.consecutiveStuck}×): ${stuckReason}`, "warning");
1134
+ }
1135
+ } else {
1136
+ loop.consecutiveStuck = 0;
1137
+ loop.lastStuckReason = undefined;
1061
1138
  }
1062
1139
  const outcome = metricless ? applyMetriclessTick(loop, nowIso()) : applyMeasurement(loop, value, nowIso());
1063
1140
  persistState(ctx);
@@ -1067,6 +1144,7 @@ async function runLoopTick(ctx: ExtensionContext, event?: any): Promise<void> {
1067
1144
  best: loop.bestValue,
1068
1145
  stall: loop.stallCount,
1069
1146
  hypothesis,
1147
+ stuck: stuckReason,
1070
1148
  });
1071
1149
  // branch=1 mode: commit improvements, hard-reset regressions — always and
1072
1150
  // only on the scratch branch. v0.23.0: a metricless loop has no regression
@@ -1082,6 +1160,18 @@ async function runLoopTick(ctx: ExtensionContext, event?: any): Promise<void> {
1082
1160
  }
1083
1161
  persistState(ctx);
1084
1162
  }
1163
+ // v0.24.0: the top of the stuck ladder — bounded and surfaced, same
1164
+ // philosophy as a plateau stop. The loop ends WITH the reason, not in silence.
1165
+ if (outcome.kind !== "stop" && (loop.consecutiveStuck ?? 0) >= REPETITION.maxInterventions) {
1166
+ loop.active = false;
1167
+ loop.stopReason = `stuck — ${loop.lastStuckReason} (${loop.consecutiveStuck} consecutive interventions)`;
1168
+ persistState(ctx);
1169
+ await finishLoopGit(ctx, loop);
1170
+ ctx.ui.notify(`Loop stopped: ${loop.stopReason}. ${loop.history.length} iterations recorded.`, "warning");
1171
+ appendLedger(ctx.cwd, "loop_stopped", { reason: loop.stopReason, iterations: loop.iteration, best: loop.bestValue });
1172
+ notifyExternal(ctx, `Loop stopped: ${loop.stopReason}`);
1173
+ return;
1174
+ }
1085
1175
  if (outcome.kind === "stop") {
1086
1176
  await finishLoopGit(ctx, loop);
1087
1177
  ctx.ui.notify(`Loop stopped: ${outcome.reason}. ${loop.history.length} iterations recorded.`, "info");
@@ -1316,7 +1406,9 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1316
1406
  completionSummary: Type.Optional(Type.String({ description: "1-paragraph completion claim" })),
1317
1407
  verificationSummary: Type.Optional(Type.String({ description: "Per-item evidence for the verification contract" })),
1318
1408
  }),
1319
- async execute(_id, params, signal) {
1409
+ async execute(_id, params, signal, _onUpdate, execCtx) {
1410
+ const foreign0 = foreignToolGuard(execCtx);
1411
+ if (foreign0) return { content: [{ type: "text", text: foreign0 }], details: {} };
1320
1412
  if (!state.goal || state.goal.status !== "active") {
1321
1413
  return { content: [{ type: "text", text: "No active goal." }], details: {} };
1322
1414
  }
@@ -1475,7 +1567,9 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1475
1567
  reason: Type.String({ description: "Why the work is paused" }),
1476
1568
  suggestedAction: Type.Optional(Type.String({ description: "What the user should do next" })),
1477
1569
  }),
1478
- async execute(_id, params) {
1570
+ async execute(_id, params, _signal, _onUpdate, execCtx) {
1571
+ const foreign1 = foreignToolGuard(execCtx);
1572
+ if (foreign1) return { content: [{ type: "text", text: foreign1 }], details: {} };
1479
1573
  const p = params as { reason: string; suggestedAction?: string };
1480
1574
  if (!state.goal) return { content: [{ type: "text", text: "No active goal." }], details: {} };
1481
1575
  updateGoal({
@@ -1554,6 +1648,8 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1554
1648
  items: Type.Optional(Type.Array(Type.String(), { description: "LIST drafting only: many objectives at once (e.g. 'queue these 50 things'). Each becomes a list item; per-item 'Done when:' clauses are honored." })),
1555
1649
  }),
1556
1650
  async execute(_id, params, _signal, _onUpdate, execCtx) {
1651
+ const foreign2 = foreignToolGuard(execCtx);
1652
+ if (foreign2) return { content: [{ type: "text", text: foreign2 }], details: {} };
1557
1653
  const p = params as { objective: string; verificationContract?: string; items?: string[] };
1558
1654
  if (draftingTarget !== "goal" && draftingTarget !== "list") {
1559
1655
  return {
@@ -1562,11 +1658,15 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1562
1658
  };
1563
1659
  }
1564
1660
  // v0.14.0: the interview floor — no Confirm until the user replied.
1565
- // v0.14.0: the interview floor no Confirm until the user replied.
1566
- if (draftingUserReplies === 0) draftingBlockedProposals++;
1567
- const block = draftProposalBlock(draftingUserReplies, draftingBlockedProposals);
1568
- if (block) {
1569
- return { content: [{ type: "text", text: block }], details: {} };
1661
+ // v0.23.8: /glla autoaccept=on skips the floor AND the Confirm
1662
+ // the seed carries the intent (unattended rigs). Default off.
1663
+ const autoAccept = loadSettings(ctx.cwd).autoAcceptDrafts === true;
1664
+ if (!autoAccept) {
1665
+ if (draftingUserReplies === 0) draftingBlockedProposals++;
1666
+ const block = draftProposalBlock(draftingUserReplies, draftingBlockedProposals);
1667
+ if (block) {
1668
+ return { content: [{ type: "text", text: block }], details: {} };
1669
+ }
1570
1670
  }
1571
1671
  // Multi-item drafts are LIST-only: a goal is single by definition.
1572
1672
  if (p.items && p.items.length > 0 && draftingTarget !== "list") {
@@ -1583,13 +1683,19 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1583
1683
  const preview = p.items.map((t, i) => ` ${i + 1}. ${t}`).join("\n");
1584
1684
  const batchActivates = !state.goal || state.goal.status === "complete" || state.goal.status === "aborted";
1585
1685
  let batchConfirmed = false;
1586
- try {
1587
- batchConfirmed = await liveCtx.ui.confirm(
1588
- "Confirm queue batch",
1589
- `${p.items.length} items:\n${preview}${batchActivates ? "\n\n(List is empty — confirming ACTIVATES item 1 immediately as the active goal.)" : ""}`,
1590
- );
1591
- } catch {
1592
- batchConfirmed = false;
1686
+ if (autoAccept) {
1687
+ batchConfirmed = true;
1688
+ liveCtx.ui.notify(`List batch auto-accepted (/glla autoaccept=on): ${p.items.length} items${batchActivates ? " item 1 ACTIVATES now" : ""}.`, "info");
1689
+ appendLedger(liveCtx.cwd, "draft_autoaccepted", { kind: "batch", count: p.items.length });
1690
+ } else {
1691
+ try {
1692
+ batchConfirmed = await liveCtx.ui.confirm(
1693
+ "Confirm list batch",
1694
+ `${p.items.length} items:\n${preview}${batchActivates ? "\n\n(List is empty — confirming ACTIVATES item 1 immediately as the active goal.)" : ""}`,
1695
+ );
1696
+ } catch {
1697
+ batchConfirmed = false;
1698
+ }
1593
1699
  }
1594
1700
  if (!batchConfirmed) {
1595
1701
  return {
@@ -1603,7 +1709,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1603
1709
  if (wasIdle) {
1604
1710
  return { content: [{ type: "text", text: `${n} items confirmed; first activated (list was empty). Begin work now.` }], details: {} };
1605
1711
  }
1606
- return { content: [{ type: "text", text: `${n} items confirmed and queued (${listQueue().length} waiting).` }], details: {} };
1712
+ return { content: [{ type: "text", text: `${n} items confirmed and added to the list (${listQueue().length} waiting).` }], details: {} };
1607
1713
  }
1608
1714
  const normContract = p.verificationContract?.trim() ? normalizeDraftContract(p.verificationContract) : "";
1609
1715
  const checkCount = normContract ? draftContractItemCount(normContract) : 0;
@@ -1617,14 +1723,20 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1617
1723
  const willActivate = isListDraft && (!state.goal || state.goal.status === "complete" || state.goal.status === "aborted");
1618
1724
  const activationNote = isListDraft
1619
1725
  ? willActivate
1620
- ? "\n\n(List is empty — confirming ACTIVATES this immediately as the active goal. Reject if you only wanted to queue it.)"
1621
- : "\n\n(Goes into the list, queued behind the active goal.)"
1726
+ ? "\n\n(List is empty — confirming ACTIVATES this immediately as the active goal. Reject if you only wanted to add it, not start it.)"
1727
+ : "\n\n(Goes into the list, waiting behind the active goal.)"
1622
1728
  : "";
1623
1729
  let confirmed = false;
1624
- try {
1625
- confirmed = await liveCtx.ui.confirm(isListDraft ? "Confirm list item" : "Confirm goal", `${p.objective.trim()}${contractBlock}${activationNote}`);
1626
- } catch {
1627
- confirmed = false;
1730
+ if (autoAccept) {
1731
+ confirmed = true;
1732
+ liveCtx.ui.notify(`Draft auto-accepted (/glla autoaccept=on)${willActivate ? " — ACTIVATING now" : ""}: ${p.objective.trim().slice(0, 90)}`, "info");
1733
+ appendLedger(liveCtx.cwd, "draft_autoaccepted", { kind: isListDraft ? "list" : "goal", objective: p.objective.trim().slice(0, 200) });
1734
+ } else {
1735
+ try {
1736
+ confirmed = await liveCtx.ui.confirm(isListDraft ? "Confirm list item" : "Confirm goal", `${p.objective.trim()}${contractBlock}${activationNote}`);
1737
+ } catch {
1738
+ confirmed = false;
1739
+ }
1628
1740
  }
1629
1741
  if (!confirmed) {
1630
1742
  return {
@@ -1646,7 +1758,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1646
1758
  activateNextListItem(liveCtx);
1647
1759
  return { content: [{ type: "text", text: "Confirmed and activated (list was empty). Begin work now." }], details: {} };
1648
1760
  }
1649
- return { content: [{ type: "text", text: `Confirmed and queued (${listQueue().length} waiting). It activates when the current goal completes.` }], details: {} };
1761
+ return { content: [{ type: "text", text: `Confirmed and added to the list (${listQueue().length} waiting). It activates when the current goal completes.` }], details: {} };
1650
1762
  }
1651
1763
  const goal = createGoal(full, liveCtx);
1652
1764
  setGoal(goal, liveCtx);
@@ -1675,6 +1787,8 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1675
1787
  branch: Type.Optional(Type.Boolean({ description: "branch=true: scratch-branch mode (clean git tree required)" })),
1676
1788
  }),
1677
1789
  async execute(_id, params, _signal, _onUpdate, execCtx) {
1790
+ const foreign3 = foreignToolGuard(execCtx);
1791
+ if (foreign3) return { content: [{ type: "text", text: foreign3 }], details: {} };
1678
1792
  const p = params as { target: string; measureCmd?: string; direction?: "min" | "max"; window?: number; max?: number; time?: number; tokens?: number; branch?: boolean };
1679
1793
  if (draftingTarget !== "loop") {
1680
1794
  return {
@@ -1722,17 +1836,27 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1722
1836
  }
1723
1837
  const window = p.window && p.window > 0 ? Math.floor(p.window) : 5;
1724
1838
  // v0.23.0: explicit max=0 = truly unbounded (no iteration cap).
1725
- const max = p.max !== undefined && Number.isFinite(p.max) && p.max >= 0 ? Math.floor(p.max) : 50;
1839
+ // v0.23.8: metricless + no explicit max = UNBOUNDED here too the
1840
+ // drafter path was still defaulting to 50 after v0.23.6 flipped the
1841
+ // CLI default.
1842
+ const max = p.max !== undefined && Number.isFinite(p.max) && p.max >= 0 ? Math.floor(p.max) : metricless ? 0 : 50;
1843
+ const autoAccept = loadSettings(ctx.cwd).autoAcceptDrafts === true;
1726
1844
  let confirmed = false;
1727
- try {
1728
- confirmed = await liveCtx.ui.confirm(
1845
+ if (autoAccept) {
1846
+ confirmed = true;
1847
+ liveCtx.ui.notify(`Loop draft auto-accepted (/glla autoaccept=on): ${p.target.trim().slice(0, 90)}`, "info");
1848
+ appendLedger(liveCtx.cwd, "draft_autoaccepted", { kind: "loop", target: p.target.trim().slice(0, 200), metricless });
1849
+ } else {
1850
+ try {
1851
+ confirmed = await liveCtx.ui.confirm(
1729
1852
  "Confirm loop",
1730
1853
  metricless
1731
1854
  ? `Target: ${p.target.trim()}\n\nMeasure: NONE — metricless spec loop. There is NO plateau stop: the loop ends only at ${max > 0 ? `${max} iterations` : "NO iteration cap"}${typeof p.time === "number" && p.time > 0 ? ` · Time bound: ${p.time}h` : ""}${typeof p.tokens === "number" && p.tokens > 0 ? ` · Token bound: ${p.tokens.toLocaleString()}` : ""} · /loop stop.${p.branch ? "\nbranch mode: scratch branch, every iteration committed (clean tree required)" : ""}\n\nEvery iteration must make ONE real, inspectable change — cosmetic churn is the known failure mode (doorknob-polishing). Start it?`
1732
1855
  : `Target: ${p.target.trim()}\n\nMeasure: ${p.measureCmd}\nTest-run output: ${rawOutput.slice(0, 200)}\nParsed number: ${parsed} (${p.direction === "min" ? "lower is better" : "higher is better"})\n\nPlateau stop: ${window} non-improving iterations · Cap: ${max > 0 ? `${max} iterations` : "none (unbounded)"}${typeof p.time === "number" && p.time > 0 ? ` · Time bound: ${p.time}h` : ""}${typeof p.tokens === "number" && p.tokens > 0 ? ` · Token bound: ${p.tokens.toLocaleString()}` : ""}${p.branch ? "\nbranch mode: scratch branch (clean tree required)" : ""}\n\nThe loop never completes — it runs until one of these bounds, plateau, or /loop stop. Start it?`,
1733
- );
1734
- } catch {
1735
- confirmed = false;
1856
+ );
1857
+ } catch {
1858
+ confirmed = false;
1859
+ }
1736
1860
  }
1737
1861
  if (!confirmed) {
1738
1862
  return {
@@ -1771,6 +1895,8 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1771
1895
  rationale: Type.String({ description: "Why the current spec no longer captures 'better' — shown to the user in the Confirm dialog" }),
1772
1896
  }),
1773
1897
  async execute(_id, params, _signal, _onUpdate, execCtx) {
1898
+ const foreign4 = foreignToolGuard(execCtx);
1899
+ if (foreign4) return { content: [{ type: "text", text: foreign4 }], details: {} };
1774
1900
  const p = params as { target?: string; measureCmd?: string; rationale: string };
1775
1901
  const liveCtx = (execCtx as ExtensionContext | undefined) ?? ctx;
1776
1902
  const loop = state.loop;
@@ -1835,12 +1961,14 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1835
1961
 
1836
1962
  pi.registerTool(defineTool({
1837
1963
  name: "list_add",
1838
- label: "Add to queue",
1839
- description: "Add one or many objectives to the /list list (loop 2). Use when the user asks to queue work — 'add these to my list', 'queue these 10 things', 'put this on the backlog'. Each item becomes an audited goal; per-item 'Done when:' clauses are honored. The first queued item activates automatically when nothing is running. The list is UNBOUNDED — hundreds of small items are fine; propose them all.",
1964
+ label: "Add to list",
1965
+ description: "Add one or many objectives to the /list list (loop 2). Use when the user asks to add work — 'add these to my list', 'queue these 10 things', 'put this on the backlog'. The list is a POOL, not a FIFO: order is the default, not the law — any item can be activated next. Each item becomes an audited goal; per-item 'Done when:' clauses are honored. The first item activates automatically when nothing is running. The list is UNBOUNDED — hundreds of small items are fine; propose them all.",
1840
1966
  parameters: Type.Object({
1841
- items: Type.Array(Type.String(), { description: "Objectives to enqueue — no count limit; large plans belong in ONE call." }),
1967
+ items: Type.Array(Type.String(), { description: "Objectives to add — no count limit; large plans belong in ONE call." }),
1842
1968
  }),
1843
1969
  async execute(_id, params, _signal, _onUpdate, execCtx) {
1970
+ const foreign5 = foreignToolGuard(execCtx);
1971
+ if (foreign5) return { content: [{ type: "text", text: foreign5 }], details: {} };
1844
1972
  const p = params as { items: string[] };
1845
1973
  if (listMutationBlocked(draftingTarget)) {
1846
1974
  return { content: [{ type: "text", text: LIST_DRAFTING_BLOCK_MESSAGE }], details: {} };
@@ -1856,7 +1984,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1856
1984
  content: [{
1857
1985
  type: "text",
1858
1986
  text: wasIdle
1859
- ? `${n} item(s) queued; the first is now active. Work it normally and call complete_goal when done — the next item activates automatically.`
1987
+ ? `${n} item(s) added; the first is now active. Work it normally and call complete_goal when done — the next item activates automatically.`
1860
1988
  : `${n} item(s) queued (${listQueue().length} waiting behind the active goal).`,
1861
1989
  }],
1862
1990
  details: {},
@@ -1872,6 +2000,8 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1872
2000
  n: Type.Number({ description: "1-based position in the queue (1 = head)" }),
1873
2001
  }),
1874
2002
  async execute(_id, params, _signal, _onUpdate, execCtx) {
2003
+ const foreign6 = foreignToolGuard(execCtx);
2004
+ if (foreign6) return { content: [{ type: "text", text: foreign6 }], details: {} };
1875
2005
  const p = params as { n: number };
1876
2006
  if (listMutationBlocked(draftingTarget)) {
1877
2007
  return { content: [{ type: "text", text: LIST_DRAFTING_BLOCK_MESSAGE }], details: {} };
@@ -1945,11 +2075,18 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1945
2075
  const subs = (t.subtasks ?? []).map((s, j) => ` ${i + 1}.${j + 1} ${s}`).join("\n");
1946
2076
  return `${i + 1}. ${t.title}` + (subs ? `\n${subs}` : "");
1947
2077
  }).join("\n");
2078
+ const autoAcceptTasks = loadSettings(ctx.cwd).autoAcceptDrafts === true;
1948
2079
  let confirmed = false;
1949
- try {
1950
- confirmed = await liveCtx.ui.confirm("Confirm task list", preview);
1951
- } catch {
1952
- confirmed = false;
2080
+ if (autoAcceptTasks) {
2081
+ confirmed = true;
2082
+ liveCtx.ui.notify(`Task list auto-accepted (/glla autoaccept=on): ${p.tasks.length} tasks.`, "info");
2083
+ appendLedger(liveCtx.cwd, "draft_autoaccepted", { kind: "tasks", count: p.tasks.length });
2084
+ } else {
2085
+ try {
2086
+ confirmed = await liveCtx.ui.confirm("Confirm task list", preview);
2087
+ } catch {
2088
+ confirmed = false;
2089
+ }
1953
2090
  }
1954
2091
  if (!confirmed) {
1955
2092
  return { content: [{ type: "text", text: "Task list rejected by the user. Adjust and propose again." }], details: {} };
@@ -1984,6 +2121,10 @@ interface Settings {
1984
2121
  /** on → restored goals/loops/lists auto-resume even in fresh sessions
1985
2122
  * (unattended rigs). Default off: restore holds until /goal resume. */
1986
2123
  autoResume?: boolean;
2124
+ /** on → propose_* drafts activate WITHOUT the Confirm dialog and the
2125
+ * interview floor is skipped — the seed carries the intent (unattended
2126
+ * rigs). Default off: nothing activates before the user confirms. */
2127
+ autoAcceptDrafts?: boolean;
1987
2128
  }
1988
2129
 
1989
2130
  const DEFAULT_SETTINGS: Settings = {
@@ -2028,7 +2169,7 @@ function settingsProvenance(cwd: string): Record<keyof Settings, { value: unknow
2028
2169
  const glob = readSettingsFile(globalSettingsPath());
2029
2170
  const effective = loadSettings(cwd);
2030
2171
  const out: Record<string, { value: unknown; source: "project" | "global" | "default" }> = {};
2031
- const keys: Array<keyof Settings> = ["auditorModel", "auditorThinkingLevel", "notifyCmd", "tokenLimit", "wedgeAlertMinutes", "autoResume"];
2172
+ const keys: Array<keyof Settings> = ["auditorModel", "auditorThinkingLevel", "notifyCmd", "tokenLimit", "wedgeAlertMinutes", "autoResume", "autoAcceptDrafts"];
2032
2173
  for (const k of keys) {
2033
2174
  if ((proj as Record<string, unknown>)[k] !== undefined) out[k] = { value: (proj as any)[k], source: "project" };
2034
2175
  else if ((glob as Record<string, unknown>)[k] !== undefined) out[k] = { value: (glob as any)[k], source: "global" };
@@ -2198,6 +2339,7 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
2198
2339
  fmt("notifyCmd", "notify"),
2199
2340
  fmt("tokenLimit", "tokenLimit"),
2200
2341
  fmt("autoResume", "autoResume"),
2342
+ fmt("autoAcceptDrafts", "autoAccept"),
2201
2343
  `\nglobal: ${globalSettingsPath()}`,
2202
2344
  `project: ${projectSettingsPath(ctx.cwd)}`,
2203
2345
  `Set with: /glla key=value (global) · /glla project key=value (project override)`,
@@ -2259,6 +2401,17 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
2259
2401
  } else {
2260
2402
  ctx.ui.notify(`autoresume must be on or off, got: ${value}`, "warning");
2261
2403
  }
2404
+ } else if (key === "autoaccept") {
2405
+ if (["on", "true", "1", "yes"].includes(value)) {
2406
+ patch.autoAcceptDrafts = true;
2407
+ changed = true;
2408
+ ctx.ui.notify("autoaccept=on: drafts will ACTIVATE without the Confirm dialog (the interview floor is skipped too — the seed is the intent). /glla autoaccept=off restores the gate.", "warning");
2409
+ } else if (["off", "false", "0", "no", "unset"].includes(value)) {
2410
+ patch.autoAcceptDrafts = undefined;
2411
+ changed = true;
2412
+ } else {
2413
+ ctx.ui.notify(`autoaccept must be on or off, got: ${value}`, "warning");
2414
+ }
2262
2415
  } else if (key === "thinking" || key === "auditorthinkinglevel") {
2263
2416
  if (["off", "minimal", "low", "medium", "high", "xhigh"].includes(value)) {
2264
2417
  patch.auditorThinkingLevel = value as Settings["auditorThinkingLevel"];
@@ -2390,6 +2543,7 @@ export default function (pi: ExtensionAPI): void {
2390
2543
  ["notify=", "desktop push command: /glla notify='notify-send pi \"$1\"'"],
2391
2544
  ["tokenlimit=", "per-goal token budget (0 = off): /glla tokenlimit=2000000"],
2392
2545
  ["autoresume=", "on: auto-resume held goals/loops in fresh sessions"],
2546
+ ["autoaccept=", "on: drafts activate without the Confirm dialog (unattended rigs)"],
2393
2547
  ["project", "write a project override: /glla project key=value"],
2394
2548
  ]),
2395
2549
  handler: settingsHandler,
@@ -2397,7 +2551,7 @@ export default function (pi: ExtensionAPI): void {
2397
2551
  pi.registerCommand("list", {
2398
2552
  description: "Loop 2: the list of audited goals — order is the default, not the law. /list <describe tasks or name a plan file> (dumps get shaped into items, files import, 'Done when:' adds directly) | /list show | /list resume | /list next [n] | /list remove <n> | /list clear",
2399
2553
  getArgumentCompletions: completions([
2400
- ["show", "display the queued items"],
2554
+ ["show", "display the waiting items"],
2401
2555
  ["resume", "resume the paused list item (the list's head)"],
2402
2556
  ["next", "activate the next item (or /list next <n> for position n)"],
2403
2557
  ["remove", "remove an item: /list remove <n>"],
@@ -2435,6 +2589,18 @@ export default function (pi: ExtensionAPI): void {
2435
2589
  // v0.15.1: ask_user_question answers arrive as tool results, not chat
2436
2590
  // messages — count answered (non-cancelled) questionnaires as replies too.
2437
2591
  pi.on("tool_result", async (event: any) => {
2592
+ // v0.24.0: roll loop tool-result fingerprints (same-tool-same-result
2593
+ // detection) — recorded for ANY tool result while a loop is active.
2594
+ if (isLoopActive()) {
2595
+ const loop = state.loop!;
2596
+ const out = event?.output ?? event?.result ?? event?.details ?? "";
2597
+ const text = typeof out === "string" ? out : JSON.stringify(out) ?? "";
2598
+ loop.recentToolResults = pushRepetitionCapped(
2599
+ loop.recentToolResults ?? [],
2600
+ { tool: String(event?.toolName ?? "?"), hash: textFingerprint(text), isError: Boolean(event?.isError ?? event?.error) },
2601
+ REPETITION.toolWindow,
2602
+ );
2603
+ }
2438
2604
  if (draftingTarget === null) return;
2439
2605
  if (askUserQuestionAnswered(String(event?.toolName ?? ""), event?.details)) {
2440
2606
  draftingUserReplies++;
@@ -2443,6 +2609,10 @@ export default function (pi: ExtensionAPI): void {
2443
2609
 
2444
2610
  pi.on("session_start", async (event: any, ctx: ExtensionContext) => {
2445
2611
  rememberCtx(ctx);
2612
+ // v0.23.8: subagent sessions (pi-subagents binds extensions there too)
2613
+ // are workers — never run the restore gate or reschedule the loop from
2614
+ // a foreign session.
2615
+ if (isForeignCtx(ctx)) return;
2446
2616
  state = readState(ctx.cwd);
2447
2617
  if (!registeredCtx) {
2448
2618
  registerAgentTools(pi, ctx);
@@ -2485,14 +2655,14 @@ export default function (pi: ExtensionAPI): void {
2485
2655
  // v0.22.7: name WHAT is held — a list head resumes through /list.
2486
2656
  const isListItem = state.goal.policy === "list";
2487
2657
  const resumeCmd = isListItem ? "/list resume" : "/goal resume";
2488
- const resumeHint = `${resumeCmd} to continue${queued > 0 ? ` (+${queued} queued in the list)` : ""} · /glla autoresume=on to auto-resume in this project`;
2658
+ const resumeHint = `${resumeCmd} to continue${queued > 0 ? ` (+${queued} waiting in the list)` : ""} · /glla autoresume=on to auto-resume in this project`;
2489
2659
  updateGoal({
2490
2660
  status: "paused",
2491
2661
  pauseReason: "restored in a fresh session — no work started",
2492
2662
  pauseSuggestedAction: resumeHint,
2493
2663
  }, ctx);
2494
2664
  ctx.ui.notify(
2495
- `${isListItem ? "List item" : "Goal"} held on restore [${state.goal.id}]: ${state.goal.objective.slice(0, 70)}${queued > 0 ? ` (+${queued} queued in the list)` : ""} — ${resumeCmd} to continue.`,
2665
+ `${isListItem ? "List item" : "Goal"} held on restore [${state.goal.id}]: ${state.goal.objective.slice(0, 70)}${queued > 0 ? ` (+${queued} waiting in the list)` : ""} — ${resumeCmd} to continue.`,
2496
2666
  "info",
2497
2667
  );
2498
2668
  }
@@ -2520,6 +2690,9 @@ export default function (pi: ExtensionAPI): void {
2520
2690
 
2521
2691
  pi.on("agent_end", async (event: any, ctx: ExtensionContext) => {
2522
2692
  rememberCtx(ctx);
2693
+ // v0.23.8: a subagent finishing must not drive the main session's
2694
+ // continuation loop.
2695
+ if (isForeignCtx(ctx)) return;
2523
2696
  noteActivity();
2524
2697
  if (!registeredCtx) {
2525
2698
  registerAgentTools(pi, ctx);
@@ -2615,5 +2788,9 @@ export default function (pi: ExtensionAPI): void {
2615
2788
  pi.on("tool_call", () => {
2616
2789
  toolCallsThisTurn++;
2617
2790
  noteActivity();
2791
+ // v0.24.0: count loop-iteration tool calls (narration-only detection).
2792
+ if (isLoopActive()) {
2793
+ state.loop!.toolsThisTurn = (state.loop!.toolsThisTurn ?? 0) + 1;
2794
+ }
2618
2795
  });
2619
2796
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.23.7",
3
+ "version": "0.24.0",
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",
@@ -16,10 +16,13 @@ ${TARGET}
16
16
 
17
17
  ## Your job THIS turn
18
18
 
19
+ ${VARIANT_NOTE}
20
+
19
21
  Start your reply with exactly one line: `HYPOTHESIS: <what you will change and why it is real progress on the spec>`.
20
22
  Then make **ONE** concrete, inspectable change that advances the target.
21
23
  Then stop.
22
24
 
25
+ ${INTERVENTION_NOTE}
23
26
  ${REGRESSION_NOTE}
24
27
  ${STRATEGY_NOTE}
25
28
 
@@ -26,6 +26,7 @@ Start your reply with exactly one line: `HYPOTHESIS: <what you will change and w
26
26
  Then make **ONE** small, concrete change that moves the metric in the right
27
27
  direction. Then stop.
28
28
 
29
+ ${INTERVENTION_NOTE}
29
30
  ${REGRESSION_NOTE}
30
31
  ${STRATEGY_NOTE}
31
32