fdeops 3.8.0 → 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.
Files changed (2) hide show
  1. package/bin/fde.js +147 -12
  2. package/package.json +1 -1
package/bin/fde.js CHANGED
@@ -170,6 +170,52 @@ 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
+
173
219
 
174
220
  // Exclusive create lock + retry. Two parallel agent sessions (or hook + CLI)
175
221
  // appending the same .fde file otherwise interleave/corrupt under load.
@@ -263,6 +309,7 @@ function appendLogEntry(eng, type, entry) {
263
309
  } else {
264
310
  lockedAppendFile(p, `\n${entry}\n`)
265
311
  }
312
+ recordLastWrite(eng, LOG_FILES[type], entry)
266
313
  }
267
314
 
268
315
  // phase / trust / top risk / freshness - identical heuristic for status + dashboard.
@@ -272,6 +319,36 @@ function appendLogEntry(eng, type, entry) {
272
319
  // signal never silently drives triage. The keyword grep survives only as the
273
320
  // zero-effort floor when NO token exists anywhere - prose like "escalated to CTO,
274
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
+
275
352
  function computeSignals(eng) {
276
353
  // readClean, not readEng: status/dashboard echo topRisk and stakeholder lines
277
354
  // to the terminal and the rendered HTML - a <private> risk must never surface.
@@ -284,11 +361,17 @@ function computeSignals(eng) {
284
361
  const sm = l.match(/\[signal:(red|amber|green)\]/i)
285
362
  if (!sm) continue
286
363
  const date = (l.match(/\[(\d{4}-\d{2}-\d{2})\]/) || [])[1] || ''
287
- 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 }
288
366
  }
289
- let trust, signalAge = null, stale = false
290
- 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) {
291
373
  trust = latest.sig === 'red' ? 'RED' : latest.sig
374
+ trustReason = (latest.text || '').slice(0, 80)
292
375
  if (latest.date) {
293
376
  signalAge = Math.max(0, Math.floor((Date.now() - Date.parse(latest.date)) / 86400000))
294
377
  stale = signalAge > 21
@@ -303,12 +386,14 @@ function computeSignals(eng) {
303
386
  return /^[-|]/.test(t) && t.length > 20 && !/^\|?[-\s|]+$/.test(t) &&
304
387
  !/risk\s*\|\s*status|mitigation/i.test(t) && !t.startsWith('<!--')
305
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
306
391
  let updated = 'never', ageDays = Infinity
307
392
  try {
308
393
  ageDays = Math.floor((Date.now() - fs.statSync(path.join(eng, 'context.md')).mtimeMs) / 86400000)
309
394
  updated = ageDays === 0 ? 'today' : `${ageDays}d ago`
310
395
  } catch (_) {}
311
- return { phase, trust, signalAge, stale, topRisk, updated, ageDays }
396
+ return { phase, trust, signalAge, stale, topRisk, reason, memoryWarn: mem.warn, updated, ageDays }
312
397
  }
313
398
 
314
399
  // ---------- dashboard content extractors (best-effort, read-only) ----------
@@ -715,8 +800,39 @@ function resumeView(md) {
715
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}`
716
801
  }
717
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
+
718
830
  function cmdLog(args) {
719
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) }
720
836
  // --signal red|amber|green (contact only) → structured token computeSignals trusts
721
837
  let signal = ''
722
838
  const sigIdx = args.indexOf('--signal')
@@ -726,10 +842,13 @@ function cmdLog(args) {
726
842
  args.splice(sigIdx, 2)
727
843
  }
728
844
  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) }
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) }
730
846
  if (signal && type !== 'contact') { console.error('--signal only applies to: fde log contact'); process.exit(1) }
731
847
  const eng = resolveEngagement({ forWrite: true })
732
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)`)
733
852
  const date = new Date().toISOString().slice(0, 10)
734
853
  const entry = `- [${date}] ${signal ? `[signal:${signal}] ` : ''}${text}`
735
854
  appendLogEntry(eng, type, entry)
@@ -751,6 +870,9 @@ function cmdDebrief(args) {
751
870
  const dryIdx = args.indexOf('--dry-run')
752
871
  const dry = dryIdx !== -1
753
872
  if (dry) args.splice(dryIdx, 1)
873
+ let force = false
874
+ const forceIdx = args.indexOf('--force')
875
+ if (forceIdx !== -1) { force = true; args.splice(forceIdx, 1) }
754
876
  const eng = resolveEngagement({ forWrite: true })
755
877
  if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
756
878
  let input = ''
@@ -788,14 +910,26 @@ function cmdDebrief(args) {
788
910
  const m = bare.match(/^(decision|risk|delivery|contact):\s*(.+)$/i)
789
911
  if (m) {
790
912
  const type = m[1].toLowerCase()
791
- 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}`
792
920
  if (dry) console.log(`→ ${LOG_FILES[type]} ${entry}`)
793
921
  // appendLogEntry, not a blind append: a contact: line may carry an
794
922
  // inline [signal:x] token (the skill's own convention) and must land
795
923
  // inside "## Signal history" the same way `fde log --signal` does.
796
924
  else appendLogEntry(eng, type, entry)
797
925
  counts[type]++
798
- } 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
+ }
799
933
  }
800
934
  if (ctxLines.length) {
801
935
  const stamp = `${date} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
@@ -890,7 +1024,7 @@ function cmdStatus(args) {
890
1024
  const eng = path.join(ENGAGEMENTS_ROOT, d, '.fde')
891
1025
  if (!fs.existsSync(eng)) continue
892
1026
  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) })
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) })
894
1028
  }
895
1029
  } else {
896
1030
  const eng = resolveEngagement()
@@ -899,7 +1033,7 @@ function cmdStatus(args) {
899
1033
  process.exit(2)
900
1034
  }
901
1035
  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) })
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) })
903
1037
  }
904
1038
  if (!rows.length) { console.log('no engagements yet'); return }
905
1039
  const order = { RED: 0, amber: 1, green: 2 }
@@ -909,7 +1043,7 @@ function cmdStatus(args) {
909
1043
  // "amber?" = structured signal went stale (>21d) - reconfirm before trusting it
910
1044
  const label = r.trust + (r.stale ? '?' : '')
911
1045
  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}`)
1046
+ console.log(` [${label.padEnd(6)}] ${r.name.padEnd(24)} phase:${r.phase.padEnd(10)} updated:${r.updated.padEnd(8)} ${sig}${r.reason}`)
913
1047
  }
914
1048
  if (!all) console.log('\n(current engagement only - pass --all for the full portfolio)')
915
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.')
@@ -1677,8 +1811,9 @@ function printUsage() {
1677
1811
  fde resume --full load the complete context.md (no bound)
1678
1812
  fde resume --init <name> create + bind engagement for this workspace (rebind replaces)
1679
1813
  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)
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)
1682
1817
  fde receipts <term> "what did we agree?" with dates
1683
1818
  fde capture session-end memory snapshot (hooks use this)
1684
1819
  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.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",