@llblab/pi-kit 0.16.0 → 0.17.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 (53) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +1 -1
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +4 -4
  4. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +3 -1
  5. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +24 -0
  6. package/node_modules/@llblab/pi-state-flow/README.md +5 -2
  7. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +1 -1
  8. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +7 -0
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.d.ts +2 -4
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +35 -240
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/history.js +1 -1
  12. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.d.ts +18 -0
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.js +44 -1
  14. package/node_modules/@llblab/pi-state-flow/dist/lib/migration.d.ts +2 -2
  15. package/node_modules/@llblab/pi-state-flow/dist/lib/migration.js +21 -3
  16. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +7 -0
  17. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +55 -5
  18. package/node_modules/@llblab/pi-state-flow/dist/lib/publication.d.ts +17 -0
  19. package/node_modules/@llblab/pi-state-flow/dist/lib/publication.js +102 -0
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/query.d.ts +16 -0
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +98 -12
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +4 -3
  23. package/node_modules/@llblab/pi-state-flow/dist/lib/session.d.ts +17 -0
  24. package/node_modules/@llblab/pi-state-flow/dist/lib/session.js +38 -0
  25. package/node_modules/@llblab/pi-state-flow/dist/lib/state.d.ts +5 -0
  26. package/node_modules/@llblab/pi-state-flow/dist/lib/state.js +10 -2
  27. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +1 -0
  28. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +1 -1
  29. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +4 -3
  30. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  31. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +79 -0
  32. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +23 -129
  33. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +22 -17
  34. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +1 -1
  35. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +15 -6
  36. package/node_modules/@llblab/pi-state-flow/docs/usage.md +2 -2
  37. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +6 -1
  38. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +37 -229
  39. package/node_modules/@llblab/pi-state-flow/lib/history.ts +1 -1
  40. package/node_modules/@llblab/pi-state-flow/lib/logging.ts +53 -1
  41. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +18 -4
  42. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +51 -5
  43. package/node_modules/@llblab/pi-state-flow/lib/publication.ts +96 -0
  44. package/node_modules/@llblab/pi-state-flow/lib/query.ts +99 -11
  45. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +3 -2
  46. package/node_modules/@llblab/pi-state-flow/lib/session.ts +45 -0
  47. package/node_modules/@llblab/pi-state-flow/lib/state.ts +13 -2
  48. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +2 -1
  49. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +4 -3
  50. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  51. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +79 -0
  52. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +23 -129
  53. package/package.json +2 -2
@@ -34,24 +34,33 @@ The model-facing surface remains small:
34
34
 
35
35
  ## Semantic model
36
36
 
37
- Each scope may contain five semantic planes:
37
+ Each scope may contain six semantic planes:
38
38
 
39
39
  ```text
40
40
  global | CWD | session
41
41
  ├── artifacts
42
42
  ├── contract
43
43
  ├── working
44
+ ├── intents
44
45
  ├── response (session-owned where applicable)
45
46
  └── lazy
46
47
  ```
47
48
 
48
- `artifacts`, `contract`, `working`, and `response` remain hot. `lazy` differs only in projection policy:
49
+ `artifacts`, `contract`, `working`, `intents`, and `response` remain hot. `intents` may keep compact active direction while referring to large supporting detail in `lazy`. `lazy` differs only in projection policy:
49
50
 
50
51
  - It is canonical semantic JSON, validated and versioned with its owning scope.
51
52
  - It is excluded from the ordinary baseline effective-state body.
52
53
  - It becomes model-visible only through bounded baseline navigation hints or explicit `read_state` output.
53
54
  - Reading it does not mutate state, freshness, usage metadata, history, or future context.
54
55
 
56
+ ### Semantic references
57
+
58
+ A reference is semantic content, not a runtime type. The optional `{"$ref":"cwd.lazy.plan"}` object provides the structured state-reference form. Inside any ordinary string or paragraph, a semantic-state reference uses `$` immediately followed by one valid `read_state` path, for example `$effective.lazy.memory[7]`. The prefix separates a deliberate reference from incidental path-like text and provides a deterministic seam if code-based parsing is ever justified. File paths, document sections, URIs, artifact locators, Skill identities, and agent identities retain their native syntax.
59
+
60
+ State Flow preserves all forms exactly as ordinary JSON. It does not scan prose, index targets, validate existence, rewrite relative locators, or infer authority, dependency, hydration, execution, or completion. When a reference matters, the agent resolves it explicitly with `read_state` for semantic paths or the appropriate external read/tool for other resources. A locator supports retrieval but does not replace content required for the current decision.
61
+
62
+ Reference repair is reactive, not a maintenance scan. The agent does not enumerate, audit, or resolve references merely to test them. Only after one requested `read_state` value path is missing does State Flow perform one bounded reverse lookup over current model-patchable semantic planes for exact structured `$ref` and `$path` matches. If found, the tool returns `{value:null, hint:[{type:"dangling-reference", message, paths}]}`. `hint` is explicit top-level metadata rather than state data; its action message asks for reconciliation and its path array contains at most three runtime-verified current owners. The null sentinel is never returned alone for this case. Keys, patch, and multi-path reads keep ordinary all-or-error semantics; no durable match retains the missing-path error and does not prove the agent invented the path. The agent may then reconcile a proven stale owning value while preserving surrounding meaning. This applies equally to `$ref` objects and contextual references in prose. Effective-state absence alone does not identify the owner, and unavailable history, inaccessible external resources, or transient read failure do not prove that a durable reference is broken.
63
+
55
64
  Valid lazy values include:
56
65
 
