agent-coord-mcp 0.26.20 → 0.26.21

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.
@@ -0,0 +1,134 @@
1
+ /*
2
+ * ⛔⛆ THE THING MERGED MUST BE THE THING GATED — AND NOTHING COMPARED THEM.
3
+ *
4
+ * `⟨q-6a4f0c38⟩`, from a real breach on kit#268. The sequence, which no single
5
+ * seat could see alone:
6
+ *
7
+ * 10:23:14Z QA posts PASS bound to 6051193
8
+ * 10:23:22Z the merge runs <- 8 seconds later
9
+ * but the branch head was 1d6deab by then: an author force-pushed
10
+ * 10:28:06Z QA re-issues PASS bound to 1d6deab, AFTER the fact
11
+ *
12
+ * Three controls had to fail together: the force-push created the opportunity,
13
+ * the merge did not re-read the head, and the recording step did not compare.
14
+ * This module is the predicate for the two that are mechanisable.
15
+ *
16
+ * ⭐ WHY THE CHECK MUST BE TEMPORAL, and this is the trap that makes the naive
17
+ * version certify the very breach it was built for: by the time anyone looks,
18
+ * a PASS bound to `1d6deab` EXISTS. "Is there a PASS for the merged head?" is
19
+ * TRUE for #268 today. Only "was there one AT OR BEFORE the merge" is false.
20
+ * A re-issued verdict is an honest record that the CONTENT was verified; it is
21
+ * not evidence that the MERGE was gated, and conflating them erases the event.
22
+ *
23
+ * ⭐ WHY THE SHA COMPARISON IS PREFIX-TOLERANT RATHER THAN EQUALITY. Measured
24
+ * across all 45 verdict records on this fleet's room log:
25
+ *
26
+ * headRefOid length: 7 chars -> 14 records, 40 chars -> 31 records
27
+ *
28
+ * Verdicts are written by hand and abbreviate. An exact compare against `gh`'s
29
+ * 40-char head would report "never gated" on ~31% of verdicts that DID gate —
30
+ * false positives on correct merges, which is the failure that gets a check
31
+ * switched off. MIN_SHA guards the other direction: a 4-character "sha" is not
32
+ * an identifier, it is a collision, so it is REFUSED rather than matched.
33
+ *
34
+ * ⛔ AND "COULD NOT CHECK" IS NEVER "CHECKED AND CLEAN" — the rule this repo
35
+ * already applies in `versionDrift`. No verdict found, an unreadable log and an
36
+ * unreachable `gh` are each a REFUSAL with a reason, never a pass.
37
+ */
38
+
39
+ /** Below this, an abbreviation is a collision rather than an identifier. */
40
+ export const MIN_SHA = 7;
41
+
42
+ /**
43
+ * Do two object names refer to the same commit, allowing either to be
44
+ * abbreviated? Case-insensitive; hex only; both must reach MIN_SHA.
45
+ */
46
+ export function shaAgrees(a: string | undefined, b: string | undefined): boolean {
47
+ const x = (a ?? "").trim().toLowerCase();
48
+ const y = (b ?? "").trim().toLowerCase();
49
+ if (!/^[0-9a-f]+$/.test(x) || !/^[0-9a-f]+$/.test(y)) return false;
50
+ if (x.length < MIN_SHA || y.length < MIN_SHA) return false;
51
+ return x.length <= y.length ? y.startsWith(x) : x.startsWith(y);
52
+ }
53
+
54
+ export type PassVerdict = { head: string; ts: number; from: string; result: string };
55
+
56
+ /** The PR number a verdict record is about, from its `cites`. */
57
+ function citedPr(cites: unknown): string | null {
58
+ if (!Array.isArray(cites)) return null;
59
+ for (const c of cites) {
60
+ const ref = String((c as { ref?: unknown })?.ref ?? "");
61
+ const m = ref.match(/#(\d+)/);
62
+ if (m) return m[1];
63
+ }
64
+ return null;
65
+ }
66
+
67
+ /**
68
+ * Every verdict record in a room log that is about PR `pr`.
69
+ *
70
+ * Tolerant of unparseable lines by SKIPPING them and reporting how many, rather
71
+ * than throwing: a log this cannot fully read still answers the question for the
72
+ * lines it can, and the caller is told the denominator it actually saw.
73
+ */
74
+ export function verdictsFor(logText: string, pr: string): { verdicts: PassVerdict[]; lines: number; unparsed: number } {
75
+ const lines = logText.split("\n").filter((l) => l.trim());
76
+ let unparsed = 0;
77
+ const verdicts: PassVerdict[] = [];
78
+ for (const l of lines) {
79
+ let o: Record<string, unknown>;
80
+ try {
81
+ o = JSON.parse(l) as Record<string, unknown>;
82
+ } catch {
83
+ unparsed++;
84
+ continue;
85
+ }
86
+ const r = o.record as { type?: string; payload?: Record<string, unknown>; cites?: unknown } | undefined;
87
+ if (!r || r.type !== "verdict") continue;
88
+ if (citedPr(r.cites) !== pr) continue;
89
+ verdicts.push({
90
+ head: String(r.payload?.headRefOid ?? ""),
91
+ ts: Number(o.ts ?? 0),
92
+ from: String(o.from ?? ""),
93
+ result: String(r.payload?.result ?? ""),
94
+ });
95
+ }
96
+ return { verdicts, lines: lines.length, unparsed };
97
+ }
98
+
99
+ export type GateAnswer =
100
+ | { gated: true; by: PassVerdict }
101
+ | { gated: false; reason: string; crossed?: { gatedSha: string; at: number }[] };
102
+
103
+ /**
104
+ * Was `head` gated by a PASS that existed at or before `at`?
105
+ *
106
+ * `at` is the decisive argument. Pass the merge time to ask "was this merge
107
+ * gated"; pass `Date.now()` to ask "is this head gated right now", which is the
108
+ * pre-merge question. Same predicate, two positions.
109
+ */
110
+ export function gatedAt(verdicts: PassVerdict[], head: string, at: number): GateAnswer {
111
+ const passes = verdicts.filter((v) => v.result === "pass");
112
+ if (passes.length === 0) {
113
+ return { gated: false, reason: `no PASS verdict was ever recorded for this PR — not checked, which is not the same as checked and passing` };
114
+ }
115
+ const inTime = passes.filter((v) => v.ts <= at);
116
+ if (inTime.length === 0) {
117
+ return {
118
+ gated: false,
119
+ reason:
120
+ `every PASS for this PR was recorded AFTER the moment being judged — a verdict posted later says the content ` +
121
+ `was verified, never that this merge was gated`,
122
+ crossed: passes.map((v) => ({ gatedSha: v.head, at: v.ts })),
123
+ };
124
+ }
125
+ const match = inTime.find((v) => shaAgrees(v.head, head));
126
+ if (match) return { gated: true, by: match };
127
+ return {
128
+ gated: false,
129
+ reason:
130
+ `the head being judged (${head.slice(0, 7)}) matches no PASS recorded at or before that moment — ` +
131
+ `the thing merged is not the thing gated`,
132
+ crossed: inTime.map((v) => ({ gatedSha: v.head, at: v.ts })),
133
+ };
134
+ }
package/src/server.ts CHANGED
@@ -113,6 +113,7 @@ import {
113
113
  exportWorkTool,
114
114
  isPidAlive,
115
115
  } from "./tools/index.js";
116
+ import { queueWriteSchema, queueWriteTool } from "./tools/queue-write.js";
116
117
 
117
118
  function jsonResult(data: unknown) {
118
119
  return {
@@ -690,6 +691,13 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
690
691
  gate(null, landTool as (a: Record<string, unknown>) => Promise<unknown>),
691
692
  );
692
693
 
694
+ addTool(
695
+ "queue_write",
696
+ "FILE, AMEND, REPRIORITISE or RETAG a queue item by passing FIELDS \u2014 never markdown. The verb owns the grammar, mints and stamps the id, and writes the file; direct editing of docs/QUEUE.md is the exception, not the route. `op:'file'` needs text + priority and mints the id; `op:'amend'` needs id + text; `op:'reprioritise'` needs id + priority; `op:'retag'` needs id + tags and REFUSES text, because it exists so a routing tag can be set WITHOUT rewriting the item's prose \u2014 a verb that makes you restate the row to set one field is a verb nobody uses at the transition. `tags` carries the leading-tag family as fields (`awaits`, `sweep`); null clears a key and an omitted key is left alone, so setting `awaits` never drops a `sweep` the row carries. A tag inside `text` is still REFUSED: the position after the id is machine-readable and `next_unblocked` routes on it, so a field satisfiable by prose would be the defect rather than the fix. `undelivered` is not settable \u2014 it is derived from the publish record. Refuses multi-line text, because queue.v1 is one line per item and every parser drops a continuation silently. Refuses when the file moved under it rather than clobbering another writer, and returns the id, priority, text, the tags the row PARSES as, and what it wrote \u2014 no markdown in either direction.",
697
+ queueWriteSchema,
698
+ gate(null, queueWriteTool as (a: Record<string, unknown>) => Promise<unknown>),
699
+ );
700
+
693
701
  addTool(
694
702
  "merge",
695
703
  "Merge a PR ONLY as the consequence of its check verdict: reads the status rollup and merges in the same call, so there is no ordering in which the check runs and the merge ignores it. Refuses on any failing check, on any check not yet terminal, on a CONFLICTING base, and on ZERO checks \u2014 no checks is not passing checks, an empty rollup has zero failures and evidences nothing. Every return carries the POPULATION it judged. Reports by default; pass write:true to merge.",
@@ -0,0 +1,485 @@
1
+ /*
2
+ * `queue_write` — FILE, AMEND and REPRIORITISE a queue item through a verb.
3
+ *
4
+ * ⭐ DAVID SET THE LEVEL OF THIS API, NOT JUST ITS EXISTENCE (2026-09-09):
5
+ * "where possible we should hide the plumbing from the agents. with a tool he
6
+ * can just say task x is ready and then the tool moves it to done."
7
+ * "as long as we offer enough tooling to support future reads and writes
8
+ * through tools we should as much as possible discourage direct file editing.
9
+ * we also should avoid token costs an agent patching lines would cost tokens."
10
+ *
11
+ * ⛔ SO THE SURFACE IS INTENT PLUS EVIDENCE, NEVER FIELDS PLUS FORMAT. The caller
12
+ * says WHAT the item is and HOW URGENT; this module owns the grammar, the id, the
13
+ * glyphs and the file. **No caller-supplied string reaches a document verbatim**
14
+ * — `text` is carried inside a modelled `QueueItem` and rendered by the seam's
15
+ * `renderQueueLine`, which is the same function the parser round-trips against.
16
+ * A verb that accepted a pre-formatted row would be hand-editing with an API on
17
+ * top, which is the defect this row names.
18
+ *
19
+ * WHY THE AGENT MUST NOT KNOW THE GRAMMAR — an inventory, not a preference. Every
20
+ * one of these cost this fleet something in one week, and not one is a rule an
21
+ * agent should ever have had to know:
22
+ * · the closing citation must sit on the item's OWN FIRST LINE (⟨q-6d21ff84⟩,
23
+ * paid for twice, and a continuation line does not satisfy it)
24
+ * · every item must carry a RECORDED id and the writer must stamp it
25
+ * (⟨q-c50e9b83⟩, which reddened `main` twice in six minutes)
26
+ * · `queue.v1` is ONE LINE PER ITEM — a wrapped continuation is dropped by
27
+ * every parser (⟨q-8c1f04b7⟩: 83% of one item's content lost)
28
+ * · conflict markers committed into QUEUE.md with every gate green
29
+ * Each disappears behind a verb that renders the line itself.
30
+ *
31
+ * CONCURRENCY is not handled here and that is deliberate: every write goes
32
+ * through `writeDoc`, which re-reads and REFUSES a moved base (⟨q-0c5e73a1⟩).
33
+ * This module would be unsafe without it, which is why that landed first.
34
+ */
35
+ import { z } from "zod";
36
+ import path from "node:path";
37
+ import { existsSync, readFileSync } from "node:fs";
38
+ import {
39
+ parseWorkDoc,
40
+ queueItemsOf,
41
+ withRecordedId,
42
+ renderQueueLine,
43
+ leadingTagsOf,
44
+ type LeadingTag,
45
+ type QueueItem,
46
+ type WorkDoc,
47
+ } from "@davidbalzan/groundwork-seam";
48
+ import { writeDoc, StaleWriteError } from "./records.js";
49
+
50
+ const QUEUE_DOC = "docs/QUEUE.md";
51
+ const PRIORITIES = ["P1", "P2", "P3"] as const;
52
+
53
+ /**
54
+ * ⛔ THE GUARD MUST ASK ABOUT THE ROW AS IT WILL BE *READ*, NOT AS IT WAS BUILT.
55
+ *
56
+ * QA FAIL on #263 @ 05bf3ee: `" **[AWAITS:david]** x"` — leading whitespace, a
57
+ * TAB too — passed the gate and landed LIVE. Measured, both halves:
58
+ *
59
+ * pre-id item text `" **[AWAITS..."` leadingTagsOf -> []
60
+ * as written text `"**[AWAITS..."` leadingTagsOf -> [{awaits:david}]
61
+ *
62
+ * `LEADING_TAG` is anchored `^\*\*\[`, so the spaces hid the tag from it. Then
63
+ * `renderQueueLine` stamps the id, and the parser's id-strip consumes exactly
64
+ * that whitespace — so the text the gate cleared is not the text a consumer
65
+ * reads. The sigil is the discriminator, not the render/parse round-trip: the
66
+ * same line without an id still parses to `[]`.
67
+ *
68
+ * ⭐ So the row goes through the PARSER before it is judged — the oracle this
69
+ * module already uses for the empty-queue append. Trimming `text` would also
70
+ * close this one input; re-parsing closes the CLASS, because anything else the
71
+ * id-strip swallows arrives here already swallowed.
72
+ */
73
+ function asWritten(item: QueueItem): QueueItem {
74
+ const parsed = queueItemsOf(parseWorkDoc(`## Queue\n${renderQueueLine(item)}\n`))[0];
75
+ if (!parsed) {
76
+ throw new QueueWriteError(
77
+ `the composed row does not parse as queue.v1 — refusing to write it: ${renderQueueLine(item)}`,
78
+ );
79
+ }
80
+ return parsed;
81
+ }
82
+
83
+ /**
84
+ * ⛔ THE TEXT FIELD LANDS IN ROW-PREFIX POSITION, WHICH IS STRUCTURAL, NOT BODY.
85
+ *
86
+ * `renderQueueLine` puts `text` immediately after the id — and the first thing
87
+ * after the id is MACHINE-READABLE. `leadingTagsOf` reads `**[key:value]**`
88
+ * there, `awaitingOf` reads `awaits`, and `next_unblocked` routes on it. So a
89
+ * caller passing `**[AWAITS:david]**` as prose changed ROUTING: measured end to
90
+ * end on the first version of this verb, `leadingTagsOf` returned
91
+ * `[{key:"awaits",value:"david"}]` and the item appeared on `awaitingDecision`.
92
+ *
93
+ * ⭐ THE GATE IS THE REAL PREDICATE, NOT A PATTERN. The first version of this
94
+ * module had no gate and its test used a regex proxy
95
+ * (`^- \[|⟨q-|\(P[123]\)`) which the tag grammar matches none of — so the
96
+ * suite was green while the premise was false. Asking `leadingTagsOf` the
97
+ * question it exists to answer means the gate cannot drift from the grammar: if
98
+ * the tag vocabulary widens, this widens with it, for free.
99
+ *
100
+ * Tags are not refused because they are dangerous to write — they are refused
101
+ * because they are not this verb's arguments. Adding them as TYPED arguments the
102
+ * verb renders itself is a separate, coherent change; letting them in through
103
+ * prose is the hand-editing this verb exists to replace.
104
+ */
105
+ function assertNoLeadingTag(item: QueueItem): void {
106
+ const tags = leadingTagsOf(asWritten(item));
107
+ if (tags.length) {
108
+ const shown = tags.map((t) => `**[${t.key}:${t.value}]**`).join(" ");
109
+ throw new QueueWriteError(
110
+ `text must not begin with a row-prefix tag (${shown}): the position right after the id is ` +
111
+ `MACHINE-READABLE, so this would change routing rather than describe the item — ` +
112
+ `\`leadingTagsOf\` reads it and \`next_unblocked\` acts on it. ` +
113
+ `Markers are set by the seats that own them, not through an item's prose.`,
114
+ );
115
+ }
116
+ }
117
+
118
+ /*
119
+ * ⛔⛆⛆ THE TAG THAT DECIDES ROUTING HAD NO FIELD, SO THE STATE "BUILT, GREEN,
120
+ * STOPPED AT A HUMAN" WAS UNREACHABLE THROUGH THIS VERB — `⟨q-d920a123⟩`.
121
+ *
122
+ * `**[AWAITS:<who>]**` is READ by `next_unblocked` and reported on its own
123
+ * `awaitingDecision` axis, and the seam calls it "a FIELD set by whoever files the
124
+ * item". But this verb's schema was `op · id · text · priority` and nothing else, so
125
+ * the only route through it was to smuggle the tag inside `text` — which
126
+ * `assertNoLeadingTag` correctly refuses. ⭐ THE MARKER WAS NEVER WRITTEN AT THE
127
+ * TRANSITION NOT THROUGH FORGETFULNESS BUT BECAUSE THERE WAS NO MECHANISM: the aide
128
+ * hand-edited `⟨q-217cc151⟩`'s marker at 17:05 because the verb could not.
129
+ *
130
+ * The live instance is the cost: `next_unblocked` offered `⟨q-217cc151⟩` as `next`
131
+ * while that row was built, CI-green, gated FAIL and blocked on a publish only a
132
+ * human can perform.
133
+ *
134
+ * ⛔ AND THE REFUSAL STAYS EXACTLY AS STRONG. Tags arrive as TYPED FIELDS the verb
135
+ * renders itself; a tag inside `text` is still refused. The file already said this
136
+ * was the coherent change — "Adding them as TYPED arguments the verb renders itself
137
+ * is a separate, coherent change; letting them in through prose is the hand-editing
138
+ * this verb exists to replace." A field satisfiable by prose would BE the defect.
139
+ */
140
+
141
+ /*
142
+ * ⭐ THE KEYS THIS VERB WILL SET, AND WHY THE LIST IS SHORT RATHER THAN OPEN.
143
+ *
144
+ * `awaits` and `sweep` are both READ by shipped code (`awaitingOf`, `sweepTagOf`),
145
+ * so setting them completes a loop that already exists. `undelivered` uses the same
146
+ * grammar and is deliberately NOT settable here: it is a fact about what is on the
147
+ * REGISTRY, derived by `check-undelivered-markers` from the publish record, and an
148
+ * agent asserting it by hand is how that marker would start lying. A caller that
149
+ * wants it is asking the wrong question of the wrong verb.
150
+ */
151
+ const TAG_KEYS = ["awaits", "sweep"] as const;
152
+ type TagKey = (typeof TAG_KEYS)[number];
153
+
154
+ /**
155
+ * Split `item.text` into its carried tag run and the body after it.
156
+ *
157
+ * ⛔ THE SEAM IS THE ONLY LEXER, AND THAT IS THE POINT — NOT AN ECONOMY.
158
+ * `⟨q-1c4f8ae3⟩` is exactly this shape: one grammar, two parsers, different
159
+ * domains, both "working" (the check accepted `@scope/name@1.0.0`; the seam dropped
160
+ * it). So this does NOT re-implement `LEADING_TAG`. It asks `leadingTagsOf` where
161
+ * the run ends, by finding the SMALLEST boundary `i` such that
162
+ *
163
+ * · the suffix from `i` carries NO tags, and
164
+ * · the prefix up to `i`, with a tag-free sentinel appended so its run
165
+ * terminates, carries ALL of them.
166
+ *
167
+ * Both conditions are needed. The first alone is satisfied by `i = 1` — cutting one
168
+ * `*` off `**[AWAITS:x]**` leaves a suffix that parses to `[]`, which would report
169
+ * the whole tag as body. The second alone is satisfied by `i = text.length` on a row
170
+ * whose BODY quotes a tag, which would report the whole body as tag run.
171
+ *
172
+ * ⭐ SMALLEST rather than largest, and the difference is a swallowed body: for
173
+ * `**[A:b]** body **[C:d]** x` the largest valid boundary is the end of the line —
174
+ * the prefix still carries exactly the one real tag and the empty suffix carries
175
+ * none — so a descending scan returns an EMPTY body. Measured before it was written.
176
+ */
177
+ function splitLeadingTags(item: QueueItem): { tags: LeadingTag[]; body: string } {
178
+ const text = String(item.text);
179
+ const tagsOf = (t: string): LeadingTag[] => leadingTagsOf({ ...item, text: t });
180
+ const tags = tagsOf(text);
181
+ if (tags.length === 0) return { tags, body: text };
182
+ // Tag-free by construction: the grammar's key class is [a-z]+ and its value class
183
+ // admits no NUL, so this can never extend a run it is appended to.
184
+ const SENTINEL = "\u0000";
185
+ for (let i = 1; i <= text.length; i++) {
186
+ if (tagsOf(text.slice(i)).length !== 0) continue;
187
+ if (tagsOf(text.slice(0, i) + SENTINEL).length !== tags.length) continue;
188
+ // The grammar consumes the whitespace after `]**`; a second space would
189
+ // otherwise survive into the rendered line as a double space.
190
+ return { tags, body: text.slice(i).replace(/^\s+/, "") };
191
+ }
192
+ throw new QueueWriteError(
193
+ `could not locate the end of the tag run in ${JSON.stringify(text)} — refusing to guess a boundary, ` +
194
+ `because a wrong one would move part of the item's prose into machine-readable position`,
195
+ );
196
+ }
197
+
198
+ /**
199
+ * `item.text` with `changes` applied to its leading tags. `null` CLEARS a key.
200
+ *
201
+ * Existing tags are preserved and their order is kept, so setting `awaits` on a row
202
+ * that already carries `**[SWEEP:console]**` does not silently drop the sweep tag —
203
+ * the failure that would make this verb worse than the hand-edit it replaces.
204
+ */
205
+ function retagged(
206
+ item: QueueItem,
207
+ changes: Partial<Record<TagKey, string | null>>,
208
+ ): { text: string; want: Map<string, string> } {
209
+ const { tags, body } = splitLeadingTags(item);
210
+ const map = new Map<string, string>(tags.map((t) => [t.key, t.value]));
211
+ for (const [k, v] of Object.entries(changes)) {
212
+ if (v === null) map.delete(k);
213
+ else if (v !== undefined) map.set(k, String(v));
214
+ }
215
+ const rendered = [...map].map(([k, v]) => `**[${k.toUpperCase()}:${v}]**`).join(" ");
216
+ return { text: rendered ? `${rendered} ${body}` : body, want: map };
217
+ }
218
+
219
+ /**
220
+ * ⛔ WHAT WAS INTENDED IS WHAT THE ROW WILL BE READ AS — asked of the parser, not assumed.
221
+ *
222
+ * `renderQueueLine` then the parser then `leadingTagsOf`, exactly the path a consumer
223
+ * takes. A value the grammar cannot express (a space, a `%`) composes into a token that
224
+ * does not match, and the oracle reports the tag as MISSING rather than this module
225
+ * deciding what the value class is — the charset stays the seam's, which is the lesson
226
+ * of `⟨q-1c4f8ae3⟩` where a reused grammar silently inherited a domain.
227
+ */
228
+ function assertTagsAsRead(item: QueueItem, want: Map<string, string>): void {
229
+ const got = new Map(leadingTagsOf(asWritten(item)).map((t) => [t.key, t.value]));
230
+ const same =
231
+ got.size === want.size && [...want].every(([k, v]) => got.get(k) === String(v).toLowerCase());
232
+ if (!same) {
233
+ const show = (m: Map<string, string>) =>
234
+ m.size ? [...m].map(([k, v]) => `**[${k.toUpperCase()}:${v}]**`).join(" ") : "(none)";
235
+ throw new QueueWriteError(
236
+ `the tags this would WRITE are not the tags a consumer would READ — wanted ${show(want)}, ` +
237
+ `the row parses as ${show(got)}. Most likely a value the tag grammar cannot express; ` +
238
+ `the value class is the seam's and this verb will not widen it by guessing.`,
239
+ );
240
+ }
241
+ }
242
+
243
+ /**
244
+ * ⛔ THE ONE-LINE INVARIANT, ENFORCED AT THE BOUNDARY RATHER THAN DOCUMENTED.
245
+ *
246
+ * `queue.v1` is one line per item and a continuation is silently dropped by every
247
+ * consumer — measured at 83% of one item's content. An agent cannot be expected
248
+ * to know that, so a multi-line `text` is REFUSED here with the reason, instead
249
+ * of being written and quietly truncated later.
250
+ */
251
+ function assertSingleLine(text: string): void {
252
+ if (/[\r\n]/.test(text)) {
253
+ throw new QueueWriteError(
254
+ "text must be a single line: queue.v1 is one line per item, and every parser drops a " +
255
+ "continuation line silently (measured: 83% of one item's content lost). " +
256
+ "Put detail in a file the row points at, or fold it into this line.",
257
+ );
258
+ }
259
+ }
260
+
261
+ /** The tags the row will be READ as, for the result. Read back, never echoed. */
262
+ function tagsAsRead(item: QueueItem): Record<string, string> {
263
+ return Object.fromEntries(leadingTagsOf(asWritten(item)).map((t) => [t.key, t.value]));
264
+ }
265
+
266
+ /** A refusal the caller can act on, distinct from a stale-base refusal. */
267
+ export class QueueWriteError extends Error {
268
+ constructor(message: string) {
269
+ super(message);
270
+ this.name = "QueueWriteError";
271
+ }
272
+ }
273
+
274
+ const readQueue = (repo: string): { text: string; doc: WorkDoc } => {
275
+ const p = path.join(repo, QUEUE_DOC);
276
+ if (!existsSync(p)) throw new QueueWriteError(`${QUEUE_DOC} does not exist under ${repo}`);
277
+ const text = readFileSync(p, "utf8");
278
+ return { text, doc: parseWorkDoc(text) };
279
+ };
280
+
281
+ /** The facts a caller needs back — never markdown. */
282
+ type QueueWriteResult = {
283
+ ok: true;
284
+ op: "file" | "amend" | "reprioritise" | "retag";
285
+ id: string;
286
+ priority: (typeof PRIORITIES)[number];
287
+ /** The item's text as stored, so a caller can confirm what it said. */
288
+ text: string;
289
+ wrote: string[];
290
+ /** Ids this write stamped that the caller did not name — absorption, reported. */
291
+ stampedNotSupplied: string[];
292
+ /**
293
+ * The tags the written row PARSES as — read back through the seam, not echoed from
294
+ * the request, so a caller can see what a consumer will see.
295
+ */
296
+ tags: Record<string, string>;
297
+ };
298
+
299
+ export const queueWriteSchema = {
300
+ project: z.string().min(1),
301
+ repo: z.string().optional(),
302
+ op: z.enum(["file", "amend", "reprioritise", "retag"]),
303
+ /** Required for amend and reprioritise; never supplied for file (the verb mints it). */
304
+ id: z.string().optional(),
305
+ /** What the item is about. Prose, not markdown — a single line. */
306
+ text: z.string().min(1).optional(),
307
+ priority: z.enum(PRIORITIES).optional(),
308
+ /**
309
+ * Leading tags as FIELDS. `null` clears a key; an omitted key is left alone, so
310
+ * setting `awaits` never drops a `sweep` tag the row already carries.
311
+ */
312
+ tags: z
313
+ .object({
314
+ awaits: z.string().min(1).nullable().optional(),
315
+ sweep: z.string().min(1).nullable().optional(),
316
+ })
317
+ .optional(),
318
+ };
319
+
320
+ export async function queueWriteTool(args: {
321
+ project: string;
322
+ repo?: string;
323
+ op: "file" | "amend" | "reprioritise" | "retag";
324
+ id?: string;
325
+ text?: string;
326
+ priority?: (typeof PRIORITIES)[number];
327
+ tags?: Partial<Record<TagKey, string | null>>;
328
+ }): Promise<QueueWriteResult | { ok: false; error: string; staleWrite?: unknown }> {
329
+ const repo = args.repo ?? process.cwd();
330
+ try {
331
+ // Refused at the boundary rather than rendered: an unknown key would compose a
332
+ // token the grammar accepts and no reader asks for — a tag nothing routes on,
333
+ // which is worse than an error because it LOOKS set.
334
+ for (const k of Object.keys(args.tags ?? {})) {
335
+ if (!(TAG_KEYS as readonly string[]).includes(k)) {
336
+ throw new QueueWriteError(
337
+ `unknown tag field '${k}' — this verb sets ${TAG_KEYS.join(", ")}. ` +
338
+ `\`undelivered\` is deliberately not settable here: it is derived from the publish ` +
339
+ `record by check-undelivered-markers, and asserting it by hand is how that marker starts lying.`,
340
+ );
341
+ }
342
+ }
343
+ const { text: original, doc } = readQueue(repo);
344
+ const items = queueItemsOf(doc);
345
+
346
+ // Locate the block the items live in, so a filed row joins the record model
347
+ // rather than being appended as text the parser has to re-discover.
348
+ const block = doc.blocks.find((b) => b.kind === "queue");
349
+
350
+ let target: QueueItem;
351
+ if (args.op === "file") {
352
+ if (args.id) throw new QueueWriteError("do not supply an id when filing: the verb mints and stamps it");
353
+ if (!args.text) throw new QueueWriteError("filing needs `text` — what the item is about");
354
+ if (!args.priority) throw new QueueWriteError(`filing needs \`priority\` — one of ${PRIORITIES.join(", ")}`);
355
+ assertSingleLine(args.text);
356
+ // Identity is the seam's to mint: derived from the text, then RECORDED, so
357
+ // the id stops depending on prose the moment it exists.
358
+ const seeded = parseWorkDoc(`## Queue\n- [ ] (${args.priority}) ${args.text}\n`);
359
+ const minted = queueItemsOf(seeded)[0];
360
+ if (!minted) throw new QueueWriteError("the composed row does not parse as queue.v1 — refusing to write it");
361
+ target = withRecordedId(minted);
362
+ // AFTER the id is stamped, never before: `assertNoLeadingTag` re-parses the
363
+ // rendered row, and the id-strip is the step that exposes a hidden tag.
364
+ assertNoLeadingTag(target);
365
+ // Tags AFTER the prose gate, never instead of it: `text` is still judged on its
366
+ // own, so the field form cannot be used to smuggle what the gate refuses.
367
+ if (args.tags && Object.keys(args.tags).length) {
368
+ const { text: next, want } = retagged(target, args.tags);
369
+ assertTagsAsRead({ ...target, text: next }, want);
370
+ assertSingleLine(next);
371
+ target.text = next;
372
+ }
373
+
374
+ if (block) {
375
+ block.items.push(target);
376
+ } else {
377
+ // AN EMPTY QUEUE SECTION HAS NO BLOCK, because blocks appear when items
378
+ // parse — and a fresh `groundwork init` queue is exactly that, so the
379
+ // FIRST filed row would otherwise be the one that cannot be filed.
380
+ //
381
+ // The seam's heading classifier is private and this module will not widen
382
+ // a shipped package's surface for its own convenience, so THE PARSER IS
383
+ // THE ORACLE instead of a guess: append the rendered line, re-parse, and
384
+ // accept only if the document now yields exactly this item as a queue
385
+ // item. If the append landed outside the queue section — a document whose
386
+ // queue is not last — the re-parse does not see it and this REFUSES
387
+ // rather than corrupting the file.
388
+ const candidate = `${original.replace(/\n*$/, "\n")}${renderQueueLine(target)}\n`;
389
+ const reparsed = parseWorkDoc(candidate);
390
+ const got = queueItemsOf(reparsed);
391
+ if (got.length !== items.length + 1 || !got.some((i) => i.id === target.id)) {
392
+ throw new QueueWriteError(
393
+ `${QUEUE_DOC} has no parsed queue block and appending did not produce one — the queue section is ` +
394
+ `probably not the last section in the file. Refusing to guess where the row goes; file the first ` +
395
+ `item by hand once, or move the queue section last.`,
396
+ );
397
+ }
398
+ const w0 = writeDoc(repo, QUEUE_DOC, reparsed, original);
399
+ return {
400
+ ok: true,
401
+ op: args.op,
402
+ id: target.id,
403
+ priority: args.priority,
404
+ text: target.text,
405
+ wrote: w0.written ? [QUEUE_DOC] : [],
406
+ stampedNotSupplied: w0.stamped.filter((id) => id !== target.id),
407
+ tags: tagsAsRead(target),
408
+ };
409
+ }
410
+ } else if (args.op === "retag") {
411
+ /*
412
+ * ⛔ THE WHOLE POINT: TAGS CHANGE WITHOUT THE TEXT BEING REWRITTEN.
413
+ *
414
+ * `amend` would have worked by making the caller resend the row's prose, and
415
+ * that is the defect in miniature — a verb that makes you restate 400 characters
416
+ * to set one field is a verb nobody uses at the transition, which is how the
417
+ * marker went unwritten in the first place. So `retag` REFUSES `text`: if it
418
+ * accepted it, the easy path would silently be "rewrite the row" again.
419
+ */
420
+ if (!args.id) throw new QueueWriteError("retag needs `id` — which item to tag");
421
+ if (args.text !== undefined) {
422
+ throw new QueueWriteError(
423
+ "retag does not take `text`: it exists so a tag can be set WITHOUT rewriting the item's prose. " +
424
+ "Use `amend` to change what the item says.",
425
+ );
426
+ }
427
+ if (args.priority !== undefined) {
428
+ throw new QueueWriteError("retag does not take `priority` — use `reprioritise`");
429
+ }
430
+ const changes = args.tags ?? {};
431
+ if (!Object.keys(changes).length) {
432
+ throw new QueueWriteError(
433
+ `retag needs \`tags\` — at least one of ${TAG_KEYS.join(", ")} (a value to set, or null to clear)`,
434
+ );
435
+ }
436
+ const found = items.find((i) => i.id === args.id);
437
+ if (!found) throw new QueueWriteError(`no queue item with id '${args.id}' in ${QUEUE_DOC}`);
438
+ target = found;
439
+ const { text: next, want } = retagged(target, changes);
440
+ // Composed on a COPY and judged BEFORE the item is mutated, so a refusal cannot
441
+ // leave a half-tagged row in the document about to be written.
442
+ assertTagsAsRead({ ...target, text: next }, want);
443
+ assertSingleLine(next);
444
+ target.text = next;
445
+ } else {
446
+ if (!args.id) throw new QueueWriteError(`${args.op} needs \`id\` — which item to change`);
447
+ const found = items.find((i) => i.id === args.id);
448
+ if (!found) throw new QueueWriteError(`no queue item with id '${args.id}' in ${QUEUE_DOC}`);
449
+ target = found;
450
+ if (args.op === "amend") {
451
+ if (!args.text) throw new QueueWriteError("amend needs `text`");
452
+ assertSingleLine(args.text);
453
+ // Amend has the same hole as file — the rewritten text occupies the same
454
+ // structural slot — so it gets the same gate. Applied to a COPY first, so
455
+ // a refusal cannot leave the in-memory item half-changed.
456
+ assertNoLeadingTag({ ...target, text: args.text });
457
+ target.text = args.text;
458
+ } else {
459
+ if (!args.priority) throw new QueueWriteError(`reprioritise needs \`priority\` — one of ${PRIORITIES.join(", ")}`);
460
+ target.priority = args.priority;
461
+ // A raw tag that disagreed with the parsed priority would re-render the
462
+ // old value; clearing it keeps the two from drifting apart.
463
+ delete (target as { priorityRaw?: string }).priorityRaw;
464
+ }
465
+ }
466
+
467
+ const w = writeDoc(repo, QUEUE_DOC, doc, original);
468
+ return {
469
+ ok: true,
470
+ op: args.op,
471
+ id: target.id,
472
+ priority: (target.priority ?? args.priority) as (typeof PRIORITIES)[number],
473
+ text: target.text,
474
+ wrote: w.written ? [QUEUE_DOC] : [],
475
+ stampedNotSupplied: w.stamped.filter((id) => id !== target.id),
476
+ tags: tagsAsRead(target),
477
+ };
478
+ } catch (e) {
479
+ if (e instanceof StaleWriteError) {
480
+ return { ok: false, error: e.message, staleWrite: { doc: e.rel, drift: e.detail, alreadyWritten: e.alreadyWritten } };
481
+ }
482
+ if (e instanceof QueueWriteError) return { ok: false, error: e.message };
483
+ throw e;
484
+ }
485
+ }