@remnic/core 9.3.697 → 9.3.699

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 (104) hide show
  1. package/dist/access-boundary.d.ts +1 -1
  2. package/dist/access-boundary.js +6 -6
  3. package/dist/access-cli.js +18 -18
  4. package/dist/access-http.d.ts +1 -1
  5. package/dist/access-http.js +9 -9
  6. package/dist/access-mcp.d.ts +1 -1
  7. package/dist/access-mcp.js +8 -8
  8. package/dist/access-operations.d.ts +1 -1
  9. package/dist/access-operations.js +7 -7
  10. package/dist/{access-service-BqBQeUJd.d.ts → access-service-DftqtNUy.d.ts} +10 -0
  11. package/dist/access-service.d.ts +1 -1
  12. package/dist/access-service.js +5 -5
  13. package/dist/access-surface-catalog.d.ts +1 -1
  14. package/dist/briefing.js +3 -3
  15. package/dist/causal-consolidation.js +4 -4
  16. package/dist/{chunk-75KYTVFK.js → chunk-2K64VH66.js} +2 -2
  17. package/dist/{chunk-62Q2QA4Z.js → chunk-2KJG6ZZK.js} +2 -2
  18. package/dist/{chunk-AYMVRRDP.js → chunk-2ULWWAQH.js} +2 -2
  19. package/dist/{chunk-HKE7UREW.js → chunk-A62RAIBN.js} +2 -2
  20. package/dist/{chunk-6YXRSHKG.js → chunk-ANZLT74L.js} +2 -2
  21. package/dist/{chunk-N5UHWRGK.js → chunk-COEZR6F5.js} +2 -2
  22. package/dist/{chunk-3LLLRQPV.js → chunk-CQ4PGFMC.js} +3 -3
  23. package/dist/{chunk-AUGA5JZD.js → chunk-E6GOVHHJ.js} +3 -3
  24. package/dist/{chunk-WHJ4WTHH.js → chunk-EZWQZYLK.js} +2 -2
  25. package/dist/{chunk-4X3LO5OF.js → chunk-FEB7W5JG.js} +103 -15
  26. package/dist/chunk-FEB7W5JG.js.map +1 -0
  27. package/dist/{chunk-GPHHYOQC.js → chunk-FSEQXHEZ.js} +2 -2
  28. package/dist/{chunk-F5J5OVWF.js → chunk-HZ5DRA4Q.js} +13 -13
  29. package/dist/{chunk-2IYYMOQZ.js → chunk-IZ5E6ZTK.js} +4 -4
  30. package/dist/{chunk-FLV6YAS3.js → chunk-JYVD2XZ4.js} +13 -13
  31. package/dist/{chunk-TAEO72ZC.js → chunk-LIWU4QI6.js} +4 -4
  32. package/dist/{chunk-4LM6W5SX.js → chunk-M4DQWUKX.js} +2 -2
  33. package/dist/{chunk-WDLJCSZ4.js → chunk-MRX6S22R.js} +2 -2
  34. package/dist/{chunk-TFZKNDVY.js → chunk-N55RJT4N.js} +2 -2
  35. package/dist/{chunk-AEPUGOPY.js → chunk-PJIHCOGV.js} +2 -2
  36. package/dist/{chunk-UZW4UWIK.js → chunk-RIC5U67B.js} +2 -2
  37. package/dist/{chunk-BQEZPX6Z.js → chunk-SIDSEXUG.js} +2 -2
  38. package/dist/{chunk-4FXCAY7J.js → chunk-SJQ4HY3E.js} +2 -2
  39. package/dist/{chunk-5JTY7OXQ.js → chunk-UDDSC6PO.js} +5 -5
  40. package/dist/{chunk-5MNL24XY.js → chunk-XFG3PVZE.js} +27 -7
  41. package/dist/chunk-XFG3PVZE.js.map +1 -0
  42. package/dist/{chunk-TLIVN7IU.js → chunk-XL5RSHZP.js} +2 -2
  43. package/dist/{chunk-VXNJGBUK.js → chunk-YY2HGKWR.js} +2 -2
  44. package/dist/{cli-DBDgdvh9.d.ts → cli-DUMkkdLl.d.ts} +1 -1
  45. package/dist/cli.d.ts +2 -2
  46. package/dist/cli.js +20 -20
  47. package/dist/compounding/engine.js +3 -3
  48. package/dist/connectors/codex-materialize-runner.js +3 -3
  49. package/dist/connectors/index.js +3 -3
  50. package/dist/entity-retrieval.js +3 -3
  51. package/dist/extraction-faithfulness.d.ts +23 -3
  52. package/dist/extraction-faithfulness.js +1 -1
  53. package/dist/index.d.ts +2 -2
  54. package/dist/index.js +26 -26
  55. package/dist/maintenance/memory-governance.js +3 -3
  56. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
  57. package/dist/maintenance/rebuild-memory-projection.js +4 -4
  58. package/dist/mcp-memory-inspector-app.d.ts +1 -1
  59. package/dist/namespaces/migrate.js +4 -4
  60. package/dist/namespaces/storage.js +3 -3
  61. package/dist/operator-toolkit.js +8 -8
  62. package/dist/orchestration/maintenance.js +5 -5
  63. package/dist/orchestrator.js +14 -14
  64. package/dist/schemas.d.ts +22 -22
  65. package/dist/semantic-consolidation.js +4 -4
  66. package/dist/semantic-rule-promotion.js +3 -3
  67. package/dist/semantic-rule-verifier.js +3 -3
  68. package/dist/storage.js +2 -2
  69. package/dist/transfer/types.d.ts +12 -12
  70. package/dist/verified-recall.js +3 -3
  71. package/package.json +2 -2
  72. package/src/access-service.ts +17 -0
  73. package/src/coding/session-delta-surfaces.test.ts +185 -1
  74. package/src/coding/session-delta-surfaces.ts +30 -1
  75. package/src/coding/session-delta.test.ts +46 -0
  76. package/src/coding/session-delta.ts +18 -1
  77. package/src/extraction-faithfulness.test.ts +149 -5
  78. package/src/extraction-faithfulness.ts +195 -21
  79. package/dist/chunk-4X3LO5OF.js.map +0 -1
  80. package/dist/chunk-5MNL24XY.js.map +0 -1
  81. /package/dist/{chunk-75KYTVFK.js.map → chunk-2K64VH66.js.map} +0 -0
  82. /package/dist/{chunk-62Q2QA4Z.js.map → chunk-2KJG6ZZK.js.map} +0 -0
  83. /package/dist/{chunk-AYMVRRDP.js.map → chunk-2ULWWAQH.js.map} +0 -0
  84. /package/dist/{chunk-HKE7UREW.js.map → chunk-A62RAIBN.js.map} +0 -0
  85. /package/dist/{chunk-6YXRSHKG.js.map → chunk-ANZLT74L.js.map} +0 -0
  86. /package/dist/{chunk-N5UHWRGK.js.map → chunk-COEZR6F5.js.map} +0 -0
  87. /package/dist/{chunk-3LLLRQPV.js.map → chunk-CQ4PGFMC.js.map} +0 -0
  88. /package/dist/{chunk-AUGA5JZD.js.map → chunk-E6GOVHHJ.js.map} +0 -0
  89. /package/dist/{chunk-WHJ4WTHH.js.map → chunk-EZWQZYLK.js.map} +0 -0
  90. /package/dist/{chunk-GPHHYOQC.js.map → chunk-FSEQXHEZ.js.map} +0 -0
  91. /package/dist/{chunk-F5J5OVWF.js.map → chunk-HZ5DRA4Q.js.map} +0 -0
  92. /package/dist/{chunk-2IYYMOQZ.js.map → chunk-IZ5E6ZTK.js.map} +0 -0
  93. /package/dist/{chunk-FLV6YAS3.js.map → chunk-JYVD2XZ4.js.map} +0 -0
  94. /package/dist/{chunk-TAEO72ZC.js.map → chunk-LIWU4QI6.js.map} +0 -0
  95. /package/dist/{chunk-4LM6W5SX.js.map → chunk-M4DQWUKX.js.map} +0 -0
  96. /package/dist/{chunk-WDLJCSZ4.js.map → chunk-MRX6S22R.js.map} +0 -0
  97. /package/dist/{chunk-TFZKNDVY.js.map → chunk-N55RJT4N.js.map} +0 -0
  98. /package/dist/{chunk-AEPUGOPY.js.map → chunk-PJIHCOGV.js.map} +0 -0
  99. /package/dist/{chunk-UZW4UWIK.js.map → chunk-RIC5U67B.js.map} +0 -0
  100. /package/dist/{chunk-BQEZPX6Z.js.map → chunk-SIDSEXUG.js.map} +0 -0
  101. /package/dist/{chunk-4FXCAY7J.js.map → chunk-SJQ4HY3E.js.map} +0 -0
  102. /package/dist/{chunk-5JTY7OXQ.js.map → chunk-UDDSC6PO.js.map} +0 -0
  103. /package/dist/{chunk-TLIVN7IU.js.map → chunk-XL5RSHZP.js.map} +0 -0
  104. /package/dist/{chunk-VXNJGBUK.js.map → chunk-YY2HGKWR.js.map} +0 -0
