@soleri/core 9.5.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 (249) hide show
  1. package/dist/adapters/claude-code-adapter.d.ts +27 -0
  2. package/dist/adapters/claude-code-adapter.d.ts.map +1 -0
  3. package/dist/adapters/claude-code-adapter.js +111 -0
  4. package/dist/adapters/claude-code-adapter.js.map +1 -0
  5. package/dist/adapters/index.d.ts +9 -0
  6. package/dist/adapters/index.d.ts.map +1 -0
  7. package/dist/adapters/index.js +10 -0
  8. package/dist/adapters/index.js.map +1 -0
  9. package/dist/adapters/registry.d.ts +21 -0
  10. package/dist/adapters/registry.d.ts.map +1 -0
  11. package/dist/adapters/registry.js +44 -0
  12. package/dist/adapters/registry.js.map +1 -0
  13. package/dist/adapters/types.d.ts +93 -0
  14. package/dist/adapters/types.d.ts.map +1 -0
  15. package/dist/adapters/types.js +10 -0
  16. package/dist/adapters/types.js.map +1 -0
  17. package/dist/brain/brain.d.ts +12 -1
  18. package/dist/brain/brain.d.ts.map +1 -1
  19. package/dist/brain/brain.js +106 -44
  20. package/dist/brain/brain.js.map +1 -1
  21. package/dist/brain/intelligence.d.ts.map +1 -1
  22. package/dist/brain/intelligence.js +36 -30
  23. package/dist/brain/intelligence.js.map +1 -1
  24. package/dist/chat/agent-loop.js +1 -1
  25. package/dist/chat/agent-loop.js.map +1 -1
  26. package/dist/chat/notifications.d.ts.map +1 -1
  27. package/dist/chat/notifications.js +4 -0
  28. package/dist/chat/notifications.js.map +1 -1
  29. package/dist/control/intent-router.d.ts +1 -0
  30. package/dist/control/intent-router.d.ts.map +1 -1
  31. package/dist/control/intent-router.js +11 -5
  32. package/dist/control/intent-router.js.map +1 -1
  33. package/dist/curator/curator.d.ts +4 -0
  34. package/dist/curator/curator.d.ts.map +1 -1
  35. package/dist/curator/curator.js +141 -27
  36. package/dist/curator/curator.js.map +1 -1
  37. package/dist/index.d.ts +22 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +18 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/llm/llm-client.d.ts.map +1 -1
  42. package/dist/llm/llm-client.js +1 -0
  43. package/dist/llm/llm-client.js.map +1 -1
  44. package/dist/packs/index.d.ts +3 -2
  45. package/dist/packs/index.d.ts.map +1 -1
  46. package/dist/packs/index.js +3 -2
  47. package/dist/packs/index.js.map +1 -1
  48. package/dist/packs/lockfile.d.ts +23 -1
  49. package/dist/packs/lockfile.d.ts.map +1 -1
  50. package/dist/packs/lockfile.js +50 -4
  51. package/dist/packs/lockfile.js.map +1 -1
  52. package/dist/packs/pack-installer.d.ts +10 -0
  53. package/dist/packs/pack-installer.d.ts.map +1 -1
  54. package/dist/packs/pack-installer.js +69 -2
  55. package/dist/packs/pack-installer.js.map +1 -1
  56. package/dist/packs/pack-lifecycle.d.ts +50 -0
  57. package/dist/packs/pack-lifecycle.d.ts.map +1 -0
  58. package/dist/packs/pack-lifecycle.js +91 -0
  59. package/dist/packs/pack-lifecycle.js.map +1 -0
  60. package/dist/packs/types.d.ts +76 -29
  61. package/dist/packs/types.d.ts.map +1 -1
  62. package/dist/packs/types.js +9 -0
  63. package/dist/packs/types.js.map +1 -1
  64. package/dist/persistence/sqlite-provider.d.ts +5 -1
  65. package/dist/persistence/sqlite-provider.d.ts.map +1 -1
  66. package/dist/persistence/sqlite-provider.js +22 -2
  67. package/dist/persistence/sqlite-provider.js.map +1 -1
  68. package/dist/planning/github-projection.d.ts +11 -9
  69. package/dist/planning/github-projection.d.ts.map +1 -1
  70. package/dist/planning/github-projection.js +47 -43
  71. package/dist/planning/github-projection.js.map +1 -1
  72. package/dist/planning/goal-ancestry.d.ts +72 -0
  73. package/dist/planning/goal-ancestry.d.ts.map +1 -0
  74. package/dist/planning/goal-ancestry.js +137 -0
  75. package/dist/planning/goal-ancestry.js.map +1 -0
  76. package/dist/planning/plan-lifecycle.d.ts +2 -0
  77. package/dist/planning/plan-lifecycle.d.ts.map +1 -1
  78. package/dist/planning/plan-lifecycle.js +1 -0
  79. package/dist/planning/plan-lifecycle.js.map +1 -1
  80. package/dist/planning/planner-types.d.ts +2 -0
  81. package/dist/planning/planner-types.d.ts.map +1 -1
  82. package/dist/plugins/types.d.ts +21 -21
  83. package/dist/queue/pipeline-runner.d.ts.map +1 -1
  84. package/dist/queue/pipeline-runner.js +4 -0
  85. package/dist/queue/pipeline-runner.js.map +1 -1
  86. package/dist/runtime/context-health.d.ts +14 -1
  87. package/dist/runtime/context-health.d.ts.map +1 -1
  88. package/dist/runtime/context-health.js +30 -2
  89. package/dist/runtime/context-health.js.map +1 -1
  90. package/dist/runtime/curator-extra-ops.d.ts.map +1 -1
  91. package/dist/runtime/curator-extra-ops.js +9 -1
  92. package/dist/runtime/curator-extra-ops.js.map +1 -1
  93. package/dist/runtime/facades/memory-facade.d.ts.map +1 -1
  94. package/dist/runtime/facades/memory-facade.js +169 -0
  95. package/dist/runtime/facades/memory-facade.js.map +1 -1
  96. package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
  97. package/dist/runtime/orchestrate-ops.js +133 -4
  98. package/dist/runtime/orchestrate-ops.js.map +1 -1
  99. package/dist/runtime/runtime.d.ts.map +1 -1
  100. package/dist/runtime/runtime.js +128 -90
  101. package/dist/runtime/runtime.js.map +1 -1
  102. package/dist/runtime/session-briefing.d.ts.map +1 -1
  103. package/dist/runtime/session-briefing.js +44 -11
  104. package/dist/runtime/session-briefing.js.map +1 -1
  105. package/dist/runtime/shutdown-registry.d.ts +36 -0
  106. package/dist/runtime/shutdown-registry.d.ts.map +1 -0
  107. package/dist/runtime/shutdown-registry.js +74 -0
  108. package/dist/runtime/shutdown-registry.js.map +1 -0
  109. package/dist/runtime/types.d.ts +10 -1
  110. package/dist/runtime/types.d.ts.map +1 -1
  111. package/dist/session/compaction-evaluator.d.ts +20 -0
  112. package/dist/session/compaction-evaluator.d.ts.map +1 -0
  113. package/dist/session/compaction-evaluator.js +73 -0
  114. package/dist/session/compaction-evaluator.js.map +1 -0
  115. package/dist/session/compaction-policy.d.ts +50 -0
  116. package/dist/session/compaction-policy.d.ts.map +1 -0
  117. package/dist/session/compaction-policy.js +17 -0
  118. package/dist/session/compaction-policy.js.map +1 -0
  119. package/dist/session/handoff-renderer.d.ts +22 -0
  120. package/dist/session/handoff-renderer.d.ts.map +1 -0
  121. package/dist/session/handoff-renderer.js +49 -0
  122. package/dist/session/handoff-renderer.js.map +1 -0
  123. package/dist/session/index.d.ts +6 -0
  124. package/dist/session/index.d.ts.map +1 -0
  125. package/dist/session/index.js +5 -0
  126. package/dist/session/index.js.map +1 -0
  127. package/dist/session/policy-resolver.d.ts +20 -0
  128. package/dist/session/policy-resolver.d.ts.map +1 -0
  129. package/dist/session/policy-resolver.js +28 -0
  130. package/dist/session/policy-resolver.js.map +1 -0
  131. package/dist/skills/sync-skills.d.ts +27 -0
  132. package/dist/skills/sync-skills.d.ts.map +1 -1
  133. package/dist/skills/sync-skills.js +92 -1
  134. package/dist/skills/sync-skills.js.map +1 -1
  135. package/dist/skills/trust-classifier.d.ts +32 -0
  136. package/dist/skills/trust-classifier.d.ts.map +1 -0
  137. package/dist/skills/trust-classifier.js +109 -0
  138. package/dist/skills/trust-classifier.js.map +1 -0
  139. package/dist/subagent/concurrency-manager.d.ts +29 -0
  140. package/dist/subagent/concurrency-manager.d.ts.map +1 -0
  141. package/dist/subagent/concurrency-manager.js +73 -0
  142. package/dist/subagent/concurrency-manager.js.map +1 -0
  143. package/dist/subagent/dispatcher.d.ts +45 -0
  144. package/dist/subagent/dispatcher.d.ts.map +1 -0
  145. package/dist/subagent/dispatcher.js +271 -0
  146. package/dist/subagent/dispatcher.js.map +1 -0
  147. package/dist/subagent/index.d.ts +14 -0
  148. package/dist/subagent/index.d.ts.map +1 -0
  149. package/dist/subagent/index.js +15 -0
  150. package/dist/subagent/index.js.map +1 -0
  151. package/dist/subagent/orphan-reaper.d.ts +37 -0
  152. package/dist/subagent/orphan-reaper.d.ts.map +1 -0
  153. package/dist/subagent/orphan-reaper.js +71 -0
  154. package/dist/subagent/orphan-reaper.js.map +1 -0
  155. package/dist/subagent/result-aggregator.d.ts +7 -0
  156. package/dist/subagent/result-aggregator.d.ts.map +1 -0
  157. package/dist/subagent/result-aggregator.js +57 -0
  158. package/dist/subagent/result-aggregator.js.map +1 -0
  159. package/dist/subagent/task-checkout.d.ts +36 -0
  160. package/dist/subagent/task-checkout.d.ts.map +1 -0
  161. package/dist/subagent/task-checkout.js +52 -0
  162. package/dist/subagent/task-checkout.js.map +1 -0
  163. package/dist/subagent/types.d.ts +114 -0
  164. package/dist/subagent/types.d.ts.map +1 -0
  165. package/dist/subagent/types.js +9 -0
  166. package/dist/subagent/types.js.map +1 -0
  167. package/dist/subagent/workspace-resolver.d.ts +35 -0
  168. package/dist/subagent/workspace-resolver.d.ts.map +1 -0
  169. package/dist/subagent/workspace-resolver.js +99 -0
  170. package/dist/subagent/workspace-resolver.js.map +1 -0
  171. package/dist/transport/http-server.d.ts.map +1 -1
  172. package/dist/transport/http-server.js +49 -3
  173. package/dist/transport/http-server.js.map +1 -1
  174. package/dist/transport/ws-server.d.ts.map +1 -1
  175. package/dist/transport/ws-server.js +7 -0
  176. package/dist/transport/ws-server.js.map +1 -1
  177. package/dist/vault/linking.d.ts +3 -4
  178. package/dist/vault/linking.d.ts.map +1 -1
  179. package/dist/vault/linking.js +79 -32
  180. package/dist/vault/linking.js.map +1 -1
  181. package/dist/vault/vault-maintenance.d.ts.map +1 -1
  182. package/dist/vault/vault-maintenance.js +7 -14
  183. package/dist/vault/vault-maintenance.js.map +1 -1
  184. package/dist/vault/vault-memories.d.ts.map +1 -1
  185. package/dist/vault/vault-memories.js +19 -9
  186. package/dist/vault/vault-memories.js.map +1 -1
  187. package/dist/vault/vault-schema.d.ts +1 -0
  188. package/dist/vault/vault-schema.d.ts.map +1 -1
  189. package/dist/vault/vault-schema.js +20 -0
  190. package/dist/vault/vault-schema.js.map +1 -1
  191. package/dist/vault/vault.d.ts.map +1 -1
  192. package/dist/vault/vault.js +7 -3
  193. package/dist/vault/vault.js.map +1 -1
  194. package/package.json +5 -2
  195. package/src/__tests__/adapters/claude-code-adapter.test.ts +167 -0
  196. package/src/__tests__/adapters/registry.test.ts +100 -0
  197. package/src/__tests__/packs/pack-lifecycle.test.ts +379 -0
  198. package/src/__tests__/subagent/concurrency-manager.test.ts +132 -0
  199. package/src/__tests__/subagent/dispatcher.test.ts +195 -0
  200. package/src/__tests__/subagent/orphan-reaper.test.ts +141 -0
  201. package/src/__tests__/subagent/result-aggregator.test.ts +141 -0
  202. package/src/__tests__/subagent/task-checkout.test.ts +86 -0
  203. package/src/__tests__/subagent/workspace-resolver.test.ts +138 -0
  204. package/src/adapters/claude-code-adapter.ts +163 -0
  205. package/src/adapters/index.ts +22 -0
  206. package/src/adapters/registry.ts +53 -0
  207. package/src/adapters/types.ts +114 -0
  208. package/src/curator/curator.ts +1 -0
  209. package/src/index.ts +78 -1
  210. package/src/packs/index.ts +9 -1
  211. package/src/packs/lockfile.ts +70 -5
  212. package/src/packs/pack-installer.ts +78 -2
  213. package/src/packs/pack-lifecycle.ts +115 -0
  214. package/src/packs/pack-lockfile.test.ts +1 -1
  215. package/src/packs/pack-system.test.ts +1 -1
  216. package/src/packs/types.ts +72 -2
  217. package/src/persistence/sqlite-provider.ts +26 -2
  218. package/src/planning/github-projection.ts +6 -0
  219. package/src/planning/goal-ancestry.test.ts +427 -0
  220. package/src/planning/goal-ancestry.ts +187 -0
  221. package/src/planning/plan-lifecycle.ts +3 -0
  222. package/src/planning/planner-types.ts +2 -0
  223. package/src/runtime/admin-setup-ops.test.ts +9 -4
  224. package/src/runtime/context-health.ts +42 -2
  225. package/src/runtime/orchestrate-ops.ts +153 -1
  226. package/src/runtime/runtime.ts +15 -0
  227. package/src/runtime/session-briefing.test.ts +94 -2
  228. package/src/runtime/session-briefing.ts +48 -12
  229. package/src/runtime/types.ts +6 -0
  230. package/src/session/compaction-evaluator.ts +87 -0
  231. package/src/session/compaction-policy.ts +66 -0
  232. package/src/session/compaction.test.ts +259 -0
  233. package/src/session/handoff-renderer.ts +56 -0
  234. package/src/session/index.ts +12 -0
  235. package/src/session/policy-resolver.ts +34 -0
  236. package/src/skills/sync-skills.ts +114 -1
  237. package/src/skills/trust-classifier.test.ts +252 -0
  238. package/src/skills/trust-classifier.ts +127 -0
  239. package/src/subagent/concurrency-manager.ts +89 -0
  240. package/src/subagent/dispatcher.ts +342 -0
  241. package/src/subagent/index.ts +28 -0
  242. package/src/subagent/orphan-reaper.ts +82 -0
  243. package/src/subagent/result-aggregator.ts +66 -0
  244. package/src/subagent/task-checkout.ts +60 -0
  245. package/src/subagent/types.ts +138 -0
  246. package/src/subagent/workspace-resolver.ts +117 -0
  247. package/src/vault/vault-scaling.test.ts +3 -2
  248. package/vitest.config.ts +2 -0
  249. package/src/hooks/index.ts +0 -6
