pi-goal-list-loop-audit 0.38.62 → 0.38.67

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/CHANGELOG.md CHANGED
@@ -1,5 +1,103 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.38.67 — Stop clobbering other extensions' compactions (2026-09-19)
4
+
5
+ ### session_before_compact returns nothing (issue #56, ezoushen)
6
+
7
+ The `session_before_compact` handler ended with `return {}`, which under
8
+ pi's last-truthy-wins handler semantics silently discarded a
9
+ previously-run extension's `SessionBeforeCompactResult.compaction` — pi
10
+ fell back to default compaction after the other extension had already paid
11
+ for summarization. The handler only prunes the shared preparation by
12
+ reference and arms the in-flight marker, so it now returns nothing, with a
13
+ regression pin asserting no truthy object return.
14
+
15
+ ## 0.38.66 — Auditor reliability: eviction, mirrors, bounded waits (2026-09-19)
16
+
17
+ ### Dead-auditor eviction and identical-failure parking (audit-stuck batch)
18
+
19
+ Detached-auditor failures that prove a model ref unresolvable (`Model not
20
+ found` and synonyms) evict the ref from the candidate chain instead of
21
+ retrying it forever. `resume_goal` runs the recovery probe inline rather
22
+ than refusing while main-model recovery is pending. Three consecutive
23
+ identical infra failures park the claim `blocked` with a named chain and a
24
+ structured fingerprint instead of spinning. Parked-queue duplicates coalesce
25
+ instead of piling up, and queued retries drain rather than jam the list head.
26
+
27
+ ### Session-identity refusal fix (field 195237)
28
+
29
+ Subagent-session confusion is gone: foreign-context detection compares
30
+ session identity, not object identity, so a worker in the same session is
31
+ no longer refused as foreign.
32
+
33
+ ### Bounded agent load waits (field 153404)
34
+
35
+ Far-future agent `pause_goal kind="wait"` requests are clamped to one hour
36
+ with a visible notice instead of parking for 4+ hours unchecked.
37
+
38
+ ### Tweak keeps provenance raw (field 184316)
39
+
40
+ `/goal tweak` strips the procedural tail before extracting the verification
41
+ contract while keeping the raw user seed, so filings say what was done
42
+ without laundering what was asked.
43
+
44
+ ### Relentless provider retry within bounds (fields 200751/200754)
45
+
46
+ Provider failures keep hammering within the recovery envelope: both
47
+ error-brake callbacks anchor on the recovery episode key instead of matching
48
+ reason strings, so a self-park no longer disarms an armed retry. Genuine
49
+ user pauses and decision-picker stand-downs still stand down.
50
+
51
+ ### No mid-objective parking while recovery is armed
52
+
53
+ Agent `error`/`blocked` park requests are refused with routing text while
54
+ provider recovery is armed, and ledgered — the run must not stop
55
+ mid-objective unless it must. Decision waits, genuine blockers, and user
56
+ pauses are untouched.
57
+
58
+ ### Suspicious-goal close (field 032245)
59
+
60
+ A goal paused only by the suspicious-objective dispatch shield can now close
61
+ via `complete_goal`: the accept path verifies artifacts through the isolated
62
+ auditor and voids the queued repair on approval. User pauses and repair
63
+ cards (`repairTarget`) still refuse as before.
64
+
65
+ ### Standby pause for background agents (field 021655)
66
+
67
+ New structured `standby` pause kind for waits on background agents — the
68
+ card and status line say waiting (not blocked), and completion wakes
69
+ automatically. `blocked`/`error` rendering is unchanged.
70
+
71
+ ### Post-drafting stuck paths fixed
72
+
73
+ Persist-failed completion claims report error instead of fake pending;
74
+ auditor-retry waits are capped at 15 minutes; recovery waits name the agent
75
+ path (`resume_goal` retries now). After drafting, the loop no longer gets
76
+ stuck short of completion.
77
+
78
+ ### Auditor mirrors session extensions (field 151158)
79
+
80
+ The detached auditor inherits the session's extension packages by default
81
+ (tri-state `auditorMirrorSessionExtensions`, on unless explicitly off), so a
82
+ session-resolved auditor model ref no longer dies as `Model not found` in
83
+ the isolated worker. GLLA never mirrors itself; unresolvable specs drop
84
+ fail-closed; the read-only tool fence is unchanged.
85
+
86
+ ### Fallback chains offer the thinking pick (field 152226)
87
+
88
+ Saving a non-empty auditor fallback chain now offers the thinking-level pick
89
+ (the agnes-flash selection sticks), and typing the session model into the
90
+ fallback input names the slot-0 exclusion instead of silently clearing.
91
+ Clearing the chain skips the thinking prompt.
92
+
93
+ ### Goal-aware length continuation (PR #55, FOF11)
94
+
95
+ When an active goal's response is truncated at the output-token limit, the
96
+ continuation text first asks whether the goal is already satisfied — calling
97
+ `complete_goal` if so — instead of starting another blind work pass. Plain
98
+ sessions keep the generic text; marker and retry payload follow the text
99
+ actually sent.
100
+
3
101
  ## 0.38.62 — Whole-note verification aggregation (2026-09-17)
4
102
 
5
103
  ### Conservative result parsing
