@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.
- package/CHANGELOG.md +410 -0
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/bridge/mcp-server.cjs +14171 -0
- package/package.json +98 -0
- package/src/adapters/capabilities.ts +178 -0
- package/src/adapters/claude-code.ts +110 -0
- package/src/adapters/cline.ts +98 -0
- package/src/adapters/codex.ts +173 -0
- package/src/adapters/copilot.ts +106 -0
- package/src/adapters/cursor.ts +97 -0
- package/src/adapters/degradation.ts +95 -0
- package/src/adapters/index.ts +324 -0
- package/src/adapters/kiro-frontmatter.ts +139 -0
- package/src/adapters/kiro-inclusion.ts +86 -0
- package/src/adapters/kiro.ts +412 -0
- package/src/adapters/qdeveloper.ts +115 -0
- package/src/adapters/types.ts +81 -0
- package/src/adapters/windsurf.ts +96 -0
- package/src/admin.ts +283 -0
- package/src/asset-conventions.ts +118 -0
- package/src/attribution-backfill.ts +319 -0
- package/src/attribution-report.ts +95 -0
- package/src/attribution.ts +239 -0
- package/src/backends/github.ts +194 -0
- package/src/backends/http.ts +122 -0
- package/src/backends/index.ts +39 -0
- package/src/backends/local.ts +47 -0
- package/src/backends/s3.ts +157 -0
- package/src/backends/types.ts +59 -0
- package/src/base-cache.ts +270 -0
- package/src/browse-ui.ts +3754 -0
- package/src/browse.ts +1038 -0
- package/src/build.ts +1108 -0
- package/src/catalog.ts +204 -0
- package/src/cli-deprecated.ts +29 -0
- package/src/cli.ts +773 -0
- package/src/collection-admin.ts +287 -0
- package/src/collection-builder.ts +464 -0
- package/src/collections.ts +116 -0
- package/src/compatibility.ts +105 -0
- package/src/config.ts +743 -0
- package/src/eval/rubrics/kiro-progressive-steering.ts +841 -0
- package/src/eval.ts +1169 -0
- package/src/file-writer.ts +61 -0
- package/src/format-registry.ts +141 -0
- package/src/guild/auto-updater.ts +163 -0
- package/src/guild/backend-resolver.ts +49 -0
- package/src/guild/cli.ts +592 -0
- package/src/guild/collection-expander.ts +47 -0
- package/src/guild/global-cache.ts +247 -0
- package/src/guild/hook-generator.ts +100 -0
- package/src/guild/manifest.ts +154 -0
- package/src/guild/path-utils.ts +12 -0
- package/src/guild/sync.ts +622 -0
- package/src/guild/version-resolver.ts +42 -0
- package/src/help/metadata.ts +445 -0
- package/src/help/renderer.ts +265 -0
- package/src/help/typo-suggester.ts +25 -0
- package/src/hooks/expression.ts +493 -0
- package/src/hooks/pipeline.ts +141 -0
- package/src/import.ts +773 -0
- package/src/importers/claude-code.ts +134 -0
- package/src/importers/cline.ts +103 -0
- package/src/importers/codex.ts +140 -0
- package/src/importers/copilot.ts +103 -0
- package/src/importers/cursor.ts +105 -0
- package/src/importers/index.ts +390 -0
- package/src/importers/kiro.ts +110 -0
- package/src/importers/qdeveloper.ts +103 -0
- package/src/importers/types.ts +54 -0
- package/src/importers/windsurf.ts +104 -0
- package/src/install.ts +1005 -0
- package/src/manifest-admin.ts +306 -0
- package/src/mcp-bridge.ts +240 -0
- package/src/mutation/delta.ts +50 -0
- package/src/mutation/history.ts +66 -0
- package/src/mutation/operators.ts +524 -0
- package/src/mutation/runner.ts +332 -0
- package/src/new.ts +106 -0
- package/src/outcomes/collision.ts +127 -0
- package/src/outcomes/normalize.ts +208 -0
- package/src/outcomes/registry.ts +173 -0
- package/src/parser.ts +446 -0
- package/src/provenance-backfill-cli.ts +319 -0
- package/src/provenance-backfill.ts +520 -0
- package/src/publish.ts +354 -0
- package/src/reconcile-orchestrator.ts +502 -0
- package/src/reconcile-report-renderer.ts +176 -0
- package/src/resolve-body.ts +15 -0
- package/src/rosetta/builtins/compatibility-profiles.ts +297 -0
- package/src/rosetta/builtins/contracts.ts +1033 -0
- package/src/rosetta/builtins/pretty-printers/claude-code-native.ts +122 -0
- package/src/rosetta/builtins/pretty-printers/cline-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/codex-native.ts +127 -0
- package/src/rosetta/builtins/pretty-printers/copilot-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/cursor-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/index.ts +81 -0
- package/src/rosetta/builtins/pretty-printers/kiro-native.ts +166 -0
- package/src/rosetta/builtins/pretty-printers/kiro-power.ts +108 -0
- package/src/rosetta/builtins/pretty-printers/kiro-skill.ts +88 -0
- package/src/rosetta/builtins/pretty-printers/qdeveloper-native.ts +51 -0
- package/src/rosetta/builtins/pretty-printers/superpowers.ts +97 -0
- package/src/rosetta/builtins/pretty-printers/windsurf-native.ts +50 -0
- package/src/rosetta/builtins/sources/claude-code-native.ts +348 -0
- package/src/rosetta/builtins/sources/cline-native.ts +176 -0
- package/src/rosetta/builtins/sources/codex-native.ts +343 -0
- package/src/rosetta/builtins/sources/copilot-native.ts +178 -0
- package/src/rosetta/builtins/sources/cursor-native.ts +176 -0
- package/src/rosetta/builtins/sources/index.ts +95 -0
- package/src/rosetta/builtins/sources/kiro-native.ts +462 -0
- package/src/rosetta/builtins/sources/kiro-power.ts +285 -0
- package/src/rosetta/builtins/sources/kiro-skill.ts +230 -0
- package/src/rosetta/builtins/sources/qdeveloper-native.ts +181 -0
- package/src/rosetta/builtins/sources/superpowers.ts +240 -0
- package/src/rosetta/builtins/sources/windsurf-native.ts +176 -0
- package/src/rosetta/builtins/targets/claude-code.ts +181 -0
- package/src/rosetta/builtins/targets/cline.ts +87 -0
- package/src/rosetta/builtins/targets/codex.ts +226 -0
- package/src/rosetta/builtins/targets/copilot.ts +103 -0
- package/src/rosetta/builtins/targets/cursor.ts +87 -0
- package/src/rosetta/builtins/targets/index.ts +60 -0
- package/src/rosetta/builtins/targets/kiro.ts +278 -0
- package/src/rosetta/builtins/targets/qdeveloper.ts +103 -0
- package/src/rosetta/builtins/targets/windsurf.ts +87 -0
- package/src/rosetta/canonical.ts +729 -0
- package/src/rosetta/compatibility.ts +432 -0
- package/src/rosetta/contracts.ts +329 -0
- package/src/rosetta/detector.ts +724 -0
- package/src/rosetta/diagnostics.ts +630 -0
- package/src/rosetta/engine-bootstrap.ts +103 -0
- package/src/rosetta/engine.ts +744 -0
- package/src/rosetta/index.ts +381 -0
- package/src/rosetta/inspection.ts +530 -0
- package/src/rosetta/plan.ts +448 -0
- package/src/rosetta/provenance-digest.ts +369 -0
- package/src/rosetta/reconcile.ts +812 -0
- package/src/rosetta/redaction.ts +467 -0
- package/src/rosetta/registry.ts +712 -0
- package/src/rosetta/renderers.ts +571 -0
- package/src/rosetta/request-guard.ts +335 -0
- package/src/rosetta/resolution.ts +419 -0
- package/src/rosetta/source-accounting.ts +233 -0
- package/src/rosetta/templates.ts +129 -0
- package/src/rosetta-cli.ts +717 -0
- package/src/rosetta-docs-generator.ts +793 -0
- package/src/rosetta-profiles-cli.ts +367 -0
- package/src/schemas.ts +1712 -0
- package/src/spec-coordination.ts +1141 -0
- package/src/temper.ts +747 -0
- package/src/template-bundle-loader.ts +312 -0
- package/src/template-engine.ts +53 -0
- package/src/translation-application-policy.ts +496 -0
- package/src/translation-orchestrator.ts +1013 -0
- package/src/translation-plan-applier.ts +473 -0
- package/src/tutorial.ts +305 -0
- package/src/validate.ts +1093 -0
- package/src/versioning.ts +553 -0
- package/src/wizard.ts +660 -0
- package/src/workspace.ts +237 -0
- package/templates/eval-contexts/claude-code.md.njk +6 -0
- package/templates/eval-contexts/cline.md.njk +6 -0
- package/templates/eval-contexts/copilot.md.njk +6 -0
- package/templates/eval-contexts/cursor.md.njk +6 -0
- package/templates/eval-contexts/kiro.md.njk +10 -0
- package/templates/eval-contexts/qdeveloper.md.njk +6 -0
- package/templates/eval-contexts/windsurf.md.njk +6 -0
- package/templates/harness-adapters/_base/attribution-footer.md.njk +17 -0
- package/templates/harness-adapters/_base/base.md.njk +16 -0
- package/templates/harness-adapters/claude-code/claude.md.njk +1 -0
- package/templates/harness-adapters/claude-code/mcp.json.njk +1 -0
- package/templates/harness-adapters/claude-code/settings.json.njk +1 -0
- package/templates/harness-adapters/claude-code/skill-library-index.md.njk +13 -0
- package/templates/harness-adapters/claude-code/skill.md.njk +19 -0
- package/templates/harness-adapters/cline/hook.sh.njk +4 -0
- package/templates/harness-adapters/cline/mcp.json.njk +1 -0
- package/templates/harness-adapters/cline/rule.md.njk +1 -0
- package/templates/harness-adapters/codex/agents-md.md.njk +6 -0
- package/templates/harness-adapters/codex/agents-pointer.md.njk +16 -0
- package/templates/harness-adapters/codex/skill.md.njk +27 -0
- package/templates/harness-adapters/copilot/agents.md.njk +1 -0
- package/templates/harness-adapters/copilot/instructions.md.njk +1 -0
- package/templates/harness-adapters/copilot/scoped.md.njk +6 -0
- package/templates/harness-adapters/cursor/mcp.json.njk +1 -0
- package/templates/harness-adapters/cursor/rule.md.njk +6 -0
- package/templates/harness-adapters/kiro/hook.json.njk +1 -0
- package/templates/harness-adapters/kiro/mcp.json.njk +1 -0
- package/templates/harness-adapters/kiro/power-steering.md.njk +3 -0
- package/templates/harness-adapters/kiro/power.md.njk +12 -0
- package/templates/harness-adapters/kiro/steering.md.njk +16 -0
- package/templates/harness-adapters/qdeveloper/agent.md.njk +1 -0
- package/templates/harness-adapters/qdeveloper/mcp.json.njk +1 -0
- package/templates/harness-adapters/qdeveloper/rule.md.njk +1 -0
- package/templates/harness-adapters/windsurf/mcp.json.njk +1 -0
- package/templates/harness-adapters/windsurf/rule.md.njk +1 -0
- package/templates/harness-adapters/windsurf/workflow.md.njk +1 -0
- package/templates/knowledge/hooks.yaml.njk +4 -0
- package/templates/knowledge/knowledge.md.njk +53 -0
- 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
|
+
}
|