@@ -4665,9 +4665,26 @@ export class EngramAccessService {
4665
4665
  // non-default namespaces route to memoryDir/namespaces/<token>, so
4666
4666
  // session-delta markers must read/write from the routed tree, mirroring
4667
4667
  // the decision/architecture siblings (cursor review).
4668
+ // Issue #1630 fix 2: gate the marker write on the WRITE-path resolver
4669
+ // — it mirrors memory_store's exact authorization (base ACL for coding
4670
+ // overlays, selectedLayer.writable for scope-profiles), not the raw
4671
+ // canWriteNamespace which misses overlay/profile grants (cursor+codex
4672
+ // review). A throw = read-only caller; delta computed but no advance.
4673
+ let canAdvanceState = false;
4674
+ try {
4675
+ await this.resolveCodingScopedWriteNamespace({
4676
+ namespace: req.namespace,
4677
+ sessionKey: req.sessionKey,
4678
+ authenticatedPrincipal,
4679
+ });
4680
+ canAdvanceState = true;
4681
+ } catch {
4682
+ canAdvanceState = false;
4683
+ }
4668
4684
  return {
4669
4685
  memoryDir: storage.dir,
4670
4686
  namespace: ns,
4687
+ canAdvanceState,
4671
4688
  } satisfies DeltaSurfaceStorage;
4672
4689
  },
