agent-coord-mcp 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +34 -5
  2. package/dist/build.js +113 -0
  3. package/dist/build.js.map +1 -0
  4. package/dist/roles.js +92 -0
  5. package/dist/roles.js.map +1 -0
  6. package/dist/server.js +30 -14
  7. package/dist/server.js.map +1 -1
  8. package/dist/store.js +54 -1
  9. package/dist/store.js.map +1 -1
  10. package/dist/tools/admin.js +5 -3
  11. package/dist/tools/admin.js.map +1 -1
  12. package/dist/tools/index.js +2 -0
  13. package/dist/tools/index.js.map +1 -1
  14. package/dist/tools/messaging.js +187 -8
  15. package/dist/tools/messaging.js.map +1 -1
  16. package/dist/tools/registry.js +54 -3
  17. package/dist/tools/registry.js.map +1 -1
  18. package/dist/tools/render.js +84 -0
  19. package/dist/tools/render.js.map +1 -0
  20. package/dist/tools/scopes.js +126 -0
  21. package/dist/tools/scopes.js.map +1 -0
  22. package/dist/tools/shared.js +18 -0
  23. package/dist/tools/shared.js.map +1 -1
  24. package/dist/tools/transport.js +367 -33
  25. package/dist/tools/transport.js.map +1 -1
  26. package/dist/tools/work.js +209 -0
  27. package/dist/tools/work.js.map +1 -0
  28. package/dist/work.js +260 -0
  29. package/dist/work.js.map +1 -0
  30. package/hooks/marker.mjs +18 -0
  31. package/hooks/roles.mjs +79 -0
  32. package/hooks/submit.mjs +271 -0
  33. package/hooks/tier.mjs +104 -11
  34. package/hooks/tmux-pusher.mjs +137 -51
  35. package/package.json +5 -4
  36. package/scripts/check-self-dependency.mjs +42 -0
  37. package/scripts/check-test-count.mjs +83 -0
  38. package/scripts/coord-pusher.mjs +150 -33
  39. package/src/build.ts +111 -0
  40. package/src/roles.ts +111 -0
  41. package/src/server.ts +79 -14
  42. package/src/store.ts +60 -1
  43. package/src/tools/admin.ts +10 -4
  44. package/src/tools/index.ts +2 -0
  45. package/src/tools/messaging.ts +206 -7
  46. package/src/tools/registry.ts +63 -4
  47. package/src/tools/render.ts +80 -0
  48. package/src/tools/scopes.ts +177 -0
  49. package/src/tools/shared.ts +120 -0
  50. package/src/tools/transport.ts +386 -34
  51. package/src/tools/work.ts +265 -0
  52. package/src/work.ts +329 -0
@@ -48,6 +48,7 @@ import {
48
48
  updateJson,
49
49
  type RoomRegistry,
50
50
  } from "../store.js";
