pi-goal-list-loop-audit 0.23.6 → 0.23.8

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
@@ -91,7 +91,7 @@ sisyphus-style plan file (checklists, bullets, numbered, plain lines) imports
91
91
  as-is — headings become nothing, items become goals. And the drafter itself
92
92
  batches: asking for "these 50 tasks" in a `/list` drafting session produces
93
93
  ONE confirmed batch, not 50 dialogs.
94
- Note: every queue item is audited individually, so at hundreds of items the
94
+ Note: every list item is audited individually, so at hundreds of items the
95
95
  audit cost per item is the thing to think about.
96
96
 
97
97
  **Drafting is the default for long-running things.** `/goal` and
@@ -144,7 +144,7 @@ redirect you to `/goal`.
144
144
  | Loop | Command | Status |
145
145
  |---|---|---|
146
146
  | 1. Single ordered goal | `/goal "<objective>"` | **shipped v0.1.0** |
147
- | 2. Queue of goals | `/list [show\|next\|remove\|clear]` | **shipped v0.2.0** |
147
+ | 2. List of goals (a pool, not a FIFO) | `/list [show\|next\|remove\|clear]` | **shipped v0.2.0** |
148
148
  | 3. Metric-driven process loop | `/loop start\|status\|stop` | **shipped v0.3.0** |
149
149
 
150
150
  Each loop is a different policy class on the same status machine.
@@ -186,6 +186,8 @@ No external watchdog plugin needed.
186
186
  /glla tokenlimit=10000000 # per-goal token budget (default: off) → GLOBAL
187
187
  /glla tokenlimit=0 # explicitly no cap (the default)
188
188
  /glla wedgealert=30 # hung-command alert minutes (default: 30, 0 = off)
189
+ /glla autoresume=on # held goals/loops auto-resume in fresh sessions (unattended rigs)
190
+ /glla autoaccept=on # drafts ACTIVATE without the Confirm dialog (unattended rigs)
189
191
  /glla project tokenlimit=500 # rare per-project override
