fdeops 3.8.3 → 3.9.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 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.
@@ -418,10 +551,12 @@ function stakeholdersMemoryHealth(eng) {
418
551
 
419
552
  // Subject key for a signal-history line - first real name word (same spirit as
420
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.
421
555
  function signalSubjectKey(text) {
422
- const words = String(text).replace(/\([^)]*\)/g, '').split(/\s+/).filter(w => w && !/^(dr|mr|mrs|ms)\.?$/i.test(w))
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))
423
558
  const frag = (words[0] || '').replace(/[^a-z0-9]/gi, '').toLowerCase()
424
- return frag.length >= 3 ? frag : ('anon:' + String(text).slice(0, 48).toLowerCase())
559
+ return frag.length >= 3 ? frag : ('anon:' + cleaned.slice(0, 48).toLowerCase())
425
560
  }
426
561
 
427
562
  function parsePhase(ctx) {
@@ -470,7 +605,11 @@ function computeSignals(eng) {
470
605
  const sm = l.match(/\[signal:(red|amber|green)\]/i)
471
606
  if (!sm) continue
472
607
  const date = (l.match(/\[(\d{4}-\d{2}-\d{2})\]/) || [])[1] || ''
473
- const text = l.replace(/^\s*-\s*/, '').replace(/\[signal:(red|amber|green)\]/i, '').replace(/\[\d{4}-\d{2}-\d{2}\]/, '').trim()
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()
474
613
  const key = signalSubjectKey(text)
475
614
  const prev = byPerson.get(key)
476
615
  if (!prev || date >= prev.date) byPerson.set(key, { date, sig: sm[1].toLowerCase(), text })
@@ -613,89 +752,126 @@ function colIndex(headers, rx) { return headers.findIndex(h => rx.test(h)) }
613
752
  // a person and a bullet that happens to name them. No token match -> keyword
614
753
  // heuristic on the stance/signal cell. No table at all -> empty, never
615
754
  // fabricated.
616
- function extractStakeholders(eng) {
755
+ function parseSignalHistoryEntries(eng) {
756
+ // Format-agnostic on token position: CLI writes "[date] [signal:x] text";
757
+ // debrief may put the token at the end. Author tags [@x] are stripped for matching.
617
758
  const md = readClean(eng, 'stakeholders.md')
618
- const table = parseMdTable(md)
619
- if (!table) return []
620
- const { headers, rows } = table
621
- const nameIdx = colIndex(headers, /name/i)
622
- if (nameIdx === -1) return []
623
- const roleIdx = colIndex(headers, /^role$/i)
624
- const stanceIdx = colIndex(headers, /stance|signal/i)
625
- const notesIdx = colIndex(headers, /notes?/i)
626
-
627
- const history = []
628
- // Format-agnostic on token position: `fde log contact --signal` writes
629
- // "[date] [signal:x] text" (token right after the date), but `fde debrief`
630
- // appends the token at the END of whatever the agent wrote per the skill's
631
- // own contact: convention - "[date] text [signal:x]". Both are subject-first
632
- // once the token is stripped, so match the token anywhere on the line rather
633
- // than requiring it immediately after the date; a debrief-written signal was
634
- // silently invisible to per-stakeholder matching before this.
635
759
  const histText = sectionBody(md, 'Signal history') + '\n' + readEng(eng, SIGNAL_LEDGER)
760
+ const history = []
636
761
  histText.split('\n').forEach(l => {
637
762
  const dm = l.trim().match(/^-\s*\[(\d{4}-\d{2}-\d{2})\]\s*(.*)$/i)
638
763
  if (!dm) return
639
764
  const sm = dm[2].match(/\[signal:(red|amber|green)\]/i)
640
765
  if (!sm) return
641
- const text = dm[2].replace(/\[signal:(red|amber|green)\]/i, '').trim()
766
+ const text = dm[2]
767
+ .replace(/\[signal:(red|amber|green)\]/i, '')
768
+ .replace(/\[@[^\]]+\]/g, '')
769
+ .trim()
642
770
  history.push({ date: dm[1], signal: sm[1].toLowerCase(), text })
643
771
  })
772
+ return history
773
+ }
644
774
 
645
- return rows.map(cs => {
646
- const name = (cs[nameIdx] || '').trim()
647
- if (!name) return null
648
- const role = roleIdx !== -1 ? (cs[roleIdx] || '').trim() : ''
649
- const stance = stanceIdx !== -1 ? (cs[stanceIdx] || '').trim() : ''
650
- const note = notesIdx !== -1 ? (cs[notesIdx] || '').trim() : ''
651
-
652
- // naive match fragment: first real word of the name, skipping honorifics,
653
- // so "Dr. Anand Mehta" matches signal-history prose on "Anand", not "Dr."
654
- const words = name.replace(/\([^)]*\)/g, '').split(/\s+/).filter(w => w && !/^(dr|mr|mrs|ms)\.?$/i.test(w))
655
- const frag = (words[0] || '').replace(/[^a-z0-9]/gi, '')
656
-
657
- // Match the SUBJECT of the entry, not anyone it mentions in passing -
658
- // "Renata declined... told Sam..." is Renata's signal, not Sam's, even
659
- // though "Sam" appears in the text. Every real signal-history line in
660
- // this codebase's own examples is written subject-first ("Denise skipped
661
- // Thursday demo", "Randy opened the sheet..."), so requiring the name at
662
- // the START of the entry (not .includes() anywhere in it) is the fix,
663
- // not a stricter rule invented for its own sake.
664
- let signal = null, matchedDate = null
665
- if (frag.length >= 3) {
666
- for (const h of history) {
667
- if (h.text.trim().toLowerCase().startsWith(frag.toLowerCase()) && (!matchedDate || h.date >= matchedDate)) {
668
- signal = h.signal; matchedDate = h.date
775
+ function displayNameFromSignalText(text) {
776
+ const t = String(text).trim()
777
+ const proper = t.match(/^([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)/)
778
+ if (proper) return proper[1]
779
+ const word = t.split(/\s+/).find(w => w && !/^(dr|mr|mrs|ms)\.?$/i.test(w))
780
+ return word ? word.replace(/[^A-Za-z0-9.-]/g, '') : t.slice(0, 24)
781
+ }
782
+
783
+ // Stakeholders for prep/dashboard: table rows PLUS people who only appear in
784
+ // Signal history / .signal-ledger (the common log-shaped path after debrief).
785
+ function extractStakeholders(eng) {
786
+ const md = readClean(eng, 'stakeholders.md')
787
+ const table = parseMdTable(md)
788
+ const history = parseSignalHistoryEntries(eng)
789
+ const byKey = new Map()
790
+
791
+ if (table) {
792
+ const { headers, rows } = table
793
+ const nameIdx = colIndex(headers, /name|who/i)
794
+ if (nameIdx !== -1) {
795
+ const roleIdx = colIndex(headers, /^role$/i)
796
+ const stanceIdx = colIndex(headers, /stance|signal/i)
797
+ const notesIdx = colIndex(headers, /notes?/i)
798
+ for (const cs of rows) {
799
+ const name = (cs[nameIdx] || '').trim()
800
+ if (!name) continue
801
+ const role = roleIdx !== -1 ? (cs[roleIdx] || '').trim() : ''
802
+ const stance = stanceIdx !== -1 ? (cs[stanceIdx] || '').trim() : ''
803
+ const note = notesIdx !== -1 ? (cs[notesIdx] || '').trim() : ''
804
+ const words = name.replace(/\([^)]*\)/g, '').split(/\s+/).filter(w => w && !/^(dr|mr|mrs|ms)\.?$/i.test(w))
805
+ const frag = (words[0] || '').replace(/[^a-z0-9]/gi, '')
806
+ let signal = null, matchedDate = null
807
+ if (frag.length >= 3) {
808
+ for (const h of history) {
809
+ if (h.text.trim().toLowerCase().startsWith(frag.toLowerCase()) && (!matchedDate || h.date >= matchedDate)) {
810
+ signal = h.signal; matchedDate = h.date
811
+ }
812
+ }
669
813
  }
814
+ if (!signal) {
815
+ const s = stance.toLowerCase()
816
+ signal = /champion|steady|\bgreen\b/.test(s) ? 'green'
817
+ : /resistant|hostile|blocker|\bred\b/.test(s) ? 'red'
818
+ : 'amber'
819
+ }
820
+ byKey.set(signalSubjectKey(name), { name, role, note, signal, source: 'table' })
670
821
  }
671
822
  }
672
- if (!signal) {
673
- const s = stance.toLowerCase()
674
- signal = /champion|steady|\bgreen\b/.test(s) ? 'green'
675
- : /resistant|hostile|blocker|\bred\b/.test(s) ? 'red'
676
- : 'amber' // neutral / cooling / warming / not met / unknown / no signal cell at all
823
+ }
824
+
825
+ // Latest signal per subject; fill gaps when the FDE never filled the table.
826
+ const latest = new Map()
827
+ for (const h of history) {
828
+ const key = signalSubjectKey(h.text)
829
+ const prev = latest.get(key)
830
+ if (!prev || h.date >= prev.date) latest.set(key, h)
831
+ }
832
+ for (const [key, h] of latest) {
833
+ if (byKey.has(key)) {
834
+ const cur = byKey.get(key)
835
+ byKey.set(key, { ...cur, signal: h.signal, note: cur.note || h.text.slice(0, 80) })
836
+ } else {
837
+ byKey.set(key, {
838
+ name: displayNameFromSignalText(h.text),
839
+ role: '',
840
+ note: h.text.slice(0, 80),
841
+ signal: h.signal,
842
+ source: 'signal',
843
+ })
677
844
  }
678
- return { name, role, note, signal }
679
- }).filter(Boolean)
845
+ }
846
+ return [...byKey.values()]
680
847
  }
681
848
 
682
- // Risks: same table parser, matched on a "Risk" column. Real files carry no
683
- // severity field, so severity is a coarse high/med keyword guess on the risk
684
- // text itself - a guess, same honesty as computeSignals()'s trust fallback,
685
- // not a claim of real triage. "## Retired" rows are prose bullets, not table
686
- // rows, so the table parser above already stops before them - they feed the
687
- // log instead (see extractLog).
849
+ // Risks: table rows AND dated CLI/debrief bullets. Empty template cells ignored.
688
850
  function extractRisks(eng) {
689
851
  const md = readClean(eng, 'risks.md')
690
- const table = parseMdTable(md)
691
- if (!table) return []
692
- const riskIdx = colIndex(table.headers, /^risk$/i)
693
- if (riskIdx === -1) return []
694
- const HIGH = /critical|blocker|exposure|breach|urgent|at risk|at stake|\brace\b/i
695
- return table.rows.map(cs => {
696
- const text = (cs[riskIdx] || '').trim()
697
- return text ? { text, severity: HIGH.test(text) ? 'high' : 'med' } : null
698
- }).filter(Boolean)
852
+ const body = md.split(/^#{1,6}\s+Retired\b/im)[0] || md
853
+ const HIGH = /critical|blocker|exposure|breach|urgent|at risk|at stake|\brace\b|rollback|no test/i
854
+ const out = []
855
+ const seen = new Set()
856
+ const push = (text) => {
857
+ const t = String(text || '').trim()
858
+ if (!t || seen.has(t.toLowerCase())) return
859
+ seen.add(t.toLowerCase())
860
+ out.push({ text: t, severity: HIGH.test(t) ? 'high' : 'med' })
861
+ }
862
+ const table = parseMdTable(body)
863
+ if (table) {
864
+ const riskIdx = colIndex(table.headers, /^risk$/i)
865
+ if (riskIdx !== -1) {
866
+ for (const cs of table.rows) push(cs[riskIdx])
867
+ }
868
+ }
869
+ for (const raw of body.split('\n')) {
870
+ const t = raw.trim()
871
+ const m = t.match(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*(?:\[@[^\]]+\]\s*)?(.*)$/)
872
+ if (m) push(m[1])
873
+ }
874
+ return out
699
875
  }
700
876
 
701
877
  // Best-effort scan for "before -> after" metric callouts in delivery/decisions
@@ -917,6 +1093,12 @@ function cmdResume(args) {
917
1093
  // NDA surface: engagement notes must not silently leave the machine via file sync
918
1094
  const syncHit = /icloud|mobile documents|dropbox|onedrive|google drive|box sync/i.exec(ENGAGEMENTS_ROOT)
919
1095
  if (syncHit) console.log(`⚠ engagements root is inside a synced folder ("${syncHit[0]}") - client notes will leave this machine via sync. See PRIVACY.md.`)
1096
+ // Tamper-evident fieldbook: version .fde/ with git (local only, no remote).
1097
+ if (ensureMemoryGit(fdeDir)) {
1098
+ const owner = readOwner(fdeDir)
1099
+ const head = memoryHead(fdeDir)
1100
+ console.log(`memory git: ${head || 'ready'}${owner ? ` owner: ${owner.email}` : ''}`)
1101
+ }
920
1102
  return
921
1103
  }
922
1104
  if (args[0] === '--bind') {
@@ -999,7 +1181,8 @@ function cmdLogUndo() {
999
1181
  if (led != null) withFileLock(ledgerPath, () => { atomicWriteFile(ledgerPath, led.endsWith('\n') ? led : led + '\n') })
1000
1182
  }
1001
1183
  try { fs.unlinkSync(metaPath) } catch (_) {}
1002
- console.log(`undid last write ${meta.file}`)
1184
+ const hash = commitMemory(eng, `undo ${meta.file}`)
1185
+ console.log(`undid last write → ${meta.file}${hash ? ` @${hash}` : ''}`)
1003
1186
  }
1004
1187
 
1005
1188
  function cmdLog(args) {
@@ -1027,8 +1210,8 @@ function cmdLog(args) {
1027
1210
  console.error('usage: fde log phase <land|discover|plan|build|ship|close>')
1028
1211
  process.exit(1)
1029
1212
  }
1030
- setContextPhase(eng, phase)
1031
- console.log(`phase → ${phase}`)
1213
+ const hash = setContextPhase(eng, phase)
1214
+ console.log(`phase → ${phase}${hash ? ` @${hash}` : ''}`)
1032
1215
  return
1033
1216
  }
1034
1217
 
@@ -1038,12 +1221,14 @@ function cmdLog(args) {
1038
1221
  if (hit && !force) { refuseSecret('log text', hit); process.exit(1) }
1039
1222
  if (hit && force) console.error(`warning: logging possible ${hit} (--force)`)
1040
1223
  const date = new Date().toISOString().slice(0, 10)
1041
- const entry = `- [${date}] ${signal ? `[signal:${signal}] ` : ''}${text}`
1224
+ const entry = datedEntry(eng, date, text, signal || '')
1042
1225
  appendLogEntry(eng, type, entry)
1043
- console.log(`logged ${LOG_FILES[type]}${signal ? ` (signal:${signal})` : ''}`)
1226
+ const hash = memoryHead(eng)
1227
+ console.log(`logged → ${LOG_FILES[type]}${signal ? ` (signal:${signal})` : ''}${hash ? ` @${hash}` : ''}`)
1044
1228
  }
1045
1229
 
1046
1230
  function setContextPhase(eng, phase) {
1231
+ ensureMemoryGit(eng)
1047
1232
  const p = path.join(eng, 'context.md')
1048
1233
  let md = readEng(eng, 'context.md')
1049
1234
  if (!md) md = '# Engagement context\n\n'
@@ -1057,6 +1242,7 @@ function setContextPhase(eng, phase) {
1057
1242
  md = md.replace(/\*\*Last updated:\*\*\s*.*/i, `**Last updated:** ${today}`)
1058
1243
  }
1059
1244
  withFileLock(p, () => { atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n') })
1245
+ return commitMemory(eng, `phase ${phase}`)
1060
1246
  }
1061
1247
 
1062
1248
 
@@ -1065,21 +1251,79 @@ function setContextPhase(eng, phase) {
1065
1251
  // LOG_FILES target as dated bullets; everything else lands in context.md as one
1066
1252
  // dated debrief block. contact: lines may carry an inline [signal:x] token
1067
1253
  // anywhere in the text - preserved verbatim so computeSignals can trust it.
1068
- // Real notes arrive as markdown: "- decision: ...", "* contact: ...",
1069
- // "**Decision:** ..." - strip bullet/bold dressing before matching, or the
1070
- // prefix silently misses and a [signal:x] token lands in context.md, which
1071
- // signal parsing never reads. Silent loss is the one failure a memory tool
1072
- // cannot have. --dry-run prints the routing without writing anything.
1073
- function cmdDebrief(args) {
1074
- args = args.slice()
1075
- const dryIdx = args.indexOf('--dry-run')
1076
- const dry = dryIdx !== -1
1077
- if (dry) args.splice(dryIdx, 1)
1078
- let force = false
1079
- const forceIdx = args.indexOf('--force')
1080
- if (forceIdx !== -1) { force = true; args.splice(forceIdx, 1) }
1081
- const eng = resolveEngagement({ forWrite: true })
1082
- if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
1254
+ // --smart: heuristic propose from messy prose (confirm with --apply). No network.
1255
+ // --dry-run prints the routing without writing anything.
1256
+ function inferContactSignal(text) {
1257
+ const t = String(text)
1258
+ if (/\b(hostile|blocker|fired|refused|walked out|\bred\b|escalat(?:ed|ion) to (?:cto|legal))\b/i.test(t)) return 'red'
1259
+ if (/\b(gone quiet|unresponsive|skipped|cooling|seemed cold|no-show|missed the|amber)\b/i.test(t)) return 'amber'
1260
+ if (/\b(champion|helping|opened the|warming|supportive|on board|\bgreen\b|saw demo)\b/i.test(t)) return 'green'
1261
+ return ''
1262
+ }
1263
+
1264
+ function looksLikePersonLine(text) {
1265
+ // "Denise …" / "Randy opened…" — capitalized subject + field verb.
1266
+ return /^[A-Z][a-z]{1,20}\b/.test(text) &&
1267
+ /\b(helping|quiet|skipped|said|will|opened|resistant|champion|warm|cold|unresponsive|demo|sheet|slack)\b/i.test(text)
1268
+ }
1269
+
1270
+ function smartProposeText(input) {
1271
+ const out = []
1272
+ for (const raw of input.split('\n')) {
1273
+ const line = raw.trim()
1274
+ if (!line) continue
1275
+ let bare = line
1276
+ .replace(/^[-*+]\s+/, '')
1277
+ .replace(/^\*\*(decision|risk|delivery|contact|next):?\*\*:?\s*/i, '$1: ')
1278
+ if (/^(decision|risk|delivery|contact|next):\s*/i.test(bare)) {
1279
+ let routed = bare.replace(/^(decision|risk|delivery|contact|next):\s*/i, (m, t) => `${t.toLowerCase()}: `)
1280
+ if (/^contact:/i.test(routed) && !/\[signal:(red|amber|green)\]/i.test(routed)) {
1281
+ const sig = inferContactSignal(routed)
1282
+ if (sig) routed = routed.replace(/\s*$/, ` [signal:${sig}]`)
1283
+ }
1284
+ out.push(routed)
1285
+ continue
1286
+ }
1287
+ if (/^(next action|follow-?ups?|action items?|todo):\s*/i.test(bare) ||
1288
+ /\b(next action|walk in with|follow up with)\b/i.test(bare)) {
1289
+ const next = bare.replace(/^(next action|follow-?ups?|action items?|todo):\s*/i, '').trim()
1290
+ out.push(`next: ${next}`)
1291
+ continue
1292
+ }
1293
+ if (/\b(we (decided|agreed)|decision:|descope|agreed to|agreement was|freeze scope)\b/i.test(bare)) {
1294
+ out.push(`decision: ${bare}`)
1295
+ } else if (/\b(open question|who signs|unclear who|unresolved)\b/i.test(bare)) {
1296
+ out.push(`risk: ${bare}`)
1297
+ } else if (/\b(risk|blocker|concern|at risk|worried|exposure|mitigation|no tested|no rollback)\b/i.test(bare)) {
1298
+ out.push(`risk: ${bare}`)
1299
+ } else if (/\b(shipped|delivered|deployed|merged PR|rolled out|went live)\b/i.test(bare)) {
1300
+ out.push(`delivery: ${bare}`)
1301
+ } else if (looksLikePersonLine(bare) ||
1302
+ /\b(gone quiet|champion|resistant|unresponsive|skipped|cooling|signal:)\b/i.test(bare)) {
1303
+ const sig = inferContactSignal(bare)
1304
+ out.push(sig ? `contact: ${bare} [signal:${sig}]` : `contact: ${bare}`)
1305
+ } else {
1306
+ out.push(bare)
1307
+ }
1308
+ }
1309
+ return out.join('\n') + (out.length ? '\n' : '')
1310
+ }
1311
+
1312
+ function setNextAction(eng, text) {
1313
+ ensureMemoryGit(eng)
1314
+ const bullet = `- ${String(text).replace(/^[-*]\s+/, '').trim()}`
1315
+ const p = path.join(eng, 'context.md')
1316
+ let md = readEng(eng, 'context.md')
1317
+ if (!md) md = '# Engagement context\n\n'
1318
+ if (/^##\s+Next action\b/im.test(md)) {
1319
+ md = md.replace(/(^##\s+Next action\b[^\n]*\n)([\s\S]*?)(?=^##\s|\s*$)/im, `$1\n${bullet}\n\n`)
1320
+ } else {
1321
+ md = md.replace(/\n*$/, `\n\n## Next action\n\n${bullet}\n`)
1322
+ }
1323
+ withFileLock(p, () => { atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n') })
1324
+ }
1325
+
1326
+ function readDebriefInput(args) {
1083
1327
  let input = ''
1084
1328
  if (args[0]) {
1085
1329
  const notesPath = args[0].replace(/^~/, HOME)
@@ -1097,36 +1341,46 @@ function cmdDebrief(args) {
1097
1341
  }
1098
1342
  input = buf.toString('utf8')
1099
1343
  } else {
1100
- try { input = fs.readFileSync(0, 'utf8') } catch (_) {} // stdin until EOF
1344
+ try { input = fs.readFileSync(0, 'utf8') } catch (_) {}
1101
1345
  if (Buffer.byteLength(input, 'utf8') > DEBRIEF_MAX_BYTES) {
1102
1346
  console.error(`debrief refused: stdin is over ${DEBRIEF_MAX_BYTES} bytes. Split the notes.`)
1103
1347
  process.exit(1)
1104
1348
  }
1105
1349
  }
1350
+ return input
1351
+ }
1352
+
1353
+ function routeDebriefInput(eng, input, { dry, force }) {
1106
1354
  const d = new Date()
1107
1355
  const date = d.toISOString().slice(0, 10)
1108
- const counts = { decision: 0, risk: 0, delivery: 0, contact: 0 }
1356
+ const counts = { decision: 0, risk: 0, delivery: 0, contact: 0, next: 0 }
1109
1357
  const ctxLines = []
1358
+ let nextAction = ''
1359
+ ensureMemoryGit(eng)
1110
1360
  for (const raw of input.split('\n')) {
1111
1361
  let line = raw.trim()
1112
1362
  if (!line) continue
1113
- // markdown dressing: leading bullets (-, *, +) and bold around the prefix
1114
- const bare = line.replace(/^[-*+]\s+/, '').replace(/^\*\*(decision|risk|delivery|contact):?\*\*:?\s*/i, '$1: ')
1115
- const m = bare.match(/^(decision|risk|delivery|contact):\s*(.+)$/i)
1363
+ const bare = line.replace(/^[-*+]\s+/, '').replace(/^\*\*(decision|risk|delivery|contact|next):?\*\*:?\s*/i, '$1: ')
1364
+ const m = bare.match(/^(decision|risk|delivery|contact|next):\s*(.+)$/i)
1116
1365
  if (m) {
1117
1366
  const type = m[1].toLowerCase()
1118
- const body = m[2]
1367
+ let body = m[2]
1368
+ if (type === 'next') {
1369
+ if (dry) console.log(`→ context.md ## Next action - ${body}`)
1370
+ else nextAction = body
1371
+ counts.next++
1372
+ continue
1373
+ }
1374
+ const sigInline = (body.match(/\[signal:(red|amber|green)\]/i) || [])[1]
1375
+ if (sigInline) body = body.replace(/\[signal:(red|amber|green)\]/i, '').trim()
1119
1376
  const hit = findSecretHit(body)
1120
1377
  if (hit && !force) {
1121
1378
  console.error(`skipped ${type} line - looks like a ${hit}. Redact it, or re-run with --force.`)
1122
1379
  continue
1123
1380
  }
1124
- const entry = `- [${date}] ${body}`
1381
+ const entry = datedEntry(eng, date, body, type === 'contact' && sigInline ? sigInline.toLowerCase() : '')
1125
1382
  if (dry) console.log(`→ ${LOG_FILES[type]} ${entry}`)
1126
- // appendLogEntry, not a blind append: a contact: line may carry an
1127
- // inline [signal:x] token (the skill's own convention) and must land
1128
- // inside "## Signal history" the same way `fde log --signal` does.
1129
- else appendLogEntry(eng, type, entry)
1383
+ else appendLogEntry(eng, type, entry, { skipCommit: true })
1130
1384
  counts[type]++
1131
1385
  } else {
1132
1386
  if (findSecretHit(line) && !force) {
@@ -1136,14 +1390,69 @@ function cmdDebrief(args) {
1136
1390
  ctxLines.push(line)
1137
1391
  }
1138
1392
  }
1393
+ if (nextAction && !dry) setNextAction(eng, nextAction)
1139
1394
  if (ctxLines.length) {
1140
1395
  const stamp = `${date} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
1141
1396
  if (dry) ctxLines.forEach(l => console.log(`→ context.md - ${l}`))
1142
1397
  else lockedAppendFile(path.join(eng, 'context.md'), `\n## Debrief - ${stamp}\n${ctxLines.map(l => `- ${l}`).join('\n')}\n`)
1143
1398
  }
1144
- const plural = { decision: 'decisions', risk: 'risks', delivery: 'deliveries', contact: 'contacts' }
1399
+ return { counts, ctxLines, date, nextAction }
1400
+ }
1401
+
1402
+ function cmdDebrief(args) {
1403
+ args = args.slice()
1404
+ const dryIdx = args.indexOf('--dry-run')
1405
+ const dry = dryIdx !== -1
1406
+ if (dry) args.splice(dryIdx, 1)
1407
+ const smartIdx = args.indexOf('--smart')
1408
+ const smart = smartIdx !== -1
1409
+ if (smart) args.splice(smartIdx, 1)
1410
+ const applyIdx = args.indexOf('--apply')
1411
+ const apply = applyIdx !== -1
1412
+ if (apply) args.splice(applyIdx, 1)
1413
+ let force = false
1414
+ const forceIdx = args.indexOf('--force')
1415
+ if (forceIdx !== -1) { force = true; args.splice(forceIdx, 1) }
1416
+
1417
+ const eng = resolveEngagement({ forWrite: true })
1418
+ if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
1419
+
1420
+ let input = ''
1421
+ if (apply && !smart && !args[0]) {
1422
+ try { input = fs.readFileSync(path.join(eng, DEBRIEF_PROPOSE), 'utf8') } catch (_) {
1423
+ console.error('nothing to apply - run: fde debrief --smart <notes.md> then fde debrief --apply')
1424
+ process.exit(1)
1425
+ }
1426
+ } else {
1427
+ input = readDebriefInput(args)
1428
+ }
1429
+
1430
+ if (smart) {
1431
+ const proposed = smartProposeText(input)
1432
+ const proposePath = path.join(eng, DEBRIEF_PROPOSE)
1433
+ withFileLock(proposePath, () => { atomicWriteFile(proposePath, proposed) })
1434
+ console.log('SMART PROPOSE (heuristic - review before apply; no new facts invented beyond line rewrites)\n')
1435
+ routeDebriefInput(eng, proposed, { dry: true, force })
1436
+ if (!apply) {
1437
+ console.log(`\nproposal saved → ${proposePath}`)
1438
+ console.log('confirm: fde debrief --apply')
1439
+ console.log('(edit the propose file first if a line mis-routed)')
1440
+ return
1441
+ }
1442
+ input = proposed
1443
+ }
1444
+
1445
+ const { counts, ctxLines } = routeDebriefInput(eng, input, { dry, force })
1446
+ if (!dry) {
1447
+ const hash = commitMemory(eng, 'debrief')
1448
+ try { fs.unlinkSync(path.join(eng, DEBRIEF_PROPOSE)) } catch (_) {}
1449
+ if (hash) console.log(`memory @${hash}`)
1450
+ }
1451
+ const plural = {
1452
+ decision: 'decisions', risk: 'risks', delivery: 'deliveries', contact: 'contacts', next: 'next actions',
1453
+ }
1145
1454
  const parts = Object.keys(counts).filter(t => counts[t])
1146
- .map(t => `${counts[t]} ${counts[t] === 1 ? t : plural[t]}`)
1455
+ .map(t => `${counts[t]} ${counts[t] === 1 ? (t === 'next' ? 'next action' : t) : plural[t]}`)
1147
1456
  if (ctxLines.length) parts.push(`${ctxLines.length} context line${ctxLines.length === 1 ? '' : 's'}`)
1148
1457
  const verb = dry ? 'debrief would route' : 'debrief routed'
1149
1458
  console.log(parts.length ? `${verb} → ${parts.join(', ')}` : 'debrief empty - nothing routed')
@@ -1196,6 +1505,7 @@ function cmdReceipts(args) {
1196
1505
  function cmdCapture() {
1197
1506
  const eng = resolveEngagement({ forWrite: true })
1198
1507
  if (!eng) process.exit(0) // silent: capture must never break a session
1508
+ // Workspace git facts (cwd), not the engagement memory repo.
1199
1509
  const branch = sh('git branch --show-current')
1200
1510
  const lastCommit = sh("git log -1 --format='%h %s'").slice(0, 100)
1201
1511
  // porcelain lines are "XY path" - sh() trims, so parse by first whitespace
@@ -1212,7 +1522,206 @@ function cmdCapture() {
1212
1522
  if (branch) block += `- workspace: \`${branch}\` @ ${lastCommit || 'no commits yet'}\n`
1213
1523
  if (changed) block += `- uncommitted: ${changed}\n`
1214
1524
  if (updated) block += `- engagement files updated: ${updated}\n`
1215
- try { lockedAppendFile(path.join(eng, 'context.md'), block, { soft: true }) } catch (_) {}
1525
+ try {
1526
+ ensureMemoryGit(eng)
1527
+ lockedAppendFile(path.join(eng, 'context.md'), block, { soft: true })
1528
+ commitMemory(eng, 'session capture')
1529
+ } catch (_) {}
1530
+ }
1531
+
1532
+ function cmdTriage() {
1533
+ const eng = resolveEngagement()
1534
+ if (!eng) {
1535
+ console.error('no engagement - run: fde resume --init <name>')
1536
+ process.exit(2)
1537
+ }
1538
+ console.log(resumeTriage(eng))
1539
+ const owner = readOwner(eng) || writeOwnerIfMissing(eng)
1540
+ const head = memoryHead(eng)
1541
+ if (owner || head) {
1542
+ console.log(` record: ${owner ? owner.email : '?'}${head ? ` memory@${head}` : ' (unversioned)'}`)
1543
+ }
1544
+ }
1545
+
1546
+ function cmdOwner(args) {
1547
+ const eng = resolveEngagement({ forWrite: args[0] === 'set' })
1548
+ if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
1549
+ if (args[0] === 'set') {
1550
+ const email = args[1]
1551
+ if (!email || !email.includes('@')) {
1552
+ console.error('usage: fde owner set <email> [name...]')
1553
+ process.exit(1)
1554
+ }
1555
+ const name = args.slice(2).join(' ') || email.split('@')[0]
1556
+ ensureMemoryGit(eng)
1557
+ withFileLock(path.join(eng, OWNER_FILE), () => {
1558
+ atomicWriteFile(path.join(eng, OWNER_FILE), `name: ${name}\nemail: ${email}\n`)
1559
+ })
1560
+ const hash = commitMemory(eng, 'owner set')
1561
+ console.log(`owner → ${name} <${email}>${hash ? ` @${hash}` : ''}`)
1562
+ return
1563
+ }
1564
+ const o = readOwner(eng) || writeOwnerIfMissing(eng)
1565
+ console.log(`owner: ${o.name} <${o.email}>`)
1566
+ const head = memoryHead(eng)
1567
+ if (head) console.log(`memory HEAD: ${head}`)
1568
+ else console.log('memory HEAD: (unversioned - git init on next write)')
1569
+ }
1570
+
1571
+ function cmdDoctor() {
1572
+ const eng = resolveEngagement()
1573
+ if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
1574
+ const issues = []
1575
+ const s = computeSignals(eng)
1576
+ if (s.phase === '?') {
1577
+ const hasWork = /\[\d{4}-\d{2}-\d{2}\]/.test(readEng(eng, 'decisions.md') + readEng(eng, 'delivery.md'))
1578
+ if (hasWork) issues.push('phase is unset but dated work exists - run: fde log phase <land|discover|plan|build|ship|close>')
1579
+ else issues.push('phase is unset - set when you know where you are: fde log phase land')
1580
+ }
1581
+ if (s.stale) issues.push(`trust signal is STALE (${s.signalAge}d) - reconfirm with fde log contact ... --signal`)
1582
+ if (s.memoryWarn) issues.push(s.memoryWarn)
1583
+ if (!readOwner(eng)) issues.push('no .owner - run any write or: fde owner set you@firm.com')
1584
+ if (!fs.existsSync(path.join(eng, '.git'))) issues.push('memory not git-versioned - next write will init, or re-run resume --init')
1585
+ const success = readClean(eng, 'success.md')
1586
+ if (!firstLine(success, 80)) issues.push('success.md has no stated done-definition - fill before plan/build')
1587
+ if (!sectionBody(readClean(eng, 'context.md'), 'Next action')) {
1588
+ issues.push('no ## Next action in context.md - Monday morning has nothing to drive')
1589
+ }
1590
+ console.log(`FDE DOCTOR - ${engagementSlugFromPath(eng)}`)
1591
+ console.log(resumeTriage(eng))
1592
+ if (!issues.length) {
1593
+ console.log('\nOK - no structural issues (judgment still yours)')
1594
+ process.exit(0)
1595
+ }
1596
+ console.log(`\n${issues.length} issue(s):`)
1597
+ issues.forEach((i, n) => console.log(` ${n + 1}. ${i}`))
1598
+ process.exit(1)
1599
+ }
1600
+
1601
+ function cmdPrep(args) {
1602
+ const eng = resolveEngagement()
1603
+ if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
1604
+ const label = args.join(' ').trim() || 'next meeting'
1605
+ // Grounded brief: only text already in .fde/. No invention (Rowboat meeting-prep rule).
1606
+ console.log(`MEETING PREP — ${label}`)
1607
+ console.log('(grounded in local .fde/ only - if a fact is missing, it is missing)\n')
1608
+ console.log(resumeTriage(eng))
1609
+ const owner = readOwner(eng)
1610
+ const head = memoryHead(eng)
1611
+ if (owner || head) console.log(` record: ${owner ? owner.email : '?'}${head ? ` @${head}` : ''}`)
1612
+
1613
+ const people = extractStakeholders(eng).slice(0, 8)
1614
+ console.log('\nStakeholders (table + signal history)')
1615
+ if (!people.length) console.log(' (none yet - log contacts with --signal)')
1616
+ else people.forEach(p => console.log(` [${p.signal}] ${p.name}${p.role ? ` — ${p.role}` : ''}${p.note ? ` · ${p.note.slice(0, 60)}` : ''}`))
1617
+
1618
+ const risks = extractRisks(eng).slice(0, 5)
1619
+ console.log('\nOpen risks (table + dated bullets)')
1620
+ if (!risks.length) console.log(' (none logged)')
1621
+ else risks.forEach(r => console.log(` [${r.severity}] ${r.text.slice(0, 100)}`))
1622
+
1623
+ const success = firstLine(readClean(eng, 'success.md'), 160)
1624
+ console.log('\nSuccess looks like')
1625
+ console.log(success ? ` ${success}` : ' (success.md empty)')
1626
+
1627
+ const decisions = readClean(eng, 'decisions.md').split('\n')
1628
+ .filter(l => /^-\s*\[\d{4}-\d{2}-\d{2}\]/.test(l.trim()))
1629
+ .slice(-5)
1630
+ console.log('\nRecent decisions')
1631
+ if (!decisions.length) console.log(' (none logged)')
1632
+ else decisions.forEach(l => console.log(` ${l.trim().slice(0, 120)}`))
1633
+
1634
+ const next = nextActionLine(readClean(eng, 'context.md'))
1635
+ console.log('\nWalk in with')
1636
+ console.log(next ? ` ${next}` : ' (set ## Next action in context.md)')
1637
+ }
1638
+
1639
+ function cmdGarden(args) {
1640
+ const apply = args.includes('--apply')
1641
+ const eng = resolveEngagement({ forWrite: apply })
1642
+ if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
1643
+ // Gardener contract (from Rowboat note_curation): no new facts, no deleted substance,
1644
+ // reversible via git, confirm before apply. Mechanical only - no LLM rewrite.
1645
+ console.log('GARDEN (contract: no new facts · no deleted substance · reversible via memory git)')
1646
+ console.log(resumeTriage(eng))
1647
+ const proposals = []
1648
+ const s = computeSignals(eng)
1649
+ if (s.stale) {
1650
+ proposals.push({
1651
+ id: 'reconfirm-signal',
1652
+ kind: 'manual',
1653
+ text: `Reconfirm stale ${s.trust} signal (${s.signalAge}d): fde log contact "…" --signal`,
1654
+ })
1655
+ }
1656
+ const ctx = readEng(eng, 'context.md')
1657
+ const sessionBlocks = []
1658
+ const lines = ctx.split('\n')
1659
+ for (let i = 0; i < lines.length; i++) {
1660
+ const m = lines[i].match(/^##\s+Session end\s+-\s+(\d{4}-\d{2}-\d{2})\b/)
1661
+ if (!m) continue
1662
+ const age = Math.floor((Date.now() - Date.parse(m[1])) / 86400000)
1663
+ if (age >= 60) sessionBlocks.push({ line: i, date: m[1], age })
1664
+ }
1665
+ if (sessionBlocks.length >= 3) {
1666
+ proposals.push({
1667
+ id: 'archive-sessions',
1668
+ kind: 'apply',
1669
+ text: `Archive ${sessionBlocks.length} session-end blocks older than 60d into context-archive.md`,
1670
+ sessionBlocks,
1671
+ })
1672
+ }
1673
+ if (!proposals.length) {
1674
+ console.log('\nNothing to garden.')
1675
+ return
1676
+ }
1677
+ console.log(`\n${proposals.length} proposal(s):`)
1678
+ proposals.forEach((p, i) => console.log(` ${i + 1}. [${p.kind}] ${p.text}`))
1679
+ if (!apply) {
1680
+ console.log('\nApply mechanical items only: fde garden --apply')
1681
+ console.log('Manual items stay yours. Every apply commits to memory git.')
1682
+ return
1683
+ }
1684
+ ensureMemoryGit(eng)
1685
+ let applied = 0
1686
+ for (const p of proposals) {
1687
+ if (p.id !== 'archive-sessions') continue
1688
+ const cutDates = new Set(p.sessionBlocks.map(b => b.date))
1689
+ const keep = []
1690
+ const archive = []
1691
+ let mode = 'keep'
1692
+ let buf = []
1693
+ const flush = () => {
1694
+ if (!buf.length) return
1695
+ ;(mode === 'archive' ? archive : keep).push(...buf)
1696
+ buf = []
1697
+ }
1698
+ for (const line of lines) {
1699
+ const m = line.match(/^##\s+Session end\s+-\s+(\d{4}-\d{2}-\d{2})\b/)
1700
+ if (m) {
1701
+ flush()
1702
+ mode = cutDates.has(m[1]) ? 'archive' : 'keep'
1703
+ } else if (/^##\s+/.test(line) && mode === 'archive') {
1704
+ flush()
1705
+ mode = 'keep'
1706
+ }
1707
+ buf.push(line)
1708
+ }
1709
+ flush()
1710
+ if (!archive.length) continue
1711
+ const archPath = path.join(eng, 'context-archive.md')
1712
+ const prev = fs.existsSync(archPath) ? fs.readFileSync(archPath, 'utf8') : '# Context archive\n\n'
1713
+ withFileLock(archPath, () => {
1714
+ atomicWriteFile(archPath, prev.replace(/\n*$/, '\n\n') + archive.join('\n').trim() + '\n')
1715
+ })
1716
+ withFileLock(path.join(eng, 'context.md'), () => {
1717
+ atomicWriteFile(path.join(eng, 'context.md'), keep.join('\n').replace(/\n*$/, '\n'))
1718
+ })
1719
+ applied++
1720
+ console.log(`applied: archived ${p.sessionBlocks.length} old session-end blocks → context-archive.md`)
1721
+ }
1722
+ const hash = commitMemory(eng, 'garden')
1723
+ if (!applied) console.log('no mechanical proposals applied (manual items remain)')
1724
+ else console.log(`garden done${hash ? ` @${hash}` : ''}`)
1216
1725
  }
1217
1726
 
1218
1727
  function engagementSlugFromPath(eng) {
@@ -2021,24 +2530,36 @@ function printUsage() {
2021
2530
  fde resume --full load the complete context.md (no bound)
2022
2531
  fde resume --init <name> create + bind engagement for this workspace (rebind replaces)
2023
2532
  fde resume --bind show what this workspace is bound to, and what resolves
2533
+ fde triage TRIAGE block only (hooks / Cursor session entry)
2024
2534
  fde log <type> <text> append decision|risk|delivery|contact (contact takes --signal red|amber|green; --force to allow secret-like text)
2025
2535
  fde log phase <phase> set engagement phase (land|discover|plan|build|ship|close)
2026
2536
  fde log --undo remove the last CLI log/debrief entry from memory
2027
- fde debrief [file] meeting notes → memory: decision:/risk:/delivery:/contact: lines route, rest → context.md (stdin if no file; --dry-run; --force)
2537
+ fde debrief [file] meeting notes → memory (prefixed lines; --dry-run; --force)
2538
+ fde debrief --smart propose routing from messy notes → review → fde debrief --apply
2539
+ fde prep [label] grounded walk-in brief from existing .fde/ only
2540
+ fde doctor lint engagement memory (stale signals, gaps)
2541
+ fde garden [--apply] propose safe consolidations (contract: no new facts; git-reversible)
2542
+ fde owner [set email] who keeps this engagement record
2028
2543
  fde receipts <term> "what did we agree?" with dates
2029
2544
  fde capture session-end memory snapshot (hooks use this)
2030
2545
  fde status [--all] current engagement status (pass --all for full portfolio)
2031
2546
  fde dashboard [--all] current engagement fieldbook (pass --all for every client)
2032
2547
  env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)
2033
- writes require a workspace bind (or FDEOPS_ENGAGEMENT) - folder-name match is read-only`)
2548
+ writes require a workspace bind (or FDEOPS_ENGAGEMENT) - folder-name match is read-only
2549
+ .fde/ is git-versioned locally for tamper-evident receipts (no remote, no telemetry)`)
2034
2550
  }
2035
2551
 
2036
2552
  const [cmd, ...args] = process.argv.slice(2)
2037
2553
  switch (cmd) {
2038
2554
  case 'scan': cmdScan(); break
2039
2555
  case 'resume': cmdResume(args); break
2556
+ case 'triage': cmdTriage(); break
2040
2557
  case 'log': cmdLog(args); break
2041
2558
  case 'debrief': cmdDebrief(args); break
2559
+ case 'prep': cmdPrep(args); break
2560
+ case 'doctor': cmdDoctor(); break
2561
+ case 'garden': cmdGarden(args); break
2562
+ case 'owner': cmdOwner(args); break
2042
2563
  case 'receipts': cmdReceipts(args); break
2043
2564
  case 'capture': cmdCapture(); break
2044
2565
  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.3",
3
+ "version": "3.9.1",
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",