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.
- package/dist/gated-head.js +130 -0
- package/dist/gated-head.js.map +1 -0
- package/dist/server.js +2 -0
- package/dist/server.js.map +1 -1
- package/dist/tools/queue-write.js +431 -0
- package/dist/tools/queue-write.js.map +1 -0
- package/dist/tools/records.js +164 -6
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/tree-provenance.js +107 -0
- package/dist/tools/tree-provenance.js.map +1 -0
- package/package.json +1 -1
- package/src/gated-head.ts +134 -0
- package/src/server.ts +8 -0
- package/src/tools/queue-write.ts +485 -0
- package/src/tools/records.ts +188 -6
- package/src/tools/tree-provenance.ts +136 -0
|
@@ -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
|
+
}
|