@davidbalzan/groundwork-console 0.1.11 → 0.1.13

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 (102) hide show
  1. package/LICENSE +19 -0
  2. package/dist/bus.js +54 -3
  3. package/dist/events.js +1 -1
  4. package/dist/gitinfo.js +42 -2
  5. package/dist/panes.js +86 -0
  6. package/dist/provision.js +47 -7
  7. package/dist/seam.js +27 -3
  8. package/dist/seamwrite.js +5 -1
  9. package/dist/server.js +10 -10
  10. package/dist/tmux.js +1 -1
  11. package/package.json +11 -5
  12. package/public/assets/{abnfDiagram-N423BO3Z-SmC0YbkZ.js → abnfDiagram-N423BO3Z-2g9UHdfw.js} +1 -1
  13. package/public/assets/{arc-Bwss940w.js → arc-EEINoaIY.js} +1 -1
  14. package/public/assets/architecture-TIHT7OUA-CFm6TN4T.js +1 -0
  15. package/public/assets/{architectureDiagram-T3A2C74G-Cq88dj_x.js → architectureDiagram-T3A2C74G-D4F9rIiu.js} +1 -1
  16. package/public/assets/{blockDiagram-VBNYF7ZC-Cr4QI7D6.js → blockDiagram-VBNYF7ZC-CQZAhVXz.js} +1 -1
  17. package/public/assets/{c4Diagram-5PPSVZJV-C1C4eJIx.js → c4Diagram-5PPSVZJV-BlBzSIXs.js} +1 -1
  18. package/public/assets/channel-CHLtdsMA.js +1 -0
  19. package/public/assets/{chunk-2GRJ4B5K-Cpp7ECx6.js → chunk-2GRJ4B5K-ZVZxINuZ.js} +1 -1
  20. package/public/assets/{chunk-4I5QYGJK-Bps2fbU8.js → chunk-4I5QYGJK-8hqVPXHL.js} +1 -1
  21. package/public/assets/{chunk-5RXB4S5H-3UxSwRGQ.js → chunk-5RXB4S5H-D1s6L_89.js} +1 -1
  22. package/public/assets/{chunk-6Q2QTUOP-zawcmdf9.js → chunk-6Q2QTUOP-BWLeyKxS.js} +1 -1
  23. package/public/assets/{chunk-7Z6QIM7H-Dc0octNy.js → chunk-7Z6QIM7H-DjvOr6wh.js} +1 -1
  24. package/public/assets/{chunk-GF5L2VYU-RHui1NJm.js → chunk-GF5L2VYU-BCFrJAqb.js} +1 -1
  25. package/public/assets/{chunk-I66GZJ75-BlHb3oSv.js → chunk-I66GZJ75-XdTCBJhL.js} +1 -1
  26. package/public/assets/{chunk-JQJVKLGR-WIlvhJ6X.js → chunk-JQJVKLGR-BwEUHZBv.js} +1 -1
  27. package/public/assets/{chunk-KBJHAD2P-Dr1rLwdT.js → chunk-KBJHAD2P-HjBUqR8F.js} +1 -1
  28. package/public/assets/{chunk-NSK5VX7P-B07rzjwZ.js → chunk-NSK5VX7P-DkHwHrKE.js} +1 -1
  29. package/public/assets/{chunk-QR6OTTB3-ComyizSZ.js → chunk-QR6OTTB3-DF1_EWFH.js} +1 -1
  30. package/public/assets/{chunk-RYQCIY6F-k_etiI3Q.js → chunk-RYQCIY6F-BaBhRLGF.js} +1 -1
  31. package/public/assets/{chunk-UBXNYLIW-DldfSHAq.js → chunk-UBXNYLIW-BNV3VrZA.js} +1 -1
  32. package/public/assets/{chunk-W5SLKNZC-Dn5B1pXH.js → chunk-W5SLKNZC-5ymyg6B_.js} +1 -1
  33. package/public/assets/{chunk-WRU74C26-D34jYzyt.js → chunk-WRU74C26-oznLCI2s.js} +1 -1
  34. package/public/assets/{chunk-XXDRQBXY-BQXENxTm.js → chunk-XXDRQBXY-sauNIYLc.js} +1 -1
  35. package/public/assets/classDiagram-JCYQIIEL-Wf8lPn9O.js +1 -0
  36. package/public/assets/classDiagram-v2-OCEON4UE-Wf8lPn9O.js +1 -0
  37. package/public/assets/{cose-bilkent-JH36ORCC-CPGou1DN.js → cose-bilkent-JH36ORCC-B0A4Je-o.js} +1 -1
  38. package/public/assets/{cynefin-VYW2F7L2-DOI3jeNv.js → cynefin-VYW2F7L2-BtmksekA.js} +1 -1
  39. package/public/assets/{cynefinDiagram-MW4NZA55-DoW8nM2s.js → cynefinDiagram-MW4NZA55-DxlB0XWy.js} +1 -1
  40. package/public/assets/{dagre-VZM6K2ZE-Dq8ZHjT7.js → dagre-VZM6K2ZE-BwX2jQEP.js} +1 -1
  41. package/public/assets/{diagram-7IWD3JNH-3FSn8Agr.js → diagram-7IWD3JNH-CRw4eKmV.js} +1 -1
  42. package/public/assets/{diagram-B4RE2ZJO-BnXqbr3I.js → diagram-B4RE2ZJO-BhSHl_HC.js} +1 -1
  43. package/public/assets/{diagram-LBJQPF4R-B9WLcX1P.js → diagram-LBJQPF4R-BLNDbOa-.js} +1 -1
  44. package/public/assets/{diagram-Q27KOJAE-Cqc0NnFi.js → diagram-Q27KOJAE-OI-WVr2z.js} +1 -1
  45. package/public/assets/{diagram-UB23O5K3-D3DVcxz0.js → diagram-UB23O5K3-Dw4v-DRV.js} +1 -1
  46. package/public/assets/{dist-D9Nkbm8B.js → dist-CkCHUIyI.js} +1 -1
  47. package/public/assets/{ebnfDiagram-BXEA7PRR-FHf_rewb.js → ebnfDiagram-BXEA7PRR-_CCtytYQ.js} +1 -1
  48. package/public/assets/{erDiagram-JOGREHBK-CvPTNoqX.js → erDiagram-JOGREHBK-DPwktZ67.js} +1 -1
  49. package/public/assets/eventmodeling-45OFAUF4-COOjM2XZ.js +1 -0
  50. package/public/assets/flowDiagram-UKHOOZJN-ipIqOWay.js +1 -0
  51. package/public/assets/{ganttDiagram-PKOTCBZU-DWUBSW9B.js → ganttDiagram-PKOTCBZU-ChHOAX0R.js} +1 -1
  52. package/public/assets/{gitGraph-TEB2WS4Q-CjymOfrj.js → gitGraph-TEB2WS4Q-DTKjAplS.js} +1 -1
  53. package/public/assets/{gitGraphDiagram-DS77QQ5N-BZa0uJBE.js → gitGraphDiagram-DS77QQ5N-CetQtIP1.js} +1 -1
  54. package/public/assets/{index-CS6PrVvt.js → index-CMeHHiB8.js} +11 -11
  55. package/public/assets/{info-DKCQHKI2-DugyQgAo.js → info-DKCQHKI2-CEZZoshL.js} +1 -1
  56. package/public/assets/{infoDiagram-6WML65LV-DGlBHx3m.js → infoDiagram-6WML65LV-Dn0Ci1fh.js} +1 -1
  57. package/public/assets/{ishikawaDiagram-WSZJBQD7-BbqOG9Eg.js → ishikawaDiagram-WSZJBQD7-C0yvCWyv.js} +1 -1
  58. package/public/assets/{journeyDiagram-NVQOT4AX-Du3qH9mS.js → journeyDiagram-NVQOT4AX-CpTSt3oh.js} +1 -1
  59. package/public/assets/{kanban-definition-27J2QSJJ-CdVgOFzk.js → kanban-definition-27J2QSJJ-Co4MhtWb.js} +1 -1
  60. package/public/assets/{line-CvLqalqu.js → line-CKdpDvRy.js} +1 -1
  61. package/public/assets/{linear-ChXKEHCZ.js → linear-BAY9SBNB.js} +1 -1
  62. package/public/assets/{mermaid-parser.core-C7RowpcN.js → mermaid-parser.core-B-yKcreq.js} +3 -3
  63. package/public/assets/{mermaid.core-U0Q7d-gI.js → mermaid.core-CW_jKbCL.js} +4 -4
  64. package/public/assets/{mindmap-definition-FAOFIHXS-Dd8IqZN3.js → mindmap-definition-FAOFIHXS-CejNgzQn.js} +1 -1
  65. package/public/assets/{packet-7NZHBO7P-rnlOmuHK.js → packet-7NZHBO7P-B53cyWv4.js} +1 -1
  66. package/public/assets/{pegDiagram-VL7TDLO6-C61XZFPE.js → pegDiagram-VL7TDLO6-DAh-rohm.js} +1 -1
  67. package/public/assets/{pie-RZYD4A2V-BosRLnUR.js → pie-RZYD4A2V-Bbl6CzP-.js} +1 -1
  68. package/public/assets/{pieDiagram-7S7Q4E2Y-C066dHaW.js → pieDiagram-7S7Q4E2Y-BoiaUX85.js} +1 -1
  69. package/public/assets/{quadrantDiagram-CIZ2JOQS-BiL2JG5p.js → quadrantDiagram-CIZ2JOQS-2EgkGrDN.js} +1 -1
  70. package/public/assets/{radar-I7S5WNFK-VRvamsSS.js → radar-I7S5WNFK-1Piyk_Oc.js} +1 -1
  71. package/public/assets/{railroad-3IZDKUUU-_8QphIQF.js → railroad-3IZDKUUU-DcQG7hTA.js} +1 -1
  72. package/public/assets/railroad-abnf-AHOZXSZD-CaviTNom.js +1 -0
  73. package/public/assets/railroad-ebnf-EBAXGLYW-K7l-WN0j.js +1 -0
  74. package/public/assets/railroad-peg-LSFZ7HO6-Bu22X3mO.js +1 -0
  75. package/public/assets/{railroadDiagram-AXF67PYL-BO0K2FSG.js → railroadDiagram-AXF67PYL-kPPDLWet.js} +1 -1
  76. package/public/assets/{requirementDiagram-LRYGKXZP-Cs5N-_5S.js → requirementDiagram-LRYGKXZP-B0wNE6wF.js} +1 -1
  77. package/public/assets/{sankeyDiagram-W5VNT64P-BBadz3Xa.js → sankeyDiagram-W5VNT64P-CyKEUx9w.js} +1 -1
  78. package/public/assets/{sequenceDiagram-SI44F4Z6-B1wwHKdi.js → sequenceDiagram-SI44F4Z6-DT7UmiRF.js} +1 -1
  79. package/public/assets/{src-rNJ0obFg.js → src-EbLSa_z9.js} +1 -1
  80. package/public/assets/{stateDiagram-OKZ733FA-D2BPoAkk.js → stateDiagram-OKZ733FA-BNk8YOH4.js} +1 -1
  81. package/public/assets/stateDiagram-v2-UEYNNEHI-8Oafa2Rm.js +1 -0
  82. package/public/assets/{swimlanes-SLNWSIFB-CJw_VUg-.js → swimlanes-SLNWSIFB-B7eUL04h.js} +1 -1
  83. package/public/assets/swimlanesDiagram-ULZ7WXOC-BT5Ebr7W.js +8 -0
  84. package/public/assets/{timeline-definition-Z64GVDOM-8uI7Mh2Y.js → timeline-definition-Z64GVDOM-CDLh-h94.js} +1 -1
  85. package/public/assets/{treeView-QDETBFTQ-DX9MBPTO.js → treeView-QDETBFTQ-BAscBSG9.js} +1 -1
  86. package/public/assets/{treemap-6X3UGDF4-DqOdI7Cz.js → treemap-6X3UGDF4-B2ahLgfF.js} +1 -1
  87. package/public/assets/{vennDiagram-T6HMQDX7-Dv3hlyhI.js → vennDiagram-T6HMQDX7-62WQ_264.js} +1 -1
  88. package/public/assets/{wardley-OPB4EBWU-BlsdtYE0.js → wardley-OPB4EBWU-DUrXideD.js} +1 -1
  89. package/public/assets/{wardleyDiagram-T6FBY63Y-8UP_r-p8.js → wardleyDiagram-T6FBY63Y-Ci4DF612.js} +1 -1
  90. package/public/assets/{xychartDiagram-ELKLHX3M-CqKePl1c.js → xychartDiagram-ELKLHX3M-D--O5thj.js} +1 -1
  91. package/public/index.html +1 -1
  92. package/public/assets/architecture-TIHT7OUA-DxbIc2EO.js +0 -1
  93. package/public/assets/channel-q2MiI2jw.js +0 -1
  94. package/public/assets/classDiagram-JCYQIIEL-BBCC64Pk.js +0 -1
  95. package/public/assets/classDiagram-v2-OCEON4UE-BBCC64Pk.js +0 -1
  96. package/public/assets/eventmodeling-45OFAUF4-B3k39kpe.js +0 -1
  97. package/public/assets/flowDiagram-UKHOOZJN-J20LnFn0.js +0 -1
  98. package/public/assets/railroad-abnf-AHOZXSZD-ySvBeK5p.js +0 -1
  99. package/public/assets/railroad-ebnf-EBAXGLYW-B08LkBeL.js +0 -1
  100. package/public/assets/railroad-peg-LSFZ7HO6-CnEVYMxD.js +0 -1
  101. package/public/assets/stateDiagram-v2-UEYNNEHI-CsMDmX-c.js +0 -1
  102. package/public/assets/swimlanesDiagram-ULZ7WXOC-D83SR1DB.js +0 -8
