@sema-agent/core 5.0.0 → 5.1.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 (58) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/agents/repair-loop.js +9 -1
  3. package/dist/agents/roster-store.d.ts +9 -2
  4. package/dist/agents/roster-store.js +26 -5
  5. package/dist/agents/send-message-tool.js +1 -1
  6. package/dist/agents/subagent.js +15 -5
  7. package/dist/bin/sema-tb.d.ts +1 -2
  8. package/dist/bin/sema-tb.js +13 -25
  9. package/dist/brain/anthropic.js +2 -3
  10. package/dist/brain/degrading.d.ts +2 -8
  11. package/dist/brain/degrading.js +3 -3
  12. package/dist/brain/terminal-cause.js +2 -8
  13. package/dist/core/hooks.js +4 -4
  14. package/dist/core/lsp.js +2 -3
  15. package/dist/core/mcp.d.ts +1 -1
  16. package/dist/core/mcp.js +2 -1
  17. package/dist/core/memory-engine/engine.js +2 -2
  18. package/dist/core/memory-engine/file-backend.js +3 -1
  19. package/dist/core/memory-engine/layout.d.ts +3 -0
  20. package/dist/core/memory-engine/layout.js +51 -0
  21. package/dist/core/memory.d.ts +0 -1
  22. package/dist/core/memory.js +1 -1
  23. package/dist/core/permission-rules.d.ts +2 -2
  24. package/dist/core/permission-rules.js +18 -7
  25. package/dist/core/protocol-table.d.ts +14 -0
  26. package/dist/core/protocol-table.js +23 -0
  27. package/dist/core/runner/active-skill-scope.js +7 -7
  28. package/dist/core/runner/prepare-memory.js +4 -1
  29. package/dist/core/runner/prepare-task.js +38 -25
  30. package/dist/core/runner/runtask.js +9 -3
  31. package/dist/core/runner/session-rule-policy.d.ts +2 -2
  32. package/dist/core/runner/session-rule-policy.js +2 -1
  33. package/dist/core/runner/synthetic-tools.js +2 -2
  34. package/dist/core/runner/tool-disclosure.d.ts +0 -1
  35. package/dist/core/runner/tool-disclosure.js +0 -2
  36. package/dist/core/runner/turn-attachments.js +2 -1
  37. package/dist/core/sensitive-path-policy.js +2 -2
  38. package/dist/core/tool-policy.d.ts +0 -3
  39. package/dist/core/tool-policy.js +29 -15
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.js +1 -0
  42. package/dist/orchestration/run-spec.js +1 -1
  43. package/dist/orchestration/run-workflow-tool.js +2 -2
  44. package/dist/orchestration/workflow-script-store.d.ts +1 -1
  45. package/dist/orchestration/workflow-script-store.js +1 -1
  46. package/dist/prompt-assembly/assemble.js +0 -1
  47. package/dist/prompt-assembly/tool-catalog.d.ts +2 -1
  48. package/dist/prompts/default.d.ts +0 -2
  49. package/dist/prompts/default.js +1 -5
  50. package/dist/stores/cc/mailbox-store.d.ts +4 -0
  51. package/dist/stores/cc/mailbox-store.js +16 -2
  52. package/dist/stores/file/session-policy-store.d.ts +10 -1
  53. package/dist/stores/file/session-policy-store.js +20 -4
  54. package/dist/tools/fs/fs-search-tools.js +1 -2
  55. package/dist/tools/fs/fs-shared.d.ts +0 -1
  56. package/dist/tools/fs/fs-shared.js +0 -1
  57. package/dist/tools/todo.js +1 -1
  58. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.1.0 (2026-08-03)
