@sema-agent/server 7.4.0 → 7.5.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 (133) hide show
  1. package/USAGE.md +43 -0
  2. package/dist/approval-card.d.ts +15 -3
  3. package/dist/approval-card.js +41 -7
  4. package/dist/approval-reconciler.d.ts +108 -11
  5. package/dist/approval-reconciler.js +146 -19
  6. package/dist/boot/coordinators.js +10 -2
  7. package/dist/boot/org-memory.d.ts +6 -0
  8. package/dist/boot/org-memory.js +1 -1
  9. package/dist/boot/reapers.d.ts +2 -0
  10. package/dist/boot/reapers.js +11 -4
  11. package/dist/boot/resolve-spec.d.ts +3 -2
  12. package/dist/boot/resolve-spec.js +132 -32
  13. package/dist/boot/runner-deps.d.ts +23 -1
  14. package/dist/boot/runner-deps.js +8 -11
  15. package/dist/boot/workflow-orchestration.d.ts +8 -3
  16. package/dist/boot/workflow-orchestration.js +23 -1
  17. package/dist/config-center/apply-effective.js +33 -10
  18. package/dist/config-types.d.ts +24 -8
  19. package/dist/config.d.ts +6 -1
  20. package/dist/config.js +56 -11
  21. package/dist/env-facts.d.ts +3 -1
  22. package/dist/env-facts.js +3 -1
  23. package/dist/fleet/fleet-bus.d.ts +6 -1
  24. package/dist/fleet/fleet-bus.js +25 -3
  25. package/dist/governance-ask-marks.d.ts +31 -0
  26. package/dist/governance-ask-marks.js +122 -0
  27. package/dist/hooks/hook-runner.d.ts +28 -0
  28. package/dist/hooks/hook-runner.js +149 -25
  29. package/dist/http/routes/diagnostics.js +10 -5
  30. package/dist/http/routes/memory-policy.d.ts +2 -1
  31. package/dist/http/routes/memory-policy.js +77 -13
  32. package/dist/http/routes/runs.js +1 -1
  33. package/dist/http/routes/tasks.js +59 -22
  34. package/dist/http/server.d.ts +5 -0
  35. package/dist/http/server.js +23 -12
  36. package/dist/http/wire-types.d.ts +7 -2
  37. package/dist/main.js +17 -5
  38. package/dist/observability/fail-open.d.ts +13 -2
  39. package/dist/observability/fail-open.js +15 -4
  40. package/dist/observability/prompt-manifest.d.ts +5 -1
  41. package/dist/orchestration/workflow-notify-journal.d.ts +57 -1
  42. package/dist/orchestration/workflow-notify-journal.js +137 -32
  43. package/dist/parked-decide.js +9 -4
  44. package/dist/plugins/approval-ask-store-memory.d.ts +2 -2
  45. package/dist/plugins/approval-ask-store-memory.js +3 -2
  46. package/dist/plugins/approval-ask-store-sql.d.ts +27 -5
  47. package/dist/plugins/approval-ask-store-sql.js +9 -2
  48. package/dist/plugins/background-shell-support.d.ts +1 -1
  49. package/dist/plugins/background-shell-support.js +2 -2
  50. package/dist/plugins/checkpoint-store-sql.d.ts +62 -6
  51. package/dist/plugins/checkpoint-store-sql.js +71 -11
  52. package/dist/plugins/local-checkpoint-store.d.ts +20 -1
  53. package/dist/plugins/local-checkpoint-store.js +19 -0
  54. package/dist/plugins/mailbox-store-sql.d.ts +4 -10
  55. package/dist/plugins/mailbox-store-sql.js +57 -4
  56. package/dist/runs.d.ts +8 -0
  57. package/dist/runs.js +15 -2
  58. package/dist/runtime-governance.d.ts +18 -0
  59. package/dist/runtime-governance.js +90 -3
  60. package/dist/task-settings.d.ts +3 -9
  61. package/dist/task-settings.js +16 -13
  62. package/dist/tool-approval.d.ts +33 -6
  63. package/dist/tool-approval.js +80 -23
  64. package/dist/trace/core-keyset-guard.d.ts +17 -3
  65. package/package.json +3 -3
  66. package/dist/boot/lexical-path-env.d.ts +0 -10
  67. package/dist/boot/lexical-path-env.js +0 -88
  68. package/dist/capabilities/oa-tools.d.ts +0 -15
  69. package/dist/capabilities/oa-tools.js +0 -54
  70. package/dist/finance/cost-taxonomy.d.ts +0 -34
  71. package/dist/finance/cost-taxonomy.js +0 -26
  72. package/dist/plugins/approval-store-sql.d.ts +0 -116
  73. package/dist/plugins/approval-store-sql.js +0 -151
  74. package/dist/plugins/file-workflow-journal-store.d.ts +0 -12
  75. package/dist/plugins/file-workflow-journal-store.js +0 -12
  76. package/dist/plugins/pg-approval-store.d.ts +0 -9
  77. package/dist/plugins/pg-approval-store.js +0 -9
  78. package/dist/plugins/pg-breaker-state.d.ts +0 -8
  79. package/dist/plugins/pg-breaker-state.js +0 -8
  80. package/dist/plugins/pg-checkpoint-store.d.ts +0 -10
  81. package/dist/plugins/pg-checkpoint-store.js +0 -10
  82. package/dist/plugins/pg-file-snapshot-store.d.ts +0 -8
  83. package/dist/plugins/pg-file-snapshot-store.js +0 -8
  84. package/dist/plugins/pg-image-bake.d.ts +0 -12
  85. package/dist/plugins/pg-image-bake.js +0 -11
  86. package/dist/plugins/pg-image-index.d.ts +0 -12
  87. package/dist/plugins/pg-image-index.js +0 -11
  88. package/dist/plugins/pg-outcome-ledger.d.ts +0 -12
  89. package/dist/plugins/pg-outcome-ledger.js +0 -11
  90. package/dist/plugins/pg-resume-anchor-store.d.ts +0 -7
  91. package/dist/plugins/pg-resume-anchor-store.js +0 -7
  92. package/dist/plugins/pg-run-store.d.ts +0 -9
  93. package/dist/plugins/pg-run-store.js +0 -9
  94. package/dist/plugins/pg-session-policy-store.d.ts +0 -7
  95. package/dist/plugins/pg-session-policy-store.js +0 -7
  96. package/dist/plugins/pg-session-store.d.ts +0 -12
  97. package/dist/plugins/pg-session-store.js +0 -12
  98. package/dist/plugins/pg-tool-result-store.d.ts +0 -9
  99. package/dist/plugins/pg-tool-result-store.js +0 -9
  100. package/dist/plugins/pg-workflow-journal-store.d.ts +0 -9
  101. package/dist/plugins/pg-workflow-journal-store.js +0 -9
  102. package/dist/plugins/pg-workflow-run-store.d.ts +0 -9
  103. package/dist/plugins/pg-workflow-run-store.js +0 -9
  104. package/dist/plugins/tidb-approval-store.d.ts +0 -8
  105. package/dist/plugins/tidb-approval-store.js +0 -8
  106. package/dist/plugins/tidb-breaker-state.d.ts +0 -7
  107. package/dist/plugins/tidb-breaker-state.js +0 -7
  108. package/dist/plugins/tidb-checkpoint-store.d.ts +0 -9
  109. package/dist/plugins/tidb-checkpoint-store.js +0 -9
  110. package/dist/plugins/tidb-file-snapshot-store.d.ts +0 -8
  111. package/dist/plugins/tidb-file-snapshot-store.js +0 -8
  112. package/dist/plugins/tidb-image-bake.d.ts +0 -12
  113. package/dist/plugins/tidb-image-bake.js +0 -11
  114. package/dist/plugins/tidb-image-index.d.ts +0 -12
  115. package/dist/plugins/tidb-image-index.js +0 -11
  116. package/dist/plugins/tidb-outcome-ledger.d.ts +0 -12
  117. package/dist/plugins/tidb-outcome-ledger.js +0 -12
  118. package/dist/plugins/tidb-resume-anchor-store.d.ts +0 -7
  119. package/dist/plugins/tidb-resume-anchor-store.js +0 -7
  120. package/dist/plugins/tidb-run-store.d.ts +0 -10
  121. package/dist/plugins/tidb-run-store.js +0 -9
  122. package/dist/plugins/tidb-session-policy-store.d.ts +0 -7
  123. package/dist/plugins/tidb-session-policy-store.js +0 -7
  124. package/dist/plugins/tidb-tool-result-store.d.ts +0 -8
  125. package/dist/plugins/tidb-tool-result-store.js +0 -10
  126. package/dist/plugins/tidb-workflow-journal-store.d.ts +0 -9
  127. package/dist/plugins/tidb-workflow-journal-store.js +0 -9
  128. package/dist/plugins/tidb-workflow-run-store.d.ts +0 -10
  129. package/dist/plugins/tidb-workflow-run-store.js +0 -10
  130. package/dist/plugins/workflow-journal-limits.d.ts +0 -12
  131. package/dist/plugins/workflow-journal-limits.js +0 -12
  132. package/dist/sema-registry.d.ts +0 -41
  133. package/dist/sema-registry.js +0 -40
