pi-goal-list-loop-audit 0.38.48 → 0.38.50

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,73 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.38.50 — Gemini-gap terminal upgrade: grouped findings, request-echo headline, duration line (2026-09-12)
4
+
5
+ Surveyed four Gemini report screenshots (audit/GEMINI-SUMMARY-GAP-2026-09-11.md)
6
+ and closed the gap in the terminal render only (chat + transcript + archive
7
+ human layer; progress cards, status, widget, external notifies unchanged).
8
+
9
+ ### Added
10
+
11
+ - **Agent-structured finding groups:** optional `complete_goal findingGroups`
12
+ (one group per work area, findings as `Lead: body` with repo-relative
13
+ `path:line` evidence tokens, sanitized at the claim boundary). Fewer than
14
+ four groups render as nested `#### n. Area` subsections; four or more
15
+ render as an Area | Finding | Evidence table. Evidence extraction is
16
+ mechanical — absolute/home-dir/machine paths never become evidence.
17
+ Single-area work omits the parameter; the flat six-label render stays
18
+ the fallback.
19
+ - **Request-echo headline:** `## Done: <objective> — <outcome>` (and
20
+ `## Aborted: …` for aborted records) so the title mirrors the ask.
21
+ - **Duration line:** `— N turns · … elapsed · M audits` from durable state
22
+ directly under the headline; unknown facts stay absent.
23
+ - **Raised budgets:** findings 8 → 12, values 200 → 400 chars, Next 4 → 6.
24
+
25
+ ### Preserved
26
+
27
+ - Auditor-verified-before-posting, the verbatim six-label machine record,
28
+ and the honesty rules (PASS only on `pass`, `NO VERDICT` never renders,
29
+ pipe escaping) are unchanged.
30
+
31
+ ## 0.38.49 — audit-pass drift fixes: prompt gate, summary segmentation, task honesty (2026-09-11)
32
+
33
+ Full-project audit pass on v0.38.48 (three parallel scout surveys). Six new
34
+ findings, all fixed on main; scout claims that were unproven, stale, or
35
+ by-design are disposed with rationale in the pass note, not as findings.
36
+
37
+ ### Fixed
38
+
39
+ - **Continuation prompt drift:** the Available-tools list and task workflow
40
+ now name `update_task_batch` (validate whole, verify milestones, persist
41
+ once) and the pending-task completion gate (`complete_goal` refused
42
+ before audit while committed tasks are open; `record_goal_judgment`
43
+ `choice=deferred` + `taskId` exempts a blocked item). Completion-voice
44
+ guidance matches the rich-section renderer budgets.
45
+ - **README verify path:** the `complete_goal` steps now include the
46
+ pending-task refusal before audit.
47
+ - **Docs versions/voice:** INSTALL updating examples show the current
48
+ versions; the supervision design doc distinguishes the verbatim six-label
49
+ archive record from the rich chat/transcript sections and the compact
50
+ width-bound projection.
51
+ - **Summary gate segmentation:** `missingCompletionSummaryLabels` segments
52
+ on the same last-occurrence positions as every projector, so a label
53
+ named inside an earlier value can no longer pass the gate while rendering
54
+ shifted (removed the now-dead `labelIndex`).
55
+ - **Task-tool honesty:** completing an already-complete task reports
56
+ `Task <id> is already complete.` and same-status `update_task_status`
57
+ moves report `already <status>` instead of the not-found fallthrough or
58
+ a no-op persist.
59
+ - **Sidecar pending boundary:** the approval-render outbox and the
60
+ update-check refresh defer while sessionDir resolution is pending instead
61
+ of creating (or latching degraded on) a `<cwd>/.pi-glla` fallback tree.
62
+
63
+ ### Tests
64
+
65
+ - Re-baselined `tests/context-growth-measurement.test.ts` to 22_570 chars
66
+ / 22_670 bytes per payload (+769 chars incl. one em dash) with exact
67
+ multiples preserved across probe sizes. New pins: stolen-label gate
68
+ agreement, already-complete/no-op task messages, sidecar pending deferral.
69
+ `tsc` clean.
70
+
3
71
  ## 0.38.48 — atomic task batches + pending-task completion gate (2026-09-11)
4
72
 
5
73
  ### Added
package/INSTALL.md CHANGED
@@ -31,11 +31,11 @@ GLLA loads into new pi sessions. If pi is already open, reload that session:
31
31
 
32
32
  ## Updating
33
33
 
34
- The status line always shows the running version (`glla: … · v0.38.46`).
34
+ The status line always shows the running version (`glla: … · v0.38.48`).
35
35
  When the npm registry is ahead, it also nudges:
36
36
 
37
37
  ```text
38
- glla: … · v0.38.45 · update v0.38.46 available
38
+ glla: … · v0.38.47 · update v0.38.48 available
39
39
  ```
40
40
 
41
41
  The nudge comes from a daily sidecar check (`.pi-glla/update-check.json`),
package/README.md CHANGED
@@ -250,12 +250,14 @@ When the agent calls `complete_goal`, GLLA:
250
250
 
251
251
  1. runs the contract's mechanical checks when a release or command check is
252
252
  specified;
253
- 2. writes an identity-bound completion claim;
254
- 3. starts a detached, fresh pi RPC worker for the audit;
255
- 4. asks the worker to inspect the repository and run bounded checks;
256
- 5. requires raw evidence for each verification-contract item through the
253
+ 2. refuses the claim before audit when committed tasks are still open
254
+ (tasks with a recorded deferral are exempt), naming each open task;
255
+ 3. writes an identity-bound completion claim;
256
+ 4. starts a detached, fresh pi RPC worker for the audit;
257
+ 5. asks the worker to inspect the repository and run bounded checks;
258
+ 6. requires raw evidence for each verification-contract item through the
257
259
  orchestrator-side regression shield;
258
- 6. keeps the goal open on infrastructure failure, missing evidence, or
260
+ 7. keeps the goal open on infrastructure failure, missing evidence, or
259
261
  disapproval instead of silently archiving it.
260
262
 
261
263
  The auditor is intentionally isolated from the implementing conversation and
@@ -110,10 +110,16 @@ status/reason, durable telemetry, captured audit verdicts, and known archive
110
110
  path. It says `not recorded` when a changed-file manifest or test result is not
