@thinkingsage/kanon 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/CHANGELOG.md +410 -0
  2. package/LICENSE +21 -0
  3. package/README.md +168 -0
  4. package/bridge/mcp-server.cjs +14171 -0
  5. package/package.json +98 -0
  6. package/src/adapters/capabilities.ts +178 -0
  7. package/src/adapters/claude-code.ts +110 -0
  8. package/src/adapters/cline.ts +98 -0
  9. package/src/adapters/codex.ts +173 -0
  10. package/src/adapters/copilot.ts +106 -0
  11. package/src/adapters/cursor.ts +97 -0
  12. package/src/adapters/degradation.ts +95 -0
  13. package/src/adapters/index.ts +324 -0
  14. package/src/adapters/kiro-frontmatter.ts +139 -0
  15. package/src/adapters/kiro-inclusion.ts +86 -0
  16. package/src/adapters/kiro.ts +412 -0
  17. package/src/adapters/qdeveloper.ts +115 -0
  18. package/src/adapters/types.ts +81 -0
  19. package/src/adapters/windsurf.ts +96 -0
  20. package/src/admin.ts +283 -0
  21. package/src/asset-conventions.ts +118 -0
  22. package/src/attribution-backfill.ts +319 -0
  23. package/src/attribution-report.ts +95 -0
  24. package/src/attribution.ts +239 -0
  25. package/src/backends/github.ts +194 -0
  26. package/src/backends/http.ts +122 -0
  27. package/src/backends/index.ts +39 -0
  28. package/src/backends/local.ts +47 -0
  29. package/src/backends/s3.ts +157 -0
  30. package/src/backends/types.ts +59 -0
  31. package/src/base-cache.ts +270 -0
  32. package/src/browse-ui.ts +3754 -0
  33. package/src/browse.ts +1038 -0
  34. package/src/build.ts +1108 -0
  35. package/src/catalog.ts +204 -0
  36. package/src/cli-deprecated.ts +29 -0
  37. package/src/cli.ts +773 -0
  38. package/src/collection-admin.ts +287 -0
  39. package/src/collection-builder.ts +464 -0
  40. package/src/collections.ts +116 -0
  41. package/src/compatibility.ts +105 -0
  42. package/src/config.ts +743 -0
  43. package/src/eval/rubrics/kiro-progressive-steering.ts +841 -0
  44. package/src/eval.ts +1169 -0
  45. package/src/file-writer.ts +61 -0
  46. package/src/format-registry.ts +141 -0
  47. package/src/guild/auto-updater.ts +163 -0
  48. package/src/guild/backend-resolver.ts +49 -0
  49. package/src/guild/cli.ts +592 -0
  50. package/src/guild/collection-expander.ts +47 -0
  51. package/src/guild/global-cache.ts +247 -0
  52. package/src/guild/hook-generator.ts +100 -0
  53. package/src/guild/manifest.ts +154 -0
  54. package/src/guild/path-utils.ts +12 -0
  55. package/src/guild/sync.ts +622 -0
  56. package/src/guild/version-resolver.ts +42 -0
  57. package/src/help/metadata.ts +445 -0
  58. package/src/help/renderer.ts +265 -0
  59. package/src/help/typo-suggester.ts +25 -0
  60. package/src/hooks/expression.ts +493 -0
  61. package/src/hooks/pipeline.ts +141 -0
  62. package/src/import.ts +773 -0
  63. package/src/importers/claude-code.ts +134 -0
  64. package/src/importers/cline.ts +103 -0
  65. package/src/importers/codex.ts +140 -0
  66. package/src/importers/copilot.ts +103 -0
  67. package/src/importers/cursor.ts +105 -0
  68. package/src/importers/index.ts +390 -0
  69. package/src/importers/kiro.ts +110 -0
  70. package/src/importers/qdeveloper.ts +103 -0
  71. package/src/importers/types.ts +54 -0
  72. package/src/importers/windsurf.ts +104 -0
  73. package/src/install.ts +1005 -0
  74. package/src/manifest-admin.ts +306 -0
  75. package/src/mcp-bridge.ts +240 -0
  76. package/src/mutation/delta.ts +50 -0
  77. package/src/mutation/history.ts +66 -0
  78. package/src/mutation/operators.ts +524 -0
  79. package/src/mutation/runner.ts +332 -0
  80. package/src/new.ts +106 -0
  81. package/src/outcomes/collision.ts +127 -0
  82. package/src/outcomes/normalize.ts +208 -0
  83. package/src/outcomes/registry.ts +173 -0
  84. package/src/parser.ts +446 -0
  85. package/src/provenance-backfill-cli.ts +319 -0
  86. package/src/provenance-backfill.ts +520 -0
  87. package/src/publish.ts +354 -0
  88. package/src/reconcile-orchestrator.ts +502 -0
  89. package/src/reconcile-report-renderer.ts +176 -0
  90. package/src/resolve-body.ts +15 -0
  91. package/src/rosetta/builtins/compatibility-profiles.ts +297 -0
  92. package/src/rosetta/builtins/contracts.ts +1033 -0
  93. package/src/rosetta/builtins/pretty-printers/claude-code-native.ts +122 -0
  94. package/src/rosetta/builtins/pretty-printers/cline-native.ts +50 -0
  95. package/src/rosetta/builtins/pretty-printers/codex-native.ts +127 -0
  96. package/src/rosetta/builtins/pretty-printers/copilot-native.ts +50 -0
  97. package/src/rosetta/builtins/pretty-printers/cursor-native.ts +50 -0
  98. package/src/rosetta/builtins/pretty-printers/index.ts +81 -0
  99. package/src/rosetta/builtins/pretty-printers/kiro-native.ts +166 -0
  100. package/src/rosetta/builtins/pretty-printers/kiro-power.ts +108 -0
  101. package/src/rosetta/builtins/pretty-printers/kiro-skill.ts +88 -0
  102. package/src/rosetta/builtins/pretty-printers/qdeveloper-native.ts +51 -0
  103. package/src/rosetta/builtins/pretty-printers/superpowers.ts +97 -0
  104. package/src/rosetta/builtins/pretty-printers/windsurf-native.ts +50 -0
  105. package/src/rosetta/builtins/sources/claude-code-native.ts +348 -0
  106. package/src/rosetta/builtins/sources/cline-native.ts +176 -0
  107. package/src/rosetta/builtins/sources/codex-native.ts +343 -0
  108. package/src/rosetta/builtins/sources/copilot-native.ts +178 -0
  109. package/src/rosetta/builtins/sources/cursor-native.ts +176 -0
  110. package/src/rosetta/builtins/sources/index.ts +95 -0
  111. package/src/rosetta/builtins/sources/kiro-native.ts +462 -0
  112. package/src/rosetta/builtins/sources/kiro-power.ts +285 -0
  113. package/src/rosetta/builtins/sources/kiro-skill.ts +230 -0
  114. package/src/rosetta/builtins/sources/qdeveloper-native.ts +181 -0
  115. package/src/rosetta/builtins/sources/superpowers.ts +240 -0
  116. package/src/rosetta/builtins/sources/windsurf-native.ts +176 -0
  117. package/src/rosetta/builtins/targets/claude-code.ts +181 -0
  118. package/src/rosetta/builtins/targets/cline.ts +87 -0
  119. package/src/rosetta/builtins/targets/codex.ts +226 -0
  120. package/src/rosetta/builtins/targets/copilot.ts +103 -0
  121. package/src/rosetta/builtins/targets/cursor.ts +87 -0
  122. package/src/rosetta/builtins/targets/index.ts +60 -0
  123. package/src/rosetta/builtins/targets/kiro.ts +278 -0
  124. package/src/rosetta/builtins/targets/qdeveloper.ts +103 -0
  125. package/src/rosetta/builtins/targets/windsurf.ts +87 -0
  126. package/src/rosetta/canonical.ts +729 -0
  127. package/src/rosetta/compatibility.ts +432 -0
  128. package/src/rosetta/contracts.ts +329 -0
  129. package/src/rosetta/detector.ts +724 -0
  130. package/src/rosetta/diagnostics.ts +630 -0
  131. package/src/rosetta/engine-bootstrap.ts +103 -0
  132. package/src/rosetta/engine.ts +744 -0
  133. package/src/rosetta/index.ts +381 -0
  134. package/src/rosetta/inspection.ts +530 -0
  135. package/src/rosetta/plan.ts +448 -0
  136. package/src/rosetta/provenance-digest.ts +369 -0
  137. package/src/rosetta/reconcile.ts +812 -0
  138. package/src/rosetta/redaction.ts +467 -0
  139. package/src/rosetta/registry.ts +712 -0
  140. package/src/rosetta/renderers.ts +571 -0
  141. package/src/rosetta/request-guard.ts +335 -0
  142. package/src/rosetta/resolution.ts +419 -0
  143. package/src/rosetta/source-accounting.ts +233 -0
  144. package/src/rosetta/templates.ts +129 -0
  145. package/src/rosetta-cli.ts +717 -0
  146. package/src/rosetta-docs-generator.ts +793 -0
  147. package/src/rosetta-profiles-cli.ts +367 -0
  148. package/src/schemas.ts +1712 -0
  149. package/src/spec-coordination.ts +1141 -0
  150. package/src/temper.ts +747 -0
  151. package/src/template-bundle-loader.ts +312 -0
  152. package/src/template-engine.ts +53 -0
  153. package/src/translation-application-policy.ts +496 -0
  154. package/src/translation-orchestrator.ts +1013 -0
  155. package/src/translation-plan-applier.ts +473 -0
  156. package/src/tutorial.ts +305 -0
  157. package/src/validate.ts +1093 -0
  158. package/src/versioning.ts +553 -0
  159. package/src/wizard.ts +660 -0
  160. package/src/workspace.ts +237 -0
  161. package/templates/eval-contexts/claude-code.md.njk +6 -0
  162. package/templates/eval-contexts/cline.md.njk +6 -0
  163. package/templates/eval-contexts/copilot.md.njk +6 -0
  164. package/templates/eval-contexts/cursor.md.njk +6 -0
  165. package/templates/eval-contexts/kiro.md.njk +10 -0
  166. package/templates/eval-contexts/qdeveloper.md.njk +6 -0
  167. package/templates/eval-contexts/windsurf.md.njk +6 -0
  168. package/templates/harness-adapters/_base/attribution-footer.md.njk +17 -0
  169. package/templates/harness-adapters/_base/base.md.njk +16 -0
  170. package/templates/harness-adapters/claude-code/claude.md.njk +1 -0
  171. package/templates/harness-adapters/claude-code/mcp.json.njk +1 -0
  172. package/templates/harness-adapters/claude-code/settings.json.njk +1 -0
  173. package/templates/harness-adapters/claude-code/skill-library-index.md.njk +13 -0
  174. package/templates/harness-adapters/claude-code/skill.md.njk +19 -0
  175. package/templates/harness-adapters/cline/hook.sh.njk +4 -0
  176. package/templates/harness-adapters/cline/mcp.json.njk +1 -0
  177. package/templates/harness-adapters/cline/rule.md.njk +1 -0
  178. package/templates/harness-adapters/codex/agents-md.md.njk +6 -0
  179. package/templates/harness-adapters/codex/agents-pointer.md.njk +16 -0
  180. package/templates/harness-adapters/codex/skill.md.njk +27 -0
  181. package/templates/harness-adapters/copilot/agents.md.njk +1 -0
  182. package/templates/harness-adapters/copilot/instructions.md.njk +1 -0
  183. package/templates/harness-adapters/copilot/scoped.md.njk +6 -0
  184. package/templates/harness-adapters/cursor/mcp.json.njk +1 -0
  185. package/templates/harness-adapters/cursor/rule.md.njk +6 -0
  186. package/templates/harness-adapters/kiro/hook.json.njk +1 -0
  187. package/templates/harness-adapters/kiro/mcp.json.njk +1 -0
  188. package/templates/harness-adapters/kiro/power-steering.md.njk +3 -0
  189. package/templates/harness-adapters/kiro/power.md.njk +12 -0
  190. package/templates/harness-adapters/kiro/steering.md.njk +16 -0
  191. package/templates/harness-adapters/qdeveloper/agent.md.njk +1 -0
  192. package/templates/harness-adapters/qdeveloper/mcp.json.njk +1 -0
  193. package/templates/harness-adapters/qdeveloper/rule.md.njk +1 -0
  194. package/templates/harness-adapters/windsurf/mcp.json.njk +1 -0
  195. package/templates/harness-adapters/windsurf/rule.md.njk +1 -0
  196. package/templates/harness-adapters/windsurf/workflow.md.njk +1 -0
  197. package/templates/knowledge/hooks.yaml.njk +4 -0
  198. package/templates/knowledge/knowledge.md.njk +53 -0
  199. package/templates/knowledge/mcp-servers.yaml.njk +2 -0
