@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.
Files changed (62) hide show
  1. package/dist/access-boundary.d.ts +2 -2
  2. package/dist/access-boundary.js +3 -3
  3. package/dist/access-cli.js +57 -6
  4. package/dist/access-cli.js.map +1 -1
  5. package/dist/access-http.d.ts +1 -1
  6. package/dist/access-http.js +6 -6
  7. package/dist/access-mcp.d.ts +3 -1
  8. package/dist/access-mcp.js +5 -5
  9. package/dist/access-operations.d.ts +8 -3
  10. package/dist/access-operations.js +6 -4
  11. package/dist/{access-service-DvApf9LZ.d.ts → access-service-DxCpgyVW.d.ts} +95 -1
  12. package/dist/access-service.d.ts +1 -1
  13. package/dist/access-service.js +2 -2
  14. package/dist/access-surface-catalog.d.ts +1 -1
  15. package/dist/access-surface-catalog.js +2 -0
  16. package/dist/access-surface-catalog.js.map +1 -1
  17. package/dist/{chunk-2EN5WZQB.js → chunk-5HGPDRQZ.js} +2 -2
  18. package/dist/chunk-5HGPDRQZ.js.map +1 -0
  19. package/dist/{chunk-W4MICQIZ.js → chunk-F7BVJ3IE.js} +2 -2
  20. package/dist/{chunk-7MOTEVAA.js → chunk-HRUULBBV.js} +3 -1
  21. package/dist/chunk-HRUULBBV.js.map +1 -0
  22. package/dist/{chunk-NEILAC3H.js → chunk-JNKH2XWQ.js} +35 -5
  23. package/dist/chunk-JNKH2XWQ.js.map +1 -0
  24. package/dist/{chunk-V56S3A23.js → chunk-MQTPUPLP.js} +295 -15
  25. package/dist/chunk-MQTPUPLP.js.map +1 -0
  26. package/dist/{chunk-DO7MDZMU.js → chunk-RHWFMBO5.js} +32 -5
  27. package/dist/chunk-RHWFMBO5.js.map +1 -0
  28. package/dist/{chunk-O4ZIV62T.js → chunk-THKNO6OX.js} +6 -5
  29. package/dist/{chunk-O4ZIV62T.js.map → chunk-THKNO6OX.js.map} +1 -1
  30. package/dist/{chunk-SEK4XIDN.js → chunk-WYB44HHV.js} +20 -6
  31. package/dist/chunk-WYB44HHV.js.map +1 -0
  32. package/dist/{cli-BjsY9Le7.d.ts → cli-DoQsv4ut.d.ts} +1 -1
  33. package/dist/cli.d.ts +2 -2
  34. package/dist/cli.js +7 -7
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.js +8 -8
  37. package/dist/mcp-memory-inspector-app.d.ts +1 -1
  38. package/dist/orchestrator.js +2 -2
  39. package/dist/schemas.d.ts +28 -28
  40. package/dist/transfer/types.d.ts +12 -12
  41. package/package.json +2 -2
  42. package/src/access-boundary.ts +2 -1
  43. package/src/access-cli.ts +65 -2
  44. package/src/access-http.ts +20 -0
  45. package/src/access-mcp.ts +29 -0
  46. package/src/access-operations.ts +46 -0
  47. package/src/access-service.ts +55 -0
  48. package/src/access-surface-catalog.test.ts +1 -1
  49. package/src/access-surface-catalog.ts +2 -0
  50. package/src/cli.ts +1 -0
  51. package/src/coding/coding-namespace.ts +1 -1
  52. package/src/coding/session-delta-surfaces.test.ts +383 -0
  53. package/src/coding/session-delta-surfaces.ts +325 -0
  54. package/src/coding/session-delta.test.ts +411 -0
  55. package/src/coding/session-delta.ts +442 -0
  56. package/dist/chunk-2EN5WZQB.js.map +0 -1
  57. package/dist/chunk-7MOTEVAA.js.map +0 -1
  58. package/dist/chunk-DO7MDZMU.js.map +0 -1
  59. package/dist/chunk-NEILAC3H.js.map +0 -1
  60. package/dist/chunk-SEK4XIDN.js.map +0 -1
  61. package/dist/chunk-V56S3A23.js.map +0 -1
  62. /package/dist/{chunk-W4MICQIZ.js.map → chunk-F7BVJ3IE.js.map} +0 -0
@@ -317,6 +317,7 @@ export class EngramAccessHttpServer {
317
317
  emitLegacyTools: options.emitLegacyTools,
318
318
  codingDecisionVisible: this.service.decisionRecordSurfaceVisible,
319
319
  architectureCardVisible: this.service.architectureCardSurfaceVisible,
320
+ sessionDeltaVisible: this.service.sessionDeltaSurfaceVisible,
320
321
  });
