@desplega.ai/agent-swarm 2.5.2 → 2.5.3

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 (143) hide show
  1. package/dist/{acp-adapter-csh0zz3b.js → acp-adapter-gr2fcyyj.js} +3 -3
  2. package/dist/{actions-6xx079d1.js → actions-wv766ghf.js} +18 -18
  3. package/dist/{amp-adapter-8n6f0hz8.js → amp-adapter-speb906t.js} +3 -3
  4. package/dist/{app-ft6r6v23.js → app-ag29n14f.js} +3 -3
  5. package/dist/{assistant-vt2cyfzd.js → assistant-3x8bz3eg.js} +19 -19
  6. package/dist/{boot-reembed-r3e808ky.js → boot-reembed-5b3d2eea.js} +1 -1
  7. package/dist/{boot-reembed-tgrfy27t.js → boot-reembed-jsmfrpzf.js} +1 -1
  8. package/dist/{boot-scrub-sweep-masdwxkm.js → boot-scrub-sweep-s73gerxy.js} +2 -2
  9. package/dist/{boot-seeds-0n82qc7q.js → boot-seeds-5zmax2wr.js} +4 -4
  10. package/dist/{claude-adapter-s7j47k06.js → claude-adapter-hswdnm3t.js} +3 -3
  11. package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-cvbzj1h6.js} +2 -2
  12. package/dist/{claude-managed-setup-3hzrgrnk.js → claude-managed-setup-3k8m3hhc.js} +2 -2
  13. package/dist/{claude-sdk-session-0zrdzyx2.js → claude-sdk-session-fkbyk66x.js} +2 -2
  14. package/dist/{cli-d172svcw.js → cli-1fc85mvt.js} +4 -4
  15. package/dist/{cli-3e9vs93g.js → cli-210xd1f4.js} +4 -4
  16. package/dist/{cli-vaxw3bg0.js → cli-25j01dpj.js} +3 -3
  17. package/dist/{cli-n6kmqngw.js → cli-35gtk1p6.js} +1 -1
  18. package/dist/{cli-fgn7yg5r.js → cli-3ngdk5te.js} +1 -1
  19. package/dist/{cli-7ccf7akh.js → cli-40jsnrjm.js} +2 -2
  20. package/dist/{cli-b1tv22wr.js → cli-502eqv93.js} +1 -1
  21. package/dist/{cli-ydg6ep51.js → cli-6n8axjv1.js} +1 -1
  22. package/dist/{cli-f55b6ky0.js → cli-8s5wx3nz.js} +2 -2
  23. package/dist/{cli-86zpfje5.js → cli-99xgw26r.js} +1 -1
  24. package/dist/{cli-xw9q173h.js → cli-9s05422w.js} +1 -1
  25. package/dist/{cli-h5d22pfs.js → cli-aecdcr77.js} +119 -282
  26. package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
  27. package/dist/{cli-15xd0j7w.js → cli-ce0ak9ms.js} +11 -11
  28. package/dist/{cli-y3dzwyy3.js → cli-e2ar4bp1.js} +71 -20
  29. package/dist/{cli-b6tnjdvx.js → cli-e8jyq7d8.js} +1 -1
  30. package/dist/{cli-6y43v79h.js → cli-egjggehm.js} +1 -1
  31. package/dist/{cli-cf63dnxj.js → cli-ewm3pwbq.js} +2 -2
  32. package/dist/{cli-bpbcpd8b.js → cli-fsdprwrg.js} +1 -1
  33. package/dist/{cli-qrjv7t18.js → cli-fzcn3d87.js} +1 -1
  34. package/dist/{cli-14v1sxxy.js → cli-gpsz7g88.js} +6 -6
  35. package/dist/{cli-wadzapwb.js → cli-hcpj2xqz.js} +1 -1
  36. package/dist/{cli-jmcjbjkt.js → cli-hhxjnjzs.js} +1 -1
  37. package/dist/{cli-j3dwd4kz.js → cli-jjznk3f7.js} +718 -108
  38. package/dist/{cli-csh8fnbg.js → cli-kxawjrxg.js} +147 -28
  39. package/dist/{cli-mntxtkhn.js → cli-mz9afxxf.js} +2 -1
  40. package/dist/{cli-0vgy5wfw.js → cli-n45mr7zh.js} +2 -2
  41. package/dist/{cli-aaqnqpyw.js → cli-p0rzjkya.js} +2 -2
  42. package/dist/{cli-3qv8naq8.js → cli-p6zs9h4y.js} +827 -107
  43. package/dist/{cli-e91vshb1.js → cli-p9rafdrb.js} +4 -4
  44. package/dist/{cli-edtm741g.js → cli-qgf8emrn.js} +5 -5
  45. package/dist/{cli-ha22h0eg.js → cli-qzme5949.js} +26 -26
  46. package/dist/{cli-xzge46sj.js → cli-s49hpn37.js} +2 -2
  47. package/dist/{cli-b63d08rs.js → cli-s53vp709.js} +1 -1
  48. package/dist/{cli-cyfgxnvb.js → cli-svrj71pg.js} +18 -16
  49. package/dist/{cli-4qkg9968.js → cli-vwn81jbs.js} +3 -3
  50. package/dist/{cli-f3pfn4x3.js → cli-w1zwctj2.js} +1 -1
  51. package/dist/{cli-kcnwn85g.js → cli-y561h5a9.js} +4 -4
  52. package/dist/{cli-n2918sed.js → cli-z21whrrg.js} +15 -15
  53. package/dist/cli.js +16 -16
  54. package/dist/{codex-adapter-brh2tcxr.js → codex-adapter-n8ka66vf.js} +2 -2
  55. package/dist/{codex-session-runner-0dnj21qs.js → codex-session-runner-fsebts37.js} +1 -1
  56. package/dist/{commands-1cnvseqx.js → commands-t04rt2pc.js} +2 -2
  57. package/dist/{controller-kc7s9xhn.js → controller-rgna04r2.js} +2 -2
  58. package/dist/{controllers-connect-jzx7t4bn.js → controllers-connect-bqm1rtft.js} +20 -20
  59. package/dist/{cursor-adapter-qpwp5sfg.js → cursor-adapter-swr2k95y.js} +1 -1
  60. package/dist/{db-v5ytg5pw.js → db-24ck09xy.js} +1 -1
  61. package/dist/{db-4vq88jkc.js → db-a4m7haw0.js} +3 -3
  62. package/dist/{dispatcher-trtcrqae.js → dispatcher-nwf9ss0q.js} +17 -17
  63. package/dist/{dsh-adapter-f20xwn1z.js → dsh-adapter-kpdnhjst.js} +2 -2
  64. package/dist/{external-channel-n9bymc2n.js → external-channel-j1e2p34p.js} +2 -2
  65. package/dist/{grok-adapter-4kpmmnr6.js → grok-adapter-qtjz0whk.js} +4 -4
  66. package/dist/{handlers-ptsraqa8.js → handlers-19xr6dnz.js} +20 -20
  67. package/dist/{heartbeat-60qtxtjf.js → heartbeat-vtesda5d.js} +20 -20
  68. package/dist/{hook-rbktcstg.js → hook-8wqaszda.js} +1 -1
  69. package/dist/{hook-ngtvh729.js → hook-b2hhj6rd.js} +1 -1
  70. package/dist/{http-z8xy72gp.js → http-mxcqhhcs.js} +2757 -2448
  71. package/dist/{index-a9nsavhx.js → index-1r8wfpzs.js} +1 -1
  72. package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
  73. package/dist/{index-s193j5fh.js → index-kw2mmat7.js} +20 -20
  74. package/dist/{index-06qkmcx6.js → index-n1vbvta9.js} +17 -9
  75. package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
  76. package/dist/{index-1cscynkb.js → index-spwxjgay.js} +17 -17
  77. package/dist/{index-r70q0xjx.js → index-srg522m6.js} +17 -17
  78. package/dist/{keepalive-a9srcphj.js → keepalive-dsd8rtgx.js} +3 -3
  79. package/dist/{lead-3devneqy.js → lead-zyerk1x2.js} +30 -30
  80. package/dist/{maintenance-0er16h2y.js → maintenance-jqmqwnet.js} +1 -1
  81. package/dist/{oauth-refresh-sweep-b65jcyw2.js → oauth-refresh-sweep-q2c3bnvm.js} +2 -2
  82. package/dist/{onboard-wqywz5py.js → onboard-w4yd7s8t.js} +2 -2
  83. package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-9tppvxar.js} +2 -2
  84. package/dist/{otel-impl-vk32s884.js → otel-impl-f36byzbd.js} +1 -1
  85. package/dist/{pricing-refresh-t27anv4z.js → pricing-refresh-xvak5p4v.js} +3 -3
  86. package/dist/{rbac-roles-bmcgsxjn.js → rbac-roles-682k2mt5.js} +1 -1
  87. package/dist/{rbac-roles-452665y0.js → rbac-roles-gfn30fb8.js} +2 -2
  88. package/dist/{render-v2-0ttsk2j4.js → render-v2-hgytz8nn.js} +3 -3
  89. package/dist/{resume-a26kvr41.js → resume-2dvq348g.js} +7 -5
  90. package/dist/{session-access-dgh9nkwk.js → session-access-7f21ekq9.js} +4 -4
  91. package/dist/{setup-9vc9qahm.js → setup-epffxtbk.js} +1 -1
  92. package/dist/{work-objects-vn8nkxwn.js → work-objects-xz1a5rqk.js} +20 -20
  93. package/dist/{worker-1x1h8vnm.js → worker-42t8emjt.js} +30 -30
  94. package/openapi.json +3045 -2456
  95. package/package.json +4 -4
  96. package/src/be/approval-config-question.ts +389 -0
  97. package/src/be/automation-preflight.ts +37 -3
  98. package/src/be/db/session-tree.ts +73 -15
  99. package/src/be/db/skill-search.ts +87 -0
  100. package/src/be/db/tasks/read.ts +29 -2
  101. package/src/be/db/workflow-runs.ts +54 -7
  102. package/src/be/db.ts +25 -5
  103. package/src/be/migrations/239_skills_fts.sql +40 -0
  104. package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
  105. package/src/be/migrations/241_skills_meta_fts.sql +39 -0
  106. package/src/be/swarm-config-guard.ts +23 -0
  107. package/src/extensions/catalog.generated.json +4 -4
  108. package/src/http/approval-requests.ts +209 -38
  109. package/src/http/config.ts +22 -37
  110. package/src/http/sessions.ts +27 -2
  111. package/src/integrations/chat-sdk/inbound.ts +5 -4
  112. package/src/providers/amp-adapter.ts +1 -1
  113. package/src/providers/dsh-adapter.ts +1 -1
  114. package/src/tests/approval-config-question.test.ts +702 -0
  115. package/src/tests/chat-sdk-inbound.test.ts +20 -0
  116. package/src/tests/dsh-adapter.test.ts +1 -1
  117. package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
  118. package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
  119. package/src/tests/list-endpoint-slimming.test.ts +33 -0
  120. package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
  121. package/src/tests/script-connections.test.ts +6 -0
  122. package/src/tests/sessions-tree.test.ts +168 -1
  123. package/src/tests/skill-search-long-content.test.ts +193 -0
  124. package/src/tests/skill-search.test.ts +173 -22
  125. package/src/tests/workflow-sub-workflow.test.ts +538 -1
  126. package/src/tools/request-human-input.ts +27 -5
  127. package/src/types.ts +7 -0
  128. package/src/workflows/approval-notifications.ts +10 -3
  129. package/src/workflows/checkpoint.ts +69 -35
  130. package/src/workflows/engine.ts +234 -87
  131. package/src/workflows/executors/base.ts +6 -0
  132. package/src/workflows/executors/human-in-the-loop.ts +25 -1
  133. package/src/workflows/executors/sub-workflow.ts +45 -16
  134. package/src/workflows/recovery.ts +63 -1
  135. package/src/workflows/resume.ts +60 -21
  136. package/src/workflows/retry-poller.ts +25 -4
  137. package/templates/extensions/comms-adherence-gate/README.md +38 -13
  138. package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
  139. package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
  140. package/templates/skills/turso-interaction/content.md +1 -1
  141. package/templates/skills/workflow-iterate/content.md +8 -1
  142. /package/dist/{cli-stxdrqs2.js → cli-avdvgcay.js} +0 -0
  143. /package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-tqsge1ay.js} +0 -0
