fdeops 3.8.2 → 3.9.0
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 +584 -47
- package/hooks/session-start +32 -0
- package/package.json +1 -1
- package/templates/.fde/context.md +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.
|
|
@@ -416,31 +549,88 @@ function stakeholdersMemoryHealth(eng) {
|
|
|
416
549
|
return { ok: true, warn: '' }
|
|
417
550
|
}
|
|
418
551
|
|
|
552
|
+
// Subject key for a signal-history line - first real name word (same spirit as
|
|
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.
|
|
555
|
+
function signalSubjectKey(text) {
|
|
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))
|
|
558
|
+
const frag = (words[0] || '').replace(/[^a-z0-9]/gi, '').toLowerCase()
|
|
559
|
+
return frag.length >= 3 ? frag : ('anon:' + cleaned.slice(0, 48).toLowerCase())
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function parsePhase(ctx) {
|
|
563
|
+
// Template ships "**Phase:** land | discover | ..." - that is UNSET, not land.
|
|
564
|
+
const m = ctx.match(/\*\*Phase:\*\*\s*(.+)/i) || ctx.match(/^phase[:\s*]+(.+)$/im)
|
|
565
|
+
if (!m) return '?'
|
|
566
|
+
const raw = m[1].replace(/\*/g, '').trim()
|
|
567
|
+
if (!raw || /\|/.test(raw) || /^unset$/i.test(raw) || /^[\[(]/.test(raw)) return '?'
|
|
568
|
+
const one = raw.toLowerCase().match(/^(land|discover|plan|build|ship|close)\b/)
|
|
569
|
+
return one ? one[1] : '?'
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function countOpenRisks(eng) {
|
|
573
|
+
const md = readClean(eng, 'risks.md')
|
|
574
|
+
const body = md.split(/^#{1,6}\s+Retired\b/im)[0] || md
|
|
575
|
+
let n = 0
|
|
576
|
+
for (const raw of body.split('\n')) {
|
|
577
|
+
const t = raw.trim()
|
|
578
|
+
if (!t || t.startsWith('<!--') || /^#{1,6}\s/.test(t)) continue
|
|
579
|
+
if (/risk\s*\|\s*status|mitigation/i.test(t) || /^\|?[\s|:-]+$/.test(t)) continue
|
|
580
|
+
if (/^[-*]/.test(t) || (/^\|/.test(t) && t.length > 12)) n++
|
|
581
|
+
}
|
|
582
|
+
return n
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function nextActionLine(ctx) {
|
|
586
|
+
const body = sectionBody(ctx, 'Next action')
|
|
587
|
+
for (const raw of body.split('\n')) {
|
|
588
|
+
const t = raw.trim().replace(/^[-*]\s+/, '')
|
|
589
|
+
if (t) return t.slice(0, 120)
|
|
590
|
+
}
|
|
591
|
+
return ''
|
|
592
|
+
}
|
|
593
|
+
|
|
419
594
|
function computeSignals(eng) {
|
|
420
595
|
// readClean, not readEng: status/dashboard echo topRisk and stakeholder lines
|
|
421
596
|
// to the terminal and the rendered HTML - a <private> risk must never surface.
|
|
422
597
|
const ctx = readClean(eng, 'context.md'); const stake = readClean(eng, 'stakeholders.md'); const risks = readClean(eng, 'risks.md')
|
|
423
598
|
// Prefer structured tokens from stakeholders + CLI ledger (ledger survives wipes)
|
|
424
599
|
const signalText = stake + '\n' + readClean(eng, SIGNAL_LEDGER)
|
|
425
|
-
const phase = (ctx
|
|
426
|
-
|
|
600
|
+
const phase = parsePhase(ctx)
|
|
601
|
+
// Latest signal PER stakeholder, then worst-of those actives.
|
|
602
|
+
// Global "latest wins" let a green from person B hide a sponsor crisis on A.
|
|
603
|
+
const byPerson = new Map()
|
|
427
604
|
for (const l of signalText.split('\n')) {
|
|
428
605
|
const sm = l.match(/\[signal:(red|amber|green)\]/i)
|
|
429
606
|
if (!sm) continue
|
|
430
607
|
const date = (l.match(/\[(\d{4}-\d{2}-\d{2})\]/) || [])[1] || ''
|
|
431
|
-
const text = l.replace(/^\s*-\s*/, '')
|
|
432
|
-
|
|
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()
|
|
613
|
+
const key = signalSubjectKey(text)
|
|
614
|
+
const prev = byPerson.get(key)
|
|
615
|
+
if (!prev || date >= prev.date) byPerson.set(key, { date, sig: sm[1].toLowerCase(), text })
|
|
616
|
+
}
|
|
617
|
+
const RANK = { red: 0, amber: 1, green: 2 }
|
|
618
|
+
let worst = null
|
|
619
|
+
for (const s of byPerson.values()) {
|
|
620
|
+
if (!worst || RANK[s.sig] < RANK[worst.sig] || (RANK[s.sig] === RANK[worst.sig] && s.date >= worst.date)) {
|
|
621
|
+
worst = s
|
|
622
|
+
}
|
|
433
623
|
}
|
|
434
624
|
const mem = stakeholdersMemoryHealth(eng)
|
|
435
625
|
let trust, signalAge = null, stale = false, trustReason = ''
|
|
436
|
-
if (!mem.ok && !
|
|
626
|
+
if (!mem.ok && !worst) {
|
|
437
627
|
trust = 'amber'
|
|
438
628
|
trustReason = mem.warn
|
|
439
|
-
} else if (
|
|
440
|
-
trust =
|
|
441
|
-
trustReason = (
|
|
442
|
-
if (
|
|
443
|
-
signalAge = Math.max(0, Math.floor((Date.now() - Date.parse(
|
|
629
|
+
} else if (worst) {
|
|
630
|
+
trust = worst.sig === 'red' ? 'RED' : worst.sig
|
|
631
|
+
trustReason = (worst.text || '').slice(0, 80)
|
|
632
|
+
if (worst.date) {
|
|
633
|
+
signalAge = Math.max(0, Math.floor((Date.now() - Date.parse(worst.date)) / 86400000))
|
|
444
634
|
stale = signalAge > 21
|
|
445
635
|
}
|
|
446
636
|
} else {
|
|
@@ -455,14 +645,33 @@ function computeSignals(eng) {
|
|
|
455
645
|
}) || '').replace(/\|/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80)
|
|
456
646
|
// Prefer the trust trigger (signal / memory warn) over a random risk line when triage is not green
|
|
457
647
|
const reason = (trust !== 'green' && (trustReason || mem.warn)) ? (trustReason || mem.warn) : topRisk
|
|
648
|
+
const openRisks = countOpenRisks(eng)
|
|
649
|
+
const nextAction = nextActionLine(ctx)
|
|
458
650
|
let updated = 'never', ageDays = Infinity
|
|
459
651
|
try {
|
|
460
652
|
ageDays = Math.floor((Date.now() - fs.statSync(path.join(eng, 'context.md')).mtimeMs) / 86400000)
|
|
461
653
|
updated = ageDays === 0 ? 'today' : `${ageDays}d ago`
|
|
462
654
|
} catch (_) {}
|
|
463
|
-
return { phase, trust, signalAge, stale, topRisk, reason, memoryWarn: mem.warn, updated, ageDays }
|
|
655
|
+
return { phase, trust, signalAge, stale, topRisk, reason, memoryWarn: mem.warn, openRisks, nextAction, updated, ageDays }
|
|
464
656
|
}
|
|
465
657
|
|
|
658
|
+
function resumeTriage(eng) {
|
|
659
|
+
const s = computeSignals(eng)
|
|
660
|
+
const label = s.trust + (s.stale ? '?' : '')
|
|
661
|
+
const phase = s.phase === '?' ? 'unset' : s.phase
|
|
662
|
+
const lines = [
|
|
663
|
+
`TRIAGE [${label.padEnd(6)}] phase:${phase} updated:${s.updated} open risks:${s.openRisks}`,
|
|
664
|
+
]
|
|
665
|
+
if (s.reason) {
|
|
666
|
+
const age = s.signalAge != null ? ` (${s.signalAge}d old${s.stale ? ', STALE - reconfirm' : ''})` : ''
|
|
667
|
+
lines.push(` trust: ${s.reason}${age}`)
|
|
668
|
+
}
|
|
669
|
+
if (s.nextAction) lines.push(` next: ${s.nextAction}`)
|
|
670
|
+
else lines.push(' next: (none set - add under ## Next action in context.md)')
|
|
671
|
+
return lines.join('\n')
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
|
|
466
675
|
// ---------- dashboard content extractors (best-effort, read-only) ----------
|
|
467
676
|
// The fieldbook's structured widgets (stakeholders, risks, log, stats) want
|
|
468
677
|
// data shapes that .fde/ markdown does not literally carry - it is written by
|
|
@@ -568,7 +777,10 @@ function extractStakeholders(eng) {
|
|
|
568
777
|
if (!dm) return
|
|
569
778
|
const sm = dm[2].match(/\[signal:(red|amber|green)\]/i)
|
|
570
779
|
if (!sm) return
|
|
571
|
-
const text = dm[2]
|
|
780
|
+
const text = dm[2]
|
|
781
|
+
.replace(/\[signal:(red|amber|green)\]/i, '')
|
|
782
|
+
.replace(/\[@[^\]]+\]/g, '')
|
|
783
|
+
.trim()
|
|
572
784
|
history.push({ date: dm[1], signal: sm[1].toLowerCase(), text })
|
|
573
785
|
})
|
|
574
786
|
|
|
@@ -847,6 +1059,12 @@ function cmdResume(args) {
|
|
|
847
1059
|
// NDA surface: engagement notes must not silently leave the machine via file sync
|
|
848
1060
|
const syncHit = /icloud|mobile documents|dropbox|onedrive|google drive|box sync/i.exec(ENGAGEMENTS_ROOT)
|
|
849
1061
|
if (syncHit) console.log(`⚠ engagements root is inside a synced folder ("${syncHit[0]}") - client notes will leave this machine via sync. See PRIVACY.md.`)
|
|
1062
|
+
// Tamper-evident fieldbook: version .fde/ with git (local only, no remote).
|
|
1063
|
+
if (ensureMemoryGit(fdeDir)) {
|
|
1064
|
+
const owner = readOwner(fdeDir)
|
|
1065
|
+
const head = memoryHead(fdeDir)
|
|
1066
|
+
console.log(`memory git: ${head || 'ready'}${owner ? ` owner: ${owner.email}` : ''}`)
|
|
1067
|
+
}
|
|
850
1068
|
return
|
|
851
1069
|
}
|
|
852
1070
|
if (args[0] === '--bind') {
|
|
@@ -867,7 +1085,9 @@ function cmdResume(args) {
|
|
|
867
1085
|
console.log(`NO ENGAGEMENT for this workspace.\nexisting: ${list}\ncreate + bind one: fde resume --init <client-name>`)
|
|
868
1086
|
process.exit(2)
|
|
869
1087
|
}
|
|
870
|
-
|
|
1088
|
+
// Monday-morning command: triage first (trust / phase / risks / next), then memory.
|
|
1089
|
+
console.log(resumeTriage(eng))
|
|
1090
|
+
console.log(`\nENGAGEMENT: ${eng}\n`)
|
|
871
1091
|
// readClean, not fs.readFileSync: this output is what an agent loads as
|
|
872
1092
|
// context, so it goes through the same <private> redaction as the dashboard.
|
|
873
1093
|
const ctx = readClean(eng, 'context.md')
|
|
@@ -927,7 +1147,8 @@ function cmdLogUndo() {
|
|
|
927
1147
|
if (led != null) withFileLock(ledgerPath, () => { atomicWriteFile(ledgerPath, led.endsWith('\n') ? led : led + '\n') })
|
|
928
1148
|
}
|
|
929
1149
|
try { fs.unlinkSync(metaPath) } catch (_) {}
|
|
930
|
-
|
|
1150
|
+
const hash = commitMemory(eng, `undo ${meta.file}`)
|
|
1151
|
+
console.log(`undid last write → ${meta.file}${hash ? ` @${hash}` : ''}`)
|
|
931
1152
|
}
|
|
932
1153
|
|
|
933
1154
|
function cmdLog(args) {
|
|
@@ -945,39 +1166,87 @@ function cmdLog(args) {
|
|
|
945
1166
|
args.splice(sigIdx, 2)
|
|
946
1167
|
}
|
|
947
1168
|
const type = args[0]; const text = args.slice(1).join(' ')
|
|
948
|
-
if (!LOG_FILES[type] || !text) { console.error('usage: fde log <decision|risk|delivery|contact> <text> [--signal red|amber|green] [--force]\n fde log --undo'); process.exit(1) }
|
|
949
|
-
if (signal && type !== 'contact') { console.error('--signal only applies to: fde log contact'); process.exit(1) }
|
|
950
1169
|
const eng = resolveEngagement({ forWrite: true })
|
|
951
1170
|
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
1171
|
+
|
|
1172
|
+
// fde log phase <land|discover|plan|build|ship|close> - advances portfolio phase
|
|
1173
|
+
if (type === 'phase') {
|
|
1174
|
+
const phase = (text || '').toLowerCase().trim()
|
|
1175
|
+
if (!['land', 'discover', 'plan', 'build', 'ship', 'close'].includes(phase)) {
|
|
1176
|
+
console.error('usage: fde log phase <land|discover|plan|build|ship|close>')
|
|
1177
|
+
process.exit(1)
|
|
1178
|
+
}
|
|
1179
|
+
const hash = setContextPhase(eng, phase)
|
|
1180
|
+
console.log(`phase → ${phase}${hash ? ` @${hash}` : ''}`)
|
|
1181
|
+
return
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
if (!LOG_FILES[type] || !text) { console.error('usage: fde log <decision|risk|delivery|contact> <text> [--signal red|amber|green] [--force]\n fde log phase <land|discover|plan|build|ship|close>\n fde log --undo'); process.exit(1) }
|
|
1185
|
+
if (signal && type !== 'contact') { console.error('--signal only applies to: fde log contact'); process.exit(1) }
|
|
952
1186
|
const hit = findSecretHit(text)
|
|
953
1187
|
if (hit && !force) { refuseSecret('log text', hit); process.exit(1) }
|
|
954
1188
|
if (hit && force) console.error(`warning: logging possible ${hit} (--force)`)
|
|
955
1189
|
const date = new Date().toISOString().slice(0, 10)
|
|
956
|
-
const entry =
|
|
1190
|
+
const entry = datedEntry(eng, date, text, signal || '')
|
|
957
1191
|
appendLogEntry(eng, type, entry)
|
|
958
|
-
|
|
1192
|
+
const hash = memoryHead(eng)
|
|
1193
|
+
console.log(`logged → ${LOG_FILES[type]}${signal ? ` (signal:${signal})` : ''}${hash ? ` @${hash}` : ''}`)
|
|
959
1194
|
}
|
|
960
1195
|
|
|
1196
|
+
function setContextPhase(eng, phase) {
|
|
1197
|
+
ensureMemoryGit(eng)
|
|
1198
|
+
const p = path.join(eng, 'context.md')
|
|
1199
|
+
let md = readEng(eng, 'context.md')
|
|
1200
|
+
if (!md) md = '# Engagement context\n\n'
|
|
1201
|
+
if (/\*\*Phase:\*\*/i.test(md)) {
|
|
1202
|
+
md = md.replace(/\*\*Phase:\*\*\s*.*/i, `**Phase:** ${phase}`)
|
|
1203
|
+
} else {
|
|
1204
|
+
md = md.replace(/\n*$/, `\n\n**Phase:** ${phase}\n`)
|
|
1205
|
+
}
|
|
1206
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
1207
|
+
if (/\*\*Last updated:\*\*/i.test(md)) {
|
|
1208
|
+
md = md.replace(/\*\*Last updated:\*\*\s*.*/i, `**Last updated:** ${today}`)
|
|
1209
|
+
}
|
|
1210
|
+
withFileLock(p, () => { atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n') })
|
|
1211
|
+
return commitMemory(eng, `phase ${phase}`)
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
|
|
961
1215
|
// Meeting notes → structured memory. Deterministic routing, zero AI: lines that
|
|
962
1216
|
// start with decision:/risk:/delivery:/contact: (case-insensitive) go to their
|
|
963
1217
|
// LOG_FILES target as dated bullets; everything else lands in context.md as one
|
|
964
1218
|
// dated debrief block. contact: lines may carry an inline [signal:x] token
|
|
965
1219
|
// anywhere in the text - preserved verbatim so computeSignals can trust it.
|
|
966
|
-
//
|
|
967
|
-
//
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1220
|
+
// --smart: heuristic propose from messy prose (confirm with --apply). No network.
|
|
1221
|
+
// --dry-run prints the routing without writing anything.
|
|
1222
|
+
function smartProposeText(input) {
|
|
1223
|
+
const out = []
|
|
1224
|
+
for (const raw of input.split('\n')) {
|
|
1225
|
+
const line = raw.trim()
|
|
1226
|
+
if (!line) continue
|
|
1227
|
+
const bare = line
|
|
1228
|
+
.replace(/^[-*+]\s+/, '')
|
|
1229
|
+
.replace(/^\*\*(decision|risk|delivery|contact):?\*\*:?\s*/i, '$1: ')
|
|
1230
|
+
if (/^(decision|risk|delivery|contact):\s*/i.test(bare)) {
|
|
1231
|
+
out.push(bare.replace(/^(decision|risk|delivery|contact):\s*/i, (m, t) => `${t.toLowerCase()}: `))
|
|
1232
|
+
continue
|
|
1233
|
+
}
|
|
1234
|
+
if (/\b(we (decided|agreed)|decision:|descope|agreed to|agreement was)\b/i.test(bare)) {
|
|
1235
|
+
out.push(`decision: ${bare}`)
|
|
1236
|
+
} else if (/\b(risk|blocker|concern|at risk|worried|exposure|mitigation)\b/i.test(bare)) {
|
|
1237
|
+
out.push(`risk: ${bare}`)
|
|
1238
|
+
} else if (/\b(shipped|delivered|deployed|merged PR|rolled out|went live)\b/i.test(bare)) {
|
|
1239
|
+
out.push(`delivery: ${bare}`)
|
|
1240
|
+
} else if (/\b(gone quiet|champion|resistant|unresponsive|skipped|cooling|signal:)\b/i.test(bare)) {
|
|
1241
|
+
out.push(`contact: ${bare}`)
|
|
1242
|
+
} else {
|
|
1243
|
+
out.push(bare)
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
return out.join('\n') + (out.length ? '\n' : '')
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
function readDebriefInput(args) {
|
|
981
1250
|
let input = ''
|
|
982
1251
|
if (args[0]) {
|
|
983
1252
|
const notesPath = args[0].replace(/^~/, HOME)
|
|
@@ -995,36 +1264,39 @@ function cmdDebrief(args) {
|
|
|
995
1264
|
}
|
|
996
1265
|
input = buf.toString('utf8')
|
|
997
1266
|
} else {
|
|
998
|
-
try { input = fs.readFileSync(0, 'utf8') } catch (_) {}
|
|
1267
|
+
try { input = fs.readFileSync(0, 'utf8') } catch (_) {}
|
|
999
1268
|
if (Buffer.byteLength(input, 'utf8') > DEBRIEF_MAX_BYTES) {
|
|
1000
1269
|
console.error(`debrief refused: stdin is over ${DEBRIEF_MAX_BYTES} bytes. Split the notes.`)
|
|
1001
1270
|
process.exit(1)
|
|
1002
1271
|
}
|
|
1003
1272
|
}
|
|
1273
|
+
return input
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function routeDebriefInput(eng, input, { dry, force }) {
|
|
1004
1277
|
const d = new Date()
|
|
1005
1278
|
const date = d.toISOString().slice(0, 10)
|
|
1006
1279
|
const counts = { decision: 0, risk: 0, delivery: 0, contact: 0 }
|
|
1007
1280
|
const ctxLines = []
|
|
1281
|
+
ensureMemoryGit(eng)
|
|
1008
1282
|
for (const raw of input.split('\n')) {
|
|
1009
1283
|
let line = raw.trim()
|
|
1010
1284
|
if (!line) continue
|
|
1011
|
-
// markdown dressing: leading bullets (-, *, +) and bold around the prefix
|
|
1012
1285
|
const bare = line.replace(/^[-*+]\s+/, '').replace(/^\*\*(decision|risk|delivery|contact):?\*\*:?\s*/i, '$1: ')
|
|
1013
1286
|
const m = bare.match(/^(decision|risk|delivery|contact):\s*(.+)$/i)
|
|
1014
1287
|
if (m) {
|
|
1015
1288
|
const type = m[1].toLowerCase()
|
|
1016
|
-
|
|
1289
|
+
let body = m[2]
|
|
1290
|
+
const sigInline = (body.match(/\[signal:(red|amber|green)\]/i) || [])[1]
|
|
1291
|
+
if (sigInline) body = body.replace(/\[signal:(red|amber|green)\]/i, '').trim()
|
|
1017
1292
|
const hit = findSecretHit(body)
|
|
1018
1293
|
if (hit && !force) {
|
|
1019
1294
|
console.error(`skipped ${type} line - looks like a ${hit}. Redact it, or re-run with --force.`)
|
|
1020
1295
|
continue
|
|
1021
1296
|
}
|
|
1022
|
-
const entry =
|
|
1297
|
+
const entry = datedEntry(eng, date, body, type === 'contact' && sigInline ? sigInline.toLowerCase() : '')
|
|
1023
1298
|
if (dry) console.log(`→ ${LOG_FILES[type]} ${entry}`)
|
|
1024
|
-
|
|
1025
|
-
// inline [signal:x] token (the skill's own convention) and must land
|
|
1026
|
-
// inside "## Signal history" the same way `fde log --signal` does.
|
|
1027
|
-
else appendLogEntry(eng, type, entry)
|
|
1299
|
+
else appendLogEntry(eng, type, entry, { skipCommit: true })
|
|
1028
1300
|
counts[type]++
|
|
1029
1301
|
} else {
|
|
1030
1302
|
if (findSecretHit(line) && !force) {
|
|
@@ -1039,6 +1311,58 @@ function cmdDebrief(args) {
|
|
|
1039
1311
|
if (dry) ctxLines.forEach(l => console.log(`→ context.md - ${l}`))
|
|
1040
1312
|
else lockedAppendFile(path.join(eng, 'context.md'), `\n## Debrief - ${stamp}\n${ctxLines.map(l => `- ${l}`).join('\n')}\n`)
|
|
1041
1313
|
}
|
|
1314
|
+
return { counts, ctxLines, date }
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function cmdDebrief(args) {
|
|
1318
|
+
args = args.slice()
|
|
1319
|
+
const dryIdx = args.indexOf('--dry-run')
|
|
1320
|
+
const dry = dryIdx !== -1
|
|
1321
|
+
if (dry) args.splice(dryIdx, 1)
|
|
1322
|
+
const smartIdx = args.indexOf('--smart')
|
|
1323
|
+
const smart = smartIdx !== -1
|
|
1324
|
+
if (smart) args.splice(smartIdx, 1)
|
|
1325
|
+
const applyIdx = args.indexOf('--apply')
|
|
1326
|
+
const apply = applyIdx !== -1
|
|
1327
|
+
if (apply) args.splice(applyIdx, 1)
|
|
1328
|
+
let force = false
|
|
1329
|
+
const forceIdx = args.indexOf('--force')
|
|
1330
|
+
if (forceIdx !== -1) { force = true; args.splice(forceIdx, 1) }
|
|
1331
|
+
|
|
1332
|
+
const eng = resolveEngagement({ forWrite: true })
|
|
1333
|
+
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
1334
|
+
|
|
1335
|
+
let input = ''
|
|
1336
|
+
if (apply && !smart && !args[0]) {
|
|
1337
|
+
try { input = fs.readFileSync(path.join(eng, DEBRIEF_PROPOSE), 'utf8') } catch (_) {
|
|
1338
|
+
console.error('nothing to apply - run: fde debrief --smart <notes.md> then fde debrief --apply')
|
|
1339
|
+
process.exit(1)
|
|
1340
|
+
}
|
|
1341
|
+
} else {
|
|
1342
|
+
input = readDebriefInput(args)
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
if (smart) {
|
|
1346
|
+
const proposed = smartProposeText(input)
|
|
1347
|
+
const proposePath = path.join(eng, DEBRIEF_PROPOSE)
|
|
1348
|
+
withFileLock(proposePath, () => { atomicWriteFile(proposePath, proposed) })
|
|
1349
|
+
console.log('SMART PROPOSE (heuristic - review before apply; no new facts invented beyond line rewrites)\n')
|
|
1350
|
+
routeDebriefInput(eng, proposed, { dry: true, force })
|
|
1351
|
+
if (!apply) {
|
|
1352
|
+
console.log(`\nproposal saved → ${proposePath}`)
|
|
1353
|
+
console.log('confirm: fde debrief --apply')
|
|
1354
|
+
console.log('(edit the propose file first if a line mis-routed)')
|
|
1355
|
+
return
|
|
1356
|
+
}
|
|
1357
|
+
input = proposed
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
const { counts, ctxLines } = routeDebriefInput(eng, input, { dry, force })
|
|
1361
|
+
if (!dry) {
|
|
1362
|
+
const hash = commitMemory(eng, 'debrief')
|
|
1363
|
+
try { fs.unlinkSync(path.join(eng, DEBRIEF_PROPOSE)) } catch (_) {}
|
|
1364
|
+
if (hash) console.log(`memory @${hash}`)
|
|
1365
|
+
}
|
|
1042
1366
|
const plural = { decision: 'decisions', risk: 'risks', delivery: 'deliveries', contact: 'contacts' }
|
|
1043
1367
|
const parts = Object.keys(counts).filter(t => counts[t])
|
|
1044
1368
|
.map(t => `${counts[t]} ${counts[t] === 1 ? t : plural[t]}`)
|
|
@@ -1094,6 +1418,7 @@ function cmdReceipts(args) {
|
|
|
1094
1418
|
function cmdCapture() {
|
|
1095
1419
|
const eng = resolveEngagement({ forWrite: true })
|
|
1096
1420
|
if (!eng) process.exit(0) // silent: capture must never break a session
|
|
1421
|
+
// Workspace git facts (cwd), not the engagement memory repo.
|
|
1097
1422
|
const branch = sh('git branch --show-current')
|
|
1098
1423
|
const lastCommit = sh("git log -1 --format='%h %s'").slice(0, 100)
|
|
1099
1424
|
// porcelain lines are "XY path" - sh() trims, so parse by first whitespace
|
|
@@ -1110,7 +1435,206 @@ function cmdCapture() {
|
|
|
1110
1435
|
if (branch) block += `- workspace: \`${branch}\` @ ${lastCommit || 'no commits yet'}\n`
|
|
1111
1436
|
if (changed) block += `- uncommitted: ${changed}\n`
|
|
1112
1437
|
if (updated) block += `- engagement files updated: ${updated}\n`
|
|
1113
|
-
try {
|
|
1438
|
+
try {
|
|
1439
|
+
ensureMemoryGit(eng)
|
|
1440
|
+
lockedAppendFile(path.join(eng, 'context.md'), block, { soft: true })
|
|
1441
|
+
commitMemory(eng, 'session capture')
|
|
1442
|
+
} catch (_) {}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
function cmdTriage() {
|
|
1446
|
+
const eng = resolveEngagement()
|
|
1447
|
+
if (!eng) {
|
|
1448
|
+
console.error('no engagement - run: fde resume --init <name>')
|
|
1449
|
+
process.exit(2)
|
|
1450
|
+
}
|
|
1451
|
+
console.log(resumeTriage(eng))
|
|
1452
|
+
const owner = readOwner(eng) || writeOwnerIfMissing(eng)
|
|
1453
|
+
const head = memoryHead(eng)
|
|
1454
|
+
if (owner || head) {
|
|
1455
|
+
console.log(` record: ${owner ? owner.email : '?'}${head ? ` memory@${head}` : ' (unversioned)'}`)
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function cmdOwner(args) {
|
|
1460
|
+
const eng = resolveEngagement({ forWrite: args[0] === 'set' })
|
|
1461
|
+
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
1462
|
+
if (args[0] === 'set') {
|
|
1463
|
+
const email = args[1]
|
|
1464
|
+
if (!email || !email.includes('@')) {
|
|
1465
|
+
console.error('usage: fde owner set <email> [name...]')
|
|
1466
|
+
process.exit(1)
|
|
1467
|
+
}
|
|
1468
|
+
const name = args.slice(2).join(' ') || email.split('@')[0]
|
|
1469
|
+
ensureMemoryGit(eng)
|
|
1470
|
+
withFileLock(path.join(eng, OWNER_FILE), () => {
|
|
1471
|
+
atomicWriteFile(path.join(eng, OWNER_FILE), `name: ${name}\nemail: ${email}\n`)
|
|
1472
|
+
})
|
|
1473
|
+
const hash = commitMemory(eng, 'owner set')
|
|
1474
|
+
console.log(`owner → ${name} <${email}>${hash ? ` @${hash}` : ''}`)
|
|
1475
|
+
return
|
|
1476
|
+
}
|
|
1477
|
+
const o = readOwner(eng) || writeOwnerIfMissing(eng)
|
|
1478
|
+
console.log(`owner: ${o.name} <${o.email}>`)
|
|
1479
|
+
const head = memoryHead(eng)
|
|
1480
|
+
if (head) console.log(`memory HEAD: ${head}`)
|
|
1481
|
+
else console.log('memory HEAD: (unversioned - git init on next write)')
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
function cmdDoctor() {
|
|
1485
|
+
const eng = resolveEngagement()
|
|
1486
|
+
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
1487
|
+
const issues = []
|
|
1488
|
+
const s = computeSignals(eng)
|
|
1489
|
+
if (s.phase === '?') {
|
|
1490
|
+
const hasWork = /\[\d{4}-\d{2}-\d{2}\]/.test(readEng(eng, 'decisions.md') + readEng(eng, 'delivery.md'))
|
|
1491
|
+
if (hasWork) issues.push('phase is unset but dated work exists - run: fde log phase <land|discover|plan|build|ship|close>')
|
|
1492
|
+
else issues.push('phase is unset - set when you know where you are: fde log phase land')
|
|
1493
|
+
}
|
|
1494
|
+
if (s.stale) issues.push(`trust signal is STALE (${s.signalAge}d) - reconfirm with fde log contact ... --signal`)
|
|
1495
|
+
if (s.memoryWarn) issues.push(s.memoryWarn)
|
|
1496
|
+
if (!readOwner(eng)) issues.push('no .owner - run any write or: fde owner set you@firm.com')
|
|
1497
|
+
if (!fs.existsSync(path.join(eng, '.git'))) issues.push('memory not git-versioned - next write will init, or re-run resume --init')
|
|
1498
|
+
const success = readClean(eng, 'success.md')
|
|
1499
|
+
if (!firstLine(success, 80)) issues.push('success.md has no stated done-definition - fill before plan/build')
|
|
1500
|
+
if (!sectionBody(readClean(eng, 'context.md'), 'Next action')) {
|
|
1501
|
+
issues.push('no ## Next action in context.md - Monday morning has nothing to drive')
|
|
1502
|
+
}
|
|
1503
|
+
console.log(`FDE DOCTOR - ${engagementSlugFromPath(eng)}`)
|
|
1504
|
+
console.log(resumeTriage(eng))
|
|
1505
|
+
if (!issues.length) {
|
|
1506
|
+
console.log('\nOK - no structural issues (judgment still yours)')
|
|
1507
|
+
process.exit(0)
|
|
1508
|
+
}
|
|
1509
|
+
console.log(`\n${issues.length} issue(s):`)
|
|
1510
|
+
issues.forEach((i, n) => console.log(` ${n + 1}. ${i}`))
|
|
1511
|
+
process.exit(1)
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
function cmdPrep(args) {
|
|
1515
|
+
const eng = resolveEngagement()
|
|
1516
|
+
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
1517
|
+
const label = args.join(' ').trim() || 'next meeting'
|
|
1518
|
+
// Grounded brief: only text already in .fde/. No invention (Rowboat meeting-prep rule).
|
|
1519
|
+
console.log(`MEETING PREP — ${label}`)
|
|
1520
|
+
console.log('(grounded in local .fde/ only - if a fact is missing, it is missing)\n')
|
|
1521
|
+
console.log(resumeTriage(eng))
|
|
1522
|
+
const owner = readOwner(eng)
|
|
1523
|
+
const head = memoryHead(eng)
|
|
1524
|
+
if (owner || head) console.log(` record: ${owner ? owner.email : '?'}${head ? ` @${head}` : ''}`)
|
|
1525
|
+
|
|
1526
|
+
const people = extractStakeholders(eng).slice(0, 8)
|
|
1527
|
+
console.log('\nStakeholders')
|
|
1528
|
+
if (!people.length) console.log(' (none in table yet)')
|
|
1529
|
+
else people.forEach(p => console.log(` [${p.signal}] ${p.name}${p.role ? ` — ${p.role}` : ''}${p.note ? ` · ${p.note.slice(0, 60)}` : ''}`))
|
|
1530
|
+
|
|
1531
|
+
const risks = extractRisks(eng).slice(0, 5)
|
|
1532
|
+
console.log('\nOpen risks (from risks.md table)')
|
|
1533
|
+
if (!risks.length) console.log(' (none parsed)')
|
|
1534
|
+
else risks.forEach(r => console.log(` [${r.severity}] ${r.text.slice(0, 100)}`))
|
|
1535
|
+
|
|
1536
|
+
const success = firstLine(readClean(eng, 'success.md'), 160)
|
|
1537
|
+
console.log('\nSuccess looks like')
|
|
1538
|
+
console.log(success ? ` ${success}` : ' (success.md empty)')
|
|
1539
|
+
|
|
1540
|
+
const decisions = readClean(eng, 'decisions.md').split('\n')
|
|
1541
|
+
.filter(l => /^-\s*\[\d{4}-\d{2}-\d{2}\]/.test(l.trim()))
|
|
1542
|
+
.slice(-5)
|
|
1543
|
+
console.log('\nRecent decisions')
|
|
1544
|
+
if (!decisions.length) console.log(' (none logged)')
|
|
1545
|
+
else decisions.forEach(l => console.log(` ${l.trim().slice(0, 120)}`))
|
|
1546
|
+
|
|
1547
|
+
const next = nextActionLine(readClean(eng, 'context.md'))
|
|
1548
|
+
console.log('\nWalk in with')
|
|
1549
|
+
console.log(next ? ` ${next}` : ' (set ## Next action in context.md)')
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function cmdGarden(args) {
|
|
1553
|
+
const apply = args.includes('--apply')
|
|
1554
|
+
const eng = resolveEngagement({ forWrite: apply })
|
|
1555
|
+
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
1556
|
+
// Gardener contract (from Rowboat note_curation): no new facts, no deleted substance,
|
|
1557
|
+
// reversible via git, confirm before apply. Mechanical only - no LLM rewrite.
|
|
1558
|
+
console.log('GARDEN (contract: no new facts · no deleted substance · reversible via memory git)')
|
|
1559
|
+
console.log(resumeTriage(eng))
|
|
1560
|
+
const proposals = []
|
|
1561
|
+
const s = computeSignals(eng)
|
|
1562
|
+
if (s.stale) {
|
|
1563
|
+
proposals.push({
|
|
1564
|
+
id: 'reconfirm-signal',
|
|
1565
|
+
kind: 'manual',
|
|
1566
|
+
text: `Reconfirm stale ${s.trust} signal (${s.signalAge}d): fde log contact "…" --signal`,
|
|
1567
|
+
})
|
|
1568
|
+
}
|
|
1569
|
+
const ctx = readEng(eng, 'context.md')
|
|
1570
|
+
const sessionBlocks = []
|
|
1571
|
+
const lines = ctx.split('\n')
|
|
1572
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1573
|
+
const m = lines[i].match(/^##\s+Session end\s+-\s+(\d{4}-\d{2}-\d{2})\b/)
|
|
1574
|
+
if (!m) continue
|
|
1575
|
+
const age = Math.floor((Date.now() - Date.parse(m[1])) / 86400000)
|
|
1576
|
+
if (age >= 60) sessionBlocks.push({ line: i, date: m[1], age })
|
|
1577
|
+
}
|
|
1578
|
+
if (sessionBlocks.length >= 3) {
|
|
1579
|
+
proposals.push({
|
|
1580
|
+
id: 'archive-sessions',
|
|
1581
|
+
kind: 'apply',
|
|
1582
|
+
text: `Archive ${sessionBlocks.length} session-end blocks older than 60d into context-archive.md`,
|
|
1583
|
+
sessionBlocks,
|
|
1584
|
+
})
|
|
1585
|
+
}
|
|
1586
|
+
if (!proposals.length) {
|
|
1587
|
+
console.log('\nNothing to garden.')
|
|
1588
|
+
return
|
|
1589
|
+
}
|
|
1590
|
+
console.log(`\n${proposals.length} proposal(s):`)
|
|
1591
|
+
proposals.forEach((p, i) => console.log(` ${i + 1}. [${p.kind}] ${p.text}`))
|
|
1592
|
+
if (!apply) {
|
|
1593
|
+
console.log('\nApply mechanical items only: fde garden --apply')
|
|
1594
|
+
console.log('Manual items stay yours. Every apply commits to memory git.')
|
|
1595
|
+
return
|
|
1596
|
+
}
|
|
1597
|
+
ensureMemoryGit(eng)
|
|
1598
|
+
let applied = 0
|
|
1599
|
+
for (const p of proposals) {
|
|
1600
|
+
if (p.id !== 'archive-sessions') continue
|
|
1601
|
+
const cutDates = new Set(p.sessionBlocks.map(b => b.date))
|
|
1602
|
+
const keep = []
|
|
1603
|
+
const archive = []
|
|
1604
|
+
let mode = 'keep'
|
|
1605
|
+
let buf = []
|
|
1606
|
+
const flush = () => {
|
|
1607
|
+
if (!buf.length) return
|
|
1608
|
+
;(mode === 'archive' ? archive : keep).push(...buf)
|
|
1609
|
+
buf = []
|
|
1610
|
+
}
|
|
1611
|
+
for (const line of lines) {
|
|
1612
|
+
const m = line.match(/^##\s+Session end\s+-\s+(\d{4}-\d{2}-\d{2})\b/)
|
|
1613
|
+
if (m) {
|
|
1614
|
+
flush()
|
|
1615
|
+
mode = cutDates.has(m[1]) ? 'archive' : 'keep'
|
|
1616
|
+
} else if (/^##\s+/.test(line) && mode === 'archive') {
|
|
1617
|
+
flush()
|
|
1618
|
+
mode = 'keep'
|
|
1619
|
+
}
|
|
1620
|
+
buf.push(line)
|
|
1621
|
+
}
|
|
1622
|
+
flush()
|
|
1623
|
+
if (!archive.length) continue
|
|
1624
|
+
const archPath = path.join(eng, 'context-archive.md')
|
|
1625
|
+
const prev = fs.existsSync(archPath) ? fs.readFileSync(archPath, 'utf8') : '# Context archive\n\n'
|
|
1626
|
+
withFileLock(archPath, () => {
|
|
1627
|
+
atomicWriteFile(archPath, prev.replace(/\n*$/, '\n\n') + archive.join('\n').trim() + '\n')
|
|
1628
|
+
})
|
|
1629
|
+
withFileLock(path.join(eng, 'context.md'), () => {
|
|
1630
|
+
atomicWriteFile(path.join(eng, 'context.md'), keep.join('\n').replace(/\n*$/, '\n'))
|
|
1631
|
+
})
|
|
1632
|
+
applied++
|
|
1633
|
+
console.log(`applied: archived ${p.sessionBlocks.length} old session-end blocks → context-archive.md`)
|
|
1634
|
+
}
|
|
1635
|
+
const hash = commitMemory(eng, 'garden')
|
|
1636
|
+
if (!applied) console.log('no mechanical proposals applied (manual items remain)')
|
|
1637
|
+
else console.log(`garden done${hash ? ` @${hash}` : ''}`)
|
|
1114
1638
|
}
|
|
1115
1639
|
|
|
1116
1640
|
function engagementSlugFromPath(eng) {
|
|
@@ -1146,10 +1670,10 @@ function cmdStatus(args) {
|
|
|
1146
1670
|
// "amber?" = structured signal went stale (>21d) - reconfirm before trusting it
|
|
1147
1671
|
const label = r.trust + (r.stale ? '?' : '')
|
|
1148
1672
|
const sig = r.signalAge != null ? `signal ${r.signalAge}d old${r.stale ? ' (STALE - reconfirm)' : ''} ` : ''
|
|
1149
|
-
console.log(` [${label.padEnd(6)}] ${r.name.padEnd(24)} phase:${r.phase.padEnd(10)} updated:${r.updated.padEnd(8)} ${sig}${r.reason}`)
|
|
1673
|
+
console.log(` [${label.padEnd(6)}] ${r.name.padEnd(24)} phase:${(r.phase === '?' ? 'unset' : r.phase).padEnd(10)} updated:${r.updated.padEnd(8)} ${sig}${r.reason}`)
|
|
1150
1674
|
}
|
|
1151
1675
|
if (!all) console.log('\n(current engagement only - pass --all for the full portfolio)')
|
|
1152
|
-
console.log('\ntrust:
|
|
1676
|
+
console.log('\ntrust: worst active [signal:x] across stakeholders (latest per person) - a green from B cannot clear an amber/red on A; keyword heuristic only when none exists.')
|
|
1153
1677
|
}
|
|
1154
1678
|
|
|
1155
1679
|
// ---------- dashboard (deterministic markdown → one local HTML) ----------
|
|
@@ -1919,23 +2443,36 @@ function printUsage() {
|
|
|
1919
2443
|
fde resume --full load the complete context.md (no bound)
|
|
1920
2444
|
fde resume --init <name> create + bind engagement for this workspace (rebind replaces)
|
|
1921
2445
|
fde resume --bind show what this workspace is bound to, and what resolves
|
|
2446
|
+
fde triage TRIAGE block only (hooks / Cursor session entry)
|
|
1922
2447
|
fde log <type> <text> append decision|risk|delivery|contact (contact takes --signal red|amber|green; --force to allow secret-like text)
|
|
2448
|
+
fde log phase <phase> set engagement phase (land|discover|plan|build|ship|close)
|
|
1923
2449
|
fde log --undo remove the last CLI log/debrief entry from memory
|
|
1924
|
-
fde debrief [file] meeting notes → memory
|
|
2450
|
+
fde debrief [file] meeting notes → memory (prefixed lines; --dry-run; --force)
|
|
2451
|
+
fde debrief --smart propose routing from messy notes → review → fde debrief --apply
|
|
2452
|
+
fde prep [label] grounded walk-in brief from existing .fde/ only
|
|
2453
|
+
fde doctor lint engagement memory (stale signals, gaps)
|
|
2454
|
+
fde garden [--apply] propose safe consolidations (contract: no new facts; git-reversible)
|
|
2455
|
+
fde owner [set email] who keeps this engagement record
|
|
1925
2456
|
fde receipts <term> "what did we agree?" with dates
|
|
1926
2457
|
fde capture session-end memory snapshot (hooks use this)
|
|
1927
2458
|
fde status [--all] current engagement status (pass --all for full portfolio)
|
|
1928
2459
|
fde dashboard [--all] current engagement fieldbook (pass --all for every client)
|
|
1929
2460
|
env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)
|
|
1930
|
-
writes require a workspace bind (or FDEOPS_ENGAGEMENT) - folder-name match is read-only
|
|
2461
|
+
writes require a workspace bind (or FDEOPS_ENGAGEMENT) - folder-name match is read-only
|
|
2462
|
+
.fde/ is git-versioned locally for tamper-evident receipts (no remote, no telemetry)`)
|
|
1931
2463
|
}
|
|
1932
2464
|
|
|
1933
2465
|
const [cmd, ...args] = process.argv.slice(2)
|
|
1934
2466
|
switch (cmd) {
|
|
1935
2467
|
case 'scan': cmdScan(); break
|
|
1936
2468
|
case 'resume': cmdResume(args); break
|
|
2469
|
+
case 'triage': cmdTriage(); break
|
|
1937
2470
|
case 'log': cmdLog(args); break
|
|
1938
2471
|
case 'debrief': cmdDebrief(args); break
|
|
2472
|
+
case 'prep': cmdPrep(args); break
|
|
2473
|
+
case 'doctor': cmdDoctor(); break
|
|
2474
|
+
case 'garden': cmdGarden(args); break
|
|
2475
|
+
case 'owner': cmdOwner(args); break
|
|
1939
2476
|
case 'receipts': cmdReceipts(args); break
|
|
1940
2477
|
case 'capture': cmdCapture(); break
|
|
1941
2478
|
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.0",
|
|
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",
|