fdeops 3.7.8 → 3.8.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/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,95 @@ 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
+ const LAST_WRITE = '.last-write'
174
+
175
+ // Heuristic secret shapes - warn/block CLI writes so a wrong-client paste is not silent.
176
+ // Not a scanner product; high-signal patterns an FDE actually pastes by mistake.
177
+ const SECRET_PATTERNS = [
178
+ { name: 'AWS access key id', re: /\bAKIA[0-9A-Z]{16}\b/ },
179
+ { name: 'GitHub token', re: /\bghp_[A-Za-z0-9]{20,}\b/ },
180
+ { name: 'GitHub fine-grained token', re: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/ },
181
+ { name: 'OpenAI-style key', re: /\bsk-[A-Za-z0-9]{20,}\b/ },
182
+ { name: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
183
+ { name: 'PEM private key', re: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/ },
184
+ { name: 'Bearer token', re: /\bBearer\s+[A-Za-z0-9._\-]{20,}\b/ },
185
+ ]
186
+
187
+ function findSecretHit(text) {
188
+ for (const p of SECRET_PATTERNS) {
189
+ if (p.re.test(String(text))) return p.name
190
+ }
191
+ return null
192
+ }
193
+
194
+ function refuseSecret(kind, hit) {
195
+ console.error(
196
+ `refused: ${kind} looks like a ${hit}.\n` +
197
+ `Do not log credentials into engagement memory. Redact first, or pass --force if this is intentional.\n` +
198
+ `If you already wrote one: fde log --undo`
199
+ )
200
+ }
201
+
202
+ function recordLastWrite(eng, file, entry) {
203
+ const p = path.join(eng, LAST_WRITE)
204
+ withFileLock(p, () => {
205
+ atomicWriteFile(p, JSON.stringify({ file, entry, at: new Date().toISOString() }) + '\n')
206
+ })
207
+ }
208
+
209
+ function removeExactEntryLine(md, entry) {
210
+ const target = entry.trim()
211
+ const lines = md.split('\n')
212
+ const idx = lines.findIndex(l => l.trim() === target)
213
+ if (idx === -1) return null
214
+ lines.splice(idx, 1)
215
+ while (idx < lines.length && lines[idx] === '') lines.splice(idx, 1)
216
+ return lines.join('\n')
217
+ }
218
+
219
+
220
+ // Exclusive create lock + retry. Two parallel agent sessions (or hook + CLI)
221
+ // appending the same .fde file otherwise interleave/corrupt under load.
222
+ function withFileLock(targetPath, fn) {
223
+ const lockPath = targetPath + '.lock'
224
+ const deadline = Date.now() + 5000
225
+ while (true) {
226
+ let fd
227
+ try {
228
+ fd = fs.openSync(lockPath, 'wx')
229
+ } catch (e) {
230
+ if (e.code !== 'EEXIST') throw e
231
+ if (Date.now() > deadline) {
232
+ console.error(`could not lock ${path.basename(targetPath)} - another writer is active; retry`)
233
+ process.exit(1)
234
+ }
235
+ const waitUntil = Date.now() + 20
236
+ while (Date.now() < waitUntil) { /* spin */ }
237
+ continue
238
+ }
239
+ try {
240
+ return fn()
241
+ } finally {
242
+ try { fs.closeSync(fd) } catch (_) {}
243
+ try { fs.unlinkSync(lockPath) } catch (_) {}
244
+ }
245
+ }
246
+ }
247
+
248
+ function atomicWriteFile(p, content) {
249
+ const tmp = `${p}.${process.pid}.${Date.now()}.tmp`
250
+ fs.writeFileSync(tmp, content)
251
+ fs.renameSync(tmp, p)
252
+ }
253
+
254
+ function lockedAppendFile(p, text) {
255
+ withFileLock(p, () => { fs.appendFileSync(p, text) })
256
+ }
257
+
159
258
  // Pull the body under a "## Heading" up to the next "##" (or EOF).
160
259
  function sectionBody(md, heading) {
161
260
  const lines = md.split('\n')
@@ -202,10 +301,15 @@ function appendUnderSection(md, heading, entry) {
202
301
  function appendLogEntry(eng, type, entry) {
203
302
  const p = path.join(eng, LOG_FILES[type])
204
303
  if (type === 'contact' && /\[signal:(red|amber|green)\]/i.test(entry)) {
205
- fs.writeFileSync(p, appendUnderSection(readEng(eng, LOG_FILES[type]), 'Signal history', entry))
304
+ withFileLock(p, () => {
305
+ atomicWriteFile(p, appendUnderSection(readEng(eng, LOG_FILES[type]), 'Signal history', entry))
306
+ })
307
+ // Durable CLI ledger - not rewritten by agent artifact passes.
308
+ lockedAppendFile(path.join(eng, SIGNAL_LEDGER), `${entry}\n`)
206
309
  } else {
207
- fs.appendFileSync(p, `\n${entry}\n`)
310
+ lockedAppendFile(p, `\n${entry}\n`)
208
311
  }
312
+ recordLastWrite(eng, LOG_FILES[type], entry)
209
313
  }
210
314
 
211
315
  // phase / trust / top risk / freshness - identical heuristic for status + dashboard.
@@ -215,21 +319,59 @@ function appendLogEntry(eng, type, entry) {
215
319
  // signal never silently drives triage. The keyword grep survives only as the
216
320
  // zero-effort floor when NO token exists anywhere - prose like "escalated to CTO,
217
321
  // resolved amicably" must not flip a client amber forever.
322
+ function stakeholdersMemoryHealth(eng) {
323
+ // Hostile handoff: binary / unparseable stakeholders must not read as healthy green.
324
+ let buf
325
+ try { buf = fs.readFileSync(path.join(eng, 'stakeholders.md')) } catch (_) {
326
+ return { ok: true, warn: '' }
327
+ }
328
+ if (buf.includes(0)) {
329
+ return { ok: false, warn: 'memory unreadable - verify (binary data in stakeholders.md)' }
330
+ }
331
+ const md = buf.toString('utf8')
332
+ const ledger = readEng(eng, SIGNAL_LEDGER)
333
+ if (/\[signal:(red|amber|green)\]/i.test(md + '\n' + ledger)) {
334
+ return { ok: true, warn: '' }
335
+ }
336
+ const trustLine = md.match(/\*\*Trust:\*\*\s*([A-Za-z?]+)/i)
337
+ if (trustLine && !/^(red|amber|green)$/i.test(trustLine[1])) {
338
+ return { ok: false, warn: 'memory unreadable - verify (invalid trust value)' }
339
+ }
340
+ const table = parseMdTable(md)
341
+ const meaningful = md.split('\n').filter(l => {
342
+ const t = l.trim()
343
+ return t && !t.startsWith('#') && !t.startsWith('<!--') && !/^\|?\s*:?-{3,}/.test(t)
344
+ }).length
345
+ // Content present but no table and no structured signal → do not invent "green"
346
+ if (meaningful >= 3 && !table) {
347
+ return { ok: false, warn: 'memory unreadable - verify (stakeholders.md unparseable)' }
348
+ }
349
+ return { ok: true, warn: '' }
350
+ }
351
+
218
352
  function computeSignals(eng) {
219
353
  // readClean, not readEng: status/dashboard echo topRisk and stakeholder lines
220
354
  // to the terminal and the rendered HTML - a <private> risk must never surface.
221
355
  const ctx = readClean(eng, 'context.md'); const stake = readClean(eng, 'stakeholders.md'); const risks = readClean(eng, 'risks.md')
356
+ // Prefer structured tokens from stakeholders + CLI ledger (ledger survives wipes)
357
+ const signalText = stake + '\n' + readClean(eng, SIGNAL_LEDGER)
222
358
  const phase = (ctx.match(/phase[:* ]+\**([a-z-]+)/i) || [])[1] || '?'
223
359
  let latest = null
224
- for (const l of stake.split('\n')) {
360
+ for (const l of signalText.split('\n')) {
225
361
  const sm = l.match(/\[signal:(red|amber|green)\]/i)
226
362
  if (!sm) continue
227
363
  const date = (l.match(/\[(\d{4}-\d{2}-\d{2})\]/) || [])[1] || ''
228
- if (!latest || date >= latest.date) latest = { date, sig: sm[1].toLowerCase() }
364
+ const text = l.replace(/^\s*-\s*/, '').replace(/\[signal:(red|amber|green)\]/i, '').replace(/\[\d{4}-\d{2}-\d{2}\]/, '').trim()
365
+ if (!latest || date >= latest.date) latest = { date, sig: sm[1].toLowerCase(), text }
229
366
  }
230
- let trust, signalAge = null, stale = false
231
- if (latest) {
367
+ const mem = stakeholdersMemoryHealth(eng)
368
+ let trust, signalAge = null, stale = false, trustReason = ''
369
+ if (!mem.ok && !latest) {
370
+ trust = 'amber'
371
+ trustReason = mem.warn
372
+ } else if (latest) {
232
373
  trust = latest.sig === 'red' ? 'RED' : latest.sig
374
+ trustReason = (latest.text || '').slice(0, 80)
233
375
  if (latest.date) {
234
376
  signalAge = Math.max(0, Math.floor((Date.now() - Date.parse(latest.date)) / 86400000))
235
377
  stale = signalAge > 21
@@ -244,12 +386,14 @@ function computeSignals(eng) {
244
386
  return /^[-|]/.test(t) && t.length > 20 && !/^\|?[-\s|]+$/.test(t) &&
245
387
  !/risk\s*\|\s*status|mitigation/i.test(t) && !t.startsWith('<!--')
246
388
  }) || '').replace(/\|/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80)
389
+ // Prefer the trust trigger (signal / memory warn) over a random risk line when triage is not green
390
+ const reason = (trust !== 'green' && (trustReason || mem.warn)) ? (trustReason || mem.warn) : topRisk
247
391
  let updated = 'never', ageDays = Infinity
248
392
  try {
249
393
  ageDays = Math.floor((Date.now() - fs.statSync(path.join(eng, 'context.md')).mtimeMs) / 86400000)
250
394
  updated = ageDays === 0 ? 'today' : `${ageDays}d ago`
251
395
  } catch (_) {}
252
- return { phase, trust, signalAge, stale, topRisk, updated, ageDays }
396
+ return { phase, trust, signalAge, stale, topRisk, reason, memoryWarn: mem.warn, updated, ageDays }
253
397
  }
254
398
 
255
399
  // ---------- dashboard content extractors (best-effort, read-only) ----------
@@ -351,7 +495,8 @@ function extractStakeholders(eng) {
351
495
  // once the token is stripped, so match the token anywhere on the line rather
352
496
  // than requiring it immediately after the date; a debrief-written signal was
353
497
  // silently invisible to per-stakeholder matching before this.
354
- sectionBody(md, 'Signal history').split('\n').forEach(l => {
498
+ const histText = sectionBody(md, 'Signal history') + '\n' + readEng(eng, SIGNAL_LEDGER)
499
+ histText.split('\n').forEach(l => {
355
500
  const dm = l.trim().match(/^-\s*\[(\d{4}-\d{2}-\d{2})\]\s*(.*)$/i)
356
501
  if (!dm) return
357
502
  const sm = dm[2].match(/\[signal:(red|amber|green)\]/i)
@@ -470,7 +615,8 @@ function extractLog(eng) {
470
615
  sectionBody(readClean(eng, 'risks.md'), 'Retired').split('\n').forEach(l => {
471
616
  const m = l.trim().match(FLAT); if (m) push(m[1], m[2], 'receipt')
472
617
  })
473
- sectionBody(readClean(eng, 'stakeholders.md'), 'Signal history').split('\n').forEach(l => {
618
+ const signalLog = sectionBody(readClean(eng, 'stakeholders.md'), 'Signal history') + '\n' + readClean(eng, SIGNAL_LEDGER)
619
+ signalLog.split('\n').forEach(l => {
474
620
  const m = l.trim().match(FLAT); if (m) push(m[1], m[2], 'note')
475
621
  })
476
622
 
@@ -592,7 +738,7 @@ function cmdResume(args) {
592
738
  const prev = readRegistry().find(r => r.workspace === cwd)
593
739
  const kept = readRegistry().filter(r => r.workspace !== cwd).map(r => `${r.workspace} ${r.slug}`)
594
740
  kept.push(`${cwd} ${slug}`)
595
- fs.writeFileSync(REGISTRY, kept.join('\n') + '\n')
741
+ withFileLock(REGISTRY, () => { atomicWriteFile(REGISTRY, kept.join('\n') + '\n') })
596
742
  console.log(`ENGAGEMENT READY: ${fdeDir}\nbound to workspace: ${cwd}`)
597
743
  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}"`)
598
744
  // NDA surface: engagement notes must not silently leave the machine via file sync
@@ -654,8 +800,39 @@ function resumeView(md) {
654
800
  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}`
655
801
  }
656
802
 
803
+ function cmdLogUndo() {
804
+ const eng = resolveEngagement({ forWrite: true })
805
+ if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
806
+ const metaPath = path.join(eng, LAST_WRITE)
807
+ let meta
808
+ try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')) } catch (_) {
809
+ console.error('nothing to undo - no prior fde log/debrief write recorded')
810
+ process.exit(1)
811
+ }
812
+ if (!meta.file || !meta.entry) { console.error('corrupt .last-write - cannot undo'); process.exit(1) }
813
+ const target = path.join(eng, meta.file)
814
+ const before = readEng(eng, meta.file)
815
+ const after = removeExactEntryLine(before, meta.entry)
816
+ if (after == null) {
817
+ console.error(`cannot undo - entry no longer in ${meta.file} (edited by hand?). Remove it manually.`)
818
+ process.exit(1)
819
+ }
820
+ withFileLock(target, () => { atomicWriteFile(target, after.endsWith('\n') ? after : after + '\n') })
821
+ if (/\[signal:(red|amber|green)\]/i.test(meta.entry)) {
822
+ const ledgerPath = path.join(eng, SIGNAL_LEDGER)
823
+ const led = removeExactEntryLine(readEng(eng, SIGNAL_LEDGER), meta.entry)
824
+ if (led != null) withFileLock(ledgerPath, () => { atomicWriteFile(ledgerPath, led.endsWith('\n') ? led : led + '\n') })
825
+ }
826
+ try { fs.unlinkSync(metaPath) } catch (_) {}
827
+ console.log(`undid last write → ${meta.file}`)
828
+ }
829
+
657
830
  function cmdLog(args) {
658
831
  args = args.slice()
832
+ if (args[0] === '--undo') { cmdLogUndo(); return }
833
+ let force = false
834
+ const forceIdx = args.indexOf('--force')
835
+ if (forceIdx !== -1) { force = true; args.splice(forceIdx, 1) }
659
836
  // --signal red|amber|green (contact only) → structured token computeSignals trusts
660
837
  let signal = ''
661
838
  const sigIdx = args.indexOf('--signal')
@@ -665,10 +842,13 @@ function cmdLog(args) {
665
842
  args.splice(sigIdx, 2)
666
843
  }
667
844
  const type = args[0]; const text = args.slice(1).join(' ')
668
- if (!LOG_FILES[type] || !text) { console.error('usage: fde log <decision|risk|delivery|contact> <text> [--signal red|amber|green]'); process.exit(1) }
845
+ if (!LOG_FILES[type] || !text) { console.error('usage: fde log <decision|risk|delivery|contact> <text> [--signal red|amber|green] [--force]\n fde log --undo'); process.exit(1) }
669
846
  if (signal && type !== 'contact') { console.error('--signal only applies to: fde log contact'); process.exit(1) }
670
- const eng = resolveEngagement()
847
+ const eng = resolveEngagement({ forWrite: true })
671
848
  if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
849
+ const hit = findSecretHit(text)
850
+ if (hit && !force) { refuseSecret('log text', hit); process.exit(1) }
851
+ if (hit && force) console.error(`warning: logging possible ${hit} (--force)`)
672
852
  const date = new Date().toISOString().slice(0, 10)
673
853
  const entry = `- [${date}] ${signal ? `[signal:${signal}] ` : ''}${text}`
674
854
  appendLogEntry(eng, type, entry)
@@ -690,7 +870,10 @@ function cmdDebrief(args) {
690
870
  const dryIdx = args.indexOf('--dry-run')
691
871
  const dry = dryIdx !== -1
692
872
  if (dry) args.splice(dryIdx, 1)
693
- const eng = resolveEngagement()
873
+ let force = false
874
+ const forceIdx = args.indexOf('--force')
875
+ if (forceIdx !== -1) { force = true; args.splice(forceIdx, 1) }
876
+ const eng = resolveEngagement({ forWrite: true })
694
877
  if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
695
878
  let input = ''
696
879
  if (args[0]) {
@@ -727,19 +910,31 @@ function cmdDebrief(args) {
727
910
  const m = bare.match(/^(decision|risk|delivery|contact):\s*(.+)$/i)
728
911
  if (m) {
729
912
  const type = m[1].toLowerCase()
730
- const entry = `- [${date}] ${m[2]}`
913
+ const body = m[2]
914
+ const hit = findSecretHit(body)
915
+ if (hit && !force) {
916
+ console.error(`skipped ${type} line - looks like a ${hit}. Redact it, or re-run with --force.`)
917
+ continue
918
+ }
919
+ const entry = `- [${date}] ${body}`
731
920
  if (dry) console.log(`→ ${LOG_FILES[type]} ${entry}`)
732
921
  // appendLogEntry, not a blind append: a contact: line may carry an
733
922
  // inline [signal:x] token (the skill's own convention) and must land
734
923
  // inside "## Signal history" the same way `fde log --signal` does.
735
924
  else appendLogEntry(eng, type, entry)
736
925
  counts[type]++
737
- } else ctxLines.push(line)
926
+ } else {
927
+ if (findSecretHit(line) && !force) {
928
+ console.error('skipped context line - looks like a secret. Redact it, or re-run with --force.')
929
+ continue
930
+ }
931
+ ctxLines.push(line)
932
+ }
738
933
  }
739
934
  if (ctxLines.length) {
740
935
  const stamp = `${date} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
741
936
  if (dry) ctxLines.forEach(l => console.log(`→ context.md - ${l}`))
742
- else fs.appendFileSync(path.join(eng, 'context.md'), `\n## Debrief - ${stamp}\n${ctxLines.map(l => `- ${l}`).join('\n')}\n`)
937
+ else lockedAppendFile(path.join(eng, 'context.md'), `\n## Debrief - ${stamp}\n${ctxLines.map(l => `- ${l}`).join('\n')}\n`)
743
938
  }
744
939
  const plural = { decision: 'decisions', risk: 'risks', delivery: 'deliveries', contact: 'contacts' }
745
940
  const parts = Object.keys(counts).filter(t => counts[t])
@@ -794,7 +989,7 @@ function cmdReceipts(args) {
794
989
  }
795
990
 
796
991
  function cmdCapture() {
797
- const eng = resolveEngagement()
992
+ const eng = resolveEngagement({ forWrite: true })
798
993
  if (!eng) process.exit(0) // silent: capture must never break a session
799
994
  const branch = sh('git branch --show-current')
800
995
  const lastCommit = sh("git log -1 --format='%h %s'").slice(0, 100)
@@ -812,7 +1007,7 @@ function cmdCapture() {
812
1007
  if (branch) block += `- workspace: \`${branch}\` @ ${lastCommit || 'no commits yet'}\n`
813
1008
  if (changed) block += `- uncommitted: ${changed}\n`
814
1009
  if (updated) block += `- engagement files updated: ${updated}\n`
815
- try { fs.appendFileSync(path.join(eng, 'context.md'), block) } catch (_) {}
1010
+ try { lockedAppendFile(path.join(eng, 'context.md'), block) } catch (_) {}
816
1011
  }
817
1012
 
818
1013
  function engagementSlugFromPath(eng) {
@@ -829,7 +1024,7 @@ function cmdStatus(args) {
829
1024
  const eng = path.join(ENGAGEMENTS_ROOT, d, '.fde')
830
1025
  if (!fs.existsSync(eng)) continue
831
1026
  const s = computeSignals(eng)
832
- rows.push({ name: d, phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated, topRisk: s.topRisk.slice(0, 60) })
1027
+ rows.push({ name: d, phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated, reason: (s.reason || s.topRisk).slice(0, 60) })
833
1028
  }
834
1029
  } else {
835
1030
  const eng = resolveEngagement()
@@ -838,7 +1033,7 @@ function cmdStatus(args) {
838
1033
  process.exit(2)
839
1034
  }
840
1035
  const s = computeSignals(eng)
841
- rows.push({ name: engagementSlugFromPath(eng), phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated, topRisk: s.topRisk.slice(0, 60) })
1036
+ rows.push({ name: engagementSlugFromPath(eng), phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated, reason: (s.reason || s.topRisk).slice(0, 60) })
842
1037
  }
843
1038
  if (!rows.length) { console.log('no engagements yet'); return }
844
1039
  const order = { RED: 0, amber: 1, green: 2 }
@@ -848,7 +1043,7 @@ function cmdStatus(args) {
848
1043
  // "amber?" = structured signal went stale (>21d) - reconfirm before trusting it
849
1044
  const label = r.trust + (r.stale ? '?' : '')
850
1045
  const sig = r.signalAge != null ? `signal ${r.signalAge}d old${r.stale ? ' (STALE - reconfirm)' : ''} ` : ''
851
- console.log(` [${label.padEnd(6)}] ${r.name.padEnd(24)} phase:${r.phase.padEnd(10)} updated:${r.updated.padEnd(8)} ${sig}${r.topRisk}`)
1046
+ console.log(` [${label.padEnd(6)}] ${r.name.padEnd(24)} phase:${r.phase.padEnd(10)} updated:${r.updated.padEnd(8)} ${sig}${r.reason}`)
852
1047
  }
853
1048
  if (!all) console.log('\n(current engagement only - pass --all for the full portfolio)')
854
1049
  console.log('\ntrust: latest [signal:x] token in stakeholders.md wins (fde log contact --signal, fde debrief); keyword heuristic only when none exists - verify before acting.')
@@ -1609,6 +1804,24 @@ ${clientViews}
1609
1804
  }
1610
1805
  }
1611
1806
 
1807
+ function printUsage() {
1808
+ console.log(`fde - deterministic core of fdeops
1809
+ fde scan day-1 recon of this repo (facts, no AI)
1810
+ fde resume load this workspace's engagement memory (bounded)
1811
+ fde resume --full load the complete context.md (no bound)
1812
+ fde resume --init <name> create + bind engagement for this workspace (rebind replaces)
1813
+ fde resume --bind show what this workspace is bound to, and what resolves
1814
+ fde log <type> <text> append decision|risk|delivery|contact (contact takes --signal red|amber|green; --force to allow secret-like text)
1815
+ fde log --undo remove the last CLI log/debrief entry from memory
1816
+ fde debrief [file] meeting notes → memory: decision:/risk:/delivery:/contact: lines route, rest → context.md (stdin if no file; --dry-run; --force)
1817
+ fde receipts <term> "what did we agree?" with dates
1818
+ fde capture session-end memory snapshot (hooks use this)
1819
+ fde status [--all] current engagement status (pass --all for full portfolio)
1820
+ fde dashboard [--all] current engagement fieldbook (pass --all for every client)
1821
+ env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)
1822
+ writes require a workspace bind (or FDEOPS_ENGAGEMENT) - folder-name match is read-only`)
1823
+ }
1824
+
1612
1825
  const [cmd, ...args] = process.argv.slice(2)
1613
1826
  switch (cmd) {
1614
1827
  case 'scan': cmdScan(); break
@@ -1619,18 +1832,13 @@ switch (cmd) {
1619
1832
  case 'capture': cmdCapture(); break
1620
1833
  case 'status': cmdStatus(args); break
1621
1834
  case 'dashboard': cmdDashboard(args); break
1835
+ case 'help':
1836
+ case '-h':
1837
+ case '--help':
1838
+ printUsage()
1839
+ break
1622
1840
  default:
1623
- console.log(`fde - deterministic core of fdeops
1624
- fde scan day-1 recon of this repo (facts, no AI)
1625
- fde resume load this workspace's engagement memory (bounded)
1626
- fde resume --full load the complete context.md (no bound)
1627
- fde resume --init <name> create + bind engagement for this workspace (rebind replaces)
1628
- fde resume --bind show what this workspace is bound to, and what resolves
1629
- fde log <type> <text> append decision|risk|delivery|contact (contact takes --signal red|amber|green)
1630
- fde debrief [file] meeting notes → memory: decision:/risk:/delivery:/contact: lines route, rest → context.md (stdin if no file; --dry-run previews)
1631
- fde receipts <term> "what did we agree?" with dates
1632
- fde capture session-end memory snapshot (hooks use this)
1633
- fde status [--all] current engagement status (pass --all for full portfolio)
1634
- fde dashboard [--all] current engagement fieldbook (pass --all for every client)
1635
- env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)`)
1841
+ printUsage()
1842
+ // Missing or unknown command must fail - exit 0 made typos look like success in scripts/hooks.
1843
+ process.exit(1)
1636
1844
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.7.8",
3
+ "version": "3.8.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",
@@ -38,7 +38,7 @@ This is what makes fdeops a second brain instead of a chat window.
38
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.
39
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.
40
40
 
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 → `./.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.
42
42
 
43
43
  **The `fde` CLI does the deterministic work - use it instead of improvising shell:**
44
44