fdeops 3.10.2 → 3.11.1
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 +87 -81
- package/adapters/README.md +1 -1
- package/bin/check.js +25 -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 +3 -2
- 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/lib/vault.js
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Derived Obsidian view of the fieldbook. Pure builders: in comes already-redacted
|
|
4
|
+
// engagement data, out comes a list of { rel, content } files. No fs, no network.
|
|
5
|
+
//
|
|
6
|
+
// Two rules this file exists to keep:
|
|
7
|
+
// 1. `.fde/` stays the only source of truth. Nothing here is ever parsed back,
|
|
8
|
+
// so an FDE can edit the vault, delete it, or ignore it with no consequence.
|
|
9
|
+
// 2. The vault is an output of the CLI, so <private> blocks are already gone
|
|
10
|
+
// before anything reaches these builders (callers use readClean). `redacted`
|
|
11
|
+
// goes further and drops the political layer for a shared screen.
|
|
12
|
+
|
|
13
|
+
const FORMAT = 1
|
|
14
|
+
const STAMP = '.fdeops-vault'
|
|
15
|
+
|
|
16
|
+
// Obsidian resolves [[links]] by note name, and | # ^ [ ] break the link syntax.
|
|
17
|
+
// A client called "Acme | EU" must still get a reachable page.
|
|
18
|
+
function safeTitle(s) {
|
|
19
|
+
return String(s || '')
|
|
20
|
+
.replace(/[[\]|#^\\/:*?"<>]/g, ' ')
|
|
21
|
+
.replace(/\s+/g, ' ')
|
|
22
|
+
.trim() || 'untitled'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function cell(s) {
|
|
26
|
+
return String(s || '').replace(/\|/g, '\\|').replace(/\n+/g, ' ').trim()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function yamlStr(s) {
|
|
30
|
+
return `"${String(s == null ? '' : s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Frontmatter is what makes the vault queryable (Obsidian properties, Dataview,
|
|
34
|
+
// Bases). It exists only in generated files - the authoritative .fde/ markdown
|
|
35
|
+
// stays plain, so a client can read it without a tool.
|
|
36
|
+
function frontmatter(fields) {
|
|
37
|
+
const lines = ['---']
|
|
38
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
39
|
+
if (v == null || v === '') continue
|
|
40
|
+
if (Array.isArray(v)) {
|
|
41
|
+
if (!v.length) continue
|
|
42
|
+
lines.push(`${k}:`)
|
|
43
|
+
v.forEach(item => lines.push(` - ${yamlStr(item)}`))
|
|
44
|
+
} else if (typeof v === 'number' || typeof v === 'boolean') {
|
|
45
|
+
lines.push(`${k}: ${v}`)
|
|
46
|
+
} else {
|
|
47
|
+
lines.push(`${k}: ${yamlStr(v)}`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
lines.push('---', '')
|
|
51
|
+
return lines.join('\n')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function trustWord(trust) {
|
|
55
|
+
return String(trust || '').toLowerCase() === 'red' ? 'red' : String(trust || '')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A signal token is internal shorthand; the sponsor view keeps the fact and
|
|
59
|
+
// drops the grading. Whole section bodies pass through here, so only the space
|
|
60
|
+
// the token itself occupied is closed up: a global whitespace collapse ate the
|
|
61
|
+
// newlines, glued headings to prose and broke every table on these pages.
|
|
62
|
+
function stripInternalTokens(text) {
|
|
63
|
+
return String(text || '')
|
|
64
|
+
.replace(/[^\S\n]*\[signal:(red|amber|green)\]/gi, '')
|
|
65
|
+
.replace(/[^\S\n]*\[@[^\]]+\]/g, '')
|
|
66
|
+
.trim()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sectionPage({ eng, title, body, kind, redacted, today }) {
|
|
70
|
+
return frontmatter({
|
|
71
|
+
client: eng.name,
|
|
72
|
+
fde_page: kind,
|
|
73
|
+
phase: eng.signals.phase === '?' ? '' : eng.signals.phase,
|
|
74
|
+
generated: today,
|
|
75
|
+
tags: ['fdeops', `fdeops/${kind}`],
|
|
76
|
+
}) + `# ${safeTitle(eng.name)} - ${title}\n\n` +
|
|
77
|
+
`Engagement: [[${safeTitle(eng.name)}]]\n\n` +
|
|
78
|
+
(body.trim() ? body.trim() + '\n' : `*(nothing recorded yet)*\n`) +
|
|
79
|
+
`\n---\n*Generated from \`${eng.name}/.fde/${kind}.md\`${redacted ? ' - sponsor-safe copy' : ''}. Edit the fieldbook, not this page.*\n`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function personPage({ eng, person, today }) {
|
|
83
|
+
return frontmatter({
|
|
84
|
+
client: eng.name,
|
|
85
|
+
fde_page: 'person',
|
|
86
|
+
role: person.role,
|
|
87
|
+
signal: person.signal,
|
|
88
|
+
generated: today,
|
|
89
|
+
tags: ['fdeops', 'fdeops/person', `fdeops/signal/${person.signal || 'unknown'}`],
|
|
90
|
+
}) + `# ${safeTitle(person.name)}\n\n` +
|
|
91
|
+
`${person.role ? `**Role:** ${person.role} \n` : ''}` +
|
|
92
|
+
`**Latest signal:** ${person.signal || 'unrecorded'}\n\n` +
|
|
93
|
+
(person.note ? `${person.note}\n\n` : '') +
|
|
94
|
+
`Engagement: [[${safeTitle(eng.name)}]]\n`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function hubPage({ eng, today, redacted }) {
|
|
98
|
+
const s = eng.signals
|
|
99
|
+
// Next action / Brief / Reality are free text an FDE types, so they carry the
|
|
100
|
+
// same internal tokens the timeline does - strip them on the sponsor page too.
|
|
101
|
+
const plain = (text) => redacted ? stripInternalTokens(text) : String(text || '')
|
|
102
|
+
const links = [
|
|
103
|
+
['Decisions', 'decisions'],
|
|
104
|
+
['Risks', 'risks'],
|
|
105
|
+
['Delivery', 'delivery'],
|
|
106
|
+
...(redacted ? [] : [['Stakeholders', 'stakeholders']]),
|
|
107
|
+
['Terrain', 'terrain'],
|
|
108
|
+
['Success', 'success'],
|
|
109
|
+
...(redacted ? [] : [['Trust profile', 'trust-profile']]),
|
|
110
|
+
].filter(([, kind]) => eng.pages[kind] != null)
|
|
111
|
+
|
|
112
|
+
const timeline = (redacted ? eng.log.filter(e => e.kind !== 'note') : eng.log)
|
|
113
|
+
.map(e => `- **${e.date}** ${cell(redacted ? stripInternalTokens(e.text) : e.text)}${e.sig && !redacted ? ` \`[${e.sig}]\`` : ''}`)
|
|
114
|
+
|
|
115
|
+
const people = redacted ? [] : eng.stakeholders.map(p =>
|
|
116
|
+
`- [[${safeTitle(eng.name)}/People/${safeTitle(p.name)}|${safeTitle(p.name)}]]${p.role ? ` - ${cell(p.role)}` : ''} \`${p.signal || 'unrecorded'}\``)
|
|
117
|
+
|
|
118
|
+
return frontmatter({
|
|
119
|
+
client: eng.name,
|
|
120
|
+
fde_page: 'engagement',
|
|
121
|
+
phase: s.phase === '?' ? '' : s.phase,
|
|
122
|
+
...(redacted ? {} : { trust: trustWord(s.trust), signal_age_days: s.signalAge == null ? '' : s.signalAge, signal_stale: !!s.stale }),
|
|
123
|
+
open_risks: s.openRisks,
|
|
124
|
+
days_elapsed: eng.days == null ? '' : eng.days,
|
|
125
|
+
last_updated: s.updated,
|
|
126
|
+
overlay: eng.overlay || '',
|
|
127
|
+
generated: today,
|
|
128
|
+
tags: ['fdeops', 'fdeops/engagement', ...(redacted ? [] : [`fdeops/trust/${trustWord(s.trust) || 'unknown'}`])],
|
|
129
|
+
}) + [
|
|
130
|
+
`# ${safeTitle(eng.name)}`,
|
|
131
|
+
'',
|
|
132
|
+
`**Phase:** ${s.phase === '?' ? 'unset' : s.phase}` +
|
|
133
|
+
(redacted ? '' : ` · **Trust:** ${trustWord(s.trust)}${s.stale ? ' (signal stale - reconfirm)' : ''}`) +
|
|
134
|
+
` · **Open risks:** ${s.openRisks} · **Updated:** ${s.updated}`,
|
|
135
|
+
'',
|
|
136
|
+
'## Next action',
|
|
137
|
+
'',
|
|
138
|
+
eng.next ? plain(eng.next) : '*(none recorded - `fde log`/`@fde` writes one)*',
|
|
139
|
+
'',
|
|
140
|
+
...(eng.brief ? ['## Brief', '', plain(eng.brief), ''] : []),
|
|
141
|
+
...(eng.reality ? ['## Reality', '', plain(eng.reality), ''] : []),
|
|
142
|
+
'## The record',
|
|
143
|
+
'',
|
|
144
|
+
...links.map(([label, kind]) => `- [[${safeTitle(eng.name)}/${label}|${label}]]`),
|
|
145
|
+
'',
|
|
146
|
+
...(people.length ? ['## People', '', ...people, ''] : []),
|
|
147
|
+
...(timeline.length ? ['## Timeline', '', ...timeline, ''] : []),
|
|
148
|
+
'---',
|
|
149
|
+
`*Derived from \`${eng.name}/.fde/\` on ${today}. Regenerate with \`fde vault${redacted ? ' --redacted' : ''}\`.*`,
|
|
150
|
+
'',
|
|
151
|
+
].join('\n')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function portfolioPage({ engagements, today, redacted }) {
|
|
155
|
+
const order = { RED: 0, amber: 1, green: 2 }
|
|
156
|
+
const rows = [...engagements].sort((a, b) =>
|
|
157
|
+
(order[a.signals.trust] ?? 3) - (order[b.signals.trust] ?? 3) || a.name.localeCompare(b.name))
|
|
158
|
+
|
|
159
|
+
const head = redacted
|
|
160
|
+
? ['| Engagement | Phase | Open risks | Next action |', '|---|---|---|---|']
|
|
161
|
+
: ['| Engagement | Phase | Trust | Open risks | Updated | Next action |', '|---|---|---|---|---|---|']
|
|
162
|
+
|
|
163
|
+
const body = rows.map(e => {
|
|
164
|
+
const link = `[[${safeTitle(e.name)}]]`
|
|
165
|
+
const phase = e.signals.phase === '?' ? 'unset' : e.signals.phase
|
|
166
|
+
const next = cell(redacted ? stripInternalTokens(e.next) : e.next) || '-'
|
|
167
|
+
return redacted
|
|
168
|
+
? `| ${link} | ${phase} | ${e.signals.openRisks} | ${next} |`
|
|
169
|
+
: `| ${link} | ${phase} | ${trustWord(e.signals.trust)}${e.signals.stale ? '?' : ''} | ${e.signals.openRisks} | ${cell(e.signals.updated)} | ${next} |`
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
return frontmatter({
|
|
173
|
+
fde_page: 'portfolio',
|
|
174
|
+
engagements: rows.length,
|
|
175
|
+
generated: today,
|
|
176
|
+
tags: ['fdeops', 'fdeops/portfolio'],
|
|
177
|
+
}) + [
|
|
178
|
+
'# Portfolio',
|
|
179
|
+
'',
|
|
180
|
+
rows.length
|
|
181
|
+
? `${rows.length} engagement${rows.length === 1 ? '' : 's'}${redacted ? '' : ', worst trust first'}.`
|
|
182
|
+
: 'No engagements yet - `fde resume --init <name>`.',
|
|
183
|
+
'',
|
|
184
|
+
...(rows.length ? [...head, ...body, ''] : []),
|
|
185
|
+
...(redacted ? [] : ['See [[Questions]] for what the record is missing.', '']),
|
|
186
|
+
].join('\n')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Deterministic answers, computed here rather than shipped as Dataview queries:
|
|
190
|
+
// the vault must work in a stock Obsidian with no plugins installed.
|
|
191
|
+
function questionsPage({ engagements, today }) {
|
|
192
|
+
const quiet = engagements.filter(e => e.signals.ageDays !== Infinity && e.signals.ageDays >= 14)
|
|
193
|
+
const staleSignal = engagements.filter(e => e.signals.stale)
|
|
194
|
+
const noNext = engagements.filter(e => !e.next)
|
|
195
|
+
const unaccepted = []
|
|
196
|
+
const noSignal = []
|
|
197
|
+
for (const e of engagements) {
|
|
198
|
+
for (const row of e.valueRows || []) {
|
|
199
|
+
if (!row.acceptedBy) unaccepted.push({ eng: e, row })
|
|
200
|
+
}
|
|
201
|
+
if (!e.stakeholders.length) noSignal.push(e)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const list = (items, empty) => items.length ? items : [`- ${empty}`]
|
|
205
|
+
|
|
206
|
+
return frontmatter({
|
|
207
|
+
fde_page: 'questions',
|
|
208
|
+
generated: today,
|
|
209
|
+
tags: ['fdeops', 'fdeops/questions'],
|
|
210
|
+
}) + [
|
|
211
|
+
'# Questions',
|
|
212
|
+
'',
|
|
213
|
+
'What the record cannot answer is the part worth reading. Recomputed on every `fde vault`.',
|
|
214
|
+
'',
|
|
215
|
+
'## Gone quiet (14+ days since a memory write)',
|
|
216
|
+
'',
|
|
217
|
+
...list(quiet.map(e => `- [[${safeTitle(e.name)}]] - ${e.signals.updated}`), 'none'),
|
|
218
|
+
'',
|
|
219
|
+
'## Value promised but nobody accepted it',
|
|
220
|
+
'',
|
|
221
|
+
...list(unaccepted.map(({ eng, row }) => `- [[${safeTitle(eng.name)}]] - ${cell(row.slice || row.promised || 'unnamed slice')}`),
|
|
222
|
+
'none - every delivered slice names a customer-side acceptor'),
|
|
223
|
+
'',
|
|
224
|
+
'## Trust signal older than 21 days',
|
|
225
|
+
'',
|
|
226
|
+
...list(staleSignal.map(e => `- [[${safeTitle(e.name)}]] - signal ${e.signals.signalAge}d old`), 'none'),
|
|
227
|
+
'',
|
|
228
|
+
'## No stakeholder signal at all',
|
|
229
|
+
'',
|
|
230
|
+
...list(noSignal.map(e => `- [[${safeTitle(e.name)}]]`), 'none'),
|
|
231
|
+
'',
|
|
232
|
+
'## No next action recorded',
|
|
233
|
+
'',
|
|
234
|
+
...list(noNext.map(e => `- [[${safeTitle(e.name)}]]`), 'none'),
|
|
235
|
+
'',
|
|
236
|
+
`Portfolio: [[Portfolio]]`,
|
|
237
|
+
'',
|
|
238
|
+
].join('\n')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function readmePage({ engagements, today, redacted, engagementsRoot }) {
|
|
242
|
+
return [
|
|
243
|
+
'# FDEOps vault (generated - do not keep anything here)',
|
|
244
|
+
'',
|
|
245
|
+
`Generated ${today} from \`${engagementsRoot}\`. ${engagements.length} engagement${engagements.length === 1 ? '' : 's'}.`,
|
|
246
|
+
'',
|
|
247
|
+
'Open this folder as an Obsidian vault. Start at [[Portfolio]]' + (redacted ? '.' : ' and [[Questions]].'),
|
|
248
|
+
'',
|
|
249
|
+
'## What this is',
|
|
250
|
+
'',
|
|
251
|
+
'- A **derived** view. The fieldbook at `~/fde-engagements/<client>/.fde/` is the only source of truth.',
|
|
252
|
+
'- **Disposable.** `fde vault` deletes and rebuilds this folder, so anything you type here is lost. Log to the fieldbook instead (`@fde` or `fde log`).',
|
|
253
|
+
'- **Nothing is read back.** No plugin required, no sync, no network - plain markdown, wikilinks and frontmatter.',
|
|
254
|
+
'',
|
|
255
|
+
'## What is not here',
|
|
256
|
+
'',
|
|
257
|
+
'- `<private>` blocks. They never leave `.fde/`; every page here is built from redacted reads.',
|
|
258
|
+
...(redacted
|
|
259
|
+
? [
|
|
260
|
+
'- Stakeholders, people pages, trust signals and `trust-profile.md` - this is the `--redacted` build, meant for a shared screen.',
|
|
261
|
+
'- Internal `[signal:x]` and `[@owner]` tokens, and contact notes in the timeline.',
|
|
262
|
+
'',
|
|
263
|
+
'**Still check before you screen-share.** Redaction removes the political layer, not judgement: `decisions.md`, `risks.md` and `delivery.md` are shown as written.',
|
|
264
|
+
]
|
|
265
|
+
: [
|
|
266
|
+
'- Nothing else. This is the full working view, for your machine only. For a sponsor meeting run `fde vault --redacted`.',
|
|
267
|
+
]),
|
|
268
|
+
'',
|
|
269
|
+
].join('\n')
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function buildVaultFiles({ engagements, today, redacted = false, engagementsRoot = '~/fde-engagements', version = '' }) {
|
|
273
|
+
const files = []
|
|
274
|
+
const SECTIONS = [
|
|
275
|
+
['Decisions', 'decisions'],
|
|
276
|
+
['Risks', 'risks'],
|
|
277
|
+
['Delivery', 'delivery'],
|
|
278
|
+
['Stakeholders', 'stakeholders'],
|
|
279
|
+
['Terrain', 'terrain'],
|
|
280
|
+
['Success', 'success'],
|
|
281
|
+
['Trust profile', 'trust-profile'],
|
|
282
|
+
]
|
|
283
|
+
const REDACTED_OUT = new Set(['stakeholders', 'trust-profile'])
|
|
284
|
+
|
|
285
|
+
files.push({ rel: 'README.md', content: readmePage({ engagements, today, redacted, engagementsRoot }) })
|
|
286
|
+
files.push({ rel: 'Portfolio.md', content: portfolioPage({ engagements, today, redacted }) })
|
|
287
|
+
if (!redacted) files.push({ rel: 'Questions.md', content: questionsPage({ engagements, today }) })
|
|
288
|
+
|
|
289
|
+
for (const eng of engagements) {
|
|
290
|
+
const dir = safeTitle(eng.name)
|
|
291
|
+
files.push({ rel: `${dir}/${dir}.md`, content: hubPage({ eng, today, redacted }) })
|
|
292
|
+
for (const [title, kind] of SECTIONS) {
|
|
293
|
+
if (redacted && REDACTED_OUT.has(kind)) continue
|
|
294
|
+
const body = eng.pages[kind]
|
|
295
|
+
if (body == null) continue
|
|
296
|
+
files.push({
|
|
297
|
+
rel: `${dir}/${title}.md`,
|
|
298
|
+
content: sectionPage({ eng, title, body: redacted ? stripInternalTokens(body) : body, kind, redacted, today }),
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
if (!redacted) {
|
|
302
|
+
for (const person of eng.stakeholders) {
|
|
303
|
+
files.push({ rel: `${dir}/People/${safeTitle(person.name)}.md`, content: personPage({ eng, person, today }) })
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// A generated vault must not become a commit. `*` covers the whole tree, so a
|
|
309
|
+
// vault written inside a repo checkout stays out of `git status` too.
|
|
310
|
+
files.push({ rel: '.gitignore', content: '# generated by fde vault - never commit a client record\n*\n' })
|
|
311
|
+
files.push({
|
|
312
|
+
rel: STAMP,
|
|
313
|
+
content: JSON.stringify({
|
|
314
|
+
tool: 'fdeops', format: FORMAT, version, generated: today,
|
|
315
|
+
mode: redacted ? 'redacted' : 'full', engagements: engagements.length,
|
|
316
|
+
source: engagementsRoot,
|
|
317
|
+
note: 'Written by `fde vault`. Deleted and rebuilt on every run - this file is how the CLI knows the folder is safe to replace.',
|
|
318
|
+
}, null, 2) + '\n',
|
|
319
|
+
})
|
|
320
|
+
return files
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
module.exports = { buildVaultFiles, safeTitle, stripInternalTokens, frontmatter, STAMP, FORMAT }
|
package/hooks/pre-compact
CHANGED
|
@@ -7,7 +7,23 @@ resolve_engagement_dir() {
|
|
|
7
7
|
[ -z "$raw" ] && return 1
|
|
8
8
|
# strip surrounding whitespace/quotes only - paths may contain spaces
|
|
9
9
|
raw=$(printf '%s' "$raw" | sed -e 's/^[[:space:]"'"'"']*//' -e 's/[[:space:]"'"'"']*$//' -e "s|^~|$HOME|")
|
|
10
|
-
|
|
10
|
+
# Same forms bin/fde.js accepts: an absolute .fde, an absolute engagement
|
|
11
|
+
# folder, or a bare slug under the engagements root - keep the two in lockstep.
|
|
12
|
+
# Anything relative is refused: it would resolve against whatever directory
|
|
13
|
+
# the agent happened to start in.
|
|
14
|
+
case $raw in
|
|
15
|
+
/*)
|
|
16
|
+
[ -d "$raw/.fde" ] && printf '%s\n' "$raw/.fde" && return 0
|
|
17
|
+
[ -d "$raw" ] && printf '%s\n' "$raw" && return 0
|
|
18
|
+
return 1
|
|
19
|
+
;;
|
|
20
|
+
*/*|.*) return 1 ;;
|
|
21
|
+
esac
|
|
22
|
+
local root="${FDEOPS_ENGAGEMENTS_ROOT:-$HOME/fde-engagements}"
|
|
23
|
+
root="${root/#\~/$HOME}"
|
|
24
|
+
local slug
|
|
25
|
+
slug=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed -e 's/[^a-z0-9]\+/-/g' -e 's/^-//' -e 's/-$//')
|
|
26
|
+
[ -n "$slug" ] && [ -d "$root/$slug/.fde" ] && printf '%s\n' "$root/$slug/.fde" && return 0
|
|
11
27
|
return 1
|
|
12
28
|
}
|
|
13
29
|
|
|
@@ -57,6 +73,13 @@ resolve_fde() {
|
|
|
57
73
|
|
|
58
74
|
ENG_DIR=$(resolve_engagement_dir "${FDEOPS_ENGAGEMENT:-${FDEOS_ENGAGEMENT:-}}")
|
|
59
75
|
|
|
76
|
+
# An override that cannot be honored is never ignored: falling through to the
|
|
77
|
+
# registry would write this session into whichever engagement the workspace is
|
|
78
|
+
# bound to, and the operator named a different one. bin/fde.js refuses; so do we.
|
|
79
|
+
if [ -z "$ENG_DIR" ] && [ -n "${FDEOPS_ENGAGEMENT:-${FDEOS_ENGAGEMENT:-}}" ]; then
|
|
80
|
+
exit 0
|
|
81
|
+
fi
|
|
82
|
+
|
|
60
83
|
if [ -z "$ENG_DIR" ]; then
|
|
61
84
|
ENG_DIR=$(registry_engagement_dir)
|
|
62
85
|
fi
|
package/hooks/session-start
CHANGED
|
@@ -11,7 +11,23 @@ resolve_engagement_dir() {
|
|
|
11
11
|
[ -z "$raw" ] && return 1
|
|
12
12
|
# strip surrounding whitespace/quotes only - paths may contain spaces
|
|
13
13
|
raw=$(printf '%s' "$raw" | sed -e 's/^[[:space:]"'"'"']*//' -e 's/[[:space:]"'"'"']*$//' -e "s|^~|$HOME|")
|
|
14
|
-
|
|
14
|
+
# Same forms bin/fde.js accepts: an absolute .fde, an absolute engagement
|
|
15
|
+
# folder, or a bare slug under the engagements root - keep the two in lockstep.
|
|
16
|
+
# Anything relative is refused: it would resolve against whatever directory
|
|
17
|
+
# the agent happened to start in.
|
|
18
|
+
case $raw in
|
|
19
|
+
/*)
|
|
20
|
+
[ -d "$raw/.fde" ] && printf '%s\n' "$raw/.fde" && return 0
|
|
21
|
+
[ -d "$raw" ] && printf '%s\n' "$raw" && return 0
|
|
22
|
+
return 1
|
|
23
|
+
;;
|
|
24
|
+
*/*|.*) return 1 ;;
|
|
25
|
+
esac
|
|
26
|
+
local root="${FDEOPS_ENGAGEMENTS_ROOT:-$HOME/fde-engagements}"
|
|
27
|
+
root="${root/#\~/$HOME}"
|
|
28
|
+
local slug
|
|
29
|
+
slug=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed -e 's/[^a-z0-9]\+/-/g' -e 's/^-//' -e 's/-$//')
|
|
30
|
+
[ -n "$slug" ] && [ -d "$root/$slug/.fde" ] && printf '%s\n' "$root/$slug/.fde" && return 0
|
|
15
31
|
return 1
|
|
16
32
|
}
|
|
17
33
|
|
|
@@ -46,6 +62,13 @@ if [ -z "$CONTEXT_FILE" ]; then
|
|
|
46
62
|
[ -n "$ENG_DIR" ] && CONTEXT_FILE="$ENG_DIR/context.md"
|
|
47
63
|
fi
|
|
48
64
|
|
|
65
|
+
# An override that cannot be honored is never ignored: falling through to the
|
|
66
|
+
# registry would load whichever engagement the workspace is bound to, and the
|
|
67
|
+
# operator named a different one. bin/fde.js refuses; so do we.
|
|
68
|
+
if [ -z "$CONTEXT_FILE" ] && [ -n "${FDEOPS_ENGAGEMENT:-${FDEOS_ENGAGEMENT:-}}" ]; then
|
|
69
|
+
exit 0
|
|
70
|
+
fi
|
|
71
|
+
|
|
49
72
|
# 2) Workspace registry binding (written by fde resume --init)
|
|
50
73
|
if [ -z "$CONTEXT_FILE" ]; then
|
|
51
74
|
ENG_DIR=$(registry_engagement_dir)
|
package/hooks/session-stop
CHANGED
|
@@ -14,7 +14,23 @@ resolve_engagement_dir() {
|
|
|
14
14
|
[ -z "$raw" ] && return 1
|
|
15
15
|
# strip surrounding whitespace/quotes only - paths may contain spaces
|
|
16
16
|
raw=$(printf '%s' "$raw" | sed -e 's/^[[:space:]"'"'"']*//' -e 's/[[:space:]"'"'"']*$//' -e "s|^~|$HOME|")
|
|
17
|
-
|
|
17
|
+
# Same forms bin/fde.js accepts: an absolute .fde, an absolute engagement
|
|
18
|
+
# folder, or a bare slug under the engagements root - keep the two in lockstep.
|
|
19
|
+
# Anything relative is refused: it would resolve against whatever directory
|
|
20
|
+
# the agent happened to start in.
|
|
21
|
+
case $raw in
|
|
22
|
+
/*)
|
|
23
|
+
[ -d "$raw/.fde" ] && printf '%s\n' "$raw/.fde" && return 0
|
|
24
|
+
[ -d "$raw" ] && printf '%s\n' "$raw" && return 0
|
|
25
|
+
return 1
|
|
26
|
+
;;
|
|
27
|
+
*/*|.*) return 1 ;;
|
|
28
|
+
esac
|
|
29
|
+
local root="${FDEOPS_ENGAGEMENTS_ROOT:-$HOME/fde-engagements}"
|
|
30
|
+
root="${root/#\~/$HOME}"
|
|
31
|
+
local slug
|
|
32
|
+
slug=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed -e 's/[^a-z0-9]\+/-/g' -e 's/^-//' -e 's/-$//')
|
|
33
|
+
[ -n "$slug" ] && [ -d "$root/$slug/.fde" ] && printf '%s\n' "$root/$slug/.fde" && return 0
|
|
18
34
|
return 1
|
|
19
35
|
}
|
|
20
36
|
|
|
@@ -77,6 +93,13 @@ ENG_DIR=""
|
|
|
77
93
|
# 1) Environment variable (any agent)
|
|
78
94
|
ENG_DIR=$(resolve_engagement_dir "${FDEOPS_ENGAGEMENT:-${FDEOS_ENGAGEMENT:-}}")
|
|
79
95
|
|
|
96
|
+
# An override that cannot be honored is never ignored: falling through to the
|
|
97
|
+
# registry would write this session into whichever engagement the workspace is
|
|
98
|
+
# bound to, and the operator named a different one. bin/fde.js refuses; so do we.
|
|
99
|
+
if [ -z "$ENG_DIR" ] && [ -n "${FDEOPS_ENGAGEMENT:-${FDEOS_ENGAGEMENT:-}}" ]; then
|
|
100
|
+
exit 0
|
|
101
|
+
fi
|
|
102
|
+
|
|
80
103
|
# 2) Workspace registry binding (written by fde resume --init)
|
|
81
104
|
if [ -z "$ENG_DIR" ]; then
|
|
82
105
|
ENG_DIR=$(registry_engagement_dir)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fdeops",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.11.1",
|
|
4
4
|
"description": "Field kit for engineers embedded in client work - a real CLI (recon, memory, portfolio), one @fde skill with field judgment on top, and hooks that make it automatic. Claude Code plugin and any agent that loads skills.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"fdeops": "bin/install.js",
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "fdeops",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.11.1",
|
|
5
5
|
"description": "Engagement fieldbook for Forward Deployed Engineers: per-client memory in local .fde/ files, one @fde skill, land to close methodology. Local-only, no network.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Subash Natarajan",
|
package/skills/fde/SKILL.md
CHANGED
|
@@ -74,7 +74,7 @@ When NOT to interrogate or challenge: unambiguous one-liners, mechanical ops, FD
|
|
|
74
74
|
- Data tagged `<private>` (sacred data, PHI, cardholder, classified) is **redacted from CLI, dashboard, and hook-injected context**. Do **not** open raw `<private>` blocks with file tools (that bypasses redaction) or paste them into prompts/subagents - work around them, never with them.
|
|
75
75
|
- Locked-down engagement (no AI on their code)? Use the CLI + the fieldbook only. The memory layer is the FDE's own notes, not customer code.
|
|
76
76
|
|
|
77
|
-
**Engagement path - zero ceremony.** Run `fde resume` (
|
|
77
|
+
**Engagement path - zero ceremony.** Run `fde resume` (fallbacks, in order: `node ~/.claude/fdeops/fde.js resume`, then `npx --yes fdeops resume` - the CLI is one command away on any machine with Node, so reach for it before doing memory work by hand). The **workspace registry** (written once by `fde resume --init <name>`) is the normal path; resolution order is env var override → registry → pointer file → workspace-name match (read-only) → `./.fde`. Writes require a bind (or `FDEOPS_ENGAGEMENT`), not folder name alone. It prints a **bounded** view of `context.md` - the curated head (state, next action) plus the most recent activity, with the older session log collapsed (use `fde resume --full` when you genuinely need the whole history). If it reports NO ENGAGEMENT: confirm the client name in conversation (one question), then run `fde resume --init <name>` yourself - the one setup step; the FDE never runs setup commands. Never install fdeops on infrastructure the FDE does not control.
|
|
78
78
|
|
|
79
79
|
**You run the `fde` CLI for deterministic work - never improvise shell, never hand the command to the FDE:**
|
|
80
80
|
|
|
@@ -90,12 +90,13 @@ When NOT to interrogate or challenge: unambiguous one-liners, mechanical ops, FD
|
|
|
90
90
|
| "Draft the sponsor update" / how are we doing | `fde status` then follow `references/status.md` for the narrative |
|
|
91
91
|
| "Log that they went quiet" / trust signal | `fde log contact "…" --signal amber\|green\|red`. If they already named the color ("log that as amber"), that is the confirm — write it. If they only described the situation, playback the color once, then write. |
|
|
92
92
|
| Want the HTML fieldbook | `fde dashboard` |
|
|
93
|
+
| "Open my clients in Obsidian" / one window over everything / "can I show this to the sponsor?" | `fde vault` (add `--redacted` for a shared screen). Derived and disposable: it is rebuilt from `.fde/` on every run and never read back, so tell them to keep logging to the fieldbook, not to the vault. |
|
|
93
94
|
| "Clean up the fieldbook" / hygiene / memory feels messy | `fde doctor` - walk issues in plain language; propose fixes; never auto-rewrite without confirm. Includes structural gaps: empty operating map (plan+), stakeholder name forks (Denise vs Denise Chen), duplicates, ship/close risks. Contradictions need judgment (brief vs reality) - doctor is structural; you handle meaning. |
|
|
94
95
|
| "Scrub this secret / redact that token" (buried line, not just last write) | `fde redact <term>` preview, then `fde redact <term> --apply` after confirm. Undo is last-write only; redact is for buried lines. Remind them to rotate the real credential. |
|
|
95
96
|
|
|
96
97
|
**The debrief verb.** Highest-frequency loop. When the FDE shares notes or says "debrief": **you** run the smart path (write notes to a temp file if needed). `--smart` writes a propose file via deterministic heuristics (existing prefixes + light keywords); authentic rambling notes often land mostly in context until **you** rewrite lines with type prefixes. Show the proposed routing in plain language. Only `--apply` (or pipe prefixed lines) after they confirm. Never ask them to run the CLI. Detail: `references/debrief.md`.
|
|
97
98
|
|
|
98
|
-
CLI
|
|
99
|
+
CLI genuinely unavailable (no Node, offline, npx blocked) → use the manual fallbacks inside each reference (still you write files; still never ask the FDE to run setup). A skill-only install is not "unavailable": run the verb through `npx --yes fdeops …` so the gates, dating and redaction still hold.
|
|
99
100
|
|
|
100
101
|
**Token model - where the cost goes.** Deterministic work is the CLI's job and costs **zero model tokens**: memory writes, recon, receipts, status, dashboard, and the bounded `fde resume`. Session-start hooks inject **TRIAGE + bounded `context.md` + a one-line pointer** - never this full skill body (that loads only when `@fde` triggers). Spend tokens only on judgment - reading the situation, routing, running the phase method, writing the artifact. Three rules keep a full day of FDE work cheap: load the router first and pull **one** reference only when you route to it; never dump a whole `.fde/` file into context - read the bounded resume, or `fde receipts <term>` for a targeted slice; don't re-read files you already have. The expensive model should fire for real decisions, not for plumbing the CLI already does.
|
|
101
102
|
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
| # | Assumption | Blast radius | How we test | Status | Evidence |
|
|
6
6
|
|---|------------|--------------|-------------|--------|----------|
|
|
7
|
-
| 1 | *(seed from brief - stated, unverified)* | CRITICAL / LOAD-BEARING / CONVENIENCE | | OPEN | |
|
|
8
7
|
|
|
9
|
-
**
|
|
8
|
+
**Blast radius:** `CRITICAL` · `LOAD-BEARING` · `CONVENIENCE`
|
|
9
|
+
**Status:** `OPEN` · `TESTING` · `CONFIRMED` · `DISPROVED` · `PARKED`
|
|
10
10
|
|
|
11
11
|
**Rule:** a CRITICAL assumption still OPEN blocks plan. DISPROVED → update `reality.md` / `success.md` and log the reset in `decisions.md` the same day.
|
|
@@ -6,13 +6,5 @@
|
|
|
6
6
|
|
|
7
7
|
## Decision log
|
|
8
8
|
|
|
9
|
-
<!--
|
|
10
|
-
|
|
11
|
-
<!--
|
|
12
|
-
### [Date] Decision title
|
|
13
|
-
- Context: what prompted this decision
|
|
14
|
-
- Options considered: what was on the table
|
|
15
|
-
- Decision: what was chosen
|
|
16
|
-
- Rationale: why this over the alternatives
|
|
17
|
-
- Owner: who approved
|
|
18
|
-
-->
|
|
9
|
+
<!-- Dated lines: what was chosen, and why over the alternatives. Long form: docs/schema.md
|
|
10
|
+
- [2026-07-18] kept the vendor connector over an in-house rewrite - Priya wants the Q3 audit clean first -->
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
| Date | Slice | Bucket | Promised | Measured | Accepted by | Evidence | Rollback |
|
|
8
8
|
|------|-------|--------|----------|----------|-------------|----------|----------|
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
**Bucket:** `cost-save` · `risk-mitigation` · `revenue-uplift`
|
|
11
|
+
**Accepted by:** a customer-side name and date - without one the value stays *claimed*, not accepted.
|
|
10
12
|
|
|
11
13
|
## Ship receipts
|
|
12
14
|
|
|
@@ -14,7 +16,9 @@
|
|
|
14
16
|
|
|
15
17
|
| Date | Slice | Audit receipt | Eval receipt |
|
|
16
18
|
|------|-------|---------------|--------------|
|
|
17
|
-
|
|
19
|
+
|
|
20
|
+
**Audit receipt:** dated cite that the exception/operating path was verified (`terrain.md` / `reality.md`).
|
|
21
|
+
**Eval receipt:** `n/a` unless AI touches the slice, else `evals.md` pass + the HITL owner.
|
|
18
22
|
|
|
19
23
|
## Shipped
|
|
20
24
|
|
package/templates/.fde/evals.md
CHANGED
|
@@ -13,13 +13,11 @@
|
|
|
13
13
|
|
|
14
14
|
| ID | Input (sanitized) | Expected | Pass rule | Last run | Result |
|
|
15
15
|
|----|-------------------|----------|-----------|----------|--------|
|
|
16
|
-
| G1 | | | | | |
|
|
17
16
|
|
|
18
17
|
## Failure modes
|
|
19
18
|
|
|
20
19
|
| Mode | How it shows up | Detection | Mitigation |
|
|
21
20
|
|------|-----------------|-----------|------------|
|
|
22
|
-
| | | | |
|
|
23
21
|
|
|
24
22
|
## Pass / fail (this ship)
|
|
25
23
|
- **Golden:** _/_ pass (threshold: _)
|
|
@@ -31,7 +29,6 @@
|
|
|
31
29
|
|
|
32
30
|
| Decision / action | Autonomous OK? | Reviewer role | Escalation |
|
|
33
31
|
|-------------------|----------------|---------------|------------|
|
|
34
|
-
| | | | |
|
|
35
32
|
|
|
36
33
|
**HITL rule for this ship:**
|
|
37
34
|
|
|
@@ -39,4 +36,3 @@
|
|
|
39
36
|
|
|
40
37
|
| Date | What changed | Pack re-run? | Notes |
|
|
41
38
|
|------|--------------|--------------|-------|
|
|
42
|
-
| | | | |
|
package/templates/.fde/risks.md
CHANGED
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
|
|
14
14
|
| Exception / break | Who notices first | What they do today (workaround) | System of record then | Blast if wrong | Evidence |
|
|
15
15
|
|-------------------|-------------------|---------------------------------|-----------------------|----------------|----------|
|
|
16
|
-
| | | | | | |
|
|
17
16
|
|
|
18
17
|
**Shadow systems / silent workarounds:**
|
|
19
18
|
**Sacred / untouchable in ops:**
|
|
@@ -23,4 +22,3 @@
|
|
|
23
22
|
|
|
24
23
|
| Step | Deterministic | Model judgement | Human approve |
|
|
25
24
|
|------|---------------|-----------------|---------------|
|
|
26
|
-
| | | | |
|