fdeops 3.22.2 → 3.24.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/bin/fde.js CHANGED
@@ -25,7 +25,7 @@
25
25
  * fde capture session-end snapshot → context.md (hooks use this)
26
26
  * fde preserve pre-compaction context snapshot (hook-internal; hooks use this)
27
27
  * fde status [--all] value ledger first, then trust (pass --all for portfolio)
28
- * fde dashboard [--all] current engagement fieldbook (default) or all (--all)
28
+ * fde dashboard [--all] [--open] [--out <path>] bound fieldbook, or all (--all)
29
29
  * fde vault derived Obsidian vault of the fieldbook (disposable; --redacted)
30
30
  */
31
31
  const fs = require('fs')
@@ -58,6 +58,26 @@ function sh(cmd, cwd) {
58
58
  } catch (_) { return '' }
59
59
  }
60
60
 
61
+ // Hex hashes only - never a shell. True when olderHash is an ancestor of newerHash.
62
+ function gitIsAncestor(eng, olderHash, newerHash) {
63
+ if (!olderHash || !newerHash || olderHash === newerHash) return false
64
+ if (!/^[0-9a-f]{7,64}$/i.test(olderHash) || !/^[0-9a-f]{7,64}$/i.test(newerHash)) return false
65
+ try {
66
+ execFileSync('git', ['merge-base', '--is-ancestor', olderHash, newerHash], {
67
+ cwd: eng, stdio: 'ignore', timeout: 15000,
68
+ })
69
+ return true
70
+ } catch (_) { return false }
71
+ }
72
+
73
+ function gitLogHash(eng, args) {
74
+ try {
75
+ return execFileSync('git', ['log', '-1', '--format=%H', ...args], {
76
+ cwd: eng, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 15000,
77
+ }).trim()
78
+ } catch (_) { return '' }
79
+ }
80
+
61
81
  function slugify(name) {
62
82
  return String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'engagement'
63
83
  }