@@ -0,0 +1,87 @@
1
+ import { getDbClient } from "./runtime";
2
+
3
+ // bm25() takes one weight per FTS column, in declaration order.
4
+ // skills_fts (migration 239): skill_id (UNINDEXED), name, description, content.
5
+ // skills_meta_fts (migration 241): skill_id (UNINDEXED), name, description.
6
+ const SKILLS_FTS_BM25 = "bm25(skills_fts, 0.0, 3.0, 2.0, 1.0)";
7
+ const SKILLS_META_FTS_BM25 = "bm25(skills_meta_fts, 0.0, 3.0, 2.0)";
8
+
9
+ // FTS5 bm25 normalizes by the length of the whole row, so in skills_fts a
10
+ // long SKILL.md body buries a name or description hit. skills_meta_fts holds
11
+ // only those short fields; its score is added at this multiple. On the live
12
+ // catalog, multiples from 2 to 10 rank the same; 3 is mid-range.
13
+ const META_SCORE_MULTIPLE = 3;
14
+
15
+ // English function words that agents put in natural-language queries. Each
16
+ // one matches most of the catalog ("a" matched 189 of 189 skills) and only
17
+ // adds noise to the ranking.
18
+ const STOPWORDS = new Set(
19
+ (
20
+ "a an and are as at be but by can do does for from get got have how i if in into is it its " +
21
+ "me my need of on or our should so that the their them then there these this to use using " +
22
+ "want was we what when where which who why will with would you your"
23
+ ).split(" "),
24
+ );
25
+
26
+ /**
27
+ * Build a skills_fts MATCH expression from free text. Every term is quoted as
28
+ * an FTS5 string, so user text cannot inject operators (NEAR, AND, -, *, ^,
29
+ * column filters). Stopwords are dropped and the remaining terms are OR-ed:
30
+ * ranked recall, ordered by bm25. Returns null when no term is left, so the
31
+ * caller falls back to the substring match.
32
+ */
33
+ export function buildSkillsFtsMatch(query: string): string | null {
34
+ const terms = query
35
+ .split(/[^\p{L}\p{N}_-]+/u)
36
+ .filter((term) => /[\p{L}\p{N}]/u.test(term) && !STOPWORDS.has(term.toLowerCase()))
37
+ .slice(0, 12);
38
+ if (terms.length === 0) return null;
39
+ return terms.map((term) => `"${term.replaceAll('"', '""')}"`).join(" OR ");
40
+ }
41
+
42
+ /**
43
+ * Enabled skills ranked by BM25: the skills_fts score (name 3, description 2,
44
+ * content 1) plus META_SCORE_MULTIPLE times the skills_meta_fts score (name 3,
45
+ * description 2). A skill whose name equals the whole query (case-insensitive)
46
+ * ranks first. Returns [] when the query has no indexable terms or the
47
+ * backend cannot run the FTS query, so the caller can fall back.
48
+ *
49
+ * `columns` is a select list over `skills`; it must not name a bare
50
+ * `skill_id` or `score`, which the joined `hits` and `meta` CTEs also expose.
51
+ */
52
+ export async function searchSkillRowsRanked<T>(
53
+ query: string,
54
+ limit: number,
55
+ columns: string,
56
+ ): Promise<T[]> {
57
+ const match = buildSkillsFtsMatch(query);
58
+ if (!match) return [];
59
+ const exactName = query.trim();
60
+ try {
61
+ return await getDbClient().query<T>(
62
+ `WITH hits AS (
63
+ SELECT skill_id, ${SKILLS_FTS_BM25} AS score FROM skills_fts WHERE skills_fts MATCH ?
64
+ ),
65
+ meta AS (
66
+ SELECT skill_id, ${SKILLS_META_FTS_BM25} AS score
67
+ FROM skills_meta_fts WHERE skills_meta_fts MATCH ?
68
+ )
69
+ SELECT ${columns} FROM skills
70
+ LEFT JOIN hits ON hits.skill_id = skills.id
71
+ LEFT JOIN meta ON meta.skill_id = skills.id
72
+ WHERE skills.isEnabled = 1
73
+ AND (hits.skill_id IS NOT NULL OR lower(skills.name) = lower(?))
74
+ ORDER BY lower(skills.name) = lower(?) DESC,
75
+ COALESCE(hits.score, 0) + ${META_SCORE_MULTIPLE} * COALESCE(meta.score, 0) ASC,
76
+ skills.name ASC
77
+ LIMIT ?`,
78
+ [match, match, exactName, exactName, limit],
79
+ );
80
+ } catch (err) {
81
+ console.warn(
82
+ "[skills-fts] ranked query failed, using substring match:",
83
+ (err as Error).message,
84
+ );
85
+ return [];
86
+ }
87
+ }
@@ -196,12 +196,39 @@ type AgentTaskTimelineRow = Pick<
196
196
  * past it).
197
197
  */
198
198
  const TASK_PREVIEW_BYTES = 4 * (TASK_PREVIEW_LENGTH + 1);
199
- const TASK_PREVIEW_SQL = `substr(CAST(agent_tasks.task AS BLOB), 1, ${TASK_PREVIEW_BYTES}) AS task`;
199
+ /**
200
+ * A leading `<sibling_tasks_in_progress>` block (`src/tasks/sibling-block.ts`)
201
+ * is generated context that can fill the whole preview, so the list row never
202
+ * reaches the ask. The preview starts after the block and the `\n\n`
203
+ * `prependSiblingBlock` puts after it. A block with no closing tag stays.
204
+ */
205
+ const SIBLING_OPEN = "<sibling_tasks_in_progress>";
206
+ const SIBLING_CLOSE = "</sibling_tasks_in_progress>";
207
+ const TASK_BLOB = "CAST(agent_tasks.task AS BLOB)";
208
+ const SIBLING_CLOSE_AT = `instr(${TASK_BLOB}, CAST('${SIBLING_CLOSE}' AS BLOB))`;
209
+ const SIBLING_END = `(${SIBLING_CLOSE_AT} + ${SIBLING_CLOSE.length})`;
210
+ const TASK_PREVIEW_START = `CASE
211
+ WHEN substr(${TASK_BLOB}, 1, ${SIBLING_OPEN.length}) = CAST('${SIBLING_OPEN}' AS BLOB)
212
+ AND ${SIBLING_CLOSE_AT} > 0
213
+ THEN ${SIBLING_END} + (substr(${TASK_BLOB}, ${SIBLING_END}, 2) = x'0a0a') * 2
214
+ ELSE 1 END`;
215
+ const TASK_PREVIEW_SQL = `substr(${TASK_BLOB}, ${TASK_PREVIEW_START}, ${TASK_PREVIEW_BYTES}) AS task`;
200
216
  // ignoreBOM keeps a leading U+FEFF, which the full-text preview keeps too.
201
217
  const taskPreviewDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
202
218
 
219
+ /** What `TASK_PREVIEW_START` does in SQL, for a full-text row. */
220
+ function skipSiblingBlock(text: string): string {
221
+ if (!text.startsWith(SIBLING_OPEN)) return text;
222
+ const close = text.indexOf(SIBLING_CLOSE);
223
+ if (close === -1) return text;
224
+ const end = close + SIBLING_CLOSE.length;
225
+ return text.slice(text.startsWith("\n\n", end) ? end + 2 : end);
226
+ }
227
+
203
228
  function taskPreview(task: TaskPreviewSource["task"]): string {
204
- const text = task instanceof Uint8Array ? taskPreviewDecoder.decode(task) : task;
229
+ // Bytes come from `TASK_PREVIEW_SQL`, which already skipped the block.
230
+ const text =
231
+ task instanceof Uint8Array ? taskPreviewDecoder.decode(task) : skipSiblingBlock(task ?? "");
205
232
  return dependencies.previewText(text, TASK_PREVIEW_LENGTH);
206
233
  }
207
234
 
@@ -4,7 +4,8 @@ import { scrubJsonValue } from "../scrub-json";
4
4
  import { getDbClient } from "./runtime";
5
5
 
6
6
  // Parent/child links between workflow runs (migration 203): a `sub-workflow`
7
- // step starts a child run that records the step in `parentStepId`.
7
+ // step starts a child run that records the step in `parentStepId`, and the
8
+ // step's `retryCount` at that time in `parentStepAttempt` (migration 240).
8
9
 