4
+
5
+ _The second (and final planned) baggage sweep: every remaining compatibility arm, plus a durable memory-key fix with an automatic adoption leg and the protocol table that clears the road for a second tool protocol. Version-line policy (project ruling): the major stays at 5 — breaking changes below are declared loudly here and coordinated with all in-org consumers, but do not mint a new major._
6
+
7
+ **BREAKING (declared, rides the 5.x line by project ruling)**
8
+
9
+ 0. **The registry closes — every remaining compatibility arm is retired in this release:**
10
+ - `createDegradingBrain`: ONE form — the ordered `fallbacks` chain (`fallback`+`fallbackModel` retired; a chain-of-one is `fallbacks: [{brain, model}]`). The pair's two-trigger default retires with it: every construction gets the four-class gate; narrow explicitly via `downgradeOn`.
11
+ - `ToolSearch`: the pre-design/116 `select` ARRAY argument resolves nothing (the CC-canonical `select:A,B,C` string prefix in `query` is the live selection form).
12
+ - `Skill`: `skill` is REQUIRED (the `{name}` arm retired) — a stale replay bounces as loud, model-correctable invalid-arguments.
13
+ - `LSP`: the deprecated `symbol` alias for `query` leaves the tool schema (the `LspRequest.symbol` SEAM field is the wire contract, untouched).
14
+ - `TodoWrite`: `activeForm` is REQUIRED (exactly CC's TodoItem).
15
+ - `WorkflowScriptStore.resolveName`: ONE return shape — `{script, defaultArgs?}` (the bare-string arm retired).
16
+ - `sema-tb`: the `--message` argv leg is a usage error (process-list exposure once made a model's pgrep cleanup kill its own agent); use `--objective-file` or stdin.
17
+ - Anthropic brain: thinking enables ONLY on a valid non-`"off"` `ThinkingLevel` — the legacy-boolean silent 0.5-share enable is gone (out-of-contract value = absence).
18
+ - Terminal-cause predicates read `errorKind` ONLY — the `errorMessage`-sentinel fallback is deleted; stamping `errorKind` on every cut turn is now a documented Brain obligation.
19
+ - Read/Edit/Write schemas declare `file_path` only (the durable `path` alias retired; the security-gate readers keep their `path` over-read — that side is fail-closed coverage, not compat).
20
+ - The legacy prompt composer loses `memoryEnabled`/`consolidationEnabled` (dead in production since design/138 S4; `MEMORY_SAFETY`/`MEMORY_HYGIENE`/`MEMORY_GUIDANCE` stay exported as standalone assets).
21
+ 1. **`PermissionResult` loses the legacy `reason` field.** All three arms carry `message` only, and `decisionText` reads `message` only. A TS policy returning `{reason}` compiles red; a JS policy keeps its verdict but its custom text degrades to the per-site generic default. Migrate: `reason:` → `message:`.
22
+ 2. **`MemorySpecInput` is ONE form.** The singular `scope` field is removed; `scopes: string[]` is the only spelling. Migrate: `{scope: "x"}` → `{scopes: ["x"]}`. An untyped caller still passing `{scope}` gets "no usable scopes" = memory off for that task.
23
+ 3. **The approval folds are STRICT.** `createApprovalPolicy.approve` and the `onAsk` boolean arm approve ONLY on the literal `true`; an out-of-contract truthy (`"yes"`, `1`, …) from an untyped bridge is a fail-closed deny naming the defect. The historical truthy leniency was a fail-open on the security face with no live producer (server bridge verified).
24
+ 4. **Grep drops the retired keys (CC alignment).** `max_results` leaves the schema and the wiring — a stale caller's value applies NOTHING (`head_limit` is the one spelling). The `regex` key has no declared arm anywhere.
25
+
26
+ **Fixed**
27
+
28
+ - **One repo, one memory (RB-472).** `deriveRepoKey` canonicalizes (realpath) before minting, so a repo reached through a symlink alias and through its physical path no longer splits into two memory partitions. A pre-6.x deployment's legacy-keyed dirs are ADOPTED automatically at mount (one-shot whole-dir rename, concurrent-open race-tolerant). A store that already split is a loud `memory.partition_split` incident with both coordinates — never silently merged. Rename failure = `memory.partition_adopt_failed` disclosure; the mount never fails over adoption.
29
+
30
+ **Added**
31
+
32
+ - **Protocol namespace table**: `PROTOCOL_TABLE` / `MCP_NAMESPACE` / `protocolOf` / `ProtocolNamespace` / `ProtocolId` — "which protocol owns this tool name" as a lookup (`MCP_PREFIX` is now the table entry's prefix; a second protocol lands by appending one entry). `ToolOrigin`'s protocol slice derives from `ProtocolId`.
33
+ - **Corrupt-read disclosure seats** (the tolerances stay; the silence goes): `FileSessionPolicyStore`/`FileRosterStore` constructor `onCorruptRead`, `CcFileMailboxStoreOptions.onCorruptRead`, `recordRosterSpawn` optional `onError` (threaded to `onObserverError`, site `roster.recordSpawn`), and the durable-approval continuity read disclosing via `onObserverError` (site `resume.durableApprovalContinuity`). Plain ENOENT never fires any of them.
34
+
35
+ **Migration one-line table**: `reason:` → `message:` / `{scope}` → `{scopes: [..]}` / approver returns literal `true`·`false`·`"unavailable"`·`{allow}` only / `max_results` → `head_limit`.
36
+
37
+ ## 5.0.1 (2026-08-02)
38
+
39
+ _Post-release review patch: the 5.0.0 review wave (five domain reviewers + two independent codex legs) surfaced silent-widening gaps in the new audit surfaces plus wiring/anchoring stragglers. All fixes are loud-direction; no new API._
40
+
41
+ **Fixed**
42
+
43
+ - **Persisted session/ancestor rules join the retired-name audit (review G-2).** Session-rule policies were composed after the only policy-name audit pass and exposed no name metadata — a stored `{toolDeny: ["bash"]}` compiled clean and silently widened. The audit is now a reusable pass run over every narrowing layer, and `createSessionRulePolicy` returns a `NamedToolPolicy`.
44
+ - **Pre-prefix MCP names reach the hard arm (review G-3).** The `config.legacy_tool_name` check only consulted the static `RETIRED_TOOL_NAMES` map, which cannot enumerate dynamic MCP names — a v4-era policy entry like `github__delete_repository` (auto-prefixed by the retired canonicalizer) degraded to an advisory and the destructive call ran. An unmatched `__`-shaped name without the `mcp__` prefix now hard-fails prepare with prefix guidance; an `mcp__*` miss stays advisory (the server may not be mounted this run).
45
+ - **Repair-loop accounts stay live across an in-flight attempt (review G-4, time half).** A checkpoint minted mid-attempt clones the threaded bundle at that instant, and only a pre-dispatch stamp existed — an attempt that ran 900ms and parked persisted ~zero elapsed time, so the resume re-granted the washed budget. An unref'd 1s interval keeps the stamp current. (The money half — mid-flight partial spend — is tracked separately: it has a double-count design question against the resumed attempt's own stats.)
46
+ - **Permission-rule policies join the retired-name audit (review F-6).** `createPermissionRulePolicy` now returns a `NamedToolPolicy` exposing its compiled tool names as `nameSets`, so the prepare-time policy-name audit covers rule policies: a rule written with a RETIRED name (e.g. `{rule: "bash", behavior: "deny"}`) that matches nothing in the run's roster is now the same HARD `config.legacy_tool_name` prepare failure as a list policy — previously it compiled with zero issues and the deny silently guarded nothing. A deployment mounting its own tool under a retired name is unaffected (roster hit). Behavior note: configs carrying such stale rules now fail loudly at prepare instead of running silently widened.
47
+ - **Name-addressed revives anchor correctly (review F-1).** The SendMessage by-name resolution hand-copy dropped `createdAt`, so a `{to: NAME}` revive's spawn frame fell back to the wake instant for `startedAt` — the exact-id rung was fixed in 5.0.0, the name rung is now too.
48
+ - **Two more revive legs chain `root ?? parent` (review F-8).** The revived child's own spec internals and the re-minted registry row now derive the same root anchor as the roster/observer legs — a pre-floor row no longer yields an internally inconsistent agent (fleet view anchored, `canAccess` root arm blind).
49
+ - **The disclosure channel reaches both remaining composition roots (review F-2/F-3).** The runner's SendMessage mount wires `onNotifyError` to the `observer.notify_failed` trace lane, and the delegation tool's steer-lane resume threads `opts.onObserverError` — a throwing deployment sink on either revive path is now disclosed instead of counted on an unread notifier.
50
+ - **Checkpoint mints read the clock once.** All three checkpoint mint points stamped `createdAt` and `deadline` from separate `Date.now()` reads; a mint straddling a ms tick violated `deadline = createdAt + ttl` by 1ms.
51
+ - Comment-truth sweep: 14 sites still narrating the deleted OLD_TO_NEW/tombstone machinery rewritten to post-5.0.0 behavior; `markStopSourceByShellId`'s orphaned docstring relocated and corrected (env is REQUIRED for effect — an env-less call is a no-op).
52
+
3
53
  ## 5.0.0 (2026-08-02)
4
54
 
5
55
  _The baggage-unload major: the compatibility faces RB-476 cleared for tool NAMES are now cleared for id arguments, adapters and indexes too (RB-479), plus the revive-frame anchoring fix a downstream consumer was blocked on (RB-478). Everything that used to resolve silently under an old spelling now refuses loudly with the current spelling named._
@@ -194,7 +194,15 @@ export async function runRepairLoop(runner, implSpec, config) {
194
194
  }
195
195
  : implSpec;
196
196
  restartedOnce = false;
197
- const result = await runner.runTask(spec, { repairBundle: bundle });
197
+ const liveStamp = setInterval(stampAccount, 1_000);
198
+ liveStamp.unref?.();
199
+ let result;
200
+ try {
201
+ result = await runner.runTask(spec, { repairBundle: bundle });
202
+ }
203
+ finally {
204
+ clearInterval(liveStamp);
205
+ }
198
206
  spend += (result.stats.costMicroUsd ?? 0) + (result.stats.nested?.costMicroUsd ?? 0);
199
207
  stampAccount();
200
208
  accumulate(result.stats);
@@ -42,7 +42,14 @@ export declare class FileRosterStore implements RosterStore {
42
42
  private readonly path;
43
43
  private readonly maxAgeMs;
44
44
  private readonly gc;
45
- constructor(path: string, opts?: RosterGcOptions);
45
+ private readonly onCorruptRead;
46
+ constructor(path: string, opts?: RosterGcOptions & {
47
+ onCorruptRead?: (info: {
48
+ path: string;
49
+ reason: string;
50
+ }) => void;
51
+ });
52
+ private discloseCorrupt;
46
53
  private read;
47
54
  private write;
48
55
  record(entry: RosterEntry): void;
@@ -50,4 +57,4 @@ export declare class FileRosterStore implements RosterStore {
50
57
  list(): RosterEntry[];
51
58
  releaseAgent(agentId: string): void;
52
59
  }
53
- export declare function recordRosterSpawn(roster: RosterStore | undefined, entry: RosterEntry): void;
60
+ export declare function recordRosterSpawn(roster: RosterStore | undefined, entry: RosterEntry, onError?: (err: unknown) => void): void;
@@ -76,10 +76,19 @@ export class FileRosterStore {
76
76
  path;
77
77
  maxAgeMs;
78
78
  gc;
79
+ onCorruptRead;
79
80
  constructor(path, opts) {
80
81
  this.path = path;
81
82
  this.maxAgeMs = opts?.maxAgeMs;
82
83
  this.gc = opts;
84
+ this.onCorruptRead = opts?.onCorruptRead;
85
+ }
86
+ discloseCorrupt(reason) {
87
+ try {
88
+ this.onCorruptRead?.({ path: this.path, reason });
89
+ }
90
+ catch {
91
+ }
83
92
  }
84
93
  read(mode) {
85
94
  let text;
@@ -89,8 +98,10 @@ export class FileRosterStore {
89
98
  catch (e) {
90
99
  if (e.code === "ENOENT")
91
100
  return [];
92
- if (mode === "lenient")
101
+ if (mode === "lenient") {
102
+ this.discloseCorrupt(`read failed (non-ENOENT): ${e.message}`);
93
103
  return [];
104
+ }
94
105
  throw e;
95
106
  }
96
107
  if (text.trim() === "")
@@ -102,8 +113,10 @@ export class FileRosterStore {
102
113
  return raw.entries.filter((e) => typeof e === "object" && e !== null && typeof e.name === "string" && typeof e.agentId === "string" && typeof e.createdAt === "number");
103
114
  }
104
115
  catch (e) {
105
- if (mode === "lenient")
116
+ if (mode === "lenient") {
117
+ this.discloseCorrupt(`corrupt roster document: ${e.message}`);
106
118
  return [];
119
+ }
107
120
  throw e;
108
121
  }
109
122
  }
@@ -127,12 +140,20 @@ export class FileRosterStore {
127
140
  this.write(this.read("strict").filter((e) => e.agentId !== agentId));
128
141
  }
129
142
  }
130
- export function recordRosterSpawn(roster, entry) {
143
+ export function recordRosterSpawn(roster, entry, onError) {
131
144
  if (!roster)
132
145
  return;
146
+ const disclose = (err) => {
147
+ try {
148
+ onError?.(err);
149
+ }
150
+ catch {
151
+ }
152
+ };
133
153
  try {
134
- void Promise.resolve(roster.record(entry)).catch(() => { });
154
+ void Promise.resolve(roster.record(entry)).catch(disclose);
135
155
  }
136
- catch {
156
+ catch (err) {
157
+ disclose(err);
137
158
  }
138
159
  }
@@ -369,7 +369,7 @@ export function createSendMessageTool(opts) {
369
369
  }
370
370
  if (byName.status === "found") {
371
371
  const h = byName.handle;
372
- target = { task_id: h.id, type: h.type, status: h.status, ...(h.toolUseId !== undefined ? { toolUseId: h.toolUseId } : {}), ...(h.sessionScoped ? { sessionScoped: true } : {}), ...(h.description !== undefined ? { description: h.description } : {}), ...(h.name !== undefined ? { name: h.name } : {}), ...(h.agentType !== undefined ? { agentType: h.agentType } : {}), ...(h.owner !== undefined ? { owner: h.owner } : {}), ...(h.scope !== undefined ? { scope: h.scope } : {}), ...(h.parentTaskId !== undefined ? { parentTaskId: h.parentTaskId } : {}), ...(h.parentSessionId !== undefined ? { parentSessionId: h.parentSessionId } : {}), ...(h.rootSessionId !== undefined ? { rootSessionId: h.rootSessionId } : {}) };
372
+ target = { task_id: h.id, type: h.type, status: h.status, createdAt: h.createdAt, ...(h.toolUseId !== undefined ? { toolUseId: h.toolUseId } : {}), ...(h.sessionScoped ? { sessionScoped: true } : {}), ...(h.description !== undefined ? { description: h.description } : {}), ...(h.name !== undefined ? { name: h.name } : {}), ...(h.agentType !== undefined ? { agentType: h.agentType } : {}), ...(h.owner !== undefined ? { owner: h.owner } : {}), ...(h.scope !== undefined ? { scope: h.scope } : {}), ...(h.parentTaskId !== undefined ? { parentTaskId: h.parentTaskId } : {}), ...(h.parentSessionId !== undefined ? { parentSessionId: h.parentSessionId } : {}), ...(h.rootSessionId !== undefined ? { rootSessionId: h.rootSessionId } : {}) };
373
373
  }
374
374
  else {
375
375
  let rosterHit;
@@ -1381,7 +1381,9 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1381
1381
  ? {
1382
1382
  ...(reviveClaim.row.parentTaskId !== undefined ? { parentTaskId: reviveClaim.row.parentTaskId } : {}),
1383
1383
  ...(reviveClaim.row.parentSessionId !== undefined ? { parentSessionId: reviveClaim.row.parentSessionId } : {}),
1384
- ...(reviveClaim.row.rootSessionId !== undefined ? { rootSessionId: reviveClaim.row.rootSessionId } : {}),
1384
+ ...((reviveClaim.row.rootSessionId ?? reviveClaim.row.parentSessionId) !== undefined
1385
+ ? { rootSessionId: reviveClaim.row.rootSessionId ?? reviveClaim.row.parentSessionId }
1386
+ : {}),
1385
1387
  }
1386
1388
  : {
1387
1389
  ...(ctx.taskId !== undefined ? { parentTaskId: ctx.taskId } : {}),
@@ -1691,7 +1693,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1691
1693
  }, { priority: "later" }), "subagent.reapTerminalNotify");
1692
1694
  });
1693
1695
  if (agentName !== undefined) {
1694
- recordRosterSpawn(ctx.roster, { name: agentName, agentId: taskId, sessionId: forkedId, toolUseId: ctx.toolCallId, owner: bgOwner, scope: bgScope, ...((typeof childModel === "string" ? resolveModelDisplayLabel(childModel) : childModel?.id) !== undefined ? { model: typeof childModel === "string" ? childModel : childModel?.id } : {}), ...((ctx.rootSessionId ?? ctx.sessionId) !== undefined ? { rootSessionId: ctx.rootSessionId ?? ctx.sessionId } : {}), ...(sessionScopedBg ? { sessionScoped: true } : {}), createdAt: Date.now() });
1696
+ recordRosterSpawn(ctx.roster, { name: agentName, agentId: taskId, sessionId: forkedId, toolUseId: ctx.toolCallId, owner: bgOwner, scope: bgScope, ...((typeof childModel === "string" ? resolveModelDisplayLabel(childModel) : childModel?.id) !== undefined ? { model: typeof childModel === "string" ? childModel : childModel?.id } : {}), ...((ctx.rootSessionId ?? ctx.sessionId) !== undefined ? { rootSessionId: ctx.rootSessionId ?? ctx.sessionId } : {}), ...(sessionScopedBg ? { sessionScoped: true } : {}), createdAt: Date.now() }, (err) => opts.onObserverError?.(err, { site: "roster.recordSpawn" }));
1695
1697
  }
1696
1698
  const bgSink = ctx.onBackgroundChildEvent;
1697
1699
  const sinkEmit = (event) => {
@@ -2045,7 +2047,9 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2045
2047
  ...(reviveRow.name !== undefined ? { name: reviveRow.name } : {}),
2046
2048
  ...(reviveRow.parentTaskId !== undefined ? { parentTaskId: reviveRow.parentTaskId } : {}),
2047
2049
  ...(reviveRow.parentSessionId !== undefined ? { parentSessionId: reviveRow.parentSessionId } : {}),
2048
- ...(reviveRow.rootSessionId !== undefined ? { rootSessionId: reviveRow.rootSessionId } : {}),
2050
+ ...((reviveRow.rootSessionId ?? reviveRow.parentSessionId) !== undefined
2051
+ ? { rootSessionId: reviveRow.rootSessionId ?? reviveRow.parentSessionId }
2052
+ : {}),
2049
2053
  ...(reviveRow.model !== undefined ? { model: reviveRow.model } : {}),
2050
2054
  ...(reviveRow.teamName !== undefined ? { teamName: reviveRow.teamName } : {}),
2051
2055
  deliveryChannel: "attaching",
@@ -2092,7 +2096,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2092
2096
  return { isError: true, content: `Sub-agent not started in background: ${e instanceof Error ? e.message : String(e)}${wt ? `\n${wt}` : ""}`, details: { error: "register_failed" } };
2093
2097
  }
2094
2098
  if (agentName !== undefined) {
2095
- recordRosterSpawn(ctx.roster, { name: agentName, agentId: taskId, toolUseId: ctx.toolCallId, owner: bgOwner, scope: bgScope, ...((typeof childModel === "string" ? resolveModelDisplayLabel(childModel) : childModel?.id) !== undefined ? { model: typeof childModel === "string" ? childModel : childModel?.id } : {}), ...(reviveRow !== undefined ? ((reviveRow.rootSessionId ?? reviveRow.parentSessionId) !== undefined ? { rootSessionId: reviveRow.rootSessionId ?? reviveRow.parentSessionId } : {}) : (ctx.rootSessionId ?? ctx.sessionId) !== undefined ? { rootSessionId: ctx.rootSessionId ?? ctx.sessionId } : {}), ...(sessionScopedBg ? { sessionScoped: true } : {}), createdAt: reviveRow?.spawnedAt ?? Date.now() });
2099
+ recordRosterSpawn(ctx.roster, { name: agentName, agentId: taskId, toolUseId: ctx.toolCallId, owner: bgOwner, scope: bgScope, ...((typeof childModel === "string" ? resolveModelDisplayLabel(childModel) : childModel?.id) !== undefined ? { model: typeof childModel === "string" ? childModel : childModel?.id } : {}), ...(reviveRow !== undefined ? ((reviveRow.rootSessionId ?? reviveRow.parentSessionId) !== undefined ? { rootSessionId: reviveRow.rootSessionId ?? reviveRow.parentSessionId } : {}) : (ctx.rootSessionId ?? ctx.sessionId) !== undefined ? { rootSessionId: ctx.rootSessionId ?? ctx.sessionId } : {}), ...(sessionScopedBg ? { sessionScoped: true } : {}), createdAt: reviveRow?.spawnedAt ?? Date.now() }, (err) => opts.onObserverError?.(err, { site: "roster.recordSpawn" }));
2096
2100
  }
2097
2101
  const bgSink = ctx.onBackgroundChildEvent;
2098
2102
  const sinkEmit = (event) => {
@@ -2329,7 +2333,12 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2329
2333
  resumeConfig.durableApproval = { scope: cp0.scope };
2330
2334
  }
2331
2335
  }
2332
- catch {
2336
+ catch (err) {
2337
+ try {
2338
+ opts.onObserverError?.(err, { site: "resume.durableApprovalContinuity" });
2339
+ }
2340
+ catch {
2341
+ }
2333
2342
  }
2334
2343
  }
2335
2344
  const bgStream = await opts.runner.resumeStream(pr.ticket.token, pr.outcome, resumeConfig, {
@@ -2673,6 +2682,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2673
2682
  ...(opts.background?.notify ? { notify: opts.background.notify } : {}),
2674
2683
  sink: ctx.onSubagentSpawn,
2675
2684
  ...(opts.background ? { registry: opts.background.registry } : {}),
2685
+ ...(opts.onObserverError !== undefined ? { onNotifyError: (f) => opts.onObserverError?.(f.error, { site: f.site }) } : {}),
2676
2686
  });
2677
2687
  notifier.notify(() => ctx.onSubagentSpawn?.(createSteerHandle(stream, ctx.toolCallId, childAgentName, settled, {
2678
2688
  resume,
@@ -2,7 +2,6 @@
2
2
  import { type Model } from "../index.js";
3
3
  interface Args {
4
4
  model: string;
5
- message: string | undefined;
6
5
  objectiveFile: string | undefined;
7
6
  jsonl: string | undefined;
8
7
  }
@@ -19,7 +18,7 @@ export type InstructionResolution = {
19
18
  } | {
20
19
  error: string;
21
20
  };
22
- export declare function resolveInstruction(args: Pick<Args, "message" | "objectiveFile">, readInput: (source: string | 0) => string): InstructionResolution;
21
+ export declare function resolveInstruction(args: Pick<Args, "objectiveFile">, readInput: (source: string | 0) => string): InstructionResolution;
23
22
  type TbCost = Model["cost"];
24
23
  export type TbCostResolution = {
25
24
  cost: TbCost;
@@ -6,11 +6,11 @@ import { positiveIntFromEnv, tbModelInput } from "./tb-env.js";
6
6
  import { inferMaxTokensField } from "../brain/openai.js";
7
7
  function usage(code = 2) {
8
8
  const out = code === 0 ? process.stdout : process.stderr;
9
- out.write("Usage: sema-tb --model <provider/model> [--message <instruction> | --objective-file <path>] [--jsonl <path>]\n" +
10
- " Instruction legs: --objective-file <path> (preferred for real task text) | --message <text> (argv)\n" +
9
+ out.write("Usage: sema-tb --model <provider/model> [--objective-file <path>] [--jsonl <path>]\n" +
10
+ " Instruction legs: --objective-file <path> (preferred for real task text) | stdin\n" +
11
11
  " | stdin (default when neither flag is given). Both flags together = usage error.\n" +
12
12
  " WARNING: argv is visible to every process in the sandbox (ps / pgrep -f / /proc/*/cmdline) — long\n" +
13
- " or sensitive instructions should ride --objective-file or stdin, never --message.\n" +
13
+ " Instructions ride --objective-file or stdin; the retired --message argv leg is refused (process-list exposure).\n" +
14
14
  "Env: <PROVIDER>_API_KEY / <PROVIDER>_BASE_URL, TB_MAX_TURNS, TB_TIMEOUT_SEC, TB_FULLBODY, TB_TODOREMIND, TB_BGTASKS, TB_SUMMARIZE,\n" +
15
15
  " TB_FINALVERIFY, TB_CALLCAP, TB_FINALIZE, TB_REASONING, TB_JSONL, TB_CONTEXT_WINDOW, TB_MAX_OUTPUT_TOKENS, TB_TASKLIST, TB_VISION\n" +
16
16
  ' TB_COST="input,output[,cacheRead[,cacheWrite]]" — USD per MTok; explicit pricing override (omitted\n' +
@@ -20,19 +20,19 @@ function usage(code = 2) {
20
20
  }
21
21
  export function parseArgs(argv, env = process.env) {
22
22
  let model;
23
- let message;
24
23
  let objectiveFile;
25
24
  let jsonl;
26
25
  for (let i = 0; i < argv.length; i++) {
27
26
  const a = argv[i];
28
- if (a === "--model" || a === "--message" || a === "--objective-file" || a === "--jsonl") {
27
+ if (a === "--message") {
28
+ return { usageError: "--message is retired (argv is visible to every sandbox process — the exposure once made a model's pgrep cleanup kill its own agent). Use --objective-file <path> or pipe the instruction on stdin." };
29
+ }
30
+ if (a === "--model" || a === "--objective-file" || a === "--jsonl") {
29
31
  const v = argv[++i];
30
32
  if (v === undefined)
31
33
  return { usageError: `missing value for ${a}` };
32
34
  if (a === "--model")
33
35
  model = v;
34
- else if (a === "--message")
35
- message = v;
36
36
  else if (a === "--objective-file")
37
37
  objectiveFile = v;
38
38
  else
@@ -46,28 +46,16 @@ export function parseArgs(argv, env = process.env) {
46
46
  if (!model || !model.includes("/")) {
47
47
  return { usageError: "--model must be <provider/model> (e.g. deepseek/deepseek-v4-flash)" };
48
48
  }
49
- return { args: { model, message, objectiveFile, jsonl: jsonl ?? env.TB_JSONL } };
49
+ return { args: { model, objectiveFile, jsonl: jsonl ?? env.TB_JSONL } };
50
50
  }
51
51
  export function resolveInstruction(args, readInput) {
52
- if (args.message !== undefined && args.objectiveFile !== undefined) {
53
- return { error: "--message and --objective-file are mutually exclusive (pick one instruction leg)" };
54
- }
55
- const leg = args.objectiveFile !== undefined
56
- ? `--objective-file ${args.objectiveFile}`
57
- : args.message !== undefined
58
- ? "--message"
59
- : "stdin (no --message/--objective-file)";
52
+ const leg = args.objectiveFile !== undefined ? `--objective-file ${args.objectiveFile}` : "stdin (no --objective-file)";
60
53
  let raw;
61
- if (args.message !== undefined) {
62
- raw = args.message;
54
+ try {
55
+ raw = readInput(args.objectiveFile ?? 0);
63
56
  }
64
- else {
65
- try {
66
- raw = readInput(args.objectiveFile ?? 0);
67
- }
68
- catch (e) {
69
- return { error: `cannot read instruction from ${leg}: ${e instanceof Error ? e.message : String(e)}` };
70
- }
57
+ catch (e) {
58
+ return { error: `cannot read instruction from ${leg}: ${e instanceof Error ? e.message : String(e)}` };
71
59
  }
72
60
  const instruction = raw.trim();
73
61
  if (!instruction)
@@ -253,7 +253,7 @@ export function createAnthropicBrain(config = {}) {
253
253
  if (options?.temperature !== undefined && anthCompat.supportsTemperature !== false) {
254
254
  body.temperature = options.temperature;
255
255
  }
256
- if (model.reasoning && options?.reasoning && options.reasoning !== "off") {
256
+ if (model.reasoning && isThinkingLevel(options?.reasoning) && options.reasoning !== "off") {
257
257
  if (anthCompat.thinkingMode === "adaptive") {
258
258
  body.thinking = { type: "adaptive" };
259
259
  }
@@ -264,8 +264,7 @@ export function createAnthropicBrain(config = {}) {
264
264
  delete body.thinking;
265
265
  }
266
266
  else {
267
- const tierShare = isThinkingLevel(options.reasoning) ? reasoningBudgetShare(options.reasoning) : undefined;
268
- const share = config.thinkingBudgetShare ?? tierShare ?? 0.5;
267
+ const share = config.thinkingBudgetShare ?? reasoningBudgetShare(options.reasoning);
269
268
  const { max, budget } = thinkingBudget(body.max_tokens, share, config.thinkingBudgetTokens, hardCap);
270
269
  body.max_tokens = max;
271
270
  body.thinking = { type: "enabled", budget_tokens: budget };
@@ -12,18 +12,12 @@ interface DegradingBrainBaseOptions {
12
12
  primary: Brain;
13
13
  downgradeOn?: DegradeReason[];
14
14
  }
15
- export type DegradingBrainOptions = (DegradingBrainBaseOptions & {
15
+ export type DegradingBrainOptions = DegradingBrainBaseOptions & {
16
16
  fallbacks: Array<{
17
17
  brain: Brain;
18
18
  model: Model;
19
19
  }>;
20
- fallback?: never;
21
- fallbackModel?: never;
22
- }) | (DegradingBrainBaseOptions & {
23
- fallbacks?: never;
24
- fallback: Brain;
25
- fallbackModel: Model;
26
- });
20
+ };
27
21
  export declare function readDegradation(msg: {
28
22
  diagnostics?: AssistantMessageDiagnostic[];
29
23
  } | undefined): DegradationInfo | undefined;
@@ -115,12 +115,12 @@ export function passthroughStream(brain, model, context, options) {
115
115
  return out;
116
116
  }
117
117
  export function createDegradingBrain(opts) {
118
- const triggers = new Set(opts.downgradeOn ?? (opts.fallbacks ? ["rate_limit", "breaker_open", "server_error", "last_resort"] : ["rate_limit", "breaker_open"]));
119
- const rawHops = opts.fallbacks ?? (opts.fallback && opts.fallbackModel ? [{ brain: opts.fallback, model: opts.fallbackModel }] : []);
118
+ const triggers = new Set(opts.downgradeOn ?? ["rate_limit", "breaker_open", "server_error", "last_resort"]);
119
+ const rawHops = opts.fallbacks ?? [];
120
120
  const seenIds = new Set();
121
121
  const hops = rawHops.filter((h) => (seenIds.has(h.model.id) ? false : (seenIds.add(h.model.id), true))).slice(0, MAX_CHAIN);
122
122
  if (hops.length === 0) {
123
- throw new Error("createDegradingBrain: provide `fallbacks` (design/126 chain) or the legacy `fallback`+`fallbackModel` pair");
123
+ throw new Error("createDegradingBrain: `fallbacks` needs at least one {brain, model} hop (RB-482 #3: the legacy fallback+fallbackModel pair is retired — a chain-of-one is `fallbacks: [{brain, model}]`)");
124
124
  }
125
125
  const stream = (model, context, options) => {
126
126
  if (options?.resilience?.allowDegrade === false) {
@@ -1,12 +1,6 @@
1
- import { DEGENERATE_MESSAGE } from "./repetition.js";
2
- import { WALLTIME_CUTOFF_MESSAGE } from "./walltime.js";
3
1
  export function isDegenerateCutMessage(message) {
4
- if (message.errorKind === "degenerate")
5
- return true;
6
- return message.errorMessage === DEGENERATE_MESSAGE;
2
+ return message.errorKind === "degenerate";
7
3
  }
8
4
  export function isWalltimeCutMessage(message) {
9
- if (message.errorKind === "walltime_cutoff")
10
- return true;
11
- return message.errorMessage === WALLTIME_CUTOFF_MESSAGE;
5
+ return message.errorKind === "walltime_cutoff";
12
6
  }
@@ -177,7 +177,7 @@ export async function runToolGate(input) {
177
177
  if (decision.action === "ask" && req.toolName === ASK_USER_QUESTION_TOOL_NAME) {
178
178
  decision = {
179
179
  action: "deny",
180
- reason: "AskUserQuestion could not be parked for a durable human answer on this path (no checkpoint " +
180
+ message: "AskUserQuestion could not be parked for a durable human answer on this path (no checkpoint " +
181
181
  "facility, or the suspend failed) and a synchronous permission approver cannot carry an answer — " +
182
182
  "the question was NOT shown to anyone. Proceed with your best judgment and state the assumption " +
183
183
  "you made.",
@@ -211,7 +211,7 @@ export async function runToolGate(input) {
211
211
  }
212
212
  catch (err) {
213
213
  traceHookCrash(input, err, notifier);
214
- editDenied = { action: "deny", reason: preToolUseCrashReason(`the approved edit for "${toolName}"`, err) };
214
+ editDenied = { action: "deny", message: preToolUseCrashReason(`the approved edit for "${toolName}"`, err) };
215
215
  denySource = "hook";
216
216
  break;
217
217
  }
@@ -219,7 +219,7 @@ export async function runToolGate(input) {
219
219
  if (hr.additionalContext)
220
220
  preToolContext.push(hr.additionalContext);
221
221
  if (hr.action === "deny") {
222
- editDenied = { action: "deny", reason: decisionText(hr) ?? `the approved edit for "${toolName}" was blocked by a PreToolUse hook` };
222
+ editDenied = { action: "deny", message: decisionText(hr) ?? `the approved edit for "${toolName}" was blocked by a PreToolUse hook` };
223
223
  denySource = "hook";
224
224
  break;
225
225
  }
@@ -244,7 +244,7 @@ export async function runToolGate(input) {
244
244
  catch (err) {
245
245
  recheck = {
246
246
  action: "deny",
247
- reason: `policy re-check of the approved edit for "${toolName}" errored: ${err instanceof Error ? err.message : String(err)}`,
247
+ message: `policy re-check of the approved edit for "${toolName}" errored: ${err instanceof Error ? err.message : String(err)}`,
248
248
  };
249
249
  }
250
250
  if (recheck.action === "deny") {
package/dist/core/lsp.js CHANGED
@@ -171,18 +171,17 @@ export function createLspTool(manager, opts = {}) {
171
171
  line: Type.Optional(Type.Number({ description: "The line number (1-based, as shown in editors)." })),
172
172
  character: Type.Optional(Type.Number({ description: "The character offset (1-based, as shown in editors)." })),
173
173
  query: Type.Optional(Type.String({ description: "Symbol query for workspaceSymbol (may be empty to browse broadly)." })),
174
- symbol: Type.Optional(Type.String({ description: "Deprecated alias for query." })),
175
174
  }),
176
175
  effect: "read",
177
176
  execute: async (args, ctx) => {
178
- const { operation, filePath, line, character, query, symbol } = args;
177
+ const { operation, filePath, line, character, query } = args;
179
178
  if (POSITION_OPS.has(operation) && (line === undefined || character === undefined)) {
180
179
  return errorResult(`Error (LSP): the "${operation}" operation needs both line and character.`);
181
180
  }
182
181
  if (POSITION_OPS.has(operation) && (!Number.isInteger(line) || line < 1 || !Number.isInteger(character) || character < 1)) {
183
182
  return errorResult(`Error (LSP): line and character are 1-based integers (as shown in editors); got line=${line}, character=${character}.`);
184
183
  }
185
- const wsQuery = query ?? symbol;
184
+ const wsQuery = query;
186
185
  if (operation === "workspaceSymbol" && wsQuery === undefined) {
187
186
  return errorResult(`Error (LSP): the "workspaceSymbol" operation needs a query.`);
188
187
  }
@@ -56,7 +56,7 @@ export interface McpServerStatus {
56
56
  error?: string;
57
57
  transportClosed?: boolean;
58
58
  }
59
- export declare const MCP_PREFIX = "mcp__";
59
+ export declare const MCP_PREFIX: string;
60
60
  export declare function resolveMcpDeclaredResultSize(meta: Record<string, unknown> | undefined): number | undefined;
61
61
  export declare function gateMcpOutput(content: Array<TextContent | ImageContent>, limitTokens?: number): Array<TextContent | ImageContent>;
62
62
  export declare function truncateMcpErrorText(s: string): string;
package/dist/core/mcp.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { MCP_NAMESPACE } from "./protocol-table.js";
2
3
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
4
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4
5
  import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
@@ -12,7 +13,7 @@ import { truncateError } from "./tool-errors.js";
12
13
  import { delimitUntrusted, inlineUntrusted, sanitizeUntrustedText } from "./untrusted-text.js";
13
14
  import { validateJsonSchemaShape } from "./runner/strict-output-schema.js";
14
15
  const NAME_SEP = "__";
15
- export const MCP_PREFIX = "mcp__";
16
+ export const MCP_PREFIX = MCP_NAMESPACE.prefix;
16
17
  const MCP_OUTPUT_TOKENS_DEFAULT = 25_000;
17
18
  const MCP_CHARS_PER_TOKEN = 4;
18
19
  const MCP_META_RESULT_SIZE_CAP = 500_000;
@@ -6,7 +6,7 @@ import { inlineUntrusted } from "../untrusted-text.js";
6
6
  import { formatMemoryAge } from "../memory-recall.js";
7
7
  import { computeEntryRev, parseEntryFile, serializeEntryFile } from "./frontmatter.js";
8
8
  import { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, scanEntryFiles } from "./file-backend.js";
9
- import { QUARANTINE_DIR, SCAN_FUSE_THRESHOLD, quarantineAndTombstone, readIndexRevs, writeIndexRevs, bumpScanFuse, canonicalize, claimRootScope, clearScanFuse, deriveControlPlaneDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, ensureDirExists, isContainedIn, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirFor, } from "./layout.js";
9
+ import { QUARANTINE_DIR, SCAN_FUSE_THRESHOLD, quarantineAndTombstone, readIndexRevs, writeIndexRevs, bumpScanFuse, canonicalize, claimRootScope, clearScanFuse, adoptLegacyRepoDirs, deriveControlPlaneDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, ensureDirExists, isContainedIn, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirFor, } from "./layout.js";
10
10
  import { scanMemoryFileName, scanMemoryWrite, scanRemediation } from "./scan.js";
11
11
  export const MEMORY_INSTRUCTION_TEMPLATE = `# Memory
12
12
 
@@ -61,7 +61,7 @@ export class MemoryEngine {
61
61
  this.controlDir =
62
62
  typeof backendControl === "string" && backendControl
63
63
  ? backendControl
64
- : (opts.controlDir ?? deriveControlPlaneDir(resolveMemoryEngineRoot(), opts.memoryDir));
64
+ : (opts.controlDir ?? (adoptLegacyRepoDirs(resolveMemoryEngineRoot(), opts.memoryDir), deriveControlPlaneDir(resolveMemoryEngineRoot(), opts.memoryDir)));
65
65
  this.now = opts.now ?? Date.now;
66
66
  this.perScopeBudgetBytes = opts.perScopeBudgetBytes ?? Number.MAX_SAFE_INTEGER;
67
67
  this.perFileBytes = opts.perFileBytes ?? MAX_MEMORY_BYTES;
@@ -5,7 +5,7 @@ import { jaccardDistance, termSet } from "../memory-vector.js";
5
5
  import { MAX_MEMORY_BYTES } from "../memory.js";
6
6
  import { inlineUntrusted } from "../untrusted-text.js";
7
7
  import { computeEntryRev, entryFromFile, isValidEntryId, parseEntryFile, serializeEntryFile } from "./frontmatter.js";
8
- import { ControlPlaneCorruptError, CURSORS_FILE, QUARANTINE_DIR, atomicWriteFileSync, quarantineAndTombstone, claimRootScope, deriveControlPlaneDir, ensureDirExists, enqueueMemoryAnnouncement, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirName, writeAllSync, } from "./layout.js";
8
+ import { ControlPlaneCorruptError, CURSORS_FILE, QUARANTINE_DIR, atomicWriteFileSync, quarantineAndTombstone, claimRootScope, adoptLegacyRepoDirs, deriveControlPlaneDir, ensureDirExists, enqueueMemoryAnnouncement, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirName, writeAllSync, } from "./layout.js";
9
9
  import { scanMemoryFileName, scanMemoryWrite } from "./scan.js";
10
10
  export const MEMORY_INDEX_FILENAME = "MEMORY.md";
11
11
  export const DEFAULT_MAX_ENTRY_DEPTH = 3;
@@ -78,6 +78,8 @@ export class FileMemoryEngineBackend {
78
78
  constructor(dir, opts = {}) {
79
79
  this.now = opts.now ?? Date.now;
80
80
  this.directoryRoot = dir;
81
+ if (opts.controlDir === undefined)
82
+ adoptLegacyRepoDirs(resolveMemoryEngineRoot(), dir);
81
83
  this.controlPlaneRoot = opts.controlDir ?? deriveControlPlaneDir(resolveMemoryEngineRoot(), dir);
82
84
  ensureDirExists(dir);
83
85
  ensureDirExists(this.controlPlaneRoot);
@@ -6,6 +6,9 @@ export declare class ControlPlaneCorruptError extends Error {
6
6
  });
7
7
  }
8
8
  export declare function deriveRepoKey(repoRoot: string): string;
9
+ export declare function adoptLegacyRepoDirs(configRoot: string, repoRoot: string, onIncident?: (err: Error & {
10
+ code?: string;
11
+ }) => void): void;
9
12
  export declare function deriveRepoMemoryDir(configRoot: string, repoRoot: string): string;
10
13
  export declare function deriveProjectMemoryDir(configRoot: string, projectId: string): string;
11
14
  export declare function deriveProjectControlDir(configRoot: string, projectId: string): string;
@@ -11,8 +11,59 @@ export class ControlPlaneCorruptError extends Error {
11
11
  }
12
12
  }
13
13
  export function deriveRepoKey(repoRoot) {
14
+ return canonicalize(resolve(repoRoot)).normalize("NFC").replace(/[^A-Za-z0-9]/g, "-");
15
+ }
16
+ function deriveRepoKeyLegacy(repoRoot) {
14
17
  return resolve(repoRoot).normalize("NFC").replace(/[^A-Za-z0-9]/g, "-");
15
18
  }
19
+ export function adoptLegacyRepoDirs(configRoot, repoRoot, onIncident) {
20
+ const disclose = (code, message) => {
21
+ try {
22
+ const e = new Error(message);
23
+ e.code = code;
24
+ onIncident?.(e);
25
+ }
26
+ catch {
27
+ }
28
+ };
29
+ try {
30
+ const newKey = deriveRepoKey(repoRoot);
31
+ const oldKey = deriveRepoKeyLegacy(repoRoot);
32
+ if (newKey === oldKey)
33
+ return;
34
+ const oldDir = join(configRoot, oldKey);
35
+ const newDir = join(configRoot, newKey);
36
+ const exists = (p) => {
37
+ try {
38
+ statSync(p);
39
+ return true;
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ };
45
+ if (!exists(oldDir))
46
+ return;
47
+ if (exists(newDir)) {
48
+ disclose("memory.partition_split", `memory partition split detected for ${repoRoot}: BOTH the legacy alias-keyed dir (${oldDir}) and the canonical dir (${newDir}) exist. ` +
49
+ `This session uses the canonical dir; the legacy dir is left untouched (never merged — CAS baselines would collide). ` +
50
+ `If the legacy side holds newer memory, move its rows by hand and remove it.`);
51
+ return;
52
+ }
53
+ try {
54
+ renameSync(oldDir, newDir);
55
+ }
56
+ catch (err) {
57
+ const code = err.code;
58
+ if ((code === "ENOENT" || code === "EEXIST" || code === "ENOTEMPTY") && exists(newDir))
59
+ return;
60
+ disclose("memory.partition_adopt_failed", `could not adopt the legacy memory partition ${oldDir} → ${newDir} (${code ?? String(err)}): this session proceeds on the canonical dir; ` +
61
+ `the legacy dir still holds the prior memory — move it by hand.`);
62
+ }
63
+ }
64
+ catch {
65
+ }
66
+ }
16
67
  export function deriveRepoMemoryDir(configRoot, repoRoot) {
17
68
  return join(configRoot, deriveRepoKey(repoRoot), "memory");
18
69
  }
@@ -91,7 +91,6 @@ export interface NormalizedMemorySpec {
91
91
  scopeContract?: "v2";
92
92
  }
93
93
  export type MemorySpecInput = {
94
- scope?: string;
95
94
  scopes?: ReadonlyArray<string>;
96
95
  writeScope?: string | null;
97
96
  enabled?: boolean;
@@ -354,7 +354,7 @@ function mtimeMsOf(ts) {
354
354
  export function normalizeMemorySpec(input) {
355
355
  if (!input)
356
356
  return undefined;
357
- const raw = input.scopes && input.scopes.length > 0 ? input.scopes : input.scope !== undefined ? [input.scope] : [];
357
+ const raw = input.scopes ?? [];
358
358
  const scopes = [];
359
359
  const seen = new Set();
360
360
  for (const s of raw) {