pi-goal-list-loop-audit 0.35.13 → 0.35.64

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.
Files changed (55) hide show
  1. package/CHANGELOG.md +977 -0
  2. package/INSTALL.md +13 -3
  3. package/LIST-PHILOSOPHY.md +90 -0
  4. package/PLAN.md +316 -0
  5. package/README.md +201 -26
  6. package/docs/DESIGN-subagent-visibility.md +76 -0
  7. package/docs/DESIGN.md +59 -8
  8. package/docs/GLLA-POSITIONING-AND-DECOMPOSITION-2026-08-08.md +6 -1
  9. package/docs/INDEX.md +4 -0
  10. package/docs/VISION-ASSIST.md +2 -2
  11. package/extensions/auditor-extensions.ts +289 -0
  12. package/extensions/context-hygiene.ts +129 -0
  13. package/extensions/faulty-objective-recovery.ts +24 -0
  14. package/extensions/glla-state-root.ts +99 -0
  15. package/extensions/goal-agents-panel.ts +211 -0
  16. package/extensions/goal-commands.ts +226 -10
  17. package/extensions/goal-continuation.ts +119 -21
  18. package/extensions/goal-heartbeat.ts +560 -35
  19. package/extensions/goal-loop-auditor-process.ts +126 -4
  20. package/extensions/goal-loop-auditor.ts +3 -5
  21. package/extensions/goal-loop-backoff.ts +79 -0
  22. package/extensions/goal-loop-core.ts +197 -15
  23. package/extensions/goal-loop-dispatch.ts +3 -2
  24. package/extensions/goal-loop-display.ts +141 -8
  25. package/extensions/goal-loop-forever.ts +52 -3
  26. package/extensions/goal-loop-shield.ts +132 -26
  27. package/extensions/goal-loop-stats.ts +3 -2
  28. package/extensions/goal-loop.ts +101 -4
  29. package/extensions/goal-recovery.ts +8 -2
  30. package/extensions/goal-settings.ts +61 -14
  31. package/extensions/goal-state.ts +20 -0
  32. package/extensions/loops/goal-activation.ts +426 -45
  33. package/extensions/loops/goal-auditor-hooks.ts +39 -3
  34. package/extensions/loops/goal-auditor-surface.ts +32 -0
  35. package/extensions/loops/goal-list-queue.ts +72 -12
  36. package/extensions/loops/goal-orchestrator.ts +33 -1
  37. package/extensions/loops/goal-runtime-globals.ts +5 -0
  38. package/extensions/loops/goal-session.ts +68 -8
  39. package/extensions/loops/goal-settings-ui.ts +116 -5
  40. package/extensions/loops/goal-tools.ts +104 -29
  41. package/extensions/loops/goal-ui.ts +106 -1
  42. package/extensions/loops/goal.ts +7 -7
  43. package/extensions/main-model-recovery.ts +10 -0
  44. package/extensions/multi-model-picker.ts +45 -7
  45. package/extensions/payload-guard.ts +172 -0
  46. package/extensions/reviewer.ts +3 -1
  47. package/extensions/settings-menu.ts +39 -4
  48. package/package.json +9 -3
  49. package/prompts/goal-loop-plan-loop.md +31 -0
  50. package/prompts/goal-loop-plan.md +51 -0
  51. package/schemas/goal.schema.json +4 -1
  52. package/scripts/auditor-extension-fixture.mjs +19 -0
  53. package/scripts/goal-auditor-launch.mjs +19 -1
  54. package/scripts/goal-auditor-worker.mjs +15 -0
  55. package/scripts/verify-auditor-extensions-offline.mjs +72 -0
