fdeops 3.8.2 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -142,9 +142,13 @@ Deterministic, offline, zero tokens - the skill adds judgment on top:
142
142
 
143
143
  ```bash
144
144
  fde scan # day-1 recon + ASK ON DAY 1 questions (works via npx)
145
- fde resume # load this workspace's engagement
146
- fde resume --init <client> # THE setup step: create + bind an engagement
147
- fde debrief notes.md # route meeting notes into memory (also reads stdin)
145
+ fde resume # TRIAGE + load this workspace's engagement
146
+ fde resume --init <client> # THE setup step: create + bind + git-version .fde/
147
+ fde triage # TRIAGE only (session hooks / Cursor entry)
148
+ fde debrief notes.md # route prefixed meeting notes (also reads stdin)
149
+ fde debrief --smart notes.md # propose routing from messy notes → --apply to confirm
150
+ fde prep "Denise sync" # grounded walk-in brief from existing memory
151
+ fde doctor # lint: stale signals, unset phase, gaps
148
152
  fde log decision "descope agreed with Kowalczyk"
149
153
  fde log contact "Denise gone quiet" --signal amber
150
154
  fde receipts <term> # dated search; no hit = a gap in the record, not proof of absence
@@ -154,7 +158,7 @@ fde dashboard # current engagement fieldbook (add --all for
154
158
 
155
159
  Optional: `export FDEOPS_ENGAGEMENTS_ROOT=~/path/to/engagements` to isolate init/status/dashboard from the default `~/fde-engagements`.
156
160
 
157
- The latest dated `[signal:...]` token per stakeholder drives the trust column in `status` and `dashboard`; signals older than 21 days show as stale.
161
+ Each `.fde/` is a local git repo (no remote, no telemetry) — dated entries carry an author tag; every write commits so receipts are tamper-evident. Worst-of `[signal:...]` per stakeholder drives trust; signals older than 21 days show as stale.
158
162
 
159
163
  <p align="center"><img src="media/terminal-demo.svg" alt="fde CLI - status, scan, dashboard" width="720"/></p>
160
164
 
@@ -18,7 +18,15 @@ When the FDE types **`@fde`** or describes an engagement situation (new customer
18
18
 
19
19
  Read and write engagement files under the workspace's bound engagement: run `fde resume` to resolve it (binding created once with `fde resume --init <name>`; default `~/fde-engagements/<name>/.fde/`). `FDEOPS_ENGAGEMENT` (expand `~`) overrides when set. Use `./.fde/` only when the engagement approves it and it is gitignored.
20
20
 
21
- On entry, run `fde resume` (fallback `node ~/.claude/fdeops/fde.js resume`) to load `context.md`. Use the CLI for deterministic work - `fde scan | log | receipts | status | dashboard` - instead of improvising shell.
21
+ **On every session entry (before other work):** run `fde triage` (fallback `node ~/.claude/fdeops/fde.js triage`, then `fde resume`). Lead with that TRIAGE block trust, phase, open risks, next action, record owner/hash. Do not invent stakeholders or status.
22
+
23
+ Use the CLI for deterministic work - `fde scan | log | debrief | prep | doctor | receipts | status | dashboard` - instead of improvising shell.
24
+
25
+ ### Meeting → memory loop
26
+ - Messy notes: `fde debrief --smart notes.md` → review `.debrief-propose` → `fde debrief --apply`
27
+ - Prefixed notes: `fde debrief notes.md` (or pipe stdin)
28
+ - Walk-in: `fde prep "Denise sync"`
29
+ - Nothing enters the record unreviewed when using `--smart` (confirm with `--apply`)
22
30
 
23
31
  ## Voice
24
32
 
package/bin/fde.js CHANGED
@@ -12,8 +12,14 @@
12
12
  * fde resume find this workspace's engagement, print bounded context
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
+ * fde triage deterministic TRIAGE block (hooks / Cursor entry)
15
16
  * fde log <type> <text> structured append (decision|risk|delivery|contact)
16
17
  * fde debrief [file] meeting notes → structured memory (stdin if no file)
18
+ * fde debrief --smart propose routing from messy notes; --apply commits it
19
+ * fde prep [label] grounded walk-in brief from existing .fde/ only
20
+ * fde doctor deterministic memory lint (stale signals, gaps)
21
+ * fde garden [--apply] propose safe consolidations; apply only with --apply
22
+ * fde owner [set …] who keeps this engagement record
17
23
  * fde receipts <term> "what did we agree?" - search memory with dates
18
24
  * fde capture session-end snapshot → context.md (hooks use this)
19
25
  * fde status [--all] current engagement (default) or full portfolio (--all)
@@ -322,6 +328,131 @@ function rmTreeQuiet(dir) {
322
328
  try { fs.rmSync(dir, { recursive: true, force: true }) } catch (_) {}
323
329
  }
324
330
 
331
+ // ---------- versioned engagement memory (tamper-evident receipts) ----------
332
+ // Each .fde/ is its own git repo. Writes auto-commit. No new npm deps - shell git.
333
+ // Skips quietly if git is missing (warn once). Cross-process safety stays on
334
+ // withFileLock + atomic rename; this layer is history + attribution, not locking.
335
+
336
+ const OWNER_FILE = '.owner'
337
+ const DEBRIEF_PROPOSE = '.debrief-propose'
338
+ let _gitWarned = false
339
+
340
+ function gitBinOk() {
341
+ try {
342
+ execFileSync('git', ['--version'], { stdio: 'ignore', timeout: 5000 })
343
+ return true
344
+ } catch (_) { return false }
345
+ }
346
+
347
+ function readOwner(eng) {
348
+ try {
349
+ const raw = fs.readFileSync(path.join(eng, OWNER_FILE), 'utf8')
350
+ const name = (raw.match(/^name:\s*(.+)$/m) || [])[1]
351
+ const email = (raw.match(/^email:\s*(.+)$/m) || [])[1]
352
+ if (name && email) return { name: name.trim(), email: email.trim() }
353
+ } catch (_) {}
354
+ return null
355
+ }
356
+
357
+ function writeOwnerIfMissing(eng) {
358
+ if (readOwner(eng)) return readOwner(eng)
359
+ const name = sh('git config user.name') || process.env.USER || process.env.LOGNAME || 'fde'
360
+ const email = sh('git config user.email') || `${String(name).replace(/\s+/g, '.').toLowerCase()}@local`
361
+ const body = `name: ${name}\nemail: ${email}\n`
362
+ try {
363
+ withFileLock(path.join(eng, OWNER_FILE), () => {
364
+ atomicWriteFile(path.join(eng, OWNER_FILE), body)
365
+ })
366
+ } catch (_) {
367
+ try { atomicWriteFile(path.join(eng, OWNER_FILE), body) } catch (_) {}
368
+ }
369
+ return { name, email }
370
+ }
371
+
372
+ function authorBracket(eng) {
373
+ const o = writeOwnerIfMissing(eng)
374
+ const id = (o.email.includes('@') ? o.email.split('@')[0] : o.name)
375
+ .replace(/[^\w.-]/g, '')
376
+ .slice(0, 40)
377
+ return id ? `@${id}` : ''
378
+ }
379
+
380
+ function datedEntry(eng, date, text, signal) {
381
+ const who = authorBracket(eng)
382
+ const bits = [`- [${date}]`]
383
+ if (who) bits.push(`[${who}]`)
384
+ if (signal) bits.push(`[signal:${signal}]`)
385
+ bits.push(text)
386
+ return bits.join(' ')
387
+ }
388
+
389
+ function ensureMemoryGit(eng) {
390
+ if (!eng || !fs.existsSync(eng)) return false
391
+ if (fs.existsSync(path.join(eng, '.git'))) {
392
+ writeOwnerIfMissing(eng)
393
+ return true
394
+ }
395
+ if (!gitBinOk()) {
396
+ if (!_gitWarned) {
397
+ process.stderr.write('⚠ git not found - engagement memory will not be versioned (receipts stay dated, but not tamper-evident)\n')
398
+ _gitWarned = true
399
+ }
400
+ writeOwnerIfMissing(eng)
401
+ return false
402
+ }
403
+ try {
404
+ execFileSync('git', ['init'], { cwd: eng, stdio: 'ignore', timeout: 10000 })
405
+ atomicWriteFile(
406
+ path.join(eng, '.gitignore'),
407
+ ['*.lock', '*.tmp', '.last-write', '.debrief-propose', ''].join('\n')
408
+ )
409
+ writeOwnerIfMissing(eng)
410
+ commitMemory(eng, 'init engagement memory')
411
+ return true
412
+ } catch (_) {
413
+ return false
414
+ }
415
+ }
416
+
417
+ function commitMemory(eng, message) {
418
+ if (!eng || !fs.existsSync(path.join(eng, '.git'))) {
419
+ if (!ensureMemoryGit(eng)) return null
420
+ }
421
+ if (!gitBinOk()) return null
422
+ const owner = writeOwnerIfMissing(eng)
423
+ try {
424
+ execFileSync('git', ['add', '-A'], { cwd: eng, stdio: 'ignore', timeout: 10000 })
425
+ const porcelain = execFileSync('git', ['status', '--porcelain'], {
426
+ cwd: eng, encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'],
427
+ })
428
+ if (!String(porcelain || '').trim()) return null
429
+ const env = {
430
+ ...process.env,
431
+ GIT_AUTHOR_NAME: owner.name,
432
+ GIT_AUTHOR_EMAIL: owner.email,
433
+ GIT_COMMITTER_NAME: owner.name,
434
+ GIT_COMMITTER_EMAIL: owner.email,
435
+ }
436
+ execFileSync('git', ['-c', 'commit.gpgsign=false', 'commit', '-m', String(message || 'memory write').slice(0, 72)], {
437
+ cwd: eng, stdio: 'ignore', timeout: 15000, env,
438
+ })
439
+ return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
440
+ cwd: eng, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
441
+ }).toString().trim()
442
+ } catch (_) {
443
+ return null
444
+ }
445
+ }
446
+
447
+ function memoryHead(eng) {
448
+ if (!eng || !fs.existsSync(path.join(eng, '.git'))) return ''
449
+ try {
450
+ return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
451
+ cwd: eng, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
452
+ }).toString().trim()
453
+ } catch (_) { return '' }
454
+ }
455
+
325
456
  // Pull the body under a "## Heading" up to the next "##" (or EOF).
326
457
  function sectionBody(md, heading) {
327
458
  const lines = md.split('\n')
@@ -365,7 +496,8 @@ function appendUnderSection(md, heading, entry) {
365
496
  // them matched what extractStakeholders actually read). A contact entry
366
497
  // carrying a [signal:x] token - however it got there - lands inside
367
498
  // "## Signal history"; everything else is a plain end-of-file append.
368
- function appendLogEntry(eng, type, entry) {
499
+ function appendLogEntry(eng, type, entry, opts = {}) {
500
+ ensureMemoryGit(eng)
369
501
  const p = path.join(eng, LOG_FILES[type])
370
502
  if (type === 'contact' && /\[signal:(red|amber|green)\]/i.test(entry)) {
371
503
  withFileLock(p, () => {
@@ -377,6 +509,7 @@ function appendLogEntry(eng, type, entry) {
377
509
  lockedAppendFile(p, `\n${entry}\n`)
378
510
  }
379
511
  recordLastWrite(eng, LOG_FILES[type], entry)
512
+ if (!opts.skipCommit) commitMemory(eng, opts.commitMsg || `log ${type}`)
380
513
  }
381
514
 
382
515
  // phase / trust / top risk / freshness - identical heuristic for status + dashboard.
@@ -416,31 +549,88 @@ function stakeholdersMemoryHealth(eng) {
416
549
  return { ok: true, warn: '' }
417
550
  }
418
551
 
552
+ // Subject key for a signal-history line - first real name word (same spirit as
553
+ // extractStakeholders). A green about Randy must not clear an amber about Denise.
554
+ // Strip author tags [@email-local] so attribution never becomes the subject key.
555
+ function signalSubjectKey(text) {
556
+ const cleaned = String(text).replace(/\[@[^\]]+\]/g, '').replace(/\([^)]*\)/g, '')
557
+ const words = cleaned.split(/\s+/).filter(w => w && !/^(dr|mr|mrs|ms)\.?$/i.test(w))
558
+ const frag = (words[0] || '').replace(/[^a-z0-9]/gi, '').toLowerCase()
559
+ return frag.length >= 3 ? frag : ('anon:' + cleaned.slice(0, 48).toLowerCase())
560
+ }
561
+
562
+ function parsePhase(ctx) {
563
+ // Template ships "**Phase:** land | discover | ..." - that is UNSET, not land.
564
+ const m = ctx.match(/\*\*Phase:\*\*\s*(.+)/i) || ctx.match(/^phase[:\s*]+(.+)$/im)
565
+ if (!m) return '?'
566
+ const raw = m[1].replace(/\*/g, '').trim()
567
+ if (!raw || /\|/.test(raw) || /^unset$/i.test(raw) || /^[\[(]/.test(raw)) return '?'
568
+ const one = raw.toLowerCase().match(/^(land|discover|plan|build|ship|close)\b/)
569
+ return one ? one[1] : '?'
570
+ }
571
+
572
+ function countOpenRisks(eng) {
573
+ const md = readClean(eng, 'risks.md')
574
+ const body = md.split(/^#{1,6}\s+Retired\b/im)[0] || md
575
+ let n = 0
576
+ for (const raw of body.split('\n')) {
577
+ const t = raw.trim()
578
+ if (!t || t.startsWith('<!--') || /^#{1,6}\s/.test(t)) continue
579
+ if (/risk\s*\|\s*status|mitigation/i.test(t) || /^\|?[\s|:-]+$/.test(t)) continue
580
+ if (/^[-*]/.test(t) || (/^\|/.test(t) && t.length > 12)) n++
581
+ }
582
+ return n
583
+ }
584
+
585
+ function nextActionLine(ctx) {
586
+ const body = sectionBody(ctx, 'Next action')
587
+ for (const raw of body.split('\n')) {
588
+ const t = raw.trim().replace(/^[-*]\s+/, '')
589
+ if (t) return t.slice(0, 120)
590
+ }
591
+ return ''
592
+ }
593
+
419
594
  function computeSignals(eng) {
420
595
  // readClean, not readEng: status/dashboard echo topRisk and stakeholder lines
421
596
  // to the terminal and the rendered HTML - a <private> risk must never surface.
422
597
  const ctx = readClean(eng, 'context.md'); const stake = readClean(eng, 'stakeholders.md'); const risks = readClean(eng, 'risks.md')
423
598
  // Prefer structured tokens from stakeholders + CLI ledger (ledger survives wipes)
424
599
  const signalText = stake + '\n' + readClean(eng, SIGNAL_LEDGER)
425
- const phase = (ctx.match(/phase[:* ]+\**([a-z-]+)/i) || [])[1] || '?'
426
- let latest = null
600
+ const phase = parsePhase(ctx)
601
+ // Latest signal PER stakeholder, then worst-of those actives.
602
+ // Global "latest wins" let a green from person B hide a sponsor crisis on A.
603
+ const byPerson = new Map()
427
604
  for (const l of signalText.split('\n')) {
428
605
  const sm = l.match(/\[signal:(red|amber|green)\]/i)
429
606
  if (!sm) continue
430
607
  const date = (l.match(/\[(\d{4}-\d{2}-\d{2})\]/) || [])[1] || ''
431
- const text = l.replace(/^\s*-\s*/, '').replace(/\[signal:(red|amber|green)\]/i, '').replace(/\[\d{4}-\d{2}-\d{2}\]/, '').trim()
432
- if (!latest || date >= latest.date) latest = { date, sig: sm[1].toLowerCase(), text }
608
+ const text = l.replace(/^\s*-\s*/, '')
609
+ .replace(/\[signal:(red|amber|green)\]/i, '')
610
+ .replace(/\[\d{4}-\d{2}-\d{2}\]/, '')
611
+ .replace(/\[@[^\]]+\]/g, '')
612
+ .trim()
613
+ const key = signalSubjectKey(text)
614
+ const prev = byPerson.get(key)
615
+ if (!prev || date >= prev.date) byPerson.set(key, { date, sig: sm[1].toLowerCase(), text })
616
+ }
617
+ const RANK = { red: 0, amber: 1, green: 2 }
618
+ let worst = null
619
+ for (const s of byPerson.values()) {
620
+ if (!worst || RANK[s.sig] < RANK[worst.sig] || (RANK[s.sig] === RANK[worst.sig] && s.date >= worst.date)) {
621
+ worst = s
622
+ }
433
623
  }
434
624
  const mem = stakeholdersMemoryHealth(eng)
435
625
  let trust, signalAge = null, stale = false, trustReason = ''
436
- if (!mem.ok && !latest) {
626
+ if (!mem.ok && !worst) {
437
627
  trust = 'amber'
438
628
  trustReason = mem.warn
439
- } else if (latest) {
440
- trust = latest.sig === 'red' ? 'RED' : latest.sig
441
- trustReason = (latest.text || '').slice(0, 80)
442
- if (latest.date) {
443
- signalAge = Math.max(0, Math.floor((Date.now() - Date.parse(latest.date)) / 86400000))
629
+ } else if (worst) {
630
+ trust = worst.sig === 'red' ? 'RED' : worst.sig
631
+ trustReason = (worst.text || '').slice(0, 80)
632
+ if (worst.date) {
633
+ signalAge = Math.max(0, Math.floor((Date.now() - Date.parse(worst.date)) / 86400000))
444
634
  stale = signalAge > 21
445
635
  }
446
636
  } else {
@@ -455,14 +645,33 @@ function computeSignals(eng) {
455
645
  }) || '').replace(/\|/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80)
456
646
  // Prefer the trust trigger (signal / memory warn) over a random risk line when triage is not green
457
647
  const reason = (trust !== 'green' && (trustReason || mem.warn)) ? (trustReason || mem.warn) : topRisk
648
+ const openRisks = countOpenRisks(eng)
649
+ const nextAction = nextActionLine(ctx)
458
650
  let updated = 'never', ageDays = Infinity
459
651
  try {
460
652
  ageDays = Math.floor((Date.now() - fs.statSync(path.join(eng, 'context.md')).mtimeMs) / 86400000)
461
653
  updated = ageDays === 0 ? 'today' : `${ageDays}d ago`
462
654
  } catch (_) {}
463
- return { phase, trust, signalAge, stale, topRisk, reason, memoryWarn: mem.warn, updated, ageDays }
655
+ return { phase, trust, signalAge, stale, topRisk, reason, memoryWarn: mem.warn, openRisks, nextAction, updated, ageDays }
464
656
  }
465
657
 
658
+ function resumeTriage(eng) {
659
+ const s = computeSignals(eng)
660
+ const label = s.trust + (s.stale ? '?' : '')
661
+ const phase = s.phase === '?' ? 'unset' : s.phase
662
+ const lines = [
663
+ `TRIAGE [${label.padEnd(6)}] phase:${phase} updated:${s.updated} open risks:${s.openRisks}`,
664
+ ]
665
+ if (s.reason) {
666
+ const age = s.signalAge != null ? ` (${s.signalAge}d old${s.stale ? ', STALE - reconfirm' : ''})` : ''
667
+ lines.push(` trust: ${s.reason}${age}`)
668
+ }
669
+ if (s.nextAction) lines.push(` next: ${s.nextAction}`)
670
+ else lines.push(' next: (none set - add under ## Next action in context.md)')
671
+ return lines.join('\n')
672
+ }
673
+
674
+
466
675
  // ---------- dashboard content extractors (best-effort, read-only) ----------
467
676
  // The fieldbook's structured widgets (stakeholders, risks, log, stats) want
468
677
  // data shapes that .fde/ markdown does not literally carry - it is written by
@@ -568,7 +777,10 @@ function extractStakeholders(eng) {
568
777
  if (!dm) return
569
778
  const sm = dm[2].match(/\[signal:(red|amber|green)\]/i)
570
779
  if (!sm) return
571
- const text = dm[2].replace(/\[signal:(red|amber|green)\]/i, '').trim()
780
+ const text = dm[2]
781
+ .replace(/\[signal:(red|amber|green)\]/i, '')
782
+ .replace(/\[@[^\]]+\]/g, '')
783
+ .trim()
572
784
  history.push({ date: dm[1], signal: sm[1].toLowerCase(), text })
573
785
  })
574
786
 
@@ -847,6 +1059,12 @@ function cmdResume(args) {
847
1059
  // NDA surface: engagement notes must not silently leave the machine via file sync
848
1060
  const syncHit = /icloud|mobile documents|dropbox|onedrive|google drive|box sync/i.exec(ENGAGEMENTS_ROOT)
849
1061
  if (syncHit) console.log(`⚠ engagements root is inside a synced folder ("${syncHit[0]}") - client notes will leave this machine via sync. See PRIVACY.md.`)
1062
+ // Tamper-evident fieldbook: version .fde/ with git (local only, no remote).
1063
+ if (ensureMemoryGit(fdeDir)) {
1064
+ const owner = readOwner(fdeDir)
1065
+ const head = memoryHead(fdeDir)
1066
+ console.log(`memory git: ${head || 'ready'}${owner ? ` owner: ${owner.email}` : ''}`)
1067
+ }
850
1068
  return
851
1069
  }
852
1070
  if (args[0] === '--bind') {
@@ -867,7 +1085,9 @@ function cmdResume(args) {
867
1085
  console.log(`NO ENGAGEMENT for this workspace.\nexisting: ${list}\ncreate + bind one: fde resume --init <client-name>`)
868
1086
  process.exit(2)
869
1087
  }
870
- console.log(`ENGAGEMENT: ${eng}\n`)
1088
+ // Monday-morning command: triage first (trust / phase / risks / next), then memory.
1089
+ console.log(resumeTriage(eng))
1090
+ console.log(`\nENGAGEMENT: ${eng}\n`)
871
1091
  // readClean, not fs.readFileSync: this output is what an agent loads as
872
1092
  // context, so it goes through the same <private> redaction as the dashboard.
873
1093
  const ctx = readClean(eng, 'context.md')
@@ -927,7 +1147,8 @@ function cmdLogUndo() {
927
1147
  if (led != null) withFileLock(ledgerPath, () => { atomicWriteFile(ledgerPath, led.endsWith('\n') ? led : led + '\n') })
928
1148
  }
929
1149
  try { fs.unlinkSync(metaPath) } catch (_) {}
930
- console.log(`undid last write ${meta.file}`)
1150
+ const hash = commitMemory(eng, `undo ${meta.file}`)
1151
+ console.log(`undid last write → ${meta.file}${hash ? ` @${hash}` : ''}`)
931
1152
  }
932
1153
 
933
1154
  function cmdLog(args) {
@@ -945,39 +1166,87 @@ function cmdLog(args) {
945
1166
  args.splice(sigIdx, 2)
946
1167
  }
947
1168
  const type = args[0]; const text = args.slice(1).join(' ')
948
- if (!LOG_FILES[type] || !text) { console.error('usage: fde log <decision|risk|delivery|contact> <text> [--signal red|amber|green] [--force]\n fde log --undo'); process.exit(1) }
949
- if (signal && type !== 'contact') { console.error('--signal only applies to: fde log contact'); process.exit(1) }
950
1169
  const eng = resolveEngagement({ forWrite: true })
951
1170
  if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
1171
+
1172
+ // fde log phase <land|discover|plan|build|ship|close> - advances portfolio phase
1173
+ if (type === 'phase') {
1174
+ const phase = (text || '').toLowerCase().trim()
1175
+ if (!['land', 'discover', 'plan', 'build', 'ship', 'close'].includes(phase)) {
1176
+ console.error('usage: fde log phase <land|discover|plan|build|ship|close>')
1177
+ process.exit(1)
1178
+ }
1179
+ const hash = setContextPhase(eng, phase)
1180
+ console.log(`phase → ${phase}${hash ? ` @${hash}` : ''}`)
1181
+ return
1182
+ }
1183
+
1184
+ if (!LOG_FILES[type] || !text) { console.error('usage: fde log <decision|risk|delivery|contact> <text> [--signal red|amber|green] [--force]\n fde log phase <land|discover|plan|build|ship|close>\n fde log --undo'); process.exit(1) }
1185
+ if (signal && type !== 'contact') { console.error('--signal only applies to: fde log contact'); process.exit(1) }
952
1186
  const hit = findSecretHit(text)
953
1187
  if (hit && !force) { refuseSecret('log text', hit); process.exit(1) }
954
1188
  if (hit && force) console.error(`warning: logging possible ${hit} (--force)`)
955
1189
  const date = new Date().toISOString().slice(0, 10)
956
- const entry = `- [${date}] ${signal ? `[signal:${signal}] ` : ''}${text}`
1190
+ const entry = datedEntry(eng, date, text, signal || '')
957
1191
  appendLogEntry(eng, type, entry)
958
- console.log(`logged ${LOG_FILES[type]}${signal ? ` (signal:${signal})` : ''}`)
1192
+ const hash = memoryHead(eng)
1193
+ console.log(`logged → ${LOG_FILES[type]}${signal ? ` (signal:${signal})` : ''}${hash ? ` @${hash}` : ''}`)
959
1194
  }
960
1195
 
1196
+ function setContextPhase(eng, phase) {
1197
+ ensureMemoryGit(eng)
1198
+ const p = path.join(eng, 'context.md')
1199
+ let md = readEng(eng, 'context.md')
1200
+ if (!md) md = '# Engagement context\n\n'
1201
+ if (/\*\*Phase:\*\*/i.test(md)) {
1202
+ md = md.replace(/\*\*Phase:\*\*\s*.*/i, `**Phase:** ${phase}`)
1203
+ } else {
1204
+ md = md.replace(/\n*$/, `\n\n**Phase:** ${phase}\n`)
1205
+ }
1206
+ const today = new Date().toISOString().slice(0, 10)
1207
+ if (/\*\*Last updated:\*\*/i.test(md)) {
1208
+ md = md.replace(/\*\*Last updated:\*\*\s*.*/i, `**Last updated:** ${today}`)
1209
+ }
1210
+ withFileLock(p, () => { atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n') })
1211
+ return commitMemory(eng, `phase ${phase}`)
1212
+ }
1213
+
1214
+
961
1215
  // Meeting notes → structured memory. Deterministic routing, zero AI: lines that
962
1216
  // start with decision:/risk:/delivery:/contact: (case-insensitive) go to their
963
1217
  // LOG_FILES target as dated bullets; everything else lands in context.md as one
964
1218
  // dated debrief block. contact: lines may carry an inline [signal:x] token
965
1219
  // anywhere in the text - preserved verbatim so computeSignals can trust it.
966
- // Real notes arrive as markdown: "- decision: ...", "* contact: ...",
967
- // "**Decision:** ..." - strip bullet/bold dressing before matching, or the
968
- // prefix silently misses and a [signal:x] token lands in context.md, which
969
- // signal parsing never reads. Silent loss is the one failure a memory tool
970
- // cannot have. --dry-run prints the routing without writing anything.
971
- function cmdDebrief(args) {
972
- args = args.slice()
973
- const dryIdx = args.indexOf('--dry-run')
974
- const dry = dryIdx !== -1
975
- if (dry) args.splice(dryIdx, 1)
976
- let force = false
977
- const forceIdx = args.indexOf('--force')
978
- if (forceIdx !== -1) { force = true; args.splice(forceIdx, 1) }
979
- const eng = resolveEngagement({ forWrite: true })
980
- if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
1220
+ // --smart: heuristic propose from messy prose (confirm with --apply). No network.
1221
+ // --dry-run prints the routing without writing anything.
1222
+ function smartProposeText(input) {
1223
+ const out = []
1224
+ for (const raw of input.split('\n')) {
1225
+ const line = raw.trim()
1226
+ if (!line) continue
1227
+ const bare = line
1228
+ .replace(/^[-*+]\s+/, '')
1229
+ .replace(/^\*\*(decision|risk|delivery|contact):?\*\*:?\s*/i, '$1: ')
1230
+ if (/^(decision|risk|delivery|contact):\s*/i.test(bare)) {
1231
+ out.push(bare.replace(/^(decision|risk|delivery|contact):\s*/i, (m, t) => `${t.toLowerCase()}: `))
1232
+ continue
1233
+ }
1234
+ if (/\b(we (decided|agreed)|decision:|descope|agreed to|agreement was)\b/i.test(bare)) {
1235
+ out.push(`decision: ${bare}`)
1236
+ } else if (/\b(risk|blocker|concern|at risk|worried|exposure|mitigation)\b/i.test(bare)) {
1237
+ out.push(`risk: ${bare}`)
1238
+ } else if (/\b(shipped|delivered|deployed|merged PR|rolled out|went live)\b/i.test(bare)) {
1239
+ out.push(`delivery: ${bare}`)
1240
+ } else if (/\b(gone quiet|champion|resistant|unresponsive|skipped|cooling|signal:)\b/i.test(bare)) {
1241
+ out.push(`contact: ${bare}`)
1242
+ } else {
1243
+ out.push(bare)
1244
+ }
1245
+ }
1246
+ return out.join('\n') + (out.length ? '\n' : '')
1247
+ }
1248
+
1249
+ function readDebriefInput(args) {
981
1250
  let input = ''
982
1251
  if (args[0]) {
983
1252
  const notesPath = args[0].replace(/^~/, HOME)
@@ -995,36 +1264,39 @@ function cmdDebrief(args) {
995
1264
  }
996
1265
  input = buf.toString('utf8')
997
1266
  } else {
998
- try { input = fs.readFileSync(0, 'utf8') } catch (_) {} // stdin until EOF
1267
+ try { input = fs.readFileSync(0, 'utf8') } catch (_) {}
999
1268
  if (Buffer.byteLength(input, 'utf8') > DEBRIEF_MAX_BYTES) {
1000
1269
  console.error(`debrief refused: stdin is over ${DEBRIEF_MAX_BYTES} bytes. Split the notes.`)
1001
1270
  process.exit(1)
1002
1271
  }
1003
1272
  }
1273
+ return input
1274
+ }
1275
+
1276
+ function routeDebriefInput(eng, input, { dry, force }) {
1004
1277
  const d = new Date()
1005
1278
  const date = d.toISOString().slice(0, 10)
1006
1279
  const counts = { decision: 0, risk: 0, delivery: 0, contact: 0 }
1007
1280
  const ctxLines = []
1281
+ ensureMemoryGit(eng)
1008
1282
  for (const raw of input.split('\n')) {
1009
1283
  let line = raw.trim()
1010
1284
  if (!line) continue
1011
- // markdown dressing: leading bullets (-, *, +) and bold around the prefix
1012
1285
  const bare = line.replace(/^[-*+]\s+/, '').replace(/^\*\*(decision|risk|delivery|contact):?\*\*:?\s*/i, '$1: ')
1013
1286
  const m = bare.match(/^(decision|risk|delivery|contact):\s*(.+)$/i)
1014
1287
  if (m) {
1015
1288
  const type = m[1].toLowerCase()
1016
- const body = m[2]
1289
+ let body = m[2]
1290
+ const sigInline = (body.match(/\[signal:(red|amber|green)\]/i) || [])[1]
1291
+ if (sigInline) body = body.replace(/\[signal:(red|amber|green)\]/i, '').trim()
1017
1292
  const hit = findSecretHit(body)
1018
1293
  if (hit && !force) {
1019
1294
  console.error(`skipped ${type} line - looks like a ${hit}. Redact it, or re-run with --force.`)
1020
1295
  continue
1021
1296
  }
1022
- const entry = `- [${date}] ${body}`
1297
+ const entry = datedEntry(eng, date, body, type === 'contact' && sigInline ? sigInline.toLowerCase() : '')
1023
1298
  if (dry) console.log(`→ ${LOG_FILES[type]} ${entry}`)
1024
- // appendLogEntry, not a blind append: a contact: line may carry an
1025
- // inline [signal:x] token (the skill's own convention) and must land
1026
- // inside "## Signal history" the same way `fde log --signal` does.
1027
- else appendLogEntry(eng, type, entry)
1299
+ else appendLogEntry(eng, type, entry, { skipCommit: true })
1028
1300
  counts[type]++
1029
1301
  } else {
1030
1302
  if (findSecretHit(line) && !force) {
@@ -1039,6 +1311,58 @@ function cmdDebrief(args) {
1039
1311
  if (dry) ctxLines.forEach(l => console.log(`→ context.md - ${l}`))
1040
1312
  else lockedAppendFile(path.join(eng, 'context.md'), `\n## Debrief - ${stamp}\n${ctxLines.map(l => `- ${l}`).join('\n')}\n`)
