@davidbalzan/groundwork-console 0.1.6 → 0.1.7

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 (101) hide show
  1. package/bin/groundwork-console.mjs +2 -0
  2. package/dist/answered.js +16 -34
  3. package/dist/kitresolve.js +3 -2
  4. package/dist/mcp.js +2 -1
  5. package/dist/provision.js +94 -4
  6. package/dist/server.js +106 -40
  7. package/dist/skills.js +51 -7
  8. package/package.json +5 -2
  9. package/public/assets/{abnfDiagram-N423BO3Z-CdHjY-Rl.js → abnfDiagram-N423BO3Z-B0nns4cA.js} +1 -1
  10. package/public/assets/{arc-BrWBiZCE.js → arc-DsDgWIU7.js} +1 -1
  11. package/public/assets/architecture-TIHT7OUA-okwBKksv.js +1 -0
  12. package/public/assets/{architectureDiagram-T3A2C74G-BlL-ALnB.js → architectureDiagram-T3A2C74G-Bqo7zIaB.js} +1 -1
  13. package/public/assets/{blockDiagram-VBNYF7ZC-DZyulKP3.js → blockDiagram-VBNYF7ZC-CvNPqlbt.js} +1 -1
  14. package/public/assets/{c4Diagram-5PPSVZJV-BMMLwghr.js → c4Diagram-5PPSVZJV-BqjkNgv6.js} +1 -1
  15. package/public/assets/channel-CI1-SNMv.js +1 -0
  16. package/public/assets/{chunk-2GRJ4B5K-BzJtMGzP.js → chunk-2GRJ4B5K-CWJJzbol.js} +1 -1
  17. package/public/assets/{chunk-4I5QYGJK-DwFXVxXp.js → chunk-4I5QYGJK-CuHGN-lT.js} +1 -1
  18. package/public/assets/{chunk-5RXB4S5H-BohnhuTy.js → chunk-5RXB4S5H-Cl92nmIs.js} +1 -1
  19. package/public/assets/{chunk-6Q2QTUOP-BXxDc17T.js → chunk-6Q2QTUOP-C3vYg7rX.js} +1 -1
  20. package/public/assets/{chunk-7Z6QIM7H-k5qXgkB4.js → chunk-7Z6QIM7H-jkpbvAhi.js} +1 -1
  21. package/public/assets/{chunk-GF5L2VYU-Cl8z_Pcd.js → chunk-GF5L2VYU-Bo-zB54d.js} +1 -1
  22. package/public/assets/{chunk-I66GZJ75-CCxcXl9W.js → chunk-I66GZJ75-BD9h95Vq.js} +1 -1
  23. package/public/assets/{chunk-JQJVKLGR-Dah9RJ-p.js → chunk-JQJVKLGR-C6TRkyf7.js} +1 -1
  24. package/public/assets/{chunk-KBJHAD2P-B1kM7HCm.js → chunk-KBJHAD2P-RgoL4Nto.js} +1 -1
  25. package/public/assets/{chunk-NSK5VX7P-Ddw2Pneq.js → chunk-NSK5VX7P-C6TsGnGA.js} +1 -1
  26. package/public/assets/{chunk-QR6OTTB3-SR1VT_gf.js → chunk-QR6OTTB3-Bq5akaxk.js} +1 -1
  27. package/public/assets/{chunk-RYQCIY6F-CsMzInBn.js → chunk-RYQCIY6F-DtVCesJ4.js} +1 -1
  28. package/public/assets/{chunk-UBXNYLIW-CysKciL_.js → chunk-UBXNYLIW-BjnaZZH0.js} +1 -1
  29. package/public/assets/{chunk-W5SLKNZC-CueNCRji.js → chunk-W5SLKNZC-D1KW-lQn.js} +1 -1
  30. package/public/assets/{chunk-WRU74C26-CVxduXc3.js → chunk-WRU74C26-YZFoWy6X.js} +1 -1
  31. package/public/assets/{chunk-XXDRQBXY-DOdFQa-t.js → chunk-XXDRQBXY-CyrI_Mox.js} +1 -1
  32. package/public/assets/classDiagram-JCYQIIEL-zdEoGtpz.js +1 -0
  33. package/public/assets/classDiagram-v2-OCEON4UE-zdEoGtpz.js +1 -0
  34. package/public/assets/{cose-bilkent-JH36ORCC-B2Z9nj2r.js → cose-bilkent-JH36ORCC-v6lDOu7m.js} +1 -1
  35. package/public/assets/{cynefin-VYW2F7L2-CmV_z2bb.js → cynefin-VYW2F7L2-BtzRQs_J.js} +1 -1
  36. package/public/assets/{cynefinDiagram-MW4NZA55-zeWgiEbQ.js → cynefinDiagram-MW4NZA55--R8S0KoS.js} +1 -1
  37. package/public/assets/{dagre-VZM6K2ZE-CaMVheN4.js → dagre-VZM6K2ZE-D6mRFOfB.js} +1 -1
  38. package/public/assets/{diagram-7IWD3JNH-BTJeDC0S.js → diagram-7IWD3JNH-kfCoRMpW.js} +1 -1
  39. package/public/assets/{diagram-B4RE2ZJO-BUtRYMUD.js → diagram-B4RE2ZJO-sxUW6rzT.js} +1 -1
  40. package/public/assets/{diagram-LBJQPF4R-DM--EdGk.js → diagram-LBJQPF4R-B926bgCA.js} +1 -1
  41. package/public/assets/{diagram-Q27KOJAE-Dcp9D1k4.js → diagram-Q27KOJAE-CcVi6njJ.js} +1 -1
  42. package/public/assets/{diagram-UB23O5K3-DzLpzKu2.js → diagram-UB23O5K3-BtW4XOuK.js} +1 -1
  43. package/public/assets/{dist-DFkPtM9v.js → dist-BnkmMC7M.js} +1 -1
  44. package/public/assets/{ebnfDiagram-BXEA7PRR-C2lFoa24.js → ebnfDiagram-BXEA7PRR-BNGfWHYj.js} +1 -1
  45. package/public/assets/{erDiagram-JOGREHBK-GvedjkFY.js → erDiagram-JOGREHBK-BGrhe741.js} +1 -1
  46. package/public/assets/eventmodeling-45OFAUF4-Cn6RDMEG.js +1 -0
  47. package/public/assets/flowDiagram-UKHOOZJN-DOMsEKL1.js +1 -0
  48. package/public/assets/{ganttDiagram-PKOTCBZU-NzyabXr_.js → ganttDiagram-PKOTCBZU-CR5BNdXR.js} +1 -1
  49. package/public/assets/{gitGraph-TEB2WS4Q-CAnBW3hc.js → gitGraph-TEB2WS4Q-CfvC-iZD.js} +1 -1
  50. package/public/assets/{gitGraphDiagram-DS77QQ5N-ChFOjBWW.js → gitGraphDiagram-DS77QQ5N-D45EWdll.js} +1 -1
  51. package/public/assets/{index-CsJm47wv.css → index-CxNe802C.css} +1 -1
  52. package/public/assets/index-yHI4kvM-.js +41 -0
  53. package/public/assets/{info-DKCQHKI2-9HGQNL8q.js → info-DKCQHKI2-stahmZKW.js} +1 -1
  54. package/public/assets/{infoDiagram-6WML65LV-CSZIpCN6.js → infoDiagram-6WML65LV-nWu8ujif.js} +1 -1
  55. package/public/assets/{ishikawaDiagram-WSZJBQD7-m5LI_l0g.js → ishikawaDiagram-WSZJBQD7-Ccwzv-xZ.js} +1 -1
  56. package/public/assets/{journeyDiagram-NVQOT4AX-DlhpOOGP.js → journeyDiagram-NVQOT4AX-R7gZw1Qw.js} +1 -1
  57. package/public/assets/{kanban-definition-27J2QSJJ-CpS1zcAJ.js → kanban-definition-27J2QSJJ-By8aTzkI.js} +1 -1
  58. package/public/assets/{line-DqdLvBGQ.js → line-zJXbSVxf.js} +1 -1
  59. package/public/assets/{linear-ZFSYfg_z.js → linear-B8U-jWnO.js} +1 -1
  60. package/public/assets/{mermaid-parser.core-B4KSdud0.js → mermaid-parser.core-B1iqIz20.js} +3 -3
  61. package/public/assets/{mermaid.core-CSBAT3ig.js → mermaid.core-CuFK5CuK.js} +4 -4
  62. package/public/assets/{mindmap-definition-FAOFIHXS-DM39eR7l.js → mindmap-definition-FAOFIHXS-qJ2DuBku.js} +1 -1
  63. package/public/assets/{packet-7NZHBO7P-CaKShi1f.js → packet-7NZHBO7P-CzQJdD0e.js} +1 -1
  64. package/public/assets/{pegDiagram-VL7TDLO6-CeupMOTZ.js → pegDiagram-VL7TDLO6-CKmHw89b.js} +1 -1
  65. package/public/assets/{pie-RZYD4A2V-CdyK9ryQ.js → pie-RZYD4A2V-Dt3CA16n.js} +1 -1
  66. package/public/assets/{pieDiagram-7S7Q4E2Y-BTsBpWka.js → pieDiagram-7S7Q4E2Y-D9vwefN9.js} +1 -1
  67. package/public/assets/{quadrantDiagram-CIZ2JOQS-UamjAqXX.js → quadrantDiagram-CIZ2JOQS-jtSAmOcp.js} +1 -1
  68. package/public/assets/{radar-I7S5WNFK-BHPrA655.js → radar-I7S5WNFK-BtDfUAqO.js} +1 -1
  69. package/public/assets/{railroad-3IZDKUUU-BmnKim6H.js → railroad-3IZDKUUU-D5DNw8qB.js} +1 -1
  70. package/public/assets/railroad-abnf-AHOZXSZD-DuJUfLMt.js +1 -0
  71. package/public/assets/railroad-ebnf-EBAXGLYW-CiLIN42p.js +1 -0
  72. package/public/assets/railroad-peg-LSFZ7HO6-DKtIAsvs.js +1 -0
  73. package/public/assets/{railroadDiagram-AXF67PYL-bbNt4a4d.js → railroadDiagram-AXF67PYL-C-S9VT4Z.js} +1 -1
  74. package/public/assets/{requirementDiagram-LRYGKXZP-BrwBiGll.js → requirementDiagram-LRYGKXZP-Bmv6EbmT.js} +1 -1
  75. package/public/assets/{sankeyDiagram-W5VNT64P-BeKu1QRQ.js → sankeyDiagram-W5VNT64P-Cm2dLWH9.js} +1 -1
  76. package/public/assets/{sequenceDiagram-SI44F4Z6-DiBZBmdo.js → sequenceDiagram-SI44F4Z6-DPslkAXf.js} +1 -1
  77. package/public/assets/{src-B2eptEoc.js → src-l8EnJwXz.js} +1 -1
  78. package/public/assets/{stateDiagram-OKZ733FA-Tjie08JN.js → stateDiagram-OKZ733FA-BBXMNQQ7.js} +1 -1
  79. package/public/assets/stateDiagram-v2-UEYNNEHI-BxOobbWc.js +1 -0
  80. package/public/assets/{swimlanes-SLNWSIFB-B_W9IMQM.js → swimlanes-SLNWSIFB-BeiwtQg-.js} +1 -1
  81. package/public/assets/swimlanesDiagram-ULZ7WXOC-BEW9yZ9H.js +8 -0
  82. package/public/assets/{timeline-definition-Z64GVDOM-B3CqBSPD.js → timeline-definition-Z64GVDOM-Drd7Un_g.js} +1 -1
  83. package/public/assets/{treeView-QDETBFTQ-BUEh9zyF.js → treeView-QDETBFTQ-BT2KyIoB.js} +1 -1
  84. package/public/assets/{treemap-6X3UGDF4-D_t1bJ-9.js → treemap-6X3UGDF4-gdZw2F6X.js} +1 -1
  85. package/public/assets/{vennDiagram-T6HMQDX7-B1_XMyC0.js → vennDiagram-T6HMQDX7-Bbk8OMR0.js} +1 -1
  86. package/public/assets/{wardley-OPB4EBWU-BlIYjwGG.js → wardley-OPB4EBWU-BI0xutNp.js} +1 -1
  87. package/public/assets/{wardleyDiagram-T6FBY63Y-BUJJz-1q.js → wardleyDiagram-T6FBY63Y-DQvEcd8r.js} +1 -1
  88. package/public/assets/{xychartDiagram-ELKLHX3M-CgWS9hey.js → xychartDiagram-ELKLHX3M-CRWoUpzf.js} +1 -1
  89. package/public/index.html +2 -2
  90. package/public/assets/architecture-TIHT7OUA-nRAC6cKL.js +0 -1
  91. package/public/assets/channel-CmRhJjbj.js +0 -1
  92. package/public/assets/classDiagram-JCYQIIEL-BhHtVWEo.js +0 -1
  93. package/public/assets/classDiagram-v2-OCEON4UE-BhHtVWEo.js +0 -1
  94. package/public/assets/eventmodeling-45OFAUF4-CVrSQ2LC.js +0 -1
  95. package/public/assets/flowDiagram-UKHOOZJN-BWxvJEZS.js +0 -1
  96. package/public/assets/index-DPLoCbew.js +0 -40
  97. package/public/assets/railroad-abnf-AHOZXSZD-n6VUqVzW.js +0 -1
  98. package/public/assets/railroad-ebnf-EBAXGLYW-Buhu71hT.js +0 -1
  99. package/public/assets/railroad-peg-LSFZ7HO6-DxxRF3eB.js +0 -1
  100. package/public/assets/stateDiagram-v2-UEYNNEHI-Bt5as9x1.js +0 -1
  101. package/public/assets/swimlanesDiagram-ULZ7WXOC-B1GEgwSB.js +0 -8
