@sema-agent/core 5.21.0 → 5.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/agents/send-message-tool.js +6 -3
  3. package/dist/agents/subagent.d.ts +6 -0
  4. package/dist/agents/subagent.js +45 -4
  5. package/dist/brain/errors.d.ts +20 -0
  6. package/dist/brain/errors.js +40 -0
  7. package/dist/brain/retry.d.ts +16 -2
  8. package/dist/brain/retry.js +3 -2
  9. package/dist/brain/status-sink.d.ts +9 -2
  10. package/dist/brain/stream-engine.d.ts +22 -0
  11. package/dist/brain/stream-engine.js +41 -10
  12. package/dist/core/ask-class.d.ts +48 -0
  13. package/dist/core/ask-class.js +33 -0
  14. package/dist/core/checkpoint-store.d.ts +103 -10
  15. package/dist/core/checkpoint-store.js +3 -1
  16. package/dist/core/governance-codes.d.ts +38 -0
  17. package/dist/core/governance-codes.js +11 -0
  18. package/dist/core/hooks.d.ts +39 -0
  19. package/dist/core/hooks.js +26 -2
  20. package/dist/core/locked-config.d.ts +7 -1
  21. package/dist/core/locked-config.js +2 -1
  22. package/dist/core/memory-engine/delegation-provenance.d.ts +62 -0
  23. package/dist/core/memory-engine/delegation-provenance.js +26 -0
  24. package/dist/core/memory-engine/engine.d.ts +67 -1
  25. package/dist/core/memory-engine/engine.js +270 -12
  26. package/dist/core/memory-engine/header-hints.d.ts +30 -0
  27. package/dist/core/memory-engine/header-hints.js +41 -0
  28. package/dist/core/memory-engine/index.d.ts +3 -2
  29. package/dist/core/memory-engine/index.js +3 -2
  30. package/dist/core/memory-engine/layout.d.ts +166 -0
  31. package/dist/core/memory-engine/layout.js +399 -0
  32. package/dist/core/memory-engine/tools.d.ts +30 -0
  33. package/dist/core/memory-engine/tools.js +108 -17
  34. package/dist/core/permission-rule-consent.d.ts +25 -9
  35. package/dist/core/permission-rule-consent.js +91 -20
  36. package/dist/core/permission-rule-model.d.ts +9 -1
  37. package/dist/core/permission-rule-model.js +2 -2
  38. package/dist/core/permission-rule-org.d.ts +161 -0
  39. package/dist/core/permission-rule-org.js +211 -0
  40. package/dist/core/permission-rule-store.d.ts +249 -6
  41. package/dist/core/permission-rule-store.js +313 -3
  42. package/dist/core/permission-rule-sync.d.ts +131 -0
  43. package/dist/core/permission-rule-sync.js +314 -0
  44. package/dist/core/runner/prepare-memory.js +35 -8
  45. package/dist/core/runner/prepare-task.d.ts +54 -1
  46. package/dist/core/runner/prepare-task.js +246 -27
  47. package/dist/core/runner/runtask.js +147 -6
  48. package/dist/core/shared-memory/contract.js +19 -4
  49. package/dist/core/shared-memory/normalize.d.ts +3 -1
  50. package/dist/core/shared-memory/tools.js +73 -17
  51. package/dist/core/shared-memory/types.d.ts +27 -1
  52. package/dist/core/store-contracts/permission-rule-sync-contract.d.ts +33 -0
  53. package/dist/core/store-contracts/permission-rule-sync-contract.js +186 -0
  54. package/dist/core/task-notification.d.ts +5 -2
  55. package/dist/core/task-registry-agent.d.ts +1 -1
  56. package/dist/core/task-registry-agent.js +6 -2
  57. package/dist/core/task-registry-shared.d.ts +9 -2
  58. package/dist/core/task-registry.d.ts +9 -3
  59. package/dist/core/task-registry.js +2 -0
  60. package/dist/core/tool-policy.d.ts +120 -2
  61. package/dist/core/tool-policy.js +116 -6
  62. package/dist/core/trace.d.ts +32 -1
  63. package/dist/core/types.d.ts +56 -3
  64. package/dist/index.d.ts +12 -7
  65. package/dist/index.js +10 -5
  66. package/dist/stores/file/checkpoint-store.d.ts +4 -0
  67. package/dist/stores/file/checkpoint-store.js +1 -0
  68. package/dist/stores/file/permission-rule-adopt.d.ts +62 -0
  69. package/dist/stores/file/permission-rule-adopt.js +95 -0
  70. package/dist/stores/file/permission-rule-store.d.ts +80 -2
  71. package/dist/stores/file/permission-rule-store.js +189 -46
  72. package/dist/tools/fs/fs-search-tools.js +0 -1
  73. package/package.json +1 -1
@@ -32,13 +32,13 @@ import { TOOL_SEARCH_NAME } from "./tool-disclosure.js";
32
32
  import { hasVerifiableStructureSignal } from "./grounding-signal.js";