@@ -65,6 +65,24 @@ export const MAX_HOOK_HTTP_HEADERS = 32;
65
65
  export const MAX_HOOK_OUTPUT_BYTES = 1024 * 1024;
66
66
  /** 喂给模型的 deny 原因/additionalContext 的单条长度上限(字符)。 */
67
67
  const MAX_HOOK_FEEDBACK_CHARS = 4_096;
68
+ /**
69
+ * 一次事件里所有 hook 上下文片段**聚合后**喂给模型的总量上限(字符)。
70
+ *
71
+ * 为什么单条帽不够:一次事件最多 {@link MAX_HOOK_ENTRIES_PER_EVENT} 条(parseHooksConfig 的广度闸),
72
+ * 每条最多推两段(`decision:"block"` 的 reason + `hookSpecificOutput.additionalContext`),
73
+ * {@link composeHooks} 再把部署槽与 task 槽的**成品**拼一次 —— 单条 4096 相乘后一次注入可达 ~256KB,
74
+ * 而这些字符是**逐次工具调用**进模型可见文本的(core `hooks.js` 的 preToolContext / `prepare-task.js`
75
+ * 的 tool_result 追加 / `runtask.js` 的 Stop follow-up),不是日志。
76
+ *
77
+ * 帽值取 10_000 的依据:模型可见文本在本栈的既有预算量纲就是 10k —— core `truncateError`
78
+ * (`src/core/tool-errors.ts`)的 10k 中截,逐字锚 CC 序列化器 `A7e` 的 `1e4`;#181 战役里 core 还要给
79
+ * `beforeToolCall` block reason 补一道同值兜底闸。取同一量级 = 「一次 hook 注入 ≤ 一份错误文本预算」,
80
+ * 且 4096 的单条帽保证**前两条**钩子的话仍整段进得去(截断是配置广度的代价,不是常态)。
81
+ */
82
+ export const MAX_HOOK_CONTEXT_TOTAL_CHARS = 10_000;
83
+ /** 帽满时仍值得保留的最短残段(字符)。比这更短的尾巴不是信息:模型会把半句话读成完整指令,
84
+ * 宁可整条落进"省略"计数。 */
85
+ const MIN_HOOK_CONTEXT_PARTIAL_CHARS = 256;
68
86
  /** PostToolUse payload 里 tool_response 文本投影的上限(字符)——工具结果可能是 MB 级。 */
69
87
  const MAX_TOOL_RESPONSE_CHARS = 32_768;
