@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.
- package/docs/orchestrator-guide.md +335 -0
- package/package.json +3 -1
- package/packages/cli/src/index.js +26 -3
- package/packages/cli/src/init-project.js +213 -0
- package/packages/cli/src/templates.js +84 -0
- package/packages/cli/templates/.claude-settings.json.tmpl +32 -0
- package/packages/cli/templates/.gitignore.tmpl +28 -0
- package/packages/cli/templates/CLAUDE.md.tmpl +35 -0
- package/packages/cli/templates/CONTRADICTIONS.md.tmpl +30 -0
- package/packages/cli/templates/README.md.tmpl +15 -0
- package/packages/cli/templates/RESTART-PROMPT.md.tmpl +38 -0
- package/packages/cli/templates/docs-orchestration-README.md.tmpl +29 -0
- package/packages/cli/templates/project_facts.md.tmpl +39 -0
- package/packages/client/public/app.js +781 -0
- package/packages/client/public/graph.html +104 -0
- package/packages/client/public/graph.js +683 -0
- package/packages/client/public/index.html +145 -0
- package/packages/client/public/style.css +1185 -0
- package/packages/server/src/graph-routes.js +555 -0
- package/packages/server/src/index.js +158 -5
- package/packages/server/src/orchestration-preview.js +256 -0
- package/packages/server/src/preflight.js +82 -0
- package/packages/server/src/rag.js +138 -0
- package/packages/server/src/setup/mnestra-migrations/009_memory_relationship_metadata.sql +126 -0
- package/packages/server/src/setup/mnestra-migrations/010_memory_recall_graph.sql +147 -0
- package/packages/server/src/setup/rumen/migrations/003_graph_inference_schedule.sql +49 -0
- package/packages/server/src/sprint-inject.js +156 -0
- 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, '&')
|
|
1557
|
+
.replace(/</g, '<')
|
|
1558
|
+
.replace(/>/g, '>')
|
|
1559
|
+
.replace(/"/g, '"')
|
|
1560
|
+
.replace(/'/g, ''');
|
|
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();
|