33
33
  import { hasDestroy, isIsolated } from "../remote-env.js";
34
34
  import { hasBackgroundShell, sweepBackgroundShells } from "../background-shell.js";
35
- import { formatHookFeedback } from "../hooks.js";
35
+ import { cloneObserverInput, formatHookFeedback } from "../hooks.js";
36
36
  import { buildHumanInputEvent, projectHumanInput } from "../human-input-projection.js";
37
37
  import { delimitUntrusted, inlineUntrusted, sanitizeUntrustedText } from "../untrusted-text.js";
38
38
  import { reconcileInterruptedSession } from "../session-reconcile.js";
39
39
  import { RunnerSharedToolResultStore } from "../tool-result-store.js";
40
40
  import { formatDiagnosticsBlock } from "../lsp-diagnostics.js";
41
- import { isApprovalSettledBy, refuseOutOfContractDecision, toolPolicyNameSets } from "../tool-policy.js";
41
+ import { checkToolPolicyProjection, constraintChainDigest, constraintChainEntryOf, isApprovalSettledBy, refuseOutOfContractDecision, toolPolicyNameSets } from "../tool-policy.js";
42
42
  import { defaultTaskRegistry } from "../task-registry.js";
43
43
  import { discloseDroppedPending, isDelegatedAgentTerminal, PendingSessionNotifications, renderTaskNotificationXml, SystemInjectionQueue, taskNotificationDedupKey } from "../task-notification.js";
44
44
  import { ToolDetachHub } from "../tool-detach.js";
@@ -2324,6 +2324,7 @@ export class Runner {
2324
2324
  ...(s.retryInMs !== undefined ? { retryInMs: s.retryInMs } : {}),
2325
2325
  ...(s.attempt !== undefined ? { attempt: s.attempt } : {}),
2326
2326
  ...(s.maxRetries !== undefined ? { maxRetries: s.maxRetries } : {}),
2327
+ ...(s.errClass !== undefined ? { errClass: s.errClass } : {}),
2327
2328
  ...ident(),
2328
2329
  });
2329
2330
  };
@@ -2341,7 +2342,16 @@ export class Runner {
2341
2342
  : t.kind === "breaker"
2342
2343
  ? { kind: "breaker.transition", version: 1, taskId: rs.telemetry.taskId, key: t.key, phase: t.phase, failures: t.failures, ts: Date.now() }
2343
2344
  : t.kind === "retry"
2344
- ? { kind: "brain.retry", version: 1, taskId: rs.telemetry.taskId, attempt: t.attempt, phase: t.phase, ts: Date.now() }
2345
+ ? {
2346
+ kind: "brain.retry",
2347
+ version: 1,
2348
+ taskId: rs.telemetry.taskId,
2349
+ attempt: t.attempt,
2350
+ phase: t.phase,
2351
+ ...(t.errClass !== undefined ? { errClass: t.errClass } : {}),
2352
+ ...(t.nextDelayMs !== undefined ? { nextDelayMs: t.nextDelayMs } : {}),
2353
+ ts: Date.now(),
2354
+ }
2345
2355
  : { kind: "vision.placeholder", version: 1, taskId: rs.telemetry.taskId, count: t.count, ts: Date.now() });
2346
2356
  };
2347
2357
  const withBrainSinks = (fn) => runWithStatusSink(statusEmit, () => runWithBrainTelemetry(telemetryEmit, fn));
@@ -3719,6 +3729,128 @@ export class Runner {
3719
3729
  "re-supply would run the resumed leg under a different ancestor chain than it suspended with; " +
3720
3730
  "rejected pre-CAS (the checkpoint stays pending) — re-resume with the full original chain");
3721
3731
  }
