@llblab/pi-kit 0.13.0 → 0.14.1

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 (95) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +1 -1
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +7 -7
  4. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +8 -9
  5. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +30 -0
  6. package/node_modules/@llblab/pi-state-flow/README.md +1 -3
  7. package/node_modules/@llblab/pi-state-flow/dist/index.d.ts +21 -0
  8. package/node_modules/@llblab/pi-state-flow/dist/index.js +20 -0
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.d.ts +39 -0
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.js +78 -0
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.d.ts +110 -0
  12. package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.js +334 -0
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.d.ts +49 -0
  14. package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.js +67 -0
  15. package/node_modules/@llblab/pi-state-flow/dist/lib/config.d.ts +11 -0
  16. package/node_modules/@llblab/pi-state-flow/dist/lib/config.js +53 -0
  17. package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +23 -0
  18. package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +109 -0
  19. package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.d.ts +111 -0
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.js +189 -0
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/discovery.d.ts +21 -0
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/discovery.js +125 -0
  23. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +102 -0
  24. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +507 -0
  25. package/node_modules/@llblab/pi-state-flow/dist/lib/episode.d.ts +8 -0
  26. package/node_modules/@llblab/pi-state-flow/dist/lib/episode.js +27 -0
  27. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.d.ts +22 -0
  28. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +1263 -0
  29. package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +72 -0
  30. package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +565 -0
  31. package/node_modules/@llblab/pi-state-flow/dist/lib/history.d.ts +22 -0
  32. package/node_modules/@llblab/pi-state-flow/dist/lib/history.js +79 -0
  33. package/node_modules/@llblab/pi-state-flow/dist/lib/json.d.ts +12 -0
  34. package/node_modules/@llblab/pi-state-flow/dist/lib/json.js +109 -0
  35. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.d.ts +25 -0
  36. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.js +24 -0
  37. package/node_modules/@llblab/pi-state-flow/dist/lib/maintenance.d.ts +36 -0
  38. package/node_modules/@llblab/pi-state-flow/dist/lib/maintenance.js +98 -0
  39. package/node_modules/@llblab/pi-state-flow/dist/lib/memory.d.ts +15 -0
  40. package/node_modules/@llblab/pi-state-flow/dist/lib/memory.js +42 -0
  41. package/node_modules/@llblab/pi-state-flow/dist/lib/migration.d.ts +13 -0
  42. package/node_modules/@llblab/pi-state-flow/dist/lib/migration.js +133 -0
  43. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +7 -0
  44. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +59 -0
  45. package/node_modules/@llblab/pi-state-flow/dist/lib/publication.d.ts +69 -0
  46. package/node_modules/@llblab/pi-state-flow/dist/lib/publication.js +335 -0
  47. package/node_modules/@llblab/pi-state-flow/dist/lib/query.d.ts +27 -0
  48. package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +35 -0
  49. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.d.ts +8 -0
  50. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.js +27 -0
  51. package/node_modules/@llblab/pi-state-flow/dist/lib/rehydration.d.ts +36 -0
  52. package/node_modules/@llblab/pi-state-flow/dist/lib/rehydration.js +38 -0
  53. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +142 -0
  54. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +529 -0
  55. package/node_modules/@llblab/pi-state-flow/dist/lib/session.d.ts +21 -0
  56. package/node_modules/@llblab/pi-state-flow/dist/lib/session.js +44 -0
  57. package/node_modules/@llblab/pi-state-flow/dist/lib/skills.d.ts +25 -0
  58. package/node_modules/@llblab/pi-state-flow/dist/lib/skills.js +131 -0
  59. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +88 -0
  60. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +255 -0
  61. package/node_modules/@llblab/pi-state-flow/dist/lib/state.d.ts +55 -0
  62. package/node_modules/@llblab/pi-state-flow/dist/lib/state.js +31 -0
  63. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +38 -0
  64. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +79 -0
  65. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.d.ts +46 -0
  66. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.js +217 -0
  67. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +100 -0
  68. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +234 -0
  69. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.d.ts +39 -0
  70. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.js +203 -0
  71. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.d.ts +25 -0
  72. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +204 -0
  73. package/node_modules/@llblab/pi-state-flow/dist/package.json +79 -0
  74. package/node_modules/@llblab/pi-state-flow/dist/pi-state-flow/index.js +1 -0
  75. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +138 -0
  76. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +11 -5
  77. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +5 -1
  78. package/node_modules/@llblab/pi-state-flow/docs/filesystem-recovery.md +3 -3
  79. package/node_modules/@llblab/pi-state-flow/docs/usage.md +6 -4
  80. package/node_modules/@llblab/pi-state-flow/index.ts +1 -0
  81. package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +17 -1
  82. package/node_modules/@llblab/pi-state-flow/lib/config.ts +6 -1
  83. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +88 -96
  84. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +64 -12
  85. package/node_modules/@llblab/pi-state-flow/lib/git.ts +32 -188
  86. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +84 -48
  87. package/node_modules/@llblab/pi-state-flow/lib/query.ts +40 -0
  88. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +7 -30
  89. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +48 -50
  90. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +41 -97
  91. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +17 -12
  92. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +13 -9
  93. package/node_modules/@llblab/pi-state-flow/package.json +23 -6
  94. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +3 -1
  95. package/package.json +4 -4
