@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,473 @@
1
+ /**
2
+ * Translation Plan Applier — Safe Filesystem Plan Application
3
+ *
4
+ * The impure PlanApplier accepts only a validated plan, a destination AllowedRoot,
5
+ * and an explicit collision policy. It resolves the root and nearest existing parent
6
+ * for each destination, rejects symlinks or resolved paths outside the root, rechecks
7
+ * collisions immediately before writing, writes temporary files inside the allowed root,
8
+ * applies executable mode only when requested, and atomically renames each file.
9
+ *
10
+ * For multi-file artifact replacement it stages the complete artifact under the
11
+ * destination root before swapping, preventing partially written artifacts.
12
+ * It never follows instructions contained in file content.
13
+ *
14
+ * CONSTRAINTS:
15
+ * - This file IS impure — uses node:fs/promises for all filesystem operations
16
+ * - Uses codePointCompare from ./rosetta/contracts for deterministic ordering
17
+ * - Imports AllowedRoot and isWithinRoot from ./translation-orchestrator
18
+ * - Imports CollisionPolicy from ./translation-application-policy
19
+ * - Imports TranslationPlan from ./schemas
20
+ * - The ApplicationReport must NOT be part of the translation output (it's separate)
21
+ *
22
+ * Requirements: 1.3, 9.1, 12.2, 13.4
23
+ */
24
+
25
+ import { chmod, mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
26
+ import { dirname, join, resolve, sep } from "node:path";
27
+ import { codePointCompare } from "./rosetta/contracts";
28
+ import type { TranslationPlan } from "./schemas";
29
+ import type { CollisionPolicy } from "./translation-application-policy";
30
+ import type { AllowedRoot } from "./translation-orchestrator";
31
+ import { isWithinRoot } from "./translation-orchestrator";
32
+
33
+ // ═══════════════════════════════════════════════════════════════════════════════
34
+ // Types
35
+ // ═══════════════════════════════════════════════════════════════════════════════
36
+
37
+ /**
38
+ * Result of a single file write during plan application.
39
+ */
40
+ export interface WriteOutcome {
41
+ /** Normalized relative path */
42
+ readonly path: string;
43
+ /** What happened to this file */
44
+ readonly action: "written" | "skipped" | "failed";
45
+ /** Error message (if failed) */
46
+ readonly error?: string;
47
+ /** Bytes written (if written) */
48
+ readonly bytesWritten?: number;
49
+ /** Whether executable mode was set */
50
+ readonly executable: boolean;
51
+ }
52
+
53
+ /**
54
+ * Report of an entire plan application run.
55
+ * Kept separate from translation output — tracks timestamps, operation IDs,
56
+ * and write failures.
57
+ */
58
+ export interface ApplicationReport {
59
+ /** Unique ID for this application run */
60
+ readonly operationId: string;
61
+ /** ISO timestamp of application start */
62
+ readonly timestamp: string;
63
+ /** Per-file write results */
64
+ readonly outcomes: readonly WriteOutcome[];
65
+ /** Whether all writes succeeded */
66
+ readonly completedSuccessfully: boolean;
67
+ /** Path where failure occurred (if any) */
68
+ readonly failedAt?: string;
69
+ /** Staging directory used (for debugging) */
70
+ readonly stagedDir?: string;
71
+ }
72
+
73
+ /**
74
+ * Options for plan application.
75
+ */
76
+ export interface ApplyPlanOptions {
77
+ /** The validated plan to apply */
78
+ readonly plan: TranslationPlan;
79
+ /** Where to write files */
80
+ readonly destinationRoot: AllowedRoot;
81
+ /** How to handle existing files */
82
+ readonly collisionPolicy: CollisionPolicy;
83
+ /** If true, validate everything but don't write (default: false) */
84
+ readonly dryRun?: boolean;
85
+ }
86
+
87
+ // ═══════════════════════════════════════════════════════════════════════════════
88
+ // Operation ID Counter
89
+ // ═══════════════════════════════════════════════════════════════════════════════
90
+
91
+ let operationCounter = 0;
92
+
93
+ /**
94
+ * Generate a simple counter-based operation ID.
95
+ * No crypto randomness needed per spec.
96
+ */
97
+ function nextOperationId(): string {
98
+ operationCounter += 1;
99
+ return `apply-${operationCounter}`;
100
+ }
101
+
102
+ // ═══════════════════════════════════════════════════════════════════════════════
103
+ // Symlink Safety
104
+ // ═══════════════════════════════════════════════════════════════════════════════
105
+
106
+ /**
107
+ * Find the nearest existing ancestor directory for a given path.
108
+ * Walks up the directory tree until an existing directory is found.
109
+ */
110
+ async function findNearestExistingParent(
111
+ absolutePath: string,
112
+ ): Promise<string> {
113
+ let current = dirname(absolutePath);
114
+ while (true) {
115
+ try {
116
+ const stats = await stat(current);
117
+ if (stats.isDirectory()) {
118
+ return current;
119
+ }
120
+ } catch {
121
+ // Directory doesn't exist, walk up
122
+ }
123
+ const parent = dirname(current);
124
+ if (parent === current) {
125
+ // Reached filesystem root
126
+ return current;
127
+ }
128
+ current = parent;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Verify that the nearest existing parent of a destination path resolves
134
+ * within the allowed root. This prevents symlink escape attacks where an
135
+ * intermediate directory is a symlink pointing outside the root.
136
+ *
137
+ * @throws Error if the resolved parent is outside the allowed root
138
+ */
139
+ async function verifyParentWithinRoot(
140
+ absoluteDestination: string,
141
+ root: AllowedRoot,
142
+ ): Promise<void> {
143
+ const nearestParent = await findNearestExistingParent(absoluteDestination);
144
+ const withinRoot = await isWithinRoot(nearestParent, root);
145
+ if (!withinRoot) {
146
+ throw new Error(
147
+ `Symlink escape detected: nearest existing parent "${nearestParent}" ` +
148
+ `resolves outside allowed root "${root.label}"`,
149
+ );
150
+ }
151
+ }
152
+
153
+ // ═══════════════════════════════════════════════════════════════════════════════
154
+ // Collision Rechecking
155
+ // ═══════════════════════════════════════════════════════════════════════════════
156
+
157
+ /**
158
+ * Recheck collision policy immediately before writing.
159
+ * Returns "write" | "skip" | "abort" based on the collision policy and file existence.
160
+ */
161
+ async function recheckCollision(
162
+ absolutePath: string,
163
+ policy: CollisionPolicy,
164
+ ): Promise<"write" | "skip" | "abort"> {
165
+ try {
166
+ await stat(absolutePath);
167
+ // File exists — apply collision policy
168
+ switch (policy) {
169
+ case "error":
170
+ return "abort";
171
+ case "skip":
172
+ return "skip";
173
+ case "replace":
174
+ case "reconcile":
175
+ return "write";
176
+ default:
177
+ return "abort";
178
+ }
179
+ } catch {
180
+ // File does not exist, safe to write
181
+ return "write";
182
+ }
183
+ }
184
+
185
+ // ═══════════════════════════════════════════════════════════════════════════════
186
+ // Staging and Atomic Writes
187
+ // ═══════════════════════════════════════════════════════════════════════════════
188
+
189
+ /**
190
+ * Write content to a temporary file inside the allowed root, then atomically
191
+ * rename it to the final destination.
192
+ *
193
+ * @param content - File content (string or Uint8Array)
194
+ * @param finalPath - Absolute path where the file should end up
195
+ * @param root - AllowedRoot for staging
196
+ * @param executable - Whether to set executable mode
197
+ * @returns Number of bytes written
198
+ */
199
+ async function atomicWrite(
200
+ content: string | Uint8Array,
201
+ finalPath: string,
202
+ root: AllowedRoot,
203
+ executable: boolean,
204
+ ): Promise<number> {
205
+ // Create the parent directory inside root
206
+ const parentDir = dirname(finalPath);
207
+ await mkdir(parentDir, { recursive: true });
208
+
209
+ // Verify the created parent is still within root (post-creation check)
210
+ const parentWithin = await isWithinRoot(parentDir, root);
211
+ if (!parentWithin) {
212
+ throw new Error(
213
+ `Created parent directory "${parentDir}" resolves outside allowed root "${root.label}"`,
214
+ );
215
+ }
216
+
217
+ // Write to a temporary file inside the root's staging area
218
+ const tempSuffix = `.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
219
+ const tempPath = finalPath + tempSuffix;
220
+
221
+ const bytes =
222
+ typeof content === "string"
223
+ ? Buffer.byteLength(content, "utf-8")
224
+ : content.length;
225
+
226
+ await writeFile(tempPath, content);
227
+
228
+ // Set executable mode if requested
229
+ if (executable) {
230
+ await chmod(tempPath, 0o755);
231
+ }
232
+
233
+ // Atomically rename temp file to final destination
234
+ await rename(tempPath, finalPath);
235
+
236
+ return bytes;
237
+ }
238
+
239
+ // ═══════════════════════════════════════════════════════════════════════════════
240
+ // Staging Helper for Multi-File Artifacts
241
+ // ═══════════════════════════════════════════════════════════════════════════════
242
+
243
+ /**
244
+ * Internal staging helper: writes all files to a staging directory first.
245
+ * Only if ALL writes succeed, atomically moves each to the final destination.
246
+ * Prevents partially written artifacts on failure.
247
+ */
248
+ export async function stageArtifactFiles(
249
+ files: ReadonlyArray<{
250
+ content: string | Uint8Array;
251
+ absoluteDestination: string;
252
+ executable: boolean;
253
+ }>,
254
+ stagingDir: string,
255
+ ): Promise<void> {
256
+ // Create staging directory
257
+ await mkdir(stagingDir, { recursive: true });
258
+
259
+ const stagedFiles: Array<{ stagePath: string; finalPath: string }> = [];
260
+
261
+ try {
262
+ // Stage all files
263
+ for (let i = 0; i < files.length; i++) {
264
+ const file = files[i];
265
+ const stagePath = join(stagingDir, `staged-${i}`);
266
+
267
+ await writeFile(stagePath, file.content);
268
+
269
+ if (file.executable) {
270
+ await chmod(stagePath, 0o755);
271
+ }
272
+
273
+ stagedFiles.push({ stagePath, finalPath: file.absoluteDestination });
274
+ }
275
+
276
+ // All staging succeeded — atomically move each file to its destination
277
+ for (const { stagePath, finalPath } of stagedFiles) {
278
+ const parentDir = dirname(finalPath);
279
+ await mkdir(parentDir, { recursive: true });
280
+ await rename(stagePath, finalPath);
281
+ }
282
+ } finally {
283
+ // Clean up staging directory regardless of outcome
284
+ try {
285
+ await rm(stagingDir, { recursive: true, force: true });
286
+ } catch {
287
+ // Best-effort cleanup
288
+ }
289
+ }
290
+ }
291
+
292
+ // ═══════════════════════════════════════════════════════════════════════════════
293
+ // Main Plan Application
294
+ // ═══════════════════════════════════════════════════════════════════════════════
295
+
296
+ /**
297
+ * Apply a validated TranslationPlan to the filesystem.
298
+ *
299
+ * For each output file in the plan:
300
+ * a. Compute absolute destination path from root + relative path
301
+ * b. Resolve real path of nearest existing parent directory
302
+ * c. Verify the resolved path is within the allowed root (reject symlink escapes)
303
+ * d. Check collision policy against existing files
304
+ * e. Create parent directories as needed (inside root only)
305
+ * f. Write to a temporary file inside the allowed root (staging)
306
+ * g. Set executable mode if file.executable === true
307
+ * h. Atomically rename (move) the temp file to the final destination
308
+ *
309
+ * Returns ApplicationReport with all outcomes.
310
+ */
311
+ export async function applyPlan(
312
+ options: ApplyPlanOptions,
313
+ ): Promise<ApplicationReport> {
314
+ const { plan, destinationRoot, collisionPolicy, dryRun = false } = options;
315
+ const operationId = nextOperationId();
316
+ const timestamp = new Date().toISOString();
317
+ const outcomes: WriteOutcome[] = [];
318
+ let failedAt: string | undefined;
319
+ let stagedDir: string | undefined;
320
+
321
+ // Validate destination root exists and is a directory
322
+ try {
323
+ const rootStats = await stat(destinationRoot.resolvedPath);
324
+ if (!rootStats.isDirectory()) {
325
+ return {
326
+ operationId,
327
+ timestamp,
328
+ outcomes: [],
329
+ completedSuccessfully: false,
330
+ failedAt: destinationRoot.resolvedPath,
331
+ };
332
+ }
333
+ } catch {
334
+ return {
335
+ operationId,
336
+ timestamp,
337
+ outcomes: [],
338
+ completedSuccessfully: false,
339
+ failedAt: destinationRoot.resolvedPath,
340
+ };
341
+ }
342
+
343
+ // Sort output files deterministically by relative path
344
+ const sortedFiles = [...plan.outputFiles].sort((a, b) =>
345
+ codePointCompare(a.relativePath, b.relativePath),
346
+ );
347
+
348
+ // If using staging for multi-file artifacts, set up staging dir
349
+ if (sortedFiles.length > 1 && !dryRun) {
350
+ stagedDir = join(destinationRoot.resolvedPath, `.staging-${operationId}`);
351
+ }
352
+
353
+ // Process each file
354
+ for (const file of sortedFiles) {
355
+ const absoluteDest = resolve(
356
+ join(destinationRoot.resolvedPath, file.relativePath),
357
+ );
358
+
359
+ // Verify destination path is within root (lexical pre-check)
360
+ const rootWithSep = destinationRoot.resolvedPath.endsWith(sep)
361
+ ? destinationRoot.resolvedPath
362
+ : destinationRoot.resolvedPath + sep;
363
+
364
+ if (
365
+ absoluteDest !== destinationRoot.resolvedPath &&
366
+ !absoluteDest.startsWith(rootWithSep)
367
+ ) {
368
+ outcomes.push({
369
+ path: file.relativePath,
370
+ action: "failed",
371
+ error: `Destination path escapes allowed root`,
372
+ executable: file.executable ?? false,
373
+ });
374
+ failedAt = file.relativePath;
375
+ break;
376
+ }
377
+
378
+ // Resolve real path of nearest existing parent and verify containment
379
+ try {
380
+ await verifyParentWithinRoot(absoluteDest, destinationRoot);
381
+ } catch (error) {
382
+ const msg =
383
+ error instanceof Error ? error.message : "Symlink escape detected";
384
+ outcomes.push({
385
+ path: file.relativePath,
386
+ action: "failed",
387
+ error: msg,
388
+ executable: file.executable ?? false,
389
+ });
390
+ failedAt = file.relativePath;
391
+ break;
392
+ }
393
+
394
+ // Recheck collision policy immediately before write
395
+ const collisionDecision = await recheckCollision(
396
+ absoluteDest,
397
+ collisionPolicy,
398
+ );
399
+
400
+ if (collisionDecision === "abort") {
401
+ outcomes.push({
402
+ path: file.relativePath,
403
+ action: "failed",
404
+ error: `File already exists and collision policy is "error"`,
405
+ executable: file.executable ?? false,
406
+ });
407
+ failedAt = file.relativePath;
408
+ break;
409
+ }
410
+
411
+ if (collisionDecision === "skip") {
412
+ outcomes.push({
413
+ path: file.relativePath,
414
+ action: "skipped",
415
+ executable: file.executable ?? false,
416
+ });
417
+ continue;
418
+ }
419
+
420
+ // Dry-run: validate everything but don't write
421
+ if (dryRun) {
422
+ const bytes =
423
+ typeof file.content === "string"
424
+ ? Buffer.byteLength(file.content, "utf-8")
425
+ : file.content.length;
426
+ outcomes.push({
427
+ path: file.relativePath,
428
+ action: "written",
429
+ bytesWritten: bytes,
430
+ executable: file.executable ?? false,
431
+ });
432
+ continue;
433
+ }
434
+
435
+ // Perform atomic write
436
+ try {
437
+ const bytesWritten = await atomicWrite(
438
+ file.content,
439
+ absoluteDest,
440
+ destinationRoot,
441
+ file.executable ?? false,
442
+ );
443
+ outcomes.push({
444
+ path: file.relativePath,
445
+ action: "written",
446
+ bytesWritten,
447
+ executable: file.executable ?? false,
448
+ });
449
+ } catch (error) {
450
+ const msg = error instanceof Error ? error.message : "Write failed";
451
+ outcomes.push({
452
+ path: file.relativePath,
453
+ action: "failed",
454
+ error: msg,
455
+ executable: file.executable ?? false,
456
+ });
457
+ failedAt = file.relativePath;
458
+ break;
459
+ }
460
+ }
461
+
462
+ const completedSuccessfully =
463
+ !failedAt && outcomes.every((o) => o.action !== "failed");
464
+
465
+ return {
466
+ operationId,
467
+ timestamp,
468
+ outcomes,
469
+ completedSuccessfully,
470
+ ...(failedAt ? { failedAt } : {}),
471
+ ...(stagedDir ? { stagedDir } : {}),
472
+ };
473
+ }