@jhizzard/termdeck 0.7.3 → 0.9.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/auto-orchestrate.js +28 -22
- package/packages/cli/src/index.js +55 -11
- package/packages/cli/src/init-project.js +213 -0
- package/packages/cli/src/init-rumen.js +30 -33
- package/packages/cli/src/mcp-config.js +174 -0
- package/packages/cli/src/stack.js +61 -11
- 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 +895 -2
- package/packages/client/public/index.html +144 -0
- package/packages/client/public/style.css +931 -52
- package/packages/server/src/config.js +96 -0
- package/packages/server/src/index.js +198 -10
- package/packages/server/src/orchestration-preview.js +256 -0
- package/packages/server/src/rag.js +43 -0
- package/packages/server/src/sprint-inject.js +156 -0
- package/packages/server/src/sprint-routes.js +503 -0
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
async function init() {
|
|
26
26
|
// Load config
|
|
27
27
|
state.config = await api('GET', '/api/config');
|
|
28
|
+
updateRagIndicator();
|
|
28
29
|
|
|
29
30
|
// Populate project dropdown
|
|
30
31
|
const sel = document.getElementById('promptProject');
|
|
@@ -84,6 +85,10 @@
|
|
|
84
85
|
// Sprint 19 T2: auto-open setup wizard if /api/setup reports firstRun.
|
|
85
86
|
// Silent-fail if the endpoint isn't available yet (T1 not merged).
|
|
86
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();
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
// ===== Create Terminal Panel =====
|
|
@@ -247,6 +252,16 @@
|
|
|
247
252
|
case 'status_broadcast':
|
|
248
253
|
updateGlobalStats(msg.sessions);
|
|
249
254
|
break;
|
|
255
|
+
case 'config_changed':
|
|
256
|
+
// Sprint 36 T3 Deliverable A: server-broadcast on PATCH /api/config.
|
|
257
|
+
// Each open panel WebSocket receives one copy; the handler is
|
|
258
|
+
// idempotent so multiple receipts settle the same state.
|
|
259
|
+
if (msg.config) {
|
|
260
|
+
state.config = { ...state.config, ...msg.config };
|
|
261
|
+
if (typeof renderSettingsPanel === 'function') renderSettingsPanel();
|
|
262
|
+
if (typeof updateRagIndicator === 'function') updateRagIndicator();
|
|
263
|
+
}
|
|
264
|
+
break;
|
|
250
265
|
}
|
|
251
266
|
} catch (err) { console.error('[client] ws message parse failed:', err); }
|
|
252
267
|
};
|
|
@@ -1437,6 +1452,7 @@
|
|
|
1437
1452
|
if (prev && state.config.projects && state.config.projects[prev]) {
|
|
1438
1453
|
sel.value = prev;
|
|
1439
1454
|
}
|
|
1455
|
+
syncPreviewButton();
|
|
1440
1456
|
}
|
|
1441
1457
|
|
|
1442
1458
|
function openAddProjectModal() {
|
|
@@ -1508,6 +1524,411 @@
|
|
|
1508
1524
|
}
|
|
1509
1525
|
}
|
|
1510
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
|
+
|
|
1511
1932
|
// ===== Rumen insights badge + briefing modal =====
|
|
1512
1933
|
function rumenState() {
|
|
1513
1934
|
if (!state.rumen) {
|
|
@@ -2299,8 +2720,17 @@
|
|
|
2299
2720
|
// Explicitly show the spotlight. CSS default is `display:none` so the
|
|
2300
2721
|
// 9999px box-shadow doesn't darken the page before/after a tour runs.
|
|
2301
2722
|
document.getElementById('tourSpotlight').style.display = 'block';
|
|
2302
|
-
|
|
2303
|
-
|
|
2723
|
+
// Defensive cleanup: if ensurePanelForTour or renderTourStep throws
|
|
2724
|
+
// after the spotlight is shown, the 9999px box-shadow stays up with no
|
|
2725
|
+
// tooltip on top — that's the "dark veil" symptom users hit with no
|
|
2726
|
+
// visible way out. Roll back to a clean state on any failure.
|
|
2727
|
+
try {
|
|
2728
|
+
await ensurePanelForTour();
|
|
2729
|
+
renderTourStep();
|
|
2730
|
+
} catch (err) {
|
|
2731
|
+
console.error('[tour] start failed, rolling back:', err);
|
|
2732
|
+
endTour();
|
|
2733
|
+
}
|
|
2304
2734
|
}
|
|
2305
2735
|
|
|
2306
2736
|
function nextTourStep() {
|
|
@@ -2523,6 +2953,7 @@
|
|
|
2523
2953
|
<button type="button" class="setup-close" id="setupClose" aria-label="Close">×</button>
|
|
2524
2954
|
</header>
|
|
2525
2955
|
<div class="setup-body">
|
|
2956
|
+
<div class="setup-settings" id="setupSettings"></div>
|
|
2526
2957
|
<div class="setup-tiers" id="setupTiers">
|
|
2527
2958
|
<div class="setup-loading">Checking tier status…</div>
|
|
2528
2959
|
</div>
|
|
@@ -2553,6 +2984,7 @@
|
|
|
2553
2984
|
ensureSetupModal();
|
|
2554
2985
|
document.getElementById('setupModal').classList.add('open');
|
|
2555
2986
|
setupModalOpen = true;
|
|
2987
|
+
renderSettingsPanel();
|
|
2556
2988
|
await refreshSetupStatus();
|
|
2557
2989
|
}
|
|
2558
2990
|
|
|
@@ -2562,6 +2994,96 @@
|
|
|
2562
2994
|
setupModalOpen = false;
|
|
2563
2995
|
}
|
|
2564
2996
|
|
|
2997
|
+
// ===== Settings panel inside the setup modal (Sprint 36 T3 Deliverable A) =====
|
|
2998
|
+
// Renders the writable subset of /api/config — currently just the RAG toggle.
|
|
2999
|
+
// Body is mutated in place; the panel is idempotent so config_changed WS
|
|
3000
|
+
// events can call it without reflow flicker.
|
|
3001
|
+
function renderSettingsPanel() {
|
|
3002
|
+
const el = document.getElementById('setupSettings');
|
|
3003
|
+
if (!el) return;
|
|
3004
|
+
const cfg = state.config || {};
|
|
3005
|
+
const intent = !!cfg.ragConfigEnabled;
|
|
3006
|
+
const effective = !!cfg.ragEnabled;
|
|
3007
|
+
const supabaseConfigured = !!cfg.ragSupabaseConfigured;
|
|
3008
|
+
|
|
3009
|
+
// Mismatch: user enabled RAG in config but Supabase isn't wired → show
|
|
3010
|
+
// a hint so the toggle's "ON but not pushing" state is explainable.
|
|
3011
|
+
const mismatch = intent && !effective && !supabaseConfigured;
|
|
3012
|
+
|
|
3013
|
+
const offCopy = 'MCP-only mode. Memory tools available through Claude Code; the in-CLI <code>termdeck flashback</code> command and the hybrid search are disabled. Faster boot, slimmer surface.';
|
|
3014
|
+
const onCopy = 'Enables <code>termdeck flashback</code> and the in-CLI hybrid search. Requires a Mnestra connection at boot — adds a few hundred ms to startup.';
|
|
3015
|
+
|
|
3016
|
+
el.innerHTML = `
|
|
3017
|
+
<div class="settings-section">
|
|
3018
|
+
<h4 class="settings-heading">RAG mode</h4>
|
|
3019
|
+
<div class="settings-row">
|
|
3020
|
+
<label class="toggle" for="settingsRagToggle">
|
|
3021
|
+
<input type="checkbox" id="settingsRagToggle" ${intent ? 'checked' : ''}>
|
|
3022
|
+
<span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span>
|
|
3023
|
+
<span class="toggle-label">${intent ? 'On' : 'Off'}</span>
|
|
3024
|
+
</label>
|
|
3025
|
+
<p class="settings-copy">${intent ? onCopy : offCopy}</p>
|
|
3026
|
+
</div>
|
|
3027
|
+
${mismatch ? `
|
|
3028
|
+
<div class="settings-warn">
|
|
3029
|
+
RAG is enabled in <code>config.yaml</code> but Supabase isn't configured yet, so it isn't actually pushing.
|
|
3030
|
+
Configure Tier 2 below or run <code>npx @jhizzard/termdeck-stack</code>.
|
|
3031
|
+
</div>
|
|
3032
|
+
` : ''}
|
|
3033
|
+
</div>
|
|
3034
|
+
`;
|
|
3035
|
+
|
|
3036
|
+
const toggle = document.getElementById('settingsRagToggle');
|
|
3037
|
+
if (toggle) {
|
|
3038
|
+
toggle.addEventListener('change', async (e) => {
|
|
3039
|
+
const desired = !!e.target.checked;
|
|
3040
|
+
// Optimistic UI: lock the toggle while the round-trip is in flight.
|
|
3041
|
+
toggle.disabled = true;
|
|
3042
|
+
try {
|
|
3043
|
+
const updated = await api('PATCH', '/api/config', { rag: { enabled: desired } });
|
|
3044
|
+
state.config = { ...state.config, ...updated };
|
|
3045
|
+
renderSettingsPanel();
|
|
3046
|
+
updateRagIndicator();
|
|
3047
|
+
} catch (err) {
|
|
3048
|
+
console.error('[settings] PATCH /api/config failed:', err);
|
|
3049
|
+
// Revert: refetch and re-render.
|
|
3050
|
+
try {
|
|
3051
|
+
state.config = await api('GET', '/api/config');
|
|
3052
|
+
renderSettingsPanel();
|
|
3053
|
+
} catch {}
|
|
3054
|
+
} finally {
|
|
3055
|
+
const t = document.getElementById('settingsRagToggle');
|
|
3056
|
+
if (t) t.disabled = false;
|
|
3057
|
+
}
|
|
3058
|
+
});
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
// Topbar RAG indicator. The #stat-rag stub in index.html was hidden by
|
|
3063
|
+
// Sprint 9 T2; re-purpose it as a live state line so users can see, at a
|
|
3064
|
+
// glance, what the toggle is doing without opening Settings each time.
|
|
3065
|
+
function updateRagIndicator() {
|
|
3066
|
+
const el = document.getElementById('stat-rag');
|
|
3067
|
+
if (!el) return;
|
|
3068
|
+
const cfg = state.config || {};
|
|
3069
|
+
const intent = !!cfg.ragConfigEnabled;
|
|
3070
|
+
const effective = !!cfg.ragEnabled;
|
|
3071
|
+
el.style.display = '';
|
|
3072
|
+
if (effective) {
|
|
3073
|
+
el.textContent = 'RAG · on';
|
|
3074
|
+
el.className = 'topbar-stat rag-on';
|
|
3075
|
+
el.title = 'Mnestra hybrid search + termdeck flashback enabled';
|
|
3076
|
+
} else if (intent) {
|
|
3077
|
+
el.textContent = 'RAG · pending';
|
|
3078
|
+
el.className = 'topbar-stat rag-pending';
|
|
3079
|
+
el.title = 'RAG enabled in config.yaml but Supabase not wired — see Settings';
|
|
3080
|
+
} else {
|
|
3081
|
+
el.textContent = 'RAG · mcp-only';
|
|
3082
|
+
el.className = 'topbar-stat rag-off';
|
|
3083
|
+
el.title = 'MCP-only mode; toggle in Settings to enable';
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
|
|
2565
3087
|
async function refreshSetupStatus() {
|
|
2566
3088
|
const tiersEl = document.getElementById('setupTiers');
|
|
2567
3089
|
const subtitle = document.getElementById('setupSubtitle');
|
|
@@ -3142,6 +3664,18 @@
|
|
|
3142
3664
|
if (e.key === 'Escape') { e.preventDefault(); closeAddProjectModal(); }
|
|
3143
3665
|
});
|
|
3144
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
|
+
|
|
3145
3679
|
// Status + config dropdowns (Sprint 9 T2): btn-status/btn-config were
|
|
3146
3680
|
// stubs with no listeners. Each now opens a dropdown with live data
|
|
3147
3681
|
// fetched from /api/status and /api/config. Reuses .health-dropdown
|
|
@@ -3156,6 +3690,16 @@
|
|
|
3156
3690
|
// legacy config dropdown (renderConfigDropdown is kept as dead code).
|
|
3157
3691
|
document.getElementById('btn-config').addEventListener('click', openSetupModal);
|
|
3158
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
|
+
|
|
3159
3703
|
// Onboarding tour wiring
|
|
3160
3704
|
document.getElementById('btn-how').addEventListener('click', startTour);
|
|
3161
3705
|
document.getElementById('tourNextBtn').addEventListener('click', nextTourStep);
|
|
@@ -3765,5 +4309,354 @@
|
|
|
3765
4309
|
});
|
|
3766
4310
|
}
|
|
3767
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
|
+
|
|
3768
4661
|
// Boot
|
|
3769
4662
|
init();
|