fdeops 3.8.3 → 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/README.md +8 -4
- package/adapters/cursor.fde.mdc +9 -1
- package/bin/fde.js +626 -105
- package/hooks/session-start +32 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -142,9 +142,13 @@ Deterministic, offline, zero tokens - the skill adds judgment on top:
|
|
|
142
142
|
|
|
143
143
|
```bash
|
|
144
144
|
fde scan # day-1 recon + ASK ON DAY 1 questions (works via npx)
|
|
145
|
-
fde resume # load this workspace's engagement
|
|
146
|
-
fde resume --init <client> # THE setup step: create + bind
|
|
147
|
-
fde
|
|
145
|
+
fde resume # TRIAGE + load this workspace's engagement
|
|
146
|
+
fde resume --init <client> # THE setup step: create + bind + git-version .fde/
|
|
147
|
+
fde triage # TRIAGE only (session hooks / Cursor entry)
|
|
148
|
+
fde debrief notes.md # route prefixed meeting notes (also reads stdin)
|
|
149
|
+
fde debrief --smart notes.md # propose routing from messy notes → --apply to confirm
|
|
150
|
+
fde prep "Denise sync" # grounded walk-in brief from existing memory
|
|
151
|
+
fde doctor # lint: stale signals, unset phase, gaps
|
|
148
152
|
fde log decision "descope agreed with Kowalczyk"
|
|
149
153
|
fde log contact "Denise gone quiet" --signal amber
|
|
150
154
|
fde receipts <term> # dated search; no hit = a gap in the record, not proof of absence
|
|
@@ -154,7 +158,7 @@ fde dashboard # current engagement fieldbook (add --all for
|
|
|
154
158
|
|
|
155
159
|
Optional: `export FDEOPS_ENGAGEMENTS_ROOT=~/path/to/engagements` to isolate init/status/dashboard from the default `~/fde-engagements`.
|
|
156
160
|
|
|
157
|
-
|
|
161
|
+
Each `.fde/` is a local git repo (no remote, no telemetry) — dated entries carry an author tag; every write commits so receipts are tamper-evident. Worst-of `[signal:...]` per stakeholder drives trust; signals older than 21 days show as stale.
|
|
158
162
|
|
|
159
163
|
<p align="center"><img src="media/terminal-demo.svg" alt="fde CLI - status, scan, dashboard" width="720"/></p>
|
|
160
164
|
|
package/adapters/cursor.fde.mdc
CHANGED
|
@@ -18,7 +18,15 @@ When the FDE types **`@fde`** or describes an engagement situation (new customer
|
|
|
18
18
|
|
|
19
19
|
Read and write engagement files under the workspace's bound engagement: run `fde resume` to resolve it (binding created once with `fde resume --init <name>`; default `~/fde-engagements/<name>/.fde/`). `FDEOPS_ENGAGEMENT` (expand `~`) overrides when set. Use `./.fde/` only when the engagement approves it and it is gitignored.
|
|
20
20
|
|
|
21
|
-
On entry
|
|
21
|
+
**On every session entry (before other work):** run `fde triage` (fallback `node ~/.claude/fdeops/fde.js triage`, then `fde resume`). Lead with that TRIAGE block — trust, phase, open risks, next action, record owner/hash. Do not invent stakeholders or status.
|
|
22
|
+
|
|
23
|
+
Use the CLI for deterministic work - `fde scan | log | debrief | prep | doctor | receipts | status | dashboard` - instead of improvising shell.
|
|
24
|
+
|
|
25
|
+
### Meeting → memory loop
|
|
26
|
+
- Messy notes: `fde debrief --smart notes.md` → review `.debrief-propose` → `fde debrief --apply`
|
|
27
|
+
- Prefixed notes: `fde debrief notes.md` (or pipe stdin)
|
|
28
|
+
- Walk-in: `fde prep "Denise sync"`
|
|
29
|
+
- Nothing enters the record unreviewed when using `--smart` (confirm with `--apply`)
|
|
22
30
|
|
|
23
31
|
## Voice
|
|
24
32
|
|
package/bin/fde.js
CHANGED
|
@@ -12,8 +12,14 @@
|
|
|
12
12
|
* fde resume find this workspace's engagement, print bounded context
|
|
13
13
|
* fde resume --full same, but the complete context.md (no bound)
|
|
14
14
|
* fde resume --init <n> create + bind an engagement for this workspace
|
|
15
|
+
* fde triage deterministic TRIAGE block (hooks / Cursor entry)
|
|
15
16
|
* fde log <type> <text> structured append (decision|risk|delivery|contact)
|
|
16
17
|
* fde debrief [file] meeting notes → structured memory (stdin if no file)
|
|
18
|
+
* fde debrief --smart propose routing from messy notes; --apply commits it
|
|
19
|
+
* fde prep [label] grounded walk-in brief from existing .fde/ only
|
|
20
|
+
* fde doctor deterministic memory lint (stale signals, gaps)
|
|
21
|
+
* fde garden [--apply] propose safe consolidations; apply only with --apply
|
|
22
|
+
* fde owner [set …] who keeps this engagement record
|
|
17
23
|
* fde receipts <term> "what did we agree?" - search memory with dates
|
|
18
24
|
* fde capture session-end snapshot → context.md (hooks use this)
|
|
19
25
|
* fde status [--all] current engagement (default) or full portfolio (--all)
|
|
@@ -322,6 +328,131 @@ function rmTreeQuiet(dir) {
|
|
|
322
328
|
try { fs.rmSync(dir, { recursive: true, force: true }) } catch (_) {}
|
|
323
329
|
}
|
|
324
330
|
|
|
331
|
+
// ---------- versioned engagement memory (tamper-evident receipts) ----------
|
|
332
|
+
// Each .fde/ is its own git repo. Writes auto-commit. No new npm deps - shell git.
|
|
333
|
+
// Skips quietly if git is missing (warn once). Cross-process safety stays on
|
|
334
|
+
// withFileLock + atomic rename; this layer is history + attribution, not locking.
|
|
335
|
+
|
|
336
|
+
const OWNER_FILE = '.owner'
|
|
337
|
+
const DEBRIEF_PROPOSE = '.debrief-propose'
|
|
338
|
+
let _gitWarned = false
|
|
339
|
+
|
|
340
|
+
function gitBinOk() {
|
|
341
|
+
try {
|
|
342
|
+
execFileSync('git', ['--version'], { stdio: 'ignore', timeout: 5000 })
|
|
343
|
+
return true
|
|
344
|
+
} catch (_) { return false }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function readOwner(eng) {
|
|
348
|
+
try {
|
|
349
|
+
const raw = fs.readFileSync(path.join(eng, OWNER_FILE), 'utf8')
|
|
350
|
+
const name = (raw.match(/^name:\s*(.+)$/m) || [])[1]
|
|
351
|
+
const email = (raw.match(/^email:\s*(.+)$/m) || [])[1]
|
|
352
|
+
if (name && email) return { name: name.trim(), email: email.trim() }
|
|
353
|
+
} catch (_) {}
|
|
354
|
+
return null
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function writeOwnerIfMissing(eng) {
|
|
358
|
+
if (readOwner(eng)) return readOwner(eng)
|
|
359
|
+
const name = sh('git config user.name') || process.env.USER || process.env.LOGNAME || 'fde'
|
|
360
|
+
const email = sh('git config user.email') || `${String(name).replace(/\s+/g, '.').toLowerCase()}@local`
|
|
361
|
+
const body = `name: ${name}\nemail: ${email}\n`
|
|
362
|
+
try {
|
|
363
|
+
withFileLock(path.join(eng, OWNER_FILE), () => {
|
|
364
|
+
atomicWriteFile(path.join(eng, OWNER_FILE), body)
|
|
365
|
+
})
|
|
366
|
+
} catch (_) {
|
|
367
|
+
try { atomicWriteFile(path.join(eng, OWNER_FILE), body) } catch (_) {}
|
|
368
|
+
}
|
|
369
|
+
return { name, email }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function authorBracket(eng) {
|
|
373
|
+
const o = writeOwnerIfMissing(eng)
|
|
374
|
+
const id = (o.email.includes('@') ? o.email.split('@')[0] : o.name)
|
|
375
|
+
.replace(/[^\w.-]/g, '')
|
|
376
|
+
.slice(0, 40)
|
|
377
|
+
return id ? `@${id}` : ''
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function datedEntry(eng, date, text, signal) {
|
|
381
|
+
const who = authorBracket(eng)
|
|
382
|
+
const bits = [`- [${date}]`]
|
|
383
|
+
if (who) bits.push(`[${who}]`)
|
|
384
|
+
if (signal) bits.push(`[signal:${signal}]`)
|
|
385
|
+
bits.push(text)
|
|
386
|
+
return bits.join(' ')
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function ensureMemoryGit(eng) {
|
|
390
|
+
if (!eng || !fs.existsSync(eng)) return false
|
|
391
|
+
if (fs.existsSync(path.join(eng, '.git'))) {
|
|
392
|
+
writeOwnerIfMissing(eng)
|
|
393
|
+
return true
|
|
394
|
+
}
|
|
395
|
+
if (!gitBinOk()) {
|
|
396
|
+
if (!_gitWarned) {
|
|
397
|
+
process.stderr.write('⚠ git not found - engagement memory will not be versioned (receipts stay dated, but not tamper-evident)\n')
|
|
398
|
+
_gitWarned = true
|
|
399
|
+
}
|
|
400
|
+
writeOwnerIfMissing(eng)
|
|
401
|
+
return false
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
execFileSync('git', ['init'], { cwd: eng, stdio: 'ignore', timeout: 10000 })
|
|
405
|
+
atomicWriteFile(
|
|
406
|
+
path.join(eng, '.gitignore'),
|
|
407
|
+
['*.lock', '*.tmp', '.last-write', '.debrief-propose', ''].join('\n')
|
|
408
|
+
)
|
|
409
|
+
writeOwnerIfMissing(eng)
|
|
410
|
+
commitMemory(eng, 'init engagement memory')
|
|
411
|
+
return true
|
|
412
|
+
} catch (_) {
|
|
413
|
+
return false
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function commitMemory(eng, message) {
|
|
418
|
+
if (!eng || !fs.existsSync(path.join(eng, '.git'))) {
|
|
419
|
+
if (!ensureMemoryGit(eng)) return null
|
|
420
|
+
}
|
|
421
|
+
if (!gitBinOk()) return null
|
|
422
|
+
const owner = writeOwnerIfMissing(eng)
|
|
423
|
+
try {
|
|
424
|
+
execFileSync('git', ['add', '-A'], { cwd: eng, stdio: 'ignore', timeout: 10000 })
|
|
425
|
+
const porcelain = execFileSync('git', ['status', '--porcelain'], {
|
|
426
|
+
cwd: eng, encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
427
|
+
})
|
|
428
|
+
if (!String(porcelain || '').trim()) return null
|
|
429
|
+
const env = {
|
|
430
|
+
...process.env,
|
|
431
|
+
GIT_AUTHOR_NAME: owner.name,
|
|
432
|
+
GIT_AUTHOR_EMAIL: owner.email,
|
|
433
|
+
GIT_COMMITTER_NAME: owner.name,
|
|
434
|
+
GIT_COMMITTER_EMAIL: owner.email,
|
|
435
|
+
}
|
|
436
|
+
execFileSync('git', ['-c', 'commit.gpgsign=false', 'commit', '-m', String(message || 'memory write').slice(0, 72)], {
|
|
437
|
+
cwd: eng, stdio: 'ignore', timeout: 15000, env,
|
|
438
|
+
})
|
|
439
|
+
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
|
|
440
|
+
cwd: eng, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
441
|
+
}).toString().trim()
|
|
442
|
+
} catch (_) {
|
|
443
|
+
return null
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function memoryHead(eng) {
|
|
448
|
+
if (!eng || !fs.existsSync(path.join(eng, '.git'))) return ''
|
|
449
|
+
try {
|
|
450
|
+
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
|
|
451
|
+
cwd: eng, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
452
|
+
}).toString().trim()
|
|
453
|
+
} catch (_) { return '' }
|
|
454
|
+
}
|
|
455
|
+
|
|
325
456
|
// Pull the body under a "## Heading" up to the next "##" (or EOF).
|
|
326
457
|
function sectionBody(md, heading) {
|
|
327
458
|
const lines = md.split('\n')
|
|
@@ -365,7 +496,8 @@ function appendUnderSection(md, heading, entry) {
|
|
|
365
496
|
// them matched what extractStakeholders actually read). A contact entry
|
|
366
497
|
// carrying a [signal:x] token - however it got there - lands inside
|
|
367
498
|
// "## Signal history"; everything else is a plain end-of-file append.
|
|
368
|
-
function appendLogEntry(eng, type, entry) {
|
|
499
|
+
function appendLogEntry(eng, type, entry, opts = {}) {
|
|
500
|
+
ensureMemoryGit(eng)
|
|
369
501
|
const p = path.join(eng, LOG_FILES[type])
|
|
370
502
|
if (type === 'contact' && /\[signal:(red|amber|green)\]/i.test(entry)) {
|
|
371
503
|
withFileLock(p, () => {
|
|
@@ -377,6 +509,7 @@ function appendLogEntry(eng, type, entry) {
|
|
|
377
509
|
lockedAppendFile(p, `\n${entry}\n`)
|
|
378
510
|
}
|
|
379
511
|
recordLastWrite(eng, LOG_FILES[type], entry)
|
|
512
|
+
if (!opts.skipCommit) commitMemory(eng, opts.commitMsg || `log ${type}`)
|
|
380
513
|
}
|
|
381
514
|
|
|
382
515
|
// phase / trust / top risk / freshness - identical heuristic for status + dashboard.
|
|
@@ -418,10 +551,12 @@ function stakeholdersMemoryHealth(eng) {
|
|
|
418
551
|
|
|
419
552
|
// Subject key for a signal-history line - first real name word (same spirit as
|
|
420
553
|
// extractStakeholders). A green about Randy must not clear an amber about Denise.
|
|
554
|
+
// Strip author tags [@email-local] so attribution never becomes the subject key.
|
|
421
555
|
function signalSubjectKey(text) {
|
|
422
|
-
const
|
|
556
|
+
const cleaned = String(text).replace(/\[@[^\]]+\]/g, '').replace(/\([^)]*\)/g, '')
|
|
557
|
+
const words = cleaned.split(/\s+/).filter(w => w && !/^(dr|mr|mrs|ms)\.?$/i.test(w))
|
|
423
558
|
const frag = (words[0] || '').replace(/[^a-z0-9]/gi, '').toLowerCase()
|
|
424
|
-
return frag.length >= 3 ? frag : ('anon:' +
|
|
559
|
+
return frag.length >= 3 ? frag : ('anon:' + cleaned.slice(0, 48).toLowerCase())
|
|
425
560
|
}
|
|
426
561
|
|
|
427
562
|
function parsePhase(ctx) {
|
|
@@ -470,7 +605,11 @@ function computeSignals(eng) {
|
|
|
470
605
|
const sm = l.match(/\[signal:(red|amber|green)\]/i)
|
|
471
606
|
if (!sm) continue
|
|
472
607
|
const date = (l.match(/\[(\d{4}-\d{2}-\d{2})\]/) || [])[1] || ''
|
|
473
|
-
const text = l.replace(/^\s*-\s*/, '')
|
|
608
|
+
const text = l.replace(/^\s*-\s*/, '')
|
|
609
|
+
.replace(/\[signal:(red|amber|green)\]/i, '')
|
|
610
|
+
.replace(/\[\d{4}-\d{2}-\d{2}\]/, '')
|
|
611
|
+
.replace(/\[@[^\]]+\]/g, '')
|
|
612
|
+
.trim()
|
|
474
613
|
const key = signalSubjectKey(text)
|
|
475
614
|
const prev = byPerson.get(key)
|
|
476
615
|
if (!prev || date >= prev.date) byPerson.set(key, { date, sig: sm[1].toLowerCase(), text })
|
|
@@ -613,89 +752,126 @@ function colIndex(headers, rx) { return headers.findIndex(h => rx.test(h)) }
|
|
|
613
752
|
// a person and a bullet that happens to name them. No token match -> keyword
|
|
614
753
|
// heuristic on the stance/signal cell. No table at all -> empty, never
|
|
615
754
|
// fabricated.
|
|
616
|
-
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.
|
|
617
758
|
const md = readClean(eng, 'stakeholders.md')
|
|
618
|
-
const table = parseMdTable(md)
|
|
619
|
-
if (!table) return []
|
|
620
|
-
const { headers, rows } = table
|
|
621
|
-
const nameIdx = colIndex(headers, /name/i)
|
|
622
|
-
if (nameIdx === -1) return []
|
|
623
|
-
const roleIdx = colIndex(headers, /^role$/i)
|
|
624
|
-
const stanceIdx = colIndex(headers, /stance|signal/i)
|
|
625
|
-
const notesIdx = colIndex(headers, /notes?/i)
|
|
626
|
-
|
|
627
|
-
const history = []
|
|
628
|
-
// Format-agnostic on token position: `fde log contact --signal` writes
|
|
629
|
-
// "[date] [signal:x] text" (token right after the date), but `fde debrief`
|
|
630
|
-
// appends the token at the END of whatever the agent wrote per the skill's
|
|
631
|
-
// own contact: convention - "[date] text [signal:x]". Both are subject-first
|
|
632
|
-
// once the token is stripped, so match the token anywhere on the line rather
|
|
633
|
-
// than requiring it immediately after the date; a debrief-written signal was
|
|
634
|
-
// silently invisible to per-stakeholder matching before this.
|
|
635
759
|
const histText = sectionBody(md, 'Signal history') + '\n' + readEng(eng, SIGNAL_LEDGER)
|
|
760
|
+
const history = []
|
|
636
761
|
histText.split('\n').forEach(l => {
|
|
637
762
|
const dm = l.trim().match(/^-\s*\[(\d{4}-\d{2}-\d{2})\]\s*(.*)$/i)
|
|
638
763
|
if (!dm) return
|
|
639
764
|
const sm = dm[2].match(/\[signal:(red|amber|green)\]/i)
|
|
640
765
|
if (!sm) return
|
|
641
|
-
const text = dm[2]
|
|
766
|
+
const text = dm[2]
|
|
767
|
+
.replace(/\[signal:(red|amber|green)\]/i, '')
|
|
768
|
+
.replace(/\[@[^\]]+\]/g, '')
|
|
769
|
+
.trim()
|
|
642
770
|
history.push({ date: dm[1], signal: sm[1].toLowerCase(), text })
|
|
643
771
|
})
|
|
772
|
+
return history
|
|
773
|
+
}
|
|
644
774
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
+
}
|
|
669
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'
|
|
819
|
+
}
|
|
820
|
+
byKey.set(signalSubjectKey(name), { name, role, note, signal, source: 'table' })
|
|
670
821
|
}
|
|
671
822
|
}
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
+
})
|
|
677
844
|
}
|
|
678
|
-
|
|
679
|
-
|
|
845
|
+
}
|
|
846
|
+
return [...byKey.values()]
|
|
680
847
|
}
|
|
681
848
|
|
|
682
|
-
// Risks:
|
|
683
|
-
// severity field, so severity is a coarse high/med keyword guess on the risk
|
|
684
|
-
// text itself - a guess, same honesty as computeSignals()'s trust fallback,
|
|
685
|
-
// not a claim of real triage. "## Retired" rows are prose bullets, not table
|
|
686
|
-
// rows, so the table parser above already stops before them - they feed the
|
|
687
|
-
// log instead (see extractLog).
|
|
849
|
+
// Risks: table rows AND dated CLI/debrief bullets. Empty template cells ignored.
|
|
688
850
|
function extractRisks(eng) {
|
|
689
851
|
const md = readClean(eng, 'risks.md')
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
const
|
|
693
|
-
|
|
694
|
-
const
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
|
699
875
|
}
|
|
700
876
|
|
|
701
877
|
// Best-effort scan for "before -> after" metric callouts in delivery/decisions
|
|
@@ -917,6 +1093,12 @@ function cmdResume(args) {
|
|
|
917
1093
|
// NDA surface: engagement notes must not silently leave the machine via file sync
|
|
918
1094
|
const syncHit = /icloud|mobile documents|dropbox|onedrive|google drive|box sync/i.exec(ENGAGEMENTS_ROOT)
|
|
919
1095
|
if (syncHit) console.log(`⚠ engagements root is inside a synced folder ("${syncHit[0]}") - client notes will leave this machine via sync. See PRIVACY.md.`)
|
|
1096
|
+
// Tamper-evident fieldbook: version .fde/ with git (local only, no remote).
|
|
1097
|
+
if (ensureMemoryGit(fdeDir)) {
|
|
1098
|
+
const owner = readOwner(fdeDir)
|
|
1099
|
+
const head = memoryHead(fdeDir)
|
|
1100
|
+
console.log(`memory git: ${head || 'ready'}${owner ? ` owner: ${owner.email}` : ''}`)
|
|
1101
|
+
}
|
|
920
1102
|
return
|
|
921
1103
|
}
|
|
922
1104
|
if (args[0] === '--bind') {
|
|
@@ -999,7 +1181,8 @@ function cmdLogUndo() {
|
|
|
999
1181
|
if (led != null) withFileLock(ledgerPath, () => { atomicWriteFile(ledgerPath, led.endsWith('\n') ? led : led + '\n') })
|
|
1000
1182
|
}
|
|
1001
1183
|
try { fs.unlinkSync(metaPath) } catch (_) {}
|
|
1002
|
-
|
|
1184
|
+
const hash = commitMemory(eng, `undo ${meta.file}`)
|
|
1185
|
+
console.log(`undid last write → ${meta.file}${hash ? ` @${hash}` : ''}`)
|
|
1003
1186
|
}
|
|
1004
1187
|
|
|
1005
1188
|
function cmdLog(args) {
|
|
@@ -1027,8 +1210,8 @@ function cmdLog(args) {
|
|
|
1027
1210
|
console.error('usage: fde log phase <land|discover|plan|build|ship|close>')
|
|
1028
1211
|
process.exit(1)
|
|
1029
1212
|
}
|
|
1030
|
-
setContextPhase(eng, phase)
|
|
1031
|
-
console.log(`phase → ${phase}`)
|
|
1213
|
+
const hash = setContextPhase(eng, phase)
|
|
1214
|
+
console.log(`phase → ${phase}${hash ? ` @${hash}` : ''}`)
|
|
1032
1215
|
return
|
|
1033
1216
|
}
|
|
1034
1217
|
|
|
@@ -1038,12 +1221,14 @@ function cmdLog(args) {
|
|
|
1038
1221
|
if (hit && !force) { refuseSecret('log text', hit); process.exit(1) }
|
|
1039
1222
|
if (hit && force) console.error(`warning: logging possible ${hit} (--force)`)
|
|
1040
1223
|
const date = new Date().toISOString().slice(0, 10)
|
|
1041
|
-
const entry =
|
|
1224
|
+
const entry = datedEntry(eng, date, text, signal || '')
|
|
1042
1225
|
appendLogEntry(eng, type, entry)
|
|
1043
|
-
|
|
1226
|
+
const hash = memoryHead(eng)
|
|
1227
|
+
console.log(`logged → ${LOG_FILES[type]}${signal ? ` (signal:${signal})` : ''}${hash ? ` @${hash}` : ''}`)
|
|
1044
1228
|
}
|
|
1045
1229
|
|
|
1046
1230
|
function setContextPhase(eng, phase) {
|
|
1231
|
+
ensureMemoryGit(eng)
|
|
1047
1232
|
const p = path.join(eng, 'context.md')
|
|
1048
1233
|
let md = readEng(eng, 'context.md')
|
|
1049
1234
|
if (!md) md = '# Engagement context\n\n'
|
|
@@ -1057,6 +1242,7 @@ function setContextPhase(eng, phase) {
|
|
|
1057
1242
|
md = md.replace(/\*\*Last updated:\*\*\s*.*/i, `**Last updated:** ${today}`)
|
|
1058
1243
|
}
|
|
1059
1244
|
withFileLock(p, () => { atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n') })
|
|
1245
|
+
return commitMemory(eng, `phase ${phase}`)
|
|
1060
1246
|
}
|
|
1061
1247
|
|
|
1062
1248
|
|
|
@@ -1065,21 +1251,79 @@ function setContextPhase(eng, phase) {
|
|
|
1065
1251
|
// LOG_FILES target as dated bullets; everything else lands in context.md as one
|
|
1066
1252
|
// dated debrief block. contact: lines may carry an inline [signal:x] token
|
|
1067
1253
|
// anywhere in the text - preserved verbatim so computeSignals can trust it.
|
|
1068
|
-
//
|
|
1069
|
-
//
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1254
|
+
// --smart: heuristic propose from messy prose (confirm with --apply). No network.
|
|
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
|
+
|
|
1270
|
+
function smartProposeText(input) {
|
|
1271
|
+
const out = []
|
|
1272
|
+
for (const raw of input.split('\n')) {
|
|
1273
|
+
const line = raw.trim()
|
|
1274
|
+
if (!line) continue
|
|
1275
|
+
let bare = line
|
|
1276
|
+
.replace(/^[-*+]\s+/, '')
|
|
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)
|
|
1285
|
+
continue
|
|
1286
|
+
}
|
|
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)) {
|
|
1294
|
+
out.push(`decision: ${bare}`)
|
|
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)) {
|
|
1298
|
+
out.push(`risk: ${bare}`)
|
|
1299
|
+
} else if (/\b(shipped|delivered|deployed|merged PR|rolled out|went live)\b/i.test(bare)) {
|
|
1300
|
+
out.push(`delivery: ${bare}`)
|
|
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}`)
|
|
1305
|
+
} else {
|
|
1306
|
+
out.push(bare)
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
return out.join('\n') + (out.length ? '\n' : '')
|
|
1310
|
+
}
|
|
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
|
+
|
|
1326
|
+
function readDebriefInput(args) {
|
|
1083
1327
|
let input = ''
|
|
1084
1328
|
if (args[0]) {
|
|
1085
1329
|
const notesPath = args[0].replace(/^~/, HOME)
|
|
@@ -1097,36 +1341,46 @@ function cmdDebrief(args) {
|
|
|
1097
1341
|
}
|
|
1098
1342
|
input = buf.toString('utf8')
|
|
1099
1343
|
} else {
|
|
1100
|
-
try { input = fs.readFileSync(0, 'utf8') } catch (_) {}
|
|
1344
|
+
try { input = fs.readFileSync(0, 'utf8') } catch (_) {}
|
|
1101
1345
|
if (Buffer.byteLength(input, 'utf8') > DEBRIEF_MAX_BYTES) {
|
|
1102
1346
|
console.error(`debrief refused: stdin is over ${DEBRIEF_MAX_BYTES} bytes. Split the notes.`)
|
|
1103
1347
|
process.exit(1)
|
|
1104
1348
|
}
|
|
1105
1349
|
}
|
|
1350
|
+
return input
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function routeDebriefInput(eng, input, { dry, force }) {
|
|
1106
1354
|
const d = new Date()
|
|
1107
1355
|
const date = d.toISOString().slice(0, 10)
|
|
1108
|
-
const counts = { decision: 0, risk: 0, delivery: 0, contact: 0 }
|
|
1356
|
+
const counts = { decision: 0, risk: 0, delivery: 0, contact: 0, next: 0 }
|
|
1109
1357
|
const ctxLines = []
|
|
1358
|
+
let nextAction = ''
|
|
1359
|
+
ensureMemoryGit(eng)
|
|
1110
1360
|
for (const raw of input.split('\n')) {
|
|
1111
1361
|
let line = raw.trim()
|
|
1112
1362
|
if (!line) continue
|
|
1113
|
-
|
|
1114
|
-
const
|
|
1115
|
-
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)
|
|
1116
1365
|
if (m) {
|
|
1117
1366
|
const type = m[1].toLowerCase()
|
|
1118
|
-
|
|
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
|
+
}
|
|
1374
|
+
const sigInline = (body.match(/\[signal:(red|amber|green)\]/i) || [])[1]
|
|
1375
|
+
if (sigInline) body = body.replace(/\[signal:(red|amber|green)\]/i, '').trim()
|
|
1119
1376
|
const hit = findSecretHit(body)
|
|
1120
1377
|
if (hit && !force) {
|
|
1121
1378
|
console.error(`skipped ${type} line - looks like a ${hit}. Redact it, or re-run with --force.`)
|
|
1122
1379
|
continue
|
|
1123
1380
|
}
|
|
1124
|
-
const entry =
|
|
1381
|
+
const entry = datedEntry(eng, date, body, type === 'contact' && sigInline ? sigInline.toLowerCase() : '')
|
|
1125
1382
|
if (dry) console.log(`→ ${LOG_FILES[type]} ${entry}`)
|
|
1126
|
-
|
|
1127
|
-
// inline [signal:x] token (the skill's own convention) and must land
|
|
1128
|
-
// inside "## Signal history" the same way `fde log --signal` does.
|
|
1129
|
-
else appendLogEntry(eng, type, entry)
|
|
1383
|
+
else appendLogEntry(eng, type, entry, { skipCommit: true })
|
|
1130
1384
|
counts[type]++
|
|
1131
1385
|
} else {
|
|
1132
1386
|
if (findSecretHit(line) && !force) {
|
|
@@ -1136,14 +1390,69 @@ function cmdDebrief(args) {
|
|
|
1136
1390
|
ctxLines.push(line)
|
|
1137
1391
|
}
|
|
1138
1392
|
}
|
|
1393
|
+
if (nextAction && !dry) setNextAction(eng, nextAction)
|
|
1139
1394
|
if (ctxLines.length) {
|
|
1140
1395
|
const stamp = `${date} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
1141
1396
|
if (dry) ctxLines.forEach(l => console.log(`→ context.md - ${l}`))
|
|
1142
1397
|
else lockedAppendFile(path.join(eng, 'context.md'), `\n## Debrief - ${stamp}\n${ctxLines.map(l => `- ${l}`).join('\n')}\n`)
|
|
1143
1398
|
}
|
|
1144
|
-
|
|
1399
|
+
return { counts, ctxLines, date, nextAction }
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
function cmdDebrief(args) {
|
|
1403
|
+
args = args.slice()
|
|
1404
|
+
const dryIdx = args.indexOf('--dry-run')
|
|
1405
|
+
const dry = dryIdx !== -1
|
|
1406
|
+
if (dry) args.splice(dryIdx, 1)
|
|
1407
|
+
const smartIdx = args.indexOf('--smart')
|
|
1408
|
+
const smart = smartIdx !== -1
|
|
1409
|
+
if (smart) args.splice(smartIdx, 1)
|
|
1410
|
+
const applyIdx = args.indexOf('--apply')
|
|
1411
|
+
const apply = applyIdx !== -1
|
|
1412
|
+
if (apply) args.splice(applyIdx, 1)
|
|
1413
|
+
let force = false
|
|
1414
|
+
const forceIdx = args.indexOf('--force')
|
|
1415
|
+
if (forceIdx !== -1) { force = true; args.splice(forceIdx, 1) }
|
|
1416
|
+
|
|
1417
|
+
const eng = resolveEngagement({ forWrite: true })
|
|
1418
|
+
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
1419
|
+
|
|
1420
|
+
let input = ''
|
|
1421
|
+
if (apply && !smart && !args[0]) {
|
|
1422
|
+
try { input = fs.readFileSync(path.join(eng, DEBRIEF_PROPOSE), 'utf8') } catch (_) {
|
|
1423
|
+
console.error('nothing to apply - run: fde debrief --smart <notes.md> then fde debrief --apply')
|
|
1424
|
+
process.exit(1)
|
|
1425
|
+
}
|
|
1426
|
+
} else {
|
|
1427
|
+
input = readDebriefInput(args)
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
if (smart) {
|
|
1431
|
+
const proposed = smartProposeText(input)
|
|
1432
|
+
const proposePath = path.join(eng, DEBRIEF_PROPOSE)
|
|
1433
|
+
withFileLock(proposePath, () => { atomicWriteFile(proposePath, proposed) })
|
|
1434
|
+
console.log('SMART PROPOSE (heuristic - review before apply; no new facts invented beyond line rewrites)\n')
|
|
1435
|
+
routeDebriefInput(eng, proposed, { dry: true, force })
|
|
1436
|
+
if (!apply) {
|
|
1437
|
+
console.log(`\nproposal saved → ${proposePath}`)
|
|
1438
|
+
console.log('confirm: fde debrief --apply')
|
|
1439
|
+
console.log('(edit the propose file first if a line mis-routed)')
|
|
1440
|
+
return
|
|
1441
|
+
}
|
|
1442
|
+
input = proposed
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
const { counts, ctxLines } = routeDebriefInput(eng, input, { dry, force })
|
|
1446
|
+
if (!dry) {
|
|
1447
|
+
const hash = commitMemory(eng, 'debrief')
|
|
1448
|
+
try { fs.unlinkSync(path.join(eng, DEBRIEF_PROPOSE)) } catch (_) {}
|
|
1449
|
+
if (hash) console.log(`memory @${hash}`)
|
|
1450
|
+
}
|
|
1451
|
+
const plural = {
|
|
1452
|
+
decision: 'decisions', risk: 'risks', delivery: 'deliveries', contact: 'contacts', next: 'next actions',
|
|
1453
|
+
}
|
|
1145
1454
|
const parts = Object.keys(counts).filter(t => counts[t])
|
|
1146
|
-
.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]}`)
|
|
1147
1456
|
if (ctxLines.length) parts.push(`${ctxLines.length} context line${ctxLines.length === 1 ? '' : 's'}`)
|
|
1148
1457
|
const verb = dry ? 'debrief would route' : 'debrief routed'
|
|
1149
1458
|
console.log(parts.length ? `${verb} → ${parts.join(', ')}` : 'debrief empty - nothing routed')
|
|
@@ -1196,6 +1505,7 @@ function cmdReceipts(args) {
|
|
|
1196
1505
|
function cmdCapture() {
|
|
1197
1506
|
const eng = resolveEngagement({ forWrite: true })
|
|
1198
1507
|
if (!eng) process.exit(0) // silent: capture must never break a session
|
|
1508
|
+
// Workspace git facts (cwd), not the engagement memory repo.
|
|
1199
1509
|
const branch = sh('git branch --show-current')
|
|
1200
1510
|
const lastCommit = sh("git log -1 --format='%h %s'").slice(0, 100)
|
|
1201
1511
|
// porcelain lines are "XY path" - sh() trims, so parse by first whitespace
|
|
@@ -1212,7 +1522,206 @@ function cmdCapture() {
|
|
|
1212
1522
|
if (branch) block += `- workspace: \`${branch}\` @ ${lastCommit || 'no commits yet'}\n`
|
|
1213
1523
|
if (changed) block += `- uncommitted: ${changed}\n`
|
|
1214
1524
|
if (updated) block += `- engagement files updated: ${updated}\n`
|
|
1215
|
-
try {
|
|
1525
|
+
try {
|
|
1526
|
+
ensureMemoryGit(eng)
|
|
1527
|
+
lockedAppendFile(path.join(eng, 'context.md'), block, { soft: true })
|
|
1528
|
+
commitMemory(eng, 'session capture')
|
|
1529
|
+
} catch (_) {}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function cmdTriage() {
|
|
1533
|
+
const eng = resolveEngagement()
|
|
1534
|
+
if (!eng) {
|
|
1535
|
+
console.error('no engagement - run: fde resume --init <name>')
|
|
1536
|
+
process.exit(2)
|
|
1537
|
+
}
|
|
1538
|
+
console.log(resumeTriage(eng))
|
|
1539
|
+
const owner = readOwner(eng) || writeOwnerIfMissing(eng)
|
|
1540
|
+
const head = memoryHead(eng)
|
|
1541
|
+
if (owner || head) {
|
|
1542
|
+
console.log(` record: ${owner ? owner.email : '?'}${head ? ` memory@${head}` : ' (unversioned)'}`)
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
function cmdOwner(args) {
|
|
1547
|
+
const eng = resolveEngagement({ forWrite: args[0] === 'set' })
|
|
1548
|
+
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
1549
|
+
if (args[0] === 'set') {
|
|
1550
|
+
const email = args[1]
|
|
1551
|
+
if (!email || !email.includes('@')) {
|
|
1552
|
+
console.error('usage: fde owner set <email> [name...]')
|
|
1553
|
+
process.exit(1)
|
|
1554
|
+
}
|
|
1555
|
+
const name = args.slice(2).join(' ') || email.split('@')[0]
|
|
1556
|
+
ensureMemoryGit(eng)
|
|
1557
|
+
withFileLock(path.join(eng, OWNER_FILE), () => {
|
|
1558
|
+
atomicWriteFile(path.join(eng, OWNER_FILE), `name: ${name}\nemail: ${email}\n`)
|
|
1559
|
+
})
|
|
1560
|
+
const hash = commitMemory(eng, 'owner set')
|
|
1561
|
+
console.log(`owner → ${name} <${email}>${hash ? ` @${hash}` : ''}`)
|
|
1562
|
+
return
|
|
1563
|
+
}
|
|
1564
|
+
const o = readOwner(eng) || writeOwnerIfMissing(eng)
|
|
1565
|
+
console.log(`owner: ${o.name} <${o.email}>`)
|
|
1566
|
+
const head = memoryHead(eng)
|
|
1567
|
+
if (head) console.log(`memory HEAD: ${head}`)
|
|
1568
|
+
else console.log('memory HEAD: (unversioned - git init on next write)')
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
function cmdDoctor() {
|
|
1572
|
+
const eng = resolveEngagement()
|
|
1573
|
+
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
1574
|
+
const issues = []
|
|
1575
|
+
const s = computeSignals(eng)
|
|
1576
|
+
if (s.phase === '?') {
|
|
1577
|
+
const hasWork = /\[\d{4}-\d{2}-\d{2}\]/.test(readEng(eng, 'decisions.md') + readEng(eng, 'delivery.md'))
|
|
1578
|
+
if (hasWork) issues.push('phase is unset but dated work exists - run: fde log phase <land|discover|plan|build|ship|close>')
|
|
1579
|
+
else issues.push('phase is unset - set when you know where you are: fde log phase land')
|
|
1580
|
+
}
|
|
1581
|
+
if (s.stale) issues.push(`trust signal is STALE (${s.signalAge}d) - reconfirm with fde log contact ... --signal`)
|
|
1582
|
+
if (s.memoryWarn) issues.push(s.memoryWarn)
|
|
1583
|
+
if (!readOwner(eng)) issues.push('no .owner - run any write or: fde owner set you@firm.com')
|
|
1584
|
+
if (!fs.existsSync(path.join(eng, '.git'))) issues.push('memory not git-versioned - next write will init, or re-run resume --init')
|
|
1585
|
+
const success = readClean(eng, 'success.md')
|
|
1586
|
+
if (!firstLine(success, 80)) issues.push('success.md has no stated done-definition - fill before plan/build')
|
|
1587
|
+
if (!sectionBody(readClean(eng, 'context.md'), 'Next action')) {
|
|
1588
|
+
issues.push('no ## Next action in context.md - Monday morning has nothing to drive')
|
|
1589
|
+
}
|
|
1590
|
+
console.log(`FDE DOCTOR - ${engagementSlugFromPath(eng)}`)
|
|
1591
|
+
console.log(resumeTriage(eng))
|
|
1592
|
+
if (!issues.length) {
|
|
1593
|
+
console.log('\nOK - no structural issues (judgment still yours)')
|
|
1594
|
+
process.exit(0)
|
|
1595
|
+
}
|
|
1596
|
+
console.log(`\n${issues.length} issue(s):`)
|
|
1597
|
+
issues.forEach((i, n) => console.log(` ${n + 1}. ${i}`))
|
|
1598
|
+
process.exit(1)
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
function cmdPrep(args) {
|
|
1602
|
+
const eng = resolveEngagement()
|
|
1603
|
+
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
1604
|
+
const label = args.join(' ').trim() || 'next meeting'
|
|
1605
|
+
// Grounded brief: only text already in .fde/. No invention (Rowboat meeting-prep rule).
|
|
1606
|
+
console.log(`MEETING PREP — ${label}`)
|
|
1607
|
+
console.log('(grounded in local .fde/ only - if a fact is missing, it is missing)\n')
|
|
1608
|
+
console.log(resumeTriage(eng))
|
|
1609
|
+
const owner = readOwner(eng)
|
|
1610
|
+
const head = memoryHead(eng)
|
|
1611
|
+
if (owner || head) console.log(` record: ${owner ? owner.email : '?'}${head ? ` @${head}` : ''}`)
|
|
1612
|
+
|
|
1613
|
+
const people = extractStakeholders(eng).slice(0, 8)
|
|
1614
|
+
console.log('\nStakeholders (table + signal history)')
|
|
1615
|
+
if (!people.length) console.log(' (none yet - log contacts with --signal)')
|
|
1616
|
+
else people.forEach(p => console.log(` [${p.signal}] ${p.name}${p.role ? ` — ${p.role}` : ''}${p.note ? ` · ${p.note.slice(0, 60)}` : ''}`))
|
|
1617
|
+
|
|
1618
|
+
const risks = extractRisks(eng).slice(0, 5)
|
|
1619
|
+
console.log('\nOpen risks (table + dated bullets)')
|
|
1620
|
+
if (!risks.length) console.log(' (none logged)')
|
|
1621
|
+
else risks.forEach(r => console.log(` [${r.severity}] ${r.text.slice(0, 100)}`))
|
|
1622
|
+
|
|
1623
|
+
const success = firstLine(readClean(eng, 'success.md'), 160)
|
|
1624
|
+
console.log('\nSuccess looks like')
|
|
1625
|
+
console.log(success ? ` ${success}` : ' (success.md empty)')
|
|
1626
|
+
|
|
1627
|
+
const decisions = readClean(eng, 'decisions.md').split('\n')
|
|
1628
|
+
.filter(l => /^-\s*\[\d{4}-\d{2}-\d{2}\]/.test(l.trim()))
|
|
1629
|
+
.slice(-5)
|
|
1630
|
+
console.log('\nRecent decisions')
|
|
1631
|
+
if (!decisions.length) console.log(' (none logged)')
|
|
1632
|
+
else decisions.forEach(l => console.log(` ${l.trim().slice(0, 120)}`))
|
|
1633
|
+
|
|
1634
|
+
const next = nextActionLine(readClean(eng, 'context.md'))
|
|
1635
|
+
console.log('\nWalk in with')
|
|
1636
|
+
console.log(next ? ` ${next}` : ' (set ## Next action in context.md)')
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
function cmdGarden(args) {
|
|
1640
|
+
const apply = args.includes('--apply')
|
|
1641
|
+
const eng = resolveEngagement({ forWrite: apply })
|
|
1642
|
+
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
1643
|
+
// Gardener contract (from Rowboat note_curation): no new facts, no deleted substance,
|
|
1644
|
+
// reversible via git, confirm before apply. Mechanical only - no LLM rewrite.
|
|
1645
|
+
console.log('GARDEN (contract: no new facts · no deleted substance · reversible via memory git)')
|
|
1646
|
+
console.log(resumeTriage(eng))
|
|
1647
|
+
const proposals = []
|
|
1648
|
+
const s = computeSignals(eng)
|
|
1649
|
+
if (s.stale) {
|
|
1650
|
+
proposals.push({
|
|
1651
|
+
id: 'reconfirm-signal',
|
|
1652
|
+
kind: 'manual',
|
|
1653
|
+
text: `Reconfirm stale ${s.trust} signal (${s.signalAge}d): fde log contact "…" --signal`,
|
|
1654
|
+
})
|
|
1655
|
+
}
|
|
1656
|
+
const ctx = readEng(eng, 'context.md')
|
|
1657
|
+
const sessionBlocks = []
|
|
1658
|
+
const lines = ctx.split('\n')
|
|
1659
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1660
|
+
const m = lines[i].match(/^##\s+Session end\s+-\s+(\d{4}-\d{2}-\d{2})\b/)
|
|
1661
|
+
if (!m) continue
|
|
1662
|
+
const age = Math.floor((Date.now() - Date.parse(m[1])) / 86400000)
|
|
1663
|
+
if (age >= 60) sessionBlocks.push({ line: i, date: m[1], age })
|
|
1664
|
+
}
|
|
1665
|
+
if (sessionBlocks.length >= 3) {
|
|
1666
|
+
proposals.push({
|
|
1667
|
+
id: 'archive-sessions',
|
|
1668
|
+
kind: 'apply',
|
|
1669
|
+
text: `Archive ${sessionBlocks.length} session-end blocks older than 60d into context-archive.md`,
|
|
1670
|
+
sessionBlocks,
|
|
1671
|
+
})
|
|
1672
|
+
}
|
|
1673
|
+
if (!proposals.length) {
|
|
1674
|
+
console.log('\nNothing to garden.')
|
|
1675
|
+
return
|
|
1676
|
+
}
|
|
1677
|
+
console.log(`\n${proposals.length} proposal(s):`)
|
|
1678
|
+
proposals.forEach((p, i) => console.log(` ${i + 1}. [${p.kind}] ${p.text}`))
|
|
1679
|
+
if (!apply) {
|
|
1680
|
+
console.log('\nApply mechanical items only: fde garden --apply')
|
|
1681
|
+
console.log('Manual items stay yours. Every apply commits to memory git.')
|
|
1682
|
+
return
|
|
1683
|
+
}
|
|
1684
|
+
ensureMemoryGit(eng)
|
|
1685
|
+
let applied = 0
|
|
1686
|
+
for (const p of proposals) {
|
|
1687
|
+
if (p.id !== 'archive-sessions') continue
|
|
1688
|
+
const cutDates = new Set(p.sessionBlocks.map(b => b.date))
|
|
1689
|
+
const keep = []
|
|
1690
|
+
const archive = []
|
|
1691
|
+
let mode = 'keep'
|
|
1692
|
+
let buf = []
|
|
1693
|
+
const flush = () => {
|
|
1694
|
+
if (!buf.length) return
|
|
1695
|
+
;(mode === 'archive' ? archive : keep).push(...buf)
|
|
1696
|
+
buf = []
|
|
1697
|
+
}
|
|
1698
|
+
for (const line of lines) {
|
|
1699
|
+
const m = line.match(/^##\s+Session end\s+-\s+(\d{4}-\d{2}-\d{2})\b/)
|
|
1700
|
+
if (m) {
|
|
1701
|
+
flush()
|
|
1702
|
+
mode = cutDates.has(m[1]) ? 'archive' : 'keep'
|
|
1703
|
+
} else if (/^##\s+/.test(line) && mode === 'archive') {
|
|
1704
|
+
flush()
|
|
1705
|
+
mode = 'keep'
|
|
1706
|
+
}
|
|
1707
|
+
buf.push(line)
|
|
1708
|
+
}
|
|
1709
|
+
flush()
|
|
1710
|
+
if (!archive.length) continue
|
|
1711
|
+
const archPath = path.join(eng, 'context-archive.md')
|
|
1712
|
+
const prev = fs.existsSync(archPath) ? fs.readFileSync(archPath, 'utf8') : '# Context archive\n\n'
|
|
1713
|
+
withFileLock(archPath, () => {
|
|
1714
|
+
atomicWriteFile(archPath, prev.replace(/\n*$/, '\n\n') + archive.join('\n').trim() + '\n')
|
|
1715
|
+
})
|
|
1716
|
+
withFileLock(path.join(eng, 'context.md'), () => {
|
|
1717
|
+
atomicWriteFile(path.join(eng, 'context.md'), keep.join('\n').replace(/\n*$/, '\n'))
|
|
1718
|
+
})
|
|
1719
|
+
applied++
|
|
1720
|
+
console.log(`applied: archived ${p.sessionBlocks.length} old session-end blocks → context-archive.md`)
|
|
1721
|
+
}
|
|
1722
|
+
const hash = commitMemory(eng, 'garden')
|
|
1723
|
+
if (!applied) console.log('no mechanical proposals applied (manual items remain)')
|
|
1724
|
+
else console.log(`garden done${hash ? ` @${hash}` : ''}`)
|
|
1216
1725
|
}
|
|
1217
1726
|
|
|
1218
1727
|
function engagementSlugFromPath(eng) {
|
|
@@ -2021,24 +2530,36 @@ function printUsage() {
|
|
|
2021
2530
|
fde resume --full load the complete context.md (no bound)
|
|
2022
2531
|
fde resume --init <name> create + bind engagement for this workspace (rebind replaces)
|
|
2023
2532
|
fde resume --bind show what this workspace is bound to, and what resolves
|
|
2533
|
+
fde triage TRIAGE block only (hooks / Cursor session entry)
|
|
2024
2534
|
fde log <type> <text> append decision|risk|delivery|contact (contact takes --signal red|amber|green; --force to allow secret-like text)
|
|
2025
2535
|
fde log phase <phase> set engagement phase (land|discover|plan|build|ship|close)
|
|
2026
2536
|
fde log --undo remove the last CLI log/debrief entry from memory
|
|
2027
|
-
fde debrief [file] meeting notes → memory
|
|
2537
|
+
fde debrief [file] meeting notes → memory (prefixed lines; --dry-run; --force)
|
|
2538
|
+
fde debrief --smart propose routing from messy notes → review → fde debrief --apply
|
|
2539
|
+
fde prep [label] grounded walk-in brief from existing .fde/ only
|
|
2540
|
+
fde doctor lint engagement memory (stale signals, gaps)
|
|
2541
|
+
fde garden [--apply] propose safe consolidations (contract: no new facts; git-reversible)
|
|
2542
|
+
fde owner [set email] who keeps this engagement record
|
|
2028
2543
|
fde receipts <term> "what did we agree?" with dates
|
|
2029
2544
|
fde capture session-end memory snapshot (hooks use this)
|
|
2030
2545
|
fde status [--all] current engagement status (pass --all for full portfolio)
|
|
2031
2546
|
fde dashboard [--all] current engagement fieldbook (pass --all for every client)
|
|
2032
2547
|
env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)
|
|
2033
|
-
writes require a workspace bind (or FDEOPS_ENGAGEMENT) - folder-name match is read-only
|
|
2548
|
+
writes require a workspace bind (or FDEOPS_ENGAGEMENT) - folder-name match is read-only
|
|
2549
|
+
.fde/ is git-versioned locally for tamper-evident receipts (no remote, no telemetry)`)
|
|
2034
2550
|
}
|
|
2035
2551
|
|
|
2036
2552
|
const [cmd, ...args] = process.argv.slice(2)
|
|
2037
2553
|
switch (cmd) {
|
|
2038
2554
|
case 'scan': cmdScan(); break
|
|
2039
2555
|
case 'resume': cmdResume(args); break
|
|
2556
|
+
case 'triage': cmdTriage(); break
|
|
2040
2557
|
case 'log': cmdLog(args); break
|
|
2041
2558
|
case 'debrief': cmdDebrief(args); break
|
|
2559
|
+
case 'prep': cmdPrep(args); break
|
|
2560
|
+
case 'doctor': cmdDoctor(); break
|
|
2561
|
+
case 'garden': cmdGarden(args); break
|
|
2562
|
+
case 'owner': cmdOwner(args); break
|
|
2042
2563
|
case 'receipts': cmdReceipts(args); break
|
|
2043
2564
|
case 'capture': cmdCapture(); break
|
|
2044
2565
|
case 'status': cmdStatus(args); break
|
package/hooks/session-start
CHANGED
|
@@ -147,7 +147,39 @@ if [ -n "$BOOTSTRAP" ]; then
|
|
|
147
147
|
CONTENT="$CONTENT$(cat "$BOOTSTRAP")\n\n"
|
|
148
148
|
fi
|
|
149
149
|
|
|
150
|
+
# Same TRIAGE block as `fde resume` / `fde triage` - Monday morning must not
|
|
151
|
+
# depend on the model remembering to run a CLI command. Prefer the installed
|
|
152
|
+
# fde binary; fall back to the plugin/repo copy of bin/fde.js.
|
|
153
|
+
resolve_fde() {
|
|
154
|
+
if command -v fde >/dev/null 2>&1; then
|
|
155
|
+
printf '%s\n' "fde"
|
|
156
|
+
return 0
|
|
157
|
+
fi
|
|
158
|
+
for candidate in \
|
|
159
|
+
"${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/bin/fde.js}" \
|
|
160
|
+
"$(dirname "$0")/../bin/fde.js" \
|
|
161
|
+
"$HOME/.claude/fdeops/fde.js" \
|
|
162
|
+
"$HOME/.claude/plugins/fdeops/bin/fde.js"; do
|
|
163
|
+
if [ -n "$candidate" ] && [ -f "$candidate" ]; then
|
|
164
|
+
printf '%s\n' "$candidate"
|
|
165
|
+
return 0
|
|
166
|
+
fi
|
|
167
|
+
done
|
|
168
|
+
return 1
|
|
169
|
+
}
|
|
170
|
+
|
|
150
171
|
if [ -n "$CONTEXT_FILE" ] && [ -f "$CONTEXT_FILE" ]; then
|
|
172
|
+
FDE_CMD=$(resolve_fde || true)
|
|
173
|
+
if [ -n "$FDE_CMD" ]; then
|
|
174
|
+
if [ "$FDE_CMD" = "fde" ]; then
|
|
175
|
+
TRIAGE=$(fde triage 2>/dev/null || true)
|
|
176
|
+
else
|
|
177
|
+
TRIAGE=$(node "$FDE_CMD" triage 2>/dev/null || true)
|
|
178
|
+
fi
|
|
179
|
+
if [ -n "$TRIAGE" ]; then
|
|
180
|
+
CONTENT="$CONTENT---\n$TRIAGE\n\n"
|
|
181
|
+
fi
|
|
182
|
+
fi
|
|
151
183
|
REDACTED_CONTEXT=$(mktemp)
|
|
152
184
|
strip_private "$CONTEXT_FILE" > "$REDACTED_CONTEXT"
|
|
153
185
|
CONTENT="$CONTENT---\nEngagement context ($CONTEXT_FILE):\n$(bounded_context "$REDACTED_CONTEXT")\n"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fdeops",
|
|
3
|
-
"version": "3.
|
|
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",
|