diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1c0a1dc..b63bf21 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "fdeops", "description": "Second brain for Forward Deployed Engineers. One @fde skill - enter at day one, mid-project, or mid-fire; it routes and does the phase work; engagement memory lands in .fde/ as you confirm judgment.", - "version": "3.10.4", + "version": "3.11.0", "category": "productivity", "tags": [ "community-managed" diff --git a/CHANGELOG.md b/CHANGELOG.md index 87fc946..662fed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 3.11.0 — 2026-08-27 + +One window over every client, without a second memory to maintain. + +### Added +- **`fde vault`** - a derived Obsidian vault of the whole portfolio: a `Portfolio` page across all clients, a page per engagement (phase, trust, next action, timeline, people), a `Questions` page (gone quiet, value nobody accepted, stale signals, no next action), plus frontmatter and `[[wikilinks]]` so search and graph view work in a stock Obsidian with no plugins. Obsidian ignores dot-paths, so `~/fde-engagements` as a vault shows nothing - the record lives inside `.fde/`. +- **`fde vault --redacted`** - the same vault with the political layer removed (`stakeholders.md`, `trust-profile.md`, people pages, trust signals, contact notes, `[signal:x]`/`[@owner]` tokens), on top of the `` redaction every output already does. The first version of a fieldbook that is safe on a shared screen. + +### Notes +- The vault is **derived and disposable**: `.fde/` stays the only source of truth, the folder is deleted and rebuilt on every run, it is gitignored, and nothing in it is ever parsed back. Authoritative `.fde/` files gain no frontmatter and no wikilinks - they stay plain markdown a client can read. +- It refuses to build over `$HOME`, the engagements root, anything inside a `.fde/`, a symlink, or any directory it did not write itself (proved by its `.fdeops-vault` stamp). `--out ` for anywhere else. + ## 3.10.4 — 2026-08-20 The same refusal in the automatic paths: hooks no longer capture one client's session into another. diff --git a/bin/check.js b/bin/check.js index aa8e651..e5a26fd 100644 --- a/bin/check.js +++ b/bin/check.js @@ -464,7 +464,7 @@ if (!fs.existsSync(path.join(root, 'bin', 'fde.js'))) { .map(name => path.join('bin', 'lib', name)), ] const cliSource = cliFiles.map(read).join('\n') - for (const sub of ['cmdScan', 'cmdResume', 'cmdLog', 'cmdDebrief', 'cmdIngest', 'cmdReceipts', 'cmdCapture', 'cmdStatus', 'cmdDashboard']) { + for (const sub of ['cmdScan', 'cmdResume', 'cmdLog', 'cmdDebrief', 'cmdIngest', 'cmdReceipts', 'cmdCapture', 'cmdStatus', 'cmdDashboard', 'cmdVault']) { if (!cliSource.includes(sub)) fail(`CLI sources missing ${sub}`) } if (!JSON.parse(read('package.json')).bin.fde) fail('package.json must expose the fde bin') diff --git a/bin/fde.js b/bin/fde.js index fdc301b..d696794 100755 --- a/bin/fde.js +++ b/bin/fde.js @@ -26,6 +26,7 @@ * fde preserve pre-compaction context snapshot (hook-internal; hooks use this) * fde status [--all] current engagement (default) or full portfolio (--all) * fde dashboard [--all] current engagement fieldbook (default) or all (--all) + * fde vault derived Obsidian vault of the fieldbook (disposable; --redacted) */ const fs = require('fs') const path = require('path') @@ -33,6 +34,7 @@ const os = require('os') const { execSync, execFileSync } = require('child_process') const { createMemoryApi } = require('./lib/memory') const { createTrustApi } = require('./lib/trust') +const vault = require('./lib/vault') const HOME = os.homedir() // FDEOPS_ENGAGEMENTS_ROOT isolates init/status/dashboard (and the registry) for @@ -2800,6 +2802,190 @@ function cmdDashboard(args) { } } +// ---------- vault (a window onto the fieldbook, not a second copy of it) ---------- +// Obsidian skips any path starting with "." - so ~/fde-engagements as a vault shows +// nothing, because every client's record lives inside .fde/. The answer is a derived +// vault: generated from .fde/, rebuilt from scratch each run, gitignored, never read +// back. That is also where redaction belongs (`--redacted` for a shared screen). + +// Stamped into the vault so a stale folder is identifiable. Best-effort: a +// missing package.json must not stop an FDE generating their vault. +function cliVersion() { + try { + return String(JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version || '') + } catch (_) { return '' } +} + +function valueLedgerRows(eng) { + const ledger = stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger') || '') + const table = parseMdTable(ledger) + if (!table) return [] + const sIdx = colIndex(table.headers, /slice/i) + const pIdx = colIndex(table.headers, /promis/i) + const aIdx = colIndex(table.headers, /accept/i) + const rows = [] + for (const row of table.rows) { + const slice = sIdx === -1 ? '' : String(row[sIdx] || '').trim() + const promised = pIdx === -1 ? '' : String(row[pIdx] || '').trim() + if (!slice && !promised) continue + const acceptedRaw = aIdx === -1 ? '' : String(row[aIdx] || '').trim() + rows.push({ + slice, promised, + acceptedBy: !acceptedRaw || PENDING_CELL_RE.test(acceptedRaw) ? '' : acceptedRaw, + }) + } + return rows +} + +function isInside(child, parent) { + const rel = path.relative(parent, child) + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)) +} + +// Containment must be judged on the real path: a symlinked parent +// (ln -s ~/fde-engagements /tmp/l; --out /tmp/l/vault) resolves textually to +// somewhere harmless while writing inside the engagements root. The vault target +// usually does not exist yet, so resolve the deepest ancestor that does. +function realPathish(p) { + let cur = p + const tail = [] + for (let i = 0; i < 64; i++) { + try { + return path.join(fs.realpathSync(cur), ...tail) + } catch (e) { + if (e.code !== 'ENOENT' && e.code !== 'ENOTDIR') return p + const parent = path.dirname(cur) + if (parent === cur) return p + tail.unshift(path.basename(cur)) + cur = parent + } + } + return p +} + +// `fde vault` deletes its output directory before rebuilding, so the only +// acceptable targets are a fresh path or a folder this command wrote before +// (proved by its stamp file). Never the engagements root, never $HOME. +function resolveVaultOut(args, redacted) { + const outIdx = args.indexOf('--out') + const raw = outIdx !== -1 ? String(args[outIdx + 1] || '').trim() : '' + if (outIdx !== -1 && !raw) { + console.error('--out needs a directory path') + process.exit(1) + } + const out = raw + ? path.resolve(raw.replace(/^~(?=$|\/)/, HOME)) + : path.join(HOME, redacted ? 'fde-vault-redacted' : 'fde-vault') + + const refuse = (why) => { + console.error(`refused: will not build the vault at ${out} - ${why}`) + process.exit(1) + } + // The symlink check reads the path as given; every containment check reads it + // resolved, so a link cannot smuggle the target past them. + try { + if (fs.lstatSync(out).isSymbolicLink()) { + refuse('it is a symlink - a rebuild would delete whatever it points at') + } + } catch (e) { + if (e.code !== 'ENOENT') failFs(e, 'check', out) + } + const canon = realPathish(out) + const engRoot = realPathish(ENGAGEMENTS_ROOT) + const realHome = realPathish(HOME) + for (const p of new Set([out, canon])) { + if (p === path.parse(p).root) refuse('that is the filesystem root') + if (p === HOME || p === realHome) refuse('the vault directory is deleted and rebuilt on every run') + if (p.split(path.sep).includes('.fde')) refuse('that is inside a fieldbook; .fde/ is the source of truth') + for (const root of new Set([ENGAGEMENTS_ROOT, engRoot])) { + if (isInside(p, root) || isInside(root, p)) refuse('it would contain or sit inside your engagements root') + } + } + try { + const st = fs.lstatSync(out) + if (!st.isDirectory()) refuse('it exists and is not a directory') + const entries = fs.readdirSync(out) + if (entries.length && !entries.includes(vault.STAMP)) { + refuse(`it already holds files fde vault did not write (no ${vault.STAMP}). Pick an empty path with --out`) + } + } catch (e) { + if (e.code !== 'ENOENT') failFs(e, 'check', out) + } + return out +} + +function cmdVault(args) { + const redacted = args.includes('--redacted') + const all = !args.includes('--current') + const out = resolveVaultOut(args, redacted) + + let engagements + if (all) { + engagements = gatherEngagements() + } else { + const eng = resolveEngagement() + if (!eng) { + console.error('no engagement bound to this workspace.\nrun: fde resume --init or fde vault') + process.exit(2) + } + engagements = gatherEngagements({ only: eng }) + } + + const SECTION_FILES = ['decisions', 'risks', 'delivery', 'stakeholders', 'terrain', 'success', 'trust-profile'] + const ALWAYS = new Set(['decisions', 'risks', 'delivery']) + engagements.forEach(e => { + const ctx = readClean(e.dir, 'context.md') + e.next = (sectionBody(ctx, 'Next action', { lastNonEmpty: true }).split('\n').find(l => l.trim()) || '').trim() + e.brief = firstLine(readClean(e.dir, 'brief.md'), 400) + e.reality = firstLine(readClean(e.dir, 'reality.md'), 400) + e.overlay = detectOverlay(e.dir) + e.days = daysElapsed(e.dir) + e.stakeholders = extractStakeholders(e.dir) + e.log = extractLog(e.dir) + e.valueRows = valueLedgerRows(e.dir) + e.pages = {} + for (const f of SECTION_FILES) { + if (!fs.existsSync(path.join(e.dir, `${f}.md`))) continue + // stripTemplateNoise: the instruction comments are for whoever writes the + // fieldbook, not for whoever reads it in Obsidian. + const body = stripTemplateNoise(readClean(e.dir, `${f}.md`)) + if (!ALWAYS.has(f) && !render.hasRealContent(body)) continue + e.pages[f] = body + } + }) + + const files = vault.buildVaultFiles({ + engagements, + today: render.formatToday(new Date()), + redacted, + engagementsRoot: ENGAGEMENTS_ROOT, + version: cliVersion(), + }) + + // Fresh every run: a client dropped from the portfolio, or a page that stopped + // having content, must not linger as a stale note. + rmTreeQuiet(out) + try { + fs.mkdirSync(out, { recursive: true }) + } catch (e) { + failFs(e, 'create vault', out) + } + for (const f of files) { + const target = path.join(out, f.rel) + try { + fs.mkdirSync(path.dirname(target), { recursive: true }) + } catch (e) { + failFs(e, 'create vault folder', target) + } + atomicWriteFile(target, f.content) + } + + console.log(`vault → ${out}${redacted ? ' (redacted)' : ''}`) + console.log(`${engagements.length} engagement(s) · ${files.length} pages · derived, gitignored, rebuilt on every run`) + console.log('open it: Obsidian → Open folder as vault → this folder, then start at Portfolio') + if (!redacted) console.log('sharing a screen with the sponsor? fde vault --redacted') +} + // ---------- demo (see the value before touching a real client) ---------- // Everything below runs the real commands against a throwaway engagement under // ~/fde-engagements/.demo/ - the leading dot keeps it out of every portfolio @@ -2971,6 +3157,7 @@ function printUsage() { fde preserve pre-compaction context snapshot (hook-internal; hooks use this) fde status [--all] current engagement status (pass --all for full portfolio) fde dashboard [--all] current engagement fieldbook (pass --all for every client) + fde vault derived Obsidian vault of every engagement (--current for one, --redacted for a shared screen, --out ) env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry) writes require a workspace bind (or FDEOPS_ENGAGEMENT) - folder-name match is read-only .fde/ is git-versioned locally for tamper-evident receipts (no remote, no telemetry) @@ -2996,6 +3183,7 @@ switch (cmd) { case 'preserve': cmdPreserve(); break case 'status': cmdStatus(args); break case 'dashboard': cmdDashboard(args); break + case 'vault': cmdVault(args); break case 'help': case '-h': case '--help': diff --git a/bin/install.js b/bin/install.js index 003cd07..e0c55c7 100755 --- a/bin/install.js +++ b/bin/install.js @@ -355,7 +355,7 @@ function cmdInstall(opts = {}) { // through to the CLI (fde.js reads process.argv itself, so require() is enough). const FDE_SUBCOMMANDS = [ 'demo', 'scan', 'resume', 'triage', 'log', 'debrief', 'ingest', 'prep', 'doctor', 'redact', - 'garden', 'owner', 'receipts', 'capture', 'preserve', 'status', 'dashboard', 'help', + 'garden', 'owner', 'receipts', 'capture', 'preserve', 'status', 'dashboard', 'vault', 'help', ] const INSTALL_SUBCOMMANDS = ['init', 'adapters', 'install'] diff --git a/bin/lib/vault.js b/bin/lib/vault.js new file mode 100644 index 0000000..16e0bd4 --- /dev/null +++ b/bin/lib/vault.js @@ -0,0 +1,323 @@ +'use strict' + +// Derived Obsidian view of the fieldbook. Pure builders: in comes already-redacted +// engagement data, out comes a list of { rel, content } files. No fs, no network. +// +// Two rules this file exists to keep: +// 1. `.fde/` stays the only source of truth. Nothing here is ever parsed back, +// so an FDE can edit the vault, delete it, or ignore it with no consequence. +// 2. The vault is an output of the CLI, so blocks are already gone +// before anything reaches these builders (callers use readClean). `redacted` +// goes further and drops the political layer for a shared screen. + +const FORMAT = 1 +const STAMP = '.fdeops-vault' + +// Obsidian resolves [[links]] by note name, and | # ^ [ ] break the link syntax. +// A client called "Acme | EU" must still get a reachable page. +function safeTitle(s) { + return String(s || '') + .replace(/[[\]|#^\\/:*?"<>]/g, ' ') + .replace(/\s+/g, ' ') + .trim() || 'untitled' +} + +function cell(s) { + return String(s || '').replace(/\|/g, '\\|').replace(/\n+/g, ' ').trim() +} + +function yamlStr(s) { + return `"${String(s == null ? '' : s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` +} + +// Frontmatter is what makes the vault queryable (Obsidian properties, Dataview, +// Bases). It exists only in generated files - the authoritative .fde/ markdown +// stays plain, so a client can read it without a tool. +function frontmatter(fields) { + const lines = ['---'] + for (const [k, v] of Object.entries(fields)) { + if (v == null || v === '') continue + if (Array.isArray(v)) { + if (!v.length) continue + lines.push(`${k}:`) + v.forEach(item => lines.push(` - ${yamlStr(item)}`)) + } else if (typeof v === 'number' || typeof v === 'boolean') { + lines.push(`${k}: ${v}`) + } else { + lines.push(`${k}: ${yamlStr(v)}`) + } + } + lines.push('---', '') + return lines.join('\n') +} + +function trustWord(trust) { + return String(trust || '').toLowerCase() === 'red' ? 'red' : String(trust || '') +} + +// A signal token is internal shorthand; the sponsor view keeps the fact and +// drops the grading. Whole section bodies pass through here, so only the space +// the token itself occupied is closed up: a global whitespace collapse ate the +// newlines, glued headings to prose and broke every table on these pages. +function stripInternalTokens(text) { + return String(text || '') + .replace(/[^\S\n]*\[signal:(red|amber|green)\]/gi, '') + .replace(/[^\S\n]*\[@[^\]]+\]/g, '') + .trim() +} + +function sectionPage({ eng, title, body, kind, redacted, today }) { + return frontmatter({ + client: eng.name, + fde_page: kind, + phase: eng.signals.phase === '?' ? '' : eng.signals.phase, + generated: today, + tags: ['fdeops', `fdeops/${kind}`], + }) + `# ${safeTitle(eng.name)} - ${title}\n\n` + + `Engagement: [[${safeTitle(eng.name)}]]\n\n` + + (body.trim() ? body.trim() + '\n' : `*(nothing recorded yet)*\n`) + + `\n---\n*Generated from \`${eng.name}/.fde/${kind}.md\`${redacted ? ' - sponsor-safe copy' : ''}. Edit the fieldbook, not this page.*\n` +} + +function personPage({ eng, person, today }) { + return frontmatter({ + client: eng.name, + fde_page: 'person', + role: person.role, + signal: person.signal, + generated: today, + tags: ['fdeops', 'fdeops/person', `fdeops/signal/${person.signal || 'unknown'}`], + }) + `# ${safeTitle(person.name)}\n\n` + + `${person.role ? `**Role:** ${person.role} \n` : ''}` + + `**Latest signal:** ${person.signal || 'unrecorded'}\n\n` + + (person.note ? `${person.note}\n\n` : '') + + `Engagement: [[${safeTitle(eng.name)}]]\n` +} + +function hubPage({ eng, today, redacted }) { + const s = eng.signals + // Next action / Brief / Reality are free text an FDE types, so they carry the + // same internal tokens the timeline does - strip them on the sponsor page too. + const plain = (text) => redacted ? stripInternalTokens(text) : String(text || '') + const links = [ + ['Decisions', 'decisions'], + ['Risks', 'risks'], + ['Delivery', 'delivery'], + ...(redacted ? [] : [['Stakeholders', 'stakeholders']]), + ['Terrain', 'terrain'], + ['Success', 'success'], + ...(redacted ? [] : [['Trust profile', 'trust-profile']]), + ].filter(([, kind]) => eng.pages[kind] != null) + + const timeline = (redacted ? eng.log.filter(e => e.kind !== 'note') : eng.log) + .map(e => `- **${e.date}** ${cell(redacted ? stripInternalTokens(e.text) : e.text)}${e.sig && !redacted ? ` \`[${e.sig}]\`` : ''}`) + + const people = redacted ? [] : eng.stakeholders.map(p => + `- [[${safeTitle(eng.name)}/People/${safeTitle(p.name)}|${safeTitle(p.name)}]]${p.role ? ` - ${cell(p.role)}` : ''} \`${p.signal || 'unrecorded'}\``) + + return frontmatter({ + client: eng.name, + fde_page: 'engagement', + phase: s.phase === '?' ? '' : s.phase, + ...(redacted ? {} : { trust: trustWord(s.trust), signal_age_days: s.signalAge == null ? '' : s.signalAge, signal_stale: !!s.stale }), + open_risks: s.openRisks, + days_elapsed: eng.days == null ? '' : eng.days, + last_updated: s.updated, + overlay: eng.overlay || '', + generated: today, + tags: ['fdeops', 'fdeops/engagement', ...(redacted ? [] : [`fdeops/trust/${trustWord(s.trust) || 'unknown'}`])], + }) + [ + `# ${safeTitle(eng.name)}`, + '', + `**Phase:** ${s.phase === '?' ? 'unset' : s.phase}` + + (redacted ? '' : ` · **Trust:** ${trustWord(s.trust)}${s.stale ? ' (signal stale - reconfirm)' : ''}`) + + ` · **Open risks:** ${s.openRisks} · **Updated:** ${s.updated}`, + '', + '## Next action', + '', + eng.next ? plain(eng.next) : '*(none recorded - `fde log`/`@fde` writes one)*', + '', + ...(eng.brief ? ['## Brief', '', plain(eng.brief), ''] : []), + ...(eng.reality ? ['## Reality', '', plain(eng.reality), ''] : []), + '## The record', + '', + ...links.map(([label, kind]) => `- [[${safeTitle(eng.name)}/${label}|${label}]]`), + '', + ...(people.length ? ['## People', '', ...people, ''] : []), + ...(timeline.length ? ['## Timeline', '', ...timeline, ''] : []), + '---', + `*Derived from \`${eng.name}/.fde/\` on ${today}. Regenerate with \`fde vault${redacted ? ' --redacted' : ''}\`.*`, + '', + ].join('\n') +} + +function portfolioPage({ engagements, today, redacted }) { + const order = { RED: 0, amber: 1, green: 2 } + const rows = [...engagements].sort((a, b) => + (order[a.signals.trust] ?? 3) - (order[b.signals.trust] ?? 3) || a.name.localeCompare(b.name)) + + const head = redacted + ? ['| Engagement | Phase | Open risks | Next action |', '|---|---|---|---|'] + : ['| Engagement | Phase | Trust | Open risks | Updated | Next action |', '|---|---|---|---|---|---|'] + + const body = rows.map(e => { + const link = `[[${safeTitle(e.name)}]]` + const phase = e.signals.phase === '?' ? 'unset' : e.signals.phase + const next = cell(redacted ? stripInternalTokens(e.next) : e.next) || '-' + return redacted + ? `| ${link} | ${phase} | ${e.signals.openRisks} | ${next} |` + : `| ${link} | ${phase} | ${trustWord(e.signals.trust)}${e.signals.stale ? '?' : ''} | ${e.signals.openRisks} | ${cell(e.signals.updated)} | ${next} |` + }) + + return frontmatter({ + fde_page: 'portfolio', + engagements: rows.length, + generated: today, + tags: ['fdeops', 'fdeops/portfolio'], + }) + [ + '# Portfolio', + '', + rows.length + ? `${rows.length} engagement${rows.length === 1 ? '' : 's'}${redacted ? '' : ', worst trust first'}.` + : 'No engagements yet - `fde resume --init `.', + '', + ...(rows.length ? [...head, ...body, ''] : []), + ...(redacted ? [] : ['See [[Questions]] for what the record is missing.', '']), + ].join('\n') +} + +// Deterministic answers, computed here rather than shipped as Dataview queries: +// the vault must work in a stock Obsidian with no plugins installed. +function questionsPage({ engagements, today }) { + const quiet = engagements.filter(e => e.signals.ageDays !== Infinity && e.signals.ageDays >= 14) + const staleSignal = engagements.filter(e => e.signals.stale) + const noNext = engagements.filter(e => !e.next) + const unaccepted = [] + const noSignal = [] + for (const e of engagements) { + for (const row of e.valueRows || []) { + if (!row.acceptedBy) unaccepted.push({ eng: e, row }) + } + if (!e.stakeholders.length) noSignal.push(e) + } + + const list = (items, empty) => items.length ? items : [`- ${empty}`] + + return frontmatter({ + fde_page: 'questions', + generated: today, + tags: ['fdeops', 'fdeops/questions'], + }) + [ + '# Questions', + '', + 'What the record cannot answer is the part worth reading. Recomputed on every `fde vault`.', + '', + '## Gone quiet (14+ days since a memory write)', + '', + ...list(quiet.map(e => `- [[${safeTitle(e.name)}]] - ${e.signals.updated}`), 'none'), + '', + '## Value promised but nobody accepted it', + '', + ...list(unaccepted.map(({ eng, row }) => `- [[${safeTitle(eng.name)}]] - ${cell(row.slice || row.promised || 'unnamed slice')}`), + 'none - every delivered slice names a customer-side acceptor'), + '', + '## Trust signal older than 21 days', + '', + ...list(staleSignal.map(e => `- [[${safeTitle(e.name)}]] - signal ${e.signals.signalAge}d old`), 'none'), + '', + '## No stakeholder signal at all', + '', + ...list(noSignal.map(e => `- [[${safeTitle(e.name)}]]`), 'none'), + '', + '## No next action recorded', + '', + ...list(noNext.map(e => `- [[${safeTitle(e.name)}]]`), 'none'), + '', + `Portfolio: [[Portfolio]]`, + '', + ].join('\n') +} + +function readmePage({ engagements, today, redacted, engagementsRoot }) { + return [ + '# FDEOps vault (generated - do not keep anything here)', + '', + `Generated ${today} from \`${engagementsRoot}\`. ${engagements.length} engagement${engagements.length === 1 ? '' : 's'}.`, + '', + 'Open this folder as an Obsidian vault. Start at [[Portfolio]]' + (redacted ? '.' : ' and [[Questions]].'), + '', + '## What this is', + '', + '- A **derived** view. The fieldbook at `~/fde-engagements//.fde/` is the only source of truth.', + '- **Disposable.** `fde vault` deletes and rebuilds this folder, so anything you type here is lost. Log to the fieldbook instead (`@fde` or `fde log`).', + '- **Nothing is read back.** No plugin required, no sync, no network - plain markdown, wikilinks and frontmatter.', + '', + '## What is not here', + '', + '- `` blocks. They never leave `.fde/`; every page here is built from redacted reads.', + ...(redacted + ? [ + '- Stakeholders, people pages, trust signals and `trust-profile.md` - this is the `--redacted` build, meant for a shared screen.', + '- Internal `[signal:x]` and `[@owner]` tokens, and contact notes in the timeline.', + '', + '**Still check before you screen-share.** Redaction removes the political layer, not judgement: `decisions.md`, `risks.md` and `delivery.md` are shown as written.', + ] + : [ + '- Nothing else. This is the full working view, for your machine only. For a sponsor meeting run `fde vault --redacted`.', + ]), + '', + ].join('\n') +} + +function buildVaultFiles({ engagements, today, redacted = false, engagementsRoot = '~/fde-engagements', version = '' }) { + const files = [] + const SECTIONS = [ + ['Decisions', 'decisions'], + ['Risks', 'risks'], + ['Delivery', 'delivery'], + ['Stakeholders', 'stakeholders'], + ['Terrain', 'terrain'], + ['Success', 'success'], + ['Trust profile', 'trust-profile'], + ] + const REDACTED_OUT = new Set(['stakeholders', 'trust-profile']) + + files.push({ rel: 'README.md', content: readmePage({ engagements, today, redacted, engagementsRoot }) }) + files.push({ rel: 'Portfolio.md', content: portfolioPage({ engagements, today, redacted }) }) + if (!redacted) files.push({ rel: 'Questions.md', content: questionsPage({ engagements, today }) }) + + for (const eng of engagements) { + const dir = safeTitle(eng.name) + files.push({ rel: `${dir}/${dir}.md`, content: hubPage({ eng, today, redacted }) }) + for (const [title, kind] of SECTIONS) { + if (redacted && REDACTED_OUT.has(kind)) continue + const body = eng.pages[kind] + if (body == null) continue + files.push({ + rel: `${dir}/${title}.md`, + content: sectionPage({ eng, title, body: redacted ? stripInternalTokens(body) : body, kind, redacted, today }), + }) + } + if (!redacted) { + for (const person of eng.stakeholders) { + files.push({ rel: `${dir}/People/${safeTitle(person.name)}.md`, content: personPage({ eng, person, today }) }) + } + } + } + + // A generated vault must not become a commit. `*` covers the whole tree, so a + // vault written inside a repo checkout stays out of `git status` too. + files.push({ rel: '.gitignore', content: '# generated by fde vault - never commit a client record\n*\n' }) + files.push({ + rel: STAMP, + content: JSON.stringify({ + tool: 'fdeops', format: FORMAT, version, generated: today, + mode: redacted ? 'redacted' : 'full', engagements: engagements.length, + source: engagementsRoot, + note: 'Written by `fde vault`. Deleted and rebuilt on every run - this file is how the CLI knows the folder is safe to replace.', + }, null, 2) + '\n', + }) + return files +} + +module.exports = { buildVaultFiles, safeTitle, stripInternalTokens, frontmatter, STAMP, FORMAT } diff --git a/docs/USAGE.md b/docs/USAGE.md index 6cdf0c8..b816f34 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -144,6 +144,7 @@ fde log contact "…" --signal amber fde receipts "descope" # dated agreements (ON RECORD) fde dashboard --all # every client, sorted by trust fde status [--all] # trust-first triage +fde vault [--redacted] [--out D] # derived Obsidian vault of the whole portfolio (disposable) fde demo # the whole loop on a fake client (--clean removes it) ``` @@ -181,6 +182,24 @@ One folder per client, one binding per workspace. Never merge contexts. `fde sta --- +## One window over every client (Obsidian) + +```bash +fde vault # → ~/fde-vault, then: Obsidian → Open folder as vault +fde vault --redacted # → ~/fde-vault-redacted, safe for a shared screen +``` + +Obsidian ignores any path starting with `.`, so pointing it at `~/fde-engagements` shows nothing - every record lives inside `.fde/`. `fde vault` therefore writes a **derived** vault: a `Portfolio` page across all clients, one page per engagement (phase, trust, next action, timeline, people), a `Questions` page (gone quiet, value nobody accepted, stale signals), plus frontmatter and `[[wikilinks]]` so search and graph view work with no plugins installed. + +The rules that keep it from becoming a second memory: + +- `.fde/` stays the only source of truth. The vault is **never** read back. +- It is **disposable** - every run deletes and rebuilds it, so anything typed there is lost. Log to the fieldbook instead (`@fde`, or `fde log`). +- It is gitignored, and it refuses to build over `$HOME`, your engagements root, a `.fde/` folder, a symlink, or any directory it did not write itself. +- `--redacted` drops `stakeholders.md`, `trust-profile.md`, people pages, trust signals and contact notes - on top of the `` redaction every FDEOps output already does. + +--- + ## What fdeops does not do The skills are methods refined from real engagements, not autonomy - they tell you what to check, not what to decide. Concretely, fdeops does not: diff --git a/mcp/fdeops-ingest/package.json b/mcp/fdeops-ingest/package.json index b8d969f..86660b2 100644 --- a/mcp/fdeops-ingest/package.json +++ b/mcp/fdeops-ingest/package.json @@ -1,6 +1,6 @@ { "name": "fdeops-ingest-mcp", - "version": "3.10.4", + "version": "3.11.0", "private": true, "description": "Thin stdio MCP sink for FDEOps ingest (stage → propose → apply). Zero runtime dependencies.", "bin": { diff --git a/package.json b/package.json index c849ccb..464ee5d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fdeops", - "version": "3.10.4", + "version": "3.11.0", "description": "Field kit for engineers embedded in client work - a real CLI (recon, memory, portfolio), one @fde skill with field judgment on top, and hooks that make it automatic. Claude Code plugin and any agent that loads skills.", "bin": { "fdeops": "bin/install.js", diff --git a/plugin.json b/plugin.json index dbaef3d..9733c27 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "fdeops", - "version": "3.10.4", + "version": "3.11.0", "description": "Engagement fieldbook for Forward Deployed Engineers: per-client memory in local .fde/ files, one @fde skill, land to close methodology. Local-only, no network.", "author": { "name": "Subash Natarajan", diff --git a/skills/fde/SKILL.md b/skills/fde/SKILL.md index 148b4eb..b581f9b 100644 --- a/skills/fde/SKILL.md +++ b/skills/fde/SKILL.md @@ -90,6 +90,7 @@ When NOT to interrogate or challenge: unambiguous one-liners, mechanical ops, FD | "Draft the sponsor update" / how are we doing | `fde status` then follow `references/status.md` for the narrative | | "Log that they went quiet" / trust signal | `fde log contact "…" --signal amber\|green\|red`. If they already named the color ("log that as amber"), that is the confirm — write it. If they only described the situation, playback the color once, then write. | | Want the HTML fieldbook | `fde dashboard` | +| "Open my clients in Obsidian" / one window over everything / "can I show this to the sponsor?" | `fde vault` (add `--redacted` for a shared screen). Derived and disposable: it is rebuilt from `.fde/` on every run and never read back, so tell them to keep logging to the fieldbook, not to the vault. | | "Clean up the fieldbook" / hygiene / memory feels messy | `fde doctor` - walk issues in plain language; propose fixes; never auto-rewrite without confirm. Includes structural gaps: empty operating map (plan+), stakeholder name forks (Denise vs Denise Chen), duplicates, ship/close risks. Contradictions need judgment (brief vs reality) - doctor is structural; you handle meaning. | | "Scrub this secret / redact that token" (buried line, not just last write) | `fde redact ` preview, then `fde redact --apply` after confirm. Undo is last-write only; redact is for buried lines. Remind them to rotate the real credential. | diff --git a/test/fde-cli.test.js b/test/fde-cli.test.js index 545e744..8139eac 100644 --- a/test/fde-cli.test.js +++ b/test/fde-cli.test.js @@ -2783,3 +2783,227 @@ test('a whitespace-only FDEOPS_ENGAGEMENT refuses instead of using the workspace assert.match(res.stderr, /set to whitespace/) assert.equal(fs.readFileSync(path.join(bound, 'decisions.md'), 'utf8'), before) }) + +// ---------- fde vault (derived Obsidian view) ---------- + +function vaultDir(sandbox, name = 'fde-vault') { + return path.join(sandbox.home, name) +} + +function readVault(sandbox, rel, name = 'fde-vault') { + return fs.readFileSync(path.join(vaultDir(sandbox, name), rel), 'utf8') +} + +test('vault renders the portfolio and every engagement, and never the private block', () => { + const sandbox = makeSandbox('vault') + assert.equal(runFde(sandbox, ['resume', '--init', 'Acme Payments']).status, 0) + runFde(sandbox, ['log', 'decision', 'keep the existing Stripe connector']) + runFde(sandbox, ['log', 'contact', 'Priya sponsor still backing it', '--signal', 'green']) + const eng = engagementPath(sandbox, 'acme-payments') + fs.appendFileSync(path.join(eng, 'risks.md'), '\nTom is about to be managed out\n') + + const second = path.join(sandbox.dir, 'ws2') + fs.mkdirSync(second, { recursive: true }) + assert.equal(runFde(sandbox, ['resume', '--init', 'Beta Bank'], { cwd: second }).status, 0) + + const res = runFde(sandbox, ['vault']) + assert.equal(res.status, 0, res.stdout + res.stderr) + assert.match(res.stdout, /2 engagement\(s\)/) + + const portfolio = readVault(sandbox, 'Portfolio.md') + assert.match(portfolio, /\[\[acme-payments\]\]/) + assert.match(portfolio, /\[\[beta-bank\]\]/) + + const hub = readVault(sandbox, path.join('acme-payments', 'acme-payments.md')) + assert.match(hub, /^---\nclient: "acme-payments"/) // frontmatter only in the derived copy + assert.match(hub, /\[\[acme-payments\/Decisions\|Decisions\]\]/) + assert.match(hub, /keep the existing Stripe connector/) + assert.match(readVault(sandbox, path.join('acme-payments', 'People', 'Priya.md')), /signal: "green"/) + assert.ok(fs.existsSync(path.join(vaultDir(sandbox), 'Questions.md'))) + + // the authoritative fieldbook stays plain markdown - no frontmatter, no wikilinks + const source = fs.readFileSync(path.join(eng, 'decisions.md'), 'utf8') + assert.equal(source.startsWith('---'), false) + assert.equal(/\[\[/.test(source), false) + + for (const file of walkFiles(vaultDir(sandbox))) { + const body = fs.readFileSync(file, 'utf8') + assert.equal(/managed out/.test(body), false, `private text leaked into ${file}`) + } +}) + +function walkFiles(dir) { + const out = [] + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name) + if (entry.isDirectory()) out.push(...walkFiles(p)) + else out.push(p) + } + return out +} + +test('vault --redacted drops the political layer and its internal tokens', () => { + const sandbox = makeSandbox('vaultredacted') + assert.equal(runFde(sandbox, ['resume', '--init', 'Acme']).status, 0) + runFde(sandbox, ['log', 'contact', 'Priya sponsor still backing it', '--signal', 'amber']) + runFde(sandbox, ['log', 'decision', 'ship the reconciliation slice first']) + + const res = runFde(sandbox, ['vault', '--redacted']) + assert.equal(res.status, 0, res.stdout + res.stderr) + const out = vaultDir(sandbox, 'fde-vault-redacted') + assert.ok(fs.existsSync(out)) + assert.equal(fs.existsSync(path.join(out, 'acme', 'Stakeholders.md')), false) + assert.equal(fs.existsSync(path.join(out, 'acme', 'Trust profile.md')), false) + assert.equal(fs.existsSync(path.join(out, 'acme', 'People')), false) + + const hub = fs.readFileSync(path.join(out, 'acme', 'acme.md'), 'utf8') + assert.match(hub, /ship the reconciliation slice first/) + assert.equal(/Priya/.test(hub), false) + assert.equal(/trust:/.test(hub), false) + assert.equal(/\[signal:/.test(hub), false) + assert.equal(/\[@/.test(hub), false) + assert.equal(/amber/.test(fs.readFileSync(path.join(out, 'Portfolio.md'), 'utf8')), false) +}) + +test('vault is disposable: identical on a rerun, and a dropped engagement disappears', () => { + const sandbox = makeSandbox('vaultrebuild') + assert.equal(runFde(sandbox, ['resume', '--init', 'Acme']).status, 0) + const second = path.join(sandbox.dir, 'ws2') + fs.mkdirSync(second, { recursive: true }) + assert.equal(runFde(sandbox, ['resume', '--init', 'Gone'], { cwd: second }).status, 0) + + assert.equal(runFde(sandbox, ['vault']).status, 0) + const first = walkFiles(vaultDir(sandbox)).map(f => f + '\n' + fs.readFileSync(f, 'utf8')).join('\n') + assert.equal(runFde(sandbox, ['vault']).status, 0) + const again = walkFiles(vaultDir(sandbox)).map(f => f + '\n' + fs.readFileSync(f, 'utf8')).join('\n') + assert.equal(again, first, 'vault output is not deterministic') + + // A hand-typed note in the vault is not memory - the rebuild takes it away. + fs.writeFileSync(path.join(vaultDir(sandbox), 'acme', 'scratch.md'), 'typed into the wrong place') + fs.rmSync(path.join(sandbox.home, 'fde-engagements', 'gone'), { recursive: true, force: true }) + assert.equal(runFde(sandbox, ['vault']).status, 0) + assert.equal(fs.existsSync(path.join(vaultDir(sandbox), 'acme', 'scratch.md')), false) + assert.equal(fs.existsSync(path.join(vaultDir(sandbox), 'gone')), false) + assert.equal(/\[\[gone\]\]/.test(readVault(sandbox, 'Portfolio.md')), false) +}) + +test('vault refuses any target it would be wrong to delete', () => { + const sandbox = makeSandbox('vaultrefuse') + assert.equal(runFde(sandbox, ['resume', '--init', 'Acme']).status, 0) + const engRoot = path.join(sandbox.home, 'fde-engagements') + + const mine = path.join(sandbox.dir, 'my-notes') + fs.mkdirSync(mine, { recursive: true }) + fs.writeFileSync(path.join(mine, 'notes.md'), 'ten years of notes') + const link = path.join(sandbox.dir, 'link') + fs.symlinkSync(mine, link) + + const cases = [ + [engRoot, /engagements root/], + [sandbox.home, /deleted and rebuilt/], + [engagementPath(sandbox, 'acme'), /inside a fieldbook/], + [mine, /did not write/], + [link, /symlink/], + ] + for (const [target, expected] of cases) { + const res = runFde(sandbox, ['vault', '--out', target]) + assert.equal(res.status, 1, `${target} was not refused: ${res.stdout + res.stderr}`) + assert.match(res.stderr, expected) + } + assert.equal(fs.readFileSync(path.join(mine, 'notes.md'), 'utf8'), 'ten years of notes') + assert.equal(runFde(sandbox, ['vault', '--out']).status, 1) + + // a path with spaces is a path, not two arguments + const spaced = path.join(sandbox.dir, 'my vault') + assert.equal(runFde(sandbox, ['vault', '--out', spaced]).status, 0) + assert.ok(fs.existsSync(path.join(spaced, 'Portfolio.md'))) + assert.ok(fs.existsSync(path.join(spaced, '.gitignore'))) +}) + +test('an empty portfolio builds a vault that says so instead of failing', () => { + const sandbox = makeSandbox('vaultempty') + const res = runFde(sandbox, ['vault']) + assert.equal(res.status, 0, res.stdout + res.stderr) + assert.match(readVault(sandbox, 'Portfolio.md'), /No engagements yet/) + assert.equal(runFde(sandbox, ['vault', '--current']).status, 2) +}) + +test('vault surfaces value promised with nobody named as accepting it', () => { + const sandbox = makeSandbox('vaultquestions') + assert.equal(runFde(sandbox, ['resume', '--init', 'Acme']).status, 0) + const eng = engagementPath(sandbox, 'acme') + const delivery = fs.readFileSync(path.join(eng, 'delivery.md'), 'utf8') + .replace('|------|-------|--------|----------|----------|-------------|----------|----------|', + '|------|-------|--------|----------|----------|-------------|----------|----------|\n' + + '| 2026-08-27 | reconciliation | cost-save | 4h/week | 6h/week | pending | run log | flag |') + fs.writeFileSync(path.join(eng, 'delivery.md'), delivery) + + assert.equal(runFde(sandbox, ['vault']).status, 0) + const questions = readVault(sandbox, 'Questions.md') + assert.match(questions, /Value promised but nobody accepted it[\s\S]*reconciliation/) +}) + +test('a redacted section page keeps its markdown structure (headings, table rows, blank lines)', () => { + const sandbox = makeSandbox('vaultstructure') + assert.equal(runFde(sandbox, ['resume', '--init', 'Acme']).status, 0) + runFde(sandbox, ['log', 'delivery', 'reconciliation slice shipped']) + const eng = engagementPath(sandbox, 'acme') + const source = fs.readFileSync(path.join(eng, 'delivery.md'), 'utf8') + + assert.equal(runFde(sandbox, ['vault', '--redacted']).status, 0) + const page = fs.readFileSync(path.join(sandbox.home, 'fde-vault-redacted', 'acme', 'Delivery.md'), 'utf8') + const body = page.split(/\n---\n/).slice(1).join('\n---\n') + + // one heading per line, table rows intact, and the [@owner] token gone + for (const heading of source.match(/^#{1,6} .*$/gm) || []) { + assert.match(body, new RegExp(`^${heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'), `heading collapsed: ${heading}`) + } + assert.match(body, /^\|------\|-------\|/m, 'table separator row collapsed') + assert.match(body, /^- \[\d{4}-\d{2}-\d{2}\] reconciliation slice shipped$/m) + assert.equal(/\[@/.test(body), false) + assert.ok(body.includes('\n\n'), 'every blank line was collapsed away') +}) + +test('a redacted vault strips internal tokens from next action, brief and reality too', () => { + const sandbox = makeSandbox('vaulttokens') + assert.equal(runFde(sandbox, ['resume', '--init', 'Acme']).status, 0) + const eng = engagementPath(sandbox, 'acme') + fs.appendFileSync(path.join(eng, 'context.md'), '\n## Next action\n\nchase the sign-off [@alice] [signal:red]\n') + fs.writeFileSync(path.join(eng, 'brief.md'), '# Brief\n\nreplace the batch job [signal:amber]\n') + fs.writeFileSync(path.join(eng, 'reality.md'), '# Reality\n\nthe batch job is load-bearing [@bob]\n') + + assert.equal(runFde(sandbox, ['vault', '--redacted']).status, 0) + const out = path.join(sandbox.home, 'fde-vault-redacted') + for (const rel of ['Portfolio.md', path.join('acme', 'acme.md')]) { + const body = fs.readFileSync(path.join(out, rel), 'utf8') + assert.equal(/\[signal:/.test(body), false, `signal token leaked into ${rel}`) + assert.equal(/\[@/.test(body), false, `owner token leaked into ${rel}`) + assert.match(body, /chase the sign-off/) + } + const hub = fs.readFileSync(path.join(out, 'acme', 'acme.md'), 'utf8') + assert.match(hub, /replace the batch job/) + assert.match(hub, /the batch job is load-bearing/) + + // the full build keeps them - they are how the CLI reads its own memory + assert.equal(runFde(sandbox, ['vault']).status, 0) + assert.match(fs.readFileSync(path.join(sandbox.home, 'fde-vault', 'acme', 'acme.md'), 'utf8'), /\[@alice\]/) +}) + +test('a symlinked parent cannot smuggle the vault into the engagements root', () => { + const sandbox = makeSandbox('vaultsymlinkparent') + assert.equal(runFde(sandbox, ['resume', '--init', 'Acme']).status, 0) + const engRoot = path.join(sandbox.home, 'fde-engagements') + const link = path.join(sandbox.dir, 'link') + fs.symlinkSync(engRoot, link) + + const res = runFde(sandbox, ['vault', '--out', path.join(link, 'newvault')]) + assert.equal(res.status, 1, res.stdout + res.stderr) + assert.match(res.stderr, /engagements root/) + assert.equal(fs.existsSync(path.join(engRoot, 'newvault')), false) + + const homeLink = path.join(sandbox.dir, 'homelink') + fs.symlinkSync(sandbox.home, homeLink) + const res2 = runFde(sandbox, ['vault', '--out', path.join(homeLink, '..', path.basename(sandbox.home))]) + assert.equal(res2.status, 1, res2.stdout + res2.stderr) + assert.match(res2.stderr, /deleted and rebuilt|engagements root/) +})