70
88
  /** stdin payload 里 tool_input JSON 投影的上限(字符;巨大 tool_input 经 JSON.stringify 造成
@@ -233,6 +251,104 @@ const DEFAULT_AGENT_HOOK_TIMEOUT_SECONDS = 120;
233
251
  function clip(s, n) {
234
252
  return s.length > n ? `${s.slice(0, n)}…(${s.length})` : s;
235
253
  }
254
+ /**
255
+ * hook 上下文片段 → 喂给模型的单串。**所有** `additionalContext` 聚合点的唯一属主(此前是同一个
256
+ * `contexts.join("\n")` 表达式抄在二十余处,总量帽无处可挂 —— 那正是漂移成因)。
257
+ *
258
+ * 三条语义,都是承重的:
259
+ * 1. **顺序保留、只截尾**:先来的钩子先说话;一旦装不下就停,后面的条目一律不再挤进来(哪怕更短)。
260
+ * 按长度重排会让"第 3 条钩子的话"随别人的长度忽隐忽现,排障时无从复现。
261
+ * 2. **截断可见**:尾巴挂一行标记说明省了几条、丢了多少字符。静默丢弃会让 hook 作者以为自己的
262
+ * context 生效了,而模型那头根本没见过 —— 这类"以为配好了"的缺席比长文本本身更贵。
263
+ * 3. **标记不计预算**:与 core `truncateError` / CC `A7e` 同姿势(marker 不占那 10k),否则帽值的
264
+ * 含义会随标记文案长度漂。
265
+ */
266
+ export function buildHookContext(parts) {
267
+ const present = parts.filter((p) => p.length > 0).map(readHookContextPart);
268
+ if (present.length === 0)
269
+ return undefined;
270
+ const kept = [];
271
+ let used = 0;
272
+ let truncating = false;
273
+ let messages = 0;
274
+ let omitted = 0;
275
+ let droppedChars = 0;
276
+ for (const part of present) {
277
+ // 内层的账先并进来(标记读回,见 {@link readHookContextPart}),再谈这一轮自己丢了什么。
278
+ messages += part.messages;
279
+ omitted += part.omitted;
280
+ droppedChars += part.dropped;
281
+ if (!truncating) {
282
+ const sep = kept.length > 0 ? 1 : 0; // join 的 "\n" 也占预算
283
+ const room = MAX_HOOK_CONTEXT_TOTAL_CHARS - used - sep;
284
+ if (room >= part.body.length) {
285
+ if (part.body.length > 0) {
286
+ kept.push(part.body);
287
+ used += sep + part.body.length;
288
+ }
289
+ continue;
290
+ }
291
+ truncating = true;
292
+ // 只有**单条**发言可以切尾:切了它仍是"部分在场",账目诚实(dropped 记尾巴,omitted 不变)。
293
+ // 已聚合的成品代表内层多条、正文里却没有条目边界 —— 切一刀会把第 2..N 条整个抹掉却记 0 条省略,
294
+ // 标记恰好在最常走的两层合流路上说谎(codex 复审 R4,亲核属实)。⇒ 它要么整段留,要么整条丢。
295
+ if (part.messages === 1 && room >= MIN_HOOK_CONTEXT_PARTIAL_CHARS) {
296
+ kept.push(part.body.slice(0, room));
297
+ used += sep + room;
298
+ droppedChars += part.body.length - room;
299
+ continue;
300
+ }
301
+ }
302
+ // 整条不见 = 它代表的**全部**条目都不见。内层已经报过的那几条上面加过了,这里只补差额
303
+ // ——否则一个"3 of 5"的成品被整条丢掉会记成 8 条,报出"省掉的比总数还多"这种自相矛盾的账。
304
+ omitted += part.messages - part.omitted;
305
+ droppedChars += part.body.length;
306
+ }
307
+ const body = kept.join("\n");
308
+ if (droppedChars === 0)
309
+ return body;
310
+ const marker = `[hook context truncated at ${MAX_HOOK_CONTEXT_TOTAL_CHARS} chars: ${omitted} of ${messages} hook messages omitted, ${droppedChars} characters dropped]`;
311
+ return kept.length > 0 ? `${body}\n${marker}` : marker;
312
+ }
313
+ /** 截断标记的尾锚形。帽值**逐字写进模式**(不是 `\d+`):只认"我们自己这一版才会写出的那一份",
314
+ * 别处飘来的同类文字连模式都不匹配。 */
315
+ const HOOK_CONTEXT_MARKER_RE = new RegExp(`\\n\\[hook context truncated at ${MAX_HOOK_CONTEXT_TOTAL_CHARS} chars: (\\d+) of (\\d+) hook messages omitted, (\\d+) characters dropped\\]$`);
316
+ /** 读回标记时接受的数字上界(2^42)。比任何真值大若干数量级,又小到几十条相加也绝不越过安全整数
317
+ * —— 伪造的巨数因此根本进不来,合并加法也不必每一步复验溢出。 */
318
+ const MAX_HOOK_CONTEXT_PARSED_COUNT = 2 ** 42;
319
+ /**
320
+ * 片段 → 内容 + 它自带的截断账。
321
+ *
322
+ * 为什么必须能**读回**自己写的标记:`composeHooks` 会把 {@link buildHookContext} 的成品当片段再喂一次
323
+ * (部署槽在场即走这条路 —— 是常态不是边角)。标记按内容处理会犯两桩:①切在标记中间 ⇒ 模型读到半截
324
+ * 标记;②外层重新记账 ⇒ 内层丢掉的那一万多字符凭空消失,标记数字变成谎。标记既然不计预算,读回时也
325
+ * 不能算内容;把它携带的账并进外层,函数对自己的输出就是幂等的。
326
+ *
327
+ * 🔴 这把尺子对着的是**不可信文本**(钩子想吐什么就吐什么),所以只认"我们自己才写得出的那一份":
328
+ * 帽值逐字相等(在模式里)、`dropped ≥ 1`(我们从不在零丢弃时写标记)、`omitted ≤ messages`、
329
+ * `messages ≥ 1`、数字都在 {@link MAX_HOOK_CONTEXT_PARSED_COUNT} 内。任一条不合 ⇒ **整段当普通内容**。
330
+ * 认下之后也不会有"内容静默少一行":认下 ⇒ `dropped > 0` ⇒ 输出必然带标记 —— 摘掉的那一行总是被
331
+ * **重写**回去(数字可能被合并),从不是被删掉。伪造得刚好合规只会让记账口径偏,不改任何裁决;
332
+ * 而且钩子配置在单用户闸内,伪造者就是这份账的读者本人。
333
+ */
334
+ function readHookContextPart(part) {
335
+ const m = HOOK_CONTEXT_MARKER_RE.exec(part);
336
+ if (!m)
337
+ return { body: part, messages: 1, omitted: 0, dropped: 0 };
338
+ const omitted = Number(m[1]);
339
+ const messages = Number(m[2]);
340
+ const dropped = Number(m[3]);
341
+ const sane = (n) => Number.isSafeInteger(n) && n <= MAX_HOOK_CONTEXT_PARSED_COUNT;
342
+ if (!sane(omitted) || !sane(messages) || !sane(dropped) || messages < 1 || omitted > messages || dropped < 1) {
343
+ return { body: part, messages: 1, omitted: 0, dropped: 0 };
344
+ }
345
+ return { body: part.slice(0, part.length - m[0].length), messages, omitted, dropped };
346
+ }
347
+ /** {@link buildHookContext} 的字段形(聚合点全是 `...spread` 进结果对象):空 → 不产字段。 */
348
+ function buildHookContextField(parts) {
349
+ const text = buildHookContext(parts);
350
+ return text === undefined ? {} : { additionalContext: text };
351
+ }
236
352
  /** 跑一条 command hook:sh -c,stdin=payload JSON(CC-verbatim),超时 SIGKILL,输出各限 1MiB。 */
