fdeops 3.24.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
@@ -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
@@ -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
@@ -1282,6 +1288,9 @@ function cmdScan() {
1282
1288
  }
1283
1289
 
1284
1290
  function cmdResume(args) {
1291
+ let maxBytes
1292
+ try { ({ args, maxBytes } = context.budgetArgs(args)) } catch (e) { console.error(e.message); process.exit(2) }
1293
+
1285
1294
  const initIdx = args.indexOf('--init')
1286
1295
  if (initIdx !== -1) {
1287
1296
  const name = args[initIdx + 1]
@@ -1306,30 +1315,23 @@ function cmdResume(args) {
1306
1315
  }
1307
1316
 
1308
1317
  try {
1318
+ const installPaths = require('./lib/install-paths')
1319
+ installPaths.checkTree(tpl, fdeDir)
1309
1320
  if (!existed) {
1310
- // Atomic create: build under a staging dir, then rename into place.
1311
- // Disk-full / permission mid-copy must not leave a half-built engagement.
1312
- fs.mkdirSync(ENGAGEMENTS_ROOT, { recursive: true })
1313
- const stagingRoot = path.join(ENGAGEMENTS_ROOT, `.init-${slug}-${process.pid}`)
1314
- const stagingEng = path.join(stagingRoot, slug)
1315
- const stagingFde = path.join(stagingEng, '.fde')
1316
- 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')
1317
1326
  try {
1318
- fs.mkdirSync(stagingFde, { recursive: true })
1327
+ fs.mkdirSync(stagingFde)
1319
1328
  fillTemplates(stagingFde)
1320
- // If a partial engRoot exists from an older failed run, remove it first.
1321
- if (fs.existsSync(engRoot)) rmTreeQuiet(engRoot)
1322
- fs.renameSync(stagingEng, engRoot)
1323
- rmTreeQuiet(stagingRoot)
1324
- } catch (e) {
1329
+ fs.renameSync(stagingFde, fdeDir)
1330
+ } finally {
1325
1331
  rmTreeQuiet(stagingRoot)
1326
- if (fs.existsSync(engRoot) && !fs.existsSync(path.join(engRoot, '.fde', 'context.md'))) {
1327
- rmTreeQuiet(engRoot)
1328
- }
1329
- failFs(e, 'create engagement', engRoot)
1330
1332
  }
1331
1333
  } else {
1332
- // Re-init / rebind: only fill missing template files in place.
1334
+ // Re-init only fills missing templates; existing records are preserved.
1333
1335
  fillTemplates(fdeDir)
1334
1336
  }
1335
1337
  } catch (e) {
@@ -1394,17 +1396,24 @@ function cmdResume(args) {
1394
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.`)
1395
1397
  process.exit(2)
1396
1398
  }
1397
- // Monday-morning: triage + proactive hygiene (silent when clean), the record
1398
- // (sponsor / promise / decisions), then the session log.
1399
- printTriageBlock(eng)
1400
- const digest = recordDigest(eng)
1401
- if (digest.length) console.log('\n' + digest.join('\n'))
1402
- console.log(`\nENGAGEMENT: ${eng}\n`)
1403
- // readClean, not fs.readFileSync: this output is what an agent loads as
1404
- // context, so it goes through the same <private> redaction as the dashboard.
1399
+ const intro = [resumeTriage(eng), ...hygieneTriageLines(eng), ...recordDigest(eng)].join('\n')
1405
1400
  const ctx = readClean(eng, 'context.md')
1406
- if (!ctx) { console.log('(context.md empty - new engagement)') }
1407
- 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))
1408
1417
  }
1409
1418
 
1410
1419
  // Token discipline: context.md grows every session (the session-stop hook
@@ -1412,18 +1421,18 @@ function cmdResume(args) {
1412
1421
  // each day for less marginal signal. This returns a bounded view - the curated
1413
1422
  // head (state / next action) plus the most recent activity - and hides the
1414
1423
  // middle of the log behind `fde resume --full`. Pure code, zero model tokens.
1415
- // 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.
1416
1425
  function resumeView(md) {
1417
1426
  const lines = md.split('\n')
1418
1427
  // A file ending in "\n" yields a trailing "" here; drop it so the line count
1419
- // matches the bash hook's `wc -l` and the two bounded views stay byte-aligned.
1428
+ // counts content lines rather than the terminal newline.
1420
1429
  if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
1421
1430
  if (lines.length <= 160) return md
1422
1431
  // Anchor on the "## Session end" heading, NOT the "<!-- fdeops auto-capture -->"
1423
1432
  // comment: this text is read via readClean (stripPrivate strips HTML comments),
1424
1433
  // so the comment is gone by the time we get here. The heading is written on the
1425
1434
  // very next line by cmdCapture and the session-stop hook and survives redaction.
1426
- // The bash bounded_context() anchors on the same heading - keep them identical.
1435
+ // Bound after sanitation so removed comments cannot shift the anchor.
1427
1436
  let headEnd = lines.findIndex(l => /^##\s+Session end\b/.test(l.trim()))
1428
1437
  if (headEnd === -1) headEnd = 120
1429
1438
  headEnd = Math.min(headEnd, 120)
@@ -1440,25 +1449,35 @@ function cmdLogUndo() {
1440
1449
  if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
1441
1450
  const metaPath = path.join(eng, LAST_WRITE)
1442
1451
  let meta
1443
- try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')) } catch (_) {
1444
- console.error('nothing to undo - no prior fde log/debrief write recorded')
1445
- process.exit(1)
1446
- }
1447
- if (!meta.file || !meta.entry) { console.error('corrupt .last-write - cannot undo'); process.exit(1) }
1448
- const target = path.join(eng, meta.file)
1449
- const before = readEng(eng, meta.file)
1450
- const after = removeExactEntryLine(before, meta.entry)
1451
- if (after == null) {
1452
- 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)
1453
1479
  process.exit(1)
1454
1480
  }
1455
- withFileLock(target, () => { atomicWriteFile(target, after.endsWith('\n') ? after : after + '\n') })
1456
- if (/\[signal:(red|amber|green)\]/i.test(meta.entry)) {
1457
- const ledgerPath = path.join(eng, SIGNAL_LEDGER)
1458
- const led = removeExactEntryLine(readEng(eng, SIGNAL_LEDGER), meta.entry)
1459
- if (led != null) withFileLock(ledgerPath, () => { atomicWriteFile(ledgerPath, led.endsWith('\n') ? led : led + '\n') })
1460
- }
1461
- try { fs.unlinkSync(metaPath) } catch (_) {}
1462
1481
  const undoFiles = [meta.file]
1463
1482
  if (/\[signal:(red|amber|green)\]/i.test(meta.entry)) undoFiles.push(SIGNAL_LEDGER)
1464
1483
  const hash = commitMemory(eng, `undo ${meta.file}`, { files: undoFiles })
@@ -1536,18 +1555,20 @@ function cmdLog(args) {
1536
1555
  function setContextPhase(eng, phase) {
1537
1556
  ensureMemoryGit(eng)
1538
1557
  const p = path.join(eng, 'context.md')
1539
- let md = readEng(eng, 'context.md')
1540
- if (!md) md = '# Engagement context\n\n'
1541
- if (/\*\*Phase:\*\*/i.test(md)) {
1542
- md = md.replace(/\*\*Phase:\*\*\s*.*/i, `**Phase:** ${phase}`)
1543
- } else {
1544
- md = md.replace(/\n*$/, `\n\n**Phase:** ${phase}\n`)
1545
- }
1546
- const today = new Date().toISOString().slice(0, 10)
1547
- if (/\*\*Last updated:\*\*/i.test(md)) {
1548
- md = md.replace(/\*\*Last updated:\*\*\s*.*/i, `**Last updated:** ${today}`)
1549
- }
1550
- 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
+ })
1551
1572
  const hash = commitMemory(eng, `phase ${phase}`, { files: ['context.md'] })
1552
1573
  // Proactive warn at the moment it matters - don't wait for Monday hygiene.
1553
1574
  if (phase === 'ship' || phase === 'close') {
@@ -1674,57 +1695,61 @@ function signerFromLine(text) {
1674
1695
  function setSigner(eng, who) {
1675
1696
  ensureMemoryGit(eng)
1676
1697
  const p = path.join(eng, 'success.md')
1677
- let md = readEng(eng, 'success.md')
1678
- if (!md) md = '# Success definition\n\n'
1679
- const norm = (s) => String(s).replace(/\s+/g, ' ').trim().toLowerCase()
1680
- // [^\S\n], not \s: \s crosses newlines, so an empty field captured the next
1681
- // line - and setSigner then read a filled field and filed the name as "also
1682
- // named" under whatever heading followed.
1683
- const line = /^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m
1684
- const m = md.match(line)
1685
- if (m && !m[1].trim()) {
1686
- md = md.replace(line, `**Stakeholder who signs off:** ${who}`)
1687
- } else if (m) {
1688
- // Whole-name compare against the primary and every "also named" line under
1689
- // it: "Sam" must not vanish inside "Samantha", and re-applying must not
1690
- // stack duplicates.
1691
- const start = md.indexOf(m[0]) + m[0].length
1692
- const alsoNamed = []
1693
- for (const l of md.slice(start).split('\n').slice(1)) {
1694
- const a = l.match(/^- also named:\s*(.+)$/)
1695
- if (!a) break
1696
- alsoNamed.push(a[1])
1697
- }
1698
- const known = [m[1], ...alsoNamed].map(norm)
1699
- if (known.includes(norm(who))) return false
1700
- // A second, different name is a fact worth keeping next to the first, not
1701
- // a silent overwrite - who signs is exactly the thing people argue about.
1702
- const block = [m[0], ...alsoNamed.map(a => `- also named: ${a}`)].join('\n')
1703
- md = md.replace(block, `${block}\n- also named: ${who}`)
1704
- } else {
1705
- md = md.replace(/\n*$/, `\n\n**Stakeholder who signs off:** ${who}\n`)
1706
- }
1707
- withFileLock(p, () => { atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n') })
1708
- 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
+ })
1709
1732
  }
1710
1733
 
1711
1734
  function setNextAction(eng, text) {
1712
1735
  ensureMemoryGit(eng)
1713
1736
  const bullet = `- ${stripControlChars(String(text).replace(/^[-*]\s+/, '').trim())}`
1714
1737
  const p = path.join(eng, 'context.md')
1715
- let md = readEng(eng, 'context.md')
1716
- if (!md) md = '# Engagement context\n\n'
1717
- // Collapse duplicate ## Next action headings (skill-append trap) into one.
1718
- md = stripAllSections(md, 'Next action')
1719
- if (/^##\s+Current state\b/im.test(md)) {
1720
- md = md.replace(
1721
- /(^##\s+Current state\b[^\n]*\n)([\s\S]*?)(?=^##\s|\s*$)/im,
1722
- (_, h, body) => `${h}${String(body).replace(/\n*$/, '\n')}\n## Next action\n\n${bullet}\n\n`
1723
- )
1724
- } else {
1725
- md = md.replace(/\n*$/, `\n\n## Next action\n\n${bullet}\n`)
1726
- }
1727
- 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
+ })
1728
1753
  }
