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.
@@ -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 }
@@ -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` - Monday morning must not
142
- # depend on the model remembering to run a CLI command. Prefer the installed
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.6",
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",
@@ -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
- After loading `context.md` via `fde resume`, run a quick integrity scan and open with a brief state playback - like a senior colleague who reviewed the file before the meeting started.
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 scan (in order, surface only what matters):**
97
+ **What to surface (in order, at most ONE finding):**
95
98
 
96
- 1. **Artifact staleness.** Any file the current work depends on that's 10+ days stale? Especially stakeholders.md (signals decay fast) and risks.md (unactioned risks compound).
97
- 2. **Plan-success alignment.** Tasks in decisions.md that don't trace to any outcome in success.md - they may have absorbed in as scope creep.
98
- 3. **Open risks overdue.** Critical or high risk open 7+ days with no mitigation.
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
- - Surface at most ONE finding alongside the state summary. Don't barrage.
103
- - If nothing's flagged, say so in one line and ask where they want to pick up.
104
- - Frame as observation: "I'm noticing stakeholders.md is 12 days old" - not accusation.
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.