321
322
  }
322
323
 
@@ -1386,6 +1387,25 @@ export class EngramAccessHttpServer {
1386
1387
  return;
1387
1388
  }
1388
1389
 
1390
+ if (req.method === "POST" && pathname === "/engram/v1/coding/delta") {
1391
+ // Migrated through the access boundary (issue #1525/#1548 PR4):
1392
+ // get is read-only with respect to user memory content. It DOES write
1393
+ // the operator-side state file (last-seen-head marker), but that is
1394
+ // bookkeeping — the same way calibration.ts writes are uncounted. No
1395
+ // write-quota enforcement.
1396
+ const body = await this.readJsonBody(req);
1397
+ const op = getOperation("coding_delta");
1398
+ if (!op) {
1399
+ throw new EngramAccessInputError("access-boundary: operation not registered: coding_delta");
1400
+ }
1401
+ const output = (await op.run(body, {
1402
+ service: this.service,
1403
+ authenticatedPrincipal: this.resolveRequestPrincipal(req),
1404
+ })) as { result: unknown };
1405
+ this.respondJson(res, 200, output.result);
1406
+ return;
1407
+ }
1408
+
1389
1409
  if (req.method === "POST" && pathname === "/engram/v1/suggestions") {
1390
1410
  const body = await this.readValidatedBody(req, "suggestionSubmit");
1391
1411
  const request = {
package/src/access-mcp.ts CHANGED
@@ -111,6 +111,7 @@ const MCP_MIGRATED_OPERATIONS: Readonly<Record<string, OperationName>> = {
111
111
  "engram.memory_store": "memory_store",
112
112
  "engram.coding_decision": "coding_decision",
113
113
  "engram.coding_architecture": "coding_architecture",
114
+ "engram.coding_delta": "coding_delta",
114
115
  };
115
116
 
116
117
  function resolveChatGptInspectorRecallSessionKey(
@@ -331,6 +332,7 @@ export class EngramMcpServer {
331
332
  * (issue #1548 Track A PR 3, rule 39).
332
333
  */
333
334
  private readonly architectureCardVisible: boolean;
335
+ private readonly sessionDeltaVisible: boolean;
334
336
 
335
337
  constructor(
336
338
  private readonly service: EngramAccessService,
@@ -341,6 +343,7 @@ export class EngramMcpServer {
341
343
  emitLegacyTools?: boolean;
342
344
  codingDecisionVisible?: boolean;
343
345
  architectureCardVisible?: boolean;
346
+ sessionDeltaVisible?: boolean;
344
347
  } = {},
345
348
  ) {
346
349
  this.citationsEnabled = options.citationsEnabled === true;
@@ -348,6 +351,7 @@ export class EngramMcpServer {
348
351
  this.emitLegacyTools = options.emitLegacyTools !== false;
349
352
  this.codingDecisionVisible = options.codingDecisionVisible === true;
350
353
  this.architectureCardVisible = options.architectureCardVisible === true;
354
+ this.sessionDeltaVisible = options.sessionDeltaVisible === true;
351
355
  this.authenticatedPrincipal =
352
356
  options.principal?.trim() ||
353
357
  readEnvVar("OPENCLAW_ENGRAM_ACCESS_PRINCIPAL")?.trim() ||
@@ -2033,6 +2037,31 @@ export class EngramMcpServer {
2033
2037
  );
2034
2038
  this.tools = [...this.tools, ...architectureTools];
2035
2039
  }
2040
+ if (this.sessionDeltaVisible) {
2041
+ const deltaTools = withToolAliases(
2042
+ {
2043
+ name: "engram.coding_delta",
2044
+ description:
2045
+ "Get the session delta (commits + touched files since last seen) for the session's coding namespace (issue #1548 Track A PR 4). Subcommand: get.",
2046
+ inputSchema: {
2047
+ type: "object",
2048
+ properties: {
2049
+ subcommand: {
2050
+ type: "string",
2051
+ enum: ["get"],
2052
+ description: "Which delta operation to run.",
2053
+ },
2054
+ sessionKey: { type: "string", description: "Session identifier whose coding context scopes the operation." },
2055
+ namespace: { type: "string", description: "Optional explicit namespace (overrides coding-context overlay)." },
2056
+ },
2057
+ required: ["subcommand"],
2058
+ additionalProperties: false,
2059
+ },
2060
+ },
2061
+ this.emitLegacyTools,
2062
+ );
2063
+ this.tools = [...this.tools, ...deltaTools];
2064
+ }
2036
2065
  }
2037
2066
 
2038
2067
  /** Get clientInfo for a specific MCP session. Returns undefined for non-MCP requests. */
@@ -29,6 +29,11 @@ import {
29
29
  type ArchitectureSurfaceRequest,
30
30
  type ArchitectureSurfaceResponse,
31
31
  } from "./coding/architecture-surfaces.js";
32
+ import {
33
+ DELTA_SUBCOMMANDS,
34
+ type DeltaSurfaceRequest,
35
+ type DeltaSurfaceResponse,
36
+ } from "./coding/session-delta-surfaces.js";
32
37
 
33
38
  // ---------------------------------------------------------------------------
34
39
  // memory_get — fetch one memory by id
@@ -250,6 +255,46 @@ export const codingArchitectureOperation = defineOperation<
250
255
  },
251
256
  });
252
257
 
258
+ // ---------------------------------------------------------------------------
259
+ // coding_delta — session-delta surface (issue #1548 Track A PR 4)
260
+ // ---------------------------------------------------------------------------
261
+
262
+ const codingDeltaSchema = z.preprocess(
263
+ (data) => {
264
+ if (data !== null && typeof data === "object" && !Array.isArray(data)) {
265
+ const obj = data as Record<string, unknown>;
266
+ const cleaned: Record<string, unknown> = {};
267
+ for (const [key, value] of Object.entries(obj)) {
268
+ if (value !== null) cleaned[key] = value;
269
+ }
270
+ return cleaned;
271
+ }
272
+ return data;
273
+ },
274
+ z.object({
275
+ subcommand: z.enum(DELTA_SUBCOMMANDS),
276
+ sessionKey: z.string().trim().max(512).optional(),
277
+ namespace: z.string().trim().max(256).optional(),
278
+ }),
279
+ );
280
+
281
+ export type CodingDeltaInput = DeltaSurfaceRequest;
282
+ export type CodingDeltaOutput = { result: DeltaSurfaceResponse };
283
+
284
+ export const codingDeltaOperation = defineOperation<
285
+ CodingDeltaInput,
286
+ CodingDeltaOutput
287
+ >({
288
+ name: "coding_delta",
289
+ description:
290
+ "Get the session delta (commits + touched files since last seen) for the session's coding namespace (issue #1548 Track A PR 4).",
291
+ schema: codingDeltaSchema as z.ZodType<CodingDeltaInput>,
292
+ handler: async (input, ctx) => {
293
+ const result = await ctx.service.codingDelta(input, ctx.authenticatedPrincipal);
294
+ return { result };
295
+ },
296
+ });
297
+
253
298
  // ---------------------------------------------------------------------------
254
299
  // Surface registration map — what each transport calls the pilot ops
255
300
  // ---------------------------------------------------------------------------
@@ -265,4 +310,5 @@ export const REGISTERED_OPERATIONS = [
265
310
  memoryStoreOperation.spec.name,
266
311
  codingDecisionOperation.spec.name,
267
312
  codingArchitectureOperation.spec.name,
313
+ codingDeltaOperation.spec.name,
268
314
  ] as const;
@@ -28,6 +28,13 @@ import {
28
28
  ARCHITECTURE_CARD_TAG,
29
29
  } from "./coding/architecture-surfaces.js";
30
30
  import { buildArchitectureCard, createArchitectureCardSummariser } from "./coding/architecture-card.js";
31
+ import {
32
+ handleCodingDelta,
33
+ type DeltaSurfaceRequest,
34
+ type DeltaSurfaceResponse,
35
+ type DeltaSurfaceStorage,
36
+ } from "./coding/session-delta-surfaces.js";
37
+ import { defaultGitInvoker } from "./coding/git-context.js";
31
38
  import { createVersion } from "./page-versioning.js";
32
39
  import { WorkStorage } from "./work/storage.js";
33
40
  import {
@@ -4545,6 +4552,54 @@ export class EngramAccessService {
4545
4552
  });
4546
4553
  }
4547
4554
 
4555
+ /** Whether the coding_delta tool should appear in tools/list (rule 39). */
4556
+ get sessionDeltaSurfaceVisible(): boolean {
4557
+ return this.orchestrator.config.codingKnowledge?.enabled === true
4558
+ && this.orchestrator.config.codingKnowledge?.sessionDelta === true;
4559
+ }
4560
+ /**
4561
+ * Thin delegate — handler logic in coding/session-delta-surfaces.ts (#1548 PR4).
4562
+ * All three surfaces (MCP/HTTP/CLI) arrive here via the boundary operation.
4563
+ * Namespace resolution uses the SAME coding-scoped read path as the other
4564
+ * coding surfaces; the delta state file lives under
4565
+ * `<memoryDir>/state/coding-knowledge/<namespace>.json`.
4566
+ */
4567
+ async codingDelta(
4568
+ request: DeltaSurfaceRequest,
4569
+ authenticatedPrincipal?: string,
4570
+ ): Promise<DeltaSurfaceResponse> {
4571
+ return handleCodingDelta(request, {
4572
+ codingKnowledge: this.orchestrator.config.codingKnowledge,
4573
+ getCodingContext: (sk) => this.orchestrator.getCodingContextForSession(sk),
4574
+ resolveStorage: async (req) => {
4575
+ const ns = await this.resolveCodingScopedReadableNamespace({
4576
+ namespace: req.namespace,
4577
+ sessionKey: req.sessionKey,
4578
+ authenticatedPrincipal,
4579
+ });
4580
+ const storage = await this.orchestrator.getStorage(ns);
4581
+ // Delta only needs memoryDir + namespace (no readAllMemories). Use the
4582
+ // namespace-ROUTED root (storage.dir), not the global config.memoryDir:
4583
+ // non-default namespaces route to memoryDir/namespaces/<token>, so
4584
+ // session-delta markers must read/write from the routed tree, mirroring
4585
+ // the decision/architecture siblings (cursor review).
4586
+ return {
4587
+ memoryDir: storage.dir,
4588
+ namespace: ns,
4589
+ } satisfies DeltaSurfaceStorage;
4590
+ },
4591
+ gitInvoker: (cwd, args) => {
4592
+ // Reuse the existing defaultGitInvoker (2s timeout, never throws —
4593
+ // the discipline git-context.ts already established). It returns
4594
+ // { stdout, exitCode } so the handler can recover from non-zero
4595
+ // exits without a try/catch.
4596
+ const invoker = defaultGitInvoker();
4597
+ return invoker(cwd, args);
4598
+ },
4599
+ throwInputError: (msg) => { throw new EngramAccessInputError(msg); },
4600
+ });
4601
+ }
4602
+
4548
4603
  async memoryBrowse(
4549
4604
  request: EngramAccessMemoryBrowseRequest = {},
4550
4605
  ): Promise<EngramAccessMemoryBrowseResponse> {
@@ -67,7 +67,7 @@ function shortToolName(advertised: string): string {
67
67
  /** Spin up a server with emitLegacyTools=true and read the deduped short names. */
68
68
  async function liveMcpToolShortNames(): Promise<ReadonlySet<string>> {
69
69
  const stub = { briefingEnabled: true } as unknown as EngramAccessService;
70
- const server = new EngramMcpServer(stub, { emitLegacyTools: true, codingDecisionVisible: true, architectureCardVisible: true });
70
+ const server = new EngramMcpServer(stub, { emitLegacyTools: true, codingDecisionVisible: true, architectureCardVisible: true, sessionDeltaVisible: true });
71
71
  const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
72
72
  const result = (response as { result?: { tools?: Array<{ name: string }> } }).result;
73
73
  const names = new Set<string>();
@@ -74,6 +74,7 @@ export const MCP_TOOLS: readonly McpToolEntry[] = [
74
74
  { tool: "memory_store", operation: "memory_store" },
75
75
  { tool: "coding_decision", operation: "coding_decision" },
76
76
  { tool: "coding_architecture", operation: "coding_architecture" },
77
+ { tool: "coding_delta", operation: "coding_delta" },
77
78
  { tool: "suggestion_submit", operation: null },
78
79
  { tool: "entity_get", operation: null },
79
80
  { tool: "review_queue_list", operation: null },
@@ -172,6 +173,7 @@ export const HTTP_ROUTES: readonly HttpRouteEntry[] = [
172
173
  { method: "POST", pathname: "/engram/v1/memories", operation: "memory_store" },
173
174
  { method: "POST", pathname: "/engram/v1/coding/decisions", operation: "coding_decision" },
174
175
  { method: "POST", pathname: "/engram/v1/coding/architecture", operation: "coding_architecture" },
176
+ { method: "POST", pathname: "/engram/v1/coding/delta", operation: "coding_delta" },
175
177
  { method: "POST", pathname: "/engram/v1/suggestions", operation: null },
176
178
  { method: "GET", pathname: "/engram/v1/memories", operation: null },
177
179
  { method: "GET", pathname: "/engram/v1/memories/:id", operation: "memory_get" },
package/src/cli.ts CHANGED
@@ -2803,6 +2803,7 @@ export async function runAccessMcpServeCliCommand(
2803
2803
  emitLegacyTools: options.emitLegacyTools,
2804
2804
  codingDecisionVisible: service.decisionRecordSurfaceVisible,
2805
2805
  architectureCardVisible: service.architectureCardSurfaceVisible,
2806
+ sessionDeltaVisible: service.sessionDeltaSurfaceVisible,
2806
2807
  });
2807
2808
  await server.runStdio(options.stdin ?? process.stdin, options.stdout ?? process.stdout);
2808
2809
  return { ok: true };
@@ -66,7 +66,7 @@ export interface CodingNamespaceOverlay {
66
66
  * greedy quantifiers) closes the polynomial-backtracking surface that
67
67
  * CodeQL flagged on patterns like `-+` and `^-+|-+$`.
68
68
  */
69
- function sanitizeFragment(input: string): string {
69
+ export function sanitizeFragment(input: string): string {
70
70
  if (typeof input !== "string") return "";
71
71
  const trimmed = input.trim().toLowerCase();
72
72
  let out = "";
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Surface contract tests for the session-delta handler
3
+ * (issue #1548 Track A PR 4).
4
+ *
5
+ * Contract under test:
6
+ * - Gate predicate: enabled + sessionDelta + coding context (rule 39).
7
+ * - get with no prior state → first_run, state initialized.
8
+ * - get with prior head == current → unchanged.
9
+ * - get with prior head ancestor → changed with capped delta.
10
+ * - get with unreachable prior head → tagged failure (rule 34).
11
+ * - State write happens AFTER compute (rule 25); failures are logged not fatal.
12
+ */
13
+ import assert from "node:assert/strict";
14
+ import test from "node:test";
15
+
16
+ import {
17
+ DELTA_SUBCOMMANDS,
18
+ formatDeltaSubcommands,
19
+ handleCodingDelta,
20
+ isDeltaSubcommand,
21
+ isSessionDeltaSurfaceEnabled,
22
+ isSessionDeltaSurfaceVisible,
23
+ type DeltaSurfaceContext,
24
+ type DeltaSurfaceStorage,
25
+ } from "./session-delta-surfaces.js";
26
+ import type {
27
+ CodingKnowledgeConfig,
28
+ CodingContext,
29
+ } from "../types.js";
30
+ import type { SessionDeltaGitInvoker } from "./session-delta.js";
31
+
32
+ // ──────────────────────────────────────────────────────────────────────────
33
+ // Fixtures
34
+ // ──────────────────────────────────────────────────────────────────────────
35
+
36
+ const DEFAULT_CONFIG: CodingKnowledgeConfig = {
37
+ enabled: true,
38
+ decisionRecords: true,
39
+ architectureCard: true,
40
+ sessionDelta: true,
41
+ architectureCardLlmSummary: false,
42
+ structuralProvider: "none",
43
+ structuralProviderCommand: "",
44
+ };
45
+
46
+ const CODING_CONTEXT: CodingContext = {
47
+ projectId: "origin:deadbeef",
48
+ branch: "main",
49
+ rootPath: "/fake/repo",
50
+ defaultBranch: "main",
51
+ };
52
+
53
+ function makeStorage(): DeltaSurfaceStorage {
54
+ return { memoryDir: "/fake/mem", namespace: "project-test" };
55
+ }
56
+
57
+ function makeInvoker(responses: Array<{ args: string[]; stdout: string; exitCode?: number }>): SessionDeltaGitInvoker {
58
+ return (_cwd: string, args: string[]) => {
59
+ const match = responses.find((r) => r.args.join(" ") === args.join(" "));
60
+ if (!match) return { stdout: "", exitCode: 1 };
61
+ return { stdout: match.stdout, exitCode: match.exitCode ?? 0 };
62
+ };
63
+ }
64
+
65
+ function makeContext(overrides: Partial<DeltaSurfaceContext> = {}): DeltaSurfaceContext {
66
+ return {
67
+ codingKnowledge: DEFAULT_CONFIG,
68
+ getCodingContext: () => CODING_CONTEXT,
69
+ resolveStorage: async () => makeStorage(),
70
+ gitInvoker: makeInvoker([]),
71
+ throwInputError: (msg) => {
72
+ throw new Error(msg);
73
+ },
74
+ ...overrides,
75
+ };
76
+ }
77
+
78
+ // Stub the pure module's state read/write by importing the real module and
79
+ // shimming its filesystem. The handler imports readLastSeenState / writeLastSeenState
80
+ // from ./session-delta.js, which hit the real fs. We point memoryDir at a
81
+ // temp dir per-test.
82
+ import { mkdtemp, rm } from "node:fs/promises";
83
+ import { tmpdir } from "node:os";
84
+ import path from "node:path";
85
+
86
+ async function makeTempStorage(): Promise<{ storage: DeltaSurfaceStorage; cleanup: () => Promise<void> }> {
87
+ const dir = await mkdtemp(path.join(tmpdir(), "delta-surface-"));
88
+ return {
89
+ storage: { memoryDir: dir, namespace: "project-test" },
90
+ cleanup: async () => { await rm(dir, { recursive: true, force: true }); },
91
+ };
92
+ }
93
+
94
+ // ──────────────────────────────────────────────────────────────────────────
95
+ // Subcommand + gate helpers
96
+ // ──────────────────────────────────────────────────────────────────────────
97
+
98
+ test("subcommands: the surface exposes only `get`", () => {
99
+ assert.deepEqual([...DELTA_SUBCOMMANDS], ["get"]);
100
+ assert.equal(formatDeltaSubcommands(), "get");
101
+ });
102
+
103
+ test("isDeltaSubcommand: narrows valid + rejects unknown", () => {
104
+ assert.equal(isDeltaSubcommand("get"), true);
105
+ assert.equal(isDeltaSubcommand("refresh"), false);
106
+ assert.equal(isDeltaSubcommand(undefined), false);
107
+ assert.equal(isDeltaSubcommand(123), false);
108
+ });
109
+
110
+ test("gate: surface enabled when config + sessionDelta + coding context all true", () => {
111
+ assert.equal(isSessionDeltaSurfaceEnabled(DEFAULT_CONFIG, CODING_CONTEXT), true);
112
+ });
113
+
114
+ test("gate: surface disabled when master gate off", () => {
115
+ const config: CodingKnowledgeConfig = { ...DEFAULT_CONFIG, enabled: false };
116
+ assert.equal(isSessionDeltaSurfaceEnabled(config, CODING_CONTEXT), false);
117
+ });
118
+
119
+ test("gate: surface disabled when sessionDelta off", () => {
120
+ const config: CodingKnowledgeConfig = { ...DEFAULT_CONFIG, sessionDelta: false };
121
+ assert.equal(isSessionDeltaSurfaceEnabled(config, CODING_CONTEXT), false);
122
+ });
123
+
124
+ test("gate: surface disabled when no coding context", () => {
125
+ assert.equal(isSessionDeltaSurfaceEnabled(DEFAULT_CONFIG, null), false);
126
+ assert.equal(isSessionDeltaSurfaceEnabled(DEFAULT_CONFIG, undefined), false);
127
+ });
128
+
129
+ test("visibility gate: config-only check mirrors full gate minus context", () => {
130
+ assert.equal(isSessionDeltaSurfaceVisible(DEFAULT_CONFIG), true);
131
+ assert.equal(isSessionDeltaSurfaceVisible({ ...DEFAULT_CONFIG, enabled: false }), false);
132
+ assert.equal(isSessionDeltaSurfaceVisible({ ...DEFAULT_CONFIG, sessionDelta: false }), false);
133
+ });
134
+
135
+ // ──────────────────────────────────────────────────────────────────────────
136
+ // Handler — gate enforcement
137
+ // ──────────────────────────────────────────────────────────────────────────
138
+
139
+ test("handler: throws when gate fails (no coding context)", async () => {
140
+ const ctx = makeContext({ getCodingContext: () => null });
141
+ await assert.rejects(
142
+ () => handleCodingDelta({ subcommand: "get", sessionKey: "s1" }, ctx),
143
+ /coding_delta requires/,
144
+ );
145
+ });
146
+
147
+ test("handler: throws when gate fails (master gate off)", async () => {
148
+ const ctx = makeContext({
149
+ codingKnowledge: { ...DEFAULT_CONFIG, enabled: false },
150
+ });
151
+ await assert.rejects(
152
+ () => handleCodingDelta({ subcommand: "get", sessionKey: "s1" }, ctx),
153
+ /coding_delta requires/,
154
+ );
155
+ });
156
+
157
+ test("handler: throws when gate fails (sessionDelta off)", async () => {
158
+ const ctx = makeContext({
159
+ codingKnowledge: { ...DEFAULT_CONFIG, sessionDelta: false },
160
+ });
161
+ await assert.rejects(
162
+ () => handleCodingDelta({ subcommand: "get", sessionKey: "s1" }, ctx),
163
+ /coding_delta requires/,
164
+ );
165
+ });
166
+
167
+ // ──────────────────────────────────────────────────────────────────────────
168
+ // Handler — three-state matrix via the surface
169
+ // ──────────────────────────────────────────────────────────────────────────
170
+
171
+ test("handler/get: first run → first_run, nextState initialized", async () => {
172
+ const { storage, cleanup } = await makeTempStorage();
173
+ try {
174
+ const invoker = makeInvoker([
175
+ { args: ["rev-parse", "HEAD"], stdout: "newhead\n" },
176
+ ]);
177
+ const ctx = makeContext({
178
+ resolveStorage: async () => storage,
179
+ gitInvoker: invoker,
180
+ });
181
+ const result = await handleCodingDelta({ subcommand: "get", sessionKey: "s1" }, ctx);
182
+ assert.equal(result.subcommand, "get");
183
+ if (!("ok" in result) || !result.ok || result.kind !== "first_run") {
184
+ assert.fail(`expected first_run, got ${JSON.stringify(result)}`);
185
+ return;
186
+ }
187
+ assert.equal(result.nextState.head, "newhead");
188
+ assert.ok(result.nextState.at);
189
+ } finally {
190
+ await cleanup();
191
+ }
192
+ });
193
+
194
+ test("handler/get: unchanged head → suppressed", async () => {
195
+ const { storage, cleanup } = await makeTempStorage();
196
+ try {
197
+ // Pre-seed the state file by running once.
198
+ const invoker1 = makeInvoker([
199
+ { args: ["rev-parse", "HEAD"], stdout: "stable\n" },
200
+ ]);
201
+ const ctx1 = makeContext({ resolveStorage: async () => storage, gitInvoker: invoker1 });
202
+ await handleCodingDelta({ subcommand: "get", sessionKey: "s1" }, ctx1);
203
+
204
+ // Second call with the same head.
205
+ const invoker2 = makeInvoker([
206
+ { args: ["rev-parse", "HEAD"], stdout: "stable\n" },
207
+ ]);
208
+ const ctx2 = makeContext({ resolveStorage: async () => storage, gitInvoker: invoker2 });
209
+ const result = await handleCodingDelta({ subcommand: "get", sessionKey: "s1" }, ctx2);
210
+ if (!("ok" in result) || !result.ok || result.kind !== "unchanged") {
211
+ assert.fail(`expected unchanged, got ${JSON.stringify(result)}`);
212
+ return;
213
+ }
214
+ } finally {
215
+ await cleanup();
216
+ }
217
+ });
218
+
219
+ test("handler/get: changed head → real delta", async () => {
220
+ const { storage, cleanup } = await makeTempStorage();
221
+ try {
222
+ // Seed: first run on headA.
223
+ const invoker1 = makeInvoker([
224
+ { args: ["rev-parse", "HEAD"], stdout: "headA\n" },
225
+ ]);
226
+ await handleCodingDelta(
227
+ { subcommand: "get", sessionKey: "s1" },
228
+ makeContext({ resolveStorage: async () => storage, gitInvoker: invoker1 }),
229
+ );
230
+
231
+ // Now head moved to headB with two commits.
232
+ const sep = "\x1f";
233
+ const log = [
234
+ `sha1${sep}feat: a`,
235
+ "",
236
+ "src/a.ts",
237
+ "",
238
+ `sha2${sep}fix: b`,
239
+ "",
240
+ "src/b.ts",
241
+ "",
242
+ ].join("\n");
243
+ const invoker2 = makeInvoker([
244
+ { args: ["rev-parse", "HEAD"], stdout: "headB\n" },
245
+ { args: ["log", "--reverse", "--pretty=format:%H\x1f%s", "--name-only", "headA..headB"], stdout: log },
246
+ ]);
247
+ const result = await handleCodingDelta(
248
+ { subcommand: "get", sessionKey: "s1" },
249
+ makeContext({ resolveStorage: async () => storage, gitInvoker: invoker2 }),
250
+ );
251
+ if (!("ok" in result) || !result.ok || result.kind !== "changed") {
252
+ assert.fail(`expected changed, got ${JSON.stringify(result)}`);
253
+ return;
254
+ }
255
+ assert.equal(result.delta.commits.length, 2);
256
+ assert.equal(result.delta.touchedFiles.length, 2);
257
+ assert.match(result.delta.summaryLine, /2 commits, 2 files touched/);
258
+ } finally {
259
+ await cleanup();
260
+ }
261
+ });
262
+
263
+ test("handler/get: unreachable prior head → tagged failure, state advances", async () => {
264
+ const { storage, cleanup } = await makeTempStorage();
265
+ try {
266
+ // Seed headA.
267
+ const invoker1 = makeInvoker([
268
+ { args: ["rev-parse", "HEAD"], stdout: "headA\n" },
269
+ ]);
270
+ await handleCodingDelta(
271
+ { subcommand: "get", sessionKey: "s1" },
272
+ makeContext({ resolveStorage: async () => storage, gitInvoker: invoker1 }),
273
+ );
274
+
275
+ // Now head is headB but `git log headA..headB` fails (force-push).
276
+ const invoker2 = makeInvoker([
277
+ { args: ["rev-parse", "HEAD"], stdout: "headB\n" },
278
+ { args: ["log", "--reverse", "--pretty=format:%H\x1f%s", "--name-only", "headA..headB"], stdout: "", exitCode: 128 },
279
+ ]);
280
+ const result = await handleCodingDelta(
281
+ { subcommand: "get", sessionKey: "s1" },
282
+ makeContext({ resolveStorage: async () => storage, gitInvoker: invoker2 }),
283
+ );
284
+ if (!("ok" in result) || result.ok || result.code !== "unreachable_head") {
285
+ assert.fail(`expected unreachable_head, got ${JSON.stringify(result)}`);
286
+ return;
287
+ }
288
+ // nextState advanced so the NEXT call sees headB as the baseline.
289
+ assert.equal(result.nextState?.head, "headB");
290
+ } finally {
291
+ await cleanup();
292
+ }
293
+ });
294
+
295
+ test("handler/get: git rev-parse failure → git_failed, no state advance", async () => {
296
+ const { storage, cleanup } = await makeTempStorage();
297
+ try {
298
+ const invoker = makeInvoker([
299
+ { args: ["rev-parse", "HEAD"], stdout: "", exitCode: 128 },
300
+ ]);
301
+ const result = await handleCodingDelta(
302
+ { subcommand: "get", sessionKey: "s1" },
303
+ makeContext({ resolveStorage: async () => storage, gitInvoker: invoker }),
304
+ );
305
+ if (!("ok" in result) || result.ok || result.code !== "git_failed") {
306
+ assert.fail(`expected git_failed, got ${JSON.stringify(result)}`);
307
+ return;
308
+ }
309
+ // No nextState when the head itself was unreadable.
310
+ assert.equal(result.nextState, undefined);
311
+ } finally {
312
+ await cleanup();
313
+ }
314
+ });
315
+
316
+ test("handler/get: transient git log failure (exit 127) → git_failed, state NOT advanced", async () => {
317
+ const { storage, cleanup } = await makeTempStorage();
318
+ try {
319
+ // Seed headA.
320
+ const invoker1 = makeInvoker([
321
+ { args: ["rev-parse", "HEAD"], stdout: "headA\n" },
322
+ ]);
323
+ await handleCodingDelta(
324
+ { subcommand: "get", sessionKey: "s1" },
325
+ makeContext({ resolveStorage: async () => storage, gitInvoker: invoker1 }),
326
+ );
327
+
328
+ // Head is headB but git log times out (exit 127 = spawn/timeout).
329
+ const invoker2 = makeInvoker([
330
+ { args: ["rev-parse", "HEAD"], stdout: "headB\n" },
331
+ { args: ["log", "--reverse", "--pretty=format:%H\x1f%s", "--name-only", "headA..headB"], stdout: "", exitCode: 127 },
332
+ ]);
333
+ const result = await handleCodingDelta(
334
+ { subcommand: "get", sessionKey: "s1" },
335
+ makeContext({ resolveStorage: async () => storage, gitInvoker: invoker2 }),
336
+ );
337
+ if (!("ok" in result) || result.ok || result.code !== "git_failed") {
338
+ assert.fail(`expected git_failed, got ${JSON.stringify(result)}`);
339
+ return;
340
+ }
341
+ // State marker must NOT advance — headA preserved for retry on next call.
342
+ // Read the state file back to verify.
343
+ const { readFile } = await import("node:fs/promises");
344
+ const { sessionDeltaStatePath } = await import("./session-delta.js");
345
+ const raw = await readFile(
346
+ sessionDeltaStatePath(storage.memoryDir, storage.namespace),
347
+ "utf8",
348
+ );
349
+ const persisted = JSON.parse(raw);
350
+ assert.equal(persisted.head, "headA", "transient failure must not advance the state marker");
351
+ } finally {
352
+ await cleanup();
353
+ }
354
+ });
355
+
356
+ // ──────────────────────────────────────────────────────────────────────────
357
+ // Rule 25 — state write happens AFTER compute
358
+ // ──────────────────────────────────────────────────────────────────────────
359
+
360
+ test("handler/get: state file is created after a successful first_run", async () => {
361
+ const { storage, cleanup } = await makeTempStorage();
362
+ try {
363
+ const invoker = makeInvoker([
364
+ { args: ["rev-parse", "HEAD"], stdout: "head1\n" },
365
+ ]);
366
+ await handleCodingDelta(
367
+ { subcommand: "get", sessionKey: "s1" },
368
+ makeContext({ resolveStorage: async () => storage, gitInvoker: invoker }),
369
+ );
370
+ const { readFile } = await import("node:fs/promises");
371
+ const raw = await readFile(
372
+ // The handler uses sessionDeltaStatePath(memoryDir, namespace).
373
+ // We import and call the same helper for parity.
374
+ (await import("./session-delta.js")).sessionDeltaStatePath(storage.memoryDir, storage.namespace),
375
+ "utf8",
376
+ );
377
+ const parsed = JSON.parse(raw);
378
+ assert.equal(parsed.head, "head1");
379
+ assert.ok(parsed.at);
380
+ } finally {
381
+ await cleanup();
382
+ }
383
+ });