@davidbalzan/groundwork-console 0.1.6 → 0.1.8

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 (104) hide show
  1. package/README.md +1 -1
  2. package/bin/groundwork-console.mjs +2 -0
  3. package/dist/answered.js +16 -34
  4. package/dist/gitinfo.js +114 -0
  5. package/dist/kitresolve.js +12 -9
  6. package/dist/mcp.js +41 -2
  7. package/dist/provision.js +94 -4
  8. package/dist/server.js +218 -41
  9. package/dist/skills.js +51 -7
  10. package/package.json +5 -2
  11. package/public/assets/{abnfDiagram-N423BO3Z-CdHjY-Rl.js → abnfDiagram-N423BO3Z-oco1yJr8.js} +1 -1
  12. package/public/assets/{arc-BrWBiZCE.js → arc-D-FYRBZ0.js} +1 -1
  13. package/public/assets/architecture-TIHT7OUA-bBJswTmy.js +1 -0
  14. package/public/assets/{architectureDiagram-T3A2C74G-BlL-ALnB.js → architectureDiagram-T3A2C74G-CIG6iOMV.js} +1 -1
  15. package/public/assets/{blockDiagram-VBNYF7ZC-DZyulKP3.js → blockDiagram-VBNYF7ZC-B6dDKm7d.js} +1 -1
  16. package/public/assets/{c4Diagram-5PPSVZJV-BMMLwghr.js → c4Diagram-5PPSVZJV-Y3UQgn94.js} +1 -1
  17. package/public/assets/channel-BeJg4QSq.js +1 -0
  18. package/public/assets/{chunk-2GRJ4B5K-BzJtMGzP.js → chunk-2GRJ4B5K-BoWCLegZ.js} +1 -1
  19. package/public/assets/{chunk-4I5QYGJK-DwFXVxXp.js → chunk-4I5QYGJK-DZTRMmZG.js} +1 -1
  20. package/public/assets/{chunk-5RXB4S5H-BohnhuTy.js → chunk-5RXB4S5H-Dw1IdYaS.js} +1 -1
  21. package/public/assets/{chunk-6Q2QTUOP-BXxDc17T.js → chunk-6Q2QTUOP-nWUr6zuz.js} +1 -1
  22. package/public/assets/{chunk-7Z6QIM7H-k5qXgkB4.js → chunk-7Z6QIM7H-DxXQZe2t.js} +1 -1
  23. package/public/assets/{chunk-GF5L2VYU-Cl8z_Pcd.js → chunk-GF5L2VYU-DbHTRiGa.js} +1 -1
  24. package/public/assets/{chunk-I66GZJ75-CCxcXl9W.js → chunk-I66GZJ75-C97VIOWu.js} +1 -1
  25. package/public/assets/{chunk-JQJVKLGR-Dah9RJ-p.js → chunk-JQJVKLGR-CeOq7ul_.js} +1 -1
  26. package/public/assets/{chunk-KBJHAD2P-B1kM7HCm.js → chunk-KBJHAD2P-D_xgFhfD.js} +1 -1
  27. package/public/assets/{chunk-NSK5VX7P-Ddw2Pneq.js → chunk-NSK5VX7P-DjNVQuCo.js} +1 -1
  28. package/public/assets/{chunk-QR6OTTB3-SR1VT_gf.js → chunk-QR6OTTB3-CGPHl8Ev.js} +1 -1
  29. package/public/assets/{chunk-RYQCIY6F-CsMzInBn.js → chunk-RYQCIY6F-Ca_NSSUq.js} +1 -1
  30. package/public/assets/{chunk-UBXNYLIW-CysKciL_.js → chunk-UBXNYLIW-BH4KEg7B.js} +1 -1
  31. package/public/assets/{chunk-W5SLKNZC-CueNCRji.js → chunk-W5SLKNZC-CzSVKrQX.js} +1 -1
  32. package/public/assets/{chunk-WRU74C26-CVxduXc3.js → chunk-WRU74C26--qTXNfid.js} +1 -1
  33. package/public/assets/{chunk-XXDRQBXY-DOdFQa-t.js → chunk-XXDRQBXY-0XJhmQM-.js} +1 -1
  34. package/public/assets/classDiagram-JCYQIIEL-B6RcDz49.js +1 -0
  35. package/public/assets/classDiagram-v2-OCEON4UE-B6RcDz49.js +1 -0
  36. package/public/assets/{cose-bilkent-JH36ORCC-B2Z9nj2r.js → cose-bilkent-JH36ORCC-CeMP2vk-.js} +1 -1
  37. package/public/assets/{cynefin-VYW2F7L2-CmV_z2bb.js → cynefin-VYW2F7L2-NtSbHkFO.js} +1 -1
  38. package/public/assets/{cynefinDiagram-MW4NZA55-zeWgiEbQ.js → cynefinDiagram-MW4NZA55-B4uWxM0-.js} +1 -1
  39. package/public/assets/{dagre-VZM6K2ZE-CaMVheN4.js → dagre-VZM6K2ZE-C8wHGLeh.js} +1 -1
  40. package/public/assets/{diagram-7IWD3JNH-BTJeDC0S.js → diagram-7IWD3JNH-DKe15FkE.js} +1 -1
  41. package/public/assets/{diagram-B4RE2ZJO-BUtRYMUD.js → diagram-B4RE2ZJO-B-pgDq0Z.js} +1 -1
  42. package/public/assets/{diagram-LBJQPF4R-DM--EdGk.js → diagram-LBJQPF4R-16bhvJKB.js} +1 -1
  43. package/public/assets/{diagram-Q27KOJAE-Dcp9D1k4.js → diagram-Q27KOJAE-BuzXd81m.js} +1 -1
  44. package/public/assets/{diagram-UB23O5K3-DzLpzKu2.js → diagram-UB23O5K3-CZzMkkP7.js} +1 -1
  45. package/public/assets/{dist-DFkPtM9v.js → dist-DoiwlY9_.js} +1 -1
  46. package/public/assets/{ebnfDiagram-BXEA7PRR-C2lFoa24.js → ebnfDiagram-BXEA7PRR-BGUXNic-.js} +1 -1
  47. package/public/assets/{erDiagram-JOGREHBK-GvedjkFY.js → erDiagram-JOGREHBK--mo3pLi7.js} +1 -1
  48. package/public/assets/eventmodeling-45OFAUF4-Bv0pQfuo.js +1 -0
  49. package/public/assets/flowDiagram-UKHOOZJN-cF6VTIV-.js +1 -0
  50. package/public/assets/{ganttDiagram-PKOTCBZU-NzyabXr_.js → ganttDiagram-PKOTCBZU-DbHv-Q1F.js} +1 -1
  51. package/public/assets/{gitGraph-TEB2WS4Q-CAnBW3hc.js → gitGraph-TEB2WS4Q-CU8msIuE.js} +1 -1
  52. package/public/assets/{gitGraphDiagram-DS77QQ5N-ChFOjBWW.js → gitGraphDiagram-DS77QQ5N-CmVui6qG.js} +1 -1
  53. package/public/assets/index-BZBf_g4s.css +2 -0
  54. package/public/assets/index-CJ73bGme.js +41 -0
  55. package/public/assets/{info-DKCQHKI2-9HGQNL8q.js → info-DKCQHKI2-CcA5kzgP.js} +1 -1
  56. package/public/assets/{infoDiagram-6WML65LV-CSZIpCN6.js → infoDiagram-6WML65LV-Drd9lSSV.js} +1 -1
  57. package/public/assets/{ishikawaDiagram-WSZJBQD7-m5LI_l0g.js → ishikawaDiagram-WSZJBQD7-CcnlbfwE.js} +1 -1
  58. package/public/assets/{journeyDiagram-NVQOT4AX-DlhpOOGP.js → journeyDiagram-NVQOT4AX-CYcWG8V9.js} +1 -1
  59. package/public/assets/{kanban-definition-27J2QSJJ-CpS1zcAJ.js → kanban-definition-27J2QSJJ-Ca5F0ySI.js} +1 -1
  60. package/public/assets/{line-DqdLvBGQ.js → line-BFnVTMoX.js} +1 -1
  61. package/public/assets/{linear-ZFSYfg_z.js → linear-BVUPf0HH.js} +1 -1
  62. package/public/assets/{mermaid-parser.core-B4KSdud0.js → mermaid-parser.core-BXY3JF8d.js} +3 -3
  63. package/public/assets/{mermaid.core-CSBAT3ig.js → mermaid.core-DqjaFy6t.js} +4 -4
  64. package/public/assets/{mindmap-definition-FAOFIHXS-DM39eR7l.js → mindmap-definition-FAOFIHXS-m3-vScki.js} +1 -1
  65. package/public/assets/{packet-7NZHBO7P-CaKShi1f.js → packet-7NZHBO7P-BTm7636C.js} +1 -1
  66. package/public/assets/{pegDiagram-VL7TDLO6-CeupMOTZ.js → pegDiagram-VL7TDLO6-C3kirtKa.js} +1 -1
  67. package/public/assets/{pie-RZYD4A2V-CdyK9ryQ.js → pie-RZYD4A2V-BsF__qFU.js} +1 -1
  68. package/public/assets/{pieDiagram-7S7Q4E2Y-BTsBpWka.js → pieDiagram-7S7Q4E2Y-YEbSx31D.js} +1 -1
  69. package/public/assets/{quadrantDiagram-CIZ2JOQS-UamjAqXX.js → quadrantDiagram-CIZ2JOQS-qY1c3Wr-.js} +1 -1
  70. package/public/assets/{radar-I7S5WNFK-BHPrA655.js → radar-I7S5WNFK-BaQRhfKS.js} +1 -1
  71. package/public/assets/{railroad-3IZDKUUU-BmnKim6H.js → railroad-3IZDKUUU-C4riB9xs.js} +1 -1
  72. package/public/assets/railroad-abnf-AHOZXSZD-CBcG7kXE.js +1 -0
  73. package/public/assets/railroad-ebnf-EBAXGLYW-EqgxHSrX.js +1 -0
  74. package/public/assets/railroad-peg-LSFZ7HO6-C5-qxTK4.js +1 -0
  75. package/public/assets/{railroadDiagram-AXF67PYL-bbNt4a4d.js → railroadDiagram-AXF67PYL-BDXHJJin.js} +1 -1
  76. package/public/assets/{requirementDiagram-LRYGKXZP-BrwBiGll.js → requirementDiagram-LRYGKXZP-5N_4nQH3.js} +1 -1
  77. package/public/assets/{sankeyDiagram-W5VNT64P-BeKu1QRQ.js → sankeyDiagram-W5VNT64P-C0zqsu_g.js} +1 -1
  78. package/public/assets/{sequenceDiagram-SI44F4Z6-DiBZBmdo.js → sequenceDiagram-SI44F4Z6-DonYJ8_h.js} +1 -1
  79. package/public/assets/{src-B2eptEoc.js → src-D5IewQLB.js} +1 -1
  80. package/public/assets/{stateDiagram-OKZ733FA-Tjie08JN.js → stateDiagram-OKZ733FA-CEY8i14f.js} +1 -1
  81. package/public/assets/stateDiagram-v2-UEYNNEHI-C13FXtls.js +1 -0
  82. package/public/assets/{swimlanes-SLNWSIFB-B_W9IMQM.js → swimlanes-SLNWSIFB-BBKzhZ8g.js} +1 -1
  83. package/public/assets/swimlanesDiagram-ULZ7WXOC-D0VKK_TP.js +8 -0
  84. package/public/assets/{timeline-definition-Z64GVDOM-B3CqBSPD.js → timeline-definition-Z64GVDOM-RI7P7P5y.js} +1 -1
  85. package/public/assets/{treeView-QDETBFTQ-BUEh9zyF.js → treeView-QDETBFTQ-Bpw_kGf5.js} +1 -1
  86. package/public/assets/{treemap-6X3UGDF4-D_t1bJ-9.js → treemap-6X3UGDF4-Deq82US6.js} +1 -1
  87. package/public/assets/{vennDiagram-T6HMQDX7-B1_XMyC0.js → vennDiagram-T6HMQDX7-DetRcU6h.js} +1 -1
  88. package/public/assets/{wardley-OPB4EBWU-BlIYjwGG.js → wardley-OPB4EBWU-Iwt8nlFq.js} +1 -1
  89. package/public/assets/{wardleyDiagram-T6FBY63Y-BUJJz-1q.js → wardleyDiagram-T6FBY63Y-iVT5tLSy.js} +1 -1
  90. package/public/assets/{xychartDiagram-ELKLHX3M-CgWS9hey.js → xychartDiagram-ELKLHX3M-B7_cZ8Wg.js} +1 -1
  91. package/public/index.html +2 -2
  92. package/public/assets/architecture-TIHT7OUA-nRAC6cKL.js +0 -1
  93. package/public/assets/channel-CmRhJjbj.js +0 -1
  94. package/public/assets/classDiagram-JCYQIIEL-BhHtVWEo.js +0 -1
  95. package/public/assets/classDiagram-v2-OCEON4UE-BhHtVWEo.js +0 -1
  96. package/public/assets/eventmodeling-45OFAUF4-CVrSQ2LC.js +0 -1
  97. package/public/assets/flowDiagram-UKHOOZJN-BWxvJEZS.js +0 -1
  98. package/public/assets/index-CsJm47wv.css +0 -2
  99. package/public/assets/index-DPLoCbew.js +0 -40
  100. package/public/assets/railroad-abnf-AHOZXSZD-n6VUqVzW.js +0 -1
  101. package/public/assets/railroad-ebnf-EBAXGLYW-Buhu71hT.js +0 -1
  102. package/public/assets/railroad-peg-LSFZ7HO6-DxxRF3eB.js +0 -1
  103. package/public/assets/stateDiagram-v2-UEYNNEHI-Bt5as9x1.js +0 -1
  104. package/public/assets/swimlanesDiagram-ULZ7WXOC-B1GEgwSB.js +0 -8