237
353
  function runCommandHook(entry, payload, ctx) {
238
354
  return new Promise((resolve) => {
@@ -736,7 +852,7 @@ export function createTaskHooks(config, ctx) {
736
852
  if (run.code === 2) {
737
853
  // CC:exit 2 = 阻断,stderr 喂给模型
738
854
  const reason = clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS) || "blocked by a PreToolUse hook";
739
- return { action: "deny", message: reason, ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}) };
855
+ return { action: "deny", message: reason, ...buildHookContextField(contexts) };
740
856
  }
741
857
  if (run.code !== 0) {
742
858
  ctx.logger.warn("hook_command_failed", { event: "PreToolUse", code: run.code, stderr: clip(run.stderr, 300) });
@@ -750,11 +866,11 @@ export function createTaskHooks(config, ctx) {
750
866
  if (out.continue === false) {
751
867
  const reason = clip(typeof out.stopReason === "string" ? out.stopReason : "hook requested stop", MAX_HOOK_FEEDBACK_CHARS);
752
868
  // core 的 preToolUse seam 无"终止整个 run"能力(阶段一诚实近似:deny 本次调用)。
753
- return { action: "deny", message: reason, ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}) };
869
+ return { action: "deny", message: reason, ...buildHookContextField(contexts) };
754
870
  }
755
871
  if (out.decision === "block") {
756
872
  const reason = clip(typeof out.reason === "string" ? out.reason : "blocked by a PreToolUse hook", MAX_HOOK_FEEDBACK_CHARS);
757
- return { action: "deny", message: reason, ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}) };
873
+ return { action: "deny", message: reason, ...buildHookContextField(contexts) };
758
874
  }
759
875
  const hso = out.hookSpecificOutput;
760
876
  if (hso !== null && typeof hso === "object" && hso.hookEventName === "PreToolUse") {
@@ -764,7 +880,7 @@ export function createTaskHooks(config, ctx) {
764
880
  const decision = h.permissionDecision;
765
881
  if (decision === "deny") {
766
882
  const reason = clip(typeof h.permissionDecisionReason === "string" ? h.permissionDecisionReason : "denied by a PreToolUse hook", MAX_HOOK_FEEDBACK_CHARS);
767
- return { action: "deny", message: reason, ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}) };
883
+ return { action: "deny", message: reason, ...buildHookContextField(contexts) };
768
884
  }
769
885
  if (decision === "ask") {
770
886
  asked = clip(typeof h.permissionDecisionReason === "string" ? h.permissionDecisionReason : "a PreToolUse hook requires approval", MAX_HOOK_FEEDBACK_CHARS);
@@ -798,7 +914,7 @@ export function createTaskHooks(config, ctx) {
798
914
  action: "ask",
799
915
  message: asked,
800
916
  ...(rewritten ? { updatedInput: currentInput } : {}),
801
- ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}),
917
+ ...buildHookContextField(contexts),
802
918
  };
803
919
  }
804
920
  if (unknownDecision) {
@@ -814,14 +930,14 @@ export function createTaskHooks(config, ctx) {
814
930
  action: "ask",
815
931
  message: "a PreToolUse hook returned an unrecognized permissionDecision — approval required",
816
932
  ...(rewritten ? { updatedInput: currentInput } : {}),
817
- ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}),
933
+ ...buildHookContextField(contexts),
818
934
  };
819
935
  }
820
936
  if (rewritten || contexts.length > 0) {
821
937
  return {
822
938
  action: "allow",
823
939
  ...(rewritten ? { updatedInput: currentInput } : {}),
824
- ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}),
940
+ ...buildHookContextField(contexts),
825
941
  };
826
942
  }
827
943
  return undefined; // 无意见(≠ allow 短路;core 门照常裁决)
@@ -879,7 +995,8 @@ export function createTaskHooks(config, ctx) {
879
995
  }
880
996
  }
881
997
  }
882
- return contexts.length > 0 ? { additionalContext: contexts.join("\n") } : undefined;
998
+ const additionalContext = buildHookContext(contexts);
999
+ return additionalContext === undefined ? undefined : { additionalContext };
883
1000
  };
884
1001
  hooks.postToolUse = postToolUseHook;
885
1002
  }
@@ -927,7 +1044,8 @@ export function createTaskHooks(config, ctx) {
927
1044
  contexts.push(clip(h.additionalContext, MAX_HOOK_FEEDBACK_CHARS));
928
1045
  }
929
1046
  }
