fdeops 3.28.0 → 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/README.md CHANGED
@@ -247,7 +247,7 @@ If your work has no client commitments or operating handover to track, a simpler
247
247
 
248
248
  The CLI works with local files and Git, without network calls or telemetry. Client records remain readable Markdown if you stop using FDEOps.
249
249
 
250
- Your AI host may send the material it reads to its configured model. FDEOps redacts `<private>` blocks from CLI, dashboard, and hook outputs; do not load those raw blocks through the agent's file tools. Review reports before sharing client information.
250
+ Your AI host may send the material it reads to its configured model. CLI context and smart proposals mask common email, phone, SSN-shaped, and credential patterns locally; this is not complete PII detection. FDEOps redacts `<private>` blocks from CLI, dashboard, and hook outputs; do not load those raw blocks through the agent's file tools. Review reports before sharing client information.
251
251
 
252
252
  You review proposed decisions. Enabled session hooks can save where the session left off automatically; direct CLI write commands update records when you run them.
253
253
 
package/bin/check.js CHANGED
@@ -354,7 +354,7 @@ if (/\broutes methods\b|\bphase methods\b|\bengagement methods\b/.test(usage)) {
354
354
  ok('README tone')
355
355
 
356
356
  if (fs.existsSync(path.join(root, '.codex')) || fs.existsSync(path.join(root, '.opencode'))) {
357
- fail('.codex/ or .opencode/ must not live at repo root - use docs/internal/experimental-agents/')
357
+ fail('.codex/ or .opencode/ must not live at repo root - keep personal agent setup outside the public repository')
358
358
  } else ok('no root-level experimental stubs')
359
359
 
360
360
  for (const rel of ['docs/schema.md', 'docs/skills-reference.md', 'PRIVACY.md']) {
@@ -415,18 +415,13 @@ ok('examples walkthrough files')
415
415
  }
416
416
 
417
417
  if (fs.existsSync(path.join(root, 'tasks', 'plan.md'))) {
418
- fail('tasks/plan.md should not be in public tree (move to docs/internal)')
418
+ fail('tasks/plan.md should not be in the public tree; keep work plans outside the repository')
419
419
  }
420
420
 
421
421
  if (fs.existsSync(path.join(root, 'patterns'))) {
422
422
  fail('patterns/ is deprecated - use skills/ only (overlays live there)')
423
423
  }
424
424
 
425
- const pmf = path.join(root, 'docs', 'internal', 'PMF_360_REVIEW.md')
426
- if (fs.existsSync(pmf) && !read('docs/internal/PMF_360_REVIEW.md').includes('INTERNAL')) {
427
- fail('PMF_360_REVIEW.md needs INTERNAL banner')
428
- } else if (fs.existsSync(pmf)) ok('internal PMF banner')
429
-
430
425
  const hook = read('hooks/session-start')
431
426
  const hookCode = hook.replace(/^[ \t]*#.*$/gm, '')
432
427
  if (!hook.includes('FDEOPS_ENGAGEMENT')) {
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: '' }
@@ -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
  })
