fdeops 3.5.5 → 3.6.1

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
@@ -13,6 +13,7 @@
13
13
  * fde resume --full same, but the complete context.md (no bound)
14
14
  * fde resume --init <n> create + bind an engagement for this workspace
15
15
  * fde log <type> <text> structured append (decision|risk|delivery|contact)
16
+ * fde debrief [file] meeting notes → structured memory (stdin if no file)
16
17
  * fde receipts <term> "what did we agree?" - search memory with dates
17
18
  * fde capture session-end snapshot → context.md (hooks use this)
18
19
  * fde status portfolio across ~/fde-engagements (red/amber/green)
@@ -28,6 +29,8 @@ const ENGAGEMENTS_ROOT = path.join(HOME, 'fde-engagements')
28
29
  const REGISTRY = path.join(ENGAGEMENTS_ROOT, '.registry')
29
30
  const CODE_EXT = ['.js', '.ts', '.tsx', '.jsx', '.py', '.java', '.go', '.rb', '.cs', '.php']
30
31
  const CONF_EXT = CODE_EXT.concat(['.env', '.yaml', '.yml', '.json'])
32
+ // one routing table for structured appends - cmdLog and cmdDebrief share it
33
+ const LOG_FILES = { decision: 'decisions.md', risk: 'risks.md', delivery: 'delivery.md', contact: 'stakeholders.md' }
31
34
 
32
35
  // constant-command runner - never receives user input
