@desplega.ai/agent-swarm 2.5.2 → 2.5.4

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 (236) hide show
  1. package/dist/{acp-adapter-csh0zz3b.js → acp-adapter-ek38cwsv.js} +5 -5
  2. package/dist/{actions-6xx079d1.js → actions-92mepq03.js} +19 -19
  3. package/dist/{amp-adapter-8n6f0hz8.js → amp-adapter-9gaxg015.js} +4 -4
  4. package/dist/{app-ft6r6v23.js → app-ctn8gsgx.js} +3 -3
  5. package/dist/{assistant-vt2cyfzd.js → assistant-zyr5kz47.js} +21 -21
  6. package/dist/{boot-reembed-tgrfy27t.js → boot-reembed-dwzfttdr.js} +1 -1
  7. package/dist/{boot-reembed-r3e808ky.js → boot-reembed-w75maga2.js} +1 -1
  8. package/dist/{boot-scrub-sweep-masdwxkm.js → boot-scrub-sweep-e9kq43kf.js} +3 -3
  9. package/dist/{boot-seeds-0n82qc7q.js → boot-seeds-x28v3fhb.js} +4 -4
  10. package/dist/{claude-adapter-s7j47k06.js → claude-adapter-f0afqnne.js} +5 -5
  11. package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-x81a7r65.js} +3 -3
  12. package/dist/{claude-managed-setup-3hzrgrnk.js → claude-managed-setup-bqzwjd9x.js} +2 -2
  13. package/dist/{claude-sdk-session-0zrdzyx2.js → claude-sdk-session-7b7whkaq.js} +9307 -9254
  14. package/dist/{cli-a5ebc95n.js → cli-0shx9j93.js} +16 -13
  15. package/dist/{cli-4qkg9968.js → cli-19202kew.js} +4 -4
  16. package/dist/{cli-cyfgxnvb.js → cli-1r5ad7j5.js} +19 -17
  17. package/dist/{cli-jmcjbjkt.js → cli-25n3mv5d.js} +9 -7
  18. package/dist/cli-2655k16q.js +140 -0
  19. package/dist/{cli-wadzapwb.js → cli-277c31dw.js} +1 -1
  20. package/dist/{cli-n6kmqngw.js → cli-4b2z6n0m.js} +29 -7
  21. package/dist/{cli-7ccf7akh.js → cli-5bnsc0e1.js} +9 -7
  22. package/dist/{cli-qrjv7t18.js → cli-621ce7kw.js} +1 -1
  23. package/dist/{cli-xzge46sj.js → cli-64mh9mf3.js} +20 -6
  24. package/dist/{cli-b6tnjdvx.js → cli-6a1xg1p7.js} +6 -2
  25. package/dist/{cli-ydg6ep51.js → cli-6n8axjv1.js} +1 -1
  26. package/dist/{cli-e91vshb1.js → cli-7p4jxgtt.js} +5 -5
  27. package/dist/{cli-f3pfn4x3.js → cli-9j129brq.js} +9 -3
  28. package/dist/{cli-xw9q173h.js → cli-9s05422w.js} +1 -1
  29. package/dist/{cli-15xd0j7w.js → cli-abs8ndtr.js} +12 -12
  30. package/dist/{cli-h5d22pfs.js → cli-asq5ydm4.js} +219 -309
  31. package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
  32. package/dist/{cli-0vgy5wfw.js → cli-bvpgr9ny.js} +2 -2
  33. package/dist/{cli-3e9vs93g.js → cli-c61mbdds.js} +4 -4
  34. package/dist/{cli-e5bxnjps.js → cli-c952c7ac.js} +5 -5
  35. package/dist/{cli-p859hcc8.js → cli-dew6x255.js} +1 -1
  36. package/dist/{cli-tx00c0h6.js → cli-dqygeeh5.js} +17 -1
  37. package/dist/{cli-gpv60278.js → cli-e42vss7b.js} +2 -2
  38. package/dist/{cli-b63d08rs.js → cli-eftzyh9y.js} +1 -1
  39. package/dist/{cli-fgn7yg5r.js → cli-ethgy7ha.js} +1 -1
  40. package/dist/{cli-86zpfje5.js → cli-ff1tm9n9.js} +1 -1
  41. package/dist/{cli-cf63dnxj.js → cli-g9vd9ece.js} +37 -12
  42. package/dist/{cli-j3dwd4kz.js → cli-gbfxp48k.js} +770 -126
  43. package/dist/{cli-n2918sed.js → cli-gn5af0x5.js} +21 -21
  44. package/dist/{cli-d172svcw.js → cli-gwhjmh8g.js} +4 -4
  45. package/dist/{cli-stxdrqs2.js → cli-hh5we352.js} +24 -5
  46. package/dist/{cli-0ebey9hg.js → cli-j6rktc91.js} +1 -1
  47. package/dist/{cli-csh8fnbg.js → cli-jb30dzvk.js} +473 -58
  48. package/dist/{cli-vaxw3bg0.js → cli-jbnn4vxb.js} +3 -3
  49. package/dist/{cli-ha22h0eg.js → cli-k4tjsdx9.js} +45 -40
  50. package/dist/{cli-f55b6ky0.js → cli-m4ecdxvz.js} +40 -13
  51. package/dist/{cli-mntxtkhn.js → cli-mz9afxxf.js} +2 -1
  52. package/dist/{cli-ta8bd7th.js → cli-pzatvw94.js} +1 -1
  53. package/dist/{cli-3qv8naq8.js → cli-rr7zx19j.js} +1591 -353
  54. package/dist/{cli-b1tv22wr.js → cli-s682zgqz.js} +1 -1
  55. package/dist/{cli-bpbcpd8b.js → cli-sbwsxvtd.js} +1 -1
  56. package/dist/{cli-0xk505e8.js → cli-sxgtf5fw.js} +1 -1
  57. package/dist/{cli-6y43v79h.js → cli-tsma7p8v.js} +1 -1
  58. package/dist/{cli-edtm741g.js → cli-wghjkr0e.js} +5 -5
  59. package/dist/{cli-aaqnqpyw.js → cli-wx2nj19m.js} +30 -11
  60. package/dist/{cli-y3dzwyy3.js → cli-x0f2v69c.js} +180 -60
  61. package/dist/{cli-kcnwn85g.js → cli-xtrz6bzs.js} +7 -7
  62. package/dist/{cli-14v1sxxy.js → cli-xzfq4at3.js} +7 -7
  63. package/dist/cli.js +20 -20
  64. package/dist/{codex-adapter-brh2tcxr.js → codex-adapter-wch12q99.js} +3 -3
  65. package/dist/{codex-hook-e2he7aca.js → codex-hook-brpnycn5.js} +1 -1
  66. package/dist/{codex-session-runner-0dnj21qs.js → codex-session-runner-41pfcr95.js} +2 -2
  67. package/dist/{commands-1cnvseqx.js → commands-9849dkj6.js} +2 -2
  68. package/dist/{controller-kc7s9xhn.js → controller-ja0sd95p.js} +29 -3
  69. package/dist/{controllers-connect-jzx7t4bn.js → controllers-connect-xp05dj6x.js} +20 -20
  70. package/dist/{cursor-adapter-qpwp5sfg.js → cursor-adapter-623qxqew.js} +1 -2
  71. package/dist/{db-4vq88jkc.js → db-47vj40dy.js} +3 -3
  72. package/dist/{db-v5ytg5pw.js → db-w759qmx0.js} +2 -2
  73. package/dist/{dispatcher-trtcrqae.js → dispatcher-7b8e4z2j.js} +17 -17
  74. package/dist/{docker-nfy2gc8t.js → docker-s84hjs91.js} +2 -2
  75. package/dist/{dsh-adapter-f20xwn1z.js → dsh-adapter-2ezw3xea.js} +2 -3
  76. package/dist/{external-channel-n9bymc2n.js → external-channel-e2s028da.js} +2 -2
  77. package/dist/{grok-adapter-4kpmmnr6.js → grok-adapter-ys7gaqee.js} +6 -6
  78. package/dist/{handlers-ptsraqa8.js → handlers-wy1dr6ja.js} +20 -20
  79. package/dist/{heartbeat-60qtxtjf.js → heartbeat-w3mntghj.js} +20 -20
  80. package/dist/{hook-rbktcstg.js → hook-3jjywnpb.js} +9 -3
  81. package/dist/{hook-ngtvh729.js → hook-g7kvv6x9.js} +3 -2
  82. package/dist/{http-z8xy72gp.js → http-tkp7d4kb.js} +2984 -2483
  83. package/dist/{index-r70q0xjx.js → index-1ef1myq2.js} +17 -17
  84. package/dist/{index-a9nsavhx.js → index-3n8m9ea2.js} +1 -1
  85. package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
  86. package/dist/{index-s193j5fh.js → index-nmm79qgr.js} +20 -20
  87. package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
  88. package/dist/{index-06qkmcx6.js → index-q7m26kvs.js} +33 -23
  89. package/dist/{index-1cscynkb.js → index-xdaahthb.js} +17 -17
  90. package/dist/{keepalive-a9srcphj.js → keepalive-v4ta8wgg.js} +3 -3
  91. package/dist/{lead-3devneqy.js → lead-sawnyqxk.js} +35 -35
  92. package/dist/{maintenance-0er16h2y.js → maintenance-84n1ex21.js} +1 -1
  93. package/dist/{oauth-refresh-sweep-b65jcyw2.js → oauth-refresh-sweep-vjnnandw.js} +2 -2
  94. package/dist/{onboard-wqywz5py.js → onboard-sfmpe4np.js} +2 -2
  95. package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-jmpst4q4.js} +4 -4
  96. package/dist/{otel-impl-vk32s884.js → otel-impl-v6cc0zve.js} +1 -1
  97. package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-fk8fkcf6.js} +33 -6
  98. package/dist/{pricing-refresh-t27anv4z.js → pricing-refresh-fsk1e1zc.js} +3 -3
  99. package/dist/{rbac-roles-452665y0.js → rbac-roles-prmkdnkw.js} +2 -2
  100. package/dist/{rbac-roles-bmcgsxjn.js → rbac-roles-zxmxqa39.js} +1 -1
  101. package/dist/{render-v2-0ttsk2j4.js → render-v2-pjwxvnkw.js} +3 -3
  102. package/dist/{resume-a26kvr41.js → resume-frdtgfa0.js} +9 -5
  103. package/dist/{session-access-dgh9nkwk.js → session-access-fpez4y5q.js} +4 -4
  104. package/dist/{setup-9vc9qahm.js → setup-mq6m3m8q.js} +1 -1
  105. package/dist/{work-objects-vn8nkxwn.js → work-objects-kfahhjsq.js} +20 -20
  106. package/dist/{worker-1x1h8vnm.js → worker-v5e5rxj3.js} +35 -35
  107. package/openapi.json +3078 -2424
  108. package/package.json +7 -7
  109. package/plugin/commands/create-pr.md +2 -2
  110. package/plugin/pi-skills/create-pr/SKILL.md +2 -2
  111. package/src/be/approval-config-question.ts +389 -0
  112. package/src/be/automation-preflight.ts +37 -3
  113. package/src/be/db/approvals.ts +48 -0
  114. package/src/be/db/session-tree.ts +73 -15
  115. package/src/be/db/skill-search.ts +87 -0
  116. package/src/be/db/tasks/read.ts +29 -2
  117. package/src/be/db/tasks/write.ts +15 -5
  118. package/src/be/db/workflow-idempotency.ts +183 -0
  119. package/src/be/db/workflow-runs.ts +366 -8
  120. package/src/be/db.ts +92 -22
  121. package/src/be/migrations/239_skills_fts.sql +40 -0
  122. package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
  123. package/src/be/migrations/241_skills_meta_fts.sql +39 -0
  124. package/src/be/migrations/242_workflow_idempotency_keys.sql +40 -0
  125. package/src/be/sealed-json.ts +1 -0
  126. package/src/be/seed-scripts/catalog/compound-insights.inline.ts +2 -2
  127. package/src/be/seed-scripts/catalog/compound-insights.ts +2 -2
  128. package/src/be/seed-skills/bundled-files.generated.json +1 -1
  129. package/src/be/sql-connections.ts +7 -1
  130. package/src/be/swarm-config-guard.ts +25 -0
  131. package/src/be/users.ts +9 -0
  132. package/src/commands/controller.ts +33 -0
  133. package/src/commands/runner.ts +6 -2
  134. package/src/extensions/apply-task-create.ts +57 -11
  135. package/src/extensions/catalog.generated.json +4 -4
  136. package/src/extensions/contract-types.generated.ts +1 -1
  137. package/src/extensions/contract.ts +25 -0
  138. package/src/extensions/dispatcher.ts +4 -0
  139. package/src/extensions/post-bridge.ts +8 -0
  140. package/src/hooks/context-bound-note.ts +196 -0
  141. package/src/hooks/hook.ts +12 -0
  142. package/src/http/approval-requests.ts +209 -38
  143. package/src/http/config.ts +70 -53
  144. package/src/http/idempotency-key.ts +117 -0
  145. package/src/http/sessions.ts +27 -2
  146. package/src/http/workflows.ts +129 -24
  147. package/src/integrations/chat-sdk/inbound.ts +5 -4
  148. package/src/prompts/context-bound-note.ts +48 -0
  149. package/src/prompts/session-templates.ts +25 -1
  150. package/src/providers/amp-adapter.ts +30 -8
  151. package/src/providers/claude-adapter.ts +29 -4
  152. package/src/providers/claude-managed-adapter.ts +26 -0
  153. package/src/providers/claude-sdk-session.ts +10 -0
  154. package/src/providers/claude-session-events.ts +13 -0
  155. package/src/providers/codex-adapter.ts +12 -0
  156. package/src/providers/dsh-adapter.ts +1 -1
  157. package/src/providers/opencode-adapter.ts +18 -0
  158. package/src/providers/pi-mono-adapter.ts +35 -0
  159. package/src/runtime-providers/docker.ts +6 -2
  160. package/src/scripts-runtime/types/swarm-extension.d.ts +35 -0
  161. package/src/tests/amp-adapter.test.ts +97 -0
  162. package/src/tests/approval-config-question.test.ts +702 -0
  163. package/src/tests/approval-requests.test.ts +10 -7
  164. package/src/tests/approval-sweeps.test.ts +12 -6
  165. package/src/tests/base-prompt.test.ts +1 -1
  166. package/src/tests/chat-sdk-inbound.test.ts +20 -0
  167. package/src/tests/claude-hook-output.test.ts +79 -1
  168. package/src/tests/claude-managed-adapter.test.ts +275 -0
  169. package/src/tests/codex-adapter.test.ts +163 -0
  170. package/src/tests/context-bound-note.test.ts +269 -0
  171. package/src/tests/dsh-adapter.test.ts +1 -1
  172. package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
  173. package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
  174. package/src/tests/extensions-pre-task.test.ts +273 -0
  175. package/src/tests/fetch-resolved-env.test.ts +17 -0
  176. package/src/tests/fixtures/extensions/record-blocks/hooks.ts +10 -0
  177. package/src/tests/fixtures/extensions/record-blocks/manifest.json +7 -0
  178. package/src/tests/fixtures/fake-amp.ts +22 -8
  179. package/src/tests/fixtures/held-template-render.ts +42 -0
  180. package/src/tests/fixtures/workflow-idempotency-trigger.child.ts +73 -0
  181. package/src/tests/list-endpoint-slimming.test.ts +33 -0
  182. package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
  183. package/src/tests/openapi-route-headers.test.ts +2 -0
  184. package/src/tests/opencode-adapter.test.ts +134 -0
  185. package/src/tests/pi-mono-adapter.test.ts +105 -0
  186. package/src/tests/prompt-template-session.test.ts +4 -3
  187. package/src/tests/runner-push-delivery.test.ts +25 -0
  188. package/src/tests/runtime-controller-liveness.test.ts +133 -0
  189. package/src/tests/runtime-providers-docker.test.ts +2 -0
  190. package/src/tests/script-connections.test.ts +9 -0
  191. package/src/tests/script-run-config-scope.test.ts +91 -3
  192. package/src/tests/session-logs.test.ts +70 -0
  193. package/src/tests/sessions-tree.test.ts +205 -1
  194. package/src/tests/skill-search-long-content.test.ts +193 -0
  195. package/src/tests/skill-search.test.ts +173 -22
  196. package/src/tests/workflow-cascade-cancel.test.ts +1169 -0
  197. package/src/tests/workflow-foreach-supersede-retry.test.ts +488 -0
  198. package/src/tests/workflow-idempotency.test.ts +915 -0
  199. package/src/tests/workflow-sub-workflow.test.ts +538 -1
  200. package/src/tools/request-human-input.ts +27 -5
  201. package/src/tools/workflows/cancel-workflow-run.ts +1 -1
  202. package/src/types.ts +7 -0
  203. package/src/utils/context-window.ts +8 -6
  204. package/src/workflows/approval-cancel.ts +15 -13
  205. package/src/workflows/approval-notifications.ts +15 -4
  206. package/src/workflows/checkpoint.ts +72 -35
  207. package/src/workflows/engine.ts +262 -93
  208. package/src/workflows/executors/base.ts +6 -0
  209. package/src/workflows/executors/foreach.ts +2 -2
  210. package/src/workflows/executors/human-in-the-loop.ts +25 -1
  211. package/src/workflows/executors/sub-workflow.ts +45 -16
  212. package/src/workflows/foreach-driver.ts +10 -3
  213. package/src/workflows/foreach-join.ts +29 -6
  214. package/src/workflows/recovery.ts +68 -1
  215. package/src/workflows/resume.ts +228 -77
  216. package/src/workflows/retry-poller.ts +74 -7
  217. package/src/workflows/task-step-routing.ts +29 -7
  218. package/templates/ai-toolbox.manifest.json +8 -8
  219. package/templates/community/open-prompt-showreel/PLAYBOOK.md +5 -6
  220. package/templates/community/open-prompt-showreel/lead-prompt.md +1 -1
  221. package/templates/community/open-prompt-showreel/skills/open-prompt-showreel/SKILL.md +4 -4
  222. package/templates/extensions/comms-adherence-gate/README.md +38 -13
  223. package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
  224. package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
  225. package/templates/skills/agent-browser/SKILL.md +4 -4
  226. package/templates/skills/agent-browser/content.md +4 -4
  227. package/templates/skills/code-quality/config.json +1 -1
  228. package/templates/skills/code-quality/content.md +2 -2
  229. package/templates/skills/delegate-work/content.md +29 -25
  230. package/templates/skills/delegate-work/files/scripts/codex-exec.sh +5 -5
  231. package/templates/skills/qa/content.md +2 -2
  232. package/templates/skills/researching/content.md +2 -0
  233. package/templates/skills/turso-interaction/content.md +1 -1
  234. package/templates/skills/work-on-task/SKILL.md +1 -1
  235. package/templates/skills/work-on-task/content.md +1 -1
  236. package/templates/skills/workflow-iterate/content.md +8 -1
