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/README.md +84 -80
- package/adapters/README.md +1 -1
- package/bin/check.js +9 -6
- package/bin/fde.js +341 -24
- package/bin/install.js +1 -1
- package/bin/lib/trust.js +11 -3
- package/bin/lib/vault.js +323 -0
- package/hooks/pre-compact +24 -1
- package/hooks/session-start +24 -1
- package/hooks/session-stop +24 -1
- package/mcp/fdeops-ingest/package.json +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/fde/SKILL.md +1 -0
- package/templates/.fde/assumptions.md +2 -2
- package/templates/.fde/decisions.md +2 -10
- package/templates/.fde/delivery.md +6 -2
- package/templates/.fde/evals.md +0 -4
- package/templates/.fde/risks.md +3 -1
- package/templates/.fde/stakeholders.md +2 -1
- package/templates/.fde/terrain.md +0 -2
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
380
|
-
//
|
|
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
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|