@@ -0,0 +1,496 @@
1
+ /**
2
+ * Translation Application Policy and Cross-Request Collision Analysis
3
+ *
4
+ * This module is the impure orchestration layer that evaluates caller policy
5
+ * against Rosetta Stone plan states and performs cross-request collision analysis
6
+ * before any write. It classifies plan eligibility, resolves diagnostic overrides,
7
+ * detects normalized path collisions across plans and against the filesystem,
8
+ * and resolves collision policy into actionable write/skip/block decisions.
9
+ *
10
+ * CONSTRAINTS:
11
+ * - This file IS impure — it uses node:fs/promises for stat checks
12
+ * - Uses codePointCompare from ./rosetta/contracts for deterministic ordering
13
+ * - Collision analysis is deterministic — same plans always produce same analysis
14
+ *
15
+ * Requirements: 4.8, 8.7, 9.1, 9.9, 13.5, 13.6
16
+ */
17
+
18
+ import { stat } from "node:fs/promises";
19
+ import { join } from "node:path";
20
+ import { codePointCompare } from "./rosetta/contracts";
21
+ import { normalizePlanPath } from "./rosetta/plan";
22
+ import type { TranslationPlan } from "./schemas";
23
+ import type { AllowedRoot } from "./translation-orchestrator";
24
+
25
+ // ═══════════════════════════════════════════════════════════════════════════════
26
+ // Types
27
+ // ═══════════════════════════════════════════════════════════════════════════════
28
+
29
+ /**
30
+ * Collision resolution strategy for output files that already exist.
31
+ */
32
+ export type CollisionPolicy = "error" | "skip" | "replace" | "reconcile";
33
+
34
+ /**
35
+ * Application-level policy governing whether a plan may be applied.
36
+ */
37
+ export interface ApplicationPolicy {
38
+ /** What to do when output files already exist */
39
+ readonly collisionPolicy: CollisionPolicy;
40
+ /** Diagnostic codes that can be overridden (allow application despite these codes) */
41
+ readonly allowedPolicyOverrideCodes: readonly string[];
42
+ /** If true, no policy overrides are allowed — any policy-required state blocks application */
43
+ readonly strict: boolean;
44
+ }
45
+
46
+ /**
47
+ * Result of evaluating application policy against a plan's state.
48
+ */
49
+ export interface ApplicationDecision {
50
+ /** Whether the plan can be applied */
51
+ readonly proceed: boolean;
52
+ /** Human-readable explanation */
53
+ readonly reason: string;
54
+ /** Diagnostic codes that blocked application */
55
+ readonly blockingCodes: readonly string[];
56
+ /** Diagnostic codes that were overridden by policy */
57
+ readonly overriddenCodes: readonly string[];
58
+ }
59
+
60
+ /**
61
+ * A single collision detected during cross-request analysis.
62
+ */
63
+ export interface CollisionEntry {
64
+ /** Normalized collision path */
65
+ readonly path: string;
66
+ /** Collision type */
67
+ readonly kind: "cross-plan" | "filesystem";
68
+ /** Which plan/file owns the existing path (for cross-plan: plan identifier) */
69
+ readonly existingSource?: string;
70
+ /** Which plan is trying to write there */
71
+ readonly newSource: string;
72
+ }
73
+
74
+ /**
75
+ * Result of cross-request collision analysis.
76
+ */
77
+ export interface CollisionAnalysis {
78
+ /** Detected collisions */
79
+ readonly collisions: readonly CollisionEntry[];
80
+ /** True if any collision would block under the given policy */
81
+ readonly hasBlockingCollisions: boolean;
82
+ /** Count of cross-plan collisions */
83
+ readonly crossPlanCollisions: number;
84
+ /** Count of existing-file collisions */
85
+ readonly filesystemCollisions: number;
86
+ }
87
+
88
+ /**
89
+ * Resolution decision for an individual file after applying collision policy.
90
+ */
91
+ export interface FileResolution {
92
+ /** Normalized relative path */
93
+ readonly path: string;
94
+ /** What to do with this file */
95
+ readonly action: "write" | "skip" | "block";
96
+ /** Reason for the decision */
97
+ readonly reason?: string;
98
+ }
99
+
100
+ /**
101
+ * Result of applying collision policy to a set of collisions.
102
+ */
103
+ export interface CollisionResolution {
104
+ /** Files that will be written */
105
+ readonly filesToWrite: readonly FileResolution[];
106
+ /** Files that will be skipped */
107
+ readonly filesToSkip: readonly FileResolution[];
108
+ /** Files that block the operation */
109
+ readonly blockedFiles: readonly FileResolution[];
110
+ /** Whether the overall operation can proceed */
111
+ readonly canProceed: boolean;
112
+ }
113
+
114
+ // ═══════════════════════════════════════════════════════════════════════════════
115
+ // Application Policy Evaluation
116
+ // ═══════════════════════════════════════════════════════════════════════════════
117
+
118
+ /**
119
+ * Evaluate whether a plan can be applied given the caller's application policy.
120
+ *
121
+ * - `eligible` plans can always proceed.
122
+ * - `policy-required` plans require all their policyDiagnosticCodes to be in
123
+ * the policy's allowedPolicyOverrideCodes list. Strict mode blocks all overrides.
124
+ * - `withheld` plans are always blocked.
125
+ */
126
+ export function evaluateApplicationPolicy(
127
+ plan: TranslationPlan,
128
+ policy: ApplicationPolicy,
129
+ _diagnostics?: readonly unknown[],
130
+ ): ApplicationDecision {
131
+ const { applicationState, policyDiagnosticCodes } = plan;
132
+
133
+ // Eligible plans can proceed unconditionally
134
+ if (applicationState === "eligible") {
135
+ return {
136
+ proceed: true,
137
+ reason: "Plan is eligible for application.",
138
+ blockingCodes: [],
139
+ overriddenCodes: [],
140
+ };
141
+ }
142
+
143
+ // Withheld plans are always blocked
144
+ if (applicationState === "withheld") {
145
+ return {
146
+ proceed: false,
147
+ reason:
148
+ "Plan is withheld and cannot be applied regardless of policy configuration.",
149
+ blockingCodes: [...policyDiagnosticCodes].sort(codePointCompare),
150
+ overriddenCodes: [],
151
+ };
152
+ }
153
+
154
+ // policy-required: check each code against the allowlist
155
+ if (applicationState === "policy-required") {
156
+ // In strict mode, no overrides are allowed
157
+ if (policy.strict) {
158
+ return {
159
+ proceed: false,
160
+ reason:
161
+ "Strict mode is enabled; no policy overrides are allowed for policy-required plans.",
162
+ blockingCodes: [...policyDiagnosticCodes].sort(codePointCompare),
163
+ overriddenCodes: [],
164
+ };
165
+ }
166
+
167
+ const allowedSet = new Set(policy.allowedPolicyOverrideCodes);
168
+ const blocking: string[] = [];
169
+ const overridden: string[] = [];
170
+
171
+ for (const code of policyDiagnosticCodes) {
172
+ if (allowedSet.has(code)) {
173
+ overridden.push(code);
174
+ } else {
175
+ blocking.push(code);
176
+ }
177
+ }
178
+
179
+ // Sort for deterministic output
180
+ blocking.sort(codePointCompare);
181
+ overridden.sort(codePointCompare);
182
+
183
+ if (blocking.length > 0) {
184
+ return {
185
+ proceed: false,
186
+ reason: `Application blocked by ${blocking.length} unresolved diagnostic code(s): ${blocking.join(", ")}.`,
187
+ blockingCodes: blocking,
188
+ overriddenCodes: overridden,
189
+ };
190
+ }
191
+
192
+ return {
193
+ proceed: true,
194
+ reason: `All ${overridden.length} policy-required diagnostic code(s) overridden by policy.`,
195
+ blockingCodes: [],
196
+ overriddenCodes: overridden,
197
+ };
198
+ }
199
+
200
+ // Unreachable if the schema is valid, but fail closed
201
+ return {
202
+ proceed: false,
203
+ reason: `Unknown application state: "${applicationState}".`,
204
+ blockingCodes: [...policyDiagnosticCodes].sort(codePointCompare),
205
+ overriddenCodes: [],
206
+ };
207
+ }
208
+
209
+ // ═══════════════════════════════════════════════════════════════════════════════
210
+ // Cross-Request Collision Analysis
211
+ // ═══════════════════════════════════════════════════════════════════════════════
212
+
213
+ /**
214
+ * Labeled plan for collision analysis. Associates a plan with a human-readable
215
+ * identifier for diagnostic reporting.
216
+ */
217
+ export interface LabeledPlan {
218
+ /** Human-readable label identifying this plan (e.g., artifact name) */
219
+ readonly label: string;
220
+ /** The translation plan to analyze */
221
+ readonly plan: TranslationPlan;
222
+ }
223
+
224
+ /**
225
+ * Analyze collisions across multiple translation plans and against the filesystem.
226
+ *
227
+ * For each plan's output files, checks if the normalized path collides with:
228
+ * - Another plan's output files (cross-plan collision)
229
+ * - An existing file in the destination (filesystem collision — checked via stat)
230
+ *
231
+ * The analysis is deterministic: same plans always produce the same result
232
+ * (filesystem checks are ordered deterministically by path).
233
+ */
234
+ export async function analyzeCollisions(
235
+ plans: readonly LabeledPlan[],
236
+ destinationRoot: AllowedRoot,
237
+ collisionPolicy: CollisionPolicy,
238
+ ): Promise<CollisionAnalysis> {
239
+ const collisions: CollisionEntry[] = [];
240
+
241
+ // Build a map of normalized path → first plan that claims it
242
+ const pathOwnership = new Map<string, string>();
243
+ // Collect all unique normalized paths for filesystem checks
244
+ const allNormalizedPaths = new Map<string, string>(); // path → claiming plan label
245
+
246
+ // Sort plans deterministically by label for stable iteration order
247
+ const sortedPlans = [...plans].sort((a, b) =>
248
+ codePointCompare(a.label, b.label),
249
+ );
250
+
251
+ // Phase 1: Cross-plan collision detection
252
+ for (const { label, plan } of sortedPlans) {
253
+ // Sort output files deterministically by path
254
+ const sortedFiles = [...plan.outputFiles].sort((a, b) =>
255
+ codePointCompare(a.relativePath, b.relativePath),
256
+ );
257
+
258
+ for (const file of sortedFiles) {
259
+ const normResult = normalizePlanPath(file.relativePath);
260
+ const normalizedPath = normResult.ok
261
+ ? normResult.normalized
262
+ : file.relativePath;
263
+
264
+ if (pathOwnership.has(normalizedPath)) {
265
+ // biome-ignore lint/style/noNonNullAssertion: guarded by .has() check above
266
+ const existingOwner = pathOwnership.get(normalizedPath)!;
267
+ collisions.push({
268
+ path: normalizedPath,
269
+ kind: "cross-plan",
270
+ existingSource: existingOwner,
271
+ newSource: label,
272
+ });
273
+ } else {
274
+ pathOwnership.set(normalizedPath, label);
275
+ }
276
+
277
+ // Track for filesystem check
278
+ if (!allNormalizedPaths.has(normalizedPath)) {
279
+ allNormalizedPaths.set(normalizedPath, label);
280
+ }
281
+ }
282
+ }
283
+
284
+ // Phase 2: Filesystem collision detection
285
+ // Sort paths deterministically for stable filesystem access ordering
286
+ const sortedPaths = [...allNormalizedPaths.entries()].sort(([a], [b]) =>
287
+ codePointCompare(a, b),
288
+ );
289
+
290
+ for (const [normalizedPath, claimingLabel] of sortedPaths) {
291
+ const absolutePath = join(destinationRoot.resolvedPath, normalizedPath);
292
+ try {
293
+ await stat(absolutePath);
294
+ // File exists — this is a filesystem collision
295
+ collisions.push({
296
+ path: normalizedPath,
297
+ kind: "filesystem",
298
+ existingSource: absolutePath,
299
+ newSource: claimingLabel,
300
+ });
301
+ } catch {
302
+ // File does not exist, no collision
303
+ }
304
+ }
305
+
306
+ // Sort collisions deterministically: by path, then by kind, then by newSource
307
+ collisions.sort((a, b) => {
308
+ const pathCmp = codePointCompare(a.path, b.path);
309
+ if (pathCmp !== 0) return pathCmp;
310
+ const kindCmp = codePointCompare(a.kind, b.kind);
311
+ if (kindCmp !== 0) return kindCmp;
312
+ return codePointCompare(a.newSource, b.newSource);
313
+ });
314
+
315
+ const crossPlanCollisions = collisions.filter(
316
+ (c) => c.kind === "cross-plan",
317
+ ).length;
318
+ const filesystemCollisions = collisions.filter(
319
+ (c) => c.kind === "filesystem",
320
+ ).length;
321
+
322
+ // Determine if there are blocking collisions based on policy
323
+ const hasBlockingCollisions = determineBlockingCollisions(
324
+ collisions,
325
+ collisionPolicy,
326
+ );
327
+
328
+ return {
329
+ collisions,
330
+ hasBlockingCollisions,
331
+ crossPlanCollisions,
332
+ filesystemCollisions,
333
+ };
334
+ }
335
+
336
+ /**
337
+ * Determine whether any collisions are blocking given the collision policy.
338
+ * This is a pure helper — no I/O.
339
+ */
340
+ function determineBlockingCollisions(
341
+ collisions: readonly CollisionEntry[],
342
+ policy: CollisionPolicy,
343
+ ): boolean {
344
+ if (collisions.length === 0) return false;
345
+
346
+ switch (policy) {
347
+ case "error":
348
+ // Any collision blocks all writes
349
+ return collisions.length > 0;
350
+ case "skip":
351
+ // Cross-plan collisions still block (ambiguous ownership)
352
+ return collisions.some((c) => c.kind === "cross-plan");
353
+ case "replace":
354
+ // Cross-plan collisions still block (ambiguous ownership)
355
+ return collisions.some((c) => c.kind === "cross-plan");
356
+ case "reconcile":
357
+ // Cross-plan collisions block; filesystem collisions are replaceable
358
+ return collisions.some((c) => c.kind === "cross-plan");
359
+ default:
360
+ // Fail closed for unknown policies
361
+ return true;
362
+ }
363
+ }
364
+
365
+ // ═══════════════════════════════════════════════════════════════════════════════
366
+ // Collision Policy Application
367
+ // ═══════════════════════════════════════════════════════════════════════════════
368
+
369
+ /**
370
+ * Resolve collisions per policy, determining which files to write, skip, or block.
371
+ *
372
+ * - `"error"` — any collision blocks all writes
373
+ * - `"skip"` — skip files that already exist, proceed with non-colliding
374
+ * - `"replace"` — overwrite existing files
375
+ * - `"reconcile"` — treat as error for cross-plan, replace for filesystem
376
+ *
377
+ * Returns deterministic results regardless of input ordering.
378
+ */
379
+ export function applyCollisionPolicy(
380
+ collisions: readonly CollisionEntry[],
381
+ policy: CollisionPolicy,
382
+ allOutputPaths: readonly string[],
383
+ ): CollisionResolution {
384
+ // Build sets for quick lookup
385
+ const collidingPaths = new Set(collisions.map((c) => c.path));
386
+ const crossPlanPaths = new Set(
387
+ collisions.filter((c) => c.kind === "cross-plan").map((c) => c.path),
388
+ );
389
+ const filesystemPaths = new Set(
390
+ collisions.filter((c) => c.kind === "filesystem").map((c) => c.path),
391
+ );
392
+
393
+ const filesToWrite: FileResolution[] = [];
394
+ const filesToSkip: FileResolution[] = [];
395
+ const blockedFiles: FileResolution[] = [];
396
+
397
+ // Sort all output paths deterministically
398
+ const sortedPaths = [...allOutputPaths].sort(codePointCompare);
399
+
400
+ for (const path of sortedPaths) {
401
+ if (!collidingPaths.has(path)) {
402
+ // No collision — always write
403
+ filesToWrite.push({ path, action: "write" });
404
+ continue;
405
+ }
406
+
407
+ const isCrossPlan = crossPlanPaths.has(path);
408
+ const isFilesystem = filesystemPaths.has(path);
409
+
410
+ switch (policy) {
411
+ case "error":
412
+ // Any collision blocks
413
+ blockedFiles.push({
414
+ path,
415
+ action: "block",
416
+ reason: isCrossPlan
417
+ ? "Cross-plan collision under error policy"
418
+ : "Filesystem collision under error policy",
419
+ });
420
+ break;
421
+
422
+ case "skip":
423
+ if (isCrossPlan) {
424
+ // Cross-plan collisions always block (ambiguous ownership)
425
+ blockedFiles.push({
426
+ path,
427
+ action: "block",
428
+ reason: "Cross-plan collision cannot be resolved by skip policy",
429
+ });
430
+ } else if (isFilesystem) {
431
+ // Skip existing files
432
+ filesToSkip.push({
433
+ path,
434
+ action: "skip",
435
+ reason: "File already exists; skipped per skip policy",
436
+ });
437
+ }
438
+ break;
439
+
440
+ case "replace":
441
+ if (isCrossPlan) {
442
+ // Cross-plan collisions always block (ambiguous ownership)
443
+ blockedFiles.push({
444
+ path,
445
+ action: "block",
446
+ reason: "Cross-plan collision cannot be resolved by replace policy",
447
+ });
448
+ } else if (isFilesystem) {
449
+ // Replace existing files
450
+ filesToWrite.push({
451
+ path,
452
+ action: "write",
453
+ reason: "Overwriting existing file per replace policy",
454
+ });
455
+ }
456
+ break;
457
+
458
+ case "reconcile":
459
+ if (isCrossPlan) {
460
+ // Cross-plan: treat as error
461
+ blockedFiles.push({
462
+ path,
463
+ action: "block",
464
+ reason:
465
+ "Cross-plan collision cannot be reconciled; blocking as error",
466
+ });
467
+ } else if (isFilesystem) {
468
+ // Filesystem: treat as replace
469
+ filesToWrite.push({
470
+ path,
471
+ action: "write",
472
+ reason: "Overwriting existing file per reconcile policy",
473
+ });
474
+ }
475
+ break;
476
+
477
+ default:
478
+ // Fail closed for unknown policies
479
+ blockedFiles.push({
480
+ path,
481
+ action: "block",
482
+ reason: `Unknown collision policy: "${policy}"`,
483
+ });
484
+ break;
485
+ }
486
+ }
487
+
488
+ const canProceed = blockedFiles.length === 0;
489
+
490
+ return {
491
+ filesToWrite,
492
+ filesToSkip,
493
+ blockedFiles,
494
+ canProceed,
495
+ };
496
+ }