1729
1754
 
1730
1755
  function looksLikeBinaryNoise(text) {
@@ -2300,6 +2325,23 @@ function cmdReceipts(args) {
2300
2325
  }
2301
2326
  }
2302
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
+
2303
2345
  function cmdCapture() {
2304
2346
  const eng = resolveEngagement({ forWrite: true })
2305
2347
  if (!eng) process.exit(0) // silent: capture must never break a session
@@ -2759,13 +2801,8 @@ function hasValueBucket(eng) {
2759
2801
  return false
2760
2802
  }
2761
2803
 
2762
- // A measured number the FDE calculated is not a benefit the customer agreed to.
2763
- // Rows carrying a real Measured value need a named customer-side owner in
2764
- // "Accepted by", or they close as claimed - the distinction the renewal turns on.
2765
- // "pending review", "TBD.", "n/a (blocked)" and "..." are all the same thing an
2766
- // FDE means by an empty cell - nagging about them teaches people to ignore doctor.
2767
- const PENDING_CELL_RE =
2768
- /^(?: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')
2769
2806
 
2770
2807
  function parseValueLedger(eng) {
2771
2808
  // Last section with actual rows, not merely the last non-empty one: a template
@@ -2780,7 +2817,8 @@ function parseValueLedger(eng) {
2780
2817
  slice: colIndex(table.headers, /slice/i),
2781
2818
  promised: colIndex(table.headers, /promised/i),
2782
2819
  measured: colIndex(table.headers, /measured/i),
2783
- accepted: colIndex(table.headers, /accept/i),
2820
+ accepted: colIndex(table.headers, /accepted by/i),
2821
+ acceptanceStatus: colIndex(table.headers, /^acceptance status$/i),
2784
2822
  evidence: colIndex(table.headers, /evidence/i),
2785
2823
  }
2786
2824
  const cell = (row, i) => (i === -1 ? '' : String(row[i] || '').trim())
@@ -2791,12 +2829,9 @@ function parseValueLedger(eng) {
2791
2829
  const measured = cell(row, idx.measured)
2792
2830
  const accepted = cell(row, idx.accepted)
2793
2831
  if (!slice && !promised && !measured) continue
2794
- const measuredPending = !measured || PENDING_CELL_RE.test(measured)
2795
- const acceptedPending = idx.accepted === -1 || !accepted || PENDING_CELL_RE.test(accepted)
2796
- let state = 'unmeasured'
2797
- if (!measuredPending && acceptedPending) state = 'claimed'
2798
- else if (!measuredPending) state = 'accepted'
2799
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 })
2800
2835
  rows.push({ slice, promised, measured, accepted, evidence, evidenceMissing: !evidence || PENDING_CELL_RE.test(evidence), state })
2801
2836
  }
2802
2837
  return { rows, columnMissing: idx.accepted === -1 }
@@ -2990,19 +3025,15 @@ function cmdRedact(args) {
2990
3025
  }
2991
3026
  ensureMemoryGit(eng)
2992
3027
  const touched = []
2993
- const byFile = new Map()
2994
- for (const h of hits) {
2995
- if (!byFile.has(h.file)) byFile.set(h.file, new Set())
2996
- byFile.get(h.file).add(h.lineNo)
2997
- }
2998
- for (const [file, lineNos] of byFile) {
3028
+ for (const file of new Set(hits.map(h => h.file))) {
2999
3029
  const abs = path.join(eng, file)
3000
- const before = readEng(eng, file)
3001
- const kept = before.split('\n').filter((_, i) => !lineNos.has(i + 1))
3002
- const after = kept.join('\n')
3003
- if (after === before) continue
3004
- withFileLock(abs, () => { atomicWriteFile(abs, after.endsWith('\n') || after === '' ? after : after + '\n') })
3005
- 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
+ })
3006
3037
  }
