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 +42 -6
- package/extensions/goal-loop-core.ts +18 -0
- package/extensions/goal-loop-forever.ts +14 -0
- package/extensions/goal-loop-repetition.ts +253 -0
- package/extensions/loops/goal.ts +237 -60
- package/package.json +1 -1
- package/prompts/goal-loop-forever-metricless.md +3 -0
- package/prompts/goal-loop-forever.md +1 -0
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 →
|
|
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 +
|
|
56
|
+
/list # show active + waiting items
|
|
57
57
|
/list next # skip current, activate next
|
|
58
|
-
/list remove <n> # drop item n from the
|
|
59
|
-
/list clear # empty the
|
|
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
|
|
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.
|
|
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
|
+
}
|
package/extensions/loops/goal.ts
CHANGED
|
@@ -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
|
|
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
|
|
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}
|
|
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}
|
|
594
|
-
: `Resumed goal [${state.goal.id}]: ${state.goal.objective.replace(/\s+/g, " ").slice(0, 70)}${queued > 0 ? ` (+${queued}
|
|
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
|
|
658
|
-
(newContract ? `\n\nNew contract:\n${newContract
|
|
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
|
-
|
|
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
|
|
709
|
-
`${parsed.length} items from ${sourceName}:\n${preview}
|
|
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}
|
|
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
|
|
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(`
|
|
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
|
-
|
|
1060
|
-
hypothesis =
|
|
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.
|
|
1566
|
-
|
|
1567
|
-
const
|
|
1568
|
-
if (
|
|
1569
|
-
|
|
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
|
-
|
|
1587
|
-
batchConfirmed =
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
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
|
|
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
|
|
1621
|
-
: "\n\n(Goes into the list,
|
|
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
|
-
|
|
1625
|
-
confirmed =
|
|
1626
|
-
|
|
1627
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1728
|
-
confirmed =
|
|
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
|
-
|
|
1735
|
-
|
|
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
|
|
1839
|
-
description: "Add one or many objectives to the /list list (loop 2). Use when the user asks to
|
|
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
|
|
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)
|
|
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
|
-
|
|
1950
|
-
confirmed =
|
|
1951
|
-
|
|
1952
|
-
|
|
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
|
|
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}
|
|
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}
|
|
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.
|
|
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
|
|