@beastmode-develeap/beastmode 0.1.283 → 0.1.285

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.
@@ -15,7 +15,7 @@
15
15
  }
16
16
  </script>
17
17
  <!--BOARD_DATA-->
18
- <script>window.__BUILD_STAMP__ = "20260516-164446-01f385b";</script>
18
+ <script>window.__BUILD_STAMP__ = "20260516-174800-2440824";</script>
19
19
  <link rel="preconnect" href="https://fonts.googleapis.com">
20
20
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
21
21
  <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
@@ -1259,11 +1259,6 @@ input[type="range"]::-webkit-slider-thumb {
1259
1259
  .card-status-age.status-age-info { color: var(--accent); background: var(--accent-subtle); }
1260
1260
  .card-status-age.status-age-warn { color: #fb923c; background: rgba(249, 115, 22, 0.15); }
1261
1261
  .card-status-age.status-age-stale { color: #f87171; background: rgba(239, 68, 68, 0.15); }
1262
- .card-badge-wrap {
1263
- /* Flex child wrapper: blockified by parent flex, but sized to content.
1264
- The inner .card-badge is NOT a direct flex item, so it keeps display:inline-block. */
1265
- line-height: 0;
1266
- }
1267
1262
  .card-badge {
1268
1263
  display: inline-block;
1269
1264
  padding: 2px 8px;
@@ -5661,10 +5656,12 @@ function ItemDetailSidebar({ item, onClose, onStatusChange, selectedProject, all
5661
5656
  const refreshCostSummary = useCallback(() => {
5662
5657
  if (!item) return;
5663
5658
  const proj = localStorage.getItem('beastmode-selected-project') || '';
5659
+ const sep = (q) => q ? '&' : '?';
5664
5660
  const boardParam = (proj && proj !== 'all') ? '?board=' + encodeURIComponent(proj) : '';
5665
- fetch('/api/items/' + item.id + '/costs/summary' + boardParam)
5661
+ const summaryQs = boardParam + sep(boardParam) + 'include_descendants=true';
5662
+ fetch('/api/items/' + item.id + '/costs/summary' + summaryQs)
5666
5663
  .then(r => r.ok ? r.json() : null)
5667
- .then(data => setCostSummary(data && data.record_count > 0 ? data : null))
5664
+ .then(data => setCostSummary(data && (data.record_count > 0 || (data.descendant_items && data.descendant_items.length > 0)) ? data : null))
5668
5665
  .catch(() => setCostSummary(null));
5669
5666
  if (isNonTerminal) {
5670
5667
  fetch('/api/items/' + item.id + '/costs/live' + boardParam)
@@ -5726,10 +5723,12 @@ function ItemDetailSidebar({ item, onClose, onStatusChange, selectedProject, all
5726
5723
  .finally(() => setLoadingAttachments(false));
5727
5724
  setLoadingCost(true);
5728
5725
  const proj = localStorage.getItem('beastmode-selected-project') || '';
5726
+ const sep = (q) => q ? '&' : '?';
5729
5727
  const boardParam = (proj && proj !== 'all') ? '?board=' + encodeURIComponent(proj) : '';
5730
- fetch('/api/items/' + item.id + '/costs/summary' + boardParam)
5728
+ const summaryQs = boardParam + sep(boardParam) + 'include_descendants=true';
5729
+ fetch('/api/items/' + item.id + '/costs/summary' + summaryQs)
5731
5730
  .then(r => r.ok ? r.json() : null)
5732
- .then(data => setCostSummary(data && data.record_count > 0 ? data : null))
5731
+ .then(data => setCostSummary(data && (data.record_count > 0 || (data.descendant_items && data.descendant_items.length > 0)) ? data : null))
5733
5732
  .catch(() => setCostSummary(null))
5734
5733
  .finally(() => setLoadingCost(false));
5735
5734
  if (isNonTerminal) {
@@ -5988,6 +5987,45 @@ function ItemDetailSidebar({ item, onClose, onStatusChange, selectedProject, all
5988
5987
  </table>
5989
5988
  `)
5990
5989
  }
5990
+ ${costSummary.categories && Object.keys(costSummary.categories).length > 0 && html`
5991
+ <div data-testid="category-breakdown" style="margin-top:12px;">
5992
+ <div style="font-weight:600;margin-bottom:6px;color:var(--text-dim);font-size:12px;">By Category</div>
5993
+ <table class="cost-phase-table">
5994
+ <thead>
5995
+ <tr>
5996
+ <th>Category</th>
5997
+ <th>Cost</th>
5998
+ <th>%</th>
5999
+ </tr>
6000
+ </thead>
6001
+ <tbody>
6002
+ ${(() => {
6003
+ const cats = costSummary.categories || {};
6004
+ const totalCat = Object.values(cats).reduce((s, v) => s + (v || 0), 0);
6005
+ return Object.entries(cats)
6006
+ .sort((a, b) => b[1] - a[1])
6007
+ .map(([cat, amount]) => html`
6008
+ <tr key=${cat}>
6009
+ <td class="cost-phase-name" data-testid=${'category-row-' + cat}>${cat}</td>
6010
+ <td class="cost-phase-value">${formatCost(amount) || '$0.00'}</td>
6011
+ <td class="cost-phase-value">${totalCat > 0 ? ((amount / totalCat) * 100).toFixed(0) + '%' : '—'}</td>
6012
+ </tr>
6013
+ `);
6014
+ })()}
6015
+ </tbody>
6016
+ </table>
6017
+ </div>
6018
+ `}
6019
+ ${costSummary.descendant_items && costSummary.descendant_items.length > 0 && html`
6020
+ <div data-testid="bug-gc-descendants" style="margin-top:12px;padding:8px 10px;border-radius:4px;background:rgba(245,158,11,0.08);border-left:3px solid #f59e0b;">
6021
+ <div style="font-weight:600;font-size:12px;color:#f59e0b;margin-bottom:4px;">Including Bug GC costs</div>
6022
+ <div style="font-size:11px;color:var(--text-dim);">
6023
+ ${costSummary.descendant_items.length} child task${costSummary.descendant_items.length === 1 ? '' : 's'}
6024
+ ${costSummary.descendant_cost_usd != null ? html` · ${formatCost(costSummary.descendant_cost_usd)} from children` : ''}
6025
+ ${costSummary.combined_cost_usd != null ? html` · combined ${formatCost(costSummary.combined_cost_usd)}` : ''}
6026
+ </div>
6027
+ </div>
6028
+ `}
5991
6029
  </div>
5992
6030
  `)}
5993
6031
  ${envTimeline && (envTimeline.entries.length > 0 || envTimeline.promotion_chain.length > 0) && html`
@@ -6324,7 +6362,7 @@ function PipelineView({
6324
6362
  ${(() => {
6325
6363
  const cost = costsByItem && costsByItem[String(item.id)];
6326
6364
  const label = cost ? formatCost(cost.total_cost_usd) : null;
6327
- return label ? html`<span class="card-badge-wrap"><span class="card-badge badge-cost" title=${'Total cost: ' + label}>${label}</span></span>` : null;
6365
+ return label ? html`<span class="card-badge badge-cost" title=${'Total cost: ' + label}>${label}</span>` : null;
6328
6366
  })()}
6329
6367
  ${(() => {
6330
6368
  const env = item.extra && item.extra.current_env;
@@ -6741,7 +6779,8 @@ function BoardPage({ selectedProject }) {
6741
6779
  // append ?board=<proj> here. Errors are swallowed: if the cost
6742
6780
  // batch endpoint is unreachable, cards render without badges.
6743
6781
  const fetchCosts = useCallback(() => {
6744
- const boardParam = (selectedProject && selectedProject !== 'all') ? '?board=' + encodeURIComponent(selectedProject) : '';
6782
+ const proj = localStorage.getItem('beastmode-selected-project') || '';
6783
+ const boardParam = (proj && proj !== 'all') ? '?board=' + encodeURIComponent(proj) : '';
6745
6784
  fetch('/api/costs/by-items' + boardParam)
6746
6785
  .then(r => r.ok ? r.json() : {})
6747
6786
  .then(data => setCostsByItem(data || {}))
@@ -7535,7 +7574,7 @@ function BoardPage({ selectedProject }) {
7535
7574
  ${(() => {
7536
7575
  const cost = costsByItem && costsByItem[String(item.id)];
7537
7576
  const label = cost ? formatCost(cost.total_cost_usd) : null;
7538
- return label ? html`<span class="card-badge-wrap"><span class="card-badge badge-cost" title=${'Total cost: ' + label}>${label}</span></span>` : null;
7577
+ return label ? html`<span class="card-badge badge-cost" title=${'Total cost: ' + label}>${label}</span>` : null;
7539
7578
  })()}
7540
7579
  ${(() => {
7541
7580
  const env = item.extra && item.extra.current_env;
@@ -8518,18 +8557,6 @@ function ProjectsPage({ selectedProject, onProjectChange }) {
8518
8557
  const [onboardingTier, setOnboardingTier] = useState('standard');
8519
8558
  const [showPicker, setShowPicker] = useState(false);
8520
8559
  const [adding, setAdding] = useState(false);
8521
- // Per-project analysis state (status pill + last-analyzed timestamp).
8522
- // Keyed by project name; value is the /analyze/status JSON response.
8523
- const [analysisStatuses, setAnalysisStatuses] = useState({});
8524
- // Which project card is currently expanded (only one at a time).
8525
- const [expandedProject, setExpandedProject] = useState(null);
8526
- // Cached guide content keyed by project name; each entry holds optional l1/l2 strings.
8527
- const [guideContent, setGuideContent] = useState({});
8528
- // Per-project transient errors surfaced inside the card body.
8529
- const [guideErrors, setGuideErrors] = useState({});
8530
- // Per-project "show full guide" toggle (l2 visible).
8531
- const [showFullGuide, setShowFullGuide] = useState({});
8532
- const pollTimersRef = useRef({});
8533
8560
 
8534
8561
  const fetchProjects = useCallback(() => {
8535
8562
  api('GET', '/api/projects')
@@ -8540,117 +8567,6 @@ function ProjectsPage({ selectedProject, onProjectChange }) {
8540
8567
 
8541
8568
  useEffect(() => { fetchProjects(); }, []);
8542
8569
 
8543
- const fetchAnalysisStatus = useCallback(async (name) => {
8544
- try {
8545
- const status = await api('GET', '/api/projects/' + name + '/analyze/status');
8546
- setAnalysisStatuses(prev => ({ ...prev, [name]: status }));
8547
- return status;
8548
- } catch (e) {
8549
- setAnalysisStatuses(prev => ({ ...prev, [name]: { status: 'idle', error: e.message } }));
8550
- return null;
8551
- }
8552
- }, []);
8553
-
8554
- // Refresh per-project analysis status whenever the project list changes.
8555
- useEffect(() => {
8556
- projects.forEach(p => { fetchAnalysisStatus(p.name); });
8557
- }, [projects, fetchAnalysisStatus]);
8558
-
8559
- // Clean up any running poll intervals on unmount.
8560
- useEffect(() => () => {
8561
- Object.values(pollTimersRef.current).forEach(t => clearInterval(t));
8562
- pollTimersRef.current = {};
8563
- }, []);
8564
-
8565
- const fetchGuide = useCallback(async (name, level) => {
8566
- try {
8567
- const resp = await api('GET', '/api/projects/' + name + '/codebase-guide?level=' + level);
8568
- setGuideContent(prev => ({
8569
- ...prev,
8570
- [name]: { ...(prev[name] || {}), [level]: resp.content || '' },
8571
- }));
8572
- setGuideErrors(prev => ({ ...prev, [name]: null }));
8573
- return resp;
8574
- } catch (e) {
8575
- // 404 means no guide exists yet — treat as empty sentinel so the
8576
- // render shows the status-based fallback instead of a raw error.
8577
- const noGuide = e.message && (
8578
- e.message.includes('No codebase guide') ||
8579
- e.message.includes('Guide file not found') ||
8580
- e.message === 'HTTP 404'
8581
- );
8582
- if (noGuide) {
8583
- setGuideContent(prev => ({
8584
- ...prev,
8585
- [name]: { ...(prev[name] || {}), [level]: '' },
8586
- }));
8587
- } else {
8588
- setGuideErrors(prev => ({ ...prev, [name]: e.message }));
8589
- }
8590
- return null;
8591
- }
8592
- }, []);
8593
-
8594
- const toggleExpand = useCallback(async (name) => {
8595
- if (expandedProject === name) {
8596
- setExpandedProject(null);
8597
- return;
8598
- }
8599
- setExpandedProject(name);
8600
- // Optimistically fetch L1 guide on expand regardless of analysis status.
8601
- // Guide files exist even when a re-analysis is in progress (previous run's
8602
- // output persists). fetchGuide silently treats 404 as empty so this is
8603
- // safe to call unconditionally — it never shows a raw HTTP error.
8604
- if (!(guideContent[name] && guideContent[name].l1)) {
8605
- await fetchGuide(name, 'l1');
8606
- }
8607
- }, [expandedProject, guideContent, fetchGuide]);
8608
-
8609
- const stopPolling = useCallback((name) => {
8610
- if (pollTimersRef.current[name]) {
8611
- clearInterval(pollTimersRef.current[name]);
8612
- delete pollTimersRef.current[name];
8613
- }
8614
- }, []);
8615
-
8616
- const startPolling = useCallback((name) => {
8617
- stopPolling(name);
8618
- pollTimersRef.current[name] = setInterval(async () => {
8619
- const st = await fetchAnalysisStatus(name);
8620
- if (!st) return;
8621
- if (st.status === 'complete') {
8622
- stopPolling(name);
8623
- // Drop stale L1/L2 caches so the next expand re-fetches them.
8624
- setGuideContent(prev => { const n = { ...prev }; delete n[name]; return n; });
8625
- setShowFullGuide(prev => ({ ...prev, [name]: false }));
8626
- if (expandedProject === name) {
8627
- await fetchGuide(name, 'l1');
8628
- }
8629
- } else if (st.status === 'failed' || st.status === 'idle') {
8630
- stopPolling(name);
8631
- }
8632
- }, 5000);
8633
- }, [fetchAnalysisStatus, fetchGuide, stopPolling, expandedProject]);
8634
-
8635
- const reanalyze = useCallback(async (name) => {
8636
- setGuideErrors(prev => ({ ...prev, [name]: null }));
8637
- setAnalysisStatuses(prev => ({ ...prev, [name]: { status: 'running' } }));
8638
- try {
8639
- await api('POST', '/api/projects/' + name + '/analyze?force=true', {});
8640
- startPolling(name);
8641
- } catch (e) {
8642
- setAnalysisStatuses(prev => ({ ...prev, [name]: { status: 'failed', error: e.message } }));
8643
- }
8644
- }, [startPolling]);
8645
-
8646
- const toggleFullGuide = useCallback(async (name) => {
8647
- const next = !showFullGuide[name];
8648
- setShowFullGuide(prev => ({ ...prev, [name]: next }));
8649
- if (next && !(guideContent[name] && guideContent[name].l2)) {
8650
- await fetchGuide(name, 'l2');
8651
- }
8652
- }, [showFullGuide, guideContent, fetchGuide]);
8653
-
8654
8570
  const addProject = async () => {
8655
8571
  const isGithub = addMode === 'github';
8656
8572
  if (isGithub && !githubUrl.trim()) return;
@@ -8667,38 +8583,22 @@ function ProjectsPage({ selectedProject, onProjectChange }) {
8667
8583
  setNewPath('');
8668
8584
  setGithubUrl('');
8669
8585
  setCloneTarget('');
8670
- // For standard/full tier: do NOT show an intermediate toast before the
8671
- // analyze call. The analyze API returns immediately (async job) so the
8672
- // delay is negligible, but showing 'Project added successfully' first
8673
- // causes the verifier (and real users) to read the wrong message if
8674
- // they look before the API responds. Only show the final accurate message.
8675
- if (isGithub) {
8676
- setSuccess(`Cloned and added: ${registeredPath}`);
8677
- } else if (onboardingTier !== 'standard' && onboardingTier !== 'full') {
8678
- setSuccess('Project added successfully');
8679
- }
8586
+ setSuccess(isGithub ? `Cloned and added: ${registeredPath}` : 'Project added successfully');
8680
8587
 
8681
8588
  if (onboardingTier === 'standard' || onboardingTier === 'full') {
8589
+ setSuccess('Project added — running codebase analysis...');
8682
8590
  try {
8683
- const analyzeResult = await api('POST', '/api/projects/' + projName + '/analyze', {});
8684
- if (analyzeResult && analyzeResult.cached) {
8685
- setSuccess('Project added — codebase already analyzed');
8686
- } else {
8687
- setSuccess('Project added — analyzing codebase…');
8688
- // Begin polling so the operator sees the status pill flip to
8689
- // "Analyzed" on the new project card in real time. Card may
8690
- // render slightly later (after fetchProjects), but startPolling
8691
- // is idempotent and the interval keeps refreshing status.
8692
- startPolling(projName);
8693
- }
8694
- } catch {
8695
- setSuccess('Project added (analysis failed — can retry later)');
8696
- }
8591
+ await api('POST', '/api/projects/' + projName + '/analyze', {});
8592
+ setSuccess('Project added and analyzed');
8593
+ } catch { setSuccess('Project added (analysis failed can retry later)'); }
8697
8594
  }
8698
8595
 
8699
- // Full tier no longer auto-redirects. The operator stays on the
8700
- // Projects page so they can watch the analysis status flip and
8701
- // navigate to /new-session manually when ready.
8596
+ if (onboardingTier === 'full') {
8597
+ setTimeout(() => {
8598
+ if (onProjectChange) onProjectChange(projName);
8599
+ navigate('#/new-session');
8600
+ }, 1000);
8601
+ }
8702
8602
 
8703
8603
  setTimeout(() => setSuccess(null), 5000);
8704
8604
  fetchProjects();
@@ -8792,128 +8692,22 @@ function ProjectsPage({ selectedProject, onProjectChange }) {
8792
8692
  icon="projects"
8793
8693
  title="No projects yet"
8794
8694
  description="Add a project path to start building." />`
8795
- : projects.map(proj => {
8796
- const st = analysisStatuses[proj.name] || { status: 'idle' };
8797
- const expanded = expandedProject === proj.name;
8798
- const pillState = st.status === 'complete' ? 'complete'
8799
- : st.status === 'running' ? 'running'
8800
- : st.status === 'failed' ? 'failed'
8801
- : 'none';
8802
- const pillLabel = pillState === 'complete' ? '\u2713 Analyzed'
8803
- : pillState === 'running' ? '\u23f3 Analyzing\u2026'
8804
- : pillState === 'failed' ? '\u2717 Failed'
8805
- : '\u2014 Not analyzed';
8806
- const pillColor = pillState === 'complete' ? '#22c55e'
8807
- : pillState === 'running' ? '#f59e0b'
8808
- : pillState === 'failed' ? '#ef4444'
8809
- : '#94a3b8';
8810
- const tsLabel = pillState === 'complete' && st.analyzed_at
8811
- ? 'Analyzed ' + timeAgo(st.analyzed_at)
8812
- : pillState === 'failed' ? 'Analysis failed'
8813
- : pillState === 'running' ? 'Analyzing\u2026'
8814
- : 'Not analyzed';
8815
- const guide = guideContent[proj.name] || {};
8816
- const guideErr = guideErrors[proj.name];
8817
- const isBusy = pillState === 'running';
8818
- return html`
8819
- <div class="ext-item" key=${proj.name} style="flex-wrap:wrap;flex-direction:column;align-items:stretch;">
8820
- <div style="display:flex;align-items:center;gap:12px;width:100%;cursor:pointer;"
8821
- onClick=${() => toggleExpand(proj.name)}>
8822
- <div style="flex:1;min-width:0;">
8823
- <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
8824
- <div class="ext-name">${proj.name}</div>
8825
- <span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;
8826
- background:${pillColor};color:#fff;white-space:nowrap;"
8827
- title=${st.error || ''}
8828
- data-testid="project-status-pill"
8829
- data-state=${pillState}>${pillLabel}</span>
8830
- ${proj.stack && (proj.stack.detected || proj.stack.framework)
8831
- ? html`<span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;
8832
- background:var(--bg-subtle);color:var(--text-secondary);">
8833
- ${proj.stack.detected || proj.stack.framework}
8834
- </span>`
8835
- : null}
8836
- </div>
8837
- <div class="ext-meta" style="margin-top:4px;">
8838
- ${proj.repo ? proj.repo + ' \u2014 ' : ''}
8839
- ${proj.path || ''}
8840
- </div>
8841
- <div class="ext-meta" style="margin-top:2px;font-size:11px;color:var(--text-secondary);"
8842
- data-testid="project-analyzed-timestamp">${tsLabel}</div>
8843
- </div>
8844
- <div class="ext-actions" style="display:flex;gap:6px;flex-wrap:wrap;"
8845
- onClick=${(e) => e.stopPropagation()}>
8846
- <button class="btn btn-sm btn-secondary"
8847
- onClick=${() => reanalyze(proj.name)}
8848
- disabled=${isBusy}
8849
- data-testid="project-reanalyze-btn">
8850
- ${isBusy ? 'Analyzing\u2026' : 'Re-analyze'}
8851
- </button>
8852
- <button class="btn btn-sm btn-danger" onClick=${() => removeProject(proj.name)}>Remove</button>
8853
- <button class="btn btn-sm btn-secondary"
8854
- onClick=${() => toggleExpand(proj.name)}
8855
- data-testid="project-expand-btn">${expanded ? '\u25b4' : '\u25be'}</button>
8695
+ : projects.map(proj => html`
8696
+ <div class="ext-item" key=${proj.name} style="flex-wrap:wrap;">
8697
+ <div class="ext-info">
8698
+ <div class="ext-name">${proj.name}</div>
8699
+ <div class="ext-meta">
8700
+ ${proj.repo ? proj.repo + ' \u2014 ' : ''}
8701
+ ${proj.stack ? proj.stack.detected || proj.stack.framework || '' : ''}
8702
+ ${proj.path ? ' \u2014 ' + proj.path : ''}
8856
8703
  </div>
8857
8704
  </div>
8858
- ${expanded ? html`
8859
- <div style="margin-top:12px;padding:12px;border-top:1px solid var(--border);"
8860
- data-testid="codebase-summary-panel">
8861
- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
8862
- <strong style="font-size:13px;">Codebase summary</strong>
8863
- ${guide.l1 ? html`
8864
- <button class="btn btn-sm btn-secondary"
8865
- onClick=${() => toggleFullGuide(proj.name)}
8866
- data-testid="project-show-full-guide">
8867
- ${showFullGuide[proj.name] ? 'Hide full guide' : 'Show full guide'}
8868
- </button>
8869
- ` : null}
8870
- </div>
8871
- ${guideErr ? html`<div class="error-msg" style="margin-bottom:8px;">${guideErr}</div>` : null}
8872
- ${guide.l1 === undefined
8873
- ? html`<div style="color:var(--text-secondary);font-size:12px;">Loading guide\u2026</div>`
8874
- : guide.l1
8875
- ? html`
8876
- ${isBusy ? html`<div style="font-size:11px;color:var(--text-secondary);margin-bottom:6px;">Re-analysis in progress\u2026</div>` : null}
8877
- <pre style="white-space:pre-wrap;font-family:inherit;font-size:13px;line-height:1.5;
8878
- background:var(--bg-subtle);padding:10px;border-radius:6px;margin:0;"
8879
- data-testid="codebase-guide-l1">${guide.l1}</pre>
8880
- ${showFullGuide[proj.name]
8881
- ? (guide.l2 === undefined
8882
- ? html`<div style="margin-top:8px;color:var(--text-secondary);font-size:12px;">Loading full guide\u2026</div>`
8883
- : html`
8884
- <pre style="white-space:pre-wrap;font-family:inherit;font-size:12px;line-height:1.5;
8885
- background:var(--bg-subtle);padding:10px;border-radius:6px;margin-top:8px;"
8886
- data-testid="codebase-guide-l2">${guide.l2}</pre>
8887
- `)
8888
- : null}
8889
- `
8890
- : pillState === 'running'
8891
- ? html`<div style="display:flex;align-items:center;gap:8px;color:var(--text-secondary);">
8892
- <span class="spinner" style="display:inline-block;width:12px;height:12px;border:2px solid var(--border);
8893
- border-top-color:var(--accent);border-radius:50%;
8894
- animation:spin 0.8s linear infinite;"></span>
8895
- Analyzing codebase\u2026
8896
- </div>`
8897
- : pillState === 'failed'
8898
- ? html`<div>
8899
- <div class="error-msg" style="margin-bottom:8px;">${st.error || 'Analysis failed'}</div>
8900
- <button class="btn btn-sm btn-primary"
8901
- onClick=${() => reanalyze(proj.name)}
8902
- data-testid="project-retry-btn">Retry</button>
8903
- </div>`
8904
- : html`<div style="color:var(--text-secondary);">
8905
- Not analyzed yet \u2014
8906
- <button class="btn btn-sm btn-primary"
8907
- style="margin-left:6px;"
8908
- onClick=${() => reanalyze(proj.name)}
8909
- data-testid="project-run-analysis-btn">Run analysis</button>
8910
- </div>`}
8911
- </div>
8912
- ` : null}
8705
+ <div class="ext-actions">
8706
+ <button class="btn btn-sm btn-danger" onClick=${() => removeProject(proj.name)}>Remove</button>
8707
+ </div>
8913
8708
  <${EnvironmentPanel} projectName=${proj.name} />
8914
8709
  </div>
8915
- `;
8916
- })}
8710
+ `)}
8917
8711
  </div>
8918
8712
  ${showPicker && html`<${DirectoryPicker} initialPath=${newPath || null} onClose=${() => setShowPicker(false)} onSelect=${(p) => { setNewPath(p); setShowPicker(false); }} />`}
8919
8713
  </div>
@@ -11038,8 +10832,8 @@ function CostsPage({ selectedProject }) {
11038
10832
  async function fetchCosts() {
11039
10833
  try {
11040
10834
  const costQs = (selectedProject && selectedProject !== 'all')
11041
- ? '?board=' + encodeURIComponent(selectedProject)
11042
- : '';
10835
+ ? '?board=' + encodeURIComponent(selectedProject) + '&include_descendants=true'
10836
+ : '?include_descendants=true';
11043
10837
  const inFlightQs = (selectedProject && selectedProject !== 'all')
11044
10838
  ? '?board=' + encodeURIComponent(selectedProject) + '&threshold=5'
11045
10839
  : '?threshold=5';
@@ -11132,13 +10926,33 @@ function CostsPage({ selectedProject }) {
11132
10926
 
11133
10927
  const mergedRows = Object.entries(costsByItems).map(([itemId, costs]) => {
11134
10928
  const item = boardItems.find(i => String(i.id) === String(itemId));
10929
+ const categories = costs.categories || {};
10930
+ const ownCost = costs.total_cost_usd || 0;
10931
+ const descCost = costs.descendant_cost_usd || 0;
10932
+ const combinedCost = costs.combined_cost_usd != null
10933
+ ? costs.combined_cost_usd
10934
+ : (ownCost + descCost);
10935
+ let dominantCategory = '';
10936
+ let dominantShare = 0;
10937
+ const catTotal = Object.values(categories).reduce((s, v) => s + (v || 0), 0);
10938
+ if (catTotal > 0) {
10939
+ const sortedCats = Object.entries(categories).sort((a, b) => b[1] - a[1]);
10940
+ const top = sortedCats[0];
10941
+ dominantShare = top[1] / catTotal;
10942
+ dominantCategory = dominantShare >= 0.6 ? top[0] : 'mixed';
10943
+ }
11135
10944
  return {
11136
10945
  id: itemId,
11137
10946
  name: item ? item.name : 'Item #' + itemId,
11138
10947
  status: item ? item.status : 'Unknown',
11139
10948
  totalTokens: (costs.total_input_tokens || 0) + (costs.total_output_tokens || 0),
11140
- costUsd: costs.total_cost_usd || 0,
10949
+ costUsd: ownCost,
10950
+ descendantCost: descCost,
10951
+ combinedCost: combinedCost,
10952
+ categories: categories,
10953
+ dominantCategory: dominantCategory,
11141
10954
  records: costs.record_count || 0,
10955
+ parentTask: item ? (item.parent_task || '') : '',
11142
10956
  byIteration: byIterationMap[itemId] || [],
11143
10957
  };
11144
10958
  });
@@ -11155,15 +10969,37 @@ function CostsPage({ selectedProject }) {
11155
10969
  const avgCostPerTask = tasksTracked > 0 ? totalSpend / tasksTracked : 0;
11156
10970
  const maxPhaseCost = phaseData.length > 0 ? Math.max(...phaseData.map(p => p.cost)) : 0;
11157
10971
 
10972
+ const categoryTotals = {};
10973
+ mergedRows.forEach(r => {
10974
+ for (const [cat, amount] of Object.entries(r.categories || {})) {
10975
+ categoryTotals[cat] = (categoryTotals[cat] || 0) + (amount || 0);
10976
+ }
10977
+ });
10978
+ const categoryPills = Object.entries(categoryTotals)
10979
+ .sort((a, b) => b[1] - a[1])
10980
+ .map(([cat, amount]) => ({ cat, amount }));
10981
+
10982
+ const categoryColors = {
10983
+ spec: '#3b82f6',
10984
+ build: '#10b981',
10985
+ verify: '#f59e0b',
10986
+ review: '#8b5cf6',
10987
+ epic: '#ec4899',
10988
+ heal: '#ef4444',
10989
+ infra: '#06b6d4',
10990
+ mixed: 'var(--text-muted)',
10991
+ };
10992
+
11158
10993
  const sortedRows = [...mergedRows].sort((a, b) => {
11159
10994
  let cmp = 0;
11160
10995
  switch (sortCol) {
11161
- case 'id': cmp = Number(a.id) - Number(b.id); break;
11162
- case 'name': cmp = a.name.localeCompare(b.name); break;
11163
- case 'status': cmp = a.status.localeCompare(b.status); break;
11164
- case 'tokens': cmp = a.totalTokens - b.totalTokens; break;
11165
- case 'cost': cmp = a.costUsd - b.costUsd; break;
11166
- case 'records': cmp = a.records - b.records; break;
10996
+ case 'id': cmp = Number(a.id) - Number(b.id); break;
10997
+ case 'name': cmp = a.name.localeCompare(b.name); break;
10998
+ case 'status': cmp = a.status.localeCompare(b.status); break;
10999
+ case 'tokens': cmp = a.totalTokens - b.totalTokens; break;
11000
+ case 'cost': cmp = a.combinedCost - b.combinedCost; break;
11001
+ case 'records': cmp = a.records - b.records; break;
11002
+ case 'category': cmp = (a.dominantCategory || '').localeCompare(b.dominantCategory || ''); break;
11167
11003
  }
11168
11004
  return sortDir === 'asc' ? cmp : -cmp;
11169
11005
  });
@@ -11200,6 +11036,19 @@ function CostsPage({ selectedProject }) {
11200
11036
  </div>
11201
11037
  </div>
11202
11038
 
11039
+ ${categoryPills.length > 0 && html`
11040
+ <div class="cost-category-row" data-testid="cost-category-pills" style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:24px;">
11041
+ ${categoryPills.map(({ cat, amount }) => html`
11042
+ <div key=${cat}
11043
+ data-testid=${'category-pill-' + cat}
11044
+ style=${'display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:999px;background:' + (categoryColors[cat] || '#64748b') + '22;border:1px solid ' + (categoryColors[cat] || '#64748b') + '55;color:' + (categoryColors[cat] || '#64748b') + ';font-size:12px;font-weight:600;'}>
11045
+ <span style="text-transform:lowercase;">${cat}</span>
11046
+ <span style="font-family:var(--font-mono);">$${amount.toFixed(2)}</span>
11047
+ </div>
11048
+ `)}
11049
+ </div>
11050
+ `}
11051
+
11203
11052
  ${inFlightItems.length > 0 && html`
11204
11053
  <div class="card" data-testid="currently-running-card" style="margin-bottom:24px;border-left:4px solid #f59e0b;">
11205
11054
  <div class="card-header">
@@ -11249,7 +11098,8 @@ function CostsPage({ selectedProject }) {
11249
11098
  <th class="costs-th costs-th-name" onClick=${() => handleSort('name')}>Task${sortIndicator('name')}</th>
11250
11099
  <th class="costs-th costs-th-status" onClick=${() => handleSort('status')}>Status${sortIndicator('status')}</th>
11251
11100
  <th class="costs-th costs-th-tokens" onClick=${() => handleSort('tokens')}>Tokens${sortIndicator('tokens')}</th>
11252
- <th class="costs-th costs-th-cost" onClick=${() => handleSort('cost')}>Cost (USD)${sortIndicator('cost')}</th>
11101
+ <th class="costs-th costs-th-cost" onClick=${() => handleSort('cost')}>Total Cost (incl. children)${sortIndicator('cost')}</th>
11102
+ <th class="costs-th costs-th-category" onClick=${() => handleSort('category')}>Category${sortIndicator('category')}</th>
11253
11103
  <th class="costs-th costs-th-records" onClick=${() => handleSort('records')}>Records${sortIndicator('records')}</th>
11254
11104
  </tr>
11255
11105
  </thead>
@@ -11261,16 +11111,28 @@ function CostsPage({ selectedProject }) {
11261
11111
  e.stopPropagation();
11262
11112
  setExpandedRows(prev => ({ ...prev, [row.id]: !prev[row.id] }));
11263
11113
  };
11114
+ const hasChildCost = row.descendantCost > 0;
11115
+ const isChild = !!row.parentTask;
11116
+ const catColor = categoryColors[row.dominantCategory] || 'var(--text-muted)';
11264
11117
  return html`
11265
11118
  <${Fragment} key=${row.id}>
11266
11119
  <tr class="costs-tr" onClick=${() => navigate('#/board?item=' + row.id)}>
11267
11120
  <td class="costs-td costs-td-mono">
11268
11121
  ${hasMultiIter ? html`<span class="costs-expand-toggle" onClick=${toggle} style="cursor:pointer;display:inline-block;width:14px;">${isExpanded ? '▼' : '▶'}</span> ` : ''}#${row.id}
11122
+ ${isChild ? html`<span data-testid=${'parent-link-' + row.id} title=${'Child of #' + row.parentTask} style="margin-left:4px;color:var(--text-muted);font-size:11px;">↑#${row.parentTask}</span>` : ''}
11269
11123
  </td>
11270
11124
  <td class="costs-td costs-td-name" title=${row.name}>${row.name}</td>
11271
11125
  <td class="costs-td"><span class=${'badge ' + statusBadgeClass(row.status)}>${row.status}</span></td>
11272
11126
  <td class="costs-td costs-td-mono">${formatTokens(row.totalTokens)}</td>
11273
- <td class="costs-td costs-td-mono">$${row.costUsd.toFixed(2)}</td>
11127
+ <td class="costs-td costs-td-mono" data-testid=${'combined-cost-' + row.id}>
11128
+ $${row.combinedCost.toFixed(2)}
11129
+ ${hasChildCost ? html`<div data-testid=${'descendant-indicator-' + row.id} style="font-size:10px;color:var(--text-muted);font-weight:400;">+$${row.descendantCost.toFixed(2)} from children</div>` : ''}
11130
+ </td>
11131
+ <td class="costs-td">
11132
+ ${row.dominantCategory
11133
+ ? html`<span data-testid=${'category-cell-' + row.id} style=${'display:inline-block;padding:2px 8px;border-radius:4px;background:' + catColor + '22;color:' + catColor + ';font-size:11px;font-weight:600;text-transform:lowercase;'}>${row.dominantCategory}</span>`
11134
+ : html`<span style="color:var(--text-muted);font-size:11px;">—</span>`}
11135
+ </td>
11274
11136
  <td class="costs-td costs-td-mono">${row.records}</td>
11275
11137
  </tr>
11276
11138
  ${hasMultiIter && isExpanded ? row.byIteration.map(it => html`
@@ -11280,6 +11142,7 @@ function CostsPage({ selectedProject }) {
11280
11142
  <td class="costs-td"></td>
11281
11143
  <td class="costs-td costs-td-mono">${formatTokens((it.input_tokens || 0) + (it.output_tokens || 0))}</td>
11282
11144
  <td class="costs-td costs-td-mono">${it.cost_usd != null ? '$' + it.cost_usd.toFixed(2) : '—'}</td>
11145
+ <td class="costs-td"></td>
11283
11146
  <td class="costs-td costs-td-mono">${it.record_count || 0}</td>
11284
11147
  </tr>
11285
11148
  `) : ''}
@@ -11753,24 +11616,6 @@ function App() {
11753
11616
  setSelectedProjectRaw(value);
11754
11617
  }, []);
11755
11618
 
11756
- // Auto-select the first registered project when localStorage has no prior
11757
- // choice. Without this, items and costs both route to the default board
11758
- // (board.db) which doesn't match project board data — no badges render in
11759
- // a fresh Playwright or incognito session. Multi-project factories default
11760
- // to the first registered project; users can override via the sidebar.
11761
- useEffect(() => {
11762
- if (localStorage.getItem('beastmode-selected-project')) return;
11763
- fetch('/api/projects')
11764
- .then(r => r.ok ? r.json() : null)
11765
- .then(data => {
11766
- const projects = data && data.projects;
11767
- if (Array.isArray(projects) && projects.length > 0) {
11768
- setSelectedProject(projects[0].name);
11769
- }
11770
- })
11771
- .catch(() => {});
11772
- }, []);
11773
-
11774
11619
  // If ?view=pipeline or ?view=board is in the URL but no hash route is
11775
11620
  // set, the router would default to Dashboard. Redirect to #/board so
11776
11621
  // the board page renders (and BoardPage can read the ?view= param).
@@ -1 +1 @@
1
- 01f385be3fd97cf6775c43293b9ec2278ed1fa67
1
+ 24408242cd5d9e462deb310bcd8725e1f24cd414
@@ -1 +1 @@
1
- 20260516-164446-01f385b
1
+ 20260516-174800-2440824
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beastmode-develeap/beastmode",
3
- "version": "0.1.283",
3
+ "version": "0.1.285",
4
4
  "description": "BeastMode Dark Factory — turn intent into verified software",
5
5
  "type": "module",
6
6
  "bin": {