agent-coord-mcp 0.26.9 → 0.26.11

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/src/server.ts CHANGED
@@ -287,7 +287,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
287
287
 
288
288
  addTool(
289
289
  "join",
290
- "Recommended session-start call. Does register + auto-attach (if running inside tmux) + read inbox in one round-trip. Pass attach=false to skip the transport, attach={...overrides} to customize, or omit it to let the server auto-detect $TMUX_PANE. Returns the registration, attach result, any unread inbox messages, and the default channel's topic + MOTD (room rules) so you see them on connect. Calling join binds this MCP process's identity to agentId for the lifetime of the session — no env var or config needed. Each Claude Code session runs its own stdio process so bindings are naturally isolated. Claiming an id that is currently LIVE on the bus (fresh heartbeat, live pusher, or another bound session) is refused unless the claim comes from that agent's own tmux pane or carries the agent's token or force:true — diagnosing someone else's agent is what status/ping are for.",
290
+ "Recommended session-start call. Does register + auto-attach (if running inside tmux) + read inbox in one round-trip. Pass attach=false to skip the transport, attach={...overrides} to customize, or omit it to let the server auto-detect $TMUX_PANE. Returns the registration, attach result, any unread inbox messages, and the default channel's topic + MOTD (room rules) so you see them on connect. Calling join binds this MCP process's identity to agentId for the lifetime of the session — no env var or config needed. Each Claude Code session runs its own stdio process so bindings are naturally isolated. Claiming an id that is currently LIVE on the bus (fresh heartbeat, live pusher, or another bound session) is refused unless the claim comes from that agent's own tmux pane or carries the agent's token or force:true — diagnosing someone else's agent is what status/ping are for. `proseOnly:true` claims the per-agent exemption from the typed-record rule — see `register`.",
291
291
  joinSchema,
292
292
  // join explicitly sets the session binding when unset, so each agent can
293
293
  // declare its identity via join rather than relying on env vars.
@@ -310,7 +310,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
310
310
 
311
311
  addTool(
312
312
  "register",
313
- "Register this agent in the shared registry. Lower-level than `join` — does not attach a transport or drain the inbox. Prefer `join` unless you need explicit control.",
313
+ "Register this agent in the shared registry. Lower-level than `join` — does not attach a transport or drain the inbox. Prefer `join` unless you need explicit control. `proseOnly:true` claims the per-agent exemption from the typed-record rule, for a model that cannot reliably pick a record.type; it is visible and counted in list_agents, and it is granted to the SENDER but paid by every READER (an untyped message cannot be slimmed). Omit it to leave any existing exemption untouched; pass false to revoke.",
314
314
  registerSchema,
315
315
  gate("agentId", registerTool as (a: Record<string, unknown>) => Promise<unknown>),
316
316
  );
@@ -352,14 +352,14 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
352
352
 
353
353
  addTool(
354
354
  "list_agents",
355
- "List all known agents and whether they appear online (heartbeat <5min).",
355
+ "List all known agents and whether they appear online (heartbeat <5min). Also reports the prose-only exemptions from the typed-record rule — who holds one, since when, and the count over the total, so a rising exempt share is visible rather than inferred.",
356
356
  listAgentsSchema,
357
357
  gate(null, listAgentsTool as () => Promise<unknown>),
358
358
  );
359
359
 
360
360
  addTool(
361
361
  "send_message",
362
- "Send a message. If 'to' is set, goes to that agent's inbox (DM); otherwise to a channel — pass 'room' (e.g. 'seo' or '#seo') to target a specific channel, or omit it for the default 'general' channel. For channel posts, tag 'kind': 'decision' for GOs/verdicts/agreements that must outlive routine cleanup (kept ~30 days, quoted verbatim in digests), 'status' for progress notes, omit for ordinary chatter. Optional 'inReplyTo' is a parent message uuid (a reply that resolves a DAVID_DECISION); malformed id is refused, unknown id is stored with a warning. The 'from' field is enforced against the session's bound identity when binding is configured.",
362
+ "Send a message. If 'to' is set, goes to that agent's inbox (DM); otherwise to a channel — pass 'room' (e.g. 'seo' or '#seo') to target a specific channel, or omit it for the default 'general' channel. For channel posts, tag 'kind': 'decision' for GOs/verdicts/agreements that must outlive routine cleanup (kept ~30 days, quoted verbatim in digests), 'status' for progress notes, omit for ordinary chatter. Optional 'inReplyTo' is a parent message uuid (a reply that resolves a DAVID_DECISION); malformed id is refused, unknown id is stored with a warning. The 'from' field is enforced against the session's bound identity when binding is configured. EVERY AGENT→AGENT MESSAGE MUST CARRY 'record' with a typed 'type' (decision · verdict · done · blocker · risk · fyi · action · go · scope): a typed multi-line message is delivered as ONE attributed line plus a retrieve_message handle, while an untyped one arrives in full in every reader's context. 'fyi' is the honest catch-all — use it rather than forcing a false 'decision'/'risk'. Untyped sends WARN today and are REFUSED from 2026-09-15. Messages TO a human are exempt (David-facing traffic stays prose), as is a sender that declared proseOnly:true at join.",
363
363
  sendMessageSchema,
364
364
  gate("from", sendMessageTool as (a: Record<string, unknown>) => Promise<unknown>),
365
365
  );
@@ -1,5 +1,11 @@
1
1
  import { adjustCursors } from "./admin.js";
2
- import { RECORD_AUTHORITY, resolveRole, roleMatches } from "../roles.js";
2
+ import { RECORD_AUTHORITY, isHuman, recordAuthorityFor, resolveRole, roleMatches } from "../roles.js";
3
+ import {
4
+ TYPED_RECORD_CUTOVER_ISO,
5
+ suggestRecordType,
6
+ typedRecordGuidance,
7
+ typedRecordMode,
8
+ } from "../typed-records.js";
3
9
  import { ARCHIVE_STATUS_FILE, ARCHIVE_INBOX_DIR, ARCHIVE_ROOMS_DIR, archiveJsonl, archiveInboxFile, archiveRoomFile } from "../store.js";
4
10
  import { randomUUID } from "node:crypto";
5
11
  import { existsSync, openSync, watch } from "node:fs";
@@ -159,6 +165,67 @@ export async function checkRecordAuthority(
159
165
  };
160
166
  }
