pi-goal-list-loop-audit 0.38.44 → 0.38.45

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.38.45 — full-project audit pass: 16 fixes across version, summary, and approval paths (2026-09-10)
4
+
5
+ ### Fixed
6
+
7
+ - **Update-check spawn hardening (HIGH):** `refreshUpdateCheck` now subscribes to async `error` (a missing/broken npm crashed the host unhandled), detaches via `unref`, ignores stderr/stdin, and caches only version-shaped stdout tokens — registry noise can no longer poison the sidecar.
8
+ - **`/glla version` honesty (HIGH):** no staleness claim when the running version is unreadable (previously printed a false "up to date"); both surfaces share one strict sidecar reader so the status tail and the command cannot disagree.
9
+ - **Recorded-facts Next survives (HIGH):** `withoutStaleNext` exempts the concrete "review the durable record at …" fallback Next that the `/review/i` clause ate, leaving fallback approval chats actionless.
10
+ - **No per-contact spawn storm:** the TTL skip uses the raw sidecar read, so a future-dated cache (clock skew) suppresses the spawn instead of causing one per command contact.
11
+ - **Approval re-render after delivery:** delivered history dedups exact duplicates only (repeated settlements never double-notify); a genuine re-approval with new verdict lines queues fresh — content, not goalId, decides.
12
+ - **SessionDir-aware sidecar path:** `updateCheckPath` resolves via `resolveGllaStateDir` instead of hardcoding `cwd/.pi-glla`.
13
+ - **String-safety:** `clipSummaryValue` is code-point-safe (no more split surrogate pairs); `chatSafeDetailValue` converges nested machine-path groups to a fixpoint; label segmentation uses last-occurrence search so a label named inside a value no longer steals later segments.
14
+ - **Verdict tally uses its clock:** `auditorVerdictTally(now)` suppresses future `lastAt` instead of printing "0s ago"; dead `void now` and dead `readUpdateCheck` import removed; `recovery-resume.json` documented as a reserved hook with no current producer.
15
+ - **Pins:** spawn-error swallow, token validation, future-cache skip, prerelease compare, unknown-version silence, recorded-facts survival, nested-husk convergence, surrogate safety, last-restatement-wins, delivery-then-reapproval, paused-branch tail, future-verdict silence.
16
+
3
17
  ## 0.38.44 — stale-version surfacing: running version in the status line (2026-09-10)
4
18
 
5
19
  ### Fixed
package/docs/INDEX.md CHANGED
@@ -18,7 +18,7 @@ For shipped docs, the relevant entry points are:
18
18
  failback; v0.35.9 hardened cross-version npm tarball checks; v0.35.10
19
19
  handles multi-entry npm dry-run reports; v0.35.11 accepts both npm report
20
20
  shapes; v0.35.12 supports npm 12's keyed pack reports; v0.35.13 fixes stale-API recovery loops.
21
- v0.35.14–v0.38.44 continue through the supervisor freeze (`/glla pause`),
21
+ v0.35.14–v0.38.45 continue through the supervisor freeze (`/glla pause`),
22
22
  load hold, auditor picker parity, Windows launch fix, zombie-watchdog
23
23
  subagent carve-out, due-wait backstop, the `/glla agents` visibility panel,
24
24
  durable state-root selection, blank-until-resume auditor context, frozen
