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/README.md +133 -174
- package/bin/check.js +5 -0
- package/bin/fde.js +146 -14
- package/bin/lib/fieldbook-client.js +166 -0
- package/bin/lib/render.js +135 -174
- package/bin/lib/trust.js +27 -4
- package/mcp/fdeops-ingest/package.json +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/fde/SKILL.md +9 -5
- package/skills/fde/references/dashboard.md +5 -5
- package/skills/fde/references/debrief.md +12 -5
- package/skills/fde/references/discover.md +5 -1
- package/skills/fde/references/earn-trust.md +1 -1
- package/skills/fde/references/ingest.md +1 -1
- package/skills/fde/references/land.md +6 -6
- package/skills/fde/references/pick-three.md +1 -1
- package/skills/fde/references/plan.md +8 -0
- package/skills/fde/references/poc.md +1 -1
- package/skills/fde/references/readout.md +4 -2
- package/skills/fde/references/review.md +1 -1
- package/skills/fde/references/score-use-cases.md +1 -1
- package/skills/fde/references/ship.md +4 -2
- package/skills/fde/references/switch-clients.md +2 -2
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]
|
|
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
|
|
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
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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]
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
+
}
|