@tt-a1i/openpi 0.1.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 (114) hide show
  1. package/README.md +643 -0
  2. package/SETUP.md +74 -0
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/openpi-package.png +0 -0
  5. package/assets/readme-hero-mobile.svg +72 -0
  6. package/assets/readme-hero.svg +118 -0
  7. package/assets/readme-runtime-mobile.svg +91 -0
  8. package/assets/readme-runtime.svg +111 -0
  9. package/extensions/ask-user/handoff.ts +205 -0
  10. package/extensions/ask-user/index.ts +1110 -0
  11. package/extensions/ask-user/limits.ts +89 -0
  12. package/extensions/ask-user/prompt.ts +76 -0
  13. package/extensions/background-terminals/index.ts +653 -0
  14. package/extensions/background-terminals/src/domain.ts +99 -0
  15. package/extensions/background-terminals/src/manager.ts +989 -0
  16. package/extensions/background-terminals/src/output.ts +84 -0
  17. package/extensions/background-terminals/src/prompt.ts +195 -0
  18. package/extensions/background-terminals/src/result-delivery.ts +43 -0
  19. package/extensions/background-terminals/src/runtime.ts +36 -0
  20. package/extensions/background-terminals/src/ui/output-view.ts +55 -0
  21. package/extensions/background-terminals/src/ui/ps.ts +642 -0
  22. package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
  23. package/extensions/background-terminals/src/watch.ts +192 -0
  24. package/extensions/context-pivot/index.ts +222 -0
  25. package/extensions/copy-all/index.ts +65 -0
  26. package/extensions/cron/index.ts +173 -0
  27. package/extensions/cron/schedule.ts +127 -0
  28. package/extensions/file-mutation-display/index.ts +105 -0
  29. package/extensions/file-mutation-display/render.ts +107 -0
  30. package/extensions/file-search/index.ts +515 -0
  31. package/extensions/file-search/src/args.ts +129 -0
  32. package/extensions/file-search/src/binaries.ts +419 -0
  33. package/extensions/file-search/src/output.ts +142 -0
  34. package/extensions/file-search/src/process.ts +309 -0
  35. package/extensions/file-search/src/prompt.ts +53 -0
  36. package/extensions/git-info/index.ts +272 -0
  37. package/extensions/git-info/src/changed-files-view.ts +414 -0
  38. package/extensions/git-info/src/process.ts +107 -0
  39. package/extensions/git-info/src/refresh-coordinator.ts +13 -0
  40. package/extensions/git-info/src/runtime.ts +28 -0
  41. package/extensions/goal/controller.ts +794 -0
  42. package/extensions/goal/index.ts +521 -0
  43. package/extensions/goal/prompts.ts +122 -0
  44. package/extensions/goal/state.ts +763 -0
  45. package/extensions/goal/ui.ts +158 -0
  46. package/extensions/model-info/index.ts +234 -0
  47. package/extensions/plan-mode/bash-policy.ts +313 -0
  48. package/extensions/plan-mode/index.ts +539 -0
  49. package/extensions/post-edit/index.ts +129 -0
  50. package/extensions/sessions/LICENSE.upstream +21 -0
  51. package/extensions/sessions/git-stats.ts +226 -0
  52. package/extensions/sessions/index.ts +1092 -0
  53. package/extensions/sessions/sessions.ts +385 -0
  54. package/extensions/setup/index.ts +408 -0
  55. package/extensions/shared/activity-status.ts +65 -0
  56. package/extensions/shared/below-editor-navigation.ts +343 -0
  57. package/extensions/shared/child-session.ts +352 -0
  58. package/extensions/shared/context-utilization.ts +47 -0
  59. package/extensions/shared/dashboard-state.ts +102 -0
  60. package/extensions/shared/plan-mode-state.ts +65 -0
  61. package/extensions/shared/setup-config.ts +971 -0
  62. package/extensions/shared/subagent-roles.ts +22 -0
  63. package/extensions/shared/terminal-text.ts +38 -0
  64. package/extensions/shared/tool-call-timeout.ts +104 -0
  65. package/extensions/shared/worktree.ts +526 -0
  66. package/extensions/subagents/index.ts +1225 -0
  67. package/extensions/subagents/navigation.ts +121 -0
  68. package/extensions/subagents/src/agent-types.ts +543 -0
  69. package/extensions/subagents/src/backend.ts +63 -0
  70. package/extensions/subagents/src/backends/pi.ts +493 -0
  71. package/extensions/subagents/src/backends/stub.ts +296 -0
  72. package/extensions/subagents/src/by-the-way.ts +21 -0
  73. package/extensions/subagents/src/domain.ts +271 -0
  74. package/extensions/subagents/src/format.ts +48 -0
  75. package/extensions/subagents/src/manager.ts +769 -0
  76. package/extensions/subagents/src/prompt.ts +190 -0
  77. package/extensions/subagents/src/result-delivery.ts +20 -0
  78. package/extensions/subagents/src/runtime.ts +51 -0
  79. package/extensions/subagents/src/ui/takeover.ts +615 -0
  80. package/extensions/subagents/src/ui/transcript.ts +293 -0
  81. package/extensions/subagents/src/ui/wait-result.ts +89 -0
  82. package/extensions/suggestions/index.ts +172 -0
  83. package/extensions/suggestions/src/config.ts +12 -0
  84. package/extensions/suggestions/src/predictor.ts +147 -0
  85. package/extensions/suggestions/src/prompt.ts +20 -0
  86. package/extensions/suggestions/src/transcript.ts +233 -0
  87. package/extensions/suggestions/src/ui.ts +224 -0
  88. package/extensions/tasks/index.ts +512 -0
  89. package/extensions/tasks/tasks.ts +649 -0
  90. package/extensions/tasks/ui.ts +421 -0
  91. package/extensions/turn-time/index.ts +61 -0
  92. package/extensions/ui-customization/footer.ts +512 -0
  93. package/extensions/ui-customization/index.ts +217 -0
  94. package/extensions/workflows/acceptance.ts +298 -0
  95. package/extensions/workflows/artifacts.ts +225 -0
  96. package/extensions/workflows/controller.ts +210 -0
  97. package/extensions/workflows/dashboard.ts +1226 -0
  98. package/extensions/workflows/index.ts +1884 -0
  99. package/extensions/workflows/journal.ts +188 -0
  100. package/extensions/workflows/meta.ts +250 -0
  101. package/extensions/workflows/model.ts +423 -0
  102. package/extensions/workflows/navigation.ts +93 -0
  103. package/extensions/workflows/prompt.ts +212 -0
  104. package/extensions/workflows/replay-safety.ts +577 -0
  105. package/extensions/workflows/runner.ts +786 -0
  106. package/extensions/workflows/sandbox-child.cjs +402 -0
  107. package/extensions/workflows/sandbox.ts +397 -0
  108. package/extensions/workflows/serialization.ts +162 -0
  109. package/extensions/workflows/worktree-handoff.ts +216 -0
  110. package/package.json +87 -0
  111. package/scripts/prepare-effect-tsgo.mjs +16 -0
  112. package/skills/background-terminals/SKILL.md +30 -0
  113. package/skills/subagents/SKILL.md +15 -0
  114. package/themes/github-dark-default.json +89 -0
