fdeops 3.8.0 → 3.8.2

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.
Files changed (2) hide show
  1. package/bin/fde.js +278 -35
  2. package/package.json +1 -1
package/bin/fde.js CHANGED
@@ -170,10 +170,93 @@ function readClean(eng, f) { return stripPrivate(readEng(eng, f)) }
170
170
  // that drops stakeholders.md "## Signal history" - skill discipline still
171
171
  // matters, but CLI-logged trust tokens must not vanish with the markdown.
172
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
+ // Map Node fs errno codes to one-line field messages - never dump a stack at an FDE.
221
+ function formatFsError(err, action, target) {
222
+ const code = err && err.code
223
+ const where = path.basename(String(target || '')) || String(target || 'path')
224
+ if (code === 'ENOSPC') return `cannot ${action} ${where} - disk full`
225
+ if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') {
226
+ return `cannot ${action} ${where} - permission denied (read-only or locked down)`
227
+ }
228
+ if (code === 'ELOOP') return `cannot ${action} ${where} - symlink loop`
229
+ if (code === 'ENOENT') return `cannot ${action} ${where} - path missing`
230
+ return `cannot ${action} ${where}${code ? ` (${code})` : ''}${err && err.message && !code ? ': ' + err.message : ''}`
231
+ }
232
+
233
+ function failFs(err, action, target) {
234
+ console.error(formatFsError(err, action, target))
235
+ process.exit(1)
236
+ }
237
+
238
+ // Refuse writes that would follow a symlink out of the engagement tree.
239
+ // Missing path is fine (new file). Soft mode returns the message instead of exiting
240
+ // (session capture must never crash a hook).
241
+ function refuseSymlinkWrite(p, opts = {}) {
242
+ try {
243
+ if (fs.lstatSync(p).isSymbolicLink()) {
244
+ const msg = `refused: ${path.basename(p)} is a symlink - write would leave the engagement tree. Replace it with a real file.`
245
+ if (opts.soft) return msg
246
+ console.error(msg)
247
+ process.exit(1)
248
+ }
249
+ } catch (e) {
250
+ if (e.code === 'ENOENT') return null
251
+ if (opts.soft) return formatFsError(e, 'check', p)
252
+ failFs(e, 'check', p)
253
+ }
254
+ return null
255
+ }
173
256
 
174
257
  // Exclusive create lock + retry. Two parallel agent sessions (or hook + CLI)
175
258
  // appending the same .fde file otherwise interleave/corrupt under load.
