pi-goal-list-loop-audit 0.30.0 → 0.31.1

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.
@@ -478,7 +478,85 @@ export function countOpenAuditFindings(cwd: string): number {
478
478
  * reasonable answers exist) are presented, never touched. DECIDE lines use
479
479
  * "- [?]" so they never inflate the loop's open-findings measure.
480
480
  */
481
+ /** v0.31.0: /list audit — the collect-then-drain audit (user design
482
+ * 2026-07-31: "this command could run a project audit, collect a bunch of
483
+ * tasks, then do them all too"). Division of labor:
484
+ * /goal audit — one audited unit: audit + fix in the SAME pass (small scopes).
485
+ * /list audit — audit once, then every open finding becomes its own queued,
486
+ * individually-audited list item (the actionables stop living
487
+ * only inside findings.md — the user's "audits don't make a
488
+ * list of actionables" pain).
489
+ * /loop audit — forever fix-first cadence for living codebases.
490
+ * The collection item FIXES NOTHING: every fix lands as its own list item with
491
+ * its own isolated audit trail. DECIDE findings are presented at collection
492
+ * completion, never queued — a decision is not a task (the agent can't "do" it).
493
+ * The marker survives restarts inside the objective itself (no schema change):
494
+ * the completion fan-out matches on it.
495
+ */
496
+ export const LIST_AUDIT_COLLECT_MARKER = "[LIST-AUDIT-COLLECT]";
497
+
498
+ export function listAuditCollectTarget(focus?: string): string {
499
+ const scope = focus && focus.trim() ? focus.trim() : "the whole project";
500
+ return `${LIST_AUDIT_COLLECT_MARKER} Run ONE project audit pass that COLLECTS work — the follow-up fixes are queued as separate list items, so this pass changes no code. Scope: ${scope}. (1) Run a FRESH audit pass over the codebase — spawn Explore subagents for breadth — hunting real problems: bugs, broken flows, regressions, drift between docs and code, dead code, security holes. Not style nits, not speculative refactors. (2) Append every NEW finding to ${AUDIT_FINDINGS_REL} (create the file on the first finding; append-only — never delete, rewrite, or reorder existing lines; never re-report a finding already listed), classified: "- [ ] FIX: SEVERITY: short description (file:line)" for bugs and polish — and "- [?] DECIDE: short description (what the choice is, what each side costs)" for direction, trade-offs, and scope questions where two reasonable answers exist. (3) Change NOTHING — no fixes, no refactors, no drive-by edits: the orchestrator queues each open FIX finding as its own list item after this pass completes, and each fix lands with its own commit and its own audit. (4) DECIDE findings are listed in the completion report — they are presented to the user, never queued and never silently fixed. (5) Honesty law: never fabricate findings to look busy; if the pass is genuinely clean, say so plainly — an empty findings set is a success, not a failure. Done when: the audit pass is complete and every finding it surfaced is appended to ${AUDIT_FINDINGS_REL} with the right classification (or the report states plainly that nothing was found).`;
501
+ }
502
+
503
+ /** One parsed open finding from the audit findings file. */
504
+ export interface AuditFindingLine {
505
+ /** Raw finding text with the checkbox + optional "FIX:" prefix stripped. */
506
+ text: string;
507
+ /** Severity rank for sorting: 0 = CRITICAL … 4 = unclassified. */
508
+ rank: number;
509
+ }
510
+
511
+ const AUDIT_SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW"];
512
+
513
+ /** v0.31.0: parse findings.md into the fan-out shape — OPEN boxes become
514
+ * actionable findings (severity-sorted, stable within a rank), DECIDE boxes
515
+ * ("- [?]") are returned separately for presentation (never queued). Tolerates
516
+ * the /loop audit format (no "FIX:" prefix): any open box that isn't a
517
+ * decision is actionable.
518
+ */
519
+ export function parseAuditFindingsForFanout(md: string): { open: AuditFindingLine[]; decisions: string[] } {
520
+ const open: AuditFindingLine[] = [];
521
+ const decisions: string[] = [];
522
+ md.split("\n").forEach((line, idx) => {
523
+ const decide = line.match(/^\s*-\s*\[\?\]\s*(.*)$/);
524
+ if (decide) {
525
+ const text = (decide[1] ?? "").replace(/^DECIDE:\s*/i, "").trim();
526
+ if (text) decisions.push(text);
527
+ return;
528
+ }
529
+ const box = line.match(/^\s*-\s*\[ \]\s*(.*)$/);
530
+ if (!box) return;
531
+ const text = (box[1] ?? "").replace(/^FIX:\s*/i, "").trim();
532
+ if (!text) return;
533
+ const sev = text.match(/^([A-Z]+)\s*:/);
534
+ const rank = sev ? AUDIT_SEVERITY_ORDER.indexOf(sev[1]!) : -1;
535
+ open.push({ text, rank: rank >= 0 ? rank : AUDIT_SEVERITY_ORDER.length + (idx / 100000) });
536
+ });
537
+ // Severity first; stable within a rank (file order) via the fractional idx.
538
+ open.sort((a, b) => a.rank - b.rank);
539
+ return { open, decisions };
540
+ }
541
+
542
+ /** v0.31.0: the list-item text for one finding — short objective + a checkable
543
+ * Done when (the fix commit exists AND the box is checked with its hash, so
544
+ * findings.md stays honest as the drain proceeds).
545
+ */
546
+ export function listAuditFanoutItemText(finding: string): string {
547
+ return `Fix audit finding: ${finding} — Done when: the fix is committed on the current branch with the repo's configured identity, and this finding's box in ${AUDIT_FINDINGS_REL} is checked ("- [x] … — fixed in <commit>").`;
548
+ }
549
+
550
+ /** v0.31.1: stacking-detection markers (junk-runner 2026-07-31: a held
551
+ * one-shot audit goal + a running audit loop = two stacked audit initiatives
552
+ * — the held one-shot read as "stalled" for 8h while the loop did all the
553
+ * work, and the agent conflated them). The guards in goal.ts match on these;
554
+ * the unit tests pin that the built targets still contain them.
555
+ */
556
+ export const GOAL_AUDIT_ONESHOT_MARKER = "Run ONE project audit pass and leave the project in a known state";
557
+ export const LOOP_AUDIT_MARKER = "iteration by iteration — FIX-FIRST";
558
+
481
559
  export function projectAuditTarget(focus?: string): string {
482
560
  const scope = focus && focus.trim() ? focus.trim() : "the whole project";
483
- return `Run ONE project audit pass and leave the project in a known state. Scope: ${scope}. (1) Run a FRESH audit pass over the codebase — spawn Explore subagents for breadth — hunting real problems: bugs, broken flows, regressions, drift between docs and code, dead code, security holes. Not style nits, not speculative refactors. (2) Append every NEW finding to ${AUDIT_FINDINGS_REL} (create the file on the first finding; append-only — never delete, rewrite, or reorder existing lines; never re-report a finding already listed), classified: "- [ ] FIX: SEVERITY: short description (file:line)" for bugs and polish — whether to fix these is NOT a decision — and "- [?] DECIDE: short description (what the choice is, what each side costs)" for direction, trade-offs, and scope questions where two reasonable answers exist. (3) Fix every NEW FIX finding from this pass — real fixes, committed with the repo's configured identity on the current branch (no invented identities or branches) — then check the box: "- [x] … — fixed in <commit>". (4) Change NOTHING for DECIDE findings — present them in the completion report instead. (5) Honesty law: never fabricate findings to look busy; never check a box without the fix commit existing; never silently turn a DECIDE into a fix. Done when: the audit pass is complete, every new FIX finding has a fix commit and a checked box in ${AUDIT_FINDINGS_REL}, and every DECIDE finding is listed in the file and presented in the completion report.`;
561
+ return `${GOAL_AUDIT_ONESHOT_MARKER}. Scope: ${scope}. (1) Run a FRESH audit pass over the codebase — spawn Explore subagents for breadth — hunting real problems: bugs, broken flows, regressions, drift between docs and code, dead code, security holes. Not style nits, not speculative refactors. (2) Append every NEW finding to ${AUDIT_FINDINGS_REL} (create the file on the first finding; append-only — never delete, rewrite, or reorder existing lines; never re-report a finding already listed), classified: "- [ ] FIX: SEVERITY: short description (file:line)" for bugs and polish — whether to fix these is NOT a decision — and "- [?] DECIDE: short description (what the choice is, what each side costs)" for direction, trade-offs, and scope questions where two reasonable answers exist. (3) Fix every NEW FIX finding from this pass — real fixes, committed with the repo's configured identity on the current branch (no invented identities or branches) — then check the box: "- [x] … — fixed in <commit>". (4) Change NOTHING for DECIDE findings — present them in the completion report instead. (5) Honesty law: never fabricate findings to look busy; never check a box without the fix commit existing; never silently turn a DECIDE into a fix. Done when: the audit pass is complete, every new FIX finding has a fix commit and a checked box in ${AUDIT_FINDINGS_REL}, and every DECIDE finding is listed in the file and presented in the completion report.`;
484
562
  }
