fdeops 3.8.1 → 3.8.3

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/bin/fde.js CHANGED
@@ -217,9 +217,46 @@ function removeExactEntryLine(md, entry) {
217
217
  }
218
218
 
219
219
 
220
+ // Map Node fs errno codes to one-line field messages - never dump a stack at an FDE.
221
+ function formatFsError(err, action, target) {
222
+ const code = err && err.code
223
+ const where = path.basename(String(target || '')) || String(target || 'path')
224
+ if (code === 'ENOSPC') return `cannot ${action} ${where} - disk full`
225
+ if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') {
226
+ return `cannot ${action} ${where} - permission denied (read-only or locked down)`
227
+ }
228
+ if (code === 'ELOOP') return `cannot ${action} ${where} - symlink loop`
229
+ if (code === 'ENOENT') return `cannot ${action} ${where} - path missing`
230
+ return `cannot ${action} ${where}${code ? ` (${code})` : ''}${err && err.message && !code ? ': ' + err.message : ''}`
231
+ }
232
+
233
+ function failFs(err, action, target) {
234
+ console.error(formatFsError(err, action, target))
235
+ process.exit(1)
236
+ }
237
+
238
+ // Refuse writes that would follow a symlink out of the engagement tree.
239
+ // Missing path is fine (new file). Soft mode returns the message instead of exiting
240
+ // (session capture must never crash a hook).
241
+ function refuseSymlinkWrite(p, opts = {}) {
242
+ try {
243
+ if (fs.lstatSync(p).isSymbolicLink()) {
244
+ const msg = `refused: ${path.basename(p)} is a symlink - write would leave the engagement tree. Replace it with a real file.`
245
+ if (opts.soft) return msg
246
+ console.error(msg)
247
+ process.exit(1)
248
+ }
249
+ } catch (e) {
250
+ if (e.code === 'ENOENT') return null
251
+ if (opts.soft) return formatFsError(e, 'check', p)
252
+ failFs(e, 'check', p)
253
+ }
254
+ return null
255
+ }
256
+
220
257
  // Exclusive create lock + retry. Two parallel agent sessions (or hook + CLI)
221
258
  // appending the same .fde file otherwise interleave/corrupt under load.