176
- function withFileLock(targetPath, fn) {
259
+ function withFileLock(targetPath, fn, opts = {}) {
177
260
  const lockPath = targetPath + '.lock'
178
261
  const deadline = Date.now() + 5000
179
262
  while (true) {
@@ -181,14 +264,19 @@ function withFileLock(targetPath, fn) {
181
264
  try {
182
265
  fd = fs.openSync(lockPath, 'wx')
183
266
  } 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)
267
+ if (e.code === 'EEXIST') {
268
+ if (Date.now() > deadline) {
269
+ const msg = `could not lock ${path.basename(targetPath)} - another writer is active; retry`
270
+ if (opts.soft) throw Object.assign(new Error(msg), { code: 'ELOCKED' })
271
+ console.error(msg)
272
+ process.exit(1)
273
+ }
274
+ const waitUntil = Date.now() + 20
275
+ while (Date.now() < waitUntil) { /* spin */ }
276
+ continue
188
277
  }
189
- const waitUntil = Date.now() + 20
190
- while (Date.now() < waitUntil) { /* spin */ }
191
- continue
278
+ if (opts.soft) throw e
279
+ failFs(e, 'lock', targetPath)
192
280
  }
193
281
  try {
194
282
  return fn()
@@ -199,14 +287,39 @@ function withFileLock(targetPath, fn) {
199
287
  }
200
288
  }
201
289
 
202
- function atomicWriteFile(p, content) {
290
+ function atomicWriteFile(p, content, opts = {}) {
291
+ const blocked = refuseSymlinkWrite(p, opts)
292
+ if (blocked) {
293
+ if (opts.soft) throw Object.assign(new Error(blocked), { code: 'ESYMLINK' })
294
+ return
295
+ }
203
296
  const tmp = `${p}.${process.pid}.${Date.now()}.tmp`
204
- fs.writeFileSync(tmp, content)
205
- fs.renameSync(tmp, p)
297
+ try {
298
+ fs.writeFileSync(tmp, content)
299
+ fs.renameSync(tmp, p)
300
+ } catch (e) {
301
+ try { fs.unlinkSync(tmp) } catch (_) {}
302
+ if (opts.soft) throw e
303
+ failFs(e, 'write', p)
304
+ }
206
305
  }
207
306
 
208
- function lockedAppendFile(p, text) {
209
- withFileLock(p, () => { fs.appendFileSync(p, text) })
307
+ function lockedAppendFile(p, text, opts = {}) {
308
+ const blocked = refuseSymlinkWrite(p, opts)
309
+ if (blocked) {
310
+ if (opts.soft) throw Object.assign(new Error(blocked), { code: 'ESYMLINK' })
311
+ return
312
+ }
313
+ try {
314
+ withFileLock(p, () => { fs.appendFileSync(p, text) }, opts)
315
+ } catch (e) {
316
+ if (opts.soft) throw e
317
+ failFs(e, 'append', p)
318
+ }
319
+ }
320
+
321
+ function rmTreeQuiet(dir) {
322
+ try { fs.rmSync(dir, { recursive: true, force: true }) } catch (_) {}
210
323
  }
211
324
 
212
325
  // Pull the body under a "## Heading" up to the next "##" (or EOF).
@@ -263,6 +376,7 @@ function appendLogEntry(eng, type, entry) {
263
376
  } else {
264
377
  lockedAppendFile(p, `\n${entry}\n`)
265
378
  }
379
+ recordLastWrite(eng, LOG_FILES[type], entry)
266
380
  }
267
381
 
268
382
  // phase / trust / top risk / freshness - identical heuristic for status + dashboard.
@@ -272,6 +386,36 @@ function appendLogEntry(eng, type, entry) {
272
386
  // signal never silently drives triage. The keyword grep survives only as the
273
387
  // zero-effort floor when NO token exists anywhere - prose like "escalated to CTO,
274
388
  // resolved amicably" must not flip a client amber forever.
389
+ function stakeholdersMemoryHealth(eng) {
390
+ // Hostile handoff: binary / unparseable stakeholders must not read as healthy green.
391
+ let buf
392
+ try { buf = fs.readFileSync(path.join(eng, 'stakeholders.md')) } catch (_) {
393
+ return { ok: true, warn: '' }
394
+ }
395
+ if (buf.includes(0)) {
396
+ return { ok: false, warn: 'memory unreadable - verify (binary data in stakeholders.md)' }
397
+ }
398
+ const md = buf.toString('utf8')
399
+ const ledger = readEng(eng, SIGNAL_LEDGER)
400
+ if (/\[signal:(red|amber|green)\]/i.test(md + '\n' + ledger)) {
401
+ return { ok: true, warn: '' }
402
+ }
403
+ const trustLine = md.match(/\*\*Trust:\*\*\s*([A-Za-z?]+)/i)
404
+ if (trustLine && !/^(red|amber|green)$/i.test(trustLine[1])) {
405
+ return { ok: false, warn: 'memory unreadable - verify (invalid trust value)' }
406
+ }
407
+ const table = parseMdTable(md)
408
+ const meaningful = md.split('\n').filter(l => {
409
+ const t = l.trim()
410
+ return t && !t.startsWith('#') && !t.startsWith('<!--') && !/^\|?\s*:?-{3,}/.test(t)
411
+ }).length
412
+ // Content present but no table and no structured signal → do not invent "green"
413
+ if (meaningful >= 3 && !table) {
414
+ return { ok: false, warn: 'memory unreadable - verify (stakeholders.md unparseable)' }
415
+ }
416
+ return { ok: true, warn: '' }
417
+ }
418
+
275
419
  function computeSignals(eng) {
276
420
  // readClean, not readEng: status/dashboard echo topRisk and stakeholder lines
277
421
  // to the terminal and the rendered HTML - a <private> risk must never surface.
@@ -284,11 +428,17 @@ function computeSignals(eng) {
284
428
  const sm = l.match(/\[signal:(red|amber|green)\]/i)
285
429
  if (!sm) continue
286
430
  const date = (l.match(/\[(\d{4}-\d{2}-\d{2})\]/) || [])[1] || ''
287
- if (!latest || date >= latest.date) latest = { date, sig: sm[1].toLowerCase() }
431
+ const text = l.replace(/^\s*-\s*/, '').replace(/\[signal:(red|amber|green)\]/i, '').replace(/\[\d{4}-\d{2}-\d{2}\]/, '').trim()
432
+ if (!latest || date >= latest.date) latest = { date, sig: sm[1].toLowerCase(), text }
288
433
  }
289
- let trust, signalAge = null, stale = false
290
- if (latest) {
434
+ const mem = stakeholdersMemoryHealth(eng)
435
+ let trust, signalAge = null, stale = false, trustReason = ''
436
+ if (!mem.ok && !latest) {
437
+ trust = 'amber'
438
+ trustReason = mem.warn
439
+ } else if (latest) {
291
440
  trust = latest.sig === 'red' ? 'RED' : latest.sig
441
+ trustReason = (latest.text || '').slice(0, 80)
292
442
  if (latest.date) {
293
443
  signalAge = Math.max(0, Math.floor((Date.now() - Date.parse(latest.date)) / 86400000))
294
444
  stale = signalAge > 21
@@ -303,12 +453,14 @@ function computeSignals(eng) {
303
453
  return /^[-|]/.test(t) && t.length > 20 && !/^\|?[-\s|]+$/.test(t) &&
304
454
  !/risk\s*\|\s*status|mitigation/i.test(t) && !t.startsWith('<!--')
305
455
  }) || '').replace(/\|/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80)
456
+ // Prefer the trust trigger (signal / memory warn) over a random risk line when triage is not green
457
+ const reason = (trust !== 'green' && (trustReason || mem.warn)) ? (trustReason || mem.warn) : topRisk
306
458
  let updated = 'never', ageDays = Infinity
307
459
  try {
308
460
  ageDays = Math.floor((Date.now() - fs.statSync(path.join(eng, 'context.md')).mtimeMs) / 86400000)
309
461
  updated = ageDays === 0 ? 'today' : `${ageDays}d ago`
310
462
  } catch (_) {}
311
- return { phase, trust, signalAge, stale, topRisk, updated, ageDays }
463
+ return { phase, trust, signalAge, stale, topRisk, reason, memoryWarn: mem.warn, updated, ageDays }
312
464
  }
313
465
 
314
466
  // ---------- dashboard content extractors (best-effort, read-only) ----------
@@ -637,14 +789,50 @@ function cmdResume(args) {
637
789
  const tpl = templatesDir()
638
790
  if (!tpl) { console.error('templates not found - run from the fdeops clone or reinstall'); process.exit(1) }
639
791
  const slug = slugify(name)
640
- const fdeDir = path.join(ENGAGEMENTS_ROOT, slug, '.fde')
641
- fs.mkdirSync(fdeDir, { recursive: true })
642
- for (const f of fs.readdirSync(tpl)) {
643
- const src = path.join(tpl, f); const dst = path.join(fdeDir, f)
644
- if (fs.statSync(src).isDirectory()) fs.mkdirSync(dst, { recursive: true })
645
- else if (!fs.existsSync(dst)) fs.copyFileSync(src, dst)
792
+ const engRoot = path.join(ENGAGEMENTS_ROOT, slug)
793
+ const fdeDir = path.join(engRoot, '.fde')
794
+ const existed = fs.existsSync(fdeDir)
795
+
796
+ const fillTemplates = (destFde) => {
797
+ for (const f of fs.readdirSync(tpl)) {
798
+ const src = path.join(tpl, f); const dst = path.join(destFde, f)
799
+ if (fs.statSync(src).isDirectory()) fs.mkdirSync(dst, { recursive: true })
800
+ else if (!fs.existsSync(dst)) fs.copyFileSync(src, dst)
801
+ }
802
+ fs.mkdirSync(path.join(destFde, 'retrospectives'), { recursive: true })
646
803
  }
647
- fs.mkdirSync(path.join(fdeDir, 'retrospectives'), { recursive: true })
804
+
805
+ try {
806
+ if (!existed) {
807
+ // Atomic create: build under a staging dir, then rename into place.
808
+ // Disk-full / permission mid-copy must not leave a half-built engagement.
809
+ fs.mkdirSync(ENGAGEMENTS_ROOT, { recursive: true })
810
+ const stagingRoot = path.join(ENGAGEMENTS_ROOT, `.init-${slug}-${process.pid}`)
811
+ const stagingEng = path.join(stagingRoot, slug)
812
+ const stagingFde = path.join(stagingEng, '.fde')
813
+ rmTreeQuiet(stagingRoot)
814
+ try {
815
+ fs.mkdirSync(stagingFde, { recursive: true })
816
+ fillTemplates(stagingFde)
817
+ // If a partial engRoot exists from an older failed run, remove it first.
818
+ if (fs.existsSync(engRoot)) rmTreeQuiet(engRoot)
819
+ fs.renameSync(stagingEng, engRoot)
820
+ rmTreeQuiet(stagingRoot)
821
+ } catch (e) {
822
+ rmTreeQuiet(stagingRoot)
823
+ if (fs.existsSync(engRoot) && !fs.existsSync(path.join(engRoot, '.fde', 'context.md'))) {
824
+ rmTreeQuiet(engRoot)
825
+ }
826
+ failFs(e, 'create engagement', engRoot)
827
+ }
828
+ } else {
829
+ // Re-init / rebind: only fill missing template files in place.
830
+ fillTemplates(fdeDir)
831
+ }
832
+ } catch (e) {
833
+ failFs(e, 'init engagement', fdeDir)
834
+ }
835
+
648
836
  // bind THIS workspace to the engagement (zero ceremony next time).
649
837
  // A workspace binds to exactly ONE engagement: rebinding REPLACES the old
650
838
  // line - resolution is first-match-wins, so appending a second line would
@@ -715,8 +903,39 @@ function resumeView(md) {
715
903
  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}`
716
904
  }
717
905
 
906
+ function cmdLogUndo() {
907
+ const eng = resolveEngagement({ forWrite: true })
908
+ if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
909
+ const metaPath = path.join(eng, LAST_WRITE)
910
+ let meta
911
+ try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')) } catch (_) {
912
+ console.error('nothing to undo - no prior fde log/debrief write recorded')
913
+ process.exit(1)
914
+ }
915
+ if (!meta.file || !meta.entry) { console.error('corrupt .last-write - cannot undo'); process.exit(1) }
916
+ const target = path.join(eng, meta.file)
917
+ const before = readEng(eng, meta.file)
918
+ const after = removeExactEntryLine(before, meta.entry)
919
+ if (after == null) {
920
+ console.error(`cannot undo - entry no longer in ${meta.file} (edited by hand?). Remove it manually.`)
921
+ process.exit(1)
922
+ }
923
+ withFileLock(target, () => { atomicWriteFile(target, after.endsWith('\n') ? after : after + '\n') })
924
+ if (/\[signal:(red|amber|green)\]/i.test(meta.entry)) {
925
+ const ledgerPath = path.join(eng, SIGNAL_LEDGER)
926
+ const led = removeExactEntryLine(readEng(eng, SIGNAL_LEDGER), meta.entry)
927
+ if (led != null) withFileLock(ledgerPath, () => { atomicWriteFile(ledgerPath, led.endsWith('\n') ? led : led + '\n') })
928
+ }
929
+ try { fs.unlinkSync(metaPath) } catch (_) {}
930
+ console.log(`undid last write → ${meta.file}`)
931
+ }
932
+
718
933
  function cmdLog(args) {
719
934
  args = args.slice()
935
+ if (args[0] === '--undo') { cmdLogUndo(); return }
936
+ let force = false
937
+ const forceIdx = args.indexOf('--force')
938
+ if (forceIdx !== -1) { force = true; args.splice(forceIdx, 1) }
720
939
  // --signal red|amber|green (contact only) → structured token computeSignals trusts
721
940
  let signal = ''
722
941
  const sigIdx = args.indexOf('--signal')
@@ -726,10 +945,13 @@ function cmdLog(args) {
726
945
  args.splice(sigIdx, 2)
727
946
  }
728
947
  const type = args[0]; const text = args.slice(1).join(' ')
729
- if (!LOG_FILES[type] || !text) { console.error('usage: fde log <decision|risk|delivery|contact> <text> [--signal red|amber|green]'); process.exit(1) }
948
+ 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) }
730
949
  if (signal && type !== 'contact') { console.error('--signal only applies to: fde log contact'); process.exit(1) }
731
950
  const eng = resolveEngagement({ forWrite: true })
732
951
  if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
952
+ const hit = findSecretHit(text)
953
+ if (hit && !force) { refuseSecret('log text', hit); process.exit(1) }
954
+ if (hit && force) console.error(`warning: logging possible ${hit} (--force)`)
733
955
  const date = new Date().toISOString().slice(0, 10)
734
956
  const entry = `- [${date}] ${signal ? `[signal:${signal}] ` : ''}${text}`
735
957
  appendLogEntry(eng, type, entry)
@@ -751,6 +973,9 @@ function cmdDebrief(args) {
751
973
  const dryIdx = args.indexOf('--dry-run')
752
974
  const dry = dryIdx !== -1
753
975
  if (dry) args.splice(dryIdx, 1)
976
+ let force = false
977
+ const forceIdx = args.indexOf('--force')
978
+ if (forceIdx !== -1) { force = true; args.splice(forceIdx, 1) }
754
979
  const eng = resolveEngagement({ forWrite: true })
755
980
  if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
756
981
  let input = ''
@@ -788,14 +1013,26 @@ function cmdDebrief(args) {
788
1013
  const m = bare.match(/^(decision|risk|delivery|contact):\s*(.+)$/i)
789
1014
  if (m) {
790
1015
  const type = m[1].toLowerCase()
791
- const entry = `- [${date}] ${m[2]}`
1016
+ const body = m[2]
1017
+ const hit = findSecretHit(body)
1018
+ if (hit && !force) {
1019
+ console.error(`skipped ${type} line - looks like a ${hit}. Redact it, or re-run with --force.`)
1020
+ continue
1021
+ }
1022
+ const entry = `- [${date}] ${body}`
792
1023
  if (dry) console.log(`→ ${LOG_FILES[type]} ${entry}`)
793
1024
  // appendLogEntry, not a blind append: a contact: line may carry an
794
1025
  // inline [signal:x] token (the skill's own convention) and must land
795
1026
  // inside "## Signal history" the same way `fde log --signal` does.
796
1027
  else appendLogEntry(eng, type, entry)
797
1028
  counts[type]++
798
- } else ctxLines.push(line)
1029
+ } else {
1030
+ if (findSecretHit(line) && !force) {
1031
+ console.error('skipped context line - looks like a secret. Redact it, or re-run with --force.')
1032
+ continue
1033
+ }
1034
+ ctxLines.push(line)
1035
+ }
799
1036
  }
800
1037
  if (ctxLines.length) {
801
1038
  const stamp = `${date} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
@@ -873,7 +1110,7 @@ function cmdCapture() {
873
1110
  if (branch) block += `- workspace: \`${branch}\` @ ${lastCommit || 'no commits yet'}\n`
874
1111
  if (changed) block += `- uncommitted: ${changed}\n`
875
1112
  if (updated) block += `- engagement files updated: ${updated}\n`
876
- try { lockedAppendFile(path.join(eng, 'context.md'), block) } catch (_) {}
1113
+ try { lockedAppendFile(path.join(eng, 'context.md'), block, { soft: true }) } catch (_) {}
877
1114
  }
878
1115
 
879
1116
  function engagementSlugFromPath(eng) {
@@ -890,7 +1127,7 @@ function cmdStatus(args) {
890
1127
  const eng = path.join(ENGAGEMENTS_ROOT, d, '.fde')
891
1128
  if (!fs.existsSync(eng)) continue
892
1129
  const s = computeSignals(eng)
893
- 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) })
1130
+ 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) })
894
1131
  }
895
1132
  } else {
896
1133
  const eng = resolveEngagement()
@@ -899,7 +1136,7 @@ function cmdStatus(args) {
899
1136
  process.exit(2)
900
1137
  }
901
1138
  const s = computeSignals(eng)
902
- 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) })
1139
+ 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) })
903
1140
  }