3007
3038
  if (!touched.length) {
3008
3039
  console.log('nothing changed')
@@ -3153,37 +3184,45 @@ function cmdGarden(args) {
3153
3184
  continue
3154
3185
  }
3155
3186
  if (p.id !== 'archive-sessions') continue
3156
- const cutDates = new Set(p.sessionBlocks.map(b => b.date))
3157
- const keep = []
3158
- const archive = []
3159
- let mode = 'keep'
3160
- let buf = []
3161
- const flush = () => {
3162
- if (!buf.length) return
3163
- ;(mode === 'archive' ? archive : keep).push(...buf)
3164
- buf = []
3165
- }
3166
- for (const line of lines) {
3167
- const m = line.match(/^##\s+Session end\s+-\s+(\d{4}-\d{2}-\d{2})\b/)
3168
- if (m) {
3169
- flush()
3170
- mode = cutDates.has(m[1]) ? 'archive' : 'keep'
3171
- } 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
+ }
3172
3211
  flush()
3173
- mode = 'keep'
3174
- }
3175
- buf.push(line)
3176
- }
3177
- flush()
3178
- if (!archive.length) continue
3179
- const archPath = path.join(eng, 'context-archive.md')
3180
- const prev = fs.existsSync(archPath) ? fs.readFileSync(archPath, 'utf8') : '# Context archive\n\n'
3181
- withFileLock(archPath, () => {
3182
- atomicWriteFile(archPath, prev.replace(/\n*$/, '\n\n') + archive.join('\n').trim() + '\n')
3183
- })
3184
- withFileLock(path.join(eng, 'context.md'), () => {
3185
- atomicWriteFile(path.join(eng, 'context.md'), keep.join('\n').replace(/\n*$/, '\n'))
3186
- })
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
3187
3226
  applied++
