fdeops 5.1.17 → 5.1.19

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
@@ -527,10 +527,11 @@ function formatFsError(err, action, target) {
527
527
  }
528
528
 
529
529
  let debriefTransactionActive = false
530
+ let activeFileLocks = 0
530
531
  const ownedDebriefLocks = new Set()
531
532
 
532
533
  function failFs(err, action, target) {
533
- if (debriefTransactionActive || ownedDebriefLocks.size) throw new Error(formatFsError(err, action, target))
534
+ if (activeFileLocks || debriefTransactionActive || ownedDebriefLocks.size) throw new Error(formatFsError(err, action, target))
534
535
  console.error(formatFsError(err, action, target))
535
536
  process.exit(1)
536
537
  }
@@ -546,7 +547,7 @@ function refuseSymlinkWrite(p, opts = {}) {
546
547
  const msg = st.isSymbolicLink()
547
548
  ? `refused: ${path.basename(p)} is a symlink - write would leave the engagement tree. Replace it with a real file.`
548
549
  : `refused: ${path.basename(p)} is not a regular file - remove it and re-run; every write is refused while it is there.`
549
- if ((debriefTransactionActive || ownedDebriefLocks.size) && !opts.soft) throw new Error(msg)
550
+ if ((activeFileLocks || debriefTransactionActive || ownedDebriefLocks.size) && !opts.soft) throw new Error(msg)
550
551
  if (opts.soft) return msg
551
552
  console.error(msg)
552
553
  process.exit(1)
@@ -563,7 +564,7 @@ function refuseSymlinkWrite(p, opts = {}) {
563
564
  // appending the same .fde file otherwise interleave/corrupt under load.
564
565
  function withFileLock(targetPath, fn, opts = {}) {
565
566
  if (ownedDebriefLocks.has(targetPath)) return fn()
566
- if (debriefTransactionActive || ownedDebriefLocks.size) opts = { ...opts, soft: true }
567
+ if (activeFileLocks || debriefTransactionActive || ownedDebriefLocks.size) opts = { ...opts, soft: true }
567
568
  const lockPath = targetPath + '.lock'
568
569
  const deadline = Date.now() + 5000
569
570
  while (true) {
@@ -585,9 +586,12 @@ function withFileLock(targetPath, fn, opts = {}) {
585
586
  if (opts.soft) throw e
586
587
  failFs(e, 'lock', targetPath)
587
588
  }
589
+ // Filesystem failures must unwind this lock instead of exiting in place.
590
+ activeFileLocks++
588
591
  try {
589
592
  return fn()
590
593
  } finally {
594
+ activeFileLocks--
591
595
  try { fs.closeSync(fd) } catch (_) {}
592
596
  try { fs.unlinkSync(lockPath) } catch (_) {}
593
597
  }
@@ -794,6 +798,13 @@ function appendLogEntry(eng, type, entry, opts = {}) {
794
798
  })
795
799
  // Durable CLI ledger - not rewritten by agent artifact passes.
796
800
  lockedAppendFile(path.join(eng, SIGNAL_LEDGER), `${entry}\n`)
801
+ } else if (type === 'risk') {
802
+ withFileLock(p, () => {
803
+ const md = readEng(eng, LOG_FILES[type])
804
+ const retired = md.search(/^#{1,6}\s+Retired\b/im)
805
+ const end = retired < 0 ? md.length : retired
806
+ atomicWriteFile(p, `${md.slice(0, end).trimEnd()}\n\n${entry}\n${retired < 0 ? '' : '\n' + md.slice(end)}`, { soft: true })
807
+ })
797
808
  } else {
798
809
  lockedAppendFile(p, `\n${entry}\n`)
799
810
  }
@@ -1406,14 +1417,18 @@ function cmdResume(args) {
1406
1417
  // line - resolution is first-match-wins, so appending a second line would
1407
1418
  // leave the stale binding winning and silently write to the wrong client.
1408
1419
  const cwd = process.cwd()
1409
- const prev = readRegistry().find(r => r.workspace === cwd)
1410
- const kept = readRegistry().filter(r => r.workspace !== cwd).map(r => `${r.workspace} ${r.slug}`)
1411
- kept.push(`${cwd} ${slug}`)
1420
+ let prev
1412
1421
  let bindErr = null
1413
1422
  // soft: an unwritable registry must not process.exit() from inside the lock -
1414
1423
  // that skipped the finally and left a stale .registry.lock behind.
1415
1424
  try {
1416
- withFileLock(REGISTRY, () => { atomicWriteFile(REGISTRY, kept.join('\n') + '\n', { soft: true }) }, { soft: true })
1425
+ withFileLock(REGISTRY, () => {
1426
+ const registry = readRegistry()
1427
+ prev = registry.find(r => r.workspace === cwd)
1428
+ const kept = registry.filter(r => r.workspace !== cwd).map(r => `${r.workspace} ${r.slug}`)
1429
+ kept.push(`${cwd} ${slug}`)
1430
+ atomicWriteFile(REGISTRY, kept.join('\n') + '\n', { soft: true })
1431
+ }, { soft: true })
1417
1432
  } catch (e) { bindErr = e }
1418
1433
  if (bindErr || !readRegistry().some(r => r.workspace === cwd && r.slug === slug)) {
1419
1434
  // Silently unbound is the worst outcome: the memory exists, every later
@@ -1423,7 +1438,6 @@ function cmdResume(args) {
1423
1438
  `could not bind this workspace - ${REGISTRY} is not writable${bindErr ? ` (${bindErr.code || bindErr.message})` : ''}.\n` +
1424
1439
  ` fix the file (it must be a regular file), or work with: export FDEOPS_ENGAGEMENT=${fdeDir}\n`
1425
1440
  )
1426
- try { fs.unlinkSync(REGISTRY + '.lock') } catch (_) {}
1427
1441
  process.exit(1)
1428
1442
  }
1429
1443
  console.log(`ENGAGEMENT READY: ${fdeDir}\nbound to workspace: ${cwd}`)
@@ -2540,7 +2554,10 @@ function cmdIngest(args) {
2540
2554
  if (args[i] === '--force') { force = true; continue }
2541
2555
  rest.push(args[i])
2542
2556
  }
2543
- source = sanitizeIngestToken(source, 'manual')
2557
+ // Redact before slugification: filenames no longer retain privacy markers.
2558
+ const cleanMetadata = value => masking.mask(splitPrivate(value, { sealDangling: true }).clean).replace(/[\r\n]+/g, ' ')
2559
+ source = sanitizeIngestToken(cleanMetadata(source), 'manual')
2560
+ title = cleanMetadata(title)
2544
2561
  const titleSlug = sanitizeIngestToken(title || 'notes', 'notes')
2545
2562
  if (!title) title = titleSlug
2546
2563
 
@@ -2574,23 +2591,35 @@ function cmdIngest(args) {
2574
2591
 
2575
2592
  const box = inboxDir(eng)
2576
2593
  try {
2577
- if (!checkedInbox(eng)) fs.mkdirSync(box)
2594
+ if (!checkedInbox(eng)) {
2595
+ try { fs.mkdirSync(box) } catch (error) { if (error.code !== 'EEXIST') throw error }
2596
+ }
2578
2597
  checkedInbox(eng)
2579
2598
  } catch (e) { failFs(e, 'create inbox', box) }
2580
2599
  const compact = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z')
2581
- const id = `${compact}-${source}-${titleSlug}.md`
2582
- const dest = path.join(box, id)
2583
- const body = [
2584
- '---',
2585
- `source: ${source}`,
2586
- `title: ${title.replace(/\n/g, ' ')}`,
2587
- `staged: ${new Date().toISOString()}`,
2588
- `id: ${id}`,
2589
- '---',
2590
- '',
2591
- input.replace(/\s+$/, '') + '\n',
2592
- ].join('\n')
2593
- withFileLock(dest, () => { atomicWriteFile(dest, body) })
2600
+ const stem = `${compact}-${source}-${titleSlug}`
2601
+ let id, dest
2602
+ // Serialize name selection with the write: two agents can stage in one second.
2603
+ withFileLock(path.join(box, '.stage'), () => {
2604
+ let suffix = 0
2605
+ while (true) {
2606
+ id = `${stem}${suffix ? `-${suffix}` : ''}.md`
2607
+ dest = path.join(box, id)
2608
+ try { fs.lstatSync(dest); suffix++ }
2609
+ catch (error) { if (error.code === 'ENOENT') break; throw error }
2610
+ }
2611
+ const body = [
2612
+ '---',
2613
+ `source: ${source}`,
2614
+ `title: ${title.replace(/\n/g, ' ')}`,
2615
+ `staged: ${new Date().toISOString()}`,
2616
+ `id: ${id}`,
2617
+ '---',
2618
+ '',
2619
+ input.replace(/\s+$/, '') + '\n',
2620
+ ].join('\n')
2621
+ atomicWriteFile(dest, body)
2622
+ })
2594
2623
  console.log(`staged → ${dest}`)
2595
2624
  console.log(`id: ${id}`)
2596
2625
  console.log('next: fde ingest propose ' + id)
@@ -3932,9 +3961,13 @@ function cmdDashboard(args) {
3932
3961
  // Stamped into the vault so a stale folder is identifiable. Best-effort: a
3933
3962
  // missing package.json must not stop an FDE generating their vault.
3934
3963
  function cliVersion() {
3935
- try {
3936
- return String(JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version || '')
3937
- } catch (_) { return '' }
3964
+ for (const file of [path.join(__dirname, 'package.json'), path.join(__dirname, '..', 'package.json')]) {
3965
+ try {
3966
+ const metadata = JSON.parse(fs.readFileSync(file, 'utf8'))
3967
+ if (metadata.name === 'fdeops' && typeof metadata.version === 'string') return metadata.version
3968
+ } catch (_) {}
3969
+ }
3970
+ return ''
3938
3971
  }
3939
3972
 
3940
3973
  function valueLedgerRows(eng) {
@@ -4318,7 +4351,7 @@ switch (cmd) {
4318
4351
  case 'setup': finishAsync(setup.command(setupStore, args)); break
4319
4352
  case 'privacy':
4320
4353
  if (args.length) { console.error('usage: fde privacy'); process.exitCode = 2; break }
4321
- console.log(`FDEOps ${require('../package.json').version} - identifier masking enabled by default.\nCLI responses, smart proposals, handoff packets and ingest MCP results use local aliases.\nPatterns: common emails, international/US phones, SSN-shaped identifiers and supported credentials.\nNames and arbitrary sensitive prose are not automatically detected; mark them <private> or supply local custom terms with fde setup.\nRaw files, pasted chat and upstream MCP content bypass this protection. Local reports retain identifiers by default; fde setup can also mask newly generated report content.`)
4354
+ console.log(`FDEOps ${cliVersion() || '(version unavailable)'} - identifier masking enabled by default.\nCLI responses, smart proposals, handoff packets and ingest MCP results use local aliases.\nPatterns: common emails, international/US phones, SSN-shaped identifiers and supported credentials.\nNames and arbitrary sensitive prose are not automatically detected; mark them <private> or supply local custom terms with fde setup.\nRaw files, pasted chat and upstream MCP content bypass this protection. Local reports retain identifiers by default; fde setup can also mask newly generated report content.`)
4322
4355
  break
4323
4356
  case 'demo': cmdDemo(args); break
4324
4357
  case 'scan': cmdScan(); break
package/bin/install.js CHANGED
@@ -22,7 +22,8 @@ const LIB_SRC = path.join(__dirname, 'lib')
22
22
  const GLOBAL_SKILLS_DIR = path.join(os.homedir(), '.claude', 'skills')
23
23
  const GLOBAL_HOOKS_DIR = path.join(os.homedir(), '.claude', 'hooks')
24
24
  const HOOK_SCRIPTS = ['session-start', 'session-stop', 'pre-compact']
25
- const ENGAGEMENTS_ROOT = path.join(os.homedir(), 'fde-engagements')
25
+ const ENGAGEMENTS_ROOT = (process.env.FDEOPS_ENGAGEMENTS_ROOT || '').trim().replace(/^~/, os.homedir())
26
+ || path.join(os.homedir(), 'fde-engagements')
26
27
 
27
28
  function copyDir(src, dest) {
28
29
  checkTree(src, dest)
@@ -268,6 +269,7 @@ function installSkills(opts = {}) {
268
269
  mkdir(cliHome)
269
270
  copyFile(path.join(__dirname, 'fde.js'), path.join(cliHome, 'fde.js'))
270
271
  copyDir(LIB_SRC, path.join(cliHome, 'lib'))
272
+ copyFile(path.join(__dirname, '..', 'package.json'), path.join(cliHome, 'package.json'))
271
273
  try { fs.chmodSync(path.join(cliHome, 'fde.js'), '755') } catch (_) {}
272
274
  copyDir(FDE_TEMPLATES_SRC, path.join(cliHome, 'templates', '.fde'))
273
275
 
@@ -292,7 +294,10 @@ function placePointer(destPath, content, label, appendable) {
292
294
  mkdir(path.dirname(destPath))
293
295
  if (fs.existsSync(destPath)) {
294
296
  const existing = fs.readFileSync(destPath, 'utf8')
295
- if (/FDEOS|fdeops/i.test(existing)) {
297
+ // Templates can change wording; a branded pointer remains installed.
298
+ const hasIdentity = existing.includes(FDE_MARKER) || /^#\s+fde(?:ops|os)\b/im.test(existing)
299
+ const hasSkillPointer = /\bskills\/fde\/SKILL\.md\b/.test(existing)
300
+ if (hasIdentity && hasSkillPointer) {
296
301
  console.log(` skip ${label} (already wired)`)
297
302
  return
298
303
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops-ingest-mcp",
3
- "version": "5.1.17",
3
+ "version": "5.1.19",
4
4
  "private": true,
5
5
  "description": "Thin stdio MCP sink for FDEOps ingest (stage \u2192 propose \u2192 apply). Zero runtime dependencies.",
6
6
  "bin": {
@@ -164,8 +164,11 @@ function runFde(args, stdin, extraEnv) {
164
164
  }
165
165
 
166
166
  function engagementEnv(args) {
167
- const p = args && typeof args.engagement === 'string' ? args.engagement.trim() : ''
168
- return p ? { FDEOPS_ENGAGEMENT: p } : {}
167
+ if (!Object.prototype.hasOwnProperty.call(args, 'engagement')) return {}
168
+ if (typeof args.engagement !== 'string' || !args.engagement.trim()) {
169
+ throw new Error('engagement must be a non-empty string when supplied; no customer was selected')
170
+ }
171
+ return { FDEOPS_ENGAGEMENT: args.engagement.trim() }
169
172
  }
170
173
 
171
174
  function cliPayload(out) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "5.1.17",
3
+ "version": "5.1.19",
4
4
  "description": "Skills for forward deployed engineers across strategy, architecture and engineering. Use individual tasks or @fde coordination; local customer memory supports continuity.",
5
5
  "bin": {
6
6
  "fdeops": "bin/install.js",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "fdeops",
4
- "version": "5.1.17",
4
+ "version": "5.1.19",
5
5
  "description": "Skills for forward deployed engineers across strategy, architecture and engineering. Use individual tasks or @fde coordination; local customer memory supports continuity.",
6
6
  "author": {
7
7
  "name": "Subash Natarajan",