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 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 an engagement
147
- fde debrief notes.md # route meeting notes into memory (also reads stdin)
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
- The latest dated `[signal:...]` token per stakeholder drives the trust column in `status` and `dashboard`; signals older than 21 days show as stale.
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
 
@@ -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, run `fde resume` (fallback `node ~/.claude/fdeops/fde.js resume`) to load `context.md`. Use the CLI for deterministic work - `fde scan | log | receipts | status | dashboard` - instead of improvising shell.
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 words = String(text).replace(/\([^)]*\)/g, '').split(/\s+/).filter(w => w && !/^(dr|mr|mrs|ms)\.?$/i.test(w))
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:' + String(text).slice(0, 48).toLowerCase())
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*/, '').replace(/\[signal:(red|amber|green)\]/i, '').replace(/\[\d{4}-\d{2}-\d{2}\]/, '').trim()
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].replace(/\[signal:(red|amber|green)\]/i, '').trim()
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
- console.log(`undid last write ${meta.file}`)
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 = `- [${date}] ${signal ? `[signal:${signal}] ` : ''}${text}`
1190
+ const entry = datedEntry(eng, date, text, signal || '')
1042
1191
  appendLogEntry(eng, type, entry)
1043
- console.log(`logged ${LOG_FILES[type]}${signal ? ` (signal:${signal})` : ''}`)
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
- // Real notes arrive as markdown: "- decision: ...", "* contact: ...",
1069
- // "**Decision:** ..." - strip bullet/bold dressing before matching, or the
1070
- // prefix silently misses and a [signal:x] token lands in context.md, which
1071
- // signal parsing never reads. Silent loss is the one failure a memory tool
1072
- // cannot have. --dry-run prints the routing without writing anything.
1073
- function cmdDebrief(args) {
1074
- args = args.slice()
1075
- const dryIdx = args.indexOf('--dry-run')
1076
- const dry = dryIdx !== -1
1077
- if (dry) args.splice(dryIdx, 1)
1078
- let force = false
1079
- const forceIdx = args.indexOf('--force')
1080
- if (forceIdx !== -1) { force = true; args.splice(forceIdx, 1) }
1081
- const eng = resolveEngagement({ forWrite: true })
1082
- if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
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 (_) {} // stdin until EOF
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
- const body = m[2]
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 = `- [${date}] ${body}`
1297
+ const entry = datedEntry(eng, date, body, type === 'contact' && sigInline ? sigInline.toLowerCase() : '')
1125
1298
  if (dry) console.log(`→ ${LOG_FILES[type]} ${entry}`)
1126
- // appendLogEntry, not a blind append: a contact: line may carry an
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 { lockedAppendFile(path.join(eng, 'context.md'), block, { soft: true }) } catch (_) {}
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: decision:/risk:/delivery:/contact: lines route, rest → context.md (stdin if no file; --dry-run; --force)
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
@@ -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.8.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",