33
36
  function sh(cmd, cwd) {
@@ -143,12 +146,34 @@ function sectionBody(md, heading) {
143
146
  }
144
147
 
145
148
  // phase / trust / top risk / freshness - identical heuristic for status + dashboard.
149
+ // Trust resolution: structured [signal:red|amber|green] tokens in stakeholders.md
150
+ // (written by `fde log contact --signal` and `fde debrief`) win - the latest dated
151
+ // one. Older than 21 days → stale: shown with a "?" marker + age so a forgotten
152
+ // signal never silently drives triage. The keyword grep survives only as the
153
+ // zero-effort floor when NO token exists anywhere - prose like "escalated to CTO,
154
+ // resolved amicably" must not flip a client amber forever.
146
155
  function computeSignals(eng) {
147
156
  const ctx = readEng(eng, 'context.md'); const stake = readEng(eng, 'stakeholders.md'); const risks = readEng(eng, 'risks.md')
148
157
  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'
158
+ let latest = null
159
+ for (const l of stake.split('\n')) {
160
+ const sm = l.match(/\[signal:(red|amber|green)\]/i)
161
+ if (!sm) continue
162
+ const date = (l.match(/\[(\d{4}-\d{2}-\d{2})\]/) || [])[1] || ''
163
+ if (!latest || date >= latest.date) latest = { date, sig: sm[1].toLowerCase() }
164
+ }
165
+ let trust, signalAge = null, stale = false
166
+ if (latest) {
167
+ trust = latest.sig === 'red' ? 'RED' : latest.sig
168
+ if (latest.date) {
169
+ signalAge = Math.max(0, Math.floor((Date.now() - Date.parse(latest.date)) / 86400000))
170
+ stale = signalAge > 21
171
+ }
172
+ } else {
173
+ const sLines = stake.split('\n').filter(l => !(/green/i.test(l) && /red|amber/i.test(l)))
174
+ trust = sLines.some(l => /\bred\b/i.test(l)) ? 'RED'
175
+ : sLines.some(l => /amber|gone quiet|routing around|escalat/i.test(l)) ? 'amber' : 'green'
176
+ }
152
177
  const topRisk = (risks.split('\n').find(l => {
153
178
  const t = l.trim()
154
179
  return /^[-|]/.test(t) && t.length > 20 && !/^\|?[-\s|]+$/.test(t) &&
@@ -159,7 +184,7 @@ function computeSignals(eng) {
159
184
  ageDays = Math.floor((Date.now() - fs.statSync(path.join(eng, 'context.md')).mtimeMs) / 86400000)
160
185
  updated = ageDays === 0 ? 'today' : `${ageDays}d ago`
161
186
  } catch (_) {}
162
- return { phase, trust, topRisk, updated, ageDays }
187
+ return { phase, trust, signalAge, stale, topRisk, updated, ageDays }
163
188
  }
164
189
 
165
190
  // ---------- commands ----------
@@ -183,6 +208,7 @@ function cmdScan() {
183
208
 
184
209
  // churn × tests = the load-bearing walls
185
210
  out.push('\nHOTSPOTS (churn 90d × test coverage) - handle with care:')
211
+ let firstUntested = ''
186
212
  if (isGit) {
187
213
  const churn = sh("git log --since='90 days ago' --name-only --pretty=format:") || ''
188
214
  const counts = {}
@@ -193,6 +219,7 @@ function cmdScan() {
193
219
  for (const [f, n] of top) {
194
220
  const base = path.basename(f).replace(/\.[^.]+$/, '')
195
221
  const tested = testFiles.some(t => t.includes(base))
222
+ if (!tested && !firstUntested) firstUntested = f
196
223
  out.push(` ${String(n).padStart(3)} commits/90d ${f} ${tested ? '' : '⚠ NO TEST NEIGHBOR'}`)
197
224
  }
198
225
  } else out.push(' (not a git repo - churn unavailable)')
@@ -205,7 +232,10 @@ function cmdScan() {
205
232
 
206
233
  // AI components - they fail silently
207
234
  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)
235
+ // NOTE: bare "inference" is banned from this regex - TypeScript codebases are
236
+ // full of "type inference" comments and the false positives poison the day-1
237
+ // questions. Model inference only, in explicit forms.
238
+ const ai = grepFiles(codeFiles, /openai|anthropic|\bllm\b|gpt-|claude|embedding|vector store|model inference|inference (?:api|endpoint|server|engine)/i, 10)
209
239
  ai.length ? ai.forEach(h => out.push(` ${h.file}:${h.line} ${h.text}`)) : out.push(' none found')
210
240
 
211
241
  // secrets (redacted)
@@ -216,6 +246,7 @@ function cmdScan() {
216
246
  sec.length
217
247
  ? sec.forEach(h => out.push(` ${h.file}:${h.line} ${h.text.replace(/(['"])([^'"]{4})[^'"]+(['"])/, '$1$2…REDACTED$3')}`))
218
248
  : out.push(' none found')
249
+ out.push(' (grep-grade check - run gitleaks or trufflehog for real secret coverage)')
219
250
 
220
251
  // previous attempts - the political archaeology
221
252
  out.push('\nPREVIOUS ATTEMPTS (ask who ran these, and what happened):')
@@ -229,6 +260,18 @@ function cmdScan() {
229
260
  const testCount = files.filter(f => /test|spec/i.test(f)).length
230
261
  out.push(`\nTEST LANDSCAPE ${testCount} test file(s) across ${codeFiles.length} code files`)
231
262
 
263
+ // day-1 questions - each one earned by a finding above, skipped when empty
264
+ out.push('\nASK ON DAY 1:')
265
+ const asks = []
266
+ if (reverts || readmeHits.length) asks.push('Who ran the previous attempt(s), and what happened to them?')
267
+ if (firstUntested) asks.push(`What breaks when ${firstUntested} changes, and who owns it?`)
268
+ if (ai.length) asks.push(`How would anyone notice if ${ai[0].file}'s model output drifted?`)
269
+ if (sec.length) asks.push('What is the secret-rotation story?')
270
+ if (tmp.length) asks.push("Which of these 'temporary' fixes are now load-bearing contracts?")
271
+ asks.length
272
+ ? asks.slice(0, 5).forEach((q, i) => out.push(` ${i + 1}. ${q}`))
273
+ : out.push(' (clean scan - ask what the last engineer wished they had known)')
274
+
232
275
  out.push('\n' + '-'.repeat(60))
233
276
  out.push("Facts only - interpretation is the FDE's (or @fde's) job.")
234
277
  console.log(out.join('\n'))
@@ -250,11 +293,30 @@ function cmdResume(args) {
250
293
  else if (!fs.existsSync(dst)) fs.copyFileSync(src, dst)
251
294
  }
252
295
  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()}`)
296
+ // bind THIS workspace to the engagement (zero ceremony next time).
297
+ // A workspace binds to exactly ONE engagement: rebinding REPLACES the old
298
+ // line - resolution is first-match-wins, so appending a second line would
299
+ // leave the stale binding winning and silently write to the wrong client.
300
+ const cwd = process.cwd()
301
+ const prev = readRegistry().find(r => r.workspace === cwd)
302
+ const kept = readRegistry().filter(r => r.workspace !== cwd).map(r => `${r.workspace} ${r.slug}`)
303
+ kept.push(`${cwd} ${slug}`)
304
+ fs.writeFileSync(REGISTRY, kept.join('\n') + '\n')
305
+ console.log(`ENGAGEMENT READY: ${fdeDir}\nbound to workspace: ${cwd}`)
306
+ if (prev && prev.slug !== slug) console.log(`rebound: this workspace previously wrote to "${prev.slug}" - that memory is untouched; sessions here now write to "${slug}"`)
307
+ // NDA surface: engagement notes must not silently leave the machine via file sync
308
+ const syncHit = /icloud|mobile documents|dropbox|onedrive|google drive|box sync/i.exec(ENGAGEMENTS_ROOT)
309
+ if (syncHit) console.log(`⚠ engagements root is inside a synced folder ("${syncHit[0]}") - client notes will leave this machine via sync. See PRIVACY.md.`)
310
+ return
311
+ }
312
+ if (args[0] === '--bind') {
313
+ // inspection: what does THIS workspace resolve to, and why
314
+ const cwd = process.cwd()
315
+ const reg = readRegistry().find(r => r.workspace === cwd)
316
+ const eng = resolveEngagement()
317
+ console.log(`workspace: ${cwd}`)
318
+ console.log(`registry: ${reg ? `${reg.slug} (${path.join(ENGAGEMENTS_ROOT, reg.slug, '.fde')})` : '(not bound)'}`)
319
+ console.log(`resolves: ${eng || '(nothing - run: fde resume --init <client>)'}`)
258
320
  return
259
321
  }
260
322
  const eng = resolveEngagement()
@@ -296,14 +358,77 @@ function resumeView(md) {
296
358
  }
297
359
 
298
360
  function cmdLog(args) {
299
- const map = { decision: 'decisions.md', risk: 'risks.md', delivery: 'delivery.md', contact: 'stakeholders.md' }
361
+ args = args.slice()
362
+ // --signal red|amber|green (contact only) → structured token computeSignals trusts
363
+ let signal = ''
364
+ const sigIdx = args.indexOf('--signal')
365
+ if (sigIdx !== -1) {
366
+ signal = (args[sigIdx + 1] || '').toLowerCase()
367
+ if (!['red', 'amber', 'green'].includes(signal)) { console.error('usage: fde log contact <text> --signal red|amber|green'); process.exit(1) }
368
+ args.splice(sigIdx, 2)
369
+ }
300
370
  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) }
371
+ if (!LOG_FILES[type] || !text) { console.error('usage: fde log <decision|risk|delivery|contact> <text> [--signal red|amber|green]'); process.exit(1) }
372
+ if (signal && type !== 'contact') { console.error('--signal only applies to: fde log contact'); process.exit(1) }
302
373
  const eng = resolveEngagement()
303
374
  if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
304
375
  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]}`)
376
+ fs.appendFileSync(path.join(eng, LOG_FILES[type]), `\n- [${date}] ${signal ? `[signal:${signal}] ` : ''}${text}\n`)
377
+ console.log(`logged → ${LOG_FILES[type]}${signal ? ` (signal:${signal})` : ''}`)
378
+ }
379
+
380
+ // Meeting notes → structured memory. Deterministic routing, zero AI: lines that
381
+ // start with decision:/risk:/delivery:/contact: (case-insensitive) go to their
382
+ // LOG_FILES target as dated bullets; everything else lands in context.md as one
383
+ // dated debrief block. contact: lines may carry an inline [signal:x] token
384
+ // anywhere in the text - preserved verbatim so computeSignals can trust it.
385
+ // Real notes arrive as markdown: "- decision: ...", "* contact: ...",
386
+ // "**Decision:** ..." - strip bullet/bold dressing before matching, or the
387
+ // prefix silently misses and a [signal:x] token lands in context.md, which
388
+ // signal parsing never reads. Silent loss is the one failure a memory tool
389
+ // cannot have. --dry-run prints the routing without writing anything.
390
+ function cmdDebrief(args) {
391
+ args = args.slice()
392
+ const dryIdx = args.indexOf('--dry-run')
393
+ const dry = dryIdx !== -1
394
+ if (dry) args.splice(dryIdx, 1)
395
+ const eng = resolveEngagement()
396
+ if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
397
+ let input = ''
398
+ if (args[0]) {
399
+ try { input = fs.readFileSync(args[0].replace(/^~/, HOME), 'utf8') }
400
+ catch (_) { console.error(`cannot read ${args[0]}`); process.exit(1) }
401
+ } else {
402
+ try { input = fs.readFileSync(0, 'utf8') } catch (_) {} // stdin until EOF
403
+ }
404
+ const d = new Date()
405
+ const date = d.toISOString().slice(0, 10)
406
+ const counts = { decision: 0, risk: 0, delivery: 0, contact: 0 }
407
+ const ctxLines = []
408
+ for (const raw of input.split('\n')) {
409
+ let line = raw.trim()
410
+ if (!line) continue
411
+ // markdown dressing: leading bullets (-, *, +) and bold around the prefix
412
+ const bare = line.replace(/^[-*+]\s+/, '').replace(/^\*\*(decision|risk|delivery|contact):?\*\*:?\s*/i, '$1: ')
413
+ const m = bare.match(/^(decision|risk|delivery|contact):\s*(.+)$/i)
414
+ if (m) {
415
+ const type = m[1].toLowerCase()
416
+ if (dry) console.log(`→ ${LOG_FILES[type]} - [${date}] ${m[2]}`)
417
+ else fs.appendFileSync(path.join(eng, LOG_FILES[type]), `\n- [${date}] ${m[2]}\n`)
418
+ counts[type]++
419
+ } else ctxLines.push(line)
420
+ }
421
+ if (ctxLines.length) {
422
+ const stamp = `${date} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
423
+ if (dry) ctxLines.forEach(l => console.log(`→ context.md - ${l}`))
424
+ else fs.appendFileSync(path.join(eng, 'context.md'), `\n## Debrief - ${stamp}\n${ctxLines.map(l => `- ${l}`).join('\n')}\n`)
425
+ }
426
+ const plural = { decision: 'decisions', risk: 'risks', delivery: 'deliveries', contact: 'contacts' }
427
+ const parts = Object.keys(counts).filter(t => counts[t])
428
+ .map(t => `${counts[t]} ${counts[t] === 1 ? t : plural[t]}`)
429
+ if (ctxLines.length) parts.push(`${ctxLines.length} context line${ctxLines.length === 1 ? '' : 's'}`)
430
+ const verb = dry ? 'debrief would route' : 'debrief routed'
431
+ console.log(parts.length ? `${verb} → ${parts.join(', ')}` : 'debrief empty - nothing routed')
307
432
  }
308
433
 
309
434
  function cmdReceipts(args) {
@@ -320,7 +445,7 @@ function cmdReceipts(args) {
320
445
  if (rx.test(l)) { console.log(`${f}:${i + 1} ${l.trim().slice(0, 160)}`); found++ }
321
446
  })
322
447
  }
323
- if (!found) console.log(`no record of "${term}" - if it was agreed, it was never logged. That is itself the answer.`)
448
+ if (!found) console.log(`no record of "${term}" - nothing was ever logged about it. A gap in the record, not proof of absence: if it WAS agreed, log it now, dated today.`)
324
449
  }
325
450
 
326
451
  function cmdCapture() {
@@ -353,16 +478,19 @@ function cmdStatus() {
353
478
  const eng = path.join(ENGAGEMENTS_ROOT, d, '.fde')
354
479
  if (!fs.existsSync(eng)) continue
355
480
  const s = computeSignals(eng)
356
- rows.push({ name: d, phase: s.phase, trust: s.trust, updated: s.updated, topRisk: s.topRisk.slice(0, 60) })
481
+ rows.push({ name: d, phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated, topRisk: s.topRisk.slice(0, 60) })
357
482
  }
358
483
  if (!rows.length) { console.log('no engagements yet'); return }
359
484
  const order = { RED: 0, amber: 1, green: 2 }
360
485
  rows.sort((a, b) => order[a.trust] - order[b.trust])
361
486
  console.log('FDE PORTFOLIO - trust-first triage (heuristic: red > amber > green)\n')
362
487
  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}`)
488
+ // "amber?" = structured signal went stale (>21d) - reconfirm before trusting it
489
+ const label = r.trust + (r.stale ? '?' : '')
490
+ const sig = r.signalAge != null ? `signal ${r.signalAge}d old${r.stale ? ' (STALE - reconfirm)' : ''} ` : ''
491
+ console.log(` [${label.padEnd(6)}] ${r.name.padEnd(24)} phase:${r.phase.padEnd(10)} updated:${r.updated.padEnd(8)} ${sig}${r.topRisk}`)
364
492
  }
365
- console.log('\nred/amber derive from stakeholders.md signal words - verify before acting.')
493
+ console.log('\ntrust: latest [signal:x] token in stakeholders.md wins (fde log contact --signal, fde debrief); keyword heuristic only when none exists - verify before acting.')
366
494
  }
367
495
 
368
496
  // ---------- dashboard (deterministic markdown → one local HTML) ----------
@@ -475,12 +603,21 @@ const DASH_SECTIONS = [
475
603
  function dashStyles() {
476
604
  return [
477
605
  // 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;',
606
+ // light (default) + dark (opt-in via [data-fde-theme="dark"], set pre-paint - see cmdDashboard head script)
607
+ ':root{--bg:#f9fafb;--card:#fff;--ink:#111827;--2:#374151;--3:#4b5563;--muted:#6b7280;--line:#f3f4f6;--line2:#e5e7eb;',
479
608
  '--green:#10b981;--amber:#f59e0b;--red:#ef4444;--accent:#6366f1;',
609
+ '--red-bg:#fef2f2;--red-border:#fecaca;--red-ink:#7f1d1d;--red-ink2:#991b1b;',
610
+ '--amber-bg:#fffbeb;--amber-border:#fde68a;--amber-ink:#78350f;--amber-ink2:#92400e;',
480
611
  '--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
612
  '--r:8px}',
613
+ 'html[data-fde-theme="dark"]{--bg:#0b0d12;--card:#161a22;--ink:#e5e7eb;--2:#cbd5e1;--3:#9ca3af;--muted:#7b8494;--line:#1f2430;--line2:#262c3a;',
614
+ '--green:#34d399;--amber:#fbbf24;--red:#f87171;--accent:#818cf8;',
615
+ '--red-bg:#2a1517;--red-border:#5b2328;--red-ink:#fecaca;--red-ink2:#fca5a5;',
616
+ '--amber-bg:#2a2210;--amber-border:#5b4a1f;--amber-ink:#fde68a;--amber-ink2:#fcd34d;',
617
+ '--shadow-s:0 1px 2px rgba(0,0,0,.3);--shadow:0 1px 3px rgba(0,0,0,.35);--shadow-l:0 4px 16px rgba(0,0,0,.45)}',
482
618
  '*{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}',
619
+ '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;transition:background .15s,color .15s}',
620
+ '.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace}',
484
621
  // header - compact, purposeful
485
622
  'header{background:#111827;color:#fff;padding:20px 32px}',
486
623
  'header .inner{max-width:960px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px}',
@@ -490,34 +627,61 @@ function dashStyles() {
490
627
  '.stats{display:flex;align-items:center;gap:16px}',
491
628
  '.stat{font-size:12px;color:#9ca3af;display:flex;align-items:center;gap:5px}',
492
629
  '.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}',
630
+ '.stat .sdot{width:6px;height:6px;border-radius:50%;flex-shrink:0;box-sizing:border-box}',
631
+ '.stat .sdot.green{background:#34d399}.stat .sdot.amber{background:#fbbf24;border-radius:1.5px}.stat .sdot.red{background:transparent;border:1.5px solid #f87171}',
632
+ '.theme-toggle{border:1px solid #374151;background:transparent;color:#d1d5db;font-size:11px;padding:5px 10px;border-radius:6px;cursor:pointer;font-family:inherit;line-height:1.4}',
633
+ '.theme-toggle:hover{border-color:#6b7280;color:#fff}',
495
634
  // layout
496
635
  '.wrap{max-width:960px;margin:0 auto;padding:24px 32px 64px}',
497
636
  // search - integrated, not floating
498
- '.search-wrap{position:relative;margin-bottom:24px}',
637
+ '.search-wrap{position:relative;margin-bottom:8px}',
499
638
  '.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)}',
639
+ '.search{width:100%;padding:10px 14px 10px 36px;font-size:13px;border:1px solid var(--line2);border-radius:var(--r);background:var(--card);color:var(--ink);transition:border-color .15s,box-shadow .15s;outline:none}',
640
+ '.search:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(99,102,241,.15)}',
502
641
  '.search::placeholder{color:var(--muted)}',
642
+ '.search-hint{display:block;margin:6px 2px 20px;font-size:11px;color:var(--muted)}',
643
+ '.search-hint kbd{font-family:inherit;border:1px solid var(--line2);border-radius:4px;padding:1px 5px;font-size:10.5px;background:var(--bg)}',
644
+ // directive - the one line that says where to start today
645
+ '.directive{display:flex;align-items:center;gap:10px;margin:0 0 20px;padding:12px 16px;background:var(--red-bg);border:1px solid var(--red-border);border-radius:var(--r);font-size:13.5px;line-height:1.5;color:var(--red-ink)}',
646
+ '.directive b{font-weight:700;color:var(--red-ink)}',
647
+ '.directive.amber{background:var(--amber-bg);border-color:var(--amber-border);color:var(--amber-ink)}',
648
+ '.directive.amber b{color:var(--amber-ink)}',
649
+ '.directive-dot{width:9px;height:9px;border-radius:50%;background:var(--red);flex-shrink:0;box-shadow:0 0 0 3px rgba(239,68,68,.18)}',
650
+ '.directive.amber .directive-dot{background:var(--amber);box-shadow:0 0 0 3px rgba(245,158,11,.18)}',
651
+ // attention rows - red engagements dominate the page, full width
652
+ '.attn-stack{display:flex;flex-direction:column;gap:10px}',
653
+ '.attn{background:var(--red-bg);border:1px solid var(--red-border);border-left:4px solid var(--red);border-radius:var(--r);padding:16px 18px;cursor:pointer;transition:box-shadow .15s,border-color .15s}',
654
+ '.attn:hover{border-color:var(--red);box-shadow:var(--shadow-l)}',
655
+ '.attn-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
656
+ '.attn-head h3{font-size:15px;font-weight:700;color:var(--ink)}',
657
+ '.attn .risk{margin-top:10px}',
658
+ '.attn .next{margin-top:8px;font-size:13px;line-height:1.5;color:var(--2)}.attn .next b{color:var(--3);font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:.3px;margin-right:4px}',
503
659
  // grid
504
660
  '.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}',
505
661
  // cards - clean, scannable
506
662
  '.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
663
  '.card::before{content:"";position:absolute;left:0;top:0;bottom:0;width:3px}',
508
- '.card:hover{border-color:#d1d5db;box-shadow:var(--shadow-l)}',
664
+ '.card:hover{border-color:var(--muted);box-shadow:var(--shadow-l)}',
509
665
  '.card.green::before{background:var(--green)}.card.amber::before{background:var(--amber)}.card.red::before{background:var(--red)}',
510
666
  '.card h3{font-size:14px;font-weight:600;margin-bottom:6px}',
511
667
  '.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)}',
668
+ '.badge{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;padding:2px 7px;border-radius:4px;background:var(--line);color:var(--3);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace}',
669
+ // colorblind-safe: shape carries the signal too, color reinforces it - circle/square/ring
670
+ '.dot{width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0;box-sizing:border-box}',
671
+ '.dot.green{background:var(--green)}.dot.amber{background:var(--amber);border-radius:2px}.dot.red{background:transparent;border:2px solid var(--red)}',
515
672
  '.trust-label{font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.3px}',
516
673
  '.t-green{color:var(--green)}.t-amber{color:var(--amber)}.t-red{color:var(--red)}',
517
674
  '.meta{color:var(--muted);font-size:11px}',
518
675
  '.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)}',
676
+ '.card .risk{margin-top:8px;font-size:11.5px;color:var(--red-ink2);padding:5px 8px;background:var(--red-bg);border-radius:4px;border-left:2px solid var(--red);line-height:1.4}',
677
+ '.card .risk.amber-risk{color:var(--amber-ink2);background:var(--amber-bg);border-left-color:var(--amber)}',
678
+ // demote placeholder cards (no next action) - quiet until they earn attention
679
+ '.card.muted{opacity:.55}',
680
+ '.card.muted:hover{opacity:1}',
681
+ // search match context - why this card/row matched, in the card's own words
682
+ '.match-snippet{margin-top:8px;font-size:11.5px;line-height:1.4;color:var(--3);background:var(--bg);border-radius:4px;padding:5px 8px}',
683
+ '.match-snippet:empty{display:none;padding:0;margin:0}',
684
+ '.match-snippet mark{background:rgba(99,102,241,.25);color:inherit;border-radius:2px;padding:0 1px}',
521
685
  // section labels
522
686
  'h2.section{margin:28px 0 12px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)}',
523
687
  // detail accordion - tight, content-forward
@@ -541,10 +705,10 @@ function dashStyles() {
541
705
  '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
706
  'tr:last-child td{border-bottom:none}',
543
707
  'tr:hover td{background:var(--bg)}',
544
- 'code{background:#f3f4f6;padding:1px 5px;border-radius:4px;font-size:12px;color:#4b5563}',
708
+ 'code{background:var(--line);padding:1px 5px;border-radius:4px;font-size:12px;color:var(--3)}',
545
709
  // footer
546
710
  '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}',
711
+ 'footer code{color:var(--3);background:var(--line)}',
548
712
  '.hide{display:none!important}',
549
713
  // responsive
550
714
  '@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}}',
@@ -555,19 +719,47 @@ function dashScript() {
555
719
  return [
556
720
  "var q=document.getElementById('q');",
557
721
  "function norm(s){return (s||'').toLowerCase();}",
722
+ "function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}",
723
+ // why a card matched - a short excerpt from its own text, term highlighted
724
+ "function snippetFor(raw,term){",
725
+ " var i=raw.indexOf(term);",
726
+ " if(i<0)return '';",
727
+ " var start=Math.max(0,i-28),end=Math.min(raw.length,i+term.length+46);",
728
+ " var pre=esc(raw.slice(start,i)),hit=esc(raw.slice(i,i+term.length)),post=esc(raw.slice(i+term.length,end));",
729
+ " return (start>0?'\u2026':'')+pre+'<mark>'+hit+'</mark>'+post+(end<raw.length?'\u2026':'');",
730
+ "}",
558
731
  "if(q){q.addEventListener('input',function(){",
559
732
  " var term=norm(q.value);",
560
733
  " document.querySelectorAll('[data-search]').forEach(function(el){",
561
- " var hit=!term||norm(el.getAttribute('data-search')).indexOf(term)>-1;",
734
+ " var raw=norm(el.getAttribute('data-search'));",
735
+ " var hit=!term||raw.indexOf(term)>-1;",
562
736
  " el.classList.toggle('hide',!hit);",
737
+ " var snip=el.querySelector('.match-snippet');",
738
+ " if(snip)snip.innerHTML=(hit&&term)?snippetFor(raw,term):'';",
563
739
  " });",
564
740
  "});}",
565
- "document.querySelectorAll('.card').forEach(function(c){",
741
+ "document.querySelectorAll('.card,.attn').forEach(function(c){",
566
742
  " c.addEventListener('click',function(){",
567
743
  " var d=document.getElementById(c.getAttribute('data-target'));",
568
744
  " if(d){d.open=true;d.scrollIntoView({behavior:'smooth',block:'start'});}",
569
745
  " });",
570
746
  "});",
747
+ // '/' jumps to search from anywhere, unless already typing somewhere
748
+ "document.addEventListener('keydown',function(e){",
749
+ " var t=e.target,typing=t&&(t.tagName==='INPUT'||t.tagName==='TEXTAREA'||t.isContentEditable);",
750
+ " if(!typing&&e.key==='/'&&q){e.preventDefault();q.focus();}",
751
+ "});",
752
+ // theme toggle - local only, no network; pre-paint script in <head> avoids a flash
753
+ "var themeBtn=document.getElementById('fde-theme-btn');",
754
+ "function currentTheme(){return document.documentElement.getAttribute('data-fde-theme')==='dark'?'dark':'light';}",
755
+ "function setThemeLabel(){if(themeBtn)themeBtn.textContent=(currentTheme()==='dark'?'light':'dark')+' mode';}",
756
+ "setThemeLabel();",
757
+ "if(themeBtn){themeBtn.addEventListener('click',function(){",
758
+ " var next=currentTheme()==='dark'?'light':'dark';",
759
+ " try{localStorage.setItem('fde-fieldbook-theme',next);}catch(_){}",
760
+ " document.documentElement.setAttribute('data-fde-theme',next);",
761
+ " setThemeLabel();",
762
+ "});}",
571
763
  ].join('\n')
572
764
  }
573
765
 
@@ -582,25 +774,71 @@ function cmdDashboard(args) {
582
774
  const now = new Date()
583
775
  const stamp = now.toISOString().slice(0, 16).replace('T', ' ') + ' UTC'
584
776
 
585
- const cards = engagements.map(e => {
586
- const id = 'eng-' + slugify(e.name)
777
+ // enrich each engagement: next action + search index (reused in rows, cards, directive)
778
+ engagements.forEach(e => {
587
779
  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())
780
+ e.next = (sectionBody(ctx, 'Next action').split('\n').find(l => l.trim()) || '').trim()
781
+ e.hasNext = !!e.next
782
+ e.searchBlob = escapeHtml(stripPrivate(e.name + ' ' + ctx + ' ' + readEng(e.dir, 'risks.md') + ' ' + readEng(e.dir, 'stakeholders.md')).toLowerCase())
783
+ })
784
+
785
+ // triage order: red first (most stale first), then amber, then green; real work above placeholders
786
+ const tierRank = { RED: 0, amber: 1, green: 2 }
787
+ const ordered = engagements.slice().sort((a, b) =>
788
+ (tierRank[a.signals.trust] - tierRank[b.signals.trust])
789
+ || ((b.hasNext ? 1 : 0) - (a.hasNext ? 1 : 0))
790
+ || ((b.signals.ageDays || 0) - (a.signals.ageDays || 0))
791
+ || a.name.localeCompare(b.name))
792
+ const reds = ordered.filter(e => e.signals.trust === 'RED')
793
+ const rest = ordered.filter(e => e.signals.trust !== 'RED')
794
+
795
+ // one directive line: where the FDE starts today, and why
796
+ const lead = reds[0] || rest.find(e => e.signals.trust === 'amber' && e.hasNext) || rest[0]
797
+ let directive = '', directiveClass = 'directive'
798
+ if (lead) {
799
+ const why = stripPrivate(lead.signals.topRisk || lead.next || (lead.signals.trust === 'RED' ? 'trust signal is red' : 'oldest open thread')).trim().slice(0, 90)
800
+ const age = lead.signals.ageDays != null ? `${lead.signals.ageDays}d since touched` : ''
801
+ directive = `Start here: <b>${inlineMd(lead.name)}</b> - ${inlineMd(why)}${age ? ' · ' + age : ''}`
802
+ directiveClass = reds.length ? 'directive' : 'directive amber'
803
+ }
804
+
805
+ // signal provenance: how old the structured trust token is, "?" when stale (>21d)
806
+ const signalMeta = e => e.signals.signalAge != null
807
+ ? `<span class="meta">signal ${e.signals.signalAge}d old${e.signals.stale ? ' - reconfirm' : ''}</span>` : ''
808
+
809
+ // red engagements: full-width attention rows that dominate the page
810
+ const attention = reds.map(e => {
811
+ const id = 'eng-' + slugify(e.name)
592
812
  return [
593
- `<div class="card ${trustClass}" data-target="${id}" data-search="${searchBlob}">`,
813
+ `<div class="attn" data-target="${id}" data-search="${e.searchBlob}">`,
814
+ `<div class="attn-head"><span class="dot red"></span><span class="trust-label t-red">RED${e.signals.stale ? '?' : ''}</span>`,
594
815
  `<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>',
816
+ `<span class="badge">${inlineMd(e.signals.phase)}</span><span class="meta">updated ${e.signals.updated}</span>${signalMeta(e)}</div>`,
598
817
  e.signals.topRisk ? `<div class="risk">${inlineMd(e.signals.topRisk)}</div>` : '',
818
+ e.next ? `<div class="next"><b>Next</b> ${inlineMd(e.next)}</div>` : '',
819
+ `<div class="match-snippet"></div>`,
820
+ `</div>`,
821
+ ].join('')
822
+ }).join('\n')
823
+
824
+ // amber/green: quieter portfolio grid; cards without a next action sink and dim
825
+ const cards = rest.map(e => {
826
+ const id = 'eng-' + slugify(e.name)
827
+ const trustClass = e.signals.trust
828
+ const muted = e.hasNext ? '' : ' muted'
829
+ return [
830
+ `<div class="card ${trustClass}${muted}" data-target="${id}" data-search="${e.searchBlob}">`,
831
+ `<h3>${inlineMd(e.name)}</h3>`,
832
+ `<div class="row"><span class="dot ${trustClass}"></span><span class="trust-label t-${trustClass}">${trustClass}${e.signals.stale ? '?' : ''}</span>`,
833
+ `<span class="badge">${inlineMd(e.signals.phase)}</span><span class="meta">updated ${e.signals.updated}</span>${signalMeta(e)}</div>`,
834
+ e.next ? `<div class="next"><b>Next</b> ${inlineMd(e.next)}</div>` : '<div class="next meta">next action not set</div>',
835
+ e.signals.topRisk ? `<div class="risk amber-risk">${inlineMd(e.signals.topRisk)}</div>` : '',
836
+ `<div class="match-snippet"></div>`,
599
837
  `</div>`,
600
838
  ].join('')
601
839
  }).join('\n')
