From 9966fef84a49afcd02fe68405439ddfbb932bdc8 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:50:39 -0400 Subject: [PATCH] fix(agents): preserve user-owned definitions during Pi conversion --- scripts/agent-convert.js | 28 ++++++++++++++++++++++++---- tests/pi/agent-convert.test.js | 18 ++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/scripts/agent-convert.js b/scripts/agent-convert.js index 5274917f7..c8b60623b 100644 --- a/scripts/agent-convert.js +++ b/scripts/agent-convert.js @@ -135,13 +135,33 @@ function main() { if (args.out) { const dir = path.resolve(args.out); - if (!args.dryRun) fs.mkdirSync(dir, { recursive: true }); + if (!args.dryRun) { + if (fs.existsSync(dir) && fs.lstatSync(dir).isSymbolicLink()) { + throw new Error('output directory must not be a symlink'); + } + // Check the whole batch before writing. Existing operator-owned agents + // cannot be replaced simply because they share an ECC dispatch name. + for (const r of results) { + const target = path.join(dir, `${r.id}.md`); + let stat; + try { stat = fs.lstatSync(target); } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + if (stat && (!stat.isFile() || stat.isSymbolicLink() + || fs.readFileSync(target, 'utf8') !== r.markdown)) { + throw new Error(`refusing to overwrite existing agent: ${target}`); + } + } + fs.mkdirSync(dir, { recursive: true }); + } for (const r of results) { const target = path.join(dir, `${r.id}.md`); if (args.dryRun) { progress(`[dry-run] would write ${target}`); - } else { - fs.writeFileSync(target, r.markdown); + } else if (!fs.existsSync(target)) { + // Exclusive creation prevents a file/symlink appearing after preflight + // from being overwritten. Identical existing output stays untouched. + fs.writeFileSync(target, r.markdown, { flag: 'wx' }); progress(`wrote ${target}`); } } @@ -172,4 +192,4 @@ function main() { } } -main(); +try { main(); } catch (error) { console.error(`error: ${error.message}`); process.exitCode = 1; } diff --git a/tests/pi/agent-convert.test.js b/tests/pi/agent-convert.test.js index dadc2783f..9bdd68ed8 100644 --- a/tests/pi/agent-convert.test.js +++ b/tests/pi/agent-convert.test.js @@ -65,6 +65,24 @@ function main() { assert.match(planner, /prompt_mode: replace/); }], + ["--out preserves user-owned agents before writing any output", () => { + const dir = tmpdir(); + const target = path.join(dir, "planner.md"); + fs.writeFileSync(target, "USER_OWNED_AGENT\n"); + assert.throws(() => runCli(["--out", dir]), /refusing to overwrite existing agent/); + assert.strictEqual(fs.readFileSync(target, "utf8"), "USER_OWNED_AGENT\n"); + assert.deepStrictEqual(fs.readdirSync(dir), ["planner.md"]); + }], + + ["--out repeats identical output without changing bytes", () => { + const dir = tmpdir(); + runCli(["--out", dir]); + const target = path.join(dir, "planner.md"); + const before = fs.readFileSync(target, "utf8"); + runCli(["--out", dir]); + assert.strictEqual(fs.readFileSync(target, "utf8"), before); + }], + ["--out --dry-run writes nothing", () => { const dir = tmpdir() const out = runCli(["--from", "claude", "--to", "pi", "--out", dir, "--dry-run"])