fdeops 3.7.7 → 3.8.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # AGENTS.md - working in the fdeops repository
2
2
 
3
- This repository **is** fdeops - the second brain for Forward Deployed Engineers. One `@fde` skill routes an entire client engagement across six domains, the `fde` CLI does the deterministic work, and per-customer memory writes itself into `.fde/` files.
3
+ This repository **is** fdeops - the second brain for Forward Deployed Engineers. One `@fde` skill routes an entire client engagement across six domains, the `fde` CLI does the deterministic work, and per-customer memory lands in `.fde/` files as a side effect of the work (you still confirm judgment).
4
4
 
5
5
  ## If you are helping use fdeops in an engagement
6
6
 
package/README.md CHANGED
@@ -37,7 +37,7 @@ A notes app stores what you type. fdeops loads the right client into your AI age
37
37
  | **After a meeting** | Notes rot in a scratch file | `fde debrief` routes decisions, risks, deliveries, and contacts into the record, dated |
38
38
  | **Scope dispute** | "Small" additions absorbed silently; no record when the sponsor asks | `fde receipts <term>` answers "when did we agree to that?" with dates |
39
39
  | **Quiet stakeholder** | Noticed three weeks too late | `fde log contact --signal amber` the day it happens; `fde status` surfaces it |
40
- | **Multiple clients** | Details blur across engagements | One folder per client, never cross-contaminated |
40
+ | **Multiple clients** | Details blur across engagements | One folder per client; bind the workspace so writes cannot land on a name-alike checkout |
41
41
 
42
42
  ---
43
43
 
@@ -66,7 +66,7 @@ fdeops' `--init` creates the engagement memory at `~/fde-engagements/garvey/.fde
66
66
  @fde I just got the brief. New client, payments platform, they want it live before their Q3 audit.
67
67
  ```
68
68
 
69
- `@fde` is the one skill fdeops installs. Describe what's happening; it routes to the right field method and the memory writes itself. Full workflow: [docs/USAGE.md](docs/USAGE.md).
69
+ `@fde` is the one skill fdeops installs. Describe what's happening; it routes to the right field method and writes matching `.fde/` artifacts — you still confirm judgment. Full workflow: [docs/USAGE.md](docs/USAGE.md).
70
70
 
71
71
  Not ready to install? `npx fdeops scan` runs on any repo you can read - day-1 recon (pure `git` + file reads, no config, no account) that maps hotspots, test gaps, and reverted attempts, and ends with the ASK ON DAY 1 questions the brief never mentions. The scan is heuristic by design - treat its output as leads to verify on day one, not findings.
72
72
 
package/bin/fde.js CHANGED
@@ -93,7 +93,11 @@ function readRegistry() {
93
93
  } catch (_) { return [] }
94
94
  }
95
95
 
96
- function resolveEngagement() {
96
+ function resolveEngagement(opts = {}) {
97
+ // opts.forWrite: memory mutations (log/debrief/capture) require an intentional
98
+ // bind - env, registry, pointer, or in-repo .fde. Basename matching is
99
+ // read-only convenience; writing on a folder-name guess contaminates clients.
100
+ const forWrite = !!opts.forWrite
97
101
  // 1) explicit env (back-compat: accept old FDEOS_ENGAGEMENT too)
98
102
  const env = (process.env.FDEOPS_ENGAGEMENT || process.env.FDEOS_ENGAGEMENT || '').replace(/^~/, HOME).trim()
99
103
  if (env && fs.existsSync(env)) return env
@@ -123,15 +127,21 @@ function resolveEngagement() {
123
127
  }
124
128
  } catch (_) {}
125
129
  }
126
- // 4) workspace dir name matches an engagement slug. This is a convenience,
127
- // NOT a binding - an unbound directory that merely happens to be named like a
128
- // client (a fork, a demo, a second client with the same codename) would
129
- // otherwise attach to that client's memory silently and get written into.
130
- // Never silent: warn on stderr so cross-client contamination can't happen
131
- // unnoticed, and tell the user how to make the binding explicit.
132
- const guess = path.join(ENGAGEMENTS_ROOT, slugify(path.basename(cwd)), '.fde')
130
+ // 4) workspace dir name matches an engagement slug. Read-only convenience.
131
+ // NEVER a write target - an unbound checkout named like a client must not
132
+ // append into that client's memory.
133
+ const slugGuess = slugify(path.basename(cwd))
134
+ const guess = path.join(ENGAGEMENTS_ROOT, slugGuess, '.fde')
133
135
  if (fs.existsSync(guess)) {
134
- process.stderr.write(`⚠ resolved engagement by directory name ("${slugify(path.basename(cwd))}"), not a saved binding. If this is the right client, run \`fde resume --init ${slugify(path.basename(cwd))}\` here to bind it; if not, you are about to read/write the WRONG client's memory.\n`)
136
+ if (forWrite) {
137
+ process.stderr.write(
138
+ `no binding for this workspace - folder name matched "${slugGuess}" but writes require an explicit bind.\n` +
139
+ `run: fde resume --init ${slugGuess}\n` +
140
+ ` or: export FDEOPS_ENGAGEMENT=${guess}\n`
141
+ )
142
+ return null
143
+ }
144
+ process.stderr.write(`⚠ resolved engagement by directory name ("${slugGuess}"), not a saved binding (read-only). If this is the right client, run \`fde resume --init ${slugGuess}\` here to bind it before logging or debriefing.\n`)
135
145
  return guess