57
66
  ```json
@@ -105,7 +114,7 @@ Active obligations, current constraints, unresolved next actions, and facts requ
105
114
 
106
115
  `projection` defaults to `value`. A batch uses one projection for every path, evaluates every path against one captured state view, and returns results in request order. Duplicate paths remain duplicate results. If any path is invalid, the whole read fails; there is no mixed partial result.
107
116
 
108
- The legacy single `path` form remains first-class rather than mere compatibility syntax. Existing `offset`/`scope` input may remain temporarily during migration but cannot combine with `path` or `paths`.
117
+ The single `path` form is first-class. The retired top-level `offset` and `scope` inputs are rejected; history and ownership belong in the semantic path itself, such as `cwd[1].lazy.memory`.
109
118
 
110
119
  ### Response shape
111
120
 
@@ -117,13 +126,13 @@ There are three projections:
117
126
  | `keys` | `{ "meta": ..., "keys": ... }` | Minimal structural facts followed by immediate keys |
118
127
  | `patch` | `{ "patch": ... }` | Historical semantic patch at the selected boundary |
119
128
 
120
- A single-path request returns one payload. A multi-path request returns positionally aligned arrays under the same projection fields.
129
+ A single-path request returns one payload. A multi-path request returns positionally aligned arrays under the same projection fields. One missing value path with exact current durable references instead returns `{ "value": null, "hint": [{ "type": "dangling-reference", "message": "Reconcile the verified current values that reference this path.", "paths": ["cwd.working.note"] }] }`; this explicit sentinel is diagnostic metadata, not semantic state.
121
130
 
122
- `value` deliberately mirrors the effective-state snapshot injected at iteration start: it is semantic state without revision, provenance, range, transport, or storage fields. `patch` likewise contains only the selected semantic patch. Only structural discovery earns `meta`, and `keys` remains the final and most valuable field in that response.
131
+ `value` otherwise deliberately mirrors the effective-state snapshot injected at iteration start: it is semantic state without revision, provenance, range, transport, or storage fields. `patch` likewise contains only the selected semantic patch. Only structural discovery earns `meta`, and `keys` remains the final and most valuable field in that response.
123
132
 
124
133
  The response does not repeat the requested path or projection and does not return an internal revision. Runtime owns revision selection, locking, CAS, and publication; the model cannot improve correctness by echoing that machinery.
125
134
 
126
- Errors use the normal tool-error channel rather than successful JSON containing an `error` field.
135
+ Errors use the normal tool-error channel rather than successful JSON containing an `error` field. The explicit dangling-reference sentinel above is the sole missing-path exception; keys, patch, multi-path, and unmatched value reads still fail.
127
136
 
128
137
  ### Path and range model
129
138
 
@@ -20,7 +20,7 @@ State Flow does not undo tool effects. After interruption or returning to an old
20
20
 
21
21
  Native fork replacement copies the source session checkpoint, retained patch tail and matching provenance into the new session's own storage. Global/CWD streams and provenance stay current and unchanged. An earlier fork selection copies that point's private state, not the parent's later private work. Parent data/history remain intact; selected enablement is retained, so a stopped source does not become enabled automatically.
22
22
 
23
- The child starts at step zero and a new temporal origin. Its copied tail is preserved, but pre-origin records are not seven past aligned `state[n]` boundaries. The child's own transitions build its hot window; owned checkpoints support normal reload/resume. Parent Stop projection is not inherited, including after child reload.
23
+ The child starts at step zero and a new temporal origin. Its copied tail is preserved, but pre-origin records are not seven past aligned causal boundaries addressable through `effective[n]` or scoped paths. The child's own transitions build its hot window; owned checkpoints support normal reload/resume. Parent Stop projection is not inherited, including after child reload.
24
24
 
25
25
  Initial copying requires a native fork start event, a regular canonical direct-parent session file, matching CWD/identity, a readable temporal source and an unused child namespace. Missing/unsafe evidence or CAS conflicts leave the copy unavailable rather than importing unrelated or newer private state. Explicit Start can retry an unaccepted copy in the same loaded fork after the cause is corrected.
26
26
 
@@ -144,4 +144,4 @@ The agent should use sufficient materialized knowledge before rereading files. R
144
144
 
145
145
  Knowledge discovery finds regular lowercase `*.md` beneath its configured root, hashes opaque bytes, and skips symlinks. Only confirmed missing Markdown paths within an available root are pruned; external/non-Markdown artifacts and state under a missing whole root are preserved. An unavailable root makes freshness unknown in status. Successful reads of stale ordinary candidates require same-path global compilation; Skill reads require CWD compilation. The runtime owns provenance: model patches cannot write or delete individual freshness fields, including legacy spellings. Existing legacy entries remain readable and semantically editable. Missing freshness evidence means unknown-but-usable, not proof that a source was acquired.
146
146
 
147
- The optional `state-flow-memory` Skill handles explicit bounded curation, required feature/release/project phase-boundary reconciliation, and external promotion. Ordinary handoffs clean only touched and obviously stale visible branches; the Skill supplies the fuller scoped migration procedure when a phase boundary or explicit request earns it. It is not a background maintenance loop. Promotion must verify the destination before removing the only accepted source copy. Artifact/compiler details and model-tool contracts belong in the [architecture](architecture.md#artifact-routing).
147
+ The packaged `state-flow-guide` Skill answers concrete operational questions about reads, patches, inheritance, acquisition, finalization, and recovery without initiating cleanup. The separate `state-flow-memory` Skill handles explicit bounded curation, feature/release/project phase-boundary reconciliation, and external promotion. Ordinary handoffs clean only touched and obviously stale visible branches; neither Skill is a background maintenance loop. Promotion must verify the destination before removing the only accepted source copy. Artifact/compiler details and model-tool contracts belong in the [architecture](architecture.md#artifact-routing).
@@ -20,7 +20,7 @@ import {
20
20
  } from "./artifact.ts";
21
21
  import { canonicalJson, isJsonValue, isObject } from "./json.ts";
22
22
  import { validateScopeStream, validateTemporalState, type ScopeStream, type TemporalState } from "./temporal.ts";
23
- import type { StateScope } from "./state.ts";
23
+ import { migratePreIntentState, type StateScope } from "./state.ts";
24
24
 
25
25
  const SESSION_KEY_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
26
26
  const STATE_FILE = "state.json";
@@ -100,6 +100,7 @@ export function classifyScopeStream(
100
100
  }
101
101
  const temporal = meta?.temporal;
102
102
  if (isObject(temporal) && Object.hasOwn(temporal, "checkpoint") && Array.isArray(temporal.patches)) {
103
+ checkpoint = migratePreIntentState(checkpoint) ?? checkpoint;
103
104
  const owner = meta?.owner;
104
105
  if (scope === "cwd" && expectedCwd !== undefined
105
106
  && (!isObject(owner) || Object.keys(owner).join(",") !== "cwd" || owner.cwd !== resolve(expectedCwd))) {
@@ -126,6 +127,10 @@ export function classifyScopeStream(
126
127
  } else if (scope === "cwd" && expectedCwd !== undefined) {
127
128
  throw new Error("State Flow CWD scope identity is missing");
128
129
  }
130
+ if (isObject(legacyCheckpoint) && isObject(legacyCheckpoint.state)) {
131
+ const migrated = migratePreIntentState(legacyCheckpoint.state);
132
+ if (migrated) legacyCheckpoint = { ...legacyCheckpoint, state: migrated };
133
+ }
129
134
  const stream = { checkpoint: legacyCheckpoint, patches };
130
135
  validateScopeStream(stream, scope);
131
136
  return { kind: "present", stream };
@@ -4,27 +4,24 @@ import { StringEnum, Type } from "@earendil-works/pi-ai";
4
4
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
6
  import { Text } from "@earendil-works/pi-tui";
7
- import { assistantToolCallCount, finalizedAssistantResponse, stateFlowProtocol } from "./protocol.ts";
7
+ import { assistantToolCallCount, finalizedAssistantResponse, formatPatchStateArguments, normalizePatchStateArguments, separatedFailure, separatedOutput, stateFlowProtocol } from "./protocol.ts";
8
8
  import { createPassiveContinuation, currentRunTrajectory, lazyNavigationHint, passiveContinuationMessages, runtimeContextMessage, syntheticUser, VALIDATION_MESSAGE_TYPE, withoutPrivateValidation, type PassiveContinuation } from "./context.ts";
9
9
  import { ArtifactReadTracker } from "./acquisition.ts";
10
10
  import { loadStateFlowConfig } from "./config.ts";
11
11
  import { createStateFlowTelegramAdapter, type StateFlowTelegramControlResult, type StateFlowTelegramLoader } from "./telegram.ts";
12
- import { isAbsolute, relative, resolve, sep } from "node:path";
12
+ import { isAbsolute, resolve } from "node:path";
13
13
  import { SkillReadTracker } from "./skills.ts";
14
14
  import { emptySnapshot, migrationFailure, persistableSnapshot, RevisionUnavailableError, type Snapshot } from "./snapshot.ts";
15
15
  import { readNativeSessionHeader } from "./continuation.ts";
16
16
  import { MissingSessionRuntimeError, SharedScopeRemovalConflictError, TemporalRuntime, type RuntimePublication } from "./runtime.ts";
17
17
  import { emptyState, overlayStates, projectStateForModel, type AtomicScopePatches, type MaterializedState, type ScopedStates, type StateScope } from "./state.ts";
18
18
  import { commitScopedTransition, stageAtomicScopePatches, stageScopedTransition, validateFinalEligibility, type StagedScopedTransition } from "./transition.ts";
19
- import { discoverSnapshotData, hasPriorConversation, isNewSession, SNAPSHOT_ENTRY_TYPE } from "./session.ts";
19
+ import { discoverSnapshotData, findAssistantToolBatch, findPassiveStopBoundary, hasPriorConversation, isNewSession, retainsPhysicalSessionProjection, SNAPSHOT_ENTRY_TYPE } from "./session.ts";
20
20
  import { compactStatus, detailedStatus, STATUS_KEY, type PendingPublicationDiagnostic, type StatusDiagnostics } from "./status.ts";
21
21
  import { completeRun, prepareRun, resumeEpisode, startEpisode, stopEpisode } from "./episode.ts";
22
22
  import { recoverSnapshot } from "./recovery.ts";
23
23
  import type { RehydrationPhase } from "./rehydration.ts";
24
- import { resolveRemotePublicationPolicy, serializeRemotePublicationPolicyDocument } from "./publication.ts";
25
- import { coalescePublicationTarget, createPublicationQueue, type PublicationQueueState } from "./publication.ts";
26
- import { acquirePublicationWorkerLease, loadPublicationQueue, publicationQueuePath, removePublicationQueue, savePublicationQueue } from "./publication.ts";
27
- import { runPublicationWorker } from "./publication.ts";
24
+ import { loadPublicationQueue, publicationQueuePath, PublicationWorkerController, resolveRemotePublicationPolicy, serializeRemotePublicationPolicyDocument, type PublicationQueueState } from "./publication.ts";
28
25
  import { getKnowledgeRoot, GlobalMarkdownDiscovery } from "./discovery.ts";
29
26
  import { canonicalJson, isObject, sameJson } from "./json.ts";
30
27
  import { readProjectedState, readStatePath } from "./query.ts";
@@ -35,7 +32,7 @@ import {
35
32
  type SessionAddress,
36
33
  } from "./durable.ts";
37
34
  import { projectRecentTransitionsWithLimit, RECENT_TRANSITION_LIMIT } from "./history.ts";
38
- import { appendStateFlowDiagnostic, projectDiagnosticContent, stateFlowLogPath, type StateFlowDiagnosticCategory } from "./logging.ts";
35
+ import { StateFlowDiagnosticWriter, stateFlowLogPath, type DiagnosticExtras, type StateFlowDiagnosticCategory } from "./logging.ts";
39
36
  import { isGitCommitAncestor, pushGitCommit, pushGitTarget, resolveGitPushDestination } from "./git.ts";
40
37
  import {
41
38
  ORDINARY_ARTIFACT_COMPILER,
@@ -54,64 +51,13 @@ export interface StateFlowExtensionOptions {
54
51
  passive?: { bootstrap?: boolean; tools?: boolean };
55
52
  }
56
53
 
54
+ export { formatPatchStateArguments, normalizePatchStateArguments };
55
+
57
56
  export const PATCH_STATE_TOOL_NAME = "patch_state";
58
57
  export const READ_STATE_TOOL_NAME = "read_state";
59
58
  export const MAX_FALLBACK_ATTEMPTS: number = 2;
60
59
  const PASSIVE_STOP_ENTRY_TYPE = "state-flow-passive-stop";
61
60
  const PUBLICATION_SHUTDOWN_WAIT_MS = 2_000;
62
- const PATCH_DISPLAY_SECTION_KEYS = new Set([
63
- "global",
64
- "cwd",
65
- "session",
66
- "artifacts",
67
- "contract",
68
- "working",
69
- "response",
70
- "final",
71
- ]);
72
-
73
- /** Keep successful patch JSON valid while separating adjacent scopes and memory sections visually. */
74
- export function formatPatchStateArguments(args: unknown): string {
75
- const seenAtIndent = new Set<number>();
76
- return JSON.stringify(args, null, 2).split("\n").flatMap((line) => {
77
- const indent = line.length - line.trimStart().length;
78
- const match = /^(\s+)"([^"]+)":/.exec(line);
79
- if (match === null || !PATCH_DISPLAY_SECTION_KEYS.has(match[2])) {
80
- for (const seenIndent of seenAtIndent) {
81
- if (seenIndent > indent) seenAtIndent.delete(seenIndent);
82
- }
83
- return [line];
84
- }
85
- const separator = seenAtIndent.has(indent) ? [""] : [];
86
- seenAtIndent.add(indent);
87
- return [...separator, line];
88
- }).join("\n");
89
- }
90
-
91
- /** Normalize a bounded compatibility superset without advertising aliases in the model-facing contract. */
92
- export function normalizePatchStateArguments(args: unknown): any {
93
- if (!isObject(args) || !Object.hasOwn(args, "final")) return args;
94
- const value = args.final;
95
- let final: boolean;
96
- if (typeof value === "boolean") final = value;
97
- else if (value === 1) final = true;
98
- else if (value === 0) final = false;
99
- else if (typeof value === "string" && value.trim().toLowerCase() === "true") final = true;
100
- else if (typeof value === "string" && value.trim().toLowerCase() === "false") final = false;
101
- else return args;
102
- return { ...args, final };
103
- }
104
-
105
- /** Keep tool output visually separated from its heading with exactly one leading newline. */
106
- function separatedOutput(text: string): string {
107
- return `\n${text.replace(/^\n+/, "")}`;
108
- }
109
-
110
- /** Keep a failed tool invocation visually separated from its rendered error without changing error semantics. */
111
- function separatedFailure(error: unknown): Error {
112
- const message = error instanceof Error ? error.message : String(error);
113
- return new Error(separatedOutput(message), error instanceof Error ? { cause: error } : undefined);
114
- }
115
61
 
116
62
  export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowExtensionOptions = {}): void {
117
63
  const agentDir = options.agentDir ?? getAgentDir();
@@ -145,15 +91,22 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
145
91
  let pendingPublication: PendingPublicationDiagnostic | undefined;
146
92
  let rehydrationPhase: RehydrationPhase | undefined;
147
93
  let turnPublicationTarget: string | undefined;
148
- const activePublicationWorkers = new Map<string, { controller: AbortController; done: Promise<void> }>();
149
- let publicationStopped = false;
150
94
  let publicationShutdown: Promise<void> | undefined;
151
95
  const repositoryRoot = resolve(options.repositoryRoot ?? config.directory);
96
+ const diagnosticWriter = new StateFlowDiagnosticWriter(config.logging, stateFlowLogPath(agentDir), repositoryRoot, (message) => activeContext?.ui.notify(message, "warning"));
97
+ const publicationWorker = new PublicationWorkerController({
98
+ resolveDestination: () => resolveGitPushDestination(repositoryRoot),
99
+ isAncestor: (ancestor, descendant) => isGitCommitAncestor(repositoryRoot, ancestor, descendant),
100
+ push: (destination, target, signal) => pushGitTarget(repositoryRoot, destination, target, signal),
101
+ onDiverged: (dropped, target) => activeContext && recordDiagnostic(
102
+ `Retired publication queue target ${dropped.target} after a journal lineage rewrite; retargeting to ${target}`,
103
+ "publication-conflict", activeContext,
104
+ ),
105
+ });
152
106
  const skillReads = new SkillReadTracker();
153
107
  const artifactReads = new ArtifactReadTracker();
154
108
  const globalMarkdown = new GlobalMarkdownDiscovery(options.knowledgeRoot ?? getKnowledgeRoot(agentDir));
155
109
  let artifactInvalidations: ArtifactInvalidationRequest[] = [];
156
- let loggingWarningReported = false;
157
110
  let telegramStartPending = false;
158
111
 
159
112
  function sessionAddress(ctx: ExtensionContext): SessionAddress {
@@ -206,27 +159,6 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
206
159
  artifactReads.clear();
207
160
  }
208
161
 
209
- function passiveStopBoundary(ctx: ExtensionContext): { at: number; from?: number } | undefined {
210
- for (const entry of [...ctx.sessionManager.getBranch()].reverse()) {
211
- try {
212
- if (entry?.type !== "custom" || entry.customType !== PASSIVE_STOP_ENTRY_TYPE) continue;
213
- const { at, from, reset, owner } = (entry.data as { at?: unknown; from?: unknown; reset?: unknown; owner?: unknown } | undefined) ?? {};
214
- if (reset === true && owner === ctx.sessionManager.getSessionId()) return undefined;
215
- if (typeof at === "number" && Number.isSafeInteger(at) && at >= 0) return {
216
- at,
217
- ...(typeof from === "number" && Number.isSafeInteger(from) && from >= 0 ? { from } : {}),
218
- };
219
- } catch {
220
- // A hostile unrelated branch entry cannot manufacture or suppress a valid marker.
221
- }
222
- }
223
- return undefined;
224
- }
225
-
226
- function retainsPhysicalSessionProjection(reason: unknown): boolean {
227
- return reason === undefined || reason === "startup" || reason === "reload" || reason === "resume";
228
- }
229
-
230
162
  function deferArtifactRefresh(): void {
231
163
  artifactInvalidations = [];
232
164
  artifactReads.setCandidates([]);
@@ -289,21 +221,6 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
289
221
  : active.filter((name) => !owned.includes(name)));
290
222
  }
291
223
 
292
- function assistantToolBatch(ctx: ExtensionContext, toolCallId: string): string[] | undefined {
293
- for (let cursor = ctx.sessionManager.getLeafEntry(); cursor; cursor = cursor.parentId ? ctx.sessionManager.getEntry(cursor.parentId) : undefined) {
294
- const entry = cursor as { type?: unknown; message?: { role?: unknown; content?: unknown } };
295
- if (entry.type !== "message" || entry.message?.role !== "assistant" || !Array.isArray(entry.message.content)) continue;
296
- const calls = entry.message.content.filter((block): block is { type: "toolCall"; id: string; name: string } => {
297
- return typeof block === "object" && block !== null
298
- && (block as { type?: unknown }).type === "toolCall"
299
- && typeof (block as { id?: unknown }).id === "string"
300
- && typeof (block as { name?: unknown }).name === "string";
301
- });
302
- if (calls.some(({ id }) => id === toolCallId)) return calls.map(({ name }) => name);
303
- }
304
- return undefined;
305
- }
306
-
307
224
  function recordPublication(publication: RuntimePublication, ctx: ExtensionContext): void {
308
225
  const revision = publication.revision ?? publication.commit;
309
226
  if (revision !== undefined) snapshot.meta.durableBase = revision;
@@ -330,8 +247,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
330
247
  if (mode !== "turn-end" || target === undefined || !/^[0-9a-f]{40,64}$/.test(target)) return;
331
248
  turnPublicationTarget = target;
332
249
  try {
333
- enqueueTurnPublication(ctx);
334
- launchPublicationWorker();
250
+ enqueueTurnPublication();
251
+ publicationWorker.launch();
335
252
  } catch (error) {
336
253
  ctx.ui.notify(
337
254
  `State Flow accepted the local commit; remote publication is deferred: ${error instanceof Error ? error.message : String(error)}`,
@@ -372,95 +289,16 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
372
289
  return true;
373
290
  }
374
291
 
375
- function enqueueTurnPublication(ctx: ExtensionContext): void {
292
+ function enqueueTurnPublication(): void {
376
293
  const target = turnPublicationTarget;
377
294
  turnPublicationTarget = undefined;
378
- if (!target) return;
379
- const destination = resolveGitPushDestination(repositoryRoot);
380
- if (!destination) return;
381
- const path = publicationQueuePath(destination);
382
- const previous = loadPublicationQueue(path);
383
- const next = previous
384
- ? coalescePublicationTarget(previous, destination, target, (ancestor, descendant) => isGitCommitAncestor(repositoryRoot, ancestor, descendant), {
385
- onDivergedLineage: (dropped) => recordDiagnostic(
386
- `Retired publication queue target ${dropped.target} after a journal lineage rewrite; retargeting to ${target}`,
387
- "publication-conflict", ctx,
388
- ),
389
- })
390
- : createPublicationQueue(destination, target);
391
- savePublicationQueue(path, next, previous);
392
- }
393
-
394
- function launchPublicationWorker(): void {
395
- if (publicationStopped) return;
396
- let destination: ReturnType<typeof resolveGitPushDestination>;
397
- try {
398
- destination = resolveGitPushDestination(repositoryRoot);
399
- } catch (error) {
400
- if (error instanceof Error && /ENOENT/.test(error.message)) return;
401
- throw error;
402
- }
403
- if (!destination) return;
404
- const path = publicationQueuePath(destination);
405
- if (activePublicationWorkers.has(path)) return;
406
- let queued: PublicationQueueState | undefined;
407
- let lease: ReturnType<typeof acquirePublicationWorkerLease>;
408
- try {
409
- queued = loadPublicationQueue(path);
410
- if (!queued) return;
411
- lease = acquirePublicationWorkerLease(path);
412
- } catch {
413
- return;
414
- }
415
- if (!lease) return;
416
- const controller = new AbortController();
417
- const done = runPublicationWorker(
418
- queued,
419
- ({ target }) => pushGitTarget(repositoryRoot, destination, target, controller.signal),
420
- () => loadPublicationQueue(path) ?? queued,
421
- (ancestor, descendant) => isGitCommitAncestor(repositoryRoot, ancestor, descendant),
422
- ).then((result) => {
423
- if (publicationStopped) return; // Late outcomes belong to an unconfirmed queue, not the replacement generation.
424
- const current = loadPublicationQueue(path);
425
- if (!current) return;
426
- if (result.next === undefined) {
427
- if (current.target === result.attempted.target) removePublicationQueue(path, current);
428
- return;
429
- }
430
- if (current.target === result.attempted.target || result.next.target === current.target) {
431
- savePublicationQueue(path, result.next, current);
432
- }
433
- }).catch(() => {
434
- // Queue/CAS truth remains durable; status and a later activation expose retry.
435
- }).finally(() => {
436
- activePublicationWorkers.delete(path);
437
- try {
438
- lease.release();
439
- if (!publicationStopped && loadPublicationQueue(path)?.status === "pending") launchPublicationWorker();
440
- } catch {
441
- // Failed lease cleanup or malformed persistence stays inert until retry or repair.
442
- }
443
- });
444
- activePublicationWorkers.set(path, { controller, done });
295
+ if (target) publicationWorker.enqueue(target);
445
296
  }
446
297
 
447
298
  function shutdownPublicationWorkers(ctx: ExtensionContext): Promise<void> {
448
- publicationStopped = true;
449
- return publicationShutdown ??= (async () => {
450
- const workers = [...activePublicationWorkers.values()];
451
- for (const { controller } of workers) controller.abort();
452
- if (workers.length === 0) return;
453
- let timeout: ReturnType<typeof setTimeout> | undefined;
454
- try {
455
- const completed = await Promise.race([
456
- Promise.all(workers.map(({ done }) => done)).then(() => true),
457
- new Promise<false>((resolve) => { timeout = setTimeout(() => resolve(false), PUBLICATION_SHUTDOWN_WAIT_MS); }),
458
- ]);
459
- if (!completed) ctx.ui.notify(`State Flow push cleanup is unconfirmed after ${PUBLICATION_SHUTDOWN_WAIT_MS}ms; worker leases remain held until child exit.`, "warning");
460
- } finally {
461
- clearTimeout(timeout);
462
- }
463
- })();
299
+ return publicationShutdown ??= publicationWorker.shutdown(PUBLICATION_SHUTDOWN_WAIT_MS).then((completed) => {
300
+ if (!completed) ctx.ui.notify(`State Flow push cleanup is unconfirmed after ${PUBLICATION_SHUTDOWN_WAIT_MS}ms; worker leases remain held until child exit.`, "warning");
301
+ });
464
302
  }
465
303
 
466
304
  function retryPendingPush(ctx: ExtensionContext): void {
@@ -472,8 +310,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
472
310
  if (mode === "turn-end") {
473
311
  turnPublicationTarget = target;
474
312
  try {
475
- enqueueTurnPublication(ctx);
476
- launchPublicationWorker();
313
+ enqueueTurnPublication();
314
+ publicationWorker.launch();
477
315
  } catch (error) {
478
316
  ctx.ui.notify(`State Flow retained local state; asynchronous publication recovery is deferred: ${error instanceof Error ? error.message : String(error)}`, "warning");
479
317
  }
@@ -681,7 +519,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
681
519
  ctx.ui.notify(`State Flow restored disabled: ${snapshot.meta.validation.error}`, "error");
682
520
  }
683
521
  if (retainsPhysicalSessionProjection(sessionStartReason) && runtime?.view) {
684
- const boundary = passiveStopBoundary(ctx);
522
+ const boundary = findPassiveStopBoundary(ctx.sessionManager.getBranch(), ctx.sessionManager.getSessionId(), PASSIVE_STOP_ENTRY_TYPE);
685
523
  if (boundary !== undefined) {
686
524
  const continuation = createPassiveContinuation(
687
525
  projectStateForModel(overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session)),
@@ -705,41 +543,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
705
543
  updateUi(ctx);
706
544
  }
707
545
 
708
- interface DiagnosticExtras {
709
- content?: unknown;
710
- input?: unknown;
711
- tool?: string;
712
- toolCallId?: string;
713
- resolutionAttempt?: number;
714
- terminalEligible?: boolean;
715
- }
716
-
717
546
  function recordDiagnostic(error: string, category: StateFlowDiagnosticCategory, ctx: ExtensionContext, extras: DiagnosticExtras = {}): void {
718
- if (!config.logging) return;
719
- try {
720
- const path = stateFlowLogPath(agentDir);
721
- const fromRepository = relative(repositoryRoot, path);
722
- if (fromRepository === "" || (!isAbsolute(fromRepository) && fromRepository !== ".." && !fromRepository.startsWith(`..${sep}`))) {
723
- throw new Error("diagnostic path overlaps the State Flow repository");
724
- }
725
- appendStateFlowDiagnostic(path, {
726
- at: new Date().toISOString(),
727
- sessionId: sessionAddress(ctx).id,
728
- cwd: resolve(ctx.cwd),
729
- category,
730
- error,
731
- ...(extras.content === undefined ? {} : { content: projectDiagnosticContent(extras.content) }),
732
- ...(extras.input === undefined ? {} : { input: extras.input }),
733
- ...(extras.tool === undefined ? {} : { tool: extras.tool }),
734
- ...(extras.toolCallId === undefined ? {} : { toolCallId: extras.toolCallId }),
735
- ...(extras.resolutionAttempt === undefined ? {} : { resolutionAttempt: extras.resolutionAttempt }),
736
- ...(extras.terminalEligible === undefined ? {} : { terminalEligible: extras.terminalEligible }),
737
- });
738
- } catch (failure) {
739
- if (loggingWarningReported) return;
740
- loggingWarningReported = true;
741
- ctx.ui.notify(`State Flow could not write diagnostics: ${failure instanceof Error ? failure.message : String(failure)}`, "warning");
742
- }
547
+ diagnosticWriter.record(sessionAddress(ctx).id, ctx.cwd, error, category, extras);
743
548
  }
744
549
 
745
550
  function continueFallbackResolution(lastWarning: boolean): void {
@@ -977,8 +782,11 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
977
782
  );
978
783
  }
979
784
  branchHasSnapshot = true;
785
+ // Activation reasserts the selected session stream as a complete cohort. A resumed
786
+ // branch may have no reusable temporal revision after an extension upgrade; a
787
+ // runtime-only write would then reject its selected session as an omitted change.
980
788
  const publication = runtime.view
981
- ? runtime.promote(snapshot) ?? runtime.publish(snapshot)
789
+ ? runtime.promote(snapshot) ?? runtime.publish(snapshot, true)
982
790
  : runtime.initialize(snapshot, true, undefined, branchStartsWithoutRuntime);
983
791
  recordPolicyPublication(publication, ctx);
984
792
  installScopeStates();
@@ -1193,7 +1001,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1193
1001
 
1194
1002
  pi.on("tool_call", (event, ctx) => {
1195
1003
  if (!snapshot.config.enabled) return;
1196
- const batch = assistantToolBatch(ctx, event.toolCallId);
1004
+ const batch = findAssistantToolBatch(ctx.sessionManager, event.toolCallId);
1197
1005
  const patchCalls = batch?.filter((name) => name === PATCH_STATE_TOOL_NAME).length ?? 0;
1198
1006
  if (patchCalls > 0) {
1199
1007
  if (patchCalls !== 1) {
@@ -1264,8 +1072,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1264
1072
  responseCommitted = true;
1265
1073
  bootstrapContinuation = undefined;
1266
1074
  rehydrationPhase = "step";
1267
- enqueueTurnPublication(ctx);
1268
- if (snapshot.meta.remotePublication?.mode === "turn-end") launchPublicationWorker();
1075
+ enqueueTurnPublication();
1076
+ if (snapshot.meta.remotePublication?.mode === "turn-end") publicationWorker.launch();
1269
1077
  completedRunAccepted = !wasBootstrap;
1270
1078
  } catch (error) {
1271
1079
  if (!responseCommitted && specification !== undefined) snapshot.meta.specification = specification;
@@ -1292,7 +1100,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1292
1100
  telegramStartPending = false;
1293
1101
  startStateFlow(ctx);
1294
1102
  }
1295
- if (snapshot.meta.remotePublication?.mode === "turn-end") launchPublicationWorker();
1103
+ if (snapshot.meta.remotePublication?.mode === "turn-end") publicationWorker.launch();
1296
1104
  if (!completedRunAccepted || compactionStopped || !snapshot.config.enabled || snapshot.meta.bootstrap || resolutionPending
1297
1105
  || compactionInFlight || !ctx.isIdle() || ctx.hasPendingMessages() || !snapshot.meta.durableBase
1298
1106
  || !shouldRequestStateFlowCompaction(ctx.getContextUsage())) return;
@@ -1314,7 +1122,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1314
1122
  rehydrationPhase = event.reason === "resume" ? "resume-bootstrap" : "new-bootstrap";
1315
1123
  restoreActiveBranch(ctx, event.reason);
1316
1124
  retryPendingPush(ctx);
1317
- if (snapshot.meta.remotePublication?.mode === "turn-end") launchPublicationWorker();
1125
+ if (snapshot.meta.remotePublication?.mode === "turn-end") publicationWorker.launch();
1318
1126
  updateUi(ctx);
1319
1127
  void telegram.ensure();
1320
1128
  });
@@ -22,7 +22,7 @@ export interface RecentTransition extends AcceptedTransition {
22
22
 
23
23
  export type RecentTransitionWindow = RecentTransition[];
24
24
  const SCOPES = new Set<StateScope>(["global", "cwd", "session"]);
25
- const PATCH_KEYS = new Set(["artifacts", "contract", "working", "response", "lazy"]);
25
+ const PATCH_KEYS = new Set(["artifacts", "contract", "working", "intents", "response", "lazy"]);
26
26
 
27
27
  /** Normalize accepted replacements into recursive-merge replay, including removals. */
28
28
  function replayPatch(before: JsonObject, after: JsonObject): JsonObject {
@@ -1,6 +1,6 @@
1
1
  // Domain: opt-in diagnostic capture for rejected State Flow resolutions.
2
2
  import { appendFileSync, mkdirSync } from "node:fs";
3
- import { dirname, join } from "node:path";
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { isObject } from "./json.ts";
5
5
 
6
6
  export type StateFlowDiagnosticCategory = "invalid-patch" | "publication-conflict" | "terminal-pending" | "finalization";
@@ -45,3 +45,55 @@ export function appendStateFlowDiagnostic(path: string, record: StateFlowDiagnos
45
45
  mkdirSync(dirname(path), { recursive: true });
46
46
  appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
47
47
  }
48
+
49
+ export interface DiagnosticExtras {
50
+ content?: unknown;
51
+ input?: unknown;
52
+ tool?: string;
53
+ toolCallId?: string;
54
+ resolutionAttempt?: number;
55
+ terminalEligible?: boolean;
56
+ }
57
+
58
+ /** Own diagnostic path safety, projection, persistence, and one-shot failure reporting. */
59
+ export class StateFlowDiagnosticWriter {
60
+ private warningReported = false;
61
+ private readonly enabled: boolean;
62
+ private readonly path: string;
63
+ private readonly repositoryRoot: string;
64
+ private readonly notify: (message: string) => void;
65
+
66
+ constructor(enabled: boolean, path: string, repositoryRoot: string, notify: (message: string) => void) {
67
+ this.enabled = enabled;
68
+ this.path = path;
69
+ this.repositoryRoot = repositoryRoot;
70
+ this.notify = notify;
71
+ }
72
+
73
+ record(sessionId: string, cwd: string, error: string, category: StateFlowDiagnosticCategory, extras: DiagnosticExtras = {}): void {
74
+ if (!this.enabled) return;
75
+ try {
76
+ const fromRepository = relative(this.repositoryRoot, this.path);
77
+ if (fromRepository === "" || (!isAbsolute(fromRepository) && fromRepository !== ".." && !fromRepository.startsWith(`..${sep}`))) {
78
+ throw new Error("diagnostic path overlaps the State Flow repository");
79
+ }
80
+ appendStateFlowDiagnostic(this.path, {
81
+ at: new Date().toISOString(),
82
+ sessionId,
83
+ cwd: resolve(cwd),
84
+ category,
85
+ error,
86
+ ...(extras.content === undefined ? {} : { content: projectDiagnosticContent(extras.content) }),
87
+ ...(extras.input === undefined ? {} : { input: extras.input }),
88
+ ...(extras.tool === undefined ? {} : { tool: extras.tool }),
89
+ ...(extras.toolCallId === undefined ? {} : { toolCallId: extras.toolCallId }),
90
+ ...(extras.resolutionAttempt === undefined ? {} : { resolutionAttempt: extras.resolutionAttempt }),
91
+ ...(extras.terminalEligible === undefined ? {} : { terminalEligible: extras.terminalEligible }),
92
+ });
93
+ } catch (failure) {
94
+ if (this.warningReported) return;
95
+ this.warningReported = true;
96
+ this.notify(`State Flow could not write diagnostics: ${failure instanceof Error ? failure.message : String(failure)}`);
97
+ }
98
+ }
99
+ }
@@ -13,7 +13,7 @@ import {
13
13
  type OwnedFileUpdate,
14
14
  } from "./durable.ts";
15
15
  import { parseSessionRuntime, serializeSessionRuntime } from "./snapshot.ts";
16
- import type { StateScope } from "./state.ts";
16
+ import { migratePreIntentState, type StateScope } from "./state.ts";
17
17
 
18
18
  export interface LegacyStorageMigration {
19
19
  bases: DurableFileBase[];
@@ -57,9 +57,14 @@ function migrationDirectories(cwd: string, sessionId: string, root: string, sess
57
57
  try { sessionScopeKey(entry.name); } catch { continue; }
58
58
  const directory = join(cwdDirectory, entry.name);
59
59
  let meta: unknown;
60
+ let runtime: unknown;
60
61
  try { meta = JSON.parse(readFileSync(join(directory, "meta.json"), "utf8")); }
61
62
  catch { continue; }
62
- const identity = meta && typeof meta === "object" && !Array.isArray(meta) ? (meta as { identity?: unknown }).identity : undefined;
63
+ try { runtime = JSON.parse(readFileSync(join(directory, "runtime.json"), "utf8")); }
64
+ catch { /* predecessor sessions keep identity in meta.json */ }
65
+ const identity = meta && typeof meta === "object" && !Array.isArray(meta) && (meta as { identity?: unknown }).identity !== undefined
66
+ ? (meta as { identity?: unknown }).identity
67
+ : runtime && typeof runtime === "object" && !Array.isArray(runtime) ? (runtime as { identity?: unknown }).identity : undefined;
63
68
  if (!identity || typeof identity !== "object" || Array.isArray(identity)) continue;
64
69
  const owner = identity as { cwd?: unknown; sessionId?: unknown };
65
70
  if (owner.cwd === cwdOwner && typeof owner.sessionId === "string" && owner.sessionId.length > 0) {
@@ -85,7 +90,14 @@ export function hasCwdMaterialization(cwd: string, repositoryRoot: string): bool
85
90
  return false;
86
91
  }
87
92
 
88
- /** Detect predecessor snapshots or temporal envelopes without mutating the store. */
93
+ function checkpointNeedsIntentMigration(source: string): boolean {
94
+ const checkpoint = JSON.parse(source) as unknown;
95
+ if (migratePreIntentState(checkpoint) !== undefined) return true;
96
+ return checkpoint !== null && typeof checkpoint === "object" && !Array.isArray(checkpoint)
97
+ && migratePreIntentState((checkpoint as { state?: unknown }).state) !== undefined;
98
+ }
99
+
100
+ /** Detect predecessor snapshots, temporal envelopes, or pre-intents semantic states without mutating the store. */
89
101
  export function hasLegacyStateSources(
90
102
  cwd: string,
91
103
  sessionId: string,
@@ -94,8 +106,10 @@ export function hasLegacyStateSources(
94
106
  ): boolean {
95
107
  const root = resolve(repositoryRoot);
96
108
  return migrationDirectories(cwd, sessionId, root, sessionKey).some(({ directory, scope }) => {
97
- if (lstatSync(join(directory, "checkpoint.json"), { throwIfNoEntry: false }) === undefined) return false;
109
+ const checkpointPath = join(directory, "checkpoint.json");
110
+ if (lstatSync(checkpointPath, { throwIfNoEntry: false }) === undefined) return false;
98
111
  try {
112
+ if (checkpointNeedsIntentMigration(readFileSync(checkpointPath, "utf8"))) return true;
99
113
  const meta = JSON.parse(readFileSync(join(directory, "meta.json"), "utf8"));
100
114
  if (meta === null || typeof meta !== "object" || !("temporal" in meta)) return true;
101
115
  return scope === "session"