fdeops 3.10.2 → 3.11.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/bin/fde.js CHANGED
@@ -26,6 +26,7 @@
26
26
  * fde preserve pre-compaction context snapshot (hook-internal; hooks use this)
27
27
  * fde status [--all] current engagement (default) or full portfolio (--all)
28
28
  * fde dashboard [--all] current engagement fieldbook (default) or all (--all)
29
+ * fde vault derived Obsidian vault of the fieldbook (disposable; --redacted)
29
30
  */
30
31
  const fs = require('fs')
31
32
  const path = require('path')
@@ -33,6 +34,7 @@ const os = require('os')
33
34
  const { execSync, execFileSync } = require('child_process')
34
35
  const { createMemoryApi } = require('./lib/memory')
35
36
  const { createTrustApi } = require('./lib/trust')
37
+ const vault = require('./lib/vault')
36
38
 
37
39
  const HOME = os.homedir()
38
40
  // FDEOPS_ENGAGEMENTS_ROOT isolates init/status/dashboard (and the registry) for
@@ -94,13 +96,35 @@ function grepFiles(files, regex, cap) {
94
96
 
95
97
  // ---------- engagement resolution (zero-ceremony order) ----------
96
98
 
99
+ // Registry lines are "<absolute workspace path> <slug>". A hand-edited or
100
+ // truncated file used to parse into nonsense bindings (a line with no space
101
+ // yielded a workspace missing its last character), so every binding a workspace
102
+ // could match on silently disappeared behind "NO ENGAGEMENT". Skip what cannot
103
+ // be a binding and say so once.
104
+ let registryWarned = false
97
105
  function readRegistry() {
98
- try {
99
- return fs.readFileSync(REGISTRY, 'utf8').split('\n').filter(Boolean).map(l => {
100
- const i = l.lastIndexOf(' ')
101
- return { workspace: l.slice(0, i), slug: l.slice(i + 1) }
102
- })
103
- } catch (_) { return [] }
106
+ let raw
107
+ // Regular files only: a fifo here blocked `fde resume --init` on open.
108
+ try { if (!fs.lstatSync(REGISTRY).isFile()) return [] } catch (_) { return [] }
109
+ try { raw = fs.readFileSync(REGISTRY, 'utf8') } catch (_) { return [] }
110
+ const entries = []
111
+ let skipped = 0
112
+ for (const line of raw.split('\n')) {
113
+ if (!line.trim()) continue
114
+ const i = line.lastIndexOf(' ')
115
+ const workspace = i > 0 ? line.slice(0, i) : ''
116
+ const slug = i > 0 ? line.slice(i + 1).trim() : ''
117
+ if (!workspace || !slug || !path.isAbsolute(workspace)) { skipped++; continue }
118
+ entries.push({ workspace, slug })
119
+ }
120
+ if (skipped && !registryWarned) {
121
+ registryWarned = true
122
+ process.stderr.write(
123
+ `⚠ ${skipped} unreadable line(s) in ${REGISTRY} ignored - expected "<workspace path> <slug>" per line.\n` +
124
+ ' re-bind this workspace with: fde resume --init <name>\n'
125
+ )
126
+ }
127
+ return entries
104
128
  }
105
129
 
106
130
  function resolveEngagement(opts = {}) {
@@ -109,11 +133,72 @@ function resolveEngagement(opts = {}) {
109
133
  // read-only convenience; writing on a folder-name guess contaminates clients.
110
134
  const forWrite = !!opts.forWrite
111
135
  const accept = (p) => acceptEngagementPath(p, { forWrite })
112
- // 1) explicit env (back-compat: accept old FDEOS_ENGAGEMENT too)
113
- const env = (process.env.FDEOPS_ENGAGEMENT || process.env.FDEOS_ENGAGEMENT || '').replace(/^~/, HOME).trim()
136
+ // 1) explicit env (back-compat: accept old FDEOS_ENGAGEMENT too). An override
137
+ // that cannot be honored is never silently ignored: falling through to the
138
+ // registry would route this client's note into whichever engagement the
139
+ // workspace happens to be bound to. A bare slug is accepted too - it is what
140
+ // an FDE types - but only when it resolves under the engagements root.
141
+ const envRaw = process.env.FDEOPS_ENGAGEMENT || process.env.FDEOS_ENGAGEMENT || ''
142
+ const env = envRaw.replace(/^~/, HOME).trim()
143
+ // A value that is all whitespace is a variable someone meant to set - usually
144
+ // an empty expansion. Treating it as unset filed the note under whichever
145
+ // engagement the workspace was bound to, silently.
146
+ if (!env && envRaw) {
147
+ process.stderr.write(
148
+ 'FDEOPS_ENGAGEMENT is set to whitespace - that names no engagement.\n' +
149
+ ' set it to an engagement, or unset it to use this workspace\'s binding.\n'
150
+ )
151
+ return null
152
+ }
114
153
  if (env) {
115
- const ok = accept(env)
116
- if (ok) return ok
154
+ // A relative value resolves against whatever directory the agent happened
155
+ // to start in - `FDEOPS_ENGAGEMENT=..` accepted the parent folder and put
156
+ // memory there. Absolute path, ~ path, or bare slug; nothing in between.
157
+ const looksLikePath = env.includes(path.sep) || env.includes('/') || env.startsWith('.')
158
+ if (looksLikePath && !path.isAbsolute(env)) {
159
+ process.stderr.write(
160
+ `FDEOPS_ENGAGEMENT must be an absolute path or a bare engagement slug - got "${env}".\n` +
161
+ ` e.g. ${path.join(ENGAGEMENTS_ROOT, '<client>', '.fde')} or just <client>\n`
162
+ )
163
+ return null
164
+ }
165
+ if (looksLikePath) {
166
+ // Pointing at the engagement folder instead of its .fde used to create a
167
+ // second, git-less memory beside the real one - same client, split record.
168
+ const nested = accept(path.join(env, '.fde'))
169
+ if (nested) return nested
170
+ const ok = accept(env)
171
+ if (ok) return ok
172
+ } else {
173
+ // slugify() falls back to the literal "engagement" for a value with no
174
+ // slug characters at all ("???"), which used to resolve onto a real
175
+ // engagement nobody named. A name that slugifies to nothing names nothing.
176
+ if (!/[a-z0-9]/i.test(env)) {
177
+ process.stderr.write(
178
+ `FDEOPS_ENGAGEMENT is set to "${env}", which is not an engagement name.\n` +
179
+ ' refusing to guess - fix or unset the variable.\n' +
180
+ ' list what exists: fde status --all\n'
181
+ )
182
+ return null
183
+ }
184
+ const slugDir = path.join(ENGAGEMENTS_ROOT, slugify(env), '.fde')
185
+ const asSlug = accept(slugDir)
186
+ if (asSlug) return asSlug
187
+ process.stderr.write(
188
+ `FDEOPS_ENGAGEMENT is set to "${env}" but no engagement memory is there.\n` +
189
+ ` looked at: ${slugDir}\n` +
190
+ ' refusing to fall back to another engagement - fix or unset the variable.\n' +
191
+ ' list what exists: fde status --all\n'
192
+ )
193
+ return null
194
+ }
195
+ process.stderr.write(
196
+ `FDEOPS_ENGAGEMENT is set to "${env}" but no engagement memory is there.\n` +
197
+ ` looked at: ${env}\n` +
198
+ ' refusing to fall back to another engagement - fix or unset the variable.\n' +
199
+ ' list what exists: fde status --all\n'
200
+ )
201
+ return null
117
202
  }
118
203
  // 2) workspace registry binding (written by resume --init). Match the cwd OR
119
204
  // any ancestor of it - FDEs run commands from src/, packages/api/, etc., not
@@ -195,8 +280,12 @@ function templatesDir() {
195
280
 
196
281
  // ---------- shared engagement signals (one source of truth) ----------
197
282
 
283
+ // Regular files only: a fifo left in a memory slot used to block the whole CLI
284
+ // on open (doctor/resume/triage hung forever), and a directory threw EISDIR.
198
285
  function readEng(eng, f) {
199
- try { return fs.readFileSync(path.join(eng, f), 'utf8') } catch (_) { return '' }
286
+ const abs = path.join(eng, f)
287
+ try { if (!fs.lstatSync(abs).isFile()) return '' } catch (_) { return '' }
288
+ try { return fs.readFileSync(abs, 'utf8') } catch (_) { return '' }
200
289
  }
201
290
 
202
291
  // Redact private notes and template hints from every model-facing read.
@@ -375,13 +464,17 @@ function failFs(err, action, target) {
375
464
  process.exit(1)
376
465
  }
377
466
 
378
- // Refuse writes that would follow a symlink out of the engagement tree.
379
- // Missing path is fine (new file). Soft mode returns the message instead of exiting
380
- // (session capture must never crash a hook).
467
+ // Refuse writes that would follow a symlink out of the engagement tree, or
468
+ // block forever on something that is not a file (a fifo in a memory slot hung
469
+ // every append). Missing path is fine (new file). Soft mode returns the message
470
+ // instead of exiting (session capture must never crash a hook).
381
471
  function refuseSymlinkWrite(p, opts = {}) {
382
472
  try {
383
- if (fs.lstatSync(p).isSymbolicLink()) {
384
- const msg = `refused: ${path.basename(p)} is a symlink - write would leave the engagement tree. Replace it with a real file.`
473
+ const st = fs.lstatSync(p)
474
+ if (st.isSymbolicLink() || !st.isFile()) {
475
+ const msg = st.isSymbolicLink()
476
+ ? `refused: ${path.basename(p)} is a symlink - write would leave the engagement tree. Replace it with a real file.`
477
+ : `refused: ${path.basename(p)} is not a regular file - remove it and re-run; every write is refused while it is there.`
385
478
  if (opts.soft) return msg
386
479
  console.error(msg)
387
480
  process.exit(1)
@@ -430,7 +523,7 @@ function withFileLock(targetPath, fn, opts = {}) {
430
523
  function atomicWriteFile(p, content, opts = {}) {
431
524
  const blocked = refuseSymlinkWrite(p, opts)
432
525
  if (blocked) {
433
- if (opts.soft) throw Object.assign(new Error(blocked), { code: 'ESYMLINK' })
526
+ if (opts.soft) throw Object.assign(new Error(blocked), { code: blocked.includes('symlink') ? 'ESYMLINK' : 'EIRREGULAR' })
434
527
  return
435
528
  }
436
529
  const tmp = `${p}.${process.pid}.${Date.now()}.tmp`
@@ -450,7 +543,7 @@ function atomicWriteFile(p, content, opts = {}) {
450
543
  function lockedAppendFile(p, text, opts = {}) {
451
544
  const blocked = refuseSymlinkWrite(p, opts)
452
545
  if (blocked) {
453
- if (opts.soft) throw Object.assign(new Error(blocked), { code: 'ESYMLINK' })
546
+ if (opts.soft) throw Object.assign(new Error(blocked), { code: blocked.includes('symlink') ? 'ESYMLINK' : 'EIRREGULAR' })
454
547
  return
455
548
  }
456
549
  try {
@@ -1083,7 +1176,23 @@ function cmdResume(args) {
1083
1176
  const prev = readRegistry().find(r => r.workspace === cwd)
1084
1177
  const kept = readRegistry().filter(r => r.workspace !== cwd).map(r => `${r.workspace} ${r.slug}`)
1085
1178
  kept.push(`${cwd} ${slug}`)
1086
- withFileLock(REGISTRY, () => { atomicWriteFile(REGISTRY, kept.join('\n') + '\n') })
1179
+ let bindErr = null
1180
+ // soft: an unwritable registry must not process.exit() from inside the lock -
1181
+ // that skipped the finally and left a stale .registry.lock behind.
1182
+ try {
1183
+ withFileLock(REGISTRY, () => { atomicWriteFile(REGISTRY, kept.join('\n') + '\n', { soft: true }) }, { soft: true })
1184
+ } catch (e) { bindErr = e }
1185
+ if (bindErr || !readRegistry().some(r => r.workspace === cwd && r.slug === slug)) {
1186
+ // Silently unbound is the worst outcome: the memory exists, every later
1187
+ // command says NO ENGAGEMENT, and nothing said why.
1188
+ console.log(`ENGAGEMENT READY: ${fdeDir}`)
1189
+ process.stderr.write(
1190
+ `could not bind this workspace - ${REGISTRY} is not writable${bindErr ? ` (${bindErr.code || bindErr.message})` : ''}.\n` +
1191
+ ` fix the file (it must be a regular file), or work with: export FDEOPS_ENGAGEMENT=${fdeDir}\n`
1192
+ )
1193
+ try { fs.unlinkSync(REGISTRY + '.lock') } catch (_) {}
1194
+ process.exit(1)
1195
+ }
1087
1196
  console.log(`ENGAGEMENT READY: ${fdeDir}\nbound to workspace: ${cwd}`)
1088
1197
  if (prev && prev.slug !== slug) console.log(`rebound: this workspace previously wrote to "${prev.slug}" - that memory is untouched; sessions here now write to "${slug}"`)
1089
1198
  // NDA surface: engagement notes must not silently leave the machine via file sync
@@ -1856,7 +1965,7 @@ function cmdPreserve() {
1856
1965
  ensureMemoryGit(eng)
1857
1966
  const contextPath = path.join(eng, 'context.md')
1858
1967
  const blocked = refuseSymlinkWrite(contextPath, { soft: true })
1859
- if (blocked) throw Object.assign(new Error(blocked), { code: 'ESYMLINK' })
1968
+ if (blocked) throw Object.assign(new Error(blocked), { code: blocked.includes('symlink') ? 'ESYMLINK' : 'EIRREGULAR' })
1860
1969
  withFileLock(contextPath, () => {
1861
1970
  const context = readEng(eng, 'context.md')
1862
1971
  const preservedToday = context.split('\n')
@@ -1956,15 +2065,28 @@ function findDuplicateOpenRisks(eng) {
1956
2065
  function collectDoctorIssues(eng) {
1957
2066
  const issues = []
1958
2067
  const s = computeSignals(eng)
1959
- const datedBlob = [
2068
+ // stripTemplateNoise: a dated example inside a template comment is not work.
2069
+ const datedBlob = stripTemplateNoise([
1960
2070
  readEng(eng, 'decisions.md'), readEng(eng, 'delivery.md'),
1961
2071
  readEng(eng, 'risks.md'), readEng(eng, 'stakeholders.md'),
1962
- ].join('\n')
2072
+ ].join('\n'))
1963
2073
  const hasDatedWork = /\[\d{4}-\d{2}-\d{2}\]/.test(datedBlob)
1964
2074
  // Day-1 empty templates are not hygiene failures - nagging there trains people to ignore doctor.
1965
2075
  const fresh = !hasDatedWork && (s.phase === '?' || s.phase === 'unset') && !s.openRisks
1966
2076
 
1967
2077
  if (s.memoryWarn) issues.push(s.memoryWarn)
2078
+ // A memory file that is not a regular file (a stray directory, a socket)
2079
+ // reads as empty and rejects every append - doctor used to call that healthy.
2080
+ for (const f of Object.values(LOG_FILES).concat('context.md')) {
2081
+ const abs = path.join(eng, f)
2082
+ let st
2083
+ try { st = fs.lstatSync(abs) } catch (_) { continue }
2084
+ if (st.isSymbolicLink()) {
2085
+ issues.push(`${f} is a symlink - writes are refused; replace it with a real file inside .fde/`)
2086
+ } else if (!st.isFile()) {
2087
+ issues.push(`${f} is not a regular file - reads come back empty and every write fails; remove it and re-run any fde write`)
2088
+ }
2089
+ }
1968
2090
  if (fresh) return issues
1969
2091
 
1970
2092
  if (s.phase === '?' || s.phase === 'unset') {
@@ -2130,6 +2252,15 @@ function stripTemplateNoise(md) {
2130
2252
  .replace(/\*\([^)]*\)\*/g, '')
2131
2253
  }
2132
2254
 
2255
+ // Drop "**Label:** allowed · values" guidance lines. A receipt is a dated line or a
2256
+ // table row - never a bold label - so template prose that *documents* a receipt must
2257
+ // not satisfy the gate requiring one. hasOperatingMapContent already skips these.
2258
+ function stripLegendLines(md) {
2259
+ return String(md || '').split('\n')
2260
+ .filter(l => !/^\s*\*\*[^*]+:\*\*/.test(l))
2261
+ .join('\n')
2262
+ }
2263
+
2133
2264
  const VALUE_BUCKET_RE = /(cost[- ]?save|risk[- ]?mitigat|revenue[- ]?uplift)/i
2134
2265
 
2135
2266
  function hasValueBucket(eng) {
@@ -2138,7 +2269,7 @@ function hasValueBucket(eng) {
2138
2269
  if (bucketLine && VALUE_BUCKET_RE.test(bucketLine[1].trim())) return true
2139
2270
  if (!/\*\*Primary value bucket:\*\*/i.test(success) && VALUE_BUCKET_RE.test(success)) return true
2140
2271
 
2141
- const ledger = stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger') || '')
2272
+ const ledger = stripLegendLines(stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger') || ''))
2142
2273
  const table = parseMdTable(ledger)
2143
2274
  if (table) {
2144
2275
  const bIdx = colIndex(table.headers, /bucket/i)
@@ -2203,7 +2334,7 @@ function hasEvalReceipt(eng) {
2203
2334
  if (/\bLast run:\s*\d{4}-\d{2}-\d{2}/i.test(e)) return true
2204
2335
  if (/\|\s*G\d+\s*\|[^|\n]+\|[^|\n]+\|[^|\n]+\|[^|\n]+\|\s*pass\s*\|/i.test(e)) return true
2205
2336
  }
2206
- const del = stripTemplateNoise(readClean(eng, 'delivery.md'))
2337
+ const del = stripLegendLines(stripTemplateNoise(readClean(eng, 'delivery.md')))
2207
2338
  if (/#{1,6}\s+Eval\b/i.test(del) && /\b(pass|SHIP|\d+\/\d+)\b/i.test(sectionBody(del, 'Eval') || del)) return true
2208
2339
  if (/\beval (pack|receipt)[:\s].*\b(pass|SHIP)\b/i.test(del)) return true
2209
2340
  const receipts = sectionBody(del, 'Ship receipts') || ''
@@ -2671,6 +2802,190 @@ function cmdDashboard(args) {
2671
2802
  }
2672
2803
  }
2673
2804
 
2805
+ // ---------- vault (a window onto the fieldbook, not a second copy of it) ----------
2806
+ // Obsidian skips any path starting with "." - so ~/fde-engagements as a vault shows
2807
+ // nothing, because every client's record lives inside .fde/. The answer is a derived
2808
+ // vault: generated from .fde/, rebuilt from scratch each run, gitignored, never read
2809
+ // back. That is also where redaction belongs (`--redacted` for a shared screen).
2810
+
2811
+ // Stamped into the vault so a stale folder is identifiable. Best-effort: a
2812
+ // missing package.json must not stop an FDE generating their vault.
2813
+ function cliVersion() {
2814
+ try {
2815
+ return String(JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version || '')
2816
+ } catch (_) { return '' }
2817
+ }
2818
+
2819
+ function valueLedgerRows(eng) {
2820
+ const ledger = stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger') || '')
2821
+ const table = parseMdTable(ledger)
2822
+ if (!table) return []
2823
+ const sIdx = colIndex(table.headers, /slice/i)
2824
+ const pIdx = colIndex(table.headers, /promis/i)
2825
+ const aIdx = colIndex(table.headers, /accept/i)
2826
+ const rows = []
2827
+ for (const row of table.rows) {
2828
+ const slice = sIdx === -1 ? '' : String(row[sIdx] || '').trim()
2829
+ const promised = pIdx === -1 ? '' : String(row[pIdx] || '').trim()
2830
+ if (!slice && !promised) continue
2831
+ const acceptedRaw = aIdx === -1 ? '' : String(row[aIdx] || '').trim()
2832
+ rows.push({
2833
+ slice, promised,
2834
+ acceptedBy: !acceptedRaw || PENDING_CELL_RE.test(acceptedRaw) ? '' : acceptedRaw,
2835
+ })
2836
+ }
2837
+ return rows
2838
+ }
2839
+
2840
+ function isInside(child, parent) {
2841
+ const rel = path.relative(parent, child)
2842
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
2843
+ }
2844
+
2845
+ // Containment must be judged on the real path: a symlinked parent
2846
+ // (ln -s ~/fde-engagements /tmp/l; --out /tmp/l/vault) resolves textually to
2847
+ // somewhere harmless while writing inside the engagements root. The vault target
2848
+ // usually does not exist yet, so resolve the deepest ancestor that does.
2849
+ function realPathish(p) {
2850
+ let cur = p
2851
+ const tail = []
2852
+ for (let i = 0; i < 64; i++) {
2853
+ try {
2854
+ return path.join(fs.realpathSync(cur), ...tail)
2855
+ } catch (e) {
2856
+ if (e.code !== 'ENOENT' && e.code !== 'ENOTDIR') return p
2857
+ const parent = path.dirname(cur)
2858
+ if (parent === cur) return p
2859
+ tail.unshift(path.basename(cur))
2860
+ cur = parent
2861
+ }
2862
+ }
2863
+ return p
2864
+ }
2865
+
2866
+ // `fde vault` deletes its output directory before rebuilding, so the only
2867
+ // acceptable targets are a fresh path or a folder this command wrote before
2868
+ // (proved by its stamp file). Never the engagements root, never $HOME.
2869
+ function resolveVaultOut(args, redacted) {
2870
+ const outIdx = args.indexOf('--out')
2871
+ const raw = outIdx !== -1 ? String(args[outIdx + 1] || '').trim() : ''
2872
+ if (outIdx !== -1 && !raw) {
2873
+ console.error('--out needs a directory path')
2874
+ process.exit(1)
2875
+ }
2876
+ const out = raw
2877
+ ? path.resolve(raw.replace(/^~(?=$|\/)/, HOME))
2878
+ : path.join(HOME, redacted ? 'fde-vault-redacted' : 'fde-vault')
2879
+
2880
+ const refuse = (why) => {
2881
+ console.error(`refused: will not build the vault at ${out} - ${why}`)
2882
+ process.exit(1)
2883
+ }
2884
+ // The symlink check reads the path as given; every containment check reads it
2885
+ // resolved, so a link cannot smuggle the target past them.
2886
+ try {
2887
+ if (fs.lstatSync(out).isSymbolicLink()) {
2888
+ refuse('it is a symlink - a rebuild would delete whatever it points at')
2889
+ }
2890
+ } catch (e) {
2891
+ if (e.code !== 'ENOENT') failFs(e, 'check', out)
2892
+ }
2893
+ const canon = realPathish(out)
2894
+ const engRoot = realPathish(ENGAGEMENTS_ROOT)
2895
+ const realHome = realPathish(HOME)
2896
+ for (const p of new Set([out, canon])) {
2897
+ if (p === path.parse(p).root) refuse('that is the filesystem root')
2898
+ if (p === HOME || p === realHome) refuse('the vault directory is deleted and rebuilt on every run')
2899
+ if (p.split(path.sep).includes('.fde')) refuse('that is inside a fieldbook; .fde/ is the source of truth')
2900
+ for (const root of new Set([ENGAGEMENTS_ROOT, engRoot])) {
2901
+ if (isInside(p, root) || isInside(root, p)) refuse('it would contain or sit inside your engagements root')
2902
+ }
2903
+ }
2904
+ try {
2905
+ const st = fs.lstatSync(out)
2906
+ if (!st.isDirectory()) refuse('it exists and is not a directory')
2907
+ const entries = fs.readdirSync(out)
2908
+ if (entries.length && !entries.includes(vault.STAMP)) {
2909
+ refuse(`it already holds files fde vault did not write (no ${vault.STAMP}). Pick an empty path with --out`)
2910
+ }
2911
+ } catch (e) {
2912
+ if (e.code !== 'ENOENT') failFs(e, 'check', out)
2913
+ }
2914
+ return out
2915
+ }
2916
+
2917
+ function cmdVault(args) {
2918
+ const redacted = args.includes('--redacted')
2919
+ const all = !args.includes('--current')
2920
+ const out = resolveVaultOut(args, redacted)
2921
+
2922
+ let engagements
2923
+ if (all) {
2924
+ engagements = gatherEngagements()
2925
+ } else {
2926
+ const eng = resolveEngagement()
2927
+ if (!eng) {
2928
+ console.error('no engagement bound to this workspace.\nrun: fde resume --init <name> or fde vault')
2929
+ process.exit(2)
2930
+ }
2931
+ engagements = gatherEngagements({ only: eng })
2932
+ }
2933
+
2934
+ const SECTION_FILES = ['decisions', 'risks', 'delivery', 'stakeholders', 'terrain', 'success', 'trust-profile']
2935
+ const ALWAYS = new Set(['decisions', 'risks', 'delivery'])
2936
+ engagements.forEach(e => {
2937
+ const ctx = readClean(e.dir, 'context.md')
2938
+ e.next = (sectionBody(ctx, 'Next action', { lastNonEmpty: true }).split('\n').find(l => l.trim()) || '').trim()
2939
+ e.brief = firstLine(readClean(e.dir, 'brief.md'), 400)
2940
+ e.reality = firstLine(readClean(e.dir, 'reality.md'), 400)
2941
+ e.overlay = detectOverlay(e.dir)
2942
+ e.days = daysElapsed(e.dir)
2943
+ e.stakeholders = extractStakeholders(e.dir)
2944
+ e.log = extractLog(e.dir)
2945
+ e.valueRows = valueLedgerRows(e.dir)
2946
+ e.pages = {}
2947
+ for (const f of SECTION_FILES) {
2948
+ if (!fs.existsSync(path.join(e.dir, `${f}.md`))) continue
2949
+ // stripTemplateNoise: the instruction comments are for whoever writes the
2950
+ // fieldbook, not for whoever reads it in Obsidian.
2951
+ const body = stripTemplateNoise(readClean(e.dir, `${f}.md`))
2952
+ if (!ALWAYS.has(f) && !render.hasRealContent(body)) continue
2953
+ e.pages[f] = body
2954
+ }
2955
+ })
2956
+
2957
+ const files = vault.buildVaultFiles({
2958
+ engagements,
2959
+ today: render.formatToday(new Date()),
2960
+ redacted,
2961
+ engagementsRoot: ENGAGEMENTS_ROOT,
2962
+ version: cliVersion(),
2963
+ })
2964
+
2965
+ // Fresh every run: a client dropped from the portfolio, or a page that stopped
2966
+ // having content, must not linger as a stale note.
2967
+ rmTreeQuiet(out)
2968
+ try {
2969
+ fs.mkdirSync(out, { recursive: true })
2970
+ } catch (e) {
2971
+ failFs(e, 'create vault', out)
2972
+ }
2973
+ for (const f of files) {
2974
+ const target = path.join(out, f.rel)
2975
+ try {
2976
+ fs.mkdirSync(path.dirname(target), { recursive: true })
2977
+ } catch (e) {
2978
+ failFs(e, 'create vault folder', target)
2979
+ }
2980
+ atomicWriteFile(target, f.content)
2981
+ }
2982
+
2983
+ console.log(`vault → ${out}${redacted ? ' (redacted)' : ''}`)
2984
+ console.log(`${engagements.length} engagement(s) · ${files.length} pages · derived, gitignored, rebuilt on every run`)
2985
+ console.log('open it: Obsidian → Open folder as vault → this folder, then start at Portfolio')
2986
+ if (!redacted) console.log('sharing a screen with the sponsor? fde vault --redacted')
2987
+ }
2988
+
2674
2989
  // ---------- demo (see the value before touching a real client) ----------
2675
2990
  // Everything below runs the real commands against a throwaway engagement under
2676
2991
  // ~/fde-engagements/.demo/ - the leading dot keeps it out of every portfolio
@@ -2842,6 +3157,7 @@ function printUsage() {
2842
3157
  fde preserve pre-compaction context snapshot (hook-internal; hooks use this)
2843
3158
  fde status [--all] current engagement status (pass --all for full portfolio)
2844
3159
  fde dashboard [--all] current engagement fieldbook (pass --all for every client)
3160
+ fde vault derived Obsidian vault of every engagement (--current for one, --redacted for a shared screen, --out <dir>)
2845
3161
  env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)
2846
3162
  writes require a workspace bind (or FDEOPS_ENGAGEMENT) - folder-name match is read-only
2847
3163
  .fde/ is git-versioned locally for tamper-evident receipts (no remote, no telemetry)
@@ -2867,6 +3183,7 @@ switch (cmd) {
2867
3183
  case 'preserve': cmdPreserve(); break
2868
3184
  case 'status': cmdStatus(args); break
2869
3185
  case 'dashboard': cmdDashboard(args); break
3186
+ case 'vault': cmdVault(args); break
2870
3187
  case 'help':
2871
3188
  case '-h':
2872
3189
  case '--help':
package/bin/install.js CHANGED
@@ -355,7 +355,7 @@ function cmdInstall(opts = {}) {
355
355
  // through to the CLI (fde.js reads process.argv itself, so require() is enough).
356
356
  const FDE_SUBCOMMANDS = [
357
357
  'demo', 'scan', 'resume', 'triage', 'log', 'debrief', 'ingest', 'prep', 'doctor', 'redact',
358
- 'garden', 'owner', 'receipts', 'capture', 'preserve', 'status', 'dashboard', 'help',
358
+ 'garden', 'owner', 'receipts', 'capture', 'preserve', 'status', 'dashboard', 'vault', 'help',
359
359
  ]
360
360
 
361
361
  const INSTALL_SUBCOMMANDS = ['init', 'adapters', 'install']
package/bin/lib/trust.js CHANGED
@@ -15,7 +15,13 @@ function createTrustApi(deps) {
15
15
  function stakeholdersMemoryHealth(eng) {
16
16
  // Hostile handoff: binary / unparseable stakeholders must not read as healthy green.
17
17
  let buf
18
- try { buf = fs.readFileSync(path.join(eng, 'stakeholders.md')) } catch (_) {
18
+ const abs = path.join(eng, 'stakeholders.md')
19
+ // Regular files only - opening a fifo here blocked status/triage/doctor
20
+ // forever. doctor reports the shape separately.
21
+ try { if (!fs.lstatSync(abs).isFile()) return { ok: true, warn: '' } } catch (_) {
22
+ return { ok: true, warn: '' }
23
+ }
24
+ try { buf = fs.readFileSync(abs) } catch (_) {
19
25
  return { ok: true, warn: '' }
20
26
  }
21
27
  if (buf.includes(0)) {
@@ -70,8 +76,10 @@ function createTrustApi(deps) {
70
76
  const t = raw.trim()
71
77
  if (!t || t.startsWith('<!--') || /^#{1,6}\s/.test(t)) continue
72
78
  if (/risk\s*\|\s*status|mitigation/i.test(t) || /^\|?[\s|:-]+$/.test(t)) continue
73
- // Bullet risk with substance (skip empty "- " stubs).
74
- if (/^[-*]/.test(t)) {
79
+ // Bullet risk with substance (skip empty "- " stubs). The bullet marker
80
+ // must be followed by space: "**Status:** open · closed" is a legend, and
81
+ // counting it as a risk reported one open risk on an empty register.
82
+ if (/^[-*]\s/.test(t)) {
75
83
  if (t.replace(/^[-*]\s+/, '').trim()) n++
76
84
  continue
77
85
  }