@goodandready/dsh-goal 0.2.20 → 0.2.21

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.
@@ -22,6 +22,15 @@ export class AddonStore {
22
22
  getGoalData(goalId) {
23
23
  if (!goalId) return null;
24
24
  if (!this.goals.has(goalId)) {
25
+ if (this.goals.size > 100) {
26
+ for (const [k, v] of this.goals.entries()) {
27
+ const isInactive = (!v.milestones || v.milestones.length === 0 || v.milestones.every(m => m.completed));
28
+ if (isInactive) {
29
+ this.goals.delete(k);
30
+ if (this.goals.size <= 80) break;
31
+ }
32
+ }
33
+ }
25
34
  this.goals.set(goalId, {
26
35
  milestones: [],
27
36
  snapshots: [],
@@ -32,6 +41,13 @@ export class AddonStore {
32
41
  return this.goals.get(goalId);
33
42
  }
34
43
 
44
+ removeGoalData(goalId) {
45
+ if (!goalId) return false;
46
+ const deleted = this.goals.delete(goalId);
47
+ if (deleted) this.scheduleSave();
48
+ return deleted;
49
+ }
50
+
35
51
  getMilestones(goalId) {
36
52
  const data = this.getGoalData(goalId);
37
53
  return data ? data.milestones : [];
@@ -102,13 +118,14 @@ export class AddonStore {
102
118
  return;
103
119
  }
104
120
  if (!this.saveTimer) {
105
- this.saveTimer = setTimeout(() => {
121
+ const timer = setTimeout(() => {
106
122
  this.saveTimer = null;
107
123
  this.writeToDiskSync();
108
124
  }, 200);
109
- if (typeof this.saveTimer.unref === 'function') {
110
- this.saveTimer.unref();
125
+ if (typeof timer?.unref === 'function') {
126
+ timer.unref();
111
127
  }
128
+ this.saveTimer = timer;
112
129
  }
113
130
  }
114
131
 
@@ -192,10 +209,12 @@ export class AddonStore {
192
209
  migratedAny = true;
193
210
  }
194
211
 
195
- data._migratedToAddon = true;
196
- data._migratedAt = Date.now();
197
- fs.writeFileSync(legacyPath, JSON.stringify(data, null, 2), 'utf8');
198
- this.scheduleSave(true);
212
+ if (migratedAny) {
213
+ data._migratedToAddon = true;
214
+ data._migratedAt = Date.now();
215
+ fs.writeFileSync(legacyPath, JSON.stringify(data, null, 2), 'utf8');
216
+ this.scheduleSave(true);
217
+ }
199
218
  return migratedAny;
200
219
  } catch (err) {
201
220
  this.logger.warn?.('[AddonStore] Legacy migration failed:', err.message);
@@ -116,7 +116,16 @@ export function applyMilestoneUpdate(target, status, notes = '', checklist = nul
116
116
  }
117
117
  }
118
118
 
119
- if (status && (validStatuses.length === 0 || validStatuses.includes(status))) {
119
+ if (status) {
120
+ if (validStatuses.length > 0 && !validStatuses.includes(status)) {
121
+ return {
122
+ prevStatus,
123
+ updatedStatus: prevStatus,
124
+ blocked: false,
125
+ invalidStatus: true,
126
+ error: `Invalid milestone status: ${status}`,
127
+ };
128
+ }
120
129
  target.status = status;
121
130
  }
122
131
  if (notes !== undefined && notes !== null && notes !== '') {
@@ -184,7 +184,7 @@ export function exportReportGitHubPR(state) {
184
184
  md += `\n</details>\n\n`;
185
185
  }
186
186
 
187
- md += `*Automated by [@goodandready/dsh-goal](https://github.com/GooDAnDReaDY/dsh-goal)*\n`;
187
+ md += `*Automated by [@goodandready/dsh-goal](https://github.com/goodandready/dsh-goal)*\n`;
188
188
  return md;
189
189
  }
190
190
 
@@ -261,7 +261,7 @@ export function rollbackToCheckpoint(commitHash, cwd) {
261
261
  * @param {string} [cwd]
262
262
  * @returns {{ relativePath: string, fullPath: string, content: string }|null}
263
263
  */
264
- export function saveGoalArtifact(state, cwd) {
264
+ export function saveGoalArtifact(state, cwd, addonData = {}) {
265
265
  if (!state || (!state.title && !state.objective)) return null;
266
266
  try {
267
267
  const root = cwd || process.cwd();
@@ -281,7 +281,7 @@ export function saveGoalArtifact(state, cwd) {
281
281
  const fullPath = path.join(targetDir, filename);
282
282
  const relativePath = path.join('.dsh', 'goals', filename).replace(/\\/g, '/');
283
283
 
284
- const content = exportReportMarkdown(state);
284
+ const content = exportReportMarkdown(state, addonData);
285
285
  fs.writeFileSync(fullPath, content, 'utf8');
286
286
 
287
287
  return { relativePath, fullPath, content };
@@ -295,5 +295,5 @@ export function saveGoalArtifact(state, cwd) {
295
295
  */
296
296
  export function saveGoalReportFile(goal, addonData = {}, baseDir = null) {
297
297
  const root = baseDir || process.cwd();
298
- return saveGoalArtifact(goal, root)?.fullPath || null;
298
+ return saveGoalArtifact(goal, root, addonData)?.fullPath || null;
299
299
  }
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { MilestoneStatus, calculateElapsedSeconds } from './goal-engine-constants.js';
3
+ import { isValidGitRef } from './engine-git-branch.js';
3
4
 
4
5
  /**
5
6
  * Generate post-goal retrospective analytics card
@@ -33,8 +34,9 @@ export function generateRetrospectiveData(goal, cwd = process.cwd()) {
33
34
  let gitDiffStat = '';
34
35
  let filesChangedCount = 0;
35
36
  try {
36
- const startRef = goal.startCommit || 'HEAD~1';
37
- const rawStat = execFileSync('git', ['diff', '--stat', `${startRef}..HEAD`], {
37
+ const rawStartRef = goal.gitStartCommit || goal.startCommit || 'HEAD~1';
38
+ const startRef = (rawStartRef && isValidGitRef(rawStartRef)) ? rawStartRef : 'HEAD~1';
39
+ const rawStat = execFileSync('git', ['diff', '--stat', `${startRef}..HEAD`, '--'], {
38
40
  cwd,
39
41
  encoding: 'utf8',
40
42
  stdio: ['ignore', 'pipe', 'ignore'],
@@ -32,6 +32,7 @@ export function addNudgeToGoal(goal, text) {
32
32
  if (!Array.isArray(goal.nudges)) goal.nudges = [];
33
33
  const now = Date.now();
34
34
  goal.nudges.push({ text: clean, timestamp: now });
35
+ if (goal.nudges.length > 50) goal.nudges = goal.nudges.slice(-50);
35
36
  goal.pendingNudge = clean;
36
37
  goal.logs.push({
37
38
  timestamp: now,
@@ -93,13 +93,14 @@ export class EngineStore {
93
93
  return;
94
94
  }
95
95
  if (!this.saveTimer) {
96
- this.saveTimer = setTimeout(() => {
96
+ const timer = setTimeout(() => {
97
97
  this.saveTimer = null;
98
98
  this.writeStateToDiskSync();
99
99
  }, 250);
100
- if (typeof this.saveTimer.unref === 'function') {
101
- this.saveTimer.unref();
100
+ if (typeof timer?.unref === 'function') {
101
+ timer.unref();
102
102
  }
103
+ this.saveTimer = timer;
103
104
  }
104
105
  }
105
106
 
@@ -18,7 +18,9 @@ export { getGitCurrentCommit, exportReportMarkdown, exportReportGitHubPR, create
18
18
  */
19
19
  export class GoalEngine {
20
20
  constructor(options = {}) {
21
- this.defaultMaxIterations = options.defaultMaxIterations ?? 25;
21
+ this.logger = options.logger || console;
22
+ const rawDefIters = options.defaultMaxIterations ?? 25;
23
+ this.defaultMaxIterations = typeof rawDefIters === "number" ? rawDefIters : (typeof rawDefIters?.value === "number" ? rawDefIters.value : 25);
22
24
  this.autoDrive = options.autoDrive ?? true;
23
25
  this.enableSound = options.enableSound ?? true;
24
26
  this.showQuickLaunchButton = options.showQuickLaunchButton ?? true;
@@ -95,11 +97,15 @@ export class GoalEngine {
95
97
  }
96
98
 
97
99
  updateConfig(config = {}) {
98
- if (typeof config.defaultMaxIterations === 'number' && config.defaultMaxIterations >= 1) {
100
+ const rawMax = config.defaultMaxIterations ?? config.maxIterations;
101
+ const maxVal = typeof rawMax === 'number' ? rawMax : (typeof rawMax?.value === 'number' ? rawMax.value : undefined);
102
+ if (typeof maxVal === 'number' && maxVal >= 1) {
99
103
  const prev = this.defaultMaxIterations;
100
- this.defaultMaxIterations = config.defaultMaxIterations;
104
+ this.defaultMaxIterations = maxVal;
101
105
  for (const [_, goal] of this.goals) {
102
- if (goal && goal.maxIterations === prev) goal.maxIterations = config.defaultMaxIterations;
106
+ if (goal && (goal.maxIterations === prev || typeof goal.maxIterations !== 'number' || typeof goal.maxIterations === 'object')) {
107
+ goal.maxIterations = maxVal;
108
+ }
103
109
  }
104
110
  }
105
111
  if (typeof config.autoDrive === 'boolean') this.autoDrive = config.autoDrive;
@@ -373,13 +379,18 @@ export class GoalEngine {
373
379
  enforceDependencies: this.enableMilestoneDependencies,
374
380
  allMilestones: goal.milestones,
375
381
  });
376
- if (res.blocked) {
377
- recordActivity(goal, {
378
- type: 'milestone',
379
- tool: 'updateMilestone',
380
- status: 'error',
381
- details: `Milestone ${id} blocked by ${res.missingDependencies?.join(', ')}`,
382
- });
382
+ if (res.invalidStatus || res.blocked) {
383
+ if (res.invalidStatus) {
384
+ this.logger?.warn?.(`[dsh-goal] Invalid milestone status "${status}" for milestone ${id}`);
385
+ }
386
+ if (res.blocked) {
387
+ recordActivity(goal, {
388
+ type: 'milestone',
389
+ tool: 'updateMilestone',
390
+ status: 'error',
391
+ details: `Milestone ${id} blocked by ${res.missingDependencies?.join(', ')}`,
392
+ });
393
+ }
383
394
  return false;
384
395
  }
385
396
 
@@ -401,7 +412,7 @@ export class GoalEngine {
401
412
  recordActivity(goal, { type: 'issue', tool: 'syncIssueChecklist', status: 'success', details: `Issue #${syncRes.issueNumber} updated` });
402
413
  this.emit(sid);
403
414
  }
404
- }).catch(() => {});
415
+ }).catch((err) => { this.logger?.warn?.("[dsh-goal] Failed to sync milestone with issue:", err?.message || err); });
405
416
  }
406
417
  }
407
418
 
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import fs from 'node:fs';
1
2
  import path from 'node:path';
2
3
  import os from 'node:os';
3
4
  import z from '@deepseek-ai/schemastery';
@@ -130,9 +131,12 @@ export function apply(ctx, config = {}) {
130
131
 
131
132
  // Attempt migration of legacy state if present
132
133
  try {
133
- const agentsSvc = safeService(ctx, "agents");
134
- const firstAgent = agentsSvc?.get?.(agentsSvc?.keys?.()?.next()?.value);
135
- addonStore.migrateLegacyState(storagePath, safeService(ctx, "goals"), firstAgent);
134
+ const legacyStatePath = path.resolve(defaultStorageDir, 'dsh-goal-state.json');
135
+ if (fs.existsSync(legacyStatePath)) {
136
+ const agentsSvc = safeService(ctx, "agents");
137
+ const firstAgent = agentsSvc?.get?.(agentsSvc?.keys?.()?.next()?.value);
138
+ addonStore.migrateLegacyState(legacyStatePath, safeService(ctx, "goals"), firstAgent);
139
+ }
136
140
  } catch (migErr) {
137
141
  logger.warn?.('[dsh-goal] Legacy migration check failed:', migErr.message);
138
142
  }
@@ -162,6 +166,7 @@ export function apply(ctx, config = {}) {
162
166
  autoScaleBudgetNearCompletion: currentSettings.autoScaleBudgetNearCompletion,
163
167
  enableTemplatesDrawer: currentSettings.enableTemplatesDrawer,
164
168
  enableMilestoneDependencies: currentSettings.enableMilestoneDependencies,
169
+ logger,
165
170
  });
166
171
  }
167
172
 
@@ -192,14 +197,55 @@ export function apply(ctx, config = {}) {
192
197
  }
193
198
  }
194
199
 
200
+ const sessionAgents = new Map();
201
+
202
+ const stopRunningAgents = (sessionId = 'default') => {
203
+ try {
204
+ const agentsSvc = safeService(ctx, "agents");
205
+ const agent = sessionAgents.get(sessionId) || agentsSvc?.get?.(sessionId) || agentsSvc?.get?.(agentsSvc?.keys?.()?.next()?.value);
206
+ if (agent && typeof agent.abort === 'function') {
207
+ agent.abort();
208
+ } else if (agent && typeof agent.cancel === 'function') {
209
+ agent.cancel();
210
+ } else if (agent && typeof agent.stop === 'function') {
211
+ agent.stop();
212
+ }
213
+ } catch (err) {
214
+ logger.warn?.('[dsh-goal] Failed to stop agent:', err?.message || err);
215
+ }
216
+ };
217
+
218
+ const resumeActiveAgent = (prompt, sessionId = 'default') => {
219
+ try {
220
+ const agentsSvc = safeService(ctx, "agents");
221
+ const agent = sessionAgents.get(sessionId) || agentsSvc?.get?.(sessionId) || agentsSvc?.get?.(agentsSvc?.keys?.()?.next()?.value);
222
+ if (agent) {
223
+ if (typeof agent.resume === 'function') {
224
+ agent.resume(prompt);
225
+ } else if (typeof agent.step === 'function') {
226
+ agent.step(prompt);
227
+ } else if (typeof agent.send === 'function' && prompt) {
228
+ agent.send(prompt);
229
+ }
230
+ }
231
+ } catch (err) {
232
+ logger.warn?.('[dsh-goal] Failed to resume agent:', err?.message || err);
233
+ }
234
+ };
235
+
195
236
  // 2. WebServer HTTP & SSE routes (registered after updater so it remains primary)
196
237
  const { broadcastEvent } = registerRoutes(ctx, {
197
238
  engine: fallbackEngine,
198
239
  milestoneManager,
240
+ snapshotManager: gitSnapshotManager,
199
241
  gitSnapshotManager,
242
+ budgetGuard: tokenBudgetGuard,
200
243
  tokenBudgetGuard,
201
244
  addonStore,
202
245
  goalsService: safeService(ctx, "goals"),
246
+ stopRunningAgents,
247
+ resumeActiveAgent,
248
+ sessionAgents,
203
249
  getConfig: () => currentSettings,
204
250
  sseClients,
205
251
  logger,
@@ -243,6 +289,7 @@ export function apply(ctx, config = {}) {
243
289
 
244
290
  // Inject milestone prompts and check budget during agent turns
245
291
  ctx.on('agent/pre-step', async ({ agent, messages, step, signal }, next) => {
292
+ if (agent) { sessionAgents.set(sessionIdOf(agent, "default"), agent); }
246
293
  const goalsSvc = safeService(ctx, "goals");
247
294
  if (goalsSvc && typeof goalsSvc.get === "function" && agent) {
248
295
  try {
@@ -284,7 +331,7 @@ export function apply(ctx, config = {}) {
284
331
 
285
332
  // Compatibility events for turn/end and approval/asked
286
333
  ctx.on('turn/end', (event) => {
287
- // noop or session isolation
334
+ // session isolation turn tracking
288
335
  });
289
336
 
290
337
  ctx.on('approval/asked', (event) => {
@@ -95,11 +95,15 @@ export class MilestoneManager {
95
95
 
96
96
  // Best-effort Gitea issue checklist sync if issue data exists
97
97
  const goalData = this.store.getGoalData(goalId);
98
- if (goalData?.issue?.number) {
98
+ if (goalData?.issueRef?.issueNumber || goalData?.issue?.number) {
99
99
  try {
100
- syncMilestoneWithIssue(goalData.issue.number, m.title, true);
101
- } catch (e) {
102
- // sync error non-fatal
100
+ const issueRef = goalData.issueRef || { issueNumber: goalData.issue.number };
101
+ const fakeGoal = { issueRef, cwd: workspaceRoot };
102
+ syncMilestoneWithIssue(fakeGoal, m, { cwd: workspaceRoot }).catch((err) => {
103
+ this.logger?.warn?.('[MilestoneManager] Issue sync failed:', err?.message || err);
104
+ });
105
+ } catch (err) {
106
+ this.logger?.warn?.('[MilestoneManager] Issue sync error:', err?.message || err);
103
107
  }
104
108
  }
105
109
 
@@ -132,8 +136,8 @@ export class MilestoneManager {
132
136
 
133
137
  const uncompletedChecklist = (active.checklist || []).filter(c => !c.done);
134
138
  let clText = '';
135
- if (active.checklist?.length > 0) {
136
- clText = ` | Next items: ${active.checklist.map(c => `[${c.done ? 'x' : ' '}] ${c.text}`).join(', ')}`;
139
+ if (uncompletedChecklist.length > 0) {
140
+ clText = ` | Next items: ${uncompletedChecklist.map(c => `[ ] ${c.text}`).join(', ')}`;
137
141
  }
138
142
 
139
143
  return `[Goal Milestones]: Current step: "${active.title}"${clText}`;
package/lib/routes.js CHANGED
@@ -79,10 +79,14 @@ export function registerRoutes(ctx, {
79
79
  sseClients = new Map(),
80
80
  addonStore,
81
81
  budgetGuard,
82
+ tokenBudgetGuard,
82
83
  snapshotManager,
84
+ gitSnapshotManager,
83
85
  milestoneManager,
84
86
  logger = console,
85
87
  } = {}) {
88
+ const activeBudgetGuard = budgetGuard || tokenBudgetGuard;
89
+ const activeSnapshotManager = snapshotManager || gitSnapshotManager;
86
90
  // Subscribe to engine if provided (backward compatibility)
87
91
  if (engine && typeof engine.subscribe === 'function') {
88
92
  engine.subscribe((snapshot, sid) => {
@@ -396,6 +400,9 @@ export function registerRoutes(ctx, {
396
400
  if (engine) {
397
401
  result = engine.clear(sid);
398
402
  }
403
+ if (addonStore && typeof addonStore.removeGoalData === 'function') {
404
+ addonStore.removeGoalData(sid);
405
+ }
399
406
  if (typeof stopRunningAgents === 'function') {
400
407
  stopRunningAgents(sid);
401
408
  }
package/lib/tools.js CHANGED
@@ -141,7 +141,6 @@ export function registerTools(ctx, { milestoneManager, goalsService, logger = co
141
141
  },
142
142
  output: JSON_OUTPUT,
143
143
  execute: handleGoalMilestones,
144
- handler: handleGoalMilestones,
145
144
  });
146
145
 
147
146
  // Tool 2: goal_checklist
@@ -181,7 +180,99 @@ export function registerTools(ctx, { milestoneManager, goalsService, logger = co
181
180
  },
182
181
  output: JSON_OUTPUT,
183
182
  execute: handleGoalChecklist,
184
- handler: handleGoalChecklist,
183
+ });
184
+ // Tool 3: goal_set_milestones (Issue #127 alias matching autonomous-contract prompt)
185
+ const handleGoalSetMilestones = async (args = {}, toolCtx = {}) => {
186
+ const goalId = args.goalId || args.goal_id || resolveGoalId(toolCtx);
187
+ let list = args.milestones;
188
+ if (Array.isArray(list)) {
189
+ list = list.map((item, idx) => {
190
+ if (typeof item === 'string') {
191
+ return { id: `m-${idx + 1}`, title: item };
192
+ }
193
+ return item;
194
+ });
195
+ } else {
196
+ list = [];
197
+ }
198
+ const res = mm.setMilestones(goalId, list);
199
+ return { ok: true, goalId, count: res.length, milestones: res };
200
+ };
201
+
202
+ safeRegister({
203
+ name: 'goal_set_milestones',
204
+ description: 'Set initial work plan milestones for the active goal.',
205
+ parameters: {
206
+ type: 'object',
207
+ properties: {
208
+ milestones: {
209
+ type: 'array',
210
+ items: { type: ['string', 'object'] },
211
+ description: 'Array of milestone titles or objects',
212
+ },
213
+ },
214
+ required: ['milestones'],
215
+ },
216
+ output: JSON_OUTPUT,
217
+ execute: handleGoalSetMilestones,
218
+ });
219
+
220
+ // Tool 4: goal_update_progress (Issue #127 alias matching autonomous-contract prompt)
221
+ const handleGoalUpdateProgress = async (args = {}, toolCtx = {}) => {
222
+ const goalId = args.goalId || args.goal_id || resolveGoalId(toolCtx);
223
+ const milestoneId = args.milestone_id || args.milestoneId;
224
+ const status = args.status || 'in_progress';
225
+ const notes = typeof args.notes === 'string' ? args.notes : '';
226
+ if (!milestoneId) throw new Error('milestone_id is required');
227
+ if (status === 'completed') {
228
+ const rawCwd = toolCtx?.session?.cwd || toolCtx?.cwd || process.cwd();
229
+ const safeCwd = getSafeCwd(rawCwd);
230
+ const completed = await mm.completeMilestone(goalId, milestoneId, notes, safeCwd);
231
+ return { ok: true, goalId, milestoneId, status: 'completed', milestone: completed };
232
+ }
233
+ const updated = mm.updateMilestone(goalId, milestoneId, { status, notes, checklist: args.checklist });
234
+ return { ok: true, goalId, milestoneId, status, milestone: updated };
235
+ };
236
+
237
+ safeRegister({
238
+ name: 'goal_update_progress',
239
+ description: 'Update progress and status of a milestone.',
240
+ parameters: {
241
+ type: 'object',
242
+ properties: {
243
+ milestone_id: { type: 'string', description: 'Target milestone ID' },
244
+ status: { type: 'string', enum: ['pending', 'in_progress', 'completed', 'blocked'], description: 'Milestone status' },
245
+ notes: { type: 'string', description: 'Brief outcome or status notes' },
246
+ checklist: { type: 'array', items: { type: 'string' }, description: 'Subtask items' },
247
+ },
248
+ required: ['milestone_id', 'status'],
249
+ },
250
+ output: JSON_OUTPUT,
251
+ execute: handleGoalUpdateProgress,
252
+ });
253
+
254
+ // Tool 5: goal_finish (Issue #127 alias matching autonomous-contract prompt)
255
+ const handleGoalFinish = async (args = {}, toolCtx = {}) => {
256
+ const goalId = args.goalId || args.goal_id || resolveGoalId(toolCtx);
257
+ const summary = typeof args.summary === 'string' ? args.summary : '';
258
+ if (goalsService && typeof goalsService.complete === 'function') {
259
+ try { goalsService.complete(toolCtx?.agent, summary); } catch (e) { /* ignore */ }
260
+ }
261
+ return { ok: true, goalId, completed: true, summary };
262
+ };
263
+
264
+ safeRegister({
265
+ name: 'goal_finish',
266
+ description: 'Finish active goal with final achieved outcome summary.',
267
+ parameters: {
268
+ type: 'object',
269
+ properties: {
270
+ summary: { type: 'string', description: 'Comprehensive summary of results' },
271
+ },
272
+ required: ['summary'],
273
+ },
274
+ output: JSON_OUTPUT,
275
+ execute: handleGoalFinish,
185
276
  });
186
277
  });
187
278
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-goal",
3
- "version": "0.2.20",
3
+ "version": "0.2.21",
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",