3188
3227
  touched.add('context.md')
3189
3228
  touched.add('context-archive.md')
@@ -3197,47 +3236,49 @@ function cmdGarden(args) {
3197
3236
  // Keep the first open-risk bullet per fingerprint; move later echoes under ## Retired.
3198
3237
  function applyRiskDedupe(eng, clusters) {
3199
3238
  const p = path.join(eng, 'risks.md')
3200
- let md = readEng(eng, 'risks.md')
3201
- if (!md) return 0
3202
- const echoTexts = new Set()
3203
- for (const group of clusters) {
3204
- for (let i = 1; i < group.length; i++) echoTexts.add(group[i])
3205
- }
3206
- if (!echoTexts.size) return 0
3207
- const retiredLines = []
3208
- const kept = []
3209
- let inRetired = false
3210
- let moved = 0
3211
- for (const raw of md.split('\n')) {
3212
- const t = raw.trim()
3213
- if (/^#{1,6}\s+Retired\b/i.test(t)) {
3214
- inRetired = true
3215
- kept.push(raw)
3216
- continue
3217
- }
3218
- if (!inRetired) {
3219
- const m = t.match(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*(?:\[@[^\]]+\]\s*)?(.*)$/)
3220
- if (m && echoTexts.has(m[1].trim())) {
3221
- retiredLines.push(raw)
3222
- 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)
3223
3256
  continue
3224
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)
3225
3267
  }
3226
- kept.push(raw)
3227
- }
3228
- if (!moved) return 0
3229
- let out = kept.join('\n')
3230
- if (!/^#{1,6}\s+Retired\b/im.test(out)) {
3231
- out = out.replace(/\n*$/, '\n\n## Retired\n')
3232
- }
3233
- const stamp = new Date().toISOString().slice(0, 10)
3234
- const block = retiredLines.map(l => {
3235
- const body = l.trim().replace(/^-\s*/, '')
3236
- return `- [${stamp}] (tidy dedupe) ${body}`
3237
- }).join('\n')
3238
- out = appendUnderSection(out, 'Retired', block)
3239
- withFileLock(p, () => { atomicWriteFile(p, out.endsWith('\n') ? out : out + '\n') })
3240
- 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
+ })
3241
3282
  }
