From e01838ae4c01081ea30ddc392024014ac1e32d5e Mon Sep 17 00:00:00 2001 From: Subash Date: Tue, 22 Sep 2026 06:57:37 +0530 Subject: [PATCH 1/3] fix: verify exact npm release with fresh bounded retries --- .github/scripts/verify-release.cjs | 50 +++++++++++++++++++++ .github/workflows/release.yml | 10 +---- test/release-registry.test.js | 72 ++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 .github/scripts/verify-release.cjs create mode 100644 test/release-registry.test.js diff --git a/.github/scripts/verify-release.cjs b/.github/scripts/verify-release.cjs new file mode 100644 index 0000000..f01027c --- /dev/null +++ b/.github/scripts/verify-release.cjs @@ -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 } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index baa05d6..7bb899c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/test/release-registry.test.js b/test/release-registry.test.js new file mode 100644 index 0000000..9c62517 --- /dev/null +++ b/test/release-registry.test.js @@ -0,0 +1,72 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const { verifyRelease } = require('../.github/scripts/verify-release.cjs') + +const pkg = { name: 'fdeops', version: '5.1.18' } +const visible = () => ({ ok: true, json: async () => pkg }) + +function harness(respond) { + const requests = [], waits = [], logs = [] + return { + requests, waits, logs, + run: () => verifyRelease(pkg, { + fetch: async (url, options) => { + requests.push({ url, options }) + return respond(requests.length) + }, + wait: async ms => { waits.push(ms) }, + log: line => { logs.push(line) }, + }), + } +} + +test('exact version succeeds without querying latest or sleeping', async () => { + const h = harness(visible) + assert.equal(await h.run(), true) + assert.equal(h.requests.length, 1) + assert.equal(h.requests[0].url.pathname, '/fdeops/5.1.18') + assert.deepEqual(h.waits, []) + assert.match(h.logs.at(-1), /Registry serves fdeops@5\.1\.18/) +}) + +test('propagation beyond the old ten attempts uses fresh requests and eventually succeeds', async () => { + let cancelled = 0 + const h = harness(n => n < 12 + ? { ok: false, status: 404, body: { cancel: async () => { cancelled++ } } } + : visible()) + assert.equal(await h.run(), true) + assert.equal(cancelled, 11) + assert.deepEqual(h.waits, Array(11).fill(10000)) + assert.equal(new Set(h.requests.map(r => r.url.href)).size, 12) + for (const { url, options } of h.requests) { + assert.equal(url.pathname, '/fdeops/5.1.18') + assert.equal(options.headers['cache-control'], 'no-cache') + assert.ok(options.signal instanceof AbortSignal) + } +}) + +test('network, timeout, server and malformed responses retry without false confirmation', async () => { + const h = harness(n => { + if (n === 1) throw new Error('network unavailable') + if (n === 2) throw Object.assign(new Error('timeout'), { name: 'TimeoutError' }) + if (n === 3) return { ok: false, status: 503 } + if (n === 4) return { ok: true, json: async () => { throw new SyntaxError('bad JSON') } } + if (n === 5) return { ok: true, json: async () => null } + if (n === 6) return { ok: true, json: async () => ({ ...pkg, version: '5.1.17' }) } + if (n === 7) return { ok: true, json: async () => ({ ...pkg, name: 'other' }) } + return visible() + }) + assert.equal(await h.run(), true) + assert.equal(h.requests.length, 8) + assert.equal(h.waits.length, 7) + assert.ok(h.logs.some(line => line.includes('request timed out'))) +}) + +test('persistent failure is bounded and distinguishes publication from visibility', async () => { + const h = harness(() => ({ ok: false, status: 404 })) + assert.equal(await h.run(), false) + assert.equal(h.requests.length, 30) + assert.deepEqual(h.waits, Array(29).fill(10000)) + assert.match(h.logs.at(-1), /npm publish succeeded.*visibility remains unconfirmed/) + assert.match(h.logs.at(-1), /Do not republish; rerun only node/) +}) From 21de2e8e1db2c3ebf2be437cc23ba4723dab191b Mon Sep 17 00:00:00 2001 From: Subash Date: Tue, 22 Sep 2026 07:15:42 +0530 Subject: [PATCH 2/3] fix: protect customer selection and record continuity (5.1.19) --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 10 ++++ bin/fde.js | 35 ++++++++++---- bin/install.js | 2 +- mcp/fdeops-ingest/package.json | 2 +- mcp/fdeops-ingest/server.js | 7 ++- package.json | 2 +- plugin.json | 2 +- test/debrief-reliability.test.js | 11 +++-- test/ingest-boundaries.test.js | 25 ++++++++++ test/installer-safety.test.js | 20 ++++++++ test/mcp-selection.test.js | 48 ++++++++++++++++++ test/record-concurrency.test.js | 83 ++++++++++++++++++++++++++++++++ 13 files changed, 229 insertions(+), 20 deletions(-) create mode 100644 test/mcp-selection.test.js diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index a9d8e36..31d79e2 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.18", + "version": "5.1.19", "category": "productivity", "tags": [ "community-managed" diff --git a/CHANGELOG.md b/CHANGELOG.md index f1a0f52..7dd1b39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/bin/fde.js b/bin/fde.js index ad73db8..7110355 100755 --- a/bin/fde.js +++ b/bin/fde.js @@ -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) } @@ -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) @@ -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) { @@ -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 (_) {} } @@ -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`) } @@ -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 @@ -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}`) @@ -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 diff --git a/bin/install.js b/bin/install.js index 3494375..617005d 100755 --- a/bin/install.js +++ b/bin/install.js @@ -294,7 +294,7 @@ 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)) { + if (existing.includes(content.trim())) { console.log(` skip ${label} (already wired)`) return } diff --git a/mcp/fdeops-ingest/package.json b/mcp/fdeops-ingest/package.json index fa378c8..df29e50 100644 --- a/mcp/fdeops-ingest/package.json +++ b/mcp/fdeops-ingest/package.json @@ -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": { diff --git a/mcp/fdeops-ingest/server.js b/mcp/fdeops-ingest/server.js index cc56db5..6a85f87 100755 --- a/mcp/fdeops-ingest/server.js +++ b/mcp/fdeops-ingest/server.js @@ -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) { diff --git a/package.json b/package.json index e8a9489..5dc2ef8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/plugin.json b/plugin.json index 2e9e477..f4e4e73 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.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", diff --git a/test/debrief-reliability.test.js b/test/debrief-reliability.test.js index 3e38b0f..4c59cb0 100644 --- a/test/debrief-reliability.test.js +++ b/test/debrief-reliability.test.js @@ -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) diff --git a/test/ingest-boundaries.test.js b/test/ingest-boundaries.test.js index 3b9fb68..b3425e6 100644 --- a/test/ingest-boundaries.test.js +++ b/test/ingest-boundaries.test.js @@ -79,3 +79,28 @@ for (const opener of ['', '', + '\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) +}) diff --git a/test/mcp-selection.test.js b/test/mcp-selection.test.js new file mode 100644 index 0000000..3368300 --- /dev/null +++ b/test/mcp-selection.test.js @@ -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')) + } +}) diff --git a/test/record-concurrency.test.js b/test/record-concurrency.test.js index 4e88efb..b0e40de 100644 --- a/test/record-concurrency.test.js +++ b/test/record-concurrency.test.js @@ -195,3 +195,86 @@ test('unsafe archive write unwinds both locks and preserves linked data', t => { assert.equal(fs.readFileSync(context, 'utf8'), original) assert.equal(fs.readFileSync(outside, 'utf8'), 'KEEP_EXTERNAL') }) + +test('client initialization preserves another workspace binding completed before its lock', async t => { + const f = fixture(t), other = path.join(f.dir, 'other-workspace') + fs.mkdirSync(other) + const registry = path.join(f.env.FDEOPS_ENGAGEMENTS_ROOT, '.registry') + await interleave(f, ['resume', '--init', 'alpha'], registry, () => { + const result = spawnSync(process.execPath, [cli, 'resume', '--init', 'beta'], { cwd: other, env: f.env, encoding: 'utf8' }) + assert.equal(result.status, 0, result.stderr) + }) + const text = fs.readFileSync(registry, 'utf8') + assert.ok(text.includes(`${f.workspace} alpha`)) + assert.ok(text.includes(`${other} beta`)) + assert.ok(!text.includes(`${f.workspace} acme`)) + for (const [cwd, client] of [[f.workspace, 'alpha'], [other, 'beta']]) { + const result = spawnSync(process.execPath, [cli, 'log', 'decision', `Only ${client}`], { cwd, env: f.env, encoding: 'utf8' }) + assert.equal(result.status, 0, result.stderr) + assert.match(fs.readFileSync(path.join(f.env.FDEOPS_ENGAGEMENTS_ROOT, client, '.fde/decisions.md'), 'utf8'), new RegExp(`Only ${client}`)) + } +}) + +test('a refused bind leaves another writer’s registry lock intact', t => { + const f = fixture(t), lock = path.join(f.env.FDEOPS_ENGAGEMENTS_ROOT, '.registry.lock') + fs.writeFileSync(lock, 'other writer') + const result = f.run(['resume', '--init', 'alpha']) + assert.equal(result.status, 1) + assert.match(result.stderr, /could not bind/) + assert.equal(fs.readFileSync(lock, 'utf8'), 'other writer') +}) + +test('new logged and debriefed risks stay active after an earlier risk is retired', t => { + const f = fixture(t), risks = path.join(f.eng, 'risks.md') + assert.equal(f.run(['log', 'risk', 'Earlier risk']).status, 0) + assert.equal(f.run(['log', 'risk', '--retire', 'Earlier risk']).status, 0) + assert.equal(f.run(['log', 'risk', 'CRITICAL New credential risk']).status, 0) + const notes = path.join(f.dir, 'meeting.md') + fs.writeFileSync(notes, 'risk: CRITICAL New approval risk\n') + assert.equal(f.run(['debrief', notes]).status, 0) + const text = fs.readFileSync(risks, 'utf8'), retired = text.indexOf('## Retired') + for (const risk of ['New credential risk', 'New approval risk']) { + assert.ok(text.indexOf(risk) < retired, text) + assert.match(f.run(['triage']).stdout, new RegExp(risk)) + assert.equal(f.run(['log', 'risk', '--retire', risk]).status, 0) + } + const final = fs.readFileSync(risks, 'utf8') + assert.match(final.slice(final.indexOf('## Retired')), /Earlier risk/) +}) + +for (const kind of ['symlink', 'directory']) { + test(`a refused ${kind} risk write releases its lock and permits recovery`, t => { + const f = fixture(t), risks = path.join(f.eng, 'risks.md'), saved = fs.readFileSync(risks, 'utf8') + const outside = path.join(f.dir, 'outside.md') + fs.writeFileSync(outside, 'other record') + fs.unlinkSync(risks) + if (kind === 'symlink') fs.symlinkSync(outside, risks) + else fs.mkdirSync(risks) + assert.equal(f.run(['log', 'risk', 'must not write']).status, 1) + assert.equal(fs.existsSync(risks + '.lock'), false) + assert.equal(fs.readFileSync(outside, 'utf8'), 'other record') + fs.rmSync(risks, { recursive: true }) + fs.writeFileSync(risks, saved) + assert.equal(f.run(['log', 'risk', 'restored risk']).status, 0) + assert.match(fs.readFileSync(risks, 'utf8'), /restored risk/) + }) +} + +for (const [file, args] of [ + ['context.md', ['log', 'phase', 'plan']], + ['stakeholders.md', ['log', 'contact', 'Sponsor approval', '--signal', 'green']], + ['.last-write', ['log', 'risk', 'Risk with undo metadata']], +]) { + test(`refused ${file} write unwinds its lock before returning an error`, t => { + const f = fixture(t), target = path.join(f.eng, file) + const saved = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : '' + fs.rmSync(target, { force: true }); fs.mkdirSync(target) + const result = f.run(args) + assert.equal(result.status, 1) + assert.match(result.stderr, /not a regular file/) + assert.equal(fs.existsSync(target + '.lock'), false) + fs.rmdirSync(target); fs.writeFileSync(target, saved) + assert.equal(f.run(args).status, 0) + assert.equal(fs.existsSync(target + '.lock'), false) + }) +} From d5ea7bf5c7a9ae6399c565c606e3c050a69258a4 Mon Sep 17 00:00:00 2001 From: Subash Date: Tue, 22 Sep 2026 07:21:46 +0530 Subject: [PATCH 3/3] fix: preserve installed adapters across wording changes --- bin/install.js | 5 ++++- test/installer-safety.test.js | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/bin/install.js b/bin/install.js index 617005d..0684259 100755 --- a/bin/install.js +++ b/bin/install.js @@ -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 (existing.includes(content.trim())) { + // 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 } diff --git a/test/installer-safety.test.js b/test/installer-safety.test.js index 4e975bb..c0c3041 100644 --- a/test/installer-safety.test.js +++ b/test/installer-safety.test.js @@ -355,3 +355,18 @@ test('a brand mention or orphaned marker does not count as an installed adapter' 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', '']) { + 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) + }) +}