@@ -0,0 +1,763 @@
1
+ import { sanitizeTerminalText } from "../shared/terminal-text.ts";
2
+
3
+ export const GOAL_ENTRY_TYPE = "session-goal";
4
+ export const GOAL_STATUSES = [
5
+ "active",
6
+ "paused",
7
+ "blocked",
8
+ "usage_limited",
9
+ "budget_limited",
10
+ "complete",
11
+ "cleared",
12
+ ] as const;
13
+
14
+ export type GoalStatus = (typeof GOAL_STATUSES)[number];
15
+
16
+ export interface GoalBlockedAudit {
17
+ blocker: string;
18
+ consecutiveTurns: number;
19
+ lastTurn: number;
20
+ }
21
+
22
+ export interface GoalSnapshot {
23
+ version: 2;
24
+ revision: number;
25
+ id: string;
26
+ objective: string;
27
+ status: GoalStatus;
28
+ tokenBudget?: number;
29
+ tokensUsed: number;
30
+ timeUsedSeconds: number;
31
+ createdAt: number;
32
+ updatedAt: number;
33
+ reason?: string;
34
+ deferContinuation?: boolean;
35
+ continuationCount?: number;
36
+ blockedAudit?: GoalBlockedAudit;
37
+ completionAcknowledged?: boolean;
38
+ }
39
+
40
+ export interface GoalInput {
41
+ objective: string;
42
+ tokenBudget?: number;
43
+ }
44
+
45
+ export const GOAL_LIMITS = Object.freeze({
46
+ objectiveChars: 4_000,
47
+ reasonChars: 500,
48
+ snapshotBytes: 32_768,
49
+ emergencyContinuations: 1_000,
50
+ });
51
+
52
+ const SNAPSHOT_KEYS = new Set([
53
+ "version",
54
+ "revision",
55
+ "id",
56
+ "objective",
57
+ "status",
58
+ "tokenBudget",
59
+ "tokensUsed",
60
+ "timeUsedSeconds",
61
+ "createdAt",
62
+ "updatedAt",
63
+ "reason",
64
+ "deferContinuation",
65
+ "continuationCount",
66
+ "blockedAudit",
67
+ "completionAcknowledged",
68
+ ]);
69
+ const RESUMABLE_STATUSES = new Set<GoalStatus>([
70
+ "paused",
71
+ "blocked",
72
+ "usage_limited",
73
+ ]);
74
+ const TERMINAL_STATUSES = new Set<GoalStatus>(["complete", "cleared"]);
75
+
76
+ export class GoalValidationError extends Error {
77
+ constructor(message: string) {
78
+ super(message);
79
+ this.name = "GoalValidationError";
80
+ }
81
+ }
82
+
83
+ export class GoalRestoreError extends Error {
84
+ constructor(message: string) {
85
+ super(message);
86
+ this.name = "GoalRestoreError";
87
+ }
88
+ }
89
+
90
+ export function isGoalVisible(
91
+ goal: GoalSnapshot | undefined,
92
+ ): goal is GoalSnapshot {
93
+ return goal !== undefined && goal.status !== "cleared";
94
+ }
95
+
96
+ export function isGoalUnfinished(goal: GoalSnapshot) {
97
+ return !TERMINAL_STATUSES.has(goal.status);
98
+ }
99
+
100
+ export function isGoalActive(goal: GoalSnapshot) {
101
+ return goal.status === "active";
102
+ }
103
+
104
+ export function canResumeGoal(goal: GoalSnapshot) {
105
+ return RESUMABLE_STATUSES.has(goal.status);
106
+ }
107
+
108
+ export function normalizeGoalObjective(value: unknown) {
109
+ if (typeof value !== "string") fail("goal objective must be a string");
110
+ const objective = sanitizeTerminalText(value).trim();
111
+ if (!objective) fail("goal objective must not be empty");
112
+ if (Array.from(objective).length > GOAL_LIMITS.objectiveChars) {
113
+ fail(
114
+ `goal objective must be at most ${GOAL_LIMITS.objectiveChars} characters`,
115
+ );
116
+ }
117
+ return objective;
118
+ }
119
+
120
+ export function createGoalSnapshot(
121
+ input: GoalInput,
122
+ revision: number,
123
+ now: number,
124
+ id: string,
125
+ ) {
126
+ if (!isRecord(input)) fail("goal input must be an object");
127
+ assertExactKeys(input, new Set(["objective", "tokenBudget"]), "goal input");
128
+ assertNonNegativeInteger(revision, "revision");
129
+ assertTimestamp(now, "now");
130
+ assertId(id);
131
+ if (input.tokenBudget !== undefined) {
132
+ assertPositiveInteger(input.tokenBudget, "tokenBudget");
133
+ }
134
+
135
+ return validateGoalSnapshot({
136
+ version: 2,
137
+ revision: increment(revision, "revision"),
138
+ id,
139
+ objective: normalizeGoalObjective(input.objective),
140
+ status: "active",
141
+ ...(input.tokenBudget === undefined
142
+ ? {}
143
+ : { tokenBudget: input.tokenBudget }),
144
+ tokensUsed: 0,
145
+ timeUsedSeconds: 0,
146
+ createdAt: now,
147
+ updatedAt: now,
148
+ });
149
+ }
150
+
151
+ export function validateGoalSnapshot(value: unknown): GoalSnapshot {
152
+ if (!isRecord(value)) fail("snapshot must be an object");
153
+ assertExactKeys(value, SNAPSHOT_KEYS, "snapshot");
154
+ if (value.version !== 2) {
155
+ fail(`unsupported snapshot version: ${String(value.version)}`);
156
+ }
157
+ assertNonNegativeInteger(value.revision, "snapshot.revision");
158
+ assertId(value.id);
159
+ const objective = normalizeGoalObjective(value.objective);
160
+ if (!GOAL_STATUSES.includes(value.status as GoalStatus)) {
161
+ fail("snapshot.status is invalid");
162
+ }
163
+ const status = value.status as GoalStatus;
164
+ if (hasOwn(value, "tokenBudget")) {
165
+ assertPositiveInteger(value.tokenBudget, "snapshot.tokenBudget");
166
+ }
167
+ assertNonNegativeInteger(value.tokensUsed, "snapshot.tokensUsed");
168
+ assertNonNegativeInteger(value.timeUsedSeconds, "snapshot.timeUsedSeconds");
169
+ assertTimestamp(value.createdAt, "snapshot.createdAt");
170
+ assertTimestamp(value.updatedAt, "snapshot.updatedAt");
171
+ if ((value.updatedAt as number) < (value.createdAt as number)) {
172
+ fail("snapshot.updatedAt precedes createdAt");
173
+ }
174
+ const reason = hasOwn(value, "reason")
175
+ ? normalizeGoalReason(value.reason)
176
+ : undefined;
177
+ if (
178
+ hasOwn(value, "deferContinuation") &&
179
+ typeof value.deferContinuation !== "boolean"
180
+ ) {
181
+ fail("snapshot.deferContinuation must be boolean");
182
+ }
183
+ if (hasOwn(value, "continuationCount")) {
184
+ assertNonNegativeInteger(
185
+ value.continuationCount,
186
+ "snapshot.continuationCount",
187
+ );
188
+ }
189
+ const blockedAudit = hasOwn(value, "blockedAudit")
190
+ ? validateBlockedAudit(value.blockedAudit, value.continuationCount)
191
+ : undefined;
192
+ if (blockedAudit !== undefined && status !== "active") {
193
+ fail("only an active goal can retain a blocked audit");
194
+ }
195
+ if (
196
+ hasOwn(value, "completionAcknowledged") &&
197
+ value.completionAcknowledged !== true
198
+ ) {
199
+ fail("snapshot.completionAcknowledged must be true when present");
200
+ }
201
+ if (value.completionAcknowledged === true && status !== "complete") {
202
+ fail("only a complete goal can acknowledge completion");
203
+ }
204
+
205
+ const snapshot: GoalSnapshot = {
206
+ version: 2,
207
+ revision: value.revision as number,
208
+ id: value.id,
209
+ objective,
210
+ status,
211
+ ...(hasOwn(value, "tokenBudget")
212
+ ? { tokenBudget: value.tokenBudget as number }
213
+ : {}),
214
+ tokensUsed: value.tokensUsed as number,
215
+ timeUsedSeconds: value.timeUsedSeconds as number,
216
+ createdAt: value.createdAt as number,
217
+ updatedAt: value.updatedAt as number,
218
+ ...(reason === undefined ? {} : { reason }),
219
+ ...(value.deferContinuation === true ? { deferContinuation: true } : {}),
220
+ ...(hasOwn(value, "continuationCount")
221
+ ? { continuationCount: value.continuationCount as number }
222
+ : {}),
223
+ ...(blockedAudit === undefined ? {} : { blockedAudit }),
224
+ ...(value.completionAcknowledged === true
225
+ ? { completionAcknowledged: true }
226
+ : {}),
227
+ };
228
+ assertSnapshotBytes(snapshot);
229
+ return snapshot;
230
+ }
231
+
232
+ export interface RestoredGoalState {
233
+ snapshot?: GoalSnapshot;
234
+ migrated: boolean;
235
+ }
236
+
237
+ export function restoreGoalState(
238
+ entries: readonly unknown[],
239
+ ): RestoredGoalState {
240
+ const candidates = entries.flatMap((entry, position) => {
241
+ const payload = extractGoalPayload(entry);
242
+ return payload.found ? [{ payload: payload.value, position }] : [];
243
+ });
244
+ if (candidates.length === 0) return { migrated: false };
245
+
246
+ let winner:
247
+ | {
248
+ position: number;
249
+ revision: number;
250
+ snapshot?: GoalSnapshot;
251
+ migrated: boolean;
252
+ error?: Error;
253
+ }
254
+ | undefined;
255
+ const malformedPositions: number[] = [];
256
+
257
+ for (const candidate of candidates) {
258
+ const revision = declaredRevision(candidate.payload);
259
+ let snapshot: GoalSnapshot | undefined;
260
+ let migrated = false;
261
+ let error: Error | undefined;
262
+ try {
263
+ if (isRecord(candidate.payload) && candidate.payload.version === 1) {
264
+ snapshot = migrateV1Snapshot(candidate.payload);
265
+ migrated = true;
266
+ } else {
267
+ snapshot = validateGoalSnapshot(candidate.payload);
268
+ }
269
+ } catch (caught) {
270
+ error = caught instanceof Error ? caught : new Error(String(caught));
271
+ malformedPositions.push(candidate.position);
272
+ }
273
+ if (revision === undefined) continue;
274
+ if (
275
+ !winner ||
276
+ revision > winner.revision ||
277
+ (revision === winner.revision && candidate.position > winner.position)
278
+ ) {
279
+ winner = {
280
+ position: candidate.position,
281
+ revision,
282
+ snapshot,
283
+ migrated,
284
+ error,
285
+ };
286
+ }
287
+ }
288
+
289
+ if (!winner) {
290
+ throw new GoalRestoreError("goal history contains no ranked snapshot");
291
+ }
292
+ if (malformedPositions.some((position) => position > winner.position)) {
293
+ throw new GoalRestoreError(
294
+ "a later malformed goal entry locks restoration",
295
+ );
296
+ }
297
+ if (!winner.snapshot) {
298
+ throw new GoalRestoreError(
299
+ `winning goal revision ${winner.revision} is malformed: ${winner.error?.message ?? "invalid snapshot"}`,
300
+ );
301
+ }
302
+ return { snapshot: winner.snapshot, migrated: winner.migrated };
303
+ }
304
+
305
+ export function restoreGoalSnapshot(entries: readonly unknown[]) {
306
+ const { snapshot } = restoreGoalState(entries);
307
+ return isGoalVisible(snapshot) ? snapshot : undefined;
308
+ }
309
+
310
+ export function transitionGoal(
311
+ current: GoalSnapshot,
312
+ status: GoalStatus,
313
+ now: number,
314
+ reason?: string,
315
+ ) {
316
+ const base = validateGoalSnapshot(current);
317
+ assertTimestampAfter(now, base.updatedAt);
318
+ const {
319
+ reason: _reason,
320
+ deferContinuation: _defer,
321
+ blockedAudit: _blockedAudit,
322
+ completionAcknowledged: _acknowledged,
323
+ ...stable
324
+ } = base;
325
+ return validateGoalSnapshot({
326
+ ...stable,
327
+ revision: increment(base.revision, "revision"),
328
+ status,
329
+ updatedAt: now,
330
+ ...(reason === undefined ? {} : { reason: normalizeGoalReason(reason) }),
331
+ });
332
+ }
333
+
334
+ /**
335
+ * Resume a goal to active on explicit user action. Unlike a plain
336
+ * transitionGoal, this resets continuationCount: resuming is the user's
337
+ * deliberate "run it again", and it is the only recovery path from the
338
+ * emergency continuation breaker (which trips at emergencyContinuations and is
339
+ * otherwise preserved by ...stable across every other transition). Without the
340
+ * reset, resume() would flip to active and dispatchPrompt would immediately
341
+ * re-trip the breaker, so resume could never actually recover the goal.
342
+ */
343
+ export function resumeGoal(
344
+ current: GoalSnapshot,
345
+ now: number,
346
+ reason?: string,
347
+ ) {
348
+ const base = validateGoalSnapshot(current);
349
+ assertTimestampAfter(now, base.updatedAt);
350
+ const {
351
+ reason: _reason,
352
+ deferContinuation: _defer,
353
+ blockedAudit: _blockedAudit,
354
+ completionAcknowledged: _acknowledged,
355
+ continuationCount: _continuationCount,
356
+ ...stable
357
+ } = base;
358
+ return validateGoalSnapshot({
359
+ ...stable,
360
+ revision: increment(base.revision, "revision"),
361
+ status: "active",
362
+ updatedAt: now,
363
+ ...(reason === undefined ? {} : { reason: normalizeGoalReason(reason) }),
364
+ });
365
+ }
366
+
367
+ export function editGoalObjective(
368
+ current: GoalSnapshot,
369
+ objective: unknown,
370
+ now: number,
371
+ ) {
372
+ const base = validateGoalSnapshot(current);
373
+ assertTimestampAfter(now, base.updatedAt);
374
+ let status: GoalStatus =
375
+ base.status === "complete" || base.status === "budget_limited"
376
+ ? "active"
377
+ : base.status;
378
+ if (
379
+ status === "active" &&
380
+ base.tokenBudget !== undefined &&
381
+ base.tokensUsed >= base.tokenBudget
382
+ ) {
383
+ status = "budget_limited";
384
+ }
385
+ const {
386
+ reason: _reason,
387
+ deferContinuation: _defer,
388
+ blockedAudit: _blockedAudit,
389
+ completionAcknowledged: _acknowledged,
390
+ ...stable
391
+ } = base;
392
+ return validateGoalSnapshot({
393
+ ...stable,
394
+ revision: increment(base.revision, "revision"),
395
+ objective: normalizeGoalObjective(objective),
396
+ status,
397
+ updatedAt: now,
398
+ });
399
+ }
400
+
401
+ export function recordBlockedAudit(
402
+ current: GoalSnapshot,
403
+ blocker: unknown,
404
+ now: number,
405
+ ) {
406
+ const base = validateGoalSnapshot(current);
407
+ if (base.status !== "active") {
408
+ fail("only an active goal can record a blocked audit");
409
+ }
410
+ const normalizedBlocker = normalizeGoalReason(blocker);
411
+ const turn = base.continuationCount ?? 0;
412
+ const previous = base.blockedAudit;
413
+ if (previous?.blocker === normalizedBlocker && previous.lastTurn === turn) {
414
+ return { snapshot: base, audit: previous, recorded: false };
415
+ }
416
+ assertTimestampAfter(now, base.updatedAt);
417
+ const audit: GoalBlockedAudit = {
418
+ blocker: normalizedBlocker,
419
+ consecutiveTurns:
420
+ previous?.blocker === normalizedBlocker && previous.lastTurn === turn - 1
421
+ ? increment(previous.consecutiveTurns, "blockedAudit.consecutiveTurns")
422
+ : 1,
423
+ lastTurn: turn,
424
+ };
425
+ return {
426
+ snapshot: validateGoalSnapshot({
427
+ ...base,
428
+ revision: increment(base.revision, "revision"),
429
+ updatedAt: now,
430
+ blockedAudit: audit,
431
+ }),
432
+ audit,
433
+ recorded: true,
434
+ };
435
+ }
436
+
437
+ export function clearBlockedAudit(current: GoalSnapshot, now: number) {
438
+ const base = validateGoalSnapshot(current);
439
+ if (!base.blockedAudit) return base;
440
+ assertTimestampAfter(now, base.updatedAt);
441
+ const { blockedAudit: _blockedAudit, ...withoutAudit } = base;
442
+ return validateGoalSnapshot({
443
+ ...withoutAudit,
444
+ revision: increment(base.revision, "revision"),
445
+ updatedAt: now,
446
+ });
447
+ }
448
+
449
+ export function acknowledgeGoalCompletion(current: GoalSnapshot, now: number) {
450
+ const base = validateGoalSnapshot(current);
451
+ if (base.status !== "complete" || base.completionAcknowledged) return base;
452
+ assertTimestampAfter(now, base.updatedAt);
453
+ return validateGoalSnapshot({
454
+ ...base,
455
+ revision: increment(base.revision, "revision"),
456
+ updatedAt: now,
457
+ completionAcknowledged: true,
458
+ });
459
+ }
460
+
461
+ export function recordGoalProgress(
462
+ current: GoalSnapshot,
463
+ tokens: number,
464
+ elapsedSeconds: number,
465
+ now: number,
466
+ ) {
467
+ const base = validateGoalSnapshot(current);
468
+ if (base.status === "cleared") fail("a cleared goal cannot record progress");
469
+ assertNonNegativeInteger(tokens, "tokens");
470
+ assertNonNegativeInteger(elapsedSeconds, "elapsedSeconds");
471
+ assertTimestampAfter(now, base.updatedAt);
472
+ return validateGoalSnapshot({
473
+ ...base,
474
+ revision: increment(base.revision, "revision"),
475
+ tokensUsed: safeAdd(base.tokensUsed, tokens, "tokensUsed"),
476
+ timeUsedSeconds: safeAdd(
477
+ base.timeUsedSeconds,
478
+ elapsedSeconds,
479
+ "timeUsedSeconds",
480
+ ),
481
+ updatedAt: now,
482
+ });
483
+ }
484
+
485
+ export function markContinuationDispatched(current: GoalSnapshot, now: number) {
486
+ const base = validateGoalSnapshot(current);
487
+ if (base.status !== "active") {
488
+ fail("only an active goal can dispatch a continuation");
489
+ }
490
+ assertTimestampAfter(now, base.updatedAt);
491
+ return validateGoalSnapshot({
492
+ ...base,
493
+ revision: increment(base.revision, "revision"),
494
+ continuationCount: increment(
495
+ base.continuationCount ?? 0,
496
+ "continuationCount",
497
+ ),
498
+ updatedAt: now,
499
+ });
500
+ }
501
+
502
+ export function setContinuationDeferred(
503
+ current: GoalSnapshot,
504
+ deferred: boolean,
505
+ now: number,
506
+ ) {
507
+ const base = validateGoalSnapshot(current);
508
+ if (Boolean(base.deferContinuation) === deferred) return base;
509
+ assertTimestampAfter(now, base.updatedAt);
510
+ const { deferContinuation: _defer, ...stable } = base;
511
+ return validateGoalSnapshot({
512
+ ...stable,
513
+ revision: increment(base.revision, "revision"),
514
+ updatedAt: now,
515
+ ...(deferred ? { deferContinuation: true } : {}),
516
+ });
517
+ }
518
+
519
+ export function budgetLimitTransition(goal: GoalSnapshot, now: number) {
520
+ const checked = validateGoalSnapshot(goal);
521
+ if (
522
+ checked.status !== "active" ||
523
+ checked.tokenBudget === undefined ||
524
+ checked.tokensUsed < checked.tokenBudget
525
+ ) {
526
+ return undefined;
527
+ }
528
+ return transitionGoal(
529
+ checked,
530
+ "budget_limited",
531
+ now,
532
+ `Reached the ${checked.tokenBudget}-token goal budget.`,
533
+ );
534
+ }
535
+
536
+ export function emergencyLimitTransition(goal: GoalSnapshot, now: number) {
537
+ const checked = validateGoalSnapshot(goal);
538
+ if (
539
+ checked.status !== "active" ||
540
+ (checked.continuationCount ?? 0) < GOAL_LIMITS.emergencyContinuations
541
+ ) {
542
+ return undefined;
543
+ }
544
+ return transitionGoal(
545
+ checked,
546
+ "blocked",
547
+ now,
548
+ "Stopped after the internal continuation safety limit.",
549
+ );
550
+ }
551
+
552
+ function migrateV1Snapshot(value: Record<string, unknown>) {
553
+ assertNonNegativeInteger(value.revision, "legacy.revision");
554
+ assertId(value.id);
555
+ const objective = normalizeGoalObjective(value.objective);
556
+ const condition =
557
+ typeof value.condition === "string" ? value.condition.trim() : "";
558
+ const combined =
559
+ condition &&
560
+ condition.localeCompare(objective, "en", { sensitivity: "base" })
561
+ ? `${objective}\n\nSuccess criteria: ${condition}`
562
+ : objective;
563
+ const migratedObjective =
564
+ Array.from(combined).length <= GOAL_LIMITS.objectiveChars
565
+ ? combined
566
+ : objective;
567
+ assertTimestamp(value.createdAt, "legacy.createdAt");
568
+ assertTimestamp(value.updatedAt, "legacy.updatedAt");
569
+ if ((value.updatedAt as number) < (value.createdAt as number)) {
570
+ fail("legacy.updatedAt precedes createdAt");
571
+ }
572
+ assertNonNegativeInteger(value.activeMs, "legacy.activeMs");
573
+ const tokensUsed = nonNegativeIntegerOrZero(value.parentTokens);
574
+ const continuationCount = nonNegativeIntegerOrZero(value.iterations);
575
+ const status = migrateV1Status(value.status);
576
+ if (value.tokenBudget !== undefined) {
577
+ assertPositiveInteger(value.tokenBudget, "legacy.tokenBudget");
578
+ }
579
+ const reason =
580
+ typeof value.reason === "string" && value.reason.trim()
581
+ ? normalizeGoalReason(value.reason)
582
+ : status === "paused" &&
583
+ ["active", "waiting"].includes(String(value.status))
584
+ ? "Paused once while migrating the legacy goal; run /goal resume to continue."
585
+ : undefined;
586
+
587
+ return validateGoalSnapshot({
588
+ version: 2,
589
+ revision: increment(value.revision as number, "revision"),
590
+ id: value.id,
591
+ objective: migratedObjective,
592
+ status,
593
+ ...(value.tokenBudget === undefined
594
+ ? {}
595
+ : { tokenBudget: value.tokenBudget }),
596
+ tokensUsed,
597
+ timeUsedSeconds: Math.floor((value.activeMs as number) / 1_000),
598
+ createdAt: value.createdAt,
599
+ updatedAt: value.updatedAt,
600
+ ...(reason === undefined ? {} : { reason }),
601
+ ...(continuationCount > 0 ? { continuationCount } : {}),
602
+ });
603
+ }
604
+
605
+ function migrateV1Status(value: unknown): GoalStatus {
606
+ switch (value) {
607
+ case "active":
608
+ case "waiting":
609
+ case "paused":
610
+ return "paused";
611
+ case "achieved":
612
+ return "complete";
613
+ case "impossible":
614
+ case "stalled":
615
+ return "blocked";
616
+ case "budget_limited":
617
+ case "max_iterations":
618
+ return "budget_limited";
619
+ case "cleared":
620
+ return "cleared";
621
+ default:
622
+ fail("legacy.status is invalid");
623
+ }
624
+ }
625
+
626
+ function extractGoalPayload(entry: unknown): {
627
+ found: boolean;
628
+ value?: unknown;
629
+ } {
630
+ if (
631
+ !isRecord(entry) ||
632
+ entry.type !== "custom" ||
633
+ entry.customType !== GOAL_ENTRY_TYPE
634
+ ) {
635
+ return { found: false };
636
+ }
637
+ return { found: true, value: hasOwn(entry, "data") ? entry.data : undefined };
638
+ }
639
+
640
+ function declaredRevision(value: unknown) {
641
+ if (!isRecord(value)) return undefined;
642
+ return Number.isSafeInteger(value.revision) && (value.revision as number) >= 0
643
+ ? (value.revision as number)
644
+ : undefined;
645
+ }
646
+
647
+ function validateBlockedAudit(value: unknown, continuationCount: unknown) {
648
+ if (!isRecord(value)) fail("snapshot.blockedAudit must be an object");
649
+ assertExactKeys(
650
+ value,
651
+ new Set(["blocker", "consecutiveTurns", "lastTurn"]),
652
+ "snapshot.blockedAudit",
653
+ );
654
+ const blocker = normalizeGoalReason(value.blocker);
655
+ assertPositiveInteger(
656
+ value.consecutiveTurns,
657
+ "snapshot.blockedAudit.consecutiveTurns",
658
+ );
659
+ assertNonNegativeInteger(value.lastTurn, "snapshot.blockedAudit.lastTurn");
660
+ const currentTurn =
661
+ typeof continuationCount === "number" ? continuationCount : 0;
662
+ if ((value.lastTurn as number) > currentTurn) {
663
+ fail("snapshot.blockedAudit.lastTurn exceeds continuationCount");
664
+ }
665
+ if ((value.consecutiveTurns as number) > (value.lastTurn as number) + 1) {
666
+ fail("snapshot.blockedAudit.consecutiveTurns exceeds elapsed goal turns");
667
+ }
668
+ return {
669
+ blocker,
670
+ consecutiveTurns: value.consecutiveTurns as number,
671
+ lastTurn: value.lastTurn as number,
672
+ };
673
+ }
674
+
675
+ export function normalizeGoalReason(value: unknown) {
676
+ if (typeof value !== "string") fail("reason must be a string");
677
+ const reason = sanitizeTerminalText(value).replace(/\s+/gu, " ").trim();
678
+ if (!reason) fail("reason must not be blank");
679
+ if (Array.from(reason).length > GOAL_LIMITS.reasonChars) {
680
+ fail(`reason exceeds ${GOAL_LIMITS.reasonChars} characters`);
681
+ }
682
+ return reason;
683
+ }
684
+
685
+ function assertSnapshotBytes(snapshot: GoalSnapshot) {
686
+ const bytes = new TextEncoder().encode(JSON.stringify(snapshot)).byteLength;
687
+ if (bytes > GOAL_LIMITS.snapshotBytes) {
688
+ fail(
689
+ `serialized snapshot exceeds ${GOAL_LIMITS.snapshotBytes} UTF-8 bytes`,
690
+ );
691
+ }
692
+ }
693
+
694
+ function assertExactKeys(
695
+ value: Record<string, unknown>,
696
+ allowed: ReadonlySet<string>,
697
+ path: string,
698
+ ) {
699
+ for (const key of Object.keys(value)) {
700
+ if (!allowed.has(key)) fail(`${path} contains unknown field: ${key}`);
701
+ }
702
+ }
703
+
704
+ function assertId(value: unknown): asserts value is string {
705
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]{8,100}$/.test(value)) {
706
+ fail("id must contain 8..100 URL-safe characters");
707
+ }
708
+ }
709
+
710
+ function assertTimestamp(value: unknown, path: string) {
711
+ assertNonNegativeInteger(value, path);
712
+ }
713
+
714
+ function assertTimestampAfter(value: unknown, previous: number) {
715
+ assertTimestamp(value, "now");
716
+ if ((value as number) < previous) fail("now precedes snapshot.updatedAt");
717
+ }
718
+
719
+ function assertPositiveInteger(value: unknown, path: string) {
720
+ if (!Number.isSafeInteger(value) || (value as number) < 1) {
721
+ fail(`${path} must be a positive safe integer`);
722
+ }
723
+ }
724
+
725
+ function assertNonNegativeInteger(value: unknown, path: string) {
726
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
727
+ fail(`${path} must be a non-negative safe integer`);
728
+ }
729
+ }
730
+
731
+ function nonNegativeIntegerOrZero(value: unknown) {
732
+ return Number.isSafeInteger(value) && (value as number) >= 0
733
+ ? (value as number)
734
+ : 0;
735
+ }
736
+
737
+ function increment(value: number, path: string) {
738
+ return safeAdd(value, 1, path);
739
+ }
740
+
741
+ function safeAdd(left: number, right: number, path: string) {
742
+ const result = left + right;
743
+ if (!Number.isSafeInteger(result) || result < 0) {
744
+ fail(`${path} exhausted safe integers`);
745
+ }
746
+ return result;
747
+ }
748
+
749
+ function isRecord(value: unknown): value is Record<string, unknown> {
750
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
751
+ return false;
752
+ }
753
+ const prototype = Object.getPrototypeOf(value);
754
+ return prototype === Object.prototype || prototype === null;
755
+ }
756
+
757
+ function hasOwn(value: object, key: string) {
758
+ return Object.prototype.hasOwnProperty.call(value, key);
759
+ }
760
+
761
+ function fail(message: string): never {
762
+ throw new GoalValidationError(message);
763
+ }