1041
1313
  }
1314
+ return { counts, ctxLines, date }
1315
+ }
1316
+
1317
+ function cmdDebrief(args) {
1318
+ args = args.slice()
1319
+ const dryIdx = args.indexOf('--dry-run')
1320
+ const dry = dryIdx !== -1
1321
+ if (dry) args.splice(dryIdx, 1)
1322
+ const smartIdx = args.indexOf('--smart')
1323
+ const smart = smartIdx !== -1
1324
+ if (smart) args.splice(smartIdx, 1)
1325
+ const applyIdx = args.indexOf('--apply')
1326
+ const apply = applyIdx !== -1
1327
+ if (apply) args.splice(applyIdx, 1)
1328
+ let force = false
1329
+ const forceIdx = args.indexOf('--force')
1330
+ if (forceIdx !== -1) { force = true; args.splice(forceIdx, 1) }
1331
+
1332
+ const eng = resolveEngagement({ forWrite: true })
1333
+ if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
1334
+
1335
+ let input = ''
1336
+ if (apply && !smart && !args[0]) {
1337
+ try { input = fs.readFileSync(path.join(eng, DEBRIEF_PROPOSE), 'utf8') } catch (_) {
1338
+ console.error('nothing to apply - run: fde debrief --smart <notes.md> then fde debrief --apply')
1339
+ process.exit(1)
1340
+ }
1341
+ } else {
1342
+ input = readDebriefInput(args)
1343
+ }
1344
+
1345
+ if (smart) {
1346
+ const proposed = smartProposeText(input)
1347
+ const proposePath = path.join(eng, DEBRIEF_PROPOSE)
1348
+ withFileLock(proposePath, () => { atomicWriteFile(proposePath, proposed) })
1349
+ console.log('SMART PROPOSE (heuristic - review before apply; no new facts invented beyond line rewrites)\n')
1350
+ routeDebriefInput(eng, proposed, { dry: true, force })
1351
+ if (!apply) {
1352
+ console.log(`\nproposal saved → ${proposePath}`)
1353
+ console.log('confirm: fde debrief --apply')
1354
+ console.log('(edit the propose file first if a line mis-routed)')
1355
+ return
1356
+ }
1357
+ input = proposed
1358
+ }
1359
+
1360
+ const { counts, ctxLines } = routeDebriefInput(eng, input, { dry, force })
1361
+ if (!dry) {
1362
+ const hash = commitMemory(eng, 'debrief')
1363
+ try { fs.unlinkSync(path.join(eng, DEBRIEF_PROPOSE)) } catch (_) {}
1364
+ if (hash) console.log(`memory @${hash}`)
1365
+ }
1042
1366
  const plural = { decision: 'decisions', risk: 'risks', delivery: 'deliveries', contact: 'contacts' }
1043
1367
  const parts = Object.keys(counts).filter(t => counts[t])
1044
1368
  .map(t => `${counts[t]} ${counts[t] === 1 ? t : plural[t]}`)
@@ -1094,6 +1418,7 @@ function cmdReceipts(args) {
1094
1418
  function cmdCapture() {
1095
1419
  const eng = resolveEngagement({ forWrite: true })
1096
1420
  if (!eng) process.exit(0) // silent: capture must never break a session
1421
+ // Workspace git facts (cwd), not the engagement memory repo.
1097
1422
  const branch = sh('git branch --show-current')
1098
1423
  const lastCommit = sh("git log -1 --format='%h %s'").slice(0, 100)
1099
1424
  // porcelain lines are "XY path" - sh() trims, so parse by first whitespace
@@ -1110,7 +1435,206 @@ function cmdCapture() {
1110
1435
  if (branch) block += `- workspace: \`${branch}\` @ ${lastCommit || 'no commits yet'}\n`
1111
1436
  if (changed) block += `- uncommitted: ${changed}\n`
1112
1437
  if (updated) block += `- engagement files updated: ${updated}\n`
1113
- try { lockedAppendFile(path.join(eng, 'context.md'), block, { soft: true }) } catch (_) {}
1438
+ try {
1439
+ ensureMemoryGit(eng)
1440
+ lockedAppendFile(path.join(eng, 'context.md'), block, { soft: true })
1441
+ commitMemory(eng, 'session capture')
1442
+ } catch (_) {}
1443
+ }
1444
+
1445
+ function cmdTriage() {
1446
+ const eng = resolveEngagement()
1447
+ if (!eng) {
1448
+ console.error('no engagement - run: fde resume --init <name>')
1449
+ process.exit(2)
1450
+ }
1451
+ console.log(resumeTriage(eng))
1452
+ const owner = readOwner(eng) || writeOwnerIfMissing(eng)
1453
+ const head = memoryHead(eng)
1454
+ if (owner || head) {
1455
+ console.log(` record: ${owner ? owner.email : '?'}${head ? ` memory@${head}` : ' (unversioned)'}`)
1456
+ }
1457
+ }
1458
+
1459
+ function cmdOwner(args) {
1460
+ const eng = resolveEngagement({ forWrite: args[0] === 'set' })
1461
+ if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
1462
+ if (args[0] === 'set') {
1463
+ const email = args[1]
1464
+ if (!email || !email.includes('@')) {
1465
+ console.error('usage: fde owner set <email> [name...]')
1466
+ process.exit(1)
1467
+ }
1468
+ const name = args.slice(2).join(' ') || email.split('@')[0]
1469
+ ensureMemoryGit(eng)
1470
+ withFileLock(path.join(eng, OWNER_FILE), () => {
1471
+ atomicWriteFile(path.join(eng, OWNER_FILE), `name: ${name}\nemail: ${email}\n`)
1472
+ })
1473
+ const hash = commitMemory(eng, 'owner set')
1474
+ console.log(`owner → ${name} <${email}>${hash ? ` @${hash}` : ''}`)
1475
+ return
1476
+ }
1477
+ const o = readOwner(eng) || writeOwnerIfMissing(eng)
1478
+ console.log(`owner: ${o.name} <${o.email}>`)
1479
+ const head = memoryHead(eng)
1480
+ if (head) console.log(`memory HEAD: ${head}`)
1481
+ else console.log('memory HEAD: (unversioned - git init on next write)')
1482
+ }
1483
+
1484
+ function cmdDoctor() {
1485
+ const eng = resolveEngagement()
1486
+ if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
1487
+ const issues = []
1488
+ const s = computeSignals(eng)
1489
+ if (s.phase === '?') {
1490
+ const hasWork = /\[\d{4}-\d{2}-\d{2}\]/.test(readEng(eng, 'decisions.md') + readEng(eng, 'delivery.md'))
1491
+ if (hasWork) issues.push('phase is unset but dated work exists - run: fde log phase <land|discover|plan|build|ship|close>')
1492
+ else issues.push('phase is unset - set when you know where you are: fde log phase land')
1493
+ }
1494
+ if (s.stale) issues.push(`trust signal is STALE (${s.signalAge}d) - reconfirm with fde log contact ... --signal`)
1495
+ if (s.memoryWarn) issues.push(s.memoryWarn)
1496
+ if (!readOwner(eng)) issues.push('no .owner - run any write or: fde owner set you@firm.com')
1497
+ if (!fs.existsSync(path.join(eng, '.git'))) issues.push('memory not git-versioned - next write will init, or re-run resume --init')
1498
+ const success = readClean(eng, 'success.md')
1499
+ if (!firstLine(success, 80)) issues.push('success.md has no stated done-definition - fill before plan/build')
1500
+ if (!sectionBody(readClean(eng, 'context.md'), 'Next action')) {
1501
+ issues.push('no ## Next action in context.md - Monday morning has nothing to drive')
1502
+ }
1503
+ console.log(`FDE DOCTOR - ${engagementSlugFromPath(eng)}`)
1504
+ console.log(resumeTriage(eng))
1505
+ if (!issues.length) {
1506
+ console.log('\nOK - no structural issues (judgment still yours)')
1507
+ process.exit(0)
1508
+ }
1509
+ console.log(`\n${issues.length} issue(s):`)
1510
+ issues.forEach((i, n) => console.log(` ${n + 1}. ${i}`))
1511
+ process.exit(1)
1512
+ }
1513
+
1514
+ function cmdPrep(args) {
1515
+ const eng = resolveEngagement()
1516
+ if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
1517
+ const label = args.join(' ').trim() || 'next meeting'
1518
+ // Grounded brief: only text already in .fde/. No invention (Rowboat meeting-prep rule).
1519
+ console.log(`MEETING PREP — ${label}`)
1520
+ console.log('(grounded in local .fde/ only - if a fact is missing, it is missing)\n')
1521
+ console.log(resumeTriage(eng))
1522
+ const owner = readOwner(eng)
1523
+ const head = memoryHead(eng)
1524
+ if (owner || head) console.log(` record: ${owner ? owner.email : '?'}${head ? ` @${head}` : ''}`)
1525
+
1526
+ const people = extractStakeholders(eng).slice(0, 8)
1527
+ console.log('\nStakeholders')
1528
+ if (!people.length) console.log(' (none in table yet)')
1529
+ else people.forEach(p => console.log(` [${p.signal}] ${p.name}${p.role ? ` — ${p.role}` : ''}${p.note ? ` · ${p.note.slice(0, 60)}` : ''}`))
1530
+
1531
+ const risks = extractRisks(eng).slice(0, 5)
1532
+ console.log('\nOpen risks (from risks.md table)')
1533
+ if (!risks.length) console.log(' (none parsed)')
1534
+ else risks.forEach(r => console.log(` [${r.severity}] ${r.text.slice(0, 100)}`))
1535
+
1536
+ const success = firstLine(readClean(eng, 'success.md'), 160)
1537
+ console.log('\nSuccess looks like')
1538
+ console.log(success ? ` ${success}` : ' (success.md empty)')
1539
+
1540
+ const decisions = readClean(eng, 'decisions.md').split('\n')
1541
+ .filter(l => /^-\s*\[\d{4}-\d{2}-\d{2}\]/.test(l.trim()))
1542
+ .slice(-5)
1543
+ console.log('\nRecent decisions')
1544
+ if (!decisions.length) console.log(' (none logged)')
1545
+ else decisions.forEach(l => console.log(` ${l.trim().slice(0, 120)}`))
1546
+
1547
+ const next = nextActionLine(readClean(eng, 'context.md'))
1548
+ console.log('\nWalk in with')
1549
+ console.log(next ? ` ${next}` : ' (set ## Next action in context.md)')
1550
+ }
1551
+
1552
+ function cmdGarden(args) {
1553
+ const apply = args.includes('--apply')
1554
+ const eng = resolveEngagement({ forWrite: apply })
1555
+ if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
1556
+ // Gardener contract (from Rowboat note_curation): no new facts, no deleted substance,
1557
+ // reversible via git, confirm before apply. Mechanical only - no LLM rewrite.
1558
+ console.log('GARDEN (contract: no new facts · no deleted substance · reversible via memory git)')
1559
+ console.log(resumeTriage(eng))
1560
+ const proposals = []
1561
+ const s = computeSignals(eng)
1562
+ if (s.stale) {
1563
+ proposals.push({
1564
+ id: 'reconfirm-signal',
1565
+ kind: 'manual',
1566
+ text: `Reconfirm stale ${s.trust} signal (${s.signalAge}d): fde log contact "…" --signal`,
1567
+ })
1568
+ }
1569
+ const ctx = readEng(eng, 'context.md')
1570
+ const sessionBlocks = []
1571
+ const lines = ctx.split('\n')
1572
+ for (let i = 0; i < lines.length; i++) {
1573
+ const m = lines[i].match(/^##\s+Session end\s+-\s+(\d{4}-\d{2}-\d{2})\b/)
1574
+ if (!m) continue
1575
+ const age = Math.floor((Date.now() - Date.parse(m[1])) / 86400000)
1576
+ if (age >= 60) sessionBlocks.push({ line: i, date: m[1], age })
1577
+ }
1578
+ if (sessionBlocks.length >= 3) {
1579
+ proposals.push({
1580
+ id: 'archive-sessions',
1581
+ kind: 'apply',
1582
+ text: `Archive ${sessionBlocks.length} session-end blocks older than 60d into context-archive.md`,
1583
+ sessionBlocks,
1584
+ })
1585
+ }
1586
+ if (!proposals.length) {
1587
+ console.log('\nNothing to garden.')
1588
+ return
1589
+ }
1590
+ console.log(`\n${proposals.length} proposal(s):`)
1591
+ proposals.forEach((p, i) => console.log(` ${i + 1}. [${p.kind}] ${p.text}`))
1592
+ if (!apply) {
1593
+ console.log('\nApply mechanical items only: fde garden --apply')
1594
+ console.log('Manual items stay yours. Every apply commits to memory git.')
1595
+ return
1596
+ }
1597
+ ensureMemoryGit(eng)
1598
+ let applied = 0
1599
+ for (const p of proposals) {
1600
+ if (p.id !== 'archive-sessions') continue
1601
+ const cutDates = new Set(p.sessionBlocks.map(b => b.date))
1602
+ const keep = []
1603
+ const archive = []
1604
+ let mode = 'keep'
1605
+ let buf = []
1606
+ const flush = () => {
1607
+ if (!buf.length) return
1608
+ ;(mode === 'archive' ? archive : keep).push(...buf)
1609
+ buf = []
1610
+ }
1611
+ for (const line of lines) {
1612
+ const m = line.match(/^##\s+Session end\s+-\s+(\d{4}-\d{2}-\d{2})\b/)
1613
+ if (m) {
1614
+ flush()
1615
+ mode = cutDates.has(m[1]) ? 'archive' : 'keep'
1616
+ } else if (/^##\s+/.test(line) && mode === 'archive') {
1617
+ flush()
1618
+ mode = 'keep'
1619
+ }
1620
+ buf.push(line)
1621
+ }
1622
+ flush()
1623
+ if (!archive.length) continue
1624
+ const archPath = path.join(eng, 'context-archive.md')
1625
+ const prev = fs.existsSync(archPath) ? fs.readFileSync(archPath, 'utf8') : '# Context archive\n\n'
1626
+ withFileLock(archPath, () => {
1627
+ atomicWriteFile(archPath, prev.replace(/\n*$/, '\n\n') + archive.join('\n').trim() + '\n')
1628
+ })
1629
+ withFileLock(path.join(eng, 'context.md'), () => {
1630
+ atomicWriteFile(path.join(eng, 'context.md'), keep.join('\n').replace(/\n*$/, '\n'))
1631
+ })
1632
+ applied++
1633
+ console.log(`applied: archived ${p.sessionBlocks.length} old session-end blocks → context-archive.md`)
1634
+ }
1635
+ const hash = commitMemory(eng, 'garden')
1636
+ if (!applied) console.log('no mechanical proposals applied (manual items remain)')
1637
+ else console.log(`garden done${hash ? ` @${hash}` : ''}`)
1114
1638
  }
1115
1639
 
1116
1640
  function engagementSlugFromPath(eng) {
@@ -1146,10 +1670,10 @@ function cmdStatus(args) {
1146
1670
  // "amber?" = structured signal went stale (>21d) - reconfirm before trusting it
1147
1671
  const label = r.trust + (r.stale ? '?' : '')
1148
1672
  const sig = r.signalAge != null ? `signal ${r.signalAge}d old${r.stale ? ' (STALE - reconfirm)' : ''} ` : ''
1149
- console.log(` [${label.padEnd(6)}] ${r.name.padEnd(24)} phase:${r.phase.padEnd(10)} updated:${r.updated.padEnd(8)} ${sig}${r.reason}`)
1673
+ console.log(` [${label.padEnd(6)}] ${r.name.padEnd(24)} phase:${(r.phase === '?' ? 'unset' : r.phase).padEnd(10)} updated:${r.updated.padEnd(8)} ${sig}${r.reason}`)
1150
1674
  }
1151
1675
  if (!all) console.log('\n(current engagement only - pass --all for the full portfolio)')
1152
- 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.')
1676
+ console.log('\ntrust: worst active [signal:x] across stakeholders (latest per person) - a green from B cannot clear an amber/red on A; keyword heuristic only when none exists.')
1153
1677
  }
1154
1678
 
1155
1679
  // ---------- dashboard (deterministic markdown → one local HTML) ----------
@@ -1919,23 +2443,36 @@ function printUsage() {
1919
2443
  fde resume --full load the complete context.md (no bound)
1920
2444
  fde resume --init <name> create + bind engagement for this workspace (rebind replaces)
1921
2445
  fde resume --bind show what this workspace is bound to, and what resolves
2446
+ fde triage TRIAGE block only (hooks / Cursor session entry)
1922
2447
  fde log <type> <text> append decision|risk|delivery|contact (contact takes --signal red|amber|green; --force to allow secret-like text)
2448
+ fde log phase <phase> set engagement phase (land|discover|plan|build|ship|close)
1923
2449
  fde log --undo remove the last CLI log/debrief entry from memory
1924
- fde debrief [file] meeting notes → memory: decision:/risk:/delivery:/contact: lines route, rest → context.md (stdin if no file; --dry-run; --force)
2450
+ fde debrief [file] meeting notes → memory (prefixed lines; --dry-run; --force)
2451
+ fde debrief --smart propose routing from messy notes → review → fde debrief --apply
2452
+ fde prep [label] grounded walk-in brief from existing .fde/ only
2453
+ fde doctor lint engagement memory (stale signals, gaps)
2454
+ fde garden [--apply] propose safe consolidations (contract: no new facts; git-reversible)
2455
+ fde owner [set email] who keeps this engagement record
1925
2456
  fde receipts <term> "what did we agree?" with dates
1926
2457
  fde capture session-end memory snapshot (hooks use this)
1927
2458
  fde status [--all] current engagement status (pass --all for full portfolio)
1928
2459
  fde dashboard [--all] current engagement fieldbook (pass --all for every client)
1929
2460
  env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)
1930
- writes require a workspace bind (or FDEOPS_ENGAGEMENT) - folder-name match is read-only`)
2461
+ writes require a workspace bind (or FDEOPS_ENGAGEMENT) - folder-name match is read-only
2462
+ .fde/ is git-versioned locally for tamper-evident receipts (no remote, no telemetry)`)
1931
2463
  }
1932
2464
 
1933
2465
  const [cmd, ...args] = process.argv.slice(2)
1934
2466
  switch (cmd) {
1935
2467
  case 'scan': cmdScan(); break
1936
2468
  case 'resume': cmdResume(args); break
2469
+ case 'triage': cmdTriage(); break
1937
2470
  case 'log': cmdLog(args); break
1938
2471
  case 'debrief': cmdDebrief(args); break
2472
+ case 'prep': cmdPrep(args); break
2473
+ case 'doctor': cmdDoctor(); break
2474
+ case 'garden': cmdGarden(args); break
2475
+ case 'owner': cmdOwner(args); break
1939
2476
  case 'receipts': cmdReceipts(args); break
1940
2477
  case 'capture': cmdCapture(); break
1941
2478
  case 'status': cmdStatus(args); break
@@ -147,7 +147,39 @@ if [ -n "$BOOTSTRAP" ]; then
147
147
  CONTENT="$CONTENT$(cat "$BOOTSTRAP")\n\n"
148
148
  fi
149
149
 
150
+ # Same TRIAGE block as `fde resume` / `fde triage` - Monday morning must not
151
+ # depend on the model remembering to run a CLI command. Prefer the installed
152
+ # fde binary; fall back to the plugin/repo copy of bin/fde.js.
153
+ resolve_fde() {
154
+ if command -v fde >/dev/null 2>&1; then
155
+ printf '%s\n' "fde"
156
+ return 0
157
+ fi
158
+ for candidate in \
159
+ "${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/bin/fde.js}" \
160
+ "$(dirname "$0")/../bin/fde.js" \
161
+ "$HOME/.claude/fdeops/fde.js" \
162
+ "$HOME/.claude/plugins/fdeops/bin/fde.js"; do
163
+ if [ -n "$candidate" ] && [ -f "$candidate" ]; then
164
+ printf '%s\n' "$candidate"
165
+ return 0
166
+ fi
167
+ done
168
+ return 1
169
+ }
170
+
150
171
  if [ -n "$CONTEXT_FILE" ] && [ -f "$CONTEXT_FILE" ]; then
172
+ FDE_CMD=$(resolve_fde || true)
173
+ if [ -n "$FDE_CMD" ]; then
174
+ if [ "$FDE_CMD" = "fde" ]; then
175
+ TRIAGE=$(fde triage 2>/dev/null || true)
176
+ else
177
+ TRIAGE=$(node "$FDE_CMD" triage 2>/dev/null || true)
178
+ fi
179
+ if [ -n "$TRIAGE" ]; then
180
+ CONTENT="$CONTENT---\n$TRIAGE\n\n"
181
+ fi
182
+ fi
151
183
  REDACTED_CONTEXT=$(mktemp)
152
184
  strip_private "$CONTEXT_FILE" > "$REDACTED_CONTEXT"
153
185
  CONTENT="$CONTENT---\nEngagement context ($CONTEXT_FILE):\n$(bounded_context "$REDACTED_CONTEXT")\n"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.8.2",
3
+ "version": "3.9.0",
4
4
  "description": "Field kit for engineers embedded in client work - a real CLI (recon, memory, portfolio), one @fde skill with field judgment on top, and hooks that make it automatic. Claude Code plugin and any agent that loads skills.",
5
5
  "bin": {
6
6
  "fdeops": "bin/install.js",
@@ -4,7 +4,7 @@
4
4
 
5
5
  **Engagement:**
6
6
  **Customer:**
7
- **Phase:** land | discover | build | ship | close
7
+ **Phase:** unset
8
8
  **Last updated:**
9
9
 
10
10
  ## Current state