diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b7dd50d..a9d8e36 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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" diff --git a/CHANGELOG.md b/CHANGELOG.md index 7040985..f1a0f52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/bin/fde.js b/bin/fde.js index 163d5f4..ad73db8 100755 --- a/bin/fde.js +++ b/bin/fde.js @@ -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'), () => { + 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) @@ -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) { @@ -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 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 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 diff --git a/bin/install.js b/bin/install.js index 6fc8840..3494375 100755 --- a/bin/install.js +++ b/bin/install.js @@ -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) @@ -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')) diff --git a/mcp/fdeops-ingest/package.json b/mcp/fdeops-ingest/package.json index 28f8721..fa378c8 100644 --- a/mcp/fdeops-ingest/package.json +++ b/mcp/fdeops-ingest/package.json @@ -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": { diff --git a/package.json b/package.json index 6ebde3c..e8a9489 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/plugin.json b/plugin.json index ca12a50..2e9e477 100644 --- a/plugin.json +++ b/plugin.json @@ -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", diff --git a/test/installed-runtime.test.js b/test/installed-runtime.test.js new file mode 100644 index 0000000..3ff6d7b --- /dev/null +++ b/test/installed-runtime.test.js @@ -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) + }) +}