package/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 David Balzan
2
+
3
+ All rights reserved.
4
+
5
+ This software and its accompanying files are proprietary. No licence is
6
+ granted to use, copy, modify, merge, publish, distribute, sublicense, or sell
7
+ copies of this software, in whole or in part, except by a separate written
8
+ agreement signed by the copyright holder.
9
+
10
+ Possession of these files — including by download from a public package
11
+ registry — does not constitute such an agreement and grants no rights.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
package/dist/bus.js CHANGED
@@ -19,6 +19,54 @@ const tailJsonl = (p, limit) => {
19
19
  return { malformed: true, raw: l };
20
20
  } });
21
21
  };
22
+ /* Thresholds are judgement, not measurement, and are stated here rather than buried: "producing"
23
+ is the working cadence, and >8h is the shape of an overnight stall. The 2h boundary exists
24
+ because the previous model put a coffee break and a seventeen-hour stall in one bucket. */
25
+ const PRODUCING_MS = 2 * 3_600_000;
26
+ const STALLED_MS = 8 * 3_600_000;
27
+ const SCAN_LINES = 4000;
28
+ /** Newest emission per agent, from the room logs and the status stream. */
29
+ export function outputIndex(root, scanLines = SCAN_LINES) {
30
+ const out = new Map();
31
+ const note = (from, ts, basis) => {
32
+ if (typeof from !== "string" || typeof ts !== "number")
33
+ return;
34
+ const prev = out.get(from);
35
+ if (!prev || ts > prev.ts)
36
+ out.set(from, { ts, basis });
37
+ };
38
+ const roomDir = path.join(root, "rooms");
39
+ if (existsSync(roomDir))
40
+ for (const f of readdirSync(roomDir).filter((x) => x.endsWith(".jsonl"))) {
41
+ for (const m of tailJsonl(path.join(roomDir, f), scanLines))
42
+ note(m.from, m.ts, "room");
43
+ }
44
+ for (const m of tailJsonl(path.join(root, "status.jsonl"), scanLines))
45
+ note(m.agentId ?? m.from, m.ts, "status");
46
+ /* DMs count as output and are NOT in the room logs — they are rows in the RECIPIENT's inbox,
47
+ carrying the sender in `from`. Omitting them made an agent working entirely by DM read as
48
+ stalled; that was caught on the first live run, where this console's own worker showed
49
+ `stalled` while it had been reporting by DM all morning. Scanning inboxes attributes each
50
+ message to its sender, not its owner. */
51
+ const inboxDir = path.join(root, "inbox");
52
+ if (existsSync(inboxDir))
53
+ for (const f of readdirSync(inboxDir).filter((x) => x.endsWith(".jsonl"))) {
54
+ for (const m of tailJsonl(path.join(inboxDir, f), scanLines))
55
+ note(m.from, m.ts, "dm");
56
+ }
57
+ return out;
58
+ }
59
+ /* `unknown` is NOT "no output" — it is "none within the scanned window", and the window is reported
60
+ so a caller can tell the two apart. Collapsing them would be the same defect one level down. */
61
+ const progressOf = (hit, now, scanLines) => {
62
+ if (!hit)
63
+ return { lastOutputAt: null, ageMs: null, state: "unknown", basis: null, scannedLines: scanLines };
64
+ const ageMs = now - hit.ts;
65
+ return { lastOutputAt: hit.ts, ageMs, state: ageMs < PRODUCING_MS ? "producing" : ageMs < STALLED_MS ? "quiet" : "stalled", basis: hit.basis, scannedLines: scanLines };
66
+ };
67
+ /* Reachability buckets. `idle` used to span 15 MINUTES to 24 HOURS, so a coffee break and an
68
+ overnight stall rendered identically; `dormant` splits that. */
69
+ export const reachOf = (age) => age < 15 * 60_000 ? "live" : age < 2 * 3_600_000 ? "idle" : age < 24 * 3_600_000 ? "dormant" : "dead";
22
70
  export async function agentsAsync(root) {
23
71
  const panes = await listPanes();
24
72
  const base = agentsSync(root);
@@ -33,11 +81,14 @@ export async function agentsAsync(root) {
33
81
  const paneActivity = ct === undefined || ct === null ? windowActivity : ct || null; // 0 = seeded, no change observed yet → treated as unknown, not as "now"
34
82
  const lastSeen = Math.max(a.lastSeen ?? 0, paneActivity ?? 0);
35
83
  const age = lastSeen ? Date.now() - lastSeen : Infinity;
36
- return { ...a, paneActivity, windowActivity, windowName: pane?.windowName ?? null, paneCommand: pane?.command ?? null, paneAlive: pane ? !pane.dead : null, lastSeen: lastSeen || null, heartbeatAgeMs: Number.isFinite(age) ? age : null, liveness: age < 15 * 60_000 ? "live" : age < 24 * 3_600_000 ? "idle" : "dead", active: paneActivity ? Date.now() - paneActivity < 60_000 : false };
84
+ /* paneActivity is another REACHABILITY input, not progress: a pane redraws when a message is pasted into
85
+ it. It is deliberately not folded into `progress`, which stays as agentsSync computed it. */
86
+ return { ...a, paneActivity, windowActivity, windowName: pane?.windowName ?? null, paneCommand: pane?.command ?? null, paneAlive: pane ? !pane.dead : null, lastSeen: lastSeen || null, heartbeatAgeMs: Number.isFinite(age) ? age : null, reachability: reachOf(age), active: paneActivity ? Date.now() - paneActivity < 60_000 : false };
37
87
  }).sort((a, b) => (b.lastSeen ?? 0) - (a.lastSeen ?? 0));