111
111
  available. It never infers a passing test or invents a commit.
112
112
 
113
- The full recap lives in the archive and status/history surfaces. Every
114
- terminal goal notification—including version-bearing already-shipped claims,
115
- explicit goal/list cancellation, and `/glla wipe`—includes a compact
116
- projection of all six labels; loop notifications do the same. The terminal
113
+ The full six-label recap lives verbatim in the archive (`## Completion summary`)
114
+ and the status/history surfaces. The human layer in chat, transcript, and the
115
+ archive's `## Terminal summary` renders the same facts as rich sections
116
+ (`## Done: <objective> — <outcome>`, duration line, Key Findings grouped by area
117
+ or tabulated at 4+ groups, Verification Summary table, Next, capped and clipped
118
+ to fixed budgets). Width-bound and external surfaces (status line, widget card,
119
+ external notifies) use the compact single-line projection of all six labels.
120
+ Every terminal goal notification, including version-bearing already-shipped claims,
121
+ explicit goal/list cancellation, and `/glla wipe`, carries either the rich sections
122
+ or the compact projection; loop notifications do the same. The terminal
117
123
  notification may use a compact excerpt. The executor recap and independent
118
124
  auditor verdict stay separate: an approval is not manufactured from the
119
125
  presence of a summary.
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.48 continue through the supervisor freeze (`/glla pause`),
19
+ v0.35.14–v0.38.50 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
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { appendLedger, ensureDirs, nowIso, piGlaDir, runPersistStep } from "./goal-loop-core.js";
4
+ import { stateRootPending } from "./glla-state-root.js";
4
5
 
5
6
  /** Durable terminal-summary outbox. A toast or a live host is not delivery:
6
7
  * only a confirmed visible session message acknowledges a render. */
@@ -67,6 +68,9 @@ function readRenders(cwd: string): PendingApprovalRender[] {
67
68
  }
68
69
 
