pi-goal-list-loop-audit 0.28.23 → 0.28.24

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.
@@ -463,6 +463,14 @@ function startUITicker(): void {
463
463
  // and escalated loudly past 5 minutes.
464
464
  let continuationRearmStreak = 0;
465
465
  let loopRearmStreak = 0;
466
+ // v0.28.24: post-compaction grace — a just-replaced session gets 3 minutes
467
+ // to settle (queue drain, provider recovery) before stall counting resumes.
468
+ // Field-observed in junk-runner: a 196k-token compact finished, then the
469
+ // heartbeat burned all 5 stall refires in the next 5 minutes into a session
470
+ // whose turn trigger was still dead — pausing a resumable goal 4 minutes
471
+ // after the compact instead of giving pi room to recover.
472
+ let compactionGraceUntil = 0;
473
+ const COMPACTION_GRACE_MS = 3 * 60_000;
466
474
  const SEND_REARM_LEDGER_EVERY = 600; // 600 × 50ms = 30s
467
475
  const SEND_REARM_ESCALATE_AT = 6000; // 6000 × 50ms = 5 minutes
468
476
 
@@ -545,6 +553,10 @@ function heartbeatTick(): void {
545
553
  return;
546
554
  }
547
555
  const sessionIdle = idle && !pending;
556
+ // v0.28.24: post-compaction grace — the whole stall/refire/watchdog
557
+ // machinery below stays quiet for 3 minutes while the replaced session
558
+ // settles (latch watchdog, wedge alert, refire counting all resume after).
559
+ if (Date.now() < compactionGraceUntil) return;
548
560
  // v0.26.5: pending-latch watchdog — a queued continuation whose turn
549
561
  // trigger was dropped (field-observed post-compaction: continuation
550
562
  // ACCEPTED at compact+0s, then 22 minutes of silence). The stuck latch
@@ -1205,10 +1217,10 @@ async function cmdSet(args: string, ctx: ExtensionContext, skipDraft = false): P
1205
1217
  // v0.28.1 (S3): the goal is persisted — mark the interrupt so the next
1206
1218
  // fresh session LOADS it (held by default since v0.28.21), and tell the truth instead of "starting now".
1207
1219
  updateGoal({ interruptedAt: nowIso(), interruptedReason: "created in a stale session" }, ctx);
1208
- ctx.ui.notify(`Goal saved: ${shortObj(goal.objective)} — safe in .pi-glla/, but this stale process can't send continuations. Restart pi, then /goal resume (v0.28.21: session loads no longer auto-start by default). (id: ${goal.id})`, "warning");
1220
+ ctx.ui.notify(`Goal saved: ${shortObj(goal.objective)} — safe in .pi-glla/, but this stale process can't send continuations. Restart pi, then /goal resume (v0.28.21: session loads no longer auto-start by default).`, "warning");
1209
1221
  return;
1210
1222
  }
1211
- ctx.ui.notify(`Goal started: ${shortObj(goal.objective)} — the auditor will verify on completion. (id: ${goal.id})`, "info");
1223
+ ctx.ui.notify(`Goal started: ${shortObj(goal.objective)} — the auditor will verify on completion.`, "info");
1212
1224
  scheduleContinuation(ctx, true);
1213
1225
  }
1214
1226
 
@@ -1219,8 +1231,7 @@ async function cmdStatus(ctx: ExtensionContext): Promise<void> {
1219
1231
  }
1220
1232
  const g = state.goal;