@@ -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
+ });
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Goal Ancestry — hierarchical goal tracking for plans and tasks.
3
+ *
4
+ * Goals form a tree: objective → project → plan → task.
5
+ * Each plan/task can reference its parent goal, enabling context
6
+ * to flow from high-level objectives down to individual work items.
7
+ */
8
+
9
+ // ─── Types ────────────────────────────────────────────────────────
10
+
11
+ export type GoalLevel = 'objective' | 'project' | 'plan' | 'task';
12
+
13
+ export type GoalStatus = 'planned' | 'active' | 'completed' | 'abandoned';
14
+
15
+ export interface Goal {
16
+ id: string;
17
+ title: string;
18
+ level: GoalLevel;
19
+ parentId?: string;
20
+ status: GoalStatus;
21
+ createdAt?: number;
22
+ updatedAt?: number;
23
+ }
24
+
25
+ // ─── Goal Store ───────────────────────────────────────────────────
26
+
27
+ export interface GoalStore {
28
+ version: string;
29
+ goals: Goal[];
30
+ }
31
+
32
+ /**
33
+ * Persistent goal repository backed by a JSON file.
34
+ * Follows the same pattern as PlanStore in planner.ts.
35
+ */
36
+ export interface GoalRepository {
37
+ getById(id: string): Goal | null;
38
+ getByParentId(parentId: string): Goal[];
39
+ create(goal: Omit<Goal, 'createdAt' | 'updatedAt'>): Goal;
40
+ updateStatus(id: string, status: GoalStatus): Goal;
41
+ list(): Goal[];
42
+ }
43
+
44
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
45
+ import { dirname } from 'node:path';
46
+
47
+ export class JsonGoalRepository implements GoalRepository {
48
+ private store: GoalStore;
49
+
50
+ constructor(private filePath: string) {
51
+ this.store = this.load();
52
+ }
53
+
54
+ private load(): GoalStore {
55
+ if (!existsSync(this.filePath)) return { version: '1.0', goals: [] };
56
+ try {
57
+ const data = readFileSync(this.filePath, 'utf-8');
58
+ return JSON.parse(data) as GoalStore;
59
+ } catch {
60
+ return { version: '1.0', goals: [] };
61
+ }
62
+ }
63
+
64
+ private save(): void {
65
+ mkdirSync(dirname(this.filePath), { recursive: true });
66
+ writeFileSync(this.filePath, JSON.stringify(this.store, null, 2), 'utf-8');
67
+ }
68
+
69
+ getById(id: string): Goal | null {
70
+ return this.store.goals.find((g) => g.id === id) ?? null;
71
+ }
72
+
73
+ getByParentId(parentId: string): Goal[] {
74
+ return this.store.goals.filter((g) => g.parentId === parentId);
75
+ }
76
+
77
+ create(goal: Omit<Goal, 'createdAt' | 'updatedAt'>): Goal {
78
+ const now = Date.now();
79
+ const full: Goal = { ...goal, createdAt: now, updatedAt: now };
80
+ this.store.goals.push(full);
81
+ this.save();
82
+ return full;
83
+ }
84
+
85
+ updateStatus(id: string, status: GoalStatus): Goal {
86
+ const goal = this.getById(id);
87
+ if (!goal) throw new Error(`Goal not found: ${id}`);
88
+ goal.status = status;
89
+ goal.updatedAt = Date.now();
90
+ this.save();
91
+ return goal;
92
+ }
93
+
94
+ list(): Goal[] {
95
+ return [...this.store.goals];
96
+ }
97
+ }
98
+
99
+ // ─── Max ancestor depth ──────────────────────────────────────────
100
+
101
+ const MAX_ANCESTOR_DEPTH = 10;
102
+
103
+ // ─── GoalAncestry ────────────────────────────────────────────────
104
+
105
+ export class GoalAncestry {
106
+ constructor(private repo: GoalRepository) {}
107
+
108
+ /**
109
+ * Walk the parent chain from a goal up to the root.
110
+ * Returns ancestors from immediate parent to root (closest first).
111
+ * Max 10 levels; throws on cycle detection.
112
+ */
113
+ getAncestors(goalId: string): Goal[] {
114
+ const ancestors: Goal[] = [];
115
+ const visited = new Set<string>();
116
+ let currentId: string | undefined = goalId;
117
+
118
+ // Start by finding the goal itself to get its parentId
119
+ const start = this.repo.getById(goalId);
120
+ if (!start) return [];
121
+ currentId = start.parentId;
122
+
123
+ while (currentId && ancestors.length < MAX_ANCESTOR_DEPTH) {
124
+ if (visited.has(currentId)) {
125
+ throw new Error(`Cycle detected in goal hierarchy at goal '${currentId}'`);
126
+ }
127
+ visited.add(currentId);
128
+
129
+ const parent = this.repo.getById(currentId);
130
+ if (!parent) break;
131
+
132
+ ancestors.push(parent);
133
+ currentId = parent.parentId;
134
+ }
135
+
136
+ return ancestors;
137
+ }
138
+
139
+ /**
140
+ * Render a markdown summary of the goal hierarchy for a given goal.
141
+ * Shows the full chain from root objective down to the current goal.
142
+ */
143
+ getContext(goalId: string): string {
144
+ const goal = this.repo.getById(goalId);
145
+ if (!goal) return '';
146
+
147
+ const ancestors = this.getAncestors(goalId);
148
+ // Build chain from root to current: reverse ancestors then append current
149
+ const chain = [...ancestors].reverse();
150
+ chain.push(goal);
151
+
152
+ const lines: string[] = ['## Goal Context', ''];
153
+
154
+ for (let i = 0; i < chain.length; i++) {
155
+ const g = chain[i];
156
+ const indent = ' '.repeat(i);
157
+ const marker = i === chain.length - 1 ? '**→**' : '-';
158
+ lines.push(`${indent}${marker} [${g.level}] ${g.title} (${g.status})`);
159
+ }
160
+
161
+ return lines.join('\n');
162
+ }
163
+
164
+ /**
165
+ * Inject goal ancestry context into an execution context metadata object.
166
+ * Returns a new context with goalAncestry added to config.
167
+ */
168
+ inject<T extends { config?: Record<string, unknown> }>(ctx: T, goalId: string): T {
169
+ const rendered = this.getContext(goalId);
170
+ if (!rendered) return ctx;
171
+
172
+ return {
173
+ ...ctx,
174
+ config: {
175
+ ...ctx.config,
176
+ goalAncestry: rendered,
177
+ },
178
+ };
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Generate a goal ID with the given level prefix.
184
+ */
185
+ export function generateGoalId(level: GoalLevel): string {
186
+ return `goal-${level}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
187
+ }
@@ -372,6 +372,8 @@ export function createPlanObject(params: {
372
372
  target_mode?: string;
373
373
  alternatives?: import('./planner-types.js').PlanAlternative[];
374
374
  initialStatus?: 'brainstorming' | 'draft';
375
+ /** Optional goal ID to link this plan to the goal hierarchy. */
376
+ goalId?: string;
375
377
  }): Plan {
376
378
  const now = Date.now();
377
379
  return {
@@ -401,6 +403,7 @@ export function createPlanObject(params: {
401
403
  ...(params.flow !== undefined && { flow: params.flow }),
402
404
  ...(params.target_mode !== undefined && { target_mode: params.target_mode }),
403
405
  ...(params.alternatives !== undefined && { alternatives: params.alternatives }),
406
+ ...(params.goalId !== undefined && { goalId: params.goalId }),
404
407
  checks: [],
405
408
  createdAt: now,
406
409
  updatedAt: now,
@@ -205,6 +205,8 @@ export interface Plan {
205
205
  };
206
206
  /** Aggregate execution metrics — populated by reconcile() and complete(). */
207
207
  executionSummary?: ExecutionSummary;
208
+ /** Goal ID linking this plan to the goal hierarchy. */
209
+ goalId?: string;
208
210
  createdAt: number;
209
211
  updatedAt: number;
210
212
  }
@@ -5,17 +5,21 @@ import type { OpDefinition } from '../facades/types.js';
5
5
 
6
6
  // ─── Mock Node.js fs/os modules ────────────────────────────────────────
7
7
 
8
+ /** Normalize path separators so Windows backslash paths match forward-slash keys */
9
+ const norm = (p: string): string => p.replace(/\\/g, '/');
10
+
8
11
  const mockFs: Record<string, string> = {};
9
12
  const mockDirs = new Set<string>();
10
13
 
11
14
  vi.mock('node:fs', () => ({
12
- existsSync: vi.fn((p: string) => p in mockFs || mockDirs.has(p)),
15
+ existsSync: vi.fn((p: string) => norm(p) in mockFs || mockDirs.has(norm(p))),
13
16
  readFileSync: vi.fn((p: string) => {
14
- if (p in mockFs) return mockFs[p];
17
+ const key = norm(p);
18
+ if (key in mockFs) return mockFs[key];
15
19
  throw new Error(`ENOENT: ${p}`);
16
20
  }),
17
21
  writeFileSync: vi.fn((p: string, content: string) => {
18
- mockFs[p] = content;
22
+ mockFs[norm(p)] = content;
19
23
  }),
20
24
  mkdirSync: vi.fn((_p: string) => undefined),
21
25
  copyFileSync: vi.fn(),
@@ -30,7 +34,8 @@ vi.mock('node:os', () => ({
30
34
 
31
35
  vi.mock('node:path', async () => {
32
36
  const actual = await vi.importActual<typeof import('node:path')>('node:path');
33
- return actual;
37
+ // Always use posix path semantics so mock filesystem keys (forward slashes) work on all platforms
38
+ return { ...actual.posix, default: actual.posix };
34
39
  });
35
40
 
36
41
  vi.mock('./claude-md-helpers.js', () => ({