pi-goal-list-loop-audit 0.38.55 → 0.38.56

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,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.38.56 — Refine-and-resume loops, chatter-resolving tweaks (2026-09-16)
4
+
5
+ ### Context-inferred tweak objectives
6
+
7
+ `/goal tweak`, `/list tweak`, and the bare-tweak dialog resolve chatter
8
+ against the session context instead of adopting it (field 2026-09-16 —
9
+ the VidPro ledger recorded `objective:"ok adjsut it"`). Self-contained
10
+ text still adopts verbatim; an acknowledgment (`go`, `ok adjust it`)
11
+ resolves via the discussed context and the confirm shows
12
+ CURRENT → INFERRED; with nothing clear discussed, a guidance message
13
+ names the shape and nothing changes. An explicit `Done when:` contract
14
+ stays verbatim — explicit intent wins. Raw chatter never reaches the
15
+ ledger on any adoption path (including the plain-`/goal` conflict
16
+ update, which funnels through the same resolver).
17
+
18
+ ### Loop-sweep refinability
19
+
20
+ `/loop refine` works on resumable-held loops (field 2026-09-16 loop
21
+ sweep): the hint queues, then the byte-identical resume path runs —
22
+ refine-and-resume (owner choice), with wrong-branch and active-goal
23
+ guards keeping their voices so the loop stays held when they fire. Dead
24
+ loops stay refused with the honest path (`/loop start` for ended runs,
25
+ the agent `propose_loop_refine` repair for broken measures — no hint is
26
+ queued onto a loop that can never run again). The agent tool predicate
27
+ accepts parked loops too, so both surfaces agree (parity matrix pinned;
28
+ broken-measure stays tool-only by design). `stop`/`pause`/`finish`/
29
+ `status`/`resume` already handled held loops gracefully — sweep found no
30
+ further dead spots.
31
+
32
+ ### Paused-goal tweak
33
+
34
+ `/goal tweak` accepts a paused goal instead of stranding it behind a
35
+ resume round-trip (field 2026-09-16, the VidPro park: tweak bounced
36
+ with "No active goal to tweak" while the goal sat paused). Mirrors
37
+ `/list tweak`, which is paused-only: the tweak applies, the goal stays
38
+ paused with its pause reason intact, nothing is scheduled, and the
39
+ result points at `/goal resume`. The nothing-to-tweak refusal now reads
40
+ "No goal to tweak. /goal <objective> to start one."
41
+
42
+ ### Structured-long summaries
43
+
44
+ A section-structured `Outcome` (2+ `##`–`####` headers, tables) now
45
+ renders in full as the card's `### Summary` section — a working document
46
+ is no longer truncated into a status ping (field 2026-09-16, the
47
+ keyword-review close). The card answers two questions, informative over
48
+ long: Summary carries what happened plus the horizon (slices 2–3),
49
+ `Next` still names only the single immediate move (slice 1). Structure
50
+ is the price of length: unstructured values keep today's budgets and
51
+ the headline echo stays short. Delivery-only (terminal card +
52
+ archive human layer) with a 100k pathological guard pointing at the
53
+ archive; headline, verification, recap, and every recycled per-tick
54
+ payload stay bounded.
55
+
3
56
  ## 0.38.55 — Full-parity terminal card, lenient auditor, agent resume (2026-09-16)
4
57
 
5
58
  ### Full-parity terminal card
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.55 continue through the supervisor freeze (`/glla pause`),
19
+ v0.35.14–v0.38.56 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
@@ -289,6 +289,83 @@ export const RICH_OBJECTIVE_ECHO_CHARS = 80;
289
289
  export const RICH_TABLE_GROUP_THRESHOLD = 4;
290
290
  /** v0.38.50: evidence tokens per finding cell — density, not a dump. */
291
291
  export const RICH_EVIDENCE_TOKENS_PER_FINDING = 4;
