pan-wizard 3.10.0 → 3.12.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.
- package/README.md +76 -8
- package/agents/pan-conductor.md +14 -1
- package/agents/pan-release.md +58 -0
- package/assets/pan-avatar.png +0 -0
- package/assets/pan-developer.png +0 -0
- package/assets/pan-docs-header.png +0 -0
- package/assets/pan-hero.png +0 -0
- package/assets/pan-logo-2000-transparent.svg +11 -30
- package/assets/pan-logo-2000.svg +12 -43
- package/assets/pan-logo-lockup.svg +11 -0
- package/assets/pan-mark.svg +7 -0
- package/assets/pan-orchestration.png +0 -0
- package/assets/pan-readme-hero.png +0 -0
- package/assets/terminal.svg +39 -119
- package/commands/pan/army.md +169 -0
- package/commands/pan/dashboard.md +25 -0
- package/commands/pan/focus-auto.md +32 -4
- package/commands/pan/hud.md +91 -0
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/campaign.cjs +198 -0
- package/pan-wizard-core/bin/lib/constants.cjs +8 -0
- package/pan-wizard-core/bin/lib/core.cjs +11 -0
- package/pan-wizard-core/bin/lib/focus.cjs +13 -1
- package/pan-wizard-core/bin/lib/hud.cjs +887 -0
- package/pan-wizard-core/bin/lib/squads.cjs +152 -0
- package/pan-wizard-core/bin/lib/worktree.cjs +123 -0
- package/pan-wizard-core/bin/pan-tools.cjs +68 -0
- package/pan-wizard-core/learnings/universal/autonomous-loop.md +56 -0
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HUD — single-page HTML dashboard of the bot army + project (ADR-0035).
|
|
3
|
+
*
|
|
4
|
+
* Aggregates state that already lives in PAN (state.md, roadmap/phases, the
|
|
5
|
+
* squad registry, the campaign schedule, army worktrees, the cost ledger,
|
|
6
|
+
* requirements, verification, git history) into ONE self-contained HTML file
|
|
7
|
+
* — no server, no network, no external CSS/JS. This module is a *view*: it
|
|
8
|
+
* owns no state and writes only the rendered file, so it can never corrupt
|
|
9
|
+
* planning data.
|
|
10
|
+
*
|
|
11
|
+
* Graceful degradation: army-only panels (command stack, campaign, harness,
|
|
12
|
+
* worktrees) render only when a campaign is scheduled or army worktrees
|
|
13
|
+
* exist. A plain PAN project still gets mission, roadmap, telemetry,
|
|
14
|
+
* requirements/quality and activity panels.
|
|
15
|
+
*
|
|
16
|
+
* collectHudData() and renderHud() are pure given their inputs (a `now` Date
|
|
17
|
+
* is injected for testability); cmdHud() is the only side-effecting wrapper.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const {
|
|
25
|
+
output, error, loadConfig, safeReadFile, getMilestoneInfo,
|
|
26
|
+
execGit, isGitRepo, escapeRegex, toPosix,
|
|
27
|
+
} = require('./core.cjs');
|
|
28
|
+
const {
|
|
29
|
+
PLANNING_DIR, STATE_FILE, PROJECT_FILE, REQUIREMENTS_FILE, PAUSE_FILE,
|
|
30
|
+
} = require('./constants.cjs');
|
|
31
|
+
const {
|
|
32
|
+
planningPath, phasesPath, listPhaseDirs, parsePhaseDir,
|
|
33
|
+
classifyPhaseStatus, filterPlanFiles, filterSummaryFiles,
|
|
34
|
+
} = require('./utils.cjs');
|
|
35
|
+
const squads = require('./squads.cjs');
|
|
36
|
+
const campaign = require('./campaign.cjs');
|
|
37
|
+
const worktree = require('./worktree.cjs');
|
|
38
|
+
const cost = require('./cost.cjs');
|
|
39
|
+
|
|
40
|
+
const HUD_FILE = 'hud.html';
|
|
41
|
+
|
|
42
|
+
// ─── small parsing helpers ────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/** First non-empty line of a `## Heading` section, or null. */
|
|
45
|
+
function sectionFirstLine(content, heading) {
|
|
46
|
+
if (!content) return null;
|
|
47
|
+
const re = new RegExp('#{2,3}\\s*' + escapeRegex(heading) + '\\s*\\n([\\s\\S]*?)(?=\\n#{2,3}\\s|$)', 'i');
|
|
48
|
+
const m = content.match(re);
|
|
49
|
+
if (!m) return null;
|
|
50
|
+
const line = m[1].split('\n').map(s => s.trim()).filter(Boolean)[0];
|
|
51
|
+
return line ? line.replace(/^[-*]\s+/, '') : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Extract a `**Field:** value` line from state.md content. */
|
|
55
|
+
function stateField(content, label) {
|
|
56
|
+
if (!content) return null;
|
|
57
|
+
const re = new RegExp('\\*\\*' + escapeRegex(label) + ':\\*\\*\\s*(.+)', 'i');
|
|
58
|
+
const m = content.match(re);
|
|
59
|
+
return m ? m[1].trim() : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseBlockers(content) {
|
|
63
|
+
if (!content) return [];
|
|
64
|
+
const block = content.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i);
|
|
65
|
+
if (!block) return [];
|
|
66
|
+
const items = block[1].match(/^-\s+(.+)$/gm) || [];
|
|
67
|
+
return items
|
|
68
|
+
.map(i => i.replace(/^-\s+/, '').trim())
|
|
69
|
+
.filter(t => t && !/^none$/i.test(t));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── data collectors ──────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
function scanPhases(cwd) {
|
|
75
|
+
const dirs = listPhaseDirs(cwd);
|
|
76
|
+
const phases = [];
|
|
77
|
+
let plansTotal = 0;
|
|
78
|
+
let plansDone = 0;
|
|
79
|
+
for (const dir of dirs) {
|
|
80
|
+
const { number, name } = parsePhaseDir(dir);
|
|
81
|
+
let files = [];
|
|
82
|
+
try { files = fs.readdirSync(path.join(phasesPath(cwd), dir)); } catch { /* gone */ }
|
|
83
|
+
const plans = filterPlanFiles(files);
|
|
84
|
+
const summaries = filterSummaryFiles(files);
|
|
85
|
+
const hasResearch = files.some(f => f.endsWith('-research.md') || f === 'research.md');
|
|
86
|
+
const hasContext = files.some(f => f.endsWith('-context.md') || f === 'context.md');
|
|
87
|
+
const status = classifyPhaseStatus(plans.length, summaries.length, { hasResearch, hasContext });
|
|
88
|
+
plansTotal += plans.length;
|
|
89
|
+
plansDone += Math.min(summaries.length, plans.length);
|
|
90
|
+
phases.push({
|
|
91
|
+
number,
|
|
92
|
+
name: name ? name.replace(/-/g, ' ') : null,
|
|
93
|
+
status,
|
|
94
|
+
plans: plans.length,
|
|
95
|
+
summaries: summaries.length,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const completed = phases.filter(p => p.status === 'complete').length;
|
|
99
|
+
const percent = phases.length ? Math.round((completed / phases.length) * 100) : null;
|
|
100
|
+
return { phases, completed, total: phases.length, plansTotal, plansDone, percent };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function scanRequirements(cwd) {
|
|
104
|
+
const content = safeReadFile(path.join(planningPath(cwd), REQUIREMENTS_FILE));
|
|
105
|
+
if (!content) return null;
|
|
106
|
+
const checked = (content.match(/^\s*[-*]\s+\[x\]/gim) || []).length;
|
|
107
|
+
const unchecked = (content.match(/^\s*[-*]\s+\[ \]/gim) || []).length;
|
|
108
|
+
const total = checked + unchecked;
|
|
109
|
+
if (total === 0) return null;
|
|
110
|
+
const open = (content.match(/^\s*[-*]\s+\[ \]\s+(.+)$/gim) || [])
|
|
111
|
+
.map(l => l.replace(/^\s*[-*]\s+\[ \]\s+/, '').trim())
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
.slice(0, 6);
|
|
114
|
+
return { total, done: checked, open };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function scanVerification(cwd) {
|
|
118
|
+
// listPhaseDirs is sorted ascending — last hit wins (latest phase).
|
|
119
|
+
let found = null;
|
|
120
|
+
for (const dir of listPhaseDirs(cwd)) {
|
|
121
|
+
let files = [];
|
|
122
|
+
try { files = fs.readdirSync(path.join(phasesPath(cwd), dir)); } catch { /* gone */ }
|
|
123
|
+
const vf = files.find(f => f.endsWith('-verification.md') || f === 'verification.md');
|
|
124
|
+
const uf = files.find(f => f.endsWith('-uat.md') || f === 'uat.md');
|
|
125
|
+
if (vf || uf) {
|
|
126
|
+
const { number, name } = parsePhaseDir(dir);
|
|
127
|
+
found = {
|
|
128
|
+
phase: number,
|
|
129
|
+
phase_name: name ? name.replace(/-/g, ' ') : null,
|
|
130
|
+
verification: !!vf,
|
|
131
|
+
uat: !!uf,
|
|
132
|
+
file: vf || uf,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return found;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function recentCommits(cwd, limit) {
|
|
140
|
+
if (!isGitRepo(cwd)) return [];
|
|
141
|
+
const r = execGit(cwd, ['log', '-n', String(limit || 8), '--pretty=%h\x1f%s\x1f%cr']);
|
|
142
|
+
if (r.exitCode !== 0) return [];
|
|
143
|
+
return r.stdout.split(/\r?\n/).filter(Boolean).map(line => {
|
|
144
|
+
const [hash, subject, when] = line.split('\x1f');
|
|
145
|
+
return { hash, subject, when };
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function buildArmy(agg, trees) {
|
|
150
|
+
const byAgent = agg.by_agent || {};
|
|
151
|
+
const squadList = squads.listSquads().map(s => {
|
|
152
|
+
const full = squads.getSquad(s.name);
|
|
153
|
+
const agents = full.agents.map(a => {
|
|
154
|
+
const rec = byAgent[a];
|
|
155
|
+
return {
|
|
156
|
+
name: a,
|
|
157
|
+
active: !!rec,
|
|
158
|
+
calls: rec ? rec.calls : 0,
|
|
159
|
+
tokens: rec ? (rec.input + rec.output) : 0,
|
|
160
|
+
cost: rec ? Math.round((rec.cost || 0) * 10000) / 10000 : 0,
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
let activeCount = agents.filter(a => a.active).length;
|
|
164
|
+
if (s.name === 'build' && trees.length) activeCount = Math.max(activeCount, trees.length);
|
|
165
|
+
return { ...s, agents, active_count: activeCount };
|
|
166
|
+
});
|
|
167
|
+
return { coordinator: squads.COORDINATOR, workers: [...squads.WORKERS], squads: squadList };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function telemetryBySquad(agg) {
|
|
171
|
+
const out = {};
|
|
172
|
+
const byAgent = agg.by_agent || {};
|
|
173
|
+
for (const a of Object.keys(byAgent)) {
|
|
174
|
+
const sq = squads.squadForAgent(a)
|
|
175
|
+
|| (a === squads.COORDINATOR ? 'command' : (squads.WORKERS.includes(a) ? 'workers' : 'other'));
|
|
176
|
+
if (!out[sq]) out[sq] = { cost: 0, tokens: 0, calls: 0 };
|
|
177
|
+
out[sq].cost += byAgent[a].cost || 0;
|
|
178
|
+
out[sq].tokens += (byAgent[a].input || 0) + (byAgent[a].output || 0);
|
|
179
|
+
out[sq].calls += byAgent[a].calls || 0;
|
|
180
|
+
}
|
|
181
|
+
for (const k of Object.keys(out)) out[k].cost = Math.round(out[k].cost * 10000) / 10000;
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Collect every signal the HUD renders into one plain data object.
|
|
187
|
+
* @param {string} cwd
|
|
188
|
+
* @param {{now?: Date}} [opts]
|
|
189
|
+
*/
|
|
190
|
+
function collectHudData(cwd, opts = {}) {
|
|
191
|
+
const now = opts.now || new Date();
|
|
192
|
+
const config = loadConfig(cwd) || {};
|
|
193
|
+
|
|
194
|
+
// project identity
|
|
195
|
+
let name = null;
|
|
196
|
+
let version = null;
|
|
197
|
+
try {
|
|
198
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8'));
|
|
199
|
+
name = pkg.name || null;
|
|
200
|
+
version = pkg.version || null;
|
|
201
|
+
} catch { /* no package.json */ }
|
|
202
|
+
let milestone = null;
|
|
203
|
+
try { milestone = getMilestoneInfo(cwd); } catch { /* none */ }
|
|
204
|
+
const projectMd = safeReadFile(path.join(planningPath(cwd), PROJECT_FILE));
|
|
205
|
+
const coreValue = sectionFirstLine(projectMd, 'Core Value') || sectionFirstLine(projectMd, 'What This Is');
|
|
206
|
+
|
|
207
|
+
// state
|
|
208
|
+
const stateContent = safeReadFile(path.join(planningPath(cwd), STATE_FILE));
|
|
209
|
+
const state = {
|
|
210
|
+
current_phase: stateField(stateContent, 'Current Phase'),
|
|
211
|
+
current_phase_name: stateField(stateContent, 'Current Phase Name'),
|
|
212
|
+
status: stateField(stateContent, 'Status'),
|
|
213
|
+
last_activity: stateField(stateContent, 'Last Activity'),
|
|
214
|
+
last_activity_desc: stateField(stateContent, 'Last Activity Description'),
|
|
215
|
+
blockers: parseBlockers(stateContent),
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// progress / roadmap
|
|
219
|
+
const phaseScan = scanPhases(cwd);
|
|
220
|
+
|
|
221
|
+
// army
|
|
222
|
+
const schedule = campaign.readSchedule(cwd);
|
|
223
|
+
const due = campaign.isRunDue(schedule, now);
|
|
224
|
+
const trees = worktree.listArmyWorktrees(cwd);
|
|
225
|
+
const armyActive = !!schedule || trees.length > 0;
|
|
226
|
+
const agg = cost.aggregate(cwd);
|
|
227
|
+
const army = buildArmy(agg, trees);
|
|
228
|
+
|
|
229
|
+
// campaign
|
|
230
|
+
let campaignData = null;
|
|
231
|
+
if (schedule) {
|
|
232
|
+
campaignData = {
|
|
233
|
+
enabled: schedule.enabled,
|
|
234
|
+
paused: schedule.paused,
|
|
235
|
+
cadence: schedule.cadence,
|
|
236
|
+
daily_budget: schedule.daily_budget,
|
|
237
|
+
next_due: schedule.next_due,
|
|
238
|
+
last_run: schedule.last_run,
|
|
239
|
+
spent_today: due.spent_today,
|
|
240
|
+
due: due.due,
|
|
241
|
+
reason: due.reason,
|
|
242
|
+
goal: schedule.goal || null,
|
|
243
|
+
source: schedule.source || null,
|
|
244
|
+
runs: (schedule.history || []).length,
|
|
245
|
+
history: (schedule.history || []).slice(-7),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// safety harness
|
|
250
|
+
const paused = !!safeReadFile(path.join(planningPath(cwd), PAUSE_FILE));
|
|
251
|
+
const harness = {
|
|
252
|
+
merge_gate: (config.build && config.build.merge_gate) || 'always-ask',
|
|
253
|
+
abort: paused ? 'paused' : 'clear',
|
|
254
|
+
active_worktrees: trees.length,
|
|
255
|
+
daily_budget: schedule ? schedule.daily_budget : null,
|
|
256
|
+
spent_today: schedule ? due.spent_today : null,
|
|
257
|
+
concurrency: config.concurrency || null,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// telemetry
|
|
261
|
+
const telemetry = {
|
|
262
|
+
totals: agg.totals,
|
|
263
|
+
cache_hit_rate_pct: agg.cache_hit_rate_pct,
|
|
264
|
+
by_squad: telemetryBySquad(agg),
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
generated_at: now.toISOString(),
|
|
269
|
+
army_active: armyActive,
|
|
270
|
+
project: { name, version, milestone: milestone ? { version: milestone.version, name: milestone.name } : null, core_value: coreValue },
|
|
271
|
+
state,
|
|
272
|
+
progress: phaseScan,
|
|
273
|
+
army,
|
|
274
|
+
campaign: campaignData,
|
|
275
|
+
harness,
|
|
276
|
+
worktrees: trees,
|
|
277
|
+
roadmap: phaseScan.phases,
|
|
278
|
+
telemetry,
|
|
279
|
+
requirements: scanRequirements(cwd),
|
|
280
|
+
quality: scanVerification(cwd),
|
|
281
|
+
activity: recentCommits(cwd, 8),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ─── rendering ────────────────────────────────────────────────────────────────
|
|
286
|
+
//
|
|
287
|
+
// Faithful to the PanWizard HUD design (docs/branding/PanWizard HUD.dc.html):
|
|
288
|
+
// light Sand/Paper page, a dark "now building" hero card, per-squad colored
|
|
289
|
+
// command stack, inline metric bars, spend-by-squad bars. Self-contained — no
|
|
290
|
+
// network fonts, no <script>, no external assets. Renderers are pure; every
|
|
291
|
+
// value that comes from project state is HTML-escaped.
|
|
292
|
+
|
|
293
|
+
function esc(s) {
|
|
294
|
+
return String(s == null ? '' : s).replace(/[&<>"']/g, c => (
|
|
295
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
|
296
|
+
));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Each squad reads as a distinct brand color (not by model tier).
|
|
300
|
+
const SQUAD_COLOR = {
|
|
301
|
+
architecture: '#5B4BE6', build: '#FF5A3C', quality: '#1E8E5A', release: '#FFCE4A',
|
|
302
|
+
};
|
|
303
|
+
const SQUAD_PILL = { architecture: 'info', build: 'danger', quality: 'ok', release: 'warn' };
|
|
304
|
+
|
|
305
|
+
// Roadmap status → pill kind.
|
|
306
|
+
const STATUS_KIND = {
|
|
307
|
+
complete: 'ok', partial: 'info', planned: 'muted',
|
|
308
|
+
researched: 'muted', discussed: 'muted', empty: 'muted',
|
|
309
|
+
};
|
|
310
|
+
const STATUS_DOT = {
|
|
311
|
+
complete: '#1E8E5A', partial: '#5B4BE6', planned: '#C9C0AE',
|
|
312
|
+
researched: '#C9C0AE', discussed: '#C9C0AE', empty: '#C9C0AE',
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// PanWizard node-graph mark + a checkmark glyph (inline, no xmlns → still
|
|
316
|
+
// self-contained: the HTML parser places them in the SVG namespace).
|
|
317
|
+
const MARK_SVG = '<svg class="mark" viewBox="0 0 100 100" aria-hidden="true">'
|
|
318
|
+
+ '<line x1="50" y1="26" x2="26" y2="74" stroke="#5B4BE6" stroke-width="7" stroke-linecap="round"/>'
|
|
319
|
+
+ '<line x1="50" y1="26" x2="74" y2="74" stroke="#5B4BE6" stroke-width="7" stroke-linecap="round"/>'
|
|
320
|
+
+ '<circle cx="50" cy="26" r="13" fill="#FF5A3C"/>'
|
|
321
|
+
+ '<circle cx="26" cy="74" r="11" fill="#FFCE4A"/>'
|
|
322
|
+
+ '<circle cx="74" cy="74" r="11" fill="#1E8E5A"/></svg>';
|
|
323
|
+
const CHECK_SVG = '<svg class="ck" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3.4" aria-hidden="true">'
|
|
324
|
+
+ '<path d="M5 12l5 5L19 7" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
325
|
+
|
|
326
|
+
function pill(text, kind) {
|
|
327
|
+
return `<span class="pill ${kind || 'muted'}">${esc(text)}</span>`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function bar(pct, color) {
|
|
331
|
+
const w = Math.max(0, Math.min(100, Number(pct) || 0));
|
|
332
|
+
return `<div class="bar"><span style="width:${w}%;background:${color || 'var(--coral)'}"></span></div>`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function fmtUsd(n) {
|
|
336
|
+
return '$' + (Number(n) || 0).toFixed(2);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function fmtTokens(n) {
|
|
340
|
+
const v = Number(n) || 0;
|
|
341
|
+
if (v >= 1e6) return (v / 1e6).toFixed(1) + 'M';
|
|
342
|
+
if (v >= 1e3) return (v / 1e3).toFixed(1) + 'k';
|
|
343
|
+
return String(v);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function relDue(nextIso, nowIso) {
|
|
347
|
+
const a = new Date(nextIso).getTime();
|
|
348
|
+
const b = new Date(nowIso).getTime();
|
|
349
|
+
if (isNaN(a) || isNaN(b)) return esc(nextIso || '—');
|
|
350
|
+
const diff = a - b;
|
|
351
|
+
if (diff <= 0) return 'due now';
|
|
352
|
+
const h = Math.floor(diff / 3600000);
|
|
353
|
+
const m = Math.floor((diff % 3600000) / 60000);
|
|
354
|
+
if (h >= 24) return `in ${Math.floor(h / 24)}d ${h % 24}h`;
|
|
355
|
+
return `in ${h}h ${m}m`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function metricCard(o) {
|
|
359
|
+
const num = `<div class="mnum">${esc(o.value)}${o.unit ? `<span class="munit">${esc(o.unit)}</span>` : ''}</div>`;
|
|
360
|
+
const b = o.barPct != null
|
|
361
|
+
? `<div class="mbar"><span style="width:${Math.max(0, Math.min(100, o.barPct))}%;background:${o.barColor || 'var(--coral)'}"></span></div>`
|
|
362
|
+
: '';
|
|
363
|
+
return `<div class="metric"><div class="mlabel">${esc(o.label)}</div>${num}${b}<div class="msub">${esc(o.sub || '')}</div></div>`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function currentPhase(d) {
|
|
367
|
+
const curNum = d.state.current_phase
|
|
368
|
+
|| (d.roadmap.find(p => p.status !== 'complete') || {}).number;
|
|
369
|
+
return d.roadmap.find(p => String(p.number) === String(curNum)) || d.roadmap[0] || null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Map a phase's on-disk status to its position in the pan pipeline.
|
|
373
|
+
function pipelineStage(p) {
|
|
374
|
+
if (!p) return 'queued';
|
|
375
|
+
if (p.status === 'complete') return 'verify';
|
|
376
|
+
if (p.status === 'partial') return 'execute';
|
|
377
|
+
if (p.status === 'researched') return 'plan';
|
|
378
|
+
if (p.status === 'discussed') return 'research';
|
|
379
|
+
return 'queued';
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function renderTopBar(d) {
|
|
383
|
+
const right = [
|
|
384
|
+
d.army_active ? '<span class="tb-live"><span class="ld"></span>campaign active</span>' : '',
|
|
385
|
+
'<span>pan-tools hud</span>',
|
|
386
|
+
'<span class="sep">·</span>',
|
|
387
|
+
`<span>${esc(new Date(d.generated_at).toLocaleString())}</span>`,
|
|
388
|
+
].filter(Boolean).join('');
|
|
389
|
+
return `
|
|
390
|
+
<div class="topbar">
|
|
391
|
+
<div class="tb-brand">${MARK_SVG}<span class="tb-word"><span class="c">Pan</span>Wizard <span class="hud">HUD</span></span></div>
|
|
392
|
+
<div class="tb-meta">${right}</div>
|
|
393
|
+
</div>`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function renderMission(d) {
|
|
397
|
+
const p = d.project;
|
|
398
|
+
const st = d.state;
|
|
399
|
+
const prog = d.progress;
|
|
400
|
+
const statusKind = /complete|done|green/i.test(st.status || '') ? 'ok'
|
|
401
|
+
: /block|fail|error/i.test(st.status || '') ? 'danger'
|
|
402
|
+
: st.status ? 'info' : 'muted';
|
|
403
|
+
const req = d.requirements;
|
|
404
|
+
const tok = d.telemetry.totals.input_tokens + d.telemetry.totals.output_tokens;
|
|
405
|
+
const cache = d.telemetry.cache_hit_rate_pct;
|
|
406
|
+
const cards = [
|
|
407
|
+
metricCard({
|
|
408
|
+
label: 'Progress', value: prog.percent == null ? '—' : String(prog.percent), unit: prog.percent == null ? '' : '%',
|
|
409
|
+
barPct: prog.percent, barColor: 'var(--coral)', sub: prog.total ? `${prog.completed} / ${prog.total} phases` : 'no phases',
|
|
410
|
+
}),
|
|
411
|
+
metricCard({
|
|
412
|
+
label: 'Phase', value: st.current_phase || prog.completed || 0, unit: prog.total ? ` / ${prog.total}` : '',
|
|
413
|
+
sub: st.current_phase_name || '',
|
|
414
|
+
}),
|
|
415
|
+
req
|
|
416
|
+
? metricCard({ label: 'Requirements', value: req.done, unit: ` / ${req.total}`, barPct: Math.round((req.done / req.total) * 100), barColor: 'var(--indigo)', sub: `${req.total - req.done} open` })
|
|
417
|
+
: metricCard({ label: 'Requirements', value: '—', sub: 'none tracked' }),
|
|
418
|
+
metricCard({
|
|
419
|
+
label: 'Spend', value: fmtUsd(d.telemetry.totals.cost_usd),
|
|
420
|
+
sub: `${fmtTokens(tok)} tok${cache == null ? '' : ' · ' + cache + '% cache'}`,
|
|
421
|
+
}),
|
|
422
|
+
].join('');
|
|
423
|
+
return `
|
|
424
|
+
<section class="panel mission">
|
|
425
|
+
<div class="mhead">
|
|
426
|
+
<div>
|
|
427
|
+
<div class="kicker">pan army · mission control</div>
|
|
428
|
+
<div class="title">${esc(p.name || 'Untitled project')}</div>
|
|
429
|
+
${p.core_value ? `<div class="sub">${esc(p.core_value)}</div>` : ''}
|
|
430
|
+
</div>
|
|
431
|
+
<div class="mmeta">
|
|
432
|
+
${d.army_active ? pill('campaign active', 'info') : pill('no campaign', 'muted')}
|
|
433
|
+
<div class="mono dim">${p.version ? 'v' + esc(p.version) : ''}${p.milestone && p.milestone.version ? ' · milestone ' + esc(p.milestone.version) : ''}</div>
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
${st.status ? `<div class="statusline">status ${pill(st.status, statusKind)}${st.blockers.length ? ' ' + pill(st.blockers.length + ' blocker' + (st.blockers.length > 1 ? 's' : ''), 'danger') : ' ' + pill('0 blockers', 'ok')}</div>` : ''}
|
|
437
|
+
<div class="metrics">${cards}</div>
|
|
438
|
+
</section>`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function renderNowBuilding(d) {
|
|
442
|
+
if (!d.roadmap.length) return '';
|
|
443
|
+
const cur = currentPhase(d);
|
|
444
|
+
const stage = pipelineStage(cur);
|
|
445
|
+
const total = d.progress.total;
|
|
446
|
+
const idx = d.roadmap.findIndex(p => cur && String(p.number) === String(cur.number));
|
|
447
|
+
|
|
448
|
+
// WHERE — phase stepper
|
|
449
|
+
const stepEls = [];
|
|
450
|
+
d.roadmap.forEach((p, i) => {
|
|
451
|
+
const done = p.status === 'complete';
|
|
452
|
+
const active = cur && String(p.number) === String(cur.number);
|
|
453
|
+
const dot = done ? `<span class="sdot done">${CHECK_SVG}</span>`
|
|
454
|
+
: active ? `<span class="sdot now">${esc(p.number)}</span>`
|
|
455
|
+
: `<span class="sdot todo">${esc(p.number)}</span>`;
|
|
456
|
+
const lab = `<span class="slabel ${active ? 'now' : done ? 'done' : 'todo'}">${esc((p.name || '').split(' ')[0] || p.number)}</span>`;
|
|
457
|
+
stepEls.push(`<div class="step">${dot}${lab}</div>`);
|
|
458
|
+
if (i < d.roadmap.length - 1) {
|
|
459
|
+
const next = d.roadmap[i + 1];
|
|
460
|
+
const nextActive = cur && String(next.number) === String(cur.number);
|
|
461
|
+
const cls = done && next.status === 'complete' ? 'done' : done && nextActive ? 'grad' : 'todo';
|
|
462
|
+
stepEls.push(`<span class="sline ${cls}"></span>`);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// WHAT — pipeline + in-flight tasks
|
|
467
|
+
const stages = ['research', 'plan', 'execute', 'verify'];
|
|
468
|
+
const ci = stages.indexOf(stage);
|
|
469
|
+
const pipe = stages.map((s, i) => {
|
|
470
|
+
const k = i < ci ? 'done' : i === ci ? 'on' : 'off';
|
|
471
|
+
return `<span class="pstep ${k}">${esc(s)}${i < ci ? ' ✓' : i === ci ? ' ●' : ''}</span>`;
|
|
472
|
+
}).join('<span class="pgt">›</span>');
|
|
473
|
+
|
|
474
|
+
const tasks = d.worktrees.length
|
|
475
|
+
? d.worktrees.map(t => `<div class="task"><span class="tname"><span class="td"></span>${esc(t.branch.replace(/^army\//, ''))}</span>`
|
|
476
|
+
+ `<span class="tpath">${esc(t.worktree)}</span></div>`).join('')
|
|
477
|
+
: `<div class="task"><span class="tname dim">${cur ? (cur.plans || 0) : 0} plan(s) · ${cur ? (cur.summaries || 0) : 0} done</span></div>`;
|
|
478
|
+
const whatSub = d.worktrees.length
|
|
479
|
+
? `Build squad · ${d.worktrees.length} task${d.worktrees.length > 1 ? 's' : ''} in flight across isolated worktrees`
|
|
480
|
+
: 'pan pipeline — no army worktrees active';
|
|
481
|
+
|
|
482
|
+
return `
|
|
483
|
+
<section class="nowbuilding">
|
|
484
|
+
<div class="nbtop">
|
|
485
|
+
<div class="ph dark">now building</div>
|
|
486
|
+
<div class="nbphase"><span class="nd"></span>phase ${esc(cur ? cur.number : '—')} of ${esc(total)} · ${esc(stage)}</div>
|
|
487
|
+
</div>
|
|
488
|
+
<div class="stepper">${stepEls.join('')}</div>
|
|
489
|
+
<div class="nbcard">
|
|
490
|
+
<div class="nbhead">
|
|
491
|
+
<div class="nbtitle">Phase ${esc(cur ? cur.number : '')} — ${esc(cur && cur.name ? cur.name : '')}</div>
|
|
492
|
+
<div class="pipeline">${pipe}</div>
|
|
493
|
+
</div>
|
|
494
|
+
<div class="nbsub">${esc(whatSub)}</div>
|
|
495
|
+
<div class="tasks">${tasks}</div>
|
|
496
|
+
</div>
|
|
497
|
+
</section>`;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function renderCommandStack(d) {
|
|
501
|
+
if (!d.army_active) return '';
|
|
502
|
+
const a = d.army;
|
|
503
|
+
const anyActive = a.squads.some(s => s.active_count > 0);
|
|
504
|
+
const squadCards = a.squads.map(s => {
|
|
505
|
+
const color = SQUAD_COLOR[s.name] || '#5B4BE6';
|
|
506
|
+
const pillEl = s.active_count > 0
|
|
507
|
+
? pill(`${s.active_count} active`, SQUAD_PILL[s.name] || 'info')
|
|
508
|
+
: (s.name === 'release' ? pill('human gate', 'warn') : pill('idle', 'muted'));
|
|
509
|
+
const drill = s.agents.length
|
|
510
|
+
? s.agents.map(ag => `<div class="ag"><span class="amono">↳ ${esc(ag.name)}</span>`
|
|
511
|
+
+ `<span class="amono ${ag.active ? 'okc' : 'dim'}">${ag.active ? `${ag.calls} calls · ${fmtTokens(ag.tokens)} tok` : 'idle'}</span></div>`).join('')
|
|
512
|
+
: '<div class="ag"><span class="amono dim">git-tool driven · no agents</span></div>';
|
|
513
|
+
return `
|
|
514
|
+
<div class="squad" style="border-left:3px solid ${color}">
|
|
515
|
+
<div class="squad-head">
|
|
516
|
+
<span class="squad-name"><span class="dot" style="background:${color}"></span>${esc(s.label)}
|
|
517
|
+
<span class="amono dim">· ${esc(s.tier)} · ${esc(s.access)}</span></span>
|
|
518
|
+
${pillEl}
|
|
519
|
+
</div>
|
|
520
|
+
<div class="squad-sum">${esc(s.summary)}</div>
|
|
521
|
+
${drill}
|
|
522
|
+
</div>`;
|
|
523
|
+
}).join('');
|
|
524
|
+
return `
|
|
525
|
+
<section class="panel">
|
|
526
|
+
<div class="ph">command stack — live</div>
|
|
527
|
+
<div class="coord">
|
|
528
|
+
<span class="dot" style="background:var(--coral)"></span>
|
|
529
|
+
<span class="cname">Mission Control</span>
|
|
530
|
+
<span class="amono dim">· ${esc(a.coordinator)} · opus · reasoning · delegation-only</span>
|
|
531
|
+
<span class="mc-state">${anyActive ? 'delegating' : 'idle'}</span>
|
|
532
|
+
</div>
|
|
533
|
+
<div class="squads">${squadCards}</div>
|
|
534
|
+
<div class="workers amono dim"><span class="dot" style="background:var(--green)"></span>workers · ${a.workers.map(esc).join(', ')}</div>
|
|
535
|
+
</section>`;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function renderCampaign(d) {
|
|
539
|
+
if (!d.campaign) return '';
|
|
540
|
+
const c = d.campaign;
|
|
541
|
+
const budgetPct = c.daily_budget ? Math.round((c.spent_today / c.daily_budget) * 100) : 0;
|
|
542
|
+
const runs = c.history.length
|
|
543
|
+
? c.history.map(h => `${h.items_landed || 0}↑${h.points_used || 0}p`).join(' · ')
|
|
544
|
+
: 'no runs yet';
|
|
545
|
+
return `
|
|
546
|
+
<section class="panel">
|
|
547
|
+
<div class="ph">campaign</div>
|
|
548
|
+
<div class="row"><span class="rl">Status</span>${pill(c.enabled ? (c.paused ? 'paused' : 'active') : 'disabled', c.enabled && !c.paused ? 'ok' : 'muted')}</div>
|
|
549
|
+
<div class="row"><span class="rl">Cadence</span><span class="amono">${esc(c.cadence)}</span></div>
|
|
550
|
+
${c.goal ? `<div class="row"><span class="rl">Goal</span><span class="amono">${esc(c.goal)}</span></div>` : ''}
|
|
551
|
+
<div class="row"><span class="rl">Next due</span><span class="amono warnc">${c.due ? 'due now' : relDue(c.next_due, d.generated_at)}</span></div>
|
|
552
|
+
<div class="row noborder"><span class="rl">Daily budget</span><span class="amono">${c.spent_today} / ${c.daily_budget} pts</span></div>
|
|
553
|
+
${bar(budgetPct, budgetPct >= 100 ? 'var(--red)' : 'var(--indigo)')}
|
|
554
|
+
<div class="row noborder"><span class="rl">Runs</span><span class="amono dim">${esc(runs)}</span></div>
|
|
555
|
+
</section>`;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function renderRoadmap(d) {
|
|
559
|
+
if (!d.roadmap.length) return '';
|
|
560
|
+
const rows = d.roadmap.map(p =>
|
|
561
|
+
`<div class="row"><span><span class="dot" style="background:${STATUS_DOT[p.status] || '#C9C0AE'}"></span>${esc(p.number)}${p.name ? ' · ' + esc(p.name) : ''}</span>${pill(p.status, STATUS_KIND[p.status])}</div>`
|
|
562
|
+
).join('');
|
|
563
|
+
return `
|
|
564
|
+
<section class="panel">
|
|
565
|
+
<div class="ph">roadmap</div>
|
|
566
|
+
${rows}
|
|
567
|
+
</section>`;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function renderHarness(d) {
|
|
571
|
+
if (!d.army_active) return '';
|
|
572
|
+
const h = d.harness;
|
|
573
|
+
const conc = h.concurrency
|
|
574
|
+
? ('serial_build' in h.concurrency ? `serial_build: ${Boolean(h.concurrency.serial_build)}` : esc(JSON.stringify(h.concurrency)))
|
|
575
|
+
: 'default';
|
|
576
|
+
return `
|
|
577
|
+
<section class="panel">
|
|
578
|
+
<div class="ph">safety harness — sentinel</div>
|
|
579
|
+
<div class="row"><span class="rl">Merge gate</span>${pill(h.merge_gate, h.merge_gate === 'always-ask' ? 'warn' : 'info')}</div>
|
|
580
|
+
<div class="row"><span class="rl">Abort switch</span>${pill(h.abort, h.abort === 'clear' ? 'ok' : 'warn')}</div>
|
|
581
|
+
<div class="row"><span class="rl">Active worktrees</span><span class="amono">${h.active_worktrees}</span></div>
|
|
582
|
+
<div class="row"><span class="rl">Nesting depth</span><span class="amono">2 (max)</span></div>
|
|
583
|
+
<div class="row noborder"><span class="rl">Concurrency</span><span class="amono">${esc(conc)}</span></div>
|
|
584
|
+
</section>`;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function renderTelemetry(d) {
|
|
588
|
+
const t = d.telemetry;
|
|
589
|
+
const keys = Object.keys(t.by_squad).sort((a, b) => t.by_squad[b].cost - t.by_squad[a].cost);
|
|
590
|
+
const max = keys.reduce((m, k) => Math.max(m, t.by_squad[k].cost), 0) || 1;
|
|
591
|
+
const bars = keys.length
|
|
592
|
+
? keys.map(k => {
|
|
593
|
+
const color = SQUAD_COLOR[k] || (k === 'command' ? '#FFCE4A' : '#9A9180');
|
|
594
|
+
const pct = Math.round((t.by_squad[k].cost / max) * 100);
|
|
595
|
+
return `<div class="sqbar"><div class="sqbar-h"><span>${esc(k)}</span><span>${fmtUsd(t.by_squad[k].cost)}</span></div>`
|
|
596
|
+
+ `<div class="bar"><span style="width:${pct}%;background:${color}"></span></div></div>`;
|
|
597
|
+
}).join('')
|
|
598
|
+
: '<div class="row noborder dim amono">No cost records yet</div>';
|
|
599
|
+
return `
|
|
600
|
+
<section class="panel">
|
|
601
|
+
<div class="ph">telemetry · spend by squad</div>
|
|
602
|
+
<div class="row"><span class="rl">Total spend</span><span class="amono">${fmtUsd(t.totals.cost_usd)}</span></div>
|
|
603
|
+
<div class="row"><span class="rl">Tokens</span><span class="amono">${fmtTokens(t.totals.input_tokens + t.totals.output_tokens)}</span></div>
|
|
604
|
+
<div class="row"><span class="rl">Cache hit</span><span class="amono okc">${t.cache_hit_rate_pct == null ? 'n/a' : t.cache_hit_rate_pct + '%'}</span></div>
|
|
605
|
+
<div class="sqbars">${bars}</div>
|
|
606
|
+
</section>`;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function renderWorktrees(d) {
|
|
610
|
+
if (!d.army_active) return '';
|
|
611
|
+
const rows = d.worktrees.length
|
|
612
|
+
? d.worktrees.map(t => `<div class="row"><span class="amono"><span class="dot" style="background:var(--coral)"></span>${esc(t.branch)}</span>`
|
|
613
|
+
+ `<span class="amono dim">${esc(t.worktree)}</span></div>`).join('')
|
|
614
|
+
: '<div class="row noborder dim amono">No army worktrees</div>';
|
|
615
|
+
return `
|
|
616
|
+
<section class="panel">
|
|
617
|
+
<div class="ph">active worktrees — build squad</div>
|
|
618
|
+
${rows}
|
|
619
|
+
</section>`;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function renderQuality(d) {
|
|
623
|
+
if (!d.requirements && !d.quality) return '';
|
|
624
|
+
const req = d.requirements;
|
|
625
|
+
const q = d.quality;
|
|
626
|
+
const left = req
|
|
627
|
+
? `<div class="qcol">
|
|
628
|
+
<div class="row noborder"><span class="rl">Requirements done</span><span class="amono">${req.done} / ${req.total}</span></div>
|
|
629
|
+
${bar(Math.round((req.done / req.total) * 100), 'var(--indigo)')}
|
|
630
|
+
${req.open.length ? `<div class="open">${req.open.map(o => `<div class="amono dim">• ${esc(o)}</div>`).join('')}</div>` : '<div class="amono dim">all requirements met</div>'}
|
|
631
|
+
</div>`
|
|
632
|
+
: '<div class="qcol amono dim">No requirements tracked</div>';
|
|
633
|
+
const right = q
|
|
634
|
+
? `<div class="qcol">
|
|
635
|
+
<div class="row"><span class="rl">Last verification</span>${pill('phase ' + q.phase, 'info')}</div>
|
|
636
|
+
${q.phase_name ? `<div class="row"><span class="rl">Phase</span><span class="amono">${esc(q.phase_name)}</span></div>` : ''}
|
|
637
|
+
<div class="row noborder"><span class="rl">Artifacts</span><span class="amono">${q.verification ? 'verification' : ''}${q.verification && q.uat ? ' + ' : ''}${q.uat ? 'uat' : ''}</span></div>
|
|
638
|
+
</div>`
|
|
639
|
+
: '<div class="qcol amono dim">No verification yet</div>';
|
|
640
|
+
return `
|
|
641
|
+
<section class="panel">
|
|
642
|
+
<div class="ph">requirements & quality</div>
|
|
643
|
+
<div class="qgrid">${left}${right}</div>
|
|
644
|
+
</section>`;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function renderActivity(d) {
|
|
648
|
+
if (!d.activity.length) return '';
|
|
649
|
+
const rows = d.activity.map(c =>
|
|
650
|
+
`<div class="row"><span class="amono"><span class="hash">${esc(c.hash)}</span> · ${esc(c.subject)}</span><span class="amono dim">${esc(c.when)}</span></div>`
|
|
651
|
+
).join('');
|
|
652
|
+
return `
|
|
653
|
+
<section class="panel">
|
|
654
|
+
<div class="ph">recent activity</div>
|
|
655
|
+
${rows}
|
|
656
|
+
</section>`;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const HUD_CSS = `
|
|
660
|
+
:root{
|
|
661
|
+
--bg:#E7DBC2;--panel:#FBF7EE;--panel2:#F3ECDD;--border:#E4D8C0;--border2:#E8DDC6;--rowline:#EDE3D0;
|
|
662
|
+
--text:#211E18;--text2:#5C5446;--muted:#9A9180;--faint:#C9C0AE;
|
|
663
|
+
--coral:#FF5A3C;--indigo:#5B4BE6;--green:#1E8E5A;--butter:#FFCE4A;--gold:#C28A1E;--red:#D2431F;
|
|
664
|
+
--font:"Gabarito","Segoe UI",system-ui,sans-serif;
|
|
665
|
+
--mono:"JetBrains Mono","SFMono-Regular",Consolas,monospace;
|
|
666
|
+
}
|
|
667
|
+
*{box-sizing:border-box}
|
|
668
|
+
body{margin:0;background:var(--bg);color:var(--text);font-family:var(--font);font-size:14px;line-height:1.5;padding:28px;-webkit-font-smoothing:antialiased;}
|
|
669
|
+
::selection{background:var(--coral);color:#fff;}
|
|
670
|
+
.wrap{max-width:1120px;margin:0 auto;}
|
|
671
|
+
.mono,.amono{font-family:var(--mono);}
|
|
672
|
+
.amono{font-size:12px;}
|
|
673
|
+
.dim{color:var(--muted);}
|
|
674
|
+
.okc{color:var(--green);}
|
|
675
|
+
.warnc{color:#9A7A12;}
|
|
676
|
+
.hash{color:var(--gold);}
|
|
677
|
+
|
|
678
|
+
.topbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:22px;}
|
|
679
|
+
.tb-brand{display:flex;align-items:center;gap:13px;}
|
|
680
|
+
.tb-brand .mark{width:34px;height:34px;}
|
|
681
|
+
.tb-word{font-weight:800;font-size:17px;letter-spacing:-0.01em;}
|
|
682
|
+
.tb-word .c{color:var(--coral);}
|
|
683
|
+
.tb-word .hud{color:var(--muted);font-weight:600;}
|
|
684
|
+
.tb-meta{display:flex;align-items:center;gap:10px;font-family:var(--mono);font-size:11px;color:#8C8475;}
|
|
685
|
+
.tb-meta .sep{color:#C2B79E;}
|
|
686
|
+
.tb-live{display:inline-flex;align-items:center;gap:7px;background:#E4F3EB;color:var(--green);padding:6px 11px;border-radius:8px;}
|
|
687
|
+
.tb-live .ld{width:7px;height:7px;border-radius:50%;background:var(--green);}
|
|
688
|
+
|
|
689
|
+
.panel{background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:20px 24px;margin-bottom:16px;box-shadow:0 1px 3px rgba(33,30,24,0.05);}
|
|
690
|
+
.ph{font-family:var(--mono);font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);margin-bottom:14px;}
|
|
691
|
+
.row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:7px 0;border-bottom:1px solid var(--rowline);font-size:13px;}
|
|
692
|
+
.row.noborder,.row:last-child{border-bottom:none;}
|
|
693
|
+
.rl{color:var(--text2);}
|
|
694
|
+
.pill{font-family:var(--mono);font-size:11px;padding:3px 10px;border-radius:8px;white-space:nowrap;}
|
|
695
|
+
.pill.ok{background:#E4F3EB;color:#1E8E5A;}
|
|
696
|
+
.pill.info{background:#E9E6FB;color:#5B4BE6;}
|
|
697
|
+
.pill.warn{background:#FFF3D4;color:#9A7A12;}
|
|
698
|
+
.pill.danger{background:#FBE2DB;color:#D2431F;}
|
|
699
|
+
.pill.muted{background:#EDE8DC;color:#8C8475;}
|
|
700
|
+
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:8px;vertical-align:middle;}
|
|
701
|
+
.bar{height:7px;border-radius:6px;background:#EAE2D1;overflow:hidden;margin:8px 0;}
|
|
702
|
+
.bar>span{display:block;height:100%;border-radius:6px;}
|
|
703
|
+
|
|
704
|
+
.mission .mhead{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap;}
|
|
705
|
+
.kicker{font-family:var(--mono);font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);}
|
|
706
|
+
.title{font-size:27px;font-weight:800;letter-spacing:-0.02em;margin-top:4px;}
|
|
707
|
+
.sub{color:var(--text2);margin-top:4px;max-width:60ch;}
|
|
708
|
+
.mmeta{text-align:right;}
|
|
709
|
+
.statusline{margin-top:16px;color:var(--text2);font-size:13px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
|
|
710
|
+
.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-top:18px;}
|
|
711
|
+
.metric{background:var(--panel2);border:1px solid var(--border2);border-radius:12px;padding:16px 18px;}
|
|
712
|
+
.mlabel{color:var(--text2);font-size:13px;}
|
|
713
|
+
.mnum{font-size:27px;font-weight:800;margin-top:2px;}
|
|
714
|
+
.munit{font-size:17px;color:var(--muted);font-weight:700;margin-left:2px;}
|
|
715
|
+
.mbar{height:6px;border-radius:5px;background:#E6DAC4;overflow:hidden;margin:9px 0 7px;}
|
|
716
|
+
.mbar>span{display:block;height:100%;}
|
|
717
|
+
.msub{font-family:var(--mono);font-size:11px;color:var(--muted);}
|
|
718
|
+
|
|
719
|
+
/* dark "now building" hero */
|
|
720
|
+
.nowbuilding{background:#211E18;border-radius:16px;padding:24px 26px;margin-bottom:16px;box-shadow:0 8px 24px -12px rgba(33,30,24,0.4);}
|
|
721
|
+
.nbtop{display:flex;align-items:center;justify-content:space-between;margin-bottom:18px;}
|
|
722
|
+
.ph.dark{color:#8C8475;margin-bottom:0;}
|
|
723
|
+
.nbphase{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:11px;color:#FF9077;}
|
|
724
|
+
.nbphase .nd{width:7px;height:7px;border-radius:50%;background:var(--coral);}
|
|
725
|
+
.stepper{display:flex;align-items:center;gap:0;margin-bottom:18px;}
|
|
726
|
+
.step{flex:none;display:flex;flex-direction:column;align-items:center;gap:6px;width:64px;}
|
|
727
|
+
.sdot{width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:12px;font-weight:700;}
|
|
728
|
+
.sdot.done{background:var(--green);color:#fff;}
|
|
729
|
+
.sdot.done .ck{width:13px;height:13px;}
|
|
730
|
+
.sdot.now{width:28px;height:28px;background:var(--coral);color:#fff;font-weight:800;box-shadow:0 0 0 4px rgba(255,90,60,0.22);}
|
|
731
|
+
.sdot.todo{background:#2E2A22;border:1.5px solid #4A4339;color:#8C8475;}
|
|
732
|
+
.slabel{font-family:var(--mono);font-size:10px;text-align:center;max-width:62px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#6E685B;}
|
|
733
|
+
.slabel.now{color:#FF9077;font-weight:600;}
|
|
734
|
+
.slabel.done{color:#8C8475;}
|
|
735
|
+
.sline{flex:1;height:3px;min-width:8px;background:#3A352C;margin-bottom:18px;}
|
|
736
|
+
.sline.done{background:var(--green);}
|
|
737
|
+
.sline.grad{background:linear-gradient(90deg,#1E8E5A,#FF5A3C);}
|
|
738
|
+
.nbcard{background:#2A251D;border:1px solid #3A352C;border-radius:13px;padding:18px 20px;}
|
|
739
|
+
.nbhead{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;margin-bottom:6px;}
|
|
740
|
+
.nbtitle{font-size:20px;font-weight:800;color:#FBF7EE;}
|
|
741
|
+
.pipeline{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:11px;}
|
|
742
|
+
.pstep{color:#6E685B;}
|
|
743
|
+
.pstep.done{color:#76D2A2;}
|
|
744
|
+
.pstep.on{color:#FF9077;font-weight:600;}
|
|
745
|
+
.pgt{color:#4A4339;}
|
|
746
|
+
.nbsub{color:#A7A091;font-size:13px;margin-bottom:14px;}
|
|
747
|
+
.tasks{display:flex;flex-direction:column;gap:10px;}
|
|
748
|
+
.task{background:#211E18;border-radius:10px;padding:12px 14px;display:flex;justify-content:space-between;align-items:center;gap:12px;}
|
|
749
|
+
.tname{font-weight:600;font-size:14px;color:#FBF7EE;display:flex;align-items:center;gap:10px;min-width:0;}
|
|
750
|
+
.tname .td{width:8px;height:8px;border-radius:50%;background:var(--coral);flex:none;}
|
|
751
|
+
.tpath{font-family:var(--mono);font-size:10.5px;color:#8C8475;flex:none;}
|
|
752
|
+
|
|
753
|
+
.coord{border:1px solid var(--border2);background:var(--panel2);border-radius:11px;padding:13px 16px;margin-bottom:14px;display:flex;align-items:center;gap:10px;}
|
|
754
|
+
.cname{font-weight:700;}
|
|
755
|
+
.mc-state{margin-left:auto;font-family:var(--mono);font-size:11px;color:var(--indigo);}
|
|
756
|
+
.squads{display:flex;flex-direction:column;gap:11px;}
|
|
757
|
+
.squad{background:#fff;border:1px solid var(--rowline);border-radius:10px;padding:13px 15px;}
|
|
758
|
+
.squad-head{display:flex;justify-content:space-between;align-items:center;}
|
|
759
|
+
.squad-name{font-weight:700;}
|
|
760
|
+
.squad-sum{color:var(--text2);font-size:12px;margin:7px 0 6px;}
|
|
761
|
+
.ag{display:flex;justify-content:space-between;padding:4px 0 1px;border-top:1px solid var(--rowline);}
|
|
762
|
+
.ag:first-of-type{}
|
|
763
|
+
.workers{margin-top:13px;}
|
|
764
|
+
|
|
765
|
+
.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start;}
|
|
766
|
+
.grid .panel{margin-bottom:0;}
|
|
767
|
+
.gcol{display:flex;flex-direction:column;gap:16px;}
|
|
768
|
+
.sqbars{display:flex;flex-direction:column;gap:9px;margin-top:8px;}
|
|
769
|
+
.sqbar-h{display:flex;justify-content:space-between;font-family:var(--mono);font-size:11px;color:var(--text2);margin-bottom:5px;}
|
|
770
|
+
|
|
771
|
+
.qgrid{display:grid;grid-template-columns:1fr 1fr;gap:24px;}
|
|
772
|
+
.open{margin-top:8px;display:flex;flex-direction:column;gap:5px;}
|
|
773
|
+
.foot{color:var(--muted);font-family:var(--mono);font-size:11px;text-align:center;margin-top:22px;}
|
|
774
|
+
@media(max-width:760px){.metrics,.grid,.qgrid{grid-template-columns:1fr;}.step{width:44px;}}
|
|
775
|
+
`;
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Render the collected data into one self-contained HTML document.
|
|
779
|
+
* @param {object} d - from collectHudData()
|
|
780
|
+
* @returns {string}
|
|
781
|
+
*/
|
|
782
|
+
function renderHud(d) {
|
|
783
|
+
const leftCol = [renderCampaign(d), renderRoadmap(d)].filter(Boolean).join('');
|
|
784
|
+
const rightCol = [renderHarness(d), renderTelemetry(d)].filter(Boolean).join('');
|
|
785
|
+
const grid = (leftCol || rightCol)
|
|
786
|
+
? `<div class="grid"><div class="gcol">${leftCol}</div><div class="gcol">${rightCol}</div></div>`
|
|
787
|
+
: '';
|
|
788
|
+
const body = [
|
|
789
|
+
renderTopBar(d),
|
|
790
|
+
renderMission(d),
|
|
791
|
+
renderNowBuilding(d),
|
|
792
|
+
renderCommandStack(d),
|
|
793
|
+
grid,
|
|
794
|
+
renderWorktrees(d),
|
|
795
|
+
renderQuality(d),
|
|
796
|
+
renderActivity(d),
|
|
797
|
+
].filter(Boolean).join('\n');
|
|
798
|
+
return `<!DOCTYPE html>
|
|
799
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
800
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
801
|
+
<title>PanWizard HUD — ${esc(d.project.name || 'project')}</title>
|
|
802
|
+
<style>${HUD_CSS}</style></head>
|
|
803
|
+
<body><div class="wrap">
|
|
804
|
+
${body}
|
|
805
|
+
<div class="foot">PanWizard · generated ${esc(d.generated_at)} · self-contained snapshot</div>
|
|
806
|
+
</div></body></html>`;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// ─── CLI ──────────────────────────────────────────────────────────────────────
|
|
810
|
+
|
|
811
|
+
function openInBrowser(filePath) {
|
|
812
|
+
const { execFileSync } = require('child_process');
|
|
813
|
+
try {
|
|
814
|
+
if (process.platform === 'win32') {
|
|
815
|
+
execFileSync('cmd', ['/c', 'start', '', filePath], { stdio: 'ignore' });
|
|
816
|
+
} else if (process.platform === 'darwin') {
|
|
817
|
+
execFileSync('open', [filePath], { stdio: 'ignore' });
|
|
818
|
+
} else {
|
|
819
|
+
execFileSync('xdg-open', [filePath], { stdio: 'ignore' });
|
|
820
|
+
}
|
|
821
|
+
return true;
|
|
822
|
+
} catch {
|
|
823
|
+
return false;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Generate the HUD. Default: write `.planning/hud.html`.
|
|
829
|
+
* @param {string} cwd
|
|
830
|
+
* @param {{out?:string, open?:boolean, stdout?:boolean, now?:Date}} opts
|
|
831
|
+
* @param {boolean} raw
|
|
832
|
+
*/
|
|
833
|
+
function cmdHud(cwd, opts = {}, raw) {
|
|
834
|
+
const data = collectHudData(cwd, opts);
|
|
835
|
+
const html = renderHud(data);
|
|
836
|
+
|
|
837
|
+
if (opts.stdout) {
|
|
838
|
+
process.stdout.write(html);
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const outPath = opts.out
|
|
843
|
+
? path.resolve(cwd, opts.out)
|
|
844
|
+
: path.join(planningPath(cwd), HUD_FILE);
|
|
845
|
+
try {
|
|
846
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
847
|
+
fs.writeFileSync(outPath, html, 'utf-8');
|
|
848
|
+
} catch (e) {
|
|
849
|
+
return error('Failed to write HUD: ' + e.message);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
let opened = false;
|
|
853
|
+
if (opts.open) opened = openInBrowser(outPath);
|
|
854
|
+
|
|
855
|
+
const sections = [
|
|
856
|
+
'mission',
|
|
857
|
+
data.roadmap.length && 'now-building',
|
|
858
|
+
data.army_active && 'command-stack',
|
|
859
|
+
data.campaign && 'campaign',
|
|
860
|
+
data.army_active && 'safety-harness',
|
|
861
|
+
data.army_active && 'worktrees',
|
|
862
|
+
data.roadmap.length && 'roadmap',
|
|
863
|
+
'telemetry',
|
|
864
|
+
(data.requirements || data.quality) && 'requirements-quality',
|
|
865
|
+
data.activity.length && 'activity',
|
|
866
|
+
].filter(Boolean);
|
|
867
|
+
|
|
868
|
+
output(
|
|
869
|
+
{ path: toPosix(outPath), bytes: Buffer.byteLength(html), army_active: data.army_active, sections, opened },
|
|
870
|
+
raw,
|
|
871
|
+
`HUD written to ${toPosix(outPath)} (${sections.length} sections${opened ? ', opened' : ''})`,
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
module.exports = {
|
|
876
|
+
HUD_FILE,
|
|
877
|
+
collectHudData,
|
|
878
|
+
renderHud,
|
|
879
|
+
cmdHud,
|
|
880
|
+
// exported for focused unit tests
|
|
881
|
+
scanPhases,
|
|
882
|
+
scanRequirements,
|
|
883
|
+
scanVerification,
|
|
884
|
+
buildArmy,
|
|
885
|
+
telemetryBySquad,
|
|
886
|
+
esc,
|
|
887
|
+
};
|