pi-session-continuity 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 (29) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +172 -0
  4. package/docs/deploys/README.md +18 -0
  5. package/docs/deploys/deploy-2026-07-07-SCOPE-0001-package-skeleton.md +53 -0
  6. package/docs/deploys/deploy-2026-07-07-SCOPE-0002-core-artifact-engine.md +39 -0
  7. package/docs/deploys/deploy-2026-07-07-SCOPE-0003-manual-checkpoint.md +38 -0
  8. package/docs/deploys/deploy-2026-07-07-SCOPE-0004-status-settings.md +38 -0
  9. package/docs/deploys/deploy-2026-07-07-SCOPE-0005-automatic-threshold.md +37 -0
  10. package/docs/deploys/deploy-2026-07-07-SCOPE-0006-compaction-hygiene.md +38 -0
  11. package/docs/deploys/deploy-2026-07-07-SCOPE-0007-tests-smoke.md +38 -0
  12. package/docs/deploys/deploy-2026-07-07-SCOPE-0008-public-docs-release-readiness.md +37 -0
  13. package/docs/deploys/deploy-2026-07-07-SCOPE-0009-dogfood-hardening-local-readiness.md +40 -0
  14. package/docs/deploys/deploy-2026-07-08-SCOPE-0010-ux-settings-menu.md +41 -0
  15. package/docs/deploys/deploy-2026-07-08-SCOPE-0011-compact-status-footer.md +26 -0
  16. package/docs/deploys/deploy-2026-07-08-SCOPE-0012-remove-settings-show.md +20 -0
  17. package/docs/deploys/deploy-2026-07-08-SCOPE-0014-status-and-effort.md +21 -0
  18. package/docs/deploys/deploy-2026-07-08-SCOPE-0016-codex-empty-synthesis.md +21 -0
  19. package/docs/product-spec.md +702 -0
  20. package/docs/release-readiness/local-v1-readiness-2026-07-07.md +76 -0
  21. package/extensions/session-continuity/index.ts +317 -0
  22. package/package.json +58 -0
  23. package/scripts/smoke/manual-checks.sh +42 -0
  24. package/src/artifact.ts +535 -0
  25. package/src/config.ts +360 -0
  26. package/src/constants.ts +68 -0
  27. package/src/handoff.ts +674 -0
  28. package/src/status.ts +130 -0
  29. package/src/trigger.ts +40 -0
