@quantmind-br/pi-goal-x 0.0.0-stage → 0.35.0

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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -2
  3. package/assets/badge-dark.svg +18 -0
  4. package/assets/badge-light.svg +18 -0
  5. package/docs/advanced-usage.md +106 -0
  6. package/docs/agent-flow-design.md +306 -0
  7. package/docs/agentic-runtime-prd.md +773 -0
  8. package/docs/architecture.md +368 -0
  9. package/docs/controls.md +140 -0
  10. package/docs/pi-autoresearch-survey.md +50 -0
  11. package/docs/pi-goal-x-unified-dashboard-plan.md +1667 -0
  12. package/docs/prompt-caching.md +31 -0
  13. package/docs/unified-dashboard.md +286 -0
  14. package/extensions/auditor-selector.ts +74 -0
  15. package/extensions/goal-accounting.ts +107 -0
  16. package/extensions/goal-activity.ts +202 -0
  17. package/extensions/goal-auditor.ts +480 -0
  18. package/extensions/goal-commands.ts +866 -0
  19. package/extensions/goal-compaction.ts +197 -0
  20. package/extensions/goal-completion.ts +173 -0
  21. package/extensions/goal-context.ts +12 -0
  22. package/extensions/goal-contract.ts +58 -0
  23. package/extensions/goal-controls.ts +202 -0
  24. package/extensions/goal-core-tools.ts +549 -0
  25. package/extensions/goal-core.ts +78 -0
  26. package/extensions/goal-detail.ts +65 -0
  27. package/extensions/goal-dialog-controls.ts +31 -0
  28. package/extensions/goal-draft.ts +244 -0
  29. package/extensions/goal-drafting.ts +535 -0
  30. package/extensions/goal-events.ts +692 -0
  31. package/extensions/goal-format.ts +376 -0
  32. package/extensions/goal-interruption.ts +53 -0
  33. package/extensions/goal-ledger-index.ts +68 -0
  34. package/extensions/goal-ledger.ts +1007 -0
  35. package/extensions/goal-live-retention.ts +219 -0
  36. package/extensions/goal-oracle.ts +346 -0
  37. package/extensions/goal-policy.ts +370 -0
  38. package/extensions/goal-pool.ts +103 -0
  39. package/extensions/goal-prompt-cache.ts +80 -0
  40. package/extensions/goal-questionnaire.ts +1073 -0
  41. package/extensions/goal-record.ts +388 -0
  42. package/extensions/goal-recovery.ts +256 -0
  43. package/extensions/goal-runtime.ts +277 -0
  44. package/extensions/goal-scheduler-state.ts +100 -0
  45. package/extensions/goal-scheduler.ts +436 -0
  46. package/extensions/goal-service.ts +843 -0
  47. package/extensions/goal-session-health.ts +130 -0
  48. package/extensions/goal-session-safety.ts +65 -0
  49. package/extensions/goal-settings.ts +1370 -0
  50. package/extensions/goal-state.ts +1069 -0
  51. package/extensions/goal-status.ts +305 -0
  52. package/extensions/goal-task-confirmation.ts +174 -0
  53. package/extensions/goal-task-count.ts +59 -0
  54. package/extensions/goal-task-derive.ts +46 -0
  55. package/extensions/goal-task-index.ts +57 -0
  56. package/extensions/goal-task-tools.ts +595 -0
  57. package/extensions/goal-tool-inputs.ts +102 -0
  58. package/extensions/goal-tool-names.ts +73 -0
  59. package/extensions/goal-tools.ts +16 -0
  60. package/extensions/goal-widget.ts +455 -0
  61. package/extensions/goal.ts +42 -0
  62. package/extensions/network-error-backoff.ts +55 -0
  63. package/extensions/prompts/goal-prompts.ts +321 -0
  64. package/extensions/storage/atomic-rename.ts +52 -0
  65. package/extensions/storage/goal-files.ts +695 -0
  66. package/extensions/storage/goal-lock.ts +131 -0
  67. package/extensions/storage/goal-root.ts +95 -0
  68. package/extensions/widgets/auditor-dashboard-model.ts +174 -0
  69. package/extensions/widgets/dialog-scaffold.ts +26 -0
  70. package/extensions/widgets/goal-controls-dialog.ts +329 -0
  71. package/extensions/widgets/goal-dashboard-model.ts +530 -0
  72. package/extensions/widgets/goal-dashboard-renderer.ts +674 -0
  73. package/extensions/widgets/goal-escape-dialog.ts +148 -0
  74. package/extensions/widgets/goal-notifications.ts +9 -0
  75. package/extensions/widgets/goal-widget.ts +723 -0
  76. package/extensions/widgets/task-list-overlay.ts +409 -0
  77. package/extensions/widgets/text-cache.ts +30 -0
  78. package/package.json +117 -4
  79. package/scripts/recover-session-checkpoints.mjs +289 -0