package/README.md CHANGED
@@ -18,7 +18,7 @@ groundwork-console token # print the token
18
18
  groundwork-console config # print the config path
19
19
  ```
20
20
 
21
- Requires on the host: Node ≥ 22, tmux (pane monitor / spawn), and either the three kit bins above on PATH or a kit checkout at `kitPath` in the config. `doctor` tells you which it found. Console requires: seam `^0.1.0` · agent-coord-mcp `^0.23.0` · groundwork `^0.3.0` · agent-coordination `^0.5.0`.
21
+ Requires on the host: Node ≥ 22, tmux (pane monitor / spawn), and either the three kit bins above on PATH or a kit checkout at `kitPath` in the config. `doctor` tells you which it found. Console requires: seam `^0.1.0` · agent-coord-mcp `^0.24.0` · groundwork `^0.3.0` · agent-coordination `^0.5.0`.
22
22
 
23
23
  ## From another machine
24
24
 
@@ -58,6 +58,8 @@ if (sub === "doctor") {
58
58
  console.log(`groundwork-console ${pkg.version} · config ${CONFIG_FILE} · store ${cfg.coordDir}`);
59
59
  for (const k of ["seam", "mcp", "groundwork", "playbook"]) console.log(` ${r[k].ok === false ? "✗" : r[k].ok ? "✓" : "?"} ${k.padEnd(10)} ${String(r[k].installed ?? "missing").padEnd(8)} needs ${r[k].required}${r.paths[k] ? ` ← ${r.paths[k]}` : ""}`);
60
60
  console.log(` kit checkout: ${r.paths.kitCheckout ?? "none (using installed packages)"}`);
61
+ // Capability probe: connect once to the MCP (as groundwork-console, read-only) and check send_message.inReplyTo — the bus feature the console's answered-decisions logic relies on.
62
+ try { const { BusClient } = await import("../dist/mcp.js"); const cl = new BusClient(cfg.kitPath, cfg.coordDir, "groundwork-console"); await cl.connect(); const ok = cl.supports("send_message", "inReplyTo"); await cl.close(); console.log(` ${ok ? "✓" : "⚠"} mcp capability inReplyTo ${ok ? "present" : "missing — decision replies will not be linked/cleared; upgrade agent-coord-mcp"}`); } catch (e) { console.log(` ? mcp capability probe failed: ${e.message}`); }
61
63
  if (!r.ok) { console.log("\nfix: npm i -g agent-coord-mcp @davidbalzan/groundwork agent-coordination"); process.exit(1); }
62
64
  process.exit(0);
63
65
  }
package/dist/answered.js CHANGED
@@ -1,40 +1,11 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import path from "node:path";
4
- /* Console-owned "answered" set for DAVID_DECISION packets (room posts + DMs), keyed by message id.
5
- Until the bus has inReplyTo (queued P3), this is how the UI stops showing a packet as open once David replied from the console.
6
- Persisted in ~/.groundwork-console/answered.json — never touches ~/agent-coord. */
7
- const FILE = path.join(homedir(), ".groundwork-console", "answered.json");
8
- let cache = null;
9
- export function answered() {
10
- if (cache)
11
- return cache;
12
- try {
13
- cache = existsSync(FILE) ? JSON.parse(readFileSync(FILE, "utf8")) : {};
14
- }
15
- catch {
16
- cache = {};
17
- }
18
- return cache;
19
- }
20
- export function markAnswered(ids, meta) {
21
- const a = answered();
22
- let n = 0;
23
- for (const id of ids)
24
- if (id && !a[id]) {
25
- a[id] = { ts: Date.now(), ...meta };
26
- n++;
27
- }
28
- if (n) {
29
- mkdirSync(path.dirname(FILE), { recursive: true });
30
- writeFileSync(FILE, JSON.stringify(a, null, 2));
31
- }
32
- return n;
33
- }
34
- /* Bus-native answered set (kit ≥ 18ffbf4): any message FROM david carrying inReplyTo:<id> answers <id>. Scanned from ~/agent-coord inbox/*.jsonl + rooms/*.jsonl, cached 10 s. Unioned with the console-owned set above. */
4
+ /* Answered DAVID_DECISION packets bus-native only (kit 18ffbf4): a message FROM david carrying inReplyTo:<id> answers <id>.
5
+ Derived by scanning ~/agent-coord inbox/*.jsonl + rooms/*.jsonl (cached 10 s). The console-owned answered.json store was retired 2026-08-19. */
35
6
  let busCache = null;
36
7
  let coordRoot = null;
37
- export function setCoordDir(dir) { coordRoot = dir; }
8
+ export function setCoordDir(dir) { coordRoot = dir; busCache = null; }
38
9
  function busAnswered() {
39
10
  if (!coordRoot)
40
11
  return new Set();
@@ -64,4 +35,15 @@ function busAnswered() {
64
35
  busCache = { at: Date.now(), ids };
65
36
  return ids;
66
37
  }
67
- export const isAnswered = (id) => typeof id === "string" && (!!answered()[id] || busAnswered().has(id));
38
+ export const isAnswered = (id) => typeof id === "string" && busAnswered().has(id);
39
+ /** Forget the cache (after the console itself sends a reply) so the next read reflects it immediately. */
40
+ export const invalidateAnswered = () => { busCache = null; };
41
+ /** One-time cleanup of the retired console-owned store. Returns true if a file was removed. */
42
+ const LEGACY_FILE = path.join(homedir(), ".groundwork-console", "answered.json");
43
+ export function removeLegacyAnsweredFile() { try {
44
+ if (existsSync(LEGACY_FILE)) {
45
+ unlinkSync(LEGACY_FILE);
46
+ return true;
47
+ }
48
+ }
49
+ catch { /* leave it */ } return false; }
package/dist/gitinfo.js CHANGED
@@ -1,6 +1,8 @@
1
1
  /* Read-only git views for a project's docs: uncommitted hunks (working tree vs HEAD, staged included), last commit touching the file + its hunks, and a docs/ status map. */
2
2
  import { execFile } from "node:child_process";
3
3
  import { promisify } from "node:util";
4
+ import { existsSync } from "node:fs";
5
+ import path from "node:path";
4
6
  const run = promisify(execFile);
5
7
  async function git(cwd, args) { try {
6
8
  const r = await run("git", args, { cwd, maxBuffer: 8 << 20 });
@@ -67,3 +69,115 @@ export async function lastDocsCommit(projectPath) {
67
69
  const files = ((await git(projectPath, ["show", "--format=", "--name-only", short, "--", "docs"])) ?? "").split("\n").filter(Boolean).map((f) => f.replace(/^docs\//, ""));
68
70
  return { short, subject, date, files };
69
71
  }
72
+ const GIT_CACHE_MS = 3_000;
73
+ const STALE_DAYS = 14;
74
+ const GRAPH_MAX = 500;
75
+ const cache = new Map();
76
+ function cached(key, fn) {
77
+ const hit = cache.get(key);
78
+ if (hit && Date.now() - hit.at < GIT_CACHE_MS)
79
+ return hit.value;
80
+ const value = fn();
81
+ cache.set(key, { at: Date.now(), value });
82
+ value.catch(() => cache.delete(key));
83
+ return value;
84
+ }
85
+ const splitRecords = (out) => (out ?? "").split("\n").filter(Boolean).map((l) => l.split("\x1f"));
86
+ export function gitGraph(projectPath, limit = 200) {
87
+ const n = Math.max(1, Math.min(GRAPH_MAX, Math.floor(limit) || 200));
88
+ return cached(`graph:${projectPath}:${n}`, async () => {
89
+ const head = (await git(projectPath, ["rev-parse", "HEAD"]))?.trim() ?? "";
90
+ const rows = splitRecords(await git(projectPath, ["log", "--all", "--date-order", `-n${n}`, "--format=%H%x1f%h%x1f%P%x1f%s%x1f%an%x1f%aI%x1f%D"]));
91
+ const commits = rows.map(([sha, short, parents, subject, author, date, refs]) => ({ sha, short, parents: (parents ?? "").split(" ").filter(Boolean), subject: subject ?? "", author: author ?? "", date: date ?? "", refs: (refs ?? "").split(",").map((r) => r.trim().replace(/^HEAD -> /, "HEAD -> ")).filter(Boolean) }));
92
+ return { head, commits };
93
+ });
94
+ }
95
+ /** `git worktree list --porcelain` → entries; dirty = `git status --porcelain` non-empty (skipped for prunable/missing paths); agent from the fleet sibling dir name `<repo>-<agentId>[-<slice>]`, else board owner whose Branch·Worktree column names the branch/path. */
96
+ export function gitWorktrees(projectPath, hints = {}) {
97
+ return cached(`worktrees:${projectPath}:${(hints.agentIds ?? []).join(",")}`, async () => {
98
+ const out = (await git(projectPath, ["worktree", "list", "--porcelain"])) ?? "";
99
+ const blocks = out.split(/\n\s*\n/).map((b) => b.split("\n").filter(Boolean)).filter((b) => b.length);
100
+ const repoBase = path.basename(projectPath.replace(/\/+$/, ""));
101
+ const agentIds = [...(hints.agentIds ?? [])].sort((a, b) => b.length - a.length); // longest first: "<project>-worker-1" before "<project>"
102
+ const worktrees = await Promise.all(blocks.map(async (b, i) => {
103
+ const get = (k) => b.find((l) => l === k || l.startsWith(k + " "))?.slice(k.length).trim();
104
+ const wt = get("worktree") ?? "";
105
+ const sha = get("HEAD") ?? "";
106
+ const branchRef = get("branch");
107
+ const prunable = get("prunable") !== undefined;
108
+ const detached = b.includes("detached") || !branchRef;
109
+ const branch = branchRef ? branchRef.replace(/^refs\/heads\//, "") : null;
110
+ const dirty = !prunable && wt && existsSync(wt) ? ((await git(wt, ["status", "--porcelain"])) ?? "").trim().length > 0 : false;
111
+ const dir = path.basename(wt);
112
+ let agent;
113
+ if (i > 0 && dir.startsWith(repoBase + "-")) {
114
+ const suffix = dir.slice(repoBase.length + 1);
115
+ agent = agentIds.find((id) => suffix === id || suffix.startsWith(id + "-"));
116
+ }
117
+ if (!agent) {
118
+ const row = hints.boardRows?.find((r) => r.branchWorktree && ((branch && r.branchWorktree.includes(branch)) || r.branchWorktree.includes(dir)));
119
+ if (row?.owner)
120
+ agent = row.owner;
121
+ }
122
+ return { path: wt, branch, sha, short: sha.slice(0, 7), detached, dirty, prunable, ...(agent ? { agent } : {}), isMain: i === 0 };
123
+ }));
124
+ return { worktrees };
125
+ });
126
+ }
127
+ /** gh (optional): open/merged/closed PRs by head branch. Missing/unauthenticated gh → null (envelope `ghAvailable:false`). */
128
+ async function ghPrs(projectPath) {
129
+ try {
130
+ const r = await run("gh", ["pr", "list", "--state", "all", "--limit", "50", "--json", "number,url,state,title,headRefName"], { cwd: projectPath, maxBuffer: 8 << 20, timeout: 15_000 });
131
+ const list = JSON.parse(r.stdout);
132
+ const m = new Map();
133
+ for (const p of list)
134
+ if (!m.has(p.headRefName))
135
+ m.set(p.headRefName, { number: p.number, url: p.url, state: p.state, title: p.title });
136
+ return m;
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ }
142
+ export function gitBranches(projectPath, hints = {}) {
143
+ return cached(`branches:${projectPath}:${(hints.agentIds ?? []).join(",")}`, async () => {
144
+ const defaultBranch = "main";
145
+ const base = `origin/${defaultBranch}`;
146
+ const hasBase = (await git(projectPath, ["rev-parse", "--verify", "-q", `${base}^{commit}`]))?.trim() ? true : false;
147
+ const rows = splitRecords(await git(projectPath, ["for-each-ref", "--sort=-committerdate", "--format=%(refname)%1f%(objectname)%1f%(objectname:short)%1f%(subject)%1f%(committerdate:iso-strict)", "refs/heads", "refs/remotes"]))
148
+ .filter(([ref]) => !/^refs\/remotes\/[^/]+\/HEAD$/.test(ref));
149
+ const [{ worktrees }, prs] = await Promise.all([gitWorktrees(projectPath, hints), ghPrs(projectPath)]);
150
+ const qaOf = (n) => { const row = hints.openPrs?.find((r) => Object.values(r).some((v) => new RegExp(`(^|[^0-9])#${n}([^0-9]|$)`).test(v))); if (!row)
151
+ return undefined; const qa = row.mergeGate ?? row.status ?? row.state; return qa ? String(qa) : undefined; };
152
+ const now = Date.now();
153
+ const branches = await Promise.all(rows.map(async ([ref, sha, short, subject, date]) => {
154
+ const remote = ref.startsWith("refs/remotes/");
155
+ const name = ref.replace(/^refs\/(heads|remotes)\//, "");
156
+ let ahead = null, behind = null, merged = false;
157
+ if (hasBase) {
158
+ const lr = (await git(projectPath, ["rev-list", "--left-right", "--count", `${base}...${ref}`]))?.trim().split(/\s+/);
159
+ if (lr && lr.length === 2) {
160
+ behind = +lr[0];
161
+ ahead = +lr[1];
162
+ }
163
+ merged = (await gitOk(projectPath, ["merge-base", "--is-ancestor", ref, base]));
164
+ }
165
+ const stale = date ? now - Date.parse(date) > STALE_DAYS * 86_400_000 : false;
166
+ const localName = remote ? name.replace(/^[^/]+\//, "") : name;
167
+ const wt = remote ? undefined : worktrees.find((w) => w.branch === name);
168
+ const boardRow = hints.boardRows?.find((r) => r.branchWorktree && r.branchWorktree.includes(localName));
169
+ const owner = boardRow?.owner || wt?.agent;
170
+ const pr = prs?.get(localName);
171
+ const prOut = pr ? { ...pr, ...(qaOf(pr.number) ? { qa: qaOf(pr.number) } : {}) } : undefined;
172
+ return { name, remote, sha, short, subject: subject ?? "", date: date ?? "", ahead, behind, merged, stale, ...(wt ? { worktree: wt.path } : {}), ...(owner ? { owner } : {}), ...(prOut ? { pr: prOut } : {}) };
173
+ }));
174
+ return { defaultBranch, base, baseFound: hasBase, ghAvailable: prs !== null, branches };
175
+ });
176
+ }
177
+ async function gitOk(cwd, args) { try {
178
+ await run("git", args, { cwd });
179
+ return true;
180
+ }
181
+ catch {
182
+ return false;
183
+ } }
@@ -2,7 +2,9 @@ import { execFileSync } from "node:child_process";
2
2
  import { existsSync, readFileSync, realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
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" };
5
+ // mcp ^0.24.0 ships send_message.inReplyTo (npm 2026-08-19) — that is the floor. send_command's allowlist (/reload-skills since 0.25.0) is NOT a floor:
6
+ // it is feature-detected per connection (BusClient.sendCommands → /health.requires.mcp.capabilities.sendCommand) and the console degrades gracefully without it.
7
+ export const REQUIRES = { seam: "^0.1.0", mcp: "^0.24.0", groundwork: "^0.3.0", playbook: "^0.5.0" };
6
8
  const which = (bin) => { try {
7
9
  return execFileSync("which", [bin], { encoding: "utf8" }).trim() || null;
8
10
  }
@@ -56,25 +58,26 @@ export function installedVersions(kitPath) {
56
58
  const gw = groundworkCli(kitPath), pb = playbookCli(kitPath), mcp = mcpServer(kitPath);
57
59
  return { seam, groundwork: pkgVersionNear(gw), playbook: pkgVersionNear(pb), mcp: pkgVersionNear(mcp), paths: { groundwork: gw, playbook: pb, mcp, kitCheckout: hasKitCheckout(kitPath) ? kitPath : null } };
58
60
  }
59
- /** Minimal caret-range check (^a.b.c): same major (or same minor when major is 0), and >= min. */
61
+ /** Floor check: the console's requirement is a MINIMUM within the same major (kit packages bump minors often; 0.25.1 must satisfy "^0.24.0").
62
+ Written as ^a.b.c for familiarity, evaluated as >= a.b.c and < (a+1).0.0 — NOT strict caret semantics for 0.x. */
60
63
  export function satisfies(v, range) {
61
64
  if (!v)
62
65
  return null;
63
66
  const m = range.match(/^\^(\d+)\.(\d+)\.(\d+)$/);
64
67
  if (!m)
65
68
  return null;
66
- const [a, b, c] = v.split(".").map(Number);
69
+ const [a, b, c] = v.split(".").map((n) => parseInt(n, 10));
67
70
  const [x, y, z] = m.slice(1).map(Number);
71
+ if (![a, b, c].every(Number.isFinite))
72
+ return null;
68
73
  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);
74
+ return false; // different major: not satisfied (breaking either way)
75
+ return b > y || (b === y && c >= z);
73
76
  }
74
- export function requiresReport(kitPath) {
77
+ export function requiresReport(kitPath, caps) {
75
78
  const inst = installedVersions(kitPath);
76
79
  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") };
80
+ const r = { seam: check("seam"), mcp: { ...check("mcp"), capabilities: caps ?? { inReplyTo: null, sendCommand: null }, warnings: caps?.inReplyTo === false ? ["inReplyTo missing — decision replies will not be linked/cleared; upgrade agent-coord-mcp"] : [] }, groundwork: check("groundwork"), playbook: check("playbook") };
78
81
  return { ...r, paths: inst.paths, ok: Object.values(r).every((x) => x.ok !== false) };
79
82
  }
80
83
  /** Package root dir for a kit package: the checkout's packages/<dir> if present, else the installed npm package (found from its bin). Null when neither exists. */
package/dist/mcp.js CHANGED
@@ -14,7 +14,7 @@ const IDENTITIES = {
14
14
  };
15
15
  export const AGENT_ID = "groundwork-console";
16
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"]);
17
+ export const WRITE_TOOLS_GATED = new Set(["send_message", "send_command", "post_status", "prune", "force_unregister", "unregister", "join_room", "leave_room", "delete_room", "set_room_topic", "set_room_motd", "import_work", "export_work"]);
18
18
  const SWITCH_FILE = path.join(homedir(), ".groundwork-console", "switches.json");
19
19
  function loadSwitches() {
20
20
  let saved = {};
@@ -74,12 +74,17 @@ export class BusClient {
74
74
  const reg = await client.callTool({ name: "register", arguments: { agentId: id.agentId, project: id.project, role: id.role, force: true } });
75
75
  log?.(`register → ${JSON.stringify(reg.structuredContent ?? textOf(reg)).slice(0, 160)}`);
76
76
  // Feature detection: which args each tool accepts (e.g. send_message.inReplyTo landed in kit 18ffbf4; the live bin may predate it).
77
+ let sendCommandDesc = "";
77
78
  try {
78
79
  const tools = await client.listTools();
79
- for (const t of tools.tools)
80
+ for (const t of tools.tools) {
80
81
  this.toolArgs.set(t.name, new Set(Object.keys(t.inputSchema?.properties ?? {})));
82
+ if (t.name === "send_command")
83
+ sendCommandDesc = t.description ?? "";
84
+ }
81
85
  }
82
86
  catch { /* older server: no detection, pass nothing extra */ }
87
+ this.commands = await detectCommands(client, sendCommandDesc, log);
83
88
  this.client = client;
84
89
  return client;
85
90
  })();
@@ -91,8 +96,14 @@ export class BusClient {
91
96
  }
92
97
  }
93
98
  toolArgs = new Map();
99
+ commands = null;
100
+ /** send_command allowlist detected on the connected server (e.g. ["/clear","/compact"] on 0.24.0, +"/reload-skills" on 0.25.0); null = not connected / undetectable. */
101
+ get sendCommands() { return this.client ? this.commands : null; }
102
+ /** True when the connected server accepts this raw control command. */
103
+ acceptsCommand(cmd) { return this.sendCommands?.includes(cmd) ?? false; }
94
104
  /** True when the connected server's tool schema lists this argument (false when unknown or not connected yet). */
95
105
  supports(tool, arg) { return this.toolArgs.get(tool)?.has(arg) ?? false; }
106
+ get connected() { return !!this.client; }
96
107
  async call(name, args, log) {
97
108
  const isWrite = WRITE_TOOLS_GATED.has(name);
98
109
  if (!READ_ONLY_TOOLS.has(name) && !isWrite)
@@ -108,6 +119,34 @@ export class BusClient {
108
119
  }
109
120
  async close() { await this.client?.close(); this.client = null; }
110
121
  }
122
+ /* send_command allowlist detection. The MCP does not expose the allowlist in the tool schema (command is a plain string), so:
123
+ 1. probe once per connection with a command that can never be valid — the server rejects it BEFORE looking at to/room or touching any pane
124
+ (transport.ts sendCommandTool: normalizeControlCommand → `unsupported command '…'. Allowed: /clear, /compact[, /reload-skills]`), so this is
125
+ side-effect-free and needs no bus-writes gate; 2. fall back to the tool description ("allowlist is locked to /clear, /compact and /reload-skills"). */
126
+ const PROBE_COMMAND = "/__groundwork-console-probe__";
127
+ async function detectCommands(client, description, log) {
128
+ const fromText = (t) => { const m = t.match(/Allowed:\s*([^\n]+)/i) ?? t.match(/locked to ([^.;\n]+)/i); return m ? [...m[1].matchAll(/\/[a-z][\w-]*/g)].map((x) => x[0]) : []; };
129
+ try {
130
+ const res = await client.callTool({ name: "send_command", arguments: { from: "groundwork-console", command: PROBE_COMMAND } });
131
+ const out = (res.structuredContent ?? parseText(textOf(res)));
132
+ const list = fromText(typeof out === "string" ? out : out?.error ?? textOf(res));
133
+ if (list.length) {
134
+ log?.(`send_command allowlist (probe): ${list.join(" ")}`);
135
+ return list;
136
+ }
137
+ }
138
+ catch (e) {
139
+ const list = fromText(e.message ?? "");
140
+ if (list.length)
141
+ return list;
142
+ }
143
+ const list = fromText(description);
144
+ if (list.length) {
145
+ log?.(`send_command allowlist (description): ${list.join(" ")}`);
146
+ return list;
147
+ }
148
+ return null;
149
+ }
111
150
  function textOf(r) { return (r.content ?? []).map((c) => c.text ?? "").join("\n"); }
112
151
  function parseText(t) { try {
113
152
  return JSON.parse(t);
package/dist/provision.js CHANGED
@@ -25,6 +25,30 @@ async function fleetWindow(project, startDir, log) {
25
25
  log(`created window ${win} in session ${session} (first pane ${pane} reused for the first agent — no spare shell)`);
26
26
  return { target: `${session}:${win}`, freshPane: pane };
27
27
  }
28
+ const PROMPT_GLYPH = { claude: /^\s*[❯>]\s?/, grok: /^\s*[❯>›]\s?/, codex: /^\s*[❯>›]\s?/, opencode: /^\s*[❯>›]\s?/ };
29
+ /** Was `line` submitted? Decided from the INPUT BOX only — the last prompt-glyph line framed by a rule above it (Claude Code always frames the box; other harnesses: within the last 4 lines) —
30
+ never from transcript echoes ("❯ <submitted text>" followed by output). Stuck ⇔ the input still holds the typed text, or a slash popup is open under it. Pure; unit-tested in scripts/test-inject.mjs. */
31
+ export function inputSubmitted(captureText, harness, line) {
32
+ const lines = captureText.split("\n").map((l) => l.replace(/\u00a0/g, " ").replace(/\s+$/, ""));
33
+ const isRule = (l) => !!l && /^\s*[─━-]{10,}\s*$/.test(l);
34
+ const needle = line.slice(0, Math.min(line.length, 40));
35
+ let idx = -1;
36
+ for (let i = lines.length - 1; i >= 0; i--) {
37
+ if (!PROMPT_GLYPH[harness].test(lines[i]))
38
+ continue;
39
+ if (isRule(lines[i - 1]) || (harness !== "claude" && i >= lines.length - 4)) {
40
+ idx = i;
41
+ break;
42
+ }
43
+ else
44
+ break;
45
+ } // Claude Code always frames its box with a rule; an unframed ❯ line is a transcript echo
46
+ if (idx < 0)
47
+ return true; // no input box visible → nothing is sitting in the input
48
+ const inputBlock = lines.slice(idx, Math.min(lines.length, idx + 3)).filter((l) => !isRule(l)).join(" "); // the box can wrap
49
+ const popup = /\/[a-z][\w-]*\s{2,}\S/.test(lines.slice(idx + 1, idx + 7).join(" ")); // slash popup rows: "/coord-worker Act as…"
50
+ return !inputBlock.includes(needle) && !popup;
51
+ }
28
52
  export async function spawnAgent(req, isRegistered, log, isAttached = () => true, roleOf = () => null) {
29
53
  if (!/^[a-z0-9][a-z0-9-_]*$/i.test(req.agentId))
30
54
  throw new Error("agentId: letters, digits, - _");
@@ -44,14 +68,78 @@ export async function spawnAgent(req, isRegistered, log, isAttached = () => true
44
68
  await tmux("send-keys", "-t", paneId, "C-m");
45
69
  log(`launched: ${cmd}`);
46
70
  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); };
71
+ const capture = async () => { try {
72
+ return await tmux("capture-pane", "-p", "-t", paneId, "-S", "-40");
73
+ }
74
+ catch {
75
+ return "";
76
+ } };
77
+ const paneAlive = async () => { try {
78
+ const out = await tmux("list-panes", "-t", target, "-F", "#{pane_id}\t#{pane_dead}");
79
+ return out.split("\n").some((l) => l.startsWith(paneId + "\t0"));
80
+ }
81
+ catch {
82
+ return false;
83
+ } };
84
+ // (1) Ready-prompt wait: poll the pane until the harness input prompt is visible (bounded by waitSeconds). Claude Code: "? for shortcuts" / the "›" input box; others: a prompt glyph on the last lines.
85
+ const READY = { claude: /\? for shortcuts|bypass permissions|esc to interrupt|Welcome to Claude Code|Claude Code v\d/i, grok: /grok|›/i, codex: /codex|›/i, opencode: /opencode|›/i }; // never the bare shell prompt: an early inject would type into zsh
86
+ const t0 = Date.now();
87
+ let ready = false;
88
+ while (Date.now() - t0 < wait * 1000) {
89
+ if (!(await paneAlive()))
90
+ break;
91
+ if (READY[req.harness].test(await capture())) {
92
+ ready = true;
93
+ break;
94
+ }
95
+ await sleep(700);
96
+ }
97
+ log(ready ? `harness prompt visible after ${((Date.now() - t0) / 1000).toFixed(1)}s` : `⚠ no harness prompt seen within ${wait}s — injecting anyway`);
98
+ if (!(await paneAlive()))
99
+ throw new Error(`pane ${paneId} died before the seed (harness failed to start?) — check the window ${target}`);
100
+ // (2) Verified injection: type the line, settle, C-m, then confirm the typed text left the input; on a stuck slash popup (Claude Code eats the first C-m while the command menu is open) → Escape + C-m, up to 3 tries.
101
+ // "Submitted" is decided from the INPUT LINE only (the line starting with the harness prompt glyph), never from transcript echoes: Claude Code echoes the
102
+ // submitted command in the transcript, and an Escape sent on a false "stuck" would interrupt the running turn (skill boot / registration).
103
+ const submitted = async (line) => inputSubmitted(await capture(), req.harness, line);
104
+ const inject = async (line) => {
105
+ for (let attempt = 1; attempt <= 3; attempt++) {
106
+ if (!(await paneAlive()))
107
+ throw new Error(`✗ inject failed: pane died — "${line}"`);
108
+ if (attempt === 1) {
109
+ await tmux("send-keys", "-t", paneId, "-l", line);
110
+ await sleep(450);
111
+ }
112
+ else {
113
+ await tmux("send-keys", "-t", paneId, "Escape");
114
+ await sleep(250);
115
+ }
116
+ await tmux("send-keys", "-t", paneId, "C-m");
117
+ const d = Date.now() + 5_000;
118
+ let ok = false;
119
+ while (Date.now() < d) {
120
+ await sleep(400);
121
+ if (await submitted(line)) {
122
+ ok = true;
123
+ break;
124
+ }
125
+ }
126
+ if (ok) {
127
+ log(`injected${attempt > 1 ? ` (attempt ${attempt})` : ""}: ${line}`);
128
+ await sleep(500);
129
+ return;
130
+ }
131
+ log(`… "${line.slice(0, 50)}" still in the input after C-m (attempt ${attempt}) — ${attempt < 3 ? "Escape + retry" : "giving up"}`);
132
+ }
133
+ throw new Error(`✗ inject failed after 3 tries: "${line}" — the harness did not accept the line; seed aborted (registration wait skipped)`);
134
+ };
50
135
  if (req.model && CLAUDE_HARNESS_ONLY.has(req.harness))
51
136
  await inject(`/model ${req.model}`);
52
137
  if (CLAUDE_HARNESS_ONLY.has(req.harness))
53
138
  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}` : ""}`;
139
+ // Skill line: the common coord-* signature (kit b84e14c): --id --role --project --room [--rooms] --repo [--lane]. Cards accept it once the machine's playbook is updated from the kit; older cards ignore unknown flags.
140
+ const roleId = `coord-${req.role === "coordinator" ? "coordinator" : req.role}`;
141
+ const skill = `${ROLE_SKILL[req.role]} --id=${req.agentId} --role=${roleId} --project=${req.project} --room=${req.room ?? req.project} --repo=${req.repoPath}${req.lane ? ` --lane=${req.lane}` : ""}`;
142
+ log(`seed (common coord-* signature, kit b84e14c): ${skill}`);
55
143
  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
144
  if (req.note && CLAUDE_HARNESS_ONLY.has(req.harness))
57
145
  await inject(req.note);
@@ -63,6 +151,8 @@ export async function spawnAgent(req, isRegistered, log, isAttached = () => true
63
151
  registered = true;
64
152
  break;
65
153
  }
154
+ if (!(await paneAlive()))
155
+ throw new Error(`✗ pane ${paneId} died while waiting for ${req.agentId} to register — seed aborted`);
66
156
  await sleep(2_000);
67
157
  }
68
158
  log(registered ? `✓ ${req.agentId} registered on the bus` : `⚠ ${req.agentId} not registered after 90s — check the pane`);