package/README.md CHANGED
@@ -421,7 +421,8 @@ Open `/glla` for the settings table. The most important choices are:
421
421
  unset, auditor thinking inherits the parent session dial (including `max`);
422
422
  - **Main-agent and auditor fallback models:** both roles use the same ordered,
423
423
  deselectable, bounded fallback-chain picker for provider failures; the
424
- auditor's session model remains the final last resort;
424
+ auditor's session model remains the final last resort. Saving a non-empty
425
+ auditor chain also offers the thinking-level pick so the selection sticks;
425
426
  - **Auto-resume:** whether persisted work may restart automatically after a
426
427
  session loads; explicit resume commands are always available;
427
428
  - **State root:** `workingDir` by default, opt-in `sessionDir`;
@@ -442,10 +443,12 @@ supervisor.
442
443
  ## Model and auditor requirements
443
444
 
444
445
  The main agent may use the model/provider you normally use in pi. The detached
445
- auditor starts a fresh extension-less pi process by default, so its selected
446
- model must authenticate and work without an extension-registered provider.
447
- Choose an auditor model in `/glla` if the session model depends on a provider
448
- extension.
446
+ auditor starts a fresh pi process that mirrors your session's extension
447
+ packages by default, so session-resolved models (including
448
+ provider-extension models) work there too; disable the mirror in `/glla`
449
+ settings if you want the fully isolated extension-less auditor. Choose an
450
+ auditor model in `/glla` if the session model depends on something the
451
+ worker cannot reach.
449
452
 
450
453
  The worker inherits normal pi provider configuration and resolves `pi` from
451
454
  `PATH`. If needed, set:
package/docs/INDEX.md CHANGED
@@ -16,7 +16,7 @@ Policy contracts and recent changes live in the `audit/` directory of the
16
16
  failback; v0.35.9 hardened cross-version npm tarball checks; v0.35.10
17
17
  handles multi-entry npm dry-run reports; v0.35.11 accepts both npm report
18
18
  shapes; v0.35.12 supports npm 12's keyed pack reports; v0.35.13 fixes stale-API recovery loops.
19
- v0.35.14–v0.38.62 continue through the supervisor freeze (`/glla pause`),
19
+ v0.35.14–v0.38.67 continue through the supervisor freeze (`/glla pause`),
20
20
  load hold, auditor picker parity, Windows launch fix, zombie-watchdog
21
21
  subagent carve-out, due-wait backstop, the `/glla agents` visibility panel,
22
22
  durable state-root selection, blank-until-resume auditor context, frozen
package/docs/SETTINGS.md CHANGED
@@ -50,8 +50,9 @@ copies are ignored (the recovery runtime reads the global file):
50
50
  | `blockForbiddenModelSwitches` | `true` | Revert forbidden switches (`false` = stand but ledger). |
51
51
  | `visionAssist` | `true` | Continuation prompts carry the prefer-native-vision directive. |
52
52
  | `auditorModel` | unset (session) | Detached auditor primary (`"provider/id"` or bare id). |
53
- | `auditorModelFallbacks` | `[]` | Ordered auditor fallbacks; session model is final. Global-only. |
53
+ | `auditorModelFallbacks` | `[]` | Ordered auditor fallbacks; session model is final. Saving a non-empty chain offers the thinking pick. Global-only. |
54
54
  | `auditorAllowedExtensions` | `[]` | Extension specs the detached auditor may load; resolved fail-closed, `[]` = isolated. |
55
+ | `auditorMirrorSessionExtensions` | `true` | Also load the session's own packages in the worker (GLLA itself never mirrored); `false` = curated allow-list only. |
55
56
  | `auditorSameSessionSwap` | `true` | Walk the fallback chain when the auditor is the session model. |
56
57
  | `auditorThinkingLevel` | unset (inherit) | Detached auditor reasoning level; picked with the model. |
57
58
  | `auditorToolTimeoutMs` | `300000` | Base budget per auditor tool call (30s–6h). Global-only. |
@@ -35,6 +35,7 @@
35
35
 
36
36
  import * as fs from "node:fs";
37
37
  import * as path from "node:path";
38
+ import { fileURLToPath } from "node:url";
38
39
 
39
40
  /** One extension the user could allow-list for the auditor. */
