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.18",
"version": "5.1.19",
"category": "productivity",
"tags": [
"community-managed"
Expand Down
50 changes: 50 additions & 0 deletions .github/scripts/verify-release.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Release-only network verification; never imported by the local fde CLI.
const { randomUUID } = require('node:crypto')
const { setTimeout: sleep } = require('node:timers/promises')

async function verifyRelease({ name, version }, {
fetch = globalThis.fetch,
wait = sleep,
log = console.log,
} = {}) {
// At most 30 ten-second requests and 29 ten-second sleeps (under 10 minutes).
const attempts = 30
const nonce = randomUUID()
let last = 'no response'
log(`npm publish succeeded for ${name}@${version}; checking registry visibility.`)
for (let attempt = 1; attempt <= attempts; attempt++) {
// Exact version avoids a stale or subsequently moved latest tag. Bypass both
// npm's local cache and intermediary caches on every request.
const url = new URL(`https://registry.npmjs.org/${encodeURIComponent(name)}/${encodeURIComponent(version)}`)
url.searchParams.set('release-check', `${nonce}-${attempt}`)
try {
const response = await fetch(url, {
headers: { accept: 'application/json', 'cache-control': 'no-cache', pragma: 'no-cache' },
signal: AbortSignal.timeout(10000),
})
if (response.ok) {
const metadata = await response.json()
if (metadata?.name === name && metadata?.version === version) {
log(`Registry serves ${name}@${version} (attempt ${attempt}/${attempts}).`)
return true
}
last = 'registry metadata did not match the exact package and version'
} else {
last = `HTTP ${response.status}`
await response.body?.cancel()
}
} catch (error) {
last = error.name === 'TimeoutError' ? 'request timed out' : 'request or JSON response failed'
}
log(`Visibility attempt ${attempt}/${attempts}: ${last}.`)
if (attempt < attempts) await wait(10000)
}
log(`::error::npm publish succeeded for ${name}@${version}, but registry visibility remains unconfirmed after ${attempts} attempts (${last}). Do not republish; rerun only node .github/scripts/verify-release.cjs from this release commit.`)
return false
}

if (require.main === module) {
verifyRelease(require('../../package.json')).then(visible => { process.exitCode = visible ? 0 : 1 })
}

module.exports = { verifyRelease }
10 changes: 2 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,5 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

- name: Confirm the registry serves it
run: |
pkg=$(node -p "require('./package.json').version")
for i in $(seq 1 10); do
got=$(npm view fdeops version 2>/dev/null || true)
[ "$got" = "$pkg" ] && { echo "registry serves $got"; exit 0; }
sleep 6
done
echo "published, but the registry still serves ${got:-unknown}"; exit 1
timeout-minutes: 12
run: node .github/scripts/verify-release.cjs
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## 5.1.19 - 2026-09-22

- Redact private and masked ingest metadata before creating filenames, keeping protected values out of inbox listings and review provenance.
- Reject empty or invalid explicit MCP customer selections instead of falling back to the current workspace's customer.
- Install agent pointers when an existing instruction file only mentions FDEOps; preserve existing text and keep repeat installs unchanged.
- Keep new logged and debriefed risks active after earlier risks have been retired.
- Preserve other workspace bindings during concurrent customer setup, and leave another writer's registry lock intact when binding fails.
- Release owned file locks when a record write is refused, so repairing the file permits a retry.
- Verify the exact npm release with fresh, bounded retries so registry propagation does not falsely report a failed publication.

## 5.1.18 - 2026-09-22

- Preserve distinct staged notes when source and title match within the same second, including concurrent staging.
Expand Down
35 changes: 26 additions & 9 deletions bin/fde.js
Original file line number Diff line number Diff line change
Expand Up @@ -527,10 +527,11 @@ function formatFsError(err, action, target) {
}

let debriefTransactionActive = false
let activeFileLocks = 0
const ownedDebriefLocks = new Set()

function failFs(err, action, target) {
if (debriefTransactionActive || ownedDebriefLocks.size) throw new Error(formatFsError(err, action, target))
if (activeFileLocks || debriefTransactionActive || ownedDebriefLocks.size) throw new Error(formatFsError(err, action, target))
console.error(formatFsError(err, action, target))
process.exit(1)
}
Expand All @@ -546,7 +547,7 @@ function refuseSymlinkWrite(p, opts = {}) {
const msg = st.isSymbolicLink()
? `refused: ${path.basename(p)} is a symlink - write would leave the engagement tree. Replace it with a real file.`
: `refused: ${path.basename(p)} is not a regular file - remove it and re-run; every write is refused while it is there.`
if ((debriefTransactionActive || ownedDebriefLocks.size) && !opts.soft) throw new Error(msg)
if ((activeFileLocks || debriefTransactionActive || ownedDebriefLocks.size) && !opts.soft) throw new Error(msg)
if (opts.soft) return msg
console.error(msg)
process.exit(1)
Expand All @@ -563,7 +564,7 @@ function refuseSymlinkWrite(p, opts = {}) {
// appending the same .fde file otherwise interleave/corrupt under load.
function withFileLock(targetPath, fn, opts = {}) {
if (ownedDebriefLocks.has(targetPath)) return fn()
if (debriefTransactionActive || ownedDebriefLocks.size) opts = { ...opts, soft: true }
if (activeFileLocks || debriefTransactionActive || ownedDebriefLocks.size) opts = { ...opts, soft: true }
const lockPath = targetPath + '.lock'
const deadline = Date.now() + 5000
while (true) {
Expand All @@ -585,9 +586,12 @@ function withFileLock(targetPath, fn, opts = {}) {
if (opts.soft) throw e
failFs(e, 'lock', targetPath)
}
// Filesystem failures must unwind this lock instead of exiting in place.
activeFileLocks++
try {
return fn()
} finally {
activeFileLocks--
try { fs.closeSync(fd) } catch (_) {}
try { fs.unlinkSync(lockPath) } catch (_) {}
}
Expand Down Expand Up @@ -794,6 +798,13 @@ function appendLogEntry(eng, type, entry, opts = {}) {
})
// Durable CLI ledger - not rewritten by agent artifact passes.
lockedAppendFile(path.join(eng, SIGNAL_LEDGER), `${entry}\n`)
} else if (type === 'risk') {
withFileLock(p, () => {
const md = readEng(eng, LOG_FILES[type])
const retired = md.search(/^#{1,6}\s+Retired\b/im)
const end = retired < 0 ? md.length : retired
atomicWriteFile(p, `${md.slice(0, end).trimEnd()}\n\n${entry}\n${retired < 0 ? '' : '\n' + md.slice(end)}`, { soft: true })
})
} else {
lockedAppendFile(p, `\n${entry}\n`)
}
Expand Down Expand Up @@ -1406,14 +1417,18 @@ function cmdResume(args) {
// line - resolution is first-match-wins, so appending a second line would
// leave the stale binding winning and silently write to the wrong client.
const cwd = process.cwd()
const prev = readRegistry().find(r => r.workspace === cwd)
const kept = readRegistry().filter(r => r.workspace !== cwd).map(r => `${r.workspace} ${r.slug}`)
kept.push(`${cwd} ${slug}`)
let prev
let bindErr = null
// soft: an unwritable registry must not process.exit() from inside the lock -
// that skipped the finally and left a stale .registry.lock behind.
try {
withFileLock(REGISTRY, () => { atomicWriteFile(REGISTRY, kept.join('\n') + '\n', { soft: true }) }, { soft: true })
withFileLock(REGISTRY, () => {
const registry = readRegistry()
prev = registry.find(r => r.workspace === cwd)
const kept = registry.filter(r => r.workspace !== cwd).map(r => `${r.workspace} ${r.slug}`)
kept.push(`${cwd} ${slug}`)
atomicWriteFile(REGISTRY, kept.join('\n') + '\n', { soft: true })
}, { soft: true })
} catch (e) { bindErr = e }
if (bindErr || !readRegistry().some(r => r.workspace === cwd && r.slug === slug)) {
// Silently unbound is the worst outcome: the memory exists, every later
Expand All @@ -1423,7 +1438,6 @@ function cmdResume(args) {
`could not bind this workspace - ${REGISTRY} is not writable${bindErr ? ` (${bindErr.code || bindErr.message})` : ''}.\n` +
` fix the file (it must be a regular file), or work with: export FDEOPS_ENGAGEMENT=${fdeDir}\n`
)
try { fs.unlinkSync(REGISTRY + '.lock') } catch (_) {}
process.exit(1)
}
console.log(`ENGAGEMENT READY: ${fdeDir}\nbound to workspace: ${cwd}`)
Expand Down Expand Up @@ -2540,7 +2554,10 @@ function cmdIngest(args) {
if (args[i] === '--force') { force = true; continue }
rest.push(args[i])
}
source = sanitizeIngestToken(source, 'manual')
// Redact before slugification: filenames no longer retain privacy markers.
const cleanMetadata = value => masking.mask(splitPrivate(value, { sealDangling: true }).clean).replace(/[\r\n]+/g, ' ')
source = sanitizeIngestToken(cleanMetadata(source), 'manual')
title = cleanMetadata(title)
const titleSlug = sanitizeIngestToken(title || 'notes', 'notes')
if (!title) title = titleSlug

Expand Down
5 changes: 4 additions & 1 deletion bin/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,10 @@ function placePointer(destPath, content, label, appendable) {
mkdir(path.dirname(destPath))
if (fs.existsSync(destPath)) {
const existing = fs.readFileSync(destPath, 'utf8')
if (/FDEOS|fdeops/i.test(existing)) {
// Templates can change wording; a branded pointer remains installed.
const hasIdentity = existing.includes(FDE_MARKER) || /^#\s+fde(?:ops|os)\b/im.test(existing)
const hasSkillPointer = /\bskills\/fde\/SKILL\.md\b/.test(existing)
if (hasIdentity && hasSkillPointer) {
console.log(` skip ${label} (already wired)`)
return
}
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.18",
"version": "5.1.19",
"private": true,
"description": "Thin stdio MCP sink for FDEOps ingest (stage \u2192 propose \u2192 apply). Zero runtime dependencies.",
"bin": {
Expand Down
7 changes: 5 additions & 2 deletions mcp/fdeops-ingest/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,11 @@ function runFde(args, stdin, extraEnv) {
}

function engagementEnv(args) {
const p = args && typeof args.engagement === 'string' ? args.engagement.trim() : ''
return p ? { FDEOPS_ENGAGEMENT: p } : {}
if (!Object.prototype.hasOwnProperty.call(args, 'engagement')) return {}
if (typeof args.engagement !== 'string' || !args.engagement.trim()) {
throw new Error('engagement must be a non-empty string when supplied; no customer was selected')
}
return { FDEOPS_ENGAGEMENT: args.engagement.trim() }
}

function cliPayload(out) {
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.18",
"version": "5.1.19",
"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.18",
"version": "5.1.19",
"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
11 changes: 7 additions & 4 deletions test/debrief-reliability.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,19 @@ test('an ordinary mid-write error restores prior records and retry is safe', t =
fs.writeFileSync(f.notes, 'decision: ROLLBACK_ME\nrisk: WRITE_FAILS\n')
assert.equal(f.run(['debrief', '--smart', f.notes]).status, 0)
const before = fs.readFileSync(path.join(f.eng, 'decisions.md'), 'utf8')
const beforeRisk = fs.readFileSync(path.join(f.eng, 'risks.md'), 'utf8')
const shim = path.join(f.dir, 'fail-write.cjs')
fs.writeFileSync(shim, `const fs = require('fs'); const append = fs.appendFileSync; let failed = false;
fs.appendFileSync = function(file, ...args) {
if (!failed && String(file).endsWith('/risks.md')) { failed = true; throw Object.assign(new Error('simulated disk full'), { code: 'ENOSPC' }); }
return append.call(fs, file, ...args);
fs.writeFileSync(shim, `const fs = require('fs'); const rename = fs.renameSync; let failed = false;
fs.renameSync = function(source, dest, ...args) {
if (!failed && String(dest).endsWith('/risks.md')) { failed = true; throw Object.assign(new Error('simulated disk full'), { code: 'ENOSPC' }); }
return rename.call(fs, source, dest, ...args);
};`)
const result = spawnSync(process.execPath, ['--require', shim, path.resolve(__dirname, '../bin/fde.js'), 'debrief', '--apply'], {
cwd: f.dir, env: { ...process.env, HOME: path.join(f.dir, 'home'), FDEOPS_ENGAGEMENT: f.eng, FDEOPS_ENGAGEMENTS_ROOT: path.join(f.dir, 'clients') }, encoding: 'utf8',
})
assert.equal(result.status, 1); assert.match(result.stderr, /no record changes kept/)
assert.equal(fs.readFileSync(path.join(f.eng, 'risks.md'), 'utf8'), beforeRisk)
assert.deepEqual(fs.readdirSync(f.eng).filter(name => name.endsWith('.lock') || name.endsWith('.tmp')), [])
assert.equal(fs.readFileSync(path.join(f.eng, 'decisions.md'), 'utf8'), before)
assert.ok(fs.existsSync(path.join(f.eng, '.debrief-propose')))
assert.equal(f.run(['debrief', '--apply']).status, 0)
Expand Down
25 changes: 25 additions & 0 deletions test/ingest-boundaries.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,28 @@ for (const opener of ['<private>', '<!--']) {
assert.equal(fs.existsSync(f.proposal), false)
})
}

for (const metadata of [
'<private>HiddenAcquisition</private>',
'<private data-x="1">HiddenAcquisition',
'<private>outer <private>HiddenAcquisition</private> still hidden</private>',
'<!-- HiddenAcquisition -->',
'<!-- HiddenAcquisition',
'owner.hidden@example.com',
'ghp_abcdefghijklmnopqrstuvwxy',
]) {
test(`stage redacts metadata before deriving filenames: ${metadata.slice(0, 20)}`, t => {
const f = fixture(t)
const staged = f.run(['ingest', 'stage', '--title', metadata, '--source', metadata], 'decision: Public delivery note\n')
assert.equal(staged.status, 0, staged.stderr)
const [id] = fs.readdirSync(f.box).filter(name => name.endsWith('.md'))
assert.ok(id)
const listed = f.run(['ingest', 'list'])
const proposed = f.run(['ingest', 'propose', id])
assert.equal(listed.status, 0, listed.stderr)
assert.equal(proposed.status, 0, proposed.stderr)
const output = [id, staged.stdout, listed.stdout, proposed.stdout, fs.readFileSync(f.proposal, 'utf8')].join('\n')
assert.doesNotMatch(output, /HiddenAcquisition|hiddenacquisition|owner[.-]hidden|abcdefghijklmnopqrstuvwxy/)
assert.match(fs.readFileSync(f.proposal, 'utf8'), /Public delivery note/)
})
}
35 changes: 35 additions & 0 deletions test/installer-safety.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -335,3 +335,38 @@ for (const kind of ['file', 'symlink']) {
}
})
}

test('a brand mention or orphaned marker does not count as an installed adapter', t => {
const f = fixture(t)
const files = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.github/copilot-instructions.md']
const notes = '# Project\nWe are evaluating FDEOps.\n<!-- fdeops adapter - points your AI tool at @fde; safe to keep -->\n'
for (const name of files) {
const dest = path.join(f.workspace, name)
fs.mkdirSync(path.dirname(dest), { recursive: true })
fs.writeFileSync(dest, notes)
}
const result = f.run('adapters', f.workspace)
assert.equal(result.status, 0, result.stdout + result.stderr)
const first = files.map(name => fs.readFileSync(path.join(f.workspace, name), 'utf8'))
for (const text of first) {
assert.ok(text.startsWith(notes.trimEnd()))
assert.match(text, /skills\/fde\/SKILL\.md/)
}
assert.equal(f.run('adapters', f.workspace).status, 0)
assert.deepEqual(files.map(name => fs.readFileSync(path.join(f.workspace, name), 'utf8')), first)
})

for (const identity of ['# fdeops - existing workspace adapter', '<!-- fdeops adapter - points your AI tool at @fde; safe to keep -->']) {
test(`adapter wording changes preserve an existing branded skill pointer: ${identity.slice(0, 12)}`, t => {
const f = fixture(t)
const names = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.github/copilot-instructions.md']
const existing = `# Personal instructions\nRetain my guidance.\n\n${identity}\nUse @fde and read ~/.claude/skills/fde/SKILL.md for client work.\nOlder or customised wording stays mine.\n`
for (const name of names) {
const dest = path.join(f.workspace, name)
fs.mkdirSync(path.dirname(dest), { recursive: true })
fs.writeFileSync(dest, existing)
}
assert.equal(f.run('adapters', f.workspace).status, 0)
for (const name of names) assert.equal(fs.readFileSync(path.join(f.workspace, name), 'utf8'), existing)
})
}
48 changes: 48 additions & 0 deletions test/mcp-selection.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { spawnSync } = require('node:child_process')
const cli = path.resolve(__dirname, '../bin/fde.js')
const server = path.resolve(__dirname, '../mcp/fdeops-ingest/server.js')

test('MCP refuses invalid explicit customer selectors before any tool reads or writes', t => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fde-mcp-selection-'))
t.after(() => fs.rmSync(dir, { recursive: true, force: true }))
const env = { ...process.env, HOME: dir, USERPROFILE: dir, FDEOPS_ENGAGEMENTS_ROOT: path.join(dir, 'clients'), FDEOPS_ENGAGEMENT: '', FDEOS_ENGAGEMENT: '', FDEOPS_FDE: '' }
const run = args => spawnSync(process.execPath, [cli, ...args], { cwd: dir, env, encoding: 'utf8' })
assert.equal(run(['resume', '--init', 'atlas']).status, 0)
const requests = []
for (const name of ['ingest_stage', 'ingest_list', 'ingest_propose', 'ingest_apply']) {
for (const engagement of ['', ' ', null, 42, false, {}, []]) {
requests.push({ jsonrpc: '2.0', id: requests.length + 1, method: 'tools/call', params: { name, arguments: { engagement, content: 'decision: Wrong customer', id: 'missing' } } })
}
}
const result = spawnSync(process.execPath, [server], { cwd: dir, env, encoding: 'utf8', input: requests.map(r => JSON.stringify(r)).join('\n') + '\n', timeout: 10000 })
assert.equal(result.status, 0, result.stderr)
const responses = result.stdout.trim().split('\n').map(line => JSON.parse(line))
assert.equal(responses.length, requests.length)
for (const response of responses) {
assert.equal(response.result.isError, true)
assert.match(response.result.content[0].text, /engagement must be a non-empty string/)
}
assert.equal(fs.existsSync(path.join(dir, 'clients/atlas/.inbox')), false)
assert.equal(fs.existsSync(path.join(dir, 'clients/atlas/.fde/.debrief-propose')), false)
// Omission uses the binding; an explicit valid selection overrides it.
assert.equal(run(['resume', '--init', 'beta']).status, 0)
const call = arguments_ => {
const request = { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'ingest_stage', arguments: arguments_ } }
const result = spawnSync(process.execPath, [server], { cwd: dir, env, encoding: 'utf8', input: JSON.stringify(request) + '\n', timeout: 10000 })
assert.equal(result.status, 0, result.stderr)
assert.equal(JSON.parse(result.stdout).result.isError, undefined, result.stdout)
}
call({ content: 'decision: Bound beta', title: 'beta-note' })
call({ engagement: path.join(dir, 'clients/atlas/.fde'), content: 'decision: Selected atlas', title: 'atlas-note' })
for (const client of ['atlas', 'beta']) {
const inbox = path.join(dir, 'clients', client, '.inbox')
const files = fs.readdirSync(inbox).filter(name => name.endsWith('.md'))
assert.equal(files.length, 1)
assert.match(files[0], new RegExp(client + '-note'))
}
})
Loading
Loading