fdeops 3.8.2 → 3.8.3

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/bin/fde.js CHANGED
@@ -416,31 +416,82 @@ function stakeholdersMemoryHealth(eng) {
416
416
  return { ok: true, warn: '' }
417
417
  }
418
418
 
419
+ // Subject key for a signal-history line - first real name word (same spirit as
420
+ // extractStakeholders). A green about Randy must not clear an amber about Denise.
421
+ function signalSubjectKey(text) {
422
+ const words = String(text).replace(/\([^)]*\)/g, '').split(/\s+/).filter(w => w && !/^(dr|mr|mrs|ms)\.?$/i.test(w))
423
+ const frag = (words[0] || '').replace(/[^a-z0-9]/gi, '').toLowerCase()
424
+ return frag.length >= 3 ? frag : ('anon:' + String(text).slice(0, 48).toLowerCase())
425
+ }
426
+
427
+ function parsePhase(ctx) {
428
+ // Template ships "**Phase:** land | discover | ..." - that is UNSET, not land.
429
+ const m = ctx.match(/\*\*Phase:\*\*\s*(.+)/i) || ctx.match(/^phase[:\s*]+(.+)$/im)
430
+ if (!m) return '?'
431
+ const raw = m[1].replace(/\*/g, '').trim()
432
+ if (!raw || /\|/.test(raw) || /^unset$/i.test(raw) || /^[\[(]/.test(raw)) return '?'
433
+ const one = raw.toLowerCase().match(/^(land|discover|plan|build|ship|close)\b/)
434
+ return one ? one[1] : '?'
435
+ }
436
+
437
+ function countOpenRisks(eng) {
438
+ const md = readClean(eng, 'risks.md')
439
+ const body = md.split(/^#{1,6}\s+Retired\b/im)[0] || md
440
+ let n = 0
441
+ for (const raw of body.split('\n')) {
442
+ const t = raw.trim()
443
+ if (!t || t.startsWith('<!--') || /^#{1,6}\s/.test(t)) continue
444
+ if (/risk\s*\|\s*status|mitigation/i.test(t) || /^\|?[\s|:-]+$/.test(t)) continue
445
+ if (/^[-*]/.test(t) || (/^\|/.test(t) && t.length > 12)) n++
446
+ }
447
+ return n
448
+ }
449
+
450
+ function nextActionLine(ctx) {
451
+ const body = sectionBody(ctx, 'Next action')
452
+ for (const raw of body.split('\n')) {
453
+ const t = raw.trim().replace(/^[-*]\s+/, '')
454
+ if (t) return t.slice(0, 120)
455
+ }
456
+ return ''
457
+ }
458
+
419
459
  function computeSignals(eng) {
420
460
  // readClean, not readEng: status/dashboard echo topRisk and stakeholder lines
421
461
  // to the terminal and the rendered HTML - a <private> risk must never surface.
422
462
  const ctx = readClean(eng, 'context.md'); const stake = readClean(eng, 'stakeholders.md'); const risks = readClean(eng, 'risks.md')
423
463
  // Prefer structured tokens from stakeholders + CLI ledger (ledger survives wipes)
424
464
  const signalText = stake + '\n' + readClean(eng, SIGNAL_LEDGER)
425
- const phase = (ctx.match(/phase[:* ]+\**([a-z-]+)/i) || [])[1] || '?'
426
- let latest = null
465
+ const phase = parsePhase(ctx)
466
+ // Latest signal PER stakeholder, then worst-of those actives.
467
+ // Global "latest wins" let a green from person B hide a sponsor crisis on A.
468
+ const byPerson = new Map()
427
469
  for (const l of signalText.split('\n')) {
428
470
  const sm = l.match(/\[signal:(red|amber|green)\]/i)
429
471
  if (!sm) continue
430
472
  const date = (l.match(/\[(\d{4}-\d{2}-\d{2})\]/) || [])[1] || ''
431
473
  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 }
474
+ const key = signalSubjectKey(text)
475
+ const prev = byPerson.get(key)
476
+ if (!prev || date >= prev.date) byPerson.set(key, { date, sig: sm[1].toLowerCase(), text })
477
+ }
478
+ const RANK = { red: 0, amber: 1, green: 2 }
479
+ let worst = null
480
+ for (const s of byPerson.values()) {
481
+ if (!worst || RANK[s.sig] < RANK[worst.sig] || (RANK[s.sig] === RANK[worst.sig] && s.date >= worst.date)) {
482
+ worst = s
483
+ }
433
484
  }
434
485
  const mem = stakeholdersMemoryHealth(eng)
435
486
  let trust, signalAge = null, stale = false, trustReason = ''
436
- if (!mem.ok && !latest) {
487
+ if (!mem.ok && !worst) {
437
488
  trust = 'amber'
438
489
  trustReason = mem.warn
439
- } else if (latest) {
440
- trust = latest.sig === 'red' ? 'RED' : latest.sig
441
- trustReason = (latest.text || '').slice(0, 80)
442
- if (latest.date) {
443
- signalAge = Math.max(0, Math.floor((Date.now() - Date.parse(latest.date)) / 86400000))
490
+ } else if (worst) {
491
+ trust = worst.sig === 'red' ? 'RED' : worst.sig
492
+ trustReason = (worst.text || '').slice(0, 80)
493
+ if (worst.date) {
494
+ signalAge = Math.max(0, Math.floor((Date.now() - Date.parse(worst.date)) / 86400000))
444
495
  stale = signalAge > 21
445
496
  }
446
497
  } else {
@@ -455,14 +506,33 @@ function computeSignals(eng) {
455
506
  }) || '').replace(/\|/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80)
456
507
  // Prefer the trust trigger (signal / memory warn) over a random risk line when triage is not green
457
508
  const reason = (trust !== 'green' && (trustReason || mem.warn)) ? (trustReason || mem.warn) : topRisk
509
+ const openRisks = countOpenRisks(eng)
510
+ const nextAction = nextActionLine(ctx)
458
511
  let updated = 'never', ageDays = Infinity
459
512
  try {
460
513
  ageDays = Math.floor((Date.now() - fs.statSync(path.join(eng, 'context.md')).mtimeMs) / 86400000)
461
514
  updated = ageDays === 0 ? 'today' : `${ageDays}d ago`
462
515
  } catch (_) {}
463
- return { phase, trust, signalAge, stale, topRisk, reason, memoryWarn: mem.warn, updated, ageDays }
516
+ return { phase, trust, signalAge, stale, topRisk, reason, memoryWarn: mem.warn, openRisks, nextAction, updated, ageDays }
464
517
  }
465
518
 
519
+ function resumeTriage(eng) {
520
+ const s = computeSignals(eng)
521
+ const label = s.trust + (s.stale ? '?' : '')
522
+ const phase = s.phase === '?' ? 'unset' : s.phase
523
+ const lines = [
524
+ `TRIAGE [${label.padEnd(6)}] phase:${phase} updated:${s.updated} open risks:${s.openRisks}`,
525
+ ]
526
+ if (s.reason) {
527
+ const age = s.signalAge != null ? ` (${s.signalAge}d old${s.stale ? ', STALE - reconfirm' : ''})` : ''
528
+ lines.push(` trust: ${s.reason}${age}`)
529
+ }
530
+ if (s.nextAction) lines.push(` next: ${s.nextAction}`)
531
+ else lines.push(' next: (none set - add under ## Next action in context.md)')
532
+ return lines.join('\n')
533
+ }
534
+
535
+
466
536
  // ---------- dashboard content extractors (best-effort, read-only) ----------
467
537
  // The fieldbook's structured widgets (stakeholders, risks, log, stats) want
468
538
  // data shapes that .fde/ markdown does not literally carry - it is written by
@@ -867,7 +937,9 @@ function cmdResume(args) {
867
937
  console.log(`NO ENGAGEMENT for this workspace.\nexisting: ${list}\ncreate + bind one: fde resume --init <client-name>`)
868
938
  process.exit(2)
869
939
  }
870
- console.log(`ENGAGEMENT: ${eng}\n`)
940
+ // Monday-morning command: triage first (trust / phase / risks / next), then memory.
941
+ console.log(resumeTriage(eng))
942
+ console.log(`\nENGAGEMENT: ${eng}\n`)
871
943
  // readClean, not fs.readFileSync: this output is what an agent loads as
872
944
  // context, so it goes through the same <private> redaction as the dashboard.
873
945
  const ctx = readClean(eng, 'context.md')
@@ -945,10 +1017,23 @@ function cmdLog(args) {
945
1017
  args.splice(sigIdx, 2)
946
1018
  }
947
1019
  const type = args[0]; const text = args.slice(1).join(' ')
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) }
949
- if (signal && type !== 'contact') { console.error('--signal only applies to: fde log contact'); process.exit(1) }
950
1020
  const eng = resolveEngagement({ forWrite: true })
951
1021
  if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
1022
+
1023
+ // fde log phase <land|discover|plan|build|ship|close> - advances portfolio phase
1024
+ if (type === 'phase') {
1025
+ const phase = (text || '').toLowerCase().trim()
1026
+ if (!['land', 'discover', 'plan', 'build', 'ship', 'close'].includes(phase)) {
1027
+ console.error('usage: fde log phase <land|discover|plan|build|ship|close>')
1028
+ process.exit(1)
1029
+ }
1030
+ setContextPhase(eng, phase)
1031
+ console.log(`phase → ${phase}`)
1032
+ return
1033
+ }
1034
+
1035
+ if (!LOG_FILES[type] || !text) { console.error('usage: fde log <decision|risk|delivery|contact> <text> [--signal red|amber|green] [--force]\n fde log phase <land|discover|plan|build|ship|close>\n fde log --undo'); process.exit(1) }
1036
+ if (signal && type !== 'contact') { console.error('--signal only applies to: fde log contact'); process.exit(1) }
952
1037
  const hit = findSecretHit(text)
953
1038
  if (hit && !force) { refuseSecret('log text', hit); process.exit(1) }
954
1039
  if (hit && force) console.error(`warning: logging possible ${hit} (--force)`)
@@ -958,6 +1043,23 @@ function cmdLog(args) {
958
1043
  console.log(`logged → ${LOG_FILES[type]}${signal ? ` (signal:${signal})` : ''}`)
959
1044
  }
960
1045
 
1046
+ function setContextPhase(eng, phase) {
1047
+ const p = path.join(eng, 'context.md')
1048
+ let md = readEng(eng, 'context.md')
1049
+ if (!md) md = '# Engagement context\n\n'
1050
+ if (/\*\*Phase:\*\*/i.test(md)) {
1051
+ md = md.replace(/\*\*Phase:\*\*\s*.*/i, `**Phase:** ${phase}`)
1052
+ } else {
1053
+ md = md.replace(/\n*$/, `\n\n**Phase:** ${phase}\n`)
1054
+ }
1055
+ const today = new Date().toISOString().slice(0, 10)
1056
+ if (/\*\*Last updated:\*\*/i.test(md)) {
1057
+ md = md.replace(/\*\*Last updated:\*\*\s*.*/i, `**Last updated:** ${today}`)
1058
+ }
1059
+ withFileLock(p, () => { atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n') })
1060
+ }
1061
+
1062
+
961
1063
  // Meeting notes → structured memory. Deterministic routing, zero AI: lines that
962
1064
  // start with decision:/risk:/delivery:/contact: (case-insensitive) go to their
963
1065
  // LOG_FILES target as dated bullets; everything else lands in context.md as one
@@ -1146,10 +1248,10 @@ function cmdStatus(args) {
1146
1248
  // "amber?" = structured signal went stale (>21d) - reconfirm before trusting it
1147
1249
  const label = r.trust + (r.stale ? '?' : '')
1148
1250
  const sig = r.signalAge != null ? `signal ${r.signalAge}d old${r.stale ? ' (STALE - reconfirm)' : ''} ` : ''
1149
- console.log(` [${label.padEnd(6)}] ${r.name.padEnd(24)} phase:${r.phase.padEnd(10)} updated:${r.updated.padEnd(8)} ${sig}${r.reason}`)
1251
+ console.log(` [${label.padEnd(6)}] ${r.name.padEnd(24)} phase:${(r.phase === '?' ? 'unset' : r.phase).padEnd(10)} updated:${r.updated.padEnd(8)} ${sig}${r.reason}`)
1150
1252
  }
1151
1253
  if (!all) console.log('\n(current engagement only - pass --all for the full portfolio)')
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.')
1254
+ console.log('\ntrust: worst active [signal:x] across stakeholders (latest per person) - a green from B cannot clear an amber/red on A; keyword heuristic only when none exists.')
1153
1255
  }
1154
1256
 
1155
1257
  // ---------- dashboard (deterministic markdown → one local HTML) ----------
@@ -1920,6 +2022,7 @@ function printUsage() {
1920
2022
  fde resume --init <name> create + bind engagement for this workspace (rebind replaces)
1921
2023
  fde resume --bind show what this workspace is bound to, and what resolves
1922
2024
  fde log <type> <text> append decision|risk|delivery|contact (contact takes --signal red|amber|green; --force to allow secret-like text)
2025
+ fde log phase <phase> set engagement phase (land|discover|plan|build|ship|close)
1923
2026
  fde log --undo remove the last CLI log/debrief entry from memory
1924
2027
  fde debrief [file] meeting notes → memory: decision:/risk:/delivery:/contact: lines route, rest → context.md (stdin if no file; --dry-run; --force)
1925
2028
  fde receipts <term> "what did we agree?" with dates
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.8.2",
3
+ "version": "3.8.3",
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",
@@ -4,7 +4,7 @@
4
4
 
5
5
  **Engagement:**
6
6
  **Customer:**
7
- **Phase:** land | discover | build | ship | close
7
+ **Phase:** unset
8
8
  **Last updated:**
9
9
 
10
10
  ## Current state