51
+ import { recordAuthorityFor, resolveRole, roleInputSchema, type RoleArg } from "../roles.js";
51
52
  import {
52
53
  type AgentEntry,
53
54
  type AgentRegistry,
@@ -71,24 +72,82 @@ import {
71
72
  export const registerSchema = {
72
73
  agentId: z.string().min(1),
73
74
  project: z.string().optional(),
74
- role: z.string().optional(),
75
+ role: roleInputSchema.optional(),
75
76
  };
76
77
 
77
- export async function registerTool(args: { agentId: string; project?: string; role?: string }) {
78
+ // Work out what `role`/`roleId` should become, or why the update is refused.
79
+ //
80
+ // Rules (Phase 8 Task 4):
81
+ // - A DECLARED roleId is immutable. Re-declaring the same id is a no-op;
82
+ // declaring a different one is rejected.
83
+ // - displayName is always free to change — that is the whole point.
84
+ // - A plain string never freezes an id (it only sets the display name), so
85
+ // v1 agents that re-register under changing free text keep working exactly
86
+ // as before, and their id stays derived.
87
+ export function resolveRoleUpdate(
88
+ agentId: string,
89
+ existing: AgentEntry | undefined,
90
+ input: RoleArg | undefined,
91
+ ): { ok: true; role?: string; roleId?: string } | { ok: false; error: string } {
92
+ if (input === undefined) return { ok: true, role: existing?.role, roleId: existing?.roleId };
93
+
94
+ const resolved = resolveRole(typeof input === "string" ? input : { ...input });
95
+ if (!resolved) return { ok: true, role: existing?.role, roleId: existing?.roleId };
96
+
97
+ const declared = typeof input === "object" && !!input.roleId;
98
+ if (existing?.roleId && declared && resolved.roleId !== existing.roleId) {
99
+ return {
100
+ ok: false,
101
+ error:
102
+ `roleId '${existing.roleId}' is frozen for agent '${agentId}'; rejected attempt to change it to '${resolved.roleId}'. ` +
103
+ `The display name is free to change — pass role.displayName instead.`,
104
+ };
105
+ }
106
+
107
+ // A bare string against an agent with a frozen id updates the name only.
108
+ const nextRole =
109
+ typeof input === "string" ? input : input.displayName ?? existing?.role ?? resolved.displayName;
110
+ return { ok: true, role: nextRole, roleId: declared ? resolved.roleId : existing?.roleId };
111
+ }
112
+
113
+ export async function registerTool(args: { agentId: string; project?: string; role?: RoleArg }) {
114
+ const before = await readJson<AgentRegistry>(AGENTS_FILE, {});
115
+ const roleUpdate = resolveRoleUpdate(args.agentId, before[args.agentId], args.role);
116
+ if (!roleUpdate.ok) return { ok: false as const, error: roleUpdate.error };
117
+
78
118
  const reg = await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
79
119
  const now = Date.now();
80
120
  const existing = current[args.agentId];
81
121
  current[args.agentId] = {
82
122
  agentId: args.agentId,
83
123
  project: args.project ?? existing?.project,
84
- role: args.role ?? existing?.role,
124
+ role: roleUpdate.role,
125
+ // Only ever written when the caller declared one — absent stays absent,
126
+ // so an existing agents.json is never rewritten into a new shape.
127
+ ...(roleUpdate.roleId ? { roleId: roleUpdate.roleId } : {}),
85
128
  registeredAt: existing?.registeredAt ?? now,
86
129
  lastHeartbeat: now,
87
130
  capabilities: existing?.capabilities,
88
131
  };
89
132
  return current;
90
133
  });
91
- return { ok: true, agent: reg[args.agentId] };
134
+ const entry = reg[args.agentId];
135
+ // Echo the record types this role may and may not emit. Record authority is
136
+ // otherwise invisible until the first typed send is refused mid-work — this
137
+ // is how an agent whose role owns `go`/`scope`/`verdict` finds out at
138
+ // onboarding that it has to declare that role.
139
+ const authority = recordAuthorityFor(entry);
140
+ return {
141
+ ok: true as const,
142
+ agent: entry,
143
+ resolvedRole: resolveRole(entry),
144
+ recordAuthority: {
145
+ ...authority,
146
+ ...(authority.mayNotEmit.length
147
+ ? { note: `this role may not emit ${authority.mayNotEmit.map((t) => `'${t}'`).join("/")} as a typed record — register with the owning role (e.g. role:{roleId:"coordinator"}) if it should. Text prefixes are unrestricted.` }
148
+ : {}),
149
+ },
150
+ };
92
151
  }
93
152
 
94
153
  // ---------- unregister ----------
@@ -0,0 +1,80 @@
1
+ // Typed record → the text layout the fleet already reads (Phase 8 Task 3.3).
2
+ //
3
+ // Every consumer downstream of a message — hooks/tier.mjs's prefix table, the
4
+ // UI's alert parser, a human reading a tmux pane — reads `text`. Phase 8 adds
5
+ // `record` alongside it, so the rendering must reproduce the byte layout those
6
+ // consumers already expect. Nothing downstream changes while agents migrate.
7
+ //
8
+ // This is a pure function of the record: no clock, no I/O, no registry. It
9
+ // never sees `from`, `to`, or anything the sender could use it to forge.
10
+
11
+ import type { DecisionPayload, MessageRecord, SummaryPayload, VerdictPayload } from "./shared.js";
12
+
13
+ // The case-sensitive prefixes at byte 0 of `text`, exactly as classifyTier
14
+ // matches them. `decision` is absent because it renders as a multi-line block,
15
+ // not a one-liner.
16
+ const PREFIX: Record<string, string> = {
17
+ blocker: "BLOCKER",
18
+ risk: "RISK",
19
+ done: "DONE",
20
+ fyi: "FYI",
21
+ action: "AGENT_ACTION",
22
+ go: "GO",
23
+ scope: "SCOPE CHANGE",
24
+ // `verdict` has no prefix in the v1 vocabulary — it is new in Phase 8. A gate
25
+ // PASS/FAIL was previously posted as prose, which is why verdicts could not
26
+ // be routed. Rendering it under its own prefix gives it one.
27
+ verdict: "VERDICT",
28
+ };
29
+
30
+ function isNonEmpty(v: unknown): v is string {
31
+ return typeof v === "string" && v.trim().length > 0;
32
+ }
33
+
34
+ // The playbook's decision packet (§Decision Packet Format), byte-for-byte —
35
+ // the UI parses this into a clickable decision card, and a layout that drifts
36
+ // still alerts loudly but loses the card. Returns null when any of the five
37
+ // fields is missing, so the caller can reject rather than emit a half packet.
38
+ function renderDecision(p: DecisionPayload): string | null {
39
+ if (!isNonEmpty(p.title) || !isNonEmpty(p.context)) return null;
40
+ if (!isNonEmpty(p.recommendation) || !isNonEmpty(p.ifNoAction)) return null;
41
+ if (!Array.isArray(p.options) || p.options.length === 0) return null;
42
+ if (!p.options.every(isNonEmpty)) return null;
43
+ return [
44
+ `DAVID_DECISION: ${p.title}`,
45
+ `Context: ${p.context}`,
46
+ "Options:",
47
+ ...p.options.map((o, i) => `${i + 1}. ${o}`),
48
+ `Recommendation: ${p.recommendation}`,
49
+ `If no action: ${p.ifNoAction}`,
50
+ ].join("\n");
51
+ }
52
+
53
+ // `VERDICT: PASS <sha> — <notes>`. The sha is not optional in the rendering:
54
+ // a verdict that doesn't name the commit it was issued against is
55
+ // unfalsifiable the moment the branch moves.
56
+ function renderVerdict(p: VerdictPayload): string | null {
57
+ if (p.result !== "pass" && p.result !== "fail") return null;
58
+ if (!isNonEmpty(p.headRefOid)) return null;
59
+ const head = `${PREFIX.verdict}: ${p.result.toUpperCase()} ${p.headRefOid}`;
60
+ return isNonEmpty(p.notes) ? `${head} — ${p.notes}` : head;
61
+ }
62
+
63
+ // Render a record to its text form, or null when the payload can't support one
64
+ // (absent, or missing a field the layout needs). Null is not an error here —
65
+ // the caller decides whether a record without a rendering is fatal, and it only
66
+ // is when there's no author-supplied `text` to fall back on.
67
+ export function renderRecord(record: MessageRecord): string | null {
68
+ if (!record || typeof record.type !== "string") return null;
69
+ const payload = record.payload as unknown;
70
+ if (payload === undefined || payload === null) return null;
71
+ if (typeof payload !== "object" || Array.isArray(payload)) return null;
72
+
73
+ if (record.type === "decision") return renderDecision(payload as DecisionPayload);
74
+ if (record.type === "verdict") return renderVerdict(payload as VerdictPayload);
75
+
76
+ const prefix = PREFIX[record.type];
77
+ if (!prefix) return null;
78
+ const { summary } = payload as SummaryPayload;
79
+ return isNonEmpty(summary) ? `${prefix}: ${summary}` : null;
80
+ }
@@ -0,0 +1,177 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+ import { AGENTS_FILE, ROOT, SCOPES_FILE, readJson } from "../store.js";
6
+ import { resolveRole, roleMatches, slugifyRole } from "../roles.js";
7
+ import type { AgentRegistry } from "./shared.js";
8
+
9
+ // ---------- document write scopes (Phase 8 Task 4) ----------
10
+ //
11
+ // ADVISORY, AND THAT IS DELIBERATE. Nothing here can stop a write: agents edit
12
+ // docs/QUEUE.md, DONE.md and WORKSTREAMS.md with ordinary file tools, which
13
+ // never touch the bus, so there is no point at which the server could refuse
14
+ // one. What this module gives you is:
15
+ //
16
+ // list_scopes — ask "who owns this document, may I write it?" BEFORE writing
17
+ // doctor — detect, after the fact, that the last writer wasn't the owner
18
+ //
19
+ // Pre-emptive enforcement arrives with Task 5 (work state as data in the
20
+ // store, where the bus IS the write path). Until then, do not describe a
21
+ // declared scope as a guarantee — it is a convention with a smoke detector.
22
+
23
+ // On-disk shape. Either the flat form:
24
+ // { "docs/QUEUE.md": {"owner": "aide", "mode": "exclusive"} }
25
+ // or, when the documents don't live next to the bus's cwd:
26
+ // { "repo": "/path/to/repo", "documents": { ...same map... } }
27
+ export type ScopeMode = "exclusive" | "append-only" | "shared";
28
+ export type ScopeEntry = { owner: string; mode?: ScopeMode; note?: string };
29
+ export type ScopesFile =
30
+ | Record<string, ScopeEntry>
31
+ | { repo?: string; documents: Record<string, ScopeEntry> };
32
+
33
+ export type LoadedScopes = {
34
+ configured: boolean;
35
+ file: string;
36
+ repo: string;
37
+ documents: { path: string; owner: string; mode: ScopeMode; note?: string }[];
38
+ };
39
+
40
+ function isWrapped(raw: ScopesFile): raw is { repo?: string; documents: Record<string, ScopeEntry> } {
41
+ return typeof (raw as { documents?: unknown }).documents === "object" &&
42
+ (raw as { documents?: unknown }).documents !== null;
43
+ }
44
+
45
+ // Absent file → configured:false, empty document list. Never throws: a broken
46
+ // scopes.json degrades to "nothing declared" rather than taking a tool down.
47
+ export async function loadScopes(): Promise<LoadedScopes> {
48
+ const empty: LoadedScopes = { configured: false, file: SCOPES_FILE, repo: process.cwd(), documents: [] };
49
+ if (!existsSync(SCOPES_FILE)) return empty;
50
+ const raw = await readJson<ScopesFile | null>(SCOPES_FILE, null);
51
+ if (!raw || typeof raw !== "object") return empty;
52
+
53
+ const map = isWrapped(raw) ? raw.documents : (raw as Record<string, ScopeEntry>);
54
+ const repo = isWrapped(raw) && raw.repo ? raw.repo : process.cwd();
55
+ const documents = Object.entries(map ?? {})
56
+ .filter(([, e]) => e && typeof e === "object" && typeof e.owner === "string")
57
+ .map(([p, e]) => ({
58
+ path: p.replace(/^\.\//, ""),
59
+ owner: e.owner,
60
+ mode: (e.mode ?? "exclusive") as ScopeMode,
61
+ ...(e.note ? { note: e.note } : {}),
62
+ }));
63
+ return { configured: true, file: SCOPES_FILE, repo, documents };
64
+ }
65
+
66
+ // Does `agent` own `doc`? An owner is a roleId (matched through the same
67
+ // resolution the rest of Task 4 uses) or a bare agentId, so a document can be
68
+ // pinned to a role ("aide") or to one specific agent ("david-worker-2").
69
+ export function ownsDocument(
70
+ agentId: string | undefined,
71
+ entry: { agentId?: string; role?: string; roleId?: string } | undefined,
72
+ owner: string,
73
+ ): boolean {
74
+ const target = slugifyRole(owner);
75
+ if (agentId && slugifyRole(agentId) === target) return true;
76
+ return roleMatches(entry, new Set([target]));
77
+ }
78
+
79
+ export const listScopesSchema = {
80
+ // Ask about one document instead of listing everything.
81
+ path: z.string().optional(),
82
+ // Whose write is being contemplated (defaults to nobody — a pure listing).
83
+ agentId: z.string().optional(),
84
+ };
85
+
86
+ export async function listScopesTool(args: { path?: string; agentId?: string }) {
87
+ const scopes = await loadScopes();
88
+ const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
89
+ const entry = args.agentId ? reg[args.agentId] : undefined;
90
+
91
+ // Every answer carries `advisory: true`. A caller that stores this verdict
92
+ // is storing an opinion, not a permission.
93
+ const base = {
94
+ ok: true as const,
95
+ advisory: true as const,
96
+ configured: scopes.configured,
97
+ file: scopes.file,
98
+ repo: scopes.repo,
99
+ note: scopes.configured
100
+ ? "declared scopes are advisory — the bus does not mediate file writes (Phase 8 Task 5)"
101
+ : `no ${path.basename(SCOPES_FILE)} in ${ROOT} — no document is owned, nothing is checked (opt-in)`,
102
+ };
103
+
104
+ if (args.path === undefined) {
105
+ return {
106
+ ...base,
107
+ documents: scopes.documents,
108
+ ...(args.agentId
109
+ ? { you: { agentId: args.agentId, role: resolveRole(entry) ?? null } }
110
+ : {}),
111
+ };
112
+ }
113
+
114
+ const wanted = args.path.replace(/^\.\//, "");
115
+ const doc = scopes.documents.find((d) => d.path === wanted);
116
+ if (!doc) {
117
+ return { ...base, path: wanted, owned: false, mayWrite: true, reason: "no scope declared for this document" };
118
+ }
119
+ const mine = args.agentId ? ownsDocument(args.agentId, entry, doc.owner) : false;
120
+ return {
121
+ ...base,
122
+ path: doc.path,
123
+ owned: true,
124
+ owner: doc.owner,
125
+ mode: doc.mode,
126
+ ...(doc.note ? { docNote: doc.note } : {}),
127
+ mayWrite: mine || doc.mode === "shared",
128
+ reason: mine
129
+ ? `'${args.agentId}' matches the declared owner '${doc.owner}'`
130
+ : doc.mode === "shared"
131
+ ? `declared shared — any agent may write, '${doc.owner}' is the maintainer`
132
+ : `owned by '${doc.owner}' (${doc.mode})${args.agentId ? `; '${args.agentId}' does not match` : ""} — coordinate before writing`,
133
+ };
134
+ }
135
+
136
+ // ---------- last-writer lookup (used by doctor's scope-drift check) ----------
137
+
138
+ export type LastWriter = { author: string; email: string; commit: string; when: string };
139
+
140
+ export function isGitRepo(repo: string): boolean {
141
+ if (!existsSync(repo)) return false;
142
+ const r = spawnSync("git", ["-C", repo, "rev-parse", "--is-inside-work-tree"], { encoding: "utf8" });
143
+ return r.status === 0 && String(r.stdout).trim() === "true";
144
+ }
145
+
146
+ // Last commit that touched `file`, or null if git has never seen it. Cheap
147
+ // (`-1`), read-only, and confined to `repo`.
148
+ export function lastWriterOf(repo: string, file: string): LastWriter | null {
149
+ const r = spawnSync(
150
+ "git",
151
+ ["-C", repo, "log", "-1", "--format=%an%x00%ae%x00%h%x00%aI", "--", file],
152
+ { encoding: "utf8" },
153
+ );
154
+ if (r.status !== 0) return null;
155
+ const line = String(r.stdout).trim();
156
+ if (!line) return null;
157
+ const [author = "", email = "", commit = "", when = ""] = line.split("\0");
158
+ return { author, email, commit, when };
159
+ }
160
+
161
+ // Map a commit author back to a bus agent. Git records humans (or one shared
162
+ // machine account), not agent ids, so this only succeeds when the author name
163
+ // or email local-part IS a registered agentId or its role id — which is the
164
+ // honest limit of the check. Unmappable authors are reported as such by doctor
165
+ // instead of being counted as drift.
166
+ export function attributeWriter(writer: LastWriter, reg: AgentRegistry): { agentId: string; roleId?: string } | null {
167
+ const candidates = new Set(
168
+ [writer.author, writer.email.split("@")[0] ?? ""].map(slugifyRole).filter(Boolean),
169
+ );
170
+ for (const [agentId, entry] of Object.entries(reg)) {
171
+ const resolved = resolveRole(entry);
172
+ if (candidates.has(slugifyRole(agentId)) || (resolved && candidates.has(resolved.roleId))) {
173
+ return { agentId, ...(resolved ? { roleId: resolved.roleId } : {}) };
174
+ }
175
+ }
176
+ return null;
177
+ }
@@ -51,7 +51,16 @@ import {
51
51
  export type AgentEntry = {
52
52
  agentId: string;
53
53
  project?: string;
54
+ // DISPLAY NAME — free to change. Stays a plain string on disk so existing
55
+ // agents.json files, coord-chat's `/whois`, and every v1 reader keep working
56
+ // untouched.
54
57
  role?: string;
58
+ // FROZEN IDENTITY (Phase 8 Task 4). Optional and additive: when absent the id
59
+ // is derived from `role` at read time (resolveRole), which is what every
60
+ // pre-Task-4 entry does. Once DECLARED it is immutable — register rejects an
61
+ // attempt to change it — so a role can be renamed (curator → liaison → aide)
62
+ // without every id, skill and script that keys off it having to move.
63
+ roleId?: string;
55
64
  registeredAt: number;
56
65
  lastHeartbeat: number;
57
66
  capabilities?: string[];
@@ -72,10 +81,93 @@ export type TransportMarker = {
72
81
  // pusher — the class of bug that silently dropped /clear /compact in v0.8.1.
73
82
  // Absent on markers written by older versions (treated as "unknown, skip").
74
83
  scriptMtime?: number;
84
+ // Build identity (newest dist mtime, sampled at that server's module load)
85
+ // of the MCP server whose attach_agent stamped this marker. A marker
86
+ // stamped by a server predating the current on-disk build was written by
87
+ // attach/stamp logic the rebuild replaced — doctor's provenance check flags
88
+ // it for a session restart + re-attach. Absent on markers written by older
89
+ // versions (treated as "unknown, skip", deliberately mirroring scriptMtime).
90
+ serverBuildMtime?: number;
75
91
  };
76
92
 
77
93
  export type AgentRegistry = Record<string, AgentEntry>;
78
94
 
95
+ // Where a claim can be independently verified. The point is that a consumer
96
+ // resolves the ref (gh, git, fs) instead of trusting the message body — a
97
+ // `done` record whose PR ref doesn't exist is a false claim, not a typo.
98
+ export type Citation = {
99
+ kind: "pr" | "file" | "commit" | "url";
100
+ ref: string;
101
+ };
102
+
103
+ // The protocol vocabulary the fleet already speaks. Today these live as
104
+ // case-sensitive prefixes at byte 0 of `text` (hooks/tier.mjs), parsed a
105
+ // second time by the UI for alert priority — so a greeting before the prefix
106
+ // silently downgrades a production blocker, and the two parsers can disagree
107
+ // about the same message. As a field there is nothing to mis-parse.
108
+ export type MessageRecordType =
109
+ | "blocker" // BLOCKER: work cannot continue
110
+ | "decision" // DAVID_DECISION: the human must decide
111
+ | "risk" // RISK: quality/security/cost/product risk
112
+ | "done" // DONE: completed work, must cite
113
+ | "fyi" // FYI: no action needed
114
+ | "action" // AGENT_ACTION: another agent can handle it
115
+ | "go" // GO: a work order
116
+ | "scope" // SCOPE CHANGE: amends a contract in flight
117
+ | "verdict"; // (new) a gate PASS/FAIL — has no prefix today
118
+
119
+ // The five fields of the playbook's decision packet (§Decision Packet Format).
120
+ // Named to match that layout because `renderRecord` reproduces it byte-for-byte
121
+ // — the UI parses the rendering into a clickable decision card.
122
+ export type DecisionPayload = {
123
+ title: string;
124
+ context: string;
125
+ options: string[];
126
+ recommendation: string;
127
+ ifNoAction: string;
128
+ };
129
+
130
+ // A gate verdict. `headRefOid` pins WHICH commit was gated: a PASS that doesn't
131
+ // name the sha it was issued against is unfalsifiable once the branch moves.
132
+ export type VerdictPayload = {
133
+ result: "pass" | "fail";
134
+ headRefOid: string;
135
+ notes?: string;
136
+ };
137
+
138
+ // Everything else carries prose. One line, because these render as
139
+ // `<PREFIX>: <summary>` and a paragraph in a tmux pane is a wall, not a status.
140
+ export type SummaryPayload = { summary: string };
141
+
142
+ // Message types whose payload is just a summary.
143
+ export type SummaryRecordType = "blocker" | "risk" | "fyi" | "action" | "go" | "scope";
144
+
145
+ // Structured counterpart to `text`, NOT a replacement: `text` is the rendering,
146
+ // and a tmux pane can only receive text. A v1 agent that has never heard of
147
+ // `record` omits it entirely and behaves byte-identically.
148
+ //
149
+ // `payload` stays OPTIONAL on every arm — Phase 8 is additive, so no new
150
+ // required field may appear on the wire. What is pinned is the shape *if* a
151
+ // payload is supplied: a `decision` carrying three of its five fields is
152
+ // structurally wrong for the type it claims and is rejected, while a payload
153
+ // with extra unknown keys passes through untouched (a v3 sender must not be
154
+ // broken by a v2 server, and stripping would silently drop data on the way to
155
+ // disk).
156
+ //
157
+ // `cites` is likewise optional here. `done` requires a PR citation, but that is
158
+ // enforced in sendMessageTool as a plain {ok:false,error} rather than a schema
159
+ // rejection — see the identity-binding precedent in src/server.ts.
160
+ //
161
+ // UNTRUSTED, exactly like `from`. A peer can claim any type here, so trust
162
+ // decisions (the SCOPE countersignature, gate-runner routing) must still
163
+ // resolve the sender against the registry — typed is not the same as
164
+ // authenticated, and `record` must never become a path to setting `urgent`.
165
+ export type MessageRecord =
166
+ | { type: "decision"; payload?: DecisionPayload; cites?: Citation[] }
167
+ | { type: "verdict"; payload?: VerdictPayload; cites?: Citation[] }
168
+ | { type: "done"; payload?: SummaryPayload; cites?: Citation[] }
169
+ | { type: SummaryRecordType; payload?: SummaryPayload; cites?: Citation[] };
170
+
79
171
  export type Message = {
80
172
  id: string;
81
173
  ts: number;
@@ -96,9 +188,37 @@ export type Message = {
96
188
  // Semantic weight of a room post (absent = chatter). Decisions get a longer
97
189
  // prune retention (decisionDays), survive live compaction while fresh, and
98
190
  // are surfaced verbatim in overflow digests.
191
+ //
192
+ // This field is on disk in every JSONL file, so it is the half of the old
193
+ // name collision that could not move. The pushers' *synthetic* channel tag
194
+ // ("DM" / "room #general"), read by injectLine and classifyTier, was also
195
+ // called `kind` until Phase 8 Task 3 renamed it to `tag` — it is
196
+ // process-local and never persisted, so renaming it cost no migration.
197
+ // The two no longer collide; see hooks/tmux-pusher.mjs's collectSource.
99
198
  kind?: "decision" | "status" | "chatter";
199
+ // Typed protocol record (Phase 8). Optional and additive; see MessageRecord.
200
+ record?: MessageRecord;
100
201
  };
101
202
 
203
+ // THE retention predicate — one definition, three call sites (prune,
204
+ // live compaction, overflow-digest quoting). It lived as three copies of
205
+ // `e.kind === "decision"`, and they drifted: a v2 agent doing exactly what
206
+ // Phase 8 asks — sending `record:{type:"decision"}` and omitting the legacy
207
+ // `kind` — had its decisions compacted at chatter rate and dropped from
208
+ // digests. A protocol whose correct usage loses data.
209
+ //
210
+ // MONOTONE, like the tier floor: `||` can only ever GRANT the long retention,
211
+ // never remove it. So there is no cutover, no migration, and no existing file
212
+ // whose behaviour changes — a legacy `kind:"decision"` keeps exactly the
213
+ // retention it had. Two sources that can each override each other is the
214
+ // disagreement this phase deletes; a source that can only raise is not that.
215
+ //
216
+ // Deliberately structural about `record`: it takes anything with the two
217
+ // fields, so status entries and archive rows can be passed without a cast.
218
+ export function isDecision(e: { kind?: string; record?: { type?: string } } | null | undefined): boolean {
219
+ return e?.kind === "decision" || e?.record?.type === "decision";
220
+ }
221
+
102
222
  export type StatusEntry = {
103
223
  id: string;
104
224
  ts: number;