@@ -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; }
@@ -2,6 +2,7 @@ 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
+ // TODO(kit publish): bump mcp to the version that ships send_message.inReplyTo (kit main 18ffbf4 is still tagged 0.23.0) — until then the capability is detected at runtime (BusClient.supports) and surfaced in /health.requires.mcp.capabilities.
5
6
  export const REQUIRES = { seam: "^0.1.0", mcp: "^0.23.0", groundwork: "^0.3.0", playbook: "^0.5.0" };
6
7
  const which = (bin) => { try {
7
8
  return execFileSync("which", [bin], { encoding: "utf8" }).trim() || null;
@@ -71,10 +72,10 @@ export function satisfies(v, range) {
71
72
  return false;
72
73
  return a > x || b > y || (b === y && c >= z);
73
74
  }
74
- export function requiresReport(kitPath) {
75
+ export function requiresReport(kitPath, caps) {
75
76
  const inst = installedVersions(kitPath);
76
77
  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
+ const r = { seam: check("seam"), mcp: { ...check("mcp"), capabilities: caps ?? { inReplyTo: 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
79
  return { ...r, paths: inst.paths, ok: Object.values(r).every((x) => x.ok !== false) };
79
80
  }
80
81
  /** 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 = {};
@@ -93,6 +93,7 @@ export class BusClient {
93
93
  toolArgs = new Map();
94
94
  /** True when the connected server's tool schema lists this argument (false when unknown or not connected yet). */
95
95
  supports(tool, arg) { return this.toolArgs.get(tool)?.has(arg) ?? false; }
96
+ get connected() { return !!this.client; }
96
97
  async call(name, args, log) {
97
98
  const isWrite = WRITE_TOOLS_GATED.has(name);
98
99
  if (!READ_ONLY_TOOLS.has(name) && !isWrite)
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`);
package/dist/server.js CHANGED
@@ -16,7 +16,7 @@ import { agents, agentsAsync, rooms, roomMessages, roomMessageCount, statusStrea
16
16
  import { capturePane } from "./tmux.js";
17
17
  import { spawnAgent, suggestId, fleetStatus, FLEET_TEMPLATES, HARNESS_CMD, ROLE_SKILL } from "./provision.js";
18
18
  import { doctor, status, list, playbookList } from "./groundwork.js";
19
- import { groundworkSkills, playbookCards } from "./skills.js";
19
+ import { detectedHarnesses, groundworkSkills, playbookCards } from "./skills.js";
20
20
  import { extensionsOf } from "./extensions.js";
21
21
  import { parseWorkDoc, workstreamsExtensionsOf } from "@davidbalzan/groundwork-seam";
22
22
  import { EventEngine } from "./events.js";
@@ -27,10 +27,10 @@ import { BusClient, AGENT_ID, busWrites, seamWrites, saveSwitches, SWITCHES_FILE
27
27
  import { addQueueItem, reverifyFact, scopeAllows } from "./seamwrite.js";
28
28
  import { fileURLToPath } from "node:url";
29
29
  import { saveUpload, uploadPath, purgeUploads, UPLOAD_DIR, UPLOAD_TTL_HOURS } from "./uploads.js";
30
- import { answered, markAnswered, isAnswered, setCoordDir } from "./answered.js";
30
+ import { isAnswered, setCoordDir, invalidateAnswered, removeLegacyAnsweredFile } from "./answered.js";
31
31
  import { requireAuth, login, logout, isAuthed, authEnabled, tokenFile } from "./auth.js";
32
32
  import { knowledgeIndex, knowledgeDoc, knowledgePath } from "./knowledge.js";
33
- import { requiresReport, groundworkCli, playbookCli, packageRoot, REQUIRES } from "./kitresolve.js";
33
+ import { requiresReport, groundworkCli, playbookCli, packageRoot, hasKitCheckout, REQUIRES } from "./kitresolve.js";
34
34
  let cfg = loadConfig();
35
35
  const port = Number(process.env.PORT ?? 8790);
36
36
  const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -38,11 +38,15 @@ const RULES = process.env.GROUNDWORK_CONSOLE_RULES ?? [path.join(process.env.HOM
38
38
  const PKG = JSON.parse(readFileSync(path.resolve(HERE, "../package.json"), "utf8"));
39
39
  const runs = new Runs();
40
40
  setCoordDir(cfg.coordDir);
41
+ if (removeLegacyAnsweredFile())
42
+ void runs.exec({ kind: "cli", label: "retired ~/.groundwork-console/answered.json (answered = bus inReplyTo now)", sideEffects: true }, async (log) => { log("removed the console-owned answered store; decision packets are answered by replies carrying inReplyTo"); return { removed: true }; });
41
43
  const bus = new BusClient(cfg.kitPath, cfg.coordDir, "groundwork-console", process.env.AGENT_COORD_MCP);
42
44
  const events = new EventEngine(cfg, RULES, runs, (args, log) => bus.call("send_message", args, log));
43
45
  const notifier = new Notifier(cfg, runs, events);
44
46
  const busDavid = new BusClient(cfg.kitPath, cfg.coordDir, "david", process.env.AGENT_COORD_MCP);
45
47
  const clientFor = (id) => (id === "david" ? busDavid : bus);
48
+ /** Capabilities of the connected MCP (null until a client has connected and listed tools). */
49
+ const mcpCaps = () => ({ inReplyTo: bus.connected ? bus.supports("send_message", "inReplyTo") : busDavid.connected ? busDavid.supports("send_message", "inReplyTo") : null });
46
50
  const PUBLIC_DIR = path.resolve(HERE, "../public");
47
51
  const root = new Hono();
48
52
  const app = new Hono().basePath("/api");
@@ -57,7 +61,7 @@ app.post("/auth/logout", (c) => { logout(c); return c.json({ ok: true }); });
57
61
  app.get("/auth/login", (c) => { const t = c.req.query("token") ?? ""; if (!login(c, t))
58
62
  return c.json({ error: "wrong token" }, 401); return c.redirect(c.req.query("next") ?? "/"); });
59
63
  const project = (name) => cfg.projects.find((p) => p.name === name);
60
- app.get("/health", (c) => c.json({ ok: true, readOnly: !busWrites.enabled, busWrites: busWrites.enabled, version: PKG.version, requires: requiresReport(cfg.kitPath), config: CONFIG_FILE, hostname: osHostname().replace(/\.local$/, "") }));
64
+ app.get("/health", (c) => c.json({ ok: true, readOnly: !busWrites.enabled, busWrites: busWrites.enabled, version: PKG.version, requires: requiresReport(cfg.kitPath, mcpCaps()), config: CONFIG_FILE, hostname: osHostname().replace(/\.local$/, "") }));
61
65
  app.get("/admin/bus-writes", (c) => c.json(busWrites));
62
66
  app.get("/admin/seam-writes", (c) => c.json(seamWrites));
63
67
  app.post("/admin/seam-writes", async (c) => {
@@ -109,6 +113,23 @@ app.post("/actions/send", async (c) => {
109
113
  return c.json({ error: "text required" }, 400);
110
114
  if (!b.room && !b.to)
111
115
  return c.json({ error: "room or to required" }, 400);
116
+ // Raw control commands: exactly `/clear` or `/compact` (no args) to ONE agent go through MCP send_command — the only path that types a slash command
117
+ // into a live CLI (allowlist locked to these two in the MCP; widening it — /reload-skills etc. — is a kit decision, not done here). Everything else is a text DM.
118
+ const RAW_COMMANDS = new Set(["/clear", "/compact"]);
119
+ const cmd = b.text.trim();
120
+ if (RAW_COMMANDS.has(cmd)) {
121
+ if (!b.to)
122
+ return c.json({ error: `${cmd} is unicast — pick an agent (rooms get text only)` }, 400);
123
+ const r = await runs.exec({ kind: "bus", label: `command ${cmd} → ${b.to}`, sideEffects: true, args: { as: who, to: b.to, command: cmd } }, async (log) => {
124
+ const out = (await clientFor(who).call("send_command", { to: b.to, command: cmd, waitForDelivery: true, deliveryTimeoutMs: 15_000 }, log));
125
+ log(`delivery: ${out.delivery ?? "?"}${out.reason ? ` · ${out.reason}` : ""}${out.note ? ` · ${out.note}` : ""}`);
126
+ if (out.error)
127
+ throw new Error(out.error);
128
+ return out;
129
+ });
130
+ const out = (r.result ?? {});
131
+ return c.json({ ...r, raw: true, delivery: out.delivery ?? null, warning: out.delivery === "pending" ? `pusher has not confirmed typing ${cmd} yet${out.reason ? ` — ${out.reason}` : ""}` : undefined }, r.status === "ok" ? 200 : 403);
132
+ }
112
133
  const PREFIX_RE = /^(BLOCKER|RISK|DONE|FYI|AGENT_ACTION|GO|SCOPE CHANGE|VERDICT|DAVID_DECISION)\b/;
113
134
  if (b.room && !PREFIX_RE.test(b.text))
114
135
  return c.json({ error: "room posts need a prefix (FYI / AGENT_ACTION / DAVID_DECISION / BLOCKER / RISK / DONE / GO / SCOPE CHANGE / VERDICT)" }, 400);
@@ -116,29 +137,30 @@ app.post("/actions/send", async (c) => {
116
137
  const r = await runs.exec({ kind: "bus", label: `send as ${who} ${target}`, sideEffects: true, args: { as: who, room: b.room, to: b.to, kind: b.kind, text: b.text.slice(0, 200) } }, async (log) => {
117
138
  const cl = clientFor(who);
118
139
  await cl.connect(log);
119
- const parent = b.inReplyTo ?? b.answers?.[0];
120
- const withReply = parent && cl.supports("send_message", "inReplyTo") ? { inReplyTo: parent } : {};
121
- if (parent && !("inReplyTo" in withReply))
122
- log("bus predates inReplyTo using the console-side answered set only");
123
- const out = await cl.call("send_message", { ...(b.to ? { to: b.to } : { room: b.room.replace(/^#/, "") }), text: b.text, ...(b.kind ? { kind: b.kind } : {}), ...withReply }, log);
124
- // Answered bookkeeping (console-owned, until the bus has inReplyTo): explicit ids from the UI, plus — for a DM as David — every open DAVID_DECISION DM from that agent.
125
- if (who === "david") {
126
- const ids = [...(b.answers ?? [])];
127
- if (b.to) {
128
- const f = path.join(cfg.coordDir, "inbox", "david.jsonl");
129
- if (existsSync(f))
130
- for (const l of readFileSync(f, "utf8").split("\n").filter(Boolean)) {
131
- try {
132
- const m = JSON.parse(l);
133
- if (m.from === b.to && typeof m.text === "string" && m.text.startsWith("DAVID_DECISION") && m.id)
134
- ids.push(m.id);
135
- }
136
- catch { /* skip */ }
140
+ // Reply linkage = bus inReplyTo only (console-owned answered.json retired 2026-08-19). Parent: explicit from the UI (answers[0] / inReplyTo);
141
+ // for a DM as David with no explicit parent, the latest still-open DAVID_DECISION DM from that agent — so "reply to the agent" clears its packet.
142
+ let parent = b.inReplyTo ?? b.answers?.[0];
143
+ if (!parent && who === "david" && b.to) {
144
+ const f = path.join(cfg.coordDir, "inbox", "david.jsonl");
145
+ if (existsSync(f))
146
+ for (const l of readFileSync(f, "utf8").split("\n").filter(Boolean)) {
147
+ try {
148
+ const m = JSON.parse(l);
149
+ if (m.from === b.to && typeof m.text === "string" && m.text.startsWith("DAVID_DECISION") && m.id && !isAnswered(m.id))
150
+ parent = m.id;
137
151
  }
138
- }
139
- const n = markAnswered(ids, { by: "david", to: b.to ?? b.room, note: b.text.slice(0, 80) });
140
- if (n)
141
- log(`answered: ${n} decision packet${n === 1 ? "" : "s"} marked (console-owned ~/.groundwork-console/answered.json)`);
152
+ catch { /* skip */ }
153
+ }
154
+ if (parent)
155
+ log(`inReplyTo latest open DAVID_DECISION from ${b.to} (${parent})`);
156
+ }
157
+ const supportsReply = cl.supports("send_message", "inReplyTo");
158
+ if (parent && !supportsReply)
159
+ log("⚠ connected agent-coord-mcp has no inReplyTo — reply will NOT be linked to the packet; upgrade agent-coord-mcp (gwc doctor)");
160
+ const out = await cl.call("send_message", { ...(b.to ? { to: b.to } : { room: b.room.replace(/^#/, "") }), text: b.text, ...(b.kind ? { kind: b.kind } : {}), ...(parent && supportsReply ? { inReplyTo: parent } : {}) }, log);
161
+ if (parent && supportsReply) {
162
+ invalidateAnswered();
163
+ log(`linked: inReplyTo ${parent}`);
142
164
  }
143
165
  return out;
144
166
  });
@@ -291,11 +313,11 @@ app.get("/projects/:name/skills", async (c) => {
291
313
  return c.json({ error: "unknown project" }, 404);
292
314
  const [gl, pl] = await Promise.all([list(cfg.kitPath, p.path), playbookList(cfg.kitPath)]);
293
315
  const sync = groundworkSkills(cfg.kitPath, p.path);
294
- const cards = playbookCards(cfg.kitPath);
316
+ const cards = await playbookCards(cfg.kitPath);
295
317
  return c.json({
296
- groundwork: { versionMarker: gl.version || sync.versionMarker, kitVersion: sync.kitVersion, driftedVersion: (gl.version || sync.versionMarker) !== sync.kitVersion, error: gl.error,
318
+ groundwork: { versionMarker: gl.version || sync.versionMarker, kitVersion: sync.kitVersion, source: sync.source ?? null, driftedVersion: (gl.version || sync.versionMarker) !== sync.kitVersion, error: gl.error,
297
319
  skills: gl.skills.map((s) => ({ ...s, tier: s.set, inSync: sync.skills.find((x) => x.name === s.name)?.inSync ?? null })) },
298
- playbook: { kitVersion: pl.version || cards.kitVersion, globalDir: cards.globalDir, homes: cards.homes, legacyDirs: cards.legacyDirs, error: pl.error,
320
+ playbook: { kitVersion: pl.version || cards.kitVersion, source: cards.source ?? null, rewrite: cards.rewrite ?? null, globalDir: cards.globalDir, homes: cards.homes, legacyDirs: cards.legacyDirs, error: pl.error,
299
321
  cards: pl.cards.map((k) => { const x = cards.cards.find((y) => y.name === k.name); const kh = k.harness; return { ...k, inSync: x?.inSync ?? null, slices: x?.slices ?? 0, legacyEssay: x?.legacyEssay ?? false, homes: x?.homes ?? {}, mirrors: { cursor: kh.cursor, vscode: kh.vscode, opencode: kh.opencode, grok: kh.grok ?? x?.homes?.grok?.installed ?? false } }; }) },
300
322
  });
301
323
  });
@@ -485,8 +507,6 @@ app.get("/bus/inbox", async (c) => {
485
507
  });
486
508
  /* DM thread with one agent, both directions: their DMs to david (inbox/david.jsonl) + david's DMs to them (inbox/<agent>.jsonl).
487
509
  Read-only file scan (no cursor consumed) — this is a view, the Bus inbox stays the place that marks read. */
488
- app.get("/bus/answered", (c) => c.json(answered()));
489
- app.post("/bus/answered", async (c) => { const b = (await c.req.json()); const n = markAnswered(b.ids ?? [], { by: "david", note: b.note }); return c.json({ ok: true, marked: n }); });
490
510
  app.get("/bus/dm/:agent", (c) => {
491
511
  const agent = c.req.param("agent");
492
512
  const limit = Number(c.req.query("limit") ?? 80);
@@ -572,14 +592,18 @@ app.post("/actions/playbook-update", async (c) => {
572
592
  const cli = playbookCli(cfg.kitPath);
573
593
  if (!cli)
574
594
  return c.json({ error: "agent-coordination CLI not found — npm i -g agent-coordination (or set kitPath to a kit checkout)" }, 500);
575
- const argv = [cli, "update", ...(dry ? ["--dry-run"] : [])];
576
- const r = await runs.exec({ kind: "shell", label: `agent-coordination update${dry ? " (dry-run)" : ""}`, sideEffects: !dry, args: { argv: argv.slice(1) } }, async (log) => {
595
+ // --harness= every home detected on this machine (~/.claude, ~/.grok, …) so grok & co. mirrors are refreshed too — not only the Claude home (the no-flag default is all five, but be explicit in the Run).
596
+ const harnesses = detectedHarnesses();
597
+ if (!harnesses.length)
598
+ return c.json({ error: "no harness home found (~/.claude, ~/.grok, ~/.cursor, ~/.vscode, ~/.config/opencode)" }, 400);
599
+ const argv = [cli, "update", `--harness=${harnesses.join(",")}`, ...(dry ? ["--dry-run"] : [])];
600
+ const r = await runs.exec({ kind: "shell", label: `agent-coordination update${dry ? " (dry-run · preview)" : ""} · ${harnesses.join(",")}`, sideEffects: !dry, args: { argv: argv.slice(1), harnesses } }, async (log) => {
577
601
  log(`$ node ${argv.map((a) => a.replace(cfg.kitPath, "<kit>")).join(" ")}`);
578
602
  const out = await execP("node", argv, { timeout: 120_000, maxBuffer: 8 << 20 }).catch((e) => { log(`exit: ${e.message}`); return { stdout: e.stdout ?? "", stderr: e.stderr ?? "" }; });
579
603
  const lines = (out.stdout + out.stderr).replace(/\x1b\[[0-9;]*m/g, "").split("\n").filter(Boolean);
580
604
  for (const l of lines.slice(0, 120))
581
605
  log(l);
582
- return { lines: lines.length, dryRun: dry };
606
+ return { lines: lines.length, dryRun: dry, harnesses };
583
607
  });
584
608
  return c.json(r, r.status === "ok" ? 200 : 500);
585
609
  });
@@ -836,12 +860,49 @@ app.get("/runs/stream", (c) => {
836
860
  });
837
861
  return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" } });
838
862
  });
839
- /* Actions (allow-listed; each becomes a Run). Read-only build: only read-only MCP tools + read-only CLIs. */
863
+ const doctorCounts = (d) => ({ warn: d?.summary?.warn ?? (d?.findings ?? []).filter((f) => f.level === "warn").length, error: d?.summary?.error ?? (d?.findings ?? []).filter((f) => f.level === "error").length, fixable: (d?.findings ?? []).filter((f) => f.fixable && f.level !== "ok").length });
840
864
  app.post("/actions/bus-doctor", async (c) => {
841
865
  const body = await c.req.json().catch(() => ({}));
842
- if (body.fix)
843
- return c.json({ error: "fix=true is a write; disabled in read-only build" }, 403);
844
- const r = await runs.exec({ kind: "mcp", label: "bus doctor", sideEffects: false, args: { tool: "doctor", agentId: AGENT_ID } }, (log) => bus.call("doctor", {}, log));
866
+ if (!body.fix) {
867
+ const r = await runs.exec({ kind: "mcp", label: "bus doctor", sideEffects: false, args: { tool: "doctor", agentId: AGENT_ID } }, (log) => bus.call("doctor", {}, log));
868
+ return c.json(r, r.status === "ok" ? 200 : 500);
869
+ }
870
+ // fix=true: the MCP doctor's OWN fix path (orphan markers etc. — only the findings it tags fixable). Gated like every bus write. Before → fix → after, with the delta.
871
+ if (!busWrites.enabled)
872
+ return c.json({ error: "bus writes are off — switch them on in the header (WRITES), then Fix fixable" }, 403);
873
+ const r = await runs.exec({ kind: "mcp", label: "bus doctor · fix", sideEffects: true, args: { tool: "doctor", fix: true, agentId: AGENT_ID } }, async (log) => {
874
+ const before = (await bus.call("doctor", {}, log));
875
+ const b = doctorCounts(before);
876
+ log(`before: ${b.warn} warn · ${b.error} error · ${b.fixable} fixable`);
877
+ const fixed = (await bus.call("doctor", { fix: true }, log));
878
+ log(`fix: ${fixed.fixApplied ? "applied" : "nothing applied"}`);
879
+ const after = (await bus.call("doctor", {}, log));
880
+ const a = doctorCounts(after);
881
+ log(`after: ${a.warn} warn · ${a.error} error · ${a.fixable} fixable (Δ warn ${a.warn - b.warn}, error ${a.error - b.error}, fixable ${a.fixable - b.fixable})`);
882
+ return { ...after, fixed: { applied: !!fixed.fixApplied, fixedFindings: (before.findings ?? []).filter((f) => f.fixable && f.level !== "ok" && !(after.findings ?? []).some((g) => g.check === f.check && g.level !== "ok")).map((f) => f.check) }, before: b, after: a, delta: { warn: a.warn - b.warn, error: a.error - b.error, fixable: a.fixable - b.fixable } };
883
+ });
884
+ return c.json(r, r.status === "ok" ? 200 : 500);
885
+ });
886
+ /* dist-behind-source is not doctor-fixable: rebuild the kit's MCP (the doctor's own advice: `npm run build` in packages/coord-mcp — here via the kit workspace: `pnpm --filter agent-coord-mcp build`, cwd = kit checkout). Only when a kit checkout is configured; never touches ~/agent-coord. */
887
+ app.post("/actions/rebuild-kit-mcp", async (c) => {
888
+ if (!busWrites.enabled)
889
+ return c.json({ error: "bus writes are off — switch them on in the header (WRITES) first" }, 403);
890
+ const b = (await c.req.json().catch(() => ({})));
891
+ if (b.confirm !== "BUILD")
892
+ return c.json({ error: "confirm must be BUILD" }, 400);
893
+ if (!hasKitCheckout(cfg.kitPath))
894
+ return c.json({ error: `no kit checkout at kitPath (${cfg.kitPath || "unset"}) — on this machine the MCP is the installed npm package; upgrade it with npm i -g agent-coord-mcp@latest, or ask the kit coordinator to publish` }, 409);
895
+ const argv = ["pnpm", "--filter", "agent-coord-mcp", "build"];
896
+ const r = await runs.exec({ kind: "shell", label: "rebuild kit MCP (pnpm --filter agent-coord-mcp build)", sideEffects: true, args: { cwd: cfg.kitPath, argv } }, async (log) => {
897
+ log(`$ ${argv.join(" ")} (cwd ${cfg.kitPath})`);
898
+ const out = await execP(argv[0], argv.slice(1), { cwd: cfg.kitPath, timeout: 180_000, maxBuffer: 8 << 20 }).catch((e) => { log(`exit: ${e.message}`); return { stdout: e.stdout ?? "", stderr: e.stderr ?? "", failed: true }; });
899
+ for (const l of (out.stdout + out.stderr).replace(/\x1b\[[0-9;]*m/g, "").split("\n").filter(Boolean).slice(-40))
900
+ log(l);
901
+ if (out.failed)
902
+ throw new Error("build failed — see log");
903
+ log("built. Live MCP sessions still run the old inode: restart agents (detach_agent + attach_agent) or wait for their next session; the console's own client reconnects on next use.");
904
+ return { ok: true, cwd: cfg.kitPath };
905
+ });
845
906
  return c.json(r, r.status === "ok" ? 200 : 500);
846
907
  });
847
908
  app.post("/actions/project-doctor/:name", async (c) => {
@@ -912,8 +973,13 @@ setInterval(runPurge, 30 * 60_000).unref();
912
973
  /* Packaged mode: the built web UI lives in ../public (copied at prepack) and is served by the same process at / — one port, no Vite. Dev keeps using Vite on :5173 with the /api proxy. */
913
974
  root.route("/", app);
914
975
  if (existsSync(PUBLIC_DIR)) {
915
- root.use("/*", serveStatic({ root: PUBLIC_DIR }));
916
- root.get("*", (c) => c.html(readFileSync(path.join(PUBLIC_DIR, "index.html"), "utf8"))); // hash router index for everything else
976
+ // Cache policy: the shell (index.html) is never cached (so an upgraded API serves the new UI on next load — David saw a 0.1.5 UI on 0.1.6 until a hard refresh);
977
+ // hashed Vite assets under /assets/* are immutable for a year; everything else (fonts, icons) a day.
978
+ const shell = (c) => { c.header("Cache-Control", "no-cache"); c.header("X-Console-Version", PKG.version); return c.html(readFileSync(path.join(PUBLIC_DIR, "index.html"), "utf8")); };
979
+ root.get("/", shell);
980
+ root.get("/index.html", shell);
981
+ root.use("/*", serveStatic({ root: PUBLIC_DIR, onFound: (p, c) => { c.header("Cache-Control", /\/assets\/[^/]+-[\w-]{6,}\.\w+$/.test(p) ? "public, max-age=31536000, immutable" : "public, max-age=86400"); } }));
982
+ root.get("*", shell); // hash router → index for everything else
917
983
  }
918
984
  // Bind loopback only: the console can write to the bus and to docs/ and has no auth yet. Set GROUNDWORK_CONSOLE_HOST=0.0.0.0 to expose (put auth in front first).
919
985
  const hostname = process.env.GROUNDWORK_CONSOLE_HOST ?? "127.0.0.1";
package/dist/skills.js CHANGED
@@ -1,10 +1,41 @@
1
- /* Supplement to the CLIs' `list --json`: byte-compare installed SKILL.md with the kit source (in-sync signal the CLIs do not report). */
1
+ /* Supplement to the CLIs' `list --json`: compare installed SKILL.md with what the installer would write (in-sync signal the CLIs do not report).
2
+ groundwork: byte-copy → raw hash. playbook: per-harness placeholder rewrite → hash the rewritten source (see loadRewrite). */
2
3
  import { existsSync, readFileSync, readdirSync } from "node:fs";
3
4
  import { createHash } from "node:crypto";
4
5
  import { homedir } from "node:os";
5
6
  import path from "node:path";
6
7
  import { packageRoot } from "./kitresolve.js";
8
+ import { pathToFileURL } from "node:url";
9
+ const FALLBACK_REWRITE = {
10
+ claude: { tool: (n) => `mcp__agent-coord__${n}`, tools: "mcp__agent-coord__*" },
11
+ grok: { tool: (n) => `agent-coord__${n}`, tools: "agent-coord__*", playbookHome: "~/.grok/groundwork-playbook" },
12
+ cursor: { tool: (n) => n, tools: "the agent-coord MCP tools" },
13
+ vscode: { tool: (n) => `#tool:${n}`, tools: "#tool:agent-coord/*" },
14
+ opencode: { tool: (n) => `agent-coord_${n}`, tools: "agent-coord_*" },
15
+ generic: { tool: (n) => `the \`${n}\` tool on the \`agent-coord\` MCP server`, tools: "the tools on the `agent-coord` MCP server" },
16
+ };
17
+ const fallbackRewrite = (text, harness) => { const spec = FALLBACK_REWRITE[harness] ?? FALLBACK_REWRITE.generic; let out = text.replaceAll("{{tools:agent-coord}}", spec.tools).replace(/\{\{tool:([a-z0-9_]+)\}\}/g, (_m, n) => spec.tool(n)); if (spec.playbookHome)
18
+ out = out.replaceAll("~/.claude/groundwork-playbook", spec.playbookHome); return out; };
19
+ const rewriteCache = new Map();
20
+ async function loadRewrite(root) {
21
+ const hit = rewriteCache.get(root);
22
+ if (hit)
23
+ return hit;
24
+ let out = { fn: fallbackRewrite, source: "console fallback table" };
25
+ const mod = path.join(root, "lib", "packaging.mjs");
26
+ if (existsSync(mod)) {
27
+ try {
28
+ const m = (await import(pathToFileURL(mod).href));
29
+ if (typeof m.rewritePlaceholders === "function")
30
+ out = { fn: m.rewritePlaceholders, source: "kit lib/packaging.mjs" };
31
+ }
32
+ catch { /* fallback */ }
33
+ }
34
+ rewriteCache.set(root, out);
35
+ return out;
36
+ }
7
37
  const sha = (p) => (existsSync(p) ? createHash("sha1").update(readFileSync(p)).digest("hex").slice(0, 10) : null);
38
+ const shaText = (t) => createHash("sha1").update(t).digest("hex").slice(0, 10);
8
39
  const dirs = (p) => (existsSync(p) ? readdirSync(p, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) : []);
9
40
  const front = (p) => { if (!existsSync(p))
10
41
  return {}; const m = readFileSync(p, "utf8").match(/^---\n([\s\S]*?)\n---/); const o = {}; m?.[1].split("\n").forEach((l) => { const i = l.indexOf(":"); if (i > 0)
@@ -14,13 +45,13 @@ export function groundworkSkills(kitPath, projectPath) {
14
45
  const versionMarker0 = existsSync(path.join(projectPath, "docs/.groundwork/VERSION")) ? readFileSync(path.join(projectPath, "docs/.groundwork/VERSION"), "utf8").trim() : null;
15
46
  if (!root)
16
47
  return { versionMarker: versionMarker0, kitVersion: null, driftedVersion: false, skills: [], error: "groundwork not found — npm i -g @davidbalzan/groundwork (or set kitPath to a kit checkout)" };
17
- const payload = path.join(root, "payload/skills");
48
+ const payload = path.join(root, "payload/skills"); // groundwork init/update byte-copies SKILL.md (copyFileSafe) → raw hash comparison is correct here
18
49
  const pathsMjs = path.join(root, "src/lib/paths.mjs");
19
50
  const minimal = existsSync(pathsMjs) ? [...(readFileSync(pathsMjs, "utf8").match(/MINIMAL_SKILLS = \[([\s\S]*?)\]/)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((m) => m[1]) : [];
20
51
  const versionMarker = existsSync(path.join(projectPath, "docs/.groundwork/VERSION")) ? readFileSync(path.join(projectPath, "docs/.groundwork/VERSION"), "utf8").trim() : null;
21
52
  const kitVersion = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version;
22
53
  return {
23
- versionMarker, kitVersion, driftedVersion: !!versionMarker && versionMarker !== kitVersion,
54
+ versionMarker, kitVersion, source: root, driftedVersion: !!versionMarker && versionMarker !== kitVersion,
24
55
  skills: dirs(payload).map((name) => {
25
56
  const src = path.join(payload, name, "SKILL.md");
26
57
  const inst = path.join(projectPath, ".claude/skills", name, "SKILL.md");
@@ -35,19 +66,32 @@ export function groundworkSkills(kitPath, projectPath) {
35
66
  };
36
67
  }
37
68
  /* Global playbook cards live in more than one harness home: Claude → ~/.claude/skills, Grok → ~/.grok/skills (+ ~/.grok/groundwork-playbook). Measure each. */
38
- const HARNESS_HOMES = [["claude", path.join(homedir(), ".claude/skills")], ["grok", path.join(homedir(), ".grok/skills")]];
39
- export function playbookCards(kitPath) {
69
+ // Same home overrides the installer honours (CLAUDE_HOME / GROK_HOME), so measurement and install target the same dirs (and tests can point both at a temp home).
70
+ const HARNESS_HOMES = [["claude", path.join(process.env.CLAUDE_HOME || path.join(homedir(), ".claude"), "skills")], ["grok", path.join(process.env.GROK_HOME || path.join(homedir(), ".grok"), "skills")]];
71
+ /* Harness homes the playbook installer can target (same env overrides as install.mjs). `detectedHarnesses` = those whose home dir exists on this machine —
72
+ passed as --harness= to `update` so grok/cursor/... mirrors are refreshed only where the harness is present (the installer's no-flag default is all five). */
73
+ export const INSTALLER_HOMES = {
74
+ claude: process.env.CLAUDE_HOME || path.join(homedir(), ".claude"), grok: process.env.GROK_HOME || path.join(homedir(), ".grok"), cursor: process.env.CURSOR_HOME || path.join(homedir(), ".cursor"),
75
+ vscode: process.env.VSCODE_HOME || path.join(homedir(), ".vscode"), opencode: process.env.OPENCODE_HOME || path.join(homedir(), ".config/opencode"),
76
+ };
77
+ export const detectedHarnesses = () => Object.entries(INSTALLER_HOMES).filter(([, d]) => existsSync(d)).map(([h]) => h);
78
+ /* Exposed for the fixture test: the fallback table must produce the same bytes as the kit's rewritePlaceholders. */
79
+ export const __rewrite = { fallbackRewrite, loadRewrite };
80
+ export async function playbookCards(kitPath) {
40
81
  const root = packageRoot(kitPath, "playbook");
41
82
  if (!root)
42
83
  return { kitVersion: null, globalDir: HARNESS_HOMES[0][1], homes: [], cards: [], error: "agent-coordination not found — npm i -g agent-coordination (or set kitPath to a kit checkout)" };
43
84
  const srcRoot = path.join(root, "skills");
44
85
  const kitVersion = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version;
45
86
  const homes = HARNESS_HOMES.filter(([, d]) => existsSync(d));
87
+ const rw = await loadRewrite(root);
88
+ // expected installed bytes per harness = installer rewrite of the card (same transform install.mjs applies before writing SKILL.md)
89
+ const expected = (src, harness) => shaText(rw.fn(readFileSync(src, "utf8"), harness));
46
90
  return {
47
- kitVersion, globalDir: HARNESS_HOMES[0][1], homes: homes.map(([h, d]) => ({ harness: h, dir: d })),
91
+ kitVersion, source: root, rewrite: rw.source, globalDir: HARNESS_HOMES[0][1], homes: homes.map(([h, d]) => ({ harness: h, dir: d })),
48
92
  cards: dirs(srcRoot).filter((n) => n !== "references").map((name) => {
49
93
  const src = path.join(srcRoot, name, "SKILL.md");
50
- const per = Object.fromEntries(homes.map(([h, d]) => { const inst = path.join(d, name, "SKILL.md"); const installed = existsSync(inst); const slices = existsSync(path.join(d, name, "references")) ? readdirSync(path.join(d, name, "references")).filter((f) => f.endsWith(".md")) : []; return [h, { installed, inSync: installed ? sha(src) === sha(inst) : null, slices: slices.length, legacyEssay: slices.includes("playbook.md") }]; }));
94
+ const per = Object.fromEntries(homes.map(([h, d]) => { const inst = path.join(d, name, "SKILL.md"); const installed = existsSync(inst); const slices = existsSync(path.join(d, name, "references")) ? readdirSync(path.join(d, name, "references")).filter((f) => f.endsWith(".md")) : []; return [h, { installed, inSync: installed ? expected(src, h) === sha(inst) : null, slices: slices.length, legacyEssay: slices.includes("playbook.md") }]; }));
51
95
  const claude = per.claude ?? { installed: false, inSync: null, slices: 0, legacyEssay: false };
52
96
  return { name, installed: claude.installed, inSync: claude.inSync, slices: claude.slices, legacyEssay: claude.legacyEssay, homes: per, description: front(src).description ?? "", mirrors: { opencode: existsSync(path.join(homedir(), ".config/opencode/skills", name)), cursor: existsSync(path.join(homedir(), ".cursor/commands", `${name}.md`)), vscode: existsSync(path.join(homedir(), ".vscode/prompts", `${name}.prompt.md`)) } };
53
97
  }),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@davidbalzan/groundwork-console",
3
3
  "private": false,
4
- "version": "0.1.6",
4
+ "version": "0.1.7",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@davidbalzan/groundwork-seam": "^0.1.0",
@@ -44,6 +44,9 @@
44
44
  "build:web": "pnpm --filter @groundwork-console/web build && rm -rf public && cp -R ../web/dist public",
45
45
  "typecheck": "tsc -p tsconfig.json --noEmit",
46
46
  "start": "node bin/groundwork-console.mjs",
47
- "publish-check": "node scripts/publish-check.mjs"
47
+ "publish-check": "node scripts/publish-check.mjs",
48
+ "test:inject": "tsx test/inject.test.mts",
49
+ "test:skills": "tsx test/skills.test.mts",
50
+ "test": "pnpm run test:inject && pnpm run test:skills"
48
51
  }
49
52
  }