Compiler Safety & Output Conflict Detection¶
Date: 2026-03-22 Status: Draft Scope: packages/cli, packages/core, packages/compiler
Problem Statement¶
During the migration of the "Everything Claude Code" (ECC) project (28 agents, 116 skills, 59 commands) to PromptScript, four compiler safety issues were discovered that block adoption in large, multi-target projects:
- Silent overwrites — The default behavior allows overwriting existing user files without explicit opt-in. Three independent review bots flagged this as P1 risk during the ECC migration PR.
- Conflicting output paths — Multiple targets (
factory,codex,amp) all map toAGENTS.mdviaDEFAULT_OUTPUT_PATHS. When multiple such targets are enabled simultaneously, the last write wins silently with no warning. - Unrecoverable file/directory conflicts — The Cline formatter writes to
.clinerules(a file), but also attempts to create.clinerules/skills/(a directory). If.clinerulesalready exists as a plain file, Node.js throws a rawENOTDIRerror with no actionable guidance. - False diffs from generation markers —
prs diffshows timestamp-only changes as real diffs because it does not strip PromptScript generation markers before comparing. Thecompilecommand already has astripMarkers()function that handles this correctly, but it is not shared.
Goals¶
- Make the compiler safe by default for large projects with many enabled targets
- Surface output path collisions before compilation writes any files
- Provide actionable, user-friendly error messages for all filesystem edge cases
- Eliminate false positives in
prs diffoutput - Maintain backward compatibility with
--forceas an explicit escape hatch
Non-Goals¶
- Automatic conflict resolution (renaming files, merging outputs)
- Per-target output directory isolation (would require config schema changes)
- Changes to the formatter output logic itself
- Supporting multiple targets writing different content to the same path (this is inherently a conflict)
Design¶
1. Safer default overwrite behavior¶
Current behavior: output.overwrite in promptscript.yaml is typed as boolean | undefined. When undefined, the compile command falls through to interactive prompting in TTY mode, or silently overwrites in non-interactive mode (CI/CD). This is the dangerous case — a CI pipeline can destroy hand-crafted files without warning.
Proposed behavior:
- Treat
undefinedthe same asfalsein non-interactive mode. - When
overwriteisfalse(or unset) and a conflict is detected in non-interactive mode, fail with exit code 1 and a clear error listing all conflicting files:
Error: Cannot overwrite existing files (not generated by PromptScript):
- .clinerules (last modified 2026-03-15)
- CLAUDE.md (last modified 2026-03-10)
Use --force to overwrite, or set output.overwrite: true in promptscript.yaml.
- Interactive mode (TTY) retains the current per-file prompt behavior — no change.
--forceflag continues to bypass all overwrite checks, as it does today.
Files affected:
| File | Change |
|---|---|
packages/core/src/types/config.ts | Update JSDoc on overwrite to document the new default semantics |
packages/cli/src/commands/compile.ts | Change the non-interactive fallback from silent overwrite to error-and-exit |
2. Output path conflict detection¶
Current behavior: DEFAULT_OUTPUT_PATHS maps multiple targets to the same file. Currently:
| Output path | Targets |
|---|---|
AGENTS.md | factory, codex, amp, cursor (agents-md version) |
.clinerules | cline |
When a user enables both factory and codex, both formatters generate content for AGENTS.md. The last target processed wins; earlier output is silently discarded. There is no validation, warning, or error.
Additionally, github in full mode generates AGENTS.md as an additional file, creating a potential three-way collision with factory and codex.
Proposed behavior:
Add a pre-compilation validation step that scans all enabled targets and their output paths (primary path from DEFAULT_OUTPUT_PATHS or custom output, plus any additionalFiles the formatter declares). If two or more targets resolve to the same output path:
- Default mode: Emit a warning to stderr:
--strictmode: Treat the conflict as an error and exit with code 1:
Error: Output path conflict detected:
AGENTS.md <- factory, codex
Disable one of the conflicting targets or assign a custom output path.
The compiler must expose a method to resolve output paths for all enabled targets before running formatters. This avoids wasted compilation work when --strict is active.
Files affected:
| File | Change |
|---|---|
packages/cli/src/commands/compile.ts | Add detectOutputConflicts() validation step before compilation loop |
packages/compiler/src/compiler.ts | Expose resolveOutputPaths(config): Map<string, string[]> that maps each output path to the list of targets that write to it |
packages/core/src/types/config.ts | No schema changes needed; uses existing DEFAULT_OUTPUT_PATHS and per-target output overrides |
Algorithm:
function detectOutputConflicts(enabledTargets, config):
pathMap = new Map<string, string[]>()
for each target in enabledTargets:
paths = [resolvedPrimaryPath(target, config)]
paths.push(...resolvedAdditionalPaths(target, config))
for each path in paths:
pathMap.get(path).push(target)
conflicts = entries in pathMap where value.length > 1
return conflicts
3. Graceful file/directory conflict handling (Cline ENOTDIR)¶
Current behavior: The Cline formatter's primary output path is .clinerules (a file). In multifile or full mode, it also generates skill files under .clinerules/skills/promptscript/. If .clinerules already exists as a plain file (from a previous simple-mode compilation or manual creation), fs.mkdir('.clinerules/skills/promptscript', { recursive: true }) throws:
This raw Node.js error is opaque to users and halts the entire compilation, including other targets that would have succeeded.
Proposed behavior:
Wrap file-writing operations in the compile command's output loop with a targeted catch for ENOTDIR (and the related EEXIST when a directory exists but a file write is attempted). On catch:
- Emit a user-friendly error for that specific target:
- Mark that target as failed.
- Continue compilation for remaining targets.
- At the end, exit with code 1 if any target failed, with a summary of failures.
This pattern (fail-per-target, continue others) is consistent with how build systems handle partial failures and is more useful in CI than aborting on the first error.
Files affected:
| File | Change |
|---|---|
packages/cli/src/commands/compile.ts | Add try/catch around per-target file writing with ENOTDIR/EEXIST detection and user-friendly error message |
4. Diff command marker stripping¶
Current behavior: The compile command has a stripMarkers() function (defined at line 88 of compile.ts) that removes PromptScript generation markers (HTML comments and YAML comments containing timestamps) from content before comparing existing vs. new output. This prevents unnecessary overwrites when only the timestamp changed.
The diff command does not use stripMarkers(). As a result, prs diff reports changes even when the only difference is the generation timestamp, producing noisy output that makes it hard to see real changes.
Proposed behavior:
- Extract
stripMarkers()fromcompile.tsinto a shared utility module. - Import and use it in both
compile.tsanddiff.tsbefore content comparison.
The shared utility should be placed in the CLI package since it is specific to CLI output handling (not a core/compiler concern).
Files affected:
| File | Change |
|---|---|
packages/cli/src/utils/markers.ts | New file: export stripMarkers(content: string): string |
packages/cli/src/commands/compile.ts | Remove inline stripMarkers(), import from ../utils/markers.js |
packages/cli/src/commands/diff.ts | Import stripMarkers from ../utils/markers.js, apply before diff comparison |
Testing Strategy¶
1. Safer default overwrite¶
- Unit test: Non-interactive mode with
overwrite: undefinedand existing non-PromptScript files exits with code 1 and lists conflicting files. - Unit test: Non-interactive mode with
overwrite: trueproceeds without error. - Unit test:
--forceflag bypasses overwrite protection regardless of config. - Integration test: Interactive mode still prompts per-file (existing behavior preserved).
2. Output path conflict detection¶
- Unit test: Two targets mapping to the same path triggers a warning in default mode.
- Unit test: Same scenario with
--stricttriggers an error and exits. - Unit test: Custom
outputoverride on one target resolves the conflict (no warning). - Unit test:
githubinfullmode withfactoryenabled detectsAGENTS.mdcollision.
3. Graceful file/directory conflict handling¶
- Unit test: Mock
fs.mkdirthrowingENOTDIR, verify user-friendly error is emitted and other targets continue. - Integration test: Create
.clinerulesas a file, run compile with cline + claude targets, verify claude succeeds and cline reports a clear error.
4. Diff marker stripping¶
- Unit test:
stripMarkers()removes HTML comment markers (<!-- Generated by PromptScript ... -->). - Unit test:
stripMarkers()removes YAML comment markers (# Generated by PromptScript ...). - Unit test:
stripMarkers()preserves all other content unchanged. - Integration test:
prs diffwith only timestamp changes reports no differences.
Migration / Breaking Changes¶
Breaking: Non-interactive overwrite default¶
What changes: Projects that rely on prs compile silently overwriting files in CI/CD without --force or output.overwrite: true will now get an error.
Migration path:
- Add
--forceto CI scripts, OR - Add
output.overwrite: truetopromptscript.yaml
Risk: Low. The marker detection system already identifies PromptScript-generated files and overwrites them freely. Only files not generated by PromptScript (i.e., user-created files) are affected by this change. The scenarios where CI silently destroys user files are exactly the cases we want to catch.
Non-breaking: All other changes¶
- Output conflict warnings are informational (no behavior change without
--strict). ENOTDIRhandling is purely additive error recovery.stripMarkersextraction is an internal refactor with no public API change.
Open Questions¶
-
Conflict resolution order: Should we define a deterministic target processing order, or is "last write wins" acceptable as long as we warn? A deterministic order would let users predict which target's output survives.
-
Per-target overwrite config: Should
TargetConfiggain its ownoverwritefield to allow fine-grained control? For example, a user might want--forcebehavior forclaudebut protection forgithub. This would add config complexity but enables more precise control in large projects. -
Cline formatter restructuring: Should the Cline formatter be updated to always use a directory (
.clinerules/) instead of sometimes a file and sometimes a directory? This would eliminate the file/directory conflict at the source rather than just handling it gracefully. However, it would be a breaking change for existing Cline users. -
Warning fatigue: For the output path conflict warning, should we suppress it when the user has explicitly configured custom output paths that happen to collide? An explicit collision may be intentional (e.g., wanting the union of two formatters' output in one file).