@goodandready/dsh-goal 0.2.31 → 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.
package/lib/client.js CHANGED
@@ -1930,6 +1930,77 @@ window.__ModuleLoader__.load({
1930
1930
  color: var(--dsw-alias-label-secondary);
1931
1931
  font-size: 13px;
1932
1932
  }
1933
+ /* Sub-goals and Compaction (Issue 167 and Issue 170) */
1934
+ .dsh-goal-badge-subgoal {
1935
+ background: var(--dsw-alias-brand-bg, var(--dsw-alias-bg-hover));
1936
+ color: var(--dsw-alias-brand-primary, var(--dsw-alias-text-primary));
1937
+ border: 1px solid var(--dsw-alias-brand-border, var(--dsw-alias-border));
1938
+ }
1939
+ .dsh-goal-breadcrumbs {
1940
+ display: inline-flex;
1941
+ align-items: center;
1942
+ gap: 4px;
1943
+ font-size: 13px;
1944
+ color: var(--dsw-alias-label-secondary);
1945
+ }
1946
+ .dsh-goal-breadcrumb-item {
1947
+ color: var(--dsw-alias-label-secondary);
1948
+ }
1949
+ .dsh-goal-breadcrumb-item.active {
1950
+ color: var(--dsw-alias-text-primary);
1951
+ font-weight: 600;
1952
+ }
1953
+ .dsh-goal-crumb-sep {
1954
+ color: var(--dsw-alias-label-tertiary);
1955
+ margin: 0 2px;
1956
+ }
1957
+ .dsh-goal-subgoals-box {
1958
+ display: flex;
1959
+ flex-direction: column;
1960
+ gap: 10px;
1961
+ padding: 4px 0;
1962
+ }
1963
+ .dsh-goal-subgoals-list {
1964
+ display: flex;
1965
+ flex-direction: column;
1966
+ gap: 8px;
1967
+ }
1968
+ .dsh-goal-subgoal-card {
1969
+ background: var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-hover));
1970
+ border: 1px solid var(--dsw-alias-border);
1971
+ border-radius: 6px;
1972
+ padding: 10px 12px;
1973
+ display: flex;
1974
+ flex-direction: column;
1975
+ gap: 4px;
1976
+ }
1977
+ .dsh-goal-subgoal-header {
1978
+ display: flex;
1979
+ align-items: center;
1980
+ justify-content: space-between;
1981
+ gap: 8px;
1982
+ }
1983
+ .dsh-goal-subgoal-title {
1984
+ font-weight: 600;
1985
+ font-size: 13px;
1986
+ color: var(--dsw-alias-text-primary);
1987
+ }
1988
+ .dsh-goal-subgoal-summary {
1989
+ font-size: 12px;
1990
+ color: var(--dsw-alias-label-secondary);
1991
+ }
1992
+ .dsh-goal-compacted-pill {
1993
+ display: inline-flex;
1994
+ align-items: center;
1995
+ gap: 3px;
1996
+ font-size: 10px;
1997
+ padding: 1px 5px;
1998
+ border-radius: 3px;
1999
+ background: var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-hover));
2000
+ color: var(--dsw-alias-state-success-primary, var(--dsw-alias-text-secondary));
2001
+ border: 1px solid var(--dsw-alias-border);
2002
+ margin-left: 6px;
2003
+ }
1933
2004
  `;
1934
2005
  function injectStylesOnce() {
1935
2006
 
@@ -2364,6 +2435,53 @@ window.__ModuleLoader__.load({
2364
2435
  );
2365
2436
  };
2366
2437
 
2438
+ const renderSubGoalsBox = () => {
2439
+ const subList = Array.isArray(state.subGoals) ? state.subGoals : [];
2440
+ return React.createElement(
2441
+ 'div',
2442
+ { className: 'dsh-goal-subgoals-box' },
2443
+ state.isSubGoal
2444
+ ? React.createElement(
2445
+ 'div',
2446
+ { className: 'dsh-goal-subgoal-card', style: { borderStyle: 'dashed' } },
2447
+ React.createElement('div', { style: { fontSize: 11, color: 'var(--dsw-alias-brand-primary)', fontWeight: 600 } }, '🌱 ' + (t('parentGoalBanner') || 'Child Sub-goal of:')),
2448
+ React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, state.parentGoalId || 'Parent Goal')
2449
+ )
2450
+ : null,
2451
+ subList.length === 0
2452
+ ? React.createElement('div', { className: 'dsh-goal-diff-empty' }, t('noSubGoals') || 'No sub-goals spawned yet.')
2453
+ : React.createElement(
2454
+ 'div',
2455
+ { className: 'dsh-goal-subgoals-list' },
2456
+ subList.map((sub, idx) =>
2457
+ React.createElement(
2458
+ 'div',
2459
+ { key: sub.id || sub.subGoalId || idx, className: 'dsh-goal-subgoal-card' },
2460
+ React.createElement(
2461
+ 'div',
2462
+ { className: 'dsh-goal-subgoal-header' },
2463
+ React.createElement('span', { className: 'dsh-goal-subgoal-title' }, sub.title),
2464
+ React.createElement(
2465
+ 'span',
2466
+ {
2467
+ className: 'dsh-goal-badge ' + (sub.state === 'COMPLETED' ? 'dsh-goal-badge-ok' : sub.state === 'RUNNING' ? 'dsh-goal-badge-plan' : ''),
2468
+ },
2469
+ sub.state || 'PENDING'
2470
+ )
2471
+ ),
2472
+ sub.resultSummary
2473
+ ? React.createElement(
2474
+ 'div',
2475
+ { className: 'dsh-goal-subgoal-summary' },
2476
+ sub.resultSummary
2477
+ )
2478
+ : null
2479
+ )
2480
+ )
2481
+ )
2482
+ );
2483
+ };
2484
+
2367
2485
  const totalTokens = state.tokensUsage?.totalTokens || 0;
2368
2486
 
2369
2487
  const promptTokens = state.tokensUsage?.promptTokens || 0;
@@ -2502,11 +2620,22 @@ window.__ModuleLoader__.load({
2502
2620
  onClick: () => setActiveTab("diff"),
2503
2621
  },
2504
2622
  "🔍 " + (t("tabDiff") || "Changes / Diff") + (diffData && diffData.stats && diffData.stats.filesChanged ? ` (${diffData.stats.filesChanged})` : "")
2623
+ ),
2624
+ React.createElement(
2625
+ 'button',
2626
+ {
2627
+ type: 'button',
2628
+ className: `dsh-goal-tab-btn${activeTab === "subgoals" ? " active" : ""}`,
2629
+ onClick: () => setActiveTab("subgoals"),
2630
+ },
2631
+ "🌳 " + (t("tabSubGoals") || "Sub-goals") + (state.subGoals && state.subGoals.length > 0 ? ` (${state.subGoals.length})` : "")
2505
2632
  )
2506
2633
  ),
2507
2634
 
2508
2635
  activeTab === 'diff'
2509
2636
  ? renderDiffBox()
2637
+ : activeTab === 'subgoals'
2638
+ ? renderSubGoalsBox()
2510
2639
  : React.createElement(
2511
2640
  React.Fragment,
2512
2641
  null,
@@ -2835,6 +2964,13 @@ window.__ModuleLoader__.load({
2835
2964
  },
2836
2965
 
2837
2966
  `${i + 1}. ${m.title}`,
2967
+ m.compactionSummary
2968
+ ? React.createElement(
2969
+ 'span',
2970
+ { className: 'dsh-goal-compacted-pill', title: m.compactionSummary },
2971
+ '⚡ ' + (t('compactedBadge') || 'Compacted')
2972
+ )
2973
+ : null,
2838
2974
  m.verificationCommand
2839
2975
  ? React.createElement(
2840
2976
  'span',
@@ -4357,7 +4493,28 @@ window.__ModuleLoader__.load({
4357
4493
 
4358
4494
  ),
4359
4495
 
4360
- React.createElement('span', { className: 'dsh-goal-title', title: state.title }, state.title),
4496
+ state.isSubGoal
4497
+ ? React.createElement(
4498
+ 'span',
4499
+ { className: 'dsh-goal-badge dsh-goal-badge-subgoal', title: t('subGoalBadge') || 'Sub-goal' },
4500
+ '🌱 ' + (t('subGoalBadge') || 'Sub-goal')
4501
+ )
4502
+ : null,
4503
+
4504
+ state.breadcrumbs && state.breadcrumbs.length > 1
4505
+ ? React.createElement(
4506
+ 'span',
4507
+ { className: 'dsh-goal-breadcrumbs', title: state.title },
4508
+ state.breadcrumbs.map((crumb, idx) =>
4509
+ React.createElement(
4510
+ 'span',
4511
+ { key: crumb.sessionId || idx, className: 'dsh-goal-breadcrumb-item' + (crumb.isCurrent ? ' active' : '') },
4512
+ idx > 0 && React.createElement('span', { className: 'dsh-goal-crumb-sep' }, ' / '),
4513
+ crumb.title
4514
+ )
4515
+ )
4516
+ )
4517
+ : React.createElement('span', { className: 'dsh-goal-title', title: state.title }, state.title),
4361
4518
 
4362
4519
  ),
4363
4520
 
@@ -4367,6 +4524,19 @@ window.__ModuleLoader__.load({
4367
4524
 
4368
4525
  { className: 'dsh-goal-right' },
4369
4526
 
4527
+ state.subGoals && state.subGoals.length > 0
4528
+ ? React.createElement(
4529
+ 'span',
4530
+ {
4531
+ className: 'dsh-goal-stat-pill',
4532
+ style: { cursor: 'pointer' },
4533
+ title: t('subGoalsTitle') || 'Sub-goals',
4534
+ onClick: () => setIsModalOpen(true),
4535
+ },
4536
+ '🌿 ' + state.subGoals.filter((s) => s.state === 'COMPLETED').length + '/' + state.subGoals.length + ' ' + (t('subGoalsPill') || 'sub-goals')
4537
+ )
4538
+ : null,
4539
+
4370
4540
  React.createElement(
4371
4541
 
4372
4542
  'span',
@@ -5835,6 +6005,14 @@ window.__ModuleLoader__.load({
5835
6005
  allFiles: 'All Files',
5836
6006
  planModeDesc: 'Plan first, review milestones, then execute',
5837
6007
  actModeDesc: 'Direct execution right away',
6008
+ tabSubGoals: 'Sub-goals',
6009
+ subGoalsTitle: 'Hierarchical Sub-goals',
6010
+ subGoalBadge: 'Sub-goal',
6011
+ subGoalsPill: 'sub-goals',
6012
+ noSubGoals: 'No sub-goals spawned yet. Model can spawn sub-goals via goal_spawn_subgoal tool.',
6013
+ parentGoalBanner: 'Child Sub-goal of:',
6014
+ compactedBadge: 'Compacted',
6015
+ compactionTooltip: 'History compacted to reduce context footprint',
5838
6016
 
5839
6017
  },
5840
6018
 
@@ -5997,6 +6175,14 @@ window.__ModuleLoader__.load({
5997
6175
  allFiles: '所有文件',
5998
6176
  planModeDesc: '先制定里程碑方案,人工确认后再执行',
5999
6177
  actModeDesc: '立即开始编码与任务执行',
6178
+ tabSubGoals: '子目标',
6179
+ subGoalsTitle: '分层子目标管理',
6180
+ subGoalBadge: '子目标',
6181
+ subGoalsPill: '个子目标',
6182
+ noSubGoals: '当前任务暂无衍生子目标。模型可通过 goal_spawn_subgoal 工具动态创建独立子目标。',
6183
+ parentGoalBanner: '衍生自主目标:',
6184
+ compactedBadge: '已压缩',
6185
+ compactionTooltip: '阶段历史已自动精简压缩以优化上下文窗口',
6000
6186
 
6001
6187
  },
6002
6188
 
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Milestone Context Compaction for DSH Goal Engine (Issue #167).
3
+ * Compresses verbose raw logs and full checklist traces of completed milestones
4
+ * into high-density summaries, cutting context token footprint by > 40%.
5
+ */
6
+
7
+ /**
8
+ * Compact a single milestone upon completion into a structured summary
9
+ * @param {object} milestone
10
+ * @param {object} [options]
11
+ * @returns {object}
12
+ */
13
+ export function compactMilestone(milestone, options = {}) {
14
+ if (!milestone) return null;
15
+
16
+ const id = milestone.id ? String(milestone.id).toUpperCase() : 'M';
17
+ const title = milestone.title || 'Milestone';
18
+ const notes = milestone.notes ? String(milestone.notes).trim() : '';
19
+
20
+ let checklistPart = '';
21
+ if (Array.isArray(milestone.checklist) && milestone.checklist.length > 0) {
22
+ const doneCount = milestone.checklist.filter((item) => item && item.done).length;
23
+ checklistPart = ` [${doneCount}/${milestone.checklist.length} subtasks]`;
24
+ }
25
+
26
+ let gatePart = '';
27
+ if (milestone.verificationCommand) {
28
+ gatePart = milestone.verificationPassed ? ' [Gate: verified]' : ' [Gate: passed]';
29
+ }
30
+
31
+ const outcomePart = notes ? ` (Outcome: ${notes})` : '';
32
+ const summaryLine = `[${id} COMPLETED] ${title}${outcomePart}${checklistPart}${gatePart}`;
33
+
34
+ milestone.compactionSummary = summaryLine;
35
+ milestone.compactedAt = milestone.completedAt || Date.now();
36
+
37
+ return {
38
+ id: milestone.id,
39
+ title,
40
+ notes,
41
+ summary: summaryLine,
42
+ completedAt: milestone.compactedAt,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Build compacted milestones history block for active prompt injection
48
+ * @param {Array<object>} milestones
49
+ * @param {string} [lang='en']
50
+ * @returns {string}
51
+ */
52
+ export function buildCompactedMilestonesHistory(milestones = [], lang = 'en') {
53
+ if (!Array.isArray(milestones) || milestones.length === 0) return '';
54
+
55
+ const completed = milestones.filter((m) => m && m.status === 'completed');
56
+ if (completed.length === 0) return '';
57
+
58
+ const header = lang === 'zh'
59
+ ? '[已完成里程碑历史简报 / COMPACTED MILESTONES HISTORY]'
60
+ : '[COMPACTED MILESTONES HISTORY]';
61
+
62
+ const lines = completed.map((m) => {
63
+ if (m.compactionSummary) return `- ${m.compactionSummary}`;
64
+ const id = m.id ? String(m.id).toUpperCase() : 'M';
65
+ const outcome = m.notes ? ` (Outcome: ${m.notes})` : '';
66
+ return `- [${id} COMPLETED] ${m.title}${outcome}`;
67
+ });
68
+
69
+ return `${header}\n${lines.join('\n')}`;
70
+ }
71
+
72
+ /**
73
+ * Format milestone into raw uncompacted text (for baseline comparison & active milestones)
74
+ * @param {object} m
75
+ * @param {number} idx
76
+ * @returns {string}
77
+ */
78
+ export function formatRawMilestoneTrace(m, idx = 0) {
79
+ if (!m) return '';
80
+ const statusStr = (m.status || 'pending').toUpperCase();
81
+ let line = ` ${idx + 1}. [${statusStr}] ${m.title || ''}${m.notes ? ` (${m.notes})` : ''}`;
82
+ if (m.verificationCommand) {
83
+ line += `\n [verification_command: ${m.verificationCommand}]`;
84
+ }
85
+ if (Array.isArray(m.checklist) && m.checklist.length > 0) {
86
+ const checkLines = m.checklist
87
+ .map((item) => ` [${item && item.done ? 'x' : ' '}] ${item && item.text ? item.text : ''}`)
88
+ .join('\n');
89
+ line += `\n${checkLines}`;
90
+ }
91
+ return line;
92
+ }
93
+
94
+ /**
95
+ * Estimate context savings achieved by compaction on completed milestones
96
+ * @param {Array<object>} milestones
97
+ * @param {string} [lang='en']
98
+ * @returns {{ completedCount: number, uncompactedChars: number, compactedChars: number, savedChars: number, savingsRatio: number, savingsPercent: string }}
99
+ */
100
+ export function estimateCompactionSavings(milestones = [], lang = 'en') {
101
+ if (!Array.isArray(milestones) || milestones.length === 0) {
102
+ return { completedCount: 0, uncompactedChars: 0, compactedChars: 0, savedChars: 0, savingsRatio: 0, savingsPercent: '0%' };
103
+ }
104
+
105
+ const completed = milestones.filter((m) => m && m.status === 'completed');
106
+ if (completed.length === 0) {
107
+ return { completedCount: 0, uncompactedChars: 0, compactedChars: 0, savedChars: 0, savingsRatio: 0, savingsPercent: '0%' };
108
+ }
109
+
110
+ const uncompacted = completed.map((m, i) => formatRawMilestoneTrace(m, i)).join('\n');
111
+ const compacted = completed.map((m) => m.compactionSummary || `[${m.id ? String(m.id).toUpperCase() : 'M'} COMPLETED] ${m.title}`).join('\n');
112
+
113
+ const uncompactedChars = uncompacted.length;
114
+ const compactedChars = compacted.length;
115
+ const savedChars = Math.max(0, uncompactedChars - compactedChars);
116
+ const savingsRatio = uncompactedChars > 0 ? (savedChars / uncompactedChars) : 0;
117
+
118
+ return {
119
+ completedCount: completed.length,
120
+ uncompactedChars,
121
+ compactedChars,
122
+ savedChars,
123
+ savingsRatio,
124
+ savingsPercent: `${Math.round(savingsRatio * 100)}%`,
125
+ };
126
+ }
@@ -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
+ }
@@ -192,6 +192,12 @@ export function buildSnapshot(goal, sid, engine) {
192
192
  enableMilestoneDependencies: engine.enableMilestoneDependencies ?? true,
193
193
  soundScheme: engine.soundScheme ?? 'default',
194
194
  enableVoiceAnnouncements: engine.enableVoiceAnnouncements ?? false,
195
+ enableMilestoneCompaction: engine.enableMilestoneCompaction ?? true,
196
+ parentGoalId: null,
197
+ parentSessionId: null,
198
+ isSubGoal: false,
199
+ subGoals: [],
200
+ breadcrumbs: [],
195
201
  operatorQuery: null,
196
202
  waitingForOperator: false,
197
203
  };
@@ -253,6 +259,12 @@ export function buildSnapshot(goal, sid, engine) {
253
259
  enableMilestoneDependencies: engine.enableMilestoneDependencies ?? true,
254
260
  soundScheme: engine.soundScheme ?? 'default',
255
261
  enableVoiceAnnouncements: engine.enableVoiceAnnouncements ?? false,
262
+ enableMilestoneCompaction: engine.enableMilestoneCompaction ?? true,
263
+ parentGoalId: goal.parentGoalId || null,
264
+ parentSessionId: goal.parentSessionId || null,
265
+ isSubGoal: Boolean(goal.parentGoalId || goal.isSubGoal),
266
+ subGoals: Array.isArray(goal.subGoals) ? goal.subGoals : [],
267
+ breadcrumbs: typeof engine.getSubGoalBreadcrumbs === "function" ? engine.getSubGoalBreadcrumbs(sid) : [],
256
268
  operatorQuery: goal.operatorQuery || null,
257
269
  waitingForOperator: Boolean(goal.waitingForOperator),
258
270
  };
@@ -13,8 +13,10 @@ import { parseIssueRef, parseIssueChecklist, syncMilestoneWithIssue } from './en
13
13
  import { generateRetrospectiveData } from './engine-retrospective.js';
14
14
  import { generatePreplanDraft } from './engine-templates.js';
15
15
  import { pruneInactiveSessions, addNudgeToGoal, consumeGoalNudge, extendGoalBudget, setGoalMode, pauseGoal, resumeGoal, cancelGoal } from './engine-sessions.js';
16
+ import { compactMilestone, estimateCompactionSavings } from './engine-compaction.js';
17
+ import { spawnSubGoal, completeSubGoal, syncSubGoalToParent, getSubGoalBreadcrumbs, getSubGoalsList } from './engine-subgoals.js';
16
18
 
17
- export { GoalMode, GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, matchMilestone, parseIssueChecklist, getGitCurrentCommit, exportReportMarkdown, exportReportGitHubPR, createMilestoneCheckpoint, rollbackToCheckpoint, saveGoalArtifact };
19
+ export { GoalMode, GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, matchMilestone, parseIssueChecklist, getGitCurrentCommit, exportReportMarkdown, exportReportGitHubPR, createMilestoneCheckpoint, rollbackToCheckpoint, saveGoalArtifact, compactMilestone, estimateCompactionSavings, spawnSubGoal, completeSubGoal, getSubGoalBreadcrumbs, getSubGoalsList };
18
20
 
19
21
  /**
20
22
  * Isolated Goal Management Engine for DeepSeek Harness
@@ -34,7 +36,7 @@ export class GoalEngine {
34
36
  autoBranchOnGoalStart: options.autoBranchOnGoalStart ?? true, enablePostGoalRetrospective: options.enablePostGoalRetrospective ?? true,
35
37
  autoScaleBudgetNearCompletion: options.autoScaleBudgetNearCompletion ?? false, enableTemplatesDrawer: options.enableTemplatesDrawer ?? true,
36
38
  enableMilestoneDependencies: options.enableMilestoneDependencies ?? true, soundScheme: options.soundScheme ?? 'default', enableKanbanSync: options.enableKanbanSync ?? true, ctx: options.ctx || null, kanbanService: options.kanbanService || null, kanbanStore: options.kanbanStore || null,
37
- enableVoiceAnnouncements: options.enableVoiceAnnouncements ?? false, approvalGate: options.approvalGate || null,
39
+ enableVoiceAnnouncements: options.enableVoiceAnnouncements ?? false, enableMilestoneCompaction: options.enableMilestoneCompaction ?? true, approvalGate: options.approvalGate || null,
38
40
  checkApproval: options.checkApproval || null, maxSessions: options.maxSessions ?? 100,
39
41
  });
40
42
  this.goals = new Map();
@@ -58,10 +60,7 @@ export class GoalEngine {
58
60
  flushSync() { this.store.flushSync(); }
59
61
  saveStateToDisk() { this.flushSync(); }
60
62
 
61
- subscribe(callback) {
62
- this.listeners.add(callback);
63
- return () => this.listeners.delete(callback);
64
- }
63
+ subscribe(callback) { this.listeners.add(callback); return () => this.listeners.delete(callback); }
65
64
 
66
65
  emit(sessionId = 'default', immediate = false) {
67
66
  this.scheduleSave(immediate);
@@ -76,21 +75,9 @@ export class GoalEngine {
76
75
  }
77
76
  }
78
77
 
79
- recordProgress(sessionId = 'default') {
80
- this.stallCounters.set(sessionId || 'default', 0);
81
- }
82
-
83
- incrementStallCount(sessionId = 'default') {
84
- const sid = sessionId || 'default';
85
- const current = this.stallCounters.get(sid) || 0;
86
- const next = current + 1;
87
- this.stallCounters.set(sid, next);
88
- return next;
89
- }
90
-
91
- getStallCount(sessionId = 'default') {
92
- return this.stallCounters.get(sessionId || 'default') || 0;
93
- }
78
+ recordProgress(sessionId = 'default') { this.stallCounters.set(sessionId || 'default', 0); }
79
+ incrementStallCount(sessionId = 'default') { const sid = sessionId || 'default'; const n = (this.stallCounters.get(sid) || 0) + 1; this.stallCounters.set(sid, n); return n; }
80
+ getStallCount(sessionId = 'default') { return this.stallCounters.get(sessionId || 'default') || 0; }
94
81
 
95
82
  updateConfig(config = {}) {
96
83
  const rawMax = config.defaultMaxIterations ?? config.maxIterations;
@@ -104,7 +91,7 @@ export class GoalEngine {
104
91
  }
105
92
  }
106
93
  }
107
- const boolKeys = ['autoDrive', 'enableSound', 'showQuickLaunchButton', 'autoCheckpointOnMilestone', 'enableLiveActivityFeed', 'enablePreplanning', 'autoBranchOnGoalStart', 'enablePostGoalRetrospective', 'autoScaleBudgetNearCompletion', 'enableTemplatesDrawer', 'enableMilestoneDependencies', 'enableVoiceAnnouncements'];
94
+ const boolKeys = ['autoDrive', 'enableSound', 'showQuickLaunchButton', 'autoCheckpointOnMilestone', 'enableLiveActivityFeed', 'enablePreplanning', 'autoBranchOnGoalStart', 'enablePostGoalRetrospective', 'autoScaleBudgetNearCompletion', 'enableTemplatesDrawer', 'enableMilestoneDependencies', 'enableVoiceAnnouncements', 'enableMilestoneCompaction'];
108
95
  for (const k of boolKeys) if (typeof config[k] === 'boolean') this[k] = config[k];
109
96
  if (typeof config.consecutiveToolFailureLimit === 'number') this.consecutiveToolFailureLimit = Math.max(0, config.consecutiveToolFailureLimit);
110
97
  if (typeof config.maxTokenBudget === 'number') this.maxTokenBudget = Math.max(0, config.maxTokenBudget);
@@ -208,6 +195,10 @@ export class GoalEngine {
208
195
  issueRef: parseIssueRef(cleanTitle),
209
196
  operatorQuery: null,
210
197
  waitingForOperator: false,
198
+ parentGoalId: options.parentGoalId || null,
199
+ parentSessionId: options.parentSessionId || null,
200
+ isSubGoal: Boolean(options.parentGoalId),
201
+ subGoals: [],
211
202
  tokensUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
212
203
  logs: [{
213
204
  timestamp: now,
@@ -305,16 +296,14 @@ export class GoalEngine {
305
296
  m.status = MilestoneStatus.COMPLETED;
306
297
  }
307
298
  }
299
+ if (goal.parentSessionId) syncSubGoalToParent(this, sid, summary);
308
300
  this.stallCounters.delete(sid);
309
301
  this.toolFailureCounters.delete(sid);
310
302
  this.emit(sid, true);
311
303
  return this.getSnapshot(sid);
312
304
  }
313
305
 
314
- setMilestones(milestonesList, sessionId = 'default') {
315
- this.addMilestones(milestonesList, true, sessionId);
316
- return this.getSnapshot(sessionId);
317
- }
306
+ setMilestones(milestonesList, sessionId = 'default') { this.addMilestones(milestonesList, true, sessionId); return this.getSnapshot(sessionId); }
318
307
 
319
308
  addMilestones(milestonesList, shouldEmit = true, sessionId = 'default') {
320
309
  const sid = typeof shouldEmit === 'string' ? shouldEmit : (sessionId || 'default');
@@ -365,6 +354,7 @@ export class GoalEngine {
365
354
  if (this.enableKanbanSync) syncMilestoneStatusToKanban(target, { goal, ctx: this.ctx, store: this.kanbanStore, kanbanService: this.kanbanService }).catch(() => {});
366
355
 
367
356
  if (target.status === MilestoneStatus.COMPLETED && prevStatus !== MilestoneStatus.COMPLETED) {
357
+ if (this.enableMilestoneCompaction !== false) compactMilestone(target, { goal, cwd: goal.cwd });
368
358
  if (this.autoCheckpointOnMilestone) {
369
359
  const commitHash = createMilestoneCheckpoint(target, sid);
370
360
  if (commitHash) {
@@ -439,9 +429,7 @@ export class GoalEngine {
439
429
  return true;
440
430
  }
441
431
 
442
- getElapsedSeconds(sessionId = 'default') {
443
- return calculateElapsedSeconds(this.goals.get(sessionId || 'default'));
444
- }
432
+ getElapsedSeconds(sessionId = 'default') { return calculateElapsedSeconds(this.goals.get(sessionId || 'default')); }
445
433
 
446
434
  addTokenUsage(usage, sessionId = 'default') {
447
435
  if (!usage || typeof usage !== 'object') return;
@@ -471,19 +459,9 @@ export class GoalEngine {
471
459
  this.emit(sid);
472
460
  }
473
461
 
474
- getEstimatedRemainingSeconds(sessionId = 'default') {
475
- const sid = sessionId || 'default';
476
- return calculateRemainingSeconds(this.goals.get(sid), this.getElapsedSeconds(sid));
477
- }
478
-
479
- getSnapshot(sessionId = 'default') {
480
- const sid = sessionId || 'default';
481
- return buildSnapshot(this.goals.get(sid), sid, this);
482
- }
483
-
484
- getGoalSnapshot(sessionId = 'default') {
485
- return this.getSnapshot(sessionId);
486
- }
462
+ getEstimatedRemainingSeconds(sessionId = 'default') { const sid = sessionId || 'default'; return calculateRemainingSeconds(this.goals.get(sid), this.getElapsedSeconds(sid)); }
463
+ getSnapshot(sessionId = 'default') { const sid = sessionId || 'default'; return buildSnapshot(this.goals.get(sid), sid, this); }
464
+ getGoalSnapshot(sessionId = 'default') { return this.getSnapshot(sessionId); }
487
465
 
488
466
  getStatePromptInjection(sessionId = 'default') {
489
467
  const sid = sessionId || 'default';
@@ -591,4 +569,9 @@ export class GoalEngine {
591
569
  const goal = this.goals.get(sessionId || 'default');
592
570
  return Boolean(goal && (goal.mode === GoalMode.PLAN || goal.state === GoalState.PLANNING));
593
571
  }
572
+ spawnSubGoal(options = {}, sessionId = 'default') { return spawnSubGoal(this, sessionId, options); }
573
+ completeSubGoal(sessionId, summary = '') { return completeSubGoal(this, sessionId, summary); }
574
+ getSubGoals(sessionId = 'default') { return getSubGoalsList(this, sessionId); }
575
+ getSubGoalBreadcrumbs(sessionId = 'default') { return getSubGoalBreadcrumbs(this, sessionId); }
576
+ getCompactionSavings(sessionId = 'default') { const g = this.goals.get(sessionId || 'default'); return estimateCompactionSavings(g?.milestones || [], g?.lang || 'en'); }
594
577
  }
package/lib/index.js CHANGED
@@ -143,13 +143,6 @@ export function apply(ctx, config = {}) {
143
143
  logger.warn?.('[dsh-goal] Legacy migration check failed:', migErr.message);
144
144
  }
145
145
 
146
- // Register addon tools: goal_milestones and goal_checklist
147
- registerTools(ctx, {
148
- milestoneManager,
149
- goalsService: safeService(ctx, "goals"),
150
- logger,
151
- });
152
-
153
146
  // Support fallback engine for tests or non-core environments
154
147
  let fallbackEngine = null;
155
148
  if (!safeService(ctx, "goals")) {
@@ -168,12 +161,22 @@ export function apply(ctx, config = {}) {
168
161
  autoScaleBudgetNearCompletion: currentSettings.autoScaleBudgetNearCompletion,
169
162
  enableTemplatesDrawer: currentSettings.enableTemplatesDrawer,
170
163
  enableMilestoneDependencies: currentSettings.enableMilestoneDependencies,
164
+ enableMilestoneCompaction: currentSettings.enableMilestoneCompaction,
171
165
  approvalGate: safeService(ctx, "approvalGate"),
172
166
  logger,
173
167
  });
168
+ ctx.goalEngine = fallbackEngine;
174
169
  registerPlanModeGuard(ctx, { engine: fallbackEngine, logger });
175
170
  }
176
171
 
172
+ // Register addon tools: goal_milestones, goal_checklist, goal_spawn_subgoal
173
+ registerTools(ctx, {
174
+ milestoneManager,
175
+ goalsService: safeService(ctx, "goals"),
176
+ engine: fallbackEngine,
177
+ logger,
178
+ });
179
+
177
180
  // 1. One-click auto-updater registration (Issue #99)
178
181
  if (ctx?.webServer?.register) {
179
182
  if (typeof ctx?.effect === 'function') {
package/lib/routes.js CHANGED
@@ -873,6 +873,31 @@ export function registerRoutes(ctx, {
873
873
  break;
874
874
  }
875
875
 
876
+ case 'spawn_subgoal': {
877
+ const subTitle = typeof data.title === 'string' ? data.title.trim() : '';
878
+ if (!subTitle) {
879
+ res.statusCode = 400;
880
+ return res.end(JSON.stringify({ error: 'Sub-goal title is required' }));
881
+ }
882
+ if (engine) {
883
+ const spawnRes = engine.spawnSubGoal({
884
+ title: subTitle,
885
+ prompt: data.prompt || data.description,
886
+ maxIterations: data.maxIterations ?? data.max_iterations,
887
+ inheritContext: data.inheritContext ?? data.inherit_context,
888
+ }, sid);
889
+ if (!spawnRes.ok) {
890
+ res.statusCode = 400;
891
+ return res.end(JSON.stringify({ error: spawnRes.error || 'Failed to spawn sub-goal' }));
892
+ }
893
+ result = spawnRes;
894
+ } else {
895
+ res.statusCode = 404;
896
+ result = { ok: false, error: 'Goal engine unavailable' };
897
+ }
898
+ break;
899
+ }
900
+
876
901
  case 'instantiate_template': {
877
902
  const tplId = data.templateId || data.id;
878
903
  if (!tplId) {
package/lib/tools.js CHANGED
@@ -376,5 +376,74 @@ export function registerTools(ctx, { milestoneManager, goalsService, logger = co
376
376
  output: JSON_OUTPUT,
377
377
  execute: handleGoalAskOperator,
378
378
  });
379
+
380
+ // Tool 7: goal_spawn_subgoal (Issue #170)
381
+ const handleGoalSpawnSubgoal = async (args = {}, toolCtx = {}) => {
382
+ const parentSessionId = args.goalId || args.goal_id || resolveGoalId(toolCtx);
383
+ const title = args.title || args.goal;
384
+ if (!title || typeof title !== 'string' || !title.trim()) {
385
+ throw new Error('Field "title" is required and must be a non-empty string');
386
+ }
387
+
388
+ const engine = ctx.goalEngine || (ctx.get && ctx.get('goalEngine'));
389
+ if (engine && typeof engine.spawnSubGoal === 'function') {
390
+ const res = engine.spawnSubGoal({
391
+ title: title.trim(),
392
+ prompt: args.prompt || args.description,
393
+ maxIterations: args.max_iterations ?? args.maxIterations,
394
+ inheritContext: args.inherit_context ?? args.inheritContext,
395
+ }, parentSessionId);
396
+
397
+ if (!res.ok) {
398
+ throw new Error(`Failed to spawn sub-goal: ${res.error}`);
399
+ }
400
+
401
+ return {
402
+ ok: true,
403
+ parentGoalId: res.parentGoalId,
404
+ parentSessionId: res.parentSessionId,
405
+ subGoalId: res.subGoalId,
406
+ childSessionId: res.childSessionId,
407
+ title: title.trim(),
408
+ message: 'Sub-goal spawned with isolated execution context. Complete sub-milestones and invoke goal_finish to conclude.',
409
+ };
410
+ }
411
+
412
+ return {
413
+ ok: true,
414
+ parentSessionId,
415
+ title: title.trim(),
416
+ message: 'Sub-goal registered (fallback mode).',
417
+ };
418
+ };
419
+
420
+ safeRegister({
421
+ name: 'goal_spawn_subgoal',
422
+ description: 'Spawn a child sub-goal with isolated context and task checklist (#170). When all sub-milestones are completed, invoking goal_finish returns deliverables and control to the parent goal.',
423
+ parameters: {
424
+ type: 'object',
425
+ properties: {
426
+ title: {
427
+ type: 'string',
428
+ description: 'Clear, actionable title of the sub-goal',
429
+ },
430
+ prompt: {
431
+ type: 'string',
432
+ description: 'Optional context, detailed instructions, or constraints for the sub-goal',
433
+ },
434
+ max_iterations: {
435
+ type: 'number',
436
+ description: 'Maximum iteration budget for the sub-goal (default: 15)',
437
+ },
438
+ inherit_context: {
439
+ type: 'boolean',
440
+ description: 'Whether the child sub-goal inherits parent goal context summary (default: true)',
441
+ },
442
+ },
443
+ required: ['title'],
444
+ },
445
+ output: JSON_OUTPUT,
446
+ execute: handleGoalSpawnSubgoal,
447
+ });
379
448
  });
380
449
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-goal",
3
- "version": "0.2.31",
3
+ "version": "0.2.32",
4
4
  "description": "Core Goal Addon (Token Budget, Milestones, Git Snapshots & Retrospective Reports) for DeepSeek Harness",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",