fdeops 3.9.6 → 3.9.8
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/README.md +8 -49
- package/bin/check.js +15 -5
- package/bin/fde.js +180 -900
- package/bin/install.js +6 -1
- package/bin/lib/memory.js +164 -0
- package/bin/lib/render.js +666 -0
- package/bin/lib/trust.js +188 -0
- package/package.json +1 -1
package/bin/lib/trust.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
function createTrustApi(deps) {
|
|
4
|
+
const {
|
|
5
|
+
fs, path, readClean, readEng, parseMdTable, sectionBody, SIGNAL_LEDGER, memoryDirtyManual,
|
|
6
|
+
} = deps
|
|
7
|
+
|
|
8
|
+
// phase / trust / top risk / freshness - identical heuristic for status + dashboard.
|
|
9
|
+
// Trust resolution: structured [signal:red|amber|green] tokens in stakeholders.md
|
|
10
|
+
// (written by `fde log contact --signal` and `fde debrief`) win - the latest dated
|
|
11
|
+
// one. Older than 21 days → stale: shown with a "?" marker + age so a forgotten
|
|
12
|
+
// signal never silently drives triage. The keyword grep survives only as the
|
|
13
|
+
// zero-effort floor when NO token exists anywhere - prose like "escalated to CTO,
|
|
14
|
+
// resolved amicably" must not flip a client amber forever.
|
|
15
|
+
function stakeholdersMemoryHealth(eng) {
|
|
16
|
+
// Hostile handoff: binary / unparseable stakeholders must not read as healthy green.
|
|
17
|
+
let buf
|
|
18
|
+
try { buf = fs.readFileSync(path.join(eng, 'stakeholders.md')) } catch (_) {
|
|
19
|
+
return { ok: true, warn: '' }
|
|
20
|
+
}
|
|
21
|
+
if (buf.includes(0)) {
|
|
22
|
+
return { ok: false, warn: 'memory unreadable - verify (binary data in stakeholders.md)' }
|
|
23
|
+
}
|
|
24
|
+
const md = buf.toString('utf8')
|
|
25
|
+
const ledger = readEng(eng, SIGNAL_LEDGER)
|
|
26
|
+
if (/\[signal:(red|amber|green)\]/i.test(md + '\n' + ledger)) {
|
|
27
|
+
return { ok: true, warn: '' }
|
|
28
|
+
}
|
|
29
|
+
const trustLine = md.match(/\*\*Trust:\*\*\s*([A-Za-z?]+)/i)
|
|
30
|
+
if (trustLine && !/^(red|amber|green)$/i.test(trustLine[1])) {
|
|
31
|
+
return { ok: false, warn: 'memory unreadable - verify (invalid trust value)' }
|
|
32
|
+
}
|
|
33
|
+
const table = parseMdTable(md)
|
|
34
|
+
const meaningful = md.split('\n').filter(l => {
|
|
35
|
+
const t = l.trim()
|
|
36
|
+
return t && !t.startsWith('#') && !t.startsWith('<!--') && !/^\|?\s*:?-{3,}/.test(t)
|
|
37
|
+
}).length
|
|
38
|
+
// Content present but no table and no structured signal → do not invent "green"
|
|
39
|
+
if (meaningful >= 3 && !table) {
|
|
40
|
+
return { ok: false, warn: 'memory unreadable - verify (stakeholders.md unparseable)' }
|
|
41
|
+
}
|
|
42
|
+
return { ok: true, warn: '' }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Subject key for a signal-history line - first real name word (same spirit as
|
|
46
|
+
// extractStakeholders). A green about Randy must not clear an amber about Denise.
|
|
47
|
+
// Strip author tags [@email-local] so attribution never becomes the subject key.
|
|
48
|
+
function signalSubjectKey(text) {
|
|
49
|
+
const cleaned = String(text).replace(/\[@[^\]]+\]/g, '').replace(/\([^)]*\)/g, '')
|
|
50
|
+
const words = cleaned.split(/\s+/).filter(w => w && !/^(dr|mr|mrs|ms)\.?$/i.test(w))
|
|
51
|
+
const frag = (words[0] || '').replace(/[^a-z0-9]/gi, '').toLowerCase()
|
|
52
|
+
return frag.length >= 3 ? frag : ('anon:' + cleaned.slice(0, 48).toLowerCase())
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parsePhase(ctx) {
|
|
56
|
+
// Template ships "**Phase:** land | discover | ..." - that is UNSET, not land.
|
|
57
|
+
const m = ctx.match(/\*\*Phase:\*\*\s*(.+)/i) || ctx.match(/^phase[:\s*]+(.+)$/im)
|
|
58
|
+
if (!m) return '?'
|
|
59
|
+
const raw = m[1].replace(/\*/g, '').trim()
|
|
60
|
+
if (!raw || /\|/.test(raw) || /^unset$/i.test(raw) || /^[\[(]/.test(raw)) return '?'
|
|
61
|
+
const one = raw.toLowerCase().match(/^(land|discover|plan|build|ship|close)\b/)
|
|
62
|
+
return one ? one[1] : '?'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function countOpenRisks(eng) {
|
|
66
|
+
const md = readClean(eng, 'risks.md')
|
|
67
|
+
const body = md.split(/^#{1,6}\s+Retired\b/im)[0] || md
|
|
68
|
+
let n = 0
|
|
69
|
+
for (const raw of body.split('\n')) {
|
|
70
|
+
const t = raw.trim()
|
|
71
|
+
if (!t || t.startsWith('<!--') || /^#{1,6}\s/.test(t)) continue
|
|
72
|
+
if (/risk\s*\|\s*status|mitigation/i.test(t) || /^\|?[\s|:-]+$/.test(t)) continue
|
|
73
|
+
if (/^[-*]/.test(t) || (/^\|/.test(t) && t.length > 12)) n++
|
|
74
|
+
}
|
|
75
|
+
return n
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function nextActionLine(ctx) {
|
|
79
|
+
const body = sectionBody(ctx, 'Next action')
|
|
80
|
+
for (const raw of body.split('\n')) {
|
|
81
|
+
const t = raw.trim().replace(/^[-*]\s+/, '')
|
|
82
|
+
if (t) return t.slice(0, 120)
|
|
83
|
+
}
|
|
84
|
+
return ''
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function computeSignals(eng) {
|
|
88
|
+
// readClean, not readEng: status/dashboard echo topRisk and stakeholder lines
|
|
89
|
+
// to the terminal and the rendered HTML - a <private> risk must never surface.
|
|
90
|
+
const ctx = readClean(eng, 'context.md'); const stake = readClean(eng, 'stakeholders.md'); const risks = readClean(eng, 'risks.md')
|
|
91
|
+
// Prefer structured tokens from stakeholders + CLI ledger (ledger survives wipes)
|
|
92
|
+
const signalText = stake + '\n' + readClean(eng, SIGNAL_LEDGER)
|
|
93
|
+
const phase = parsePhase(ctx)
|
|
94
|
+
// Latest signal PER stakeholder, then worst-of those actives.
|
|
95
|
+
// Global "latest wins" let a green from person B hide a sponsor crisis on A.
|
|
96
|
+
const byPerson = new Map()
|
|
97
|
+
for (const l of signalText.split('\n')) {
|
|
98
|
+
const sm = l.match(/\[signal:(red|amber|green)\]/i)
|
|
99
|
+
if (!sm) continue
|
|
100
|
+
const date = (l.match(/\[(\d{4}-\d{2}-\d{2})\]/) || [])[1] || ''
|
|
101
|
+
const text = l.replace(/^\s*-\s*/, '')
|
|
102
|
+
.replace(/\[signal:(red|amber|green)\]/i, '')
|
|
103
|
+
.replace(/\[\d{4}-\d{2}-\d{2}\]/, '')
|
|
104
|
+
.replace(/\[@[^\]]+\]/g, '')
|
|
105
|
+
.trim()
|
|
106
|
+
const key = signalSubjectKey(text)
|
|
107
|
+
const prev = byPerson.get(key)
|
|
108
|
+
if (!prev || date >= prev.date) byPerson.set(key, { date, sig: sm[1].toLowerCase(), text })
|
|
109
|
+
}
|
|
110
|
+
const RANK = { red: 0, amber: 1, green: 2 }
|
|
111
|
+
let worst = null
|
|
112
|
+
for (const s of byPerson.values()) {
|
|
113
|
+
if (!worst || RANK[s.sig] < RANK[worst.sig] || (RANK[s.sig] === RANK[worst.sig] && s.date >= worst.date)) {
|
|
114
|
+
worst = s
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const mem = stakeholdersMemoryHealth(eng)
|
|
118
|
+
let trust, signalAge = null, stale = false, trustReason = ''
|
|
119
|
+
if (!mem.ok && !worst) {
|
|
120
|
+
trust = 'amber'
|
|
121
|
+
trustReason = mem.warn
|
|
122
|
+
} else if (worst) {
|
|
123
|
+
trust = worst.sig === 'red' ? 'RED' : worst.sig
|
|
124
|
+
trustReason = (worst.text || '').slice(0, 80)
|
|
125
|
+
if (worst.date) {
|
|
126
|
+
signalAge = Math.max(0, Math.floor((Date.now() - Date.parse(worst.date)) / 86400000))
|
|
127
|
+
stale = signalAge > 21
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
const sLines = stake.split('\n').filter(l => !(/green/i.test(l) && /red|amber/i.test(l)))
|
|
131
|
+
trust = sLines.some(l => /\bred\b/i.test(l)) ? 'RED'
|
|
132
|
+
: sLines.some(l => /amber|gone quiet|routing around|escalat/i.test(l)) ? 'amber' : 'green'
|
|
133
|
+
}
|
|
134
|
+
const topRisk = (risks.split('\n').find(l => {
|
|
135
|
+
const t = l.trim()
|
|
136
|
+
return /^[-|]/.test(t) && t.length > 20 && !/^\|?[-\s|]+$/.test(t) &&
|
|
137
|
+
!/risk\s*\|\s*status|mitigation/i.test(t) && !t.startsWith('<!--')
|
|
138
|
+
}) || '').replace(/\|/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80)
|
|
139
|
+
// Prefer trust trigger / memory warn over a random risk line; always keep mem.warn available
|
|
140
|
+
const reason = (trustReason || mem.warn) ? (trustReason || mem.warn) : topRisk
|
|
141
|
+
const openRisks = countOpenRisks(eng)
|
|
142
|
+
const nextAction = nextActionLine(ctx)
|
|
143
|
+
let updated = 'never', ageDays = Infinity
|
|
144
|
+
try {
|
|
145
|
+
ageDays = Math.floor((Date.now() - fs.statSync(path.join(eng, 'context.md')).mtimeMs) / 86400000)
|
|
146
|
+
updated = ageDays === 0 ? 'today' : `${ageDays}d ago`
|
|
147
|
+
} catch (_) {}
|
|
148
|
+
const dirty = memoryDirtyManual(eng)
|
|
149
|
+
return {
|
|
150
|
+
phase, trust, signalAge, stale, topRisk, reason, memoryWarn: mem.warn,
|
|
151
|
+
dirtyFiles: dirty, openRisks, nextAction, updated, ageDays,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function resumeTriage(eng) {
|
|
156
|
+
const s = computeSignals(eng)
|
|
157
|
+
const label = s.trust + (s.stale ? '?' : '')
|
|
158
|
+
const phase = s.phase === '?' ? 'unset' : s.phase
|
|
159
|
+
const lines = [
|
|
160
|
+
`TRIAGE [${label.padEnd(6)}] phase:${phase} updated:${s.updated} open risks:${s.openRisks}`,
|
|
161
|
+
]
|
|
162
|
+
if (s.reason) {
|
|
163
|
+
const age = s.signalAge != null ? ` (${s.signalAge}d old${s.stale ? ', STALE - reconfirm' : ''})` : ''
|
|
164
|
+
lines.push(` trust: ${s.reason}${age}`)
|
|
165
|
+
}
|
|
166
|
+
// Always surface corruption / unreadable memory - even when trust still reads green
|
|
167
|
+
if (s.memoryWarn) lines.push(` memory: ${s.memoryWarn}`)
|
|
168
|
+
if (s.dirtyFiles && s.dirtyFiles.length) {
|
|
169
|
+
lines.push(` ⚠ memory dirty (uncommitted manual edits): ${s.dirtyFiles.slice(0, 5).join(', ')}${s.dirtyFiles.length > 5 ? '…' : ''}`)
|
|
170
|
+
lines.push(' review before relying on the ledger - fde writes will not auto-commit these')
|
|
171
|
+
}
|
|
172
|
+
if (s.nextAction) lines.push(` next: ${s.nextAction}`)
|
|
173
|
+
else lines.push(' next: (none set - add under ## Next action in context.md)')
|
|
174
|
+
return lines.join('\n')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
stakeholdersMemoryHealth,
|
|
179
|
+
signalSubjectKey,
|
|
180
|
+
parsePhase,
|
|
181
|
+
countOpenRisks,
|
|
182
|
+
nextActionLine,
|
|
183
|
+
computeSignals,
|
|
184
|
+
resumeTriage,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { createTrustApi }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fdeops",
|
|
3
|
-
"version": "3.9.
|
|
3
|
+
"version": "3.9.8",
|
|
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",
|