@sema-agent/core 5.26.0 → 5.27.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.
- package/CHANGELOG.md +49 -0
- package/dist/agents/agent-transcript-tool.d.ts +5 -2
- package/dist/agents/agent-transcript-tool.js +2 -1
- package/dist/agents/send-message-tool.d.ts +4 -1
- package/dist/agents/subagent.d.ts +5 -2
- package/dist/core/checkpoint-store.d.ts +7 -2
- package/dist/core/hooks.d.ts +39 -4
- package/dist/core/hooks.js +15 -12
- package/dist/core/memory-engine/engine.d.ts +8 -5
- package/dist/core/memory-engine/engine.js +20 -6
- package/dist/core/memory-engine/file-backend.d.ts +81 -0
- package/dist/core/memory-engine/file-backend.js +250 -24
- package/dist/core/memory-engine/types.d.ts +8 -1
- package/dist/core/memory-vector.d.ts +6 -1
- package/dist/core/memory-vector.js +14 -4
- package/dist/core/memory.js +1 -6
- package/dist/core/permission-rule-model.d.ts +70 -5
- package/dist/core/permission-rule-model.js +58 -0
- package/dist/core/runner/prepare-memory.js +14 -9
- package/dist/core/runner/prepare-task.d.ts +9 -3
- package/dist/core/runner/prepare-task.js +29 -8
- package/dist/core/runner/runtask.d.ts +8 -1
- package/dist/core/task-registry-agent.d.ts +4 -3
- package/dist/core/task-registry.d.ts +6 -3
- package/dist/core/tool-policy.d.ts +9 -2
- package/dist/core/types.d.ts +35 -7
- package/dist/engine/loop/types.d.ts +10 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/orchestration/run-workflow-tool.d.ts +5 -3
- package/dist/orchestration/workflow.d.ts +9 -6
- package/dist/stores/file/checkpoint-store.d.ts +2 -1
- package/dist/stores/file/index.d.ts +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.27.0 — 2026-08-11
|
|
4
|
+
|
|
5
|
+
No API-BREAKING changes (exports grow only). Narrowings are tighten-direction and named below.
|
|
6
|
+
|
|
7
|
+
### Narrowed (behavior, ruled)
|
|
8
|
+
|
|
9
|
+
- **A declared-unavailable session reads the committed memory view.** Under
|
|
10
|
+
`memoryPersistenceCapable: false`, materialize/search/harvest serve the committed account
|
|
11
|
+
(ledger + shadow): disk divergence with no transaction backing is neither adopted nor served —
|
|
12
|
+
it stays on disk, is loudly reported (`restricted_divergence`, a new `HarvestRejectionCode`
|
|
13
|
+
member — consumers pinning that enum as a closed set must add the row), and a later unrestricted
|
|
14
|
+
session adopts it through the normal gates. Legitimate flows are untouched: user hand-edits and
|
|
15
|
+
git-pull drops keep working (deferred, not destroyed), a concurrent writable session's committed
|
|
16
|
+
changes pass as transaction-backed, and **a writeScope-null plane (org layers default read-only;
|
|
17
|
+
the dual-root non-write plane) keeps its ordinary adopt-on-read semantics** — the restriction is
|
|
18
|
+
the SESSION's declaration, never the plane's structure.
|
|
19
|
+
- **Rule suggestions appear only where a persisted rule could actually work.** Mandated
|
|
20
|
+
(`shellGate:"always"` / tool marks / org / requiresRealApproval), shadowed, hook-originated,
|
|
21
|
+
inherited-unresolved, ancestor-resolved (a live parent approver), and anonymous-task (no
|
|
22
|
+
principal, no local-owner declaration) asks no longer carry `ruleSuggestions` — a card whose rule
|
|
23
|
+
could never silence it stops offering to mint one. Classifier-tier and ordinary policy asks keep
|
|
24
|
+
their suggestions.
|
|
25
|
+
- **Known residual (registered)**: bytes refused by a restricted session remain on disk and are
|
|
26
|
+
adoptable by a later unrestricted session — the alternative (destroying bytes indistinguishable
|
|
27
|
+
from user edits) is ruled out; the boundary is "the restricted session is never polluted and
|
|
28
|
+
tampering is always visible", not "the bytes can never enter".
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **Prefix rule candidates**: the suggestion engine mints a reviewed PREFIX option beside the exact
|
|
33
|
+
one for lexicon-listed command bodies (`git push origin main` → optional `Bash(git push:*)`),
|
|
34
|
+
longest word-boundary hit, no fallback; candidate arrays grow from ≤1 to ≤2 with `exact` fixed at
|
|
35
|
+
index 0 (a documented contract across `AskRequest.ruleSuggestions`, checkpoint park rows, and the
|
|
36
|
+
approval card record). Consumers reading `[0]` unconditionally only miss the new option.
|
|
37
|
+
- **CJK lexical retrieval**: `termSet` now mints CJK character bigrams and unigrams beside
|
|
38
|
+
alphanumeric runs — a pure-Chinese memory entry was previously INVISIBLE to the File backend's
|
|
39
|
+
lexical rung (empty term set); the class is written in explicit escapes (a literal compat
|
|
40
|
+
ideograph NFC-folds to its unified twin, silently widening the range).
|
|
41
|
+
- **`toolResultContentSegment` exported** from the package root (the digest segment the engine's
|
|
42
|
+
own mints carry — consumers derive it instead of inventing a placeholder).
|
|
43
|
+
- `MemoryEngine.materialize` accepts an optional `adoptionRestricted` verdict;
|
|
44
|
+
`MemorySessionHandle.adoptionRestricted` reports it (both additive).
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- Three loaded-suite timing margins widened without losing discrimination; the defectscan
|
|
49
|
+
guardrail audit skips a concurrently-vanished temp dir instead of throwing; 24 stale doc/JSDoc
|
|
50
|
+
claims restated to actual behavior (bidirectional sweep + rescan trio).
|
|
51
|
+
|
|
3
52
|
## 5.26.0 — 2026-08-11
|
|
4
53
|
|
|
5
54
|
No API-BREAKING changes (exports grow only; new members optional/additive). Several
|
|
@@ -37,8 +37,11 @@ export interface AgentTranscriptToolOptions {
|
|
|
37
37
|
* into a child's tail when the residual isn't enough.
|
|
38
38
|
*
|
|
39
39
|
* Safety: reads are bounded to the parent's OWN lineage — the same non-leaking registry access scope as
|
|
40
|
-
* SendMessage/TaskOutput (an unknown or out-of-scope id reads identically to "not found").
|
|
41
|
-
*
|
|
40
|
+
* SendMessage/TaskOutput (an unknown or out-of-scope id reads identically to "not found"). Two read
|
|
41
|
+
* paths keep a child's session reachable: the retain ledger (a RETAINED child), and the durable
|
|
42
|
+
* agent-store fallback (`agentStore` wired + accessible row + preserved `sessionId` — readable even
|
|
43
|
+
* with retain off/expired/evicted). Only when BOTH miss does the call return the honest
|
|
44
|
+
* "not retained" / "not found".
|
|
42
45
|
*/
|
|
43
46
|
export declare function createAgentTranscriptTool(opts: AgentTranscriptToolOptions): import("../internal/harness-types.js").AgentTool<Type.TObject<{
|
|
44
47
|
id: Type.TString;
|
|
@@ -16,7 +16,8 @@ export function createAgentTranscriptTool(opts) {
|
|
|
16
16
|
description: `Read the recent tool steps of a background agent you launched (task_id 'a…' from the Agent tool with ` +
|
|
17
17
|
`run_in_background), so you can see precisely where it got to — useful before SendMessage-ing a follow-up, ` +
|
|
18
18
|
`or to inspect a stopped agent's tail. Returns the last N steps (tool, target, outcome); it does not re-run ` +
|
|
19
|
-
`the agent.
|
|
19
|
+
`the agent. Works for agents this run retained (retainSubagentSessions) and for agents whose durable ` +
|
|
20
|
+
`record preserved their transcript session; when neither holds you get an honest error.`,
|
|
20
21
|
parameters: Type.Object({
|
|
21
22
|
id: Type.String({ description: "The background agent's task_id (a…) returned by the Agent tool." }),
|
|
22
23
|
lastN: Type.Optional(Type.Integer({ minimum: 1, maximum: AGENT_TRANSCRIPT_MAX_N, description: `How many recent steps to return (default ${AGENT_TRANSCRIPT_DEFAULT_N}).` })),
|
|
@@ -19,7 +19,10 @@ export interface SendMessageToolOptions {
|
|
|
19
19
|
owner?: string;
|
|
20
20
|
scope?: string;
|
|
21
21
|
sessionId?: string;
|
|
22
|
-
/**
|
|
22
|
+
/** Completion-notify sink for the resumed run. The Runner mount wires the SENDING run's own
|
|
23
|
+
* notification injector here (its runtask-wrapped `injectTaskNotification`), so the completion
|
|
24
|
+
* notice lands in the sender's live injection queue at a turn boundary; a direct mount may wire a
|
|
25
|
+
* deployment-level background-notify sink instead. Absent ⇒ completion is silent. */
|
|
23
26
|
notify?: (n: TaskNotificationPayload, opts?: {
|
|
24
27
|
priority?: "now" | "next" | "later";
|
|
25
28
|
}) => void;
|
|
@@ -361,8 +361,11 @@ export interface SubagentSteerHandle {
|
|
|
361
361
|
* before any await; rolled back on pre-launch failure) → session exists AND non-empty (r1-m1).
|
|
362
362
|
* Then: fenced revive prompt (unpredictable marker) →
|
|
363
363
|
* `runTaskStream` on the FROZEN spawn snapshot + `requireExistingSession` (spec is tighten-only: the caller
|
|
364
|
-
* contributes ONLY `content`) → ledger `activeRun` bookkeeping → completion
|
|
365
|
-
*
|
|
364
|
+
* contributes ONLY `content`) → ledger `activeRun` bookkeeping → completion announced through
|
|
365
|
+
* `deps.notify` (no sink ⇒ silent). Which lane
|
|
366
|
+
* that is depends on the mount: the background Agent lane wires the deployment-level background-notify
|
|
367
|
+
* sink, while the SendMessage mount wires the SENDING run's own notification injector — there the
|
|
368
|
+
* completion notice lands in the sender's live injection queue at its next turn boundary.
|
|
366
369
|
*/
|
|
367
370
|
export declare function createSubagentResume(deps: {
|
|
368
371
|
ledger: SubagentRetainLedger | undefined;
|
|
@@ -574,8 +574,13 @@ export type PendingAction = {
|
|
|
574
574
|
/** design/179 §4 (additive) — the persistable allow-rule forms this parked call could be covered by,
|
|
575
575
|
* minted from the SAME post-hook args the row executes. Advisory display metadata for the approval
|
|
576
576
|
* inbox's "stop asking me this" option; never read by the resume path, and never a rule by itself
|
|
577
|
-
* (minting one goes through the approval-record protocol). Absent when no rule lane is armed
|
|
578
|
-
* command is one the lane cannot speak for
|
|
577
|
+
* (minting one goes through the approval-record protocol). Absent when no rule lane is armed, the
|
|
578
|
+
* command is one the lane cannot speak for, or the parked ask is one a persisted rule could not
|
|
579
|
+
* clear (mandated marks / a real-approval demand / a shadowed matching rule).
|
|
580
|
+
* CONTRACT (same as the synchronous `AskRequest.ruleSuggestions`) — array order is display order,
|
|
581
|
+
* narrowest first: the EXACT form is always index 0, a broader reviewed PREFIX form (at most one)
|
|
582
|
+
* follows; basis ≤ 2, and selection/ticket indices are keyed against this order. A row persisted
|
|
583
|
+
* before the prefix arm existed carries ≤ 1 entry and stays valid under the same reading. */
|
|
579
584
|
ruleSuggestions?: readonly import("./permission-rule-model.js").RuleSuggestion[];
|
|
580
585
|
/**
|
|
581
586
|
* design/80 D-1 §2 (slice 1a.2): the server-minted **opaque** boundInputHash of {@link args} — a
|
package/dist/core/hooks.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { DocumentContent, ImageContent, TextContent } from "../internal/llm.js";
|
|
2
2
|
import type { ExecutionEnv, FileError, Result, SessionTreeEntry } from "../internal/harness-types.js";
|
|
3
|
-
import type { PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
|
|
3
|
+
import type { DecisionReason, PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
|
|
4
4
|
import { type AskClass } from "./ask-class.js";
|
|
5
5
|
/**
|
|
6
|
-
* In-process hook seam (design/37) — a
|
|
7
|
-
*
|
|
6
|
+
* In-process hook seam (design/37) — a provider-agnostic interception layer modeled on CC's hooks,
|
|
7
|
+
* kept process-internal (no shell/HTTP executors, no settings files). The original tool-call trio:
|
|
8
8
|
*
|
|
9
9
|
* - {@link Hooks.preToolUse} runs before a tool executes. It may **rewrite** the args (an `allow` with
|
|
10
10
|
* `updatedInput`), **restrict** the call (`deny`/`ask`), and/or inject `additionalContext`. It is a
|
|
@@ -15,6 +15,13 @@ import { type AskClass } from "./ask-class.js";
|
|
|
15
15
|
* - {@link Hooks.userPromptSubmit} runs before the objective becomes a user message. It may `block`
|
|
16
16
|
* submission (the task fails with a model-readable reason) or inject `additionalContext` ahead of it.
|
|
17
17
|
*
|
|
18
|
+
* The seam has since grown well past the trio — each later member carries its own contract below:
|
|
19
|
+
* {@link Hooks.stop} / {@link Hooks.stopFailure} (run-end pushback / API-error terminal observer),
|
|
20
|
+
* {@link Hooks.postToolUseFailure} and {@link Hooks.postToolBatch} (failure + batch-boundary taps),
|
|
21
|
+
* {@link Hooks.preCompact} / {@link Hooks.postCompact} (compaction gate + observer),
|
|
22
|
+
* {@link Hooks.permissionDenied} (deny observer), and the {@link Hooks.preToolUseObservational}
|
|
23
|
+
* declaration flag.
|
|
24
|
+
*
|
|
18
25
|
* All feedback the model should see (a block reason, injected context) is wrapped as a
|
|
19
26
|
* `<system-reminder>` via {@link formatHookFeedback} so the model can read and self-correct.
|
|
20
27
|
*/
|
|
@@ -611,7 +618,12 @@ export interface ToolGateInput {
|
|
|
611
618
|
realApproval?: import("./checkpoint-store.js").RealApprovalGateBit,
|
|
612
619
|
/** #144: the matched-but-outranked persisted rule (the surviving ask's `persistedRuleShadowed`)
|
|
613
620
|
* — threaded so the park mint's risk descriptor carries the disclosure on the durable route. */
|
|
614
|
-
shadowedRule?: string
|
|
621
|
+
shadowedRule?: string,
|
|
622
|
+
/** The surviving ask's `decisionReason` — threaded so the park mint's rule-suggestion door can
|
|
623
|
+
* read the ask's origin on the durable route exactly as the synchronous mints read it off the
|
|
624
|
+
* decision (a hook-raised ask is one no persisted rule may clear, so its parked card must not
|
|
625
|
+
* offer to mint one). */
|
|
626
|
+
askDecisionReason?: DecisionReason) => Promise<ToolGateResult["suspend"] | ParkAttemptFailed | undefined>;
|
|
615
627
|
/**
|
|
616
628
|
* design/174 — route a policy `ask` on the reserved question tool to this run's CONTENT-ask channel
|
|
617
629
|
* before it can become a park or a refusal. Called in the `ask` branch with the FINAL post-hook,
|
|
@@ -871,6 +883,29 @@ export interface ToolGateInput {
|
|
|
871
883
|
* reach the same lane from inside a descendant's fold.
|
|
872
884
|
*/
|
|
873
885
|
export declare function createPreToolUseConstraintPolicy(preToolUse: NonNullable<Hooks["preToolUse"]>, env?: HookEnvCapabilities, onCrash?: (err: unknown) => void): ToolPolicy;
|
|
886
|
+
/**
|
|
887
|
+
* The mandate provenance of one call, judged from the SAME mark inputs the gate is driven with —
|
|
888
|
+
* the single source for "could a persisted allow rule clear this ask?". Allow rules silence the
|
|
889
|
+
* classifier's questions, never a mandated one, and this predicate is the mandated-family half of
|
|
890
|
+
* that boundary (the real-approval/governance half rides the decision's own `requiresRealApproval`
|
|
891
|
+
* bit, which the org layer stamps):
|
|
892
|
+
* · `egress` — the tool's own external-write mark, judged FIRST: it is the tool's declaration even
|
|
893
|
+
* when the coarse doctrine also installed a shell tier on the same seat;
|
|
894
|
+
* · `shellGated` + tier `"always"` — the operator's per-call confirmation doctrine
|
|
895
|
+
* (`"operator_always"`); the classify doctrine installs `"maybe"`, and THOSE asks stay the rule
|
|
896
|
+
* lane's home turf (`undefined`);
|
|
897
|
+
* · a tool's OWN `"always"`/`"maybe"` irreversibility tier without the doctrine (`"tool_marks"`).
|
|
898
|
+
*
|
|
899
|
+
* Two consumers, one derivation: the gate's silencing arm (a matching rule is disclosed as shadowed
|
|
900
|
+
* instead of clearing the ask) and the runner's suggestion factory (a mandated ask offers no
|
|
901
|
+
* "stop asking me this" option — a rule minted from it would never clear it). A drift between the
|
|
902
|
+
* two would let a card offer a rule the lane then refuses to honor.
|
|
903
|
+
*/
|
|
904
|
+
export declare function persistedRuleMandateOf(marks: {
|
|
905
|
+
egress?: boolean;
|
|
906
|
+
shellGated?: boolean;
|
|
907
|
+
irreversibility?: "never" | "maybe" | "always";
|
|
908
|
+
}): "operator_always" | "tool_marks" | undefined;
|
|
874
909
|
/**
|
|
875
910
|
* The design/37 **two-phase tool gate** — the single chokepoint that makes the load-bearing invariant
|
|
876
911
|
* structural ("a hook's `allow` cannot bypass the policy's `deny`/`ask`"):
|
package/dist/core/hooks.js
CHANGED
|
@@ -161,6 +161,17 @@ function withProbeTimeout(p, ms, signal) {
|
|
|
161
161
|
p.then((v) => done(resolve, v), (err) => done(reject, err));
|
|
162
162
|
});
|
|
163
163
|
}
|
|
164
|
+
export function persistedRuleMandateOf(marks) {
|
|
165
|
+
return marks.egress === true
|
|
166
|
+
? "tool_marks"
|
|
167
|
+
: marks.shellGated === true
|
|
168
|
+
? marks.irreversibility === "always"
|
|
169
|
+
? "operator_always"
|
|
170
|
+
: undefined
|
|
171
|
+
: marks.irreversibility === "always" || marks.irreversibility === "maybe"
|
|
172
|
+
? "tool_marks"
|
|
173
|
+
: undefined;
|
|
174
|
+
}
|
|
164
175
|
export async function runToolGate(input) {
|
|
165
176
|
const { event, preToolUse, adjudicate, resolveAsk, suspendAsk } = input;
|
|
166
177
|
const { toolCallId, toolName } = event;
|
|
@@ -315,15 +326,7 @@ export async function runToolGate(input) {
|
|
|
315
326
|
currentInput = policyRewrite;
|
|
316
327
|
req.args = policyRewrite;
|
|
317
328
|
}
|
|
318
|
-
const persistedRuleMandate = input
|
|
319
|
-
? "tool_marks"
|
|
320
|
-
: input.shellGated === true
|
|
321
|
-
? input.irreversibility === "always"
|
|
322
|
-
? "operator_always"
|
|
323
|
-
: undefined
|
|
324
|
-
: input.irreversibility === "always" || input.irreversibility === "maybe"
|
|
325
|
-
? "tool_marks"
|
|
326
|
-
: undefined;
|
|
329
|
+
const persistedRuleMandate = persistedRuleMandateOf(input);
|
|
327
330
|
if (input.persistedRules &&
|
|
328
331
|
!orgRealApprovalRequired &&
|
|
329
332
|
decision.action === "ask" &&
|
|
@@ -408,7 +411,7 @@ export async function runToolGate(input) {
|
|
|
408
411
|
? { origin: orgAskOrigin !== undefined ? `org_${orgAskOrigin}` : "policy" }
|
|
409
412
|
: undefined;
|
|
410
413
|
if (suspendAsk && decision.action === "ask") {
|
|
411
|
-
const suspended = await suspendAsk(req, currentInput, safety, undefined, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined);
|
|
414
|
+
const suspended = await suspendAsk(req, currentInput, safety, undefined, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined, decision.action === "ask" ? decision.decisionReason : undefined);
|
|
412
415
|
if (suspended) {
|
|
413
416
|
if ("parkFailed" in suspended)
|
|
414
417
|
parkFailed = suspended.parkFailed;
|
|
@@ -427,7 +430,7 @@ export async function runToolGate(input) {
|
|
|
427
430
|
req.args = outcome.presentedInput;
|
|
428
431
|
}
|
|
429
432
|
if (suspendAsk && outcome.parkDeclined && parkFailed === undefined) {
|
|
430
|
-
const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined);
|
|
433
|
+
const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined, decision.action === "ask" ? decision.decisionReason : undefined);
|
|
431
434
|
if (suspended) {
|
|
432
435
|
if ("parkFailed" in suspended)
|
|
433
436
|
parkFailed = suspended.parkFailed;
|
|
@@ -455,7 +458,7 @@ export async function runToolGate(input) {
|
|
|
455
458
|
const resolved = await resolveAsk(decision, req);
|
|
456
459
|
decision = resolved;
|
|
457
460
|
if (resolved.action === "deny" && resolved.approverUnavailable === true && suspendAsk && parkFailed === undefined) {
|
|
458
|
-
const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(askBeforeResolve), askBeforeResolve.action === "ask" ? askBeforeResolve.persistedRuleShadowed : undefined);
|
|
461
|
+
const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(askBeforeResolve), askBeforeResolve.action === "ask" ? askBeforeResolve.persistedRuleShadowed : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.decisionReason : undefined);
|
|
459
462
|
if (suspended) {
|
|
460
463
|
if ("parkFailed" in suspended)
|
|
461
464
|
parkFailed = suspended.parkFailed;
|
|
@@ -321,7 +321,9 @@ export declare class MemoryEngine {
|
|
|
321
321
|
* eventId anchors on the pollution record (sessionId + marked-at), so however many times this
|
|
322
322
|
* settlement replays the same pollution event, the generation is allocated once. */
|
|
323
323
|
private settlePromotions;
|
|
324
|
-
materialize(scopes: readonly string[], writeScope: string | null
|
|
324
|
+
materialize(scopes: readonly string[], writeScope: string | null, opts?: {
|
|
325
|
+
adoptionRestricted?: boolean;
|
|
326
|
+
}): Promise<MemorySessionHandle>;
|
|
325
327
|
inject(handle: MemorySessionHandle, opts?: {
|
|
326
328
|
writeToolMounted?: boolean;
|
|
327
329
|
}): MemoryInjection;
|
|
@@ -365,10 +367,11 @@ export declare class MemoryEngine {
|
|
|
365
367
|
* @param keepBaseline RB-440 (2026-07-31, hardening probe P3) — relative paths whose baseline must NOT
|
|
366
368
|
* advance: the files a DEGRADED harvest deferred (`report.degraded.pending`).
|
|
367
369
|
*
|
|
368
|
-
* The deferral contract
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
370
|
+
* The deferral contract in the harvest scan (the budget/deadline arm — see `harvestDeadlineMs`)
|
|
371
|
+
* promises "the REST is deferred to the next harvest (still on disk), never dropped", but execution
|
|
372
|
+
* then continued into this scan, which re-baselined EVERY file carrying an id — the pending ones
|
|
373
|
+
* included — writing the CURRENT disk rev into `handle.baseRevs`. The next harvest hit the harvest
|
|
374
|
+
* scan's unchanged-baseline fast path and skipped the file FOREVER: the deferred edit never
|
|
372
375
|
* became a patch. Session-NEW files escaped it (no id ⇒ `continue` below), which is exactly why the
|
|
373
376
|
* existing budget test passed. Measured with a copy-out backend: harvest#1 {update:1} + pending ["b.md"],
|
|
374
377
|
* harvest#2 {update:0}, and the backend still held v1 — so the next `materialize` wrote the stale text
|
|
@@ -323,9 +323,13 @@ export class MemoryEngine {
|
|
|
323
323
|
catch {
|
|
324
324
|
}
|
|
325
325
|
}
|
|
326
|
-
async materialize(scopes, writeScope) {
|
|
326
|
+
async materialize(scopes, writeScope, opts) {
|
|
327
327
|
ensureDirExists(this.memoryDir);
|
|
328
328
|
ensureDirExists(this.controlDir);
|
|
329
|
+
const restricted = opts?.adoptionRestricted === true;
|
|
330
|
+
const readBackend = restricted
|
|
331
|
+
? (this.backend.restrictedAdoptionView?.({ audit: true }) ?? this.backend)
|
|
332
|
+
: this.backend;
|
|
329
333
|
if (writeScope !== null)
|
|
330
334
|
claimRootScope(this.controlDir, writeScope);
|
|
331
335
|
const scopeDirs = new Map();
|
|
@@ -336,8 +340,8 @@ export class MemoryEngine {
|
|
|
336
340
|
for (const [scope, dir] of scopeDirs)
|
|
337
341
|
this.chmodScopeTree(dir, 0o755, 0o644, { excludeTopDirs: this.siblingScopeDirNames(dir, scope) });
|
|
338
342
|
const allScopes = [...scopeDirs.keys()];
|
|
339
|
-
const headers = await
|
|
340
|
-
const entries = await
|
|
343
|
+
const headers = await readBackend.listHeaders(allScopes);
|
|
344
|
+
const entries = await readBackend.getByIds(headers.map((h) => h.id));
|
|
341
345
|
const byScope = new Map();
|
|
342
346
|
for (const e of entries) {
|
|
343
347
|
const arr = byScope.get(e.scope) ?? [];
|
|
@@ -357,6 +361,7 @@ export class MemoryEngine {
|
|
|
357
361
|
repoIndexTargets: new Map(),
|
|
358
362
|
indexBaselineLines: 0,
|
|
359
363
|
indexText: "",
|
|
364
|
+
...(restricted ? { adoptionRestricted: true } : {}),
|
|
360
365
|
};
|
|
361
366
|
if (writeScope !== null)
|
|
362
367
|
ensureDirExists(handle.writableRoot);
|
|
@@ -382,7 +387,8 @@ export class MemoryEngine {
|
|
|
382
387
|
const { deleted: _stubDrop, ...stubFm } = entry.frontmatter;
|
|
383
388
|
const text = stub ? serializeEntryFile({ id: entry.id, frontmatter: stubFm, body: STUB_ARCHIVED_LINE }) : fullText;
|
|
384
389
|
const target = join(dir, `${entry.slug}.md`);
|
|
385
|
-
|
|
390
|
+
if (!(zeroCopy && restricted))
|
|
391
|
+
this.writeIfChanged(target, text);
|
|
386
392
|
const onDiskRev = stub ? revOfText(text, entry.id) : entry.rev;
|
|
387
393
|
const path = canonicalize(target);
|
|
388
394
|
handle.materialized.push({
|
|
@@ -415,7 +421,7 @@ export class MemoryEngine {
|
|
|
415
421
|
this.discloseAnnounceFailure("index-gate enqueue", err);
|
|
416
422
|
}
|
|
417
423
|
}
|
|
418
|
-
const indexText = this.rebuildIndex(handle, headers, { write: writeScope !== null, ignoreOnDisk: indexGate !== undefined });
|
|
424
|
+
const indexText = this.rebuildIndex(handle, headers, { write: writeScope !== null && !restricted, ignoreOnDisk: indexGate !== undefined || restricted });
|
|
419
425
|
handle.indexBaselineLines = countIndexLines(indexText);
|
|
420
426
|
handle.indexText = indexText;
|
|
421
427
|
for (const [scope, dir] of scopeDirs) {
|
|
@@ -433,7 +439,7 @@ export class MemoryEngine {
|
|
|
433
439
|
const instruction = writeChannel ? buildMemoryInstruction(handle.writableRoot) : "";
|
|
434
440
|
const readOnlyNotice = handle.writeScope === null ? MEMORY_READONLY_NOTICE : undefined;
|
|
435
441
|
const indexPath = join(handle.writableRoot, MEMORY_INDEX_FILENAME);
|
|
436
|
-
const onDisk = handle.indexOnDiskUntrusted === true ? undefined : readSafe(indexPath);
|
|
442
|
+
const onDisk = handle.indexOnDiskUntrusted === true || handle.adoptionRestricted === true ? undefined : readSafe(indexPath);
|
|
437
443
|
const indexText = onDisk !== undefined && onDisk.trim() !== "" ? onDisk : handle.indexText;
|
|
438
444
|
const truncated = truncateIndex(indexText);
|
|
439
445
|
const index = composeMemoryBlock(truncated, handle.writeScope ?? handle.scopes[0] ?? "memory");
|
|
@@ -529,6 +535,14 @@ export class MemoryEngine {
|
|
|
529
535
|
};
|
|
530
536
|
const writeScope = handle.writeScope;
|
|
531
537
|
if (writeScope === null || opts?.admitNothing !== undefined) {
|
|
538
|
+
try {
|
|
539
|
+
this.backend.auditRestrictedDivergence?.([
|
|
540
|
+
...new Set([...handle.scopes, ...(writeScope !== null ? [writeScope] : [])]),
|
|
541
|
+
]);
|
|
542
|
+
}
|
|
543
|
+
catch (err) {
|
|
544
|
+
report.warnings.push(`restricted-session divergence audit failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
545
|
+
}
|
|
532
546
|
const roFindings = this.backend.drainInboundFindings?.();
|
|
533
547
|
if (roFindings !== undefined && roFindings.length > 0)
|
|
534
548
|
report.inboundFindings = roFindings;
|
|
@@ -128,6 +128,15 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
|
|
|
128
128
|
/** The names of sibling scope subdirs to exclude when scanning the ROOT scope's own nested content. */
|
|
129
129
|
private excludedSubdirNames;
|
|
130
130
|
private scopeDir;
|
|
131
|
+
/** B2/C-F9 — contain one inbound-gate REJECT, shared by the adopting read (`readScope` sync) and
|
|
132
|
+
* the adoption-restricted committed view (`readScopeCommitted`) so the two doors can never drift:
|
|
133
|
+
* report the finding, capture the SCANNED bytes into control-plane quarantine (never a path
|
|
134
|
+
* re-read — TOCTOU), restore the committed shadow over the suspect where one exists (the write
|
|
135
|
+
* lands by `rename`, which REPLACES a link at the final component instead of traversing it —
|
|
136
|
+
* pinned by test), and escalate any deviation as a report-visible `quarantine_failed`. Returns
|
|
137
|
+
* the shadow text that now backs the entry (undefined ⇔ never committed / shadow-less legacy —
|
|
138
|
+
* each caller decides what leaving the set means on its channel). */
|
|
139
|
+
private containInboundReject;
|
|
131
140
|
/** B2 inbound gate for content that did NOT come through applyPatches (git pull / hand edits /
|
|
132
141
|
* a hand-seeded file): secret + cap + structure. Returns the rejection, or undefined (clean). */
|
|
133
142
|
private inboundGate;
|
|
@@ -142,8 +151,10 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
|
|
|
142
151
|
private readScope;
|
|
143
152
|
listHeaders(scopes: readonly string[]): Promise<MemoryEntryHeader[]>;
|
|
144
153
|
private listHeadersWith;
|
|
154
|
+
private listHeadersFrom;
|
|
145
155
|
getByIds(ids: readonly string[]): Promise<MemoryEntry[]>;
|
|
146
156
|
private getByIdsWith;
|
|
157
|
+
private getByIdsFrom;
|
|
147
158
|
/**
|
|
148
159
|
* design/178 §3 (ruled 2026-08-08) — a SIDE-EFFECT-FREE projection of this backend for the session's
|
|
149
160
|
* RETRIEVAL face (`memory_search` / `memory_get`).
|
|
@@ -162,11 +173,81 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
|
|
|
162
173
|
* WRITES ARE REFUSED, loudly — a view handed to a write path is a defect, not a fallback.
|
|
163
174
|
*/
|
|
164
175
|
retrievalView(): MemoryBackend;
|
|
176
|
+
/** Dedup keys for divergence findings the restricted view already recorded on THIS instance: one
|
|
177
|
+
* materialize reads a scope several times (listHeaders, then getByIds per scope), and a
|
|
178
|
+
* checkpoint-cadence harvest re-audits the same still-pending state — without the key, one
|
|
179
|
+
* untouched divergence would mint a finding per read. Per-process on purpose (no durable state to
|
|
180
|
+
* lifecycle: a cross-process resume re-discloses once, which is the desired "still pending"
|
|
181
|
+
* notice); a divergence whose bytes CHANGE mints a new key and is reported again. */
|
|
182
|
+
private restrictedFindingKeys;
|
|
183
|
+
private recordRestrictedFinding;
|
|
184
|
+
/**
|
|
185
|
+
* The ADOPTION-RESTRICTED read of one scope: serve the COMMITTED account (ledger + control-plane
|
|
186
|
+
* shadow) and never advance it. This is the tamper boundary for a session with no write intent:
|
|
187
|
+
* the ordinary `readScope(sync=true)` ADOPTS disk divergence as the new committed baseline
|
|
188
|
+
* regardless of session intent, so a byte landed by a side channel (e.g. a shell write under a
|
|
189
|
+
* session whose persistence is declared unavailable, or into a read-only layering) would be
|
|
190
|
+
* committed by the very session that was told it cannot persist — its own checkpoint-resume
|
|
191
|
+
* materialize included. Here instead:
|
|
192
|
+
* - disk rev == committed rev ⇒ served as-is (it IS the committed content);
|
|
193
|
+
* - divergence whose bytes equal the committed SHADOW ⇒ served as-is: the shadow is written at
|
|
194
|
+
* the applyPatches commit point, so matching it is transactional proof that a concurrent
|
|
195
|
+
* writable session committed this state and only this call's ledger snapshot raced it —
|
|
196
|
+
* never treated as tampering;
|
|
197
|
+
* - other divergence of a shadow-backed entry ⇒ the SHADOW (committed content) is served; the
|
|
198
|
+
* divergent disk bytes stay in place UNLESS they fail the inbound gate (the existing
|
|
199
|
+
* quarantine door, unchanged under restriction). Scan-clean divergence is never quarantined,
|
|
200
|
+
* overwritten, or adopted — it stays on disk as deferred external state a later unrestricted
|
|
201
|
+
* session adjudicates normally;
|
|
202
|
+
* - a divergent entry with NO shadow (shadow-less legacy), and a never-committed id-bearing
|
|
203
|
+
* file, have no committed content to serve ⇒ WITHHELD with a loud finding, never served from
|
|
204
|
+
* disk, never adopted.
|
|
205
|
+
* The ledger is reloaded from disk on every call (a stale cache would misread a concurrent commit
|
|
206
|
+
* as divergence) and NEVER written here; the only writes this path may perform are the
|
|
207
|
+
* gate-failing containment above and a shadow backfill for a rev-matching entry (recording
|
|
208
|
+
* committed content — the account itself does not move).
|
|
209
|
+
*
|
|
210
|
+
* `audit` picks the mount: true = the engine's materialize/harvest boundary (findings recorded,
|
|
211
|
+
* gate-failing divergence contained); false = the retrieval-tool face (a PURE committed read —
|
|
212
|
+
* no findings, no containment, no writes: a model lookup must be side-effect-free).
|
|
213
|
+
*/
|
|
214
|
+
private readScopeCommitted;
|
|
215
|
+
/** Does `id` have a projection file anywhere in the store OTHER than `excludeAbsPath`? Returns
|
|
216
|
+
* that projection's rel path, or undefined. Early-exits on the first hit; only consulted on the
|
|
217
|
+
* rare divergent-with-shadow path (the scope-binding guard above), never on clean reads. */
|
|
218
|
+
private idProjectionElsewhere;
|
|
219
|
+
/**
|
|
220
|
+
* The ADOPTION-RESTRICTED projection of this backend (see {@link readScopeCommitted}) — what a
|
|
221
|
+
* session with no write intent materializes and retrieves through. The CALLER decides restriction
|
|
222
|
+
* (an explicit verdict: a read-only layering, or persistence declared unavailable) and mounts this
|
|
223
|
+
* view; the backend never guesses a session's intent from its own state.
|
|
224
|
+
* - `audit: true` (the engine's materialize/harvest mount): divergence findings are recorded for
|
|
225
|
+
* the harvest report + announcement queue, and gate-FAILING divergence is contained by the
|
|
226
|
+
* existing quarantine door;
|
|
227
|
+
* - `audit: false` (the retrieval-tool mount): a pure committed-view read — no findings, no
|
|
228
|
+
* containment, no writes.
|
|
229
|
+
* WRITES ARE REFUSED loudly — same posture as {@link retrievalView}.
|
|
230
|
+
*/
|
|
231
|
+
restrictedAdoptionView(opts?: {
|
|
232
|
+
audit?: boolean;
|
|
233
|
+
}): MemoryBackend;
|
|
234
|
+
/**
|
|
235
|
+
* The harvest-boundary divergence sweep for an adoption-restricted session (the engine's
|
|
236
|
+
* zero-admission harvest arm calls this BEFORE draining findings): re-run the committed-view audit
|
|
237
|
+
* over the session's scopes — bytes landed AFTER materialize (a mid-session shell write) are
|
|
238
|
+
* detected here, at the very boundary where an unrestricted harvest would have admitted them —
|
|
239
|
+
* then check the committed account for entries MISSING from disk entirely (an unattributed delete
|
|
240
|
+
* is divergence too). Ledger rows carry no scope, so presence is judged across every registered
|
|
241
|
+
* scope, the same resolution `getByIds` uses; a rename or cross-scope move therefore never reads
|
|
242
|
+
* as missing. Non-destructive throughout: nothing is restored, forgotten, or adopted here.
|
|
243
|
+
*/
|
|
244
|
+
auditRestrictedDivergence(scopes: readonly string[]): void;
|
|
165
245
|
/** Lexical floor (design/138 §2.1): `1 - Jaccard` distance over name+description+body, ascending. */
|
|
166
246
|
search(query: string, scopes: readonly string[], opts?: {
|
|
167
247
|
limit?: number;
|
|
168
248
|
}): Promise<ScoredMemoryEntry[]>;
|
|
169
249
|
private searchWith;
|
|
250
|
+
private searchFrom;
|
|
170
251
|
/**
|
|
171
252
|
* H4 — the entry transaction, staged and journaled:
|
|
172
253
|
* 1. RELOAD the ledger from disk (验收 M2: a concurrently-committed transaction from another
|