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 +118 -15
- package/package.json +1 -1
- package/templates/.fde/context.md +1 -1
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
|
|
426
|
-
|
|
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
|
-
|
|
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 && !
|
|
487
|
+
if (!mem.ok && !worst) {
|
|
437
488
|
trust = 'amber'
|
|
438
489
|
trustReason = mem.warn
|
|
439
|
-
} else if (
|
|
440
|
-
trust =
|
|
441
|
-
trustReason = (
|
|
442
|
-
if (
|
|
443
|
-
signalAge = Math.max(0, Math.floor((Date.now() - Date.parse(
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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",
|