@soleri/core 9.6.0 → 9.7.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 (80) hide show
  1. package/dist/index.d.ts +8 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +6 -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/session/compaction-evaluator.d.ts +20 -0
  30. package/dist/session/compaction-evaluator.d.ts.map +1 -0
  31. package/dist/session/compaction-evaluator.js +73 -0
  32. package/dist/session/compaction-evaluator.js.map +1 -0
  33. package/dist/session/compaction-policy.d.ts +50 -0
  34. package/dist/session/compaction-policy.d.ts.map +1 -0
  35. package/dist/session/compaction-policy.js +17 -0
  36. package/dist/session/compaction-policy.js.map +1 -0
  37. package/dist/session/handoff-renderer.d.ts +22 -0
  38. package/dist/session/handoff-renderer.d.ts.map +1 -0
  39. package/dist/session/handoff-renderer.js +49 -0
  40. package/dist/session/handoff-renderer.js.map +1 -0
  41. package/dist/session/index.d.ts +6 -0
  42. package/dist/session/index.d.ts.map +1 -0
  43. package/dist/session/index.js +5 -0
  44. package/dist/session/index.js.map +1 -0
  45. package/dist/session/policy-resolver.d.ts +20 -0
  46. package/dist/session/policy-resolver.d.ts.map +1 -0
  47. package/dist/session/policy-resolver.js +28 -0
  48. package/dist/session/policy-resolver.js.map +1 -0
  49. package/dist/skills/sync-skills.d.ts +27 -0
  50. package/dist/skills/sync-skills.d.ts.map +1 -1
  51. package/dist/skills/sync-skills.js +92 -1
  52. package/dist/skills/sync-skills.js.map +1 -1
  53. package/dist/skills/trust-classifier.d.ts +32 -0
  54. package/dist/skills/trust-classifier.d.ts.map +1 -0
  55. package/dist/skills/trust-classifier.js +109 -0
  56. package/dist/skills/trust-classifier.js.map +1 -0
  57. package/dist/subagent/dispatcher.d.ts +4 -0
  58. package/dist/subagent/dispatcher.d.ts.map +1 -1
  59. package/dist/subagent/dispatcher.js +14 -2
  60. package/dist/subagent/dispatcher.js.map +1 -1
  61. package/package.json +1 -4
  62. package/src/index.ts +40 -0
  63. package/src/packs/index.ts +4 -0
  64. package/src/packs/types.ts +32 -0
  65. package/src/planning/github-projection.ts +6 -0
  66. package/src/planning/goal-ancestry.test.ts +427 -0
  67. package/src/planning/goal-ancestry.ts +187 -0
  68. package/src/planning/plan-lifecycle.ts +3 -0
  69. package/src/planning/planner-types.ts +2 -0
  70. package/src/runtime/context-health.ts +42 -2
  71. package/src/session/compaction-evaluator.ts +87 -0
  72. package/src/session/compaction-policy.ts +66 -0
  73. package/src/session/compaction.test.ts +259 -0
  74. package/src/session/handoff-renderer.ts +56 -0
  75. package/src/session/index.ts +12 -0
  76. package/src/session/policy-resolver.ts +34 -0
  77. package/src/skills/sync-skills.ts +114 -1
  78. package/src/skills/trust-classifier.test.ts +252 -0
  79. package/src/skills/trust-classifier.ts +127 -0
  80. package/src/subagent/dispatcher.ts +18 -2
package/src/index.ts CHANGED
@@ -366,6 +366,16 @@ export {
366
366
  } from './planning/gap-types.js';
367
367
  export type { GapSeverity, GapCategory, PlanGap } from './planning/gap-types.js';
368
368
 
369
+ // ─── Goal Ancestry ──────────────────────────────────────────────────
370
+ export { GoalAncestry, JsonGoalRepository, generateGoalId } from './planning/goal-ancestry.js';
371
+ export type {
372
+ GoalLevel,
373
+ GoalStatus,
374
+ Goal,
375
+ GoalStore,
376
+ GoalRepository,
377
+ } from './planning/goal-ancestry.js';
378
+
369
379
  // ─── Task Complexity Assessor ────────────────────────────────────────
