@remnic/core 9.3.691 → 9.3.693

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 (113) hide show
  1. package/dist/access-boundary.d.ts +6 -5
  2. package/dist/access-boundary.js +12 -12
  3. package/dist/access-cli.js +71 -18
  4. package/dist/access-cli.js.map +1 -1
  5. package/dist/access-http.d.ts +5 -4
  6. package/dist/access-http.js +16 -16
  7. package/dist/access-mcp.d.ts +7 -4
  8. package/dist/access-mcp.js +15 -15
  9. package/dist/access-operations.d.ts +12 -6
  10. package/dist/access-operations.js +16 -14
  11. package/dist/access-schema.js +3 -3
  12. package/dist/{access-service-CKnFdxH-.d.ts → access-service-DxCpgyVW.d.ts} +96 -2
  13. package/dist/access-service.d.ts +5 -4
  14. package/dist/access-service.js +11 -11
  15. package/dist/access-surface-catalog.d.ts +5 -4
  16. package/dist/access-surface-catalog.js +2 -0
  17. package/dist/access-surface-catalog.js.map +1 -1
  18. package/dist/bootstrap.d.ts +4 -3
  19. package/dist/{chunk-QNG35H27.js → chunk-4IQNAOP3.js} +5 -141
  20. package/dist/chunk-4IQNAOP3.js.map +1 -0
  21. package/dist/{chunk-VH2DHBHG.js → chunk-5HGPDRQZ.js} +2 -2
  22. package/dist/chunk-5HGPDRQZ.js.map +1 -0
  23. package/dist/chunk-6C2BMUZA.js +444 -0
  24. package/dist/chunk-6C2BMUZA.js.map +1 -0
  25. package/dist/{chunk-UUMYB3FR.js → chunk-6ZNIUGXB.js} +10 -10
  26. package/dist/{chunk-IPJD7WJH.js → chunk-E6BOD3UJ.js} +2 -2
  27. package/dist/{chunk-TJ5IIQIR.js → chunk-F7BVJ3IE.js} +40 -369
  28. package/dist/chunk-F7BVJ3IE.js.map +1 -0
  29. package/dist/{chunk-D3DNGQNQ.js → chunk-FM7KLRKN.js} +2 -2
  30. package/dist/{chunk-7MOTEVAA.js → chunk-HRUULBBV.js} +3 -1
  31. package/dist/chunk-HRUULBBV.js.map +1 -0
  32. package/dist/chunk-HXNMDPHN.js +149 -0
  33. package/dist/chunk-HXNMDPHN.js.map +1 -0
  34. package/dist/{chunk-7UVDIWLL.js → chunk-JNKH2XWQ.js} +36 -6
  35. package/dist/chunk-JNKH2XWQ.js.map +1 -0
  36. package/dist/{chunk-IPLYGWQF.js → chunk-KQAFEZQX.js} +5 -5
  37. package/dist/{chunk-OOO7ZKOD.js → chunk-KYH53EFD.js} +3 -3
  38. package/dist/{chunk-LQ6JI4VH.js → chunk-LD53WPMU.js} +4 -4
  39. package/dist/{chunk-KO6OL4I7.js → chunk-MQTPUPLP.js} +297 -17
  40. package/dist/chunk-MQTPUPLP.js.map +1 -0
  41. package/dist/{chunk-TSGREOYY.js → chunk-RHWFMBO5.js} +33 -6
  42. package/dist/chunk-RHWFMBO5.js.map +1 -0
  43. package/dist/{chunk-5CEJH5ZN.js → chunk-TAPSNIMA.js} +4 -4
  44. package/dist/{chunk-K7I24ZO2.js → chunk-THKNO6OX.js} +22 -21
  45. package/dist/{chunk-K7I24ZO2.js.map → chunk-THKNO6OX.js.map} +1 -1
  46. package/dist/{chunk-QLRYXOAD.js → chunk-UDJLF3BO.js} +2 -2
  47. package/dist/{chunk-NCRDCTEJ.js → chunk-WYB44HHV.js} +21 -7
  48. package/dist/chunk-WYB44HHV.js.map +1 -0
  49. package/dist/{chunk-WRFKZEO6.js → chunk-YQGHW5ML.js} +4 -4
  50. package/dist/{cli-CrSW_KRq.d.ts → cli-DoQsv4ut.d.ts} +2 -2
  51. package/dist/cli.d.ts +6 -5
  52. package/dist/cli.js +26 -25
  53. package/dist/contradiction/index.js +4 -4
  54. package/dist/explicit-capture.d.ts +4 -3
  55. package/dist/index.d.ts +6 -5
  56. package/dist/index.js +33 -31
  57. package/dist/index.js.map +1 -1
  58. package/dist/mcp-memory-inspector-app.d.ts +5 -4
  59. package/dist/namespaces/migrate.js +8 -8
  60. package/dist/namespaces/search.js +7 -7
  61. package/dist/operator-toolkit.js +11 -10
  62. package/dist/orchestration/maintenance.d.ts +112 -0
  63. package/dist/orchestration/maintenance.js +39 -0
  64. package/dist/orchestration/maintenance.js.map +1 -0
  65. package/dist/{orchestrator-DvswGK76.d.ts → orchestrator-CNJCYjZ1.d.ts} +9 -33
  66. package/dist/orchestrator.d.ts +2 -1
  67. package/dist/orchestrator.js +13 -11
  68. package/dist/schemas.d.ts +28 -28
  69. package/dist/search/factory.js +6 -6
  70. package/dist/search/index.js +11 -11
  71. package/dist/search/lancedb-backend.js +2 -2
  72. package/dist/search/meilisearch-backend.js +2 -2
  73. package/dist/search/orama-backend.js +2 -2
  74. package/dist/transfer/autodetect.js +1 -1
  75. package/dist/transfer/backup.js +1 -1
  76. package/dist/transfer/capsule-export.js +2 -2
  77. package/dist/transfer/types.d.ts +12 -12
  78. package/package.json +12 -2
  79. package/src/access-boundary.ts +2 -1
  80. package/src/access-cli.ts +65 -2
  81. package/src/access-http.ts +20 -0
  82. package/src/access-mcp.ts +29 -0
  83. package/src/access-operations.ts +46 -0
  84. package/src/access-service.ts +55 -0
  85. package/src/access-surface-catalog.test.ts +1 -1
  86. package/src/access-surface-catalog.ts +2 -0
  87. package/src/cli.ts +1 -0
  88. package/src/coding/coding-namespace.ts +1 -1
  89. package/src/coding/session-delta-surfaces.test.ts +383 -0
  90. package/src/coding/session-delta-surfaces.ts +325 -0
  91. package/src/coding/session-delta.test.ts +411 -0
  92. package/src/coding/session-delta.ts +442 -0
  93. package/src/orchestration/maintenance.test.ts +738 -0
  94. package/src/orchestration/maintenance.ts +557 -0
  95. package/src/orchestrator-flush.test.ts +0 -599
  96. package/src/orchestrator.ts +33 -419
  97. package/dist/chunk-7MOTEVAA.js.map +0 -1
  98. package/dist/chunk-7UVDIWLL.js.map +0 -1
  99. package/dist/chunk-KO6OL4I7.js.map +0 -1
  100. package/dist/chunk-NCRDCTEJ.js.map +0 -1
  101. package/dist/chunk-QNG35H27.js.map +0 -1
  102. package/dist/chunk-TJ5IIQIR.js.map +0 -1
  103. package/dist/chunk-TSGREOYY.js.map +0 -1
  104. package/dist/chunk-VH2DHBHG.js.map +0 -1
  105. /package/dist/{chunk-UUMYB3FR.js.map → chunk-6ZNIUGXB.js.map} +0 -0
  106. /package/dist/{chunk-IPJD7WJH.js.map → chunk-E6BOD3UJ.js.map} +0 -0
  107. /package/dist/{chunk-D3DNGQNQ.js.map → chunk-FM7KLRKN.js.map} +0 -0
  108. /package/dist/{chunk-IPLYGWQF.js.map → chunk-KQAFEZQX.js.map} +0 -0
  109. /package/dist/{chunk-OOO7ZKOD.js.map → chunk-KYH53EFD.js.map} +0 -0
  110. /package/dist/{chunk-LQ6JI4VH.js.map → chunk-LD53WPMU.js.map} +0 -0
  111. /package/dist/{chunk-5CEJH5ZN.js.map → chunk-TAPSNIMA.js.map} +0 -0
  112. /package/dist/{chunk-QLRYXOAD.js.map → chunk-UDJLF3BO.js.map} +0 -0
  113. /package/dist/{chunk-WRFKZEO6.js.map → chunk-YQGHW5ML.js.map} +0 -0
