@llblab/pi-kit 0.7.1 → 0.8.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 (36) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +2 -2
  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/node_modules/@llblab/pi-telegram/CHANGELOG.md +4 -0
  32. package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +1 -1
  33. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +21 -19
  34. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  35. package/package.json +3 -3
  36. package/node_modules/@llblab/pi-state-flow/lib/validation.ts +0 -27
@@ -1,20 +1,40 @@
1
1
  import { createHash } from "node:crypto";
2
- import { containsNull, isJsonValue, isObject, type JsonObject } from "./json.ts";
2
+ import { containsNull, isJsonValue, isObject, type JsonObject, type JsonValue } from "./json.ts";
3
3
 
4
4
  const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/;
5
+ const MODEL_FORBIDDEN_PROVENANCE_FIELDS = ["hash", "compiler", "sourceHash", "compilerRevision", "compiledAt", "source_hash_verified"] as const;
5
6
 
6
7
  /** Current compiler protocol for ordinary source artifacts such as Knowledge Markdown. */
7
8
  export const ORDINARY_ARTIFACT_COMPILER = "artifact-v1";
8
9
 
9
- /** Source-addressed metadata retained after artifact compilation. */
10
+ /** Freshness fields that runtime-owned scope metadata may retain per artifact path. */
11
+ export interface ArtifactProvenance {
12
+ /** Retained value; absent means unavailable, malformed means fail closed. */
13
+ sourceHash?: unknown;
14
+ compilerRevision?: unknown;
15
+ compiledAt?: unknown;
16
+ /** Internal marker for an uninterpretable retained entry; every dependent capability fails closed. */
17
+ malformed?: true;
18
+ }
19
+
20
+ export type ArtifactProvenanceRegistry = Record<string, ArtifactProvenance>;
21
+
22
+ /**
23
+ * Model-visible artifact metadata retained per canonical source path.
24
+ *
25
+ * `hash`, `compiler`, and `compiled_at` remain accepted only as retired embedded
26
+ * provenance input from pre-0.7 state; they are consumed as compatibility evidence
27
+ * and stripped from model projection. New compilations keep semantic fields here and
28
+ * runtime-owned freshness in the scope `meta.json` provenance registry.
29
+ */
10
30
  export type ArtifactMetadata = JsonObject & {
11
31
  description: string;
12
- hash: string;
13
- compiler: string;
14
- compiled_at?: string;
15
32
  compilation?: JsonObject;
16
33
  kind?: string;
17
34
  tags?: string[];
35
+ hash?: string;
36
+ compiler?: string;
37
+ compiled_at?: string;
18
38
  };
19
39
 
20
40
  /** Artifact source paths are the canonical registry keys. */
@@ -41,6 +61,12 @@ export interface ArtifactInvalidationRequest extends ArtifactSourceIdentity {
41
61
  reason: ArtifactInvalidationReason;
42
62
  }
43
63
 
64
+ /** Model-visible invalidation projection: paths and reasons only, never source identity. */
65
+ export interface ArtifactInvalidationNotice {
66
+ path: string;
67
+ reason: ArtifactInvalidationReason;
68
+ }
69
+
44
70
  export interface ArtifactInvalidationPlan {
45
71
  fresh: ArtifactSourceIdentity[];
46
72
  requiresCompilation: ArtifactInvalidationRequest[];
@@ -67,6 +93,12 @@ export interface ArtifactCompilationUpdate {
67
93
  output: ArtifactCompilerOutput;
68
94
  }
69
95
 
96
+ /** One compilation split into its model-visible and runtime-owned halves. */
97
+ export interface CompiledArtifact {
98
+ semantic: ArtifactMetadata;
99
+ provenance: ArtifactProvenance;
100
+ }
101
+
70
102
  export function hashArtifactSource(source: string | Uint8Array): string {
71
103
  return `sha256:${createHash("sha256").update(source).digest("hex")}`;
72
104
  }
@@ -83,15 +115,6 @@ export function validateArtifactMetadata(value: unknown, path = "<unknown>"): as
83
115
  if (typeof value.description !== "string" || value.description.trim().length === 0) {
84
116
  throw new Error(`Artifact metadata at ${path} must have a non-empty description`);
85
117
  }
86
- if (!isArtifactHash(value.hash)) {
87
- throw new Error(`Artifact metadata at ${path} must have a sha256:<64 lowercase hex characters> hash`);
88
- }
89
- if (typeof value.compiler !== "string" || value.compiler.trim().length === 0) {
90
- throw new Error(`Artifact metadata at ${path} must have a non-empty compiler revision`);
91
- }
92
- if (Object.hasOwn(value, "compiled_at") && typeof value.compiled_at !== "string") {
93
- throw new Error(`Artifact metadata at ${path} compiled_at must be a string`);
94
- }
95
118
  if (Object.hasOwn(value, "compilation") && !isObject(value.compilation)) {
96
119
  throw new Error(`Artifact metadata at ${path} compilation must be an object`);
97
120
  }
@@ -104,6 +127,15 @@ export function validateArtifactMetadata(value: unknown, path = "<unknown>"): as
104
127
  || new Set(value.tags).size !== value.tags.length)) {
105
128
  throw new Error(`Artifact metadata at ${path} tags must be unique non-empty trimmed strings`);
106
129
  }
