fdeops 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/AGENTS.md +18 -0
  2. package/CLAUDE.md.template +25 -0
  3. package/LICENSE +21 -0
  4. package/README.md +328 -0
  5. package/adapters/AGENTS.md +24 -0
  6. package/adapters/GEMINI.md +24 -0
  7. package/adapters/README.md +31 -0
  8. package/adapters/copilot-instructions.md +24 -0
  9. package/adapters/cursor.fde.mdc +29 -0
  10. package/bin/check.js +260 -0
  11. package/bin/fde.js +690 -0
  12. package/bin/install.js +220 -0
  13. package/hooks/hooks.json +40 -0
  14. package/hooks/pre-compact +66 -0
  15. package/hooks/run-hook.cmd +3 -0
  16. package/hooks/session-start +91 -0
  17. package/hooks/session-stop +86 -0
  18. package/package.json +52 -0
  19. package/skills/fde/SKILL.md +219 -0
  20. package/skills/fde/references/ai.md +91 -0
  21. package/skills/fde/references/artifacts.md +247 -0
  22. package/skills/fde/references/assumption-audit.md +77 -0
  23. package/skills/fde/references/audit.md +61 -0
  24. package/skills/fde/references/blast-radius.md +91 -0
  25. package/skills/fde/references/build.md +98 -0
  26. package/skills/fde/references/business-case.md +78 -0
  27. package/skills/fde/references/close.md +43 -0
  28. package/skills/fde/references/dashboard.md +40 -0
  29. package/skills/fde/references/debrief.md +36 -0
  30. package/skills/fde/references/debug.md +55 -0
  31. package/skills/fde/references/demo-prep.md +31 -0
  32. package/skills/fde/references/discover.md +163 -0
  33. package/skills/fde/references/exec-narrative.md +108 -0
  34. package/skills/fde/references/fintech.md +48 -0
  35. package/skills/fde/references/gov.md +47 -0
  36. package/skills/fde/references/handoff-engineering.md +139 -0
  37. package/skills/fde/references/healthcare.md +45 -0
  38. package/skills/fde/references/incremental-build.md +91 -0
  39. package/skills/fde/references/initiative-triage.md +78 -0
  40. package/skills/fde/references/land.md +75 -0
  41. package/skills/fde/references/multi-customer-ops.md +114 -0
  42. package/skills/fde/references/observability.md +103 -0
  43. package/skills/fde/references/options-analysis.md +81 -0
  44. package/skills/fde/references/pattern-extract.md +93 -0
  45. package/skills/fde/references/plan.md +108 -0
  46. package/skills/fde/references/qa-live.md +113 -0
  47. package/skills/fde/references/rescue.md +81 -0
  48. package/skills/fde/references/review.md +53 -0
  49. package/skills/fde/references/rollback-drill.md +102 -0
  50. package/skills/fde/references/scope-defense.md +71 -0
  51. package/skills/fde/references/security-audit.md +105 -0
  52. package/skills/fde/references/ship.md +121 -0
  53. package/skills/fde/references/sketch.md +40 -0
  54. package/skills/fde/references/stakeholder-radar.md +68 -0
  55. package/skills/fde/references/status.md +30 -0
  56. package/skills/fde/references/test-on-legacy.md +108 -0
  57. package/skills/fde/references/trust-engineering.md +100 -0
  58. package/skills/fde/references/use-case-scoring.md +70 -0
  59. package/templates/.fde/README.md +13 -0
  60. package/templates/.fde/brief.md +8 -0
  61. package/templates/.fde/context.md +14 -0
  62. package/templates/.fde/decisions.md +18 -0
  63. package/templates/.fde/delivery.md +7 -0
  64. package/templates/.fde/reality.md +7 -0
  65. package/templates/.fde/retrospectives/.gitkeep +0 -0
  66. package/templates/.fde/risks.md +5 -0
  67. package/templates/.fde/stakeholders.md +10 -0
  68. package/templates/.fde/success.md +7 -0
  69. package/templates/.fde/terrain.md +7 -0
  70. package/templates/.fde/trust-profile.md +11 -0
