agent-coord-mcp 0.19.1 → 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.
- package/README.md +1 -1
- package/dist/build.js.map +1 -1
- package/dist/roles.js +10 -0
- package/dist/roles.js.map +1 -1
- package/dist/server.js +39 -35
- package/dist/server.js.map +1 -1
- package/dist/tools/render.js +1 -83
- package/dist/tools/render.js.map +1 -1
- package/dist/tools/shared.js +1 -3
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/work.js +88 -34
- package/dist/tools/work.js.map +1 -1
- package/dist/work.js +1 -291
- package/dist/work.js.map +1 -1
- package/hooks/peek-coord.mjs +0 -0
- package/hooks/roles.mjs +12 -0
- package/hooks/tier.mjs +9 -4
- package/hooks/tmux-pusher.mjs +10 -5
- package/package.json +16 -16
- package/scripts/check-self-dependency.mjs +62 -14
- package/scripts/check-test-count.mjs +3 -3
- package/scripts/coord-node.sh +0 -0
- package/scripts/coord-token.mjs +0 -0
- package/scripts/spawn-agent.sh +0 -0
- package/scripts/stop-agent.sh +0 -0
- package/src/roles.ts +12 -0
- package/src/server.ts +50 -38
- package/src/tools/render.ts +1 -80
- package/src/tools/shared.ts +18 -77
- package/src/tools/work.ts +107 -40
- package/src/work.ts +31 -386
- package/dist/tools.js +0 -1852
- package/dist/tools.js.map +0 -1
package/src/tools/work.ts
CHANGED
|
@@ -4,17 +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
16
|
workDocIssues,
|
|
17
|
+
workDocLegacyWriteIssues,
|
|
18
|
+
LANES_V0_WRITE_ISSUE,
|
|
17
19
|
} from "../work.js";
|
|
20
|
+
import { parseFactsDoc, type FactEntry } from "@davidbalzan/groundwork-seam";
|
|
18
21
|
import { loadScopes, ownsDocument } from "./scopes.js";
|
|
19
22
|
import { AGENTS_FILE } from "../store.js";
|
|
20
23
|
import type { AgentRegistry } from "./shared.js";
|
|
@@ -43,16 +46,66 @@ export const QUEUE_DOC = "docs/QUEUE.md";
|
|
|
43
46
|
export const DONE_DOC = "docs/DONE.md";
|
|
44
47
|
export const BOARD_DOC = "docs/WORKSTREAMS.md";
|
|
45
48
|
export const LEGACY_DOC = "docs/BACKLOG.md";
|
|
49
|
+
export const FACTS_DOC = "docs/FACTS.md";
|
|
46
50
|
|
|
47
|
-
export type WorkFileKind = "queue" | "done" | "board" | "legacy";
|
|
51
|
+
export type WorkFileKind = "queue" | "done" | "board" | "legacy" | "facts";
|
|
48
52
|
|
|
49
|
-
export type
|
|
50
|
-
kind:
|
|
51
|
-
// Repo-relative, exactly as it will be written back.
|
|
53
|
+
export type StoredWorkDoc = {
|
|
54
|
+
kind: "queue" | "done" | "board" | "legacy";
|
|
52
55
|
path: string;
|
|
53
56
|
doc: WorkDoc;
|
|
54
57
|
};
|
|
55
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
|
+
|
|
56
109
|
export type WorkState = {
|
|
57
110
|
project: string;
|
|
58
111
|
repo: string;
|
|
@@ -73,6 +126,7 @@ function resolveDocs(repo: string): { kind: WorkFileKind; path: string }[] {
|
|
|
73
126
|
out.push({ kind: "legacy", path: LEGACY_DOC });
|
|
74
127
|
}
|
|
75
128
|
if (has(BOARD_DOC)) out.push({ kind: "board", path: BOARD_DOC });
|
|
129
|
+
if (has(FACTS_DOC)) out.push({ kind: "facts", path: FACTS_DOC });
|
|
76
130
|
return out;
|
|
77
131
|
}
|
|
78
132
|
|
|
@@ -99,38 +153,25 @@ export async function importWorkTool(args: { project: string; repo?: string }) {
|
|
|
99
153
|
if (!found.length) {
|
|
100
154
|
return {
|
|
101
155
|
ok: false as const,
|
|
102
|
-
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})`,
|
|
103
157
|
};
|
|
104
158
|
}
|
|
105
159
|
|
|
106
160
|
const docs: StoredDoc[] = [];
|
|
107
|
-
for (const f of found)
|
|
108
|
-
const source = await fsp.readFile(path.join(repo, f.path), "utf8");
|
|
109
|
-
docs.push({ kind: f.kind, path: f.path, doc: parseWorkDoc(source) });
|
|
110
|
-
}
|
|
161
|
+
for (const f of found) docs.push(await loadDoc(repo, f));
|
|
111
162
|
const state: WorkState = { project: args.project, repo, importedAt: Date.now(), docs };
|
|
112
163
|
await saveState(state);
|
|
113
164
|
|
|
114
|
-
const allIssues = docs.flatMap(
|
|
165
|
+
const allIssues = docs.flatMap(docIssues);
|
|
115
166
|
return {
|
|
116
167
|
ok: true as const,
|
|
117
168
|
project: args.project,
|
|
118
169
|
repo,
|
|
119
170
|
file: workFile(args.project),
|
|
120
|
-
imported: docs.map(
|
|
121
|
-
const issues = workDocIssues(d.doc);
|
|
122
|
-
return {
|
|
123
|
-
path: d.path,
|
|
124
|
-
kind: d.kind,
|
|
125
|
-
queue: queueItemsOf(d.doc).length,
|
|
126
|
-
done: doneEntriesOf(d.doc).length,
|
|
127
|
-
board: boardRowsOf(d.doc).length,
|
|
128
|
-
...(issues.length ? { issues } : {}),
|
|
129
|
-
};
|
|
130
|
-
}),
|
|
171
|
+
imported: docs.map(importedSummary),
|
|
131
172
|
...(allIssues.length
|
|
132
173
|
? {
|
|
133
|
-
warning: `${allIssues.length}
|
|
174
|
+
warning: `${allIssues.length} issue(s) (board arity / unknown grammar / facts) — documents round-trip unchanged. See imported[].issues.`,
|
|
134
175
|
}
|
|
135
176
|
: {}),
|
|
136
177
|
note: "the markdown remains authoritative — this store is a derived index",
|
|
@@ -141,18 +182,21 @@ export async function importWorkTool(args: { project: string; repo?: string }) {
|
|
|
141
182
|
|
|
142
183
|
export const listWorkSchema = {
|
|
143
184
|
project: z.string().min(1),
|
|
144
|
-
kind: z.enum(["queue", "done", "board"]).optional(),
|
|
185
|
+
kind: z.enum(["queue", "done", "board", "facts"]).optional(),
|
|
145
186
|
priority: z.enum(["P1", "P2", "P3"]).optional(),
|
|
146
187
|
// Include queue items already ticked off (default false).
|
|
147
188
|
includeDone: z.boolean().optional(),
|
|
189
|
+
// native (default): discriminated v1 | lanes-v0. lanes: lossy projection of v1.
|
|
190
|
+
view: z.enum(["native", "lanes"]).optional(),
|
|
148
191
|
repo: z.string().optional(),
|
|
149
192
|
};
|
|
150
193
|
|
|
151
194
|
export async function listWorkTool(args: {
|
|
152
195
|
project: string;
|
|
153
|
-
kind?: "queue" | "done" | "board";
|
|
196
|
+
kind?: "queue" | "done" | "board" | "facts";
|
|
154
197
|
priority?: "P1" | "P2" | "P3";
|
|
155
198
|
includeDone?: boolean;
|
|
199
|
+
view?: "native" | "lanes";
|
|
156
200
|
repo?: string;
|
|
157
201
|
}) {
|
|
158
202
|
// No import yet (or the store was deleted) → read the documents directly.
|
|
@@ -171,14 +215,21 @@ export async function listWorkTool(args: {
|
|
|
171
215
|
|
|
172
216
|
const queue: QueueItem[] = [];
|
|
173
217
|
const done: DoneEntry[] = [];
|
|
174
|
-
const
|
|
218
|
+
const facts: FactEntry[] = [];
|
|
219
|
+
let board = [];
|
|
175
220
|
const issues: string[] = [];
|
|
176
221
|
for (const d of state.docs) {
|
|
222
|
+
if (isFactsDoc(d)) {
|
|
223
|
+
facts.push(...d.entries);
|
|
224
|
+
issues.push(...docIssues(d));
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
177
227
|
queue.push(...queueItemsOf(d.doc));
|
|
178
228
|
done.push(...doneEntriesOf(d.doc));
|
|
179
|
-
board.push(...
|
|
180
|
-
issues.push(...
|
|
229
|
+
board.push(...listWorkBoardOf(d.doc));
|
|
230
|
+
issues.push(...docIssues(d));
|
|
181
231
|
}
|
|
232
|
+
if (args.view === "lanes") board = board.map(projectV1ToLanes);
|
|
182
233
|
|
|
183
234
|
const openQueue = args.includeDone ? queue : queue.filter((q) => !q.done);
|
|
184
235
|
const filtered = args.priority ? openQueue.filter((q) => q.priority === args.priority) : openQueue;
|
|
@@ -191,8 +242,7 @@ export async function listWorkTool(args: {
|
|
|
191
242
|
...(args.kind === "queue" || args.kind === undefined ? { queue: filtered } : {}),
|
|
192
243
|
...(args.kind === "done" || args.kind === undefined ? { done } : {}),
|
|
193
244
|
...(args.kind === "board" || args.kind === undefined ? { board } : {}),
|
|
194
|
-
|
|
195
|
-
// than let the absence read as "that lane doesn't exist".
|
|
245
|
+
...(args.kind === "facts" || args.kind === undefined ? { facts } : {}),
|
|
196
246
|
...(issues.length ? { issues } : {}),
|
|
197
247
|
};
|
|
198
248
|
}
|
|
@@ -203,10 +253,7 @@ async function importFromDisk(project: string, repo: string): Promise<WorkState
|
|
|
203
253
|
const found = resolveDocs(repo);
|
|
204
254
|
if (!found.length) return null;
|
|
205
255
|
const docs: StoredDoc[] = [];
|
|
206
|
-
for (const f of found)
|
|
207
|
-
const src = await fsp.readFile(path.join(repo, f.path), "utf8");
|
|
208
|
-
docs.push({ kind: f.kind, path: f.path, doc: parseWorkDoc(src) });
|
|
209
|
-
}
|
|
256
|
+
for (const f of found) docs.push(await loadDoc(repo, f));
|
|
210
257
|
return { project, repo, importedAt: Date.now(), docs };
|
|
211
258
|
}
|
|
212
259
|
|
|
@@ -247,6 +294,19 @@ export async function exportWorkTool(args: {
|
|
|
247
294
|
const files = [];
|
|
248
295
|
for (const d of state.docs) {
|
|
249
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
|
+
}
|
|
250
310
|
const rendered = renderWorkDoc(d.doc);
|
|
251
311
|
const current = existsSync(target) ? await fsp.readFile(target, "utf8") : null;
|
|
252
312
|
const declared = scopes.documents.find((s) => s.path === d.path);
|
|
@@ -260,27 +320,34 @@ export async function exportWorkTool(args: {
|
|
|
260
320
|
}
|
|
261
321
|
: undefined;
|
|
262
322
|
|
|
263
|
-
|
|
264
|
-
const
|
|
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");
|
|
265
327
|
files.push({
|
|
266
328
|
path: d.path,
|
|
267
329
|
bytes: Buffer.byteLength(rendered, "utf8"),
|
|
268
330
|
identical: rendered === current,
|
|
269
|
-
written:
|
|
331
|
+
written: wouldWrite && !refuseLegacyWrite,
|
|
270
332
|
// Refused rows replay verbatim — the write is byte-faithful — but a
|
|
271
333
|
// caller rewriting a document should hear that some rows carry no
|
|
272
334
|
// record, rather than infer health from `identical:true`.
|
|
335
|
+
...(refuseLegacyWrite ? { error: LANES_V0_WRITE_ISSUE } : {}),
|
|
273
336
|
...(issues.length ? { issues } : {}),
|
|
274
337
|
...(scope ? { scope } : {}),
|
|
275
338
|
});
|
|
276
339
|
}
|
|
277
340
|
|
|
341
|
+
const refused = files.filter((f) => "error" in f);
|
|
278
342
|
return {
|
|
279
|
-
ok:
|
|
280
|
-
project:
|
|
343
|
+
ok: refused.length === 0,
|
|
344
|
+
project: args.project,
|
|
281
345
|
repo,
|
|
282
346
|
write,
|
|
283
347
|
files,
|
|
348
|
+
...(refused.length
|
|
349
|
+
? { error: `refused ${refused.length} new lanes-v0 write(s) — ${LANES_V0_WRITE_ISSUE}` }
|
|
350
|
+
: {}),
|
|
284
351
|
...(write ? {} : { note: "dry run — pass write:true to rewrite the documents" }),
|
|
285
352
|
};
|
|
286
353
|
}
|
package/src/work.ts
CHANGED
|
@@ -1,386 +1,31 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
// The one arity BoardRow can hold. boardCells() and the parser's arity guard
|
|
67
|
-
// both reference this constant so the two sides cannot drift; a test pins
|
|
68
|
-
// boardCells().length === BOARD_ARITY.
|
|
69
|
-
export const BOARD_ARITY = 5;
|
|
70
|
-
|
|
71
|
-
// A table row whose column count does not match BoardRow. It is REFUSED a
|
|
72
|
-
// record (boardRowsOf never returns it) but PRESERVED byte-exactly — the
|
|
73
|
-
// alternative, destructuring whatever arity into a fixed 5-tuple, silently
|
|
74
|
-
// narrowed 6-column rows and widened 4-column ones on export (hit live
|
|
75
|
-
// 2026-07-29: #38's extra `Pane` column was dropped on write-back). Refusing
|
|
76
|
-
// the row rather than throwing keeps one bad row from taking down the whole
|
|
77
|
-
// document's import. The schema decision stays with the board owner: widening
|
|
78
|
-
// BoardRow is a deliberate act (change BOARD_ARITY, boardCells, and this
|
|
79
|
-
// guard together), never something a docs commit does by accident.
|
|
80
|
-
export type MalformedRow = {
|
|
81
|
-
malformed: true;
|
|
82
|
-
// Replayed byte-exactly on render — never narrowed, never widened.
|
|
83
|
-
verbatim: string;
|
|
84
|
-
// Names the line, quotes the row, states expected/actual — loud enough to
|
|
85
|
-
// fix from the message alone.
|
|
86
|
-
issue: string;
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
export function isMalformedRow(r: BoardRow | MalformedRow): r is MalformedRow {
|
|
90
|
-
return (r as MalformedRow).malformed === true;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// A parsed document: an ordered block list. `text` blocks are verbatim lines
|
|
94
|
-
// (never interpreted); the others carry records rendered back in place.
|
|
95
|
-
export type Block =
|
|
96
|
-
| { kind: "text"; lines: string[] }
|
|
97
|
-
| { kind: "queue"; items: QueueItem[] }
|
|
98
|
-
| { kind: "done"; entries: DoneEntry[] }
|
|
99
|
-
// `header` and `align` are kept as raw lines for the same reason as
|
|
100
|
-
// BoardRow.raw — the alignment row (`|---|---|`) has no canonical form.
|
|
101
|
-
| { kind: "board"; header: string; align: string; rows: (BoardRow | MalformedRow)[] };
|
|
102
|
-
|
|
103
|
-
export type WorkDoc = {
|
|
104
|
-
// Kept so an export can be written back with the exact byte tail it had.
|
|
105
|
-
trailingNewline: boolean;
|
|
106
|
-
blocks: Block[];
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
// ---------- the glyph contract ----------
|
|
110
|
-
//
|
|
111
|
-
// Pinned in docs/DONE.md and consumed by external parsers: the ref splits on
|
|
112
|
-
// the LAST ` — ` (space, U+2014, space) and the date is a trailing ` · `
|
|
113
|
-
// (space, U+00B7, space) + ISO date. Written as explicit escapes so a source
|
|
114
|
-
// file normalization or a copy-paste through an ASCII-mangling tool can never
|
|
115
|
-
// change them silently — the constants are the contract.
|
|
116
|
-
export const EM_DASH_SEP = " — ";
|
|
117
|
-
export const MIDDOT_SEP = " · ";
|
|
118
|
-
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
119
|
-
|
|
120
|
-
// Deterministic ids: same content in → same id out, so import→export→import is
|
|
121
|
-
// stable and no id has to be written into the markdown (which would change the
|
|
122
|
-
// bytes the round trip is measured on).
|
|
123
|
-
function idFor(kind: string, seed: string): string {
|
|
124
|
-
return `${kind}-${createHash("sha1").update(seed).digest("hex").slice(0, 8)}`;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// ---------- line-level recognizers ----------
|
|
128
|
-
|
|
129
|
-
const QUEUE_LINE = /^- \[( |x)\] \((P1|P2|P3)\) (.*)$/;
|
|
130
|
-
const DONE_LINE = /^- \[( |x)\] (.*)$/;
|
|
131
|
-
|
|
132
|
-
export function parseQueueLine(line: string, section?: string): QueueItem | null {
|
|
133
|
-
const m = QUEUE_LINE.exec(line);
|
|
134
|
-
if (!m) return null;
|
|
135
|
-
const [, mark, priority, text] = m;
|
|
136
|
-
return {
|
|
137
|
-
id: idFor("q", text ?? ""),
|
|
138
|
-
priority: priority as Priority,
|
|
139
|
-
text: text ?? "",
|
|
140
|
-
done: mark === "x",
|
|
141
|
-
...(section ? { section } : {}),
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Split on the LAST ` — `, then peel a trailing ` · YYYY-MM-DD`. Both parts are
|
|
146
|
-
// optional: an entry with neither is still a done entry, it just carries no
|
|
147
|
-
// verifiable ref — which is a fact about the entry, not a parse failure. The
|
|
148
|
-
// old parser treated the same line as nothing at all.
|
|
149
|
-
export function parseDoneLine(line: string, section?: string): DoneEntry | null {
|
|
150
|
-
const m = DONE_LINE.exec(line);
|
|
151
|
-
if (!m) return null;
|
|
152
|
-
let body = m[2] ?? "";
|
|
153
|
-
let ref: string | undefined;
|
|
154
|
-
let date: string | undefined;
|
|
155
|
-
|
|
156
|
-
const cut = body.lastIndexOf(EM_DASH_SEP);
|
|
157
|
-
if (cut !== -1) {
|
|
158
|
-
const tail = body.slice(cut + EM_DASH_SEP.length);
|
|
159
|
-
const dot = tail.lastIndexOf(MIDDOT_SEP);
|
|
160
|
-
if (dot !== -1) {
|
|
161
|
-
const maybeDate = tail.slice(dot + MIDDOT_SEP.length);
|
|
162
|
-
if (ISO_DATE.test(maybeDate)) {
|
|
163
|
-
date = maybeDate;
|
|
164
|
-
ref = tail.slice(0, dot);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
// A tail with no date is still a ref, as long as it isn't prose with
|
|
168
|
-
// spaces — refs are `owner/repo#N` or `owner/repo@sha`.
|
|
169
|
-
if (date === undefined && /^\S+$/.test(tail)) ref = tail;
|
|
170
|
-
if (ref !== undefined) body = body.slice(0, cut);
|
|
171
|
-
}
|
|
172
|
-
return {
|
|
173
|
-
id: idFor("d", `${body}|${ref ?? ""}|${date ?? ""}`),
|
|
174
|
-
text: body,
|
|
175
|
-
...(ref !== undefined ? { ref } : {}),
|
|
176
|
-
...(date !== undefined ? { date } : {}),
|
|
177
|
-
...(section ? { section } : {}),
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export function renderQueueLine(item: QueueItem): string {
|
|
182
|
-
return `- [${item.done ? "x" : " "}] (${item.priority}) ${item.text}`;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export function renderDoneLine(entry: DoneEntry): string {
|
|
186
|
-
let line = `- [x] ${entry.text}`;
|
|
187
|
-
if (entry.ref !== undefined) line += `${EM_DASH_SEP}${entry.ref}`;
|
|
188
|
-
if (entry.date !== undefined) line += `${MIDDOT_SEP}${entry.date}`;
|
|
189
|
-
return line;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// ---------- board table ----------
|
|
193
|
-
|
|
194
|
-
const TABLE_ROW = /^\|(.*)\|\s*$/;
|
|
195
|
-
const TABLE_ALIGN = /^\|[\s:|-]+\|\s*$/;
|
|
196
|
-
|
|
197
|
-
function splitRow(line: string): string[] {
|
|
198
|
-
const m = TABLE_ROW.exec(line);
|
|
199
|
-
if (!m) return [];
|
|
200
|
-
return (m[1] ?? "").split("|").map((c) => c.trim());
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function renderRow(cells: string[]): string {
|
|
204
|
-
return `| ${cells.join(" | ")} |`;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function boardCells(r: BoardRow): string[] {
|
|
208
|
-
return [r.lane, r.owner, r.state, r.currentSlice, r.nextGo];
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Replay the original line when the fields still say what it said; re-render
|
|
212
|
-
// once anything actually changed. A malformed row has no fields to have
|
|
213
|
-
// changed — it replays its bytes, always.
|
|
214
|
-
export function renderBoardRow(r: BoardRow | MalformedRow): string {
|
|
215
|
-
if (isMalformedRow(r)) return r.verbatim;
|
|
216
|
-
if (r.raw !== undefined) {
|
|
217
|
-
const cells = splitRow(r.raw);
|
|
218
|
-
if (cells.length === BOARD_ARITY && cells.every((c, i) => c === boardCells(r)[i])) return r.raw;
|
|
219
|
-
}
|
|
220
|
-
return renderRow(boardCells(r));
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// ---------- document parsing ----------
|
|
224
|
-
|
|
225
|
-
type SectionKind = "queue" | "done" | "none";
|
|
226
|
-
|
|
227
|
-
// Which record kind a `## Heading` opens. `## Queue` → queue items,
|
|
228
|
-
// `## Done` → done entries; anything else closes the record region, so the
|
|
229
|
-
// Cross-project section's bullets stay verbatim prose.
|
|
230
|
-
function sectionKindOf(heading: string): SectionKind {
|
|
231
|
-
const h = heading.replace(/^#+\s*/, "").trim().toLowerCase();
|
|
232
|
-
if (h === "queue") return "queue";
|
|
233
|
-
if (h === "done") return "done";
|
|
234
|
-
return "none";
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
export function parseWorkDoc(source: string): WorkDoc {
|
|
238
|
-
const trailingNewline = source.endsWith("\n");
|
|
239
|
-
const lines = source.split("\n");
|
|
240
|
-
if (trailingNewline) lines.pop(); // the split's empty tail, restored on render
|
|
241
|
-
|
|
242
|
-
const blocks: Block[] = [];
|
|
243
|
-
let text: string[] = [];
|
|
244
|
-
let kind: SectionKind = "none";
|
|
245
|
-
let subsection: string | undefined;
|
|
246
|
-
let inFence = false;
|
|
247
|
-
|
|
248
|
-
const flushText = () => {
|
|
249
|
-
if (text.length) blocks.push({ kind: "text", lines: text });
|
|
250
|
-
text = [];
|
|
251
|
-
};
|
|
252
|
-
// Consecutive item lines coalesce into one block; anything in between (a
|
|
253
|
-
// blank line, a subsection heading) flushes as text first and therefore
|
|
254
|
-
// starts a new one, which is what keeps rendering order faithful.
|
|
255
|
-
const pushItem = (item: QueueItem) => {
|
|
256
|
-
flushText();
|
|
257
|
-
const last = blocks[blocks.length - 1];
|
|
258
|
-
if (last?.kind === "queue") last.items.push(item);
|
|
259
|
-
else blocks.push({ kind: "queue", items: [item] });
|
|
260
|
-
};
|
|
261
|
-
const pushEntry = (entry: DoneEntry) => {
|
|
262
|
-
flushText();
|
|
263
|
-
const last = blocks[blocks.length - 1];
|
|
264
|
-
if (last?.kind === "done") last.entries.push(entry);
|
|
265
|
-
else blocks.push({ kind: "done", entries: [entry] });
|
|
266
|
-
};
|
|
267
|
-
|
|
268
|
-
for (let i = 0; i < lines.length; i++) {
|
|
269
|
-
const line = lines[i] ?? "";
|
|
270
|
-
|
|
271
|
-
// Fenced blocks are never interpreted — docs/DONE.md pins the done-line
|
|
272
|
-
// FORMAT inside a fence, and parsing that example as an entry would
|
|
273
|
-
// invent a record out of documentation.
|
|
274
|
-
if (/^\s*```/.test(line)) {
|
|
275
|
-
inFence = !inFence;
|
|
276
|
-
text.push(line);
|
|
277
|
-
continue;
|
|
278
|
-
}
|
|
279
|
-
if (inFence) {
|
|
280
|
-
text.push(line);
|
|
281
|
-
continue;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
if (/^#{1,6}\s/.test(line)) {
|
|
285
|
-
const level = (line.match(/^#+/) ?? [""])[0].length;
|
|
286
|
-
if (level <= 2) {
|
|
287
|
-
kind = sectionKindOf(line);
|
|
288
|
-
subsection = undefined;
|
|
289
|
-
} else if (kind !== "none") {
|
|
290
|
-
subsection = line.replace(/^#+\s*/, "").trim();
|
|
291
|
-
}
|
|
292
|
-
text.push(line);
|
|
293
|
-
continue;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
if (kind === "queue") {
|
|
297
|
-
const item = parseQueueLine(line, subsection);
|
|
298
|
-
if (item) {
|
|
299
|
-
pushItem(item);
|
|
300
|
-
continue;
|
|
301
|
-
}
|
|
302
|
-
} else if (kind === "done") {
|
|
303
|
-
const entry = parseDoneLine(line, subsection);
|
|
304
|
-
if (entry) {
|
|
305
|
-
pushEntry(entry);
|
|
306
|
-
continue;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// Lanes table: a header row followed by an alignment row.
|
|
311
|
-
if (TABLE_ROW.test(line) && TABLE_ALIGN.test(lines[i + 1] ?? "")) {
|
|
312
|
-
const rows: (BoardRow | MalformedRow)[] = [];
|
|
313
|
-
let j = i + 2;
|
|
314
|
-
for (; j < lines.length && TABLE_ROW.test(lines[j] ?? ""); j++) {
|
|
315
|
-
const raw = lines[j] ?? "";
|
|
316
|
-
const cells = splitRow(raw);
|
|
317
|
-
// Arity guard: a row BoardRow cannot hold is refused a record, kept
|
|
318
|
-
// verbatim, and named loudly — destructuring it into the 5-tuple is
|
|
319
|
-
// exactly the silent narrow/widen this exists to prevent. An empty
|
|
320
|
-
// trailing cell (`| a | b | c | d | |`) is still five columns and
|
|
321
|
-
// still a legitimate row.
|
|
322
|
-
if (cells.length !== BOARD_ARITY) {
|
|
323
|
-
rows.push({
|
|
324
|
-
malformed: true,
|
|
325
|
-
verbatim: raw,
|
|
326
|
-
issue:
|
|
327
|
-
`board row at line ${j + 1} has ${cells.length} column(s), expected ${BOARD_ARITY} — ` +
|
|
328
|
-
`refused (kept verbatim, excluded from board records). Fix the row, or widen BoardRow ` +
|
|
329
|
-
`deliberately (BOARD_ARITY + boardCells + this guard together). Row: ${raw}`,
|
|
330
|
-
});
|
|
331
|
-
continue;
|
|
332
|
-
}
|
|
333
|
-
const [lane = "", owner = "", state = "", currentSlice = "", nextGo = ""] = cells;
|
|
334
|
-
rows.push({ id: idFor("b", lane), lane, owner, state, currentSlice, nextGo, raw });
|
|
335
|
-
}
|
|
336
|
-
flushText();
|
|
337
|
-
blocks.push({ kind: "board", header: line, align: lines[i + 1] ?? "", rows });
|
|
338
|
-
i = j - 1;
|
|
339
|
-
continue;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
text.push(line);
|
|
343
|
-
}
|
|
344
|
-
flushText();
|
|
345
|
-
return { trailingNewline, blocks };
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
export function renderWorkDoc(doc: WorkDoc): string {
|
|
349
|
-
const out: string[] = [];
|
|
350
|
-
for (const block of doc.blocks) {
|
|
351
|
-
if (block.kind === "text") out.push(...block.lines);
|
|
352
|
-
else if (block.kind === "queue") out.push(...block.items.map(renderQueueLine));
|
|
353
|
-
else if (block.kind === "done") out.push(...block.entries.map(renderDoneLine));
|
|
354
|
-
else {
|
|
355
|
-
out.push(block.header);
|
|
356
|
-
out.push(block.align);
|
|
357
|
-
for (const r of block.rows) out.push(renderBoardRow(r));
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
return out.join("\n") + (doc.trailingNewline ? "\n" : "");
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// ---------- record extraction ----------
|
|
364
|
-
|
|
365
|
-
export function queueItemsOf(doc: WorkDoc): QueueItem[] {
|
|
366
|
-
return doc.blocks.flatMap((b) => (b.kind === "queue" ? b.items : []));
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
export function doneEntriesOf(doc: WorkDoc): DoneEntry[] {
|
|
370
|
-
return doc.blocks.flatMap((b) => (b.kind === "done" ? b.entries : []));
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
export function boardRowsOf(doc: WorkDoc): BoardRow[] {
|
|
374
|
-
return doc.blocks.flatMap((b) =>
|
|
375
|
-
b.kind === "board" ? b.rows.filter((r): r is BoardRow => !isMalformedRow(r)) : [],
|
|
376
|
-
);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
// Every refused row's issue, in document order — what makes the parse-time
|
|
380
|
-
// rejection LOUD at the tool layer (import_work/list_work/export_work all
|
|
381
|
-
// surface it) instead of a filtered-out record nobody notices.
|
|
382
|
-
export function workDocIssues(doc: WorkDoc): string[] {
|
|
383
|
-
return doc.blocks.flatMap((b) =>
|
|
384
|
-
b.kind === "board" ? b.rows.filter(isMalformedRow).map((r) => r.issue) : [],
|
|
385
|
-
);
|
|
386
|
-
}
|
|
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";
|