602
840
 
603
- const details = engagements.map(e => {
841
+ const details = ordered.map(e => {
604
842
  const id = 'eng-' + slugify(e.name)
605
843
  const trustClass = e.signals.trust === 'RED' ? 'red' : e.signals.trust
606
844
  const subs = DASH_SECTIONS.map(([file, title]) => {
@@ -633,6 +871,9 @@ function cmdDashboard(args) {
633
871
  const html = [
634
872
  '<!doctype html><html lang="en"><head><meta charset="utf-8">',
635
873
  '<meta name="viewport" content="width=device-width,initial-scale=1">',
874
+ // pre-paint theme: read the one localStorage key before first render so there is no flash.
875
+ // No network calls - this file still opens and works fully offline.
876
+ '<script>try{var t=localStorage.getItem("fde-fieldbook-theme");if(t==="dark"||(!t&&window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches))document.documentElement.setAttribute("data-fde-theme","dark");}catch(e){}</script>',
636
877
  '<title>FDE Fieldbook</title><style>' + dashStyles() + '</style></head><body>',
637
878
  '<header><div class="inner"><div class="brand"><h1>FDE Fieldbook</h1>',
638
879
  `<span class="tagline">${engagements.length} engagement${engagements.length === 1 ? '' : 's'}</span></div>`,
@@ -640,11 +881,15 @@ function cmdDashboard(args) {
640
881
  `<span class="stat"><span class="sdot green"></span> <b>${counts.green}</b> green</span>`,
641
882
  `<span class="stat"><span class="sdot amber"></span> <b>${counts.amber}</b> amber</span>`,
642
883
  `<span class="stat"><span class="sdot red"></span> <b>${counts.RED}</b> red</span>`,
884
+ '<button id="fde-theme-btn" class="theme-toggle" type="button">dark mode</button>',
643
885
  '</div></div></header>',
644
886
  '<div class="wrap">',
645
887
  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,
888
+ engagements.length ? '<span class="search-hint">press <kbd>/</kbd> to search - a matching card shows why, right on the card</span>' : '',
889
+ directive ? `<div class="${directiveClass}"><span class="directive-dot"></span><span>${directive}</span></div>` : '',
890
+ reds.length ? '<h2 class="section">Needs attention</h2><div class="attn-stack">' + attention + '</div>' : '',
891
+ engagements.length ? `<h2 class="section">${reds.length ? 'Rest of portfolio' : 'Portfolio'}</h2>` : '',
892
+ engagements.length ? (rest.length ? `<div class="grid">${cards}</div>` : '<p class="empty">Every active engagement needs attention - see above.</p>') : emptyState,
648
893
  engagements.length ? '<h2 class="section">Engagement detail</h2>' + details : '',
649
894
  '</div>',
650
895
  `<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>`,
@@ -672,6 +917,7 @@ switch (cmd) {
672
917
  case 'scan': cmdScan(); break
673
918
  case 'resume': cmdResume(args); break
674
919
  case 'log': cmdLog(args); break
920
+ case 'debrief': cmdDebrief(args); break
675
921
  case 'receipts': cmdReceipts(args); break
676
922
  case 'capture': cmdCapture(); break
677
923
  case 'status': cmdStatus(); break
@@ -681,8 +927,10 @@ switch (cmd) {
681
927
  fde scan day-1 recon of this repo (facts, no AI)
682
928
  fde resume load this workspace's engagement memory (bounded)
683
929
  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
930
+ fde resume --init <name> create + bind engagement for this workspace (rebind replaces)
931
+ fde resume --bind show what this workspace is bound to, and what resolves
932
+ fde log <type> <text> append decision|risk|delivery|contact (contact takes --signal red|amber|green)
933
+ fde debrief [file] meeting notes → memory: decision:/risk:/delivery:/contact: lines route, rest → context.md (stdin if no file; --dry-run previews)
686
934
  fde receipts <term> "what did we agree?" with dates
687
935
  fde capture session-end memory snapshot (hooks use this)
688
936
  fde status portfolio across all engagements
package/bin/install.js CHANGED
@@ -210,11 +210,17 @@ function cmdInstall() {
210
210
  console.log('')
211
211
  }
212
212
 
213
+ // `npx fdeops scan` must recon, not install - any fde subcommand passes straight
214
+ // through to the CLI (fde.js reads process.argv itself, so require() is enough).
215
+ const FDE_SUBCOMMANDS = ['scan', 'resume', 'log', 'debrief', 'receipts', 'capture', 'status', 'dashboard']
216
+
213
217
  const arg = process.argv[2]
214
218
  if (arg === 'init') {
215
219
  cmdInit(process.argv[3])
216
220
  } else if (arg === 'adapters') {
217
221
  cmdAdapters(process.argv[3])
222
+ } else if (FDE_SUBCOMMANDS.includes(arg)) {
223
+ require(path.join(__dirname, 'fde.js'))
218
224
  } else {
219
225
  cmdInstall()
220
226
  }
package/hooks/pre-compact CHANGED
@@ -11,8 +11,27 @@ resolve_engagement_dir() {
11
11
  return 1
12
12
  }
13
13
 
14
+ # Workspace registry written by `fde resume --init` - mirrors resolveEngagement()
15
+ # step 2 in bin/fde.js; keep the resolution order identical in bash and JS.
16
+ # Lines are "<workspace-path> <slug>": the path may contain spaces, the slug
17
+ # never does, so split on the LAST space (same as the JS lastIndexOf parse).
18
+ registry_engagement_dir() {
19
+ local reg="$HOME/fde-engagements/.registry" slug
20
+ [ -f "$reg" ] || return 1
21
+ slug=$(awk 'BEGIN{ws=ENVIRON["PWD"]}
22
+ { i = match($0, / [^ ]*$/)
23
+ if (i > 0 && substr($0, 1, i - 1) == ws) { print substr($0, i + 1); exit } }' "$reg" 2>/dev/null)
24
+ [ -z "$slug" ] && return 1
25
+ [ -d "$HOME/fde-engagements/$slug/.fde" ] && printf '%s\n' "$HOME/fde-engagements/$slug/.fde" && return 0
26
+ return 1
27
+ }
28
+
14
29
  ENG_DIR=$(resolve_engagement_dir "${FDEOPS_ENGAGEMENT:-${FDEOS_ENGAGEMENT:-}}")
15
30
 
31
+ if [ -z "$ENG_DIR" ]; then
32
+ ENG_DIR=$(registry_engagement_dir)
33
+ fi
34
+
16
35
  if [ -z "$ENG_DIR" ] && [ -f "CLAUDE.md" ]; then
17
36
  ENG=$(grep -m1 '^FDEOPS_ENGAGEMENT=\|^FDEOS_ENGAGEMENT=' CLAUDE.md 2>/dev/null | cut -d= -f2-)
18
37
  ENG_DIR=$(resolve_engagement_dir "$ENG")