@@ -0,0 +1,289 @@
1
+ // pi-goal-list-loop-audit — v0.36.0
2
+ // extensions/auditor-extensions.ts
3
+ //
4
+ // Discovery + normalization for the auditor extension allowlist
5
+ // (`auditorAllowedExtensions`, GitHub issue: "Allow extension-based model
6
+ // providers in detached auditor sessions").
7
+ //
8
+ // The detached auditor worker spawns `pi --mode rpc --no-extensions …`. Pi's
9
+ // `--extension <spec>` flag still loads explicitly given specs even under
10
+ // `--no-extensions`, BUT a raw `npm:<pkg>` spec makes pi perform a fresh
11
+ // *temporary* npm install into `~/.pi/agent/tmp/extensions/npm/<hash>` on
12
+ // every spawn — network-bound, hundreds of MB, and completely dead offline
13
+ // (field-verified by the 2026-08-22 audit: `-e npm:pi-cursor-sdk` with
14
+ // PI_OFFLINE=1 registered 0 models; the resolved install path registered
15
+ // 209). Relative `extensions[]` entries are likewise resolved by pi against
16
+ // the *auditor's cwd*, not the settings base dir, so a verbatim
17
+ // `../../ghq/...` spec silently loads nothing.
18
+ //
19
+ // We therefore resolve every allowlist entry to a concrete, existing
20
+ // absolute path BEFORE it reaches the worker:
21
+ // • `npm:<name>[@version]` → `~/.pi/agent/npm/node_modules/<name>` (or the
22
+ // project `.pi/npm/node_modules/<name>` equivalent) — existing install
23
+ // only, never triggering a new one.
24
+ // • `git:<url>` → `~/.pi/agent/git/<host>/<user>/<project>` (or project
25
+ // `.pi/git/...` equivalent).
26
+ // • paths → `~` expanded; relative paths resolved against the settings
27
+ // base dir (user scope: `~/.pi/agent`, project scope: `<cwd>/.pi`),
28
+ // matching pi's own `getBaseDirForScope` resolution.
29
+ // Entries that do not resolve to an existing path are dropped (fail-closed)
30
+ // rather than emitted as unloadable specs.
31
+ //
32
+ // Discovery is read-only and best-effort: unreadable files simply contribute
33
+ // no entries. The picker menu (Auditor tab) is the only consumer besides the
34
+ // dispatch path, which re-reads the normalized settings at request time.
35
+
36
+ import * as fs from "node:fs";
37
+ import * as path from "node:path";
38
+
39
+ /** One extension the user could allow-list for the auditor. */
40
+ export interface DiscoveredPiExtension {
41
+ /** The spec to pass to `pi --extension` / store in settings.
42
+ * Settings-sourced entries are RESOLVED to concrete install paths (see
43
+ * module header); directory-discovered entries are already absolute. */
44
+ spec: string;
45
+ /** Short human label (package name or file/dir basename). */
46
+ label: string;
47
+ /** Where the entry came from — for the picker's descriptive text. */
48
+ source: "package" | "extensions-setting" | "user-dir" | "project-dir";
49
+ /** The raw settings entry this was resolved from (specs resolved from
50
+ * packages[]/extensions[] keep the original string for display). */
51
+ raw?: string;
52
+ }
53
+
54
+ /** Parse an npm package spec into its install directory name.
55
+ * Mirrors pi's PackageManager.parseNpmSpec: `@scope/pkg@1.2.3` →
56
+ * `@scope/pkg`; `pkg@^2` → `pkg`; `pkg` → `pkg`. */
57
+ function npmSpecName(spec: string): string {
58
+ const match = spec.match(/^(@?[^@]+(?:\/?[^@]+)?)(?:@(.+))?$/);
59
+ return (match?.[1] ?? spec).trim() || spec;
60
+ }
61
+
62
+ /** Parse a git source into `{ host, repoPath }` where repoPath is
63
+ * `user/project` (`.git` and ref stripped). Mirrors the subset of pi's
64
+ * parseGitUrl/splitRef that settings `packages[]` entries can contain.
65
+ * Returns undefined when the string is not a git source. */
66
+ function parseGitSpec(source: string): { host: string; repoPath: string } | undefined {
67
+ let url = source.trim();
68
+ if (url.startsWith("git:")) url = url.slice(4).trim();
69
+ else if (!/^(https?|ssh|git):\/\//i.test(url) && !/^git@[^:]+:/.test(url) && !/^[^.\s]+\.[^.\s]+\//.test(url)) {
70
+ return undefined; // not a git-shaped source
71
+ }
72
+ url = url.split("#")[0] ?? url; // strip #ref
73
+ let host = "";
74
+ let rest = "";
75
+ const scp = url.match(/^git@([^:]+):(.+)$/);
76
+ if (scp) {
77
+ host = scp[1] ?? "";
78
+ rest = scp[2] ?? "";
79
+ } else if (url.includes("://")) {
80
+ try {
81
+ const parsed = new URL(url);
82
+ host = parsed.hostname;
83
+ rest = parsed.pathname.replace(/^\/+/, "");
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ } else {
88
+ const slash = url.indexOf("/");
89
+ if (slash < 0) return undefined;
90
+ host = url.slice(0, slash);
91
+ rest = url.slice(slash + 1);
92
+ }
93
+ const at = rest.indexOf("@"); // strip @ref (pi's splitRef semantics)
94
+ if (at >= 0) rest = rest.slice(0, at);
95
+ rest = rest.replace(/\.git$/, "").replace(/^\/+/, "");
96
+ const parts = rest.split("/").filter(Boolean);
97
+ if (!host || parts.length < 2 || parts.some((p) => p === "..")) return undefined;
98
+ return { host, repoPath: parts.join("/") };
99
+ }
100
+
101
+ function firstExisting(candidates: string[]): string | undefined {
102
+ for (const candidate of candidates) {
103
+ try {
104
+ if (fs.existsSync(candidate)) return candidate;
105
+ } catch {
106
+ /* unreadable — treat as missing */
107
+ }
108
+ }
109
+ return undefined;
110
+ }
111
+
112
+ /** Expand `~` and resolve a path against a base dir (pi's
113
+ * resolvePathFromBase semantics for settings entries). */
114
+ function resolveSettingsPath(input: string, baseDir: string, home: string): string {
115
+ if (input.startsWith("~")) return path.join(home, input.slice(1).replace(/^\/+/, ""));
116
+ return path.resolve(baseDir, input);
117
+ }
118
+
119
+ /** Resolve one allowlist spec to a concrete existing absolute path the
120
+ * worker can pass as `pi --extension <path>` without triggering installs.
121
+ * `base` is the settings base dir for relative paths (user scope:
122
+ * `~/.pi/agent`; project scope: `<cwd>/.pi`). Returns undefined when the
123
+ * entry does not resolve to something loadable — callers drop it
124
+ * (fail-closed) rather than emit an unloadable spec. */
125
+ export function resolveAuditorExtensionSpec(
126
+ spec: string,
127
+ opts: { home: string; cwd?: string; base?: string },
128
+ ): string | undefined {
129
+ const agentDir = path.join(opts.home, ".pi", "agent");
130
+ const projectDir = opts.cwd ? path.join(opts.cwd, ".pi") : undefined;
131
+ const trimmed = spec.trim();
132
+ if (trimmed.startsWith("npm:")) {
133
+ const name = npmSpecName(trimmed.slice(4));
134
+ if (!name) return undefined;
135
+ return firstExisting([
136
+ path.join(agentDir, "npm", "node_modules", name),
137
+ ...(projectDir ? [path.join(projectDir, "npm", "node_modules", name)] : []),
138
+ ]);
139
+ }
140
+ const git = parseGitSpec(trimmed);
141
+ if (git) {
142
+ return firstExisting([
143
+ path.join(agentDir, "git", git.host, ...git.repoPath.split("/")),
144
+ ...(projectDir ? [path.join(projectDir, "git", git.host, ...git.repoPath.split("/"))] : []),
145
+ ]);
146
+ }
147
+ // Local path: `~`/absolute as-is, relative against the settings base dir
148
+ // (NOT the auditor's future cwd — pi resolves -e against cwd, so emitting
149
+ // a relative spec would silently load nothing).
150
+ if (/^[a-z]+:/.test(trimmed)) return undefined; // unknown scheme — no safe resolution
151
+ const resolved = resolveSettingsPath(trimmed, opts.base ?? agentDir, opts.home);
152
+ return firstExisting([resolved]);
153
+ }
154
+
155
+ /** Resolve an allowlist to worker-ready install paths: every entry maps to
156
+ * a concrete existing path; unresolvable entries are dropped fail-closed. */
157
+ export function resolveAuditorAllowedExtensions(
158
+ specs: string[] | undefined,
159
+ home: string,
160
+ cwd?: string,
161
+ ): string[] {
162
+ const out: string[] = [];
163
+ const seen = new Set<string>();
164
+ for (const spec of normalizeAuditorAllowedExtensions(specs)) {
165
+ const resolved = resolveAuditorExtensionSpec(spec, { home, cwd, base: path.join(home, ".pi", "agent") });
166
+ if (!resolved || seen.has(resolved)) continue; // not installed here — skip, don't emit a dead spec
167
+ seen.add(resolved);
168
+ out.push(resolved);
169
+ }
170
+ return out;
171
+ }
172
+
173
+ /** Bounded, deterministic normalization of the allowlist value. */
174
+ export function normalizeAuditorAllowedExtensions(value: unknown): string[] {
175
+ if (!Array.isArray(value)) return [];
176
+ const seen = new Set<string>();
177
+ const out: string[] = [];
178
+ for (const entry of value) {
179
+ if (typeof entry !== "string") continue;
180
+ const spec = entry.trim();
181
+ if (!spec || seen.has(spec)) continue;
182
+ if (out.length >= 32) break; // bounded: the request hash stays sane
183
+ seen.add(spec);
184
+ out.push(spec);
185
+ }
186
+ return out;
187
+ }
188
+
189
+ function readJsonArray(file: string): string[] {
190
+ try {
191
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
192
+ if (typeof parsed !== "object" || parsed === null) return [];
193
+ const out: string[] = [];
194
+ for (const key of ["packages", "extensions"]) {
195
+ const value = (parsed as Record<string, unknown>)[key];
196
+ if (!Array.isArray(value)) continue;
197
+ for (const entry of value) {
198
+ if (typeof entry === "string" && entry.trim()) out.push(entry.trim());
199
+ }
200
+ }
201
+ return out;
202
+ } catch {
203
+ return [];
204
+ }
205
+ }
206
+
207
+ /** Auto-discovered extension entries in a directory: *.ts / *.js files and
208
+ * subdirectories (loaded by pi as <dir>/index.ts). */
209
+ function discoverDir(dir: string, source: DiscoveredPiExtension["source"]): DiscoveredPiExtension[] {
210
+ let entries: fs.Dirent[];
211
+ try {
212
+ entries = fs.readdirSync(dir, { withFileTypes: true });
213
+ } catch {
214
+ return [];
215
+ }
216
+ const out: DiscoveredPiExtension[] = [];
217
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
218
+ const full = path.join(dir, entry.name);
219
+ if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) {
220
+ out.push({ spec: full, label: entry.name, source });
221
+ } else if (entry.isDirectory()) {
222
+ out.push({ spec: full, label: `${entry.name}/`, source });
223
+ }
224
+ }
225
+ return out;
226
+ }
227
+
228
+ function labelForSpec(spec: string): string {
229
+ // "npm:@scope/pkg@version" → "@scope/pkg"; "git:url" → url tail; paths → basename.
230
+ const colon = spec.indexOf(":");
231
+ if (colon > 0) {
232
+ const scheme = spec.slice(0, colon);
233
+ const rest = spec.slice(colon + 1);
234
+ if (scheme === "npm" || scheme === "git") {
235
+ const withoutVersion = rest.replace(/@[^/@]*$/, "");
236
+ return withoutVersion || rest;
237
+ }
238
+ }
239
+ return path.basename(spec) || spec;
240
+ }
241
+
242
+ /**
243
+ * Discover every extension a normal pi session would load in this
244
+ * environment: user settings packages/extensions, the user extensions dir,
245
+ * project settings packages/extensions, and the project extensions dir.
246
+ * Deduplicated by spec, stable order: settings packages first, then
247
+ * settings extension paths, then directory discovery (user before project).
248
+ */
249
+ export function discoverAuditorExtensions(home: string, cwd?: string): DiscoveredPiExtension[] {
250
+ const agentDir = path.join(home, ".pi", "agent");
251
+ const out: DiscoveredPiExtension[] = [];
252
+ const seen = new Set<string>();
253
+ const push = (spec: string, source: DiscoveredPiExtension["source"]) => {
254
+ if (!spec || seen.has(spec)) return;
255
+ seen.add(spec);
256
+ out.push({ spec, label: labelForSpec(spec), source });
257
+ };
258
+ // Settings entries: packages[] specs and extensions[] paths are resolved
259
+ // to concrete install paths (npm: → managed node_modules, git: → managed
260
+ // git root, relative paths → settings base dir) so the stored allowlist
261
+ // entry is directly loadable by `pi --extension <path>` without a fresh
262
+ // temporary install. Unresolvable entries are skipped, not emitted.
263
+ const pushResolved = (raw: string, source: DiscoveredPiExtension["source"], base: string) => {
264
+ const spec = resolveAuditorExtensionSpec(raw, { home, cwd, base });
265
+ if (!spec || seen.has(spec)) return;
266
+ seen.add(spec);
267
+ out.push({ spec, label: labelForSpec(raw), source, raw });
268
+ };
269
+ const userSettings = readJsonArray(path.join(agentDir, "settings.json"));
270
+ for (const raw of userSettings) pushResolved(raw, /^[a-z]+:/.test(raw) ? "package" : "extensions-setting", agentDir);
271
+ for (const entry of discoverDir(path.join(agentDir, "extensions"), "user-dir")) push(entry.spec, entry.source);
272
+ if (cwd) {
273
+ const projectSettings = readJsonArray(path.join(cwd, ".pi", "settings.json"));
274
+ const projectBase = path.join(cwd, ".pi");
275
+ for (const raw of projectSettings) pushResolved(raw, /^[a-z]+:/.test(raw) ? "package" : "extensions-setting", projectBase);
276
+ for (const entry of discoverDir(path.join(cwd, ".pi", "extensions"), "project-dir")) push(entry.spec, entry.source);
277
+ }
278
+ return out;
279
+ }
280
+
281
+ /** Expand the allowlist into `pi` CLI args: ["--extension", spec, …].
282
+ * Empty/absent list → [] (the worker keeps plain --no-extensions). */
283
+ export function auditorExtensionArgs(allowed: string[] | undefined): string[] {
284
+ const args: string[] = [];
285
+ for (const spec of normalizeAuditorAllowedExtensions(allowed)) {
286
+ args.push("--extension", spec);
287
+ }
288
+ return args;
289
+ }
@@ -0,0 +1,129 @@
1
+ // pi-goal-list-loop-audit — v0.35.52
2
+ // extensions/context-hygiene.ts
3
+ //
4
+ // note.md Now: "failed requests add to the context, while clearly adding
5
+ // nothing of value" — field evidence (polis session, 2026-08-23): a run of
6
+ // failed turns (503 server_error, "Provider finish_reason: network_error",
7
+ // "Retry failed after 3 attempts: Retry cancelled") accumulated until the
8
+ // estimated context hit 122.7% of the 200k window, and auto-compaction then
9
+ // failed on its own bloated summarization input.
10
+ //
11
+ // Mechanism (verified in pi sources): when retries are exhausted, the failed
12
+ // assistant message (stopReason "error", errorMessage set, content typically
13
+ // empty or partial) STAYS in agent state and the session — pi strips it from
14
+ // live state only for mid-flight retries. Every later LLM call receives it,
15
+ // and compaction summarizes it. Nothing downstream filters these.
16
+ //
17
+ // The durable bounded rule: project error-only assistant turns OUT of the
18
+ // effective context — EXCEPT the most recent one, which stays so the model
19
+ // can see WHY the previous attempt failed on the immediate retry send. Only
20
+ // turns with NO tool-call blocks are dropped (an error turn that emitted
21
+ // tool calls owns paired toolResult messages and must stay intact); only
22
+ // stopReason "error" is projected ("aborted" turns are user-intent
23
+ // boundaries and are never touched). Session transcripts on disk are NOT
24
+ // modified — this is a per-send projection, exactly like the v0.35.51
25
+ // payload guard; a second application point prunes the same turns from the
26
+ // compaction summarization input (the preparation object is shared by
27
+ // reference with the compaction runner).
28
+ //
29
+ // Pure module — no pi runtime, no fs — so the rule is unit-testable in
30
+ // isolation; the orchestration layer (goal-activation.ts) registers the
31
+ // context/compaction hooks and writes the context_hygiene ledger entries.
32
+
33
+ /** How many of the most recent failed turns stay visible. One is enough for
34
+ * retry continuity; older failures are pure noise. */
35
+ export const DEFAULT_KEEP_RECENT_ERROR_TURNS = 1;
36
+
37
+ export interface FailedTurnDrop {
38
+ messageIndex: number;
39
+ errorMessage?: string;
40
+ }
41
+
42
+ export interface FailedTurnResult {
43
+ /** The projected message list (same identity when nothing was dropped). */
44
+ messages: readonly unknown[];
45
+ dropped: FailedTurnDrop[];
46
+ /** Failed turns kept (the most recent window). */
47
+ kept: number;
48
+ }
49
+
50
+ export interface ContextHygieneOptions {
51
+ keepRecentErrorTurns?: number;
52
+ }
53
+
54
+ /** True when the block is a tool call (an error turn carrying these owns
55
+ * paired toolResult messages and is never droppable). */
56
+ function isToolCallBlock(block: unknown): boolean {
57
+ return typeof block === "object" && block !== null
58
+ && (block as { type?: unknown }).type === "toolCall";
59
+ }
60
+
61
+ /**
62
+ * A failed error-only assistant turn: stopReason "error", content carrying
63
+ * no tool-call blocks. Partial text/thinking from a stream that died
64
+ * mid-flight is projected too — the transcript keeps it; the effective
65
+ * context does not need it.
66
+ */
67
+ export function isFailedErrorOnlyTurn(message: unknown): boolean {
68
+ if (typeof message !== "object" || message === null) return false;
69
+ const m = message as { role?: unknown; stopReason?: unknown; content?: unknown };
70
+ if (m.role !== "assistant" || m.stopReason !== "error") return false;
71
+ if (!Array.isArray(m.content)) return false;
72
+ return !m.content.some(isToolCallBlock);
73
+ }
74
+
75
+ /**
76
+ * Project failed error-only assistant turns out of the message list. The
77
+ * most recent `keepRecentErrorTurns` failed turns are kept (bounded rule);
78
+ * older ones are dropped entirely. Returns the input array identity when
79
+ * nothing needs dropping.
80
+ */
81
+ export function dropFailedErrorOnlyTurns(
82
+ messages: readonly unknown[],
83
+ opts: ContextHygieneOptions = {},
84
+ ): FailedTurnResult {
85
+ const keepRecent = typeof opts.keepRecentErrorTurns === "number" && opts.keepRecentErrorTurns >= 0
86
+ ? opts.keepRecentErrorTurns
87
+ : DEFAULT_KEEP_RECENT_ERROR_TURNS;
88
+
89
+ const failedIndexes: number[] = [];
90
+ messages.forEach((message, index) => {
91
+ if (isFailedErrorOnlyTurn(message)) failedIndexes.push(index);
92
+ });
93
+ if (failedIndexes.length <= keepRecent) {
94
+ return { messages, dropped: [], kept: failedIndexes.length };
95
+ }
96
+
97
+ const dropSet = new Set(failedIndexes.slice(0, failedIndexes.length - keepRecent));
98
+ const dropped: FailedTurnDrop[] = [...dropSet].map((messageIndex) => ({
99
+ messageIndex,
100
+ errorMessage: (messages[messageIndex] as { errorMessage?: string } | null)?.errorMessage,
101
+ }));
102
+ const projected = messages.filter((_, index) => !dropSet.has(index));
103
+ return { messages: projected, dropped, kept: keepRecent };
104
+ }
105
+
106
+ /**
107
+ * In-place application to a CompactionPreparation-shaped object (the runner
108
+ * passes the same object to `compact()` after the extension event, so
109
+ * property reassignment propagates). Returns the number of dropped turns
110
+ * across both message arrays; never throws over shape surprises.
111
+ */
112
+ export function pruneCompactionPreparation(
113
+ preparation: unknown,
114
+ opts: ContextHygieneOptions = {},
115
+ ): number {
116
+ if (typeof preparation !== "object" || preparation === null) return 0;
117
+ const prep = preparation as { messagesToSummarize?: unknown; turnPrefixMessages?: unknown };
118
+ let dropped = 0;
119
+ for (const key of ["messagesToSummarize", "turnPrefixMessages"] as const) {
120
+ const list = prep[key];
121
+ if (!Array.isArray(list)) continue;
122
+ const result = dropFailedErrorOnlyTurns(list, opts);
123
+ if (result.dropped.length > 0) {
124
+ prep[key] = result.messages;
125
+ dropped += result.dropped.length;
126
+ }
127
+ }
128
+ return dropped;
129
+ }
@@ -149,6 +149,30 @@ function seedObjective(seed: unknown): string | null {
149
149
  return usableCandidate(raw.split(/\b(?:done\s+when|verify)\s*:/i)[0]);
150
150
  }