@@ -779,7 +799,7 @@ function firstLine(md, maxLen) {
779
799
  // not carry Working theory / Evidence / Differs from brief, do not scrape a
780
800
  // first line that might be the inherited brief and label it truth.
781
801
  function parseReality(md, maxLen) {
782
- const theory = (md.match(/\*\*Working theory:\*\*\s*(.*)/i) || [])[1]
802
+ const theory = (md.match(/\*\*Working theory:\*\*[^\S\r\n]*(.*)/i) || [])[1]
783
803
  const hasSchema = /\*\*(Working theory|Evidence|Differs from brief how):\*\*/i.test(md)
784
804
  const theoryText = (theory || '').trim()
785
805
  if (theoryText) {
@@ -941,6 +961,7 @@ function colIndex(headers, rx) { return headers.findIndex(h => rx.test(h)) }
941
961
  const {
942
962
  personFromSignalText,
943
963
  signalSubjectKey,
964
+ isSignalNameNoise,
944
965
  nextActionLine,
945
966
  computeSignals,
946
967
  resumeTriage,
@@ -983,10 +1004,14 @@ function displayNameFromSignalText(text) {
983
1004
  const person = personFromSignalText(text)
984
1005
  if (person) return person
985
1006
  const t = String(text).trim()
986
- const proper = t.match(/^([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)/)
987
- if (proper) return proper[1]
988
- const word = t.split(/\s+/).find(w => w && !/^(dr|mr|mrs|ms)\.?$/i.test(w))
989
- return word ? word.replace(/[^A-Za-z0-9.-]/g, '') : t.slice(0, 24)
1007
+ const words = t.split(/\s+/).filter(w => {
1008
+ const bare = w.replace(/[^A-Za-z0-9.-]/g, '')
1009
+ return bare.length >= 2 && !isSignalNameNoise(bare) && !/^(dr|mr|mrs|ms)\.?$/i.test(bare)
1010
+ })
1011
+ if (!words.length) return ''
1012
+ const proper = words.find(w => /^[A-Z][a-z]+(?:\s+[A-Z][a-z]+)?$/.test(w) || /^[A-Z]{2,}$/.test(w))
1013
+ if (proper && !isSignalNameNoise(proper)) return proper.replace(/[^A-Za-z0-9. -]/g, '')
1014
+ return words.slice(0, 2).join(' ').replace(/[^A-Za-z0-9. -]/g, '')
990
1015
  }
991
1016
 
992
1017
  // Stakeholders for prep/dashboard: table rows PLUS people who only appear in
@@ -1043,8 +1068,10 @@ function extractStakeholders(eng) {
1043
1068
  const cur = byKey.get(key)
1044
1069
  byKey.set(key, { ...cur, signal: h.signal, note: cur.note || h.text.slice(0, 80) })
1045
1070
  } else {
1071
+ const name = displayNameFromSignalText(h.text)
1072
+ if (!name || isSignalNameNoise(name)) continue
1046
1073
  byKey.set(key, {
1047
- name: displayNameFromSignalText(h.text),
1074
+ name,
1048
1075
  role: '',
1049
1076
  note: h.text.slice(0, 80),
1050
1077
  signal: h.signal,
@@ -1781,6 +1808,101 @@ function writeProposal(eng, text) {
1781
1808
  return { proposePath, clean, blocks }
1782
1809
  }
1783
1810
 
1811
+ function approvedStamp(text) {
1812
+ const m = String(text || '').match(/\[approved:\s*([^\]]+)\]/i)
1813
+ return m ? m[1].trim() : ''
1814
+ }
1815
+
1816
+ function stripApprovedStamp(text) {
1817
+ return String(text || '').replace(/\s*\[approved:\s*[^\]]+\]/i, '').trim()
1818
+ }
1819
+
1820
+ // One screen a human can confirm in two minutes. The file-by-file routing
1821
+ // still prints after this - agents edit prefixes; people read this.
1822
+ function printDebriefReview(text) {
1823
+ const buckets = { decided: [], asked: [], open: [], next: [], signer: [] }
1824
+ for (const raw of String(text || '').split('\n')) {
1825
+ const line = raw.trim().replace(/^[-*+]\s+/, '')
1826
+ if (!line) continue
1827
+ const m = line.match(/^(decision|risk|delivery|contact|next|signer):\s*(.+)$/i)
1828
+ if (!m) continue
1829
+ const type = m[1].toLowerCase()
1830
+ const body = m[2]
1831
+ if (type === 'decision') {
1832
+ const who = approvedStamp(body)
1833
+ const core = previewLine(stripApprovedStamp(body), 90)
1834
+ buckets.decided.push(who ? `${core} (approved ${who})` : `${core} (unconfirmed)`)
1835
+ } else if (type === 'delivery') {
1836
+ buckets.asked.push(previewLine(body, 100))
1837
+ } else if (type === 'risk') {
1838
+ buckets.open.push(previewLine(body, 100))
1839
+ } else if (type === 'next') {
1840
+ buckets.next.push(previewLine(body, 100))
1841
+ } else if (type === 'signer') {
1842
+ buckets.signer.push(previewLine(body, 80))
1843
+ }
1844
+ }
1845
+ console.log('REVIEW (one screen - confirm once, then apply)\n')
1846
+ const order = [
1847
+ ['decided', buckets.decided],
1848
+ ['asked', buckets.asked],
1849
+ ['open', buckets.open],
1850
+ ['next', buckets.next],
1851
+ ['signer', buckets.signer],
1852
+ ]
1853
+ let any = false
1854
+ for (const [label, items] of order) {
1855
+ if (!items.length) continue
1856
+ any = true
1857
+ console.log(` ${label}:`)
1858
+ for (const item of items) console.log(` - ${item}`)
1859
+ }
1860
+ if (!any) console.log(' (nothing prefixed yet - edit .debrief-propose, then apply)')
1861
+ console.log('')
1862
+ }
1863
+
1864
+ function latestDatedDecision(md) {
1865
+ let latest = { date: '', line: '' }
1866
+ for (const raw of String(md || '').split('\n')) {
1867
+ const t = raw.trim()
1868
+ const m = t.match(/^[-*]\s*\[(\d{4}-\d{2}-\d{2})\]/)
1869
+ if (!m) continue
1870
+ if (m[1] >= latest.date) latest = { date: m[1], line: t }
1871
+ }
1872
+ return latest
1873
+ }
1874
+
1875
+ function formatDecisionRecord(line) {
1876
+ const raw = String(line || '').trim().replace(/^[-*]\s*/, '')
1877
+ const who = approvedStamp(raw)
1878
+ const core = stripApprovedStamp(raw)
1879
+ const stamp = who ? `(approved ${who})` : '(unconfirmed)'
1880
+ return previewLine(`${core} ${stamp}`, 110)
1881
+ }
1882
+
1883
+ function changeReviewIssues(eng) {
1884
+ const issues = []
1885
+ const del = latestDeliveryEntry(readClean(eng, 'delivery.md'))
1886
+ const dec = latestDatedDecision(readClean(eng, 'decisions.md'))
1887
+ const delHash = del.line
1888
+ ? sh(`git log -1 --format=%H -S${JSON.stringify(del.line)} -- delivery.md`, eng)
1889
+ : ''
1890
+ if (del.date && dec.date && dec.date > del.date && delHash) {
1891
+ issues.push(
1892
+ 'a decision landed after the last delivery line - review whether what you are shipping still matches'
1893
+ )
1894
+ }
1895
+ if (claimedValueRows(eng).claimed && delHash) {
1896
+ const sigHash = gitLogHash(eng, ['-GStakeholder who signs off|also named:', '--', 'success.md'])
1897
+ if (sigHash && gitIsAncestor(eng, delHash, sigHash)) {
1898
+ issues.push(
1899
+ 'the signer line changed while a measured number is still unaccepted - pending acceptance may need a new yes'
1900
+ )
1901
+ }
1902
+ }
1903
+ return issues
1904
+ }
1905
+
1784
1906
  function readSealCount(eng) {
1785
1907
  try {
1786
1908
  const n = parseInt(fs.readFileSync(path.join(eng, DEBRIEF_SEAL), 'utf8').trim(), 10)
@@ -1904,7 +2026,9 @@ function cmdDebrief(args) {
1904
2026
  if (smart) {
1905
2027
  const { proposePath, clean, blocks } = writeProposal(eng, smartProposeText(input))
1906
2028
  console.log('SMART PROPOSE (heuristic - review before apply; no new facts invented beyond line rewrites)\n')
2029
+ printDebriefReview(clean)
1907
2030
  console.log('Prefix vocabulary (lines that route): decision: risk: delivery: contact: next: signer:')
2031
+ console.log('Optional on a decision: [approved: Name YYYY-MM-DD]. Missing means unconfirmed.')
1908
2032
  console.log('Everything else → context.md. Keep the prefixes; the preview gate stays.\n')
1909
2033
  routeDebriefInput(eng, clean, { dry: true, force, sealed: blocks })
1910
2034
  if (!apply) {
@@ -2046,6 +2170,7 @@ function cmdIngest(args) {
2046
2170
  }
2047
2171
  const { proposePath, clean, blocks } = writeProposal(eng, smartProposeText(input))
2048
2172
  console.log(`INGEST PROPOSE from ${path.basename(item)} (via:${source})\n`)
2173
+ printDebriefReview(clean)
2049
2174
  routeDebriefInput(eng, clean, { dry: true, force: false, sealed: blocks })
2050
2175
  console.log(`\nproposal saved → ${proposePath}`)
2051
2176
  console.log('confirm: fde ingest apply')
@@ -2516,6 +2641,7 @@ function collectDoctorIssues(eng) {
2516
2641
  }
2517
2642
  const reality = parseReality(readClean(eng, 'reality.md'), 220)
2518
2643
  if (reality.missing) issues.push(reality.missing)
2644
+ issues.push(...changeReviewIssues(eng))
2519
2645
  return issues
2520
2646
  }
2521
2647
 
@@ -2566,7 +2692,8 @@ function findAmbiguousStakeholders(eng) {
2566
2692
  for (const name of forms) {
2567
2693
  const key = signalSubjectKey(name)
2568
2694
  if (!key || key.startsWith('anon:')) continue
2569
- const norm = name.replace(/\s+/g, ' ').trim().toLowerCase()
2695
+ const norm = name.replace(/\([^)]*\)/g, '').replace(/\s+/g, ' ').trim().toLowerCase()
2696
+ if (!norm || norm.length < 2) continue
2570
2697
  if (!byKey.has(key)) byKey.set(key, new Set())
2571
2698
  byKey.get(key).add(norm)
2572
2699
  }
@@ -2654,6 +2781,7 @@ function parseValueLedger(eng) {
2654
2781
  promised: colIndex(table.headers, /promised/i),
2655
2782
  measured: colIndex(table.headers, /measured/i),
2656
2783
  accepted: colIndex(table.headers, /accept/i),
2784
+ evidence: colIndex(table.headers, /evidence/i),
2657
2785
  }
2658
2786
  const cell = (row, i) => (i === -1 ? '' : String(row[i] || '').trim())
2659
2787
  const rows = []
@@ -2668,7 +2796,8 @@ function parseValueLedger(eng) {
2668
2796
  let state = 'unmeasured'
2669
2797
  if (!measuredPending && acceptedPending) state = 'claimed'
2670
2798
  else if (!measuredPending) state = 'accepted'
2671
- rows.push({ slice, promised, measured, accepted, state })
2799
+ const evidence = cell(row, idx.evidence)
2800
+ rows.push({ slice, promised, measured, accepted, evidence, evidenceMissing: !evidence || PENDING_CELL_RE.test(evidence), state })
2672
2801
  }
2673
2802
  return { rows, columnMissing: idx.accepted === -1 }
2674
2803
  }
@@ -2768,7 +2897,7 @@ function hygieneTriageLines(eng) {
2768
2897
  const top = issues[0].replace(/\s+/g, ' ').trim().slice(0, 72)
2769
2898
  return [
2770
2899
  ` hygiene: ${issues.length} issue(s) - ${top}${issues[0].length > 72 ? '…' : ''}`,
2771
- ' → say "@fde clean up the fieldbook" when ready (agent runs fde doctor; nothing auto-rewrites)',
2900
+ ' → say "@fde clean up the fieldbook" when ready (agent runs fde doctor; nothing auto-rewrites), or: fde doctor',
2772
2901
  ]
2773
2902
  }
2774
2903
 
@@ -2798,7 +2927,7 @@ function recordDigest(eng) {
2798
2927
  }
2799
2928
  const decisions = readClean(eng, 'decisions.md').split('\n')
2800
2929
  .filter(l => /^-\s*\[\d{4}-\d{2}-\d{2}\]/.test(l.trim())).slice(-2)
2801
- for (const d of decisions) lines.push(` decided: ${d.trim().replace(/^-\s*/, '').slice(0, 110)}`)
2930
+ for (const d of decisions) lines.push(` decided: ${formatDecisionRecord(d)}`)
2802
2931
  return ['RECORD (read-only - success, delivery, decisions)', ...lines]
2803
2932
  }
2804
2933
 
@@ -3211,6 +3340,7 @@ function cmdDashboard(args) {
3211
3340
  engagements.forEach(e => {
3212
3341
  const ctx = readClean(e.dir, 'context.md')
3213
3342
  e.next = (sectionBody(ctx, 'Next action', { lastNonEmpty: true }).split('\n').find(l => l.trim()) || '').trim()
3343
+ e.next = e.next.replace(/^[-*]\s+/, '')
3214
3344
  e.hasNext = !!e.next
3215
3345
  e.lastSession = firstLine(sectionBody(ctx, 'Current state'), 240)
3216
3346
  // 220, not 140 - now that brief/reality each get their own full-width
@@ -3227,6 +3357,7 @@ function cmdDashboard(args) {
3227
3357
  e.risks = extractRisks(e.dir)
3228
3358
  e.log = extractLog(e.dir)
3229
3359
  e.stats = extractStats(e.dir)
3360
+ e.valueRows = parseValueLedger(e.dir).rows
3230
3361
  e.highRisks = e.risks.filter(r => r.severity === 'high').length
3231
3362
  e.quiet = e.signals.ageDays !== Infinity && e.signals.ageDays >= 3
3232
3363
  e.slug = slugify(e.name)
@@ -3244,10 +3375,11 @@ function cmdDashboard(args) {
3244
3375
  ...e.log.map(g => g.text), ...e.risks.map(r => r.text),
3245
3376
  ...e.stakeholders.map(p => `${p.name} ${p.role} ${p.note}`),
3246
3377
  ...e.moreSections.map(s => s.title),
3378
+ ...e.valueRows.map(r => `${r.slice} ${r.promised} ${r.measured} ${r.accepted} ${r.evidence}`),
3247
3379
  ].join(' ').toLowerCase())
3248
3380
  })
3249
3381
 
3250
- const html = render.buildFieldbookHtml({ engagements, today })
3382
+ const html = render.buildFieldbookHtml({ engagements, today, generatedAt: new Date().toISOString() })
3251
3383
 
3252
3384
  try {
3253
3385
  fs.mkdirSync(path.dirname(outPath), { recursive: true })
@@ -3614,7 +3746,7 @@ function printUsage() {
3614
3746
  fde log phase <phase> set engagement phase (land|discover|plan|ship|outcome|close)
3615
3747
  fde log --undo remove the last CLI log/debrief entry from memory
3616
3748
  fde debrief [file] meeting notes → memory (prefixed lines; --dry-run; --force)
3617
- fde debrief --smart heuristic propose (prints decision:/risk:/delivery:/contact:/next:); --apply after confirm
3749
+ fde debrief --smart heuristic propose; REVIEW first (decided/asked/open/next/signer); --apply after one confirm
3618
3750
  fde ingest stage … stage raw pull into <engagement>/.inbox/ (not .fde/)
3619
3751
  fde ingest list list staged inbox items
3620
3752
  fde ingest propose <id> smart-propose a staged item → .debrief-propose (confirm before apply)
@@ -3626,7 +3758,7 @@ function printUsage() {
3626
3758
  fde owner [set email] who keeps this engagement record
3627
3759
  fde receipts <term> "what did we agree?" with dates
3628
3760
  fde status [--all] value ledger, then trust (pass --all for full portfolio)
3629
- fde dashboard [--all] current engagement fieldbook (pass --all for every client)
3761
+ fde dashboard [--all] [--open] [--out <path>] bound fieldbook (pass --all for every client)
3630
3762
  fde vault derived Obsidian vault of every engagement (--current for one, --redacted for a shared screen, --out <dir>)
3631
3763
  hooks call these; you do not: capture (session-end snapshot), preserve (pre-compaction snapshot)
3632
3764
  env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)
@@ -0,0 +1,166 @@
1
+ 'use strict'
2
+
3
+ // Embedded verbatim in the offline fieldbook. No imports, network, or record writes.
4
+ module.exports = function fieldbookClient() {
5
+ const all = selector => Array.from(document.querySelectorAll(selector))
6
+ const views = all('.fb-view')
7
+ const nav = all('.fb-nav')
8
+ const search = document.getElementById('fb-search')
9
+ const main = document.querySelector('.fb-main')
10
+ const status = document.getElementById('fb-status')
11
+ const palette = document.getElementById('fb-palette-dialog')
12
+ const paletteInput = document.getElementById('fb-palette-input')
13
+ const items = all('.fb-palette-item')
14
+ const promptDialog = document.getElementById('fb-prompt-dialog')
15
+ let returnFocus = null
16
+ let active = 0
17
+ const currentId = () => document.querySelector('.fb-view:not([hidden])').id.replace('view-', '')
18
+ function selectView(id, updateHash = true, focus = false) {
19
+ if (id === 'fb-main') { id = 'today'; updateHash = false }
20
+ if (!document.getElementById('view-' + id)) id = 'today'
21
+ views.forEach(v => { v.hidden = v.id !== 'view-' + id })
22
+ nav.forEach(n => {
23
+ const selected = n.dataset.target === id
24
+ n.classList.toggle('active', selected)
25
+ if (selected) n.setAttribute('aria-current', 'page')
26
+ else n.removeAttribute('aria-current')
27
+ })
28
+ if (updateHash && location.hash !== '#' + id) location.hash = id
29
+ main.scrollTop = 0
30
+ if (focus) document.querySelector('.fb-view:not([hidden]) h1').focus()
31
+ }
32
+ const hashId = () => location.hash.slice(1)
33
+ selectView(hashId() || 'today', false)
34
+ window.addEventListener('hashchange', () => selectView(hashId(), false))
35
+ const skip = document.querySelector('.fb-skip')
36
+ if (skip) skip.addEventListener('click', event => {
37
+ event.preventDefault()
38
+ main.focus()
39
+ })
40
+ nav.forEach(n => n.addEventListener('click', () => selectView(n.dataset.target, true, true)))
41
+ all('[data-nav]').forEach(n => n.addEventListener('click', () => selectView(n.dataset.nav, true, true)))
42
+
43
+ // Only escaped fragments become markup; engagement strings never become code.
44
+ const esc = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
45
+ function snippet(raw, term) {
46
+ const i = raw.indexOf(term)
47
+ const start = Math.max(0, i - 24), end = Math.min(raw.length, i + term.length + 40)
48
+ return (start ? '…' : '') + esc(raw.slice(start, i)) + '<mark>' + esc(raw.slice(i, i + term.length)) + '</mark>' + esc(raw.slice(i + term.length, end)) + (end < raw.length ? '…' : '')
49
+ }
50
+ search.addEventListener('input', () => {
51
+ const term = search.value.trim().toLowerCase()
52
+ let count = 0
53
+ nav.filter(n => n.dataset.target !== 'today').forEach(n => {
54
+ const raw = n.dataset.search || ''
55
+ const hit = !term || raw.includes(term)
56
+ n.classList.toggle('hide', !hit)
57
+ if (hit) count++
58
+ const reason = n.querySelector('.fb-nav-reason')
59
+ if (term && hit) reason.innerHTML = snippet(raw, term)
60
+ else reason.textContent = n.dataset.defaultReason || ''
61
+ reason.classList.toggle('hide', !reason.textContent)
62
+ })
63
+ document.getElementById('fb-search-empty').hidden = !term || count > 0
64
+ document.getElementById('fb-search-count').textContent = term ? count + ' matching engagement' + (count === 1 ? '' : 's') : ''
65
+ })
66
+
67
+ function toggleTheme() {
68
+ const next = document.documentElement.dataset.fdeTheme === 'dark' ? 'light' : 'dark'
69
+ document.documentElement.dataset.fdeTheme = next
70
+ try { localStorage.setItem('fde-fieldbook-theme', next) } catch (_) {}
71
+ themeLabel()
72
+ }
73
+ function themeLabel() {
74
+ const dark = document.documentElement.dataset.fdeTheme === 'dark'
75
+ document.getElementById('fde-theme-btn').textContent = dark ? 'Light theme' : 'Dark theme'
76
+ }
77
+ themeLabel()
78
+ document.getElementById('fde-theme-btn').addEventListener('click', toggleTheme)
79
+ document.getElementById('fb-print-btn').addEventListener('click', () => window.print())
80
+
81
+ function openDialog(dialog) {
82
+ returnFocus = document.activeElement
83
+ dialog.showModal()
84
+ }
85
+ ;[palette, promptDialog].forEach(dialog => {
86
+ dialog.addEventListener('close', () => { if (returnFocus) returnFocus.focus() })
87
+ dialog.addEventListener('click', event => { if (event.target === dialog) dialog.close() })
88
+ // Native modal dialogs isolate background controls; contain Tab explicitly
89
+ // so keyboard focus never escapes into browser chrome between controls.
90
+ dialog.addEventListener('keydown', event => {
91
+ if (event.key !== 'Tab') return
92
+ const controls = Array.from(dialog.querySelectorAll('input, textarea, button')).filter(el => !el.hidden && !el.classList.contains('hide'))
93
+ const first = controls[0], last = controls[controls.length - 1]
94
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
95
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
96
+ })
97
+ })
98
+ all('[data-close-dialog]').forEach(button => button.addEventListener('click', () => button.closest('dialog').close()))
99
+ const visibleItems = () => items.filter(it => !it.classList.contains('hide'))
100
+ function highlight() {
101
+ const visible = visibleItems()
102
+ items.forEach(it => it.classList.remove('active'))
103
+ if (visible[active]) visible[active].classList.add('active')
104
+ }
105
+ function filterPalette() {
106
+ const term = paletteInput.value.trim().toLowerCase()
107
+ items.forEach(it => it.classList.toggle('hide', !it.textContent.toLowerCase().includes(term)))
108
+ active = 0
109
+ highlight()
110
+ document.getElementById('fb-palette-empty').hidden = visibleItems().length > 0
111
+ }
112
+ function openPalette() { openDialog(palette); paletteInput.value = ''; filterPalette(); paletteInput.focus() }
113
+ function runItem(it) {
114
+ if (!it) return
115
+ palette.close()
116
+ if (it.dataset.target === '__theme__') toggleTheme()
117
+ else selectView(it.dataset.target, true, true)
118
+ }
119
+ document.getElementById('fb-palette-btn').addEventListener('click', openPalette)
120
+ paletteInput.addEventListener('input', filterPalette)
121
+ items.forEach(it => it.addEventListener('click', () => runItem(it)))
122
+ paletteInput.addEventListener('keydown', event => {
123
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
124
+ event.preventDefault()
125
+ active = Math.max(0, Math.min(visibleItems().length - 1, active + (event.key === 'ArrowDown' ? 1 : -1)))
126
+ highlight()
127
+ if (visibleItems()[active]) visibleItems()[active].scrollIntoView({ block: 'nearest' })
128
+ }
129
+ if (event.key === 'Enter') { event.preventDefault(); runItem(visibleItems()[active]) }
130
+ })
131
+
132
+ all('[data-prompt]').forEach(button => button.addEventListener('click', async () => {
133
+ const text = button.dataset.prompt
134
+ try {
135
+ await navigator.clipboard.writeText(text)
136
+ status.textContent = 'Prompt copied. Paste it into your coding agent; review changes before saving.'
137
+ } catch (_) {
138
+ const input = document.getElementById('fb-prompt-text')
139
+ input.value = text
140
+ openDialog(promptDialog)
141
+ input.focus(); input.select()
142
+ }
143
+ }))
144
+ document.addEventListener('keydown', event => {
145
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
146
+ event.preventDefault()
147
+ if (!promptDialog.open) { if (palette.open) palette.close(); else openPalette() }
148
+ return
149
+ }
150
+ if (palette.open || promptDialog.open || event.metaKey || event.ctrlKey || event.altKey) return
151
+ const typing = event.target.matches('input, textarea, select') || event.target.isContentEditable
152
+ if (typing) {
153
+ if (event.key === 'Escape') { search.value = ''; search.dispatchEvent(new Event('input')); event.target.blur() }
154
+ return
155
+ }
156
+ if (event.target.closest('.fb-table-scroll')) return
157
+ if (event.key === '/') { event.preventDefault(); search.focus(); return }
158
+ if (event.key === 'j' || event.key === 'k' || (event.target.closest('.fb-rail') && ['ArrowDown', 'ArrowUp'].includes(event.key))) {
159
+ event.preventDefault()
160
+ const ids = nav.filter(n => !n.classList.contains('hide')).map(n => n.dataset.target)
161
+ const delta = event.key === 'j' || event.key === 'ArrowDown' ? 1 : -1
162
+ const next = ids[Math.max(0, Math.min(ids.length - 1, ids.indexOf(currentId()) + delta))]
163
+ if (next) selectView(next)
164
+ }
165
+ })
166
+ }