@soleri/core 9.6.0 → 9.7.1

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 (90) hide show
  1. package/dist/index.d.ts +10 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +8 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/packs/index.d.ts +1 -1
  6. package/dist/packs/index.d.ts.map +1 -1
  7. package/dist/packs/index.js.map +1 -1
  8. package/dist/packs/types.d.ts +69 -42
  9. package/dist/packs/types.d.ts.map +1 -1
  10. package/dist/packs/types.js.map +1 -1
  11. package/dist/planning/github-projection.d.ts +3 -1
  12. package/dist/planning/github-projection.d.ts.map +1 -1
  13. package/dist/planning/github-projection.js +5 -1
  14. package/dist/planning/github-projection.js.map +1 -1
  15. package/dist/planning/goal-ancestry.d.ts +72 -0
  16. package/dist/planning/goal-ancestry.d.ts.map +1 -0
  17. package/dist/planning/goal-ancestry.js +137 -0
  18. package/dist/planning/goal-ancestry.js.map +1 -0
  19. package/dist/planning/plan-lifecycle.d.ts +2 -0
  20. package/dist/planning/plan-lifecycle.d.ts.map +1 -1
  21. package/dist/planning/plan-lifecycle.js +1 -0
  22. package/dist/planning/plan-lifecycle.js.map +1 -1
  23. package/dist/planning/planner-types.d.ts +2 -0
  24. package/dist/planning/planner-types.d.ts.map +1 -1
  25. package/dist/runtime/context-health.d.ts +14 -1
  26. package/dist/runtime/context-health.d.ts.map +1 -1
  27. package/dist/runtime/context-health.js +30 -2
  28. package/dist/runtime/context-health.js.map +1 -1
  29. package/dist/runtime/facades/orchestrate-facade.d.ts.map +1 -1
  30. package/dist/runtime/facades/orchestrate-facade.js +11 -0
  31. package/dist/runtime/facades/orchestrate-facade.js.map +1 -1
  32. package/dist/session/compaction-evaluator.d.ts +20 -0
  33. package/dist/session/compaction-evaluator.d.ts.map +1 -0
  34. package/dist/session/compaction-evaluator.js +73 -0
  35. package/dist/session/compaction-evaluator.js.map +1 -0
  36. package/dist/session/compaction-policy.d.ts +50 -0
  37. package/dist/session/compaction-policy.d.ts.map +1 -0
  38. package/dist/session/compaction-policy.js +17 -0
  39. package/dist/session/compaction-policy.js.map +1 -0
  40. package/dist/session/handoff-renderer.d.ts +22 -0
  41. package/dist/session/handoff-renderer.d.ts.map +1 -0
  42. package/dist/session/handoff-renderer.js +49 -0
  43. package/dist/session/handoff-renderer.js.map +1 -0
  44. package/dist/session/index.d.ts +6 -0
  45. package/dist/session/index.d.ts.map +1 -0
  46. package/dist/session/index.js +5 -0
  47. package/dist/session/index.js.map +1 -0
  48. package/dist/session/policy-resolver.d.ts +20 -0
  49. package/dist/session/policy-resolver.d.ts.map +1 -0
  50. package/dist/session/policy-resolver.js +28 -0
  51. package/dist/session/policy-resolver.js.map +1 -0
  52. package/dist/skills/sync-skills.d.ts +27 -0
  53. package/dist/skills/sync-skills.d.ts.map +1 -1
  54. package/dist/skills/sync-skills.js +92 -1
  55. package/dist/skills/sync-skills.js.map +1 -1
  56. package/dist/skills/trust-classifier.d.ts +32 -0
  57. package/dist/skills/trust-classifier.d.ts.map +1 -0
  58. package/dist/skills/trust-classifier.js +109 -0
  59. package/dist/skills/trust-classifier.js.map +1 -0
  60. package/dist/subagent/dispatcher.d.ts +4 -0
  61. package/dist/subagent/dispatcher.d.ts.map +1 -1
  62. package/dist/subagent/dispatcher.js +14 -2
  63. package/dist/subagent/dispatcher.js.map +1 -1
  64. package/dist/update-check.d.ts +19 -0
  65. package/dist/update-check.d.ts.map +1 -1
  66. package/dist/update-check.js +51 -4
  67. package/dist/update-check.js.map +1 -1
  68. package/package.json +1 -4
  69. package/src/index.ts +44 -0
  70. package/src/packs/index.ts +4 -0
  71. package/src/packs/types.ts +32 -0
  72. package/src/planning/github-projection.ts +6 -0
  73. package/src/planning/goal-ancestry.test.ts +427 -0
  74. package/src/planning/goal-ancestry.ts +187 -0
  75. package/src/planning/plan-lifecycle.ts +3 -0
  76. package/src/planning/planner-types.ts +2 -0
  77. package/src/runtime/context-health.ts +42 -2
  78. package/src/runtime/facades/orchestrate-facade.ts +14 -0
  79. package/src/session/compaction-evaluator.ts +87 -0
  80. package/src/session/compaction-policy.ts +66 -0
  81. package/src/session/compaction.test.ts +259 -0
  82. package/src/session/handoff-renderer.ts +56 -0
  83. package/src/session/index.ts +12 -0
  84. package/src/session/policy-resolver.ts +34 -0
  85. package/src/skills/sync-skills.ts +114 -1
  86. package/src/skills/trust-classifier.test.ts +252 -0
  87. package/src/skills/trust-classifier.ts +127 -0
  88. package/src/subagent/dispatcher.ts +18 -2
  89. package/src/update-check.test.ts +91 -0
  90. package/src/update-check.ts +76 -6
