@sema-agent/core 5.27.0 → 5.29.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 (99) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/dist/agents/cascade.d.ts +1 -1
  3. package/dist/agents/observer.d.ts +3 -3
  4. package/dist/agents/peer-admission.d.ts +1 -1
  5. package/dist/agents/teacher.d.ts +1 -1
  6. package/dist/agents/verify.d.ts +6 -6
  7. package/dist/bench/metrics.d.ts +1 -1
  8. package/dist/brain/retry.d.ts +1 -1
  9. package/dist/core/arg-summary.d.ts +1 -1
  10. package/dist/core/ask-class.d.ts +2 -2
  11. package/dist/core/ask-question.d.ts +1 -1
  12. package/dist/core/ask-question.js +14 -1
  13. package/dist/core/background-shell.d.ts +5 -5
  14. package/dist/core/checkpoint-store.d.ts +14 -14
  15. package/dist/core/file-snapshot-store.d.ts +1 -1
  16. package/dist/core/hooks.d.ts +25 -3
  17. package/dist/core/hooks.js +22 -3
  18. package/dist/core/human-input-projection.d.ts +2 -2
  19. package/dist/core/memory-admission.d.ts +2 -2
  20. package/dist/core/memory-engine/engine.d.ts +1 -1
  21. package/dist/core/memory-engine/engine.js +2 -4
  22. package/dist/core/memory-engine/file-backend.d.ts +68 -9
  23. package/dist/core/memory-engine/file-backend.js +69 -27
  24. package/dist/core/memory-engine/header-hints.d.ts +1 -1
  25. package/dist/core/memory-engine/layout.d.ts +32 -3
  26. package/dist/core/memory-engine/layout.js +132 -8
  27. package/dist/core/memory-engine/types.d.ts +6 -5
  28. package/dist/core/memory-recall.d.ts +1 -1
  29. package/dist/core/memory.d.ts +2 -2
  30. package/dist/core/oracle-isolation.d.ts +2 -2
  31. package/dist/core/permission-rule-consent.d.ts +83 -9
  32. package/dist/core/permission-rule-consent.js +92 -1
  33. package/dist/core/permission-rule-model.d.ts +18 -2
  34. package/dist/core/permission-rule-model.js +21 -0
  35. package/dist/core/permission-rule-org.d.ts +23 -4
  36. package/dist/core/permission-rule-org.js +67 -20
  37. package/dist/core/permission-rule-store.d.ts +1 -1
  38. package/dist/core/permission-rule-store.js +2 -2
  39. package/dist/core/permission-rule-sync.d.ts +15 -1
  40. package/dist/core/permission-rule-sync.js +89 -47
  41. package/dist/core/runner/active-skill-scope.d.ts +1 -1
  42. package/dist/core/runner/memory-consolidation.d.ts +1 -1
  43. package/dist/core/runner/prepare-task.d.ts +8 -3
  44. package/dist/core/runner/prepare-task.js +22 -7
  45. package/dist/core/runner/runtask.d.ts +1 -1
  46. package/dist/core/runner/runtask.js +13 -6
  47. package/dist/core/runner/session-rule-policy.d.ts +2 -2
  48. package/dist/core/runner/teardown-bounded.d.ts +1 -1
  49. package/dist/core/runner/tool-disclosure.d.ts +1 -1
  50. package/dist/core/scheduler.d.ts +4 -4
  51. package/dist/core/session-policy-store.d.ts +3 -3
  52. package/dist/core/shared-memory/normalize.d.ts +1 -1
  53. package/dist/core/skill-tool-specifier.d.ts +1 -1
  54. package/dist/core/task-notification.d.ts +2 -2
  55. package/dist/core/task-registry-agent.d.ts +18 -9
  56. package/dist/core/task-registry-agent.js +51 -21
  57. package/dist/core/task-registry-monitor.js +1 -1
  58. package/dist/core/task-registry-shared.d.ts +13 -4
  59. package/dist/core/tool-errors.d.ts +2 -2
  60. package/dist/core/tool-policy.d.ts +78 -12
  61. package/dist/core/tool-policy.js +74 -7
  62. package/dist/core/tool-result-store.d.ts +109 -8
  63. package/dist/core/tool-result-store.js +95 -15
  64. package/dist/core/trace.d.ts +1 -1
  65. package/dist/core/types.d.ts +92 -22
  66. package/dist/core/types.js +30 -1
  67. package/dist/core/untrusted-text.d.ts +6 -0
  68. package/dist/core/untrusted-text.js +1 -0
  69. package/dist/engine/compaction/compaction.d.ts +1 -1
  70. package/dist/engine/harness/messages.d.ts +1 -1
  71. package/dist/engine/harness/types.d.ts +2 -2
  72. package/dist/engine/llm/types.d.ts +1 -1
  73. package/dist/engine/loop/types.d.ts +2 -2
  74. package/dist/engine/session/import-validate.d.ts +1 -1
  75. package/dist/engine/session/log-digest.d.ts +1 -1
  76. package/dist/fixtures/index.d.ts +18 -2
  77. package/dist/fixtures/index.js +11 -0
  78. package/dist/index.d.ts +2 -2
  79. package/dist/orchestration/goal.d.ts +1 -1
  80. package/dist/orchestration/workflow-types.d.ts +5 -4
  81. package/dist/orchestration/workflow.js +11 -3
  82. package/dist/prompt-assembly/artifact.d.ts +1 -1
  83. package/dist/prompts/default.d.ts +1 -1
  84. package/dist/prompts/default.js +1 -1
  85. package/dist/scenarios/scenario-registry.d.ts +3 -3
  86. package/dist/stores/file/tool-result-store.d.ts +43 -3
  87. package/dist/stores/file/tool-result-store.js +107 -19
  88. package/dist/tools/fs/bash-readonly-classifier.d.ts +2 -2
  89. package/dist/tools/fs/fs-bash.d.ts +8 -1
  90. package/dist/tools/fs/fs-search-tools.d.ts +1 -1
  91. package/dist/tools/fs/fs-shared.d.ts +6 -1
  92. package/dist/tools/fs/fs-shared.js +11 -7
  93. package/dist/tools/fs/index.d.ts +6 -0
  94. package/dist/tools/fs/index.js +2 -0
  95. package/dist/tools/fs/safety.d.ts +1 -1
  96. package/dist/tools/scheduler-tools.d.ts +1 -1
  97. package/dist/tools/task-list.d.ts +1 -1
  98. package/dist/tools/web.d.ts +3 -3
  99. package/package.json +1 -1
