agent-coord-mcp 0.17.1 → 0.19.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 +8 -1
  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 +393 -40
  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 +393 -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 +54 -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 +413 -40
  51. package/src/tools/work.ts +265 -0
  52. package/src/work.ts +329 -0
@@ -0,0 +1,393 @@
1
+ // Paste + submit, shared by BOTH pushers (hooks/tmux-pusher.mjs and
2
+ // scripts/coord-pusher.mjs). One implementation on purpose: the two carried
3
+ // copies of this pipeline and they had already drifted — the local one sent two
4
+ // Enters with 100ms/50ms delays, the remote one sent a single Enter with no
5
+ // delay at all. Same class of defect as three copies of the retention
6
+ // predicate. Import it; do not re-implement it.
7
+ //
8
+ // tmux is injected (`run`, `runStdin`) so this module is pure logic and can be
9
+ // tested without a terminal. It never imports node:child_process itself.
10
+
11
+ function envInt(name, fallback) {
12
+ const n = Number(process.env[name]);
13
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
14
+ }
15
+
16
+ // WHY THESE NUMBERS.
17
+ //
18
+ // The old defaults (100ms then 50ms) were picked for a multi-line PASTE
19
+ // settling, not for a slash command. A real agent TUI does more on "/" than
20
+ // accept text: it opens an autocomplete menu, which is rendered on a later
21
+ // frame, and the first Enter is consumed selecting from that menu rather than
22
+ // submitting. The command then sits in the input looking delivered — which is
23
+ // exactly what David hit.
24
+ //
25
+ // 400ms is a frame-budget argument, not a measurement of one machine: a TUI
26
+ // that re-renders at 60fps needs a handful of frames to open a menu, and
27
+ // terminal apps commonly debounce input by 100-250ms. 400ms clears that with
28
+ // margin while staying under the ~500ms a human reads as instant. 150ms
29
+ // between Enters is the same reasoning at smaller scale — enough for the menu
30
+ // to close and the input to settle before the submitting Enter.
31
+ //
32
+ // These are DEFAULTS, not guarantees. That is why verification exists below:
33
+ // no delay can be proven sufficient on a machine we have not seen, so the
34
+ // pipeline observes the result instead of trusting the clock.
35
+ export const ENTER_DELAY_MS = () => envInt("AGENT_COORD_ENTER_DELAY_MS", 400);
36
+ export const ENTER_GAP_MS = () => envInt("AGENT_COORD_ENTER_GAP_MS", 150);
37
+ // How long to keep checking that the command actually left the input, and how
38
+ // often. Bounded and finite — this must not spin, and it must not stall the
39
+ // delivery loop behind a pane that will never change.
40
+ export const VERIFY_TIMEOUT_MS = () => envInt("AGENT_COORD_SUBMIT_VERIFY_MS", 1500);
41
+ export const VERIFY_POLL_MS = () => envInt("AGENT_COORD_SUBMIT_POLL_MS", 100);
42
+ // Extra Enters to try when the first pair didn't submit. 0 disables retrying.
43
+ export const ENTER_RETRIES = () => envInt("AGENT_COORD_ENTER_RETRIES", 2);
44
+ // How long to wait for a BUSY pane to go idle before giving up on a control
45
+ // command. 15s covers a short tool call or a model turn's tail; beyond that the
46
+ // honest answer is "not now" rather than queueing a slash command behind a
47
+ // turn that may run for minutes. 0 disables waiting (paste immediately).
48
+ export const CONTROL_IDLE_WAIT_MS = () => envInt("AGENT_COORD_CONTROL_IDLE_WAIT_MS", 15000);
49
+
50
+ // Pane-state patterns. Defaults match the Claude Code TUI, which is what the
51
+ // bus drives; override for another TUI. Empty string disables that check.
52
+ //
53
+ // These exist because control commands fail in ways a delay cannot fix:
54
+ // BUSY — the command lands in the TUI's queue and runs only when the
55
+ // current turn ends (observed: minutes later), while the receipt
56
+ // already said "confirmed".
57
+ // DRAFT — unsent text in the input; the paste APPENDS to it, so
58
+ // "draft I was typing" + "/compact" submits as an ordinary MESSAGE
59
+ // to the model. The command never runs at all and a model turn is
60
+ // burned on nonsense. This is the worst observed outcome.
61
+ export const BUSY_PATTERN = () => envStr("AGENT_COORD_BUSY_PATTERN", "esc to interrupt");
62
+ export const PROMPT_PATTERN = () => envStr("AGENT_COORD_PROMPT_PATTERN", "^\\s*[❯>]\\s?(.*)$");
63
+ // Input-line content that means "empty" — placeholder hints, not real text.
64
+ export const PLACEHOLDER_PATTERN = () =>
65
+ envStr("AGENT_COORD_PLACEHOLDER_PATTERN", '^(Try ".*"|Press up to edit queued messages|)$');
66
+
67
+ // GHOST TEXT — the ONE place the rendering assumption lives.
68
+ //
69
+ // Claude Code renders a session-derived SUGGESTED next prompt ("ghost text")
70
+ // inside the input box while idle: `❯ ` + ESC[2m + suggestion + ESC[0m. It is
71
+ // chrome, not content: nobody typed it, the first keystroke replaces it, and
72
+ // it never reaches scrollback. To a plain `capture-pane` it is byte-for-byte
73
+ // indistinguishable from a real draft — which made this guard refuse control
74
+ // commands against EMPTY inputs fleet-wide (2026-07-29: three live refusals,
75
+ // every quoted "draft" was the suggestion; /clear and /compact were
76
+ // undeliverable to exactly the agents they were built for). The placeholder
77
+ // regex above cannot help: the suggestion is derived from session state, so
78
+ // its space of values is unbounded — no content pattern can enumerate it.
79
+ // Styling is the only channel that carries the distinction, hence
80
+ // `capture-pane -e` below.
81
+ //
82
+ // SGR 2 (dim/faint) is Claude Code's CURRENT rendering of the suggestion — a
83
+ // per-harness rendering detail, not a protocol guarantee. If a harness styles
84
+ // suggestions differently, or a release changes it, override or edit HERE and
85
+ // nowhere else.
86
+ export const GHOST_TEXT_SGR = () => envStr("AGENT_COORD_GHOST_TEXT_SGR", "2");
87
+
88
+ function envStr(name, fallback) {
89
+ const v = process.env[name];
90
+ return v === undefined ? fallback : v;
91
+ }
92
+
93
+
94
+ export function sleep(ms) {
95
+ return new Promise((res) => setTimeout(res, ms));
96
+ }
97
+
98
+ // Normalize for comparison: a TUI re-wraps and re-pads its input box, so raw
99
+ // substring matching on captured pane text is unreliable.
100
+ function squash(s) {
101
+ return String(s ?? "").replace(/\s+/g, " ").trim();
102
+ }
103
+
104
+ // Terminal escape sequences as they appear in `capture-pane -e` output:
105
+ // CSI (colors/attributes, cursor) and OSC (hyperlinks, titles). Anything not
106
+ // matched stays in the text — unrecognized bytes read as content, and content
107
+ // fails toward refusing, never toward delivering.
108
+ const CSI_RE = /\x1b\[[0-9;:?]*[A-Za-z]/g;
109
+ const OSC_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
110
+
111
+ export function stripAnsi(s) {
112
+ return String(s ?? "").replace(OSC_RE, "").replace(CSI_RE, "");
113
+ }
114
+
115
+ // Split ONE styled line into real (typed) and ghost (suggestion) text by
116
+ // walking its SGR state. Dim-attributed spans are ghost; everything else —
117
+ // including any styling we do not recognize — is real. See GHOST_TEXT_SGR for
118
+ // why dim, and for the asymmetry that makes "unrecognized = real" the safe
119
+ // default.
120
+ export function partitionStyledLine(styledLine) {
121
+ const ghostAttr = GHOST_TEXT_SGR();
122
+ const src = String(styledLine ?? "");
123
+ let real = "";
124
+ let ghost = "";
125
+ let dim = false;
126
+ let i = 0;
127
+ while (i < src.length) {
128
+ if (src[i] === "\x1b") {
129
+ const rest = src.slice(i);
130
+ const om = /^\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/.exec(rest);
131
+ if (om) {
132
+ i += om[0].length;
133
+ continue;
134
+ }
135
+ const cm = /^\x1b\[([0-9;:?]*)([A-Za-z])/.exec(rest);
136
+ if (cm) {
137
+ if (cm[2] === "m") {
138
+ // SGR: parameters separated by ; or :. Empty list means reset.
139
+ const params = cm[1] === "" ? ["0"] : cm[1].split(/[;:]/);
140
+ for (const p of params) {
141
+ if (p === "0" || p === "") dim = false; // full reset
142
+ else if (p === "22") dim = false; // "normal intensity" clears dim
143
+ else if (p === ghostAttr) dim = true;
144
+ // 38;5;N color runs share the list; a bare "5"/"2" inside a color
145
+ // spec could false-trigger — handle the extended-color form:
146
+ if (p === "38" || p === "48") break; // rest of list is a color spec
147
+ }
148
+ }
149
+ i += cm[0].length;
150
+ continue;
151
+ }
152
+ // Lone ESC we do not understand: drop the ESC byte, keep going.
153
+ i += 1;
154
+ continue;
155
+ }
156
+ if (dim) ghost += src[i];
157
+ else real += src[i];
158
+ i += 1;
159
+ }
160
+ return { real, ghost };
161
+ }
162
+
163
+ // Is `payload` still sitting in the pane's INPUT LINE?
164
+ //
165
+ // It asks the input line specifically, and nothing else. The first version
166
+ // squashed the last 20 lines of the whole pane and searched that window — but
167
+ // Claude Code ECHOES a submitted command into the transcript directly above
168
+ // the prompt box, inside that window. So a command that ran perfectly still
169
+ // matched, and the verifier reported failure on success (David hit this live:
170
+ // the pane read "Compacting conversation… 28%" while the receipt said the
171
+ // command "did not run").
172
+ //
173
+ // It only ever passed in testing because a freshly spawned disposable session
174
+ // has an empty scrollback — the one condition that hides a scrollback bug. The
175
+ // lesson is narrower than "test on a real TUI": a check that reads scrollback
176
+ // has to be tested against a pane that HAS scrollback.
177
+ //
178
+ // Returns true = still waiting in the input, false = gone (submitted),
179
+ // null = UNKNOWN. Unknown is never upgraded to a confirmation: no pane text,
180
+ // or no line matching AGENT_COORD_PROMPT_PATTERN, means we cannot tell.
181
+ export function stillInInput(paneText, payload) {
182
+ if (paneText === null || paneText === undefined) return null;
183
+ const needle = squash(payload);
184
+ if (!needle) return false;
185
+ const { draft } = readPaneState(paneText);
186
+ if (draft === null) return null; // no recognizable input line — cannot tell
187
+ return squash(draft).includes(needle);
188
+ }
189
+
190
+ // Read the pane's input state: is the TUI busy, and is there unsent text?
191
+ //
192
+ // Accepts BOTH plain and styled (`capture-pane -e`) text — on styled input the
193
+ // ghost suggestion is partitioned out of `draft` (see GHOST_TEXT_SGR); plain
194
+ // text has no style layer, so everything on the input line reads as content,
195
+ // which is the conservative side. `ghost` and `styledInputLine` are null on
196
+ // plain input.
197
+ //
198
+ // `null` for busy/draft means UNKNOWN — the pane could not be captured, or
199
+ // this TUI does not look like the one we know how to read. Unknown is never
200
+ // treated as "fine": callers either proceed and fall back to verify-by-absence
201
+ // or report the uncertainty, but they never upgrade it to a confirmation.
202
+ export function readPaneState(paneText) {
203
+ if (paneText === null || paneText === undefined) {
204
+ return { busy: null, draft: null, ghost: null, styledInputLine: null };
205
+ }
206
+ const text = String(paneText);
207
+ const busyRe = BUSY_PATTERN();
208
+ // Busy markers live in the status chrome; match on the de-styled text.
209
+ const busy = busyRe ? new RegExp(busyRe).test(stripAnsi(text)) : null;
210
+
211
+ const promptRe = PROMPT_PATTERN();
212
+ let draft = null;
213
+ let ghost = null;
214
+ let styledInputLine = null;
215
+ if (promptRe) {
216
+ const re = new RegExp(promptRe);
217
+ const lines = text.split("\n");
218
+ // The LAST prompt line is the live input; earlier ones are history.
219
+ for (let i = lines.length - 1; i >= 0; i--) {
220
+ // Partition FIRST: the prompt char renders undimmed, so it stays in
221
+ // `real` and the prompt regex matches the de-styled real text. Ghost
222
+ // spans never reach the draft no matter what they contain.
223
+ const { real, ghost: ghostText } = partitionStyledLine(lines[i]);
224
+ const m = re.exec(real.replace(/\s+$/, ""));
225
+ if (!m) continue;
226
+ const content = (m[1] ?? "").trim();
227
+ const placeholder = PLACEHOLDER_PATTERN();
228
+ draft = placeholder && new RegExp(placeholder).test(content) ? "" : content;
229
+ ghost = squash(ghostText) || null;
230
+ styledInputLine = lines[i];
231
+ break;
232
+ }
233
+ }
234
+ return { busy, draft, ghost, styledInputLine };
235
+ }
236
+
237
+ // Submit a CONTROL command (/clear, /compact) — the path that must actually
238
+ // run, not merely arrive.
239
+ //
240
+ // Refuses rather than corrupts. Pasting into a busy pane queues the command
241
+ // behind a whole model turn; pasting onto a draft concatenates with it and
242
+ // sends the result to the model as chat text. Both were previously reported as
243
+ // delivery:"confirmed". Waiting briefly and then declining is slower and
244
+ // louder — deliberately, because a control command that silently becomes a
245
+ // chat message is worse than one that says it did not run.
246
+ // The ONE place a pane is captured for guard/verify decisions. `-e` is
247
+ // load-bearing: it keeps the style layer, the only channel that distinguishes
248
+ // a real draft from the TUI's ghost suggestion (see GHOST_TEXT_SGR). Without
249
+ // it every ghost reads as a draft again — and the SUITE CANNOT NOTICE,
250
+ // because the parser tests inject styled text below this call. That is the
251
+ // scriptMtime family of failure: the subject of a check silently disabling
252
+ // it. A source lock in control-submit.test.mjs pins the flag here and counts
253
+ // capture sites, so a new capture call added without `-e` trips it too.
254
+ function captureStyled(run, target) {
255
+ const cap = run(["capture-pane", "-e", "-p", "-t", target]);
256
+ return cap.status === 0 ? String(cap.stdout ?? "") : null;
257
+ }
258
+
259
+ export async function submitControl(deps, payload) {
260
+ const { run, target } = deps;
261
+ const capture = () => captureStyled(run, target);
262
+
263
+ const idleBudget = CONTROL_IDLE_WAIT_MS();
264
+ const deadline = Date.now() + idleBudget;
265
+ let state = readPaneState(capture());
266
+ while (state.busy === true && idleBudget > 0 && Date.now() < deadline) {
267
+ await sleep(VERIFY_POLL_MS() * 5);
268
+ state = readPaneState(capture());
269
+ }
270
+ if (state.busy === true) {
271
+ return {
272
+ submitted: false,
273
+ verified: true,
274
+ pasted: false,
275
+ attempts: 0,
276
+ reason:
277
+ `pane '${target}' is still busy after ${idleBudget}ms — not pasted. A control command sent now would ` +
278
+ `queue behind the running turn instead of executing (raise AGENT_COORD_CONTROL_IDLE_WAIT_MS to wait longer).`,
279
+ };
280
+ }
281
+ if (state.draft) {
282
+ // ASYMMETRY — do not "fix" this branch toward delivering. A false REFUSAL
283
+ // costs one control command: retryable, visible, and self-diagnosing via
284
+ // the styled quote below. A false DELIVERY types keystrokes into someone's
285
+ // real unsent text and submits the concatenation to their model — the
286
+ // exact harm this guard exists to prevent. A harness whose ghost text is
287
+ // NOT dim-styled therefore lands here and refuses; that is the intended
288
+ // conservative direction, and the remedy is teaching GHOST_TEXT_SGR its
289
+ // rendering, never loosening this check.
290
+ return {
291
+ submitted: false,
292
+ verified: true,
293
+ pasted: false,
294
+ attempts: 0,
295
+ reason:
296
+ `pane '${target}' has unsent text in its input (${JSON.stringify(state.draft.slice(0, 40))}…) — not pasted. ` +
297
+ `The command would have been appended to that draft and sent to the model as an ordinary message. ` +
298
+ `Rule: non-dim input-line content is a draft; dim (SGR ${GHOST_TEXT_SGR()}) spans are the TUI's ghost ` +
299
+ `suggestion and are ignored (AGENT_COORD_GHOST_TEXT_SGR overrides). ` +
300
+ `Styled input line: ${JSON.stringify(String(state.styledInputLine ?? "").slice(0, 160))}`,
301
+ };
302
+ }
303
+ return pasteAndSubmit(deps, payload, { bracketed: false, verify: true });
304
+ }
305
+
306
+ // Paste a payload into the target pane and submit it.
307
+ //
308
+ // deps:
309
+ // runStdin(args, payload) -> Promise<void> // tmux load-buffer -
310
+ // run(args) -> {status, stdout, stderr} // spawnSync-shaped
311
+ // target, buffer // tmux -t target, buffer name
312
+ //
313
+ // Returns {submitted, verified, reason?, attempts}. For bracketed peer content
314
+ // verification is skipped entirely (`verified:false`, `submitted:true`) — that
315
+ // path pastes inert data on the hot path and has never been the problem; only
316
+ // control commands are verified.
317
+ export async function pasteAndSubmit(deps, payload, { bracketed = false, verify = false } = {}) {
318
+ const { run, runStdin, target, buffer } = deps;
319
+
320
+ await runStdin(["load-buffer", "-b", buffer, "-"], payload);
321
+ const paste = run(["paste-buffer", ...(bracketed ? ["-p"] : []), "-b", buffer, "-t", target, "-d"]);
322
+ if (paste.status !== 0) {
323
+ throw new Error(`tmux paste-buffer: ${String(paste.stderr ?? "").trim()}`);
324
+ }
325
+
326
+ await sleep(ENTER_DELAY_MS());
327
+ const e1 = run(["send-keys", "-t", target, "Enter"]);
328
+ if (e1.status !== 0) throw new Error(`tmux send-keys: ${String(e1.stderr ?? "").trim()}`);
329
+ await sleep(ENTER_GAP_MS());
330
+ run(["send-keys", "-t", target, "Enter"]);
331
+
332
+ if (!verify) return { submitted: true, verified: false, attempts: 1 };
333
+
334
+ const retries = ENTER_RETRIES();
335
+ for (let attempt = 1; ; attempt++) {
336
+ const outcome = await pollUntilGone(deps, payload);
337
+ if (outcome === false) return { submitted: true, verified: true, attempts: attempt };
338
+ if (outcome === null) {
339
+ return {
340
+ submitted: false,
341
+ verified: false,
342
+ attempts: attempt,
343
+ reason:
344
+ `could not read the input line of pane '${target}' to verify submission — the command was typed but may not have run ` +
345
+ `(pane capture failed, or no line matched AGENT_COORD_PROMPT_PATTERN for this TUI)`,
346
+ };
347
+ }
348
+ if (attempt > retries) {
349
+ return {
350
+ submitted: false,
351
+ verified: true, // we DID observe the pane; what we observed is failure
352
+ attempts: attempt,
353
+ reason:
354
+ `the command is still in the input of pane '${target}' after ${attempt} submit attempt(s) — ` +
355
+ `it was typed but did not run (raise AGENT_COORD_ENTER_DELAY_MS if this recurs)`,
356
+ };
357
+ }
358
+ // Still sitting there: one more Enter before giving up.
359
+ //
360
+ // Every retry re-verifies FIRST (the loop head above), so an Enter is only
361
+ // ever sent after we have just looked at the input line and seen the
362
+ // command still in it. That ordering is the whole safety argument: these
363
+ // are keystrokes into a live session someone else may be typing in, and an
364
+ // Enter sent on a stale reading would submit whatever they had drafted
365
+ // since. Under the old tail-window check this fired on EVERY successful
366
+ // submit — three Enters into a pane that had already run the command.
367
+ await sleep(ENTER_GAP_MS());
368
+ run(["send-keys", "-t", target, "Enter"]);
369
+ }
370
+ }
371
+
372
+ // Poll capture-pane until the payload leaves the input line, the budget runs
373
+ // out, or we run out of ways to tell. true = still there, false = gone,
374
+ // null = unknown (capture failed, or no input line we can read).
375
+ async function pollUntilGone(deps, payload) {
376
+ const { run, target } = deps;
377
+ const deadline = Date.now() + VERIFY_TIMEOUT_MS();
378
+ let readInput = false;
379
+ for (;;) {
380
+ // Styled capture here too: a ghost suggestion CONTAINING the payload
381
+ // (e.g. the TUI suggesting "/compact" right after it ran) would otherwise
382
+ // read as "still in the input" — a false non-submit whose retry path
383
+ // sends extra Enters into a live pane on the strength of chrome.
384
+ const pane = captureStyled(run, target);
385
+ if (pane !== null) {
386
+ const verdict = stillInInput(pane, payload);
387
+ if (verdict === false) return false;
388
+ if (verdict === true) readInput = true; // we could read the input line
389
+ }
390
+ if (Date.now() >= deadline) return readInput ? true : null;
391
+ await sleep(VERIFY_POLL_MS());
392
+ }
393
+ }
package/hooks/tier.mjs CHANGED
@@ -1,6 +1,6 @@
1
- // Pure delivery-tier logic for the pusher. Dependency-free and
2
- // side-effect-free so it can be unit-tested directly and adds no I/O to the
3
- // delivery hot path.
1
+ // Pure delivery-tier logic for the pusher. Side-effect-free, and its only
2
+ // import (./roles.mjs) is equally pure — so it can be unit-tested directly and
3
+ // adds no I/O to the delivery hot path.
4
4
  //
5
5
  // "urgent" = push now (wakes the target agent's model).
6
6
  // "routine" = queue silently; it rides along as a coalesced digest on the
@@ -9,6 +9,41 @@
9
9
  // All prefix checks are case-sensitive: the protocol prefixes are uppercase
10
10
  // by convention, and a lowercase lookalike is chatter, not a work order.
11
11
 
12
+ import { isGateRunner } from "./roles.mjs";
13
+
14
+ // Typed protocol record → tier (Phase 8). The prefix table below is the same
15
+ // vocabulary parsed out of text; reading the field instead removes the parse
16
+ // entirely, so a body that leads with a greeting no longer downgrades a
17
+ // blocker to routine.
18
+ //
19
+ // TRUST: `record` is caller-supplied, exactly like `from`. A typed record may
20
+ // therefore assert what a text prefix could already assert, and NOTHING more —
21
+ // `scope` resolves the sender against trustedSenders and `done` depends on the
22
+ // RECIPIENT being a gate runner, exactly as the prefix path does. `go` is
23
+ // unconditionally urgent here because literal "GO:" is unconditionally urgent
24
+ // in the prefix path too; do NOT "fix" that asymmetry by gating go, and do not
25
+ // ungate scope to match go — each mirrors its v1 prefix, which is the whole
26
+ // invariant. Typed is not authenticated: anything that would let a peer
27
+ // self-declare urgency belongs in the server-set `urgent` flag, not here.
28
+ function tierFromRecord(rec, m, opts) {
29
+ switch (rec.type) {
30
+ case "blocker":
31
+ case "decision":
32
+ return "urgent";
33
+ case "go":
34
+ return "urgent";
35
+ case "scope":
36
+ return opts.trustedSenders?.has?.(m.from) ? "urgent" : "routine";
37
+ case "done":
38
+ return opts.gateRunner ? "urgent" : "routine";
39
+ // risk / fyi / action / verdict: real semantics, but not a reason to
40
+ // interrupt someone else's turn. A risk that genuinely needs an immediate
41
+ // turn is a blocker — that judgement stays with the sender.
42
+ default:
43
+ return "routine";
44
+ }
45
+ }
46
+
12
47
  export function classifyTier(m, opts = {}) {
13
48
  if (!m) return "routine";
14
49
  // Server-set push-now override (post-/clear reminder). send_message builds
@@ -18,7 +53,39 @@ export function classifyTier(m, opts = {}) {
18
53
  // wants it specifically, and DM volume is tiny next to room traffic. The
19
54
  // tiers exist to absorb broadcast noise, not point-to-point asks (the
20
55
  // liaison relaying a David question must not sit in a digest queue).
21
- if (m.kind === "DM") return "urgent";
56
+ //
57
+ // `tag`, not `kind`: this is the pushers' synthetic channel tag ("DM" /
58
+ // "room #general"), set on their own copy of the message. It used to be
59
+ // called `kind`, which collided with the stored Message.kind (retention
60
+ // weight, "decision"/"status"/"chatter") — a room post tagged
61
+ // kind:"decision" overwrote the channel tag and rendered as `[decision …]`.
62
+ // The tag is process-local and never persisted, so it was the safe half of
63
+ // the collision to rename; Message.kind is on disk in every JSONL file.
64
+ // NOTE: this returns before the record is read, so a DM can never be
65
+ // downgraded by one. The floor rule below makes that moot, but the ordering
66
+ // is load-bearing if anyone reintroduces a record-first branch.
67
+ if (m.tag === "DM") return "urgent";
68
+ // The record is a FLOOR, not an override: it can raise the tier, never lower
69
+ // it. An earlier version let the record win outright, on the argument that
70
+ // Task 3 renders text from the record so the two agree by construction. That
71
+ // is false when BOTH are supplied — contract 3.3 makes the author's `text`
72
+ // win for rendering, so `{text:"BLOCKER: prod down", record:{type:"fyi"}}`
73
+ // displayed a BLOCKER in the pane and delivered it routine, with the reader
74
+ // unable to see why (`record` is never rendered). That is a REGRESSION IN A
75
+ // SAFETY PROPERTY: in v1, "BLOCKER:" at byte 0 always woke the pane. The
76
+ // trigger is ordinary relay, not malice — an aide forwarding a worker's body
77
+ // under its own `fyi` would silently bury that worker's blocker.
78
+ //
79
+ // max() is not the two-overridable-sources design this phase exists to
80
+ // delete: it is monotone, so there is nothing to disagree about, only a
81
+ // higher claim winning. It also fails safe on an UNKNOWN future record type,
82
+ // which hits the routine default — an old pusher meeting a new vocabulary
83
+ // must not bury a "BLOCKER:" body. The one legitimate downgrade, quoting a
84
+ // blocker mid-body, is already handled by the byte-0 rule.
85
+ // Found by ai-workflow-worker-1 gating 721882a.
86
+ if (m.record && typeof m.record.type === "string") {
87
+ if (tierFromRecord(m.record, m, opts) === "urgent") return "urgent";
88
+ }
22
89
  if (typeof m.text !== "string") return "routine";
23
90
  const text = m.text.trimStart();
24
91
  // Control/slash commands are injected raw and must fire immediately.
@@ -46,10 +113,16 @@ export function effectiveTier(m, opts = {}) {
46
113
  }
47
114
 
48
115
  // A gate runner is the agent that consumes DONE: reports (QA / coordinator).
49
- // Resolved from the registry role, with an env override in both directions
50
- // (AGENT_COORD_GATE_RUNNER=1|0).
116
+ // Resolved from the registry role's frozen `roleId` (Phase 8 Task 4) rather
117
+ // than by regex-matching display prose, so renaming the role does not change
118
+ // who runs the gate. Registry entries with no declared roleId fall back to the
119
+ // legacy word match — see roleMatches in roles.mjs. Env override in both
120
+ // directions (AGENT_COORD_GATE_RUNNER=1|0) is applied by the caller.
121
+ //
122
+ // Accepts a plain string or a whole registry entry ({role, roleId}); pass the
123
+ // entry when you have it, or the id is lost.
51
124
  export function isGateRunnerRole(role) {
52
- return /\b(qa|quality|coordinator|gate)\b/i.test(role ?? "");
125
+ return isGateRunner(role);
53
126
  }
54
127
 
55
128
  // In-memory routine queue with the push decision. Ingest classified messages;
@@ -96,15 +169,35 @@ export class TierQueue {
96
169
  // The per-message PARSE CONTRACT line. Agent harnesses read from/room/text
97
170
  // back out of this, so its shape is load-bearing and MUST stay byte-identical
98
171
  // to coord-pusher.mjs's `injectLine`. Compact form (v0.14.0, salvaged from
99
- // v0.8.10): ` [<kind> <HH:MM> <from>] <text>` where kind drops the leading
172
+ // v0.8.10): ` [<tag> <HH:MM> <from>] <text>` where tag drops the leading
100
173
  // "room " ("room #general" → "#general"), the timestamp is HH:MM UTC, and the
101
- // "from=" label is dropped (bare id). kind/time/from never contain spaces
174
+ // "from=" label is dropped (bare id). tag/time/from never contain spaces
102
175
  // (ids are sanitized), so a parser splits on the first "] " unambiguously.
103
176
  export function injectLine(m) {
104
- const tag = String(m.kind ?? "").replace(/^room /, "");
177
+ const tag = String(m.tag ?? "").replace(/^room /, "");
105
178
  const d = new Date(m.ts ?? 0);
106
179
  const hhmm = `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
107
- return ` [${tag} ${hhmm} ${m.from}] ${m.text ?? ""}`;
180
+ let text = m.text ?? "";
181
+ // Phase 8 Task 6: a TYPED record whose rendering spans lines is delivered as
182
+ // ONE attributed line — first line, a count of what was withheld, and the
183
+ // message id as the retrieval handle. Continuation lines used to arrive bare,
184
+ // with no `[tag HH:MM from]` header, so a parser could not attribute them.
185
+ //
186
+ // The handle is the message id, NOT a stashed copy: the full record is
187
+ // already persisted in rooms/<chan>.jsonl or inbox/<id>.jsonl, and
188
+ // retrieve_message reads it back by id (falling through to the append-only
189
+ // archive if compaction moved it). A cache would have had a TTL and lost the
190
+ // record permanently on expiry.
191
+ //
192
+ // Gated on `m.record`: a record-LESS multi-line message is untouched and
193
+ // still arrives unattributed past line 1, exactly as today. Task 6 does not
194
+ // fix hand-typed multi-line messages, and must not change their bytes.
195
+ const nl = text.indexOf("\n");
196
+ if (nl !== -1 && m.record && typeof m.record.type === "string" && m.id) {
197
+ const held = text.split("\n").length - 1;
198
+ text = `${text.slice(0, nl)} [+${held} lines · record:${m.record.type} · retrieve_message id=${m.id}]`;
199
+ }
200
+ return ` [${tag} ${hhmm} ${m.from}] ${text}`;
108
201
  }
109
202
 
110
203
  // Render one delivery: urgent verbatim under the banner, then at most ONE