@goodandready/dsh-goal 0.2.31 → 0.2.33

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.
@@ -13,8 +13,12 @@ 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';
18
+ import { updateGoalCostUsage, processCostBudget, calculateCost, formatCurrency } from './engine-cost-meter.js';
19
+ import { generateReleaseNotes, dispatchGoalTelegramNotification } from './engine-release-notes.js';
16
20
 
17
- export { GoalMode, GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, matchMilestone, parseIssueChecklist, getGitCurrentCommit, exportReportMarkdown, exportReportGitHubPR, createMilestoneCheckpoint, rollbackToCheckpoint, saveGoalArtifact };
21
+ export { GoalMode, GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, matchMilestone, parseIssueChecklist, getGitCurrentCommit, exportReportMarkdown, exportReportGitHubPR, createMilestoneCheckpoint, rollbackToCheckpoint, saveGoalArtifact, compactMilestone, estimateCompactionSavings, spawnSubGoal, completeSubGoal, getSubGoalBreadcrumbs, getSubGoalsList, calculateCost, formatCurrency, generateReleaseNotes, dispatchGoalTelegramNotification };
18
22
 
19
23
  /**
20
24
  * Isolated Goal Management Engine for DeepSeek Harness
@@ -34,8 +38,8 @@ export class GoalEngine {
34
38
  autoBranchOnGoalStart: options.autoBranchOnGoalStart ?? true, enablePostGoalRetrospective: options.enablePostGoalRetrospective ?? true,
35
39
  autoScaleBudgetNearCompletion: options.autoScaleBudgetNearCompletion ?? false, enableTemplatesDrawer: options.enableTemplatesDrawer ?? true,
36
40
  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,
38
- checkApproval: options.checkApproval || null, maxSessions: options.maxSessions ?? 100,
41
+ enableVoiceAnnouncements: options.enableVoiceAnnouncements ?? false, enableMilestoneCompaction: options.enableMilestoneCompaction ?? true, approvalGate: options.approvalGate || null,
42
+ checkApproval: options.checkApproval || null, maxSessions: options.maxSessions ?? 100, maxCostBudget: options.maxCostBudget ?? 0, costWarningThreshold: options.costWarningThreshold ?? 80, currency: options.currency || 'USD', enableTelegramNotifications: options.enableTelegramNotifications ?? false, activeModel: options.activeModel || '', customRates: options.customRates || {},
39
43
  });
40
44
  this.goals = new Map();
41
45
  this.listeners = new Set();
@@ -58,10 +62,7 @@ export class GoalEngine {
58
62
  flushSync() { this.store.flushSync(); }
59
63
  saveStateToDisk() { this.flushSync(); }
60
64
 
61
- subscribe(callback) {
62
- this.listeners.add(callback);
63
- return () => this.listeners.delete(callback);
64
- }
65
+ subscribe(callback) { this.listeners.add(callback); return () => this.listeners.delete(callback); }
65
66
 
66
67
  emit(sessionId = 'default', immediate = false) {
67
68
  this.scheduleSave(immediate);
@@ -76,21 +77,9 @@ export class GoalEngine {
76
77
  }
77
78
  }
78
79
 
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
- }
80
+ recordProgress(sessionId = 'default') { this.stallCounters.set(sessionId || 'default', 0); }
81
+ incrementStallCount(sessionId = 'default') { const sid = sessionId || 'default'; const n = (this.stallCounters.get(sid) || 0) + 1; this.stallCounters.set(sid, n); return n; }
82
+ getStallCount(sessionId = 'default') { return this.stallCounters.get(sessionId || 'default') || 0; }
94
83
 
95
84
  updateConfig(config = {}) {
96
85
  const rawMax = config.defaultMaxIterations ?? config.maxIterations;
@@ -104,12 +93,17 @@ export class GoalEngine {
104
93
  }
105
94
  }
106
95
  }
107
- const boolKeys = ['autoDrive', 'enableSound', 'showQuickLaunchButton', 'autoCheckpointOnMilestone', 'enableLiveActivityFeed', 'enablePreplanning', 'autoBranchOnGoalStart', 'enablePostGoalRetrospective', 'autoScaleBudgetNearCompletion', 'enableTemplatesDrawer', 'enableMilestoneDependencies', 'enableVoiceAnnouncements'];
96
+ const boolKeys = ['autoDrive', 'enableSound', 'showQuickLaunchButton', 'autoCheckpointOnMilestone', 'enableLiveActivityFeed', 'enablePreplanning', 'autoBranchOnGoalStart', 'enablePostGoalRetrospective', 'autoScaleBudgetNearCompletion', 'enableTemplatesDrawer', 'enableMilestoneDependencies', 'enableVoiceAnnouncements', 'enableMilestoneCompaction'];
108
97
  for (const k of boolKeys) if (typeof config[k] === 'boolean') this[k] = config[k];
109
98
  if (typeof config.consecutiveToolFailureLimit === 'number') this.consecutiveToolFailureLimit = Math.max(0, config.consecutiveToolFailureLimit);
110
99
  if (typeof config.maxTokenBudget === 'number') this.maxTokenBudget = Math.max(0, config.maxTokenBudget);
111
100
  if (typeof config.budgetWarningThreshold === 'number') this.budgetWarningThreshold = Math.max(1, Math.min(100, config.budgetWarningThreshold));
112
101
  if (typeof config.soundScheme === 'string') this.soundScheme = config.soundScheme;
102
+ if (typeof config.maxCostBudget === 'number') this.maxCostBudget = Math.max(0, config.maxCostBudget);
103
+ if (typeof config.costWarningThreshold === 'number') this.costWarningThreshold = Math.max(1, Math.min(100, config.costWarningThreshold));
104
+ if (typeof config.enableTelegramNotifications === 'boolean') this.enableTelegramNotifications = config.enableTelegramNotifications;
105
+ if (typeof config.currency === 'string') this.currency = config.currency;
106
+ if (typeof config.activeModel === 'string') this.activeModel = config.activeModel;
113
107
  this.emit();
114
108
  }
115
109
 
@@ -208,7 +202,17 @@ export class GoalEngine {
208
202
  issueRef: parseIssueRef(cleanTitle),
209
203
  operatorQuery: null,
210
204
  waitingForOperator: false,
205
+ parentGoalId: options.parentGoalId || null,
206
+ parentSessionId: options.parentSessionId || null,
207
+ isSubGoal: Boolean(options.parentGoalId),
208
+ subGoals: [],
211
209
  tokensUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
210
+ maxCostBudget: options.maxCostBudget ?? this.maxCostBudget ?? 0,
211
+ currency: options.currency || this.currency || 'USD',
212
+ costUsage: { promptCost: 0, completionCost: 0, totalCost: 0, formattedCost: '.00', currency: options.currency || this.currency || 'USD' },
213
+ costWarningTriggered: false,
214
+ costLimitExceeded: false,
215
+ telegramNotificationsSent: [],
212
216
  logs: [{
213
217
  timestamp: now,
214
218
  type: 'info',
@@ -305,16 +309,15 @@ export class GoalEngine {
305
309
  m.status = MilestoneStatus.COMPLETED;
306
310
  }
307
311
  }
312
+ if (goal.parentSessionId) syncSubGoalToParent(this, sid, summary);
313
+ if (this.enableTelegramNotifications) { dispatchGoalTelegramNotification(goal, { engine: this, ctx: this.ctx }).catch(() => {}); }
308
314
  this.stallCounters.delete(sid);
309
315
  this.toolFailureCounters.delete(sid);
310
316
  this.emit(sid, true);
311
317
  return this.getSnapshot(sid);
312
318
  }
313
319
 
314
- setMilestones(milestonesList, sessionId = 'default') {
315
- this.addMilestones(milestonesList, true, sessionId);
316
- return this.getSnapshot(sessionId);
317
- }
320
+ setMilestones(milestonesList, sessionId = 'default') { this.addMilestones(milestonesList, true, sessionId); return this.getSnapshot(sessionId); }
318
321
 
319
322
  addMilestones(milestonesList, shouldEmit = true, sessionId = 'default') {
320
323
  const sid = typeof shouldEmit === 'string' ? shouldEmit : (sessionId || 'default');
@@ -365,6 +368,7 @@ export class GoalEngine {
365
368
  if (this.enableKanbanSync) syncMilestoneStatusToKanban(target, { goal, ctx: this.ctx, store: this.kanbanStore, kanbanService: this.kanbanService }).catch(() => {});
366
369
 
367
370
  if (target.status === MilestoneStatus.COMPLETED && prevStatus !== MilestoneStatus.COMPLETED) {
371
+ if (this.enableMilestoneCompaction !== false) compactMilestone(target, { goal, cwd: goal.cwd });
368
372
  if (this.autoCheckpointOnMilestone) {
369
373
  const commitHash = createMilestoneCheckpoint(target, sid);
370
374
  if (commitHash) {
@@ -439,9 +443,7 @@ export class GoalEngine {
439
443
  return true;
440
444
  }
441
445
 
442
- getElapsedSeconds(sessionId = 'default') {
443
- return calculateElapsedSeconds(this.goals.get(sessionId || 'default'));
444
- }
446
+ getElapsedSeconds(sessionId = 'default') { return calculateElapsedSeconds(this.goals.get(sessionId || 'default')); }
445
447
 
446
448
  addTokenUsage(usage, sessionId = 'default') {
447
449
  if (!usage || typeof usage !== 'object') return;
@@ -458,32 +460,24 @@ export class GoalEngine {
458
460
  goal.tokensUsage.completionTokens += completion;
459
461
  goal.tokensUsage.totalTokens += total;
460
462
 
463
+ updateGoalCostUsage(goal, { promptTokens: prompt, completionTokens: completion, totalTokens: total, modelName: usage.modelName || usage.model || this.activeModel || '' }, this);
464
+ const costCheck = processCostBudget(goal, { maxCostBudget: goal.maxCostBudget ?? this.maxCostBudget, costWarningThreshold: this.costWarningThreshold, GoalState });
461
465
  const budgetCheck = processTokenBudget(goal, {
462
466
  maxTokenBudget: this.maxTokenBudget,
463
467
  budgetWarningThreshold: this.budgetWarningThreshold,
464
468
  autoScaleBudgetNearCompletion: this.autoScaleBudgetNearCompletion,
465
469
  GoalState,
466
470
  });
467
- if (budgetCheck.paused) {
471
+ if (budgetCheck.paused || costCheck.paused) {
468
472
  this.emit(sid, true);
469
473
  return;
470
474
  }
471
475
  this.emit(sid);
472
476
  }
473
477
 
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
- }
478
+ getEstimatedRemainingSeconds(sessionId = 'default') { const sid = sessionId || 'default'; return calculateRemainingSeconds(this.goals.get(sid), this.getElapsedSeconds(sid)); }
479
+ getSnapshot(sessionId = 'default') { const sid = sessionId || 'default'; return buildSnapshot(this.goals.get(sid), sid, this); }
480
+ getGoalSnapshot(sessionId = 'default') { return this.getSnapshot(sessionId); }
487
481
 
488
482
  getStatePromptInjection(sessionId = 'default') {
489
483
  const sid = sessionId || 'default';
@@ -591,4 +585,11 @@ export class GoalEngine {
591
585
  const goal = this.goals.get(sessionId || 'default');
592
586
  return Boolean(goal && (goal.mode === GoalMode.PLAN || goal.state === GoalState.PLANNING));
593
587
  }
588
+ spawnSubGoal(options = {}, sessionId = 'default') { return spawnSubGoal(this, sessionId, options); }
589
+ completeSubGoal(sessionId, summary = '') { return completeSubGoal(this, sessionId, summary); }
590
+ getSubGoals(sessionId = 'default') { return getSubGoalsList(this, sessionId); }
591
+ getSubGoalBreadcrumbs(sessionId = 'default') { return getSubGoalBreadcrumbs(this, sessionId); }
592
+ getCompactionSavings(sessionId = 'default') { const g = this.goals.get(sessionId || 'default'); return estimateCompactionSavings(g?.milestones || [], g?.lang || 'en'); }
593
+ exportReleaseNotes(sessionId = 'default', options = {}) { return generateReleaseNotes(this.goals.get(sessionId || 'default'), options); }
594
+ getCostUsage(sessionId = 'default') { return this.goals.get(sessionId || 'default')?.costUsage || null; }
594
595
  }
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') {
@@ -335,7 +338,15 @@ export function apply(ctx, config = {}) {
335
338
 
336
339
  // Compatibility events for turn/end and approval/asked
337
340
  ctx.on('turn/end', (event) => {
338
- // session isolation turn tracking
341
+ if (!fallbackEngine || !event) return;
342
+ const sid = sessionIdOf(event?.agent || event, 'default');
343
+ const usage = event?.usage || event?.tokenUsage || event?.tokens || event?.response?.usage;
344
+ if (usage) {
345
+ fallbackEngine.addTokenUsage({
346
+ ...usage,
347
+ modelName: event?.model || event?.modelName || event?.response?.model || '',
348
+ }, sid);
349
+ }
339
350
  });
340
351
 
341
352
  ctx.on('approval/asked', (event) => {
package/lib/routes.js CHANGED
@@ -5,6 +5,7 @@ import { isValidGitRef } from './engine-git-branch.js';
5
5
  import { saveGoalArtifact, rollbackToCheckpoint } from './engine-reports.js';
6
6
  import { getTemplatesCatalogue, instantiateTemplate } from './engine-templates.js';
7
7
  import { parseIssueChecklist } from './engine-issue-sync.js';
8
+ import { dispatchGoalTelegramNotification } from './engine-release-notes.js';
8
9
 
9
10
  export function isLoopback(value) {
10
11
  if (!value) return false;
@@ -490,6 +491,8 @@ export function registerRoutes(ctx, {
490
491
  cwd: safeStartCwd,
491
492
  issueBody: data.issueBody,
492
493
  mode: data.mode,
494
+ maxCostBudget: data.maxCostBudget,
495
+ currency: data.currency,
493
496
  }, sid);
494
497
  } else {
495
498
  let parsedMilestones = [];
@@ -873,6 +876,31 @@ export function registerRoutes(ctx, {
873
876
  break;
874
877
  }
875
878
 
879
+ case 'spawn_subgoal': {
880
+ const subTitle = typeof data.title === 'string' ? data.title.trim() : '';
881
+ if (!subTitle) {
882
+ res.statusCode = 400;
883
+ return res.end(JSON.stringify({ error: 'Sub-goal title is required' }));
884
+ }
885
+ if (engine) {
886
+ const spawnRes = engine.spawnSubGoal({
887
+ title: subTitle,
888
+ prompt: data.prompt || data.description,
889
+ maxIterations: data.maxIterations ?? data.max_iterations,
890
+ inheritContext: data.inheritContext ?? data.inherit_context,
891
+ }, sid);
892
+ if (!spawnRes.ok) {
893
+ res.statusCode = 400;
894
+ return res.end(JSON.stringify({ error: spawnRes.error || 'Failed to spawn sub-goal' }));
895
+ }
896
+ result = spawnRes;
897
+ } else {
898
+ res.statusCode = 404;
899
+ result = { ok: false, error: 'Goal engine unavailable' };
900
+ }
901
+ break;
902
+ }
903
+
876
904
  case 'instantiate_template': {
877
905
  const tplId = data.templateId || data.id;
878
906
  if (!tplId) {
@@ -888,6 +916,29 @@ export function registerRoutes(ctx, {
888
916
  return res.end(JSON.stringify({ ok: true, template: instance }));
889
917
  }
890
918
 
919
+ case 'export_release_notes': {
920
+ const snap = engine ? engine.getSnapshot(sid) : null;
921
+ if (engine && (!snap || !snap.hasActiveGoal)) {
922
+ res.statusCode = 404;
923
+ return res.end(JSON.stringify({ error: 'No active goal found to export release notes' }));
924
+ }
925
+ const markdown = engine ? engine.exportReleaseNotes(sid, data.options || {}) : '';
926
+ res.statusCode = 200;
927
+ return res.end(JSON.stringify({ ok: true, releaseNotes: markdown, state: snap || {} }));
928
+ }
929
+
930
+ case 'notify_telegram': {
931
+ const snap = engine ? engine.getSnapshot(sid) : null;
932
+ if (engine && (!snap || !snap.hasActiveGoal)) {
933
+ res.statusCode = 404;
934
+ return res.end(JSON.stringify({ error: 'No active goal found to dispatch notification' }));
935
+ }
936
+ const goal = engine ? engine.getGoal(sid) : null;
937
+ const notifyRes = await dispatchGoalTelegramNotification(goal, { engine, ctx, ...(data.options || {}) });
938
+ res.statusCode = notifyRes.ok ? 200 : 500;
939
+ return res.end(JSON.stringify(notifyRes));
940
+ }
941
+
891
942
  default:
892
943
  res.statusCode = 400;
893
944
  return res.end(JSON.stringify({ error: `Unknown action: ${action}` }));
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.33",
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",