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.
@@ -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
- [ -d "$raw" ] && printf '%s\n' "$raw" && return 0
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
@@ -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
- [ -d "$raw" ] && printf '%s\n' "$raw" && return 0
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)
@@ -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
- [ -d "$raw" ] && printf '%s\n' "$raw" && return 0
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)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops-ingest-mcp",
3
- "version": "3.10.2",
3
+ "version": "3.11.1",
4
4
  "private": true,
5
5
  "description": "Thin stdio MCP sink for FDEOps ingest (stage → propose → apply). Zero runtime dependencies.",
6
6
  "bin": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.10.2",
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.10.2",
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",
@@ -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` (fallback: `node ~/.claude/fdeops/fde.js resume`). 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.
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 missing → use the manual fallbacks inside each reference (still you write files; still never ask the FDE to run setup).
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
- **Status values:** `OPEN` · `TESTING` · `CONFIRMED` · `DISPROVED` · `PARKED`
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
- <!-- Use this format for each decision. The rationale is the part that survives the engagement. -->
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
- | | | *(cost-save / risk-mitigation / revenue-uplift)* | *(what we said it would change)* | *(what actually changed, or pending)* | *(customer-side name + date, or it stays claimed)* | *(who/when/metric)* | |
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
- | | | *(dated cite: exceptions/operating path verified — terrain/reality/audit)* | *(n/a \| evals.md pass + HITL owner)* |
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
 
@@ -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
- | | | | |
@@ -2,4 +2,6 @@
2
2
 
3
3
  | Risk | Status | Owner | Mitigation |
4
4
  |------|--------|-------|------------|
5
- | | open | | |
5
+
6
+ **Status:** `open` · `mitigating` · `closed` · `accepted`
7
+ <!-- `fde log risk "<text>"` appends a dated line; the table is for live, owned risk. -->
@@ -5,7 +5,8 @@
5
5
 
6
6
  | Name | Role | Stance | Notes |
7
7
  |------|------|--------|-------|
8
- | | | champion / neutral / resistant | |
8
+
9
+ **Stance:** `champion` · `neutral` · `resistant`
9
10
 
10
11
  **Trust signal:** green | amber | red
11
12
  **Last trust check:**
@@ -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
- | | | | |