@@ -0,0 +1,442 @@
1
+ /**
2
+ * Session delta — pure repo differ + thin state persistence
3
+ * (issue #1548 Track A PR 4).
4
+ *
5
+ * Tells a returning agent "since you last worked here: N commits, these
6
+ * files." The differ is a pure function over a {@link GitLogSlice}; the
7
+ * caller supplies the slice (from a real git invoker or a test fixture).
8
+ *
9
+ * Design rules honoured:
10
+ * - rule 11 — no module-level mutable state; the state file is read and
11
+ * written through passed-in functions, never cached in module scope.
12
+ * - rule 25 — the new last-seen-head is persisted AFTER the delta is
13
+ * computed from the old one; the caller never destroys the old marker
14
+ * before the new state is useful.
15
+ * - rule 27 — `slice(-n)` caps guard against `n === 0` (which would
16
+ * return the whole array instead of an empty slice).
17
+ * - rule 34 — every non-trivial outcome is a tagged union; a missing
18
+ * prior head, an unreachable head (force-push/rebase), or a git failure
19
+ * is surfaced with a distinct code, never as an empty delta.
20
+ * - rule 48 — least-privileged defaults: caps are conservative.
21
+ * - rule 51 — invalid inputs are rejected loudly by the persistence
22
+ * helpers, never silently coerced.
23
+ * - rule 54 — state writes are temp-file-then-rename so a crashed write
24
+ * never leaves a truncated JSON file at the canonical path.
25
+ *
26
+ * Storage: `<memoryDir>/state/coding-knowledge/<sanitized-namespace>.json`
27
+ * (precedent: `calibration.ts`). The namespace is already sanitized by the
28
+ * coding-namespace router, but `sanitizeFragment` is reused defensively so a
29
+ * future caller cannot place a state file outside the directory.
30
+ */
31
+ import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
32
+ import path from "node:path";
33
+ import { sanitizeFragment } from "./coding-namespace.js";
34
+
35
+ // ──────────────────────────────────────────────────────────────────────────
36
+ // Public types
37
+ // ──────────────────────────────────────────────────────────────────────────
38
+
39
+ /** A single commit in the delta slice. */
40
+ export interface GitCommit {
41
+ /** Full or abbreviated SHA — callers decide precision. */
42
+ sha: string;
43
+ /** First-line commit subject. */
44
+ subject: string;
45
+ }
46
+
47
+ /**
48
+ * The repo's current state since the last-seen head. Callers populate this
49
+ * from a real git invoker (see {@link SessionDeltaGitInvoker}); tests inject
50
+ * a fixture directly.
51
+ */
52
+ export interface GitLogSlice {
53
+ /** Commits in `lastSeen..currentHead`, oldest-first. Empty when unchanged. */
54
+ commits: GitCommit[];
55
+ /** Files touched across those commits, de-duplicated and sorted. */
56
+ touchedFiles: string[];
57
+ /** The repo's current HEAD SHA. */
58
+ currentHead: string;
59
+ }
60
+
61
+ /** Persisted marker: the HEAD we last computed a delta against, and when. */
62
+ export interface LastSeenState {
63
+ /** HEAD SHA at the time of the last delta. */
64
+ head: string;
65
+ /** ISO timestamp of the last delta computation. */
66
+ at: string;
67
+ }
68
+
69
+ /** A successful delta computation. */
70
+ export interface SessionDelta {
71
+ /** Commits since last seen, capped to {@link MAX_DELTA_COMMITS}. */
72
+ commits: GitCommit[];
73
+ /** Touched files since last seen, capped to {@link MAX_DELTA_FILES}. */
74
+ touchedFiles: string[];
75
+ /** A single human-readable summary line for briefing injection. */
76
+ summaryLine: string;
77
+ }
78
+
79
+ /**
80
+ * Tagged result. Every non-trivial path returns a distinct shape so callers
81
+ * (briefing, xray, doctor) can distinguish "first run" from "no changes"
82
+ * from "delta unavailable" — never an empty delta masquerading as "nothing
83
+ * happened" (rule 34).
84
+ */
85
+ export type SessionDeltaResult =
86
+ /** A non-empty delta was computed. */
87
+ | { ok: true; kind: "changed"; delta: SessionDelta; nextState: LastSeenState }
88
+ /** Prior head equals current head — no changes to report. */
89
+ | { ok: true; kind: "unchanged"; nextState: LastSeenState }
90
+ /** No prior state — first session for this namespace. */
91
+ | { ok: true; kind: "first_run"; nextState: LastSeenState }
92
+ /** The prior head is unreachable (force-push/rebase). Delta unavailable. */
93
+ | { ok: false; code: "unreachable_head"; detail: string; nextState: LastSeenState }
94
+ /** The git invoker failed (timeout, missing binary, non-zero exit). */
95
+ | { ok: false; code: "git_failed"; detail: string };
96
+
97
+ // ──────────────────────────────────────────────────────────────────────────
98
+ // Caps (rule 27 / rule 48)
99
+ // ──────────────────────────────────────────────────────────────────────────
100
+
101
+ /** Maximum commits retained in a delta. Older commits beyond the cap are dropped. */
102
+ export const MAX_DELTA_COMMITS = 20;
103
+
104
+ /** Maximum touched files retained in a delta. */
105
+ export const MAX_DELTA_FILES = 50;
106
+
107
+ // ──────────────────────────────────────────────────────────────────────────
108
+ // Pure differ
109
+ // ──────────────────────────────────────────────────────────────────────────
110
+
111
+ /**
112
+ * Compute a session delta from a prior state and the current repo slice.
113
+ *
114
+ * Returns one of:
115
+ * - `first_run` — no prior state; the caller should persist `nextState`
116
+ * but render NO delta section (a first session must not claim "0 changes").
117
+ * - `unchanged` — prior head equals current head; suppress the section.
118
+ * - `changed` — a real delta with capped commits/files + a summary line.
119
+ *
120
+ * This function is pure: it reads neither disk nor git. The caller supplies
121
+ * `current` already resolved. The only side-effect-bearing step is the
122
+ * caller's subsequent `writeLastSeenState(nextState)` call.
123
+ */
124
+ export function computeSessionDelta(
125
+ lastSeen: LastSeenState | null,
126
+ current: GitLogSlice,
127
+ ): SessionDeltaResult {
128
+ const now = new Date().toISOString();
129
+ const nextState: LastSeenState = { head: current.currentHead, at: now };
130
+
131
+ // First session — no prior marker. Initialize state but render nothing.
132
+ // A first session claiming "0 changes" is a contract lie (rule 34).
133
+ if (lastSeen === null) {
134
+ return { ok: true, kind: "first_run", nextState };
135
+ }
136
+
137
+ // Unchanged — prior head still current. Suppress, do not render "no changes".
138
+ if (lastSeen.head === current.currentHead) {
139
+ return { ok: true, kind: "unchanged", nextState };
140
+ }
141
+
142
+ // If the slice reports zero commits despite a head change, the prior head
143
+ // is unreachable (force-push/rebase erased it). Tagged failure, never a crash.
144
+ if (current.commits.length === 0) {
145
+ return {
146
+ ok: false,
147
+ code: "unreachable_head",
148
+ detail: `prior head ${lastSeen.head} is not an ancestor of current ${current.currentHead}`,
149
+ nextState,
150
+ };
151
+ }
152
+
153
+ const commits = capCommits(current.commits, MAX_DELTA_COMMITS);
154
+ const touchedFiles = capFiles(current.touchedFiles, MAX_DELTA_FILES);
155
+
156
+ return {
157
+ ok: true,
158
+ kind: "changed",
159
+ delta: {
160
+ commits,
161
+ touchedFiles,
162
+ summaryLine: buildSummaryLine(commits.length, touchedFiles.length, lastSeen.at),
163
+ },
164
+ nextState,
165
+ };
166
+ }
167
+
168
+ // ──────────────────────────────────────────────────────────────────────────
169
+ // Caps — rule 27: guard slice(-n) against n === 0
170
+ // ──────────────────────────────────────────────────────────────────────────
171
+
172
+ /**
173
+ * Cap a commits array to the most-recent `max` entries.
174
+ *
175
+ * `Array.prototype.slice(-n)` with `n === 0` returns the WHOLE array (not
176
+ * an empty slice), so an explicit guard is mandatory (rule 27). We keep the
177
+ * most-recent commits (tail), which is what a returning agent cares about.
178
+ */
179
+ export function capCommits(commits: GitCommit[], max: number): GitCommit[] {
180
+ if (!Number.isFinite(max) || max <= 0) return [];
181
+ if (commits.length <= max) return commits;
182
+ return commits.slice(-max);
183
+ }
184
+
185
+ /**
186
+ * Cap a touched-files list to `max` entries, preserving sort order.
187
+ * Same rule-27 guard as {@link capCommits}.
188
+ */
189
+ export function capFiles(files: string[], max: number): string[] {
190
+ if (!Number.isFinite(max) || max <= 0) return [];
191
+ if (files.length <= max) return files;
192
+ return files.slice(0, max);
193
+ }
194
+
195
+ // ──────────────────────────────────────────────────────────────────────────
196
+ // Summary line
197
+ // ──────────────────────────────────────────────────────────────────────────
198
+
199
+ /**
200
+ * Build the single briefing-summary line. Deterministic and locale-stable
201
+ * so a briefing snapshot is byte-identical across runs for the same input.
202
+ */
203
+ export function buildSummaryLine(commitCount: number, fileCount: number, sinceIso: string): string {
204
+ const since = formatSinceDate(sinceIso);
205
+ const commitWord = commitCount === 1 ? "commit" : "commits";
206
+ const fileWord = fileCount === 1 ? "file" : "files";
207
+ return `Since ${since}: ${commitCount} ${commitWord}, ${fileCount} ${fileWord} touched.`;
208
+ }
209
+
210
+ /**
211
+ * Format the "since" timestamp as a stable, locale-independent label.
212
+ * Returns the raw ISO date (YYYY-MM-DD) so the briefing snapshot never
213
+ * depends on the runtime locale or timezone.
214
+ */
215
+ function formatSinceDate(iso: string): string {
216
+ // Defensive: if the stored timestamp is malformed, fall back to the raw
217
+ // string rather than crashing the delta render (rule 34 — never crash on
218
+ // persisted state corruption).
219
+ const match = /^(\d{4}-\d{2}-\d{2})/.exec(iso);
220
+ return match ? match[1]! : iso;
221
+ }
222
+
223
+ // ──────────────────────────────────────────────────────────────────────────
224
+ // State persistence
225
+ // ──────────────────────────────────────────────────────────────────────────
226
+
227
+ /** Subdirectory under `<memoryDir>/state/` holding coding-knowledge markers. */
228
+ export const CODING_KNOWLEDGE_STATE_DIR = path.join("state", "coding-knowledge");
229
+
230
+ /**
231
+ * Compute the canonical state-file path for a namespace.
232
+ *
233
+ * The namespace is already router-sanitized, but `sanitizeFragment` is
234
+ * applied defensively so a caller passing an unsanitized value cannot place
235
+ * a file outside the directory (rule 51 — defensive at the boundary).
236
+ */
237
+ export function sessionDeltaStatePath(memoryDir: string, namespace: string): string {
238
+ const safe = sanitizeFragment(namespace) || "default";
239
+ return path.join(memoryDir, CODING_KNOWLEDGE_STATE_DIR, `${safe}.json`);
240
+ }
241
+
242
+ /**
243
+ * Read the persisted last-seen-head marker. Returns `null` when the file is
244
+ * absent, empty, or malformed — never throws (rule 34 — corrupted state
245
+ * degrades to a first-run, not a crash).
246
+ */
247
+ export async function readLastSeenState(statePath: string): Promise<LastSeenState | null> {
248
+ try {
249
+ const raw = await readFile(statePath, "utf8");
250
+ const parsed = JSON.parse(raw) as unknown;
251
+ if (typeof parsed !== "object" || parsed === null) return null;
252
+ const obj = parsed as Record<string, unknown>;
253
+ const head = typeof obj.head === "string" ? obj.head : null;
254
+ const at = typeof obj.at === "string" ? obj.at : null;
255
+ if (!head || !at) return null;
256
+ return { head, at };
257
+ } catch {
258
+ // Absent or unreadable — treat as first run.
259
+ return null;
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Persist the last-seen-head marker using temp-file-then-rename (rule 54)
265
+ * so a crashed write never leaves a truncated file at the canonical path.
266
+ *
267
+ * The caller invokes this AFTER computing the delta from the prior state
268
+ * (rule 25 — do not destroy the old marker before the new state is useful).
269
+ */
270
+ export async function writeLastSeenState(
271
+ statePath: string,
272
+ state: LastSeenState,
273
+ ): Promise<void> {
274
+ if (!state.head || !state.at) {
275
+ throw new Error(
276
+ `writeLastSeenState: invalid state (head=${JSON.stringify(state.head)}, at=${JSON.stringify(state.at)})`,
277
+ );
278
+ }
279
+ const dir = path.dirname(statePath);
280
+ await mkdir(dir, { recursive: true });
281
+ const tmp = `${statePath}.tmp-${process.pid}-${Date.now()}`;
282
+ await writeFile(tmp, JSON.stringify(state, null, 2), "utf8");
283
+ await rename(tmp, statePath);
284
+ }
285
+
286
+ // ──────────────────────────────────────────────────────────────────────────
287
+ // Git invoker — the contract the surface handler satisfies to populate a
288
+ // GitLogSlice from a real repo. Mirrors coding/git-context.ts discipline:
289
+ // 2s timeout per call, never throws, exitCode returned to the caller.
290
+ // ──────────────────────────────────────────────────────────────────────────
291
+
292
+ /**
293
+ * Injectable git-invocation surface for session-delta. Only the two commands
294
+ * the differ needs are exposed. Implementations MUST NOT throw for non-zero
295
+ * exit codes — they return `{ exitCode, stdout }` so the handler can decide
296
+ * how to recover (rule 34).
297
+ */
298
+ export interface SessionDeltaGitInvoker {
299
+ /**
300
+ * Run `git <args>` with `cwd` as the working directory.
301
+ * Implementations enforce a per-call timeout (2s precedent in
302
+ * `coding/git-context.ts`) and return non-zero exit codes instead of
303
+ * throwing.
304
+ */
305
+ (cwd: string, args: string[]): { stdout: string; exitCode: number };
306
+ }
307
+
308
+ /**
309
+ * Result of resolving the current repo slice. Tagged so the handler can
310
+ * surface a distinct degradation code (rule 34).
311
+ */
312
+ export type ResolveSliceResult =
313
+ | { ok: true; slice: GitLogSlice }
314
+ | { ok: false; code: "git_failed" | "no_head"; detail: string };
315
+
316
+ /** Default per-call timeout when no invoker override is supplied. */
317
+ export const SESSION_DELTA_GIT_TIMEOUT_MS = 2_000;
318
+
319
+ /**
320
+ * Resolve the current HEAD and the commit/file slice since `sinceHead`.
321
+ *
322
+ * Uses two git calls:
323
+ * 1. `rev-parse HEAD` — current head.
324
+ * 2. `log --name-only --pretty=format:... sinceHead..HEAD` — commits + files.
325
+ *
326
+ * Never throws; failures map to `{ ok: false, code }` and the handler
327
+ * surfaces them as `git_failed` or `unreachable_head`.
328
+ */
329
+ export function resolveSlice(
330
+ repoRoot: string,
331
+ sinceHead: string | null,
332
+ invoker: SessionDeltaGitInvoker,
333
+ ): ResolveSliceResult {
334
+ const headResult = invoker(repoRoot, ["rev-parse", "HEAD"]);
335
+ if (headResult.exitCode !== 0) {
336
+ return {
337
+ ok: false,
338
+ code: headResult.stdout.trim().length === 0 ? "no_head" : "git_failed",
339
+ detail: `rev-parse HEAD exited ${headResult.exitCode}`,
340
+ };
341
+ }
342
+ const currentHead = headResult.stdout.trim();
343
+ if (!currentHead) {
344
+ return { ok: false, code: "no_head", detail: "rev-parse HEAD returned empty" };
345
+ }
346
+
347
+ // First run — no prior head to diff against. Return an empty slice; the
348
+ // differ turns this into a `first_run` outcome.
349
+ if (!sinceHead) {
350
+ return { ok: true, slice: { commits: [], touchedFiles: [], currentHead } };
351
+ }
352
+
353
+ // Unchanged — short-circuit before the log call (saves a git invocation).
354
+ if (sinceHead === currentHead) {
355
+ return { ok: true, slice: { commits: [], touchedFiles: [], currentHead } };
356
+ }
357
+
358
+ // Use a unique ASCII separator unlikely to appear in commit subjects or
359
+ // paths. NUL would be ideal but is awkward to pass through argv; unit
360
+ // separator (\x1f) is the next-best thing and is rejected by sanitize if
361
+ // it ever leaks into memory content.
362
+ const SEP = "\x1f";
363
+ // --reverse makes git emit oldest-first, so the commits array reads in
364
+ // chronological order and capCommits' slice(-max) keeps the NEWEST entries
365
+ // (the ones a returning agent cares about). Without --reverse, git log is
366
+ // newest-first and slice(-max) would drop exactly the commits we want.
367
+ const logResult = invoker(repoRoot, [
368
+ "log",
369
+ "--reverse",
370
+ `--pretty=format:%H${SEP}%s`,
371
+ "--name-only",
372
+ `${sinceHead}..${currentHead}`,
373
+ ]);
374
+ if (logResult.exitCode !== 0) {
375
+ // Exit code 128 is git's "bad revision" / "object not found" — the prior
376
+ // head is genuinely unreachable (force-push/rebase erased it). Return an
377
+ // empty slice so the differ labels it `unreachable_head`; the state marker
378
+ // advances so the next call sees the new head as the baseline.
379
+ if (logResult.exitCode === 128) {
380
+ return { ok: true, slice: { commits: [], touchedFiles: [], currentHead } };
381
+ }
382
+ // Other non-zero exit codes (127 = spawn failure / 2s timeout, 129+ =
383
+ // signal, etc.) are TRANSIENT — the old head is probably still valid. Do
384
+ // NOT treat these as unreachable and do NOT let the caller advance the
385
+ // state marker. Return `git_failed` so the surface preserves the old
386
+ // marker and the next session retries.
387
+ return {
388
+ ok: false,
389
+ code: "git_failed",
390
+ detail: 'git log exited ' + logResult.exitCode + ' (transient — state marker preserved)',
391
+ };
392
+ }
393
+
394
+ const { commits, touchedFiles } = parseLogOutput(logResult.stdout, SEP);
395
+ return { ok: true, slice: { commits, touchedFiles, currentHead } };
396
+ }
397
+
398
+ /**
399
+ * Parse `git log --pretty=format:%H<SEP>%s --name-only` output into structured
400
+ * commits + a de-duplicated, sorted touched-file list.
401
+ *
402
+ * Exported for unit tests.
403
+ */
404
+ export function parseLogOutput(stdout: string, sep: string): {
405
+ commits: GitCommit[];
406
+ touchedFiles: string[];
407
+ } {
408
+ const commits: GitCommit[] = [];
409
+ const fileSet = new Set<string>();
410
+
411
+ // Git emits each commit as:
412
+ // <sha><SEP><subject>
413
+ // <blank line>
414
+ // <file1>
415
+ // <file2>
416
+ // <blank line>
417
+ // <next commit>...
418
+ //
419
+ // We split on blank-line-separated blocks. A commit block starts with a
420
+ // line containing the separator; file blocks do not.
421
+ const blocks = stdout.split(/\n\s*\n/);
422
+ for (const block of blocks) {
423
+ const lines = block.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
424
+ if (lines.length === 0) continue;
425
+ const header = lines[0]!;
426
+ const sepIdx = header.indexOf(sep);
427
+ if (sepIdx >= 0) {
428
+ const sha = header.slice(0, sepIdx);
429
+ const subject = header.slice(sepIdx + sep.length);
430
+ commits.push({ sha, subject });
431
+ for (let i = 1; i < lines.length; i += 1) {
432
+ if (lines[i]!) fileSet.add(lines[i]!);
433
+ }
434
+ } else {
435
+ // A file-only block (continuation) — every non-empty line is a file.
436
+ for (const line of lines) fileSet.add(line);
437
+ }
438
+ }
439
+
440
+ const touchedFiles = [...fileSet].sort((a, b) => a.localeCompare(b));
441
+ return { commits, touchedFiles };
442
+ }