agent-coord-mcp 0.19.0 → 0.23.0

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.
Files changed (46) hide show
  1. package/README.md +12 -2
  2. package/dist/build.js.map +1 -1
  3. package/dist/roles.js +10 -0
  4. package/dist/roles.js.map +1 -1
  5. package/dist/server.js +164 -42
  6. package/dist/server.js.map +1 -1
  7. package/dist/store.js +33 -1
  8. package/dist/store.js.map +1 -1
  9. package/dist/tools/admin.js +24 -5
  10. package/dist/tools/admin.js.map +1 -1
  11. package/dist/tools/registry.js +69 -1
  12. package/dist/tools/registry.js.map +1 -1
  13. package/dist/tools/render.js +1 -83
  14. package/dist/tools/render.js.map +1 -1
  15. package/dist/tools/shared.js +1 -3
  16. package/dist/tools/shared.js.map +1 -1
  17. package/dist/tools/transport.js +97 -7
  18. package/dist/tools/transport.js.map +1 -1
  19. package/dist/tools/work.js +98 -24
  20. package/dist/tools/work.js.map +1 -1
  21. package/dist/work.js +1 -259
  22. package/dist/work.js.map +1 -1
  23. package/hooks/peek-coord.mjs +0 -0
  24. package/hooks/roles.mjs +12 -0
  25. package/hooks/tier.mjs +9 -4
  26. package/hooks/tmux-pusher.mjs +16 -5
  27. package/package.json +16 -16
  28. package/scripts/check-self-dependency.mjs +62 -14
  29. package/scripts/check-test-count.mjs +3 -3
  30. package/scripts/coord-node.sh +0 -0
  31. package/scripts/coord-pusher.mjs +39 -9
  32. package/scripts/coord-token.mjs +0 -0
  33. package/scripts/spawn-agent.sh +0 -0
  34. package/scripts/stop-agent.sh +0 -0
  35. package/src/roles.ts +12 -0
  36. package/src/server.ts +181 -47
  37. package/src/store.ts +45 -1
  38. package/src/tools/admin.ts +22 -5
  39. package/src/tools/registry.ts +96 -0
  40. package/src/tools/render.ts +1 -80
  41. package/src/tools/shared.ts +18 -77
  42. package/src/tools/transport.ts +116 -6
  43. package/src/tools/work.ts +118 -30
  44. package/src/work.ts +31 -329
  45. package/dist/tools.js +0 -1852
  46. package/dist/tools.js.map +0 -1
package/src/tools/work.ts CHANGED
@@ -4,16 +4,20 @@ import path from "node:path";
4
4
  import { z } from "zod";
5
5
  import { WORK_DIR, workFile, readJson } from "../store.js";
6
6
  import {
7
- type BoardRow,
8
7
  type DoneEntry,
9
8
  type QueueItem,
10
9
  type WorkDoc,
11
- boardRowsOf,
12
10
  doneEntriesOf,
11
+ listWorkBoardOf,
13
12
  parseWorkDoc,
13
+ projectV1ToLanes,
14
14
  queueItemsOf,
15
15
  renderWorkDoc,
16
+ workDocIssues,
17
+ workDocLegacyWriteIssues,
18
+ LANES_V0_WRITE_ISSUE,
16
19
  } from "../work.js";
20
+ import { parseFactsDoc, type FactEntry } from "@davidbalzan/groundwork-seam";
17
21
  import { loadScopes, ownsDocument } from "./scopes.js";
18
22
  import { AGENTS_FILE } from "../store.js";
19
23
  import type { AgentRegistry } from "./shared.js";
@@ -42,16 +46,66 @@ export const QUEUE_DOC = "docs/QUEUE.md";
42
46
  export const DONE_DOC = "docs/DONE.md";
43
47
  export const BOARD_DOC = "docs/WORKSTREAMS.md";
44
48
  export const LEGACY_DOC = "docs/BACKLOG.md";