3732
+ const expectedDigest = cp.state.inheritedGate.constraintDigest;
3733
+ if (expectedDigest !== undefined) {
3734
+ const persistedChain = cp.state.inheritedGate.constraintChain;
3735
+ if (persistedChain === undefined || constraintChainDigest(persistedChain) !== expectedDigest) {
3736
+ throw new CheckpointError("resume.parent_constraint_mismatch", "the checkpoint's OWN recorded constraint chain does not match its recorded digest " +
3737
+ "(corrupt / inconsistently-minted row) — the frozen deny data the resume would execute is not the data the " +
3738
+ "digest authenticates; rejected pre-CAS (the checkpoint stays pending)");
3739
+ }
3740
+ const suppliedDigest = constraintChainDigest((internals?.inheritedGate?.parentConstraints ?? []).map((pc) => constraintChainEntryOf(pc.policy, { ...(pc.autoMode !== undefined ? { autoModeArmed: true } : {}), ...(pc.durableMandate === true ? { durableMandate: true } : {}), ...(pc.contentMandate === true ? { contentMandate: true } : {}) })));
3741
+ if (suppliedDigest !== expectedDigest) {
3742
+ throw new CheckpointError("resume.parent_constraint_mismatch", "the re-supplied parent-constraint chain's projection content does not match what the checkpoint recorded " +
3743
+ `(recorded ${expectedDigest}, re-supplied ${suppliedDigest}) — a same-shape chain with different frozen ` +
3744
+ "deny data would run the resumed leg under a different ancestor gate than it suspended with; " +
3745
+ "rejected pre-CAS (the checkpoint stays pending) — re-resume with the original chain's policies");
3746
+ }
3747
+ }
3748
+ }
3749
+ const resumeInternals = internals?.inheritedGate?.parentConstraints !== undefined
3750
+ ? {
3751
+ ...internals,
3752
+ inheritedGate: {
3753
+ ...internals.inheritedGate,
3754
+ parentConstraints: Object.freeze(internals.inheritedGate.parentConstraints.map((e) => ({ ...e }))),
3755
+ },
3756
+ }
3757
+ : internals;
3758
+ if (this.deps.lockedConfig?.keys?.includes("toolPolicy") === true && taskConfig.basePolicyForResumeEdit !== undefined) {
3759
+ throw new CheckpointError("checkpoint.invalid_outcome", "TaskSpec.basePolicyForResumeEdit is administratively locked by this deployment (locked key \"toolPolicy\") — a task-supplied " +
3760
+ "resume-edit policy is refused pre-CAS (the checkpoint stays pending); remove the field or change the deployment's lock configuration");
3761
+ }
3762
+ if (plainPolicyOutcome !== undefined &&
3763
+ plainPolicyOutcome.decision === "allow" &&
3764
+ plainPolicyOutcome.updatedInput !== undefined &&
3765
+ cp.pendingAction.kind === "tool_approval" &&
3766
+ cp.pendingAction.toolName !== ASK_USER_QUESTION_TOOL_NAME) {
3767
+ const editedArgs = plainPolicyOutcome.updatedInput;
3768
+ const editBudget = {
3769
+ resourceRemainingMicroUsd: remainingBudgetMicroUsd(cp.resourceLedger),
3770
+ resourceSpentMicroUsd: cp.resourceLedger?.spentMicroUsd ?? 0,
3771
+ suspendCount: cp.suspendCount ?? 0,
3772
+ };
3773
+ const editedReq = {
3774
+ toolName: cp.pendingAction.toolName,
3775
+ args: editedArgs,
3776
+ toolCallId: cp.pendingAction.toolCallId,
3777
+ budget: editBudget,
3778
+ ...(cp.state.handsCwd !== undefined ? { cwd: cp.state.handsCwd } : {}),
3779
+ };
3780
+ const editSignal = taskConfig.signal;
3781
+ const raceEditAbort = async (p) => {
3782
+ if (editSignal === undefined)
3783
+ return p;
3784
+ if (editSignal.aborted) {
3785
+ throw new CheckpointError("checkpoint.resume_aborted", "the resume was aborted while re-adjudicating the approver's edit — the checkpoint stays pending and is resumable with a live signal");
3786
+ }
3787
+ return new Promise((resolve, reject) => {
3788
+ const onAbort = () => reject(new CheckpointError("checkpoint.resume_aborted", "the resume was aborted while re-adjudicating the approver's edit — the checkpoint stays pending and is resumable with a live signal"));
3789
+ editSignal.addEventListener("abort", onAbort, { once: true });
3790
+ p.then((v) => { editSignal.removeEventListener("abort", onAbort); resolve(v); }, (e) => { editSignal.removeEventListener("abort", onAbort); reject(e); });
3791
+ });
3792
+ };
3793
+ const ig = cp.state.inheritedGate;
3794
+ if (ig?.requiresParentConstraint === true) {
3795
+ const chain = ig.constraintChain;
3796
+ const unprojectable = chain === undefined
3797
+ ? "the checkpoint predates the frozen projection chain (no constraintChain recorded)"
3798
+ : chain.some((e) => "opaque" in e)
3799
+ ? "the inherited chain carries an OPAQUE layer (no projection exported)"
3800
+ : chain.some((e) => !("opaque" in e) && e.requiresLiveRemainder)
3801
+ ? "an inherited chain layer declares a live remainder beyond its projection"
3802
+ : undefined;
3803
+ if (unprojectable !== undefined) {
3804
+ throw new CheckpointError("resume.constraint_unprojectable", `the approver EDITED this call's input, but the inherited ancestor chain cannot re-adjudicate the edit statically: ${unprojectable}. ` +
3805
+ "Refusing the edited resume pre-CAS (the checkpoint stays pending and remains decidable) — approve the shown input as-is, deny it, " +
3806
+ "or re-issue the edited action as a fresh call so the full live chain adjudicates it");
3807
+ }
3808
+ for (const entry of chain) {
3809
+ const denied = checkToolPolicyProjection(entry, editedReq);
3810
+ if (denied !== undefined) {
3811
+ throw new CheckpointError("resume.constraint_rejected", `the approver EDITED this call's input and a FROZEN inherited ancestor constraint denies the edited args${denied.message ? `: ${denied.message}` : ""}. ` +
3812
+ "Refused pre-CAS (fresh-redecision semantics: the checkpoint stays pending and the same token remains decidable — approve as shown, deny, or edit differently)");
3813
+ }
3814
+ }
3815
+ }
3816
+ const toolPolicyLocked = this.deps.lockedConfig?.keys?.includes("toolPolicy") === true;
3817
+ const editBasePolicy = toolPolicyLocked
3818
+ ? (this.deps.basePolicyForResumeEdit ?? this.deps.toolPolicy)
3819
+ : (taskConfig.basePolicyForResumeEdit ?? this.deps.basePolicyForResumeEdit ?? taskConfig.toolPolicy ?? this.deps.toolPolicy);
3820
+ if (editBasePolicy !== undefined) {
3821
+ let rechecked;
3822
+ try {
3823
+ rechecked = refuseOutOfContractDecision(await raceEditAbort(Promise.resolve(editBasePolicy.check(editedReq, editSignal))));
3824
+ }
3825
+ catch (err) {
3826
+ if (err instanceof CheckpointError)
3827
+ throw err;
3828
+ rechecked = { action: "deny", message: `the resume-edit policy re-check errored: ${err instanceof Error ? err.message : String(err)}` };
3829
+ }
3830
+ if (rechecked.action === "deny") {
3831
+ throw new CheckpointError("resume.constraint_rejected", `the approver EDITED this call's input and the deployment's resume-edit policy denies the edited args${rechecked.message ? `: ${rechecked.message}` : ""}. ` +
3832
+ "Refused pre-CAS (the checkpoint stays pending and remains decidable)");
3833
+ }
3834
+ if (rechecked.updatedInput !== undefined && !deepJsonEqual(rechecked.updatedInput, editedArgs)) {
3835
+ throw new CheckpointError("resume.constraint_rejected", `the deployment's resume-edit policy REWROTE the approver's edited args to a different value than was approved — the durable resume cannot execute either the un-adjudicated edit or an unapproved rewrite; ` +
3836
+ `refused pre-CAS (the checkpoint stays pending); re-submit the edited action so the rewrite is adjudicated and approved synchronously`);
3837
+ }
3838
+ }
3839
+ const resumeHooks = taskConfig.hooks ?? this.deps.hooks;
3840
+ if (resumeHooks?.preToolUse !== undefined && resumeHooks.preToolUseObservational === true) {
3841
+ try {
3842
+ await raceEditAbort(Promise.resolve(resumeHooks.preToolUse(cp.pendingAction.toolName, cloneObserverInput(editedArgs), {
3843
+ toolCallId: cp.pendingAction.toolCallId,
3844
+ toolName: cp.pendingAction.toolName,
3845
+ })));
3846
+ }
3847
+ catch (err) {
3848
+ if (err instanceof CheckpointError)
3849
+ throw err;
3850
+ throw new CheckpointError("resume.constraint_rejected", `the declared-observational PreToolUse face threw while observing the approver's edited args (${err instanceof Error ? err.message : String(err)}) — ` +
3851
+ "refused fail-closed pre-CAS (the checkpoint stays pending and remains decidable)");
3852
+ }
3853
+ }
3722
3854
  }
