@llblab/pi-kit 0.5.2 → 0.7.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 (141) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +5 -5
  3. package/node_modules/@llblab/pi-actors/AGENTS.md +1 -1
  4. package/node_modules/@llblab/pi-actors/CHANGELOG.md +6 -0
  5. package/node_modules/@llblab/pi-actors/README.md +1 -1
  6. package/node_modules/@llblab/pi-actors/dist/lib/async-runs.d.ts +3 -0
  7. package/node_modules/@llblab/pi-actors/dist/lib/async-runs.js +14 -1
  8. package/node_modules/@llblab/pi-actors/dist/lib/command-templates.js +45 -3
  9. package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +1 -1
  10. package/node_modules/@llblab/pi-actors/dist/lib/observability.d.ts +16 -3
  11. package/node_modules/@llblab/pi-actors/dist/lib/observability.js +92 -7
  12. package/node_modules/@llblab/pi-actors/dist/lib/pi.d.ts +0 -1
  13. package/node_modules/@llblab/pi-actors/dist/lib/pi.js +15 -24
  14. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.d.ts +17 -0
  15. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.js +44 -0
  16. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.d.ts +4 -0
  17. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.js +102 -4
  18. package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +58 -39
  19. package/node_modules/@llblab/pi-actors/dist/lib/runtime.js +14 -6
  20. package/node_modules/@llblab/pi-actors/dist/skills/actors/SKILL.md +1 -1
  21. package/node_modules/@llblab/pi-actors/docs/async-runs.md +1 -1
  22. package/node_modules/@llblab/pi-actors/docs/coordinator-delivery.md +18 -23
  23. package/node_modules/@llblab/pi-actors/lib/async-runs.ts +18 -1
  24. package/node_modules/@llblab/pi-actors/lib/command-templates.ts +41 -3
  25. package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +1 -1
  26. package/node_modules/@llblab/pi-actors/lib/observability.ts +119 -5
  27. package/node_modules/@llblab/pi-actors/lib/pi.ts +15 -28
  28. package/node_modules/@llblab/pi-actors/lib/run-delivery-lineage.ts +68 -0
  29. package/node_modules/@llblab/pi-actors/lib/run-delivery.ts +120 -4
  30. package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +69 -44
  31. package/node_modules/@llblab/pi-actors/lib/runtime.ts +17 -6
  32. package/node_modules/@llblab/pi-actors/package.json +1 -1
  33. package/node_modules/@llblab/pi-actors/skills/actors/SKILL.md +1 -1
  34. package/node_modules/@llblab/pi-grow-loop/AGENTS.md +2 -2
  35. package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +4 -0
  36. package/node_modules/@llblab/pi-grow-loop/README.md +6 -6
  37. package/node_modules/@llblab/pi-grow-loop/index.ts +6 -3
  38. package/node_modules/@llblab/pi-grow-loop/package.json +1 -1
  39. package/node_modules/@llblab/pi-state-flow/AGENTS.md +33 -12
  40. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +125 -2
  41. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +87 -45
  42. package/node_modules/@llblab/pi-state-flow/README.md +203 -107
  43. package/node_modules/@llblab/pi-state-flow/docs/README.md +4 -0
  44. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +188 -0
  45. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +42 -0
  46. package/node_modules/@llblab/pi-state-flow/index.ts +164 -2
  47. package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +138 -0
  48. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +273 -0
  49. package/node_modules/@llblab/pi-state-flow/lib/config.ts +48 -0
  50. package/node_modules/@llblab/pi-state-flow/lib/context.ts +18 -5
  51. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +268 -0
  52. package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +117 -0
  53. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +562 -0
  54. package/node_modules/@llblab/pi-state-flow/lib/episode.ts +24 -12
  55. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +606 -70
  56. package/node_modules/@llblab/pi-state-flow/lib/git.ts +666 -0
  57. package/node_modules/@llblab/pi-state-flow/lib/history.ts +95 -0
  58. package/node_modules/@llblab/pi-state-flow/lib/json.ts +24 -0
  59. package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +141 -0
  60. package/node_modules/@llblab/pi-state-flow/lib/memory.ts +52 -0
  61. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +88 -0
  62. package/node_modules/@llblab/pi-state-flow/lib/publication.ts +296 -0
  63. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +23 -7
  64. package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +79 -0
  65. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +264 -0
  66. package/node_modules/@llblab/pi-state-flow/lib/session.ts +6 -0
  67. package/node_modules/@llblab/pi-state-flow/lib/skills.ts +99 -7
  68. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +305 -48
  69. package/node_modules/@llblab/pi-state-flow/lib/state.ts +74 -7
  70. package/node_modules/@llblab/pi-state-flow/lib/status.ts +125 -6
  71. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +196 -0
  72. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +233 -0
  73. package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +70 -24
  74. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +254 -29
  75. package/node_modules/@llblab/pi-state-flow/package.json +8 -2
  76. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +128 -0
  77. package/node_modules/@llblab/pi-telegram/AGENTS.md +14 -9
  78. package/node_modules/@llblab/pi-telegram/BACKLOG.md +20 -4
  79. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +22 -5
  80. package/node_modules/@llblab/pi-telegram/README.md +13 -9
  81. package/node_modules/@llblab/pi-telegram/docs/README.md +1 -0
  82. package/node_modules/@llblab/pi-telegram/docs/architecture.md +220 -18
  83. package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +1 -1
  84. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +70 -19
  85. package/node_modules/@llblab/pi-telegram/docs/outbound.md +13 -7
  86. package/node_modules/@llblab/pi-telegram/docs/public-api.md +13 -5
  87. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +3 -1
  88. package/node_modules/@llblab/pi-telegram/index.ts +4 -1415
  89. package/node_modules/@llblab/pi-telegram/lib/activity.ts +19 -5
  90. package/node_modules/@llblab/pi-telegram/lib/agent-messages.ts +6 -3
  91. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +37 -2
  92. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +600 -135
  93. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +962 -55
  94. package/node_modules/@llblab/pi-telegram/lib/bus.ts +350 -26
  95. package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +544 -0
  96. package/node_modules/@llblab/pi-telegram/lib/commands.ts +234 -11
  97. package/node_modules/@llblab/pi-telegram/lib/config.ts +178 -25
  98. package/node_modules/@llblab/pi-telegram/lib/delivery.ts +18 -18
  99. package/node_modules/@llblab/pi-telegram/lib/extension.ts +1792 -0
  100. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +20 -2
  101. package/node_modules/@llblab/pi-telegram/lib/journal.ts +2184 -126
  102. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +7 -1
  103. package/node_modules/@llblab/pi-telegram/lib/locks.ts +38 -1
  104. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +154 -15
  105. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +74 -40
  106. package/node_modules/@llblab/pi-telegram/lib/outbound-voice.ts +28 -42
  107. package/node_modules/@llblab/pi-telegram/lib/outbound.ts +18 -14
  108. package/node_modules/@llblab/pi-telegram/lib/paths.ts +29 -0
  109. package/node_modules/@llblab/pi-telegram/lib/polling.ts +85 -17
  110. package/node_modules/@llblab/pi-telegram/lib/preview.ts +115 -70
  111. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +5 -2
  112. package/node_modules/@llblab/pi-telegram/lib/queue.ts +66 -22
  113. package/node_modules/@llblab/pi-telegram/lib/replies.ts +47 -39
  114. package/node_modules/@llblab/pi-telegram/lib/routing.ts +305 -112
  115. package/node_modules/@llblab/pi-telegram/lib/status.ts +10 -0
  116. package/node_modules/@llblab/pi-telegram/lib/sync.ts +308 -39
  117. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +315 -7
  118. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +664 -0
  119. package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +226 -0
  120. package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +118 -0
  121. package/node_modules/@llblab/pi-telegram/lib/threads.ts +1686 -129
  122. package/node_modules/@llblab/pi-telegram/lib/updates.ts +1319 -97
  123. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +1643 -0
  124. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +968 -0
  125. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +84 -0
  126. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  127. package/node_modules/@llblab/pi-telegram/screenshot.png +0 -0
  128. package/node_modules/@llblab/pi-telegram/scripts/measure-bus.mjs +83 -0
  129. package/node_modules/@llblab/pi-telegram/scripts/measure-workspace.mjs +101 -0
  130. package/node_modules/@llblab/{skills → pi-telegram/skills}/show-me/SKILL.md +28 -6
  131. package/node_modules/@llblab/pi-telegram/skills/show-me/references/telegram-surfaces.md +43 -0
  132. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/delivery-and-threads.md +1 -1
  133. package/node_modules/@llblab/skills/abcd-context/AGENTS.md +1 -0
  134. package/node_modules/@llblab/skills/abcd-context/CHANGELOG.md +6 -2
  135. package/node_modules/@llblab/skills/abcd-context/SKILL.md +1 -1
  136. package/node_modules/@llblab/skills/abcd-context/docs/validation-design.md +11 -5
  137. package/node_modules/@llblab/skills/abcd-context/scripts/_self-test.mjs +61 -0
  138. package/node_modules/@llblab/skills/abcd-context/scripts/validate-context.mjs +67 -0
  139. package/node_modules/@llblab/skills/package.json +2 -3
  140. package/node_modules/@llblab/skills/release-flow/SKILL.md +2 -4
  141. package/package.json +7 -6
