fdeops 3.28.0 → 3.30.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
@@ -56,6 +56,8 @@ Read-only HTML of the record - promised, measured, accepted, and evidence. Regen
56
56
  /plugin install fdeops@fdeops
57
57
  ```
58
58
 
59
+ Make it fit your day: `fde setup` asks three choices for your daily overview, context size, and report masking. [First-use setup](docs/USAGE.md#make-fdeops-fit-your-day).
60
+
59
61
  The plugin adds session hooks and the slash commands below. Skill-only installation does not add hooks. See the [installation guide](docs/install.md) for setup details.
60
62
 
61
63
  </details>
@@ -247,7 +249,7 @@ If your work has no client commitments or operating handover to track, a simpler
247
249
 
248
250
  The CLI works with local files and Git, without network calls or telemetry. Client records remain readable Markdown if you stop using FDEOps.
249
251
 
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.
252
+ 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
253
 
252
254
  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
255
 
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,31 @@ 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
+ const setup = require('./lib/setup')
50
+ const setupStore = setup.createSetup(ENGAGEMENTS_ROOT)
51
+ let preferences = setup.DEFAULTS
52
+ function contextBudget(args) {
53
+ return context.budgetArgs(args, preferences.context === 'compact' ? 4096 : 16384)
54
+ }
55
+ function portfolioView(args) {
56
+ if (args.includes('--all') && args.includes('--current')) throw new Error('Choose --all or --current, not both.')
57
+ return args.includes('--all') || (!args.includes('--current') && preferences.view === 'portfolio')
58
+ }
59
+ function maskDisplay(text) {
60
+ return ['dashboard', 'vault'].includes(process.argv[2]) && preferences.privacy !== 'reports' ? String(text) : masking.mask(text)
61
+ }
62
+ // Classify the original record first; aliases must never become evidence or a signer.
63
+ function maskReport(value) {
64
+ if (preferences.privacy !== 'reports') return value
65
+ if (typeof value === 'string') return masking.mask(value)
66
+ if (Array.isArray(value)) return value.map(maskReport)
67
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, maskReport(item)]))
68
+ return value
69
+ }
70
+ function maskedSections(sections, maxBytes) {
71
+ return context.boundedSections(sections.map(text => masking.mask(text)), maxBytes)
72
+ }
48
73
  const DEBRIEF_MAX_BYTES = 256 * 1024
49
74
  const CODE_EXT = ['.js', '.ts', '.tsx', '.jsx', '.py', '.java', '.go', '.rb', '.cs', '.php']
50
75
  const CONF_EXT = CODE_EXT.concat(['.env', '.yaml', '.yml', '.json'])
@@ -114,7 +139,7 @@ function grepFiles(files, regex, cap) {
114
139
  try { text = fs.readFileSync(f, 'utf8') } catch (_) { continue }
115
140
  const lines = text.split('\n')
116
141
  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) })
142
+ 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
143
  }
119
144
  }
120
145
  return hits
@@ -798,7 +823,7 @@ function firstLine(md, maxLen) {
798
823
  for (const raw of md.split('\n')) {
799
824
  const l = raw.trim()
800
825
  if (!l || /^#{1,6}\s/.test(l)) continue
801
- const clean = l.replace(/^\*\*[^*]+:\*\*\s*/, '').replace(/\*\*/g, '').replace(/^["']|["']$/g, '').trim()
826
+ const clean = maskDisplay(l.replace(/^\*\*[^*]+:\*\*\s*/, '').replace(/\*\*/g, '').replace(/^["']|["']$/g, '').trim())
802
827
  if (!clean) continue
803
828
  return clean.length > maxLen ? clean.slice(0, maxLen - 1).trim() + '…' : clean
804
829
  }
@@ -811,7 +836,7 @@ function firstLine(md, maxLen) {
811
836
  function parseReality(md, maxLen) {
812
837
  const theory = (md.match(/\*\*Working theory:\*\*[^\S\r\n]*(.*)/i) || [])[1]
813
838
  const hasSchema = /\*\*(Working theory|Evidence|Differs from brief how):\*\*/i.test(md)
814
- const theoryText = (theory || '').trim()
839
+ const theoryText = maskDisplay((theory || '').trim())
815
840
  if (theoryText) {
816
841
  const line = theoryText.length > maxLen ? theoryText.slice(0, maxLen - 1).trim() + '…' : theoryText
817
842
  return { line, missing: '' }
@@ -983,7 +1008,7 @@ const {
983
1008
  countOpenRisks,
984
1009
  } = createTrustApi({
985
1010
  fs, path, readClean, readEng, parseMdTable, sectionBody, SIGNAL_LEDGER, memoryDirtyManual,
986
- stripTemplateNoise, stripLegendLines, extractRisks,
1011
+ stripTemplateNoise, stripLegendLines, extractRisks, maskDisplay,
987
1012
  })
988
1013
 
989
1014
  // Stakeholders: columns are matched by header wording, not position - real
@@ -1016,6 +1041,7 @@ function parseSignalHistoryEntries(eng) {
1016
1041
  }
1017
1042
 
1018
1043
  function displayNameFromSignalText(text) {
1044
+ if (preferences.privacy === 'reports' && masking.mask(text) !== text) return 'Contact (identifier masked)'
1019
1045
  const person = personFromSignalText(text)
1020
1046
  if (person) return person
1021
1047
  const t = String(text).trim()
@@ -1081,14 +1107,14 @@ function extractStakeholders(eng) {
1081
1107
  for (const [key, h] of latest) {
1082
1108
  if (byKey.has(key)) {
1083
1109
  const cur = byKey.get(key)
1084
- byKey.set(key, { ...cur, signal: h.signal, note: cur.note || h.text.slice(0, 80) })
1110
+ byKey.set(key, { ...cur, signal: h.signal, note: cur.note || maskDisplay(h.text).slice(0, 80) })
1085
1111
  } else {
1086
1112
  const name = displayNameFromSignalText(h.text)
1087
1113
  if (!name || isSignalNameNoise(name)) continue
1088
1114
  byKey.set(key, {
1089
1115
  name,
1090
1116
  role: '',
1091
- note: h.text.slice(0, 80),
1117
+ note: maskDisplay(h.text).slice(0, 80),
1092
1118
  signal: h.signal,
1093
1119
  source: 'signal',
1094
1120
  })
@@ -1155,7 +1181,7 @@ function extractStats(eng) {
1155
1181
  const key = from + '→' + to
1156
1182
  if (seen.has(key)) continue
1157
1183
  seen.add(key)
1158
- const pre = text.slice(Math.max(0, m.index - 40), m.index)
1184
+ const pre = maskDisplay(text.slice(0, m.index)).slice(-40)
1159
1185
  const label = pre.split(/\s+/).filter(Boolean).slice(-3).join(' ').replace(/^[,:;.\-]+|[,:;.\-]+$/g, '') || 'metric'
1160
1186
  stats.push({ label, from, to })
1161
1187
  }
@@ -1308,7 +1334,7 @@ function cmdScan() {
1308
1334
 
1309
1335
  function cmdResume(args) {
1310
1336
  let maxBytes
1311
- try { ({ args, maxBytes } = context.budgetArgs(args)) } catch (e) { console.error(e.message); process.exit(2) }
1337
+ try { ({ args, maxBytes } = contextBudget(args)) } catch (e) { console.error(e.message); process.exit(2) }
1312
1338
 
1313
1339
  const initIdx = args.indexOf('--init')
1314
1340
  if (initIdx !== -1) {
@@ -1393,6 +1419,10 @@ function cmdResume(args) {
1393
1419
  const head = memoryHead(fdeDir)
1394
1420
  console.log(`memory git: ${head || 'ready'}${owner ? ` owner: ${owner.email}` : ''}`)
1395
1421
  }
1422
+ if (!setupStore.read()) {
1423
+ if (process.stdin.isTTY && process.stdout.isTTY) return setup.command(setupStore, [])
1424
+ console.log('Make FDEOps fit your day: run fde setup once for three short choices. Agents: ask these after binding; do not guess the answers.')
1425
+ }
1396
1426
  return
1397
1427
  }
1398
1428
  if (args[0] === '--bind') {
@@ -1426,7 +1456,7 @@ function cmdResume(args) {
1426
1456
  const policy = readClean(eng, 'trust-profile.md')
1427
1457
  const success = readClean(eng, 'success.md')
1428
1458
  const risks = readClean(eng, 'risks.md')
1429
- process.stdout.write(context.boundedSections([
1459
+ process.stdout.write(maskedSections([
1430
1460
  policy ? `CLIENT POLICY - trust-profile.md\n${policy}` : '',
1431
1461
  `${intro}\n\nENGAGEMENT: ${eng}`,
1432
1462
  success ? `CURRENT GOALS & ACCEPTANCE - success.md\n${success}` : '',
@@ -1832,6 +1862,7 @@ function readDebriefInput(args) {
1832
1862
  }
1833
1863
 
1834
1864
  function previewLine(text, max = 240) {
1865
+ text = masking.mask(text)
1835
1866
  const t = String(text || '').replace(/\s+/g, ' ').trim()
1836
1867
  if (t.length <= max) return t
1837
1868
  return `${t.slice(0, max)}… (${t.length} chars)`
@@ -1845,7 +1876,8 @@ function writeProposal(eng, text, { replace = false, locked = false } = {}) {
1845
1876
  finally { ownedDebriefLocks.delete(path.join(eng, DEBRIEF_PROPOSE)) }
1846
1877
  }, { soft: true })
1847
1878
  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 })
1879
+ const { clean: original, blocks } = splitPrivate(text, { sealDangling: true })
1880
+ const clean = masking.mask(original)
1849
1881
  const proposePath = path.join(eng, DEBRIEF_PROPOSE)
1850
1882
  const privatePath = path.join(eng, DEBRIEF_PRIVATE)
1851
1883
  if (fs.existsSync(proposePath) && !replace) {
@@ -1887,6 +1919,7 @@ function stripApprovedStamp(text) {
1887
1919
  // One screen a human can confirm in two minutes. The file-by-file routing
1888
1920
  // still prints after this - agents edit prefixes; people read this.
1889
1921
  function printDebriefReview(text, eng) {
1922
+ text = masking.restore(text)
1890
1923
  const repeats = repeatedDebriefStatements(eng, text)
1891
1924
  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
1925
  const buckets = { decided: [], asked: [], scope: [], delivery: [], open: [], next: [], signer: [] }
@@ -2091,6 +2124,8 @@ function repeatedDebriefStatements(eng, input) {
2091
2124
  }
2092
2125
 
2093
2126
  function routeDebriefInput(eng, input, { dry, force, sealed = [], allowReplay = false }) {
2127
+ input = masking.restore(input)
2128
+ masking.mask(splitPrivate(input, { sealDangling: true }).clean)
2094
2129
  if (!dry && !debriefTransactionActive) {
2095
2130
  ensureMemoryGit(eng)
2096
2131
  return withDebriefRecords(eng, () => {
@@ -2207,7 +2242,11 @@ function runDebrief(args, eng) {
2207
2242
  const refused = refuseSymlinkWrite(proposal, { soft: true })
2208
2243
  if (refused) throw new Error(refused)
2209
2244
  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 })
2245
+ const stored = fs.readFileSync(proposal, 'utf8')
2246
+ 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.')
2247
+ const { clean: input } = splitPrivate(masking.restore(stored), { sealDangling: true })
2248
+ const safe = masking.mask(input)
2249
+ if (safe !== stored) atomicWriteFile(proposal, safe, { mode: 0o600, soft: true })
2211
2250
  return boundedDebriefPreview(eng, () => {
2212
2251
  printDebriefReview(input, eng)
2213
2252
  routeDebriefInput(eng, input, { dry: true, force: false })
@@ -2244,6 +2283,7 @@ function runDebrief(args, eng) {
2244
2283
  console.error('nothing to apply - run: fde debrief --smart <notes.md> then fde debrief --apply')
2245
2284
  return void (process.exitCode = 1)
2246
2285
  }
2286
+ input = masking.restore(input)
2247
2287
  sealed = readSealedProposal(eng)
2248
2288
  const expected = readSealCount(eng)
2249
2289
  if (expected === null ? (!sealed.length && input.includes(PRIVATE_MARKER)) : sealed.length < expected) {
@@ -2465,7 +2505,7 @@ function cmdIngest(args) {
2465
2505
  const body = [
2466
2506
  '---',
2467
2507
  `source: ${source}`,
2468
- `title: ${title.replace(/\n/g, ' ').slice(0, 120)}`,
2508
+ `title: ${title.replace(/\n/g, ' ')}`,
2469
2509
  `staged: ${new Date().toISOString()}`,
2470
2510
  `id: ${id}`,
2471
2511
  '---',
@@ -2498,7 +2538,7 @@ function cmdReceipts(args) {
2498
2538
  document.split('\n').forEach((line, i) => {
2499
2539
  if (!line.toLowerCase().includes(term.toLowerCase())) return
2500
2540
  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' : ''}`