161
167
 
168
+ // ---------- typed records obligatory (Phase 5.1 Task 12) ----------
169
+
170
+ // An untyped agent→agent message must not be able to EXIST. Enforced HERE, at
171
+ // the send, and not at the render: a rule applied where the message is read
172
+ // leaves the untyped message on disk, and the next reader re-derives the type
173
+ // from prose. See src/typed-records.ts for the staging, the suggestion rules,
174
+ // and why `fyi` stays an honest catch-all.
175
+ //
176
+ // Returns `undefined` when the send is fine, a WARNING string while the rule is
177
+ // staged, or a REFUSAL after the cutover.
178
+ //
179
+ // TWO EXEMPTIONS, AND THEY ARE DIFFERENT IN KIND:
180
+ // - the RECIPIENT is a human. Canon: "David-facing messages may use normal
181
+ // prose". Scoped to agent→agent traffic, so the one channel whose reader is
182
+ // a person is untouched. Nothing is declared for this — it is a property of
183
+ // who is being written to.
184
+ // - the SENDER holds a prose-only exemption, declared per agent at join and
185
+ // visible in list_agents. That one is a statement about a model's ability
186
+ // to pick a type, and it is paid for by every reader.
187
+ async function typedRecordCheck(args: {
188
+ from: string;
189
+ to?: string;
190
+ text: string;
191
+ record?: MessageRecord;
192
+ }): Promise<{ ok: true; warning?: string } | { ok: false; error: string }> {
193
+ if (args.record?.type) return { ok: true };
194
+
195
+ const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
196
+
197
+ // David-facing prose stays prose. An UNREGISTERED recipient is treated as an
198
+ // agent, not as a human: the safe reading of "I cannot tell" is the rule, and
199
+ // a human on this bus has a registry entry (that is how the pane is found).
200
+ if (args.to && isHuman(reg[args.to])) return { ok: true };
201
+
202
+ const sender = reg[args.from];
203
+ if (sender?.proseOnly) return { ok: true };
204
+
205
+ const suggestion = suggestRecordType(args.text, recordAuthorityFor(sender).mayNotEmit);
206
+ const guidance = typedRecordGuidance(suggestion);
207
+
208
+ if (typedRecordMode() === "warn") {
209
+ return {
210
+ ok: true,
211
+ warning:
212
+ `UNTYPED — stored, but this send is REFUSED from ${TYPED_RECORD_CUTOVER_ISO}. ` +
213
+ `${guidance} Until then an untyped multi-line message arrives in full in every reader's context ` +
214
+ `instead of one line plus a retrieve_message handle. ` +
215
+ `A model that cannot pick a type declares proseOnly:true at join (per-agent, visible in list_agents).`,
216
+ };
217
+ }
218
+ return {
219
+ ok: false,
220
+ error:
221
+ `agent→agent messages must carry a typed record (since ${TYPED_RECORD_CUTOVER_ISO}) — nothing was written. ` +
222
+ `${guidance} ` +
223
+ `Types: decision · verdict · done · blocker · risk · fyi · action · go · scope; 'fyi' is the honest ` +
224
+ `catch-all — do not force a false 'decision'/'risk' to get past this. ` +
225
+ `Messages TO a human are exempt, and an agent that cannot pick a type declares proseOnly:true at join.`,
226
+ };
227
+ }
228
+
162
229
  export const sendMessageSchema = {
163
230
  from: z.string().min(1),
164
231
  to: z.string().optional(),
@@ -245,6 +312,12 @@ export async function sendMessageTool(args: {
245
312
  };
246
313
  }
247
314
 
315
+ // After `text` is resolved (a record can fill it) and before anything is
316
+ // written, so a refusal leaves nothing on disk.
317
+ const typed = await typedRecordCheck({ from: args.from, to: args.to, text, record: args.record });
318
+ if (!typed.ok) return { ok: false as const, error: typed.error };
319
+ const typedWarning = typed.warning;
320
+
248
321
  let replyWarning: string | undefined;
249
322
  if (args.inReplyTo !== undefined) {
250
323
  if (!MESSAGE_ID_RE.test(args.inReplyTo)) {
@@ -312,7 +385,7 @@ export async function sendMessageTool(args: {
312
385
  const recipientWarning = reg[args.to]
313
386
  ? undefined
314
387
  : `recipient '${args.to}' is not a registered agent — message stored in their inbox but no one may be listening`;
315
- const warning = [recipientWarning, replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
388
+ const warning = [typedWarning, recipientWarning, replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
316
389
  return { ok: true, id: msg.id, target, room: undefined, warning };
317
390
  }
318
391
 
@@ -331,7 +404,7 @@ export async function sendMessageTool(args: {
331
404
  const target = roomFile(chan);
332
405
  await appendJsonl(target, msg);
333
406
  await maybeCompactRoom(chan);
334
- const roomWarning = [replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
407
+ const roomWarning = [typedWarning, replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
335
408
  return { ok: true, id: msg.id, target, room: chan, ...(roomWarning ? { warning: roomWarning } : {}) };
336
409
  }
337
410
 
@@ -83,6 +83,11 @@ export const registerSchema = {
83
83
  // token (tokens.json / coord-token) or force:true. Ignored once bound.
84
84
  token: z.string().optional(),
85
85
  force: z.boolean().optional(),
86
+ // PROSE-ONLY EXEMPTION (Phase 5.1 Task 12.8). `true` grants it, `false`
87
+ // revokes it, omitted leaves it exactly as it was — so an agent re-joining
88
+ // after a /clear does not silently drop an exemption it was granted, and a
89
+ // card that never heard of the flag cannot revoke one either.
90
+ proseOnly: z.union([z.boolean(), z.object({ reason: z.string().min(1) })]).optional(),
86
91
  };
87
92
 
88
93
  // Work out what `role`/`roleId` should become, or why the update is refused.
@@ -120,7 +125,12 @@ export function resolveRoleUpdate(
120
125
  return { ok: true, role: nextRole, roleId: declared ? resolved.roleId : existing?.roleId };
121
126
  }
122
127
 
123
- export async function registerTool(args: { agentId: string; project?: string; role?: RoleArg }) {
128
+ export async function registerTool(args: {
129
+ agentId: string;
130
+ project?: string;
131
+ role?: RoleArg;
132
+ proseOnly?: boolean | { reason: string };
133
+ }) {
124
134
  const before = await readJson<AgentRegistry>(AGENTS_FILE, {});
125
135
  const roleUpdate = resolveRoleUpdate(args.agentId, before[args.agentId], args.role);
126
136
  if (!roleUpdate.ok) return { ok: false as const, error: roleUpdate.error };
@@ -145,6 +155,23 @@ export async function registerTool(args: { agentId: string; project?: string; ro
145
155
  registeredAt: existing?.registeredAt ?? now,
146
156
  lastHeartbeat: now,
147
157
  capabilities: existing?.capabilities,
158
+ // Omitted → carried forward untouched. Only an explicit `false` revokes.
159
+ ...(args.proseOnly === undefined
160
+ ? existing?.proseOnly
161
+ ? { proseOnly: existing.proseOnly }
162
+ : {}
163
+ : args.proseOnly === false
164
+ ? {}
165
+ : {
166
+ proseOnly: {
167
+ since: existing?.proseOnly?.since ?? now,
168
+ ...(typeof args.proseOnly === "object" && args.proseOnly.reason
169
+ ? { reason: args.proseOnly.reason }
170
+ : existing?.proseOnly?.reason
171
+ ? { reason: existing.proseOnly.reason }
172
+ : {}),
173
+ },
174
+ }),
148
175
  };
149
176
  return current;
150
177
  });
@@ -163,10 +190,28 @@ export async function registerTool(args: { agentId: string; project?: string; ro
163
190
  `It keeps any authority its words carry (e.g. 'coord-qa' → 'qa'), but prefer the canonical id — ` +
164
191
  `role:{roleId:"<canonical>", displayName:"${entry.role ?? entry.roleId}"}.`
165
192
  : undefined;
193
+ // The exemption's own docs state the asymmetry, at the one moment the agent
194
+ // granting itself one is reading the response. An exemption whose cost is
195
+ // invisible to the agent holding it is how the rule decays.
196
+ const proseOnlyEcho = entry.proseOnly
197
+ ? {
198
+ proseOnly: {
199
+ ...entry.proseOnly,
200
+ asymmetry:
201
+ "GRANTED TO THE SENDER, PAID BY EVERY READER. An untyped message cannot be slimmed by the " +
202
+ "transport (hooks/tier.mjs slims only when record.type is present), so every multi-line message " +
203
+ "you send arrives in full in every reader's context — which is precisely the cost the typed-record " +
204
+ "rule exists to remove. This is opt-in and reviewed, not self-service: it is visible in list_agents " +
205
+ "and counted there, so if the exempt share climbs the drift is measurable. Drop it with " +
206
+ "proseOnly:false as soon as the model behind this agent can pick a record.type.",
207
+ },
208
+ }
209
+ : {};
166
210
  return {
167
211
  ok: true as const,
168
212
  ...(canonWarning ? { warning: canonWarning } : {}),
169
213
  agent: entry,
214
+ ...proseOnlyEcho,
170
215
  resolvedRole: resolveRole(entry),
171
216
  recordAuthority: {
172
217
  ...authority,
@@ -344,7 +389,22 @@ export async function listAgentsTool() {
344
389
  : undefined,
345
390
  };
346
391
  });
347
- return { agents, evicted };
392
+ // COUNTED, so drift is measurable (Task 12.8). "No exemptions" and "I did not
393
+ // look" are different claims, so the block is always present and always
394
+ // carries its denominator — a bare list of ids would read as zero on a bus
395
+ // where the field had simply never been written.
396
+ const exempt = agents.filter((a) => a.proseOnly);
397
+ const proseOnly = {
398
+ exempt: exempt.map((a) => ({ agentId: a.agentId, since: a.proseOnly!.since, reason: a.proseOnly!.reason })),
399
+ count: exempt.length,
400
+ total: agents.length,
401
+ share: agents.length ? Number((exempt.length / agents.length).toFixed(3)) : 0,
402
+ note:
403
+ "prose-only agents are exempt from the typed-record rule on agent→agent sends. The exemption is " +
404
+ "granted to the SENDER and paid by every READER: their multi-line messages cannot be slimmed. " +
405
+ "A rising share means the rule is decaying.",
406
+ };
407
+ return { agents, evicted, proseOnly };
348
408
  }
349
409
 
350
410
  export async function loadLiveTransports(): Promise<Map<string, TransportMarker>> {
@@ -73,6 +73,19 @@ export type AgentEntry = {
73
73
  registeredAt: number;
74
74
  lastHeartbeat: number;
75
75
  capabilities?: string[];
76
+ // PROSE-ONLY EXEMPTION from the typed-record rule (Phase 5.1 Task 12.8).
77
+ //
78
+ // Declared PER AGENT at register/join, never as a global env var: a global
79
+ // switch turns the rule off fleet-wide in one line and nobody notices, while
80
+ // a per-agent declaration is a statement ABOUT THAT AGENT and shows up in
81
+ // `list_agents` next to it. It exists so a model that cannot reliably pick a
82
+ // `record.type` is not locked off the bus — opt-in, never the default.
83
+ //
84
+ // THE ASYMMETRY: granted to the SENDER, paid by every READER. An untyped
85
+ // message cannot be slimmed by hooks/tier.mjs, so a prose-only agent spends
86
+ // OTHER agents' context on every multi-line send. That is why it is stamped,
87
+ // counted, and reviewed rather than self-service.
88
+ proseOnly?: { since: number; reason?: string };
76
89
  };
77
90
 
78
91
  export type TransportMarker = {
@@ -944,12 +944,16 @@ export const joinSchema = {
944
944
  // token (tokens.json / coord-token) or force:true. Ignored once bound.
945
945
  token: z.string().optional(),
946
946
  force: z.boolean().optional(),
947
+ // Prose-only exemption from the typed-record rule — see registerSchema.
948
+ // Declared here too because `join` is the call every card actually makes.
949
+ proseOnly: z.union([z.boolean(), z.object({ reason: z.string().min(1) })]).optional(),
947
950
  };
948
951
 
949
952
  export async function joinTool(args: {
950
953
  agentId: string;
951
954
  project?: string;
952
955
  role?: RoleArg;
956
+ proseOnly?: boolean | { reason: string };
953
957
  attach?: boolean | { tmuxTarget?: string; includeRoom?: boolean; allowlist?: string[]; debounceMs?: number };
954
958
  readInbox?: boolean;
955
959
  }) {
@@ -957,6 +961,7 @@ export async function joinTool(args: {
957
961
  agentId: args.agentId,
958
962
  project: args.project,
959
963
  role: args.role,
964
+ proseOnly: args.proseOnly,
960
965
  });
961
966
  // A refused role update (frozen roleId) fails the whole join rather than
962
967
  // silently attaching a transport under the wrong identity.
@@ -0,0 +1,216 @@
1
+ // Typed records become obligatory on agent→agent traffic (Phase 5.1 Task 12).
2
+ //
3
+ // THE MECHANISM ALREADY SHIPPED AND ADOPTION WAS THE WHOLE GAP.
4
+ // `hooks/tier.mjs:injectLine` slims a multi-line message to *first line +
5
+ // `[+N lines · record:<type> · retrieve_message id=<uuid>]`* — but ONLY when
6
+ // `record.type` is a string. Measured on the groundwork room 2026-08-30: 206
7
+ // messages, 175 multi-line, 33 typed, 33 slimmed; the coordinator had sent 101
8
+ // and typed 0. The renderer was never the problem.
9
+ //
10
+ // SO THE RULE IS ENFORCED AT THE SEND, NOT THE RENDER. An untyped agent→agent
11
+ // message must not be able to EXIST, or the next reader re-derives the type
12
+ // from prose and we are back to parsing English. Prose is a second source of
13
+ // truth about what a message meant.
14
+ //
15
+ // Three things this module is careful about, each one a stated acceptance
16
+ // property rather than an implementation taste:
17
+ //
18
+ // 1. STAGED, WITH A NAMED DATE. Five fleets share this bus and 7 of 13 server
19
+ // processes predate 0.26.7. A refusal shipped before a verified restart
20
+ // rejects messages from agents that CANNOT comply — the sender gets an
21
+ // error it has no code path to satisfy. So: WARN until the cutover, REFUSE
22
+ // after it, and the warning names the date in every single message so the
23
+ // deadline arrives having been announced ~every send rather than once.
24
+ //
25
+ // 2. THE REFUSAL NAMES A TYPE THE SENDER MAY ACTUALLY USE. `go` and `scope`
26
+ // are coordinator-restricted and `verdict` is gate-runner-restricted
27
+ // (RECORD_AUTHORITY): the aide hit exactly this, being told to use `scope`
28
+ // by one check and refused it by another. Naming a forbidden type is
29
+ // unusable guidance, so every suggestion is filtered through what this
30
+ // sender's role may emit before it is offered.
31
+ //
32
+ // 3. `fyi` IS AN HONEST CATCH-ALL. If the nine types do not cover a legitimate
33
+ // send, agents mistype into whatever passes and the type becomes noise —
34
+ // which costs more than the untyped message did. Anything unrecognised
35
+ // suggests `fyi` and says so plainly, never a forced `decision`/`risk`.
36
+
37
+ // The named cutover. Until this instant an untyped agent→agent send WARNS and
38
+ // is stored; from it, the same send is refused.
39
+ //
40
+ // A DATE, NOT A VERSION, because the constraint being waited on is a fleet-wide
41
+ // RESTART, and no version string on the wire can answer "has every server
42
+ // restarted" — 0.26.7 is published while servers predating it still run. Time
43
+ // is the only clock every one of those processes shares.
44
+ export const TYPED_RECORD_CUTOVER_ISO = "2026-09-15T00:00:00.000Z";
45
+ export const TYPED_RECORD_CUTOVER_MS = Date.parse(TYPED_RECORD_CUTOVER_ISO);
46
+
47
+ export type TypedRecordMode = "warn" | "refuse";
48
+
49
+ // `AGENT_COORD_TYPED_RECORDS=warn|refuse` overrides the date. It exists for two
50
+ // callers and no others: tests (which must exercise both sides of a date that
51
+ // has not arrived) and an operator who needs to pull the refusal back for a
52
+ // fleet mid-incident. It is NOT the exemption — an exemption is per-agent and
53
+ // visible (see `proseOnly`); this switch is fleet-wide and invisible, which is
54
+ // exactly why it is not how a less capable model gets on the bus.
55
+ export function typedRecordMode(now: number = Date.now()): TypedRecordMode {
56
+ const env = process.env.AGENT_COORD_TYPED_RECORDS;
57
+ if (env === "warn" || env === "refuse") return env;
58
+ return now >= TYPED_RECORD_CUTOVER_MS ? "refuse" : "warn";
59
+ }
60
+
61
+ // ---------- suggesting the type the sender should have used ----------
62
+
63
+ // A rejection that says only "record required" costs a round trip; one that
64
+ // says "looks like a `done` — it cites a PR" costs none. A FAILURE PATH MUST BE
65
+ // MORE INFORMATIVE THAN THE SUCCESS PATH HERE, because it fires during a
66
+ // migration, at agents that are mid-slice and did nothing wrong yesterday.
67
+ //
68
+ // Read from the text the fleet already writes: the six canonical prefixes are
69
+ // in every card and on ~every message, so the type is usually already declared
70
+ // in English one token in.
71
+ const PREFIX_TYPES: Array<[RegExp, string, string]> = [
72
+ [/^\s*DONE\b/i, "done", "the message opens with the DONE: prefix"],
73
+ [/^\s*BLOCKER\b/i, "blocker", "the message opens with the BLOCKER: prefix"],
74
+ [/^\s*RISK\b/i, "risk", "the message opens with the RISK: prefix"],
75
+ [/^\s*FYI\b/i, "fyi", "the message opens with the FYI: prefix"],
76
+ [/^\s*AGENT_ACTION\b/i, "action", "the message opens with the AGENT_ACTION: prefix"],
77
+ [/^\s*DAVID_DECISION\b/i, "decision", "the message opens with the DAVID_DECISION: prefix"],
78
+ [/^\s*GO\b/i, "go", "the message opens with the GO: prefix"],
79
+ [/^\s*SCOPE(?:\s+CHANGE)?\b/i, "scope", "the message opens with the SCOPE: prefix"],
80
+ ];
81
+
82
+ // Weaker than a prefix and only consulted when there is no prefix at all.
83
+ const SHAPE_TYPES: Array<[RegExp, string, string]> = [
84
+ [/\b(PASS|FAIL)\b.*\b[0-9a-f]{7,40}\b/, "verdict", "it reads as a gate verdict over a commit sha"],
85
+ [/^\s*(PASS|FAIL)\b/, "verdict", "it opens with a PASS/FAIL gate result"],
86
+ [/(^|\s)(#\d+|[\w.-]+\/[\w.-]+#\d+|https:\/\/github\.com\/\S+\/pull\/\d+)/, "done", "it cites a PR"],
87
+ ];
88
+
89
+ export type RecordSuggestion = {
90
+ type: string;
91
+ /** Why this type — quoted back to the sender so the guess is auditable. */
92
+ why: string;
93
+ /** Set when the best-fitting type is one this sender's role may not emit. */
94
+ downgradedFrom?: string;
95
+ };
96
+
97
+ /**
98
+ * The record type this message most likely should have carried, CONSTRAINED to
99
+ * what this sender is allowed to emit.
100
+ *
101
+ * `mayNotEmit` is the restricted set this role is refused (recordAuthorityFor).
102
+ * A suggestion landing in it is downgraded to `fyi` and the downgrade is
103
+ * reported rather than hidden — being quietly steered off `go` reads as the
104
+ * heuristic being bad, when in fact the role simply does not own that type.
105
+ */
106
+ export function suggestRecordType(text: string, mayNotEmit: readonly string[] = []): RecordSuggestion {
107
+ const forbidden = new Set(mayNotEmit);
108
+ const firstLine = (text ?? "").split("\n", 1)[0] ?? "";
109
+
110
+ const hit =
111
+ PREFIX_TYPES.find(([re]) => re.test(firstLine)) ??
112
+ SHAPE_TYPES.find(([re]) => re.test(firstLine) || re.test(text ?? ""));
113
+
114
+ if (!hit) {
115
+ return {
116
+ type: "fyi",
117
+ why: "nothing in the text names a kind — 'fyi' is the honest catch-all and is never wrong on purpose",
118
+ };
119
+ }
120
+
121
+ const [, type, why] = hit;
122
+ if (forbidden.has(type)) {
123
+ return {
124
+ type: "fyi",
125
+ downgradedFrom: type,
126
+ why: `${why}, but '${type}' is restricted to roles this sender does not hold — 'fyi' carries the same slimming`,
127
+ };
128
+ }
129
+ return { type, why };
130
+ }
131
+
132
+ /**
133
+ * The one line of guidance appended to both the warning and the refusal. Same
134
+ * words either side of the cutover ON PURPOSE: an agent that reads it during
135
+ * the warn window has already been told the exact call that will keep working.
136
+ */
137
+ export function typedRecordGuidance(s: RecordSuggestion): string {
138
+ const cite =
139
+ s.type === "done"
140
+ ? `, cites: [{kind:'pr', ref:'<owner/repo#N>'}]` // a `done` is refused without one anyway
141
+ : "";
142
+ return (
143
+ `looks like a '${s.type}' — ${s.why}. ` +
144
+ `Add record: {type:'${s.type}', payload:{summary:'<one line>'}${cite}} to this same call; ` +
145
+ `'text' is untouched by the record and your wording always wins.`
146
+ );
147
+ }
148
+
149
+ // ---------- measuring adoption, and the inverse failure ----------
150
+
151
+ // SUCCESS IS THE TRAFFIC TABLE RE-MEASURED, NOT THIS TASK MERGED. The baseline
152
+ // captured the day the rule was made: 212 room messages, 37 typed (17%),
153
+ // 425,903 bytes sitting below line 1 — bytes every reader pays for and no
154
+ // reader asked for.
155
+ //
156
+ // AND THE INVERSE FAILURE LOOKS EXACTLY LIKE SUCCESS ON A COVERAGE NUMBER. If
157
+ // `fyi` becomes nearly everything, coverage reads ~100% while agents are
158
+ // mistyping to get past the gate and the type has stopped carrying
159
+ // information — that is the catch-all rule (12.5) failing, not holding. So the
160
+ // distribution is reported alongside the percentage and is what the verdict
161
+ // keys on. A coverage-only check would certify the failure it exists to catch.
162
+ //
163
+ // The threshold is deliberately generous: `fyi` IS the honest answer for a
164
+ // large share of real traffic, so this fires on domination, not on presence.
165
+ export const FYI_DOMINANCE_THRESHOLD = 0.75;
166
+
167
+ export type TypedRecordStats = {
168
+ total: number;
169
+ typed: number;
170
+ coverage: number;
171
+ /** Bytes below the first line of UNTYPED messages — the cost still being paid. */
172
+ unslimmedBytes: number;
173
+ /** count by record.type, plus `untyped`. */
174
+ distribution: Record<string, number>;
175
+ fyiShareOfTyped: number;
176
+ verdict: "healthy" | "adopting" | "degenerate";
177
+ note: string;
178
+ };
179
+
180
+ /** `messages` is any array of stored messages ({text, record?}). */
181
+ export function typedRecordStats(messages: ReadonlyArray<{ text?: string; record?: { type?: string } }>): TypedRecordStats {
182
+ const distribution: Record<string, number> = {};
183
+ let typed = 0;
184
+ let unslimmedBytes = 0;
185
+
186
+ for (const m of messages) {
187
+ const type = typeof m.record?.type === "string" ? m.record.type : undefined;
188
+ distribution[type ?? "untyped"] = (distribution[type ?? "untyped"] ?? 0) + 1;
189
+ if (type) {
190
+ typed++;
191
+ continue;
192
+ }
193
+ const text = m.text ?? "";
194
+ const nl = text.indexOf("\n");
195
+ if (nl !== -1) unslimmedBytes += Buffer.byteLength(text.slice(nl + 1), "utf8");
196
+ }
197
+
198
+ const total = messages.length;
199
+ const coverage = total ? typed / total : 0;
200
+ const fyiShareOfTyped = typed ? (distribution.fyi ?? 0) / typed : 0;
201
+
202
+ // Order matters: degeneracy is checked BEFORE coverage, or a room that is
203
+ // 100% `fyi` reports "healthy" — the exact misreading this guards.
204
+ const degenerate = typed >= 10 && fyiShareOfTyped > FYI_DOMINANCE_THRESHOLD;
205
+ const verdict = degenerate ? "degenerate" : coverage >= 0.95 ? "healthy" : "adopting";
206
+
207
+ const note = degenerate
208
+ ? `'fyi' is ${(fyiShareOfTyped * 100).toFixed(0)}% of typed messages — coverage looks solved while the type has ` +
209
+ `stopped carrying information. Agents are typing to pass the gate, which is 12.5 FAILING, not holding.`
210
+ : verdict === "healthy"
211
+ ? `${(coverage * 100).toFixed(0)}% typed with a spread distribution — the reader-side cost is paid down and the type still discriminates.`
212
+ : `${(coverage * 100).toFixed(0)}% typed; ${unslimmedBytes.toLocaleString("en-US")} bytes still sit below line 1 in untyped messages, ` +
213
+ `carried by every reader.`;
214
+
215
+ return { total, typed, coverage: Number(coverage.toFixed(3)), unslimmedBytes, distribution, fyiShareOfTyped: Number(fyiShareOfTyped.toFixed(3)), verdict, note };
216
+ }