@@ -25,7 +25,7 @@
25
25
  * privilege-separation ticket adds device-bound signatures. Deployments should connect knowing which
26
26
  * level they are buying.
27
27
  */
28
- import type { PersistedAllowRule, RuleDot, RuleScope, RuleTombstone } from "./permission-rule-model.js";
28
+ import type { PersistedAllowRule, RuleDot, RuleRejectCode, RuleScope, RuleTombstone } from "./permission-rule-model.js";
29
29
  import type { PermissionRuleStoreProvider, RuleOwner, RuleSyncDrop, RuleSyncFrontier, RuleSyncState } from "./permission-rule-store.js";
30
30
  import { type TracerHook } from "./trace.js";
31
31
  /** The injected HTTP seam — core never bundles a fetch. The deployment owns base URL, auth, TLS and
@@ -125,6 +125,15 @@ interface ParsedRuleSyncResponse {
125
125
  dot: RuleDot;
126
126
  reason: string;
127
127
  }>;
128
+ /** Tombstones whose SHAPE is whole but whose rule TEXT the current validator refuses — the third
129
+ * outcome, kept OUT of `merged.tombstones` so it does not read as malformed. See {@link
130
+ * parseRuleSyncResponse} for why this one does not withhold the round's adds (backlog #176). */
131
+ textRefusedTombstones: Array<{
132
+ rule: string;
133
+ scope: RuleScope;
134
+ dot: RuleDot;
135
+ code: RuleRejectCode;
136
+ }>;
128
137
  warnings: string[];
129
138
  }
130
139
  /**
@@ -134,6 +143,11 @@ interface ParsedRuleSyncResponse {
134
143
  * reaches the local disk); an unshapeable tombstone is surfaced as `undefined` so the caller's
135
144
  * tighten-only partitioning withholds the round's adds. Unknown keys never flow anywhere — every field
136
145
  * below is PICKED off the response, the object itself is discarded.
146
+ *
147
+ * A tombstone has THREE outcomes, not two (backlog #176): sound; malformed (bad shape, or a spelling
148
+ * that parses but is not canonical — the withholding arm); and shape-whole-but-TEXT-refused, reported
149
+ * on {@link ParsedRuleSyncResponse.textRefusedTombstones}. The third is a legacy row a tightened
150
+ * validator no longer admits — permanent, so treating it as the second would stall inbound adds forever.
137
151
  */
138
152
  export declare function parseRuleSyncResponse(raw: unknown, expectedPrincipal: string): ParsedRuleSyncResponse;
139
153
  export {};
@@ -1,4 +1,4 @@
1
- import { parseAllowRuleText } from "./permission-rule-model.js";
1
+ import { escapeForDisclosure, parseAllowRuleText } from "./permission-rule-model.js";
2
2
  import { applyTombstones, errText, joinFrontiers, joinRuleStates, normalizePersistedRule, ruleSyncVector, sameScope, screenRuleSyncState, writerOf, } from "./permission-rule-store.js";
3
3
  import { emitTrace } from "./trace.js";
4
4
  export const PERMISSION_RULE_SYNC_PATH = "/v1/permission-rules/sync";
@@ -9,12 +9,13 @@ function refuseLocalOwnerSync() {
9
9
  throw e;
10
10
  }
11
11
  const SYNC_MAX_ATTEMPTS = 8;