222
- function withFileLock(targetPath, fn) {
259
+ function withFileLock(targetPath, fn, opts = {}) {
223
260
  const lockPath = targetPath + '.lock'
224
261
  const deadline = Date.now() + 5000
225
262
  while (true) {
@@ -227,14 +264,19 @@ function withFileLock(targetPath, fn) {
227
264
  try {
228
265
  fd = fs.openSync(lockPath, 'wx')
229
266
  } catch (e) {
230
- if (e.code !== 'EEXIST') throw e
231
- if (Date.now() > deadline) {
232
- console.error(`could not lock ${path.basename(targetPath)} - another writer is active; retry`)
233
- process.exit(1)
267
+ if (e.code === 'EEXIST') {
268
+ if (Date.now() > deadline) {
269
+ const msg = `could not lock ${path.basename(targetPath)} - another writer is active; retry`
270
+ if (opts.soft) throw Object.assign(new Error(msg), { code: 'ELOCKED' })
271
+ console.error(msg)
272
+ process.exit(1)
273
+ }
274
+ const waitUntil = Date.now() + 20
275
+ while (Date.now() < waitUntil) { /* spin */ }
276
+ continue
234
277
  }
235
- const waitUntil = Date.now() + 20
236
- while (Date.now() < waitUntil) { /* spin */ }
237
- continue
278
+ if (opts.soft) throw e
279
+ failFs(e, 'lock', targetPath)
238
280
  }
239
281
  try {
240
282
  return fn()
@@ -245,14 +287,39 @@ function withFileLock(targetPath, fn) {
245
287
  }
246
288
  }
247
289
 
248
- function atomicWriteFile(p, content) {
290
+ function atomicWriteFile(p, content, opts = {}) {
291
+ const blocked = refuseSymlinkWrite(p, opts)
292
+ if (blocked) {
293
+ if (opts.soft) throw Object.assign(new Error(blocked), { code: 'ESYMLINK' })
294
+ return
295
+ }
249
296
  const tmp = `${p}.${process.pid}.${Date.now()}.tmp`
250
- fs.writeFileSync(tmp, content)
251
- fs.renameSync(tmp, p)
297
+ try {
298
+ fs.writeFileSync(tmp, content)
299
+ fs.renameSync(tmp, p)
300
+ } catch (e) {
301
+ try { fs.unlinkSync(tmp) } catch (_) {}
302
+ if (opts.soft) throw e
303
+ failFs(e, 'write', p)
304
+ }
252
305
  }
253
306
 
254
- function lockedAppendFile(p, text) {
255
- withFileLock(p, () => { fs.appendFileSync(p, text) })
307
+ function lockedAppendFile(p, text, opts = {}) {
308
+ const blocked = refuseSymlinkWrite(p, opts)
309
+ if (blocked) {
310
+ if (opts.soft) throw Object.assign(new Error(blocked), { code: 'ESYMLINK' })
311
+ return
312
+ }
313
+ try {
314
+ withFileLock(p, () => { fs.appendFileSync(p, text) }, opts)
315
+ } catch (e) {
316
+ if (opts.soft) throw e
317
+ failFs(e, 'append', p)
318
+ }
319
+ }
320
+
321
+ function rmTreeQuiet(dir) {
322
+ try { fs.rmSync(dir, { recursive: true, force: true }) } catch (_) {}
256
323
  }
257
324
 
258
325
  // Pull the body under a "## Heading" up to the next "##" (or EOF).
@@ -349,31 +416,82 @@ function stakeholdersMemoryHealth(eng) {
349
416
  return { ok: true, warn: '' }
350
417
  }
351
418
 
419
+ // Subject key for a signal-history line - first real name word (same spirit as
420
+ // extractStakeholders). A green about Randy must not clear an amber about Denise.
421
+ function signalSubjectKey(text) {
422
+ const words = String(text).replace(/\([^)]*\)/g, '').split(/\s+/).filter(w => w && !/^(dr|mr|mrs|ms)\.?$/i.test(w))
423
+ const frag = (words[0] || '').replace(/[^a-z0-9]/gi, '').toLowerCase()
424
+ return frag.length >= 3 ? frag : ('anon:' + String(text).slice(0, 48).toLowerCase())
425
+ }
426
+
427
+ function parsePhase(ctx) {
428
+ // Template ships "**Phase:** land | discover | ..." - that is UNSET, not land.
429
+ const m = ctx.match(/\*\*Phase:\*\*\s*(.+)/i) || ctx.match(/^phase[:\s*]+(.+)$/im)
430
+ if (!m) return '?'
431
+ const raw = m[1].replace(/\*/g, '').trim()
432
+ if (!raw || /\|/.test(raw) || /^unset$/i.test(raw) || /^[\[(]/.test(raw)) return '?'
433
+ const one = raw.toLowerCase().match(/^(land|discover|plan|build|ship|close)\b/)
434
+ return one ? one[1] : '?'
435
+ }
436
+
437
+ function countOpenRisks(eng) {
438
+ const md = readClean(eng, 'risks.md')
439
+ const body = md.split(/^#{1,6}\s+Retired\b/im)[0] || md
440
+ let n = 0
441
+ for (const raw of body.split('\n')) {
442
+ const t = raw.trim()
443
+ if (!t || t.startsWith('<!--') || /^#{1,6}\s/.test(t)) continue
444
+ if (/risk\s*\|\s*status|mitigation/i.test(t) || /^\|?[\s|:-]+$/.test(t)) continue
445
+ if (/^[-*]/.test(t) || (/^\|/.test(t) && t.length > 12)) n++
446
+ }
447
+ return n
448
+ }
449
+
450
+ function nextActionLine(ctx) {
451
+ const body = sectionBody(ctx, 'Next action')
452
+ for (const raw of body.split('\n')) {
453
+ const t = raw.trim().replace(/^[-*]\s+/, '')
454
+ if (t) return t.slice(0, 120)
455
+ }
456
+ return ''
457
+ }
458
+
352
459
  function computeSignals(eng) {
353
460
  // readClean, not readEng: status/dashboard echo topRisk and stakeholder lines
354
461
  // to the terminal and the rendered HTML - a <private> risk must never surface.
355
462
  const ctx = readClean(eng, 'context.md'); const stake = readClean(eng, 'stakeholders.md'); const risks = readClean(eng, 'risks.md')
356
463
  // Prefer structured tokens from stakeholders + CLI ledger (ledger survives wipes)
357
464
  const signalText = stake + '\n' + readClean(eng, SIGNAL_LEDGER)
358
- const phase = (ctx.match(/phase[:* ]+\**([a-z-]+)/i) || [])[1] || '?'
359
- let latest = null
465
+ const phase = parsePhase(ctx)
466
+ // Latest signal PER stakeholder, then worst-of those actives.
467
+ // Global "latest wins" let a green from person B hide a sponsor crisis on A.
468
+ const byPerson = new Map()
360
469
  for (const l of signalText.split('\n')) {
361
470
  const sm = l.match(/\[signal:(red|amber|green)\]/i)
362
471
  if (!sm) continue
363
472
  const date = (l.match(/\[(\d{4}-\d{2}-\d{2})\]/) || [])[1] || ''
364
473
  const text = l.replace(/^\s*-\s*/, '').replace(/\[signal:(red|amber|green)\]/i, '').replace(/\[\d{4}-\d{2}-\d{2}\]/, '').trim()
365
- if (!latest || date >= latest.date) latest = { date, sig: sm[1].toLowerCase(), text }
474
+ const key = signalSubjectKey(text)
475
+ const prev = byPerson.get(key)
476
+ if (!prev || date >= prev.date) byPerson.set(key, { date, sig: sm[1].toLowerCase(), text })
477
+ }
478
+ const RANK = { red: 0, amber: 1, green: 2 }
479
+ let worst = null
480
+ for (const s of byPerson.values()) {
481
+ if (!worst || RANK[s.sig] < RANK[worst.sig] || (RANK[s.sig] === RANK[worst.sig] && s.date >= worst.date)) {
482
+ worst = s
483
+ }
366
484
  }
367
485
  const mem = stakeholdersMemoryHealth(eng)
368
486
  let trust, signalAge = null, stale = false, trustReason = ''
369
- if (!mem.ok && !latest) {
487
+ if (!mem.ok && !worst) {
370
488
  trust = 'amber'
371
489
  trustReason = mem.warn
372
- } else if (latest) {
373
- trust = latest.sig === 'red' ? 'RED' : latest.sig
374
- trustReason = (latest.text || '').slice(0, 80)
375
- if (latest.date) {
376
- signalAge = Math.max(0, Math.floor((Date.now() - Date.parse(latest.date)) / 86400000))
490
+ } else if (worst) {
491
+ trust = worst.sig === 'red' ? 'RED' : worst.sig
492
+ trustReason = (worst.text || '').slice(0, 80)
493
+ if (worst.date) {
494
+ signalAge = Math.max(0, Math.floor((Date.now() - Date.parse(worst.date)) / 86400000))
377
495
  stale = signalAge > 21
378
496
  }
379
497
  } else {
@@ -388,14 +506,33 @@ function computeSignals(eng) {
388
506
  }) || '').replace(/\|/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80)
389
507
  // Prefer the trust trigger (signal / memory warn) over a random risk line when triage is not green
390
508
  const reason = (trust !== 'green' && (trustReason || mem.warn)) ? (trustReason || mem.warn) : topRisk
509
+ const openRisks = countOpenRisks(eng)
510
+ const nextAction = nextActionLine(ctx)
391
511
  let updated = 'never', ageDays = Infinity
392
512
  try {
393
513
  ageDays = Math.floor((Date.now() - fs.statSync(path.join(eng, 'context.md')).mtimeMs) / 86400000)
394
514
  updated = ageDays === 0 ? 'today' : `${ageDays}d ago`
395
515
  } catch (_) {}
396
- return { phase, trust, signalAge, stale, topRisk, reason, memoryWarn: mem.warn, updated, ageDays }
516
+ return { phase, trust, signalAge, stale, topRisk, reason, memoryWarn: mem.warn, openRisks, nextAction, updated, ageDays }
517
+ }
518
+
519
+ function resumeTriage(eng) {
520
+ const s = computeSignals(eng)
521
+ const label = s.trust + (s.stale ? '?' : '')
522
+ const phase = s.phase === '?' ? 'unset' : s.phase
523
+ const lines = [
524
+ `TRIAGE [${label.padEnd(6)}] phase:${phase} updated:${s.updated} open risks:${s.openRisks}`,
525
+ ]
526
+ if (s.reason) {
527
+ const age = s.signalAge != null ? ` (${s.signalAge}d old${s.stale ? ', STALE - reconfirm' : ''})` : ''
528
+ lines.push(` trust: ${s.reason}${age}`)
529
+ }
530
+ if (s.nextAction) lines.push(` next: ${s.nextAction}`)
531
+ else lines.push(' next: (none set - add under ## Next action in context.md)')
532
+ return lines.join('\n')
397
533
  }
398
534
 
535
+
399
536
  // ---------- dashboard content extractors (best-effort, read-only) ----------
400
537
  // The fieldbook's structured widgets (stakeholders, risks, log, stats) want
401
538
  // data shapes that .fde/ markdown does not literally carry - it is written by
@@ -722,14 +859,50 @@ function cmdResume(args) {
722
859
  const tpl = templatesDir()
723
860
  if (!tpl) { console.error('templates not found - run from the fdeops clone or reinstall'); process.exit(1) }
724
861
  const slug = slugify(name)
725
- const fdeDir = path.join(ENGAGEMENTS_ROOT, slug, '.fde')
726
- fs.mkdirSync(fdeDir, { recursive: true })
727
- for (const f of fs.readdirSync(tpl)) {
728
- const src = path.join(tpl, f); const dst = path.join(fdeDir, f)
729
- if (fs.statSync(src).isDirectory()) fs.mkdirSync(dst, { recursive: true })
730
- else if (!fs.existsSync(dst)) fs.copyFileSync(src, dst)
862
+ const engRoot = path.join(ENGAGEMENTS_ROOT, slug)
863
+ const fdeDir = path.join(engRoot, '.fde')
864
+ const existed = fs.existsSync(fdeDir)
865
+
866
+ const fillTemplates = (destFde) => {
867
+ for (const f of fs.readdirSync(tpl)) {
868
+ const src = path.join(tpl, f); const dst = path.join(destFde, f)
869
+ if (fs.statSync(src).isDirectory()) fs.mkdirSync(dst, { recursive: true })
870
+ else if (!fs.existsSync(dst)) fs.copyFileSync(src, dst)
871
+ }
872
+ fs.mkdirSync(path.join(destFde, 'retrospectives'), { recursive: true })
873
+ }
874
+
875
+ try {
876
+ if (!existed) {
877
+ // Atomic create: build under a staging dir, then rename into place.
878
+ // Disk-full / permission mid-copy must not leave a half-built engagement.
879
+ fs.mkdirSync(ENGAGEMENTS_ROOT, { recursive: true })
880
+ const stagingRoot = path.join(ENGAGEMENTS_ROOT, `.init-${slug}-${process.pid}`)
881
+ const stagingEng = path.join(stagingRoot, slug)
882
+ const stagingFde = path.join(stagingEng, '.fde')
883
+ rmTreeQuiet(stagingRoot)
884
+ try {
885
+ fs.mkdirSync(stagingFde, { recursive: true })
886
+ fillTemplates(stagingFde)
887
+ // If a partial engRoot exists from an older failed run, remove it first.
888
+ if (fs.existsSync(engRoot)) rmTreeQuiet(engRoot)
889
+ fs.renameSync(stagingEng, engRoot)
890
+ rmTreeQuiet(stagingRoot)
891
+ } catch (e) {
892
+ rmTreeQuiet(stagingRoot)
893
+ if (fs.existsSync(engRoot) && !fs.existsSync(path.join(engRoot, '.fde', 'context.md'))) {
894
+ rmTreeQuiet(engRoot)
895
+ }
896
+ failFs(e, 'create engagement', engRoot)
897
+ }
898
+ } else {
899
+ // Re-init / rebind: only fill missing template files in place.
900
+ fillTemplates(fdeDir)
901
+ }
902
+ } catch (e) {
903
+ failFs(e, 'init engagement', fdeDir)
731
904
  }
732
- fs.mkdirSync(path.join(fdeDir, 'retrospectives'), { recursive: true })
905
+
733
906
  // bind THIS workspace to the engagement (zero ceremony next time).
734
907
  // A workspace binds to exactly ONE engagement: rebinding REPLACES the old
735
908
  // line - resolution is first-match-wins, so appending a second line would
@@ -764,7 +937,9 @@ function cmdResume(args) {
764
937
  console.log(`NO ENGAGEMENT for this workspace.\nexisting: ${list}\ncreate + bind one: fde resume --init <client-name>`)
765
938
  process.exit(2)
766
939
  }
767
- console.log(`ENGAGEMENT: ${eng}\n`)
940
+ // Monday-morning command: triage first (trust / phase / risks / next), then memory.
941
+ console.log(resumeTriage(eng))
942
+ console.log(`\nENGAGEMENT: ${eng}\n`)
768
943
  // readClean, not fs.readFileSync: this output is what an agent loads as
769
944
  // context, so it goes through the same <private> redaction as the dashboard.
770
945
  const ctx = readClean(eng, 'context.md')
@@ -842,10 +1017,23 @@ function cmdLog(args) {
842
1017
  args.splice(sigIdx, 2)
843
1018
  }
844
1019
  const type = args[0]; const text = args.slice(1).join(' ')
845
- 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) }
846
- if (signal && type !== 'contact') { console.error('--signal only applies to: fde log contact'); process.exit(1) }
847
1020
  const eng = resolveEngagement({ forWrite: true })
848
1021
  if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
1022
+
1023
+ // fde log phase <land|discover|plan|build|ship|close> - advances portfolio phase
1024
+ if (type === 'phase') {
1025
+ const phase = (text || '').toLowerCase().trim()
1026
+ if (!['land', 'discover', 'plan', 'build', 'ship', 'close'].includes(phase)) {
1027
+ console.error('usage: fde log phase <land|discover|plan|build|ship|close>')
1028
+ process.exit(1)
1029
+ }
1030
+ setContextPhase(eng, phase)
1031
+ console.log(`phase → ${phase}`)
1032
+ return
1033
+ }
1034
+
1035
+ 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) }
1036
+ if (signal && type !== 'contact') { console.error('--signal only applies to: fde log contact'); process.exit(1) }
849
1037
  const hit = findSecretHit(text)
850
1038
  if (hit && !force) { refuseSecret('log text', hit); process.exit(1) }
851
1039
  if (hit && force) console.error(`warning: logging possible ${hit} (--force)`)
@@ -855,6 +1043,23 @@ function cmdLog(args) {
855
1043
  console.log(`logged → ${LOG_FILES[type]}${signal ? ` (signal:${signal})` : ''}`)
856
1044
  }
857
1045
 
1046
+ function setContextPhase(eng, phase) {
1047
+ const p = path.join(eng, 'context.md')
1048
+ let md = readEng(eng, 'context.md')
1049
+ if (!md) md = '# Engagement context\n\n'
1050
+ if (/\*\*Phase:\*\*/i.test(md)) {
1051
+ md = md.replace(/\*\*Phase:\*\*\s*.*/i, `**Phase:** ${phase}`)
1052
+ } else {
1053
+ md = md.replace(/\n*$/, `\n\n**Phase:** ${phase}\n`)
1054
+ }
1055
+ const today = new Date().toISOString().slice(0, 10)
1056
+ if (/\*\*Last updated:\*\*/i.test(md)) {
1057
+ md = md.replace(/\*\*Last updated:\*\*\s*.*/i, `**Last updated:** ${today}`)
1058
+ }
1059
+ withFileLock(p, () => { atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n') })
1060
+ }
1061
+
1062
+
858
1063
  // Meeting notes → structured memory. Deterministic routing, zero AI: lines that
859
1064
  // start with decision:/risk:/delivery:/contact: (case-insensitive) go to their
860
1065
  // LOG_FILES target as dated bullets; everything else lands in context.md as one
@@ -1007,7 +1212,7 @@ function cmdCapture() {
1007
1212
  if (branch) block += `- workspace: \`${branch}\` @ ${lastCommit || 'no commits yet'}\n`
1008
1213
  if (changed) block += `- uncommitted: ${changed}\n`
1009
1214
  if (updated) block += `- engagement files updated: ${updated}\n`
1010
- try { lockedAppendFile(path.join(eng, 'context.md'), block) } catch (_) {}
1215
+ try { lockedAppendFile(path.join(eng, 'context.md'), block, { soft: true }) } catch (_) {}
1011
1216
  }
1012
1217
 
1013
1218
  function engagementSlugFromPath(eng) {
@@ -1043,10 +1248,10 @@ function cmdStatus(args) {
1043
1248
  // "amber?" = structured signal went stale (>21d) - reconfirm before trusting it
1044
1249
  const label = r.trust + (r.stale ? '?' : '')
1045
1250
  const sig = r.signalAge != null ? `signal ${r.signalAge}d old${r.stale ? ' (STALE - reconfirm)' : ''} ` : ''
1046
- console.log(` [${label.padEnd(6)}] ${r.name.padEnd(24)} phase:${r.phase.padEnd(10)} updated:${r.updated.padEnd(8)} ${sig}${r.reason}`)
1251
+ 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}`)
1047
1252
  }
1048
1253
  if (!all) console.log('\n(current engagement only - pass --all for the full portfolio)')
1049
- console.log('\ntrust: latest [signal:x] token in stakeholders.md wins (fde log contact --signal, fde debrief); keyword heuristic only when none exists - verify before acting.')
1254
+ 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.')
1050
1255
  }
1051
1256
 
1052
1257
  // ---------- dashboard (deterministic markdown → one local HTML) ----------
@@ -1789,8 +1994,13 @@ ${clientViews}
1789
1994
  <script>${dashScript()}</script>
1790
1995
  </body></html>`
1791
1996
 
1792
- fs.mkdirSync(path.dirname(outPath), { recursive: true })
1793
- fs.writeFileSync(outPath, html)
1997
+ try {
1998
+ fs.mkdirSync(path.dirname(outPath), { recursive: true })
1999
+ refuseSymlinkWrite(outPath)
2000
+ fs.writeFileSync(outPath, html)
2001
+ } catch (e) {
2002
+ failFs(e, 'write fieldbook', outPath)
2003
+ }
1794
2004
  console.log(`fieldbook → ${outPath}`)
1795
2005
  console.log(`${engagements.length} engagement(s) rendered · ${counts.RED} red / ${counts.amber} amber / ${counts.green} green · 0 tokens (pure render)`)
1796
2006
  if (args.includes('--open')) {
@@ -1812,6 +2022,7 @@ function printUsage() {
1812
2022
  fde resume --init <name> create + bind engagement for this workspace (rebind replaces)
1813
2023
  fde resume --bind show what this workspace is bound to, and what resolves
1814
2024
  fde log <type> <text> append decision|risk|delivery|contact (contact takes --signal red|amber|green; --force to allow secret-like text)
2025
+ fde log phase <phase> set engagement phase (land|discover|plan|build|ship|close)
1815
2026
  fde log --undo remove the last CLI log/debrief entry from memory
1816
2027
  fde debrief [file] meeting notes → memory: decision:/risk:/delivery:/contact: lines route, rest → context.md (stdin if no file; --dry-run; --force)
1817
2028
  fde receipts <term> "what did we agree?" with dates
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.8.1",
3
+ "version": "3.8.3",
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",
@@ -4,7 +4,7 @@
4
4
 
5
5
  **Engagement:**
6
6
  **Customer:**
7
- **Phase:** land | discover | build | ship | close
7
+ **Phase:** unset
8
8
  **Last updated:**
9
9
 
10
10
  ## Current state