diff --git a/overlays/skills/impeccable/scripts/impeccable.cmd b/overlays/skills/impeccable/scripts/impeccable.cmd index 0be0a0d..ec5768f 100644 --- a/overlays/skills/impeccable/scripts/impeccable.cmd +++ b/overlays/skills/impeccable/scripts/impeccable.cmd @@ -1,3 +1,6 @@ @echo off -node "%~dp0..\..\..\src\impeccable-launcher.mjs" %* +setlocal EnableExtensions +rem Do not set PLUGIN_ROOT here; that env var selects the Codex host in resolveHost. +for %%I in ("%~dp0..\..\..") do set "DESIGN_LAUNCHER_ROOT=%%~fI" +node "%DESIGN_LAUNCHER_ROOT%\src\impeccable-launcher.mjs" %* exit /b %errorlevel% diff --git a/scripts/build-plugin-targets.mjs b/scripts/build-plugin-targets.mjs index 1e51c40..dcc7f57 100644 --- a/scripts/build-plugin-targets.mjs +++ b/scripts/build-plugin-targets.mjs @@ -211,8 +211,10 @@ function narrowModules(destination, host) { } function replaceRequired(source, search, replacement, label) { - if (source.split(search).length !== 2) throw new Error(`portable skill projection anchor must occur exactly once: ${label}`); - return source.replace(search, replacement); + // Projection anchors are authored with LF; normalize Windows CRLF checkouts first. + const normalized = source.replace(/\r\n/g, "\n"); + if (normalized.split(search).length !== 2) throw new Error(`portable skill projection anchor must occur exactly once: ${label}`); + return normalized.replace(search, replacement); } const portableImpeccableTransforms = [ @@ -343,7 +345,7 @@ function adaptAgentPluginSkills(destination) { for (const skillName of ["design", "impeccable"]) rmSync(join(skillsRoot, skillName, "agents"), { recursive: true, force: true }); const designPath = join(skillsRoot, "design", "SKILL.md"); - let design = readFileSync(designPath, "utf8"); + let design = readProjectedText(designPath); design = replaceRequired( design, "description: Use when the user explicitly invokes /design in Cursor or $design in Codex for project setup, status, diagnostics, stakeholder questionnaires, explicit local detector scans, change-scoped interface review, or curated website and web-app design work. Routes general design work to the bundled Impeccable skill and narrower work to registered curated modules.", @@ -379,7 +381,8 @@ function adaptAgentPluginSkills(destination) { writeFileSync(designPath, design); const impeccablePath = join(skillsRoot, "impeccable", "SKILL.md"); - let impeccable = readFileSync(impeccablePath, "utf8"); + // SKILL.md anchors below are LF-authored; normalize CRLF from Windows checkouts. + let impeccable = readProjectedText(impeccablePath); const start = "## Geldmacher Design host contract\n\n"; const end = "\n\nThis skill gives you the tools and permission to create design"; const startIndex = impeccable.indexOf(start); @@ -430,9 +433,8 @@ function adaptAgentPluginSkills(destination) { "portable live panel operation notation")); const hostPath = join(destination, "src", "host.mjs"); - const host = readFileSync(hostPath, "utf8"); writeFileSync(hostPath, replaceRequired( - host, + readProjectedText(hostPath), " return skill;\n}", " return `the loaded ${skill} skill's`;\n}", "portable lifecycle operation notation", @@ -595,6 +597,11 @@ function assertSourceRoot(sourceRoot) { return resolved; } +function readProjectedText(file) { + // Windows checkouts may materialize CRLF; projection anchors are authored as LF. + return readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); +} + function projectSelfContainedReferences(destination) { const transformations = [ { @@ -615,7 +622,7 @@ function projectSelfContainedReferences(destination) { ]; for (const item of transformations) { const file = join(destination, 'skills/impeccable', item.path); - writeFileSync(file, replaceRequired(readFileSync(file, 'utf8'), item.search, item.replacement, `self-contained ${item.path}`)); + writeFileSync(file, replaceRequired(readProjectedText(file), item.search, item.replacement, `self-contained ${item.path}`)); } } diff --git a/scripts/lib/impeccable-candidate-runtime.mjs b/scripts/lib/impeccable-candidate-runtime.mjs index 374ba4d..0c49ea2 100644 --- a/scripts/lib/impeccable-candidate-runtime.mjs +++ b/scripts/lib/impeccable-candidate-runtime.mjs @@ -36,13 +36,16 @@ export function verifyCandidateRuntime(root, projection, destinations) { const nativeScript = !file.endsWith('.mjs'); const executable = nativeScript ? (process.platform === 'win32' ? 'cmd.exe' : file) : process.execPath; const argv = nativeScript ? (process.platform === 'win32' ? ['/d', '/s', '/c', `"${[file, ...args].map(value => `"${value}"`).join(' ')}"`] : args) : [file, ...args]; - const result = spawnSync(executable, argv, { cwd: project, env, input, encoding: 'utf8', timeout: 10000, shell: false }); + const result = spawnSync(executable, argv, { + cwd: project, env, input, encoding: 'utf8', timeout: 10000, shell: false, + windowsVerbatimArguments: process.platform === 'win32' && nativeScript, + }); if (result.error || result.status !== 0) throw new Error(`Candidate ${host} entrypoint failed (${args.join(' ')}): ${result.error?.message || result.stderr || result.status}`); return result.stdout; }; const launcher = join(target, 'skills/impeccable/scripts', process.platform === 'win32' ? 'impeccable.cmd' : 'impeccable'); if (run(launcher, ['engine-probe']).trim() !== `impeccable-engine ${runtime.engineVersion}`) throw new Error('Engine handshake differs from the skill pin.'); - const context = run(launcher, ['context']); + const context = run(launcher, ['context']).replace(/\r\n/g, '\n'); if (!context.includes('RESOLVED_CONTEXT:') || !context.includes(`PLUGIN_HOOK_STATE: ${host}:`)) throw new Error('Candidate plugin context contract missing.'); const doctor = JSON.parse(run(launcher, ['doctor'])); if (!Array.isArray(doctor.findings) || doctor.pluginHook?.host !== host) throw new Error('Candidate plugin doctor contract missing.'); diff --git a/skills/impeccable/scripts/impeccable.cmd b/skills/impeccable/scripts/impeccable.cmd index 0be0a0d..ec5768f 100644 --- a/skills/impeccable/scripts/impeccable.cmd +++ b/skills/impeccable/scripts/impeccable.cmd @@ -1,3 +1,6 @@ @echo off -node "%~dp0..\..\..\src\impeccable-launcher.mjs" %* +setlocal EnableExtensions +rem Do not set PLUGIN_ROOT here; that env var selects the Codex host in resolveHost. +for %%I in ("%~dp0..\..\..") do set "DESIGN_LAUNCHER_ROOT=%%~fI" +node "%DESIGN_LAUNCHER_ROOT%\src\impeccable-launcher.mjs" %* exit /b %errorlevel% diff --git a/src/impeccable-engine.mjs b/src/impeccable-engine.mjs index c2aceeb..02d9af0 100644 --- a/src/impeccable-engine.mjs +++ b/src/impeccable-engine.mjs @@ -57,6 +57,10 @@ export function resolveEngine(root, { platform = process.platform, arch = proces if (bytes.length !== asset.size || createHash('sha256').update(bytes).digest('hex') !== asset.sha256) { throw new Error(`Bundled engine hash or size mismatch: ${key}`); } - if (platform !== 'win32' && !(fs.statSync(file).mode & 0o111)) throw new Error(`Bundled engine is not executable: ${key}`); + // Unix execute bits are authoritative on POSIX hosts only. Windows checkouts often + // drop +x on foreign engine binaries, and win32 itself does not use mode 0111. + if (process.platform !== 'win32' && key !== 'windows-x64' && !(fs.statSync(file).mode & 0o111)) { + throw new Error(`Bundled engine is not executable: ${key}`); + } return { file, relativeScript: relative, engineVersion: pin.engine.version, platform: key, pluginRoot: fs.realpathSync(root) }; } diff --git a/src/impeccable-plugin-commands.mjs b/src/impeccable-plugin-commands.mjs index 55484cc..310fb48 100644 --- a/src/impeccable-plugin-commands.mjs +++ b/src/impeccable-plugin-commands.mjs @@ -140,6 +140,8 @@ function protectProjectContext(stdout, cwd) { } export function projectEngineOutput({ command, stdout, host, cwd }) { + // Windows engine/console captures may use CRLF; projection anchors are LF-authored. + stdout = stdout.replace(/\r\n/g, '\n'); if (command === 'context') { if (stdout.startsWith('TARGET_SELECTION_REQUIRED:\n')) { const parts = stdout.trim().split('\n\n'); diff --git a/tests/impeccable-engine.test.mjs b/tests/impeccable-engine.test.mjs index 7eab50d..9e249ae 100644 --- a/tests/impeccable-engine.test.mjs +++ b/tests/impeccable-engine.test.mjs @@ -156,10 +156,16 @@ test('thin launcher and generated commands work from a plugin path containing sp } const launcher = path.join(copy, 'skills/impeccable/scripts', process.platform === 'win32' ? 'impeccable.cmd' : 'impeccable'); const env = impeccableRuntimeEnvironment('codex', copy); + // On Windows, pass a /s /c line with every token quoted and disable Node's + // extra argv escaping (windowsVerbatimArguments). Without that, cmd.exe + // rewrites paths with spaces into a UNC-looking fragment and fails with + // "The network path was not found." const result = process.platform === 'win32' - ? spawnSync('cmd.exe', ['/d', '/s', '/c', `""${launcher}" engine-probe"`], { cwd, env, encoding: 'utf8', shell: false }) + ? spawnSync('cmd.exe', ['/d', '/s', '/c', `"${[launcher, 'engine-probe'].map((value) => `"${value}"`).join(' ')}"`], { + cwd, env, encoding: 'utf8', shell: false, windowsVerbatimArguments: true, + }) : spawnSync(launcher, ['engine-probe'], { cwd, env, encoding: 'utf8', shell: false }); - assert.equal(result.status, 0, result.stderr); + assert.equal(result.status, 0, result.stderr || result.stdout); assert.equal(result.stdout.trim(), `impeccable-engine ${pin.engine.version}`); const context = runBundledImpeccable({ pluginRoot: copy, cwd, host: 'codex', command: 'context' }); assert.equal(context.status, 0, context.error); diff --git a/tests/portable-runtime.test.mjs b/tests/portable-runtime.test.mjs index de57a8a..53a11c9 100644 --- a/tests/portable-runtime.test.mjs +++ b/tests/portable-runtime.test.mjs @@ -206,7 +206,9 @@ test("built targets run self-contained lifecycle and hook simulations", (t) => { for (const [file, body] of Object.entries(projectTexts)) writeFileSync(join(project, file), body); const launcher = join(portable, 'skills', 'impeccable', 'scripts', process.platform === 'win32' ? 'impeccable.cmd' : 'impeccable'); const preserved = process.platform === 'win32' - ? spawnSync('cmd.exe', ['/d', '/s', '/c', `""${launcher}" context"`], { cwd: project, env: portableEnv, encoding: 'utf8', shell: false }) + ? spawnSync('cmd.exe', ['/d', '/s', '/c', `"${[launcher, 'context'].map((value) => `"${value}"`).join(' ')}"`], { + cwd: project, env: portableEnv, encoding: 'utf8', shell: false, windowsVerbatimArguments: true, + }) : spawnSync(launcher, ['context'], { cwd: project, env: portableEnv, encoding: 'utf8', shell: false }); assert.equal(preserved.status, 0, preserved.stderr); for (const [file, body] of Object.entries(projectTexts)) { @@ -234,7 +236,9 @@ test("built targets run self-contained lifecycle and hook simulations", (t) => { const child = join(monorepo, 'apps', app); for (const [cwd, args] of [[child, ['context']], [monorepo, ['context', '--target', `apps/${app}`]]]) { const result = process.platform === 'win32' - ? spawnSync('cmd.exe', ['/d', '/s', '/c', `"${[launcher, ...args].map(value => `"${value}"`).join(' ')}"`], { cwd, env, encoding: 'utf8', shell: false }) + ? spawnSync('cmd.exe', ['/d', '/s', '/c', `"${[launcher, ...args].map(value => `"${value}"`).join(' ')}"`], { + cwd, env, encoding: 'utf8', shell: false, windowsVerbatimArguments: true, + }) : spawnSync(launcher, args, { cwd, env, encoding: 'utf8', shell: false }); assert.equal(result.status, 0, `${host} ${cwd}: ${result.stderr}`); for (const [file, body] of Object.entries(app === 'one' ? projectTexts : inherited)) { diff --git a/upstream/impeccable.lock.json b/upstream/impeccable.lock.json index 14a43b3..0550649 100644 --- a/upstream/impeccable.lock.json +++ b/upstream/impeccable.lock.json @@ -35,7 +35,7 @@ "evidence-based-usability-guidance" ], "patch": "upstream/patches/impeccable-plugin.patch", - "patchSha256": "85f4fe5700c18fcb0e1b0343e790d9c646f4d7b4abddbc3588d29f5aa7767f4a", + "patchSha256": "9cd4cad609aaa125f9a4a621d5489266d8ff64472b321a7e6aff028e93426bf6", "files": [ { "source": ".cursor/agents/impeccable-asset-producer.md", @@ -439,7 +439,7 @@ "source": ".cursor/skills/impeccable/scripts/impeccable.cmd", "destination": "skills/impeccable/scripts/impeccable.cmd", "sourceSha256": "759bab1d8adc071fdde2efcf6e2157aa4026f3c2b4f3c342a8070bf328011616", - "vendoredSha256": "0eed70b974fe25a84de65b7a96409ce562cbf58d84ad7d82aeae8eee5601cd3c", + "vendoredSha256": "073975e9a2bd302bad9a06310c3a71c8353ac128a8dc11969c0a289ae5ccd2c8", "transformations": [ "native-engine-launcher" ] diff --git a/upstream/patches/impeccable-plugin.patch b/upstream/patches/impeccable-plugin.patch index e0e6ade..bb564ed 100644 --- a/upstream/patches/impeccable-plugin.patch +++ b/upstream/patches/impeccable-plugin.patch @@ -1012,7 +1012,7 @@ diff --git a/skills/impeccable/scripts/impeccable.cmd b/skills/impeccable/script index 7ebe419..0be0a0d 100644 --- a/skills/impeccable/scripts/impeccable.cmd +++ b/skills/impeccable/scripts/impeccable.cmd -@@ -1,214 +1,3 @@ +@@ -1,214 +1,6 @@ @echo off -setlocal -rem Impeccable launcher (Windows). Runs bin\windows-\impeccable.exe next @@ -1227,7 +1227,10 @@ index 7ebe419..0be0a0d 100644 -echo impeccable: no engine binary found (looked in %bin%, %cached%, PATH). 1>&2 -echo Download impeccable-windows-%arch%.exe from https://github.com/pbakaus/impeccable/releases (tag engine-v%version%) and save it as %cached%, or set IMPECCABLE_BIN to a preinstalled engine binary. Docs: https://impeccable.style 1>&2 -exit /b 127 -+node "%~dp0..\..\..\src\impeccable-launcher.mjs" %* ++setlocal EnableExtensions ++rem Do not set PLUGIN_ROOT here; that env var selects the Codex host in resolveHost. ++for %%I in ("%~dp0..\..\..") do set "DESIGN_LAUNCHER_ROOT=%%~fI" ++node "%DESIGN_LAUNCHER_ROOT%\src\impeccable-launcher.mjs" %* +exit /b %errorlevel% diff --git a/agents/impeccable-asset-producer.md b/agents/impeccable-asset-producer.md index 005c61c..d1080f5 100644