fdeops 3.27.1 → 3.29.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
@@ -45,6 +45,13 @@ const HOME = os.homedir()
45
45
  const ENGAGEMENTS_ROOT = ((process.env.FDEOPS_ENGAGEMENTS_ROOT || '').trim().replace(/^~/, HOME))
46
46
  || path.join(HOME, 'fde-engagements')
47
47
  const REGISTRY = path.join(ENGAGEMENTS_ROOT, '.registry')
48
+ const masking = require('./lib/masking').createMasking(ENGAGEMENTS_ROOT)
49
+ function maskDisplay(text) {
50
+ return ['dashboard', 'vault'].includes(process.argv[2]) ? String(text) : masking.mask(text)
51
+ }
52
+ function maskedSections(sections, maxBytes) {
53
+ return context.boundedSections(sections.map(text => masking.mask(text)), maxBytes)
54
+ }
48
55
  const DEBRIEF_MAX_BYTES = 256 * 1024
49
56
  const CODE_EXT = ['.js', '.ts', '.tsx', '.jsx', '.py', '.java', '.go', '.rb', '.cs', '.php']
50
57
  const CONF_EXT = CODE_EXT.concat(['.env', '.yaml', '.yml', '.json'])
@@ -114,7 +121,7 @@ function grepFiles(files, regex, cap) {
114
121
  try { text = fs.readFileSync(f, 'utf8') } catch (_) { continue }
115
122
  const lines = text.split('\n')
116
123
  for (let i = 0; i < lines.length && hits.length < cap; i++) {
117
- if (regex.test(lines[i])) hits.push({ file: path.relative(process.cwd(), f), line: i + 1, text: lines[i].trim().slice(0, 120) })
124
+ if (regex.test(lines[i])) hits.push({ file: path.relative(process.cwd(), f), line: i + 1, text: maskDisplay(lines[i].trim()).slice(0, 120) })
118
125
  }
119
126
  }
120
127
  return hits
