agent-coord-mcp 0.26.21 → 0.26.23
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/capabilities.js +270 -2
- package/dist/capabilities.js.map +1 -1
- package/dist/closing-line.js +83 -0
- package/dist/closing-line.js.map +1 -0
- package/dist/commit-cite.js +55 -0
- package/dist/commit-cite.js.map +1 -0
- package/dist/gated-head.js +67 -20
- package/dist/gated-head.js.map +1 -1
- package/dist/server-spread.js +195 -0
- package/dist/server-spread.js.map +1 -0
- package/dist/server.js +2 -2
- package/dist/server.js.map +1 -1
- package/dist/store.js +32 -0
- package/dist/store.js.map +1 -1
- package/dist/tools/away.js +67 -7
- package/dist/tools/away.js.map +1 -1
- package/dist/tools/board-ref.js +44 -4
- package/dist/tools/board-ref.js.map +1 -1
- package/dist/tools/event-kinds.js +5 -1
- package/dist/tools/event-kinds.js.map +1 -1
- package/dist/tools/events.js +40 -4
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/herdr-delivery.js +99 -0
- package/dist/tools/herdr-delivery.js.map +1 -0
- package/dist/tools/messaging.js +72 -6
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/record-events.js +85 -5
- package/dist/tools/record-events.js.map +1 -1
- package/dist/tools/records.js +310 -44
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +67 -3
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/seat-build.js +182 -0
- package/dist/tools/seat-build.js.map +1 -0
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/stall.js +1095 -18
- package/dist/tools/stall.js.map +1 -1
- package/dist/tools/transport.js +71 -3
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/worktrees.js +14 -0
- package/dist/tools/worktrees.js.map +1 -1
- package/dist/transports/herdr.js +297 -0
- package/dist/transports/herdr.js.map +1 -0
- package/dist/transports/index.js +10 -4
- package/dist/transports/index.js.map +1 -1
- package/package.json +1 -1
- package/scripts/coord-attention-clock.mjs +2 -0
- package/scripts/coord-stall-clock.mjs +52 -11
- package/src/capabilities.ts +284 -2
- package/src/closing-line.ts +85 -0
- package/src/commit-cite.ts +58 -0
- package/src/gated-head.ts +128 -26
- package/src/server-spread.ts +233 -0
- package/src/server.ts +2 -2
- package/src/store.ts +32 -0
- package/src/tools/away.ts +82 -9
- package/src/tools/board-ref.ts +70 -3
- package/src/tools/event-kinds.ts +19 -2
- package/src/tools/events.ts +42 -4
- package/src/tools/herdr-delivery.ts +87 -0
- package/src/tools/messaging.ts +71 -6
- package/src/tools/record-events.ts +78 -5
- package/src/tools/records.ts +316 -44
- package/src/tools/registry.ts +68 -4
- package/src/tools/seat-build.ts +201 -0
- package/src/tools/shared.ts +22 -0
- package/src/tools/stall.ts +1266 -23
- package/src/tools/transport.ts +69 -2
- package/src/tools/worktrees.ts +13 -0
- package/src/transports/herdr.ts +311 -0
- package/src/transports/index.ts +10 -4
package/src/tools/board-ref.ts
CHANGED
|
@@ -49,6 +49,16 @@ import { execFileSync } from "node:child_process";
|
|
|
49
49
|
export type BoardRefVerdict =
|
|
50
50
|
| { kind: "measurable"; ref: string }
|
|
51
51
|
| { kind: "empty"; why: string }
|
|
52
|
+
/**
|
|
53
|
+
* ⟨q-5d1c8e04⟩ — A DELIBERATE NO-BRANCH. The cell holds WORDS in the ref
|
|
54
|
+
* position (`docs-direct · no per-agent branch`, `own clone …`): a human
|
|
55
|
+
* statement that this lane carries no per-agent branch. Out of the stall
|
|
56
|
+
* clock's population, never "unmeasurable" — five seats wrote this shape
|
|
57
|
+
* because the grammar gave them no other way to say it.
|
|
58
|
+
*/
|
|
59
|
+
| { kind: "none"; why: string }
|
|
60
|
+
/** A ref a human ABBREVIATED for display (`…/kit-worker-lane`) — unreadable by construction, and said so. */
|
|
61
|
+
| { kind: "elided"; why: string }
|
|
52
62
|
| { kind: "path"; why: string }
|
|
53
63
|
| { kind: "shared"; why: string }
|
|
54
64
|
| { kind: "unscoped"; why: string }
|
|
@@ -84,9 +94,51 @@ const isSyntacticallyValidRef = (name: string): boolean => {
|
|
|
84
94
|
}
|
|
85
95
|
};
|
|
86
96
|
|
|
87
|
-
|
|
97
|
+
/*
|
|
98
|
+
* ⟨q-5d1c8e04⟩ — THE CELL GRAMMAR, read by POSITION rather than by scanning.
|
|
99
|
+
*
|
|
100
|
+
* \`ref\` · <path> the ref is the LEADING backticked token; what follows is a note
|
|
101
|
+
* — (or blank) nothing declared — on a lane row this is "lacks a branch"
|
|
102
|
+
* <prose> a deliberate statement: this row carries no per-agent branch
|
|
103
|
+
*
|
|
104
|
+
* `refInCell` used to take the FIRST backticked token ANYWHERE, so the aide's
|
|
105
|
+
* `**own clone \`groundwork-kit-aide-write\`** (ADR-016, \`a7bd341\`)` yielded a
|
|
106
|
+
* clone name that was then looked up as `origin/groundwork-kit-aide-write` and
|
|
107
|
+
* reported "unpushed" — a ref manufactured from prose. Measured live: five
|
|
108
|
+
* prose cells on one board, three of them reaching `unmeasurable` under three
|
|
109
|
+
* different mis-parses. A position cannot be mis-parsed that way.
|
|
110
|
+
*/
|
|
111
|
+
export type CellKind = "ref" | "empty" | "prose";
|
|
112
|
+
export function cellKindOf(cell: string): CellKind {
|
|
113
|
+
const s = String(cell ?? "").trim();
|
|
114
|
+
if (!s || s === "—" || s === "-" || s === "–") return "empty";
|
|
115
|
+
return /^`[^`]+`/.test(s) ? "ref" : "prose";
|
|
116
|
+
}
|
|
117
|
+
/** The `\`ref\`` in a `Branch · Worktree` cell's REF POSITION (leading token), or "". */
|
|
88
118
|
export function refInCell(cell: string): string {
|
|
89
|
-
return (String(cell ?? "").match(
|
|
119
|
+
return (String(cell ?? "").trim().match(/^`([^`]+)`/)?.[1] ?? "").trim();
|
|
120
|
+
}
|
|
121
|
+
/** A display elision a human typed into a machine-read field. */
|
|
122
|
+
const ELIDED = /…|\.\.\./;
|
|
123
|
+
|
|
124
|
+
/*
|
|
125
|
+
* ⟨q-5d1c8e04⟩ — LANES vs ROLES. A ROLE row is a standing seat (coordinator,
|
|
126
|
+
* aide) with nothing to score: no branch, no slice, no stall. It is written with
|
|
127
|
+
* its own status glyph and a `—` branch cell, in the same table. The glyph is
|
|
128
|
+
* a bus-level ROW KIND, not a work state: the seam's `workStateOf` reads it as
|
|
129
|
+
* `unknown`, which is correct — a role is not work, and the seam is not asked
|
|
130
|
+
* to invent a state for it.
|
|
131
|
+
*
|
|
132
|
+
* ⚠ A 🪑 ROW THAT CARRIES A REF IS A LANE IN DISGUISE and is scored as one —
|
|
133
|
+
* relabelling work as a role must not exempt it from the clock.
|
|
134
|
+
*/
|
|
135
|
+
export const ROLE_GLYPH = "🪑";
|
|
136
|
+
const STATUS_DECORATION = /^[\s*⭐]+/u;
|
|
137
|
+
export const isRoleStatus = (status: string): boolean => String(status ?? "").replace(STATUS_DECORATION, "").startsWith(ROLE_GLYPH);
|
|
138
|
+
export type RowKind = "lane" | "role" | "role-with-ref";
|
|
139
|
+
export function rowKindOf(row: { status: string; branchWorktree: string }): RowKind {
|
|
140
|
+
if (!isRoleStatus(row.status)) return "lane";
|
|
141
|
+
return cellKindOf(row.branchWorktree) === "ref" ? "role-with-ref" : "role";
|
|
90
142
|
}
|
|
91
143
|
|
|
92
144
|
/** The remote-tracking form a board cell should name for `branch`. */
|
|
@@ -106,7 +158,22 @@ export function classifyBoardRef(
|
|
|
106
158
|
base = "origin/main",
|
|
107
159
|
): BoardRefVerdict {
|
|
108
160
|
const raw = refInCell(cell);
|
|
109
|
-
if (!raw)
|
|
161
|
+
if (!raw) {
|
|
162
|
+
// Words in the ref position are a DECLARATION, not an absence: the row
|
|
163
|
+
// says it has no per-agent branch. Only a blank or `—` is "nothing said".
|
|
164
|
+
if (cellKindOf(cell) === "prose") {
|
|
165
|
+
return { kind: "none", why: `the Branch · Worktree cell declares in words that this row carries no per-agent branch: "${String(cell).trim().slice(0, 60)}"` };
|
|
166
|
+
}
|
|
167
|
+
return { kind: "empty", why: "no ref in the Branch · Worktree cell, and nothing declared in its place" };
|
|
168
|
+
}
|
|
169
|
+
if (ELIDED.test(raw)) {
|
|
170
|
+
return {
|
|
171
|
+
kind: "elided",
|
|
172
|
+
why:
|
|
173
|
+
`'${raw}' is a ref a human abbreviated for display — it contains an elision and cannot be looked up as written. ` +
|
|
174
|
+
`Unreadable by construction, not "unpushed": the real ref may well exist. Write the full ref in the cell; the note can carry the shape.`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
110
177
|
|
|
111
178
|
const bare = raw.replace(/^origin\//, "");
|
|
112
179
|
|
package/src/tools/event-kinds.ts
CHANGED
|
@@ -17,7 +17,20 @@
|
|
|
17
17
|
* widen without adding an emitter in the same file.
|
|
18
18
|
*/
|
|
19
19
|
export type SubKind = keyof typeof EVENT_KINDS;
|
|
20
|
-
|
|
20
|
+
/**
|
|
21
|
+
* ⟨q-2b7d9f04⟩ — EVERY KIND SHIPS WITH THE CHANGE THAT MUST PRODUCE IT. The
|
|
22
|
+
* `item` subscription sat at `health: ok` for three days while structurally
|
|
23
|
+
* unable to fire: an emitter existed, so the registry was satisfied, and the
|
|
24
|
+
* scanner RAN, so liveness was satisfied — but the grammar never read the
|
|
25
|
+
* leading item id `land` writes, so no live commit could ever produce the
|
|
26
|
+
* kind. A probe is the LIVE shape, not the smallest one: two items out, two
|
|
27
|
+
* leading-id entries in, one PR — the #328 commit. Health asks the scanner to
|
|
28
|
+
* produce each kind from its probe; a kind whose probe yields nothing CANNOT
|
|
29
|
+
* FIRE, whatever the emitter list and the scan clock say.
|
|
30
|
+
*/
|
|
31
|
+
export type KindProbe = { diff: string; after?: { done?: string; phases?: Record<string, string> } };
|
|
32
|
+
/** `refs`: ⟨q-cbace757⟩ — a closure cited by N PRs carries all N, each checked against the record on its own; `ref` stays the joined form for the event key. */
|
|
33
|
+
export type RecordEvent = { kind: SubKind; target: string; ref: string; summary: string; refs?: string[] };
|
|
21
34
|
|
|
22
35
|
/*
|
|
23
36
|
* THE KIND REGISTRY IS THE SINGLE SOURCE, AND THAT IS TASK 9.2.
|
|
@@ -37,23 +50,27 @@ export type RecordEvent = { kind: SubKind; target: string; ref: string; summary:
|
|
|
37
50
|
export const EVENT_KINDS = {
|
|
38
51
|
item: {
|
|
39
52
|
record: "docs/QUEUE.md + docs/DONE.md",
|
|
40
|
-
what: "a queue item closed —
|
|
53
|
+
what: "a queue item closed — a DONE.md entry beginning with its ⟨q-…⟩ id appeared (land writes it), or it left QUEUE.md against one unambiguous entry",
|
|
41
54
|
targetIs: "the queue item id",
|
|
55
|
+
probe: { diff: "diff --git a/docs/QUEUE.md b/docs/QUEUE.md\n--- a/docs/QUEUE.md\n+++ b/docs/QUEUE.md\n@@ -1,1 +1,1 @@\n-- [ ] (P1) ⟨q-0a0a0a01⟩ Kit: the first thing to do\n-- [ ] (P1) ⟨q-0a0a0a02⟩ Kit: the second thing to do\n\ndiff --git a/docs/DONE.md b/docs/DONE.md\n--- a/docs/DONE.md\n+++ b/docs/DONE.md\n@@ -1,1 +1,1 @@\n+- [x] ⟨q-0a0a0a01⟩ Kit: what was done for the first… — owner/repo#77 · 2026-09-14\n+- [x] ⟨q-0a0a0a02⟩ Kit: what was done for the second… — owner/repo#77 · 2026-09-14\n" } as KindProbe,
|
|
42
56
|
},
|
|
43
57
|
pr: {
|
|
44
58
|
record: "docs/DONE.md",
|
|
45
59
|
what: "a PR recorded in the completion log",
|
|
46
60
|
targetIs: "the PR ref, e.g. owner/repo#163",
|
|
61
|
+
probe: { diff: "diff --git a/docs/DONE.md b/docs/DONE.md\n--- a/docs/DONE.md\n+++ b/docs/DONE.md\n@@ -1,1 +1,1 @@\n+- [x] a PR recorded in the completion log — owner/repo#78 · 2026-09-14\n" } as KindProbe,
|
|
47
62
|
},
|
|
48
63
|
task: {
|
|
49
64
|
record: "docs/phases/**/PHASE*_TASKS.md",
|
|
50
65
|
what: "a phase task checkbox newly ticked",
|
|
51
66
|
targetIs: "the task key, e.g. 5:12.1",
|
|
67
|
+
probe: { diff: "diff --git a/docs/phases/phase9/PHASE9_TASKS.md b/docs/phases/phase9/PHASE9_TASKS.md\n--- a/docs/phases/phase9/PHASE9_TASKS.md\n+++ b/docs/phases/phase9/PHASE9_TASKS.md\n@@ -1,1 +1,1 @@\n-- [ ] 1.1 first\n+- [x] 1.1 first\n" } as KindProbe,
|
|
52
68
|
},
|
|
53
69
|
phase: {
|
|
54
70
|
record: "docs/phases/**/PHASE*_TASKS.md",
|
|
55
71
|
what: "the last open checkbox in a phase document ticked",
|
|
56
72
|
targetIs: "the phase number, e.g. 5",
|
|
73
|
+
probe: { diff: "diff --git a/docs/phases/phase9/PHASE9_TASKS.md b/docs/phases/phase9/PHASE9_TASKS.md\n--- a/docs/phases/phase9/PHASE9_TASKS.md\n+++ b/docs/phases/phase9/PHASE9_TASKS.md\n@@ -1,1 +1,1 @@\n-- [ ] 1.2 second\n+- [x] 1.2 second\n", after: { phases: { "docs/phases/phase9/PHASE9_TASKS.md": "# Phase 9\n\n- [x] 1.1 first\n- [x] 1.2 second\n" } } } as KindProbe,
|
|
57
74
|
},
|
|
58
75
|
} as const;
|
|
59
76
|
|
package/src/tools/events.ts
CHANGED
|
@@ -78,8 +78,33 @@ function writeSubs(subs: Subscription[]): void {
|
|
|
78
78
|
* subscription with no last-evaluated mark has produced no evidence of
|
|
79
79
|
* anything, and "no events" is the same output a broken subscription gives.
|
|
80
80
|
*/
|
|
81
|
+
/**
|
|
82
|
+
* ⟨q-2b7d9f04⟩ — CAPABILITY, NOT LIVENESS. The `item` subscription read
|
|
83
|
+
* `health: ok — no events yet` for three days while the scanner's grammar
|
|
84
|
+
* could not produce its kind from any commit: the scan clock was fresh, the
|
|
85
|
+
* emitter list had an entry, and the field answered the easier question. The
|
|
86
|
+
* probe is registered by record-events.ts (which owns the grammar) and asks
|
|
87
|
+
* "can this kind be produced at all?"; a kind that cannot is an ERROR, however
|
|
88
|
+
* recently the scanner ran. Absent probe → no claim either way.
|
|
89
|
+
*/
|
|
90
|
+
let capabilityProbe: ((kind: SubKind) => boolean) | null = null;
|
|
91
|
+
export function setCapabilityProbe(fn: ((kind: SubKind) => boolean) | null): void {
|
|
92
|
+
capabilityProbe = fn;
|
|
93
|
+
}
|
|
94
|
+
export const kindCanFire = (kind: SubKind): boolean | null => (capabilityProbe ? capabilityProbe(kind) : null);
|
|
95
|
+
|
|
81
96
|
export function subscriptionHealth(s: Subscription): { level: "ok" | "error" | "unknown"; detail: string } {
|
|
82
97
|
const iso = (n: number) => new Date(n).toISOString();
|
|
98
|
+
// ⟨q-2b7d9f04⟩ — a kind the grammar cannot produce is not ok, scanned or not.
|
|
99
|
+
if (kindCanFire(s.kind) === false) {
|
|
100
|
+
return {
|
|
101
|
+
level: "error",
|
|
102
|
+
detail:
|
|
103
|
+
`CANNOT FIRE — the scanner's grammar produces no '${s.kind}' event from the kind's own probe, so this subscription is structurally unable to deliver ` +
|
|
104
|
+
`${s.lastScannedAt ? `(scanned ${iso(s.lastScannedAt)}: the clock is live, the capability is not)` : "(and has never been scanned)"}. ` +
|
|
105
|
+
"Never fired and cannot fire are different facts; this is the second.",
|
|
106
|
+
};
|
|
107
|
+
}
|
|
83
108
|
|
|
84
109
|
// THREE STATES, BECAUSE THERE ARE THREE FACTS. They were two, and the
|
|
85
110
|
// collapse cost a week: an `item` subscriber read `error — never evaluated`
|
|
@@ -152,11 +177,18 @@ export const eventKey = (kind: SubKind, target: string, ref: string): string =>
|
|
|
152
177
|
*/
|
|
153
178
|
export function eventIsDerived(recordText: string, ev: RecordEvent): { ok: true } | { ok: false; error: string } {
|
|
154
179
|
if (!ev.ref) return { ok: false, error: `event for ${ev.kind} ${ev.target} carries no ref — nothing ties it to a record entry` };
|
|
155
|
-
|
|
180
|
+
// ⟨q-cbace757⟩ — a multi-PR closer is N citations, checked ONE BY ONE. The
|
|
181
|
+
// joined string ("a#1, a#2, a#3") is how the event is keyed, not how the record
|
|
182
|
+
// is read: a DONE line citing the same three PRs in another arrangement carries
|
|
183
|
+
// every ref and none of the joined form. The guard stays exactly as strict per
|
|
184
|
+
// ref — a citation absent from the record still refuses, and is named.
|
|
185
|
+
const refs = ev.refs?.length ? ev.refs : [ev.ref];
|
|
186
|
+
const missing = refs.filter((r) => !String(recordText).includes(r));
|
|
187
|
+
if (missing.length)
|
|
156
188
|
return {
|
|
157
189
|
ok: false,
|
|
158
190
|
error:
|
|
159
|
-
`refusing to emit ${ev.kind} ${ev.target}: its ref ${ev.ref} is NOT in the record. ` +
|
|
191
|
+
`refusing to emit ${ev.kind} ${ev.target}: ${missing.length === refs.length && refs.length === 1 ? `its ref ${ev.ref}` : `${missing.length} of its ${refs.length} cited ref(s) — ${missing.join(", ")} —`} is NOT in the record. ` +
|
|
160
192
|
`An event that exists without the record change that caused it is a second source of truth — ` +
|
|
161
193
|
`the stream would claim something the authoritative document does not.`,
|
|
162
194
|
};
|
|
@@ -258,10 +290,16 @@ export async function listSubscriptionsTool(args: { agentId?: string }) {
|
|
|
258
290
|
const all = readSubs();
|
|
259
291
|
const subs = args.agentId ? all.filter((s) => s.agentId === args.agentId) : all;
|
|
260
292
|
const rows = subs.map((s) => ({ ...s, health: subscriptionHealth(s) }));
|
|
261
|
-
const
|
|
293
|
+
const cannotFire = rows.filter((r) => r.health.level === "error" && /^CANNOT FIRE/.test(r.health.detail));
|
|
294
|
+
const neverEvaluated = rows.filter((r) => r.health.level === "error" && !/^CANNOT FIRE/.test(r.health.detail));
|
|
262
295
|
const undetermined = rows.filter((r) => r.health.level === "unknown");
|
|
263
296
|
return {
|
|
264
|
-
ok: neverEvaluated.length === 0,
|
|
297
|
+
ok: neverEvaluated.length === 0 && cannotFire.length === 0,
|
|
298
|
+
// ⟨q-2b7d9f04⟩ — capability per kind, beside each row's liveness.
|
|
299
|
+
capability: Object.fromEntries(EVENT_KIND_IDS.map((k) => [k, kindCanFire(k)])),
|
|
300
|
+
...(cannotFire.length
|
|
301
|
+
? { cannotFire: `${cannotFire.length} of ${rows.length} subscription(s) are to a kind the scanner CANNOT PRODUCE — they will never fire, however live the scan clock reads: ${[...new Set(cannotFire.map((r) => r.kind))].join(", ")}.` }
|
|
302
|
+
: {}),
|
|
265
303
|
// Population beside the verdict, always: "no subscriptions" and "none
|
|
266
304
|
// listed for you" are different claims.
|
|
267
305
|
population: { listed: rows.length, total: all.length },
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IN-PROCESS DELIVERY FOR HERDR SEATS (Phase 5.4 Task 4, box 4.4).
|
|
3
|
+
*
|
|
4
|
+
* A tmux seat has a pusher that tails its inbox and the rooms it joined, formats a batch,
|
|
5
|
+
* types it into the pane and advances the PUSH cursor once the paste is verified submitted
|
|
6
|
+
* (hooks/tmux-pusher.mjs + hooks/push-cursor.mjs). A herdr seat has no pusher, so the
|
|
7
|
+
* server does the same at SEND time: the message it just appended is formatted by the very
|
|
8
|
+
* same formatter the pusher uses (hooks/tier.mjs — one renderer, so the pane sees one
|
|
9
|
+
* shape on either transport), pushed through the transport, and ONLY on a verified
|
|
10
|
+
* delivery is the seat's push cursor advanced past it.
|
|
11
|
+
*
|
|
12
|
+
* THE CURSOR RULE IS THE PUSHER'S, UNCHANGED (⟨q-7be94b5e⟩): a paste that did not reach the
|
|
13
|
+
* pane must not mark the message consumed. The push cursor is advanced to the offset the
|
|
14
|
+
* append produced, and never touched when the transport reports not-delivered — the
|
|
15
|
+
* message stays where `read_messages` still serves it. The READ cursor is the agent's and
|
|
16
|
+
* is never written here.
|
|
17
|
+
*
|
|
18
|
+
* Simplification stated: the pusher batches routine room traffic into digests on a
|
|
19
|
+
* debounce; a herdr seat receives each message as it is sent, in the digest FORMAT (the
|
|
20
|
+
* `[agent-coord] …` banner and the attributed line) but one at a time. Tiers still decide
|
|
21
|
+
* the banner text. Batching for herdr is Task 5 territory if it is wanted.
|
|
22
|
+
*/
|
|
23
|
+
import { statSync } from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { pathToFileURL } from "node:url";
|
|
26
|
+
import { activeTransport, HERDR, type TransportMarker } from "../transports/index.js";
|
|
27
|
+
import { ROOT, inboxFile, roomFile } from "../store.js";
|
|
28
|
+
import { loadLiveTransports } from "./registry.js";
|
|
29
|
+
import type { Message } from "./shared.js";
|
|
30
|
+
|
|
31
|
+
type Tier = { formatBatch: (batch: unknown[], agentId: string, rooms: string[]) => string; classifyTier: (m: unknown, opts?: unknown) => string };
|
|
32
|
+
type PushCursor = { readPushCursor: (root: string, safeId: string) => Record<string, unknown>; writePushCursor: (root: string, safeId: string, c: Record<string, unknown>) => void };
|
|
33
|
+
let hooks: Promise<{ tier: Tier; cursor: PushCursor }> | undefined;
|
|
34
|
+
function loadHooks(): Promise<{ tier: Tier; cursor: PushCursor }> {
|
|
35
|
+
hooks ??= (async () => {
|
|
36
|
+
const base = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", "..", "hooks");
|
|
37
|
+
const tier = (await import(pathToFileURL(path.join(base, "tier.mjs")).href)) as Tier;
|
|
38
|
+
const cursor = (await import(pathToFileURL(path.join(base, "push-cursor.mjs")).href)) as PushCursor;
|
|
39
|
+
return { tier, cursor };
|
|
40
|
+
})();
|
|
41
|
+
return hooks;
|
|
42
|
+
}
|
|
43
|
+
const safeId = (id: string) => id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
44
|
+
|
|
45
|
+
export type HerdrDeliveryOutcome = { agentId: string; delivered: boolean; enters?: number; error?: string; cursorAdvanced: boolean };
|
|
46
|
+
|
|
47
|
+
/** Deliver one just-appended message to every herdr-attached recipient among `recipients`. */
|
|
48
|
+
export async function deliverToHerdrSeats(msg: Message, recipients: string[], where: { kind: "dm" } | { kind: "room"; chan: string }): Promise<HerdrDeliveryOutcome[]> {
|
|
49
|
+
const t = activeTransport();
|
|
50
|
+
if (!t || t.kind !== HERDR) return [];
|
|
51
|
+
let markers: Map<string, TransportMarker>;
|
|
52
|
+
try { markers = await loadLiveTransports(); } catch { return []; }
|
|
53
|
+
const out: HerdrDeliveryOutcome[] = [];
|
|
54
|
+
for (const agentId of recipients) {
|
|
55
|
+
const marker = markers.get(agentId);
|
|
56
|
+
if (!marker || marker.transport !== HERDR) continue;
|
|
57
|
+
if (where.kind === "room" && marker.rooms === false) continue;
|
|
58
|
+
if (msg.from === agentId) continue;
|
|
59
|
+
const { tier, cursor } = await loadHooks();
|
|
60
|
+
const tagged = { ...msg, tag: where.kind === "dm" ? "DM" : `room #${where.chan}` };
|
|
61
|
+
const rendered = tier.formatBatch([{ ...tagged, tier: tier.classifyTier(tagged) }], agentId, where.kind === "room" ? [where.chan] : []);
|
|
62
|
+
// A control never comes through here: send_command hands a herdr seat's control to the
|
|
63
|
+
// transport's sendControl directly (a raw-vs-rendered branch here was dead and its
|
|
64
|
+
// mutation survived, so it is gone). Everything delivered here is a rendered message.
|
|
65
|
+
let r: { delivered: boolean; error?: string; enters?: number };
|
|
66
|
+
try { r = await t.push(marker, rendered); } catch (e) { r = { delivered: false, error: (e as Error).message }; }
|
|
67
|
+
let cursorAdvanced = false;
|
|
68
|
+
if (r.delivered) {
|
|
69
|
+
try {
|
|
70
|
+
const file = where.kind === "dm" ? inboxFile(agentId) : roomFile(where.chan);
|
|
71
|
+
const size = statSync(file).size;
|
|
72
|
+
const id = safeId(agentId);
|
|
73
|
+
const c = cursor.readPushCursor(ROOT, id) ?? {};
|
|
74
|
+
if (where.kind === "dm") c.inboxOffset = Math.max(Number(c.inboxOffset ?? 0), size);
|
|
75
|
+
else {
|
|
76
|
+
const offsets = ((c.roomOffsets as Record<string, number> | undefined) ?? {});
|
|
77
|
+
offsets[where.chan] = Math.max(Number(offsets[where.chan] ?? 0), size);
|
|
78
|
+
c.roomOffsets = offsets;
|
|
79
|
+
}
|
|
80
|
+
cursor.writePushCursor(ROOT, id, c);
|
|
81
|
+
cursorAdvanced = true;
|
|
82
|
+
} catch { cursorAdvanced = false; }
|
|
83
|
+
}
|
|
84
|
+
out.push({ agentId, delivered: r.delivered, enters: r.enters, error: r.error, cursorAdvanced });
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
package/src/tools/messaging.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { promises as fsp } from "node:fs";
|
|
|
13
13
|
import { spawn, spawnSync } from "node:child_process";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { z } from "zod";
|
|
16
|
+
import { checkClosingLine } from "../closing-line.js";
|
|
16
17
|
import { readLog } from "./logwatch.js";
|
|
17
18
|
// The replay grammar is single-sourced in hooks/replay.mjs and shared with both
|
|
18
19
|
// pushers — see the note at the annotation site below. `hooks/` ships beside
|
|
@@ -82,6 +83,9 @@ import {
|
|
|
82
83
|
MAX_WAIT_MS,
|
|
83
84
|
isDecision,
|
|
84
85
|
} from "./shared.js";
|
|
86
|
+
import { isKnownHuman } from "../store.js";
|
|
87
|
+
import { deliverToHerdrSeats } from "./herdr-delivery.js";
|
|
88
|
+
import { verifyCommitCite } from "../commit-cite.js";
|
|
85
89
|
|
|
86
90
|
// ---------- send_message ----------
|
|
87
91
|
|
|
@@ -110,10 +114,18 @@ const decisionPayload = z.looseObject({
|
|
|
110
114
|
ifNoAction: z.string().min(1),
|
|
111
115
|
});
|
|
112
116
|
|
|
117
|
+
// ⟨q-dcbaf544⟩ — `gatedBy` is the seat that JUDGED when it is not the sender;
|
|
118
|
+
// `scribe` is the sender that TRANSCRIBED it. Fields, not prose: a name in
|
|
119
|
+
// prose is a mention, a name in a field is a position, and only the position
|
|
120
|
+
// survives a scanner. Authority does not move (David's ruling 2026-09-14):
|
|
121
|
+
// the roles that may emit `verdict` are unchanged; this is how a gate routed
|
|
122
|
+
// to any other seat reaches the record without lying about who gated.
|
|
113
123
|
const verdictPayload = z.looseObject({
|
|
114
124
|
result: z.enum(["pass", "fail"]),
|
|
115
125
|
headRefOid: z.string().min(1),
|
|
116
126
|
notes: z.string().optional(),
|
|
127
|
+
gatedBy: z.string().min(1).optional(),
|
|
128
|
+
scribe: z.string().min(1).optional(),
|
|
117
129
|
});
|
|
118
130
|
|
|
119
131
|
// Discriminated on `type`, so an unknown type is rejected outright while a
|
|
@@ -170,6 +182,21 @@ export async function checkRecordAuthority(
|
|
|
170
182
|
};
|
|
171
183
|
}
|
|
172
184
|
|
|
185
|
+
/** ⟨q-dcbaf544⟩ — `scribe`, when present, must be the sender; `gatedBy` may name anyone (that is its point). */
|
|
186
|
+
export function checkVerdictScribe(from: string, record: MessageRecord | undefined): { ok: true } | { ok: false; error: string } {
|
|
187
|
+
if (!record || record.type !== "verdict") return { ok: true };
|
|
188
|
+
const p = record.payload as { scribe?: string; gatedBy?: string } | undefined;
|
|
189
|
+
if (p?.scribe && p.scribe !== from) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
error:
|
|
193
|
+
`verdict payload.scribe is '${p.scribe}' but the sender is '${from}' — the scribe is the seat that SENDS the record. ` +
|
|
194
|
+
`Put the seat that judged in payload.gatedBy and either omit scribe or set it to '${from}'.`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return { ok: true };
|
|
198
|
+
}
|
|
199
|
+
|
|
173
200
|
// ---------- typed records obligatory (Phase 5.1 Task 12) ----------
|
|
174
201
|
|
|
175
202
|
// An untyped agent→agent message must not be able to EXIST. Enforced HERE, at
|
|
@@ -202,7 +229,10 @@ async function typedRecordCheck(args: {
|
|
|
202
229
|
// David-facing prose stays prose. An UNREGISTERED recipient is treated as an
|
|
203
230
|
// agent, not as a human: the safe reading of "I cannot tell" is the rule, and
|
|
204
231
|
// a human on this bus has a registry entry (that is how the pane is found).
|
|
205
|
-
|
|
232
|
+
// ⟨q-178878aa⟩ — and the human need not be a REGISTERED agent: a registry entry for a
|
|
233
|
+
// human is evicted after EVICT_MS (no heartbeat), so the exemption keys on the durable
|
|
234
|
+
// human set the server knows (humans.json + AGENT_COORD_HUMANS), read here, at send time.
|
|
235
|
+
if (args.to && (isHuman(reg[args.to]) || (await isKnownHuman(args.to)))) return { ok: true };
|
|
206
236
|
|
|
207
237
|
const sender = reg[args.from];
|
|
208
238
|
if (sender?.proseOnly) return { ok: true };
|
|
@@ -227,7 +257,8 @@ async function typedRecordCheck(args: {
|
|
|
227
257
|
`${guidance} ` +
|
|
228
258
|
`Types: decision · verdict · done · blocker · risk · fyi · action · go · scope; 'fyi' is the honest ` +
|
|
229
259
|
`catch-all — do not force a false 'decision'/'risk' to get past this. ` +
|
|
230
|
-
`Messages TO a human are exempt,
|
|
260
|
+
`Messages TO a human are exempt (a recipient registered with a human role, or an id the server knows as human: ` +
|
|
261
|
+
`humans.json / AGENT_COORD_HUMANS — see list_agents.humans), and an agent that cannot pick a type declares proseOnly:true at join.`,
|
|
231
262
|
};
|
|
232
263
|
}
|
|
233
264
|
|
|
@@ -245,6 +276,9 @@ export const sendMessageSchema = {
|
|
|
245
276
|
// unknown → store + warn) so the rejection shape matches identity-binding
|
|
246
277
|
// / unknown-recipient, not a schema throw. Existing records unchanged.
|
|
247
278
|
inReplyTo: z.string().optional(),
|
|
279
|
+
// ⟨q-cc0819dc⟩ — needed only for a `done` cited by COMMIT: the repository whose
|
|
280
|
+
// origin/main must carry the sha (one local git call, no network).
|
|
281
|
+
repo: z.string().optional(),
|
|
248
282
|
};
|
|
249
283
|
|
|
250
284
|
const MESSAGE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -298,25 +332,47 @@ export async function sendMessageTool(args: {
|
|
|
298
332
|
kind?: "decision" | "status" | "chatter";
|
|
299
333
|
record?: MessageRecord;
|
|
300
334
|
inReplyTo?: string;
|
|
335
|
+
repo?: string;
|
|
301
336
|
}) {
|
|
302
337
|
// Record authority first — a sender who may not emit this type is refused
|
|
303
338
|
// before any other check runs, so a rejected record writes nothing anywhere.
|
|
304
339
|
const authority = await checkRecordAuthority(args.from, args.record);
|
|
305
340
|
if (!authority.ok) return { ok: false as const, error: authority.error };
|
|
341
|
+
// ⟨q-dcbaf544⟩ — a scribe is the sender by definition; a verdict whose
|
|
342
|
+
// `scribe` names someone else has its two positions crossed and would be
|
|
343
|
+
// attributed wrongly by every reader. Refused as a value, nothing written.
|
|
344
|
+
const scribeCheck = checkVerdictScribe(args.from, args.record);
|
|
345
|
+
if (!scribeCheck.ok) return { ok: false as const, error: scribeCheck.error };
|
|
306
346
|
|
|
307
347
|
// A `done` must cite the work it claims. Presence and shape only — resolving
|
|
308
348
|
// the ref against gh/git is a consumer's job, and the send path makes no
|
|
309
349
|
// network calls. Rejected as a value, not a throw, mirroring the
|
|
310
350
|
// identity-binding rejection in src/server.ts.
|
|
311
351
|
if (args.record?.type === "done") {
|
|
312
|
-
|
|
313
|
-
|
|
352
|
+
// ⟨q-fee7239f⟩ — the same check that reads the closing citation reads the
|
|
353
|
+
// closing GRAMMAR: a merge closing may not assert a deletion it has not
|
|
354
|
+
// read. Refused as a value, naming the two accepted forms; nothing written.
|
|
355
|
+
const closing = checkClosingLine(String(args.text ?? ""));
|
|
356
|
+
if (!closing.ok) return { ok: false as const, error: closing.error };
|
|
357
|
+
const cites = args.record.cites ?? [];
|
|
358
|
+
const hasPr = cites.some((c) => c.kind === "pr" && c.ref.trim().length > 0);
|
|
359
|
+
const commitCites = cites.filter((c) => c.kind === "commit");
|
|
360
|
+
if (!hasPr && commitCites.length === 0) {
|
|
314
361
|
return {
|
|
315
362
|
ok: false as const,
|
|
316
363
|
error:
|
|
317
|
-
"a 'done' record must carry at least one {kind:'pr'} citation — an uncited DONE is an unverifiable claim"
|
|
364
|
+
"a 'done' record must carry at least one {kind:'pr'} citation — an uncited DONE is an unverifiable claim. " +
|
|
365
|
+
"Work that has no PR by rule (docs pushed straight to the shared branch) cites {kind:'commit', ref:<full 40-hex sha>} " +
|
|
366
|
+
"and passes `repo`, the repository whose origin/main carries it.",
|
|
318
367
|
};
|
|
319
368
|
}
|
|
369
|
+
// ⟨q-cc0819dc⟩ — a COMMIT cite satisfies a `done` only when the commit is real and on the
|
|
370
|
+
// shared branch: every commit cite is verified, so a fabricated or short sha is refused
|
|
371
|
+
// by name and nothing is written. A PR cite beside it does not excuse a bad commit cite.
|
|
372
|
+
for (const c of commitCites) {
|
|
373
|
+
const v = verifyCommitCite(c.ref, args.repo);
|
|
374
|
+
if (!v.ok) return { ok: false as const, error: `a 'done' record cited by commit was refused — ${v.why}` };
|
|
375
|
+
}
|
|
320
376
|
}
|
|
321
377
|
|
|
322
378
|
// `text` is what every consumer reads, so it must exist. The author's
|
|
@@ -399,13 +455,18 @@ export async function sendMessageTool(args: {
|
|
|
399
455
|
};
|
|
400
456
|
const target = inboxFile(args.to);
|
|
401
457
|
await appendJsonl(target, msg);
|
|
458
|
+
// Phase 5.4 Task 4 — a herdr-attached recipient has no pusher: deliver now, in-process.
|
|
459
|
+
const herdr = await deliverToHerdrSeats(msg, [args.to], { kind: "dm" });
|
|
460
|
+
void herdr;
|
|
402
461
|
// Offline delivery is intentional (the inbox is created on demand), but a
|
|
403
462
|
// typo'd recipient shouldn't vanish silently — surface a warning when the
|
|
404
463
|
// target isn't a known agent so the caller can catch the mistake.
|
|
405
464
|
const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
|
|
406
465
|
const recipientWarning = reg[args.to]
|
|
407
466
|
? undefined
|
|
408
|
-
:
|
|
467
|
+
: (await isKnownHuman(args.to))
|
|
468
|
+
? undefined // a human is not an agent and is not expected to be registered; the inbox is theirs to read
|
|
469
|
+
: `recipient '${args.to}' is not a registered agent — message stored in their inbox but no one may be listening`;
|
|
409
470
|
const warning = [typedWarning, recipientWarning, replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
|
|
410
471
|
return { ok: true, id: msg.id, target, room: undefined, warning };
|
|
411
472
|
}
|
|
@@ -425,6 +486,10 @@ export async function sendMessageTool(args: {
|
|
|
425
486
|
};
|
|
426
487
|
const target = roomFile(chan);
|
|
427
488
|
await appendJsonl(target, msg);
|
|
489
|
+
// Phase 5.4 Task 4 — herdr-attached members with rooms on receive the post now, in-process.
|
|
490
|
+
const roomMembers = (await getRooms())[chan]?.members ?? [];
|
|
491
|
+
const herdrRoom = await deliverToHerdrSeats(msg, roomMembers, { kind: "room", chan });
|
|
492
|
+
void herdrRoom;
|
|
428
493
|
await maybeCompactRoom(chan);
|
|
429
494
|
const roomWarning = [typedWarning, replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
|
|
430
495
|
return { ok: true, id: msg.id, target, room: chan, ...(roomWarning ? { warning: roomWarning } : {}) };
|
|
@@ -159,8 +159,28 @@ export function eventsFromCommittedChange(
|
|
|
159
159
|
// first PR it saw, which is a wrong claim about fourteen of them, and a wrong
|
|
160
160
|
// claim on this bus is worse than a missing one.
|
|
161
161
|
const removedQueue = linesWhere(byFile, isQueue, "removed");
|
|
162
|
+
// ⟨q-d527f435⟩ — A REMOVED LINE IS NOT A DEPARTED ITEM. The scan diffed text,
|
|
163
|
+
// so the aide's in-place rewrite of q-a1c9d4e7 (70c730a) read as a departure
|
|
164
|
+
// and was reported unattributed. The question is whether the ⟨q-…⟩ id is
|
|
165
|
+
// still present at the end of the range; three cases, never conflated:
|
|
166
|
+
// id still present in the ADDED lines → an EDIT — no event, not unattributed
|
|
167
|
+
// id absent → a DEPARTURE — attributed or reported
|
|
168
|
+
// id CHANGED (same text, new id) → a RE-ID — reported as such (⟨q-1c4f8ae3⟩)
|
|
169
|
+
const addedQueue = linesWhere(byFile, isQueue, "added");
|
|
170
|
+
// Only rows still OPEN count as present: the house style closes an item by
|
|
171
|
+
// flipping `[ ]` → `[x]` in place, and that row is closed, not edited.
|
|
172
|
+
const addedItems = (queueItemsOf(parseWorkDoc(`## Queue\n${addedQueue.join("\n")}\n`)) as Array<{ id?: string; text?: string; done?: boolean }>).filter((i) => i.id && !i.done);
|
|
173
|
+
const addedIds = new Set(addedItems.map((i) => i.id!));
|
|
174
|
+
const reIdentified: { from: string; to: string }[] = [];
|
|
162
175
|
const removedItems = (queueItemsOf(parseWorkDoc(`## Queue\n${removedQueue.join("\n")}\n`)) as Array<{ id?: string; text?: string }>)
|
|
163
|
-
.filter((i) => i.id)
|
|
176
|
+
.filter((i) => i.id)
|
|
177
|
+
.filter((i) => {
|
|
178
|
+
if (addedIds.has(i.id!)) return false; // edited in place: still on the queue
|
|
179
|
+
const twin = addedItems.find((a) => a.id !== i.id && norm(a.text ?? "") === norm(i.text ?? "") && norm(i.text ?? ""));
|
|
180
|
+
if (twin) { reIdentified.push({ from: i.id!, to: twin.id! }); return false; }
|
|
181
|
+
return true;
|
|
182
|
+
});
|
|
183
|
+
lastReIdentifiedItems = reIdentified;
|
|
164
184
|
// An entry qualifies to CLOSE an item if it cites anything resolvable — a PR,
|
|
165
185
|
// or an `@sha` commit. `land` requires a PR by rule; the scan reads what the
|
|
166
186
|
// record actually says, and a commit-cited entry is still the record stating
|
|
@@ -168,7 +188,24 @@ export function eventsFromCommittedChange(
|
|
|
168
188
|
const citedEntries = newEntries.filter((e) => prRefsIn(e.ref).length > 0 || hasCommitRef(e.ref ?? ""));
|
|
169
189
|
const unattributed: string[] = [];
|
|
170
190
|
|
|
191
|
+
// ⟨q-2b7d9f04⟩ — THE LINK THE MARKDOWN DOES CARRY. `land` writes the item's
|
|
192
|
+
// id in LEADING position on the DONE line (#286), and a hand-written line in
|
|
193
|
+
// land's format carries it the same way. An entry that begins with ⟨q-…⟩ IS
|
|
194
|
+
// the record of that item closing — read by position, never guessed. The
|
|
195
|
+
// `item` subscription never fired in three days because this read was
|
|
196
|
+
// missing while the paragraph above said no link existed.
|
|
197
|
+
const attributedByLeadingId = new Set<string>();
|
|
198
|
+
for (const entry of citedEntries) {
|
|
199
|
+
const id = leadingItemIdOf(entry.text);
|
|
200
|
+
if (!id || attributedByLeadingId.has(id)) continue;
|
|
201
|
+
const ref = prRefsIn(entry.ref)[0] ?? entry.ref?.trim();
|
|
202
|
+
if (!ref) continue;
|
|
203
|
+
attributedByLeadingId.add(id);
|
|
204
|
+
events.push({ kind: "item", target: id, ref, summary: entry.text?.slice(0, 120) ?? id });
|
|
205
|
+
}
|
|
206
|
+
|
|
171
207
|
for (const item of removedItems) {
|
|
208
|
+
if (attributedByLeadingId.has(item.id!)) continue;
|
|
172
209
|
const itemText = norm(item.text ?? "");
|
|
173
210
|
let entry = itemText
|
|
174
211
|
? citedEntries.find((e) => {
|
|
@@ -241,6 +278,36 @@ export function eventsFromCommittedChange(
|
|
|
241
278
|
* different facts, and only one of them needs a human.
|
|
242
279
|
*/
|
|
243
280
|
export let lastUnattributedItems: string[] = [];
|
|
281
|
+
/** ⟨q-d527f435⟩ — removed rows whose TEXT reappeared under a NEW id in the same change: re-identified, not departed. */
|
|
282
|
+
export let lastReIdentifiedItems: { from: string; to: string }[] = [];
|
|
283
|
+
/** ⟨q-2b7d9f04⟩ — the ⟨q-…⟩ id in LEADING position on a DONE entry's text (bold allowed), or null. Position, not occurrence: an id quoted mid-sentence is a mention. */
|
|
284
|
+
export function leadingItemIdOf(text: string | undefined): string | null {
|
|
285
|
+
const m = /^\s*(?:\*\*)?⟨(q-[0-9a-f]{8})⟩/.exec(String(text ?? ""));
|
|
286
|
+
return m ? m[1]! : null;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* ⟨q-2b7d9f04⟩ — CAN THIS KIND FIRE AT ALL? Runs the scanner over the kind's
|
|
290
|
+
* own probe (event-kinds.ts) and asks whether an event of that kind comes out.
|
|
291
|
+
* Health reads this as CAPABILITY, beside the scan clock's LIVENESS: a kind
|
|
292
|
+
* whose probe yields nothing cannot be produced by the grammar, and a
|
|
293
|
+
* subscription to it is not `ok` however recently the scanner ran.
|
|
294
|
+
*/
|
|
295
|
+
export function kindCapability(kind: SubKind): { capable: boolean; produced: number; why: string } {
|
|
296
|
+
const probe = (EVENT_KINDS[kind] as { probe?: { diff: string; after?: { done?: string; phases?: Record<string, string> } } }).probe;
|
|
297
|
+
if (!probe) return { capable: false, produced: 0, why: `'${kind}' ships no probe — its capability cannot be shown` };
|
|
298
|
+
const saved = lastUnattributedItems;
|
|
299
|
+
let produced = 0;
|
|
300
|
+
try {
|
|
301
|
+
produced = eventsFromCommittedChange(probe.diff, probe.after ?? {}).filter((e) => e.kind === kind).length;
|
|
302
|
+
} finally {
|
|
303
|
+
lastUnattributedItems = saved;
|
|
304
|
+
}
|
|
305
|
+
return produced > 0
|
|
306
|
+
? { capable: true, produced, why: `the scanner produced ${produced} '${kind}' event(s) from the kind's own probe` }
|
|
307
|
+
: { capable: false, produced: 0, why: `the scanner produced NO '${kind}' event from the kind's own probe — this kind CANNOT FIRE on any commit, whatever the scan clock says` };
|
|
308
|
+
}
|
|
309
|
+
export const kindCapabilities = (): Record<SubKind, ReturnType<typeof kindCapability>> =>
|
|
310
|
+
Object.fromEntries((Object.keys(EVENT_KINDS) as SubKind[]).map((k) => [k, kindCapability(k)])) as Record<SubKind, ReturnType<typeof kindCapability>>;
|
|
244
311
|
|
|
245
312
|
/** `git` in a repo, returning "" rather than throwing — a scan is read-only. */
|
|
246
313
|
export function git(repo: string, args: string[]): string {
|
|
@@ -257,7 +324,9 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
|
257
324
|
import path from "node:path";
|
|
258
325
|
import { z } from "zod";
|
|
259
326
|
import { ROOT } from "../store.js";
|
|
260
|
-
import { readSubs, evaluate, commitEvaluation, eventIsDerived, markScanned } from "./events.js";
|
|
327
|
+
import { readSubs, evaluate, commitEvaluation, eventIsDerived, markScanned, setCapabilityProbe } from "./events.js";
|
|
328
|
+
// ⟨q-2b7d9f04⟩ — health consults the scanner's grammar through this hook (events.ts cannot import this module: the kinds are its leaf and record-events imports events).
|
|
329
|
+
setCapabilityProbe((kind) => kindCapability(kind).capable);
|
|
261
330
|
|
|
262
331
|
/**
|
|
263
332
|
* The watermark: the last commit whose record change has been turned into
|
|
@@ -333,6 +402,7 @@ export async function scanRecordEventsTool(args: { repo: string; since?: string;
|
|
|
333
402
|
|
|
334
403
|
const candidates = eventsFromCommittedChange(diff, { done: doneText, phases });
|
|
335
404
|
const unattributed = [...lastUnattributedItems];
|
|
405
|
+
const reIdentified = [...lastReIdentifiedItems];
|
|
336
406
|
|
|
337
407
|
// Every event is checked against the record AS IT NOW STANDS (6.2). A change
|
|
338
408
|
// that has since been reverted produces a candidate the record no longer
|
|
@@ -378,11 +448,14 @@ export async function scanRecordEventsTool(args: { repo: string; since?: string;
|
|
|
378
448
|
? {
|
|
379
449
|
unattributedItems: unattributed,
|
|
380
450
|
unattributedNote:
|
|
381
|
-
`${unattributed.length} queue item(s) left docs/QUEUE.md in this range
|
|
382
|
-
`
|
|
383
|
-
`
|
|
451
|
+
`${unattributed.length} queue item(s) left docs/QUEUE.md in this range with NO done entry carrying their id in leading position ` +
|
|
452
|
+
`(\`- [x] ⟨q-…⟩ …\`, the form land writes) and no unambiguous pairing. They emitted NOTHING rather than a guess: a DONE line ` +
|
|
453
|
+
`without a leading id names no item. "Closed by an unknown entry" and "not closed" are different facts and this is the first.`,
|
|
384
454
|
}
|
|
385
455
|
: {}),
|
|
456
|
+
...(reIdentified.length
|
|
457
|
+
? { reIdentifiedItems: reIdentified, reIdentifiedNote: `${reIdentified.length} queue row(s) reappeared under a NEW id with the same text — re-identified, neither departed nor closed (⟨q-1c4f8ae3⟩'s subject).` }
|
|
458
|
+
: {}),
|
|
386
459
|
...(args.write
|
|
387
460
|
? { watermark: head }
|
|
388
461
|
: {
|