4673
4690
  gitInvoker: (cwd, args) => {
@@ -81,9 +81,10 @@ function makeContext(overrides: Partial<DeltaSurfaceContext> = {}): DeltaSurface
81
81
  // shimming its filesystem. The handler imports readLastSeenState / writeLastSeenState
82
82
  // from ./session-delta.js, which hit the real fs. We point memoryDir at a
83
83
  // temp dir per-test.
84
- import { mkdtemp, rm } from "node:fs/promises";
84
+ import { mkdtemp, rm, readFile } from "node:fs/promises";
85
85
  import { tmpdir } from "node:os";
86
86
  import path from "node:path";
87
+ import { sessionDeltaStatePath } from "./session-delta.js";
87
88
 
88
89
  async function makeTempStorage(): Promise<{ storage: DeltaSurfaceStorage; cleanup: () => Promise<void> }> {
89
90
  const dir = await mkdtemp(path.join(tmpdir(), "delta-surface-"));
@@ -383,3 +384,186 @@ test("handler/get: state file is created after a successful first_run", async ()
383
384
  await cleanup();
384
385
  }
385
386
  });
387
+
388
+ // ──────────────────────────────────────────────────────────────────────────
389
+ // Issue #1630 fix 1 — uncapped totals surface in the changed response
390
+ // ──────────────────────────────────────────────────────────────────────────
391
+
392
+ test("handler/get: changed response surfaces uncapped totals alongside capped slices (issue #1630 fix 1)", async () => {
393
+ const { storage, cleanup } = await makeTempStorage();
394
+ try {
395
+ // Seed: first run on headA.
396
+ const invoker1 = makeInvoker([
397
+ { args: ["rev-parse", "HEAD"], stdout: "headA\n" },
398
+ ]);
399
+ await handleCodingDelta(
400
+ { subcommand: "get", sessionKey: "s1" },
401
+ makeContext({ resolveStorage: async () => storage, gitInvoker: invoker1 }),
402
+ );
403
+
404
+ // Now head moved to headB with 25 commits / 60 files — both exceed the
405
+ // caps (MAX_DELTA_COMMITS=20, MAX_DELTA_FILES=50).
406
+ const sep = "\x1f";
407
+ const commitBlocks = Array.from({ length: 25 }, (_, i) => [
408
+ `sha${i}${sep}fix ${i}`,
409
+ "",
410
+ `src/file${i}.ts`,
411
+ "",
412
+ ].join("\n"));
413
+ // Add 35 more distinct files so the total crosses the file cap.
414
+ const extraFiles = Array.from({ length: 35 }, (_, i) => `extra${i}.ts`);
415
+ const lastBlock = [
416
+ `shaExtra${sep}extra`,
417
+ "",
418
+ ...extraFiles,
419
+ "",
420
+ ].join("\n");
421
+ const log = [...commitBlocks, lastBlock].join("\n");
422
+ const invoker2 = makeInvoker([
423
+ { args: ["rev-parse", "HEAD"], stdout: "headB\n" },
424
+ { args: ["log", "--reverse", "--pretty=format:%H\x1f%s", "--name-only", "headA..headB"], stdout: log },
425
+ ]);
426
+ const result = await handleCodingDelta(
427
+ { subcommand: "get", sessionKey: "s1" },
428
+ makeContext({ resolveStorage: async () => storage, gitInvoker: invoker2 }),
429
+ );
430
+ if (!("ok" in result) || !result.ok || result.kind !== "changed") {
431
+ assert.fail(`expected changed, got ${JSON.stringify(result)}`);
432
+ return;
433
+ }
434
+ // Capped display slices.
435
+ assert.equal(result.delta.commits.length, 20, "commits slice must be capped to MAX_DELTA_COMMITS");
436
+ assert.equal(result.delta.touchedFiles.length, 50, "touchedFiles slice must be capped to MAX_DELTA_FILES");
437
+ // Uncapped totals — the TRUE delta size, NOT the capped length.
438
+ assert.equal(result.delta.totalCommits, 26, "totalCommits must report the uncapped commit count");
439
+ assert.ok(
440
+ result.delta.totalTouchedFiles > 50,
441
+ `totalTouchedFiles must report the uncapped file count (got ${result.delta.totalTouchedFiles})`,
442
+ );
443
+ } finally {
444
+ await cleanup();
445
+ }
446
+ });
447
+
448
+ // ──────────────────────────────────────────────────────────────────────────
449
+ // Issue #1630 fix 2 — read-only callers do NOT advance the state marker
450
+ // ──────────────────────────────────────────────────────────────────────────
451
+
452
+ test("handler/get: read-only caller (canAdvanceState=false) does NOT advance the state marker (issue #1630 fix 2)", async () => {
453
+ const { storage, cleanup } = await makeTempStorage();
454
+ try {
455
+ // Seed headA with a WRITE-capable caller (default canAdvanceState=true).
456
+ const invoker1 = makeInvoker([
457
+ { args: ["rev-parse", "HEAD"], stdout: "headA\n" },
458
+ ]);
459
+ await handleCodingDelta(
460
+ { subcommand: "get", sessionKey: "s1" },
461
+ makeContext({
462
+ resolveStorage: async () => ({ ...storage, canAdvanceState: true }),
463
+ gitInvoker: invoker1,
464
+ }),
465
+ );
466
+ // Confirm the marker landed at headA.
467
+ const statePath = sessionDeltaStatePath(storage.memoryDir, storage.namespace);
468
+ const seeded = JSON.parse(await readFile(statePath, "utf8"));
469
+ assert.equal(seeded.head, "headA");
470
+
471
+ // A READ-ONLY caller (canAdvanceState=false) on headB. The delta is
472
+ // computed and returned, but the marker MUST NOT advance.
473
+ const sep = "\x1f";
474
+ const log = [
475
+ `shaRO${sep}read only commit`,
476
+ "",
477
+ "src/ro.ts",
478
+ "",
479
+ ].join("\n");
480
+ const invoker2 = makeInvoker([
481
+ { args: ["rev-parse", "HEAD"], stdout: "headB\n" },
482
+ { args: ["log", "--reverse", "--pretty=format:%H\x1f%s", "--name-only", "headA..headB"], stdout: log },
483
+ ]);
484
+ const result = await handleCodingDelta(
485
+ { subcommand: "get", sessionKey: "s1" },
486
+ makeContext({
487
+ resolveStorage: async () => ({ ...storage, canAdvanceState: false }),
488
+ gitInvoker: invoker2,
489
+ }),
490
+ );
491
+ // The delta is still computed and returned — read-only callers see the data.
492
+ if (!("ok" in result) || !result.ok || result.kind !== "changed") {
493
+ assert.fail(`expected changed, got ${JSON.stringify(result)}`);
494
+ return;
495
+ }
496
+ assert.equal(result.delta.commits.length, 1);
497
+ // But the persisted marker stayed at headA — read-only did not advance it.
498
+ const persisted = JSON.parse(await readFile(statePath, "utf8"));
499
+ assert.equal(persisted.head, "headA", "read-only caller must NOT advance the state marker");
500
+ } finally {
501
+ await cleanup();
502
+ }
503
+ });
504
+
505
+ test("handler/get: write-capable caller (canAdvanceState=true) DOES advance the state marker (issue #1630 fix 2)", async () => {
506
+ const { storage, cleanup } = await makeTempStorage();
507
+ try {
508
+ // Seed headA.
509
+ const invoker1 = makeInvoker([
510
+ { args: ["rev-parse", "HEAD"], stdout: "headA\n" },
511
+ ]);
512
+ await handleCodingDelta(
513
+ { subcommand: "get", sessionKey: "s1" },
514
+ makeContext({
515
+ resolveStorage: async () => ({ ...storage, canAdvanceState: true }),
516
+ gitInvoker: invoker1,
517
+ }),
518
+ );
519
+
520
+ // A WRITE-capable caller on headB — the marker MUST advance.
521
+ const sep = "\x1f";
522
+ const log = [
523
+ `shaW${sep}write commit`,
524
+ "",
525
+ "src/w.ts",
526
+ "",
527
+ ].join("\n");
528
+ const invoker2 = makeInvoker([
529
+ { args: ["rev-parse", "HEAD"], stdout: "headB\n" },
530
+ { args: ["log", "--reverse", "--pretty=format:%H\x1f%s", "--name-only", "headA..headB"], stdout: log },
531
+ ]);
532
+ await handleCodingDelta(
533
+ { subcommand: "get", sessionKey: "s1" },
534
+ makeContext({
535
+ resolveStorage: async () => ({ ...storage, canAdvanceState: true }),
536
+ gitInvoker: invoker2,
537
+ }),
538
+ );
539
+ const statePath = sessionDeltaStatePath(storage.memoryDir, storage.namespace);
540
+ const persisted = JSON.parse(await readFile(statePath, "utf8"));
541
+ assert.equal(persisted.head, "headB", "write-capable caller must advance the state marker");
542
+ } finally {
543
+ await cleanup();
544
+ }
545
+ });
546
+
547
+ test("handler/get: canAdvanceState defaults to true when omitted (back-compat) (issue #1630 fix 2)", async () => {
548
+ // Legacy callers that don't set canAdvanceState keep pre-fix behavior:
549
+ // the marker advances. This protects the existing surface contract.
550
+ const { storage, cleanup } = await makeTempStorage();
551
+ try {
552
+ const invoker = makeInvoker([
553
+ { args: ["rev-parse", "HEAD"], stdout: "headDefault\n" },
554
+ ]);
555
+ await handleCodingDelta(
556
+ { subcommand: "get", sessionKey: "s1" },
557
+ makeContext({
558
+ // canAdvanceState intentionally omitted.
559
+ resolveStorage: async () => storage,
560
+ gitInvoker: invoker,
561
+ }),
562
+ );
563
+ const statePath = sessionDeltaStatePath(storage.memoryDir, storage.namespace);
564
+ const persisted = JSON.parse(await readFile(statePath, "utf8"));
565
+ assert.equal(persisted.head, "headDefault", "omitted canAdvanceState defaults to true (back-compat)");
566
+ } finally {
567
+ await cleanup();
568
+ }
569
+ });
@@ -117,6 +117,16 @@ export type DeltaSurfaceResponse =
117
117
  delta: {
118
118
  commits: ReadonlyArray<{ sha: string; subject: string }>;
119
119
  touchedFiles: readonly string[];
120
+ /**
121
+ * Uncapped total commit count (the {@link commits} slice is capped
122
+ * for transport). Issue #1630 fix 1.
123
+ */
124
+ totalCommits: number;
125
+ /**
126
+ * Uncapped total touched-file count (the {@link touchedFiles} slice
127
+ * is capped for transport). Issue #1630 fix 1.
128
+ */
129
+ totalTouchedFiles: number;
120
130
  summaryLine: string;
121
131
  };
