@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,1013 @@
1
+ /**
2
+ * Translation Orchestrator — Allowed-root Scanning and Document Grouping
3
+ *
4
+ * This module is the impure filesystem bridge between the repository and the
5
+ * pure Rosetta Stone translation core. It resolves symlinks, enforces containment
6
+ * within caller-approved roots, scans for artifacts, groups documents per artifact,
7
+ * and converts everything to normalized in-memory SourceDocument[] values.
8
+ *
9
+ * It also provides the dry-run/write orchestration flow and per-profile status
10
+ * isolation. The same pre-application path (scan, guard, detect, translate,
11
+ * validate plan, collision analysis) runs for both modes. In dry-run mode the
12
+ * PlanApplier is never invoked. Artifact plans are combined only after individual
13
+ * validation. Each profile maintains independent acquisition/translation/application
14
+ * status so one profile failure does not block other profiles.
15
+ *
16
+ * CONSTRAINTS:
17
+ * - This file IS impure — it uses node:fs and node:path for filesystem operations
18
+ * - All symlinks are resolved BEFORE reading to prevent escape
19
+ * - Output is always SourceDocument[] — pure data for the Rosetta Stone boundary
20
+ * - Byte limits prevent DoS from large repositories
21
+ *
22
+ * Requirements: 1.3, 9.1, 9.2, 9.9, 11.2, 11.5, 11.6, 11.7, 12.1, 13.3
23
+ */
24
+
25
+ import { readdir, readFile, realpath, stat } from "node:fs/promises";
26
+ import { join, relative, resolve, sep } from "node:path";
27
+ import { Glob } from "bun";
28
+ import { codePointCompare } from "./rosetta/contracts";
29
+ import type { SourceDocument } from "./schemas";
30
+
31
+ // ═══════════════════════════════════════════════════════════════════════════════
32
+ // Types
33
+ // ═══════════════════════════════════════════════════════════════════════════════
34
+
35
+ /**
36
+ * A validated, resolved absolute path serving as a containment boundary.
37
+ * All filesystem operations within the orchestrator are confined to this root.
38
+ */
39
+ export interface AllowedRoot {
40
+ /** Real (resolved) absolute path — all intermediate symlinks resolved */
41
+ readonly resolvedPath: string;
42
+ /** Human-readable identifier for diagnostics */
43
+ readonly label: string;
44
+ }
45
+
46
+ /**
47
+ * Configuration for artifact scanning within an allowed root.
48
+ */
49
+ export interface ScanOptions {
50
+ /** Containment boundary */
51
+ root: AllowedRoot;
52
+ /** Glob patterns for artifact directories (default: ["*\/knowledge.md"]) */
53
+ patterns?: string[];
54
+ /** Per-document byte limit (default: 1MB) */
55
+ maxBytesPerFile?: number;
56
+ /** Aggregate byte limit across all documents (default: 50MB) */
57
+ maxTotalBytes?: number;
58
+ /** Glob patterns to exclude */
59
+ exclude?: string[];
60
+ }
61
+
62
+ /**
63
+ * A group of in-memory documents belonging to one artifact.
64
+ */
65
+ export interface ArtifactDocumentGroup {
66
+ /** Artifact name derived from directory name */
67
+ artifactName: string;
68
+ /** All files for this artifact as in-memory documents */
69
+ documents: SourceDocument[];
70
+ /** Path relative to allowed root */
71
+ rootRelativePath: string;
72
+ /** Total size of all documents in bytes */
73
+ totalBytes: number;
74
+ }
75
+
76
+ /**
77
+ * Options for reading an individual artifact directory.
78
+ */
79
+ export interface ReadArtifactOptions {
80
+ /** Per-document byte limit (default: 1MB) */
81
+ maxBytesPerFile?: number;
82
+ /** Aggregate byte limit (default: 50MB) */
83
+ maxTotalBytes?: number;
84
+ }
85
+
86
+ // ═══════════════════════════════════════════════════════════════════════════════
87
+ // Constants
88
+ // ═══════════════════════════════════════════════════════════════════════════════
89
+
90
+ const DEFAULT_MAX_BYTES_PER_FILE = 1024 * 1024; // 1 MB
91
+ const DEFAULT_MAX_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MB
92
+ const DEFAULT_PATTERNS = ["*/knowledge.md"];
93
+
94
+ // ═══════════════════════════════════════════════════════════════════════════════
95
+ // Allowed Root Resolution
96
+ // ═══════════════════════════════════════════════════════════════════════════════
97
+
98
+ /**
99
+ * Resolves a path to an AllowedRoot by resolving symlinks and verifying existence.
100
+ *
101
+ * @param path - Filesystem path to resolve (may be relative or contain symlinks)
102
+ * @param label - Human-readable label for diagnostics (defaults to the resolved path)
103
+ * @returns Immutable AllowedRoot
104
+ * @throws If the path does not exist or is not a directory
105
+ */
106
+ export async function resolveAllowedRoot(
107
+ path: string,
108
+ label?: string,
109
+ ): Promise<AllowedRoot> {
110
+ const absolutePath = resolve(path);
111
+ let resolvedPath: string;
112
+ try {
113
+ resolvedPath = await realpath(absolutePath);
114
+ } catch {
115
+ throw new Error(
116
+ `Cannot resolve allowed root "${path}": path does not exist or is inaccessible`,
117
+ );
118
+ }
119
+
120
+ const stats = await stat(resolvedPath);
121
+ if (!stats.isDirectory()) {
122
+ throw new Error(
123
+ `Cannot resolve allowed root "${path}": resolved path is not a directory`,
124
+ );
125
+ }
126
+
127
+ return Object.freeze({
128
+ resolvedPath,
129
+ label: label ?? resolvedPath,
130
+ });
131
+ }
132
+
133
+ // ═══════════════════════════════════════════════════════════════════════════════
134
+ // Path Containment Check
135
+ // ═══════════════════════════════════════════════════════════════════════════════
136
+
137
+ /**
138
+ * Checks whether a path is contained within the allowed root after resolving symlinks.
139
+ * Safe against symlink escape attacks — resolves the target's real path before comparison.
140
+ *
141
+ * @param path - Path to check (resolved to real path before comparison)
142
+ * @param root - AllowedRoot serving as the containment boundary
143
+ * @returns true if the path is within the root, false otherwise
144
+ */
145
+ export async function isWithinRoot(
146
+ path: string,
147
+ root: AllowedRoot,
148
+ ): Promise<boolean> {
149
+ let resolvedTarget: string;
150
+ try {
151
+ resolvedTarget = await realpath(resolve(path));
152
+ } catch {
153
+ // If we can't resolve the path, it's not within the root
154
+ return false;
155
+ }
156
+
157
+ // The resolved path must start with the root's resolved path followed by a separator,
158
+ // or be exactly the root path itself
159
+ const rootWithSep = root.resolvedPath.endsWith(sep)
160
+ ? root.resolvedPath
161
+ : root.resolvedPath + sep;
162
+
163
+ return (
164
+ resolvedTarget === root.resolvedPath ||
165
+ resolvedTarget.startsWith(rootWithSep)
166
+ );
167
+ }
168
+
169
+ // ═══════════════════════════════════════════════════════════════════════════════
170
+ // Artifact Scanning
171
+ // ═══════════════════════════════════════════════════════════════════════════════
172
+
173
+ /**
174
+ * Scans an allowed root for artifact directories.
175
+ * Groups files by artifact directory (one knowledge.md = one artifact).
176
+ * Rejects paths that escape the root via symlinks.
177
+ * Enforces byte limits and returns deterministically ordered groups.
178
+ *
179
+ * @param options - Scan configuration
180
+ * @returns Deterministically ordered array of artifact document groups
181
+ * @throws On byte limit violations or root escape
182
+ */
183
+ export async function scanForArtifacts(
184
+ options: ScanOptions,
185
+ ): Promise<ArtifactDocumentGroup[]> {
186
+ const {
187
+ root,
188
+ patterns = DEFAULT_PATTERNS,
189
+ maxBytesPerFile = DEFAULT_MAX_BYTES_PER_FILE,
190
+ maxTotalBytes = DEFAULT_MAX_TOTAL_BYTES,
191
+ exclude = [],
192
+ } = options;
193
+
194
+ // Find all matching knowledge.md files using glob patterns
195
+ const artifactDirs = new Set<string>();
196
+
197
+ for (const pattern of patterns) {
198
+ const glob = new Glob(pattern);
199
+ const matches = glob.scanSync({
200
+ cwd: root.resolvedPath,
201
+ absolute: false,
202
+ onlyFiles: true,
203
+ });
204
+
205
+ for (const match of matches) {
206
+ // Derive the artifact directory from the match
207
+ // For pattern "*/knowledge.md", match is "artifact-name/knowledge.md"
208
+ const parts = match.split("/");
209
+ if (parts.length >= 2) {
210
+ const artifactDir = parts.slice(0, -1).join("/");
211
+
212
+ // Check exclusions
213
+ let excluded = false;
214
+ for (const excludePattern of exclude) {
215
+ const excludeGlob = new Glob(excludePattern);
216
+ if (excludeGlob.match(artifactDir) || excludeGlob.match(match)) {
217
+ excluded = true;
218
+ break;
219
+ }
220
+ }
221
+ if (excluded) continue;
222
+
223
+ // Verify the artifact directory is within the root (symlink safety)
224
+ const fullDir = join(root.resolvedPath, artifactDir);
225
+ const withinRoot = await isWithinRoot(fullDir, root);
226
+ if (!withinRoot) {
227
+ throw new Error(
228
+ `Artifact directory "${artifactDir}" escapes allowed root "${root.label}" via symlink`,
229
+ );
230
+ }
231
+
232
+ artifactDirs.add(artifactDir);
233
+ }
234
+ }
235
+ }
236
+
237
+ // Sort artifact directories deterministically by code-point order
238
+ const sortedDirs = [...artifactDirs].sort(codePointCompare);
239
+
240
+ // Read each artifact directory
241
+ let totalBytesUsed = 0;
242
+ const groups: ArtifactDocumentGroup[] = [];
243
+
244
+ for (const artifactDir of sortedDirs) {
245
+ const fullDir = join(root.resolvedPath, artifactDir);
246
+ const group = await readArtifactDocuments(fullDir, root, {
247
+ maxBytesPerFile,
248
+ maxTotalBytes: maxTotalBytes - totalBytesUsed,
249
+ });
250
+ totalBytesUsed += group.totalBytes;
251
+
252
+ if (totalBytesUsed > maxTotalBytes) {
253
+ throw new Error(
254
+ `Aggregate byte limit exceeded: ${totalBytesUsed} bytes exceeds ${maxTotalBytes} byte limit`,
255
+ );
256
+ }
257
+
258
+ groups.push(group);
259
+ }
260
+
261
+ return groups;
262
+ }
263
+
264
+ // ═══════════════════════════════════════════════════════════════════════════════
265
+ // Artifact Document Reading
266
+ // ═══════════════════════════════════════════════════════════════════════════════
267
+
268
+ /**
269
+ * Reads an artifact directory and returns its documents as in-memory SourceDocument[].
270
+ * Verifies containment within root, reads canonical artifact files, converts to
271
+ * SourceDocument[] with normalized relative paths, and enforces byte limits.
272
+ *
273
+ * @param artifactDir - Absolute path to the artifact directory
274
+ * @param root - AllowedRoot serving as containment boundary
275
+ * @param options - Byte limit options
276
+ * @returns ArtifactDocumentGroup with all files as in-memory documents
277
+ * @throws On root escape or byte limit violations
278
+ */
279
+ export async function readArtifactDocuments(
280
+ artifactDir: string,
281
+ root: AllowedRoot,
282
+ options?: ReadArtifactOptions,
283
+ ): Promise<ArtifactDocumentGroup> {
284
+ const maxBytesPerFile =
285
+ options?.maxBytesPerFile ?? DEFAULT_MAX_BYTES_PER_FILE;
286
+ const maxTotalBytes = options?.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
287
+
288
+ // Verify the artifact directory is within the root
289
+ const withinRoot = await isWithinRoot(artifactDir, root);
290
+ if (!withinRoot) {
291
+ throw new Error(
292
+ `Artifact directory "${artifactDir}" escapes allowed root "${root.label}"`,
293
+ );
294
+ }
295
+
296
+ const resolvedDir = await realpath(resolve(artifactDir));
297
+ const rootRelativePath = relative(root.resolvedPath, resolvedDir);
298
+ const artifactName = rootRelativePath.split(sep).pop() ?? rootRelativePath;
299
+
300
+ const documents: SourceDocument[] = [];
301
+ let totalBytes = 0;
302
+
303
+ // Helper to read a file safely. Returns false if the file doesn't exist.
304
+ async function readDocumentFile(
305
+ filePath: string,
306
+ relativePath: string,
307
+ ): Promise<boolean> {
308
+ // First attempt to read the file — if it doesn't exist, return false
309
+ let content: string;
310
+ try {
311
+ content = await readFile(filePath, "utf-8");
312
+ } catch {
313
+ // File doesn't exist — not an error for optional files
314
+ return false;
315
+ }
316
+
317
+ // File exists — now verify it's within the root (symlink check)
318
+ const fileWithinRoot = await isWithinRoot(filePath, root);
319
+ if (!fileWithinRoot) {
320
+ throw new Error(
321
+ `File "${relativePath}" escapes allowed root "${root.label}" via symlink`,
322
+ );
323
+ }
324
+
325
+ const byteLength = Buffer.byteLength(content, "utf-8");
326
+
327
+ if (byteLength > maxBytesPerFile) {
328
+ throw new Error(
329
+ `File "${relativePath}" exceeds per-file byte limit: ${byteLength} bytes > ${maxBytesPerFile} bytes`,
330
+ );
331
+ }
332
+
333
+ totalBytes += byteLength;
334
+ if (totalBytes > maxTotalBytes) {
335
+ throw new Error(
336
+ `Aggregate byte limit exceeded while reading "${relativePath}": ${totalBytes} bytes > ${maxTotalBytes} bytes`,
337
+ );
338
+ }
339
+
340
+ documents.push({
341
+ path: relativePath,
342
+ content,
343
+ executable: false,
344
+ });
345
+ return true;
346
+ }
347
+
348
+ // Read knowledge.md (required for artifact detection but not enforced here)
349
+ const knowledgeMdPath = join(resolvedDir, "knowledge.md");
350
+ await readDocumentFile(knowledgeMdPath, "knowledge.md");
351
+
352
+ // Read hooks.yaml (optional)
353
+ const hooksYamlPath = join(resolvedDir, "hooks.yaml");
354
+ await readDocumentFile(hooksYamlPath, "hooks.yaml");
355
+
356
+ // Read mcp-servers.yaml (optional)
357
+ const mcpServersYamlPath = join(resolvedDir, "mcp-servers.yaml");
358
+ await readDocumentFile(mcpServersYamlPath, "mcp-servers.yaml");
359
+
360
+ // Read workflows directory (optional)
361
+ const workflowsDir = join(resolvedDir, "workflows");
362
+ try {
363
+ const workflowsStat = await stat(workflowsDir);
364
+ if (workflowsStat.isDirectory()) {
365
+ // Directory exists — verify containment
366
+ const workflowsDirWithinRoot = await isWithinRoot(workflowsDir, root);
367
+ if (!workflowsDirWithinRoot) {
368
+ throw new Error(
369
+ `Workflows directory escapes allowed root "${root.label}" via symlink`,
370
+ );
371
+ }
372
+
373
+ const workflowFiles = await collectFilesRecursive(workflowsDir, root);
374
+ // Sort workflow files deterministically
375
+ workflowFiles.sort(codePointCompare);
376
+
377
+ for (const relFile of workflowFiles) {
378
+ const fullPath = join(workflowsDir, relFile);
379
+ await readDocumentFile(fullPath, `workflows/${relFile}`);
380
+ }
381
+ }
382
+ } catch (error) {
383
+ if (
384
+ error instanceof Error &&
385
+ (error.message.includes("escapes allowed root") ||
386
+ error.message.includes("byte limit"))
387
+ ) {
388
+ throw error;
389
+ }
390
+ // Missing workflows dir is fine — optional
391
+ }
392
+
393
+ // Read body override files (optional)
394
+ const bodyOverrideRe = /^body\..+\.md$/;
395
+ try {
396
+ const dirents = await readdir(resolvedDir, { withFileTypes: true });
397
+ const bodyFiles = dirents
398
+ .filter((d) => d.isFile() && bodyOverrideRe.test(d.name))
399
+ .map((d) => d.name)
400
+ .sort(codePointCompare);
401
+
402
+ for (const filename of bodyFiles) {
403
+ const filePath = join(resolvedDir, filename);
404
+ await readDocumentFile(filePath, filename);
405
+ }
406
+ } catch (error) {
407
+ if (
408
+ error instanceof Error &&
409
+ (error.message.includes("escapes allowed root") ||
410
+ error.message.includes("byte limit"))
411
+ ) {
412
+ throw error;
413
+ }
414
+ }
415
+
416
+ return {
417
+ artifactName,
418
+ documents,
419
+ rootRelativePath: rootRelativePath.split(sep).join("/"),
420
+ totalBytes,
421
+ };
422
+ }
423
+
424
+ // ═══════════════════════════════════════════════════════════════════════════════
425
+ // Document Grouping for Translation
426
+ // ═══════════════════════════════════════════════════════════════════════════════
427
+
428
+ /**
429
+ * Prepares document groups for translation requests.
430
+ * One request per artifact group, deterministically ordered by artifact name
431
+ * using code-point comparison. Adds artifactNameHint to caller context.
432
+ *
433
+ * @param groups - Artifact document groups from scanning
434
+ * @returns Array of { documents, callerContext } pairs ready for Rosetta Stone
435
+ */
436
+ export function groupDocumentsForTranslation(
437
+ groups: ArtifactDocumentGroup[],
438
+ ): Array<{
439
+ documents: SourceDocument[];
440
+ callerContext: Record<string, string>;
441
+ }> {
442
+ // Sort groups deterministically by artifact name (code-point comparison)
443
+ const sorted = [...groups].sort((a, b) =>
444
+ codePointCompare(a.artifactName, b.artifactName),
445
+ );
446
+
447
+ return sorted.map((group) => ({
448
+ documents: group.documents,
449
+ callerContext: {
450
+ artifactNameHint: group.artifactName,
451
+ },
452
+ }));
453
+ }
454
+
455
+ // ═══════════════════════════════════════════════════════════════════════════════
456
+ // Internal Helpers
457
+ // ═══════════════════════════════════════════════════════════════════════════════
458
+
459
+ /**
460
+ * Recursively collects files from a directory, returning paths relative to the
461
+ * given base directory. Verifies each file is within the allowed root.
462
+ */
463
+ async function collectFilesRecursive(
464
+ dir: string,
465
+ root: AllowedRoot,
466
+ prefix = "",
467
+ ): Promise<string[]> {
468
+ const files: string[] = [];
469
+
470
+ let entries: Array<{
471
+ name: string;
472
+ isDirectory(): boolean;
473
+ isFile(): boolean;
474
+ }>;
475
+ try {
476
+ const dirents = await readdir(dir, { withFileTypes: true });
477
+ entries = dirents as unknown as Array<{
478
+ name: string;
479
+ isDirectory(): boolean;
480
+ isFile(): boolean;
481
+ }>;
482
+ } catch {
483
+ return files;
484
+ }
485
+
486
+ for (const dirent of entries) {
487
+ const entryPath = join(dir, dirent.name);
488
+ const relativePath = prefix ? `${prefix}/${dirent.name}` : dirent.name;
489
+
490
+ // Verify containment for every entry
491
+ const entryWithinRoot = await isWithinRoot(entryPath, root);
492
+ if (!entryWithinRoot) {
493
+ throw new Error(
494
+ `Path "${relativePath}" escapes allowed root "${root.label}" via symlink`,
495
+ );
496
+ }
497
+
498
+ if (dirent.isDirectory()) {
499
+ const nested = await collectFilesRecursive(entryPath, root, relativePath);
500
+ files.push(...nested);
501
+ } else if (dirent.isFile()) {
502
+ files.push(relativePath);
503
+ }
504
+ }
505
+
506
+ return files;
507
+ }
508
+
509
+ // ═══════════════════════════════════════════════════════════════════════════════
510
+ // Per-Profile Status Types
511
+ // ═══════════════════════════════════════════════════════════════════════════════
512
+
513
+ /**
514
+ * Status of an individual operation phase within a profile.
515
+ */
516
+ export type PhaseStatus = "success" | "failure" | "partial" | "skipped";
517
+
518
+ /**
519
+ * Per-profile acquisition result.
520
+ * Tracks whether documents were successfully acquired for this profile.
521
+ */
522
+ export interface AcquisitionStatus {
523
+ /** Profile name */
524
+ readonly profileName: string;
525
+ /** Whether acquisition succeeded */
526
+ readonly status: PhaseStatus;
527
+ /** Error message if acquisition failed */
528
+ readonly error?: string;
529
+ /** Number of source documents acquired */
530
+ readonly documentCount: number;
531
+ }
532
+
533
+ /**
534
+ * Per-profile translation result.
535
+ * Tracks the pure translation phase outcome independently of acquisition/application.
536
+ */
537
+ export interface TranslationStatus {
538
+ /** Profile name */
539
+ readonly profileName: string;
540
+ /** Whether translation succeeded */
541
+ readonly status: PhaseStatus;
542
+ /** Number of artifacts translated */
543
+ readonly artifactCount: number;
544
+ /** Number of blocking diagnostics */
545
+ readonly blockingDiagnosticCount: number;
546
+ /** Number of warnings */
547
+ readonly warningCount: number;
548
+ /** Per-artifact plan summaries (path counts, application states) */
549
+ readonly planSummaries: readonly PlanSummary[];
550
+ }
551
+
552
+ /**
553
+ * Summary of a single artifact's translation plan.
554
+ */
555
+ export interface PlanSummary {
556
+ /** Artifact name or identifier */
557
+ readonly artifactName: string;
558
+ /** Number of output files in the plan */
559
+ readonly outputFileCount: number;
560
+ /** Application state derived by the engine */
561
+ readonly applicationState: "eligible" | "policy-required" | "withheld";
562
+ }
563
+
564
+ /**
565
+ * Per-profile application result.
566
+ * Tracks the filesystem write phase independently.
567
+ */
568
+ export interface ApplicationStatus {
569
+ /** Profile name */
570
+ readonly profileName: string;
571
+ /** Whether application succeeded */
572
+ readonly status: PhaseStatus;
573
+ /** Number of files written */
574
+ readonly filesWritten: number;
575
+ /** Number of files skipped */
576
+ readonly filesSkipped: number;
577
+ /** Number of files that failed */
578
+ readonly filesFailed: number;
579
+ /** Error message if application failed */
580
+ readonly error?: string;
581
+ }
582
+
583
+ /**
584
+ * Complete orchestration result for a single profile.
585
+ * Separates acquisition, translation, and application phases.
586
+ */
587
+ export interface ProfileOrchestrationResult {
588
+ /** Profile name */
589
+ readonly profileName: string;
590
+ /** Acquisition phase status */
591
+ readonly acquisition: AcquisitionStatus;
592
+ /** Translation phase status */
593
+ readonly translation: TranslationStatus;
594
+ /** Application phase status (skipped in dry-run) */
595
+ readonly application: ApplicationStatus;
596
+ }
597
+
598
+ /**
599
+ * Complete orchestration result across all profiles.
600
+ * One profile failure does NOT block other profiles.
601
+ */
602
+ export interface OrchestrationResult {
603
+ /** Whether this was a dry-run */
604
+ readonly dryRun: boolean;
605
+ /** Per-profile results, ordered deterministically by profile name */
606
+ readonly profiles: readonly ProfileOrchestrationResult[];
607
+ /** Overall status (success if all profiles succeeded, partial if any partial, failure if all failed) */
608
+ readonly overallStatus: PhaseStatus;
609
+ /** Combined plan summaries across all profiles */
610
+ readonly combinedPlanSummaries: readonly PlanSummary[];
611
+ }
612
+
613
+ // ═══════════════════════════════════════════════════════════════════════════════
614
+ // Orchestration Options
615
+ // ═══════════════════════════════════════════════════════════════════════════════
616
+
617
+ import type { TranslationResult } from "./schemas";
618
+ import type { CollisionPolicy } from "./translation-application-policy";
619
+ import type { ApplicationReport } from "./translation-plan-applier";
620
+
621
+ /**
622
+ * Options for a single profile's translation orchestration.
623
+ */
624
+ export interface ProfileOrchestrationOptions {
625
+ /** Profile name (used as label in reports) */
626
+ readonly profileName: string;
627
+ /** Source documents already acquired for this profile */
628
+ readonly documents: readonly SourceDocument[];
629
+ /** Caller context passed to translators */
630
+ readonly callerContext: Record<string, string>;
631
+ /** Whether to run in dry-run mode (no applier invocation) */
632
+ readonly dryRun: boolean;
633
+ /** Collision policy for plan application */
634
+ readonly collisionPolicy: CollisionPolicy;
635
+ /** Destination root for plan application */
636
+ readonly destinationRoot: AllowedRoot;
637
+ }
638
+
639
+ /**
640
+ * Translation function signature — injected to decouple from engine instantiation.
641
+ * Returns a TranslationResult for the given documents and context.
642
+ */
643
+ export type TranslateFn = (
644
+ documents: readonly SourceDocument[],
645
+ callerContext: Record<string, string>,
646
+ ) => TranslationResult;
647
+
648
+ /**
649
+ * Plan application function signature — injected to decouple from applier.
650
+ */
651
+ export type ApplyFn = (options: {
652
+ plan: TranslationResult["plan"];
653
+ destinationRoot: AllowedRoot;
654
+ collisionPolicy: CollisionPolicy;
655
+ dryRun: boolean;
656
+ }) => Promise<ApplicationReport>;
657
+
658
+ /**
659
+ * Full multi-profile orchestration options.
660
+ */
661
+ export interface MultiProfileOrchestrationOptions {
662
+ /** Profile orchestration options per profile */
663
+ readonly profiles: readonly ProfileOrchestrationOptions[];
664
+ /** Translation function (delegates to RosettaEngine) */
665
+ readonly translate: TranslateFn;
666
+ /** Plan application function (delegates to PlanApplier) */
667
+ readonly apply: ApplyFn;
668
+ }
669
+
670
+ // ═══════════════════════════════════════════════════════════════════════════════
671
+ // Pre-Application Path (shared between dry-run and write)
672
+ // ═══════════════════════════════════════════════════════════════════════════════
673
+
674
+ /**
675
+ * Internal result of running the pre-application path for a single profile.
676
+ */
677
+ interface PreApplicationResult {
678
+ readonly translationResult: TranslationResult;
679
+ readonly planSummaries: PlanSummary[];
680
+ readonly artifactCount: number;
681
+ readonly blockingDiagnosticCount: number;
682
+ readonly warningCount: number;
683
+ }
684
+
685
+ /**
686
+ * Run the pre-application path for a single profile.
687
+ * This executes: scan → guard → detect → translate → validate plan → collision analysis.
688
+ *
689
+ * The SAME path runs for both dry-run and write modes. The only difference
690
+ * is whether the applier is invoked afterward.
691
+ */
692
+ function runPreApplicationPath(
693
+ documents: readonly SourceDocument[],
694
+ callerContext: Record<string, string>,
695
+ translate: TranslateFn,
696
+ ): PreApplicationResult {
697
+ const translationResult = translate(documents, callerContext);
698
+
699
+ const blockingDiagnosticCount = translationResult.diagnostics.filter(
700
+ (d) => d.severity === "error",
701
+ ).length;
702
+ const warningCount = translationResult.diagnostics.filter(
703
+ (d) => d.severity === "warning",
704
+ ).length;
705
+
706
+ const planSummaries: PlanSummary[] = [];
707
+ let artifactCount = 0;
708
+
709
+ if (translationResult.plan) {
710
+ artifactCount = 1;
711
+ const artifactName = callerContext.artifactNameHint ?? "unknown";
712
+ planSummaries.push({
713
+ artifactName,
714
+ outputFileCount: translationResult.plan.outputFiles.length,
715
+ applicationState: translationResult.plan.applicationState ?? "withheld",
716
+ });
717
+ } else if (translationResult.canonical) {
718
+ // We have a canonical artifact but no plan — still counts as translated
719
+ artifactCount = 1;
720
+ }
721
+
722
+ return {
723
+ translationResult,
724
+ planSummaries,
725
+ artifactCount,
726
+ blockingDiagnosticCount,
727
+ warningCount,
728
+ };
729
+ }
730
+
731
+ // ═══════════════════════════════════════════════════════════════════════════════
732
+ // Single Profile Orchestration
733
+ // ═══════════════════════════════════════════════════════════════════════════════
734
+
735
+ /**
736
+ * Orchestrate translation for a single profile.
737
+ * Runs the full pre-application path, then conditionally applies the plan.
738
+ *
739
+ * In dry-run mode: runs all phases through collision analysis, does NOT invoke applier.
740
+ * In write mode: runs identical pre-application path, then applies eligible plans.
741
+ */
742
+ export async function orchestrateProfile(
743
+ options: ProfileOrchestrationOptions,
744
+ translate: TranslateFn,
745
+ apply: ApplyFn,
746
+ ): Promise<ProfileOrchestrationResult> {
747
+ const {
748
+ profileName,
749
+ documents,
750
+ callerContext,
751
+ dryRun,
752
+ collisionPolicy,
753
+ destinationRoot,
754
+ } = options;
755
+
756
+ // Build acquisition status from the documents provided
757
+ const acquisition: AcquisitionStatus = {
758
+ profileName,
759
+ status: documents.length > 0 ? "success" : "failure",
760
+ documentCount: documents.length,
761
+ ...(documents.length === 0
762
+ ? { error: "No source documents provided" }
763
+ : {}),
764
+ };
765
+
766
+ // If acquisition failed, skip translation and application
767
+ if (acquisition.status === "failure") {
768
+ return {
769
+ profileName,
770
+ acquisition,
771
+ translation: {
772
+ profileName,
773
+ status: "skipped",
774
+ artifactCount: 0,
775
+ blockingDiagnosticCount: 0,
776
+ warningCount: 0,
777
+ planSummaries: [],
778
+ },
779
+ application: {
780
+ profileName,
781
+ status: "skipped",
782
+ filesWritten: 0,
783
+ filesSkipped: 0,
784
+ filesFailed: 0,
785
+ },
786
+ };
787
+ }
788
+
789
+ // ─── Run pre-application path (same for dry-run and write) ────────
790
+ let preResult: PreApplicationResult;
791
+ try {
792
+ preResult = runPreApplicationPath(documents, callerContext, translate);
793
+ } catch (error) {
794
+ const msg = error instanceof Error ? error.message : "Translation failed";
795
+ return {
796
+ profileName,
797
+ acquisition,
798
+ translation: {
799
+ profileName,
800
+ status: "failure",
801
+ artifactCount: 0,
802
+ blockingDiagnosticCount: 1,
803
+ warningCount: 0,
804
+ planSummaries: [],
805
+ },
806
+ application: {
807
+ profileName,
808
+ status: "skipped",
809
+ filesWritten: 0,
810
+ filesSkipped: 0,
811
+ filesFailed: 0,
812
+ error: msg,
813
+ },
814
+ };
815
+ }
816
+
817
+ // Derive translation status
818
+ const translationStatus: PhaseStatus =
819
+ preResult.blockingDiagnosticCount > 0
820
+ ? "failure"
821
+ : preResult.warningCount > 0
822
+ ? "partial"
823
+ : "success";
824
+
825
+ const translation: TranslationStatus = {
826
+ profileName,
827
+ status: translationStatus,
828
+ artifactCount: preResult.artifactCount,
829
+ blockingDiagnosticCount: preResult.blockingDiagnosticCount,
830
+ warningCount: preResult.warningCount,
831
+ planSummaries: preResult.planSummaries,
832
+ };
833
+
834
+ // ─── Dry-run mode: skip application entirely ──────────────────────
835
+ if (dryRun) {
836
+ return {
837
+ profileName,
838
+ acquisition,
839
+ translation,
840
+ application: {
841
+ profileName,
842
+ status: "skipped",
843
+ filesWritten: 0,
844
+ filesSkipped: 0,
845
+ filesFailed: 0,
846
+ },
847
+ };
848
+ }
849
+
850
+ // ─── Write mode: apply eligible plans ─────────────────────────────
851
+ const plan = preResult.translationResult.plan;
852
+ const applicationState = plan?.applicationState;
853
+
854
+ // Only apply if the plan is eligible or policy-required (with overrides)
855
+ if (!plan || applicationState === "withheld") {
856
+ return {
857
+ profileName,
858
+ acquisition,
859
+ translation,
860
+ application: {
861
+ profileName,
862
+ status: applicationState === "withheld" ? "failure" : "skipped",
863
+ filesWritten: 0,
864
+ filesSkipped: 0,
865
+ filesFailed: 0,
866
+ ...(applicationState === "withheld"
867
+ ? { error: "Plan withheld due to blocking diagnostics" }
868
+ : {}),
869
+ },
870
+ };
871
+ }
872
+
873
+ // Invoke the plan applier
874
+ try {
875
+ const report = await apply({
876
+ plan,
877
+ destinationRoot,
878
+ collisionPolicy,
879
+ dryRun: false,
880
+ });
881
+
882
+ const filesWritten = report.outcomes.filter(
883
+ (o) => o.action === "written",
884
+ ).length;
885
+ const filesSkipped = report.outcomes.filter(
886
+ (o) => o.action === "skipped",
887
+ ).length;
888
+ const filesFailed = report.outcomes.filter(
889
+ (o) => o.action === "failed",
890
+ ).length;
891
+
892
+ const applicationPhaseStatus: PhaseStatus = report.completedSuccessfully
893
+ ? "success"
894
+ : filesFailed > 0
895
+ ? "failure"
896
+ : "partial";
897
+
898
+ return {
899
+ profileName,
900
+ acquisition,
901
+ translation,
902
+ application: {
903
+ profileName,
904
+ status: applicationPhaseStatus,
905
+ filesWritten,
906
+ filesSkipped,
907
+ filesFailed,
908
+ ...(report.failedAt ? { error: `Failed at: ${report.failedAt}` } : {}),
909
+ },
910
+ };
911
+ } catch (error) {
912
+ const msg = error instanceof Error ? error.message : "Application failed";
913
+ return {
914
+ profileName,
915
+ acquisition,
916
+ translation,
917
+ application: {
918
+ profileName,
919
+ status: "failure",
920
+ filesWritten: 0,
921
+ filesSkipped: 0,
922
+ filesFailed: 0,
923
+ error: msg,
924
+ },
925
+ };
926
+ }
927
+ }
928
+
929
+ // ═══════════════════════════════════════════════════════════════════════════════
930
+ // Multi-Profile Orchestration with Isolation
931
+ // ═══════════════════════════════════════════════════════════════════════════════
932
+
933
+ /**
934
+ * Orchestrate translation across multiple profiles with status isolation.
935
+ *
936
+ * Key invariants:
937
+ * - One profile's failure does NOT block other profiles
938
+ * - The same pre-application path runs for dry-run and write modes
939
+ * - Artifact plans are combined only AFTER individual validation
940
+ * - Each profile maintains independent acquisition/translation/application status
941
+ *
942
+ * @param options - Multi-profile orchestration configuration
943
+ * @returns OrchestrationResult with per-profile isolation
944
+ */
945
+ export async function orchestrateProfiles(
946
+ options: MultiProfileOrchestrationOptions,
947
+ ): Promise<OrchestrationResult> {
948
+ const { profiles, translate, apply } = options;
949
+
950
+ // Sort profiles deterministically by name
951
+ const sortedProfiles = [...profiles].sort((a, b) =>
952
+ codePointCompare(a.profileName, b.profileName),
953
+ );
954
+
955
+ // Determine if this is a dry-run (all profiles share the same mode)
956
+ const dryRun = sortedProfiles.length > 0 ? sortedProfiles[0].dryRun : false;
957
+
958
+ // Run each profile independently — failures are isolated
959
+ const results: ProfileOrchestrationResult[] = [];
960
+ const allPlanSummaries: PlanSummary[] = [];
961
+
962
+ for (const profileOpts of sortedProfiles) {
963
+ const result = await orchestrateProfile(profileOpts, translate, apply);
964
+ results.push(result);
965
+
966
+ // Combine plan summaries only from successfully validated profiles
967
+ if (result.translation.status !== "failure") {
968
+ allPlanSummaries.push(...result.translation.planSummaries);
969
+ }
970
+ }
971
+
972
+ // Derive overall status from individual profile statuses
973
+ const overallStatus = deriveOverallStatus(results);
974
+
975
+ return {
976
+ dryRun,
977
+ profiles: results,
978
+ overallStatus,
979
+ combinedPlanSummaries: allPlanSummaries,
980
+ };
981
+ }
982
+
983
+ /**
984
+ * Derive overall orchestration status from per-profile results.
985
+ * - All success → success
986
+ * - All failure → failure
987
+ * - Mix → partial
988
+ */
989
+ function deriveOverallStatus(
990
+ results: readonly ProfileOrchestrationResult[],
991
+ ): PhaseStatus {
992
+ if (results.length === 0) return "success";
993
+
994
+ const statuses = results.map((r) => {
995
+ // A profile's effective status is the worst of its three phases
996
+ const phases = [
997
+ r.acquisition.status,
998
+ r.translation.status,
999
+ r.application.status,
1000
+ ].filter((s) => s !== "skipped");
1001
+
1002
+ if (phases.includes("failure")) return "failure";
1003
+ if (phases.includes("partial")) return "partial";
1004
+ return "success";
1005
+ });
1006
+
1007
+ const allSuccess = statuses.every((s) => s === "success");
1008
+ const allFailure = statuses.every((s) => s === "failure");
1009
+
1010
+ if (allSuccess) return "success";
1011
+ if (allFailure) return "failure";
1012
+ return "partial";
1013
+ }