2541
+ 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
2542
  ;(recordFiles.includes(file) && source ? records : claims).push({ file, hit })
2503
2543
  })
2504
2544
  }
@@ -2524,14 +2564,14 @@ function cmdReceipts(args) {
2524
2564
  if (records.length) sections.push('ON RECORD (dated, source-backed):\n' + select(records))
2525
2565
  if (claims.length) sections.push('CLAIMS & working notes (verify source and approval before citing):\n' + select(claims))
2526
2566
  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))
2567
+ process.stdout.write(maskedSections(sections))
2528
2568
  }
2529
2569
 
2530
2570
  // Portable snapshot; stdout is read-only. --out creates a new file and never
2531
2571
  // overwrites an engagement record, existing file, or symlink.
2532
2572
  function cmdHandoff(args, label = 'Handoff') {
2533
2573
  let parsed
2534
- try { parsed = context.budgetArgs(args) } catch (e) { console.error(e.message); process.exit(1) }
2574
+ try { parsed = contextBudget(args) } catch (e) { console.error(e.message); process.exit(1) }
2535
2575
  let out = ''
2536
2576
  if (parsed.args.length) {
2537
2577
  if (parsed.args.length !== 2 || parsed.args[0] !== '--out' || !parsed.args[1] || parsed.args[1].startsWith('--')) {
@@ -2552,7 +2592,7 @@ function cmdHandoff(args, label = 'Handoff') {
2552
2592
  const decisionText = d => `${d.text} (decisions.md:${d.line}, redacted view)`
2553
2593
  const next = stripTemplateNoise(sectionBody(readClean(eng, 'context.md'), 'Next action', { lastNonEmpty: true }))
2554
2594
  const gaps = collectDoctorIssues(eng, { readiness: true })
2555
- const report = context.boundedSections([
2595
+ const report = maskedSections([
2556
2596
  `# ${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
2597
  `## Constraints - trust-profile.md\n${stripTemplateNoise(readClean(eng, 'trust-profile.md')) || '(missing)'}`,
2558
2598
  `## Signer and success - success.md\nSigner: ${signer || '(missing; do not infer)'}\n${success || '(missing)'}`,
@@ -2577,7 +2617,7 @@ function cmdHandoff(args, label = 'Handoff') {
2577
2617
 
2578
2618
  function cmdRecall(args) {
2579
2619
  let maxBytes
2580
- try { ({ args, maxBytes } = context.budgetArgs(args)) } catch (e) { console.error(e.message); process.exit(2) }
2620
+ try { ({ args, maxBytes } = contextBudget(args)) } catch (e) { console.error(e.message); process.exit(2) }
2581
2621
  const query = args.join(' ').trim()
2582
2622
  if (!query || Buffer.byteLength(query) > 2048 || args.some(a => a.startsWith('--'))) {
2583
2623
  console.error('usage: fde recall <topic> [--max-bytes 4096..65536]'); process.exit(2)
@@ -2585,8 +2625,8 @@ function cmdRecall(args) {
2585
2625
  const eng = resolveEngagement()
2586
2626
  if (!eng) { console.error('no engagement - bind a client before recall'); process.exit(2) }
2587
2627
  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([
2628
+ const result = context.recallSections(files.map(file => ({ file, text: readClean(eng, file) })), query, 12, masking.mask)
2629
+ process.stdout.write(maskedSections([
2590
2630
  `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
2631
  ...result.sections,
2592
2632
  ], maxBytes))
@@ -2597,7 +2637,7 @@ function cmdCapture() {
2597
2637
  if (!eng) process.exit(0) // silent: capture must never break a session
2598
2638
  // Workspace git facts (cwd), not the engagement memory repo.
2599
2639
  const branch = sh('git branch --show-current')
2600
- const lastCommit = sh("git log -1 --format='%h %s'").slice(0, 100)
2640
+ const lastCommit = sh("git log -1 --format='%h %s'")
2601
2641
  // porcelain lines are "XY path" - sh() trims, so parse by first whitespace
2602
2642
  const changed = sh('git status --porcelain').split('\n').filter(Boolean).slice(0, 8)
2603
2643
  .map(l => l.trim().split(/\s+/).slice(1).join(' ')).join(' ')
@@ -2942,7 +2982,7 @@ function collectDoctorIssues(eng, { readiness = false } = {}) {
2942
2982
  }
2943
2983
  const dupes = findDuplicateOpenRisks(eng)
2944
2984
  if (dupes.length) {
2945
- const sample = (dupes[0][0] || '').replace(/\s+/g, ' ').trim().slice(0, 60)
2985
+ const sample = maskDisplay((dupes[0][0] || '').replace(/\s+/g, ' ').trim()).slice(0, 60)
2946
2986
  issues.push(
2947
2987
  `${dupes.length} duplicate open-risk cluster(s) (e.g. "${sample}${sample.length >= 60 ? '…' : ''}") - consolidate or retire echoes in risks.md`
2948
2988
  )
@@ -3218,7 +3258,7 @@ function hasEvalReceipt(eng) {
3218
3258
  function hygieneTriageLines(eng) {
3219
3259
  const issues = collectDoctorIssues(eng)
3220
3260
  if (!issues.length) return []
3221
- const top = issues[0].replace(/\s+/g, ' ').trim().slice(0, 72)
3261
+ const top = maskDisplay(issues[0].replace(/\s+/g, ' ').trim()).slice(0, 72)
3222
3262
  return [
3223
3263
  ` hygiene: ${issues.length} issue(s) - ${top}${issues[0].length > 72 ? '…' : ''}`,
3224
3264
  ' → say "@fde clean up the fieldbook" when ready (agent runs fde doctor; nothing auto-rewrites), or: fde doctor',
@@ -3258,14 +3298,14 @@ function recordDigest(eng) {
3258
3298
  const signer = ((success.match(/^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
3259
3299
  // "(none)" rather than a missing line: on session start, nobody named to sign
3260
3300
  // off is the fact worth seeing, not an absence to scroll past.
3261
- const lines = [` signer: ${signer.slice(0, 110) || '(none)'}`]
3301
+ const lines = [` signer: ${masking.mask(signer).slice(0, 110) || '(none)'}`]
3262
3302
  const { rows } = parseValueLedger(eng)
3263
3303
  const promisedRow = [...rows].reverse().find(r => r.promised)
3264
3304
  if (promisedRow) {
3265
- lines.push(` promised: ${formatValueLedgerLine(promisedRow).slice(0, 110)}`)
3305
+ lines.push(` promised: ${masking.mask(formatValueLedgerLine(promisedRow)).slice(0, 110)}`)
3266
3306
  } else {
3267
3307
  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)}`)
3308
+ if (target) lines.push(` promised: ${masking.mask(target).slice(0, 110)}`)
3269
3309
  }
3270
3310
  const decisions = datedDecisions(readClean(eng, 'decisions.md')).slice(-2)
3271
3311
  for (const d of decisions) lines.push(` decided: ${formatDecisionRecord(d.text)}; source: ${previewLine(sourceReference(d.text) || '(missing)', 100)}`)
@@ -3320,7 +3360,7 @@ function cmdRedact(args) {
3320
3360
  }
3321
3361
  console.log(`REDACT - ${hits.length} matching line(s) for ${JSON.stringify(term)}`)
3322
3362
  hits.slice(0, 20).forEach(h => {
3323
- const preview = h.line.length > 100 ? h.line.slice(0, 97) + '…' : h.line
3363
+ const preview = h.line.length > 100 ? masking.mask(h.line).slice(0, 97) + '…' : h.line
3324
3364
  console.log(` ${h.file}:${h.lineNo} ${preview}`)
3325
3365
  })
3326
3366
  if (hits.length > 20) console.log(` … +${hits.length - 20} more`)
@@ -3365,12 +3405,12 @@ function cmdPrep(args) {
3365
3405
  const people = extractStakeholders(eng).slice(0, 8)
3366
3406
  console.log('\nStakeholders (table + signal history)')
3367
3407
  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)}` : ''}`))
3408
+ else people.forEach(p => console.log(` [${p.signal}] ${p.name}${p.role ? ` - ${p.role}` : ''}${p.note ? ` · ${masking.mask(p.note).slice(0, 60)}` : ''}`))
3369
3409
 
3370
3410
  const risks = extractRisks(eng).slice(0, 5)
3371
3411
  console.log('\nOpen risks (table + dated bullets)')
3372
3412
  if (!risks.length) console.log(' (none logged)')
3373
- else risks.forEach(r => console.log(` [${r.severity}] ${r.text.slice(0, 100)}`))
3413
+ else risks.forEach(r => console.log(` [${r.severity}] ${masking.mask(r.text).slice(0, 100)}`))
3374
3414
 
3375
3415
  const success = firstLine(readClean(eng, 'success.md'), 160)
3376
3416
  console.log('\nSuccess looks like')
@@ -3381,7 +3421,7 @@ function cmdPrep(args) {
3381
3421
  .slice(-5)
3382
3422
  console.log('\nRecent decisions')
3383
3423
  if (!decisions.length) console.log(' (none logged)')
3384
- else decisions.forEach(l => console.log(` ${l.trim().slice(0, 120)}`))
3424
+ else decisions.forEach(l => console.log(` ${masking.mask(l.trim()).slice(0, 120)}`))
3385
3425
 
3386
3426
  const next = nextActionLine(readClean(eng, 'context.md'))
3387
3427
  console.log('\nWalk in with')
@@ -3421,7 +3461,7 @@ function cmdGarden(args) {
3421
3461
  }
3422
3462
  const dupes = findDuplicateOpenRisks(eng)
3423
3463
  if (dupes.length) {
3424
- const sample = (dupes[0][0] || '').replace(/\s+/g, ' ').trim().slice(0, 50)
3464
+ const sample = maskDisplay((dupes[0][0] || '').replace(/\s+/g, ' ').trim()).slice(0, 50)
3425
3465
  proposals.push({
3426
3466
  id: 'dedupe-risks',
3427
3467
  kind: 'apply',
@@ -3592,7 +3632,7 @@ function engagementSlugFromPath(eng) {
3592
3632
  }
3593
3633
 
3594
3634
  function cmdStatus(args) {
3595
- const all = args.includes('--all')
3635
+ const all = portfolioView(args)
3596
3636
  if (!fs.existsSync(ENGAGEMENTS_ROOT)) { console.log('no engagements yet - fde resume --init <name>'); return }
3597
3637
  const rows = []
3598
3638
  if (all) {
@@ -3601,7 +3641,7 @@ function cmdStatus(args) {
3601
3641
  const eng = path.join(ENGAGEMENTS_ROOT, d, '.fde')
3602
3642
  if (!fs.existsSync(eng)) continue
3603
3643
  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)
3644
+ 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
3645
  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
3646
  }
3607
3647
  } else {
@@ -3611,7 +3651,7 @@ function cmdStatus(args) {
3611
3651
  process.exit(2)
3612
3652
  }
3613
3653
  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)
3654
+ 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
3655
  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
3656
  }
3617
3657
  if (!rows.length) { console.log('no engagements yet'); return }
@@ -3661,7 +3701,7 @@ function gatherEngagements(opts = {}) {
3661
3701
  }
3662
3702
 
3663
3703
  function cmdDashboard(args) {
3664
- const all = args.includes('--all')
3704
+ const all = portfolioView(args)
3665
3705
  const outIdx = args.indexOf('--out')
3666
3706
  const outPath = outIdx !== -1 && args[outIdx + 1]
3667
3707
  ? path.resolve(args[outIdx + 1].replace(/^~/, HOME))
@@ -3717,7 +3757,7 @@ function cmdDashboard(args) {
3717
3757
  ['trust-profile.md', 'Trust profile'],
3718
3758
  ].map(([f, title]) => [title, readClean(e.dir, f)])
3719
3759
  .filter(([, md]) => render.hasRealContent(md))
3720
- .map(([title, md]) => ({ title, html: render.mdBlockHtml(md, parseMdTable) }))
3760
+ .map(([title, md]) => ({ title, html: render.mdBlockHtml(maskDisplay(md), parseMdTable) }))
3721
3761
  e.searchBlob = render.escapeHtml([
3722
3762
  e.name, e.next, e.lastSession, e.reality, e.brief,
3723
3763
  ...e.log.map(g => g.text), ...e.risks.map(r => r.text),
@@ -3727,7 +3767,7 @@ function cmdDashboard(args) {
3727
3767
  ].join(' ').toLowerCase())
3728
3768
  })
3729
3769
 
3730
- const html = render.buildFieldbookHtml({ engagements, today, generatedAt: new Date().toISOString() })
3770
+ const html = render.buildFieldbookHtml({ engagements: maskReport(engagements), today, generatedAt: new Date().toISOString() })
3731
3771
 
3732
3772
  try {
3733
3773
  const isRecordPath = p => p.split(path.sep).some(part => ['.fde', '.git'].includes(part.toLowerCase()))
@@ -3898,10 +3938,10 @@ function cmdVault(args) {
3898
3938
  })
3899
3939
 
3900
3940
  const files = vault.buildVaultFiles({
3901
- engagements,
3941
+ engagements: maskReport(engagements),
3902
3942
  today: render.formatToday(new Date()),
3903
3943
  redacted,
3904
- engagementsRoot: ENGAGEMENTS_ROOT,
3944
+ engagementsRoot: maskReport(ENGAGEMENTS_ROOT),
3905
3945
  version: cliVersion(),
3906
3946
  })
3907
3947
 
@@ -4085,6 +4125,8 @@ ${fs.existsSync(html) ? `\n Open the fieldbook: ${html}` : ''}
4085
4125
  function printUsage() {
4086
4126
  console.log(`fde - deterministic core of fdeops
4087
4127
  fde demo the whole loop on a fake client (fde demo --clean removes it)
4128
+ fde setup three first-use choices (or --show for saved preferences)
4129
+ fde privacy show masking capability and its boundaries
4088
4130
  fde scan day-1 recon of this repo (facts, no AI)
4089
4131
  fde resume load this workspace's engagement memory (bounded)
4090
4132
  fde resume --full load the complete context.md (no bound)
@@ -4113,8 +4155,8 @@ function printUsage() {
4113
4155
  fde receipts <term> source-backed records versus claims
4114
4156
  fde defend sponsor readout: accepted assertions, claims, sources, gaps
4115
4157
  fde handoff [--out file] portable redacted packet; stdout by default, new file only
4116
- fde status [--all] value ledger, then trust (pass --all for full portfolio)
4117
- fde dashboard [--all] [--open] [--out <path>] bound fieldbook (pass --all for every client)
4158
+ fde status [--all|--current] value ledger, then trust (scope follows setup)
4159
+ fde dashboard [--all|--current] [--open] [--out <path>] fieldbook (scope follows setup)
4118
4160
  fde vault derived Obsidian vault of every engagement (--current for one, --redacted for a shared screen, --out <dir>)
4119
4161
  hooks call these; you do not: capture (session-end snapshot), preserve (pre-compaction snapshot)
4120
4162
  env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)
@@ -4123,15 +4165,38 @@ function printUsage() {
4123
4165
  ingest is a sink only - source MCPs (Granola/Gmail/…) are user-configured; never ambient sync`)
4124
4166
  }
4125
4167
 
4126
- const [cmd, ...args] = process.argv.slice(2)
4168
+ const [cmd, ...rawArgs] = process.argv.slice(2)
4169
+ let outputBudget
4170
+ if (['resume', 'recall', 'handoff', 'defend'].includes(cmd) && !rawArgs.some(a => ['--full', '--init', '--bind', '--out'].includes(a))) {
4171
+ try { outputBudget = context.budgetArgs(rawArgs, (setupStore.read() || setup.DEFAULTS).context === 'compact' ? 4096 : 16384).maxBytes } catch (_) {}
4172
+ }
4173
+ require('./lib/masking').protectOutput(masking, { maxBytes: outputBudget })
4174
+ let args
4175
+ try {
4176
+ args = rawArgs.map(arg => masking.restore(arg))
4177
+ for (const key of ['FDEOPS_ENGAGEMENT', 'FDEOS_ENGAGEMENT']) {
4178
+ if (process.env[key]) process.env[key] = masking.restore(process.env[key])
4179
+ }
4180
+ // Resolve privacy-state failures before a user-authorized argument write.
4181
+ args.forEach(arg => masking.mask(arg))
4182
+ }
4183
+ catch (e) { console.error(e.message); process.exit(1) }
4127
4184
  if (args.includes('--help') || args.includes('-h') || cmd === 'help' || cmd === '--help' || cmd === '-h') {
4128
4185
  printUsage()
4129
4186
  process.exit(0)
4130
4187
  }
4188
+ try {
4189
+ if (cmd !== 'setup' && cmd !== 'privacy') preferences = setupStore.read() || setup.DEFAULTS
4190
+ const finishAsync = result => { if (result && typeof result.catch === 'function') result.catch(error => { console.error(error.message); process.exitCode = 1 }) }
4131
4191
  switch (cmd) {
4192
+ case 'setup': finishAsync(setup.command(setupStore, args)); break
4193
+ case 'privacy':
4194
+ if (args.length) { console.error('usage: fde privacy'); process.exitCode = 2; break }
4195
+ 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 and upstream MCP content bypass this protection. Local reports retain identifiers by default; fde setup can also mask newly generated report content.`)
4196
+ break
4132
4197
  case 'demo': cmdDemo(args); break
4133
4198
  case 'scan': cmdScan(); break
4134
- case 'resume': cmdResume(args); break
4199
+ case 'resume': finishAsync(cmdResume(args)); break
4135
4200
  case 'recall': cmdRecall(args); break
4136
4201
  case 'triage': cmdTriage(); break
4137
4202
  case 'log': cmdLog(args); break
@@ -4162,3 +4227,8 @@ switch (cmd) {
4162
4227
  // Missing or unknown command must fail - exit 0 made typos look like success in scripts/hooks.
4163
4228
  process.exit(1)
4164
4229
  }
4230
+
4231
+ } catch (error) {
4232
+ console.error(error && error.message ? error.message : "command failed")
4233
+ process.exitCode = 1
4234
+ }
package/bin/install.js CHANGED
@@ -376,7 +376,7 @@ function cmdInstall(opts = {}) {
376
376
  // `npx fdeops scan` must recon, not install - any fde subcommand passes straight
377
377
  // through to the CLI (fde.js reads process.argv itself, so require() is enough).
378
378
  const FDE_SUBCOMMANDS = [
379
- 'demo', 'scan', 'resume', 'triage', 'log', 'debrief', 'ingest', 'prep', 'doctor', 'redact',
379
+ 'setup', 'privacy', 'demo', 'scan', 'resume', 'triage', 'log', 'debrief', 'ingest', 'prep', 'doctor', 'redact',
380
380
  'tidy', 'garden', 'owner', 'receipts', 'recall', 'handoff', 'defend', 'capture', 'preserve', 'status', 'dashboard', 'vault', 'help',
381
381
  ]
382
382
 
@@ -14,9 +14,9 @@ function clipUtf8(text, bytes) {
14
14
  while (end > 0 && (buf[end] & 0xc0) === 0x80) end--
15
15
  return buf.subarray(0, end).toString('utf8')
16
16
  }
17
- function budgetArgs(args) {
17
+ function budgetArgs(args, defaultBytes = DEFAULT_BYTES) {
18
18
  const rest = [...args]
19
- let maxBytes = DEFAULT_BYTES
19
+ let maxBytes = defaultBytes
20
20
  const at = rest.indexOf('--max-bytes')
21
21
  if (at !== -1) {
22
22
  const raw = rest[at + 1]
@@ -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)
@@ -0,0 +1,109 @@
1
+ 'use strict'
2
+ const fs = require('node:fs')
3
+ const path = require('node:path')
4
+ const crypto = require('node:crypto')
5
+ const paths = require('./install-paths')
6
+
7
+ const DEFAULTS = { view: 'current', context: 'standard', privacy: 'agent' }
8
+ const QUESTIONS = [
9
+ { key: 'view', title: 'What should your daily overview show?', options: [
10
+ ['current', 'The client I am working on'], ['portfolio', 'All my clients'],
11
+ ] },
12
+ { key: 'context', title: 'How much context should your agent start with?', options: [
13
+ ['standard', 'Standard (up to 16 KiB)'], ['compact', 'Compact (up to 4 KiB; retrieve details as needed)'],
14
+ ] },
15
+ { key: 'privacy', title: 'Where should common identifiers be masked?', options: [
16
+ ['agent', 'Agent context; keep originals in my local reports'],
17
+ ['reports', 'Agent context and newly generated Fieldbook/vault reports'],
18
+ ] },
19
+ ]
20
+ const LIMITS = 'Both options hide <private> content. Masking covers common identifier formats, not all personal data. Names and sensitive prose need private marking. Your AI host controls provider transmission; setup does not change it. Existing exports are not rewritten.'
21
+ function validate(value) {
22
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
23
+ Object.keys(value).length !== 3 || QUESTIONS.some(q => !q.options.some(([v]) => value[q.key] === v))) {
24
+ throw new Error('Invalid setup choices; run fde setup to see the supported options.')
25
+ }
26
+ return value
27
+ }
28
+ function createSetup(root) {
29
+ const file = path.join(root, '.preferences.json')
30
+ function read() {
31
+ let fd
32
+ try {
33
+ paths.checkPath(file)
34
+ fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK)
35
+ const st = fs.fstatSync(fd)
36
+ if (!st.isFile() || st.nlink !== 1 || st.size > 4096) throw new Error('unsafe')
37
+ return validate(JSON.parse(fs.readFileSync(fd, 'utf8')))
38
+ } catch (e) {
39
+ if (e.code === 'ENOENT') return null
40
+ throw new Error('Cannot read setup safely. Check .preferences.json in your engagements folder; no settings were changed.')
41
+ } finally { if (fd !== undefined) fs.closeSync(fd) }
42
+ }
43
+ function save(value) {
44
+ validate(value)
45
+ paths.mkdir(root)
46
+ paths.checkPath(file)
47
+ const temporary = path.join(root, `.preferences-${crypto.randomBytes(12).toString('hex')}.tmp`)
48
+ try {
49
+ fs.writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 })
50
+ paths.checkPath(file)
51
+ fs.renameSync(temporary, file)
52
+ } finally { try { fs.unlinkSync(temporary) } catch (_) {} }
53
+ }
54
+ return { read, save }
55
+ }
56
+ function describe(value) {
57
+ return QUESTIONS.map(q => `${q.title} ${q.options.find(([v]) => v === value[q.key])[1]}`).join('\n')
58
+ }
59
+ function command(store, args) {
60
+ if (args.length === 1 && args[0] === '--show') {
61
+ const saved = store.read()
62
+ console.log(JSON.stringify({ configured: !!saved, ...(saved || DEFAULTS) }))
63
+ return
64
+ }
65
+ if (args.length) {
66
+ const choices = {}
67
+ if (!args.includes('--save') || args.filter(a => a === '--save').length !== 1) throw new Error('Use fde setup, or supply all three choices with --save.')
68
+ const rest = args.filter(a => a !== '--save')
69
+ for (let i = 0; i < rest.length; i += 2) {
70
+ const key = rest[i].replace(/^--/, '')
71
+ if (!rest[i].startsWith('--') || !Object.hasOwn(DEFAULTS, key) || Object.hasOwn(choices, key)) throw new Error('Unknown or repeated setup option.')
72
+ choices[key] = rest[i + 1]
73
+ }
74
+ validate(choices)
75
+ store.save(choices)
76
+ console.log('Setup saved for this engagements folder. Change it anytime with fde setup.\n' + describe(choices) + '\n' + LIMITS)
77
+ return
78
+ }
79
+ if (process.stdin.isTTY && process.stdout.isTTY) return interactive(store)
80
+ console.log('FIRST-USE SETUP: ask these three questions together, then save the answers. No settings have been changed.')
81
+ for (const [i, q] of QUESTIONS.entries()) console.log(`${i + 1}. ${q.title}\n` + q.options.map(([value, label]) => ` ${value}: ${label}`).join('\n'))
82
+ console.log(LIMITS + '\nAfter the user answers: fde setup --view current|portfolio --context standard|compact --privacy agent|reports --save\nUse fde setup --show to inspect existing choices. Never infer client policy from these preferences.')
83
+ }
84
+ async function interactive(store) {
85
+ // Only static prompts and validated enum labels bypass buffered CLI output.
86
+ const emit = text => fs.writeSync(1, text)
87
+ const rl = require('node:readline').createInterface({ input: process.stdin, terminal: false })
88
+ const lines = rl[Symbol.asyncIterator]()
89
+ const current = store.read() || DEFAULTS, choices = {}
90
+ try {
91
+ emit('Make FDEOps fit your day. Three choices; Enter keeps the current choice. Ctrl-C cancels.\n' + LIMITS + '\n')
92
+ for (const q of QUESTIONS) {
93
+ const selected = q.options.findIndex(([v]) => v === current[q.key])
94
+ while (true) {
95
+ emit(q.title + '\n' + q.options.map(([, label], i) => ` ${i + 1}. ${label}${i === selected ? ' (current)' : ''}`).join('\n') + '\n> ')
96
+ const next = await lines.next()
97
+ if (next.done) { emit('\nSetup cancelled; nothing saved.\n'); return }
98
+ const value = next.value.trim(), index = value === '' ? selected : /^[12]$/.test(value) ? Number(value) - 1 : -1
99
+ if (index >= 0) { choices[q.key] = q.options[index][0]; break }
100
+ emit('Choose 1 or 2, or press Enter.\n')
101
+ }
102
+ }
103
+ emit('\n' + describe(choices) + '\nSave these settings? [y/N] ')
104
+ const next = await lines.next()
105
+ if (!next.done && /^y(es)?$/i.test(next.value.trim())) { store.save(choices); emit('\nSetup saved. Change it anytime with fde setup.\n') }
106
+ else emit('\nSetup cancelled; nothing saved.\n')
107
+ } finally { rl.close() }
108
+ }
109
+ module.exports = { createSetup, command, DEFAULTS }
package/bin/lib/trust.js CHANGED
@@ -3,7 +3,7 @@
3
3
  function createTrustApi(deps) {
4
4
  const {
5
5
  fs, path, readClean, readEng, parseMdTable, sectionBody, SIGNAL_LEDGER, memoryDirtyManual,
6
- stripTemplateNoise, stripLegendLines, extractRisks,
6
+ stripTemplateNoise, stripLegendLines, extractRisks, maskDisplay = text => text,
7
7
  } = deps
8
8
 
9
9
  // phase / trust / top risk / freshness - identical heuristic for status + dashboard.
@@ -138,7 +138,7 @@ function createTrustApi(deps) {
138
138
  const body = sectionBody(ctx, 'Next action', { lastNonEmpty: true })
139
139
  for (const raw of body.split('\n')) {
140
140
  const t = raw.trim().replace(/^[-*]\s+/, '')
141
- if (t) return t.slice(0, 120)
141
+ if (t) return maskDisplay(t).slice(0, 120)
142
142
  }
143
143
  return ''
144
144
  }
@@ -180,7 +180,7 @@ function createTrustApi(deps) {
180
180
  trustReason = mem.warn
181
181
  } else if (worst) {
182
182
  trust = worst.sig === 'red' ? 'RED' : worst.sig
183
- trustReason = (worst.text || '').slice(0, 80)
183
+ trustReason = maskDisplay(worst.text || '').slice(0, 80)
184
184
  if (worst.date) {
185
185
  signalAge = Math.max(0, Math.floor((Date.now() - Date.parse(worst.date)) / 86400000))
186
186
  stale = signalAge > 21
@@ -195,7 +195,7 @@ function createTrustApi(deps) {
195
195
  trust = sLines.some(l => /\bred\b/i.test(l)) ? 'RED'
196
196
  : sLines.some(l => /amber|gone quiet|routing around|escalat/i.test(l)) ? 'amber' : 'new'
197
197
  }
198
- const topRisk = (extractRisks(eng)[0]?.text || '').replace(/\s+/g, ' ').trim().slice(0, 80)
198
+ const topRisk = maskDisplay((extractRisks(eng)[0]?.text || '').replace(/\s+/g, ' ').trim()).slice(0, 80)
199
199
  // Prefer trust trigger / memory warn over a random risk line; always keep mem.warn available
200
200
  const reason = (trustReason || mem.warn) ? (trustReason || mem.warn) : topRisk
201
201
  // What the triage line is actually quoting. A risk bullet printed under
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops-ingest-mcp",
3
- "version": "3.28.0",
3
+ "version": "3.30.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.30.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.30.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
 
@@ -56,9 +56,13 @@ Use the customer's existing coding, testing, review, and repository instructions
56
56
 
57
57
  Fallbacks: `node ~/.claude/fdeops/fde.js …`, then `npx --yes fdeops …`. Skill-only install is not "unavailable."
58
58
 
59
+ ## First-use preferences
60
+
61
+ Run `fde setup --show` before client reads. If unavailable, update the CLI before offering setup; never pretend preferences were saved. If `configured` is false, finish binding the named client, then run `fde setup` and present its **three questions together**, with the two choices each. Use answers already given; do not invent preferences or ask again per client. Save their selections using the displayed `--save` command; their answers authorize this settings write only. If they skip, continue with existing defaults and leave setup unsaved. On later sessions use saved choices; change them only when requested. Setup configures report scope, initial context size and masking of newly generated reports. It does not install a model, approve client data for AI, or change provider settings. Client policy always takes precedence. Reports can still contain names or other unrecognized sensitive prose; never treat them as anonymized.
62
+
59
63
  ## Entry (every session)
60
64
 
61
- 1. `fde resume` (16 KiB output ceiling, not a model token count). Read client constraints first, then signer, goals, risks, delivery ledger and current context. This command is the inspectable packet the session hook loads; never substitute a recursive read of `.fde/` or raw transcripts. If truncated or a decision needs evidence, run `fde recall <specific topic>`; narrow the query rather than loading the whole history. `--max-bytes 4096` reduces the allowance for smaller models. `--full` only when the complete log is explicitly needed.
65
+ 1. `fde resume` (16 KiB by default, 4 KiB with compact setup; a byte ceiling, not a model token count). Read client constraints first, then signer, goals, risks, delivery ledger and current context. This command is the inspectable packet the session hook loads; never substitute a recursive read of `.fde/` or raw transcripts. If truncated or a decision needs evidence, run `fde recall <specific topic>`; narrow the query rather than loading the whole history. `--max-bytes 4096` reduces the allowance for smaller models. `--full` only when the complete log is explicitly needed.
62
66
  2. **NO ENGAGEMENT:** ask "What should we call this client?" then **you** init. Pasted notes → debrief after bind.
63
67
  3. Playback 2-3 lines. `hygiene:` → offer `fde doctor`; **never auto-rewrite**.
64
68
  4. Route. Read **one** `references/*.md`. Confirm, then write.
@@ -69,7 +73,7 @@ Writes need a bind (`FDEOPS_ENGAGEMENT` or registry). Never install fdeops on in
69
73
  |----------|---------|
70
74
  | where are we | `fde resume` |
71
75
  | 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` |
76
+ | 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
77
  | prep me for … | `fde prep "<label>"` |
74
78
  | when did we agree | `fde receipts <term>` |
75
79
  | sponsor update / defend the number | `fde defend` |
@@ -211,3 +215,7 @@ Ready to build with no `terrain.md` / plan: discover or plan first. Takeover wit
211
215
  - Evidence on every claim. The FDE will be challenged on these files.
212
216
  - Overlays activate on signal, not on request.
213
217
  - Load `.fde/` files on demand, never the whole folder.
218
+
219
+ ## Identifier masking
220
+
221
+ 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