3242
3283
 
3243
3284
  function engagementSlugFromPath(eng) {
@@ -3419,24 +3460,10 @@ function cliVersion() {
3419
3460
  }
3420
3461
 
3421
3462
  function valueLedgerRows(eng) {
3422
- const ledger = stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger', { lastNonEmpty: true }) || '')
3423
- const table = parseMdTable(ledger)
3424
- if (!table) return []
3425
- const sIdx = colIndex(table.headers, /slice/i)
3426
- const pIdx = colIndex(table.headers, /promis/i)
3427
- const aIdx = colIndex(table.headers, /accept/i)
3428
- const rows = []
3429
- for (const row of table.rows) {
3430
- const slice = sIdx === -1 ? '' : String(row[sIdx] || '').trim()
3431
- const promised = pIdx === -1 ? '' : String(row[pIdx] || '').trim()
3432
- if (!slice && !promised) continue
3433
- const acceptedRaw = aIdx === -1 ? '' : String(row[aIdx] || '').trim()
3434
- rows.push({
3435
- slice, promised,
3436
- acceptedBy: !acceptedRaw || PENDING_CELL_RE.test(acceptedRaw) ? '' : acceptedRaw,
3437
- })
3438
- }
3439
- return rows
3463
+ return parseValueLedger(eng).rows.map(row => ({
3464
+ slice: row.slice, promised: row.promised,
3465
+ acceptedBy: row.state === 'accepted' ? row.accepted : '',
3466
+ }))
3440
3467
  }
3441
3468
 
3442
3469
  function isInside(child, parent) {
@@ -3756,6 +3783,7 @@ function printUsage() {
3756
3783
  fde redact <term> preview/remove lines containing a buried term (pass --apply to commit; subject never repeats the term)
3757
3784
  fde tidy [--apply] propose consolidations; blesses hand-written dirty files when you apply
3758
3785
  fde owner [set email] who keeps this engagement record
3786
+ fde recall <topic> bounded, redacted source excerpts (--max-bytes 4096..65536)
3759
3787
  fde receipts <term> "what did we agree?" with dates
3760
3788
  fde status [--all] value ledger, then trust (pass --all for full portfolio)
3761
3789
  fde dashboard [--all] [--open] [--out <path>] bound fieldbook (pass --all for every client)
@@ -3776,6 +3804,7 @@ switch (cmd) {
3776
3804
  case 'demo': cmdDemo(args); break
3777
3805
  case 'scan': cmdScan(); break
3778
3806
  case 'resume': cmdResume(args); break
3807
+ case 'recall': cmdRecall(args); break
3779
3808
  case 'triage': cmdTriage(); break
3780
3809
  case 'log': cmdLog(args); break
3781
3810
  case 'debrief': cmdDebrief(args); break