@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,622 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Sync Engine — orchestrate resolve → expand → materialize pipeline
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import {
6
+ access,
7
+ copyFile,
8
+ mkdir,
9
+ readdir,
10
+ readFile,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import { dirname, join } from "node:path";
14
+ import type { BackendConfig } from "../backends/types";
15
+ import { loadForgeConfig, resolveBackendConfigs } from "../config";
16
+ import {
17
+ aggregateOutcomes,
18
+ type CollisionFinding,
19
+ runRegistryCheck,
20
+ } from "../outcomes/registry";
21
+ import { isParseError, loadKnowledgeArtifact } from "../parser";
22
+ import type { HarnessName, Outcome } from "../schemas";
23
+ import { SUPPORTED_HARNESSES } from "../schemas";
24
+ import { autoUpdate } from "./auto-updater";
25
+ import { resolveEntryBackend } from "./backend-resolver";
26
+ import type { ExpandedArtifact } from "./collection-expander";
27
+ import { expandCollection } from "./collection-expander";
28
+ import type { GlobalCacheAPI } from "./global-cache";
29
+ import { GlobalCache } from "./global-cache";
30
+ import type {
31
+ ArtifactManifestEntry,
32
+ CollectionManifestEntry,
33
+ Manifest,
34
+ } from "./manifest";
35
+ import { isCollectionRef, parseManifest } from "./manifest";
36
+ import { normalizePath } from "./path-utils";
37
+ import { resolveVersion } from "./version-resolver";
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Types
41
+ // ---------------------------------------------------------------------------
42
+
43
+ export interface SyncOptions {
44
+ manifestPath?: string; // default: .forge/manifest.yaml
45
+ autoUpdate?: boolean;
46
+ throttleMinutes?: number; // default: 60
47
+ dryRun?: boolean;
48
+ harness?: string;
49
+ /**
50
+ * When set, outcomes COLLISION/duplicate-id verdicts are downgraded to
51
+ * warnings and materialization proceeds regardless (Req 2G.3).
52
+ */
53
+ force?: boolean;
54
+ /** Override cache instance (for testing). */
55
+ cache?: GlobalCacheAPI;
56
+ /** Override config backends (for testing). */
57
+ configBackends?: Map<string, BackendConfig>;
58
+ /**
59
+ * Knowledge source directories scanned for each resolved artifact's
60
+ * `outcomes` frontmatter (default: `["knowledge", "packages"]`). Override
61
+ * for testing.
62
+ */
63
+ knowledgeSourceDirs?: string[];
64
+ }
65
+
66
+ export interface SyncResult {
67
+ resolved: ResolvedEntry[];
68
+ warnings: string[];
69
+ errors: string[];
70
+ filesWritten: number;
71
+ }
72
+
73
+ export interface ResolvedEntry {
74
+ name: string;
75
+ version: string;
76
+ source?: string; // collection name if expanded from a collection
77
+ harnesses: string[];
78
+ mode: "required" | "optional";
79
+ }
80
+
81
+ export interface SyncLock {
82
+ syncedAt: string;
83
+ entries: SyncLockEntry[];
84
+ }
85
+
86
+ export interface SyncLockEntry {
87
+ name: string;
88
+ version: string;
89
+ source?: string; // collection name if from collection expansion
90
+ harnesses: string[];
91
+ backend: string;
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Harness target mapping (mirrors HARNESS_INSTALL_PATHS from install.ts)
96
+ // ---------------------------------------------------------------------------
97
+
98
+ const HARNESS_INSTALL_PATHS: Record<HarnessName, string> = {
99
+ kiro: ".kiro",
100
+ "claude-code": ".",
101
+ codex: ".",
102
+ copilot: ".",
103
+ cursor: ".",
104
+ windsurf: ".",
105
+ cline: ".",
106
+ qdeveloper: ".",
107
+ };
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Internal: merged entry after collection expansion + individual merge
111
+ // ---------------------------------------------------------------------------
112
+
113
+ interface MergedEntry {
114
+ name: string;
115
+ version: string;
116
+ mode: "required" | "optional";
117
+ harnesses: string[];
118
+ backend?: string;
119
+ source?: string; // collection name if from expansion
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Helpers
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /** Recursively collect all relative file paths under `dir`. */
127
+ async function collectFiles(dir: string, base = ""): Promise<string[]> {
128
+ const results: string[] = [];
129
+ let dirEntries: { name: string; isDirectory(): boolean }[];
130
+ try {
131
+ dirEntries = (await readdir(dir, { withFileTypes: true })) as unknown as {
132
+ name: string;
133
+ isDirectory(): boolean;
134
+ }[];
135
+ } catch {
136
+ return results;
137
+ }
138
+ for (const entry of dirEntries) {
139
+ const rel = base ? `${base}/${entry.name}` : entry.name;
140
+ if (entry.isDirectory()) {
141
+ results.push(...(await collectFiles(join(dir, entry.name), rel)));
142
+ } else {
143
+ results.push(rel);
144
+ }
145
+ }
146
+ return results;
147
+ }
148
+
149
+ // resolveBackendName moved to ./backend-resolver.ts as resolveEntryBackend
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Outcomes collision detection (Req 2G)
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /** Default knowledge source directories scanned for `outcomes` frontmatter. */
156
+ const DEFAULT_KNOWLEDGE_SOURCE_DIRS = ["knowledge", "packages"] as const;
157
+
158
+ /** Check whether a path exists on disk. */
159
+ async function pathExists(p: string): Promise<boolean> {
160
+ try {
161
+ await access(p);
162
+ return true;
163
+ } catch {
164
+ return false;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Scan the given knowledge source directories and build a map from artifact
170
+ * name to its declared `outcomes`. Handles both the flat layout
171
+ * (`<dir>/<artifact>/knowledge.md`) and the namespaced layout
172
+ * (`<dir>/<prefix>/<artifact>/knowledge.md`), mirroring the catalog scanner.
173
+ * Artifacts that fail to parse are skipped silently — outcomes are an optional,
174
+ * advisory signal here, not a hard parse gate (validate owns strict parsing).
175
+ */
176
+ async function collectOutcomesByName(
177
+ sourceDirs: readonly string[],
178
+ ): Promise<Map<string, Outcome[]>> {
179
+ const map = new Map<string, Outcome[]>();
180
+
181
+ const tryLoad = async (artifactDir: string): Promise<void> => {
182
+ if (!(await pathExists(join(artifactDir, "knowledge.md")))) return;
183
+ const result = await loadKnowledgeArtifact(artifactDir);
184
+ if (isParseError(result)) return;
185
+ const fm = result.data.frontmatter;
186
+ if (!map.has(fm.name)) {
187
+ map.set(fm.name, fm.outcomes);
188
+ }
189
+ };
190
+
191
+ for (const dir of sourceDirs) {
192
+ let entries: { name: string; isDirectory(): boolean }[];
193
+ try {
194
+ entries = (await readdir(dir, { withFileTypes: true })) as unknown as {
195
+ name: string;
196
+ isDirectory(): boolean;
197
+ }[];
198
+ } catch {
199
+ continue; // source dir absent — nothing to contribute
200
+ }
201
+ for (const sub of entries) {
202
+ if (!sub.isDirectory()) continue;
203
+ const subPath = join(dir, sub.name);
204
+ if (await pathExists(join(subPath, "knowledge.md"))) {
205
+ // Flat layout: this subdir is the artifact.
206
+ await tryLoad(subPath);
207
+ } else {
208
+ // Namespaced layout: recurse one level.
209
+ let inner: { name: string; isDirectory(): boolean }[];
210
+ try {
211
+ inner = (await readdir(subPath, {
212
+ withFileTypes: true,
213
+ })) as unknown as { name: string; isDirectory(): boolean }[];
214
+ } catch {
215
+ continue;
216
+ }
217
+ for (const innerDir of inner) {
218
+ if (innerDir.isDirectory()) {
219
+ await tryLoad(join(subPath, innerDir.name));
220
+ }
221
+ }
222
+ }
223
+ }
224
+ }
225
+
226
+ return map;
227
+ }
228
+
229
+ /** Render a collision/duplicate-id finding as an actionable error line (Req 2G.2). */
230
+ function formatErrorFinding(f: CollisionFinding): string {
231
+ if (f.kind === "duplicate-id") {
232
+ return (
233
+ `Outcome id collision: "${f.a.outcome.id}" is declared by both ` +
234
+ `"${f.a.artifactName}" and "${f.b.artifactName}" (outcome ids must be globally unique).`
235
+ );
236
+ }
237
+ return (
238
+ `Outcome COLLISION: "${f.a.outcome.id}" (${f.a.artifactName}) and ` +
239
+ `"${f.b.outcome.id}" (${f.b.artifactName}) — matching shapes ` +
240
+ `input="${f.inputShape}" output="${f.outputShape}", keyword Jaccard=${(f.jaccard ?? 0).toFixed(2)}.`
241
+ );
242
+ }
243
+
244
+ /** Render an ambiguous finding as a warning line (Req 2G.4). */
245
+ function formatAmbiguousFinding(f: CollisionFinding): string {
246
+ return (
247
+ `Outcome AMBIGUOUS: "${f.a.outcome.id}" (${f.a.artifactName}) and ` +
248
+ `"${f.b.outcome.id}" (${f.b.artifactName}) partially overlap ` +
249
+ `(keyword Jaccard=${(f.jaccard ?? 0).toFixed(2)}).`
250
+ );
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // Main sync function
255
+ // ---------------------------------------------------------------------------
256
+
257
+ export async function sync(options: SyncOptions): Promise<SyncResult> {
258
+ const manifestPath = options.manifestPath ?? ".forge/manifest.yaml";
259
+ const throttleMinutes = options.throttleMinutes ?? 60;
260
+ const dryRun = options.dryRun ?? false;
261
+ const harnessFilter = options.harness;
262
+
263
+ const warnings: string[] = [];
264
+ const errors: string[] = [];
265
+ let filesWritten = 0;
266
+
267
+ // -----------------------------------------------------------------------
268
+ // Step 1: Parse manifest
269
+ // -----------------------------------------------------------------------
270
+ let manifestContent: string;
271
+ try {
272
+ manifestContent = await readFile(manifestPath, "utf-8");
273
+ } catch {
274
+ errors.push(`Cannot read manifest at ${manifestPath}`);
275
+ return { resolved: [], warnings, errors, filesWritten: 0 };
276
+ }
277
+
278
+ const parseWarnings: string[] = [];
279
+ let manifest: Manifest;
280
+ try {
281
+ manifest = parseManifest(manifestContent, parseWarnings);
282
+ } catch (err: unknown) {
283
+ const msg = err instanceof Error ? err.message : String(err);
284
+ errors.push(`Manifest parse error: ${msg}`);
285
+ return { resolved: [], warnings, errors, filesWritten: 0 };
286
+ }
287
+ warnings.push(...parseWarnings);
288
+
289
+ // -----------------------------------------------------------------------
290
+ // Load config and cache
291
+ // -----------------------------------------------------------------------
292
+ const config = await loadForgeConfig();
293
+ const configBackends =
294
+ options.configBackends ?? resolveBackendConfigs(config);
295
+ const cache = options.cache ?? new GlobalCache();
296
+
297
+ // -----------------------------------------------------------------------
298
+ // Step 2: Auto-update (if requested)
299
+ // -----------------------------------------------------------------------
300
+ if (options.autoUpdate) {
301
+ try {
302
+ await autoUpdate(manifest.artifacts, manifest.backend, {
303
+ throttleMinutes,
304
+ cache,
305
+ configBackends,
306
+ });
307
+ } catch {
308
+ // Silently fall back to cache on auto-update failure (Req 6.5, 10.2)
309
+ }
310
+ }
311
+
312
+ // -----------------------------------------------------------------------
313
+ // Step 3: Expand collection refs
314
+ // -----------------------------------------------------------------------
315
+ const individualEntries: ArtifactManifestEntry[] = [];
316
+ const expandedArtifacts: ExpandedArtifact[] = [];
317
+
318
+ for (const entry of manifest.artifacts) {
319
+ if (isCollectionRef(entry)) {
320
+ const collEntry = entry as CollectionManifestEntry;
321
+ try {
322
+ const expanded = await expandCollection(
323
+ collEntry.collection,
324
+ collEntry.version,
325
+ collEntry.mode,
326
+ collEntry.harnesses,
327
+ collEntry.backend,
328
+ cache,
329
+ );
330
+ expandedArtifacts.push(...expanded);
331
+ } catch (err: unknown) {
332
+ const msg = err instanceof Error ? err.message : String(err);
333
+ warnings.push(
334
+ `Failed to expand collection "${collEntry.collection}": ${msg}`,
335
+ );
336
+ }
337
+ } else {
338
+ individualEntries.push(entry as ArtifactManifestEntry);
339
+ }
340
+ }
341
+
342
+ // -----------------------------------------------------------------------
343
+ // Step 4: Merge — individual entries take precedence (Req 11.3)
344
+ // -----------------------------------------------------------------------
345
+ const individualNames = new Set(individualEntries.map((e) => e.name));
346
+ const defaultHarnesses = [...SUPPORTED_HARNESSES] as string[];
347
+
348
+ const merged: MergedEntry[] = [];
349
+
350
+ // Add individual entries first
351
+ for (const entry of individualEntries) {
352
+ merged.push({
353
+ name: entry.name,
354
+ version: entry.version,
355
+ mode: entry.mode,
356
+ harnesses: entry.harnesses ?? defaultHarnesses,
357
+ backend: entry.backend,
358
+ });
359
+ }
360
+
361
+ // Add expanded entries only if not already declared individually
362
+ for (const expanded of expandedArtifacts) {
363
+ if (!individualNames.has(expanded.name)) {
364
+ merged.push({
365
+ name: expanded.name,
366
+ version: expanded.version,
367
+ mode: expanded.mode,
368
+ harnesses: expanded.harnesses ?? defaultHarnesses,
369
+ backend: expanded.backend,
370
+ source: expanded.source,
371
+ });
372
+ }
373
+ }
374
+
375
+ // -----------------------------------------------------------------------
376
+ // Step 5 & 6: Resolve backend + version for each entry
377
+ // -----------------------------------------------------------------------
378
+ const resolved: ResolvedEntry[] = [];
379
+ const syncLockEntries: SyncLockEntry[] = [];
380
+ let hasFatalError = false;
381
+
382
+ for (const entry of merged) {
383
+ // Step 5: Resolve backend (Req 12.1, 12.5)
384
+ let backendName: string | undefined;
385
+ try {
386
+ const resolution = resolveEntryBackend(
387
+ entry.backend,
388
+ manifest.backend,
389
+ configBackends,
390
+ );
391
+ backendName = resolution?.name;
392
+ } catch (err: unknown) {
393
+ const msg = err instanceof Error ? err.message : String(err);
394
+ errors.push(msg);
395
+ hasFatalError = true;
396
+ continue;
397
+ }
398
+
399
+ // Step 6: Resolve version against cache
400
+ const availableVersions = await cache.listVersions(entry.name);
401
+
402
+ // Req 9.3: stale sync-lock detection — handled by re-resolving from available versions
403
+ const resolution = resolveVersion(
404
+ entry.name,
405
+ entry.version,
406
+ availableVersions,
407
+ );
408
+
409
+ // Step 7: Handle unresolved entries (Req 2.4, 2.5, 5.8, 5.9)
410
+ if (resolution.resolvedVersion === null) {
411
+ const versionInfo =
412
+ availableVersions.length > 0
413
+ ? `Available versions in cache: ${availableVersions.join(", ")}`
414
+ : "No versions found in cache";
415
+
416
+ if (entry.mode === "required") {
417
+ errors.push(
418
+ `Cannot resolve "${entry.name}" — no version satisfies "${entry.version}". ${versionInfo}. ` +
419
+ `Run \`forge install --global ${entry.name}\` to install a matching version.`,
420
+ );
421
+ hasFatalError = true;
422
+ } else {
423
+ warnings.push(
424
+ `Optional artifact "${entry.name}" not resolved — no version satisfies "${entry.version}". ${versionInfo}`,
425
+ );
426
+ }
427
+ continue;
428
+ }
429
+
430
+ const resolvedEntry: ResolvedEntry = {
431
+ name: entry.name,
432
+ version: resolution.resolvedVersion,
433
+ harnesses: entry.harnesses,
434
+ mode: entry.mode,
435
+ ...(entry.source ? { source: entry.source } : {}),
436
+ };
437
+ resolved.push(resolvedEntry);
438
+
439
+ syncLockEntries.push({
440
+ name: entry.name,
441
+ version: resolution.resolvedVersion,
442
+ harnesses: entry.harnesses,
443
+ backend: backendName ?? "local",
444
+ ...(entry.source ? { source: entry.source } : {}),
445
+ });
446
+ }
447
+
448
+ // If any required entry failed, return early with errors
449
+ if (hasFatalError) {
450
+ return { resolved, warnings, errors, filesWritten };
451
+ }
452
+
453
+ // -----------------------------------------------------------------------
454
+ // Step 7.5: Outcomes collision detection (Req 2G)
455
+ //
456
+ // After resolution and before materialization, aggregate the outcomes
457
+ // declared by the resolved artifacts and run the shared registry check
458
+ // (the same pure `runRegistryCheck` used by `forge validate`). Outcomes
459
+ // live in each artifact's `knowledge.md` frontmatter, which is not present
460
+ // in the compiled cache dist, so they are read from the local knowledge
461
+ // source directories.
462
+ // -----------------------------------------------------------------------
463
+ const sourceDirs = options.knowledgeSourceDirs ?? [
464
+ ...DEFAULT_KNOWLEDGE_SOURCE_DIRS,
465
+ ];
466
+ const outcomesByName = await collectOutcomesByName(sourceDirs);
467
+
468
+ // One entry per resolved artifact name (dedupe so an artifact pulled via
469
+ // multiple paths is not falsely flagged as a duplicate id).
470
+ const seenNames = new Set<string>();
471
+ const artifactsWithOutcomes: Array<{ name: string; outcomes: Outcome[] }> =
472
+ [];
473
+ for (const entry of resolved) {
474
+ if (seenNames.has(entry.name)) continue;
475
+ seenNames.add(entry.name);
476
+ artifactsWithOutcomes.push({
477
+ name: entry.name,
478
+ outcomes: outcomesByName.get(entry.name) ?? [],
479
+ });
480
+ }
481
+
482
+ const report = runRegistryCheck(aggregateOutcomes(artifactsWithOutcomes));
483
+
484
+ let hasOutcomeError = false;
485
+ for (const finding of report.findings) {
486
+ if (finding.kind === "collision" || finding.kind === "duplicate-id") {
487
+ const message = formatErrorFinding(finding);
488
+ if (options.force) {
489
+ // Req 2G.3: --force downgrades collisions to warnings and proceeds.
490
+ warnings.push(`[--force] ${message}`);
491
+ } else {
492
+ // Req 2G.2: collision is a fatal error; surface and halt before write.
493
+ errors.push(message);
494
+ hasOutcomeError = true;
495
+ }
496
+ } else if (finding.kind === "ambiguous") {
497
+ // Req 2G.4: ambiguous verdicts are warnings only and never block sync.
498
+ warnings.push(formatAmbiguousFinding(finding));
499
+ }
500
+ // acknowledged-overlap -> intentionally suppressed, no finding surfaced.
501
+ }
502
+
503
+ // Req 2G.2: return before materialize when a non-acknowledged collision or
504
+ // duplicate id was found and --force was not supplied.
505
+ if (hasOutcomeError) {
506
+ return { resolved, warnings, errors, filesWritten };
507
+ }
508
+
509
+ // -----------------------------------------------------------------------
510
+ // Step 8 & 9: Materialize artifacts into harness targets (Req 5.4, 5.10, 8.2)
511
+ // -----------------------------------------------------------------------
512
+ const generatedPaths: string[] = [];
513
+
514
+ for (const entry of resolved) {
515
+ const targetHarnesses = harnessFilter
516
+ ? entry.harnesses.filter((h) => h === harnessFilter)
517
+ : entry.harnesses;
518
+
519
+ for (const harness of targetHarnesses) {
520
+ const harnessKey = harness as HarnessName;
521
+ const harnessBase = HARNESS_INSTALL_PATHS[harnessKey];
522
+ if (harnessBase === undefined) continue;
523
+
524
+ const srcDir = cache.distPath(entry.name, entry.version, harness);
525
+
526
+ // Each artifact gets its own subdirectory (Req 5.10)
527
+ const destDir = join(harnessBase, entry.name);
528
+
529
+ if (dryRun) {
530
+ // Req 5.6: display plan without writing
531
+ const files = await collectFiles(srcDir);
532
+ for (const file of files) {
533
+ const destPath = normalizePath(join(destDir, file));
534
+ warnings.push(`[dry-run] Would write: ${destPath}`);
535
+ }
536
+ generatedPaths.push(normalizePath(destDir));
537
+ continue;
538
+ }
539
+
540
+ // Copy files from cache into harness target
541
+ const files = await collectFiles(srcDir);
542
+ for (const file of files) {
543
+ const src = join(srcDir, file);
544
+ const dest = join(destDir, file);
545
+ await mkdir(dirname(dest), { recursive: true });
546
+ await copyFile(src, dest);
547
+ filesWritten++;
548
+ }
549
+
550
+ generatedPaths.push(normalizePath(destDir));
551
+ }
552
+ }
553
+
554
+ // -----------------------------------------------------------------------
555
+ // Step 10: Write sync-lock.json (Req 5.11, 11.4)
556
+ // -----------------------------------------------------------------------
557
+ if (!dryRun) {
558
+ const syncLock: SyncLock = {
559
+ syncedAt: new Date().toISOString(),
560
+ entries: syncLockEntries.map((e) => ({
561
+ ...e,
562
+ // Normalize harness paths in sync-lock (Req 8.3)
563
+ harnesses: e.harnesses.map((h) => normalizePath(h)),
564
+ })),
565
+ };
566
+
567
+ const lockDir = dirname(join(".forge", "sync-lock.json"));
568
+ await mkdir(lockDir, { recursive: true });
569
+ await writeFile(
570
+ join(".forge", "sync-lock.json"),
571
+ JSON.stringify(syncLock, null, 2),
572
+ "utf-8",
573
+ );
574
+ }
575
+
576
+ // -----------------------------------------------------------------------
577
+ // Step 11: Update .forge/.gitignore (Req 5.5)
578
+ // -----------------------------------------------------------------------
579
+ if (!dryRun && generatedPaths.length > 0) {
580
+ await updateForgeGitignore(generatedPaths);
581
+ }
582
+
583
+ return { resolved, warnings, errors, filesWritten };
584
+ }
585
+
586
+ // ---------------------------------------------------------------------------
587
+ // .forge/.gitignore management
588
+ // ---------------------------------------------------------------------------
589
+
590
+ async function updateForgeGitignore(generatedPaths: string[]): Promise<void> {
591
+ const gitignorePath = join(".forge", ".gitignore");
592
+ let existing = "";
593
+
594
+ try {
595
+ existing = await readFile(gitignorePath, "utf-8");
596
+ } catch {
597
+ // File doesn't exist yet — will create
598
+ }
599
+
600
+ const marker = "# --- forge guild sync (auto-generated) ---";
601
+ const endMarker = "# --- end forge guild sync ---";
602
+
603
+ // Remove previous auto-generated block if present
604
+ const startIdx = existing.indexOf(marker);
605
+ const endIdx = existing.indexOf(endMarker);
606
+ let base = existing;
607
+ if (startIdx !== -1 && endIdx !== -1) {
608
+ base =
609
+ existing.slice(0, startIdx) + existing.slice(endIdx + endMarker.length);
610
+ }
611
+
612
+ // Deduplicate paths
613
+ const uniquePaths = [...new Set(generatedPaths)].map(normalizePath).sort();
614
+
615
+ const block = [marker, ...uniquePaths, endMarker, ""].join("\n");
616
+
617
+ const trimmedBase = base.trimEnd();
618
+ const content = trimmedBase.length > 0 ? `${trimmedBase}\n\n${block}` : block;
619
+
620
+ await mkdir(dirname(gitignorePath), { recursive: true });
621
+ await writeFile(gitignorePath, content, "utf-8");
622
+ }
@@ -0,0 +1,42 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Version Resolver — semver matching against Global Cache contents
3
+ // ---------------------------------------------------------------------------
4
+
5
+ /**
6
+ * Result of resolving a version pin against available versions.
7
+ */
8
+ export interface ResolutionResult {
9
+ name: string;
10
+ requestedVersion: string;
11
+ resolvedVersion: string | null;
12
+ availableVersions: string[];
13
+ }
14
+
15
+ /**
16
+ * Find the highest version in the cache that satisfies the version pin.
17
+ * Supports exact versions ("1.2.3") and semver ranges ("^1.0.0", "~1.2.0").
18
+ *
19
+ * Uses Bun's built-in `Bun.semver` API for range satisfaction checks and
20
+ * version ordering.
21
+ */
22
+ export function resolveVersion(
23
+ artifactName: string,
24
+ versionPin: string,
25
+ availableVersions: string[],
26
+ ): ResolutionResult {
27
+ // Filter to versions that satisfy the pin
28
+ const satisfying = availableVersions.filter((v) =>
29
+ Bun.semver.satisfies(v, versionPin),
30
+ );
31
+
32
+ // Sort ascending and pick the last (highest) one
33
+ const sorted = satisfying.sort(Bun.semver.order);
34
+ const resolvedVersion = sorted.length > 0 ? sorted[sorted.length - 1] : null;
35
+
36
+ return {
37
+ name: artifactName,
38
+ requestedVersion: versionPin,
39
+ resolvedVersion,
40
+ availableVersions,
41
+ };
42
+ }