930
- return contexts.length > 0 ? { additionalContext: contexts.join("\n") } : undefined;
1047
+ const additionalContext = buildHookContext(contexts);
1048
+ return additionalContext === undefined ? undefined : { additionalContext };
931
1049
  };
932
1050
  hooks.postToolUseFailure = postToolUseFailureHook;
933
1051
  }
@@ -973,7 +1091,8 @@ export function createTaskHooks(config, ctx) {
973
1091
  if (out.hookSpecificOutput !== undefined)
974
1092
  ctx.logger.warn("hook_result_unsupported", { event: "PostToolBatch" });
975
1093
  }
976
- return contexts.length > 0 ? { additionalContext: contexts.join("\n") } : undefined;
1094
+ const additionalContext = buildHookContext(contexts);
1095
+ return additionalContext === undefined ? undefined : { additionalContext };
977
1096
  };
978
1097
  hooks.postToolBatch = postToolBatchHook;
979
1098
  }
@@ -989,7 +1108,7 @@ export function createTaskHooks(config, ctx) {
989
1108
  for (const { run } of singles) {
990
1109
  if (run.code === 2) {
991
1110
  const reason = clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS) || "blocked by a UserPromptSubmit hook";
992
- return { block: reason, ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}) };
1111
+ return { block: reason, ...buildHookContextField(contexts) };
993
1112
  }
994
1113
  if (run.code !== 0) {
995
1114
  ctx.logger.warn("hook_command_failed", { event: "UserPromptSubmit", code: run.code, stderr: clip(run.stderr, 300) });
@@ -1003,11 +1122,11 @@ export function createTaskHooks(config, ctx) {
1003
1122
  if (out.continue === false) {
1004
1123
  // continue:false=终止整个 run;userPromptSubmit 的 block 语义正是"任务带理由收场"——同义直映射。
1005
1124
  const reason = clip(typeof out.stopReason === "string" ? out.stopReason : "hook requested stop", MAX_HOOK_FEEDBACK_CHARS);
1006
- return { block: reason, ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}) };
1125
+ return { block: reason, ...buildHookContextField(contexts) };
1007
1126
  }
1008
1127
  if (out.decision === "block") {
1009
1128
  const reason = clip(typeof out.reason === "string" ? out.reason : "blocked by a UserPromptSubmit hook", MAX_HOOK_FEEDBACK_CHARS);
1010
- return { block: reason, ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}) };
1129
+ return { block: reason, ...buildHookContextField(contexts) };
1011
1130
  }
1012
1131
  const hso = out.hookSpecificOutput;
1013
1132
  if (hso !== null && typeof hso === "object" && hso.hookEventName === "UserPromptSubmit") {
@@ -1016,7 +1135,8 @@ export function createTaskHooks(config, ctx) {
1016
1135
  contexts.push(clip(h.additionalContext, MAX_HOOK_FEEDBACK_CHARS));
1017
1136
  }
1018
1137
  }
1019
- return contexts.length > 0 ? { additionalContext: contexts.join("\n") } : undefined;
1138
+ const additionalContext = buildHookContext(contexts);
1139
+ return additionalContext === undefined ? undefined : { additionalContext };
1020
1140
  };
1021
1141
  hooks.userPromptSubmit = userPromptSubmitHook;
1022
1142
  }
@@ -1180,7 +1300,7 @@ export function createTaskHooks(config, ctx) {
1180
1300
  return undefined;
1181
1301
  return {
1182
1302
  ...(block !== undefined ? { block } : {}),
1183
- ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}),
1303
+ ...buildHookContextField(contexts),
1184
1304
  };
1185
1305
  };
1186
1306
  hooks.stop = stopHook;
@@ -1288,7 +1408,7 @@ export function composeHooks(deployment, task) {
1288
1408
  if (r.additionalContext)
1289
1409
  contexts.push(r.additionalContext);
1290
1410
  if (r.action === "deny")
1291
- return { ...r, ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}) };
1411
+ return { ...r, ...buildHookContextField(contexts) };
1292
1412
  if (r.action === "ask")
1293
1413
  asked = r;
1294
1414
  if (r.action === "allow" && r.updatedInput !== undefined) {
@@ -1301,14 +1421,14 @@ export function composeHooks(deployment, task) {
1301
1421
  return {
1302
1422
  ...asked,
1303
1423
  ...(askedUpdated === undefined && current !== input ? { updatedInput: current } : {}),
1304
- ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}),
1424
+ ...buildHookContextField(contexts),
1305
1425
  };
1306
1426
  }
1307
1427
  if (current !== input || contexts.length > 0) {
1308
1428
  return {
1309
1429
  action: "allow",
1310
1430
  ...(current !== input ? { updatedInput: current } : {}),
1311
- ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}),
1431
+ ...buildHookContextField(contexts),
1312
1432
  };
1313
1433
  }
1314
1434
  return undefined;
@@ -1324,7 +1444,7 @@ export function composeHooks(deployment, task) {
1324
1444
  const updatedOutput = rt?.updatedOutput ?? rd?.updatedOutput;
1325
1445
  return {
1326
1446
  ...(updatedOutput ? { updatedOutput } : {}),
1327
- ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}),
1447
+ ...buildHookContextField(contexts),
1328
1448
  };
1329
1449
  };
1330
1450
  }
@@ -1333,7 +1453,8 @@ export function composeHooks(deployment, task) {
1333
1453
  const rd = d.postToolUseFailure ? await d.postToolUseFailure(toolName, input, failure, ctx) : undefined;
1334
1454
  const rt = t.postToolUseFailure ? await t.postToolUseFailure(toolName, input, failure, ctx) : undefined;
1335
1455
  const contexts = [rd?.additionalContext, rt?.additionalContext].filter((c) => typeof c === "string" && c.length > 0);
1336
- return contexts.length ? { additionalContext: contexts.join("\n") } : undefined;
1456
+ const additionalContext = buildHookContext(contexts);
1457
+ return additionalContext === undefined ? undefined : { additionalContext };
1337
1458
  };
1338
1459
  }
