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 +2 -0
- package/bin/fde.js +58 -29
- package/bin/install.js +1 -1
- package/bin/lib/context.js +2 -2
- package/bin/lib/masking.js +26 -6
- package/bin/lib/memory.js +3 -3
- package/bin/lib/setup.js +191 -0
- package/bin/lib/trust.js +4 -4
- 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 +8 -2
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',
|
|
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(
|
|
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 } =
|
|
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
|
-
?
|
|
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 =
|
|
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 } =
|
|
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
|
-
?
|
|
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
|
|
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
|
|
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]
|
|
4135
|
-
fde dashboard [--all] [--open] [--out <path>]
|
|
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(
|
|
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
|
|
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
|
|
package/bin/lib/context.js
CHANGED
|
@@ -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 =
|
|
19
|
+
let maxBytes = defaultBytes
|
|
20
20
|
const at = rest.indexOf('--max-bytes')
|
|
21
21
|
if (at !== -1) {
|
|
22
22
|
const raw = rest[at + 1]
|
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
ADDED
|
@@ -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
|
|
@@ -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
|
@@ -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
|
|
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.
|