@@ -1426,7 +1433,7 @@ function cmdResume(args) {
1426
1433
  const policy = readClean(eng, 'trust-profile.md')
1427
1434
  const success = readClean(eng, 'success.md')
1428
1435
  const risks = readClean(eng, 'risks.md')
1429
- process.stdout.write(context.boundedSections([
1436
+ process.stdout.write(maskedSections([
1430
1437
  policy ? `CLIENT POLICY - trust-profile.md\n${policy}` : '',
1431
1438
  `${intro}\n\nENGAGEMENT: ${eng}`,
1432
1439
  success ? `CURRENT GOALS & ACCEPTANCE - success.md\n${success}` : '',
@@ -1832,6 +1839,7 @@ function readDebriefInput(args) {
1832
1839
  }
1833
1840
 
1834
1841
  function previewLine(text, max = 240) {
1842
+ text = masking.mask(text)
1835
1843
  const t = String(text || '').replace(/\s+/g, ' ').trim()
1836
1844
  if (t.length <= max) return t
1837
1845
  return `${t.slice(0, max)}… (${t.length} chars)`
@@ -1845,7 +1853,8 @@ function writeProposal(eng, text, { replace = false, locked = false } = {}) {
1845
1853
  finally { ownedDebriefLocks.delete(path.join(eng, DEBRIEF_PROPOSE)) }
1846
1854
  }, { soft: true })
1847
1855
  if (!debriefTransactionActive) return withDebriefRecords(eng, () => writeProposal(eng, text, { replace, locked: true }), [DEBRIEF_PROPOSE, DEBRIEF_PRIVATE, DEBRIEF_SEAL])
1848
- const { clean, blocks } = splitPrivate(text, { sealDangling: true })
1856
+ const { clean: original, blocks } = splitPrivate(text, { sealDangling: true })
1857
+ const clean = masking.mask(original)
1849
1858
  const proposePath = path.join(eng, DEBRIEF_PROPOSE)
1850
1859
  const privatePath = path.join(eng, DEBRIEF_PRIVATE)
1851
1860
  if (fs.existsSync(proposePath) && !replace) {
@@ -1887,6 +1896,7 @@ function stripApprovedStamp(text) {
1887
1896
  // One screen a human can confirm in two minutes. The file-by-file routing
1888
1897
  // still prints after this - agents edit prefixes; people read this.
1889
1898
  function printDebriefReview(text, eng) {
1899
+ text = masking.restore(text)
1890
1900
  const repeats = repeatedDebriefStatements(eng, text)
1891
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`)
1892
1902
  const buckets = { decided: [], asked: [], scope: [], delivery: [], open: [], next: [], signer: [] }
@@ -2091,6 +2101,8 @@ function repeatedDebriefStatements(eng, input) {
2091
2101
  }
2092
2102
 
2093
2103
  function routeDebriefInput(eng, input, { dry, force, sealed = [], allowReplay = false }) {
2104
+ input = masking.restore(input)
2105
+ masking.mask(splitPrivate(input, { sealDangling: true }).clean)
2094
2106
  if (!dry && !debriefTransactionActive) {
2095
2107
  ensureMemoryGit(eng)
2096
2108
  return withDebriefRecords(eng, () => {
@@ -2207,7 +2219,11 @@ function runDebrief(args, eng) {
2207
2219
  const refused = refuseSymlinkWrite(proposal, { soft: true })
2208
2220
  if (refused) throw new Error(refused)
2209
2221
  if (!fs.existsSync(proposal)) throw new Error('nothing to review - run debrief --smart <notes> first')
2210
- const { clean: input } = splitPrivate(fs.readFileSync(proposal, 'utf8'), { sealDangling: true })
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 })
2211
2227
  return boundedDebriefPreview(eng, () => {
2212
2228
  printDebriefReview(input, eng)
2213
2229
  routeDebriefInput(eng, input, { dry: true, force: false })
@@ -2244,6 +2260,7 @@ function runDebrief(args, eng) {
2244
2260
  console.error('nothing to apply - run: fde debrief --smart <notes.md> then fde debrief --apply')
2245
2261
  return void (process.exitCode = 1)
2246
2262
  }
2263
+ input = masking.restore(input)
2247
2264
  sealed = readSealedProposal(eng)
2248
2265
  const expected = readSealCount(eng)
2249
2266
  if (expected === null ? (!sealed.length && input.includes(PRIVATE_MARKER)) : sealed.length < expected) {
@@ -2465,7 +2482,7 @@ function cmdIngest(args) {
2465
2482
  const body = [
2466
2483
  '---',
2467
2484
  `source: ${source}`,
2468
- `title: ${title.replace(/\n/g, ' ').slice(0, 120)}`,
2485
+ `title: ${title.replace(/\n/g, ' ')}`,
2469
2486
  `staged: ${new Date().toISOString()}`,
2470
2487
  `id: ${id}`,
2471
2488
  '---',
@@ -2498,7 +2515,7 @@ function cmdReceipts(args) {
2498
2515
  document.split('\n').forEach((line, i) => {
2499
2516
  if (!line.toLowerCase().includes(term.toLowerCase())) return
2500
2517
  const source = decisionSources.get(i + 1) || sourceReference(line)
2501
- 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' : ''}`
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' : ''}`
2502
2519
  ;(recordFiles.includes(file) && source ? records : claims).push({ file, hit })
2503
2520
  })
2504
2521
  }
@@ -2524,7 +2541,7 @@ function cmdReceipts(args) {
2524
2541
  if (records.length) sections.push('ON RECORD (dated, source-backed):\n' + select(records))
2525
2542
  if (claims.length) sections.push('CLAIMS & working notes (verify source and approval before citing):\n' + select(claims))
2526
2543
  if (!records.length && !claims.length) sections.push(`no record of "${term}" - a gap in the record, not proof of absence`)
2527
- process.stdout.write(context.boundedSections(sections))
2544
+ process.stdout.write(maskedSections(sections))
2528
2545
  }
2529
2546
 
2530
2547
  // Portable snapshot; stdout is read-only. --out creates a new file and never
@@ -2552,7 +2569,7 @@ function cmdHandoff(args, label = 'Handoff') {
2552
2569
  const decisionText = d => `${d.text} (decisions.md:${d.line}, redacted view)`
2553
2570
  const next = stripTemplateNoise(sectionBody(readClean(eng, 'context.md'), 'Next action', { lastNonEmpty: true }))
2554
2571
  const gaps = collectDoctorIssues(eng, { readiness: true })
2555
- const report = context.boundedSections([
2572
+ const report = maskedSections([
2556
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.`,
2557
2574
  `## Constraints - trust-profile.md\n${stripTemplateNoise(readClean(eng, 'trust-profile.md')) || '(missing)'}`,
2558
2575
  `## Signer and success - success.md\nSigner: ${signer || '(missing; do not infer)'}\n${success || '(missing)'}`,
@@ -2585,8 +2602,8 @@ function cmdRecall(args) {
2585
2602
  const eng = resolveEngagement()
2586
2603
  if (!eng) { console.error('no engagement - bind a client before recall'); process.exit(2) }
2587
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']
2588
- const result = context.recallSections(files.map(file => ({ file, text: readClean(eng, file) })), query)
2589
- process.stdout.write(context.boundedSections([
2605
+ const result = context.recallSections(files.map(file => ({ file, text: readClean(eng, file) })), query, 12, masking.mask)
2606
+ process.stdout.write(maskedSections([
2590
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.`,
2591
2608
  ...result.sections,
2592
2609
  ], maxBytes))
@@ -2597,7 +2614,7 @@ function cmdCapture() {
2597
2614
  if (!eng) process.exit(0) // silent: capture must never break a session
2598
2615
  // Workspace git facts (cwd), not the engagement memory repo.
2599
2616
  const branch = sh('git branch --show-current')
2600
- const lastCommit = sh("git log -1 --format='%h %s'").slice(0, 100)
2617
+ const lastCommit = sh("git log -1 --format='%h %s'")
2601
2618
  // porcelain lines are "XY path" - sh() trims, so parse by first whitespace
2602
2619
  const changed = sh('git status --porcelain').split('\n').filter(Boolean).slice(0, 8)
2603
2620
  .map(l => l.trim().split(/\s+/).slice(1).join(' ')).join(' ')
@@ -2942,7 +2959,7 @@ function collectDoctorIssues(eng, { readiness = false } = {}) {
2942
2959
  }
2943
2960
  const dupes = findDuplicateOpenRisks(eng)
2944
2961
  if (dupes.length) {
2945
- 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)
2946
2963
  issues.push(
2947
2964
  `${dupes.length} duplicate open-risk cluster(s) (e.g. "${sample}${sample.length >= 60 ? '…' : ''}") - consolidate or retire echoes in risks.md`
2948
2965
  )
@@ -3218,7 +3235,7 @@ function hasEvalReceipt(eng) {
3218
3235
  function hygieneTriageLines(eng) {
3219
3236
  const issues = collectDoctorIssues(eng)
3220
3237
  if (!issues.length) return []
3221
- const top = issues[0].replace(/\s+/g, ' ').trim().slice(0, 72)
3238
+ const top = maskDisplay(issues[0].replace(/\s+/g, ' ').trim()).slice(0, 72)
3222
3239
  return [
3223
3240
  ` hygiene: ${issues.length} issue(s) - ${top}${issues[0].length > 72 ? '…' : ''}`,
3224
3241
  ' → say "@fde clean up the fieldbook" when ready (agent runs fde doctor; nothing auto-rewrites), or: fde doctor',
@@ -3258,14 +3275,14 @@ function recordDigest(eng) {
3258
3275
  const signer = ((success.match(/^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
3259
3276
  // "(none)" rather than a missing line: on session start, nobody named to sign
3260
3277
  // off is the fact worth seeing, not an absence to scroll past.
3261
- const lines = [` signer: ${signer.slice(0, 110) || '(none)'}`]
3278
+ const lines = [` signer: ${masking.mask(signer).slice(0, 110) || '(none)'}`]
3262
3279
  const { rows } = parseValueLedger(eng)
3263
3280
  const promisedRow = [...rows].reverse().find(r => r.promised)
3264
3281
  if (promisedRow) {
3265
- lines.push(` promised: ${formatValueLedgerLine(promisedRow).slice(0, 110)}`)
3282
+ lines.push(` promised: ${masking.mask(formatValueLedgerLine(promisedRow)).slice(0, 110)}`)
3266
3283
  } else {
3267
3284
  const target = ((success.match(/^\*\*Baseline[^\S\n]*→[^\S\n]*target:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
3268
- if (target) lines.push(` promised: ${target.slice(0, 110)}`)
3285
+ if (target) lines.push(` promised: ${masking.mask(target).slice(0, 110)}`)
3269
3286
  }
3270
3287
  const decisions = datedDecisions(readClean(eng, 'decisions.md')).slice(-2)
3271
3288
  for (const d of decisions) lines.push(` decided: ${formatDecisionRecord(d.text)}; source: ${previewLine(sourceReference(d.text) || '(missing)', 100)}`)
@@ -3320,7 +3337,7 @@ function cmdRedact(args) {
3320
3337
  }
3321
3338
  console.log(`REDACT - ${hits.length} matching line(s) for ${JSON.stringify(term)}`)
3322
3339
  hits.slice(0, 20).forEach(h => {
3323
- 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
3324
3341
  console.log(` ${h.file}:${h.lineNo} ${preview}`)
3325
3342
  })
3326
3343
  if (hits.length > 20) console.log(` … +${hits.length - 20} more`)
@@ -3365,12 +3382,12 @@ function cmdPrep(args) {
3365
3382
  const people = extractStakeholders(eng).slice(0, 8)
3366
3383
  console.log('\nStakeholders (table + signal history)')
3367
3384
  if (!people.length) console.log(' (none yet - log contacts with --signal)')
3368
- 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)}` : ''}`))
3369
3386
 
3370
3387
  const risks = extractRisks(eng).slice(0, 5)
3371
3388
  console.log('\nOpen risks (table + dated bullets)')
3372
3389
  if (!risks.length) console.log(' (none logged)')
3373
- 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)}`))
3374
3391
 
3375
3392
  const success = firstLine(readClean(eng, 'success.md'), 160)
3376
3393
  console.log('\nSuccess looks like')
@@ -3381,7 +3398,7 @@ function cmdPrep(args) {
3381
3398
  .slice(-5)
3382
3399
  console.log('\nRecent decisions')
3383
3400
  if (!decisions.length) console.log(' (none logged)')
3384
- 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)}`))
3385
3402
 
3386
3403
  const next = nextActionLine(readClean(eng, 'context.md'))
3387
3404
  console.log('\nWalk in with')
@@ -3421,7 +3438,7 @@ function cmdGarden(args) {
3421
3438
  }
3422
3439
  const dupes = findDuplicateOpenRisks(eng)
3423
3440
  if (dupes.length) {
3424
- 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)
3425
3442
  proposals.push({
3426
3443
  id: 'dedupe-risks',
3427
3444
  kind: 'apply',
@@ -3601,7 +3618,7 @@ function cmdStatus(args) {
3601
3618
  const eng = path.join(ENGAGEMENTS_ROOT, d, '.fde')
3602
3619
  if (!fs.existsSync(eng)) continue
3603
3620
  const s = computeSignals(eng)
3604
- 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)
3605
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 }) })
3606
3623
  }
3607
3624
  } else {
@@ -3611,7 +3628,7 @@ function cmdStatus(args) {
3611
3628
  process.exit(2)
3612
3629
  }
3613
3630
  const s = computeSignals(eng)
3614
- 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)
3615
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) })
3616
3633
  }
3617
3634
  if (!rows.length) { console.log('no engagements yet'); return }
@@ -4085,6 +4102,7 @@ ${fs.existsSync(html) ? `\n Open the fieldbook: ${html}` : ''}
4085
4102
  function printUsage() {
4086
4103
  console.log(`fde - deterministic core of fdeops
4087
4104
  fde demo the whole loop on a fake client (fde demo --clean removes it)
4105
+ fde privacy show masking capability and its boundaries
4088
4106
  fde scan day-1 recon of this repo (facts, no AI)
4089
4107
  fde resume load this workspace's engagement memory (bounded)
4090
4108
  fde resume --full load the complete context.md (no bound)
@@ -4123,12 +4141,32 @@ function printUsage() {
4123
4141
  ingest is a sink only - source MCPs (Granola/Gmail/…) are user-configured; never ambient sync`)
4124
4142
  }
4125
4143
 
4126
- 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) }
4127
4160
  if (args.includes('--help') || args.includes('-h') || cmd === 'help' || cmd === '--help' || cmd === '-h') {
4128
4161
  printUsage()
4129
4162
  process.exit(0)
4130
4163
  }
4164
+ try {
4131
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
4132
4170
  case 'demo': cmdDemo(args); break
4133
4171
  case 'scan': cmdScan(); break
4134
4172
  case 'resume': cmdResume(args); break
@@ -4162,3 +4200,8 @@ switch (cmd) {
4162
4200
  // Missing or unknown command must fail - exit 0 made typos look like success in scripts/hooks.
4163
4201
  process.exit(1)
4164
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
  }
@@ -0,0 +1,138 @@
1
+ 'use strict'
2
+
3
+ // Local pseudonyms, not anonymization. Recognized identifiers are replaced
4
+ // in output and proposals, and retained in a private reversible dictionary.
5
+ const fs = require('node:fs')
6
+ const path = require('node:path')
7
+ const crypto = require('node:crypto')
8
+ const { StringDecoder } = require('node:string_decoder')
9
+ const ALIAS = /\[\[(email|phone|identifier|credential):[a-f0-9]{16}\]\]/g
10
+ const PATTERNS = [
11
+ ['credential', /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?(?:-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|$)/g],
12
+ ['credential', /\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,})\b/g],
13
+ ['credential', /\b[a-z][a-z0-9+.-]*:\/\/[^/\s:]+:[^/\s@]+@[^\s<>"']+/gi],
14
+ ['credential', /\bBearer\s+[A-Za-z0-9._-]{20,}/gi],
15
+ ['credential', /\b(?:api[_-]?key|secret|password)\s*=\s*[^\s"']{8,}/gi],
16
+ ['email', /(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?){1,10}/g],
17
+ ['identifier', /\b\d{3}-\d{2}-\d{4}\b/g],
18
+ // Deliberately conservative: international + notation or explicit US shape.
19
+ // Plain integers and dates are not reliably distinguishable from business data.
20
+ ['phone', /(?<![\w])(?:\+\d{1,3}[ .-]?(?:\(\d{2,4}\)|\d{2,4})(?:[ .-]?\d){6,10}|\(\d{3}\)[ .-]?\d{3}[ .-]\d{4}|\d{3}[ .-]\d{3}[ .-]\d{4})(?!\d)/g],
21
+ ]
22
+ const FAIL = 'privacy masking unavailable: check the local .privacy directory; no unmasked output was returned'
23
+ function replacements(text, replace) {
24
+ let out = String(text)
25
+ for (const [kind, pattern] of PATTERNS) out = out.replace(pattern, value => replace(kind, value))
26
+ return out
27
+ }
28
+ function createMasking(root) {
29
+ const directory = path.join(root, '.privacy'), file = path.join(directory, 'identifiers.json')
30
+ function directoryReady(create) {
31
+ if (create) fs.mkdirSync(root, { recursive: true })
32
+ if (create) { try { fs.mkdirSync(directory, { mode: 0o700 }) } catch (e) { if (e.code !== 'EEXIST') throw e } }
33
+ const st = fs.lstatSync(directory)
34
+ if (!st.isDirectory() || st.isSymbolicLink() || (process.platform !== 'win32' && (st.mode & 0o077))) throw new Error(FAIL)
35
+ }
36
+ function read(create) {
37
+ let fd
38
+ try {
39
+ fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK)
40
+ const st = fs.fstatSync(fd)
41
+ if (!st.isFile() || st.nlink !== 1 || st.size > 8 * 1024 * 1024 || (process.platform !== 'win32' && (st.mode & 0o077))) throw new Error(FAIL)
42
+ const data = JSON.parse(fs.readFileSync(fd, 'utf8'))
43
+ if (data.version !== 1 || !Array.isArray(data.entries) || data.entries.length > 50000) throw new Error(FAIL)
44
+ const seen = new Set()
45
+ for (const entry of data.entries) {
46
+ if (!entry || typeof entry.value !== 'string' || !/^\[\[(email|phone|identifier|credential):[a-f0-9]{16}\]\]$/.test(entry.alias) || seen.has(entry.alias)) throw new Error(FAIL)
47
+ seen.add(entry.alias)
48
+ }
49
+ return data
50
+ } catch (e) { if (e.code === 'ENOENT' && create) return { version: 1, entries: [] }; throw e }
51
+ finally { if (fd !== undefined) fs.closeSync(fd) }
52
+ }
53
+ function transact(create, fn) {
54
+ let locked = false
55
+ const lock = path.join(directory, 'lock')
56
+ try {
57
+ directoryReady(create)
58
+ for (let attempt = 0; attempt < 100; attempt++) {
59
+ try { fs.mkdirSync(lock, { mode: 0o700 }); locked = true; break }
60
+ catch (e) { if (e.code !== 'EEXIST') throw e; Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10) }
61
+ }
62
+ if (!locked) throw new Error(FAIL)
63
+ const data = read(create), before = data.entries.length, result = fn(data.entries)
64
+ if (data.entries.length !== before) {
65
+ if (data.entries.length > 50000) throw new Error(FAIL)
66
+ const body = JSON.stringify(data)
67
+ if (Buffer.byteLength(body) > 8 * 1024 * 1024) throw new Error(FAIL)
68
+ const tmp = path.join(directory, `.write-${crypto.randomBytes(12).toString('hex')}`)
69
+ try { fs.writeFileSync(tmp, body, { mode: 0o600, flag: 'wx' }); fs.renameSync(tmp, file) }
70
+ finally { try { fs.unlinkSync(tmp) } catch (_) {} }
71
+ }
72
+ return result
73
+ } catch (_) { throw new Error(FAIL) }
74
+ finally { if (locked) { try { fs.rmdirSync(lock) } catch (_) {} } }
75
+ }
76
+ function mask(text) {
77
+ text = String(text)
78
+ let detected = false
79
+ replacements(text, (_, value) => { detected = true; return value })
80
+ if (!detected) return text
81
+ return transact(true, entries => {
82
+ const values = new Map(entries.map(e => [e.value, e.alias]))
83
+ return replacements(text, (kind, value) => {
84
+ if (!values.has(value)) {
85
+ const alias = `[[${kind}:${crypto.randomBytes(8).toString('hex')}]]`
86
+ entries.push({ alias, value }); values.set(value, alias)
87
+ }
88
+ return values.get(value)
89
+ })
90
+ })
91
+ }
92
+ function restore(text) {
93
+ text = String(text)
94
+ if (/\[\[(email|phone|identifier|credential):/.test(text.replace(ALIAS, ''))) throw new Error(FAIL)
95
+ if (!text.match(ALIAS)) return text
96
+ return transact(false, entries => {
97
+ const aliases = new Map(entries.map(e => [e.alias, e.value]))
98
+ return text.replace(ALIAS, alias => {
99
+ if (!aliases.has(alias)) throw new Error(FAIL)
100
+ return aliases.get(alias)
101
+ })
102
+ })
103
+ }
104
+ return { mask, restore }
105
+ }
106
+
107
+ // CLI output is finite, not an interactive stream. Buffer to catch identifiers
108
+ // split across writes, including multiline credentials. Never fall back to raw.
109
+ function protectOutput(masking, { maxBytes } = {}) {
110
+ const buffers = ['', ''], decoders = [new StringDecoder('utf8'), new StringDecoder('utf8')]
111
+ let overflow = false
112
+ for (const [index, stream] of [process.stdout, process.stderr].entries()) {
113
+ stream.write = (chunk, encoding, callback) => {
114
+ const text = typeof chunk === 'string' ? chunk : decoders[index].write(chunk)
115
+ buffers[index] += text
116
+ if (Buffer.byteLength(buffers[index]) > 16 * 1024 * 1024) { overflow = true; buffers[index] = '' }
117
+ const cb = typeof encoding === 'function' ? encoding : callback
118
+ if (cb) cb()
119
+ return true
120
+ }
121
+ }
122
+ process.once('exit', () => {
123
+ try {
124
+ if (overflow) throw new Error(FAIL)
125
+ // Prepare both before emitting either, so a failure cannot expose raw data.
126
+ const output = buffers.map((text, i) => masking.mask(text + decoders[i].end()))
127
+ if (maxBytes && Buffer.byteLength(output[0]) > maxBytes) {
128
+ const notice = '\n[Masked context truncated; retrieve a narrower topic.]\n'
129
+ let body = Buffer.from(output[0]).subarray(0, maxBytes - Buffer.byteLength(notice)).toString('utf8').replace(/\uFFFD$/, '')
130
+ const open = body.lastIndexOf('[[')
131
+ if (open > body.lastIndexOf(']]')) body = body.slice(0, open)
132
+ output[0] = body + notice
133
+ }
134
+ output.forEach((text, i) => { if (text) fs.writeSync(i + 1, text) })
135
+ } catch (_) { process.exitCode = 1; fs.writeSync(2, FAIL + '\n') }
136
+ })
137
+ }
138
+ module.exports = { createMasking, protectOutput }
package/bin/lib/memory.js CHANGED
@@ -53,7 +53,7 @@ function createMemoryApi(deps) {
53
53
  execFileSync('git', ['add', '--', f], { cwd: eng, stdio: 'ignore', timeout: 10000 })
54
54
  }
55
55
  } else {
56
- execFileSync('git', ['add', '-A'], { cwd: eng, stdio: 'ignore', timeout: 10000 })
56
+ execFileSync('git', ['add', '-A', '--', '.', ':(exclude).privacy'], { cwd: eng, stdio: 'ignore', timeout: 10000 })
57
57
  }
58
58
  const porcelain = execFileSync('git', ['status', '--porcelain'], {
59
59
  cwd: eng, encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'],
@@ -72,6 +72,9 @@ function createMemoryApi(deps) {
72
72
  })
73
73
  }
74
74
  }
75
+ // Even a previously staged alias dictionary must never enter a CLI commit.
76
+ const privateStaged = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: eng, encoding: 'utf8', timeout: 10000 }).split('\n').some(f => f.split('/').includes('.privacy'))
77
+ if (privateStaged) throw new Error('private alias state must not be staged in engagement history')
75
78
  const still = execFileSync('git', ['diff', '--cached', '--name-only'], {
76
79
  cwd: eng, encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'],
77
80
  }).toString().trim()
@@ -131,7 +134,7 @@ function createMemoryApi(deps) {
131
134
  execFileSync('git', ['init'], { cwd: eng, stdio: 'ignore', timeout: 10000 })
132
135
  atomicWriteFile(
133
136
  path.join(eng, '.gitignore'),
134
- ['*.lock', '*.tmp', '.last-write', '.debrief-propose', '.debrief-private', '.debrief-seal', ''].join('\n')
137
+ ['*.lock', '*.tmp', '.last-write', '.debrief-propose', '.debrief-private', '.debrief-seal', '.privacy/', ''].join('\n')
135
138
  )
136
139
  const owner = writeOwnerIfMissing(eng)
137
140
  configureMemoryGitIdentity(eng, owner)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops-ingest-mcp",
3
- "version": "3.28.0",
3
+ "version": "3.29.0",
4
4
  "private": true,
5
5
  "description": "Thin stdio MCP sink for FDEOps ingest (stage → propose → apply). Zero runtime dependencies.",
6
6
  "bin": {
@@ -10,6 +10,10 @@
10
10
 
11
11
  const fs = require('fs')
12
12
  const path = require('path')
13
+ const os = require('os')
14
+ const masking = require('../../bin/lib/masking').createMasking(
15
+ (process.env.FDEOPS_ENGAGEMENTS_ROOT || '').trim().replace(/^~/, os.homedir()) || path.join(os.homedir(), 'fde-engagements')
16
+ )
13
17
  const { spawnSync } = require('child_process')
14
18
 
15
19
  const PROTOCOL_VERSION = '2024-11-05'
@@ -171,7 +175,9 @@ function cliPayload(out) {
171
175
  }
172
176
 
173
177
  function toolResult(payload) {
174
- const text = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2)
178
+ let text
179
+ try { text = masking.mask(typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2)) }
180
+ catch (_) { return { isError: true, content: [{ type: 'text', text: 'privacy masking unavailable; no unmasked tool output returned' }] } }
175
181
  return { content: [{ type: 'text', text }] }
176
182
  }
177
183
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.28.0",
3
+ "version": "3.29.0",
4
4
  "description": "Client delivery tools for Forward Deployed Engineers. One @fde skill, local Markdown engagement records, and an offline dashboard for decisions, evidence, approvals, and next actions.",
5
5
  "bin": {
6
6
  "fdeops": "bin/install.js",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "fdeops",
4
- "version": "3.28.0",
4
+ "version": "3.29.0",
5
5
  "description": "Forward deployed engineering skills for AI coding agents. One @fde skill for the client work around the code. You confirm; then it lands in .fde/ on your laptop.",
6
6
  "author": {
7
7
  "name": "Subash Natarajan",
@@ -28,7 +28,7 @@ A one-line typo or compile error in a file that will not ship. On a bound client
28
28
  | **When did we agree?** | Don't argue from memory. Search the record. | `fde receipts <term>` | - |
29
29
  | **What's the outcome?** | A number nobody signed is claimed, not delivered. | `fde status` | `references/readout.md` |
30
30
 
31
- After a meeting: `fde debrief --smart` → one REVIEW screen (decisions / asks / scope / delivery gaps / next / signer) → in chat, a four-row card (omit empty; Previously / Not yet agreed) → **Save this update?** (engineer accepted the record, not customer approval of every ask) → `--apply`. Walk-in: `fde prep`. Friday: `fde status`.
31
+ After a meeting: the agent runs `fde debrief --smart`, interprets and reconciles the sanitized proposal, then validates it with `fde debrief --review`. Show the human one concise review of consequential changes and uncertainties → **Save this update?** → `--apply` only after confirmation → verify the saved facts. See `references/debrief.md` for the shared preparation contract. Walk-in: `fde prep`. Friday: `fde status`.
32
32
 
33
33
  ## Ground loop
34
34
 
@@ -69,7 +69,7 @@ Writes need a bind (`FDEOPS_ENGAGEMENT` or registry). Never install fdeops on in
69
69
  |----------|---------|
70
70
  | where are we | `fde resume` |
71
71
  | day-1 look at the repo | `fde scan` |
72
- | debrief / pasted notes | `fde debrief --smart` → REVIEW → four-row chat card → Save this update? → `--apply`. `--smart` is a gate, not a brain. `references/debrief.md` |
72
+ | debrief / pasted notes | `fde debrief --smart` → agent reconciliation → one plain-English review → Save this update? → `--apply`. `--smart` is a gate, not a brain. `references/debrief.md` |
73
73
  | prep me for … | `fde prep "<label>"` |
74
74
  | when did we agree | `fde receipts <term>` |
75
75
  | sponsor update / defend the number | `fde defend` |
@@ -211,3 +211,7 @@ Ready to build with no `terrain.md` / plan: discover or plan first. Takeover wit
211
211
  - Evidence on every claim. The FDE will be challenged on these files.
212
212
  - Overlays activate on signal, not on request.
213
213
  - Load `.fde/` files on demand, never the whole folder.
214
+
215
+ ## Identifier masking
216
+
217
+ Before reading engagement content in a session, run `fde privacy` to verify runtime support. If the command is unavailable, stop and update the CLI; a new skill alone does not upgrade an older executable. Use CLI context and previews for model input. They mask common email, phone, SSN-shaped, and credential patterns by default; aliases remain consistent within the local engagements root. Preserve complete alias tokens when drafting updates; the CLI resolves them locally. Never read the private `.privacy/` dictionary, sealed sidecars, raw sensitive notes, or local dashboard/vault files to recover an identity. Names, company names, addresses, and unrecognized formats are not automatically detected: keep sensitive prose in `<private>` blocks. Direct file tools, pasted chat, and upstream source MCPs bypass this boundary.
@@ -4,7 +4,7 @@
4
4
 
5
5
  **Large transcripts or emails** sitting in Granola/Gmail/Notion → prefer **`fde ingest stage`** first (via source MCPs the FDE configured), then the same propose → confirm → **`fde ingest apply`** path. See `references/ingest.md`. Pasted short notes stay on this debrief verb.
6
6
 
7
- **Read first:** `context.md`, `stakeholders.md` (signals against what's known).
7
+ **Read first:** the bounded `fde resume` packet for the bound client. Use `fde recall` for the specific prior decision, action, or delivery result needed to reconcile this update. Do not reload the whole engagement.
8
8
 
9
9
  **Who runs the CLI:** you (the agent). Never tell the FDE to type `fde debrief …`.
10
10
 
@@ -13,7 +13,7 @@
13
13
  - The `fde` CLI is **local, deterministic, no AI**. `--smart` is a **gate + writer**, not a brain.
14
14
  - It keeps lines that already have `decision:` / `risk:` / `delivery:` / `contact:` / `next:` / `signer:` prefixes, plus a thin keyword pass (e.g. "we agreed", person+verb lines, "open question", "X signs off").
15
15
  - `signer: Priya` fills **Stakeholder who signs off** in `success.md` and logs Priya as a contact. The CLI proposes it when a sentence says someone signs off / approves / has final say. If the notes name who can say yes and the proposal does not carry a `signer:` line, add one - that is the most expensive sentence in the meeting.
16
- - Real messy notes without prefixes often route **0 useful lines** - everything else lands as a context dump. That is expected. **You are the router:** rewrite `.debrief-propose` with type prefixes, then `--apply`.
16
+ - Heuristics can miss facts **and mislabel prefixed lines**. You interpret every candidate against the sanitized source, not just unprefixed lines. Split distinct decisions, requests, actions, and results; keep uncertainty. The user reviews meaning, never prefix syntax.
17
17
  - `.debrief-propose` is raw lines only (no routing annotations). "Edit if mis-routed" means **rewrite the line with the right prefix**, not leave a comment in the file.
18
18
 
19
19
  ## Method (you do this work)
@@ -22,25 +22,24 @@
22
22
 
23
23
  1. Save the FDE's notes to a temp `.md` file in the workspace (or pipe stdin).
24
24
  2. Run `fde debrief --smart <notes.md>` (or `npx fdeops debrief --smart …`).
25
- 3. Open `.debrief-propose`. If lines lack type prefixes, **rewrite them** before showing the FDE, e.g.:
26
- - `decision: agreed chargebacks stay phase 2 - Priya`
27
- - `decision: freeze the API [approved: Priya 2026-09-08]` (optional; missing means unconfirmed)
28
- - `risk: legal may reopen scope if we slip the SOW date`
29
- - `contact: Priya pushed hard on Friday deck [signal:amber]`
30
- - `signer: Priya` (she can say yes; lands in `success.md`)
31
- - `next: send one-pager before Thursday 9am`
32
- - unprefixed lines stay context color only
33
- 4. After editing, run `fde debrief --review` to show the pending proposal without replacing it. Show the **REVIEW** block first (decided / asked / open / next / signer). That is the one screen to confirm. File routing stays underneath.
34
- 5. In **chat**, after that REVIEW, present a four-row card and omit empty rows:
35
- - Decided
36
- - Asked / open
37
- - Next
38
- - Signer
39
- Then a **Previously:** line from the record, and **Not yet agreed** for anything still proposed. Ask **Save this update?** Saving means the engineer accepted this as the engagement record, not that the customer approved every ask. Uncertainty stays visible.
40
- 6. On FDE confirm → run `fde debrief --apply`.
41
- 7. On reject → stop; ask what to change; do not apply. Do not rebuild or replace the CLI REVIEW engine.
42
-
43
- No invented names or quotes. If the propose looks wrong, fix prefixes with judgment then re-apply or use the fallback path.
25
+ 3. Run `fde debrief --review` before opening an existing proposal so legacy identifiers are masked. Open the proposal only after review succeeds. Never open a proposal containing manually inserted raw private blocks. Prepare the pending proposal using **Prepare one update** below. Read only the sanitized `.debrief-propose`, never the sealed private sidecars or raw private source. Preserve privacy markers, source metadata, and complete identifier aliases such as `[[email:...]]`. The CLI restores known aliases locally on apply. Never read `.privacy/` or try to recover an identity with file tools. If an alias is truncated, retrieve a narrower excerpt; never guess or edit the token.
26
+ 4. Run `fde debrief --review` after editing. Treat the CLI REVIEW and routing output as your validation, not a second presentation to the user. Resolve errors and replay warnings before asking for confirmation.
27
+ 5. Show **one** concise review in chat: name the client, then the consequential changes in plain English. Include decisions, requests still unagreed, actions, reported delivery, signer or contact changes, and unresolved conflicts when present. Show the previous value only where it changes the meaning. Omit empty categories and CLI routing details; do not impose a fixed four-row card that hides other changes. If the proposal is too large to show faithfully, split the review into explicit batches; never approve hidden changes.
28
+ 6. Ask **Save this update?** This confirms the engineer's record, not customer acceptance. On confirmation, apply precisely that proposal with `fde debrief --apply`. A material correction requires a revised review and renewed confirmation. On rejection, leave the proposal pending and do not apply.
29
+ 7. Verify the changed facts through bounded `fde resume` / targeted `fde recall`. If a fieldbook is part of the current task, regenerate it using the existing command and destination after the confirmed save; do not make the user run it. End with a brief saved/not-saved result and the next action, not another full summary.
30
+
31
+ ### Prepare one update (shared with ingest)
32
+
33
+ Do this work yourself before the human review:
34
+
35
+ - **Check meaning, not keywords.** “We settled on delaying the rewrite” is a decision; “Mara will request access” is an action, even if the heuristic calls it a contact. A wish or suggestion remains a request, not agreement. Do not infer authority, approval, a calendar date from an unanchored relative date, or production value from staging.
36
+ - **Keep facts traceable.** Preserve supplied source locators on each consequential fact, using `[source: ...]`. If only a local file or staged item exists, cite that actual locator as a note source, not a customer receipt. Do not invent a meeting date or speaker. A source label is not authenticated approval.
37
+ - **Reconcile only what changed.** Compare affected facts with the current record using targeted retrieval. Leave unchanged sourced statements out of an accidental re-import. Preserve earlier history; record changed or conflicting claims explicitly. If everything is already recorded, say so and leave the pending proposal unapplied. If it blocks a later capture, explain that no new facts were saved and ask permission to replace that pending review; use `--replace-proposal` with the new notes only after that authorization. Do not delete proposal files or private sidecars manually. Do not use `--allow-replay` without explicit approval of an intentional repeat.
38
+ - **Protect the current next action.** A late meeting note does not automatically supersede a newer action. Keep older actions as dated context unless their current priority is established; show a conflict when it needs a decision. Use exactly one physical `next:` line for the current action. If multiple current actions are explicitly agreed, include them on that same line separated by semicolons; the CLI retains only the last `next:` line. Keep other dated commitments in context. Do not silently discard other commitments.
39
+ - **Keep memory useful.** Retain consequential facts and indispensable context; remove chatter and repetition from the proposal, not from the source. Preserve the raw input outside `.fde/` (staged material stays in `.inbox/`). Never remove privacy placeholders or modify sealed sidecars. Ask only about a consequential ambiguity that cannot remain explicitly unknown.
40
+ - **Structure the result.** Use `decision:` / `risk:` / `delivery:` / `contact:` / `next:` / `signer:`. Preserve `ask:` / `scope:` as explicitly proposed context when appropriate. Prepare the seven-field delivery row yourself for a reported result (see below); unknown fields stay `pending`. The human should not have to fill out a ledger to capture a meeting.
41
+
42
+ Before showing the review, check that every consequential fact in the sanitized source is represented, already recorded, or explicitly unresolved. Check classified lines as carefully as unclassified ones. Nothing is saved simply because this preparation is complete.
44
43
 
45
44
  ### Fallback - you structure, then route
46
45
 
@@ -53,10 +52,10 @@ If `--smart` is unavailable or you already have clean prefixes:
53
52
  - **Risks** - new / confirmed / retired
54
53
  - **Open questions** - what to chase next
55
54
  2. Format lines as `decision:` / `risk:` / `delivery:` / `contact:` / `next:` / `signer:` (contacts may end with `[signal:green|amber|red]`).
56
- 3. Show the same **chat card** as the smart path (omit empty rows; Previously; Not yet agreed; **Save this update?**). Do not invent a second confirm surface.
55
+ 3. Follow **Prepare one update** and show the same single plain-English review as the preferred path. Include every consequential change and ask **Save this update?**.
57
56
  4. On confirm, pipe to `fde debrief` (or write a file and run it).
58
57
 
59
- One clarifying question max if the dump is ambiguous - then write. Never stall capture on completeness.
58
+ Ask at most one focused question at a time when ambiguity would change the record. Otherwise preserve the unknown and include it in the review. Never treat silence as confirmation.
60
59
 
61
60
  ## Artifact
62
61
 
@@ -66,7 +65,7 @@ One clarifying question max if the dump is ambiguous - then write. Never stall c
66
65
 
67
66
  ## Checkpoint
68
67
 
69
- Read back the 2-3 most consequential captures in one breath - so the FDE can correct on the spot. Then stop. No summary theatre.
68
+ Use the single pre-save review above. After saving, report verification and the next action briefly; do not ask for a second approval or repeat the review.
70
69
 
71
70
  ## Principles
72
71
 
@@ -108,7 +108,7 @@ Flag every one. AI components don't fail like regular code - they degrade as the
108
108
 
109
109
  **6. Data flow.** Where data enters, how it moves, where it stops. Entry points first: routes, queues, cron, file drops.
110
110
 
111
- **7. Existing capability.** Trace the requested user action through existing code, configuration, tests, and operating workarounds. In `terrain.md`, record what can already be reused and the evidence that it works or fails. Check whether a configuration, ownership, or process change could resolve the observed break. A disabled feature is a lead, not a proven root cause. Keep observations and hypotheses distinct; option selection still belongs to plan / three-options.
111
+ **7. Existing capability.** Trace the requested user action through existing code, configuration, tests, and operating workarounds. In `terrain.md`, record what can already be reused and the evidence that it works or fails. Check whether a configuration, ownership, or process change could resolve the observed break. A disabled feature is a lead, not a proven root cause. Keep observations and hypotheses distinct; option selection still belongs to plan / three-options. Summarize the remaining gap in `reality.md`: what works today → what the customer needs → what is still missing, with sources. If existing capability meets the need, say so; do not manufacture a build requirement.
112
112
 
113
113
  ## Method - part 2: the humans (you coach, the FDE asks)
114
114
 
@@ -150,7 +150,7 @@ Always map the estate before you score a use case - not only when someone said "
150
150
  **The 5 questions (ask the data owner, not the sponsor):**
151
151
  1. **Where does data live?** - List every source: databases, warehouses, SaaS exports, spreadsheets, S3 buckets, vendor APIs. Map it.
152
152
  2. **How fresh is it?** - Real-time, daily batch, "someone uploads a CSV on Mondays"? Freshness determines what's buildable.
153
- 3. **Who owns it?** - Not "IT" - the named person who can grant access and explain the schema. No name = no access in practice.
153
+ 3. **Who owns it?** - Not "IT" - the named person who can grant access and explain the schema. No named owner: access responsibility remains unverified.
154
154
  4. **What's the quality?** - Sample 100 rows from each critical source. Check: nulls, duplicates, format consistency, semantic correctness. A 60% null rate in a key field = that source is fiction.
155
155
  5. **What are the governance constraints?** - PII classification, retention policies, cross-border rules, consent basis. One missed constraint = a compliance stop later.
156
156
 
@@ -164,6 +164,10 @@ Always map the estate before you score a use case - not only when someone said "
164
164
 
165
165
  A use case that depends on a "Blocker" source **or a Blocker pipe** doesn't get scored - it gets a remediation conversation first. `what-breaks` finding an invisible integration at ship is already too late. Write this to `terrain.md` under a `## Data estate` section.
166
166
 
167
+ **Promised dependencies are not ready dependencies.** For consequential promises such as "data in two weeks," record or update one dependency entry in `assumptions.md` with the responsible owner, dated verification checkpoint, and evidence needed. Unknown owners or dates stay unknown; propose a checkpoint for confirmation. Link the affected work; if the checkpoint slips, identify what can proceed and what needs replanning. Missing ownership is an unresolved dependency, not proof that the project will fail. On-prem or restricted access is a constraint to investigate, not a red flag by itself.
168
+
169
+ **Verify the future operator now.** Check the proposed owner in `success.md` against who will actually monitor, recover, and support the result. Record whether they have agreed, access or training gaps, and a practical handoff check there; carry these into `handoff.md` at close. Keep unconfirmed ownership explicit. Reuse supplied evidence and ask only what changes the plan.
170
+
167
171
  ## When scope is a transformation, not a single problem
168
172
 
169
173
  Score every candidate use case before anything gets prototyped:
@@ -4,7 +4,7 @@
4
4
 
5
5
  **Connect / capability (different entry):** "connect a new MCP", "connect Granola/Slack/Notion", "what can you pull?" → `references/connect.md` first. Recipes: `mcp/recipes/` (file, granola, slack, notion).
6
6
 
7
- **Read first:** `context.md` (what's already logged, what's stale). Bind the engagement before staging anything.
7
+ **Read first:** the bounded `fde resume` packet and targeted recall for affected prior facts. Bind the engagement before staging anything.
8
8
 
9
9
  **Who runs the CLI:** you (the agent). Never tell the FDE to type `fde ingest …`. Never auto-apply. Never background-sync or poll sources on your own.
10
10
 
@@ -31,9 +31,9 @@ List what you can actually call **this session**:
31
31
  3. **Stage** - `fde ingest stage [--source NAME] [--title TEXT] [file|-]` writes raw text into `<engagement>/.inbox/` (outside the memory git ledger).
32
32
  4. **List** (optional) - `fde ingest list` shows staged items when you need an id or filename.
33
33
  5. **Propose** - `fde ingest propose <id-or-filename>` runs the debrief `--smart` path on the staged body (+ provenance line). Opens `.debrief-propose`.
34
- 6. **Rewrite prefixes** - same as debrief: lines without `decision:` / `risk:` / `delivery:` / `contact:` / `next:` / `signer:` need **you** to rewrite before showing the FDE. `--smart` is a gate, not a brain.
35
- 7. **Show** the same chat card as debrief (decided / asked / open / next / signer; omit empty; Previously; Not yet agreed; **Save this update?**). Wait for confirm. The CLI REVIEW printout is unchanged.
36
- 8. **Apply** - on FDE confirm only → `fde ingest apply` (= `fde debrief --apply`). On reject → stop; ask what to change.
34
+ 6. **Prepare** - follow **Prepare one update** in `references/debrief.md`. Interpret every sanitized candidate, including already-prefixed lines; reconcile changed facts, preserve source locators, and keep raw chatter out of memory. Preserve privacy placeholders and sealed sidecars.
35
+ 7. **Validate and show** - run `fde debrief --review` after editing, then show the same single plain-English review as debrief, including delivery changes and conflicts. Ask **Save this update?** and wait for confirmation. CLI output is agent validation, not a second user review.
36
+ 8. **Apply and verify** - on FDE confirm only → `fde ingest apply` (= `fde debrief --apply`), then verify affected facts through bounded resume/recall. Refresh the current fieldbook if it is part of this task. On reject, leave the proposal pending; material edits require a revised review.
37
37
 
38
38
  No invented names, meetings, or quotes. If the propose looks wrong, fix prefixes with judgment, then re-show before apply.
39
39
 
@@ -56,7 +56,7 @@ fde ingest apply
56
56
 
57
57
  ## Provenance
58
58
 
59
- When a staged fact came from a named source, carry `via:<source>` on the applied line where useful (e.g. `via:granola`, `via:gmail`). Helps receipts and sponsor disputes later - not mandatory on every context line.
59
+ Carry an actual `[source: ...]` locator on each consequential fact. Preserve upstream IDs or links when supplied; otherwise cite the staged item path as a note source. Retain its `via:` metadata, but do not treat a standalone `via:` line as a source marker for every fact or as proof of approval. Re-imports still require semantic comparison; exact replay protection is not semantic deduplication.
60
60
 
61
61
  ## MCP sink + recipes
62
62
 
@@ -64,7 +64,7 @@ Optional `mcp/fdeops-ingest` wraps the same verbs over stdio. Source MCPs remain
64
64
 
65
65
  ## Checkpoint
66
66
 
67
- Before apply, read back the 2-3 most consequential captures in one breath - same as debrief. Confirm which sources you staged and what would land in the record. Then stop.
67
+ Use the single review from debrief: identify the client and sources, show consequential changes, then wait. Do not add another summary or approval step.
68
68
 
69
69
  ## Principles
70
70
 
@@ -57,6 +57,7 @@ Intent: coach the FDE's first *customer* conversation - what keeps the sponsor u
57
57
  - "Before you open the laptop - what would make this a bad engagement for *them*, not just a delayed project?"
58
58
  - "What are they afraid you'll miss?"
59
59
  - "Who loses credibility if this goes wrong?"
60
+ - "If nothing changes over the agreed timeframe, what happens, and who bears it?" Record the consequence and its source in `brief.md`; distinguish reported impact from measured cost. Unknown cost stays unknown, not an invented ROI.
60
61
 
61
62
  Let silence sit. If their fear doesn't match the written brief, the brief is wrong - say so plainly, log it.
62
63
 
@@ -67,6 +68,7 @@ Let silence sit. If their fear doesn't match the written brief, the brief is wro
67
68
  - **The sacred thing** - "Is there anything in this environment I should treat as untouchable?" The hesitation before the answer is the answer.
68
69
  - **Exception path (operating map seed)** - "When the happy path breaks this week, what do people actually do - who do they call, what spreadsheet opens, what do they skip?" Capture the break → workaround → who owns it. Do not build a full map on day 1; seed rows later in `terrain.md` → `## Operating map (exception-led)` during discover. Unknowns stay `unknown - ask:`.
69
70
  - **AI posture and policy** - tools already in use (sanctioned or shadow), and: "Does your organisation have a policy on AI-generated code? Are there decisions where you would not be comfortable with AI involvement?"
71
+ - **Future operator** - "Who will run this after we leave, and have they agreed?" Record the proposed operator and unresolved ownership in `success.md`, separately from the signer. A sponsor naming a team is not that team accepting responsibility; verify with the operator during discover.
70
72
  - **Boundaries in multi-vendor rooms** - who owns what surface, who signs off before a change crosses it.
71
73
 
72
74
  ## The day 1 deliverable