1339
1460
  if (d.postToolBatch || t.postToolBatch) {
@@ -1341,19 +1462,22 @@ export function composeHooks(deployment, task) {
1341
1462
  const rd = d.postToolBatch ? await d.postToolBatch(calls) : undefined;
1342
1463
  const rt = t.postToolBatch ? await t.postToolBatch(calls) : undefined;
1343
1464
  const contexts = [rd?.additionalContext, rt?.additionalContext].filter((c) => typeof c === "string" && c.length > 0);
1344
- return contexts.length ? { additionalContext: contexts.join("\n") } : undefined;
1465
+ const additionalContext = buildHookContext(contexts);
1466
+ return additionalContext === undefined ? undefined : { additionalContext };
1345
1467
  };
1346
1468
  }
1347
1469
  if (d.userPromptSubmit || t.userPromptSubmit) {
1348
1470
  out.userPromptSubmit = async (prompt) => {
1349
1471
  const rd = d.userPromptSubmit ? await d.userPromptSubmit(prompt) : undefined;
1472
+ // 短路的是**槽序**(部署拦下就不再跑 task 槽),不是预算:`additionalContext` 与 `block` 在 core
1473
+ // 是解耦两字段(见下方 stop 注),原样外送就是一条绕过总帽的道。
1350
1474
  if (rd?.block)
1351
- return rd;
1475
+ return { ...rd, ...buildHookContextField(rd.additionalContext ? [rd.additionalContext] : []) };
1352
1476
  const rt = t.userPromptSubmit ? await t.userPromptSubmit(prompt) : undefined;
1353
1477
  if (!rd && !rt)
1354
1478
  return undefined;
1355
1479
  const contexts = [rd?.additionalContext, rt?.additionalContext].filter((c) => typeof c === "string" && c.length > 0);
1356
- return { ...(rt?.block ? { block: rt.block } : {}), ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}) };
1480
+ return { ...(rt?.block ? { block: rt.block } : {}), ...buildHookContextField(contexts) };
1357
1481
  };
1358
1482
  }
1359
1483
  if (d.stop || t.stop) {
@@ -1362,12 +1486,12 @@ export function composeHooks(deployment, task) {
1362
1486
  out.stop = async (sctx) => {
1363
1487
  const rd = d.stop ? await d.stop(sctx) : undefined;
1364
1488
  if (rd?.block)
1365
- return rd;
1489
+ return { ...rd, ...buildHookContextField(rd.additionalContext ? [rd.additionalContext] : []) };
1366
1490
  const rt = t.stop ? await t.stop(sctx) : undefined;
1367
1491
  if (!rd && !rt)
1368
1492
  return undefined;
1369
1493
  const contexts = [rd?.additionalContext, rt?.additionalContext].filter((c) => typeof c === "string" && c.length > 0);
1370
- return { ...(rt?.block ? { block: rt.block } : {}), ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}) };
1494
+ return { ...(rt?.block ? { block: rt.block } : {}), ...buildHookContextField(contexts) };
1371
1495
  };
1372
1496
  }