122
132
  nextState: LastSeenState;
@@ -161,6 +171,15 @@ export interface DeltaSurfaceStorage {
161
171
  readonly memoryDir: string;
162
172
  /** The resolved coding-scoped namespace — basis for the state filename. */
163
173
  readonly namespace: string;
174
+ /**
175
+ * Whether the calling principal may WRITE the namespace — i.e. advance the
176
+ * last-seen-head marker. Read-only callers (e.g. a principal with read-but-
177
+ * not-write on the shared namespace) receive the computed delta but the
178
+ * state file is NOT advanced, so they cannot move another principal's
179
+ * baseline (issue #1630 fix 2). Defaults to `true` when omitted so existing
180
+ * callers (and the surface contract tests) keep their pre-fix behavior.
181
+ */
182
+ readonly canAdvanceState?: boolean;
164
183
  }
165
184
 
166
185
  /**
@@ -265,13 +284,21 @@ async function deltaGet(
265
284
  // 5. Persist the new state (rule 25 + rule 54). Failures here are logged
266
285
  // but do NOT fail the operation — the delta was computed; the next
267
286
  // session may re-derive it. A write failure surfaces in doctor/xray.
287
+ //
288
+ // Issue #1630 fix 2: the marker write is gated on a write-capable
289
+ // principal. A read-only caller (e.g. read-but-not-write on the shared
290
+ // namespace) receives the computed delta but does NOT advance the state
291
+ // marker, so it cannot move another principal's baseline. The default
292
+ // is `true` so legacy callers and the surface contract tests keep their
293
+ // pre-fix behavior.
294
+ const canAdvanceState = storage.canAdvanceState !== false;
268
295
  let nextState: LastSeenState | null = null;
269
296
  if (result.ok) {
270
297
  nextState = result.nextState;
271
298
  } else if (result.code === "unreachable_head") {
272
299
  nextState = result.nextState;
273
300
  }
274
- if (nextState) {
301
+ if (nextState && canAdvanceState) {
275
302
  try {
276
303
  await writeLastSeenState(statePath, nextState);
277
304
  } catch (err) {
@@ -312,6 +339,8 @@ async function deltaGet(
312
339
  delta: {
313
340
  commits: result.delta.commits,
314
341
  touchedFiles: result.delta.touchedFiles,
342
+ totalCommits: result.delta.totalCommits,
343
+ totalTouchedFiles: result.delta.totalTouchedFiles,
315
344
  summaryLine: result.delta.summaryLine,
316
345
  },
317
346
  nextState: result.nextState,
@@ -171,6 +171,52 @@ test("computeSessionDelta caps a large delta to MAX constants", () => {
171
171
  assert.equal(result.delta.touchedFiles.length, MAX_DELTA_FILES);
172
172
  });
173
173
 
174
+ test("computeSessionDelta reports uncapped totals alongside capped slices (issue #1630 fix 1)", () => {
175
+ // A repo exceeding both caps: 100 commits / 200 files. The slices are
176
+ // capped for transport, but totalCommits/totalTouchedFiles must report
177
+ // the TRUE delta size so summaries never under-report.
178
+ const commitCount = 100;
179
+ const fileCount = 200;
180
+ const commits: GitCommit[] = Array.from({ length: commitCount }, (_, i) => ({
181
+ sha: `c${i}`,
182
+ subject: `s ${i}`,
183
+ }));
184
+ const files = Array.from({ length: fileCount }, (_, i) => `f${i}.ts`);
185
+ const result = computeSessionDelta(PRIOR, slice("head", commits, files));
186
+ if (!result.ok || result.kind !== "changed") {
187
+ assert.fail(`expected changed, got ${JSON.stringify(result)}`);
188
+ return;
189
+ }
190
+ // Capped display lists — transport-sized.
191
+ assert.equal(result.delta.commits.length, MAX_DELTA_COMMITS);
192
+ assert.equal(result.delta.touchedFiles.length, MAX_DELTA_FILES);
193
+ // Uncapped totals — the true delta size, NOT the capped slice length.
194
+ assert.equal(result.delta.totalCommits, commitCount);
195
+ assert.equal(result.delta.totalTouchedFiles, fileCount);
196
+ // The summary line must report the UNCAPPED totals, not the capped slice
197
+ // length — otherwise the briefing under-reports the delta size (codex review).
198
+ assert.match(result.delta.summaryLine, /100 commits, 200 files touched/);
199
+ assert.ok(!result.delta.summaryLine.match(/^.*20 commits, 50 files/), "summary must NOT use capped counts");
200
+ });
201
+
202
+ test("computeSessionDelta totals equal slice lengths when under the cap (issue #1630 fix 1)", () => {
203
+ // Below the caps, totals === slice lengths (no information lost).
204
+ const commits: GitCommit[] = [
205
+ { sha: "u1", subject: "under cap 1" },
206
+ { sha: "u2", subject: "under cap 2" },
207
+ ];
208
+ const files = ["a.ts", "b.ts", "c.ts"];
209
+ const result = computeSessionDelta(PRIOR, slice("head", commits, files));
210
+ if (!result.ok || result.kind !== "changed") {
211
+ assert.fail(`expected changed, got ${JSON.stringify(result)}`);
212
+ return;
213
+ }
214
+ assert.equal(result.delta.totalCommits, 2);
215
+ assert.equal(result.delta.totalTouchedFiles, 3);
216
+ assert.equal(result.delta.totalCommits, result.delta.commits.length);
217
+ assert.equal(result.delta.totalTouchedFiles, result.delta.touchedFiles.length);
218
+ });
219
+
174
220
  // ──────────────────────────────────────────────────────────────────────────
175
221
  // State persistence — rule 25 (write after compute) + rule 54 (temp+rename)
176
222
  // ──────────────────────────────────────────────────────────────────────────
@@ -72,6 +72,18 @@ export interface SessionDelta {
72
72
  commits: GitCommit[];
73
73
  /** Touched files since last seen, capped to {@link MAX_DELTA_FILES}. */