12
+ const DISCLOSED_REFUSED_TOMBSTONES = 5;
12
13
  export async function syncPermissionRules(opts) {
13
14
  if (opts.owner !== undefined) {
14
15
  if (opts.owner.kind === "local-owner")
15
16
  refuseLocalOwnerSync();
16
17
  if (opts.owner.principal !== opts.principal) {
17
- throw new Error(`contradictory identity: principal "${opts.principal}" and owner principal "${opts.owner.principal}" disagree`);
18
+ throw new Error(`contradictory identity: principal "${escapeForDisclosure(opts.principal)}" and owner principal "${escapeForDisclosure(opts.owner.principal)}" disagree`);
18
19
  }
19
20
  }
20
21
  if (typeof opts.principal !== "string" || opts.principal === "") {
@@ -83,6 +84,18 @@ export async function syncPermissionRules(opts) {
83
84
  if (tombstoneWithholding) {
84
85
  warnings.push("the response carried at least one malformed tombstone — every inbound add is withheld this round (legal tombstones land; the adds re-arrive once the peer repairs the tombstone: full-state exchange has no missed-forever window)");
85
86
  }
87
+ for (const t of response.textRefusedTombstones) {
88
+ dropped.push({ rule: t.rule, scope: t.scope, dot: t.dot, reason: "invalid_rule_text" });
89
+ }
90
+ if (response.textRefusedTombstones.length > 0) {
91
+ const named = response.textRefusedTombstones
92
+ .slice(0, DISCLOSED_REFUSED_TOMBSTONES)
93
+ .map((t) => `"${escapeForDisclosure(t.rule)}" (${t.code})`)
94
+ .join(", ");
95
+ const more = response.textRefusedTombstones.length - DISCLOSED_REFUSED_TOMBSTONES;
96
+ warnings.push(`the response carried ${response.textRefusedTombstones.length} tombstone(s) whose rule text this version's validator refuses — ${named}${more > 0 ? ` and ${more} more` : ""}. ` +
97
+ "They are refused here and never land; this round's inbound adds are NOT withheld for them (an add under the same text is refused by the same validator, so such a tombstone covers nothing that could go live). The peer still holds them — removing them there is what ends this notice");
98
+ }
86
99
  const inboundRules = [];
87
100
  if (!tombstoneWithholding) {
88
101
  for (const r of response.merged.rules) {
@@ -120,7 +133,7 @@ export async function syncPermissionRules(opts) {
120
133
  dropped.push({ rule: d.rule, scope: d.scope, dot: d.dot, reason });
121
134
  }
122
135
  else {
123
- warnings.push(`the server dropped an inbound-side record (${d.rule}, dot ${d.dot.actor}#${d.dot.counter}): ${d.reason}`);
136
+ warnings.push(`the server dropped an inbound-side record (${escapeForDisclosure(d.rule)}, dot ${escapeForDisclosure(d.dot.actor)}#${d.dot.counter}): ${escapeForDisclosure(d.reason)}`);
124
137
  }
125
138
  }
126
139
  const clean = !tombstoneWithholding;
@@ -147,7 +160,7 @@ export async function syncPermissionRules(opts) {
147
160
  resurrected: [],
148
161
  dropped,
149
162
  rev: current.rev,
150
- warnings: [...warnings, `the store refused the sync landing: ${errText(err)} — nothing landed, the local state is unchanged`],
163
+ warnings: [...warnings, `the store refused the sync landing: ${escapeForDisclosure(errText(err))} — nothing landed, the local state is unchanged`],
151
164
  });
152
165
  }
153
166
  if ("conflict" in res) {
@@ -205,9 +218,11 @@ export async function syncPermissionRules(opts) {
205
218
  warnings: [...warnings, `optimistic-concurrency retries exhausted after ${SYNC_MAX_ATTEMPTS} attempts — nothing landed, the local state is unchanged`],
206
219
  });
207
220
  }
208
- function isDot(v) {
221
+ function pickDot(v) {
209
222
  const d = v;
210
- return typeof d?.actor === "string" && d.actor !== "" && typeof d.counter === "number" && Number.isFinite(d.counter);
223
+ const actor = d?.actor;
224
+ const counter = d?.counter;
225
+ return typeof actor === "string" && actor !== "" && typeof counter === "number" && Number.isFinite(counter) ? { actor, counter } : undefined;
211
226
  }
212
227
  function pickScope(v) {
213
228
  const s = v;
@@ -219,13 +234,16 @@ function pickScope(v) {
219
234
  }
220
235
  function pickAdd(v) {
221
236
  const a = v;
222
- if (!isDot(a?.dot))
237
+ const dot = pickDot(a?.dot);
238
+ const origin = a?.origin;
239
+ const createdAt = a?.createdAt;
240
+ if (dot === undefined)
223
241
  return undefined;
224
- if (a.origin !== "user" && a.origin !== "imported-cc" && a.origin !== "starter")
242
+ if (origin !== "user" && origin !== "imported-cc" && origin !== "starter")
225
243
  return undefined;
226
- if (typeof a.createdAt !== "string")
244
+ if (typeof createdAt !== "string")
227
245
  return undefined;
228
- return { dot: { actor: a.dot.actor, counter: a.dot.counter }, origin: a.origin, createdAt: a.createdAt };
246
+ return { dot, origin, createdAt };
229
247
  }
230
248
  function pickFrontier(v) {
231
249
  if (v === null || typeof v !== "object" || Array.isArray(v))
@@ -243,35 +261,44 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
243
261
  throw new Error("the sync response is not an object — protocol error, nothing landed");
244
262
  }
245
263
  const r = raw;
246
- if (typeof r.principal !== "string") {
264
+ const echoedPrincipal = r.principal;
265
+ if (typeof echoedPrincipal !== "string") {
247
266
  throw new Error("the sync response carries no principal echo — protocol error, nothing landed");
248
267
  }
249
- if (r.principal !== expectedPrincipal) {
250
- throw new Error(`the sync response echoes principal "${r.principal}" but this round synced "${expectedPrincipal}" — the transport is wired to the wrong bucket; refusing the whole round`);
268
+ if (echoedPrincipal !== expectedPrincipal) {
269
+ throw new Error(`the sync response echoes principal "${escapeForDisclosure(echoedPrincipal)}" but this round synced "${escapeForDisclosure(expectedPrincipal)}" — the transport is wired to the wrong bucket; refusing the whole round`);
251
270
  }
252
271
  const merged = r.merged;
253
- if (merged === null || typeof merged !== "object" || !Array.isArray(merged.rules) || !Array.isArray(merged.tombstones)) {
272
+ const mergedRules = merged?.rules;
273
+ const mergedTombstones = merged?.tombstones;
274
+ if (merged === null || typeof merged !== "object" || !Array.isArray(mergedRules) || !Array.isArray(mergedTombstones)) {
254
275
  throw new Error("the sync response carries no merged state — protocol error, nothing landed");
255
276
  }
277
+ const rawGcFrontier = r.gcFrontier;
256
278
  let gcFrontier;
257
- if (r.gcFrontier !== undefined) {
258
- gcFrontier = pickFrontier(r.gcFrontier);
279
+ if (rawGcFrontier !== undefined) {
280
+ gcFrontier = pickFrontier(rawGcFrontier);
259
281
  if (gcFrontier === undefined) {
260
282
  throw new Error("the sync response carries an unparseable gcFrontier — refusing the whole round (a garbled frontier must not drive the fence arm)");
261
283
  }
262
284
  }
263
285
  const warnings = [];
264
286
  const rules = [];
265
- for (const entry of merged.rules) {
287
+ for (const entry of mergedRules) {
266
288
  const e = entry;
267
289
  const scope = pickScope(e?.scope);
268
- if (typeof e?.rule !== "string" || scope === undefined || !Array.isArray(e.adds) || typeof e.command !== "string" || typeof e.tool !== "string" || typeof e.match !== "string") {
290
+ const eRule = e?.rule;
291
+ const eCommand = e?.command;
292
+ const eTool = e?.tool;
293
+ const eMatch = e?.match;
294
+ const eAdds = e?.adds;
295
+ if (typeof eRule !== "string" || scope === undefined || !Array.isArray(eAdds) || typeof eCommand !== "string" || typeof eTool !== "string" || typeof eMatch !== "string") {
269
296
  warnings.push("the response carried an unshapeable rule entry — skipped (it never reaches the local store)");
270
297
  continue;
271
298
  }
272
299
  const adds = [];
273
300
  let addDamaged = false;
274
- for (const a of e.adds) {
301
+ for (const a of eAdds) {
275
302
  const picked = pickAdd(a);
276
303
  if (picked === undefined) {
277
304
  addDamaged = true;
@@ -280,64 +307,79 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
280
307
  adds.push(picked);
281
308
  }
282
309
  if (addDamaged)
283
- warnings.push(`rule "${e.rule}" carried at least one unshapeable add — skipped those adds`);
310
+ warnings.push(`rule "${escapeForDisclosure(eRule)}" carried at least one unshapeable add — skipped those adds`);
284
311
  if (adds.length === 0)
285
312
  continue;
286
313
  rules.push({
287
- rule: e.rule,
288
- tool: e.tool,
289
- match: e.match,
290
- command: e.command,
314
+ rule: eRule,
315
+ tool: eTool,
316
+ match: eMatch,
317
+ command: eCommand,
291
318
  scope,
292
319
  adds,
293
320
  });
294
321
  }
295
322
  const tombstones = [];
296
- for (const entry of merged.tombstones) {
323
+ const textRefusedTombstones = [];
324
+ for (const entry of mergedTombstones) {
297
325
  const t = entry;
298
326
  const scope = pickScope(t?.scope);
299
- const canonicalTomb = typeof t?.rule === "string" ? parseAllowRuleText(t.rule) : undefined;
300
- if (typeof t?.rule !== "string" ||
301
- canonicalTomb === undefined ||
302
- "reject" in canonicalTomb ||
303
- canonicalTomb.rule.rule !== t.rule ||
304
- scope === undefined ||
305
- !isDot(t.deletedBy) ||
306
- !Array.isArray(t.removedDots) ||
307
- t.removedDots.length === 0 ||
308
- !t.removedDots.every(isDot)) {
327
+ const ruleText = t?.rule;
328
+ const deletedBy = pickDot(t?.deletedBy);
329
+ const removedRaw = t?.removedDots;
330
+ const removedDots = [];
331
+ if (Array.isArray(removedRaw)) {
332
+ for (const d of removedRaw) {
333
+ const dot = pickDot(d);
334
+ if (dot === undefined) {
335
+ removedDots.length = 0;
336
+ break;
337
+ }
338
+ removedDots.push(dot);
339
+ }
340
+ }
341
+ if (typeof ruleText !== "string" || scope === undefined || deletedBy === undefined || removedDots.length === 0) {
309
342
  tombstones.push(undefined);
310
343
  continue;
311
344
  }
312
- tombstones.push({
313
- rule: t.rule,
314
- scope,
315
- removedDots: t.removedDots.map((d) => ({ actor: d.actor, counter: d.counter })),
316
- deletedBy: { actor: t.deletedBy.actor, counter: t.deletedBy.counter },
317
- });
345
+ const canonicalTomb = parseAllowRuleText(ruleText);
346
+ if ("reject" in canonicalTomb) {
347
+ textRefusedTombstones.push({ rule: ruleText, scope, dot: deletedBy, code: canonicalTomb.reject.code });
348
+ continue;
349
+ }
350
+ if (canonicalTomb.rule.rule !== ruleText) {
351
+ tombstones.push(undefined);
352
+ continue;
353
+ }
354
+ tombstones.push({ rule: ruleText, scope, removedDots, deletedBy });
318
355
  }
319
356
  const dropped = [];
320
- if (r.dropped !== undefined) {
321
- if (!Array.isArray(r.dropped)) {
357
+ const rawDropped = r.dropped;
358
+ if (rawDropped !== undefined) {
359
+ if (!Array.isArray(rawDropped)) {
322
360
  warnings.push("the response carried a non-array dropped field — ignored");
323
361
  }
324
362
  else {
325
- for (const entry of r.dropped) {
363
+ for (const entry of rawDropped) {
326
364
  const d = entry;
327
365
  const scope = pickScope(d?.scope);
328
- if (typeof d?.rule !== "string" || scope === undefined || !isDot(d.dot) || typeof d.reason !== "string") {
366
+ const dRule = d?.rule;
367
+ const dDot = pickDot(d?.dot);
368
+ const dReason = d?.reason;
369
+ if (typeof dRule !== "string" || scope === undefined || dDot === undefined || typeof dReason !== "string") {
329
370
  warnings.push("the response carried an unshapeable dropped entry — ignored (the server's own drop discipline is its contract obligation)");
330
371
  continue;
331
372
  }
332
- dropped.push({ rule: d.rule, scope, dot: { actor: d.dot.actor, counter: d.dot.counter }, reason: d.reason });
373
+ dropped.push({ rule: dRule, scope, dot: dDot, reason: dReason });
333
374
  }
334
375
  }
335
376
  }
336
377
  return {
337
- principal: r.principal,
378
+ principal: echoedPrincipal,
338
379
  merged: { rules, tombstones },
339
380
  ...(gcFrontier !== undefined ? { gcFrontier } : {}),
340
381
  dropped,
382
+ textRefusedTombstones,
341
383
  warnings,
342
384
  };
343
385
  }
@@ -34,7 +34,7 @@ export declare class ActiveSkillScope {
34
34
  * - DENY any tool not in the INTERSECTION of every active frame's `allowTools`.
35
35
  * - For a WRITE tool with an `allowPaths` constraint on ANY active frame, DENY a write whose
36
36
  * canonicalized target is outside every listed prefix (reuses {@link canonicalizeTarget}, so a
37
- * symlink can't smuggle a write outside the allowed paths).
37
+ * symlink can't carry across a write outside the allowed paths).
38
38
  * - A frame whose manifest could not be resolved (`kind:"unresolved"`) → DENY-ALL (fail-closed: no
39
39
  * allowlist means nothing is allowed — NOT the bare task policy).
40
40
  * When NO frame is active, the policy returns `allow` for everything (it only ever subtracts), leaving
@@ -27,7 +27,7 @@ export declare const DEFAULT_CONSOLIDATION_MAX_NOTES = 50;
27
27
  export declare const DEFAULT_CONSOLIDATION_TIMEOUT_SEC = 30;
28
28
  /** Normalize a note body for the EXACT-restatement check in the `<lo` near-dup cheap path. Case- + whitespace-
29
29
  * insensitive ONLY (lower-case, collapse whitespace runs, trim); it PRESERVES every symbol/punctuation so it can
30
- * NOT false-equate semantically-distinct facts. (service [365] adversarial: the earlier `[^\p{L}\p{N}]`-stripping
30
+ * NOT false-equate semantically-distinct facts. (service [365] falsification-style: the earlier `[^\p{L}\p{N}]`-stripping
31
31
  * normalization made "C#"≡"C", "delta +5"≡"delta -5", "100%"≡"100" — and a textual-CONTAINMENT test on top of it
32
32
  * also mis-judged "rate limit 1000" ⊇ "rate limit 100" (word boundary). A fire-on-every-note, no-LLM path must not
33
33
  * make a semantic-subset call, so the cheap path now folds ONLY an exact restatement; every real near-dup defers
@@ -864,7 +864,7 @@ export interface InheritedGate {
864
864
  * Org-memory admission freeze (ruled 2026-08-05): the spawning chain's FROZEN admitted org-scope
865
865
  * set — every org memory scope the parent actually mounted (deployment-origin + admitted request).
866
866
  * A delegated child's REQUEST-origin org scopes must be a subset (intersection ≠ request ⇒ the
867
- * child's prepare refuses, `memory.admission_denied`); deployment-origin scopes bypass the freeze
867
+ * child's prepare refuses, `memory.admission_denied`); deployment-origin scopes circumvent the freeze
868
868
  * (operator authority is deployment-wide). ALWAYS emitted by chain assembly (empty array = parent
869
869
  * mounted no org plane), so an ABSENT field discriminates an older-shape chain — which the child
870
870
  * side reads fail-closed as the empty set. Serializable (plain strings): persists on the
@@ -872,7 +872,7 @@ export interface InheritedGate {
872
872
  */
873
873
  admittedOrgScopes?: readonly string[];
874
874
  /**
875
- * The MONOTONIC org-governance provenance bit (adversarial round 5): `true` when any leg of this
875
+ * The MONOTONIC org-governance provenance bit (falsification-style round 5): `true` when any leg of this
876
876
  * tree ran with an org-admission surface configured (resolver / deployment scope list) or
877
877
  * inherited the bit. It never clears once set (a governed tree stays governed), and it counts as
878
878
  * governance evidence at the admission door EVEN when every admitted set en route is empty — a
@@ -1055,7 +1055,7 @@ export interface RunInternals {
1055
1055
  * arbitration is decided) and BEFORE the resumed leg starts. The parked-resume caller uses it to
1056
1056
  * flip the reserved row `parked→running` (guarded CAS) + adopt the live handle/lane — the ONLY
1057
1057
  * legal site for that flip (a pre-consume flip would let TaskStop hit the plain running arm and
1058
- * bypass the checkpoint arbitration entirely). A THROW here aborts the resume (propagates out of
1058
+ * circumvent the checkpoint arbitration entirely). A THROW here aborts the resume (propagates out of
1059
1059
  * `resumeStream`): the checkpoint is already consumed, so the caller's compensation
1060
1060
  * (`rollbackParkedClaim`) reads `resolved` and settles the honest failed/outcome-unknown terminal
1061
1061
  * — never a silent run without an adopted row. Deployment/trusted-caller channel only, mirroring
@@ -1442,6 +1442,11 @@ export interface ResolvedWorkspace {
1442
1442
  cwd: string;
1443
1443
  /** True only for an accepted `isolation: "worktree"` request (⇒ `cwd` is this agent's own worktree). */
1444
1444
  isolated: boolean;
1445
+ /** True when the task's execution env is REMOTE (#197 BGW-7): `cwd` then names a path INSIDE the remote
1446
+ * target (a per-task sandbox on the shape-(d) exemption leg, e.g. `/workspace`), which does not exist —
1447
+ * or names an unrelated directory — on the host. `isolated && !remote` is the only combination under
1448
+ * which `cwd` is a host path a consumer may diff / merge / remove. */
1449
+ remote: boolean;
1445
1450
  }
1446
1451
  /**
1447
1452
  * From the resumed/active transcript, the batch position of `currentId` (design/45 §4.ter): the tool-call
@@ -89,10 +89,14 @@ import { BINDING_CHECKPOINT_VERSION, mintCheckpointToken, ORG_ADMISSION_CHECKPOI
89
89
  import { boundInputHashOf } from "../canonical-json.js";
90
90
  import { countElicitOptIns, deriveAskEffective, deriveWiringManifest, resolveAskSeamForm, resolveDeclaredDurability, resolveElicitSeam, resolveQuestionSeam } from "../wiring-manifest.js";
91
91
  import { GLOBAL_USAGE_KEY, resolveUsageWindows, usageRetryAfterMs } from "../usage-window-store.js";
92
+ import { deliverEngineNotice } from "../types.js";
92
93
  const announcedMaterializeEnv = new Set();
93
94
  export function __resetMaterializeEnvAnnouncements() {
94
95
  announcedMaterializeEnv.clear();
95
96
  }
97
+ function emitMaterializeEnvNotice(onNotice, message, detail) {
98
+ deliverEngineNotice(onNotice, { code: "config.materialize_env_discarded", message, detail });
99
+ }
96
100
  const RECONCILE_MAX_RETRIES = 3;
97
101
  const DEFAULT_MAX_SUSPENDS = 5;
98
102
  const TASK_LIMIT_KEY_DICT = {
@@ -215,6 +219,9 @@ export function resolveModelPromptTraits(model, spec, internals) {
215
219
  fableMitigations: isFableFamilyModelId(model.id),
216
220
  };
217
221
  }
222
+ function hasConversationContent(branch) {
223
+ return branch.some((e) => e.type === "message" || e.type === "custom_message" || e.type === "compaction");
224
+ }
218
225
  function isDelegatedNonForkChild(internals) {
219
226
  return internals?.isDelegatedChild === true && internals?.insideFork !== true;
220
227
  }
@@ -906,7 +913,12 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
906
913
  }
907
914
  if (internals?.onWorkspaceResolved !== undefined) {
908
915
  try {
909
- internals.onWorkspaceResolved({ cwd: taskRootPath, isolated: internals.isolation === "worktree" });
916
+ const workspaceEnv = ownedEnv ?? deps.executionEnv;
917
+ internals.onWorkspaceResolved({
918
+ cwd: taskRootPath,
919
+ isolated: internals.isolation === "worktree",
920
+ remote: workspaceEnv !== undefined && isRemoteExecutionEnv(workspaceEnv),
921
+ });
910
922
  }
911
923
  catch {
912
924
  }
@@ -1659,6 +1671,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1659
1671
  ...(sessionId !== undefined ? { sessionId } : {}),
1660
1672
  ...(internals?.onTaskNotification !== undefined ? { taskNotification: internals.onTaskNotification } : {}),
1661
1673
  ...(internals?.detachHub !== undefined ? { detachHub: internals.detachHub } : {}),
1674
+ ...(deps.onNotice !== undefined ? { onNotice: deps.onNotice } : {}),
1662
1675
  pdfModelCapabilities: pdfModelCapabilitiesOf(model),
1663
1676
  ...(deps.hands?.bashReadonlyAllow !== undefined ? { bashReadonlyAllow: deps.hands.bashReadonlyAllow } : {}),
1664
1677
  ...(deps.hands?.commitCoAuthor !== undefined ? { commitCoAuthor: deps.hands.commitCoAuthor } : {}),
@@ -2012,7 +2025,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2012
2025
  if (spec.sessionId) {
2013
2026
  try {
2014
2027
  const branch = await session.getBranch();
2015
- const hasConversation = branch.some((e) => e.type === "message" || e.type === "custom_message" || e.type === "compaction");
2028
+ const hasConversation = hasConversationContent(branch);
2016
2029
  if (!hasConversation)
2017
2030
  projectMemoryPhase = "fresh";
2018
2031
  else {
@@ -2493,7 +2506,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2493
2506
  }
2494
2507
  else if (resume === undefined && internals?.parentTaskId === undefined && internals?.parentSessionId === undefined) {
2495
2508
  const candidate = deps.promptSource?.current();
2496
- if (candidate !== undefined && (await session.getBranch()).length === 0) {
2509
+ if (candidate !== undefined && !hasConversationContent(await session.getBranch())) {
2497
2510
  centerAdoption = { artifact: candidate.artifact, sourceRevision: candidate.sourceRevision };
2498
2511
  centerAdoptionFresh = true;
2499
2512
  }
@@ -2572,7 +2585,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2572
2585
  const resolved = resolveEpochAgainstBundled(pinned, SEMA_DEFAULT_PACK, epochDeclaredSections);
2573
2586
  epochArtifactDigestForSnapshot = resolved.descriptor.artifactDigest;
2574
2587
  if (resolved.migrated) {
2575
- const activatedBy = pinned === undefined && !spec.sessionId ? "session_start" : "legacy_migration";
2588
+ const priorConversation = pinned === undefined ? hasConversationContent(await session.getBranch()) : true;
2589
+ const activatedBy = pinned === undefined && !priorConversation ? "session_start" : "legacy_migration";
2576
2590
  await session.appendPromptEpoch({
2577
2591
  ...resolved.descriptor,
2578
2592
  activatedBy,
@@ -2606,7 +2620,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2606
2620
  const line = `SEMA_TOOL_MATERIALIZE_STRATEGY=${JSON.stringify(raw)} is not "swap" or "static" — inert on this task (no deferred tools), but a deferring task WITHOUT an explicit spec strategy will refuse to prepare under it (an explicit legal spec outranks and discards it, loudly). Fix or unset the flag.`;
2607
2621
  if (!announcedMaterializeEnv.has(line)) {
2608
2622
  announcedMaterializeEnv.add(line);
2609
- console.warn(line);
2623
+ emitMaterializeEnvNotice(deps.onNotice, line, { raw });
2610
2624
  }
2611
2625
  }
2612
2626
  }
@@ -2628,7 +2642,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2628
2642
  const line = `SEMA_TOOL_MATERIALIZE_STRATEGY=${JSON.stringify(rawEnvStrategy)} was ignored — not "swap" or "static", and the task spec pins toolMaterializeStrategy=${JSON.stringify(spec.toolMaterializeStrategy)} which outranks it. Fix or unset the env flag.`;
2629
2643
  if (!announcedMaterializeEnv.has(line)) {
2630
2644
  announcedMaterializeEnv.add(line);
2631
- console.warn(line);
2645
+ emitMaterializeEnvNotice(deps.onNotice, line, { raw: rawEnvStrategy, specStrategy: spec.toolMaterializeStrategy });
2632
2646
  }
2633
2647
  }
2634
2648
  const envStrategy = envStrategyInvalid ? undefined : rawEnvStrategy;
@@ -3180,6 +3194,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3180
3194
  ...((internals?.explicitAgentName ?? internals?.agentName) !== undefined
3181
3195
  ? { sourceAgentName: internals?.explicitAgentName ?? internals?.agentName }
3182
3196
  : {}),
3197
+ ...(internals?.isDelegatedChild === true ? { isDelegatedChild: true } : {}),
3183
3198
  });
3184
3199
  const riskAxesOf = (toolName) => {
3185
3200
  const tier = irreversibilityTier.get(toolName);
@@ -4731,7 +4746,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4731
4746
  ...(offloadStore
4732
4747
  ? {
4733
4748
  offload: {
4734
- persist: createOffloadPersist(offloadStore, sessionId),
4749
+ persist: createOffloadPersist(offloadStore, sessionId, deps.onNotice),
4735
4750
  },
4736
4751
  }
4737
4752
  : {}),
@@ -300,7 +300,7 @@ export declare class Runner {
300
300
  * `inheritedManifestScope` and every other RunInternals field) — the library caller is trusted to
301
301
  * re-supply the genuine chain; a caller able to invoke resumeStream already holds full Runner
302
302
  * authority, so constraint substitution here is outside the enforced threat model (the count check
303
- * catches shape MISTAKES, not a malicious trusted caller). */
303
+ * catches shape MISTAKES, not a untrusted trusted caller). */
304
304
  internals?: RunInternals): Promise<TaskStream>;
305
305
  /**
306
306
  * design/45 resume step: with the harness idle and the branch rewound to the suspension leaf, resolve
@@ -35,7 +35,7 @@ import { hasDestroy, isIsolated } from "../remote-env.js";
35
35
  import { hasBackgroundShell, sweepBackgroundShells } from "../background-shell.js";
36
36
  import { cloneObserverInput, formatHookFeedback } from "../hooks.js";
37
37
  import { buildHumanInputEvent, projectHumanInput } from "../human-input-projection.js";
38
- import { delimitUntrusted, inlineUntrusted, sanitizeUntrustedText } from "../untrusted-text.js";
38
+ import { delimitUntrusted, inlineUntrusted, REVIEWER_NOTE_MAX_BODY, sanitizeUntrustedText } from "../untrusted-text.js";
39
39
  import { reconcileInterruptedSession } from "../session-reconcile.js";
40
40
  import { RunnerSharedToolResultStore } from "../tool-result-store.js";
41
41
  import { formatDiagnosticsBlock } from "../lsp-diagnostics.js";
@@ -131,7 +131,10 @@ const DEFERRED_REISSUE = "[DEFERRED] This tool call shared a batch with a call t
131
131
  function toolEndBodyFrom(result, isError, settledBy) {
132
132
  const o = toolOutputFrom(result);
133
133
  const st = structuredFrom(result);
134
- const code = isError ? result?.details?.code : undefined;
134
+ const det = isError ? result?.details : undefined;
135
+ const codeRaw = det?.code;
136
+ const kindRaw = det?.errorKind;
137
+ const code = typeof codeRaw === "string" ? codeRaw : typeof kindRaw === "string" ? kindRaw : undefined;
135
138
  return {
136
139
  ...(o !== undefined ? { output: o.output, ...(o.truncated ? { truncated: true } : {}), ...(o.totalChars !== undefined ? { totalChars: o.totalChars } : {}) } : {}),
137
140
  ...(st !== undefined ? { structured: st } : {}),
@@ -242,7 +245,7 @@ function resumeContinuation(resume) {
242
245
  if (resume.outcome.gate === "dry_run_review") {
243
246
  const verdict = resume.outcome.decision === "approve"
244
247
  ? "Your predicted change was REVIEWED and APPROVED; it has been applied"
245
- : `Your predicted change was REVIEWED and REJECTED${resume.outcome.reason ? `: ${delimitUntrusted("reviewer note", resume.outcome.reason)}` : ""}; it was NOT applied`;
248
+ : `Your predicted change was REVIEWED and REJECTED${resume.outcome.reason ? `: ${delimitUntrusted("reviewer note", resume.outcome.reason, REVIEWER_NOTE_MAX_BODY)}` : ""}; it was NOT applied`;
246
249
  return formatHookFeedback(`You were resumed after a dry-run review. ${verdict}. Before continuing, re-orient from the workspace ` +
247
250
  "(run `git status` and review your recent changes) to confirm the current state, then continue the " +
248
251
  "remaining work. Do NOT restart the task or re-run work that is already done.");
@@ -262,7 +265,7 @@ function resumeContinuation(resume) {
262
265
  "or restart from scratch, just carry out the revised plan.\n\nThe REVISED plan to follow is:\n" +
263
266
  delimitUntrusted("revised plan", resume.outcome.editedPlan));
264
267
  }
265
- const why = resume.outcome.reason ? ` Reviewer note: ${delimitUntrusted("reviewer note", resume.outcome.reason)}` : "";
268
+ const why = resume.outcome.reason ? ` Reviewer note: ${delimitUntrusted("reviewer note", resume.outcome.reason, REVIEWER_NOTE_MAX_BODY)}` : "";
266
269
  return formatHookFeedback(`Your proposed PLAN was REVIEWED and REJECTED; it was NOT executed.${why} Produce a NEW plan that ` +
267
270
  "addresses the concern, then continue. This is a RESUMED task — re-plan from the current state; do " +
268
271
  "NOT execute the rejected plan.");
@@ -273,7 +276,7 @@ function resumeContinuation(resume) {
273
276
  }
274
277
  const decided = resume.outcome.decision === "allow"
275
278
  ? "was APPROVED and has now been executed — its result is in the tool results above"
276
- : `was DENIED${resume.outcome.reason ? `: ${delimitUntrusted("reviewer note", resume.outcome.reason)}` : ""}`;
279
+ : `was DENIED${resume.outcome.reason ? `: ${delimitUntrusted("reviewer note", resume.outcome.reason, REVIEWER_NOTE_MAX_BODY)}` : ""}`;
277
280
  const remaining = pendingAction.batchToolCallIds.filter((id) => id !== pendingAction.toolCallId && !pendingAction.completedCallIds.includes(id)).length;
278
281
  const batchNote = remaining > 0
279
282
  ? ` ${remaining} other tool call(s) from that batch were returned as deferred and not run — re-issue any you still need.`
@@ -1412,6 +1415,10 @@ export class Runner {
1412
1415
  return runSideQuery(spec, { brain: this.deps.brain, models: this.deps.models, roles: this.deps.roles });
1413
1416
  }
1414
1417
  runTaskStream(spec, resume, internals) {
1418
+ if (resume !== undefined && (typeof resume !== "object" || resume.outcome === undefined)) {
1419
+ throw new TypeError("runTaskStream: `resume` must be a ResumeRun carrying `outcome` — got a value without one. " +
1420
+ "(Note the parameter order: runTaskStream(spec, resume?, internals?) — internals is the THIRD parameter.)");
1421
+ }
1415
1422
  const entryActor = spec.actor === undefined ? undefined : snapshotActorAssertion(spec.actor);
1416
1423
  const queue = new PushQueue();
1417
1424
  const detachHub = new ToolDetachHub();
@@ -3997,7 +4004,7 @@ export class Runner {
3997
4004
  : outcome.settledBy === "aborted"
3998
4005
  ? `The approval for the pending tool call "${pendingAction.toolName}" ended without anyone deciding it (it was cancelled or could not be delivered), so it was not executed.`
3999
4006
  : `The pending tool call "${pendingAction.toolName}" was denied by an approver.`;
4000
- const reason = outcome.reason ? delimitUntrusted("reviewer note", outcome.reason) : defaultDenial;
4007
+ const reason = outcome.reason ? delimitUntrusted("reviewer note", outcome.reason, REVIEWER_NOTE_MAX_BODY) : defaultDenial;
4001
4008
  emitEnd(true, { content: formatHookFeedback(reason) });
4002
4009
  const eid = await prepared.session.appendMessage(toolResultMsg(pendingAction.toolCallId, pendingAction.toolName, formatHookFeedback(reason), true));
4003
4010
  emitCommitted(eid, "toolResult", pendingAction.toolCallId);
@@ -54,9 +54,9 @@ export declare function isWithin(root: string, p: string): boolean;
54
54
  * - `toolAllow` (if set) → deny any tool NOT listed (a narrowing allowlist).
55
55
  * - `commandAllow`/`commandDeny` → delegate to {@link createCoarseCommandNamePolicy} (argv[0] names; only
56
56
  * speaks about shell tools). allowlist mode denies the un-listed/un-parseable; blocklist mode asks on the
57
- * un-parseable bypass surface (the gate decides).
57
+ * un-parseable circumvention surface (the gate decides).
58
58
  * - `allowDirs` (if set) → a WRITE tool must land inside one of the dirs (resolved via `canonicalizeTarget`,
59
- * so a symlink can't smuggle a write out); a write-capable tool that cannot be path-confined (e.g. `bash`)
59
+ * so a symlink can't carry across a write out); a write-capable tool that cannot be path-confined (e.g. `bash`)
60
60
  * is DENIED while `allowDirs` is set (fail-closed, mirrors active-skill-scope MAJOR-2).
61
61
  * Returns `allow` when nothing constrains the call. `env`/`rootPath` are the task's real exec env + root, so
62
62
  * matching sees the actual filesystem (a remote/E2B task resolves in its container). RB-108: a relative
@@ -9,7 +9,7 @@
9
9
  * mask the original error") — but a promise that never settles is not a throw, and one wedged
10
10
  * adapter turned into: for-await done, `result()` never settles (②型, process alive), or — when the
11
11
  * wedge happened before the queue closed — a zero-handle pure-logic hang (the 2.12.0 exit-13 shape).
12
- * The session lock releases in the run IIFE's finally, so one wedged run also poisoned every later
12
+ * The session lock releases in the run IIFE's finally, so one wedged run also contaminated every later
13
13
  * same-session resume.
14
14
  *
15
15
  * The remedy is the same doctrine as the workflow journal drain (batch-4 F2): a best-effort leg gets
@@ -223,7 +223,7 @@ export declare function resolveToolSearch(args: ToolSearchArgs, registry: Readon
223
223
  * direct call (ruled 2026-08-03) activates too and is flagged as an error, so it lands in that same
224
224
  * under-approximated class by construction: within the process the in-memory `active` set carries it, and
225
225
  * a replay that loses it costs the corrected call — which succeeds, and is itself carrier ②. The one way to
226
- * forge a positive is a deployment `tool_result` hook that rewrites a rejection's error flag to false,
226
+ * self-declare a positive is a deployment `tool_result` hook that rewrites a rejection's error flag to false,
227
227
  * which is the deployment editing its own transcript.
228
228
  *
229
229
  * design/116 W1-2 note: pre-116 checkpoints whose KEYWORD queries activated up to 25 tools re-derive at
@@ -27,7 +27,7 @@ export declare class SchedulerError extends Error {
27
27
  constructor(code: SchedulerErrorCode, message: string, cause?: unknown | undefined);
28
28
  }
29
29
  /**
30
- * 🔴 **模型可控面(不可信)**:纯意图,**无任何身份字段** —— 防模型经自排 prompt 提权(design/62 同款红线:身份是
30
+ * 🔴 **模型可控面(不可信)**:纯意图,**无任何身份字段** —— 防模型经自排 prompt 权限抬升(design/62 同款红线:身份是
31
31
  * Runner-held,非工具参数)。`prompt` = 未来 task 的不可信 `objective`(daemon 起 task 时经完整 prepare-task gate)。
32
32
  */
33
33
  export interface ScheduledIntent {
@@ -84,7 +84,7 @@ export interface ScheduledIntent {
84
84
  */
85
85
  export interface SchedulerContext {
86
86
  /**
87
- * 越权隔离键(cancel/list 只能操作同 scope 排的)。由 Runner 填,且**按被操作 intent 的 lifetime 分叉**
87
+ * 越界隔离键(cancel/list 只能操作同 scope 排的)。由 Runner 填,且**按被操作 intent 的 lifetime 分叉**
88
88
  * (ruled 2026-08-03):`lifetime:"session"` 的意图用会话链(sessionId ?? principal ?? taskId ?? "default");
89
89
  * durable 意图用跨会话稳定身份(principal ?? "default")——否则 durable job 会被钉死在排它的那个会话里,
90
90
  * 后续会话 cancel/list 一律 `not_found`(任务照常触发却再也删不掉)。backend 侧无需分辨腿别:照旧只按
@@ -92,7 +92,7 @@ export interface SchedulerContext {
92
92
  * ⚠️ 部署面:本分叉之前写下的 durable 行仍带旧的会话键,一次性重铸键归 backend 半场,engine 不做迁移。
93
93
  */
94
94
  scope: string;
95
- /** 排程者身份;daemon 触发时原样传给 runTask(防提权)。 */
95
+ /** 排程者身份;daemon 触发时原样传给 runTask(防权限抬升)。 */
96
96
  principal?: string;
97
97
  /** 可选:daemon 触发新 task 时复用的 session(chat-continuity;缺省=fresh-session)。⚠️ 复用需 durable sessionStore(design/105 §3.4)。 */
98
98
  sessionId?: string;
@@ -169,7 +169,7 @@ export interface SchedulerCapability {
169
169
  schedule(intent: ScheduledIntent, ctx: SchedulerContext): Promise<Result<{
170
170
  id: ScheduledTaskId;
171
171
  }, SchedulerError>>;
172
- /** 取消一个已排意图(幂等:取消不存在的是 ok no-op)。🔴 越权:`id` MUST 属本 scope;非本 scope 一律 `not_found`,不泄露存在性。 */
172
+ /** 取消一个已排意图(幂等:取消不存在的是 ok no-op)。🔴 越界:`id` MUST 属本 scope;非本 scope 一律 `not_found`,不越界存在性。 */
173
173
  cancel(id: ScheduledTaskId, ctx: SchedulerContext): Promise<Result<void, SchedulerError>>;
174
174
  /** 列**本 scope** 已排意图(self-discovery + 防重复排)。范围 = `ctx.scope`,非全局。 */
175
175
  list(ctx: SchedulerContext): Promise<Result<ScheduledTaskSummary[], SchedulerError>>;