/* * `queue_write` — FILE, AMEND and REPRIORITISE a queue item through a verb. * * ⭐ DAVID SET THE LEVEL OF THIS API, NOT JUST ITS EXISTENCE (2026-09-09): * "where possible we should hide the plumbing from the agents. with a tool he * can just say task x is ready and then the tool moves it to done." * "as long as we offer enough tooling to support future reads and writes * through tools we should as much as possible discourage direct file editing. * we also should avoid token costs an agent patching lines would cost tokens." * * ⛔ SO THE SURFACE IS INTENT PLUS EVIDENCE, NEVER FIELDS PLUS FORMAT. The caller * says WHAT the item is and HOW URGENT; this module owns the grammar, the id, the * glyphs and the file. **No caller-supplied string reaches a document verbatim** * — `text` is carried inside a modelled `QueueItem` and rendered by the seam's * `renderQueueLine`, which is the same function the parser round-trips against. * A verb that accepted a pre-formatted row would be hand-editing with an API on * top, which is the defect this row names. * * WHY THE AGENT MUST NOT KNOW THE GRAMMAR — an inventory, not a preference. Every * one of these cost this fleet something in one week, and not one is a rule an * agent should ever have had to know: * · the closing citation must sit on the item's OWN FIRST LINE (⟨q-6d21ff84⟩, * paid for twice, and a continuation line does not satisfy it) * · every item must carry a RECORDED id and the writer must stamp it * (⟨q-c50e9b83⟩, which reddened `main` twice in six minutes) * · `queue.v1` is ONE LINE PER ITEM — a wrapped continuation is dropped by * every parser (⟨q-8c1f04b7⟩: 83% of one item's content lost) * · conflict markers committed into QUEUE.md with every gate green * Each disappears behind a verb that renders the line itself. * * CONCURRENCY is not handled here and that is deliberate: every write goes * through `writeDoc`, which re-reads and REFUSES a moved base (⟨q-0c5e73a1⟩). * This module would be unsafe without it, which is why that landed first. */ import { z } from "zod"; import path from "node:path"; import { existsSync, readFileSync } from "node:fs"; import { parseWorkDoc, queueItemsOf, withRecordedId, renderQueueLine, leadingTagsOf, type LeadingTag, type QueueItem, type WorkDoc, } from "@davidbalzan/groundwork-seam"; import { writeDoc, StaleWriteError } from "./records.js"; const QUEUE_DOC = "docs/QUEUE.md"; const PRIORITIES = ["P1", "P2", "P3"] as const; /** * ⛔ THE GUARD MUST ASK ABOUT THE ROW AS IT WILL BE *READ*, NOT AS IT WAS BUILT. * * QA FAIL on #263 @ 05bf3ee: `" **[AWAITS:david]** x"` — leading whitespace, a * TAB too — passed the gate and landed LIVE. Measured, both halves: * * pre-id item text `" **[AWAITS..."` leadingTagsOf -> [] * as written text `"**[AWAITS..."` leadingTagsOf -> [{awaits:david}] * * `LEADING_TAG` is anchored `^\*\*\[`, so the spaces hid the tag from it. Then * `renderQueueLine` stamps the id, and the parser's id-strip consumes exactly * that whitespace — so the text the gate cleared is not the text a consumer * reads. The sigil is the discriminator, not the render/parse round-trip: the * same line without an id still parses to `[]`. * * ⭐ So the row goes through the PARSER before it is judged — the oracle this * module already uses for the empty-queue append. Trimming `text` would also * close this one input; re-parsing closes the CLASS, because anything else the * id-strip swallows arrives here already swallowed. */ function asWritten(item: QueueItem): QueueItem { const parsed = queueItemsOf(parseWorkDoc(`## Queue\n${renderQueueLine(item)}\n`))[0]; if (!parsed) { throw new QueueWriteError( `the composed row does not parse as queue.v1 — refusing to write it: ${renderQueueLine(item)}`, ); } return parsed; } /** * ⛔ THE TEXT FIELD LANDS IN ROW-PREFIX POSITION, WHICH IS STRUCTURAL, NOT BODY. * * `renderQueueLine` puts `text` immediately after the id — and the first thing * after the id is MACHINE-READABLE. `leadingTagsOf` reads `**[key:value]**` * there, `awaitingOf` reads `awaits`, and `next_unblocked` routes on it. So a * caller passing `**[AWAITS:david]**` as prose changed ROUTING: measured end to * end on the first version of this verb, `leadingTagsOf` returned * `[{key:"awaits",value:"david"}]` and the item appeared on `awaitingDecision`. * * ⭐ THE GATE IS THE REAL PREDICATE, NOT A PATTERN. The first version of this * module had no gate and its test used a regex proxy * (`^- \[|⟨q-|\(P[123]\)`) which the tag grammar matches none of — so the * suite was green while the premise was false. Asking `leadingTagsOf` the * question it exists to answer means the gate cannot drift from the grammar: if * the tag vocabulary widens, this widens with it, for free. * * Tags are not refused because they are dangerous to write — they are refused * because they are not this verb's arguments. Adding them as TYPED arguments the * verb renders itself is a separate, coherent change; letting them in through * prose is the hand-editing this verb exists to replace. */ function assertNoLeadingTag(item: QueueItem): void { const tags = leadingTagsOf(asWritten(item)); if (tags.length) { const shown = tags.map((t) => `**[${t.key}:${t.value}]**`).join(" "); throw new QueueWriteError( `text must not begin with a row-prefix tag (${shown}): the position right after the id is ` + `MACHINE-READABLE, so this would change routing rather than describe the item — ` + `\`leadingTagsOf\` reads it and \`next_unblocked\` acts on it. ` + `Markers are set by the seats that own them, not through an item's prose.`, ); } } /* * ⛔⛆⛆ THE TAG THAT DECIDES ROUTING HAD NO FIELD, SO THE STATE "BUILT, GREEN, * STOPPED AT A HUMAN" WAS UNREACHABLE THROUGH THIS VERB — `⟨q-d920a123⟩`. * * `**[AWAITS:]**` is READ by `next_unblocked` and reported on its own * `awaitingDecision` axis, and the seam calls it "a FIELD set by whoever files the * item". But this verb's schema was `op · id · text · priority` and nothing else, so * the only route through it was to smuggle the tag inside `text` — which * `assertNoLeadingTag` correctly refuses. ⭐ THE MARKER WAS NEVER WRITTEN AT THE * TRANSITION NOT THROUGH FORGETFULNESS BUT BECAUSE THERE WAS NO MECHANISM: the aide * hand-edited `⟨q-217cc151⟩`'s marker at 17:05 because the verb could not. * * The live instance is the cost: `next_unblocked` offered `⟨q-217cc151⟩` as `next` * while that row was built, CI-green, gated FAIL and blocked on a publish only a * human can perform. * * ⛔ AND THE REFUSAL STAYS EXACTLY AS STRONG. Tags arrive as TYPED FIELDS the verb * renders itself; a tag inside `text` is still refused. The file already said this * was the coherent change — "Adding them as TYPED arguments the verb renders itself * is a separate, coherent change; letting them in through prose is the hand-editing * this verb exists to replace." A field satisfiable by prose would BE the defect. */ /* * ⭐ THE KEYS THIS VERB WILL SET, AND WHY THE LIST IS SHORT RATHER THAN OPEN. * * `awaits` and `sweep` are both READ by shipped code (`awaitingOf`, `sweepTagOf`), * so setting them completes a loop that already exists. `undelivered` uses the same * grammar and is deliberately NOT settable here: it is a fact about what is on the * REGISTRY, derived by `check-undelivered-markers` from the publish record, and an * agent asserting it by hand is how that marker would start lying. A caller that * wants it is asking the wrong question of the wrong verb. */ const TAG_KEYS = ["awaits", "sweep"] as const; type TagKey = (typeof TAG_KEYS)[number]; /** * Split `item.text` into its carried tag run and the body after it. * * ⛔ THE SEAM IS THE ONLY LEXER, AND THAT IS THE POINT — NOT AN ECONOMY. * `⟨q-1c4f8ae3⟩` is exactly this shape: one grammar, two parsers, different * domains, both "working" (the check accepted `@scope/name@1.0.0`; the seam dropped * it). So this does NOT re-implement `LEADING_TAG`. It asks `leadingTagsOf` where * the run ends, by finding the SMALLEST boundary `i` such that * * · the suffix from `i` carries NO tags, and * · the prefix up to `i`, with a tag-free sentinel appended so its run * terminates, carries ALL of them. * * Both conditions are needed. The first alone is satisfied by `i = 1` — cutting one * `*` off `**[AWAITS:x]**` leaves a suffix that parses to `[]`, which would report * the whole tag as body. The second alone is satisfied by `i = text.length` on a row * whose BODY quotes a tag, which would report the whole body as tag run. * * ⭐ SMALLEST rather than largest, and the difference is a swallowed body: for * `**[A:b]** body **[C:d]** x` the largest valid boundary is the end of the line — * the prefix still carries exactly the one real tag and the empty suffix carries * none — so a descending scan returns an EMPTY body. Measured before it was written. */ function splitLeadingTags(item: QueueItem): { tags: LeadingTag[]; body: string } { const text = String(item.text); const tagsOf = (t: string): LeadingTag[] => leadingTagsOf({ ...item, text: t }); const tags = tagsOf(text); if (tags.length === 0) return { tags, body: text }; // Tag-free by construction: the grammar's key class is [a-z]+ and its value class // admits no NUL, so this can never extend a run it is appended to. const SENTINEL = "\u0000"; for (let i = 1; i <= text.length; i++) { if (tagsOf(text.slice(i)).length !== 0) continue; if (tagsOf(text.slice(0, i) + SENTINEL).length !== tags.length) continue; // The grammar consumes the whitespace after `]**`; a second space would // otherwise survive into the rendered line as a double space. return { tags, body: text.slice(i).replace(/^\s+/, "") }; } throw new QueueWriteError( `could not locate the end of the tag run in ${JSON.stringify(text)} — refusing to guess a boundary, ` + `because a wrong one would move part of the item's prose into machine-readable position`, ); } /** * `item.text` with `changes` applied to its leading tags. `null` CLEARS a key. * * Existing tags are preserved and their order is kept, so setting `awaits` on a row * that already carries `**[SWEEP:console]**` does not silently drop the sweep tag — * the failure that would make this verb worse than the hand-edit it replaces. */ function retagged( item: QueueItem, changes: Partial>, ): { text: string; want: Map } { const { tags, body } = splitLeadingTags(item); const map = new Map(tags.map((t) => [t.key, t.value])); for (const [k, v] of Object.entries(changes)) { if (v === null) map.delete(k); else if (v !== undefined) map.set(k, String(v)); } const rendered = [...map].map(([k, v]) => `**[${k.toUpperCase()}:${v}]**`).join(" "); return { text: rendered ? `${rendered} ${body}` : body, want: map }; } /** * ⛔ WHAT WAS INTENDED IS WHAT THE ROW WILL BE READ AS — asked of the parser, not assumed. * * `renderQueueLine` then the parser then `leadingTagsOf`, exactly the path a consumer * takes. A value the grammar cannot express (a space, a `%`) composes into a token that * does not match, and the oracle reports the tag as MISSING rather than this module * deciding what the value class is — the charset stays the seam's, which is the lesson * of `⟨q-1c4f8ae3⟩` where a reused grammar silently inherited a domain. */ function assertTagsAsRead(item: QueueItem, want: Map): void { const got = new Map(leadingTagsOf(asWritten(item)).map((t) => [t.key, t.value])); const same = got.size === want.size && [...want].every(([k, v]) => got.get(k) === String(v).toLowerCase()); if (!same) { const show = (m: Map) => m.size ? [...m].map(([k, v]) => `**[${k.toUpperCase()}:${v}]**`).join(" ") : "(none)"; throw new QueueWriteError( `the tags this would WRITE are not the tags a consumer would READ — wanted ${show(want)}, ` + `the row parses as ${show(got)}. Most likely a value the tag grammar cannot express; ` + `the value class is the seam's and this verb will not widen it by guessing.`, ); } } /** * ⛔ THE ONE-LINE INVARIANT, ENFORCED AT THE BOUNDARY RATHER THAN DOCUMENTED. * * `queue.v1` is one line per item and a continuation is silently dropped by every * consumer — measured at 83% of one item's content. An agent cannot be expected * to know that, so a multi-line `text` is REFUSED here with the reason, instead * of being written and quietly truncated later. */ function assertSingleLine(text: string): void { if (/[\r\n]/.test(text)) { throw new QueueWriteError( "text must be a single line: queue.v1 is one line per item, and every parser drops a " + "continuation line silently (measured: 83% of one item's content lost). " + "Put detail in a file the row points at, or fold it into this line.", ); } } /** The tags the row will be READ as, for the result. Read back, never echoed. */ function tagsAsRead(item: QueueItem): Record { return Object.fromEntries(leadingTagsOf(asWritten(item)).map((t) => [t.key, t.value])); } /** A refusal the caller can act on, distinct from a stale-base refusal. */ export class QueueWriteError extends Error { constructor(message: string) { super(message); this.name = "QueueWriteError"; } } const readQueue = (repo: string): { text: string; doc: WorkDoc } => { const p = path.join(repo, QUEUE_DOC); if (!existsSync(p)) throw new QueueWriteError(`${QUEUE_DOC} does not exist under ${repo}`); const text = readFileSync(p, "utf8"); return { text, doc: parseWorkDoc(text) }; }; /** The facts a caller needs back — never markdown. */ type QueueWriteResult = { ok: true; op: "file" | "amend" | "reprioritise" | "retag"; id: string; priority: (typeof PRIORITIES)[number]; /** The item's text as stored, so a caller can confirm what it said. */ text: string; wrote: string[]; /** Ids this write stamped that the caller did not name — absorption, reported. */ stampedNotSupplied: string[]; /** * The tags the written row PARSES as — read back through the seam, not echoed from * the request, so a caller can see what a consumer will see. */ tags: Record; }; export const queueWriteSchema = { project: z.string().min(1), repo: z.string().optional(), op: z.enum(["file", "amend", "reprioritise", "retag"]), /** Required for amend and reprioritise; never supplied for file (the verb mints it). */ id: z.string().optional(), /** What the item is about. Prose, not markdown — a single line. */ text: z.string().min(1).optional(), priority: z.enum(PRIORITIES).optional(), /** * Leading tags as FIELDS. `null` clears a key; an omitted key is left alone, so * setting `awaits` never drops a `sweep` tag the row already carries. */ tags: z .object({ awaits: z.string().min(1).nullable().optional(), sweep: z.string().min(1).nullable().optional(), }) .optional(), }; export async function queueWriteTool(args: { project: string; repo?: string; op: "file" | "amend" | "reprioritise" | "retag"; id?: string; text?: string; priority?: (typeof PRIORITIES)[number]; tags?: Partial>; }): Promise { const repo = args.repo ?? process.cwd(); try { // Refused at the boundary rather than rendered: an unknown key would compose a // token the grammar accepts and no reader asks for — a tag nothing routes on, // which is worse than an error because it LOOKS set. for (const k of Object.keys(args.tags ?? {})) { if (!(TAG_KEYS as readonly string[]).includes(k)) { throw new QueueWriteError( `unknown tag field '${k}' — this verb sets ${TAG_KEYS.join(", ")}. ` + `\`undelivered\` is deliberately not settable here: it is derived from the publish ` + `record by check-undelivered-markers, and asserting it by hand is how that marker starts lying.`, ); } } const { text: original, doc } = readQueue(repo); const items = queueItemsOf(doc); // Locate the block the items live in, so a filed row joins the record model // rather than being appended as text the parser has to re-discover. const block = doc.blocks.find((b) => b.kind === "queue"); let target: QueueItem; if (args.op === "file") { if (args.id) throw new QueueWriteError("do not supply an id when filing: the verb mints and stamps it"); if (!args.text) throw new QueueWriteError("filing needs `text` — what the item is about"); if (!args.priority) throw new QueueWriteError(`filing needs \`priority\` — one of ${PRIORITIES.join(", ")}`); assertSingleLine(args.text); // Identity is the seam's to mint: derived from the text, then RECORDED, so // the id stops depending on prose the moment it exists. const seeded = parseWorkDoc(`## Queue\n- [ ] (${args.priority}) ${args.text}\n`); const minted = queueItemsOf(seeded)[0]; if (!minted) throw new QueueWriteError("the composed row does not parse as queue.v1 — refusing to write it"); target = withRecordedId(minted); // AFTER the id is stamped, never before: `assertNoLeadingTag` re-parses the // rendered row, and the id-strip is the step that exposes a hidden tag. assertNoLeadingTag(target); // Tags AFTER the prose gate, never instead of it: `text` is still judged on its // own, so the field form cannot be used to smuggle what the gate refuses. if (args.tags && Object.keys(args.tags).length) { const { text: next, want } = retagged(target, args.tags); assertTagsAsRead({ ...target, text: next }, want); assertSingleLine(next); target.text = next; } if (block) { block.items.push(target); } else { // AN EMPTY QUEUE SECTION HAS NO BLOCK, because blocks appear when items // parse — and a fresh `groundwork init` queue is exactly that, so the // FIRST filed row would otherwise be the one that cannot be filed. // // The seam's heading classifier is private and this module will not widen // a shipped package's surface for its own convenience, so THE PARSER IS // THE ORACLE instead of a guess: append the rendered line, re-parse, and // accept only if the document now yields exactly this item as a queue // item. If the append landed outside the queue section — a document whose // queue is not last — the re-parse does not see it and this REFUSES // rather than corrupting the file. const candidate = `${original.replace(/\n*$/, "\n")}${renderQueueLine(target)}\n`; const reparsed = parseWorkDoc(candidate); const got = queueItemsOf(reparsed); if (got.length !== items.length + 1 || !got.some((i) => i.id === target.id)) { throw new QueueWriteError( `${QUEUE_DOC} has no parsed queue block and appending did not produce one — the queue section is ` + `probably not the last section in the file. Refusing to guess where the row goes; file the first ` + `item by hand once, or move the queue section last.`, ); } // `own` names the row this call authored. It is already recorded above, so this // changes nothing today — it is stated so the guarantee survives a refactor that // stops pre-stamping, rather than depending on line 361 staying where it is. const w0 = writeDoc(repo, QUEUE_DOC, reparsed, original, [], [target.id]); return { ok: true, op: args.op, id: target.id, priority: args.priority, text: target.text, wrote: w0.written ? [QUEUE_DOC] : [], stampedNotSupplied: w0.stamped.filter((id) => id !== target.id), tags: tagsAsRead(target), }; } } else if (args.op === "retag") { /* * ⛔ THE WHOLE POINT: TAGS CHANGE WITHOUT THE TEXT BEING REWRITTEN. * * `amend` would have worked by making the caller resend the row's prose, and * that is the defect in miniature — a verb that makes you restate 400 characters * to set one field is a verb nobody uses at the transition, which is how the * marker went unwritten in the first place. So `retag` REFUSES `text`: if it * accepted it, the easy path would silently be "rewrite the row" again. */ if (!args.id) throw new QueueWriteError("retag needs `id` — which item to tag"); if (args.text !== undefined) { throw new QueueWriteError( "retag does not take `text`: it exists so a tag can be set WITHOUT rewriting the item's prose. " + "Use `amend` to change what the item says.", ); } if (args.priority !== undefined) { throw new QueueWriteError("retag does not take `priority` — use `reprioritise`"); } const changes = args.tags ?? {}; if (!Object.keys(changes).length) { throw new QueueWriteError( `retag needs \`tags\` — at least one of ${TAG_KEYS.join(", ")} (a value to set, or null to clear)`, ); } const found = items.find((i) => i.id === args.id); if (!found) throw new QueueWriteError(`no queue item with id '${args.id}' in ${QUEUE_DOC}`); target = found; const { text: next, want } = retagged(target, changes); // Composed on a COPY and judged BEFORE the item is mutated, so a refusal cannot // leave a half-tagged row in the document about to be written. assertTagsAsRead({ ...target, text: next }, want); assertSingleLine(next); target.text = next; } else { if (!args.id) throw new QueueWriteError(`${args.op} needs \`id\` — which item to change`); const found = items.find((i) => i.id === args.id); if (!found) throw new QueueWriteError(`no queue item with id '${args.id}' in ${QUEUE_DOC}`); target = found; if (args.op === "amend") { if (!args.text) throw new QueueWriteError("amend needs `text`"); assertSingleLine(args.text); // Amend has the same hole as file — the rewritten text occupies the same // structural slot — so it gets the same gate. Applied to a COPY first, so // a refusal cannot leave the in-memory item half-changed. assertNoLeadingTag({ ...target, text: args.text }); target.text = args.text; } else { if (!args.priority) throw new QueueWriteError(`reprioritise needs \`priority\` — one of ${PRIORITIES.join(", ")}`); target.priority = args.priority; // A raw tag that disagreed with the parsed priority would re-render the // old value; clearing it keeps the two from drifting apart. delete (target as { priorityRaw?: string }).priorityRaw; } } const w = writeDoc(repo, QUEUE_DOC, doc, original); return { ok: true, op: args.op, id: target.id, priority: (target.priority ?? args.priority) as (typeof PRIORITIES)[number], text: target.text, wrote: w.written ? [QUEUE_DOC] : [], stampedNotSupplied: w.stamped.filter((id) => id !== target.id), tags: tagsAsRead(target), }; } catch (e) { if (e instanceof StaleWriteError) { return { ok: false, error: e.message, staleWrite: { doc: e.rel, drift: e.detail, alreadyWritten: e.alreadyWritten } }; } if (e instanceof QueueWriteError) return { ok: false, error: e.message }; throw e; } }