fdeops 3.5.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/AGENTS.md +18 -0
- package/CLAUDE.md.template +25 -0
- package/LICENSE +21 -0
- package/README.md +328 -0
- package/adapters/AGENTS.md +24 -0
- package/adapters/GEMINI.md +24 -0
- package/adapters/README.md +31 -0
- package/adapters/copilot-instructions.md +24 -0
- package/adapters/cursor.fde.mdc +29 -0
- package/bin/check.js +260 -0
- package/bin/fde.js +690 -0
- package/bin/install.js +220 -0
- package/hooks/hooks.json +40 -0
- package/hooks/pre-compact +66 -0
- package/hooks/run-hook.cmd +3 -0
- package/hooks/session-start +91 -0
- package/hooks/session-stop +86 -0
- package/package.json +52 -0
- package/skills/fde/SKILL.md +219 -0
- package/skills/fde/references/ai.md +91 -0
- package/skills/fde/references/artifacts.md +247 -0
- package/skills/fde/references/assumption-audit.md +77 -0
- package/skills/fde/references/audit.md +61 -0
- package/skills/fde/references/blast-radius.md +91 -0
- package/skills/fde/references/build.md +98 -0
- package/skills/fde/references/business-case.md +78 -0
- package/skills/fde/references/close.md +43 -0
- package/skills/fde/references/dashboard.md +40 -0
- package/skills/fde/references/debrief.md +36 -0
- package/skills/fde/references/debug.md +55 -0
- package/skills/fde/references/demo-prep.md +31 -0
- package/skills/fde/references/discover.md +163 -0
- package/skills/fde/references/exec-narrative.md +108 -0
- package/skills/fde/references/fintech.md +48 -0
- package/skills/fde/references/gov.md +47 -0
- package/skills/fde/references/handoff-engineering.md +139 -0
- package/skills/fde/references/healthcare.md +45 -0
- package/skills/fde/references/incremental-build.md +91 -0
- package/skills/fde/references/initiative-triage.md +78 -0
- package/skills/fde/references/land.md +75 -0
- package/skills/fde/references/multi-customer-ops.md +114 -0
- package/skills/fde/references/observability.md +103 -0
- package/skills/fde/references/options-analysis.md +81 -0
- package/skills/fde/references/pattern-extract.md +93 -0
- package/skills/fde/references/plan.md +108 -0
- package/skills/fde/references/qa-live.md +113 -0
- package/skills/fde/references/rescue.md +81 -0
- package/skills/fde/references/review.md +53 -0
- package/skills/fde/references/rollback-drill.md +102 -0
- package/skills/fde/references/scope-defense.md +71 -0
- package/skills/fde/references/security-audit.md +105 -0
- package/skills/fde/references/ship.md +121 -0
- package/skills/fde/references/sketch.md +40 -0
- package/skills/fde/references/stakeholder-radar.md +68 -0
- package/skills/fde/references/status.md +30 -0
- package/skills/fde/references/test-on-legacy.md +108 -0
- package/skills/fde/references/trust-engineering.md +100 -0
- package/skills/fde/references/use-case-scoring.md +70 -0
- package/templates/.fde/README.md +13 -0
- package/templates/.fde/brief.md +8 -0
- package/templates/.fde/context.md +14 -0
- package/templates/.fde/decisions.md +18 -0
- package/templates/.fde/delivery.md +7 -0
- package/templates/.fde/reality.md +7 -0
- package/templates/.fde/retrospectives/.gitkeep +0 -0
- package/templates/.fde/risks.md +5 -0
- package/templates/.fde/stakeholders.md +10 -0
- package/templates/.fde/success.md +7 -0
- package/templates/.fde/terrain.md +7 -0
- package/templates/.fde/trust-profile.md +11 -0
package/bin/fde.js
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* fde - the deterministic core of fdeops.
|
|
4
|
+
* Real tool, no AI required: recon, memory ops, portfolio status.
|
|
5
|
+
* The @fde skill calls these for mechanics and adds judgment on top.
|
|
6
|
+
*
|
|
7
|
+
* Security note: every execSync below runs a FIXED constant command
|
|
8
|
+
* (git introspection only). User input never reaches a shell - file
|
|
9
|
+
* writes go through fs, and search terms through an escaped RegExp.
|
|
10
|
+
*
|
|
11
|
+
* fde scan day-1 recon of the cwd repo (facts, no opinions)
|
|
12
|
+
* fde resume find this workspace's engagement, print bounded context
|
|
13
|
+
* fde resume --full same, but the complete context.md (no bound)
|
|
14
|
+
* fde resume --init <n> create + bind an engagement for this workspace
|
|
15
|
+
* fde log <type> <text> structured append (decision|risk|delivery|contact)
|
|
16
|
+
* fde receipts <term> "what did we agree?" - search memory with dates
|
|
17
|
+
* fde capture session-end snapshot → context.md (hooks use this)
|
|
18
|
+
* fde status portfolio across ~/fde-engagements (red/amber/green)
|
|
19
|
+
* fde dashboard render every engagement into one local fieldbook.html
|
|
20
|
+
*/
|
|
21
|
+
const fs = require('fs')
|
|
22
|
+
const path = require('path')
|
|
23
|
+
const os = require('os')
|
|
24
|
+
const { execSync, execFileSync } = require('child_process')
|
|
25
|
+
|
|
26
|
+
const HOME = os.homedir()
|
|
27
|
+
const ENGAGEMENTS_ROOT = path.join(HOME, 'fde-engagements')
|
|
28
|
+
const REGISTRY = path.join(ENGAGEMENTS_ROOT, '.registry')
|
|
29
|
+
const CODE_EXT = ['.js', '.ts', '.tsx', '.jsx', '.py', '.java', '.go', '.rb', '.cs', '.php']
|
|
30
|
+
const CONF_EXT = CODE_EXT.concat(['.env', '.yaml', '.yml', '.json'])
|
|
31
|
+
|
|
32
|
+
// constant-command runner - never receives user input
|
|
33
|
+
function sh(cmd, cwd) {
|
|
34
|
+
try {
|
|
35
|
+
return execSync(cmd, { cwd: cwd || process.cwd(), stdio: ['ignore', 'pipe', 'ignore'], timeout: 15000 }).toString().trim()
|
|
36
|
+
} catch (_) { return '' }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function slugify(name) {
|
|
40
|
+
return String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'engagement'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Walk the repo collecting candidate files. Hard caps keep scan fast on monorepos.
|
|
44
|
+
function walk(dir, exts, cap) {
|
|
45
|
+
const out = []
|
|
46
|
+
const skip = new Set(['node_modules', '.git', 'dist', 'build', 'vendor', '.next', 'target', '__pycache__'])
|
|
47
|
+
;(function rec(d) {
|
|
48
|
+
if (out.length >= cap) return
|
|
49
|
+
let entries
|
|
50
|
+
try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch (_) { return }
|
|
51
|
+
for (const e of entries) {
|
|
52
|
+
if (out.length >= cap) return
|
|
53
|
+
if (e.name.startsWith('.') && e.name !== '.env') continue
|
|
54
|
+
const p = path.join(d, e.name)
|
|
55
|
+
if (e.isDirectory()) { if (!skip.has(e.name)) rec(p) }
|
|
56
|
+
else if (exts.includes(path.extname(e.name)) || e.name === '.env') {
|
|
57
|
+
try { if (fs.statSync(p).size <= 1024 * 1024) out.push(p) } catch (_) {}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
})(dir)
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function grepFiles(files, regex, cap) {
|
|
65
|
+
const hits = []
|
|
66
|
+
for (const f of files) {
|
|
67
|
+
if (hits.length >= cap) break
|
|
68
|
+
let text
|
|
69
|
+
try { text = fs.readFileSync(f, 'utf8') } catch (_) { continue }
|
|
70
|
+
const lines = text.split('\n')
|
|
71
|
+
for (let i = 0; i < lines.length && hits.length < cap; i++) {
|
|
72
|
+
if (regex.test(lines[i])) hits.push({ file: path.relative(process.cwd(), f), line: i + 1, text: lines[i].trim().slice(0, 120) })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return hits
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------- engagement resolution (zero-ceremony order) ----------
|
|
79
|
+
|
|
80
|
+
function readRegistry() {
|
|
81
|
+
try {
|
|
82
|
+
return fs.readFileSync(REGISTRY, 'utf8').split('\n').filter(Boolean).map(l => {
|
|
83
|
+
const i = l.lastIndexOf(' ')
|
|
84
|
+
return { workspace: l.slice(0, i), slug: l.slice(i + 1) }
|
|
85
|
+
})
|
|
86
|
+
} catch (_) { return [] }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveEngagement() {
|
|
90
|
+
// 1) explicit env (back-compat: accept old FDEOS_ENGAGEMENT too)
|
|
91
|
+
const env = (process.env.FDEOPS_ENGAGEMENT || process.env.FDEOS_ENGAGEMENT || '').replace(/^~/, HOME).trim()
|
|
92
|
+
if (env && fs.existsSync(env)) return env
|
|
93
|
+
// 2) workspace registry binding (written by resume --init)
|
|
94
|
+
const cwd = process.cwd()
|
|
95
|
+
const reg = readRegistry().find(r => r.workspace === cwd)
|
|
96
|
+
if (reg) {
|
|
97
|
+
const p = path.join(ENGAGEMENTS_ROOT, reg.slug, '.fde')
|
|
98
|
+
if (fs.existsSync(p)) return p
|
|
99
|
+
}
|
|
100
|
+
// 3) global pointer file (back-compat: try old FDEOS-CLAUDE.md too)
|
|
101
|
+
for (const ptrName of ['FDEOPS-CLAUDE.md', 'FDEOS-CLAUDE.md']) {
|
|
102
|
+
try {
|
|
103
|
+
const ptr = fs.readFileSync(path.join(HOME, '.claude', ptrName), 'utf8')
|
|
104
|
+
const m = ptr.match(/^(?:FDEOPS|FDEOS)_ENGAGEMENT=(.+)$/m)
|
|
105
|
+
if (m) {
|
|
106
|
+
const p = m[1].trim().replace(/^~/, HOME)
|
|
107
|
+
if (fs.existsSync(p)) return p
|
|
108
|
+
}
|
|
109
|
+
} catch (_) {}
|
|
110
|
+
}
|
|
111
|
+
// 4) workspace dir name matches an engagement slug
|
|
112
|
+
const guess = path.join(ENGAGEMENTS_ROOT, slugify(path.basename(cwd)), '.fde')
|
|
113
|
+
if (fs.existsSync(guess)) return guess
|
|
114
|
+
// 5) in-repo .fde (engagement-approved only)
|
|
115
|
+
if (fs.existsSync(path.join(cwd, '.fde'))) return path.join(cwd, '.fde')
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function templatesDir() {
|
|
120
|
+
for (const c of [path.join(__dirname, '..', 'templates', '.fde'), path.join(__dirname, 'templates', '.fde')]) {
|
|
121
|
+
if (fs.existsSync(c)) return c
|
|
122
|
+
}
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------- shared engagement signals (one source of truth) ----------
|
|
127
|
+
|
|
128
|
+
function readEng(eng, f) {
|
|
129
|
+
try { return fs.readFileSync(path.join(eng, f), 'utf8') } catch (_) { return '' }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Pull the body under a "## Heading" up to the next "##" (or EOF).
|
|
133
|
+
function sectionBody(md, heading) {
|
|
134
|
+
const lines = md.split('\n')
|
|
135
|
+
const start = lines.findIndex(l => new RegExp('^#{1,6}\\s+' + heading + '\\b', 'i').test(l.trim()))
|
|
136
|
+
if (start === -1) return ''
|
|
137
|
+
const body = []
|
|
138
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
139
|
+
if (/^#{1,6}\s/.test(lines[i].trim())) break
|
|
140
|
+
body.push(lines[i])
|
|
141
|
+
}
|
|
142
|
+
return body.join('\n').trim()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// phase / trust / top risk / freshness - identical heuristic for status + dashboard.
|
|
146
|
+
function computeSignals(eng) {
|
|
147
|
+
const ctx = readEng(eng, 'context.md'); const stake = readEng(eng, 'stakeholders.md'); const risks = readEng(eng, 'risks.md')
|
|
148
|
+
const phase = (ctx.match(/phase[:* ]+\**([a-z-]+)/i) || [])[1] || '?'
|
|
149
|
+
const sLines = stake.split('\n').filter(l => !(/green/i.test(l) && /red|amber/i.test(l)))
|
|
150
|
+
const trust = sLines.some(l => /\bred\b/i.test(l)) ? 'RED'
|
|
151
|
+
: sLines.some(l => /amber|gone quiet|routing around|escalat/i.test(l)) ? 'amber' : 'green'
|
|
152
|
+
const topRisk = (risks.split('\n').find(l => {
|
|
153
|
+
const t = l.trim()
|
|
154
|
+
return /^[-|]/.test(t) && t.length > 20 && !/^\|?[-\s|]+$/.test(t) &&
|
|
155
|
+
!/risk\s*\|\s*status|mitigation/i.test(t) && !t.startsWith('<!--')
|
|
156
|
+
}) || '').replace(/\|/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80)
|
|
157
|
+
let updated = 'never', ageDays = Infinity
|
|
158
|
+
try {
|
|
159
|
+
ageDays = Math.floor((Date.now() - fs.statSync(path.join(eng, 'context.md')).mtimeMs) / 86400000)
|
|
160
|
+
updated = ageDays === 0 ? 'today' : `${ageDays}d ago`
|
|
161
|
+
} catch (_) {}
|
|
162
|
+
return { phase, trust, topRisk, updated, ageDays }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------- commands ----------
|
|
166
|
+
|
|
167
|
+
function cmdScan() {
|
|
168
|
+
const cwd = process.cwd()
|
|
169
|
+
const isGit = !!sh('git rev-parse --git-dir')
|
|
170
|
+
const out = []
|
|
171
|
+
out.push('FDE RECON - ' + path.basename(cwd))
|
|
172
|
+
out.push('local only · git + file reads · no AI, no network - nothing leaves this machine')
|
|
173
|
+
out.push('='.repeat(60))
|
|
174
|
+
|
|
175
|
+
// stack + age
|
|
176
|
+
const files = walk(cwd, CONF_EXT.concat(['.md']), 5000)
|
|
177
|
+
const extCount = {}
|
|
178
|
+
for (const f of files) { const e = path.extname(f); extCount[e] = (extCount[e] || 0) + 1 }
|
|
179
|
+
const langs = Object.entries(extCount).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([e, n]) => `${e}:${n}`).join(' ')
|
|
180
|
+
const birth = isGit ? sh('git log --reverse --format=%ad --date=short').split('\n')[0] : 'n/a'
|
|
181
|
+
const last = isGit ? sh('git log -1 --format=%ad --date=short') : 'n/a'
|
|
182
|
+
out.push(`STACK ${langs || 'no code files found'} first commit: ${birth} last: ${last}`)
|
|
183
|
+
|
|
184
|
+
// churn × tests = the load-bearing walls
|
|
185
|
+
out.push('\nHOTSPOTS (churn 90d × test coverage) - handle with care:')
|
|
186
|
+
if (isGit) {
|
|
187
|
+
const churn = sh("git log --since='90 days ago' --name-only --pretty=format:") || ''
|
|
188
|
+
const counts = {}
|
|
189
|
+
churn.split('\n').filter(Boolean).forEach(f => { counts[f] = (counts[f] || 0) + 1 })
|
|
190
|
+
const top = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 8)
|
|
191
|
+
const testFiles = files.filter(f => /test|spec/i.test(f))
|
|
192
|
+
if (top.length === 0) out.push(' (no commits in the last 90 days)')
|
|
193
|
+
for (const [f, n] of top) {
|
|
194
|
+
const base = path.basename(f).replace(/\.[^.]+$/, '')
|
|
195
|
+
const tested = testFiles.some(t => t.includes(base))
|
|
196
|
+
out.push(` ${String(n).padStart(3)} commits/90d ${f} ${tested ? '' : '⚠ NO TEST NEIGHBOR'}`)
|
|
197
|
+
}
|
|
198
|
+
} else out.push(' (not a git repo - churn unavailable)')
|
|
199
|
+
|
|
200
|
+
// temporary archaeology
|
|
201
|
+
out.push('\n"TEMPORARY" ARCHAEOLOGY (permanent code with an excuse):')
|
|
202
|
+
const codeFiles = files.filter(f => CODE_EXT.includes(path.extname(f)))
|
|
203
|
+
const tmp = grepFiles(codeFiles, /HACK|FIXME|XXX|temporar|for now|remove this|remove after|workaround/i, 15)
|
|
204
|
+
tmp.length ? tmp.forEach(h => out.push(` ${h.file}:${h.line} ${h.text}`)) : out.push(' none found')
|
|
205
|
+
|
|
206
|
+
// AI components - they fail silently
|
|
207
|
+
out.push('\nAI COMPONENTS (no exception fires when these drift):')
|
|
208
|
+
const ai = grepFiles(codeFiles, /openai|anthropic|\bllm\b|gpt-|claude|embedding|vector store|inference/i, 10)
|
|
209
|
+
ai.length ? ai.forEach(h => out.push(` ${h.file}:${h.line} ${h.text}`)) : out.push(' none found')
|
|
210
|
+
|
|
211
|
+
// secrets (redacted)
|
|
212
|
+
out.push('\nPOSSIBLE HARDCODED SECRETS (values redacted):')
|
|
213
|
+
const confFiles = files.filter(f => CONF_EXT.includes(path.extname(f)) || path.basename(f) === '.env')
|
|
214
|
+
const sec = grepFiles(confFiles, /(api[_-]?key|secret|password|token)\s*[:=]\s*['"][^'"]{8,}/i, 10)
|
|
215
|
+
.filter(h => !/example|template|test|sample|placeholder/i.test(h.file + h.text))
|
|
216
|
+
sec.length
|
|
217
|
+
? sec.forEach(h => out.push(` ${h.file}:${h.line} ${h.text.replace(/(['"])([^'"]{4})[^'"]+(['"])/, '$1$2…REDACTED$3')}`))
|
|
218
|
+
: out.push(' none found')
|
|
219
|
+
|
|
220
|
+
// previous attempts - the political archaeology
|
|
221
|
+
out.push('\nPREVIOUS ATTEMPTS (ask who ran these, and what happened):')
|
|
222
|
+
const reverts = isGit ? sh("git log --oneline -i --grep=revert --grep=rollback") : ''
|
|
223
|
+
const readmeHits = grepFiles(files.filter(f => /readme/i.test(f)), /revert|rewrite|attempted|abandoned|deprecated/i, 5)
|
|
224
|
+
if (reverts) reverts.split('\n').slice(0, 5).forEach(l => out.push(' git: ' + l))
|
|
225
|
+
readmeHits.forEach(h => out.push(` ${h.file}:${h.line} ${h.text}`))
|
|
226
|
+
if (!reverts && readmeHits.length === 0) out.push(' none visible')
|
|
227
|
+
|
|
228
|
+
// test landscape
|
|
229
|
+
const testCount = files.filter(f => /test|spec/i.test(f)).length
|
|
230
|
+
out.push(`\nTEST LANDSCAPE ${testCount} test file(s) across ${codeFiles.length} code files`)
|
|
231
|
+
|
|
232
|
+
out.push('\n' + '-'.repeat(60))
|
|
233
|
+
out.push("Facts only - interpretation is the FDE's (or @fde's) job.")
|
|
234
|
+
console.log(out.join('\n'))
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function cmdResume(args) {
|
|
238
|
+
const initIdx = args.indexOf('--init')
|
|
239
|
+
if (initIdx !== -1) {
|
|
240
|
+
const name = args[initIdx + 1]
|
|
241
|
+
if (!name) { console.error('usage: fde resume --init <engagement-name>'); process.exit(1) }
|
|
242
|
+
const tpl = templatesDir()
|
|
243
|
+
if (!tpl) { console.error('templates not found - run from the fdeops clone or reinstall'); process.exit(1) }
|
|
244
|
+
const slug = slugify(name)
|
|
245
|
+
const fdeDir = path.join(ENGAGEMENTS_ROOT, slug, '.fde')
|
|
246
|
+
fs.mkdirSync(fdeDir, { recursive: true })
|
|
247
|
+
for (const f of fs.readdirSync(tpl)) {
|
|
248
|
+
const src = path.join(tpl, f); const dst = path.join(fdeDir, f)
|
|
249
|
+
if (fs.statSync(src).isDirectory()) fs.mkdirSync(dst, { recursive: true })
|
|
250
|
+
else if (!fs.existsSync(dst)) fs.copyFileSync(src, dst)
|
|
251
|
+
}
|
|
252
|
+
fs.mkdirSync(path.join(fdeDir, 'retrospectives'), { recursive: true })
|
|
253
|
+
// bind THIS workspace to the engagement (zero ceremony next time)
|
|
254
|
+
const line = `${process.cwd()} ${slug}\n`
|
|
255
|
+
const reg = fs.existsSync(REGISTRY) ? fs.readFileSync(REGISTRY, 'utf8') : ''
|
|
256
|
+
if (!reg.includes(line.trim())) fs.appendFileSync(REGISTRY, line)
|
|
257
|
+
console.log(`ENGAGEMENT READY: ${fdeDir}\nbound to workspace: ${process.cwd()}`)
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
const eng = resolveEngagement()
|
|
261
|
+
if (!eng) {
|
|
262
|
+
const list = fs.existsSync(ENGAGEMENTS_ROOT)
|
|
263
|
+
? fs.readdirSync(ENGAGEMENTS_ROOT).filter(d => !d.startsWith('.')).join(', ') || '(none yet)'
|
|
264
|
+
: '(none yet)'
|
|
265
|
+
console.log(`NO ENGAGEMENT for this workspace.\nexisting: ${list}\ncreate + bind one: fde resume --init <client-name>`)
|
|
266
|
+
process.exit(2)
|
|
267
|
+
}
|
|
268
|
+
console.log(`ENGAGEMENT: ${eng}\n`)
|
|
269
|
+
try {
|
|
270
|
+
const ctx = fs.readFileSync(path.join(eng, 'context.md'), 'utf8')
|
|
271
|
+
console.log(args.includes('--full') ? ctx : resumeView(ctx))
|
|
272
|
+
} catch (_) { console.log('(context.md empty - new engagement)') }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Token discipline: context.md grows every session (the session-stop hook
|
|
276
|
+
// appends a snapshot). Loading the whole file on every resume costs more tokens
|
|
277
|
+
// each day for less marginal signal. This returns a bounded view - the curated
|
|
278
|
+
// head (state / next action) plus the most recent activity - and hides the
|
|
279
|
+
// middle of the log behind `fde resume --full`. Pure code, zero model tokens.
|
|
280
|
+
// The session-start hook mirrors this same bound in bash; keep them in sync.
|
|
281
|
+
function resumeView(md) {
|
|
282
|
+
const lines = md.split('\n')
|
|
283
|
+
// A file ending in "\n" yields a trailing "" here; drop it so the line count
|
|
284
|
+
// matches the bash hook's `wc -l` and the two bounded views stay byte-aligned.
|
|
285
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
|
286
|
+
if (lines.length <= 160) return md
|
|
287
|
+
let headEnd = lines.findIndex(l => l.includes('fdeops auto-capture'))
|
|
288
|
+
if (headEnd === -1) headEnd = 120
|
|
289
|
+
headEnd = Math.min(headEnd, 120)
|
|
290
|
+
const tailStart = Math.max(headEnd, lines.length - 40)
|
|
291
|
+
const hidden = tailStart - headEnd
|
|
292
|
+
if (hidden <= 0) return md
|
|
293
|
+
const head = lines.slice(0, headEnd).join('\n').replace(/\s+$/, '')
|
|
294
|
+
const tail = lines.slice(tailStart).join('\n').trim()
|
|
295
|
+
return `${head}\n\n_(\u2026 ${hidden} lines of earlier session log hidden \u2014 \`fde resume --full\` or open context.md for the full history)_\n\n${tail}`
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function cmdLog(args) {
|
|
299
|
+
const map = { decision: 'decisions.md', risk: 'risks.md', delivery: 'delivery.md', contact: 'stakeholders.md' }
|
|
300
|
+
const type = args[0]; const text = args.slice(1).join(' ')
|
|
301
|
+
if (!map[type] || !text) { console.error('usage: fde log <decision|risk|delivery|contact> <text>'); process.exit(1) }
|
|
302
|
+
const eng = resolveEngagement()
|
|
303
|
+
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
304
|
+
const date = new Date().toISOString().slice(0, 10)
|
|
305
|
+
fs.appendFileSync(path.join(eng, map[type]), `\n- [${date}] ${text}\n`)
|
|
306
|
+
console.log(`logged → ${map[type]}`)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function cmdReceipts(args) {
|
|
310
|
+
const term = args.join(' ')
|
|
311
|
+
if (!term) { console.error('usage: fde receipts <search term>'); process.exit(1) }
|
|
312
|
+
const eng = resolveEngagement()
|
|
313
|
+
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
314
|
+
const rx = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')
|
|
315
|
+
let found = 0
|
|
316
|
+
for (const f of ['decisions.md', 'delivery.md', 'risks.md', 'stakeholders.md', 'context.md', 'success.md', 'brief.md', 'reality.md']) {
|
|
317
|
+
const p = path.join(eng, f)
|
|
318
|
+
if (!fs.existsSync(p)) continue
|
|
319
|
+
fs.readFileSync(p, 'utf8').split('\n').forEach((l, i) => {
|
|
320
|
+
if (rx.test(l)) { console.log(`${f}:${i + 1} ${l.trim().slice(0, 160)}`); found++ }
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
if (!found) console.log(`no record of "${term}" - if it was agreed, it was never logged. That is itself the answer.`)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function cmdCapture() {
|
|
327
|
+
const eng = resolveEngagement()
|
|
328
|
+
if (!eng) process.exit(0) // silent: capture must never break a session
|
|
329
|
+
const branch = sh('git branch --show-current')
|
|
330
|
+
const lastCommit = sh("git log -1 --format='%h %s'").slice(0, 100)
|
|
331
|
+
// porcelain lines are "XY path" - sh() trims, so parse by first whitespace
|
|
332
|
+
const changed = sh('git status --porcelain').split('\n').filter(Boolean).slice(0, 8)
|
|
333
|
+
.map(l => l.trim().split(/\s+/).slice(1).join(' ')).join(' ')
|
|
334
|
+
const updated = fs.readdirSync(eng).filter(f => {
|
|
335
|
+
if (!f.endsWith('.md') || f === 'context.md') return false
|
|
336
|
+
try { return (Date.now() - fs.statSync(path.join(eng, f)).mtimeMs) < 12 * 3600 * 1000 } catch (_) { return false }
|
|
337
|
+
}).join(' ')
|
|
338
|
+
if (!changed && !updated) process.exit(0) // idle session - keep memory clean
|
|
339
|
+
const d = new Date()
|
|
340
|
+
const stamp = `${d.toISOString().slice(0, 10)} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
341
|
+
let block = `\n<!-- fdeops auto-capture -->\n## Session end - ${stamp}\n`
|
|
342
|
+
if (branch) block += `- workspace: \`${branch}\` @ ${lastCommit || 'no commits yet'}\n`
|
|
343
|
+
if (changed) block += `- uncommitted: ${changed}\n`
|
|
344
|
+
if (updated) block += `- engagement files updated: ${updated}\n`
|
|
345
|
+
try { fs.appendFileSync(path.join(eng, 'context.md'), block) } catch (_) {}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function cmdStatus() {
|
|
349
|
+
if (!fs.existsSync(ENGAGEMENTS_ROOT)) { console.log('no engagements yet - fde resume --init <name>'); return }
|
|
350
|
+
const rows = []
|
|
351
|
+
for (const d of fs.readdirSync(ENGAGEMENTS_ROOT)) {
|
|
352
|
+
if (d.startsWith('.')) continue
|
|
353
|
+
const eng = path.join(ENGAGEMENTS_ROOT, d, '.fde')
|
|
354
|
+
if (!fs.existsSync(eng)) continue
|
|
355
|
+
const s = computeSignals(eng)
|
|
356
|
+
rows.push({ name: d, phase: s.phase, trust: s.trust, updated: s.updated, topRisk: s.topRisk.slice(0, 60) })
|
|
357
|
+
}
|
|
358
|
+
if (!rows.length) { console.log('no engagements yet'); return }
|
|
359
|
+
const order = { RED: 0, amber: 1, green: 2 }
|
|
360
|
+
rows.sort((a, b) => order[a.trust] - order[b.trust])
|
|
361
|
+
console.log('FDE PORTFOLIO - trust-first triage (heuristic: red > amber > green)\n')
|
|
362
|
+
for (const r of rows) {
|
|
363
|
+
console.log(` [${r.trust.padEnd(5)}] ${r.name.padEnd(24)} phase:${r.phase.padEnd(10)} updated:${r.updated.padEnd(8)} ${r.topRisk}`)
|
|
364
|
+
}
|
|
365
|
+
console.log('\nred/amber derive from stakeholders.md signal words - verify before acting.')
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---------- dashboard (deterministic markdown → one local HTML) ----------
|
|
369
|
+
|
|
370
|
+
function escapeHtml(s) {
|
|
371
|
+
return String(s)
|
|
372
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
373
|
+
.replace(/"/g, '"').replace(/'/g, ''')
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Never leak <private> notes or template hint comments into the rendered page.
|
|
377
|
+
// Closed pairs are redacted; an unclosed <private> redacts to end-of-text so a
|
|
378
|
+
// forgotten closing tag can never leak the rest of the file.
|
|
379
|
+
function stripPrivate(md) {
|
|
380
|
+
return md
|
|
381
|
+
.replace(/<private>[\s\S]*?<\/private>/gi, '(private - redacted from dashboard)')
|
|
382
|
+
.replace(/<private>[\s\S]*$/i, '(private - redacted from dashboard)')
|
|
383
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function inlineMd(s) {
|
|
387
|
+
return escapeHtml(s)
|
|
388
|
+
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
389
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function renderTable(rows) {
|
|
393
|
+
const cells = r => r.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map(c => c.trim())
|
|
394
|
+
const isSep = r => r.includes('-') && /^\|?[\s:|-]+\|?$/.test(r.trim())
|
|
395
|
+
let html = '<table>'; let headerDone = false
|
|
396
|
+
for (const r of rows) {
|
|
397
|
+
if (isSep(r)) continue
|
|
398
|
+
const cs = cells(r)
|
|
399
|
+
if (!headerDone) {
|
|
400
|
+
html += '<thead><tr>' + cs.map(c => '<th>' + inlineMd(c) + '</th>').join('') + '</tr></thead><tbody>'
|
|
401
|
+
headerDone = true
|
|
402
|
+
} else {
|
|
403
|
+
if (cs.every(c => !c)) continue
|
|
404
|
+
html += '<tr>' + cs.map(c => '<td>' + inlineMd(c) + '</td>').join('') + '</tr>'
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return html + '</tbody></table>'
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function mdToHtml(md) {
|
|
411
|
+
const lines = stripPrivate(md).split('\n')
|
|
412
|
+
const out = []; let i = 0
|
|
413
|
+
while (i < lines.length) {
|
|
414
|
+
const t = lines[i].trim()
|
|
415
|
+
if (!t) { i++; continue }
|
|
416
|
+
if (/^\|.*\|/.test(t)) {
|
|
417
|
+
const tbl = []
|
|
418
|
+
while (i < lines.length && /^\s*\|.*\|/.test(lines[i])) { tbl.push(lines[i]); i++ }
|
|
419
|
+
out.push(renderTable(tbl)); continue
|
|
420
|
+
}
|
|
421
|
+
const h = t.match(/^(#{1,6})\s+(.*)$/)
|
|
422
|
+
if (h) { out.push('<h4>' + inlineMd(h[2]) + '</h4>'); i++; continue }
|
|
423
|
+
if (/^[-*]\s+/.test(t)) {
|
|
424
|
+
const items = []
|
|
425
|
+
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) { items.push('<li>' + inlineMd(lines[i].trim().replace(/^[-*]\s+/, '')) + '</li>'); i++ }
|
|
426
|
+
out.push('<ul>' + items.join('') + '</ul>'); continue
|
|
427
|
+
}
|
|
428
|
+
const para = []
|
|
429
|
+
while (i < lines.length && lines[i].trim() && !/^\s*\|.*\|/.test(lines[i]) && !/^#{1,6}\s/.test(lines[i].trim()) && !/^[-*]\s+/.test(lines[i].trim())) {
|
|
430
|
+
para.push(lines[i].trim()); i++
|
|
431
|
+
}
|
|
432
|
+
out.push('<p>' + inlineMd(para.join(' ')) + '</p>')
|
|
433
|
+
}
|
|
434
|
+
return out.join('\n')
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// True if a file has real content beyond headings, labels with empty values, and blank table rows.
|
|
438
|
+
function hasContent(md) {
|
|
439
|
+
const txt = stripPrivate(md)
|
|
440
|
+
.replace(/^#{1,6}\s.*$/gm, '') // headings
|
|
441
|
+
.replace(/^\s*\|[\s:|-]+\|\s*$/gm, '') // table separators
|
|
442
|
+
.replace(/^\s*\|[\s|]*\|\s*$/gm, '') // empty table rows
|
|
443
|
+
.replace(/\*\*[^*]+:\*\*\s*$/gm, '') // bold labels with no value
|
|
444
|
+
.replace(/[-*]\s*$/gm, '') // empty bullets
|
|
445
|
+
.replace(/\s+/g, ' ')
|
|
446
|
+
.trim()
|
|
447
|
+
return txt.length > 0
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function gatherEngagements() {
|
|
451
|
+
const list = []
|
|
452
|
+
if (!fs.existsSync(ENGAGEMENTS_ROOT)) return list
|
|
453
|
+
for (const d of fs.readdirSync(ENGAGEMENTS_ROOT).sort()) {
|
|
454
|
+
if (d.startsWith('.')) continue
|
|
455
|
+
const eng = path.join(ENGAGEMENTS_ROOT, d, '.fde')
|
|
456
|
+
if (!fs.existsSync(eng)) continue
|
|
457
|
+
list.push({ name: d, dir: eng, signals: computeSignals(eng) })
|
|
458
|
+
}
|
|
459
|
+
return list
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const DASH_SECTIONS = [
|
|
463
|
+
['context.md', 'Context'],
|
|
464
|
+
['brief.md', 'Brief - stated problem'],
|
|
465
|
+
['reality.md', 'Reality - actual problem'],
|
|
466
|
+
['success.md', 'Success & scope'],
|
|
467
|
+
['stakeholders.md', 'Stakeholders'],
|
|
468
|
+
['decisions.md', 'Plan & decisions'],
|
|
469
|
+
['risks.md', 'Risk register'],
|
|
470
|
+
['delivery.md', 'Delivery log'],
|
|
471
|
+
['terrain.md', 'Terrain - codebase map'],
|
|
472
|
+
['trust-profile.md', 'Trust profile'],
|
|
473
|
+
]
|
|
474
|
+
|
|
475
|
+
function dashStyles() {
|
|
476
|
+
return [
|
|
477
|
+
// design system: quiet chrome, high density, daily-use second brain
|
|
478
|
+
':root{--bg:#f9fafb;--card:#fff;--ink:#111827;--2:#374151;--3:#6b7280;--muted:#9ca3af;--line:#f3f4f6;--line2:#e5e7eb;',
|
|
479
|
+
'--green:#10b981;--amber:#f59e0b;--red:#ef4444;--accent:#6366f1;',
|
|
480
|
+
'--shadow-s:0 1px 2px rgba(0,0,0,.04);--shadow:0 1px 3px rgba(0,0,0,.06);--shadow-l:0 4px 12px rgba(0,0,0,.08);',
|
|
481
|
+
'--r:8px}',
|
|
482
|
+
'*{box-sizing:border-box;margin:0}',
|
|
483
|
+
'body{font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;color:var(--ink);background:var(--bg);-webkit-font-smoothing:antialiased}',
|
|
484
|
+
// header - compact, purposeful
|
|
485
|
+
'header{background:#111827;color:#fff;padding:20px 32px}',
|
|
486
|
+
'header .inner{max-width:960px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px}',
|
|
487
|
+
'header .brand{display:flex;align-items:center;gap:10px}',
|
|
488
|
+
'header h1{font-size:16px;font-weight:600;letter-spacing:-.2px}',
|
|
489
|
+
'header .tagline{color:#9ca3af;font-size:12px}',
|
|
490
|
+
'.stats{display:flex;align-items:center;gap:16px}',
|
|
491
|
+
'.stat{font-size:12px;color:#9ca3af;display:flex;align-items:center;gap:5px}',
|
|
492
|
+
'.stat b{color:#e5e7eb;font-weight:600}',
|
|
493
|
+
'.stat .sdot{width:6px;height:6px;border-radius:50%;flex-shrink:0}',
|
|
494
|
+
'.stat .sdot.green{background:#34d399}.stat .sdot.amber{background:#fbbf24}.stat .sdot.red{background:#f87171}',
|
|
495
|
+
// layout
|
|
496
|
+
'.wrap{max-width:960px;margin:0 auto;padding:24px 32px 64px}',
|
|
497
|
+
// search - integrated, not floating
|
|
498
|
+
'.search-wrap{position:relative;margin-bottom:24px}',
|
|
499
|
+
'.search-wrap svg{position:absolute;left:12px;top:50%;transform:translateY(-50%);width:16px;height:16px;stroke:var(--muted);stroke-width:2;fill:none}',
|
|
500
|
+
'.search{width:100%;padding:10px 14px 10px 36px;font-size:13px;border:1px solid var(--line2);border-radius:var(--r);background:var(--card);transition:border-color .15s,box-shadow .15s;outline:none}',
|
|
501
|
+
'.search:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(99,102,241,.1)}',
|
|
502
|
+
'.search::placeholder{color:var(--muted)}',
|
|
503
|
+
// grid
|
|
504
|
+
'.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}',
|
|
505
|
+
// cards - clean, scannable
|
|
506
|
+
'.card{background:var(--card);border:1px solid var(--line2);border-radius:var(--r);padding:16px 18px;cursor:pointer;transition:border-color .15s,box-shadow .15s;position:relative;overflow:hidden}',
|
|
507
|
+
'.card::before{content:"";position:absolute;left:0;top:0;bottom:0;width:3px}',
|
|
508
|
+
'.card:hover{border-color:#d1d5db;box-shadow:var(--shadow-l)}',
|
|
509
|
+
'.card.green::before{background:var(--green)}.card.amber::before{background:var(--amber)}.card.red::before{background:var(--red)}',
|
|
510
|
+
'.card h3{font-size:14px;font-weight:600;margin-bottom:6px}',
|
|
511
|
+
'.row{display:flex;align-items:center;gap:6px;flex-wrap:wrap}',
|
|
512
|
+
'.badge{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;padding:2px 7px;border-radius:4px;background:#f3f4f6;color:#4b5563}',
|
|
513
|
+
'.dot{width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0}',
|
|
514
|
+
'.dot.green{background:var(--green)}.dot.amber{background:var(--amber)}.dot.red{background:var(--red)}',
|
|
515
|
+
'.trust-label{font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.3px}',
|
|
516
|
+
'.t-green{color:var(--green)}.t-amber{color:var(--amber)}.t-red{color:var(--red)}',
|
|
517
|
+
'.meta{color:var(--muted);font-size:11px}',
|
|
518
|
+
'.card .next{margin-top:10px;font-size:12.5px;line-height:1.5;color:var(--2)}.card .next b{color:var(--3);font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:.3px;margin-right:2px}',
|
|
519
|
+
'.card .risk{margin-top:8px;font-size:11.5px;color:#991b1b;padding:5px 8px;background:#fef2f2;border-radius:4px;border-left:2px solid var(--red);line-height:1.4}',
|
|
520
|
+
'.card .risk.amber-risk{color:#92400e;background:#fffbeb;border-left-color:var(--amber)}',
|
|
521
|
+
// section labels
|
|
522
|
+
'h2.section{margin:28px 0 12px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)}',
|
|
523
|
+
// detail accordion - tight, content-forward
|
|
524
|
+
'details.eng{background:var(--card);border:1px solid var(--line2);border-radius:var(--r);margin:8px 0;overflow:hidden}',
|
|
525
|
+
'details.eng>summary{list-style:none;cursor:pointer;padding:14px 18px;display:flex;align-items:center;gap:10px;font-weight:600;font-size:14px;transition:background .1s}',
|
|
526
|
+
'details.eng>summary:hover{background:var(--bg)}',
|
|
527
|
+
'details.eng>summary::-webkit-details-marker{display:none}',
|
|
528
|
+
'details.eng>summary::after{content:"";margin-left:auto;width:16px;height:16px;background:url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'16\' height=\'16\' viewBox=\'0 0 24 24\' fill=\'none\' stroke=\'%239ca3af\' stroke-width=\'2\'%3E%3Cpath d=\'M6 9l6 6 6-6\'/%3E%3C/svg%3E") no-repeat center;transition:transform .15s}',
|
|
529
|
+
'details.eng[open]>summary::after{transform:rotate(180deg)}',
|
|
530
|
+
'details.eng[open]>summary{border-bottom:1px solid var(--line)}',
|
|
531
|
+
'.eng-body{padding:8px 20px 20px}',
|
|
532
|
+
'.sub-sec{padding:14px 0;border-bottom:1px solid var(--line)}.sub-sec:last-child{border-bottom:none}',
|
|
533
|
+
'.sub-sec h4.title{margin:0 0 8px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--muted)}',
|
|
534
|
+
'.sub-sec h4{margin:10px 0 4px;font-size:13.5px;font-weight:600;color:var(--ink)}',
|
|
535
|
+
'.sub-sec p{margin:4px 0;line-height:1.6;color:var(--2);font-size:13px}.sub-sec ul{margin:4px 0;padding-left:20px;color:var(--2);font-size:13px}',
|
|
536
|
+
'.sub-sec li{margin:2px 0}',
|
|
537
|
+
'.empty{color:var(--muted);font-style:italic;font-size:12px}',
|
|
538
|
+
// tables
|
|
539
|
+
'table{border-collapse:collapse;width:100%;margin:8px 0;font-size:12.5px;border:1px solid var(--line2);border-radius:6px;overflow:hidden}',
|
|
540
|
+
'th,td{padding:7px 10px;text-align:left;vertical-align:top;border-bottom:1px solid var(--line)}',
|
|
541
|
+
'th{background:var(--bg);font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;color:var(--3);border-bottom:1px solid var(--line2)}',
|
|
542
|
+
'tr:last-child td{border-bottom:none}',
|
|
543
|
+
'tr:hover td{background:var(--bg)}',
|
|
544
|
+
'code{background:#f3f4f6;padding:1px 5px;border-radius:4px;font-size:12px;color:#4b5563}',
|
|
545
|
+
// footer
|
|
546
|
+
'footer{max-width:960px;margin:0 auto;padding:0 32px 48px;color:var(--muted);font-size:11.5px;line-height:1.5}',
|
|
547
|
+
'footer code{color:var(--3);background:#f3f4f6}',
|
|
548
|
+
'.hide{display:none!important}',
|
|
549
|
+
// responsive
|
|
550
|
+
'@media(max-width:640px){header{padding:16px}.header .inner{flex-direction:column;align-items:flex-start}.wrap{padding:16px 16px 48px}.grid{grid-template-columns:1fr}.search{padding-left:32px}}',
|
|
551
|
+
].join('')
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function dashScript() {
|
|
555
|
+
return [
|
|
556
|
+
"var q=document.getElementById('q');",
|
|
557
|
+
"function norm(s){return (s||'').toLowerCase();}",
|
|
558
|
+
"if(q){q.addEventListener('input',function(){",
|
|
559
|
+
" var term=norm(q.value);",
|
|
560
|
+
" document.querySelectorAll('[data-search]').forEach(function(el){",
|
|
561
|
+
" var hit=!term||norm(el.getAttribute('data-search')).indexOf(term)>-1;",
|
|
562
|
+
" el.classList.toggle('hide',!hit);",
|
|
563
|
+
" });",
|
|
564
|
+
"});}",
|
|
565
|
+
"document.querySelectorAll('.card').forEach(function(c){",
|
|
566
|
+
" c.addEventListener('click',function(){",
|
|
567
|
+
" var d=document.getElementById(c.getAttribute('data-target'));",
|
|
568
|
+
" if(d){d.open=true;d.scrollIntoView({behavior:'smooth',block:'start'});}",
|
|
569
|
+
" });",
|
|
570
|
+
"});",
|
|
571
|
+
].join('\n')
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function cmdDashboard(args) {
|
|
575
|
+
const outIdx = args.indexOf('--out')
|
|
576
|
+
const outPath = outIdx !== -1 && args[outIdx + 1]
|
|
577
|
+
? path.resolve(args[outIdx + 1].replace(/^~/, HOME))
|
|
578
|
+
: path.join(ENGAGEMENTS_ROOT, 'fieldbook.html')
|
|
579
|
+
const engagements = gatherEngagements()
|
|
580
|
+
const counts = { green: 0, amber: 0, RED: 0 }
|
|
581
|
+
engagements.forEach(e => { counts[e.signals.trust]++ })
|
|
582
|
+
const now = new Date()
|
|
583
|
+
const stamp = now.toISOString().slice(0, 16).replace('T', ' ') + ' UTC'
|
|
584
|
+
|
|
585
|
+
const cards = engagements.map(e => {
|
|
586
|
+
const id = 'eng-' + slugify(e.name)
|
|
587
|
+
const ctx = readEng(e.dir, 'context.md')
|
|
588
|
+
const next = (sectionBody(ctx, 'Next action').split('\n').find(l => l.trim()) || '').trim()
|
|
589
|
+
const trustClass = e.signals.trust === 'RED' ? 'red' : e.signals.trust
|
|
590
|
+
const trustLabel = e.signals.trust === 'RED' ? 'RED' : e.signals.trust
|
|
591
|
+
const searchBlob = escapeHtml(stripPrivate(e.name + ' ' + ctx + ' ' + readEng(e.dir, 'risks.md') + ' ' + readEng(e.dir, 'stakeholders.md')).toLowerCase())
|
|
592
|
+
return [
|
|
593
|
+
`<div class="card ${trustClass}" data-target="${id}" data-search="${searchBlob}">`,
|
|
594
|
+
`<h3>${inlineMd(e.name)}</h3>`,
|
|
595
|
+
`<div class="row"><span class="dot ${trustClass}"></span><span class="trust-label t-${trustClass}">${trustLabel}</span>`,
|
|
596
|
+
`<span class="badge">${inlineMd(e.signals.phase)}</span><span class="meta">updated ${e.signals.updated}</span></div>`,
|
|
597
|
+
next ? `<div class="next"><b>Next</b> ${inlineMd(next)}</div>` : '<div class="next meta">next action not set</div>',
|
|
598
|
+
e.signals.topRisk ? `<div class="risk">${inlineMd(e.signals.topRisk)}</div>` : '',
|
|
599
|
+
`</div>`,
|
|
600
|
+
].join('')
|
|
601
|
+
}).join('\n')
|
|
602
|
+
|
|
603
|
+
const details = engagements.map(e => {
|
|
604
|
+
const id = 'eng-' + slugify(e.name)
|
|
605
|
+
const trustClass = e.signals.trust === 'RED' ? 'red' : e.signals.trust
|
|
606
|
+
const subs = DASH_SECTIONS.map(([file, title]) => {
|
|
607
|
+
const md = readEng(e.dir, file)
|
|
608
|
+
const body = hasContent(md) ? mdToHtml(md) : '<p class="empty">- not yet filled -</p>'
|
|
609
|
+
return `<div class="sub-sec"><h4 class="title">${title}</h4>${body}</div>`
|
|
610
|
+
})
|
|
611
|
+
// retrospectives (optional folder)
|
|
612
|
+
const retroDir = path.join(e.dir, 'retrospectives')
|
|
613
|
+
let retros = ''
|
|
614
|
+
try {
|
|
615
|
+
const files = fs.readdirSync(retroDir).filter(f => f.endsWith('.md')).sort()
|
|
616
|
+
const items = files.filter(f => hasContent(readEng(retroDir, f)))
|
|
617
|
+
.map(f => `<h4>${escapeHtml(f)}</h4>${mdToHtml(readEng(retroDir, f))}`).join('')
|
|
618
|
+
if (items) retros = `<div class="sub-sec"><h4 class="title">Retrospectives</h4>${items}</div>`
|
|
619
|
+
} catch (_) {}
|
|
620
|
+
const searchBlob = escapeHtml(stripPrivate(e.name + ' ' + DASH_SECTIONS.map(([f]) => readEng(e.dir, f)).join(' ')).toLowerCase())
|
|
621
|
+
return [
|
|
622
|
+
`<details class="eng" id="${id}" data-search="${searchBlob}">`,
|
|
623
|
+
`<summary><span class="dot ${trustClass}"></span>${inlineMd(e.name)}`,
|
|
624
|
+
`<span class="badge">${inlineMd(e.signals.phase)}</span>`,
|
|
625
|
+
`<span class="meta" style="font-weight:400">updated ${e.signals.updated}</span></summary>`,
|
|
626
|
+
`<div class="eng-body">${subs.join('')}${retros}</div>`,
|
|
627
|
+
`</details>`,
|
|
628
|
+
].join('')
|
|
629
|
+
}).join('\n')
|
|
630
|
+
|
|
631
|
+
const emptyState = '<p class="empty">No engagements yet. Start one with <code>fde resume --init <client-name></code>, then re-run <code>fde dashboard</code>.</p>'
|
|
632
|
+
|
|
633
|
+
const html = [
|
|
634
|
+
'<!doctype html><html lang="en"><head><meta charset="utf-8">',
|
|
635
|
+
'<meta name="viewport" content="width=device-width,initial-scale=1">',
|
|
636
|
+
'<title>FDE Fieldbook</title><style>' + dashStyles() + '</style></head><body>',
|
|
637
|
+
'<header><div class="inner"><div class="brand"><h1>FDE Fieldbook</h1>',
|
|
638
|
+
`<span class="tagline">${engagements.length} engagement${engagements.length === 1 ? '' : 's'}</span></div>`,
|
|
639
|
+
'<div class="stats">',
|
|
640
|
+
`<span class="stat"><span class="sdot green"></span> <b>${counts.green}</b> green</span>`,
|
|
641
|
+
`<span class="stat"><span class="sdot amber"></span> <b>${counts.amber}</b> amber</span>`,
|
|
642
|
+
`<span class="stat"><span class="sdot red"></span> <b>${counts.RED}</b> red</span>`,
|
|
643
|
+
'</div></div></header>',
|
|
644
|
+
'<div class="wrap">',
|
|
645
|
+
engagements.length ? '<div class="search-wrap"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="q" class="search" placeholder="Search across all engagements..."></div>' : '',
|
|
646
|
+
'<h2 class="section">Portfolio</h2>',
|
|
647
|
+
engagements.length ? `<div class="grid">${cards}</div>` : emptyState,
|
|
648
|
+
engagements.length ? '<h2 class="section">Engagement detail</h2>' + details : '',
|
|
649
|
+
'</div>',
|
|
650
|
+
`<footer>fdeops · fieldbook is a deterministic render of your <code>.fde/</code> memory - edit the markdown, re-run <code>fde dashboard</code>. Source of truth stays in the files.</footer>`,
|
|
651
|
+
'<script>' + dashScript() + '</script>',
|
|
652
|
+
'</body></html>',
|
|
653
|
+
].join('\n')
|
|
654
|
+
|
|
655
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true })
|
|
656
|
+
fs.writeFileSync(outPath, html)
|
|
657
|
+
console.log(`fieldbook → ${outPath}`)
|
|
658
|
+
console.log(`${engagements.length} engagement(s) rendered · ${counts.RED} red / ${counts.amber} amber / ${counts.green} green · 0 tokens (pure render)`)
|
|
659
|
+
if (args.includes('--open')) {
|
|
660
|
+
// arg-array form: the path is never interpolated into a shell string.
|
|
661
|
+
const [bin, pre] = process.platform === 'darwin' ? ['open', []]
|
|
662
|
+
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '']]
|
|
663
|
+
: ['xdg-open', []]
|
|
664
|
+
try { execFileSync(bin, [...pre, outPath], { stdio: 'ignore', timeout: 5000 }) } catch (_) {}
|
|
665
|
+
} else {
|
|
666
|
+
console.log('open it: double-click the file, or run with --open')
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const [cmd, ...args] = process.argv.slice(2)
|
|
671
|
+
switch (cmd) {
|
|
672
|
+
case 'scan': cmdScan(); break
|
|
673
|
+
case 'resume': cmdResume(args); break
|
|
674
|
+
case 'log': cmdLog(args); break
|
|
675
|
+
case 'receipts': cmdReceipts(args); break
|
|
676
|
+
case 'capture': cmdCapture(); break
|
|
677
|
+
case 'status': cmdStatus(); break
|
|
678
|
+
case 'dashboard': cmdDashboard(args); break
|
|
679
|
+
default:
|
|
680
|
+
console.log(`fde - deterministic core of fdeops
|
|
681
|
+
fde scan day-1 recon of this repo (facts, no AI)
|
|
682
|
+
fde resume load this workspace's engagement memory (bounded)
|
|
683
|
+
fde resume --full load the complete context.md (no bound)
|
|
684
|
+
fde resume --init <name> create + bind engagement for this workspace
|
|
685
|
+
fde log <type> <text> append decision|risk|delivery|contact
|
|
686
|
+
fde receipts <term> "what did we agree?" with dates
|
|
687
|
+
fde capture session-end memory snapshot (hooks use this)
|
|
688
|
+
fde status portfolio across all engagements
|
|
689
|
+
fde dashboard render every engagement into one local fieldbook.html`)
|
|
690
|
+
}
|