190
192
  ```
191
193
 
@@ -195,6 +197,29 @@ auditor can't auth it — you're told once (info level) with the fix:
195
197
  `/glla model=provider/id`, set once, rarely touched again. The plugin never
196
198
  picks a model itself. Thinking follows the session too (floor `high`).
197
199
 
200
+ `autoaccept=on` skips BOTH the Confirm dialog and the drafting interview
201
+ floor — every `propose_*` draft (goal, list batch, loop, task list)
202
+ activates the moment the agent proposes it, with a notification and a
203
+ `draft_autoaccepted` ledger entry (auto-accept is never silent). The seed
204
+ carries the intent. Pair with `autoresume=on` for fully unattended rigs.
205
+
206
+ ## Subagents (`@tintinweb/pi-subagents`)
207
+
208
+ Subagent sessions bind extensions too, so glla loads there — by design the
209
+ **main session owns the goal/loop/list; subagents are workers** (v0.23.8):
210
+
211
+ - Read-only agents (Explore, Plan) get no glla tools (pi-subagents gates
212
+ them); general-purpose agents see them but state-mutating calls
213
+ (`complete_goal`, `propose_*`, `list_add`, `pause_goal`, …) are refused
214
+ with "report back to the main agent".
215
+ - A subagent session never clobbers the loop's session handle, never runs
216
+ the restore gate, and never drives continuation — so the heartbeat,
217
+ wedge alert, and auto-resume machinery always act on the main session.
218
+ (pi hands a fresh ctx wrapper per event; `ctx.sessionManager` identity
219
+ is the discriminator.)
220
+ - Subagent tool activity counts as activity for the wedge clock — a long
221
+ subagent run is work, not a hang.
222
+
198
223
  ## Token guard
199
224
 
200
225
  Every goal tracks real token usage; crossing the budget pauses the goal.
@@ -300,7 +300,10 @@ export interface ListItem {
300
300
  export function goalArgsNeedDrafting(args: string): boolean {
301
301
  const t = args.trim();
302
302
  if (!t) return false; // no-args is already the drafting path
303
- return !/\bdone\s+when\s*:/i.test(t);
303
+ // v0.23.7: any "done when" phrase counts — requiring the colon to
304
+ // immediately follow made "Done when ALL of the following are true:"
305
+ // route to the interview even though the user wrote a contract.
306
+ return !/\bdone\s+when\b/i.test(t);
304
307
  }
305
308
 
306
309
  /**
@@ -627,3 +630,61 @@ export function normalizeDraftContract(raw: string): string {
627
630
  export function draftContractItemCount(normalized: string): number {
628
631
  return normalized.split("\n").filter((l) => /^\d+\.\s/.test(l)).length;
629
632
  }
633
+
634
+ /**
635
+ * Split raw objective text into { objective, verificationContract } at the
636
+ * first "Done when…:"-family marker (line-start preferred, inline fallback
637
+ * for one-liners). v0.23.7: the marker family accepts ANY text between the
638
+ * keyword and the colon ("Done when ALL of the following are true:") —
639
+ * the shield's contractItems already drops such introducer lines
640
+ * (v0.23.4), and goalArgsNeedDrafting recognizes the same phrase, so the
641
+ * three "done when" parsers can no longer drift apart. Lives in the pure
642
+ * module so tests exercise THIS function, not a copy (the pre-0.23.7 test
643
+ * re-implemented it and silently went stale).
644
+ */
645
+ export function extractVerificationContract(raw: string): { objective: string; verificationContract: string } {
646
+ // Line-based first: a marker at line start begins the contract block.
647
+ const lines = raw.split("\n");
648
+ let mode: "obj" | "verify" = "obj";
649
+ const objParts: string[] = [];
650
+ const verifyParts: string[] = [];
651
+ for (const line of lines) {
652
+ if (line.match(/^\s*(?:done when|verified when|verify|verification|done)\b[^:]*:/i)) {
653
+ mode = "verify";
654
+ }
655
+ if (mode === "obj") objParts.push(line);
656
+ else verifyParts.push(line);
657
+ }
658
+ let objective = objParts.join("\n").trim();
659
+ let verificationContract = verifyParts.join("\n").trim();
660
+
661
+ // Inline fallback: users write one-liners like
662
+ // "Create x.txt. Done when: grep -q ok x.txt"
663
+ // where the marker is mid-line. Split at the first inline marker.
664
+ if (!verificationContract) {
665
+ const m = raw.match(/^(.*?)(?:\.|;)??\s+(?:done when|verified when|verify|verification)\b[^:]*:\s*(.+)$/is);
666
+ if (m) {
667
+ objective = (m[1] ?? "").trim().replace(/[.;]\s*$/, "");
668
+ verificationContract = (m[2] ?? "").trim();
669
+ }
670
+ }
671
+ return { objective, verificationContract };
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
+ }
@@ -55,6 +55,8 @@ import {
55
55
  piGlaDir,
56
56
  normalizeDraftContract,
57
57
  draftContractItemCount,
58
+ extractVerificationContract,
59
+ classifySessionCtx,
58
60
  readState,
59
61
  renderGoalMarkdown,
60
62
  shouldAutoResumeOnSessionStart,
@@ -104,11 +106,37 @@ let extensionApi: ExtensionAPI | null = null;
104
106
  // pi replaces sessions (newSession/fork/reload) and stale ctx throws on use,
105
107
  // so timers must never capture a ctx — they read lastCtx at fire time.
106
108
  let lastCtx: ExtensionContext | null = null;
109
+ // v0.23.8: the session that OWNS the loop (its sessionManager). Subagent
110
+ // sessions (pi-subagents binds extensions there too) fire our handlers
111
+ // with their own ctx — they must never take over lastCtx (a headless
112
+ // subagent ctx would silently kill the heartbeat/wedge machinery).
113
+ let ownerSession: unknown = null;
107
114
 
108
115
  function rememberCtx(ctx: ExtensionContext): void {
116
+ let ownerLive = false;
117
+ if (ownerSession && lastCtx) {
118
+ try { lastCtx.isIdle(); ownerLive = true; } catch { /* owner went stale (session replaced) */ }
119
+ }
120
+ const claim = classifySessionCtx(ownerSession, ownerLive, ctx.sessionManager);
121
+ if (claim === "foreign") return;
122
+ ownerSession = ctx.sessionManager;
109
123
  lastCtx = ctx;
110
124
  }
111
125
 
126
+ /** True when ctx belongs to a subagent/foreign session, not the loop owner. */
127
+ function isForeignCtx(ctx: ExtensionContext): boolean {
128
+ return ownerSession !== null && ctx.sessionManager !== ownerSession;
129
+ }
130
+
131
+ const FOREIGN_SESSION_TOOL_MESSAGE =
132
+ "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.";
133
+
134
+ /** Refusal message when a state-mutating tool is called from a subagent session, else null. */
135
+ function foreignToolGuard(execCtx: unknown): string | null {
136
+ const c = execCtx as ExtensionContext | undefined;
137
+ return c && isForeignCtx(c) ? FOREIGN_SESSION_TOOL_MESSAGE : null;
138
+ }
139
+
112
140
  let state: State = { goal: null };
113
141
 
114
142
  // Drafting mode: a no-arg loop command starts a clarification turn; the agent
@@ -354,43 +382,13 @@ function createGoal(objective: string, ctx: ExtensionContext, policy: "goal" | "
354
382
  return goal;
355
383
  }
356
384
 
357
- function extractVerificationContract(raw: string): { objective: string; verificationContract: string } {
358
- // Line-based first: a marker at line start begins the contract block.
359
- const lines = raw.split("\n");
360
- let mode: "obj" | "verify" = "obj";
361
- const objParts: string[] = [];
362
- const verifyParts: string[] = [];
363
- for (const line of lines) {
364
- const lower = line.toLowerCase();
365
- if (lower.match(/^\s*(?:done when|verify|verified when|verification|done):/)) {
366
- mode = "verify";
367
- }
368
- if (mode === "obj") objParts.push(line);
369
- else verifyParts.push(line);
370
- }
371
- let objective = objParts.join("\n").trim();
372
- let verificationContract = verifyParts.join("\n").trim();
373
-
374
- // Inline fallback: users write one-liners like
375
- // "Create x.txt. Done when: grep -q ok x.txt"
376
- // where the marker is mid-line. Split at the first inline marker.
377
- if (!verificationContract) {
378
- const m = raw.match(/^(.*?)(?:\.|;)??\s+(done when|verified when|verify|verification)\s*:\s*(.+)$/is);
379
- if (m) {
380
- objective = (m[1] ?? "").trim().replace(/[.;]\s*$/, "");
381
- verificationContract = (m[3] ?? "").trim();
382
- }
383
- }
384
- return { objective, verificationContract };
385
- }
386
-
387
385
  function persistState(ctx: ExtensionContext): void {
388
386
  appendLedger(ctx.cwd, "state", { goal: state.goal, list: state.list ?? [], loop: state.loop ?? null });
389
387
  refreshUI(ctx); // every state transition flows through here → the TUI is always current
390
388
  }
391
389
 
392
390
  function setGoal(goal: Goal, ctx: ExtensionContext): void {
393
- state = { goal, list: state.list ?? [] }; // preserve the queue!
391
+ state = { goal, list: state.list ?? [] }; // preserve the list!
394
392
  const file = writeGoalMd(ctx.cwd, goal);
395
393
  state.goal!.activePath = path.relative(ctx.cwd, file) || file;
396
394
  persistState(ctx);
@@ -482,7 +480,7 @@ async function startDrafting(ctx: ExtensionContext, target: "goal" | "list" | "l
482
480
  if (target === "list") {
483
481
  tmpl = tmpl.replace(
484
482
  "[GOAL DRAFTING]",
485
- "[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.]",
483
+ "[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.]",
486
484
  );
487
485
  }
488
486
  } catch {
@@ -596,7 +594,7 @@ async function cmdPause(ctx: ExtensionContext): Promise<void> {
596
594
  // v0.22.7: name WHAT was paused — a list item resumes through /list.
597
595
  if (state.goal.policy === "list") {
598
596
  const queued = listQueue().length;
599
- ctx.ui.notify(`List item ${state.goal.id} paused${queued > 0 ? ` (${queued} queued in the list)` : ""}. /list resume to continue.`, "info");
597
+ ctx.ui.notify(`List item ${state.goal.id} paused${queued > 0 ? ` (${queued} waiting in the list)` : ""}. /list resume to continue.`, "info");
600
598
  return;
601
599
  }
602
600
  ctx.ui.notify(`Goal ${state.goal.id} paused. /goal resume to continue.`, "info");
@@ -619,8 +617,8 @@ async function cmdResume(ctx: ExtensionContext): Promise<void> {
619
617
  const isListItem = state.goal.policy === "list";
620
618
  ctx.ui.notify(
621
619
  isListItem
622
- ? `Resumed list item [${state.goal.id}]: ${state.goal.objective.replace(/\s+/g, " ").slice(0, 70)}${queued > 0 ? ` (+${queued} queued in the list)` : ""}`
623
- : `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)` : ""}`,
620
+ ? `Resumed list item [${state.goal.id}]: ${state.goal.objective.replace(/\s+/g, " ").slice(0, 70)}${queued > 0 ? ` (+${queued} waiting in the list)` : ""}`
621
+ : `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)` : ""}`,
624
622
  "info",
625
623
  );
626
624
  scheduleContinuation(ctx, true);
@@ -683,8 +681,8 @@ async function cmdTweak(args: string, ctx: ExtensionContext): Promise<void> {
683
681
  try {
684
682
  confirmed = await ctx.ui.confirm(
685
683
  "Tweak goal?",
686
- `CURRENT:\n${current.objective.slice(0, 400)}\n\nNEW:\n${newObjective.slice(0, 400)}` +
687
- (newContract ? `\n\nNew contract:\n${newContract.slice(0, 200)}` : "\n\n(New text carries no contract; old contract is dropped.)"),
684
+ `CURRENT:\n${current.objective}\n\nNEW:\n${newObjective}` +
685
+ (newContract ? `\n\nNew contract:\n${newContract}` : "\n\n(New text carries no contract; old contract is dropped.)"),
688
686
  );
689
687
  } catch {
690
688
  confirmed = false;
@@ -729,13 +727,15 @@ async function bulkAddItems(ctx: ExtensionContext, parsed: string[], sourceName:
729
727
  ctx.ui.notify("No items found (headings/blank lines don't count).", "warning");
730
728
  return;
731
729
  }
732
- const preview = parsed.slice(0, 5).map((t, i) => ` ${i + 1}. ${t.slice(0, 70)}`).join("\n");
730
+ // v0.23.7: show ALL items in full a Confirm the user can't fully
731
+ // read is not a gate (same rule as the draft dialog, v0.23.5).
732
+ const preview = parsed.map((t, i) => ` ${i + 1}. ${t}`).join("\n");
733
733
  let confirmed = true;
734
734
  if (ctx.hasUI) {
735
735
  try {
736
736
  confirmed = await ctx.ui.confirm(
737
- "Import into queue?",
738
- `${parsed.length} items from ${sourceName}:\n${preview}${parsed.length > 5 ? `\n … and ${parsed.length - 5} more` : ""}`,
737
+ "Import into list?",
738
+ `${parsed.length} items from ${sourceName}:\n${preview}`,
739
739
  );
740
740
  } catch {
741
741
  confirmed = false;
@@ -747,7 +747,7 @@ async function bulkAddItems(ctx: ExtensionContext, parsed: string[], sourceName:
747
747
  }
748
748
  const n = enqueueItems(ctx, parsed, sourceName);
749
749
  if (state.goal && state.goal.status === "active") {
750
- ctx.ui.notify(`Imported ${n} items (${listQueue().length} queued).`, "info");
750
+ ctx.ui.notify(`Imported ${n} items (${listQueue().length} waiting in the list).`, "info");
751
751
  }
752
752
  }
753
753
 
@@ -773,7 +773,7 @@ async function cmdList(args: string, ctx: ExtensionContext): Promise<void> {
773
773
  // is the same motion as /goal resume — named for the surface the user is
774
774
  // looking at (v0.22.7: "we would just unpause, and that is next").
775
775
  if (!state.goal || state.goal.status !== "paused") {
776
- ctx.ui.notify("No paused list item to resume. /list show to see the queue.", "info");
776
+ ctx.ui.notify("No paused list item to resume. /list show to see the list.", "info");
777
777
  return;
778
778
  }
779
779
  if (state.goal.policy !== "list") {
@@ -914,7 +914,7 @@ function addSingleItem(ctx: ExtensionContext, raw: string): void {
914
914
  if (!state.goal || state.goal.status === "complete" || state.goal.status === "aborted") {
915
915
  activateNextListItem(ctx);
916
916
  } else {
917
- ctx.ui.notify(`Queued (${listQueue().length} waiting): ${objective.slice(0, 80)}`, "info");
917
+ ctx.ui.notify(`Added to the list (${listQueue().length} waiting): ${objective.slice(0, 80)}`, "info");
918
918
  }
919
919
  }
920
920
 
@@ -1345,7 +1345,9 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1345
1345
  completionSummary: Type.Optional(Type.String({ description: "1-paragraph completion claim" })),
1346
1346
  verificationSummary: Type.Optional(Type.String({ description: "Per-item evidence for the verification contract" })),
1347
1347
  }),
1348
- async execute(_id, params, signal) {
1348
+ async execute(_id, params, signal, _onUpdate, execCtx) {
1349
+ const foreign0 = foreignToolGuard(execCtx);
1350
+ if (foreign0) return { content: [{ type: "text", text: foreign0 }], details: {} };
1349
1351
  if (!state.goal || state.goal.status !== "active") {
1350
1352
  return { content: [{ type: "text", text: "No active goal." }], details: {} };
1351
1353
  }
@@ -1504,7 +1506,9 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1504
1506
  reason: Type.String({ description: "Why the work is paused" }),
1505
1507
  suggestedAction: Type.Optional(Type.String({ description: "What the user should do next" })),
1506
1508
  }),
1507
- async execute(_id, params) {
1509
+ async execute(_id, params, _signal, _onUpdate, execCtx) {
1510
+ const foreign1 = foreignToolGuard(execCtx);
1511
+ if (foreign1) return { content: [{ type: "text", text: foreign1 }], details: {} };
1508
1512
  const p = params as { reason: string; suggestedAction?: string };
1509
1513
  if (!state.goal) return { content: [{ type: "text", text: "No active goal." }], details: {} };
1510
1514
  updateGoal({
@@ -1583,6 +1587,8 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1583
1587
  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." })),
1584
1588
  }),
1585
1589
  async execute(_id, params, _signal, _onUpdate, execCtx) {
1590
+ const foreign2 = foreignToolGuard(execCtx);
1591
+ if (foreign2) return { content: [{ type: "text", text: foreign2 }], details: {} };
1586
1592
  const p = params as { objective: string; verificationContract?: string; items?: string[] };
1587
1593
  if (draftingTarget !== "goal" && draftingTarget !== "list") {
1588
1594
  return {
@@ -1591,11 +1597,15 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1591
1597
  };
1592
1598
  }
1593
1599
  // v0.14.0: the interview floor — no Confirm until the user replied.
1594
- // v0.14.0: the interview floor no Confirm until the user replied.
1595
- if (draftingUserReplies === 0) draftingBlockedProposals++;
1596
- const block = draftProposalBlock(draftingUserReplies, draftingBlockedProposals);
1597
- if (block) {
1598
- return { content: [{ type: "text", text: block }], details: {} };
1600
+ // v0.23.8: /glla autoaccept=on skips the floor AND the Confirm
1601
+ // the seed carries the intent (unattended rigs). Default off.
1602
+ const autoAccept = loadSettings(ctx.cwd).autoAcceptDrafts === true;
1603
+ if (!autoAccept) {
1604
+ if (draftingUserReplies === 0) draftingBlockedProposals++;
1605
+ const block = draftProposalBlock(draftingUserReplies, draftingBlockedProposals);
1606
+ if (block) {
1607
+ return { content: [{ type: "text", text: block }], details: {} };
1608
+ }
1599
1609
  }
1600
1610
  // Multi-item drafts are LIST-only: a goal is single by definition.
1601
1611
  if (p.items && p.items.length > 0 && draftingTarget !== "list") {
@@ -1607,16 +1617,24 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1607
1617
  const liveCtx = (execCtx as ExtensionContext | undefined) ?? ctx;
1608
1618
  // Multi-item list draft: one Confirm for the whole batch.
1609
1619
  if (p.items && p.items.length > 0) {
1610
- const preview = p.items.slice(0, 6).map((t, i) => ` ${i + 1}. ${t.slice(0, 60)}`).join("\n");
1620
+ // v0.23.7: show ALL items in full the user approves the whole
1621
+ // batch; hidden items would be approved blind.
1622
+ const preview = p.items.map((t, i) => ` ${i + 1}. ${t}`).join("\n");
1611
1623
  const batchActivates = !state.goal || state.goal.status === "complete" || state.goal.status === "aborted";
1612
1624
  let batchConfirmed = false;
1613
- try {
1614
- batchConfirmed = await liveCtx.ui.confirm(
1615
- "Confirm queue batch",
1616
- `${p.items.length} items:\n${preview}${p.items.length > 6 ? `\n … and ${p.items.length - 6} more` : ""}${batchActivates ? "\n\n(List is empty — confirming ACTIVATES item 1 immediately as the active goal.)" : ""}`,
1617
- );
1618
- } catch {
1619
- batchConfirmed = false;
1625
+ if (autoAccept) {
1626
+ batchConfirmed = true;
1627
+ liveCtx.ui.notify(`List batch auto-accepted (/glla autoaccept=on): ${p.items.length} items${batchActivates ? " item 1 ACTIVATES now" : ""}.`, "info");
1628
+ appendLedger(liveCtx.cwd, "draft_autoaccepted", { kind: "batch", count: p.items.length });
1629
+ } else {
1630
+ try {
1631
+ batchConfirmed = await liveCtx.ui.confirm(
1632
+ "Confirm list batch",
1633
+ `${p.items.length} items:\n${preview}${batchActivates ? "\n\n(List is empty — confirming ACTIVATES item 1 immediately as the active goal.)" : ""}`,
1634
+ );
1635
+ } catch {
1636
+ batchConfirmed = false;
1637
+ }
1620
1638
  }
1621
1639
  if (!batchConfirmed) {
1622
1640
  return {
@@ -1630,7 +1648,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1630
1648
  if (wasIdle) {
1631
1649
  return { content: [{ type: "text", text: `${n} items confirmed; first activated (list was empty). Begin work now.` }], details: {} };
1632
1650
  }
1633
- return { content: [{ type: "text", text: `${n} items confirmed and queued (${listQueue().length} waiting).` }], details: {} };
1651
+ return { content: [{ type: "text", text: `${n} items confirmed and added to the list (${listQueue().length} waiting).` }], details: {} };
1634
1652
  }
1635
1653
  const normContract = p.verificationContract?.trim() ? normalizeDraftContract(p.verificationContract) : "";
1636
1654
  const checkCount = normContract ? draftContractItemCount(normContract) : 0;
@@ -1644,14 +1662,20 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1644
1662
  const willActivate = isListDraft && (!state.goal || state.goal.status === "complete" || state.goal.status === "aborted");
1645
1663
  const activationNote = isListDraft
1646
1664
  ? willActivate
1647
- ? "\n\n(List is empty — confirming ACTIVATES this immediately as the active goal. Reject if you only wanted to queue it.)"
1648
- : "\n\n(Goes into the list, queued behind the active goal.)"
1665
+ ? "\n\n(List is empty — confirming ACTIVATES this immediately as the active goal. Reject if you only wanted to add it, not start it.)"
1666
+ : "\n\n(Goes into the list, waiting behind the active goal.)"
1649
1667
  : "";
1650
1668
  let confirmed = false;
1651
- try {
1652
- confirmed = await liveCtx.ui.confirm(isListDraft ? "Confirm list item" : "Confirm goal", `${p.objective.trim()}${contractBlock}${activationNote}`);
1653
- } catch {
1654
- confirmed = false;
1669
+ if (autoAccept) {
1670
+ confirmed = true;
1671
+ liveCtx.ui.notify(`Draft auto-accepted (/glla autoaccept=on)${willActivate ? " — ACTIVATING now" : ""}: ${p.objective.trim().slice(0, 90)}`, "info");
1672
+ appendLedger(liveCtx.cwd, "draft_autoaccepted", { kind: isListDraft ? "list" : "goal", objective: p.objective.trim().slice(0, 200) });
1673
+ } else {
1674
+ try {
1675
+ confirmed = await liveCtx.ui.confirm(isListDraft ? "Confirm list item" : "Confirm goal", `${p.objective.trim()}${contractBlock}${activationNote}`);
1676
+ } catch {
1677
+ confirmed = false;
1678
+ }
1655
1679
  }
1656
1680
  if (!confirmed) {
1657
1681
  return {
@@ -1673,7 +1697,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1673
1697
  activateNextListItem(liveCtx);
1674
1698
  return { content: [{ type: "text", text: "Confirmed and activated (list was empty). Begin work now." }], details: {} };
1675
1699
  }
1676
- return { content: [{ type: "text", text: `Confirmed and queued (${listQueue().length} waiting). It activates when the current goal completes.` }], details: {} };
1700
+ return { content: [{ type: "text", text: `Confirmed and added to the list (${listQueue().length} waiting). It activates when the current goal completes.` }], details: {} };
1677
1701
  }
1678
1702
  const goal = createGoal(full, liveCtx);
1679
1703
  setGoal(goal, liveCtx);
@@ -1702,6 +1726,8 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1702
1726
  branch: Type.Optional(Type.Boolean({ description: "branch=true: scratch-branch mode (clean git tree required)" })),
1703
1727
  }),
1704
1728
  async execute(_id, params, _signal, _onUpdate, execCtx) {
1729
+ const foreign3 = foreignToolGuard(execCtx);
1730
+ if (foreign3) return { content: [{ type: "text", text: foreign3 }], details: {} };
1705
1731
  const p = params as { target: string; measureCmd?: string; direction?: "min" | "max"; window?: number; max?: number; time?: number; tokens?: number; branch?: boolean };
1706
1732
  if (draftingTarget !== "loop") {
1707
1733
  return {
@@ -1749,17 +1775,27 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1749
1775
  }
1750
1776
  const window = p.window && p.window > 0 ? Math.floor(p.window) : 5;
1751
1777
  // v0.23.0: explicit max=0 = truly unbounded (no iteration cap).
1752
- const max = p.max !== undefined && Number.isFinite(p.max) && p.max >= 0 ? Math.floor(p.max) : 50;
1778
+ // v0.23.8: metricless + no explicit max = UNBOUNDED here too the
1779
+ // drafter path was still defaulting to 50 after v0.23.6 flipped the
1780
+ // CLI default.
1781
+ const max = p.max !== undefined && Number.isFinite(p.max) && p.max >= 0 ? Math.floor(p.max) : metricless ? 0 : 50;
1782
+ const autoAccept = loadSettings(ctx.cwd).autoAcceptDrafts === true;
1753
1783
  let confirmed = false;
1754
- try {
1755
- confirmed = await liveCtx.ui.confirm(
1784
+ if (autoAccept) {
1785
+ confirmed = true;
1786
+ liveCtx.ui.notify(`Loop draft auto-accepted (/glla autoaccept=on): ${p.target.trim().slice(0, 90)}`, "info");
1787
+ appendLedger(liveCtx.cwd, "draft_autoaccepted", { kind: "loop", target: p.target.trim().slice(0, 200), metricless });
1788
+ } else {
1789
+ try {
1790
+ confirmed = await liveCtx.ui.confirm(
1756
1791
  "Confirm loop",
1757
1792
  metricless
1758
1793
  ? `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?`
1759
1794
  : `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?`,
1760
- );
1761
- } catch {
1762
- confirmed = false;
1795
+ );
1796
+ } catch {
1797
+ confirmed = false;
1798
+ }
1763
1799
  }
1764
1800
  if (!confirmed) {
1765
1801
  return {
@@ -1798,6 +1834,8 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1798
1834
  rationale: Type.String({ description: "Why the current spec no longer captures 'better' — shown to the user in the Confirm dialog" }),
1799
1835
  }),
1800
1836
  async execute(_id, params, _signal, _onUpdate, execCtx) {
1837
+ const foreign4 = foreignToolGuard(execCtx);
1838
+ if (foreign4) return { content: [{ type: "text", text: foreign4 }], details: {} };
1801
1839
  const p = params as { target?: string; measureCmd?: string; rationale: string };
1802
1840
  const liveCtx = (execCtx as ExtensionContext | undefined) ?? ctx;
1803
1841
  const loop = state.loop;
@@ -1862,12 +1900,14 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1862
1900
 
1863
1901
  pi.registerTool(defineTool({
1864
1902
  name: "list_add",
1865
- label: "Add to queue",
1866
- 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.",
1903
+ label: "Add to list",
1904
+ 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.",
1867
1905
  parameters: Type.Object({
1868
- items: Type.Array(Type.String(), { description: "Objectives to enqueue — no count limit; large plans belong in ONE call." }),
1906
+ items: Type.Array(Type.String(), { description: "Objectives to add — no count limit; large plans belong in ONE call." }),
1869
1907
  }),
1870
1908
  async execute(_id, params, _signal, _onUpdate, execCtx) {
1909
+ const foreign5 = foreignToolGuard(execCtx);
1910
+ if (foreign5) return { content: [{ type: "text", text: foreign5 }], details: {} };
1871
1911
  const p = params as { items: string[] };
1872
1912
  if (listMutationBlocked(draftingTarget)) {
1873
1913
  return { content: [{ type: "text", text: LIST_DRAFTING_BLOCK_MESSAGE }], details: {} };
@@ -1883,7 +1923,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1883
1923
  content: [{
1884
1924
  type: "text",
1885
1925
  text: wasIdle
1886
- ? `${n} item(s) queued; the first is now active. Work it normally and call complete_goal when done — the next item activates automatically.`
1926
+ ? `${n} item(s) added; the first is now active. Work it normally and call complete_goal when done — the next item activates automatically.`
1887
1927
  : `${n} item(s) queued (${listQueue().length} waiting behind the active goal).`,
1888
1928
  }],
1889
1929
  details: {},
@@ -1899,6 +1939,8 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1899
1939
  n: Type.Number({ description: "1-based position in the queue (1 = head)" }),
1900
1940
  }),
1901
1941
  async execute(_id, params, _signal, _onUpdate, execCtx) {
1942
+ const foreign6 = foreignToolGuard(execCtx);
1943
+ if (foreign6) return { content: [{ type: "text", text: foreign6 }], details: {} };
1902
1944
  const p = params as { n: number };
1903
1945
  if (listMutationBlocked(draftingTarget)) {
1904
1946
  return { content: [{ type: "text", text: LIST_DRAFTING_BLOCK_MESSAGE }], details: {} };
@@ -1972,11 +2014,18 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1972
2014
  const subs = (t.subtasks ?? []).map((s, j) => ` ${i + 1}.${j + 1} ${s}`).join("\n");
1973
2015
  return `${i + 1}. ${t.title}` + (subs ? `\n${subs}` : "");
1974
2016
  }).join("\n");
2017
+ const autoAcceptTasks = loadSettings(ctx.cwd).autoAcceptDrafts === true;
1975
2018
  let confirmed = false;
1976
- try {
1977
- confirmed = await liveCtx.ui.confirm("Confirm task list", preview);
1978
- } catch {
1979
- confirmed = false;
2019
+ if (autoAcceptTasks) {
2020
+ confirmed = true;
2021
+ liveCtx.ui.notify(`Task list auto-accepted (/glla autoaccept=on): ${p.tasks.length} tasks.`, "info");
2022
+ appendLedger(liveCtx.cwd, "draft_autoaccepted", { kind: "tasks", count: p.tasks.length });
2023
+ } else {
2024
+ try {
2025
+ confirmed = await liveCtx.ui.confirm("Confirm task list", preview);
2026
+ } catch {
2027
+ confirmed = false;
2028
+ }
1980
2029
  }
1981
2030
  if (!confirmed) {
1982
2031
  return { content: [{ type: "text", text: "Task list rejected by the user. Adjust and propose again." }], details: {} };
@@ -2011,6 +2060,10 @@ interface Settings {
2011
2060
  /** on → restored goals/loops/lists auto-resume even in fresh sessions
2012
2061
  * (unattended rigs). Default off: restore holds until /goal resume. */
2013
2062
  autoResume?: boolean;
2063
+ /** on → propose_* drafts activate WITHOUT the Confirm dialog and the
2064
+ * interview floor is skipped — the seed carries the intent (unattended
2065
+ * rigs). Default off: nothing activates before the user confirms. */
2066
+ autoAcceptDrafts?: boolean;
2014
2067
  }
2015
2068
 
2016
2069
  const DEFAULT_SETTINGS: Settings = {
@@ -2055,7 +2108,7 @@ function settingsProvenance(cwd: string): Record<keyof Settings, { value: unknow
2055
2108
  const glob = readSettingsFile(globalSettingsPath());
2056
2109
  const effective = loadSettings(cwd);
2057
2110
  const out: Record<string, { value: unknown; source: "project" | "global" | "default" }> = {};
2058
- const keys: Array<keyof Settings> = ["auditorModel", "auditorThinkingLevel", "notifyCmd", "tokenLimit", "wedgeAlertMinutes", "autoResume"];
2111
+ const keys: Array<keyof Settings> = ["auditorModel", "auditorThinkingLevel", "notifyCmd", "tokenLimit", "wedgeAlertMinutes", "autoResume", "autoAcceptDrafts"];
2059
2112
  for (const k of keys) {
2060
2113
  if ((proj as Record<string, unknown>)[k] !== undefined) out[k] = { value: (proj as any)[k], source: "project" };
2061
2114
  else if ((glob as Record<string, unknown>)[k] !== undefined) out[k] = { value: (glob as any)[k], source: "global" };
@@ -2181,7 +2234,7 @@ async function openSettingsUI(ctx: ExtensionContext): Promise<void> {
2181
2234
  else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
2182
2235
  }
2183
2236
  } else if (choice.startsWith("Wedge alert")) {
2184
- const v = await ctx.ui.input("Wedge alert threshold (minutes)", "non-negative integer; 0 = off, empty = default 45");
2237
+ const v = await ctx.ui.input("Wedge alert threshold (minutes)", "non-negative integer; 0 = off, empty = default 30");
2185
2238
  if (v !== undefined) {
2186
2239
  const n = Number.parseInt(v.trim(), 10);
2187
2240
  if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { wedgeAlertMinutes: n });
@@ -2225,6 +2278,7 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
2225
2278
  fmt("notifyCmd", "notify"),
2226
2279
  fmt("tokenLimit", "tokenLimit"),
2227
2280
  fmt("autoResume", "autoResume"),
2281
+ fmt("autoAcceptDrafts", "autoAccept"),
2228
2282
  `\nglobal: ${globalSettingsPath()}`,
2229
2283
  `project: ${projectSettingsPath(ctx.cwd)}`,
2230
2284
  `Set with: /glla key=value (global) · /glla project key=value (project override)`,
@@ -2270,7 +2324,7 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
2270
2324
  } else {
2271
2325
  const n = Number.parseInt(value, 10);
2272
2326
  if (Number.isFinite(n) && n >= 0) {
2273
- patch.wedgeAlertMinutes = n; // 0 = off; unset = default 45
2327
+ patch.wedgeAlertMinutes = n; // 0 = off; unset = default 30
2274
2328
  changed = true;
2275
2329
  } else {
2276
2330
  ctx.ui.notify(`wedgealert must be a non-negative integer (minutes, 0 = off), got: ${value}`, "warning");
@@ -2286,6 +2340,17 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
2286
2340
  } else {
2287
2341
  ctx.ui.notify(`autoresume must be on or off, got: ${value}`, "warning");
2288
2342
  }
2343
+ } else if (key === "autoaccept") {
2344
+ if (["on", "true", "1", "yes"].includes(value)) {
2345
+ patch.autoAcceptDrafts = true;
2346
+ changed = true;
2347
+ 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");
2348
+ } else if (["off", "false", "0", "no", "unset"].includes(value)) {
2349
+ patch.autoAcceptDrafts = undefined;
2350
+ changed = true;
2351
+ } else {
2352
+ ctx.ui.notify(`autoaccept must be on or off, got: ${value}`, "warning");
2353
+ }
2289
2354
  } else if (key === "thinking" || key === "auditorthinkinglevel") {
2290
2355
  if (["off", "minimal", "low", "medium", "high", "xhigh"].includes(value)) {
2291
2356
  patch.auditorThinkingLevel = value as Settings["auditorThinkingLevel"];
@@ -2417,6 +2482,7 @@ export default function (pi: ExtensionAPI): void {
2417
2482
  ["notify=", "desktop push command: /glla notify='notify-send pi \"$1\"'"],
2418
2483
  ["tokenlimit=", "per-goal token budget (0 = off): /glla tokenlimit=2000000"],
2419
2484
  ["autoresume=", "on: auto-resume held goals/loops in fresh sessions"],
2485
+ ["autoaccept=", "on: drafts activate without the Confirm dialog (unattended rigs)"],
2420
2486
  ["project", "write a project override: /glla project key=value"],
2421
2487
  ]),
2422
2488
  handler: settingsHandler,
@@ -2424,7 +2490,7 @@ export default function (pi: ExtensionAPI): void {
2424
2490
  pi.registerCommand("list", {
2425
2491
  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",
2426
2492
  getArgumentCompletions: completions([
2427
- ["show", "display the queued items"],
2493
+ ["show", "display the waiting items"],
2428
2494
  ["resume", "resume the paused list item (the list's head)"],
2429
2495
  ["next", "activate the next item (or /list next <n> for position n)"],
2430
2496
  ["remove", "remove an item: /list remove <n>"],
@@ -2470,6 +2536,10 @@ export default function (pi: ExtensionAPI): void {
2470
2536
 
2471
2537
  pi.on("session_start", async (event: any, ctx: ExtensionContext) => {
2472
2538
  rememberCtx(ctx);
2539
+ // v0.23.8: subagent sessions (pi-subagents binds extensions there too)
2540
+ // are workers — never run the restore gate or reschedule the loop from
2541
+ // a foreign session.
2542
+ if (isForeignCtx(ctx)) return;
2473
2543
  state = readState(ctx.cwd);
2474
2544
  if (!registeredCtx) {
2475
2545
  registerAgentTools(pi, ctx);
@@ -2512,14 +2582,14 @@ export default function (pi: ExtensionAPI): void {
2512
2582
  // v0.22.7: name WHAT is held — a list head resumes through /list.
2513
2583
  const isListItem = state.goal.policy === "list";
2514
2584
  const resumeCmd = isListItem ? "/list resume" : "/goal resume";
2515
- const resumeHint = `${resumeCmd} to continue${queued > 0 ? ` (+${queued} queued in the list)` : ""} · /glla autoresume=on to auto-resume in this project`;
2585
+ const resumeHint = `${resumeCmd} to continue${queued > 0 ? ` (+${queued} waiting in the list)` : ""} · /glla autoresume=on to auto-resume in this project`;
2516
2586
  updateGoal({
2517
2587
  status: "paused",
2518
2588
  pauseReason: "restored in a fresh session — no work started",
2519
2589
  pauseSuggestedAction: resumeHint,
2520
2590
  }, ctx);
2521
2591
  ctx.ui.notify(
2522
- `${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.`,
2592
+ `${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.`,
2523
2593
  "info",
2524
2594
  );
2525
2595
  }
@@ -2547,6 +2617,9 @@ export default function (pi: ExtensionAPI): void {
2547
2617
 
2548
2618
  pi.on("agent_end", async (event: any, ctx: ExtensionContext) => {
2549
2619
  rememberCtx(ctx);
2620
+ // v0.23.8: a subagent finishing must not drive the main session's
2621
+ // continuation loop.
2622
+ if (isForeignCtx(ctx)) return;
2550
2623
  noteActivity();
2551
2624
  if (!registeredCtx) {
2552
2625
  registerAgentTools(pi, ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.23.6",
3
+ "version": "0.23.8",
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",
@@ -38,10 +38,12 @@ stops it.
38
38
  3. Clarify the **direction**: is lower better (min) or higher better (max)?
39
39
  (Skip this for a metricless loop — there is no direction without a metric.)
40
40
  4. Optional tuning: `window` (plateau stop after N non-improving iterations,
41
- default 5 — meaningless for metricless), `max` (iteration cap, default 50;
42
- `max=0` = truly unbounded). Suggest smaller values for expensive measures.
43
- For metricless loops, ALWAYS discuss bounds an unbounded metricless loop
44
- is a deliberate furnace.
41
+ default 5 — meaningless for metricless), `max` (iteration cap: default 50
42
+ for METRIC loops; default UNBOUNDED (`max=0`) for metricless loops,
43
+ v0.23.6 a bare infinite loop is the point of the metricless form).
44
+ Suggest smaller values for expensive measures. For metricless loops,
45
+ ALWAYS discuss bounds — an unbounded metricless loop is a deliberate
46
+ furnace.
45
47
  5. When concrete, call `propose_loop_draft` with `target`, `measureCmd` (or
46
48
  omit/`"none"` for metricless), `direction` (measured only), and optional
47
49
  `window`/`max`/`time`/`tokens`.