@@ -0,0 +1,252 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { classifyTrust } from './trust-classifier.js';
6
+ import { classifySkills, checkSkillCompatibility, ApprovalRequiredError } from './sync-skills.js';
7
+ import type { SkillEntry } from './sync-skills.js';
8
+
9
+ // =============================================================================
10
+ // HELPERS
11
+ // =============================================================================
12
+
13
+ let testDir: string;
14
+
15
+ function setup(): string {
16
+ testDir = join(tmpdir(), `soleri-trust-test-${Date.now()}`);
17
+ mkdirSync(testDir, { recursive: true });
18
+ return testDir;
19
+ }
20
+
21
+ function createSkillDir(parentDir: string, name: string, files: Record<string, string>): string {
22
+ const dir = join(parentDir, name);
23
+ mkdirSync(dir, { recursive: true });
24
+ for (const [filePath, content] of Object.entries(files)) {
25
+ const fullPath = join(dir, filePath);
26
+ mkdirSync(join(fullPath, '..'), { recursive: true });
27
+ writeFileSync(fullPath, content);
28
+ }
29
+ return dir;
30
+ }
31
+
32
+ // =============================================================================
33
+ // TrustClassifier
34
+ // =============================================================================
35
+
36
+ describe('TrustClassifier', () => {
37
+ beforeEach(() => setup());
38
+ afterEach(() => {
39
+ if (testDir) rmSync(testDir, { recursive: true, force: true });
40
+ });
41
+
42
+ it('classifies markdown-only directory', () => {
43
+ const dir = createSkillDir(testDir, 'md-skill', {
44
+ 'SKILL.md': '---\nname: test\n---\n# Test Skill',
45
+ 'reference.md': '# Reference doc',
46
+ });
47
+
48
+ const result = classifyTrust(dir);
49
+
50
+ expect(result.trust).toBe('markdown_only');
51
+ expect(result.inventory).toHaveLength(2);
52
+ expect(result.inventory.find((i) => i.path === 'SKILL.md')?.kind).toBe('skill');
53
+ expect(result.inventory.find((i) => i.path === 'reference.md')?.kind).toBe('reference');
54
+ });
55
+
56
+ it('classifies directory with assets', () => {
57
+ const dir = createSkillDir(testDir, 'asset-skill', {
58
+ 'SKILL.md': '# Skill',
59
+ 'logo.png': 'fake-png-data',
60
+ 'config.json': '{}',
61
+ });
62
+
63
+ const result = classifyTrust(dir);
64
+
65
+ expect(result.trust).toBe('assets');
66
+ expect(result.inventory.find((i) => i.path === 'logo.png')?.kind).toBe('asset');
67
+ expect(result.inventory.find((i) => i.path === 'config.json')?.kind).toBe('asset');
68
+ });
69
+
70
+ it('classifies directory with scripts', () => {
71
+ const dir = createSkillDir(testDir, 'script-skill', {
72
+ 'SKILL.md': '# Skill',
73
+ 'setup.sh': '#!/bin/bash\necho hi',
74
+ 'helper.ts': 'export const x = 1;',
75
+ });
76
+
77
+ const result = classifyTrust(dir);
78
+
79
+ expect(result.trust).toBe('scripts');
80
+ expect(result.inventory.filter((i) => i.kind === 'script')).toHaveLength(2);
81
+ });
82
+
83
+ it('treats .d.ts files as reference, not scripts', () => {
84
+ const dir = createSkillDir(testDir, 'decl-skill', {
85
+ 'SKILL.md': '# Skill',
86
+ 'types.d.ts': 'export type Foo = string;',
87
+ });
88
+
89
+ const result = classifyTrust(dir);
90
+
91
+ expect(result.trust).toBe('markdown_only');
92
+ expect(result.inventory.find((i) => i.path === 'types.d.ts')?.kind).toBe('reference');
93
+ });
94
+
95
+ it('returns markdown_only for empty directory', () => {
96
+ const dir = join(testDir, 'empty-skill');
97
+ mkdirSync(dir, { recursive: true });
98
+
99
+ const result = classifyTrust(dir);
100
+
101
+ expect(result.trust).toBe('markdown_only');
102
+ expect(result.inventory).toHaveLength(0);
103
+ });
104
+
105
+ it('returns markdown_only for non-existent directory', () => {
106
+ const result = classifyTrust(join(testDir, 'nonexistent'));
107
+
108
+ expect(result.trust).toBe('markdown_only');
109
+ expect(result.inventory).toHaveLength(0);
110
+ });
111
+
112
+ it('handles nested directories', () => {
113
+ const dir = createSkillDir(testDir, 'nested-skill', {
114
+ 'SKILL.md': '# Skill',
115
+ 'sub/helper.js': 'module.exports = {};',
116
+ 'sub/deep/readme.md': '# Deep',
117
+ });
118
+
119
+ const result = classifyTrust(dir);
120
+
121
+ expect(result.trust).toBe('scripts');
122
+ expect(result.inventory).toHaveLength(3);
123
+ expect(result.inventory.find((i) => i.path === 'sub/helper.js')?.kind).toBe('script');
124
+ });
125
+
126
+ it('skips hidden directories', () => {
127
+ const dir = createSkillDir(testDir, 'hidden-skill', {
128
+ 'SKILL.md': '# Skill',
129
+ '.git/config': 'gitconfig',
130
+ });
131
+
132
+ const result = classifyTrust(dir);
133
+
134
+ expect(result.inventory.some((i) => i.path.includes('.git'))).toBe(false);
135
+ });
136
+ });
137
+
138
+ // =============================================================================
139
+ // checkSkillCompatibility
140
+ // =============================================================================
141
+
142
+ describe('checkSkillCompatibility', () => {
143
+ it('returns unknown when no engine version specified', () => {
144
+ expect(checkSkillCompatibility(undefined, '9.6.0')).toBe('unknown');
145
+ });
146
+
147
+ it('returns unknown when no current version available', () => {
148
+ expect(checkSkillCompatibility('>=9.0.0', undefined)).toBe('unknown');
149
+ });
150
+
151
+ it('returns compatible for matching version', () => {
152
+ expect(checkSkillCompatibility('>=9.0.0', '9.6.0')).toBe('compatible');
153
+ });
154
+
155
+ it('returns invalid for incompatible version', () => {
156
+ expect(checkSkillCompatibility('>=10.0.0', '9.6.0')).toBe('invalid');
157
+ });
158
+
159
+ it('returns compatible for caret range', () => {
160
+ expect(checkSkillCompatibility('^9.0.0', '9.6.0')).toBe('compatible');
161
+ });
162
+
163
+ it('returns invalid for caret range with major mismatch', () => {
164
+ expect(checkSkillCompatibility('^10.0.0', '9.6.0')).toBe('invalid');
165
+ });
166
+ });
167
+
168
+ // =============================================================================
169
+ // classifySkills (integration with approval gate)
170
+ // =============================================================================
171
+
172
+ describe('classifySkills', () => {
173
+ beforeEach(() => setup());
174
+ afterEach(() => {
175
+ if (testDir) rmSync(testDir, { recursive: true, force: true });
176
+ });
177
+
178
+ it('classifies markdown-only skills without error', () => {
179
+ createSkillDir(testDir, 'safe-skill', {
180
+ 'SKILL.md': '---\nname: safe\n---\n# Safe Skill',
181
+ });
182
+
183
+ const skills: SkillEntry[] = [
184
+ { name: 'safe-skill', sourcePath: join(testDir, 'safe-skill', 'SKILL.md') },
185
+ ];
186
+
187
+ const result = classifySkills(skills);
188
+
189
+ expect(result).toHaveLength(1);
190
+ expect(result[0].metadata?.trust).toBe('markdown_only');
191
+ expect(result[0].metadata?.compatibility).toBe('unknown');
192
+ expect(result[0].metadata?.source.type).toBe('local');
193
+ });
194
+
195
+ it('throws ApprovalRequiredError for scripts without approval', () => {
196
+ createSkillDir(testDir, 'risky-skill', {
197
+ 'SKILL.md': '# Risky',
198
+ 'run.sh': '#!/bin/bash\nrm -rf /',
199
+ });
200
+
201
+ const skills: SkillEntry[] = [
202
+ { name: 'risky-skill', sourcePath: join(testDir, 'risky-skill', 'SKILL.md') },
203
+ ];
204
+
205
+ expect(() => classifySkills(skills)).toThrow(ApprovalRequiredError);
206
+ });
207
+
208
+ it('allows scripts when explicitly approved', () => {
209
+ createSkillDir(testDir, 'approved-skill', {
210
+ 'SKILL.md': '# Approved',
211
+ 'setup.sh': '#!/bin/bash\necho ok',
212
+ });
213
+
214
+ const skills: SkillEntry[] = [
215
+ { name: 'approved-skill', sourcePath: join(testDir, 'approved-skill', 'SKILL.md') },
216
+ ];
217
+
218
+ const result = classifySkills(skills, {
219
+ approvedScripts: new Set(['approved-skill']),
220
+ });
221
+
222
+ expect(result).toHaveLength(1);
223
+ expect(result[0].metadata?.trust).toBe('scripts');
224
+ });
225
+
226
+ it('reads engine version from SKILL.md frontmatter', () => {
227
+ createSkillDir(testDir, 'versioned-skill', {
228
+ 'SKILL.md': '---\nname: versioned\nengineVersion: ">=9.0.0"\n---\n# Versioned',
229
+ });
230
+
231
+ const skills: SkillEntry[] = [
232
+ { name: 'versioned-skill', sourcePath: join(testDir, 'versioned-skill', 'SKILL.md') },
233
+ ];
234
+
235
+ const result = classifySkills(skills, { currentEngineVersion: '9.6.0' });
236
+
237
+ expect(result[0].metadata?.engineVersion).toBe('>=9.0.0');
238
+ expect(result[0].metadata?.compatibility).toBe('compatible');
239
+ });
240
+
241
+ it('detects npm source type from node_modules path', () => {
242
+ const npmDir = join(testDir, 'node_modules', '@soleri', 'pack-test');
243
+ mkdirSync(npmDir, { recursive: true });
244
+ writeFileSync(join(npmDir, 'SKILL.md'), '---\nname: npm-skill\n---\n# NPM Skill');
245
+
246
+ const skills: SkillEntry[] = [{ name: 'npm-skill', sourcePath: join(npmDir, 'SKILL.md') }];
247
+
248
+ const result = classifySkills(skills);
249
+
250
+ expect(result[0].metadata?.source.type).toBe('npm');
251
+ });
252
+ });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Trust Classifier — scans a skill directory and determines its trust level.
3
+ *
4
+ * Classification rules:
5
+ * - `.sh`, `.ts`, `.js` files (non-declaration) -> `scripts`
6
+ * - Non-`.md` files (images, JSON, etc.) -> `assets`
7
+ * - `.md` files only -> `markdown_only`
8
+ *
9
+ * Also builds a full inventory of all files with their classified kind.
10
+ */
11
+
12
+ import { existsSync, readdirSync, statSync } from 'node:fs';
13
+ import { join, extname, relative } from 'node:path';
14
+ import type { TrustLevel, SkillInventoryItem } from '../packs/types.js';
15
+
16
+ /** File extensions that indicate executable scripts */
17
+ const SCRIPT_EXTENSIONS = new Set(['.sh', '.ts', '.js', '.mjs', '.cjs', '.py', '.rb', '.bash']);
18
+
19
+ /** File extensions considered markdown/documentation */
20
+ const MARKDOWN_EXTENSIONS = new Set(['.md', '.mdx']);
21
+
22
+ /** File extensions for TypeScript declaration files (not executable) */
23
+ const DECLARATION_PATTERN = /\.d\.[mc]?ts$/;
24
+
25
+ /**
26
+ * Classify a skill directory and return its trust level and inventory.
27
+ *
28
+ * @param dirPath - Absolute path to the skill directory
29
+ * @returns Trust level and full file inventory
30
+ */
31
+ export function classifyTrust(dirPath: string): {
32
+ trust: TrustLevel;
33
+ inventory: SkillInventoryItem[];
34
+ } {
35
+ if (!existsSync(dirPath)) {
36
+ return { trust: 'markdown_only', inventory: [] };
37
+ }
38
+
39
+ const inventory: SkillInventoryItem[] = [];
40
+ walkDir(dirPath, dirPath, inventory);
41
+
42
+ // Determine trust level from inventory
43
+ const hasScripts = inventory.some((item) => item.kind === 'script');
44
+ const hasAssets = inventory.some((item) => item.kind === 'asset');
45
+
46
+ let trust: TrustLevel;
47
+ if (hasScripts) {
48
+ trust = 'scripts';
49
+ } else if (hasAssets) {
50
+ trust = 'assets';
51
+ } else {
52
+ trust = 'markdown_only';
53
+ }
54
+
55
+ return { trust, inventory };
56
+ }
57
+
58
+ /**
59
+ * Namespace object for backward compatibility and namespaced access.
60
+ * Delegates to standalone `classifyTrust` function.
61
+ */
62
+ export const TrustClassifier = {
63
+ classify(dirPath: string): Promise<{ trust: TrustLevel; inventory: SkillInventoryItem[] }> {
64
+ return Promise.resolve(classifyTrust(dirPath));
65
+ },
66
+ };
67
+
68
+ /** Recursively walk a directory and classify all files */
69
+ function walkDir(rootDir: string, currentDir: string, inventory: SkillInventoryItem[]): void {
70
+ let names: string[];
71
+ try {
72
+ names = readdirSync(currentDir);
73
+ } catch {
74
+ return;
75
+ }
76
+
77
+ for (const name of names) {
78
+ const fullPath = join(currentDir, name);
79
+ let stat;
80
+ try {
81
+ stat = statSync(fullPath);
82
+ } catch {
83
+ continue;
84
+ }
85
+
86
+ if (stat.isDirectory()) {
87
+ // Skip hidden directories and node_modules
88
+ if (name.startsWith('.') || name === 'node_modules') continue;
89
+ walkDir(rootDir, fullPath, inventory);
90
+ continue;
91
+ }
92
+
93
+ if (!stat.isFile()) continue;
94
+
95
+ const relPath = relative(rootDir, fullPath);
96
+ const ext = extname(name).toLowerCase();
97
+ const kind = classifyFile(name, ext);
98
+
99
+ inventory.push({ path: relPath, kind });
100
+ }
101
+ }
102
+
103
+ /** Classify a single file by its extension and name */
104
+ function classifyFile(fileName: string, ext: string): SkillInventoryItem['kind'] {
105
+ // SKILL.md is the primary skill definition
106
+ if (fileName === 'SKILL.md' || fileName === 'skill.md') {
107
+ return 'skill';
108
+ }
109
+
110
+ // Declaration files are not executable
111
+ if (DECLARATION_PATTERN.test(fileName)) {
112
+ return 'reference';
113
+ }
114
+
115
+ // Script files
116
+ if (SCRIPT_EXTENSIONS.has(ext)) {
117
+ return 'script';
118
+ }
119
+
120
+ // Markdown files are references
121
+ if (MARKDOWN_EXTENSIONS.has(ext)) {
122
+ return 'reference';
123
+ }
124
+
125
+ // Everything else is an asset
126
+ return 'asset';
127
+ }
@@ -19,6 +19,8 @@ import { WorkspaceResolver } from './workspace-resolver.js';
19
19
  import { ConcurrencyManager } from './concurrency-manager.js';