3723
3855
  const outcomeForStore = plainPolicyOutcome ?? plainReviewOutcome ?? plainParkOutcome;
3724
3856
  if (outcomeForStore === undefined) {
@@ -3778,7 +3910,7 @@ export class Runner {
3778
3910
  ? new CheckpointError("checkpoint.reopen_failed", "the resume was aborted before the approved action could run AND the store refused to reopen the checkpoint — the approval is terminally consumed and the suspended work was not executed; a retry needs a fresh approval")
3779
3911
  : new CheckpointError("checkpoint.reopen_failed", "the resume was aborted before the approved action could run and the reopen attempt FAILED IN FLIGHT — the checkpoint's state is unprovable from here: it may already be pending again. Re-read it before deciding; do NOT issue a fresh approval on the assumption the old one is dead (the approved action did NOT run either way)");
3780
3912
  }
3781
- return this.runTaskStream(spec, { cp, outcome: outcomeForStore, onEnvRestoreFailed, ...(wakeMessage !== undefined ? { wakeMessage } : {}) }, internals);
3913
+ return this.runTaskStream(spec, { cp, outcome: outcomeForStore, onEnvRestoreFailed, ...(wakeMessage !== undefined ? { wakeMessage } : {}) }, resumeInternals);
3782
3914
  }