136
146
  }
137
147
  // 5) in-repo .fde (engagement-approved only)
@@ -156,6 +166,49 @@ function readEng(eng, f) {
156
166
  // a markdown file, so a forgotten stripPrivate() call can't leak a <private> block.
157
167
  function readClean(eng, f) { return stripPrivate(readEng(eng, f)) }
158
168
 
169
+ // CLI-owned append-only mirror of [signal:x] lines. Survives an agent rewrite
170
+ // that drops stakeholders.md "## Signal history" - skill discipline still
171
+ // matters, but CLI-logged trust tokens must not vanish with the markdown.
172
+ const SIGNAL_LEDGER = '.signal-ledger'
173
+
174
+ // Exclusive create lock + retry. Two parallel agent sessions (or hook + CLI)
175
+ // appending the same .fde file otherwise interleave/corrupt under load.
176
+ function withFileLock(targetPath, fn) {
177
+ const lockPath = targetPath + '.lock'
178
+ const deadline = Date.now() + 5000
179
+ while (true) {
180
+ let fd
181
+ try {
182
+ fd = fs.openSync(lockPath, 'wx')
183
+ } catch (e) {
184
+ if (e.code !== 'EEXIST') throw e
185
+ if (Date.now() > deadline) {
186
+ console.error(`could not lock ${path.basename(targetPath)} - another writer is active; retry`)
187
+ process.exit(1)
188
+ }
189
+ const waitUntil = Date.now() + 20
190
+ while (Date.now() < waitUntil) { /* spin */ }
191
+ continue
192
+ }
193
+ try {
194
+ return fn()
195
+ } finally {
196
+ try { fs.closeSync(fd) } catch (_) {}
197
+ try { fs.unlinkSync(lockPath) } catch (_) {}
198
+ }
199
+ }
200
+ }
201
+
202
+ function atomicWriteFile(p, content) {
203
+ const tmp = `${p}.${process.pid}.${Date.now()}.tmp`
204
+ fs.writeFileSync(tmp, content)
205
+ fs.renameSync(tmp, p)
206
+ }
207
+
208
+ function lockedAppendFile(p, text) {
209
+ withFileLock(p, () => { fs.appendFileSync(p, text) })
210
+ }
211
+
159
212
  // Pull the body under a "## Heading" up to the next "##" (or EOF).
160
213
  function sectionBody(md, heading) {
161
214
  const lines = md.split('\n')
@@ -169,6 +222,49 @@ function sectionBody(md, heading) {
169
222
  return body.join('\n').trim()
170
223
  }
171
224
 
225
+ // Append `entry` as the last line of a "## Heading" section, creating the
226
+ // section at end-of-file if it doesn't exist yet. Plain fs.appendFileSync
227
+ // would land the entry after ANY later section the agent added (e.g. a
228
+ // "## Notes" heading appended after "## Signal history"), silently moving a
229
+ // signal token outside the section the reader scans - this keeps it inside
230
+ // regardless of what follows.
231
+ function appendUnderSection(md, heading, entry) {
232
+ const lines = md.split('\n')
233
+ const start = lines.findIndex(l => new RegExp('^#{1,6}\\s+' + heading + '\\b', 'i').test(l.trim()))
234
+ if (start === -1) {
235
+ const sep = md.length && !md.endsWith('\n') ? '\n' : ''
236
+ return `${md}${sep}\n## ${heading}\n\n${entry}\n`
237
+ }
238
+ let end = lines.length
239
+ for (let i = start + 1; i < lines.length; i++) {
240
+ if (/^#{1,6}\s/.test(lines[i].trim())) { end = i; break }
241
+ }
242
+ const before = lines.slice(0, end)
243
+ const after = lines.slice(end)
244
+ while (before.length > start + 1 && before[before.length - 1].trim() === '') before.pop()
245
+ before.push(entry)
246
+ if (after.length) before.push('')
247
+ return before.concat(after).join('\n')
248
+ }
249
+
250
+ // Shared by cmdLog and cmdDebrief so the two writers can't drift (the earlier
251
+ // bug: fde log's format and fde debrief's format both existed, only one of
252
+ // them matched what extractStakeholders actually read). A contact entry
253
+ // carrying a [signal:x] token - however it got there - lands inside
254
+ // "## Signal history"; everything else is a plain end-of-file append.
255
+ function appendLogEntry(eng, type, entry) {
256
+ const p = path.join(eng, LOG_FILES[type])
257
+ if (type === 'contact' && /\[signal:(red|amber|green)\]/i.test(entry)) {
258
+ withFileLock(p, () => {
259
+ atomicWriteFile(p, appendUnderSection(readEng(eng, LOG_FILES[type]), 'Signal history', entry))
260
+ })
261
+ // Durable CLI ledger - not rewritten by agent artifact passes.
262
+ lockedAppendFile(path.join(eng, SIGNAL_LEDGER), `${entry}\n`)
263
+ } else {
264
+ lockedAppendFile(p, `\n${entry}\n`)
265
+ }
266
+ }
267
+
172
268
  // phase / trust / top risk / freshness - identical heuristic for status + dashboard.
173
269
  // Trust resolution: structured [signal:red|amber|green] tokens in stakeholders.md
174
270
  // (written by `fde log contact --signal` and `fde debrief`) win - the latest dated
@@ -180,9 +276,11 @@ function computeSignals(eng) {
180
276
  // readClean, not readEng: status/dashboard echo topRisk and stakeholder lines
181
277
  // to the terminal and the rendered HTML - a <private> risk must never surface.
182
278
  const ctx = readClean(eng, 'context.md'); const stake = readClean(eng, 'stakeholders.md'); const risks = readClean(eng, 'risks.md')
279
+ // Prefer structured tokens from stakeholders + CLI ledger (ledger survives wipes)
280
+ const signalText = stake + '\n' + readClean(eng, SIGNAL_LEDGER)
183
281
  const phase = (ctx.match(/phase[:* ]+\**([a-z-]+)/i) || [])[1] || '?'
184
282
  let latest = null
185
- for (const l of stake.split('\n')) {
283
+ for (const l of signalText.split('\n')) {
186
284
  const sm = l.match(/\[signal:(red|amber|green)\]/i)
187
285
  if (!sm) continue
188
286
  const date = (l.match(/\[(\d{4}-\d{2}-\d{2})\]/) || [])[1] || ''
@@ -305,9 +403,21 @@ function extractStakeholders(eng) {
305
403
  const notesIdx = colIndex(headers, /notes?/i)
306
404
 
307
405
  const history = []
308
- sectionBody(md, 'Signal history').split('\n').forEach(l => {
309
- const m = l.trim().match(/^-\s*\[(\d{4}-\d{2}-\d{2})\]\s*\[signal:(red|amber|green)\]\s*(.+)$/i)
310
- if (m) history.push({ date: m[1], signal: m[2].toLowerCase(), text: m[3] })
406
+ // Format-agnostic on token position: `fde log contact --signal` writes
407
+ // "[date] [signal:x] text" (token right after the date), but `fde debrief`
408
+ // appends the token at the END of whatever the agent wrote per the skill's
409
+ // own contact: convention - "[date] text [signal:x]". Both are subject-first
410
+ // once the token is stripped, so match the token anywhere on the line rather
411
+ // than requiring it immediately after the date; a debrief-written signal was
412
+ // silently invisible to per-stakeholder matching before this.
413
+ const histText = sectionBody(md, 'Signal history') + '\n' + readEng(eng, SIGNAL_LEDGER)
414
+ histText.split('\n').forEach(l => {
415
+ const dm = l.trim().match(/^-\s*\[(\d{4}-\d{2}-\d{2})\]\s*(.*)$/i)
416
+ if (!dm) return
417
+ const sm = dm[2].match(/\[signal:(red|amber|green)\]/i)
418
+ if (!sm) return
419
+ const text = dm[2].replace(/\[signal:(red|amber|green)\]/i, '').trim()
420
+ history.push({ date: dm[1], signal: sm[1].toLowerCase(), text })
311
421
  })
312
422
 
313
423
  return rows.map(cs => {
@@ -420,7 +530,8 @@ function extractLog(eng) {
420
530
  sectionBody(readClean(eng, 'risks.md'), 'Retired').split('\n').forEach(l => {
421
531
  const m = l.trim().match(FLAT); if (m) push(m[1], m[2], 'receipt')
422
532
  })
423
- sectionBody(readClean(eng, 'stakeholders.md'), 'Signal history').split('\n').forEach(l => {
533
+ const signalLog = sectionBody(readClean(eng, 'stakeholders.md'), 'Signal history') + '\n' + readClean(eng, SIGNAL_LEDGER)
534
+ signalLog.split('\n').forEach(l => {
424
535
  const m = l.trim().match(FLAT); if (m) push(m[1], m[2], 'note')
425
536
  })
426
537
 
@@ -542,7 +653,7 @@ function cmdResume(args) {
542
653
  const prev = readRegistry().find(r => r.workspace === cwd)
543
654
  const kept = readRegistry().filter(r => r.workspace !== cwd).map(r => `${r.workspace} ${r.slug}`)
544
655
  kept.push(`${cwd} ${slug}`)
545
- fs.writeFileSync(REGISTRY, kept.join('\n') + '\n')
656
+ withFileLock(REGISTRY, () => { atomicWriteFile(REGISTRY, kept.join('\n') + '\n') })
546
657
  console.log(`ENGAGEMENT READY: ${fdeDir}\nbound to workspace: ${cwd}`)
547
658
  if (prev && prev.slug !== slug) console.log(`rebound: this workspace previously wrote to "${prev.slug}" - that memory is untouched; sessions here now write to "${slug}"`)
548
659
  // NDA surface: engagement notes must not silently leave the machine via file sync
@@ -617,10 +728,11 @@ function cmdLog(args) {
617
728
  const type = args[0]; const text = args.slice(1).join(' ')
618
729
  if (!LOG_FILES[type] || !text) { console.error('usage: fde log <decision|risk|delivery|contact> <text> [--signal red|amber|green]'); process.exit(1) }
619
730
  if (signal && type !== 'contact') { console.error('--signal only applies to: fde log contact'); process.exit(1) }
620
- const eng = resolveEngagement()
731
+ const eng = resolveEngagement({ forWrite: true })
621
732
  if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
622
733
  const date = new Date().toISOString().slice(0, 10)
623
- fs.appendFileSync(path.join(eng, LOG_FILES[type]), `\n- [${date}] ${signal ? `[signal:${signal}] ` : ''}${text}\n`)
734
+ const entry = `- [${date}] ${signal ? `[signal:${signal}] ` : ''}${text}`
735
+ appendLogEntry(eng, type, entry)
624
736
  console.log(`logged → ${LOG_FILES[type]}${signal ? ` (signal:${signal})` : ''}`)
625
737
  }
626
738
 
@@ -639,7 +751,7 @@ function cmdDebrief(args) {
639
751
  const dryIdx = args.indexOf('--dry-run')
640
752
  const dry = dryIdx !== -1
641
753
  if (dry) args.splice(dryIdx, 1)
642
- const eng = resolveEngagement()
754
+ const eng = resolveEngagement({ forWrite: true })
643
755
  if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
644
756
  let input = ''
645
757
  if (args[0]) {
@@ -676,15 +788,19 @@ function cmdDebrief(args) {
676
788
  const m = bare.match(/^(decision|risk|delivery|contact):\s*(.+)$/i)
677
789
  if (m) {
678
790
  const type = m[1].toLowerCase()
679
- if (dry) console.log(`→ ${LOG_FILES[type]} - [${date}] ${m[2]}`)
680
- else fs.appendFileSync(path.join(eng, LOG_FILES[type]), `\n- [${date}] ${m[2]}\n`)
791
+ const entry = `- [${date}] ${m[2]}`
792
+ if (dry) console.log(`→ ${LOG_FILES[type]} ${entry}`)
793
+ // appendLogEntry, not a blind append: a contact: line may carry an
794
+ // inline [signal:x] token (the skill's own convention) and must land
795
+ // inside "## Signal history" the same way `fde log --signal` does.
796
+ else appendLogEntry(eng, type, entry)
681
797
  counts[type]++
682
798
  } else ctxLines.push(line)
683
799
  }
684
800
  if (ctxLines.length) {
685
801
  const stamp = `${date} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
686
802
  if (dry) ctxLines.forEach(l => console.log(`→ context.md - ${l}`))
687
- else fs.appendFileSync(path.join(eng, 'context.md'), `\n## Debrief - ${stamp}\n${ctxLines.map(l => `- ${l}`).join('\n')}\n`)
803
+ else lockedAppendFile(path.join(eng, 'context.md'), `\n## Debrief - ${stamp}\n${ctxLines.map(l => `- ${l}`).join('\n')}\n`)
688
804
  }
689
805
  const plural = { decision: 'decisions', risk: 'risks', delivery: 'deliveries', contact: 'contacts' }
690
806
  const parts = Object.keys(counts).filter(t => counts[t])
@@ -739,7 +855,7 @@ function cmdReceipts(args) {
739
855
  }
740
856
 
741
857
  function cmdCapture() {
742
- const eng = resolveEngagement()
858
+ const eng = resolveEngagement({ forWrite: true })
743
859
  if (!eng) process.exit(0) // silent: capture must never break a session
744
860
  const branch = sh('git branch --show-current')
745
861
  const lastCommit = sh("git log -1 --format='%h %s'").slice(0, 100)
@@ -757,7 +873,7 @@ function cmdCapture() {
757
873
  if (branch) block += `- workspace: \`${branch}\` @ ${lastCommit || 'no commits yet'}\n`
758
874
  if (changed) block += `- uncommitted: ${changed}\n`
759
875
  if (updated) block += `- engagement files updated: ${updated}\n`
760
- try { fs.appendFileSync(path.join(eng, 'context.md'), block) } catch (_) {}
876
+ try { lockedAppendFile(path.join(eng, 'context.md'), block) } catch (_) {}
761
877
  }
762
878
 
763
879
  function engagementSlugFromPath(eng) {
@@ -1554,18 +1670,8 @@ ${clientViews}
1554
1670
  }
1555
1671
  }
1556
1672
 
1557
- const [cmd, ...args] = process.argv.slice(2)
1558
- switch (cmd) {
1559
- case 'scan': cmdScan(); break
1560
- case 'resume': cmdResume(args); break
1561
- case 'log': cmdLog(args); break
1562
- case 'debrief': cmdDebrief(args); break
1563
- case 'receipts': cmdReceipts(args); break
1564
- case 'capture': cmdCapture(); break
1565
- case 'status': cmdStatus(args); break
1566
- case 'dashboard': cmdDashboard(args); break
1567
- default:
1568
- console.log(`fde - deterministic core of fdeops
1673
+ function printUsage() {
1674
+ console.log(`fde - deterministic core of fdeops
1569
1675
  fde scan day-1 recon of this repo (facts, no AI)
1570
1676
  fde resume load this workspace's engagement memory (bounded)
1571
1677
  fde resume --full load the complete context.md (no bound)
@@ -1577,5 +1683,27 @@ switch (cmd) {
1577
1683
  fde capture session-end memory snapshot (hooks use this)
1578
1684
  fde status [--all] current engagement status (pass --all for full portfolio)
1579
1685
  fde dashboard [--all] current engagement fieldbook (pass --all for every client)
1580
- env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)`)
1686
+ env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)
1687
+ writes require a workspace bind (or FDEOPS_ENGAGEMENT) - folder-name match is read-only`)
1688
+ }
1689
+
1690
+ const [cmd, ...args] = process.argv.slice(2)
1691
+ switch (cmd) {
1692
+ case 'scan': cmdScan(); break
1693
+ case 'resume': cmdResume(args); break
1694
+ case 'log': cmdLog(args); break
1695
+ case 'debrief': cmdDebrief(args); break
1696
+ case 'receipts': cmdReceipts(args); break
1697
+ case 'capture': cmdCapture(); break
1698
+ case 'status': cmdStatus(args); break
1699
+ case 'dashboard': cmdDashboard(args); break
1700
+ case 'help':
1701
+ case '-h':
1702
+ case '--help':
1703
+ printUsage()
1704
+ break
1705
+ default:
1706
+ printUsage()
1707
+ // Missing or unknown command must fail - exit 0 made typos look like success in scripts/hooks.
1708
+ process.exit(1)
1581
1709
  }
package/bin/install.js CHANGED
@@ -142,6 +142,16 @@ function cmdAdapters(targetDir) {
142
142
  console.log(` fdeops cross-platform adapters → ${dest}`)
143
143
  console.log(' One brain (skills/fde/SKILL.md). These are thin pointers per tool.')
144
144
  console.log('')
145
+ // The pointers below all point at ~/.claude/skills/fde/SKILL.md. Only the
146
+ // default install (bare `npx fdeops` / `node bin/install.js`) used to place
147
+ // that file - `adapters` alone wrote pointers to a brain that didn't exist
148
+ // yet, a dangling reference for anyone following the documented Cursor/Codex
149
+ // path. installSkills() is idempotent (safe to call every run).
150
+ if (!fs.existsSync(path.join(GLOBAL_SKILLS_DIR, 'fde', 'SKILL.md'))) {
151
+ installSkills()
152
+ console.log(' Skills → ~/.claude/skills/ (installed - the pointers below need this)')
153
+ console.log('')
154
+ }
145
155
  for (const a of ADAPTER_TARGETS) {
146
156
  if (!fs.existsSync(a.src)) { console.log(` skip ${a.label} (template missing)`); continue }
147
157
  placePointer(path.join(dest, a.dest), fs.readFileSync(a.src, 'utf8'), a.label, a.appendable)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.7.7",
3
+ "version": "3.8.0",
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",
@@ -28,6 +28,7 @@ This is what makes fdeops a second brain instead of a chat window.
28
28
  4. **No invented facts - ever.** People, names, quotes, meetings, and numbers exist only if the FDE said them or the repo shows them. Never invent a stakeholder, a conversation, or a source to make the narrative richer - one fabricated name poisons every real citation around it. A missing fact is written as `unknown - ask: <the question>`, nothing else.
29
29
  5. **On exit:** before the session ends, append three lines to `context.md`: where we are, what changed today, the next step. The `session-stop` hook backstops this deterministically (hooks resolve the engagement through the workspace registry - no env var needed), but you write the meaningful version.
30
30
  6. **One customer, one folder.** Never merge two engagements into one `.fde/`. Confirm which engagement applies when multiple exist.
31
+ 7. **Never delete a code-read section when rewriting an artifact.** `stakeholders.md`'s `## Signal history` holds dated `[signal:...]` tokens that `fde status`/`fde receipts`/the dashboard read verbatim; `risks.md`'s `## Retired` is read the same way. Rewriting either file as an artifact (land, audit, stakeholder-radar) is fine - dropping one of these sections is not. Carry existing entries forward untouched.
31
32
 
32
33
  ## Data boundary (confirm before touching their code)
33
34
 
@@ -37,7 +38,7 @@ This is what makes fdeops a second brain instead of a chat window.
37
38
  - Data tagged `<private>` in `trust-profile.md` (sacred data, PHI, cardholder, classified) **never enters your context or any subagent prompt** - work around it, never with it.
38
39
  - 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.
39
40
 
40
- **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 → `./.fde`. 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.
41
+ **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.
41
42
 
42
43
  **The `fde` CLI does the deterministic work - use it instead of improvising shell:**
43
44
 
@@ -69,6 +69,7 @@ Before the end of day 1, ship one visible thing: a small bug fix, a cleanup the
69
69
  |-----|------|--------|-------|
70
70
  | <name> | sponsor / champion / resistor / veto / passed-over | green/amber/red | <evidence, day> |
71
71
  ```
72
+ If `stakeholders.md` already has a `## Signal history` section (it does from the template), **never delete or overwrite it** when you rewrite this file - it holds the dated `[signal:...]` tokens `fde log contact --signal` and `fde debrief` write, and `fde status`/`fde receipts`/the dashboard read only from that section. Edit the table above it freely; keep the section below intact.
72
73
 
73
74
  **`trust-profile.md`** - sacred data (`<private>` tagged), fears heard, AI policy, approval chain. Sensitive: never loaded for status reads, never into subagent prompts.
74
75
 
@@ -8,3 +8,11 @@
8
8
 
9
9
  **Trust signal:** green | amber | red
10
10
  **Last trust check:**
11
+
12
+ ## Signal history
13
+
14
+ <!-- `fde log contact --signal <color>` and `fde debrief` write dated tokens here.
15
+ fde status/receipts read the LATEST dated [signal:...] token in this section
16
+ to drive the trust column - do not delete this heading, and if you rewrite
17
+ this file as an artifact, keep this section's entries intact. -->
18
+