fdeops 3.30.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,7 +56,7 @@ 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).
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
60
 
61
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.
62
62
 
package/bin/fde.js CHANGED
@@ -98,9 +98,9 @@ function gitIsAncestor(eng, olderHash, newerHash) {
98
98
  } catch (_) { return false }
99
99
  }
100
100
 
101
- function gitLogHash(eng, args) {
101
+ function gitLogHash(eng, args, format = '%H') {
102
102
  try {
103
- return execFileSync('git', ['log', '-1', '--format=%H', ...args], {
103
+ return execFileSync('git', ['log', '-1', `--format=${format}`, ...args], {
104
104
  cwd: eng, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 15000,
105
105
  }).trim()
106
106
  } catch (_) { return '' }
@@ -821,7 +821,7 @@ function phaseLabel(phase) {
821
821
  // colon) collapses to '' here, which callers treat as "nothing to show".
822
822
  function firstLine(md, maxLen) {
823
823
  for (const raw of md.split('\n')) {
824
- const l = raw.trim()
824
+ const l = maskDisplay(raw).trim()
825
825
  if (!l || /^#{1,6}\s/.test(l)) continue
826
826
  const clean = maskDisplay(l.replace(/^\*\*[^*]+:\*\*\s*/, '').replace(/\*\*/g, '').replace(/^["']|["']$/g, '').trim())
827
827
  if (!clean) continue
@@ -1041,7 +1041,7 @@ function parseSignalHistoryEntries(eng) {
1041
1041
  }
1042
1042
 
1043
1043
  function displayNameFromSignalText(text) {
1044
- if (preferences.privacy === 'reports' && masking.mask(text) !== text) return 'Contact (identifier masked)'
1044
+ if ((preferences.privacy === 'reports' || !['dashboard', 'vault'].includes(process.argv[2])) && masking.mask(text) !== text) return 'Contact (identifier masked)'
1045
1045
  const person = personFromSignalText(text)
1046
1046
  if (person) return person
1047
1047
  const t = String(text).trim()
@@ -1166,7 +1166,7 @@ function extractRisks(eng) {
1166
1166
  // sides look like real numbers/percentages, deduped by value pair, capped at
1167
1167
  // 4; nothing reliable found -> the widget stays empty, never an invented number.
1168
1168
  function extractStats(eng) {
1169
- const text = readClean(eng, 'delivery.md') + '\n' + readClean(eng, 'decisions.md')
1169
+ const text = maskDisplay(readClean(eng, 'delivery.md') + '\n' + readClean(eng, 'decisions.md'))
1170
1170
  const patterns = [
1171
1171
  /(\d+(?:\.\d+)?%)[^\n%]{0,40}?(?:→|->)[^\n%]{0,20}?(\d+(?:\.\d+)?%)/g, // "X% ... -> Y%"
1172
1172
  /(\d+(?:\.\d+)?%)\s+to\s+(\d+(?:\.\d+)?%)/gi, // "X% to Y%"
@@ -1458,6 +1458,7 @@ function cmdResume(args) {
1458
1458
  const risks = readClean(eng, 'risks.md')
1459
1459
  process.stdout.write(maskedSections([
1460
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.` : '',
1461
1462
  `${intro}\n\nENGAGEMENT: ${eng}`,
1462
1463
  success ? `CURRENT GOALS & ACCEPTANCE - success.md\n${success}` : '',
1463
1464
  risks ? `OPEN RISKS - risks.md\n${extractRisks(eng).map(r => r.text).join('\n') || '(none recorded)'}` : '',
@@ -1575,7 +1576,7 @@ function cmdLog(args) {
1575
1576
  if (hit && force) console.error(`warning: logging possible ${hit} (--force)`)
1576
1577
  if (retire) {
1577
1578
  const n = retireOpenRisks(eng, text)
1578
- 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) }
1579
1580
  const hash = commitMemory(eng, 'retire risk', { files: ['risks.md'] })
1580
1581
  console.log(`retired ${n} risk(s) → risks.md${hash ? ` @${hash}` : ''}`)
1581
1582
  return
@@ -2005,7 +2006,7 @@ function changeReviewIssues(eng) {
2005
2006
  const del = latestDeliveryEntry(readClean(eng, 'delivery.md'))
2006
2007
  const dec = latestDatedDecision(readClean(eng, 'decisions.md'))
2007
2008
  const delHash = del.line
2008
- ? sh(`git log -1 --format=%H -S${JSON.stringify(del.line)} -- delivery.md`, eng)
2009
+ ? gitLogHash(eng, ['-S', del.line, '--', 'delivery.md'])
2009
2010
  : ''
2010
2011
  if (del.date && dec.date && dec.date > del.date && delHash) {
2011
2012
  issues.push(
@@ -2819,7 +2820,7 @@ function silentCommitIssues(eng) {
2819
2820
  // Pickaxe on the entry text, not the file: a later status edit to
2820
2821
  // delivery.md must not become the cutoff and hide commits before it.
2821
2822
  const raw = entry.line
2822
- ? sh(`git log -1 --format=%cI -S${JSON.stringify(entry.line)} -- delivery.md`, eng)
2823
+ ? gitLogHash(eng, ['-S', entry.line, '--', 'delivery.md'], '%cI')
2823
2824
  : ''
2824
2825
  // git --since is inclusive at second grain; a commit in the same second as
2825
2826
  // the receipt is the receipt's own work, not a silent one.
@@ -3355,10 +3356,10 @@ function cmdRedact(args) {
3355
3356
  })
3356
3357
  }
3357
3358
  if (!hits.length) {
3358
- console.log(`redact: no lines contain ${JSON.stringify(term)}`)
3359
+ console.log(`redact: no lines contain ${JSON.stringify(masking.mask(term))}`)
3359
3360
  return
3360
3361
  }
3361
- 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))}`)
3362
3363
  hits.slice(0, 20).forEach(h => {
3363
3364
  const preview = h.line.length > 100 ? masking.mask(h.line).slice(0, 97) + '…' : h.line
3364
3365
  console.log(` ${h.file}:${h.lineNo} ${preview}`)
@@ -3758,13 +3759,13 @@ function cmdDashboard(args) {
3758
3759
  ].map(([f, title]) => [title, readClean(e.dir, f)])
3759
3760
  .filter(([, md]) => render.hasRealContent(md))
3760
3761
  .map(([title, md]) => ({ title, html: render.mdBlockHtml(maskDisplay(md), parseMdTable) }))
3761
- e.searchBlob = render.escapeHtml([
3762
+ e.searchBlob = render.escapeHtml(maskDisplay([
3762
3763
  e.name, e.next, e.lastSession, e.reality, e.brief,
3763
3764
  ...e.log.map(g => g.text), ...e.risks.map(r => r.text),
3764
3765
  ...e.stakeholders.map(p => `${p.name} ${p.role} ${p.note}`),
3765
3766
  ...e.moreSections.map(s => s.title),
3766
3767
  ...e.valueRows.map(r => `${r.slice} ${r.promised} ${r.measured} ${r.accepted} ${r.evidence}`),
3767
- ].join(' ').toLowerCase())
3768
+ ].join(' ').toLowerCase()))
3768
3769
  })
3769
3770
 
3770
3771
  const html = render.buildFieldbookHtml({ engagements: maskReport(engagements), today, generatedAt: new Date().toISOString() })
@@ -4170,7 +4171,8 @@ let outputBudget
4170
4171
  if (['resume', 'recall', 'handoff', 'defend'].includes(cmd) && !rawArgs.some(a => ['--full', '--init', '--bind', '--out'].includes(a))) {
4171
4172
  try { outputBudget = context.budgetArgs(rawArgs, (setupStore.read() || setup.DEFAULTS).context === 'compact' ? 4096 : 16384).maxBytes } catch (_) {}
4172
4173
  }
4173
- 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 })
4174
4176
  let args
4175
4177
  try {
4176
4178
  args = rawArgs.map(arg => masking.restore(arg))
@@ -4192,7 +4194,7 @@ switch (cmd) {
4192
4194
  case 'setup': finishAsync(setup.command(setupStore, args)); break
4193
4195
  case 'privacy':
4194
4196
  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.`)
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.`)
4196
4198
  break
4197
4199
  case 'demo': cmdDemo(args); break
4198
4200
  case 'scan': cmdScan(); break
@@ -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)
package/bin/lib/setup.js CHANGED
@@ -5,7 +5,7 @@ const crypto = require('node:crypto')
5
5
  const paths = require('./install-paths')
6
6
 
7
7
  const DEFAULTS = { view: 'current', context: 'standard', privacy: 'agent' }
8
- const QUESTIONS = [
8
+ const SETTINGS = [
9
9
  { key: 'view', title: 'What should your daily overview show?', options: [
10
10
  ['current', 'The client I am working on'], ['portfolio', 'All my clients'],
11
11
  ] },
@@ -17,14 +17,60 @@ const QUESTIONS = [
17
17
  ['reports', 'Agent context and newly generated Fieldbook/vault reports'],
18
18
  ] },
19
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.'
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
+ }
21
39
  function validate(value) {
40
+ const keys = value && Object.keys(value)
41
+ const personal = value && Object.hasOwn(value, 'work')
22
42
  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))) {
43
+ keys.length !== (personal ? 7 : 3) ||
44
+ (personal ? SETTINGS.concat(QUESTIONS) : SETTINGS).some(q => !q.options.some(([v]) => value[q.key] === v))) {
24
45
  throw new Error('Invalid setup choices; run fde setup to see the supported options.')
25
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
+ }
26
51
  return value
27
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
+ }
28
74
  function createSetup(root) {
29
75
  const file = path.join(root, '.preferences.json')
30
76
  function read() {
@@ -33,7 +79,7 @@ function createSetup(root) {
33
79
  paths.checkPath(file)
34
80
  fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK)
35
81
  const st = fs.fstatSync(fd)
36
- if (!st.isFile() || st.nlink !== 1 || st.size > 4096) throw new Error('unsafe')
82
+ if (!st.isFile() || st.nlink !== 1 || st.size > 65536 || (process.platform !== 'win32' && (st.mode & 0o077))) throw new Error('unsafe')
37
83
  return validate(JSON.parse(fs.readFileSync(fd, 'utf8')))
38
84
  } catch (e) {
39
85
  if (e.code === 'ENOENT') return null
@@ -53,57 +99,93 @@ function createSetup(root) {
53
99
  }
54
100
  return { read, save }
55
101
  }
56
- function describe(value) {
57
- return QUESTIONS.map(q => `${q.title} ${q.options.find(([v]) => v === value[q.key])[1]}`).join('\n')
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
58
117
  }
59
118
  function command(store, args) {
60
119
  if (args.length === 1 && args[0] === '--show') {
61
- const saved = store.read()
62
- console.log(JSON.stringify({ configured: !!saved, ...(saved || DEFAULTS) }))
120
+ const saved = store.read(), { terms, ...safe } = saved || DEFAULTS
121
+ console.log(JSON.stringify({ configured: !!saved, ...safe, ...(terms ? { termCount: terms.length, nextStep: nextStep(saved) } : {}) }))
63
122
  return
64
123
  }
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')
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.')
69
128
  for (let i = 0; i < rest.length; i += 2) {
70
129
  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.')
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.')
72
132
  choices[key] = rest[i + 1]
73
133
  }
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)
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)
77
138
  return
78
139
  }
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.')
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.')
83
149
  }
84
- async function interactive(store) {
85
- // Only static prompts and validated enum labels bypass buffered CLI output.
150
+ async function interactive(store, questions) {
86
151
  const emit = text => fs.writeSync(1, text)
152
+ const current = { ...PROFILE_DEFAULTS, ...(store.read() || DEFAULTS) }, choices = {}
87
153
  const rl = require('node:readline').createInterface({ input: process.stdin, terminal: false })
88
154
  const lines = rl[Symbol.asyncIterator]()
89
- const current = store.read() || DEFAULTS, choices = {}
90
155
  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) {
156
+ emit('Make FDEOps fit your work. Enter keeps the current choice. Ctrl-C cancels.\n' + LIMITS + '\n')
157
+ for (const q of questions) {
93
158
  const selected = q.options.findIndex(([v]) => v === current[q.key])
94
159
  while (true) {
95
160
  emit(q.title + '\n' + q.options.map(([, label], i) => ` ${i + 1}. ${label}${i === selected ? ' (current)' : ''}`).join('\n') + '\n> ')
96
161
  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')
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')
101
166
  }
102
167
  }
103
- emit('\n' + describe(choices) + '\nSave these settings? [y/N] ')
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] ')
104
186
  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')
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')
107
189
  } finally { rl.close() }
108
190
  }
109
- module.exports = { createSetup, command, DEFAULTS }
191
+ module.exports = { createSetup, command, DEFAULTS, nextStep }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops-ingest-mcp",
3
- "version": "3.30.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.30.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.30.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",
@@ -58,7 +58,9 @@ Fallbacks: `node ~/.claude/fdeops/fde.js …`, then `npx --yes fdeops …`. Skil
58
58
 
59
59
  ## First-use preferences
60
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.
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.
62
64
 
63
65
  ## Entry (every session)
64
66
 
@@ -218,4 +220,4 @@ Ready to build with no `terrain.md` / plan: discover or plan first. Takeover wit
218
220
 
219
221
  ## Identifier masking
220
222
 
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.
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.