pi-notes 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 (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -0
  3. package/entry.ts +20 -0
  4. package/index.ts +1231 -0
  5. package/package.json +48 -0
package/index.ts ADDED
@@ -0,0 +1,1231 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import {
5
+ getAgentDir,
6
+ type ExtensionAPI,
7
+ type ExtensionContext,
8
+ type SessionEntry,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import { Type } from "typebox";
11
+ import { Value } from "typebox/value";
12
+
13
+ export const NOTES_STATE_TYPE = "pi-notes-state";
14
+ export const NOTES_CHECKPOINT_TYPE = "pi-notes-checkpoint";
15
+ export const NOTES_REMINDER_TYPE = "pi-notes-reminder";
16
+ export const NOTES_VERSION = 1;
17
+
18
+ export const DEFAULT_CONFIG = Object.freeze({
19
+ activationMode: "auto" as const,
20
+ notesMaxBytes: 8192,
21
+ autoActivation: {
22
+ turns: 8,
23
+ toolCalls: 32,
24
+ readOnlyLongTaskTurns: 10,
25
+ requireHighSignalActivity: true,
26
+ },
27
+ checkpointing: {
28
+ dirtyTurns: 20,
29
+ continuityRelevantToolResults: 64,
30
+ readOnlyToolResults: 32,
31
+ },
32
+ integrations: {
33
+ goal: true,
34
+ subagentChildProbe: true,
35
+ },
36
+ });
37
+
38
+ type ActivationMode = "off" | "manual" | "auto";
39
+ type VerificationOutcome = "success" | "error" | "unknown";
40
+
41
+ export interface CheckpointPayload {
42
+ current: string;
43
+ completed: string[];
44
+ findings: string[];
45
+ decisions: string[];
46
+ failed_approaches: string[];
47
+ blockers: string[];
48
+ verification: string[];
49
+ next_action: string;
50
+ }
51
+
52
+ export interface HarnessFacts {
53
+ modifiedFiles: Set<string>;
54
+ lastVerificationCommand?: string;
55
+ lastVerificationOutcome?: VerificationOutcome;
56
+ recentFailedCommandCount: number;
57
+ }
58
+
59
+ export interface NotesRuntime {
60
+ /** Set during session replacement so late callbacks cannot touch stale pi objects. */
61
+ sessionEnded: boolean;
62
+ activationMode: ActivationMode;
63
+ active: boolean;
64
+ notesId: string;
65
+ notesPath: string;
66
+ dirty: boolean;
67
+ checkpointDue: boolean;
68
+ /** Runtime-only latch: each due episode gets one ambient reminder. */
69
+ checkpointReminderPending: boolean;
70
+ /** Runtime-only suppression state after a failed checkpoint attempt. */
71
+ checkpointFailureCount: number;
72
+ checkpointRetryAfterTurn: number;
73
+ checkpointActivitySinceFailure: boolean;
74
+ /** Runtime-only latch for an explicitly requested follow-up turn. */
75
+ checkpointExplicitRequestPending: boolean;
76
+ turnsSinceCheckpoint: number;
77
+ continuityRelevantToolResultsSinceCheckpoint: number;
78
+ readOnlyToolResultsSinceCheckpoint: number;
79
+ activationTurns: number;
80
+ activationToolCalls: number;
81
+ readOnlyTurns: number;
82
+ sawHighSignalActivity: boolean;
83
+ toolCallsThisTurn: number;
84
+ highSignalThisTurn: boolean;
85
+ checkpointGeneration: number;
86
+ lastCheckpointHash?: string;
87
+ lastCheckpointAt?: number;
88
+ reentryRequired: boolean;
89
+ harnessFacts: HarnessFacts;
90
+ checkpointInFlight: boolean;
91
+ }
92
+
93
+ type StateRecord = {
94
+ version: number;
95
+ notesId: string;
96
+ activationMode: ActivationMode;
97
+ active: boolean;
98
+ generation: number;
99
+ dirty: boolean;
100
+ };
101
+
102
+ type CheckpointRecord = {
103
+ version: number;
104
+ notesId: string;
105
+ activationMode: ActivationMode;
106
+ active: boolean;
107
+ generation: number;
108
+ notesPath: string;
109
+ hash: string;
110
+ checkpointedAt: number;
111
+ payload: CheckpointPayload;
112
+ harnessFacts: {
113
+ modifiedFiles: string[];
114
+ lastVerificationCommand?: string;
115
+ lastVerificationOutcome?: VerificationOutcome;
116
+ recentFailedCommandCount: number;
117
+ };
118
+ };
119
+
120
+ const CHECKPOINT_TEXT_MAX_LENGTH = 2048;
121
+ const CHECKPOINT_LIST_ITEM_MAX_LENGTH = 1024;
122
+ const CHECKPOINT_ARRAY_FIELDS = [
123
+ "completed",
124
+ "findings",
125
+ "decisions",
126
+ "failed_approaches",
127
+ "blockers",
128
+ "verification",
129
+ ] as const;
130
+ type CheckpointArrayField = (typeof CHECKPOINT_ARRAY_FIELDS)[number];
131
+ const CHECKPOINT_MAX_ITEMS: Record<CheckpointArrayField, number> = {
132
+ completed: 40,
133
+ findings: 40,
134
+ decisions: 40,
135
+ failed_approaches: 30,
136
+ blockers: 30,
137
+ verification: 40,
138
+ };
139
+
140
+ const CHECKPOINT_SCHEMA = Type.Object({
141
+ current: Type.String({
142
+ minLength: 1,
143
+ maxLength: CHECKPOINT_TEXT_MAX_LENGTH,
144
+ description: "Present objective and status only; keep this compact.",
145
+ }),
146
+ completed: Type.Array(
147
+ Type.String({ minLength: 1, maxLength: CHECKPOINT_LIST_ITEM_MAX_LENGTH }),
148
+ { maxItems: CHECKPOINT_MAX_ITEMS.completed, description: "Finished work only." },
149
+ ),
150
+ findings: Type.Array(
151
+ Type.String({ minLength: 1, maxLength: CHECKPOINT_LIST_ITEM_MAX_LENGTH }),
152
+ { maxItems: CHECKPOINT_MAX_ITEMS.findings, description: "Observed facts and constraints." },
153
+ ),
154
+ decisions: Type.Array(
155
+ Type.String({ minLength: 1, maxLength: CHECKPOINT_LIST_ITEM_MAX_LENGTH }),
156
+ { maxItems: CHECKPOINT_MAX_ITEMS.decisions, description: "Chosen approaches and rationale." },
157
+ ),
158
+ failed_approaches: Type.Array(
159
+ Type.String({ minLength: 1, maxLength: CHECKPOINT_LIST_ITEM_MAX_LENGTH }),
160
+ { maxItems: CHECKPOINT_MAX_ITEMS.failed_approaches, description: "Failed attempts to avoid repeating." },
161
+ ),
162
+ blockers: Type.Array(
163
+ Type.String({ minLength: 1, maxLength: CHECKPOINT_LIST_ITEM_MAX_LENGTH }),
164
+ { maxItems: CHECKPOINT_MAX_ITEMS.blockers, description: "Unresolved impediments only." },
165
+ ),
166
+ verification: Type.Array(
167
+ Type.String({ minLength: 1, maxLength: CHECKPOINT_LIST_ITEM_MAX_LENGTH }),
168
+ { maxItems: CHECKPOINT_MAX_ITEMS.verification, description: "Commands and outcomes only." },
169
+ ),
170
+ next_action: Type.String({
171
+ minLength: 1,
172
+ maxLength: CHECKPOINT_TEXT_MAX_LENGTH,
173
+ description: "One concrete next action only; keep this compact.",
174
+ }),
175
+ }, { additionalProperties: false });
176
+ const CHECKPOINT_FIELDS = new Set<string>([
177
+ "current",
178
+ ...CHECKPOINT_ARRAY_FIELDS,
179
+ "next_action",
180
+ ]);
181
+ const MALFORMED_ARRAY_FIELD = new RegExp(
182
+ `^(${CHECKPOINT_ARRAY_FIELDS.join("|")})\\]\\n([^\\r\\n]+)\\n</parameter$`,
183
+ );
184
+ const MALFORMED_ARROW_SPLIT_FIELD = new RegExp(
185
+ `^(${CHECKPOINT_ARRAY_FIELDS.join("|")})\\]\\n([^\\r\\n]+)=$`,
186
+ );
187
+
188
+ /**
189
+ * Repair the one observed model serialization artifact without changing the
190
+ * public checkpoint schema. Invalid or ambiguous input is deliberately left
191
+ * untouched so the normal strict validator remains authoritative.
192
+ */
193
+ export function repairCheckpointArguments(args: unknown): unknown {
194
+ if (typeof args !== "object" || args === null || Array.isArray(args)) return args;
195
+ const source = args as Record<string, unknown>;
196
+ const input: Record<string, unknown> = { ...source };
197
+ let normalizedCanonicalArray = false;
198
+ for (const field of CHECKPOINT_ARRAY_FIELDS) {
199
+ const value = source[field];
200
+ if (typeof value !== "string") continue;
201
+ let parsed: unknown;
202
+ try {
203
+ parsed = JSON.parse(value);
204
+ } catch {
205
+ return args;
206
+ }
207
+ if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === "string")) return args;
208
+ input[field] = parsed;
209
+ normalizedCanonicalArray = true;
210
+ }
211
+ const malformed: Array<{ key: string; field: (typeof CHECKPOINT_ARRAY_FIELDS)[number]; values: string[] }> = [];
212
+
213
+ for (const key of Object.keys(input)) {
214
+ if (CHECKPOINT_FIELDS.has(key)) continue;
215
+
216
+ const closedFragment = MALFORMED_ARRAY_FIELD.exec(key);
217
+ const arrowSplitFragment = MALFORMED_ARROW_SPLIT_FIELD.exec(key);
218
+ let field: (typeof CHECKPOINT_ARRAY_FIELDS)[number];
219
+ let serialized: string;
220
+ if (closedFragment && input[key] === "") {
221
+ field = closedFragment[1] as (typeof CHECKPOINT_ARRAY_FIELDS)[number];
222
+ serialized = closedFragment[2];
223
+ } else if (arrowSplitFragment && typeof input[key] === "string") {
224
+ field = arrowSplitFragment[1] as (typeof CHECKPOINT_ARRAY_FIELDS)[number];
225
+ serialized = `${arrowSplitFragment[2]}=>${input[key]}`;
226
+ } else {
227
+ return args;
228
+ }
229
+
230
+ let parsed: unknown;
231
+ try {
232
+ parsed = JSON.parse(serialized);
233
+ } catch {
234
+ return args;
235
+ }
236
+ if (!Array.isArray(parsed) || !parsed.every((value) => typeof value === "string")) return args;
237
+ malformed.push({ key, field, values: parsed });
238
+ }
239
+
240
+ if (!malformed.length) return normalizedCanonicalArray ? input : args;
241
+ const seen = new Set<string>();
242
+ for (const fragment of malformed) {
243
+ if (Object.prototype.hasOwnProperty.call(input, fragment.field) || seen.has(fragment.field)) return args;
244
+ seen.add(fragment.field);
245
+ }
246
+
247
+ const repaired: Record<string, unknown> = { ...input };
248
+ for (const fragment of malformed) {
249
+ delete repaired[fragment.key];
250
+ repaired[fragment.field] = fragment.values;
251
+ }
252
+ return repaired;
253
+ }
254
+
255
+ const CHECKPOINT_FAILURE_HINT =
256
+ "Continue the main task; no checkpoint was committed. Do not retry this checkpoint in the same turn; keep a later checkpoint compact.";
257
+ const CHECKPOINT_FAILURE_COOLDOWN_TURNS = 5;
258
+ const INVALID_CHECKPOINT_MESSAGE = [
259
+ "Invalid checkpoint payload.",
260
+ "Limits: current and next_action 1-2048 characters; each list item 1-1024 characters;",
261
+ "completed, findings, decisions, and verification at most 40 items;",
262
+ "failed_approaches and blockers at most 30 items.",
263
+ CHECKPOINT_FAILURE_HINT,
264
+ ].join(" ");
265
+
266
+ function checkpointLimitViolation(args: unknown): string | undefined {
267
+ if (typeof args !== "object" || args === null || Array.isArray(args)) return undefined;
268
+ const input = args as Record<string, unknown>;
269
+
270
+ for (const field of ["current", "next_action"] as const) {
271
+ const value = input[field];
272
+ if (typeof value === "string" && value.length > CHECKPOINT_TEXT_MAX_LENGTH) {
273
+ return `${field} is ${value.length} characters (maximum ${CHECKPOINT_TEXT_MAX_LENGTH}).`;
274
+ }
275
+ }
276
+
277
+ for (const field of CHECKPOINT_ARRAY_FIELDS) {
278
+ const value = input[field];
279
+ if (value !== undefined && !Array.isArray(value)) {
280
+ return `${field} must be an array of strings.`;
281
+ }
282
+ if (!Array.isArray(value)) continue;
283
+ const maxItems = CHECKPOINT_MAX_ITEMS[field];
284
+ if (value.length > maxItems) {
285
+ return `${field} has ${value.length} items (maximum ${maxItems}).`;
286
+ }
287
+ const oversizedIndex = value.findIndex(
288
+ (item) => typeof item === "string" && item.length > CHECKPOINT_LIST_ITEM_MAX_LENGTH,
289
+ );
290
+ if (oversizedIndex !== -1) {
291
+ const item = value[oversizedIndex] as string;
292
+ return `${field}[${oversizedIndex}] is ${item.length} characters (maximum ${CHECKPOINT_LIST_ITEM_MAX_LENGTH}).`;
293
+ }
294
+ }
295
+
296
+ const missing = ["current", ...CHECKPOINT_ARRAY_FIELDS, "next_action"]
297
+ .find((field) => !Object.prototype.hasOwnProperty.call(input, field));
298
+ if (missing) return `${missing} is required.`;
299
+
300
+ return undefined;
301
+ }
302
+
303
+ export function prepareCheckpointArguments(args: unknown): CheckpointPayload {
304
+ const repaired = repairCheckpointArguments(args);
305
+ if (!Value.Check(CHECKPOINT_SCHEMA, repaired)) {
306
+ const detail = checkpointLimitViolation(repaired);
307
+ throw new Error(
308
+ detail
309
+ ? `Invalid checkpoint payload: ${detail} ${CHECKPOINT_FAILURE_HINT}`
310
+ : INVALID_CHECKPOINT_MESSAGE,
311
+ );
312
+ }
313
+ return repaired as CheckpointPayload;
314
+ }
315
+
316
+ function freshHarnessFacts(): HarnessFacts {
317
+ return { modifiedFiles: new Set(), recentFailedCommandCount: 0 };
318
+ }
319
+
320
+ function notesRoot(): string {
321
+ return join(getAgentDir(), "notes");
322
+ }
323
+
324
+ export function notesPathFor(notesId: string): string {
325
+ return join(notesRoot(), notesId, "NOTES.md");
326
+ }
327
+
328
+ export function createRuntime(mode: ActivationMode = DEFAULT_CONFIG.activationMode): NotesRuntime {
329
+ const notesId = randomUUID();
330
+ return {
331
+ sessionEnded: false,
332
+ activationMode: mode,
333
+ active: mode === "manual",
334
+ notesId,
335
+ notesPath: notesPathFor(notesId),
336
+ dirty: false,
337
+ checkpointDue: false,
338
+ checkpointReminderPending: false,
339
+ checkpointFailureCount: 0,
340
+ checkpointRetryAfterTurn: 0,
341
+ checkpointActivitySinceFailure: false,
342
+ checkpointExplicitRequestPending: false,
343
+ turnsSinceCheckpoint: 0,
344
+ continuityRelevantToolResultsSinceCheckpoint: 0,
345
+ readOnlyToolResultsSinceCheckpoint: 0,
346
+ activationTurns: 0,
347
+ activationToolCalls: 0,
348
+ readOnlyTurns: 0,
349
+ sawHighSignalActivity: false,
350
+ toolCallsThisTurn: 0,
351
+ highSignalThisTurn: false,
352
+ checkpointGeneration: 0,
353
+ reentryRequired: false,
354
+ harnessFacts: freshHarnessFacts(),
355
+ checkpointInFlight: false,
356
+ };
357
+ }
358
+
359
+ function hashText(text: string): string {
360
+ return createHash("sha256").update(text).digest("hex");
361
+ }
362
+
363
+ function normalizeList(values: readonly string[]): string[] {
364
+ return values.map((value) => value.trim()).filter(Boolean);
365
+ }
366
+
367
+ function bulletSection(title: string, values: readonly string[]): string {
368
+ const items = normalizeList(values);
369
+ return `## ${title}\n${items.length ? items.map((value) => `- ${value}`).join("\n") : "- None."}`;
370
+ }
371
+
372
+ function boundedWorkingSet(
373
+ prefix: string,
374
+ suffix: string,
375
+ modifiedFiles: readonly string[],
376
+ ): string {
377
+ const pathLines = modifiedFiles.map((path) => `- \`${path}\``);
378
+ const omissionLine = (omitted: number) => `- … ${omitted} more paths retained in checkpoint metadata.`;
379
+ const render = (body: string) => `${prefix}${body}${suffix}`;
380
+ const empty = render("- None.");
381
+ if (Buffer.byteLength(empty, "utf8") > DEFAULT_CONFIG.notesMaxBytes) {
382
+ return pathLines.length ? render(omissionLine(pathLines.length)) : empty;
383
+ }
384
+ if (!pathLines.length) return empty;
385
+
386
+ const all = render(pathLines.join("\n"));
387
+ if (Buffer.byteLength(all, "utf8") <= DEFAULT_CONFIG.notesMaxBytes) return all;
388
+
389
+ for (let included = pathLines.length - 1; included >= 0; included -= 1) {
390
+ const omitted = pathLines.length - included;
391
+ const omission = omissionLine(omitted);
392
+ const body = [...pathLines.slice(0, included), omission].join("\n");
393
+ const candidate = render(body);
394
+ if (Buffer.byteLength(candidate, "utf8") <= DEFAULT_CONFIG.notesMaxBytes) return candidate;
395
+ }
396
+
397
+ // No accurate omission marker fits; keep the non-empty working set truthful
398
+ // and let the commit-time assertion preserve the hard failure.
399
+ return render(omissionLine(pathLines.length));
400
+ }
401
+
402
+ export function renderNotes(payload: CheckpointPayload, runtime: NotesRuntime): string {
403
+ const modifiedFiles = [...runtime.harnessFacts.modifiedFiles].sort();
404
+ const authoredSections = [
405
+ "# Task State",
406
+ `## Current\n${payload.current.trim()}`,
407
+ bulletSection("Completed", payload.completed),
408
+ bulletSection("Findings", payload.findings),
409
+ bulletSection("Decisions", payload.decisions),
410
+ bulletSection("Failed Approaches", payload.failed_approaches),
411
+ bulletSection("Verification", payload.verification),
412
+ bulletSection("Blockers", payload.blockers),
413
+ `## Next Action\n${payload.next_action.trim()}`,
414
+ ];
415
+ const prefix = `${authoredSections.join("\n\n")}\n\n## Working Set\n`;
416
+ const suffix = `\n\n<!-- pi-notes:v1 notesId=${runtime.notesId} generation=${runtime.checkpointGeneration + 1} -->\n`;
417
+ return boundedWorkingSet(prefix, suffix, modifiedFiles);
418
+ }
419
+
420
+ function customEntry<T>(entry: SessionEntry, customType: string): T | undefined {
421
+ if (entry.type !== "custom" || entry.customType !== customType) return undefined;
422
+ return entry.data as T | undefined;
423
+ }
424
+
425
+ function compatibleState(entry: SessionEntry): StateRecord | undefined {
426
+ const data = customEntry<StateRecord>(entry, NOTES_STATE_TYPE);
427
+ if (!data || data.version !== NOTES_VERSION || typeof data.notesId !== "string") return undefined;
428
+ return data;
429
+ }
430
+
431
+ function compatibleCheckpoint(entry: SessionEntry): CheckpointRecord | undefined {
432
+ const data = customEntry<CheckpointRecord>(entry, NOTES_CHECKPOINT_TYPE);
433
+ if (!data || data.version !== NOTES_VERSION || typeof data.notesId !== "string" || typeof data.hash !== "string") return undefined;
434
+ return data;
435
+ }
436
+
437
+ function latestRecord<T>(entries: readonly SessionEntry[], read: (entry: SessionEntry) => T | undefined): T | undefined {
438
+ for (let index = entries.length - 1; index >= 0; index--) {
439
+ const value = read(entries[index]);
440
+ if (value) return value;
441
+ }
442
+ return undefined;
443
+ }
444
+
445
+ function latestStateForId(entries: readonly SessionEntry[], notesId: string): StateRecord | undefined {
446
+ for (let index = entries.length - 1; index >= 0; index--) {
447
+ const checkpoint = compatibleCheckpoint(entries[index]);
448
+ if (checkpoint?.notesId === notesId) return undefined;
449
+ const state = compatibleState(entries[index]);
450
+ if (state?.notesId === notesId) return state;
451
+ }
452
+ return undefined;
453
+ }
454
+
455
+ function latestCheckpointForId(entries: readonly SessionEntry[], notesId: string): CheckpointRecord | undefined {
456
+ return latestRecord(entries, (entry) => {
457
+ const checkpoint = compatibleCheckpoint(entry);
458
+ return checkpoint?.notesId === notesId ? checkpoint : undefined;
459
+ });
460
+ }
461
+
462
+ function isChildSession(): boolean {
463
+ const registry = globalThis as unknown as Record<PropertyKey, unknown>;
464
+ const probe = registry[Symbol.for("pi-subagents:child-context:v1")];
465
+ return typeof probe === "function" && (probe as () => boolean)() === true;
466
+ }
467
+
468
+ async function ensureSafeDestination(runtime: NotesRuntime): Promise<void> {
469
+ const root = notesRoot();
470
+ await mkdir(root, { recursive: true });
471
+ const rootStat = await lstat(root);
472
+ if (rootStat.isSymbolicLink()) throw new Error("Notes root must not be a symlink");
473
+ const resolvedRoot = await realpath(root);
474
+ const directory = dirname(runtime.notesPath);
475
+ // Compare lexical paths before creation so symlinked ancestors such as macOS
476
+ // /tmp -> /private/tmp do not look like an escape. The realpath check below
477
+ // validates the created directory against the canonical root.
478
+ const rel = relative(resolve(root), resolve(directory));
479
+ if (rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) {
480
+ throw new Error("Notes destination escapes the configured agent notes directory");
481
+ }
482
+ try {
483
+ const dirStat = await lstat(directory);
484
+ if (dirStat.isSymbolicLink()) throw new Error("Notes session directory must not be a symlink");
485
+ } catch (error) {
486
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
487
+ await mkdir(directory, { recursive: false });
488
+ }
489
+ const realDirectory = await realpath(directory);
490
+ const afterRel = relative(resolvedRoot, realDirectory);
491
+ if (afterRel === ".." || afterRel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) {
492
+ throw new Error("Validated Notes directory resolved outside the agent notes root");
493
+ }
494
+ try {
495
+ const fileStat = await lstat(runtime.notesPath);
496
+ if (fileStat.isSymbolicLink()) throw new Error("NOTES.md must not be a symlink");
497
+ } catch (error) {
498
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
499
+ }
500
+ }
501
+
502
+ async function readCurrentHash(path: string): Promise<string | undefined> {
503
+ try {
504
+ return hashText(await readFile(path, "utf8"));
505
+ } catch (error) {
506
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
507
+ throw error;
508
+ }
509
+ }
510
+
511
+ export async function hasUnexpectedMaterializedChange(runtime: NotesRuntime): Promise<boolean> {
512
+ if (!runtime.lastCheckpointHash) return false;
513
+ return await readCurrentHash(runtime.notesPath) !== runtime.lastCheckpointHash;
514
+ }
515
+
516
+ async function atomicWrite(path: string, content: string): Promise<void> {
517
+ const tmp = join(dirname(path), `.NOTES.${process.pid}.${randomUUID()}.tmp`);
518
+ try {
519
+ await writeFile(tmp, content, { encoding: "utf8", flag: "wx", mode: 0o600 });
520
+ await rename(tmp, path);
521
+ } catch (error) {
522
+ await unlink(tmp).catch(() => undefined);
523
+ throw error;
524
+ }
525
+ }
526
+
527
+ function persistentFacts(runtime: NotesRuntime): CheckpointRecord["harnessFacts"] {
528
+ return {
529
+ modifiedFiles: [...runtime.harnessFacts.modifiedFiles].sort(),
530
+ lastVerificationCommand: runtime.harnessFacts.lastVerificationCommand,
531
+ lastVerificationOutcome: runtime.harnessFacts.lastVerificationOutcome,
532
+ recentFailedCommandCount: runtime.harnessFacts.recentFailedCommandCount,
533
+ };
534
+ }
535
+
536
+ function clearCheckpointFailure(runtime: NotesRuntime): void {
537
+ runtime.checkpointFailureCount = 0;
538
+ runtime.checkpointRetryAfterTurn = 0;
539
+ runtime.checkpointActivitySinceFailure = false;
540
+ }
541
+
542
+ function recordCheckpointFailure(runtime: NotesRuntime): void {
543
+ runtime.checkpointFailureCount += 1;
544
+ runtime.checkpointRetryAfterTurn = runtime.activationTurns + CHECKPOINT_FAILURE_COOLDOWN_TURNS;
545
+ runtime.checkpointActivitySinceFailure = false;
546
+ runtime.checkpointExplicitRequestPending = false;
547
+ runtime.checkpointReminderPending = false;
548
+ if (runtime.active) runtime.checkpointDue = true;
549
+ }
550
+
551
+ function maybeRearmCheckpointReminder(runtime: NotesRuntime): void {
552
+ if (!runtime.active
553
+ || !runtime.dirty
554
+ || !runtime.checkpointDue
555
+ || runtime.checkpointReminderPending
556
+ || runtime.checkpointExplicitRequestPending
557
+ || runtime.checkpointFailureCount === 0
558
+ || !runtime.checkpointActivitySinceFailure
559
+ || runtime.activationTurns < runtime.checkpointRetryAfterTurn) {
560
+ return;
561
+ }
562
+ setCheckpointDue(runtime, true, { remind: true });
563
+ }
564
+
565
+ async function commitCheckpoint(pi: ExtensionAPI, runtime: NotesRuntime, payload: CheckpointPayload): Promise<{ hash: string; generation: number }> {
566
+ if (runtime.sessionEnded) throw new Error("The session was replaced before the Notes checkpoint completed");
567
+ if (!runtime.active) throw new Error("Durable Notes are not active; run /notes on or /notes auto first");
568
+ if (isChildSession()) throw new Error("Child subagent sessions cannot write the parent session Notes file");
569
+ if (runtime.checkpointInFlight) throw new Error("A Notes checkpoint is already in progress");
570
+ runtime.checkpointInFlight = true;
571
+ try {
572
+ await ensureSafeDestination(runtime);
573
+ if (await hasUnexpectedMaterializedChange(runtime)) {
574
+ throw new Error("Session-local NOTES.md changed outside checkpoint_notes; run /notes restore before checkpointing");
575
+ }
576
+ const rendered = renderNotes(payload, runtime);
577
+ const bytes = Buffer.byteLength(rendered, "utf8");
578
+ if (bytes > DEFAULT_CONFIG.notesMaxBytes) {
579
+ throw new Error(`Rendered Notes exceed ${DEFAULT_CONFIG.notesMaxBytes} bytes (${bytes}); keep only continuation-relevant state`);
580
+ }
581
+ await atomicWrite(runtime.notesPath, rendered);
582
+ if (runtime.sessionEnded) throw new Error("The session was replaced before the Notes checkpoint completed");
583
+ const hash = hashText(rendered);
584
+ const generation = runtime.checkpointGeneration + 1;
585
+ const checkpointedAt = Date.now();
586
+ pi.appendEntry<CheckpointRecord>(NOTES_CHECKPOINT_TYPE, {
587
+ version: NOTES_VERSION,
588
+ notesId: runtime.notesId,
589
+ activationMode: runtime.activationMode,
590
+ active: true,
591
+ generation,
592
+ notesPath: runtime.notesPath,
593
+ hash,
594
+ checkpointedAt,
595
+ payload,
596
+ harnessFacts: persistentFacts(runtime),
597
+ });
598
+ runtime.checkpointGeneration = generation;
599
+ runtime.lastCheckpointHash = hash;
600
+ runtime.lastCheckpointAt = checkpointedAt;
601
+ runtime.dirty = false;
602
+ runtime.checkpointDue = false;
603
+ runtime.checkpointReminderPending = false;
604
+ runtime.turnsSinceCheckpoint = 0;
605
+ runtime.continuityRelevantToolResultsSinceCheckpoint = 0;
606
+ runtime.readOnlyToolResultsSinceCheckpoint = 0;
607
+ runtime.readOnlyTurns = 0;
608
+ runtime.reentryRequired = false;
609
+ runtime.harnessFacts.recentFailedCommandCount = 0;
610
+ clearCheckpointFailure(runtime);
611
+ runtime.checkpointExplicitRequestPending = false;
612
+ return { hash, generation };
613
+ } finally {
614
+ runtime.checkpointInFlight = false;
615
+ }
616
+ }
617
+
618
+ function resetIdentity(runtime: NotesRuntime): void {
619
+ runtime.notesId = randomUUID();
620
+ runtime.notesPath = notesPathFor(runtime.notesId);
621
+ runtime.checkpointGeneration = 0;
622
+ runtime.lastCheckpointHash = undefined;
623
+ runtime.lastCheckpointAt = undefined;
624
+ runtime.dirty = false;
625
+ runtime.checkpointDue = false;
626
+ runtime.checkpointReminderPending = false;
627
+ clearCheckpointFailure(runtime);
628
+ runtime.checkpointExplicitRequestPending = false;
629
+ runtime.reentryRequired = false;
630
+ runtime.turnsSinceCheckpoint = 0;
631
+ runtime.continuityRelevantToolResultsSinceCheckpoint = 0;
632
+ runtime.readOnlyToolResultsSinceCheckpoint = 0;
633
+ runtime.activationTurns = 0;
634
+ runtime.activationToolCalls = 0;
635
+ runtime.readOnlyTurns = 0;
636
+ runtime.sawHighSignalActivity = false;
637
+ runtime.toolCallsThisTurn = 0;
638
+ runtime.highSignalThisTurn = false;
639
+ runtime.harnessFacts = freshHarnessFacts();
640
+ }
641
+
642
+ function appendState(pi: ExtensionAPI, runtime: NotesRuntime): void {
643
+ pi.appendEntry<StateRecord>(NOTES_STATE_TYPE, {
644
+ version: NOTES_VERSION,
645
+ notesId: runtime.notesId,
646
+ activationMode: runtime.activationMode,
647
+ active: runtime.active,
648
+ generation: runtime.checkpointGeneration,
649
+ dirty: runtime.dirty,
650
+ });
651
+ }
652
+
653
+ async function materializeCheckpoint(runtime: NotesRuntime, checkpoint: CheckpointRecord): Promise<void> {
654
+ runtime.checkpointGeneration = Math.max(0, checkpoint.generation - 1);
655
+ runtime.harnessFacts.modifiedFiles = new Set(checkpoint.harnessFacts.modifiedFiles);
656
+ runtime.harnessFacts.lastVerificationCommand = checkpoint.harnessFacts.lastVerificationCommand;
657
+ runtime.harnessFacts.lastVerificationOutcome = checkpoint.harnessFacts.lastVerificationOutcome;
658
+ runtime.harnessFacts.recentFailedCommandCount = checkpoint.harnessFacts.recentFailedCommandCount;
659
+ const rendered = renderNotes(checkpoint.payload, runtime);
660
+ runtime.checkpointGeneration = checkpoint.generation;
661
+ if (hashText(rendered) !== checkpoint.hash) throw new Error("Persisted Notes checkpoint hash does not match its bounded payload");
662
+ await ensureSafeDestination(runtime);
663
+ if (await readCurrentHash(runtime.notesPath) !== checkpoint.hash) await atomicWrite(runtime.notesPath, rendered);
664
+ runtime.lastCheckpointHash = checkpoint.hash;
665
+ runtime.lastCheckpointAt = checkpoint.checkpointedAt;
666
+ }
667
+
668
+ async function clearMaterializedNotes(runtime: NotesRuntime): Promise<void> {
669
+ await unlink(runtime.notesPath).catch((error: NodeJS.ErrnoException) => {
670
+ if (error.code !== "ENOENT") throw error;
671
+ });
672
+ runtime.checkpointGeneration = 0;
673
+ runtime.lastCheckpointHash = undefined;
674
+ runtime.lastCheckpointAt = undefined;
675
+ runtime.harnessFacts = freshHarnessFacts();
676
+ }
677
+
678
+ function restoreRuntimeState(runtime: NotesRuntime, state: StateRecord | undefined, checkpoint: CheckpointRecord | undefined): void {
679
+ clearCheckpointFailure(runtime);
680
+ runtime.checkpointExplicitRequestPending = false;
681
+ if (state) {
682
+ runtime.activationMode = state.activationMode;
683
+ runtime.active = state.active;
684
+ runtime.dirty = state.dirty;
685
+ runtime.checkpointGeneration = state.generation;
686
+ } else if (checkpoint) {
687
+ runtime.activationMode = checkpoint.activationMode;
688
+ runtime.active = checkpoint.active;
689
+ runtime.dirty = false;
690
+ runtime.checkpointGeneration = checkpoint.generation;
691
+ } else {
692
+ runtime.dirty = false;
693
+ setCheckpointDue(runtime, false);
694
+ runtime.checkpointGeneration = 0;
695
+ runtime.lastCheckpointHash = undefined;
696
+ runtime.lastCheckpointAt = undefined;
697
+ }
698
+ setCheckpointDue(runtime, runtime.dirty);
699
+ runtime.turnsSinceCheckpoint = 0;
700
+ runtime.continuityRelevantToolResultsSinceCheckpoint = 0;
701
+ runtime.readOnlyToolResultsSinceCheckpoint = 0;
702
+ runtime.reentryRequired = runtime.active;
703
+ }
704
+
705
+ async function restoreFromBranch(pi: ExtensionAPI, runtime: NotesRuntime, ctx: ExtensionContext, reason: string): Promise<void> {
706
+ const entries = ctx.sessionManager.getBranch();
707
+ if (reason === "new" || reason === "fork") {
708
+ resetIdentity(runtime);
709
+ runtime.active = runtime.activationMode === "manual";
710
+ appendState(pi, runtime);
711
+ return;
712
+ }
713
+
714
+ if (reason === "tree") {
715
+ const state = latestStateForId(entries, runtime.notesId);
716
+ const checkpoint = latestCheckpointForId(entries, runtime.notesId);
717
+ restoreRuntimeState(runtime, state, checkpoint);
718
+ if (checkpoint) await materializeCheckpoint(runtime, checkpoint);
719
+ else await clearMaterializedNotes(runtime);
720
+ runtime.reentryRequired = runtime.active;
721
+ return;
722
+ }
723
+
724
+ const state = latestRecord(entries, compatibleState);
725
+ const checkpoint = latestRecord(entries, compatibleCheckpoint);
726
+ const identity = state?.notesId ?? checkpoint?.notesId;
727
+ if (!identity) {
728
+ resetIdentity(runtime);
729
+ runtime.active = runtime.activationMode === "manual";
730
+ appendState(pi, runtime);
731
+ return;
732
+ }
733
+ runtime.notesId = identity;
734
+ runtime.notesPath = notesPathFor(identity);
735
+ const ownState = latestStateForId(entries, identity);
736
+ const ownCheckpoint = latestCheckpointForId(entries, identity);
737
+ restoreRuntimeState(runtime, ownState, ownCheckpoint);
738
+ if (ownCheckpoint) await materializeCheckpoint(runtime, ownCheckpoint);
739
+ else await clearMaterializedNotes(runtime);
740
+ }
741
+
742
+ function setCheckpointDue(runtime: NotesRuntime, due: boolean, options: { remind?: boolean } = {}): void {
743
+ const changed = runtime.checkpointDue !== due;
744
+ runtime.checkpointDue = due;
745
+ if (!due) runtime.checkpointReminderPending = false;
746
+ else if (options.remind === false) runtime.checkpointReminderPending = false;
747
+ else if (changed || options.remind === true) {
748
+ const canRemind = runtime.checkpointFailureCount === 0
749
+ || (runtime.checkpointActivitySinceFailure
750
+ && runtime.activationTurns >= runtime.checkpointRetryAfterTurn);
751
+ runtime.checkpointReminderPending = canRemind;
752
+ }
753
+ }
754
+
755
+ function activateIfNeeded(pi: ExtensionAPI, runtime: NotesRuntime): void {
756
+ if (runtime.activationMode !== "auto" || runtime.active) return;
757
+ const cfg = DEFAULT_CONFIG.autoActivation;
758
+ const highSignal = !cfg.requireHighSignalActivity || runtime.sawHighSignalActivity;
759
+ const threshold = (highSignal && runtime.activationTurns >= cfg.turns)
760
+ || (highSignal && runtime.activationToolCalls >= cfg.toolCalls)
761
+ || runtime.readOnlyTurns >= cfg.readOnlyLongTaskTurns;
762
+ if (!threshold) return;
763
+ runtime.active = true;
764
+ runtime.dirty = runtime.activationTurns > 0 || runtime.activationToolCalls > 0;
765
+ setCheckpointDue(runtime, false);
766
+ appendState(pi, runtime);
767
+ }
768
+
769
+ function markDirty(pi: ExtensionAPI, runtime: NotesRuntime): void {
770
+ if (!runtime.active || runtime.dirty) return;
771
+ runtime.dirty = true;
772
+ appendState(pi, runtime);
773
+ }
774
+
775
+ const VERIFY_PATTERN = /(?:^|\s)(?:npm|pnpm|yarn|bun)?\s*(?:test|build|lint|typecheck|check)|\b(?:pytest|cargo test|go test|tsc|eslint|vitest|jest|ruff|mypy)\b/i;
776
+ const MUTATE_PATTERN = /(?:^|\s)(?:npm|pnpm|yarn|bun)\s+(?:install|add|remove)|\b(?:git\s+(?:add|commit|checkout|switch|merge|rebase|reset|restore|clean)|sed\s+-i|mv\s|cp\s|rm\s|mkdir\s|touch\s|chmod\s|chown\s)\b/i;
777
+ const INSPECT_PATTERN = /\b(?:grep|rg|find|ls|cat|head|tail|git\s+(?:status|log|diff|show|branch|rev-parse)|gh\s+(?:pr|issue|repo|run|api)|jq)\b/i;
778
+ const READ_TOOL_PATTERN = /^(?:read|grep|find|ls|symbol_search|project_report|module_report|read_symbol|read_enclosing|lsp_diagnostics|lens_diagnostics|ast_grep_search|ast_grep_outline|lsp_navigation|ctx_execute_file)$/i;
779
+ const RESEARCH_TOOL_PATTERN = /^(?:web_search|source_check|fetch_content|get_search_content|resolve-library-id|query-docs|ctx_execute|ctx_batch_execute|ctx_search|ctx_fetch_and_index)$/i;
780
+ const SUBAGENT_RESULT_TOOL_PATTERN = /^(?:Agent|get_subagent_result)$/i;
781
+ const SUBAGENT_COMPLETION_TOOL_PATTERN = /^get_subagent_result$/i;
782
+
783
+ function commandFromInput(input: Record<string, unknown>): string | undefined {
784
+ const command = input.command;
785
+ return typeof command === "string" && command.trim() ? command.trim() : undefined;
786
+ }
787
+
788
+ function pathFromInput(input: Record<string, unknown>): string | undefined {
789
+ const candidate = input.path ?? input.file_path ?? input.filePath;
790
+ return typeof candidate === "string" && candidate.trim() ? candidate.trim() : undefined;
791
+ }
792
+
793
+ export function classifyToolResult(toolName: string, input: Record<string, unknown>, isError: boolean): { continuityRelevant: boolean; highSignal: boolean; verification?: string; modifiedPath?: string } {
794
+ if (toolName === "edit" || toolName === "write") {
795
+ return { continuityRelevant: !isError, highSignal: true, modifiedPath: isError ? undefined : pathFromInput(input) };
796
+ }
797
+ if (toolName === "bash" || toolName === "powershell") {
798
+ const command = commandFromInput(input);
799
+ if (!command) return { continuityRelevant: false, highSignal: false };
800
+ if (VERIFY_PATTERN.test(command)) return { continuityRelevant: true, highSignal: true, verification: command };
801
+ if (MUTATE_PATTERN.test(command)) return { continuityRelevant: !isError, highSignal: true };
802
+ if (INSPECT_PATTERN.test(command)) return { continuityRelevant: !isError, highSignal: false };
803
+ if (isError) return { continuityRelevant: true, highSignal: true };
804
+ return { continuityRelevant: false, highSignal: false };
805
+ }
806
+ if (/^(?:apply_patch|patch|create_file|update_file|delete_file|migration|format|codegen)$/i.test(toolName)) {
807
+ return { continuityRelevant: !isError, highSignal: true, modifiedPath: isError ? undefined : pathFromInput(input) };
808
+ }
809
+ if (/^(?:test|build|lint|typecheck|verify|check)/i.test(toolName)) return { continuityRelevant: true, highSignal: true };
810
+ if (READ_TOOL_PATTERN.test(toolName) || RESEARCH_TOOL_PATTERN.test(toolName) || SUBAGENT_RESULT_TOOL_PATTERN.test(toolName)) {
811
+ return { continuityRelevant: !isError, highSignal: false };
812
+ }
813
+ return { continuityRelevant: false, highSignal: false };
814
+ }
815
+
816
+ function isDeferredReadOnlyActivity(
817
+ classified: ReturnType<typeof classifyToolResult>,
818
+ isError: boolean,
819
+ meaningfulSubagentCompletion: boolean,
820
+ ): boolean {
821
+ return !isError
822
+ && classified.continuityRelevant
823
+ && !classified.highSignal
824
+ && !meaningfulSubagentCompletion;
825
+ }
826
+
827
+ type ToolResultOutput = {
828
+ content?: readonly unknown[];
829
+ details?: unknown;
830
+ };
831
+
832
+ type SubagentResultStatus = "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
833
+ const SUBAGENT_RESULT_STATUSES = new Set<SubagentResultStatus>([
834
+ "queued",
835
+ "running",
836
+ "completed",
837
+ "steered",
838
+ "aborted",
839
+ "stopped",
840
+ "error",
841
+ ]);
842
+
843
+ function subagentResultStatus(value: unknown): SubagentResultStatus | undefined {
844
+ if (typeof value !== "string") return undefined;
845
+ const status = value.trim().toLowerCase();
846
+ return SUBAGENT_RESULT_STATUSES.has(status as SubagentResultStatus)
847
+ ? status as SubagentResultStatus
848
+ : undefined;
849
+ }
850
+
851
+ function statusFromSubagentResultText(content: readonly unknown[] | undefined): SubagentResultStatus | undefined {
852
+ for (const block of content ?? []) {
853
+ if (typeof block !== "object" || block === null) continue;
854
+ const text = (block as { type?: unknown; text?: unknown }).type === "text"
855
+ ? (block as { text?: unknown }).text
856
+ : undefined;
857
+ if (typeof text !== "string") continue;
858
+ const match = /\bStatus\s*:\s*(queued|running|completed|steered|aborted|stopped|error)\b/i.exec(text);
859
+ const status = subagentResultStatus(match?.[1]);
860
+ if (status) return status;
861
+ }
862
+ return undefined;
863
+ }
864
+
865
+ function indicatesCompletedSubagentResult(output: ToolResultOutput | undefined): boolean {
866
+ const details = output?.details;
867
+ const detailRecord = typeof details === "object" && details !== null && !Array.isArray(details)
868
+ ? details as Record<string, unknown>
869
+ : undefined;
870
+ const detailHasStatus = detailRecord !== undefined && Object.prototype.hasOwnProperty.call(detailRecord, "status");
871
+ const detailStatus = subagentResultStatus(detailRecord?.status);
872
+ const contentStatus = statusFromSubagentResultText(output?.content);
873
+
874
+ // Details are structured and therefore authoritative when present. If they
875
+ // disagree with the text, or contain an unrecognized status, fail closed.
876
+ if (detailHasStatus) {
877
+ return detailStatus !== undefined
878
+ && (contentStatus === undefined || contentStatus === detailStatus)
879
+ && (detailStatus === "completed" || detailStatus === "steered");
880
+ }
881
+ return contentStatus === "completed" || contentStatus === "steered";
882
+ }
883
+
884
+ function recordActivity(
885
+ pi: ExtensionAPI,
886
+ runtime: NotesRuntime,
887
+ toolName: string,
888
+ input: Record<string, unknown>,
889
+ isError: boolean,
890
+ output?: ToolResultOutput,
891
+ ): void {
892
+ runtime.activationToolCalls += 1;
893
+ runtime.toolCallsThisTurn += 1;
894
+ const classified = classifyToolResult(toolName, input, isError);
895
+ const meaningfulSubagentCompletion = !isError
896
+ && SUBAGENT_COMPLETION_TOOL_PATTERN.test(toolName)
897
+ && indicatesCompletedSubagentResult(output);
898
+ if (runtime.checkpointFailureCount > 0
899
+ && (classified.continuityRelevant || classified.highSignal || meaningfulSubagentCompletion)) {
900
+ runtime.checkpointActivitySinceFailure = true;
901
+ }
902
+ if (classified.highSignal || isError || meaningfulSubagentCompletion) {
903
+ runtime.sawHighSignalActivity = true;
904
+ runtime.highSignalThisTurn = true;
905
+ }
906
+ if (classified.modifiedPath) runtime.harnessFacts.modifiedFiles.add(classified.modifiedPath);
907
+ if (classified.verification) {
908
+ runtime.harnessFacts.lastVerificationCommand = classified.verification;
909
+ runtime.harnessFacts.lastVerificationOutcome = isError ? "error" : "success";
910
+ }
911
+ if (classified.continuityRelevant && isError) runtime.harnessFacts.recentFailedCommandCount += 1;
912
+ const deferredReadOnly = isDeferredReadOnlyActivity(classified, isError, meaningfulSubagentCompletion);
913
+ const readOnlyInvestigation = deferredReadOnly
914
+ && (READ_TOOL_PATTERN.test(toolName) || RESEARCH_TOOL_PATTERN.test(toolName));
915
+ if (runtime.active && readOnlyInvestigation) {
916
+ runtime.readOnlyToolResultsSinceCheckpoint += 1;
917
+ if (runtime.readOnlyToolResultsSinceCheckpoint >= DEFAULT_CONFIG.checkpointing.readOnlyToolResults) {
918
+ markDirty(pi, runtime);
919
+ setCheckpointDue(runtime, true);
920
+ }
921
+ }
922
+ if (runtime.active && !deferredReadOnly && (classified.continuityRelevant || classified.highSignal || isError || meaningfulSubagentCompletion)) {
923
+ if (classified.continuityRelevant) {
924
+ runtime.continuityRelevantToolResultsSinceCheckpoint += 1;
925
+ if (runtime.continuityRelevantToolResultsSinceCheckpoint >= DEFAULT_CONFIG.checkpointing.continuityRelevantToolResults) {
926
+ setCheckpointDue(runtime, true);
927
+ }
928
+ }
929
+ markDirty(pi, runtime);
930
+ }
931
+ activateIfNeeded(pi, runtime);
932
+ }
933
+
934
+ const CHECKPOINT_FIELD_GUIDANCE = [
935
+ "Every checkpoint payload field has a mutually exclusive role; keep each fact in exactly one field.",
936
+ "current = the present objective and status.",
937
+ "completed = finished work only.",
938
+ "findings = observed facts, constraints, and discoveries.",
939
+ "decisions = chosen approaches and their rationale.",
940
+ "failed_approaches = attempts that failed and should not be repeated.",
941
+ "blockers = unresolved impediments.",
942
+ "verification = verification commands and outcomes only.",
943
+ "next_action = the one next concrete action.",
944
+ "Do not put verification in completed, repeat current in next_action, or copy deterministic working-set facts such as modified files into authored sections; the extension supplies those facts separately.",
945
+ "Use a compact budget well below the hard limits: current <=400 characters, next_action <=250 characters, at most 3 items per list, and each item <=180 characters.",
946
+ "Never paste plans, logs, raw test output, or file lists. Keep only facts needed to resume; the extension adds the deterministic working set automatically.",
947
+ "Checkpoint limits: current and next_action are 1-2048 characters; every list item is 1-1024 characters; completed, findings, decisions, and verification allow at most 40 items; failed_approaches and blockers allow at most 30. Summarize before calling checkpoint_notes rather than exceeding these limits.",
948
+ ].join(" ");
949
+
950
+ function notesPolicy(): string {
951
+ return [
952
+ "DURABLE TASK-STATE HANDOFF IS ACTIVE.",
953
+ "NOTES.md is a compact durable continuation/task-state handoff, not general notes, a diary, or proof. Live worktree/tool/test state is authoritative. Only the current top-level session writes its session-local file.",
954
+ CHECKPOINT_FIELD_GUIDANCE,
955
+ "Use checkpoint_notes after meaningful milestones, important findings/decisions, significant verification results, blockers, harness requests, and before reporting completion when the handoff is dirty. Do not checkpoint after minor observations. If checkpoint_notes fails, continue the main task, do not retry it in the same turn, and treat the eventual handoff as dirty until a later checkpoint succeeds.",
956
+ ].join("\n");
957
+ }
958
+
959
+ function reminderMessage(text: string) {
960
+ return { role: "custom" as const, customType: NOTES_REMINDER_TYPE, content: text, display: false, timestamp: Date.now() };
961
+ }
962
+
963
+ export function stripNotesReminders(messages: readonly any[]): any[] {
964
+ return messages.filter((message) => message?.customType !== NOTES_REMINDER_TYPE);
965
+ }
966
+
967
+ export function selectReminder(pi: Pick<ExtensionAPI, "getActiveTools">, runtime: NotesRuntime): string | undefined {
968
+ if (runtime.sessionEnded) return undefined;
969
+ if (!runtime.active || !pi.getActiveTools().includes("checkpoint_notes")) return undefined;
970
+ if (runtime.reentryRequired) {
971
+ runtime.reentryRequired = false;
972
+ if (runtime.lastCheckpointHash) {
973
+ const checkpointDue = runtime.checkpointDue && runtime.checkpointReminderPending;
974
+ if (checkpointDue) runtime.checkpointReminderPending = false;
975
+ return `[TASK NOTES RE-ENTRY]\nReread the compact durable continuation/task-state handoff at ${runtime.notesPath} and inspect live worktree/tool state before continuing. It is not general notes or proof.${checkpointDue ? "\n[TASK NOTES CHECKPOINT DUE]\nAfter rereading, call checkpoint_notes once only if the handoff is still materially dirty, then continue the main task." : ""}`;
976
+ }
977
+ }
978
+ if (runtime.checkpointDue && runtime.checkpointReminderPending) {
979
+ runtime.checkpointReminderPending = false;
980
+ return "[TASK NOTES CHECKPOINT DUE]\nExecution state changed materially since the last durable checkpoint or a checkpoint was explicitly requested. Before doing substantially more work, call checkpoint_notes once with only the current NOTES.md compact durable continuation/task-state handoff, not general notes, then continue.";
981
+ }
982
+ return undefined;
983
+ }
984
+
985
+ function displayStatus(ctx: ExtensionContext, runtime: NotesRuntime, pi: ExtensionAPI): void {
986
+ const paused = runtime.active && !pi.getActiveTools().includes("checkpoint_notes");
987
+ ctx.ui.notify([
988
+ `Notes mode: ${runtime.activationMode}`,
989
+ `active: ${runtime.active}`,
990
+ `dirty: ${runtime.dirty}`,
991
+ `checkpoint due: ${runtime.checkpointDue}`,
992
+ `checkpoint failures: ${runtime.checkpointFailureCount}`,
993
+ `checkpoint retry after turn: ${runtime.checkpointFailureCount ? runtime.checkpointRetryAfterTurn : "none"}`,
994
+ `generation: ${runtime.checkpointGeneration}`,
995
+ `path: ${runtime.notesPath}`,
996
+ `tool policy: ${paused ? "paused-by-tool-policy" : "available"}`,
997
+ `last checkpoint: ${runtime.lastCheckpointAt ? new Date(runtime.lastCheckpointAt).toISOString() : "none"}`,
998
+ ].join("\n"), "info");
999
+ }
1000
+
1001
+ async function restoreCommittedSnapshot(runtime: NotesRuntime, ctx: ExtensionContext): Promise<boolean> {
1002
+ const entries = ctx.sessionManager.getBranch();
1003
+ const checkpoint = latestCheckpointForId(entries, runtime.notesId);
1004
+ if (!checkpoint) return false;
1005
+ const state = latestStateForId(entries, runtime.notesId);
1006
+ restoreRuntimeState(runtime, state, checkpoint);
1007
+ await materializeCheckpoint(runtime, checkpoint);
1008
+ runtime.reentryRequired = runtime.active;
1009
+ return true;
1010
+ }
1011
+
1012
+ function canonicalToolPath(ctx: ExtensionContext, input: Record<string, unknown>): string | undefined {
1013
+ const target = pathFromInput(input);
1014
+ if (!target) return undefined;
1015
+ return resolve(isAbsolute(target) ? target : join(ctx.cwd, target));
1016
+ }
1017
+
1018
+ export default function notesExtension(pi: ExtensionAPI): void {
1019
+ const runtime = createRuntime();
1020
+
1021
+ pi.registerTool({
1022
+ name: "checkpoint_notes",
1023
+ label: "Checkpoint Notes",
1024
+ description: "Atomically rewrite the current top-level session's private NOTES.md with a bounded compact durable continuation/task-state handoff, not general notes.",
1025
+ promptSnippet: "Checkpoint a compact durable continuation/task-state handoff to the session-local NOTES.md",
1026
+ promptGuidelines: [
1027
+ "Use checkpoint_notes only for the compact durable continuation/task-state handoff in NOTES.md; it is not general notes, a diary, or proof. Do not include secrets, large logs, or hidden reasoning.",
1028
+ CHECKPOINT_FIELD_GUIDANCE,
1029
+ ],
1030
+ parameters: CHECKPOINT_SCHEMA,
1031
+ prepareArguments(args) {
1032
+ const prepared = prepareCheckpointArguments(args);
1033
+ const rendered = renderNotes(prepared, runtime);
1034
+ const bytes = Buffer.byteLength(rendered, "utf8");
1035
+ if (bytes > DEFAULT_CONFIG.notesMaxBytes) {
1036
+ throw new Error(`Invalid checkpoint payload: rendered Notes exceed ${DEFAULT_CONFIG.notesMaxBytes} bytes (${bytes}); keep only compact continuation state. ${CHECKPOINT_FAILURE_HINT}`);
1037
+ }
1038
+ return prepared;
1039
+ },
1040
+ executionMode: "sequential",
1041
+ async execute(_toolCallId, params) {
1042
+ const committed = await commitCheckpoint(pi, runtime, params as CheckpointPayload);
1043
+ return {
1044
+ content: [{ type: "text" as const, text: `Notes checkpoint committed: ${runtime.notesPath} (generation ${committed.generation}, sha256 ${committed.hash.slice(0, 12)})` }],
1045
+ details: { notesPath: runtime.notesPath, generation: committed.generation, hash: committed.hash },
1046
+ };
1047
+ },
1048
+ });
1049
+
1050
+ pi.registerCommand("notes", {
1051
+ description: "Durable task Notes: status | on | off | auto | checkpoint | resume | restore",
1052
+ handler: async (args, ctx) => {
1053
+ if (runtime.sessionEnded) return;
1054
+ const command = args.trim().toLowerCase() || "status";
1055
+ if (command === "status") return displayStatus(ctx, runtime, pi);
1056
+ if (command === "on") {
1057
+ runtime.activationMode = "manual";
1058
+ runtime.active = true;
1059
+ appendState(pi, runtime);
1060
+ return displayStatus(ctx, runtime, pi);
1061
+ }
1062
+ if (command === "off") {
1063
+ runtime.activationMode = "off";
1064
+ runtime.active = false;
1065
+ setCheckpointDue(runtime, false);
1066
+ clearCheckpointFailure(runtime);
1067
+ runtime.checkpointExplicitRequestPending = false;
1068
+ appendState(pi, runtime);
1069
+ return displayStatus(ctx, runtime, pi);
1070
+ }
1071
+ if (command === "auto") {
1072
+ runtime.activationMode = "auto";
1073
+ activateIfNeeded(pi, runtime);
1074
+ appendState(pi, runtime);
1075
+ return displayStatus(ctx, runtime, pi);
1076
+ }
1077
+ if (command === "checkpoint") {
1078
+ if (!runtime.active) {
1079
+ ctx.ui.notify("Notes are inactive; run /notes on or /notes auto first.", "warning");
1080
+ return;
1081
+ }
1082
+ if (!pi.getActiveTools().includes("checkpoint_notes")) {
1083
+ ctx.ui.notify("checkpoint_notes is disabled by the current tool policy.", "warning");
1084
+ return;
1085
+ }
1086
+ if (runtime.checkpointExplicitRequestPending) {
1087
+ ctx.ui.notify("A Notes checkpoint request is already pending; continue the main task.", "info");
1088
+ return;
1089
+ }
1090
+ runtime.checkpointExplicitRequestPending = true;
1091
+ setCheckpointDue(runtime, true, { remind: false });
1092
+ pi.sendMessage({
1093
+ customType: NOTES_REMINDER_TYPE,
1094
+ content: "[TASK NOTES CHECKPOINT REQUESTED]\nCall checkpoint_notes now with the current NOTES.md compact durable continuation/task-state handoff, not general notes, then continue.",
1095
+ display: false,
1096
+ }, { triggerTurn: true, deliverAs: "followUp" });
1097
+ return;
1098
+ }
1099
+ if (command === "restore") {
1100
+ const restored = await restoreCommittedSnapshot(runtime, ctx);
1101
+ ctx.ui.notify(restored ? `Restored committed Notes snapshot to ${runtime.notesPath}.` : "No compatible checkpoint exists on the active branch.", restored ? "info" : "warning");
1102
+ return;
1103
+ }
1104
+ if (command === "resume") {
1105
+ const inherited = latestRecord(ctx.sessionManager.getBranch(), compatibleCheckpoint);
1106
+ if (!inherited || inherited.notesId === runtime.notesId) {
1107
+ ctx.ui.notify(inherited ? "The current session already owns this checkpoint." : "No compatible inherited Notes checkpoint is visible on the active branch.", inherited ? "info" : "warning");
1108
+ return;
1109
+ }
1110
+ clearCheckpointFailure(runtime);
1111
+ runtime.checkpointExplicitRequestPending = false;
1112
+ runtime.active = true;
1113
+ runtime.dirty = true;
1114
+ setCheckpointDue(runtime, true);
1115
+ runtime.reentryRequired = true;
1116
+ runtime.harnessFacts.modifiedFiles = new Set(inherited.harnessFacts.modifiedFiles);
1117
+ runtime.harnessFacts.lastVerificationCommand = inherited.harnessFacts.lastVerificationCommand;
1118
+ runtime.harnessFacts.lastVerificationOutcome = inherited.harnessFacts.lastVerificationOutcome;
1119
+ runtime.harnessFacts.recentFailedCommandCount = inherited.harnessFacts.recentFailedCommandCount;
1120
+ const rendered = renderNotes(inherited.payload, runtime);
1121
+ if (Buffer.byteLength(rendered, "utf8") > DEFAULT_CONFIG.notesMaxBytes) throw new Error("Inherited Notes exceed configured bound");
1122
+ await ensureSafeDestination(runtime);
1123
+ await atomicWrite(runtime.notesPath, rendered);
1124
+ runtime.lastCheckpointHash = hashText(rendered);
1125
+ runtime.lastCheckpointAt = undefined;
1126
+ appendState(pi, runtime);
1127
+ ctx.ui.notify(`Adopted inherited checkpoint content into fresh Notes identity ${runtime.notesId}; checkpoint it before relying on it.`, "info");
1128
+ return;
1129
+ }
1130
+ ctx.ui.notify("Use /notes status | on | off | auto | checkpoint | resume | restore.", "warning");
1131
+ },
1132
+ });
1133
+
1134
+ pi.on("session_start", async (event, ctx) => {
1135
+ runtime.sessionEnded = false;
1136
+ await restoreFromBranch(pi, runtime, ctx, event.reason);
1137
+ });
1138
+ pi.on("session_tree", async (_event, ctx) => {
1139
+ if (runtime.sessionEnded) return;
1140
+ await restoreFromBranch(pi, runtime, ctx, "tree");
1141
+ });
1142
+ pi.on("session_shutdown", () => {
1143
+ runtime.sessionEnded = true;
1144
+ });
1145
+ pi.on("session_compact", () => {
1146
+ if (runtime.sessionEnded) return;
1147
+ if (!runtime.active) return;
1148
+ runtime.reentryRequired = true;
1149
+ if (runtime.dirty) setCheckpointDue(runtime, true);
1150
+ });
1151
+ pi.on("session_compact_failed", () => {
1152
+ if (runtime.sessionEnded) return;
1153
+ // Preserve state. A failed/aborted compaction is not a recovery boundary.
1154
+ });
1155
+ pi.on("before_agent_start", (event) => {
1156
+ if (runtime.sessionEnded) return undefined;
1157
+ if (!runtime.active) return undefined;
1158
+ return { systemPrompt: `${event.systemPrompt}\n\n${notesPolicy()}` };
1159
+ });
1160
+ pi.on("context", (event) => {
1161
+ if (runtime.sessionEnded) return { messages: stripNotesReminders(event.messages) };
1162
+ const messages = stripNotesReminders(event.messages);
1163
+ const reminder = selectReminder(pi, runtime);
1164
+ return { messages: reminder ? [...messages, reminderMessage(reminder) as any] : messages };
1165
+ });
1166
+ pi.on("turn_end", () => {
1167
+ if (runtime.sessionEnded) return;
1168
+ runtime.activationTurns += 1;
1169
+ if (runtime.toolCallsThisTurn > 0 && !runtime.highSignalThisTurn) runtime.readOnlyTurns += 1;
1170
+ else if (runtime.highSignalThisTurn) runtime.readOnlyTurns = 0;
1171
+ if (runtime.active && runtime.readOnlyTurns >= DEFAULT_CONFIG.autoActivation.readOnlyLongTaskTurns) {
1172
+ markDirty(pi, runtime);
1173
+ }
1174
+ runtime.toolCallsThisTurn = 0;
1175
+ runtime.highSignalThisTurn = false;
1176
+ if (runtime.active && runtime.dirty) {
1177
+ runtime.turnsSinceCheckpoint += 1;
1178
+ if (runtime.turnsSinceCheckpoint >= DEFAULT_CONFIG.checkpointing.dirtyTurns
1179
+ || runtime.continuityRelevantToolResultsSinceCheckpoint >= DEFAULT_CONFIG.checkpointing.continuityRelevantToolResults) {
1180
+ setCheckpointDue(runtime, true);
1181
+ }
1182
+ maybeRearmCheckpointReminder(runtime);
1183
+ }
1184
+ activateIfNeeded(pi, runtime);
1185
+ });
1186
+ pi.on("tool_result", (event) => {
1187
+ if (runtime.sessionEnded) return;
1188
+ if (event.toolName === "checkpoint_notes") {
1189
+ if (event.isError) recordCheckpointFailure(runtime);
1190
+ else clearCheckpointFailure(runtime);
1191
+ runtime.checkpointExplicitRequestPending = false;
1192
+ return;
1193
+ }
1194
+ recordActivity(pi, runtime, event.toolName, event.input, event.isError, {
1195
+ content: event.content,
1196
+ details: event.details,
1197
+ });
1198
+ });
1199
+ pi.on("tool_call", async (event, ctx) => {
1200
+ if (runtime.sessionEnded) return;
1201
+ if (isChildSession() && event.toolName === "checkpoint_notes") {
1202
+ return { block: true, reason: "Child subagent sessions cannot write the parent session Notes file." };
1203
+ }
1204
+ if (event.toolName === "checkpoint_notes"
1205
+ && runtime.checkpointFailureCount > 0
1206
+ && !runtime.checkpointExplicitRequestPending
1207
+ && (!runtime.checkpointActivitySinceFailure || runtime.activationTurns < runtime.checkpointRetryAfterTurn)) {
1208
+ return { block: true, reason: CHECKPOINT_FAILURE_HINT };
1209
+ }
1210
+ if (runtime.active && (event.toolName === "edit" || event.toolName === "write")) {
1211
+ const target = canonicalToolPath(ctx, event.input);
1212
+ if (target === resolve(runtime.notesPath)) {
1213
+ return { block: true, reason: "Direct writes to the canonical session NOTES.md are blocked; use checkpoint_notes." };
1214
+ }
1215
+ }
1216
+ if (DEFAULT_CONFIG.integrations.goal && event.toolName === "goal_progress") {
1217
+ if ((event.input as Record<string, unknown>).status === "done" && runtime.active) {
1218
+ if (await hasUnexpectedMaterializedChange(runtime)) {
1219
+ return { block: true, reason: "Goal completion is blocked because session-local NOTES.md changed outside checkpoint_notes; run /notes restore before completing the goal." };
1220
+ }
1221
+ if (runtime.dirty && runtime.checkpointFailureCount === 0) {
1222
+ return { block: true, reason: "Goal completion is blocked until dirty durable Notes are checkpointed with checkpoint_notes." };
1223
+ }
1224
+ if (runtime.dirty && runtime.checkpointFailureCount > 0) {
1225
+ ctx.ui.notify("Goal completion is proceeding with dirty Notes because the latest checkpoint failed; the durable handoff may be stale.", "warning");
1226
+ }
1227
+ }
1228
+ }
1229
+ return undefined;
1230
+ });
1231
+ }