package/bin/fde.js ADDED
@@ -0,0 +1,690 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * fde - the deterministic core of fdeops.
4
+ * Real tool, no AI required: recon, memory ops, portfolio status.
5
+ * The @fde skill calls these for mechanics and adds judgment on top.
6
+ *
7
+ * Security note: every execSync below runs a FIXED constant command
8
+ * (git introspection only). User input never reaches a shell - file
9
+ * writes go through fs, and search terms through an escaped RegExp.
10
+ *
11
+ * fde scan day-1 recon of the cwd repo (facts, no opinions)
12
+ * fde resume find this workspace's engagement, print bounded context
13
+ * fde resume --full same, but the complete context.md (no bound)
14
+ * fde resume --init <n> create + bind an engagement for this workspace
15
+ * fde log <type> <text> structured append (decision|risk|delivery|contact)
16
+ * fde receipts <term> "what did we agree?" - search memory with dates
17
+ * fde capture session-end snapshot → context.md (hooks use this)
18
+ * fde status portfolio across ~/fde-engagements (red/amber/green)
19
+ * fde dashboard render every engagement into one local fieldbook.html
20
+ */
21
+ const fs = require('fs')
22
+ const path = require('path')
23
+ const os = require('os')
24
+ const { execSync, execFileSync } = require('child_process')
25
+
26
+ const HOME = os.homedir()
27
+ const ENGAGEMENTS_ROOT = path.join(HOME, 'fde-engagements')
28
+ const REGISTRY = path.join(ENGAGEMENTS_ROOT, '.registry')
29
+ const CODE_EXT = ['.js', '.ts', '.tsx', '.jsx', '.py', '.java', '.go', '.rb', '.cs', '.php']
30
+ const CONF_EXT = CODE_EXT.concat(['.env', '.yaml', '.yml', '.json'])
31
+
32
+ // constant-command runner - never receives user input
33
+ function sh(cmd, cwd) {
34
+ try {
35
+ return execSync(cmd, { cwd: cwd || process.cwd(), stdio: ['ignore', 'pipe', 'ignore'], timeout: 15000 }).toString().trim()
36
+ } catch (_) { return '' }
37
+ }
38
+
39
+ function slugify(name) {
40
+ return String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'engagement'
41
+ }
42
+
43
+ // Walk the repo collecting candidate files. Hard caps keep scan fast on monorepos.
44
+ function walk(dir, exts, cap) {
45
+ const out = []
46
+ const skip = new Set(['node_modules', '.git', 'dist', 'build', 'vendor', '.next', 'target', '__pycache__'])
47
+ ;(function rec(d) {
48
+ if (out.length >= cap) return
49
+ let entries
50
+ try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch (_) { return }
51
+ for (const e of entries) {
52
+ if (out.length >= cap) return
53
+ if (e.name.startsWith('.') && e.name !== '.env') continue
54
+ const p = path.join(d, e.name)
55
+ if (e.isDirectory()) { if (!skip.has(e.name)) rec(p) }
56
+ else if (exts.includes(path.extname(e.name)) || e.name === '.env') {
57
+ try { if (fs.statSync(p).size <= 1024 * 1024) out.push(p) } catch (_) {}
58
+ }
59
+ }
60
+ })(dir)
61
+ return out
62
+ }
63
+
64
+ function grepFiles(files, regex, cap) {
65
+ const hits = []
66
+ for (const f of files) {
67
+ if (hits.length >= cap) break
68
+ let text
69
+ try { text = fs.readFileSync(f, 'utf8') } catch (_) { continue }
70
+ const lines = text.split('\n')
71
+ for (let i = 0; i < lines.length && hits.length < cap; i++) {
72
+ if (regex.test(lines[i])) hits.push({ file: path.relative(process.cwd(), f), line: i + 1, text: lines[i].trim().slice(0, 120) })
73
+ }
74
+ }
75
+ return hits
76
+ }
77
+
78
+ // ---------- engagement resolution (zero-ceremony order) ----------
79
+
80
+ function readRegistry() {
81
+ try {
82
+ return fs.readFileSync(REGISTRY, 'utf8').split('\n').filter(Boolean).map(l => {
83
+ const i = l.lastIndexOf(' ')
84
+ return { workspace: l.slice(0, i), slug: l.slice(i + 1) }
85
+ })
86
+ } catch (_) { return [] }
87
+ }
88
+
89
+ function resolveEngagement() {
90
+ // 1) explicit env (back-compat: accept old FDEOS_ENGAGEMENT too)
91
+ const env = (process.env.FDEOPS_ENGAGEMENT || process.env.FDEOS_ENGAGEMENT || '').replace(/^~/, HOME).trim()
92
+ if (env && fs.existsSync(env)) return env
93
+ // 2) workspace registry binding (written by resume --init)
94
+ const cwd = process.cwd()
95
+ const reg = readRegistry().find(r => r.workspace === cwd)
96
+ if (reg) {
97
+ const p = path.join(ENGAGEMENTS_ROOT, reg.slug, '.fde')
98
+ if (fs.existsSync(p)) return p
99
+ }
100
+ // 3) global pointer file (back-compat: try old FDEOS-CLAUDE.md too)
101
+ for (const ptrName of ['FDEOPS-CLAUDE.md', 'FDEOS-CLAUDE.md']) {
102
+ try {
103
+ const ptr = fs.readFileSync(path.join(HOME, '.claude', ptrName), 'utf8')
104
+ const m = ptr.match(/^(?:FDEOPS|FDEOS)_ENGAGEMENT=(.+)$/m)
105
+ if (m) {
106
+ const p = m[1].trim().replace(/^~/, HOME)
107
+ if (fs.existsSync(p)) return p
108
+ }
109
+ } catch (_) {}
110
+ }
111
+ // 4) workspace dir name matches an engagement slug
112
+ const guess = path.join(ENGAGEMENTS_ROOT, slugify(path.basename(cwd)), '.fde')
113
+ if (fs.existsSync(guess)) return guess
114
+ // 5) in-repo .fde (engagement-approved only)
115
+ if (fs.existsSync(path.join(cwd, '.fde'))) return path.join(cwd, '.fde')
116
+ return null
117
+ }
118
+
119
+ function templatesDir() {
120
+ for (const c of [path.join(__dirname, '..', 'templates', '.fde'), path.join(__dirname, 'templates', '.fde')]) {
121
+ if (fs.existsSync(c)) return c
122
+ }
123
+ return null
124
+ }
125
+
126
+ // ---------- shared engagement signals (one source of truth) ----------
127
+
128
+ function readEng(eng, f) {
129
+ try { return fs.readFileSync(path.join(eng, f), 'utf8') } catch (_) { return '' }
130
+ }
131
+
132
+ // Pull the body under a "## Heading" up to the next "##" (or EOF).
133
+ function sectionBody(md, heading) {
134
+ const lines = md.split('\n')
135
+ const start = lines.findIndex(l => new RegExp('^#{1,6}\\s+' + heading + '\\b', 'i').test(l.trim()))
136
+ if (start === -1) return ''
137
+ const body = []
138
+ for (let i = start + 1; i < lines.length; i++) {
139
+ if (/^#{1,6}\s/.test(lines[i].trim())) break
140
+ body.push(lines[i])
141
+ }
142
+ return body.join('\n').trim()
143
+ }
144
+
145
+ // phase / trust / top risk / freshness - identical heuristic for status + dashboard.
146
+ function computeSignals(eng) {
147
+ const ctx = readEng(eng, 'context.md'); const stake = readEng(eng, 'stakeholders.md'); const risks = readEng(eng, 'risks.md')
148
+ const phase = (ctx.match(/phase[:* ]+\**([a-z-]+)/i) || [])[1] || '?'
149
+ const sLines = stake.split('\n').filter(l => !(/green/i.test(l) && /red|amber/i.test(l)))
150
+ const trust = sLines.some(l => /\bred\b/i.test(l)) ? 'RED'
151
+ : sLines.some(l => /amber|gone quiet|routing around|escalat/i.test(l)) ? 'amber' : 'green'
152
+ const topRisk = (risks.split('\n').find(l => {
153
+ const t = l.trim()
154
+ return /^[-|]/.test(t) && t.length > 20 && !/^\|?[-\s|]+$/.test(t) &&
155
+ !/risk\s*\|\s*status|mitigation/i.test(t) && !t.startsWith('<!--')
156
+ }) || '').replace(/\|/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80)
157
+ let updated = 'never', ageDays = Infinity
158
+ try {
159
+ ageDays = Math.floor((Date.now() - fs.statSync(path.join(eng, 'context.md')).mtimeMs) / 86400000)
160
+ updated = ageDays === 0 ? 'today' : `${ageDays}d ago`
161
+ } catch (_) {}
162
+ return { phase, trust, topRisk, updated, ageDays }
163
+ }
164
+
165
+ // ---------- commands ----------
166
+
167
+ function cmdScan() {
168
+ const cwd = process.cwd()
169
+ const isGit = !!sh('git rev-parse --git-dir')
170
+ const out = []
171
+ out.push('FDE RECON - ' + path.basename(cwd))
172
+ out.push('local only · git + file reads · no AI, no network - nothing leaves this machine')
173
+ out.push('='.repeat(60))
174
+
175
+ // stack + age
176
+ const files = walk(cwd, CONF_EXT.concat(['.md']), 5000)
177
+ const extCount = {}
178
+ for (const f of files) { const e = path.extname(f); extCount[e] = (extCount[e] || 0) + 1 }
179
+ const langs = Object.entries(extCount).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([e, n]) => `${e}:${n}`).join(' ')
180
+ const birth = isGit ? sh('git log --reverse --format=%ad --date=short').split('\n')[0] : 'n/a'
181
+ const last = isGit ? sh('git log -1 --format=%ad --date=short') : 'n/a'
182
+ out.push(`STACK ${langs || 'no code files found'} first commit: ${birth} last: ${last}`)
183
+
184
+ // churn × tests = the load-bearing walls
185
+ out.push('\nHOTSPOTS (churn 90d × test coverage) - handle with care:')
186
+ if (isGit) {
187
+ const churn = sh("git log --since='90 days ago' --name-only --pretty=format:") || ''
188
+ const counts = {}
189
+ churn.split('\n').filter(Boolean).forEach(f => { counts[f] = (counts[f] || 0) + 1 })
190
+ const top = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 8)
191
+ const testFiles = files.filter(f => /test|spec/i.test(f))
192
+ if (top.length === 0) out.push(' (no commits in the last 90 days)')
193
+ for (const [f, n] of top) {
194
+ const base = path.basename(f).replace(/\.[^.]+$/, '')
195
+ const tested = testFiles.some(t => t.includes(base))
196
+ out.push(` ${String(n).padStart(3)} commits/90d ${f} ${tested ? '' : '⚠ NO TEST NEIGHBOR'}`)
197
+ }
198
+ } else out.push(' (not a git repo - churn unavailable)')
199
+
200
+ // temporary archaeology
201
+ out.push('\n"TEMPORARY" ARCHAEOLOGY (permanent code with an excuse):')
202
+ const codeFiles = files.filter(f => CODE_EXT.includes(path.extname(f)))
203
+ const tmp = grepFiles(codeFiles, /HACK|FIXME|XXX|temporar|for now|remove this|remove after|workaround/i, 15)
204
+ tmp.length ? tmp.forEach(h => out.push(` ${h.file}:${h.line} ${h.text}`)) : out.push(' none found')
205
+
206
+ // AI components - they fail silently
207
+ out.push('\nAI COMPONENTS (no exception fires when these drift):')
208
+ const ai = grepFiles(codeFiles, /openai|anthropic|\bllm\b|gpt-|claude|embedding|vector store|inference/i, 10)
209
+ ai.length ? ai.forEach(h => out.push(` ${h.file}:${h.line} ${h.text}`)) : out.push(' none found')
210
+
211
+ // secrets (redacted)
212
+ out.push('\nPOSSIBLE HARDCODED SECRETS (values redacted):')
213
+ const confFiles = files.filter(f => CONF_EXT.includes(path.extname(f)) || path.basename(f) === '.env')
214
+ const sec = grepFiles(confFiles, /(api[_-]?key|secret|password|token)\s*[:=]\s*['"][^'"]{8,}/i, 10)
215
+ .filter(h => !/example|template|test|sample|placeholder/i.test(h.file + h.text))
216
+ sec.length
217
+ ? sec.forEach(h => out.push(` ${h.file}:${h.line} ${h.text.replace(/(['"])([^'"]{4})[^'"]+(['"])/, '$1$2…REDACTED$3')}`))
218
+ : out.push(' none found')
219
+
220
+ // previous attempts - the political archaeology
221
+ out.push('\nPREVIOUS ATTEMPTS (ask who ran these, and what happened):')
222
+ const reverts = isGit ? sh("git log --oneline -i --grep=revert --grep=rollback") : ''
223
+ const readmeHits = grepFiles(files.filter(f => /readme/i.test(f)), /revert|rewrite|attempted|abandoned|deprecated/i, 5)
224
+ if (reverts) reverts.split('\n').slice(0, 5).forEach(l => out.push(' git: ' + l))
225
+ readmeHits.forEach(h => out.push(` ${h.file}:${h.line} ${h.text}`))
226
+ if (!reverts && readmeHits.length === 0) out.push(' none visible')
227
+
228
+ // test landscape
229
+ const testCount = files.filter(f => /test|spec/i.test(f)).length
230
+ out.push(`\nTEST LANDSCAPE ${testCount} test file(s) across ${codeFiles.length} code files`)
231
+
232
+ out.push('\n' + '-'.repeat(60))
233
+ out.push("Facts only - interpretation is the FDE's (or @fde's) job.")
234
+ console.log(out.join('\n'))
235
+ }
236
+
237
+ function cmdResume(args) {
238
+ const initIdx = args.indexOf('--init')
239
+ if (initIdx !== -1) {
240
+ const name = args[initIdx + 1]
241
+ if (!name) { console.error('usage: fde resume --init <engagement-name>'); process.exit(1) }
242
+ const tpl = templatesDir()
243
+ if (!tpl) { console.error('templates not found - run from the fdeops clone or reinstall'); process.exit(1) }
244
+ const slug = slugify(name)
245
+ const fdeDir = path.join(ENGAGEMENTS_ROOT, slug, '.fde')
246
+ fs.mkdirSync(fdeDir, { recursive: true })
247
+ for (const f of fs.readdirSync(tpl)) {
248
+ const src = path.join(tpl, f); const dst = path.join(fdeDir, f)
249
+ if (fs.statSync(src).isDirectory()) fs.mkdirSync(dst, { recursive: true })
250
+ else if (!fs.existsSync(dst)) fs.copyFileSync(src, dst)
251
+ }
252
+ fs.mkdirSync(path.join(fdeDir, 'retrospectives'), { recursive: true })
253
+ // bind THIS workspace to the engagement (zero ceremony next time)
254
+ const line = `${process.cwd()} ${slug}\n`
255
+ const reg = fs.existsSync(REGISTRY) ? fs.readFileSync(REGISTRY, 'utf8') : ''
256
+ if (!reg.includes(line.trim())) fs.appendFileSync(REGISTRY, line)
257
+ console.log(`ENGAGEMENT READY: ${fdeDir}\nbound to workspace: ${process.cwd()}`)
258
+ return
259
+ }
260
+ const eng = resolveEngagement()
261
+ if (!eng) {
262
+ const list = fs.existsSync(ENGAGEMENTS_ROOT)
263
+ ? fs.readdirSync(ENGAGEMENTS_ROOT).filter(d => !d.startsWith('.')).join(', ') || '(none yet)'
264
+ : '(none yet)'
265
+ console.log(`NO ENGAGEMENT for this workspace.\nexisting: ${list}\ncreate + bind one: fde resume --init <client-name>`)
266
+ process.exit(2)
267
+ }
268
+ console.log(`ENGAGEMENT: ${eng}\n`)
269
+ try {
270
+ const ctx = fs.readFileSync(path.join(eng, 'context.md'), 'utf8')
271
+ console.log(args.includes('--full') ? ctx : resumeView(ctx))
272
+ } catch (_) { console.log('(context.md empty - new engagement)') }
273
+ }
274
+
275
+ // Token discipline: context.md grows every session (the session-stop hook
276
+ // appends a snapshot). Loading the whole file on every resume costs more tokens
277
+ // each day for less marginal signal. This returns a bounded view - the curated
278
+ // head (state / next action) plus the most recent activity - and hides the
279
+ // middle of the log behind `fde resume --full`. Pure code, zero model tokens.
280
+ // The session-start hook mirrors this same bound in bash; keep them in sync.
281
+ function resumeView(md) {
282
+ const lines = md.split('\n')
283
+ // A file ending in "\n" yields a trailing "" here; drop it so the line count
284
+ // matches the bash hook's `wc -l` and the two bounded views stay byte-aligned.
285
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
286
+ if (lines.length <= 160) return md
287
+ let headEnd = lines.findIndex(l => l.includes('fdeops auto-capture'))
288
+ if (headEnd === -1) headEnd = 120
289
+ headEnd = Math.min(headEnd, 120)
290
+ const tailStart = Math.max(headEnd, lines.length - 40)
291
+ const hidden = tailStart - headEnd
292
+ if (hidden <= 0) return md
293
+ const head = lines.slice(0, headEnd).join('\n').replace(/\s+$/, '')
294
+ const tail = lines.slice(tailStart).join('\n').trim()
295
+ return `${head}\n\n_(\u2026 ${hidden} lines of earlier session log hidden \u2014 \`fde resume --full\` or open context.md for the full history)_\n\n${tail}`
296
+ }
297
+
298
+ function cmdLog(args) {
299
+ const map = { decision: 'decisions.md', risk: 'risks.md', delivery: 'delivery.md', contact: 'stakeholders.md' }
300
+ const type = args[0]; const text = args.slice(1).join(' ')
301
+ if (!map[type] || !text) { console.error('usage: fde log <decision|risk|delivery|contact> <text>'); process.exit(1) }
302
+ const eng = resolveEngagement()
303
+ if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
304
+ const date = new Date().toISOString().slice(0, 10)
305
+ fs.appendFileSync(path.join(eng, map[type]), `\n- [${date}] ${text}\n`)
306
+ console.log(`logged → ${map[type]}`)
307
+ }
308
+
309
+ function cmdReceipts(args) {
310
+ const term = args.join(' ')
311
+ if (!term) { console.error('usage: fde receipts <search term>'); process.exit(1) }
312
+ const eng = resolveEngagement()
313
+ if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
314
+ const rx = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')
315
+ let found = 0
316
+ for (const f of ['decisions.md', 'delivery.md', 'risks.md', 'stakeholders.md', 'context.md', 'success.md', 'brief.md', 'reality.md']) {
317
+ const p = path.join(eng, f)
318
+ if (!fs.existsSync(p)) continue
319
+ fs.readFileSync(p, 'utf8').split('\n').forEach((l, i) => {
320
+ if (rx.test(l)) { console.log(`${f}:${i + 1} ${l.trim().slice(0, 160)}`); found++ }
321
+ })
322
+ }
323
+ if (!found) console.log(`no record of "${term}" - if it was agreed, it was never logged. That is itself the answer.`)
324
+ }
325
+
326
+ function cmdCapture() {
327
+ const eng = resolveEngagement()
328
+ if (!eng) process.exit(0) // silent: capture must never break a session
329
+ const branch = sh('git branch --show-current')
330
+ const lastCommit = sh("git log -1 --format='%h %s'").slice(0, 100)
331
+ // porcelain lines are "XY path" - sh() trims, so parse by first whitespace
332
+ const changed = sh('git status --porcelain').split('\n').filter(Boolean).slice(0, 8)
333
+ .map(l => l.trim().split(/\s+/).slice(1).join(' ')).join(' ')
334
+ const updated = fs.readdirSync(eng).filter(f => {
335
+ if (!f.endsWith('.md') || f === 'context.md') return false
336
+ try { return (Date.now() - fs.statSync(path.join(eng, f)).mtimeMs) < 12 * 3600 * 1000 } catch (_) { return false }
337
+ }).join(' ')
338
+ if (!changed && !updated) process.exit(0) // idle session - keep memory clean
339
+ const d = new Date()
340
+ const stamp = `${d.toISOString().slice(0, 10)} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
341
+ let block = `\n<!-- fdeops auto-capture -->\n## Session end - ${stamp}\n`
342
+ if (branch) block += `- workspace: \`${branch}\` @ ${lastCommit || 'no commits yet'}\n`
343
+ if (changed) block += `- uncommitted: ${changed}\n`
344
+ if (updated) block += `- engagement files updated: ${updated}\n`
345
+ try { fs.appendFileSync(path.join(eng, 'context.md'), block) } catch (_) {}
346
+ }
347
+
348
+ function cmdStatus() {
349
+ if (!fs.existsSync(ENGAGEMENTS_ROOT)) { console.log('no engagements yet - fde resume --init <name>'); return }
350
+ const rows = []
351
+ for (const d of fs.readdirSync(ENGAGEMENTS_ROOT)) {
352
+ if (d.startsWith('.')) continue
353
+ const eng = path.join(ENGAGEMENTS_ROOT, d, '.fde')
354
+ if (!fs.existsSync(eng)) continue
355
+ const s = computeSignals(eng)
356
+ rows.push({ name: d, phase: s.phase, trust: s.trust, updated: s.updated, topRisk: s.topRisk.slice(0, 60) })
357
+ }
358
+ if (!rows.length) { console.log('no engagements yet'); return }
359
+ const order = { RED: 0, amber: 1, green: 2 }
360
+ rows.sort((a, b) => order[a.trust] - order[b.trust])
361
+ console.log('FDE PORTFOLIO - trust-first triage (heuristic: red > amber > green)\n')
362
+ for (const r of rows) {
363
+ console.log(` [${r.trust.padEnd(5)}] ${r.name.padEnd(24)} phase:${r.phase.padEnd(10)} updated:${r.updated.padEnd(8)} ${r.topRisk}`)
364
+ }
365
+ console.log('\nred/amber derive from stakeholders.md signal words - verify before acting.')
366
+ }
367
+
368
+ // ---------- dashboard (deterministic markdown → one local HTML) ----------
369
+
370
+ function escapeHtml(s) {
371
+ return String(s)
372
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
373
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;')
374
+ }
375
+
376
+ // Never leak <private> notes or template hint comments into the rendered page.
377
+ // Closed pairs are redacted; an unclosed <private> redacts to end-of-text so a
378
+ // forgotten closing tag can never leak the rest of the file.
379
+ function stripPrivate(md) {
380
+ return md
381
+ .replace(/<private>[\s\S]*?<\/private>/gi, '(private - redacted from dashboard)')
382
+ .replace(/<private>[\s\S]*$/i, '(private - redacted from dashboard)')
383
+ .replace(/<!--[\s\S]*?-->/g, '')
384
+ }
385
+
386
+ function inlineMd(s) {
387
+ return escapeHtml(s)
388
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
389
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
390
+ }
391
+
392
+ function renderTable(rows) {
393
+ const cells = r => r.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map(c => c.trim())
394
+ const isSep = r => r.includes('-') && /^\|?[\s:|-]+\|?$/.test(r.trim())
395
+ let html = '<table>'; let headerDone = false
396
+ for (const r of rows) {
397
+ if (isSep(r)) continue
398
+ const cs = cells(r)
399
+ if (!headerDone) {
400
+ html += '<thead><tr>' + cs.map(c => '<th>' + inlineMd(c) + '</th>').join('') + '</tr></thead><tbody>'
401
+ headerDone = true
402
+ } else {
403
+ if (cs.every(c => !c)) continue
404
+ html += '<tr>' + cs.map(c => '<td>' + inlineMd(c) + '</td>').join('') + '</tr>'
405
+ }
406
+ }
407
+ return html + '</tbody></table>'
408
+ }
409
+
410
+ function mdToHtml(md) {
411
+ const lines = stripPrivate(md).split('\n')
412
+ const out = []; let i = 0
413
+ while (i < lines.length) {
414
+ const t = lines[i].trim()
415
+ if (!t) { i++; continue }
416
+ if (/^\|.*\|/.test(t)) {
417
+ const tbl = []
418
+ while (i < lines.length && /^\s*\|.*\|/.test(lines[i])) { tbl.push(lines[i]); i++ }
419
+ out.push(renderTable(tbl)); continue
420
+ }
421
+ const h = t.match(/^(#{1,6})\s+(.*)$/)
422
+ if (h) { out.push('<h4>' + inlineMd(h[2]) + '</h4>'); i++; continue }
423
+ if (/^[-*]\s+/.test(t)) {
424
+ const items = []
425
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) { items.push('<li>' + inlineMd(lines[i].trim().replace(/^[-*]\s+/, '')) + '</li>'); i++ }
426
+ out.push('<ul>' + items.join('') + '</ul>'); continue
427
+ }
428
+ const para = []
429
+ while (i < lines.length && lines[i].trim() && !/^\s*\|.*\|/.test(lines[i]) && !/^#{1,6}\s/.test(lines[i].trim()) && !/^[-*]\s+/.test(lines[i].trim())) {
430
+ para.push(lines[i].trim()); i++
431
+ }
432
+ out.push('<p>' + inlineMd(para.join(' ')) + '</p>')
433
+ }
434
+ return out.join('\n')
435
+ }
436
+
437
+ // True if a file has real content beyond headings, labels with empty values, and blank table rows.
438
+ function hasContent(md) {
439
+ const txt = stripPrivate(md)
440
+ .replace(/^#{1,6}\s.*$/gm, '') // headings
441
+ .replace(/^\s*\|[\s:|-]+\|\s*$/gm, '') // table separators
442
+ .replace(/^\s*\|[\s|]*\|\s*$/gm, '') // empty table rows
443
+ .replace(/\*\*[^*]+:\*\*\s*$/gm, '') // bold labels with no value
444
+ .replace(/[-*]\s*$/gm, '') // empty bullets
445
+ .replace(/\s+/g, ' ')
446
+ .trim()
447
+ return txt.length > 0
448
+ }
449
+
450
+ function gatherEngagements() {
451
+ const list = []
452
+ if (!fs.existsSync(ENGAGEMENTS_ROOT)) return list
453
+ for (const d of fs.readdirSync(ENGAGEMENTS_ROOT).sort()) {
454
+ if (d.startsWith('.')) continue
455
+ const eng = path.join(ENGAGEMENTS_ROOT, d, '.fde')
456
+ if (!fs.existsSync(eng)) continue
457
+ list.push({ name: d, dir: eng, signals: computeSignals(eng) })
458
+ }
459
+ return list
460
+ }
461
+
462
+ const DASH_SECTIONS = [
463
+ ['context.md', 'Context'],
464
+ ['brief.md', 'Brief - stated problem'],
465
+ ['reality.md', 'Reality - actual problem'],
466
+ ['success.md', 'Success & scope'],
467
+ ['stakeholders.md', 'Stakeholders'],
468
+ ['decisions.md', 'Plan & decisions'],
469
+ ['risks.md', 'Risk register'],
470
+ ['delivery.md', 'Delivery log'],
471
+ ['terrain.md', 'Terrain - codebase map'],
472
+ ['trust-profile.md', 'Trust profile'],
473
+ ]
474
+
475
+ function dashStyles() {
476
+ return [
477
+ // design system: quiet chrome, high density, daily-use second brain
478
+ ':root{--bg:#f9fafb;--card:#fff;--ink:#111827;--2:#374151;--3:#6b7280;--muted:#9ca3af;--line:#f3f4f6;--line2:#e5e7eb;',
479
+ '--green:#10b981;--amber:#f59e0b;--red:#ef4444;--accent:#6366f1;',
480
+ '--shadow-s:0 1px 2px rgba(0,0,0,.04);--shadow:0 1px 3px rgba(0,0,0,.06);--shadow-l:0 4px 12px rgba(0,0,0,.08);',
481
+ '--r:8px}',
482
+ '*{box-sizing:border-box;margin:0}',
483
+ 'body{font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;color:var(--ink);background:var(--bg);-webkit-font-smoothing:antialiased}',
484
+ // header - compact, purposeful
485
+ 'header{background:#111827;color:#fff;padding:20px 32px}',
486
+ 'header .inner{max-width:960px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px}',
487
+ 'header .brand{display:flex;align-items:center;gap:10px}',
488
+ 'header h1{font-size:16px;font-weight:600;letter-spacing:-.2px}',
489
+ 'header .tagline{color:#9ca3af;font-size:12px}',
490
+ '.stats{display:flex;align-items:center;gap:16px}',
491
+ '.stat{font-size:12px;color:#9ca3af;display:flex;align-items:center;gap:5px}',
492
+ '.stat b{color:#e5e7eb;font-weight:600}',
493
+ '.stat .sdot{width:6px;height:6px;border-radius:50%;flex-shrink:0}',
494
+ '.stat .sdot.green{background:#34d399}.stat .sdot.amber{background:#fbbf24}.stat .sdot.red{background:#f87171}',
495
+ // layout
496
+ '.wrap{max-width:960px;margin:0 auto;padding:24px 32px 64px}',
497
+ // search - integrated, not floating
498
+ '.search-wrap{position:relative;margin-bottom:24px}',
499
+ '.search-wrap svg{position:absolute;left:12px;top:50%;transform:translateY(-50%);width:16px;height:16px;stroke:var(--muted);stroke-width:2;fill:none}',
500
+ '.search{width:100%;padding:10px 14px 10px 36px;font-size:13px;border:1px solid var(--line2);border-radius:var(--r);background:var(--card);transition:border-color .15s,box-shadow .15s;outline:none}',
501
+ '.search:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(99,102,241,.1)}',
502
+ '.search::placeholder{color:var(--muted)}',
503
+ // grid
504
+ '.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}',
505
+ // cards - clean, scannable
506
+ '.card{background:var(--card);border:1px solid var(--line2);border-radius:var(--r);padding:16px 18px;cursor:pointer;transition:border-color .15s,box-shadow .15s;position:relative;overflow:hidden}',
507
+ '.card::before{content:"";position:absolute;left:0;top:0;bottom:0;width:3px}',
508
+ '.card:hover{border-color:#d1d5db;box-shadow:var(--shadow-l)}',
509
+ '.card.green::before{background:var(--green)}.card.amber::before{background:var(--amber)}.card.red::before{background:var(--red)}',
510
+ '.card h3{font-size:14px;font-weight:600;margin-bottom:6px}',
511
+ '.row{display:flex;align-items:center;gap:6px;flex-wrap:wrap}',
512
+ '.badge{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;padding:2px 7px;border-radius:4px;background:#f3f4f6;color:#4b5563}',
513
+ '.dot{width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0}',
514
+ '.dot.green{background:var(--green)}.dot.amber{background:var(--amber)}.dot.red{background:var(--red)}',
515
+ '.trust-label{font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.3px}',
516
+ '.t-green{color:var(--green)}.t-amber{color:var(--amber)}.t-red{color:var(--red)}',
517
+ '.meta{color:var(--muted);font-size:11px}',
518
+ '.card .next{margin-top:10px;font-size:12.5px;line-height:1.5;color:var(--2)}.card .next b{color:var(--3);font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:.3px;margin-right:2px}',
519
+ '.card .risk{margin-top:8px;font-size:11.5px;color:#991b1b;padding:5px 8px;background:#fef2f2;border-radius:4px;border-left:2px solid var(--red);line-height:1.4}',
520
+ '.card .risk.amber-risk{color:#92400e;background:#fffbeb;border-left-color:var(--amber)}',
521
+ // section labels
522
+ 'h2.section{margin:28px 0 12px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)}',
523
+ // detail accordion - tight, content-forward
524
+ 'details.eng{background:var(--card);border:1px solid var(--line2);border-radius:var(--r);margin:8px 0;overflow:hidden}',
525
+ 'details.eng>summary{list-style:none;cursor:pointer;padding:14px 18px;display:flex;align-items:center;gap:10px;font-weight:600;font-size:14px;transition:background .1s}',
526
+ 'details.eng>summary:hover{background:var(--bg)}',
527
+ 'details.eng>summary::-webkit-details-marker{display:none}',
528
+ 'details.eng>summary::after{content:"";margin-left:auto;width:16px;height:16px;background:url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'16\' height=\'16\' viewBox=\'0 0 24 24\' fill=\'none\' stroke=\'%239ca3af\' stroke-width=\'2\'%3E%3Cpath d=\'M6 9l6 6 6-6\'/%3E%3C/svg%3E") no-repeat center;transition:transform .15s}',
529
+ 'details.eng[open]>summary::after{transform:rotate(180deg)}',
530
+ 'details.eng[open]>summary{border-bottom:1px solid var(--line)}',
531
+ '.eng-body{padding:8px 20px 20px}',
532
+ '.sub-sec{padding:14px 0;border-bottom:1px solid var(--line)}.sub-sec:last-child{border-bottom:none}',
533
+ '.sub-sec h4.title{margin:0 0 8px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--muted)}',
534
+ '.sub-sec h4{margin:10px 0 4px;font-size:13.5px;font-weight:600;color:var(--ink)}',
535
+ '.sub-sec p{margin:4px 0;line-height:1.6;color:var(--2);font-size:13px}.sub-sec ul{margin:4px 0;padding-left:20px;color:var(--2);font-size:13px}',
536
+ '.sub-sec li{margin:2px 0}',
537
+ '.empty{color:var(--muted);font-style:italic;font-size:12px}',
538
+ // tables
539
+ 'table{border-collapse:collapse;width:100%;margin:8px 0;font-size:12.5px;border:1px solid var(--line2);border-radius:6px;overflow:hidden}',
540
+ 'th,td{padding:7px 10px;text-align:left;vertical-align:top;border-bottom:1px solid var(--line)}',
541
+ 'th{background:var(--bg);font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;color:var(--3);border-bottom:1px solid var(--line2)}',
542
+ 'tr:last-child td{border-bottom:none}',
543
+ 'tr:hover td{background:var(--bg)}',
544
+ 'code{background:#f3f4f6;padding:1px 5px;border-radius:4px;font-size:12px;color:#4b5563}',
545
+ // footer
546
+ 'footer{max-width:960px;margin:0 auto;padding:0 32px 48px;color:var(--muted);font-size:11.5px;line-height:1.5}',
547
+ 'footer code{color:var(--3);background:#f3f4f6}',
548
+ '.hide{display:none!important}',
549
+ // responsive
550
+ '@media(max-width:640px){header{padding:16px}.header .inner{flex-direction:column;align-items:flex-start}.wrap{padding:16px 16px 48px}.grid{grid-template-columns:1fr}.search{padding-left:32px}}',
551
+ ].join('')
552
+ }
553
+
554
+ function dashScript() {
555
+ return [
556
+ "var q=document.getElementById('q');",
557
+ "function norm(s){return (s||'').toLowerCase();}",
558
+ "if(q){q.addEventListener('input',function(){",
559
+ " var term=norm(q.value);",
560
+ " document.querySelectorAll('[data-search]').forEach(function(el){",
561
+ " var hit=!term||norm(el.getAttribute('data-search')).indexOf(term)>-1;",
562
+ " el.classList.toggle('hide',!hit);",
563
+ " });",
564
+ "});}",
565
+ "document.querySelectorAll('.card').forEach(function(c){",
566
+ " c.addEventListener('click',function(){",
567
+ " var d=document.getElementById(c.getAttribute('data-target'));",
568
+ " if(d){d.open=true;d.scrollIntoView({behavior:'smooth',block:'start'});}",
569
+ " });",
570
+ "});",
571
+ ].join('\n')
572
+ }
573
+
574
+ function cmdDashboard(args) {
575
+ const outIdx = args.indexOf('--out')
576
+ const outPath = outIdx !== -1 && args[outIdx + 1]
577
+ ? path.resolve(args[outIdx + 1].replace(/^~/, HOME))
578
+ : path.join(ENGAGEMENTS_ROOT, 'fieldbook.html')
579
+ const engagements = gatherEngagements()
580
+ const counts = { green: 0, amber: 0, RED: 0 }
581
+ engagements.forEach(e => { counts[e.signals.trust]++ })
582
+ const now = new Date()
583
+ const stamp = now.toISOString().slice(0, 16).replace('T', ' ') + ' UTC'
584
+
585
+ const cards = engagements.map(e => {
586
+ const id = 'eng-' + slugify(e.name)
587
+ const ctx = readEng(e.dir, 'context.md')
588
+ const next = (sectionBody(ctx, 'Next action').split('\n').find(l => l.trim()) || '').trim()
589
+ const trustClass = e.signals.trust === 'RED' ? 'red' : e.signals.trust
590
+ const trustLabel = e.signals.trust === 'RED' ? 'RED' : e.signals.trust
591
+ const searchBlob = escapeHtml(stripPrivate(e.name + ' ' + ctx + ' ' + readEng(e.dir, 'risks.md') + ' ' + readEng(e.dir, 'stakeholders.md')).toLowerCase())
592
+ return [
593
+ `<div class="card ${trustClass}" data-target="${id}" data-search="${searchBlob}">`,
594
+ `<h3>${inlineMd(e.name)}</h3>`,
595
+ `<div class="row"><span class="dot ${trustClass}"></span><span class="trust-label t-${trustClass}">${trustLabel}</span>`,
596
+ `<span class="badge">${inlineMd(e.signals.phase)}</span><span class="meta">updated ${e.signals.updated}</span></div>`,
597
+ next ? `<div class="next"><b>Next</b> ${inlineMd(next)}</div>` : '<div class="next meta">next action not set</div>',
598
+ e.signals.topRisk ? `<div class="risk">${inlineMd(e.signals.topRisk)}</div>` : '',
599
+ `</div>`,
600
+ ].join('')
601
+ }).join('\n')
602
+
603
+ const details = engagements.map(e => {
604
+ const id = 'eng-' + slugify(e.name)
605
+ const trustClass = e.signals.trust === 'RED' ? 'red' : e.signals.trust
606
+ const subs = DASH_SECTIONS.map(([file, title]) => {
607
+ const md = readEng(e.dir, file)
608
+ const body = hasContent(md) ? mdToHtml(md) : '<p class="empty">- not yet filled -</p>'
609
+ return `<div class="sub-sec"><h4 class="title">${title}</h4>${body}</div>`
610
+ })
611
+ // retrospectives (optional folder)
612
+ const retroDir = path.join(e.dir, 'retrospectives')
613
+ let retros = ''
614
+ try {
615
+ const files = fs.readdirSync(retroDir).filter(f => f.endsWith('.md')).sort()
616
+ const items = files.filter(f => hasContent(readEng(retroDir, f)))
617
+ .map(f => `<h4>${escapeHtml(f)}</h4>${mdToHtml(readEng(retroDir, f))}`).join('')
618
+ if (items) retros = `<div class="sub-sec"><h4 class="title">Retrospectives</h4>${items}</div>`
619
+ } catch (_) {}
620
+ const searchBlob = escapeHtml(stripPrivate(e.name + ' ' + DASH_SECTIONS.map(([f]) => readEng(e.dir, f)).join(' ')).toLowerCase())
621
+ return [
622
+ `<details class="eng" id="${id}" data-search="${searchBlob}">`,
623
+ `<summary><span class="dot ${trustClass}"></span>${inlineMd(e.name)}`,
624
+ `<span class="badge">${inlineMd(e.signals.phase)}</span>`,
625
+ `<span class="meta" style="font-weight:400">updated ${e.signals.updated}</span></summary>`,
626
+ `<div class="eng-body">${subs.join('')}${retros}</div>`,
627
+ `</details>`,
628
+ ].join('')
629
+ }).join('\n')
630
+
631
+ const emptyState = '<p class="empty">No engagements yet. Start one with <code>fde resume --init &lt;client-name&gt;</code>, then re-run <code>fde dashboard</code>.</p>'
632
+
633
+ const html = [
634
+ '<!doctype html><html lang="en"><head><meta charset="utf-8">',
635
+ '<meta name="viewport" content="width=device-width,initial-scale=1">',
636
+ '<title>FDE Fieldbook</title><style>' + dashStyles() + '</style></head><body>',
637
+ '<header><div class="inner"><div class="brand"><h1>FDE Fieldbook</h1>',
638
+ `<span class="tagline">${engagements.length} engagement${engagements.length === 1 ? '' : 's'}</span></div>`,
639
+ '<div class="stats">',
640
+ `<span class="stat"><span class="sdot green"></span> <b>${counts.green}</b> green</span>`,
641
+ `<span class="stat"><span class="sdot amber"></span> <b>${counts.amber}</b> amber</span>`,
642
+ `<span class="stat"><span class="sdot red"></span> <b>${counts.RED}</b> red</span>`,
643
+ '</div></div></header>',
644
+ '<div class="wrap">',
645
+ engagements.length ? '<div class="search-wrap"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="q" class="search" placeholder="Search across all engagements..."></div>' : '',
646
+ '<h2 class="section">Portfolio</h2>',
647
+ engagements.length ? `<div class="grid">${cards}</div>` : emptyState,
648
+ engagements.length ? '<h2 class="section">Engagement detail</h2>' + details : '',
649
+ '</div>',
650
+ `<footer>fdeops · fieldbook is a deterministic render of your <code>.fde/</code> memory - edit the markdown, re-run <code>fde dashboard</code>. Source of truth stays in the files.</footer>`,
651
+ '<script>' + dashScript() + '</script>',
652
+ '</body></html>',
653
+ ].join('\n')
654
+
655
+ fs.mkdirSync(path.dirname(outPath), { recursive: true })
656
+ fs.writeFileSync(outPath, html)
657
+ console.log(`fieldbook → ${outPath}`)
658
+ console.log(`${engagements.length} engagement(s) rendered · ${counts.RED} red / ${counts.amber} amber / ${counts.green} green · 0 tokens (pure render)`)
659
+ if (args.includes('--open')) {
660
+ // arg-array form: the path is never interpolated into a shell string.
661
+ const [bin, pre] = process.platform === 'darwin' ? ['open', []]
662
+ : process.platform === 'win32' ? ['cmd', ['/c', 'start', '']]
663
+ : ['xdg-open', []]
664
+ try { execFileSync(bin, [...pre, outPath], { stdio: 'ignore', timeout: 5000 }) } catch (_) {}
665
+ } else {
666
+ console.log('open it: double-click the file, or run with --open')
667
+ }
668
+ }
669
+
670
+ const [cmd, ...args] = process.argv.slice(2)
671
+ switch (cmd) {
672
+ case 'scan': cmdScan(); break
673
+ case 'resume': cmdResume(args); break
674
+ case 'log': cmdLog(args); break
675
+ case 'receipts': cmdReceipts(args); break
676
+ case 'capture': cmdCapture(); break
677
+ case 'status': cmdStatus(); break
678
+ case 'dashboard': cmdDashboard(args); break
679
+ default:
680
+ console.log(`fde - deterministic core of fdeops
681
+ fde scan day-1 recon of this repo (facts, no AI)
682
+ fde resume load this workspace's engagement memory (bounded)
683
+ fde resume --full load the complete context.md (no bound)
684
+ fde resume --init <name> create + bind engagement for this workspace
685
+ fde log <type> <text> append decision|risk|delivery|contact
686
+ fde receipts <term> "what did we agree?" with dates
687
+ fde capture session-end memory snapshot (hooks use this)
688
+ fde status portfolio across all engagements
689
+ fde dashboard render every engagement into one local fieldbook.html`)
690
+ }