9
10
  const TERMINAL_STATUSES = new Set<WorkflowRunStatus>([
10
11
  "completed",
@@ -13,15 +14,60 @@ const TERMINAL_STATUSES = new Set<WorkflowRunStatus>([
13
14
  "skipped",
14
15
  ]);
15
16
 
16
- /** The id of the child run a `sub-workflow` step started, if any. */
17
- export async function getChildWorkflowRunId(parentStepId: string): Promise<string | null> {
17
+ /** The id of the child run a `sub-workflow` step started on `attempt`, if any. */
18
+ export async function getChildWorkflowRunId(
19
+ parentStepId: string,
20
+ attempt: number,
21
+ ): Promise<string | null> {
18
22
  const row = await getDbClient().get<{ id: string }>(
19
- "SELECT id FROM workflow_runs WHERE parentStepId = ?",
20
- [parentStepId],
23
+ "SELECT id FROM workflow_runs WHERE parentStepId = ? AND parentStepAttempt = ?",
24
+ [parentStepId, attempt],
21
25
  );
22
26
  return row?.id ?? null;
23
27
  }
24
28
 
29
+ /**
30
+ * True while `attempt` of the step is executing in a live run: the step is
31
+ * `running` with that `retryCount`. A child run is created only then, so a
32
+ * cancelled run, a step another attempt moved on, or a step already parked
33
+ * never gets a new child.
34
+ */
35
+ export async function isParentStepAttemptCurrent(
36
+ parentStepId: string,
37
+ attempt: number,
38
+ ): Promise<boolean> {
39
+ const row = await getDbClient().get<{ found: number }>(
40
+ `SELECT 1 AS found
41
+ FROM workflow_run_steps s
42
+ JOIN workflow_runs r ON r.id = s.runId
43
+ WHERE s.id = ? AND s.status = 'running' AND s.retryCount = ?
44
+ AND r.status IN ('running', 'waiting')`,
45
+ [parentStepId, attempt],
46
+ );
47
+ return row != null;
48
+ }
49
+
50
+ /**
51
+ * `sub-workflow` steps left `running` in a live run: each is its node's latest
52
+ * row, so a row a newer one superseded is not listed. The caller skips the
53
+ * ones this process is still executing.
54
+ */
55
+ export async function getRunningSubWorkflowSteps(): Promise<
56
+ Array<{ stepId: string; runId: string; nodeId: string }>
57
+ > {
58
+ return getDbClient().query<{ stepId: string; runId: string; nodeId: string }>(
59
+ `SELECT s.id AS stepId, s.runId AS runId, s.nodeId AS nodeId
60
+ FROM workflow_run_steps s
61
+ JOIN workflow_runs r ON r.id = s.runId
62
+ WHERE s.status = 'running' AND s.nodeType = 'sub-workflow'
63
+ AND r.status IN ('running', 'waiting')
64
+ AND s.rowid = (
65
+ SELECT MAX(rowid) FROM workflow_run_steps
66
+ WHERE runId = s.runId AND nodeId = s.nodeId
67
+ )`,
68
+ );
69
+ }
70
+
25
71
  /** Where a child run came from: the `sub-workflow` step that started it and that step's run. */
26
72
  export type WorkflowRunParent = {
27
73
  runId: string;
@@ -65,12 +111,13 @@ export async function getWorkflowRunLineage(runId: string, maxDepth: number): Pr
65
111
  return rows.map((row) => row.workflowId);
66
112
  }
67
113
 
68
- /** Waiting `sub-workflow` steps whose child run is terminal or deleted. */
114
+ /** Waiting `sub-workflow` steps whose current attempt's child run is terminal or deleted. */
69
115
  export async function getSettledChildRunParentSteps(): Promise<string[]> {
70
116
  const rows = await getDbClient().query<{ id: string }>(
71
117
  `SELECT s.id
72
118
  FROM workflow_run_steps s
73
- LEFT JOIN workflow_runs c ON c.parentStepId = s.id
119
+ LEFT JOIN workflow_runs c
120
+ ON c.parentStepId = s.id AND c.parentStepAttempt = s.retryCount
74
121
  WHERE s.status = 'waiting' AND s.nodeType = 'sub-workflow'
75
122
  AND (c.id IS NULL OR c.status IN ('completed', 'failed', 'cancelled', 'skipped'))`,
76
123
  );
package/src/be/db.ts CHANGED
@@ -208,6 +208,7 @@ import {
208
208
  sessionPageFields,
209
209
  sessionSummaryQuery,
210
210
  } from "./db/session-tree";
211
+ import { searchSkillRowsRanked } from "./db/skill-search";
211
212
  import { classifyTaskHumanFree, reclassifyTaskHumanFree } from "./db/tasks/human-free";
212
213
  import {
213
214
  type AgentTaskRow,
@@ -7842,6 +7843,8 @@ type WorkflowRunRow = {
7842
7843
  finishedAt: string | null;
7843
7844
  /** The `sub-workflow` step that started this run (migration 203). */
7844
7845
  parentStepId?: string | null;
7846
+ /** That step's attempt (`retryCount`) when it started this run (migration 240). */
7847
+ parentStepAttempt?: number | null;
7845
7848
  };
7846
7849
 
7847
7850
  /**
@@ -7862,6 +7865,7 @@ function rowToWorkflowRun(row: WorkflowRunRow, view: WorkflowPayloadView = "repl
7862
7865
  lastUpdatedAt: normalizeDateRequired(row.lastUpdatedAt),
7863
7866
  finishedAt: normalizeDate(row.finishedAt) ?? undefined,
7864
7867
  parentStepId: row.parentStepId ?? undefined,
7868
+ parentStepAttempt: row.parentStepId ? (row.parentStepAttempt ?? 0) : undefined,
7865
7869
  };
7866
7870
  return defineWorkflowPayload(run, "context", row.context, row.context_replay, view, {
7867
7871
  runId: row.id,
@@ -7876,10 +7880,12 @@ export async function createWorkflowRun(data: {
7876
7880
  triggerData?: unknown;
7877
7881
  createdBy?: string;
7878
7882
  parentStepId?: string;
7883
+ /** The parent step's `retryCount` when it started this run (migration 240). */
7884
+ parentStepAttempt?: number;
7879
7885
  }): Promise<WorkflowRun> {
7880
7886
  const now = new Date().toISOString();
7881
7887
  const row = await getDbClient().get<WorkflowRunRow>(
7882
- `INSERT INTO workflow_runs (id, workflowId, triggerType, startedAt, triggerData, created_by, parentStepId) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *`,
7888
+ `INSERT INTO workflow_runs (id, workflowId, triggerType, startedAt, triggerData, created_by, parentStepId, parentStepAttempt) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
7883
7889
  [
7884
7890
  data.id,
7885
7891
  data.workflowId,
@@ -7888,6 +7894,7 @@ export async function createWorkflowRun(data: {
7888
7894
  data.triggerData ? scrubJsonValue(data.triggerData) : null,
7889
7895
  data.createdBy ?? null,
7890
7896
  data.parentStepId ?? null,
7897
+ data.parentStepAttempt ?? 0,
7891
7898
  ],
7892
7899
  );
7893
7900
  if (!row) throw new Error("Failed to create workflow run");
@@ -7992,9 +7999,10 @@ export type WorkflowRunListOptions = {
7992
7999
  };
7993
8000
 
7994
8001
  // Everything but `context`. `triggerData` stays: it is small, and stored
7995
- // scripts read it off list rows (dedupe by trigger, failure reports).
8002
+ // scripts read it off list rows (dedupe by trigger, failure reports). The
8003
+ // parent link and attempt stay so a list row identifies a child's invocation.
7996
8004
  const WORKFLOW_RUN_SUMMARY_COLUMNS =
7997
- "id, workflowId, status, triggerType, triggerData, error, created_by, startedAt, lastUpdatedAt, finishedAt";
8005
+ "id, workflowId, status, triggerType, triggerData, error, created_by, startedAt, lastUpdatedAt, finishedAt, parentStepId, parentStepAttempt";
7998
8006
 
7999
8007
  export type WorkflowRunPage = {
8000
8008
  runs: WorkflowRun[];
@@ -10989,11 +10997,23 @@ export async function listSkills(filters?: SkillFilters): Promise<Skill[]> {
10989
10997
  return rows.map(rowToSkill);
10990
10998
  }
10991
10999
 
11000
+ /**
11001
+ * Ranked skill search (see searchSkillRowsRanked). Falls back to the unranked
11002
+ * substring match when the ranked query finds nothing, e.g. a partial word or
11003
+ * a query of only stopwords. The fallback also ranks an exact name first.
11004
+ */
10992
11005
  export async function searchSkills(
10993
11006
  query: string,
10994
11007
  limit = 20,
10995
11008
  includeContent = true,
10996
11009
  ): Promise<Skill[]> {
11010
+ const ranked = await searchSkillRowsRanked<SkillRow>(
11011
+ query,
11012
+ limit,
11013
+ includeContent === false ? SKILL_SLIM_COLUMNS : "skills.*",
11014
+ );
11015
+ if (ranked.length > 0) return ranked.map(rowToSkill);
11016
+
10997
11017
  const tokens = query.split(/\s+/).filter(Boolean);
10998
11018
  const columns = includeContent === false ? SKILL_SLIM_COLUMNS : "*";
10999
11019
  const searchClauses = tokens.map(() => "(name LIKE ? OR description LIKE ? OR content LIKE ?)");
@@ -11002,8 +11022,8 @@ export async function searchSkills(
11002
11022
  return [term, term, term];
11003
11023
  });
11004
11024
  const rows = await getDbClient().query<SkillRow>(
11005
- `SELECT ${columns} FROM skills WHERE ${[...searchClauses, "isEnabled = 1"].join(" AND ")} ORDER BY name ASC LIMIT ?`,
11006
- [...params, limit],
11025
+ `SELECT ${columns} FROM skills WHERE ${[...searchClauses, "isEnabled = 1"].join(" AND ")} ORDER BY lower(name) = lower(?) DESC, name ASC LIMIT ?`,
11026
+ [...params, query.trim(), limit],
11007
11027
  );
11008
11028
  return rows.map(rowToSkill);
11009
11029
  }
@@ -0,0 +1,40 @@
1
+ -- Full-text index for ranked skill search (searchSkills / skill-search tool).
2
+ -- Same shape as memory_fts: an id column FTS5 does not index, the searchable
3
+ -- text columns, and the porter tokenizer. Column order matters: searchSkills
4
+ -- passes bm25() weights positionally (skill_id, name, description, content).
5
+ CREATE VIRTUAL TABLE IF NOT EXISTS skills_fts USING fts5(
6
+ skill_id UNINDEXED,
7
+ name,
8
+ description,
9
+ content,
10
+ tokenize='porter unicode61'
11
+ );
12
+
13
+ INSERT INTO skills_fts(skill_id, name, description, content)
14
+ SELECT id, name, description, content FROM skills
15
+ WHERE id NOT IN (SELECT skill_id FROM skills_fts WHERE skill_id IS NOT NULL);
16
+
17
+ -- Triggers keep the index in sync on every write path, including FK cascade
18
+ -- deletes. A later migration that rebuilds `skills` (CREATE new + DROP +
19
+ -- RENAME) drops these triggers with the old table and must recreate them;
20
+ -- src/tests/skill-search.test.ts asserts they exist on the migrated schema.
21
+ CREATE TRIGGER IF NOT EXISTS skills_fts_insert
22
+ AFTER INSERT ON skills
23
+ BEGIN
24
+ INSERT INTO skills_fts(skill_id, name, description, content)
25
+ VALUES (NEW.id, NEW.name, NEW.description, NEW.content);
26
+ END;
27
+
28
+ CREATE TRIGGER IF NOT EXISTS skills_fts_update
29
+ AFTER UPDATE OF id, name, description, content ON skills
30
+ BEGIN
31
+ DELETE FROM skills_fts WHERE skill_id = OLD.id;
32
+ INSERT INTO skills_fts(skill_id, name, description, content)
33
+ VALUES (NEW.id, NEW.name, NEW.description, NEW.content);
34
+ END;
35
+
36
+ CREATE TRIGGER IF NOT EXISTS skills_fts_delete
37
+ AFTER DELETE ON skills
38
+ BEGIN
39
+ DELETE FROM skills_fts WHERE skill_id = OLD.id;
40
+ END;
@@ -0,0 +1,37 @@
1
+ -- Key a child workflow run by its parent step AND that step's attempt.
2
+ --
3
+ -- The retry poller re-executes a failed step under the same step id, with
4
+ -- `retryCount` bumped. Keyed by step id alone, the retried `sub-workflow`
5
+ -- step reconnected to the failed child of the previous attempt and failed
6
+ -- again without starting a new one.
7
+
8
+ ALTER TABLE workflow_runs ADD COLUMN parentStepAttempt INTEGER NOT NULL DEFAULT 0;
9
+
10
+ -- Before this migration a step had at most one child, created on whichever
11
+ -- attempt first got that far (a retry after a synchronous failure, such as a
12
+ -- disabled child workflow, creates it on attempt 1 or later), and every later
13
+ -- attempt reconnected to it. Hand that child to the attempt that settles or
14
+ -- re-executes it now, or recovery would look up the step's current attempt,
15
+ -- find no child and fail the parent as "deleted":
16
+ -- - a step queued for a retry (`failed` with `nextRetryAt`) starts its next
17
+ -- attempt at `retryCount`, which must start a new child, so the existing
18
+ -- child belongs to the previous attempt;
19
+ -- - any other step (running, waiting, terminal) owns it at `retryCount`.
20
+ -- A child whose step is gone keeps attempt 0.
21
+ UPDATE workflow_runs
22
+ SET parentStepAttempt = (
23
+ SELECT CASE
24
+ WHEN s.status = 'failed' AND s.nextRetryAt IS NOT NULL
25
+ THEN MAX(s.retryCount - 1, 0)
26
+ ELSE s.retryCount
27
+ END
28
+ FROM workflow_run_steps s
29
+ WHERE s.id = workflow_runs.parentStepId
30
+ )
31
+ WHERE parentStepId IS NOT NULL
32
+ AND EXISTS (SELECT 1 FROM workflow_run_steps s WHERE s.id = workflow_runs.parentStepId);
33
+
34
+ DROP INDEX IF EXISTS idx_workflow_runs_parentStepId;
35
+
36
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_runs_parentStep_attempt
37
+ ON workflow_runs(parentStepId, parentStepAttempt) WHERE parentStepId IS NOT NULL;
@@ -0,0 +1,39 @@
1
+ -- Name + description index for ranked skill search. skills_fts (239) scores
2
+ -- the whole row, and FTS5 bm25 length-normalizes over every column, so a
3
+ -- long SKILL.md body buries a name or description hit. This index holds only
4
+ -- the short fields; searchSkillRowsRanked adds its score to skills_fts's.
5
+ -- Column order matters: bm25() weights are positional (skill_id, name,
6
+ -- description).
7
+ CREATE VIRTUAL TABLE IF NOT EXISTS skills_meta_fts USING fts5(
8
+ skill_id UNINDEXED,
9
+ name,
10
+ description,
11
+ tokenize='porter unicode61'
12
+ );
13
+
14
+ INSERT INTO skills_meta_fts(skill_id, name, description)
15
+ SELECT id, name, description FROM skills
16
+ WHERE id NOT IN (SELECT skill_id FROM skills_meta_fts WHERE skill_id IS NOT NULL);
17
+
18
+ -- Same sync rules as skills_fts: a later migration that rebuilds `skills`
19
+ -- must recreate these triggers; src/tests/skill-search.test.ts asserts them.
20
+ CREATE TRIGGER IF NOT EXISTS skills_meta_fts_insert
21
+ AFTER INSERT ON skills
22
+ BEGIN
23
+ INSERT INTO skills_meta_fts(skill_id, name, description)
24
+ VALUES (NEW.id, NEW.name, NEW.description);
25
+ END;
26
+
27
+ CREATE TRIGGER IF NOT EXISTS skills_meta_fts_update
28
+ AFTER UPDATE OF id, name, description ON skills
29
+ BEGIN
30
+ DELETE FROM skills_meta_fts WHERE skill_id = OLD.id;
31
+ INSERT INTO skills_meta_fts(skill_id, name, description)
32
+ VALUES (NEW.id, NEW.name, NEW.description);
33
+ END;
34
+
35
+ CREATE TRIGGER IF NOT EXISTS skills_meta_fts_delete
36
+ AFTER DELETE ON skills
37
+ BEGIN
38
+ DELETE FROM skills_meta_fts WHERE skill_id = OLD.id;
39
+ END;
@@ -628,6 +628,29 @@ export function validateConfigValue(key: string, value: unknown): string | null
628
628
  return validator ? validator(value) : null;
629
629
  }
630
630
 
631
+ /**
632
+ * Every check `PUT /api/config` runs before a write: scope and scopeId shape,
633
+ * reserved and internal keys, the per-key value validator and the env-export
634
+ * rule. Shared with HITL `config` questions so the two writers cannot drift.
635
+ */
636
+ export function validateConfigEntry(entry: {
637
+ scope: "global" | "agent" | "repo";
638
+ scopeId?: string | null;
639
+ key: string;
640
+ value: unknown;
641
+ exportToEnv?: boolean;
642
+ }): string | null {
643
+ if (entry.scope === "global" && entry.scopeId) return "Global scope must not have scopeId";
644
+ if ((entry.scope === "agent" || entry.scope === "repo") && !entry.scopeId) {
645
+ return "Agent/repo scope requires scopeId";
646
+ }
647
+ if (isReservedConfigKey(entry.key)) return reservedKeyError(entry.key).message;
648
+ if (isInternalConfigKey(entry.key)) return internalConfigKeyError(entry.key).message;
649
+ return (
650
+ validateConfigValue(entry.key, entry.value) ?? validateExportToEnv(entry.key, entry.exportToEnv)
651
+ );
652
+ }
653
+
631
654
  /**
632
655
  * Rejects `exportToEnv: true` for keys only the API server reads. Omitting the
633
656
  * flag is fine: the write path stores false for those keys.
@@ -4,17 +4,17 @@
4
4
  "manifest": {
5
5
  "$schema": "../manifest.schema.json",
6
6
  "name": "comms-adherence-gate",
7
- "description": "Judges every Slack-facing store-progress completion against the requester's comms preferences and latest request with Jev (TypeSafe or OpenRouter), and in enforce blocks a non-adherent completion once per task",
8
- "version": "1.5.0",
7
+ "description": "Judges every Slack-facing store-progress completion and agent Slack post against the comms preferences of the requester, or of the person it @-mentions, their latest request and a plain-language rubric with Jev (TypeSafe or OpenRouter), and in enforce blocks a failing call once per task",
8
+ "version": "1.7.0",
9
9
  "runtime": "api",
10
10
  "assets": {
11
11
  "hooks": "hooks.ts"
12
12
  }
13
13
  },
14
14
  "files": {
15
- "hooks.ts": "import { type ApiCtx, block, type SwarmExtension } from \"swarm-extension\";\nimport { z } from \"zod\";\n\n/**\n * comms-adherence-gate\n *\n * When an agent calls `store-progress` with status \"completed\" on a task that\n * reaches a human in Slack, judge the `output` against the requesting user's\n * stored comms preferences (users.metadata.$.comms) with Jev, and against what\n * that user actually asked for in their latest message. An explicit request\n * (\"tell me a long joke\", \"explain in detail\") outranks the stored default, so\n * a requested long answer is not a verbosity failure.\n *\n * Transport (config.provider): Jev is served by TypeSafe directly and by\n * OpenRouter's Decisions endpoint. Both take the same {model, state, questions}\n * body and return the same `answers` shape. `auto` uses TYPESAFE_API_KEY when\n * set, else OPENROUTER_API_KEY.\n *\n * modes (config.mode):\n * off -> never evaluate\n * shadow -> evaluate + record, never block (default)\n * enforce -> record, and block the completion up to maxBlocksPerTask times\n * per task with actionable feedback\n *\n * Fails OPEN on every error: a judge outage must never wall the swarm.\n * Every verdict lands in KV namespace `ext:comms-gate`.\n */\n\nexport const config = z.object({\n mode: z.enum([\"off\", \"shadow\", \"enforce\"]).default(\"shadow\"),\n /** A dimension scoring below this fails. */\n threshold: z.number().min(0).max(1).default(0.5),\n timeoutMs: z.number().int().positive().default(4000),\n maxBlocksPerTask: z.number().int().min(0).default(1),\n provider: z.enum([\"auto\", \"typesafe\", \"openrouter\"]).default(\"auto\"),\n /** Jev model on the TypeSafe transport. */\n model: z.string().default(\"jev-latest\"),\n /** Jev model on the OpenRouter transport. */\n openrouterModel: z.string().default(\"typesafe/jev-1.13\"),\n /** Explicit TypeSafe key; otherwise TYPESAFE_API_KEY from the API process env. */\n apiKey: z.string().optional(),\n});\n\ntype Cfg = z.infer<typeof config>;\ntype Swarm = ApiCtx[\"swarm\"];\n\nconst NS = \"ext:comms-gate\";\nconst TYPESAFE_ENDPOINT = \"https://api.typesafe.ai/v1/systemone\";\nconst OPENROUTER_ENDPOINT = \"https://openrouter.ai/api/alpha/decisions\";\nconst UUID_RE = /^[0-9a-fA-F-]{36}$/;\n// The dispatcher cancels a handler after 5 s; leave room for the DB read and KV writes.\nconst MAX_TIMEOUT_MS = 4000;\n\nexport const cfgOf = (raw: unknown): Cfg => {\n const parsed = config.safeParse(raw ?? {});\n return parsed.success ? parsed.data : config.parse({});\n};\n\n// kv keys accept [a-zA-Z0-9._:/%-]\nconst safeKey = (raw: string | undefined | null): string =>\n (raw && raw.length > 0 ? raw : \"unknown\").replace(/[^a-zA-Z0-9._-]/g, \"_\").slice(0, 120);\n\n/** SDK envelopes: {success, data:{...}} or the bare payload. */\ntype Envelope = {\n data?: { rows?: unknown[]; value?: unknown };\n rows?: unknown[];\n value?: unknown;\n} | null;\n\nconst cell = (row: unknown, i: number, key: string): unknown =>\n Array.isArray(row) ? row[i] : (row as Record<string, unknown>)?.[key];\n\n/**\n * The newest human message sits at the END of a Slack task: the\n * `<thread_context>` blob comes first and can run to thousands of chars, so a\n * leading slice of `agent_tasks.task` shows the judge everything except the\n * actual request. Take the tail, then the part after the last `</thread_context>`.\n */\nconst CTX_END = \"</thread_context>\";\nexport const latestAsk = (tail: string): string => {\n const end = tail.lastIndexOf(CTX_END);\n let out = (end >= 0 ? tail.slice(end + CTX_END.length) : tail)\n .replace(/\\[Thread follow-up[^\\]]*\\]/g, \"\")\n .trim();\n // Some tasks end with the context block; fall back to the last messages in it.\n if (out.length < 8) out = (end >= 0 ? tail.slice(0, end) : tail).trim().slice(-800);\n return out.slice(0, 900);\n};\n\n// Preferences are the DEFAULT. A direct request outranks them.\nconst JUDGING_RULES = [\n \"The requester's explicit instruction in latest_request OUTRANKS requester_preferences. Preferences describe the DEFAULT; they never override a direct request.\",\n \"If latest_request asks for something long, detailed, creative, a story, a joke, a full explanation or a document, then length and structure serving that request are CORRECT and must NOT be penalized.\",\n \"Judge whether agent_message serves what latest_request actually asked for, written in the requester's manner. Do not judge it against the default preference when the request overrides it.\",\n];\n\ntype Prefs = { verbosity?: unknown; tone?: unknown; language?: unknown } & Record<string, unknown>;\n\n/** One `noul` question per stated preference the gate knows how to judge. */\nexport const buildQuestions = (prefs: Prefs): Record<string, unknown> => {\n const questions: Record<string, unknown> = {};\n if (prefs.verbosity) {\n questions.verbosity = {\n type: \"noul\",\n instructions:\n \"Does agent_message respect the requester's verbosity preference, AS OVERRIDDEN by any explicit instruction in latest_request? Apply judging_rules.\",\n criteria: {\n true: \"Length and structure fit what latest_request asked for (or the stated preference when the request does not override it), and it leads with the result.\",\n false:\n \"Too long for what was asked, or it opens with preamble, recaps the request, narrates the process, or ends with an offer to elaborate. Length explicitly requested in latest_request is NOT a failure.\",\n },\n };\n }\n if (prefs.tone) {\n questions.tone = {\n type: \"noul\",\n instructions:\n \"Does agent_message match the requester's stated tone preference, as overridden by any explicit instruction in latest_request? Apply judging_rules.\",\n criteria: {\n true: \"The tone matches what the requester asked for.\",\n false:\n \"The tone conflicts with it (for example hedging, flattery or filler when the requester asked for direct and plain). A voice appropriate to an explicitly requested creative or narrative form is NOT a failure.\",\n },\n };\n }\n if (prefs.language) {\n questions.language = {\n type: \"noul\",\n instructions: \"Is agent_message written in the requester's stated language?\",\n criteria: {\n true: \"Written in the stated language.\",\n false: \"Written in a different language.\",\n },\n };\n }\n return questions;\n};\n\nconst secretCache = new Map<string, { value: string; at: number }>();\nconst KEY_TTL_MS = 5 * 60 * 1000;\nconst usable = (v: string): boolean => Boolean(v) && !v.startsWith(\"[REDACTED\");\nconst envOf = (name: string): string => String(process.env[name] ?? \"\");\n\n/**\n * Resolve a secret: explicit value, then the API process env. The API process loads\n * global swarm_config rows into process.env at boot and on config reload.\n * ctx.swarm.config_get cannot serve it: the SDK scrubs secrets to \"[REDACTED:<name>]\".\n */\nconst secret = (name: string, explicit?: string): string => {\n const cached = secretCache.get(name);\n if (cached && Date.now() - cached.at < KEY_TTL_MS) return cached.value;\n const value = [String(explicit ?? \"\"), envOf(name)].find(usable) ?? \"\";\n if (usable(value)) secretCache.set(name, { value, at: Date.now() });\n return value;\n};\n\n/** Test hook: forget cached secrets. */\nexport const resetKeyCache = (): void => {\n secretCache.clear();\n};\n\nexport type JevTransport = {\n provider: \"typesafe\" | \"openrouter\";\n endpoint: string;\n key: string;\n model: string;\n};\n\n/**\n * Pick the Jev transport. `auto` prefers TypeSafe and falls back to OpenRouter.\n * Throws when the chosen provider has no usable key; the handler fails open.\n */\nexport const resolveTransport = (cfg: Cfg): JevTransport => {\n const typesafe = (): JevTransport | null => {\n const key = secret(\"TYPESAFE_API_KEY\", cfg.apiKey);\n return usable(key)\n ? { provider: \"typesafe\", endpoint: TYPESAFE_ENDPOINT, key, model: cfg.model }\n : null;\n };\n const openrouter = (): JevTransport | null => {\n const key = secret(\"OPENROUTER_API_KEY\");\n return usable(key)\n ? { provider: \"openrouter\", endpoint: OPENROUTER_ENDPOINT, key, model: cfg.openrouterModel }\n : null;\n };\n const transport =\n cfg.provider === \"typesafe\"\n ? typesafe()\n : cfg.provider === \"openrouter\"\n ? openrouter()\n : (typesafe() ?? openrouter());\n if (transport) return transport;\n throw new Error(\n cfg.provider === \"typesafe\"\n ? \"no TYPESAFE_API_KEY\"\n : cfg.provider === \"openrouter\"\n ? \"no OPENROUTER_API_KEY\"\n : \"no TYPESAFE_API_KEY or OPENROUTER_API_KEY\",\n );\n};\n\n/** One Jev call; returns the `noul` score per question. Throws on any failure. */\nexport const askJev = async (\n state: Record<string, unknown>,\n questions: Record<string, unknown>,\n transport: JevTransport,\n cfg: Cfg,\n outer?: AbortSignal,\n): Promise<{ scores: Record<string, number>; latencyMs: number }> => {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), Math.min(cfg.timeoutMs, MAX_TIMEOUT_MS));\n const onOuterAbort = () => controller.abort();\n outer?.addEventListener(\"abort\", onOuterAbort);\n const t0 = Date.now();\n try {\n const res = await fetch(transport.endpoint, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\", Authorization: `Bearer ${transport.key}` },\n body: JSON.stringify({ state, model: transport.model, questions }),\n signal: controller.signal,\n });\n if (!res.ok) throw new Error(`${transport.provider} ${res.status}`);\n const body = (await res.json()) as { answers?: Record<string, { noul?: unknown }> } | null;\n const answers = body?.answers;\n if (!answers || typeof answers !== \"object\") throw new Error(\"no answers in response\");\n const scores: Record<string, number> = {};\n for (const [id, a] of Object.entries(answers)) {\n if (typeof a?.noul === \"number\") scores[id] = a.noul;\n }\n if (Object.keys(scores).length === 0) throw new Error(\"no scores in response\");\n return { scores, latencyMs: Date.now() - t0 };\n } finally {\n clearTimeout(timer);\n outer?.removeEventListener(\"abort\", onOuterAbort);\n }\n};\n\nconst SELFTEST = \"comms-gate-selftest\";\nconst SELFTEST_PREFS = {\n tone: \"direct, plain, no hedging\",\n language: \"English\",\n verbosity:\n \"very terse - answer in the first sentence, ~400 chars max. No preamble, no recap, no closing offer.\",\n};\nconst SELFTEST_BAD =\n \"Great question! Let me walk you through what I did here. First I looked at the task, then I reviewed the relevant files, and after careful consideration I decided to go with option B. I hope this helps - let me know if you would like me to elaborate further on any of the above!\";\nconst SELFTEST_GOOD =\n \"Went with option B: it is the only one that keeps the migration reversible. PR #123.\";\n\n/**\n * Run the judge on a known-bad (positive control, must score below threshold) and a\n * known-good (negative control, must score at or above it) message and record both\n * under `selftest:last`. Proves key, egress and KV from inside the API runtime\n * without touching real traffic.\n */\nconst selfTest = async (swarm: Swarm, cfg: Cfg, signal?: AbortSignal): Promise<void> => {\n const started = Date.now();\n try {\n const transport = resolveTransport(cfg);\n const questions = { verbosity: buildQuestions(SELFTEST_PREFS).verbosity };\n const judge = (message: string) =>\n askJev(\n { requester_preferences: SELFTEST_PREFS, agent_message: message },\n questions,\n transport,\n cfg,\n signal,\n ).then((r) => r.scores.verbosity ?? null);\n const [bad, good] = await Promise.all([judge(SELFTEST_BAD), judge(SELFTEST_GOOD)]);\n await swarm.kv_set({\n namespace: NS,\n key: \"selftest:last\",\n value: {\n at: new Date().toISOString(),\n provider: transport.provider,\n threshold: cfg.threshold,\n positiveControlBadScore: bad,\n negativeControlGoodScore: good,\n controlsOk:\n typeof bad === \"number\" &&\n typeof good === \"number\" &&\n bad < cfg.threshold &&\n good >= cfg.threshold,\n latencyMs: Date.now() - started,\n },\n });\n } catch (err) {\n await swarm\n .kv_set({\n namespace: NS,\n key: \"selftest:last\",\n value: {\n at: new Date().toISOString(),\n error: String(err).slice(0, 400),\n latencyMs: Date.now() - started,\n },\n })\n .catch(() => {});\n }\n};\n\nconst TASK_SQL = `SELECT t.source, t.slackThreadTs, t.agentId, t.requestedByUserId,\n json_extract(u.metadata, '$.comms') AS comms, u.name,\n substr(t.task, 1, 400) AS askHead,\n substr(t.task, -1400) AS askTail\nFROM agent_tasks t\nLEFT JOIN users u ON u.id = t.requestedByUserId\nWHERE t.id = ?`;\n\nconst LEAD_SQL = \"SELECT isLead FROM agents WHERE id = ?\";\n\n/**\n * Mirrors the `task.progress.write` rule store-progress enforces\n * (src/rbac/legacy-policy.ts): the caller owns the task, the task is\n * unassigned, or the caller is a lead. Lead status comes from the caller's\n * agents row, never from tool args. Extension callers return before this.\n */\nconst mayWriteProgress = async (\n swarm: Swarm,\n callerId: string | undefined,\n taskAgentId: string,\n): Promise<boolean> => {\n if (!callerId) return false;\n if (!taskAgentId || taskAgentId === callerId) return true;\n const q = (await swarm.db_query({ sql: LEAD_SQL, params: [callerId] })) as Envelope;\n const row = (q?.data?.rows ?? q?.rows ?? [])[0];\n return Number(cell(row, 0, \"isLead\") ?? 0) === 1;\n};\n\nconst extension: SwarmExtension = (api) => {\n api.on(\"pre.tool.call\", async (event, ctx) => {\n if (event.tool !== \"store-progress\") return;\n if (event.requestInfo?.callOrigin === \"extension\") return;\n\n const cfg = cfgOf(ctx.config);\n if (cfg.mode === \"off\") return;\n\n const args = (event.args ?? {}) as Record<string, unknown>;\n\n // Self-test hatch: a progress call whose text starts with the sentinel runs\n // the controls and records them under `selftest:last`.\n if (args.status !== \"completed\") {\n if (typeof args.progress === \"string\" && args.progress.startsWith(SELFTEST)) {\n await selfTest(ctx.swarm, cfg, ctx.signal);\n }\n return;\n }\n\n const output = typeof args.output === \"string\" ? args.output.trim() : \"\";\n if (!output) return;\n\n const taskId = String(args.taskId ?? event.requestInfo?.sourceTaskId ?? \"\");\n if (!UUID_RE.test(taskId)) return;\n\n try {\n const q = (await ctx.swarm.db_query({ sql: TASK_SQL, params: [taskId] })) as Envelope;\n const row = (q?.data?.rows ?? q?.rows ?? [])[0];\n if (!row) return;\n\n const source = String(cell(row, 0, \"source\") ?? \"\");\n const slackThreadTs = cell(row, 1, \"slackThreadTs\");\n const agentId = String(cell(row, 2, \"agentId\") ?? \"\");\n const requesterId = String(cell(row, 3, \"requestedByUserId\") ?? \"\");\n const commsRaw = cell(row, 4, \"comms\");\n const requesterName = String(cell(row, 5, \"name\") ?? requesterId);\n const threadOpening = String(cell(row, 6, \"askHead\") ?? \"\");\n const ask = latestAsk(String(cell(row, 7, \"askTail\") ?? \"\"));\n\n // Judge only a completion store-progress will accept, so no agent can ship\n // another task's thread and preferences to the external judge.\n if (!(await mayWriteProgress(ctx.swarm, event.requestInfo?.agentId, agentId))) return;\n\n // Only where the text actually reaches a human in Slack.\n if (source !== \"slack\" && !slackThreadTs) return;\n if (!commsRaw) return; // no stated preferences -> nothing to judge against\n const prefs = (typeof commsRaw === \"string\" ? JSON.parse(commsRaw) : commsRaw) as Prefs;\n if (!prefs || typeof prefs !== \"object\") return;\n\n const questions = buildQuestions(prefs);\n if (Object.keys(questions).length === 0) return;\n\n const transport = resolveTransport(cfg);\n const { scores, latencyMs } = await askJev(\n {\n role: \"You audit an AI agent's final message to the human who requested the work.\",\n requester: requesterName,\n requester_preferences: prefs,\n latest_request: ask,\n thread_opening: threadOpening,\n judging_rules: JUDGING_RULES,\n agent_message: output,\n },\n questions,\n transport,\n cfg,\n ctx.signal,\n );\n\n const failed = Object.entries(scores)\n .filter(([, v]) => v < cfg.threshold)\n .map(([k]) => k);\n\n const verdict = {\n at: new Date().toISOString(),\n taskId,\n agentId,\n source,\n requester: requesterName,\n requesterId,\n provider: transport.provider,\n scores,\n failed,\n threshold: cfg.threshold,\n mode: cfg.mode,\n latencyMs,\n preferences: prefs,\n askPreview: ask.slice(0, 400),\n outputPreview: output.slice(0, 700),\n };\n\n await ctx.swarm.kv_incr({ namespace: NS, key: \"total\" });\n await ctx.swarm.kv_set({ namespace: NS, key: \"last\", value: verdict });\n\n if (failed.length === 0) {\n await ctx.swarm.kv_incr({ namespace: NS, key: \"pass\" });\n return;\n }\n\n await ctx.swarm.kv_incr({ namespace: NS, key: \"fail\" });\n for (const dim of failed) {\n await ctx.swarm.kv_incr({ namespace: NS, key: `dim:${safeKey(dim)}` });\n }\n\n const blocksKey = `blocks:${safeKey(taskId)}`;\n // kv_get answers {success, data:{value}}, and {success:false} when absent.\n const prior = (await ctx.swarm.kv_get({ namespace: NS, key: blocksKey })) as Envelope;\n const priorBlocks = Number(prior?.data?.value ?? prior?.value ?? 0) || 0;\n const willBlock = cfg.mode === \"enforce\" && priorBlocks < cfg.maxBlocksPerTask;\n\n await ctx.swarm.kv_set({\n namespace: NS,\n key: `fail:${Date.now()}:${safeKey(taskId)}`,\n value: { ...verdict, action: willBlock ? \"blocked\" : \"recorded\" },\n });\n\n if (!willBlock) {\n await ctx.swarm.kv_incr({\n namespace: NS,\n key: cfg.mode === \"enforce\" ? \"escaped\" : \"shadowed\",\n });\n return;\n }\n\n await ctx.swarm.kv_incr({ namespace: NS, key: \"blocked\" });\n await ctx.swarm.kv_incr({ namespace: NS, key: blocksKey });\n\n const detail = failed\n .map(\n (d) =>\n `${d} (${scores[d]!.toFixed(2)} vs ${cfg.threshold}) — requested: ${String(prefs[d] ?? \"\").slice(0, 220)}`,\n )\n .join(\"\\n\");\n\n return block(\n [\n `Comms gate: this output does not match ${requesterName}'s stated preferences.`,\n detail,\n \"Rewrite the output to match and call store-progress again. Do not change the facts, only how they are said.\",\n ].join(\"\\n\"),\n );\n } catch (err) {\n // Fail open, always.\n try {\n await ctx.swarm.kv_incr({ namespace: NS, key: \"error\" });\n await ctx.swarm.kv_set({\n namespace: NS,\n key: \"error:last\",\n value: { at: new Date().toISOString(), taskId, error: String(err).slice(0, 400) },\n });\n } catch {\n /* ignore */\n }\n ctx.log.warn(\"comms-adherence-gate: failing open\", { error: String(err) });\n return;\n }\n });\n};\n\nexport default extension;\n"
15
+ "hooks.ts": "import { type ApiCtx, block, type SwarmExtension } from \"swarm-extension\";\nimport { z } from \"zod\";\n\n/**\n * comms-adherence-gate\n *\n * When an agent calls `store-progress` with status \"completed\" on a task that\n * reaches a human in Slack, or posts to Slack itself (`slack-reply`,\n * `slack-post`, `slack-start-thread`, `slack-update`), judge the text with Jev:\n * against the requesting user's stored comms preferences\n * (users.metadata.$.comms) and what that user actually asked for in their\n * latest message, and against a plain-language rubric that applies to every\n * message whatever preferences exist. An explicit request (\"tell me a long\n * joke\", \"explain in detail\") outranks the stored default, so a requested long\n * answer is not a verbosity failure.\n *\n * Threads with several people: a message that @-mentions someone is judged\n * against the first mentioned person with stored preferences, skipping a\n * requester without preferences; otherwise against the requester. `plain` applies to everyone.\n *\n * Transport (config.provider): Jev is served by TypeSafe directly and by\n * OpenRouter's Decisions endpoint. Both take the same {model, state, questions}\n * body and return the same `answers` shape. `auto` uses TYPESAFE_API_KEY when\n * set, else OPENROUTER_API_KEY.\n *\n * modes (config.mode for the preference dimensions, config.plainMode for `plain`):\n * off -> never evaluate\n * shadow -> evaluate + record, never block (default)\n * enforce -> record, and block the call up to maxBlocksPerTask times per task\n * with actionable feedback. The budget is shared by both modes and\n * by every gated tool.\n *\n * Fails OPEN on every error: a judge outage must never wall the swarm.\n * Every verdict lands in KV namespace `ext:comms-gate`.\n */\n\nexport const config = z.object({\n mode: z.enum([\"off\", \"shadow\", \"enforce\"]).default(\"shadow\"),\n /** A preference dimension scoring below this fails. */\n threshold: z.number().min(0).max(1).default(0.5),\n /** The `plain` dimension, judged on every message. Independent of `mode`. */\n plainMode: z.enum([\"off\", \"shadow\", \"enforce\"]).default(\"shadow\"),\n /**\n * `plain` scoring below this fails. Jev scores dense status text low and\n * plain text high with little in between, so this sits far below `threshold`.\n */\n plainThreshold: z.number().min(0).max(1).default(0.25),\n timeoutMs: z.number().int().positive().default(4000),\n maxBlocksPerTask: z.number().int().min(0).default(1),\n provider: z.enum([\"auto\", \"typesafe\", \"openrouter\"]).default(\"auto\"),\n /** Jev model on the TypeSafe transport. */\n model: z.string().default(\"jev-latest\"),\n /** Jev model on the OpenRouter transport. */\n openrouterModel: z.string().default(\"typesafe/jev-1.13\"),\n /** Explicit TypeSafe key; otherwise TYPESAFE_API_KEY from the API process env. */\n apiKey: z.string().optional(),\n});\n\ntype Cfg = z.infer<typeof config>;\ntype Swarm = ApiCtx[\"swarm\"];\n\nconst NS = \"ext:comms-gate\";\nconst TYPESAFE_ENDPOINT = \"https://api.typesafe.ai/v1/systemone\";\nconst OPENROUTER_ENDPOINT = \"https://openrouter.ai/api/alpha/decisions\";\nconst UUID_RE = /^[0-9a-fA-F-]{36}$/;\n/** MCP tools that post agent-authored text to Slack, all with a `message` arg. */\nexport const SLACK_TOOLS = new Set([\n \"slack-reply\",\n \"slack-post\",\n \"slack-start-thread\",\n \"slack-update\",\n]);\n// The dispatcher cancels a handler after 5 s; leave room for the DB read and KV writes.\nconst MAX_TIMEOUT_MS = 4000;\n\nexport const cfgOf = (raw: unknown): Cfg => {\n const parsed = config.safeParse(raw ?? {});\n return parsed.success ? parsed.data : config.parse({});\n};\n\n// kv keys accept [a-zA-Z0-9._:/%-]\nconst safeKey = (raw: string | undefined | null): string =>\n (raw && raw.length > 0 ? raw : \"unknown\").replace(/[^a-zA-Z0-9._-]/g, \"_\").slice(0, 120);\n\n/** SDK envelopes: {success, data:{...}} or the bare payload. */\ntype Envelope = {\n data?: { rows?: unknown[]; value?: unknown };\n rows?: unknown[];\n value?: unknown;\n} | null;\n\nconst cell = (row: unknown, i: number, key: string): unknown =>\n Array.isArray(row) ? row[i] : (row as Record<string, unknown>)?.[key];\n\n/**\n * The newest human message sits at the END of a Slack task: the\n * `<thread_context>` blob comes first and can run to thousands of chars, so a\n * leading slice of `agent_tasks.task` shows the judge everything except the\n * actual request. Take the tail, then the part after the last `</thread_context>`.\n */\nconst CTX_END = \"</thread_context>\";\nexport const latestAsk = (tail: string): string => {\n const end = tail.lastIndexOf(CTX_END);\n let out = (end >= 0 ? tail.slice(end + CTX_END.length) : tail)\n .replace(/\\[Thread follow-up[^\\]]*\\]/g, \"\")\n .trim();\n // Some tasks end with the context block; fall back to the last messages in it.\n if (out.length < 8) out = (end >= 0 ? tail.slice(0, end) : tail).trim().slice(-800);\n return out.slice(0, 900);\n};\n\n// Preferences are the DEFAULT. A direct request outranks them.\nconst JUDGING_RULES = [\n \"The requester's explicit instruction in latest_request OUTRANKS requester_preferences. Preferences describe the DEFAULT; they never override a direct request.\",\n \"If latest_request asks for something long, detailed, creative, a story, a joke, a full explanation or a document, then length and structure serving that request are CORRECT and must NOT be penalized.\",\n \"Judge whether agent_message serves what latest_request actually asked for, written in the requester's manner. Do not judge it against the default preference when the request overrides it.\",\n];\n\ntype Prefs = { verbosity?: unknown; tone?: unknown; language?: unknown } & Record<string, unknown>;\n\n/**\n * The `plain` rubric, from the `comms` skill (Simpler and Precise modes). Jev\n * separates dense from plain text reliably only with contrasting examples; a\n * prose-only rubric scored a plain rewrite as low as the dense original.\n */\nexport const PLAIN_RULES = [\n \"Examples of NOT plain: 'p50 Δ +120ms vs baseline (n=37)', 'error rate 2.1% → 0.4% (cohort B, ±0.3 at 95%)', 'cost $-42/day', 'first-call hit ratio 0.61', 'drift unmeasured; 2 flaky'.\",\n \"Examples of plain: 'Pages load 120 ms slower than last week, across 37 runs', 'Errors dropped from 2 in 100 requests to 4 in 1,000', 'We spend $42 a day less', 'PR #123 keeps the migration reversible', 'Lead saves about $75 a week'.\",\n \"Code identifiers, task ids, PR numbers, links, file paths and version numbers are fine as they are.\",\n];\n\nexport const PLAIN_QUESTION = {\n type: \"noul\",\n instructions:\n \"Is agent_message written like the plain examples in plain_language_rules rather than like the NOT plain ones? Ignore length and tone.\",\n criteria: {\n true: \"Written like the plain examples: everyday words and numbers with units and direction.\",\n false:\n \"Written like the NOT plain examples: statistics shorthand, abbreviations, signed or cryptic numbers.\",\n },\n};\n\n/** Structured output (an outputSchema answer) is for machines; `plain` skips it. */\nexport const looksLikeJson = (text: string): boolean => {\n if (!/^[[{]/.test(text)) return false;\n try {\n JSON.parse(text);\n return true;\n } catch {\n return false;\n }\n};\n\n/** One `noul` question per stated preference the gate knows how to judge. */\nexport const buildQuestions = (prefs: Prefs): Record<string, unknown> => {\n const questions: Record<string, unknown> = {};\n if (prefs.verbosity) {\n questions.verbosity = {\n type: \"noul\",\n instructions:\n \"Does agent_message respect the requester's verbosity preference, AS OVERRIDDEN by any explicit instruction in latest_request? Apply judging_rules.\",\n criteria: {\n true: \"Length and structure fit what latest_request asked for (or the stated preference when the request does not override it), and it leads with the result.\",\n false:\n \"Too long for what was asked, or it opens with preamble, recaps the request, narrates the process, or ends with an offer to elaborate. Length explicitly requested in latest_request is NOT a failure.\",\n },\n };\n }\n if (prefs.tone) {\n questions.tone = {\n type: \"noul\",\n instructions:\n \"Does agent_message match the requester's stated tone preference, as overridden by any explicit instruction in latest_request? Apply judging_rules.\",\n criteria: {\n true: \"The tone matches what the requester asked for.\",\n false:\n \"The tone conflicts with it (for example hedging, flattery or filler when the requester asked for direct and plain). A voice appropriate to an explicitly requested creative or narrative form is NOT a failure.\",\n },\n };\n }\n if (prefs.language) {\n questions.language = {\n type: \"noul\",\n instructions: \"Is agent_message written in the requester's stated language?\",\n criteria: {\n true: \"Written in the stated language.\",\n false: \"Written in a different language.\",\n },\n };\n }\n return questions;\n};\n\nconst secretCache = new Map<string, { value: string; at: number }>();\nconst KEY_TTL_MS = 5 * 60 * 1000;\nconst usable = (v: string): boolean => Boolean(v) && !v.startsWith(\"[REDACTED\");\nconst envOf = (name: string): string => String(process.env[name] ?? \"\");\n\n/**\n * Resolve a secret: explicit value, then the API process env. The API process loads\n * global swarm_config rows into process.env at boot and on config reload.\n * ctx.swarm.config_get cannot serve it: the SDK scrubs secrets to \"[REDACTED:<name>]\".\n */\nconst secret = (name: string, explicit?: string): string => {\n const cached = secretCache.get(name);\n if (cached && Date.now() - cached.at < KEY_TTL_MS) return cached.value;\n const value = [String(explicit ?? \"\"), envOf(name)].find(usable) ?? \"\";\n if (usable(value)) secretCache.set(name, { value, at: Date.now() });\n return value;\n};\n\n/** Test hook: forget cached secrets. */\nexport const resetKeyCache = (): void => {\n secretCache.clear();\n};\n\nexport type JevTransport = {\n provider: \"typesafe\" | \"openrouter\";\n endpoint: string;\n key: string;\n model: string;\n};\n\n/**\n * Pick the Jev transport. `auto` prefers TypeSafe and falls back to OpenRouter.\n * Throws when the chosen provider has no usable key; the handler fails open.\n */\nexport const resolveTransport = (cfg: Cfg): JevTransport => {\n const typesafe = (): JevTransport | null => {\n const key = secret(\"TYPESAFE_API_KEY\", cfg.apiKey);\n return usable(key)\n ? { provider: \"typesafe\", endpoint: TYPESAFE_ENDPOINT, key, model: cfg.model }\n : null;\n };\n const openrouter = (): JevTransport | null => {\n const key = secret(\"OPENROUTER_API_KEY\");\n return usable(key)\n ? { provider: \"openrouter\", endpoint: OPENROUTER_ENDPOINT, key, model: cfg.openrouterModel }\n : null;\n };\n const transport =\n cfg.provider === \"typesafe\"\n ? typesafe()\n : cfg.provider === \"openrouter\"\n ? openrouter()\n : (typesafe() ?? openrouter());\n if (transport) return transport;\n throw new Error(\n cfg.provider === \"typesafe\"\n ? \"no TYPESAFE_API_KEY\"\n : cfg.provider === \"openrouter\"\n ? \"no OPENROUTER_API_KEY\"\n : \"no TYPESAFE_API_KEY or OPENROUTER_API_KEY\",\n );\n};\n\n/** One Jev call; returns the `noul` score per question. Throws on any failure. */\nexport const askJev = async (\n state: Record<string, unknown>,\n questions: Record<string, unknown>,\n transport: JevTransport,\n cfg: Cfg,\n outer?: AbortSignal,\n): Promise<{ scores: Record<string, number>; latencyMs: number }> => {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), Math.min(cfg.timeoutMs, MAX_TIMEOUT_MS));\n const onOuterAbort = () => controller.abort();\n outer?.addEventListener(\"abort\", onOuterAbort);\n const t0 = Date.now();\n try {\n const res = await fetch(transport.endpoint, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\", Authorization: `Bearer ${transport.key}` },\n body: JSON.stringify({ state, model: transport.model, questions }),\n signal: controller.signal,\n });\n if (!res.ok) throw new Error(`${transport.provider} ${res.status}`);\n const body = (await res.json()) as { answers?: Record<string, { noul?: unknown }> } | null;\n const answers = body?.answers;\n if (!answers || typeof answers !== \"object\") throw new Error(\"no answers in response\");\n const scores: Record<string, number> = {};\n for (const [id, a] of Object.entries(answers)) {\n if (typeof a?.noul === \"number\") scores[id] = a.noul;\n }\n if (Object.keys(scores).length === 0) throw new Error(\"no scores in response\");\n return { scores, latencyMs: Date.now() - t0 };\n } finally {\n clearTimeout(timer);\n outer?.removeEventListener(\"abort\", onOuterAbort);\n }\n};\n\nconst SELFTEST = \"comms-gate-selftest\";\nconst SELFTEST_PREFS = {\n tone: \"direct, plain, no hedging\",\n language: \"English\",\n verbosity:\n \"very terse - answer in the first sentence, ~400 chars max. No preamble, no recap, no closing offer.\",\n};\nconst SELFTEST_BAD =\n \"Great question! Let me walk you through what I did here. First I looked at the task, then I reviewed the relevant files, and after careful consideration I decided to go with option B. I hope this helps - let me know if you would like me to elaborate further on any of the above!\";\nconst SELFTEST_GOOD =\n \"Went with option B: it is the only one that keeps the migration reversible. PR #123.\";\n// A real dense monitor post a requester had to ask to have rewritten, and that rewrite.\nconst SELFTEST_PLAIN_BAD =\n \"Prompt cache monitor, day 7 verdict for v2.0.0: partial. Lead first-call read median 12.4k (baseline v1.173.0, n 410) → 19.1k tokens (n 2234; expected 25.0k-40.0k). First-call saving about $75/week on Lead vs v1.173.0 (expected $129; sampling noise ±$24 at 95%, one baseline version, day-to-day drift not measured); session $ change $-167/week; fleet first-call saving $-7/week.\";\nconst SELFTEST_PLAIN_GOOD =\n \"Cheaper, but half what we hoped: Lead saves about $75 a week, not $129. Whole Lead tasks still cost $167 a week more, probably from other releases and heavier work, not this change.\";\n\nconst below = (bad: unknown, good: unknown, threshold: number): boolean =>\n typeof bad === \"number\" && typeof good === \"number\" && bad < threshold && good >= threshold;\n\n/**\n * Run the judge on known-bad (positive control, must score below threshold) and\n * known-good (negative control, must score at or above it) messages and record\n * them under `selftest:last`: one pair for verbosity, one for `plain`. Proves key,\n * egress and KV from inside the API runtime without touching real traffic.\n */\nconst selfTest = async (swarm: Swarm, cfg: Cfg, signal?: AbortSignal): Promise<void> => {\n const started = Date.now();\n try {\n const transport = resolveTransport(cfg);\n const judge = (message: string, dim: \"verbosity\" | \"plain\") =>\n askJev(\n dim === \"plain\"\n ? { plain_language_rules: PLAIN_RULES, agent_message: message }\n : { requester_preferences: SELFTEST_PREFS, agent_message: message },\n { [dim]: dim === \"plain\" ? PLAIN_QUESTION : buildQuestions(SELFTEST_PREFS).verbosity },\n transport,\n cfg,\n signal,\n ).then((r) => r.scores[dim] ?? null);\n const [bad, good, plainBad, plainGood] = await Promise.all([\n judge(SELFTEST_BAD, \"verbosity\"),\n judge(SELFTEST_GOOD, \"verbosity\"),\n judge(SELFTEST_PLAIN_BAD, \"plain\"),\n judge(SELFTEST_PLAIN_GOOD, \"plain\"),\n ]);\n await swarm.kv_set({\n namespace: NS,\n key: \"selftest:last\",\n value: {\n at: new Date().toISOString(),\n provider: transport.provider,\n threshold: cfg.threshold,\n positiveControlBadScore: bad,\n negativeControlGoodScore: good,\n controlsOk: below(bad, good, cfg.threshold),\n plainThreshold: cfg.plainThreshold,\n plainPositiveControlBadScore: plainBad,\n plainNegativeControlGoodScore: plainGood,\n plainControlsOk: below(plainBad, plainGood, cfg.plainThreshold),\n latencyMs: Date.now() - started,\n },\n });\n } catch (err) {\n await swarm\n .kv_set({\n namespace: NS,\n key: \"selftest:last\",\n value: {\n at: new Date().toISOString(),\n error: String(err).slice(0, 400),\n latencyMs: Date.now() - started,\n },\n })\n .catch(() => {});\n }\n};\n\nconst TASK_SQL = `SELECT t.source, t.slackThreadTs, t.agentId, t.requestedByUserId,\n json_extract(u.metadata, '$.comms') AS comms, u.name,\n substr(t.task, 1, 400) AS askHead,\n substr(t.task, -1400) AS askTail,\n t.creatorAgentId\nFROM agent_tasks t\nLEFT JOIN users u ON u.id = t.requestedByUserId\nWHERE t.id = ?`;\n\nconst LEAD_SQL = \"SELECT isLead FROM agents WHERE id = ?\";\n\n/** Slack user mentions in agent text: `<@U123>` or `<@U123|name>`. */\nconst MENTION_RE = /<@([UW][A-Z0-9]+)(?:\\|[^>]*)?>/g;\nconst MAX_MENTIONS = 5;\n\n/** Mentioned Slack user ids in order of first appearance, capped. */\nexport const mentionedSlackIds = (text: string): string[] => {\n const ids: string[] = [];\n for (const m of text.matchAll(MENTION_RE)) {\n if (!ids.includes(m[1]!)) ids.push(m[1]!);\n if (ids.length >= MAX_MENTIONS) break;\n }\n return ids;\n};\n\nconst addresseeSql = (n: number) => `SELECT x.externalId, u.id, u.name,\n json_extract(u.metadata, '$.comms') AS comms\nFROM user_external_ids x\nJOIN users u ON u.id = x.userId\nWHERE x.kind = 'slack' AND x.externalId IN (${Array(n).fill(\"?\").join(\", \")})`;\n\nconst parsePrefs = (raw: unknown): Prefs | null => {\n if (!raw) return null;\n const parsed = typeof raw === \"string\" ? JSON.parse(raw) : raw;\n return parsed && typeof parsed === \"object\" && Object.keys(parsed).length > 0\n ? (parsed as Prefs)\n : null;\n};\n\ntype Audience = { id: string; name: string; prefs: Prefs; via: \"mention\" | \"requester\" };\n\n/**\n * Whose preferences a message is judged against. In a thread with several\n * people, the message is for whoever it @-mentions: the first mentioned person\n * with stored preferences wins, including the requester only if they have prefs.\n * Without such a mention, the task requester (the\n * person whose message created this task). One SQL read, only when the text\n * mentions someone; no judge call.\n */\nconst resolveAudience = async (\n swarm: Swarm,\n text: string,\n requester: Audience,\n): Promise<Audience> => {\n const ids = mentionedSlackIds(text);\n if (ids.length === 0) return requester;\n const q = (await swarm.db_query({ sql: addresseeSql(ids.length), params: ids })) as Envelope;\n const rows = q?.data?.rows ?? q?.rows ?? [];\n for (const slackId of ids) {\n const row = rows.find((r) => String(cell(r, 0, \"externalId\")) === slackId);\n if (!row) continue;\n const userId = String(cell(row, 1, \"id\") ?? \"\");\n // A requester with stored preferences keeps their own task context.\n if (userId && userId === requester.id) {\n if (Object.keys(requester.prefs).length > 0) return requester;\n continue;\n }\n const prefs = parsePrefs(cell(row, 3, \"comms\"));\n if (!prefs) continue;\n return { id: userId, name: String(cell(row, 2, \"name\") ?? userId), prefs, via: \"mention\" };\n }\n return requester;\n};\n\n/**\n * Mirrors the `task.progress.write` rule store-progress enforces\n * (src/rbac/legacy-policy.ts): the caller owns the task, the task is\n * unassigned, or the caller is a lead. Lead status comes from the caller's\n * agents row, never from tool args. Extension callers return before this.\n */\nconst mayWriteProgress = async (\n swarm: Swarm,\n callerId: string | undefined,\n taskAgentId: string,\n): Promise<boolean> => {\n if (!callerId) return false;\n if (!taskAgentId || taskAgentId === callerId) return true;\n const q = (await swarm.db_query({ sql: LEAD_SQL, params: [callerId] })) as Envelope;\n const row = (q?.data?.rows ?? q?.rows ?? [])[0];\n return Number(cell(row, 0, \"isLead\") ?? 0) === 1;\n};\n\n/** The text a gated call would put in front of a human, and the task it belongs to. */\nconst gatedText = (\n tool: string,\n args: Record<string, unknown>,\n sourceTaskId: string | undefined,\n): { text: string; taskId: string } | null => {\n if (tool === \"store-progress\") {\n if (args.status !== \"completed\") return null;\n return {\n text: typeof args.output === \"string\" ? args.output.trim() : \"\",\n taskId: String(args.taskId ?? sourceTaskId ?? \"\"),\n };\n }\n if (SLACK_TOOLS.has(tool)) {\n // slack-reply names its task; the other tools post from the caller's task context.\n const named = tool === \"slack-reply\" ? args.taskId : undefined;\n return {\n text: typeof args.message === \"string\" ? args.message.trim() : \"\",\n taskId: String(named ?? sourceTaskId ?? \"\"),\n };\n }\n return null;\n};\n\nconst extension: SwarmExtension = (api) => {\n api.on(\"pre.tool.call\", async (event, ctx) => {\n if (event.tool !== \"store-progress\" && !SLACK_TOOLS.has(event.tool)) return;\n const origin = event.requestInfo?.callOrigin;\n if (origin === \"extension\") return;\n\n const cfg = cfgOf(ctx.config);\n if (cfg.mode === \"off\" && cfg.plainMode === \"off\") return;\n\n const args = (event.args ?? {}) as Record<string, unknown>;\n\n // Self-test hatch: a progress call whose text starts with the sentinel runs\n // the controls and records them under `selftest:last`.\n if (\n event.tool === \"store-progress\" &&\n args.status !== \"completed\" &&\n typeof args.progress === \"string\" &&\n args.progress.startsWith(SELFTEST)\n ) {\n await selfTest(ctx.swarm, cfg, ctx.signal);\n return;\n }\n\n const gated = gatedText(event.tool, args, event.requestInfo?.sourceTaskId);\n if (!gated?.text) return;\n const { text, taskId } = gated;\n if (!UUID_RE.test(taskId)) return;\n\n try {\n const q = (await ctx.swarm.db_query({ sql: TASK_SQL, params: [taskId] })) as Envelope;\n const row = (q?.data?.rows ?? q?.rows ?? [])[0];\n if (!row) return;\n\n const source = String(cell(row, 0, \"source\") ?? \"\");\n const slackThreadTs = cell(row, 1, \"slackThreadTs\");\n const agentId = String(cell(row, 2, \"agentId\") ?? \"\");\n const requesterId = String(cell(row, 3, \"requestedByUserId\") ?? \"\");\n const commsRaw = cell(row, 4, \"comms\");\n const requesterName = String(cell(row, 5, \"name\") ?? (requesterId || \"the requester\"));\n const threadOpening = String(cell(row, 6, \"askHead\") ?? \"\");\n const ask = latestAsk(String(cell(row, 7, \"askTail\") ?? \"\"));\n\n // Judge only a call the tool itself will accept, so no agent can ship another\n // task's thread and preferences to the external judge. store-progress follows\n // its write rule; the Slack tools follow slack-reply's: the caller is the\n // task's assignee or its creator.\n const callerId = event.requestInfo?.agentId;\n const creatorAgentId = String(cell(row, 8, \"creatorAgentId\") ?? \"\");\n const allowed =\n event.tool === \"store-progress\"\n ? await mayWriteProgress(ctx.swarm, callerId, agentId)\n : Boolean(callerId) && (callerId === agentId || callerId === creatorAgentId);\n if (!allowed) return;\n\n // A completion reaches a human only on a Slack task; a Slack tool always does.\n if (event.tool === \"store-progress\" && source !== \"slack\" && !slackThreadTs) return;\n\n const requester: Audience = {\n id: requesterId,\n name: requesterName,\n prefs: parsePrefs(commsRaw) ?? {},\n via: \"requester\",\n };\n // `plain` alone judges no one's preferences, so it skips the lookup.\n const audience =\n cfg.mode === \"off\" ? requester : await resolveAudience(ctx.swarm, text, requester);\n const { prefs, name: audienceName } = audience;\n\n const questions: Record<string, unknown> = cfg.mode === \"off\" ? {} : buildQuestions(prefs);\n // Only a store-progress outputSchema answer is machine JSON; a Slack post is\n // always read by a human, JSON or not.\n if (cfg.plainMode !== \"off\" && (event.tool !== \"store-progress\" || !looksLikeJson(text)))\n questions.plain = PLAIN_QUESTION;\n if (Object.keys(questions).length === 0) return;\n\n const transport = resolveTransport(cfg);\n const { scores, latencyMs } = await askJev(\n {\n role: \"You audit an AI agent's message to the human who requested the work.\",\n requester: audienceName,\n requester_preferences: prefs,\n latest_request: ask,\n thread_opening: threadOpening,\n judging_rules: JUDGING_RULES,\n plain_language_rules: PLAIN_RULES,\n agent_message: text,\n },\n questions,\n transport,\n cfg,\n ctx.signal,\n );\n\n const limitOf = (dim: string) => (dim === \"plain\" ? cfg.plainThreshold : cfg.threshold);\n const failed = Object.entries(scores)\n .filter(([k, v]) => v < limitOf(k))\n .map(([k]) => k);\n // Script SDK calls never reach pre.tool.call today; if one does, record it only.\n const blocking =\n origin === \"script-sdk\"\n ? []\n : failed.filter((d) => (d === \"plain\" ? cfg.plainMode : cfg.mode) === \"enforce\");\n\n const verdict = {\n at: new Date().toISOString(),\n tool: event.tool,\n taskId,\n agentId,\n source,\n requester: requesterName,\n requesterId,\n judgedFor: audience.via,\n audienceId: audience.id,\n audience: audienceName,\n provider: transport.provider,\n scores,\n failed,\n threshold: cfg.threshold,\n plainThreshold: cfg.plainThreshold,\n mode: cfg.mode,\n plainMode: cfg.plainMode,\n latencyMs,\n preferences: prefs,\n askPreview: ask.slice(0, 400),\n outputPreview: text.slice(0, 700),\n };\n\n await ctx.swarm.kv_incr({ namespace: NS, key: \"total\" });\n await ctx.swarm.kv_set({ namespace: NS, key: \"last\", value: verdict });\n\n if (failed.length === 0) {\n await ctx.swarm.kv_incr({ namespace: NS, key: \"pass\" });\n return;\n }\n\n await ctx.swarm.kv_incr({ namespace: NS, key: \"fail\" });\n for (const dim of failed) {\n await ctx.swarm.kv_incr({ namespace: NS, key: `dim:${safeKey(dim)}` });\n }\n\n const blocksKey = `blocks:${safeKey(taskId)}`;\n // kv_get answers {success, data:{value}}, and {success:false} when absent.\n const prior = (await ctx.swarm.kv_get({ namespace: NS, key: blocksKey })) as Envelope;\n const priorBlocks = Number(prior?.data?.value ?? prior?.value ?? 0) || 0;\n const willBlock = blocking.length > 0 && priorBlocks < cfg.maxBlocksPerTask;\n\n await ctx.swarm.kv_set({\n namespace: NS,\n key: `fail:${Date.now()}:${safeKey(taskId)}`,\n value: { ...verdict, action: willBlock ? \"blocked\" : \"recorded\" },\n });\n\n if (!willBlock) {\n await ctx.swarm.kv_incr({\n namespace: NS,\n key: blocking.length > 0 ? \"escaped\" : \"shadowed\",\n });\n return;\n }\n\n await ctx.swarm.kv_incr({ namespace: NS, key: \"blocked\" });\n await ctx.swarm.kv_incr({ namespace: NS, key: blocksKey });\n\n const lines = [\n blocking.includes(\"plain\")\n ? `Comms gate: this message is not in plain words for ${audienceName}.`\n : `Comms gate: this output does not match ${audienceName}'s stated preferences.`,\n ];\n for (const d of blocking) {\n const score = `${d} (${scores[d]!.toFixed(2)} vs ${limitOf(d)})`;\n lines.push(\n d === \"plain\"\n ? `${score}: a reader outside the swarm cannot follow it on first read.`\n : `${score}: requested ${String(prefs[d] ?? \"\").slice(0, 220)}`,\n );\n }\n lines.push(\n blocking.includes(\"plain\")\n ? `Rewrite the message with the \\`comms\\` skill (Simpler mode): common words, one idea per sentence, every number with a unit and a direction word, shorthand explained or replaced. Keep every number, name, link and hedge verbatim, then call ${event.tool} again.`\n : `Rewrite the message to match and call ${event.tool} again. Do not change the facts, only how they are said.`,\n );\n return block(lines.join(\"\\n\"));\n } catch (err) {\n // Fail open, always.\n try {\n await ctx.swarm.kv_incr({ namespace: NS, key: \"error\" });\n await ctx.swarm.kv_set({\n namespace: NS,\n key: \"error:last\",\n value: {\n at: new Date().toISOString(),\n tool: event.tool,\n taskId,\n error: String(err).slice(0, 400),\n },\n });\n } catch {\n /* ignore */\n }\n ctx.log.warn(\"comms-adherence-gate: failing open\", { error: String(err) });\n return;\n }\n });\n};\n\nexport default extension;\n"
16
16
  },
17
- "readme": "# comms-adherence-gate\n\nAgents finish Slack tasks with a final message a human reads. This extension judges that message against the requester's stated comms preferences and their latest request with Jev, [TypeSafe](https://typesafe.ai)'s decision model. In `enforce` it blocks a non-adherent completion once and tells the agent what to fix.\n\nInstall config:\n\n```json\n{ \"mode\": \"shadow\" }\n```\n\nStart in `shadow`, read the verdicts in KV for a few days, then switch to `enforce`.\n\n## Modes\n\n| `mode` | Effect |\n|---|---|\n| `off` | Never evaluates. |\n| `shadow` (default) | Asks Jev and records the verdict. Never blocks. |\n| `enforce` | Asks Jev, records the verdict, and blocks the `store-progress` call when a dimension fails, at most `maxBlocksPerTask` times per task. The agent gets the failed dimensions, their scores and the stated preference, and calls again. |\n\n## What it gates\n\nHook: `pre.tool.call` on `store-progress` with `status: \"completed\"` and a non-empty `output`. Calls made by extensions are skipped. The task must:\n\n1. Come from Slack (`source = \"slack\"` or a `slackThreadTs`). Other tasks pass without a Jev call.\n2. Have a requester with stated preferences in `users.metadata.comms`. A requester with none passes. Set them with the `manage-user` tool.\n\n## Decision\n\nThe gate reads the task row and asks one `noul` question per stated preference it knows: `verbosity`, `tone`, `language`. Any other key in `comms` is context only.\n\nJev sees the preferences, the requester's latest message (the text after the last `</thread_context>`, from the last 1,400 characters of the task), the first 400 characters of the task, and the output. Its judging rules say an explicit request outranks the stored default. A requested long answer, story or document is not a verbosity failure.\n\nA dimension fails when its score is below `threshold`. Any failed dimension fails the output.\n\n## Jev transport\n\nJev is reachable two ways. Both take the same request and return the same answer.\n\n| `provider` | Endpoint | Key | Model |\n|---|---|---|---|\n| `typesafe` | `POST https://api.typesafe.ai/v1/systemone` | `TYPESAFE_API_KEY` (or `apiKey`) | `model`, default `jev-latest` |\n| `openrouter` | `POST https://openrouter.ai/api/alpha/decisions` | `OPENROUTER_API_KEY` | `openrouterModel`, default `typesafe/jev-1.13` |\n| `auto` (default) | TypeSafe when its key is set, else OpenRouter | | |\n\nEach verdict row records the `provider` that answered.\n\n## Fail open\n\nEvery error lets the completion through: a missing key, an HTTP error, a network error, a timeout, a malformed answer, or a failed DB read. The handler never throws, so an outage cannot auto-disable the extension. Each failure bumps the `error` counter and overwrites `error:last`.\n\n## Self-test\n\nCall `store-progress` with a non-completed status and `progress` starting with `comms-gate-selftest`. The gate judges a known-bad and a known-good message against a terse preference and writes `selftest:last`: both scores, the threshold, the provider, and `controlsOk` (bad below threshold and good at or above it). Real traffic is untouched.\n\n## Config\n\n| Key | Default | Notes |\n|---|---|---|\n| `mode` | `shadow` | `off`, `shadow`, or `enforce` |\n| `threshold` | `0.5` | A dimension scoring below this fails |\n| `timeoutMs` | `4000` | Jev call timeout, capped at 4000 (the dispatcher cancels a handler at 5 s) |\n| `maxBlocksPerTask` | `1` | Blocks per task in `enforce`; after that the gate records and lets it through |\n| `provider` | `auto` | `auto`, `typesafe`, or `openrouter`. See [Jev transport](#jev-transport) |\n| `model` | `jev-latest` | Jev model on TypeSafe |\n| `openrouterModel` | `typesafe/jev-1.13` | Jev model on OpenRouter |\n| `apiKey` | unset | Explicit TypeSafe key. Prefer the `TYPESAFE_API_KEY` secret below |\n\n## Required secrets\n\nRead from the API process environment at call time. Set them as global `swarm_config` secrets: the API process loads global rows into `process.env` at boot and on config reload.\n\n- `TYPESAFE_API_KEY` or `OPENROUTER_API_KEY`: one is required for any evaluation. Under `auto`, TypeSafe wins when both are set. Without either, every completion fails open.\n\nNo key is written to config, KV, logs, or a block reason.\n\n## KV namespace `ext:comms-gate`\n\n- `last`: the latest verdict, with `scores`, `failed`, `provider`, `latencyMs`, the preferences, a 400-character `askPreview` and a 700-character `outputPreview`.\n- `fail:<epochMs>:<taskId>`: one row per failed verdict, with `action` (`blocked` or `recorded`).\n- `blocks:<taskId>`: blocks issued for that task.\n- `error:last`, `selftest:last`.\n- Counters: `total`, `pass`, `fail`, `blocked`, `escaped` (enforce, block budget spent), `shadowed`, `error`, and `dim:<dimension>`.\n\n## Known limits\n\n- Only the last 1,400 characters of the task are searched for the latest request. A very long final message is cut to its last 900 characters.\n- The block budget is per task, so a retried completion after a block goes through once the budget is spent, even if it still fails.\n"
17
+ "readme": "# comms-adherence-gate\n\nAgents finish Slack tasks with a final message a human reads, and post to Slack threads directly. This extension judges that text with Jev, [TypeSafe](https://typesafe.ai)'s decision model: against the requester's stated comms preferences and their latest request, and against a plain-language rubric that applies to every message. In `enforce` it blocks a failing call once and tells the agent what to fix. A `plain` failure points the agent at the `comms` skill.\n\nInstall config:\n\n```json\n{ \"mode\": \"shadow\", \"plainMode\": \"shadow\" }\n```\n\nStart in `shadow`, read the verdicts in KV for a few days, then switch to `enforce`. `mode` and `plainMode` move independently.\n\n## Modes\n\n| `mode` | Effect |\n|---|---|\n| `off` | Never evaluates. |\n| `shadow` (default) | Asks Jev and records the verdict. Never blocks. |\n| `enforce` | Asks Jev, records the verdict, and blocks the call when a dimension fails, at most `maxBlocksPerTask` times per task. The agent gets the failed dimensions, their scores and the stated preference, and calls again. |\n\n`mode` governs the preference dimensions (`verbosity`, `tone`, `language`). `plainMode` governs `plain`. A dimension blocks only when its own mode is `enforce`. Both share one block budget per task, across every gated tool.\n\n## What it gates\n\nHook: `pre.tool.call` on:\n\n- `store-progress` with `status: \"completed\"` and a non-empty `output`, on a task that came from Slack (`source = \"slack\"` or a `slackThreadTs`). Other completions pass without a Jev call.\n- `slack-reply`, `slack-post`, `slack-start-thread` and `slack-update`, judged on `message`. The task is `slack-reply`'s `taskId`, else the caller's task context. A post with no task context passes.\n\nOnly a call the tool itself accepts is judged, so no other task's text reaches Jev. For `store-progress` the caller must own the task, the task must be unassigned, or the caller must be a lead. For the Slack tools the caller must be the task's assignee or its creator, the rule `slack-reply` enforces. Calls made by extensions are skipped. Script SDK calls never reach `pre.tool.call` (the tool registrar dispatches it only for MCP calls); if one ever does, the gate records it and never blocks.\n\nA requester with no stated preferences in `users.metadata.comms` still gets the `plain` question. Set preferences with the `manage-user` tool.\n\nIn a thread with several people, the requester is the person whose message created the task. A message that @-mentions someone (`<@U123>`) is judged against the first mentioned person with stored preferences, resolved through their Slack identity in `user_external_ids`; otherwise against the requester. The lookup is one SQL read, only when the text mentions someone and `mode` is not `off`. The verdict records who was judged in `judgedFor` (`mention` or `requester`) and `audience`.\n\n## Decision\n\nThe gate reads the task row and asks one `noul` question per stated preference it knows: `verbosity`, `tone`, `language`. Any other key in `comms` is context only.\n\nJev sees the preferences, the requester's latest message (the text after the last `</thread_context>`, from the last 1,400 characters of the task), the first 400 characters of the task, and the output. Its judging rules say an explicit request outranks the stored default. A requested long answer, story or document is not a verbosity failure.\n\nIt also asks `plain` on every message unless `plainMode` is `off` or the text is a JSON document (structured output for an `outputSchema`). The rubric comes from the `comms` skill: everyday words, one idea per sentence, every number with a unit and a direction word, and no unexplained shorthand (n, median, p90, cohort, baseline, delta, `±X at 95%`). Code identifiers, task ids, PR numbers, links and versions are fine. Jev gets contrasting plain and dense examples in `plain_language_rules`; a prose-only rubric scored plain rewrites as low as the dense originals.\n\nAll questions go in one Jev request. A preference dimension fails below `threshold`; `plain` fails below `plainThreshold`. Any failed dimension fails the message.\n\n### Calibration of `plain`\n\nJev scores dense status text near 0 and plain text near 0.8 to 0.9, with a wide middle of compact but readable engineering notes. Measured on 400 Slack-facing completions over 7 days, at `threshold` 0.75 for preferences:\n\n| `plainThreshold` | Newly blocked (of messages that pass today) |\n|---|---|\n| 0.75 | 81% to 83% |\n| 0.3 | 9% to 13% |\n| 0.25 (default) | 5% to 7% |\n\nAt 0.25 the dense prompt-cache-monitor post scores about 0.15 and its plain rewrite about 0.81.\n\n## Jev transport\n\nJev is reachable two ways. Both take the same request and return the same answer.\n\n| `provider` | Endpoint | Key | Model |\n|---|---|---|---|\n| `typesafe` | `POST https://api.typesafe.ai/v1/systemone` | `TYPESAFE_API_KEY` (or `apiKey`) | `model`, default `jev-latest` |\n| `openrouter` | `POST https://openrouter.ai/api/alpha/decisions` | `OPENROUTER_API_KEY` | `openrouterModel`, default `typesafe/jev-1.13` |\n| `auto` (default) | TypeSafe when its key is set, else OpenRouter | | |\n\nEach verdict row records the `provider` that answered.\n\n## Fail open\n\nEvery error lets the completion through: a missing key, an HTTP error, a network error, a timeout, a malformed answer, or a failed DB read. The handler never throws, so an outage cannot auto-disable the extension. Each failure bumps the `error` counter and overwrites `error:last`.\n\n## Self-test\n\nCall `store-progress` with a non-completed status and `progress` starting with `comms-gate-selftest`. The gate judges a known-bad and a known-good message against a terse preference, and a dense and a plain message on `plain`, then writes `selftest:last`: every score, both thresholds, the provider, `controlsOk` (verbosity: bad below `threshold`, good at or above it) and `plainControlsOk` (the same against `plainThreshold`). Real traffic is untouched.\n\n## Config\n\n| Key | Default | Notes |\n|---|---|---|\n| `mode` | `shadow` | `off`, `shadow`, or `enforce` |\n| `threshold` | `0.5` | A preference dimension scoring below this fails |\n| `plainMode` | `shadow` | `off`, `shadow`, or `enforce` for the `plain` dimension |\n| `plainThreshold` | `0.25` | `plain` scoring below this fails. See [Calibration](#calibration-of-plain) |\n| `timeoutMs` | `4000` | Jev call timeout, capped at 4000 (the dispatcher cancels a handler at 5 s) |\n| `maxBlocksPerTask` | `1` | Blocks per task in `enforce`; after that the gate records and lets it through |\n| `provider` | `auto` | `auto`, `typesafe`, or `openrouter`. See [Jev transport](#jev-transport) |\n| `model` | `jev-latest` | Jev model on TypeSafe |\n| `openrouterModel` | `typesafe/jev-1.13` | Jev model on OpenRouter |\n| `apiKey` | unset | Explicit TypeSafe key. Prefer the `TYPESAFE_API_KEY` secret below |\n\n## Required secrets\n\nRead from the API process environment at call time. Set them as global `swarm_config` secrets: the API process loads global rows into `process.env` at boot and on config reload.\n\n- `TYPESAFE_API_KEY` or `OPENROUTER_API_KEY`: one is required for any evaluation. Under `auto`, TypeSafe wins when both are set. Without either, every completion fails open.\n\nNo key is written to config, KV, logs, or a block reason.\n\n## KV namespace `ext:comms-gate`\n\n- `last`: the latest verdict, with `tool`, `scores`, `failed`, `mode`, `plainMode`, `provider`, `latencyMs`, the preferences, a 400-character `askPreview` and a 700-character `outputPreview`.\n- `fail:<epochMs>:<taskId>`: one row per failed verdict, with `action` (`blocked` or `recorded`).\n- `blocks:<taskId>`: blocks issued for that task.\n- `error:last`, `selftest:last`.\n- Counters: `total`, `pass`, `fail`, `blocked`, `escaped` (a blocking dimension failed, block budget spent), `shadowed` (only non-blocking dimensions failed), `error`, and `dim:<dimension>`.\n\n## Known limits\n\n- Only the last 1,400 characters of the task are searched for the latest request. A very long final message is cut to its last 900 characters.\n- The block budget is per task, so a retried call after a block goes through once the budget is spent, even if it still fails.\n- Each gated Slack post waits for one Jev call (about 0.2 s measured, capped by `timeoutMs`).\n"
18
18
  },
19
19
  "deploy-awareness": {
20
20
  "manifestFile": "manifest.json",