@desplega.ai/agent-swarm 2.5.1 → 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.
- package/dist/{acp-adapter-7g6bpwbk.js → acp-adapter-gr2fcyyj.js} +3 -3
- package/dist/{actions-w8xd014k.js → actions-wv766ghf.js} +18 -18
- package/dist/{amp-adapter-z4qvhacj.js → amp-adapter-speb906t.js} +3 -3
- package/dist/{app-nndcvftq.js → app-ag29n14f.js} +3 -3
- package/dist/{assistant-hjmqyscx.js → assistant-3x8bz3eg.js} +19 -19
- package/dist/{boot-reembed-r8rhf9h8.js → boot-reembed-5b3d2eea.js} +1 -1
- package/dist/{boot-reembed-vwcj2y5d.js → boot-reembed-jsmfrpzf.js} +1 -1
- package/dist/{boot-scrub-sweep-fr9q36ne.js → boot-scrub-sweep-s73gerxy.js} +2 -2
- package/dist/{boot-seeds-9jqwnegq.js → boot-seeds-5zmax2wr.js} +4 -4
- package/dist/{claude-adapter-2f8b85df.js → claude-adapter-hswdnm3t.js} +3 -3
- package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-cvbzj1h6.js} +2 -2
- package/dist/{claude-managed-setup-c3hp36ph.js → claude-managed-setup-3k8m3hhc.js} +2 -2
- package/dist/{claude-sdk-session-05kat8rv.js → claude-sdk-session-fkbyk66x.js} +6905 -6855
- package/dist/{cli-y3zyaee5.js → cli-1fc85mvt.js} +5 -5
- package/dist/{cli-v2g93k4v.js → cli-210xd1f4.js} +4 -4
- package/dist/{cli-sn1vbtg2.js → cli-25j01dpj.js} +3 -3
- package/dist/{cli-n6kmqngw.js → cli-35gtk1p6.js} +1 -1
- package/dist/{cli-r9fy0ddc.js → cli-3ngdk5te.js} +1 -1
- package/dist/{cli-z63a269p.js → cli-40jsnrjm.js} +2 -2
- package/dist/{cli-ba8ne1es.js → cli-4rvr7zfx.js} +10 -0
- package/dist/{cli-qfm6my86.js → cli-502eqv93.js} +1 -1
- package/dist/{cli-xd4eqtr2.js → cli-6n8axjv1.js} +1 -1
- package/dist/{cli-hadv0725.js → cli-8s5wx3nz.js} +2 -2
- package/dist/{cli-z2dv25mf.js → cli-99xgw26r.js} +116 -5
- package/dist/{cli-jpxsrxcb.js → cli-9s05422w.js} +1 -1
- package/dist/{cli-kcsd1ez8.js → cli-aecdcr77.js} +343 -325
- package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
- package/dist/{cli-e9btqw4y.js → cli-ce0ak9ms.js} +18 -13
- package/dist/{cli-nj12tn2c.js → cli-e2ar4bp1.js} +71 -20
- package/dist/{cli-gwcf52gz.js → cli-e8jyq7d8.js} +11 -7
- package/dist/{cli-rngde2jv.js → cli-egjggehm.js} +1 -1
- package/dist/{cli-qfjm4gvt.js → cli-ewm3pwbq.js} +2 -2
- package/dist/{cli-9mm5ade4.js → cli-fsdprwrg.js} +1 -1
- package/dist/{cli-3ee5z0q0.js → cli-fzcn3d87.js} +1 -1
- package/dist/{cli-eym3jg7w.js → cli-gpsz7g88.js} +6 -6
- package/dist/{cli-cwyafhw1.js → cli-hcpj2xqz.js} +1 -1
- package/dist/{cli-j1eghqfm.js → cli-hhxjnjzs.js} +1 -1
- package/dist/{cli-pyy4c9ch.js → cli-jjznk3f7.js} +881 -232
- package/dist/{cli-q9pzwxvk.js → cli-kxawjrxg.js} +222 -37
- package/dist/{cli-rh5smt8t.js → cli-mz9afxxf.js} +14 -3
- package/dist/{cli-mz2ygeyq.js → cli-n45mr7zh.js} +2 -2
- package/dist/{cli-fes51ya2.js → cli-p0rzjkya.js} +2 -2
- package/dist/{cli-3qv8naq8.js → cli-p6zs9h4y.js} +827 -107
- package/dist/{cli-mq7kg1b1.js → cli-p9rafdrb.js} +41 -6
- package/dist/{cli-cah5detr.js → cli-qgf8emrn.js} +5 -5
- package/dist/{cli-2wba1ac9.js → cli-qzme5949.js} +26 -26
- package/dist/{cli-znvq0k3t.js → cli-s49hpn37.js} +2 -2
- package/dist/{cli-xc65x52q.js → cli-s53vp709.js} +1 -1
- package/dist/{cli-0jy85zz4.js → cli-svrj71pg.js} +18 -16
- package/dist/{cli-1dp1zj04.js → cli-vwn81jbs.js} +3 -3
- package/dist/{cli-ajqhxtnz.js → cli-w1zwctj2.js} +1 -1
- package/dist/{cli-22rmcjkg.js → cli-y561h5a9.js} +7 -6
- package/dist/{cli-2jg61wab.js → cli-z21whrrg.js} +15 -15
- package/dist/cli.js +19 -18
- package/dist/{codex-adapter-p4ptwar5.js → codex-adapter-n8ka66vf.js} +2 -2
- package/dist/{codex-session-runner-964c7dnk.js → codex-session-runner-fsebts37.js} +1 -1
- package/dist/{commands-mbvhbrcy.js → commands-t04rt2pc.js} +2 -2
- package/dist/{controller-gnq1k1mf.js → controller-rgna04r2.js} +2 -2
- package/dist/{controllers-connect-6dkm71tg.js → controllers-connect-bqm1rtft.js} +20 -20
- package/dist/{cursor-adapter-yq5xgfcg.js → cursor-adapter-swr2k95y.js} +1 -1
- package/dist/{db-88wcymxt.js → db-24ck09xy.js} +1 -1
- package/dist/{db-af9y8nkf.js → db-a4m7haw0.js} +3 -3
- package/dist/{dispatcher-606ydx5v.js → dispatcher-nwf9ss0q.js} +17 -17
- package/dist/{dsh-adapter-tvhc4k2d.js → dsh-adapter-kpdnhjst.js} +2 -2
- package/dist/{external-channel-y8q5z8z2.js → external-channel-j1e2p34p.js} +2 -2
- package/dist/{grok-adapter-39aha52t.js → grok-adapter-qtjz0whk.js} +4 -4
- package/dist/{handlers-rk9dqech.js → handlers-19xr6dnz.js} +20 -20
- package/dist/{heartbeat-jfe9zfjr.js → heartbeat-vtesda5d.js} +20 -20
- package/dist/{hook-b1mez1m8.js → hook-8wqaszda.js} +1 -1
- package/dist/{hook-ffevzed1.js → hook-b2hhj6rd.js} +1 -1
- package/dist/{http-d5b44f1r.js → http-mxcqhhcs.js} +3020 -2463
- package/dist/{index-5en23s5z.js → index-1r8wfpzs.js} +1 -1
- package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
- package/dist/{index-mj46rk9k.js → index-kw2mmat7.js} +20 -20
- package/dist/{index-nq8hhv28.js → index-n1vbvta9.js} +19 -10
- package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
- package/dist/{index-d2qeem97.js → index-spwxjgay.js} +17 -17
- package/dist/{index-4q3vj1yr.js → index-srg522m6.js} +17 -17
- package/dist/{keepalive-wsby1btq.js → keepalive-dsd8rtgx.js} +3 -3
- package/dist/{lead-paa141j6.js → lead-zyerk1x2.js} +30 -30
- package/dist/{maintenance-vnshcyv9.js → maintenance-jqmqwnet.js} +1 -1
- package/dist/{oauth-refresh-sweep-d6sqn5np.js → oauth-refresh-sweep-q2c3bnvm.js} +2 -2
- package/dist/{onboard-nr4xhpzy.js → onboard-w4yd7s8t.js} +2 -2
- package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-9tppvxar.js} +2 -2
- package/dist/{otel-impl-ytn9wyv9.js → otel-impl-f36byzbd.js} +1 -1
- package/dist/{pricing-refresh-pva04zee.js → pricing-refresh-xvak5p4v.js} +3 -3
- package/dist/{rbac-roles-7rc1q3g4.js → rbac-roles-682k2mt5.js} +12 -2
- package/dist/rbac-roles-gfn30fb8.js +26 -0
- package/dist/{render-v2-pyfsg9zk.js → render-v2-hgytz8nn.js} +3 -3
- package/dist/{resume-phj8xhqr.js → resume-2dvq348g.js} +7 -5
- package/dist/{session-access-jzx2z9h5.js → session-access-7f21ekq9.js} +4 -4
- package/dist/{setup-rfg0f0sg.js → setup-epffxtbk.js} +1 -1
- package/dist/{work-objects-44z2yjg8.js → work-objects-xz1a5rqk.js} +20 -20
- package/dist/{worker-zsfqzxhm.js → worker-42t8emjt.js} +30 -30
- package/openapi.json +3163 -2114
- package/package.json +7 -6
- package/src/be/approval-config-question.ts +389 -0
- package/src/be/automation-preflight.ts +37 -3
- package/src/be/db/session-tree.ts +158 -13
- package/src/be/db/session-visibility.ts +31 -0
- package/src/be/db/skill-search.ts +87 -0
- package/src/be/db/tasks/read.ts +47 -7
- package/src/be/db/workflow-runs.ts +54 -7
- package/src/be/db.ts +27 -5
- package/src/be/migrations/233_script_run_session_tokens.sql +8 -0
- package/src/be/migrations/235_chat_relay_defer.sql +86 -0
- package/src/be/migrations/237_session_inbox_cleanup.sql +75 -0
- package/src/be/migrations/239_skills_fts.sql +40 -0
- package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
- package/src/be/migrations/241_skills_meta_fts.sql +39 -0
- package/src/be/rbac-roles.ts +138 -0
- package/src/be/scripts/run-credential.ts +64 -0
- package/src/be/scripts/run-saved.ts +3 -2
- package/src/be/swarm-config-guard.ts +23 -0
- package/src/be/users.ts +28 -4
- package/src/extensions/catalog.generated.json +4 -4
- package/src/http/all-routes.ts +1 -0
- package/src/http/approval-requests.ts +254 -43
- package/src/http/config.ts +22 -37
- package/src/http/index.ts +2 -0
- package/src/http/schedules.ts +26 -0
- package/src/http/scripts.ts +2 -2
- package/src/http/sessions.ts +118 -2
- package/src/http/user-roles.ts +173 -0
- package/src/http/users.ts +9 -0
- package/src/http/x.ts +2 -2
- package/src/integrations/chat-sdk/commands.ts +14 -1
- package/src/integrations/chat-sdk/inbound.ts +42 -6
- package/src/integrations/chat-sdk/outbound.ts +35 -10
- package/src/integrations/chat-sdk/reactions.ts +107 -0
- package/src/integrations/chat-sdk/relay.ts +75 -7
- package/src/integrations/chat-sdk/thread-buffer.ts +2 -0
- package/src/integrations/reaction-events.ts +20 -0
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/rbac/legacy-policy.ts +3 -0
- package/src/rbac/permissions.ts +8 -0
- package/src/scheduler/schedule-task.ts +44 -3
- package/src/scheduler/scheduler.ts +4 -4
- package/src/scripts-runtime/loader.ts +8 -1
- package/src/slack/ack.ts +2 -1
- package/src/slack/reaction-shortcode.ts +4 -7
- package/src/tasks/worker-follow-up.ts +30 -0
- package/src/tests/approval-config-question.test.ts +702 -0
- package/src/tests/approval-requests-notifications.test.ts +189 -0
- package/src/tests/chat-sdk-commands.test.ts +12 -0
- package/src/tests/chat-sdk-inbound.test.ts +183 -9
- package/src/tests/chat-sdk-outbound-files.test.ts +9 -2
- package/src/tests/chat-sdk-relay.test.ts +361 -10
- package/src/tests/chat-sdk-tools.test.ts +104 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
- package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
- package/src/tests/http-users.test.ts +38 -0
- package/src/tests/list-endpoint-slimming.test.ts +33 -0
- package/src/tests/migration-237-session-inbox-cleanup.test.ts +136 -0
- package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
- package/src/tests/rbac-engine.test.ts +2 -0
- package/src/tests/rbac-roles.test.ts +10 -0
- package/src/tests/schedule-http-triage-tools.test.ts +44 -0
- package/src/tests/script-api-credential.test.ts +173 -0
- package/src/tests/script-connections.test.ts +10 -0
- package/src/tests/script-run-config-scope.test.ts +120 -0
- package/src/tests/script-run-token.test.ts +75 -0
- package/src/tests/scripts-http.test.ts +19 -0
- package/src/tests/sessions-tree.test.ts +410 -1
- package/src/tests/skill-search-long-content.test.ts +193 -0
- package/src/tests/skill-search.test.ts +173 -22
- package/src/tests/user-roles-http.test.ts +204 -0
- package/src/tests/workflow-hitl-dynamic-questions.test.ts +1 -1
- package/src/tests/workflow-sub-workflow.test.ts +538 -1
- package/src/tools/chat-reply.ts +2 -0
- package/src/tools/request-human-input.ts +27 -5
- package/src/tools/schedules/patch-schedule.ts +18 -0
- package/src/tools/schedules/update-schedule.ts +18 -0
- package/src/types.ts +23 -0
- package/src/workflows/approval-notifications.ts +221 -0
- package/src/workflows/checkpoint.ts +69 -35
- package/src/workflows/engine.ts +234 -87
- package/src/workflows/executors/base.ts +6 -0
- package/src/workflows/executors/human-in-the-loop.ts +36 -173
- package/src/workflows/executors/sub-workflow.ts +45 -16
- package/src/workflows/executors/swarm-script.ts +2 -2
- package/src/workflows/recovery.ts +63 -1
- package/src/workflows/resume.ts +60 -21
- package/src/workflows/retry-poller.ts +25 -4
- package/templates/extensions/comms-adherence-gate/README.md +38 -13
- package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
- package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
- package/templates/schedules/weekly-harness-upgrade-check/content.md +1 -1
- package/templates/skills/code-quality/content.md +1 -1
- package/templates/skills/turso-interaction/content.md +1 -1
- package/templates/skills/workflow-iterate/content.md +8 -1
- package/dist/rbac-roles-ff1ffmec.js +0 -12
- /package/dist/{cli-stxdrqs2.js → cli-avdvgcay.js} +0 -0
- /package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-tqsge1ay.js} +0 -0
package/src/be/users.ts
CHANGED
|
@@ -615,6 +615,23 @@ export async function mintRuntimeToken(
|
|
|
615
615
|
return await insertSessionToken(agentId, null, runtimeId, ttlMs);
|
|
616
616
|
}
|
|
617
617
|
|
|
618
|
+
/**
|
|
619
|
+
* Mint the short-lived `aseph_` token a script run uses for its `ctx.swarm`
|
|
620
|
+
* SDK calls, so those calls present the executing agent instead of the swarm
|
|
621
|
+
* API key (the operator). It resolves as a plain agent principal (`kind:
|
|
622
|
+
* "agent"`, no task/runtime binding), so the API layer scopes it to that
|
|
623
|
+
* agent's permissions: a lead keeps lead powers, a worker reads only its own
|
|
624
|
+
* scope. Marked `scriptRun` so it stays apart from the agent's worker token
|
|
625
|
+
* (see `AGENT_TOKEN_BINDING`). The run revokes it when it ends; `ttlMs` is the
|
|
626
|
+
* backstop for a run that dies without revoking.
|
|
627
|
+
*/
|
|
628
|
+
export async function mintScriptRunToken(
|
|
629
|
+
agentId: string,
|
|
630
|
+
ttlMs: number,
|
|
631
|
+
): Promise<{ tokenId: string; plaintext: string }> {
|
|
632
|
+
return await insertSessionToken(agentId, null, null, ttlMs, null, undefined, null, null, true);
|
|
633
|
+
}
|
|
634
|
+
|
|
618
635
|
export async function insertSessionToken(
|
|
619
636
|
agentId: string,
|
|
620
637
|
taskId: string | null,
|
|
@@ -624,6 +641,7 @@ export async function insertSessionToken(
|
|
|
624
641
|
expiresAtCeiling?: string,
|
|
625
642
|
controllerId: string | null = null,
|
|
626
643
|
parentTokenId: string | null = null,
|
|
644
|
+
scriptRun = false,
|
|
627
645
|
): Promise<{ tokenId: string; plaintext: string }> {
|
|
628
646
|
const plaintext = `${SESSION_TOKEN_PREFIX}${base62(randomBytes(24))}`;
|
|
629
647
|
const tokenId = randomUUID().replace(/-/g, "");
|
|
@@ -636,8 +654,8 @@ export async function insertSessionToken(
|
|
|
636
654
|
|
|
637
655
|
await getDbClient().run(
|
|
638
656
|
`INSERT INTO session_tokens
|
|
639
|
-
(id, tokenHash, tokenPreview, agentId, taskId, runtimeId, parentRuntimeId, expiresAt, createdAt, controllerId, parentTokenId)
|
|
640
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
657
|
+
(id, tokenHash, tokenPreview, agentId, taskId, runtimeId, parentRuntimeId, expiresAt, createdAt, controllerId, parentTokenId, scriptRun)
|
|
658
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
641
659
|
[
|
|
642
660
|
tokenId,
|
|
643
661
|
hash,
|
|
@@ -650,6 +668,7 @@ export async function insertSessionToken(
|
|
|
650
668
|
now.toISOString(),
|
|
651
669
|
controllerId,
|
|
652
670
|
parentTokenId,
|
|
671
|
+
scriptRun ? 1 : 0,
|
|
653
672
|
],
|
|
654
673
|
);
|
|
655
674
|
|
|
@@ -713,9 +732,14 @@ export async function revokeRuntimeTokensForRuntime(runtimeId: string): Promise<
|
|
|
713
732
|
// Agent tokens (long-lived, one agent, no runtime)
|
|
714
733
|
// ---------------------------------------------------------------------------
|
|
715
734
|
|
|
716
|
-
/**
|
|
735
|
+
/**
|
|
736
|
+
* SQL predicate for an agent (worker) token: bound to its agent and nothing
|
|
737
|
+
* else. `scriptRun = 0` excludes a script run's ephemeral token, which shares
|
|
738
|
+
* the same all-null binding shape but has its own lifecycle (see migration 233
|
|
739
|
+
* and `mintScriptRunToken`).
|
|
740
|
+
*/
|
|
717
741
|
const AGENT_TOKEN_BINDING =
|
|
718
|
-
"taskId IS NULL AND runtimeId IS NULL AND controllerId IS NULL AND parentRuntimeId IS NULL";
|
|
742
|
+
"taskId IS NULL AND runtimeId IS NULL AND controllerId IS NULL AND parentRuntimeId IS NULL AND scriptRun = 0";
|
|
719
743
|
|
|
720
744
|
/** Stored expiry of a token that does not expire. `expiresAt` is NOT NULL. */
|
|
721
745
|
export const AGENT_TOKEN_NO_EXPIRY = "9999-12-31T23:59:59.999Z";
|
|
@@ -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
|
|
8
|
-
"version": "1.
|
|
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
|
|
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",
|