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.
- package/bin/fde.js +169 -82
- 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
|
|
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
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
821
|
-
|
|
845
|
+
}
|
|
846
|
+
return [...byKey.values()]
|
|
822
847
|
}
|
|
823
848
|
|
|
824
|
-
// Risks:
|
|
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
|
|
833
|
-
|
|
834
|
-
const
|
|
835
|
-
|
|
836
|
-
const
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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 (
|
|
1241
|
-
|
|
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 = {
|
|
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
|
|
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 (
|
|
1533
|
-
if (!risks.length) console.log(' (none
|
|
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.
|
|
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",
|