fdeops 3.29.0 → 3.31.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 how you work, what would help first, and what to mask before sharing context with your agent. [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>
package/bin/fde.js CHANGED
@@ -46,8 +46,26 @@ const ENGAGEMENTS_ROOT = ((process.env.FDEOPS_ENGAGEMENTS_ROOT || '').trim().rep
46
46
  || path.join(HOME, 'fde-engagements')
47
47
  const REGISTRY = path.join(ENGAGEMENTS_ROOT, '.registry')
48
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
+ }
49
59
  function maskDisplay(text) {
50
- return ['dashboard', 'vault'].includes(process.argv[2]) ? String(text) : masking.mask(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
51
69
  }
52
70
  function maskedSections(sections, maxBytes) {
53
71
  return context.boundedSections(sections.map(text => masking.mask(text)), maxBytes)
@@ -80,9 +98,9 @@ function gitIsAncestor(eng, olderHash, newerHash) {
80
98
  } catch (_) { return false }
81
99
  }
82
100
 
83
- function gitLogHash(eng, args) {
101
+ function gitLogHash(eng, args, format = '%H') {
84
102
  try {
85
- return execFileSync('git', ['log', '-1', '--format=%H', ...args], {
103
+ return execFileSync('git', ['log', '-1', `--format=${format}`, ...args], {
86
104
  cwd: eng, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 15000,
87
105
  }).trim()
88
106
  } catch (_) { return '' }
@@ -803,7 +821,7 @@ function phaseLabel(phase) {
803
821
  // colon) collapses to '' here, which callers treat as "nothing to show".
804
822
  function firstLine(md, maxLen) {
805
823
  for (const raw of md.split('\n')) {
806
- const l = raw.trim()
824
+ const l = maskDisplay(raw).trim()
807
825
  if (!l || /^#{1,6}\s/.test(l)) continue
808
826
  const clean = maskDisplay(l.replace(/^\*\*[^*]+:\*\*\s*/, '').replace(/\*\*/g, '').replace(/^["']|["']$/g, '').trim())
809
827
  if (!clean) continue
@@ -990,7 +1008,7 @@ const {
990
1008
  countOpenRisks,
991
1009
  } = createTrustApi({
992
1010
  fs, path, readClean, readEng, parseMdTable, sectionBody, SIGNAL_LEDGER, memoryDirtyManual,
993
- stripTemplateNoise, stripLegendLines, extractRisks,
1011
+ stripTemplateNoise, stripLegendLines, extractRisks, maskDisplay,
994
1012
  })
995
1013
 
996
1014
  // Stakeholders: columns are matched by header wording, not position - real
@@ -1023,6 +1041,7 @@ function parseSignalHistoryEntries(eng) {
1023
1041
  }
1024
1042
 
1025
1043
  function displayNameFromSignalText(text) {
1044
+ if ((preferences.privacy === 'reports' || !['dashboard', 'vault'].includes(process.argv[2])) && masking.mask(text) !== text) return 'Contact (identifier masked)'
1026
1045
  const person = personFromSignalText(text)
1027
1046
  if (person) return person
1028
1047
  const t = String(text).trim()
@@ -1147,7 +1166,7 @@ function extractRisks(eng) {
1147
1166
  // sides look like real numbers/percentages, deduped by value pair, capped at
1148
1167
  // 4; nothing reliable found -> the widget stays empty, never an invented number.
1149
1168
  function extractStats(eng) {
1150
- const text = readClean(eng, 'delivery.md') + '\n' + readClean(eng, 'decisions.md')
1169
+ const text = maskDisplay(readClean(eng, 'delivery.md') + '\n' + readClean(eng, 'decisions.md'))
1151
1170
  const patterns = [
1152
1171
  /(\d+(?:\.\d+)?%)[^\n%]{0,40}?(?:→|->)[^\n%]{0,20}?(\d+(?:\.\d+)?%)/g, // "X% ... -> Y%"
1153
1172
  /(\d+(?:\.\d+)?%)\s+to\s+(\d+(?:\.\d+)?%)/gi, // "X% to Y%"
@@ -1162,7 +1181,7 @@ function extractStats(eng) {
1162
1181
  const key = from + '→' + to
1163
1182
  if (seen.has(key)) continue
1164
1183
  seen.add(key)
1165
- const pre = text.slice(Math.max(0, m.index - 40), m.index)
1184
+ const pre = maskDisplay(text.slice(0, m.index)).slice(-40)
1166
1185
  const label = pre.split(/\s+/).filter(Boolean).slice(-3).join(' ').replace(/^[,:;.\-]+|[,:;.\-]+$/g, '') || 'metric'
1167
1186
  stats.push({ label, from, to })
1168
1187
  }
@@ -1315,7 +1334,7 @@ function cmdScan() {
1315
1334
 
1316
1335
  function cmdResume(args) {
1317
1336
  let maxBytes
1318
- 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) }
1319
1338
 
1320
1339
  const initIdx = args.indexOf('--init')
1321
1340
  if (initIdx !== -1) {
@@ -1400,6 +1419,10 @@ function cmdResume(args) {
1400
1419
  const head = memoryHead(fdeDir)
1401
1420
  console.log(`memory git: ${head || 'ready'}${owner ? ` owner: ${owner.email}` : ''}`)
1402
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
+ }
1403
1426
  return
1404
1427
  }
1405
1428
  if (args[0] === '--bind') {
@@ -1435,6 +1458,7 @@ function cmdResume(args) {
1435
1458
  const risks = readClean(eng, 'risks.md')
1436
1459
  process.stdout.write(maskedSections([
1437
1460
  policy ? `CLIENT POLICY - trust-profile.md\n${policy}` : '',
1461
+ preferences.work ? `WORKING PREFERENCES: ${preferences.work}. Starting help: ${preferences.start}.\n${setup.nextStep(preferences)}\nThis is a starting preference, not a client fact; current instructions and engagement state take precedence.` : '',
1438
1462
  `${intro}\n\nENGAGEMENT: ${eng}`,
1439
1463
  success ? `CURRENT GOALS & ACCEPTANCE - success.md\n${success}` : '',
1440
1464
  risks ? `OPEN RISKS - risks.md\n${extractRisks(eng).map(r => r.text).join('\n') || '(none recorded)'}` : '',
@@ -1552,7 +1576,7 @@ function cmdLog(args) {
1552
1576
  if (hit && force) console.error(`warning: logging possible ${hit} (--force)`)
1553
1577
  if (retire) {
1554
1578
  const n = retireOpenRisks(eng, text)
1555
- if (!n) { console.error(`no open risk matched ${JSON.stringify(text)}`); process.exit(1) }
1579
+ if (!n) { console.error(`no open risk matched ${JSON.stringify(masking.mask(text))}`); process.exit(1) }
1556
1580
  const hash = commitMemory(eng, 'retire risk', { files: ['risks.md'] })
1557
1581
  console.log(`retired ${n} risk(s) → risks.md${hash ? ` @${hash}` : ''}`)
1558
1582
  return
@@ -1982,7 +2006,7 @@ function changeReviewIssues(eng) {
1982
2006
  const del = latestDeliveryEntry(readClean(eng, 'delivery.md'))
1983
2007
  const dec = latestDatedDecision(readClean(eng, 'decisions.md'))
1984
2008
  const delHash = del.line
1985
- ? sh(`git log -1 --format=%H -S${JSON.stringify(del.line)} -- delivery.md`, eng)
2009
+ ? gitLogHash(eng, ['-S', del.line, '--', 'delivery.md'])
1986
2010
  : ''
1987
2011
  if (del.date && dec.date && dec.date > del.date && delHash) {
1988
2012
  issues.push(
@@ -2548,7 +2572,7 @@ function cmdReceipts(args) {
2548
2572
  // overwrites an engagement record, existing file, or symlink.
2549
2573
  function cmdHandoff(args, label = 'Handoff') {
2550
2574
  let parsed
2551
- try { parsed = context.budgetArgs(args) } catch (e) { console.error(e.message); process.exit(1) }
2575
+ try { parsed = contextBudget(args) } catch (e) { console.error(e.message); process.exit(1) }
2552
2576
  let out = ''
2553
2577
  if (parsed.args.length) {
2554
2578
  if (parsed.args.length !== 2 || parsed.args[0] !== '--out' || !parsed.args[1] || parsed.args[1].startsWith('--')) {
@@ -2594,7 +2618,7 @@ function cmdHandoff(args, label = 'Handoff') {
2594
2618
 
2595
2619
  function cmdRecall(args) {
2596
2620
  let maxBytes
2597
- try { ({ args, maxBytes } = context.budgetArgs(args)) } catch (e) { console.error(e.message); process.exit(2) }
2621
+ try { ({ args, maxBytes } = contextBudget(args)) } catch (e) { console.error(e.message); process.exit(2) }
2598
2622
  const query = args.join(' ').trim()
2599
2623
  if (!query || Buffer.byteLength(query) > 2048 || args.some(a => a.startsWith('--'))) {
2600
2624
  console.error('usage: fde recall <topic> [--max-bytes 4096..65536]'); process.exit(2)
@@ -2796,7 +2820,7 @@ function silentCommitIssues(eng) {
2796
2820
  // Pickaxe on the entry text, not the file: a later status edit to
2797
2821
  // delivery.md must not become the cutoff and hide commits before it.
2798
2822
  const raw = entry.line
2799
- ? sh(`git log -1 --format=%cI -S${JSON.stringify(entry.line)} -- delivery.md`, eng)
2823
+ ? gitLogHash(eng, ['-S', entry.line, '--', 'delivery.md'], '%cI')
2800
2824
  : ''
2801
2825
  // git --since is inclusive at second grain; a commit in the same second as
2802
2826
  // the receipt is the receipt's own work, not a silent one.
@@ -3332,10 +3356,10 @@ function cmdRedact(args) {
3332
3356
  })
3333
3357
  }
3334
3358
  if (!hits.length) {
3335
- console.log(`redact: no lines contain ${JSON.stringify(term)}`)
3359
+ console.log(`redact: no lines contain ${JSON.stringify(masking.mask(term))}`)
3336
3360
  return
3337
3361
  }
3338
- console.log(`REDACT - ${hits.length} matching line(s) for ${JSON.stringify(term)}`)
3362
+ console.log(`REDACT - ${hits.length} matching line(s) for ${JSON.stringify(masking.mask(term))}`)
3339
3363
  hits.slice(0, 20).forEach(h => {
3340
3364
  const preview = h.line.length > 100 ? masking.mask(h.line).slice(0, 97) + '…' : h.line
3341
3365
  console.log(` ${h.file}:${h.lineNo} ${preview}`)
@@ -3609,7 +3633,7 @@ function engagementSlugFromPath(eng) {
3609
3633
  }
3610
3634
 
3611
3635
  function cmdStatus(args) {
3612
- const all = args.includes('--all')
3636
+ const all = portfolioView(args)
3613
3637
  if (!fs.existsSync(ENGAGEMENTS_ROOT)) { console.log('no engagements yet - fde resume --init <name>'); return }
3614
3638
  const rows = []
3615
3639
  if (all) {
@@ -3678,7 +3702,7 @@ function gatherEngagements(opts = {}) {
3678
3702
  }
3679
3703
 
3680
3704
  function cmdDashboard(args) {
3681
- const all = args.includes('--all')
3705
+ const all = portfolioView(args)
3682
3706
  const outIdx = args.indexOf('--out')
3683
3707
  const outPath = outIdx !== -1 && args[outIdx + 1]
3684
3708
  ? path.resolve(args[outIdx + 1].replace(/^~/, HOME))
@@ -3734,17 +3758,17 @@ function cmdDashboard(args) {
3734
3758
  ['trust-profile.md', 'Trust profile'],
3735
3759
  ].map(([f, title]) => [title, readClean(e.dir, f)])
3736
3760
  .filter(([, md]) => render.hasRealContent(md))
3737
- .map(([title, md]) => ({ title, html: render.mdBlockHtml(md, parseMdTable) }))
3738
- e.searchBlob = render.escapeHtml([
3761
+ .map(([title, md]) => ({ title, html: render.mdBlockHtml(maskDisplay(md), parseMdTable) }))
3762
+ e.searchBlob = render.escapeHtml(maskDisplay([
3739
3763
  e.name, e.next, e.lastSession, e.reality, e.brief,
3740
3764
  ...e.log.map(g => g.text), ...e.risks.map(r => r.text),
3741
3765
  ...e.stakeholders.map(p => `${p.name} ${p.role} ${p.note}`),
3742
3766
  ...e.moreSections.map(s => s.title),
3743
3767
  ...e.valueRows.map(r => `${r.slice} ${r.promised} ${r.measured} ${r.accepted} ${r.evidence}`),
3744
- ].join(' ').toLowerCase())
3768
+ ].join(' ').toLowerCase()))
3745
3769
  })
3746
3770
 
3747
- const html = render.buildFieldbookHtml({ engagements, today, generatedAt: new Date().toISOString() })
3771
+ const html = render.buildFieldbookHtml({ engagements: maskReport(engagements), today, generatedAt: new Date().toISOString() })
3748
3772
 
3749
3773
  try {
3750
3774
  const isRecordPath = p => p.split(path.sep).some(part => ['.fde', '.git'].includes(part.toLowerCase()))
@@ -3915,10 +3939,10 @@ function cmdVault(args) {
3915
3939
  })
3916
3940
 
3917
3941
  const files = vault.buildVaultFiles({
3918
- engagements,
3942
+ engagements: maskReport(engagements),
3919
3943
  today: render.formatToday(new Date()),
3920
3944
  redacted,
3921
- engagementsRoot: ENGAGEMENTS_ROOT,
3945
+ engagementsRoot: maskReport(ENGAGEMENTS_ROOT),
3922
3946
  version: cliVersion(),
3923
3947
  })
3924
3948
 
@@ -4102,6 +4126,7 @@ ${fs.existsSync(html) ? `\n Open the fieldbook: ${html}` : ''}
4102
4126
  function printUsage() {
4103
4127
  console.log(`fde - deterministic core of fdeops
4104
4128
  fde demo the whole loop on a fake client (fde demo --clean removes it)
4129
+ fde setup three first-use choices (or --show for saved preferences)
4105
4130
  fde privacy show masking capability and its boundaries
4106
4131
  fde scan day-1 recon of this repo (facts, no AI)
4107
4132
  fde resume load this workspace's engagement memory (bounded)
@@ -4131,8 +4156,8 @@ function printUsage() {
4131
4156
  fde receipts <term> source-backed records versus claims
4132
4157
  fde defend sponsor readout: accepted assertions, claims, sources, gaps
4133
4158
  fde handoff [--out file] portable redacted packet; stdout by default, new file only
4134
- fde status [--all] value ledger, then trust (pass --all for full portfolio)
4135
- fde dashboard [--all] [--open] [--out <path>] bound fieldbook (pass --all for every client)
4159
+ fde status [--all|--current] value ledger, then trust (scope follows setup)
4160
+ fde dashboard [--all|--current] [--open] [--out <path>] fieldbook (scope follows setup)
4136
4161
  fde vault derived Obsidian vault of every engagement (--current for one, --redacted for a shared screen, --out <dir>)
4137
4162
  hooks call these; you do not: capture (session-end snapshot), preserve (pre-compaction snapshot)
4138
4163
  env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)
@@ -4144,9 +4169,10 @@ function printUsage() {
4144
4169
  const [cmd, ...rawArgs] = process.argv.slice(2)
4145
4170
  let outputBudget
4146
4171
  if (['resume', 'recall', 'handoff', 'defend'].includes(cmd) && !rawArgs.some(a => ['--full', '--init', '--bind', '--out'].includes(a))) {
4147
- try { outputBudget = context.budgetArgs(rawArgs).maxBytes } catch (_) {}
4172
+ try { outputBudget = context.budgetArgs(rawArgs, (setupStore.read() || setup.DEFAULTS).context === 'compact' ? 4096 : 16384).maxBytes } catch (_) {}
4148
4173
  }
4149
- require('./lib/masking').protectOutput(masking, { maxBytes: outputBudget })
4174
+ require('./lib/masking').protectOutput(cmd === 'setup' && rawArgs.length === 1 && rawArgs[0] === '--show'
4175
+ ? require('./lib/masking').createMasking(ENGAGEMENTS_ROOT, { custom: false }) : masking, { maxBytes: outputBudget })
4150
4176
  let args
4151
4177
  try {
4152
4178
  args = rawArgs.map(arg => masking.restore(arg))
@@ -4162,14 +4188,17 @@ if (args.includes('--help') || args.includes('-h') || cmd === 'help' || cmd ===
4162
4188
  process.exit(0)
4163
4189
  }
4164
4190
  try {
4191
+ if (cmd !== 'setup' && cmd !== 'privacy') preferences = setupStore.read() || setup.DEFAULTS
4192
+ const finishAsync = result => { if (result && typeof result.catch === 'function') result.catch(error => { console.error(error.message); process.exitCode = 1 }) }
4165
4193
  switch (cmd) {
4194
+ case 'setup': finishAsync(setup.command(setupStore, args)); break
4166
4195
  case 'privacy':
4167
4196
  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.`)
4197
+ 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 automatically detected; mark them <private> or supply local custom terms with fde setup.\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.`)
4169
4198
  break
4170
4199
  case 'demo': cmdDemo(args); break
4171
4200
  case 'scan': cmdScan(); break
4172
- case 'resume': cmdResume(args); break
4201
+ case 'resume': finishAsync(cmdResume(args)); break
4173
4202
  case 'recall': cmdRecall(args); break
4174
4203
  case 'triage': cmdTriage(); break
4175
4204
  case 'log': cmdLog(args); break
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]
@@ -6,7 +6,7 @@ const fs = require('node:fs')
6
6
  const path = require('node:path')
7
7
  const crypto = require('node:crypto')
8
8
  const { StringDecoder } = require('node:string_decoder')
9
- const ALIAS = /\[\[(email|phone|identifier|credential):[a-f0-9]{16}\]\]/g
9
+ const ALIAS = /\[\[(email|phone|identifier|credential|term):[a-f0-9]{16}\]\]/g
10
10
  const PATTERNS = [
11
11
  ['credential', /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?(?:-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|$)/g],
12
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],
@@ -25,7 +25,27 @@ function replacements(text, replace) {
25
25
  for (const [kind, pattern] of PATTERNS) out = out.replace(pattern, value => replace(kind, value))
26
26
  return out
27
27
  }
28
- function createMasking(root) {
28
+ function createMasking(root, { custom = true } = {}) {
29
+ const settings = require('./setup').createSetup(root)
30
+ function replaceAll(text, replace) {
31
+ let terms = []
32
+ if (custom) {
33
+ const profile = settings.read()
34
+ if (profile && profile.masking === 'custom') terms = profile.terms
35
+ }
36
+ const escape = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
37
+ const pattern = terms.length ? new RegExp(terms.slice().sort((a, b) => b.length - a.length).map(term =>
38
+ '(?<![\\p{L}\\p{N}_])' + escape(term) + '(?![\\p{L}\\p{N}_])').join('|'), 'giu') : null
39
+ // Existing aliases are protocol tokens, never input for further masking.
40
+ const pieces = String(text).split(/(\[\[(?:email|phone|identifier|credential|term):[a-f0-9]{16}\]\])/g)
41
+ return pieces.map((piece, i) => {
42
+ if (i % 2) return piece
43
+ const builtIn = replacements(piece, replace)
44
+ if (!pattern) return builtIn
45
+ return builtIn.split(/(\[\[(?:email|phone|identifier|credential|term):[a-f0-9]{16}\]\])/g)
46
+ .map((part, j) => j % 2 ? part : part.replace(pattern, value => replace('term', value))).join('')
47
+ }).join('')
48
+ }
29
49
  const directory = path.join(root, '.privacy'), file = path.join(directory, 'identifiers.json')
30
50
  function directoryReady(create) {
31
51
  if (create) fs.mkdirSync(root, { recursive: true })
@@ -43,7 +63,7 @@ function createMasking(root) {
43
63
  if (data.version !== 1 || !Array.isArray(data.entries) || data.entries.length > 50000) throw new Error(FAIL)
44
64
  const seen = new Set()
45
65
  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)
66
+ if (!entry || typeof entry.value !== 'string' || !/^\[\[(email|phone|identifier|credential|term):[a-f0-9]{16}\]\]$/.test(entry.alias) || seen.has(entry.alias)) throw new Error(FAIL)
47
67
  seen.add(entry.alias)
48
68
  }
49
69
  return data
@@ -76,11 +96,11 @@ function createMasking(root) {
76
96
  function mask(text) {
77
97
  text = String(text)
78
98
  let detected = false
79
- replacements(text, (_, value) => { detected = true; return value })
99
+ replaceAll(text, (_, value) => { detected = true; return value })
80
100
  if (!detected) return text
81
101
  return transact(true, entries => {
82
102
  const values = new Map(entries.map(e => [e.value, e.alias]))
83
- return replacements(text, (kind, value) => {
103
+ return replaceAll(text, (kind, value) => {
84
104
  if (!values.has(value)) {
85
105
  const alias = `[[${kind}:${crypto.randomBytes(8).toString('hex')}]]`
86
106
  entries.push({ alias, value }); values.set(value, alias)
@@ -91,7 +111,7 @@ function createMasking(root) {
91
111
  }
92
112
  function restore(text) {
93
113
  text = String(text)
94
- if (/\[\[(email|phone|identifier|credential):/.test(text.replace(ALIAS, ''))) throw new Error(FAIL)
114
+ if (/\[\[(email|phone|identifier|credential|term):/.test(text.replace(ALIAS, ''))) throw new Error(FAIL)
95
115
  if (!text.match(ALIAS)) return text
96
116
  return transact(false, entries => {
97
117
  const aliases = new Map(entries.map(e => [e.alias, e.value]))
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', '--', '.', ':(exclude).privacy'], { cwd: eng, stdio: 'ignore', timeout: 10000 })
56
+ execFileSync('git', ['add', '-A', '--', '.', ':(exclude).privacy', ':(exclude).preferences.json'], { 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'],
@@ -73,7 +73,7 @@ function createMemoryApi(deps) {
73
73
  }
74
74
  }
75
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'))
76
+ const privateStaged = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: eng, encoding: 'utf8', timeout: 10000 }).split('\n').some(f => f.split('/').some(part => ['.privacy', '.preferences.json'].includes(part)))
77
77
  if (privateStaged) throw new Error('private alias state must not be staged in engagement history')
78
78
  const still = execFileSync('git', ['diff', '--cached', '--name-only'], {
79
79
  cwd: eng, encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'],
@@ -134,7 +134,7 @@ function createMemoryApi(deps) {
134
134
  execFileSync('git', ['init'], { cwd: eng, stdio: 'ignore', timeout: 10000 })
135
135
  atomicWriteFile(
136
136
  path.join(eng, '.gitignore'),
137
- ['*.lock', '*.tmp', '.last-write', '.debrief-propose', '.debrief-private', '.debrief-seal', '.privacy/', ''].join('\n')
137
+ ['*.lock', '*.tmp', '.last-write', '.debrief-propose', '.debrief-private', '.debrief-seal', '.privacy/', '.preferences.json', ''].join('\n')
138
138
  )
139
139
  const owner = writeOwnerIfMissing(eng)
140
140
  configureMemoryGitIdentity(eng, owner)
@@ -0,0 +1,191 @@
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 SETTINGS = [
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 QUESTIONS = [
21
+ { key: 'work', title: 'How do you work?', options: [
22
+ ['single', 'One client'], ['multiple', 'Several clients'], ['team', 'Leading a delivery team'],
23
+ ] },
24
+ { key: 'start', title: 'What would help you first?', options: [
25
+ ['new', 'Starting an engagement'], ['daily', 'Continuing daily work'], ['takeover', 'Taking over existing work'],
26
+ ] },
27
+ { key: 'masking', title: 'What should FDEOps hide before sharing context with your agent?', options: [
28
+ ['standard', 'Common identifiers and secrets'], ['custom', 'Those, plus names and terms I specify'],
29
+ ] },
30
+ ]
31
+ const PROFILE_DEFAULTS = { work: 'single', start: 'daily', masking: 'standard', terms: [] }
32
+ const LIMITS = 'Both choices hide marked private content. Masking reduces exposure; it does not guarantee anonymity or permission to share client data. Raw file tools, pasted chat and other tools can bypass it. AI-provider settings are unchanged.'
33
+ function validateTerms(terms) {
34
+ if (!Array.isArray(terms) || terms.length > 100 || terms.some(t => typeof t !== 'string' || t.length < 2 || t.length > 128 || t !== t.trim() || /[\r\n\x00-\x1f]|\[\[|\]\]/.test(t))) {
35
+ throw new Error('Use up to 100 names or terms, one per line, each 2-128 characters; control characters and alias markers are not allowed.')
36
+ }
37
+ return [...new Set(terms)]
38
+ }
39
+ function validate(value) {
40
+ const keys = value && Object.keys(value)
41
+ const personal = value && Object.hasOwn(value, 'work')
42
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
43
+ keys.length !== (personal ? 7 : 3) ||
44
+ (personal ? SETTINGS.concat(QUESTIONS) : SETTINGS).some(q => !q.options.some(([v]) => value[q.key] === v))) {
45
+ throw new Error('Invalid setup choices; run fde setup to see the supported options.')
46
+ }
47
+ if (personal) {
48
+ validateTerms(value.terms)
49
+ if (value.masking === 'custom' && !value.terms.length) throw new Error('Custom masking needs at least one name or term.')
50
+ }
51
+ return value
52
+ }
53
+ function readTerms(file) {
54
+ let fd
55
+ try {
56
+ paths.checkPath(file)
57
+ fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK)
58
+ const st = fs.fstatSync(fd)
59
+ if (!st.isFile() || st.nlink !== 1 || st.size > 65536) throw new Error('unsafe')
60
+ const terms = validateTerms(fs.readFileSync(fd, 'utf8').split(/\r?\n/).map(t => t.trim()).filter(Boolean))
61
+ if (!terms.length) throw new Error('empty')
62
+ return terms
63
+ } catch (_) { throw new Error('Cannot load custom terms. Use an ordinary local UTF-8 file with 1-100 terms, one per line, each 2-128 characters.') }
64
+ finally { if (fd !== undefined) fs.closeSync(fd) }
65
+ }
66
+ function nextStep(value) {
67
+ const start = {
68
+ new: 'Clarify the client problem, one measurable outcome, and who will accept it. Start with land.',
69
+ daily: 'Resume the current client and choose one action from the existing blockers. Start with triage.',
70
+ takeover: 'Check the existing evidence, open risks and previous commitments before changing anything. Start with audit.',
71
+ }
72
+ return (start[value.start] || '') + (value.work === 'team' ? ' Make responsibility and handoff clear; do not assume shared access or synchronization.' : '')
73
+ }
74
+ function createSetup(root) {
75
+ const file = path.join(root, '.preferences.json')
76
+ function read() {
77
+ let fd
78
+ try {
79
+ paths.checkPath(file)
80
+ fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK)
81
+ const st = fs.fstatSync(fd)
82
+ if (!st.isFile() || st.nlink !== 1 || st.size > 65536 || (process.platform !== 'win32' && (st.mode & 0o077))) throw new Error('unsafe')
83
+ return validate(JSON.parse(fs.readFileSync(fd, 'utf8')))
84
+ } catch (e) {
85
+ if (e.code === 'ENOENT') return null
86
+ throw new Error('Cannot read setup safely. Check .preferences.json in your engagements folder; no settings were changed.')
87
+ } finally { if (fd !== undefined) fs.closeSync(fd) }
88
+ }
89
+ function save(value) {
90
+ validate(value)
91
+ paths.mkdir(root)
92
+ paths.checkPath(file)
93
+ const temporary = path.join(root, `.preferences-${crypto.randomBytes(12).toString('hex')}.tmp`)
94
+ try {
95
+ fs.writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 })
96
+ paths.checkPath(file)
97
+ fs.renameSync(temporary, file)
98
+ } finally { try { fs.unlinkSync(temporary) } catch (_) {} }
99
+ }
100
+ return { read, save }
101
+ }
102
+ function describe(value, questions = QUESTIONS) {
103
+ return questions.map(q => `${q.title} ${q.options.find(([v]) => v === value[q.key])[1]}`).join('\n')
104
+ }
105
+ function saveAnswers(store, choices, questions, termsFile) {
106
+ const saved = store.read(), current = saved || DEFAULTS
107
+ if (questions.some(q => !q.options.some(([v]) => choices[q.key] === v)) || Object.keys(choices).length !== questions.length) throw new Error('Supply one valid answer for each question.')
108
+ let result
109
+ if (questions === SETTINGS) result = { ...current, ...choices }
110
+ else {
111
+ if (termsFile && choices.masking !== 'custom') throw new Error('A terms file requires custom masking.')
112
+ const terms = termsFile ? readTerms(termsFile) : current.terms || []
113
+ result = { ...current, ...choices, terms, view: !saved || (saved.work && saved.work !== choices.work) ? (choices.work === 'multiple' ? 'portfolio' : 'current') : saved.view }
114
+ }
115
+ store.save(result)
116
+ return result
117
+ }
118
+ function command(store, args) {
119
+ if (args.length === 1 && args[0] === '--show') {
120
+ const saved = store.read(), { terms, ...safe } = saved || DEFAULTS
121
+ console.log(JSON.stringify({ configured: !!saved, ...safe, ...(terms ? { termCount: terms.length, nextStep: nextStep(saved) } : {}) }))
122
+ return
123
+ }
124
+ if (args.length && !(args.length === 1 && args[0] === '--settings')) {
125
+ const choices = {}, rest = args.filter(a => a !== '--save')
126
+ let termsFile
127
+ if (args.filter(a => a === '--save').length !== 1) throw new Error('Use fde setup, or supply all three answers with --save.')
128
+ for (let i = 0; i < rest.length; i += 2) {
129
+ const key = rest[i].replace(/^--/, '')
130
+ if (key === 'terms-file' && !termsFile && rest[i + 1]) { termsFile = rest[i + 1]; continue }
131
+ if (!rest[i].startsWith('--') || !SETTINGS.concat(QUESTIONS).some(q => q.key === key) || Object.hasOwn(choices, key)) throw new Error('Unknown or repeated setup option.')
132
+ choices[key] = rest[i + 1]
133
+ }
134
+ const questions = Object.hasOwn(choices, 'work') ? QUESTIONS : SETTINGS
135
+ if (termsFile && questions === SETTINGS) throw new Error('Use personal setup to change custom terms.')
136
+ const result = saveAnswers(store, choices, questions, termsFile)
137
+ console.log('Setup saved locally. Change it anytime with fde setup.\n' + describe(result, questions) + '\n' + nextStep(result) + '\n' + LIMITS)
138
+ return
139
+ }
140
+ const questions = args[0] === '--settings' ? SETTINGS : QUESTIONS
141
+ if (process.stdin.isTTY && process.stdout.isTTY) return interactive(store, questions)
142
+ console.log('SETUP: ask these three questions together. No settings have been changed.')
143
+ for (const [i, q] of questions.entries()) console.log(`${i + 1}. ${q.title}\n` + q.options.map(([value, label]) => ` ${value}: ${label}`).join('\n'))
144
+ console.log(LIMITS)
145
+ console.log(questions === QUESTIONS
146
+ ? 'After answers: fde setup --work single|multiple|team --start new|daily|takeover --masking standard|custom [--terms-file LOCAL_FILE] --save\nFor custom masking, enter names locally with fde setup, or provide a local terms-file path; do not ask to paste sensitive names into chat. Existing terms are kept unless a replacement file is supplied.'
147
+ : 'Save settings: fde setup --view current|portfolio --context standard|compact --privacy agent|reports --save')
148
+ console.log('fde setup --show shows choices and term count only. fde setup --settings changes display and context options.')
149
+ }
150
+ async function interactive(store, questions) {
151
+ const emit = text => fs.writeSync(1, text)
152
+ const current = { ...PROFILE_DEFAULTS, ...(store.read() || DEFAULTS) }, choices = {}
153
+ const rl = require('node:readline').createInterface({ input: process.stdin, terminal: false })
154
+ const lines = rl[Symbol.asyncIterator]()
155
+ try {
156
+ emit('Make FDEOps fit your work. Enter keeps the current choice. Ctrl-C cancels.\n' + LIMITS + '\n')
157
+ for (const q of questions) {
158
+ const selected = q.options.findIndex(([v]) => v === current[q.key])
159
+ while (true) {
160
+ emit(q.title + '\n' + q.options.map(([, label], i) => ` ${i + 1}. ${label}${i === selected ? ' (current)' : ''}`).join('\n') + '\n> ')
161
+ const next = await lines.next()
162
+ if (next.done) { emit('\nCancelled; nothing saved.\n'); return }
163
+ const value = next.value.trim(), index = value === '' ? selected : /^[123]$/.test(value) ? Number(value) - 1 : -1
164
+ if (index >= 0 && index < q.options.length) { choices[q.key] = q.options[index][0]; break }
165
+ emit('Choose a listed number, or press Enter.\n')
166
+ }
167
+ }
168
+ let terms = current.terms
169
+ if (choices.masking === 'custom') {
170
+ emit(`Names or terms to hide, one per line; finish with an empty line. Enter alone keeps ${terms.length} saved terms. This input stays local.\n`)
171
+ const entered = []
172
+ while (true) {
173
+ const next = await lines.next()
174
+ if (next.done) { emit('\nCancelled; nothing saved.\n'); return }
175
+ if (!next.value.trim()) break
176
+ entered.push(next.value.trim())
177
+ validateTerms(entered)
178
+ }
179
+ terms = entered.length ? validateTerms(entered) : terms
180
+ if (!terms.length) throw new Error('Custom masking needs at least one term; nothing saved.')
181
+ }
182
+ const saved = store.read()
183
+ const result = questions === SETTINGS ? { ...(saved || DEFAULTS), ...choices }
184
+ : { ...(saved || DEFAULTS), ...choices, terms, view: !saved || (saved.work && saved.work !== choices.work) ? (choices.work === 'multiple' ? 'portfolio' : 'current') : saved.view }
185
+ emit('\n' + describe(result, questions) + (choices.masking === 'custom' ? `\n${terms.length} custom terms ready to save locally.` : '') + '\nSave these choices? [y/N] ')
186
+ const next = await lines.next()
187
+ if (!next.done && /^y(es)?$/i.test(next.value.trim())) { store.save(result); emit('\nSetup saved. Change it anytime with fde setup.\n' + nextStep(result) + '\n') }
188
+ else emit('\nCancelled; nothing saved.\n')
189
+ } finally { rl.close() }
190
+ }
191
+ module.exports = { createSetup, command, DEFAULTS, nextStep }
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.29.0",
3
+ "version": "3.31.0",
4
4
  "private": true,
5
5
  "description": "Thin stdio MCP sink for FDEOps ingest (stage → propose → apply). Zero runtime dependencies.",
6
6
  "bin": {
@@ -176,7 +176,13 @@ function cliPayload(out) {
176
176
 
177
177
  function toolResult(payload) {
178
178
  let text
179
- try { text = masking.mask(typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2)) }
179
+ try {
180
+ const clean = value => typeof value === 'string' ? masking.mask(value)
181
+ : Array.isArray(value) ? value.map(clean)
182
+ : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, clean(item)])) : value
183
+ const safe = clean(payload)
184
+ text = typeof safe === 'string' ? safe : JSON.stringify(safe, null, 2)
185
+ }
180
186
  catch (_) { return { isError: true, content: [{ type: 'text', text: 'privacy masking unavailable; no unmasked tool output returned' }] } }
181
187
  return { content: [{ type: 'text', text }] }
182
188
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.29.0",
3
+ "version": "3.31.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.29.0",
4
+ "version": "3.31.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",
@@ -56,9 +56,15 @@ 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. If `configured` is false, bind the named client, then run `fde setup` and present its three questions together: how they work, what would help first, and what to mask. Save their explicit answers; never infer permission to share data. For custom masking, the optional fourth question asks them to enter terms **locally** with `fde setup`, or give a local terms-file path. Do not ask them to paste sensitive names into chat or open that file with model-facing file tools. Pass the path directly to `--terms-file`; inspect only the returned count, never `.preferences.json` or the alias dictionary. If they skip, keep existing defaults.
62
+
63
+ Use `work` to tailor the help: single = focus on the bound client; multiple = portfolio overview with one bound client per write; team = clarify responsibility and handoff, without implying shared storage. `start` chooses the initial route when no more specific request or record determines it: new → land, daily → triage, takeover → audit. Current client evidence and the user's request always take precedence; never restart an existing engagement because of this preference. `masking` selects standard patterns or those plus custom terms. Older technical settings remain valid; offer personal setup when requested rather than resetting them. Do not ask again per client. `fde setup --settings` keeps display, context size and report masking editable. Choices do not configure models or approve client data use.
64
+
59
65
  ## Entry (every session)
60
66
 
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.
67
+ 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
68
  2. **NO ENGAGEMENT:** ask "What should we call this client?" then **you** init. Pasted notes → debrief after bind.
63
69
  3. Playback 2-3 lines. `hygiene:` → offer `fde doctor`; **never auto-rewrite**.
64
70
  4. Route. Read **one** `references/*.md`. Confirm, then write.
@@ -214,4 +220,4 @@ Ready to build with no `terrain.md` / plan: discover or plan first. Takeover wit
214
220
 
215
221
  ## Identifier masking
216
222
 
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.
223
+ 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. Custom masking additionally hides the literal names or terms the user supplied locally, ignoring letter case and matching whole terms. It does not infer variants or discover names. Names, company names, addresses, and unrecognized formats are otherwise not automatically detected: keep sensitive prose in `<private>` blocks. Direct file tools, pasted chat, and upstream source MCPs bypass this boundary.