@@ -0,0 +1,273 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { CHILD_PROCESS_TEST_BUDGET_MS, expectChildOk, runChild } from "./test-proc";
6
+
7
+ /**
8
+ * Regression test for the GitLab git credential helper setup in
9
+ * docker-entrypoint.sh.
10
+ *
11
+ * The GITLAB_HOST assignment and the `git config --global ... helper` command
12
+ * are extracted verbatim from docker-entrypoint.sh and run under bash against
13
+ * real `git`, so this tracks the deployed snippet instead of a copy of it.
14
+ *
15
+ * Regression: the helper was set with a plain `git config --global KEY VALUE`.
16
+ * When the origin already had several helper values (a startup hook's
17
+ * `helper = ""` reset plus its own helper, or a second boot on a persisted
18
+ * HOME), git refused with "cannot overwrite multiple values with a single
19
+ * value" and `set -e` killed the entrypoint. `--replace-all` makes the step
20
+ * idempotent for that one origin.
21
+ *
22
+ * Every git call runs with an isolated HOME / global config, no system config,
23
+ * terminal prompts off, and synthetic hosts and tokens. Nothing hits the network.
24
+ */
25
+
26
+ const entrypointPath = `${import.meta.dir}/../../docker-entrypoint.sh`;
27
+
28
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: a literal shell expansion in the entrypoint
29
+ const HELPER_KEY_EXPR = '"credential.${GITLAB_HOST%/}.helper"';
30
+ const SYNTHETIC_TOKEN = "glpat-synthetic-test-token"; // gitleaks:allow (synthetic test fixture)
31
+ const HOOK_HELPER = "!synthetic-hook-helper";
32
+
33
+ function extractGitlabHelperSnippet(): string {
34
+ const script = readFileSync(entrypointPath, "utf8");
35
+
36
+ const hostLineStart = script.indexOf('GITLAB_HOST="${GITLAB_URL:-');
37
+ if (hostLineStart === -1) {
38
+ throw new Error("Could not locate the GITLAB_HOST assignment in docker-entrypoint.sh.");
39
+ }
40
+ const hostLine = script.slice(hostLineStart, script.indexOf("\n", hostLineStart));
41
+
42
+ const keyIndex = script.indexOf(HELPER_KEY_EXPR, hostLineStart);
43
+ if (keyIndex === -1) {
44
+ throw new Error(
45
+ `Could not locate ${HELPER_KEY_EXPR} in the GitLab block of docker-entrypoint.sh.`,
46
+ );
47
+ }
48
+ const commandStart = script.lastIndexOf("git config --global", keyIndex);
49
+ const closingMarker = "; f'";
50
+ const closingIndex = script.indexOf(closingMarker, keyIndex);
51
+ if (commandStart === -1 || closingIndex === -1) {
52
+ throw new Error("Could not locate the bounds of the GitLab helper `git config` command.");
53
+ }
54
+ const command = script.slice(commandStart, closingIndex + closingMarker.length);
55
+
56
+ return `set -e\n${hostLine}\n${command}\n`;
57
+ }
58
+
59
+ const snippet = extractGitlabHelperSnippet();
60
+ const tempDirs: string[] = [];
61
+
62
+ afterEach(() => {
63
+ for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
64
+ });
65
+
66
+ function makeHome(): string {
67
+ const home = mkdtempSync(join(tmpdir(), "gitlab-helper-"));
68
+ tempDirs.push(home);
69
+ return home;
70
+ }
71
+
72
+ function isolatedEnv(home: string, extra: Record<string, string> = {}): Record<string, string> {
73
+ return {
74
+ PATH: process.env.PATH ?? "/usr/bin:/bin",
75
+ HOME: home,
76
+ XDG_CONFIG_HOME: join(home, ".config"),
77
+ GIT_CONFIG_GLOBAL: join(home, ".gitconfig"),
78
+ GIT_CONFIG_NOSYSTEM: "1",
79
+ GIT_TERMINAL_PROMPT: "0",
80
+ LANG: "C",
81
+ LC_ALL: "C",
82
+ ...extra,
83
+ };
84
+ }
85
+
86
+ async function git(home: string, args: string[], stdin?: string, extra?: Record<string, string>) {
87
+ return runChild(["git", ...args], { env: isolatedEnv(home, extra), stdin });
88
+ }
89
+
90
+ async function addHelper(home: string, origin: string, value: string) {
91
+ expectChildOk(
92
+ await git(home, ["config", "--global", "--add", `credential.${origin}.helper`, value]),
93
+ "git config --add",
94
+ );
95
+ }
96
+
97
+ /** The startup-hook pattern: reset inherited helpers, then add its own. */
98
+ async function addHookPair(home: string, origin: string) {
99
+ await addHelper(home, origin, "");
100
+ await addHelper(home, origin, HOOK_HELPER);
101
+ }
102
+
103
+ async function getHelpers(home: string, origin: string): Promise<string[]> {
104
+ const result = await git(home, [
105
+ "config",
106
+ "--global",
107
+ "--get-all",
108
+ `credential.${origin}.helper`,
109
+ ]);
110
+ if (result.exitCode === 1) return [];
111
+ expectChildOk(result, "git config --get-all");
112
+ return result.stdout.replace(/\n$/, "").split("\n");
113
+ }
114
+
115
+ async function runSnippet(home: string, gitlabUrl?: string) {
116
+ const extra: Record<string, string> = { GITLAB_TOKEN: SYNTHETIC_TOKEN };
117
+ if (gitlabUrl !== undefined) extra.GITLAB_URL = gitlabUrl;
118
+ return runChild(["bash", "-c", snippet], { env: isolatedEnv(home, extra) });
119
+ }
120
+
121
+ function expectOnlyEntrypointHelper(helpers: string[]) {
122
+ expect(helpers).toHaveLength(1);
123
+ expect(helpers[0]).toStartWith("!f() {");
124
+ expect(helpers[0]).toContain("username=oauth2");
125
+ }
126
+
127
+ const ORIGIN = "https://gitlab.example.test";
128
+
129
+ describe("docker-entrypoint.sh: GitLab git credential helper", () => {
130
+ test(
131
+ "sets the helper when none exists (default gitlab.com origin)",
132
+ async () => {
133
+ const home = makeHome();
134
+ expectChildOk(await runSnippet(home), "snippet");
135
+ expectOnlyEntrypointHelper(await getHelpers(home, "https://gitlab.com"));
136
+ },
137
+ CHILD_PROCESS_TEST_BUDGET_MS,
138
+ );
139
+
140
+ test(
141
+ "replaces a single existing value",
142
+ async () => {
143
+ const home = makeHome();
144
+ await addHelper(home, ORIGIN, "!old-helper");
145
+ expectChildOk(await runSnippet(home, ORIGIN), "snippet");
146
+ expectOnlyEntrypointHelper(await getHelpers(home, ORIGIN));
147
+ },
148
+ CHILD_PROCESS_TEST_BUDGET_MS,
149
+ );
150
+
151
+ test(
152
+ "succeeds over an empty reset value plus a custom helper",
153
+ async () => {
154
+ const home = makeHome();
155
+ await addHookPair(home, ORIGIN);
156
+ expectChildOk(await runSnippet(home, ORIGIN), "snippet");
157
+ expectOnlyEntrypointHelper(await getHelpers(home, ORIGIN));
158
+ },
159
+ CHILD_PROCESS_TEST_BUDGET_MS,
160
+ );
161
+
162
+ test(
163
+ "succeeds over several non-empty values",
164
+ async () => {
165
+ const home = makeHome();
166
+ await addHelper(home, ORIGIN, "!helper-one");
167
+ await addHelper(home, ORIGIN, "!helper-two");
168
+ expectChildOk(await runSnippet(home, ORIGIN), "snippet");
169
+ expectOnlyEntrypointHelper(await getHelpers(home, ORIGIN));
170
+ },
171
+ CHILD_PROCESS_TEST_BUDGET_MS,
172
+ );
173
+
174
+ test(
175
+ "repeated runs do not accumulate values",
176
+ async () => {
177
+ const home = makeHome();
178
+ for (let i = 0; i < 3; i++) expectChildOk(await runSnippet(home, ORIGIN), `snippet run ${i}`);
179
+ expectOnlyEntrypointHelper(await getHelpers(home, ORIGIN));
180
+ },
181
+ CHILD_PROCESS_TEST_BUDGET_MS,
182
+ );
183
+
184
+ test(
185
+ "leaves another origin's helpers untouched",
186
+ async () => {
187
+ const home = makeHome();
188
+ const other = "https://other.example.test";
189
+ await addHookPair(home, other);
190
+ await addHookPair(home, ORIGIN);
191
+ expectChildOk(await runSnippet(home, ORIGIN), "snippet");
192
+ expect(await getHelpers(home, other)).toEqual(["", HOOK_HELPER]);
193
+ expectOnlyEntrypointHelper(await getHelpers(home, ORIGIN));
194
+ },
195
+ CHILD_PROCESS_TEST_BUDGET_MS,
196
+ );
197
+
198
+ test(
199
+ "keeps the port and strips one trailing slash from GITLAB_URL",
200
+ async () => {
201
+ const home = makeHome();
202
+ const origin = "https://gitlab.example.test:8443";
203
+ await addHookPair(home, origin);
204
+ expectChildOk(await runSnippet(home, `${origin}/`), "snippet");
205
+ expectOnlyEntrypointHelper(await getHelpers(home, origin));
206
+ expect(await getHelpers(home, `${origin}/`)).toEqual([]);
207
+ },
208
+ CHILD_PROCESS_TEST_BUDGET_MS,
209
+ );
210
+
211
+ test(
212
+ "stores a reference to GITLAB_TOKEN, never the token value",
213
+ async () => {
214
+ const home = makeHome();
215
+ expectChildOk(await runSnippet(home, ORIGIN), "snippet");
216
+ const gitconfig = readFileSync(join(home, ".gitconfig"), "utf8");
217
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: the helper must keep the shell reference
218
+ expect(gitconfig).toContain("${GITLAB_TOKEN}");
219
+ expect(gitconfig).not.toContain(SYNTHETIC_TOKEN);
220
+ },
221
+ CHILD_PROCESS_TEST_BUDGET_MS,
222
+ );
223
+
224
+ test(
225
+ "answers get from the environment and ignores store / erase",
226
+ async () => {
227
+ const home = makeHome();
228
+ expectChildOk(await runSnippet(home, ORIGIN), "snippet");
229
+ const tokenEnv = { GITLAB_TOKEN: SYNTHETIC_TOKEN };
230
+
231
+ const fill = expectChildOk(
232
+ await git(
233
+ home,
234
+ ["credential", "fill"],
235
+ "protocol=https\nhost=gitlab.example.test\n\n",
236
+ tokenEnv,
237
+ ),
238
+ "git credential fill",
239
+ );
240
+ expect(fill.stdout).toContain("username=oauth2\n");
241
+ expect(fill.stdout).toContain(`password=${SYNTHETIC_TOKEN}\n`);
242
+
243
+ const cred = `protocol=https\nhost=gitlab.example.test\nusername=oauth2\npassword=${SYNTHETIC_TOKEN}\n\n`;
244
+ for (const action of ["approve", "reject"]) {
245
+ const result = expectChildOk(
246
+ await git(home, ["credential", action], cred, tokenEnv),
247
+ `git credential ${action}`,
248
+ );
249
+ expect(result.stdout).toBe("");
250
+ }
251
+ expect(readFileSync(join(home, ".gitconfig"), "utf8")).not.toContain(SYNTHETIC_TOKEN);
252
+ },
253
+ CHILD_PROCESS_TEST_BUDGET_MS,
254
+ );
255
+
256
+ test(
257
+ "a startup hook can re-add its reset + helper pair across restarts",
258
+ async () => {
259
+ const home = makeHome();
260
+ for (let boot = 0; boot < 2; boot++) {
261
+ expectChildOk(await runSnippet(home, ORIGIN), `snippet boot ${boot}`);
262
+ await addHookPair(home, ORIGIN);
263
+ // The hook's "" resets the entrypoint helper before it in the chain,
264
+ // and the next boot's --replace-all collapses all three again.
265
+ const helpers = await getHelpers(home, ORIGIN);
266
+ expect(helpers).toHaveLength(3);
267
+ expectOnlyEntrypointHelper(helpers.slice(0, 1));
268
+ expect(helpers.slice(1)).toEqual(["", HOOK_HELPER]);
269
+ }
270
+ },
271
+ CHILD_PROCESS_TEST_BUDGET_MS,
272
+ );
273
+ });
@@ -83,6 +83,8 @@ const makeCtx = (
83
83
  dbThrows?: boolean;
84
84
  /** The caller's agents.isLead; null means no agents row. */
85
85
  callerIsLead?: boolean | null;
86
+ /** user_external_ids x users rows for the mention lookup. */
87
+ addressees?: Record<string, unknown>[];
86
88
  } = {},