49
+ export const FACTS_DOC = "docs/FACTS.md";
45
50
 
46
- export type WorkFileKind = "queue" | "done" | "board" | "legacy";
51
+ export type WorkFileKind = "queue" | "done" | "board" | "legacy" | "facts";
47
52
 
48
- export type StoredDoc = {
49
- kind: WorkFileKind;
50
- // Repo-relative, exactly as it will be written back.
53
+ export type StoredWorkDoc = {
54
+ kind: "queue" | "done" | "board" | "legacy";
51
55
  path: string;
52
56
  doc: WorkDoc;
53
57
  };
54
58
 
59
+ export type StoredFactsDoc = {
60
+ kind: "facts";
61
+ path: string;
62
+ source: string;
63
+ entries: FactEntry[];
64
+ issues: string[];
65
+ };
66
+
67
+ export type StoredDoc = StoredWorkDoc | StoredFactsDoc;
68
+
69
+ function isFactsDoc(d: StoredDoc): d is StoredFactsDoc {
70
+ return d.kind === "facts";
71
+ }
72
+
73
+ async function loadDoc(repo: string, f: { kind: WorkFileKind; path: string }): Promise<StoredDoc> {
74
+ const source = await fsp.readFile(path.join(repo, f.path), "utf8");
75
+ if (f.kind === "facts") {
76
+ const parsed = parseFactsDoc(source);
77
+ return { kind: "facts", path: f.path, source, entries: parsed.entries, issues: parsed.issues };
78
+ }
79
+ return { kind: f.kind, path: f.path, doc: parseWorkDoc(source) };
80
+ }
81
+
82
+ function docIssues(d: StoredDoc): string[] {
83
+ if (isFactsDoc(d)) return d.issues.map((issue) => `${d.path}: ${issue}`);
84
+ return [...workDocIssues(d.doc), ...workDocLegacyWriteIssues(d.doc)].map(
85
+ (issue) => `${d.path}: ${issue}`,
86
+ );
87
+ }
88
+
89
+ function importedSummary(d: StoredDoc) {
90
+ if (isFactsDoc(d)) {
91
+ return {
92
+ path: d.path,
93
+ kind: d.kind,
94
+ facts: d.entries.length,
95
+ ...(d.issues.length ? { issues: d.issues } : {}),
96
+ };
97
+ }
98
+ const issues = [...workDocIssues(d.doc), ...workDocLegacyWriteIssues(d.doc)];
99
+ return {
100
+ path: d.path,
101
+ kind: d.kind,
102
+ queue: queueItemsOf(d.doc).length,
103
+ done: doneEntriesOf(d.doc).length,
104
+ board: listWorkBoardOf(d.doc).length,
105
+ ...(issues.length ? { issues } : {}),
106
+ };
107
+ }
108
+
55
109
  export type WorkState = {
56
110
  project: string;
57
111
  repo: string;
@@ -72,6 +126,7 @@ function resolveDocs(repo: string): { kind: WorkFileKind; path: string }[] {
72
126
  out.push({ kind: "legacy", path: LEGACY_DOC });
73
127
  }
74
128
  if (has(BOARD_DOC)) out.push({ kind: "board", path: BOARD_DOC });
129
+ if (has(FACTS_DOC)) out.push({ kind: "facts", path: FACTS_DOC });
75
130
  return out;
76
131
  }
77
132
 
@@ -98,30 +153,27 @@ export async function importWorkTool(args: { project: string; repo?: string }) {
98
153
  if (!found.length) {
99
154
  return {
100
155
  ok: false as const,
101
- error: `no work documents under '${repo}' — expected ${QUEUE_DOC}/${DONE_DOC} or the legacy ${LEGACY_DOC}`,
156
+ error: `no work documents under '${repo}' — expected ${QUEUE_DOC}/${DONE_DOC} or the legacy ${LEGACY_DOC} (optional ${FACTS_DOC})`,
102
157
  };
103
158
  }
104
159
 
105
160
  const docs: StoredDoc[] = [];
106
- for (const f of found) {
107
- const source = await fsp.readFile(path.join(repo, f.path), "utf8");
108
- docs.push({ kind: f.kind, path: f.path, doc: parseWorkDoc(source) });
109
- }
161
+ for (const f of found) docs.push(await loadDoc(repo, f));
110
162
  const state: WorkState = { project: args.project, repo, importedAt: Date.now(), docs };
111
163
  await saveState(state);
112
164
 
165
+ const allIssues = docs.flatMap(docIssues);
113
166
  return {
114
167
  ok: true as const,
115
168
  project: args.project,
116
169
  repo,
117
170
  file: workFile(args.project),
118
- imported: docs.map((d) => ({
119
- path: d.path,
120
- kind: d.kind,
121
- queue: queueItemsOf(d.doc).length,
122
- done: doneEntriesOf(d.doc).length,
123
- board: boardRowsOf(d.doc).length,
124
- })),
171
+ imported: docs.map(importedSummary),
172
+ ...(allIssues.length
173
+ ? {
174
+ warning: `${allIssues.length} issue(s) (board arity / unknown grammar / facts) — documents round-trip unchanged. See imported[].issues.`,
175
+ }
176
+ : {}),
125
177
  note: "the markdown remains authoritative — this store is a derived index",
126
178
  };
127
179
  }
@@ -130,18 +182,21 @@ export async function importWorkTool(args: { project: string; repo?: string }) {
130
182
 
131
183
  export const listWorkSchema = {
132
184
  project: z.string().min(1),
133
- kind: z.enum(["queue", "done", "board"]).optional(),
185
+ kind: z.enum(["queue", "done", "board", "facts"]).optional(),
134
186
  priority: z.enum(["P1", "P2", "P3"]).optional(),
135
187
  // Include queue items already ticked off (default false).
136
188
  includeDone: z.boolean().optional(),
189
+ // native (default): discriminated v1 | lanes-v0. lanes: lossy projection of v1.
190
+ view: z.enum(["native", "lanes"]).optional(),
137
191
  repo: z.string().optional(),
138
192
  };
139
193
 
140
194
  export async function listWorkTool(args: {
141
195
  project: string;
142
- kind?: "queue" | "done" | "board";
196
+ kind?: "queue" | "done" | "board" | "facts";
143
197
  priority?: "P1" | "P2" | "P3";
144
198
  includeDone?: boolean;
199
+ view?: "native" | "lanes";
145
200
  repo?: string;
146
201
  }) {
147
202
  // No import yet (or the store was deleted) → read the documents directly.
@@ -160,12 +215,21 @@ export async function listWorkTool(args: {
160
215
 
161
216
  const queue: QueueItem[] = [];
162
217
  const done: DoneEntry[] = [];
163
- const board: BoardRow[] = [];
218
+ const facts: FactEntry[] = [];
219
+ let board = [];
220
+ const issues: string[] = [];
164
221
  for (const d of state.docs) {
222
+ if (isFactsDoc(d)) {
223
+ facts.push(...d.entries);
224
+ issues.push(...docIssues(d));
225
+ continue;
226
+ }
165
227
  queue.push(...queueItemsOf(d.doc));
166
228
  done.push(...doneEntriesOf(d.doc));
167
- board.push(...boardRowsOf(d.doc));
229
+ board.push(...listWorkBoardOf(d.doc));
230
+ issues.push(...docIssues(d));
168
231
  }
232
+ if (args.view === "lanes") board = board.map(projectV1ToLanes);
169
233
 
170
234
  const openQueue = args.includeDone ? queue : queue.filter((q) => !q.done);
171
235
  const filtered = args.priority ? openQueue.filter((q) => q.priority === args.priority) : openQueue;
@@ -178,6 +242,8 @@ export async function listWorkTool(args: {
178
242
  ...(args.kind === "queue" || args.kind === undefined ? { queue: filtered } : {}),
179
243
  ...(args.kind === "done" || args.kind === undefined ? { done } : {}),
180
244
  ...(args.kind === "board" || args.kind === undefined ? { board } : {}),
245
+ ...(args.kind === "facts" || args.kind === undefined ? { facts } : {}),
246
+ ...(issues.length ? { issues } : {}),
181
247
  };
182
248
  }
183
249
 
@@ -187,10 +253,7 @@ async function importFromDisk(project: string, repo: string): Promise<WorkState
187
253
  const found = resolveDocs(repo);
188
254
  if (!found.length) return null;
189
255
  const docs: StoredDoc[] = [];
190
- for (const f of found) {
191
- const src = await fsp.readFile(path.join(repo, f.path), "utf8");
192
- docs.push({ kind: f.kind, path: f.path, doc: parseWorkDoc(src) });
193
- }
256
+ for (const f of found) docs.push(await loadDoc(repo, f));
194
257
  return { project, repo, importedAt: Date.now(), docs };
195
258
  }
196
259
 
@@ -231,6 +294,19 @@ export async function exportWorkTool(args: {
231
294
  const files = [];
232
295
  for (const d of state.docs) {
233
296
  const target = path.join(repo, d.path);
297
+ if (isFactsDoc(d)) {
298
+ // FACTS is not an export write target — set-fact.mjs owns the file.
299
+ const current = existsSync(target) ? await fsp.readFile(target, "utf8") : null;
300
+ files.push({
301
+ path: d.path,
302
+ bytes: Buffer.byteLength(d.source, "utf8"),
303
+ identical: current === d.source,
304
+ written: false,
305
+ note: "facts is not an export write target (set-fact.mjs owns the file)",
306
+ ...(d.issues.length ? { issues: d.issues } : {}),
307
+ });
308
+ continue;
309
+ }
234
310
  const rendered = renderWorkDoc(d.doc);
235
311
  const current = existsSync(target) ? await fsp.readFile(target, "utf8") : null;
236
312
  const declared = scopes.documents.find((s) => s.path === d.path);
@@ -244,22 +320,34 @@ export async function exportWorkTool(args: {
244
320
  }
245
321
  : undefined;
246
322
 
247
- if (write && rendered !== current) await fsp.writeFile(target, rendered, "utf8");
323
+ const issues = [...workDocIssues(d.doc), ...workDocLegacyWriteIssues(d.doc)];
324
+ const wouldWrite = write && rendered !== current;
325
+ const refuseLegacyWrite = wouldWrite && workDocLegacyWriteIssues(d.doc).length > 0;
326
+ if (wouldWrite && !refuseLegacyWrite) await fsp.writeFile(target, rendered, "utf8");
248
327
  files.push({
249
328
  path: d.path,
250
329
  bytes: Buffer.byteLength(rendered, "utf8"),
251
330
  identical: rendered === current,
252
- written: write && rendered !== current,
331
+ written: wouldWrite && !refuseLegacyWrite,
332
+ // Refused rows replay verbatim — the write is byte-faithful — but a
333
+ // caller rewriting a document should hear that some rows carry no
334
+ // record, rather than infer health from `identical:true`.
335
+ ...(refuseLegacyWrite ? { error: LANES_V0_WRITE_ISSUE } : {}),
336
+ ...(issues.length ? { issues } : {}),
253
337
  ...(scope ? { scope } : {}),
254
338
  });
255
339
  }
256
340
 
341
+ const refused = files.filter((f) => "error" in f);
257
342
  return {
258
- ok: true as const,
259
- project: state.project,
343
+ ok: refused.length === 0,
344
+ project: args.project,
260
345
  repo,
261
346
  write,
262
347
  files,
348
+ ...(refused.length
349
+ ? { error: `refused ${refused.length} new lanes-v0 write(s) — ${LANES_V0_WRITE_ISSUE}` }
350
+ : {}),
263
351
  ...(write ? {} : { note: "dry run — pass write:true to rewrite the documents" }),
264
352
  };
265
353
  }
package/src/work.ts CHANGED
@@ -1,329 +1,31 @@
1
- // Work state as data (Phase 8 Task 5): queue items, done entries and board
2
- // rows as typed records, with markdown as a first-class EXPORT rather than the
3
- // datastore.
4
- //
5
- // WHY THIS SHAPE. `docs/QUEUE.md` / `docs/DONE.md` were parsed under a contract
6
- // requiring exact `—` (U+2014) and ` · ` (U+00B7). A parser once returned ZERO
7
- // items on the real file while passing every synthetic test, and the consuming
8
- // UI rendered an empty panel — no error, no signal. The files themselves are
9
- // good: David edits them, git diffs them, they grep. So markdown stays the
10
- // INTERFACE and stops being the DATASTORE.
11
- //
12
- // MARKDOWN REMAINS AUTHORITATIVE. Delete the store and the documents still
13
- // stand alone: every field here is recoverable from the file it came from, the
14
- // exporter is byte-exact, and nothing downstream is required to read records.
15
- // The store is a derived index until a consumer chooses otherwise.
16
- //
17
- // The round-trip guarantee is structural, not incidental: everything this
18
- // module does NOT model — prose, the write-rule paragraphs, the Cross-project
19
- // section, fenced examples — is captured verbatim as a `text` block and
20
- // replayed in place. Only lines it genuinely understands are re-rendered, so
21
- // an unmodelled construct can never be silently dropped.
22
-
23
- import { createHash } from "node:crypto";
24
-
25
- // ---------- record shapes ----------
26
-
27
- export type Priority = "P1" | "P2" | "P3";
28
-
29
- // Fields taken from what the pre-existing parser extracted, no more.
30
- export type QueueItem = {
31
- id: string;
32
- priority: Priority;
33
- text: string;
34
- done: boolean;
35
- // The `### Subsection` this item sits under, when the document groups them.
36
- section?: string;
37
- };
38
-
39
- // `ref` and `date` are parsed separately today and stay separate fields — a
40
- // reassembled "ref · date" string would put the glyph contract right back into
41
- // the data model, which is the bug this task exists to delete.
42
- export type DoneEntry = {
43
- id: string;
44
- text: string;
45
- ref?: string;
46
- date?: string;
47
- section?: string;
48
- };
49
-
50
- // WORKSTREAMS.md's Lanes table, as it genuinely exists: a 5-column markdown
51
- // table. Column headers are carried so a renamed column round-trips.
52
- export type BoardRow = {
53
- id: string;
54
- lane: string;
55
- owner: string;
56
- state: string;
57
- currentSlice: string;
58
- nextGo: string;
59
- // The line this row was read from. Markdown tables are hand-aligned and
60
- // there is no canonical spacing, so an UNCHANGED row replays its original
61
- // bytes; edit any field and the renderer notices the mismatch and re-renders
62
- // in the normalized form. Absent on rows built from scratch.
63
- raw?: string;
64
- };
65
-
66
- // A parsed document: an ordered block list. `text` blocks are verbatim lines
67
- // (never interpreted); the others carry records rendered back in place.
68
- export type Block =
69
- | { kind: "text"; lines: string[] }
70
- | { kind: "queue"; items: QueueItem[] }
71
- | { kind: "done"; entries: DoneEntry[] }
72
- // `header` and `align` are kept as raw lines for the same reason as
73
- // BoardRow.raw — the alignment row (`|---|---|`) has no canonical form.
74
- | { kind: "board"; header: string; align: string; rows: BoardRow[] };
75
-
76
- export type WorkDoc = {
77
- // Kept so an export can be written back with the exact byte tail it had.
78
- trailingNewline: boolean;
79
- blocks: Block[];
80
- };
81
-
82
- // ---------- the glyph contract ----------
83
- //
84
- // Pinned in docs/DONE.md and consumed by external parsers: the ref splits on
85
- // the LAST ` — ` (space, U+2014, space) and the date is a trailing ` · `
86
- // (space, U+00B7, space) + ISO date. Written as explicit escapes so a source
87
- // file normalization or a copy-paste through an ASCII-mangling tool can never
88
- // change them silently — the constants are the contract.
89
- export const EM_DASH_SEP = " — ";
90
- export const MIDDOT_SEP = " · ";
91
- const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
92
-
93
- // Deterministic ids: same content in → same id out, so import→export→import is
94
- // stable and no id has to be written into the markdown (which would change the
95
- // bytes the round trip is measured on).
96
- function idFor(kind: string, seed: string): string {
97
- return `${kind}-${createHash("sha1").update(seed).digest("hex").slice(0, 8)}`;
98
- }
99
-
100
- // ---------- line-level recognizers ----------
101
-
102
- const QUEUE_LINE = /^- \[( |x)\] \((P1|P2|P3)\) (.*)$/;
103
- const DONE_LINE = /^- \[( |x)\] (.*)$/;
104
-
105
- export function parseQueueLine(line: string, section?: string): QueueItem | null {
106
- const m = QUEUE_LINE.exec(line);
107
- if (!m) return null;
108
- const [, mark, priority, text] = m;
109
- return {
110
- id: idFor("q", text ?? ""),
111
- priority: priority as Priority,
112
- text: text ?? "",
113
- done: mark === "x",
114
- ...(section ? { section } : {}),
115
- };
116
- }
117
-
118
- // Split on the LAST ` — `, then peel a trailing ` · YYYY-MM-DD`. Both parts are
119
- // optional: an entry with neither is still a done entry, it just carries no
120
- // verifiable ref — which is a fact about the entry, not a parse failure. The
121
- // old parser treated the same line as nothing at all.
122
- export function parseDoneLine(line: string, section?: string): DoneEntry | null {
123
- const m = DONE_LINE.exec(line);
124
- if (!m) return null;
125
- let body = m[2] ?? "";
126
- let ref: string | undefined;
127
- let date: string | undefined;
128
-
129
- const cut = body.lastIndexOf(EM_DASH_SEP);
130
- if (cut !== -1) {
131
- const tail = body.slice(cut + EM_DASH_SEP.length);
132
- const dot = tail.lastIndexOf(MIDDOT_SEP);
133
- if (dot !== -1) {
134
- const maybeDate = tail.slice(dot + MIDDOT_SEP.length);
135
- if (ISO_DATE.test(maybeDate)) {
136
- date = maybeDate;
137
- ref = tail.slice(0, dot);
138
- }
139
- }
140
- // A tail with no date is still a ref, as long as it isn't prose with
141
- // spaces — refs are `owner/repo#N` or `owner/repo@sha`.
142
- if (date === undefined && /^\S+$/.test(tail)) ref = tail;
143
- if (ref !== undefined) body = body.slice(0, cut);
144
- }
145
- return {
146
- id: idFor("d", `${body}|${ref ?? ""}|${date ?? ""}`),
147
- text: body,
148
- ...(ref !== undefined ? { ref } : {}),
149
- ...(date !== undefined ? { date } : {}),
150
- ...(section ? { section } : {}),
151
- };
152
- }
153
-
154
- export function renderQueueLine(item: QueueItem): string {
155
- return `- [${item.done ? "x" : " "}] (${item.priority}) ${item.text}`;
156
- }
157
-
158
- export function renderDoneLine(entry: DoneEntry): string {
159
- let line = `- [x] ${entry.text}`;
160
- if (entry.ref !== undefined) line += `${EM_DASH_SEP}${entry.ref}`;
161
- if (entry.date !== undefined) line += `${MIDDOT_SEP}${entry.date}`;
162
- return line;
163
- }
164
-
165
- // ---------- board table ----------
166
-
167
- const TABLE_ROW = /^\|(.*)\|\s*$/;
168
- const TABLE_ALIGN = /^\|[\s:|-]+\|\s*$/;
169
-
170
- function splitRow(line: string): string[] {
171
- const m = TABLE_ROW.exec(line);
172
- if (!m) return [];
173
- return (m[1] ?? "").split("|").map((c) => c.trim());
174
- }
175
-
176
- function renderRow(cells: string[]): string {
177
- return `| ${cells.join(" | ")} |`;
178
- }
179
-
180
- function boardCells(r: BoardRow): string[] {
181
- return [r.lane, r.owner, r.state, r.currentSlice, r.nextGo];
182
- }
183
-
184
- // Replay the original line when the fields still say what it said; re-render
185
- // once anything actually changed.
186
- export function renderBoardRow(r: BoardRow): string {
187
- if (r.raw !== undefined) {
188
- const cells = splitRow(r.raw);
189
- if (cells.length === 5 && cells.every((c, i) => c === boardCells(r)[i])) return r.raw;
190
- }
191
- return renderRow(boardCells(r));
192
- }
193
-
194
- // ---------- document parsing ----------
195
-
196
- type SectionKind = "queue" | "done" | "none";
197
-
198
- // Which record kind a `## Heading` opens. `## Queue` → queue items,
199
- // `## Done` → done entries; anything else closes the record region, so the
200
- // Cross-project section's bullets stay verbatim prose.
201
- function sectionKindOf(heading: string): SectionKind {
202
- const h = heading.replace(/^#+\s*/, "").trim().toLowerCase();
203
- if (h === "queue") return "queue";
204
- if (h === "done") return "done";
205
- return "none";
206
- }
207
-
208
- export function parseWorkDoc(source: string): WorkDoc {
209
- const trailingNewline = source.endsWith("\n");
210
- const lines = source.split("\n");
211
- if (trailingNewline) lines.pop(); // the split's empty tail, restored on render
212
-
213
- const blocks: Block[] = [];
214
- let text: string[] = [];
215
- let kind: SectionKind = "none";
216
- let subsection: string | undefined;
217
- let inFence = false;
218
-
219
- const flushText = () => {
220
- if (text.length) blocks.push({ kind: "text", lines: text });
221
- text = [];
222
- };
223
- // Consecutive item lines coalesce into one block; anything in between (a
224
- // blank line, a subsection heading) flushes as text first and therefore
225
- // starts a new one, which is what keeps rendering order faithful.
226
- const pushItem = (item: QueueItem) => {
227
- flushText();
228
- const last = blocks[blocks.length - 1];
229
- if (last?.kind === "queue") last.items.push(item);
230
- else blocks.push({ kind: "queue", items: [item] });
231
- };
232
- const pushEntry = (entry: DoneEntry) => {
233
- flushText();
234
- const last = blocks[blocks.length - 1];
235
- if (last?.kind === "done") last.entries.push(entry);
236
- else blocks.push({ kind: "done", entries: [entry] });
237
- };
238
-
239
- for (let i = 0; i < lines.length; i++) {
240
- const line = lines[i] ?? "";
241
-
242
- // Fenced blocks are never interpreted — docs/DONE.md pins the done-line
243
- // FORMAT inside a fence, and parsing that example as an entry would
244
- // invent a record out of documentation.
245
- if (/^\s*```/.test(line)) {
246
- inFence = !inFence;
247
- text.push(line);
248
- continue;
249
- }
250
- if (inFence) {
251
- text.push(line);
252
- continue;
253
- }
254
-
255
- if (/^#{1,6}\s/.test(line)) {
256
- const level = (line.match(/^#+/) ?? [""])[0].length;
257
- if (level <= 2) {
258
- kind = sectionKindOf(line);
259
- subsection = undefined;
260
- } else if (kind !== "none") {
261
- subsection = line.replace(/^#+\s*/, "").trim();
262
- }
263
- text.push(line);
264
- continue;
265
- }
266
-
267
- if (kind === "queue") {
268
- const item = parseQueueLine(line, subsection);
269
- if (item) {
270
- pushItem(item);
271
- continue;
272
- }
273
- } else if (kind === "done") {
274
- const entry = parseDoneLine(line, subsection);
275
- if (entry) {
276
- pushEntry(entry);
277
- continue;
278
- }
279
- }
280
-
281
- // Lanes table: a header row followed by an alignment row.
282
- if (TABLE_ROW.test(line) && TABLE_ALIGN.test(lines[i + 1] ?? "")) {
283
- const rows: BoardRow[] = [];
284
- let j = i + 2;
285
- for (; j < lines.length && TABLE_ROW.test(lines[j] ?? ""); j++) {
286
- const raw = lines[j] ?? "";
287
- const [lane = "", owner = "", state = "", currentSlice = "", nextGo = ""] = splitRow(raw);
288
- rows.push({ id: idFor("b", lane), lane, owner, state, currentSlice, nextGo, raw });
289
- }
290
- flushText();
291
- blocks.push({ kind: "board", header: line, align: lines[i + 1] ?? "", rows });
292
- i = j - 1;
293
- continue;
294
- }
295
-
296
- text.push(line);
297
- }
298
- flushText();
299
- return { trailingNewline, blocks };
300
- }
301
-
302
- export function renderWorkDoc(doc: WorkDoc): string {
303
- const out: string[] = [];
304
- for (const block of doc.blocks) {
305
- if (block.kind === "text") out.push(...block.lines);
306
- else if (block.kind === "queue") out.push(...block.items.map(renderQueueLine));
307
- else if (block.kind === "done") out.push(...block.entries.map(renderDoneLine));
308
- else {
309
- out.push(block.header);
310
- out.push(block.align);
311
- for (const r of block.rows) out.push(renderBoardRow(r));
312
- }
313
- }
314
- return out.join("\n") + (doc.trailingNewline ? "\n" : "");
315
- }
316
-
317
- // ---------- record extraction ----------
318
-
319
- export function queueItemsOf(doc: WorkDoc): QueueItem[] {
320
- return doc.blocks.flatMap((b) => (b.kind === "queue" ? b.items : []));
321
- }
322
-
323
- export function doneEntriesOf(doc: WorkDoc): DoneEntry[] {
324
- return doc.blocks.flatMap((b) => (b.kind === "done" ? b.entries : []));
325
- }
326
-
327
- export function boardRowsOf(doc: WorkDoc): BoardRow[] {
328
- return doc.blocks.flatMap((b) => (b.kind === "board" ? b.rows : []));
329
- }
1
+ export {
2
+ type Priority,
3
+ type QueueItem,
4
+ type DoneEntry,
5
+ type BoardRow,
6
+ BOARD_ARITY,
7
+ type MalformedRow,
8
+ isMalformedRow,
9
+ type Block,
10
+ type WorkDoc,
11
+ EM_DASH_SEP,
12
+ MIDDOT_SEP,
13
+ parseQueueLine,
14
+ parseDoneLine,
15
+ renderQueueLine,
16
+ renderDoneLine,
17
+ renderBoardRow,
18
+ parseWorkDoc,
19
+ renderWorkDoc,
20
+ queueItemsOf,
21
+ doneEntriesOf,
22
+ boardRowsOf,
23
+ workstreamsV1RowsOf,
24
+ listWorkBoardOf,
25
+ projectV1ToLanes,
26
+ workDocIssues,
27
+ workDocLegacyWriteIssues,
28
+ LANES_V0_WRITE_ISSUE,
29
+ type WorkstreamsV1Row,
30
+ type ListWorkBoardRow,
31
+ } from "@davidbalzan/groundwork-seam/docs";