904
1141
  if (!rows.length) { console.log('no engagements yet'); return }
905
1142
  const order = { RED: 0, amber: 1, green: 2 }
@@ -909,7 +1146,7 @@ function cmdStatus(args) {
909
1146
  // "amber?" = structured signal went stale (>21d) - reconfirm before trusting it
910
1147
  const label = r.trust + (r.stale ? '?' : '')
911
1148
  const sig = r.signalAge != null ? `signal ${r.signalAge}d old${r.stale ? ' (STALE - reconfirm)' : ''} ` : ''
912
- console.log(` [${label.padEnd(6)}] ${r.name.padEnd(24)} phase:${r.phase.padEnd(10)} updated:${r.updated.padEnd(8)} ${sig}${r.topRisk}`)
1149
+ console.log(` [${label.padEnd(6)}] ${r.name.padEnd(24)} phase:${r.phase.padEnd(10)} updated:${r.updated.padEnd(8)} ${sig}${r.reason}`)
913
1150
  }
914
1151
  if (!all) console.log('\n(current engagement only - pass --all for the full portfolio)')
915
1152
  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.')
@@ -1655,8 +1892,13 @@ ${clientViews}
1655
1892
  <script>${dashScript()}</script>
1656
1893
  </body></html>`
1657
1894
 
1658
- fs.mkdirSync(path.dirname(outPath), { recursive: true })
1659
- fs.writeFileSync(outPath, html)
1895
+ try {
1896
+ fs.mkdirSync(path.dirname(outPath), { recursive: true })
1897
+ refuseSymlinkWrite(outPath)
1898
+ fs.writeFileSync(outPath, html)
1899
+ } catch (e) {
1900
+ failFs(e, 'write fieldbook', outPath)
1901
+ }
1660
1902
  console.log(`fieldbook → ${outPath}`)
1661
1903
  console.log(`${engagements.length} engagement(s) rendered · ${counts.RED} red / ${counts.amber} amber / ${counts.green} green · 0 tokens (pure render)`)
1662
1904
  if (args.includes('--open')) {
@@ -1677,8 +1919,9 @@ function printUsage() {
1677
1919
  fde resume --full load the complete context.md (no bound)
1678
1920
  fde resume --init <name> create + bind engagement for this workspace (rebind replaces)
1679
1921
  fde resume --bind show what this workspace is bound to, and what resolves
1680
- fde log <type> <text> append decision|risk|delivery|contact (contact takes --signal red|amber|green)
1681
- fde debrief [file] meeting notes memory: decision:/risk:/delivery:/contact: lines route, rest → context.md (stdin if no file; --dry-run previews)
1922
+ fde log <type> <text> append decision|risk|delivery|contact (contact takes --signal red|amber|green; --force to allow secret-like text)
1923
+ fde log --undo remove the last CLI log/debrief entry from memory
1924
+ fde debrief [file] meeting notes → memory: decision:/risk:/delivery:/contact: lines route, rest → context.md (stdin if no file; --dry-run; --force)
1682
1925
  fde receipts <term> "what did we agree?" with dates
1683
1926
  fde capture session-end memory snapshot (hooks use this)
1684
1927
  fde status [--all] current engagement status (pass --all for full portfolio)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.8.0",
3
+ "version": "3.8.2",
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",