@davidbalzan/groundwork-console 0.1.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 (134) hide show
  1. package/bin/groundwork-console.mjs +47 -0
  2. package/dist/answered.js +34 -0
  3. package/dist/auth.js +47 -0
  4. package/dist/bus.js +79 -0
  5. package/dist/config.js +40 -0
  6. package/dist/docwrite.js +66 -0
  7. package/dist/env.js +23 -0
  8. package/dist/events.js +170 -0
  9. package/dist/extensions.js +31 -0
  10. package/dist/gitinfo.js +69 -0
  11. package/dist/groundwork.js +34 -0
  12. package/dist/kitresolve.js +79 -0
  13. package/dist/knowledge.js +72 -0
  14. package/dist/mcp.js +106 -0
  15. package/dist/notify.js +85 -0
  16. package/dist/provision.js +124 -0
  17. package/dist/runs.js +46 -0
  18. package/dist/seam.js +125 -0
  19. package/dist/seamwrite.js +87 -0
  20. package/dist/server.js +818 -0
  21. package/dist/skills.js +48 -0
  22. package/dist/tmux.js +36 -0
  23. package/dist/uploads.js +40 -0
  24. package/groundwork-console.rules.json +85 -0
  25. package/package.json +49 -0
  26. package/public/assets/abnfDiagram-N423BO3Z-DPhyKd1N.js +1 -0
  27. package/public/assets/arc-DmkLmHHi.js +1 -0
  28. package/public/assets/architecture-TIHT7OUA-MgcjoiPc.js +1 -0
  29. package/public/assets/architectureDiagram-T3A2C74G-BXFkjpze.js +36 -0
  30. package/public/assets/array-BifhSqXX.js +1 -0
  31. package/public/assets/blockDiagram-VBNYF7ZC-Ddhca5b4.js +132 -0
  32. package/public/assets/c4Diagram-5PPSVZJV-D4M2Oz2j.js +10 -0
  33. package/public/assets/channel-BAs2g7Wn.js +1 -0
  34. package/public/assets/chunk-2GRJ4B5K-B2cLnEJA.js +1 -0
  35. package/public/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  36. package/public/assets/chunk-4I5QYGJK-CVuqmZe1.js +1 -0
  37. package/public/assets/chunk-5RXB4S5H-CAj33Qry.js +231 -0
  38. package/public/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  39. package/public/assets/chunk-6Q2QTUOP-B6kTvnZS.js +88 -0
  40. package/public/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
  41. package/public/assets/chunk-7Z6QIM7H-BCYMaGkK.js +10 -0
  42. package/public/assets/chunk-GF5L2VYU-eAfQwP-A.js +206 -0
  43. package/public/assets/chunk-I66GZJ75-DyWmv43M.js +127 -0
  44. package/public/assets/chunk-JQJVKLGR-xpwVLHiM.js +156 -0
  45. package/public/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  46. package/public/assets/chunk-KBJHAD2P-BsIw_3BM.js +1 -0
  47. package/public/assets/chunk-KEIR6QF5-BICK3FdT.js +161 -0
  48. package/public/assets/chunk-NSK5VX7P-Dy4p7ORp.js +2 -0
  49. package/public/assets/chunk-QR6OTTB3-D8k2NFkq.js +62 -0
  50. package/public/assets/chunk-RYQCIY6F-TbGMiTbW.js +1 -0
  51. package/public/assets/chunk-UBXNYLIW-By3VqDlw.js +1 -0
  52. package/public/assets/chunk-W5SLKNZC-B6TSWP8U.js +1 -0
  53. package/public/assets/chunk-WRU74C26-CqCOF3fo.js +70 -0
  54. package/public/assets/chunk-XXDRQBXY-B6BeKGFV.js +1 -0
  55. package/public/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  56. package/public/assets/classDiagram-JCYQIIEL-D8vKmZUF.js +1 -0
  57. package/public/assets/classDiagram-v2-OCEON4UE-D8vKmZUF.js +1 -0
  58. package/public/assets/cose-bilkent-JH36ORCC-2ieLCSa1.js +1 -0
  59. package/public/assets/cynefin-VYW2F7L2-BfajhBFb.js +1 -0
  60. package/public/assets/cynefinDiagram-MW4NZA55-DZFY7WjS.js +62 -0
  61. package/public/assets/cytoscape.esm-C2x7inlt.js +321 -0
  62. package/public/assets/dagre-Buvkdvvj.js +1 -0
  63. package/public/assets/dagre-VZM6K2ZE-DECeuEL2.js +4 -0
  64. package/public/assets/defaultLocale-BFoDCU3G.js +1 -0
  65. package/public/assets/diagram-7IWD3JNH-DA6fCDep.js +30 -0
  66. package/public/assets/diagram-B4RE2ZJO-Dj8QoIQR.js +3 -0
  67. package/public/assets/diagram-LBJQPF4R-9JYAugOO.js +24 -0
  68. package/public/assets/diagram-Q27KOJAE-ecTfssa6.js +24 -0
  69. package/public/assets/diagram-UB23O5K3-DOGgwSDm.js +41 -0
  70. package/public/assets/dist-DTWyzWtG.js +1 -0
  71. package/public/assets/ebnfDiagram-BXEA7PRR-BGmWK80p.js +1 -0
  72. package/public/assets/erDiagram-JOGREHBK-CNl1QvhN.js +85 -0
  73. package/public/assets/eventmodeling-45OFAUF4-GcvHanur.js +1 -0
  74. package/public/assets/flowDiagram-UKHOOZJN-DdgmWP6g.js +1 -0
  75. package/public/assets/ganttDiagram-PKOTCBZU-C01y-MjT.js +292 -0
  76. package/public/assets/gitGraph-TEB2WS4Q-BW4zQmf5.js +1 -0
  77. package/public/assets/gitGraphDiagram-DS77QQ5N-CnlU6XMJ.js +106 -0
  78. package/public/assets/graphlib-DS17s2tU.js +1 -0
  79. package/public/assets/index-C3zhnM_H.css +2 -0
  80. package/public/assets/index-DrqxoBmk.js +40 -0
  81. package/public/assets/info-DKCQHKI2--A5cJC9V.js +1 -0
  82. package/public/assets/infoDiagram-6WML65LV-Cz2UOAMW.js +2 -0
  83. package/public/assets/init-C-OQMol4.js +1 -0
  84. package/public/assets/ishikawaDiagram-WSZJBQD7-B4Iqm47_.js +70 -0
  85. package/public/assets/journeyDiagram-NVQOT4AX-BU0js6EE.js +139 -0
  86. package/public/assets/kanban-definition-27J2QSJJ-DmeEPzz0.js +89 -0
  87. package/public/assets/katex-CXMH3UgJ.js +257 -0
  88. package/public/assets/line-Dxi6NubD.js +1 -0
  89. package/public/assets/linear-tama2Fqh.js +1 -0
  90. package/public/assets/map-BaFkSB1l.js +1 -0
  91. package/public/assets/mermaid-parser.core-bGhrBY9T.js +7 -0
  92. package/public/assets/mermaid.core-_OoTZaAK.js +42 -0
  93. package/public/assets/mindmap-definition-FAOFIHXS-BRJt4F9_.js +96 -0
  94. package/public/assets/ordinal-BDEzSJ7C.js +1 -0
  95. package/public/assets/packet-7NZHBO7P-DAZsca60.js +1 -0
  96. package/public/assets/path-fybaL0A-.js +1 -0
  97. package/public/assets/pegDiagram-VL7TDLO6-2k0rcz9N.js +1 -0
  98. package/public/assets/pie-RZYD4A2V-ojW1YX_G.js +1 -0
  99. package/public/assets/pieDiagram-7S7Q4E2Y-CrrciDrS.js +39 -0
  100. package/public/assets/quadrantDiagram-CIZ2JOQS-Dj5lMd95.js +7 -0
  101. package/public/assets/radar-I7S5WNFK-flsKqrK_.js +1 -0
  102. package/public/assets/railroad-3IZDKUUU-BX9H_OcC.js +1 -0
  103. package/public/assets/railroad-abnf-AHOZXSZD-DD8mz__c.js +1 -0
  104. package/public/assets/railroad-ebnf-EBAXGLYW-CWer5P7V.js +1 -0
  105. package/public/assets/railroad-peg-LSFZ7HO6-DDrRJYYw.js +1 -0
  106. package/public/assets/railroadDiagram-AXF67PYL-BRNOv6Sh.js +1 -0
  107. package/public/assets/requirementDiagram-LRYGKXZP-BqJbwH3b.js +84 -0
  108. package/public/assets/rough.esm-Dy-Kn_BL.js +1 -0
  109. package/public/assets/sankeyDiagram-W5VNT64P-BE7fRCAN.js +40 -0
  110. package/public/assets/sequenceDiagram-SI44F4Z6-Cwt-L1ko.js +162 -0
  111. package/public/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
  112. package/public/assets/src-Bc2f8joo.js +1 -0
  113. package/public/assets/stateDiagram-OKZ733FA-etLHG0qi.js +1 -0
  114. package/public/assets/stateDiagram-v2-UEYNNEHI-Crgtg4pC.js +1 -0
  115. package/public/assets/swimlanes-SLNWSIFB-B06NxeYI.js +2 -0
  116. package/public/assets/swimlanesDiagram-ULZ7WXOC-C8S-XAyy.js +8 -0
  117. package/public/assets/timeline-definition-Z64GVDOM-BihNR1uq.js +120 -0
  118. package/public/assets/treeView-QDETBFTQ-CImTfOYf.js +1 -0
  119. package/public/assets/treemap-6X3UGDF4-DBRjbghn.js +1 -0
  120. package/public/assets/vennDiagram-T6HMQDX7-HcbmXJRd.js +34 -0
  121. package/public/assets/wardley-OPB4EBWU-xY5d-6YU.js +1 -0
  122. package/public/assets/wardleyDiagram-T6FBY63Y-B6KN3TYi.js +78 -0
  123. package/public/assets/xychartDiagram-ELKLHX3M-AAW8Pwrl.js +7 -0
  124. package/public/favicon.svg +1 -0
  125. package/public/fonts/JetBrainsMono-400.woff2 +0 -0
  126. package/public/fonts/JetBrainsMono-500.woff2 +0 -0
  127. package/public/fonts/JetBrainsMono-600.woff2 +0 -0
  128. package/public/fonts/SourceSans3-400.woff2 +0 -0
  129. package/public/fonts/SourceSans3-500.woff2 +0 -0
  130. package/public/fonts/SourceSans3-600.woff2 +0 -0
  131. package/public/fonts/SourceSans3-700.woff2 +0 -0
  132. package/public/fonts/SourceSerif4-600.woff2 +0 -0
  133. package/public/icons.svg +24 -0
  134. package/public/index.html +14 -0