87
89
  ) => {
88
90
  const kv: Record<string, unknown> = {};
@@ -99,6 +101,10 @@ const makeCtx = (
99
101
  db_query: async (args: any) => {
100
102
  queries.push(args);
101
103
  if (opts.dbThrows) throw new Error("db down");
104
+ if (String(args.sql).includes("user_external_ids")) {
105
+ const rows = (opts.addressees ?? []).filter((r) => args.params.includes(r.externalId));
106
+ return { success: true, data: { rows } };
107
+ }
102
108
  if (String(args.sql).includes("FROM agents")) {
103
109
  const lead = opts.callerIsLead ?? false;
104
110
  return {
@@ -157,6 +163,8 @@ describe("defaults", () => {
157
163
  timeoutMs: 4000,
158
164
  maxBlocksPerTask: 1,
159
165
  provider: "auto",
166
+ plainMode: "shadow",
167
+ plainThreshold: 0.25,
160
168
  });
161
169
  });
162
170
 
@@ -198,11 +206,17 @@ describe("pass", () => {
198
206
  const call = jevCalls[0]!;
199
207
  expect(call.url).toBe("https://api.typesafe.ai/v1/systemone");
200
208
  expect(call.auth).toBe("Bearer tsk-test");
201
- expect(Object.keys(call.body.questions).sort()).toEqual(["language", "tone", "verbosity"]);
209
+ expect(Object.keys(call.body.questions).sort()).toEqual([
210
+ "language",
211
+ "plain",
212
+ "tone",
213
+ "verbosity",
214
+ ]);
215
+ expect(call.body.state.plain_language_rules).toEqual(mod.PLAIN_RULES);
202
216
  expect(call.body.state.latest_request).toBe("which option did you pick?");
203
217
  });
204
218
 
205
- test("non-Slack tasks, requesters without prefs, non-completions and other agents' tasks skip Jev", async () => {
219
+ test("with plain off: non-Slack tasks, requesters without prefs, non-completions and other agents' tasks skip Jev", async () => {
206
220
  jevScores(0.1);
207
221
  const cases: Array<[any, Row]> = [
208
222
  [completion("x"), taskRow({ source: "mcp", slackThreadTs: null })],
@@ -220,7 +234,7 @@ describe("pass", () => {
220
234
  [completion("x", { taskId: "not-a-uuid" }), taskRow()],
221
235
  ];
222
236
  for (const [event, row] of cases) {
223
- const { ctx } = makeCtx({ mode: "enforce" }, { row });
237
+ const { ctx } = makeCtx({ mode: "enforce", plainMode: "off" }, { row });
224
238
  expect(await handler(event, ctx)).toBeUndefined();
225
239
  }
226
240
  expect(jevCalls).toHaveLength(0);
@@ -264,9 +278,9 @@ describe("pass", () => {
264
278
  expect(queries).toHaveLength(1);
265
279
  });
266
280
 
267
- test("mode off never reads the task", async () => {
281
+ test("mode off and plainMode off never read the task", async () => {
268
282
  jevScores(0.1);
269
- const { ctx, queries } = makeCtx({ mode: "off" });
283
+ const { ctx, queries } = makeCtx({ mode: "off", plainMode: "off" });
270
284
  expect(await handler(completion("x"), ctx)).toBeUndefined();
271
285
  expect(queries).toHaveLength(0);
272
286
  });
@@ -387,13 +401,12 @@ describe("fail open", () => {
387
401
 
388
402
  describe("self-test", () => {
389
403
  test("records both controls without judging real traffic", async () => {
390
- mockJev((body) =>
391
- Response.json({
392
- answers: {
393
- verbosity: { noul: String(body.state.agent_message).startsWith("Great") ? 0.1 : 0.9 },
394
- },
395
- }),
396
- );
404
+ mockJev((body) => {
405
+ const msg = String(body.state.agent_message);
406
+ const bad = msg.startsWith("Great") || msg.startsWith("Prompt cache monitor");
407
+ const [dim] = Object.keys(body.questions);
408
+ return Response.json({ answers: { [dim!]: { noul: bad ? 0.1 : 0.9 } } });
409
+ });
397
410
  const { ctx, kv, queries, counters } = makeCtx();
398
411
  const event = {
399
412
  tool: "store-progress",
@@ -405,9 +418,305 @@ describe("self-test", () => {
405
418
  positiveControlBadScore: 0.1,
406
419
  negativeControlGoodScore: 0.9,
407
420
  controlsOk: true,
421
+ plainPositiveControlBadScore: 0.1,
422
+ plainNegativeControlGoodScore: 0.9,
423
+ plainControlsOk: true,
408
424
  provider: "typesafe",
409
425
  });
426
+ // Two verbosity calls and two plain calls, each asking one question.
427
+ expect(jevCalls.map((c) => Object.keys(c.body.questions)[0]).sort()).toEqual([
428
+ "plain",
429
+ "plain",
430
+ "verbosity",
431
+ "verbosity",
432
+ ]);
410
433
  expect(queries).toHaveLength(0);
411
434
  expect(counters.total).toBeUndefined();
412
435
  });
413
436
  });
437
+
438
+ describe("plain", () => {
439
+ const slackReply = (message: string, extra: Record<string, unknown> = {}) => ({
440
+ tool: "slack-reply",
441
+ args: { taskId: TASK_ID, message, ...extra },
442
+ requestInfo: { callOrigin: "mcp", agentId: "agent-1" },
443
+ });
444
+
445
+ test("a requester without prefs still gets the plain question", async () => {
446
+ jevScores(0.9);
447
+ const { ctx, counters } = makeCtx({}, { row: taskRow({ comms: null }) });
448
+ expect(await handler(completion("Picked B."), ctx)).toBeUndefined();
449
+ expect(Object.keys(jevCalls[0]!.body.questions)).toEqual(["plain"]);
450
+ expect(counters).toMatchObject({ total: 1, pass: 1 });
451
+ });
452
+
453
+ test("mode off with plainMode shadow asks only plain", async () => {
454
+ jevScores(0.9);
455
+ const { ctx } = makeCtx({ mode: "off" });
456
+ await handler(completion("Picked B."), ctx);
457
+ expect(Object.keys(jevCalls[0]!.body.questions)).toEqual(["plain"]);
458
+ });
459
+
460
+ test("plain uses plainThreshold, not threshold", async () => {
461
+ jevScores({ plain: 0.3 });
462
+ const passing = makeCtx({ mode: "enforce", plainMode: "enforce", threshold: 0.75 });
463
+ expect(await handler(completion("Picked B."), passing.ctx)).toBeUndefined();
464
+ expect(passing.counters).toMatchObject({ pass: 1 });
465
+
466
+ jevScores({ plain: 0.2 });
467
+ const failing = makeCtx({ mode: "enforce", plainMode: "enforce", threshold: 0.75 });
468
+ const result = await handler(completion("p50 Δ +120ms (n=37)"), failing.ctx);
469
+ expect(result).toMatchObject({ action: "block" });
470
+ expect(result.reason).toContain("plain (0.20 vs 0.25)");
471
+ });
472
+
473
+ test("plainMode shadow records a plain failure and never blocks, even with mode enforce", async () => {
474
+ jevScores({ plain: 0.05 });
475
+ const { ctx, counters, kv } = makeCtx({ mode: "enforce" });
476
+ expect(await handler(completion("p50 Δ +120ms (n=37)"), ctx)).toBeUndefined();
477
+ expect(counters).toMatchObject({ fail: 1, shadowed: 1, "dim:plain": 1 });
478
+ expect(counters.blocked).toBeUndefined();
479
+ expect(kv.last).toMatchObject({ failed: ["plain"], plainMode: "shadow" });
480
+ });
481
+
482
+ test("plainMode enforce blocks and points the agent at the comms skill", async () => {
483
+ jevScores({ plain: 0.05 });
484
+ const { ctx, counters } = makeCtx({ mode: "shadow", plainMode: "enforce" });
485
+ const result = await handler(completion("p50 Δ +120ms (n=37)"), ctx);
486
+ expect(result).toMatchObject({ action: "block" });
487
+ expect(result.reason).toContain("plain (0.05 vs 0.25)");
488
+ expect(result.reason).toContain("`comms` skill (Simpler mode)");
489
+ expect(result.reason).toContain("Keep every number, name, link and hedge verbatim");
490
+ expect(result.reason).toContain("call store-progress again");
491
+ expect(counters).toMatchObject({ blocked: 1, "dim:plain": 1 });
492
+ });
493
+
494
+ test("a failing preference in shadow is left out of a plain block", async () => {
495
+ jevScores({ plain: 0.05, verbosity: 0.1 });
496
+ const { ctx } = makeCtx({ mode: "shadow", plainMode: "enforce" });
497
+ const result = await handler(completion("x"), ctx);
498
+ expect(result.reason).toContain("plain (");
499
+ expect(result.reason).not.toContain("verbosity (");
500
+ });
501
+
502
+ test("structured JSON output skips the plain question", async () => {
503
+ jevScores(0.9);
504
+ const { ctx } = makeCtx({ mode: "enforce", plainMode: "enforce" });
505
+ await handler(completion('{"prUrl":"https://example.com/pull/1","n":3}'), ctx);
506
+ expect(Object.keys(jevCalls[0]!.body.questions)).not.toContain("plain");
507
+ expect(mod.looksLikeJson("{not json")).toBe(false);
508
+ });
509
+
510
+ test("a JSON message on any Slack tool is still judged plain, even with mode off", async () => {
511
+ const json = '{"p50":"+120ms","n":37}';
512
+ for (const tool of ["slack-reply", "slack-post", "slack-start-thread", "slack-update"]) {
513
+ jevCalls = [];
514
+ jevScores({ plain: 0.05 });
515
+ const { ctx } = makeCtx({ mode: "off", plainMode: "enforce" });
516
+ const result = await handler(
517
+ {
518
+ tool,
519
+ args: { taskId: TASK_ID, channelId: "C1", message: json },
520
+ requestInfo: { callOrigin: "mcp", agentId: "agent-1", sourceTaskId: TASK_ID },
521
+ },
522
+ ctx,
523
+ );
524
+ expect(jevCalls).toHaveLength(1);
525
+ expect(Object.keys(jevCalls[0]!.body.questions)).toEqual(["plain"]);
526
+ expect(result).toMatchObject({ action: "block" });
527
+ }
528
+ });
529
+
530
+ test("slack-reply is judged on its message with the named task", async () => {
531
+ jevScores({ plain: 0.05 });
532
+ const { ctx, queries } = makeCtx({ plainMode: "enforce" });
533
+ const result = await handler(slackReply("p50 Δ +120ms (n=37)"), ctx);
534
+ expect(result).toMatchObject({ action: "block" });
535
+ expect(result.reason).toContain("call slack-reply again");
536
+ expect(queries[0].params).toEqual([TASK_ID]);
537
+ expect(jevCalls[0]!.body.state.agent_message).toBe("p50 Δ +120ms (n=37)");
538
+ });
539
+
540
+ test("slack-post, slack-start-thread and slack-update use the caller's task context", async () => {
541
+ for (const tool of ["slack-post", "slack-start-thread", "slack-update"]) {
542
+ jevCalls = [];
543
+ jevScores(0.9);
544
+ const { ctx, queries } = makeCtx();
545
+ await handler(
546
+ {
547
+ tool,
548
+ args: { channelId: "C1", message: "Deploys take 4 minutes now." },
549
+ requestInfo: { callOrigin: "mcp", agentId: "agent-1", sourceTaskId: TASK_ID },
550
+ },
551
+ ctx,
552
+ );
553
+ expect(queries[0].params).toEqual([TASK_ID]);
554
+ expect(jevCalls).toHaveLength(1);
555
+ }
556
+ });
557
+
558
+ test("a Slack post from a non-Slack task is still judged", async () => {
559
+ jevScores(0.9);
560
+ const { ctx } = makeCtx({}, { row: taskRow({ source: "mcp", slackThreadTs: null }) });
561
+ await handler(slackReply("Deploys take 4 minutes now."), ctx);
562
+ expect(jevCalls).toHaveLength(1);
563
+ });
564
+
565
+ test("a Slack post without a task, or on another agent's task, skips Jev", async () => {
566
+ jevScores(0.05);
567
+ const noTask = makeCtx({ plainMode: "enforce" });
568
+ expect(
569
+ await handler(
570
+ {
571
+ tool: "slack-post",
572
+ args: { channelId: "C1", message: "x" },
573
+ requestInfo: { callOrigin: "mcp", agentId: "agent-1" },
574
+ },
575
+ noTask.ctx,
576
+ ),
577
+ ).toBeUndefined();
578
+ const other = makeCtx({ plainMode: "enforce" }, { row: taskRow({ agentId: "agent-2" }) });
579
+ expect(await handler(slackReply("x"), other.ctx)).toBeUndefined();
580
+ expect(jevCalls).toHaveLength(0);
581
+ });
582
+
583
+ test("a Slack post on an unassigned task the caller did not create skips Jev", async () => {
584
+ // slack-reply itself rejects this task, so nothing from it may reach the judge,
585
+ // even though store-progress may write to an unassigned task.
586
+ jevScores(0.05);
587
+ const unassigned = makeCtx({ plainMode: "enforce" }, { row: taskRow({ agentId: null }) });
588
+ expect(await handler(slackReply("x"), unassigned.ctx)).toBeUndefined();
589
+ const lead = makeCtx(
590
+ { plainMode: "enforce" },
591
+ { row: taskRow({ agentId: "agent-2" }), callerIsLead: true },
592
+ );
593
+ expect(await handler(slackReply("x"), lead.ctx)).toBeUndefined();
594
+ expect(jevCalls).toHaveLength(0);
595
+ });
596
+
597
+ test("the task's creator may post and is judged", async () => {
598
+ jevScores(0.9);
599
+ const { ctx } = makeCtx(
600
+ {},
601
+ { row: taskRow({ agentId: "agent-2", creatorAgentId: "agent-1" }) },
602
+ );
603
+ await handler(slackReply("Deploys take 4 minutes now."), ctx);
604
+ expect(jevCalls).toHaveLength(1);
605
+ });
606
+
607
+ test("store-progress and slack-reply share one block budget per task", async () => {
608
+ jevScores({ plain: 0.05 });
609
+ const { ctx, counters } = makeCtx({ plainMode: "enforce" });
610
+ expect(await handler(slackReply("p50 Δ"), ctx)).toMatchObject({ action: "block" });
611
+ expect(await handler(completion("p50 Δ"), ctx)).toBeUndefined();
612
+ expect(counters).toMatchObject({ blocked: 1, escaped: 1 });
613
+ });
614
+
615
+ test("a script SDK call is recorded and never blocked", async () => {
616
+ jevScores({ plain: 0.05 });
617
+ const { ctx, counters } = makeCtx({ mode: "enforce", plainMode: "enforce" });
618
+ const event = {
619
+ ...slackReply("p50 Δ"),
620
+ requestInfo: { callOrigin: "script-sdk", agentId: "agent-1" },
621
+ };
622
+ expect(await handler(event, ctx)).toBeUndefined();
623
+ expect(counters).toMatchObject({ fail: 1, shadowed: 1 });
624
+ expect(counters.blocked).toBeUndefined();
625
+ });
626
+ });
627
+
628
+ describe("multi-person threads", () => {
629
+ const BOB_PREFS = { language: "Spanish", tone: "warm" };
630
+ const bob = { externalId: "UBOB", id: "user-2", name: "Bob", comms: JSON.stringify(BOB_PREFS) };
631
+ const post = (message: string) => ({
632
+ tool: "slack-reply",
633
+ args: { taskId: TASK_ID, message },
634
+ requestInfo: { callOrigin: "mcp", agentId: "agent-1" },
635
+ });
636
+
637
+ test("mentionedSlackIds keeps first-appearance order and drops duplicates", () => {
638
+ expect(mod.mentionedSlackIds("<@UBOB|bob> and <@U1> then <@UBOB>")).toEqual(["UBOB", "U1"]);
639
+ expect(mod.mentionedSlackIds("no mentions, @bob")).toEqual([]);
640
+ });
641
+
642
+ test("a message that mentions another person is judged against that person's prefs", async () => {
643
+ jevScores({ language: 0.1 });
644
+ const { ctx, kv } = makeCtx({ mode: "enforce" }, { addressees: [bob] });
645
+ const result = await handler(post("<@UBOB> hola, el deploy tarda 4 minutos."), ctx);
646
+ const state = jevCalls[0]!.body.state;
647
+ expect(state.requester).toBe("Bob");
648
+ expect(state.requester_preferences).toEqual(BOB_PREFS);
649
+ expect(Object.keys(jevCalls[0]!.body.questions).sort()).toEqual(["language", "plain", "tone"]);
650
+ expect(result).toMatchObject({ action: "block" });
651
+ expect(result.reason).toContain("Bob's stated preferences");
652
+ expect(kv.last).toMatchObject({
653
+ judgedFor: "mention",
654
+ audienceId: "user-2",
655
+ requester: "Alice",
656
+ });
657
+ });
658
+
659
+ test("the first mentioned person with prefs wins; one without prefs is skipped", async () => {
660
+ jevScores(0.9);
661
+ const carol = { externalId: "UCAROL", id: "user-3", name: "Carol", comms: null };
662
+ const { ctx } = makeCtx({}, { addressees: [carol, bob] });
663
+ await handler(post("<@UCAROL> <@UBOB> deploys take 4 minutes."), ctx);
664
+ expect(jevCalls[0]!.body.state.requester).toBe("Bob");
665
+ });
666
+
667
+ test("no mention, or an unknown one, falls back to the requester", async () => {
668
+ for (const message of ["Deploys take 4 minutes.", "<@UNOBODY> deploys take 4 minutes."]) {
669
+ jevCalls = [];
670
+ jevScores(0.9);
671
+ const { ctx, kv } = makeCtx({}, { addressees: [bob] });
672
+ await handler(post(message), ctx);
673
+ expect(jevCalls[0]!.body.state.requester_preferences).toEqual(PREFS);
674
+ expect(kv.last).toMatchObject({ judgedFor: "requester", audience: "Alice" });
675
+ }
676
+ });
677
+
678
+ test("mentioning the requester keeps the requester's prefs", async () => {
679
+ jevScores(0.9);
680
+ const alice = {
681
+ externalId: "U1",
682
+ id: "user-1",
683
+ name: "Alice",
684
+ comms: JSON.stringify(BOB_PREFS),
685
+ };
686
+ const { ctx } = makeCtx({}, { addressees: [alice] });
687
+ await handler(post("<@U1> deploys take 4 minutes."), ctx);
688
+ expect(jevCalls[0]!.body.state.requester_preferences).toEqual(PREFS);
689
+ });
690
+
691
+ test.each([
692
+ null,
693
+ "{}",
694
+ ])("a self-mention without requester prefs (%s) skips to the next mentioned person with prefs", async (comms) => {
695
+ jevScores(0.9);
696
+ const alice = { externalId: "U1", id: "user-1", name: "Alice", comms };
697
+ const { ctx, kv } = makeCtx(
698
+ { mode: "enforce", plainMode: "off" },
699
+ { row: taskRow({ comms }), addressees: [alice, bob] },
700
+ );
701
+ await handler(post("<@U1> <@UBOB> deploys take 4 minutes."), ctx);
702
+ expect(jevCalls).toHaveLength(1);
703
+ expect(jevCalls[0]!.body.state.requester).toBe("Bob");
704
+ expect(jevCalls[0]!.body.state.requester_preferences).toEqual(BOB_PREFS);
705
+ expect(kv.last).toMatchObject({ judgedFor: "mention", audienceId: "user-2" });
706
+ });
707
+
708
+ test("a completion that mentions someone is judged for them too", async () => {
709
+ jevScores(0.9);
710
+ const { ctx } = makeCtx({}, { addressees: [bob] });
711
+ await handler(completion("<@UBOB> done, PR #12."), ctx);
712
+ expect(jevCalls[0]!.body.state.requester).toBe("Bob");
713
+ });
714
+
715
+ test("with mode off, plain alone skips the mention lookup", async () => {
716
+ jevScores(0.9);
717
+ const { ctx, queries } = makeCtx({ mode: "off" }, { addressees: [bob] });
718
+ await handler(post("<@UBOB> deploys take 4 minutes."), ctx);
719
+ expect(queries.some((q) => String(q.sql).includes("user_external_ids"))).toBe(false);
720
+ expect(jevCalls).toHaveLength(1);
721
+ });
722
+ });