@@ -0,0 +1,695 @@
1
+ import { goalStoragePath, goalPoolSnapshotPath, isExternalGoalStorage, ensureGoalStorageDirectory, type GoalStorageContext } from "./goal-root.ts";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { renameAtomic, renameAtomicSync } from "./atomic-rename.ts";
5
+
6
+ import {
7
+ formatDuration,
8
+ formatTokenValue,
9
+ statusLabel,
10
+ } from "../goal-core.ts";
11
+ import {
12
+ cloneGoal,
13
+ normalizeGoalRecord,
14
+ normalizeRelPath,
15
+ nowIso,
16
+ safeIdPart,
17
+ type GoalRecord,
18
+ type TaskStatus,
19
+ } from "../goal-record.ts";
20
+
21
+ export const GOALS_DIR = ".pi/goals";
22
+ export const ARCHIVED_GOALS_DIR = ".pi/goals/archived";
23
+
24
+ /**
25
+ * Per-file parse cache (P1-1): keyed by (absolute path, mtime, size), so
26
+ * steady-state parses of goal files cost one lstat instead of lstat+read+
27
+ * JSON-parse. Every parse call site (pool scan, reconcile, merge-from-disk)
28
+ * shares this cache; writers invalidate the entries they touch.
29
+ */
30
+ interface GoalFileParseCacheEntry {
31
+ mtimeMs: number;
32
+ size: number;
33
+ parsed: GoalRecord | null;
34
+ }
35
+
36
+ const goalFileParseCache = new Map<string, GoalFileParseCacheEntry>();
37
+
38
+ /** Directory listing cache (names only): keyed by (absolute path, dir mtime). */
39
+ interface GoalDirListingCacheEntry {
40
+ mtimeMs: number;
41
+ names: string[];
42
+ }
43
+
44
+ const goalDirListingCache = new Map<string, GoalDirListingCacheEntry>();
45
+
46
+ /**
47
+ * Zero-op pool cache (NAF 2026-08-06): the fully-scanned active pool per
48
+ * goals dir, served without any stat. Every extension write (atomic write /
49
+ * unlink / archive) invalidates it, so extension-mediated changes are always
50
+ * observed; external (non-extension) file edits go stale mid-session
51
+ * (documented in the naf spec PRODUCT.md). The safety-critical persist path
52
+ * re-reads the goal file directly (parseGoalFile, mtime-keyed) under the
53
+ * lock, so cross-process revision conflicts are still detected.
54
+ */
55
+ const goalPoolCache = new Map<string, Map<string, GoalRecord>>();
56
+
57
+ /**
58
+ * Session boundary (session_start / resume): drop the zero-op pool cache so
59
+ * a new session rescans the goal pool fresh from disk.
60
+ */
61
+ export function invalidateGoalPoolCache(): void {
62
+ goalPoolCache.clear();
63
+ }
64
+
65
+ function invalidateGoalPathCaches(filePath: string): void {
66
+ goalFileParseCache.delete(filePath);
67
+ }
68
+
69
+ function invalidateGoalDirCache(root: string): void {
70
+ goalDirListingCache.delete(root);
71
+ goalPoolCache.delete(root);
72
+ }
73
+
74
+ // ── Persistent pool snapshot (NAF cold-start, 2026-08-06) ───────────────────
75
+ // The zero-op pool cache is in-memory; a fresh process (new session) has no
76
+ // cache, so a cold pool read re-scanned every goal file (2 fs ops per goal).
77
+ // The snapshot persists the pool as ONE file, so a cold read is
78
+ // lstat(root) + readFile(snapshot) = 2 ops total, and every extension write
79
+ // keeps it current (read-modify-write, +4 ops per mutation — mutations are
80
+ // write-floor-exempt in the bench).
81
+ //
82
+ // Freshness: the snapshot records the goals-dir mtime; if the dir mtime
83
+ // changed (goal files added/removed — including external edits), the
84
+ // snapshot is invalidated and a full scan re-syncs it. In-place content
85
+ // edits to a goal file do NOT change the dir mtime, so a cold read may serve
86
+ // the last extension-written snapshot until the next extension write or
87
+ // mtime-changing change — the same staleness class as the in-memory pool
88
+ // cache's documented mid-session behavior (external hand-edits go stale;
89
+ // the safety-critical persist path still re-reads the goal file directly
90
+ // via parseGoalFile, mtime-keyed, so cross-process conflicts are detected).
91
+ interface PoolSnapshot {
92
+ version: 1;
93
+ dirMtimeMs: number;
94
+ goals: GoalRecord[];
95
+ }
96
+
97
+ const POOL_SNAPSHOT_LEGACY_NAME = ".goals-pool-snapshot.json";
98
+
99
+ function poolSnapshotLegacyPath(root: string): string {
100
+ return path.join(root, POOL_SNAPSHOT_LEGACY_NAME);
101
+ }
102
+
103
+ /** Read + validate the snapshot; null when missing/corrupt/unsupported. */
104
+ function readPoolSnapshotSync(ctx: GoalFileContext, root: string): PoolSnapshot | null {
105
+ const parsed = tryParsePoolSnapshotSync(goalPoolSnapshotPath(ctx));
106
+ if (parsed) return parsed;
107
+ // One-time migration fallback: a snapshot written before 2026-08-09 lives
108
+ // inside the goals dir; keep serving it until the next write replaces it.
109
+ return tryParsePoolSnapshotSync(poolSnapshotLegacyPath(root));
110
+ }
111
+
112
+ function tryParsePoolSnapshotSync(filePath: string): PoolSnapshot | null {
113
+ try {
114
+ const data = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial<PoolSnapshot>;
115
+ if (data.version === 1 && Array.isArray(data.goals) && typeof data.dirMtimeMs === "number") {
116
+ return data as PoolSnapshot;
117
+ }
118
+ } catch {
119
+ // missing or corrupt — caller falls back to a full scan
120
+ }
121
+ return null;
122
+ }
123
+
124
+ async function readPoolSnapshotAsync(ctx: GoalFileContext, root: string): Promise<PoolSnapshot | null> {
125
+ const parsed = await tryParsePoolSnapshotAsync(goalPoolSnapshotPath(ctx));
126
+ if (parsed) return parsed;
127
+ return tryParsePoolSnapshotAsync(poolSnapshotLegacyPath(root));
128
+ }
129
+
130
+ async function tryParsePoolSnapshotAsync(filePath: string): Promise<PoolSnapshot | null> {
131
+ try {
132
+ const data = JSON.parse(await fs.promises.readFile(filePath, "utf8")) as Partial<PoolSnapshot>;
133
+ if (data.version === 1 && Array.isArray(data.goals) && typeof data.dirMtimeMs === "number") {
134
+ return data as PoolSnapshot;
135
+ }
136
+ } catch {
137
+ return null;
138
+ }
139
+ return null;
140
+ }
141
+
142
+ function hydratePoolFromSnapshot(snapshot: PoolSnapshot): Map<string, GoalRecord> {
143
+ const pool = new Map<string, GoalRecord>();
144
+ for (const goal of snapshot.goals) {
145
+ if (goal.status === "complete") continue; // matches scanActiveGoalFiles
146
+ pool.set(goal.id, goal);
147
+ }
148
+ return pool;
149
+ }
150
+
151
+ /** Best-effort atomic snapshot write (temp + rename). */
152
+ function writePoolSnapshotSync(ctx: GoalFileContext, root: string, goals: GoalRecord[]): void {
153
+ try {
154
+ const rootStat = fs.lstatSync(root);
155
+ const snapshot: PoolSnapshot = { version: 1, dirMtimeMs: rootStat.mtimeMs, goals };
156
+ if (isExternalGoalStorage(ctx)) ensureGoalStorageDirectory(ctx, ".pi/goals/.metadata");
157
+ const target = goalPoolSnapshotPath(ctx);
158
+ const tempPath = `${target}.${process.pid}.${Date.now()}.tmp`;
159
+ fs.writeFileSync(tempPath, JSON.stringify(snapshot), "utf8");
160
+ try { renameAtomicSync(tempPath, target); }
161
+ catch (error) { try { fs.unlinkSync(tempPath); } catch { /* best effort */ } throw error; }
162
+ removeLegacyPoolSnapshot(root);
163
+ } catch {
164
+ // best-effort: a missing/stale snapshot just costs a full scan next cold read
165
+ }
166
+ }
167
+
168
+ async function writePoolSnapshotAsync(ctx: GoalFileContext, root: string, goals: GoalRecord[]): Promise<void> {
169
+ try {
170
+ const rootStat = await fs.promises.lstat(root);
171
+ const snapshot: PoolSnapshot = { version: 1, dirMtimeMs: rootStat.mtimeMs, goals };
172
+ if (isExternalGoalStorage(ctx)) ensureGoalStorageDirectory(ctx, ".pi/goals/.metadata");
173
+ const target = goalPoolSnapshotPath(ctx);
174
+ const tempPath = `${target}.${process.pid}.${Date.now()}.tmp`;
175
+ await fs.promises.writeFile(tempPath, JSON.stringify(snapshot), "utf8");
176
+ try { await renameAtomic(tempPath, target); }
177
+ catch (error) { try { await fs.promises.unlink(tempPath); } catch { /* best effort */ } throw error; }
178
+ removeLegacyPoolSnapshot(root);
179
+ } catch {
180
+ // best-effort
181
+ }
182
+ }
183
+
184
+ /** Merge a delta (written goal or removal) into the persisted snapshot. */
185
+ function updatePoolSnapshotSync(ctx: GoalFileContext, root: string, mutate: (goals: GoalRecord[]) => GoalRecord[]): void {
186
+ const snapshot = readPoolSnapshotSync(ctx, root);
187
+ if (!snapshot) return; // no snapshot yet — next cold read does a full scan + write
188
+ snapshot.goals = mutate(snapshot.goals);
189
+ try {
190
+ const rootStat = fs.lstatSync(root);
191
+ snapshot.dirMtimeMs = rootStat.mtimeMs;
192
+ if (isExternalGoalStorage(ctx)) ensureGoalStorageDirectory(ctx, ".pi/goals/.metadata");
193
+ const target = goalPoolSnapshotPath(ctx);
194
+ const tempPath = `${target}.${process.pid}.${Date.now()}.tmp`;
195
+ fs.writeFileSync(tempPath, JSON.stringify(snapshot), "utf8");
196
+ try { renameAtomicSync(tempPath, target); }
197
+ catch (error) { try { fs.unlinkSync(tempPath); } catch { /* best effort */ } throw error; }
198
+ removeLegacyPoolSnapshot(root);
199
+ } catch {
200
+ // best-effort
201
+ }
202
+ }
203
+
204
+ /** Best-effort removal of the legacy in-dir snapshot after the first new-location write. */
205
+ function removeLegacyPoolSnapshot(root: string): void {
206
+ try {
207
+ fs.unlinkSync(poolSnapshotLegacyPath(root));
208
+ } catch {
209
+ // already absent
210
+ }
211
+ }
212
+
213
+ export type GoalFileContext = GoalStorageContext;
214
+
215
+ export function timestampForFile(iso = nowIso()): string {
216
+ const date = new Date(iso);
217
+ const safe = Number.isFinite(date.getTime()) ? date : new Date();
218
+ const pad = (value: number, width = 2) => String(value).padStart(width, "0");
219
+ return [
220
+ safe.getFullYear(),
221
+ pad(safe.getMonth() + 1),
222
+ pad(safe.getDate()),
223
+ pad(safe.getHours()),
224
+ pad(safe.getMinutes()),
225
+ pad(safe.getSeconds()),
226
+ pad(Math.floor(safe.getMilliseconds() / 10)),
227
+ ].join("");
228
+ }
229
+
230
+ export function isSafeRelativeUnder(ctx: GoalFileContext, rootRel: string, relPath: string | undefined): relPath is string {
231
+ if (!relPath || path.isAbsolute(relPath) || relPath.includes("\0")) return false;
232
+ const normalized = normalizeRelPath(relPath);
233
+ const parent = normalizeRelPath(path.posix.dirname(normalized));
234
+ if (parent !== normalizeRelPath(rootRel)) return false;
235
+ const root = goalStoragePath(ctx, rootRel);
236
+ const absolutePath = goalStoragePath(ctx, normalized);
237
+ const relative = path.relative(root, absolutePath);
238
+ return !relative.startsWith("..") && !path.isAbsolute(relative);
239
+ }
240
+
241
+ export function isSafeActivePath(ctx: GoalFileContext, relPath: string | undefined): relPath is string {
242
+ return Boolean(
243
+ isSafeRelativeUnder(ctx, GOALS_DIR, relPath)
244
+ && /^active_goal_.*\.md$/.test(path.posix.basename(normalizeRelPath(relPath))),
245
+ );
246
+ }
247
+
248
+ export function isSafeArchivedPath(ctx: GoalFileContext, relPath: string | undefined): relPath is string {
249
+ return Boolean(
250
+ isSafeRelativeUnder(ctx, ARCHIVED_GOALS_DIR, relPath)
251
+ && /^goal_.*\.md$/.test(path.posix.basename(normalizeRelPath(relPath))),
252
+ );
253
+ }
254
+
255
+ export function sanitizeGoalPaths(ctx: GoalFileContext, goal: GoalRecord): GoalRecord {
256
+ const next = cloneGoal(goal);
257
+ if (!isSafeActivePath(ctx, next.activePath)) delete next.activePath;
258
+ if (!isSafeArchivedPath(ctx, next.archivedPath)) delete next.archivedPath;
259
+ return next;
260
+ }
261
+
262
+ export function ensureDirectory(ctx: GoalFileContext, relPath: string): void {
263
+ ensureGoalStorageDirectory(ctx, relPath);
264
+ }
265
+
266
+ export function resolveGoalPath(ctx: GoalFileContext, rootRel: string, relPath: string): string {
267
+ const root = goalStoragePath(ctx, rootRel);
268
+ const absolutePath = goalStoragePath(ctx, normalizeRelPath(relPath));
269
+ const relative = path.relative(root, absolutePath);
270
+ if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Goal path escapes ${rootRel}: ${relPath}`);
271
+ return absolutePath;
272
+ }
273
+
274
+ export function atomicWriteGoalFile(ctx: GoalFileContext, rootRel: string, relPath: string, content: string): void {
275
+ ensureDirectory(ctx, rootRel);
276
+ const filePath = resolveGoalPath(ctx, rootRel, relPath);
277
+ const prior = statIfPresent(filePath);
278
+ if (prior?.isSymbolicLink()) {
279
+ throw new Error(`Refusing to write symlinked goal file: ${relPath}`);
280
+ }
281
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
282
+ let fd: number | undefined;
283
+ let created = false;
284
+ try {
285
+ fd = fs.openSync(tempPath, "wx", prior ? prior.mode & 0o777 : 0o666);
286
+ created = true;
287
+ // open() applies the process umask even when replacing an existing file.
288
+ if (prior && process.platform !== "win32" && (prior.mode & 0o777 & process.umask())) fs.fchmodSync(fd, prior.mode & 0o777);
289
+ fs.writeFileSync(fd, content, "utf8");
290
+ fs.closeSync(fd);
291
+ fd = undefined;
292
+ renameAtomicSync(tempPath, filePath);
293
+ } catch (error) {
294
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* best effort */ } }
295
+ if (created) { try { fs.unlinkSync(tempPath); } catch { /* best effort */ } }
296
+ throw error;
297
+ }
298
+ invalidateGoalPathCaches(filePath);
299
+ invalidateGoalDirCache(goalStoragePath(ctx, rootRel));
300
+ }
301
+
302
+ /** One metadata read suffices for existence and symlink checks; retain real IO errors. */
303
+ function statIfPresent(filePath: string): fs.Stats | undefined {
304
+ try { return fs.lstatSync(filePath); }
305
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
306
+ }
307
+
308
+ export function safeUnlinkGoalFile(ctx: GoalFileContext, rootRel: string, relPath: string): void {
309
+ const filePath = resolveGoalPath(ctx, rootRel, relPath);
310
+ const stat = statIfPresent(filePath);
311
+ if (stat && !stat.isSymbolicLink()) {
312
+ fs.unlinkSync(filePath);
313
+ invalidateGoalPathCaches(filePath);
314
+ invalidateGoalDirCache(goalStoragePath(ctx, rootRel));
315
+ if (rootRel === GOALS_DIR) {
316
+ const root = goalStoragePath(ctx, GOALS_DIR);
317
+ const normalized = normalizeRelPath(relPath);
318
+ updatePoolSnapshotSync(ctx, root, (goals) => goals.filter((g) => normalizeRelPath(g.activePath ?? "") !== normalized));
319
+ }
320
+ }
321
+ }
322
+
323
+ export function makeActiveGoalPath(goal: GoalRecord): string {
324
+ return `${GOALS_DIR}/active_goal_${timestampForFile(goal.createdAt)}_${safeIdPart(goal.id)}.md`;
325
+ }
326
+
327
+ export function makeArchivedGoalPath(goal: GoalRecord): string {
328
+ return `${ARCHIVED_GOALS_DIR}/goal_${timestampForFile(goal.updatedAt)}_${safeIdPart(goal.id)}.md`;
329
+ }
330
+
331
+ export function activePathForGoal(ctx: GoalFileContext, goal: GoalRecord): string {
332
+ return isSafeActivePath(ctx, goal.activePath) ? goal.activePath : makeActiveGoalPath(goal);
333
+ }
334
+
335
+ export function archivedPathForGoal(ctx: GoalFileContext, goal: GoalRecord): string {
336
+ return isSafeArchivedPath(ctx, goal.archivedPath) ? goal.archivedPath : makeArchivedGoalPath(goal);
337
+ }
338
+
339
+ function taskCheckbox(status: TaskStatus): string {
340
+ if (status === "complete") return "x";
341
+ if (status === "skipped") return "~";
342
+ return " ";
343
+ }
344
+
345
+ function taskLineSuffix(task: { status: TaskStatus; evidence?: string; skipReason?: string; verificationContract?: string }): string {
346
+ const parts: string[] = [];
347
+ if (task.status === "complete" && task.evidence) parts.push(`evidence: ${task.evidence}`);
348
+ if (task.status === "skipped" && task.skipReason) parts.push(`skipped: ${task.skipReason}`);
349
+ if ((task.status === "pending") && task.verificationContract) parts.push(`contract: ${task.verificationContract}`);
350
+ return parts.length > 0 ? ` — ${parts.join("; ")}` : "";
351
+ }
352
+
353
+ export function serializeGoalFile(goal: GoalRecord): string {
354
+ const meta = JSON.stringify({ version: 3, ...goal }, null, 2);
355
+ const pauseLines: string[] = [];
356
+ if (goal.pauseReason) pauseLines.push(`- Agent pause reason: ${goal.pauseReason}`);
357
+ if (goal.pauseSuggestedAction) pauseLines.push(`- Agent suggests: ${goal.pauseSuggestedAction}`);
358
+ const pauseBlock = pauseLines.length > 0 ? `\n${pauseLines.join("\n")}` : "";
359
+ let taskSection = "";
360
+ if (goal.taskList) {
361
+ const taskLines = goal.taskList.tasks.map((t) => {
362
+ return `- [${taskCheckbox(t.status)}] ${t.id}: ${t.title}${taskLineSuffix(t)}`;
363
+ });
364
+ taskSection = `\n## Tasks
365
+
366
+ <!-- blockCompletion: ${goal.taskList.blockCompletion} -->\n${taskLines.join("\n")}\n`;
367
+ }
368
+ const contractLine = goal.verificationContract?.trim() ? `
369
+ - Verification contract: ${goal.verificationContract.trim()}` : "";
370
+ return `${meta}
371
+
372
+ # Goal Prompt
373
+
374
+ ${goal.objective.trim()}
375
+
376
+ ## Progress
377
+
378
+ - Status: ${statusLabel(goal)}
379
+ - Auto-continue: ${goal.autoContinue ? "on" : "off"}
380
+ - Sisyphus mode: ${goal.sisyphus ? "yes (prompt/criteria style)" : "no"}
381
+ - Time spent: ${formatDuration(goal.usage.activeSeconds)}
382
+ - Tokens used: ${formatTokenValue(goal.usage.tokensUsed)}${contractLine}${taskSection}${pauseBlock}
383
+ `;
384
+ }
385
+
386
+ export function findJsonObjectEnd(content: string): number {
387
+ let depth = 0;
388
+ let inString = false;
389
+ let escaped = false;
390
+
391
+ for (let i = 0; i < content.length; i++) {
392
+ const char = content[i];
393
+ if (inString) {
394
+ if (escaped) {
395
+ escaped = false;
396
+ } else if (char === "\\") {
397
+ escaped = true;
398
+ } else if (char === "\"") {
399
+ inString = false;
400
+ }
401
+ continue;
402
+ }
403
+ if (char === "\"") {
404
+ inString = true;
405
+ continue;
406
+ }
407
+ if (char === "{") {
408
+ depth++;
409
+ continue;
410
+ }
411
+ if (char === "}") {
412
+ depth--;
413
+ if (depth === 0) return i;
414
+ }
415
+ }
416
+ return -1;
417
+ }
418
+
419
+ export function extractObjectiveFromBody(body: string): string | undefined {
420
+ const lines = body.replace(/^\s+/, "").split(/\r?\n/);
421
+ const start = lines.findIndex((line) => line.trim() === "# Goal Prompt");
422
+ if (start < 0) return body.trim() || undefined;
423
+ let end = lines.length;
424
+ for (let i = start + 1; i < lines.length; i++) {
425
+ if (lines[i]?.trim() === "## Progress") {
426
+ end = i;
427
+ break;
428
+ }
429
+ }
430
+ return lines.slice(start + 1, end).join("\n").trim() || undefined;
431
+ }
432
+
433
+ export function parseGoalFile(filePath: string): GoalRecord | null {
434
+ let stat: fs.Stats;
435
+ try {
436
+ stat = fs.lstatSync(filePath);
437
+ } catch {
438
+ goalFileParseCache.delete(filePath);
439
+ return null;
440
+ }
441
+ if (stat.isSymbolicLink()) return null;
442
+ const cached = goalFileParseCache.get(filePath);
443
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
444
+ return cached.parsed;
445
+ }
446
+ let content: string;
447
+ try {
448
+ content = fs.readFileSync(filePath, "utf8");
449
+ } catch {
450
+ return null;
451
+ }
452
+ return parseGoalContentCached(filePath, stat.mtimeMs, stat.size, content);
453
+ }
454
+
455
+ /**
456
+ * Parse goal-file content and seed the P1-1 parse cache (shared by the sync
457
+ * and the parallel async readers). Returns null for malformed content.
458
+ */
459
+ export function parseGoalContentCached(filePath: string, mtimeMs: number, size: number, content: string): GoalRecord | null {
460
+ const end = findJsonObjectEnd(content);
461
+ if (end < 0) return null;
462
+ let raw: Record<string, unknown>;
463
+ try {
464
+ raw = JSON.parse(content.slice(0, end + 1)) as Record<string, unknown>;
465
+ } catch {
466
+ return null;
467
+ }
468
+ const objective = extractObjectiveFromBody(content.slice(end + 1)) ?? raw.objective;
469
+ const parsed = normalizeGoalRecord({ ...raw, objective });
470
+ goalFileParseCache.set(filePath, { mtimeMs, size, parsed });
471
+ return parsed;
472
+ }
473
+
474
+ export function writeActiveGoalFile(ctx: GoalFileContext, current: GoalRecord): GoalRecord {
475
+ const activePath = activePathForGoal(ctx, current);
476
+ const next = sanitizeGoalPaths(ctx, { ...current, activePath, updatedAt: nowIso() });
477
+ atomicWriteGoalFile(ctx, GOALS_DIR, activePath, serializeGoalFile(next));
478
+ // Keep the persistent pool snapshot current (read-modify-write, best-effort).
479
+ updatePoolSnapshotSync(ctx, goalStoragePath(ctx, GOALS_DIR), (goals) => {
480
+ const rest = goals.filter((g) => g.id !== next.id);
481
+ rest.push(next);
482
+ return rest;
483
+ });
484
+ return next;
485
+ }
486
+
487
+ export function archiveGoalFile(ctx: GoalFileContext, current: GoalRecord): GoalRecord {
488
+ const archivedPath = archivedPathForGoal(ctx, current);
489
+ const next = sanitizeGoalPaths(ctx, { ...current, archivedPath, updatedAt: nowIso() });
490
+ delete next.activePath;
491
+ atomicWriteGoalFile(ctx, ARCHIVED_GOALS_DIR, archivedPath, serializeGoalFile(next));
492
+ if (isSafeActivePath(ctx, current.activePath)) {
493
+ try {
494
+ safeUnlinkGoalFile(ctx, GOALS_DIR, current.activePath);
495
+ } catch {}
496
+ }
497
+ return next;
498
+ }
499
+
500
+ export function mergeGoalPromptFromDisk(ctx: GoalFileContext, current: GoalRecord): GoalRecord {
501
+ if (!isSafeActivePath(ctx, current.activePath)) return current;
502
+ // NAF: the zero-op pool cache is the session view of the parsed goal file
503
+ // (invalidated on every extension write) — source the objective from it so
504
+ // the per-turn merge is 0 fs ops. When the cache is empty (cold or just
505
+ // invalidated by a write), fall back to the mtime-keyed direct parse.
506
+ const root = goalStoragePath(ctx, GOALS_DIR);
507
+ const cached = goalPoolCache.get(root)?.get(current.id);
508
+ if (cached) return { ...current, objective: cached.objective };
509
+ try {
510
+ const parsed = parseGoalFile(resolveGoalPath(ctx, GOALS_DIR, current.activePath));
511
+ if (!parsed) return current;
512
+ return { ...current, objective: parsed.objective };
513
+ } catch {
514
+ return current;
515
+ }
516
+ }
517
+
518
+ export function readActiveGoalFiles(ctx: GoalFileContext): GoalRecord[] {
519
+ const root = goalStoragePath(ctx, GOALS_DIR);
520
+ const cachedPool = goalPoolCache.get(root);
521
+ if (cachedPool) return Array.from(cachedPool.values());
522
+ const goals = scanActiveGoalFiles(ctx, root);
523
+ const pool = new Map<string, GoalRecord>();
524
+ for (const goal of goals) pool.set(goal.id, goal);
525
+ goalPoolCache.set(root, pool);
526
+ return goals;
527
+ }
528
+
529
+ /** Uncached full scan (used when the zero-op pool cache is empty). */
530
+ function scanActiveGoalFiles(ctx: GoalFileContext, root: string): GoalRecord[] {
531
+ let entries: string[];
532
+ try {
533
+ const rootStat = fs.lstatSync(root);
534
+ if (rootStat.isSymbolicLink()) { if (isExternalGoalStorage(ctx)) throw new Error(`Goal root is a symlink: ${root}`); return []; }
535
+ const cachedListing = goalDirListingCache.get(root);
536
+ if (cachedListing && cachedListing.mtimeMs === rootStat.mtimeMs) {
537
+ entries = cachedListing.names;
538
+ } else {
539
+ entries = fs.readdirSync(root)
540
+ .filter((name) => /^active_goal_.*\.md$/.test(name))
541
+ .sort((a, b) => a.localeCompare(b));
542
+ goalDirListingCache.set(root, { mtimeMs: rootStat.mtimeMs, names: entries });
543
+ }
544
+ } catch (error) {
545
+ if (isExternalGoalStorage(ctx)) throw error;
546
+ return [];
547
+ }
548
+ return entries
549
+ .map((name) => {
550
+ const relPath = `${GOALS_DIR}/${name}`;
551
+ if (!isSafeActivePath(ctx, relPath)) return null;
552
+ const parsed = parseGoalFile(resolveGoalPath(ctx, GOALS_DIR, relPath));
553
+ if (!parsed || parsed.status === "complete") return null;
554
+ return sanitizeGoalPaths(ctx, { ...parsed, activePath: relPath });
555
+ })
556
+ .filter((goal): goal is GoalRecord => goal !== null);
557
+ }
558
+
559
+ export function readActiveGoalPoolView(ctx: GoalFileContext): ReadonlyMap<string, GoalRecord> {
560
+ const root = goalStoragePath(ctx, GOALS_DIR);
561
+ const cached = goalPoolCache.get(root);
562
+ if (cached) return cached;
563
+ const pool = readPoolWithSnapshotSync(ctx, root);
564
+ goalPoolCache.set(root, pool);
565
+ return pool;
566
+ }
567
+
568
+ export function readActiveGoalPool(ctx: GoalFileContext): Map<string, GoalRecord> {
569
+ return new Map(readActiveGoalPoolView(ctx));
570
+ }
571
+
572
+ /** Cold pool read: serve the persisted snapshot when the goals dir is unchanged (2 ops),
573
+ * or when only non-goal entries changed (ledger churn: one extra readdir to verify the
574
+ * active_goal filename set is identical — catches external add/remove). Otherwise full
575
+ * scan + re-snapshot (external goal add/remove or no snapshot yet). */
576
+ function readPoolWithSnapshotSync(ctx: GoalFileContext, root: string): Map<string, GoalRecord> {
577
+ let rootStat: fs.Stats | null = null;
578
+ try {
579
+ rootStat = fs.lstatSync(root);
580
+ } catch {
581
+ rootStat = null;
582
+ }
583
+ if (rootStat && !rootStat.isSymbolicLink()) {
584
+ const snapshot = readPoolSnapshotSync(ctx, root);
585
+ if (snapshot) {
586
+ if (snapshot.dirMtimeMs === rootStat.mtimeMs || activeGoalNamesMatchSync(root, snapshot)) {
587
+ return hydratePoolFromSnapshot(snapshot);
588
+ }
589
+ }
590
+ }
591
+ const pool = new Map<string, GoalRecord>();
592
+ for (const goal of scanActiveGoalFiles(ctx, root)) {
593
+ pool.set(goal.id, goal);
594
+ }
595
+ writePoolSnapshotSync(ctx, root, Array.from(pool.values()));
596
+ return pool;
597
+ }
598
+
599
+ /** True when the snapshot's goal filename set equals the goals dir's active_goal files. */
600
+ function activeGoalNamesMatchSync(root: string, snapshot: PoolSnapshot): boolean {
601
+ try {
602
+ return activeGoalNamesMatch(fs.readdirSync(root), snapshot);
603
+ } catch {
604
+ return false;
605
+ }
606
+ }
607
+
608
+ async function activeGoalNamesMatchAsync(root: string, snapshot: PoolSnapshot): Promise<boolean> {
609
+ try {
610
+ return activeGoalNamesMatch(await fs.promises.readdir(root), snapshot);
611
+ } catch {
612
+ return false;
613
+ }
614
+ }
615
+
616
+ function activeGoalNamesMatch(names: string[], snapshot: PoolSnapshot): boolean {
617
+ const remaining = new Set(names.filter(name => /^active_goal_.*\.md$/.test(name)));
618
+ for (const goal of snapshot.goals) {
619
+ const name = path.posix.basename(normalizeRelPath(goal.activePath ?? ""));
620
+ if (/^active_goal_.*\.md$/.test(name) && !remaining.delete(name)) return false;
621
+ }
622
+ return remaining.size === 0;
623
+ }
624
+
625
+ /**
626
+ * Parallel pool read (P1-7): fs.promises-based readdir + per-file stat/read
627
+ * in parallel, seeding the P1-1 parse cache so subsequent sync reads hit it.
628
+ * Used by session startup rehydration (loadState). NAF: serves the zero-op
629
+ * pool cache when present; a cold read populates it so subsequent sync reads
630
+ * (reconcile, get_goal, prompts) are zero-op.
631
+ */
632
+ export async function readActiveGoalPoolAsync(ctx: GoalFileContext): Promise<Map<string, GoalRecord>> {
633
+ const root = goalStoragePath(ctx, GOALS_DIR);
634
+ const cachedPool = goalPoolCache.get(root);
635
+ if (cachedPool) return new Map(cachedPool);
636
+ const pool = await readPoolWithSnapshotAsync(ctx, root);
637
+ goalPoolCache.set(root, pool);
638
+ return new Map(pool);
639
+ }
640
+
641
+ async function readPoolWithSnapshotAsync(ctx: GoalFileContext, root: string): Promise<Map<string, GoalRecord>> {
642
+ let rootStat: fs.Stats | null = null;
643
+ try {
644
+ rootStat = await fs.promises.lstat(root);
645
+ } catch {
646
+ rootStat = null;
647
+ }
648
+ if (rootStat && !rootStat.isSymbolicLink()) {
649
+ const snapshot = await readPoolSnapshotAsync(ctx, root);
650
+ if (snapshot) {
651
+ if (snapshot.dirMtimeMs === rootStat.mtimeMs || (await activeGoalNamesMatchAsync(root, snapshot))) {
652
+ return hydratePoolFromSnapshot(snapshot);
653
+ }
654
+ }
655
+ }
656
+ const pool = await scanActiveGoalFilesAsync(ctx, root);
657
+ await writePoolSnapshotAsync(ctx, root, Array.from(pool.values()));
658
+ return pool;
659
+ }
660
+
661
+ async function scanActiveGoalFilesAsync(ctx: GoalFileContext, root: string): Promise<Map<string, GoalRecord>> {
662
+ let names: string[];
663
+ try {
664
+ const rootStat = await fs.promises.lstat(root);
665
+ if (rootStat.isSymbolicLink()) { if (isExternalGoalStorage(ctx)) throw new Error(`Goal root is a symlink: ${root}`); return new Map(); }
666
+ names = (await fs.promises.readdir(root))
667
+ .filter((name) => /^active_goal_.*\.md$/.test(name))
668
+ .sort((a, b) => a.localeCompare(b));
669
+ } catch (error) {
670
+ if (isExternalGoalStorage(ctx)) throw error;
671
+ return new Map();
672
+ }
673
+ const parsed = await Promise.all(names.map(async (name): Promise<GoalRecord | null> => {
674
+ const relPath = `${GOALS_DIR}/${name}`;
675
+ if (!isSafeActivePath(ctx, relPath)) return null;
676
+ const abs = resolveGoalPath(ctx, GOALS_DIR, relPath);
677
+ try {
678
+ const [stat, content] = await Promise.all([
679
+ fs.promises.lstat(abs),
680
+ fs.promises.readFile(abs, "utf8"),
681
+ ]);
682
+ if (stat.isSymbolicLink()) return null;
683
+ const parsed = parseGoalContentCached(abs, stat.mtimeMs, stat.size, content);
684
+ if (!parsed || parsed.status === "complete") return null;
685
+ return sanitizeGoalPaths(ctx, { ...parsed, activePath: relPath });
686
+ } catch {
687
+ return null;
688
+ }
689
+ }));
690
+ const pool = new Map<string, GoalRecord>();
691
+ for (const goal of parsed) {
692
+ if (goal) pool.set(goal.id, goal);
693
+ }
694
+ return pool;
695
+ }