@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.
- package/bin/groundwork-console.mjs +2 -0
- package/dist/answered.js +16 -34
- package/dist/kitresolve.js +3 -2
- package/dist/mcp.js +2 -1
- package/dist/provision.js +94 -4
- package/dist/server.js +106 -40
- package/dist/skills.js +51 -7
- package/package.json +5 -2
- package/public/assets/{abnfDiagram-N423BO3Z-CdHjY-Rl.js → abnfDiagram-N423BO3Z-B0nns4cA.js} +1 -1
- package/public/assets/{arc-BrWBiZCE.js → arc-DsDgWIU7.js} +1 -1
- package/public/assets/architecture-TIHT7OUA-okwBKksv.js +1 -0
- package/public/assets/{architectureDiagram-T3A2C74G-BlL-ALnB.js → architectureDiagram-T3A2C74G-Bqo7zIaB.js} +1 -1
- package/public/assets/{blockDiagram-VBNYF7ZC-DZyulKP3.js → blockDiagram-VBNYF7ZC-CvNPqlbt.js} +1 -1
- package/public/assets/{c4Diagram-5PPSVZJV-BMMLwghr.js → c4Diagram-5PPSVZJV-BqjkNgv6.js} +1 -1
- package/public/assets/channel-CI1-SNMv.js +1 -0
- package/public/assets/{chunk-2GRJ4B5K-BzJtMGzP.js → chunk-2GRJ4B5K-CWJJzbol.js} +1 -1
- package/public/assets/{chunk-4I5QYGJK-DwFXVxXp.js → chunk-4I5QYGJK-CuHGN-lT.js} +1 -1
- package/public/assets/{chunk-5RXB4S5H-BohnhuTy.js → chunk-5RXB4S5H-Cl92nmIs.js} +1 -1
- package/public/assets/{chunk-6Q2QTUOP-BXxDc17T.js → chunk-6Q2QTUOP-C3vYg7rX.js} +1 -1
- package/public/assets/{chunk-7Z6QIM7H-k5qXgkB4.js → chunk-7Z6QIM7H-jkpbvAhi.js} +1 -1
- package/public/assets/{chunk-GF5L2VYU-Cl8z_Pcd.js → chunk-GF5L2VYU-Bo-zB54d.js} +1 -1
- package/public/assets/{chunk-I66GZJ75-CCxcXl9W.js → chunk-I66GZJ75-BD9h95Vq.js} +1 -1
- package/public/assets/{chunk-JQJVKLGR-Dah9RJ-p.js → chunk-JQJVKLGR-C6TRkyf7.js} +1 -1
- package/public/assets/{chunk-KBJHAD2P-B1kM7HCm.js → chunk-KBJHAD2P-RgoL4Nto.js} +1 -1
- package/public/assets/{chunk-NSK5VX7P-Ddw2Pneq.js → chunk-NSK5VX7P-C6TsGnGA.js} +1 -1
- package/public/assets/{chunk-QR6OTTB3-SR1VT_gf.js → chunk-QR6OTTB3-Bq5akaxk.js} +1 -1
- package/public/assets/{chunk-RYQCIY6F-CsMzInBn.js → chunk-RYQCIY6F-DtVCesJ4.js} +1 -1
- package/public/assets/{chunk-UBXNYLIW-CysKciL_.js → chunk-UBXNYLIW-BjnaZZH0.js} +1 -1
- package/public/assets/{chunk-W5SLKNZC-CueNCRji.js → chunk-W5SLKNZC-D1KW-lQn.js} +1 -1
- package/public/assets/{chunk-WRU74C26-CVxduXc3.js → chunk-WRU74C26-YZFoWy6X.js} +1 -1
- package/public/assets/{chunk-XXDRQBXY-DOdFQa-t.js → chunk-XXDRQBXY-CyrI_Mox.js} +1 -1
- package/public/assets/classDiagram-JCYQIIEL-zdEoGtpz.js +1 -0
- package/public/assets/classDiagram-v2-OCEON4UE-zdEoGtpz.js +1 -0
- package/public/assets/{cose-bilkent-JH36ORCC-B2Z9nj2r.js → cose-bilkent-JH36ORCC-v6lDOu7m.js} +1 -1
- package/public/assets/{cynefin-VYW2F7L2-CmV_z2bb.js → cynefin-VYW2F7L2-BtzRQs_J.js} +1 -1
- package/public/assets/{cynefinDiagram-MW4NZA55-zeWgiEbQ.js → cynefinDiagram-MW4NZA55--R8S0KoS.js} +1 -1
- package/public/assets/{dagre-VZM6K2ZE-CaMVheN4.js → dagre-VZM6K2ZE-D6mRFOfB.js} +1 -1
- package/public/assets/{diagram-7IWD3JNH-BTJeDC0S.js → diagram-7IWD3JNH-kfCoRMpW.js} +1 -1
- package/public/assets/{diagram-B4RE2ZJO-BUtRYMUD.js → diagram-B4RE2ZJO-sxUW6rzT.js} +1 -1
- package/public/assets/{diagram-LBJQPF4R-DM--EdGk.js → diagram-LBJQPF4R-B926bgCA.js} +1 -1
- package/public/assets/{diagram-Q27KOJAE-Dcp9D1k4.js → diagram-Q27KOJAE-CcVi6njJ.js} +1 -1
- package/public/assets/{diagram-UB23O5K3-DzLpzKu2.js → diagram-UB23O5K3-BtW4XOuK.js} +1 -1
- package/public/assets/{dist-DFkPtM9v.js → dist-BnkmMC7M.js} +1 -1
- package/public/assets/{ebnfDiagram-BXEA7PRR-C2lFoa24.js → ebnfDiagram-BXEA7PRR-BNGfWHYj.js} +1 -1
- package/public/assets/{erDiagram-JOGREHBK-GvedjkFY.js → erDiagram-JOGREHBK-BGrhe741.js} +1 -1
- package/public/assets/eventmodeling-45OFAUF4-Cn6RDMEG.js +1 -0
- package/public/assets/flowDiagram-UKHOOZJN-DOMsEKL1.js +1 -0
- package/public/assets/{ganttDiagram-PKOTCBZU-NzyabXr_.js → ganttDiagram-PKOTCBZU-CR5BNdXR.js} +1 -1
- package/public/assets/{gitGraph-TEB2WS4Q-CAnBW3hc.js → gitGraph-TEB2WS4Q-CfvC-iZD.js} +1 -1
- package/public/assets/{gitGraphDiagram-DS77QQ5N-ChFOjBWW.js → gitGraphDiagram-DS77QQ5N-D45EWdll.js} +1 -1
- package/public/assets/{index-CsJm47wv.css → index-CxNe802C.css} +1 -1
- package/public/assets/index-yHI4kvM-.js +41 -0
- package/public/assets/{info-DKCQHKI2-9HGQNL8q.js → info-DKCQHKI2-stahmZKW.js} +1 -1
- package/public/assets/{infoDiagram-6WML65LV-CSZIpCN6.js → infoDiagram-6WML65LV-nWu8ujif.js} +1 -1
- package/public/assets/{ishikawaDiagram-WSZJBQD7-m5LI_l0g.js → ishikawaDiagram-WSZJBQD7-Ccwzv-xZ.js} +1 -1
- package/public/assets/{journeyDiagram-NVQOT4AX-DlhpOOGP.js → journeyDiagram-NVQOT4AX-R7gZw1Qw.js} +1 -1
- package/public/assets/{kanban-definition-27J2QSJJ-CpS1zcAJ.js → kanban-definition-27J2QSJJ-By8aTzkI.js} +1 -1
- package/public/assets/{line-DqdLvBGQ.js → line-zJXbSVxf.js} +1 -1
- package/public/assets/{linear-ZFSYfg_z.js → linear-B8U-jWnO.js} +1 -1
- package/public/assets/{mermaid-parser.core-B4KSdud0.js → mermaid-parser.core-B1iqIz20.js} +3 -3
- package/public/assets/{mermaid.core-CSBAT3ig.js → mermaid.core-CuFK5CuK.js} +4 -4
- package/public/assets/{mindmap-definition-FAOFIHXS-DM39eR7l.js → mindmap-definition-FAOFIHXS-qJ2DuBku.js} +1 -1
- package/public/assets/{packet-7NZHBO7P-CaKShi1f.js → packet-7NZHBO7P-CzQJdD0e.js} +1 -1
- package/public/assets/{pegDiagram-VL7TDLO6-CeupMOTZ.js → pegDiagram-VL7TDLO6-CKmHw89b.js} +1 -1
- package/public/assets/{pie-RZYD4A2V-CdyK9ryQ.js → pie-RZYD4A2V-Dt3CA16n.js} +1 -1
- package/public/assets/{pieDiagram-7S7Q4E2Y-BTsBpWka.js → pieDiagram-7S7Q4E2Y-D9vwefN9.js} +1 -1
- package/public/assets/{quadrantDiagram-CIZ2JOQS-UamjAqXX.js → quadrantDiagram-CIZ2JOQS-jtSAmOcp.js} +1 -1
- package/public/assets/{radar-I7S5WNFK-BHPrA655.js → radar-I7S5WNFK-BtDfUAqO.js} +1 -1
- package/public/assets/{railroad-3IZDKUUU-BmnKim6H.js → railroad-3IZDKUUU-D5DNw8qB.js} +1 -1
- package/public/assets/railroad-abnf-AHOZXSZD-DuJUfLMt.js +1 -0
- package/public/assets/railroad-ebnf-EBAXGLYW-CiLIN42p.js +1 -0
- package/public/assets/railroad-peg-LSFZ7HO6-DKtIAsvs.js +1 -0
- package/public/assets/{railroadDiagram-AXF67PYL-bbNt4a4d.js → railroadDiagram-AXF67PYL-C-S9VT4Z.js} +1 -1
- package/public/assets/{requirementDiagram-LRYGKXZP-BrwBiGll.js → requirementDiagram-LRYGKXZP-Bmv6EbmT.js} +1 -1
- package/public/assets/{sankeyDiagram-W5VNT64P-BeKu1QRQ.js → sankeyDiagram-W5VNT64P-Cm2dLWH9.js} +1 -1
- package/public/assets/{sequenceDiagram-SI44F4Z6-DiBZBmdo.js → sequenceDiagram-SI44F4Z6-DPslkAXf.js} +1 -1
- package/public/assets/{src-B2eptEoc.js → src-l8EnJwXz.js} +1 -1
- package/public/assets/{stateDiagram-OKZ733FA-Tjie08JN.js → stateDiagram-OKZ733FA-BBXMNQQ7.js} +1 -1
- package/public/assets/stateDiagram-v2-UEYNNEHI-BxOobbWc.js +1 -0
- package/public/assets/{swimlanes-SLNWSIFB-B_W9IMQM.js → swimlanes-SLNWSIFB-BeiwtQg-.js} +1 -1
- package/public/assets/swimlanesDiagram-ULZ7WXOC-BEW9yZ9H.js +8 -0
- package/public/assets/{timeline-definition-Z64GVDOM-B3CqBSPD.js → timeline-definition-Z64GVDOM-Drd7Un_g.js} +1 -1
- package/public/assets/{treeView-QDETBFTQ-BUEh9zyF.js → treeView-QDETBFTQ-BT2KyIoB.js} +1 -1
- package/public/assets/{treemap-6X3UGDF4-D_t1bJ-9.js → treemap-6X3UGDF4-gdZw2F6X.js} +1 -1
- package/public/assets/{vennDiagram-T6HMQDX7-B1_XMyC0.js → vennDiagram-T6HMQDX7-Bbk8OMR0.js} +1 -1
- package/public/assets/{wardley-OPB4EBWU-BlIYjwGG.js → wardley-OPB4EBWU-BI0xutNp.js} +1 -1
- package/public/assets/{wardleyDiagram-T6FBY63Y-BUJJz-1q.js → wardleyDiagram-T6FBY63Y-DQvEcd8r.js} +1 -1
- package/public/assets/{xychartDiagram-ELKLHX3M-CgWS9hey.js → xychartDiagram-ELKLHX3M-CRWoUpzf.js} +1 -1
- package/public/index.html +2 -2
- package/public/assets/architecture-TIHT7OUA-nRAC6cKL.js +0 -1
- package/public/assets/channel-CmRhJjbj.js +0 -1
- package/public/assets/classDiagram-JCYQIIEL-BhHtVWEo.js +0 -1
- package/public/assets/classDiagram-v2-OCEON4UE-BhHtVWEo.js +0 -1
- package/public/assets/eventmodeling-45OFAUF4-CVrSQ2LC.js +0 -1
- package/public/assets/flowDiagram-UKHOOZJN-BWxvJEZS.js +0 -1
- package/public/assets/index-DPLoCbew.js +0 -40
- package/public/assets/railroad-abnf-AHOZXSZD-n6VUqVzW.js +0 -1
- package/public/assets/railroad-ebnf-EBAXGLYW-Buhu71hT.js +0 -1
- package/public/assets/railroad-peg-LSFZ7HO6-DxxRF3eB.js +0 -1
- package/public/assets/stateDiagram-v2-UEYNNEHI-Bt5as9x1.js +0 -1
- 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,
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
/*
|
|
5
|
-
|
|
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" &&
|
|
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/kitresolve.js
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
140
|
-
if (
|
|
141
|
-
log(`
|
|
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
|
-
|
|
576
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
844
|
-
|
|
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
|
-
|
|
916
|
-
|
|
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`:
|
|
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
|
-
|
|
39
|
-
|
|
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 ?
|
|
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.
|
|
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
|
}
|