fdeops 3.28.0 → 3.29.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/check.js +2 -7
- package/bin/fde.js +71 -28
- package/bin/lib/context.js +2 -2
- package/bin/lib/masking.js +138 -0
- package/bin/lib/memory.js +5 -2
- 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 +6 -2
- package/skills/fde/references/debrief.md +23 -24
- package/skills/fde/references/discover.md +6 -2
- package/skills/fde/references/ingest.md +6 -6
- package/skills/fde/references/land.md +2 -0
package/README.md
CHANGED
|
@@ -247,7 +247,7 @@ If your work has no client commitments or operating handover to track, a simpler
|
|
|
247
247
|
|
|
248
248
|
The CLI works with local files and Git, without network calls or telemetry. Client records remain readable Markdown if you stop using FDEOps.
|
|
249
249
|
|
|
250
|
-
Your AI host may send the material it reads to its configured model. FDEOps redacts `<private>` blocks from CLI, dashboard, and hook outputs; do not load those raw blocks through the agent's file tools. Review reports before sharing client information.
|
|
250
|
+
Your AI host may send the material it reads to its configured model. CLI context and smart proposals mask common email, phone, SSN-shaped, and credential patterns locally; this is not complete PII detection. FDEOps redacts `<private>` blocks from CLI, dashboard, and hook outputs; do not load those raw blocks through the agent's file tools. Review reports before sharing client information.
|
|
251
251
|
|
|
252
252
|
You review proposed decisions. Enabled session hooks can save where the session left off automatically; direct CLI write commands update records when you run them.
|
|
253
253
|
|
package/bin/check.js
CHANGED
|
@@ -354,7 +354,7 @@ if (/\broutes methods\b|\bphase methods\b|\bengagement methods\b/.test(usage)) {
|
|
|
354
354
|
ok('README tone')
|
|
355
355
|
|
|
356
356
|
if (fs.existsSync(path.join(root, '.codex')) || fs.existsSync(path.join(root, '.opencode'))) {
|
|
357
|
-
fail('.codex/ or .opencode/ must not live at repo root -
|
|
357
|
+
fail('.codex/ or .opencode/ must not live at repo root - keep personal agent setup outside the public repository')
|
|
358
358
|
} else ok('no root-level experimental stubs')
|
|
359
359
|
|
|
360
360
|
for (const rel of ['docs/schema.md', 'docs/skills-reference.md', 'PRIVACY.md']) {
|
|
@@ -415,18 +415,13 @@ ok('examples walkthrough files')
|
|
|
415
415
|
}
|
|
416
416
|
|
|
417
417
|
if (fs.existsSync(path.join(root, 'tasks', 'plan.md'))) {
|
|
418
|
-
fail('tasks/plan.md should not be in public tree
|
|
418
|
+
fail('tasks/plan.md should not be in the public tree; keep work plans outside the repository')
|
|
419
419
|
}
|
|
420
420
|
|
|
421
421
|
if (fs.existsSync(path.join(root, 'patterns'))) {
|
|
422
422
|
fail('patterns/ is deprecated - use skills/ only (overlays live there)')
|
|
423
423
|
}
|
|
424
424
|
|
|
425
|
-
const pmf = path.join(root, 'docs', 'internal', 'PMF_360_REVIEW.md')
|
|
426
|
-
if (fs.existsSync(pmf) && !read('docs/internal/PMF_360_REVIEW.md').includes('INTERNAL')) {
|
|
427
|
-
fail('PMF_360_REVIEW.md needs INTERNAL banner')
|
|
428
|
-
} else if (fs.existsSync(pmf)) ok('internal PMF banner')
|
|
429
|
-
|
|
430
425
|
const hook = read('hooks/session-start')
|
|
431
426
|
const hookCode = hook.replace(/^[ \t]*#.*$/gm, '')
|
|
432
427
|
if (!hook.includes('FDEOPS_ENGAGEMENT')) {
|
package/bin/fde.js
CHANGED
|
@@ -45,6 +45,13 @@ const HOME = os.homedir()
|
|
|
45
45
|
const ENGAGEMENTS_ROOT = ((process.env.FDEOPS_ENGAGEMENTS_ROOT || '').trim().replace(/^~/, HOME))
|
|
46
46
|
|| path.join(HOME, 'fde-engagements')
|
|
47
47
|
const REGISTRY = path.join(ENGAGEMENTS_ROOT, '.registry')
|
|
48
|
+
const masking = require('./lib/masking').createMasking(ENGAGEMENTS_ROOT)
|
|
49
|
+
function maskDisplay(text) {
|
|
50
|
+
return ['dashboard', 'vault'].includes(process.argv[2]) ? String(text) : masking.mask(text)
|
|
51
|
+
}
|
|
52
|
+
function maskedSections(sections, maxBytes) {
|
|
53
|
+
return context.boundedSections(sections.map(text => masking.mask(text)), maxBytes)
|
|
54
|
+
}
|
|
48
55
|
const DEBRIEF_MAX_BYTES = 256 * 1024
|
|
49
56
|
const CODE_EXT = ['.js', '.ts', '.tsx', '.jsx', '.py', '.java', '.go', '.rb', '.cs', '.php']
|
|
50
57
|
const CONF_EXT = CODE_EXT.concat(['.env', '.yaml', '.yml', '.json'])
|
|
@@ -114,7 +121,7 @@ function grepFiles(files, regex, cap) {
|
|
|
114
121
|
try { text = fs.readFileSync(f, 'utf8') } catch (_) { continue }
|
|
115
122
|
const lines = text.split('\n')
|
|
116
123
|
for (let i = 0; i < lines.length && hits.length < cap; i++) {
|
|
117
|
-
if (regex.test(lines[i])) hits.push({ file: path.relative(process.cwd(), f), line: i + 1, text: lines[i].trim().slice(0, 120) })
|
|
124
|
+
if (regex.test(lines[i])) hits.push({ file: path.relative(process.cwd(), f), line: i + 1, text: maskDisplay(lines[i].trim()).slice(0, 120) })
|
|
118
125
|
}
|
|
119
126
|
}
|
|
120
127
|
return hits
|
|
@@ -798,7 +805,7 @@ function firstLine(md, maxLen) {
|
|
|
798
805
|
for (const raw of md.split('\n')) {
|
|
799
806
|
const l = raw.trim()
|
|
800
807
|
if (!l || /^#{1,6}\s/.test(l)) continue
|
|
801
|
-
const clean = l.replace(/^\*\*[^*]+:\*\*\s*/, '').replace(/\*\*/g, '').replace(/^["']|["']$/g, '').trim()
|
|
808
|
+
const clean = maskDisplay(l.replace(/^\*\*[^*]+:\*\*\s*/, '').replace(/\*\*/g, '').replace(/^["']|["']$/g, '').trim())
|
|
802
809
|
if (!clean) continue
|
|
803
810
|
return clean.length > maxLen ? clean.slice(0, maxLen - 1).trim() + '…' : clean
|
|
804
811
|
}
|
|
@@ -811,7 +818,7 @@ function firstLine(md, maxLen) {
|
|
|
811
818
|
function parseReality(md, maxLen) {
|
|
812
819
|
const theory = (md.match(/\*\*Working theory:\*\*[^\S\r\n]*(.*)/i) || [])[1]
|
|
813
820
|
const hasSchema = /\*\*(Working theory|Evidence|Differs from brief how):\*\*/i.test(md)
|
|
814
|
-
const theoryText = (theory || '').trim()
|
|
821
|
+
const theoryText = maskDisplay((theory || '').trim())
|
|
815
822
|
if (theoryText) {
|
|
816
823
|
const line = theoryText.length > maxLen ? theoryText.slice(0, maxLen - 1).trim() + '…' : theoryText
|
|
817
824
|
return { line, missing: '' }
|
|
@@ -1081,14 +1088,14 @@ function extractStakeholders(eng) {
|
|
|
1081
1088
|
for (const [key, h] of latest) {
|
|
1082
1089
|
if (byKey.has(key)) {
|
|
1083
1090
|
const cur = byKey.get(key)
|
|
1084
|
-
byKey.set(key, { ...cur, signal: h.signal, note: cur.note || h.text.slice(0, 80) })
|
|
1091
|
+
byKey.set(key, { ...cur, signal: h.signal, note: cur.note || maskDisplay(h.text).slice(0, 80) })
|
|
1085
1092
|
} else {
|
|
1086
1093
|
const name = displayNameFromSignalText(h.text)
|
|
1087
1094
|
if (!name || isSignalNameNoise(name)) continue
|
|
1088
1095
|
byKey.set(key, {
|
|
1089
1096
|
name,
|
|
1090
1097
|
role: '',
|
|
1091
|
-
note: h.text.slice(0, 80),
|
|
1098
|
+
note: maskDisplay(h.text).slice(0, 80),
|
|
1092
1099
|
signal: h.signal,
|
|
1093
1100
|
source: 'signal',
|
|
1094
1101
|
})
|
|
@@ -1426,7 +1433,7 @@ function cmdResume(args) {
|
|
|
1426
1433
|
const policy = readClean(eng, 'trust-profile.md')
|
|
1427
1434
|
const success = readClean(eng, 'success.md')
|
|
1428
1435
|
const risks = readClean(eng, 'risks.md')
|
|
1429
|
-
process.stdout.write(
|
|
1436
|
+
process.stdout.write(maskedSections([
|
|
1430
1437
|
policy ? `CLIENT POLICY - trust-profile.md\n${policy}` : '',
|
|
1431
1438
|
`${intro}\n\nENGAGEMENT: ${eng}`,
|
|
1432
1439
|
success ? `CURRENT GOALS & ACCEPTANCE - success.md\n${success}` : '',
|
|
@@ -1832,6 +1839,7 @@ function readDebriefInput(args) {
|
|
|
1832
1839
|
}
|
|
1833
1840
|
|
|
1834
1841
|
function previewLine(text, max = 240) {
|
|
1842
|
+
text = masking.mask(text)
|
|
1835
1843
|
const t = String(text || '').replace(/\s+/g, ' ').trim()
|
|
1836
1844
|
if (t.length <= max) return t
|
|
1837
1845
|
return `${t.slice(0, max)}… (${t.length} chars)`
|
|
@@ -1845,7 +1853,8 @@ function writeProposal(eng, text, { replace = false, locked = false } = {}) {
|
|
|
1845
1853
|
finally { ownedDebriefLocks.delete(path.join(eng, DEBRIEF_PROPOSE)) }
|
|
1846
1854
|
}, { soft: true })
|
|
1847
1855
|
if (!debriefTransactionActive) return withDebriefRecords(eng, () => writeProposal(eng, text, { replace, locked: true }), [DEBRIEF_PROPOSE, DEBRIEF_PRIVATE, DEBRIEF_SEAL])
|
|
1848
|
-
const { clean, blocks } = splitPrivate(text, { sealDangling: true })
|
|
1856
|
+
const { clean: original, blocks } = splitPrivate(text, { sealDangling: true })
|
|
1857
|
+
const clean = masking.mask(original)
|
|
1849
1858
|
const proposePath = path.join(eng, DEBRIEF_PROPOSE)
|
|
1850
1859
|
const privatePath = path.join(eng, DEBRIEF_PRIVATE)
|
|
1851
1860
|
if (fs.existsSync(proposePath) && !replace) {
|
|
@@ -1887,6 +1896,7 @@ function stripApprovedStamp(text) {
|
|
|
1887
1896
|
// One screen a human can confirm in two minutes. The file-by-file routing
|
|
1888
1897
|
// still prints after this - agents edit prefixes; people read this.
|
|
1889
1898
|
function printDebriefReview(text, eng) {
|
|
1899
|
+
text = masking.restore(text)
|
|
1890
1900
|
const repeats = repeatedDebriefStatements(eng, text)
|
|
1891
1901
|
if (repeats.length) console.log(`REPLAY WARNING: ${repeats.length} source-backed statement(s) already recorded. Review newer facts and next action; applying again requires --allow-replay.\n`)
|
|
1892
1902
|
const buckets = { decided: [], asked: [], scope: [], delivery: [], open: [], next: [], signer: [] }
|
|
@@ -2091,6 +2101,8 @@ function repeatedDebriefStatements(eng, input) {
|
|
|
2091
2101
|
}
|
|
2092
2102
|
|
|
2093
2103
|
function routeDebriefInput(eng, input, { dry, force, sealed = [], allowReplay = false }) {
|
|
2104
|
+
input = masking.restore(input)
|
|
2105
|
+
masking.mask(splitPrivate(input, { sealDangling: true }).clean)
|
|
2094
2106
|
if (!dry && !debriefTransactionActive) {
|
|
2095
2107
|
ensureMemoryGit(eng)
|
|
2096
2108
|
return withDebriefRecords(eng, () => {
|
|
@@ -2207,7 +2219,11 @@ function runDebrief(args, eng) {
|
|
|
2207
2219
|
const refused = refuseSymlinkWrite(proposal, { soft: true })
|
|
2208
2220
|
if (refused) throw new Error(refused)
|
|
2209
2221
|
if (!fs.existsSync(proposal)) throw new Error('nothing to review - run debrief --smart <notes> first')
|
|
2210
|
-
const
|
|
2222
|
+
const stored = fs.readFileSync(proposal, 'utf8')
|
|
2223
|
+
if (splitPrivate(stored, { sealDangling: true }).blocks.length) throw new Error('pending proposal contains raw private content: do not open it with an agent. Recreate it through debrief --smart from local notes, using --replace-proposal only after confirmation.')
|
|
2224
|
+
const { clean: input } = splitPrivate(masking.restore(stored), { sealDangling: true })
|
|
2225
|
+
const safe = masking.mask(input)
|
|
2226
|
+
if (safe !== stored) atomicWriteFile(proposal, safe, { mode: 0o600, soft: true })
|
|
2211
2227
|
return boundedDebriefPreview(eng, () => {
|
|
2212
2228
|
printDebriefReview(input, eng)
|
|
2213
2229
|
routeDebriefInput(eng, input, { dry: true, force: false })
|
|
@@ -2244,6 +2260,7 @@ function runDebrief(args, eng) {
|
|
|
2244
2260
|
console.error('nothing to apply - run: fde debrief --smart <notes.md> then fde debrief --apply')
|
|
2245
2261
|
return void (process.exitCode = 1)
|
|
2246
2262
|
}
|
|
2263
|
+
input = masking.restore(input)
|
|
2247
2264
|
sealed = readSealedProposal(eng)
|
|
2248
2265
|
const expected = readSealCount(eng)
|
|
2249
2266
|
if (expected === null ? (!sealed.length && input.includes(PRIVATE_MARKER)) : sealed.length < expected) {
|
|
@@ -2465,7 +2482,7 @@ function cmdIngest(args) {
|
|
|
2465
2482
|
const body = [
|
|
2466
2483
|
'---',
|
|
2467
2484
|
`source: ${source}`,
|
|
2468
|
-
`title: ${title.replace(/\n/g, ' ')
|
|
2485
|
+
`title: ${title.replace(/\n/g, ' ')}`,
|
|
2469
2486
|
`staged: ${new Date().toISOString()}`,
|
|
2470
2487
|
`id: ${id}`,
|
|
2471
2488
|
'---',
|
|
@@ -2498,7 +2515,7 @@ function cmdReceipts(args) {
|
|
|
2498
2515
|
document.split('\n').forEach((line, i) => {
|
|
2499
2516
|
if (!line.toLowerCase().includes(term.toLowerCase())) return
|
|
2500
2517
|
const source = decisionSources.get(i + 1) || sourceReference(line)
|
|
2501
|
-
const hit = ` ${file}:${i + 1} ${line.trim().slice(0, 160)}${source ? ` [source: ${source.slice(0, 160)}]` : ' [source missing]'}${dirty.has(file) ? ' dirty file - review manual edits' : ''}`
|
|
2518
|
+
const hit = ` ${file}:${i + 1} ${masking.mask(line.trim()).slice(0, 160)}${source ? ` [source: ${masking.mask(source).slice(0, 160)}]` : ' [source missing]'}${dirty.has(file) ? ' dirty file - review manual edits' : ''}`
|
|
2502
2519
|
;(recordFiles.includes(file) && source ? records : claims).push({ file, hit })
|
|
2503
2520
|
})
|
|
2504
2521
|
}
|
|
@@ -2524,7 +2541,7 @@ function cmdReceipts(args) {
|
|
|
2524
2541
|
if (records.length) sections.push('ON RECORD (dated, source-backed):\n' + select(records))
|
|
2525
2542
|
if (claims.length) sections.push('CLAIMS & working notes (verify source and approval before citing):\n' + select(claims))
|
|
2526
2543
|
if (!records.length && !claims.length) sections.push(`no record of "${term}" - a gap in the record, not proof of absence`)
|
|
2527
|
-
process.stdout.write(
|
|
2544
|
+
process.stdout.write(maskedSections(sections))
|
|
2528
2545
|
}
|
|
2529
2546
|
|
|
2530
2547
|
// Portable snapshot; stdout is read-only. --out creates a new file and never
|
|
@@ -2552,7 +2569,7 @@ function cmdHandoff(args, label = 'Handoff') {
|
|
|
2552
2569
|
const decisionText = d => `${d.text} (decisions.md:${d.line}, redacted view)`
|
|
2553
2570
|
const next = stripTemplateNoise(sectionBody(readClean(eng, 'context.md'), 'Next action', { lastNonEmpty: true }))
|
|
2554
2571
|
const gaps = collectDoctorIssues(eng, { readiness: true })
|
|
2555
|
-
const report =
|
|
2572
|
+
const report = maskedSections([
|
|
2556
2573
|
`# ${label}: ${engagementSlugFromPath(eng)}\nSnapshot: ${new Date().toISOString()} · memory ${memoryHead(eng) || 'unversioned'}\nRead-only record, not proof of approval. Confirm sources with the named customer before relying on a claim. Private blocks are excluded; review remaining client information before sharing.`,
|
|
2557
2574
|
`## Constraints - trust-profile.md\n${stripTemplateNoise(readClean(eng, 'trust-profile.md')) || '(missing)'}`,
|
|
2558
2575
|
`## Signer and success - success.md\nSigner: ${signer || '(missing; do not infer)'}\n${success || '(missing)'}`,
|
|
@@ -2585,8 +2602,8 @@ function cmdRecall(args) {
|
|
|
2585
2602
|
const eng = resolveEngagement()
|
|
2586
2603
|
if (!eng) { console.error('no engagement - bind a client before recall'); process.exit(2) }
|
|
2587
2604
|
const files = ['context.md', 'trust-profile.md', 'success.md', 'decisions.md', 'risks.md', 'delivery.md', 'stakeholders.md', 'brief.md', 'reality.md', 'assumptions.md', 'terrain.md', 'handoff.md']
|
|
2588
|
-
const result = context.recallSections(files.map(file => ({ file, text: readClean(eng, file) })), query)
|
|
2589
|
-
process.stdout.write(
|
|
2605
|
+
const result = context.recallSections(files.map(file => ({ file, text: readClean(eng, file) })), query, 12, masking.mask)
|
|
2606
|
+
process.stdout.write(maskedSections([
|
|
2590
2607
|
`RECALL - ${eng}\n${result.total ? `${result.sections.length} of ${result.total} matching lines; refine the query if evidence is omitted.` : 'No matching record. This is not proof that the event never happened.'}\nSources are local record assertions; verify dates, supersession and approval scope.`,
|
|
2591
2608
|
...result.sections,
|
|
2592
2609
|
], maxBytes))
|
|
@@ -2597,7 +2614,7 @@ function cmdCapture() {
|
|
|
2597
2614
|
if (!eng) process.exit(0) // silent: capture must never break a session
|
|
2598
2615
|
// Workspace git facts (cwd), not the engagement memory repo.
|
|
2599
2616
|
const branch = sh('git branch --show-current')
|
|
2600
|
-
const lastCommit = sh("git log -1 --format='%h %s'")
|
|
2617
|
+
const lastCommit = sh("git log -1 --format='%h %s'")
|
|
2601
2618
|
// porcelain lines are "XY path" - sh() trims, so parse by first whitespace
|
|
2602
2619
|
const changed = sh('git status --porcelain').split('\n').filter(Boolean).slice(0, 8)
|
|
2603
2620
|
.map(l => l.trim().split(/\s+/).slice(1).join(' ')).join(' ')
|
|
@@ -2942,7 +2959,7 @@ function collectDoctorIssues(eng, { readiness = false } = {}) {
|
|
|
2942
2959
|
}
|
|
2943
2960
|
const dupes = findDuplicateOpenRisks(eng)
|
|
2944
2961
|
if (dupes.length) {
|
|
2945
|
-
const sample = (dupes[0][0] || '').replace(/\s+/g, ' ').trim().slice(0, 60)
|
|
2962
|
+
const sample = maskDisplay((dupes[0][0] || '').replace(/\s+/g, ' ').trim()).slice(0, 60)
|
|
2946
2963
|
issues.push(
|
|
2947
2964
|
`${dupes.length} duplicate open-risk cluster(s) (e.g. "${sample}${sample.length >= 60 ? '…' : ''}") - consolidate or retire echoes in risks.md`
|
|
2948
2965
|
)
|
|
@@ -3218,7 +3235,7 @@ function hasEvalReceipt(eng) {
|
|
|
3218
3235
|
function hygieneTriageLines(eng) {
|
|
3219
3236
|
const issues = collectDoctorIssues(eng)
|
|
3220
3237
|
if (!issues.length) return []
|
|
3221
|
-
const top = issues[0].replace(/\s+/g, ' ').trim().slice(0, 72)
|
|
3238
|
+
const top = maskDisplay(issues[0].replace(/\s+/g, ' ').trim()).slice(0, 72)
|
|
3222
3239
|
return [
|
|
3223
3240
|
` hygiene: ${issues.length} issue(s) - ${top}${issues[0].length > 72 ? '…' : ''}`,
|
|
3224
3241
|
' → say "@fde clean up the fieldbook" when ready (agent runs fde doctor; nothing auto-rewrites), or: fde doctor',
|
|
@@ -3258,14 +3275,14 @@ function recordDigest(eng) {
|
|
|
3258
3275
|
const signer = ((success.match(/^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
|
|
3259
3276
|
// "(none)" rather than a missing line: on session start, nobody named to sign
|
|
3260
3277
|
// off is the fact worth seeing, not an absence to scroll past.
|
|
3261
|
-
const lines = [` signer: ${signer.slice(0, 110) || '(none)'}`]
|
|
3278
|
+
const lines = [` signer: ${masking.mask(signer).slice(0, 110) || '(none)'}`]
|
|
3262
3279
|
const { rows } = parseValueLedger(eng)
|
|
3263
3280
|
const promisedRow = [...rows].reverse().find(r => r.promised)
|
|
3264
3281
|
if (promisedRow) {
|
|
3265
|
-
lines.push(` promised: ${formatValueLedgerLine(promisedRow).slice(0, 110)}`)
|
|
3282
|
+
lines.push(` promised: ${masking.mask(formatValueLedgerLine(promisedRow)).slice(0, 110)}`)
|
|
3266
3283
|
} else {
|
|
3267
3284
|
const target = ((success.match(/^\*\*Baseline[^\S\n]*→[^\S\n]*target:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
|
|
3268
|
-
if (target) lines.push(` promised: ${target.slice(0, 110)}`)
|
|
3285
|
+
if (target) lines.push(` promised: ${masking.mask(target).slice(0, 110)}`)
|
|
3269
3286
|
}
|
|
3270
3287
|
const decisions = datedDecisions(readClean(eng, 'decisions.md')).slice(-2)
|
|
3271
3288
|
for (const d of decisions) lines.push(` decided: ${formatDecisionRecord(d.text)}; source: ${previewLine(sourceReference(d.text) || '(missing)', 100)}`)
|
|
@@ -3320,7 +3337,7 @@ function cmdRedact(args) {
|
|
|
3320
3337
|
}
|
|
3321
3338
|
console.log(`REDACT - ${hits.length} matching line(s) for ${JSON.stringify(term)}`)
|
|
3322
3339
|
hits.slice(0, 20).forEach(h => {
|
|
3323
|
-
const preview = h.line.length > 100 ? h.line.slice(0, 97) + '…' : h.line
|
|
3340
|
+
const preview = h.line.length > 100 ? masking.mask(h.line).slice(0, 97) + '…' : h.line
|
|
3324
3341
|
console.log(` ${h.file}:${h.lineNo} ${preview}`)
|
|
3325
3342
|
})
|
|
3326
3343
|
if (hits.length > 20) console.log(` … +${hits.length - 20} more`)
|
|
@@ -3365,12 +3382,12 @@ function cmdPrep(args) {
|
|
|
3365
3382
|
const people = extractStakeholders(eng).slice(0, 8)
|
|
3366
3383
|
console.log('\nStakeholders (table + signal history)')
|
|
3367
3384
|
if (!people.length) console.log(' (none yet - log contacts with --signal)')
|
|
3368
|
-
else people.forEach(p => console.log(` [${p.signal}] ${p.name}${p.role ? ` - ${p.role}` : ''}${p.note ? ` · ${p.note.slice(0, 60)}` : ''}`))
|
|
3385
|
+
else people.forEach(p => console.log(` [${p.signal}] ${p.name}${p.role ? ` - ${p.role}` : ''}${p.note ? ` · ${masking.mask(p.note).slice(0, 60)}` : ''}`))
|
|
3369
3386
|
|
|
3370
3387
|
const risks = extractRisks(eng).slice(0, 5)
|
|
3371
3388
|
console.log('\nOpen risks (table + dated bullets)')
|
|
3372
3389
|
if (!risks.length) console.log(' (none logged)')
|
|
3373
|
-
else risks.forEach(r => console.log(` [${r.severity}] ${r.text.slice(0, 100)}`))
|
|
3390
|
+
else risks.forEach(r => console.log(` [${r.severity}] ${masking.mask(r.text).slice(0, 100)}`))
|
|
3374
3391
|
|
|
3375
3392
|
const success = firstLine(readClean(eng, 'success.md'), 160)
|
|
3376
3393
|
console.log('\nSuccess looks like')
|
|
@@ -3381,7 +3398,7 @@ function cmdPrep(args) {
|
|
|
3381
3398
|
.slice(-5)
|
|
3382
3399
|
console.log('\nRecent decisions')
|
|
3383
3400
|
if (!decisions.length) console.log(' (none logged)')
|
|
3384
|
-
else decisions.forEach(l => console.log(` ${l.trim().slice(0, 120)}`))
|
|
3401
|
+
else decisions.forEach(l => console.log(` ${masking.mask(l.trim()).slice(0, 120)}`))
|
|
3385
3402
|
|
|
3386
3403
|
const next = nextActionLine(readClean(eng, 'context.md'))
|
|
3387
3404
|
console.log('\nWalk in with')
|
|
@@ -3421,7 +3438,7 @@ function cmdGarden(args) {
|
|
|
3421
3438
|
}
|
|
3422
3439
|
const dupes = findDuplicateOpenRisks(eng)
|
|
3423
3440
|
if (dupes.length) {
|
|
3424
|
-
const sample = (dupes[0][0] || '').replace(/\s+/g, ' ').trim().slice(0, 50)
|
|
3441
|
+
const sample = maskDisplay((dupes[0][0] || '').replace(/\s+/g, ' ').trim()).slice(0, 50)
|
|
3425
3442
|
proposals.push({
|
|
3426
3443
|
id: 'dedupe-risks',
|
|
3427
3444
|
kind: 'apply',
|
|
@@ -3601,7 +3618,7 @@ function cmdStatus(args) {
|
|
|
3601
3618
|
const eng = path.join(ENGAGEMENTS_ROOT, d, '.fde')
|
|
3602
3619
|
if (!fs.existsSync(eng)) continue
|
|
3603
3620
|
const s = computeSignals(eng)
|
|
3604
|
-
const note = [s.memoryWarn, (s.dirtyFiles && s.dirtyFiles.length) ? `dirty:${s.dirtyFiles.length}` : '', s.reason || s.topRisk].filter(Boolean).join(' · ').slice(0, 70)
|
|
3621
|
+
const note = maskDisplay([s.memoryWarn, (s.dirtyFiles && s.dirtyFiles.length) ? `dirty:${s.dirtyFiles.length}` : '', s.reason || s.topRisk].filter(Boolean).join(' · ')).slice(0, 70)
|
|
3605
3622
|
rows.push({ name: d, phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated, reason: note, memoryWarn: s.memoryWarn, dirtyFiles: s.dirtyFiles, valueLines: valueLedgerStatusLines(eng, { compact: true }) })
|
|
3606
3623
|
}
|
|
3607
3624
|
} else {
|
|
@@ -3611,7 +3628,7 @@ function cmdStatus(args) {
|
|
|
3611
3628
|
process.exit(2)
|
|
3612
3629
|
}
|
|
3613
3630
|
const s = computeSignals(eng)
|
|
3614
|
-
const note = [s.memoryWarn, (s.dirtyFiles && s.dirtyFiles.length) ? `dirty:${s.dirtyFiles.length}` : '', s.reason || s.topRisk].filter(Boolean).join(' · ').slice(0, 70)
|
|
3631
|
+
const note = maskDisplay([s.memoryWarn, (s.dirtyFiles && s.dirtyFiles.length) ? `dirty:${s.dirtyFiles.length}` : '', s.reason || s.topRisk].filter(Boolean).join(' · ')).slice(0, 70)
|
|
3615
3632
|
rows.push({ name: engagementSlugFromPath(eng), phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated, reason: note, memoryWarn: s.memoryWarn, dirtyFiles: s.dirtyFiles, valueLines: valueLedgerStatusLines(eng) })
|
|
3616
3633
|
}
|
|
3617
3634
|
if (!rows.length) { console.log('no engagements yet'); return }
|
|
@@ -4085,6 +4102,7 @@ ${fs.existsSync(html) ? `\n Open the fieldbook: ${html}` : ''}
|
|
|
4085
4102
|
function printUsage() {
|
|
4086
4103
|
console.log(`fde - deterministic core of fdeops
|
|
4087
4104
|
fde demo the whole loop on a fake client (fde demo --clean removes it)
|
|
4105
|
+
fde privacy show masking capability and its boundaries
|
|
4088
4106
|
fde scan day-1 recon of this repo (facts, no AI)
|
|
4089
4107
|
fde resume load this workspace's engagement memory (bounded)
|
|
4090
4108
|
fde resume --full load the complete context.md (no bound)
|
|
@@ -4123,12 +4141,32 @@ function printUsage() {
|
|
|
4123
4141
|
ingest is a sink only - source MCPs (Granola/Gmail/…) are user-configured; never ambient sync`)
|
|
4124
4142
|
}
|
|
4125
4143
|
|
|
4126
|
-
const [cmd, ...
|
|
4144
|
+
const [cmd, ...rawArgs] = process.argv.slice(2)
|
|
4145
|
+
let outputBudget
|
|
4146
|
+
if (['resume', 'recall', 'handoff', 'defend'].includes(cmd) && !rawArgs.some(a => ['--full', '--init', '--bind', '--out'].includes(a))) {
|
|
4147
|
+
try { outputBudget = context.budgetArgs(rawArgs).maxBytes } catch (_) {}
|
|
4148
|
+
}
|
|
4149
|
+
require('./lib/masking').protectOutput(masking, { maxBytes: outputBudget })
|
|
4150
|
+
let args
|
|
4151
|
+
try {
|
|
4152
|
+
args = rawArgs.map(arg => masking.restore(arg))
|
|
4153
|
+
for (const key of ['FDEOPS_ENGAGEMENT', 'FDEOS_ENGAGEMENT']) {
|
|
4154
|
+
if (process.env[key]) process.env[key] = masking.restore(process.env[key])
|
|
4155
|
+
}
|
|
4156
|
+
// Resolve privacy-state failures before a user-authorized argument write.
|
|
4157
|
+
args.forEach(arg => masking.mask(arg))
|
|
4158
|
+
}
|
|
4159
|
+
catch (e) { console.error(e.message); process.exit(1) }
|
|
4127
4160
|
if (args.includes('--help') || args.includes('-h') || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
4128
4161
|
printUsage()
|
|
4129
4162
|
process.exit(0)
|
|
4130
4163
|
}
|
|
4164
|
+
try {
|
|
4131
4165
|
switch (cmd) {
|
|
4166
|
+
case 'privacy':
|
|
4167
|
+
if (args.length) { console.error('usage: fde privacy'); process.exitCode = 2; break }
|
|
4168
|
+
console.log(`FDEOps ${require('../package.json').version} - identifier masking enabled by default.\nCLI responses, smart proposals, handoff packets and ingest MCP results use local aliases.\nPatterns: common emails, international/US phones, SSN-shaped identifiers and supported credentials.\nNames and arbitrary sensitive prose are not detected; mark them <private>.\nRaw files, pasted chat, upstream MCP content and local dashboard/vault files bypass this protection.`)
|
|
4169
|
+
break
|
|
4132
4170
|
case 'demo': cmdDemo(args); break
|
|
4133
4171
|
case 'scan': cmdScan(); break
|
|
4134
4172
|
case 'resume': cmdResume(args); break
|
|
@@ -4162,3 +4200,8 @@ switch (cmd) {
|
|
|
4162
4200
|
// Missing or unknown command must fail - exit 0 made typos look like success in scripts/hooks.
|
|
4163
4201
|
process.exit(1)
|
|
4164
4202
|
}
|
|
4203
|
+
|
|
4204
|
+
} catch (error) {
|
|
4205
|
+
console.error(error && error.message ? error.message : "command failed")
|
|
4206
|
+
process.exitCode = 1
|
|
4207
|
+
}
|
package/bin/lib/context.js
CHANGED
|
@@ -45,7 +45,7 @@ function boundedSections(sections, maxBytes = DEFAULT_BYTES) {
|
|
|
45
45
|
|
|
46
46
|
// Literal, client-scoped lexical retrieval. Retain independently matching
|
|
47
47
|
// records, including conflicting/older ones; recency never means truth.
|
|
48
|
-
function recallSections(documents, query, maxHits = 12) {
|
|
48
|
+
function recallSections(documents, query, maxHits = 12, outputText = text => text) {
|
|
49
49
|
const words = [...new Set(query.toLocaleLowerCase().split(/\s+/).filter(Boolean))].slice(0, 16)
|
|
50
50
|
const hits = []
|
|
51
51
|
for (const { file, text } of documents) {
|
|
@@ -57,7 +57,7 @@ function recallSections(documents, query, maxHits = 12) {
|
|
|
57
57
|
if (!score) continue
|
|
58
58
|
const first = Math.max(0, i - 1)
|
|
59
59
|
const last = Math.min(lines.length, i + 3)
|
|
60
|
-
const excerpt = lines.slice(first, last).map(l => Buffer.byteLength(l) <= 1024 ? l : clipUtf8(l, 900) + OMITTED).join('\n')
|
|
60
|
+
const excerpt = lines.slice(first, last).map(outputText).map(l => Buffer.byteLength(l) <= 1024 ? l : clipUtf8(l, 900) + OMITTED).join('\n')
|
|
61
61
|
hits.push({ file, line: first + 1, end: last, score, text: excerpt.trim() })
|
|
62
62
|
}
|
|
63
63
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Local pseudonyms, not anonymization. Recognized identifiers are replaced
|
|
4
|
+
// in output and proposals, and retained in a private reversible dictionary.
|
|
5
|
+
const fs = require('node:fs')
|
|
6
|
+
const path = require('node:path')
|
|
7
|
+
const crypto = require('node:crypto')
|
|
8
|
+
const { StringDecoder } = require('node:string_decoder')
|
|
9
|
+
const ALIAS = /\[\[(email|phone|identifier|credential):[a-f0-9]{16}\]\]/g
|
|
10
|
+
const PATTERNS = [
|
|
11
|
+
['credential', /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?(?:-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|$)/g],
|
|
12
|
+
['credential', /\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,})\b/g],
|
|
13
|
+
['credential', /\b[a-z][a-z0-9+.-]*:\/\/[^/\s:]+:[^/\s@]+@[^\s<>"']+/gi],
|
|
14
|
+
['credential', /\bBearer\s+[A-Za-z0-9._-]{20,}/gi],
|
|
15
|
+
['credential', /\b(?:api[_-]?key|secret|password)\s*=\s*[^\s"']{8,}/gi],
|
|
16
|
+
['email', /(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?){1,10}/g],
|
|
17
|
+
['identifier', /\b\d{3}-\d{2}-\d{4}\b/g],
|
|
18
|
+
// Deliberately conservative: international + notation or explicit US shape.
|
|
19
|
+
// Plain integers and dates are not reliably distinguishable from business data.
|
|
20
|
+
['phone', /(?<![\w])(?:\+\d{1,3}[ .-]?(?:\(\d{2,4}\)|\d{2,4})(?:[ .-]?\d){6,10}|\(\d{3}\)[ .-]?\d{3}[ .-]\d{4}|\d{3}[ .-]\d{3}[ .-]\d{4})(?!\d)/g],
|
|
21
|
+
]
|
|
22
|
+
const FAIL = 'privacy masking unavailable: check the local .privacy directory; no unmasked output was returned'
|
|
23
|
+
function replacements(text, replace) {
|
|
24
|
+
let out = String(text)
|
|
25
|
+
for (const [kind, pattern] of PATTERNS) out = out.replace(pattern, value => replace(kind, value))
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
28
|
+
function createMasking(root) {
|
|
29
|
+
const directory = path.join(root, '.privacy'), file = path.join(directory, 'identifiers.json')
|
|
30
|
+
function directoryReady(create) {
|
|
31
|
+
if (create) fs.mkdirSync(root, { recursive: true })
|
|
32
|
+
if (create) { try { fs.mkdirSync(directory, { mode: 0o700 }) } catch (e) { if (e.code !== 'EEXIST') throw e } }
|
|
33
|
+
const st = fs.lstatSync(directory)
|
|
34
|
+
if (!st.isDirectory() || st.isSymbolicLink() || (process.platform !== 'win32' && (st.mode & 0o077))) throw new Error(FAIL)
|
|
35
|
+
}
|
|
36
|
+
function read(create) {
|
|
37
|
+
let fd
|
|
38
|
+
try {
|
|
39
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK)
|
|
40
|
+
const st = fs.fstatSync(fd)
|
|
41
|
+
if (!st.isFile() || st.nlink !== 1 || st.size > 8 * 1024 * 1024 || (process.platform !== 'win32' && (st.mode & 0o077))) throw new Error(FAIL)
|
|
42
|
+
const data = JSON.parse(fs.readFileSync(fd, 'utf8'))
|
|
43
|
+
if (data.version !== 1 || !Array.isArray(data.entries) || data.entries.length > 50000) throw new Error(FAIL)
|
|
44
|
+
const seen = new Set()
|
|
45
|
+
for (const entry of data.entries) {
|
|
46
|
+
if (!entry || typeof entry.value !== 'string' || !/^\[\[(email|phone|identifier|credential):[a-f0-9]{16}\]\]$/.test(entry.alias) || seen.has(entry.alias)) throw new Error(FAIL)
|
|
47
|
+
seen.add(entry.alias)
|
|
48
|
+
}
|
|
49
|
+
return data
|
|
50
|
+
} catch (e) { if (e.code === 'ENOENT' && create) return { version: 1, entries: [] }; throw e }
|
|
51
|
+
finally { if (fd !== undefined) fs.closeSync(fd) }
|
|
52
|
+
}
|
|
53
|
+
function transact(create, fn) {
|
|
54
|
+
let locked = false
|
|
55
|
+
const lock = path.join(directory, 'lock')
|
|
56
|
+
try {
|
|
57
|
+
directoryReady(create)
|
|
58
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
59
|
+
try { fs.mkdirSync(lock, { mode: 0o700 }); locked = true; break }
|
|
60
|
+
catch (e) { if (e.code !== 'EEXIST') throw e; Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10) }
|
|
61
|
+
}
|
|
62
|
+
if (!locked) throw new Error(FAIL)
|
|
63
|
+
const data = read(create), before = data.entries.length, result = fn(data.entries)
|
|
64
|
+
if (data.entries.length !== before) {
|
|
65
|
+
if (data.entries.length > 50000) throw new Error(FAIL)
|
|
66
|
+
const body = JSON.stringify(data)
|
|
67
|
+
if (Buffer.byteLength(body) > 8 * 1024 * 1024) throw new Error(FAIL)
|
|
68
|
+
const tmp = path.join(directory, `.write-${crypto.randomBytes(12).toString('hex')}`)
|
|
69
|
+
try { fs.writeFileSync(tmp, body, { mode: 0o600, flag: 'wx' }); fs.renameSync(tmp, file) }
|
|
70
|
+
finally { try { fs.unlinkSync(tmp) } catch (_) {} }
|
|
71
|
+
}
|
|
72
|
+
return result
|
|
73
|
+
} catch (_) { throw new Error(FAIL) }
|
|
74
|
+
finally { if (locked) { try { fs.rmdirSync(lock) } catch (_) {} } }
|
|
75
|
+
}
|
|
76
|
+
function mask(text) {
|
|
77
|
+
text = String(text)
|
|
78
|
+
let detected = false
|
|
79
|
+
replacements(text, (_, value) => { detected = true; return value })
|
|
80
|
+
if (!detected) return text
|
|
81
|
+
return transact(true, entries => {
|
|
82
|
+
const values = new Map(entries.map(e => [e.value, e.alias]))
|
|
83
|
+
return replacements(text, (kind, value) => {
|
|
84
|
+
if (!values.has(value)) {
|
|
85
|
+
const alias = `[[${kind}:${crypto.randomBytes(8).toString('hex')}]]`
|
|
86
|
+
entries.push({ alias, value }); values.set(value, alias)
|
|
87
|
+
}
|
|
88
|
+
return values.get(value)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
function restore(text) {
|
|
93
|
+
text = String(text)
|
|
94
|
+
if (/\[\[(email|phone|identifier|credential):/.test(text.replace(ALIAS, ''))) throw new Error(FAIL)
|
|
95
|
+
if (!text.match(ALIAS)) return text
|
|
96
|
+
return transact(false, entries => {
|
|
97
|
+
const aliases = new Map(entries.map(e => [e.alias, e.value]))
|
|
98
|
+
return text.replace(ALIAS, alias => {
|
|
99
|
+
if (!aliases.has(alias)) throw new Error(FAIL)
|
|
100
|
+
return aliases.get(alias)
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
return { mask, restore }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// CLI output is finite, not an interactive stream. Buffer to catch identifiers
|
|
108
|
+
// split across writes, including multiline credentials. Never fall back to raw.
|
|
109
|
+
function protectOutput(masking, { maxBytes } = {}) {
|
|
110
|
+
const buffers = ['', ''], decoders = [new StringDecoder('utf8'), new StringDecoder('utf8')]
|
|
111
|
+
let overflow = false
|
|
112
|
+
for (const [index, stream] of [process.stdout, process.stderr].entries()) {
|
|
113
|
+
stream.write = (chunk, encoding, callback) => {
|
|
114
|
+
const text = typeof chunk === 'string' ? chunk : decoders[index].write(chunk)
|
|
115
|
+
buffers[index] += text
|
|
116
|
+
if (Buffer.byteLength(buffers[index]) > 16 * 1024 * 1024) { overflow = true; buffers[index] = '' }
|
|
117
|
+
const cb = typeof encoding === 'function' ? encoding : callback
|
|
118
|
+
if (cb) cb()
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
process.once('exit', () => {
|
|
123
|
+
try {
|
|
124
|
+
if (overflow) throw new Error(FAIL)
|
|
125
|
+
// Prepare both before emitting either, so a failure cannot expose raw data.
|
|
126
|
+
const output = buffers.map((text, i) => masking.mask(text + decoders[i].end()))
|
|
127
|
+
if (maxBytes && Buffer.byteLength(output[0]) > maxBytes) {
|
|
128
|
+
const notice = '\n[Masked context truncated; retrieve a narrower topic.]\n'
|
|
129
|
+
let body = Buffer.from(output[0]).subarray(0, maxBytes - Buffer.byteLength(notice)).toString('utf8').replace(/\uFFFD$/, '')
|
|
130
|
+
const open = body.lastIndexOf('[[')
|
|
131
|
+
if (open > body.lastIndexOf(']]')) body = body.slice(0, open)
|
|
132
|
+
output[0] = body + notice
|
|
133
|
+
}
|
|
134
|
+
output.forEach((text, i) => { if (text) fs.writeSync(i + 1, text) })
|
|
135
|
+
} catch (_) { process.exitCode = 1; fs.writeSync(2, FAIL + '\n') }
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
module.exports = { createMasking, protectOutput }
|
package/bin/lib/memory.js
CHANGED
|
@@ -53,7 +53,7 @@ function createMemoryApi(deps) {
|
|
|
53
53
|
execFileSync('git', ['add', '--', f], { cwd: eng, stdio: 'ignore', timeout: 10000 })
|
|
54
54
|
}
|
|
55
55
|
} else {
|
|
56
|
-
execFileSync('git', ['add', '-A'], { cwd: eng, stdio: 'ignore', timeout: 10000 })
|
|
56
|
+
execFileSync('git', ['add', '-A', '--', '.', ':(exclude).privacy'], { cwd: eng, stdio: 'ignore', timeout: 10000 })
|
|
57
57
|
}
|
|
58
58
|
const porcelain = execFileSync('git', ['status', '--porcelain'], {
|
|
59
59
|
cwd: eng, encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
@@ -72,6 +72,9 @@ function createMemoryApi(deps) {
|
|
|
72
72
|
})
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
+
// Even a previously staged alias dictionary must never enter a CLI commit.
|
|
76
|
+
const privateStaged = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: eng, encoding: 'utf8', timeout: 10000 }).split('\n').some(f => f.split('/').includes('.privacy'))
|
|
77
|
+
if (privateStaged) throw new Error('private alias state must not be staged in engagement history')
|
|
75
78
|
const still = execFileSync('git', ['diff', '--cached', '--name-only'], {
|
|
76
79
|
cwd: eng, encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
77
80
|
}).toString().trim()
|
|
@@ -131,7 +134,7 @@ function createMemoryApi(deps) {
|
|
|
131
134
|
execFileSync('git', ['init'], { cwd: eng, stdio: 'ignore', timeout: 10000 })
|
|
132
135
|
atomicWriteFile(
|
|
133
136
|
path.join(eng, '.gitignore'),
|
|
134
|
-
['*.lock', '*.tmp', '.last-write', '.debrief-propose', '.debrief-private', '.debrief-seal', ''].join('\n')
|
|
137
|
+
['*.lock', '*.tmp', '.last-write', '.debrief-propose', '.debrief-private', '.debrief-seal', '.privacy/', ''].join('\n')
|
|
135
138
|
)
|
|
136
139
|
const owner = writeOwnerIfMissing(eng)
|
|
137
140
|
configureMemoryGitIdentity(eng, owner)
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
|
|
11
11
|
const fs = require('fs')
|
|
12
12
|
const path = require('path')
|
|
13
|
+
const os = require('os')
|
|
14
|
+
const masking = require('../../bin/lib/masking').createMasking(
|
|
15
|
+
(process.env.FDEOPS_ENGAGEMENTS_ROOT || '').trim().replace(/^~/, os.homedir()) || path.join(os.homedir(), 'fde-engagements')
|
|
16
|
+
)
|
|
13
17
|
const { spawnSync } = require('child_process')
|
|
14
18
|
|
|
15
19
|
const PROTOCOL_VERSION = '2024-11-05'
|
|
@@ -171,7 +175,9 @@ function cliPayload(out) {
|
|
|
171
175
|
}
|
|
172
176
|
|
|
173
177
|
function toolResult(payload) {
|
|
174
|
-
|
|
178
|
+
let text
|
|
179
|
+
try { text = masking.mask(typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2)) }
|
|
180
|
+
catch (_) { return { isError: true, content: [{ type: 'text', text: 'privacy masking unavailable; no unmasked tool output returned' }] } }
|
|
175
181
|
return { content: [{ type: 'text', text }] }
|
|
176
182
|
}
|
|
177
183
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fdeops",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.29.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.29.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
|
@@ -28,7 +28,7 @@ A one-line typo or compile error in a file that will not ship. On a bound client
|
|
|
28
28
|
| **When did we agree?** | Don't argue from memory. Search the record. | `fde receipts <term>` | - |
|
|
29
29
|
| **What's the outcome?** | A number nobody signed is claimed, not delivered. | `fde status` | `references/readout.md` |
|
|
30
30
|
|
|
31
|
-
After a meeting: `fde debrief --smart
|
|
31
|
+
After a meeting: the agent runs `fde debrief --smart`, interprets and reconciles the sanitized proposal, then validates it with `fde debrief --review`. Show the human one concise review of consequential changes and uncertainties → **Save this update?** → `--apply` only after confirmation → verify the saved facts. See `references/debrief.md` for the shared preparation contract. Walk-in: `fde prep`. Friday: `fde status`.
|
|
32
32
|
|
|
33
33
|
## Ground loop
|
|
34
34
|
|
|
@@ -69,7 +69,7 @@ Writes need a bind (`FDEOPS_ENGAGEMENT` or registry). Never install fdeops on in
|
|
|
69
69
|
|----------|---------|
|
|
70
70
|
| where are we | `fde resume` |
|
|
71
71
|
| day-1 look at the repo | `fde scan` |
|
|
72
|
-
| debrief / pasted notes | `fde debrief --smart` →
|
|
72
|
+
| debrief / pasted notes | `fde debrief --smart` → agent reconciliation → one plain-English review → Save this update? → `--apply`. `--smart` is a gate, not a brain. `references/debrief.md` |
|
|
73
73
|
| prep me for … | `fde prep "<label>"` |
|
|
74
74
|
| when did we agree | `fde receipts <term>` |
|
|
75
75
|
| sponsor update / defend the number | `fde defend` |
|
|
@@ -211,3 +211,7 @@ Ready to build with no `terrain.md` / plan: discover or plan first. Takeover wit
|
|
|
211
211
|
- Evidence on every claim. The FDE will be challenged on these files.
|
|
212
212
|
- Overlays activate on signal, not on request.
|
|
213
213
|
- Load `.fde/` files on demand, never the whole folder.
|
|
214
|
+
|
|
215
|
+
## Identifier masking
|
|
216
|
+
|
|
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.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**Large transcripts or emails** sitting in Granola/Gmail/Notion → prefer **`fde ingest stage`** first (via source MCPs the FDE configured), then the same propose → confirm → **`fde ingest apply`** path. See `references/ingest.md`. Pasted short notes stay on this debrief verb.
|
|
6
6
|
|
|
7
|
-
**Read first:** `
|
|
7
|
+
**Read first:** the bounded `fde resume` packet for the bound client. Use `fde recall` for the specific prior decision, action, or delivery result needed to reconcile this update. Do not reload the whole engagement.
|
|
8
8
|
|
|
9
9
|
**Who runs the CLI:** you (the agent). Never tell the FDE to type `fde debrief …`.
|
|
10
10
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
- The `fde` CLI is **local, deterministic, no AI**. `--smart` is a **gate + writer**, not a brain.
|
|
14
14
|
- It keeps lines that already have `decision:` / `risk:` / `delivery:` / `contact:` / `next:` / `signer:` prefixes, plus a thin keyword pass (e.g. "we agreed", person+verb lines, "open question", "X signs off").
|
|
15
15
|
- `signer: Priya` fills **Stakeholder who signs off** in `success.md` and logs Priya as a contact. The CLI proposes it when a sentence says someone signs off / approves / has final say. If the notes name who can say yes and the proposal does not carry a `signer:` line, add one - that is the most expensive sentence in the meeting.
|
|
16
|
-
-
|
|
16
|
+
- Heuristics can miss facts **and mislabel prefixed lines**. You interpret every candidate against the sanitized source, not just unprefixed lines. Split distinct decisions, requests, actions, and results; keep uncertainty. The user reviews meaning, never prefix syntax.
|
|
17
17
|
- `.debrief-propose` is raw lines only (no routing annotations). "Edit if mis-routed" means **rewrite the line with the right prefix**, not leave a comment in the file.
|
|
18
18
|
|
|
19
19
|
## Method (you do this work)
|
|
@@ -22,25 +22,24 @@
|
|
|
22
22
|
|
|
23
23
|
1. Save the FDE's notes to a temp `.md` file in the workspace (or pipe stdin).
|
|
24
24
|
2. Run `fde debrief --smart <notes.md>` (or `npx fdeops debrief --smart …`).
|
|
25
|
-
3. Open `.debrief-propose`. If
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
No invented names or quotes. If the propose looks wrong, fix prefixes with judgment then re-apply or use the fallback path.
|
|
25
|
+
3. Run `fde debrief --review` before opening an existing proposal so legacy identifiers are masked. Open the proposal only after review succeeds. Never open a proposal containing manually inserted raw private blocks. Prepare the pending proposal using **Prepare one update** below. Read only the sanitized `.debrief-propose`, never the sealed private sidecars or raw private source. Preserve privacy markers, source metadata, and complete identifier aliases such as `[[email:...]]`. The CLI restores known aliases locally on apply. Never read `.privacy/` or try to recover an identity with file tools. If an alias is truncated, retrieve a narrower excerpt; never guess or edit the token.
|
|
26
|
+
4. Run `fde debrief --review` after editing. Treat the CLI REVIEW and routing output as your validation, not a second presentation to the user. Resolve errors and replay warnings before asking for confirmation.
|
|
27
|
+
5. Show **one** concise review in chat: name the client, then the consequential changes in plain English. Include decisions, requests still unagreed, actions, reported delivery, signer or contact changes, and unresolved conflicts when present. Show the previous value only where it changes the meaning. Omit empty categories and CLI routing details; do not impose a fixed four-row card that hides other changes. If the proposal is too large to show faithfully, split the review into explicit batches; never approve hidden changes.
|
|
28
|
+
6. Ask **Save this update?** This confirms the engineer's record, not customer acceptance. On confirmation, apply precisely that proposal with `fde debrief --apply`. A material correction requires a revised review and renewed confirmation. On rejection, leave the proposal pending and do not apply.
|
|
29
|
+
7. Verify the changed facts through bounded `fde resume` / targeted `fde recall`. If a fieldbook is part of the current task, regenerate it using the existing command and destination after the confirmed save; do not make the user run it. End with a brief saved/not-saved result and the next action, not another full summary.
|
|
30
|
+
|
|
31
|
+
### Prepare one update (shared with ingest)
|
|
32
|
+
|
|
33
|
+
Do this work yourself before the human review:
|
|
34
|
+
|
|
35
|
+
- **Check meaning, not keywords.** “We settled on delaying the rewrite” is a decision; “Mara will request access” is an action, even if the heuristic calls it a contact. A wish or suggestion remains a request, not agreement. Do not infer authority, approval, a calendar date from an unanchored relative date, or production value from staging.
|
|
36
|
+
- **Keep facts traceable.** Preserve supplied source locators on each consequential fact, using `[source: ...]`. If only a local file or staged item exists, cite that actual locator as a note source, not a customer receipt. Do not invent a meeting date or speaker. A source label is not authenticated approval.
|
|
37
|
+
- **Reconcile only what changed.** Compare affected facts with the current record using targeted retrieval. Leave unchanged sourced statements out of an accidental re-import. Preserve earlier history; record changed or conflicting claims explicitly. If everything is already recorded, say so and leave the pending proposal unapplied. If it blocks a later capture, explain that no new facts were saved and ask permission to replace that pending review; use `--replace-proposal` with the new notes only after that authorization. Do not delete proposal files or private sidecars manually. Do not use `--allow-replay` without explicit approval of an intentional repeat.
|
|
38
|
+
- **Protect the current next action.** A late meeting note does not automatically supersede a newer action. Keep older actions as dated context unless their current priority is established; show a conflict when it needs a decision. Use exactly one physical `next:` line for the current action. If multiple current actions are explicitly agreed, include them on that same line separated by semicolons; the CLI retains only the last `next:` line. Keep other dated commitments in context. Do not silently discard other commitments.
|
|
39
|
+
- **Keep memory useful.** Retain consequential facts and indispensable context; remove chatter and repetition from the proposal, not from the source. Preserve the raw input outside `.fde/` (staged material stays in `.inbox/`). Never remove privacy placeholders or modify sealed sidecars. Ask only about a consequential ambiguity that cannot remain explicitly unknown.
|
|
40
|
+
- **Structure the result.** Use `decision:` / `risk:` / `delivery:` / `contact:` / `next:` / `signer:`. Preserve `ask:` / `scope:` as explicitly proposed context when appropriate. Prepare the seven-field delivery row yourself for a reported result (see below); unknown fields stay `pending`. The human should not have to fill out a ledger to capture a meeting.
|
|
41
|
+
|
|
42
|
+
Before showing the review, check that every consequential fact in the sanitized source is represented, already recorded, or explicitly unresolved. Check classified lines as carefully as unclassified ones. Nothing is saved simply because this preparation is complete.
|
|
44
43
|
|
|
45
44
|
### Fallback - you structure, then route
|
|
46
45
|
|
|
@@ -53,10 +52,10 @@ If `--smart` is unavailable or you already have clean prefixes:
|
|
|
53
52
|
- **Risks** - new / confirmed / retired
|
|
54
53
|
- **Open questions** - what to chase next
|
|
55
54
|
2. Format lines as `decision:` / `risk:` / `delivery:` / `contact:` / `next:` / `signer:` (contacts may end with `[signal:green|amber|red]`).
|
|
56
|
-
3.
|
|
55
|
+
3. Follow **Prepare one update** and show the same single plain-English review as the preferred path. Include every consequential change and ask **Save this update?**.
|
|
57
56
|
4. On confirm, pipe to `fde debrief` (or write a file and run it).
|
|
58
57
|
|
|
59
|
-
|
|
58
|
+
Ask at most one focused question at a time when ambiguity would change the record. Otherwise preserve the unknown and include it in the review. Never treat silence as confirmation.
|
|
60
59
|
|
|
61
60
|
## Artifact
|
|
62
61
|
|
|
@@ -66,7 +65,7 @@ One clarifying question max if the dump is ambiguous - then write. Never stall c
|
|
|
66
65
|
|
|
67
66
|
## Checkpoint
|
|
68
67
|
|
|
69
|
-
|
|
68
|
+
Use the single pre-save review above. After saving, report verification and the next action briefly; do not ask for a second approval or repeat the review.
|
|
70
69
|
|
|
71
70
|
## Principles
|
|
72
71
|
|
|
@@ -108,7 +108,7 @@ Flag every one. AI components don't fail like regular code - they degrade as the
|
|
|
108
108
|
|
|
109
109
|
**6. Data flow.** Where data enters, how it moves, where it stops. Entry points first: routes, queues, cron, file drops.
|
|
110
110
|
|
|
111
|
-
**7. Existing capability.** Trace the requested user action through existing code, configuration, tests, and operating workarounds. In `terrain.md`, record what can already be reused and the evidence that it works or fails. Check whether a configuration, ownership, or process change could resolve the observed break. A disabled feature is a lead, not a proven root cause. Keep observations and hypotheses distinct; option selection still belongs to plan / three-options.
|
|
111
|
+
**7. Existing capability.** Trace the requested user action through existing code, configuration, tests, and operating workarounds. In `terrain.md`, record what can already be reused and the evidence that it works or fails. Check whether a configuration, ownership, or process change could resolve the observed break. A disabled feature is a lead, not a proven root cause. Keep observations and hypotheses distinct; option selection still belongs to plan / three-options. Summarize the remaining gap in `reality.md`: what works today → what the customer needs → what is still missing, with sources. If existing capability meets the need, say so; do not manufacture a build requirement.
|
|
112
112
|
|
|
113
113
|
## Method - part 2: the humans (you coach, the FDE asks)
|
|
114
114
|
|
|
@@ -150,7 +150,7 @@ Always map the estate before you score a use case - not only when someone said "
|
|
|
150
150
|
**The 5 questions (ask the data owner, not the sponsor):**
|
|
151
151
|
1. **Where does data live?** - List every source: databases, warehouses, SaaS exports, spreadsheets, S3 buckets, vendor APIs. Map it.
|
|
152
152
|
2. **How fresh is it?** - Real-time, daily batch, "someone uploads a CSV on Mondays"? Freshness determines what's buildable.
|
|
153
|
-
3. **Who owns it?** - Not "IT" - the named person who can grant access and explain the schema. No
|
|
153
|
+
3. **Who owns it?** - Not "IT" - the named person who can grant access and explain the schema. No named owner: access responsibility remains unverified.
|
|
154
154
|
4. **What's the quality?** - Sample 100 rows from each critical source. Check: nulls, duplicates, format consistency, semantic correctness. A 60% null rate in a key field = that source is fiction.
|
|
155
155
|
5. **What are the governance constraints?** - PII classification, retention policies, cross-border rules, consent basis. One missed constraint = a compliance stop later.
|
|
156
156
|
|
|
@@ -164,6 +164,10 @@ Always map the estate before you score a use case - not only when someone said "
|
|
|
164
164
|
|
|
165
165
|
A use case that depends on a "Blocker" source **or a Blocker pipe** doesn't get scored - it gets a remediation conversation first. `what-breaks` finding an invisible integration at ship is already too late. Write this to `terrain.md` under a `## Data estate` section.
|
|
166
166
|
|
|
167
|
+
**Promised dependencies are not ready dependencies.** For consequential promises such as "data in two weeks," record or update one dependency entry in `assumptions.md` with the responsible owner, dated verification checkpoint, and evidence needed. Unknown owners or dates stay unknown; propose a checkpoint for confirmation. Link the affected work; if the checkpoint slips, identify what can proceed and what needs replanning. Missing ownership is an unresolved dependency, not proof that the project will fail. On-prem or restricted access is a constraint to investigate, not a red flag by itself.
|
|
168
|
+
|
|
169
|
+
**Verify the future operator now.** Check the proposed owner in `success.md` against who will actually monitor, recover, and support the result. Record whether they have agreed, access or training gaps, and a practical handoff check there; carry these into `handoff.md` at close. Keep unconfirmed ownership explicit. Reuse supplied evidence and ask only what changes the plan.
|
|
170
|
+
|
|
167
171
|
## When scope is a transformation, not a single problem
|
|
168
172
|
|
|
169
173
|
Score every candidate use case before anything gets prototyped:
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**Connect / capability (different entry):** "connect a new MCP", "connect Granola/Slack/Notion", "what can you pull?" → `references/connect.md` first. Recipes: `mcp/recipes/` (file, granola, slack, notion).
|
|
6
6
|
|
|
7
|
-
**Read first:** `
|
|
7
|
+
**Read first:** the bounded `fde resume` packet and targeted recall for affected prior facts. Bind the engagement before staging anything.
|
|
8
8
|
|
|
9
9
|
**Who runs the CLI:** you (the agent). Never tell the FDE to type `fde ingest …`. Never auto-apply. Never background-sync or poll sources on your own.
|
|
10
10
|
|
|
@@ -31,9 +31,9 @@ List what you can actually call **this session**:
|
|
|
31
31
|
3. **Stage** - `fde ingest stage [--source NAME] [--title TEXT] [file|-]` writes raw text into `<engagement>/.inbox/` (outside the memory git ledger).
|
|
32
32
|
4. **List** (optional) - `fde ingest list` shows staged items when you need an id or filename.
|
|
33
33
|
5. **Propose** - `fde ingest propose <id-or-filename>` runs the debrief `--smart` path on the staged body (+ provenance line). Opens `.debrief-propose`.
|
|
34
|
-
6. **
|
|
35
|
-
7. **
|
|
36
|
-
8. **Apply** - on FDE confirm only → `fde ingest apply` (= `fde debrief --apply`). On reject
|
|
34
|
+
6. **Prepare** - follow **Prepare one update** in `references/debrief.md`. Interpret every sanitized candidate, including already-prefixed lines; reconcile changed facts, preserve source locators, and keep raw chatter out of memory. Preserve privacy placeholders and sealed sidecars.
|
|
35
|
+
7. **Validate and show** - run `fde debrief --review` after editing, then show the same single plain-English review as debrief, including delivery changes and conflicts. Ask **Save this update?** and wait for confirmation. CLI output is agent validation, not a second user review.
|
|
36
|
+
8. **Apply and verify** - on FDE confirm only → `fde ingest apply` (= `fde debrief --apply`), then verify affected facts through bounded resume/recall. Refresh the current fieldbook if it is part of this task. On reject, leave the proposal pending; material edits require a revised review.
|
|
37
37
|
|
|
38
38
|
No invented names, meetings, or quotes. If the propose looks wrong, fix prefixes with judgment, then re-show before apply.
|
|
39
39
|
|
|
@@ -56,7 +56,7 @@ fde ingest apply
|
|
|
56
56
|
|
|
57
57
|
## Provenance
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
Carry an actual `[source: ...]` locator on each consequential fact. Preserve upstream IDs or links when supplied; otherwise cite the staged item path as a note source. Retain its `via:` metadata, but do not treat a standalone `via:` line as a source marker for every fact or as proof of approval. Re-imports still require semantic comparison; exact replay protection is not semantic deduplication.
|
|
60
60
|
|
|
61
61
|
## MCP sink + recipes
|
|
62
62
|
|
|
@@ -64,7 +64,7 @@ Optional `mcp/fdeops-ingest` wraps the same verbs over stdio. Source MCPs remain
|
|
|
64
64
|
|
|
65
65
|
## Checkpoint
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
Use the single review from debrief: identify the client and sources, show consequential changes, then wait. Do not add another summary or approval step.
|
|
68
68
|
|
|
69
69
|
## Principles
|
|
70
70
|
|
|
@@ -57,6 +57,7 @@ Intent: coach the FDE's first *customer* conversation - what keeps the sponsor u
|
|
|
57
57
|
- "Before you open the laptop - what would make this a bad engagement for *them*, not just a delayed project?"
|
|
58
58
|
- "What are they afraid you'll miss?"
|
|
59
59
|
- "Who loses credibility if this goes wrong?"
|
|
60
|
+
- "If nothing changes over the agreed timeframe, what happens, and who bears it?" Record the consequence and its source in `brief.md`; distinguish reported impact from measured cost. Unknown cost stays unknown, not an invented ROI.
|
|
60
61
|
|
|
61
62
|
Let silence sit. If their fear doesn't match the written brief, the brief is wrong - say so plainly, log it.
|
|
62
63
|
|
|
@@ -67,6 +68,7 @@ Let silence sit. If their fear doesn't match the written brief, the brief is wro
|
|
|
67
68
|
- **The sacred thing** - "Is there anything in this environment I should treat as untouchable?" The hesitation before the answer is the answer.
|
|
68
69
|
- **Exception path (operating map seed)** - "When the happy path breaks this week, what do people actually do - who do they call, what spreadsheet opens, what do they skip?" Capture the break → workaround → who owns it. Do not build a full map on day 1; seed rows later in `terrain.md` → `## Operating map (exception-led)` during discover. Unknowns stay `unknown - ask:`.
|
|
69
70
|
- **AI posture and policy** - tools already in use (sanctioned or shadow), and: "Does your organisation have a policy on AI-generated code? Are there decisions where you would not be comfortable with AI involvement?"
|
|
71
|
+
- **Future operator** - "Who will run this after we leave, and have they agreed?" Record the proposed operator and unresolved ownership in `success.md`, separately from the signer. A sponsor naming a team is not that team accepting responsibility; verify with the operator during discover.
|
|
70
72
|
- **Boundaries in multi-vendor rooms** - who owns what surface, who signs off before a change crosses it.
|
|
71
73
|
|
|
72
74
|
## The day 1 deliverable
|