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/README.md +97 -113
- package/adapters/AGENTS.md +1 -1
- package/adapters/GEMINI.md +1 -1
- package/adapters/copilot-instructions.md +1 -1
- package/adapters/cursor.fde.mdc +1 -1
- package/bin/check.js +13 -1
- package/bin/fde.js +298 -50
- package/bin/install.js +6 -0
- package/hooks/pre-compact +19 -0
- package/hooks/session-start +24 -3
- package/hooks/session-stop +23 -3
- package/package.json +1 -1
- package/skills/fde/SKILL.md +10 -9
- package/README.md.bak +0 -227
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
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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 (!
|
|
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,
|
|
306
|
-
console.log(`logged → ${
|
|
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}" -
|
|
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
|
-
|
|
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('\
|
|
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
|
-
|
|
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
|
|
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:
|
|
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,.
|
|
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
|
|
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
|
|
513
|
-
|
|
514
|
-
'.dot
|
|
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
|
|
520
|
-
'.card .risk.amber-risk{color
|
|
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
|
|
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
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>');}",
|
|
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
|
|
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
|
-
|
|
586
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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="
|
|
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
|
-
`<
|
|
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 =
|
|
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
|
-
'<
|
|
647
|
-
|
|
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
|
|
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")
|