38
88
  }
39
89
  export const agents = agentsSync;
40
- export function agentsSync(root) {
90
+ export function agentsSync(root, scanLines = SCAN_LINES) {
91
+ const output = outputIndex(root, scanLines);
41
92
  const reg = readJson(path.join(root, "agents.json"), {});
42
93
  const transports = existsSync(path.join(root, "transports")) ? new Set(readdirSync(path.join(root, "transports")).map((f) => f.replace(/\.json$/, ""))) : new Set();
43
94
  const now = Date.now();
@@ -48,7 +99,7 @@ export function agentsSync(root) {
48
99
  const mtimes = [tPath, path.join(root, "inbox", `${a.agentId}.jsonl`), path.join(root, "cursors", `${a.agentId}.json`)].filter(existsSync).map((f) => statSync(f).mtimeMs);
49
100
  const lastSeen = Math.max(a.lastHeartbeat ?? 0, ...mtimes);
50
101
  const age = lastSeen ? now - lastSeen : Infinity;
51
- return { ...a, transport: t?.transport ?? t?.kind ?? (t ? "tmux-push" : null), pane: t?.tmuxTarget ?? null, liveness: age < 15 * 60_000 ? "live" : age < 24 * 3_600_000 ? "idle" : "dead", heartbeatAgeMs: Number.isFinite(age) ? age : null, lastSeen: lastSeen || null };
102
+ return { ...a, transport: t?.transport ?? t?.kind ?? (t ? "tmux-push" : null), pane: t?.tmuxTarget ?? null, reachability: reachOf(age), heartbeatAgeMs: Number.isFinite(age) ? age : null, lastSeen: lastSeen || null, progress: progressOf(output.get(a.agentId), now, scanLines) };
52
103
  }).sort((a, b) => (b.lastSeen ?? 0) - (a.lastSeen ?? 0));
53
104
  }