69
70
  function writeRenders(cwd: string, renders: PendingApprovalRender[]): boolean {
71
+ // v0.38.49 audit: pending sessionDir resolution defers every write that
72
+ // would otherwise recreate <cwd>/.pi-glla — the sidecar is no exception.
73
+ if (stateRootPending()) return false;
70
74
  const landed = runPersistStep("persistApprovalRender", () => {
71
75
  ensureDirs(cwd);
72
76
  const file = approvalRenderStorePath(cwd);
@@ -1,5 +1,5 @@
1
- import type { Goal, Status } from "./goal-loop-core.js";
2
- import { truncateCells } from "./goal-loop-display.js";
1
+ import type { FindingGroup, Goal, Status } from "./goal-loop-core.js";
2
+ import { fmtElapsed, truncateCells } from "./goal-loop-display.js";
3
3
 
4
4
  /**
5
5
  * The durable, user-facing terminal recap contract. Keep this as a small
@@ -31,10 +31,6 @@ export interface CompletionSummaryFacts {
31
31
  archivePath?: string;
32
32
  }
33
33
 
34
- function labelIndex(text: string, label: CompletionSummaryLabel): number {
35
- return text.toLowerCase().indexOf(label.toLowerCase());
36
- }
37
-
38
34
  /**
39
35
  * Validation/version annotations are metadata, not recap fields. Keep them
40
36
  * out of the parser so a generated NOTE containing examples such as
@@ -45,17 +41,20 @@ function completionSummaryBody(text: string): string {
45
41
  return annotation?.index === undefined ? text : text.slice(0, annotation.index);
46
42
  }
47
43
 
48
- /** Return labels that are absent or have no value after the label. */
44
+ /** Return labels that are absent or have no value after the label. Segments on
45
+ * the same last-occurrence positions as every projector (compact, multi-line,
46
+ * rich): a label named inside an earlier value must not shift the gate away
47
+ * from what the user will actually read. */
49
48
  export function missingCompletionSummaryLabels(text: string): CompletionSummaryLabel[] {
50
49
  const normalized = completionSummaryBody(text).trim();
50
+ const positions = labelPositions(normalized.toLowerCase());
51
51
  return COMPLETION_SUMMARY_LABELS.filter((label) => {
52
- const start = labelIndex(normalized, label);
53
- if (start < 0) return true;
54
- const valueStart = start + label.length;
55
- const next = COMPLETION_SUMMARY_LABELS
56
- .map((candidate) => labelIndex(normalized.slice(valueStart), candidate))
57
- .filter((index) => index >= 0)
58
- .map((index) => valueStart + index)
52
+ const current = positions.find((entry) => entry.label === label);
53
+ if (!current) return true;
54
+ const valueStart = current.start + label.length;
55
+ const next = positions
56
+ .filter((entry) => entry.start > current.start)
57
+ .map((entry) => entry.start)
59
58
  .sort((a, b) => a - b)[0];
60
59
  return normalized.slice(valueStart, next ?? normalized.length).trim().length === 0;
61
60
  });
@@ -270,18 +269,27 @@ export function withoutStaleNext(details: string[] | undefined): string[] {
270
269
  }
271
270
 
272
271
  /** Rich terminal voice (field 20260911_003839/003903/003907 — the
273
- * Antigravity close): section headers, numbered findings with bold
274
- * leads + code refs, and a verification table, instead of the flat
275
- * six-bullet card. Scope is the terminal render only (chat +
272
+ * Antigravity close; v0.38.50 Gemini-gap survey audit/GEMINI-SUMMARY-GAP-2026-09-11.md:
273
+ * grouped area sections with nested findings + file:line evidence, a
274
+ * request-echo headline, a duration line, and an automatic table once
275
+ * findings span 4+ groups). Scope is the terminal render only (chat +
276
276
  * transcript + archive human layer); progress cards, status line, and
277
277
  * external notifies keep their compact projections. Verbose by owner
278
- * choice: findings cap 8, values 200, table cells 200. */
279
- export const RICH_FINDINGS_CAP = 8;
280
- export const RICH_VALUE_BUDGET = 200;
281
- export const RICH_NEXT_CAP = 4;
278
+ * choice: findings cap 12, values 400, table cells 400, Next 6. */
279
+ export const RICH_FINDINGS_CAP = 12;
280
+ export const RICH_VALUE_BUDGET = 400;
281
+ export const RICH_NEXT_CAP = 6;
282
+ /** v0.38.50: objective echo clipped to a headline-safe width. */
283
+ export const RICH_OBJECTIVE_ECHO_CHARS = 80;
284
+ /** v0.38.50: findings spanning this many groups render as a table
285
+ * (the 12-row screen-by-screen example); fewer stay nested. */
286
+ export const RICH_TABLE_GROUP_THRESHOLD = 4;
287
+ /** v0.38.50: evidence tokens per finding cell — density, not a dump. */
288
+ export const RICH_EVIDENCE_TOKENS_PER_FINDING = 4;
282
289
 
283
290
  export interface RichTerminalParts {
284
291
  headline: string;
292
+ durationLine: string | null;
285
293
  findingLines: string[];
286
294
  tableLines: string[];
287
295
  nextLines: string[];
@@ -315,6 +323,63 @@ function leadBody(detail: string): { lead: string; body: string } {
315
323
  return { lead: detail.slice(0, separator).trim() || "Note", body: detail.slice(separator + 1).trim() };
316
324
  }
317
325
 
326
+ /**
327
+ * v0.38.50: repo-relative `path:line` evidence tokens (soundManager.ts:333,
328
+ * sim.ts:4505-4530). Absolute paths, home-dir paths, and machine temp
329
+ * paths are NOT evidence — those stay in the archive only. Extraction is
330
+ * mechanical substring movement, never inference.
331
+ */
332
+ const EVIDENCE_TOKEN_PATTERN = /(?<![/~+\w])[\w.+][\w./-]*\.[A-Za-z0-9]{1,8}:\d+(?:-\d+)?/g;
333
+
334
+ export function extractEvidenceTokens(text: string): { text: string; evidence: string[] } {
335
+ const evidence: string[] = [];
336
+ const stripped = text
337
+ .replace(EVIDENCE_TOKEN_PATTERN, (match) => {
338
+ if (evidence.length < RICH_EVIDENCE_TOKENS_PER_FINDING && !evidence.includes(match)) evidence.push(match);
339
+ return "";
340
+ })
341
+ .replace(/\s+/g, " ")
342
+ .replace(/\s+([,.;:])/g, "$1")
343
+ .trim();
344
+ return { text: stripped, evidence };
345
+ }
346
+
347
+ /**
348
+ * v0.38.50: enforce the total-findings budget across groups in order —
349
+ * groups that go empty are dropped so the table/nesting never shows a
350
+ * bare area header.
351
+ */
352
+ export function takeBudgetedGroups(groups: FindingGroup[]): FindingGroup[] {
353
+ const out: FindingGroup[] = [];
354
+ let remaining = RICH_FINDINGS_CAP;
355
+ for (const group of groups) {
356
+ if (remaining <= 0) break;
357
+ const findings = group.findings.slice(0, remaining);
358
+ if (findings.length === 0) continue;
359
+ out.push({ title: group.title, findings });
360
+ remaining -= findings.length;
361
+ }
362
+ return out;
363
+ }
364
+
365
+ /**
366
+ * v0.38.50: one compact duration line from durable goal state — turns,
367
+ * wall-clock elapsed since creation, and audit count. Only known facts
368
+ * render (absent stays absent); null when nothing is known.
369
+ */
370
+ export function buildDurationLine(goal: Goal, now = Date.now()): string | null {
371
+ const segs: string[] = [];
372
+ const turns = goal.telemetry?.turns;
373
+ if (typeof turns === "number" && Number.isFinite(turns) && turns >= 0) {
374
+ segs.push(`${turns} turn${turns === 1 ? "" : "s"}`);
375
+ }
376
+ const started = Date.parse(goal.createdAt ?? "");
377
+ if (Number.isFinite(started)) segs.push(`${fmtElapsed(now - started)} elapsed`);
378
+ const audits = Array.isArray(goal.auditHistory) ? goal.auditHistory.length : 0;
379
+ if (audits > 0) segs.push(`${audits} audit${audits === 1 ? "" : "s"}`);
380
+ return segs.length > 0 ? `\u2014 ${segs.join(" \u00b7 ")}` : null;
381
+ }
382
+
318
383
  /** Partition informing details into findings / Tests / next buckets. */
319
384
  export function partitionRichDetails(details: string[]): { findings: string[]; tests: string[]; next: string[] } {
320
385
  const findings: string[] = [];
@@ -328,18 +393,60 @@ export function partitionRichDetails(details: string[]): { findings: string[]; t
328
393
  return { findings, tests, next };
329
394
  }
330
395
 
331
- /** Build the section parts shared by chat, transcript, and archive. */
396
+ /**
397
+ * v0.38.50: request-echo headline — the title mirrors the ask (the
398
+ * Gemini shape) with the outcome second. Without an objective the legacy
399
+ * `## Done — outcome` shape stays, so direct unit callers are unaffected.
400
+ */
401
+ export function requestEchoHeadline(kind: "Done" | "Aborted", objective: string | undefined, outcome: string): string {
402
+ const echo = objective?.trim() ? clipSummaryValue(objective.trim(), RICH_OBJECTIVE_ECHO_CHARS) : null;
403
+ return echo ? `## ${kind}: ${echo} \u2014 ${outcome}` : `## ${kind} \u2014 ${outcome}`;
404
+ }
405
+
406
+ /** Build the section parts shared by chat, transcript, and archive.
407
+ * v0.38.50: agent-structured groups render as `#### n. Area` subsections
408
+ * with nested evidence bullets; at RICH_TABLE_GROUP_THRESHOLD groups the
409
+ * same facts render as an Area | Finding | Evidence table instead.
410
+ * Without groups the flat six-label projection stays the fallback. */
332
411
  export function buildRichTerminalParts(args: {
333
412
  outcome: string;
334
413
  details: string[];
335
414
  countsLine: string;
336
415
  auditHistory?: Goal["auditHistory"];
416
+ objective?: string;
417
+ durationLine?: string | null;
418
+ groups?: FindingGroup[];
337
419
  }): RichTerminalParts {
338
420
  const { findings, tests, next } = partitionRichDetails(args.details);
339
- const findingLines = findings.slice(0, RICH_FINDINGS_CAP).map((detail, i) => {
340
- const { lead, body } = leadBody(detail);
341
- return `${i + 1}. **${lead}** \u2014 ${clipSummaryValue(body, RICH_VALUE_BUDGET)}`;
342
- });
421
+ const headline = requestEchoHeadline("Done", args.objective, args.outcome);
422
+ const budgeted = takeBudgetedGroups(args.groups ?? []);
423
+ const useTable = budgeted.length >= RICH_TABLE_GROUP_THRESHOLD;
424
+ const findingLines: string[] = [];
425
+ if (useTable) {
426
+ findingLines.push("| Area | Finding | Evidence |", "| --- | --- | --- |");
427
+ for (const group of budgeted) {
428
+ for (const finding of group.findings) {
429
+ const { text, evidence } = extractEvidenceTokens(finding);
430
+ const { lead, body } = leadBody(text);
431
+ findingLines.push(
432
+ `| ${escapeTableCell(clipSummaryValue(group.title, RICH_OBJECTIVE_ECHO_CHARS))} | ${escapeTableCell(`**${lead}** \u2014 ${clipSummaryValue(body, RICH_VALUE_BUDGET)}`)} | ${escapeTableCell(evidence.join(", ") || "\u2014")} |`,
433
+ );
434
+ }
435
+ }
436
+ } else if (budgeted.length > 0) {
437
+ budgeted.forEach((group, i) => {
438
+ findingLines.push(`#### ${i + 1}. ${clipSummaryValue(group.title, RICH_OBJECTIVE_ECHO_CHARS)}`);
439
+ for (const finding of group.findings) {
440
+ const { lead, body } = leadBody(finding);
441
+ findingLines.push(`- **${lead}** \u2014 ${clipSummaryValue(body, RICH_VALUE_BUDGET)}`);
442
+ }
443
+ });
444
+ } else {
445
+ findings.slice(0, RICH_FINDINGS_CAP).forEach((detail, i) => {
446
+ const { lead, body } = leadBody(detail);
447
+ findingLines.push(`${i + 1}. **${lead}** \u2014 ${clipSummaryValue(body, RICH_VALUE_BUDGET)}`);
448
+ });
449
+ }
343
450
  const tableRows: string[] = [];
344
451
  for (const detail of tests.slice(0, 2)) {
345
452
  const { body } = leadBody(detail);
@@ -358,16 +465,19 @@ export function buildRichTerminalParts(args: {
358
465
  return `- **${lead}** \u2014 ${clipSummaryValue(body, RICH_VALUE_BUDGET)}`;
359
466
  });
360
467
  return {
361
- headline: `## Done \u2014 ${args.outcome}`,
468
+ headline,
469
+ durationLine: args.durationLine ?? null,
362
470
  findingLines,
363
471
  tableLines,
364
472
  nextLines,
365
473
  };
366
474
  }
367
475
 
368
- /** Compose parts + headline + section headers into markdown lines. */
476
+ /** Compose parts + headline + section headers into markdown lines.
477
+ * v0.38.50: the duration line rides directly under the headline. */
369
478
  export function composeRichTerminalLines(parts: RichTerminalParts, opts?: { headline?: string }): string[] {
370
479
  const lines = [opts?.headline ?? parts.headline, ""];
480
+ if (parts.durationLine) lines.push(parts.durationLine, "");
371
481
  if (parts.findingLines.length > 0) {
372
482
  lines.push("### Key Findings & Remediation", ...parts.findingLines, "");
373
483
  }
@@ -385,7 +495,7 @@ export function composeRichTerminalLines(parts: RichTerminalParts, opts?: { head
385
495
  * raw six-label recap stays verbatim in `## Completion summary` as the
386
496
  * machine layer. Headline follows terminal status; aborted records never
387
497
  * wear a `Done` headline. */
388
- export function buildRichArchiveSection(goal: Goal, status: Status, archivePath: string): string[] {
498
+ export function buildRichArchiveSection(goal: Goal, status: Status, archivePath: string, findingGroups?: FindingGroup[]): string[] {
389
499
  const facts: CompletionSummaryFacts = { goal, status, archivePath };
390
500
  const summary = resolveCompletionSummary(facts, goal.completionSummary).summary;
391
501
  const brief = humanCompletionBrief(summary, 140, RICH_VALUE_BUDGET);
@@ -400,10 +510,13 @@ export function buildRichArchiveSection(goal: Goal, status: Status, archivePath:
400
510
  details: withoutStaleNext(brief.details),
401
511
  countsLine,
402
512
  auditHistory: history,
513
+ objective: goal.objective,
514
+ durationLine: buildDurationLine(goal),
515
+ groups: findingGroups,
403
516
  });
404
517
  return [
405
518
  ...composeRichTerminalLines(parts, {
406
- headline: status === "complete" ? parts.headline : `## Aborted \u2014 ${brief.outcome}`,
519
+ headline: status === "complete" ? parts.headline : requestEchoHeadline("Aborted", goal.objective, brief.outcome),
407
520
  }),
408
521
  trailerBullet(stripApprovalModel(approval)),
409
522
  trailerBullet(countsLine),
@@ -493,6 +606,11 @@ export interface TerminalApprovalRenderInput {
493
606
  auditNote?: string;
494
607
  /** v0.38.37: what the agent deliberately left out (complete_goal leftOut). */
495
608
  leftOut?: string;
609
+ /**
610
+ * v0.38.50: agent-structured finding groups (complete_goal findingGroups,
611
+ * sanitized at claim time). Absent keeps the flat six-label fallback.
612
+ */
613
+ findingGroups?: FindingGroup[];
496
614
  }
497
615
 
498
616
  export interface TerminalApprovalRender {
@@ -568,6 +686,9 @@ export function buildTerminalApprovalRender(input: TerminalApprovalRenderInput):
568
686
  details: withoutStaleNext(richDetails),
569
687
  countsLine,
570
688
  auditHistory: input.goal.auditHistory,
689
+ objective: input.goal.objective,
690
+ durationLine: buildDurationLine(input.goal),
691
+ groups: input.findingGroups,
571
692
  });
572
693
  const chatBody = composeRichTerminalLines(richParts);
573
694
  const transcriptBody = composeRichTerminalLines(richParts);
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
+ import { stateRootPending } from "./glla-state-root.js";
4
5
  import {
5
6
  compareVersions,
6
7
  GLLA_PACKAGE_NAME,
@@ -60,6 +61,10 @@ export function refreshUpdateCheck(
60
61
  now = Date.now(),
61
62
  spawnFn: SpawnFn = spawn as unknown as SpawnFn,
62
63
  ): void {
64
+ // v0.38.49 audit: while sessionDir resolution is pending there is no
65
+ // session root yet — skip the refresh (and its cache write) instead of
66
+ // creating a <cwd>/.pi-glla fallback tree.
67
+ if (stateRootPending()) return;
63
68
  try {
64
69
  const cached = readUpdateCheckRaw(cwd);
65
70
  if (cached && now - cached.checkedAt < UPDATE_CHECK_TTL_MS) return;
@@ -272,6 +272,52 @@ export type AuditorRecoveryFailureClass = "transport" | "timeout" | "no-verdict"
272
272
  * completion assertion; the lifecycle fields make an interrupted isolated
273
273
  * audit distinguishable from one that is actively running. Fields beyond
274
274
  * `at` are optional so v0.34.20 and older claims remain recoverable. */
275
+ /**
276
+ * v0.38.50 (Gemini-gap survey): one agent-structured work area for the
277
+ * grouped terminal render. Presentation only — the six-label
278
+ * completionSummary stays the audited substance. Titles and findings are
279
+ * agent-authored and clipped at render; evidence tokens (`path:line`)
280
+ * are extracted mechanically, never inferred.
281
+ */
282
+ export interface FindingGroup {
283
+ title: string;
284
+ findings: string[];
285
+ }
286
+
287
+ export const MAX_FINDING_GROUPS = 6;
288
+ export const MAX_GROUP_FINDINGS = 6;
289
+ export const MAX_GROUP_TITLE_CHARS = 120;
290
+ export const MAX_GROUP_FINDING_CHARS = 500;
291
+
292
+ /**
293
+ * Bound agent-supplied finding groups at the trust boundary. Drops
294
+ * non-object groups, blank titles, and empty findings; clips the rest.
295
+ * Returns undefined when nothing valid survives — the flat six-label
296
+ * render stays the fallback, never a half-structured projection.
297
+ */
298
+ export function sanitizeFindingGroups(value: unknown): FindingGroup[] | undefined {
299
+ if (!Array.isArray(value)) return undefined;
300
+ const groups: FindingGroup[] = [];
301
+ for (const entry of value) {
302
+ if (groups.length >= MAX_FINDING_GROUPS) break;
303
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
304
+ const raw = entry as Record<string, unknown>;
305
+ const title = typeof raw.title === "string" ? raw.title.trim().slice(0, MAX_GROUP_TITLE_CHARS) : "";
306
+ if (!title) continue;
307
+ if (!Array.isArray(raw.findings)) continue;
308
+ const findings: string[] = [];
309
+ for (const finding of raw.findings) {
310
+ if (findings.length >= MAX_GROUP_FINDINGS) break;
311
+ if (typeof finding !== "string") continue;
312
+ const text = finding.trim().slice(0, MAX_GROUP_FINDING_CHARS);
313
+ if (text) findings.push(text);
314
+ }
315
+ if (findings.length === 0) continue;
316
+ groups.push({ title, findings });
317
+ }
318
+ return groups.length > 0 ? groups : undefined;
319
+ }
320
+
275
321
  export interface PendingCompletion {
276
322
  completionSummary?: string;
277
323
  verificationSummary?: string;
@@ -300,6 +346,12 @@ export interface PendingCompletion {
300
346
  * terminal user summary; absent means nothing was deliberately left out.
301
347
  */
302
348
  leftOut?: string;
349
+ /**
350
+ * v0.38.50: agent-structured finding groups for the grouped terminal
351
+ * render (complete_goal findingGroups, sanitized at claim time).
352
+ * Absent means the flat six-label render stays the fallback.
353
+ */
354
+ findingGroups?: FindingGroup[];
303
355
  /**
304
356
  * Durable one-shot recovery fence. A parked claim may receive one
305
357
  * automatic retry after a validated healthy lifecycle/recovery event;
@@ -2157,6 +2209,10 @@ function normalizePendingCompletion(value: unknown): PendingCompletion {
2157
2209
  _timeoutEscalation <= 100
2158
2210
  ? _timeoutEscalation
2159
2211
  : undefined;
2212
+ // v0.38.50: agent-structured finding groups survive reloads sanitized;
2213
+ // garbage at the boundary degrades to absent (flat fallback), never
2214
+ // to a half-structured projection.
2215
+ const sanitizedGroups = sanitizeFindingGroups(raw.findingGroups);
2160
2216
  return {
2161
2217
  ...canonicalOrUnknown,
2162
2218
  ...(auditorCandidateRefs !== undefined ? { auditorCandidateRefs } : {}),
@@ -2169,6 +2225,7 @@ function normalizePendingCompletion(value: unknown): PendingCompletion {
2169
2225
  ...(auditorFallbackExhausted ? { auditorFallbackExhausted: true } : {}),
2170
2226
  ...(auditorFailureAt ? { auditorFailureAt } : {}),
2171
2227
  ...(timeoutEscalation !== undefined ? { timeoutEscalation } : {}),
2228
+ ...(sanitizedGroups ? { findingGroups: sanitizedGroups } : {}),
2172
2229
  ...(phase === "running" || phase === "recovery-pending" || phase === "retry-waiting" ? { phase } : {}),
2173
2230
  ...(typeof raw.retryAttempts === "number"
2174
2231
  ? { retryAttempts: raw.retryAttempts }
@@ -1441,12 +1441,14 @@ async function retryStoredCompletionAudit(origin: CompletionAuditOrigin = "provi
1441
1441
  record: approvalRecord,
1442
1442
  // v0.38.37: the deliberate non-do the agent claimed, if any.
1443
1443
  ...(claim.leftOut ? { leftOut: claim.leftOut } : {}),
1444
+ // v0.38.50: agent-structured finding groups ride the audited claim.
1445
+ ...(claim.findingGroups ? { findingGroups: claim.findingGroups } : {}),
1444
1446
  extras: inspectionSessionPath
1445
1447
  ? [`Auditor session kept for review: pi --session ${inspectionSessionPath} (or pi --fork ${inspectionSessionPath}).`]
1446
1448
  : [],
1447
1449
  });
1448
1450
  const approvalObjective = state.goal.objective;
1449
- const archived = archiveCurrentGoal(liveCtx, "complete", `auditor ${result.model} approved (${origin})`);
1451
+ const archived = archiveCurrentGoal(liveCtx, "complete", `auditor ${result.model} approved (${origin})`, {}, { findingGroups: claim.findingGroups });
1450
1452
  if (!archived) {
1451
1453
  // archiveCurrentGoal already preserved the live record and warned the
1452
1454
  // user. Keep the approved claim recoverable, but never emit a terminal
@@ -29,6 +29,7 @@ import { Type } from "typebox";
29
29
  import { state, replaceState, persistStateLine } from "../goal-state.js";
30
30
 
31
31
  import {
32
+ type FindingGroup,
32
33
  type Goal,
33
34
  type Policy,
34
35
  type State,
@@ -1099,6 +1100,9 @@ function archiveCurrentGoal(
1099
1100
  status: Status,
1100
1101
  stopReason?: string,
1101
1102
  patch: Partial<Pick<Goal, "auditHistory" | "completionSummary" | "pendingTasks">> = {},
1103
+ // v0.38.50: agent-structured finding groups ride the audited claim into
1104
+ // the archive rich section only — abort/wip paths omit it (flat fallback).
1105
+ opts?: { findingGroups?: FindingGroup[] },
1102
1106
  ): boolean {
1103
1107
  if (!state.goal) return false;
1104
1108
  if (status !== "complete" && status !== "aborted") return false;
@@ -1147,7 +1151,7 @@ function archiveCurrentGoal(
1147
1151
  // it carries the same rich markdown as chat over the verbatim
1148
1152
  // six-label machine record. Built from the terminal goal AFTER the
1149
1153
  // summary fence resolves, so chat and archive cannot disagree.
1150
- const richSection = buildRichArchiveSection(terminalGoal, status, archivePath);
1154
+ const richSection = buildRichArchiveSection(terminalGoal, status, archivePath, opts?.findingGroups);
1151
1155
  const richMd = `${md}\n## Terminal summary\n\n${richSection.join("\n")}\n`;
1152
1156
  // An existing same-id archive is an immutable fence. Check it before
1153
1157
  // publishing an intent so an unrelated/sentinel winner can never make the
@@ -133,6 +133,7 @@ isGoalRevisionCurrent,
133
133
  type DurableChoiceRecord,
134
134
  buildDurableChoiceRecord,
135
135
  normalizeDurableDeferRecommendationInput,
136
+ sanitizeFindingGroups,
136
137
  } from "../goal-loop-core.js";
137
138
  import {
138
139
  applyValidatedBatch,
@@ -536,6 +537,18 @@ function registerAgentTools(pi: any): void {
536
537
  "Renders as the closing `• Left out:` bullet in the user-facing terminal summary. " +
537
538
  "Omit when nothing was deliberately left out — absent stays absent, never invented.",
538
539
  })),
540
+ findingGroups: Type.Optional(Type.Array(Type.Object({
541
+ title: Type.String({ maxLength: 120, description: "Work-area name (e.g. a subsystem, screen, or phase)" }),
542
+ findings: Type.Array(Type.String({ maxLength: 500 }), { maxItems: 6, description: "Findings in this area as `Lead: body with path:line evidence` (max 6 per area)" }),
543
+ }), {
544
+ maxItems: 6,
545
+ description:
546
+ "v0.38.50: optional finding groups for the terminal summary. Group multi-area work by area " +
547
+ "(subsystem, screen, phase); each finding is `Lead: body` with repo-relative `path:line` evidence tokens. " +
548
+ "4+ groups render as an Area | Finding | Evidence table, fewer as nested area sections. " +
549
+ "Presentation only — the six-label completionSummary stays the audited substance, and file:line tokens are never invented. " +
550
+ "Omit for single-area work — the flat six-label render stays the fallback.",
551
+ })),
539
552
  }),
540
553
  async execute(_id, params, signal, _onUpdate, execCtx) {
541
554
  const foreign0 = foreignToolGuard(execCtx);
@@ -559,7 +572,7 @@ function registerAgentTools(pi: any): void {
559
572
  }
560
573
  return { content: [{ type: "text", text: `No active goal — it is ${state.goal.status}.` }], details: {} };
561
574
  }
562
- const p = params as { completionSummary?: string; verificationSummary?: string; newObjective?: string; leftOut?: string };
575
+ const p = params as { completionSummary?: string; verificationSummary?: string; newObjective?: string; leftOut?: string; findingGroups?: unknown };
563
576
  if (state.goal.repairTarget) {
564
577
  return {
565
578
  content: [{ type: "text", text: `This repair card cannot be completed yet. Redraft the original target as a confirmed task list with propose_task_list (include objective: ${state.goal.repairTarget.objective.slice(0, 180)}), then continue the real work.` }],
@@ -772,12 +785,16 @@ function registerAgentTools(pi: any): void {
772
785
  if (!guardGoalBeforeContinuation(ctx, "completion-audit-dispatch", state.goal?.id)) {
773
786
  return staleToolResult();
774
787
  }
788
+ // v0.38.50: agent-structured finding groups ride the same claim —
789
+ // sanitized once at the boundary, rendered only after approval.
790
+ const sanitizedGroups = sanitizeFindingGroups(p.findingGroups);
775
791
  const completionClaim = beginCompletionAudit(ctx, {
776
792
  completionSummary: finalSummary,
777
793
  verificationSummary: p.verificationSummary,
778
794
  // v0.38.37: the deliberate non-do rides the pending claim into
779
795
  // the terminal render — the only source the summary may cite.
780
796
  ...(p.leftOut?.trim() ? { leftOut: p.leftOut.trim().slice(0, 500) } : {}),
797
+ ...(sanitizedGroups ? { findingGroups: sanitizedGroups } : {}),
781
798
  at: nowIso(),
782
799
  }, "complete-goal");
783
800
  if (!completionClaim) {
@@ -1232,7 +1249,9 @@ function registerAgentTools(pi: any): void {
1232
1249
  if (result.error === "Auditor aborted.") {
1233
1250
  // v0.38.37: capture the agent-claimed non-do before the claim is
1234
1251
  // cleared below — the Esc-complete path renders the same voice.
1252
+ // v0.38.50: same for agent-structured finding groups.
1235
1253
  const escLeftOut = state.goal.pendingCompletion?.leftOut;
1254
+ const escFindingGroups = state.goal.pendingCompletion?.findingGroups;
1236
1255
  updateGoal({ status: "active", auditHistory: history, pendingCompletion: undefined, pauseReason: "audit aborted by user (Esc)" }, ctx);
1237
1256
  const abortConfirmCtx = freshCtxForGeneration(auditGeneration);
1238
1257
  if (!abortConfirmCtx) return staleToolResult();
@@ -1274,8 +1293,9 @@ function registerAgentTools(pi: any): void {
1274
1293
  // v0.38.37: the claim was cleared pre-confirm; the non-do was
1275
1294
  // captured from it above.
1276
1295
  ...(escLeftOut ? { leftOut: escLeftOut } : {}),
1296
+ ...(escFindingGroups ? { findingGroups: escFindingGroups } : {}),
1277
1297
  });
1278
- if (!archiveCurrentGoal(ctx, "complete", terminalReason)) {
1298
+ if (!archiveCurrentGoal(ctx, "complete", terminalReason, {}, { findingGroups: escFindingGroups })) {
1279
1299
  return {
1280
1300
  content: [{ type: "text", text: "The audit was aborted, but the terminal archive could not be persisted. The goal remains active; fix persistence and retry." }],
1281
1301
  details: {},
@@ -1330,13 +1350,15 @@ function registerAgentTools(pi: any): void {
1330
1350
  record: manualArchiveRecord,
1331
1351
  // v0.38.37: the deliberate non-do rides the durable claim.
1332
1352
  ...(durableCompletionClaim.leftOut ? { leftOut: durableCompletionClaim.leftOut } : {}),
1353
+ // v0.38.50: agent-structured finding groups ride the same claim.
1354
+ ...(durableCompletionClaim.findingGroups ? { findingGroups: durableCompletionClaim.findingGroups } : {}),
1333
1355
  extras: inspectionSessionPath
1334
1356
  ? [`Auditor session kept for review: pi --session ${inspectionSessionPath} (or pi --fork ${inspectionSessionPath}).`]
1335
1357
  : [],
1336
1358
  });
1337
1359
  const manualObjective = state.goal.objective;
1338
1360
  const manualGoalId = state.goal.id;
1339
- const archived = archiveCurrentGoal(ctx, "complete", terminalReason);
1361
+ const archived = archiveCurrentGoal(ctx, "complete", terminalReason, {}, { findingGroups: durableCompletionClaim.findingGroups });
1340
1362
  if (!archived) {
1341
1363
  // The archive helper preserves the live objective and emits the
1342
1364
  // persistence warning. Stop here: an approved verdict is not a
@@ -2213,9 +2235,14 @@ function registerAgentTools(pi: any): void {
2213
2235
  const tl = state.goal.taskList;
2214
2236
  // v0.38.48: copy-swap — the live list is never mutated in place, so a
2215
2237
  // kill between verify and persist (or a persist failure) cannot leave
2216
- // partial state. Message strings are unchanged (pinned by tests).
2238
+ // partial state. Message strings are pinned by
2239
+ // tests/task-atomicity-gate.test.ts.
2217
2240
  const t = findTask(tl, p.id);
2218
- if (t && t.status !== "complete") {
2241
+ if (!t) return { content: [{ type: "text", text: `Task ${p.id} not found.` }], details: {} };
2242
+ // v0.38.49 audit: an existing but already-complete task is a state
2243
+ // fact, not a typo — report it instead of the not-found fallthrough.
2244
+ if (t.status === "complete") return { content: [{ type: "text", text: `Task ${p.id} is already complete.` }], details: {} };
2245
+ if (t) {
2219
2246
  const checkRes = await verifyTaskMilestone(ctx, t.verificationContract, signal);
2220
2247
  if (checkRes) {
2221
2248
  return {
@@ -2231,7 +2258,6 @@ function registerAgentTools(pi: any): void {
2231
2258
  }
2232
2259
  return { content: [{ type: "text", text: `Task ${p.id} marked complete.` }], details: {} };
2233
2260
  }
2234
- return { content: [{ type: "text", text: `Task ${p.id} not found.` }], details: {} };
2235
2261
  },
2236
2262
  }));
2237
2263
 
@@ -2253,8 +2279,13 @@ function registerAgentTools(pi: any): void {
2253
2279
  return { content: [{ type: "text", text: "No task list in this goal." }], details: {} };
2254
2280
  }
2255
2281
  const tl = state.goal.taskList;
2256
- // v0.38.48: copy-swap (see complete_task). Message strings unchanged.
2282
+ // v0.38.48: copy-swap (see complete_task). Message strings are pinned
2283
+ // by tests/task-atomicity-gate.test.ts.
2257
2284
  const t = findTask(tl, p.id);
2285
+ if (!t) return { content: [{ type: "text", text: `Task ${p.id} not found.` }], details: {} };
2286
+ // v0.38.49 audit: same-status moves are no-ops with a state-fact
2287
+ // reply, not a persist round-trip reporting a change that happened.
2288
+ if (t.status === p.status) return { content: [{ type: "text", text: `Task ${p.id} is already ${p.status}.` }], details: {} };
2258
2289
  if (t) {
2259
2290
  if (p.status === "complete") {
2260
2291
  const checkRes = await verifyTaskMilestone(ctx, t.verificationContract, signal);
@@ -2273,7 +2304,6 @@ function registerAgentTools(pi: any): void {
2273
2304
  }
2274
2305
  return { content: [{ type: "text", text: `Task ${p.id} → ${p.status}` }], details: {} };
2275
2306
  }
2276
- return { content: [{ type: "text", text: `Task ${p.id} not found.` }], details: {} };
2277
2307
  },
2278
2308
  }));
2279
2309
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.38.48",
3
+ "version": "0.38.50",
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",
@@ -55,9 +55,9 @@ When a goal, list item, or pending task explicitly says `Agent: Designer`, `Role
55
55
 
56
56
  ## Available tools
57
57
 
58
- You have `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`, the `subagent` tool, and the goal toolkit (`propose_task_list`, `complete_task`, `update_task_status`, `record_goal_judgment`, `pause_goal`, `complete_goal`), plus the list tools (`list_add`, `list_status`, `list_activate`) — when the user asks to queue more work ("add these to my list", "queue these 10 things"), call `list_add` with the items; when unsure what is running or waiting, call `list_status`.
58
+ You have `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`, the `subagent` tool, and the goal toolkit (`propose_task_list`, `complete_task`, `update_task_status`, `update_task_batch`, `record_goal_judgment`, `pause_goal`, `complete_goal`), plus the list tools (`list_add`, `list_status`, `list_activate`) — when the user asks to queue more work ("add these to my list", "queue these 10 things"), call `list_add` with the items; when unsure what is running or waiting, call `list_status`.
59
59
 
60
- If the objective decomposes into milestones and no task list exists yet, call `propose_task_list` early — the user confirms it, then you track progress with `complete_task` / `update_task_status` as you go (not in a batch at the end). Limits: 20 tasks, 5 subtasks per task.
60
+ If the objective decomposes into milestones and no task list exists yet, call `propose_task_list` early — the user confirms it, then you track progress with `complete_task` / `update_task_status` / `update_task_batch` as you go (not as a post-hoc checklist at the end). For several status changes at once, prefer one `update_task_batch` call: the whole batch validates first, milestones verify, then all statuses persist in one write. Limits: 20 tasks, 5 subtasks per task.
61
61
 
62
62
  When the agent calls any of these, the orchestrator tracks the call and persists state to `.pi-glla/active.jsonl`.
63
63
 
@@ -156,6 +156,8 @@ Do NOT conclude "the loop is too eager" or "I am broken" before checking what a
156
156
 
157
157
  Use tasks as PROGRESS TRACKERS during your work — not as a post-hoc checklist to batch-mark at the end.
158
158
 
159
+ Pending-task gate: `complete_goal` is refused BEFORE the auditor when committed tasks are still open — the refusal names each open task and sends nothing to the auditor. Finish them with `complete_task` / `update_task_batch`, or record an explicit deferral with `record_goal_judgment` (`choice="deferred"` + `taskId`) for a genuinely blocked item.
160
+
159
161
  Before deciding that the goal is achieved, perform a completion audit against the actual current state:
160
162
 
161
163
  - Restate the objective as concrete deliverables or success criteria.
@@ -174,7 +176,7 @@ recap and synthesizes a recorded-facts-only fallback at terminalization when a
174
176
  legacy or incomplete caller omits it; never invent a commit, changed file, or
175
177
  passing test to make the recap sound complete.
176
178
 
177
- Then call `complete_goal`. The orchestrator will spawn an **isolated auditor** in a fresh session to verify, and either accept (mark goal complete) or reject (continue work).
179
+ Then call `complete_goal` — only with every committed task finished or explicitly deferred, otherwise the claim is refused before audit (see the pending-task gate above). The orchestrator will spawn an **isolated auditor** in a fresh session to verify, and either accept (mark goal complete) or reject (continue work).
178
180
 
179
181
  If your work has shifted to items different from the original objective (the original was blocked, higher-ROI items emerged): pass `newObjective` to `complete_goal` to atomically update the objective and audit against the NEW one — do NOT call `complete_goal` on the original objective after shipping different work, the auditor will disapprove because the original isn't shipped. Alternatively `pause_goal` proposing a `/goal tweak` if the shift needs the user's call.
180
182
 
@@ -203,4 +205,4 @@ The orchestrator's backstop is the stall watchdog: three consecutive turns with
203
205
 
204
206
  `complete_goal` submits a nonterminal claim, not a completion verdict. While the detached audit is pending, do not say the goal is done/complete/approved or present a final success summary. Do not wait or poll. If a response is needed, give one brief pending-status sentence. GLLA posts the concrete outcome-first summary into chat after verified approval and durable archive, without needing another user prompt. Do not duplicate that summary. A rejected claim remains unfinished and repair work continues.
205
207
 
206
- The agent-written completionSummary is user-facing evidence, not a step log: open with the outcome in plain words, then one verifiable result per label with its proof inline (test counts, file paths, commit refs) — never raw run stats. Keep each label value to one line of ~90 chars so every chat bullet fits without a trailing …; cite human-readable proof (counts, versions, repo-relative paths, PR numbers) and never machine paths (/var/tmp logs, tarballs — those live in the archive only). Cut values at clause boundaries, never mid-word; write Next: as one concrete follow-up action or none (audit-self-referential lines like "verdict decides" are stripped at render); pass what was deliberately left out as the complete_goal leftOut parameter — it renders as the closing bullet, and is omitted when absent.
208
+ The agent-written completionSummary is user-facing evidence, not a step log: open with the outcome in plain words, then one verifiable result per label with its proof inline (test counts, file paths, commit refs) — never raw run stats. Keep each label value to one or two short clauses so it survives the renderer's clause-boundary clipping (rich sections budget ~200 chars per value); cite human-readable proof (counts, versions, repo-relative paths, PR numbers) and never machine paths (/var/tmp logs, tarballs — those live in the archive only). Cut values at clause boundaries, never mid-word; write Next: as one concrete follow-up action or none (audit-self-referential lines like "verdict decides" are stripped at render); pass what was deliberately left out as the complete_goal leftOut parameter — it renders as the closing bullet, and is omitted when absent. When the work spans distinct areas (subsystems, screens, phases), group the findings with the complete_goal findingGroups parameter — one group per area, each finding as `Lead: body` with repo-relative `path:line` evidence tokens (never invented paths); 4+ groups render as an Area | Finding | Evidence table, fewer as nested area sections. Single-area work omits findingGroups — the flat six-label render stays the fallback.