fdeops 3.9.6 → 3.9.9
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 +9 -50
- package/bin/check.js +15 -5
- package/bin/fde.js +239 -912
- 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 +197 -0
- package/hooks/session-start +3 -2
- package/package.json +1 -1
- package/skills/fde/SKILL.md +11 -9
package/bin/lib/trust.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
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
|
+
// Bullet risk with substance (skip empty "- " stubs).
|
|
74
|
+
if (/^[-*]/.test(t)) {
|
|
75
|
+
if (t.replace(/^[-*]\s+/, '').trim()) n++
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
// Table row: first cell must have risk text (day-1 "| | open | |" placeholders don't count).
|
|
79
|
+
if (/^\|/.test(t) && t.length > 12) {
|
|
80
|
+
const riskCell = t.split('|').map(c => c.trim())[1] || ''
|
|
81
|
+
if (riskCell) n++
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return n
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function nextActionLine(ctx) {
|
|
88
|
+
const body = sectionBody(ctx, 'Next action')
|
|
89
|
+
for (const raw of body.split('\n')) {
|
|
90
|
+
const t = raw.trim().replace(/^[-*]\s+/, '')
|
|
91
|
+
if (t) return t.slice(0, 120)
|
|
92
|
+
}
|
|
93
|
+
return ''
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function computeSignals(eng) {
|
|
97
|
+
// readClean, not readEng: status/dashboard echo topRisk and stakeholder lines
|
|
98
|
+
// to the terminal and the rendered HTML - a <private> risk must never surface.
|
|
99
|
+
const ctx = readClean(eng, 'context.md'); const stake = readClean(eng, 'stakeholders.md'); const risks = readClean(eng, 'risks.md')
|
|
100
|
+
// Prefer structured tokens from stakeholders + CLI ledger (ledger survives wipes)
|
|
101
|
+
const signalText = stake + '\n' + readClean(eng, SIGNAL_LEDGER)
|
|
102
|
+
const phase = parsePhase(ctx)
|
|
103
|
+
// Latest signal PER stakeholder, then worst-of those actives.
|
|
104
|
+
// Global "latest wins" let a green from person B hide a sponsor crisis on A.
|
|
105
|
+
const byPerson = new Map()
|
|
106
|
+
for (const l of signalText.split('\n')) {
|
|
107
|
+
const sm = l.match(/\[signal:(red|amber|green)\]/i)
|
|
108
|
+
if (!sm) continue
|
|
109
|
+
const date = (l.match(/\[(\d{4}-\d{2}-\d{2})\]/) || [])[1] || ''
|
|
110
|
+
const text = l.replace(/^\s*-\s*/, '')
|
|
111
|
+
.replace(/\[signal:(red|amber|green)\]/i, '')
|
|
112
|
+
.replace(/\[\d{4}-\d{2}-\d{2}\]/, '')
|
|
113
|
+
.replace(/\[@[^\]]+\]/g, '')
|
|
114
|
+
.trim()
|
|
115
|
+
const key = signalSubjectKey(text)
|
|
116
|
+
const prev = byPerson.get(key)
|
|
117
|
+
if (!prev || date >= prev.date) byPerson.set(key, { date, sig: sm[1].toLowerCase(), text })
|
|
118
|
+
}
|
|
119
|
+
const RANK = { red: 0, amber: 1, green: 2 }
|
|
120
|
+
let worst = null
|
|
121
|
+
for (const s of byPerson.values()) {
|
|
122
|
+
if (!worst || RANK[s.sig] < RANK[worst.sig] || (RANK[s.sig] === RANK[worst.sig] && s.date >= worst.date)) {
|
|
123
|
+
worst = s
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const mem = stakeholdersMemoryHealth(eng)
|
|
127
|
+
let trust, signalAge = null, stale = false, trustReason = ''
|
|
128
|
+
if (!mem.ok && !worst) {
|
|
129
|
+
trust = 'amber'
|
|
130
|
+
trustReason = mem.warn
|
|
131
|
+
} else if (worst) {
|
|
132
|
+
trust = worst.sig === 'red' ? 'RED' : worst.sig
|
|
133
|
+
trustReason = (worst.text || '').slice(0, 80)
|
|
134
|
+
if (worst.date) {
|
|
135
|
+
signalAge = Math.max(0, Math.floor((Date.now() - Date.parse(worst.date)) / 86400000))
|
|
136
|
+
stale = signalAge > 21
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
const sLines = stake.split('\n').filter(l => !(/green/i.test(l) && /red|amber/i.test(l)))
|
|
140
|
+
trust = sLines.some(l => /\bred\b/i.test(l)) ? 'RED'
|
|
141
|
+
: sLines.some(l => /amber|gone quiet|routing around|escalat/i.test(l)) ? 'amber' : 'green'
|
|
142
|
+
}
|
|
143
|
+
const topRisk = (risks.split('\n').find(l => {
|
|
144
|
+
const t = l.trim()
|
|
145
|
+
return /^[-|]/.test(t) && t.length > 20 && !/^\|?[-\s|]+$/.test(t) &&
|
|
146
|
+
!/risk\s*\|\s*status|mitigation/i.test(t) && !t.startsWith('<!--')
|
|
147
|
+
}) || '').replace(/\|/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80)
|
|
148
|
+
// Prefer trust trigger / memory warn over a random risk line; always keep mem.warn available
|
|
149
|
+
const reason = (trustReason || mem.warn) ? (trustReason || mem.warn) : topRisk
|
|
150
|
+
const openRisks = countOpenRisks(eng)
|
|
151
|
+
const nextAction = nextActionLine(ctx)
|
|
152
|
+
let updated = 'never', ageDays = Infinity
|
|
153
|
+
try {
|
|
154
|
+
ageDays = Math.floor((Date.now() - fs.statSync(path.join(eng, 'context.md')).mtimeMs) / 86400000)
|
|
155
|
+
updated = ageDays === 0 ? 'today' : `${ageDays}d ago`
|
|
156
|
+
} catch (_) {}
|
|
157
|
+
const dirty = memoryDirtyManual(eng)
|
|
158
|
+
return {
|
|
159
|
+
phase, trust, signalAge, stale, topRisk, reason, memoryWarn: mem.warn,
|
|
160
|
+
dirtyFiles: dirty, openRisks, nextAction, updated, ageDays,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function resumeTriage(eng) {
|
|
165
|
+
const s = computeSignals(eng)
|
|
166
|
+
const label = s.trust + (s.stale ? '?' : '')
|
|
167
|
+
const phase = s.phase === '?' ? 'unset' : s.phase
|
|
168
|
+
const lines = [
|
|
169
|
+
`TRIAGE [${label.padEnd(6)}] phase:${phase} updated:${s.updated} open risks:${s.openRisks}`,
|
|
170
|
+
]
|
|
171
|
+
if (s.reason) {
|
|
172
|
+
const age = s.signalAge != null ? ` (${s.signalAge}d old${s.stale ? ', STALE - reconfirm' : ''})` : ''
|
|
173
|
+
lines.push(` trust: ${s.reason}${age}`)
|
|
174
|
+
}
|
|
175
|
+
// Always surface corruption / unreadable memory - even when trust still reads green
|
|
176
|
+
if (s.memoryWarn) lines.push(` memory: ${s.memoryWarn}`)
|
|
177
|
+
if (s.dirtyFiles && s.dirtyFiles.length) {
|
|
178
|
+
lines.push(` ⚠ memory dirty (uncommitted manual edits): ${s.dirtyFiles.slice(0, 5).join(', ')}${s.dirtyFiles.length > 5 ? '…' : ''}`)
|
|
179
|
+
lines.push(' review before relying on the ledger - fde writes will not auto-commit these')
|
|
180
|
+
}
|
|
181
|
+
if (s.nextAction) lines.push(` next: ${s.nextAction}`)
|
|
182
|
+
else lines.push(' next: (none set - add under ## Next action in context.md)')
|
|
183
|
+
return lines.join('\n')
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
stakeholdersMemoryHealth,
|
|
188
|
+
signalSubjectKey,
|
|
189
|
+
parsePhase,
|
|
190
|
+
countOpenRisks,
|
|
191
|
+
nextActionLine,
|
|
192
|
+
computeSignals,
|
|
193
|
+
resumeTriage,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = { createTrustApi }
|
package/hooks/session-start
CHANGED
|
@@ -138,8 +138,9 @@ CONTENT=""
|
|
|
138
138
|
# Lean pointer only - never cat SKILL.md. Methods load on @fde / skill trigger.
|
|
139
139
|
CONTENT="${CONTENT}fdeops: engagement fieldbook active. Human speaks plain language with @fde - you (the agent) run the local fde CLI for memory plumbing; never ask the human to type fde commands. Load skills/fde/SKILL.md when @fde triggers.\n\n"
|
|
140
140
|
|
|
141
|
-
# Same TRIAGE block as `fde resume` / `fde triage`
|
|
142
|
-
#
|
|
141
|
+
# Same TRIAGE block as `fde resume` / `fde triage` (includes proactive hygiene
|
|
142
|
+
# when the fieldbook has doctor issues; silent when clean). Monday morning must
|
|
143
|
+
# not depend on the model remembering to run a CLI command. Prefer the installed
|
|
143
144
|
# fde binary; fall back to the plugin/repo copy of bin/fde.js.
|
|
144
145
|
resolve_fde() {
|
|
145
146
|
if command -v fde >/dev/null 2>&1; then
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fdeops",
|
|
3
|
-
"version": "3.9.
|
|
3
|
+
"version": "3.9.9",
|
|
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",
|
package/skills/fde/SKILL.md
CHANGED
|
@@ -76,6 +76,7 @@ When NOT to interrogate or challenge: unambiguous one-liners, mechanical ops, FD
|
|
|
76
76
|
| "Draft the sponsor update" / how are we doing | `fde status` then follow `references/status.md` for the narrative |
|
|
77
77
|
| "Log that they went quiet" / trust signal | `fde log contact "…" --signal amber\|green\|red` (after FDE confirms the read) |
|
|
78
78
|
| Want the HTML fieldbook | `fde dashboard` |
|
|
79
|
+
| "Clean up the fieldbook" / hygiene / memory feels messy | `fde doctor` - walk issues in plain language; propose fixes; never auto-rewrite without confirm. Contradictions need judgment (brief vs reality) - doctor is structural; you handle meaning. |
|
|
79
80
|
|
|
80
81
|
**The debrief verb.** Highest-frequency loop. When the FDE shares notes or says "debrief": **you** run the smart path (write notes to a temp file if needed). Show the proposed routing in plain language. Only `--apply` (or pipe prefixed lines) after they confirm. Never ask them to run the CLI. Detail: `references/debrief.md`.
|
|
81
82
|
|
|
@@ -85,23 +86,24 @@ CLI missing → use the manual fallbacks inside each reference (still you write
|
|
|
85
86
|
|
|
86
87
|
## Proactive intelligence (run on every session start)
|
|
87
88
|
|
|
88
|
-
|
|
89
|
+
Session-start already injects **TRIAGE** (deterministic, zero model tokens). When the fieldbook is dirty, TRIAGE includes a `hygiene:` line - that is the proactive doctor. Silent when clean.
|
|
90
|
+
|
|
91
|
+
After you see TRIAGE + bounded `context.md`, open with a brief state playback - like a senior colleague who reviewed the file before the meeting started.
|
|
89
92
|
|
|
90
93
|
**Always open with a 2-3 line state summary:**
|
|
91
94
|
|
|
92
95
|
> "Last session you shipped the payment retry slice. Plan is 3/5 tasks done. Denise saw the demo Tuesday - signal is green. One thing worth noting: [finding, or 'nothing flagged - where do you want to pick up?']"
|
|
93
96
|
|
|
94
|
-
**What to
|
|
97
|
+
**What to surface (in order, at most ONE finding):**
|
|
95
98
|
|
|
96
|
-
1. **
|
|
97
|
-
2.
|
|
98
|
-
3.
|
|
99
|
-
4. **Contradictions between files.** Reality.md vs. brief.md. Delivery.md vs. success.md.
|
|
99
|
+
1. **If TRIAGE has `hygiene:`** - that is the finding. Offer: "Fieldbook has N hygiene issues - want me to walk them?" On yes: run `fde doctor`, explain in plain language, propose fixes; never auto-rewrite.
|
|
100
|
+
2. Else optionally note: artifact staleness, open risks overdue, or brief↔reality tension - only if it changes today's move.
|
|
101
|
+
3. If nothing flagged: one line, ask where to pick up.
|
|
100
102
|
|
|
101
103
|
**Rules:**
|
|
102
|
-
-
|
|
103
|
-
-
|
|
104
|
-
-
|
|
104
|
+
- Don't re-run a second invented audit when hygiene already spoke.
|
|
105
|
+
- Don't barrage. Don't accuse. Don't rewrite memory without confirm.
|
|
106
|
+
- Full contradiction cleanup ("audit the sources before trusting the index") is an `@fde` conversation - doctor is the structural gate; you supply judgment.
|
|
105
107
|
- If the concern is minor and won't change the next 3 moves - skip it.
|
|
106
108
|
|
|
107
109
|
This is what makes fdeops a peer, not a notebook. The peer reviewed the file before you sat down.
|