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 +1 -1
- package/bin/fde.js +16 -14
- package/bin/lib/masking.js +26 -6
- package/bin/lib/memory.js +3 -3
- package/bin/lib/setup.js +115 -33
- package/mcp/fdeops-ingest/package.json +1 -1
- package/mcp/fdeops-ingest/server.js +7 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/fde/SKILL.md +4 -2
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
|
|
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',
|
|
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
|
-
?
|
|
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
|
-
?
|
|
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(
|
|
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
|
|
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
|
package/bin/lib/masking.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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('/').
|
|
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
|
|
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
|
|
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
|
-
|
|
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 >
|
|
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
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
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 (
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
92
|
-
for (const q of
|
|
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('\
|
|
98
|
-
const value = next.value.trim(), index = value === '' ? selected : /^[
|
|
99
|
-
if (index >= 0) { choices[q.key] = q.options[index][0]; break }
|
|
100
|
-
emit('Choose
|
|
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
|
-
|
|
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(
|
|
106
|
-
else emit('\
|
|
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 }
|
|
@@ -176,7 +176,13 @@ function cliPayload(out) {
|
|
|
176
176
|
|
|
177
177
|
function toolResult(payload) {
|
|
178
178
|
let text
|
|
179
|
-
try {
|
|
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.
|
|
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.
|
|
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",
|
package/skills/fde/SKILL.md
CHANGED
|
@@ -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
|
|
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.
|