@jhizzard/termdeck 0.8.0 → 0.10.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 (28) hide show
  1. package/docs/orchestrator-guide.md +335 -0
  2. package/package.json +3 -1
  3. package/packages/cli/src/index.js +26 -3
  4. package/packages/cli/src/init-project.js +213 -0
  5. package/packages/cli/src/templates.js +84 -0
  6. package/packages/cli/templates/.claude-settings.json.tmpl +32 -0
  7. package/packages/cli/templates/.gitignore.tmpl +28 -0
  8. package/packages/cli/templates/CLAUDE.md.tmpl +35 -0
  9. package/packages/cli/templates/CONTRADICTIONS.md.tmpl +30 -0
  10. package/packages/cli/templates/README.md.tmpl +15 -0
  11. package/packages/cli/templates/RESTART-PROMPT.md.tmpl +38 -0
  12. package/packages/cli/templates/docs-orchestration-README.md.tmpl +29 -0
  13. package/packages/cli/templates/project_facts.md.tmpl +39 -0
  14. package/packages/client/public/app.js +781 -0
  15. package/packages/client/public/graph.html +104 -0
  16. package/packages/client/public/graph.js +683 -0
  17. package/packages/client/public/index.html +145 -0
  18. package/packages/client/public/style.css +1185 -0
  19. package/packages/server/src/graph-routes.js +555 -0
  20. package/packages/server/src/index.js +158 -5
  21. package/packages/server/src/orchestration-preview.js +256 -0
  22. package/packages/server/src/preflight.js +82 -0
  23. package/packages/server/src/rag.js +138 -0
  24. package/packages/server/src/setup/mnestra-migrations/009_memory_relationship_metadata.sql +126 -0
  25. package/packages/server/src/setup/mnestra-migrations/010_memory_recall_graph.sql +147 -0
  26. package/packages/server/src/setup/rumen/migrations/003_graph_inference_schedule.sql +49 -0
  27. package/packages/server/src/sprint-inject.js +156 -0
  28. package/packages/server/src/sprint-routes.js +503 -0
@@ -85,6 +85,10 @@
85
85
  // Sprint 19 T2: auto-open setup wizard if /api/setup reports firstRun.
86
86
  // Silent-fail if the endpoint isn't available yet (T1 not merged).
87
87
  maybeAutoOpenSetupWizard();
88
+
89
+ // Sprint 37 T1: orchestrator Guide right-rail. Lazy — fetches the doc
90
+ // on first expand to keep page load light.
91
+ setupGuideRail();
88
92
  }
89
93
 
90
94
  // ===== Create Terminal Panel =====
@@ -1448,6 +1452,7 @@
1448
1452
  if (prev && state.config.projects && state.config.projects[prev]) {
1449
1453
  sel.value = prev;
1450
1454
  }
1455
+ syncPreviewButton();
1451
1456
  }
1452
1457
 
1453
1458
  function openAddProjectModal() {
@@ -1519,6 +1524,411 @@
1519
1524
  }
1520
1525
  }
1521
1526
 