370
380
  export { assessTaskComplexity } from './planning/task-complexity-assessor.js';
371
381
  export type {
@@ -670,8 +680,23 @@ export type {
670
680
  LockfileData,
671
681
  ResolvedPack,
672
682
  ResolveOptions,
683
+ TrustLevel,
684
+ SourceType,
685
+ SkillInventoryItem,
686
+ SkillMetadata,
673
687
  } from './packs/index.js';
674
688
 
689
+ // ─── Skill Trust & Sync ─────────────────────────────────────────────────
690
+ export { classifyTrust, TrustClassifier } from './skills/trust-classifier.js';
691
+ export {
692
+ discoverSkills,
693
+ syncSkillsToClaudeCode,
694
+ classifySkills,
695
+ checkSkillCompatibility,
696
+ ApprovalRequiredError,
697
+ } from './skills/sync-skills.js';
698
+ export type { SkillEntry, SyncResult, ClassifySkillsOptions } from './skills/sync-skills.js';
699
+
675
700
  // ─── Plugin System ──────────────────────────────────────────────────────
676
701
  export {
677
702
  PluginRegistry,
@@ -840,6 +865,21 @@ export type {
840
865
  OperatorProfileHistory,
841
866
  } from './operator/operator-types.js';
842
867
 
868
+ // ─── Session Compaction ─────────────────────────────────────────────
869
+ export type {
870
+ CompactionPolicy,
871
+ CompactionResult,
872
+ SessionState,
873
+ HandoffNote,
874
+ } from './session/index.js';
875
+ export {
876
+ ENGINE_DEFAULTS,
877
+ shouldCompact,
878
+ parseDuration,
879
+ resolvePolicy,
880
+ renderHandoff,
881
+ } from './session/index.js';
882
+
843
883
  // ─── Operator Context (Adaptive Persona) ────────────────────────────
844
884
  export { OperatorContextStore } from './operator/operator-context-store.js';
845
885
  export { DECLINED_CATEGORIES } from './operator/operator-context-types.js';
@@ -14,6 +14,10 @@ export {
14
14
  type InstalledPack,
15
15
  type InstallResult,
16
16
  type ValidateResult,
17
+ type TrustLevel,
18
+ type SourceType,
19
+ type SkillInventoryItem,
20
+ type SkillMetadata,
17
21
  } from './types.js';
18
22
 
19
23
  export { PackInstaller } from './pack-installer.js';
@@ -19,6 +19,38 @@
19
19
 
20
20
  import { z } from 'zod';
21
21
 
22
+ // =============================================================================
23
+ // SKILL TRUST & SOURCE TRACKING
24
+ // =============================================================================
25
+
26
+ /** How much a skill can do — escalates from pure docs to executable code */
27
+ export type TrustLevel = 'markdown_only' | 'assets' | 'scripts';
28
+
29
+ /** Where a skill came from */
30
+ export type SourceType = 'builtin' | 'pack' | 'local' | 'github' | 'npm';
31
+
32
+ /** A single file in a skill directory, classified by kind */
33
+ export interface SkillInventoryItem {
34
+ /** Relative path within the skill directory */
35
+ path: string;
36
+ /** What kind of file this is */
37
+ kind: 'skill' | 'reference' | 'asset' | 'script';
38
+ }
39
+
40
+ /** Extended metadata for a discovered skill */
41
+ export interface SkillMetadata {
42
+ /** Computed trust level based on directory contents */
43
+ trust: TrustLevel;
44
+ /** Where this skill was sourced from */
45
+ source: { type: SourceType; uri: string };
46
+ /** Engine version compatibility check result */
47
+ compatibility: 'compatible' | 'unknown' | 'invalid';
48
+ /** Engine version declared by the skill (if any) */
49
+ engineVersion?: string;
50
+ /** Classified inventory of all files in the skill directory */
51
+ inventory: SkillInventoryItem[];
52
+ }
53
+
22
54
  // =============================================================================
23
55
  // MANIFEST SCHEMA
24
56
  // =============================================================================
@@ -333,6 +333,7 @@ export function formatIssueBody(
333
333
  plan: PlanMetadataForIssue,
334
334
  taskTitle: string,
335
335
  taskDescription: string,
336
+ options?: { goalContext?: string },
336
337
  ): string {
337
338
  const lines: string[] = [];
338
339
 
@@ -341,6 +342,11 @@ export function formatIssueBody(
341
342
  lines.push(`**Objective:** ${plan.objective}`);
342
343
  lines.push('');
343
344
 
345
+ if (options?.goalContext) {
346
+ lines.push(options.goalContext);
347
+ lines.push('');
348
+ }
349
+
344
350
  if (plan.decisions.length > 0) {
345
351
  lines.push('## Decisions');
346
352
  for (const d of plan.decisions) {
@@ -0,0 +1,427 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdirSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { GoalAncestry, JsonGoalRepository, generateGoalId } from './goal-ancestry.js';
6
+ import type { Goal, GoalRepository } from './goal-ancestry.js';
7
+ import { Planner } from './planner.js';
8
+ import { formatIssueBody } from './github-projection.js';
9
+
10
+ // ─── In-memory repository for unit tests ──────────────────────────
11
+
12
+ class InMemoryGoalRepository implements GoalRepository {
13
+ private goals: Goal[] = [];
14
+
15
+ getById(id: string): Goal | null {
16
+ return this.goals.find((g) => g.id === id) ?? null;
17
+ }
18
+
19
+ getByParentId(parentId: string): Goal[] {
20
+ return this.goals.filter((g) => g.parentId === parentId);
21
+ }
22
+
23
+ create(goal: Omit<Goal, 'createdAt' | 'updatedAt'>): Goal {
24
+ const now = Date.now();
25
+ const full: Goal = { ...goal, createdAt: now, updatedAt: now };
26
+ this.goals.push(full);
27
+ return full;
28
+ }
29
+
30
+ updateStatus(id: string, status: Goal['status']): Goal {
31
+ const goal = this.getById(id);
32
+ if (!goal) throw new Error(`Goal not found: ${id}`);
33
+ goal.status = status;
34
+ goal.updatedAt = Date.now();
35
+ return goal;
36
+ }
37
+
38
+ list(): Goal[] {
39
+ return [...this.goals];
40
+ }
41
+
42
+ /** Test helper — seed a goal directly */
43
+ seed(goal: Goal): void {
44
+ this.goals.push(goal);
45
+ }
46
+ }
47
+
48
+ describe('GoalAncestry', () => {
49
+ let repo: InMemoryGoalRepository;
50
+ let ancestry: GoalAncestry;
51
+
52
+ beforeEach(() => {
53
+ repo = new InMemoryGoalRepository();
54
+ ancestry = new GoalAncestry(repo);
55
+ });
56
+
57
+ describe('getAncestors', () => {
58
+ it('should return empty array for goal with no parent', () => {
59
+ repo.seed({
60
+ id: 'g1',
61
+ title: 'Root',
62
+ level: 'objective',
63
+ status: 'active',
64
+ createdAt: Date.now(),
65
+ updatedAt: Date.now(),
66
+ });
67
+
68
+ const ancestors = ancestry.getAncestors('g1');
69
+ expect(ancestors).toEqual([]);
70
+ });
71
+
72
+ it('should return one ancestor for 1-level depth', () => {
73
+ repo.seed({
74
+ id: 'root',
75
+ title: 'Root Objective',
76
+ level: 'objective',
77
+ status: 'active',
78
+ createdAt: Date.now(),
79
+ updatedAt: Date.now(),
80
+ });
81
+ repo.seed({
82
+ id: 'child',
83
+ title: 'Project A',
84
+ level: 'project',
85
+ parentId: 'root',
86
+ status: 'active',
87
+ createdAt: Date.now(),
88
+ updatedAt: Date.now(),
89
+ });
90
+
91
+ const ancestors = ancestry.getAncestors('child');
92
+ expect(ancestors).toHaveLength(1);
93
+ expect(ancestors[0].id).toBe('root');
94
+ });
95
+
96
+ it('should walk 3 levels of ancestry', () => {
97
+ repo.seed({
98
+ id: 'obj',
99
+ title: 'Objective',
100
+ level: 'objective',
101
+ status: 'active',
102
+ createdAt: 1,
103
+ updatedAt: 1,
104
+ });
105
+ repo.seed({
106
+ id: 'proj',
107
+ title: 'Project',
108
+ level: 'project',
109
+ parentId: 'obj',
110
+ status: 'active',
111
+ createdAt: 2,
112
+ updatedAt: 2,
113
+ });
114
+ repo.seed({
115
+ id: 'plan',
116
+ title: 'Plan',
117
+ level: 'plan',
118
+ parentId: 'proj',
119
+ status: 'active',
120
+ createdAt: 3,
121
+ updatedAt: 3,
122
+ });
123
+ repo.seed({
124
+ id: 'task',
125
+ title: 'Task',
126
+ level: 'task',
127
+ parentId: 'plan',
128
+ status: 'planned',
129
+ createdAt: 4,
130
+ updatedAt: 4,
131
+ });
132
+
133
+ const ancestors = ancestry.getAncestors('task');
134
+ expect(ancestors).toHaveLength(3);
135
+ // Closest first: plan, proj, obj
136
+ expect(ancestors[0].id).toBe('plan');
137
+ expect(ancestors[1].id).toBe('proj');
138
+ expect(ancestors[2].id).toBe('obj');
139
+ });
140
+
141
+ it('should stop at max 10 levels', () => {
142
+ // Build a chain of 12 goals
143
+ for (let i = 0; i < 12; i++) {
144
+ repo.seed({
145
+ id: `g${i}`,
146
+ title: `Goal ${i}`,
147
+ level: 'project',
148
+ parentId: i > 0 ? `g${i - 1}` : undefined,
149
+ status: 'active',
150
+ createdAt: i,
151
+ updatedAt: i,
152
+ });
153
+ }
154
+
155
+ const ancestors = ancestry.getAncestors('g11');
156
+ expect(ancestors.length).toBeLessThanOrEqual(10);
157
+ });
158
+
159
+ it('should throw on cycle detection', () => {
160
+ repo.seed({
161
+ id: 'a',
162
+ title: 'A',
163
+ level: 'project',
164
+ parentId: 'b',
165
+ status: 'active',
166
+ createdAt: 1,
167
+ updatedAt: 1,
168
+ });
169
+ repo.seed({
170
+ id: 'b',
171
+ title: 'B',
172
+ level: 'project',
173
+ parentId: 'a',
174
+ status: 'active',
175
+ createdAt: 2,
176
+ updatedAt: 2,
177
+ });
178
+
179
+ expect(() => ancestry.getAncestors('a')).toThrow(/[Cc]ycle/);
180
+ });
181
+
182
+ it('should return empty for nonexistent goal', () => {
183
+ const ancestors = ancestry.getAncestors('nonexistent');
184
+ expect(ancestors).toEqual([]);
185
+ });
186
+ });
187
+
188
+ describe('getContext', () => {
189
+ it('should render markdown hierarchy from root to current', () => {
190
+ repo.seed({
191
+ id: 'obj',
192
+ title: 'Ship v2',
193
+ level: 'objective',
194
+ status: 'active',
195
+ createdAt: 1,
196
+ updatedAt: 1,
197
+ });
198
+ repo.seed({
199
+ id: 'proj',
200
+ title: 'Auth System',
201
+ level: 'project',
202
+ parentId: 'obj',
203
+ status: 'active',
204
+ createdAt: 2,
205
+ updatedAt: 2,
206
+ });
207
+ repo.seed({
208
+ id: 'plan',
209
+ title: 'JWT Implementation',
210
+ level: 'plan',
211
+ parentId: 'proj',
212
+ status: 'active',
213
+ createdAt: 3,
214
+ updatedAt: 3,
215
+ });
216
+
217
+ const md = ancestry.getContext('plan');
218
+ expect(md).toContain('## Goal Context');
219
+ expect(md).toContain('[objective] Ship v2');
220
+ expect(md).toContain('[project] Auth System');
221
+ expect(md).toContain('[plan] JWT Implementation');
222
+ });
223
+
224
+ it('should return empty string for nonexistent goal', () => {
225
+ expect(ancestry.getContext('nope')).toBe('');
226
+ });
227
+
228
+ it('should mark current goal with bold arrow', () => {
229
+ repo.seed({
230
+ id: 'obj',
231
+ title: 'Objective',
232
+ level: 'objective',
233
+ status: 'active',
234
+ createdAt: 1,
235
+ updatedAt: 1,
236
+ });
237
+ const md = ancestry.getContext('obj');
238
+ expect(md).toContain('**→**');
239
+ });
240
+ });
241
+
242
+ describe('inject', () => {
243
+ it('should add goalAncestry to config', () => {
244
+ repo.seed({
245
+ id: 'obj',
246
+ title: 'Ship v2',
247
+ level: 'objective',
248
+ status: 'active',
249
+ createdAt: 1,
250
+ updatedAt: 1,
251
+ });
252
+ repo.seed({
253
+ id: 'task',
254
+ title: 'Do thing',
255
+ level: 'task',
256
+ parentId: 'obj',
257
+ status: 'planned',
258
+ createdAt: 2,
259
+ updatedAt: 2,
260
+ });
261
+
262
+ const ctx = { config: { timeout: 5000 } };
263
+ const enriched = ancestry.inject(ctx, 'task');
264
+ expect(enriched.config?.goalAncestry).toContain('## Goal Context');
265
+ expect(enriched.config?.timeout).toBe(5000);
266
+ });
267
+
268
+ it('should return original context if goal not found', () => {
269
+ const ctx = { config: { foo: 'bar' } };
270
+ const result = ancestry.inject(ctx, 'nonexistent');
271
+ expect(result).toEqual(ctx);
272
+ });
273
+ });
274
+ });
275
+
276
+ describe('JsonGoalRepository', () => {
277
+ let tempDir: string;
278
+ let repo: JsonGoalRepository;
279
+
280
+ beforeEach(() => {
281
+ tempDir = join(tmpdir(), `goal-repo-test-${Date.now()}`);
282
+ mkdirSync(tempDir, { recursive: true });
283
+ repo = new JsonGoalRepository(join(tempDir, 'goals.json'));
284
+ });
285
+
286
+ afterEach(() => {
287
+ rmSync(tempDir, { recursive: true, force: true });
288
+ });
289
+
290
+ it('should create and retrieve a goal', () => {
291
+ const goal = repo.create({ id: 'g1', title: 'Ship it', level: 'objective', status: 'planned' });
292
+ expect(goal.createdAt).toBeGreaterThan(0);
293
+ expect(repo.getById('g1')?.title).toBe('Ship it');
294
+ });
295
+
296
+ it('should list goals by parent', () => {
297
+ repo.create({ id: 'parent', title: 'Parent', level: 'objective', status: 'active' });
298
+ repo.create({
299
+ id: 'child1',
300
+ title: 'Child 1',
301
+ level: 'project',
302
+ parentId: 'parent',
303
+ status: 'planned',
304
+ });
305
+ repo.create({
306
+ id: 'child2',
307
+ title: 'Child 2',
308
+ level: 'project',
309
+ parentId: 'parent',
310
+ status: 'planned',
311
+ });
312
+
313
+ const children = repo.getByParentId('parent');
314
+ expect(children).toHaveLength(2);
315
+ });
316
+
317
+ it('should update status', () => {
318
+ repo.create({ id: 'g1', title: 'Goal', level: 'plan', status: 'planned' });
319
+ const updated = repo.updateStatus('g1', 'completed');
320
+ expect(updated.status).toBe('completed');
321
+ expect(repo.getById('g1')?.status).toBe('completed');
322
+ });
323
+
324
+ it('should throw when updating nonexistent goal', () => {
325
+ expect(() => repo.updateStatus('nope', 'active')).toThrow(/not found/);
326
+ });
327
+
328
+ it('should persist across instances', () => {
329
+ const filePath = join(tempDir, 'goals.json');
330
+ const repo1 = new JsonGoalRepository(filePath);
331
+ repo1.create({ id: 'g1', title: 'Persisted', level: 'objective', status: 'active' });
332
+
333
+ const repo2 = new JsonGoalRepository(filePath);
334
+ expect(repo2.getById('g1')?.title).toBe('Persisted');
335
+ });
336
+ });
337
+
338
+ describe('generateGoalId', () => {
339
+ it('should include the level in the ID', () => {
340
+ expect(generateGoalId('objective')).toMatch(/^goal-objective-/);
341
+ expect(generateGoalId('task')).toMatch(/^goal-task-/);
342
+ });
343
+ });
344
+
345
+ describe('Planner goalId integration', () => {
346
+ let tempDir: string;
347
+ let planner: Planner;
348
+
349
+ beforeEach(() => {
350
+ tempDir = join(tmpdir(), `planner-goal-test-${Date.now()}`);
351
+ mkdirSync(tempDir, { recursive: true });
352
+ planner = new Planner(join(tempDir, 'plans.json'));
353
+ });
354
+
355
+ afterEach(() => {
356
+ rmSync(tempDir, { recursive: true, force: true });
357
+ });
358
+
359
+ it('should store goalId on created plan', () => {
360
+ const plan = planner.create({
361
+ objective: 'Add auth',
362
+ scope: 'backend',
363
+ goalId: 'goal-plan-123',
364
+ });
365
+ expect(plan.goalId).toBe('goal-plan-123');
366
+ });
367
+
368
+ it('should create plan without goalId (backward compat)', () => {
369
+ const plan = planner.create({ objective: 'Add auth', scope: 'backend' });
370
+ expect(plan.goalId).toBeUndefined();
371
+ });
372
+
373
+ it('should preserve goalId through split', () => {
374
+ const plan = planner.create({
375
+ objective: 'Add auth',
376
+ scope: 'backend',
377
+ goalId: 'goal-plan-456',
378
+ });
379
+
380
+ planner.splitTasks(plan.id, [
381
+ { title: 'JWT', description: 'Implement JWT' },
382
+ { title: 'Middleware', description: 'Auth middleware' },
383
+ ]);
384
+
385
+ const updated = planner.get(plan.id)!;
386
+ expect(updated.goalId).toBe('goal-plan-456');
387
+ expect(updated.tasks).toHaveLength(2);
388
+ });
389
+ });
390
+
391
+ describe('formatIssueBody with goal context', () => {
392
+ it('should include goal context section when provided', () => {
393
+ const body = formatIssueBody(
394
+ {
395
+ planId: 'plan-1',
396
+ grade: 'A',
397
+ score: 92,
398
+ objective: 'Build auth',
399
+ decisions: [],
400
+ tasks: [{ id: 'task-1', title: 'JWT', description: 'Implement JWT' }],
401
+ },
402
+ 'JWT',
403
+ 'Implement JWT tokens',
404
+ { goalContext: '## Goal Context\n\n- [objective] Ship v2 (active)' },
405
+ );
406
+
407
+ expect(body).toContain('## Goal Context');
408
+ expect(body).toContain('[objective] Ship v2');
409
+ });
410
+
411
+ it('should not include section when no goal context', () => {
412
+ const body = formatIssueBody(
413
+ {
414
+ planId: 'plan-1',
415
+ grade: 'A',
416
+ score: 92,
417
+ objective: 'Build auth',
418
+ decisions: [],
419
+ tasks: [],
420
+ },
421
+ 'JWT',
422
+ 'Implement JWT tokens',
423
+ );
424
+
425
+ expect(body).not.toContain('## Goal Context');
426
+ });
427
+ });