fdeops 3.23.0 → 3.26.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/bin/fde.js CHANGED
@@ -25,7 +25,7 @@
25
25
  * fde capture session-end snapshot → context.md (hooks use this)
26
26
  * fde preserve pre-compaction context snapshot (hook-internal; hooks use this)
27
27
  * fde status [--all] value ledger first, then trust (pass --all for portfolio)
28
- * fde dashboard [--all] current engagement fieldbook (default) or all (--all)
28
+ * fde dashboard [--all] [--open] [--out <path>] bound fieldbook, or all (--all)
29
29
  * fde vault derived Obsidian vault of the fieldbook (disposable; --redacted)
30
30
  */
31
31
  const fs = require('fs')
@@ -35,6 +35,7 @@ const { execSync, execFileSync } = require('child_process')
35
35
  const { createMemoryApi } = require('./lib/memory')
36
36
  const { createTrustApi } = require('./lib/trust')
37
37
  const vault = require('./lib/vault')
38
+ const context = require('./lib/context')
38
39
 
39
40
  const HOME = os.homedir()
40
41
  // FDEOPS_ENGAGEMENTS_ROOT isolates init/status/dashboard (and the registry) for
@@ -799,7 +800,7 @@ function firstLine(md, maxLen) {
799
800
  // not carry Working theory / Evidence / Differs from brief, do not scrape a
800
801
  // first line that might be the inherited brief and label it truth.
801
802
  function parseReality(md, maxLen) {
802
- const theory = (md.match(/\*\*Working theory:\*\*\s*(.*)/i) || [])[1]
803
+ const theory = (md.match(/\*\*Working theory:\*\*[^\S\r\n]*(.*)/i) || [])[1]
803
804
  const hasSchema = /\*\*(Working theory|Evidence|Differs from brief how):\*\*/i.test(md)
804
805
  const theoryText = (theory || '').trim()
805
806
  if (theoryText) {
@@ -833,46 +834,49 @@ function valueLedgerRowCount(body) {
833
834
  function appendValueLedgerRow(eng, cells) {
834
835
  ensureMemoryGit(eng)
835
836
  const p = path.join(eng, 'delivery.md')
836
- let md = readEng(eng, 'delivery.md')
837
- if (!md) md = '# Delivery log\n\n## Value ledger\n\n'
838
- const date = new Date().toISOString().slice(0, 10)
839
- const cols = []
840
- for (let i = 0; i < 7; i++) cols.push((cells[i] || '').replace(/\|/g, '\\|').trim() || ' ')
841
- const row = `| ${date} | ${cols.join(' | ')} |`
842
- // Write into the same section the readers take: the last filled ## Value ledger.
843
- // A row appended to the empty template heading above a filled one is a row no
844
- // gate can see.
845
- const lines = md.split('\n')
846
- const sections = []
847
- let cur = null
848
- for (let i = 0; i < lines.length; i++) {
849
- if (/^##\s+Value ledger\b/i.test(lines[i])) {
850
- cur = { heading: i, lastTable: -1, filled: false, body: [] }
851
- sections.push(cur)
852
- continue
837
+ let row
838
+ withFileLock(p, () => {
839
+ let md = readEng(eng, 'delivery.md')
840
+ if (!md) md = '# Delivery log\n\n## Value ledger\n\n'
841
+ const date = new Date().toISOString().slice(0, 10)
842
+ const cols = []
843
+ for (let i = 0; i < 7; i++) cols.push((cells[i] || '').replace(/\|/g, '\\|').trim() || ' ')
844
+ row = `| ${date} | ${cols.join(' | ')} |`
845
+ // Write into the same section the readers take: the last filled ## Value ledger.
846
+ // A row appended to the empty template heading above a filled one is a row no
847
+ // gate can see.
848
+ const lines = md.split('\n')
849
+ const sections = []
850
+ let cur = null
851
+ for (let i = 0; i < lines.length; i++) {
852
+ if (/^##\s+Value ledger\b/i.test(lines[i])) {
853
+ cur = { heading: i, lastTable: -1, filled: false, body: [] }
854
+ sections.push(cur)
855
+ continue
856
+ }
857
+ if (!cur) continue
858
+ if (/^##\s+/.test(lines[i])) { cur = null; continue }
859
+ cur.body.push(lines[i])
860
+ if (lines[i].trim()) cur.filled = true
861
+ if (/^\|/.test(lines[i].trim())) cur.lastTable = i
862
+ }
863
+ // Same choice parseValueLedger makes: the last section carrying rows, else the
864
+ // last with a body. A row written anywhere else is a row no gate can see.
865
+ const withRows = [...sections].reverse().find(s => valueLedgerRowCount(s.body.join('\n')))
866
+ const target = withRows || [...sections].reverse().find(s => s.filled) || sections[sections.length - 1]
867
+ if (!target) {
868
+ md = appendUnderSection(md, 'Value ledger', `${VALUE_LEDGER_HEADER}\n${VALUE_LEDGER_RULE}\n${row}`)
869
+ } else if (target.lastTable !== -1) {
870
+ lines.splice(target.lastTable + 1, 0, row)
871
+ md = lines.join('\n')
872
+ } else {
873
+ // No table under the chosen heading: a lone row would be read as the header
874
+ // line and the value would vanish. Lay the canonical table first.
875
+ lines.splice(target.heading + 1, 0, '', VALUE_LEDGER_HEADER, VALUE_LEDGER_RULE, row)
876
+ md = lines.join('\n')
853
877
  }
854
- if (!cur) continue
855
- if (/^##\s+/.test(lines[i])) { cur = null; continue }
856
- cur.body.push(lines[i])
857
- if (lines[i].trim()) cur.filled = true
858
- if (/^\|/.test(lines[i].trim())) cur.lastTable = i
859
- }
860
- // Same choice parseValueLedger makes: the last section carrying rows, else the
861
- // last with a body. A row written anywhere else is a row no gate can see.
862
- const withRows = [...sections].reverse().find(s => valueLedgerRowCount(s.body.join('\n')))
863
- const target = withRows || [...sections].reverse().find(s => s.filled) || sections[sections.length - 1]
864
- if (!target) {
865
- md = appendUnderSection(md, 'Value ledger', `${VALUE_LEDGER_HEADER}\n${VALUE_LEDGER_RULE}\n${row}`)
866
- } else if (target.lastTable !== -1) {
867
- lines.splice(target.lastTable + 1, 0, row)
868
- md = lines.join('\n')
869
- } else {
870
- // No table under the chosen heading: a lone row would be read as the header
871
- // line and the value would vanish. Lay the canonical table first.
872
- lines.splice(target.heading + 1, 0, '', VALUE_LEDGER_HEADER, VALUE_LEDGER_RULE, row)
873
- md = lines.join('\n')
874
- }
875
- withFileLock(p, () => { atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n') })
878
+ atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n')
879
+ })
876
880
  recordLastWrite(eng, 'delivery.md', row)
877
881
  commitMemory(eng, 'log delivery', { files: ['delivery.md'] })
878
882
  }
@@ -881,34 +885,36 @@ function retireOpenRisks(eng, needle) {
881
885
  const n = String(needle || '').toLowerCase()
882
886
  if (!n) return 0
883
887
  const p = path.join(eng, 'risks.md')
884
- const md = readEng(eng, 'risks.md')
885
- if (!md) return 0
886
- const retired = []
887
- const kept = []
888
- let inRetired = false
889
- for (const raw of md.split('\n')) {
890
- const t = raw.trim()
891
- if (/^#{1,6}\s+Retired\b/i.test(t)) { inRetired = true; kept.push(raw); continue }
892
- if (!inRetired) {
893
- const m = t.match(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*(?:\[@[^\]]+\]\s*)?(.*)$/)
894
- if (m && m[1].toLowerCase().includes(n)) {
895
- retired.push(raw)
896
- continue
888
+ return withFileLock(p, () => {
889
+ const md = readEng(eng, 'risks.md')
890
+ if (!md) return 0
891
+ const retired = []
892
+ const kept = []
893
+ let inRetired = false
894
+ for (const raw of md.split('\n')) {
895
+ const t = raw.trim()
896
+ if (/^#{1,6}\s+Retired\b/i.test(t)) { inRetired = true; kept.push(raw); continue }
897
+ if (!inRetired) {
898
+ const m = t.match(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*(?:\[@[^\]]+\]\s*)?(.*)$/)
899
+ if (m && m[1].toLowerCase().includes(n)) {
900
+ retired.push(raw)
901
+ continue
902
+ }
897
903
  }
904
+ kept.push(raw)
898
905
  }
899
- kept.push(raw)
900
- }
901
- if (!retired.length) return 0
902
- let out = kept.join('\n')
903
- if (!/^#{1,6}\s+Retired\b/im.test(out)) out = out.replace(/\n*$/, '\n\n## Retired\n')
904
- const stamp = new Date().toISOString().slice(0, 10)
905
- const block = retired.map(l => {
906
- const body = l.trim().replace(/^-\s*/, '')
907
- return `- [${stamp}] (retired) ${body}`
908
- }).join('\n')
909
- out = appendUnderSection(out, 'Retired', block)
910
- withFileLock(p, () => { atomicWriteFile(p, out.endsWith('\n') ? out : out + '\n') })
911
- return retired.length
906
+ if (!retired.length) return 0
907
+ let out = kept.join('\n')
908
+ if (!/^#{1,6}\s+Retired\b/im.test(out)) out = out.replace(/\n*$/, '\n\n## Retired\n')
909
+ const stamp = new Date().toISOString().slice(0, 10)
910
+ const block = retired.map(l => {
911
+ const body = l.trim().replace(/^-\s*/, '')
912
+ return `- [${stamp}] (retired) ${body}`
913
+ }).join('\n')
914
+ out = appendUnderSection(out, 'Retired', block)
915
+ atomicWriteFile(p, out.endsWith('\n') ? out : out + '\n')
916
+ return retired.length
917
+ })
912
918
  }
913
919
 
914
920
  // Engagement age from the .fde/ directory's own birth time - hidden (not
@@ -961,6 +967,7 @@ function colIndex(headers, rx) { return headers.findIndex(h => rx.test(h)) }
961
967
  const {
962
968
  personFromSignalText,
963
969
  signalSubjectKey,
970
+ isSignalNameNoise,
964
971
  nextActionLine,
965
972
  computeSignals,
966
973
  resumeTriage,
@@ -1003,10 +1010,14 @@ function displayNameFromSignalText(text) {
1003
1010
  const person = personFromSignalText(text)
1004
1011
  if (person) return person
1005
1012
  const t = String(text).trim()
1006
- const proper = t.match(/^([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)/)
1007
- if (proper) return proper[1]
1008
- const word = t.split(/\s+/).find(w => w && !/^(dr|mr|mrs|ms)\.?$/i.test(w))
1009
- return word ? word.replace(/[^A-Za-z0-9.-]/g, '') : t.slice(0, 24)
1013
+ const words = t.split(/\s+/).filter(w => {
1014
+ const bare = w.replace(/[^A-Za-z0-9.-]/g, '')
1015
+ return bare.length >= 2 && !isSignalNameNoise(bare) && !/^(dr|mr|mrs|ms)\.?$/i.test(bare)
1016
+ })
1017
+ if (!words.length) return ''
1018
+ const proper = words.find(w => /^[A-Z][a-z]+(?:\s+[A-Z][a-z]+)?$/.test(w) || /^[A-Z]{2,}$/.test(w))
1019
+ if (proper && !isSignalNameNoise(proper)) return proper.replace(/[^A-Za-z0-9. -]/g, '')
1020
+ return words.slice(0, 2).join(' ').replace(/[^A-Za-z0-9. -]/g, '')
1010
1021
  }
1011
1022
 
1012
1023
  // Stakeholders for prep/dashboard: table rows PLUS people who only appear in
@@ -1063,8 +1074,10 @@ function extractStakeholders(eng) {
1063
1074
  const cur = byKey.get(key)
1064
1075
  byKey.set(key, { ...cur, signal: h.signal, note: cur.note || h.text.slice(0, 80) })
1065
1076
  } else {
1077
+ const name = displayNameFromSignalText(h.text)
1078
+ if (!name || isSignalNameNoise(name)) continue
1066
1079
  byKey.set(key, {
1067
- name: displayNameFromSignalText(h.text),
1080
+ name,
1068
1081
  role: '',
1069
1082
  note: h.text.slice(0, 80),
1070
1083
  signal: h.signal,
@@ -1275,6 +1288,9 @@ function cmdScan() {
1275
1288
  }
1276
1289
 
1277
1290
  function cmdResume(args) {
1291
+ let maxBytes
1292
+ try { ({ args, maxBytes } = context.budgetArgs(args)) } catch (e) { console.error(e.message); process.exit(2) }
1293
+
1278
1294
  const initIdx = args.indexOf('--init')
1279
1295
  if (initIdx !== -1) {
1280
1296
  const name = args[initIdx + 1]
@@ -1299,30 +1315,23 @@ function cmdResume(args) {
1299
1315
  }
1300
1316
 
1301
1317
  try {
1318
+ const installPaths = require('./lib/install-paths')
1319
+ installPaths.checkTree(tpl, fdeDir)
1302
1320
  if (!existed) {
1303
- // Atomic create: build under a staging dir, then rename into place.
1304
- // Disk-full / permission mid-copy must not leave a half-built engagement.
1305
- fs.mkdirSync(ENGAGEMENTS_ROOT, { recursive: true })
1306
- const stagingRoot = path.join(ENGAGEMENTS_ROOT, `.init-${slug}-${process.pid}`)
1307
- const stagingEng = path.join(stagingRoot, slug)
1308
- const stagingFde = path.join(stagingEng, '.fde')
1309
- rmTreeQuiet(stagingRoot)
1321
+ // Stage only the fieldbook. A pre-existing client folder can contain
1322
+ // contracts or source files that initialization must never remove.
1323
+ installPaths.mkdir(engRoot)
1324
+ const stagingRoot = fs.mkdtempSync(path.join(ENGAGEMENTS_ROOT, `.init-${slug}-`))
1325
+ const stagingFde = path.join(stagingRoot, '.fde')
1310
1326
  try {
1311
- fs.mkdirSync(stagingFde, { recursive: true })
1327
+ fs.mkdirSync(stagingFde)
1312
1328
  fillTemplates(stagingFde)
1313
- // If a partial engRoot exists from an older failed run, remove it first.
1314
- if (fs.existsSync(engRoot)) rmTreeQuiet(engRoot)
1315
- fs.renameSync(stagingEng, engRoot)
1329
+ fs.renameSync(stagingFde, fdeDir)
1330
+ } finally {
1316
1331
  rmTreeQuiet(stagingRoot)
1317
- } catch (e) {
1318
- rmTreeQuiet(stagingRoot)
1319
- if (fs.existsSync(engRoot) && !fs.existsSync(path.join(engRoot, '.fde', 'context.md'))) {
1320
- rmTreeQuiet(engRoot)
1321
- }
1322
- failFs(e, 'create engagement', engRoot)
1323
1332
  }
1324
1333
  } else {
1325
- // Re-init / rebind: only fill missing template files in place.
1334
+ // Re-init only fills missing templates; existing records are preserved.
1326
1335
  fillTemplates(fdeDir)
1327
1336
  }
1328
1337
  } catch (e) {
@@ -1387,17 +1396,24 @@ function cmdResume(args) {
1387
1396
  console.log(`NO ENGAGEMENT for this workspace.\nexisting: ${list}\nAsk the human the client name (one question), then run: fde resume --init <client-name>\nDo not tell them to type that command.`)
1388
1397
  process.exit(2)
1389
1398
  }
1390
- // Monday-morning: triage + proactive hygiene (silent when clean), the record
1391
- // (sponsor / promise / decisions), then the session log.
1392
- printTriageBlock(eng)
1393
- const digest = recordDigest(eng)
1394
- if (digest.length) console.log('\n' + digest.join('\n'))
1395
- console.log(`\nENGAGEMENT: ${eng}\n`)
1396
- // readClean, not fs.readFileSync: this output is what an agent loads as
1397
- // context, so it goes through the same <private> redaction as the dashboard.
1399
+ const intro = [resumeTriage(eng), ...hygieneTriageLines(eng), ...recordDigest(eng)].join('\n')
1398
1400
  const ctx = readClean(eng, 'context.md')
1399
- if (!ctx) { console.log('(context.md empty - new engagement)') }
1400
- else { console.log(args.includes('--full') ? ctx : resumeView(ctx)) }
1401
+ if (args.includes('--full')) {
1402
+ console.log(`${intro}\n\nENGAGEMENT: ${eng}\n\n${ctx || '(no context.md yet)'}`)
1403
+ return
1404
+ }
1405
+ // Bound the complete command output, not only context.md's line count.
1406
+ // Separate allocations keep a long history from crowding out current goals.
1407
+ const policy = readClean(eng, 'trust-profile.md')
1408
+ const success = readClean(eng, 'success.md')
1409
+ const risks = readClean(eng, 'risks.md')
1410
+ process.stdout.write(context.boundedSections([
1411
+ `${intro}\n\nENGAGEMENT: ${eng}`,
1412
+ policy ? `CLIENT POLICY - trust-profile.md\n${policy}` : '',
1413
+ success ? `CURRENT GOALS & ACCEPTANCE - success.md\n${success}` : '',
1414
+ risks ? `RECORDED RISKS - risks.md\n${risks}` : '',
1415
+ `WORKING CONTEXT - context.md\n${ctx ? resumeView(ctx) : '(no context.md yet)'}`,
1416
+ ], maxBytes))
1401
1417
  }
1402
1418
 
1403
1419
  // Token discipline: context.md grows every session (the session-stop hook
@@ -1405,18 +1421,18 @@ function cmdResume(args) {
1405
1421
  // each day for less marginal signal. This returns a bounded view - the curated
1406
1422
  // head (state / next action) plus the most recent activity - and hides the
1407
1423
  // middle of the log behind `fde resume --full`. Pure code, zero model tokens.
1408
- // The session-start hook mirrors this same bound in bash; keep them in sync.
1424
+ // The session-start hook consumes this same view through fde resume.
1409
1425
  function resumeView(md) {
1410
1426
  const lines = md.split('\n')
1411
1427
  // A file ending in "\n" yields a trailing "" here; drop it so the line count
1412
- // matches the bash hook's `wc -l` and the two bounded views stay byte-aligned.
1428
+ // counts content lines rather than the terminal newline.
1413
1429
  if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
1414
1430
  if (lines.length <= 160) return md
1415
1431
  // Anchor on the "## Session end" heading, NOT the "<!-- fdeops auto-capture -->"
1416
1432
  // comment: this text is read via readClean (stripPrivate strips HTML comments),
1417
1433
  // so the comment is gone by the time we get here. The heading is written on the
1418
1434
  // very next line by cmdCapture and the session-stop hook and survives redaction.
1419
- // The bash bounded_context() anchors on the same heading - keep them identical.
1435
+ // Bound after sanitation so removed comments cannot shift the anchor.
1420
1436
  let headEnd = lines.findIndex(l => /^##\s+Session end\b/.test(l.trim()))
1421
1437
  if (headEnd === -1) headEnd = 120
1422
1438
  headEnd = Math.min(headEnd, 120)
@@ -1433,25 +1449,35 @@ function cmdLogUndo() {
1433
1449
  if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
1434
1450
  const metaPath = path.join(eng, LAST_WRITE)
1435
1451
  let meta
1436
- try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')) } catch (_) {
1437
- console.error('nothing to undo - no prior fde log/debrief write recorded')
1438
- process.exit(1)
1439
- }
1440
- if (!meta.file || !meta.entry) { console.error('corrupt .last-write - cannot undo'); process.exit(1) }
1441
- const target = path.join(eng, meta.file)
1442
- const before = readEng(eng, meta.file)
1443
- const after = removeExactEntryLine(before, meta.entry)
1444
- if (after == null) {
1445
- console.error(`cannot undo - entry no longer in ${meta.file} (edited by hand?). Remove it manually.`)
1452
+ try {
1453
+ withFileLock(metaPath, () => {
1454
+ try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')) } catch (_) {
1455
+ throw new Error('nothing to undo - no prior fde log/debrief write recorded')
1456
+ }
1457
+ if (!Object.values(LOG_FILES).includes(meta.file) || !meta.entry) {
1458
+ throw new Error('corrupt .last-write - cannot undo')
1459
+ }
1460
+ const target = path.join(eng, meta.file)
1461
+ withFileLock(target, () => {
1462
+ const after = removeExactEntryLine(readEng(eng, meta.file), meta.entry)
1463
+ if (after == null) {
1464
+ throw new Error(`cannot undo - entry no longer in ${meta.file} (edited by hand?). Remove it manually.`)
1465
+ }
1466
+ atomicWriteFile(target, after.endsWith('\n') ? after : after + '\n', { soft: true })
1467
+ }, { soft: true })
1468
+ if (/\[signal:(red|amber|green)\]/i.test(meta.entry)) {
1469
+ const ledgerPath = path.join(eng, SIGNAL_LEDGER)
1470
+ withFileLock(ledgerPath, () => {
1471
+ const led = removeExactEntryLine(readEng(eng, SIGNAL_LEDGER), meta.entry)
1472
+ if (led != null) atomicWriteFile(ledgerPath, led.endsWith('\n') ? led : led + '\n', { soft: true })
1473
+ }, { soft: true })
1474
+ }
1475
+ try { fs.unlinkSync(metaPath) } catch (_) {}
1476
+ })
1477
+ } catch (e) {
1478
+ console.error(e.message)
1446
1479
  process.exit(1)
1447
1480
  }
1448
- withFileLock(target, () => { atomicWriteFile(target, after.endsWith('\n') ? after : after + '\n') })
1449
- if (/\[signal:(red|amber|green)\]/i.test(meta.entry)) {
1450
- const ledgerPath = path.join(eng, SIGNAL_LEDGER)
1451
- const led = removeExactEntryLine(readEng(eng, SIGNAL_LEDGER), meta.entry)
1452
- if (led != null) withFileLock(ledgerPath, () => { atomicWriteFile(ledgerPath, led.endsWith('\n') ? led : led + '\n') })
1453
- }
1454
- try { fs.unlinkSync(metaPath) } catch (_) {}
1455
1481
  const undoFiles = [meta.file]
1456
1482
  if (/\[signal:(red|amber|green)\]/i.test(meta.entry)) undoFiles.push(SIGNAL_LEDGER)
1457
1483
  const hash = commitMemory(eng, `undo ${meta.file}`, { files: undoFiles })
@@ -1529,18 +1555,20 @@ function cmdLog(args) {
1529
1555
  function setContextPhase(eng, phase) {
1530
1556
  ensureMemoryGit(eng)
1531
1557
  const p = path.join(eng, 'context.md')
1532
- let md = readEng(eng, 'context.md')
1533
- if (!md) md = '# Engagement context\n\n'
1534
- if (/\*\*Phase:\*\*/i.test(md)) {
1535
- md = md.replace(/\*\*Phase:\*\*\s*.*/i, `**Phase:** ${phase}`)
1536
- } else {
1537
- md = md.replace(/\n*$/, `\n\n**Phase:** ${phase}\n`)
1538
- }
1539
- const today = new Date().toISOString().slice(0, 10)
1540
- if (/\*\*Last updated:\*\*/i.test(md)) {
1541
- md = md.replace(/\*\*Last updated:\*\*\s*.*/i, `**Last updated:** ${today}`)
1542
- }
1543
- withFileLock(p, () => { atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n') })
1558
+ withFileLock(p, () => {
1559
+ let md = readEng(eng, 'context.md')
1560
+ if (!md) md = '# Engagement context\n\n'
1561
+ if (/\*\*Phase:\*\*/i.test(md)) {
1562
+ md = md.replace(/\*\*Phase:\*\*\s*.*/i, `**Phase:** ${phase}`)
1563
+ } else {
1564
+ md = md.replace(/\n*$/, `\n\n**Phase:** ${phase}\n`)
1565
+ }
1566
+ const today = new Date().toISOString().slice(0, 10)
1567
+ if (/\*\*Last updated:\*\*/i.test(md)) {
1568
+ md = md.replace(/\*\*Last updated:\*\*\s*.*/i, `**Last updated:** ${today}`)
1569
+ }
1570
+ atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n')
1571
+ })
1544
1572
  const hash = commitMemory(eng, `phase ${phase}`, { files: ['context.md'] })
1545
1573
  // Proactive warn at the moment it matters - don't wait for Monday hygiene.
1546
1574
  if (phase === 'ship' || phase === 'close') {
@@ -1667,57 +1695,61 @@ function signerFromLine(text) {
1667
1695
  function setSigner(eng, who) {
1668
1696
  ensureMemoryGit(eng)
1669
1697
  const p = path.join(eng, 'success.md')
1670
- let md = readEng(eng, 'success.md')
1671
- if (!md) md = '# Success definition\n\n'
1672
- const norm = (s) => String(s).replace(/\s+/g, ' ').trim().toLowerCase()
1673
- // [^\S\n], not \s: \s crosses newlines, so an empty field captured the next
1674
- // line - and setSigner then read a filled field and filed the name as "also
1675
- // named" under whatever heading followed.
1676
- const line = /^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m
1677
- const m = md.match(line)
1678
- if (m && !m[1].trim()) {
1679
- md = md.replace(line, `**Stakeholder who signs off:** ${who}`)
1680
- } else if (m) {
1681
- // Whole-name compare against the primary and every "also named" line under
1682
- // it: "Sam" must not vanish inside "Samantha", and re-applying must not
1683
- // stack duplicates.
1684
- const start = md.indexOf(m[0]) + m[0].length
1685
- const alsoNamed = []
1686
- for (const l of md.slice(start).split('\n').slice(1)) {
1687
- const a = l.match(/^- also named:\s*(.+)$/)
1688
- if (!a) break
1689
- alsoNamed.push(a[1])
1690
- }
1691
- const known = [m[1], ...alsoNamed].map(norm)
1692
- if (known.includes(norm(who))) return false
1693
- // A second, different name is a fact worth keeping next to the first, not
1694
- // a silent overwrite - who signs is exactly the thing people argue about.
1695
- const block = [m[0], ...alsoNamed.map(a => `- also named: ${a}`)].join('\n')
1696
- md = md.replace(block, `${block}\n- also named: ${who}`)
1697
- } else {
1698
- md = md.replace(/\n*$/, `\n\n**Stakeholder who signs off:** ${who}\n`)
1699
- }
1700
- withFileLock(p, () => { atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n') })
1701
- return true
1698
+ return withFileLock(p, () => {
1699
+ let md = readEng(eng, 'success.md')
1700
+ if (!md) md = '# Success definition\n\n'
1701
+ const norm = (s) => String(s).replace(/\s+/g, ' ').trim().toLowerCase()
1702
+ // [^\S\n], not \s: \s crosses newlines, so an empty field captured the next
1703
+ // line - and setSigner then read a filled field and filed the name as "also
1704
+ // named" under whatever heading followed.
1705
+ const line = /^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m
1706
+ const m = md.match(line)
1707
+ if (m && !m[1].trim()) {
1708
+ md = md.replace(line, `**Stakeholder who signs off:** ${who}`)
1709
+ } else if (m) {
1710
+ // Whole-name compare against the primary and every "also named" line under
1711
+ // it: "Sam" must not vanish inside "Samantha", and re-applying must not
1712
+ // stack duplicates.
1713
+ const start = md.indexOf(m[0]) + m[0].length
1714
+ const alsoNamed = []
1715
+ for (const l of md.slice(start).split('\n').slice(1)) {
1716
+ const a = l.match(/^- also named:\s*(.+)$/)
1717
+ if (!a) break
1718
+ alsoNamed.push(a[1])
1719
+ }
1720
+ const known = [m[1], ...alsoNamed].map(norm)
1721
+ if (known.includes(norm(who))) return false
1722
+ // A second, different name is a fact worth keeping next to the first, not
1723
+ // a silent overwrite - who signs is exactly the thing people argue about.
1724
+ const block = [m[0], ...alsoNamed.map(a => `- also named: ${a}`)].join('\n')
1725
+ md = md.replace(block, `${block}\n- also named: ${who}`)
1726
+ } else {
1727
+ md = md.replace(/\n*$/, `\n\n**Stakeholder who signs off:** ${who}\n`)
1728
+ }
1729
+ atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n')
1730
+ return true
1731
+ })
1702
1732
  }
1703
1733
 
1704
1734
  function setNextAction(eng, text) {
1705
1735
  ensureMemoryGit(eng)
1706
1736
  const bullet = `- ${stripControlChars(String(text).replace(/^[-*]\s+/, '').trim())}`
1707
1737
  const p = path.join(eng, 'context.md')
1708
- let md = readEng(eng, 'context.md')
1709
- if (!md) md = '# Engagement context\n\n'
1710
- // Collapse duplicate ## Next action headings (skill-append trap) into one.
1711
- md = stripAllSections(md, 'Next action')
1712
- if (/^##\s+Current state\b/im.test(md)) {
1713
- md = md.replace(
1714
- /(^##\s+Current state\b[^\n]*\n)([\s\S]*?)(?=^##\s|\s*$)/im,
1715
- (_, h, body) => `${h}${String(body).replace(/\n*$/, '\n')}\n## Next action\n\n${bullet}\n\n`
1716
- )
1717
- } else {
1718
- md = md.replace(/\n*$/, `\n\n## Next action\n\n${bullet}\n`)
1719
- }
1720
- withFileLock(p, () => { atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n') })
1738
+ withFileLock(p, () => {
1739
+ let md = readEng(eng, 'context.md')
1740
+ if (!md) md = '# Engagement context\n\n'
1741
+ // Collapse duplicate ## Next action headings (skill-append trap) into one.
1742
+ md = stripAllSections(md, 'Next action')
1743
+ if (/^##\s+Current state\b/im.test(md)) {
1744
+ md = md.replace(
1745
+ /(^##\s+Current state\b[^\n]*\n)([\s\S]*?)(?=^##\s|\s*$)/im,
1746
+ (_, h, body) => `${h}${String(body).replace(/\n*$/, '\n')}\n## Next action\n\n${bullet}\n\n`
1747
+ )
1748
+ } else {
1749
+ md = md.replace(/\n*$/, `\n\n## Next action\n\n${bullet}\n`)
1750
+ }
1751
+ atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n')
1752
+ })
1721
1753
  }
1722
1754
 
1723
1755
  function looksLikeBinaryNoise(text) {
@@ -2293,6 +2325,23 @@ function cmdReceipts(args) {
2293
2325
  }
2294
2326
  }
2295
2327
 
2328
+ function cmdRecall(args) {
2329
+ let maxBytes
2330
+ try { ({ args, maxBytes } = context.budgetArgs(args)) } catch (e) { console.error(e.message); process.exit(2) }
2331
+ const query = args.join(' ').trim()
2332
+ if (!query || Buffer.byteLength(query) > 2048 || args.some(a => a.startsWith('--'))) {
2333
+ console.error('usage: fde recall <topic> [--max-bytes 4096..65536]'); process.exit(2)
2334
+ }
2335
+ const eng = resolveEngagement()
2336
+ if (!eng) { console.error('no engagement - bind a client before recall'); process.exit(2) }
2337
+ const files = ['context.md', 'trust-profile.md', 'success.md', 'decisions.md', 'risks.md', 'delivery.md', 'stakeholders.md', 'brief.md', 'reality.md', 'assumptions.md', 'terrain.md']
2338
+ const result = context.recallSections(files.map(file => ({ file, text: readClean(eng, file) })), query)
2339
+ process.stdout.write(context.boundedSections([
2340
+ `RECALL - ${eng}\n${result.total ? `${result.sections.length} of ${result.total} matching lines; refine the query if evidence is omitted.` : 'No matching record. This is not proof that the event never happened.'}\nSources are local record assertions; verify dates, supersession and approval scope.`,
2341
+ ...result.sections,
2342
+ ], maxBytes))
2343
+ }
2344
+
2296
2345
  function cmdCapture() {
2297
2346
  const eng = resolveEngagement({ forWrite: true })
2298
2347
  if (!eng) process.exit(0) // silent: capture must never break a session
@@ -2685,7 +2734,8 @@ function findAmbiguousStakeholders(eng) {
2685
2734
  for (const name of forms) {
2686
2735
  const key = signalSubjectKey(name)
2687
2736
  if (!key || key.startsWith('anon:')) continue
2688
- const norm = name.replace(/\s+/g, ' ').trim().toLowerCase()
2737
+ const norm = name.replace(/\([^)]*\)/g, '').replace(/\s+/g, ' ').trim().toLowerCase()
2738
+ if (!norm || norm.length < 2) continue
2689
2739
  if (!byKey.has(key)) byKey.set(key, new Set())
2690
2740
  byKey.get(key).add(norm)
2691
2741
  }
@@ -2751,13 +2801,8 @@ function hasValueBucket(eng) {
2751
2801
  return false
2752
2802
  }
2753
2803
 
2754
- // A measured number the FDE calculated is not a benefit the customer agreed to.
2755
- // Rows carrying a real Measured value need a named customer-side owner in
2756
- // "Accepted by", or they close as claimed - the distinction the renewal turns on.
2757
- // "pending review", "TBD.", "n/a (blocked)" and "..." are all the same thing an
2758
- // FDE means by an empty cell - nagging about them teaches people to ignore doctor.
2759
- const PENDING_CELL_RE =
2760
- /^(?:pending|tbd|to ?be ?(?:measured|confirmed|determined)|n\s*\/\s*a|na|none|unknown|not(?:\s+yet)?\s+measured|unmeasured|awaiting|\?+|\.{2,}|…|-+)(?:[^\w].*)?$/i
2804
+ // Shared classification keeps CLI, dashboard, and vault acceptance consistent.
2805
+ const { PENDING_CELL_RE, valueState } = require('./lib/value-ledger')
2761
2806
 
2762
2807
  function parseValueLedger(eng) {
2763
2808
  // Last section with actual rows, not merely the last non-empty one: a template
@@ -2772,7 +2817,9 @@ function parseValueLedger(eng) {
2772
2817
  slice: colIndex(table.headers, /slice/i),
2773
2818
  promised: colIndex(table.headers, /promised/i),
2774
2819
  measured: colIndex(table.headers, /measured/i),
2775
- accepted: colIndex(table.headers, /accept/i),
2820
+ accepted: colIndex(table.headers, /accepted by/i),
2821
+ acceptanceStatus: colIndex(table.headers, /^acceptance status$/i),
2822
+ evidence: colIndex(table.headers, /evidence/i),
2776
2823
  }
2777
2824
  const cell = (row, i) => (i === -1 ? '' : String(row[i] || '').trim())
2778
2825
  const rows = []
@@ -2782,12 +2829,10 @@ function parseValueLedger(eng) {
2782
2829
  const measured = cell(row, idx.measured)
2783
2830
  const accepted = cell(row, idx.accepted)
2784
2831
  if (!slice && !promised && !measured) continue
2785
- const measuredPending = !measured || PENDING_CELL_RE.test(measured)
2786
- const acceptedPending = idx.accepted === -1 || !accepted || PENDING_CELL_RE.test(accepted)
2787
- let state = 'unmeasured'
2788
- if (!measuredPending && acceptedPending) state = 'claimed'
2789
- else if (!measuredPending) state = 'accepted'
2790
- rows.push({ slice, promised, measured, accepted, state })
2832
+ const evidence = cell(row, idx.evidence)
2833
+ const acceptanceStatus = idx.acceptanceStatus === -1 ? undefined : cell(row, idx.acceptanceStatus)
2834
+ const state = valueState({ measured, accepted, acceptanceStatus, evidence })
2835
+ rows.push({ slice, promised, measured, accepted, evidence, evidenceMissing: !evidence || PENDING_CELL_RE.test(evidence), state })
2791
2836
  }
2792
2837
  return { rows, columnMissing: idx.accepted === -1 }
2793
2838
  }
@@ -2980,19 +3025,15 @@ function cmdRedact(args) {
2980
3025
  }
2981
3026
  ensureMemoryGit(eng)
2982
3027
  const touched = []
2983
- const byFile = new Map()
2984
- for (const h of hits) {
2985
- if (!byFile.has(h.file)) byFile.set(h.file, new Set())
2986
- byFile.get(h.file).add(h.lineNo)
2987
- }
2988
- for (const [file, lineNos] of byFile) {
3028
+ for (const file of new Set(hits.map(h => h.file))) {
2989
3029
  const abs = path.join(eng, file)
2990
- const before = readEng(eng, file)
2991
- const kept = before.split('\n').filter((_, i) => !lineNos.has(i + 1))
2992
- const after = kept.join('\n')
2993
- if (after === before) continue
2994
- withFileLock(abs, () => { atomicWriteFile(abs, after.endsWith('\n') || after === '' ? after : after + '\n') })
2995
- touched.push(file)
3030
+ withFileLock(abs, () => {
3031
+ const before = readEng(eng, file)
3032
+ const after = before.split('\n').filter(line => !line.toLowerCase().includes(needle)).join('\n')
3033
+ if (after === before) return
3034
+ atomicWriteFile(abs, after.endsWith('\n') || after === '' ? after : after + '\n')
3035
+ touched.push(file)
3036
+ })
2996
3037
  }
2997
3038
  if (!touched.length) {
2998
3039
  console.log('nothing changed')
@@ -3143,37 +3184,45 @@ function cmdGarden(args) {
3143
3184
  continue
3144
3185
  }
3145
3186
  if (p.id !== 'archive-sessions') continue
3146
- const cutDates = new Set(p.sessionBlocks.map(b => b.date))
3147
- const keep = []
3148
- const archive = []
3149
- let mode = 'keep'
3150
- let buf = []
3151
- const flush = () => {
3152
- if (!buf.length) return
3153
- ;(mode === 'archive' ? archive : keep).push(...buf)
3154
- buf = []
3155
- }
3156
- for (const line of lines) {
3157
- const m = line.match(/^##\s+Session end\s+-\s+(\d{4}-\d{2}-\d{2})\b/)
3158
- if (m) {
3159
- flush()
3160
- mode = cutDates.has(m[1]) ? 'archive' : 'keep'
3161
- } else if (/^##\s+/.test(line) && mode === 'archive') {
3187
+ let archived
3188
+ try {
3189
+ archived = withFileLock(path.join(eng, 'context.md'), () => {
3190
+ const cutDates = new Set(p.sessionBlocks.map(b => b.date))
3191
+ const keep = []
3192
+ const archive = []
3193
+ let mode = 'keep'
3194
+ let buf = []
3195
+ const flush = () => {
3196
+ if (!buf.length) return
3197
+ ;(mode === 'archive' ? archive : keep).push(...buf)
3198
+ buf = []
3199
+ }
3200
+ for (const line of readEng(eng, 'context.md').split('\n')) {
3201
+ const m = line.match(/^##\s+Session end\s+-\s+(\d{4}-\d{2}-\d{2})\b/)
3202
+ if (m) {
3203
+ flush()
3204
+ mode = cutDates.has(m[1]) ? 'archive' : 'keep'
3205
+ } else if (/^##\s+/.test(line) && mode === 'archive') {
3206
+ flush()
3207
+ mode = 'keep'
3208
+ }
3209
+ buf.push(line)
3210
+ }
3162
3211
  flush()
3163
- mode = 'keep'
3164
- }
3165
- buf.push(line)
3166
- }
3167
- flush()
3168
- if (!archive.length) continue
3169
- const archPath = path.join(eng, 'context-archive.md')
3170
- const prev = fs.existsSync(archPath) ? fs.readFileSync(archPath, 'utf8') : '# Context archive\n\n'
3171
- withFileLock(archPath, () => {
3172
- atomicWriteFile(archPath, prev.replace(/\n*$/, '\n\n') + archive.join('\n').trim() + '\n')
3173
- })
3174
- withFileLock(path.join(eng, 'context.md'), () => {
3175
- atomicWriteFile(path.join(eng, 'context.md'), keep.join('\n').replace(/\n*$/, '\n'))
3176
- })
3212
+ if (!archive.length) return false
3213
+ const archPath = path.join(eng, 'context-archive.md')
3214
+ withFileLock(archPath, () => {
3215
+ const prev = readEng(eng, 'context-archive.md') || '# Context archive\n\n'
3216
+ atomicWriteFile(archPath, prev.replace(/\n*$/, '\n\n') + archive.join('\n').trim() + '\n', { soft: true })
3217
+ }, { soft: true })
3218
+ atomicWriteFile(path.join(eng, 'context.md'), keep.join('\n').replace(/\n*$/, '\n'), { soft: true })
3219
+ return true
3220
+ }, { soft: true })
3221
+ } catch (e) {
3222
+ console.error(`tidy archive: ${e.message}`)
3223
+ process.exit(1)
3224
+ }
3225
+ if (!archived) continue
3177
3226
  applied++
3178
3227
  touched.add('context.md')
3179
3228
  touched.add('context-archive.md')
@@ -3187,47 +3236,49 @@ function cmdGarden(args) {
3187
3236
  // Keep the first open-risk bullet per fingerprint; move later echoes under ## Retired.
3188
3237
  function applyRiskDedupe(eng, clusters) {
3189
3238
  const p = path.join(eng, 'risks.md')
3190
- let md = readEng(eng, 'risks.md')
3191
- if (!md) return 0
3192
- const echoTexts = new Set()
3193
- for (const group of clusters) {
3194
- for (let i = 1; i < group.length; i++) echoTexts.add(group[i])
3195
- }
3196
- if (!echoTexts.size) return 0
3197
- const retiredLines = []
3198
- const kept = []
3199
- let inRetired = false
3200
- let moved = 0
3201
- for (const raw of md.split('\n')) {
3202
- const t = raw.trim()
3203
- if (/^#{1,6}\s+Retired\b/i.test(t)) {
3204
- inRetired = true
3205
- kept.push(raw)
3206
- continue
3207
- }
3208
- if (!inRetired) {
3209
- const m = t.match(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*(?:\[@[^\]]+\]\s*)?(.*)$/)
3210
- if (m && echoTexts.has(m[1].trim())) {
3211
- retiredLines.push(raw)
3212
- moved++
3239
+ return withFileLock(p, () => {
3240
+ let md = readEng(eng, 'risks.md')
3241
+ if (!md) return 0
3242
+ const echoTexts = new Set()
3243
+ for (const group of clusters) {
3244
+ for (let i = 1; i < group.length; i++) echoTexts.add(group[i])
3245
+ }
3246
+ if (!echoTexts.size) return 0
3247
+ const retiredLines = []
3248
+ const kept = []
3249
+ let inRetired = false
3250
+ let moved = 0
3251
+ for (const raw of md.split('\n')) {
3252
+ const t = raw.trim()
3253
+ if (/^#{1,6}\s+Retired\b/i.test(t)) {
3254
+ inRetired = true
3255
+ kept.push(raw)
3213
3256
  continue
3214
3257
  }
3258
+ if (!inRetired) {
3259
+ const m = t.match(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*(?:\[@[^\]]+\]\s*)?(.*)$/)
3260
+ if (m && echoTexts.has(m[1].trim())) {
3261
+ retiredLines.push(raw)
3262
+ moved++
3263
+ continue
3264
+ }
3265
+ }
3266
+ kept.push(raw)
3215
3267
  }
3216
- kept.push(raw)
3217
- }
3218
- if (!moved) return 0
3219
- let out = kept.join('\n')
3220
- if (!/^#{1,6}\s+Retired\b/im.test(out)) {
3221
- out = out.replace(/\n*$/, '\n\n## Retired\n')
3222
- }
3223
- const stamp = new Date().toISOString().slice(0, 10)
3224
- const block = retiredLines.map(l => {
3225
- const body = l.trim().replace(/^-\s*/, '')
3226
- return `- [${stamp}] (tidy dedupe) ${body}`
3227
- }).join('\n')
3228
- out = appendUnderSection(out, 'Retired', block)
3229
- withFileLock(p, () => { atomicWriteFile(p, out.endsWith('\n') ? out : out + '\n') })
3230
- return moved
3268
+ if (!moved) return 0
3269
+ let out = kept.join('\n')
3270
+ if (!/^#{1,6}\s+Retired\b/im.test(out)) {
3271
+ out = out.replace(/\n*$/, '\n\n## Retired\n')
3272
+ }
3273
+ const stamp = new Date().toISOString().slice(0, 10)
3274
+ const block = retiredLines.map(l => {
3275
+ const body = l.trim().replace(/^-\s*/, '')
3276
+ return `- [${stamp}] (tidy dedupe) ${body}`
3277
+ }).join('\n')
3278
+ out = appendUnderSection(out, 'Retired', block)
3279
+ atomicWriteFile(p, out.endsWith('\n') ? out : out + '\n')
3280
+ return moved
3281
+ })
3231
3282
  }
3232
3283
 
3233
3284
  function engagementSlugFromPath(eng) {
@@ -3330,6 +3381,7 @@ function cmdDashboard(args) {
3330
3381
  engagements.forEach(e => {
3331
3382
  const ctx = readClean(e.dir, 'context.md')
3332
3383
  e.next = (sectionBody(ctx, 'Next action', { lastNonEmpty: true }).split('\n').find(l => l.trim()) || '').trim()
3384
+ e.next = e.next.replace(/^[-*]\s+/, '')
3333
3385
  e.hasNext = !!e.next
3334
3386
  e.lastSession = firstLine(sectionBody(ctx, 'Current state'), 240)
3335
3387
  // 220, not 140 - now that brief/reality each get their own full-width
@@ -3346,6 +3398,7 @@ function cmdDashboard(args) {
3346
3398
  e.risks = extractRisks(e.dir)
3347
3399
  e.log = extractLog(e.dir)
3348
3400
  e.stats = extractStats(e.dir)
3401
+ e.valueRows = parseValueLedger(e.dir).rows
3349
3402
  e.highRisks = e.risks.filter(r => r.severity === 'high').length
3350
3403
  e.quiet = e.signals.ageDays !== Infinity && e.signals.ageDays >= 3
3351
3404
  e.slug = slugify(e.name)
@@ -3363,10 +3416,11 @@ function cmdDashboard(args) {
3363
3416
  ...e.log.map(g => g.text), ...e.risks.map(r => r.text),
3364
3417
  ...e.stakeholders.map(p => `${p.name} ${p.role} ${p.note}`),
3365
3418
  ...e.moreSections.map(s => s.title),
3419
+ ...e.valueRows.map(r => `${r.slice} ${r.promised} ${r.measured} ${r.accepted} ${r.evidence}`),
3366
3420
  ].join(' ').toLowerCase())
3367
3421
  })
3368
3422
 
3369
- const html = render.buildFieldbookHtml({ engagements, today })
3423
+ const html = render.buildFieldbookHtml({ engagements, today, generatedAt: new Date().toISOString() })
3370
3424
 
3371
3425
  try {
3372
3426
  fs.mkdirSync(path.dirname(outPath), { recursive: true })
@@ -3406,24 +3460,10 @@ function cliVersion() {
3406
3460
  }
3407
3461
 
3408
3462
  function valueLedgerRows(eng) {
3409
- const ledger = stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger', { lastNonEmpty: true }) || '')
3410
- const table = parseMdTable(ledger)
3411
- if (!table) return []
3412
- const sIdx = colIndex(table.headers, /slice/i)
3413
- const pIdx = colIndex(table.headers, /promis/i)
3414
- const aIdx = colIndex(table.headers, /accept/i)
3415
- const rows = []
3416
- for (const row of table.rows) {
3417
- const slice = sIdx === -1 ? '' : String(row[sIdx] || '').trim()
3418
- const promised = pIdx === -1 ? '' : String(row[pIdx] || '').trim()
3419
- if (!slice && !promised) continue
3420
- const acceptedRaw = aIdx === -1 ? '' : String(row[aIdx] || '').trim()
3421
- rows.push({
3422
- slice, promised,
3423
- acceptedBy: !acceptedRaw || PENDING_CELL_RE.test(acceptedRaw) ? '' : acceptedRaw,
3424
- })
3425
- }
3426
- return rows
3463
+ return parseValueLedger(eng).rows.map(row => ({
3464
+ slice: row.slice, promised: row.promised,
3465
+ acceptedBy: row.state === 'accepted' ? row.accepted : '',
3466
+ }))
3427
3467
  }
3428
3468
 
3429
3469
  function isInside(child, parent) {
@@ -3743,9 +3783,10 @@ function printUsage() {
3743
3783
  fde redact <term> preview/remove lines containing a buried term (pass --apply to commit; subject never repeats the term)
3744
3784
  fde tidy [--apply] propose consolidations; blesses hand-written dirty files when you apply
3745
3785
  fde owner [set email] who keeps this engagement record
3786
+ fde recall <topic> bounded, redacted source excerpts (--max-bytes 4096..65536)
3746
3787
  fde receipts <term> "what did we agree?" with dates
3747
3788
  fde status [--all] value ledger, then trust (pass --all for full portfolio)
3748
- fde dashboard [--all] current engagement fieldbook (pass --all for every client)
3789
+ fde dashboard [--all] [--open] [--out <path>] bound fieldbook (pass --all for every client)
3749
3790
  fde vault derived Obsidian vault of every engagement (--current for one, --redacted for a shared screen, --out <dir>)
3750
3791
  hooks call these; you do not: capture (session-end snapshot), preserve (pre-compaction snapshot)
3751
3792
  env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)
@@ -3763,6 +3804,7 @@ switch (cmd) {
3763
3804
  case 'demo': cmdDemo(args); break
3764
3805
  case 'scan': cmdScan(); break
3765
3806
  case 'resume': cmdResume(args); break
3807
+ case 'recall': cmdRecall(args); break
3766
3808
  case 'triage': cmdTriage(); break
3767
3809
  case 'log': cmdLog(args); break
3768
3810
  case 'debrief': cmdDebrief(args); break