fdeops 3.28.0 → 3.30.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 +3 -1
- package/bin/check.js +2 -7
- package/bin/fde.js +112 -42
- package/bin/install.js +1 -1
- package/bin/lib/context.js +4 -4
- package/bin/lib/masking.js +138 -0
- package/bin/lib/memory.js +5 -2
- package/bin/lib/setup.js +109 -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 +11 -3
- 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
|
@@ -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 three choices for your daily overview, context size, and report masking. [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>
|
|
@@ -247,7 +249,7 @@ If your work has no client commitments or operating handover to track, a simpler
|
|
|
247
249
|
|
|
248
250
|
The CLI works with local files and Git, without network calls or telemetry. Client records remain readable Markdown if you stop using FDEOps.
|
|
249
251
|
|
|
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.
|
|
252
|
+
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
253
|
|
|
252
254
|
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
255
|
|
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,31 @@ 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
|
+
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
|
+
}
|
|
59
|
+
function maskDisplay(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
|
|
69
|
+
}
|
|
70
|
+
function maskedSections(sections, maxBytes) {
|
|
71
|
+
return context.boundedSections(sections.map(text => masking.mask(text)), maxBytes)
|
|
72
|
+
}
|
|
48
73
|
const DEBRIEF_MAX_BYTES = 256 * 1024
|
|
49
74
|
const CODE_EXT = ['.js', '.ts', '.tsx', '.jsx', '.py', '.java', '.go', '.rb', '.cs', '.php']
|
|
50
75
|
const CONF_EXT = CODE_EXT.concat(['.env', '.yaml', '.yml', '.json'])
|
|
@@ -114,7 +139,7 @@ function grepFiles(files, regex, cap) {
|
|
|
114
139
|
try { text = fs.readFileSync(f, 'utf8') } catch (_) { continue }
|
|
115
140
|
const lines = text.split('\n')
|
|
116
141
|
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) })
|
|
142
|
+
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
143
|
}
|
|
119
144
|
}
|
|
120
145
|
return hits
|
|
@@ -798,7 +823,7 @@ function firstLine(md, maxLen) {
|
|
|
798
823
|
for (const raw of md.split('\n')) {
|
|
799
824
|
const l = raw.trim()
|
|
800
825
|
if (!l || /^#{1,6}\s/.test(l)) continue
|
|
801
|
-
const clean = l.replace(/^\*\*[^*]+:\*\*\s*/, '').replace(/\*\*/g, '').replace(/^["']|["']$/g, '').trim()
|
|
826
|
+
const clean = maskDisplay(l.replace(/^\*\*[^*]+:\*\*\s*/, '').replace(/\*\*/g, '').replace(/^["']|["']$/g, '').trim())
|
|
802
827
|
if (!clean) continue
|
|
803
828
|
return clean.length > maxLen ? clean.slice(0, maxLen - 1).trim() + '…' : clean
|
|
804
829
|
}
|
|
@@ -811,7 +836,7 @@ function firstLine(md, maxLen) {
|
|
|
811
836
|
function parseReality(md, maxLen) {
|
|
812
837
|
const theory = (md.match(/\*\*Working theory:\*\*[^\S\r\n]*(.*)/i) || [])[1]
|
|
813
838
|
const hasSchema = /\*\*(Working theory|Evidence|Differs from brief how):\*\*/i.test(md)
|
|
814
|
-
const theoryText = (theory || '').trim()
|
|
839
|
+
const theoryText = maskDisplay((theory || '').trim())
|
|
815
840
|
if (theoryText) {
|
|
816
841
|
const line = theoryText.length > maxLen ? theoryText.slice(0, maxLen - 1).trim() + '…' : theoryText
|
|
817
842
|
return { line, missing: '' }
|
|
@@ -983,7 +1008,7 @@ const {
|
|
|
983
1008
|
countOpenRisks,
|
|
984
1009
|
} = createTrustApi({
|
|
985
1010
|
fs, path, readClean, readEng, parseMdTable, sectionBody, SIGNAL_LEDGER, memoryDirtyManual,
|
|
986
|
-
stripTemplateNoise, stripLegendLines, extractRisks,
|
|
1011
|
+
stripTemplateNoise, stripLegendLines, extractRisks, maskDisplay,
|
|
987
1012
|
})
|
|
988
1013
|
|
|
989
1014
|
// Stakeholders: columns are matched by header wording, not position - real
|
|
@@ -1016,6 +1041,7 @@ function parseSignalHistoryEntries(eng) {
|
|
|
1016
1041
|
}
|
|
1017
1042
|
|
|
1018
1043
|
function displayNameFromSignalText(text) {
|
|
1044
|
+
if (preferences.privacy === 'reports' && masking.mask(text) !== text) return 'Contact (identifier masked)'
|
|
1019
1045
|
const person = personFromSignalText(text)
|
|
1020
1046
|
if (person) return person
|
|
1021
1047
|
const t = String(text).trim()
|
|
@@ -1081,14 +1107,14 @@ function extractStakeholders(eng) {
|
|
|
1081
1107
|
for (const [key, h] of latest) {
|
|
1082
1108
|
if (byKey.has(key)) {
|
|
1083
1109
|
const cur = byKey.get(key)
|
|
1084
|
-
byKey.set(key, { ...cur, signal: h.signal, note: cur.note || h.text.slice(0, 80) })
|
|
1110
|
+
byKey.set(key, { ...cur, signal: h.signal, note: cur.note || maskDisplay(h.text).slice(0, 80) })
|
|
1085
1111
|
} else {
|
|
1086
1112
|
const name = displayNameFromSignalText(h.text)
|
|
1087
1113
|
if (!name || isSignalNameNoise(name)) continue
|
|
1088
1114
|
byKey.set(key, {
|
|
1089
1115
|
name,
|
|
1090
1116
|
role: '',
|
|
1091
|
-
note: h.text.slice(0, 80),
|
|
1117
|
+
note: maskDisplay(h.text).slice(0, 80),
|
|
1092
1118
|
signal: h.signal,
|
|
1093
1119
|
source: 'signal',
|
|
1094
1120
|
})
|
|
@@ -1155,7 +1181,7 @@ function extractStats(eng) {
|
|
|
1155
1181
|
const key = from + '→' + to
|
|
1156
1182
|
if (seen.has(key)) continue
|
|
1157
1183
|
seen.add(key)
|
|
1158
|
-
const pre = text.slice(
|
|
1184
|
+
const pre = maskDisplay(text.slice(0, m.index)).slice(-40)
|
|
1159
1185
|
const label = pre.split(/\s+/).filter(Boolean).slice(-3).join(' ').replace(/^[,:;.\-]+|[,:;.\-]+$/g, '') || 'metric'
|
|
1160
1186
|
stats.push({ label, from, to })
|
|
1161
1187
|
}
|
|
@@ -1308,7 +1334,7 @@ function cmdScan() {
|
|
|
1308
1334
|
|
|
1309
1335
|
function cmdResume(args) {
|
|
1310
1336
|
let maxBytes
|
|
1311
|
-
try { ({ args, maxBytes } =
|
|
1337
|
+
try { ({ args, maxBytes } = contextBudget(args)) } catch (e) { console.error(e.message); process.exit(2) }
|
|
1312
1338
|
|
|
1313
1339
|
const initIdx = args.indexOf('--init')
|
|
1314
1340
|
if (initIdx !== -1) {
|
|
@@ -1393,6 +1419,10 @@ function cmdResume(args) {
|
|
|
1393
1419
|
const head = memoryHead(fdeDir)
|
|
1394
1420
|
console.log(`memory git: ${head || 'ready'}${owner ? ` owner: ${owner.email}` : ''}`)
|
|
1395
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
|
+
}
|
|
1396
1426
|
return
|
|
1397
1427
|
}
|
|
1398
1428
|
if (args[0] === '--bind') {
|
|
@@ -1426,7 +1456,7 @@ function cmdResume(args) {
|
|
|
1426
1456
|
const policy = readClean(eng, 'trust-profile.md')
|
|
1427
1457
|
const success = readClean(eng, 'success.md')
|
|
1428
1458
|
const risks = readClean(eng, 'risks.md')
|
|
1429
|
-
process.stdout.write(
|
|
1459
|
+
process.stdout.write(maskedSections([
|
|
1430
1460
|
policy ? `CLIENT POLICY - trust-profile.md\n${policy}` : '',
|
|
1431
1461
|
`${intro}\n\nENGAGEMENT: ${eng}`,
|
|
1432
1462
|
success ? `CURRENT GOALS & ACCEPTANCE - success.md\n${success}` : '',
|
|
@@ -1832,6 +1862,7 @@ function readDebriefInput(args) {
|
|
|
1832
1862
|
}
|
|
1833
1863
|
|
|
1834
1864
|
function previewLine(text, max = 240) {
|
|
1865
|
+
text = masking.mask(text)
|
|
1835
1866
|
const t = String(text || '').replace(/\s+/g, ' ').trim()
|
|
1836
1867
|
if (t.length <= max) return t
|
|
1837
1868
|
return `${t.slice(0, max)}… (${t.length} chars)`
|
|
@@ -1845,7 +1876,8 @@ function writeProposal(eng, text, { replace = false, locked = false } = {}) {
|
|
|
1845
1876
|
finally { ownedDebriefLocks.delete(path.join(eng, DEBRIEF_PROPOSE)) }
|
|
1846
1877
|
}, { soft: true })
|
|
1847
1878
|
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 })
|
|
1879
|
+
const { clean: original, blocks } = splitPrivate(text, { sealDangling: true })
|
|
1880
|
+
const clean = masking.mask(original)
|
|
1849
1881
|
const proposePath = path.join(eng, DEBRIEF_PROPOSE)
|
|
1850
1882
|
const privatePath = path.join(eng, DEBRIEF_PRIVATE)
|
|
1851
1883
|
if (fs.existsSync(proposePath) && !replace) {
|
|
@@ -1887,6 +1919,7 @@ function stripApprovedStamp(text) {
|
|
|
1887
1919
|
// One screen a human can confirm in two minutes. The file-by-file routing
|
|
1888
1920
|
// still prints after this - agents edit prefixes; people read this.
|
|
1889
1921
|
function printDebriefReview(text, eng) {
|
|
1922
|
+
text = masking.restore(text)
|
|
1890
1923
|
const repeats = repeatedDebriefStatements(eng, text)
|
|
1891
1924
|
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
1925
|
const buckets = { decided: [], asked: [], scope: [], delivery: [], open: [], next: [], signer: [] }
|
|
@@ -2091,6 +2124,8 @@ function repeatedDebriefStatements(eng, input) {
|
|
|
2091
2124
|
}
|
|
2092
2125
|
|
|
2093
2126
|
function routeDebriefInput(eng, input, { dry, force, sealed = [], allowReplay = false }) {
|
|
2127
|
+
input = masking.restore(input)
|
|
2128
|
+
masking.mask(splitPrivate(input, { sealDangling: true }).clean)
|
|
2094
2129
|
if (!dry && !debriefTransactionActive) {
|
|
2095
2130
|
ensureMemoryGit(eng)
|
|
2096
2131
|
return withDebriefRecords(eng, () => {
|
|
@@ -2207,7 +2242,11 @@ function runDebrief(args, eng) {
|
|
|
2207
2242
|
const refused = refuseSymlinkWrite(proposal, { soft: true })
|
|
2208
2243
|
if (refused) throw new Error(refused)
|
|
2209
2244
|
if (!fs.existsSync(proposal)) throw new Error('nothing to review - run debrief --smart <notes> first')
|
|
2210
|
-
const
|
|
2245
|
+
const stored = fs.readFileSync(proposal, 'utf8')
|
|
2246
|
+
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.')
|
|
2247
|
+
const { clean: input } = splitPrivate(masking.restore(stored), { sealDangling: true })
|
|
2248
|
+
const safe = masking.mask(input)
|
|
2249
|
+
if (safe !== stored) atomicWriteFile(proposal, safe, { mode: 0o600, soft: true })
|
|
2211
2250
|
return boundedDebriefPreview(eng, () => {
|
|
2212
2251
|
printDebriefReview(input, eng)
|
|
2213
2252
|
routeDebriefInput(eng, input, { dry: true, force: false })
|
|
@@ -2244,6 +2283,7 @@ function runDebrief(args, eng) {
|
|
|
2244
2283
|
console.error('nothing to apply - run: fde debrief --smart <notes.md> then fde debrief --apply')
|
|
2245
2284
|
return void (process.exitCode = 1)
|
|
2246
2285
|
}
|
|
2286
|
+
input = masking.restore(input)
|
|
2247
2287
|
sealed = readSealedProposal(eng)
|
|
2248
2288
|
const expected = readSealCount(eng)
|
|
2249
2289
|
if (expected === null ? (!sealed.length && input.includes(PRIVATE_MARKER)) : sealed.length < expected) {
|
|
@@ -2465,7 +2505,7 @@ function cmdIngest(args) {
|
|
|
2465
2505
|
const body = [
|
|
2466
2506
|
'---',
|
|
2467
2507
|
`source: ${source}`,
|
|
2468
|
-
`title: ${title.replace(/\n/g, ' ')
|
|
2508
|
+
`title: ${title.replace(/\n/g, ' ')}`,
|
|
2469
2509
|
`staged: ${new Date().toISOString()}`,
|
|
2470
2510
|
`id: ${id}`,
|
|
2471
2511
|
'---',
|
|
@@ -2498,7 +2538,7 @@ function cmdReceipts(args) {
|
|
|
2498
2538
|
document.split('\n').forEach((line, i) => {
|
|
2499
2539
|
if (!line.toLowerCase().includes(term.toLowerCase())) return
|
|
2500
2540
|
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' : ''}`
|
|
2541
|
+
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
2542
|
;(recordFiles.includes(file) && source ? records : claims).push({ file, hit })
|
|
2503
2543
|
})
|
|
2504
2544
|
}
|
|
@@ -2524,14 +2564,14 @@ function cmdReceipts(args) {
|
|
|
2524
2564
|
if (records.length) sections.push('ON RECORD (dated, source-backed):\n' + select(records))
|
|
2525
2565
|
if (claims.length) sections.push('CLAIMS & working notes (verify source and approval before citing):\n' + select(claims))
|
|
2526
2566
|
if (!records.length && !claims.length) sections.push(`no record of "${term}" - a gap in the record, not proof of absence`)
|
|
2527
|
-
process.stdout.write(
|
|
2567
|
+
process.stdout.write(maskedSections(sections))
|
|
2528
2568
|
}
|
|
2529
2569
|
|
|
2530
2570
|
// Portable snapshot; stdout is read-only. --out creates a new file and never
|
|
2531
2571
|
// overwrites an engagement record, existing file, or symlink.
|
|
2532
2572
|
function cmdHandoff(args, label = 'Handoff') {
|
|
2533
2573
|
let parsed
|
|
2534
|
-
try { parsed =
|
|
2574
|
+
try { parsed = contextBudget(args) } catch (e) { console.error(e.message); process.exit(1) }
|
|
2535
2575
|
let out = ''
|
|
2536
2576
|
if (parsed.args.length) {
|
|
2537
2577
|
if (parsed.args.length !== 2 || parsed.args[0] !== '--out' || !parsed.args[1] || parsed.args[1].startsWith('--')) {
|
|
@@ -2552,7 +2592,7 @@ function cmdHandoff(args, label = 'Handoff') {
|
|
|
2552
2592
|
const decisionText = d => `${d.text} (decisions.md:${d.line}, redacted view)`
|
|
2553
2593
|
const next = stripTemplateNoise(sectionBody(readClean(eng, 'context.md'), 'Next action', { lastNonEmpty: true }))
|
|
2554
2594
|
const gaps = collectDoctorIssues(eng, { readiness: true })
|
|
2555
|
-
const report =
|
|
2595
|
+
const report = maskedSections([
|
|
2556
2596
|
`# ${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
2597
|
`## Constraints - trust-profile.md\n${stripTemplateNoise(readClean(eng, 'trust-profile.md')) || '(missing)'}`,
|
|
2558
2598
|
`## Signer and success - success.md\nSigner: ${signer || '(missing; do not infer)'}\n${success || '(missing)'}`,
|
|
@@ -2577,7 +2617,7 @@ function cmdHandoff(args, label = 'Handoff') {
|
|
|
2577
2617
|
|
|
2578
2618
|
function cmdRecall(args) {
|
|
2579
2619
|
let maxBytes
|
|
2580
|
-
try { ({ args, maxBytes } =
|
|
2620
|
+
try { ({ args, maxBytes } = contextBudget(args)) } catch (e) { console.error(e.message); process.exit(2) }
|
|
2581
2621
|
const query = args.join(' ').trim()
|
|
2582
2622
|
if (!query || Buffer.byteLength(query) > 2048 || args.some(a => a.startsWith('--'))) {
|
|
2583
2623
|
console.error('usage: fde recall <topic> [--max-bytes 4096..65536]'); process.exit(2)
|
|
@@ -2585,8 +2625,8 @@ function cmdRecall(args) {
|
|
|
2585
2625
|
const eng = resolveEngagement()
|
|
2586
2626
|
if (!eng) { console.error('no engagement - bind a client before recall'); process.exit(2) }
|
|
2587
2627
|
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(
|
|
2628
|
+
const result = context.recallSections(files.map(file => ({ file, text: readClean(eng, file) })), query, 12, masking.mask)
|
|
2629
|
+
process.stdout.write(maskedSections([
|
|
2590
2630
|
`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
2631
|
...result.sections,
|
|
2592
2632
|
], maxBytes))
|
|
@@ -2597,7 +2637,7 @@ function cmdCapture() {
|
|
|
2597
2637
|
if (!eng) process.exit(0) // silent: capture must never break a session
|
|
2598
2638
|
// Workspace git facts (cwd), not the engagement memory repo.
|
|
2599
2639
|
const branch = sh('git branch --show-current')
|
|
2600
|
-
const lastCommit = sh("git log -1 --format='%h %s'")
|
|
2640
|
+
const lastCommit = sh("git log -1 --format='%h %s'")
|
|
2601
2641
|
// porcelain lines are "XY path" - sh() trims, so parse by first whitespace
|
|
2602
2642
|
const changed = sh('git status --porcelain').split('\n').filter(Boolean).slice(0, 8)
|
|
2603
2643
|
.map(l => l.trim().split(/\s+/).slice(1).join(' ')).join(' ')
|
|
@@ -2942,7 +2982,7 @@ function collectDoctorIssues(eng, { readiness = false } = {}) {
|
|
|
2942
2982
|
}
|
|
2943
2983
|
const dupes = findDuplicateOpenRisks(eng)
|
|
2944
2984
|
if (dupes.length) {
|
|
2945
|
-
const sample = (dupes[0][0] || '').replace(/\s+/g, ' ').trim().slice(0, 60)
|
|
2985
|
+
const sample = maskDisplay((dupes[0][0] || '').replace(/\s+/g, ' ').trim()).slice(0, 60)
|
|
2946
2986
|
issues.push(
|
|
2947
2987
|
`${dupes.length} duplicate open-risk cluster(s) (e.g. "${sample}${sample.length >= 60 ? '…' : ''}") - consolidate or retire echoes in risks.md`
|
|
2948
2988
|
)
|
|
@@ -3218,7 +3258,7 @@ function hasEvalReceipt(eng) {
|
|
|
3218
3258
|
function hygieneTriageLines(eng) {
|
|
3219
3259
|
const issues = collectDoctorIssues(eng)
|
|
3220
3260
|
if (!issues.length) return []
|
|
3221
|
-
const top = issues[0].replace(/\s+/g, ' ').trim().slice(0, 72)
|
|
3261
|
+
const top = maskDisplay(issues[0].replace(/\s+/g, ' ').trim()).slice(0, 72)
|
|
3222
3262
|
return [
|
|
3223
3263
|
` hygiene: ${issues.length} issue(s) - ${top}${issues[0].length > 72 ? '…' : ''}`,
|
|
3224
3264
|
' → say "@fde clean up the fieldbook" when ready (agent runs fde doctor; nothing auto-rewrites), or: fde doctor',
|
|
@@ -3258,14 +3298,14 @@ function recordDigest(eng) {
|
|
|
3258
3298
|
const signer = ((success.match(/^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
|
|
3259
3299
|
// "(none)" rather than a missing line: on session start, nobody named to sign
|
|
3260
3300
|
// off is the fact worth seeing, not an absence to scroll past.
|
|
3261
|
-
const lines = [` signer: ${signer.slice(0, 110) || '(none)'}`]
|
|
3301
|
+
const lines = [` signer: ${masking.mask(signer).slice(0, 110) || '(none)'}`]
|
|
3262
3302
|
const { rows } = parseValueLedger(eng)
|
|
3263
3303
|
const promisedRow = [...rows].reverse().find(r => r.promised)
|
|
3264
3304
|
if (promisedRow) {
|
|
3265
|
-
lines.push(` promised: ${formatValueLedgerLine(promisedRow).slice(0, 110)}`)
|
|
3305
|
+
lines.push(` promised: ${masking.mask(formatValueLedgerLine(promisedRow)).slice(0, 110)}`)
|
|
3266
3306
|
} else {
|
|
3267
3307
|
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)}`)
|
|
3308
|
+
if (target) lines.push(` promised: ${masking.mask(target).slice(0, 110)}`)
|
|
3269
3309
|
}
|
|
3270
3310
|
const decisions = datedDecisions(readClean(eng, 'decisions.md')).slice(-2)
|
|
3271
3311
|
for (const d of decisions) lines.push(` decided: ${formatDecisionRecord(d.text)}; source: ${previewLine(sourceReference(d.text) || '(missing)', 100)}`)
|
|
@@ -3320,7 +3360,7 @@ function cmdRedact(args) {
|
|
|
3320
3360
|
}
|
|
3321
3361
|
console.log(`REDACT - ${hits.length} matching line(s) for ${JSON.stringify(term)}`)
|
|
3322
3362
|
hits.slice(0, 20).forEach(h => {
|
|
3323
|
-
const preview = h.line.length > 100 ? h.line.slice(0, 97) + '…' : h.line
|
|
3363
|
+
const preview = h.line.length > 100 ? masking.mask(h.line).slice(0, 97) + '…' : h.line
|
|
3324
3364
|
console.log(` ${h.file}:${h.lineNo} ${preview}`)
|
|
3325
3365
|
})
|
|
3326
3366
|
if (hits.length > 20) console.log(` … +${hits.length - 20} more`)
|
|
@@ -3365,12 +3405,12 @@ function cmdPrep(args) {
|
|
|
3365
3405
|
const people = extractStakeholders(eng).slice(0, 8)
|
|
3366
3406
|
console.log('\nStakeholders (table + signal history)')
|
|
3367
3407
|
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)}` : ''}`))
|
|
3408
|
+
else people.forEach(p => console.log(` [${p.signal}] ${p.name}${p.role ? ` - ${p.role}` : ''}${p.note ? ` · ${masking.mask(p.note).slice(0, 60)}` : ''}`))
|
|
3369
3409
|
|
|
3370
3410
|
const risks = extractRisks(eng).slice(0, 5)
|
|
3371
3411
|
console.log('\nOpen risks (table + dated bullets)')
|
|
3372
3412
|
if (!risks.length) console.log(' (none logged)')
|
|
3373
|
-
else risks.forEach(r => console.log(` [${r.severity}] ${r.text.slice(0, 100)}`))
|
|
3413
|
+
else risks.forEach(r => console.log(` [${r.severity}] ${masking.mask(r.text).slice(0, 100)}`))
|
|
3374
3414
|
|
|
3375
3415
|
const success = firstLine(readClean(eng, 'success.md'), 160)
|
|
3376
3416
|
console.log('\nSuccess looks like')
|
|
@@ -3381,7 +3421,7 @@ function cmdPrep(args) {
|
|
|
3381
3421
|
.slice(-5)
|
|
3382
3422
|
console.log('\nRecent decisions')
|
|
3383
3423
|
if (!decisions.length) console.log(' (none logged)')
|
|
3384
|
-
else decisions.forEach(l => console.log(` ${l.trim().slice(0, 120)}`))
|
|
3424
|
+
else decisions.forEach(l => console.log(` ${masking.mask(l.trim()).slice(0, 120)}`))
|
|
3385
3425
|
|
|
3386
3426
|
const next = nextActionLine(readClean(eng, 'context.md'))
|
|
3387
3427
|
console.log('\nWalk in with')
|
|
@@ -3421,7 +3461,7 @@ function cmdGarden(args) {
|
|
|
3421
3461
|
}
|
|
3422
3462
|
const dupes = findDuplicateOpenRisks(eng)
|
|
3423
3463
|
if (dupes.length) {
|
|
3424
|
-
const sample = (dupes[0][0] || '').replace(/\s+/g, ' ').trim().slice(0, 50)
|
|
3464
|
+
const sample = maskDisplay((dupes[0][0] || '').replace(/\s+/g, ' ').trim()).slice(0, 50)
|
|
3425
3465
|
proposals.push({
|
|
3426
3466
|
id: 'dedupe-risks',
|
|
3427
3467
|
kind: 'apply',
|
|
@@ -3592,7 +3632,7 @@ function engagementSlugFromPath(eng) {
|
|
|
3592
3632
|
}
|
|
3593
3633
|
|
|
3594
3634
|
function cmdStatus(args) {
|
|
3595
|
-
const all = args
|
|
3635
|
+
const all = portfolioView(args)
|
|
3596
3636
|
if (!fs.existsSync(ENGAGEMENTS_ROOT)) { console.log('no engagements yet - fde resume --init <name>'); return }
|
|
3597
3637
|
const rows = []
|
|
3598
3638
|
if (all) {
|
|
@@ -3601,7 +3641,7 @@ function cmdStatus(args) {
|
|
|
3601
3641
|
const eng = path.join(ENGAGEMENTS_ROOT, d, '.fde')
|
|
3602
3642
|
if (!fs.existsSync(eng)) continue
|
|
3603
3643
|
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)
|
|
3644
|
+
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
3645
|
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
3646
|
}
|
|
3607
3647
|
} else {
|
|
@@ -3611,7 +3651,7 @@ function cmdStatus(args) {
|
|
|
3611
3651
|
process.exit(2)
|
|
3612
3652
|
}
|
|
3613
3653
|
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)
|
|
3654
|
+
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
3655
|
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
3656
|
}
|
|
3617
3657
|
if (!rows.length) { console.log('no engagements yet'); return }
|
|
@@ -3661,7 +3701,7 @@ function gatherEngagements(opts = {}) {
|
|
|
3661
3701
|
}
|
|
3662
3702
|
|
|
3663
3703
|
function cmdDashboard(args) {
|
|
3664
|
-
const all = args
|
|
3704
|
+
const all = portfolioView(args)
|
|
3665
3705
|
const outIdx = args.indexOf('--out')
|
|
3666
3706
|
const outPath = outIdx !== -1 && args[outIdx + 1]
|
|
3667
3707
|
? path.resolve(args[outIdx + 1].replace(/^~/, HOME))
|
|
@@ -3717,7 +3757,7 @@ function cmdDashboard(args) {
|
|
|
3717
3757
|
['trust-profile.md', 'Trust profile'],
|
|
3718
3758
|
].map(([f, title]) => [title, readClean(e.dir, f)])
|
|
3719
3759
|
.filter(([, md]) => render.hasRealContent(md))
|
|
3720
|
-
.map(([title, md]) => ({ title, html: render.mdBlockHtml(md, parseMdTable) }))
|
|
3760
|
+
.map(([title, md]) => ({ title, html: render.mdBlockHtml(maskDisplay(md), parseMdTable) }))
|
|
3721
3761
|
e.searchBlob = render.escapeHtml([
|
|
3722
3762
|
e.name, e.next, e.lastSession, e.reality, e.brief,
|
|
3723
3763
|
...e.log.map(g => g.text), ...e.risks.map(r => r.text),
|
|
@@ -3727,7 +3767,7 @@ function cmdDashboard(args) {
|
|
|
3727
3767
|
].join(' ').toLowerCase())
|
|
3728
3768
|
})
|
|
3729
3769
|
|
|
3730
|
-
const html = render.buildFieldbookHtml({ engagements, today, generatedAt: new Date().toISOString() })
|
|
3770
|
+
const html = render.buildFieldbookHtml({ engagements: maskReport(engagements), today, generatedAt: new Date().toISOString() })
|
|
3731
3771
|
|
|
3732
3772
|
try {
|
|
3733
3773
|
const isRecordPath = p => p.split(path.sep).some(part => ['.fde', '.git'].includes(part.toLowerCase()))
|
|
@@ -3898,10 +3938,10 @@ function cmdVault(args) {
|
|
|
3898
3938
|
})
|
|
3899
3939
|
|
|
3900
3940
|
const files = vault.buildVaultFiles({
|
|
3901
|
-
engagements,
|
|
3941
|
+
engagements: maskReport(engagements),
|
|
3902
3942
|
today: render.formatToday(new Date()),
|
|
3903
3943
|
redacted,
|
|
3904
|
-
engagementsRoot: ENGAGEMENTS_ROOT,
|
|
3944
|
+
engagementsRoot: maskReport(ENGAGEMENTS_ROOT),
|
|
3905
3945
|
version: cliVersion(),
|
|
3906
3946
|
})
|
|
3907
3947
|
|
|
@@ -4085,6 +4125,8 @@ ${fs.existsSync(html) ? `\n Open the fieldbook: ${html}` : ''}
|
|
|
4085
4125
|
function printUsage() {
|
|
4086
4126
|
console.log(`fde - deterministic core of fdeops
|
|
4087
4127
|
fde demo the whole loop on a fake client (fde demo --clean removes it)
|
|
4128
|
+
fde setup three first-use choices (or --show for saved preferences)
|
|
4129
|
+
fde privacy show masking capability and its boundaries
|
|
4088
4130
|
fde scan day-1 recon of this repo (facts, no AI)
|
|
4089
4131
|
fde resume load this workspace's engagement memory (bounded)
|
|
4090
4132
|
fde resume --full load the complete context.md (no bound)
|
|
@@ -4113,8 +4155,8 @@ function printUsage() {
|
|
|
4113
4155
|
fde receipts <term> source-backed records versus claims
|
|
4114
4156
|
fde defend sponsor readout: accepted assertions, claims, sources, gaps
|
|
4115
4157
|
fde handoff [--out file] portable redacted packet; stdout by default, new file only
|
|
4116
|
-
fde status [--all]
|
|
4117
|
-
fde dashboard [--all] [--open] [--out <path>]
|
|
4158
|
+
fde status [--all|--current] value ledger, then trust (scope follows setup)
|
|
4159
|
+
fde dashboard [--all|--current] [--open] [--out <path>] fieldbook (scope follows setup)
|
|
4118
4160
|
fde vault derived Obsidian vault of every engagement (--current for one, --redacted for a shared screen, --out <dir>)
|
|
4119
4161
|
hooks call these; you do not: capture (session-end snapshot), preserve (pre-compaction snapshot)
|
|
4120
4162
|
env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)
|
|
@@ -4123,15 +4165,38 @@ function printUsage() {
|
|
|
4123
4165
|
ingest is a sink only - source MCPs (Granola/Gmail/…) are user-configured; never ambient sync`)
|
|
4124
4166
|
}
|
|
4125
4167
|
|
|
4126
|
-
const [cmd, ...
|
|
4168
|
+
const [cmd, ...rawArgs] = process.argv.slice(2)
|
|
4169
|
+
let outputBudget
|
|
4170
|
+
if (['resume', 'recall', 'handoff', 'defend'].includes(cmd) && !rawArgs.some(a => ['--full', '--init', '--bind', '--out'].includes(a))) {
|
|
4171
|
+
try { outputBudget = context.budgetArgs(rawArgs, (setupStore.read() || setup.DEFAULTS).context === 'compact' ? 4096 : 16384).maxBytes } catch (_) {}
|
|
4172
|
+
}
|
|
4173
|
+
require('./lib/masking').protectOutput(masking, { maxBytes: outputBudget })
|
|
4174
|
+
let args
|
|
4175
|
+
try {
|
|
4176
|
+
args = rawArgs.map(arg => masking.restore(arg))
|
|
4177
|
+
for (const key of ['FDEOPS_ENGAGEMENT', 'FDEOS_ENGAGEMENT']) {
|
|
4178
|
+
if (process.env[key]) process.env[key] = masking.restore(process.env[key])
|
|
4179
|
+
}
|
|
4180
|
+
// Resolve privacy-state failures before a user-authorized argument write.
|
|
4181
|
+
args.forEach(arg => masking.mask(arg))
|
|
4182
|
+
}
|
|
4183
|
+
catch (e) { console.error(e.message); process.exit(1) }
|
|
4127
4184
|
if (args.includes('--help') || args.includes('-h') || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
4128
4185
|
printUsage()
|
|
4129
4186
|
process.exit(0)
|
|
4130
4187
|
}
|
|
4188
|
+
try {
|
|
4189
|
+
if (cmd !== 'setup' && cmd !== 'privacy') preferences = setupStore.read() || setup.DEFAULTS
|
|
4190
|
+
const finishAsync = result => { if (result && typeof result.catch === 'function') result.catch(error => { console.error(error.message); process.exitCode = 1 }) }
|
|
4131
4191
|
switch (cmd) {
|
|
4192
|
+
case 'setup': finishAsync(setup.command(setupStore, args)); break
|
|
4193
|
+
case 'privacy':
|
|
4194
|
+
if (args.length) { console.error('usage: fde privacy'); process.exitCode = 2; break }
|
|
4195
|
+
console.log(`FDEOps ${require('../package.json').version} - identifier masking enabled by default.\nCLI responses, smart proposals, handoff packets and ingest MCP results use local aliases.\nPatterns: common emails, international/US phones, SSN-shaped identifiers and supported credentials.\nNames and arbitrary sensitive prose are not detected; mark them <private>.\nRaw files, pasted chat and upstream MCP content bypass this protection. Local reports retain identifiers by default; fde setup can also mask newly generated report content.`)
|
|
4196
|
+
break
|
|
4132
4197
|
case 'demo': cmdDemo(args); break
|
|
4133
4198
|
case 'scan': cmdScan(); break
|
|
4134
|
-
case 'resume': cmdResume(args); break
|
|
4199
|
+
case 'resume': finishAsync(cmdResume(args)); break
|
|
4135
4200
|
case 'recall': cmdRecall(args); break
|
|
4136
4201
|
case 'triage': cmdTriage(); break
|
|
4137
4202
|
case 'log': cmdLog(args); break
|
|
@@ -4162,3 +4227,8 @@ switch (cmd) {
|
|
|
4162
4227
|
// Missing or unknown command must fail - exit 0 made typos look like success in scripts/hooks.
|
|
4163
4228
|
process.exit(1)
|
|
4164
4229
|
}
|
|
4230
|
+
|
|
4231
|
+
} catch (error) {
|
|
4232
|
+
console.error(error && error.message ? error.message : "command failed")
|
|
4233
|
+
process.exitCode = 1
|
|
4234
|
+
}
|
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]
|
|
@@ -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)
|
package/bin/lib/setup.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
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 QUESTIONS = [
|
|
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 LIMITS = 'Both options hide <private> content. Masking covers common identifier formats, not all personal data. Names and sensitive prose need private marking. Your AI host controls provider transmission; setup does not change it. Existing exports are not rewritten.'
|
|
21
|
+
function validate(value) {
|
|
22
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
23
|
+
Object.keys(value).length !== 3 || QUESTIONS.some(q => !q.options.some(([v]) => value[q.key] === v))) {
|
|
24
|
+
throw new Error('Invalid setup choices; run fde setup to see the supported options.')
|
|
25
|
+
}
|
|
26
|
+
return value
|
|
27
|
+
}
|
|
28
|
+
function createSetup(root) {
|
|
29
|
+
const file = path.join(root, '.preferences.json')
|
|
30
|
+
function read() {
|
|
31
|
+
let fd
|
|
32
|
+
try {
|
|
33
|
+
paths.checkPath(file)
|
|
34
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK)
|
|
35
|
+
const st = fs.fstatSync(fd)
|
|
36
|
+
if (!st.isFile() || st.nlink !== 1 || st.size > 4096) throw new Error('unsafe')
|
|
37
|
+
return validate(JSON.parse(fs.readFileSync(fd, 'utf8')))
|
|
38
|
+
} catch (e) {
|
|
39
|
+
if (e.code === 'ENOENT') return null
|
|
40
|
+
throw new Error('Cannot read setup safely. Check .preferences.json in your engagements folder; no settings were changed.')
|
|
41
|
+
} finally { if (fd !== undefined) fs.closeSync(fd) }
|
|
42
|
+
}
|
|
43
|
+
function save(value) {
|
|
44
|
+
validate(value)
|
|
45
|
+
paths.mkdir(root)
|
|
46
|
+
paths.checkPath(file)
|
|
47
|
+
const temporary = path.join(root, `.preferences-${crypto.randomBytes(12).toString('hex')}.tmp`)
|
|
48
|
+
try {
|
|
49
|
+
fs.writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 })
|
|
50
|
+
paths.checkPath(file)
|
|
51
|
+
fs.renameSync(temporary, file)
|
|
52
|
+
} finally { try { fs.unlinkSync(temporary) } catch (_) {} }
|
|
53
|
+
}
|
|
54
|
+
return { read, save }
|
|
55
|
+
}
|
|
56
|
+
function describe(value) {
|
|
57
|
+
return QUESTIONS.map(q => `${q.title} ${q.options.find(([v]) => v === value[q.key])[1]}`).join('\n')
|
|
58
|
+
}
|
|
59
|
+
function command(store, args) {
|
|
60
|
+
if (args.length === 1 && args[0] === '--show') {
|
|
61
|
+
const saved = store.read()
|
|
62
|
+
console.log(JSON.stringify({ configured: !!saved, ...(saved || DEFAULTS) }))
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
if (args.length) {
|
|
66
|
+
const choices = {}
|
|
67
|
+
if (!args.includes('--save') || args.filter(a => a === '--save').length !== 1) throw new Error('Use fde setup, or supply all three choices with --save.')
|
|
68
|
+
const rest = args.filter(a => a !== '--save')
|
|
69
|
+
for (let i = 0; i < rest.length; i += 2) {
|
|
70
|
+
const key = rest[i].replace(/^--/, '')
|
|
71
|
+
if (!rest[i].startsWith('--') || !Object.hasOwn(DEFAULTS, key) || Object.hasOwn(choices, key)) throw new Error('Unknown or repeated setup option.')
|
|
72
|
+
choices[key] = rest[i + 1]
|
|
73
|
+
}
|
|
74
|
+
validate(choices)
|
|
75
|
+
store.save(choices)
|
|
76
|
+
console.log('Setup saved for this engagements folder. Change it anytime with fde setup.\n' + describe(choices) + '\n' + LIMITS)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (process.stdin.isTTY && process.stdout.isTTY) return interactive(store)
|
|
80
|
+
console.log('FIRST-USE SETUP: ask these three questions together, then save the answers. No settings have been changed.')
|
|
81
|
+
for (const [i, q] of QUESTIONS.entries()) console.log(`${i + 1}. ${q.title}\n` + q.options.map(([value, label]) => ` ${value}: ${label}`).join('\n'))
|
|
82
|
+
console.log(LIMITS + '\nAfter the user answers: fde setup --view current|portfolio --context standard|compact --privacy agent|reports --save\nUse fde setup --show to inspect existing choices. Never infer client policy from these preferences.')
|
|
83
|
+
}
|
|
84
|
+
async function interactive(store) {
|
|
85
|
+
// Only static prompts and validated enum labels bypass buffered CLI output.
|
|
86
|
+
const emit = text => fs.writeSync(1, text)
|
|
87
|
+
const rl = require('node:readline').createInterface({ input: process.stdin, terminal: false })
|
|
88
|
+
const lines = rl[Symbol.asyncIterator]()
|
|
89
|
+
const current = store.read() || DEFAULTS, choices = {}
|
|
90
|
+
try {
|
|
91
|
+
emit('Make FDEOps fit your day. Three choices; Enter keeps the current choice. Ctrl-C cancels.\n' + LIMITS + '\n')
|
|
92
|
+
for (const q of QUESTIONS) {
|
|
93
|
+
const selected = q.options.findIndex(([v]) => v === current[q.key])
|
|
94
|
+
while (true) {
|
|
95
|
+
emit(q.title + '\n' + q.options.map(([, label], i) => ` ${i + 1}. ${label}${i === selected ? ' (current)' : ''}`).join('\n') + '\n> ')
|
|
96
|
+
const next = await lines.next()
|
|
97
|
+
if (next.done) { emit('\nSetup cancelled; nothing saved.\n'); return }
|
|
98
|
+
const value = next.value.trim(), index = value === '' ? selected : /^[12]$/.test(value) ? Number(value) - 1 : -1
|
|
99
|
+
if (index >= 0) { choices[q.key] = q.options[index][0]; break }
|
|
100
|
+
emit('Choose 1 or 2, or press Enter.\n')
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
emit('\n' + describe(choices) + '\nSave these settings? [y/N] ')
|
|
104
|
+
const next = await lines.next()
|
|
105
|
+
if (!next.done && /^y(es)?$/i.test(next.value.trim())) { store.save(choices); emit('\nSetup saved. Change it anytime with fde setup.\n') }
|
|
106
|
+
else emit('\nSetup cancelled; nothing saved.\n')
|
|
107
|
+
} finally { rl.close() }
|
|
108
|
+
}
|
|
109
|
+
module.exports = { createSetup, command, DEFAULTS }
|
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
|
|
@@ -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.30.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.30.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
|
|
|
@@ -56,9 +56,13 @@ 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; never pretend preferences were saved. If `configured` is false, finish binding the named client, then run `fde setup` and present its **three questions together**, with the two choices each. Use answers already given; do not invent preferences or ask again per client. Save their selections using the displayed `--save` command; their answers authorize this settings write only. If they skip, continue with existing defaults and leave setup unsaved. On later sessions use saved choices; change them only when requested. Setup configures report scope, initial context size and masking of newly generated reports. It does not install a model, approve client data for AI, or change provider settings. Client policy always takes precedence. Reports can still contain names or other unrecognized sensitive prose; never treat them as anonymized.
|
|
62
|
+
|
|
59
63
|
## Entry (every session)
|
|
60
64
|
|
|
61
|
-
1. `fde resume` (16 KiB
|
|
65
|
+
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
66
|
2. **NO ENGAGEMENT:** ask "What should we call this client?" then **you** init. Pasted notes → debrief after bind.
|
|
63
67
|
3. Playback 2-3 lines. `hygiene:` → offer `fde doctor`; **never auto-rewrite**.
|
|
64
68
|
4. Route. Read **one** `references/*.md`. Confirm, then write.
|
|
@@ -69,7 +73,7 @@ Writes need a bind (`FDEOPS_ENGAGEMENT` or registry). Never install fdeops on in
|
|
|
69
73
|
|----------|---------|
|
|
70
74
|
| where are we | `fde resume` |
|
|
71
75
|
| day-1 look at the repo | `fde scan` |
|
|
72
|
-
| debrief / pasted notes | `fde debrief --smart` →
|
|
76
|
+
| 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
77
|
| prep me for … | `fde prep "<label>"` |
|
|
74
78
|
| when did we agree | `fde receipts <term>` |
|
|
75
79
|
| sponsor update / defend the number | `fde defend` |
|
|
@@ -211,3 +215,7 @@ Ready to build with no `terrain.md` / plan: discover or plan first. Takeover wit
|
|
|
211
215
|
- Evidence on every claim. The FDE will be challenged on these files.
|
|
212
216
|
- Overlays activate on signal, not on request.
|
|
213
217
|
- Load `.fde/` files on demand, never the whole folder.
|
|
218
|
+
|
|
219
|
+
## Identifier masking
|
|
220
|
+
|
|
221
|
+
Before reading engagement content in a session, run `fde privacy` to verify runtime support. If the command is unavailable, stop and update the CLI; a new skill alone does not upgrade an older executable. Use CLI context and previews for model input. They mask common email, phone, SSN-shaped, and credential patterns by default; aliases remain consistent within the local engagements root. Preserve complete alias tokens when drafting updates; the CLI resolves them locally. Never read the private `.privacy/` dictionary, sealed sidecars, raw sensitive notes, or local dashboard/vault files to recover an identity. Names, company names, addresses, and unrecognized formats are not automatically detected: keep sensitive prose in `<private>` blocks. Direct file tools, pasted chat, and upstream source MCPs bypass this boundary.
|
|
@@ -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
|