agent-coord-mcp 0.26.17 → 0.26.19
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/server.js +5 -4
- package/dist/server.js.map +1 -1
- package/dist/tools/admin.js +8 -2
- package/dist/tools/admin.js.map +1 -1
- package/dist/tools/away.js +45 -3
- package/dist/tools/away.js.map +1 -1
- package/dist/tools/records.js +269 -20
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/rooms.js +2 -2
- package/dist/tools/rooms.js.map +1 -1
- package/dist/tools/shared.js +18 -0
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/stall.js +28 -1
- package/dist/tools/stall.js.map +1 -1
- package/dist/tools/transport.js +12 -5
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/work.js +59 -5
- package/dist/tools/work.js.map +1 -1
- package/dist/tools/worktrees.js +245 -3
- package/dist/tools/worktrees.js.map +1 -1
- package/dist/work.js +1 -1
- package/dist/work.js.map +1 -1
- package/hooks/peek-coord.mjs +0 -0
- package/hooks/tmux-pusher.mjs +0 -0
- package/package.json +11 -14
- package/scripts/coord-attention-clock.mjs +0 -0
- package/scripts/coord-node.sh +0 -0
- package/scripts/coord-stall-clock.mjs +0 -0
- package/scripts/coord-token.mjs +0 -0
- package/scripts/probe-tmux-liveness.sh +0 -0
- package/scripts/spawn-agent.sh +0 -0
- package/scripts/stop-agent.sh +0 -0
- package/scripts/typed-record-stats.mjs +0 -0
- package/src/server.ts +11 -3
- package/src/tools/admin.ts +8 -1
- package/src/tools/away.ts +48 -4
- package/src/tools/records.ts +274 -18
- package/src/tools/rooms.ts +2 -1
- package/src/tools/shared.ts +18 -0
- package/src/tools/stall.ts +29 -1
- package/src/tools/transport.ts +12 -4
- package/src/tools/work.ts +70 -4
- package/src/tools/worktrees.ts +267 -3
- package/src/work.ts +4 -0
package/src/tools/away.ts
CHANGED
|
@@ -155,7 +155,14 @@ export function secondCoordinatorRefusal(
|
|
|
155
155
|
|
|
156
156
|
export const coordAwaySchema = {
|
|
157
157
|
project: z.string().min(1),
|
|
158
|
-
|
|
158
|
+
// Omitted → a QUERY: report current state and mutate nothing (q-1abbbdbf).
|
|
159
|
+
// `/coord-away` used to be a bare toggle read by a human deciding which
|
|
160
|
+
// direction to call it — the incident this fixes is David typing it
|
|
161
|
+
// meaning ARM while it was already armed, and the aide independently
|
|
162
|
+
// reading the same typed command as RELEASE, with no state visible to
|
|
163
|
+
// either of them before acting. State-it-aloud-first only works if there
|
|
164
|
+
// is a state to read that does not itself flip the switch.
|
|
165
|
+
on: z.boolean().optional(),
|
|
159
166
|
/** The agent leading in David's absence — the aide. */
|
|
160
167
|
leadId: z.string().optional(),
|
|
161
168
|
/** Repo-relative path where decisions taken during the absence are written. */
|
|
@@ -184,7 +191,7 @@ async function measureCoverage(repo?: string): Promise<AwayCoverage | null> {
|
|
|
184
191
|
|
|
185
192
|
export async function coordAwayTool(args: {
|
|
186
193
|
project: string;
|
|
187
|
-
on
|
|
194
|
+
on?: boolean;
|
|
188
195
|
leadId?: string;
|
|
189
196
|
decisionLog?: string;
|
|
190
197
|
until?: string;
|
|
@@ -195,6 +202,38 @@ export async function coordAwayTool(args: {
|
|
|
195
202
|
const state = readAway();
|
|
196
203
|
const prior = state[args.project];
|
|
197
204
|
|
|
205
|
+
// QUERY — `on` omitted. Reads, never writes: no coverage measurement, no
|
|
206
|
+
// refusal, no side effect. This is the call a card makes to state current
|
|
207
|
+
// status aloud BEFORE deciding which direction to arm/release, so the
|
|
208
|
+
// decision is never made blind the way it was on 2026-08-30/31.
|
|
209
|
+
if (args.on === undefined) {
|
|
210
|
+
if (!prior?.on) {
|
|
211
|
+
return {
|
|
212
|
+
ok: true as const,
|
|
213
|
+
query: true as const,
|
|
214
|
+
project: args.project,
|
|
215
|
+
armed: false,
|
|
216
|
+
state: prior ?? null,
|
|
217
|
+
summary: prior
|
|
218
|
+
? `'${args.project}' is NOT armed — last released ${prior.at}.`
|
|
219
|
+
: `'${args.project}' is NOT armed — never armed.`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
ok: true as const,
|
|
224
|
+
query: true as const,
|
|
225
|
+
project: args.project,
|
|
226
|
+
armed: true,
|
|
227
|
+
state: prior,
|
|
228
|
+
summary:
|
|
229
|
+
`'${args.project}' is ARMED — '${prior.leadId}' leads since ${prior.at}` +
|
|
230
|
+
`${prior.until ? ` until ${prior.until}` : ""}. ` +
|
|
231
|
+
`Coverage ${prior.coverage.measurable}/${prior.coverage.checked}` +
|
|
232
|
+
`${prior.blindAcknowledged ? " (ACKNOWLEDGED BLIND)" : ""}. ` +
|
|
233
|
+
`Decisions logged to ${prior.decisionLog}.`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
198
237
|
if (args.on) {
|
|
199
238
|
// A LEAD WITH NO NAME IS AN ABSENCE DESCRIBED AS COVERED, which is strictly
|
|
200
239
|
// worse than an absence everyone can see is uncovered.
|
|
@@ -261,8 +300,12 @@ export async function coordAwayTool(args: {
|
|
|
261
300
|
state: state[args.project],
|
|
262
301
|
refused: LEAD_REFUSED,
|
|
263
302
|
parked: PARKED_CATEGORIES,
|
|
303
|
+
// A confirmation naming the transition, not just the imperative
|
|
304
|
+
// direction (q-1abbbdbf) — "ON" and "already ON" read the same at a
|
|
305
|
+
// glance; "OFF → ARMED" cannot be misread as a no-op or a repeat.
|
|
306
|
+
transition: "OFF → ARMED" as const,
|
|
264
307
|
announce:
|
|
265
|
-
`AGENT_ACTION: coord-away ON — David is away${args.until ? ` until ${args.until}` : ""}; '${args.leadId}' LEADS. ` +
|
|
308
|
+
`OFF → ARMED. AGENT_ACTION: coord-away ON — David is away${args.until ? ` until ${args.until}` : ""}; '${args.leadId}' LEADS. ` +
|
|
266
309
|
`Decides: planning · priority · curation · roadmap · canon · releases under standing authorisation. ` +
|
|
267
310
|
`Never: merges, gates, or takes a code lane. Parks for David: ${PARKED_CATEGORIES.join(" · ")}. ` +
|
|
268
311
|
`Decisions logged to ${args.decisionLog}. ` +
|
|
@@ -280,8 +323,9 @@ export async function coordAwayTool(args: {
|
|
|
280
323
|
return {
|
|
281
324
|
ok: true as const,
|
|
282
325
|
state: state[args.project],
|
|
326
|
+
transition: "ARMED → RELEASED" as const,
|
|
283
327
|
announce:
|
|
284
|
-
`AGENT_ACTION: coord-away RELEASED on '${args.project}' — David is back; '${prior.leadId}' stands down as lead. ` +
|
|
328
|
+
`ARMED → RELEASED. AGENT_ACTION: coord-away RELEASED on '${args.project}' — David is back; '${prior.leadId}' stands down as lead. ` +
|
|
285
329
|
`Anything parked during the absence is still parked: release does not authorise it. Decisions taken: ${prior.decisionLog}.`,
|
|
286
330
|
};
|
|
287
331
|
}
|
package/src/tools/records.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { z } from "zod";
|
|
|
18
18
|
import {
|
|
19
19
|
parseWorkDoc,
|
|
20
20
|
renderWorkDoc,
|
|
21
|
+
renderWorkDocForWrite,
|
|
21
22
|
queueItemsOf,
|
|
22
23
|
doneEntriesOf,
|
|
23
24
|
type QueueItem,
|
|
@@ -25,10 +26,16 @@ import {
|
|
|
25
26
|
phaseCitationsDetailed,
|
|
26
27
|
newlyTickedInDiff,
|
|
27
28
|
sweepTagOf,
|
|
29
|
+
awaitingOf,
|
|
30
|
+
refsIn,
|
|
31
|
+
refsMatch,
|
|
32
|
+
workstreamsV1RowsOf,
|
|
33
|
+
type WorkstreamsV1Row,
|
|
34
|
+
type DoneEntry,
|
|
28
35
|
} from "@davidbalzan/groundwork-seam";
|
|
29
36
|
import { ensureWorktreeTool } from "./worktrees.js";
|
|
30
37
|
import { boardRefFor, classifyBoardRef } from "./board-ref.js";
|
|
31
|
-
import { haltState } from "./stall.js";
|
|
38
|
+
import { haltState, isInFlightStatus } from "./stall.js";
|
|
32
39
|
import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js";
|
|
33
40
|
|
|
34
41
|
const QUEUE_DOC = "docs/QUEUE.md";
|
|
@@ -51,11 +58,23 @@ const readDoc = (repo: string, rel: string): { text: string; doc: WorkDoc } | nu
|
|
|
51
58
|
* was built, not assumed). Re-rendering an untouched file would still be a write,
|
|
52
59
|
* and a write is a diff someone has to review — so the no-op case returns false.
|
|
53
60
|
*/
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
/**
|
|
62
|
+
* EVERY SUPPORTED WRITE OF A WORK DOC GOES THROUGH HERE, and it stamps.
|
|
63
|
+
*
|
|
64
|
+
* `renderWorkDocForWrite` records identity for any queue item that lacks it,
|
|
65
|
+
* including rows this caller did not author (q-c50e9b83) — the absorbed raw
|
|
66
|
+
* append is exactly the case, and stamping only the caller's own rows would keep
|
|
67
|
+
* the defect and move the blame. Every id written is the one the item already
|
|
68
|
+
* has, so nothing changes value.
|
|
69
|
+
*
|
|
70
|
+
* Returns what it stamped so the caller can report it rather than leaving an
|
|
71
|
+
* absorbing writer to find it in a diff.
|
|
72
|
+
*/
|
|
73
|
+
function writeDoc(repo: string, rel: string, doc: WorkDoc, original: string): { written: boolean; stamped: string[] } {
|
|
74
|
+
const { text, stamped } = renderWorkDocForWrite(doc);
|
|
75
|
+
if (text === original) return { written: false, stamped: [] };
|
|
76
|
+
writeFileSync(path.join(repo, rel), text);
|
|
77
|
+
return { written: true, stamped };
|
|
59
78
|
}
|
|
60
79
|
|
|
61
80
|
const git = (repo: string, args: string[]): string =>
|
|
@@ -67,6 +86,25 @@ function prNumber(pr: string): string | null {
|
|
|
67
86
|
return m ? (m[1] as string) : null;
|
|
68
87
|
}
|
|
69
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The `owner/repo` a BARE `#N` in this repo's docs should be read as naming —
|
|
91
|
+
* the same derivation `groundwork doctor` makes (`originRepoOf`) before it ties
|
|
92
|
+
* a closed item's citation to DONE.md. `land` must decide "already cited" with
|
|
93
|
+
* the SAME rule the auditor will apply, or the two disagree again one level
|
|
94
|
+
* down: a bare `#77` in an item body is a tie for doctor only when the origin
|
|
95
|
+
* URL says which repo, and it is a tie here under exactly that condition.
|
|
96
|
+
* Null (no remote, not a repo) means an unknown context, never a guessed one.
|
|
97
|
+
*/
|
|
98
|
+
function originRepoOf(repo: string): string | null {
|
|
99
|
+
try {
|
|
100
|
+
const url = git(repo, ["remote", "get-url", "origin"]);
|
|
101
|
+
const m = /[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(url);
|
|
102
|
+
return m ? (m[1] as string) : null;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
70
108
|
// ---------- blocked / no-downstream ----------
|
|
71
109
|
|
|
72
110
|
const BLOCKED_RE = /\bblocked (?:by|on)\b[:\s]*([^\s·,.;]+)/i;
|
|
@@ -127,6 +165,117 @@ export function noDownstream(items: QueueItem[]): QueueItem[] {
|
|
|
127
165
|
});
|
|
128
166
|
}
|
|
129
167
|
|
|
168
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
169
|
+
* DELIVERED — READ THE RECORD'S STRUCTURE, NOT A SUBSTRING OF THE FILE.
|
|
170
|
+
*
|
|
171
|
+
* This was `doneText.includes(id) || boardText.includes(id)` over two whole
|
|
172
|
+
* documents, so ANY MENTION ANYWHERE counted as "handed out" — including a row
|
|
173
|
+
* written specifically to warn that an item must NOT be built (q-2ba7f0c5).
|
|
174
|
+
* The inversion is the finding: the more carefully a coordinator documents why
|
|
175
|
+
* something must not be routed, the more completely it disappears from the verb
|
|
176
|
+
* that routes.
|
|
177
|
+
*
|
|
178
|
+
* MEASURED ON `origin/main` @17b72ff, and the numbers moved the fix twice:
|
|
179
|
+
*
|
|
180
|
+
* q-11257590 hidden by the AIDE'S OWN `🚧 In Progress` ROW, which merely
|
|
181
|
+
* MENTIONS it in its Last note. A status-only rule leaves it
|
|
182
|
+
* hidden — the occurrence-vs-position defect one level in — so
|
|
183
|
+
* identification has to be the row's SUBJECT, not any of its cells.
|
|
184
|
+
* q-ad0fc8d4 hidden by its own `⏸ Returned to the aide unbuilt` row.
|
|
185
|
+
* q-507e80c4 hidden with NO PARSED ROW AT ALL: the id sits in board prose
|
|
186
|
+
* outside the table, which a whole-file substring test cannot
|
|
187
|
+
* distinguish from a lane.
|
|
188
|
+
* q-507e80c4 ALSO hidden by a DONE entry that CITES it as the still-open
|
|
189
|
+
* part (b) of work whose part (a) landed — the canon case
|
|
190
|
+
* q-912a67e8 requires, LIVE today rather than latent.
|
|
191
|
+
*
|
|
192
|
+
* Neither half is fixed by relabelling a row: that is satisfying a guard by
|
|
193
|
+
* editing what it reads (q-507e80c4 constraint 1) and it would destroy the
|
|
194
|
+
* warning the row exists to give.
|
|
195
|
+
*/
|
|
196
|
+
|
|
197
|
+
/** The ids a board row names as ITS OWN SUBJECT — the Stream cell only. */
|
|
198
|
+
function rowSubjectIds(row: WorkstreamsV1Row): string[] {
|
|
199
|
+
return [...String(row.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((m) => m[1] as string);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Does the board show this item as IN SOMEBODY'S HANDS?
|
|
204
|
+
*
|
|
205
|
+
* Two conditions, and both are needed — measured, not reasoned:
|
|
206
|
+
* STATUS the row is in flight (`isInFlightStatus`, shared with `stall_check`
|
|
207
|
+
* since #219 rather than a second glyph list). `⏸ Parked`,
|
|
208
|
+
* `⏸ Returned`, `⏳ Queued`, `⛔ Blocked`, `🚫 Unstaffable`,
|
|
209
|
+
* `🔻 Orphaned` describe work nobody holds, so they do not silence.
|
|
210
|
+
* SUBJECT the row's Stream cell names the item — by recorded id, or by the
|
|
211
|
+
* text prefix `claim` has always written there. A mention in a Last
|
|
212
|
+
* note is one lane REFERRING to another item — the aide's live row
|
|
213
|
+
* does exactly this — and a status-only rule would still hide it.
|
|
214
|
+
*
|
|
215
|
+
* THE PREFIX ARM IS FOR THE ROWS ALREADY ON THE BOARD, and without it this fix
|
|
216
|
+
* would have shipped a transitional hole in its own negative control: every row
|
|
217
|
+
* `claim` wrote before this change carries `keyOf(item)` — a 60-character text
|
|
218
|
+
* prefix — and no id at all, so an id-only rule leaves a lane's OWN live item
|
|
219
|
+
* routable. Measured: q-2ba7f0c5's row, written minutes earlier by the
|
|
220
|
+
* pre-merge server, was offered back while it was in hand. Rows written from
|
|
221
|
+
* here carry the id (see `claim`), so the prefix arm is the compatibility half
|
|
222
|
+
* rather than the mechanism, and it is deliberately still SUBJECT-only.
|
|
223
|
+
*/
|
|
224
|
+
function boardHoldsItem(rows: WorkstreamsV1Row[], id: string, text: string): boolean {
|
|
225
|
+
const key = keyOf({ text } as QueueItem).replace(/\s+/g, " ").trim();
|
|
226
|
+
return rows.some((r) => {
|
|
227
|
+
if (!isInFlightStatus(r.status)) return false;
|
|
228
|
+
if (rowSubjectIds(r).includes(id)) return true;
|
|
229
|
+
const stream = String(r.stream).replace(/\s+/g, " ").trim();
|
|
230
|
+
return key.length >= 20 && stream.startsWith(key.slice(0, Math.min(key.length, 60)));
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Is this DONE entry the RECORD OF THIS ITEM CLOSING, rather than an entry that
|
|
236
|
+
* merely MENTIONS it?
|
|
237
|
+
*
|
|
238
|
+
* `DONE.md` has no status cells, so the board's answer does not transfer and
|
|
239
|
+
* this needs its own — and the item's own words for what it needs are that "the
|
|
240
|
+
* canon rule must name its citations in a form the join can tell apart".
|
|
241
|
+
*
|
|
242
|
+
* MEASURED ON THE REAL `DONE.md` @17b72ff, which settles which form is which:
|
|
243
|
+
* BRACKETED ids appear ZERO times; all ELEVEN id mentions are BARE, in prose,
|
|
244
|
+
* inside backticks — every one of them a citation. So:
|
|
245
|
+
*
|
|
246
|
+
* DELIVERY the entry carries the item's own COMPOSED SUMMARY — the
|
|
247
|
+
* deterministic form `land` writes, `summarize(item.text)` — or it
|
|
248
|
+
* names the item in the recorded-id sigil `⟨id⟩`, which is this
|
|
249
|
+
* corpus's "this IS that item" marker (it is how QUEUE.md records
|
|
250
|
+
* identity, and a human writing it in DONE.md means exactly that).
|
|
251
|
+
* CITATION a BARE `q-xxxxxxxx` anywhere. This is the form our own shipped
|
|
252
|
+
* canon (q-912a67e8) produces: a `DONE` entry may name a residual
|
|
253
|
+
* gap but MUST CITE A QUEUE ITEM for it. Obeying that rule wrote an
|
|
254
|
+
* open item's id into `DONE.md`, and the substring join read the
|
|
255
|
+
* citation as delivery — a canon rule and a code path in direct
|
|
256
|
+
* contradiction, where COMPLIANCE with the canon triggered the
|
|
257
|
+
* defect. LIVE on origin/main, not latent: the part (a) entry for
|
|
258
|
+
* q-507e80c4 cites it exactly this way while part (b) is open work.
|
|
259
|
+
*
|
|
260
|
+
* Note what is deliberately NOT the rule: a shared PR ref. `land` never writes
|
|
261
|
+
* the item id into `DONE.md` at all (the summary excludes the `⟨id⟩` token), and
|
|
262
|
+
* the live entry for q-507e80c4 cites `#219` while the item's own line cites no
|
|
263
|
+
* PR — so a ref-tie would have marked a genuinely-open part (b) as delivered.
|
|
264
|
+
*/
|
|
265
|
+
function doneRecordsDelivery(entries: DoneEntry[], id: string, text: string): boolean {
|
|
266
|
+
const norm = (v: string) => v.replace(/\s+/g, " ").replace(/\*\*/g, "").trim();
|
|
267
|
+
const target = norm(summarize(text)).replace(/…$/, "");
|
|
268
|
+
const sigil = `⟨${id}⟩`;
|
|
269
|
+
return entries.some((e) => {
|
|
270
|
+
const t = String(e.text);
|
|
271
|
+
if (t.includes(sigil)) return true;
|
|
272
|
+
// A leading recorded-id token is stripped before comparing, so an entry
|
|
273
|
+
// written as `⟨id⟩ <summary>` and one written as `<summary>` agree.
|
|
274
|
+
const body = norm(t).replace(/^⟨q-[0-9a-f]{8}⟩\s*/, "");
|
|
275
|
+
return target.length >= 12 && body.startsWith(target);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
130
279
|
// ---------- next_unblocked ----------
|
|
131
280
|
|
|
132
281
|
export const nextUnblockedSchema = { project: z.string().min(1), repo: z.string().optional() };
|
|
@@ -166,12 +315,12 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
166
315
|
// the item, which is how the memory was lost in the first place.
|
|
167
316
|
const delivered = new Set<string>();
|
|
168
317
|
const doneDoc = readDoc(repo, DONE_DOC);
|
|
169
|
-
const doneText = doneDoc?.text ?? "";
|
|
170
318
|
const boardDoc = readDoc(repo, BOARD_DOC);
|
|
171
319
|
const boardText = boardDoc?.text ?? "";
|
|
320
|
+
const doneEntries = doneDoc ? doneEntriesOf(doneDoc.doc) : [];
|
|
321
|
+
const boardRows = boardDoc ? workstreamsV1RowsOf(boardDoc.doc) : [];
|
|
172
322
|
for (const i of items) {
|
|
173
|
-
|
|
174
|
-
if (doneText.includes(i.id) || boardText.includes(i.id)) delivered.add(i.id);
|
|
323
|
+
if (boardHoldsItem(boardRows, i.id, String(i.text)) || doneRecordsDelivery(doneEntries, i.id, String(i.text))) delivered.add(i.id);
|
|
175
324
|
}
|
|
176
325
|
|
|
177
326
|
const open = items.filter((i) => !i.done && !delivered.has(i.id));
|
|
@@ -188,8 +337,33 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
188
337
|
// Skipped VISIBLY, same discipline as a blocked top item — never silently
|
|
189
338
|
// dropped, so a caller can tell "nothing left for me" from "nothing left".
|
|
190
339
|
const notClaimable: { item: string; sweepTag: string }[] = [];
|
|
340
|
+
// AWAITING A HUMAN (q-e334937d). "Unblocked" means dependency-free, and an
|
|
341
|
+
// item awaiting a ruling is dependency-free and unbuildable at once. Measured
|
|
342
|
+
// 2026-09-02: this verb offered q-11257590 as next; the lane read the
|
|
343
|
+
// acceptance PROSE to learn it awaited David, declined, and picked by hand —
|
|
344
|
+
// a read every lane offered it would pay again. The fact is now a FIELD on
|
|
345
|
+
// the item (`**[AWAITS:<who>]**`, read by the seam), never inferred from
|
|
346
|
+
// prose. Its own axis, checked FIRST: it is the most specific thing that can
|
|
347
|
+
// be true of an item, and "three items wait on a human" is a fact David can
|
|
348
|
+
// act on where "nothing to route" is not. Skipped for routing, never dropped.
|
|
349
|
+
//
|
|
350
|
+
// GATHERED OVER EVERY OPEN ITEM, NOT OVER `open` — measured on the real queue
|
|
351
|
+
// the first time this ran: q-11257590 carried the marker and appeared in NO
|
|
352
|
+
// bucket, because the board holds a "⏸ Parked — awaits David" row naming it
|
|
353
|
+
// and the delivered-join above reads any board mention as "handed out". The
|
|
354
|
+
// row written to WARN about the item was what hid it. Routing still honours
|
|
355
|
+
// the join; this report does not, because its reader is the human the item
|
|
356
|
+
// waits on, and a pending decision does not stop being pending when someone
|
|
357
|
+
// writes it on the board.
|
|
358
|
+
const awaitingDecision: { item: string; id: string; who: string; onBoard: boolean }[] = [];
|
|
359
|
+
for (const i of items) {
|
|
360
|
+
if (i.done) continue;
|
|
361
|
+
const who = awaitingOf(i);
|
|
362
|
+
if (who) awaitingDecision.push({ item: keyOf(i), id: i.id, who, onBoard: boardText.includes(i.id) });
|
|
363
|
+
}
|
|
191
364
|
let pick: QueueItem | null = null;
|
|
192
365
|
for (const { i } of ranked) {
|
|
366
|
+
if (awaitingOf(i)) continue; // reported above; never routed
|
|
193
367
|
const tag = sweepTagOf(i);
|
|
194
368
|
if (tag && /^canon(\.\d+)?$/.test(tag)) {
|
|
195
369
|
notClaimable.push({ item: keyOf(i), sweepTag: tag });
|
|
@@ -223,9 +397,15 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
223
397
|
// would read a claimability gap as a dependency, which is a different
|
|
224
398
|
// remedy (route it, don't wait for it).
|
|
225
399
|
notClaimable,
|
|
400
|
+
// A THIRD AXIS, and not a variant of either above: `skipped` waits on
|
|
401
|
+
// another ITEM, `notClaimable` is not this caller's to take, and this waits
|
|
402
|
+
// on a HUMAN. The remedy differs each time (wait · route · ask), so one
|
|
403
|
+
// list covering them would leave the reader to infer which applies.
|
|
404
|
+
awaitingDecision,
|
|
226
405
|
boardHunks: [
|
|
227
406
|
...skipped.map((s) => `⏭ skipped — blocked by ${s.blockedBy}`),
|
|
228
407
|
...notClaimable.map((s) => `⏭ skipped — not worker-claimable (${s.sweepTag})`),
|
|
408
|
+
...awaitingDecision.map((a) => `⏸ skipped — awaiting decision from ${a.who} (${a.id})`),
|
|
229
409
|
],
|
|
230
410
|
// A SEPARATE AXIS, deliberately. See noDownstream().
|
|
231
411
|
noDownstream: undiscriminating
|
|
@@ -422,7 +602,14 @@ export async function claimTool(args: { project: string; agentId: string; itemId
|
|
|
422
602
|
};
|
|
423
603
|
}
|
|
424
604
|
|
|
425
|
-
|
|
605
|
+
// THE ROW NAMES THE ITEM BY ID, so the delivered-join can recognise a row this
|
|
606
|
+
// verb itself wrote. Measured on the live board: `keyOf` is the item's text
|
|
607
|
+
// prefix and the recorded `⟨id⟩` token is not part of `text`, so NO
|
|
608
|
+
// claim-written row has ever carried an id — the join could only ever match
|
|
609
|
+
// HAND-WRITTEN rows, which is the opposite of what it was for. Stamping it
|
|
610
|
+
// makes the identification recorded rather than derived from a 60-character
|
|
611
|
+
// prose prefix that any rewording changes.
|
|
612
|
+
const boardHunk = `| ⟨${item.id}⟩ ${keyOf(item)} | ${args.agentId} | \`${intendedRef}\` · ${wt.path} | 🚧 In Progress | — | claimed |`;
|
|
426
613
|
|
|
427
614
|
// 2.4b — THE VERB WRITES THE ROW. Reported by default, applied with
|
|
428
615
|
// write:true, matching `land`. A returned hunk that a human pastes is the
|
|
@@ -459,6 +646,8 @@ export const landSchema = {
|
|
|
459
646
|
repo: z.string().optional(),
|
|
460
647
|
base: z.string().optional(),
|
|
461
648
|
write: z.boolean().optional(),
|
|
649
|
+
/** One-line result, written after the citation on the closed item's line. */
|
|
650
|
+
result: z.string().optional(),
|
|
462
651
|
};
|
|
463
652
|
|
|
464
653
|
export async function landTool(args: {
|
|
@@ -468,6 +657,7 @@ export async function landTool(args: {
|
|
|
468
657
|
repo?: string;
|
|
469
658
|
base?: string;
|
|
470
659
|
write?: boolean;
|
|
660
|
+
result?: string;
|
|
471
661
|
}) {
|
|
472
662
|
const repo = args.repo ?? process.cwd();
|
|
473
663
|
const base = args.base ?? "main";
|
|
@@ -527,13 +717,46 @@ export async function landTool(args: {
|
|
|
527
717
|
const target = args.queueItemId ? items.find((i) => i.id === args.queueItemId) : null;
|
|
528
718
|
if (args.queueItemId && !target) return { ok: false as const, error: `no queue item with id '${args.queueItemId}'` };
|
|
529
719
|
|
|
530
|
-
// CLOSE = STATUS
|
|
531
|
-
// seam re-renders `- [x] (P2) <text>` from the same text it parsed, so
|
|
532
|
-
// cannot reword an item. That is asserted by a fixture, not by
|
|
720
|
+
// CLOSE = STATUS + CITATION. The item's priority and body BYTES are untouched:
|
|
721
|
+
// the seam re-renders `- [x] (P2) <text>` from the same text it parsed, so
|
|
722
|
+
// closing cannot reword an item. That is asserted by a fixture, not by
|
|
723
|
+
// inspection.
|
|
724
|
+
//
|
|
725
|
+
// AND THE LINE CITES WHAT CLOSED IT (q-6d21ff84). `doctor:queue-done-loop`
|
|
726
|
+
// requires a `[x]` item's FIRST LINE to cite the PR that DONE.md records —
|
|
727
|
+
// that strictness is what makes a closure machine-checkable, and the repo
|
|
728
|
+
// paid for it. This verb ticked the box and wrote DONE.md and left line 1
|
|
729
|
+
// uncited, so EVERY successful `land` turned main red at the next root gate
|
|
730
|
+
// and the coordinator hand-repaired a file the verb had just written
|
|
731
|
+
// (`c29becd`, and again on q-e334937d). The writer and the auditor were built
|
|
732
|
+
// to different contracts; the fix belongs in the writer, because loosening
|
|
733
|
+
// the auditor to accept what the writer emits is how a check stops being one.
|
|
734
|
+
//
|
|
735
|
+
// A SUFFIX, NOT A REWRITE. The existing text is preserved byte-for-byte as a
|
|
736
|
+
// prefix; ` · **closed by <ref>[ — <result>]**` is appended — the exact form
|
|
737
|
+
// q-40449919 carries after its manual repair. Line 1 only, because line 1 is
|
|
738
|
+
// all `item.text` is and all the check reads; a citation on a continuation
|
|
739
|
+
// line satisfies nothing (the occurrence-vs-position trap, hit twice).
|
|
740
|
+
//
|
|
741
|
+
// IDEMPOTENT: an item whose line already cites this PR (in any spelling
|
|
742
|
+
// `refsIn` ties to it) is left byte-identical, so a second `land` cannot
|
|
743
|
+
// double-cite and a hand-cited item is not re-cited.
|
|
533
744
|
let queueChanged = false;
|
|
745
|
+
let citationAppended: string | null = null;
|
|
746
|
+
// The DONE entry and the emitted event describe the WORK, from the text the
|
|
747
|
+
// item had BEFORE the citation was appended — otherwise the summary would
|
|
748
|
+
// carry the citation the DONE line already ends with, twice on one line.
|
|
749
|
+
const originalText = target ? String(target.text) : null;
|
|
534
750
|
if (target) {
|
|
535
751
|
target.done = true;
|
|
536
752
|
queueChanged = true;
|
|
753
|
+
const prRef = refsIn(args.pr)[0] ?? { raw: args.pr, repo: null, number: n };
|
|
754
|
+
const contextRepo = originRepoOf(repo);
|
|
755
|
+
const alreadyCited = refsIn(String(target.text)).some((r) => refsMatch(r, prRef, { contextRepo }));
|
|
756
|
+
if (!alreadyCited) {
|
|
757
|
+
citationAppended = ` · **closed by ${args.pr}${args.result ? ` — ${args.result.trim()}` : ""}**`;
|
|
758
|
+
target.text = `${target.text}${citationAppended}`;
|
|
759
|
+
}
|
|
537
760
|
}
|
|
538
761
|
|
|
539
762
|
const already = doneEntriesOf(d.doc).some((e) => new RegExp(`#${n}\\b`).test(String(e.ref ?? "")));
|
|
@@ -548,7 +771,7 @@ export async function landTool(args: {
|
|
|
548
771
|
// So: the summary comes from the item being closed, and a bare `#N` is reported
|
|
549
772
|
// as UNDER-QUALIFIED rather than silently written.
|
|
550
773
|
const bareRef = !/[\w.-]+\/[\w.-]+#\d+/.test(String(args.pr));
|
|
551
|
-
const summary =
|
|
774
|
+
const summary = originalText !== null ? summarize(originalText) : null;
|
|
552
775
|
|
|
553
776
|
const doneLine =
|
|
554
777
|
already || !summary ? null : `- [x] ${summary} — ${args.pr} · ${new Date().toISOString().slice(0, 10)}`;
|
|
@@ -565,8 +788,17 @@ export async function landTool(args: {
|
|
|
565
788
|
// byte-for-byte: `renderWorkDoc` reproduces every unmodelled line verbatim, and
|
|
566
789
|
// an entry added to the record model renders through the pinned glyph contract.
|
|
567
790
|
const wrote: string[] = [];
|
|
791
|
+
let stampedNotSupplied: string[] = [];
|
|
568
792
|
if (args.write) {
|
|
569
|
-
if (queueChanged
|
|
793
|
+
if (queueChanged) {
|
|
794
|
+
const w = writeDoc(repo, QUEUE_DOC, q.doc, q.text);
|
|
795
|
+
if (w.written) wrote.push(QUEUE_DOC);
|
|
796
|
+
// Rows stamped that this call did not ask to touch. `queueItemId` is the
|
|
797
|
+
// one item the caller supplied, so anything else here is a row that
|
|
798
|
+
// arrived unstamped from somewhere: the absorption this verb would
|
|
799
|
+
// otherwise carry to main under its own name.
|
|
800
|
+
stampedNotSupplied = w.stamped.filter((id) => id !== args.queueItemId);
|
|
801
|
+
}
|
|
570
802
|
if (doneLine) {
|
|
571
803
|
const blocks = d.doc.blocks;
|
|
572
804
|
let last = -1;
|
|
@@ -584,7 +816,7 @@ export async function landTool(args: {
|
|
|
584
816
|
return { ok: false as const, error: `the composed DONE line does not parse as done.v1: ${doneLine}` };
|
|
585
817
|
}
|
|
586
818
|
block.entries.push(entry);
|
|
587
|
-
if (writeDoc(repo, DONE_DOC, d.doc, d.text)) wrote.push(DONE_DOC);
|
|
819
|
+
if (writeDoc(repo, DONE_DOC, d.doc, d.text).written) wrote.push(DONE_DOC);
|
|
588
820
|
}
|
|
589
821
|
}
|
|
590
822
|
|
|
@@ -603,7 +835,7 @@ export async function landTool(args: {
|
|
|
603
835
|
if (args.write && target) {
|
|
604
836
|
const after = readDoc(repo, DONE_DOC);
|
|
605
837
|
const recordText = after?.text ?? "";
|
|
606
|
-
const ev: RecordEvent = { kind: "item", target: target.id, ref: args.pr, summary: summarize(
|
|
838
|
+
const ev: RecordEvent = { kind: "item", target: target.id, ref: args.pr, summary: summarize(originalText ?? "") };
|
|
607
839
|
const derived = eventIsDerived(recordText, ev);
|
|
608
840
|
if (!derived.ok) {
|
|
609
841
|
events.refused.push(derived.error);
|
|
@@ -621,7 +853,31 @@ export async function landTool(args: {
|
|
|
621
853
|
pr: `#${n}`,
|
|
622
854
|
comparedAgainst: ref,
|
|
623
855
|
landedIn: landedIn.slice(0, 8),
|
|
624
|
-
|
|
856
|
+
// THE ABSORBING WRITER LEARNS IT ABSORBED SOMETHING. The aide's
|
|
857
|
+
// diff-before-rename guard REFUSES on a foreign change; this REPORTS one it
|
|
858
|
+
// fixed, so a foreign row cannot pass silently in either direction.
|
|
859
|
+
...(stampedNotSupplied.length
|
|
860
|
+
? {
|
|
861
|
+
stampedNotSupplied,
|
|
862
|
+
stampNote:
|
|
863
|
+
`recorded an id for ${stampedNotSupplied.length} queue item(s) this call did not supply (${stampedNotSupplied.join(", ")}) — ` +
|
|
864
|
+
`they arrived unstamped, most likely a raw text append, and would have reddened doctor:queue-id-recorded on whoever committed next. ` +
|
|
865
|
+
`Each id written is the one the item already had, so no value changed.`,
|
|
866
|
+
}
|
|
867
|
+
: {}),
|
|
868
|
+
queueItem: target
|
|
869
|
+
? {
|
|
870
|
+
id: target.id,
|
|
871
|
+
closed: true,
|
|
872
|
+
// `textUnchanged` keeps its meaning — the priority and the body bytes
|
|
873
|
+
// the item had are all still there, in place. What is new is a SUFFIX
|
|
874
|
+
// on line 1, reported separately so a reader can tell "reworded" from
|
|
875
|
+
// "cited": null when the line already cited this PR.
|
|
876
|
+
textUnchanged: citationAppended === null,
|
|
877
|
+
bodyPreserved: true,
|
|
878
|
+
citationAppended,
|
|
879
|
+
}
|
|
880
|
+
: null,
|
|
625
881
|
events,
|
|
626
882
|
candidates: target
|
|
627
883
|
? undefined
|
package/src/tools/rooms.ts
CHANGED
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
setOffset,
|
|
61
61
|
sysMsg,
|
|
62
62
|
moveFile,
|
|
63
|
+
agentIdFromCursorFilename,
|
|
63
64
|
STALE_MS,
|
|
64
65
|
EVICT_MS,
|
|
65
66
|
MAX_WAIT_MS,
|
|
@@ -284,7 +285,7 @@ export async function deleteRoomTool(args: { agentId: string; room: string; forc
|
|
|
284
285
|
// a now-deleted file.
|
|
285
286
|
const cursorsAdjusted: string[] = [];
|
|
286
287
|
for (const fname of await listCursorFiles()) {
|
|
287
|
-
const id = fname
|
|
288
|
+
const { id } = agentIdFromCursorFilename(fname);
|
|
288
289
|
const cursorPath = path.join(CURSOR_DIR, fname);
|
|
289
290
|
let touched = false;
|
|
290
291
|
await updateJson<Cursor>(cursorPath, {}, (current) => {
|
package/src/tools/shared.ts
CHANGED
|
@@ -229,6 +229,24 @@ export type Cursor = {
|
|
|
229
229
|
|
|
230
230
|
export type Source = "inbox" | "room" | "status";
|
|
231
231
|
|
|
232
|
+
// q-40449919 (regression from 0.26.8): a `cursors/*.json` filename has TWO
|
|
233
|
+
// shapes since the push/read cursor split (hooks/push-cursor.mjs) —
|
|
234
|
+
// `<agent>.json` (read) and `<agent>.push.json` (push) — and every reader of
|
|
235
|
+
// `listCursorFiles()` was stripping only the outer `.json`, so a push
|
|
236
|
+
// cursor for `worker-3` parsed as an agent literally named `worker-3.push`.
|
|
237
|
+
// That id is unregistered by construction (false `orphan-inboxes-cursors`),
|
|
238
|
+
// and looking up ITS inbox resolves to a file that never existed, so its
|
|
239
|
+
// real, correct offset compares as past-EOF against an empty file — the
|
|
240
|
+
// bus's only error-level finding, driving `healthy:false` for a healthy
|
|
241
|
+
// fleet. ONE parser, used everywhere a cursor filename becomes an agent id,
|
|
242
|
+
// so the assumption cannot re-drift into some other reader the way the
|
|
243
|
+
// filename shape itself drifted out from under the callers that predate it.
|
|
244
|
+
export function agentIdFromCursorFilename(fname: string): { id: string; kind: "read" | "push" } {
|
|
245
|
+
const push = /^(.*)\.push\.json$/.exec(fname);
|
|
246
|
+
if (push) return { id: push[1], kind: "push" };
|
|
247
|
+
return { id: fname.replace(/\.json$/, ""), kind: "read" };
|
|
248
|
+
}
|
|
249
|
+
|
|
232
250
|
// Resolve the physical file for a (source, agent, channel) tuple.
|
|
233
251
|
export function sourceFile(source: Source, agentId: string, room?: string): string {
|
|
234
252
|
if (source === "inbox") return inboxFile(agentId);
|
package/src/tools/stall.ts
CHANGED
|
@@ -218,6 +218,34 @@ export async function stallClockStatusTool(args: { maxAgeMinutes?: number }) {
|
|
|
218
218
|
|
|
219
219
|
export const stallCheckSchema = { repo: z.string().optional(), stallMinutes: z.number().optional() };
|
|
220
220
|
|
|
221
|
+
/**
|
|
222
|
+
* A row somebody is WORKING — the population the stall clock watches.
|
|
223
|
+
*
|
|
224
|
+
* It was `/🚧/` alone, and that read a fleet living in review as an EMPTY
|
|
225
|
+
* fleet (q-507e80c4). Measured on a consumer fleet 2026-09-02: a board of 3× `🔍 In
|
|
226
|
+
* Review` and 0× `🚧` returned `checked: 0, measurable: 0`, and `coord_away`
|
|
227
|
+
* armed at 0/0 — correct and vacuous at once. A clock watching nothing and a
|
|
228
|
+
* clock watching a healthy fleet return identical green.
|
|
229
|
+
*
|
|
230
|
+
* `🔍 In Review` is a lane with an owner and a branch that can stop moving
|
|
231
|
+
* exactly as an authoring lane can; the review that never gets re-gated is
|
|
232
|
+
* THE stall shape of a QA-gated fleet. It is read here, on the row as written
|
|
233
|
+
* — relabelling `🔍`→`🚧` to satisfy the old predicate was the item's
|
|
234
|
+
* forbidden move, because it satisfies a guard by editing what it reads and
|
|
235
|
+
* reports authoring lanes that do not exist.
|
|
236
|
+
*
|
|
237
|
+
* NOT widened further, deliberately: `⏸ Parked`, `⏳ Queued`, `⛔ Blocked`,
|
|
238
|
+
* `🚫 Unstaffable`, `✅ Done` have no lane to stall. Widening to everything
|
|
239
|
+
* would be the 0/0 defect inverted.
|
|
240
|
+
*
|
|
241
|
+
* One predicate, exported: `coord_away` measures coverage by calling
|
|
242
|
+
* `stall_check` and reading `checked`/`measurable`, so it inherits this
|
|
243
|
+
* population without a change of its own. Anything else that asks "which rows
|
|
244
|
+
* are in flight" should ask here rather than re-derive it from a glyph.
|
|
245
|
+
*/
|
|
246
|
+
export const IN_FLIGHT_STATUS = /🚧|🔍/;
|
|
247
|
+
export const isInFlightStatus = (status: string): boolean => IN_FLIGHT_STATUS.test(status);
|
|
248
|
+
|
|
221
249
|
export async function stallCheckTool(args: { repo?: string; stallMinutes?: number }) {
|
|
222
250
|
const repo = args.repo ?? process.cwd();
|
|
223
251
|
const limit = (args.stallMinutes ?? 30) * 60 * 1000;
|
|
@@ -226,7 +254,7 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
|
|
|
226
254
|
|
|
227
255
|
const liveTransports = await loadLiveTransports();
|
|
228
256
|
const rows = workstreamsV1RowsOf(parseWorkDoc(readFileSync(board, "utf8")));
|
|
229
|
-
const inFlight = rows.filter((r) =>
|
|
257
|
+
const inFlight = rows.filter((r) => isInFlightStatus(r.status));
|
|
230
258
|
const reg = await readJson<Record<string, { lastHeartbeat: number }>>(AGENTS_FILE, {});
|
|
231
259
|
const now = Date.now();
|
|
232
260
|
const hits: StallHit[] = [];
|
package/src/tools/transport.ts
CHANGED
|
@@ -79,6 +79,7 @@ import {
|
|
|
79
79
|
setOffset,
|
|
80
80
|
sysMsg,
|
|
81
81
|
moveFile,
|
|
82
|
+
agentIdFromCursorFilename,
|
|
82
83
|
STALE_MS,
|
|
83
84
|
EVICT_MS,
|
|
84
85
|
MAX_WAIT_MS,
|
|
@@ -1623,7 +1624,7 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1623
1624
|
}
|
|
1624
1625
|
const orphanCursor: string[] = [];
|
|
1625
1626
|
for (const fname of await listCursorFiles()) {
|
|
1626
|
-
const id = fname
|
|
1627
|
+
const { id } = agentIdFromCursorFilename(fname);
|
|
1627
1628
|
if (!known.has(id)) {
|
|
1628
1629
|
orphanCursor.push(id);
|
|
1629
1630
|
if (fix) {
|
|
@@ -1652,10 +1653,17 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1652
1653
|
};
|
|
1653
1654
|
|
|
1654
1655
|
// 4. Cursor offsets past end-of-file (would return [] forever).
|
|
1656
|
+
//
|
|
1657
|
+
// q-40449919: `id` must be the AGENT's id, not the cursor filename minus
|
|
1658
|
+
// one extension — a push cursor's filename is `<agent>.push.json`, and
|
|
1659
|
+
// `inboxFile(id)` on the unfixed `<agent>.push` resolves to a file that
|
|
1660
|
+
// never existed, so `inboxMax` reads 0 and the cursor's real, correct
|
|
1661
|
+
// offset compares as past-EOF against an empty file that was never the
|
|
1662
|
+
// agent's inbox in the first place.
|
|
1655
1663
|
{
|
|
1656
1664
|
const broken: string[] = [];
|
|
1657
1665
|
for (const fname of await listCursorFiles()) {
|
|
1658
|
-
const id = fname
|
|
1666
|
+
const { id, kind } = agentIdFromCursorFilename(fname);
|
|
1659
1667
|
const cursorPath = path.join(CURSOR_DIR, fname);
|
|
1660
1668
|
const cursor = await readJson<Cursor>(cursorPath, {});
|
|
1661
1669
|
const overflow: string[] = [];
|
|
@@ -1670,7 +1678,7 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1670
1678
|
if (off > max) overflow.push(`roomOffsets[${chan}] ${off}>${max}`);
|
|
1671
1679
|
}
|
|
1672
1680
|
if (overflow.length) {
|
|
1673
|
-
broken.push(`${id}: ${overflow.join(", ")}`);
|
|
1681
|
+
broken.push(`${id}${kind === "push" ? " (push)" : ""}: ${overflow.join(", ")}`);
|
|
1674
1682
|
if (fix) {
|
|
1675
1683
|
await updateJson<Cursor>(cursorPath, {}, (c) => {
|
|
1676
1684
|
if ((c.inboxOffset ?? 0) > inboxMax) c.inboxOffset = inboxMax;
|
|
@@ -1684,7 +1692,7 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1684
1692
|
}
|
|
1685
1693
|
return c;
|
|
1686
1694
|
});
|
|
1687
|
-
fixed.push(`clamped cursor offsets for ${id}`);
|
|
1695
|
+
fixed.push(`clamped cursor offsets for ${id}${kind === "push" ? " (push)" : ""}`);
|
|
1688
1696
|
}
|
|
1689
1697
|
}
|
|
1690
1698
|
}
|