@llblab/pi-kit 0.7.1 → 0.8.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 (32) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -1
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +21 -21
  4. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +3 -122
  5. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +11 -0
  6. package/node_modules/@llblab/pi-state-flow/README.md +41 -35
  7. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +36 -16
  8. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +4 -4
  9. package/node_modules/@llblab/pi-state-flow/index.ts +23 -1
  10. package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +3 -0
  11. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +191 -29
  12. package/node_modules/@llblab/pi-state-flow/lib/config.ts +6 -1
  13. package/node_modules/@llblab/pi-state-flow/lib/context.ts +42 -7
  14. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +38 -8
  15. package/node_modules/@llblab/pi-state-flow/lib/episode.ts +1 -13
  16. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +290 -134
  17. package/node_modules/@llblab/pi-state-flow/lib/git.ts +32 -16
  18. package/node_modules/@llblab/pi-state-flow/lib/logging.ts +41 -0
  19. package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +12 -6
  20. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +16 -0
  21. package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +3 -0
  22. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +167 -31
  23. package/node_modules/@llblab/pi-state-flow/lib/skills.ts +15 -6
  24. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +40 -34
  25. package/node_modules/@llblab/pi-state-flow/lib/state.ts +6 -0
  26. package/node_modules/@llblab/pi-state-flow/lib/status.ts +4 -9
  27. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +25 -5
  28. package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +17 -147
  29. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +49 -27
  30. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  31. package/package.json +2 -2
  32. package/node_modules/@llblab/pi-state-flow/lib/validation.ts +0 -27
@@ -14,6 +14,7 @@ import {
14
14
  legacyTemporalScopePaths,
15
15
  durablePaths,
16
16
  isStateFlowOwnedPath,
17
+ parseScopeProvenance,
17
18
  parseScopeStream,
18
19
  parseStateSource,
19
20
  serializeScopeStream,
@@ -25,6 +26,7 @@ import {
25
26
  type DurableFileBase,
26
27
  type OwnedFileUpdate,
27
28
  } from "./durable.ts";
29
+ import { parseArtifactProvenanceRegistry, type ArtifactProvenanceRegistry } from "./artifact.ts";
28
30
  import { planLegacyStorageMigration } from "./migration.ts";
29
31
  import { sameJson } from "./json.ts";
30
32
  import { validateTemporalState, type ScopeStream, type TemporalState } from "./temporal.ts";
@@ -176,6 +178,8 @@ export interface TemporalRevisionLoad {
176
178
  base: TemporalGitBase;
177
179
  scopes: Record<StateScope, ScopeStream | undefined>;
178
180
  runtime?: { document: SessionRuntime; revision: string };
181
+ /** Runtime-owned artifact provenance retained at this revision. */
182
+ provenance: Record<StateScope, ArtifactProvenanceRegistry>;
179
183
  /** True only when this revision directly selected pre-0.4 hashed paths. */
180
184
  legacyLayout?: true;
181
185
  }
@@ -194,6 +198,7 @@ function revisionScopeFiles(root: string, revision: string, cwd: string, session
194
198
  checkpoint: revisionFile(root, revision, paths.checkpoint),
195
199
  patches: revisionFile(root, revision, paths.patches),
196
200
  legacy: revisionFile(root, revision, resolve(paths.directory, "state.json")),
201
+ meta: revisionFile(root, revision, paths.meta),
197
202
  });
198
203
  const canonical = read(temporalScopePaths(cwd, sessionId, scope, root, sessionKey));
199
204
  if (scope === "global" || [canonical.checkpoint, canonical.patches, canonical.legacy].some(({ identity }) => identity !== "missing")) return { ...canonical, legacyLayout: false };