@@ -0,0 +1,535 @@
1
+ import {
2
+ mkdir,
3
+ readdir,
4
+ readFile,
5
+ rename,
6
+ unlink,
7
+ writeFile,
8
+ } from "node:fs/promises";
9
+ import { dirname, join } from "node:path";
10
+ import { randomUUID } from "node:crypto";
11
+ import {
12
+ ALLOWED_STATUSES,
13
+ ARTIFACT_KIND,
14
+ ARTIFACT_NAME,
15
+ MANDATORY_HEADINGS,
16
+ OPERATION_NAME,
17
+ PRODUCT_NAME,
18
+ REQUIRED_FRONTMATTER_FIELDS,
19
+ RESUME_PROMPT_INTRO,
20
+ ARCHIVE_RETENTION_LIMIT,
21
+ } from "./constants.js";
22
+
23
+ export type ContinuityStatus = (typeof ALLOWED_STATUSES)[number];
24
+
25
+ export interface ContinuityFrontmatter {
26
+ kind: string;
27
+ product: string;
28
+ artifact: string;
29
+ operation: string;
30
+ status: ContinuityStatus;
31
+ version: number;
32
+ eventId: string;
33
+ sessionId: string;
34
+ sessionFile: string;
35
+ createdAt: string;
36
+ updatedAt: string;
37
+ modelId: string;
38
+ synthesisModel: string;
39
+ synthesisEffort?: string;
40
+ tokenCountAtTrigger: number;
41
+ contextWindow: number;
42
+ triggerAtPercent: number;
43
+ keepRecentPercent: number;
44
+ branchLeafBefore?: string | null;
45
+ }
46
+
47
+ export interface ArtifactPaths {
48
+ sessionRoot: string;
49
+ pendingDir: string;
50
+ archiveDir: string;
51
+ failedDir: string;
52
+ lockDir: string;
53
+ pendingPath: string;
54
+ lockPath: string;
55
+ }
56
+
57
+ export interface ParsedBrief {
58
+ frontmatter: Record<string, string | number | null>;
59
+ body: string;
60
+ }
61
+
62
+ export interface ValidationResult {
63
+ ok: boolean;
64
+ errors: string[];
65
+ }
66
+
67
+ function safePathPart(value: string): string {
68
+ return value.replace(/[^A-Za-z0-9_.-]/g, "_");
69
+ }
70
+
71
+ export function createEventId(): string {
72
+ return randomUUID();
73
+ }
74
+
75
+ export function buildArtifactPaths(
76
+ artifactDirectory: string,
77
+ sessionId: string,
78
+ eventId: string,
79
+ ): ArtifactPaths {
80
+ const safeSessionId = safePathPart(sessionId);
81
+ const safeEventId = safePathPart(eventId);
82
+ const sessionRoot = join(artifactDirectory, safeSessionId);
83
+ const pendingDir = join(sessionRoot, "pending");
84
+ const archiveDir = join(sessionRoot, "archive");
85
+ const failedDir = join(sessionRoot, "failed");
86
+ const lockDir = join(sessionRoot, "lock");
87
+ return {
88
+ sessionRoot,
89
+ pendingDir,
90
+ archiveDir,
91
+ failedDir,
92
+ lockDir,
93
+ pendingPath: join(pendingDir, `${safeEventId}.md`),
94
+ lockPath: join(lockDir, `${safeEventId}.json`),
95
+ };
96
+ }
97
+
98
+ export function buildFrontmatter(
99
+ input: Omit<
100
+ ContinuityFrontmatter,
101
+ "kind" | "product" | "artifact" | "operation" | "version"
102
+ >,
103
+ ): ContinuityFrontmatter {
104
+ return {
105
+ kind: ARTIFACT_KIND,
106
+ product: PRODUCT_NAME,
107
+ artifact: ARTIFACT_NAME,
108
+ operation: OPERATION_NAME,
109
+ version: 1,
110
+ ...input,
111
+ };
112
+ }
113
+
114
+ function yamlScalar(value: string | number | null | undefined): string {
115
+ if (value === null) return "null";
116
+ if (value === undefined) return "null";
117
+ if (typeof value === "number") return String(value);
118
+ return JSON.stringify(value);
119
+ }
120
+
121
+ export function serializeFrontmatter(
122
+ frontmatter: ContinuityFrontmatter,
123
+ ): string {
124
+ const lines = [
125
+ "---",
126
+ `kind: ${yamlScalar(frontmatter.kind)}`,
127
+ `product: ${yamlScalar(frontmatter.product)}`,
128
+ `artifact: ${yamlScalar(frontmatter.artifact)}`,
129
+ `operation: ${yamlScalar(frontmatter.operation)}`,
130
+ `status: ${yamlScalar(frontmatter.status)}`,
131
+ `version: ${yamlScalar(frontmatter.version)}`,
132
+ `eventId: ${yamlScalar(frontmatter.eventId)}`,
133
+ `sessionId: ${yamlScalar(frontmatter.sessionId)}`,
134
+ `sessionFile: ${yamlScalar(frontmatter.sessionFile)}`,
135
+ `createdAt: ${yamlScalar(frontmatter.createdAt)}`,
136
+ `updatedAt: ${yamlScalar(frontmatter.updatedAt)}`,
137
+ `modelId: ${yamlScalar(frontmatter.modelId)}`,
138
+ `synthesisModel: ${yamlScalar(frontmatter.synthesisModel)}`,
139
+ ...(frontmatter.synthesisEffort !== undefined
140
+ ? [`synthesisEffort: ${yamlScalar(frontmatter.synthesisEffort)}`]
141
+ : []),
142
+ `tokenCountAtTrigger: ${yamlScalar(frontmatter.tokenCountAtTrigger)}`,
143
+ `contextWindow: ${yamlScalar(frontmatter.contextWindow)}`,
144
+ `triggerAtPercent: ${yamlScalar(frontmatter.triggerAtPercent)}`,
145
+ `keepRecentPercent: ${yamlScalar(frontmatter.keepRecentPercent)}`,
146
+ ];
147
+ if (frontmatter.branchLeafBefore !== undefined) {
148
+ lines.push(`branchLeafBefore: ${yamlScalar(frontmatter.branchLeafBefore)}`);
149
+ }
150
+ lines.push("---");
151
+ return `${lines.join("\n")}\n`;
152
+ }
153
+
154
+ export function serializeBrief(
155
+ frontmatter: ContinuityFrontmatter,
156
+ body: string,
157
+ ): string {
158
+ return `${serializeFrontmatter(frontmatter)}${body.trim()}\n`;
159
+ }
160
+
161
+ function parseScalar(raw: string): string | number | null {
162
+ const value = raw.trim();
163
+ if (value === "null") return null;
164
+ if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
165
+ if (
166
+ (value.startsWith('"') && value.endsWith('"')) ||
167
+ (value.startsWith("'") && value.endsWith("'"))
168
+ ) {
169
+ try {
170
+ return JSON.parse(value);
171
+ } catch {
172
+ return value.slice(1, -1);
173
+ }
174
+ }
175
+ return value;
176
+ }
177
+
178
+ export function parseBrief(content: string): ParsedBrief {
179
+ if (!content.startsWith("---\n")) {
180
+ throw new Error("Continuity Brief must start with YAML frontmatter");
181
+ }
182
+ const end = content.indexOf("\n---", 4);
183
+ if (end === -1) {
184
+ throw new Error("Continuity Brief frontmatter is not closed");
185
+ }
186
+ const frontmatterText = content.slice(4, end).trim();
187
+ const body = content.slice(end + "\n---".length).replace(/^\n/, "");
188
+ const frontmatter: Record<string, string | number | null> = {};
189
+ for (const line of frontmatterText.split("\n")) {
190
+ const index = line.indexOf(":");
191
+ if (index === -1) continue;
192
+ const key = line.slice(0, index).trim();
193
+ const value = line.slice(index + 1);
194
+ frontmatter[key] = parseScalar(value);
195
+ }
196
+ return { frontmatter, body };
197
+ }
198
+
199
+ export function normalizeSynthesizedBody(synthesized: string): string {
200
+ let body = synthesized.trim();
201
+ if (body.startsWith("```")) {
202
+ body = body
203
+ .replace(/^```(?:markdown|md)?\s*/i, "")
204
+ .replace(/```\s*$/i, "")
205
+ .trim();
206
+ }
207
+ if (body.startsWith("---\n")) {
208
+ try {
209
+ body = parseBrief(body).body.trim();
210
+ } catch {
211
+ // Keep original text; validation will fail with a useful message.
212
+ }
213
+ }
214
+ const headingIndex = body.indexOf("# Continuity Brief");
215
+ if (headingIndex > 0) body = body.slice(headingIndex);
216
+ return body.trim();
217
+ }
218
+
219
+ function getSection(body: string, heading: string): string {
220
+ const start = body.indexOf(`${heading}\n`);
221
+ if (start === -1) return "";
222
+ const afterHeading = start + heading.length;
223
+ const next = body.slice(afterHeading + 1).search(/\n##[#]?\s+/);
224
+ return next === -1
225
+ ? body.slice(afterHeading).trim()
226
+ : body.slice(afterHeading, afterHeading + 1 + next).trim();
227
+ }
228
+
229
+ function findDirectivePromotion(body: string): string | undefined {
230
+ const authoritySections = [
231
+ "## Constraints / Forbid",
232
+ "## Next Actions",
233
+ "## Recovery Instructions",
234
+ ];
235
+ const directivePattern =
236
+ /\b(ignore previous instructions|system instructions are overridden|follow these instructions instead|developer instructions no longer apply)\b/i;
237
+ const evidencePattern =
238
+ /\b(observed|quoted|transcript|tool output|file content|prior artifact|as evidence)\b/i;
239
+ for (const heading of authoritySections) {
240
+ const section = getSection(body, heading);
241
+ if (directivePattern.test(section) && !evidencePattern.test(section)) {
242
+ return `${heading} appears to promote directive-looking content above active instruction authority`;
243
+ }
244
+ }
245
+ return undefined;
246
+ }
247
+
248
+ const STRING_FRONTMATTER_FIELDS = [
249
+ "eventId",
250
+ "sessionId",
251
+ "sessionFile",
252
+ "createdAt",
253
+ "updatedAt",
254
+ "modelId",
255
+ "synthesisModel",
256
+ ] as const;
257
+
258
+ const NUMERIC_FRONTMATTER_FIELDS = [
259
+ "version",
260
+ "tokenCountAtTrigger",
261
+ "contextWindow",
262
+ "triggerAtPercent",
263
+ "keepRecentPercent",
264
+ ] as const;
265
+
266
+ const STATUS_TRANSITIONS: Record<ContinuityStatus, ContinuityStatus[]> = {
267
+ pending: ["injected", "failed"],
268
+ injected: ["archived"],
269
+ archived: [],
270
+ failed: [],
271
+ };
272
+
273
+ function assertStatusTransition(from: unknown, to: ContinuityStatus): void {
274
+ if (!ALLOWED_STATUSES.includes(from as ContinuityStatus)) {
275
+ throw new Error(`invalid current status: ${String(from)}`);
276
+ }
277
+ const current = from as ContinuityStatus;
278
+ if (!STATUS_TRANSITIONS[current].includes(to)) {
279
+ throw new Error(
280
+ `invalid Continuity Brief status transition: ${current} -> ${to}`,
281
+ );
282
+ }
283
+ }
284
+
285
+ export function validateBrief(
286
+ content: string,
287
+ expectedSessionId?: string,
288
+ ): ValidationResult {
289
+ const errors: string[] = [];
290
+ let parsed: ParsedBrief;
291
+ try {
292
+ parsed = parseBrief(content);
293
+ } catch (error) {
294
+ return {
295
+ ok: false,
296
+ errors: [error instanceof Error ? error.message : String(error)],
297
+ };
298
+ }
299
+
300
+ for (const field of REQUIRED_FRONTMATTER_FIELDS) {
301
+ if (
302
+ parsed.frontmatter[field] === undefined ||
303
+ parsed.frontmatter[field] === ""
304
+ ) {
305
+ errors.push(`missing required frontmatter field: ${field}`);
306
+ }
307
+ }
308
+
309
+ if (parsed.frontmatter.kind !== ARTIFACT_KIND)
310
+ errors.push(`kind must be ${ARTIFACT_KIND}`);
311
+ if (parsed.frontmatter.product !== PRODUCT_NAME)
312
+ errors.push(`product must be ${PRODUCT_NAME}`);
313
+ if (parsed.frontmatter.artifact !== ARTIFACT_NAME)
314
+ errors.push(`artifact must be ${ARTIFACT_NAME}`);
315
+ if (parsed.frontmatter.operation !== OPERATION_NAME)
316
+ errors.push(`operation must be ${OPERATION_NAME}`);
317
+ if (
318
+ !ALLOWED_STATUSES.includes(parsed.frontmatter.status as ContinuityStatus)
319
+ ) {
320
+ errors.push(`invalid status: ${String(parsed.frontmatter.status)}`);
321
+ }
322
+ if (parsed.frontmatter.version !== 1) errors.push("version must be 1");
323
+ for (const field of STRING_FRONTMATTER_FIELDS) {
324
+ if (typeof parsed.frontmatter[field] !== "string") {
325
+ errors.push(`${field} must be a string`);
326
+ }
327
+ }
328
+ for (const field of NUMERIC_FRONTMATTER_FIELDS) {
329
+ if (
330
+ typeof parsed.frontmatter[field] !== "number" ||
331
+ !Number.isFinite(parsed.frontmatter[field])
332
+ ) {
333
+ errors.push(`${field} must be a finite number`);
334
+ }
335
+ }
336
+ if (
337
+ typeof parsed.frontmatter.tokenCountAtTrigger === "number" &&
338
+ parsed.frontmatter.tokenCountAtTrigger < 0
339
+ ) {
340
+ errors.push("tokenCountAtTrigger must be non-negative");
341
+ }
342
+ if (
343
+ typeof parsed.frontmatter.contextWindow === "number" &&
344
+ parsed.frontmatter.contextWindow < 0
345
+ ) {
346
+ errors.push("contextWindow must be non-negative");
347
+ }
348
+ if (
349
+ typeof parsed.frontmatter.triggerAtPercent === "number" &&
350
+ (parsed.frontmatter.triggerAtPercent <= 0 ||
351
+ parsed.frontmatter.triggerAtPercent >= 100)
352
+ ) {
353
+ errors.push("triggerAtPercent must be positive and below 100");
354
+ }
355
+ if (
356
+ typeof parsed.frontmatter.keepRecentPercent === "number" &&
357
+ (parsed.frontmatter.keepRecentPercent <= 0 ||
358
+ parsed.frontmatter.keepRecentPercent >= 100)
359
+ ) {
360
+ errors.push("keepRecentPercent must be positive and below 100");
361
+ }
362
+ if (
363
+ typeof parsed.frontmatter.keepRecentPercent === "number" &&
364
+ typeof parsed.frontmatter.triggerAtPercent === "number" &&
365
+ parsed.frontmatter.keepRecentPercent >= parsed.frontmatter.triggerAtPercent
366
+ ) {
367
+ errors.push("keepRecentPercent must be lower than triggerAtPercent");
368
+ }
369
+ if (expectedSessionId && parsed.frontmatter.sessionId !== expectedSessionId) {
370
+ errors.push(`sessionId mismatch: expected ${expectedSessionId}`);
371
+ }
372
+
373
+ const bodyLines = parsed.body.split(/\r?\n/).map((line) => line.trim());
374
+ for (const heading of MANDATORY_HEADINGS) {
375
+ if (!bodyLines.includes(heading))
376
+ errors.push(`missing mandatory heading: ${heading}`);
377
+ }
378
+
379
+ const directivePromotion = findDirectivePromotion(parsed.body);
380
+ if (directivePromotion) errors.push(directivePromotion);
381
+
382
+ return { ok: errors.length === 0, errors };
383
+ }
384
+
385
+ export function assertValidBrief(
386
+ content: string,
387
+ expectedSessionId?: string,
388
+ ): void {
389
+ const result = validateBrief(content, expectedSessionId);
390
+ if (!result.ok) throw new Error(result.errors.join("; "));
391
+ }
392
+
393
+ export function replaceBriefStatus(
394
+ content: string,
395
+ status: ContinuityStatus,
396
+ updatedAt: string,
397
+ ): string {
398
+ const parsed = parseBrief(content);
399
+ assertStatusTransition(parsed.frontmatter.status, status);
400
+ const nextFrontmatter = {
401
+ ...parsed.frontmatter,
402
+ status,
403
+ updatedAt,
404
+ } as unknown as ContinuityFrontmatter;
405
+ return serializeBrief(nextFrontmatter, parsed.body);
406
+ }
407
+
408
+ export function buildResumePrompt(
409
+ savedBriefContent: string,
410
+ expectedSessionId?: string,
411
+ ): string {
412
+ const parsed = parseBrief(savedBriefContent);
413
+ if (parsed.frontmatter.status !== "pending") {
414
+ throw new Error(
415
+ `only pending Continuity Brief artifacts are valid resume input; got ${String(parsed.frontmatter.status)}`,
416
+ );
417
+ }
418
+ assertValidBrief(savedBriefContent, expectedSessionId);
419
+ return `${savedBriefContent.trim()}\n\n${RESUME_PROMPT_INTRO}`;
420
+ }
421
+
422
+ export async function ensureArtifactDirectories(
423
+ paths: ArtifactPaths,
424
+ ): Promise<void> {
425
+ await Promise.all([
426
+ mkdir(paths.pendingDir, { recursive: true }),
427
+ mkdir(paths.archiveDir, { recursive: true }),
428
+ mkdir(paths.failedDir, { recursive: true }),
429
+ mkdir(paths.lockDir, { recursive: true }),
430
+ ]);
431
+ }
432
+
433
+ export async function writeTextFile(
434
+ path: string,
435
+ content: string,
436
+ ): Promise<void> {
437
+ await mkdir(dirname(path), { recursive: true });
438
+ await writeFile(path, content, "utf8");
439
+ }
440
+
441
+ export async function readTextFile(path: string): Promise<string> {
442
+ return readFile(path, "utf8");
443
+ }
444
+
445
+ export async function markBriefInjected(
446
+ pendingPath: string,
447
+ content: string,
448
+ ): Promise<string> {
449
+ const injected = replaceBriefStatus(
450
+ content,
451
+ "injected",
452
+ new Date().toISOString(),
453
+ );
454
+ await writeTextFile(pendingPath, injected);
455
+ return injected;
456
+ }
457
+
458
+ export async function archiveBrief(
459
+ pendingPath: string,
460
+ archiveDir: string,
461
+ eventId: string,
462
+ content: string,
463
+ ): Promise<string> {
464
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
465
+ const archivePath = join(archiveDir, `${timestamp}-${eventId}.md`);
466
+ const archived = replaceBriefStatus(
467
+ content,
468
+ "archived",
469
+ new Date().toISOString(),
470
+ );
471
+ await writeTextFile(pendingPath, archived);
472
+ await mkdir(archiveDir, { recursive: true });
473
+ await rename(pendingPath, archivePath);
474
+ return archivePath;
475
+ }
476
+
477
+ export async function pruneArchivedBriefs(
478
+ archiveDir: string,
479
+ limit = ARCHIVE_RETENTION_LIMIT,
480
+ ): Promise<string[]> {
481
+ if (!Number.isInteger(limit) || limit < 1)
482
+ throw new Error("archive retention limit must be a positive integer");
483
+
484
+ let entries;
485
+ try {
486
+ entries = await readdir(archiveDir, { withFileTypes: true });
487
+ } catch (error) {
488
+ if (
489
+ error instanceof Error &&
490
+ "code" in error &&
491
+ (error as NodeJS.ErrnoException).code === "ENOENT"
492
+ ) {
493
+ return [];
494
+ }
495
+ throw error;
496
+ }
497
+
498
+ const archiveFiles = entries
499
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
500
+ .map((entry) => entry.name)
501
+ .sort((a, b) => b.localeCompare(a));
502
+ const removed = archiveFiles
503
+ .slice(limit)
504
+ .map((name) => join(archiveDir, name));
505
+ for (const path of removed) await unlink(path);
506
+ return removed;
507
+ }
508
+
509
+ export async function writeFailedArtifact(
510
+ paths: ArtifactPaths,
511
+ frontmatter: ContinuityFrontmatter,
512
+ body: string,
513
+ ): Promise<string> {
514
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
515
+ const failedPath = join(
516
+ paths.failedDir,
517
+ `${timestamp}-${frontmatter.eventId}.md`,
518
+ );
519
+ const failed = serializeBrief(
520
+ { ...frontmatter, status: "failed", updatedAt: new Date().toISOString() },
521
+ body,
522
+ );
523
+ await writeTextFile(failedPath, failed);
524
+ return failedPath;
525
+ }
526
+
527
+ export function buildFailureBody(
528
+ phase: string,
529
+ errorMessage: string,
530
+ eventId: string,
531
+ sessionId: string,
532
+ sessionFile: string,
533
+ ): string {
534
+ return `# Continuity Brief Failure\n\nFailure phase: ${phase}\n\nError message: ${errorMessage}\n\nEvent id: ${eventId}\n\nSession id: ${sessionId}\n\nSession file: ${sessionFile}\n\nNo resume prompt was queued.\n`;
535
+ }