@@ -798,7 +805,7 @@ function firstLine(md, maxLen) {
798
805
  for (const raw of md.split('\n')) {
799
806
  const l = raw.trim()
800
807
  if (!l || /^#{1,6}\s/.test(l)) continue
801
- const clean = l.replace(/^\*\*[^*]+:\*\*\s*/, '').replace(/\*\*/g, '').replace(/^["']|["']$/g, '').trim()
808
+ const clean = maskDisplay(l.replace(/^\*\*[^*]+:\*\*\s*/, '').replace(/\*\*/g, '').replace(/^["']|["']$/g, '').trim())
802
809
  if (!clean) continue
803
810
  return clean.length > maxLen ? clean.slice(0, maxLen - 1).trim() + '…' : clean
804
811
  }
@@ -811,7 +818,7 @@ function firstLine(md, maxLen) {
811
818
  function parseReality(md, maxLen) {
812
819
  const theory = (md.match(/\*\*Working theory:\*\*[^\S\r\n]*(.*)/i) || [])[1]
813
820
  const hasSchema = /\*\*(Working theory|Evidence|Differs from brief how):\*\*/i.test(md)
814
- const theoryText = (theory || '').trim()
821
+ const theoryText = maskDisplay((theory || '').trim())
815
822
  if (theoryText) {
816
823
  const line = theoryText.length > maxLen ? theoryText.slice(0, maxLen - 1).trim() + '…' : theoryText
817
824
  return { line, missing: '' }
@@ -840,7 +847,7 @@ function valueLedgerRowCount(body) {
840
847
  return t.rows.filter(r => r.some(c => String(c || '').trim())).length
841
848
  }
842
849
 
843
- function appendValueLedgerRow(eng, cells) {
850
+ function appendValueLedgerRow(eng, cells, { skipCommit = false } = {}) {
844
851
  ensureMemoryGit(eng)
845
852
  const p = path.join(eng, 'delivery.md')
846
853
  let row
@@ -887,7 +894,7 @@ function appendValueLedgerRow(eng, cells) {
887
894
  atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n')
888
895
  })
889
896
  recordLastWrite(eng, 'delivery.md', row)
890
- commitMemory(eng, 'log delivery', { files: ['delivery.md'] })
897
+ if (!skipCommit) commitMemory(eng, 'log delivery', { files: ['delivery.md'] })
891
898
  }
892
899
 
893
900
  function retireOpenRisks(eng, needle) {
@@ -983,7 +990,7 @@ const {
983
990
  countOpenRisks,
984
991
  } = createTrustApi({
985
992
  fs, path, readClean, readEng, parseMdTable, sectionBody, SIGNAL_LEDGER, memoryDirtyManual,
986
- stripTemplateNoise, stripLegendLines,
993
+ stripTemplateNoise, stripLegendLines, extractRisks,
987
994
  })
988
995
 
989
996
  // Stakeholders: columns are matched by header wording, not position - real
@@ -1081,14 +1088,14 @@ function extractStakeholders(eng) {
1081
1088
  for (const [key, h] of latest) {
1082
1089
  if (byKey.has(key)) {
1083
1090
  const cur = byKey.get(key)
1084
- byKey.set(key, { ...cur, signal: h.signal, note: cur.note || h.text.slice(0, 80) })
1091
+ byKey.set(key, { ...cur, signal: h.signal, note: cur.note || maskDisplay(h.text).slice(0, 80) })
1085
1092
  } else {
1086
1093
  const name = displayNameFromSignalText(h.text)
1087
1094
  if (!name || isSignalNameNoise(name)) continue
1088
1095
  byKey.set(key, {
1089
1096
  name,
1090
1097
  role: '',
1091
- note: h.text.slice(0, 80),
1098
+ note: maskDisplay(h.text).slice(0, 80),
1092
1099
  signal: h.signal,
1093
1100
  source: 'signal',
1094
1101
  })
@@ -1099,14 +1106,14 @@ function extractStakeholders(eng) {
1099
1106
 
1100
1107
  // Risks: table rows AND dated CLI/debrief bullets. Empty template cells ignored.
1101
1108
  function extractRisks(eng) {
1102
- const md = readClean(eng, 'risks.md')
1109
+ const md = stripTemplateNoise(readClean(eng, 'risks.md'))
1103
1110
  const body = md.split(/^#{1,6}\s+Retired\b/im)[0] || md
1104
1111
  const HIGH = /critical|blocker|exposure|breach|urgent|at risk|at stake|\brace\b|rollback|no test/i
1105
1112
  const out = []
1106
1113
  const seen = new Set()
1107
1114
  const push = (text) => {
1108
1115
  const t = String(text || '').trim()
1109
- if (!t || seen.has(t.toLowerCase())) return
1116
+ if (!t || /^(?:\[[xX]\]\s|(?:closed|resolved|retired)\s*:)/i.test(t) || seen.has(t.toLowerCase())) return
1110
1117
  seen.add(t.toLowerCase())
1111
1118
  out.push({ text: t, severity: HIGH.test(t) ? 'high' : 'med' })
1112
1119
  }
@@ -1114,13 +1121,23 @@ function extractRisks(eng) {
1114
1121
  if (table) {
1115
1122
  const riskIdx = colIndex(table.headers, /^risk$/i)
1116
1123
  if (riskIdx !== -1) {
1117
- for (const cs of table.rows) push(cs[riskIdx])
1124
+ const statusIdx = colIndex(table.headers, /^status$/i)
1125
+ for (const cs of table.rows) {
1126
+ if (statusIdx !== -1 && /^(closed|resolved|retired)$/i.test((cs[statusIdx] || '').trim())) continue
1127
+ push(cs[riskIdx])
1128
+ }
1118
1129
  }
1119
1130
  }
1120
1131
  for (const raw of body.split('\n')) {
1121
1132
  const t = raw.trim()
1122
1133
  const m = t.match(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*(?:\[@[^\]]+\]\s*)?(.*)$/)
1123
1134
  if (m) push(m[1])
1135
+ else {
1136
+ // Inherited Markdown may predate the dated CLI format. Keep its open
1137
+ // bullets visible; absence of a date does not mean absence of a risk.
1138
+ const bullet = t.match(/^[-*+]\s+(.*)$/)
1139
+ if (bullet && !/^\[[xX]\]\s/.test(bullet[1])) push(bullet[1].replace(/^\[ \]\s*/, ''))
1140
+ }
1124
1141
  }
1125
1142
  return out
1126
1143
  }
@@ -1416,7 +1433,7 @@ function cmdResume(args) {
1416
1433
  const policy = readClean(eng, 'trust-profile.md')
1417
1434
  const success = readClean(eng, 'success.md')
1418
1435
  const risks = readClean(eng, 'risks.md')
1419
- process.stdout.write(context.boundedSections([
1436
+ process.stdout.write(maskedSections([
1420
1437
  policy ? `CLIENT POLICY - trust-profile.md\n${policy}` : '',
1421
1438
  `${intro}\n\nENGAGEMENT: ${eng}`,
1422
1439
  success ? `CURRENT GOALS & ACCEPTANCE - success.md\n${success}` : '',
@@ -1541,7 +1558,8 @@ function cmdLog(args) {
1541
1558
  return
1542
1559
  }
1543
1560
  if (type === 'delivery' && text.includes('|')) {
1544
- const cells = text.split('|').map(s => s.trim())
1561
+ let cells
1562
+ try { cells = deliveryCells(text) } catch (error) { console.error(error.message); process.exitCode = 1; return }
1545
1563
  appendValueLedgerRow(eng, cells)
1546
1564
  const hash = memoryHead(eng)
1547
1565
  console.log(`logged → delivery.md (value ledger)${hash ? ` @${hash}` : ''}`)
@@ -1821,6 +1839,7 @@ function readDebriefInput(args) {
1821
1839
  }
1822
1840
 
1823
1841
  function previewLine(text, max = 240) {
1842
+ text = masking.mask(text)
1824
1843
  const t = String(text || '').replace(/\s+/g, ' ').trim()
1825
1844
  if (t.length <= max) return t
1826
1845
  return `${t.slice(0, max)}… (${t.length} chars)`
@@ -1834,7 +1853,8 @@ function writeProposal(eng, text, { replace = false, locked = false } = {}) {
1834
1853
  finally { ownedDebriefLocks.delete(path.join(eng, DEBRIEF_PROPOSE)) }
1835
1854
  }, { soft: true })
1836
1855
  if (!debriefTransactionActive) return withDebriefRecords(eng, () => writeProposal(eng, text, { replace, locked: true }), [DEBRIEF_PROPOSE, DEBRIEF_PRIVATE, DEBRIEF_SEAL])
1837
- const { clean, blocks } = splitPrivate(text, { sealDangling: true })
1856
+ const { clean: original, blocks } = splitPrivate(text, { sealDangling: true })
1857
+ const clean = masking.mask(original)
1838
1858
  const proposePath = path.join(eng, DEBRIEF_PROPOSE)
1839
1859
  const privatePath = path.join(eng, DEBRIEF_PRIVATE)
1840
1860
  if (fs.existsSync(proposePath) && !replace) {
@@ -1876,6 +1896,9 @@ function stripApprovedStamp(text) {
1876
1896
  // One screen a human can confirm in two minutes. The file-by-file routing
1877
1897
  // still prints after this - agents edit prefixes; people read this.
1878
1898
  function printDebriefReview(text, eng) {
1899
+ text = masking.restore(text)
1900
+ const repeats = repeatedDebriefStatements(eng, text)
1901
+ if (repeats.length) console.log(`REPLAY WARNING: ${repeats.length} source-backed statement(s) already recorded. Review newer facts and next action; applying again requires --allow-replay.\n`)
1879
1902
  const buckets = { decided: [], asked: [], scope: [], delivery: [], open: [], next: [], signer: [] }
1880
1903
  for (const raw of String(text || '').split('\n')) {
1881
1904
  const line = raw.trim().replace(/^[-*+]\s+/, '')
@@ -2048,11 +2071,42 @@ function boundedDebriefPreview(eng, render, { proposal = true, maxBytes = 12000
2048
2071
  return result
2049
2072
  }
2050
2073
 
2051
- function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
2074
+ function deliveryCells(text) {
2075
+ const cells = text.split('|').map(s => s.trim())
2076
+ if (cells.length !== 7) throw new Error('delivery needs exactly 7 fields: Slice | Bucket | Promised | Measured | Accepted by | Evidence | Rollback. Use pending for unknowns; omit pipes for a narrative note.')
2077
+ return cells
2078
+ }
2079
+
2080
+ // Exact sourced statement replay only; a shared source may contain new facts.
2081
+ // Do not silently deduplicate: the engineer decides whether a repeated event is intended.
2082
+ function repeatedDebriefStatements(eng, input) {
2083
+ const repeats = []
2084
+ const records = new Map()
2085
+ const normalize = value => value.replace(/\s*\|\s*/g, '|').replace(/\s+/g, ' ').trim()
2086
+ for (const raw of splitPrivate(input, { sealDangling: true }).clean.split('\n')) {
2087
+ const match = raw.trim().replace(/^[-*+]\s+/, '').match(/^(decision|risk|delivery|contact):\s*(.+)$/i)
2088
+ if (!match || !/\[source:[^\]]+\]/i.test(match[2])) continue
2089
+ const body = match[2].trim()
2090
+ const file = LOG_FILES[match[1].toLowerCase()]
2091
+ if (!records.has(file)) {
2092
+ const statements = String(readClean(eng, file) || '').split('\n').map(line => {
2093
+ if (/^\s*\|/.test(line)) return normalize(line.trim().replace(/^\|\s*\d{4}-\d{2}-\d{2}\s*\|/, '').replace(/\|\s*$/, ''))
2094
+ return normalize(line.replace(/^\s*[-*+]\s+/, '').replace(/^(?:\[(?:\d{4}-\d{2}-\d{2}|@[^\]]+|signal:[^\]]+)\]\s*)+/, ''))
2095
+ })
2096
+ records.set(file, new Set(statements))
2097
+ }
2098
+ if (records.get(file).has(normalize(body))) repeats.push(body)
2099
+ }
2100
+ return repeats
2101
+ }
2102
+
2103
+ function routeDebriefInput(eng, input, { dry, force, sealed = [], allowReplay = false }) {
2104
+ input = masking.restore(input)
2105
+ masking.mask(splitPrivate(input, { sealDangling: true }).clean)
2052
2106
  if (!dry && !debriefTransactionActive) {
2053
2107
  ensureMemoryGit(eng)
2054
2108
  return withDebriefRecords(eng, () => {
2055
- const result = routeDebriefInput(eng, input, { dry, force, sealed })
2109
+ const result = routeDebriefInput(eng, input, { dry, force, sealed, allowReplay })
2056
2110
  // Consuming the review is part of the write. If cleanup fails, restoring
2057
2111
  // both the records and proposal makes the next explicit apply safe.
2058
2112
  for (const file of [DEBRIEF_PROPOSE, DEBRIEF_PRIVATE, DEBRIEF_SEAL]) {
@@ -2063,12 +2117,14 @@ function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
2063
2117
  return result
2064
2118
  })
2065
2119
  }
2120
+ const repeats = repeatedDebriefStatements(eng, input)
2121
+ if (repeats.length && !dry && !allowReplay) throw new Error('source-backed statement already recorded; review the existing record and newer next action. Explicitly confirm a repeat with --allow-replay, or remove the repeated statement from the proposal.')
2066
2122
  const d = new Date()
2067
2123
  const date = d.toISOString().slice(0, 10)
2068
2124
  const counts = { decision: 0, risk: 0, delivery: 0, contact: 0, next: 0, signer: 0 }
2069
2125
  const ctxLines = []
2070
2126
  let nextAction = ''
2071
- ensureMemoryGit(eng)
2127
+ if (!dry) ensureMemoryGit(eng)
2072
2128
  // Sealed blocks are pulled out before routing, so a <private> block's interior
2073
2129
  // lines are never previewed and never routed into decisions/risks/stakeholders
2074
2130
  // unsealed. They land verbatim in context.md instead: the preview a human
@@ -2092,7 +2148,7 @@ function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
2092
2148
  const who = body.replace(/\s+signs?(?:\s+off)?\b.*$/i, '').trim() || body.trim()
2093
2149
  if (dry) {
2094
2150
  console.log(`→ success.md **Stakeholder who signs off:** ${previewLine(who)}`)
2095
- console.log(`→ stakeholders.md ${previewLine(datedEntry(eng, date, `${who} signs off`))}`)
2151
+ console.log(`→ stakeholders.md ${previewLine(`- [${date}] ${who} signs off`)}`)
2096
2152
  } else {
2097
2153
  setSigner(eng, who)
2098
2154
  appendLogEntry(eng, 'contact', datedEntry(eng, date, `${who} signs off`), { skipCommit: true })
@@ -2106,9 +2162,16 @@ function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
2106
2162
  counts.next++
2107
2163
  continue
2108
2164
  }
2165
+ if (type === 'delivery' && body.includes('|')) {
2166
+ const cells = deliveryCells(body)
2167
+ if (dry) console.log(`→ delivery.md ## Value ledger ${previewLine(body)}`)
2168
+ else appendValueLedgerRow(eng, cells, { skipCommit: true })
2169
+ counts.delivery++
2170
+ continue
2171
+ }
2109
2172
  const sigInline = (body.match(/\[signal:(red|amber|green)\]/i) || [])[1]
2110
2173
  if (sigInline) body = body.replace(/\[signal:(red|amber|green)\]/i, '').trim()
2111
- const entry = datedEntry(eng, date, body, type === 'contact' && sigInline ? sigInline.toLowerCase() : '')
2174
+ const entry = dry ? `- [${date}]${type === 'contact' && sigInline ? ` [signal:${sigInline.toLowerCase()}]` : ''} ${body}` : datedEntry(eng, date, body, type === 'contact' && sigInline ? sigInline.toLowerCase() : '')
2112
2175
  if (dry) console.log(`→ ${LOG_FILES[type]} ${previewLine(entry)}`)
2113
2176
  else appendLogEntry(eng, type, entry, { skipCommit: true })
2114
2177
  counts[type]++
@@ -2147,6 +2210,25 @@ function cmdDebrief(args) {
2147
2210
 
2148
2211
  function runDebrief(args, eng) {
2149
2212
  args = args.slice()
2213
+ const replayIdx = args.indexOf('--allow-replay')
2214
+ const allowReplay = replayIdx !== -1
2215
+ if (allowReplay) args.splice(replayIdx, 1)
2216
+ if (args.includes('--review')) {
2217
+ if (args.length !== 1 || allowReplay) throw new Error('use debrief --review alone to inspect the pending proposal')
2218
+ const proposal = path.join(eng, DEBRIEF_PROPOSE)
2219
+ const refused = refuseSymlinkWrite(proposal, { soft: true })
2220
+ if (refused) throw new Error(refused)
2221
+ if (!fs.existsSync(proposal)) throw new Error('nothing to review - run debrief --smart <notes> first')
2222
+ const stored = fs.readFileSync(proposal, 'utf8')
2223
+ if (splitPrivate(stored, { sealDangling: true }).blocks.length) throw new Error('pending proposal contains raw private content: do not open it with an agent. Recreate it through debrief --smart from local notes, using --replace-proposal only after confirmation.')
2224
+ const { clean: input } = splitPrivate(masking.restore(stored), { sealDangling: true })
2225
+ const safe = masking.mask(input)
2226
+ if (safe !== stored) atomicWriteFile(proposal, safe, { mode: 0o600, soft: true })
2227
+ return boundedDebriefPreview(eng, () => {
2228
+ printDebriefReview(input, eng)
2229
+ routeDebriefInput(eng, input, { dry: true, force: false })
2230
+ })
2231
+ }
2150
2232
  const dryIdx = args.indexOf('--dry-run')
2151
2233
  const dry = dryIdx !== -1
2152
2234
  if (dry) args.splice(dryIdx, 1)
@@ -2178,6 +2260,7 @@ function runDebrief(args, eng) {
2178
2260
  console.error('nothing to apply - run: fde debrief --smart <notes.md> then fde debrief --apply')
2179
2261
  return void (process.exitCode = 1)
2180
2262
  }
2263
+ input = masking.restore(input)
2181
2264
  sealed = readSealedProposal(eng)
2182
2265
  const expected = readSealCount(eng)
2183
2266
  if (expected === null ? (!sealed.length && input.includes(PRIVATE_MARKER)) : sealed.length < expected) {
@@ -2202,14 +2285,14 @@ function runDebrief(args, eng) {
2202
2285
  if (!apply) {
2203
2286
  console.log(`\nproposal saved → ${proposePath}`)
2204
2287
  console.log('confirm: fde debrief --apply')
2205
- console.log('(edit the propose file first if a line mis-routed)')
2288
+ console.log('(edit the propose file if mis-routed; debrief --review shows the pending REVIEW)')
2206
2289
  return
2207
2290
  }
2208
2291
  input = clean
2209
2292
  sealed = blocks
2210
2293
  }
2211
2294
 
2212
- const route = () => routeDebriefInput(eng, input, { dry, force, sealed })
2295
+ const route = () => routeDebriefInput(eng, input, { dry, force, sealed, allowReplay })
2213
2296
  const { counts, ctxLines, privateBlocks } = boundedDebriefPreview(eng, route, { proposal: false, maxBytes: smart ? 4000 : 12000 })
2214
2297
  if (!dry) {
2215
2298
  const hash = commitMemory(eng, 'debrief', {
@@ -2399,7 +2482,7 @@ function cmdIngest(args) {
2399
2482
  const body = [
2400
2483
  '---',
2401
2484
  `source: ${source}`,
2402
- `title: ${title.replace(/\n/g, ' ').slice(0, 120)}`,
2485
+ `title: ${title.replace(/\n/g, ' ')}`,
2403
2486
  `staged: ${new Date().toISOString()}`,
2404
2487
  `id: ${id}`,
2405
2488
  '---',
@@ -2432,15 +2515,33 @@ function cmdReceipts(args) {
2432
2515
  document.split('\n').forEach((line, i) => {
2433
2516
  if (!line.toLowerCase().includes(term.toLowerCase())) return
2434
2517
  const source = decisionSources.get(i + 1) || sourceReference(line)
2435
- const hit = ` ${file}:${i + 1} ${line.trim().slice(0, 160)}${source ? ` [source: ${source.slice(0, 160)}]` : ' [source missing]'}${dirty.has(file) ? ' dirty file - review manual edits' : ''}`
2436
- ;(recordFiles.includes(file) && source ? records : claims).push(hit)
2518
+ const hit = ` ${file}:${i + 1} ${masking.mask(line.trim()).slice(0, 160)}${source ? ` [source: ${masking.mask(source).slice(0, 160)}]` : ' [source missing]'}${dirty.has(file) ? ' dirty file - review manual edits' : ''}`
2519
+ ;(recordFiles.includes(file) && source ? records : claims).push({ file, hit })
2437
2520
  })
2438
2521
  }
2439
- const sections = ['RECEIPTS: a cited record is not proof of customer approval. File line numbers refer to the redacted view.']
2440
- if (records.length) sections.push('ON RECORD (dated, source-backed):\n' + records.join('\n'))
2441
- if (claims.length) sections.push('CLAIMS & working notes (verify source and approval before citing):\n' + claims.join('\n'))
2522
+ // Alternate the latest and earliest matching lines per file. Otherwise a
2523
+ // long history can spend the entire packet on approvals before a withdrawal.
2524
+ const select = hits => {
2525
+ const groups = new Map()
2526
+ for (const { file, hit } of hits) {
2527
+ if (!groups.has(file)) groups.set(file, [])
2528
+ groups.get(file).push(hit)
2529
+ }
2530
+ const selected = []
2531
+ let latest = true
2532
+ while (selected.length < 24 && [...groups.values()].some(group => group.length)) {
2533
+ for (const group of groups.values()) {
2534
+ if (group.length && selected.length < 24) selected.push(latest ? group.pop() : group.shift())
2535
+ }
2536
+ latest = !latest
2537
+ }
2538
+ return `Selected ${selected.length} of ${hits.length} matching lines; omitted matches require a narrower search.\n` + selected.join('\n')
2539
+ }
2540
+ const sections = ['RECEIPTS: a cited record is not proof of customer approval. File line numbers refer to the redacted view. Latest and earliest matching lines are sampled; file order is not authority. Check conflicting records.']
2541
+ if (records.length) sections.push('ON RECORD (dated, source-backed):\n' + select(records))
2542
+ if (claims.length) sections.push('CLAIMS & working notes (verify source and approval before citing):\n' + select(claims))
2442
2543
  if (!records.length && !claims.length) sections.push(`no record of "${term}" - a gap in the record, not proof of absence`)
2443
- process.stdout.write(context.boundedSections(sections))
2544
+ process.stdout.write(maskedSections(sections))
2444
2545
  }
2445
2546
 
2446
2547
  // Portable snapshot; stdout is read-only. --out creates a new file and never
@@ -2468,12 +2569,13 @@ function cmdHandoff(args, label = 'Handoff') {
2468
2569
  const decisionText = d => `${d.text} (decisions.md:${d.line}, redacted view)`
2469
2570
  const next = stripTemplateNoise(sectionBody(readClean(eng, 'context.md'), 'Next action', { lastNonEmpty: true }))
2470
2571
  const gaps = collectDoctorIssues(eng, { readiness: true })
2471
- const report = context.boundedSections([
2572
+ const report = maskedSections([
2472
2573
  `# ${label}: ${engagementSlugFromPath(eng)}\nSnapshot: ${new Date().toISOString()} · memory ${memoryHead(eng) || 'unversioned'}\nRead-only record, not proof of approval. Confirm sources with the named customer before relying on a claim. Private blocks are excluded; review remaining client information before sharing.`,
2473
2574
  `## Constraints - trust-profile.md\n${stripTemplateNoise(readClean(eng, 'trust-profile.md')) || '(missing)'}`,
2474
2575
  `## Signer and success - success.md\nSigner: ${signer || '(missing; do not infer)'}\n${success || '(missing)'}`,
2475
2576
  `## Next action - context.md\n${next || '(missing)'}\n\n## Open risks - risks.md\n${extractRisks(eng).map(r => '- ' + r.text).join('\n') || '(none recorded; not proof of no risk)'}`,
2476
- `## Accepted value - recorded assertion with source\nOnly structured value-ledger rows are summarized here; review other notes in delivery.md before presenting or handing over this record.\n${ledger.filter(r => r.state === 'accepted').map(rowText).join('\n') || '(none)'}\n\n## CLAIMS and unmeasured promises\n${ledger.filter(r => r.state !== 'accepted').map(r => rowText(r) + ' [' + r.state + ']').join('\n') || '(none)'}`,
2577
+ label === 'Handoff' ? `## Operational handoff - handoff.md\n${stripTemplateNoise(readClean(eng, 'handoff.md')) || '(missing; record recovery steps and the operating owner before rotation)'}` : '',
2578
+ `## Accepted value - recorded assertion with source\nOnly structured value-ledger rows are summarized here; review other notes in delivery.md before presenting or handing over this record.\n${ledger.filter(r => r.state === 'accepted').map(rowText).join('\n') || '(none)'}\n\n## CLAIMS and unmeasured promises\n${ledger.filter(r => r.state !== 'accepted').map(r => rowText(r) + ' [' + r.state + ']' + (r.acceptanceIssue ? '; ' + r.acceptanceIssue : '')).join('\n') || '(none)'}`,
2477
2579
  `## ON RECORD decisions - source supplied, not automatic approval\n${records.map(decisionText).join('\n') || '(none)'}\n\n## CLAIM decisions - source missing\n${claims.map(decisionText).join('\n') || '(none)'}\nSelected ${selected.length} of ${decisions.length} dated decisions. Retrieve older or conflicting decisions with fde recall.`,
2478
2580
  `## Gaps before relying on this packet\n${gaps.map(g => '- ' + g).join('\n') || '(no deterministic lint gaps; human review still required)'}`,
2479
2581
  ], parsed.maxBytes)
@@ -2499,9 +2601,9 @@ function cmdRecall(args) {
2499
2601
  }
2500
2602
  const eng = resolveEngagement()
2501
2603
  if (!eng) { console.error('no engagement - bind a client before recall'); process.exit(2) }
2502
- 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']
2503
- const result = context.recallSections(files.map(file => ({ file, text: readClean(eng, file) })), query)
2504
- process.stdout.write(context.boundedSections([
2604
+ 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', 'handoff.md']
2605
+ const result = context.recallSections(files.map(file => ({ file, text: readClean(eng, file) })), query, 12, masking.mask)
2606
+ process.stdout.write(maskedSections([
2505
2607
  `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.`,
2506
2608
  ...result.sections,
2507
2609
  ], maxBytes))
@@ -2512,7 +2614,7 @@ function cmdCapture() {
2512
2614
  if (!eng) process.exit(0) // silent: capture must never break a session
2513
2615
  // Workspace git facts (cwd), not the engagement memory repo.
2514
2616
  const branch = sh('git branch --show-current')
2515
- const lastCommit = sh("git log -1 --format='%h %s'").slice(0, 100)
2617
+ const lastCommit = sh("git log -1 --format='%h %s'")
2516
2618
  // porcelain lines are "XY path" - sh() trims, so parse by first whitespace
2517
2619
  const changed = sh('git status --porcelain').split('\n').filter(Boolean).slice(0, 8)
2518
2620
  .map(l => l.trim().split(/\s+/).slice(1).join(' ')).join(' ')
@@ -2729,12 +2831,20 @@ function successContractIssues(success) {
2729
2831
  if (active !== -1 && line.trim()) checks[active] += ` ${line.trim()}`
2730
2832
  }
2731
2833
  const observable = checks.some(check => {
2834
+ // This is a lint check, not a semantic proof. Explicit fields let any
2835
+ // domain describe its test without depending on a vocabulary of verbs.
2836
+ const target = /(?:\b(?:within|under|at most|at least|exactly|zero|no missing|no duplicate|all|every|none|true|false|pass|fail|http)\b|[<>=])/i
2837
+ const vague = /\b(?:tbd|unknown|to be defined|improve|better|satisfactory|as expected|works well)\b/i
2838
+ if (vague.test(check)) return false
2839
+ const explicit = check.match(/(?:^|\s)(?:-\s*)?Input:\s*(.+?)\s+(?:-\s*)?Pass when:\s*(.+)$/i)
2840
+ if (explicit) return explicit[1].trim().length > 3 && target.test(explicit[2])
2732
2841
  const stimulus = /\b(?:test|drill|replay|runs?|request|sample|given|when|simulate|inject|compare|restore|verified|observed|measured)\b/i.test(check)
2842
+ || /^\d+\s+[a-z]/i.test(check)
2733
2843
  const result = /\b(?:returns?|rejects?|matches?|equals?|arrives?|alerts?|restores?|passes?|fails?|contains?|produces?|shows?|remains?|receives?)\b/i.test(check)
2734
- const target = /(?:\b(?:within|under|at most|at least|exactly|zero|no missing|no duplicate|all|every|none|true|false|pass|fail|http)\b|[<>=])/i.test(check)
2735
- return stimulus && result && target && !/\b(?:tbd|to be defined|improve|better|satisfactory|as expected|works well)\b/i.test(check)
2844
+ || /\b(?:zero|no duplicate|no missing)\s+[a-z]/i.test(check)
2845
+ return stimulus && result && target.test(check)
2736
2846
  })
2737
- if (!observable) issues.push('success.md needs a binary acceptance check: state a test/input and an observable pass/fail result under **Done when:** or **Acceptance check:**; numbers alone are not a check')
2847
+ if (!observable) issues.push('success.md needs a binary acceptance check: the wording was not recognized as a test/input and observable pass/fail result under **Done when:** or **Acceptance check:**. Use Input: and Pass when: for a domain-specific check; this lint does not prove readiness')
2738
2848
  const signerLine = ((text.match(/^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').replace(/\[source:[^\]]+\]/gi, '').trim()
2739
2849
  // A named primary signer may be followed by responsibilities or another
2740
2850
  // signer's role. Preserve the full record; validate only the leading name.
@@ -2849,7 +2959,7 @@ function collectDoctorIssues(eng, { readiness = false } = {}) {
2849
2959
  }
2850
2960
  const dupes = findDuplicateOpenRisks(eng)
2851
2961
  if (dupes.length) {
2852
- const sample = (dupes[0][0] || '').replace(/\s+/g, ' ').trim().slice(0, 60)
2962
+ const sample = maskDisplay((dupes[0][0] || '').replace(/\s+/g, ' ').trim()).slice(0, 60)
2853
2963
  issues.push(
2854
2964
  `${dupes.length} duplicate open-risk cluster(s) (e.g. "${sample}${sample.length >= 60 ? '…' : ''}") - consolidate or retire echoes in risks.md`
2855
2965
  )
@@ -2997,7 +3107,7 @@ function hasValueBucket(eng) {
2997
3107
  }
2998
3108
 
2999
3109
  // Shared classification keeps CLI, dashboard, and vault acceptance consistent.
3000
- const { PENDING_CELL_RE, valueState, evidenceSource } = require('./lib/value-ledger')
3110
+ const { PENDING_CELL_RE, valueState, evidenceSource, reconcileValueRows } = require('./lib/value-ledger')
3001
3111
 
3002
3112
  function parseValueLedger(eng) {
3003
3113
  // Last section with actual rows, not merely the last non-empty one: a template
@@ -3027,9 +3137,9 @@ function parseValueLedger(eng) {
3027
3137
  const evidence = cell(row, idx.evidence)
3028
3138
  const acceptanceStatus = idx.acceptanceStatus === -1 ? undefined : cell(row, idx.acceptanceStatus)
3029
3139
  const state = valueState({ measured, accepted, acceptanceStatus, evidence })
3030
- rows.push({ slice, promised, measured, accepted, evidence, evidenceMissing: !evidenceSource(evidence), state })
3140
+ rows.push({ slice, promised, measured, accepted, acceptanceStatus, evidence, evidenceMissing: !evidenceSource(evidence), state })
3031
3141
  }
3032
- return { rows, columnMissing: idx.accepted === -1 }
3142
+ return { rows: reconcileValueRows(rows, readClean(eng, 'success.md')), columnMissing: idx.accepted === -1 }
3033
3143
  }
3034
3144
 
3035
3145
  function claimedValueRows(eng) {
@@ -3046,8 +3156,9 @@ function formatValueLedgerLine(r) {
3046
3156
  }
3047
3157
  const head = body ? `${name}: ${body}` : name
3048
3158
  if (r.state === 'accepted') return `${head} · accepted by ${r.accepted}`
3049
- if (r.state === 'claimed') return `${head} · claimed, not yet accepted`
3050
- return `${head} · not yet measured`
3159
+ const issue = r.acceptanceIssue ? `; ${r.acceptanceIssue}` : ''
3160
+ if (r.state === 'claimed') return `${head} · claimed, not yet accepted${issue}`
3161
+ return `${head} · not yet measured${issue}`
3051
3162
  }
3052
3163
 
3053
3164
  function valueLedgerStatusLines(eng, opts = {}) {
@@ -3124,7 +3235,7 @@ function hasEvalReceipt(eng) {
3124
3235
  function hygieneTriageLines(eng) {
3125
3236
  const issues = collectDoctorIssues(eng)
3126
3237
  if (!issues.length) return []
3127
- const top = issues[0].replace(/\s+/g, ' ').trim().slice(0, 72)
3238
+ const top = maskDisplay(issues[0].replace(/\s+/g, ' ').trim()).slice(0, 72)
3128
3239
  return [
3129
3240
  ` hygiene: ${issues.length} issue(s) - ${top}${issues[0].length > 72 ? '…' : ''}`,
3130
3241
  ' → say "@fde clean up the fieldbook" when ready (agent runs fde doctor; nothing auto-rewrites), or: fde doctor',
@@ -3164,14 +3275,14 @@ function recordDigest(eng) {
3164
3275
  const signer = ((success.match(/^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
3165
3276
  // "(none)" rather than a missing line: on session start, nobody named to sign
3166
3277
  // off is the fact worth seeing, not an absence to scroll past.
3167
- const lines = [` signer: ${signer.slice(0, 110) || '(none)'}`]
3278
+ const lines = [` signer: ${masking.mask(signer).slice(0, 110) || '(none)'}`]
3168
3279
  const { rows } = parseValueLedger(eng)
3169
3280
  const promisedRow = [...rows].reverse().find(r => r.promised)
3170
3281
  if (promisedRow) {
3171
- lines.push(` promised: ${formatValueLedgerLine(promisedRow).slice(0, 110)}`)
3282
+ lines.push(` promised: ${masking.mask(formatValueLedgerLine(promisedRow)).slice(0, 110)}`)
3172
3283
  } else {
3173
3284
  const target = ((success.match(/^\*\*Baseline[^\S\n]*→[^\S\n]*target:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
3174
- if (target) lines.push(` promised: ${target.slice(0, 110)}`)
3285
+ if (target) lines.push(` promised: ${masking.mask(target).slice(0, 110)}`)
3175
3286
  }
3176
3287
  const decisions = datedDecisions(readClean(eng, 'decisions.md')).slice(-2)
3177
3288
  for (const d of decisions) lines.push(` decided: ${formatDecisionRecord(d.text)}; source: ${previewLine(sourceReference(d.text) || '(missing)', 100)}`)
@@ -3226,7 +3337,7 @@ function cmdRedact(args) {
3226
3337
  }
3227
3338
  console.log(`REDACT - ${hits.length} matching line(s) for ${JSON.stringify(term)}`)
3228
3339
  hits.slice(0, 20).forEach(h => {
3229
- const preview = h.line.length > 100 ? h.line.slice(0, 97) + '…' : h.line
3340
+ const preview = h.line.length > 100 ? masking.mask(h.line).slice(0, 97) + '…' : h.line
3230
3341
  console.log(` ${h.file}:${h.lineNo} ${preview}`)
3231
3342
  })
3232
3343
  if (hits.length > 20) console.log(` … +${hits.length - 20} more`)
@@ -3271,12 +3382,12 @@ function cmdPrep(args) {
3271
3382
  const people = extractStakeholders(eng).slice(0, 8)
3272
3383
  console.log('\nStakeholders (table + signal history)')
3273
3384
  if (!people.length) console.log(' (none yet - log contacts with --signal)')
3274
- else people.forEach(p => console.log(` [${p.signal}] ${p.name}${p.role ? ` - ${p.role}` : ''}${p.note ? ` · ${p.note.slice(0, 60)}` : ''}`))
3385
+ else people.forEach(p => console.log(` [${p.signal}] ${p.name}${p.role ? ` - ${p.role}` : ''}${p.note ? ` · ${masking.mask(p.note).slice(0, 60)}` : ''}`))
3275
3386
 
3276
3387
  const risks = extractRisks(eng).slice(0, 5)
3277
3388
  console.log('\nOpen risks (table + dated bullets)')
3278
3389
  if (!risks.length) console.log(' (none logged)')
3279
- else risks.forEach(r => console.log(` [${r.severity}] ${r.text.slice(0, 100)}`))
3390
+ else risks.forEach(r => console.log(` [${r.severity}] ${masking.mask(r.text).slice(0, 100)}`))
3280
3391
 
3281
3392
  const success = firstLine(readClean(eng, 'success.md'), 160)
3282
3393
  console.log('\nSuccess looks like')
@@ -3287,7 +3398,7 @@ function cmdPrep(args) {
3287
3398
  .slice(-5)
3288
3399
  console.log('\nRecent decisions')
3289
3400
  if (!decisions.length) console.log(' (none logged)')
3290
- else decisions.forEach(l => console.log(` ${l.trim().slice(0, 120)}`))
3401
+ else decisions.forEach(l => console.log(` ${masking.mask(l.trim()).slice(0, 120)}`))
3291
3402
 
3292
3403
  const next = nextActionLine(readClean(eng, 'context.md'))
3293
3404
  console.log('\nWalk in with')
@@ -3327,7 +3438,7 @@ function cmdGarden(args) {
3327
3438
  }
3328
3439
  const dupes = findDuplicateOpenRisks(eng)
3329
3440
  if (dupes.length) {
3330
- const sample = (dupes[0][0] || '').replace(/\s+/g, ' ').trim().slice(0, 50)
3441
+ const sample = maskDisplay((dupes[0][0] || '').replace(/\s+/g, ' ').trim()).slice(0, 50)
3331
3442
  proposals.push({
3332
3443
  id: 'dedupe-risks',
3333
3444
  kind: 'apply',
@@ -3507,7 +3618,7 @@ function cmdStatus(args) {
3507
3618
  const eng = path.join(ENGAGEMENTS_ROOT, d, '.fde')
3508
3619
  if (!fs.existsSync(eng)) continue
3509
3620
  const s = computeSignals(eng)
3510
- const note = [s.memoryWarn, (s.dirtyFiles && s.dirtyFiles.length) ? `dirty:${s.dirtyFiles.length}` : '', s.reason || s.topRisk].filter(Boolean).join(' · ').slice(0, 70)
3621
+ const note = maskDisplay([s.memoryWarn, (s.dirtyFiles && s.dirtyFiles.length) ? `dirty:${s.dirtyFiles.length}` : '', s.reason || s.topRisk].filter(Boolean).join(' · ')).slice(0, 70)
3511
3622
  rows.push({ name: d, phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated, reason: note, memoryWarn: s.memoryWarn, dirtyFiles: s.dirtyFiles, valueLines: valueLedgerStatusLines(eng, { compact: true }) })
3512
3623
  }
3513
3624
  } else {
@@ -3517,7 +3628,7 @@ function cmdStatus(args) {
3517
3628
  process.exit(2)
3518
3629
  }
3519
3630
  const s = computeSignals(eng)
3520
- const note = [s.memoryWarn, (s.dirtyFiles && s.dirtyFiles.length) ? `dirty:${s.dirtyFiles.length}` : '', s.reason || s.topRisk].filter(Boolean).join(' · ').slice(0, 70)
3631
+ const note = maskDisplay([s.memoryWarn, (s.dirtyFiles && s.dirtyFiles.length) ? `dirty:${s.dirtyFiles.length}` : '', s.reason || s.topRisk].filter(Boolean).join(' · ')).slice(0, 70)
3521
3632
  rows.push({ name: engagementSlugFromPath(eng), phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated, reason: note, memoryWarn: s.memoryWarn, dirtyFiles: s.dirtyFiles, valueLines: valueLedgerStatusLines(eng) })
3522
3633
  }
3523
3634
  if (!rows.length) { console.log('no engagements yet'); return }
@@ -3991,6 +4102,7 @@ ${fs.existsSync(html) ? `\n Open the fieldbook: ${html}` : ''}
3991
4102
  function printUsage() {
3992
4103
  console.log(`fde - deterministic core of fdeops
3993
4104
  fde demo the whole loop on a fake client (fde demo --clean removes it)
4105
+ fde privacy show masking capability and its boundaries
3994
4106
  fde scan day-1 recon of this repo (facts, no AI)
3995
4107
  fde resume load this workspace's engagement memory (bounded)
3996
4108
  fde resume --full load the complete context.md (no bound)
@@ -4003,6 +4115,8 @@ function printUsage() {
4003
4115
  fde log --undo remove the last CLI log/debrief entry from memory
4004
4116
  fde debrief [file] meeting notes → memory (prefixed lines; --dry-run; --force)
4005
4117
  fde debrief --smart heuristic propose; REVIEW first (decided/asked/open/next/signer); --apply after one confirm
4118
+ --review inspect the pending REVIEW after editing, without replacing it
4119
+ --allow-replay explicitly apply already recorded sourced statements after review
4006
4120
  --replace-proposal explicitly discard a pending review when proposing different notes
4007
4121
  fde ingest stage … stage raw pull into <engagement>/.inbox/ (not .fde/)
4008
4122
  fde ingest list list staged inbox items
@@ -4027,12 +4141,32 @@ function printUsage() {
4027
4141
  ingest is a sink only - source MCPs (Granola/Gmail/…) are user-configured; never ambient sync`)
4028
4142
  }
4029
4143
 
4030
- const [cmd, ...args] = process.argv.slice(2)
4144
+ const [cmd, ...rawArgs] = process.argv.slice(2)
4145
+ let outputBudget
4146
+ if (['resume', 'recall', 'handoff', 'defend'].includes(cmd) && !rawArgs.some(a => ['--full', '--init', '--bind', '--out'].includes(a))) {
4147
+ try { outputBudget = context.budgetArgs(rawArgs).maxBytes } catch (_) {}
4148
+ }
4149
+ require('./lib/masking').protectOutput(masking, { maxBytes: outputBudget })
4150
+ let args
4151
+ try {
4152
+ args = rawArgs.map(arg => masking.restore(arg))
4153
+ for (const key of ['FDEOPS_ENGAGEMENT', 'FDEOS_ENGAGEMENT']) {
4154
+ if (process.env[key]) process.env[key] = masking.restore(process.env[key])
4155
+ }
4156
+ // Resolve privacy-state failures before a user-authorized argument write.
4157
+ args.forEach(arg => masking.mask(arg))
4158
+ }
4159
+ catch (e) { console.error(e.message); process.exit(1) }
4031
4160
  if (args.includes('--help') || args.includes('-h') || cmd === 'help' || cmd === '--help' || cmd === '-h') {
4032
4161
  printUsage()
4033
4162
  process.exit(0)
4034
4163
  }
4164
+ try {
4035
4165
  switch (cmd) {
4166
+ case 'privacy':
4167
+ if (args.length) { console.error('usage: fde privacy'); process.exitCode = 2; break }
4168
+ console.log(`FDEOps ${require('../package.json').version} - identifier masking enabled by default.\nCLI responses, smart proposals, handoff packets and ingest MCP results use local aliases.\nPatterns: common emails, international/US phones, SSN-shaped identifiers and supported credentials.\nNames and arbitrary sensitive prose are not detected; mark them <private>.\nRaw files, pasted chat, upstream MCP content and local dashboard/vault files bypass this protection.`)
4169
+ break
4036
4170
  case 'demo': cmdDemo(args); break
4037
4171
  case 'scan': cmdScan(); break
4038
4172
  case 'resume': cmdResume(args); break
@@ -4066,3 +4200,8 @@ switch (cmd) {
4066
4200
  // Missing or unknown command must fail - exit 0 made typos look like success in scripts/hooks.
4067
4201
  process.exit(1)
4068
4202
  }
4203
+
4204
+ } catch (error) {
4205
+ console.error(error && error.message ? error.message : "command failed")
4206
+ process.exitCode = 1
4207
+ }
@@ -45,7 +45,7 @@ function boundedSections(sections, maxBytes = DEFAULT_BYTES) {
45
45
 
46
46
  // Literal, client-scoped lexical retrieval. Retain independently matching
47
47
  // records, including conflicting/older ones; recency never means truth.
48
- function recallSections(documents, query, maxHits = 12) {
48
+ function recallSections(documents, query, maxHits = 12, outputText = text => text) {
49
49
  const words = [...new Set(query.toLocaleLowerCase().split(/\s+/).filter(Boolean))].slice(0, 16)
50
50
  const hits = []
51
51
  for (const { file, text } of documents) {
@@ -57,7 +57,7 @@ function recallSections(documents, query, maxHits = 12) {
57
57
  if (!score) continue
58
58
  const first = Math.max(0, i - 1)
59
59
  const last = Math.min(lines.length, i + 3)
60
- const excerpt = lines.slice(first, last).map(l => Buffer.byteLength(l) <= 1024 ? l : clipUtf8(l, 900) + OMITTED).join('\n')
60
+ const excerpt = lines.slice(first, last).map(outputText).map(l => Buffer.byteLength(l) <= 1024 ? l : clipUtf8(l, 900) + OMITTED).join('\n')
61
61
  hits.push({ file, line: first + 1, end: last, score, text: excerpt.trim() })
62
62
  }
63
63
  }