292
+ /** Structured-long doctrine (field 2026-09-16 — the keyword-review close):
293
+ * a section-structured label value is a working document, not a status
294
+ * ping, and truncating it destroys its purpose. Structure is the price
295
+ * of length: at least this many `##`–`####` section headers keeps the
296
+ * value on the structured path; anything else keeps today's budgets.
297
+ * Delivery-only: the terminal card and the archive human layer. Recycled
298
+ * per-tick payloads (continuation, handoff, checkpoint) stay bounded. */
299
+ export const RICH_STRUCTURED_MIN_HEADERS = 2;
300
+ /** Structured-long guard against pathological megabytes: the tool call
301
+ * carries no length bound, so the structured path still ends — with an
302
+ * honest truncation note pointing at the archive, never silent soup. */
303
+ export const RICH_STRUCTURED_VALUE_BUDGET = 100_000;
304
+
305
+ /** Count markdown section headers (`##`–`####` with text) in raw text.
306
+ * H1 (`# `) is a document title, not a section — it does not count. */
307
+ export function countSectionHeaders(text: string): number {
308
+ const matches = (text ?? "").match(/^#{2,4}\s+\S/gm);
309
+ return matches ? matches.length : 0;
310
+ }
311
+
312
+ export function isSectionStructured(text: string): boolean {
313
+ return countSectionHeaders(text) >= RICH_STRUCTURED_MIN_HEADERS;
314
+ }
315
+
316
+ const STRUCTURED_LABELS = ["Outcome:", "Changed:", "Evidence:", "Tests:", "Unresolved:", "Next:"];
317
+
318
+ /** Raw (newline-preserving) label value for the structured path. Labels
319
+ * anchor to line starts and the last restatement wins — the same
320
+ * doctrine as labelPositions on the flattened path (v0.38.45). Residual
321
+ * ambiguity (a structured doc quoting `Changed:` at a line start) cuts
322
+ * the value early and the detector usually falls back to clipped
323
+ * rendering — doubt resolves to today's behavior, never to invention. */
324
+ export function rawLabelValue(text: string, label: string): string | null {
325
+ const source = text ?? "";
326
+ if (!source) return null;
327
+ const openings: number[] = [];
328
+ const openPattern = new RegExp(`^${label}`, "gim");
329
+ for (const match of source.matchAll(openPattern)) openings.push(match.index ?? 0);
330
+ if (openings.length === 0) return null;
331
+ const start = openings[openings.length - 1]! + label.length;
332
+ const restPattern = new RegExp(`^(${STRUCTURED_LABELS.filter((entry) => entry !== label).join("|")})`, "gim");
333
+ restPattern.lastIndex = start;
334
+ const next = restPattern.exec(source);
335
+ const end = next ? (next.index ?? source.length) : source.length;
336
+ const value = source.slice(start, end).trim();
337
+ return value || null;
338
+ }
339
+
340
+ /** Machine-token strip for structured lines. chatSafeDetailValue also
341
+ * collapses multi-space runs, which would damage markdown table
342
+ * alignment padding — structured lines keep their spacing and drop
343
+ * only the archive-only tokens. */
344
+ function stripMachineTokens(line: string): string {
345
+ return line
346
+ .replace(/(?:\/var)?\/tmp\/\S+/g, "")
347
+ .replace(/\S+\.tgz\b/g, "")
348
+ .replace(/\(\s*\)/g, "")
349
+ .replace(/\s+$/u, "");
350
+ }
351
+
352
+ /** Full Outcome body for the `### Summary` card section, or null when
353
+ * the value is not section-structured (caller keeps today's budgets).
354
+ * Newlines and tables survive; machine paths stay archive-only; the
355
+ * pathological guard ends with an honest pointer, never silent soup. */
356
+ export function structuredSummaryLines(summary: string | undefined, label = "Outcome:"): string[] | null {
357
+ const raw = rawLabelValue(summary ?? "", label);
358
+ if (!raw || !isSectionStructured(raw)) return null;
359
+ const lines = raw.split("\n").map(stripMachineTokens);
360
+ while (lines.length > 0 && !(lines[0] ?? "").trim()) lines.shift();
361
+ while (lines.length > 0 && !(lines[lines.length - 1] ?? "").trim()) lines.pop();
362
+ let text = lines.join("\n");
363
+ const units = [...text];
364
+ if (units.length > RICH_STRUCTURED_VALUE_BUDGET) {
365
+ text = `${units.slice(0, RICH_STRUCTURED_VALUE_BUDGET).join("")}\n\n… (truncated for chat — full text in the archived record.)`;
366
+ }
367
+ return text.split("\n");
368
+ }
292
369
 
293
370
  export interface RichTerminalParts {
294
371
  /** v0.38.55 (full parity): verdict banner — `## Done — auditor
@@ -303,6 +380,11 @@ export interface RichTerminalParts {
303
380
  /** v0.38.55 (full parity): final repository state section lines.
304
381
  * Empty when the state could not be read (absent stays absent). */
305
382
  repoLines: string[];
383
+ /** Structured-long body (field 2026-09-16): full Outcome text with
384
+ * newlines preserved, rendered as `### Summary`. Empty unless the
385
+ * value earned the structured path — never a second copy of the
386
+ * clipped headline echo. */
387
+ summaryLines: string[];
306
388
  }
307
389
 
308
390
  function escapeTableCell(value: string): string {
@@ -472,6 +554,10 @@ export function buildRichTerminalParts(args: {
472
554
  * tree cleanliness) from the render call site, which owns cwd access.
473
555
  * Absent keeps the section out — never invented. */
474
556
  repoState?: string[];
557
+ /** Structured-long body lines from structuredSummaryLines. Absent
558
+ * keeps the `### Summary` section out (unstructured summaries keep
559
+ * today's headline-echo shape). */
560
+ summaryLines?: string[];
475
561
  }): RichTerminalParts {
476
562
  const { findings, tests, next } = partitionRichDetails(args.details);
477
563
  const outcome = args.outcome;
@@ -573,6 +659,7 @@ export function buildRichTerminalParts(args: {
573
659
  tableLines,
574
660
  nextLines,
575
661
  repoLines: args.repoState ?? [],
662
+ summaryLines: args.summaryLines ?? [],
576
663
  };
577
664
  }
578
665
 
@@ -587,6 +674,14 @@ export function composeRichTerminalLines(parts: RichTerminalParts): string[] {
587
674
  const lines = [parts.banner ?? parts.headline, ""];
588
675
  if (parts.headline !== lines[0]) lines.push(parts.headline, "");
589
676
  if (parts.durationLine) lines.push(parts.durationLine, "");
677
+ // Structured-long (field 2026-09-16): the full Outcome body rides its
678
+ // own section between the headline and the findings — findings-first
679
+ // order is preserved (findings, verification, Next keep their relative
680
+ // order), the headline echo stays short, and the one-action Next rule
681
+ // is untouched.
682
+ if (parts.summaryLines.length > 0) {
683
+ lines.push("### Summary", ...parts.summaryLines, "");
684
+ }
590
685
  if (parts.findingLines.length > 0) {
591
686
  lines.push("### Key Findings & Remediation", ...parts.findingLines, "");
592
687
  }
@@ -611,6 +706,7 @@ export function buildRichArchiveSection(goal: Goal, status: Status, archivePath:
611
706
  const facts: CompletionSummaryFacts = { goal, status, archivePath };
612
707
  const summary = resolveCompletionSummary(facts, goal.completionSummary).summary;
613
708
  const brief = humanCompletionBrief(summary, 140, RICH_FULL_VALUE_BUDGET);
709
+ const structured = structuredSummaryLines(summary);
614
710
  const history = goal.auditHistory ?? [];
615
711
  const latest = history[history.length - 1];
616
712
  const approval = latest
@@ -628,6 +724,7 @@ export function buildRichArchiveSection(goal: Goal, status: Status, archivePath:
628
724
  gates: gateRows,
629
725
  // v0.38.55: aborted records never wear a Done banner/headline.
630
726
  kind: status === "complete" ? "Done" : "Aborted",
727
+ ...(structured ? { summaryLines: structured } : {}),
631
728
  });
632
729
  return [
633
730
  ...composeRichTerminalLines(parts),
@@ -778,6 +875,11 @@ export function buildTerminalApprovalRender(input: TerminalApprovalRenderInput):
778
875
  140,
779
876
  RICH_FULL_VALUE_BUDGET,
780
877
  );
878
+ // Structured-long (field 2026-09-16): a section-structured Outcome
879
+ // earns the full `### Summary` section on the terminal card (and the
880
+ // archive human layer). Headline echo, verification, Next, recap, and
881
+ // every recycled payload keep their bounds.
882
+ const structured = structuredSummaryLines(resolveCompletionSummary(facts, candidate).summary);
781
883
  // v0.38.37 (audit 2026-09-08): the deliberate non-do comes from the
782
884
  // agent's complete_goal leftOut claim — never invented. Filler ("none")
783
885
  // drops via the same briefValueContent filter; absent stays absent.
@@ -821,6 +923,7 @@ export function buildTerminalApprovalRender(input: TerminalApprovalRenderInput):
821
923
  groups: input.findingGroups,
822
924
  gates: input.gateRows,
823
925
  ...(input.repoState ? { repoState: input.repoState } : {}),
926
+ ...(structured ? { summaryLines: structured } : {}),
824
927
  });
825
928
  const chatBody = composeRichTerminalLines(richParts);
826
929
  const transcriptBody = composeRichTerminalLines(richParts);
@@ -39,7 +39,7 @@ import { formatGllaVersion } from "./glla-version.js";
39
39
  import { cmdGllaOwner, cmdGllaTakeover } from "./state-root-owner.js";
40
40
  import { AUDIT_JOB_CLEANUP_MIN_AGE_MS, cancelDetachedGoalCompletionAuditor, cleanupDeadAuditJobs, inspectAuditJobHealth, DEFAULT_AUDITOR_STALL_MS, DEFAULT_AUDITOR_TOOL_TIMEOUT_MS } from "./goal-loop-auditor-process.js";
41
41
  import { releaseAuditorSurface } from "./loops/goal-auditor-surface.js";
42
- import { inferStartFromSession, type StartContextInference } from "./start-context.js";
42
+ import { inferStartFromSession, resolveTweakReplacement, type StartContextInference } from "./start-context.js";
43
43
 
44
44
  /** Child pi sessions live under the shared session store, munged by cwd
45
45
  * (verified layout: ~/.pi/agent/sessions/--home-user-proj--/*.jsonl). */
@@ -755,11 +755,17 @@ export async function cmdTweak(
755
755
  && current?.policy === "list"
756
756
  && (current.status === "active" || current.status === "auditing");
757
757
  const expectedStatus = mode === "list" ? "paused" : "active";
758
- if (!current || (current.status !== expectedStatus && !liveListConflict) || (mode === "list" && current.policy !== "list")) {
758
+ // Field 2026-09-16 (the VidPro park): /goal tweak accepts a paused goal
759
+ // instead of stranding it behind a resume round-trip. Mirrors /list
760
+ // tweak, which is paused-only and stays paused.
761
+ const tweakable = mode === "list"
762
+ ? current?.status === expectedStatus
763
+ : current?.status === "active" || current?.status === "paused";
764
+ if (!current || (!tweakable && !liveListConflict) || (mode === "list" && current.policy !== "list")) {
759
765
  ctx.ui.notify(
760
766
  mode === "list"
761
767
  ? "No paused list item to tweak. /list tweak <replacement objective, optional 'Done when: ...' clause>"
762
- : "No active goal to tweak. /goal <objective> to start one.",
768
+ : "No goal to tweak. /goal <objective> to start one.",
763
769
  "info",
764
770
  );
765
771
  return false;
@@ -797,7 +803,29 @@ export async function cmdTweak(
797
803
  }
798
804
  raw = v.trim();
799
805
  }
800
- const proposed = extractVerificationContract(raw);
806
+ // Field 2026-09-16 (VidPro ledger — `/goal tweak` adopted "ok adjsut it"
807
+ // as the objective): chatter is a pointer at the session context, not a
808
+ // replacement. Resolve it against what was actually discussed; guide when
809
+ // nothing clear was discussed. Raw chatter never reaches the ledger.
810
+ // This sits in cmdTweak because all adoption paths funnel here: /goal
811
+ // tweak, /list tweak, and the plain-/goal "Update current objective"
812
+ // conflict choice (via updateWholeObjectiveFromConflict).
813
+ const replacement = resolveTweakReplacement(raw, ctx.sessionManager);
814
+ let adopted = raw;
815
+ let inferredNote = "";
816
+ if (replacement.kind === "inferred") {
817
+ adopted = replacement.text;
818
+ inferredNote = ` (inferred from ${replacement.source === "recent-context" ? "recent discussion" : "your message"} — yours read "${displaySlice(raw, 80)}"; cancel and re-run with exact wording if this is wrong)`;
819
+ } else if (replacement.kind === "unresolvable") {
820
+ const surface = mode === "list" ? "list item" : "goal";
821
+ let guidance = `That reads as an acknowledgment rather than a replacement ("${displaySlice(raw, 80)}") — nothing changed. The ${surface} keeps: ${displaySlice(current.objective, 120)}. Say what it should become as full replacement text.`;
822
+ if (replacement.reason === "ambiguous" && replacement.candidates.length > 0) {
823
+ guidance += ` It could mean: ${replacement.candidates.slice(0, 2).map((c) => `"${displaySlice(c, 100)}"`).join(" / ")} — re-run with one in full.`;
824
+ }
825
+ ctx.ui.notify(guidance, "info");
826
+ return false;
827
+ }
828
+ const proposed = extractVerificationContract(adopted);
801
829
  const newObjective = proposed.objective;
802
830
  if (!newObjective) {
803
831
  // (a bare input of "Done when: ..." only yields this — the replacement
@@ -833,7 +861,7 @@ export async function cmdTweak(
833
861
  try {
834
862
  confirmed = await ctx.ui.confirm(
835
863
  mode === "list" ? "Tweak list item?" : "Tweak goal?",
836
- `CURRENT:\n${sanitizeDisplayText(current.objective)}\n\nNEW:\n${sanitizeDisplayText(newObjective)}` +
864
+ `CURRENT:\n${sanitizeDisplayText(current.objective)}\n\nNEW${inferredNote}:\n${sanitizeDisplayText(newObjective)}` +
837
865
  (hasNewContract
838
866
  ? `\n\nNew contract:\n${sanitizeDisplayText(proposed.verificationContract)}`
839
867
  : clearsContract
@@ -862,7 +890,7 @@ export async function cmdTweak(
862
890
  return false;
863
891
  }
864
892
  const priorProvenance = latest.objectiveProvenance;
865
- const userSeeds = [...(priorProvenance?.userSeeds ?? []), raw].slice(-10);
893
+ const userSeeds = [...(priorProvenance?.userSeeds ?? []), adopted].slice(-10);
866
894
  const replacingLiveList = liveListConflict
867
895
  && latest.policy === "list"
868
896
  && (latest.status === "active" || latest.status === "auditing");
@@ -921,6 +949,14 @@ export async function cmdTweak(
921
949
  ctx.ui.notify("List item tweaked; it remains paused. /list resume to continue.", "info");
922
950
  return true;
923
951
  }
952
+ // A paused goal stays paused — the tweak must not wake the loop (field
953
+ // 2026-09-16: the VidPro park re-parked on every wake, which is the
954
+ // hamster wheel in the report). The user resumes explicitly when the
955
+ // blocker clears.
956
+ if (latest.status === "paused") {
957
+ ctx.ui.notify("Goal tweaked; it remains paused. /goal resume to continue.", "info");
958
+ return true;
959
+ }
924
960
  ctx.ui.notify("Goal tweaked. The loop continues against the new objective.", "info");
925
961
  scheduleContinuation(ctx, true);
926
962
  return true;
@@ -57,7 +57,12 @@ export function isLifecycleHeldLoopReason(reason?: string): boolean {
57
57
  /** A stopped loop can be respecified without discarding its history when the
58
58
  * stop is a recoverable work failure or an explicit time/token window. Max
59
59
  * iterations and clean/user stops remain terminal until a fresh `/loop start`.
60
- */
60
+ * Field 2026-09-16 (loop sweep): parked loops (user pause, restore-hold,
61
+ * zero-stream abort, user-stop-with-em-dash) are refinable too — parity with
62
+ * the slash-side RESUMABLE_STOP so both surfaces agree on what can be
63
+ * rescoped. `measure command broken` stays tool-only by design: the agent
64
+ * fixes the measure here, while slash resume must not restart a broken
65
+ * loop until it is fixed. */
61
66
  export function isRefinableStoppedLoopReason(reason?: string): boolean {
62
67
  return !!reason && (
63
68
  reason.startsWith("time bound reached")
@@ -68,6 +73,10 @@ export function isRefinableStoppedLoopReason(reason?: string): boolean {
68
73
  || reason.startsWith("measure command broken —")
69
74
  || reason.startsWith("provider errors —")
70
75
  || reason.startsWith("stalled:")
76
+ || reason === HELD_ON_RESTORE
77
+ || reason.startsWith("paused by user (/loop pause)")
78
+ || reason.startsWith("stopped: automatic zero-stream abort")
79
+ || reason.startsWith("stopped by user —")
71
80
  );
72
81
  }
73
82
 
@@ -215,6 +215,46 @@ function isLoopActive(): boolean {
215
215
  return !!state.loop?.active;
216
216
  }
217
217
 
218
+ // Resumable-stop predicate, hoisted to module scope (field 2026-09-16,
219
+ // loop sweep): /loop resume and /loop refine share it so a held loop is
220
+ // refinable wherever it is resumable.
221
+ // v0.29.20: plain plateau stops are resumable too — pre-gate plateaus
222
+ // could be false (hegemon/polis stopped 2026-07-31 with open findings
223
+ // on 429-dead turns), and an explicit resume is the user's call; the
224
+ // v0.29.19 gate + re-armed counters make the resumed run honest.
225
+ export const RESUMABLE_STOP = (r?: string): boolean =>
226
+ r === HELD_ON_RESTORE ||
227
+ !!r?.startsWith("provider errors —") ||
228
+ !!r?.startsWith("stopped by user —") ||
229
+ !!r?.startsWith("plateau —") ||
230
+ !!r?.startsWith("stalled:") ||
231
+ !!r?.startsWith("stuck —") ||
232
+ !!r?.startsWith("time bound reached") ||
233
+ !!r?.startsWith("token budget exhausted") ||
234
+ // v0.35.54 (collect-pass HIGH finding): the v0.35.31 "metric never
235
+ // moved" stop message promises "/loop resume retries or /loop stop",
236
+ // but this predicate never matched that prefix — the promised command
237
+ // answered "No held loop to resume" and, with propose_loop_refine
238
+ // gated on an ACTIVE loop, the only recovery was /loop stop + a fresh
239
+ // start discarding iteration history. Same class as the v0.35.25
240
+ // issue-#14 zombie prefix (fixed there, missed for this brand-new
241
+ // prefix). Resuming re-arms the counters; if the metric is still dead
242
+ // it re-stops loudly after its window — and a measure-changing
243
+ // propose_loop_refine (usable again once resumed) re-scopes the era so
244
+ // the never-moved grace re-arms.
245
+ !!r?.startsWith("metric never moved —") ||
246
+ // v0.35.25 (issue #14): abortZombieRun parks with
247
+ // "stopped: automatic zero-stream abort — … (/loop resume to retry)"
248
+ // and its user-facing message PROMISES that resume command. The
249
+ // predicate it lands in never matched the prefix, so /loop resume
250
+ // answered "No held loop to resume" and the preserved iteration,
251
+ // best value, and history were unreachable without re-drafting.
252
+ !!r?.startsWith("stopped: automatic zero-stream abort") ||
253
+ // v0.38.27 (ported from Bjynt's PR #46): /loop pause is a soft-hold
254
+ // parallel to /goal pause. The loop stays resumable; finishLoopGit
255
+ // was skipped, so resume picks up iteration/best/history verbatim.
256
+ !!r?.startsWith("paused by user (/loop pause)");
257
+
218
258
  async function resolveLoopStartConflict(ctx: ExtensionContext, target: string): Promise<boolean> {
219
259
  const current = liveObjectives(state);
220
260
  if (current.length === 0) return true;
@@ -994,42 +1034,6 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
994
1034
  return;
995
1035
  }
996
1036
  const stored = state.loop;
997
- // v0.29.20: plain plateau stops are resumable too — pre-gate plateaus
998
- // could be false (hegemon/polis stopped 2026-07-31 with open findings
999
- // on 429-dead turns), and an explicit resume is the user's call; the
1000
- // v0.29.19 gate + re-armed counters make the resumed run honest.
1001
- const RESUMABLE_STOP = (r?: string): boolean =>
1002
- r === HELD_ON_RESTORE ||
1003
- !!r?.startsWith("provider errors —") ||
1004
- !!r?.startsWith("stopped by user —") ||
1005
- !!r?.startsWith("plateau —") ||
1006
- !!r?.startsWith("stalled:") ||
1007
- !!r?.startsWith("stuck —") ||
1008
- !!r?.startsWith("time bound reached") ||
1009
- !!r?.startsWith("token budget exhausted") ||
1010
- // v0.35.54 (collect-pass HIGH finding): the v0.35.31 "metric never
1011
- // moved" stop message promises "/loop resume retries or /loop stop",
1012
- // but this predicate never matched that prefix — the promised command
1013
- // answered "No held loop to resume" and, with propose_loop_refine
1014
- // gated on an ACTIVE loop, the only recovery was /loop stop + a fresh
1015
- // start discarding iteration history. Same class as the v0.35.25
1016
- // issue-#14 zombie prefix (fixed there, missed for this brand-new
1017
- // prefix). Resuming re-arms the counters; if the metric is still dead
1018
- // it re-stops loudly after its window — and a measure-changing
1019
- // propose_loop_refine (usable again once resumed) re-scopes the era so
1020
- // the never-moved grace re-arms.
1021
- !!r?.startsWith("metric never moved —") ||
1022
- // v0.35.25 (issue #14): abortZombieRun parks with
1023
- // "stopped: automatic zero-stream abort — … (/loop resume to retry)"
1024
- // and its user-facing message PROMISES that resume command. The
1025
- // predicate it lands in never matched the prefix, so /loop resume
1026
- // answered "No held loop to resume" and the preserved iteration,
1027
- // best value, and history were unreachable without re-drafting.
1028
- !!r?.startsWith("stopped: automatic zero-stream abort") ||
1029
- // v0.38.27 (ported from Bjynt's PR #46): /loop pause is a soft-hold
1030
- // parallel to /goal pause. The loop stays resumable; finishLoopGit
1031
- // was skipped, so resume picks up iteration/best/history verbatim.
1032
- !!r?.startsWith("paused by user (/loop pause)");
1033
1037
  if (stored && !stored.active && RESUMABLE_STOP(stored.stopReason)) {
1034
1038
  // Branch-mode stop returns HEAD to originalBranch. Refuse a resume from
1035
1039
  // there rather than letting the next tick commit loop work to the
@@ -1202,10 +1206,25 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
1202
1206
  // queues the operator's suggestion into the next iteration's prompt.
1203
1207
  // ("polish" accepted as an alias: the widget footer advertised it
1204
1208
  // before the command existed — now it does.)
1205
- if (!isLoopActive()) {
1209
+ // Field 2026-09-16 (loop sweep): a resumable-held loop is refinable —
1210
+ // the hint rides the next iteration whenever it resumes. A dead loop
1211
+ // (user stop/finish/maxed) stays refused with the honest fresh-start
1212
+ // path; queuing onto it would promise a prompt that never comes.
1213
+ const stored = state.loop;
1214
+ if (!stored) {
1206
1215
  ctx.ui.notify("No active loop to refine — /loop start first.", "warning");
1207
1216
  return;
1208
1217
  }
1218
+ if (!stored.active && !RESUMABLE_STOP(stored.stopReason)) {
1219
+ // A broken measure is fixed from the agent side, not by restarting:
1220
+ // point there instead of at a fresh start.
1221
+ if (stored.stopReason?.startsWith("measure command broken —")) {
1222
+ ctx.ui.notify("That loop stopped on a broken measure — the hint was not queued. Have the agent repair it via propose_loop_refine (it test-runs the new measure), then /loop resume.", "warning");
1223
+ return;
1224
+ }
1225
+ ctx.ui.notify(`That loop ended (${stored.stopReason ?? "stopped"}) — the hint was not queued. /loop start begins a fresh run.`, "warning");
1226
+ return;
1227
+ }
1209
1228
  const hint = rest.trim();
1210
1229
  if (!hint) {
1211
1230
  ctx.ui.notify("Usage: /loop refine <what the spec should capture better> — the suggestion rides the next iteration's prompt; the agent proposes via propose_loop_refine and you confirm.", "info");
@@ -1214,6 +1233,13 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
1214
1233
  state.loop!.refineHint = hint.slice(0, 300);
1215
1234
  persistState(ctx);
1216
1235
  appendLedger(ctx.cwd, "loop_refine_hint", { iteration: state.loop!.iteration, hint: state.loop!.refineHint });
1236
+ if (!stored.active) {
1237
+ // Refine-and-resume (owner choice): the hint is queued first, then
1238
+ // the byte-identical resume path runs — wrong-branch and active-goal
1239
+ // guards keep their voices and the loop stays held when they fire.
1240
+ ctx.ui.notify("Refine hint queued — it rides the next iteration's prompt. Resuming the held loop now.", "info");
1241
+ return cmdLoop("resume", ctx);
1242
+ }
1217
1243
  ctx.ui.notify("Refine hint queued — it rides the next iteration's prompt.", "info");
1218
1244
  return;
1219
1245
  }
@@ -524,6 +524,8 @@ function registerAgentTools(pi: any): void {
524
524
  "Outcome: <what was delivered> · Changed: <files/behavior/decision> · Evidence: <key commit/report/result> · " +
525
525
  "Tests: <bounded commands + pass/fail, or `not run — <reason>`> · Unresolved: <remaining risk, or `none`> · " +
526
526
  "Next: <one follow-up hint, or `none`>. " +
527
+ "A section-structured Outcome (##/### headers) renders in full as the card Summary section — " +
528
+ "structure is the price of length. " +
527
529
  "See audit/COMPLETION-SUMMARY-POLICY-2026-08-19.md and docs/DESIGN-long-running-supervision.md. Free-form prose is allowed but discouraged; incomplete input receives a recorded-facts-only fallback at terminalization.",
528
530
  })),
529
531
  verificationSummary: Type.Optional(Type.String({
@@ -297,3 +297,44 @@ export function inferStartFromSession(sessionManager: unknown): StartContextInfe
297
297
  currentPromptTruncated: window.currentPromptTruncated,
298
298
  });
299
299
  }
300
+
301
+ /** Acknowledgment openers: the user is responding to a proposal, not
302
+ * stating an objective. `\b` keeps "goals"/"going" out. */
303
+ const ACK_PREFIX_RE = /^(?:ok(?:ay)?|yes|yeah|yep|sure|go|fine|great|perfect|thanks?|thank\s+you)\b[\s,—–-]*/i;
304
+
305
+ /** Field 2026-09-16 (VidPro ledger: `/goal tweak` adopted "ok adjsut it"
306
+ * as the objective): chatter is an acknowledgment / pronoun / bare vague
307
+ * action — possibly behind an ack opener ("ok adjust it") — never a
308
+ * self-contained objective. Deliberately NOT length-based: short-but-real
309
+ * objectives ("fix typo in X") pass through untouched. Questions pass
310
+ * through too — only the acknowledgment shapes divert. */
311
+ export function isChatterReplacement(text: string): boolean {
312
+ const clean = normalizeWhitespace(text ?? "");
313
+ if (!clean) return false;
314
+ if (GENERIC_REPLY_RE.test(clean) || PRONOUN_ONLY_RE.test(clean) || VAGUE_ACTION_RE.test(clean)) return true;
315
+ const stripped = clean.replace(ACK_PREFIX_RE, "").trim();
316
+ if (stripped !== clean) return stripped ? classifyCandidate(stripped) !== "clear" : true;
317
+ return false;
318
+ }
319
+
320
+ export type TweakReplacement =
321
+ | { kind: "verbatim" }
322
+ | { kind: "inferred"; text: string; source: "current-prompt" | "recent-context" }
323
+ | { kind: "unresolvable"; reason: "ambiguous" | "none"; candidates: string[] };
324
+
325
+ /** Resolve a tweak/update replacement against what was actually discussed.
326
+ * Self-contained text is adopted verbatim (today's path, byte-identical).
327
+ * Chatter ("go fix it", "ok adjsut it") resolves via the session
328
+ * context into the discussed objective — the user pointed at the context,
329
+ * so the context supplies the words. When nothing clear was discussed,
330
+ * the caller guides instead of adopting garbage: raw chatter must never
331
+ * land in the ledger as an objective again. */
332
+ export function resolveTweakReplacement(raw: string, sessionManager: unknown): TweakReplacement {
333
+ if (!isChatterReplacement(raw)) return { kind: "verbatim" };
334
+ const inference = inferStartFromSession(sessionManager);
335
+ if (inference.kind === "clear") return { kind: "inferred", text: inference.objective, source: inference.source };
336
+ if (inference.kind === "ambiguous") {
337
+ return { kind: "unresolvable", reason: "ambiguous", candidates: inference.candidates };
338
+ }
339
+ return { kind: "unresolvable", reason: "none", candidates: [] };
340
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.38.55",
3
+ "version": "0.38.56",
4
4
  "description": "Mission control for autonomous pi: interview-drafted goals, an audited task queue, and forever-loops (metric, spec, project-audit) that run for hours. A detached extension-less auditor process re-verifies every completion with raw evidence without holding the main pi turn; confirmed drafts, decision pauses and consent gates keep you in charge.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "dracon",