maf 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +11 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +411 -0
  5. data/assets/agents-contract.md +80 -0
  6. data/assets/analyst +240 -0
  7. data/assets/coord +2936 -0
  8. data/assets/dashboard +553 -0
  9. data/assets/dashboard.html +341 -0
  10. data/assets/dispatcher +1687 -0
  11. data/assets/doc-graph-refresh +286 -0
  12. data/assets/env.sh +6 -0
  13. data/assets/git-hooks/post-commit +7 -0
  14. data/assets/git-hooks/post-merge +7 -0
  15. data/assets/git-hooks/pre-commit +32 -0
  16. data/assets/harness-hooks/board-watch-opencode.js +87 -0
  17. data/assets/harness-hooks/board-watch.rb +286 -0
  18. data/assets/harness-hooks/context-watch.rb +268 -0
  19. data/assets/harness-hooks/next-task-hermes.sh +48 -0
  20. data/assets/harness-hooks/next-task.rb +97 -0
  21. data/assets/harness-hooks/session-guard.rb +128 -0
  22. data/assets/taskrc.append +11 -0
  23. data/assets/vault +224 -0
  24. data/assets/worktree-env.example.rb +26 -0
  25. data/exe/maf +14 -0
  26. data/install.md +326 -0
  27. data/lib/maf/bootstrap/claude_settings.rb +55 -0
  28. data/lib/maf/bootstrap/dependencies.rb +37 -0
  29. data/lib/maf/bootstrap/git_hook_planner.rb +68 -0
  30. data/lib/maf/bootstrap/global_taskrc_warning.rb +33 -0
  31. data/lib/maf/bootstrap/graph_home.rb +62 -0
  32. data/lib/maf/bootstrap/hook_merger.rb +53 -0
  33. data/lib/maf/bootstrap/installer.rb +66 -0
  34. data/lib/maf/bootstrap/layout_planner.rb +18 -0
  35. data/lib/maf/bootstrap/marked_block.rb +44 -0
  36. data/lib/maf/bootstrap/memory_branch.rb +77 -0
  37. data/lib/maf/bootstrap/options.rb +34 -0
  38. data/lib/maf/bootstrap/project.rb +77 -0
  39. data/lib/maf/bootstrap/script_planner.rb +81 -0
  40. data/lib/maf/bootstrap/text_planner.rb +42 -0
  41. data/lib/maf/bootstrap/vault_starter.rb +41 -0
  42. data/lib/maf/bootstrap/writer.rb +69 -0
  43. data/lib/maf/bootstrap.rb +162 -0
  44. data/lib/maf/budget.rb +59 -0
  45. data/lib/maf/cli.rb +135 -0
  46. data/lib/maf/env_exclude.rb +23 -0
  47. data/lib/maf/flow/agent_links.rb +79 -0
  48. data/lib/maf/flow/bootstrapper.rb +36 -0
  49. data/lib/maf/flow/codex_hooks.rb +50 -0
  50. data/lib/maf/flow/generator.rb +63 -0
  51. data/lib/maf/flow/harness_linker.rb +37 -0
  52. data/lib/maf/flow/hermes_hook.rb +48 -0
  53. data/lib/maf/flow/hermes_hook_setup.rb +69 -0
  54. data/lib/maf/flow/hook_files.rb +16 -0
  55. data/lib/maf/flow/hook_installer.rb +33 -0
  56. data/lib/maf/flow/legacy_codex_hook.rb +71 -0
  57. data/lib/maf/flow/manifest.rb +51 -0
  58. data/lib/maf/flow/mcp_config.rb +72 -0
  59. data/lib/maf/flow/mcp_installer.rb +45 -0
  60. data/lib/maf/flow/models.rb +61 -0
  61. data/lib/maf/flow/options.rb +65 -0
  62. data/lib/maf/flow/prompt_builder.rb +85 -0
  63. data/lib/maf/flow/prompt_text.rb +263 -0
  64. data/lib/maf/flow/report.rb +89 -0
  65. data/lib/maf/flow/role_catalog.rb +40 -0
  66. data/lib/maf/flow/role_files.rb +72 -0
  67. data/lib/maf/flow/role_stub.rb +38 -0
  68. data/lib/maf/flow/roster.rb +28 -0
  69. data/lib/maf/flow/validator.rb +38 -0
  70. data/lib/maf/flow/workflow.rb +28 -0
  71. data/lib/maf/flow.rb +84 -0
  72. data/lib/maf/local_exclude.rb +53 -0
  73. data/lib/maf/menu.rb +101 -0
  74. data/lib/maf/migrate/moves.rb +44 -0
  75. data/lib/maf/migrate/rewrites.rb +53 -0
  76. data/lib/maf/migrate/role_files.rb +35 -0
  77. data/lib/maf/migrate/runner.rb +66 -0
  78. data/lib/maf/migrate/worktrees.rb +65 -0
  79. data/lib/maf/migrate.rb +62 -0
  80. data/lib/maf/prompt.rb +40 -0
  81. data/lib/maf/retire.rb +116 -0
  82. data/lib/maf/role_limits.rb +49 -0
  83. data/lib/maf/setup_agent/args.rb +57 -0
  84. data/lib/maf/setup_agent/dispatch.rb +44 -0
  85. data/lib/maf/setup_agent/hermes_launcher.rb +34 -0
  86. data/lib/maf/setup_agent/hermes_skill.rb +26 -0
  87. data/lib/maf/setup_agent/launcher.rb +85 -0
  88. data/lib/maf/setup_agent/manifest.rb +35 -0
  89. data/lib/maf/setup_agent/project.rb +9 -0
  90. data/lib/maf/setup_agent/role_file.rb +30 -0
  91. data/lib/maf/setup_agent/runtime_hooks.rb +37 -0
  92. data/lib/maf/setup_agent/worktree.rb +50 -0
  93. data/lib/maf/setup_agent.rb +111 -0
  94. data/lib/maf/shared/git_exclude.rb +33 -0
  95. data/lib/maf/shared/git_identity.rb +41 -0
  96. data/lib/maf/shared/peak_rate.rb +20 -0
  97. data/lib/maf/shared/processes.rb +31 -0
  98. data/lib/maf/shared/project.rb +34 -0
  99. data/lib/maf/shared/roles.rb +19 -0
  100. data/lib/maf/team.rb +114 -0
  101. data/lib/maf/team_command.rb +73 -0
  102. data/lib/maf/uninstall/claude_settings.rb +40 -0
  103. data/lib/maf/uninstall/codex_hooks.rb +18 -0
  104. data/lib/maf/uninstall/commit_guard.rb +16 -0
  105. data/lib/maf/uninstall/coordination.rb +15 -0
  106. data/lib/maf/uninstall/doc_graph_hooks.rb +38 -0
  107. data/lib/maf/uninstall/git.rb +13 -0
  108. data/lib/maf/uninstall/local_files.rb +33 -0
  109. data/lib/maf/uninstall/manifest.rb +29 -0
  110. data/lib/maf/uninstall/marked_files.rb +37 -0
  111. data/lib/maf/uninstall/mcp_entries.rb +43 -0
  112. data/lib/maf/uninstall/notes.rb +31 -0
  113. data/lib/maf/uninstall/owned.rb +12 -0
  114. data/lib/maf/uninstall/role_files.rb +51 -0
  115. data/lib/maf/uninstall/runner.rb +67 -0
  116. data/lib/maf/uninstall/scripts.rb +35 -0
  117. data/lib/maf/uninstall/vault_watcher.rb +21 -0
  118. data/lib/maf/uninstall/worktrees.rb +30 -0
  119. data/lib/maf/uninstall.rb +59 -0
  120. data/lib/maf/untrack.rb +90 -0
  121. data/lib/maf/version.rb +5 -0
  122. data/lib/maf/worker_archive.rb +63 -0
  123. data/lib/maf/worker_control.rb +137 -0
  124. data/lib/maf/workers.rb +37 -0
  125. data/lib/maf.rb +5 -0
  126. data/templates/claude.md.erb +16 -0
  127. data/templates/codex.md.erb +7 -0
  128. data/templates/hermes.md.erb +12 -0
  129. data/templates/opencode.md.erb +24 -0
  130. data/templates/role-stub.yml.erb +15 -0
  131. data/templates/roles.yml +289 -0
  132. data/templates/workflows/panel.md +20 -0
  133. data/templates/workflows/plan-review.md +9 -0
  134. data/templates/workflows/simple.md +4 -0
  135. data/templates/workflows/tdd.md +8 -0
  136. metadata +193 -0
