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/dist/roles.js +11 -0
- package/dist/roles.js.map +1 -1
- package/dist/server.js +4 -4
- package/dist/server.js.map +1 -1
- package/dist/tools/messaging.js +60 -3
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/registry.js +54 -1
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/transport.js +4 -0
- package/dist/tools/transport.js.map +1 -1
- package/dist/typed-records.js +174 -0
- package/dist/typed-records.js.map +1 -0
- package/hooks/roles.mjs +8 -0
- package/package.json +1 -1
- package/scripts/check-test-count.mjs +1 -1
- package/scripts/typed-record-stats.mjs +43 -0
- package/src/roles.ts +12 -0
- package/src/server.ts +4 -4
- package/src/tools/messaging.ts +76 -3
- package/src/tools/registry.ts +62 -2
- package/src/tools/shared.ts +13 -0
- package/src/tools/transport.ts +5 -0
- package/src/typed-records.ts +216 -0
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
|
);
|
package/src/tools/messaging.ts
CHANGED
|
@@ -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
|
|
package/src/tools/registry.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
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>> {
|
package/src/tools/shared.ts
CHANGED
|
@@ -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 = {
|
package/src/tools/transport.ts
CHANGED
|
@@ -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
|
+
}
|