fdeops 3.9.0 → 3.9.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 +169 -82
  2. package/package.json +1 -1
package/bin/fde.js CHANGED
@@ -752,26 +752,12 @@ function colIndex(headers, rx) { return headers.findIndex(h => rx.test(h)) }
752
752
  // a person and a bullet that happens to name them. No token match -> keyword
753
753
  // heuristic on the stance/signal cell. No table at all -> empty, never
754
754
  // fabricated.
755
- function extractStakeholders(eng) {
755
+ function parseSignalHistoryEntries(eng) {
756
+ // Format-agnostic on token position: CLI writes "[date] [signal:x] text";
757
+ // debrief may put the token at the end. Author tags [@x] are stripped for matching.
756
758
  const md = readClean(eng, 'stakeholders.md')
757
- const table = parseMdTable(md)
758
- if (!table) return []
759
- const { headers, rows } = table
760
- const nameIdx = colIndex(headers, /name/i)
761
- if (nameIdx === -1) return []
762
- const roleIdx = colIndex(headers, /^role$/i)
763
- const stanceIdx = colIndex(headers, /stance|signal/i)
764
- const notesIdx = colIndex(headers, /notes?/i)
765
-
766
- const history = []
767
- // Format-agnostic on token position: `fde log contact --signal` writes
768
- // "[date] [signal:x] text" (token right after the date), but `fde debrief`
769
- // appends the token at the END of whatever the agent wrote per the skill's
770
- // own contact: convention - "[date] text [signal:x]". Both are subject-first
771
- // once the token is stripped, so match the token anywhere on the line rather
772
- // than requiring it immediately after the date; a debrief-written signal was
773
- // silently invisible to per-stakeholder matching before this.
774
759
  const histText = sectionBody(md, 'Signal history') + '\n' + readEng(eng, SIGNAL_LEDGER)
760
+ const history = []
775
761
  histText.split('\n').forEach(l => {
776
762
  const dm = l.trim().match(/^-\s*\[(\d{4}-\d{2}-\d{2})\]\s*(.*)$/i)
777
763
  if (!dm) return
@@ -783,61 +769,109 @@ function extractStakeholders(eng) {
783
769
  .trim()
784
770
  history.push({ date: dm[1], signal: sm[1].toLowerCase(), text })
785
771
  })
772
+ return history
773
+ }
786
774
 
787
- return rows.map(cs => {
788
- const name = (cs[nameIdx] || '').trim()
789
- if (!name) return null
790
- const role = roleIdx !== -1 ? (cs[roleIdx] || '').trim() : ''
791
- const stance = stanceIdx !== -1 ? (cs[stanceIdx] || '').trim() : ''
792
- const note = notesIdx !== -1 ? (cs[notesIdx] || '').trim() : ''
793
-
794
- // naive match fragment: first real word of the name, skipping honorifics,
795
- // so "Dr. Anand Mehta" matches signal-history prose on "Anand", not "Dr."
796
- const words = name.replace(/\([^)]*\)/g, '').split(/\s+/).filter(w => w && !/^(dr|mr|mrs|ms)\.?$/i.test(w))
797
- const frag = (words[0] || '').replace(/[^a-z0-9]/gi, '')
798
-
799
- // Match the SUBJECT of the entry, not anyone it mentions in passing -
800
- // "Renata declined... told Sam..." is Renata's signal, not Sam's, even
801
- // though "Sam" appears in the text. Every real signal-history line in
802
- // this codebase's own examples is written subject-first ("Denise skipped
803
- // Thursday demo", "Randy opened the sheet..."), so requiring the name at
804
- // the START of the entry (not .includes() anywhere in it) is the fix,
805
- // not a stricter rule invented for its own sake.
806
- let signal = null, matchedDate = null
807
- if (frag.length >= 3) {
808
- for (const h of history) {
809
- if (h.text.trim().toLowerCase().startsWith(frag.toLowerCase()) && (!matchedDate || h.date >= matchedDate)) {
810
- signal = h.signal; matchedDate = h.date
775
+ function displayNameFromSignalText(text) {
776
+ const t = String(text).trim()
777
+ const proper = t.match(/^([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)/)
778
+ if (proper) return proper[1]
779
+ const word = t.split(/\s+/).find(w => w && !/^(dr|mr|mrs|ms)\.?$/i.test(w))
780
+ return word ? word.replace(/[^A-Za-z0-9.-]/g, '') : t.slice(0, 24)
781
+ }
782
+
783
+ // Stakeholders for prep/dashboard: table rows PLUS people who only appear in
784
+ // Signal history / .signal-ledger (the common log-shaped path after debrief).
785
+ function extractStakeholders(eng) {
786
+ const md = readClean(eng, 'stakeholders.md')
787
+ const table = parseMdTable(md)
788
+ const history = parseSignalHistoryEntries(eng)
789
+ const byKey = new Map()
790
+
791
+ if (table) {
792
+ const { headers, rows } = table
793
+ const nameIdx = colIndex(headers, /name|who/i)
794
+ if (nameIdx !== -1) {
795
+ const roleIdx = colIndex(headers, /^role$/i)
796
+ const stanceIdx = colIndex(headers, /stance|signal/i)
797
+ const notesIdx = colIndex(headers, /notes?/i)
798
+ for (const cs of rows) {
799
+ const name = (cs[nameIdx] || '').trim()
800
+ if (!name) continue
801
+ const role = roleIdx !== -1 ? (cs[roleIdx] || '').trim() : ''
802
+ const stance = stanceIdx !== -1 ? (cs[stanceIdx] || '').trim() : ''
803
+ const note = notesIdx !== -1 ? (cs[notesIdx] || '').trim() : ''
804
+ const words = name.replace(/\([^)]*\)/g, '').split(/\s+/).filter(w => w && !/^(dr|mr|mrs|ms)\.?$/i.test(w))
805
+ const frag = (words[0] || '').replace(/[^a-z0-9]/gi, '')
806
+ let signal = null, matchedDate = null
807
+ if (frag.length >= 3) {
808
+ for (const h of history) {
809
+ if (h.text.trim().toLowerCase().startsWith(frag.toLowerCase()) && (!matchedDate || h.date >= matchedDate)) {
810
+ signal = h.signal; matchedDate = h.date
811
+ }
812
+ }
813
+ }
814
+ if (!signal) {
815
+ const s = stance.toLowerCase()
816
+ signal = /champion|steady|\bgreen\b/.test(s) ? 'green'
817
+ : /resistant|hostile|blocker|\bred\b/.test(s) ? 'red'
818
+ : 'amber'
811
819
  }
820
+ byKey.set(signalSubjectKey(name), { name, role, note, signal, source: 'table' })
812
821
  }
813
822
  }
814
- if (!signal) {
815
- const s = stance.toLowerCase()
816
- signal = /champion|steady|\bgreen\b/.test(s) ? 'green'
817
- : /resistant|hostile|blocker|\bred\b/.test(s) ? 'red'
818
- : 'amber' // neutral / cooling / warming / not met / unknown / no signal cell at all
823
+ }
824
+
825
+ // Latest signal per subject; fill gaps when the FDE never filled the table.
826
+ const latest = new Map()
827
+ for (const h of history) {
828
+ const key = signalSubjectKey(h.text)
829
+ const prev = latest.get(key)
830
+ if (!prev || h.date >= prev.date) latest.set(key, h)
831
+ }
832
+ for (const [key, h] of latest) {
833
+ if (byKey.has(key)) {
834
+ const cur = byKey.get(key)
835
+ byKey.set(key, { ...cur, signal: h.signal, note: cur.note || h.text.slice(0, 80) })
836
+ } else {
837
+ byKey.set(key, {
838
+ name: displayNameFromSignalText(h.text),
839
+ role: '',
840
+ note: h.text.slice(0, 80),
841
+ signal: h.signal,
842
+ source: 'signal',
843
+ })
819
844
  }
820
- return { name, role, note, signal }
821
- }).filter(Boolean)
845
+ }
846
+ return [...byKey.values()]
822
847
  }
823
848
 
824
- // Risks: same table parser, matched on a "Risk" column. Real files carry no
825
- // severity field, so severity is a coarse high/med keyword guess on the risk
826
- // text itself - a guess, same honesty as computeSignals()'s trust fallback,
827
- // not a claim of real triage. "## Retired" rows are prose bullets, not table
828
- // rows, so the table parser above already stops before them - they feed the
829
- // log instead (see extractLog).
849
+ // Risks: table rows AND dated CLI/debrief bullets. Empty template cells ignored.
830
850
  function extractRisks(eng) {
831
851
  const md = readClean(eng, 'risks.md')
832
- const table = parseMdTable(md)
833
- if (!table) return []
834
- const riskIdx = colIndex(table.headers, /^risk$/i)
835
- if (riskIdx === -1) return []
836
- const HIGH = /critical|blocker|exposure|breach|urgent|at risk|at stake|\brace\b/i
837
- return table.rows.map(cs => {
838
- const text = (cs[riskIdx] || '').trim()
839
- return text ? { text, severity: HIGH.test(text) ? 'high' : 'med' } : null
840
- }).filter(Boolean)
852
+ const body = md.split(/^#{1,6}\s+Retired\b/im)[0] || md
853
+ const HIGH = /critical|blocker|exposure|breach|urgent|at risk|at stake|\brace\b|rollback|no test/i
854
+ const out = []
855
+ const seen = new Set()
856
+ const push = (text) => {
857
+ const t = String(text || '').trim()
858
+ if (!t || seen.has(t.toLowerCase())) return
859
+ seen.add(t.toLowerCase())
860
+ out.push({ text: t, severity: HIGH.test(t) ? 'high' : 'med' })
861
+ }
862
+ const table = parseMdTable(body)
863
+ if (table) {
864
+ const riskIdx = colIndex(table.headers, /^risk$/i)
865
+ if (riskIdx !== -1) {
866
+ for (const cs of table.rows) push(cs[riskIdx])
867
+ }
868
+ }
869
+ for (const raw of body.split('\n')) {
870
+ const t = raw.trim()
871
+ const m = t.match(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*(?:\[@[^\]]+\]\s*)?(.*)$/)
872
+ if (m) push(m[1])
873
+ }
874
+ return out
841
875
  }
842
876
 
843
877
  // Best-effort scan for "before -> after" metric callouts in delivery/decisions
@@ -1219,26 +1253,55 @@ function setContextPhase(eng, phase) {
1219
1253
  // anywhere in the text - preserved verbatim so computeSignals can trust it.
1220
1254
  // --smart: heuristic propose from messy prose (confirm with --apply). No network.
1221
1255
  // --dry-run prints the routing without writing anything.
1256
+ function inferContactSignal(text) {
1257
+ const t = String(text)
1258
+ if (/\b(hostile|blocker|fired|refused|walked out|\bred\b|escalat(?:ed|ion) to (?:cto|legal))\b/i.test(t)) return 'red'
1259
+ if (/\b(gone quiet|unresponsive|skipped|cooling|seemed cold|no-show|missed the|amber)\b/i.test(t)) return 'amber'
1260
+ if (/\b(champion|helping|opened the|warming|supportive|on board|\bgreen\b|saw demo)\b/i.test(t)) return 'green'
1261
+ return ''
1262
+ }
1263
+
1264
+ function looksLikePersonLine(text) {
1265
+ // "Denise …" / "Randy opened…" — capitalized subject + field verb.
1266
+ return /^[A-Z][a-z]{1,20}\b/.test(text) &&
1267
+ /\b(helping|quiet|skipped|said|will|opened|resistant|champion|warm|cold|unresponsive|demo|sheet|slack)\b/i.test(text)
1268
+ }
1269
+
1222
1270
  function smartProposeText(input) {
1223
1271
  const out = []
1224
1272
  for (const raw of input.split('\n')) {
1225
1273
  const line = raw.trim()
1226
1274
  if (!line) continue
1227
- const bare = line
1275
+ let bare = line
1228
1276
  .replace(/^[-*+]\s+/, '')
1229
- .replace(/^\*\*(decision|risk|delivery|contact):?\*\*:?\s*/i, '$1: ')
1230
- if (/^(decision|risk|delivery|contact):\s*/i.test(bare)) {
1231
- out.push(bare.replace(/^(decision|risk|delivery|contact):\s*/i, (m, t) => `${t.toLowerCase()}: `))
1277
+ .replace(/^\*\*(decision|risk|delivery|contact|next):?\*\*:?\s*/i, '$1: ')
1278
+ if (/^(decision|risk|delivery|contact|next):\s*/i.test(bare)) {
1279
+ let routed = bare.replace(/^(decision|risk|delivery|contact|next):\s*/i, (m, t) => `${t.toLowerCase()}: `)
1280
+ if (/^contact:/i.test(routed) && !/\[signal:(red|amber|green)\]/i.test(routed)) {
1281
+ const sig = inferContactSignal(routed)
1282
+ if (sig) routed = routed.replace(/\s*$/, ` [signal:${sig}]`)
1283
+ }
1284
+ out.push(routed)
1232
1285
  continue
1233
1286
  }
1234
- if (/\b(we (decided|agreed)|decision:|descope|agreed to|agreement was)\b/i.test(bare)) {
1287
+ if (/^(next action|follow-?ups?|action items?|todo):\s*/i.test(bare) ||
1288
+ /\b(next action|walk in with|follow up with)\b/i.test(bare)) {
1289
+ const next = bare.replace(/^(next action|follow-?ups?|action items?|todo):\s*/i, '').trim()
1290
+ out.push(`next: ${next}`)
1291
+ continue
1292
+ }
1293
+ if (/\b(we (decided|agreed)|decision:|descope|agreed to|agreement was|freeze scope)\b/i.test(bare)) {
1235
1294
  out.push(`decision: ${bare}`)
1236
- } else if (/\b(risk|blocker|concern|at risk|worried|exposure|mitigation)\b/i.test(bare)) {
1295
+ } else if (/\b(open question|who signs|unclear who|unresolved)\b/i.test(bare)) {
1296
+ out.push(`risk: ${bare}`)
1297
+ } else if (/\b(risk|blocker|concern|at risk|worried|exposure|mitigation|no tested|no rollback)\b/i.test(bare)) {
1237
1298
  out.push(`risk: ${bare}`)
1238
1299
  } else if (/\b(shipped|delivered|deployed|merged PR|rolled out|went live)\b/i.test(bare)) {
1239
1300
  out.push(`delivery: ${bare}`)
1240
- } else if (/\b(gone quiet|champion|resistant|unresponsive|skipped|cooling|signal:)\b/i.test(bare)) {
1241
- out.push(`contact: ${bare}`)
1301
+ } else if (looksLikePersonLine(bare) ||
1302
+ /\b(gone quiet|champion|resistant|unresponsive|skipped|cooling|signal:)\b/i.test(bare)) {
1303
+ const sig = inferContactSignal(bare)
1304
+ out.push(sig ? `contact: ${bare} [signal:${sig}]` : `contact: ${bare}`)
1242
1305
  } else {
1243
1306
  out.push(bare)
1244
1307
  }
@@ -1246,6 +1309,20 @@ function smartProposeText(input) {
1246
1309
  return out.join('\n') + (out.length ? '\n' : '')
1247
1310
  }
1248
1311
 
1312
+ function setNextAction(eng, text) {
1313
+ ensureMemoryGit(eng)
1314
+ const bullet = `- ${String(text).replace(/^[-*]\s+/, '').trim()}`
1315
+ const p = path.join(eng, 'context.md')
1316
+ let md = readEng(eng, 'context.md')
1317
+ if (!md) md = '# Engagement context\n\n'
1318
+ if (/^##\s+Next action\b/im.test(md)) {
1319
+ md = md.replace(/(^##\s+Next action\b[^\n]*\n)([\s\S]*?)(?=^##\s|\s*$)/im, `$1\n${bullet}\n\n`)
1320
+ } else {
1321
+ md = md.replace(/\n*$/, `\n\n## Next action\n\n${bullet}\n`)
1322
+ }
1323
+ withFileLock(p, () => { atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n') })
1324
+ }
1325
+
1249
1326
  function readDebriefInput(args) {
1250
1327
  let input = ''
1251
1328
  if (args[0]) {
@@ -1276,17 +1353,24 @@ function readDebriefInput(args) {
1276
1353
  function routeDebriefInput(eng, input, { dry, force }) {
1277
1354
  const d = new Date()
1278
1355
  const date = d.toISOString().slice(0, 10)
1279
- const counts = { decision: 0, risk: 0, delivery: 0, contact: 0 }
1356
+ const counts = { decision: 0, risk: 0, delivery: 0, contact: 0, next: 0 }
1280
1357
  const ctxLines = []
1358
+ let nextAction = ''
1281
1359
  ensureMemoryGit(eng)
1282
1360
  for (const raw of input.split('\n')) {
1283
1361
  let line = raw.trim()
1284
1362
  if (!line) continue
1285
- const bare = line.replace(/^[-*+]\s+/, '').replace(/^\*\*(decision|risk|delivery|contact):?\*\*:?\s*/i, '$1: ')
1286
- const m = bare.match(/^(decision|risk|delivery|contact):\s*(.+)$/i)
1363
+ const bare = line.replace(/^[-*+]\s+/, '').replace(/^\*\*(decision|risk|delivery|contact|next):?\*\*:?\s*/i, '$1: ')
1364
+ const m = bare.match(/^(decision|risk|delivery|contact|next):\s*(.+)$/i)
1287
1365
  if (m) {
1288
1366
  const type = m[1].toLowerCase()
1289
1367
  let body = m[2]
1368
+ if (type === 'next') {
1369
+ if (dry) console.log(`→ context.md ## Next action - ${body}`)
1370
+ else nextAction = body
1371
+ counts.next++
1372
+ continue
1373
+ }
1290
1374
  const sigInline = (body.match(/\[signal:(red|amber|green)\]/i) || [])[1]
1291
1375
  if (sigInline) body = body.replace(/\[signal:(red|amber|green)\]/i, '').trim()
1292
1376
  const hit = findSecretHit(body)
@@ -1306,12 +1390,13 @@ function routeDebriefInput(eng, input, { dry, force }) {
1306
1390
  ctxLines.push(line)
1307
1391
  }
1308
1392
  }
1393
+ if (nextAction && !dry) setNextAction(eng, nextAction)
1309
1394
  if (ctxLines.length) {
1310
1395
  const stamp = `${date} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
1311
1396
  if (dry) ctxLines.forEach(l => console.log(`→ context.md - ${l}`))
1312
1397
  else lockedAppendFile(path.join(eng, 'context.md'), `\n## Debrief - ${stamp}\n${ctxLines.map(l => `- ${l}`).join('\n')}\n`)
1313
1398
  }
1314
- return { counts, ctxLines, date }
1399
+ return { counts, ctxLines, date, nextAction }
1315
1400
  }
1316
1401
 
1317
1402
  function cmdDebrief(args) {
@@ -1363,9 +1448,11 @@ function cmdDebrief(args) {
1363
1448
  try { fs.unlinkSync(path.join(eng, DEBRIEF_PROPOSE)) } catch (_) {}
1364
1449
  if (hash) console.log(`memory @${hash}`)
1365
1450
  }
1366
- const plural = { decision: 'decisions', risk: 'risks', delivery: 'deliveries', contact: 'contacts' }
1451
+ const plural = {
1452
+ decision: 'decisions', risk: 'risks', delivery: 'deliveries', contact: 'contacts', next: 'next actions',
1453
+ }
1367
1454
  const parts = Object.keys(counts).filter(t => counts[t])
1368
- .map(t => `${counts[t]} ${counts[t] === 1 ? t : plural[t]}`)
1455
+ .map(t => `${counts[t]} ${counts[t] === 1 ? (t === 'next' ? 'next action' : t) : plural[t]}`)
1369
1456
  if (ctxLines.length) parts.push(`${ctxLines.length} context line${ctxLines.length === 1 ? '' : 's'}`)
1370
1457
  const verb = dry ? 'debrief would route' : 'debrief routed'
1371
1458
  console.log(parts.length ? `${verb} → ${parts.join(', ')}` : 'debrief empty - nothing routed')
@@ -1524,13 +1611,13 @@ function cmdPrep(args) {
1524
1611
  if (owner || head) console.log(` record: ${owner ? owner.email : '?'}${head ? ` @${head}` : ''}`)
1525
1612
 
1526
1613
  const people = extractStakeholders(eng).slice(0, 8)
1527
- console.log('\nStakeholders')
1528
- if (!people.length) console.log(' (none in table yet)')
1614
+ console.log('\nStakeholders (table + signal history)')
1615
+ if (!people.length) console.log(' (none yet - log contacts with --signal)')
1529
1616
  else people.forEach(p => console.log(` [${p.signal}] ${p.name}${p.role ? ` — ${p.role}` : ''}${p.note ? ` · ${p.note.slice(0, 60)}` : ''}`))
1530
1617
 
1531
1618
  const risks = extractRisks(eng).slice(0, 5)
1532
- console.log('\nOpen risks (from risks.md table)')
1533
- if (!risks.length) console.log(' (none parsed)')
1619
+ console.log('\nOpen risks (table + dated bullets)')
1620
+ if (!risks.length) console.log(' (none logged)')
1534
1621
  else risks.forEach(r => console.log(` [${r.severity}] ${r.text.slice(0, 100)}`))
1535
1622
 
1536
1623
  const success = firstLine(readClean(eng, 'success.md'), 160)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.9.0",
3
+ "version": "3.9.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",