151
151
 
152
+ /**
153
+ * v0.35.53 (note.md Now): heal legacy malformed queue items WITHOUT a repair
154
+ * card. The v0.35.53 parser tightening stops NEW items from being written
155
+ * with an empty objective, but items already persisted that way (field:
156
+ * neonbreak — objective "", the entire intent inside the verification
157
+ * contract, faulty_objective_list_activation_blocked ×42 over 22h) need a
158
+ * deterministic heal at activation: when the objective is empty but the
159
+ * contract carries a clean, actionable leading imperative, derive the
160
+ * objective from the contract's first sentence. Returns null when the
161
+ * contract is absent or itself suspicious — those stay on the true
162
+ * broken-objective repair path.
163
+ */
164
+ export function deriveObjectiveFromContract(contract: unknown): string | null {
165
+ const text = normalizedText(contract);
166
+ if (!text) return null;
167
+ if (assessSuspiciousObjective(text).suspicious) return null;
168
+ if (!IMPERATIVE_START.test(text)) return null;
169
+ const firstSentence = text.split(/(?<=[.!?])\s+/)[0] ?? text;
170
+ // 400: long single-sentence intents are legitimate objectives (the field
171
+ // sentence is ~253 chars); the cap only guards against pathological dumps.
172
+ const bounded = firstSentence.slice(0, 400).trim();
173
+ return bounded.length >= 8 ? bounded : null;
174
+ }
175
+
152
176
  function repairContract(goal: Goal): { value?: string; source: string } {
153
177
  const candidates: Array<[string, unknown]> = [
154
178
  ["current contract", goal.verificationContract],
@@ -0,0 +1,99 @@
1
+ // pi-goal-list-loop-audit — v0.35.58
2
+ // extensions/glla-state-root.ts
3
+ //
4
+ // The state-root boundary is deliberately dependency-free: goal-loop-core and
5
+ // goal-settings both need it, so putting it in either of those modules would
6
+ // create a circular import. The runtime session directory is registered by the
7
+ // lifecycle slice; until then sessionDir mode is pending and persistence
8
+ // callers must not create a cwd fallback tree.
9
+
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+
14
+ export type GllaStateRoot = "workingDir" | "sessionDir";
15
+
16
+ export function globalSettingsPath(): string {
17
+ // Test/embedding override keeps the suite hermetic from the developer's
18
+ // real global settings file. This helper intentionally has no settings
19
+ // module dependency because piGlaDir must read the global root selector.
20
+ const override = process.env.GLLA_GLOBAL_SETTINGS_PATH;
21
+ if (override) return override;
22
+ return path.join(os.homedir(), ".pi", "agent", "pi-goal-list-loop-audit.settings.json");
23
+ }
24
+
25
+ function configuredGlobalSettingsPath(): string {
26
+ return globalSettingsPath();
27
+ }
28
+
29
+ /** Live session's top-level directory. The lifecycle slice registers this
30
+ * after host admission; tests and worker processes may use PI_SESSION_FILE. */
31
+ let runtimeSessionDir: string | undefined;
32
+
33
+ export function setRuntimeSessionDir(dir: string | undefined): void {
34
+ runtimeSessionDir = typeof dir === "string" && dir.trim() ? path.resolve(dir) : undefined;
35
+ }
36
+
37
+ /** Register the host session root from pi's file-backed SessionManager. The
38
+ * lifecycle owns this admission point; keeping the derivation here makes the
39
+ * state-root boundary identical for normal starts and successor rebinds.
40
+ * `getSessionDir()` is authoritative: a session file may be imported from a
41
+ * different directory, while in-memory subagent managers return an empty dir. */
42
+ export function setRuntimeSessionDirFromSessionManager(sessionManager: unknown): string | undefined {
43
+ let sessionDir: unknown;
44
+ try {
45
+ const getter = (sessionManager as { getSessionDir?: unknown } | null | undefined)?.getSessionDir;
46
+ if (typeof getter !== "function") {
47
+ setRuntimeSessionDir(undefined);
48
+ return undefined;
49
+ }
50
+ sessionDir = (getter as () => unknown).call(sessionManager);
51
+ } catch {
52
+ setRuntimeSessionDir(undefined);
53
+ return undefined;
54
+ }
55
+ if (typeof sessionDir !== "string" || !sessionDir.trim()) {
56
+ setRuntimeSessionDir(undefined);
57
+ return undefined;
58
+ }
59
+ const dir = path.resolve(sessionDir);
60
+ setRuntimeSessionDir(dir);
61
+ return dir;
62
+ }
63
+
64
+ export function resolveRuntimeSessionDir(): string | undefined {
65
+ if (runtimeSessionDir) return runtimeSessionDir;
66
+ const sessionFile = process.env.PI_SESSION_FILE;
67
+ if (!sessionFile) return undefined;
68
+ const parent = path.dirname(path.resolve(sessionFile));
69
+ return parent && parent !== "." ? parent : undefined;
70
+ }
71
+
72
+ function readStateRootSetting(): GllaStateRoot {
73
+ try {
74
+ const raw = JSON.parse(fs.readFileSync(configuredGlobalSettingsPath(), "utf8")) as Record<string, unknown>;
75
+ return raw.stateRoot === "sessionDir" ? "sessionDir" : "workingDir";
76
+ } catch {
77
+ return "workingDir";
78
+ }
79
+ }
80
+
81
+ export function configuredStateRoot(): GllaStateRoot {
82
+ return readStateRootSetting();
83
+ }
84
+
85
+ /** True when sessionDir is selected but the lifecycle has not registered a
86
+ * session root yet. Reads may fall back for compatibility; write callers must
87
+ * defer so startup cannot recreate state under an ambiguous cwd. */
88
+ export function stateRootPending(): boolean {
89
+ return readStateRootSetting() === "sessionDir" && !resolveRuntimeSessionDir();
90
+ }
91
+
92
+ /** Resolve the selected root without performing migration or filesystem I/O. */
93
+ export function resolveGllaStateDir(cwd: string): string {
94
+ if (readStateRootSetting() === "sessionDir") {
95
+ const sessionDir = resolveRuntimeSessionDir();
96
+ if (sessionDir) return path.join(sessionDir, "pi-glla");
97
+ }
98
+ return path.join(cwd, ".pi-glla");
99
+ }