1221
1233
  const lines = [
1222
- `[${g.id}] ${statusLabel(g.status)}`,
1223
- `Objective: ${g.objective}`,
1234
+ `${statusLabel(g.status)}: ${g.objective}`,
1224
1235
  // v0.24.7: name WHERE the work came from — a queue item is not a goal.
1225
1236
  ...(g.policy === "list" ? [`Source: /list queue (${listQueue().length} waiting) — /list to manage`] : []),
1226
1237
  `Auto-continue: ${g.autoContinue ? "on" : "off"}`,
@@ -1240,10 +1251,10 @@ async function cmdPause(ctx: ExtensionContext): Promise<void> {
1240
1251
  // v0.22.7: name WHAT was paused — a list item resumes through /list.
1241
1252
  if (state.goal.policy === "list") {
1242
1253
  const queued = listQueue().length;
1243
- ctx.ui.notify(`List item ${state.goal.id} paused${queued > 0 ? ` (${queued} waiting in the list)` : ""}. /list resume to continue.`, "info");
1254
+ ctx.ui.notify(`List item "${shortObj(state.goal.objective)}" paused${queued > 0 ? ` (${queued} waiting in the list)` : ""}. /list resume to continue.`, "info");
1244
1255
  return;
1245
1256
  }
1246
- ctx.ui.notify(`Goal ${state.goal.id} paused. /goal resume to continue.`, "info");
1257
+ ctx.ui.notify(`Goal "${shortObj(state.goal.objective)}" paused. /goal resume to continue.`, "info");
1247
1258
  }
1248
1259
 
1249
1260
  async function cmdResume(ctx: ExtensionContext): Promise<void> {
@@ -2732,7 +2743,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2732
2743
  pi.registerTool(defineTool({
2733
2744
  name: "pause_goal",
2734
2745
  label: "Pause goal",
2735
- description: "Pause the active goal with a reason and suggested action. Use when blocked on user input or unable to make progress. When the user must CHOOSE between options, pass kind=\"decision\" with the options list (recommended = 1-based index of the best one) — decision pauses render as a prominent DECISION NEEDED card. Time-gated waits (retry at a specific time) use kind=\"wait\" with resumeAt (ISO). Operational failures use kind=\"error\".",
2746
+ description: "Pause the active goal with a reason and suggested action. Use when blocked on user input or unable to make progress. When the user must CHOOSE between options, pass kind=\"decision\" with the options list (recommended = 1-based index of the best one) — decision pauses render as a prominent DECISION NEEDED card. Time-gated waits (retry at a specific time) use kind=\"wait\" with resumeAt (ISO). Operational failures use kind=\"error\". VOCABULARY (v0.28.24): decision options and reasons must reference REAL commands only — /goal resume, /goal cancel, /goal tweak \"<new text>\", /list remove N, /list next, /list resume, /loop stop, /loop resume. These all act on the ACTIVE goal/item: there is NO /goal drop and NO command takes a goal id. Never show goal ids to the user — name the thing ('the active goal', 'list item \"<short name>\"'); ids are internal plumbing the user cannot act on.",
2736
2747
  parameters: Type.Object({
2737
2748
  reason: Type.String({ description: "Why the work is paused" }),
2738
2749
  suggestedAction: Type.Optional(Type.String({ description: "What the user should do next" })),
@@ -4420,6 +4431,12 @@ export default function (pi: ExtensionAPI): void {
4420
4431
  rememberCtx(ctx);
4421
4432
  if (!isSupervising()) return;
4422
4433
  appendLedger(ctx.cwd, "session_compact", {});
4434
+ // v0.28.24: a compaction is LEGITIMATE busy time — reset the send-rearm
4435
+ // storm streaks (π-web nearly escalated a "send-retry storm" pause during
4436
+ // a 3.5-minute compact) and open the post-compaction stall grace.
4437
+ continuationRearmStreak = 0;
4438
+ loopRearmStreak = 0;
4439
+ compactionGraceUntil = Date.now() + COMPACTION_GRACE_MS;
4423
4440
  const settle = setTimeout(() => {
4424
4441
  const c = freshCtx();
4425
4442
  if (!c) return;
@@ -4592,7 +4609,7 @@ export default function (pi: ExtensionAPI): void {
4592
4609
  // the auto-resume the marker promised.
4593
4610
  if (wasInterrupted) updateGoal({ interruptedAt: undefined, interruptedReason: undefined }, ctx);
4594
4611
  ctx.ui.notify(
4595
- `Resuming ${state.goal.policy === "list" ? "list item" : "goal"} [${state.goal.id}]: ${state.goal.objective.slice(0, 70)}${listQueue().length > 0 ? ` (+${listQueue().length} queued)` : ""}${wasInterrupted ? " — auto-resumed after the stale-handle interrupt" : ""}`,
4612
+ `Resuming ${state.goal.policy === "list" ? "list item" : "goal"}: ${state.goal.objective.slice(0, 70)}${listQueue().length > 0 ? ` (+${listQueue().length} queued)` : ""}${wasInterrupted ? " — auto-resumed after the stale-handle interrupt" : ""}`,
4596
4613
  "info",
4597
4614
  );
4598
4615
  // v0.28.4 (P3): skip nudge accounting for the first recovery turns.
@@ -4611,14 +4628,14 @@ export default function (pi: ExtensionAPI): void {
4611
4628
  pauseSuggestedAction: resumeHint,
4612
4629
  }, ctx);
4613
4630
  ctx.ui.notify(
4614
- `${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.`,
4631
+ `${isListItem ? "List item" : "Goal"} held on restore: ${state.goal.objective.slice(0, 70)}${queued > 0 ? ` (+${queued} waiting in the list)` : ""} — ${resumeCmd} to continue.`,
4615
4632
  "info",
4616
4633
  );
4617
4634
  }
4618
4635
  } else if (state.goal && state.goal.status === "active") {
4619
4636
  // Active but autoContinue off: nothing auto-fires — just surface it.
4620
4637
  ctx.ui.notify(
4621
- `Restored ${state.goal.policy === "list" ? "list item" : "goal"} [${state.goal.id}]: ${state.goal.objective.slice(0, 70)}${listQueue().length > 0 ? ` (+${listQueue().length} queued)` : ""}`,
4638
+ `Restored ${state.goal.policy === "list" ? "list item" : "goal"}: ${state.goal.objective.slice(0, 70)}${listQueue().length > 0 ? ` (+${listQueue().length} queued)` : ""}`,
4622
4639
  "info",
4623
4640
  );
4624
4641
  } else if ((!state.goal || state.goal.status === "complete" || state.goal.status === "aborted") && listQueue().length > 0) {
@@ -4644,7 +4661,7 @@ export default function (pi: ExtensionAPI): void {
4644
4661
  pauseSuggestedAction: "/loop to work the loop, or /loop stop then /goal resume to work the goal",
4645
4662
  }, ctx);
4646
4663
  ctx.ui.notify(
4647
- `Goal [${state.goal.id}] held — a loop also exists; one active thing at a time. /loop to resume the loop, or /loop stop then /goal resume.`,
4664
+ `Goal held — a loop also exists; one active thing at a time. /loop to resume the loop, or /loop stop then /goal resume.`,
4648
4665
  "info",
4649
4666
  );
4650
4667
  maybeDecisionPopup(ctx);
@@ -111,17 +111,84 @@ export function stripCodeSpans(text: string): string {
111
111
  .replace(/`[^`\n]*`/g, " ");
112
112
  }
113
113
 
114
+ /** v0.28.24: join hard-wrapped lines before classification. Completion
115
+ * summaries and transcripts arrive wrapped at ~70 cols, and line-by-line
116
+ * extraction sliced findings at the wrap point (field-observed in
117
+ * hellhunter: a list item whose ENTIRE objective was "Run a post-completion
118
+ * regression scan on the hellhunter codebase to" — the first visual line of
119
+ * a wrapped paragraph, enqueued by the convert-findings-to-list cascade).
120
+ * A line that doesn't end a sentence continues on the next line unless that
121
+ * line starts a new list item or heading. */
122
+ export function unwrapHardWrappedLines(text: string): string {
123
+ const lines = text.split("\n");
124
+ const out: string[] = [];
125
+ for (const line of lines) {
126
+ const prev = out[out.length - 1];
127
+ const startsNewItem = /^\s*([-*•>]|\d+[.)]|#)/.test(line);
128
+ // v0.28.24: join only when the continuation starts LOWERCASE — the
129
+ // mid-sentence signal. Punctuation-less standalone items ("TODO: fix x")
130
+ // start uppercase/keyword and must NOT merge with the next item.
131
+ const continuesSentence = /^[a-z]/.test(line.trimStart());
132
+ if (
133
+ prev !== undefined &&
134
+ prev.trim().length > 0 &&
135
+ line.trim().length > 0 &&
136
+ !startsNewItem &&
137
+ continuesSentence &&
138
+ !/[.!?:;)"'\]]$/.test(prev.trimEnd())
139
+ ) {
140
+ out[out.length - 1] = `${prev.trimEnd()} ${line.trimStart()}`;
141
+ } else {
142
+ out.push(line);
143
+ }
144
+ }
145
+ return out.join("\n");
146
+ }
147
+
148
+ /** v0.28.24: a candidate ending in a dangling connector is a wrap/parse
149
+ * fragment, not a finding ("…codebase to", "…the settings and"). */
150
+ const DANGLING_END = /\s(to|and|or|but|the|a|an|of|for|with|in|on|at|that|which|into|from|by|is|are|was|were|be|been|so|if|then|than|as|nor|yet|per|via)$/i;
151
+
152
+ /** v0.28.24: cut at a clause boundary, never mid-word — the finding text IS
153
+ * the item's user-facing name once enqueued. */
154
+ export function cutAtClauseBoundary(s: string, max: number): string {
155
+ if (s.length <= max) return s;
156
+ const window = s.slice(0, max);
157
+ const clause = Math.max(
158
+ window.lastIndexOf(". "),
159
+ window.lastIndexOf("! "),
160
+ window.lastIndexOf("? "),
161
+ window.lastIndexOf("; "),
162
+ window.lastIndexOf(", "),
163
+ window.lastIndexOf(" — "),
164
+ window.lastIndexOf(": "),
165
+ );
166
+ if (clause >= Math.floor(max * 0.4)) return window.slice(0, clause + 1).trimEnd();
167
+ const space = window.lastIndexOf(" ");
168
+ return (space > 0 ? window.slice(0, space) : window).trimEnd();
169
+ }
170
+
114
171
  /** Scan source texts line-by-line for finding-shaped content. Code
115
- * spans are stripped first (v0.26.4) — findings live in prose. */
116
- export function extractFindings(sources: Array<{ name: string; text: string }>, max: number): Finding[] {
172
+ * spans are stripped first (v0.26.4) — findings live in prose. Hard-wrapped
173
+ * lines are joined (v0.28.24) — findings are sentence-shaped, not
174
+ * visual-line-shaped. `completedObjective` (v0.28.24) dedupes findings that
175
+ * merely restate the just-completed goal (exact-match dedupe at v0.28.16 was
176
+ * too narrow — duplicates arrive as prefixes/paraphrases). */
177
+ export function extractFindings(sources: Array<{ name: string; text: string }>, max: number, completedObjective?: string): Finding[] {
117
178
  const out: Finding[] = [];
118
179
  const seen = new Set<string>();
180
+ const completedNorm = completedObjective ? normalizeObjective(completedObjective) : "";
119
181
  for (const { name, text } of sources) {
120
- for (const line of stripCodeSpans(text).split("\n")) {
182
+ for (const line of unwrapHardWrappedLines(stripCodeSpans(text)).split("\n")) {
121
183
  const cls = classifyFindingText(line);
122
184
  if (!cls) continue;
123
- const clean = line.trim().replace(/^[-*>\s\[\]x]+/, "").slice(0, 200);
185
+ const clean = cutAtClauseBoundary(line.trim().replace(/^[-*>\s\[\]x]+/, ""), 200);
124
186
  if (clean.length < 8 || seen.has(clean)) continue;
187
+ if (DANGLING_END.test(clean) || /[,;:\u2014-]$/.test(clean)) continue; // v0.28.24: wrap/parse fragment
188
+ if (completedNorm) {
189
+ const nf = normalizeObjective(clean);
190
+ if (nf.length >= 24 && (completedNorm.startsWith(nf) || nf.startsWith(completedNorm))) continue; // v0.28.24: restates the completed goal
191
+ }
125
192
  seen.add(clean);
126
193
  out.push({ text: clean, source: name, class: cls });
127
194
  if (out.length >= max) return out;
@@ -260,7 +327,7 @@ export function runReviewer(
260
327
  }
261
328
  }
262
329
 
263
- const findings = extractFindings(deps.sources, config.maxFindingsPerReview);
330
+ const findings = extractFindings(deps.sources, config.maxFindingsPerReview, source.objective);
264
331
  const bugs = findings.filter((f) => f.class === "bug" || f.class === "refactor");
265
332
  const architectural = findings.filter((f) => f.class === "architectural");
266
333
  const strategic = findings.filter((f) => f.class === "strategic");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.28.23",
3
+ "version": "0.28.24",
4
4
  "description": "Goal. Loop. Audit. Done. \u2014 a pi-coding-agent extension that supervises long-running work, with isolated auditor on each completion. Beat bamboozling by design: the auditor runs in a fresh session with no extensions, no skills, no editor \u2014 only the read tools needed to verify your goal.",
5
5
  "license": "MIT",
6
6
  "author": "dracon",
@@ -144,6 +144,8 @@ When the goal is genuinely blocked and you cannot make progress without user inp
144
144
  pause_goal({reason: "...", suggestedAction: "..."})
145
145
  ```
146
146
 
147
+ When the user must CHOOSE between paths, use `pause_goal` with `kind="decision"`, an `options` list, and `recommended` (1-based index) — a prominent decision card renders and the user picks. **Vocabulary rules for reasons and options (v0.28.24):** reference only REAL commands — `/goal resume`, `/goal cancel`, `/goal tweak "<new text>"`, `/list remove N`, `/list next`, `/list resume`, `/loop stop`, `/loop resume` — all act on the ACTIVE goal/item; there is **no `/goal drop`** and **no command takes a goal id**. Never show goal ids (`20260729065635-gbtxsm`) in user-facing text — name the thing instead ("the active goal", "list item 'regression scan'"); ids are internal plumbing the user cannot act on.
148
+
147
149
  ## HARD RULES
148
150
 
149
151
  - **Do not modify the objective silently.** The objective is the user's; if it has drifted from what makes sense, use `complete_goal`'s `newObjective` at completion time, or `pause_goal` and propose a `/goal tweak` mid-flight — never just work on something else and claim the original.