@@ -0,0 +1,666 @@
1
+ // Domain: durable Git revision reads, compare-and-swap publication, and exact-commit push retry.
2
+ import { createHash } from "node:crypto";
3
+ import { closeSync, lstatSync, mkdirSync, mkdtempSync, openSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, relative, resolve, sep } from "node:path";
6
+ import { spawnSync } from "node:child_process";
7
+ import {
8
+ assertOwnedFileUpdates,
9
+ captureOwnedFileBases,
10
+ captureLegacyTemporalFileBases,
11
+ captureTemporalFileBases,
12
+ cwdScopePaths,
13
+ legacySessionRuntimePaths,
14
+ legacyTemporalScopePaths,
15
+ durablePaths,
16
+ isStateFlowOwnedPath,
17
+ parseScopeStream,
18
+ parseStateSource,
19
+ serializeScopeStream,
20
+ restoreDurableFileBases,
21
+ temporalScopePaths,
22
+ sessionScopePaths,
23
+ sessionRuntimePaths,
24
+ writeOwnedFileUpdates,
25
+ type DurableFileBase,
26
+ type OwnedFileUpdate,
27
+ } from "./durable.ts";
28
+ import { planLegacyStorageMigration } from "./migration.ts";
29
+ import { sameJson } from "./json.ts";
30
+ import { validateTemporalState, type ScopeStream, type TemporalState } from "./temporal.ts";
31
+ import { createSessionRuntime, parseSessionRuntime, serializeSessionRuntime, type SessionRuntime, type Snapshot } from "./snapshot.ts";
32
+ import { assertTemporalFileBase, loadTemporalFileRevision, planTemporalPublication, temporalFileReceipts, withStoragePublicationLock } from "./storage.ts";
33
+ import type { MaterializedState, StateScope } from "./state.ts";
34
+
35
+ const GIT_TIMEOUT_MS = 15_000;
36
+ const STATE_FLOW_COMMIT_TRAILER = "State-Flow-Durable: v1";
37
+
38
+ interface GitResult {
39
+ status: number;
40
+ stdout: string;
41
+ stderr: string;
42
+ }
43
+
44
+ export interface GitPushResult {
45
+ status: "pushed" | "local" | "pending";
46
+ commit: string;
47
+ error?: string;
48
+ }
49
+
50
+ function git(
51
+ repositoryRoot: string,
52
+ args: readonly string[],
53
+ options: { allowFailure?: boolean; env?: NodeJS.ProcessEnv; input?: string } = {},
54
+ ): GitResult {
55
+ const result = spawnSync("git", ["-C", repositoryRoot, ...args], {
56
+ encoding: "utf8",
57
+ timeout: GIT_TIMEOUT_MS,
58
+ input: options.input,
59
+ env: {
60
+ ...process.env,
61
+ GIT_TERMINAL_PROMPT: "0",
62
+ ...options.env,
63
+ },
64
+ });
65
+ const status = result.status ?? 1;
66
+ const stdout = result.stdout ?? "";
67
+ const stderr = result.error?.message ?? result.stderr ?? "";
68
+ if (status !== 0 && !options.allowFailure) {
69
+ const detail = stderr.trim() || stdout.trim() || `exit status ${status}`;
70
+ throw new Error(`Git command failed (${args.join(" ")}): ${detail}`);
71
+ }
72
+ return { status, stdout, stderr };
73
+ }
74
+
75
+ function assertDirectoryPath(path: string): void {
76
+ const parent = dirname(path);
77
+ if (parent !== path) assertDirectoryPath(parent);
78
+ const stat = lstatSync(path, { throwIfNoEntry: false });
79
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error(`State Flow repository path is not a regular directory: ${path}`);
80
+ }
81
+
82
+ /** Explicit start only: initialize this exact root, preserving existing files and ancestor Git. */
83
+ export function initializeGitRepository(repositoryRoot: string): void {
84
+ const root = resolve(repositoryRoot);
85
+ assertDirectoryPath(root);
86
+ if (lstatSync(resolve(root, ".git"), { throwIfNoEntry: false })) {
87
+ assertRepositoryRoot(root);
88
+ return;
89
+ }
90
+ mkdirSync(root, { recursive: true });
91
+ git(root, ["init"]);
92
+ assertRepositoryRoot(root);
93
+ }
94
+
95
+ function assertRepositoryRoot(repositoryRoot: string): string {
96
+ const expected = resolve(repositoryRoot);
97
+ assertDirectoryPath(expected);
98
+ if (lstatSync(resolve(expected, ".git"), { throwIfNoEntry: false })?.isSymbolicLink()) {
99
+ throw new Error(`State Flow Git metadata must not be a symlink: ${expected}`);
100
+ }
101
+ const actual = resolve(git(expected, ["rev-parse", "--show-toplevel"]).stdout.trim());
102
+ if (actual !== expected) {
103
+ throw new Error(`State Flow durable repository root mismatch: expected ${expected}, found ${actual}`);
104
+ }
105
+ return expected;
106
+ }
107
+
108
+ /** Serialize cooperating State Flow publishers through capture, commit, and rollback. */
109
+ function withPublicationLock<T>(repositoryRoot: string, action: (root: string) => T): T {
110
+ return withStoragePublicationLock(repositoryRoot, (lockedRoot) => {
111
+ const root = assertRepositoryRoot(lockedRoot);
112
+ const common = resolve(root, git(root, ["rev-parse", "--git-common-dir"]).stdout.trim());
113
+ const path = resolve(common, "state-flow-publication.lock");
114
+ let descriptor: number;
115
+ try {
116
+ descriptor = openSync(path, "wx", 0o600);
117
+ } catch (error) {
118
+ throw new Error(`State Flow publication lock is unavailable at ${path}; reconcile the active or interrupted publisher before retrying`, { cause: error });
119
+ }
120
+ try {
121
+ writeFileSync(descriptor, `${process.pid}\n`);
122
+ return action(root);
123
+ } finally {
124
+ closeSync(descriptor);
125
+ rmSync(path);
126
+ }
127
+ });
128
+ }
129
+
130
+ function currentHead(repositoryRoot: string): string | undefined {
131
+ const result = git(repositoryRoot, ["rev-parse", "--verify", "HEAD"], { allowFailure: true });
132
+ return result.status === 0 ? result.stdout.trim() : undefined;
133
+ }
134
+
135
+ function currentBranchRef(repositoryRoot: string): string {
136
+ const result = git(repositoryRoot, ["symbolic-ref", "--quiet", "HEAD"], { allowFailure: true });
137
+ if (result.status !== 0 || result.stdout.trim().length === 0) {
138
+ throw new Error("State Flow durable publication requires an attached Git branch");
139
+ }
140
+ return result.stdout.trim();
141
+ }
142
+
143
+ function revisionFile(
144
+ repositoryRoot: string,
145
+ revision: string,
146
+ path: string,
147
+ ): DurableFileBase {
148
+ const relativePath = relativeOwnedPath(path, repositoryRoot);
149
+ const object = `${revision}:${relativePath}`;
150
+ const entry = git(repositoryRoot, ["ls-tree", "-z", revision, "--", relativePath]).stdout;
151
+ if (entry.length === 0) return { path, identity: "missing" };
152
+ if (!/^100(?:644|755) blob [0-9a-f]+\t/.test(entry)) {
153
+ throw new Error(`Historical State Flow file is not a regular blob: ${path}`);
154
+ }
155
+ const content = git(repositoryRoot, ["show", object]).stdout;
156
+ return {
157
+ path,
158
+ identity: `sha256:${createHash("sha256").update(content).digest("hex")}`,
159
+ content,
160
+ };
161
+ }
162
+
163
+ function assertReadableRevision(root: string, revision: string): void {
164
+ if (!/^[0-9a-f]{40,64}$/.test(revision)
165
+ || git(root, ["cat-file", "-e", `${revision}^{commit}`], { allowFailure: true }).status !== 0) {
166
+ throw new Error(`State Flow revision is not a readable Git commit: ${revision}`);
167
+ }
168
+ }
169
+
170
+ export interface TemporalGitBase {
171
+ head?: string;
172
+ files: DurableFileBase[];
173
+ }
174
+
175
+ export interface TemporalRevisionLoad {
176
+ base: TemporalGitBase;
177
+ scopes: Record<StateScope, ScopeStream | undefined>;
178
+ runtime?: { document: SessionRuntime; revision: string };
179
+ /** True only when this revision directly selected pre-0.4 hashed paths. */
180
+ legacyLayout?: true;
181
+ }
182
+
183
+ export function captureTemporalGitBase(cwd: string, sessionId: string, repositoryRoot: string, sessionKey = sessionId): TemporalGitBase {
184
+ return withPublicationLock(repositoryRoot, (root) => captureTemporalBaseUnderLock(cwd, sessionId, root, sessionKey));
185
+ }
186
+
187
+ function captureTemporalBaseUnderLock(cwd: string, sessionId: string, root: string, sessionKey = sessionId): TemporalGitBase {
188
+ return { head: currentHead(root), files: captureTemporalFileBases(cwd, sessionId, root, sessionKey) };
189
+ }
190
+
191
+ function revisionScopeFiles(root: string, revision: string, cwd: string, sessionId: string, sessionKey: string, scope: StateScope) {
192
+ const read = (paths: ReturnType<typeof temporalScopePaths>) => ({
193
+ paths,
194
+ checkpoint: revisionFile(root, revision, paths.checkpoint),
195
+ patches: revisionFile(root, revision, paths.patches),
196
+ legacy: revisionFile(root, revision, resolve(paths.directory, "state.json")),
197
+ });
198
+ const canonical = read(temporalScopePaths(cwd, sessionId, scope, root, sessionKey));
199
+ if (scope === "global" || [canonical.checkpoint, canonical.patches, canonical.legacy].some(({ identity }) => identity !== "missing")) return { ...canonical, legacyLayout: false };
200
+ return { ...read(legacyTemporalScopePaths(cwd, sessionId, scope, root)), legacyLayout: true };
201
+ }
202
+
203
+ /** Cold scope-stream reconstruction; pre-0.4 hashed paths remain read-only revision input. */
204
+ export function loadTemporalRevision(cwd: string, sessionId: string, repositoryRoot: string, revision: string, sessionKey = sessionId): TemporalRevisionLoad {
205
+ const root = assertRepositoryRoot(repositoryRoot);
206
+ assertReadableRevision(root, revision);
207
+ const files: DurableFileBase[] = [];
208
+ const scopes = {} as Record<StateScope, ScopeStream | undefined>;
209
+ let legacyLayout = false;
210
+ for (const scope of ["global", "cwd", "session"] as const) {
211
+ const selected = revisionScopeFiles(root, revision, cwd, sessionId, sessionKey, scope);
212
+ if (selected.legacy.identity !== "missing") throw new Error("Historical legacy storage requires explicit migration interpretation");
213
+ legacyLayout ||= selected.legacyLayout;
214
+ files.push(selected.checkpoint, selected.patches, selected.legacy);
215
+ scopes[scope] = parseScopeStream(selected.checkpoint.content, selected.patches.content, scope,
216
+ scope === "cwd" && !selected.legacyLayout ? cwd : undefined);
217
+ }
218
+ const readRuntime = (paths: ReturnType<typeof sessionRuntimePaths>) => ({
219
+ paths, config: revisionFile(root, revision, paths.config), meta: revisionFile(root, revision, paths.meta),
220
+ });
221
+ let selectedRuntime = { ...readRuntime(sessionRuntimePaths(cwd, sessionId, root, sessionKey)), legacyLayout: false };
222
+ if (selectedRuntime.config.identity === "missing" && selectedRuntime.meta.identity === "missing") {
223
+ selectedRuntime = { ...readRuntime(legacySessionRuntimePaths(cwd, sessionId, root)), legacyLayout: true };
224
+ }
225
+ legacyLayout ||= selectedRuntime.legacyLayout;
226
+ const { paths: runtimePaths, config, meta } = selectedRuntime;
227
+ files.push(config, meta);
228
+ const document = parseSessionRuntime(config.content, meta.content, cwd, sessionId);
229
+ if (document === undefined) return { base: { head: revision, files }, scopes, ...(legacyLayout ? { legacyLayout: true as const } : {}) };
230
+ const owner = git(root, ["log", "-1", "--format=%H", revision, "--",
231
+ relativeOwnedPath(runtimePaths.config, root), relativeOwnedPath(runtimePaths.meta, root),
232
+ ]).stdout.trim();
233
+ assertReadableRevision(root, owner);
234
+ const temporalRevision = document.meta.temporalRevision === undefined || document.meta.temporalRevision === "self"
235
+ ? owner : document.meta.temporalRevision;
236
+ if (temporalRevision !== revision) {
237
+ if (git(root, ["merge-base", "--is-ancestor", temporalRevision, revision], { allowFailure: true }).status !== 0) {
238
+ throw new Error("Temporal revision must be an ancestor of its runtime owner");
239
+ }
240
+ const selected = loadTemporalRevision(cwd, sessionId, root, temporalRevision, sessionKey);
241
+ Object.assign(scopes, selected.scopes);
242
+ }
243
+ if (scopes.global === undefined || scopes.cwd === undefined || scopes.session === undefined) {
244
+ throw new Error("Session runtime has incomplete temporal scope storage");
245
+ }
246
+ 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 } : {}) };
248
+ }
249
+
250
+ /** Legacy state.json is already current; explanatory journals are irrelevant to semantic recovery. */
251
+ export function loadLegacyStatesAtRevision(cwd: string, sessionId: string, repositoryRoot: string, revision: string, sessionKey = sessionId): Record<StateScope, MaterializedState | undefined> {
252
+ const root = assertRepositoryRoot(repositoryRoot);
253
+ assertReadableRevision(root, revision);
254
+ const canonical = {
255
+ global: durablePaths(root).globalState,
256
+ cwd: cwdScopePaths(cwd, root).state,
257
+ session: sessionScopePaths(cwd, sessionId, root, sessionKey).state,
258
+ };
259
+ const states = {} as Record<StateScope, MaterializedState | undefined>;
260
+ for (const scope of ["global", "cwd", "session"] as const) {
261
+ let path = canonical[scope];
262
+ let source = revisionFile(root, revision, path).content;
263
+ if (source === undefined && scope !== "global") {
264
+ path = resolve(legacyTemporalScopePaths(cwd, sessionId, scope, root).directory, "state.json");
265
+ source = revisionFile(root, revision, path).content;
266
+ }
267
+ states[scope] = parseStateSource(source, path);
268
+ }
269
+ return states;
270
+ }
271
+
272
+ /** Move a current-head draft CWD pair before a new native-named session is initialized. */
273
+ export function migrateHashedCwdAtHead(cwd: string, repositoryRoot: string): void {
274
+ withPublicationLock(repositoryRoot, (root) => {
275
+ const head = currentHead(root);
276
+ if (!head) return;
277
+ const old = legacyTemporalScopePaths(cwd, "unused", "cwd", root);
278
+ const target = temporalScopePaths(cwd, "unused", "cwd", root);
279
+ const paths = [old.checkpoint, old.patches, resolve(old.directory, "state.json"), target.checkpoint, target.patches, resolve(target.directory, "state.json")];
280
+ const captured = captureOwnedFileBases(paths, root);
281
+ const byPath = new Map(captured.map((file) => [file.path, file]));
282
+ const oldCheckpoint = byPath.get(old.checkpoint)!;
283
+ const oldPatches = byPath.get(old.patches)!;
284
+ const oldState = byPath.get(resolve(old.directory, "state.json"))!;
285
+ const targetFiles = [byPath.get(target.checkpoint)!, byPath.get(target.patches)!, byPath.get(resolve(target.directory, "state.json"))!];
286
+ if (targetFiles[2]!.identity !== "missing") return; // Canonical current-state format migrates in the next owner.
287
+ if (targetFiles[0]!.identity !== "missing" || targetFiles[1]!.identity !== "missing") {
288
+ parseScopeStream(targetFiles[0]!.content, targetFiles[1]!.content, "cwd", cwd);
289
+ return;
290
+ }
291
+ if (oldCheckpoint.identity === "missing" && oldPatches.identity === "missing" && oldState.identity === "missing") return;
292
+ if (oldState.identity !== "missing") throw new Error("Hashed current-state storage requires explicit format migration before path migration");
293
+ const oldStream = parseScopeStream(oldCheckpoint.content, oldPatches.content, "cwd")!;
294
+ for (const file of [oldCheckpoint, oldPatches]) {
295
+ if (revisionFile(root, head, file.path).identity !== file.identity) throw new Error(`Hashed CWD source is not anchored at current HEAD: ${file.path}`);
296
+ }
297
+ const source = serializeScopeStream(oldStream, "cwd", cwd);
298
+ const updates = [
299
+ { path: target.checkpoint, content: source.checkpoint }, { path: target.patches, content: source.patches },
300
+ { path: old.checkpoint }, { path: old.patches },
301
+ ];
302
+ try {
303
+ publishOwnedCohort(root, updates, captured, head, [], "migrate");
304
+ } catch (error) {
305
+ try { rmdirSync(target.directory); } catch { /* Preserve nonempty or concurrently used directories. */ }
306
+ throw error;
307
+ }
308
+ try { rmdirSync(old.directory); } catch { /* Draft sessions may remain under this directory. */ }
309
+ });
310
+ }
311
+
312
+ /** Move only the selected current-head draft layout; older branch layouts remain cold read input. */
313
+ export function migrateHashedLayoutAtHead(
314
+ cwd: string,
315
+ sessionId: string,
316
+ repositoryRoot: string,
317
+ revision: string,
318
+ sessionKey: string,
319
+ ): (ReturnType<typeof publishOwnedCohort> & { base: TemporalGitBase; view: TemporalState }) | undefined {
320
+ return withPublicationLock(repositoryRoot, (root) => {
321
+ if (currentHead(root) !== revision) return undefined;
322
+ const selected = loadTemporalRevision(cwd, sessionId, root, revision, sessionKey);
323
+ if (!selected.legacyLayout || !selected.runtime || !selected.scopes.global || !selected.scopes.cwd || !selected.scopes.session) return undefined;
324
+ const view = { lineage: selected.runtime.document.meta.lineage, scopes: {
325
+ global: selected.scopes.global, cwd: selected.scopes.cwd, session: selected.scopes.session,
326
+ } };
327
+ validateTemporalState(view);
328
+ const canonical = captureTemporalBaseUnderLock(cwd, sessionId, root, sessionKey);
329
+ const legacyLive = captureLegacyTemporalFileBases(cwd, sessionId, root);
330
+ const bases = new Map([...canonical.files, ...legacyLive].map((file) => [file.path, file]));
331
+ const selectedFiles = new Map(selected.base.files.map((file) => [file.path, file]));
332
+ const updates: OwnedFileUpdate[] = [];
333
+ const cleanupDirectories = new Set<string>();
334
+ const targetDirectories = new Set<string>();
335
+ const removeEmptyDirectories = (directories: ReadonlySet<string>): void => {
336
+ for (const directory of [...directories].sort((left, right) => right.length - left.length)) {
337
+ try { rmdirSync(directory); } catch { /* Preserve nonempty or concurrently used directories. */ }
338
+ }
339
+ };
340
+ const move = (sourcePath: string, targetPath: string, content?: string): void => {
341
+ const source = selectedFiles.get(sourcePath);
342
+ if (!source || source.identity === "missing" || source.content === undefined) throw new Error(`Hashed-layout migration source is unavailable: ${sourcePath}`);
343
+ if (bases.get(sourcePath)?.identity !== source.identity) throw new Error(`Hashed-layout source changed after selected revision: ${sourcePath}`);
344
+ if (bases.get(targetPath)?.identity !== "missing") throw new Error(`Canonical State Flow path already exists during hashed-layout migration: ${targetPath}`);
345
+ updates.push({ path: targetPath, content: content ?? source.content }, { path: sourcePath });
346
+ cleanupDirectories.add(dirname(sourcePath));
347
+ targetDirectories.add(dirname(targetPath));
348
+ };
349
+ for (const scope of ["cwd", "session"] as const) {
350
+ const old = legacyTemporalScopePaths(cwd, sessionId, scope, root);
351
+ const target = temporalScopePaths(cwd, sessionId, scope, root, sessionKey);
352
+ if (selectedFiles.get(old.checkpoint)?.identity !== "missing") {
353
+ if (scope === "cwd") {
354
+ const stream = parseScopeStream(selectedFiles.get(old.checkpoint)!.content, selectedFiles.get(old.patches)!.content, "cwd")!;
355
+ const source = serializeScopeStream(stream, "cwd", cwd);
356
+ move(old.checkpoint, target.checkpoint, source.checkpoint);
357
+ move(old.patches, target.patches, source.patches);
358
+ } else {
359
+ move(old.checkpoint, target.checkpoint);
360
+ move(old.patches, target.patches);
361
+ }
362
+ }
363
+ }
364
+ const oldRuntime = legacySessionRuntimePaths(cwd, sessionId, root);
365
+ const targetRuntime = sessionRuntimePaths(cwd, sessionId, root, sessionKey);
366
+ const migratedRuntime = structuredClone(selected.runtime.document);
367
+ migratedRuntime.meta.temporalRevision = "self";
368
+ const runtimeSource = serializeSessionRuntime(migratedRuntime, cwd, sessionId);
369
+ if (selectedFiles.get(oldRuntime.config)?.identity !== "missing") {
370
+ move(oldRuntime.config, targetRuntime.config, runtimeSource.config);
371
+ move(oldRuntime.meta, targetRuntime.meta, runtimeSource.meta);
372
+ } else {
373
+ for (const [path, content] of [[targetRuntime.config, runtimeSource.config], [targetRuntime.meta, runtimeSource.meta]] as const) {
374
+ const source = selectedFiles.get(path);
375
+ if (!source || source.identity === "missing" || source.content === undefined) throw new Error(`Layout migration runtime is unavailable: ${path}`);
376
+ if (bases.get(path)?.identity !== source.identity) throw new Error(`Layout migration runtime changed after selected revision: ${path}`);
377
+ if (source.content !== content) updates.push({ path, content });
378
+ }
379
+ }
380
+ if (updates.length === 0) return undefined;
381
+ let publication: ReturnType<typeof publishOwnedCohort>;
382
+ try {
383
+ publication = publishOwnedCohort(root, updates, [...bases.values()], revision, [], "migrate");
384
+ } catch (error) {
385
+ removeEmptyDirectories(targetDirectories);
386
+ throw error;
387
+ }
388
+ const nextFiles = temporalFileReceipts(canonical, updates);
389
+ removeEmptyDirectories(cleanupDirectories);
390
+ return { ...publication, base: { head: publication.commit ?? revision, files: nextFiles }, view };
391
+ });
392
+ }
393
+
394
+ function relativeOwnedPath(path: string, repositoryRoot: string): string {
395
+ const output = relative(repositoryRoot, path);
396
+ if (output === "" || output === ".." || output.startsWith(`..${sep}`) || !isStateFlowOwnedPath(path, repositoryRoot)) {
397
+ throw new Error(`Git publication received a non-State Flow path: ${path}`);
398
+ }
399
+ return output.split(sep).join("/");
400
+ }
401
+
402
+ function commitOwnedFiles(
403
+ repositoryRoot: string,
404
+ updates: readonly OwnedFileUpdate[],
405
+ expectedHead: string | undefined,
406
+ scopes: readonly StateScope[],
407
+ operation: "persist" | "migrate" = "persist",
408
+ ): string | undefined {
409
+ assertOwnedFileUpdates(updates, repositoryRoot);
410
+ const branchRef = currentBranchRef(repositoryRoot);
411
+ const observedHead = currentHead(repositoryRoot);
412
+ if (observedHead !== expectedHead) {
413
+ throw new Error("Durable State Flow Git base changed concurrently; reload before publishing");
414
+ }
415
+ const temporary = mkdtempSync(`${tmpdir()}${sep}state-flow-index-`);
416
+ const indexPath = `${temporary}${sep}index`;
417
+ const env = { GIT_INDEX_FILE: indexPath };
418
+ try {
419
+ if (expectedHead === undefined) git(repositoryRoot, ["read-tree", "--empty"], { env });
420
+ else git(repositoryRoot, ["read-tree", expectedHead], { env });
421
+ const entries: Array<{ relativePath: string; blob?: string }> = [];
422
+ for (const update of updates) {
423
+ const relativePath = relativeOwnedPath(update.path, repositoryRoot);
424
+ if (update.content === undefined) {
425
+ entries.push({ relativePath });
426
+ git(repositoryRoot, ["update-index", "--force-remove", "--", relativePath], { env });
427
+ continue;
428
+ }
429
+ const blob = git(repositoryRoot, ["hash-object", "-w", "--stdin"], { input: update.content }).stdout.trim();
430
+ entries.push({ relativePath, blob });
431
+ git(repositoryRoot, ["update-index", "--add", "--cacheinfo", `100644,${blob},${relativePath}`], { env });
432
+ }
433
+ const tree = git(repositoryRoot, ["write-tree"], { env }).stdout.trim();
434
+ if (expectedHead !== undefined) {
435
+ const previousTree = git(repositoryRoot, ["rev-parse", `${expectedHead}^{tree}`]).stdout.trim();
436
+ if (tree === previousTree) return undefined;
437
+ }
438
+ const message = `state-flow: ${operation} ${scopes.join("+") || "runtime"} transition\n\n${STATE_FLOW_COMMIT_TRAILER}\n`;
439
+ const commitArgs = ["commit-tree", tree];
440
+ if (expectedHead !== undefined) commitArgs.push("-p", expectedHead);
441
+ const commit = git(repositoryRoot, commitArgs, { input: message }).stdout.trim();
442
+ const zero = "0".repeat(40);
443
+ assertOwnedFileUpdates(updates, repositoryRoot);
444
+ git(repositoryRoot, ["update-ref", branchRef, commit, expectedHead ?? zero]);
445
+ 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 });
450
+ } catch (error) {
451
+ git(repositoryRoot, ["update-ref", branchRef, expectedHead ?? zero, commit]);
452
+ throw error;
453
+ }
454
+ return commit;
455
+ } finally {
456
+ rmSync(temporary, { recursive: true, force: true });
457
+ }
458
+ }
459
+
460
+ export interface StorageMigrationPublication {
461
+ commit?: string;
462
+ push?: GitPushResult;
463
+ scopes: StateScope[];
464
+ }
465
+
466
+ /** Explicit migration entrypoint; initialization adopts it only with the temporal writer cutover. */
467
+ export function migrateLegacyStorageToGit(
468
+ cwd: string,
469
+ sessionId: string,
470
+ repositoryRoot: string,
471
+ sessionKey = sessionId,
472
+ ): StorageMigrationPublication {
473
+ return withPublicationLock(repositoryRoot, (root) => migrateLegacyStorageUnderLock(cwd, sessionId, root, sessionKey));
474
+ }
475
+
476
+ function migrateLegacyStorageUnderLock(cwd: string, sessionId: string, root: string, sessionKey = sessionId): StorageMigrationPublication {
477
+ const plan = planLegacyStorageMigration(cwd, sessionId, root, undefined, sessionKey);
478
+ if (plan.updates.length === 0) return { scopes: [] };
479
+ const current = captureOwnedFileBases(plan.bases.map(({ path }) => path), root);
480
+ if (current.some((base, index) => base.identity !== plan.bases[index]!.identity)) {
481
+ throw new Error("State Flow storage changed concurrently while planning migration");
482
+ }
483
+ return { scopes: plan.scopes, ...publishOwnedCohort(root, plan.updates, plan.bases, currentHead(root), plan.scopes, "migrate") };
484
+ }
485
+
486
+ function publishOwnedCohort(
487
+ root: string,
488
+ updates: readonly OwnedFileUpdate[],
489
+ bases: readonly DurableFileBase[],
490
+ head: string | undefined,
491
+ scopes: readonly StateScope[],
492
+ operation: "persist" | "migrate",
493
+ push = true,
494
+ ): { commit?: string; push?: GitPushResult } {
495
+ let published = false;
496
+ try {
497
+ writeOwnedFileUpdates(updates, bases, root);
498
+ published = true;
499
+ const commit = commitOwnedFiles(root, updates, head, scopes, operation);
500
+ return commit === undefined ? {} : push ? { commit, push: pushGitCommit(root, commit) } : { commit };
501
+ } catch (error) {
502
+ if (published) {
503
+ try {
504
+ const touched = new Set(updates.map(({ path }) => path));
505
+ restoreDurableFileBases(bases.filter(({ path }) => touched.has(path)), root, updates);
506
+ } catch (rollbackError) {
507
+ throw new AggregateError([error, rollbackError], "State Flow publication and rollback failed");
508
+ }
509
+ }
510
+ throw error;
511
+ }
512
+ }
513
+
514
+ /** 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) {
516
+ const selected = loadTemporalFileRevision(cwd, sessionId, repositoryRoot, revision, sessionKey);
517
+ 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);
519
+ runtime.meta.temporalRevision = "self";
520
+ const sources = serializeSessionRuntime(runtime, cwd, sessionId);
521
+ initializeGitRepository(repositoryRoot);
522
+ return withPublicationLock(repositoryRoot, (root) => {
523
+ const current = captureTemporalBaseUnderLock(cwd, sessionId, root, sessionKey);
524
+ assertTemporalFileBase(selected.base, current);
525
+ const paths = sessionRuntimePaths(cwd, sessionId, root, sessionKey);
526
+ // 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"))
528
+ .map(({ path, content }) => ({ path, content: content! }));
529
+ updates.push({ path: paths.config, content: sources.config }, { path: paths.meta, content: sources.meta });
530
+ const existing = current.head && updates.every(({ path, content }) => revisionFile(root, current.head!, path).content === content)
531
+ ? loadTemporalRevision(cwd, sessionId, root, current.head, sessionKey) : undefined;
532
+ if (existing && (!existing.runtime || !sameJson({ lineage: existing.runtime.document.meta.lineage, scopes: existing.scopes }, selected.view))) {
533
+ throw new Error("Existing Git runtime does not anchor the selected file cohort");
534
+ }
535
+ const publication = publishOwnedCohort(root, updates, current.files, current.head, [], "persist");
536
+ const target = publication.commit ?? existing!.runtime!.revision;
537
+ return { ...publication, revision: target, push: publication.push ?? pushGitCommit(root, target), view: selected.view,
538
+ base: { head: publication.commit ?? current.head, files: temporalFileReceipts(current, updates) } };
539
+ });
540
+ }
541
+
542
+ /** Working-tree equality alone cannot prove that a new commit contains the selected cohort. */
543
+ function includeUncommittedCohort(cwd: string, sessionId: string, root: string, current: TemporalGitBase,
544
+ updates: OwnedFileUpdate[], changedScopes: StateScope[], scopes: readonly StateScope[], runtime: boolean, sessionKey = sessionId): void {
545
+ const targets = new Map(updates.map((update) => [update.path, update]));
546
+ const desired = (paths: string[]) => paths.map((path) => targets.get(path) ?? { path, content: current.files.find((file) => file.path === path)!.content });
547
+ const absentFromHead = (files: OwnedFileUpdate[]) => files.some(({ path, content }) => current.head === undefined || revisionFile(root, current.head, path).content !== content);
548
+ for (const scope of ["global", "cwd", "session"] as const) {
549
+ const paths = temporalScopePaths(cwd, sessionId, scope, root, sessionKey);
550
+ const pair = desired([paths.checkpoint, paths.patches]);
551
+ if (!absentFromHead(pair)) continue;
552
+ if (!scopes.includes(scope)) throw new Error(`Temporal scope update omitted an uncommitted stream: ${scope}`);
553
+ for (const update of pair) targets.set(update.path, update);
554
+ if (!changedScopes.includes(scope)) changedScopes.push(scope);
555
+ }
556
+ if (runtime) {
557
+ const paths = sessionRuntimePaths(cwd, sessionId, root, sessionKey);
558
+ const pair = desired([paths.config, paths.meta]);
559
+ if (absentFromHead(pair)) for (const update of pair) targets.set(update.path, update);
560
+ }
561
+ updates.splice(0, updates.length, ...targets.values());
562
+ }
563
+
564
+ /** Persist selected streams from one validated view, never derive checkpoints from current state. */
565
+ export function publishTemporalStateToGit(
566
+ cwd: string,
567
+ sessionId: string,
568
+ view: TemporalState,
569
+ scopes: readonly StateScope[],
570
+ base: TemporalGitBase,
571
+ repositoryRoot: string,
572
+ runtime?: SessionRuntime,
573
+ sessionKey = sessionId,
574
+ push = true,
575
+ ): { base: TemporalGitBase; commit?: string; push?: GitPushResult } {
576
+ return withPublicationLock(repositoryRoot, (root) => {
577
+ const current = captureTemporalBaseUnderLock(cwd, sessionId, root, sessionKey);
578
+ assertTemporalFileBase(base, current);
579
+ if (runtime?.meta.publication === "files") throw new Error("Git publication requires explicit Git provenance");
580
+ const runtimePaths = sessionRuntimePaths(cwd, sessionId, root, sessionKey);
581
+ const previousRuntime = parseSessionRuntime(current.files.find(({ path }) => path === runtimePaths.config)?.content,
582
+ current.files.find(({ path }) => path === runtimePaths.meta)?.content, cwd, sessionId);
583
+ if (previousRuntime?.meta.publication === "files") throw new Error("File-only storage requires explicit full-cohort Git adoption");
584
+ const runtimeOnly = runtime?.meta.temporalRevision !== undefined && runtime.meta.temporalRevision !== "self";
585
+ if (runtimeOnly) {
586
+ if (scopes.length !== 0) throw new Error("Runtime-only publication cannot write semantic scopes");
587
+ const selected = loadTemporalRevision(cwd, sessionId, root, runtime!.meta.temporalRevision!, sessionKey);
588
+ if (!sameJson(selected.scopes, view.scopes)) throw new Error("Runtime temporal reference does not match selected streams");
589
+ }
590
+ const { updates, changedScopes } = planTemporalPublication(cwd, sessionId, view, scopes, current, root, runtime, runtimeOnly, sessionKey);
591
+ if (!runtimeOnly) includeUncommittedCohort(cwd, sessionId, root, current, updates, changedScopes, scopes, runtime !== undefined, sessionKey);
592
+ if (updates.length === 0) return { base: current };
593
+ const publication = publishOwnedCohort(root, updates, current.files, current.head, changedScopes, "persist", push);
594
+ const nextFiles = temporalFileReceipts(current, updates);
595
+ return { ...publication, base: { head: publication.commit ?? current.head, files: nextFiles } };
596
+ });
597
+ }
598
+
599
+ export function resolveGitPushDestination(repositoryRoot: string): { gitCommonDir: string; remote: string; ref: string } | undefined {
600
+ const root = assertRepositoryRoot(repositoryRoot);
601
+ const common = git(root, ["rev-parse", "--git-common-dir"]).stdout.trim();
602
+ const gitCommonDir = resolve(root, common);
603
+ const destination = pushDestination(root);
604
+ return destination === undefined ? undefined : { gitCommonDir, ...destination };
605
+ }
606
+
607
+ function pushDestination(repositoryRoot: string): { remote: string; ref: string } | undefined {
608
+ const branchRef = currentBranchRef(repositoryRoot);
609
+ const branch = branchRef.slice("refs/heads/".length);
610
+ const configuredRemote = git(repositoryRoot, ["config", "--get", `branch.${branch}.remote`], { allowFailure: true });
611
+ if (configuredRemote.status > 1) throw new Error(configuredRemote.stderr || "Cannot inspect Git remote configuration");
612
+ let remote = configuredRemote.status === 0 ? configuredRemote.stdout.trim() : "";
613
+ if (remote.length === 0) {
614
+ const remotes = git(repositoryRoot, ["remote"]).stdout.trim().split(/\s+/).filter(Boolean);
615
+ if (remotes.length === 0) return undefined;
616
+ if (remotes.includes("origin")) remote = "origin";
617
+ else if (remotes.length === 1) remote = remotes[0]!;
618
+ else throw new Error("State Flow durable publication cannot select a Git remote");
619
+ }
620
+ if (remote === ".") throw new Error("State Flow durable publication requires a non-local Git remote");
621
+ const configuredMerge = git(repositoryRoot, ["config", "--get", `branch.${branch}.merge`], { allowFailure: true });
622
+ const ref = configuredMerge.status === 0 && configuredMerge.stdout.trim().length > 0
623
+ ? configuredMerge.stdout.trim()
624
+ : branchRef;
625
+ return { remote, ref };
626
+ }
627
+
628
+ /** Read-only publication policy; configuration errors are not local-only success. */
629
+ export function isLocalGitRepository(repositoryRoot: string): boolean {
630
+ return pushDestination(assertRepositoryRoot(repositoryRoot)) === undefined;
631
+ }
632
+
633
+ /** Push an already-created commit exactly; failures are publication state, not transition rejection. */
634
+ export function isGitCommitAncestor(repositoryRoot: string, ancestor: string, descendant: string): boolean {
635
+ const root = assertRepositoryRoot(repositoryRoot);
636
+ assertReadableRevision(root, ancestor);
637
+ assertReadableRevision(root, descendant);
638
+ const result = git(root, ["merge-base", "--is-ancestor", ancestor, descendant], { allowFailure: true });
639
+ if (result.status === 0) return true;
640
+ if (result.status === 1) return false;
641
+ throw new Error(`Cannot inspect Git commit ancestry: ${result.stderr || `exit ${result.status}`}`);
642
+ }
643
+
644
+ export function pushGitCommit(repositoryRoot: string, commit: string): GitPushResult {
645
+ try {
646
+ const root = assertRepositoryRoot(repositoryRoot);
647
+ assertReadableRevision(root, commit);
648
+ const destination = pushDestination(root);
649
+ if (destination === undefined) return { status: "local", commit };
650
+ const remote = git(root, ["ls-remote", destination.remote, destination.ref], { allowFailure: true });
651
+ const remoteHead = remote.stdout.trim().split(/\s+/)[0];
652
+ if (remote.status === 0 && remoteHead && /^[0-9a-f]{40,64}$/.test(remoteHead)
653
+ && git(root, ["merge-base", "--is-ancestor", commit, remoteHead], { allowFailure: true }).status === 0) {
654
+ return { status: "pushed", commit };
655
+ }
656
+ const result = git(root, ["push", destination.remote, `${commit}:${destination.ref}`], { allowFailure: true });
657
+ if (result.status === 0) return { status: "pushed", commit };
658
+ return {
659
+ status: "pending",
660
+ commit,
661
+ error: (result.stderr.trim() || result.stdout.trim() || `exit status ${result.status}`),
662
+ };
663
+ } catch (error) {
664
+ return { status: "pending", commit, error: error instanceof Error ? error.message : String(error) };
665
+ }
666
+ }