1373
1497
  if (d.preCompact || t.preCompact) {
@@ -58,14 +58,19 @@ export function buildStaticWiringAudit(facts) {
58
58
  // park 车道的**持久性读数**(codex 交叉复审 2026-08-07 R2-F2,验真后部分采纳)。`capable` 说的是
59
59
  // 「有没有 checkpoint 店」、`effective` 说的是「这条部署形会不会真 park」—— 两者都为真时仍可能是一个
60
60
  // 重启即丢的店,那样窗到期后被 park 的审批在重启后无法赎回,而流内审批协议正是拿 park 当降级目的地。
61
- // 🔴 为什么是 **warn 而不是拒启**:本仓的 SQL/local checkpoint 店**都还没有 `durability` 声明**,core
62
- // 因此对每一个真持久部署都读出 `process_local`。据此拒启会拿一个不可信信号挡住 100% 的现存 durable
63
- // 部署 —— 那不是查出矛盾。根治在店侧补声明;在那之前,把「这个读数不可信」响亮说出来,不静默。
61
+ // **读数已转可信**(#168 件5,2026-08-07):树上三条 checkpoint 店(SQL 双生 + local 文件形)现在都
62
+ // 如实声明了 `durability`,所以这条读数不再对真持久部署恒假 —— 它现在说的就是字面意思。
63
+ //
64
+ // 🔴 仍然是 **warn 而不是拒启**,判据与 session 那条**刻意不同**,理由写在这里免得下一个人以为是漏改:
65
+ // session 那条对表的是「声明 durable × 介质不 durable」——两个已知量互相矛盾,拒启是查出了谎。这一条
66
+ // 对表的是「有没有人声明」,而 core 把**缺席**折成 `process-local`;一个合法的第三方/新增 checkpoint
67
+ // 店没表态时,拒启会挡住一个本来跑得好好的部署,而**没有任何旋钮**能让它继续启动(补偿为零 ⇒ 不升格)。
68
+ // 升格的前置件 = core 把「未声明」与「声明为进程内」分成两个可区分读数,或本仓给出一个显式承认旋钮。
64
69
  if (facts.parkFacility && facts.manifest.parkLane.checkpointDurability !== "declared_durable") {
65
70
  warnings.push(`park facility is enabled but the checkpoint store does not declare durable storage ` +
66
71
  `(parkLane.checkpointDurability=${JSON.stringify(facts.manifest.parkLane.checkpointDurability ?? null)}). ` +
67
- `A parked approval survives a restart only on a durable store and the in-tree checkpoint adapters currently declare no durability at all, so this reading cannot yet distinguish "really process-local" from "nobody declared it". ` +
68
- `Treat park-lane recovery as unproven until the adapter declares its durability.`);
72
+ `A parked approval survives a restart only on a durable store, and this reading folds "declared process-local" together with "declared nothing" every in-tree adapter declares "durable", so a non-durable reading here means a store this build does not own. ` +
73
+ `Treat park-lane recovery as unproven until that adapter declares its durability.`);
69
74
  }
70
75
  if (facts.manifest.interaction.posture === "interactive" && facts.manifest.question.wired !== "wired") {
71
76
  warnings.push(`interactionPosture declares "interactive" but no question delivery face is wired (question.wired=${JSON.stringify(facts.manifest.question.wired)}) — ` +
@@ -2,7 +2,8 @@
2
2
  * design/158 A9 域模块 —— memory 导出/同步 + 只读策略面。
3
3
  *
4
4
  * 路由:`GET /v1/memory/export`、`POST /v1/memory/sync/:scope`、`GET /v1/policy`。
5
- * owner 门 = 「本人 user scope 或显式 operator」,越界一律 404(零存在性 oracle)。本域零可变状态。
5
+ * owner 门 = 「本人 user scope / 显式 operator / `org:` 键的目录成员」( {@link gateMemoryScope}),
6
+ * 越界一律 404(零存在性 oracle)。本域零可变状态。
6
7
  */
7
8
  import type { IncomingMessage, ServerResponse } from "node:http";
8
9
  import type { RouteCtx } from "../route-ctx.js";
@@ -2,6 +2,70 @@ import { formatUserScope } from "@sema-agent/core";
2
2
  import { parseMemorySyncRequest } from "../../memory-sync.js";
3
3
  import { sendJson, sendError } from "../send.js";
4
4
  import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
5
+ const ORG_PREFIX = "org:";
6
+ /** 本面是否让目录参与判决。闭集穷举 switch:新增第三个模式是**编译**错误,不会静默落进某个默认臂
7
+ * ——这条门在授权轴上,静默默认值正是它不许有的东西。 */
8
+ function directoryDecidesThisFace(mode) {
9
+ switch (mode) {
10
+ case "enforce":
11
+ return true;
12
+ case "audit":
13
+ return false; // 见 gateMemoryScope 顶注的 codex R1 段
14
+ }
15
+ }
16
+ /**
17
+ * memory-policy 两面(export 读 / sync 写)共用的属主门 —— design/170 件A §7「第二消费者收编」。
18
+ *
19
+ * 收编前本门把 `org:*` 写死 operator-only(注逐字「membership waits for the S3 登记簿」),于是同一进程里
20
+ * 「谁属于 org:acme」有两个答案:core 准入门问授权目录、本面问 operatorPrincipals。收编后 `org:` 成员性
21
+ * 只由 `deps.orgMemoryDirectory` 回答(与准入 seam 同一个实例、同一份缓存)。
22
+ *
23
+ * 判决序(前两级刻意在目录之前 —— 它们不需要目录也成立,顺带让常路零额外 I/O):
24
+ * ① 本人 user 盘逐字匹配 ⇒ 通过;② 显式 operator ⇒ 通过(ops/迁移面不依赖目录在场);
25
+ * ③ `org:` 键 + 目录在场 + **enforce 模式** ⇒ 查目录:瞬时不可用照原样上抛;取数成功但无该键 ⇒
26
+ * `not_found`;`need==="write"` 另要条目 `write === true`(读授予不隐含写授予,与准入面 writeScope
27
+ * 收窄同源);④ 其余(`userproj:` 等目录没有答案的键、无目录源的部署、audit 模式)⇒ `not_found`。
28
+ *
29
+ * **audit 模式为什么在本面等于「不接目录」**(codex 对抗审 R1,2026-08-07):`audit` 是 operator 表态
30
+ * 「这份目录还没被验证过,先别拿它做判决」。拿未验证的目录去开真数据面(读)甚至写面,是这条表态能
31
+ * 造成的最严重后果——方向恰好反了;`audit=零行为变化` 的公开契约在本面因此读作「逐字保持收编前的
32
+ * operator-only」,连额外那一跳目录 I/O 和 enforce 才有的 503 响应形都不引入。org 面的诊断信号属于
33
+ * 准入侧的 `memory_admission_total{outcome,mode}`,不在本面另开一路。
34
+ *
35
+ * 拒绝一律 `not_found` 而非 403:本面的零存在性 oracle 纪律不变(与 run/trace 属主门同口径),
36
+ * 「不是成员」和「没这个 scope」对调用方必须不可区分。
37
+ */
38
+ async function gateMemoryScope(deps, principal, scope, need) {
39
+ if (scope === formatUserScope(principal))
40
+ return { kind: "allow" };
41
+ if (explicitOperatorOk(principal, deps.config.operatorPrincipals))
42
+ return { kind: "allow" };
43
+ const directory = deps.orgMemoryDirectory;
44
+ if (!scope.startsWith(ORG_PREFIX) || directory === undefined || !directoryDecidesThisFace(deps.config.memoryOrgAdmissionMode)) {
45
+ return { kind: "not_found" };
46
+ }
47
+ const lookup = await directory.lookup(principal);
48
+ if (lookup.kind === "unavailable")
49
+ return { kind: "unavailable", retryAfterMs: lookup.retryAfterMs };
50
+ const grant = lookup.scopes[scope];
51
+ if (grant === undefined)
52
+ return { kind: "not_found" };
53
+ return need === "write" && grant.write !== true ? { kind: "not_found" } : { kind: "allow" };
54
+ }
55
+ /** 门的拒绝臂下发:瞬时 ⇒ 503 `memory.admission_required` + `retryAfterSec`(秒上取整,永不 0 —— C6;
56
+ * 族形与 sync 提交腿的同码响应逐字一致);终局 ⇒ 404 零 oracle。返回 true = 已应答,调用方须 return。 */
57
+ function sendScopeGateRefusal(res, gate) {
58
+ if (gate.kind === "allow")
59
+ return false;
60
+ if (gate.kind === "unavailable") {
61
+ const retryAfterSec = Math.max(1, Math.ceil(gate.retryAfterMs / 1000));
62
+ res.setHeader("retry-after", String(retryAfterSec)); // 体里有等待提示 ⇒ 标准头同值(与同步提交腿的同码应答逐字一致)
63
+ sendError(res, 503, "memory.admission_required", "org memory directory unavailable — authorization could not be established", { retryAfterSec });
64
+ return true;
65
+ }
66
+ sendError(res, 404, "not_found.memory_scope", "not found"); // owner gate: no existence oracle
67
+ return true;
68
+ }
5
69
  export async function handleMemoryPolicy(req, res, url, ctx) {
6
70
  const miss = { fell: false };
7
71
  await handleMemoryPolicyBody(req, res, url, ctx, miss);
@@ -17,11 +81,13 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
17
81
  // C-additive 落点). After the global service-credential gate (a credential/SSO-JWT is already
18
82
  // verified); the OWNER gate here is the tenant boundary: the verified principal may export exactly its OWN
19
83
  // user disk (`scope === formatUserScope(principal)` — core's mint, byte-identical to memoryScopeFor's) or be
20
- // an explicit operator (explicitOperatorOk, ops/migration face). Anything else = 404, NEVER 403 (zero
21
- // existence oracle, run/trace owner-gate parity). org:*/userproj:* membership waits for the S3 登记簿
22
- // (design §1.4) until then those keys are operator-only by construction. proj:* keys of repo-backed
23
- // projects never live in this DB (铁律: git is their authority) an owner asking for one just gets
24
- // an empty set, honestly.
84
+ // an explicit operator (explicitOperatorOk, ops/migration face), or design/170 件A §7 收编 — a
85
+ // DIRECTORY MEMBER of an `org:` key (the same org-memory directory instance core's admission seam reads,
86
+ // so one process holds ONE answer to "who belongs to org:acme"). Anything else = 404, NEVER 403 (zero
87
+ // existence oracle, run/trace owner-gate parity); a directory that cannot answer = 503, never 404.
88
+ // `userproj:*` stays operator-only by construction (the directory has no answer for that key shape).
89
+ // proj:* keys of repo-backed projects never live in this DB (铁律: git is their authority) — an owner
90
+ // asking for one just gets an empty set, honestly.
25
91
  if (url === "/v1/memory/export" && req.method === "GET") {
26
92
  // Auth BEFORE availability (501-before-auth precedent): the backend posture must not oracle
27
93
  // to a caller who couldn't read anything anyway.
@@ -39,10 +105,8 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
39
105
  sendError(res, 400, "request.query_invalid", "missing ?scope=<key>");
40
106
  return;
41
107
  }
42
- if (scope !== formatUserScope(principal) && !explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
43
- sendError(res, 404, "not_found.memory_scope", "not found"); // owner gate: no existence oracle
108
+ if (sendScopeGateRefusal(res, await gateMemoryScope(deps, principal, scope, "read")))
44
109
  return;
45
- }
46
110
  try {
47
111
  const entries = await deps.memoryExport(scope);
48
112
  sendJson(res, 200, { scope, exportedAt: new Date().toISOString(), entries });
@@ -57,8 +121,8 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
57
121
  // plan 随数据走——reconcile/nextSyncBaseline 是 core 纯函数,由持数据的本 server 在 performMemorySync
58
122
  // (memory-sync.ts) 里直接调用;此处只持 HTTP 半场). Gate ORDER mirrors the export face verbatim:
59
123
  // auth (401) → backend posture (501, file 形态诚实拒绝——单用户 file 面自己就是 TOC 侧,没有中心
60
- // 权威半场可服务) → owner gate (404 zero-oracle: verified principal 的 user 盘逐字匹配,或 explicit
61
- // operator;org:/userproj: 键在 S3 登记簿前 = operator-only by construction) → 验型 (422 typed)。
124
+ // 权威半场可服务) → owner gate (404 zero-oracle: verified principal 的 user 盘逐字匹配 / explicit
125
+ // operator / `org:` 键的目录成员且条目 write:true;userproj: operator-only) → 验型 (422 typed)。
62
126
  if (req.method === "POST" && url.startsWith("/v1/memory/sync/")) {
63
127
  const principal = gatedPrincipal(req, deps.config); // direct-door safe: verified identity, never the spoofable header
64
128
  if (!principal) {
@@ -83,10 +147,10 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
83
147
  sendError(res, 400, "memory_sync_invalid_scope", "missing :scope");
84
148
  return;
85
149
  }
86
- if (scope !== formatUserScope(principal) && !explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
87
- sendError(res, 404, "not_found.memory_scope", "not found"); // owner gate: no existence oracle (export-face parity)
150
+ // 写面:门与 export 面同一个 `gateMemoryScope`,只在 `need` 上分家 —— org 条目须 `write === true`
151
+ // 才放行(读授予不隐含写授予)。属主/operator 两级与读面逐字同判。
152
+ if (sendScopeGateRefusal(res, await gateMemoryScope(deps, principal, scope, "write")))
88
153
  return;
89
- }
90
154
  const body = await readJson(req); // 413/400 (oversize / bad JSON) via the typed top-level catch — auth already done
91
155
  const parsed = parseMemorySyncRequest(body, scope);
92
156
  if (!parsed.ok) {
@@ -157,7 +157,7 @@ async function streamRunEvents(req, res, deps, runStore, taskId, staleMs) {
157
157
  // 🔴 开关谓词是**必须**的(codex 复审第二轮 high,2026-08-06):原按「关的时候表里本就没行 ⇒
158
158
  // 无需分支」落地,但这个前提在**回滚**下不成立 —— 开着协议的副本(或更新版本)已经落下的
159
159
  // `STREAM_PENDING` 行在关掉开关后**仍在表里**,无谓词的重放腿会把它们照常投上 wire,
160
- // 「默认关 = 逐字零变化」(§6.5/§7.2)当场破。谓词同时省掉了关闭态每次开流的一次 store 往返
160
+ // 「开关关 = 逐字零变化」(§6.5/§7.2)当场破。谓词同时省掉了关闭态每次开流的一次 store 往返
161
161
  // 与那份 store 故障暴露面。钉:场景9(零帧 **且** 零 store 读 —— 只钉零帧会放过「读了但没投」)。
162
162
  preamble: async (signal) => {
163
163
  if (!deps.config.streamApproval.enabled)