@@ -90,13 +90,28 @@ export function persistApprovalRender(cwd: string, render: {
90
90
  }): boolean {
91
91
  const at = nowIso();
92
92
  const existing = readRenders(cwd);
93
- if (existing.some((entry) => entry.goalId === render.goalId)) return true;
93
+ // v0.38.30 truncation (also code-point-safe): compare what WOULD be
94
+ // stored, so an identical re-persist of long lines still dedups.
95
+ const incomingLines = render.chatLines.slice(0, MAX_RENDER_CHAT_LINES)
96
+ .map((line) => [...line].slice(0, MAX_RENDER_LINE_CHARS).join(""));
97
+ // Dedup has two layers. (1) An UNDELIVERED entry for the goal means a
98
+ // render is already queued — a second persist before delivery changes
99
+ // nothing. (2) v0.38.45 audit: delivered history dedups only EXACT
100
+ // duplicates (a repeated settlement re-persisting identical lines must
101
+ // not double-notify) — but a re-approval carries new verdict lines, and
102
+ // dropping those silently hid an archive-failure retry's chat summary
103
+ // while the caller believed it queued. Content, not goalId, decides.
104
+ if (existing.some((entry) => !entry.deliveredAt && entry.goalId === render.goalId)) return true;
105
+ const sameLines = (a: string[], b: string[]): boolean =>
106
+ a.length === b.length && a.every((line, i) => line === b[i]);
107
+ if (existing.some((entry) =>
108
+ entry.deliveredAt && entry.goalId === render.goalId && sameLines(entry.chatLines, incomingLines))) return true;
94
109
  const entry: PendingApprovalRender = {
95
110
  goalId: render.goalId,
96
111
  // v0.38.30 audit: code-point truncation (char slice split surrogate
97
112
  // pairs) + bounded chat lines (the 20-entry cap never bound bytes).
98
113
  objective: [...render.objective].slice(0, 300).join(""),
99
- chatLines: render.chatLines.slice(0, MAX_RENDER_CHAT_LINES).map((line) => [...line].slice(0, MAX_RENDER_LINE_CHARS).join("")),
114
+ chatLines: incomingLines,
100
115
  createdAt: at,
101
116
  };
102
117
  // The cap trims DELIVERED history only: undelivered renders are never
@@ -90,32 +90,53 @@ export function isUsefulCompletionSummary(text: string | undefined): boolean {
90
90
  export function clipSummaryValue(value: string, limit: number): string {
91
91
  const clean = value.replace(/\s+/g, " ").trim();
92
92
  const capped = Number.isFinite(limit) ? Math.max(8, Math.floor(limit)) : 72;
93
- if (clean.length <= capped) return clean;
94
- const head = clean.slice(0, capped - 1);
93
+ // v0.38.45 audit: code-point-safe — UTF-16 slice split surrogate pairs.
94
+ const units = [...clean];
95
+ if (units.length <= capped) return clean;
96
+ const headUnits = units.slice(0, capped - 1);
97
+ const head = headUnits.join("");
95
98
  const floor = Math.max(16, Math.floor((capped - 1) * 0.4));
96
99
  let boundary = -1;
97
- for (let i = 0; i < head.length; i++) {
98
- if (/[,;:·—–(+[\[]/.test(head[i]!)) boundary = i;
99
- }
100
+ headUnits.forEach((unit, i) => {
101
+ if (/[,;:·—–(+[\[]/u.test(unit)) boundary = i;
102
+ });
100
103
  if (boundary >= floor) {
101
- const cut = head.slice(0, boundary).replace(/[,;:·—–(+[\[\s]+$/u, "");
102
- if (cut.length >= Math.min(floor, 16)) return `${cut}…`;
104
+ const cut = headUnits.slice(0, boundary).join("").replace(/[,;:·—–(+[\[\s]+$/u, "");
105
+ if ([...cut].length >= Math.min(floor, 16)) return `${cut}…`;
103
106
  }
104
- const space = head.lastIndexOf(" ");
105
- const kept = (space > capped / 2 ? head.slice(0, space) : head)
107
+ let space = -1;
108
+ headUnits.forEach((unit, i) => {
109
+ if (unit === " ") space = i;
110
+ });
111
+ const kept = (space > capped / 2 ? headUnits.slice(0, space).join("") : head)
106
112
  .trimEnd()
107
113
  .replace(/[,;:·—–(+\[]$/u, "");
108
114
  return `${kept}…`;
109
115
  }
110
116
 
117
+ /** v0.38.45 audit: LAST-occurrence label search — first-occurrence
118
+ * indexOf let a label named inside an earlier label's VALUE steal the
119
+ * segmentation of every later label ("Outcome: see Tests: x. Tests: y"
120
+ * read Outcome as "see"). The last restatement wins, which also favors
121
+ * the final wording when an agent echoes the skeleton twice. Residual
122
+ * ambiguity (a single skeleton whose value names a later label once)
123
+ * is accepted: values keep their text, only the boundary moves. */
124
+ function labelPositions(lower: string): Array<{ label: string; start: number }> {
125
+ const positions: Array<{ label: string; start: number }> = [];
126
+ for (const label of COMPLETION_SUMMARY_LABELS) {
127
+ const start = lower.lastIndexOf(label.toLowerCase());
128
+ if (start < 0) continue;
129
+ positions.push({ label, start });
130
+ }
131
+ return positions;
132
+ }
133
+
111
134
  export function compactCompletionSummary(text: string | undefined, maxValueLength = 72): string {
112
135
  const source = completionSummaryBody(text ?? "").replace(/\s+/g, " ").trim();
113
136
  if (!source) return "not recorded";
114
137
  const lower = source.toLowerCase();
115
138
  const limit = Number.isFinite(maxValueLength) ? Math.max(8, Math.floor(maxValueLength)) : 72;
116
- const positions = COMPLETION_SUMMARY_LABELS
117
- .map((label) => ({ label, start: lower.indexOf(label.toLowerCase()) }))
118
- .filter((entry) => entry.start >= 0);
139
+ const positions = labelPositions(lower);
119
140
  const parts = COMPLETION_SUMMARY_LABELS.map((label) => {
120
141
  const current = positions.find((entry) => entry.label === label);
121
142
  const name = label.slice(0, -1);
@@ -159,7 +180,11 @@ export interface HumanCompletionBrief {
159
180
  * the durable archive keeps the full text. Falls back to the original
160
181
  * when stripping would empty the value. */
161
182
  export function chatSafeDetailValue(value: string): string {
162
- const withoutGroups = value.replace(/\([^()]*\)/g, (group) => {
183
+ // v0.38.45 audit: loop the innermost-group strip to a fixpoint
184
+ // (bounded) — a single pass left nested husks like "(log )" behind.
185
+ let withoutGroups = value;
186
+ for (let pass = 0; pass < 5; pass++) {
187
+ const next = withoutGroups.replace(/\([^()]*\)/g, (group) => {
163
188
  // A parenthesized group that carries nothing but stripped tokens and
164
189
  // receipt words is machine packaging — drop the whole group so no
165
190
  // `( tarball )` husk survives. Groups with real words stay verbatim.
@@ -169,7 +194,10 @@ export function chatSafeDetailValue(value: string): string {
169
194
  .replace(/\S+\.tgz\b/g, "")
170
195
  .replace(/\btarballs?\b|\blogs?\b/gi, "");
171
196
  return /[a-z]/i.test(inner) ? group : "";
172
- });
197
+ });
198
+ if (next === withoutGroups) break;
199
+ withoutGroups = next;
200
+ }
173
201
  const stripped = withoutGroups
174
202
  .replace(/(?:\/var)?\/tmp\/\S+/g, "")
175
203
  .replace(/\btarballs?\s+\S+\.tgz\b/gi, "")
@@ -215,6 +243,10 @@ export function humanCompletionBrief(
215
243
  * approval`, …) still drop, but the first concrete action survives as
216
244
  * the closing bullet. The full six-label record stays in the archive. */
217
245
  const STALE_NEXT_PATTERN = /auditor|verdict|approv|audit|settl|review/i;
246
+ /** The recorded-facts fallback Next is concrete (`review the durable
247
+ * record at …`) and must survive the stale-Next filter (v0.38.45 audit:
248
+ * /review/i ate it, leaving fallback approval chats with no next action). */
249
+ const RECORDED_FACTS_NEXT_PATTERN = /review the durable/i;
218
250
  export function withoutStaleNext(details: string[] | undefined): string[] {
219
251
  const kept: string[] = [];
220
252
  let actionKept = false;
@@ -223,6 +255,12 @@ export function withoutStaleNext(details: string[] | undefined): string[] {
223
255
  kept.push(detail);
224
256
  continue;
225
257
  }
258
+ if (RECORDED_FACTS_NEXT_PATTERN.test(detail)) {
259
+ if (actionKept) continue;
260
+ actionKept = true;
261
+ kept.push(detail);
262
+ continue;
263
+ }
226
264
  if (STALE_NEXT_PATTERN.test(detail)) continue;
227
265
  if (actionKept) continue;
228
266
  actionKept = true;
@@ -394,9 +432,7 @@ export function buildTerminalApprovalRender(input: TerminalApprovalRenderInput):
394
432
  export function completionSummaryLines(text: string | undefined, maxValueLength = 240, lineWidth?: number): string[] {
395
433
  const source = completionSummaryBody(text ?? "").replace(/\s+/g, " ").trim();
396
434
  const lower = source.toLowerCase();
397
- const positions = COMPLETION_SUMMARY_LABELS
398
- .map((label) => ({ label, start: lower.indexOf(label.toLowerCase()) }))
399
- .filter((entry) => entry.start >= 0);
435
+ const positions = labelPositions(lower);
400
436
  return COMPLETION_SUMMARY_LABELS.map((label) => {
401
437
  const name = label.slice(0, -1);
402
438
  const current = positions.find((entry) => entry.label === label);
@@ -1,9 +1,18 @@
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 { compareVersions, GLLA_PACKAGE_NAME, updateCheckPath } from "./glla-version.js";
4
+ import {
5
+ compareVersions,
6
+ GLLA_PACKAGE_NAME,
7
+ isVersionLike,
8
+ readUpdateCheck,
9
+ readUpdateCheckRaw,
10
+ updateCheckPath,
11
+ type UpdateCheckCache,
12
+ } from "./glla-version.js";
5
13
 
6
- export { compareVersions, updateCheckPath };
14
+ export { compareVersions, readUpdateCheck, updateCheckPath };
15
+ export type { UpdateCheckCache };
7
16
 
8
17
  /**
9
18
  * v0.38.44 (field 20260909_161057): a live session rendered the
@@ -21,37 +30,30 @@ export { compareVersions, updateCheckPath };
21
30
  export const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1_000;
22
31
  export const UPDATE_CHECK_TIMEOUT_MS = 15_000;
23
32
 
24
- export interface UpdateCheckCache {
25
- latest: string;
26
- checkedAt: number;
27
- }
28
-
29
- /** Read the cache; null when missing, unreadable, malformed, or future-dated. */
30
- export function readUpdateCheck(cwd: string): UpdateCheckCache | null {
31
- try {
32
- const parsed: unknown = JSON.parse(fs.readFileSync(updateCheckPath(cwd), "utf8"));
33
- if (typeof parsed !== "object" || parsed === null) return null;
34
- const record = parsed as Record<string, unknown>;
35
- if (typeof record.latest !== "string" || !record.latest.trim()) return null;
36
- if (typeof record.checkedAt !== "number" || !Number.isFinite(record.checkedAt)) return null;
37
- if (record.checkedAt > Date.now() + 60_000) return null;
38
- return { latest: record.latest.trim(), checkedAt: record.checkedAt };
39
- } catch {
40
- return null;
41
- }
33
+ export interface SpawnedRefreshChild {
34
+ on(event: "close", listener: (code: number | null) => void): void;
35
+ on(event: "error", listener: (err: unknown) => void): void;
36
+ unref?: () => void;
37
+ stdout: { on(event: "data", listener: (chunk: Buffer) => void): void };
42
38
  }
43
39
 
44
40
  export type SpawnFn = (
45
41
  command: string,
46
42
  args: string[],
47
- options: { timeout: number },
48
- ) => { on(event: "close", listener: (code: number | null) => void): void; stdout: { on(event: "data", listener: (chunk: Buffer) => void): void } };
43
+ options: { timeout: number; stdio?: Array<"ignore" | "pipe"> },
44
+ ) => SpawnedRefreshChild;
49
45
 
50
46
  /**
51
47
  * Throttled fire-and-forget refresh: returns immediately in every case.
52
48
  * Skips when the cache is within TTL; otherwise shells `npm view` with a
53
49
  * bounded timeout and rewrites the cache on success. Never throws — an
54
50
  * offline session simply keeps rendering without a nudge.
51
+ * v0.38.45 audit: the spawned child carries an `error` listener (a
52
+ * missing/broken npm emits async `error`, which the outer try/catch
53
+ * cannot catch and which crashes the host unhandled), detaches via
54
+ * unref, and ignores stderr/stdin; only version-shaped stdout tokens
55
+ * are cached. The TTL skip uses the RAW read so a future-dated cache
56
+ * (clock skew) suppresses the spawn instead of causing one per contact.
55
57
  */
56
58
  export function refreshUpdateCheck(
57
59
  cwd: string,
@@ -59,9 +61,18 @@ export function refreshUpdateCheck(
59
61
  spawnFn: SpawnFn = spawn as unknown as SpawnFn,
60
62
  ): void {
61
63
  try {
62
- const cached = readUpdateCheck(cwd);
64
+ const cached = readUpdateCheckRaw(cwd);
63
65
  if (cached && now - cached.checkedAt < UPDATE_CHECK_TTL_MS) return;
64
- const child = spawnFn("npm", ["view", GLLA_PACKAGE_NAME, "version"], { timeout: UPDATE_CHECK_TIMEOUT_MS });
66
+ const child = spawnFn("npm", ["view", GLLA_PACKAGE_NAME, "version"], {
67
+ timeout: UPDATE_CHECK_TIMEOUT_MS,
68
+ stdio: ["ignore", "pipe", "ignore"],
69
+ });
70
+ // A missing/broken npm emits async `error`, not a sync throw and
71
+ // not a nonzero close — without this listener the host crashes.
72
+ child.on("error", () => {
73
+ // Fail-silent by design: keep rendering without a nudge.
74
+ });
75
+ if (typeof child.unref === "function") child.unref();
65
76
  let out = "";
66
77
  child.stdout.on("data", (chunk) => {
67
78
  out += chunk.toString();
@@ -71,10 +82,10 @@ export function refreshUpdateCheck(
71
82
  try {
72
83
  if (code !== 0) return;
73
84
  const latest = out.trim().split(/\s+/).pop() ?? "";
74
- if (!latest) return;
85
+ if (!isVersionLike(latest)) return;
75
86
  const dir = path.dirname(updateCheckPath(cwd));
76
87
  fs.mkdirSync(dir, { recursive: true });
77
- fs.writeFileSync(updateCheckPath(cwd), JSON.stringify({ latest, checkedAt: Date.now() }));
88
+ fs.writeFileSync(updateCheckPath(cwd), JSON.stringify({ latest: latest.trim(), checkedAt: Date.now() }));
78
89
  } catch {
79
90
  // Cache write failure is invisible by design.
80
91
  }
@@ -1,21 +1,31 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { resolveGllaStateDir } from "./glla-state-root.js";
4
5
 
5
6
  export const GLLA_PACKAGE_NAME = "pi-goal-list-loop-audit";
6
7
 
7
8
  /** Sidecar path shared with the update check (kept here so both
8
- * glla-version and glla-update-check resolve it without a cycle). */
9
+ * glla-version and glla-update-check resolve it without a cycle).
10
+ * v0.38.45 audit: root-aware — sessionDir mode keeps the sidecar beside
11
+ * every other state file instead of splitting it under cwd. */
9
12
  export function updateCheckPath(cwd: string): string {
10
- return path.join(cwd, ".pi-glla", "update-check.json");
13
+ return path.join(resolveGllaStateDir(cwd), "update-check.json");
11
14
  }
12
15
 
13
16
  /** Numeric semver-ish compare: 1 when a > b, -1 when a < b, 0 when equal
14
- * or unparseable. Pure — shared by the version command and the nudge. */
17
+ * or unparseable. Pure — shared by the version command and the nudge.
18
+ * v0.38.45 audit: strictly numeric segments — parseInt equated
19
+ * prereleases (`3-beta`→3), so a prerelease running build never nudged. */
15
20
  export function compareVersions(a: string, b: string): number {
16
- const pa = a.trim().replace(/^v/, "").split(".").map((part) => Number.parseInt(part, 10));
17
- const pb = b.trim().replace(/^v/, "").split(".").map((part) => Number.parseInt(part, 10));
18
- if (pa.some((n) => !Number.isFinite(n)) || pb.some((n) => !Number.isFinite(n))) return 0;
21
+ const numParts = (s: string): number[] | null => {
22
+ const parts = s.trim().replace(/^v/, "").split(".");
23
+ if (parts.some((part) => !/^\d+$/.test(part))) return null;
24
+ return parts.map((part) => Number.parseInt(part, 10));
25
+ };
26
+ const pa = numParts(a);
27
+ const pb = numParts(b);
28
+ if (!pa || !pb) return 0;
19
29
  const len = Math.max(pa.length, pb.length);
20
30
  for (let i = 0; i < len; i++) {
21
31
  const x = pa[i] ?? 0;
@@ -31,6 +41,42 @@ const PACKAGE_JSON_PATH = path.resolve(
31
41
  "../package.json",
32
42
  );
33
43
 
44
+ export interface UpdateCheckCache {
45
+ latest: string;
46
+ checkedAt: number;
47
+ }
48
+
49
+ /** Raw sidecar read: null when missing/unreadable/malformed. Accepts
50
+ * future-dated entries — the refresh skip-check uses this so one skewed
51
+ * clock does not become a per-contact spawn storm (v0.38.45 audit). */
52
+ export function readUpdateCheckRaw(cwd: string): UpdateCheckCache | null {
53
+ try {
54
+ const parsed: unknown = JSON.parse(fs.readFileSync(updateCheckPath(cwd), "utf8"));
55
+ if (typeof parsed !== "object" || parsed === null) return null;
56
+ const record = parsed as Record<string, unknown>;
57
+ if (typeof record.latest !== "string" || !record.latest.trim()) return null;
58
+ if (typeof record.checkedAt !== "number" || !Number.isFinite(record.checkedAt)) return null;
59
+ return { latest: record.latest.trim(), checkedAt: record.checkedAt };
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /** Strict sidecar read for render surfaces: additionally rejects
66
+ * future-dated entries (clock skew must read as unknown, never fresh). */
67
+ export function readUpdateCheck(cwd: string): UpdateCheckCache | null {
68
+ const raw = readUpdateCheckRaw(cwd);
69
+ if (!raw) return null;
70
+ if (raw.checkedAt > Date.now() + 60_000) return null;
71
+ return raw;
72
+ }
73
+
74
+ /** npm-version-shaped tokens only — the refresh caches stdout, so
75
+ * registry noise must never poison the sidecar (v0.38.45 audit). */
76
+ export function isVersionLike(token: string): boolean {
77
+ return /^v?\d+\.\d+\.\d+([\-+][\w.]+)?$/.test(token.trim());
78
+ }
79
+
34
80
  export interface GllaVersionInfo {
35
81
  name: string;
36
82
  version: string;
@@ -70,16 +116,17 @@ export function formatGllaVersion(cwd?: string, info: GllaVersionInfo = readGlla
70
116
  // cached registry latest, say whether this session is stale and how
71
117
  // to fix it. No cache (offline / never checked) keeps the legacy
72
118
  // three lines — absence of evidence is not evidence of freshness.
73
- if (cwd) {
119
+ // v0.38.45 audit: the strict shared reader (same validation as the
120
+ // status tail, so the two surfaces cannot disagree), and no staleness
121
+ // claim at all when the running version is unreadable — "unknown"
122
+ // compared equal and printed a false "up to date".
123
+ if (cwd && info.version !== "unknown") {
74
124
  try {
75
- const parsed: unknown = JSON.parse(fs.readFileSync(updateCheckPath(cwd), "utf8"));
76
- const latest = typeof parsed === "object" && parsed !== null
77
- ? (parsed as Record<string, unknown>).latest
78
- : undefined;
79
- if (typeof latest === "string" && latest.trim()) {
80
- lines.push(`Registry latest: v${latest.trim()}.`);
125
+ const cached = readUpdateCheck(cwd);
126
+ if (cached) {
127
+ lines.push(`Registry latest: v${cached.latest}.`);
81
128
  lines.push(
82
- compareVersions(latest, info.version) > 0
129
+ compareVersions(cached.latest, info.version) > 0
83
130
  ? `This session is stale — update: pi install npm:${info.name}@latest then /reload.`
84
131
  : "This session is up to date.",
85
132
  );
@@ -36,7 +36,6 @@ import type { SettingsSectionId } from "./settings-menu.js";
36
36
  import { cmdLoop, clearLoopTimer, finishLoopGit, isLoopActive, scheduleLoopTick } from "./goal-loop.js";
37
37
  import { chooseObjectiveConflict, liveObjectives } from "./goal-objective-conflict.js";
38
38
  import { formatGllaVersion } from "./glla-version.js";
39
- import { readUpdateCheck } from "./glla-update-check.js";
40
39
  import { cmdGllaOwner, cmdGllaTakeover } from "./state-root-owner.js";
41
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";
42
41
  import { releaseAuditorSurface } from "./loops/goal-auditor-surface.js";
@@ -609,7 +609,6 @@ export interface AuditorVerdictTally {
609
609
  lastLabel: string | null;
610
610
  }
611
611
  export function auditorVerdictTally(history: Goal["auditHistory"], now = Date.now()): AuditorVerdictTally {
612
- void now;
613
612
  const entries = Array.isArray(history) ? history : [];
614
613
  let approvals = 0;
615
614
  let disapprovals = 0;
@@ -620,11 +619,15 @@ export function auditorVerdictTally(history: Goal["auditHistory"], now = Date.no
620
619
  }
621
620
  const last = entries[entries.length - 1];
622
621
  const lastMs = last ? Date.parse(last.at) : Number.NaN;
622
+ // v0.38.45 audit: `now` earns its parameter slot — a future lastAt
623
+ // (clock skew) suppresses the age instead of printing "0s ago" via
624
+ // fmtElapsed's clamp, matching auditorLastActivity's handling.
625
+ const lastOk = last && Number.isFinite(lastMs) && (lastMs as number) <= now;
623
626
  return {
624
627
  total: entries.length,
625
628
  approvals,
626
629
  disapprovals,
627
- lastAt: last && Number.isFinite(lastMs) ? lastMs : null,
630
+ lastAt: lastOk ? (lastMs as number) : null,
628
631
  lastLabel: last ? auditVerdictLabel(last) : null,
629
632
  };
630
633
  }
@@ -52,7 +52,11 @@ const RECOVERY_RESUME_FRESH_MS = 300_000;
52
52
 
53
53
  /** v0.34.13: consume the sidecar marker on session restore. Single-use,
54
54
  * freshness-bounded — a stale marker from an abandoned recovery must not
55
- * surprise-resume a later session. */
55
+ * surprise-resume a later session.
56
+ * v0.38.45 audit: no production writer plants this marker today (repo-wide
57
+ * grep: only tests do) — the restore-gate disjuncts below read it as a
58
+ * reserved hook, always false live. Do not remove the reads: an older or
59
+ * foreign producer dropping this file must still consent a resume. */
56
60
  export function consumeRecoveryResume(cwd: string): boolean {
57
61
  try {
58
62
  const p = path.join(piGlaDir(cwd), RECOVERY_RESUME_MARKER);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.38.44",
3
+ "version": "0.38.45",
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",