fdeops 3.8.3 → 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 +470 -36
- 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 })
|
|
@@ -638,7 +777,10 @@ function extractStakeholders(eng) {
|
|
|
638
777
|
if (!dm) return
|
|
639
778
|
const sm = dm[2].match(/\[signal:(red|amber|green)\]/i)
|
|
640
779
|
if (!sm) return
|
|
641
|
-
const text = dm[2]
|
|
780
|
+
const text = dm[2]
|
|
781
|
+
.replace(/\[signal:(red|amber|green)\]/i, '')
|
|
782
|
+
.replace(/\[@[^\]]+\]/g, '')
|
|
783
|
+
.trim()
|
|
642
784
|
history.push({ date: dm[1], signal: sm[1].toLowerCase(), text })
|
|
643
785
|
})
|
|
644
786
|
|
|
@@ -917,6 +1059,12 @@ function cmdResume(args) {
|
|
|
917
1059
|
// NDA surface: engagement notes must not silently leave the machine via file sync
|
|
918
1060
|
const syncHit = /icloud|mobile documents|dropbox|onedrive|google drive|box sync/i.exec(ENGAGEMENTS_ROOT)
|
|
919
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
|
+
}
|
|
920
1068
|
return
|
|
921
1069
|
}
|
|
922
1070
|
if (args[0] === '--bind') {
|
|
@@ -999,7 +1147,8 @@ function cmdLogUndo() {
|
|
|
999
1147
|
if (led != null) withFileLock(ledgerPath, () => { atomicWriteFile(ledgerPath, led.endsWith('\n') ? led : led + '\n') })
|
|
1000
1148
|
}
|
|
1001
1149
|
try { fs.unlinkSync(metaPath) } catch (_) {}
|
|
1002
|
-
|
|
1150
|
+
const hash = commitMemory(eng, `undo ${meta.file}`)
|
|
1151
|
+
console.log(`undid last write → ${meta.file}${hash ? ` @${hash}` : ''}`)
|
|
1003
1152
|
}
|
|
1004
1153
|
|
|
1005
1154
|
function cmdLog(args) {
|
|
@@ -1027,8 +1176,8 @@ function cmdLog(args) {
|
|
|
1027
1176
|
console.error('usage: fde log phase <land|discover|plan|build|ship|close>')
|
|
1028
1177
|
process.exit(1)
|
|
1029
1178
|
}
|
|
1030
|
-
setContextPhase(eng, phase)
|
|
1031
|
-
console.log(`phase → ${phase}`)
|
|
1179
|
+
const hash = setContextPhase(eng, phase)
|
|
1180
|
+
console.log(`phase → ${phase}${hash ? ` @${hash}` : ''}`)
|
|
1032
1181
|
return
|
|
1033
1182
|
}
|
|
1034
1183
|
|
|
@@ -1038,12 +1187,14 @@ function cmdLog(args) {
|
|
|
1038
1187
|
if (hit && !force) { refuseSecret('log text', hit); process.exit(1) }
|
|
1039
1188
|
if (hit && force) console.error(`warning: logging possible ${hit} (--force)`)
|
|
1040
1189
|
const date = new Date().toISOString().slice(0, 10)
|
|
1041
|
-
const entry =
|
|
1190
|
+
const entry = datedEntry(eng, date, text, signal || '')
|
|
1042
1191
|
appendLogEntry(eng, type, entry)
|
|
1043
|
-
|
|
1192
|
+
const hash = memoryHead(eng)
|
|
1193
|
+
console.log(`logged → ${LOG_FILES[type]}${signal ? ` (signal:${signal})` : ''}${hash ? ` @${hash}` : ''}`)
|
|
1044
1194
|
}
|
|
1045
1195
|
|
|
1046
1196
|
function setContextPhase(eng, phase) {
|
|
1197
|
+
ensureMemoryGit(eng)
|
|
1047
1198
|
const p = path.join(eng, 'context.md')
|
|
1048
1199
|
let md = readEng(eng, 'context.md')
|
|
1049
1200
|
if (!md) md = '# Engagement context\n\n'
|
|
@@ -1057,6 +1208,7 @@ function setContextPhase(eng, phase) {
|
|
|
1057
1208
|
md = md.replace(/\*\*Last updated:\*\*\s*.*/i, `**Last updated:** ${today}`)
|
|
1058
1209
|
}
|
|
1059
1210
|
withFileLock(p, () => { atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n') })
|
|
1211
|
+
return commitMemory(eng, `phase ${phase}`)
|
|
1060
1212
|
}
|
|
1061
1213
|
|
|
1062
1214
|
|
|
@@ -1065,21 +1217,36 @@ function setContextPhase(eng, phase) {
|
|
|
1065
1217
|
// LOG_FILES target as dated bullets; everything else lands in context.md as one
|
|
1066
1218
|
// dated debrief block. contact: lines may carry an inline [signal:x] token
|
|
1067
1219
|
// 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
|
-
|
|
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) {
|
|
1083
1250
|
let input = ''
|
|
1084
1251
|
if (args[0]) {
|
|
1085
1252
|
const notesPath = args[0].replace(/^~/, HOME)
|
|
@@ -1097,36 +1264,39 @@ function cmdDebrief(args) {
|
|
|
1097
1264
|
}
|
|
1098
1265
|
input = buf.toString('utf8')
|
|
1099
1266
|
} else {
|
|
1100
|
-
try { input = fs.readFileSync(0, 'utf8') } catch (_) {}
|
|
1267
|
+
try { input = fs.readFileSync(0, 'utf8') } catch (_) {}
|
|
1101
1268
|
if (Buffer.byteLength(input, 'utf8') > DEBRIEF_MAX_BYTES) {
|
|
1102
1269
|
console.error(`debrief refused: stdin is over ${DEBRIEF_MAX_BYTES} bytes. Split the notes.`)
|
|
1103
1270
|
process.exit(1)
|
|
1104
1271
|
}
|
|
1105
1272
|
}
|
|
1273
|
+
return input
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function routeDebriefInput(eng, input, { dry, force }) {
|
|
1106
1277
|
const d = new Date()
|
|
1107
1278
|
const date = d.toISOString().slice(0, 10)
|
|
1108
1279
|
const counts = { decision: 0, risk: 0, delivery: 0, contact: 0 }
|
|
1109
1280
|
const ctxLines = []
|
|
1281
|
+
ensureMemoryGit(eng)
|
|
1110
1282
|
for (const raw of input.split('\n')) {
|
|
1111
1283
|
let line = raw.trim()
|
|
1112
1284
|
if (!line) continue
|
|
1113
|
-
// markdown dressing: leading bullets (-, *, +) and bold around the prefix
|
|
1114
1285
|
const bare = line.replace(/^[-*+]\s+/, '').replace(/^\*\*(decision|risk|delivery|contact):?\*\*:?\s*/i, '$1: ')
|
|
1115
1286
|
const m = bare.match(/^(decision|risk|delivery|contact):\s*(.+)$/i)
|
|
1116
1287
|
if (m) {
|
|
1117
1288
|
const type = m[1].toLowerCase()
|
|
1118
|
-
|
|
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()
|
|
1119
1292
|
const hit = findSecretHit(body)
|
|
1120
1293
|
if (hit && !force) {
|
|
1121
1294
|
console.error(`skipped ${type} line - looks like a ${hit}. Redact it, or re-run with --force.`)
|
|
1122
1295
|
continue
|
|
1123
1296
|
}
|
|
1124
|
-
const entry =
|
|
1297
|
+
const entry = datedEntry(eng, date, body, type === 'contact' && sigInline ? sigInline.toLowerCase() : '')
|
|
1125
1298
|
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)
|
|
1299
|
+
else appendLogEntry(eng, type, entry, { skipCommit: true })
|
|
1130
1300
|
counts[type]++
|
|
1131
1301
|
} else {
|
|
1132
1302
|
if (findSecretHit(line) && !force) {
|
|
@@ -1141,6 +1311,58 @@ function cmdDebrief(args) {
|
|
|
1141
1311
|
if (dry) ctxLines.forEach(l => console.log(`→ context.md - ${l}`))
|
|
1142
1312
|
else lockedAppendFile(path.join(eng, 'context.md'), `\n## Debrief - ${stamp}\n${ctxLines.map(l => `- ${l}`).join('\n')}\n`)
|
|
1143
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
|
+
}
|
|
1144
1366
|
const plural = { decision: 'decisions', risk: 'risks', delivery: 'deliveries', contact: 'contacts' }
|
|
1145
1367
|
const parts = Object.keys(counts).filter(t => counts[t])
|
|
1146
1368
|
.map(t => `${counts[t]} ${counts[t] === 1 ? t : plural[t]}`)
|
|
@@ -1196,6 +1418,7 @@ function cmdReceipts(args) {
|
|
|
1196
1418
|
function cmdCapture() {
|
|
1197
1419
|
const eng = resolveEngagement({ forWrite: true })
|
|
1198
1420
|
if (!eng) process.exit(0) // silent: capture must never break a session
|
|
1421
|
+
// Workspace git facts (cwd), not the engagement memory repo.
|
|
1199
1422
|
const branch = sh('git branch --show-current')
|
|
1200
1423
|
const lastCommit = sh("git log -1 --format='%h %s'").slice(0, 100)
|
|
1201
1424
|
// porcelain lines are "XY path" - sh() trims, so parse by first whitespace
|
|
@@ -1212,7 +1435,206 @@ function cmdCapture() {
|
|
|
1212
1435
|
if (branch) block += `- workspace: \`${branch}\` @ ${lastCommit || 'no commits yet'}\n`
|
|
1213
1436
|
if (changed) block += `- uncommitted: ${changed}\n`
|
|
1214
1437
|
if (updated) block += `- engagement files updated: ${updated}\n`
|
|
1215
|
-
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}` : ''}`)
|
|
1216
1638
|
}
|
|
1217
1639
|
|
|
1218
1640
|
function engagementSlugFromPath(eng) {
|
|
@@ -2021,24 +2443,36 @@ function printUsage() {
|
|
|
2021
2443
|
fde resume --full load the complete context.md (no bound)
|
|
2022
2444
|
fde resume --init <name> create + bind engagement for this workspace (rebind replaces)
|
|
2023
2445
|
fde resume --bind show what this workspace is bound to, and what resolves
|
|
2446
|
+
fde triage TRIAGE block only (hooks / Cursor session entry)
|
|
2024
2447
|
fde log <type> <text> append decision|risk|delivery|contact (contact takes --signal red|amber|green; --force to allow secret-like text)
|
|
2025
2448
|
fde log phase <phase> set engagement phase (land|discover|plan|build|ship|close)
|
|
2026
2449
|
fde log --undo remove the last CLI log/debrief entry from memory
|
|
2027
|
-
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
|
|
2028
2456
|
fde receipts <term> "what did we agree?" with dates
|
|
2029
2457
|
fde capture session-end memory snapshot (hooks use this)
|
|
2030
2458
|
fde status [--all] current engagement status (pass --all for full portfolio)
|
|
2031
2459
|
fde dashboard [--all] current engagement fieldbook (pass --all for every client)
|
|
2032
2460
|
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
|
|
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)`)
|
|
2034
2463
|
}
|
|
2035
2464
|
|
|
2036
2465
|
const [cmd, ...args] = process.argv.slice(2)
|
|
2037
2466
|
switch (cmd) {
|
|
2038
2467
|
case 'scan': cmdScan(); break
|
|
2039
2468
|
case 'resume': cmdResume(args); break
|
|
2469
|
+
case 'triage': cmdTriage(); break
|
|
2040
2470
|
case 'log': cmdLog(args); break
|
|
2041
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
|
|
2042
2476
|
case 'receipts': cmdReceipts(args); break
|
|
2043
2477
|
case 'capture': cmdCapture(); break
|
|
2044
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",
|