agent-coord-mcp 0.26.10 → 0.26.12
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 +7 -5
- package/dist/server.js.map +1 -1
- package/dist/tools/events.js +7 -1
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/index.js +2 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/messaging.js +60 -3
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/record-events.js +365 -0
- package/dist/tools/record-events.js.map +1 -0
- 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 +2 -2
- 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 +13 -5
- package/src/tools/events.ts +8 -4
- package/src/tools/index.ts +2 -0
- package/src/tools/messaging.ts +76 -3
- package/src/tools/record-events.ts +386 -0
- 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/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
|
+
}
|