@remnic/core 9.3.692 → 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.
- package/dist/access-boundary.d.ts +2 -2
- package/dist/access-boundary.js +3 -3
- package/dist/access-cli.js +57 -6
- package/dist/access-cli.js.map +1 -1
- package/dist/access-http.d.ts +1 -1
- package/dist/access-http.js +6 -6
- package/dist/access-mcp.d.ts +3 -1
- package/dist/access-mcp.js +5 -5
- package/dist/access-operations.d.ts +8 -3
- package/dist/access-operations.js +6 -4
- package/dist/{access-service-DvApf9LZ.d.ts → access-service-DxCpgyVW.d.ts} +95 -1
- package/dist/access-service.d.ts +1 -1
- package/dist/access-service.js +2 -2
- package/dist/access-surface-catalog.d.ts +1 -1
- package/dist/access-surface-catalog.js +2 -0
- package/dist/access-surface-catalog.js.map +1 -1
- package/dist/{chunk-2EN5WZQB.js → chunk-5HGPDRQZ.js} +2 -2
- package/dist/chunk-5HGPDRQZ.js.map +1 -0
- package/dist/{chunk-W4MICQIZ.js → chunk-F7BVJ3IE.js} +2 -2
- package/dist/{chunk-7MOTEVAA.js → chunk-HRUULBBV.js} +3 -1
- package/dist/chunk-HRUULBBV.js.map +1 -0
- package/dist/{chunk-NEILAC3H.js → chunk-JNKH2XWQ.js} +35 -5
- package/dist/chunk-JNKH2XWQ.js.map +1 -0
- package/dist/{chunk-V56S3A23.js → chunk-MQTPUPLP.js} +295 -15
- package/dist/chunk-MQTPUPLP.js.map +1 -0
- package/dist/{chunk-DO7MDZMU.js → chunk-RHWFMBO5.js} +32 -5
- package/dist/chunk-RHWFMBO5.js.map +1 -0
- package/dist/{chunk-O4ZIV62T.js → chunk-THKNO6OX.js} +6 -5
- package/dist/{chunk-O4ZIV62T.js.map → chunk-THKNO6OX.js.map} +1 -1
- package/dist/{chunk-SEK4XIDN.js → chunk-WYB44HHV.js} +20 -6
- package/dist/chunk-WYB44HHV.js.map +1 -0
- package/dist/{cli-BjsY9Le7.d.ts → cli-DoQsv4ut.d.ts} +1 -1
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +7 -7
- package/dist/index.d.ts +2 -2
- package/dist/index.js +8 -8
- package/dist/mcp-memory-inspector-app.d.ts +1 -1
- package/dist/orchestrator.js +2 -2
- package/dist/schemas.d.ts +28 -28
- package/dist/transfer/types.d.ts +12 -12
- package/package.json +2 -2
- package/src/access-boundary.ts +2 -1
- package/src/access-cli.ts +65 -2
- package/src/access-http.ts +20 -0
- package/src/access-mcp.ts +29 -0
- package/src/access-operations.ts +46 -0
- package/src/access-service.ts +55 -0
- package/src/access-surface-catalog.test.ts +1 -1
- package/src/access-surface-catalog.ts +2 -0
- package/src/cli.ts +1 -0
- package/src/coding/coding-namespace.ts +1 -1
- package/src/coding/session-delta-surfaces.test.ts +383 -0
- package/src/coding/session-delta-surfaces.ts +325 -0
- package/src/coding/session-delta.test.ts +411 -0
- package/src/coding/session-delta.ts +442 -0
- package/dist/chunk-2EN5WZQB.js.map +0 -1
- package/dist/chunk-7MOTEVAA.js.map +0 -1
- package/dist/chunk-DO7MDZMU.js.map +0 -1
- package/dist/chunk-NEILAC3H.js.map +0 -1
- package/dist/chunk-SEK4XIDN.js.map +0 -1
- package/dist/chunk-V56S3A23.js.map +0 -1
- /package/dist/{chunk-W4MICQIZ.js.map → chunk-F7BVJ3IE.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
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/access-boundary.ts"],"sourcesContent":["/**\n * Single input-validation and error boundary for the CLI/MCP/HTTP access\n * surfaces (issue #1525, epic #1520 Phase 1).\n *\n * Every operation that crosses the access-service facade passes through ONE\n * registry entry: a zod-validated request envelope, a shared error mapper,\n * and the \"reject invalid input and list valid options\" behavior that\n * CLAUDE.md rules 14/17/24/28/36/48/51 previously had to be re-implemented\n * per handler. The three surfaces become thin adapters — one operation\n * definition, three transports — so a validation fix lands everywhere at\n * once.\n *\n * Host-agnostic (rule 31): operation names carry no `openclaw-*`/`engram-*`\n * prefix. Session/namespace tenancy stays in the handler layer (resolved via\n * ScopePlan #1521); the boundary validates SHAPE, not tenancy.\n */\n\nimport { z } from \"zod\";\nimport { EngramAccessInputError, type EngramAccessService } from \"./access-service.js\";\nimport { expandTildePath } from \"./utils/path.js\";\n\n// ---------------------------------------------------------------------------\n// Canonical operation names — host-agnostic (rule 31)\n// ---------------------------------------------------------------------------\n\n/**\n * Canonical operation ids. One id is shared by the MCP tool, the HTTP route,\n * and the CLI command that expose the same operation. Add to this union as\n * each domain-group migration PR (memory ops → connectors → namespaces …)\n * lands; the fitness test in `access-surface-catalog.test.ts` treats the\n * registered set as the migration state.\n */\nexport type OperationName =\n | \"memory_get\"\n | \"memory_search\"\n | \"memory_store\"\n | \"coding_decision\"\n | \"coding_architecture\";\n\n// ---------------------------------------------------------------------------\n// Operation context — what every handler receives\n// ---------------------------------------------------------------------------\n\n/**\n * Per-call context. `service` is the facade the handler delegates to; the\n * boundary never reaches past it. `authenticatedPrincipal` is resolved by\n * the SURFACE (MCP header / HTTP identity / CLI flag) before the boundary\n * runs, so handlers stay principal-source-agnostic. `hooks` carries\n * transport-level callbacks (e.g. HTTP write-quota enforcement) that must\n * fire atomically inside the service call; surfaces that have no such hook\n * leave it undefined.\n */\nexport interface OperationContext {\n readonly service: EngramAccessService;\n readonly authenticatedPrincipal?: string;\n readonly hooks?: OperationHooks;\n}\n\n/**\n * Transport-level callbacks a handler forwards into the service call. Kept\n * narrow on purpose: the boundary owns validation + dispatch shape, not\n * transport policy. Add fields here only when a surface genuinely needs a\n * callback the service itself consumes.\n */\nexport interface OperationHooks {\n /** HTTP write-quota gate; throws to reject the write when exhausted. */\n readonly enforceWriteQuota?: () => void | Promise<void>;\n}\n\n// ---------------------------------------------------------------------------\n// Operation spec + bound operation\n// ---------------------------------------------------------------------------\n\nexport interface OperationSpec<In, Out> {\n /** Canonical operation id; matches an {@link OperationName}. */\n readonly name: OperationName;\n readonly description: string;\n /** Zod schema validating the raw request envelope. */\n readonly schema: z.ZodType<In>;\n /** Handler invoked with the parsed input; throws EngramAccessInputError for domain faults. */\n readonly handler: (input: In, ctx: OperationContext) => Promise<Out>;\n}\n\nexport interface BoundOperation<In = unknown, Out = unknown> {\n readonly spec: OperationSpec<In, Out>;\n /** Validate the raw envelope, then invoke the handler. Throws EngramAccessInputError on any validation failure. */\n readonly run: (rawInput: unknown, ctx: OperationContext) => Promise<Out>;\n}\n\n// ---------------------------------------------------------------------------\n// Shared normalizers the boundary owns (rules 17, 28, 36, 48, 51)\n// ---------------------------------------------------------------------------\n\n/**\n * Coerce boolean-like strings at the edge (rule 36). Accepts actual booleans\n * and the string spellings clients send (\"true\"/\"false\"/\"1\"/\"0\"/\"yes\"/\"no\"/\n * \"on\"/\"off\", case-insensitive). Rejects anything else loudly — `Boolean(\"false\")`\n * would silently be `true`, which is the bug rule 36 exists to prevent.\n *\n * `undefined`/`null`/`\"\"` → `undefined`, so callers can keep treating an\n * absent flag as \"use the default\" without a separate presence check.\n */\nexport function coerceBooleanLike(value: unknown): boolean | undefined {\n if (value === undefined || value === null || value === \"\") return undefined;\n if (typeof value === \"boolean\") return value;\n if (typeof value === \"string\") {\n const lower = value.trim().toLowerCase();\n if (lower === \"true\" || lower === \"1\" || lower === \"yes\" || lower === \"on\") return true;\n if (lower === \"false\" || lower === \"0\" || lower === \"no\" || lower === \"off\") return false;\n }\n throw new EngramAccessInputError(\n `expected a boolean-like value (true|false|1|0|yes|no|on|off); got ${JSON.stringify(value)}`,\n );\n}\n\n/**\n * Coerce + validate a positive integer from a numeric string or number\n * (rule 28). Loosely-typed MCP/CLI clients send `\"5\"`; `typeof saved === \"number\"`\n * on read-back would reject it later, so we coerce at the edge and reject\n * booleans/objects loudly (`Number(true) === 1` would silently pass otherwise).\n *\n * `undefined`/`null`/`\"\"` → `undefined`.\n */\nexport function coercePositiveInteger(value: unknown, label: string): number | undefined {\n if (value === undefined || value === null || value === \"\") return undefined;\n if (typeof value === \"number\") {\n if (!Number.isFinite(value) || value <= 0 || !Number.isInteger(value)) {\n throw new EngramAccessInputError(`${label} expects a positive integer; got ${JSON.stringify(value)}`);\n }\n return value;\n }\n if (typeof value === \"string\") {\n const trimmed = value.trim();\n if (!/^[+-]?\\d+$/.test(trimmed)) {\n throw new EngramAccessInputError(`${label} expects a positive integer; got ${JSON.stringify(value)}`);\n }\n const parsed = Number(trimmed);\n if (!Number.isSafeInteger(parsed) || parsed <= 0) {\n throw new EngramAccessInputError(`${label} expects a positive integer; got ${JSON.stringify(value)}`);\n }\n return parsed;\n }\n throw new EngramAccessInputError(`${label} expects a positive integer; got ${JSON.stringify(value)}`);\n}\n\n/**\n * Expand `~` in a path-shaped input (rule 17). Node `fs` does NOT expand `~`;\n * ad-hoc regex drifts. `undefined`/`null`/`\"\"` → `undefined`.\n */\nexport function normalizeOptionalPath(value: unknown): string | undefined {\n if (value === undefined || value === null || value === \"\") return undefined;\n if (typeof value !== \"string\") {\n throw new EngramAccessInputError(`expected a path string; got ${JSON.stringify(value)}`);\n }\n return expandTildePath(value);\n}\n\n// ---------------------------------------------------------------------------\n// Error formatting — rule 51: list valid options, never silently default\n// ---------------------------------------------------------------------------\n\n/**\n * Turn a zod failure into an {@link EngramAccessInputError} whose message\n * names the offending field and — for enum/union issues — lists the valid\n * options, so the caller can correct rather than guess (rule 51).\n */\nexport function formatZodIssues(error: z.ZodError): string {\n const parts: string[] = [];\n for (const issue of error.issues) {\n const path = issue.path.length > 0 ? issue.path.join(\".\") : \"(root)\";\n const options = enumOptionsFromIssue(issue);\n const suffix = options ? `. Valid: ${options.join(\", \")}` : \"\";\n parts.push(`${path}: ${issue.message}${suffix}`);\n }\n return parts.length > 0\n ? `request validation failed: ${parts.join(\"; \")}`\n : \"request validation failed\";\n}\n\nfunction enumOptionsFromIssue(issue: z.ZodIssue): readonly string[] | undefined {\n // zod exposes accepted enum values on the issue for ZodEnum / ZodNativeEnum\n // and on the options of invalid_union discriminators. Reading them here\n // keeps \"list valid options\" in ONE place rather than per handler.\n if (issue.code === z.ZodIssueCode.invalid_enum_value) {\n const rawOptions = (issue as { options?: unknown }).options;\n if (Array.isArray(rawOptions)) {\n return rawOptions.map((opt) => String(opt));\n }\n }\n return undefined;\n}\n\n// ---------------------------------------------------------------------------\n// Registry\n// ---------------------------------------------------------------------------\n\nconst registry = new Map<OperationName, BoundOperation>();\n\n/**\n * Register an operation. Throws if the name is already registered — duplicate\n * registration is a programming error, not a runtime input fault, so it throws\n * a plain Error (not the input-error class surfaces translate for clients).\n */\nexport function defineOperation<In, Out>(spec: OperationSpec<In, Out>): BoundOperation<In, Out> {\n if (registry.has(spec.name)) {\n throw new Error(`access-boundary: operation already registered: ${spec.name}`);\n }\n const bound: BoundOperation<In, Out> = {\n spec,\n run: async (rawInput, ctx) => {\n const parseResult = spec.schema.safeParse(rawInput);\n if (!parseResult.success) {\n throw new EngramAccessInputError(formatZodIssues(parseResult.error));\n }\n return spec.handler(parseResult.data, ctx);\n },\n };\n // Store under the canonical name; the cast is safe because In/Out are\n // erased at the registry boundary and recovered by callers via getOperation.\n registry.set(spec.name, bound as unknown as BoundOperation);\n return bound;\n}\n\n/** Look up a registered operation by canonical name. */\nexport function getOperation(name: OperationName): BoundOperation | undefined {\n return registry.get(name);\n}\n\n/** All registered operation names. */\nexport function listRegisteredOperations(): readonly OperationName[] {\n return [...registry.keys()];\n}\n\n/** Test-only: clear the registry so pilot definitions can be re-registered. */\nexport function __resetRegistryForTest(): void {\n registry.clear();\n}\n"],"mappings":";;;;;;;;AAiBA,SAAS,SAAS;AAqFX,SAAS,kBAAkB,OAAqC;AACrE,MAAI,UAAU,UAAa,UAAU,QAAQ,UAAU,GAAI,QAAO;AAClE,MAAI,OAAO,UAAU,UAAW,QAAO;AACvC,MAAI,OAAO,UAAU,UAAU;AAC7B,UAAM,QAAQ,MAAM,KAAK,EAAE,YAAY;AACvC,QAAI,UAAU,UAAU,UAAU,OAAO,UAAU,SAAS,UAAU,KAAM,QAAO;AACnF,QAAI,UAAU,WAAW,UAAU,OAAO,UAAU,QAAQ,UAAU,MAAO,QAAO;AAAA,EACtF;AACA,QAAM,IAAI;AAAA,IACR,qEAAqE,KAAK,UAAU,KAAK,CAAC;AAAA,EAC5F;AACF;AAUO,SAAS,sBAAsB,OAAgB,OAAmC;AACvF,MAAI,UAAU,UAAa,UAAU,QAAQ,UAAU,GAAI,QAAO;AAClE,MAAI,OAAO,UAAU,UAAU;AAC7B,QAAI,CAAC,OAAO,SAAS,KAAK,KAAK,SAAS,KAAK,CAAC,OAAO,UAAU,KAAK,GAAG;AACrE,YAAM,IAAI,uBAAuB,GAAG,KAAK,oCAAoC,KAAK,UAAU,KAAK,CAAC,EAAE;AAAA,IACtG;AACA,WAAO;AAAA,EACT;AACA,MAAI,OAAO,UAAU,UAAU;AAC7B,UAAM,UAAU,MAAM,KAAK;AAC3B,QAAI,CAAC,aAAa,KAAK,OAAO,GAAG;AAC/B,YAAM,IAAI,uBAAuB,GAAG,KAAK,oCAAoC,KAAK,UAAU,KAAK,CAAC,EAAE;AAAA,IACtG;AACA,UAAM,SAAS,OAAO,OAAO;AAC7B,QAAI,CAAC,OAAO,cAAc,MAAM,KAAK,UAAU,GAAG;AAChD,YAAM,IAAI,uBAAuB,GAAG,KAAK,oCAAoC,KAAK,UAAU,KAAK,CAAC,EAAE;AAAA,IACtG;AACA,WAAO;AAAA,EACT;AACA,QAAM,IAAI,uBAAuB,GAAG,KAAK,oCAAoC,KAAK,UAAU,KAAK,CAAC,EAAE;AACtG;AAMO,SAAS,sBAAsB,OAAoC;AACxE,MAAI,UAAU,UAAa,UAAU,QAAQ,UAAU,GAAI,QAAO;AAClE,MAAI,OAAO,UAAU,UAAU;AAC7B,UAAM,IAAI,uBAAuB,+BAA+B,KAAK,UAAU,KAAK,CAAC,EAAE;AAAA,EACzF;AACA,SAAO,gBAAgB,KAAK;AAC9B;AAWO,SAAS,gBAAgB,OAA2B;AACzD,QAAM,QAAkB,CAAC;AACzB,aAAW,SAAS,MAAM,QAAQ;AAChC,UAAM,OAAO,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,KAAK,GAAG,IAAI;AAC5D,UAAM,UAAU,qBAAqB,KAAK;AAC1C,UAAM,SAAS,UAAU,YAAY,QAAQ,KAAK,IAAI,CAAC,KAAK;AAC5D,UAAM,KAAK,GAAG,IAAI,KAAK,MAAM,OAAO,GAAG,MAAM,EAAE;AAAA,EACjD;AACA,SAAO,MAAM,SAAS,IAClB,8BAA8B,MAAM,KAAK,IAAI,CAAC,KAC9C;AACN;AAEA,SAAS,qBAAqB,OAAkD;AAI9E,MAAI,MAAM,SAAS,EAAE,aAAa,oBAAoB;AACpD,UAAM,aAAc,MAAgC;AACpD,QAAI,MAAM,QAAQ,UAAU,GAAG;AAC7B,aAAO,WAAW,IAAI,CAAC,QAAQ,OAAO,GAAG,CAAC;AAAA,IAC5C;AAAA,EACF;AACA,SAAO;AACT;AAMA,IAAM,WAAW,oBAAI,IAAmC;AAOjD,SAAS,gBAAyB,MAAuD;AAC9F,MAAI,SAAS,IAAI,KAAK,IAAI,GAAG;AAC3B,UAAM,IAAI,MAAM,kDAAkD,KAAK,IAAI,EAAE;AAAA,EAC/E;AACA,QAAM,QAAiC;AAAA,IACrC;AAAA,IACA,KAAK,OAAO,UAAU,QAAQ;AAC5B,YAAM,cAAc,KAAK,OAAO,UAAU,QAAQ;AAClD,UAAI,CAAC,YAAY,SAAS;AACxB,cAAM,IAAI,uBAAuB,gBAAgB,YAAY,KAAK,CAAC;AAAA,MACrE;AACA,aAAO,KAAK,QAAQ,YAAY,MAAM,GAAG;AAAA,IAC3C;AAAA,EACF;AAGA,WAAS,IAAI,KAAK,MAAM,KAAkC;AAC1D,SAAO;AACT;AAGO,SAAS,aAAa,MAAiD;AAC5E,SAAO,SAAS,IAAI,IAAI;AAC1B;AAGO,SAAS,2BAAqD;AACnE,SAAO,CAAC,GAAG,SAAS,KAAK,CAAC;AAC5B;AAGO,SAAS,yBAA+B;AAC7C,WAAS,MAAM;AACjB;","names":[]}
|