@@ -7,17 +7,11 @@ import { spawn, spawnSync } from "node:child_process";
7
7
  import {
8
8
  assertOwnedFileUpdates,
9
9
  captureOwnedFileBases,
10
- captureLegacyTemporalFileBases,
11
10
  captureTemporalFileBases,
12
11
  cwdScopePaths,
13
- legacySessionRuntimePaths,
14
- legacyTemporalScopePaths,
15
- durablePaths,
16
12
  isStateFlowOwnedPath,
17
13
  parseScopeProvenance,
18
14
  parseScopeStream,
19
- parseStateSource,
20
- serializeScopeStream,
21
15
  restoreDurableFileBases,
22
16
  temporalScopePaths,
23
17
  sessionScopePaths,
@@ -28,7 +22,7 @@ import {
28
22
  } from "./durable.ts";
29
23
  import { parseArtifactProvenanceRegistry, type ArtifactProvenanceRegistry } from "./artifact.ts";
30
24
  import { planLegacyStorageMigration } from "./migration.ts";
31
- import { sameJson } from "./json.ts";
25
+ import { canonicalJson, sameJson } from "./json.ts";
32
26
  import { validateTemporalState, type ScopeStream, type TemporalState } from "./temporal.ts";
33
27
  import { createSessionRuntime, parseSessionRuntime, serializeSessionRuntime, type SessionRuntime, type Snapshot } from "./snapshot.ts";
34
28
  import { assertTemporalFileBase, loadTemporalFileRevision, planTemporalPublication, temporalFileReceipts, withStoragePublicationLock } from "./storage.ts";
@@ -206,8 +200,6 @@ export interface TemporalRevisionLoad {
206
200
  runtime?: { document: SessionRuntime; revision: string };
207
201
  /** Runtime-owned artifact provenance retained at this revision. */
208
202
  provenance: Record<StateScope, ArtifactProvenanceRegistry>;
209
- /** True only when this revision directly selected pre-0.4 hashed paths. */
210
- legacyLayout?: true;
211
203
  }
212
204
 
213
205
  export function captureTemporalGitBase(cwd: string, sessionId: string, repositoryRoot: string, sessionKey = sessionId): TemporalGitBase {
@@ -223,64 +215,57 @@ function revisionScopeFiles(read: (path: string) => DurableFileBase, paths: Retu
223
215
  paths,
224
216
  checkpoint: read(paths.checkpoint),
225
217
  patches: read(paths.patches),
226
- legacy: read(resolve(paths.directory, "state.json")),
227
218
  meta: read(paths.meta),
228
219
  };
229
220
  }
230
221
 
231
- /** Cold scope-stream reconstruction; pre-0.4 hashed paths remain read-only revision input. */
222
+ /** Cold scope-stream reconstruction from canonical revision paths only. */
232
223
  export function loadTemporalRevision(cwd: string, sessionId: string, repositoryRoot: string, revision: string, sessionKey = sessionId): TemporalRevisionLoad {
233
224
  const root = assertRepositoryRoot(repositoryRoot);
234
225
  assertReadableRevision(root, revision);
235
226
  const scopePaths = (["global", "cwd", "session"] as const).map((scope) => ({
236
- scope,
237
- canonical: temporalScopePaths(cwd, sessionId, scope, root, sessionKey),
238
- legacy: scope === "global" ? undefined : legacyTemporalScopePaths(cwd, sessionId, scope, root),
227
+ scope, paths: temporalScopePaths(cwd, sessionId, scope, root, sessionKey),
239
228
  }));
240
229
  const canonicalRuntime = sessionRuntimePaths(cwd, sessionId, root, sessionKey);
241
- const legacyRuntime = legacySessionRuntimePaths(cwd, sessionId, root);
242
230
  const readFile = revisionFileReader(root, revision, [
243
- ...scopePaths.flatMap(({ canonical, legacy }) => legacy ? [canonical, legacy] : [canonical])
244
- .flatMap((paths) => [paths.checkpoint, paths.patches, resolve(paths.directory, "state.json"), paths.meta]),
245
- canonicalRuntime.config, canonicalRuntime.meta, legacyRuntime.config, legacyRuntime.meta,
231
+ ...scopePaths.flatMap(({ paths }) => [paths.checkpoint, paths.patches, paths.meta]),
232
+ canonicalRuntime.config, canonicalRuntime.meta,
246
233
  ]);
247
234
  const files: DurableFileBase[] = [];
248
235
  const scopes = {} as Record<StateScope, ScopeStream | undefined>;
249
236
  const provenance: Record<StateScope, ArtifactProvenanceRegistry> = { global: {}, cwd: {}, session: {} };
250
- let legacyLayout = false;
251
- for (const { scope, canonical, legacy } of scopePaths) {
252
- let selected = { ...revisionScopeFiles(readFile, canonical), legacyLayout: false };
253
- if (legacy && [selected.checkpoint, selected.patches, selected.legacy].every(({ identity }) => identity === "missing")) {
254
- selected = { ...revisionScopeFiles(readFile, legacy), legacyLayout: true };
255
- }
256
- if (selected.legacy.identity !== "missing") throw new Error("Historical legacy storage requires explicit migration interpretation");
257
- legacyLayout ||= selected.legacyLayout;
258
- files.push(selected.checkpoint, selected.patches, selected.legacy, ...(scope === "session" ? [] : [selected.meta]));
237
+ for (const { scope, paths } of scopePaths) {
238
+ const selected = revisionScopeFiles(readFile, paths);
239
+ files.push(selected.checkpoint, selected.patches, ...(scope === "session" ? [] : [selected.meta]));
259
240
  if (scope !== "session") provenance[scope] = parseScopeProvenance(selected.meta.content, selected.meta.path);
260
- scopes[scope] = parseScopeStream(selected.checkpoint.content, selected.patches.content, scope,
261
- scope === "cwd" && !selected.legacyLayout ? cwd : undefined);
241
+ try {
242
+ scopes[scope] = parseScopeStream(selected.checkpoint.content, selected.patches.content, scope,
243
+ scope === "cwd" ? cwd : undefined, selected.meta.content);
244
+ } catch (error) {
245
+ throw new Error(`Invalid historical State Flow ${scope} scope`, { cause: error });
246
+ }
262
247
  }
263
248
  const readRuntime = (paths: ReturnType<typeof sessionRuntimePaths>) => ({
264
249
  paths, config: readFile(paths.config), meta: readFile(paths.meta),
265
250
  });
266
- let selectedRuntime = { ...readRuntime(canonicalRuntime), legacyLayout: false };
267
- if (selectedRuntime.config.identity === "missing" && selectedRuntime.meta.identity === "missing") {
268
- selectedRuntime = { ...readRuntime(legacyRuntime), legacyLayout: true };
269
- }
270
- legacyLayout ||= selectedRuntime.legacyLayout;
271
- const { paths: runtimePaths, config, meta } = selectedRuntime;
251
+ const { paths: runtimePaths, config, meta } = readRuntime(canonicalRuntime);
272
252
  files.push(config, meta);
273
253
  const document = parseSessionRuntime(config.content, meta.content, cwd, sessionId);
274
- if (document === undefined) return { base: { head: revision, files }, scopes, provenance, ...(legacyLayout ? { legacyLayout: true as const } : {}) };
254
+ if (document === undefined) return { base: { head: revision, files }, scopes, provenance };
275
255
  provenance.session = parseArtifactProvenanceRegistry(document.meta.artifacts, "State Flow session artifact provenance");
276
256
  const owner = git(root, ["log", "-1", "--format=%H", revision, "--",
277
257
  relativeOwnedPath(runtimePaths.config, root), relativeOwnedPath(runtimePaths.meta, root),
278
258
  ]).stdout.trim();
279
259
  assertReadableRevision(root, owner);
280
- const temporalRevision = document.meta.temporalRevision === undefined || document.meta.temporalRevision === "self"
260
+ let temporalRevision = document.meta.temporalRevision === undefined || document.meta.temporalRevision === "self"
281
261
  ? owner : document.meta.temporalRevision;
282
262
  if (temporalRevision !== revision) {
283
- if (git(root, ["merge-base", "--is-ancestor", temporalRevision, revision], { allowFailure: true }).status !== 0) {
263
+ const ancestor = git(root, ["merge-base", "--is-ancestor", temporalRevision, revision], { allowFailure: true }).status === 0;
264
+ const parents = git(root, ["rev-list", "--parents", "-n", "1", revision]).stdout.trim().split(/\s+/);
265
+ // A store-wide semantic migration intentionally replaces all prior history with one root.
266
+ // Its complete tree owns every scope, so predecessor temporal pointers collapse to self.
267
+ if (!ancestor && parents.length === 1) temporalRevision = owner;
268
+ else if (!ancestor) {
284
269
  throw new Error("Temporal revision must be an ancestor of its runtime owner");
285
270
  }
286
271
  const selected = loadTemporalRevision(cwd, sessionId, root, temporalRevision, sessionKey);
@@ -291,151 +276,7 @@ export function loadTemporalRevision(cwd: string, sessionId: string, repositoryR
291
276
  throw new Error("Session runtime has incomplete temporal scope storage");
292
277
  }
293
278
  validateTemporalState({ lineage: document.meta.lineage, scopes: { global: scopes.global, cwd: scopes.cwd, session: scopes.session } });
294
- return { base: { head: revision, files }, scopes, runtime: { document, revision: owner }, provenance, ...(legacyLayout ? { legacyLayout: true as const } : {}) };
295
- }
296
-
297
- /** Legacy state.json is already current; explanatory journals are irrelevant to semantic recovery. */
298
- export function loadLegacyStatesAtRevision(cwd: string, sessionId: string, repositoryRoot: string, revision: string, sessionKey = sessionId): Record<StateScope, MaterializedState | undefined> {
299
- const root = assertRepositoryRoot(repositoryRoot);
300
- assertReadableRevision(root, revision);
301
- const canonical = {
302
- global: durablePaths(root).globalState,
303
- cwd: cwdScopePaths(cwd, root).state,
304
- session: sessionScopePaths(cwd, sessionId, root, sessionKey).state,
305
- };
306
- const states = {} as Record<StateScope, MaterializedState | undefined>;
307
- for (const scope of ["global", "cwd", "session"] as const) {
308
- let path = canonical[scope];
309
- let source = revisionFile(root, revision, path).content;
310
- if (source === undefined && scope !== "global") {
311
- path = resolve(legacyTemporalScopePaths(cwd, sessionId, scope, root).directory, "state.json");
312
- source = revisionFile(root, revision, path).content;
313
- }
314
- states[scope] = parseStateSource(source, path);
315
- }
316
- return states;
317
- }
318
-
319
- /** Move a current-head draft CWD pair before a new native-named session is initialized. */
320
- export function migrateHashedCwdAtHead(cwd: string, repositoryRoot: string): void {
321
- withPublicationLock(repositoryRoot, (root) => {
322
- const head = currentHead(root);
323
- if (!head) return;
324
- const old = legacyTemporalScopePaths(cwd, "unused", "cwd", root);
325
- const target = temporalScopePaths(cwd, "unused", "cwd", root);
326
- const paths = [old.checkpoint, old.patches, resolve(old.directory, "state.json"), target.checkpoint, target.patches, resolve(target.directory, "state.json")];
327
- const captured = captureOwnedFileBases(paths, root);
328
- const byPath = new Map(captured.map((file) => [file.path, file]));
329
- const oldCheckpoint = byPath.get(old.checkpoint)!;
330
- const oldPatches = byPath.get(old.patches)!;
331
- const oldState = byPath.get(resolve(old.directory, "state.json"))!;
332
- const targetFiles = [byPath.get(target.checkpoint)!, byPath.get(target.patches)!, byPath.get(resolve(target.directory, "state.json"))!];
333
- if (targetFiles[2]!.identity !== "missing") return; // Canonical current-state format migrates in the next owner.
334
- if (targetFiles[0]!.identity !== "missing" || targetFiles[1]!.identity !== "missing") {
335
- parseScopeStream(targetFiles[0]!.content, targetFiles[1]!.content, "cwd", cwd);
336
- return;
337
- }
338
- if (oldCheckpoint.identity === "missing" && oldPatches.identity === "missing" && oldState.identity === "missing") return;
339
- if (oldState.identity !== "missing") throw new Error("Hashed current-state storage requires explicit format migration before path migration");
340
- const oldStream = parseScopeStream(oldCheckpoint.content, oldPatches.content, "cwd")!;
341
- for (const file of [oldCheckpoint, oldPatches]) {
342
- if (revisionFile(root, head, file.path).identity !== file.identity) throw new Error(`Hashed CWD source is not anchored at current HEAD: ${file.path}`);
343
- }
344
- const source = serializeScopeStream(oldStream, "cwd", cwd);
345
- const updates = [
346
- { path: target.checkpoint, content: source.checkpoint }, { path: target.patches, content: source.patches },
347
- { path: old.checkpoint }, { path: old.patches },
348
- ];
349
- try {
350
- publishOwnedCohort(root, updates, captured, head, [], "migrate");
351
- } catch (error) {
352
- try { rmdirSync(target.directory); } catch { /* Preserve nonempty or concurrently used directories. */ }
353
- throw error;
354
- }
355
- try { rmdirSync(old.directory); } catch { /* Draft sessions may remain under this directory. */ }
356
- });
357
- }
358
-
359
- /** Move only the selected current-head draft layout; older branch layouts remain cold read input. */
360
- export function migrateHashedLayoutAtHead(
361
- cwd: string,
362
- sessionId: string,
363
- repositoryRoot: string,
364
- revision: string,
365
- sessionKey: string,
366
- ): (ReturnType<typeof publishOwnedCohort> & { base: TemporalGitBase; view: TemporalState }) | undefined {
367
- return withPublicationLock(repositoryRoot, (root) => {
368
- if (currentHead(root) !== revision) return undefined;
369
- const selected = loadTemporalRevision(cwd, sessionId, root, revision, sessionKey);
370
- if (!selected.legacyLayout || !selected.runtime || !selected.scopes.global || !selected.scopes.cwd || !selected.scopes.session) return undefined;
371
- const view = { lineage: selected.runtime.document.meta.lineage, scopes: {
372
- global: selected.scopes.global, cwd: selected.scopes.cwd, session: selected.scopes.session,
373
- } };
374
- validateTemporalState(view);
375
- const canonical = captureTemporalBaseUnderLock(cwd, sessionId, root, sessionKey);
376
- const legacyLive = captureLegacyTemporalFileBases(cwd, sessionId, root);
377
- const bases = new Map([...canonical.files, ...legacyLive].map((file) => [file.path, file]));
378
- const selectedFiles = new Map(selected.base.files.map((file) => [file.path, file]));
379
- const updates: OwnedFileUpdate[] = [];
380
- const cleanupDirectories = new Set<string>();
381
- const targetDirectories = new Set<string>();
382
- const removeEmptyDirectories = (directories: ReadonlySet<string>): void => {
383
- for (const directory of [...directories].sort((left, right) => right.length - left.length)) {
384
- try { rmdirSync(directory); } catch { /* Preserve nonempty or concurrently used directories. */ }
385
- }
386
- };
387
- const move = (sourcePath: string, targetPath: string, content?: string): void => {
388
- const source = selectedFiles.get(sourcePath);
389
- if (!source || source.identity === "missing" || source.content === undefined) throw new Error(`Hashed-layout migration source is unavailable: ${sourcePath}`);
390
- if (bases.get(sourcePath)?.identity !== source.identity) throw new Error(`Hashed-layout source changed after selected revision: ${sourcePath}`);
391
- if (bases.get(targetPath)?.identity !== "missing") throw new Error(`Canonical State Flow path already exists during hashed-layout migration: ${targetPath}`);
392
- updates.push({ path: targetPath, content: content ?? source.content }, { path: sourcePath });
393
- cleanupDirectories.add(dirname(sourcePath));
394
- targetDirectories.add(dirname(targetPath));
395
- };
396
- for (const scope of ["cwd", "session"] as const) {
397
- const old = legacyTemporalScopePaths(cwd, sessionId, scope, root);
398
- const target = temporalScopePaths(cwd, sessionId, scope, root, sessionKey);
399
- if (selectedFiles.get(old.checkpoint)?.identity !== "missing") {
400
- if (scope === "cwd") {
401
- const stream = parseScopeStream(selectedFiles.get(old.checkpoint)!.content, selectedFiles.get(old.patches)!.content, "cwd")!;
402
- const source = serializeScopeStream(stream, "cwd", cwd);
403
- move(old.checkpoint, target.checkpoint, source.checkpoint);
404
- move(old.patches, target.patches, source.patches);
405
- } else {
406
- move(old.checkpoint, target.checkpoint);
407
- move(old.patches, target.patches);
408
- }
409
- }
410
- }
411
- const oldRuntime = legacySessionRuntimePaths(cwd, sessionId, root);
412
- const targetRuntime = sessionRuntimePaths(cwd, sessionId, root, sessionKey);
413
- const migratedRuntime = structuredClone(selected.runtime.document);
414
- migratedRuntime.meta.temporalRevision = "self";
415
- const runtimeSource = serializeSessionRuntime(migratedRuntime, cwd, sessionId);
416
- if (selectedFiles.get(oldRuntime.config)?.identity !== "missing") {
417
- move(oldRuntime.config, targetRuntime.config, runtimeSource.config);
418
- move(oldRuntime.meta, targetRuntime.meta, runtimeSource.meta);
419
- } else {
420
- for (const [path, content] of [[targetRuntime.config, runtimeSource.config], [targetRuntime.meta, runtimeSource.meta]] as const) {
421
- const source = selectedFiles.get(path);
422
- if (!source || source.identity === "missing" || source.content === undefined) throw new Error(`Layout migration runtime is unavailable: ${path}`);
423
- if (bases.get(path)?.identity !== source.identity) throw new Error(`Layout migration runtime changed after selected revision: ${path}`);
424
- if (source.content !== content) updates.push({ path, content });
425
- }
426
- }
427
- if (updates.length === 0) return undefined;
428
- let publication: ReturnType<typeof publishOwnedCohort>;
429
- try {
430
- publication = publishOwnedCohort(root, updates, [...bases.values()], revision, [], "migrate");
431
- } catch (error) {
432
- removeEmptyDirectories(targetDirectories);
433
- throw error;
434
- }
435
- const nextFiles = temporalFileReceipts(canonical, updates);
436
- removeEmptyDirectories(cleanupDirectories);
437
- return { ...publication, base: { head: publication.commit ?? revision, files: nextFiles }, view };
438
- });
279
+ return { base: { head: revision, files }, scopes, runtime: { document, revision: owner }, provenance };
439
280
  }
440
281
 
441
282
  function relativeOwnedPath(path: string, repositoryRoot: string): string {
@@ -452,6 +293,7 @@ function commitOwnedFiles(
452
293
  expectedHead: string | undefined,
453
294
  scopes: readonly StateScope[],
454
295
  operation: "persist" | "migrate" = "persist",
296
+ rootCommit = false,
455
297
  ): string | undefined {
456
298
  assertOwnedFileUpdates(updates, repositoryRoot);
457
299
  const branchRef = currentBranchRef(repositoryRoot);
@@ -482,13 +324,13 @@ function commitOwnedFiles(
482
324
  // NUL framing preserves literal path characters while the blobs retain exact prepared bytes.
483
325
  if (entries.length > 0) git(repositoryRoot, ["update-index", "-z", "--index-info"], { env, input: entries.join("") });
484
326
  const tree = git(repositoryRoot, ["write-tree"], { env }).stdout.trim();
485
- if (expectedHead !== undefined) {
327
+ if (expectedHead !== undefined && !rootCommit) {
486
328
  const previousTree = git(repositoryRoot, ["rev-parse", `${expectedHead}^{tree}`]).stdout.trim();
487
329
  if (tree === previousTree) return undefined;
488
330
  }
489
331
  const message = `state-flow: ${operation} ${scopes.join("+") || "runtime"} transition\n\n${STATE_FLOW_COMMIT_TRAILER}\n`;
490
332
  const commitArgs = ["commit-tree", tree];
491
- if (expectedHead !== undefined) commitArgs.push("-p", expectedHead);
333
+ if (expectedHead !== undefined && !rootCommit) commitArgs.push("-p", expectedHead);
492
334
  const commit = git(repositoryRoot, commitArgs, { input: message }).stdout.trim();
493
335
  const zero = "0".repeat(40);
494
336
  assertOwnedFileUpdates(updates, repositoryRoot);
@@ -530,7 +372,7 @@ function migrateLegacyStorageUnderLock(cwd: string, sessionId: string, root: str
530
372
  if (current.some((base, index) => base.identity !== plan.bases[index]!.identity)) {
531
373
  throw new Error("State Flow storage changed concurrently while planning migration");
532
374
  }
533
- return { scopes: plan.scopes, ...publishOwnedCohort(root, plan.updates, plan.bases, currentHead(root), plan.scopes, "migrate") };
375
+ return { scopes: plan.scopes, ...publishOwnedCohort(root, plan.updates, plan.bases, currentHead(root), plan.scopes, "migrate", false, true) };
534
376
  }
535
377
 
536
378
  function publishOwnedCohort(
@@ -541,12 +383,13 @@ function publishOwnedCohort(
541
383
  scopes: readonly StateScope[],
542
384
  operation: "persist" | "migrate",
543
385
  push = true,
386
+ rootCommit = false,
544
387
  ): { commit?: string; push?: GitPushResult } {
545
388
  let published = false;
546
389
  try {
547
390
  writeOwnedFileUpdates(updates, bases, root);
548
391
  published = true;
549
- const commit = commitOwnedFiles(root, updates, head, scopes, operation);
392
+ const commit = commitOwnedFiles(root, updates, head, scopes, operation, rootCommit);
550
393
  return commit === undefined ? {} : push ? { commit, push: pushGitCommit(root, commit) } : { commit };
551
394
  } catch (error) {
552
395
  if (published) {
@@ -567,7 +410,8 @@ export function adoptFileStateToGit(cwd: string, sessionId: string, repositoryRo
567
410
  if (snapshot.meta.step !== selected.runtime.meta.step) throw new Error("Git adoption must preserve the semantic step");
568
411
  const runtime = createSessionRuntime(snapshot, cwd, sessionId, selected.view.lineage, "unconfirmed", selected.provenance.session);
569
412
  runtime.meta.temporalRevision = "self";
570
- const sources = serializeSessionRuntime(runtime, cwd, sessionId);
413
+ const sources = serializeSessionRuntime(runtime, cwd, sessionId, selected.view.scopes.session,
414
+ selected.base.files.find(({ path }) => path === sessionRuntimePaths(cwd, sessionId, repositoryRoot, sessionKey).meta)?.content);
571
415
  initializeGitRepository(repositoryRoot);
572
416
  return withPublicationLock(repositoryRoot, (root) => {
573
417
  const current = captureTemporalBaseUnderLock(cwd, sessionId, root, sessionKey);
@@ -1,19 +1,19 @@
1
- // Domain: conservative current-snapshot migration planning; Git owns publication and rollback.
2
- import { randomUUID } from "node:crypto";
3
- import { lstatSync } from "node:fs";
1
+ // Domain: predecessor temporal-envelope migration planning; Git/files backends own publication and rollback.
2
+ import { lstatSync, readFileSync, readdirSync } from "node:fs";
4
3
  import { join, resolve } from "node:path";
5
4
  import {
6
5
  captureOwnedFileBases,
7
6
  cwdScopePaths,
8
7
  parseScopeStream,
9
- parseStateSource,
8
+ serializeScopeMetadata,
10
9
  serializeScopeStream,
10
+ sessionScopeKey,
11
11
  sessionScopePaths,
12
12
  type DurableFileBase,
13
13
  type OwnedFileUpdate,
14
14
  } from "./durable.ts";
15
+ import { canonicalJson } from "./json.ts";
15
16
  import type { StateScope } from "./state.ts";
16
- import type { ScopeStream } from "./temporal.ts";
17
17
 
18
18
  export interface LegacyStorageMigration {
19
19
  bases: DurableFileBase[];
@@ -21,23 +21,66 @@ export interface LegacyStorageMigration {
21
21
  scopes: StateScope[];
22
22
  }
23
23
 
24
+ interface MigrationDirectory {
25
+ directory: string;
26
+ scope: StateScope;
27
+ cwdIdentity?: string;
28
+ }
29
+
30
+ /** Discover every owner-proven CWD and session cohort beneath the configured store. */
31
+ function migrationDirectories(cwd: string, sessionId: string, root: string, sessionKey: string): MigrationDirectory[] {
32
+ const selectedCwd = cwdScopePaths(cwd, root).directory;
33
+ const selectedSession = sessionScopePaths(cwd, sessionId, root, sessionKey).directory;
34
+ const directories: MigrationDirectory[] = [{ directory: root, scope: "global" }];
35
+ const cwdOwners = new Map<string, string>([[selectedCwd, resolve(cwd)]]);
36
+ if (lstatSync(root, { throwIfNoEntry: false }) !== undefined) {
37
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
38
+ if (!entry.isDirectory()) continue;
39
+ const directory = join(root, entry.name);
40
+ let checkpoint: unknown;
41
+ let meta: unknown;
42
+ try { checkpoint = JSON.parse(readFileSync(join(directory, "checkpoint.json"), "utf8")); }
43
+ catch { continue; }
44
+ try { meta = JSON.parse(readFileSync(join(directory, "meta.json"), "utf8")); } catch { /* predecessor metadata may be absent */ }
45
+ const owner = checkpoint && typeof checkpoint === "object" && !Array.isArray(checkpoint) && (checkpoint as { owner?: unknown }).owner
46
+ ? (checkpoint as { owner: { cwd?: unknown } }).owner
47
+ : meta && typeof meta === "object" && !Array.isArray(meta) ? (meta as { owner?: { cwd?: unknown } }).owner : undefined;
48
+ if (owner && typeof owner.cwd === "string" && resolve(owner.cwd) === owner.cwd) cwdOwners.set(directory, owner.cwd);
49
+ }
50
+ }
51
+ for (const [cwdDirectory, cwdOwner] of cwdOwners) {
52
+ directories.push({ directory: cwdDirectory, scope: "cwd", cwdIdentity: cwdOwner });
53
+ if (lstatSync(cwdDirectory, { throwIfNoEntry: false }) === undefined) continue;
54
+ for (const entry of readdirSync(cwdDirectory, { withFileTypes: true })) {
55
+ if (!entry.isDirectory()) continue;
56
+ try { sessionScopeKey(entry.name); } catch { continue; }
57
+ const directory = join(cwdDirectory, entry.name);
58
+ let meta: unknown;
59
+ try { meta = JSON.parse(readFileSync(join(directory, "meta.json"), "utf8")); }
60
+ catch { continue; }
61
+ const identity = meta && typeof meta === "object" && !Array.isArray(meta) ? (meta as { identity?: unknown }).identity : undefined;
62
+ if (!identity || typeof identity !== "object" || Array.isArray(identity)) continue;
63
+ const owner = identity as { cwd?: unknown; sessionId?: unknown };
64
+ if (owner.cwd === cwdOwner && typeof owner.sessionId === "string" && owner.sessionId.length > 0) directories.push({ directory, scope: "session" });
65
+ }
66
+ }
67
+ if (!directories.some(({ directory }) => directory === selectedSession)) directories.push({ directory: selectedSession, scope: "session" });
68
+ return directories;
69
+ }
70
+
24
71
  /** Read-only activation eligibility, before global migration or any repository publication. */
25
72
  export function hasCwdMaterialization(cwd: string, repositoryRoot: string): boolean {
26
73
  const root = resolve(repositoryRoot);
27
74
  const directory = cwdScopePaths(cwd, root).directory;
28
- const [legacy, checkpoint, patches] = captureOwnedFileBases([
29
- join(directory, "state.json"), join(directory, "checkpoint.json"), join(directory, "patches.jsonl"),
75
+ const [checkpoint, patches, meta] = captureOwnedFileBases([
76
+ join(directory, "checkpoint.json"), join(directory, "patches.jsonl"), join(directory, "meta.json"),
30
77
  ], root);
31
- if (checkpoint!.content !== undefined) {
32
- if (legacy!.content !== undefined) throw new Error(`Ambiguous State Flow storage has both current and checkpoint snapshots: ${directory}`);
33
- return parseScopeStream(checkpoint!.content, patches!.content, "cwd", cwd) !== undefined;
34
- }
35
- if (legacy!.content !== undefined) return parseStateSource(legacy!.content, legacy!.path) !== undefined;
36
- if (patches!.content !== undefined) throw new Error(`State Flow tail has no provable snapshot: ${directory}`);
78
+ if (checkpoint!.content !== undefined) return parseScopeStream(checkpoint!.content, patches!.content, "cwd", cwd, meta!.content) !== undefined;
79
+ if (patches!.content !== undefined) throw new Error(`State Flow tail has no provable checkpoint: ${directory}`);
37
80
  return false;
38
81
  }
39
82
 
40
- /** Cheap canonical-layout fast path: inspect only the three predecessor snapshot names. */
83
+ /** Detect predecessor snapshots or temporal envelopes without mutating the store. */
41
84
  export function hasLegacyStateSources(
42
85
  cwd: string,
43
86
  sessionId: string,
@@ -45,11 +88,13 @@ export function hasLegacyStateSources(
45
88
  sessionKey = sessionId,
46
89
  ): boolean {
47
90
  const root = resolve(repositoryRoot);
48
- return [
49
- join(root, "state.json"),
50
- join(cwdScopePaths(cwd, root).directory, "state.json"),
51
- join(sessionScopePaths(cwd, sessionId, root, sessionKey).directory, "state.json"),
52
- ].some((path) => lstatSync(path, { throwIfNoEntry: false }) !== undefined);
91
+ return migrationDirectories(cwd, sessionId, root, sessionKey).some(({ directory }) => {
92
+ if (lstatSync(join(directory, "checkpoint.json"), { throwIfNoEntry: false }) === undefined) return false;
93
+ try {
94
+ const meta = JSON.parse(readFileSync(join(directory, "meta.json"), "utf8"));
95
+ return meta === null || typeof meta !== "object" || !("temporal" in meta);
96
+ } catch { return true; }
97
+ });
53
98
  }
54
99
 
55
100
  /** Plan from current scope snapshots only; old explanatory journals are never replay input. */
@@ -57,48 +102,39 @@ export function planLegacyStorageMigration(
57
102
  cwd: string,
58
103
  sessionId: string,
59
104
  repositoryRoot: string,
60
- origin: string = randomUUID(),
105
+ _origin?: string,
61
106
  sessionKey = sessionId,
62
107
  ): LegacyStorageMigration {
63
108
  const root = resolve(repositoryRoot);
64
- const directories: Record<StateScope, string> = {
65
- global: root,
66
- cwd: cwdScopePaths(cwd, root).directory,
67
- session: sessionScopePaths(cwd, sessionId, root, sessionKey).directory,
68
- };
69
- const paths = Object.values(directories).flatMap((directory) => [
70
- join(directory, "state.json"), join(directory, "checkpoint.json"), join(directory, "patches.jsonl"),
109
+ const directories = migrationDirectories(cwd, sessionId, root, sessionKey);
110
+ const paths = directories.flatMap(({ directory }) => [
111
+ join(directory, "checkpoint.json"), join(directory, "patches.jsonl"), join(directory, "meta.json"),
71
112
  ]);
72
113
  const bases = captureOwnedFileBases(paths, root);
73
114
  const byPath = new Map(bases.map((base) => [base.path, base]));
74
115
  const updates: OwnedFileUpdate[] = [];
75
116
  const scopes: StateScope[] = [];
76
- for (const scope of ["global", "cwd", "session"] as const) {
77
- const directory = directories[scope];
78
- const legacy = byPath.get(join(directory, "state.json"))!;
117
+ for (const { scope, directory, cwdIdentity } of directories) {
79
118
  const checkpoint = byPath.get(join(directory, "checkpoint.json"))!;
80
119
  const patches = byPath.get(join(directory, "patches.jsonl"))!;
120
+ const meta = byPath.get(join(directory, "meta.json"))!;
81
121
  if (checkpoint.content !== undefined) {
82
- if (legacy.content !== undefined) throw new Error(`Ambiguous State Flow storage has both current and checkpoint snapshots: ${directory}`);
83
- parseScopeStream(checkpoint.content, patches.content, scope, scope === "cwd" ? cwd : undefined);
84
- continue;
85
- }
86
- if (legacy.content === undefined) {
87
- if (patches.content !== undefined) throw new Error(`State Flow tail has no provable snapshot: ${directory}`);
122
+ const stream = parseScopeStream(checkpoint.content, patches.content, scope, cwdIdentity, meta.content)!;
123
+ const source = serializeScopeStream(stream, scope, cwdIdentity);
124
+ let metadata = serializeScopeMetadata(undefined, stream, scope, cwdIdentity, meta.content);
125
+ if (scope === "session") {
126
+ const runtime = JSON.parse(metadata) as Record<string, unknown>;
127
+ runtime.revision = "self";
128
+ runtime.temporalRevision = "self";
129
+ metadata = `${canonicalJson(runtime)}\n`;
130
+ }
131
+ if (checkpoint.content !== source.checkpoint || patches.content !== source.patches || meta.content !== metadata) {
132
+ if (!scopes.includes(scope)) scopes.push(scope);
133
+ updates.push({ path: checkpoint.path, content: source.checkpoint }, { path: patches.path, content: source.patches }, { path: meta.path, content: metadata });
134
+ }
88
135
  continue;
89
136
  }
90
- const state = parseStateSource(legacy.content, legacy.path)!;
91
- const stream: ScopeStream = {
92
- checkpoint: { through: { id: origin, position: 0, parent: null }, state },
93
- patches: [],
94
- };
95
- const source = serializeScopeStream(stream, scope, scope === "cwd" ? cwd : undefined);
96
- scopes.push(scope);
97
- updates.push(
98
- { path: checkpoint.path, content: source.checkpoint },
99
- { path: patches.path, content: source.patches },
100
- { path: legacy.path },
101
- );
137
+ if (patches.content !== undefined) throw new Error(`State Flow tail has no provable checkpoint: ${directory}`);
102
138
  }
103
139
  return { bases, updates, scopes };
104
140
  }
@@ -0,0 +1,40 @@
1
+ import { projectStateForModel, type MaterializedState, type ScopePatch, type StateScope } from "./state.ts";
2
+ import { readTemporalState, type TemporalState, type TransitionBoundary } from "./temporal.ts";
3
+
4
+ export type StateReadQuery =
5
+ | { kind: "state"; path: string; offset: number; scope?: StateScope }
6
+ | { kind: "patch"; path: string; offset: number; scope: StateScope };
7
+
8
+ export type StateReadResult =
9
+ | { path: string; boundary: TransitionBoundary; state: MaterializedState }
10
+ | { path: string; boundary: TransitionBoundary; patch: ScopePatch & { response?: string } };
11
+
12
+ const PATH_PATTERN = /^state(?:\[(\d+)\])?(?:\.(global|cwd|session)(?:\[(\d+)\])?(?:\.patches(?:\[(\d+)\])?)?)?$/;
13
+
14
+ /** Resolve the compact model-facing path grammar without treating aliases as literal JSON containers. */
15
+ export function parseStateReadPath(path: string): StateReadQuery {
16
+ const match = PATH_PATTERN.exec(path);
17
+ if (!match) throw new Error("Invalid State Flow read path");
18
+ const [, effectiveOffset, scope, scopeOffset, patchOffset] = match;
19
+ if (effectiveOffset !== undefined && scope !== undefined) throw new Error("State Flow read path cannot index both state and a scope");
20
+ if (path.includes(".patches") && scopeOffset !== undefined) throw new Error("Index patches after .patches, not after the scope");
21
+ const rawOffset = patchOffset ?? scopeOffset ?? effectiveOffset ?? "0";
22
+ const offset = Number(rawOffset);
23
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > 7) throw new Error("State Flow read path index must be an integer from 0 to 7");
24
+ if (patchOffset !== undefined || path.endsWith(".patches")) {
25
+ return { kind: "patch", path, offset, scope: scope as StateScope };
26
+ }
27
+ return { kind: "state", path, offset, ...(scope === undefined ? {} : { scope: scope as StateScope }) };
28
+ }
29
+
30
+ export function readStatePath(view: TemporalState, path: string): StateReadResult {
31
+ const query = parseStateReadPath(path);
32
+ if (query.kind === "state") {
33
+ const boundary = view.lineage[view.lineage.length - 1 - query.offset];
34
+ if (!boundary) throw new Error("Requested history predates the proven temporal origin");
35
+ return { path, boundary: structuredClone(boundary), state: projectStateForModel(readTemporalState(view, query.offset, query.scope)) };
36
+ }
37
+ const record = view.scopes[query.scope].patches.at(-1 - query.offset);
38
+ if (!record) throw new Error(`Requested ${query.scope} patch predates retained hot history`);
39
+ return { path, boundary: structuredClone(record.transition), patch: structuredClone(record.patch) };
40
+ }
@@ -6,49 +6,26 @@ export interface SnapshotRecovery {
6
6
  disabledMarker?: true;
7
7
  }
8
8
 
9
- function failureMessage(snapshot: Snapshot): string | undefined {
10
- return !snapshot.config.enabled && snapshot.meta.validation?.attempt === 0
11
- ? snapshot.meta.validation.error
12
- : undefined;
13
- }
14
-
15
- /** Recover the newest valid snapshot, falling back through the active branch. */
16
- export function recoverSnapshot(candidates: readonly unknown[], resolveRevision?: (revision: string, legacy?: Snapshot) => Snapshot): SnapshotRecovery {
9
+ /** Recover the newest supported pointer or disabled marker from the active branch. */
10
+ export function recoverSnapshot(candidates: readonly unknown[], resolveRevision?: (revision: string) => Snapshot): SnapshotRecovery {
17
11
  const skipped: string[] = [];
18
- let newestFailure: Snapshot | undefined;
19
12
  for (const candidate of candidates) {
20
- let migrated: Snapshot;
21
13
  let selectedRevision: string | undefined;
22
14
  try {
23
15
  const parsed = parsePiCheckpoint(candidate);
24
- if ("revision" in parsed) {
25
- selectedRevision = parsed.revision;
26
- if (!resolveRevision) throw new Error("Checkpoint pointer requires immutable runtime resolution");
27
- return { snapshot: resolveRevision(parsed.revision), skipped };
28
- }
29
16
  if ("disabled" in parsed) return { snapshot: emptySnapshot(), skipped, disabledMarker: true };
30
- migrated = parsed;
31
- if (failureMessage(migrated) === undefined && migrated.meta.durableBase) {
32
- selectedRevision = migrated.meta.durableBase;
33
- if (!resolveRevision) throw new Error("Legacy checkpoint requires immutable runtime resolution");
34
- return { snapshot: resolveRevision(migrated.meta.durableBase, migrated), skipped };
35
- }
17
+ selectedRevision = parsed.revision;
18
+ if (!resolveRevision) throw new Error("Checkpoint pointer requires immutable runtime resolution");
19
+ return { snapshot: resolveRevision(parsed.revision), skipped };
36
20
  } catch (error) {
37
21
  if (selectedRevision && error instanceof RevisionUnavailableError) {
38
22
  return { snapshot: migrationFailure({ meta: { durableBase: selectedRevision } }, `Snapshot restoration failed: ${error.message}`), skipped };
39
23
  }
40
- migrated = migrationFailure(
41
- {},
42
- `Snapshot restoration failed: ${error instanceof Error ? error.message : String(error)}`,
43
- );
24
+ skipped.push(`Snapshot restoration failed: ${error instanceof Error ? error.message : String(error)}`);
44
25
  }
45
- const failure = failureMessage(migrated);
46
- if (failure === undefined) return { snapshot: migrated, skipped };
47
- newestFailure ??= migrated;
48
- skipped.push(failure);
49
26
  }
50
27
  return {
51
- snapshot: newestFailure ?? emptySnapshot(),
28
+ snapshot: migrationFailure({}, skipped[0] ?? "Snapshot restoration failed: no supported checkpoint"),
52
29
  skipped,
53
30
  };
54
31
  }