@@ -206,12 +211,14 @@ export function loadTemporalRevision(cwd: string, sessionId: string, repositoryR
206
211
  assertReadableRevision(root, revision);
207
212
  const files: DurableFileBase[] = [];
208
213
  const scopes = {} as Record<StateScope, ScopeStream | undefined>;
214
+ const provenance: Record<StateScope, ArtifactProvenanceRegistry> = { global: {}, cwd: {}, session: {} };
209
215
  let legacyLayout = false;
210
216
  for (const scope of ["global", "cwd", "session"] as const) {
211
217
  const selected = revisionScopeFiles(root, revision, cwd, sessionId, sessionKey, scope);
212
218
  if (selected.legacy.identity !== "missing") throw new Error("Historical legacy storage requires explicit migration interpretation");
213
219
  legacyLayout ||= selected.legacyLayout;
214
- files.push(selected.checkpoint, selected.patches, selected.legacy);
220
+ files.push(selected.checkpoint, selected.patches, selected.legacy, ...(scope === "session" ? [] : [selected.meta]));
221
+ if (scope !== "session") provenance[scope] = parseScopeProvenance(selected.meta.content, selected.meta.path);
215
222
  scopes[scope] = parseScopeStream(selected.checkpoint.content, selected.patches.content, scope,
216
223
  scope === "cwd" && !selected.legacyLayout ? cwd : undefined);
217
224
  }
@@ -226,7 +233,8 @@ export function loadTemporalRevision(cwd: string, sessionId: string, repositoryR
226
233
  const { paths: runtimePaths, config, meta } = selectedRuntime;
227
234
  files.push(config, meta);
228
235
  const document = parseSessionRuntime(config.content, meta.content, cwd, sessionId);
229
- if (document === undefined) return { base: { head: revision, files }, scopes, ...(legacyLayout ? { legacyLayout: true as const } : {}) };
236
+ if (document === undefined) return { base: { head: revision, files }, scopes, provenance, ...(legacyLayout ? { legacyLayout: true as const } : {}) };
237
+ provenance.session = parseArtifactProvenanceRegistry(document.meta.artifacts, "State Flow session artifact provenance");
230
238
  const owner = git(root, ["log", "-1", "--format=%H", revision, "--",
231
239
  relativeOwnedPath(runtimePaths.config, root), relativeOwnedPath(runtimePaths.meta, root),
232
240
  ]).stdout.trim();
@@ -244,7 +252,7 @@ export function loadTemporalRevision(cwd: string, sessionId: string, repositoryR
244
252
  throw new Error("Session runtime has incomplete temporal scope storage");
245
253
  }
246
254
  validateTemporalState({ lineage: document.meta.lineage, scopes: { global: scopes.global, cwd: scopes.cwd, session: scopes.session } });
247
- return { base: { head: revision, files }, scopes, runtime: { document, revision: owner }, ...(legacyLayout ? { legacyLayout: true as const } : {}) };
255
+ return { base: { head: revision, files }, scopes, runtime: { document, revision: owner }, provenance, ...(legacyLayout ? { legacyLayout: true as const } : {}) };
248
256
  }
249
257
 
250
258
  /** Legacy state.json is already current; explanatory journals are irrelevant to semantic recovery. */
@@ -418,16 +426,17 @@ function commitOwnedFiles(
418
426
  try {
419
427
  if (expectedHead === undefined) git(repositoryRoot, ["read-tree", "--empty"], { env });
420
428
  else git(repositoryRoot, ["read-tree", expectedHead], { env });
421
- const entries: Array<{ relativePath: string; blob?: string }> = [];
429
+ // A State Flow commit carries the complete non-ignored worktree delta, including user edits
430
+ // and manual deletions, while `.gitignore` stays authoritative for untracked files. The
431
+ // transient publication lock is ours, not repository content.
432
+ git(repositoryRoot, ["add", "-A", "--", ".", ":(exclude).state-flow-publication.lock"], { env });
422
433
  for (const update of updates) {
423
434
  const relativePath = relativeOwnedPath(update.path, repositoryRoot);
424
435
  if (update.content === undefined) {
425
- entries.push({ relativePath });
426
436
  git(repositoryRoot, ["update-index", "--force-remove", "--", relativePath], { env });
427
437
  continue;
428
438
  }
429
439
  const blob = git(repositoryRoot, ["hash-object", "-w", "--stdin"], { input: update.content }).stdout.trim();
430
- entries.push({ relativePath, blob });
431
440
  git(repositoryRoot, ["update-index", "--add", "--cacheinfo", `100644,${blob},${relativePath}`], { env });
432
441
  }
433
442
  const tree = git(repositoryRoot, ["write-tree"], { env }).stdout.trim();
@@ -443,10 +452,9 @@ function commitOwnedFiles(
443
452
  assertOwnedFileUpdates(updates, repositoryRoot);
444
453
  git(repositoryRoot, ["update-ref", branchRef, commit, expectedHead ?? zero]);
445
454
  try {
446
- const indexInfo = entries.map(({ relativePath, blob }) => blob === undefined
447
- ? `0 ${zero}\t${relativePath}\n`
448
- : `100644 ${blob}\t${relativePath}\n`).join("");
449
- git(repositoryRoot, ["update-index", "--index-info"], { input: indexInfo });
455
+ // Align the caller-visible index with the committed tree so advancing HEAD through the
456
+ // isolated index leaves no artificial staged/unstaged status entries behind.
457
+ git(repositoryRoot, ["read-tree", commit]);
450
458
  } catch (error) {
451
459
  git(repositoryRoot, ["update-ref", branchRef, expectedHead ?? zero, commit]);
452
460
  throw error;
@@ -512,10 +520,10 @@ function publishOwnedCohort(
512
520
  }
513
521
 
514
522
  /** Explicit current-file adoption, not reconstruction or invention of pre-Git history. */
515
- export function adoptFileStateToGit(cwd: string, sessionId: string, repositoryRoot: string, revision: string, snapshot: Snapshot, sessionKey = sessionId) {
523
+ export function adoptFileStateToGit(cwd: string, sessionId: string, repositoryRoot: string, revision: string, snapshot: Snapshot, sessionKey = sessionId, push = true) {
516
524
  const selected = loadTemporalFileRevision(cwd, sessionId, repositoryRoot, revision, sessionKey);
517
525
  if (snapshot.meta.step !== selected.runtime.meta.step) throw new Error("Git adoption must preserve the semantic step");
518
- const runtime = createSessionRuntime(snapshot, cwd, sessionId, selected.view.lineage);
526
+ const runtime = createSessionRuntime(snapshot, cwd, sessionId, selected.view.lineage, "unconfirmed", selected.provenance.session);
519
527
  runtime.meta.temporalRevision = "self";
520
528
  const sources = serializeSessionRuntime(runtime, cwd, sessionId);
521
529
  initializeGitRepository(repositoryRoot);
@@ -523,8 +531,14 @@ export function adoptFileStateToGit(cwd: string, sessionId: string, repositoryRo
523
531
  const current = captureTemporalBaseUnderLock(cwd, sessionId, root, sessionKey);
524
532
  assertTemporalFileBase(selected.base, current);
525
533
  const paths = sessionRuntimePaths(cwd, sessionId, root, sessionKey);
534
+ const provenancePaths = new Set([
535
+ temporalScopePaths(cwd, sessionId, "global", root, sessionKey).meta,
536
+ temporalScopePaths(cwd, sessionId, "cwd", root, sessionKey).meta,
537
+ ]);
526
538
  // Preserve exact valid scope bytes; only runtime provenance changes representation.
527
- const updates = current.files.filter(({ path }) => path.endsWith("checkpoint.json") || path.endsWith("patches.jsonl"))
539
+ const updates = current.files.filter(({ path, identity }) => path.endsWith("checkpoint.json")
540
+ || path.endsWith("patches.jsonl")
541
+ || (provenancePaths.has(path) && identity !== "missing"))
528
542
  .map(({ path, content }) => ({ path, content: content! }));
529
543
  updates.push({ path: paths.config, content: sources.config }, { path: paths.meta, content: sources.meta });
530
544
  const existing = current.head && updates.every(({ path, content }) => revisionFile(root, current.head!, path).content === content)
@@ -532,9 +546,10 @@ export function adoptFileStateToGit(cwd: string, sessionId: string, repositoryRo
532
546
  if (existing && (!existing.runtime || !sameJson({ lineage: existing.runtime.document.meta.lineage, scopes: existing.scopes }, selected.view))) {
533
547
  throw new Error("Existing Git runtime does not anchor the selected file cohort");
534
548
  }
535
- const publication = publishOwnedCohort(root, updates, current.files, current.head, [], "persist");
549
+ const publication = publishOwnedCohort(root, updates, current.files, current.head, [], "persist", push);
536
550
  const target = publication.commit ?? existing!.runtime!.revision;
537
- return { ...publication, revision: target, push: publication.push ?? pushGitCommit(root, target), view: selected.view,
551
+ const remote = push ? publication.push ?? pushGitCommit(root, target) : undefined;
552
+ return { ...publication, revision: target, ...(remote === undefined ? {} : { push: remote }), view: selected.view, provenance: selected.provenance,
538
553
  base: { head: publication.commit ?? current.head, files: temporalFileReceipts(current, updates) } };
539
554
  });
540
555
  }
@@ -572,6 +587,7 @@ export function publishTemporalStateToGit(
572
587
  runtime?: SessionRuntime,
573
588
  sessionKey = sessionId,
574
589
  push = true,
590
+ provenance?: Readonly<Record<StateScope, ArtifactProvenanceRegistry>>,
575
591
  ): { base: TemporalGitBase; commit?: string; push?: GitPushResult } {
576
592
  return withPublicationLock(repositoryRoot, (root) => {
577
593
  const current = captureTemporalBaseUnderLock(cwd, sessionId, root, sessionKey);
@@ -587,7 +603,7 @@ export function publishTemporalStateToGit(
587
603
  const selected = loadTemporalRevision(cwd, sessionId, root, runtime!.meta.temporalRevision!, sessionKey);
588
604
  if (!sameJson(selected.scopes, view.scopes)) throw new Error("Runtime temporal reference does not match selected streams");
589
605
  }
590
- const { updates, changedScopes } = planTemporalPublication(cwd, sessionId, view, scopes, current, root, runtime, runtimeOnly, sessionKey);
606
+ const { updates, changedScopes } = planTemporalPublication(cwd, sessionId, view, scopes, current, root, runtime, runtimeOnly, sessionKey, provenance);
591
607
  if (!runtimeOnly) includeUncommittedCohort(cwd, sessionId, root, current, updates, changedScopes, scopes, runtime !== undefined, sessionKey);
592
608
  if (updates.length === 0) return { base: current };
593
609
  const publication = publishOwnedCohort(root, updates, current.files, current.head, changedScopes, "persist", push);
@@ -0,0 +1,41 @@
1
+ // Domain: opt-in diagnostic capture for rejected State Flow resolutions.
2
+ import { appendFileSync, mkdirSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { isObject } from "./json.ts";
5
+
6
+ export type StateFlowDiagnosticCategory = "invalid-patch" | "publication-conflict" | "terminal-pending" | "finalization";
7
+
8
+ /** Minimal structural block; only ordinary text keeps its exact content. */
9
+ export interface StateFlowDiagnosticBlock {
10
+ type: string;
11
+ text?: string;
12
+ }
13
+
14
+ export interface StateFlowDiagnosticRecord {
15
+ at: string;
16
+ sessionId: string;
17
+ cwd: string;
18
+ category: StateFlowDiagnosticCategory;
19
+ error: string;
20
+ content?: StateFlowDiagnosticBlock[];
21
+ }
22
+
23
+ /** Preserve exact text blocks and block boundaries; reasoning bodies are never duplicated. */
24
+ export function projectDiagnosticContent(content: unknown): StateFlowDiagnosticBlock[] {
25
+ if (!Array.isArray(content)) return [];
26
+ return content.map((block) => {
27
+ if (!isObject(block) || typeof block.type !== "string") return { type: "unknown" };
28
+ if (block.type === "text" && typeof block.text === "string") return { type: "text", text: block.text };
29
+ return { type: block.type };
30
+ });
31
+ }
32
+
33
+ /** Diagnostic JSONL lives beneath the active Pi agent directory, never inside the state repository. */
34
+ export function stateFlowLogPath(agentDir: string): string {
35
+ return join(agentDir, "tmp", "state-flow", "logs.jsonl");
36
+ }
37
+
38
+ export function appendStateFlowDiagnostic(path: string, record: StateFlowDiagnosticRecord): void {
39
+ mkdirSync(dirname(path), { recursive: true });
40
+ appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
41
+ }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  classifyArtifactFreshness,
3
3
  type ArtifactMetadata,
4
+ type ArtifactProvenance,
4
5
  type ArtifactRegistry,
5
6
  type ArtifactSourceIdentity,
6
7
  } from "./artifact.ts";
@@ -55,10 +56,13 @@ function cycleTime(value: Date | number | string | undefined): number {
55
56
  return timestamp;
56
57
  }
57
58
 
58
- function compiledTime(metadata: ArtifactMetadata): number {
59
- if (typeof metadata.compiled_at !== "string") return Number.NEGATIVE_INFINITY;
60
- const timestamp = Date.parse(metadata.compiled_at);
61
- return Number.isFinite(timestamp) ? timestamp : Number.NEGATIVE_INFINITY;
59
+ function compiledTime(entry: ArtifactProvenance | undefined, legacy: unknown): number {
60
+ const timestamp = entry?.malformed === true ? undefined
61
+ : entry !== undefined && Object.hasOwn(entry, "compiledAt") ? entry.compiledAt
62
+ : legacy;
63
+ if (typeof timestamp !== "string") return Number.NEGATIVE_INFINITY;
64
+ const parsed = Date.parse(timestamp);
65
+ return Number.isFinite(parsed) ? parsed : Number.NEGATIVE_INFINITY;
62
66
  }
63
67
 
64
68
  function request(source: ArtifactSourceCandidate): ArtifactMaintenanceRequest {
@@ -81,6 +85,7 @@ export function planArtifactMaintenance(
81
85
  registry: Readonly<Record<string, unknown>>,
82
86
  compiler: string,
83
87
  options: ArtifactMaintenanceOptions = {},
88
+ provenance: Readonly<Record<string, ArtifactProvenance>> = {},
84
89
  ): ArtifactMaintenancePlan {
85
90
  if (!isObject(registry)) throw new Error("Artifacts must be a path-keyed JSON object");
86
91
  const now = cycleTime(options.now);
@@ -104,9 +109,10 @@ export function planArtifactMaintenance(
104
109
  seen.add(source.path);
105
110
  nonNegativeSafeInteger(source.bytes, `Artifact source bytes at ${source.path}`);
106
111
  const metadata = Object.hasOwn(registry, source.path) ? registry[source.path] : undefined;
107
- const freshness = classifyArtifactFreshness(source, metadata, compiler);
112
+ const entry = Object.hasOwn(provenance, source.path) ? provenance[source.path] : undefined;
113
+ const freshness = classifyArtifactFreshness(source, metadata, compiler, false, entry);
108
114
  if (freshness.kind !== "fresh") continue;
109
- const compiledAt = compiledTime(metadata as ArtifactMetadata);
115
+ const compiledAt = compiledTime(entry, isObject(metadata) ? (metadata as ArtifactMetadata).compiled_at : undefined);
110
116
  if (compiledAt !== Number.NEGATIVE_INFINITY && now - compiledAt < minimumAgeMs) continue;
111
117
  eligible.push({ source, compiledAt });
112
118
  }
@@ -1,5 +1,6 @@
1
1
  // Domain: conservative current-snapshot migration planning; Git owns publication and rollback.
2
2
  import { randomUUID } from "node:crypto";
3
+ import { lstatSync } from "node:fs";
3
4
  import { join, resolve } from "node:path";
4
5
  import {
5
6
  captureOwnedFileBases,
@@ -36,6 +37,21 @@ export function hasCwdMaterialization(cwd: string, repositoryRoot: string): bool
36
37
  return false;
37
38
  }
38
39
 
40
+ /** Cheap canonical-layout fast path: inspect only the three predecessor snapshot names. */
41
+ export function hasLegacyStateSources(
42
+ cwd: string,
43
+ sessionId: string,
44
+ repositoryRoot: string,
45
+ sessionKey = sessionId,
46
+ ): boolean {
47
+ 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);
53
+ }
54
+
39
55
  /** Plan from current scope snapshots only; old explanatory journals are never replay input. */
40
56
  export function planLegacyStorageMigration(
41
57
  cwd: string,
@@ -12,6 +12,8 @@ export interface RehydrationRoute {
12
12
  scope: StateScope;
13
13
  source: ArtifactSourceIdentity;
14
14
  metadata: unknown;
15
+ /** Runtime-owned freshness evidence retained beside the semantic artifact. */
16
+ provenance?: unknown;
15
17
  compiler: string;
16
18
  intent: ArtifactAcquisitionIntent;
17
19
  materializedSufficient?: boolean;
@@ -58,6 +60,7 @@ export function planKnowledgeRehydration(
58
60
  intent: route.intent,
59
61
  ...(route.materializedSufficient === undefined ? {} : { materializedSufficient: route.materializedSufficient }),
60
62
  ...(route.explicitRefresh === undefined ? {} : { explicitRefresh: route.explicitRefresh }),
63
+ ...(route.provenance === undefined ? {} : { provenance: route.provenance }),
61
64
  });
62
65
  if (decision.kind === "use-materialized") {
63
66
  materialized.push(route.source.path);
@@ -1,19 +1,38 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { lstatSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { parseScopeStream, sessionRuntimePaths, temporalScopePaths, type SessionAddress } from "./durable.ts";
4
+ import { parseScopeProvenance, parseScopeStream, sessionRuntimePaths, temporalScopePaths, type SessionAddress } from "./durable.ts";
5
+ import { parseArtifactProvenanceRegistry, pruneArtifactProvenance, type ArtifactProvenance, type ArtifactProvenanceRegistry } from "./artifact.ts";
5
6
  import { adoptFileStateToGit, initializeGitRepository, isLocalGitRepository, captureTemporalGitBase, loadLegacyStatesAtRevision, loadTemporalRevision, migrateHashedCwdAtHead, migrateHashedLayoutAtHead, migrateLegacyStorageToGit, publishTemporalStateToGit, type TemporalGitBase } from "./git.ts";
6
- import { captureTemporalFileBase, detectGitCapability, initializeFileStore, loadTemporalFileRevision, migrateLegacyStorageToFiles, publishTemporalStateToFiles } from "./storage.ts";
7
+ import { captureTemporalFileBase, detectGitCapability, initializeFileStore, loadTemporalFileRevision, migrateLegacyStorageToFiles, publishTemporalStateToFiles, type TemporalFileBase } from "./storage.ts";
7
8
  import { type AcceptedTransition, type RecentTransitionWindow } from "./history.ts";
8
9
  import { hashJson, sameJson } from "./json.ts";
9
- import { hasCwdMaterialization } from "./migration.ts";
10
+ import { hasCwdMaterialization, hasLegacyStateSources } from "./migration.ts";
10
11
  import { RevisionUnavailableError, createSessionRuntime, isFileRevision, parseSessionRuntime, resolveFileSessionRuntime, resolveSessionRuntime, type Snapshot } from "./snapshot.ts";
11
12
  import { emptyState, type MaterializedState, type ScopedStates, type StateScope } from "./state.ts";
12
- import { adoptTemporalStreams, advanceTemporalState, createTemporalState, readTemporalState, validateTemporalState, type TemporalState } from "./temporal.ts";
13
+ import { adoptTemporalStreams, advanceTemporalState, createTemporalState, readTemporalState, validateTemporalState, type ScopeStream, type TemporalState } from "./temporal.ts";
13
14
 
14
15
  const SCOPES = ["global", "cwd", "session"] as const;
16
+ const SHARED_SCOPES = ["global", "cwd"] as const;
15
17
  export type RuntimePublication = ReturnType<typeof publishTemporalStateToGit> & { revision?: string };
16
18
 
19
+ function emptyProvenance(): Record<StateScope, ArtifactProvenanceRegistry> {
20
+ return { global: {}, cwd: {}, session: {} };
21
+ }
22
+
23
+ function scopeLabel(scope: StateScope): string {
24
+ return scope === "cwd" ? "CWD" : scope;
25
+ }
26
+
27
+ /** Precise fail-closed conflict for a shared scope this transition actually overwrites. */
28
+ function targetScopeConflict(scopes: readonly StateScope[]): Error {
29
+ const labels = scopes.map(scopeLabel);
30
+ if (labels.length === 1) {
31
+ return new Error(`State Flow cannot publish the ${labels[0]} patch because the live ${labels[0]} state advanced after this transition's selected basis. Refresh or reconcile the target scope before retrying.`);
32
+ }
33
+ return new Error(`State Flow cannot publish the ${labels.join(" and ")} patches because the live ${labels.join(" and ")} states advanced after this transition's selected basis. Refresh or reconcile the target scopes before retrying.`);
34
+ }
35
+
17
36
  /** Immutable target validation is independent of acquiring the live publication basis. */
18
37
  export function inspectRuntimeRevision(cwd: string, sessionId: string, root: string, revision: string, sessionKey = sessionId) {
19
38
  const loaded = loadTemporalRevision(cwd, sessionId, root, revision, sessionKey);
@@ -22,7 +41,7 @@ export function inspectRuntimeRevision(cwd: string, sessionId: string, root: str
22
41
  if (!loaded.scopes.global || !loaded.scopes.cwd || !loaded.scopes.session) throw new Error("Incomplete temporal scope cohort");
23
42
  const view = { lineage: resolved.lineage, scopes: { global: loaded.scopes.global, cwd: loaded.scopes.cwd, session: loaded.scopes.session } };
24
43
  validateTemporalState(view);
25
- return { runtime: loaded.runtime, resolved, view, ...(loaded.legacyLayout ? { legacyLayout: true as const } : {}) };
44
+ return { runtime: loaded.runtime, resolved, view, provenance: loaded.provenance, ...(loaded.legacyLayout ? { legacyLayout: true as const } : {}) };
26
45
  }
27
46
 
28
47
  /** Both checkpoint generations validate their immutable target before any live migration/publication. */
@@ -51,6 +70,7 @@ export class TemporalRuntime {
51
70
  private semanticRevision: string | undefined;
52
71
  private savedRuntime: string | undefined;
53
72
  private backend: "git" | "files" | undefined;
73
+ private provenanceByScope: Record<StateScope, ArtifactProvenanceRegistry> = emptyProvenance();
54
74
  readonly cwd: string;
55
75
  private readonly session: SessionAddress;
56
76
  readonly root: string;
@@ -63,6 +83,11 @@ export class TemporalRuntime {
63
83
  get sessionId(): string { return this.session.id; }
64
84
  get sessionKey(): string { return this.session.key; }
65
85
 
86
+ /** Runtime-owned artifact freshness evidence for one scope; never model-visible state. */
87
+ artifactProvenance(scope: StateScope): ArtifactProvenanceRegistry {
88
+ return structuredClone(this.provenanceByScope[scope]);
89
+ }
90
+
66
91
  /** Explicit start owns directory/repository creation; reads never call this. */
67
92
  prepare(): void {
68
93
  const backend = detectGitCapability();
@@ -75,8 +100,10 @@ export class TemporalRuntime {
75
100
  promote(snapshot: Snapshot): RuntimePublication | undefined {
76
101
  if (this.backend !== "files" || !this.view || detectGitCapability() === "files") return undefined;
77
102
  if (!snapshot.meta.durableBase || snapshot.meta.durableBase !== this.semanticRevision) throw new Error("Git adoption requires the selected file revision");
78
- const result = adoptFileStateToGit(this.cwd, this.sessionId, this.root, snapshot.meta.durableBase, snapshot, this.sessionKey);
79
- const savedRuntime = hashJson(createSessionRuntime(snapshot, this.cwd, this.sessionId, result.view.lineage));
103
+ const pushRemote = (snapshot.meta.remotePublication?.mode ?? "transition") === "transition";
104
+ const result = adoptFileStateToGit(this.cwd, this.sessionId, this.root, snapshot.meta.durableBase, snapshot, this.sessionKey, pushRemote);
105
+ this.provenanceByScope = structuredClone(result.provenance);
106
+ const savedRuntime = hashJson(createSessionRuntime(snapshot, this.cwd, this.sessionId, result.view.lineage, "unconfirmed", this.provenanceByScope.session));
80
107
  this.view = result.view;
81
108
  this.base = result.base;
82
109
  this.backend = "git";
@@ -114,10 +141,11 @@ export class TemporalRuntime {
114
141
  restore(revision: string, legacySnapshot?: Snapshot): Snapshot {
115
142
  const inspected = inspectSnapshotRevision(this.cwd, this.sessionId, this.root, revision, legacySnapshot, this.sessionKey);
116
143
  if (inspected.file) {
117
- const savedRuntime = hashJson(createSessionRuntime(inspected.snapshot, this.cwd, this.sessionId, inspected.file.view.lineage, "files"));
144
+ const savedRuntime = hashJson(createSessionRuntime(inspected.snapshot, this.cwd, this.sessionId, inspected.file.view.lineage, "files", inspected.file.provenance.session));
118
145
  this.view = inspected.file.view;
119
146
  this.base = inspected.file.base;
120
147
  this.backend = "files";
148
+ this.provenanceByScope = structuredClone(inspected.file.provenance);
121
149
  this.semanticRevision = revision;
122
150
  this.savedRuntime = savedRuntime;
123
151
  return inspected.snapshot;
@@ -170,6 +198,7 @@ export class TemporalRuntime {
170
198
  this.view = view;
171
199
  this.base = base;
172
200
  this.backend = "git";
201
+ this.provenanceByScope = structuredClone(loaded.provenance);
173
202
  this.semanticRevision = semanticRevision;
174
203
  this.savedRuntime = savedRuntime;
175
204
  return resolved.snapshot;
@@ -181,14 +210,22 @@ export class TemporalRuntime {
181
210
  const backend = this.backend ?? (detectGitCapability() === "git" && lstatSync(join(this.root, ".git"), { throwIfNoEntry: false }) ? "git" : "files");
182
211
  if (backend === "git") {
183
212
  if (!hasCwd) migrateHashedCwdAtHead(this.cwd, this.root);
184
- migrateLegacyStorageToGit(this.cwd, this.sessionId, this.root, this.sessionKey);
213
+ if (hasLegacyStateSources(this.cwd, this.sessionId, this.root, this.sessionKey)) {
214
+ migrateLegacyStorageToGit(this.cwd, this.sessionId, this.root, this.sessionKey);
215
+ }
185
216
  }
186
217
  else {
187
218
  if (allowCreateCwd) initializeFileStore(this.root);
188
- migrateLegacyStorageToFiles(this.cwd, this.sessionId, this.root, this.sessionKey);
219
+ if (hasLegacyStateSources(this.cwd, this.sessionId, this.root, this.sessionKey)) {
220
+ migrateLegacyStorageToFiles(this.cwd, this.sessionId, this.root, this.sessionKey);
221
+ }
189
222
  }
190
223
  const base: TemporalGitBase = backend === "git" ? captureTemporalGitBase(this.cwd, this.sessionId, this.root, this.sessionKey) : captureTemporalFileBase(this.cwd, this.sessionId, this.root, this.sessionKey);
191
224
  const files = new Map(base.files.map((file) => [file.path, file.content]));
225
+ if (SCOPES.some((scope) => {
226
+ const paths = temporalScopePaths(this.cwd, this.sessionId, scope, this.root, this.sessionKey);
227
+ return files.get(join(paths.directory, "state.json")) !== undefined;
228
+ })) throw new Error("Legacy State Flow storage changed during initialization; retry migration from a fresh basis");
192
229
  const streams = Object.fromEntries(SCOPES.map((scope) => {
193
230
  const paths = temporalScopePaths(this.cwd, this.sessionId, scope, this.root, this.sessionKey);
194
231
  return [scope, parseScopeStream(files.get(paths.checkpoint), files.get(paths.patches), scope, scope === "cwd" ? this.cwd : undefined)];
@@ -204,6 +241,13 @@ export class TemporalRuntime {
204
241
  candidate.backend = backend;
205
242
  candidate.base = base;
206
243
  candidate.view = adoptTemporalStreams({ global: streams.global ?? fresh.scopes.global, cwd: streams.cwd ?? fresh.scopes.cwd, session: streams.session ?? fresh.scopes.session }, `${base.head ?? "unborn"}:${randomUUID()}`);
244
+ const globalMeta = temporalScopePaths(this.cwd, this.sessionId, "global", this.root, this.sessionKey).meta;
245
+ const cwdMeta = temporalScopePaths(this.cwd, this.sessionId, "cwd", this.root, this.sessionKey).meta;
246
+ candidate.provenanceByScope = {
247
+ global: parseScopeProvenance(files.get(globalMeta), globalMeta),
248
+ cwd: parseScopeProvenance(files.get(cwdMeta), cwdMeta),
249
+ session: streams.session === undefined ? {} : parseArtifactProvenanceRegistry(existingRuntime?.meta.artifacts, "State Flow session artifact provenance"),
250
+ };
207
251
  if (expectedShared && (["global", "cwd"] as const).some((scope) => !sameJson(candidate.read(0, scope), expectedShared[scope]))) {
208
252
  throw new Error("Legacy branch shared scopes diverged from the selected revision; migration cannot overwrite them");
209
253
  }
@@ -211,54 +255,146 @@ export class TemporalRuntime {
211
255
  this.view = candidate.view;
212
256
  this.base = candidate.base;
213
257
  this.backend = backend;
258
+ this.provenanceByScope = structuredClone(candidate.provenanceByScope);
214
259
  this.semanticRevision = candidate.semanticRevision;
215
260
  this.savedRuntime = candidate.savedRuntime;
216
261
  return publication;
217
262
  }
218
263
 
219
- publish(snapshot: Snapshot, semantic = false, accepted?: AcceptedTransition, options: { pushRemote?: boolean } = {}): RuntimePublication | undefined {
220
- if (!this.view || !this.base) throw new Error("State Flow temporal publication is unavailable; restore or initialize before accepting a transition");
221
- const next = accepted ? advanceTemporalState(this.view, accepted.transitions, accepted.id) : this.view;
222
- const runtime = createSessionRuntime(snapshot, this.cwd, this.sessionId, next.lineage, this.backend === "files" ? "files" : "unconfirmed");
223
- const fingerprint = hashJson(runtime);
224
- if (!semantic && fingerprint === this.savedRuntime) return undefined;
225
- if (semantic && this.semanticRevision) {
226
- // Session files may branch; shared scopes may not be silently merged or rewound.
227
- const live = new Map(this.base.files.map((file) => [file.path, file.content]));
228
- for (const scope of ["global", "cwd"] as const) {
229
- const paths = temporalScopePaths(this.cwd, this.sessionId, scope, this.root, this.sessionKey);
230
- const stream = parseScopeStream(live.get(paths.checkpoint), live.get(paths.patches), scope, scope === "cwd" ? this.cwd : undefined);
231
- if (!sameJson(stream, this.view.scopes[scope])) throw new Error("State Flow cannot publish this restored branch because its global or CWD scope changed concurrently after the linked revision");
264
+ /**
265
+ * Reconcile untouched shared-scope drift against the current proven live basis.
266
+ *
267
+ * A restored branch can lag behind live global/CWD state. Untouched shared scopes adopt
268
+ * the current live streams at a fresh origin; a shared scope the accepted transition
269
+ * actually changes remains a fail-closed write conflict. Divergence in non-adoptable
270
+ * session or runtime files also fails closed under the existing race rule.
271
+ */
272
+ private reconcileSharedDrift(changedScopes: ReadonlySet<StateScope>): {
273
+ view: TemporalState;
274
+ base: TemporalGitBase | TemporalFileBase;
275
+ provenance: Record<StateScope, ArtifactProvenanceRegistry>;
276
+ } {
277
+ const captured = this.backend === "files"
278
+ ? captureTemporalFileBase(this.cwd, this.sessionId, this.root, this.sessionKey)
279
+ : captureTemporalGitBase(this.cwd, this.sessionId, this.root, this.sessionKey);
280
+ const liveFiles = new Map(captured.files.map((file) => [file.path, file]));
281
+ const adoptable = new Set<string>();
282
+ for (const scope of SHARED_SCOPES) {
283
+ const paths = temporalScopePaths(this.cwd, this.sessionId, scope, this.root, this.sessionKey);
284
+ adoptable.add(paths.checkpoint);
285
+ adoptable.add(paths.patches);
286
+ adoptable.add(paths.meta);
287
+ }
288
+ for (const file of this.base!.files) {
289
+ if (adoptable.has(file.path)) continue;
290
+ const current = liveFiles.get(file.path);
291
+ if (current === undefined || current.identity !== file.identity) {
292
+ throw new Error("Temporal State Flow base or scope identity changed concurrently");
232
293
  }
233
294
  }
295
+ const provenance = structuredClone(this.provenanceByScope);
296
+ const adopted = new Map<StateScope, ScopeStream>();
297
+ const targets: StateScope[] = [];
298
+ for (const scope of SHARED_SCOPES) {
299
+ const paths = temporalScopePaths(this.cwd, this.sessionId, scope, this.root, this.sessionKey);
300
+ const stream = parseScopeStream(liveFiles.get(paths.checkpoint)?.content, liveFiles.get(paths.patches)?.content, scope, scope === "cwd" ? this.cwd : undefined);
301
+ if (stream === undefined) throw new Error(`Live State Flow ${scope} scope storage is incomplete`);
302
+ const liveProvenance = parseScopeProvenance(liveFiles.get(paths.meta)?.content, paths.meta);
303
+ const streamDrifted = !sameJson(stream, this.view!.scopes[scope]);
304
+ const provenanceDrifted = !sameJson(liveProvenance, this.provenanceByScope[scope]);
305
+ if (!streamDrifted && !provenanceDrifted) continue;
306
+ if (changedScopes.has(scope)) {
307
+ targets.push(scope);
308
+ continue;
309
+ }
310
+ provenance[scope] = liveProvenance;
311
+ if (streamDrifted) adopted.set(scope, stream);
312
+ }
313
+ if (targets.length > 0) throw targetScopeConflict(targets);
314
+ if (adopted.size === 0) return { view: this.view!, base: captured, provenance };
315
+ const streams = {
316
+ global: adopted.get("global") ?? structuredClone(this.view!.scopes.global),
317
+ cwd: adopted.get("cwd") ?? structuredClone(this.view!.scopes.cwd),
318
+ session: structuredClone(this.view!.scopes.session),
319
+ };
320
+ const head = "head" in captured ? captured.head : undefined;
321
+ return {
322
+ view: adoptTemporalStreams(streams, `${head ?? "unborn"}:reconcile:${randomUUID()}`),
323
+ base: captured,
324
+ provenance,
325
+ };
326
+ }
327
+
328
+ publish(
329
+ snapshot: Snapshot,
330
+ semantic = false,
331
+ accepted?: AcceptedTransition,
332
+ options: { pushRemote?: boolean; provenance?: Partial<Record<StateScope, Record<string, ArtifactProvenance>>> } = {},
333
+ ): RuntimePublication | undefined {
334
+ if (!this.view || !this.base) throw new Error("State Flow temporal publication is unavailable; restore or initialize before accepting a transition");
335
+ // Activation and terminal policy: only the legacy transition mode publishes synchronously.
336
+ const pushRemote = options.pushRemote ?? (snapshot.meta.remotePublication?.mode ?? "transition") === "transition";
337
+ const provenanceScopes = SCOPES.filter((scope) => Object.entries(options.provenance?.[scope] ?? {})
338
+ .some(([path, entry]) => this.provenanceByScope[scope][path] === undefined
339
+ || !sameJson(this.provenanceByScope[scope][path], entry)));
340
+ let basis = this.view;
341
+ let base: TemporalGitBase | TemporalFileBase = this.base;
342
+ let basisProvenance = this.provenanceByScope;
343
+ if ((semantic || provenanceScopes.length > 0) && this.semanticRevision) {
344
+ const changedScopes = new Set<StateScope>([
345
+ ...(accepted?.transitions ?? []).map(({ scope }) => scope),
346
+ ...provenanceScopes,
347
+ ]);
348
+ const reconciled = this.reconcileSharedDrift(changedScopes);
349
+ basis = reconciled.view;
350
+ base = reconciled.base;
351
+ basisProvenance = reconciled.provenance;
352
+ }
353
+ const next = accepted ? advanceTemporalState(basis, accepted.transitions, accepted.id) : basis;
354
+ const nextProvenance = structuredClone(basisProvenance);
355
+ for (const scope of SCOPES) {
356
+ const updates = options.provenance?.[scope];
357
+ if (updates) for (const [path, entry] of Object.entries(updates)) nextProvenance[scope][path] = structuredClone(entry);
358
+ }
359
+ for (const scope of SCOPES) {
360
+ nextProvenance[scope] = pruneArtifactProvenance(nextProvenance[scope], readTemporalState(next, 0, scope).artifacts);
361
+ }
362
+ const provenanceChanged = !sameJson(nextProvenance, this.provenanceByScope);
363
+ const scopedWrite = semantic || provenanceChanged;
364
+ const runtime = createSessionRuntime(snapshot, this.cwd, this.sessionId, next.lineage, this.backend === "files" ? "files" : "unconfirmed", nextProvenance.session);
365
+ const fingerprint = hashJson(runtime);
366
+ if (!semantic && fingerprint === this.savedRuntime && !provenanceChanged) return undefined;
234
367
  if (this.backend === "files") {
235
368
  runtime.meta.temporalRevision = "self";
236
- const result = publishTemporalStateToFiles(this.cwd, this.sessionId, next, semantic ? SCOPES : [], this.base, this.root, runtime, this.sessionKey);
369
+ const result = publishTemporalStateToFiles(this.cwd, this.sessionId, next, semantic ? SCOPES : [], base, this.root, runtime, this.sessionKey, nextProvenance);
237
370
  this.base = result.base;
238
371
  this.view = next;
372
+ this.provenanceByScope = nextProvenance;
239
373
  this.savedRuntime = fingerprint;
240
374
  this.semanticRevision = result.revision;
241
375
  return { base: result.base, revision: result.revision };
242
376
  }
243
- if (!semantic) {
377
+ if (!scopedWrite) {
244
378
  const current = captureTemporalGitBase(this.cwd, this.sessionId, this.root, this.sessionKey);
245
379
  const paths = sessionRuntimePaths(this.cwd, this.sessionId, this.root, this.sessionKey);
246
380
  for (const path of [paths.config, paths.meta]) {
247
- if (current.files.find((file) => file.path === path)?.identity !== this.base.files.find((file) => file.path === path)?.identity) {
381
+ if (current.files.find((file) => file.path === path)?.identity !== base.files.find((file) => file.path === path)?.identity) {
248
382
  throw new Error("Temporal State Flow runtime changed concurrently");
249
383
  }
250
384
  }
251
- this.base = current;
385
+ base = current;
252
386
  }
253
- runtime.meta.temporalRevision = semantic ? "self" : this.semanticRevision!;
387
+ runtime.meta.temporalRevision = scopedWrite ? "self" : this.semanticRevision!;
254
388
  const result = publishTemporalStateToGit(
255
- this.cwd, this.sessionId, next, semantic ? SCOPES : [], this.base, this.root, runtime, this.sessionKey,
256
- options.pushRemote !== false,
389
+ this.cwd, this.sessionId, next, semantic ? SCOPES : provenanceChanged ? provenanceScopes : [], base, this.root, runtime, this.sessionKey,
390
+ pushRemote,
391
+ nextProvenance,
257
392
  );
258
393
  this.base = result.base;
259
394
  this.view = next;
395
+ this.provenanceByScope = nextProvenance;
260
396
  this.savedRuntime = fingerprint;
261
- if (semantic && result.commit) this.semanticRevision = result.commit;
397
+ if (scopedWrite && result.commit) this.semanticRevision = result.commit;
262
398
  return result;
263
399
  }
264
400
  }
@@ -3,6 +3,7 @@ import {
3
3
  hashArtifactSource,
4
4
  isArtifactHash,
5
5
  type ArtifactMetadata,
6
+ type ArtifactProvenance,
6
7
  type ArtifactRegistry,
7
8
  } from "./artifact.ts";
8
9
  import { canonicalJson, isObject, type JsonObject, type JsonValue } from "./json.ts";
@@ -19,16 +20,24 @@ function hasContent(value: unknown): boolean {
19
20
 
20
21
  export function hasCompiledSkillArtifact(
21
22
  artifacts: ArtifactRegistry,
23
+ provenance: ArtifactProvenance | undefined,
22
24
  source: string,
23
25
  expectedHash?: string,
24
26
  ): boolean {
25
27
  const metadata = artifacts[source];
26
- return isObject(metadata)
27
- && metadata.kind === "skill"
28
- && metadata.compiler === SKILL_ARTIFACT_COMPILER
29
- && (expectedHash === undefined || metadata.hash === expectedHash)
30
- && isObject(metadata.compilation)
31
- && hasContent(metadata.compilation);
28
+ if (!isObject(metadata)
29
+ || metadata.kind !== "skill"
30
+ || !isObject(metadata.compilation)
31
+ || !hasContent(metadata.compilation)
32
+ || provenance?.malformed === true) return false;
33
+ const compilerRevision = provenance !== undefined && Object.hasOwn(provenance, "compilerRevision")
34
+ ? provenance.compilerRevision
35
+ : metadata.compiler;
36
+ const sourceHash = provenance !== undefined && Object.hasOwn(provenance, "sourceHash")
37
+ ? provenance.sourceHash
38
+ : metadata.hash;
39
+ return compilerRevision === SKILL_ARTIFACT_COMPILER
40
+ && (expectedHash === undefined || sourceHash === expectedHash);
32
41
  }
33
42
 
34
43
  export type SkillSourceHasher = (source: string) => string;