@@ -175,6 +175,12 @@ import {
175
175
  countOpenAuditFindings,
176
176
  AUDIT_FINDINGS_REL,
177
177
  projectAuditTarget,
178
+ LIST_AUDIT_COLLECT_MARKER,
179
+ GOAL_AUDIT_ONESHOT_MARKER,
180
+ LOOP_AUDIT_MARKER,
181
+ listAuditCollectTarget,
182
+ parseAuditFindingsForFanout,
183
+ listAuditFanoutItemText,
178
184
  type LoopTickOutcome,
179
185
  HELD_ON_RESTORE,
180
186
  type LoopState,
@@ -1228,6 +1234,62 @@ function autoArbitrateStackedState(ctx: ExtensionContext): void {
1228
1234
  );
1229
1235
  }
1230
1236
 
1237
+ /** v0.31.0: /list audit completion fan-out — read the audit findings file,
1238
+ * queue every OPEN finding as its own list item (severity-sorted, deduped
1239
+ * against the live queue), present DECIDE findings without queueing them.
1240
+ * Confirm-gated like every bulk import (v0.23.7: the user reads what lands
1241
+ * in the queue); a decline leaves the findings open for a later re-run.
1242
+ */
1243
+ async function fanOutListAuditFindings(ctx: ExtensionContext): Promise<void> {
1244
+ let md = "";
1245
+ try {
1246
+ md = fs.readFileSync(path.join(ctx.cwd, AUDIT_FINDINGS_REL), "utf-8");
1247
+ } catch {
1248
+ /* no findings file — the audit was clean or never wrote */
1249
+ }
1250
+ const { open, decisions } = parseAuditFindingsForFanout(md);
1251
+ // Dedupe against the live queue (a re-run must not double-queue a finding
1252
+ // that's already waiting) — match on the finding text's first 60 chars.
1253
+ const queuedText = listQueue().map((i) => i.objective).join("\n");
1254
+ const fresh = open.filter((f) => !queuedText.includes(f.text.slice(0, 60)));
1255
+ const alreadyQueued = open.length - fresh.length;
1256
+ const decideNote =
1257
+ decisions.length > 0
1258
+ ? `\n${decisions.length} DECIDE finding(s) need YOU (not queued — a decision is not a task):\n` +
1259
+ decisions.slice(0, 10).map((d) => ` ? ${d.slice(0, 110)}`).join("\n")
1260
+ : "";
1261
+ if (fresh.length === 0) {
1262
+ ctx.ui.notify(
1263
+ open.length > 0
1264
+ ? `Audit collected ${open.length} open finding(s) — all already queued.${decideNote}`
1265
+ : `Audit complete — no open findings; the project is clean, nothing to queue.${decideNote}`,
1266
+ "info",
1267
+ );
1268
+ appendLedger(ctx.cwd, "list_audit_fanout_empty", { open: open.length, decisions: decisions.length });
1269
+ return;
1270
+ }
1271
+ const preview = fresh.map((f, i) => ` ${i + 1}. ${f.text.slice(0, 110)}`).join("\n");
1272
+ let confirmed = true;
1273
+ if (ctx.hasUI) {
1274
+ try {
1275
+ confirmed = await ctx.ui.confirm(`Queue ${fresh.length} audit finding(s) as list items?`, preview);
1276
+ } catch {
1277
+ confirmed = false;
1278
+ }
1279
+ }
1280
+ if (!confirmed) {
1281
+ appendLedger(ctx.cwd, "list_audit_fanout_declined", { findings: fresh.length });
1282
+ ctx.ui.notify(`Fan-out declined — the findings stay open in ${AUDIT_FINDINGS_REL}; /list audit re-queues them any time.`, "info");
1283
+ return;
1284
+ }
1285
+ const n = enqueueItems(ctx, fresh.map((f) => listAuditFanoutItemText(f.text)), "list audit fan-out");
1286
+ appendLedger(ctx.cwd, "list_audit_fanout", { queued: n, alreadyQueued, decisions: decisions.length });
1287
+ ctx.ui.notify(
1288
+ `Queued ${n} finding(s) — the list drains them fix by fix, each with its own audited commit.${alreadyQueued > 0 ? ` (${alreadyQueued} already queued.)` : ""}${decideNote}`,
1289
+ "info",
1290
+ );
1291
+ }
1292
+
1231
1293
  function archiveCurrentGoal(ctx: ExtensionContext, status: Status, stopReason?: string): void {
1232
1294
  if (!state.goal) return;
1233
1295
  const goal = state.goal;
@@ -1253,9 +1315,15 @@ function archiveCurrentGoal(ctx: ExtensionContext, status: Status, stopReason?:
1253
1315
  // (v0.2.0 bug: bare /list next silently consumed TWO items, found by the
1254
1316
  // pick-any-item verification in v0.10.0).
1255
1317
  if (goal.policy === "list" && status === "complete") {
1318
+ // v0.31.0: a /list audit collection item completed → fan the open
1319
+ // findings out into the queue (async — Confirm-gated). When the queue
1320
+ // was empty, enqueueItems activates the first fix itself, so the
1321
+ // list-complete / reviewer noise below must NOT fire for this item.
1322
+ const isListAuditCollect = goal.objective.includes(LIST_AUDIT_COLLECT_MARKER);
1323
+ if (isListAuditCollect) void fanOutListAuditFindings(ctx);
1256
1324
  const advanced = activateNextListItem(ctx);
1257
1325
  // v0.26.0: the queue just EMPTIED on a completion → list-complete.
1258
- if (!advanced) {
1326
+ if (!advanced && !isListAuditCollect) {
1259
1327
  fireReviewer(ctx, { kind: "list", goalId: goal.id, objective: goal.objective, terminal: "goal-complete" });
1260
1328
  // v0.29.0: the well ran dry — point at the project-audit loop. A
1261
1329
  // suggestion, not an action: consent, never auto-start (v0.28.28).
@@ -1640,6 +1708,15 @@ async function cmdGoal(args: string, ctx: ExtensionContext): Promise<void> {
1640
1708
  // DECIDE findings presented, untouched. Runs as a normal goal through
1641
1709
  // cmdSet — the isolated auditor verifies the finish line.
1642
1710
  if (route.name === "audit") {
1711
+ // v0.31.1: an active audit loop already owns auditing here — the
1712
+ // one-shot duplicates it (same stacking confusion as junk-runner).
1713
+ if (state.loop?.active && state.loop.target.includes(LOOP_AUDIT_MARKER)) {
1714
+ appendLedger(ctx.cwd, "audit_stack_warn", { have: "loop", starting: "goal" });
1715
+ ctx.ui.notify(
1716
+ "An audit loop is already running here — a one-shot /goal audit duplicates its work. /loop status to see it; /loop stop first if you want the one-shot instead.",
1717
+ "warning",
1718
+ );
1719
+ }
1643
1720
  return cmdSet(projectAuditTarget(route.rest || undefined), ctx, true);
1644
1721
  }
1645
1722
  // v0.28.27 (renamed /goal audit → /goal verify in v0.29.8): run the
@@ -2084,6 +2161,32 @@ async function cmdList(args: string, ctx: ExtensionContext): Promise<void> {
2084
2161
  const sub = (parts[0] ?? "").toLowerCase();
2085
2162
  const rest = args.trim().slice(sub.length).trim();
2086
2163
 
2164
+ if (sub === "audit") {
2165
+ // v0.31.0: /list audit [focus] — collect-then-drain (user design
2166
+ // 2026-07-31: "run a project audit, collect a bunch of tasks, then do
2167
+ // them all too"). The audit item COLLECTS findings (changes no code);
2168
+ // its completion fans each open finding out into the queue and the
2169
+ // list drains them fix by fix, each with its own isolated audit.
2170
+ // Distinct from /goal audit (fix-in-pass, one audited unit) and
2171
+ // /loop audit (forever fix-first cadence).
2172
+ // v0.31.1: an active audit loop is already draining this findings file —
2173
+ // a collect pass would double-hunt the same ground.
2174
+ if (state.loop?.active && state.loop.target.includes(LOOP_AUDIT_MARKER)) {
2175
+ appendLedger(ctx.cwd, "audit_stack_warn", { have: "loop", starting: "list" });
2176
+ ctx.ui.notify("An audit loop is already draining findings here — /list audit would double-hunt the same ground. /loop status to see it.", "warning");
2177
+ }
2178
+ const objective = listAuditCollectTarget(rest || undefined);
2179
+ const n = enqueueItems(ctx, [objective], "/list audit");
2180
+ if (n === 0) return; // zombie-twin guard already explained itself
2181
+ ctx.ui.notify(
2182
+ "Audit collection item queued — it CHANGES NO CODE: it appends findings to " +
2183
+ AUDIT_FINDINGS_REL +
2184
+ ", and on completion each open finding becomes its own list item (fixes drain one audited commit at a time). DECIDE findings are presented to you, never queued.",
2185
+ "info",
2186
+ );
2187
+ return;
2188
+ }
2189
+
2087
2190
  if (sub === "depth") {
2088
2191
  // v0.25.3: long-running state at a glance — queue depth, oldest item
2089
2192
  // age, average item duration from archived list-policy goals.
@@ -2935,6 +3038,18 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
2935
3038
  ctx.ui.notify("A goal is active — /goal cancel or /goal pause it before starting a loop.", "warning");
2936
3039
  return;
2937
3040
  }
3041
+ // v0.31.1: a paused/active one-shot audit goal + this loop = two stacked
3042
+ // audit initiatives (junk-runner 2026-07-31: the held one-shot read as
3043
+ // "stalled" for 8h while the loop did all the work — the agent conflated
3044
+ // them and proposed completing the goal for the loop's work). Warn, name
3045
+ // the supersession, don't block — the user's agency, the user's call.
3046
+ if (state.goal && state.goal.objective.includes(GOAL_AUDIT_ONESHOT_MARKER)) {
3047
+ appendLedger(ctx.cwd, "audit_stack_warn", { have: "goal", starting: "loop", goalStatus: state.goal.status });
3048
+ ctx.ui.notify(
3049
+ `Heads up: a ${state.goal.status} one-shot audit goal exists in this session — the audit loop SUPERSEDES it (one pass + fixes IS the loop's job). /goal cancel clears it; one audit initiative per session.`,
3050
+ "warning",
3051
+ );
3052
+ }
2938
3053
  if (isLoopActive()) {
2939
3054
  ctx.ui.notify("A loop is already active. /loop stop first.", "warning");
2940
3055
  return;
@@ -5382,9 +5497,10 @@ export default function (pi: ExtensionAPI): void {
5382
5497
  handler: (args: string, ctx: ExtensionContext) => { rememberCtx(ctx); return cmdReview(args, ctx); },
5383
5498
  });
5384
5499
  pi.registerCommand("list", {
5385
- description: "Loop 2: the list of audited goals — order is the default, not the law. /list <describe tasks or name a plan file> (dumps get shaped into items, files import, 'Done when:' adds directly) | /list show | /list resume | /list next [n] | /list remove <n> | /list clear | /list cancel",
5500
+ description: "Loop 2: the list of audited goals — order is the default, not the law. /list <describe tasks or name a plan file> (dumps get shaped into items, files import, 'Done when:' adds directly) | /list audit [focus] (collect findings, then drain them as items) | /list show | /list resume | /list next [n] | /list remove <n> | /list clear | /list cancel",
5386
5501
  getArgumentCompletions: completions([
5387
5502
  ["show", "display the waiting items"],
5503
+ ["audit", "collect-then-drain: audit the project, queue every finding as its own item"],
5388
5504
  ["resume", "resume the paused list item (the list's head)"],
5389
5505
  ["next", "activate the next item (or /list next <n> for position n)"],
5390
5506
  ["remove", "remove an item: /list remove <n>"],
@@ -5747,11 +5863,24 @@ export default function (pi: ExtensionAPI): void {
5747
5863
  const isListItem = state.goal.policy === "list";
5748
5864
  const resumeCmd = isListItem ? "/list resume" : "/goal resume";
5749
5865
  const resumeHint = `${resumeCmd} to continue${queued > 0 ? ` (+${queued} waiting in the list)` : ""} · /glla autoresume=on to auto-resume on load (global setting)`;
5866
+ // v0.31.1: name the supersession — a held one-shot audit whose work a
5867
+ // live audit loop now owns reads as "stalled" for HOURS otherwise
5868
+ // (junk-runner: 8h21m of "held for explicit resume" on a goal the
5869
+ // loop had superseded). The widget surface must say so.
5870
+ const auditSuperseded =
5871
+ state.goal.objective.includes(GOAL_AUDIT_ONESHOT_MARKER) &&
5872
+ !!state.loop &&
5873
+ (state.loop.active || state.loop.stopReason === HELD_ON_RESTORE) &&
5874
+ !!state.loop.target?.includes(LOOP_AUDIT_MARKER);
5750
5875
  updateGoal({
5751
5876
  status: "paused",
5752
5877
  pauseKind: "blocked",
5753
- pauseReason: "restored on session load — held for explicit resume",
5754
- pauseSuggestedAction: resumeHint,
5878
+ pauseReason: auditSuperseded
5879
+ ? "restored on session load — SUPERSEDED by the audit loop in this session"
5880
+ : "restored on session load — held for explicit resume",
5881
+ pauseSuggestedAction: auditSuperseded
5882
+ ? `/goal cancel clears it (the loop already owns the audit) · ${resumeHint} if you disagree`
5883
+ : resumeHint,
5755
5884
  }, ctx);
5756
5885
  ctx.ui.notify(
5757
5886
  `${isListItem ? "List item" : "Goal"} held on restore: ${state.goal.objective.slice(0, 70)}${queued > 0 ? ` (+${queued} waiting in the list)` : ""} — ${resumeCmd} to continue.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.30.0",
3
+ "version": "0.31.1",
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",