agent-coord-mcp 0.26.10 → 0.26.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/roles.js +11 -0
- package/dist/roles.js.map +1 -1
- package/dist/server.js +7 -5
- package/dist/server.js.map +1 -1
- package/dist/tools/events.js +7 -1
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/index.js +2 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/messaging.js +60 -3
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/record-events.js +365 -0
- package/dist/tools/record-events.js.map +1 -0
- package/dist/tools/registry.js +54 -1
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/transport.js +4 -0
- package/dist/tools/transport.js.map +1 -1
- package/dist/typed-records.js +174 -0
- package/dist/typed-records.js.map +1 -0
- package/hooks/roles.mjs +8 -0
- package/package.json +2 -2
- package/scripts/check-test-count.mjs +1 -1
- package/scripts/typed-record-stats.mjs +43 -0
- package/src/roles.ts +12 -0
- package/src/server.ts +13 -5
- package/src/tools/events.ts +8 -4
- package/src/tools/index.ts +2 -0
- package/src/tools/messaging.ts +76 -3
- package/src/tools/record-events.ts +386 -0
- package/src/tools/registry.ts +62 -2
- package/src/tools/shared.ts +13 -0
- package/src/tools/transport.ts +5 -0
- package/src/typed-records.ts +216 -0
package/src/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,
|
|
@@ -287,7 +288,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
287
288
|
|
|
288
289
|
addTool(
|
|
289
290
|
"join",
|
|
290
|
-
"Recommended session-start call. Does register + auto-attach (if running inside tmux) + read inbox in one round-trip. Pass attach=false to skip the transport, attach={...overrides} to customize, or omit it to let the server auto-detect $TMUX_PANE. Returns the registration, attach result, any unread inbox messages, and the default channel's topic + MOTD (room rules) so you see them on connect. Calling join binds this MCP process's identity to agentId for the lifetime of the session — no env var or config needed. Each Claude Code session runs its own stdio process so bindings are naturally isolated. Claiming an id that is currently LIVE on the bus (fresh heartbeat, live pusher, or another bound session) is refused unless the claim comes from that agent's own tmux pane or carries the agent's token or force:true — diagnosing someone else's agent is what status/ping are for.",
|
|
291
|
+
"Recommended session-start call. Does register + auto-attach (if running inside tmux) + read inbox in one round-trip. Pass attach=false to skip the transport, attach={...overrides} to customize, or omit it to let the server auto-detect $TMUX_PANE. Returns the registration, attach result, any unread inbox messages, and the default channel's topic + MOTD (room rules) so you see them on connect. Calling join binds this MCP process's identity to agentId for the lifetime of the session — no env var or config needed. Each Claude Code session runs its own stdio process so bindings are naturally isolated. Claiming an id that is currently LIVE on the bus (fresh heartbeat, live pusher, or another bound session) is refused unless the claim comes from that agent's own tmux pane or carries the agent's token or force:true — diagnosing someone else's agent is what status/ping are for. `proseOnly:true` claims the per-agent exemption from the typed-record rule — see `register`.",
|
|
291
292
|
joinSchema,
|
|
292
293
|
// join explicitly sets the session binding when unset, so each agent can
|
|
293
294
|
// declare its identity via join rather than relying on env vars.
|
|
@@ -310,7 +311,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
310
311
|
|
|
311
312
|
addTool(
|
|
312
313
|
"register",
|
|
313
|
-
"Register this agent in the shared registry. Lower-level than `join` — does not attach a transport or drain the inbox. Prefer `join` unless you need explicit control.",
|
|
314
|
+
"Register this agent in the shared registry. Lower-level than `join` — does not attach a transport or drain the inbox. Prefer `join` unless you need explicit control. `proseOnly:true` claims the per-agent exemption from the typed-record rule, for a model that cannot reliably pick a record.type; it is visible and counted in list_agents, and it is granted to the SENDER but paid by every READER (an untyped message cannot be slimmed). Omit it to leave any existing exemption untouched; pass false to revoke.",
|
|
314
315
|
registerSchema,
|
|
315
316
|
gate("agentId", registerTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
316
317
|
);
|
|
@@ -352,14 +353,14 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
352
353
|
|
|
353
354
|
addTool(
|
|
354
355
|
"list_agents",
|
|
355
|
-
"List all known agents and whether they appear online (heartbeat <5min).",
|
|
356
|
+
"List all known agents and whether they appear online (heartbeat <5min). Also reports the prose-only exemptions from the typed-record rule — who holds one, since when, and the count over the total, so a rising exempt share is visible rather than inferred.",
|
|
356
357
|
listAgentsSchema,
|
|
357
358
|
gate(null, listAgentsTool as () => Promise<unknown>),
|
|
358
359
|
);
|
|
359
360
|
|
|
360
361
|
addTool(
|
|
361
362
|
"send_message",
|
|
362
|
-
"Send a message. If 'to' is set, goes to that agent's inbox (DM); otherwise to a channel — pass 'room' (e.g. 'seo' or '#seo') to target a specific channel, or omit it for the default 'general' channel. For channel posts, tag 'kind': 'decision' for GOs/verdicts/agreements that must outlive routine cleanup (kept ~30 days, quoted verbatim in digests), 'status' for progress notes, omit for ordinary chatter. Optional 'inReplyTo' is a parent message uuid (a reply that resolves a DAVID_DECISION); malformed id is refused, unknown id is stored with a warning. The 'from' field is enforced against the session's bound identity when binding is configured.",
|
|
363
|
+
"Send a message. If 'to' is set, goes to that agent's inbox (DM); otherwise to a channel — pass 'room' (e.g. 'seo' or '#seo') to target a specific channel, or omit it for the default 'general' channel. For channel posts, tag 'kind': 'decision' for GOs/verdicts/agreements that must outlive routine cleanup (kept ~30 days, quoted verbatim in digests), 'status' for progress notes, omit for ordinary chatter. Optional 'inReplyTo' is a parent message uuid (a reply that resolves a DAVID_DECISION); malformed id is refused, unknown id is stored with a warning. The 'from' field is enforced against the session's bound identity when binding is configured. EVERY AGENT→AGENT MESSAGE MUST CARRY 'record' with a typed 'type' (decision · verdict · done · blocker · risk · fyi · action · go · scope): a typed multi-line message is delivered as ONE attributed line plus a retrieve_message handle, while an untyped one arrives in full in every reader's context. 'fyi' is the honest catch-all — use it rather than forcing a false 'decision'/'risk'. Untyped sends WARN today and are REFUSED from 2026-09-15. Messages TO a human are exempt (David-facing traffic stays prose), as is a sender that declared proseOnly:true at join.",
|
|
363
364
|
sendMessageSchema,
|
|
364
365
|
gate("from", sendMessageTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
365
366
|
);
|
|
@@ -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.",
|
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 "./record-events.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
|
|
package/src/tools/index.ts
CHANGED
package/src/tools/messaging.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { adjustCursors } from "./admin.js";
|
|
2
|
-
import { RECORD_AUTHORITY, resolveRole, roleMatches } from "../roles.js";
|
|
2
|
+
import { RECORD_AUTHORITY, isHuman, recordAuthorityFor, resolveRole, roleMatches } from "../roles.js";
|
|
3
|
+
import {
|
|
4
|
+
TYPED_RECORD_CUTOVER_ISO,
|
|
5
|
+
suggestRecordType,
|
|
6
|
+
typedRecordGuidance,
|
|
7
|
+
typedRecordMode,
|
|
8
|
+
} from "../typed-records.js";
|
|
3
9
|
import { ARCHIVE_STATUS_FILE, ARCHIVE_INBOX_DIR, ARCHIVE_ROOMS_DIR, archiveJsonl, archiveInboxFile, archiveRoomFile } from "../store.js";
|
|
4
10
|
import { randomUUID } from "node:crypto";
|
|
5
11
|
import { existsSync, openSync, watch } from "node:fs";
|
|
@@ -159,6 +165,67 @@ export async function checkRecordAuthority(
|
|
|
159
165
|
};
|
|
160
166
|
}
|
|
161
167
|
|
|
168
|
+
// ---------- typed records obligatory (Phase 5.1 Task 12) ----------
|
|
169
|
+
|
|
170
|
+
// An untyped agent→agent message must not be able to EXIST. Enforced HERE, at
|
|
171
|
+
// the send, and not at the render: a rule applied where the message is read
|
|
172
|
+
// leaves the untyped message on disk, and the next reader re-derives the type
|
|
173
|
+
// from prose. See src/typed-records.ts for the staging, the suggestion rules,
|
|
174
|
+
// and why `fyi` stays an honest catch-all.
|
|
175
|
+
//
|
|
176
|
+
// Returns `undefined` when the send is fine, a WARNING string while the rule is
|
|
177
|
+
// staged, or a REFUSAL after the cutover.
|
|
178
|
+
//
|
|
179
|
+
// TWO EXEMPTIONS, AND THEY ARE DIFFERENT IN KIND:
|
|
180
|
+
// - the RECIPIENT is a human. Canon: "David-facing messages may use normal
|
|
181
|
+
// prose". Scoped to agent→agent traffic, so the one channel whose reader is
|
|
182
|
+
// a person is untouched. Nothing is declared for this — it is a property of
|
|
183
|
+
// who is being written to.
|
|
184
|
+
// - the SENDER holds a prose-only exemption, declared per agent at join and
|
|
185
|
+
// visible in list_agents. That one is a statement about a model's ability
|
|
186
|
+
// to pick a type, and it is paid for by every reader.
|
|
187
|
+
async function typedRecordCheck(args: {
|
|
188
|
+
from: string;
|
|
189
|
+
to?: string;
|
|
190
|
+
text: string;
|
|
191
|
+
record?: MessageRecord;
|
|
192
|
+
}): Promise<{ ok: true; warning?: string } | { ok: false; error: string }> {
|
|
193
|
+
if (args.record?.type) return { ok: true };
|
|
194
|
+
|
|
195
|
+
const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
|
|
196
|
+
|
|
197
|
+
// David-facing prose stays prose. An UNREGISTERED recipient is treated as an
|
|
198
|
+
// agent, not as a human: the safe reading of "I cannot tell" is the rule, and
|
|
199
|
+
// a human on this bus has a registry entry (that is how the pane is found).
|
|
200
|
+
if (args.to && isHuman(reg[args.to])) return { ok: true };
|
|
201
|
+
|
|
202
|
+
const sender = reg[args.from];
|
|
203
|
+
if (sender?.proseOnly) return { ok: true };
|
|
204
|
+
|
|
205
|
+
const suggestion = suggestRecordType(args.text, recordAuthorityFor(sender).mayNotEmit);
|
|
206
|
+
const guidance = typedRecordGuidance(suggestion);
|
|
207
|
+
|
|
208
|
+
if (typedRecordMode() === "warn") {
|
|
209
|
+
return {
|
|
210
|
+
ok: true,
|
|
211
|
+
warning:
|
|
212
|
+
`UNTYPED — stored, but this send is REFUSED from ${TYPED_RECORD_CUTOVER_ISO}. ` +
|
|
213
|
+
`${guidance} Until then an untyped multi-line message arrives in full in every reader's context ` +
|
|
214
|
+
`instead of one line plus a retrieve_message handle. ` +
|
|
215
|
+
`A model that cannot pick a type declares proseOnly:true at join (per-agent, visible in list_agents).`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
error:
|
|
221
|
+
`agent→agent messages must carry a typed record (since ${TYPED_RECORD_CUTOVER_ISO}) — nothing was written. ` +
|
|
222
|
+
`${guidance} ` +
|
|
223
|
+
`Types: decision · verdict · done · blocker · risk · fyi · action · go · scope; 'fyi' is the honest ` +
|
|
224
|
+
`catch-all — do not force a false 'decision'/'risk' to get past this. ` +
|
|
225
|
+
`Messages TO a human are exempt, and an agent that cannot pick a type declares proseOnly:true at join.`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
162
229
|
export const sendMessageSchema = {
|
|
163
230
|
from: z.string().min(1),
|
|
164
231
|
to: z.string().optional(),
|
|
@@ -245,6 +312,12 @@ export async function sendMessageTool(args: {
|
|
|
245
312
|
};
|
|
246
313
|
}
|
|
247
314
|
|
|
315
|
+
// After `text` is resolved (a record can fill it) and before anything is
|
|
316
|
+
// written, so a refusal leaves nothing on disk.
|
|
317
|
+
const typed = await typedRecordCheck({ from: args.from, to: args.to, text, record: args.record });
|
|
318
|
+
if (!typed.ok) return { ok: false as const, error: typed.error };
|
|
319
|
+
const typedWarning = typed.warning;
|
|
320
|
+
|
|
248
321
|
let replyWarning: string | undefined;
|
|
249
322
|
if (args.inReplyTo !== undefined) {
|
|
250
323
|
if (!MESSAGE_ID_RE.test(args.inReplyTo)) {
|
|
@@ -312,7 +385,7 @@ export async function sendMessageTool(args: {
|
|
|
312
385
|
const recipientWarning = reg[args.to]
|
|
313
386
|
? undefined
|
|
314
387
|
: `recipient '${args.to}' is not a registered agent — message stored in their inbox but no one may be listening`;
|
|
315
|
-
const warning = [recipientWarning, replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
|
|
388
|
+
const warning = [typedWarning, recipientWarning, replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
|
|
316
389
|
return { ok: true, id: msg.id, target, room: undefined, warning };
|
|
317
390
|
}
|
|
318
391
|
|
|
@@ -331,7 +404,7 @@ export async function sendMessageTool(args: {
|
|
|
331
404
|
const target = roomFile(chan);
|
|
332
405
|
await appendJsonl(target, msg);
|
|
333
406
|
await maybeCompactRoom(chan);
|
|
334
|
-
const roomWarning = [replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
|
|
407
|
+
const roomWarning = [typedWarning, replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
|
|
335
408
|
return { ok: true, id: msg.id, target, room: chan, ...(roomWarning ? { warning: roomWarning } : {}) };
|
|
336
409
|
}
|
|
337
410
|
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Phase 5.1 Task 9 — the four subscribable kinds all emit, and the TRIGGER is
|
|
3
|
+
* the record's COMMITTED CHANGE rather than the `land` verb (David's ruling,
|
|
4
|
+
* option (b), 2026-08-30).
|
|
5
|
+
*
|
|
6
|
+
* WHY NOT (a), "emit from the verbs": it bets on every fleet adopting the
|
|
7
|
+
* workflow layer, and the only evidence available says they do not — a consumer fleet called
|
|
8
|
+
* `claim`/`land`/`merge`/`next_unblocked`/`rotate`/`set_halt` ZERO times in a day
|
|
9
|
+
* of heavy use. A `pr` subscription tested against a real PR came back
|
|
10
|
+
* `health: error — never evaluated`, and the fleet unsubscribed. Kinds that can
|
|
11
|
+
* be subscribed to and can never fire are a permanent, honestly-reported error.
|
|
12
|
+
*
|
|
13
|
+
* WHAT (b) CHANGES, AND WHAT IT DOES NOT: the record stays the source — only the
|
|
14
|
+
* trigger moves from the verb to the change. That still satisfies 6.2 ("events
|
|
15
|
+
* derived from the record, never parallel to it"), because the derivation still
|
|
16
|
+
* reads the record; it just no longer requires that a particular verb performed
|
|
17
|
+
* the write. THE POINT IS THAT A HAND-EDIT BECOMES A FIRST-CLASS CAUSE rather
|
|
18
|
+
* than an invisible one, which is what fleets actually do: they merge with `gh`
|
|
19
|
+
* and edit `docs/DONE.md` by hand. `land` keeps emitting, as ONE WRITER AMONG
|
|
20
|
+
* SEVERAL rather than as the gate — the idempotency key makes the overlap safe,
|
|
21
|
+
* since both paths derive the same key from the same event.
|
|
22
|
+
*
|
|
23
|
+
* THE COMMIT IS THE BOUNDARY, not the working tree. An uncommitted edit is not
|
|
24
|
+
* yet a record: it can be reverted, rebased away, or never pushed, and a
|
|
25
|
+
* notification for work that then vanishes is worse than a late one.
|
|
26
|
+
*/
|
|
27
|
+
import { execFileSync } from "node:child_process";
|
|
28
|
+
import { newlyTickedInDiff, parseWorkDoc, queueItemsOf, doneEntriesOf } from "@davidbalzan/groundwork-seam";
|
|
29
|
+
/**
|
|
30
|
+
* The kind vocabulary lives HERE, beside the emitters, and `events.ts` imports
|
|
31
|
+
* it. That direction is deliberate: it is what makes the enum impossible to
|
|
32
|
+
* widen without adding an emitter in the same file.
|
|
33
|
+
*/
|
|
34
|
+
export type SubKind = keyof typeof EVENT_KINDS;
|
|
35
|
+
export type RecordEvent = { kind: SubKind; target: string; ref: string; summary: string };
|
|
36
|
+
|
|
37
|
+
/*
|
|
38
|
+
* THE KIND REGISTRY IS THE SINGLE SOURCE, AND THAT IS TASK 9.2.
|
|
39
|
+
*
|
|
40
|
+
* `SubKind` used to be a hand-written union next to a hand-written zod enum next
|
|
41
|
+
* to an emitter that covered one of the four. Nothing connected them, so a kind
|
|
42
|
+
* could be offered for subscription while nothing could ever emit it — and
|
|
43
|
+
* `list_subscriptions` would report its permanent `never evaluated` forever,
|
|
44
|
+
* honestly and uselessly.
|
|
45
|
+
*
|
|
46
|
+
* Now the union, the wire enum, and the emitter set are all derived from THIS
|
|
47
|
+
* object. A kind cannot be offered without an emitter because the enum is
|
|
48
|
+
* generated from the emitters; `test/record-events.test.mjs` closes the other
|
|
49
|
+
* half by asserting each kind actually fires. Unsatisfiable BY CONSTRUCTION,
|
|
50
|
+
* rather than by a reviewer noticing.
|
|
51
|
+
*/
|
|
52
|
+
export const EVENT_KINDS = {
|
|
53
|
+
item: {
|
|
54
|
+
record: "docs/QUEUE.md + docs/DONE.md",
|
|
55
|
+
what: "a queue item closed — it left QUEUE.md and a DONE.md entry appeared in the same commit",
|
|
56
|
+
targetIs: "the queue item id",
|
|
57
|
+
},
|
|
58
|
+
pr: {
|
|
59
|
+
record: "docs/DONE.md",
|
|
60
|
+
what: "a PR recorded in the completion log",
|
|
61
|
+
targetIs: "the PR ref, e.g. owner/repo#163",
|
|
62
|
+
},
|
|
63
|
+
task: {
|
|
64
|
+
record: "docs/phases/**/PHASE*_TASKS.md",
|
|
65
|
+
what: "a phase task checkbox newly ticked",
|
|
66
|
+
targetIs: "the task key, e.g. 5:12.1",
|
|
67
|
+
},
|
|
68
|
+
phase: {
|
|
69
|
+
record: "docs/phases/**/PHASE*_TASKS.md",
|
|
70
|
+
what: "the last open checkbox in a phase document ticked",
|
|
71
|
+
targetIs: "the phase number, e.g. 5",
|
|
72
|
+
},
|
|
73
|
+
} as const;
|
|
74
|
+
|
|
75
|
+
export const EVENT_KIND_IDS = Object.keys(EVENT_KINDS) as [SubKind, ...SubKind[]];
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* One pass over the diff → per-file added and removed lines.
|
|
79
|
+
*
|
|
80
|
+
* Per FILE, not per predicate: the phase rule needs to know WHICH document a
|
|
81
|
+
* tick came from, and a flat "all added lines" view cannot answer that. It
|
|
82
|
+
* emitted `phase 5 complete` off a tick in the phase 5.1 document on the first
|
|
83
|
+
* real-data run.
|
|
84
|
+
*/
|
|
85
|
+
export function diffByFile(diff: string): Map<string, { added: string[]; removed: string[] }> {
|
|
86
|
+
const out = new Map<string, { added: string[]; removed: string[] }>();
|
|
87
|
+
let cur: { added: string[]; removed: string[] } | null = null;
|
|
88
|
+
for (const line of String(diff ?? "").split("\n")) {
|
|
89
|
+
if (line.startsWith("diff --git ")) { cur = null; continue; }
|
|
90
|
+
if (line.startsWith("+++ b/")) {
|
|
91
|
+
const p = line.slice(6).trim();
|
|
92
|
+
if (p === "/dev/null") { cur = null; continue; }
|
|
93
|
+
cur = out.get(p) ?? { added: [], removed: [] };
|
|
94
|
+
out.set(p, cur);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (line.startsWith("--- a/") || line.startsWith("@@")) continue;
|
|
98
|
+
if (!cur) continue;
|
|
99
|
+
if (line.startsWith("+")) cur.added.push(line.slice(1));
|
|
100
|
+
else if (line.startsWith("-")) cur.removed.push(line.slice(1));
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const isDone = (p: string) => /(^|\/)DONE\.md$/.test(p);
|
|
106
|
+
const isQueue = (p: string) => /(^|\/)QUEUE\.md$/.test(p);
|
|
107
|
+
const isPhaseDoc = (p: string) => /PHASE[^/]*TASKS\.md$/i.test(p);
|
|
108
|
+
|
|
109
|
+
const linesWhere = (byFile: ReturnType<typeof diffByFile>, match: (p: string) => boolean, side: "added" | "removed") =>
|
|
110
|
+
[...byFile.entries()].filter(([p]) => match(p)).flatMap(([, v]) => v[side]);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A ref that actually identifies a pull request.
|
|
114
|
+
*
|
|
115
|
+
* MEASURED ON REAL DATA, and this is why the check exists: the done-entry
|
|
116
|
+
* parser splits on the LAST ` — `, so an entry whose own text contains an em
|
|
117
|
+
* dash yields a "ref" of `aide-verified, coordinator-closed`. Emitting that as
|
|
118
|
+
* a `pr` event would put a target on the bus that names no PR, and a
|
|
119
|
+
* subscription could never match it — a silent, permanent miss dressed as a
|
|
120
|
+
* delivery. Parsing correctly is not the same as the parse MEANING what you
|
|
121
|
+
* assumed.
|
|
122
|
+
*/
|
|
123
|
+
const PR_REF = /^(?:[\w.-]+\/[\w.-]+#\d+|#\d+|https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+)$/;
|
|
124
|
+
|
|
125
|
+
/** Queue and done text, compared for the pairing below. */
|
|
126
|
+
const norm = (s: string) => String(s).toLowerCase().replace(/[`*_]/g, "").replace(/\s+/g, " ").trim();
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Events implied by one committed change.
|
|
130
|
+
*
|
|
131
|
+
* `after` carries the documents AS THEY NOW STAND — every event is checked
|
|
132
|
+
* against them by the caller (`eventIsDerived`), so a diff that has since been
|
|
133
|
+
* reverted cannot produce an event that outlives the record.
|
|
134
|
+
*/
|
|
135
|
+
export function eventsFromCommittedChange(
|
|
136
|
+
diff: string,
|
|
137
|
+
after: { done?: string; phases?: Record<string, string> } = {},
|
|
138
|
+
): RecordEvent[] {
|
|
139
|
+
const events: RecordEvent[] = [];
|
|
140
|
+
const unattributedItems: string[] = [];
|
|
141
|
+
const byFile = diffByFile(diff);
|
|
142
|
+
|
|
143
|
+
// ── pr: a new DONE.md entry naming a PR ───────────────────────────────────
|
|
144
|
+
// Parsed, never regexed off the raw line: the glyph contract (` — ` U+2014,
|
|
145
|
+
// ` · ` U+00B7) is exact, and a hand-written entry using a plain hyphen must
|
|
146
|
+
// NOT quietly become an event with a mangled ref. It fails to parse, and a
|
|
147
|
+
// line that does not parse is not a record entry.
|
|
148
|
+
const addedDone = linesWhere(byFile, isDone, "added");
|
|
149
|
+
const newEntries = doneEntriesOf(parseWorkDoc(`## Done\n${addedDone.join("\n")}\n`)) as Array<{ ref?: string; text?: string }>;
|
|
150
|
+
|
|
151
|
+
for (const entry of newEntries) {
|
|
152
|
+
const ref = entry.ref?.trim();
|
|
153
|
+
if (!ref || !PR_REF.test(ref)) continue;
|
|
154
|
+
events.push({ kind: "pr", target: ref, ref, summary: entry.text?.slice(0, 120) || ref });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── item: a queue item that closed ────────────────────────────────────────
|
|
158
|
+
//
|
|
159
|
+
// ATTRIBUTION IS THE WHOLE PROBLEM, and the markdown does not carry it. A
|
|
160
|
+
// queue item and the DONE entry that closes it share no id and, on real data,
|
|
161
|
+
// no wording either: the item states what to do and the entry states what was
|
|
162
|
+
// done. `land` only knows because its caller passed `queueItemId`.
|
|
163
|
+
//
|
|
164
|
+
// So: pair on text when the texts DO correspond, otherwise pair only when the
|
|
165
|
+
// commit is unambiguous — exactly one item removed and exactly one entry
|
|
166
|
+
// added. Anything else is reported as unattributed rather than guessed. The
|
|
167
|
+
// naive version attributed all 15 items removed in one real commit to the
|
|
168
|
+
// first PR it saw, which is a wrong claim about fourteen of them, and a wrong
|
|
169
|
+
// claim on this bus is worse than a missing one.
|
|
170
|
+
const removedQueue = linesWhere(byFile, isQueue, "removed");
|
|
171
|
+
const removedItems = (queueItemsOf(parseWorkDoc(`## Queue\n${removedQueue.join("\n")}\n`)) as Array<{ id?: string; text?: string }>)
|
|
172
|
+
.filter((i) => i.id);
|
|
173
|
+
const prEntries = newEntries.filter((e) => e.ref && PR_REF.test(e.ref.trim()));
|
|
174
|
+
const unattributed: string[] = [];
|
|
175
|
+
|
|
176
|
+
for (const item of removedItems) {
|
|
177
|
+
const itemText = norm(item.text ?? "");
|
|
178
|
+
let entry = itemText
|
|
179
|
+
? prEntries.find((e) => {
|
|
180
|
+
const t = norm(e.text ?? "");
|
|
181
|
+
return t && (t === itemText || t.startsWith(itemText) || itemText.startsWith(t));
|
|
182
|
+
})
|
|
183
|
+
: undefined;
|
|
184
|
+
// The unambiguous-commit case: one out, one in. This is the shape the fleet
|
|
185
|
+
// actually commits ("close its queue item"), and it is the case the aide's
|
|
186
|
+
// dead `item` subscriptions need.
|
|
187
|
+
if (!entry && removedItems.length === 1 && prEntries.length === 1) entry = prEntries[0];
|
|
188
|
+
if (!entry) {
|
|
189
|
+
unattributed.push(item.id!);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
events.push({ kind: "item", target: item.id!, ref: entry.ref!.trim(), summary: entry.text?.slice(0, 120) ?? item.id! });
|
|
193
|
+
}
|
|
194
|
+
if (unattributed.length) unattributedItems.push(...unattributed);
|
|
195
|
+
|
|
196
|
+
// ── task: checkbox transitions ────────────────────────────────────────────
|
|
197
|
+
// `newlyTickedInDiff` requires BOTH a removed open box and an added ticked one
|
|
198
|
+
// for the same id, so a moved or reformatted line cannot manufacture a
|
|
199
|
+
// completion — that rule is the seam's and is reused rather than re-derived.
|
|
200
|
+
const ticked = [...newlyTickedInDiff(diff)];
|
|
201
|
+
const tickedLines = linesWhere(byFile, isPhaseDoc, "added");
|
|
202
|
+
for (const key of ticked) {
|
|
203
|
+
const id = key.split(":")[1] ?? "";
|
|
204
|
+
// Ref is the ticked LINE, not the bare id: `12.1` appears in prose all over
|
|
205
|
+
// a phase doc, so a bare id would pass the derivation check against a
|
|
206
|
+
// document that never ticked anything.
|
|
207
|
+
const line = tickedLines.find((l) => new RegExp(`^\\s*-\\s*\\[x\\]\\s*\\*{0,2}${id.replace(".", "\\.")}\\b`, "i").test(l));
|
|
208
|
+
if (!line) continue;
|
|
209
|
+
events.push({ kind: "task", target: key, ref: line.trim(), summary: line.trim().slice(0, 120) });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── phase: the last open box in ONE document ──────────────────────────────
|
|
213
|
+
// Keyed on the FILE, both for "is it complete" and for "did this change close
|
|
214
|
+
// it". `newlyTickedInDiff` keys ticks by the integer in the path, so
|
|
215
|
+
// `phase5.1` and `phase5` collapse to the same `5:` — the first real-data run
|
|
216
|
+
// announced PHASE 5 COMPLETE on the strength of a tick in the 5.1 document.
|
|
217
|
+
// A false completion is worse than a missing one: it closes a phase nobody
|
|
218
|
+
// finished.
|
|
219
|
+
for (const [rel, text] of Object.entries(after.phases ?? {})) {
|
|
220
|
+
if (!isPhaseDoc(rel)) continue;
|
|
221
|
+
const boxes = [...String(text).matchAll(/^\s*-\s*\[( |x)\]\s*\*{0,2}(\d+\.\d+[a-z]?)\b/gim)];
|
|
222
|
+
if (!boxes.length || boxes.some((m) => m[1] !== "x")) continue;
|
|
223
|
+
// This commit must have ticked a box IN THIS FILE.
|
|
224
|
+
const closedHere = (byFile.get(rel)?.added ?? []).some((l) => /^\s*-\s*\[x\]\s*\*{0,2}\d+\.\d+/.test(l));
|
|
225
|
+
if (!closedHere) continue;
|
|
226
|
+
const phase = /phase(\d+(?:\.\d+)?)/i.exec(rel)?.[1];
|
|
227
|
+
if (!phase) continue;
|
|
228
|
+
events.push({ kind: "phase", target: phase, ref: boxes[boxes.length - 1]![0].trim(), summary: `phase ${phase} — every task box ticked` });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
lastUnattributedItems = unattributedItems;
|
|
232
|
+
return events;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Queue item ids removed in the last scanned change that could NOT be tied to a
|
|
237
|
+
* done entry. Reported by `scan_record_events` rather than dropped: "we saw
|
|
238
|
+
* items close and could not say which PR closed them" and "nothing closed" are
|
|
239
|
+
* different facts, and only one of them needs a human.
|
|
240
|
+
*/
|
|
241
|
+
export let lastUnattributedItems: string[] = [];
|
|
242
|
+
|
|
243
|
+
/** `git` in a repo, returning "" rather than throwing — a scan is read-only. */
|
|
244
|
+
export function git(repo: string, args: string[]): string {
|
|
245
|
+
try {
|
|
246
|
+
return execFileSync("git", args, { cwd: repo, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
|
|
247
|
+
} catch {
|
|
248
|
+
return "";
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/* ── the verb ──────────────────────────────────────────────────────────────── */
|
|
253
|
+
|
|
254
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
255
|
+
import path from "node:path";
|
|
256
|
+
import { z } from "zod";
|
|
257
|
+
import { ROOT } from "../store.js";
|
|
258
|
+
import { readSubs, evaluate, commitEvaluation, eventIsDerived } from "./events.js";
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The watermark: the last commit whose record change has been turned into
|
|
262
|
+
* events, per repo.
|
|
263
|
+
*
|
|
264
|
+
* It is stored rather than inferred, because "what have I already emitted" is
|
|
265
|
+
* not derivable from the repo — and the alternative, re-deriving from some
|
|
266
|
+
* fixed point every time, would re-announce a year of closures on first run.
|
|
267
|
+
* Losing the file is safe in the direction that matters: the idempotency key is
|
|
268
|
+
* derived from the EVENT, so a re-scan of already-delivered events reports
|
|
269
|
+
* `duplicate-suppressed` rather than waking anyone twice.
|
|
270
|
+
*/
|
|
271
|
+
const watermarkFile = () => path.join(ROOT, "record-events.json");
|
|
272
|
+
|
|
273
|
+
type Watermarks = Record<string, { sha: string; at: number }>;
|
|
274
|
+
|
|
275
|
+
function readWatermarks(): Watermarks {
|
|
276
|
+
const f = watermarkFile();
|
|
277
|
+
if (!existsSync(f)) return {};
|
|
278
|
+
try {
|
|
279
|
+
return (JSON.parse(readFileSync(f, "utf8")).repos ?? {}) as Watermarks;
|
|
280
|
+
} catch {
|
|
281
|
+
return {};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function writeWatermark(repo: string, sha: string): void {
|
|
286
|
+
mkdirSync(ROOT, { recursive: true });
|
|
287
|
+
const all = readWatermarks();
|
|
288
|
+
all[repo] = { sha, at: Date.now() };
|
|
289
|
+
writeFileSync(watermarkFile(), `${JSON.stringify({ repos: all }, null, 2)}\n`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export const scanRecordEventsSchema = {
|
|
293
|
+
repo: z.string().min(1),
|
|
294
|
+
/** Defaults to the stored watermark; first run with none scans HEAD~1..HEAD. */
|
|
295
|
+
since: z.string().optional(),
|
|
296
|
+
/** Report only. Default true — same posture as `land` and `next_unblocked`. */
|
|
297
|
+
write: z.boolean().optional(),
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
export async function scanRecordEventsTool(args: { repo: string; since?: string; write?: boolean }) {
|
|
301
|
+
const repo = path.resolve(args.repo);
|
|
302
|
+
if (!existsSync(path.join(repo, ".git"))) {
|
|
303
|
+
return { ok: false as const, error: `'${repo}' is not a git repository — the commit is the boundary for a record change, so there is nothing to scan` };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const head = git(repo, ["rev-parse", "HEAD"]).trim();
|
|
307
|
+
if (!head) return { ok: false as const, error: `could not resolve HEAD in ${repo}` };
|
|
308
|
+
|
|
309
|
+
const stored = readWatermarks()[repo]?.sha;
|
|
310
|
+
const since = args.since ?? stored ?? `${head}~1`;
|
|
311
|
+
// A watermark from a rebased-away commit resolves to nothing. Say so rather
|
|
312
|
+
// than silently falling back to HEAD~1 and reporting a one-commit scan as if
|
|
313
|
+
// it covered the gap — that is the shape where a miss looks like a clean run.
|
|
314
|
+
if (!git(repo, ["cat-file", "-e", `${since}^{commit}`]) && !git(repo, ["rev-parse", "--verify", `${since}^{commit}`]).trim()) {
|
|
315
|
+
return {
|
|
316
|
+
ok: false as const,
|
|
317
|
+
error:
|
|
318
|
+
`base '${since}' does not resolve in ${repo} — it was probably rebased away. ` +
|
|
319
|
+
`Nothing was scanned and the watermark was NOT advanced: a scan that silently narrows its window ` +
|
|
320
|
+
`reports a clean run over the commits it never looked at. Pass an explicit 'since'.`,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const diff = git(repo, ["diff", "--unified=0", `${since}..${head}`, "--", "docs/"]);
|
|
325
|
+
const doneText = existsSync(path.join(repo, "docs/DONE.md")) ? readFileSync(path.join(repo, "docs/DONE.md"), "utf8") : "";
|
|
326
|
+
const phases: Record<string, string> = {};
|
|
327
|
+
for (const rel of git(repo, ["ls-files", "docs/phases/"]).split("\n").filter((p) => /PHASE[^/]*TASKS\.md$/i.test(p))) {
|
|
328
|
+
const p = path.join(repo, rel);
|
|
329
|
+
if (existsSync(p)) phases[rel] = readFileSync(p, "utf8");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const candidates = eventsFromCommittedChange(diff, { done: doneText, phases });
|
|
333
|
+
const unattributed = [...lastUnattributedItems];
|
|
334
|
+
|
|
335
|
+
// Every event is checked against the record AS IT NOW STANDS (6.2). A change
|
|
336
|
+
// that has since been reverted produces a candidate the record no longer
|
|
337
|
+
// supports, and it is refused — the stream can never claim what the
|
|
338
|
+
// authoritative markdown does not.
|
|
339
|
+
const emitted: RecordEvent[] = [];
|
|
340
|
+
const refused: string[] = [];
|
|
341
|
+
for (const ev of candidates) {
|
|
342
|
+
const recordText = ev.kind === "task" || ev.kind === "phase" ? Object.values(phases).join("\n") : doneText;
|
|
343
|
+
const derived = eventIsDerived(recordText, ev);
|
|
344
|
+
if (derived.ok) emitted.push(ev);
|
|
345
|
+
else refused.push(derived.error);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const deliveries: unknown[] = [];
|
|
349
|
+
if (args.write) {
|
|
350
|
+
let subs = readSubs();
|
|
351
|
+
const now = Date.now();
|
|
352
|
+
for (const ev of emitted) {
|
|
353
|
+
const r = evaluate(subs, ev, now);
|
|
354
|
+
subs = r.subs;
|
|
355
|
+
deliveries.push(...r.deliveries);
|
|
356
|
+
}
|
|
357
|
+
commitEvaluation(subs);
|
|
358
|
+
writeWatermark(repo, head);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
ok: true as const,
|
|
363
|
+
repo,
|
|
364
|
+
scanned: { from: since, to: head },
|
|
365
|
+
emitted,
|
|
366
|
+
refused,
|
|
367
|
+
deliveries,
|
|
368
|
+
...(unattributed.length
|
|
369
|
+
? {
|
|
370
|
+
unattributedItems: unattributed,
|
|
371
|
+
unattributedNote:
|
|
372
|
+
`${unattributed.length} queue item(s) left docs/QUEUE.md in this range without a done entry they could be tied to. ` +
|
|
373
|
+
`They emitted NOTHING: the markdown carries no link between an item and the entry that closes it, so attributing them ` +
|
|
374
|
+
`would be a guess. "Closed by an unknown PR" and "not closed" are different facts and this is the first.`,
|
|
375
|
+
}
|
|
376
|
+
: {}),
|
|
377
|
+
...(args.write
|
|
378
|
+
? { watermark: head }
|
|
379
|
+
: {
|
|
380
|
+
note:
|
|
381
|
+
"REPORT ONLY — nothing was delivered and the watermark was not advanced. Pass write:true to deliver. " +
|
|
382
|
+
"Re-scanning an already-delivered range is safe: the idempotency key is derived from the event, so it reports duplicate-suppressed.",
|
|
383
|
+
}),
|
|
384
|
+
kinds: EVENT_KINDS,
|
|
385
|
+
};
|
|
386
|
+
}
|