74
74
  touchedFiles: string[];
75
+ /**
76
+ * Uncapped total commit count. The {@link commits} slice is capped for
77
+ * transport; this total reports the true delta size so summaries and
78
+ * metrics never under-report on large repos (issue #1630 fix 1).
79
+ */
80
+ totalCommits: number;
81
+ /**
82
+ * Uncapped total touched-file count. The {@link touchedFiles} slice is
83
+ * capped for transport; this total reports the true delta size (issue
84
+ * #1630 fix 1).
85
+ */
86
+ totalTouchedFiles: number;
75
87
  /** A single human-readable summary line for briefing injection. */
76
88
  summaryLine: string;
77
89
  }
@@ -159,7 +171,12 @@ export function computeSessionDelta(
159
171
  delta: {
160
172
  commits,
161
173
  touchedFiles,
162
- summaryLine: buildSummaryLine(commits.length, touchedFiles.length, lastSeen.at),
174
+ // Uncapped totals — the slices above are capped for transport, but
175
+ // the summary/metrics must report the true delta size so callers
176
+ // never under-report on large repos (issue #1630 fix 1).
177
+ totalCommits: current.commits.length,
178
+ totalTouchedFiles: current.touchedFiles.length,
179
+ summaryLine: buildSummaryLine(current.commits.length, current.touchedFiles.length, lastSeen.at),
163
180
  },
164
181
  nextState,
165
182
  };
