agent-coord-mcp 0.26.11 → 0.26.13
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/server.js +87 -3
- package/dist/server.js.map +1 -1
- package/dist/tools/away.js +180 -59
- package/dist/tools/away.js.map +1 -1
- package/dist/tools/event-kinds.js +39 -0
- package/dist/tools/event-kinds.js.map +1 -0
- 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/record-events.js +375 -0
- package/dist/tools/record-events.js.map +1 -0
- package/dist/tools/registry.js +10 -1
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/stall.js +100 -8
- package/dist/tools/stall.js.map +1 -1
- package/dist/tools/transport.js +19 -1
- package/dist/tools/transport.js.map +1 -1
- package/package.json +3 -3
- package/scripts/check-test-count.mjs +1 -1
- package/src/server.ts +97 -4
- package/src/tools/away.ts +221 -55
- package/src/tools/event-kinds.ts +60 -0
- package/src/tools/events.ts +8 -4
- package/src/tools/index.ts +2 -0
- package/src/tools/record-events.ts +390 -0
- package/src/tools/registry.ts +10 -1
- package/src/tools/stall.ts +104 -11
- package/src/tools/transport.ts +19 -1
package/src/server.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { z, type ZodRawShape } from "zod";
|
|
|
9
9
|
import { coordAwaySchema, coordAwayTool, readAway, awayRefusal, secondCoordinatorRefusal } from "./tools/away.js";
|
|
10
10
|
import { rotateSchema, rotateTool, rotateReconcileSchema, rotateReconcileTool } from "./tools/rotate.js";
|
|
11
11
|
import { subscribeSchema, subscribeTool, unsubscribeSchema, unsubscribeTool, listSubscriptionsSchema, listSubscriptionsTool } from "./tools/events.js";
|
|
12
|
+
import { scanRecordEventsSchema, scanRecordEventsTool } from "./tools/record-events.js";
|
|
12
13
|
import {
|
|
13
14
|
ensureDirs,
|
|
14
15
|
getTokenMap,
|
|
@@ -345,7 +346,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
345
346
|
|
|
346
347
|
addTool(
|
|
347
348
|
"ping",
|
|
348
|
-
"Liveness probe for another agent, answered entirely from server-side state (registry entry, transport marker, pusher pid, tmux pane) — it never touches the target's session, so a fleet-wide sweep costs zero model tokens on the targets. Returns alive (fresh heartbeat
|
|
349
|
+
"Liveness probe for another agent, answered entirely from server-side state (registry entry, transport marker, pusher pid, tmux pane) — it never touches the target's session, so a fleet-wide sweep costs zero model tokens on the targets. Returns alive (live transport, or a fresh heartbeat ONLY where something writes one — never a local tmux-push transport's, whose heartbeat measures time since it joined rather than activity), reachable (a DM pushed now would land), granular checks including heartbeatValid (whether heartbeatFresh next to it is real evidence for this transport), and latencyMs. Distinct from heartbeat, which is an agent refreshing its OWN activity timestamp. Pass echo:true (default off) to additionally drop a PING DM into the target's inbox — that wakes the target's model, so use it sparingly and only when you need an agent-level acknowledgement. 'from' is enforced against the session's bound identity, but read-only — naming 'from' here never binds this session's identity.",
|
|
349
350
|
pingSchema,
|
|
350
351
|
gate("from", pingTool as (a: Record<string, unknown>) => Promise<unknown>, { bindOnClaim: false }),
|
|
351
352
|
);
|
|
@@ -608,9 +609,9 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
608
609
|
|
|
609
610
|
addTool(
|
|
610
611
|
"coord_away",
|
|
611
|
-
"Declare
|
|
612
|
+
"Declare that DAVID IS AWAY and the aide LEADS, or RELEASE it. (Redefined 2026-08-30; the old duty-officer meaning is RETIRED, not deprecated \u2014 one label, one meaning.) The lead DECIDES planning, priority, queue curation, roadmap, canon and releases under standing authorisation; NEVER merges, gates, or takes a code lane (enforced: `merge` and `land` are refused with their reason); and PARKS licence, funding, public-vs-private, credentials, anything altering scope or authority, and destructive machine actions \u2014 those are not the aide's to decide and an absence is not a transfer of that authority. Requires a `leadId` (an unnamed lead reports the fleet as led while leaving it unled) and a `decisionLog` path, so David returns to a diff rather than a transcript. THE PRECONDITION IS MEASURED COVERAGE, NOT A CLOCK RUN: it reads `stall_check`'s own unmeasurable list \u2014 never `stall_clock_status`'s green, which cannot express its own blindness \u2014 and REFUSES when the clock would run and see nothing, unless `acknowledgeBlindFleet:true` records that the promise is being made anyway.",
|
|
612
613
|
coordAwaySchema,
|
|
613
|
-
gate("
|
|
614
|
+
gate("agentId", coordAwayTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
614
615
|
);
|
|
615
616
|
|
|
616
617
|
addTool(
|
|
@@ -629,11 +630,18 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
629
630
|
|
|
630
631
|
addTool(
|
|
631
632
|
"subscribe",
|
|
632
|
-
"Register for a record event: a
|
|
633
|
+
"Register for a record event: `task` (a phase checkbox newly ticked), `phase` (the last open box in a phase ticked), `item` (a queue item closed), or `pr` (a PR recorded in DONE.md). Events are DERIVED from the record and refused if the ref is not in it, so the stream can never claim something the authoritative markdown does not \u2014 but the TRIGGER is the record's COMMITTED CHANGE (`scan_record_events`), not any one verb, so a hand-edited DONE.md fires exactly like `land` does. Every offered kind has an emitter: the wire enum is generated from the emitter registry, so an unsatisfiable subscription cannot be registered. Re-subscribing returns the existing registration rather than a duplicate.",
|
|
633
634
|
subscribeSchema,
|
|
634
635
|
gate("agentId", subscribeTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
635
636
|
);
|
|
636
637
|
|
|
638
|
+
addTool(
|
|
639
|
+
"scan_record_events",
|
|
640
|
+
"Turn a repo's COMMITTED record changes into events: new `docs/DONE.md` entries, queue items that left `docs/QUEUE.md`, and newly-ticked phase checkboxes, between the stored watermark and HEAD. The commit is the boundary \u2014 an uncommitted edit is not yet a record. A HAND-EDIT fires exactly like `land` does, which is the point: fleets merge with `gh` and edit DONE.md directly. Reports by default; `write:true` delivers and advances the watermark. Re-scanning a delivered range is safe (the idempotency key comes from the event, so it reports duplicate-suppressed), and a watermark that no longer resolves REFUSES rather than silently narrowing its window.",
|
|
641
|
+
scanRecordEventsSchema,
|
|
642
|
+
gate(null, scanRecordEventsTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
643
|
+
);
|
|
644
|
+
|
|
637
645
|
addTool(
|
|
638
646
|
"unsubscribe",
|
|
639
647
|
"Remove one of YOUR subscriptions. Refuses another agent's: silently dropping someone else's notification is how a miss is manufactured.",
|
|
@@ -723,6 +731,24 @@ async function main() {
|
|
|
723
731
|
}
|
|
724
732
|
const server = buildServer(boundAgent, { trackSession: true });
|
|
725
733
|
const transport = new StdioServerTransport();
|
|
734
|
+
// StdioServerTransport only wires `data`/`error` on stdin — it never
|
|
735
|
+
// observes EOF. A client that ends the pipe (Claude Code's `/mcp`
|
|
736
|
+
// reconnect calls `stdin.end()` on the child) leaves this process with
|
|
737
|
+
// nothing listening for it, so it idles forever: measured as 25 live
|
|
738
|
+
// servers against 15 registered agents, one pane holding 3 generations.
|
|
739
|
+
// React to the session ending from EITHER direction — the read side
|
|
740
|
+
// closing (client done talking to us) or the write side breaking
|
|
741
|
+
// (client stopped listening, so a reply would EPIPE) — rather than
|
|
742
|
+
// waiting on the SDK client's own SIGTERM escalation to reach us.
|
|
743
|
+
const exitOnSessionEnd = (reason: string) => {
|
|
744
|
+
console.error(`[agent-coord-mcp] stdio session ended (${reason}) — exiting`);
|
|
745
|
+
process.exit(0);
|
|
746
|
+
};
|
|
747
|
+
process.stdin.on("end", () => exitOnSessionEnd("stdin end"));
|
|
748
|
+
process.stdin.on("close", () => exitOnSessionEnd("stdin close"));
|
|
749
|
+
process.stdout.on("error", (err: NodeJS.ErrnoException) => {
|
|
750
|
+
if (err.code === "EPIPE") exitOnSessionEnd("stdout EPIPE");
|
|
751
|
+
});
|
|
726
752
|
await server.connect(transport);
|
|
727
753
|
}
|
|
728
754
|
}
|
|
@@ -791,6 +817,58 @@ async function startHttp(port: number): Promise<void> {
|
|
|
791
817
|
// by a *different* bearer that merely presents its id (session hijack).
|
|
792
818
|
const sessionAgents = new Map<string, string | undefined>();
|
|
793
819
|
|
|
820
|
+
// Session-orphan reap (docs/DECISIONS.md ADR-015 gate #2, QUEUE.md:175 part
|
|
821
|
+
// 2). Design history, kept because the rejected shape is as load-bearing as
|
|
822
|
+
// the shipped one:
|
|
823
|
+
//
|
|
824
|
+
// `transport.onclose` — the SDK's only cleanup hook — fires exclusively from
|
|
825
|
+
// an explicit DELETE (webStandardStreamableHttp.js, "Handles DELETE requests
|
|
826
|
+
// to terminate sessions"). Measured: `Client.close()` never sends one —
|
|
827
|
+
// `StreamableHTTPClientTransport.close()` only aborts locally, and the
|
|
828
|
+
// separate `terminateSession()` method that DOES send DELETE is never
|
|
829
|
+
// called anywhere in the generic client path (shared/protocol.js). So a
|
|
830
|
+
// client that reconnects the ordinary way (close + new client, the same
|
|
831
|
+
// shape as the stdio leak fixed one level up) orphans its session here
|
|
832
|
+
// forever — confirmed live: a session answered 200 after `client.close()`.
|
|
833
|
+
//
|
|
834
|
+
// FIRST DESIGN WAS IDLE-TIMEOUT, AND IT WAS WRONG — kept as the cautionary
|
|
835
|
+
// case, not deleted: the stdio sibling of this fix (#169) shipped the same
|
|
836
|
+
// shape and was HELD after a live test proved the failure mode. Killing a
|
|
837
|
+
// session's process out from under a still-attached client produces
|
|
838
|
+
// SILENT, NON-RECOVERABLE loss — no transparent respawn, confirmed by
|
|
839
|
+
// killing this repo's own coord-mcp subprocess and watching every
|
|
840
|
+
// `mcp__agent-coord__*` tool vanish with no automatic recovery. Idle is not
|
|
841
|
+
// abandoned: an agent can sit quiet for 128 minutes while working normally.
|
|
842
|
+
// A timer cannot tell those apart; it was never going to be safe as the
|
|
843
|
+
// sole signal, on the daemon or on stdio.
|
|
844
|
+
//
|
|
845
|
+
// WHAT ACTUALLY DISTINGUISHES ABANDONED FROM QUIET: whether a NEWER session
|
|
846
|
+
// for the same bound agent already exists. If it does, the OLD session's
|
|
847
|
+
// own client already reconnected — the exact shape measured live (a fresh
|
|
848
|
+
// `client.connect()` after `client.close()`) — and closing the superseded
|
|
849
|
+
// session costs that client nothing, because it already moved on. This
|
|
850
|
+
// needs no timer and no guess: `agentSessions` tracks the current session
|
|
851
|
+
// per bound agent, and a session that stops being current is provably
|
|
852
|
+
// orphaned the instant it is superseded.
|
|
853
|
+
//
|
|
854
|
+
// Unbound (TOFU/advisory) sessions have no agent identity to key
|
|
855
|
+
// supersession on, so they get none of this — deliberately: a mechanism
|
|
856
|
+
// that cannot tell "superseded" from "just another session" must not guess.
|
|
857
|
+
const agentSessions = new Map<string, string>();
|
|
858
|
+
|
|
859
|
+
async function reapSuperseded(agentId: string, supersededId: string): Promise<void> {
|
|
860
|
+
const transport = sessions.get(supersededId);
|
|
861
|
+
if (!transport) return; // already gone
|
|
862
|
+
console.error(
|
|
863
|
+
`[agent-coord-mcp] reaping session ${supersededId} for agent '${agentId}' — superseded by a newer session`,
|
|
864
|
+
);
|
|
865
|
+
try {
|
|
866
|
+
await transport.close();
|
|
867
|
+
} catch (err) {
|
|
868
|
+
console.error(`[agent-coord-mcp] reap FAILED for session ${supersededId}:`, err);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
794
872
|
async function makeSessionTransport(boundAgent?: string): Promise<StreamableHTTPServerTransport> {
|
|
795
873
|
// `let` + explicit type lets the SDK callbacks close over the binding
|
|
796
874
|
// before it's assigned — they only fire after construction completes.
|
|
@@ -800,12 +878,27 @@ async function startHttp(port: number): Promise<void> {
|
|
|
800
878
|
onsessioninitialized: (id: string) => {
|
|
801
879
|
sessions.set(id, transport);
|
|
802
880
|
sessionAgents.set(id, boundAgent);
|
|
881
|
+
if (boundAgent) {
|
|
882
|
+
const superseded = agentSessions.get(boundAgent);
|
|
883
|
+
agentSessions.set(boundAgent, id);
|
|
884
|
+
if (superseded && superseded !== id) {
|
|
885
|
+
reapSuperseded(boundAgent, superseded).catch((err) =>
|
|
886
|
+
console.error("[agent-coord-mcp] supersession reap threw:", err),
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
803
890
|
},
|
|
804
891
|
});
|
|
805
892
|
transport.onclose = () => {
|
|
806
893
|
if (transport.sessionId) {
|
|
807
894
|
sessions.delete(transport.sessionId);
|
|
808
895
|
sessionAgents.delete(transport.sessionId);
|
|
896
|
+
// Only clear the agent's CURRENT pointer if it still points here — a
|
|
897
|
+
// superseded session closing after its successor already claimed the
|
|
898
|
+
// slot must not erase the successor's own entry.
|
|
899
|
+
if (boundAgent && agentSessions.get(boundAgent) === transport.sessionId) {
|
|
900
|
+
agentSessions.delete(boundAgent);
|
|
901
|
+
}
|
|
809
902
|
}
|
|
810
903
|
};
|
|
811
904
|
const server = buildServer(boundAgent);
|
package/src/tools/away.ts
CHANGED
|
@@ -1,41 +1,97 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* `coord_away` —
|
|
2
|
+
* `coord_away` — DAVID IS AWAY AND THE AIDE LEADS.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* writing, that a duty officer may call it.
|
|
4
|
+
* REDEFINED (David, 2026-08-30). The verb used to mean "the coordinator stepped
|
|
5
|
+
* out and a duty officer keeps its seat warm". That meaning is RETIRED, not
|
|
6
|
+
* deprecated: same skill label, one meaning. A verb with two meanings is read
|
|
7
|
+
* as whichever one the reader already had in mind, and the two differ on who
|
|
8
|
+
* holds authority — the most expensive thing to be ambiguous about.
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* WHAT IT NOW MEANS: David is away, and the aide LEADS in his absence. That is
|
|
11
|
+
* a grant of DECISION authority, not of execution authority, and the boundary
|
|
12
|
+
* is the feature:
|
|
14
13
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
14
|
+
* DECIDES planning · priority · queue curation · roadmap · canon · releases
|
|
15
|
+
* under standing publish authorisation.
|
|
16
|
+
* NEVER merges, gates, or takes a code lane. Unchanged by the grant — a
|
|
17
|
+
* lead is not a second worker, and the aide's nevers are what keep
|
|
18
|
+
* the gate non-author.
|
|
19
|
+
* PARKS licence · funding · public-vs-private · credentials · anything
|
|
20
|
+
* altering scope or authority · destructive machine actions.
|
|
21
|
+
*
|
|
22
|
+
* WHY PARK RATHER THAN REFUSE-AND-FORGET: a parked decision is one David
|
|
23
|
+
* returns to. The whole point of leading in an absence is that the absence ends,
|
|
24
|
+
* and the fleet should hand back a DIFF rather than a transcript — which is why
|
|
25
|
+
* `decisionLog` is required to turn this on rather than being a nice-to-have.
|
|
26
|
+
*
|
|
27
|
+
* THE PRECONDITION IS MEASURED COVERAGE, NOT A RUN. Going away is a promise
|
|
28
|
+
* that the fleet is observed while nobody is watching, and the fleet was
|
|
29
|
+
* measured making that promise with a stall clock that had been stopped for
|
|
30
|
+
* eight hours — and then, once started, reporting `checked 3 · measurable 0`.
|
|
31
|
+
* "The clock ran" and "the fleet is observed" are different facts. So the
|
|
32
|
+
* precondition asks what the clock could actually SEE, and going away blind is
|
|
33
|
+
* a deliberate, recorded act rather than a silent default.
|
|
19
34
|
*/
|
|
20
35
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
21
36
|
import path from "node:path";
|
|
22
37
|
import { z } from "zod";
|
|
23
38
|
import { ROOT } from "../store.js";
|
|
39
|
+
import { stallCheckTool } from "./stall.js";
|
|
24
40
|
|
|
25
41
|
const awayFile = () => path.join(ROOT, "coord-away.json");
|
|
26
42
|
|
|
27
43
|
/**
|
|
28
|
-
* WHAT
|
|
44
|
+
* WHAT THE LEAD MAY NOT DO, enforced rather than trusted to a card line.
|
|
45
|
+
*
|
|
46
|
+
* ALLOWLIST FOR THE OLD MEANING, DENYLIST FOR THIS ONE — and the asymmetry is
|
|
47
|
+
* deliberate rather than a relaxation. A duty officer's grant was NARROW ("keep
|
|
48
|
+
* claimed work moving"), so absence had to mean refusal or every unlisted tool
|
|
49
|
+
* was a silent grant. A lead's grant is WIDE by design: it decides planning,
|
|
50
|
+
* priority, curation, roadmap, canon and releases, which is most of the bus. An
|
|
51
|
+
* allowlist over a wide grant would have to enumerate nearly every tool, and the
|
|
52
|
+
* one someone forgets would refuse legitimate work in the middle of an absence —
|
|
53
|
+
* failing in the direction that leaves the fleet stuck with nobody to ask.
|
|
29
54
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
|
|
34
|
-
|
|
55
|
+
* What is refused is small, closed, and named for its reason: the aide's
|
|
56
|
+
* `never merges / never gates` rule. That rule does not widen when David leaves;
|
|
57
|
+
* it is load-bearing precisely because the gate must stay non-author.
|
|
58
|
+
*/
|
|
59
|
+
export const LEAD_REFUSED: Record<string, string> = {
|
|
60
|
+
merge: "merging is the gate acting. The aide never gates, and leading in David's absence does not change who may — a lead that merges is an author gating its own plan.",
|
|
61
|
+
land: "`land` writes docs/DONE.md, which is the coordinator's file. Leading does not transfer file ownership; ask the coordinator to record it.",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Decisions that WAIT for David. Not refused because they are dangerous to
|
|
66
|
+
* execute — refused because they are not the aide's to make, and an absence is
|
|
67
|
+
* not a transfer of that authority.
|
|
35
68
|
*/
|
|
36
|
-
export const
|
|
69
|
+
export const PARKED_CATEGORIES = [
|
|
70
|
+
"licence",
|
|
71
|
+
"funding",
|
|
72
|
+
"public-vs-private",
|
|
73
|
+
"credentials",
|
|
74
|
+
"anything altering scope or authority",
|
|
75
|
+
"destructive machine actions",
|
|
76
|
+
] as const;
|
|
37
77
|
|
|
38
|
-
export type
|
|
78
|
+
export type AwayCoverage = { checked: number; measurable: number; blind: string[]; at: string };
|
|
79
|
+
|
|
80
|
+
export type AwayState = {
|
|
81
|
+
on: boolean;
|
|
82
|
+
project: string;
|
|
83
|
+
/** Who is away. The verb is about DAVID's absence now. */
|
|
84
|
+
away: string;
|
|
85
|
+
/** The agent leading in that absence — the aide. */
|
|
86
|
+
leadId: string;
|
|
87
|
+
until?: string;
|
|
88
|
+
at: string;
|
|
89
|
+
/** Where decisions taken during the absence are written, so David returns to a diff. */
|
|
90
|
+
decisionLog: string;
|
|
91
|
+
coverage: AwayCoverage;
|
|
92
|
+
/** True when this was turned on knowing the fleet is unobserved. */
|
|
93
|
+
blindAcknowledged?: boolean;
|
|
94
|
+
};
|
|
39
95
|
|
|
40
96
|
export function readAway(): Record<string, AwayState> {
|
|
41
97
|
const f = awayFile();
|
|
@@ -53,69 +109,179 @@ function writeAway(state: Record<string, AwayState>): void {
|
|
|
53
109
|
}
|
|
54
110
|
|
|
55
111
|
/**
|
|
56
|
-
* The refusal
|
|
112
|
+
* The refusal the LEAD gets, or null if the call is allowed.
|
|
57
113
|
*
|
|
58
|
-
* Only the
|
|
59
|
-
* `
|
|
60
|
-
* working because
|
|
114
|
+
* Only the lead is constrained, and only on the two verbs above. Everyone
|
|
115
|
+
* else's lane is untouched: `coord_away` is not a freeze on the project, and a
|
|
116
|
+
* worker whose tools stopped working because David went out would simply stop
|
|
117
|
+
* calling them.
|
|
61
118
|
*/
|
|
62
119
|
export function awayRefusal(state: Record<string, AwayState>, agentId: string | undefined, tool: string): string | null {
|
|
63
120
|
if (!agentId) return null;
|
|
64
|
-
const held = Object.values(state).find((s) => s.on && s.
|
|
121
|
+
const held = Object.values(state).find((s) => s.on && s.leadId === agentId);
|
|
65
122
|
if (!held) return null;
|
|
66
|
-
|
|
67
|
-
|
|
123
|
+
const why = LEAD_REFUSED[tool];
|
|
124
|
+
if (!why) return null;
|
|
125
|
+
return (
|
|
126
|
+
`'${tool}' is refused while you LEAD on '${held.project}' in David's absence: ${why} ` +
|
|
127
|
+
`Leading grants DECISION authority — planning, priority, curation, roadmap, canon, releases — not execution authority. ` +
|
|
128
|
+
`If this genuinely needs doing, it needs the coordinator, or David back. Record it in ${held.decisionLog} either way.`
|
|
129
|
+
);
|
|
68
130
|
}
|
|
69
131
|
|
|
70
132
|
/**
|
|
71
|
-
*
|
|
133
|
+
* RETIRED WITH THE OLD MEANING. Kept as a no-op so the call site in
|
|
134
|
+
* `registerTool` keeps compiling and the retirement is visible where the guard
|
|
135
|
+
* used to be, rather than as an absence someone re-derives later.
|
|
136
|
+
*
|
|
137
|
+
* Under the old meaning the coordinator was the one away, so a second
|
|
138
|
+
* coordinator joining was a fleet-shape change made behind its back. Under this
|
|
139
|
+
* one the coordinator is present and working, and blocking a second coordinator
|
|
140
|
+
* while DAVID is out would refuse a legitimate handoff at exactly the moment
|
|
141
|
+
* nobody can approve it.
|
|
72
142
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* refuses by construction.
|
|
143
|
+
* RECORDED GAP, deliberately not solved here: retiring the duty-officer meaning
|
|
144
|
+
* leaves COORDINATOR absence uncovered. If the coordinator goes down while David
|
|
145
|
+
* is away, nothing covers the seat. That is a decision, not an oversight — it is
|
|
146
|
+
* named so the next reader finds a choice rather than a hole.
|
|
78
147
|
*/
|
|
79
|
-
export function secondCoordinatorRefusal(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
148
|
+
export function secondCoordinatorRefusal(
|
|
149
|
+
_state: Record<string, AwayState>,
|
|
150
|
+
_agentId: string,
|
|
151
|
+
_roleId: string | undefined,
|
|
152
|
+
): string | null {
|
|
153
|
+
return null;
|
|
84
154
|
}
|
|
85
155
|
|
|
86
156
|
export const coordAwaySchema = {
|
|
87
157
|
project: z.string().min(1),
|
|
88
158
|
on: z.boolean(),
|
|
89
|
-
|
|
90
|
-
|
|
159
|
+
/** The agent leading in David's absence — the aide. */
|
|
160
|
+
leadId: z.string().optional(),
|
|
161
|
+
/** Repo-relative path where decisions taken during the absence are written. */
|
|
162
|
+
decisionLog: z.string().optional(),
|
|
91
163
|
until: z.string().optional(),
|
|
164
|
+
/** Repo to measure stall coverage against. */
|
|
165
|
+
repo: z.string().optional(),
|
|
166
|
+
/** Turn it on knowing the fleet is unobserved. Recorded in the state. */
|
|
167
|
+
acknowledgeBlindFleet: z.boolean().optional(),
|
|
168
|
+
/** Who is calling — the lead may turn it on and release it. */
|
|
169
|
+
agentId: z.string().optional(),
|
|
92
170
|
};
|
|
93
171
|
|
|
94
|
-
|
|
172
|
+
async function measureCoverage(repo?: string): Promise<AwayCoverage | null> {
|
|
173
|
+
if (!repo) return null;
|
|
174
|
+
try {
|
|
175
|
+
const r = (await stallCheckTool({ repo })) as unknown as {
|
|
176
|
+
ok?: boolean; checked?: number; measurable?: number; blind?: string[];
|
|
177
|
+
};
|
|
178
|
+
if (!r?.ok) return null;
|
|
179
|
+
return { checked: r.checked ?? 0, measurable: r.measurable ?? 0, blind: r.blind ?? [], at: new Date().toISOString() };
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function coordAwayTool(args: {
|
|
186
|
+
project: string;
|
|
187
|
+
on: boolean;
|
|
188
|
+
leadId?: string;
|
|
189
|
+
decisionLog?: string;
|
|
190
|
+
until?: string;
|
|
191
|
+
repo?: string;
|
|
192
|
+
acknowledgeBlindFleet?: boolean;
|
|
193
|
+
agentId?: string;
|
|
194
|
+
}) {
|
|
95
195
|
const state = readAway();
|
|
96
196
|
const prior = state[args.project];
|
|
97
197
|
|
|
98
198
|
if (args.on) {
|
|
99
|
-
// A
|
|
100
|
-
//
|
|
101
|
-
if (!args.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
199
|
+
// A LEAD WITH NO NAME IS AN ABSENCE DESCRIBED AS COVERED, which is strictly
|
|
200
|
+
// worse than an absence everyone can see is uncovered.
|
|
201
|
+
if (!args.leadId) {
|
|
202
|
+
return { ok: false as const, error: `coord-away ON requires a leadId — the agent leading in David's absence. Turning it on without naming one reports the fleet as led while leaving it unled.` };
|
|
203
|
+
}
|
|
204
|
+
if (!args.decisionLog) {
|
|
205
|
+
return {
|
|
206
|
+
ok: false as const,
|
|
207
|
+
error:
|
|
208
|
+
`coord-away ON requires a decisionLog path. The point of leading through an absence is that the absence ENDS: ` +
|
|
209
|
+
`David should return to a diff he can read, not a transcript he has to reconstruct. A log promised at the start ` +
|
|
210
|
+
`and written at the end is a log nobody wrote.`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (prior?.on) {
|
|
214
|
+
return { ok: false as const, error: `coord-away is already ON for '${args.project}' — '${prior.leadId}' leads since ${prior.at}. Release it before re-arming.` };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// THE PRECONDITION: what could the clock actually SEE?
|
|
218
|
+
const coverage = await measureCoverage(args.repo);
|
|
219
|
+
if (!coverage) {
|
|
220
|
+
return {
|
|
221
|
+
ok: false as const,
|
|
222
|
+
error:
|
|
223
|
+
`cannot measure stall coverage for '${args.project}'${args.repo ? "" : " — pass 'repo'"}. ` +
|
|
224
|
+
`Going away is a promise that the fleet is watched while nobody is watching, and an UNMEASURED promise ` +
|
|
225
|
+
`is the one this verb exists to stop making. Fix the measurement, or pass acknowledgeBlindFleet:true to ` +
|
|
226
|
+
`record that the promise is being made anyway.`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
// `checked > 0` MATTERS: an empty board is an IDLE fleet, not a blind one.
|
|
230
|
+
// Without it, arming while nothing is in flight refuses with "the clock
|
|
231
|
+
// covers 0 of 0" — a refusal that reads as a fleet nobody can see when in
|
|
232
|
+
// fact there is nothing to see, and the only way past it would be to
|
|
233
|
+
// acknowledge a blindness that does not exist.
|
|
234
|
+
if (coverage.checked > 0 && coverage.measurable === 0 && !args.acknowledgeBlindFleet) {
|
|
235
|
+
return {
|
|
236
|
+
ok: false as const,
|
|
237
|
+
error:
|
|
238
|
+
`REFUSING: the stall clock covers ${coverage.measurable} of ${coverage.checked} in-flight row(s). ` +
|
|
239
|
+
`It would RUN and see nothing${coverage.blind.length ? ` — blind on: ${coverage.blind.join(", ")}` : ""}. ` +
|
|
240
|
+
`"The clock ran" and "the fleet is observed" are different facts, and this verb must not treat the first as the second. ` +
|
|
241
|
+
`The usual cause is a board 'Branch · Worktree' cell holding a PATH rather than a branch ref: a path resolves for ` +
|
|
242
|
+
`git and measures the wrong thing, so the check reports it unmeasurable rather than guessing. Fix those cells and ` +
|
|
243
|
+
`coverage returns. If you mean to go anyway, pass acknowledgeBlindFleet:true — it is recorded in the state and in the announcement.`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
state[args.project] = {
|
|
248
|
+
on: true,
|
|
249
|
+
project: args.project,
|
|
250
|
+
away: "david",
|
|
251
|
+
leadId: args.leadId,
|
|
252
|
+
until: args.until,
|
|
253
|
+
at: new Date().toISOString(),
|
|
254
|
+
decisionLog: args.decisionLog,
|
|
255
|
+
coverage,
|
|
256
|
+
...(coverage.checked > 0 && coverage.measurable === 0 ? { blindAcknowledged: true } : {}),
|
|
257
|
+
};
|
|
105
258
|
writeAway(state);
|
|
106
259
|
return {
|
|
107
260
|
ok: true as const,
|
|
108
261
|
state: state[args.project],
|
|
109
|
-
|
|
110
|
-
|
|
262
|
+
refused: LEAD_REFUSED,
|
|
263
|
+
parked: PARKED_CATEGORIES,
|
|
264
|
+
announce:
|
|
265
|
+
`AGENT_ACTION: coord-away ON — David is away${args.until ? ` until ${args.until}` : ""}; '${args.leadId}' LEADS. ` +
|
|
266
|
+
`Decides: planning · priority · curation · roadmap · canon · releases under standing authorisation. ` +
|
|
267
|
+
`Never: merges, gates, or takes a code lane. Parks for David: ${PARKED_CATEGORIES.join(" · ")}. ` +
|
|
268
|
+
`Decisions logged to ${args.decisionLog}. ` +
|
|
269
|
+
`Stall coverage ${coverage.measurable}/${coverage.checked}${coverage.checked > 0 && coverage.measurable === 0 ? " — ACKNOWLEDGED BLIND: the clock runs and sees nothing" : ""}.`,
|
|
111
270
|
};
|
|
112
271
|
}
|
|
113
272
|
|
|
114
|
-
// RELEASE IS THE COORDINATOR'S. A duty officer releasing its own limits is
|
|
115
|
-
// the limit not existing — the one call it must not be able to make.
|
|
116
273
|
if (!prior?.on) return { ok: false as const, error: `coord-away is not on for '${args.project}' — nothing to release.` };
|
|
117
|
-
|
|
274
|
+
// RELEASE IS OBSERVING THAT DAVID IS BACK, not lifting a limit on yourself —
|
|
275
|
+
// which is why the lead may do it and a duty officer could not. The parked
|
|
276
|
+
// decisions are NOT unparked by it: they were never the lead's to make, and
|
|
277
|
+
// release does not retroactively authorise one.
|
|
118
278
|
state[args.project] = { ...prior, on: false, at: new Date().toISOString() };
|
|
119
279
|
writeAway(state);
|
|
120
|
-
return {
|
|
280
|
+
return {
|
|
281
|
+
ok: true as const,
|
|
282
|
+
state: state[args.project],
|
|
283
|
+
announce:
|
|
284
|
+
`AGENT_ACTION: coord-away RELEASED on '${args.project}' — David is back; '${prior.leadId}' stands down as lead. ` +
|
|
285
|
+
`Anything parked during the absence is still parked: release does not authorise it. Decisions taken: ${prior.decisionLog}.`,
|
|
286
|
+
};
|
|
121
287
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The subscribable kind vocabulary — a LEAF module, importing nothing.
|
|
3
|
+
*
|
|
4
|
+
* It lives alone because `events.ts` and `record-events.ts` both need it and
|
|
5
|
+
* import each other: with the registry inside `record-events.ts`, loading that
|
|
6
|
+
* module FIRST threw `Cannot access 'EVENT_KIND_IDS' before initialization`,
|
|
7
|
+
* since `events.ts` reads it at module scope while `record-events.ts` is still
|
|
8
|
+
* evaluating. It worked only because `tools/index.ts` happens to export
|
|
9
|
+
* `events.js` first — a load-ORDER dependency, invisible until something
|
|
10
|
+
* imports the module directly, which is exactly what a consumer or a test does.
|
|
11
|
+
*
|
|
12
|
+
* A cycle that works by luck is not a working cycle.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* The kind vocabulary lives HERE, beside the emitters, and `events.ts` imports
|
|
16
|
+
* it. That direction is deliberate: it is what makes the enum impossible to
|
|
17
|
+
* widen without adding an emitter in the same file.
|
|
18
|
+
*/
|
|
19
|
+
export type SubKind = keyof typeof EVENT_KINDS;
|
|
20
|
+
export type RecordEvent = { kind: SubKind; target: string; ref: string; summary: string };
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
* THE KIND REGISTRY IS THE SINGLE SOURCE, AND THAT IS TASK 9.2.
|
|
24
|
+
*
|
|
25
|
+
* `SubKind` used to be a hand-written union next to a hand-written zod enum next
|
|
26
|
+
* to an emitter that covered one of the four. Nothing connected them, so a kind
|
|
27
|
+
* could be offered for subscription while nothing could ever emit it — and
|
|
28
|
+
* `list_subscriptions` would report its permanent `never evaluated` forever,
|
|
29
|
+
* honestly and uselessly.
|
|
30
|
+
*
|
|
31
|
+
* Now the union, the wire enum, and the emitter set are all derived from THIS
|
|
32
|
+
* object. A kind cannot be offered without an emitter because the enum is
|
|
33
|
+
* generated from the emitters; `test/record-events.test.mjs` closes the other
|
|
34
|
+
* half by asserting each kind actually fires. Unsatisfiable BY CONSTRUCTION,
|
|
35
|
+
* rather than by a reviewer noticing.
|
|
36
|
+
*/
|
|
37
|
+
export const EVENT_KINDS = {
|
|
38
|
+
item: {
|
|
39
|
+
record: "docs/QUEUE.md + docs/DONE.md",
|
|
40
|
+
what: "a queue item closed — it left QUEUE.md and a DONE.md entry appeared in the same commit",
|
|
41
|
+
targetIs: "the queue item id",
|
|
42
|
+
},
|
|
43
|
+
pr: {
|
|
44
|
+
record: "docs/DONE.md",
|
|
45
|
+
what: "a PR recorded in the completion log",
|
|
46
|
+
targetIs: "the PR ref, e.g. owner/repo#163",
|
|
47
|
+
},
|
|
48
|
+
task: {
|
|
49
|
+
record: "docs/phases/**/PHASE*_TASKS.md",
|
|
50
|
+
what: "a phase task checkbox newly ticked",
|
|
51
|
+
targetIs: "the task key, e.g. 5:12.1",
|
|
52
|
+
},
|
|
53
|
+
phase: {
|
|
54
|
+
record: "docs/phases/**/PHASE*_TASKS.md",
|
|
55
|
+
what: "the last open checkbox in a phase document ticked",
|
|
56
|
+
targetIs: "the phase number, e.g. 5",
|
|
57
|
+
},
|
|
58
|
+
} as const;
|
|
59
|
+
|
|
60
|
+
export const EVENT_KIND_IDS = Object.keys(EVENT_KINDS) as [SubKind, ...SubKind[]];
|
package/src/tools/events.ts
CHANGED
|
@@ -13,10 +13,16 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { z } from "zod";
|
|
15
15
|
import { ROOT } from "../store.js";
|
|
16
|
+
import { EVENT_KINDS, EVENT_KIND_IDS, type RecordEvent, type SubKind } from "./event-kinds.js";
|
|
16
17
|
|
|
17
18
|
const subsFile = () => path.join(ROOT, "subscriptions.json");
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
// Re-exported so every existing importer keeps its import path. The vocabulary
|
|
21
|
+
// itself is defined beside the emitters (record-events.ts) — Task 9.2: a kind
|
|
22
|
+
// that can be subscribed to but never emitted is unsatisfiable BY CONSTRUCTION
|
|
23
|
+
// only if the enum cannot be widened without an emitter.
|
|
24
|
+
export { EVENT_KINDS, EVENT_KIND_IDS };
|
|
25
|
+
export type { RecordEvent, SubKind };
|
|
20
26
|
export type Subscription = {
|
|
21
27
|
id: string;
|
|
22
28
|
agentId: string;
|
|
@@ -77,8 +83,6 @@ export function subscriptionHealth(s: Subscription): { level: "ok" | "error"; de
|
|
|
77
83
|
export const eventKey = (kind: SubKind, target: string, ref: string): string =>
|
|
78
84
|
createHash("sha256").update(`${kind}:${target}:${ref}`).digest("hex").slice(0, 16);
|
|
79
85
|
|
|
80
|
-
export type RecordEvent = { kind: SubKind; target: string; ref: string; summary: string };
|
|
81
|
-
|
|
82
86
|
/**
|
|
83
87
|
* 6.2 — EVENTS ARE DERIVED FROM THE RECORD, NEVER PARALLEL TO IT.
|
|
84
88
|
*
|
|
@@ -139,7 +143,7 @@ export function evaluate(subs: Subscription[], ev: RecordEvent, now: number): {
|
|
|
139
143
|
|
|
140
144
|
export const subscribeSchema = {
|
|
141
145
|
agentId: z.string().min(1),
|
|
142
|
-
kind: z.enum(
|
|
146
|
+
kind: z.enum(EVENT_KIND_IDS),
|
|
143
147
|
target: z.string().min(1),
|
|
144
148
|
};
|
|
145
149
|
|