1527
+ // ===== Orchestration preview modal (Sprint 37 T3) =====
1528
+ // The preview button next to the project select shows what
1529
+ // `termdeck init --project <name>` would create for the currently
1530
+ // selected project. Disabled when no project is selected.
1531
+ function previewState() {
1532
+ if (!state.preview) state.preview = { current: null, busy: false };
1533
+ return state.preview;
1534
+ }
1535
+
1536
+ function syncPreviewButton() {
1537
+ const btn = document.getElementById('btnPreviewProject');
1538
+ if (!btn) return;
1539
+ const name = (document.getElementById('promptProject') || {}).value || '';
1540
+ btn.disabled = !name;
1541
+ btn.title = name
1542
+ ? `Preview orchestration scaffolding for "${name}"`
1543
+ : 'Select a project to preview its orchestration scaffolding';
1544
+ }
1545
+
1546
+ function setPpmStatus(msg, kind) {
1547
+ const el = document.getElementById('ppmStatus');
1548
+ if (!el) return;
1549
+ el.textContent = msg || '';
1550
+ el.classList.remove('error', 'ok');
1551
+ if (kind) el.classList.add(kind);
1552
+ }
1553
+
1554
+ function escHtml(s) {
1555
+ return String(s == null ? '' : s)
1556
+ .replace(/&/g, '&amp;')
1557
+ .replace(/</g, '&lt;')
1558
+ .replace(/>/g, '&gt;')
1559
+ .replace(/"/g, '&quot;')
1560
+ .replace(/'/g, '&#39;');
1561
+ }
1562
+
1563
+ function renderPreviewMeta(payload) {
1564
+ const el = document.getElementById('ppmMeta');
1565
+ if (!el) return;
1566
+ const tag = payload.exists
1567
+ ? '<span class="ppm-meta-tag exists">exists</span>'
1568
+ : '<span class="ppm-meta-tag fresh">fresh</span>';
1569
+ el.innerHTML =
1570
+ `<b>${escHtml(payload.projectName)}</b>${tag}<br>` +
1571
+ `→ ${escHtml(payload.targetPath)}`;
1572
+ }
1573
+
1574
+ function renderPreviewTree(payload) {
1575
+ const tree = document.getElementById('ppmTree');
1576
+ if (!tree) return;
1577
+ const total = (payload.wouldCreate || []).length + (payload.wouldSkip || []).length;
1578
+ if (total === 0) {
1579
+ tree.innerHTML = '<div class="ppm-empty">No templates returned. Check that the orchestration scaffolding is available on this server.</div>';
1580
+ return;
1581
+ }
1582
+
1583
+ const sections = [];
1584
+ if (payload.wouldCreate && payload.wouldCreate.length > 0) {
1585
+ sections.push(buildSection('Would create', 'create', payload.wouldCreate));
1586
+ }
1587
+ if (payload.created && payload.created.length > 0) {
1588
+ sections.push(buildSection('Created', 'create', payload.created));
1589
+ }
1590
+ if (payload.wouldSkip && payload.wouldSkip.length > 0) {
1591
+ sections.push(buildSection('Would skip', 'skip', payload.wouldSkip));
1592
+ }
1593
+ tree.innerHTML = sections.join('');
1594
+
1595
+ // Wire expand/collapse on row headers (event delegation).
1596
+ tree.querySelectorAll('.ppm-row-header').forEach((btn) => {
1597
+ btn.addEventListener('click', () => {
1598
+ const row = btn.closest('.ppm-row');
1599
+ if (row) row.classList.toggle('expanded');
1600
+ });
1601
+ });
1602
+ }
1603
+
1604
+ function buildSection(label, kind, entries) {
1605
+ const rows = entries.map((e) => buildRow(kind, e)).join('');
1606
+ return `<div class="ppm-section">
1607
+ <div class="ppm-section-label">${escHtml(label)} (${entries.length})</div>
1608
+ ${rows}
1609
+ </div>`;
1610
+ }
1611
+
1612
+ function buildRow(kind, entry) {
1613
+ const truncated = entry.totalLines > (entry.contentPreview || '').split('\n').length;
1614
+ const moreLines = truncated
1615
+ ? entry.totalLines - entry.contentPreview.split('\n').length
1616
+ : 0;
1617
+ const reason = entry.reason
1618
+ ? `<div class="ppm-row-skip-reason">${escHtml(entry.reason)}</div>`
1619
+ : '';
1620
+ const truncatedNote = moreLines > 0
1621
+ ? `<div class="ppm-row-truncated">… ${moreLines} more line${moreLines === 1 ? '' : 's'} (preview truncated)</div>`
1622
+ : '';
1623
+ return `<div class="ppm-row ${escHtml(kind)}">
1624
+ <button type="button" class="ppm-row-header" aria-label="Toggle preview">
1625
+ <span class="ppm-row-icon">▸</span>
1626
+ <span class="ppm-row-path">${escHtml(entry.path)}</span>
1627
+ <span class="ppm-row-meta">${entry.totalLines} ${entry.totalLines === 1 ? 'line' : 'lines'}</span>
1628
+ <span class="ppm-row-tag ${escHtml(kind)}">${escHtml(kind === 'skip' ? 'skip' : 'new')}</span>
1629
+ </button>
1630
+ <div class="ppm-row-body">
1631
+ ${reason}
1632
+ <pre>${escHtml(entry.contentPreview || '')}</pre>
1633
+ ${truncatedNote}
1634
+ </div>
1635
+ </div>`;
1636
+ }
1637
+
1638
+ async function loadPreview(name) {
1639
+ const ps = previewState();
1640
+ ps.current = name;
1641
+ setPpmStatus('Loading…', null);
1642
+ const tree = document.getElementById('ppmTree');
1643
+ if (tree) tree.innerHTML = '<div class="ppm-empty">Loading…</div>';
1644
+ const meta = document.getElementById('ppmMeta');
1645
+ if (meta) meta.textContent = '';
1646
+ const genBtn = document.getElementById('ppmGenerate');
1647
+ const forceCb = document.getElementById('ppmForce');
1648
+ if (genBtn) genBtn.disabled = true;
1649
+ if (forceCb) forceCb.checked = false;
1650
+
1651
+ try {
1652
+ const payload = await api('GET', `/api/projects/${encodeURIComponent(name)}/orchestration-preview`);
1653
+ if (!payload || payload.error) {
1654
+ setPpmStatus(payload && payload.error ? payload.error : 'Failed to load preview', 'error');
1655
+ return;
1656
+ }
1657
+ if (ps.current !== name) return; // user closed/changed before fetch returned
1658
+ renderPreviewMeta(payload);
1659
+ renderPreviewTree(payload);
1660
+ setPpmStatus('', null);
1661
+ if (genBtn) {
1662
+ // Enable Generate when there is at least one wouldCreate entry, OR
1663
+ // when the target dir exists (force overwrites preserved by checkbox).
1664
+ const hasNew = (payload.wouldCreate || []).length > 0;
1665
+ genBtn.disabled = !hasNew && !payload.exists;
1666
+ }
1667
+ } catch (err) {
1668
+ setPpmStatus(`Failed: ${(err && err.message) || err}`, 'error');
1669
+ }
1670
+ }
1671
+
1672
+ function openPreviewModal() {
1673
+ const sel = document.getElementById('promptProject');
1674
+ const name = sel && sel.value;
1675
+ if (!name) return;
1676
+ const modal = document.getElementById('previewProjectModal');
1677
+ if (!modal) return;
1678
+ modal.classList.add('open');
1679
+ loadPreview(name);
1680
+ }
1681
+
1682
+ function closePreviewModal() {
1683
+ const modal = document.getElementById('previewProjectModal');
1684
+ if (modal) modal.classList.remove('open');
1685
+ previewState().current = null;
1686
+ }
1687
+
1688
+ async function submitGenerate() {
1689
+ const ps = previewState();
1690
+ if (ps.busy || !ps.current) return;
1691
+ const force = !!document.getElementById('ppmForce').checked;
1692
+ const confirmMsg = force
1693
+ ? `Overwrite scaffolding files in "${ps.current}"? Existing files will be replaced.`
1694
+ : `Generate orchestration scaffolding for "${ps.current}"?`;
1695
+ if (!window.confirm(confirmMsg)) return;
1696
+
1697
+ const genBtn = document.getElementById('ppmGenerate');
1698
+ ps.busy = true;
1699
+ if (genBtn) genBtn.disabled = true;
1700
+ setPpmStatus('Generating…', null);
1701
+ try {
1702
+ const result = await api('POST',
1703
+ `/api/projects/${encodeURIComponent(ps.current)}/orchestration-preview/generate`,
1704
+ { force });
1705
+ if (!result || result.error) {
1706
+ setPpmStatus(result && result.error ? result.error : 'Generate failed', 'error');
1707
+ ps.busy = false;
1708
+ if (genBtn) genBtn.disabled = false;
1709
+ return;
1710
+ }
1711
+ renderPreviewMeta(result);
1712
+ renderPreviewTree(result);
1713
+ const count = (result.created || []).length;
1714
+ setPpmStatus(`Generated ${count} file${count === 1 ? '' : 's'} ✓`, 'ok');
1715
+ // Refresh the preview so the user sees the post-write state (every
1716
+ // file is now wouldSkip). Small delay so the success message reads.
1717
+ setTimeout(() => { if (ps.current) loadPreview(ps.current); }, 800);
1718
+ } catch (err) {
1719
+ setPpmStatus(`Failed: ${(err && err.message) || err}`, 'error');
1720
+ } finally {
1721
+ ps.busy = false;
1722
+ if (genBtn) genBtn.disabled = false;
1723
+ }
1724
+ }
1725
+
1726
+ // ===== Sprint runner modal (Sprint 37 T4) =====
1727
+ // Lets the user define a 4+1 sprint (name, version, goal, T1-T4 lanes,
1728
+ // worktree opt-in), POST /api/sprints to scaffold + spawn + inject, then
1729
+ // tail STATUS.md while lanes work.
1730
+ function sprintState() {
1731
+ if (!state.sprint) {
1732
+ state.sprint = { pollTimer: null, currentSprintName: null, currentProject: null };
1733
+ }
1734
+ return state.sprint;
1735
+ }
1736
+
1737
+ function setSprintStatus(msg, kind) {
1738
+ const el = document.getElementById('sprintStatusMsg');
1739
+ if (!el) return;
1740
+ el.textContent = msg || '';
1741
+ el.classList.remove('error', 'ok');
1742
+ if (kind) el.classList.add(kind);
1743
+ }
1744
+
1745
+ function openSprintModal() {
1746
+ const modal = document.getElementById('sprintModal');
1747
+ // Populate project dropdown from loaded config.
1748
+ const sel = document.getElementById('sprintProject');
1749
+ sel.innerHTML = '';
1750
+ const projects = Object.keys(state.config.projects || {});
1751
+ if (projects.length === 0) {
1752
+ const opt = document.createElement('option');
1753
+ opt.value = '';
1754
+ opt.textContent = '— add a project first —';
1755
+ sel.appendChild(opt);
1756
+ sel.disabled = true;
1757
+ } else {
1758
+ sel.disabled = false;
1759
+ for (const name of projects) {
1760
+ const opt = document.createElement('option');
1761
+ opt.value = name;
1762
+ opt.textContent = name;
1763
+ sel.appendChild(opt);
1764
+ }
1765
+ }
1766
+ // Reset form.
1767
+ document.getElementById('sprintName').value = '';
1768
+ document.getElementById('sprintTargetVersion').value = '';
1769
+ document.getElementById('sprintGoal').value = '';
1770
+ document.querySelectorAll('#sprintModal .sprint-lane-name').forEach((el) => { el.value = ''; });
1771
+ document.querySelectorAll('#sprintModal .sprint-lane-goal').forEach((el) => { el.value = ''; });
1772
+ document.getElementById('sprintWorktree').checked = true;
1773
+ document.getElementById('sprintAutoInject').checked = true;
1774
+ document.getElementById('sprintFormBody').style.display = '';
1775
+ document.getElementById('sprintResultPanel').style.display = 'none';
1776
+ setSprintStatus('', null);
1777
+ modal.classList.add('open');
1778
+ setTimeout(() => document.getElementById('sprintName').focus(), 50);
1779
+ }
1780
+
1781
+ function closeSprintModal() {
1782
+ const s = sprintState();
1783
+ if (s.pollTimer) {
1784
+ clearInterval(s.pollTimer);
1785
+ s.pollTimer = null;
1786
+ }
1787
+ document.getElementById('sprintModal').classList.remove('open');
1788
+ }
1789
+
1790
+ function readSprintLanes() {
1791
+ const lanes = [];
1792
+ const nameInputs = document.querySelectorAll('#sprintModal .sprint-lane-name');
1793
+ const goalInputs = document.querySelectorAll('#sprintModal .sprint-lane-goal');
1794
+ for (let i = 0; i < 4; i++) {
1795
+ lanes.push({
1796
+ name: (nameInputs[i] && nameInputs[i].value || '').trim(),
1797
+ goal: (goalInputs[i] && goalInputs[i].value || '').trim(),
1798
+ });
1799
+ }
1800
+ return lanes;
1801
+ }
1802
+
1803
+ async function submitSprint() {
1804
+ const project = document.getElementById('sprintProject').value;
1805
+ const name = document.getElementById('sprintName').value.trim();
1806
+ const targetVersion = document.getElementById('sprintTargetVersion').value.trim();
1807
+ const goal = document.getElementById('sprintGoal').value.trim();
1808
+ const worktree = document.getElementById('sprintWorktree').checked;
1809
+ const autoInject = document.getElementById('sprintAutoInject').checked;
1810
+ const lanes = readSprintLanes();
1811
+
1812
+ if (!project) { setSprintStatus('Pick a project.', 'error'); return; }
1813
+ if (!name) { setSprintStatus('Sprint name is required.', 'error'); return; }
1814
+ if (!/^[a-z0-9][a-z0-9-]{0,40}$/.test(name)) {
1815
+ setSprintStatus('Name must be a slug (lowercase a-z0-9 + hyphens, ≤40 chars).', 'error');
1816
+ return;
1817
+ }
1818
+ for (let i = 0; i < 4; i++) {
1819
+ if (!lanes[i].name) {
1820
+ setSprintStatus(`T${i + 1} lane name is required.`, 'error');
1821
+ return;
1822
+ }
1823
+ }
1824
+
1825
+ const btn = document.getElementById('sprintKickoff');
1826
+ btn.disabled = true;
1827
+ setSprintStatus('Scaffolding sprint, spawning panels, injecting boot prompts…', null);
1828
+
1829
+ try {
1830
+ const result = await api('POST', '/api/sprints', {
1831
+ project, name, targetVersion, goal, lanes, worktree, autoInject,
1832
+ });
1833
+ if (result && result.error) {
1834
+ setSprintStatus(result.error, 'error');
1835
+ btn.disabled = false;
1836
+ return;
1837
+ }
1838
+ renderSprintResult(result, project);
1839
+ // Reload sessions in the dashboard so the four new panels appear.
1840
+ try {
1841
+ const liveSessions = await api('GET', '/api/sessions');
1842
+ for (const s of liveSessions) {
1843
+ if (s.meta.status !== 'exited' && !state.sessions.has(s.id)) {
1844
+ createTerminalPanel(s);
1845
+ }
1846
+ }
1847
+ } catch {
1848
+ // Non-fatal — user can refresh.
1849
+ }
1850
+ startSprintStatusPoll(project, name);
1851
+ } catch (err) {
1852
+ setSprintStatus(`Failed: ${err && err.message || err}`, 'error');
1853
+ btn.disabled = false;
1854
+ }
1855
+ }
1856
+
1857
+ function renderSprintResult(result, project) {
1858
+ document.getElementById('sprintFormBody').style.display = 'none';
1859
+ const panel = document.getElementById('sprintResultPanel');
1860
+ panel.style.display = '';
1861
+ const meta = document.getElementById('sprintResultMeta');
1862
+ const sids = result.sessionIds || {};
1863
+ const wt = result.worktree ? 'on' : 'off';
1864
+ const inject = result.inject || {};
1865
+ const verifiedCount = Array.isArray(inject.lanes) ? inject.lanes.filter((l) => l.verified).length : 0;
1866
+ const pokedCount = Array.isArray(inject.lanes) ? inject.lanes.filter((l) => l.poked).length : 0;
1867
+ meta.innerHTML = [
1868
+ `<div>sprint dir: <code>${result.sprintDir}</code></div>`,
1869
+ `<div>worktree isolation: ${wt}</div>`,
1870
+ `<div>panels spawned: T1=${sids.T1 || '—'} · T2=${sids.T2 || '—'} · T3=${sids.T3 || '—'} · T4=${sids.T4 || '—'}</div>`,
1871
+ `<div>boot inject: verified ${verifiedCount}/4 · auto-poked ${pokedCount}</div>`,
1872
+ ].join('');
1873
+ // Reset lane status tiles to a "polling…" state.
1874
+ ['T1', 'T2', 'T3', 'T4'].forEach((laneId) => {
1875
+ const tile = panel.querySelector(`.sprint-lane-status[data-lane="${laneId}"]`);
1876
+ if (!tile) return;
1877
+ tile.querySelector('.counts').textContent = '—';
1878
+ tile.querySelector('.last-entry').textContent = 'polling…';
1879
+ });
1880
+ document.getElementById('sprintTail').textContent = '(tail loads after first STATUS.md write)';
1881
+ }
1882
+
1883
+ async function pollSprintStatus(project, sprintName) {
1884
+ try {
1885
+ const [statusRes, tailRes] = await Promise.all([
1886
+ fetch(`${API}/api/sprints/${encodeURIComponent(sprintName)}/status?project=${encodeURIComponent(project)}`),
1887
+ fetch(`${API}/api/sprints/${encodeURIComponent(sprintName)}/tail?project=${encodeURIComponent(project)}&lines=80`),
1888
+ ]);
1889
+ if (statusRes.ok) {
1890
+ const status = await statusRes.json();
1891
+ renderSprintLaneCounts(status);
1892
+ }
1893
+ if (tailRes.ok) {
1894
+ const tail = await tailRes.json();
1895
+ if (tail && typeof tail.tail === 'string') {
1896
+ document.getElementById('sprintTail').textContent = tail.tail;
1897
+ }
1898
+ }
1899
+ } catch {
1900
+ // Silently ignore poll errors; next tick retries.
1901
+ }
1902
+ }
1903
+
1904
+ function renderSprintLaneCounts(status) {
1905
+ const panel = document.getElementById('sprintResultPanel');
1906
+ if (!panel) return;
1907
+ ['T1', 'T2', 'T3', 'T4'].forEach((laneId) => {
1908
+ const tile = panel.querySelector(`.sprint-lane-status[data-lane="${laneId}"]`);
1909
+ if (!tile) return;
1910
+ const lane = status && status.lanes && status.lanes[laneId];
1911
+ if (!lane) {
1912
+ tile.querySelector('.counts').textContent = '—';
1913
+ tile.querySelector('.last-entry').textContent = 'awaiting first entry';
1914
+ return;
1915
+ }
1916
+ tile.querySelector('.counts').textContent =
1917
+ `${lane.finding} finding · ${lane.fixProposed} fix · ${lane.done} done`;
1918
+ tile.querySelector('.last-entry').textContent =
1919
+ lane.lastEntryAt ? `last: ${lane.lastEntryAt}` : 'awaiting first entry';
1920
+ });
1921
+ }
1922
+
1923
+ function startSprintStatusPoll(project, sprintName) {
1924
+ const s = sprintState();
1925
+ if (s.pollTimer) clearInterval(s.pollTimer);
1926
+ s.currentProject = project;
1927
+ s.currentSprintName = sprintName;
1928
+ pollSprintStatus(project, sprintName);
1929
+ s.pollTimer = setInterval(() => pollSprintStatus(project, sprintName), 3000);
1930
+ }
1931
+
1522
1932
  // ===== Rumen insights badge + briefing modal =====
1523
1933
  function rumenState() {
1524
1934
  if (!state.rumen) {
@@ -3254,6 +3664,18 @@
3254
3664
  if (e.key === 'Escape') { e.preventDefault(); closeAddProjectModal(); }
3255
3665
  });
3256
3666
 
3667
+ // Orchestration preview modal wiring (Sprint 37 T3)
3668
+ document.getElementById('btnPreviewProject').addEventListener('click', openPreviewModal);
3669
+ document.getElementById('promptProject').addEventListener('change', syncPreviewButton);
3670
+ document.getElementById('ppmClose').addEventListener('click', closePreviewModal);
3671
+ document.getElementById('ppmCancel').addEventListener('click', closePreviewModal);
3672
+ document.querySelector('#previewProjectModal .preview-project-backdrop').addEventListener('click', closePreviewModal);
3673
+ document.getElementById('ppmGenerate').addEventListener('click', submitGenerate);
3674
+ document.getElementById('previewProjectModal').addEventListener('keydown', (e) => {
3675
+ if (e.key === 'Escape') { e.preventDefault(); closePreviewModal(); }
3676
+ });
3677
+ syncPreviewButton();
3678
+
3257
3679
  // Status + config dropdowns (Sprint 9 T2): btn-status/btn-config were
3258
3680
  // stubs with no listeners. Each now opens a dropdown with live data
3259
3681
  // fetched from /api/status and /api/config. Reuses .health-dropdown
@@ -3268,6 +3690,16 @@
3268
3690
  // legacy config dropdown (renderConfigDropdown is kept as dead code).
3269
3691
  document.getElementById('btn-config').addEventListener('click', openSetupModal);
3270
3692
 
3693
+ // Sprint runner modal wiring (Sprint 37 T4)
3694
+ document.getElementById('btn-sprint').addEventListener('click', openSprintModal);
3695
+ document.getElementById('sprintCancel').addEventListener('click', closeSprintModal);
3696
+ document.getElementById('sprintResultClose').addEventListener('click', closeSprintModal);
3697
+ document.getElementById('sprintBackdrop').addEventListener('click', closeSprintModal);
3698
+ document.getElementById('sprintKickoff').addEventListener('click', submitSprint);
3699
+ document.getElementById('sprintModal').addEventListener('keydown', (e) => {
3700
+ if (e.key === 'Escape') { e.preventDefault(); closeSprintModal(); }
3701
+ });
3702
+
3271
3703
  // Onboarding tour wiring
3272
3704
  document.getElementById('btn-how').addEventListener('click', startTour);
3273
3705
  document.getElementById('tourNextBtn').addEventListener('click', nextTourStep);
@@ -3877,5 +4309,354 @@
3877
4309
  });