20
20
  import { OrphanReaper } from './orphan-reaper.js';
21
21
  import { aggregate } from './result-aggregator.js';
22
+ import type { GoalRepository } from '../planning/goal-ancestry.js';
23
+ import { GoalAncestry } from '../planning/goal-ancestry.js';
22
24
 
23
25
  const DEFAULT_TIMEOUT = 300_000; // 5 minutes
24
26
  const DEFAULT_MAX_CONCURRENT = 3;
@@ -28,6 +30,8 @@ export interface SubagentDispatcherConfig {
28
30
  adapterRegistry: RuntimeAdapterRegistry;
29
31
  /** Base directory for git worktree isolation */
30
32
  baseDir?: string;
33
+ /** Optional goal repository for injecting goal ancestry context */
34
+ goalRepository?: GoalRepository;
31
35
  }
32
36
 
33
37
  export class SubagentDispatcher {
@@ -36,9 +40,13 @@ export class SubagentDispatcher {
36
40
  private readonly concurrency = new ConcurrencyManager();
37
41
  private readonly reaper: OrphanReaper;
38
42
  private readonly adapterRegistry: RuntimeAdapterRegistry;
43
+ private readonly goalAncestry?: GoalAncestry;
39
44
 
40
45
  constructor(config: SubagentDispatcherConfig) {
41
46
  this.adapterRegistry = config.adapterRegistry;
47
+ if (config.goalRepository) {
48
+ this.goalAncestry = new GoalAncestry(config.goalRepository);
49
+ }
42
50
  this.workspace = new WorkspaceResolver(config.baseDir ?? process.cwd());
43
51
  this.reaper = new OrphanReaper((taskId) => {
44
52
  // On orphan: release the task claim and clean up workspace
@@ -245,13 +253,21 @@ export class SubagentDispatcher {
245
253
  };
246
254
  }
247
255
 
248
- // 4. Execute with timeout
256
+ // 4. Inject goal ancestry context if available
257
+ let enrichedConfig: Record<string, unknown> = { ...task.config, timeout };
258
+ const goalId = task.config?.goalId as string | undefined;
259
+ if (goalId && this.goalAncestry) {
260
+ enrichedConfig =
261
+ this.goalAncestry.inject({ config: enrichedConfig }, goalId).config ?? enrichedConfig;
262
+ }
263
+
264
+ // 5. Execute with timeout
249
265
  try {
250
266
  const resultPromise = adapter.execute({
251
267
  runId: `subagent-${task.taskId}-${Date.now()}`,
252
268
  prompt: task.prompt,
253
269
  workspace,
254
- config: { ...task.config, timeout },
270
+ config: enrichedConfig,
255
271
  });
256
272
 
257
273
  const timeoutPromise = new Promise<never>((_, reject) => {
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildChangelogUrl, detectBreakingChanges } from './update-check.js';
3
+
4
+ describe('buildChangelogUrl', () => {
5
+ it('generates correct URL for a standard version', () => {
6
+ expect(buildChangelogUrl('1.2.3')).toBe(
7
+ 'https://github.com/adrozdenko/soleri/releases/tag/v1.2.3',
8
+ );
9
+ });
10
+
11
+ it('generates correct URL for a major version', () => {
12
+ expect(buildChangelogUrl('2.0.0')).toBe(
13
+ 'https://github.com/adrozdenko/soleri/releases/tag/v2.0.0',
14
+ );
15
+ });
16
+
17
+ it('generates correct URL for a patch-only bump', () => {
18
+ expect(buildChangelogUrl('0.1.15')).toBe(
19
+ 'https://github.com/adrozdenko/soleri/releases/tag/v0.1.15',
20
+ );
21
+ });
22
+
23
+ it('handles single-digit versions', () => {
24
+ expect(buildChangelogUrl('0.0.1')).toBe(
25
+ 'https://github.com/adrozdenko/soleri/releases/tag/v0.0.1',
26
+ );
27
+ });
28
+ });
29
+
30
+ describe('detectBreakingChanges', () => {
31
+ it('returns no warnings when versions share the same major and minor delta < 2', () => {
32
+ const result = detectBreakingChanges('1.2.0', '1.3.0');
33
+ expect(result.hasBreakingChanges).toBe(false);
34
+ expect(result.hasMultipleReleases).toBe(false);
35
+ });
36
+
37
+ it('detects breaking changes when major version differs (upgrade)', () => {
38
+ const result = detectBreakingChanges('1.5.0', '2.0.0');
39
+ expect(result.hasBreakingChanges).toBe(true);
40
+ expect(result.hasMultipleReleases).toBe(false);
41
+ });
42
+
43
+ it('detects breaking changes when major version differs (large jump)', () => {
44
+ const result = detectBreakingChanges('1.0.0', '3.0.0');
45
+ expect(result.hasBreakingChanges).toBe(true);
46
+ expect(result.hasMultipleReleases).toBe(false);
47
+ });
48
+
49
+ it('does not flag hasMultipleReleases when major differs', () => {
50
+ // Even though minor jumped by 2+, breaking change takes priority
51
+ const result = detectBreakingChanges('1.0.0', '2.5.0');
52
+ expect(result.hasBreakingChanges).toBe(true);
53
+ expect(result.hasMultipleReleases).toBe(false);
54
+ });
55
+
56
+ it('detects multiple releases when minor jumps by 2+', () => {
57
+ const result = detectBreakingChanges('1.0.0', '1.2.0');
58
+ expect(result.hasBreakingChanges).toBe(false);
59
+ expect(result.hasMultipleReleases).toBe(true);
60
+ });
61
+
62
+ it('detects multiple releases when minor jumps by 5', () => {
63
+ const result = detectBreakingChanges('1.1.0', '1.6.0');
64
+ expect(result.hasBreakingChanges).toBe(false);
65
+ expect(result.hasMultipleReleases).toBe(true);
66
+ });
67
+
68
+ it('returns no warnings for identical versions', () => {
69
+ const result = detectBreakingChanges('1.2.3', '1.2.3');
70
+ expect(result.hasBreakingChanges).toBe(false);
71
+ expect(result.hasMultipleReleases).toBe(false);
72
+ });
73
+
74
+ it('returns no warnings for patch-only bumps', () => {
75
+ const result = detectBreakingChanges('1.2.0', '1.2.5');
76
+ expect(result.hasBreakingChanges).toBe(false);
77
+ expect(result.hasMultipleReleases).toBe(false);
78
+ });
79
+
80
+ it('handles 0.x versions correctly', () => {
81
+ const result = detectBreakingChanges('0.1.0', '0.3.0');
82
+ expect(result.hasBreakingChanges).toBe(false);
83
+ expect(result.hasMultipleReleases).toBe(true);
84
+ });
85
+
86
+ it('detects breaking change from 0.x to 1.x', () => {
87
+ const result = detectBreakingChanges('0.9.0', '1.0.0');
88
+ expect(result.hasBreakingChanges).toBe(true);
89
+ expect(result.hasMultipleReleases).toBe(false);
90
+ });
91
+ });
@@ -21,6 +21,58 @@ interface CacheEntry {
21
21
  latestVersion: string | null;
22
22
  }
23
23
 
24
+ export interface UpdateInfo {
25
+ hasUpdate: boolean;
26
+ currentVersion: string;
27
+ latestVersion: string | null;
28
+ changelogUrl: string | null;
29
+ hasBreakingChanges: boolean;
30
+ hasMultipleReleases: boolean;
31
+ }
32
+
33
+ const CHANGELOG_BASE_URL = 'https://github.com/adrozdenko/soleri/releases/tag';
34
+
35
+ /**
36
+ * Build a changelog URL for a given version.
37
+ */
38
+ export function buildChangelogUrl(version: string): string {
39
+ return `${CHANGELOG_BASE_URL}/v${version}`;
40
+ }
41
+
42
+ /**
43
+ * Parse the major version from a semver string (x.y.z).
44
+ */
45
+ function parseMajor(version: string): number {
46
+ return Number(version.split('.')[0]) || 0;
47
+ }
48
+
49
+ /**
50
+ * Parse the minor version from a semver string (x.y.z).
51
+ */
52
+ function parseMinor(version: string): number {
53
+ return Number(version.split('.')[1]) || 0;
54
+ }
55
+
56
+ /**
57
+ * Detect breaking changes (major version bump) and multi-release jumps (minor +2).
58
+ */
59
+ export function detectBreakingChanges(
60
+ currentVersion: string,
61
+ latestVersion: string,
62
+ ): { hasBreakingChanges: boolean; hasMultipleReleases: boolean } {
63
+ const currentMajor = parseMajor(currentVersion);
64
+ const latestMajor = parseMajor(latestVersion);
65
+ const hasBreakingChanges = latestMajor !== currentMajor;
66
+
67
+ const currentMinor = parseMinor(currentVersion);
68
+ const latestMinor = parseMinor(latestVersion);
69
+ // Multiple releases: same major but minor jumped by 2+
70
+ const hasMultipleReleases =
71
+ !hasBreakingChanges && latestMajor === currentMajor && latestMinor - currentMinor >= 2;
72
+
73
+ return { hasBreakingChanges, hasMultipleReleases };
74
+ }
75
+
24
76
  function readCache(): CacheEntry | null {
25
77
  try {
26
78
  if (!existsSync(CACHE_FILE)) return null;
@@ -75,10 +127,7 @@ export async function checkForUpdate(agentId: string, currentVersion: string): P
75
127
  if (cache && Date.now() - cache.checkedAt < CHECK_INTERVAL_MS) {
76
128
  // Use cached result
77
129
  if (cache.latestVersion && isNewer(currentVersion, cache.latestVersion)) {
78
- console.error(
79
- `${tag} Update available: @soleri/core ${currentVersion} → ${cache.latestVersion}`,
80
- );
81
- console.error(`${tag} Run: soleri agent update`);
130
+ emitUpdateNotifications(tag, currentVersion, cache.latestVersion);
82
131
  }
83
132
  return;
84
133
  }
@@ -101,11 +150,32 @@ export async function checkForUpdate(agentId: string, currentVersion: string): P
101
150
  writeCache({ checkedAt: Date.now(), latestVersion });
102
151
 
103
152
  if (latestVersion && isNewer(currentVersion, latestVersion)) {
104
- console.error(`${tag} Update available: @soleri/core ${currentVersion} → ${latestVersion}`);
105
- console.error(`${tag} Run: soleri agent update`);
153
+ emitUpdateNotifications(tag, currentVersion, latestVersion);
106
154
  }
107
155
  } catch {
108
156
  // Network error, timeout, etc. — fail silently
109
157
  writeCache({ checkedAt: Date.now(), latestVersion: null });
110
158
  }
111
159
  }
160
+
161
+ /**
162
+ * Emit update notification messages to stderr, including changelog URL
163
+ * and breaking change / multi-release warnings.
164
+ */
165
+ function emitUpdateNotifications(tag: string, currentVersion: string, latestVersion: string): void {
166
+ console.error(`${tag} Update available: @soleri/core ${currentVersion} → ${latestVersion}`);
167
+ console.error(`${tag} Run: soleri agent update`);
168
+ console.error(`${tag} Changelog: ${buildChangelogUrl(latestVersion)}`);
169
+
170
+ const { hasBreakingChanges, hasMultipleReleases } = detectBreakingChanges(
171
+ currentVersion,
172
+ latestVersion,
173
+ );
174
+
175
+ if (hasBreakingChanges) {
176
+ const latestMajor = latestVersion.split('.')[0];
177
+ console.error(`${tag} ⚠ Breaking changes in v${latestMajor}.0.0 — see migration guide`);
178
+ } else if (hasMultipleReleases) {
179
+ console.error(`${tag} Multiple releases since your version — review changelog`);
180
+ }
181
+ }