Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "fdeops",
"description": "Skills for forward deployed engineers across strategy, architecture and engineering. Use individual tasks or @fde coordination; local customer memory supports continuity.",
"version": "5.1.17",
"version": "5.1.18",
"category": "productivity",
"tags": [
"community-managed"
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 5.1.18 - 2026-09-22

- Preserve distinct staged notes when source and title match within the same second, including concurrent staging.
- Make installer initialization honor the configured engagement root so later binding uses the same record.
- Include version metadata with the disk-installed CLI and keep privacy guidance available for legacy installs without metadata.

## 5.1.17 - 2026-09-22

- Fix Claude Code plugin hook commands so the plugin root expands correctly, including installation paths containing spaces.
Expand Down
52 changes: 34 additions & 18 deletions bin/fde.js
Original file line number Diff line number Diff line change
Expand Up @@ -2574,23 +2574,35 @@ function cmdIngest(args) {

const box = inboxDir(eng)
try {
if (!checkedInbox(eng)) fs.mkdirSync(box)
if (!checkedInbox(eng)) {
try { fs.mkdirSync(box) } catch (error) { if (error.code !== 'EEXIST') throw error }
}
checkedInbox(eng)
} catch (e) { failFs(e, 'create inbox', box) }
const compact = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z')
const id = `${compact}-${source}-${titleSlug}.md`
const dest = path.join(box, id)
const body = [
'---',
`source: ${source}`,
`title: ${title.replace(/\n/g, ' ')}`,
`staged: ${new Date().toISOString()}`,
`id: ${id}`,
'---',
'',
input.replace(/\s+$/, '') + '\n',
].join('\n')
withFileLock(dest, () => { atomicWriteFile(dest, body) })
const stem = `${compact}-${source}-${titleSlug}`
let id, dest
// Serialize name selection with the write: two agents can stage in one second.
withFileLock(path.join(box, '.stage'), () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Concurrent first staging rejects notes

During concurrent first-time staging, withFileLock starts after both processes can race to create the absent inbox. One mkdirSync can receive EEXIST, aborting that note instead of assigning a suffix.

Learn more

Inbox creation occurs before the shared staging lock. checkedInbox returns null when the inbox is absent, and each process then calls fs.mkdirSync(box) independently. If two processes observe the missing directory, the first creates it and the second receives EEXIST; the outer catch exits through failFs before filename selection begins.

Example: Two MCP clients simultaneously stage the first notes for Atlas. Both see no .inbox; client A creates it, while client B fails with EEXIST. Only A's note reaches the suffix-selection lock, although both notes had distinct content.

Recommended fix: Create the inbox with race-safe semantics, such as fs.mkdirSync(box, { recursive: true }), then validate it with checkedInbox(eng). Alternatively, serialize directory creation using a lock whose parent already exists. Add a barrier-based regression that forces both processes past the absence check before either creates the directory.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

let suffix = 0
while (true) {
id = `${stem}${suffix ? `-${suffix}` : ''}.md`
dest = path.join(box, id)
try { fs.lstatSync(dest); suffix++ }
catch (error) { if (error.code === 'ENOENT') break; throw error }
}
const body = [
'---',
`source: ${source}`,
`title: ${title.replace(/\n/g, ' ')}`,
`staged: ${new Date().toISOString()}`,
`id: ${id}`,
'---',
'',
input.replace(/\s+$/, '') + '\n',
].join('\n')
atomicWriteFile(dest, body)
})
console.log(`staged → ${dest}`)
console.log(`id: ${id}`)
console.log('next: fde ingest propose ' + id)
Expand Down Expand Up @@ -3932,9 +3944,13 @@ function cmdDashboard(args) {
// Stamped into the vault so a stale folder is identifiable. Best-effort: a
// missing package.json must not stop an FDE generating their vault.
function cliVersion() {
try {
return String(JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version || '')
} catch (_) { return '' }
for (const file of [path.join(__dirname, 'package.json'), path.join(__dirname, '..', 'package.json')]) {
try {
const metadata = JSON.parse(fs.readFileSync(file, 'utf8'))
if (metadata.name === 'fdeops' && typeof metadata.version === 'string') return metadata.version
} catch (_) {}
}
return ''
}

function valueLedgerRows(eng) {
Expand Down Expand Up @@ -4318,7 +4334,7 @@ switch (cmd) {
case 'setup': finishAsync(setup.command(setupStore, args)); break
case 'privacy':
if (args.length) { console.error('usage: fde privacy'); process.exitCode = 2; break }
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.`)
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.`)
break
case 'demo': cmdDemo(args); break
case 'scan': cmdScan(); break
Expand Down
4 changes: 3 additions & 1 deletion bin/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ const LIB_SRC = path.join(__dirname, 'lib')
const GLOBAL_SKILLS_DIR = path.join(os.homedir(), '.claude', 'skills')
const GLOBAL_HOOKS_DIR = path.join(os.homedir(), '.claude', 'hooks')
const HOOK_SCRIPTS = ['session-start', 'session-stop', 'pre-compact']
const ENGAGEMENTS_ROOT = path.join(os.homedir(), 'fde-engagements')
const ENGAGEMENTS_ROOT = (process.env.FDEOPS_ENGAGEMENTS_ROOT || '').trim().replace(/^~/, os.homedir())
|| path.join(os.homedir(), 'fde-engagements')

function copyDir(src, dest) {
checkTree(src, dest)
Expand Down Expand Up @@ -268,6 +269,7 @@ function installSkills(opts = {}) {
mkdir(cliHome)
copyFile(path.join(__dirname, 'fde.js'), path.join(cliHome, 'fde.js'))
copyDir(LIB_SRC, path.join(cliHome, 'lib'))
copyFile(path.join(__dirname, '..', 'package.json'), path.join(cliHome, 'package.json'))
try { fs.chmodSync(path.join(cliHome, 'fde.js'), '755') } catch (_) {}
copyDir(FDE_TEMPLATES_SRC, path.join(cliHome, 'templates', '.fde'))

Expand Down
2 changes: 1 addition & 1 deletion mcp/fdeops-ingest/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fdeops-ingest-mcp",
"version": "5.1.17",
"version": "5.1.18",
"private": true,
"description": "Thin stdio MCP sink for FDEOps ingest (stage \u2192 propose \u2192 apply). Zero runtime dependencies.",
"bin": {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fdeops",
"version": "5.1.17",
"version": "5.1.18",
"description": "Skills for forward deployed engineers across strategy, architecture and engineering. Use individual tasks or @fde coordination; local customer memory supports continuity.",
"bin": {
"fdeops": "bin/install.js",
Expand Down
2 changes: 1 addition & 1 deletion plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "fdeops",
"version": "5.1.17",
"version": "5.1.18",
"description": "Skills for forward deployed engineers across strategy, architecture and engineering. Use individual tasks or @fde coordination; local customer memory supports continuity.",
"author": {
"name": "Subash Natarajan",
Expand Down
105 changes: 105 additions & 0 deletions test/installed-runtime.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { spawnSync, spawn } = require('node:child_process')
const repo = path.resolve(__dirname, '..')
function fixture(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fde-runtime-'))
t.after(() => fs.rmSync(dir, { recursive: true, force: true }))
const home = path.join(dir, 'home'), workspace = path.join(dir, 'workspace')
fs.mkdirSync(home); fs.mkdirSync(workspace)
const env = { ...process.env, HOME: home, USERPROFILE: home,
FDEOPS_ENGAGEMENT: '', FDEOS_ENGAGEMENT: '', FDEOPS_ENGAGEMENTS_ROOT: '' }
const run = (file, args, extra = {}) => spawnSync(process.execPath, [file, ...args], {
cwd: workspace, env: { ...env, ...extra }, encoding: 'utf8', timeout: 20000,
})
return { dir, home, workspace, env, run }
}
const installer = path.join(repo, 'bin/install.js'), cli = path.join(repo, 'bin/fde.js')
test('installed privacy uses its own version and tolerates missing legacy metadata', t => {
const f = fixture(t)
const installed = f.run(installer, [])
assert.equal(installed.status, 0, installed.stderr)
const target = path.join(f.home, '.claude/fdeops/fde.js')
fs.writeFileSync(path.join(f.home, '.claude/package.json'), '{"name":"another-app","version":"99.99.99"}')
const current = f.run(target, ['privacy'])
assert.equal(current.status, 0, current.stderr)
assert.ok(current.stdout.includes(`FDEOps ${require('../package.json').version} -`))
fs.unlinkSync(path.join(f.home, '.claude/fdeops/package.json'))
const legacy = f.run(target, ['privacy'])
assert.equal(legacy.status, 0, legacy.stderr)
assert.match(legacy.stdout, /identifier masking enabled/)
assert.doesNotMatch(legacy.stdout, /99\.99\.99/)
})
for (const tilde of [false, true]) {
test(`installer init and CLI bind share a ${tilde ? 'tilde' : 'custom'} root`, t => {
const f = fixture(t), actual = path.join(f.home, 'custom clients')
const env = { FDEOPS_ENGAGEMENTS_ROOT: tilde ? ' ~/custom clients ' : actual }
const init = f.run(installer, ['init', 'atlas'], env)
assert.equal(init.status, 0, init.stderr)
const record = path.join(actual, 'atlas/.fde/context.md')
assert.ok(fs.existsSync(record))
fs.appendFileSync(record, '\nKEPT_CUSTOMER_CONTEXT\n')
const bind = f.run(cli, ['resume', '--init', 'atlas'], env)
assert.equal(bind.status, 0, bind.stderr)
const resumed = f.run(cli, ['resume'], env)
assert.equal(resumed.status, 0, resumed.stderr)
assert.match(resumed.stdout, /KEPT_CUSTOMER_CONTEXT/)
assert.equal(fs.existsSync(path.join(f.home, 'fde-engagements/atlas')), false)
})
}
for (const concurrent of [false, true]) {
test(`same-second staging preserves every ${concurrent ? 'concurrent' : 'sequential'} note`, async t => {
const f = fixture(t), eng = path.join(f.dir, 'client/.fde')
fs.mkdirSync(eng, { recursive: true }); fs.writeFileSync(path.join(eng, 'context.md'), '# Fictional client\n')
const preload = path.join(f.dir, 'clock.cjs')
// Fix ID timestamps; keep Date.now advancing for lock deadlines.
fs.writeFileSync(preload, `const D=Date;global.Date=class extends D { constructor(...a){ super(...(a.length?a:['2026-09-22T10:00:00.100Z'])) } };`)
const barrier = path.join(f.dir, 'barrier')
fs.mkdirSync(barrier)
if (concurrent) fs.appendFileSync(preload, `
const fs=require('node:fs'),path=require('node:path'),stat=fs.lstatSync;
let waited=false;
fs.lstatSync=function(file,...args){
try { return stat.call(this,file,...args) }
catch(error){
if(!waited && error.code==='ENOENT' && path.basename(String(file))==='.inbox'){
waited=true;
fs.writeFileSync(path.join(process.env.STAGE_BARRIER,process.env.STAGE_INDEX),'ready');
const deadline=Date.now()+5000;
while(fs.readdirSync(process.env.STAGE_BARRIER).length<4){
if(Date.now()>deadline) throw Error('barrier timeout');
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,10);
}
}
throw error;
}
};
`)
const stage = index => new Promise((resolve, reject) => {
const child = spawn(process.execPath, ['--require', preload, cli, 'ingest', 'stage', '--source', 'manual', '--title', 'meeting'], {
cwd: f.workspace, env: { ...f.env, FDEOPS_ENGAGEMENT: eng, STAGE_BARRIER: barrier, STAGE_INDEX: String(index) }, timeout: 10000,
})
let stdout = '', stderr = ''
child.stdout.on('data', b => { stdout += b }); child.stderr.on('data', b => { stderr += b })
child.on('error', reject)
child.on('close', code => code === 0 ? resolve(stdout) : reject(new Error(stderr || `exit ${code}`)))
child.stdin.end(`NOTE_${index}_SENTINEL`)
})
const results = []
if (concurrent) {
const settled = await Promise.allSettled([0, 1, 2, 3].map(stage))
for (const result of settled) {
assert.equal(result.status, 'fulfilled', result.reason?.message)
results.push(result.value)
}
}
else for (let i = 0; i < 4; i++) results.push(await stage(i))
const box = path.join(f.dir, 'client/.inbox'), files = fs.readdirSync(box)
assert.equal(files.length, 4)
assert.equal(new Set(results.map(r => r.match(/^id: (.+)$/m)[1])).size, 4)
for (let i = 0; i < 4; i++) assert.equal(files.filter(file => fs.readFileSync(path.join(box, file), 'utf8').includes(`NOTE_${i}_SENTINEL`)).length, 1)
})
}
Loading