pi-goal-list-loop-audit 0.34.48 → 0.34.49

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.
@@ -873,6 +873,7 @@ function tryAbsorbHostSuccessor(ctx: ExtensionContext, via: string): boolean {
873
873
  staleTerminalDone = false;
874
874
  sessionHandoffPending = false;
875
875
  sessionGeneration++; // a dead generation's delayed callbacks must not fire into the new owner
876
+ clearDraftingState(); // the old interview belongs to the disposed generation
876
877
  appendLedger(ctx.cwd, "session_rebind_via_live_ctx", { via, generation: sessionGeneration });
877
878
  if (interruptedAudit) {
878
879
  // The old generation's detached worker/result handler is now stale. Do
@@ -982,6 +983,19 @@ let draftingUserReplies = 0;
982
983
  let draftingBlockedProposals = 0; // v0.15.1: stuck-gate escape hatch
983
984
  let draftingSeedInFlight = false;
984
985
 
986
+ /** Drafting is ephemeral session state, not durable goal/list state. A stale
987
+ * seed or an in-flight Confirm must never leave the next MAIN session behind
988
+ * the old interview gate. */
989
+ function clearDraftingState(): void {
990
+ draftingTarget = null;
991
+ draftingUserReplies = 0;
992
+ draftingBlockedProposals = 0;
993
+ draftingSeedInFlight = false;
994
+ }
995
+
996
+ const DRAFT_SESSION_INTERRUPTED_MESSAGE =
997
+ "The drafting flow was interrupted by a pi session replacement. This is NOT a rejection — do not refine or re-propose from the old turn. Wait for a fresh session_start, then run the drafting command again.";
998
+
985
999
  // Dedup set for token accounting (agent_end may replay seen messages).
986
1000
  const countedTokenMessages = new Set<string>();
987
1001
  const countedLoopTokenMessages = new Set<string>();
@@ -3580,7 +3594,14 @@ function activateNextListItem(ctx: ExtensionContext, n = 1): boolean {
3580
3594
  // Drafting: /goal with no args → clarify → Confirm dialog → activate
3581
3595
  // =================================================================
3582
3596
 
3583
- async function startDrafting(ctx: ExtensionContext, target: "goal" | "list" | "loop", seed?: string): Promise<void> {
3597
+ async function startDrafting(ctx: ExtensionContext, target: "goal" | "list" | "loop", seed?: string): Promise<boolean> {
3598
+ // A stale/handoff-bound MAIN cannot deliver the seed. Do not leave the
3599
+ // module in drafting mode: that orphaned gate makes later list_add and
3600
+ // propose_goal_draft calls look like user disapprovals until restart.
3601
+ if (sessionHandoffPending || extensionApiStale || staleTerminalDone || zombieStoodDown || probeExtensionApiStale()) {
3602
+ clearDraftingState();
3603
+ return false;
3604
+ }
3584
3605
  draftingTarget = target;
3585
3606
  const prompts: Record<string, [string, string, string]> = {
3586
3607
  goal: ["goal-loop-draft.md", "Goal drafting", "propose_goal_draft"],
@@ -3594,12 +3615,6 @@ async function startDrafting(ctx: ExtensionContext, target: "goal" | "list" | "l
3594
3615
  : target === "loop"
3595
3616
  ? `${label}: a loop target needs a metric and a direction — the agent will help you design them first (nothing activates until you confirm). Skip the interview entirely: /loop start "<target>" (bare = infinite metricless) or /loop start "<target>" measure="<cmd>" direction=min|max [window=5] [max=50] [time=h] [tokens=n] [branch=1].`
3596
3617
  : `${label}: the objective has no "Done when:" clause — the agent will grill you about it first (nothing activates until you confirm). Skip the interview entirely: /goal start <objective>.`;
3597
- ctx.ui.notify(
3598
- seed
3599
- ? seededHint
3600
- : `${label} started. The agent will grill until the contract is concrete, then ${tool} opens a Confirm dialog. No work begins before confirmation.`,
3601
- "info",
3602
- );
3603
3618
  const tmplPath = path.resolve(__dirname, "..", "..", "prompts", file);
3604
3619
  let tmpl: string;
3605
3620
  try {
@@ -3634,12 +3649,30 @@ async function startDrafting(ctx: ExtensionContext, target: "goal" | "list" | "l
3634
3649
  }
3635
3650
  }
3636
3651
  try {
3637
- safeSteerUser(ctx, tmpl);
3652
+ const wasStale = extensionApiStale;
3653
+ const sent = safeSteerUser(ctx, tmpl);
3654
+ if (!sent) {
3655
+ clearDraftingState();
3656
+ // safeSteerUser deliberately catches stale API errors, so its caller
3657
+ // must handle a false result explicitly rather than relying on catch.
3658
+ if (extensionApiStale && !wasStale) {
3659
+ appendLedger(ctx.cwd, "extension_api_stale", { where: "startDrafting seed" });
3660
+ ctx.ui.notify("glla: can't start the drafting interview — this session's extension handle is stale (pi session replacement). A fresh session_start will rebind it; if no replacement arrives, restart pi normally, then re-run the command.", "warning");
3661
+ }
3662
+ return false;
3663
+ }
3664
+ ctx.ui.notify(
3665
+ seed
3666
+ ? seededHint
3667
+ : `${label} started. The agent will grill until the contract is concrete, then ${tool} opens a Confirm dialog. No work begins before confirmation.`,
3668
+ "info",
3669
+ );
3638
3670
  draftingUserReplies = 0;
3639
3671
  draftingBlockedProposals = 0;
3640
3672
  draftingSeedInFlight = true; // our injected prompt also arrives as a user message — don't count it
3673
+ return true;
3641
3674
  } catch (err) {
3642
- draftingTarget = null;
3675
+ clearDraftingState();
3643
3676
  // v0.28.1 (E6): the seed send used to fail SILENTLY — the user pressed
3644
3677
  // Enter on /goal and nothing happened. Now: loud, and stale handles get
3645
3678
  // the honest restart guidance.
@@ -3650,6 +3683,7 @@ async function startDrafting(ctx: ExtensionContext, target: "goal" | "list" | "l
3650
3683
  } else {
3651
3684
  ctx.ui.notify(`glla: couldn't start the drafting interview (${err instanceof Error ? err.message : String(err)}) — try again.`, "warning");
3652
3685
  }
3686
+ return false;
3653
3687
  }
3654
3688
  }
3655
3689
 
@@ -3748,6 +3782,9 @@ async function cmdSet(args: string, ctx: ExtensionContext, skipDraft = false): P
3748
3782
  raw = raw.slice(1, -1).trim();
3749
3783
  }
3750
3784
  if (!raw) {
3785
+ // A stale MAIN cannot deliver the interview seed. Do not create an
3786
+ // orphaned drafting gate after the entry warning has fired.
3787
+ if (staleEntry) return;
3751
3788
  await startDrafting(ctx, "goal");
3752
3789
  return;
3753
3790
  }
@@ -3760,6 +3797,7 @@ async function cmdSet(args: string, ctx: ExtensionContext, skipDraft = false): P
3760
3797
  // Include an explicit "Done when: …" clause to activate instantly.
3761
3798
  // v0.16.0: /goal start bypasses this by explicit user command.
3762
3799
  if (!skipDraft && goalArgsNeedDrafting(raw)) {
3800
+ if (staleEntry) return;
3763
3801
  await startDrafting(ctx, "goal", raw);
3764
3802
  return;
3765
3803
  }
@@ -6115,7 +6153,7 @@ function registerAgentTools(pi: any): void {
6115
6153
  const foreign2 = foreignToolGuard(execCtx);
6116
6154
  if (foreign2) return { content: [{ type: "text", text: foreign2 }], details: {} };
6117
6155
  const p = params as { objective: string; verificationContract?: string; items?: string[] };
6118
- const liveCtx = currentToolContext(execCtx);
6156
+ let liveCtx = currentToolContext(execCtx);
6119
6157
  if (!liveCtx) return staleToolResult();
6120
6158
  if (draftingTarget !== "goal" && draftingTarget !== "list") {
6121
6159
  return {
@@ -6123,6 +6161,14 @@ function registerAgentTools(pi: any): void {
6123
6161
  details: {},
6124
6162
  };
6125
6163
  }
6164
+ const draftGeneration = sessionGeneration;
6165
+ const staleDraftEntry = draftingTarget === "list"
6166
+ ? warnIfStaleAtEntry(liveCtx, "list drafting")
6167
+ : warnIfStaleAtEntry(liveCtx, "goal drafting");
6168
+ if (staleDraftEntry) {
6169
+ clearDraftingState();
6170
+ return { content: [{ type: "text", text: DRAFT_SESSION_INTERRUPTED_MESSAGE }], details: {} };
6171
+ }
6126
6172
  // v0.28.14: one-active-thing EARLY guard — refuse the whole interview
6127
6173
  // when a loop is live (the post-confirm backstop below stays: state
6128
6174
  // can change mid-interview).
@@ -6148,8 +6194,6 @@ function registerAgentTools(pi: any): void {
6148
6194
  details: {},
6149
6195
  };
6150
6196
  }
6151
- // v0.28.1 (S3): honest staleness warning before any Confirm attempt.
6152
- warnIfStaleAtEntry(liveCtx, "goal drafting");
6153
6197
  // Multi-item list draft: one Confirm for the whole batch.
6154
6198
  if (p.items && p.items.length > 0) {
6155
6199
  // v0.23.7: show ALL items in full — the user approves the whole
@@ -6167,9 +6211,16 @@ function registerAgentTools(pi: any): void {
6167
6211
  "Confirm list batch",
6168
6212
  `${p.items.length} items:\n${preview}${batchActivates ? "\n\n(List is empty — confirming ACTIVATES item 1 immediately as the active goal.)" : ""}`,
6169
6213
  );
6214
+ const afterConfirm = freshCtxForGeneration(draftGeneration);
6215
+ if (!afterConfirm) {
6216
+ clearDraftingState();
6217
+ return { content: [{ type: "text", text: DRAFT_SESSION_INTERRUPTED_MESSAGE }], details: {} };
6218
+ }
6219
+ liveCtx = afterConfirm;
6170
6220
  if (c === "stale") {
6171
6221
  // v0.28.1 (T1): a stale dialog is NOT a rejection — nothing was
6172
6222
  // refused; the dialog simply can't render in a doomed process.
6223
+ clearDraftingState();
6173
6224
  extensionApiStale = true;
6174
6225
  appendLedger(liveCtx.cwd, "extension_api_stale", { where: "batch confirm" });
6175
6226
  return { content: [{ type: "text", text: "The Confirm dialog could not render: pi invalidated this session's extension handle (session replacement). This is NOT a rejection — do NOT refine or re-propose. Wait for a fresh session_start, then re-run the drafting flow." }], details: {} };
@@ -6212,8 +6263,15 @@ function registerAgentTools(pi: any): void {
6212
6263
  appendLedger(liveCtx.cwd, "draft_autoaccepted", { kind: isListDraft ? "list" : "goal", objective: p.objective.trim().slice(0, 200) });
6213
6264
  } else {
6214
6265
  const c = await confirmDraft(liveCtx, isListDraft ? "Confirm list item" : "Confirm goal", `${sanitizeDisplayText(p.objective.trim())}${sanitizeDisplayText(contractBlock)}${activationNote}`);
6266
+ const afterConfirm = freshCtxForGeneration(draftGeneration);
6267
+ if (!afterConfirm) {
6268
+ clearDraftingState();
6269
+ return { content: [{ type: "text", text: DRAFT_SESSION_INTERRUPTED_MESSAGE }], details: {} };
6270
+ }
6271
+ liveCtx = afterConfirm;
6215
6272
  if (c === "stale") {
6216
6273
  // v0.28.1 (T1): a stale dialog is NOT "Draft rejected by the user".
6274
+ clearDraftingState();
6217
6275
  extensionApiStale = true;
6218
6276
  appendLedger(liveCtx.cwd, "extension_api_stale", { where: "draft confirm" });
6219
6277
  return { content: [{ type: "text", text: "The Confirm dialog could not render: pi invalidated this session's extension handle (session replacement). This is NOT a rejection — do NOT refine or re-propose. Wait for a fresh session_start, then re-run the drafting flow." }], details: {} };
@@ -6307,6 +6365,10 @@ function registerAgentTools(pi: any): void {
6307
6365
  const p = params as { target: string; measureCmd?: string; direction?: "min" | "max"; window?: number; max?: number; time?: number; tokens?: number; branch?: boolean };
6308
6366
  const liveCtx = currentToolContext(execCtx);
6309
6367
  if (!liveCtx) return staleToolResult();
6368
+ if (warnIfStaleAtEntry(liveCtx, "loop drafting")) {
6369
+ clearDraftingState();
6370
+ return { content: [{ type: "text", text: DRAFT_SESSION_INTERRUPTED_MESSAGE }], details: {} };
6371
+ }
6310
6372
  if (draftingTarget !== "loop") {
6311
6373
  return {
6312
6374
  content: [{ type: "text", text: "You cannot start or draft a loop — only the user can, from the slash bar (the Confirm is the product). Do NOT write draft files or wait for the user to say 'start' in chat; that dead-ends. Instead hand the user the exact command: /loop start \"<target>\" (bare = infinite metricless; add measure=\"<cmd>\" direction=min|max for a metric loop), or /loop respec to reconcile against the root spec, or /loop with no args to draft interactively." }],
@@ -8160,6 +8222,7 @@ export default function (pi: ExtensionAPI): void {
8160
8222
  markSessionOwnerShutdown(ctx.cwd, shutdownReason);
8161
8223
  writeSessionHandoff(ctx, shutdownReason);
8162
8224
  sessionReplacementUntil = Date.now() + SESSION_REBIND_GRACE_MS;
8225
+ clearDraftingState();
8163
8226
  clearSessionOwnedTimers();
8164
8227
  toolsRegistered = false;
8165
8228
  toolHealNotified = false;
@@ -8208,6 +8271,7 @@ export default function (pi: ExtensionAPI): void {
8208
8271
  deadOwnerSession = null; // v0.34.25: a real session_start supersedes the silent-swap record
8209
8272
  deadOwnerCwd = null;
8210
8273
  sessionGeneration++;
8274
+ clearDraftingState();
8211
8275
  // An auditor belonging to the disposed generation cannot block the fresh
8212
8276
  // session's recovery gate; its finally block is generation-guarded too.
8213
8277
  completionAuditInFlight = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.34.48",
3
+ "version": "0.34.49",
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": "MIT",
6
6
  "author": "dracon",
@@ -109,12 +109,29 @@ function toolArgsPrefix(args) {
109
109
  }
110
110
  }
111
111
 
112
- function appendRecentOutput(recentOutput, text) {
113
- for (const raw of text.split("\n")) {
114
- if (!raw) continue;
115
- recentOutput.push(raw.length <= MAX_RECENT_OUTPUT_ITEM_CHARS
116
- ? raw
117
- : `${raw.slice(0, MAX_RECENT_OUTPUT_ITEM_CHARS - 1)}…`);
112
+ function appendRecentOutput(recentOutput, reportLine, text, flush = false) {
113
+ // text_delta fragments are arbitrary provider chunks, not logical lines.
114
+ // Assemble them exactly like MAIN's cumulative assistant renderer; treating
115
+ // each fragment as a line made the HUD show one word or punctuation mark at
116
+ // a time (for example, `Audit summary` followed by `:`). The exact audit
117
+ // result remains in outputParts and is deliberately unaffected by this
118
+ // bounded display telemetry.
119
+ const combined = `${reportLine.value}${text}`;
120
+ const parts = combined.split("\n");
121
+ reportLine.value = parts.pop() ?? "";
122
+ for (const raw of parts) {
123
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
124
+ if (!line) continue;
125
+ recentOutput.push(line.length <= MAX_RECENT_OUTPUT_ITEM_CHARS
126
+ ? line
127
+ : `${line.slice(0, MAX_RECENT_OUTPUT_ITEM_CHARS - 1)}…`);
128
+ }
129
+ if (flush && reportLine.value) {
130
+ const line = reportLine.value;
131
+ recentOutput.push(line.length <= MAX_RECENT_OUTPUT_ITEM_CHARS
132
+ ? line
133
+ : `${line.slice(0, MAX_RECENT_OUTPUT_ITEM_CHARS - 1)}…`);
134
+ reportLine.value = "";
118
135
  }
119
136
  while (recentOutput.length > MAX_RECENT_OUTPUT_ITEMS) recentOutput.shift();
120
137
  }
@@ -134,6 +151,7 @@ async function main() {
134
151
  const toolCalls = [];
135
152
  const recentOutput = [];
136
153
  const outputParts = [];
154
+ const recentReportLine = { value: "" };
137
155
  const activeTools = new Map();
138
156
  let currentTool;
139
157
  let currentToolArgs;
@@ -172,7 +190,12 @@ async function main() {
172
190
  phase,
173
191
  elapsedMs: Date.now() - startedAt,
174
192
  ...(lastActivityAt !== undefined ? { lastActivityAt } : {}),
175
- recentOutput: recentOutput.slice(-MAX_RECENT_OUTPUT_ITEMS),
193
+ recentOutput: [
194
+ ...recentOutput.slice(-MAX_RECENT_OUTPUT_ITEMS),
195
+ ...(recentReportLine.value ? [recentReportLine.value.length <= MAX_RECENT_OUTPUT_ITEM_CHARS
196
+ ? recentReportLine.value
197
+ : `${recentReportLine.value.slice(0, MAX_RECENT_OUTPUT_ITEM_CHARS - 1)}…`] : []),
198
+ ].slice(-MAX_RECENT_OUTPUT_ITEMS),
176
199
  toolCalls: toolCalls.slice(-MAX_TOOL_CALLS),
177
200
  ...(currentTool ? { currentTool } : {}),
178
201
  ...(currentToolArgs ? { currentToolArgs } : {}),
@@ -187,6 +210,9 @@ async function main() {
187
210
  const finish = async (ok, error = "") => {
188
211
  if (finalized) return;
189
212
  finalized = true;
213
+ // Preserve a final unterminated report line in the last progress snapshot
214
+ // without changing the exact result output used for verdict parsing.
215
+ appendRecentOutput(recentOutput, recentReportLine, "", true);
190
216
  if (deadlineTimer) clearTimeout(deadlineTimer);
191
217
  if (inactivityTimer) clearInterval(inactivityTimer);
192
218
  if (pi && pi.exitCode === null) pi.kill("SIGTERM");
@@ -313,7 +339,7 @@ async function main() {
313
339
  if (event.type === "message_update") {
314
340
  if (update?.type === "text_delta" && typeof update.delta === "string") {
315
341
  outputParts.push(update.delta);
316
- appendRecentOutput(recentOutput, update.delta);
342
+ appendRecentOutput(recentOutput, recentReportLine, update.delta);
317
343
  void progress(phase).catch(() => {});
318
344
  } else {
319
345
  void progress("thinking").catch(() => {});