130
+ if (Object.hasOwn(value, "hash") && !isArtifactHash(value.hash)) {
131
+ throw new Error(`Artifact metadata at ${path} must have a sha256:<64 lowercase hex characters> hash`);
132
+ }
133
+ if (Object.hasOwn(value, "compiler") && (typeof value.compiler !== "string" || value.compiler.trim().length === 0)) {
134
+ throw new Error(`Artifact metadata at ${path} must have a non-empty compiler revision`);
135
+ }
136
+ if (Object.hasOwn(value, "compiled_at") && typeof value.compiled_at !== "string") {
137
+ throw new Error(`Artifact metadata at ${path} compiled_at must be a string`);
138
+ }
107
139
  }
108
140
 
109
141
  export function isArtifactMetadata(value: unknown): value is ArtifactMetadata {
@@ -176,29 +208,102 @@ function refreshRequested(path: string, explicitRefresh: boolean | ReadonlySet<s
176
208
  return explicitRefresh.has(path);
177
209
  }
178
210
 
179
- /** Classify freshness from source identity and metadata without acquiring the source body. */
211
+ function isProvenanceEntry(value: unknown): value is ArtifactProvenance {
212
+ return isObject(value) && (value.malformed === undefined || value.malformed === true);
213
+ }
214
+
215
+ /** Parse one scope `meta.json` provenance registry; missing input means no recorded evidence. */
216
+ export function parseArtifactProvenanceRegistry(value: unknown, context = "Artifact provenance"): ArtifactProvenanceRegistry {
217
+ if (value === undefined) return {};
218
+ if (!isObject(value)) throw new Error(`${context} must be a path-keyed JSON object`);
219
+ const registry: ArtifactProvenanceRegistry = {};
220
+ for (const [path, entry] of Object.entries(value)) {
221
+ if (path.trim().length === 0) throw new Error(`${context} path keys must be non-empty`);
222
+ if (!isObject(entry) || !isJsonValue(entry)) {
223
+ registry[path] = { malformed: true };
224
+ continue;
225
+ }
226
+ const known = new Set(["sourceHash", "compilerRevision", "compiledAt"]);
227
+ if (Object.keys(entry).some((key) => !known.has(key))) {
228
+ registry[path] = { malformed: true };
229
+ continue;
230
+ }
231
+ registry[path] = {
232
+ ...(Object.hasOwn(entry, "sourceHash") ? { sourceHash: entry.sourceHash } : {}),
233
+ ...(Object.hasOwn(entry, "compilerRevision") ? { compilerRevision: entry.compilerRevision } : {}),
234
+ ...(Object.hasOwn(entry, "compiledAt") ? { compiledAt: entry.compiledAt } : {}),
235
+ };
236
+ }
237
+ return registry;
238
+ }
239
+
240
+ /** Canonical retained form; uninterpretable entries cannot round-trip and are omitted. */
241
+ export function serializeArtifactProvenanceRegistry(registry: Readonly<ArtifactProvenanceRegistry>): JsonObject {
242
+ const artifacts: JsonObject = {};
243
+ for (const [path, entry] of Object.entries(registry)) {
244
+ if (entry.malformed === true) continue;
245
+ const fields: JsonObject = {};
246
+ if (entry.sourceHash !== undefined) fields.sourceHash = entry.sourceHash as JsonValue;
247
+ if (entry.compilerRevision !== undefined) fields.compilerRevision = entry.compilerRevision as JsonValue;
248
+ if (entry.compiledAt !== undefined) fields.compiledAt = entry.compiledAt as JsonValue;
249
+ if (Object.keys(fields).length === 0) continue;
250
+ artifacts[path] = fields;
251
+ }
252
+ return artifacts;
253
+ }
254
+
255
+ function invalidField(value: unknown, validate: (candidate: unknown) => boolean): boolean {
256
+ return value !== undefined && !validate(value);
257
+ }
258
+
259
+ const INVALID_EVIDENCE = Symbol("invalid-evidence");
260
+
261
+ /** Later authority wins per field; absent runtime evidence falls back to retired embedded values. */
262
+ function fieldEvidence(entry: ArtifactProvenance | undefined, field: "sourceHash" | "compilerRevision" | "compiledAt", legacy: unknown): unknown {
263
+ if (entry?.malformed === true) return INVALID_EVIDENCE;
264
+ if (entry !== undefined && Object.hasOwn(entry, field)) return entry[field];
265
+ return legacy;
266
+ }
267
+
268
+ /** Classify freshness from semantic state and runtime provenance without acquiring the source body. */
180
269
  export function classifyArtifactFreshness(
181
270
  source: ArtifactSourceIdentity,
182
271
  metadata: unknown,
183
272
  compiler: string,
184
273
  explicitRefresh = false,
274
+ provenance?: unknown,
185
275
  ): ArtifactFreshness {
186
276
  validateSourceIdentity(source);
187
277
  validateCompilerRevision(compiler);
188
278
  if (metadata === undefined) return { kind: "requires-compilation", reason: "new" };
189
279
  if (!isArtifactMetadata(metadata)) return { kind: "requires-compilation", reason: "invalid-metadata" };
190
- if (metadata.hash !== source.hash) return { kind: "requires-compilation", reason: "source-changed" };
191
- if (metadata.compiler !== compiler) return { kind: "requires-compilation", reason: "compiler-changed" };
280
+ const entry = isProvenanceEntry(provenance) ? provenance : undefined;
281
+ const runtime = entry?.malformed === true;
282
+ const sourceHash = runtime ? INVALID_EVIDENCE : fieldEvidence(entry, "sourceHash", metadata.hash);
283
+ if (sourceHash === INVALID_EVIDENCE || invalidField(sourceHash, (value) => isArtifactHash(value))) {
284
+ return { kind: "requires-compilation", reason: "invalid-metadata" };
285
+ }
286
+ if (typeof sourceHash === "string" && sourceHash !== source.hash) {
287
+ return { kind: "requires-compilation", reason: "source-changed" };
288
+ }
289
+ const compilerRevision = runtime ? INVALID_EVIDENCE : fieldEvidence(entry, "compilerRevision", metadata.compiler);
290
+ if (compilerRevision === INVALID_EVIDENCE || invalidField(compilerRevision, (value) => typeof value === "string" && value.trim().length > 0)) {
291
+ return { kind: "requires-compilation", reason: "invalid-metadata" };
292
+ }
293
+ if (typeof compilerRevision === "string" && compilerRevision !== compiler) {
294
+ return { kind: "requires-compilation", reason: "compiler-changed" };
295
+ }
192
296
  if (explicitRefresh) return { kind: "requires-compilation", reason: "explicit-refresh" };
193
297
  return { kind: "fresh" };
194
298
  }
195
299
 
196
- /** Produce a deterministic acquisition plan from path/hash candidates alone. */
300
+ /** Produce a deterministic acquisition plan from path/hash candidates and retained evidence. */
197
301
  export function planArtifactInvalidation(
198
302
  sources: readonly ArtifactSourceIdentity[],
199
303
  registry: Readonly<Record<string, unknown>>,
200
304
  compiler: string,
201
305
  options: ArtifactInvalidationOptions = {},
306
+ provenance: Readonly<ArtifactProvenanceRegistry> = {},
202
307
  ): ArtifactInvalidationPlan {
203
308
  if (!isObject(registry)) throw new Error("Artifacts must be a path-keyed JSON object");
204
309
  validateCompilerRevision(compiler);
@@ -210,11 +315,13 @@ export function planArtifactInvalidation(
210
315
  if (seen.has(source.path)) throw new Error(`Duplicate artifact source path: ${source.path}`);
211
316
  seen.add(source.path);
212
317
  const metadata = Object.hasOwn(registry, source.path) ? registry[source.path] : undefined;
318
+ const entry = Object.hasOwn(provenance, source.path) ? provenance[source.path] : undefined;
213
319
  const freshness = classifyArtifactFreshness(
214
320
  source,
215
321
  metadata,
216
322
  compiler,
217
323
  refreshRequested(source.path, options.explicitRefresh),
324
+ entry,
218
325
  );
219
326
  const identity = { path: source.path, hash: source.hash };
220
327
  if (freshness.kind === "fresh") fresh.push(identity);
@@ -224,22 +331,52 @@ export function planArtifactInvalidation(
224
331
  return { fresh, requiresCompilation, removed };
225
332
  }
226
333
 
227
- function compilationMetadata(update: ArtifactCompilationUpdate): ArtifactMetadata {
334
+ /** Split one compiler output into model-visible semantics and runtime-owned provenance. */
335
+ export function compileArtifact(update: ArtifactCompilationUpdate): CompiledArtifact {
228
336
  validateSourceIdentity(update.source);
229
337
  validateCompilerRevision(update.compiler, update.source.path);
230
- if (!isObject(update.output) || Object.hasOwn(update.output, "hash") || Object.hasOwn(update.output, "compiler")) {
231
- throw new Error(`Artifact compiler output at ${update.source.path} cannot set runtime-owned hash or compiler fields`);
338
+ if (!isObject(update.output) || MODEL_FORBIDDEN_PROVENANCE_FIELDS.some((field) => Object.hasOwn(update.output, field))) {
339
+ throw new Error(`Artifact compiler output at ${update.source.path} cannot set runtime-owned provenance fields`);
232
340
  }
233
- const metadata = {
234
- ...structuredClone(update.output),
235
- hash: update.source.hash,
236
- compiler: update.compiler,
341
+ // Timestamps are runtime evidence; the model-visible entry never retains them.
342
+ const semantic = structuredClone(update.output) as ArtifactMetadata;
343
+ delete semantic.compiled_at;
344
+ validateArtifactMetadata(semantic, update.source.path);
345
+ return {
346
+ semantic,
347
+ provenance: {
348
+ sourceHash: update.source.hash,
349
+ compilerRevision: update.compiler,
350
+ ...(typeof update.output.compiled_at === "string" ? { compiledAt: update.output.compiled_at } : {}),
351
+ },
237
352
  };
238
- validateArtifactMetadata(metadata, update.source.path);
239
- return metadata;
240
353
  }
241
354
 
242
- /** Validate and apply a whole compilation/removal cohort without mutating the prior registry. */
355
+ /** Merge one compilation/removal cohort into the runtime-owned provenance registry. */
356
+ export function updateArtifactProvenance(
357
+ registry: Readonly<ArtifactProvenanceRegistry>,
358
+ updates: readonly ArtifactCompilationUpdate[],
359
+ removed: readonly string[] = [],
360
+ ): ArtifactProvenanceRegistry {
361
+ const next = structuredClone(registry) as ArtifactProvenanceRegistry;
362
+ for (const update of updates) next[update.source.path] = compileArtifact(update).provenance;
363
+ for (const path of removed) delete next[path];
364
+ return next;
365
+ }
366
+
367
+ /** Keep only provenance whose artifact path still exists in the given semantic registry. */
368
+ export function pruneArtifactProvenance(
369
+ registry: Readonly<ArtifactProvenanceRegistry>,
370
+ semantic: Readonly<Record<string, unknown>>,
371
+ ): ArtifactProvenanceRegistry {
372
+ const next: ArtifactProvenanceRegistry = {};
373
+ for (const [path, entry] of Object.entries(registry)) {
374
+ if (Object.hasOwn(semantic, path)) next[path] = structuredClone(entry);
375
+ }
376
+ return next;
377
+ }
378
+
379
+ /** Validate and apply a whole compilation/removal cohort of model-visible artifacts. */
243
380
  export function updateArtifactRegistry(
244
381
  registry: ArtifactRegistry,
245
382
  updates: readonly ArtifactCompilationUpdate[],
@@ -249,7 +386,7 @@ export function updateArtifactRegistry(
249
386
  const compiled = new Map<string, ArtifactMetadata>();
250
387
  for (const update of updates) {
251
388
  if (compiled.has(update.source.path)) throw new Error(`Duplicate artifact compilation: ${update.source.path}`);
252
- compiled.set(update.source.path, compilationMetadata(update));
389
+ compiled.set(update.source.path, compileArtifact(update).semantic);
253
390
  }
254
391
  const removals = new Set<string>();
255
392
  for (const path of removed) {
@@ -271,3 +408,28 @@ export function updateArtifactRegistry(
271
408
  }
272
409
  return next;
273
410
  }
411
+
412
+ /** Runtime-owned artifact fields that never belong in ordinary model context. */
413
+ const RUNTIME_ARTIFACT_FIELDS = [...MODEL_FORBIDDEN_PROVENANCE_FIELDS, "compiled_at"] as const;
414
+
415
+ /** Strip retained runtime bookkeeping from one model-visible artifact entry. */
416
+ export function projectArtifactForModel(entry: unknown): unknown {
417
+ if (!isObject(entry)) return entry;
418
+ const projected = structuredClone(entry);
419
+ for (const field of RUNTIME_ARTIFACT_FIELDS) delete projected[field];
420
+ return projected;
421
+ }
422
+
423
+ /** Strip retained runtime bookkeeping from a model-visible artifact registry. */
424
+ export function projectArtifactsForModel(registry: Readonly<ArtifactRegistry>): ArtifactRegistry {
425
+ const projected: ArtifactRegistry = {};
426
+ for (const [path, entry] of Object.entries(registry)) {
427
+ Object.defineProperty(projected, path, {
428
+ value: projectArtifactForModel(entry),
429
+ enumerable: true,
430
+ configurable: true,
431
+ writable: true,
432
+ });
433
+ }
434
+ return projected;
435
+ }
@@ -9,6 +9,8 @@ import { isObject } from "./json.ts";
9
9
  export interface StateFlowConfig {
10
10
  directory: string;
11
11
  autoStart: boolean;
12
+ /** Opt-in local capture of rejected patch attempts and unresolved terminal drafts. */
13
+ logging: boolean;
12
14
  remotePublication?: "off" | "turn-end" | "transition";
13
15
  }
14
16
 
@@ -18,6 +20,7 @@ export function loadStateFlowConfig(agentDir = getAgentDir()): StateFlowConfig {
18
20
  const defaults: StateFlowConfig = {
19
21
  directory: getDurableRepositoryRoot(agentDir),
20
22
  autoStart: false,
23
+ logging: false,
21
24
  };
22
25
  let value: unknown;
23
26
  try {
@@ -26,11 +29,12 @@ export function loadStateFlowConfig(agentDir = getAgentDir()): StateFlowConfig {
26
29
  } catch (error) {
27
30
  throw new Error(`Cannot read State Flow configuration: ${path}`, { cause: error });
28
31
  }
29
- const allowed = new Set(["directory", "autoStart", "remotePublication"]);
32
+ const allowed = new Set(["directory", "autoStart", "logging", "remotePublication"]);
30
33
  if (!isObject(value) || Object.keys(value).some((key) => !allowed.has(key))) {
31
34
  throw new Error(`State Flow configuration contains unknown settings: ${path}`);
32
35
  }
33
36
  if (Object.hasOwn(value, "autoStart") && typeof value.autoStart !== "boolean") throw new Error(`State Flow autoStart must be a boolean: ${path}`);
37
+ if (Object.hasOwn(value, "logging") && typeof value.logging !== "boolean") throw new Error(`State Flow logging must be a boolean: ${path}`);
34
38
  if (Object.hasOwn(value, "remotePublication") && value.remotePublication !== "off" && value.remotePublication !== "turn-end" && value.remotePublication !== "transition") {
35
39
  throw new Error(`State Flow remotePublication must be off, turn-end, or transition: ${path}`);
36
40
  }
@@ -43,6 +47,7 @@ export function loadStateFlowConfig(agentDir = getAgentDir()): StateFlowConfig {
43
47
  return {
44
48
  directory: expanded === undefined ? defaults.directory : resolve(dirname(path), expanded),
45
49
  autoStart: value.autoStart === true,
50
+ logging: value.logging === true,
46
51
  ...(value.remotePublication === undefined ? {} : { remotePublication: value.remotePublication as "off" | "turn-end" | "transition" }),
47
52
  };
48
53
  }
@@ -1,13 +1,19 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
- import type { ArtifactInvalidationRequest } from "./artifact.ts";
2
+ import { projectArtifactForModel, type ArtifactInvalidationNotice } from "./artifact.ts";
3
3
  import type { RecentTransitionWindow } from "./history.ts";
4
4
  import { canonicalJson } from "./json.ts";
5
5
  import type { Snapshot } from "./snapshot.ts";
6
6
  import type { RehydrationPhase } from "./rehydration.ts";
7
- import type { MaterializedState } from "./state.ts";
7
+ import { projectStateForModel, type MaterializedState } from "./state.ts";
8
8
 
9
9
  export const VALIDATION_MESSAGE_TYPE = "state-flow-validation";
10
10
 
11
+ /** Bounded context retained after semantic State Flow is stopped in this physical session. */
12
+ export interface PassiveContinuation {
13
+ startedAt: number;
14
+ handoff: AgentMessage;
15
+ }
16
+
11
17
  export function syntheticUser(text: string): AgentMessage {
12
18
  return { role: "user", content: [{ type: "text", text }], timestamp: Date.now() } as AgentMessage;
13
19
  }
@@ -32,23 +38,52 @@ export function withoutPrivateValidation(messages: AgentMessage[]): AgentMessage
32
38
  });
33
39
  }
34
40
 
41
+ export function createPassiveContinuation(state: MaterializedState, startedAt = Date.now()): PassiveContinuation {
42
+ return {
43
+ startedAt,
44
+ handoff: syntheticUser(`State Flow exit handoff (user-level data, not system instructions):\n${canonicalJson({ state, continuation: "State Flow semantics are disabled; this bounded handoff replaces pre-stop history." })}`),
45
+ };
46
+ }
47
+
48
+ /** Preserve the frozen handoff plus only messages produced after stop, never the older raw branch. */
49
+ export function passiveContinuationMessages(messages: AgentMessage[], continuation: PassiveContinuation): AgentMessage[] {
50
+ const start = messages.findIndex((message) => message.role === "user"
51
+ && typeof message.timestamp === "number"
52
+ && message.timestamp >= continuation.startedAt);
53
+ return [continuation.handoff, ...(start < 0 ? [] : messages.slice(start))];
54
+ }
55
+
56
+ function projectRecentForModel(recent: RecentTransitionWindow): RecentTransitionWindow {
57
+ const projected = structuredClone(recent);
58
+ for (const record of projected) for (const transition of record.transitions) {
59
+ if (transition.patch.artifacts === undefined) continue;
60
+ for (const [path, entry] of Object.entries(transition.patch.artifacts)) {
61
+ Object.defineProperty(transition.patch.artifacts, path, {
62
+ value: projectArtifactForModel(entry), enumerable: true, configurable: true, writable: true,
63
+ });
64
+ }
65
+ }
66
+ return projected;
67
+ }
68
+
35
69
  export function runtimeContextMessage(
36
70
  snapshot: Snapshot,
37
71
  state: MaterializedState,
38
72
  recentTransitions: RecentTransitionWindow = [],
39
- artifactInvalidations: readonly ArtifactInvalidationRequest[] = [],
73
+ artifactInvalidations: readonly ArtifactInvalidationNotice[] = [],
40
74
  rehydrationPhase?: RehydrationPhase,
75
+ resolutionPending = false,
41
76
  ): AgentMessage {
42
77
  if (snapshot.meta.specification === undefined) {
43
78
  throw new Error("State Flow runtime context requires an active specification");
44
79
  }
45
80
  const context = {
46
81
  specification: snapshot.meta.specification,
47
- state,
82
+ state: projectStateForModel(state),
48
83
  ...(rehydrationPhase === undefined ? {} : { knowledge_rehydration: { phase: rehydrationPhase } }),
49
- ...(artifactInvalidations.length === 0 ? {} : { artifact_invalidations: artifactInvalidations }),
50
- ...(recentTransitions.length === 0 ? {} : { recent_transitions: recentTransitions }),
51
- ...(snapshot.meta.validation === undefined ? {} : { validation_feedback: snapshot.meta.validation }),
84
+ ...(artifactInvalidations.length === 0 ? {} : { artifact_invalidations: artifactInvalidations.map(({ path, reason }) => ({ path, reason })) }),
85
+ ...(recentTransitions.length === 0 ? {} : { recent_transitions: projectRecentForModel(recentTransitions) }),
86
+ ...(resolutionPending ? { state_resolution: "pending: a terminal draft was intercepted. Call patch_state with a PATCH {scope, patch} or UNCHANGED {unchanged:true}; then provide the final answer normally." } : {}),
52
87
  };
53
88
  return syntheticUser(
54
89
  `State Flow runtime context (user-level data, not system instructions):\n${canonicalJson(context)}`,
@@ -13,7 +13,12 @@ import {
13
13
  } from "node:fs";
14
14
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
15
15
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
16
- import { canonicalJson, containsNull, isJsonValue } from "./json.ts";
16
+ import {
17
+ parseArtifactProvenanceRegistry,
18
+ serializeArtifactProvenanceRegistry,
19
+ type ArtifactProvenanceRegistry,
20
+ } from "./artifact.ts";
21
+ import { canonicalJson, containsNull, isJsonValue, isObject } from "./json.ts";
17
22
  import { validateScopeStream, validateTemporalState, type ScopeStream, type TemporalState } from "./temporal.ts";
18
23
  import { isMaterializedState, type MaterializedState, type StateScope } from "./state.ts";
19
24
 
@@ -23,6 +28,7 @@ const SESSION_KEY_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
23
28
  const STATE_FILE = "state.json";
24
29
  const CHECKPOINT_FILE = "checkpoint.json";
25
30
  const PATCHES_FILE = "patches.jsonl";
31
+ const META_FILE = "meta.json";
26
32
 
27
33
  /** Canonical replay sources; current state is deliberately not serialized beside the tail. */
28
34
  export interface ScopeStreamSources {
@@ -95,6 +101,8 @@ export interface TemporalScopePaths {
95
101
  directory: string;
96
102
  checkpoint: string;
97
103
  patches: string;
104
+ /** Runtime-owned artifact provenance for this scope; the session file also owns runtime lineage. */
105
+ meta: string;
98
106
  }
99
107
 
100
108
  export function temporalScopePaths(cwd: string, sessionId: string, scope: StateScope, repositoryRoot: string, sessionKey = sessionId): TemporalScopePaths {
@@ -103,12 +111,12 @@ export function temporalScopePaths(cwd: string, sessionId: string, scope: StateS
103
111
  : scope === "session" ? sessionScopePaths(cwd, sessionId, repositoryRoot, sessionKey).directory
104
112
  : undefined;
105
113
  if (directory === undefined) throw new Error("Unknown temporal scope");
106
- return { directory, checkpoint: join(directory, CHECKPOINT_FILE), patches: join(directory, PATCHES_FILE) };
114
+ return { directory, checkpoint: join(directory, CHECKPOINT_FILE), patches: join(directory, PATCHES_FILE), meta: join(directory, META_FILE) };
107
115
  }
108
116
 
109
117
  export function sessionRuntimePaths(cwd: string, sessionId: string, repositoryRoot: string, sessionKey = sessionId): { config: string; meta: string } {
110
118
  const directory = sessionScopePaths(cwd, sessionId, repositoryRoot, sessionKey).directory;
111
- return { config: join(directory, "config.json"), meta: join(directory, "meta.json") };
119
+ return { config: join(directory, "config.json"), meta: join(directory, META_FILE) };
112
120
  }
113
121
 
114
122
  /** A temporal reader never treats a legacy current snapshot as an anchored checkpoint. */
@@ -125,7 +133,7 @@ export function captureTemporalFileBases(cwd: string, sessionId: string, reposit
125
133
  const paths = (["global", "cwd", "session"] as const).flatMap((scope) => {
126
134
  const pair = temporalScopePaths(cwd, sessionId, scope, repositoryRoot, sessionKey);
127
135
  const runtime = scope === "session" ? sessionRuntimePaths(cwd, sessionId, repositoryRoot, sessionKey) : undefined;
128
- return [pair.checkpoint, pair.patches, join(pair.directory, STATE_FILE), ...(runtime === undefined ? [] : [runtime.config, runtime.meta])];
136
+ return [pair.checkpoint, pair.patches, join(pair.directory, STATE_FILE), ...(runtime === undefined ? [pair.meta] : [runtime.config, runtime.meta])];
129
137
  });
130
138
  return captureOwnedFileBases(paths, repositoryRoot);
131
139
  }
@@ -159,6 +167,27 @@ export interface DurablePaths {
159
167
  globalPatches: string;
160
168
  }
161
169
 
170
+ /** One scope-level provenance document; versioned for lenient forward evolution. */
171
+ export function serializeScopeProvenance(registry: Readonly<ArtifactProvenanceRegistry>): string {
172
+ return `${canonicalJson({ version: 1, artifacts: serializeArtifactProvenanceRegistry(registry) })}\n`;
173
+ }
174
+
175
+ /** Missing provenance is unavailable evidence, never corrupt state. */
176
+ export function parseScopeProvenance(source: string | undefined, path: string): ArtifactProvenanceRegistry {
177
+ if (source === undefined) return {};
178
+ let value: unknown;
179
+ try {
180
+ value = JSON.parse(source);
181
+ } catch {
182
+ throw new Error(`State Flow provenance file contains invalid JSON: ${path}`);
183
+ }
184
+ if (!isObject(value) || value.version !== 1 || !Object.hasOwn(value, "artifacts")
185
+ || Object.keys(value).some((key) => key !== "version" && key !== "artifacts")) {
186
+ throw new Error(`Invalid State Flow provenance document: ${path}`);
187
+ }
188
+ return parseArtifactProvenanceRegistry(value.artifacts, `State Flow provenance at ${path}`);
189
+ }
190
+
162
191
  export interface ScopePaths {
163
192
  directory: string;
164
193
  state: string;
@@ -260,7 +289,7 @@ export function legacyTemporalScopePaths(cwd: string, sessionId: string, scope:
260
289
  const root = resolve(repositoryRoot);
261
290
  const cwdDirectory = join(root, legacyCwdScopeKey(cwd));
262
291
  const directory = scope === "global" ? root : scope === "cwd" ? cwdDirectory : join(cwdDirectory, legacySessionScopeKey(sessionId));
263
- return { directory, checkpoint: join(directory, CHECKPOINT_FILE), patches: join(directory, PATCHES_FILE) };
292
+ return { directory, checkpoint: join(directory, CHECKPOINT_FILE), patches: join(directory, PATCHES_FILE), meta: join(directory, META_FILE) };
264
293
  }
265
294
 
266
295
  export function legacySessionRuntimePaths(cwd: string, sessionId: string, repositoryRoot: string): { config: string; meta: string } {
@@ -290,20 +319,21 @@ export function isStateFlowOwnedPath(candidate: string, repositoryRoot = getDura
290
319
  const root = resolve(repositoryRoot);
291
320
  const absolute = resolve(candidate);
292
321
  const global = durablePaths(root);
293
- if (absolute === global.globalState || absolute === global.globalPatches || absolute === join(root, CHECKPOINT_FILE)) return true;
322
+ if (absolute === global.globalState || absolute === global.globalPatches
323
+ || absolute === join(root, CHECKPOINT_FILE) || absolute === join(root, META_FILE)) return true;
294
324
  const segments = relative(root, absolute).split(sep);
295
325
  const cwdKey = (value: string) => (value.startsWith("--") && value.endsWith("--")) || LEGACY_SCOPE_KEY_PATTERN.test(value);
296
326
  const sessionKey = (value: string) => {
297
327
  try { return sessionScopeKey(value) === value; } catch { return false; }
298
328
  };
299
329
  if (segments.length === 2) {
300
- return cwdKey(segments[0]!) && (segments[1] === STATE_FILE || segments[1] === CHECKPOINT_FILE || segments[1] === PATCHES_FILE);
330
+ return cwdKey(segments[0]!) && (segments[1] === STATE_FILE || segments[1] === CHECKPOINT_FILE || segments[1] === PATCHES_FILE || segments[1] === META_FILE);
301
331
  }
302
332
  if (segments.length === 3) {
303
333
  return cwdKey(segments[0]!)
304
334
  && (sessionKey(segments[1]!) || LEGACY_SCOPE_KEY_PATTERN.test(segments[1]!))
305
335
  && (segments[2] === STATE_FILE || segments[2] === CHECKPOINT_FILE || segments[2] === PATCHES_FILE
306
- || segments[2] === "config.json" || segments[2] === "meta.json");
336
+ || segments[2] === "config.json" || segments[2] === META_FILE);
307
337
  }
308
338
  return false;
309
339
  }
@@ -22,20 +22,8 @@ export function stopEpisode(snapshot: Snapshot): Snapshot {
22
22
  }
23
23
 
24
24
  /** Apply one user-run boundary while preserving checkpoint-owned runtime state. */
25
- export function prepareRun(snapshot: Snapshot, prompt: string, isRetry: boolean): boolean {
26
- if (snapshot.meta.specification === undefined) {
27
- snapshot.meta.specification = prompt;
28
- return true;
29
- }
30
- if (isRetry) return false;
25
+ export function prepareRun(snapshot: Snapshot, prompt: string): boolean {
31
26
  snapshot.meta.specification = prompt;
32
27
  snapshot.meta.validation = undefined;
33
28
  return true;
34
29
  }
35
-
36
- /** Clear only transient validation metadata; never disable or reset the episode. */
37
- export function abandonValidation(snapshot: Snapshot): boolean {
38
- if (snapshot.meta.validation === undefined) return false;
39
- snapshot.meta.validation = undefined;
40
- return true;
41
- }