54
105
  export function rooms(root) {
package/dist/events.js CHANGED
@@ -103,7 +103,7 @@ export class EventEngine {
103
103
  }
104
104
  /** The live coordinator agent for a project/room (role coordinator, project == room or name), else null. */
105
105
  coordinatorFor(project, room) {
106
- const a = agents(this.cfg.coordDir).find((x) => /coordinator/i.test(x.role ?? "") && (x.project === room || x.project === project) && x.liveness !== "dead");
106
+ const a = agents(this.cfg.coordDir).find((x) => /coordinator/i.test(x.role ?? "") && (x.project === room || x.project === project) && x.reachability !== "dead");
107
107
  return a?.agentId ?? null;
108
108
  }
109
109
  emitRaw(type, project, data) {
package/dist/gitinfo.js CHANGED
@@ -139,6 +139,46 @@ async function ghPrs(projectPath) {
139
139
  return null;
140
140
  }
141
141
  }
142
+ /* Which PR a board row is ABOUT, from its `pr` CELL — never from a `#n` appearing anywhere in the
143
+ row. The openPrs extension declares fields ["repo","pr","owner","status","mergeGate"], so the
144
+ answer has a designated column and reading the others is a guess.
145
+
146
+ The old matcher was `Object.values(r).some(v => /#n/.test(v))`, which meant a Status cell reading
147
+ "blocked by #39" made THAT row answer for PR 39 — and `find` returns the first hit, so a mention
148
+ beat the real row. Measured on a two-row fixture: qaOf(39) returned "FAIL — held" from #57's row
149
+ while #39's own row said PASS. A passing PR shown as failing, in the alarming direction.
150
+
151
+ Same defect the kit found in queue-done-loop the same afternoon: a ref MENTIONED in a row is a
152
+ different fact from the ref that IDENTIFIES it. */
153
+ export const prCellNumber = (cell) => {
154
+ const m = /#(\d+)\b/.exec(cell ?? "");
155
+ return m ? Number(m[1]) : null;
156
+ };
157
+ /* `owner/name` from a remote URL, ssh or https. null when there is no origin — in which case the
158
+ repo check below is SKIPPED rather than guessed, and matching falls back to the pr cell alone. */
159
+ export const slugFromRemoteUrl = (url) => {
160
+ const m = url ? /(?:[:/])([^/:]+\/[^/]+?)(?:\.git)?$/.exec(url.trim()) : null;
161
+ return m ? m[1] : null;
162
+ };
163
+ const originSlug = async (projectPath) => slugFromRemoteUrl(await git(projectPath, ["remote", "get-url", "origin"]));
164
+ /* A row's repo, when it states one. Boards legitimately carry several repos — the kit's own board
165
+ lists `kit#11` beside console PRs — so a bare number is ambiguous across them. Only rows that
166
+ name a DIFFERENT repo are rejected; a row with no repo cell is accepted, because absence is not
167
+ a claim. */
168
+ /* The gate a board reports for PR `n` in THIS repo, or undefined. Exported and used by
169
+ gitBranches directly, so the test exercises this function rather than a copy of it — a test that
170
+ re-implements the matcher pins the fixture, not the code. */
171
+ export const gateForPr = (rows, n, slug) => {
172
+ const row = rows?.find((r) => prCellNumber(r.pr) === n && repoMatches(r.repo, slug));
173
+ const qa = row?.mergeGate ?? row?.status;
174
+ return qa ? String(qa) : undefined;
175
+ };
176
+ export const repoMatches = (rowRepo, slug) => {
177
+ const cell = (rowRepo ?? "").trim();
178
+ if (!cell || !slug)
179
+ return true;
180
+ return cell === slug || cell.endsWith(`/${slug.split("/").pop()}`) || slug.endsWith(`/${cell}`) || cell === slug.split("/").pop();
181
+ };
142
182
  export function gitBranches(projectPath, hints = {}) {
143
183
  return cached(`branches:${projectPath}:${(hints.agentIds ?? []).join(",")}`, async () => {
144
184
  const defaultBranch = "main";
@@ -147,8 +187,8 @@ export function gitBranches(projectPath, hints = {}) {
147
187
  const rows = splitRecords(await git(projectPath, ["for-each-ref", "--sort=-committerdate", "--format=%(refname)%1f%(objectname)%1f%(objectname:short)%1f%(subject)%1f%(committerdate:iso-strict)", "refs/heads", "refs/remotes"]))
148
188
  .filter(([ref]) => !/^refs\/remotes\/[^/]+\/HEAD$/.test(ref));
149
189
  const [{ worktrees }, prs] = await Promise.all([gitWorktrees(projectPath, hints), ghPrs(projectPath)]);
150
- const qaOf = (n) => { const row = hints.openPrs?.find((r) => Object.values(r).some((v) => new RegExp(`(^|[^0-9])#${n}([^0-9]|$)`).test(v))); if (!row)
151
- return undefined; const qa = row.mergeGate ?? row.status ?? row.state; return qa ? String(qa) : undefined; };
190
+ const slug = await originSlug(projectPath);
191
+ const qaOf = (n) => gateForPr(hints.openPrs, n, slug);
152
192
  const now = Date.now();
153
193
  const branches = await Promise.all(rows.map(async ([ref, sha, short, subject, date]) => {
154
194
  const remote = ref.startsWith("refs/remotes/");
package/dist/panes.js ADDED
@@ -0,0 +1,86 @@
1
+ /* Which tmux panes this console created.
2
+ *
3
+ * Window reuse used to be decided from `#{pane_title}`: every title in the window
4
+ * had to start with `<project>-`. The console DOES set those titles at spawn — and
5
+ * then the harness overwrites them with the conversation topic, so the check was
6
+ * correct for about a minute after a spawn and wrong forever after. Measured
7
+ * 2026-08-27: all three `groundwork-kit-*` windows on this machine were refused
8
+ * with intact, correct window names, and David hand-spawned three times.
9
+ *
10
+ * The fix is not a better title pattern. `pane_title` is a field ANOTHER PROGRAM
11
+ * owns; any regex over it is a guess about that program's output, and the next
12
+ * harness release changes the answer. This module records the pane ids the console
13
+ * itself created — identity it controls and the harness cannot touch.
14
+ *
15
+ * Pane ids are unique within a tmux server but RESET when the server restarts, so a
16
+ * stale id could collide with an unrelated new pane. The store records the server's
17
+ * pid and is discarded wholesale when it changes; a wrong-server record is worth
18
+ * less than no record.
19
+ */
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import path from "node:path";
23
+ const FILE = path.join(homedir(), ".groundwork-console", "panes.json");
24
+ function load(serverPid) {
25
+ try {
26
+ if (existsSync(FILE)) {
27
+ const j = JSON.parse(readFileSync(FILE, "utf8"));
28
+ // A server restart resets pane ids; keeping the old map would let a recycled
29
+ // id read as ours. Drop it rather than carry a record we cannot trust.
30
+ if (j && j.serverPid === serverPid && j.owned)
31
+ return j;
32
+ }
33
+ }
34
+ catch {
35
+ /* unreadable store: treat as empty — never as "everything is ours" */
36
+ }
37
+ return { serverPid, owned: {} };
38
+ }
39
+ function save(s) {
40
+ try {
41
+ mkdirSync(path.dirname(FILE), { recursive: true });
42
+ writeFileSync(FILE, JSON.stringify(s));
43
+ }
44
+ catch {
45
+ /* disk unavailable: ownership degrades to "unknown", which REFUSES reuse.
46
+ That is the safe direction — it costs a manual spawn, not an adopted window. */
47
+ }
48
+ }
49
+ /** Record a pane this console just created for `project`. */
50
+ export function rememberPane(serverPid, project, paneId) {
51
+ const s = load(serverPid);
52
+ const list = s.owned[project] ?? [];
53
+ if (!list.includes(paneId))
54
+ list.push(paneId);
55
+ s.owned[project] = list;
56
+ save(s);
57
+ }
58
+ /** Forget panes that no longer exist, so the store does not grow without bound. */
59
+ export function prunePanes(serverPid, alive) {
60
+ const s = load(serverPid);
61
+ let changed = false;
62
+ for (const [proj, ids] of Object.entries(s.owned)) {
63
+ const keep = ids.filter((id) => alive.has(id));
64
+ if (keep.length !== ids.length) {
65
+ changed = true;
66
+ if (keep.length)
67
+ s.owned[proj] = keep;
68
+ else
69
+ delete s.owned[proj];
70
+ }
71
+ }
72
+ if (changed)
73
+ save(s);
74
+ }
75
+ export function ownedPanes(serverPid, project) {
76
+ return new Set(load(serverPid).owned[project] ?? []);
77
+ }
78
+ /** Every pane id this console owns, across all projects — used to tell "another
79
+ fleet's pane" (a real collision) from "a pane nobody here created". */
80
+ export function allOwnedPanes(serverPid) {
81
+ const out = new Map();
82
+ for (const [proj, ids] of Object.entries(load(serverPid).owned))
83
+ for (const id of ids)
84
+ out.set(id, proj);
85
+ return out;
86
+ }
package/dist/provision.js CHANGED
@@ -9,6 +9,7 @@ import { execFile } from "node:child_process";
9
9
  import { promisify } from "node:util";
10
10
  import { existsSync, readFileSync } from "node:fs";
11
11
  import path from "node:path";
12
+ import { rememberPane, prunePanes, ownedPanes, allOwnedPanes } from "./panes.js";
12
13
  const run = promisify(execFile);
13
14
  const tmux = async (...args) => (await run("tmux", args, { timeout: 8_000 })).stdout.trim();
14
15
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -18,17 +19,52 @@ export const ROLE_COLOR = { coordinator: "green", aide: "pink", qa: "yellow", ci
18
19
  const CLAUDE_HARNESS_ONLY = new Set(["claude"]); // /color and slash-skills are Claude Code; other harnesses get the skill text as a plain instruction
19
20
  /** Window name for a role: workers share one window, singleton roles get their own (exact scheme `<project>-<role>` / `<project>-workers` — cross-fleet contract, no owner infix). */
20
21
  export const roleWindowName = (project, role) => (role === "worker" ? `${project}-workers` : `${project}-${role}`);
22
+ /** Refusal reason for reusing a window, or null when it is ours.
23
+ *
24
+ * Pure so both directions are testable without tmux — and the second direction is the
25
+ * one that must not regress: a window we do NOT own stays refused. Ownership is pane
26
+ * ids the console created; `#{pane_title}` is deliberately not an input, because the
27
+ * harness rewrites it with the conversation topic and any pattern over it is a guess
28
+ * about another program's output. */
29
+ export function windowOwnership(a) {
30
+ const foreign = a.paneIds.filter((id) => !a.mine.has(id));
31
+ if (!foreign.length)
32
+ return null;
33
+ // Distinguish the two cases: the remedies differ, and the old message prescribed the
34
+ // wrong one — it said "rename or close it" when the window NAME was already correct,
35
+ // sending a reader to rename a correctly-named window.
36
+ const otherFleet = foreign.map((id) => a.everyone.get(id)).find((p) => p && p !== a.project);
37
+ if (otherFleet) {
38
+ return `window "${a.win}" (${a.target}) holds pane(s) this console created for project "${otherFleet}" — a real name collision. Close that window, or spawn under a different project name.`;
39
+ }
40
+ return (`window "${a.win}" (${a.target}) exists but this console did not create pane(s) ${foreign.join(", ")}, so it cannot claim them. ` +
41
+ `The window NAME is correct — do NOT rename it. Close the window (tmux kill-window -t ${a.target}) and respawn, or close just those panes. ` +
42
+ `Ownership is tracked by pane id, not by pane title: a title that reads like a conversation topic is expected and is not the problem.`);
43
+ }
21
44
  async function roleWindow(project, role, startDir, log) {
22
45
  const win = roleWindowName(project, role);
23
46
  const wins = (await tmux("list-windows", "-a", "-F", "#{session_name}:#{window_index}\t#{window_name}")).split("\n").filter(Boolean).map((l) => l.split("\t"));
24
47
  const hit = wins.find(([, name]) => name === win);
25
48
  if (hit) {
26
- // Ownership check before reuse: every pane title in the window must be one of OUR agent ids (`<project>-…`) — pane titles are set at spawn.
27
- // A name-collision with panes we did not title (someone's manual window, another tool) is REFUSED, never adopted (contract amendment 11:46).
28
- const titles = (await tmux("list-panes", "-t", hit[0], "-F", "#{pane_title}")).split("\n").filter(Boolean);
29
- const ours = titles.every((t) => t.startsWith(`${project}-`));
30
- if (!ours)
31
- throw new Error(`window "${win}" (${hit[0]}) exists but its panes are not this fleet's (titles: ${titles.join(", ") || "none"}) rename or close it, then respawn`);
49
+ // Ownership check before reuse. The requirement is unchanged (contract amendment
50
+ // 11:46): a name-collision with panes we did not create is REFUSED, never adopted.
51
+ // What changed is the EVIDENCE. This read `#{pane_title}` and required every title
52
+ // to start with `<project>-`; the console does set those titles at spawn and the
53
+ // harness then overwrites them with the conversation topic, so the check was right
54
+ // for about a minute and wrong afterwardsmeasured 2026-08-27, all three
55
+ // groundwork-kit windows refused with intact window names.
56
+ //
57
+ // Titles are a field another program owns; a better regex over them is a guess
58
+ // about that program's output. Ownership is now the pane ids the console itself
59
+ // created (./panes.ts), which the harness cannot touch.
60
+ const serverPid = await tmux("display-message", "-p", "#{pid}");
61
+ const paneIds = (await tmux("list-panes", "-t", hit[0], "-F", "#{pane_id}")).split("\n").filter(Boolean);
62
+ prunePanes(serverPid, new Set((await tmux("list-panes", "-a", "-F", "#{pane_id}")).split("\n").filter(Boolean)));
63
+ const mine = ownedPanes(serverPid, project);
64
+ const everyone = allOwnedPanes(serverPid);
65
+ const verdict = windowOwnership({ win, target: hit[0], project, paneIds, mine, everyone });
66
+ if (verdict)
67
+ throw new Error(verdict);
32
68
  log(`window ${win} exists (${hit[0]}) — ${role === "worker" ? "splitting another worker pane into it" : `reusing for a second ${role} spawn (split)`}`);
33
69
  return { target: hit[0], freshPane: null };
34
70
  }
@@ -74,7 +110,11 @@ export async function spawnAgent(req, isRegistered, log, isAttached = () => true
74
110
  if (!freshPane)
75
111
  await tmux("select-layout", "-t", target, "tiled");
76
112
  await tmux("select-pane", "-t", paneId, "-T", req.agentId);
77
- log(`pane ${paneId} in ${target} (cwd ${req.repoPath}), title ${req.agentId}`);
113
+ // Record the pane as ours BEFORE anything can fail below: ownership is about who
114
+ // created it, not whether the launch succeeded. A pane we made and then abandoned
115
+ // is still ours to reuse, and forgetting it is what blocks the window forever.
116
+ rememberPane(await tmux("display-message", "-p", "#{pid}"), req.project, paneId);
117
+ log(`pane ${paneId} in ${target} (cwd ${req.repoPath}), title ${req.agentId} (recorded as owned; title is cosmetic)`);
78
118
  const cmd = HARNESS_CMD[req.harness];
79
119
  await tmux("send-keys", "-t", paneId, "-l", cmd);
80
120
  await tmux("send-keys", "-t", paneId, "C-m");
package/dist/seam.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /* Read-only views over a project's seam files, parsed ONLY through @davidbalzan/groundwork-seam. */
2
2
  import { existsSync, readFileSync, readdirSync } from "node:fs";
3
3
  import path from "node:path";
4
- import { parseWorkDoc, queueItemsOf, doneEntriesOf, listWorkBoardOf, workDocIssues, SCHEMA } from "@davidbalzan/groundwork-seam";
4
+ import { parseWorkDoc, queueItemsOf, doneEntriesOf, listWorkBoardOf, workDocIssues, boardSchemaOf, SCHEMA } from "@davidbalzan/groundwork-seam";
5
5
  import { parseFactsDoc } from "@davidbalzan/groundwork-seam";
6
6
  const read = (p) => (existsSync(p) ? readFileSync(p, "utf8") : null);
7
7
  const FACT_STALE_DAYS = 14; // groundwork doctor rule
@@ -13,7 +13,22 @@ export function boardOf(projectPath) {
13
13
  const f = read(path.join(docs, "FACTS.md"));
14
14
  const wsDoc = ws ? parseWorkDoc(ws) : null;
15
15
  const board = wsDoc ? listWorkBoardOf(wsDoc) : [];
16
- const schema = board.find((r) => "schema" in r)?.schema ?? null;
16
+ /* Schema comes from the seam's boardSchemaOf, not from a local re-derivation.
17
+ The console used to work this out itself, and console#35 fixed the first defect in that
18
+ copy: it read the schema off the first parsed ROW, so an empty-but-valid board yielded
19
+ null and rendered "unknown workstreams grammar" — the alarming reading of an empty one.
20
+ The seam now exports the answer, and its version is better than ours on two counts we
21
+ had wrong:
22
+ - it EXCLUDES extension tables by deriving their ids from WORKSTREAMS_EXT, so
23
+ registering a new extension cannot forget to exclude it. Ours allow-listed the two
24
+ board grammars we knew, which meant any FUTURE board schema read as unrecognised.
25
+ - it returns "unknown" only when a header was actually REFUSED, and null when there
26
+ is simply no board block. Ours collapsed those: `schema ?? (ws ? "unknown" : null)`
27
+ reported "unknown grammar" for a WORKSTREAMS.md that merely has no Active Streams
28
+ table — the same conflation console#35 fixed, one case over, still live until now.
29
+ So `null` here means "no board table", NOT "no file"; the caller must not render those
30
+ as the same thing. `files.workstreams` is what distinguishes them. */
31
+ const schema = wsDoc ? boardSchemaOf(wsDoc) : null;
17
32
  const qDoc = q ? parseWorkDoc(q) : null;
18
33
  const dDoc = d ? parseWorkDoc(d) : null;
19
34
  const facts = f ? parseFactsDoc(f) : null;
@@ -24,9 +39,18 @@ export function boardOf(projectPath) {
24
39
  return {
25
40
  files: { workstreams: !!ws, queue: !!q, done: !!d, facts: !!f },
26
41
  prd,
27
- workstreams: { schema: schema ?? (ws ? "unknown" : null), rows: board, issues: wsDoc ? workDocIssues(wsDoc) : [] },
42
+ workstreams: { schema, rows: board, issues: wsDoc ? workDocIssues(wsDoc) : [] },
28
43
  queue: qDoc ? queueItemsOf(qDoc) : [],
29
44
  done: dDoc ? doneEntriesOf(dDoc) : [],
45
+ /* QUEUE.md and DONE.md parse issues. Only WORKSTREAMS.md's were ever surfaced, so a malformed
46
+ queue line was parsed, kept, rendered — and reported NOWHERE. That gap is the console's half
47
+ of seam 0.1.6: the seam stopped DROPPING an out-of-range priority precisely so a consumer
48
+ could say something about it, and this consumer said nothing. `workDocIssues` already carries
49
+ the message (the seam builds it with invalidPriorityIssue), so nothing here re-derives it.
50
+ Siblings rather than a reshape of `queue`/`done`: those are arrays every caller maps over,
51
+ and the gap is a missing signal, not a wrong shape. */
52
+ queueIssues: qDoc ? workDocIssues(qDoc) : [],
53
+ doneIssues: dDoc ? workDocIssues(dDoc) : [],
30
54
  facts: facts
31
55
  ? {
32
56
  entries: facts.entries.map((e) => {
package/dist/seamwrite.js CHANGED
@@ -65,7 +65,11 @@ export function addQueueItem(projectPath, item, log) {
65
65
  log(`wrote docs/QUEUE.md (+1 item, ${out.length - src.length} bytes)`);
66
66
  return { file: "docs/QUEUE.md", line: renderWorkDocLine(newItem), bytesDelta: out.length - src.length };
67
67
  }
68
- const renderWorkDocLine = (q) => `- [ ] (${q.priority}) ${q.text}`;
68
+ /* Display-only echo of the line we appended. `QueueItem.priority` is nullable since seam 0.1.6, and
69
+ interpolating a null renders the literal "(null)" — so fall back to the raw tag the seam kept. The
70
+ console never CREATES such an item (addQueueItem's input is narrowed to P1|P2|P3); this guards the
71
+ echo against a shape the type now permits. */
72
+ const renderWorkDocLine = (q) => `- [${q.done ? "x" : " "}] (${q.priority ?? q.priorityRaw ?? "?"}) ${q.text}`;
69
73
  /** Re-verify (or add) a fact: seam upsertFact with a fresh timestamp. */
70
74
  export function reverifyFact(projectPath, entry, log) {
71
75
  const file = path.join(projectPath, "docs/FACTS.md");
package/dist/server.js CHANGED
@@ -48,7 +48,7 @@ const clientFor = (id) => (id === "david" ? busDavid : bus);
48
48
  /** Capabilities of the connected MCP (null until a client has connected and listed tools). */
49
49
  const mcpCaps = () => { const cl = bus.connected ? bus : busDavid.connected ? busDavid : null; return { inReplyTo: cl ? cl.supports("send_message", "inReplyTo") : null, sendCommand: cl ? cl.sendCommands : null }; };
50
50
  /* Live tmux-attached agents — the only valid send_command targets (the MCP hard-gates to tmux-push/-remote). */
51
- const liveAttached = () => agents(cfg.coordDir).filter((a) => a.liveness === "live" && (a.transport === "tmux-push" || a.transport === "tmux-push-remote")).map((a) => a.agentId);
51
+ const liveAttached = () => agents(cfg.coordDir).filter((a) => a.reachability === "live" && (a.transport === "tmux-push" || a.transport === "tmux-push-remote")).map((a) => a.agentId);
52
52
  const PUBLIC_DIR = path.resolve(HERE, "../public");
53
53
  const root = new Hono();
54
54
  const app = new Hono().basePath("/api");
@@ -286,7 +286,7 @@ app.get("/projects", (c) => {
286
286
  catch {
287
287
  return null;
288
288
  } })();
289
- const liveAgents = agents(cfg.coordDir).filter((a) => a.liveness !== "dead");
289
+ const liveAgents = agents(cfg.coordDir).filter((a) => a.reachability !== "dead");
290
290
  return c.json(cfg.projects.map((p) => {
291
291
  const b = boardOf(p.path);
292
292
  const fleet = fleetStatus(liveAgents.filter((a) => a.project === p.room || a.project === p.name));
@@ -723,7 +723,7 @@ app.get("/projects/:name/fleet", async (c) => {
723
723
  const p = project(c.req.param("name"));
724
724
  if (!p)
725
725
  return c.json({ error: "unknown project" }, 404);
726
- const live = (await agentsAsync(cfg.coordDir)).filter((a) => a.liveness !== "dead" && (a.project === p.room || a.project === p.name));
726
+ const live = (await agentsAsync(cfg.coordDir)).filter((a) => a.reachability !== "dead" && (a.project === p.room || a.project === p.name));
727
727
  return c.json({ ...fleetStatus(live), templates: FLEET_TEMPLATES, window: `fleet-${p.name}` });
728
728
  });
729
729
  const roleOfAgent = (id) => agents(cfg.coordDir).find((a) => a.agentId === id)?.role ?? null;
@@ -788,7 +788,7 @@ app.post("/actions/prune", async (c) => {
788
788
  });
789
789
  app.post("/actions/unregister-dead", async (c) => {
790
790
  const b = (await c.req.json().catch(() => ({})));
791
- const dead = agents(cfg.coordDir).filter((a) => a.liveness === "dead" && !["groundwork-console", "david"].includes(a.agentId)).map((a) => a.agentId);
791
+ const dead = agents(cfg.coordDir).filter((a) => a.reachability === "dead" && !["groundwork-console", "david"].includes(a.agentId)).map((a) => a.agentId);
792
792
  const ids = (b.agentIds?.length ? b.agentIds.filter((id) => dead.includes(id)) : dead);
793
793
  if (b.confirm !== "UNREGISTER")
794
794
  return c.json({ error: "confirm must be UNREGISTER", wouldUnregister: ids }, 400);
@@ -810,7 +810,7 @@ app.post("/actions/unregister", async (c) => {
810
810
  return c.json(r, r.status === "ok" ? 200 : 403);
811
811
  });
812
812
  /* ---- Roadmap actions: message the project's aide (plan) or coordinator (kick off) as david ---- */
813
- const roleAgent = (projectName, room, roleRe) => agents(cfg.coordDir).find((a) => roleRe.test(a.role ?? "") && (a.project === room || a.project === projectName) && a.liveness !== "dead")?.agentId ?? null;
813
+ const roleAgent = (projectName, room, roleRe) => agents(cfg.coordDir).find((a) => roleRe.test(a.role ?? "") && (a.project === room || a.project === projectName) && a.reachability !== "dead")?.agentId ?? null;
814
814
  app.get("/projects/:name/agents", (c) => {
815
815
  const p = project(c.req.param("name"));
816
816
  if (!p)
@@ -855,7 +855,7 @@ app.post("/actions/task/:name/:phase/:line", async (c) => {
855
855
  return c.json({ error: "no task at that line" }, 404);
856
856
  if (task.done)
857
857
  return c.json({ error: "already ticked" }, 409);
858
- const live = agents(cfg.coordDir).filter((a) => a.liveness !== "dead");
858
+ const live = agents(cfg.coordDir).filter((a) => a.reachability !== "dead");
859
859
  const owner = b.owner && live.some((a) => a.agentId === b.owner) ? b.owner : roleAgent(p.name, p.room, /aide|liaison|curator/i);
860
860
  if (!owner)
861
861
  return c.json({ error: `no live owner or aide for ${p.name}` }, 409);
@@ -897,7 +897,7 @@ app.get("/projects/:name/doctor/fixers", async (c) => {
897
897
  const p = project(c.req.param("name"));
898
898
  if (!p)
899
899
  return c.json({ error: "unknown project" }, 404);
900
- const live = (await agentsAsync(cfg.coordDir)).filter((a) => a.liveness !== "dead" && (a.project === p.room || a.project === p.name));
900
+ const live = (await agentsAsync(cfg.coordDir)).filter((a) => a.reachability !== "dead" && (a.project === p.room || a.project === p.name));
901
901
  const out = {};
902
902
  for (const [check, o] of Object.entries(DOCTOR_OWNER)) {
903
903
  const a = live.find((x) => o.re.test(x.role ?? ""));
@@ -913,7 +913,7 @@ app.post("/actions/doctor-fix/:name", async (c) => {
913
913
  const o = DOCTOR_OWNER[b.check];
914
914
  if (!o)
915
915
  return c.json({ error: "no fixer role for this check" }, 400);
916
- const live = (await agentsAsync(cfg.coordDir)).filter((a) => a.liveness !== "dead" && (a.project === p.room || a.project === p.name));
916
+ const live = (await agentsAsync(cfg.coordDir)).filter((a) => a.reachability !== "dead" && (a.project === p.room || a.project === p.name));
917
917
  const target = live.find((x) => o.re.test(x.role ?? ""))?.agentId;
918
918
  if (!target)
919
919
  return c.json({ error: `no live ${o.role} for ${p.name}` }, 409);
@@ -1036,9 +1036,9 @@ app.get("/bus/panes", async (c) => {
1036
1036
  const lines = Number(c.req.query("lines") ?? 40);
1037
1037
  const p = proj ? project(proj) : undefined;
1038
1038
  const all = await agentsAsync(cfg.coordDir);
1039
- const scoped = all.filter((a) => a.pane && a.liveness !== "dead" && (!p || a.project === p.name || (p.room && a.project === p.room)));
1039
+ const scoped = all.filter((a) => a.pane && a.reachability !== "dead" && (!p || a.project === p.name || (p.room && a.project === p.room)));
1040
1040
  const colors = c.req.query("colors") === "1";
1041
- const out = await Promise.all(scoped.map(async (a) => ({ agentId: a.agentId, role: a.role ?? null, project: a.project ?? null, pane: a.pane, windowName: a.windowName ?? null, command: a.paneCommand, active: a.active, paneActivity: a.paneActivity, liveness: a.liveness, capture: await capturePane(a.pane, lines, colors) })));
1041
+ const out = await Promise.all(scoped.map(async (a) => ({ agentId: a.agentId, role: a.role ?? null, project: a.project ?? null, pane: a.pane, windowName: a.windowName ?? null, command: a.paneCommand, active: a.active, paneActivity: a.paneActivity, reachability: a.reachability, capture: await capturePane(a.pane, lines, colors) })));
1042
1042
  return c.json(out);
1043
1043
  });
1044
1044
  /* Pane viewer: read-only capture of an agent's tmux pane (never sends keys). */
package/dist/tmux.js CHANGED
@@ -1,4 +1,4 @@
1
- /* Read-only tmux views: pane activity (for liveness + activity indicators) and pane capture (for the pane viewer). Never sends keys. */
1
+ /* Read-only tmux views: pane activity (for reachability + activity indicators) and pane capture (for the pane viewer). Never sends keys. */
2
2
  import { execFile } from "node:child_process";
3
3
  import { promisify } from "node:util";
4
4
  const run = promisify(execFile);
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@davidbalzan/groundwork-console",
3
3
  "private": false,
4
- "version": "0.1.11",
4
+ "version": "0.1.13",
5
5
  "type": "module",
6
6
  "dependencies": {
7
- "@davidbalzan/groundwork-seam": "^0.1.0",
7
+ "@davidbalzan/groundwork-seam": "0.1.7",
8
8
  "@hono/node-server": "^2.1.1",
9
9
  "@modelcontextprotocol/sdk": "^1.30.0",
10
10
  "hono": "^4.13.2",
@@ -16,7 +16,7 @@
16
16
  "typescript": "^7.0.2"
17
17
  },
18
18
  "description": "Groundwork Console — the cockpit for Groundwork Kit fleets: board, bus, monitor, docs, ops. One local server, token auth, tmux-aware.",
19
- "license": "MIT",
19
+ "license": "UNLICENSED",
20
20
  "repository": {
21
21
  "type": "git",
22
22
  "url": "git+https://github.com/davidbalzan/groundwork-console.git"
@@ -30,7 +30,8 @@
30
30
  "public",
31
31
  "bin",
32
32
  "groundwork-console.rules.json",
33
- "README.md"
33
+ "README.md",
34
+ "LICENSE"
34
35
  ],
35
36
  "engines": {
36
37
  "node": ">=22"
@@ -47,6 +48,11 @@
47
48
  "publish-check": "node scripts/publish-check.mjs",
48
49
  "test:inject": "tsx test/inject.test.mts",
49
50
  "test:skills": "tsx test/skills.test.mts",
50
- "test": "pnpm run test:inject && pnpm run test:skills"
51
+ "test": "pnpm run test:inject && pnpm run test:skills && pnpm run test:board && pnpm run test:panes && pnpm run test:seam-contract && pnpm run test:prref && pnpm run test:progress",
52
+ "test:board": "tsx test/board.test.mts",
53
+ "test:panes": "tsx test/panes.test.mts",
54
+ "test:seam-contract": "tsx test/seam-contract.test.mts",
55
+ "test:prref": "tsx test/prref.test.mts",
56
+ "test:progress": "tsx test/progress.test.mts"
51
57
  }
52
58
  }
@@ -1 +1 @@
1
- import{m as e,t}from"./mermaid-parser.core-C7RowpcN.js";import{n}from"./chunk-Y2CYZVJY-DsF7k-Jl.js";import{m as r}from"./src-rNJ0obFg.js";import"./chunk-I66GZJ75-BlHb3oSv.js";import{n as i,r as a,t as o}from"./chunk-6Q2QTUOP-zawcmdf9.js";import{t as s}from"./chunk-JWPE2WC7-DVXcaiue.js";import"./mermaid.core-U0Q7d-gI.js";var c=e().RailroadAbnf.parser.LangiumParser,l=n(e=>{let t=e.alternatives.map(u);return t.length===1?t[0]:{type:`choice`,alternatives:t}},`transformAlternation`),u=n(e=>{let t=e.elements.map(f);return t.length===1?t[0]:{type:`sequence`,elements:t}},`transformConcatenation`),d=n(e=>{if(e.includes(`*`)){let[t,n]=e.split(`*`);return{min:t?parseInt(t,10):0,max:n?parseInt(n,10):1/0}}let t=parseInt(e,10);return{min:t,max:t}},`parseRepeat`),f=n(e=>{let t=p(e.primary);if(!e.repeat)return t;let{min:n,max:r}=d(e.repeat);return n===0&&r===1?{type:`optional`,element:t}:{type:`repetition`,element:t,min:n,max:r}},`transformElement`),p=n(e=>{switch(e.$type){case`AbnfStringLiteral`:return{type:`terminal`,value:e.value};case`AbnfNumVal`:return{type:`terminal`,value:e.value};case`AbnfRuleName`:return{type:`nonterminal`,name:e.name};case`AbnfGroup`:return l(e.element);case`AbnfOptionalGroup`:return{type:`optional`,element:l(e.element)};default:throw Error(`Unsupported ABNF primary node: ${e.$type}`)}},`transformPrimary`),m=n(e=>({name:e.name,definition:l(e.definition)}),`transformRule`),h=n(e=>{s(e,o),e.title&&o.setTitle(e.title),e.rules.map(e=>o.addRule(m(e)))},`populateDb`),g={parser:{parse:n(e=>{o.clear(),r.debug(`[ABNF Parser] Starting Langium parse`);let n=c.parse(e);if(n.lexerErrors.length>0||n.parserErrors.length>0)throw new t(n);let i=n.value;r.debug(`[ABNF Parser] Parsed rules:`,i.rules.length),h(i),r.debug(`[ABNF Parser] Parse complete`)},`parse`),parser:{yy:o}},db:o,renderer:a,styles:i};export{g as diagram};
1
+ import{m as e,t}from"./mermaid-parser.core-B-yKcreq.js";import{n}from"./chunk-Y2CYZVJY-DsF7k-Jl.js";import{m as r}from"./src-EbLSa_z9.js";import"./chunk-I66GZJ75-XdTCBJhL.js";import{n as i,r as a,t as o}from"./chunk-6Q2QTUOP-BWLeyKxS.js";import{t as s}from"./chunk-JWPE2WC7-DVXcaiue.js";import"./mermaid.core-CW_jKbCL.js";var c=e().RailroadAbnf.parser.LangiumParser,l=n(e=>{let t=e.alternatives.map(u);return t.length===1?t[0]:{type:`choice`,alternatives:t}},`transformAlternation`),u=n(e=>{let t=e.elements.map(f);return t.length===1?t[0]:{type:`sequence`,elements:t}},`transformConcatenation`),d=n(e=>{if(e.includes(`*`)){let[t,n]=e.split(`*`);return{min:t?parseInt(t,10):0,max:n?parseInt(n,10):1/0}}let t=parseInt(e,10);return{min:t,max:t}},`parseRepeat`),f=n(e=>{let t=p(e.primary);if(!e.repeat)return t;let{min:n,max:r}=d(e.repeat);return n===0&&r===1?{type:`optional`,element:t}:{type:`repetition`,element:t,min:n,max:r}},`transformElement`),p=n(e=>{switch(e.$type){case`AbnfStringLiteral`:return{type:`terminal`,value:e.value};case`AbnfNumVal`:return{type:`terminal`,value:e.value};case`AbnfRuleName`:return{type:`nonterminal`,name:e.name};case`AbnfGroup`:return l(e.element);case`AbnfOptionalGroup`:return{type:`optional`,element:l(e.element)};default:throw Error(`Unsupported ABNF primary node: ${e.$type}`)}},`transformPrimary`),m=n(e=>({name:e.name,definition:l(e.definition)}),`transformRule`),h=n(e=>{s(e,o),e.title&&o.setTitle(e.title),e.rules.map(e=>o.addRule(m(e)))},`populateDb`),g={parser:{parse:n(e=>{o.clear(),r.debug(`[ABNF Parser] Starting Langium parse`);let n=c.parse(e);if(n.lexerErrors.length>0||n.parserErrors.length>0)throw new t(n);let i=n.value;r.debug(`[ABNF Parser] Parsed rules:`,i.rules.length),h(i),r.debug(`[ABNF Parser] Parse complete`)},`parse`),parser:{yy:o}},db:o,renderer:a,styles:i};export{g as diagram};
@@ -1 +1 @@
1
- import{n as e,t}from"./path-fybaL0A-.js";import{a as n,c as r,d as i,f as a,i as o,l as s,m as c,n as l,o as u,p as d,r as f,u as p}from"./dist-D9Nkbm8B.js";function m(e){return e.innerRadius}function h(e){return e.outerRadius}function g(e){return e.startAngle}function _(e){return e.endAngle}function v(e){return e&&e.padAngle}function y(e,t,n,r,i,a,o,s){var c=n-e,l=r-t,u=o-i,d=s-a,f=d*c-u*l;if(!(f*f<1e-12))return f=(u*(t-a)-d*(e-i))/f,[e+f*c,t+f*l]}function b(e,t,n,r,i,a,o){var c=e-n,l=t-r,u=(o?a:-a)/d(c*c+l*l),f=u*l,p=-u*c,m=e+f,h=t+p,g=n+f,_=r+p,v=(m+g)/2,y=(h+_)/2,b=g-m,x=_-h,S=b*b+x*x,C=i-a,w=m*_-g*h,T=(x<0?-1:1)*d(s(0,C*C*S-w*w)),E=(w*x-b*T)/S,D=(-w*b-x*T)/S,O=(w*x+b*T)/S,k=(-w*b+x*T)/S,A=E-v,j=D-y,M=O-v,N=k-y;return A*A+j*j>M*M+N*N&&(E=O,D=k),{cx:E,cy:D,x01:-f,y01:-p,x11:E*(i/C-1),y11:D*(i/C-1)}}function x(){var s=m,x=h,S=e(0),C=null,w=g,T=_,E=v,D=null,O=t(k);function k(){var e,t,m=+s.apply(this,arguments),h=+x.apply(this,arguments),g=w.apply(this,arguments)-r,_=T.apply(this,arguments)-r,v=l(_-g),k=_>g;if(D||=e=O(),h<m&&(t=h,h=m,m=t),!(h>1e-12))D.moveTo(0,0);else if(v>c-1e-12)D.moveTo(h*u(g),h*a(g)),D.arc(0,0,h,g,_,!k),m>1e-12&&(D.moveTo(m*u(_),m*a(_)),D.arc(0,0,m,_,g,k));else{var A=g,j=_,M=g,N=_,P=v,F=v,I=E.apply(this,arguments)/2,L=I>1e-12&&(C?+C.apply(this,arguments):d(m*m+h*h)),R=p(l(h-m)/2,+S.apply(this,arguments)),z=R,B=R,V,H;if(L>1e-12){var U=o(L/m*a(I)),W=o(L/h*a(I));(P-=U*2)>1e-12?(U*=k?1:-1,M+=U,N-=U):(P=0,M=N=(g+_)/2),(F-=W*2)>1e-12?(W*=k?1:-1,A+=W,j-=W):(F=0,A=j=(g+_)/2)}var G=h*u(A),K=h*a(A),q=m*u(N),J=m*a(N);if(R>1e-12){var Y=h*u(j),X=h*a(j),Z=m*u(M),Q=m*a(M),$;if(v<i){if($=y(G,K,Z,Q,Y,X,q,J)){var ee=G-$[0],te=K-$[1],ne=Y-$[0],re=X-$[1],ie=1/a(f((ee*ne+te*re)/(d(ee*ee+te*te)*d(ne*ne+re*re)))/2),ae=d($[0]*$[0]+$[1]*$[1]);z=p(R,(m-ae)/(ie-1)),B=p(R,(h-ae)/(ie+1))}else z=B=0}}F>1e-12?B>1e-12?(V=b(Z,Q,G,K,h,B,k),H=b(Y,X,q,J,h,B,k),D.moveTo(V.cx+V.x01,V.cy+V.y01),B<R?D.arc(V.cx,V.cy,B,n(V.y01,V.x01),n(H.y01,H.x01),!k):(D.arc(V.cx,V.cy,B,n(V.y01,V.x01),n(V.y11,V.x11),!k),D.arc(0,0,h,n(V.cy+V.y11,V.cx+V.x11),n(H.cy+H.y11,H.cx+H.x11),!k),D.arc(H.cx,H.cy,B,n(H.y11,H.x11),n(H.y01,H.x01),!k))):(D.moveTo(G,K),D.arc(0,0,h,A,j,!k)):D.moveTo(G,K),!(m>1e-12)||!(P>1e-12)?D.lineTo(q,J):z>1e-12?(V=b(q,J,Y,X,m,-z,k),H=b(G,K,Z,Q,m,-z,k),D.lineTo(V.cx+V.x01,V.cy+V.y01),z<R?D.arc(V.cx,V.cy,z,n(V.y01,V.x01),n(H.y01,H.x01),!k):(D.arc(V.cx,V.cy,z,n(V.y01,V.x01),n(V.y11,V.x11),!k),D.arc(0,0,m,n(V.cy+V.y11,V.cx+V.x11),n(H.cy+H.y11,H.cx+H.x11),k),D.arc(H.cx,H.cy,z,n(H.y11,H.x11),n(H.y01,H.x01),!k))):D.arc(0,0,m,N,M,k)}if(D.closePath(),e)return D=null,e+``||null}return k.centroid=function(){var e=(+s.apply(this,arguments)+ +x.apply(this,arguments))/2,t=(+w.apply(this,arguments)+ +T.apply(this,arguments))/2-i/2;return[u(t)*e,a(t)*e]},k.innerRadius=function(t){return arguments.length?(s=typeof t==`function`?t:e(+t),k):s},k.outerRadius=function(t){return arguments.length?(x=typeof t==`function`?t:e(+t),k):x},k.cornerRadius=function(t){return arguments.length?(S=typeof t==`function`?t:e(+t),k):S},k.padRadius=function(t){return arguments.length?(C=t==null?null:typeof t==`function`?t:e(+t),k):C},k.startAngle=function(t){return arguments.length?(w=typeof t==`function`?t:e(+t),k):w},k.endAngle=function(t){return arguments.length?(T=typeof t==`function`?t:e(+t),k):T},k.padAngle=function(t){return arguments.length?(E=typeof t==`function`?t:e(+t),k):E},k.context=function(e){return arguments.length?(D=e??null,k):D},k}export{x as t};
1
+ import{n as e,t}from"./path-fybaL0A-.js";import{a as n,c as r,d as i,f as a,i as o,l as s,m as c,n as l,o as u,p as d,r as f,u as p}from"./dist-CkCHUIyI.js";function m(e){return e.innerRadius}function h(e){return e.outerRadius}function g(e){return e.startAngle}function _(e){return e.endAngle}function v(e){return e&&e.padAngle}function y(e,t,n,r,i,a,o,s){var c=n-e,l=r-t,u=o-i,d=s-a,f=d*c-u*l;if(!(f*f<1e-12))return f=(u*(t-a)-d*(e-i))/f,[e+f*c,t+f*l]}function b(e,t,n,r,i,a,o){var c=e-n,l=t-r,u=(o?a:-a)/d(c*c+l*l),f=u*l,p=-u*c,m=e+f,h=t+p,g=n+f,_=r+p,v=(m+g)/2,y=(h+_)/2,b=g-m,x=_-h,S=b*b+x*x,C=i-a,w=m*_-g*h,T=(x<0?-1:1)*d(s(0,C*C*S-w*w)),E=(w*x-b*T)/S,D=(-w*b-x*T)/S,O=(w*x+b*T)/S,k=(-w*b+x*T)/S,A=E-v,j=D-y,M=O-v,N=k-y;return A*A+j*j>M*M+N*N&&(E=O,D=k),{cx:E,cy:D,x01:-f,y01:-p,x11:E*(i/C-1),y11:D*(i/C-1)}}function x(){var s=m,x=h,S=e(0),C=null,w=g,T=_,E=v,D=null,O=t(k);function k(){var e,t,m=+s.apply(this,arguments),h=+x.apply(this,arguments),g=w.apply(this,arguments)-r,_=T.apply(this,arguments)-r,v=l(_-g),k=_>g;if(D||=e=O(),h<m&&(t=h,h=m,m=t),!(h>1e-12))D.moveTo(0,0);else if(v>c-1e-12)D.moveTo(h*u(g),h*a(g)),D.arc(0,0,h,g,_,!k),m>1e-12&&(D.moveTo(m*u(_),m*a(_)),D.arc(0,0,m,_,g,k));else{var A=g,j=_,M=g,N=_,P=v,F=v,I=E.apply(this,arguments)/2,L=I>1e-12&&(C?+C.apply(this,arguments):d(m*m+h*h)),R=p(l(h-m)/2,+S.apply(this,arguments)),z=R,B=R,V,H;if(L>1e-12){var U=o(L/m*a(I)),W=o(L/h*a(I));(P-=U*2)>1e-12?(U*=k?1:-1,M+=U,N-=U):(P=0,M=N=(g+_)/2),(F-=W*2)>1e-12?(W*=k?1:-1,A+=W,j-=W):(F=0,A=j=(g+_)/2)}var G=h*u(A),K=h*a(A),q=m*u(N),J=m*a(N);if(R>1e-12){var Y=h*u(j),X=h*a(j),Z=m*u(M),Q=m*a(M),$;if(v<i){if($=y(G,K,Z,Q,Y,X,q,J)){var ee=G-$[0],te=K-$[1],ne=Y-$[0],re=X-$[1],ie=1/a(f((ee*ne+te*re)/(d(ee*ee+te*te)*d(ne*ne+re*re)))/2),ae=d($[0]*$[0]+$[1]*$[1]);z=p(R,(m-ae)/(ie-1)),B=p(R,(h-ae)/(ie+1))}else z=B=0}}F>1e-12?B>1e-12?(V=b(Z,Q,G,K,h,B,k),H=b(Y,X,q,J,h,B,k),D.moveTo(V.cx+V.x01,V.cy+V.y01),B<R?D.arc(V.cx,V.cy,B,n(V.y01,V.x01),n(H.y01,H.x01),!k):(D.arc(V.cx,V.cy,B,n(V.y01,V.x01),n(V.y11,V.x11),!k),D.arc(0,0,h,n(V.cy+V.y11,V.cx+V.x11),n(H.cy+H.y11,H.cx+H.x11),!k),D.arc(H.cx,H.cy,B,n(H.y11,H.x11),n(H.y01,H.x01),!k))):(D.moveTo(G,K),D.arc(0,0,h,A,j,!k)):D.moveTo(G,K),!(m>1e-12)||!(P>1e-12)?D.lineTo(q,J):z>1e-12?(V=b(q,J,Y,X,m,-z,k),H=b(G,K,Z,Q,m,-z,k),D.lineTo(V.cx+V.x01,V.cy+V.y01),z<R?D.arc(V.cx,V.cy,z,n(V.y01,V.x01),n(H.y01,H.x01),!k):(D.arc(V.cx,V.cy,z,n(V.y01,V.x01),n(V.y11,V.x11),!k),D.arc(0,0,m,n(V.cy+V.y11,V.cx+V.x11),n(H.cy+H.y11,H.cx+H.x11),k),D.arc(H.cx,H.cy,z,n(H.y11,H.x11),n(H.y01,H.x01),!k))):D.arc(0,0,m,N,M,k)}if(D.closePath(),e)return D=null,e+``||null}return k.centroid=function(){var e=(+s.apply(this,arguments)+ +x.apply(this,arguments))/2,t=(+w.apply(this,arguments)+ +T.apply(this,arguments))/2-i/2;return[u(t)*e,a(t)*e]},k.innerRadius=function(t){return arguments.length?(s=typeof t==`function`?t:e(+t),k):s},k.outerRadius=function(t){return arguments.length?(x=typeof t==`function`?t:e(+t),k):x},k.cornerRadius=function(t){return arguments.length?(S=typeof t==`function`?t:e(+t),k):S},k.padRadius=function(t){return arguments.length?(C=t==null?null:typeof t==`function`?t:e(+t),k):C},k.startAngle=function(t){return arguments.length?(w=typeof t==`function`?t:e(+t),k):w},k.endAngle=function(t){return arguments.length?(T=typeof t==`function`?t:e(+t),k):T},k.padAngle=function(t){return arguments.length?(E=typeof t==`function`?t:e(+t),k):E},k.context=function(e){return arguments.length?(D=e??null,k):D},k}export{x as t};
@@ -0,0 +1 @@
1
+ import"./chunk-KEIR6QF5-BICK3FdT.js";import{M as e}from"./mermaid-parser.core-B-yKcreq.js";export{e as createArchitectureServices};