@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,843 @@
1
+ import { goalStorageContext, goalStorageRoot } from "./storage/goal-root.ts";
2
+ import { cloneGoal, nowIso, type GoalFocusReason, type GoalRecord, type GoalTask, type GoalUsage } from "./goal-record.ts";
3
+ import { appendGoalEvent, appendGoalEvents, type GoalLedgerEvent } from "./goal-ledger.ts";
4
+ import { findTaskInTree, updateTaskInTree } from "./goal-policy.ts";
5
+ import {
6
+ GOALS_DIR,
7
+ archiveGoalFile,
8
+ atomicWriteGoalFile,
9
+ ensureDirectory,
10
+ mergeGoalPromptFromDisk,
11
+ parseGoalFile,
12
+ readActiveGoalPool,
13
+ readActiveGoalPoolView,
14
+ resolveGoalPath,
15
+ safeUnlinkGoalFile,
16
+ sanitizeGoalPaths,
17
+ writeActiveGoalFile,
18
+ type GoalFileContext,
19
+ } from "./storage/goal-files.ts";
20
+ import { acquireGoalLock, type GoalLock } from "./storage/goal-lock.ts";
21
+ import { mergeFocusedGoalWithDisk } from "./goal-pool.ts";
22
+
23
+ /**
24
+ * Session state access + runtime glue hooks that the GoalService needs.
25
+ * The extension (`extensions/goal.ts`) wires these to its closure state so the
26
+ * service stays the sole mutation boundary while the runtime effects
27
+ * (continuation queue, accounting, nudge state, tools, UI) remain in the
28
+ * extension's event handlers.
29
+ */
30
+ export interface GoalDiagnostic {
31
+ severity: "warning";
32
+ source: "ledger" | "storage";
33
+ goalId?: string;
34
+ eventType?: string;
35
+ message: string;
36
+ }
37
+
38
+ export interface GoalServiceRef {
39
+ getFocused(): GoalRecord | null;
40
+ /** Mirror of the `state.goal` setter: pool upsert + focus assignment. */
41
+ setFocused(goal: GoalRecord | null): void;
42
+ getPool(): Map<string, GoalRecord>;
43
+ replacePool(pool: Map<string, GoalRecord>): void;
44
+ getFocusedGoalId(): string | null;
45
+ /** Assigns the session focus; bumps the focus revision when it changes. */
46
+ assignFocusedGoalId(goalId: string | null): void;
47
+ focusToken(goalId: string): { goalId: string; revision: number };
48
+ isTokenCurrent(token: { goalId: string; revision: number }): boolean;
49
+ appendFocusEntry(goalId: string | null, reason: GoalFocusReason): void;
50
+ /** The focused goal vanished during reconciliation (external clear/archive/delete). */
51
+ onFocusedGoalLost(lostGoalId: string | null, ctx: GoalServiceContext): void;
52
+ /** A reconciled goal is now focused; clear continuation/accounting as its status requires. */
53
+ onReconciled(goal: GoalRecord): void;
54
+ /** The session focus changed; clear continuation/accounting/nudge state. */
55
+ onFocusChanged(from: string | null, to: string | null): void;
56
+ /** Observable diagnostic sink for non-fatal failures (ledger appends). */
57
+ onDiagnostic(diagnostic: GoalDiagnostic): void;
58
+ }
59
+
60
+ export type GoalServiceContext = GoalFileContext;
61
+
62
+ export interface GoalMutationSpec {
63
+ /** If provided, the focused goal id must equal this or the mutation is rejected. */
64
+ expectedGoalId?: string | null;
65
+ /** If provided, the token must still be current (same goal id + focus revision). */
66
+ focusToken?: { goalId: string; revision: number };
67
+ /** Skip the leading disk reconciliation (used when the caller already reconciled or must not). */
68
+ reconcile?: boolean;
69
+ /** Merge the authoritative objective body from disk before mutating. */
70
+ refreshFromDisk?: boolean;
71
+ /** Mutate a clone of the focused goal. May ignore its input to produce a fixed record. */
72
+ mutate: (goal: GoalRecord) => GoalRecord;
73
+ /** Ledger events appended best-effort AFTER the authoritative file write. */
74
+ ledger?: GoalLedgerEvent[] | ((written: GoalRecord) => GoalLedgerEvent[]);
75
+ /** Write the archived goal file instead of the active file (complete/clear/abort). */
76
+ archive?: boolean;
77
+ /** Commit the written record as the focused in-memory goal. Default true. */
78
+ commitFocused?: boolean;
79
+ }
80
+
81
+ export interface GoalMutationResult {
82
+ ok: true;
83
+ goal: GoalRecord;
84
+ previousGoalId: string | null;
85
+ goalId: string | null;
86
+ focusChanged: boolean;
87
+ }
88
+
89
+ export interface GoalMutationFailure {
90
+ ok: false;
91
+ message: string;
92
+ }
93
+
94
+ export type GoalMutationOutcome = GoalMutationResult | GoalMutationFailure;
95
+
96
+ /**
97
+ * GoalService — the extension's sole mutation boundary for goal records.
98
+ *
99
+ * Ordered pipeline for every focused-goal mutation (mirrors TECH.md storage
100
+ * section):
101
+ *
102
+ * 1. safe focused record reconciliation from disk;
103
+ * 2. expected goal id + focus revision check;
104
+ * 3. mutation on a clone;
105
+ * 4. active-file write (or archival for complete/clear/abort);
106
+ * 5. ledger append best effort;
107
+ * 6. in-memory pool/focus commit;
108
+ * 7. runtime/UI effects (returned and signalled through the ref hooks).
109
+ *
110
+ * If the active-file write fails it throws, so memory/ledger/focus/archive are
111
+ * never committed. A failed ledger append after the authoritative write keeps
112
+ * the successful state transition and reports diagnostics via the hook, matching
113
+ * the current best-effort ledger semantics.
114
+ */
115
+ export interface GoalTaskUpdateSpec {
116
+ /** If provided, the token must still be current (same goal id + focus revision). */
117
+ focusToken?: { goalId: string; revision: number };
118
+ /** The task to update, loaded fresh from the disk record. */
119
+ taskId: string;
120
+ /** Optional transition validation against the FRESH task; a failure aborts with a typed message. */
121
+ validate?(task: GoalTask): { ok: true } | { ok: false; message: string };
122
+ /** Transform the FRESH task. A failure aborts; only this task's path changes. */
123
+ update(task: GoalTask): GoalTask | { ok: false; message: string };
124
+ /**
125
+ * §8.1: explicit execution focus — set the persisted currentTaskId to this
126
+ * task id (start). Replaces any previous current task. Otherwise focus is
127
+ * cleared automatically when the updated task becomes terminal
128
+ * (complete/skipped) and was the current task (§8.2/§8.3).
129
+ */
130
+ setCurrentTaskId?: string;
131
+ /** Ledger events appended best-effort AFTER the authoritative file write. */
132
+ ledger?(written: GoalRecord, updatedTask: GoalTask): GoalLedgerEvent[];
133
+ }
134
+
135
+ export type GoalTaskUpdateOutcome = { ok: true; goal: GoalRecord; task: GoalTask } | { ok: false; message: string };
136
+
137
+ /**
138
+ * §8 execution-focus resolution for a task update: an explicit start sets the
139
+ * currentTaskId (replacing any previous); otherwise completing or skipping the
140
+ * current task clears it; otherwise focus is untouched.
141
+ */
142
+ function resolveUpdatedCurrentTaskId(spec: GoalTaskUpdateSpec, current: string | undefined, updatedTask: GoalTask): string | undefined {
143
+ if (spec.setCurrentTaskId !== undefined) return spec.setCurrentTaskId;
144
+ const terminal = updatedTask.status === "complete" || updatedTask.status === "skipped";
145
+ if (terminal && current === spec.taskId) return undefined;
146
+ return current;
147
+ }
148
+
149
+ export class GoalService {
150
+ private readonly ref: GoalServiceRef;
151
+
152
+ /**
153
+ * The usage this session last wrote to disk for a goal (or the disk usage
154
+ * the session's accounting is relative to after load/reconcile). The
155
+ * baseline for additive usage merging: on a persist revision conflict the
156
+ * delta since this baseline is merged onto the disk record instead of the
157
+ * local usage being dropped.
158
+ */
159
+ private lastPersistedUsage: { goalId: string; tokensUsed: number; activeSeconds: number } | null = null;
160
+
161
+ /**
162
+ * Per-turn transaction buffer (P1-3): task/status/usage mutations and
163
+ * ledger events accumulate in memory during a turn and flush once — one
164
+ * lock acquire, one goal-file write, one ledger batch — at turn end or at
165
+ * an explicit flush boundary (audit dispatch, pause/stop, focus change,
166
+ * session reload). Reads during the turn overlay the buffered goal so
167
+ * in-turn mutations are always visible; the optimistic revision check runs
168
+ * at flush time.
169
+ */
170
+ private flushError: string | null = null;
171
+ private turnBase: GoalRecord | null = null;
172
+ private turnContext: GoalServiceContext | null = null;
173
+
174
+ private turn: { active: boolean; goalId: string | null; goal: GoalRecord | null; archive: boolean; ledger: GoalLedgerEvent[] } = {
175
+ active: false,
176
+ goalId: null,
177
+ goal: null,
178
+ archive: false,
179
+ ledger: [],
180
+ };
181
+
182
+ /** Open (or reopen) the per-turn transaction for the focused goal. */
183
+ beginTurn(ctx: GoalServiceContext, goalId: string | null): void {
184
+ if (this.turn.active) {
185
+ this.flushTurn(ctx);
186
+ // Lock contention must not discard the previous transaction. Keep it
187
+ // open so the next turn boundary retries the same buffered write.
188
+ if (this.turn.active) return;
189
+ }
190
+ this.turn = { active: true, goalId, goal: null, archive: false, ledger: [] };
191
+ this.turnContext = goalStorageContext(ctx);
192
+ const focused = this.ref.getFocused();
193
+ this.turnBase = focused ? {...focused, usage: {...focused.usage}} : null;
194
+ this.flushError = null;
195
+ }
196
+
197
+ /**
198
+ * Flush the buffered transaction: one lock acquire, one (archived or
199
+ * active) goal-file write with a revision bump, one batched ledger append.
200
+ * No-op when nothing was buffered. Returns the written record or null.
201
+ */
202
+ flushTurn(ctx: GoalServiceContext): GoalRecord | null {
203
+ if (!this.turn.active) return null;
204
+ ctx = this.turnContext ?? ctx;
205
+ this.flushError = null;
206
+ const goal = this.turn.goal;
207
+ if (!goal) {
208
+ this.turn.active = false;
209
+ return null;
210
+ }
211
+ let lock: GoalLock;
212
+ try {
213
+ lock = acquireGoalLock(ctx, goal.id);
214
+ } catch {
215
+ this.flushError = "Goal storage is locked; pending changes have not been persisted.";
216
+ // Another writer holds the lock; preserve the buffer so a later turn
217
+ // boundary can retry it instead of silently losing the mutation.
218
+ return null;
219
+ }
220
+ try {
221
+ const freshDisk = this.readFreshDiskGoal(ctx, goal);
222
+ const expected = this.turnBase ?? goal;
223
+ if (!freshDisk || (freshDisk.revision ?? 0) !== (expected.revision ?? 0)) {
224
+ this.flushError = `Goal ${goal.id} changed in another process; buffered changes were rejected. Refresh and retry.`;
225
+ // Reject the speculative transaction, never overwrite another writer.
226
+ this.turn.active = false;
227
+ this.turn.goal = null;
228
+ this.turn.ledger = [];
229
+ if (freshDisk && this.ref.getFocusedGoalId() === goal.id) {
230
+ const tokens = Math.max(0, goal.usage.tokensUsed - expected.usage.tokensUsed);
231
+ const seconds = Math.max(0, goal.usage.activeSeconds - expected.usage.activeSeconds);
232
+ this.ref.setFocused({ ...freshDisk, usage: {tokensUsed: freshDisk.usage.tokensUsed + tokens, activeSeconds: freshDisk.usage.activeSeconds + seconds} });
233
+ this.trackBaseline(freshDisk.id, freshDisk.usage);
234
+ } else if (!freshDisk) {
235
+ this.ref.getPool().delete(goal.id);
236
+ if (this.ref.getFocusedGoalId() === goal.id) {
237
+ this.ref.assignFocusedGoalId(null);
238
+ this.ref.onFocusedGoalLost(goal.id, ctx);
239
+ }
240
+ }
241
+ this.ref.onDiagnostic({ severity: "warning", source: "storage", goalId: goal.id, message: this.flushError });
242
+ return null;
243
+ }
244
+ const base = freshDisk;
245
+ const next = { ...goal, revision: (base.revision ?? 0) + 1 };
246
+ const written = this.turn.archive || next.status === "complete"
247
+ ? archiveGoalFile(ctx, next)
248
+ : writeActiveGoalFile(ctx, next);
249
+ this.turn.active = false;
250
+ this.appendLedgerEventsBestEffort(ctx, this.turn.ledger);
251
+ this.trackBaseline(written.id, written.usage);
252
+ if (this.ref.getFocusedGoalId() === written.id) this.ref.setFocused(written);
253
+ else this.ref.getPool().set(written.id, written);
254
+ return written;
255
+ } finally {
256
+ lock.release();
257
+ }
258
+ }
259
+
260
+ /** Completion must never audit state that failed to flush. */
261
+ flushForAudit(ctx: GoalServiceContext): string | null {
262
+ this.flushTurn(ctx);
263
+ return this.flushError;
264
+ }
265
+
266
+ /** End the turn: flush any pending transaction (safe to call always). */
267
+ endTurn(ctx: GoalServiceContext): void {
268
+ this.flushTurn(ctx);
269
+ }
270
+
271
+ /** True while the transaction buffer is open. */
272
+ isTurnBuffered(): boolean {
273
+ return this.turn.active;
274
+ }
275
+
276
+ constructor(ref: GoalServiceRef) {
277
+ this.ref = ref;
278
+ }
279
+
280
+ /** Record the usage value the focused goal's in-memory accounting builds on. */
281
+ private trackBaseline(goalId: string, usage: GoalUsage): void {
282
+ this.lastPersistedUsage = { goalId, tokensUsed: usage.tokensUsed, activeSeconds: usage.activeSeconds };
283
+ }
284
+
285
+ /**
286
+ * Session-local usage delta since the last recorded baseline (clamped at
287
+ * zero so usage is never reduced). Falls back to the disk usage when this
288
+ * session has no baseline for the goal (never persisted/reconciled it).
289
+ */
290
+ private usageDelta(current: GoalRecord, disk: GoalRecord): { tokens: number; seconds: number } {
291
+ const baseline = this.lastPersistedUsage?.goalId === current.id
292
+ ? this.lastPersistedUsage
293
+ : { goalId: current.id, tokensUsed: disk.usage.tokensUsed, activeSeconds: disk.usage.activeSeconds };
294
+ return {
295
+ tokens: Math.max(0, current.usage.tokensUsed - baseline.tokensUsed),
296
+ seconds: Math.max(0, current.usage.activeSeconds - baseline.activeSeconds),
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Append ledger events without making them part of the authoritative state
302
+ * transaction. A batch is the common path; individual retries preserve the
303
+ * existing best-effort behavior and keep failures observable per event.
304
+ */
305
+ private appendLedgerEventsBestEffort(ctx: GoalServiceContext, events: GoalLedgerEvent[]): boolean {
306
+ if (events.length === 0) return true;
307
+ if (events.length > 1 && appendGoalEvents(ctx, events).ok) return true;
308
+ let success = true;
309
+ for (const event of events) {
310
+ const append = appendGoalEvent(ctx, event);
311
+ if (!append.ok) {
312
+ success = false;
313
+ this.ref.onDiagnostic({
314
+ severity: "warning",
315
+ source: "ledger",
316
+ goalId: "goalId" in event ? event.goalId : undefined,
317
+ eventType: event.type,
318
+ message: `Ledger append failed for ${event.type}${"goalId" in event ? ` (goal ${event.goalId})` : ""}: ${String(append.error)}`,
319
+ });
320
+ }
321
+ }
322
+ return success;
323
+ }
324
+
325
+ /** Safe focused record reconciliation from disk. */
326
+ reconcileFocused(ctx: GoalServiceContext, opts: { preserveMemoryUsage?: boolean } = {}): boolean {
327
+ const current = this.ref.getFocused();
328
+ const source = readActiveGoalPoolView(ctx);
329
+ const focused = this.ref.getFocusedGoalId();
330
+ const view = this.ref.getPool();
331
+ const buffered = this.turn.active ? this.turn.goal : null;
332
+ let unchanged = source.size === view.size && (!focused || source.has(focused));
333
+ if (unchanged) for (const [id, goal] of source) {
334
+ if (view.get(id) !== (buffered?.id === id ? buffered : goal)) { unchanged = false; break; }
335
+ }
336
+ if (unchanged) {
337
+ const same = focused ? view.get(focused) : undefined;
338
+ if (same) this.ref.onReconciled(same);
339
+ return true;
340
+ }
341
+ const fresh = new Map(source);
342
+ // P1-3: overlay the buffered goal so in-turn mutations are visible to
343
+ // every read (reconcile, get_goal, prompts) without a disk round trip.
344
+ if (this.turn.active && this.turn.goal) fresh.set(this.turn.goal.id, this.turn.goal);
345
+ const focusedGoalId = this.ref.getFocusedGoalId();
346
+ if (!focusedGoalId) {
347
+ this.ref.replacePool(fresh);
348
+ return true;
349
+ }
350
+ const diskGoal = fresh.get(focusedGoalId) ?? null;
351
+ if (!diskGoal) {
352
+ if (current && !current.activePath) {
353
+ this.ref.replacePool(fresh);
354
+ fresh.set(current.id, current);
355
+ this.ref.assignFocusedGoalId(current.id);
356
+ return true;
357
+ }
358
+ const lostGoalId = current?.id ?? null;
359
+ this.ref.replacePool(fresh);
360
+ this.ref.assignFocusedGoalId(null);
361
+ this.ref.onFocusedGoalLost(lostGoalId, ctx);
362
+ return false;
363
+ }
364
+ const reconciled = current && opts.preserveMemoryUsage
365
+ ? mergeFocusedGoalWithDisk({ memoryGoal: current, diskGoal })
366
+ : diskGoal;
367
+ this.ref.replacePool(fresh);
368
+ fresh.set(reconciled.id, reconciled);
369
+ this.ref.assignFocusedGoalId(reconciled.id);
370
+ this.ref.onReconciled(reconciled);
371
+ this.trackBaseline(reconciled.id, reconciled.usage);
372
+ return true;
373
+ }
374
+
375
+ /**
376
+ * Read the goal's authoritative active file directly (no complete-status
377
+ * filter, unlike the pool reader) for the optimistic revision check under
378
+ * the per-goal lock. Returns null when the file is gone (external
379
+ * archive/delete) or the goal has no active path.
380
+ */
381
+ private readFreshDiskGoal(ctx: GoalServiceContext, current: GoalRecord): GoalRecord | null {
382
+ if (!current.activePath) return null;
383
+ try {
384
+ return parseGoalFile(resolveGoalPath(ctx, GOALS_DIR, current.activePath));
385
+ } catch {
386
+ return null;
387
+ }
388
+ }
389
+
390
+ /**
391
+ * The single ordered mutation pipeline for a focused goal.
392
+ * Returns the written record plus focus-change effects; the extension maps
393
+ * failures to user-facing results.
394
+ *
395
+ * Cross-process control (follow-up Stage 4): after the session-local
396
+ * validation, an exclusive per-goal lock is acquired (bounded, with stale
397
+ * recovery) and the authoritative file is re-read under the lock. If the
398
+ * persisted revision differs from the one captured at reconciliation, the
399
+ * mutation returns a typed conflict carrying the current revision instead
400
+ * of overwriting blindly. On success the revision is incremented, the file
401
+ * is written atomically, ledger events are appended best-effort, memory is
402
+ * committed, and the lock is released in a finally block.
403
+ */
404
+ apply(ctx: GoalServiceContext, spec: GoalMutationSpec): GoalMutationOutcome {
405
+ // 1. reconcile (unless the caller opts out — e.g. the tweak path, which
406
+ // must not clobber the authoritative new objective with the old file).
407
+ if (spec.reconcile !== false && !this.reconcileFocused(ctx)) {
408
+ return { ok: false, message: "The focused goal was lost during reconciliation; the mutation was not applied." };
409
+ }
410
+ const current = this.ref.getFocused();
411
+ if (!current) {
412
+ return { ok: false, message: "No focused goal to mutate." };
413
+ }
414
+
415
+ // 2. expected goal id + focus revision validation.
416
+ if (spec.expectedGoalId != null && current.id !== spec.expectedGoalId) {
417
+ return { ok: false, message: `Mutation rejected: expected goal ${spec.expectedGoalId} but the focused goal is ${current.id}.` };
418
+ }
419
+ if (spec.focusToken && !this.ref.isTokenCurrent(spec.focusToken)) {
420
+ return { ok: false, message: `Mutation cancelled because goal ${spec.focusToken.goalId} is no longer focused in this session. The shared goal was not modified.` };
421
+ }
422
+
423
+ // 2b. P1-3: during a turn the mutation is buffered in memory (no lock, no
424
+ // disk write yet); the flush acquires the lock, writes, and appends
425
+ // the ledger once per turn. Outside a turn the immediate path below
426
+ // (lock + optimistic revision check) is unchanged.
427
+ if (this.turn.active) {
428
+ if (this.turn.goalId !== null && current.id !== this.turn.goalId) {
429
+ // Focus changed mid-turn: persist the previous buffer first.
430
+ this.flushTurn(ctx);
431
+ if (this.turn.active) return {ok: false, message: this.flushError ?? "Previous goal changes are still awaiting persistence."};
432
+ this.turn.active = true;
433
+ this.turn.goalId = current.id;
434
+ this.turn.goal = current;
435
+ this.turnBase = current;
436
+ this.turn.archive = false;
437
+ this.turn.ledger = [];
438
+ }
439
+ if (!this.turn.goal) {
440
+ this.turn.goal = current;
441
+ this.turn.goalId = current.id;
442
+ if (this.turnBase?.id !== current.id) this.turnBase = {...current, usage: {...current.usage}};
443
+ }
444
+ const base = current;
445
+ const mutated = sanitizeGoalPaths(ctx, {
446
+ ...spec.mutate(cloneGoal(base)),
447
+ revision: (current.revision ?? 0) + 1,
448
+ });
449
+ if (spec.ledger) {
450
+ try {
451
+ const events = typeof spec.ledger === "function" ? spec.ledger(mutated) : spec.ledger;
452
+ this.turn.ledger.push(...events);
453
+ } catch {
454
+ // Ledger spec error: nothing appended (same swallow as the write path).
455
+ }
456
+ }
457
+ this.turn.goal = mutated;
458
+ this.turn.archive = spec.archive === true;
459
+ const previousGoalId = current.id;
460
+ const commitFocused = spec.commitFocused !== false;
461
+ if (commitFocused) {
462
+ this.ref.setFocused(mutated);
463
+ this.trackBaseline(mutated.id, mutated.usage);
464
+ }
465
+ const goalId = commitFocused ? mutated.id : this.ref.getFocusedGoalId();
466
+ const focusChanged = commitFocused && previousGoalId !== mutated.id;
467
+ if (focusChanged) this.ref.onFocusChanged(previousGoalId, mutated.id);
468
+ return { ok: true, goal: mutated, previousGoalId, goalId, focusChanged };
469
+ }
470
+
471
+ // 2b. exclusive per-goal lock + optimistic revision check (follow-up Stage 4).
472
+ const capturedRevision = current.revision ?? 0;
473
+ const lock = acquireGoalLock(ctx, current.id);
474
+ try {
475
+ const freshDisk = this.readFreshDiskGoal(ctx, current);
476
+ if (!freshDisk) {
477
+ return { ok: false, message: `Goal ${current.id} was deleted or archived by another process while this mutation was in progress; the mutation was not applied.` };
478
+ }
479
+ const diskRevision = freshDisk.revision ?? 0;
480
+ if (diskRevision !== capturedRevision) {
481
+ return { ok: false, message: `Goal ${current.id} was modified by another process (revision ${capturedRevision} -> ${diskRevision}); current revision is ${diskRevision}. Refresh and retry; the mutation was not applied.` };
482
+ }
483
+
484
+ // 3. mutation on a clone (after an optional authoritative objective merge).
485
+ const base = spec.refreshFromDisk ? mergeGoalPromptFromDisk(ctx, current) : current;
486
+ const mutated = {
487
+ ...spec.mutate(cloneGoal(base)),
488
+ revision: capturedRevision + 1,
489
+ };
490
+
491
+ // 4. authoritative file write (active or archive). A failure here throws
492
+ // and prevents any memory/ledger/focus/archive commit.
493
+ const written = spec.archive ? archiveGoalFile(ctx, mutated) : writeActiveGoalFile(ctx, mutated);
494
+
495
+ // 5. ledger append best effort.
496
+ if (spec.ledger) {
497
+ let events: GoalLedgerEvent[];
498
+ try {
499
+ events = typeof spec.ledger === "function" ? spec.ledger(written) : spec.ledger;
500
+ } catch {
501
+ events = [];
502
+ }
503
+ this.appendLedgerEventsBestEffort(ctx, events);
504
+ }
505
+
506
+ // 6. in-memory pool/focus commit.
507
+ const previousGoalId = current.id;
508
+ const commitFocused = spec.commitFocused !== false;
509
+ if (commitFocused) {
510
+ this.ref.setFocused(written);
511
+ this.trackBaseline(written.id, written.usage);
512
+ }
513
+ const goalId = commitFocused ? written.id : this.ref.getFocusedGoalId();
514
+ const focusChanged = commitFocused && previousGoalId !== written.id;
515
+
516
+ // 7. runtime/UI effects.
517
+ if (focusChanged) this.ref.onFocusChanged(previousGoalId, written.id);
518
+
519
+ return { ok: true, goal: written, previousGoalId, goalId, focusChanged };
520
+ } finally {
521
+ lock.release();
522
+ }
523
+ }
524
+
525
+
526
+
527
+ /**
528
+ * Disk-fresh single-task transaction. Pipeline:
529
+ * 1. reconcile the focused record;
530
+ * 2. validate focus token/id;
531
+ * 3. load the fresh task from the cloned disk record;
532
+ * 4. validate the requested transition against that fresh task;
533
+ * 5. update only that task's path;
534
+ * 6. write the active file, append the ledger, and commit.
535
+ * Expected races (removed task, removed task list) return typed failures
536
+ * instead of throwing.
537
+ */
538
+ updateTask(ctx: GoalServiceContext, spec: GoalTaskUpdateSpec): GoalTaskUpdateOutcome {
539
+ // P1-3: during a turn, apply to the buffered goal in memory (validation
540
+ // still runs against the current in-turn state); the flush writes once.
541
+ if (this.turn.active) {
542
+ const current = this.turn.goal ?? this.ref.getFocused();
543
+ if (!current) {
544
+ return { ok: false, message: "No focused goal to mutate." };
545
+ }
546
+ if (spec.focusToken && !this.ref.isTokenCurrent(spec.focusToken)) {
547
+ return { ok: false, message: `Mutation cancelled because goal ${spec.focusToken.goalId} is no longer focused in this session. The shared goal was not modified.` };
548
+ }
549
+ if (!current.taskList) {
550
+ return { ok: false, message: "The goal has no task list." };
551
+ }
552
+ const task = findTaskInTree(current.taskList.tasks, spec.taskId);
553
+ if (!task) {
554
+ return { ok: false, message: `Task "${spec.taskId}" not found.` };
555
+ }
556
+ if (spec.validate) {
557
+ const gate = spec.validate(task);
558
+ if (!gate.ok) return gate;
559
+ }
560
+ const updated = spec.update(task);
561
+ if (typeof updated === "object" && "ok" in updated && !updated.ok) return updated;
562
+ const updatedTask = updated as GoalTask;
563
+ const updatedTasks = updateTaskInTree(current.taskList.tasks, spec.taskId, () => updatedTask);
564
+ const mutated = sanitizeGoalPaths(ctx, {
565
+ ...current,
566
+ taskList: { ...current.taskList, tasks: updatedTasks },
567
+ // Execution focus (§8): start sets it explicitly; completing or
568
+ // skipping the current task clears it.
569
+ currentTaskId: resolveUpdatedCurrentTaskId(spec, current.currentTaskId, updatedTask),
570
+ updatedAt: nowIso(),
571
+ revision: (current.revision ?? 0) + 1,
572
+ });
573
+ if (spec.ledger) {
574
+ try {
575
+ this.turn.ledger.push(...spec.ledger(mutated, updatedTask));
576
+ } catch (err) {
577
+ this.ref.onDiagnostic({
578
+ severity: "warning",
579
+ source: "ledger",
580
+ goalId: spec.taskId,
581
+ message: `Ledger spec error during task update: ${String(err)}`,
582
+ });
583
+ }
584
+ }
585
+ this.turn.goal = mutated;
586
+ this.ref.setFocused(mutated);
587
+ return { ok: true, goal: mutated, task: updatedTask };
588
+ }
589
+ return this.updateTaskAttempt(ctx, spec, 1);
590
+ }
591
+
592
+ /** Ordered all-or-nothing batch. Each validator sees earlier changes in the clone. */
593
+ updateTasks(ctx: GoalServiceContext, specs: GoalTaskUpdateSpec[]): GoalMutationOutcome {
594
+ if (!this.reconcileFocused(ctx)) return {ok: false, message: "No focused goal to mutate."};
595
+ const current = this.ref.getFocused();
596
+ if (!current?.taskList) return {ok: false, message: "The goal has no task list."};
597
+ let lock: GoalLock | undefined;
598
+ try {
599
+ let base = current;
600
+ if (!this.turn.active) {
601
+ lock = acquireGoalLock(ctx, current.id);
602
+ const fresh = this.readFreshDiskGoal(ctx, current);
603
+ if (!fresh || (fresh.revision ?? 0) !== (current.revision ?? 0)) return {ok: false, message: "The goal changed in another process; no updates applied. Refresh and retry."};
604
+ if (fresh.status !== "active" || !fresh.taskList) return {ok: false, message: "The persisted goal is no longer active with tasks; no updates applied."};
605
+ base = {...fresh, usage: current.usage};
606
+ }
607
+ const next = cloneGoal(base);
608
+ const locations = new Map<string, {tasks: GoalTask[]; index: number}>();
609
+ const indexTasks = (tasks: GoalTask[]): void => {
610
+ for (let i = 0; i < tasks.length; i++) { const task = tasks[i]!; locations.set(task.id, {tasks, index: i}); if (task.subtasks) indexTasks(task.subtasks); }
611
+ };
612
+ const removeTasks = (tasks: GoalTask[]): void => {
613
+ for (const task of tasks) { locations.delete(task.id); if (task.subtasks) removeTasks(task.subtasks); }
614
+ };
615
+ indexTasks(next.taskList!.tasks);
616
+ const events: GoalLedgerEvent[] = [];
617
+ for (const spec of specs) {
618
+ if (spec.focusToken && !this.ref.isTokenCurrent(spec.focusToken)) return {ok: false, message: "The focused goal changed; no updates applied."};
619
+ const location = locations.get(spec.taskId);
620
+ const task = location?.tasks[location.index];
621
+ if (!task) return {ok: false, message: `Task "${spec.taskId}" not found.`};
622
+ const valid = spec.validate?.(task);
623
+ if (valid && !valid.ok) return valid;
624
+ const updated = spec.update(task);
625
+ if ("ok" in updated && !updated.ok) return updated;
626
+ const updatedTask = updated as GoalTask;
627
+ location!.tasks[location!.index] = updatedTask;
628
+ if (updatedTask.id !== task.id) { locations.delete(task.id); locations.set(updatedTask.id, location!); }
629
+ if (updatedTask.subtasks !== task.subtasks) {
630
+ if (task.subtasks) removeTasks(task.subtasks);
631
+ if (updatedTask.subtasks) indexTasks(updatedTask.subtasks);
632
+ }
633
+ next.currentTaskId = resolveUpdatedCurrentTaskId(spec, next.currentTaskId, updatedTask);
634
+ next.updatedAt = nowIso();
635
+ if (spec.ledger) events.push(...spec.ledger(next, updatedTask));
636
+ }
637
+ if (this.turn.active) return this.apply(ctx, {reconcile: false, expectedGoalId: current.id, focusToken: specs[0]?.focusToken, mutate: () => next, ledger: events});
638
+ const written = writeActiveGoalFile(ctx, {...next, revision: (base.revision ?? 0) + 1});
639
+ this.appendLedgerEventsBestEffort(ctx, events);
640
+ this.trackBaseline(written.id, written.usage);
641
+ this.ref.setFocused(written);
642
+ return {ok: true, goal: written, previousGoalId: current.id, goalId: written.id, focusChanged: false};
643
+ } finally { lock?.release(); }
644
+ }
645
+
646
+ /**
647
+ * Disk-fresh single-task transaction (follow-up Stage 4 adds the per-goal
648
+ * lock + optimistic revision check). Pipeline:
649
+ * 1. reconcile the focused record;
650
+ * 2. validate focus token/id;
651
+ * 3. acquire the per-goal lock and re-read the authoritative file;
652
+ * 4. a stale writer gets a typed conflict; it retries ONCE with the fresh
653
+ * state — the transition validation re-checks that the same task and
654
+ * relevant status/structure are unchanged, so a genuinely concurrent
655
+ * edit is rejected rather than silently merged;
656
+ * 5. load the fresh task, validate the transition, update only that task;
657
+ * 6. write with an incremented revision, append the ledger, and commit.
658
+ */
659
+ private updateTaskAttempt(ctx: GoalServiceContext, spec: GoalTaskUpdateSpec, retriesLeft: number): GoalTaskUpdateOutcome {
660
+ if (!this.reconcileFocused(ctx)) {
661
+ return { ok: false, message: "The focused goal was lost during reconciliation; the task was not updated." };
662
+ }
663
+ const current = this.ref.getFocused();
664
+ if (!current) {
665
+ return { ok: false, message: "No focused goal to mutate." };
666
+ }
667
+ if (spec.focusToken && !this.ref.isTokenCurrent(spec.focusToken)) {
668
+ return { ok: false, message: `Mutation cancelled because goal ${spec.focusToken.goalId} is no longer focused in this session. The shared goal was not modified.` };
669
+ }
670
+ const capturedRevision = current.revision ?? 0;
671
+ const lock = acquireGoalLock(ctx, current.id);
672
+ try {
673
+ const freshDisk = this.readFreshDiskGoal(ctx, current);
674
+ if (!freshDisk) {
675
+ return { ok: false, message: `Goal ${current.id} was deleted or archived by another process while the task update was in progress; the task was not updated.` };
676
+ }
677
+ const diskRevision = freshDisk.revision ?? 0;
678
+ if (diskRevision !== capturedRevision) {
679
+ if (retriesLeft > 0) {
680
+ // Retry once against the fresh state; the transition validation
681
+ // below is the guard for task status/structure changes.
682
+ return this.updateTaskAttempt(ctx, spec, retriesLeft - 1);
683
+ }
684
+ return { ok: false, message: `Goal ${current.id} was modified by another process (revision ${capturedRevision} -> ${diskRevision}); current revision is ${diskRevision}. The task was not updated.` };
685
+ }
686
+ const base = mergeGoalPromptFromDisk(ctx, current);
687
+ if (!base.taskList) {
688
+ return { ok: false, message: "The goal has no task list." };
689
+ }
690
+ const task = findTaskInTree(base.taskList.tasks, spec.taskId);
691
+ if (!task) {
692
+ return { ok: false, message: `Task "${spec.taskId}" not found.` };
693
+ }
694
+ if (spec.validate) {
695
+ const gate = spec.validate(task);
696
+ if (!gate.ok) return gate;
697
+ }
698
+ const updated = spec.update(task);
699
+ if (typeof updated === "object" && "ok" in updated && !updated.ok) return updated;
700
+ const updatedTask = updated as GoalTask;
701
+ const updatedTasks = updateTaskInTree(base.taskList.tasks, spec.taskId, () => updatedTask);
702
+ const mutated = {
703
+ ...base,
704
+ taskList: { ...base.taskList, tasks: updatedTasks },
705
+ currentTaskId: resolveUpdatedCurrentTaskId(spec, base.currentTaskId, updatedTask),
706
+ updatedAt: nowIso(),
707
+ revision: capturedRevision + 1,
708
+ };
709
+ const written = writeActiveGoalFile(ctx, mutated);
710
+ if (spec.ledger) {
711
+ try {
712
+ this.appendLedgerEventsBestEffort(ctx, spec.ledger(written, updatedTask));
713
+ } catch (err) {
714
+ // Unexpected ledger-spec error after the authoritative write keeps
715
+ // the successful state transition.
716
+ this.ref.onDiagnostic({
717
+ severity: "warning",
718
+ source: "ledger",
719
+ goalId: spec.taskId,
720
+ message: `Ledger spec error during task update: ${String(err)}`,
721
+ });
722
+ }
723
+ }
724
+ this.ref.setFocused(written);
725
+ return { ok: true, goal: written, task: updatedTask };
726
+ } finally {
727
+ lock.release();
728
+ }
729
+ }
730
+
731
+ /**
732
+ * Persist the focused goal: bump updatedAt, merge objective from disk, write
733
+ * active or archive. Serialized by the per-goal lock with a short bounded
734
+ * budget. If another process bumped the revision meanwhile, the persist
735
+ * does not clobber the disk's authoritative fields — instead the session's
736
+ * additive usage/accounting delta since the last baseline is merged onto
737
+ * the disk record (concurrent work is never silently dropped).
738
+ */
739
+ persist(ctx: GoalServiceContext): GoalRecord | null {
740
+ const current = this.ref.getFocused();
741
+ if (!current) return null;
742
+ // P1-3: within a turn the persist is buffered; the flush at turn end
743
+ // performs the single write + ledger batch.
744
+ if (this.turn.active) {
745
+ const next = { ...current, updatedAt: nowIso() };
746
+ this.turn.goal = next;
747
+ this.ref.setFocused(next);
748
+ return next;
749
+ }
750
+ const capturedRevision = current.revision ?? 0;
751
+ let lock: GoalLock;
752
+ try {
753
+ lock = acquireGoalLock(ctx, current.id, { attempts: 4, retryMs: 25 }); // P1-5: 100ms bound
754
+ } catch {
755
+ // Another writer holds the goal lock; skip this persist tick.
756
+ return null;
757
+ }
758
+ try {
759
+ const freshDisk = this.readFreshDiskGoal(ctx, current);
760
+ if (!freshDisk) return null;
761
+ if ((freshDisk.revision ?? 0) !== capturedRevision) {
762
+ // Revision moved concurrently: merge only the additive usage
763
+ // delta from this session onto the disk record and advance its
764
+ // revision. All other fields stay authoritative from disk.
765
+ const { tokens, seconds } = this.usageDelta(current, freshDisk);
766
+ if (tokens === 0 && seconds === 0) return null;
767
+ const merged = mergeGoalPromptFromDisk(ctx, {
768
+ ...freshDisk,
769
+ usage: {
770
+ tokensUsed: freshDisk.usage.tokensUsed + tokens,
771
+ activeSeconds: freshDisk.usage.activeSeconds + seconds,
772
+ },
773
+ updatedAt: nowIso(),
774
+ revision: (freshDisk.revision ?? 0) + 1,
775
+ });
776
+ const written = merged.status === "complete" ? archiveGoalFile(ctx, merged) : writeActiveGoalFile(ctx, merged);
777
+ this.trackBaseline(written.id, written.usage);
778
+ this.ref.setFocused(written);
779
+ return written;
780
+ }
781
+ const merged = mergeGoalPromptFromDisk(ctx, { ...current, updatedAt: nowIso(), revision: capturedRevision + 1 });
782
+ const written = merged.status === "complete" ? archiveGoalFile(ctx, merged) : writeActiveGoalFile(ctx, merged);
783
+ this.trackBaseline(written.id, written.usage);
784
+ this.ref.setFocused(written);
785
+ return written;
786
+ } finally {
787
+ lock.release();
788
+ }
789
+ }
790
+
791
+ /** Create a goal: write active file → ledger → memory/focus commit. */
792
+ create(ctx: GoalServiceContext, spec: { goal: GoalRecord; ledger?: GoalLedgerEvent[] }): GoalMutationResult {
793
+ const previousGoalId = this.ref.getFocused()?.id ?? null;
794
+ const written = writeActiveGoalFile(ctx, spec.goal);
795
+ if (spec.ledger && spec.ledger.length > 0) {
796
+ this.appendLedgerEventsBestEffort(ctx, spec.ledger);
797
+ }
798
+ this.ref.setFocused(written);
799
+ this.trackBaseline(written.id, written.usage);
800
+ const focusChanged = previousGoalId !== written.id;
801
+ if (focusChanged) this.ref.onFocusChanged(previousGoalId, written.id);
802
+ return { ok: true, goal: written, previousGoalId, goalId: written.id, focusChanged };
803
+ }
804
+
805
+ /** Audit outcomes can bypass the current turn without flushing unrelated work. */
806
+ appendEvents(ctx: GoalServiceContext, events: GoalLedgerEvent[], opts: {immediate?: boolean} = {}): boolean {
807
+ if (this.turn.active && !opts.immediate && this.turnContext && goalStorageRoot(this.turnContext) === goalStorageRoot(ctx)) {
808
+ this.turn.ledger.push(...events);
809
+ return true;
810
+ }
811
+ return this.appendLedgerEventsBestEffort(ctx, events);
812
+ }
813
+
814
+ /** Detach an outgoing session's buffer after its final persistence attempt. */
815
+ endSession(ctx?: GoalServiceContext): void {
816
+ try {
817
+ const storage = this.turnContext ?? ctx;
818
+ if (storage) this.flushTurn(storage);
819
+ } finally {
820
+ if (this.turn.active) this.ref.onDiagnostic({severity: "warning", source: "storage", message: this.flushError ?? "Outgoing goal changes could not be persisted before session replacement."});
821
+ this.turn = {active: false, goalId: null, goal: null, archive: false, ledger: []};
822
+ this.turnContext = null;
823
+ this.turnBase = null;
824
+ this.lastPersistedUsage = null;
825
+ }
826
+ }
827
+
828
+ /** Diagnostic write for the debug widget toggle (separate debug dir; not a goal mutation). */
829
+ writeDebugFile(ctx: GoalServiceContext, relPath: string, content: string): void {
830
+ const gfc: GoalFileContext = ctx;
831
+ ensureDirectory(gfc, ".pi/goals/debug");
832
+ atomicWriteGoalFile(gfc, ".pi/goals/debug", relPath, content);
833
+ }
834
+
835
+ /** Diagnostic removal for the debug widget toggle. */
836
+ removeDebugFile(ctx: GoalServiceContext, relPath: string): void {
837
+ try {
838
+ safeUnlinkGoalFile(ctx, ".pi/goals/debug", relPath);
839
+ } catch {
840
+ // Debug file removal is best-effort.
841
+ }
842
+ }
843
+ }