3878
4310
  }
3879
4311
 
4312
+ // ===== Orchestrator Guide right-rail (Sprint 37 T1) =====
4313
+ // Lazy-load docs/orchestrator-guide.md on first expand, render with a
4314
+ // small purpose-built markdown converter, build TOC from H2 headings,
4315
+ // wire search + contextual auto-expand. No external markdown library —
4316
+ // the no-build-step ethos rules out webpack/parcel pulls.
4317
+ const guideRailState = {
4318
+ loaded: false,
4319
+ loading: false,
4320
+ sections: [], // [{id, title, el, text}]
4321
+ activeSection: null,
4322
+ };
4323
+
4324
+ function setupGuideRail() {
4325
+ const rail = document.getElementById('guideRail');
4326
+ const toggle = document.getElementById('guideRailToggle');
4327
+ const closeBtn = document.getElementById('guideRailClose');
4328
+ const search = document.getElementById('guideSearch');
4329
+ if (!rail || !toggle) return;
4330
+
4331
+ toggle.addEventListener('click', () => toggleGuideRail());
4332
+ if (closeBtn) closeBtn.addEventListener('click', () => setGuideRailCollapsed(true));
4333
+ if (search) search.addEventListener('input', () => filterGuideSections(search.value));
4334
+
4335
+ // Keyboard: 'g' opens/closes the Guide when not in an input. Skip when
4336
+ // a modifier is held to avoid stomping browser shortcuts.
4337
+ document.addEventListener('keydown', (e) => {
4338
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
4339
+ if (e.key !== 'g' && e.key !== 'G') return;
4340
+ const tgt = e.target;
4341
+ const tag = (tgt && tgt.tagName) || '';
4342
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || (tgt && tgt.isContentEditable)) return;
4343
+ // xterm.js attaches a hidden textarea inside .term-panel; skip when
4344
+ // the focus is inside a terminal panel.
4345
+ if (tgt && typeof tgt.closest === 'function' && tgt.closest('.term-panel')) return;
4346
+ e.preventDefault();
4347
+ toggleGuideRail();
4348
+ });
4349
+
4350
+ // Contextual auto-expand on terminal focus. Reuses the existing
4351
+ // focusSessionById path: when a panel is focused, scroll the right-rail
4352
+ // to the "4+1 pattern" section so its content is one glance away.
4353
+ document.addEventListener('click', (e) => {
4354
+ if (rail.dataset.collapsed === 'true') return;
4355
+ const panel = e.target && typeof e.target.closest === 'function' && e.target.closest('.term-panel');
4356
+ if (panel) scrollGuideToSection('the-4-1-pattern');
4357
+ }, true);
4358
+ }
4359
+
4360
+ function toggleGuideRail() {
4361
+ const rail = document.getElementById('guideRail');
4362
+ if (!rail) return;
4363
+ const collapsed = rail.dataset.collapsed !== 'false';
4364
+ setGuideRailCollapsed(!collapsed);
4365
+ }
4366
+
4367
+ function setGuideRailCollapsed(collapsed) {
4368
+ const rail = document.getElementById('guideRail');
4369
+ const toggle = document.getElementById('guideRailToggle');
4370
+ if (!rail) return;
4371
+ rail.dataset.collapsed = collapsed ? 'true' : 'false';
4372
+ if (toggle) toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
4373
+ if (!collapsed && !guideRailState.loaded && !guideRailState.loading) {
4374
+ loadGuideDoc();
4375
+ }
4376
+ }
4377
+
4378
+ async function loadGuideDoc() {
4379
+ const content = document.getElementById('guideContent');
4380
+ const toc = document.getElementById('guideToc');
4381
+ if (!content) return;
4382
+ guideRailState.loading = true;
4383
+ try {
4384
+ const res = await fetch('/docs/orchestrator-guide.md');
4385
+ if (!res.ok) throw new Error('HTTP ' + res.status);
4386
+ const md = await res.text();
4387
+ const html = renderGuideMarkdown(md);
4388
+ content.innerHTML = html;
4389
+ wrapGuideSections(content);
4390
+ if (toc) toc.innerHTML = buildGuideToc(content);
4391
+ bindGuideTocClicks(toc);
4392
+ observeGuideScroll(content);
4393
+ guideRailState.loaded = true;
4394
+ } catch (err) {
4395
+ content.innerHTML = '<div class="guide-loading">Couldn\'t load Guide: ' + escapeHtml(String(err && err.message || err)) + '</div>';
4396
+ } finally {
4397
+ guideRailState.loading = false;
4398
+ }
4399
+ }
4400
+
4401
+ // Wrap each H2 + its trailing siblings (until next H2) in a <section>
4402
+ // element, so search/filtering can hide/show whole sections at once.
4403
+ function wrapGuideSections(root) {
4404
+ const nodes = Array.from(root.children);
4405
+ const sections = [];
4406
+ let current = null;
4407
+ for (const node of nodes) {
4408
+ if (node.tagName === 'H2') {
4409
+ if (current) sections.push(current);
4410
+ current = { heading: node, els: [node] };
4411
+ } else if (current) {
4412
+ current.els.push(node);
4413
+ }
4414
+ }
4415
+ if (current) sections.push(current);
4416
+
4417
+ // Replace flat children with <section> wrappers
4418
+ guideRailState.sections = [];
4419
+ for (const sec of sections) {
4420
+ const wrapper = document.createElement('section');
4421
+ wrapper.className = 'guide-section';
4422
+ const slug = (sec.heading.id) || slugify(sec.heading.textContent);
4423
+ wrapper.id = 'guide-sec-' + slug;
4424
+ sec.heading.id = slug; // anchor for TOC links
4425
+ sec.heading.parentNode.insertBefore(wrapper, sec.heading);
4426
+ for (const el of sec.els) wrapper.appendChild(el);
4427
+ guideRailState.sections.push({
4428
+ id: slug,
4429
+ title: sec.heading.textContent,
4430
+ el: wrapper,
4431
+ text: wrapper.textContent.toLowerCase(),
4432
+ });
4433
+ }
4434
+ }
4435
+
4436
+ function buildGuideToc(root) {
4437
+ const headings = root.querySelectorAll('section.guide-section > h2');
4438
+ const links = [];
4439
+ headings.forEach(h => {
4440
+ links.push('<a href="#' + escapeAttr(h.id) + '" data-section="' + escapeAttr(h.id) + '">' + escapeHtml(h.textContent) + '</a>');
4441
+ });
4442
+ return links.join('');
4443
+ }
4444
+
4445
+ function bindGuideTocClicks(toc) {
4446
+ if (!toc) return;
4447
+ toc.addEventListener('click', (e) => {
4448
+ const a = e.target && e.target.closest && e.target.closest('a[data-section]');
4449
+ if (!a) return;
4450
+ e.preventDefault();
4451
+ scrollGuideToSection(a.dataset.section);
4452
+ });
4453
+ }
4454
+
4455
+ function scrollGuideToSection(sectionId) {
4456
+ const content = document.getElementById('guideContent');
4457
+ if (!content) return;
4458
+ const target = document.getElementById(sectionId);
4459
+ if (!target) return;
4460
+ const rect = target.getBoundingClientRect();
4461
+ const containerRect = content.getBoundingClientRect();
4462
+ content.scrollTop += (rect.top - containerRect.top) - 8;
4463
+ markActiveSection(sectionId);
4464
+ }
4465
+
4466
+ function markActiveSection(sectionId) {
4467
+ const toc = document.getElementById('guideToc');
4468
+ if (!toc) return;
4469
+ toc.querySelectorAll('a[data-section]').forEach(a => {
4470
+ a.classList.toggle('active', a.dataset.section === sectionId);
4471
+ });
4472
+ guideRailState.activeSection = sectionId;
4473
+ }
4474
+
4475
+ function observeGuideScroll(content) {
4476
+ // Lightweight scroll-spy — on every scroll, find the topmost visible
4477
+ // H2 and mark its TOC entry active.
4478
+ let raf = 0;
4479
+ content.addEventListener('scroll', () => {
4480
+ if (raf) return;
4481
+ raf = requestAnimationFrame(() => {
4482
+ raf = 0;
4483
+ const rect = content.getBoundingClientRect();
4484
+ const headings = content.querySelectorAll('section.guide-section > h2');
4485
+ let topId = null;
4486
+ for (const h of headings) {
4487
+ const hRect = h.getBoundingClientRect();
4488
+ if (hRect.top - rect.top <= 24) topId = h.id;
4489
+ else break;
4490
+ }
4491
+ if (topId) markActiveSection(topId);
4492
+ });
4493
+ });
4494
+ }
4495
+
4496
+ function filterGuideSections(query) {
4497
+ const content = document.getElementById('guideContent');
4498
+ const toc = document.getElementById('guideToc');
4499
+ if (!content) return;
4500
+ const q = (query || '').trim().toLowerCase();
4501
+ content.classList.toggle('has-filter', !!q);
4502
+ let anyMatch = false;
4503
+ const matchedIds = new Set();
4504
+ for (const sec of guideRailState.sections) {
4505
+ const match = !q || sec.text.includes(q) || sec.title.toLowerCase().includes(q);
4506
+ sec.el.classList.toggle('hidden', !match);
4507
+ if (match) { anyMatch = true; matchedIds.add(sec.id); }
4508
+ }
4509
+ // Sync TOC visibility with section matches
4510
+ if (toc) {
4511
+ toc.querySelectorAll('a[data-section]').forEach(a => {
4512
+ const id = a.dataset.section;
4513
+ a.classList.toggle('hidden', !!q && !matchedIds.has(id));
4514
+ });
4515
+ }
4516
+ // Show "no matches" hint if needed
4517
+ let hint = content.querySelector('em.no-match');
4518
+ if (q && !anyMatch) {
4519
+ if (!hint) {
4520
+ hint = document.createElement('em');
4521
+ hint.className = 'no-match';
4522
+ content.appendChild(hint);
4523
+ }
4524
+ hint.textContent = 'No Guide section matches "' + query + '".';
4525
+ } else if (hint) {
4526
+ hint.remove();
4527
+ }
4528
+ }
4529
+
4530
+ function slugify(text) {
4531
+ return String(text || '')
4532
+ .toLowerCase()
4533
+ .replace(/[^\w\s-]/g, '')
4534
+ .trim()
4535
+ .replace(/\s+/g, '-')
4536
+ .replace(/-+/g, '-');
4537
+ }
4538
+
4539
+ // Tiny markdown converter — handles only what orchestrator-guide.md uses:
4540
+ // ATX headings, paragraphs, blockquotes, fenced code, bullet/numbered
4541
+ // lists, hr, tables (header + separator), bold, italic, inline code,
4542
+ // links. Resilient enough for our authored Guide; not a general renderer.
4543
+ function renderGuideMarkdown(md) {
4544
+ const lines = md.replace(/\r\n/g, '\n').split('\n');
4545
+ const out = [];
4546
+ let i = 0;
4547
+ while (i < lines.length) {
4548
+ const line = lines[i];
4549
+ // Fenced code block
4550
+ if (/^```/.test(line)) {
4551
+ const lang = line.replace(/^```/, '').trim();
4552
+ const buf = [];
4553
+ i++;
4554
+ while (i < lines.length && !/^```/.test(lines[i])) { buf.push(lines[i]); i++; }
4555
+ i++; // skip closing fence
4556
+ out.push('<pre><code' + (lang ? ' class="lang-' + escapeAttr(lang) + '"' : '') + '>' + escapeHtml(buf.join('\n')) + '</code></pre>');
4557
+ continue;
4558
+ }
4559
+ // Horizontal rule
4560
+ if (/^---\s*$/.test(line)) { out.push('<hr/>'); i++; continue; }
4561
+ // ATX headings
4562
+ const h = line.match(/^(#{1,6})\s+(.+)$/);
4563
+ if (h) {
4564
+ const level = h[1].length;
4565
+ out.push('<h' + level + '>' + renderInline(h[2]) + '</h' + level + '>');
4566
+ i++;
4567
+ continue;
4568
+ }
4569
+ // Blockquote (collect consecutive > lines)
4570
+ if (/^>\s?/.test(line)) {
4571
+ const buf = [];
4572
+ while (i < lines.length && /^>\s?/.test(lines[i])) {
4573
+ buf.push(lines[i].replace(/^>\s?/, ''));
4574
+ i++;
4575
+ }
4576
+ out.push('<blockquote>' + renderInline(buf.join(' ')) + '</blockquote>');
4577
+ continue;
4578
+ }
4579
+ // Table: header line then separator line then body until blank
4580
+ if (/\|/.test(line) && i + 1 < lines.length && /^\s*\|?\s*:?-+:?(\s*\|\s*:?-+:?)+\s*\|?\s*$/.test(lines[i + 1])) {
4581
+ const header = splitTableRow(line);
4582
+ i += 2;
4583
+ const rows = [];
4584
+ while (i < lines.length && /\|/.test(lines[i]) && lines[i].trim() !== '') {
4585
+ rows.push(splitTableRow(lines[i]));
4586
+ i++;
4587
+ }
4588
+ let html = '<table><thead><tr>';
4589
+ for (const cell of header) html += '<th>' + renderInline(cell) + '</th>';
4590
+ html += '</tr></thead><tbody>';
4591
+ for (const row of rows) {
4592
+ html += '<tr>';
4593
+ for (const cell of row) html += '<td>' + renderInline(cell) + '</td>';
4594
+ html += '</tr>';
4595
+ }
4596
+ html += '</tbody></table>';
4597
+ out.push(html);
4598
+ continue;
4599
+ }
4600
+ // Bullet list
4601
+ if (/^\s*[-*]\s+/.test(line)) {
4602
+ const buf = [];
4603
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
4604
+ buf.push(lines[i].replace(/^\s*[-*]\s+/, ''));
4605
+ i++;
4606
+ }
4607
+ out.push('<ul>' + buf.map(x => '<li>' + renderInline(x) + '</li>').join('') + '</ul>');
4608
+ continue;
4609
+ }
4610
+ // Numbered list
4611
+ if (/^\s*\d+\.\s+/.test(line)) {
4612
+ const buf = [];
4613
+ while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
4614
+ buf.push(lines[i].replace(/^\s*\d+\.\s+/, ''));
4615
+ i++;
4616
+ }
4617
+ out.push('<ol>' + buf.map(x => '<li>' + renderInline(x) + '</li>').join('') + '</ol>');
4618
+ continue;
4619
+ }
4620
+ // Blank line
4621
+ if (line.trim() === '') { i++; continue; }
4622
+ // Paragraph (collect consecutive non-special lines)
4623
+ const buf = [line];
4624
+ i++;
4625
+ while (i < lines.length && lines[i].trim() !== '' &&
4626
+ !/^(#{1,6}\s|>\s?|```|---\s*$|\s*[-*]\s+|\s*\d+\.\s+)/.test(lines[i]) &&
4627
+ !(/\|/.test(lines[i]) && i + 1 < lines.length && /^\s*\|?\s*:?-+:?/.test(lines[i + 1]))) {
4628
+ buf.push(lines[i]);
4629
+ i++;
4630
+ }
4631
+ out.push('<p>' + renderInline(buf.join(' ')) + '</p>');
4632
+ }
4633
+ return out.join('\n');
4634
+ }
4635
+
4636
+ function splitTableRow(line) {
4637
+ let s = line.trim();
4638
+ if (s.startsWith('|')) s = s.slice(1);
4639
+ if (s.endsWith('|')) s = s.slice(0, -1);
4640
+ return s.split('|').map(c => c.trim());
4641
+ }
4642
+
4643
+ // Inline markdown: code, bold, italic, links. Run on already-escaped HTML
4644
+ // so we don't expose injection paths via the source markdown — the Guide
4645
+ // is repo-controlled, but defense-in-depth is cheap.
4646
+ function renderInline(text) {
4647
+ let s = escapeHtml(text);
4648
+ // Inline code first (so its contents aren't re-processed for bold/italic)
4649
+ s = s.replace(/`([^`]+)`/g, (_, code) => '<code>' + code + '</code>');
4650
+ // Links [text](url)
4651
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
4652
+ return '<a href="' + escapeAttr(url) + '" target="_blank" rel="noopener">' + label + '</a>';
4653
+ });
4654
+ // Bold **text**
4655
+ s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
4656
+ // Italic *text* (avoid matching bold leftovers)
4657
+ s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>');
4658
+ return s;
4659
+ }
4660
+
3880
4661
  // Boot
3881
4662
  init();