@@ -794,12 +794,13 @@ test("applyFaithfulnessVerdict: per-fact backend failure (ok:false) → unchecke
794
794
  assert.equal(r.enforceStatus, undefined);
795
795
  });
796
796
 
797
- test("locateFactQuote: finds the best-overlap sentence", () => {
797
+ test("locateFactQuote: finds the best-overlap sentence (returns LocatedQuote, issue #1633)", () => {
798
798
  const src = "I drove to Berlin yesterday. My favorite editor is Vim.";
799
- assert.equal(
800
- locateFactQuote("The user likes Vim.", src),
801
- "My favorite editor is Vim.",
802
- );
799
+ const located = locateFactQuote("The user likes Vim.", src);
800
+ assert.ok(located, "expected a located quote");
801
+ assert.equal(located!.quote, "My favorite editor is Vim.");
802
+ // offset must point at the matched candidate's actual position in sourceText.
803
+ assert.equal(located!.offset, src.indexOf("My favorite editor is Vim."));
803
804
  });
804
805
 
805
806
  test("locateFactQuote: returns undefined below the overlap threshold", () => {
@@ -812,6 +813,120 @@ test("locateFactQuote: empty inputs → undefined", () => {
812
813
  assert.equal(locateFactQuote("fact", ""), undefined);
813
814
  });
814
815
 
816
+ test("locateFactQuote: centers the bounded window on matched terms for >maxQuoteChars candidates (issue #1633, codex :747)", () => {
817
+ // The supporting terms ("PostgreSQL", "migration") fall well past the
818
+ // ~maxQuoteChars prefix. Returning the prefix would drop the evidence and
819
+ // route an actually-entailed fact to pending_review in enforce mode.
820
+ const filler = "Lorem ipsum dolor sit amet ".repeat(28); // ~728-char preamble
821
+ const sourceText = filler + "The team completed the PostgreSQL migration last quarter.";
822
+ const located = locateFactQuote(
823
+ "The team finished the PostgreSQL migration.",
824
+ sourceText,
825
+ 200,
826
+ );
827
+ assert.ok(located, "expected a located quote");
828
+ assert.ok(
829
+ located!.quote.length <= 200,
830
+ `bounded quote must respect maxQuoteChars (got ${located!.quote.length})`,
831
+ );
832
+ // The matched evidence must survive truncation — the whole point of centering.
833
+ assert.ok(located!.quote.includes("PostgreSQL"), "bounded quote keeps 'PostgreSQL'");
834
+ assert.ok(located!.quote.includes("migration"), "bounded quote keeps 'migration'");
835
+ // offset must locate the returned (possibly bounded) quote within sourceText.
836
+ assert.equal(
837
+ sourceText.slice(located!.offset, located!.offset + located!.quote.length),
838
+ located!.quote,
839
+ "offset must point at the returned quote within sourceText",
840
+ );
841
+ });
842
+
843
+ test("locateFactQuote: tracks the matched occurrence offset for repeated anaphoric lines (issue #1633, codex :710, :710-thread-S)", () => {
844
+ // Two entities with the EXACT SAME anaphoric sentence afterward — the
845
+ // fact is about Zeta, which appears SECOND. Both "It launched in March."
846
+ // candidates score identically on own-overlap, so the locator must use the
847
+ // preceding-neighbor tiebreak to pick the occurrence whose neighbor ("Zeta")
848
+ // names the fact's entity. The offset must then point at the Zeta clause.
849
+ const sourceText =
850
+ "We started the Acme project in January. It launched in March. " +
851
+ "Then we began the Zeta initiative in February. It launched in March.";
852
+ const located = locateFactQuote("The Zeta initiative launched in March.", sourceText);
853
+ assert.ok(located, "expected a located quote");
854
+ // The matched quote is the (identical) anaphoric line; offset must point at
855
+ // the SECOND occurrence (the one after the Zeta clause), not the first.
856
+ const firstOccurrence = sourceText.indexOf("It launched in March.");
857
+ const secondOccurrence = sourceText.indexOf("It launched in March.", firstOccurrence + 1);
858
+ assert.ok(secondOccurrence > firstOccurrence, "test fixture must contain two occurrences");
859
+ assert.equal(
860
+ located!.offset,
861
+ secondOccurrence,
862
+ "offset must point at the second (Zeta) occurrence, not the first (Acme)",
863
+ );
864
+ assert.equal(
865
+ sourceText.slice(located!.offset, located!.offset + located!.quote.length),
866
+ located!.quote,
867
+ "offset must point at the start of the matched quote",
868
+ );
869
+ });
870
+
871
+ test("locateFactQuote: bounded window keeps the densest evidence cluster when matches span wider than maxQuoteChars (codex :747-thread-O)", () => {
872
+ // Fact tokens appear at BOTH ENDS of a long candidate with filler between.
873
+ // A naive midpoint-centered window would land in the filler and contain no
874
+ // evidence. The densest-cluster window must capture actual matched terms.
875
+ const head = "PostgreSQL migration completed. "; // matches at the very start
876
+ const filler = "and ".repeat(220); // ~880 chars of filler (no fact tokens)
877
+ const tail = " for the user."; // matches at the very end
878
+ const sourceText = head + filler + tail; // one long sentence, no period inside
879
+ // Fact spans tokens from both ends: postgresql, migration (head) + user (tail).
880
+ const located = locateFactQuote(
881
+ "The user completed the PostgreSQL migration.",
882
+ sourceText,
883
+ 120,
884
+ );
885
+ assert.ok(located, "expected a located quote");
886
+ assert.ok(
887
+ located!.quote.length <= 120,
888
+ `bounded quote must respect maxQuoteChars (got ${located!.quote.length})`,
889
+ );
890
+ // The densest cluster is the head (2 matches: postgresql, migration); the
891
+ // window must contain at least one of them. (user appears alone at the tail,
892
+ // so a tail-anchored window would be sparser and is not chosen.)
893
+ assert.ok(
894
+ located!.quote.includes("PostgreSQL") || located!.quote.includes("migration"),
895
+ "bounded window must include matched evidence, not pure filler",
896
+ );
897
+ });
898
+
899
+ test("locateFactQuote: bounded window stays linear with many repeated matched tokens (codex PRRT_kwDORJXyws6Ocih3)", () => {
900
+ // A long unpunctuated candidate with thousands of repeats of a fact token
901
+ // (pasted logs / minified text). The densest-cluster selection must stay
902
+ // O(n) via the two-pointer sliding window; the O(n^2) nested loop would
903
+ // stall extraction on ~5k repeats. This test asserts both correctness (the
904
+ // bounded window contains the densest cluster) and that the call returns
905
+ // promptly — under O(n^2) this fixture (~5k repeats) would take seconds.
906
+ const repeats = 5000;
907
+ const token = "PostgreSQL "; // one fact token per repeat
908
+ const filler = "x ".repeat(50); // leading filler so the cluster is mid-string
909
+ const sourceText = filler + token.repeat(repeats) + "migration done";
910
+ const start = Date.now();
911
+ const located = locateFactQuote("PostgreSQL migration done.", sourceText, 200);
912
+ const elapsed = Date.now() - start;
913
+ assert.ok(located, "expected a located quote");
914
+ assert.ok(
915
+ located!.quote.length <= 200,
916
+ `bounded quote must respect maxQuoteChars (got ${located!.quote.length})`,
917
+ );
918
+ // The densest cluster is the run of PostgreSQL repeats; the window must be
919
+ // anchored inside it (not in the leading filler) and contain evidence.
920
+ assert.ok(
921
+ located!.quote.includes("PostgreSQL"),
922
+ "bounded window must anchor inside the densest cluster",
923
+ );
924
+ assert.ok(
925
+ elapsed < 1000,
926
+ `bounded window must stay linear (took ${elapsed}ms for ${repeats} repeats)`,
927
+ );
928
+ });
929
+
815
930
  test("applyFaithfulnessVerdict: bumps verdict counters at apply time (cursor review)", () => {
816
931
  const counters = createFaithfulnessCounters();
817
932
  const map: Map<number, FaithfulnessResult> = new Map([
@@ -1042,6 +1157,35 @@ test("extractContextWindow: returns undefined when quote is absent from sourceTe
1042
1157
  assert.equal(extractContextWindow("source", "s", 0), undefined);
1043
1158
  });
1044
1159
 
1160
+ test("extractContextWindow: matchedOffset selects the matched occurrence, not the first (issue #1633, codex :710)", () => {
1161
+ // The same anaphoric line appears twice — once about Acme, once about Zeta.
1162
+ // Without matchedOffset, indexOf centers context on Acme (the first hit).
1163
+ // With the locator's offset, the window must center on the matched (Zeta) hit.
1164
+ const sourceText =
1165
+ "Acme context before. It launched in March. Acme context after. " +
1166
+ "Zeta context before. It launched in March. Zeta context after.";
1167
+ const quote = "It launched in March.";
1168
+ const firstOccurrence = sourceText.indexOf(quote);
1169
+ const zetaOccurrence = sourceText.indexOf("Zeta context before");
1170
+ assert.ok(firstOccurrence >= 0 && zetaOccurrence > firstOccurrence);
1171
+ // Budget 50 captures the surrounding entity tag without spanning both
1172
+ // occurrences (which would make them indistinguishable).
1173
+ const ctxDefault = extractContextWindow(sourceText, quote, 50);
1174
+ const ctxMatched = extractContextWindow(sourceText, quote, 50, zetaOccurrence);
1175
+ const ctxFirst = extractContextWindow(sourceText, quote, 50, firstOccurrence);
1176
+ assert.ok(ctxDefault && ctxMatched && ctxFirst, "all windows must resolve");
1177
+ assert.ok(
1178
+ ctxDefault!.includes("Acme") && !ctxDefault!.includes("Zeta"),
1179
+ "default (no offset) centers on the first (Acme) occurrence, not Zeta",
1180
+ );
1181
+ assert.ok(
1182
+ ctxMatched!.includes("Zeta") && !ctxMatched!.includes("Acme"),
1183
+ "matchedOffset centers on the matched (Zeta) occurrence, not Acme",
1184
+ );
1185
+ assert.notEqual(ctxMatched, ctxDefault, "windows must differ when offset disambiguates");
1186
+ assert.equal(ctxFirst, ctxDefault, "explicit first-occurrence offset equals the default");
1187
+ });
1188
+
1045
1189
  test("runFaithfulnessGateBatch: fallback locator injects CONTEXT into the verifier prompt (codex P2 PRRT_kwDORJXyws6OblI1)", async () => {
1046
1190
  // No #1575 sources → the fallback locator locates a quote from sourceText.
1047
1191
  // Previously the input never set `context`, so the surrounding turn text