@@ -0,0 +1,79 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ export const REQUIRES = { seam: "^0.1.0", mcp: "^0.23.0", groundwork: "^0.3.0", playbook: "^0.5.0" };
6
+ const which = (bin) => { try {
7
+ return execFileSync("which", [bin], { encoding: "utf8" }).trim() || null;
8
+ }
9
+ catch {
10
+ return null;
11
+ } };
12
+ export const hasKitCheckout = (kitPath) => existsSync(path.join(kitPath, "packages/groundwork/src/cli.mjs"));
13
+ /** argv (for `node`) to run the groundwork CLI: kit checkout file, else the global `groundwork` bin (a JS file with a shebang). */
14
+ export function groundworkCli(kitPath) {
15
+ const local = path.join(kitPath, "packages/groundwork/src/cli.mjs");
16
+ if (existsSync(local))
17
+ return local;
18
+ const g = which("groundwork");
19
+ return g ? realpathSync(g) : null;
20
+ }
21
+ export function playbookCli(kitPath) {
22
+ const local = path.join(kitPath, "packages/coord-playbook/bin/install.mjs");
23
+ if (existsSync(local))
24
+ return local;
25
+ const g = which("agent-coordination");
26
+ return g ? realpathSync(g) : null;
27
+ }
28
+ /** MCP server entry: kit checkout dist, else the global `agent-coord-mcp` bin (which is dist/server.js). */
29
+ export function mcpServer(kitPath) {
30
+ const local = path.join(kitPath, "packages/coord-mcp/dist/server.js");
31
+ if (existsSync(local))
32
+ return local;
33
+ const g = which("agent-coord-mcp");
34
+ return g ? realpathSync(g) : null;
35
+ }
36
+ const pkgVersionNear = (file) => { if (!file)
37
+ return null; let d = path.dirname(file); for (let i = 0; i < 6; i++) {
38
+ const pj = path.join(d, "package.json");
39
+ if (existsSync(pj)) {
40
+ try {
41
+ return JSON.parse(readFileSync(pj, "utf8")).version ?? null;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ d = path.dirname(d);
48
+ } return null; };
49
+ /** Installed versions the console actually resolves to (seam from node_modules; the rest from wherever the CLIs came from). */
50
+ export function installedVersions(kitPath) {
51
+ let seam = null;
52
+ try {
53
+ seam = pkgVersionNear(fileURLToPath(import.meta.resolve("@davidbalzan/groundwork-seam")));
54
+ }
55
+ catch { /* not installed */ }
56
+ const gw = groundworkCli(kitPath), pb = playbookCli(kitPath), mcp = mcpServer(kitPath);
57
+ return { seam, groundwork: pkgVersionNear(gw), playbook: pkgVersionNear(pb), mcp: pkgVersionNear(mcp), paths: { groundwork: gw, playbook: pb, mcp, kitCheckout: hasKitCheckout(kitPath) ? kitPath : null } };
58
+ }
59
+ /** Minimal caret-range check (^a.b.c): same major (or same minor when major is 0), and >= min. */
60
+ export function satisfies(v, range) {
61
+ if (!v)
62
+ return null;
63
+ const m = range.match(/^\^(\d+)\.(\d+)\.(\d+)$/);
64
+ if (!m)
65
+ return null;
66
+ const [a, b, c] = v.split(".").map(Number);
67
+ const [x, y, z] = m.slice(1).map(Number);
68
+ if (a !== x)
69
+ return false;
70
+ if (x === 0 && b !== y)
71
+ return false;
72
+ return a > x || b > y || (b === y && c >= z);
73
+ }
74
+ export function requiresReport(kitPath) {
75
+ const inst = installedVersions(kitPath);
76
+ const check = (k) => ({ required: REQUIRES[k], installed: inst[k], ok: satisfies(inst[k], REQUIRES[k]) });
77
+ const r = { seam: check("seam"), mcp: check("mcp"), groundwork: check("groundwork"), playbook: check("playbook") };
78
+ return { ...r, paths: inst.paths, ok: Object.values(r).every((x) => x.ok !== false) };
79
+ }
@@ -0,0 +1,72 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ /* Global knowledge repo (cross-project ADRs + dated lessons) — the thing `groundwork knowledge` configures.
5
+ Resolution mirrors packages/groundwork/src/lib/config.mjs: $GROUNDWORK_KNOWLEDGE → ~/.config/groundwork/config.json knowledgeRepo → unset. */
6
+ export function knowledgePath() {
7
+ if (process.env.GROUNDWORK_KNOWLEDGE)
8
+ return { path: process.env.GROUNDWORK_KNOWLEDGE, source: "env GROUNDWORK_KNOWLEDGE" };
9
+ const cfg = path.join(process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config"), "groundwork", "config.json");
10
+ try {
11
+ const j = JSON.parse(readFileSync(cfg, "utf8"));
12
+ if (j.knowledgeRepo)
13
+ return { path: j.knowledgeRepo, source: `groundwork config (${cfg})` };
14
+ }
15
+ catch { /* unset */ }
16
+ return { path: null, source: null };
17
+ }
18
+ /** ADRs from INDEX.md tables (id · [title](adr/file) · Enf · tags · date, grouped by the ## heading above), plus any adr/*.md not in the index. */
19
+ export function knowledgeIndex() {
20
+ const { path: root, source } = knowledgePath();
21
+ if (!root || !existsSync(root))
22
+ return { path: root, source, exists: false, index: null, adrs: [], notes: [], sections: [] };
23
+ const indexFile = ["INDEX.md", "README.md"].find((f) => existsSync(path.join(root, f))) ?? null;
24
+ const adrs = [];
25
+ const sections = [];
26
+ if (indexFile) {
27
+ let section = null;
28
+ for (const line of readFileSync(path.join(root, indexFile), "utf8").split("\n")) {
29
+ const h = line.match(/^##\s+(.+)$/);
30
+ if (h) {
31
+ section = h[1].trim();
32
+ if (!sections.includes(section))
33
+ sections.push(section);
34
+ continue;
35
+ }
36
+ const m = line.match(/^\|\s*(\d{3,4})\s*\|\s*\[([^\]]+)\]\(([^)]+)\)\s*\|\s*([^|]*)\|\s*([^|]*)\|\s*([^|]*)\|/);
37
+ if (m)
38
+ adrs.push({ id: m[1], title: m[2].trim(), file: m[3].trim(), enf: m[4].replace(/[*_`]/g, "").trim() || null, tags: m[5].split(",").map((t) => t.trim()).filter(Boolean), date: m[6].trim() || null, section });
39
+ }
40
+ }
41
+ const adrDir = path.join(root, "adr");
42
+ if (existsSync(adrDir))
43
+ for (const f of readdirSync(adrDir).filter((n) => n.endsWith(".md")).sort()) {
44
+ const rel = `adr/${f}`;
45
+ if (adrs.some((a) => a.file === rel))
46
+ continue;
47
+ const id = f.match(/^(\d{3,4})/)?.[1] ?? f;
48
+ const title = firstHeading(path.join(adrDir, f)) ?? f.replace(/^\d+-/, "").replace(/\.md$/, "").replace(/-/g, " ");
49
+ adrs.push({ id, title, file: rel, enf: null, tags: [], date: null, section: "(not in INDEX)" });
50
+ }
51
+ const notesDir = path.join(root, "notes");
52
+ const notes = existsSync(notesDir) ? readdirSync(notesDir).filter((n) => n.endsWith(".md") && !n.startsWith("_")).sort().map((f) => ({ file: `notes/${f}`, title: firstHeading(path.join(notesDir, f)), bytes: readFileSync(path.join(notesDir, f)).length })) : [];
53
+ return { path: root, source, exists: true, index: indexFile, adrs, notes, sections };
54
+ }
55
+ function firstHeading(file) { try {
56
+ return readFileSync(file, "utf8").split("\n").find((l) => /^#\s+/.test(l))?.replace(/^#\s+/, "").trim() ?? null;
57
+ }
58
+ catch {
59
+ return null;
60
+ } }
61
+ export function knowledgeDoc(rel) {
62
+ const { path: root } = knowledgePath();
63
+ if (!root)
64
+ return null;
65
+ const safe = path.normalize(rel).replace(/^(\.\.[/\\])+/, "").replace(/^[/\\]+/, "");
66
+ if (!safe.endsWith(".md"))
67
+ return null;
68
+ const full = path.resolve(root, safe);
69
+ if (!full.startsWith(path.resolve(root) + path.sep) || !existsSync(full))
70
+ return null;
71
+ return { path: safe, full, source: readFileSync(full, "utf8") };
72
+ }
package/dist/mcp.js ADDED
@@ -0,0 +1,106 @@
1
+ /* MCP clients for the coord bus. One stdio session per identity (the server binds identity per session):
2
+ - groundwork-console · role automation — no record authority, no transport; used for reads and rule-driven sends.
3
+ - david · role human — David's own voice (replies, decision locks); only when bus writes are on.
4
+ Bus writes are refused unless the runtime switch is on (init from GROUNDWORK_CONSOLE_BUS_WRITES=1). */
5
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
7
+ import path from "node:path";
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { mcpServer } from "./kitresolve.js";
11
+ const IDENTITIES = {
12
+ "groundwork-console": { agentId: "groundwork-console", project: "groundwork-console", role: { roleId: "automation", displayName: "groundwork-console" } },
13
+ david: { agentId: "david", project: "groundwork-console", role: { roleId: "human", displayName: "David" } },
14
+ };
15
+ export const AGENT_ID = "groundwork-console";
16
+ export const READ_ONLY_TOOLS = new Set(["doctor", "list_agents", "list_rooms", "list_scopes", "status", "ping", "read_messages", "retrieve_room_history", "retrieve_message", "list_work"]);
17
+ export const WRITE_TOOLS_GATED = new Set(["send_message", "post_status", "prune", "force_unregister", "unregister", "join_room", "leave_room", "delete_room", "set_room_topic", "set_room_motd", "import_work", "export_work"]);
18
+ const SWITCH_FILE = path.join(homedir(), ".groundwork-console", "switches.json");
19
+ function loadSwitches() {
20
+ let saved = {};
21
+ try {
22
+ if (existsSync(SWITCH_FILE))
23
+ saved = JSON.parse(readFileSync(SWITCH_FILE, "utf8"));
24
+ }
25
+ catch { /* fresh */ }
26
+ const bus = saved.bus ?? { enabled: false, changedAt: 0, by: "default" };
27
+ const seam = saved.seam ?? { enabled: false, changedAt: 0, by: "default" };
28
+ if (process.env.GROUNDWORK_CONSOLE_BUS_WRITES === "1") {
29
+ bus.enabled = true;
30
+ bus.by = "env";
31
+ }
32
+ if (process.env.GROUNDWORK_CONSOLE_SEAM_WRITES === "1") {
33
+ seam.enabled = true;
34
+ seam.by = "env";
35
+ }
36
+ return { bus, seam };
37
+ }
38
+ const loaded = loadSwitches();
39
+ export const busWrites = loaded.bus;
40
+ export const seamWrites = loaded.seam;
41
+ export const BUS_WRITES = busWrites.enabled; // legacy import
42
+ export function saveSwitches() { mkdirSync(path.dirname(SWITCH_FILE), { recursive: true }); writeFileSync(SWITCH_FILE, JSON.stringify({ bus: busWrites, seam: seamWrites, file: SWITCH_FILE }, null, 2)); }
43
+ export const SWITCHES_FILE = SWITCH_FILE;
44
+ export class BusClient {
45
+ kitPath;
46
+ coordDir;
47
+ identity;
48
+ mcpPath;
49
+ client = null;
50
+ connecting = null;
51
+ constructor(kitPath, coordDir, identity = "groundwork-console", mcpPath) {
52
+ this.kitPath = kitPath;
53
+ this.coordDir = coordDir;
54
+ this.identity = identity;
55
+ this.mcpPath = mcpPath;
56
+ }
57
+ get serverPath() { return this.mcpPath ?? mcpServer(this.kitPath) ?? path.join(this.kitPath, "packages/coord-mcp/dist/server.js"); }
58
+ get agentId() { return IDENTITIES[this.identity].agentId; }
59
+ async connect(log) {
60
+ if (this.client)
61
+ return this.client;
62
+ if (this.connecting)
63
+ return this.connecting;
64
+ this.connecting = (async () => {
65
+ const server = this.serverPath;
66
+ const id = IDENTITIES[this.identity];
67
+ if (!existsSync(server))
68
+ throw new Error(`agent-coord-mcp not built at ${server}`);
69
+ log?.(`spawning ${server} as ${id.agentId} (${id.role.roleId})`);
70
+ const transport = new StdioClientTransport({ command: process.execPath, args: [server], env: { ...process.env, AGENT_COORD_DIR: this.coordDir, AGENT_COORD_ID: id.agentId }, stderr: "pipe" });
71
+ const client = new Client({ name: `groundwork-console/${id.agentId}`, version: "0.1.0" });
72
+ await client.connect(transport);
73
+ const reg = await client.callTool({ name: "register", arguments: { agentId: id.agentId, project: id.project, role: id.role, force: true } });
74
+ log?.(`register → ${JSON.stringify(reg.structuredContent ?? textOf(reg)).slice(0, 160)}`);
75
+ this.client = client;
76
+ return client;
77
+ })();
78
+ try {
79
+ return await this.connecting;
80
+ }
81
+ finally {
82
+ this.connecting = null;
83
+ }
84
+ }
85
+ async call(name, args, log) {
86
+ const isWrite = WRITE_TOOLS_GATED.has(name);
87
+ if (!READ_ONLY_TOOLS.has(name) && !isWrite)
88
+ throw new Error(`tool ${name} is not allow-listed`);
89
+ if (isWrite && !busWrites.enabled)
90
+ throw new Error(`bus writes are off (switch it on in the header, or GROUNDWORK_CONSOLE_BUS_WRITES=1)`);
91
+ const c = await this.connect(log);
92
+ log?.(`→ ${name} ${JSON.stringify(args).slice(0, 300)}`);
93
+ const res = await c.callTool({ name, arguments: { agentId: this.agentId, from: this.agentId, ...args } });
94
+ const out = res.structuredContent ?? parseText(textOf(res));
95
+ log?.(`← ${name} ok`);
96
+ return out;
97
+ }
98
+ async close() { await this.client?.close(); this.client = null; }
99
+ }
100
+ function textOf(r) { return (r.content ?? []).map((c) => c.text ?? "").join("\n"); }
101
+ function parseText(t) { try {
102
+ return JSON.parse(t);
103
+ }
104
+ catch {
105
+ return t;
106
+ } }
package/dist/notify.js ADDED
@@ -0,0 +1,85 @@
1
+ /* Notifications: one feed of things worth David's attention, aggregated from what the API already watches.
2
+ Kinds: dm (new DM to david) · decision (DAVID_DECISION in a watched room) · blocker (BLOCKER in a watched room) · run.failed · run.side-effect (a side-effecting run finished) · rule.sent (engine really sent).
3
+ Watched rooms = rooms linked to configured projects. Ring buffer; SSE fan-out; the client keeps its own "seen" watermark. */
4
+ import { roomMessages } from "./bus.js";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import path from "node:path";
7
+ const RING = 300;
8
+ export class Notifier {
9
+ cfg;
10
+ runs;
11
+ events;
12
+ buffer = [];
13
+ listeners = new Set();
14
+ lastRoomTs = new Map();
15
+ seenDm = new Set();
16
+ seq = 0;
17
+ constructor(cfg, runs, events) {
18
+ this.cfg = cfg;
19
+ this.runs = runs;
20
+ this.events = events;
21
+ }
22
+ recent(limit = 100) { return this.buffer.slice(-limit).reverse(); }
23
+ subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
24
+ emit(n) { const full = { id: `n-${Date.now()}-${++this.seq}`, ...n }; this.buffer.push(full); if (this.buffer.length > RING)
25
+ this.buffer.shift(); for (const l of this.listeners)
26
+ l(full); }
27
+ start(intervalMs = 15_000) {
28
+ // baseline: remember current tails so we only notify on NEW items
29
+ this.pollRooms(true);
30
+ this.pollInbox(true);
31
+ setInterval(() => { this.pollRooms(false); this.pollInbox(false); }, intervalMs).unref();
32
+ this.runs.subscribe((r) => {
33
+ if (r.status === "failed")
34
+ this.emit({ ts: Date.now(), kind: "run.failed", title: `Run failed · ${r.label}`, body: r.error, project: r.project, link: `/runs?run=${r.id}`, urgent: true });
35
+ else if (r.status === "ok" && r.sideEffects)
36
+ this.emit({ ts: Date.now(), kind: "run.side-effect", title: `Done · ${r.label}`, project: r.project, link: `/runs?run=${r.id}`, urgent: false });
37
+ });
38
+ this.events.subscribe((e) => { for (const a of e.actions)
39
+ if (a.sent && !this.buffer.some((n) => n.link === `/runs?run=${a.runId}`))
40
+ this.emit({ ts: Date.now(), kind: "rule.sent", title: `Rule sent · ${a.ruleId}`, body: `${a.prefix ?? ""} ${a.text ?? ""} → ${a.to ?? "#" + a.room}`, project: e.project, link: `/runs?run=${a.runId}`, urgent: false }); });
41
+ }
42
+ pollRooms(baseline) {
43
+ for (const p of this.cfg.projects) {
44
+ if (!p.room)
45
+ continue;
46
+ const msgs = roomMessages(this.cfg.coordDir, p.room, 50);
47
+ const last = this.lastRoomTs.get(p.room) ?? 0;
48
+ const fresh = msgs.filter((m) => typeof m.ts === "number" && m.ts > last && !m.system);
49
+ const newest = Math.max(last, ...msgs.map((m) => (typeof m.ts === "number" ? m.ts : 0)));
50
+ this.lastRoomTs.set(p.room, newest);
51
+ if (baseline)
52
+ continue;
53
+ for (const m of fresh) {
54
+ const t = String(m.text ?? "");
55
+ if (/^DAVID_DECISION\b/.test(t))
56
+ this.emit({ ts: m.ts, kind: "decision", title: `Needs David · ${p.name}`, body: t.replace(/^DAVID_DECISION:?\s*/, "").slice(0, 240), project: p.name, room: p.room, from: m.from, link: `/bus?room=${p.room}&msg=${m.id}`, urgent: true });
57
+ else if (/^BLOCKER\b/.test(t))
58
+ this.emit({ ts: m.ts, kind: "blocker", title: `BLOCKER · #${p.room}`, body: t.replace(/^BLOCKER:?\s*/, "").slice(0, 240), project: p.name, room: p.room, from: m.from, link: `/bus?room=${p.room}&msg=${m.id}`, urgent: true });
59
+ }
60
+ }
61
+ }
62
+ pollInbox(baseline) {
63
+ const file = path.join(this.cfg.coordDir, "inbox", "david.jsonl");
64
+ if (!existsSync(file))
65
+ return;
66
+ const lines = readFileSync(file, "utf8").split("\n").filter(Boolean);
67
+ for (const l of lines) {
68
+ let m;
69
+ try {
70
+ m = JSON.parse(l);
71
+ }
72
+ catch {
73
+ continue;
74
+ }
75
+ const id = String(m.id ?? l.length);
76
+ if (this.seenDm.has(id))
77
+ continue;
78
+ this.seenDm.add(id);
79
+ if (baseline)
80
+ continue;
81
+ const t = String(m.text ?? "");
82
+ this.emit({ ts: m.ts ?? Date.now(), kind: "dm", title: `DM from ${m.from}`, body: t.slice(0, 240), from: m.from, link: `/bus?view=inbox`, urgent: /^(BLOCKER|DAVID_DECISION)\b/.test(t) });
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,124 @@
1
+ /* Spawn an agent into tmux, the playbook way (coord-claude-layout.sh pattern):
2
+ window `fleet-<project>` (created if missing) → new pane cd'd to the repo → launch harness → wait → inject `<skill> --id=<agentId> [--lane]` + `/color` → wait for the agent to register on the bus.
3
+ This IS a side effect (processes + keystrokes into a pane the console just created). It never types into an existing agent's pane. */
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import path from "node:path";
8
+ const run = promisify(execFile);
9
+ const tmux = async (...args) => (await run("tmux", args, { timeout: 8_000 })).stdout.trim();
10
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
11
+ export const HARNESS_CMD = { claude: "claude --dangerously-skip-permissions", grok: "grok", codex: "codex", opencode: "opencode" };
12
+ export const ROLE_SKILL = { coordinator: "/coordinator", worker: "/coord-worker", qa: "/coord-qa", ci: "/coord-ci", aide: "/coord-aide", audit: "/coord-audit" };
13
+ export const ROLE_COLOR = { coordinator: "green", aide: "pink", qa: "yellow", ci: "orange", worker: "blue", audit: "magenta" };
14
+ const CLAUDE_HARNESS_ONLY = new Set(["claude"]); // /color and slash-skills are Claude Code; other harnesses get the skill text as a plain instruction
15
+ async function fleetWindow(project, startDir, log) {
16
+ const win = `fleet-${project}`;
17
+ const wins = (await tmux("list-windows", "-a", "-F", "#{session_name}:#{window_index}\t#{window_name}")).split("\n").filter(Boolean).map((l) => l.split("\t"));
18
+ const hit = wins.find(([, name]) => name === win);
19
+ if (hit) {
20
+ log(`window ${win} exists (${hit[0]})`);
21
+ return { target: hit[0], freshPane: null };
22
+ }
23
+ const session = (await tmux("list-sessions", "-F", "#{session_name}")).split("\n")[0];
24
+ const pane = await tmux("new-window", "-d", "-t", `${session}:`, "-n", win, "-c", startDir, "-P", "-F", "#{pane_id}");
25
+ log(`created window ${win} in session ${session} (first pane ${pane} reused for the first agent — no spare shell)`);
26
+ return { target: `${session}:${win}`, freshPane: pane };
27
+ }
28
+ export async function spawnAgent(req, isRegistered, log, isAttached = () => true) {
29
+ if (!/^[a-z0-9][a-z0-9-_]*$/i.test(req.agentId))
30
+ throw new Error("agentId: letters, digits, - _");
31
+ if (isRegistered(req.agentId))
32
+ throw new Error(`${req.agentId} is already registered on the bus`);
33
+ if (!existsSync(req.repoPath))
34
+ throw new Error("repo path missing");
35
+ const { target, freshPane } = await fleetWindow(req.project, req.repoPath, log);
36
+ // reuse the fresh window's first pane; otherwise split and tile so N agents fit
37
+ const paneId = freshPane ?? await tmux("split-window", "-t", target, "-c", req.repoPath, "-P", "-F", "#{pane_id}");
38
+ if (!freshPane)
39
+ await tmux("select-layout", "-t", target, "tiled");
40
+ await tmux("select-pane", "-t", paneId, "-T", req.agentId);
41
+ log(`pane ${paneId} in ${target} (cwd ${req.repoPath}), title ${req.agentId}`);
42
+ const cmd = HARNESS_CMD[req.harness];
43
+ await tmux("send-keys", "-t", paneId, "-l", cmd);
44
+ await tmux("send-keys", "-t", paneId, "C-m");
45
+ log(`launched: ${cmd}`);
46
+ const wait = Math.min(Math.max(req.waitSeconds ?? 8, 3), 60);
47
+ log(`waiting ${wait}s for the harness to start…`);
48
+ await sleep(wait * 1000);
49
+ const inject = async (line) => { await tmux("send-keys", "-t", paneId, "-l", line); await sleep(400); await tmux("send-keys", "-t", paneId, "C-m"); log(`injected: ${line}`); await sleep(600); };
50
+ if (req.model && CLAUDE_HARNESS_ONLY.has(req.harness))
51
+ await inject(`/model ${req.model}`);
52
+ if (CLAUDE_HARNESS_ONLY.has(req.harness))
53
+ await inject(`/color ${ROLE_COLOR[req.role]}`);
54
+ const skill = `${ROLE_SKILL[req.role]} --id=${req.agentId}${req.lane ? ` --lane=${req.lane}` : ""}${req.room ? ` --room=${req.room}` : ""}`;
55
+ await inject(CLAUDE_HARNESS_ONLY.has(req.harness) ? skill : `Act per the ${ROLE_SKILL[req.role].slice(1)} playbook card: ${skill}${req.note ? ". " + req.note : ""}`);
56
+ if (req.note && CLAUDE_HARNESS_ONLY.has(req.harness))
57
+ await inject(req.note);
58
+ // wait for registration
59
+ const deadline = Date.now() + 90_000;
60
+ let registered = false;
61
+ while (Date.now() < deadline) {
62
+ if (isRegistered(req.agentId)) {
63
+ registered = true;
64
+ break;
65
+ }
66
+ await sleep(2_000);
67
+ }
68
+ log(registered ? `✓ ${req.agentId} registered on the bus` : `⚠ ${req.agentId} not registered after 90s — check the pane`);
69
+ // transport: a registered-but-unattached agent cannot receive pushed DMs. Give it 30 s to attach itself (the role card says so); otherwise tell it — into the pane WE created.
70
+ let attached = false;
71
+ if (registered) {
72
+ const d2 = Date.now() + 30_000;
73
+ while (Date.now() < d2) {
74
+ if (isAttached(req.agentId)) {
75
+ attached = true;
76
+ break;
77
+ }
78
+ await sleep(2_000);
79
+ }
80
+ if (!attached) {
81
+ log(`⚠ not attached to tmux-push after 30 s — asking it to attach (typed into its own pane ${paneId})`);
82
+ await inject(`Attach yourself to the coord bus transport now: call attach_agent with agentId ${req.agentId}, tmuxTarget ${paneId} (tmux-push, includeRoom true). Without it, DMs will not reach this pane.`);
83
+ const d3 = Date.now() + 60_000;
84
+ while (Date.now() < d3) {
85
+ if (isAttached(req.agentId)) {
86
+ attached = true;
87
+ break;
88
+ }
89
+ await sleep(2_000);
90
+ }
91
+ }
92
+ log(attached ? `✓ ${req.agentId} attached (tmux-push) — DMs will land in ${paneId}` : `✗ ${req.agentId} still not attached — DMs will sit in its inbox until it attaches (Fleets shows "not attached")`);
93
+ }
94
+ return { agentId: req.agentId, pane: paneId, window: target, registered, attached };
95
+ }
96
+ export function suggestId(project, role, existing) {
97
+ const base = `${project}-${role}`;
98
+ if (role !== "worker")
99
+ return existing.includes(base) ? `${base}-2` : base;
100
+ let n = 1;
101
+ while (existing.includes(`${base}-${n}`))
102
+ n++;
103
+ return `${base}-${n}`;
104
+ }
105
+ export function readPresets(coordDir) { try {
106
+ return JSON.parse(readFileSync(path.join(coordDir, "presets.json"), "utf8"));
107
+ }
108
+ catch {
109
+ return [];
110
+ } }
111
+ export const FLEET_TEMPLATES = [
112
+ { id: "solo", label: "Solo · aide", roles: ["aide"], workers: 0, hint: "your planning buddy for a repo you drive yourself (plan-phase, queue)" },
113
+ { id: "minimal", label: "Minimal · aide + coordinator + 1 worker", roles: ["aide", "coordinator"], workers: 1, hint: "smallest fleet that ships: you talk to the aide, coordinator routes one lane" },
114
+ { id: "standard", label: "Standard · aide + coordinator + qa + 2 workers", roles: ["aide", "coordinator", "qa"], workers: 2, hint: "the playbook default; QA gates, aide owns the queue" },
115
+ { id: "full", label: "Full · + ci, N workers", roles: ["aide", "coordinator", "qa", "ci"], workers: 3, hint: "release-flow fleet (tier-1 auto-deploy) — cheap harness for ci" },
116
+ ];
117
+ /* Fleet presence: which playbook roles are live for a project. */
118
+ export function fleetStatus(live) {
119
+ const has = (re) => live.find((a) => re.test(a.role ?? ""))?.agentId ?? null;
120
+ const roles = { coordinator: has(/coordinator/i), aide: has(/aide|liaison|curator/i), qa: has(/\bqa\b|quality/i), ci: has(/\bci\b/i), workers: live.filter((a) => /worker|repo-owner|lane/i.test(a.role ?? "") || (!/coordinator|aide|liaison|curator|qa|quality|\bci\b|automation|human|audit/i.test(a.role ?? "") && a.role)).map((a) => a.agentId) };
121
+ const state = live.length === 0 ? "absent" : roles.coordinator && roles.workers.length > 0 ? "present" : "partial";
122
+ const missing = [!roles.coordinator && "coordinator", !roles.aide && "aide", !roles.qa && "qa", roles.workers.length === 0 && "worker"].filter(Boolean);
123
+ return { state, roles, missing, live: live.map((a) => a.agentId) };
124
+ }
package/dist/runs.js ADDED
@@ -0,0 +1,46 @@
1
+ const RING = 200, LOG_MAX = 400;
2
+ export class Runs {
3
+ runs = [];
4
+ listeners = new Set();
5
+ seq = 0;
6
+ list(limit = 50) { return this.runs.slice(-limit).reverse(); }
7
+ get(id) { return this.runs.find((r) => r.id === id); }
8
+ running() { return this.runs.filter((r) => r.status === "running" || r.status === "queued"); }
9
+ subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
10
+ emit(r) { for (const l of this.listeners)
11
+ l(r); }
12
+ create(init) {
13
+ const r = { id: `run-${Date.now()}-${++this.seq}`, status: "queued", startedAt: Date.now(), log: [], ...init };
14
+ this.runs.push(r);
15
+ if (this.runs.length > RING)
16
+ this.runs.shift();
17
+ this.emit(r);
18
+ return r;
19
+ }
20
+ log(r, line) { r.log.push(`${new Date().toISOString().slice(11, 19)} ${line}`); if (r.log.length > LOG_MAX)
21
+ r.log.shift(); this.emit(r); }
22
+ start(r) { r.status = "running"; r.startedAt = Date.now(); this.emit(r); }
23
+ finish(r, result) { r.status = "ok"; r.endedAt = Date.now(); r.result = result; this.emit(r); }
24
+ fail(r, err) { r.status = "failed"; r.endedAt = Date.now(); r.error = err instanceof Error ? err.message : String(err); this.emit(r); }
25
+ /** Fire-and-forget variant: returns the Run immediately (status running); the task keeps logging. */
26
+ execAsync(init, task) {
27
+ const r = this.create(init);
28
+ this.start(r);
29
+ task((l) => this.log(r, l), r).then((out) => this.finish(r, out)).catch((e) => { this.log(r, `error: ${e instanceof Error ? e.message : String(e)}`); this.fail(r, e); });
30
+ return r;
31
+ }
32
+ /** Run an async task as a Run; the task gets a logger. */
33
+ async exec(init, task) {
34
+ const r = this.create(init);
35
+ this.start(r);
36
+ try {
37
+ const out = await task((l) => this.log(r, l), r);
38
+ this.finish(r, out);
39
+ }
40
+ catch (e) {
41
+ this.log(r, `error: ${e instanceof Error ? e.message : String(e)}`);
42
+ this.fail(r, e);
43
+ }
44
+ return r;
45
+ }
46
+ }
package/dist/seam.js ADDED
@@ -0,0 +1,125 @@
1
+ /* Read-only views over a project's seam files, parsed ONLY through @davidbalzan/groundwork-seam. */
2
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { parseWorkDoc, queueItemsOf, doneEntriesOf, listWorkBoardOf, workDocIssues, SCHEMA } from "@davidbalzan/groundwork-seam";
5
+ import { parseFactsDoc } from "@davidbalzan/groundwork-seam";
6
+ const read = (p) => (existsSync(p) ? readFileSync(p, "utf8") : null);
7
+ const FACT_STALE_DAYS = 14; // groundwork doctor rule
8
+ export function boardOf(projectPath) {
9
+ const docs = path.join(projectPath, "docs");
10
+ const ws = read(path.join(docs, "WORKSTREAMS.md"));
11
+ const q = read(path.join(docs, "QUEUE.md"));
12
+ const d = read(path.join(docs, "DONE.md"));
13
+ const f = read(path.join(docs, "FACTS.md"));
14
+ const wsDoc = ws ? parseWorkDoc(ws) : null;
15
+ const board = wsDoc ? listWorkBoardOf(wsDoc) : [];
16
+ const schema = board.find((r) => "schema" in r)?.schema ?? null;
17
+ const qDoc = q ? parseWorkDoc(q) : null;
18
+ const dDoc = d ? parseWorkDoc(d) : null;
19
+ const facts = f ? parseFactsDoc(f) : null;
20
+ const now = Date.now();
21
+ return {
22
+ files: { workstreams: !!ws, queue: !!q, done: !!d, facts: !!f },
23
+ workstreams: { schema: schema ?? (ws ? "unknown" : null), rows: board, issues: wsDoc ? workDocIssues(wsDoc) : [] },
24
+ queue: qDoc ? queueItemsOf(qDoc) : [],
25
+ done: dDoc ? doneEntriesOf(dDoc) : [],
26
+ facts: facts
27
+ ? {
28
+ entries: facts.entries.map((e) => {
29
+ const verified = e.verified;
30
+ const ageDays = verified ? Math.floor((now - Date.parse(verified)) / 86_400_000) : null;
31
+ return { ...e, ageDays, stale: ageDays !== null && ageDays > FACT_STALE_DAYS };
32
+ }),
33
+ issues: facts.issues,
34
+ }
35
+ : null,
36
+ phases: promoteByStreams(phasesOf(docs), board),
37
+ schemas: SCHEMA,
38
+ };
39
+ }
40
+ // Phase progress the way `groundwork status` derives it: checkbox counts in docs/phases/<phase>/PHASE<n>_TASKS.md
41
+ export function phasesOf(docs) {
42
+ const dir = path.join(docs, "phases");
43
+ if (!existsSync(dir))
44
+ return [];
45
+ return readdirSync(dir, { withFileTypes: true })
46
+ .filter((e) => e.isDirectory() && /^phase/i.test(e.name))
47
+ .map((e) => {
48
+ const tasks = readdirSync(path.join(dir, e.name)).find((n) => /_TASKS\.md$/i.test(n));
49
+ const src = tasks ? read(path.join(dir, e.name, tasks)) ?? "" : "";
50
+ const total = (src.match(/^\s*- \[[ xX]\]/gm) ?? []).length;
51
+ const done = (src.match(/^\s*- \[[xX]\]/gm) ?? []).length;
52
+ const readme = read(path.join(dir, e.name, "README.md")) ?? "";
53
+ const title = readme.match(/^#\s+(.+)$/m)?.[1] ?? e.name;
54
+ // complete: all tasks ticked · ongoing: some ticked · planned: tasks exist, none ticked · pending: no PHASE*_TASKS.md yet (run /plan-phase)
55
+ const state = total && done === total ? "complete" : done > 0 ? "ongoing" : total > 0 ? "planned" : "pending";
56
+ const n = e.name.match(/(\d+(?:\.\d+)?)/)?.[1] ?? e.name;
57
+ return { id: e.name, n, title, done, total, state, readme: existsSync(path.join(dir, e.name, "README.md")) ? `phases/${e.name}/README.md` : null, tasks: tasks ? `phases/${e.name}/${tasks}` : null };
58
+ })
59
+ .sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }));
60
+ }
61
+ export function docOf(projectPath, rel) {
62
+ const safe = path.normalize(rel).replace(/^(\.\.[/\\])+/, "");
63
+ const full = path.join(projectPath, "docs", safe);
64
+ if (!full.startsWith(path.join(projectPath, "docs")))
65
+ return null;
66
+ return read(full);
67
+ }
68
+ export function docsIndex(projectPath) {
69
+ const docs = path.join(projectPath, "docs");
70
+ if (!existsSync(docs))
71
+ return [];
72
+ const out = [];
73
+ const walk = (dir, prefix = "") => {
74
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
75
+ if (e.name.startsWith("."))
76
+ continue;
77
+ if (e.isDirectory())
78
+ walk(path.join(dir, e.name), prefix + e.name + "/");
79
+ else if (e.name.endsWith(".md"))
80
+ out.push(prefix + e.name);
81
+ }
82
+ };
83
+ walk(docs);
84
+ return out.sort();
85
+ }
86
+ /** Tasks of one phase: every `- [ ]` / `- [x]` in PHASE*_TASKS.md with line number, section (last ## / ### heading) and nesting depth. Same source `groundwork status` counts. */
87
+ export function phaseTasksOf(docs, phaseId) {
88
+ const dir = path.join(docs, "phases", phaseId);
89
+ if (!existsSync(dir))
90
+ return null;
91
+ const tasksFile = readdirSync(dir).find((n) => /_TASKS\.md$/i.test(n));
92
+ if (!tasksFile)
93
+ return { phase: phaseId, file: null, sections: [], done: 0, total: 0 };
94
+ const src = read(path.join(dir, tasksFile)) ?? "";
95
+ const sections = [];
96
+ let cur = { title: "(top)", line: 1, tasks: [] };
97
+ src.split("\n").forEach((l, i) => {
98
+ const h = l.match(/^(#{2,4})\s+(.+)$/);
99
+ if (h) {
100
+ if (cur.tasks.length)
101
+ sections.push(cur);
102
+ cur = { title: h[2].trim(), line: i + 1, tasks: [] };
103
+ return;
104
+ }
105
+ const t = l.match(/^(\s*)- \[( |x|X)\]\s+(.*)$/);
106
+ if (t)
107
+ cur.tasks.push({ line: i + 1, done: t[2] !== " ", text: t[3].trim(), depth: Math.floor(t[1].length / 2) });
108
+ });
109
+ if (cur.tasks.length)
110
+ sections.push(cur);
111
+ const all = sections.flatMap((s) => s.tasks);
112
+ return { phase: phaseId, file: `phases/${phaseId}/${tasksFile}`, sections, done: all.filter((t) => t.done).length, total: all.length };
113
+ }
114
+ /** A phase with no ticked task is still ONGOING when the board has a non-done Active Stream that references it (P1-… / Phase 1 …). Streams are the coordinator's declaration of work in flight. */
115
+ function promoteByStreams(phases, board) {
116
+ const rows = board;
117
+ return phases.map((ph) => {
118
+ const n = ph.n.replace(/\./g, "\\.");
119
+ const re = new RegExp(`(^|\\b)(P${n}[-\\s.]|Phase\\s*${n}\\b)`, "i");
120
+ const mine = rows.filter((r) => re.test(r.stream ?? r.lane ?? ""));
121
+ const active = mine.filter((r) => !/done|merged|deployed|complete|closed|✅/i.test(r.status ?? r.state ?? ""));
122
+ const state = ph.state === "complete" ? "complete" : active.length > 0 && ph.state !== "ongoing" ? "ongoing" : ph.state;
123
+ return { ...ph, state, streams: mine.length };
124
+ });
125
+ }