@@ -0,0 +1,341 @@
1
+ <!DOCTYPE html>
2
+ <!-- dashboard.html - page of the maf dashboard. The installer copies it next to the dashboard script. -->
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <title>Flow Board</title>
7
+ <style>
8
+ :root{--bg:#0f1117;--surface:#1a1d27;--border:#2a2d3a;--text:#e2e8f0;--muted:#64748b;
9
+ --ok:#22c55e;--warn:#f59e0b;--danger:#ef4444;--info:#3b82f6}
10
+ *{box-sizing:border-box;margin:0;padding:0}
11
+ body{background:var(--bg);color:var(--text);font:13px/1.5 'SF Mono',Consolas,monospace;padding:16px}
12
+ header{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap}
13
+ h1{font-size:16px;font-weight:700}
14
+ .muted{color:var(--muted);font-size:11px}
15
+ #pbtn{margin-left:auto;background:var(--surface);border:1px solid var(--border);
16
+ color:var(--text);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:11px}
17
+ .alerts{display:flex;flex-direction:column;gap:6px;margin-bottom:14px}
18
+ .alert{padding:7px 12px;border-radius:5px;border-left:3px solid var(--danger);
19
+ background:#1f1a1a;font-size:12px}
20
+ .alert.warn{border-color:var(--warn);background:#1f1c16}
21
+ .alert.hint{border-color:var(--info);background:#161b22;display:flex;gap:10px;align-items:center}
22
+ .stats{display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap}
23
+ .stat{background:var(--surface);border:1px solid var(--border);border-radius:7px;
24
+ padding:10px 14px;min-width:90px}
25
+ .sv{font-size:22px;font-weight:700}
26
+ .sl{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em}
27
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));
28
+ gap:10px;margin-bottom:14px}
29
+ .card{background:var(--surface);border:1px solid var(--border);border-radius:7px;padding:11px}
30
+ .card h3{font-size:11px;font-weight:700;margin-bottom:8px;color:var(--muted);
31
+ text-transform:uppercase;letter-spacing:.05em}
32
+ .row{padding:5px 0;border-bottom:1px solid var(--border);font-size:12px}
33
+ .row:last-child{border-bottom:none}
34
+ .meta{color:var(--muted);font-size:11px}
35
+ .b{display:inline-block;padding:1px 5px;border-radius:3px;font-size:10px;font-weight:700;
36
+ text-transform:uppercase}
37
+ .b-ok{background:#052e0f;color:var(--ok)}
38
+ .b-warn{background:#2d1f02;color:var(--warn)}
39
+ .b-danger{background:#2d0909;color:var(--danger)}
40
+ .b-info{background:#0a1929;color:var(--info)}
41
+ .two{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:14px}
42
+ .er{display:flex;gap:8px;padding:4px 0;border-bottom:1px solid var(--border);font-size:12px}
43
+ .er:last-child{border-bottom:none}
44
+ .ets{color:var(--muted);flex-shrink:0;font-size:11px;min-width:48px}
45
+ .ea{font-weight:600;min-width:62px}
46
+ .ac{color:var(--info)}.ad{color:var(--ok)}.au{color:var(--warn)}.am{color:#a78bfa}
47
+ @media(max-width:600px){.two{grid-template-columns:1fr}}
48
+ table{width:100%;border-collapse:collapse;font-size:12px}
49
+ th{text-align:left;color:var(--muted);font-size:10px;text-transform:uppercase;font-weight:600;
50
+ padding:4px 6px;border-bottom:1px solid var(--border)}
51
+ td{padding:6px;border-bottom:1px solid var(--border);vertical-align:top}
52
+ .act{background:var(--bg);border:1px solid var(--border);color:var(--text);padding:2px 8px;
53
+ border-radius:4px;cursor:pointer;font-size:11px;margin:0 2px 2px 0}
54
+ .act:hover{border-color:var(--info)}
55
+ .log{white-space:pre-wrap;color:var(--muted);font-size:10px;max-width:420px}
56
+ </style>
57
+ </head>
58
+ <body>
59
+ <header>
60
+ <h1 id="proj">Flow Board</h1>
61
+ <span class="muted" id="upd">Loading...</span>
62
+ <button id="pbtn" onclick="togglePause()">Pause</button>
63
+ </header>
64
+ <div id="alerts" class="alerts"></div>
65
+ <div id="hints" class="alerts"></div>
66
+ <div id="stats" class="stats"></div>
67
+ <div class="card" style="margin-bottom:10px"><h3>Workers</h3><div id="workers"></div></div>
68
+ <div class="card" style="margin-bottom:10px"><h3>Knowledge graph</h3><div id="graph"></div></div>
69
+ <div id="roles" class="grid"></div>
70
+ <div class="two">
71
+ <div class="card"><h3>Recent Events</h3><div id="events"></div></div>
72
+ <div>
73
+ <div class="card" style="margin-bottom:10px"><h3>Inboxes</h3><div id="inboxes"></div></div>
74
+ <div class="card"><h3>Locks</h3><div id="locks"></div></div>
75
+ </div>
76
+ </div>
77
+ <script>
78
+ let paused = false, INTERVAL = 5, timer = null;
79
+ const TOKEN = '__TOKEN__';
80
+ const $ = id => document.getElementById(id);
81
+ const meta = text => `<div class="meta">${text}</div>`;
82
+ const muted = text => `<div class="muted">${text}</div>`;
83
+
84
+ function k(n) {
85
+ if (n == null) return '-';
86
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
87
+ return n >= 1e3 ? Math.round(n / 1e3) + 'k' : String(n);
88
+ }
89
+ function ago(iso) {
90
+ const s = Math.round((Date.now() - new Date(iso)) / 1000);
91
+ if (s < 60) return s + 's';
92
+ if (s < 3600) return Math.floor(s / 60) + 'm';
93
+ return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
94
+ }
95
+ function esc(s) {
96
+ return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
97
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
98
+ }
99
+ // A value as a JS argument in an onclick attribute. JSON quotes it. esc keeps the attribute closed.
100
+ function arg(v) { return esc(JSON.stringify(v)); }
101
+ function b(t, l) { return `<span class="b b-${l}">${t}</span>`; }
102
+ function ac(a) {
103
+ const classes = { done: 'ad', claim: 'ac', unclaim: 'au', msg: 'am', broadcast: 'am' };
104
+ return classes[a] || '';
105
+ }
106
+ function togglePause() {
107
+ paused = !paused;
108
+ $('pbtn').textContent = paused ? 'Resume' : 'Pause';
109
+ if (!paused) refresh();
110
+ }
111
+
112
+ async function act(id, action, limits) {
113
+ const what = action + ' ' + id + (limits ? ' with ' + limitText(limits) : '');
114
+ const asks = action === 'stop' || action === 'restart';
115
+ if (asks && !confirm(what + '? A running agent finishes its run first.')) return;
116
+ const headers = { 'Content-Type': 'application/json', 'X-Maf-Token': TOKEN };
117
+ const body = JSON.stringify({ worker: id, action: action, limits: limits || {} });
118
+ const r = await fetch('/action', { method: 'POST', headers: headers, body: body })
119
+ .then(r => r.json()).catch(e => ({ error: e.message }));
120
+ if (r.error) alert(r.error);
121
+ refresh(true);
122
+ }
123
+
124
+ function state(w) {
125
+ const s = w.status || {};
126
+ if (!w.live) return b('stopped', 'danger');
127
+ if (!w.dispatch) return b('live', 'ok');
128
+ return s.running ? b('run ' + ago(s.run_started_at), 'info') : b('idle', 'ok');
129
+ }
130
+ function ctx(w) {
131
+ const s = w.status || {};
132
+ if (s.context_tokens == null) return '-';
133
+ const pct = s.context_window ? ' (' + Math.round(100 * s.context_tokens / s.context_window) + '%)' : '';
134
+ const over = s.context_limit && s.context_tokens >= s.context_limit;
135
+ return (over ? b('over limit', 'warn') + ' ' : '') + k(s.context_tokens) + pct;
136
+ }
137
+ // A run that ended with status blocked or needs_review waits for someone else. It did not fail.
138
+ function runBadge(r) {
139
+ if (r.success) return b('ok', 'ok');
140
+ return r.outcome === 'waiting' ? b('waiting', 'warn') : b('failed', 'danger');
141
+ }
142
+ function lastRun(w) {
143
+ const r = (w.status || {}).last_run;
144
+ if (!r) return '-';
145
+ const badge = runBadge(r);
146
+ return badge + ' ' + ago(r.finished_at) + ' ago<div class="log">' + esc(r.detail) + '</div>';
147
+ }
148
+ function buttons(w) {
149
+ if (!w.dispatch && !w.live) return '<div class="log">cd ' + esc(w.dir) + ' &amp;&amp; maf start</div>';
150
+ const acts = w.dispatch ? (w.live ? ['stop', 'restart'] : ['start']) : ['stop'];
151
+ return acts.concat(['analyze'])
152
+ .map(a => `<button class="act" onclick="act(${arg(w.id)}, ${arg(a)})">${a}</button>`).join('');
153
+ }
154
+ function logLines(w) {
155
+ const lines = (w.dispatch ? w.log : []).concat(w.action.length ? ['action: ' + w.action.at(-1)] : []);
156
+ return lines.length ? '<div class="log">' + esc(lines.join('\n')) + '</div>' : '';
157
+ }
158
+ function tokens(w) {
159
+ const u = w.usage || {};
160
+ const hit = u.input_tokens ? Math.round(100 * (u.cached_input_tokens || 0) / u.input_tokens) : 0;
161
+ const rest = `${k(u.cached_input_tokens)} cached (${hit}%) &middot; ${k(u.cache_write_input_tokens)} written`
162
+ + ` &middot; ${k(u.output_tokens)} out`
163
+ + ` &middot; ${u.runs || 0} runs`;
164
+ return k(u.input_tokens) + ' in' + meta(rest);
165
+ }
166
+ function taskCell(w, tasks) {
167
+ const cur = tasks.find(t => t.worker === w.id && t.start);
168
+ return cur ? esc(cur.description) + meta(esc(cur.uuid.slice(0, 8))) : '-';
169
+ }
170
+ function workerRow(w, tasks) {
171
+ const s = w.status || {};
172
+ const runs = s.session_runs != null ? meta('session run ' + (s.session_runs + 1)) : '';
173
+ const cells = [
174
+ `<strong>${esc(w.id)}</strong>` + meta(esc(w.role)),
175
+ esc(w.harness) + meta(w.dispatch ? 'dispatch' : 'interactive'),
176
+ esc(s.model || w.model || 'harness default') + runs,
177
+ state(w) + (w.last_event ? meta('event ' + ago(w.last_event) + ' ago') : ''),
178
+ ctx(w) + (w.handoff_at ? meta('note ' + ago(w.handoff_at) + ' ago') : ''),
179
+ taskCell(w, tasks), lastRun(w), tokens(w), buttons(w) + logLines(w)
180
+ ];
181
+ return '<tr>' + cells.map(c => `<td>${c}</td>`).join('') + '</tr>';
182
+ }
183
+ function renderWorkers(ws, tasks) {
184
+ const names = ['Worker', 'Harness', 'Model', 'State', 'Context', 'Task', 'Last run', 'Tokens', 'Actions'];
185
+ const head = '<tr>' + names.map(n => `<th>${n}</th>`).join('') + '</tr>';
186
+ const rows = ws.map(w => workerRow(w, tasks)).join('');
187
+ $('workers').innerHTML = ws.length ? '<table>' + head + rows + '</table>'
188
+ : muted('no workers yet. Run maf prepare or maf start.');
189
+ }
190
+
191
+ function taskAlerts(d) {
192
+ const t = d.tasks, alerts = [];
193
+ const add = (list, l, text) => { if ((list || []).length) alerts.push({ l: l, m: list.length + text }); };
194
+ add(t.stale, 'danger', ' claimed task(s) with expired lease — likely crashed worker');
195
+ add(t.orphaned, 'danger', ' task(s) assigned to roles nobody is running');
196
+ add(t.escalated, 'danger', ' task(s) escalated to the project manager — waiting for a decision');
197
+ add(t.in_review, 'warn', ' goal pull request(s) wait for your review on GitHub');
198
+ add(t.conflicts, 'warn', ' scope conflict(s) between pending tasks');
199
+ return alerts;
200
+ }
201
+ function otherAlerts(d) {
202
+ const alerts = [];
203
+ Object.entries(d.inboxes || {}).filter(([, msgs]) => msgs.length)
204
+ .forEach(([role, msgs]) => alerts.push({ l: 'warn', m: `${role} inbox: ${msgs.length} unread messages` }));
205
+ (d.locks || []).filter(l => l.stale).forEach(l =>
206
+ alerts.push({ l: 'danger', m: 'Stale lock: ' + l.name + ' held by ' + (l.worker || '?') + ', expired' }));
207
+ return alerts;
208
+ }
209
+ function workerAlerts(ws) {
210
+ const stopped = ws.filter(w => !w.live && w.dispatch)
211
+ .map(w => ({ l: 'danger', m: 'Dispatched worker ' + w.id + ' is stopped' }));
212
+ const run = w => (w.status || {}).last_run || {};
213
+ const failed = ws.filter(w => run(w).success === false && run(w).outcome !== 'waiting')
214
+ .map(w => ({ l: 'warn', m: `Last run of ${w.id} failed: ${(run(w).detail || '').slice(0, 100)}` }));
215
+ return stopped.concat(failed);
216
+ }
217
+ function limitText(limits) {
218
+ return Object.entries(limits).map(([key, value]) => key + '=' + value).join(' ');
219
+ }
220
+ // A hint suggests a better session limit. Its button restarts the worker with that limit.
221
+ // An analysis hint comes from the analyst model (the analyze button). The other hints come from rules.
222
+ function renderHints(hints) {
223
+ const button = h => h.limits
224
+ ? `<button class="act" onclick="act(${arg(h.worker)}, 'restart', ${arg(h.limits)})">`
225
+ + 'restart with ' + esc(limitText(h.limits)) + '</button>'
226
+ : h.action === 'stop' ? `<button class="act" onclick="act(${arg(h.worker)}, 'stop')">stop</button>` : '';
227
+ const tag = h => h.level === 'analysis' ? b('analysis', 'info') + (h.at ? meta(ago(h.at) + ' ago') : '') : '';
228
+ $('hints').innerHTML = (hints || [])
229
+ .map(h => `<div class="alert hint">&#x1F4A1; ${tag(h)}<span>${esc(h.text)}</span>${button(h)}</div>`)
230
+ .join('');
231
+ }
232
+ function renderAlerts(alerts) {
233
+ const html = a => `<div class="alert ${a.l === 'warn' ? 'warn' : ''}">&#x26A0; ${esc(a.m)}</div>`;
234
+ $('alerts').innerHTML = alerts.map(html).join('');
235
+ }
236
+
237
+ function waiting(t, now) { return t.wait && new Date(t.wait) > now; }
238
+ function renderStats(d, tasks, now) {
239
+ const stale = d.tasks.stale || [];
240
+ const stats = [
241
+ { v: tasks.length, l: 'Pending' }, { v: tasks.filter(t => t.start).length, l: 'In Progress', c: 'info' },
242
+ { v: tasks.filter(t => !t.start && !waiting(t, now)).length, l: 'Backlog' },
243
+ { v: tasks.filter(t => waiting(t, now)).length, l: 'Waiting' },
244
+ { v: stale.length, l: 'Stale', c: stale.length ? 'danger' : '' },
245
+ { v: (d.declared_roles || []).length, l: 'Roles' }, { v: (d.workers || []).length, l: 'Workers' }
246
+ ];
247
+ const color = s => s.c ? ` style="color:var(--${s.c})"` : '';
248
+ const html = s => `<div class="stat"><div class="sv"${color(s)}>${s.v}</div>`
249
+ + `<div class="sl">${s.l}</div></div>`;
250
+ $('stats').innerHTML = stats.map(html).join('');
251
+ }
252
+ function renderGraph(g) {
253
+ if (!g) return $('graph').innerHTML = muted('graph age unknown');
254
+ const age = g.commits == null ? 'no age' : g.commits + ' commits since build';
255
+ $('graph').innerHTML = `<div class="row">${b(g.state, g.state === 'fresh' ? 'ok' : 'warn')} `
256
+ + `<span class="meta">${age}</span></div>`;
257
+ }
258
+
259
+ function taskRow(badge, t, detail) {
260
+ return `<div class="row">${badge} <strong>${esc(t.description)}</strong>` + meta(detail) + '</div>';
261
+ }
262
+ function claimedRow(t, staleIds) {
263
+ const badge = staleIds.has(t.uuid) ? b('stale', 'danger') : b('claimed', 'info');
264
+ const scope = t.scope ? ' &middot; ' + esc(t.scope) : '';
265
+ return taskRow(badge, t, `${esc(t.worker || '')} &middot; ${ago(t.start)} ago${scope}`);
266
+ }
267
+ function groupByRole(roles, tasks, now) {
268
+ const byRole = {};
269
+ const group = r => byRole[r] = byRole[r] || { c: [], bl: [], w: [] };
270
+ roles.forEach(group);
271
+ tasks.forEach(t => {
272
+ const g = group(t.role || 'unassigned');
273
+ (waiting(t, now) ? g.w : t.start ? g.c : g.bl).push(t);
274
+ });
275
+ return byRole;
276
+ }
277
+ function renderRoles(d, tasks, now) {
278
+ const roles = d.declared_roles || [];
279
+ const staleIds = new Set((d.tasks.stale || []).map(t => t.uuid));
280
+ $('roles').innerHTML = Object.entries(groupByRole(roles, tasks, now)).map(([role, g]) => {
281
+ const orphan = roles.length > 0 && role !== 'goal' && !roles.includes(role);
282
+ const rows = g.c.map(t => claimedRow(t, staleIds))
283
+ .concat(g.bl.map(t => taskRow(b('backlog', 'ok'), t, esc(t.scope || ''))))
284
+ .concat(g.w.map(t => taskRow(b('waiting', 'warn'), t, esc(t.scope || ''))));
285
+ return `<div class="card"><h3>${esc(role)}${orphan ? ' ' + b('no runner', 'danger') : ''}</h3>`
286
+ + (rows.join('') || muted('no tasks')) + '</div>';
287
+ }).join('');
288
+ }
289
+
290
+ function renderEvents(events) {
291
+ const row = e => `<div class="er"><span class="ets">${ago(e.ts)} ago</span>`
292
+ + `<span class="ea ${ac(e.action)}">${esc(e.action)}</span>`
293
+ + `<span>${esc(e.worker)} ${e.detail ? '&mdash; ' + esc(e.detail.slice(0, 55)) : ''}</span></div>`;
294
+ const recent = (events || []).slice().reverse().slice(0, 25);
295
+ $('events').innerHTML = recent.map(row).join('') || muted('no events yet');
296
+ }
297
+ function renderInboxes(inboxes) {
298
+ const message = m => `<div class="row" style="padding-left:10px">`
299
+ + `<span class="meta">${esc(m.from || '?')}${m.at ? ' &middot; ' + ago(m.at) + ' ago' : ''}</span>`
300
+ + `<div>${esc(m.preview)}</div></div>`;
301
+ const box = ([role, msgs]) => `<div class="row"><span>${esc(role)}</span> `
302
+ + `${msgs.length ? b(msgs.length + ' unread', 'warn') : b('clear', 'ok')}</div>`
303
+ + msgs.map(message).join('');
304
+ const list = Object.entries(inboxes || {});
305
+ $('inboxes').innerHTML = list.length ? list.map(box).join('') : muted('no inboxes');
306
+ }
307
+ function renderLocks(locks) {
308
+ const badge = l => l.stale ? b('stale', 'danger') : b('held', 'info');
309
+ const row = l => `<div class="row"><span>${esc(l.name)}</span> ${badge(l)} `
310
+ + `<span class="meta" style="margin-left:4px">${esc(l.worker || '')} &middot; `
311
+ + `expires ${ago(l.expires_at)}</span></div>`;
312
+ $('locks').innerHTML = (locks || []).length ? locks.map(row).join('') : muted('no active locks');
313
+ }
314
+
315
+ function render(d) {
316
+ INTERVAL = d.refresh_interval || 5;
317
+ $('proj').textContent = d.project + ' — Flow Board';
318
+ $('upd').textContent = 'Updated ' + ago(d.generated_at) + ' | lease ' + Math.round(d.lease_ttl / 3600) + 'h';
319
+ const tasks = d.tasks.all || [], now = new Date(), ws = d.workers || [];
320
+ renderAlerts(taskAlerts(d).concat(otherAlerts(d), workerAlerts(ws)));
321
+ renderHints(d.hints);
322
+ renderStats(d, tasks, now);
323
+ renderWorkers(ws, tasks);
324
+ renderGraph(d.graph);
325
+ renderRoles(d, tasks, now);
326
+ renderEvents(d.events);
327
+ renderInboxes(d.inboxes);
328
+ renderLocks(d.locks);
329
+ }
330
+
331
+ async function refresh(now) {
332
+ if (paused && !now) return;
333
+ clearTimeout(timer);
334
+ try { render(await fetch('/data').then(r => r.json())); }
335
+ catch (e) { $('upd').textContent = 'Error: ' + e.message; }
336
+ timer = setTimeout(refresh, INTERVAL * 1000);
337
+ }
338
+ refresh();
339
+ </script>
340
+ </body>
341
+ </html>