40
41
  export interface DiscoveredPiExtension {
@@ -185,6 +186,95 @@ export function resolveAuditorAllowedExtensions(
185
186
  return out;
186
187
  }
187
188
 
189
+ /** v0.38.65 (field 151158): repo root of THIS extension — the session
190
+ * mirror must never feed GLLA back into its own detached worker (an
191
+ * auditor loading the goal toolkit could re-dispatch goals). Exported
192
+ * for tests; the mirror filter is the real consumer. */
193
+ export function gllaRepoRoot(): string {
194
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
195
+ }
196
+
197
+ /** v0.38.65: true when a mirror candidate is GLLA itself — by package
198
+ * name (no filesystem touch; covers npm: specs and relative paths on
199
+ * machines where the install lives under a different path) or by
200
+ * resolved path inside this repo (covers relative-path specs pointing
201
+ * here under an unfamiliar spelling). */
202
+ export function isSelfMirrorSpec(raw: string, opts: { home: string; cwd?: string }): boolean {
203
+ const trimmed = raw.trim();
204
+ if (!trimmed) return false;
205
+ if (/pi-goal-list-loop-audit/i.test(labelForSpec(trimmed))) return true;
206
+ const root = gllaRepoRoot();
207
+ const bases = [path.join(opts.home, ".pi", "agent"), ...(opts.cwd ? [path.join(opts.cwd, ".pi")] : [])];
208
+ for (const base of bases) {
209
+ const resolved = resolveAuditorExtensionSpec(trimmed, { home: opts.home, cwd: opts.cwd, base });
210
+ if (resolved && (resolved === root || resolved.startsWith(root + path.sep))) return true;
211
+ }
212
+ return false;
213
+ }
214
+
215
+ /** v0.38.65 (field 151158): raw session package specs to mirror into the
216
+ * detached auditor — user settings packages[]/extensions[] first, then
217
+ * project settings. Raw strings, not resolved paths: the process layer
218
+ * resolves them fail-closed at dispatch, so an entry that vanishes
219
+ * between mirror and spawn simply drops out. GLLA itself is excluded.
220
+ * Read-only and best-effort like the rest of discovery: unreadable
221
+ * settings files contribute nothing. */
222
+ export function sessionMirrorExtensionSpecs(home: string, cwd?: string): string[] {
223
+ const out: string[] = [];
224
+ const seen = new Set<string>();
225
+ const push = (raw: string): void => {
226
+ const spec = raw.trim();
227
+ const key = spec.toLowerCase();
228
+ if (!spec || seen.has(key)) return;
229
+ if (isSelfMirrorSpec(spec, { home, cwd })) return;
230
+ seen.add(key);
231
+ out.push(spec);
232
+ };
233
+ for (const raw of readJsonArray(path.join(home, ".pi", "agent", "settings.json"))) push(raw);
234
+ if (cwd) {
235
+ for (const raw of readJsonArray(path.join(cwd, ".pi", "settings.json"))) push(raw);
236
+ }
237
+ return out;
238
+ }
239
+
240
+ /** v0.38.65: union the curated allowlist with the session mirror — user
241
+ * entries first (first spelling wins, case-insensitive), then mirrored
242
+ * session specs. Mirror off restores the pre-mirror behavior exactly:
243
+ * the curated allowlist alone, possibly empty (fully isolated). Bounded
244
+ * like the allowlist itself. */
245
+ export function mergeAuditorAllowedExtensions(
246
+ user: string[] | undefined,
247
+ mirror: string[],
248
+ mirrorEnabled: boolean,
249
+ ): string[] {
250
+ const base = normalizeAuditorAllowedExtensions(user);
251
+ if (!mirrorEnabled) return base;
252
+ const seen = new Set(base.map((entry) => entry.toLowerCase()));
253
+ const out = [...base];
254
+ for (const entry of normalizeAuditorAllowedExtensions(mirror)) {
255
+ if (seen.has(entry.toLowerCase())) continue;
256
+ seen.add(entry.toLowerCase());
257
+ out.push(entry);
258
+ }
259
+ return out;
260
+ }
261
+
262
+ /** v0.38.65 (field 151158): dispatch-time allowlist — the curated
263
+ * setting union the session package mirror, unless the user explicitly
264
+ * opted out (auditorMirrorSessionExtensions === false). Both dispatch
265
+ * paths (complete_goal launch + provider-retry re-seed) call this so
266
+ * provider parity between session and worker stays identical. */
267
+ export function dispatchAuditorAllowedExtensions(
268
+ user: string[] | undefined,
269
+ mirrorSetting: boolean | undefined,
270
+ home: string,
271
+ cwd?: string,
272
+ ): string[] {
273
+ const enabled = mirrorSetting !== false;
274
+ const mirror = enabled ? sessionMirrorExtensionSpecs(home, cwd) : [];
275
+ return mergeAuditorAllowedExtensions(user, mirror, enabled);
276
+ }
277
+
188
278
  /** Bounded, deterministic normalization of the allowlist value.
189
279
  * Audit 2026-09-06: dedup is case-insensitive like the model fallback
190
280
  * chains (npm names cannot differ by case; a same-case pair is always the
@@ -0,0 +1,16 @@
1
+ /** Resolve the auditor dial against each selected candidate, not just the primary.
2
+ * Old settings keep their meaning: unset inherits the session dial. Unsupported
3
+ * levels fall downwards; a non-reasoning model always gets off. */
4
+ export function resolveAuditorThinkingLevel(model: { reasoning?: boolean; thinkingLevelMap?: Record<string, string | null> } | undefined, requested: string): string {
5
+ const levels = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
6
+ if (!model?.reasoning) return 'off';
7
+ const ceiling = levels.indexOf(requested);
8
+ const start = ceiling < 0 ? levels.indexOf('high') : ceiling;
9
+ for (let index = start; index >= 0; index--) {
10
+ const level = levels[index]!;
11
+ const mapped = model.thinkingLevelMap?.[level];
12
+ if (mapped === null || ((level === 'xhigh' || level === 'max') && mapped === undefined)) continue;
13
+ return level;
14
+ }
15
+ return 'off';
16
+ }
@@ -1,7 +1,25 @@
1
- /** Observes a narrow, positively identified unfinished drafting handoff.
2
- * Silence and punctuation alone are never evidence that a user wait is
3
- * broken. The caller owns lifecycle-fenced, once-only dispatch.
1
+ /** Observes an unfinished drafting handoff: a list/goal/loop drafting
2
+ * turn that ends with commentary alone. The grill guidance is in effect
3
+ * for the whole episode, so every drafting turn must end with a presented
4
+ * picker, a stated next dependent question, or a concrete proposal — a
5
+ * stop turn with substantive question-less commentary strands the user
6
+ * (field 125335) exactly like the old dangling-intro shape it subsumes.
7
+ * Silence, punctuation, and short acks alone are never evidence that a
8
+ * user wait is broken. The caller owns lifecycle-fenced, once-only dispatch.
4
9
  */
10
+ const DRAFT_COMMENTARY_FLOOR = 40;
11
+ // A stated next dependent question is a legal drafting end — only
12
+ // commentary ALONE corrects. Keep tight: will-ask, next-question, and
13
+ // after-you-answer phrasing, never a bare "ask"/"question" mention
14
+ // (promising questions without stating the next one is the bug).
15
+ const STATED_NEXT_QUESTION_RE = new RegExp(
16
+ [
17
+ String.raw`\b(i[''’]ll|i will|let me)\b[^.!?]{0,100}\bask\b`,
18
+ String.raw`\bnext\b[^.!?]{0,60}\bquestions?\b`,
19
+ String.raw`\b(after|once)\b[^.!?]{0,60}\b(you (answer|reply)|your answer)\b`,
20
+ ].join("|"),
21
+ "i",
22
+ );
5
23
  export class DraftingHandoffObserver {
6
24
  private answered = false;
7
25
  private notified = false;
@@ -11,7 +29,12 @@ export class DraftingHandoffObserver {
11
29
 
12
30
  reset(): void {
13
31
  this.invalidate();
14
- this.answered = false;
32
+ // A fresh drafting episode opens armed: the grill guidance is in
33
+ // effect from turn one, so a commentary-only end strands the user
34
+ // with or without prior Q&A (field 125335 had none to rely on).
35
+ // Picker-outstanding, proposal-presented, and cancelled-picker turns
36
+ // still disarm via noteToolResult below.
37
+ this.answered = true;
15
38
  this.notified = false;
16
39
  }
17
40
 
@@ -27,11 +50,13 @@ export class DraftingHandoffObserver {
27
50
 
28
51
  observe(text: string, stopReason: string | undefined): boolean {
29
52
  if (!this.answered || this.notified || stopReason !== "stop") return false;
30
- const lastLine = text.trim().split("\n").at(-1)?.trim() ?? "";
31
- // Match the field screenshot's dangling introduction, not a real
32
- // question, imperative request, option list, or arbitrary short reply.
33
- if (!/^(?:two|three|four|[2-4]) (?:final |remaining )?(?:policy )?(?:choices|questions|decisions) (?:will|would) make (?:the |our )?(?:implementation )?(?:contract|scope|plan) concrete:\s*$/i.test(lastLine)) return false;
34
- if (text.includes("?")) return false;
53
+ const trimmed = text.trim();
54
+ // A real prose question waits without an autonomous retry.
55
+ if (trimmed.includes("?")) return false;
56
+ // Short acks are not substantive commentary.
57
+ if (trimmed.length < DRAFT_COMMENTARY_FLOOR) return false;
58
+ // A stated next dependent question ends the turn legally.
59
+ if (STATED_NEXT_QUESTION_RE.test(trimmed)) return false;
35
60
  this.notified = true;
36
61
  this.answered = false;
37
62
  return true;
@@ -8,6 +8,25 @@
8
8
 
9
9
  import type { Goal, ObjectiveRepairRecord } from "./goal-loop-core.js";
10
10
 
11
+ /** Prefix written by the dispatch guard when it parks a goal/list item
12
+ * whose objective prose tripped the fragment heuristics. Exported so the
13
+ * complete_goal close path can recognize exactly this pause shape without
14
+ * re-parsing prose. */
15
+ export const SUSPICIOUS_PAUSE_PREFIX = "Suspicious objective detected (";
16
+
17
+ /** A suspicious-objective pause is a dispatch shield, not a work stop:
18
+ * the objective prose looked like report garbage, so the worker model must
19
+ * not be dispatched — but a completion claim carries evidence to the
20
+ * isolated auditor, which verifies real artifacts. Callers use this to keep
21
+ * the close path open on exactly this pause shape. */
22
+ export function isSuspiciousObjectivePause(
23
+ goal: Pick<Goal, "pauseKind" | "pauseReason">,
24
+ ): boolean {
25
+ return goal.pauseKind === "blocked"
26
+ && typeof goal.pauseReason === "string"
27
+ && goal.pauseReason.startsWith(SUSPICIOUS_PAUSE_PREFIX);
28
+ }
29
+
11
30
  export type SuspiciousObjectiveReason =
12
31
  | "empty"
13
32
  | "archive-metadata"
@@ -14,7 +14,7 @@ import { renderAgentsPanel, tailChildTranscript, TRANSCRIPT_HEADER_SCAN_MAX_BYTE
14
14
  import { state, replaceState } from "./goal-state.js";
15
15
  import {
16
16
  DEFAULT_TOKEN_LIMIT, Goal, ListItem, Status, appendLedger, archiveDir, archivedGoalPath, bumpGoalRevision, sanitizeProviderDisplayText,
17
- computeListDepthFromLedger, clearQueueItemFiles, deleteQueueItemFile, deleteQueueItemFileResult, extractVerificationContract, formatAuditLog, formatGoalAuditHistory, formatMainModelRecoveryStatus, queueItemSidecarCount,
17
+ computeListDepthFromLedger, clearQueueItemFiles, deleteQueueItemFile, deleteQueueItemFileResult, extractVerificationContract, stripTweakProceduralTail, formatAuditLog, formatGoalAuditHistory, formatMainModelRecoveryStatus, queueItemSidecarCount,
18
18
  formatListDepth, goalArgsNeedDrafting, ledgerPath, newGoalId, nowIso, parseListImport, parseListItemDeclaration, readLedgerTail,
19
19
  assignQueueOrder, compareQueueItems, readAuditLog, readQueueFromDisk, routeGoalArgs, routeListText, sanitizeDisplayText, sanitizeProviderAuditReport, statusLabel,
20
20
  visibleListPosition, visibleListPositions,
@@ -231,6 +231,14 @@ async function cmdGoal(args: string, ctx: ExtensionContext): Promise<void> {
231
231
  ctx.ui.notify("An audit is already running…", "info");
232
232
  return;
233
233
  }
234
+ // Pending-claim lifecycle: a stored claim awaiting its verdict owns
235
+ // the goal surface. Verify reports the awaiting state instead of
236
+ // destroying the healthy claim and relaunching blindly — the
237
+ // in-process flag above only covers audits owned by this process.
238
+ if (state.goal.status === "auditing" && state.goal.pendingCompletion) {
239
+ ctx.ui.notify(`A completion audit is already awaiting its verdict — wait for it or ${activeGoalSurfaceCommand("cancel")} to discard the pending claim and start over.`, "info");
240
+ return;
241
+ }
234
242
  updateGoal({
235
243
  pendingCompletion: {
236
244
  completionSummary: "Manual audit requested by the user via /goal verify (no agent completion claim). Verify the objective against the repo directly.",
@@ -825,7 +833,12 @@ export async function cmdTweak(
825
833
  ctx.ui.notify(guidance, "info");
826
834
  return false;
827
835
  }
828
- const proposed = extractVerificationContract(adopted);
836
+ // Now 184316: strip the trailing procedural tail BEFORE contract
837
+ // extraction — a replacement ending in filing instructions plus an
838
+ // embedded command ("Ensure both objective and verification contract
839
+ // are updated, then /goal resume.") must file the said work, not the
840
+ // instructions about the filing. userSeeds below keeps the said words.
841
+ const proposed = extractVerificationContract(stripTweakProceduralTail(adopted));
829
842
  const newObjective = proposed.objective;
830
843
  if (!newObjective) {
831
844
  // (a bare input of "Done when: ..." only yields this — the replacement
@@ -1060,6 +1073,32 @@ export function hydrateListQueueFromDisk(ctx: ExtensionContext): number {
1060
1073
  return recovered.length;
1061
1074
  }
1062
1075
 
1076
+ /** v0.38.63 (audit-stuck batch, field 124536): while the head goal is
1077
+ * parked, re-adding an objective that is already queued — or duplicated
1078
+ * inside the same batch — coalesces instead of piling up. Pure; both add
1079
+ * funnels (direct + batch) call it with the live queue. */
1080
+ export function splitParkedQueueDuplicates(texts: string[], queuedObjectives: Array<string | undefined>): { fresh: string[]; coalesced: string[] } {
1081
+ const seen = new Set<string>();
1082
+ for (const objective of queuedObjectives) {
1083
+ if (typeof objective === "string" && objective.trim()) seen.add(normalizeObjective(objective));
1084
+ }
1085
+ const fresh: string[] = [];
1086
+ const coalesced: string[] = [];
1087
+ for (const text of texts) {
1088
+ let key = "";
1089
+ try {
1090
+ key = normalizeObjective(parseListItemDeclaration(text).objective);
1091
+ } catch { /* unparseable — the funnel below refuses it with its own copy */ }
1092
+ if (key && seen.has(key)) {
1093
+ coalesced.push(text);
1094
+ continue;
1095
+ }
1096
+ if (key) seen.add(key);
1097
+ fresh.push(text);
1098
+ }
1099
+ return { fresh, coalesced };
1100
+ }
1101
+
1063
1102
  function enqueueItems(ctx: ExtensionContext, texts: string[], source: string, opts?: { autoActivate?: boolean }): number {
1064
1103
  hydrateListQueueFromDisk(ctx);
1065
1104
  const recentlyDone = recentlyCompletedObjectives(ctx.cwd);
@@ -1071,7 +1110,19 @@ function enqueueItems(ctx: ExtensionContext, texts: string[], source: string, op
1071
1110
  ctx.ui.notify(`Skipped ${skipped} item(s) duplicating work COMPLETED in the last 24h (zombie-twin guard): ${first.slice(0, 90)}`, "warning");
1072
1111
  }
1073
1112
  if (fresh.length === 0) return 0;
1074
- const items = fresh.map((text) => {
1113
+ // v0.38.63 (audit-stuck batch, field 124536): while parked, duplicates
1114
+ // of waiting work coalesce — the queue must not grow without progress.
1115
+ let toQueue = fresh;
1116
+ if (state.goal?.status === "paused") {
1117
+ const split = splitParkedQueueDuplicates(fresh, listQueue().map((item) => item.objective));
1118
+ if (split.coalesced.length > 0) {
1119
+ appendLedger(ctx.cwd, "list_parked_duplicate_coalesced", { source, count: split.coalesced.length, objective: split.coalesced[0]!.slice(0, 200), queueDepth: listQueue().length });
1120
+ ctx.ui.notify(`Coalesced ${split.coalesced.length} item(s) already waiting behind the paused head — queue depth holds at ${listQueue().length}.`, "info");
1121
+ }
1122
+ toQueue = split.fresh;
1123
+ if (toQueue.length === 0) return 0;
1124
+ }
1125
+ const items = toQueue.map((text) => {
1075
1126
  const extracted = parseListItemDeclaration(text);
1076
1127
  return {
1077
1128
  id: newGoalId(),
@@ -1146,6 +1197,11 @@ function enqueueItems(ctx: ExtensionContext, texts: string[], source: string, op
1146
1197
  appendLedger(ctx.cwd, "list_queue_disk_first", { source, count: itemsToWrite.length, diskFirst });
1147
1198
  persistState(ctx);
1148
1199
  appendLedger(ctx.cwd, "list_imported", { source, count: itemsToWrite.length });
1200
+ if (state.goal?.status === "paused") {
1201
+ // v0.38.63 (audit-stuck batch, field 124536): a parked head queues
1202
+ // silently today — the pile-up grows invisibly. Name the jam + depth.
1203
+ ctx.ui.notify(`Queued ${itemsToWrite.length} item(s) behind the paused head (${listQueue().length} waiting) — ${state.goal.pauseSuggestedAction ?? "resume the head to continue"}.`, "info");
1204
+ }
1149
1205
  if (!state.goal || state.goal.status === "complete" || state.goal.status === "aborted") {
1150
1206
  // v0.28.28: unsolicited sources (the reviewer) do NOT auto-start the
1151
1207
  // head unless autoResume is on — "I cancelled a goal and the next one
@@ -1663,8 +1719,19 @@ async function cmdList(args: string, ctx: ExtensionContext): Promise<void> {
1663
1719
  }
1664
1720
 
1665
1721
  /** Append one objective to the list; activate immediately when idle. */
1722
+ /** v0.38.63 (audit-stuck batch, field 124536): parked-coalesce early-out
1723
+ * for the direct funnel — true when the add merged into waiting work. */
1724
+ function coalesceParkedDuplicate(ctx: ExtensionContext, raw: string): boolean {
1725
+ if (state.goal?.status !== "paused") return false;
1726
+ if (splitParkedQueueDuplicates([raw], listQueue().map((item) => item.objective)).coalesced.length === 0) return false;
1727
+ appendLedger(ctx.cwd, "list_parked_duplicate_coalesced", { source: "direct", count: 1, objective: raw.slice(0, 200), queueDepth: listQueue().length });
1728
+ ctx.ui.notify(`Already waiting behind the paused head — coalesced, queue depth holds at ${listQueue().length}.`, "info");
1729
+ return true;
1730
+ }
1731
+
1666
1732
  function addSingleItem(ctx: ExtensionContext, raw: string): void {
1667
1733
  hydrateListQueueFromDisk(ctx);
1734
+ if (coalesceParkedDuplicate(ctx, raw)) return;
1668
1735
  const extracted = parseListItemDeclaration(raw);
1669
1736
  const item = assignQueueOrder([{
1670
1737
  id: newGoalId(),
@@ -67,7 +67,7 @@ import {
67
67
  type ContinuationDispatch,
68
68
  } from "./goal-loop-dispatch.js";
69
69
  import { BACKOFF_IDLE_RETRY_MS, HEARTBEAT_MAX_NUDGES } from "./goal-loop-backoff.js";
70
- import { LENGTH_CONTINUE_MAX, LENGTH_CONTINUE_TEXT } from "./length-continue.js";
70
+ import { GOAL_LENGTH_CONTINUE_TEXT, LENGTH_CONTINUE_MAX, LENGTH_CONTINUE_TEXT } from "./length-continue.js";
71
71
 
72
72
  // v0.38.0: GLLA_MONITOR_INTERVAL_MS throttling is deprecated — scheduling is
73
73
  // event-driven (250ms→15s adaptive fallback) for every plane, including
@@ -86,7 +86,7 @@ import { readHandoffBriefExcerpt } from "./goal-compactor.js";
86
86
  import { loadSettings } from "./goal-settings.js";
87
87
  import { clearLoopTimer, isLoopActive } from "./goal-loop.js";
88
88
  import { attemptFreshSessionRecovery, mainModelRecoveryActive, recoverMainModelFromSendStorm } from "./goal-recovery.js";
89
- import { sendStormEscalateMs } from "./main-model-recovery.js";
89
+ import { isCompactionInFlightSince, sendStormEscalateMs } from "./main-model-recovery.js";
90
90
  import {
91
91
  appendObjectiveRepairRecord,
92
92
  applyObjectiveRepair,
@@ -95,6 +95,7 @@ import {
95
95
  buildRepairTaskObjective,
96
96
  deriveObjectiveRepair,
97
97
  hasQueuedObjectiveRepair,
98
+ SUSPICIOUS_PAUSE_PREFIX,
98
99
  } from "./faulty-objective-recovery.js";
99
100
 
100
101
  /** goal.ts-owned module lets the continuation cluster reads/writes through
@@ -130,8 +131,8 @@ export interface ContinuationFlags {
130
131
  get loopRearmMilestone(): number;
131
132
  set loopRearmMilestone(v: number);
132
133
  get completionAuditInFlight(): boolean;
134
+ get compactionInFlightSince(): number | null;
133
135
  }
134
-
135
136
  /** goal.ts functions/consts the continuation cluster calls. Wired by goal.ts
136
137
  * at factory time. */
137
138
  export interface ContinuationDeps {
@@ -410,8 +411,15 @@ export function accountSendRearm(ctx: ExtensionContext, kind: "continuation" | "
410
411
  }
411
412
  }
412
413
  if (elapsed >= sendStormEscalateMs() && Date.now() - flags.lastActivityAt >= SEND_REARM_ESCALATE_SILENT_MS) {
413
- if (kind === "continuation") { continuationRearmStreak = 0; continuationRearmSince = 0; } else { flags.loopRearmStreak = 0; flags.loopRearmSince = 0; }
414
- escalateSendRearmStorm(ctx, kind);
414
+ // In-flight auto-compaction is legitimate busy time (field 083546) —
415
+ // its silence must not read as a stuck send queue. Suppress the
416
+ // escalation (and its recovery park) until the post-compact settle.
417
+ if (isCompactionInFlightSince(flags.compactionInFlightSince)) {
418
+ appendLedger(ctx.cwd, "send_rearm_escalated_suppressed", { reason: "compaction-in-flight", kind, streak, elapsedMinutes: Math.round(elapsed / 60000) });
419
+ } else {
420
+ if (kind === "continuation") { continuationRearmStreak = 0; continuationRearmSince = 0; } else { flags.loopRearmStreak = 0; flags.loopRearmSince = 0; }
421
+ escalateSendRearmStorm(ctx, kind);
422
+ }
415
423
  }
416
424
  }
417
425
 
@@ -946,9 +954,14 @@ export function guardGoalBeforeContinuation(
946
954
  ctx: ExtensionContext,
947
955
  where: string,
948
956
  expectedGoalId?: string,
949
- options: { allowAuditing?: boolean } = {},
957
+ options: { allowAuditing?: boolean; allowSuspiciousClose?: boolean } = {},
950
958
  ): boolean {
951
959
  const allowAuditing = options.allowAuditing === true;
960
+ // v0.38.63 (field 032245): a completion-claim settlement never dispatches
961
+ // the objective prose to a worker — the isolated auditor verifies real
962
+ // artifacts. allowSuspiciousClose skips ONLY the suspicious-objective
963
+ // pause branch below; every other fence (terminal, archive, stale,
964
+ // replan) still applies.
952
965
  const goal = state.goal;
953
966
  if (!goal) return false;
954
967
  if (expectedGoalId && goal.id !== expectedGoalId) {
@@ -1024,6 +1037,15 @@ export function guardGoalBeforeContinuation(
1024
1037
 
1025
1038
  const assessment = assessSuspiciousObjective(goal.objective, goal.verificationContract);
1026
1039
  if (!assessment.suspicious) return true;
1040
+ if (options.allowSuspiciousClose === true) {
1041
+ appendLedger(ctx.cwd, "faulty_objective_suspicious_close_allowed", {
1042
+ goalId: goal.id,
1043
+ where,
1044
+ reasons: [...assessment.reasons],
1045
+ note: "completion-claim settlement verifies artifacts, never dispatches the objective; suspicious-objective pause skipped",
1046
+ });
1047
+ return true;
1048
+ }
1027
1049
  // v0.35.31 (field: Screenshot_20260822_193744): an EXPLICIT `/goal start
1028
1050
  // <text>` is user intent. The fragment heuristics (dangling-fragment,
1029
1051
  // lowercase-fragment, …) exist to stop AGENT-authored report garbage from
@@ -1080,7 +1102,7 @@ export function guardGoalBeforeContinuation(
1080
1102
  }
1081
1103
  goal.status = "paused";
1082
1104
  goal.pauseKind = "blocked";
1083
- goal.pauseReason = `Suspicious objective detected (${assessment.reasons.join(", ")}).`;
1105
+ goal.pauseReason = `${SUSPICIOUS_PAUSE_PREFIX}${assessment.reasons.join(", ")}).`;
1084
1106
  goal.pauseSuggestedAction = `${activeGoalSurfaceCommand("tweak")} the objective if needed; /list next starts the preserved repair/replan task, then resume the original target after its confirmed task list is accepted.`;
1085
1107
  goal.pauseResumeAt = undefined;
1086
1108
  goal.updatedAt = nowIso();
@@ -1099,6 +1121,14 @@ export function scheduleContinuation(ctx: ExtensionContext, force = false, delay
1099
1121
  if (mainModelRecoveryActive()) return;
1100
1122
  if (flags.sessionHandoffPending || flags.initialSessionLoadPending || flags.extensionApiStale || flags.staleTerminalDone || flags.zombieStoodDown) return;
1101
1123
  if (pendingContinuationDispatch) return;
1124
+ // In-flight auto-compaction: never dispatch into a compacting host
1125
+ // (field 083546). force (explicit user intent) still bypasses — the
1126
+ // user is alive and the host answered. The post-compact settle owns
1127
+ // the single bounded resume probe.
1128
+ if (!force && isCompactionInFlightSince(flags.compactionInFlightSince)) {
1129
+ appendLedger(ctx.cwd, "continuation_dispatch_deferred_compaction", { goalId: state.goal?.id ?? null });
1130
+ return;
1131
+ }
1102
1132
  if (continuationDispatchStoodDown && !force) return;
1103
1133
  if (force) releaseContinuationDispatchStandDown();
1104
1134
  if (!isActionableGoal()) return;
@@ -1436,21 +1466,25 @@ export function sendLengthContinue(ctx: ExtensionContext, consecutive: number):
1436
1466
  // chain — same abort-latch reasoning as sendContinuation.
1437
1467
  if (flags.sessionHandoffPending || flags.initialSessionLoadPending || !flags.extensionApi || flags.extensionApiStale || continuationDispatchStoodDown || pendingContinuationDispatch || flags.abortedStandDown) return;
1438
1468
  if (state.goal && !guardGoalBeforeContinuation(ctx, "length-continuation")) return;
1469
+ // v0.38.66 (PR #55, FOF11): active goals get completion-aware recovery
1470
+ // text — a truncated turn must offer closure, not another blind work
1471
+ // pass. Plain sessions keep the generic text.
1472
+ const content = state.goal?.status === "active" ? GOAL_LENGTH_CONTINUE_TEXT : LENGTH_CONTINUE_TEXT;
1439
1473
  const attempt = dispatchPrepare(ctx, {
1440
1474
  generation: flags.sessionGeneration,
1441
1475
  ownerSessionId: sessionManagerId(ctx),
1442
1476
  kind: "length",
1443
- marker: LENGTH_CONTINUE_TEXT.slice(0, 80),
1477
+ marker: content.slice(0, 80),
1444
1478
  resync: false,
1445
1479
  });
1446
1480
  if (!attempt) return;
1447
1481
  try {
1448
1482
  flags.extensionApi.sendMessage({
1449
1483
  customType: GOAL_EVENT_ENTRY,
1450
- content: LENGTH_CONTINUE_TEXT,
1484
+ content,
1451
1485
  display: true,
1452
1486
  }, { triggerTurn: true, deliverAs: "followUp" });
1453
- lastContinuationSentPayload = { content: LENGTH_CONTINUE_TEXT, display: true }; // v0.34.88: verbatim retry payload
1487
+ lastContinuationSentPayload = { content, display: true }; // v0.34.88: verbatim retry payload
1454
1488
  if (!dispatchAccepted(ctx, attempt)) return;
1455
1489
  appendLedger(ctx.cwd, "length_continue_sent", { consecutive, attemptId: attempt.id });
1456
1490
  ctx.ui.notify(`Response hit the output-token cap — auto-continuing (${consecutive}/${LENGTH_CONTINUE_MAX})`, "warning");
@@ -33,6 +33,7 @@ import {
33
33
  type Goal,
34
34
  } from "./goal-loop-core.js";
35
35
  import { loadSettings } from "./goal-settings.js";
36
+ import { isCompactionInFlightSince } from "./main-model-recovery.js";
36
37
  import {
37
38
  HEARTBEAT_STALL_MS,
38
39
  PENDING_LATCH_STUCK_MS,
@@ -67,6 +68,7 @@ export interface HeartbeatFlags {
67
68
  get sessionHandoffPending(): boolean;
68
69
  set sessionHandoffPending(v: boolean);
69
70
  get compactionGraceUntil(): number;
71
+ get compactionInFlightSince(): number | null;
70
72
  get continuationDispatchStoodDown(): boolean;
71
73
  get pendingContinuationDispatch(): ContinuationDispatch | null;
72
74
  get postCompactResumeOwed(): boolean;
@@ -1317,6 +1319,10 @@ function heartbeatTick(): void {
1317
1319
  // machinery below stays quiet for 3 minutes while the replaced session
1318
1320
  // settles (latch watchdog, wedge alert, refire counting all resume after).
1319
1321
  if (Date.now() < flags.compactionGraceUntil) return;
1322
+ // In-flight auto-compaction is legitimate busy time (field 083546) —
1323
+ // the whole stall/refire/watchdog machinery below stays quiet while
1324
+ // the marker is armed, same as the post-compact grace above.
1325
+ if (isCompactionInFlightSince(flags.compactionInFlightSince)) return;
1320
1326
  // v0.34.24: an accepted dispatch with no start proof owns the watchdog
1321
1327
  // until its bounded timeout. Do not let the generic heartbeat create a
1322
1328
  // second blind send underneath it; explicit resume or a fresh session