@goodandready/dsh-goal 0.2.30 → 0.2.32

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.
@@ -0,0 +1,147 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { getSafeCwd } from './goal-engine-constants.js';
3
+ import { isGitRepository, isValidGitRef } from './engine-git-branch.js';
4
+
5
+ /**
6
+ * Parse git diff --numstat output into structured file statistics
7
+ * @param {string} output
8
+ * @returns {Array<{ path: string, insertions: number, deletions: number, binary: boolean }>}
9
+ */
10
+ export function parseNumstat(output) {
11
+ if (!output || typeof output !== 'string') return [];
12
+ const lines = output.trim().split('\n');
13
+ const files = [];
14
+ for (const line of lines) {
15
+ if (!line.trim()) continue;
16
+ const parts = line.split('\t');
17
+ if (parts.length < 3) continue;
18
+ const [rawIns, rawDel, filePath] = parts;
19
+ const isBinary = rawIns === '-' || rawDel === '-';
20
+ const ins = isBinary ? 0 : parseInt(rawIns, 10) || 0;
21
+ const del = isBinary ? 0 : parseInt(rawDel, 10) || 0;
22
+ files.push({
23
+ path: filePath,
24
+ insertions: ins,
25
+ deletions: del,
26
+ binary: isBinary,
27
+ });
28
+ }
29
+ return files;
30
+ }
31
+
32
+ /**
33
+ * Get git diff between refs or current working tree
34
+ * @param {object} options
35
+ * @returns {object} diff result payload
36
+ */
37
+ export function getGitDiff({
38
+ cwd = process.cwd(),
39
+ base,
40
+ target,
41
+ file,
42
+ allowedRoots = [],
43
+ maxBytes = 256 * 1024,
44
+ } = {}) {
45
+ const safeCwd = getSafeCwd(cwd, allowedRoots);
46
+ if (!safeCwd || !isGitRepository(safeCwd, allowedRoots)) {
47
+ return { ok: false, error: 'Not a git repository or invalid path' };
48
+ }
49
+
50
+ // Ref validation: disallow flag injection and special chars
51
+ if (base && !isValidGitRef(base)) {
52
+ return { ok: false, error: 'Invalid base git ref' };
53
+ }
54
+ if (target && !isValidGitRef(target)) {
55
+ return { ok: false, error: 'Invalid target git ref' };
56
+ }
57
+ if (file && (typeof file !== 'string' || file.startsWith('-') || /[\0\r\n]/.test(file))) {
58
+ return { ok: false, error: 'Invalid file parameter' };
59
+ }
60
+
61
+ try {
62
+ const rangeArgs = [];
63
+ if (base && target) {
64
+ rangeArgs.push(`${base}..${target}`);
65
+ } else if (base) {
66
+ rangeArgs.push(base);
67
+ } else if (target) {
68
+ rangeArgs.push(target);
69
+ }
70
+
71
+ // 1. Fetch numstat for file summary
72
+ const numstatArgs = ['diff', '--numstat', ...rangeArgs];
73
+ if (file) {
74
+ numstatArgs.push('--', file);
75
+ }
76
+ const numstatOut = execFileSync('git', numstatArgs, {
77
+ cwd: safeCwd,
78
+ encoding: 'utf8',
79
+ stdio: ['ignore', 'pipe', 'pipe'],
80
+ timeout: 5000,
81
+ });
82
+
83
+ const files = parseNumstat(numstatOut);
84
+ const stats = {
85
+ filesChanged: files.length,
86
+ insertions: files.reduce((acc, f) => acc + f.insertions, 0),
87
+ deletions: files.reduce((acc, f) => acc + f.deletions, 0),
88
+ };
89
+
90
+ // 2. Fetch patch
91
+ const patchArgs = ['diff', '-p', '--no-color', ...rangeArgs];
92
+ if (file) {
93
+ patchArgs.push('--', file);
94
+ }
95
+ let patchOut = execFileSync('git', patchArgs, {
96
+ cwd: safeCwd,
97
+ encoding: 'utf8',
98
+ stdio: ['ignore', 'pipe', 'pipe'],
99
+ timeout: 5000,
100
+ maxBuffer: 2 * 1024 * 1024,
101
+ });
102
+
103
+ let truncated = false;
104
+ if (patchOut.length > maxBytes) {
105
+ patchOut = patchOut.slice(0, maxBytes);
106
+ truncated = true;
107
+ }
108
+
109
+ return {
110
+ ok: true,
111
+ cwd: safeCwd,
112
+ base: base || null,
113
+ target: target || 'HEAD',
114
+ stats,
115
+ files,
116
+ diff: patchOut,
117
+ truncated,
118
+ };
119
+ } catch (err) {
120
+ return { ok: false, error: err.message || 'git diff execution failed' };
121
+ }
122
+ }
123
+
124
+ /**
125
+ * GoalEngine helper to fetch diff for active or specified session
126
+ * @param {object} engine
127
+ * @param {string} sessionId
128
+ * @param {object} options
129
+ * @returns {object} diff result
130
+ */
131
+ export function getEngineGoalDiff(engine, sessionId = 'default', options = {}) {
132
+ const sid = sessionId || 'default';
133
+ const goal = engine.goals?.get(sid);
134
+ const allowedRoots = options.allowedRoots || engine.config?.allowedRoots || (goal?.cwd ? [goal.cwd, process.cwd()] : [process.cwd()]);
135
+ const cwd = options.cwd || goal?.cwd || engine.config?.cwd || process.cwd();
136
+ const base = options.base || goal?.gitStartCommit || null;
137
+ const target = options.target || goal?.gitBranch || goal?.branchName || 'HEAD';
138
+
139
+ return getGitDiff({
140
+ cwd,
141
+ base,
142
+ target,
143
+ file: options.file,
144
+ allowedRoots,
145
+ maxBytes: options.maxBytes,
146
+ });
147
+ }
@@ -0,0 +1,242 @@
1
+ import { MilestoneStatus } from './goal-engine-constants.js';
2
+
3
+ export const MILESTONE_TO_KANBAN_COLUMN = {
4
+ [MilestoneStatus.PENDING]: 'todo',
5
+ [MilestoneStatus.IN_PROGRESS]: 'in_progress',
6
+ [MilestoneStatus.COMPLETED]: 'done',
7
+ pending: 'todo',
8
+ in_progress: 'in_progress',
9
+ active: 'in_progress',
10
+ completed: 'done',
11
+ PENDING: 'todo',
12
+ ACTIVE: 'in_progress',
13
+ COMPLETED: 'done',
14
+ };
15
+
16
+ export const KANBAN_COLUMN_TO_MILESTONE_STATUS = {
17
+ backlog: MilestoneStatus.PENDING,
18
+ todo: MilestoneStatus.PENDING,
19
+ in_progress: MilestoneStatus.IN_PROGRESS,
20
+ doing: MilestoneStatus.IN_PROGRESS,
21
+ active: MilestoneStatus.IN_PROGRESS,
22
+ review: MilestoneStatus.COMPLETED,
23
+ done: MilestoneStatus.COMPLETED,
24
+ completed: MilestoneStatus.COMPLETED,
25
+ };
26
+
27
+ /**
28
+ * Format externalRef string for a goal milestone
29
+ * @param {string} goalId
30
+ * @param {string} milestoneId
31
+ * @returns {string}
32
+ */
33
+ export function formatMilestoneExternalRef(goalId, milestoneId) {
34
+ return `dsh-goal:${goalId || 'default'}:${milestoneId}`;
35
+ }
36
+
37
+ /**
38
+ * Parse milestone externalRef
39
+ * @param {string} externalRef
40
+ * @returns {{ goalId: string, milestoneId: string } | null}
41
+ */
42
+ export function parseMilestoneExternalRef(externalRef) {
43
+ if (typeof externalRef !== 'string' || !externalRef.startsWith('dsh-goal:')) {
44
+ return null;
45
+ }
46
+ const parts = externalRef.split(':');
47
+ if (parts.length < 3) return null;
48
+ return {
49
+ goalId: parts[1],
50
+ milestoneId: parts.slice(2).join(':'),
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Resolve active kanban service or store from context/options
56
+ * @param {object} options
57
+ * @returns {{ service: object | null, store: object | null }}
58
+ */
59
+ export function resolveKanbanServices(options = {}) {
60
+ const ctx = options.ctx;
61
+ let service = options.kanbanService || null;
62
+ let store = options.store || null;
63
+
64
+ if (!service && ctx) {
65
+ service = ctx.dshKanban || (typeof ctx.get === 'function' ? ctx.get('dshKanban') : null);
66
+ }
67
+
68
+ if (!store) {
69
+ if (service && typeof service.getStore === 'function') {
70
+ store = service.getStore();
71
+ } else if (ctx?.kanbanStore) {
72
+ store = ctx.kanbanStore;
73
+ }
74
+ }
75
+
76
+ return { service, store };
77
+ }
78
+
79
+ /**
80
+ * Synchronize all milestones of a goal with dsh-kanban
81
+ * Safe degradation if kanban is not available
82
+ * @param {object} engine
83
+ * @param {string} sessionId
84
+ * @param {object} options
85
+ * @returns {Promise<object>} sync summary
86
+ */
87
+ export async function syncGoalToKanban(engine, sessionId = 'default', options = {}) {
88
+ const sid = sessionId || 'default';
89
+ const goal = engine.goals?.get(sid);
90
+ if (!goal) {
91
+ return { ok: false, error: 'Goal not found' };
92
+ }
93
+ if (!goal.milestones || goal.milestones.length === 0) {
94
+ return { ok: true, synced: 0, reason: 'No milestones in goal' };
95
+ }
96
+
97
+ const { service, store } = resolveKanbanServices({
98
+ kanbanService: options.kanbanService || engine.kanbanService,
99
+ store: options.store || engine.kanbanStore,
100
+ ctx: options.ctx || engine.ctx,
101
+ ...options,
102
+ });
103
+
104
+ if (!service && !store) {
105
+ return { ok: false, skipped: true, reason: 'kanban-unavailable' };
106
+ }
107
+
108
+ const results = [];
109
+ for (const ms of goal.milestones) {
110
+ const externalRef = formatMilestoneExternalRef(goal.id, ms.id);
111
+ const col = MILESTONE_TO_KANBAN_COLUMN[ms.status] || 'todo';
112
+ const title = `[Goal] ${ms.title}`;
113
+ const body = ms.notes || `Milestone ${ms.id} for goal: ${goal.title}`;
114
+
115
+ try {
116
+ if (service && typeof service.createTask === 'function') {
117
+ const res = await service.createTask({
118
+ title,
119
+ body,
120
+ column: col,
121
+ externalRef,
122
+ labels: ['dsh-goal', String(goal.id)],
123
+ });
124
+ results.push({ milestoneId: ms.id, taskId: res.taskId || res.task?.id, ok: res.ok, alreadyExists: res.alreadyExists });
125
+ } else if (store && typeof store.createTask === 'function') {
126
+ const existing = typeof store.findTaskByExternalRef === 'function' ? store.findTaskByExternalRef(externalRef) : null;
127
+ if (!existing) {
128
+ const task = store.createTask({
129
+ title,
130
+ body,
131
+ column: col,
132
+ externalRef,
133
+ labels: ['dsh-goal', String(goal.id)],
134
+ });
135
+ results.push({ milestoneId: ms.id, taskId: task.id, ok: true, alreadyExists: false });
136
+ } else {
137
+ results.push({ milestoneId: ms.id, taskId: existing.id, ok: true, alreadyExists: true });
138
+ }
139
+ }
140
+ } catch (err) {
141
+ engine.logger?.warn?.(`[dsh-goal] Kanban milestone sync failed for ${ms.id}:`, err?.message || err);
142
+ results.push({ milestoneId: ms.id, ok: false, error: err?.message || String(err) });
143
+ }
144
+ }
145
+
146
+ return { ok: true, synced: results.filter((r) => r.ok).length, total: goal.milestones.length, results };
147
+ }
148
+
149
+ /**
150
+ * Update corresponding kanban card column when milestone status changes
151
+ * @param {object} milestone
152
+ * @param {object} options
153
+ * @returns {Promise<object>}
154
+ */
155
+ export async function syncMilestoneStatusToKanban(milestone, { goal, ctx, store, kanbanService } = {}) {
156
+ if (!milestone || !goal) return { ok: false, error: 'Invalid milestone or goal' };
157
+
158
+ const { store: activeStore } = resolveKanbanServices({
159
+ store,
160
+ kanbanService,
161
+ ctx,
162
+ });
163
+ if (!activeStore || typeof activeStore.findTaskByExternalRef !== 'function') {
164
+ return { ok: false, skipped: true, reason: 'kanban-store-unavailable' };
165
+ }
166
+
167
+ const externalRef = formatMilestoneExternalRef(goal.id, milestone.id);
168
+ const targetCol = MILESTONE_TO_KANBAN_COLUMN[milestone.status] || 'todo';
169
+
170
+ try {
171
+ const task = activeStore.findTaskByExternalRef(externalRef);
172
+ if (!task) {
173
+ return { ok: false, skipped: true, reason: 'kanban-task-not-found' };
174
+ }
175
+
176
+ if (task.column !== targetCol && typeof activeStore.moveTask === 'function') {
177
+ activeStore.moveTask(task.id, { column: targetCol });
178
+ return { ok: true, synced: true, taskId: task.id, column: targetCol };
179
+ }
180
+
181
+ return { ok: true, synced: true, taskId: task.id, column: task.column, unchanged: true };
182
+ } catch (err) {
183
+ return { ok: false, error: err?.message || String(err) };
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Handle external card movement from dsh-kanban board
189
+ * @param {object} engine
190
+ * @param {object} payload
191
+ * @param {string} sessionId
192
+ * @returns {object}
193
+ */
194
+ export function handleKanbanCardMoved(engine, payload = {}, sessionId = 'default') {
195
+ const externalRef = payload.externalRef;
196
+ const column = payload.column;
197
+ const targetStatus = payload.status || (column ? KANBAN_COLUMN_TO_MILESTONE_STATUS[column] : null);
198
+
199
+ if (!targetStatus) {
200
+ return { ok: false, error: 'Invalid or missing target milestone status/column' };
201
+ }
202
+
203
+ let goalId = null;
204
+ let milestoneId = payload.milestoneId || null;
205
+
206
+ if (externalRef) {
207
+ const parsed = parseMilestoneExternalRef(externalRef);
208
+ if (parsed) {
209
+ goalId = parsed.goalId;
210
+ milestoneId = parsed.milestoneId;
211
+ }
212
+ }
213
+
214
+ const candidateSessions = sessionId && sessionId !== 'default'
215
+ ? [sessionId, 'default']
216
+ : ['default', ...engine.goals.keys()];
217
+
218
+ for (const sid of candidateSessions) {
219
+ const goal = engine.goals?.get(sid);
220
+ if (!goal || (goalId && String(goal.id) !== String(goalId))) continue;
221
+
222
+ const ms = goal.milestones?.find((m) => String(m.id) === String(milestoneId) || m.title === payload.title);
223
+ if (ms) {
224
+ const updateRes = engine.updateMilestone(
225
+ ms.id,
226
+ targetStatus,
227
+ `Synced from Kanban card move to column '${column || targetStatus}'`,
228
+ sid
229
+ );
230
+ return {
231
+ ok: true,
232
+ sessionId: sid,
233
+ milestoneId: ms.id,
234
+ prevStatus: ms.status,
235
+ newStatus: targetStatus,
236
+ updateResult: updateRes,
237
+ };
238
+ }
239
+ }
240
+
241
+ return { ok: false, error: 'Matching milestone not found for external reference' };
242
+ }
@@ -1,5 +1,6 @@
1
1
  import { detectLanguage } from "./goal-engine.js";
2
2
  import { isMilestoneBlocked } from './engine-milestones.js';
3
+ import { buildCompactedMilestonesHistory } from './engine-compaction.js';
3
4
 
4
5
  /**
5
6
  * Prompt generation for DSH Goal Mode
@@ -55,13 +56,28 @@ export function buildStatePromptInjection(snapshot, pendingNudge) {
55
56
  }
56
57
  }
57
58
 
59
+ let subGoalContextText = '';
60
+ if (snapshot.isSubGoal) {
61
+ if (lang === 'zh') {
62
+ subGoalContextText = `\n\n[子目标隔离执行上下文 / ISOLATED SUB-GOAL EXECUTION CONTEXT]\n` +
63
+ `你当前处于从主目标派生出的隔离子目标中 (主目标 ID: "${snapshot.parentGoalId || 'parent'}")。\n` +
64
+ `请严格专注于当前子目标的独立交付!所有里程碑完成后,立即调用 goal_finish 提交最终成果并将控制权移交回主目标。\n`;
65
+ } else {
66
+ subGoalContextText = `\n\n[ISOLATED SUB-GOAL EXECUTION CONTEXT]\n` +
67
+ `You are running inside an isolated sub-goal spawned by parent goal (Parent Goal ID: "${snapshot.parentGoalId || 'parent'}").\n` +
68
+ `Focus strictly on delivering this specific sub-goal objective! When all sub-milestones are completed, call tool goal_finish immediately to return deliverables and control to the parent goal.\n`;
69
+ }
70
+ }
71
+
58
72
  const allMilestones = snapshot.milestones || [];
59
73
  const formatMilestone = (m, i) => {
74
+ const origIdx = allMilestones.indexOf(m);
75
+ const num = origIdx >= 0 ? origIdx + 1 : i + 1;
60
76
  const depCheck = isMilestoneBlocked(m, allMilestones);
61
77
  const blockedLabel = depCheck.blocked
62
78
  ? ` (BLOCKED by ${depCheck.missingDependencies.join(', ')})`
63
79
  : '';
64
- let line = ` ${i + 1}. [${m.status.toUpperCase()}] ${m.title}${blockedLabel}${m.notes ? ` (${m.notes})` : ''}`;
80
+ let line = ` ${num}. [${m.status.toUpperCase()}] ${m.title}${blockedLabel}${m.notes ? ` (${m.notes})` : ''}`;
65
81
  if (Array.isArray(m.checklist) && m.checklist.length > 0) {
66
82
  const checkLines = m.checklist
67
83
  .map((item) => ` [${item.done ? 'x' : ' '}] ${item.text}`)
@@ -71,12 +87,26 @@ export function buildStatePromptInjection(snapshot, pendingNudge) {
71
87
  return line;
72
88
  };
73
89
 
90
+ const enableCompaction = snapshot.enableMilestoneCompaction !== false;
91
+ const completedMilestones = allMilestones.filter((m) => m && m.status === 'completed');
92
+ const activeMilestones = allMilestones.filter((m) => m && m.status !== 'completed');
93
+ const shouldCompact = enableCompaction && completedMilestones.length > 0 && activeMilestones.length > 0;
94
+
95
+ let compactedBlock = '';
96
+ if (shouldCompact) {
97
+ compactedBlock = `${buildCompactedMilestonesHistory(completedMilestones, lang)}\n\n`;
98
+ }
99
+
100
+ const displayMilestones = shouldCompact ? activeMilestones : allMilestones;
101
+
74
102
  if (lang === 'zh') {
103
+ const planTitle = shouldCompact ? '当前进行中与后续工作计划:' : '工作计划:';
75
104
  const milestonesText = hasMilestones
76
- ? snapshot.milestones.map(formatMilestone).join('\n')
105
+ ? displayMilestones.map(formatMilestone).join('\n')
77
106
  : ' (工作计划尚未建立 — 请立即调用 goal_set_milestones 设定初始里程碑!)';
78
107
 
79
108
  return (
109
+ subGoalContextText +
80
110
  operatorAnswerText +
81
111
  directiveText +
82
112
  nudgeText +
@@ -84,7 +114,7 @@ export function buildStatePromptInjection(snapshot, pendingNudge) {
84
114
  `\n\n[DSH GOAL MODE ACTIVE]\n` +
85
115
  `目标: "${snapshot.title}"\n` +
86
116
  `运行时间: ${snapshot.formattedElapsed}${etaText} | 迭代轮次: ${snapshot.iterationsCount}/${snapshot.maxIterations}\n` +
87
- `工作计划:\n` +
117
+ `${compactedBlock}${planTitle}\n` +
88
118
  `${milestonesText}\n\n` +
89
119
  `Goal Mode 执行契约 (必须严格遵循):\n` +
90
120
  `1. ${hasMilestones ? '按部就班推进当前进行中的里程碑。注意:标记为 [BLOCKED] 的里程碑在前置依赖完成前严禁推进。' : '第一步核心指令: 立即调用 goal_set_milestones 制定 3-7 个具体里程碑。在完成此工具调用前严禁执行其他操作!'}\n` +
@@ -93,11 +123,13 @@ export function buildStatePromptInjection(snapshot, pendingNudge) {
93
123
  );
94
124
  }
95
125
 
126
+ const planTitle = shouldCompact ? 'Active & Upcoming Work Plan:' : 'Work Plan:';
96
127
  const milestonesText = hasMilestones
97
- ? snapshot.milestones.map(formatMilestone).join('\n')
128
+ ? displayMilestones.map(formatMilestone).join('\n')
98
129
  : ' (Work plan is not yet established — call goal_set_milestones immediately with initial steps!)';
99
130
 
100
131
  return (
132
+ subGoalContextText +
101
133
  operatorAnswerText +
102
134
  directiveText +
103
135
  nudgeText +
@@ -105,7 +137,7 @@ export function buildStatePromptInjection(snapshot, pendingNudge) {
105
137
  `\n\n[DSH GOAL MODE ACTIVE]\n` +
106
138
  `Goal: "${snapshot.title}"\n` +
107
139
  `Elapsed Time: ${snapshot.formattedElapsed}${etaText} | Iteration: ${snapshot.iterationsCount}/${snapshot.maxIterations}\n` +
108
- `Work Plan:\n` +
140
+ `${compactedBlock}${planTitle}\n` +
109
141
  `${milestonesText}\n\n` +
110
142
  `Goal Mode Instructions (MANDATORY TO FOLLOW):\n` +
111
143
  `1. ${hasMilestones ? 'Execute the current active milestone from the work plan. Note: Milestones marked [BLOCKED] must not be started until prerequisite milestones are COMPLETED.' : 'YOUR FIRST STEP: Immediately call tool goal_set_milestones with the list of milestones (3-7 concrete steps). You must not execute work or finish turn without calling goal_set_milestones!'}\n` +
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Hierarchical Recursive Sub-goals Engine Module (Issue #170).
3
+ * Supports isolated context execution, state counters isolation,
4
+ * breadcrumbs hierarchy, and artifact/control synchronization with parent goals.
5
+ */
6
+
7
+ import { GoalState } from './goal-engine-constants.js';
8
+
9
+ /**
10
+ * Spawn a child sub-goal under a parent goal session
11
+ * @param {object} engine - GoalEngine instance
12
+ * @param {string} parentSessionId - Parent goal session ID
13
+ * @param {object} options - Sub-goal options { title, prompt, description, maxIterations, cwd, inheritContext, milestones }
14
+ * @returns {object}
15
+ */
16
+ export function spawnSubGoal(engine, parentSessionId, options = {}) {
17
+ if (!engine) throw new Error('GoalEngine instance is required');
18
+
19
+ const pSid = parentSessionId || 'default';
20
+ const parentGoal = engine.goals.get(pSid);
21
+ if (!parentGoal) {
22
+ return { ok: false, error: `Parent goal for session "${pSid}" not found` };
23
+ }
24
+
25
+ const rawTitle = options.title || options.goal;
26
+ if (!rawTitle || typeof rawTitle !== 'string' || !rawTitle.trim()) {
27
+ return { ok: false, error: 'Sub-goal title is required and cannot be empty' };
28
+ }
29
+
30
+ const cleanTitle = rawTitle.trim();
31
+ const subGoalId = options.subGoalId || `sub-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 6)}`;
32
+ const childSessionId = options.sessionId || `${pSid}:subgoal:${subGoalId}`;
33
+
34
+ const inheritContext = options.inheritContext !== false && options.inherit_context !== false;
35
+ let childDesc = (options.prompt || options.description || '').trim();
36
+ if (inheritContext) {
37
+ const parentContextTag = `[Parent Goal: "${parentGoal.title}"]`;
38
+ childDesc = childDesc ? `${parentContextTag} ${childDesc}` : parentContextTag;
39
+ }
40
+
41
+ const maxIters = typeof options.maxIterations === 'number'
42
+ ? options.maxIterations
43
+ : (typeof options.max_iterations === 'number' ? options.max_iterations : 15);
44
+
45
+ const childSnapshot = engine.startGoal(cleanTitle, {
46
+ description: childDesc,
47
+ maxIterations: maxIters,
48
+ cwd: options.cwd || parentGoal.cwd,
49
+ lang: options.lang || parentGoal.lang,
50
+ parentGoalId: parentGoal.id,
51
+ parentSessionId: pSid,
52
+ autoBranch: false,
53
+ milestones: Array.isArray(options.milestones) ? options.milestones : [],
54
+ }, childSessionId);
55
+
56
+ const childGoal = engine.goals.get(childSessionId);
57
+ if (childGoal) {
58
+ childGoal.parentGoalId = parentGoal.id;
59
+ childGoal.parentSessionId = pSid;
60
+ childGoal.isSubGoal = true;
61
+ childGoal.subGoalId = subGoalId;
62
+ }
63
+
64
+ if (!Array.isArray(parentGoal.subGoals)) {
65
+ parentGoal.subGoals = [];
66
+ }
67
+
68
+ parentGoal.subGoals.push({
69
+ id: childGoal ? childGoal.id : childSnapshot.id,
70
+ subGoalId,
71
+ sessionId: childSessionId,
72
+ title: cleanTitle,
73
+ state: childGoal ? childGoal.state : GoalState.RUNNING,
74
+ createdAt: childGoal ? childGoal.startedAt : Date.now(),
75
+ completedAt: null,
76
+ resultSummary: '',
77
+ });
78
+
79
+ if (typeof engine.recordToolActivity === 'function') {
80
+ engine.recordToolActivity('spawnSubGoal', 'success', `Spawned sub-goal [${cleanTitle}]`, pSid);
81
+ }
82
+
83
+ engine.emit(pSid, true);
84
+
85
+ return {
86
+ ok: true,
87
+ parentGoalId: parentGoal.id,
88
+ parentSessionId: pSid,
89
+ subGoalId,
90
+ childSessionId,
91
+ childGoal: childSnapshot,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Complete a child sub-goal and synchronize state/deliverables back to parent goal
97
+ * @param {object} engine - GoalEngine instance
98
+ * @param {string} childSessionId - Child sub-goal session ID
99
+ * @param {string} [resultSummary=''] - Deliverables summary
100
+ * @returns {object}
101
+ */
102
+ export function syncSubGoalToParent(engine, childSessionId, resultSummary = '') {
103
+ if (!engine) return false;
104
+ const childGoal = engine.goals.get(childSessionId);
105
+ if (!childGoal) return false;
106
+
107
+ const pSid = childGoal.parentSessionId;
108
+ const parentGoal = pSid ? engine.goals.get(pSid) : null;
109
+ if (!parentGoal) return false;
110
+
111
+ if (Array.isArray(parentGoal.subGoals)) {
112
+ const entry = parentGoal.subGoals.find(
113
+ (s) => s.sessionId === childSessionId || s.id === childGoal.id || (childGoal.subGoalId && s.subGoalId === childGoal.subGoalId)
114
+ );
115
+ if (entry) {
116
+ entry.state = GoalState.COMPLETED;
117
+ entry.completedAt = Date.now();
118
+ entry.resultSummary = resultSummary;
119
+ }
120
+ }
121
+
122
+ const note = resultSummary ? `: ${resultSummary}` : '';
123
+ parentGoal.logs.push({
124
+ timestamp: Date.now(),
125
+ type: 'info',
126
+ message: `Sub-goal [${childGoal.title}] completed${note}`,
127
+ });
128
+
129
+ if (typeof engine.recordToolActivity === 'function') {
130
+ engine.recordToolActivity('completeSubGoal', 'success', `Sub-goal [${childGoal.title}] completed`, pSid);
131
+ }
132
+
133
+ engine.emit(pSid, true);
134
+ return true;
135
+ }
136
+
137
+ export function completeSubGoal(engine, childSessionId, resultSummary = '') {
138
+ if (!engine) throw new Error('GoalEngine instance is required');
139
+ if (!childSessionId) return { ok: false, error: 'childSessionId is required' };
140
+
141
+ const childGoal = engine.goals.get(childSessionId);
142
+ if (!childGoal) {
143
+ return { ok: false, error: `Child sub-goal "${childSessionId}" not found` };
144
+ }
145
+
146
+ const pSid = childGoal.parentSessionId;
147
+ if (childGoal.state !== GoalState.COMPLETED) {
148
+ engine.completeGoal(resultSummary, childSessionId);
149
+ } else {
150
+ syncSubGoalToParent(engine, childSessionId, resultSummary);
151
+ }
152
+
153
+ return {
154
+ ok: true,
155
+ childSessionId,
156
+ parentSessionId: pSid || null,
157
+ resultSummary,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Get hierarchy breadcrumbs chain from root goal down to current session
163
+ * @param {object} engine - GoalEngine instance
164
+ * @param {string} [sessionId='default']
165
+ * @returns {Array<{ sessionId: string, id: string, title: string, state: string, isSubGoal: boolean, isCurrent?: boolean }>}
166
+ */
167
+ export function getSubGoalBreadcrumbs(engine, sessionId = 'default') {
168
+ if (!engine || !engine.goals) return [];
169
+
170
+ const crumbs = [];
171
+ let currSid = sessionId || 'default';
172
+ const visited = new Set();
173
+
174
+ while (currSid && !visited.has(currSid)) {
175
+ visited.add(currSid);
176
+ const g = engine.goals.get(currSid);
177
+ if (!g) break;
178
+
179
+ crumbs.unshift({
180
+ sessionId: currSid,
181
+ id: g.id,
182
+ title: g.title,
183
+ state: g.state,
184
+ isSubGoal: Boolean(g.parentGoalId || g.isSubGoal),
185
+ });
186
+
187
+ currSid = g.parentSessionId;
188
+ }
189
+
190
+ if (crumbs.length > 0) {
191
+ crumbs[crumbs.length - 1].isCurrent = true;
192
+ }
193
+
194
+ return crumbs;
195
+ }
196
+
197
+ /**
198
+ * Get list of child sub-goals for a given parent session
199
+ * @param {object} engine - GoalEngine instance
200
+ * @param {string} [sessionId='default']
201
+ * @returns {Array<object>}
202
+ */
203
+ export function getSubGoalsList(engine, sessionId = 'default') {
204
+ if (!engine || !engine.goals) return [];
205
+ const g = engine.goals.get(sessionId || 'default');
206
+ return Array.isArray(g?.subGoals) ? g.subGoals : [];
207
+ }