3783
3915
  async applyResumeDecision(prepared, resume, emit, emitCommitted, onResolvedToolSuccess, onExecuteStart) {
3784
3916
  const { pendingAction } = resume.cp;
@@ -3853,15 +3985,23 @@ export class Runner {
3853
3985
  if (outcome.decision !== "allow") {
3854
3986
  throw new CheckpointError("checkpoint.invalid_outcome", `pending action reached the resolver with decision "${String(outcome.decision)}" — only "allow" executes and only "deny" injects a denial; refusing to execute`);
3855
3987
  }
3988
+ const beltBudget = {
3989
+ resourceRemainingMicroUsd: remainingBudgetMicroUsd(prepared.resourceLedger),
3990
+ resourceSpentMicroUsd: prepared.resourceLedger?.spentMicroUsd ?? 0,
3991
+ suspendCount: resume.cp.suspendCount ?? 0,
3992
+ };
3856
3993
  if (outcome.decision === "allow" && outcome.updatedInput !== undefined && prepared.basePolicyForResumeEdit) {
3857
3994
  const rechecked = refuseOutOfContractDecision(await prepared.basePolicyForResumeEdit.check({
3858
3995
  toolName: pendingAction.toolName,
3859
3996
  args: resolvedArgs,
3860
3997
  toolCallId: pendingAction.toolCallId,
3998
+ budget: beltBudget,
3861
3999
  ...(prepared.cwdRef !== undefined ? { cwd: prepared.cwdRef.current } : {}),
3862
4000
  }, prepared.abortController.signal));
3863
- if (rechecked.action === "deny") {
3864
- const editedDenial = formatHookFeedback(`The approver EDITED this call's input; the edited call is denied by the deployment's tool policy and was not executed${rechecked.message ? `: ${rechecked.message}` : ""}.`);
4001
+ const beltUnsafe = rechecked.action === "deny" ||
4002
+ (rechecked.updatedInput !== undefined && !deepJsonEqual(rechecked.updatedInput, resolvedArgs));
4003
+ if (beltUnsafe) {
4004
+ const editedDenial = formatHookFeedback(`The approver EDITED this call's input; the edited call is refused by the deployment's tool policy and was not executed${rechecked.action === "deny" && rechecked.message ? `: ${rechecked.message}` : " (the policy rewrote the edit to a value that was not approved)"}.`);
3865
4005
  emitEnd(true, { content: editedDenial });
3866
4006
  const eid = await prepared.session.appendMessage(toolResultMsg(pendingAction.toolCallId, pendingAction.toolName, editedDenial, true));
3867
4007
  emitCommitted(eid, "toolResult", pendingAction.toolCallId);
@@ -3873,6 +4013,7 @@ export class Runner {
3873
4013
  toolName: pendingAction.toolName,
3874
4014
  args: resolvedArgs,
3875
4015
  toolCallId: pendingAction.toolCallId,
4016
+ budget: beltBudget,
3876
4017
  ...(prepared.cwdRef !== undefined ? { cwd: prepared.cwdRef.current } : {}),
3877
4018
  }, prepared.abortController.signal));
3878
4019
  if (narrowed.action === "deny") {
@@ -31,6 +31,21 @@ async function connectedStores(provider) {
31
31
  }
32
32
  return [...snapshot.stores];
33
33
  }
34
+ async function walkList(reader, prefix) {
35
+ const out = [];
36
+ let cursor;
37
+ for (let round = 0; round < 64; round++) {
38
+ const rows = await reader.list(prefix, { ...(cursor !== undefined ? { cursor } : {}), limit: 50 });
39
+ if (Array.isArray(rows))
40
+ return [...out, ...rows];
41
+ out.push(...rows.entries);
42
+ if (rows.nextCursor === undefined)
43
+ return out;
44
+ assert.ok(cursor === undefined || rows.nextCursor > cursor, `paged list nextCursor must strictly advance (got ${JSON.stringify(rows.nextCursor)} after ${JSON.stringify(cursor)})`);
45
+ cursor = rows.nextCursor;
46
+ }
47
+ return assert.fail("paged list did not terminate within 64 windows — nextCursor must eventually be absent");
48
+ }
34
49
  function storeById(stores, id) {
35
50
  const found = stores.find((s) => s.info.id === id);
36
51
  if (!found)
@@ -93,7 +108,7 @@ export async function sharedMemoryStoreContract(hooks) {
93
108
  const stores = await connectedStores(await hooks.make(fixture));
94
109
  const alpha = storeById(stores, "alpha");
95
110
  const normalizedPrefix = "/a";
96
- const rows = await alpha.reader.list(listPrefixArgument(normalizedPrefix), {});
111
+ const rows = await walkList(alpha.reader, listPrefixArgument(normalizedPrefix));
97
112
  const listed = new Set(rows.map((r) => r.path));
98
113
  const expected = fixture.stores[0].documents.filter((d) => matchesPathPrefix(d.path, normalizedPrefix));
99
114
  assert.ok(expected.length > 0, "the fixture must actually place documents under the probed prefix");
@@ -105,7 +120,7 @@ export async function sharedMemoryStoreContract(hooks) {
105
120
  const fixture = twoStoreFixture();
106
121
  const stores = await connectedStores(await hooks.make(fixture));
107
122
  const alpha = storeById(stores, "alpha");
108
- const listed = new Set((await alpha.reader.list(undefined, {})).map((r) => r.path));
123
+ const listed = new Set((await walkList(alpha.reader, undefined)).map((r) => r.path));
109
124
  for (const doc of fixture.stores[0].documents) {
110
125
  assert.ok(listed.has(doc.path), `list(undefined) omitted "${doc.path}"`);
111
126
  }
@@ -122,10 +137,10 @@ export async function sharedMemoryStoreContract(hooks) {
122
137
  const stores = await connectedStores(await hooks.make(twoStoreFixture()));
123
138
  const alpha = storeById(stores, "alpha");
124
139
  const beta = storeById(stores, "beta");
125
- const alphaPaths = new Set((await alpha.reader.list(undefined, {})).map((r) => r.path));
140
+ const alphaPaths = new Set((await walkList(alpha.reader, undefined)).map((r) => r.path));
126
141
  assert.ok(!alphaPaths.has("/b/only.md"), "store alpha listed a document that belongs to store beta");
127
142
  assert.strictEqual(await alpha.reader.read("/b/only.md", {}), null, "store alpha read a document that belongs to store beta");
128
- const betaPaths = new Set((await beta.reader.list(undefined, {})).map((r) => r.path));
143
+ const betaPaths = new Set((await walkList(beta.reader, undefined)).map((r) => r.path));
129
144
  assert.ok(!betaPaths.has("/a/one.md"), "store beta listed a document that belongs to store alpha");
130
145
  assert.strictEqual(await beta.reader.read("/a/one.md", {}), null, "store beta read a document that belongs to store alpha");
131
146
  });
@@ -102,7 +102,9 @@ export interface NormalizedStore {
102
102
  info: SharedMemoryStoreInfo;
103
103
  list: (prefix: string | undefined, opts: {
104
104
  signal?: AbortSignal;
105
- }) => Promise<SharedMemoryDocumentEntry[]>;
105
+ cursor?: string;
106
+ limit?: number;
107
+ }) => Promise<SharedMemoryDocumentEntry[] | import("./types.js").SharedMemoryPagedList>;
106
108
  read: (path: string, opts: {
107
109
  signal?: AbortSignal;
108
110
  }) => Promise<{
@@ -1,7 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import { errorResult } from "../tools.js";
3
3
  import { CONNECTING_MESSAGE, GENERIC_FAILURE_MESSAGE, NO_STORES_MESSAGE, SHARED_STORE_FRAME, UNAVAILABLE_MESSAGE, datePrefixOf, echoUntrusted, foldReaderError, listPrefixArgument, normalizeEntries, normalizeMemoryPath, normalizeReadValue, normalizeSnapshot, pageDocuments, relativePathRefusal, sanitizeSharedContent, validateDocumentPath, validatePathPrefix, } from "./normalize.js";
4
- import { SHARED_MEMORY_LIST_TOOL_NAME, SHARED_MEMORY_READ_CAP_BYTES, SHARED_MEMORY_READ_TOOL_NAME, } from "./types.js";
4
+ import { SHARED_MEMORY_LIST_PAGE_SIZE, SHARED_MEMORY_LIST_TOOL_NAME, SHARED_MEMORY_READ_CAP_BYTES, SHARED_MEMORY_READ_TOOL_NAME, } from "./types.js";
5
5
  const LIST_HINT = "List shared memory stores and their documents — check here before telling the user you do not have something.";
6
6
  const LIST_DESCRIPTION = [
7
7
  LIST_HINT,
@@ -58,12 +58,14 @@ function renderEntryLine(entry) {
58
58
  const path = JSON.stringify(entry.path);
59
59
  return meta === "" ? path : `${path} (${meta})`;
60
60
  }
61
- function renderDocumentPage(page, remaining) {
61
+ function renderDocumentPage(page, remaining, providerHasMore = false) {
62
62
  if (page.length === 0)
63
63
  return "(empty)";
64
64
  const footer = remaining > 0
65
65
  ? `\n… ${remaining} more — call again with cursor=${JSON.stringify(page.at(-1)?.path ?? "")} to continue.`
66
- : "";
66
+ : providerHasMore
67
+ ? `\n… more — call again with cursor=${JSON.stringify(page.at(-1)?.path ?? "")} to continue.`
68
+ : "";
67
69
  return `${SHARED_STORE_FRAME}\n\n${page.map(renderEntryLine).join("\n")}${footer}`;
68
70
  }
69
71
  function renderDocument(content, updatedAt) {
@@ -173,31 +175,85 @@ export function createSharedMemoryTools(opts) {
173
175
  if (refusal)
174
176
  return refusedList(refusal, "refused", resolution.diagnostics, store);
175
177
  }
176
- let raw;
177
- try {
178
- raw = await resolution.store.list(normalizedPrefix === undefined ? undefined : listPrefixArgument(normalizedPrefix), { signal });
179
- }
180
- catch (err) {
181
- if (signal?.aborted === true)
182
- throw err;
183
- return refusedList(foldReaderError(err), "failed", resolution.diagnostics, store);
178
+ const collected = [];
179
+ let droppedEntries = 0;
180
+ let providerHasMore = false;
181
+ let providerCursor;
182
+ let windowCursor = cursor;
183
+ const MAX_WINDOW_ROUNDS = 8;
184
+ for (let round = 0;; round++) {
185
+ let raw;
186
+ try {
187
+ raw = await resolution.store.list(normalizedPrefix === undefined ? undefined : listPrefixArgument(normalizedPrefix), {
188
+ signal,
189
+ ...(windowCursor !== undefined ? { cursor: windowCursor } : {}),
190
+ limit: SHARED_MEMORY_LIST_PAGE_SIZE + 1,
191
+ });
192
+ }
193
+ catch (err) {
194
+ if (signal?.aborted === true)
195
+ throw err;
196
+ return refusedList(foldReaderError(err), "failed", resolution.diagnostics, store);
197
+ }
198
+ let windowEntries = raw;
199
+ let nextCursor;
200
+ let isPaged = false;
201
+ try {
202
+ if (raw !== null && typeof raw === "object" && !Array.isArray(raw) && Array.isArray(raw.entries)) {
203
+ isPaged = true;
204
+ windowEntries = raw.entries;
205
+ const nc = raw.nextCursor;
206
+ nextCursor = typeof nc === "string" ? nc : undefined;
207
+ }
208
+ }
209
+ catch (err) {
210
+ if (signal?.aborted === true)
211
+ throw err;
212
+ return refusedList(FAILED_ERROR, "failed", resolution.diagnostics, store);
213
+ }
214
+ const normalized = normalizeEntries(windowEntries);
215
+ if (normalized.kind === "malformed")
216
+ return refusedList(FAILED_ERROR, "failed", resolution.diagnostics, store);
217
+ collected.push(...normalized.entries);
218
+ droppedEntries += normalized.droppedEntries;
219
+ const progressed = isPaged && nextCursor !== undefined && (windowCursor === undefined || nextCursor > windowCursor);
220
+ if (!progressed) {
221
+ providerHasMore = false;
222
+ break;
223
+ }
224
+ providerCursor = nextCursor;
225
+ providerHasMore = true;
226
+ const candidates = pageDocuments(collected, {
227
+ ...(normalizedPrefix !== undefined ? { normalizedPrefix } : {}),
228
+ ...(cursor !== undefined ? { cursor } : {}),
229
+ });
230
+ if (candidates.page.length >= SHARED_MEMORY_LIST_PAGE_SIZE || candidates.remaining > 0)
231
+ break;
232
+ if (round + 1 >= MAX_WINDOW_ROUNDS)
233
+ break;
234
+ windowCursor = nextCursor;
184
235
  }
185
- const normalized = normalizeEntries(raw);
186
- if (normalized.kind === "malformed")
187
- return refusedList(FAILED_ERROR, "failed", resolution.diagnostics, store);
188
- const { page, remaining } = pageDocuments(normalized.entries, {
236
+ const { page, remaining } = pageDocuments(collected, {
189
237
  ...(normalizedPrefix !== undefined ? { normalizedPrefix } : {}),
190
238
  ...(cursor !== undefined ? { cursor } : {}),
191
239
  });
240
+ const hasMoreBeyondWindow = providerHasMore;
192
241
  const details = {
193
- ...diagnosticsOf(resolution.diagnostics.droppedStores ?? 0, normalized.droppedEntries),
242
+ ...diagnosticsOf(resolution.diagnostics.droppedStores ?? 0, droppedEntries),
194
243
  outcome: "ok",
195
244
  mode: "documents",
196
245
  store,
197
246
  entries: page,
198
247
  remaining,
248
+ ...(hasMoreBeyondWindow ? { providerHasMore: true } : {}),
199
249
  };
200
- return { content: renderDocumentPage(page, remaining), details };
250
+ if (page.length === 0 && hasMoreBeyondWindow && providerCursor !== undefined) {
251
+ return {
252
+ content: `${SHARED_STORE_FRAME}\n\n(no matching documents in this range)\n… more — call again with cursor=${JSON.stringify(providerCursor)} to continue.`,
253
+ details,
254
+ };
255
+ }
256
+ return { content: renderDocumentPage(page, remaining, hasMoreBeyondWindow), details };
201
257
  },
202
258
  };
203
259
  const readTool = {
@@ -69,15 +69,38 @@ export interface SharedMemoryDocumentEntry {
69
69
  /** ISO 8601. Rendering uses only a matching 10-character `YYYY-MM-DD` prefix; no match ⇒ treated as absent. */
70
70
  updatedAt?: string;
71
71
  }
72
+ /**
73
+ * #77 / design/180 实施窗 — the PROVIDER-SIDE pagination window. A reader that pages returns this
74
+ * instead of the bare array: `entries` is the window it chose to serve; `nextCursor` present ⇔ the
75
+ * store holds MORE documents past the window. The cursor vocabulary is exactly the tool face's —
76
+ * an EXCLUSIVE lower bound over document paths in plain UTF-16 code-unit order — so the same value
77
+ * flows model → core → provider unchanged. `nextCursor` is consumed as a HAS-MORE declaration only
78
+ * (the continue cursor the model receives is always the last path of the rendered page), so a
79
+ * stale or fabricated value can cost at most one empty page, never an error and never a skip.
80
+ */
81
+ export interface SharedMemoryPagedList {
82
+ entries: SharedMemoryDocumentEntry[];
83
+ nextCursor?: string;
84
+ }
72
85
  /** The read face of ONE store. Both methods must honor `signal`. */
73
86
  export interface SharedMemoryStoreReader {
74
87
  /**
75
88
  * List the documents under `prefix` (`undefined` = the whole store). core sorts, re-filters and pages —
76
89
  * an implementation may pre-filter by prefix but is only required to return a superset.
90
+ *
91
+ * #77 pagination pushdown (additive, both sides optional): core passes `cursor` (the exclusive
92
+ * lower-bound path of the previous page — same ordering contract as `pageDocuments`) and `limit`
93
+ * (a window-size hint, one over the render page) on every call. A provider may IGNORE both and
94
+ * return the full superset array — the v1 shape, byte-compatible — or honor them and return a
95
+ * {@link SharedMemoryPagedList} window, which is what lets a large library serve bounded
96
+ * containers instead of materializing the whole set per call (the container itself has no core
97
+ * cap; the unpaged form remains provider self-discipline, stated rather than silently truncated).
77
98
  */
78
99
  list(prefix: string | undefined, opts: {
79
100
  signal?: AbortSignal;
80
- }): Promise<SharedMemoryDocumentEntry[]>;
101
+ cursor?: string;
102
+ limit?: number;
103
+ }): Promise<SharedMemoryDocumentEntry[] | SharedMemoryPagedList>;
81
104
  /** Read one document. `null` = it does not exist (absence is a VALUE here, never an exception). */
82
105
  read(path: string, opts: {
83
106
  signal?: AbortSignal;
@@ -171,6 +194,9 @@ export type MemoryListDetails = SharedMemoryDiagnostics & ({
171
194
  store: string;
172
195
  entries: SharedMemoryDocumentEntry[];
173
196
  remaining: number;
197
+ /** #77 — the provider declared more documents past its served window (`remaining` counts only
198
+ * the window's local tail, so 0 here does not mean the listing is complete). */
199
+ providerHasMore?: true;
174
200
  } | {
175
201
  outcome: "refused" | "failed";
176
202
  reason: string;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * design/182 §11 — the permission-rule sync JOIN contract: the cross-repo acceptance suite the
3
+ * server-side twin runs against its own merge implementation (design/182 §13 obligation ②: consume
4
+ * core's `joinRuleStates`, or pass THIS suite against your reimplementation — semantic identity is the
5
+ * contract, not code sharing).
6
+ *
7
+ * KIT form (design/159 S1): vitest-free (`node:assert`), runner seam via {@link ContractAssertionRunner},
8
+ * version lockstep via `CONTRACT_KIT_ENGINE_VERSION`. There is NO skip hook — every case is mandatory,
9
+ * so a passing run never means "most of it passed". Fixtures are built inline: the join is a pure
10
+ * function, so there is nothing to seed and no instance lifecycle.
11
+ *
12
+ * What is asserted: the semilattice laws (idempotent/commutative/associative — the convergence proof
13
+ * itself, not prose), the design's convergence-matrix grids (M1-M13), the tombstone-union rule, the
14
+ * metadata re-projection refusal, the state vector and the paired frontier collection. Content
15
+ * assertions are set-based, never order-based: a twin with a different canonical output order passes,
16
+ * a twin that loses or invents a dot does not.
17
+ */
18
+ import type { RuleSyncFrontier, RuleSyncState } from "../permission-rule-store.js";
19
+ import { type ContractAssertionRunner } from "./contract-harness.js";
20
+ export interface PermissionRuleSyncContractHooks {
21
+ /** The merge under test. Default: core's own `joinRuleStates` (self-verification). */
22
+ join?: (a: RuleSyncState, b: RuleSyncState) => RuleSyncState;
23
+ /** The paired-collection arm under test. Default: core's `collectBelowFrontier`. */
24
+ collect?: (state: RuleSyncState, frontier: RuleSyncFrontier) => {
25
+ state: RuleSyncState;
26
+ collectedTombstones: number;
27
+ };
28
+ /** The state vector under test. Default: core's `ruleSyncVector`. */
29
+ vector?: (state: RuleSyncState) => RuleSyncFrontier;
30
+ runAssertion?: ContractAssertionRunner;
31
+ }
32
+ /** Run the full conformance suite. Throws (assert) on the first violated clause. */
33
+ export declare function permissionRuleSyncContract(hooks?: PermissionRuleSyncContractHooks): Promise<void>;