@goodandready/dsh-goal 0.2.20 → 0.2.22

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 !== '') {
@@ -1,3 +1,4 @@
1
+ import { detectLanguage } from "./goal-engine.js";
1
2
  import { isMilestoneBlocked } from './engine-milestones.js';
2
3
 
3
4
  /**
@@ -100,3 +101,29 @@ export function buildStatePromptInjection(snapshot, pendingNudge) {
100
101
  `3. When all milestones are completed, call tool goal_finish with a detailed summary of achieved results.`
101
102
  );
102
103
  }
104
+
105
+
106
+ /**
107
+ * Format goal start prompt with mandatory milestone contract
108
+ * @param {string} goalText
109
+ * @param {string} [explicitLang]
110
+ * @returns {string}
111
+ */
112
+ export function formatGoalStartPrompt(goalText, explicitLang) {
113
+ const lang = explicitLang || detectLanguage(goalText);
114
+ if (lang === "zh") {
115
+ return `🎯 目标模式已激活 (Goal Mode): "${goalText}"\n\n` +
116
+ `自主执行契约:\n` +
117
+ `1. 第一步必选动作:在开展任何工作或回复之前,你的第一个动作必须是调用 goal_set_milestones 工具,提交包含 3-7 个具体连续步骤的工作计划。用户将在界面中实时查看此计划。\n` +
118
+ `2. 第二步:计划确立后开始执行任务。在开始每个步骤前,通过 goal_update_progress(milestone_id, "in_progress") 标记其为进行中。\n` +
119
+ `3. 第三步:完成某一步骤后,通过 goal_update_progress(milestone_id, "completed", "阶段简要成果") 标记为已完成。\n` +
120
+ `4. 第四步:当所有里程碑全部完成后,调用 goal_finish(summary) 工具并附带详尽的最终成果总结。`;
121
+ }
122
+
123
+ return `🎯 Goal Mode activated: "${goalText}"\n\n` +
124
+ `STRICT AUTONOMOUS CONTRACT:\n` +
125
+ `1. MANDATORY STEP 1: Your FIRST action BEFORE doing any other work or reply MUST be calling tool goal_set_milestones with an array of 3-7 concrete, sequential milestones. The user sees this plan in real time.\n` +
126
+ `2. STEP 2: After establishing the plan, proceed to execute milestones. Before starting each milestone, mark it as in_progress via goal_update_progress(milestone_id, "in_progress").\n` +
127
+ `3. STEP 3: Upon completing a milestone, mark it as completed via goal_update_progress(milestone_id, "completed", "brief milestone outcome").\n` +
128
+ `4. STEP 4: When all milestones are completed, call tool goal_finish(summary) with a comprehensive summary of achieved results.`;
129
+ }
@@ -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.22",
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",
@@ -55,18 +55,9 @@
55
55
  "peerDependencies": {
56
56
  "@deepseek-ai/cordis": "^4.0.1",
57
57
  "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.2 || ^0.2.0-rc.1",
58
- "@deepseek-ai/schemastery": "^3.18.4",
59
- "@deepseek-ai/dsh-goal": "^0.1.7-rc.2 || ^0.2.0-rc.1"
60
- },
61
- "devDependencies": {
62
58
  "@deepseek-ai/schemastery": "^3.18.4"
63
59
  },
64
- "dependencies": {
60
+ "devDependencies": {
65
61
  "@deepseek-ai/schemastery": "^3.18.4"
66
- },
67
- "peerDependenciesMeta": {
68
- "@deepseek-ai/dsh-goal": {
69
- "optional": true
70
- }
71
62
  }
72
63
  }
@@ -1,147 +0,0 @@
1
- /**
2
- * Pure state and validation logic for dsh-goal settings form.
3
- * Independent from DOM/React, 100% testable via node --test.
4
- */
5
-
6
- export const DEFAULT_SETTINGS = {
7
- maxIterations: 25,
8
- autoDrive: true,
9
- enableSound: true,
10
- showQuickLaunchButton: true,
11
- consecutiveToolFailureLimit: 3,
12
- enableBrowserNotifications: true,
13
- maxTokenBudget: 0,
14
- budgetWarningThreshold: 80,
15
- autoCheckpointOnMilestone: false,
16
- storagePath: '',
17
- };
18
-
19
- export const NUMERIC_FIELDS = {
20
- maxIterations: 1,
21
- consecutiveToolFailureLimit: 0,
22
- maxTokenBudget: 0,
23
- budgetWarningThreshold: 0,
24
- };
25
-
26
- /**
27
- * Parse numeric settings field:
28
- * - empty string / null / undefined -> undefined (reset override)
29
- * - valid integer >= min -> number
30
- * - otherwise -> NaN (invalid)
31
- * @param {any} raw
32
- * @param {number} [min=1]
33
- * @returns {number|undefined}
34
- */
35
- export function parseNumberField(raw, min = 1) {
36
- if (raw === '' || raw === null || raw === undefined) {
37
- return undefined;
38
- }
39
- const n = Number(raw);
40
- if (!Number.isFinite(n) || n < min || !Number.isInteger(n)) {
41
- return NaN;
42
- }
43
- return n;
44
- }
45
-
46
- /**
47
- * Compute status for a specific setting field
48
- * @param {string} field
49
- * @param {any} draftValue
50
- * @param {Object} snap - { value, base, user }
51
- */
52
- export function getFieldStatus(field, draftValue, snap) {
53
- const baseVal = snap?.base?.[field] ?? DEFAULT_SETTINGS[field];
54
- const userVal = snap?.user?.[field];
55
- const effectiveVal = snap?.value?.[field] ?? baseVal;
56
-
57
- const isOverridden = userVal !== undefined && userVal !== null;
58
- const currentVal = draftValue !== undefined ? draftValue : effectiveVal;
59
-
60
- let invalid = false;
61
- let isDirty = false;
62
-
63
- if (field in NUMERIC_FIELDS) {
64
- if (draftValue !== undefined) {
65
- if (draftValue === '') {
66
- invalid = false;
67
- isDirty = userVal !== undefined;
68
- } else {
69
- const min = NUMERIC_FIELDS[field];
70
- const parsed = parseNumberField(draftValue, min);
71
- if (Number.isNaN(parsed)) {
72
- invalid = true;
73
- isDirty = true;
74
- } else {
75
- isDirty = parsed !== (userVal !== undefined ? userVal : baseVal);
76
- }
77
- }
78
- }
79
- } else if (field === 'storagePath') {
80
- if (draftValue !== undefined) {
81
- const cleanVal = typeof draftValue === 'string' ? draftValue.trim() : '';
82
- isDirty = cleanVal !== (userVal !== undefined ? userVal : (baseVal || ''));
83
- }
84
- } else {
85
- // boolean fields (autoDrive, enableSound, showQuickLaunchButton, enableBrowserNotifications, autoCheckpointOnMilestone)
86
- if (draftValue !== undefined) {
87
- isDirty = Boolean(draftValue) !== (userVal !== undefined ? Boolean(userVal) : Boolean(baseVal));
88
- }
89
- }
90
-
91
- return {
92
- value: currentVal,
93
- baseValue: baseVal,
94
- userValue: userVal,
95
- isOverridden,
96
- isDirty,
97
- invalid,
98
- };
99
- }
100
-
101
- /**
102
- * Compute list of key-value writes for scope.set
103
- * @param {Object|null} draft
104
- * @param {Object} snap - { value, base, user }
105
- * @returns {Array<[string, any]>}
106
- */
107
- export function computeSavePlan(draft, snap) {
108
- if (!draft) return [];
109
-
110
- const writes = [];
111
- const fields = [
112
- 'maxIterations',
113
- 'autoDrive',
114
- 'enableSound',
115
- 'showQuickLaunchButton',
116
- 'consecutiveToolFailureLimit',
117
- 'enableBrowserNotifications',
118
- 'maxTokenBudget',
119
- 'budgetWarningThreshold',
120
- 'autoCheckpointOnMilestone',
121
- 'storagePath',
122
- ];
123
-
124
- for (const f of fields) {
125
- if (draft[f] === undefined) continue;
126
-
127
- const status = getFieldStatus(f, draft[f], snap);
128
- if (status.invalid) continue;
129
-
130
- if (status.isDirty) {
131
- if (f in NUMERIC_FIELDS) {
132
- if (draft[f] === '') {
133
- writes.push([f, undefined]);
134
- } else {
135
- writes.push([f, parseNumberField(draft[f], NUMERIC_FIELDS[f])]);
136
- }
137
- } else if (f === 'storagePath') {
138
- const clean = typeof draft[f] === 'string' ? draft[f].trim() : '';
139
- writes.push([f, clean ? clean : undefined]);
140
- } else {
141
- writes.push([f, Boolean(draft[f])]);
142
- }
143
- }
144
- }
145
-
146
- return writes;
147
- }
@@ -1,242 +0,0 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { GoalState, detectLanguage } from './goal-engine.js';
3
-
4
- export const USAGE_EN = 'Usage: /goal [<goal>|clear|pause|resume]';
5
- export const USAGE_ZH = '用法: /goal [<目标描述>|clear|pause|resume]';
6
-
7
- /**
8
- * Parse input string for /goal command
9
- * @param {string} rawInput
10
- * @returns {{ action: string, text?: string }}
11
- */
12
- export function parseGoalInput(rawInput = '') {
13
- const input = String(rawInput || '').trim();
14
- if (!input) {
15
- return { action: 'show' };
16
- }
17
-
18
- const lower = input.toLowerCase();
19
- if (lower === 'clear') return { action: 'clear' };
20
- if (lower === 'pause') return { action: 'pause' };
21
- if (lower === 'resume') return { action: 'resume' };
22
-
23
- return { action: 'start', text: input };
24
- }
25
-
26
- /**
27
- * Create a valid identified user message for agent followup
28
- * @param {string} text
29
- * @returns {Object}
30
- */
31
- export function createGoalUserMessage(text) {
32
- return {
33
- id: randomUUID(),
34
- role: 'user',
35
- content: [{ type: 'text', text }],
36
- source: { kind: 'user' },
37
- };
38
- }
39
-
40
- /**
41
- * Format goal start prompt with mandatory milestone contract
42
- * @param {string} goalText
43
- * @param {string} [explicitLang]
44
- * @returns {string}
45
- */
46
- export function formatGoalStartPrompt(goalText, explicitLang) {
47
- const lang = explicitLang || detectLanguage(goalText);
48
- if (lang === 'zh') {
49
- return `🎯 目标模式已激活 (Goal Mode): "${goalText}"\n\n` +
50
- `自主执行契约:\n` +
51
- `1. 第一步必选动作:在开展任何工作或回复之前,你的第一个动作必须是调用 goal_set_milestones 工具,提交包含 3-7 个具体连续步骤的工作计划。用户将在界面中实时查看此计划。\n` +
52
- `2. 第二步:计划确立后开始执行任务。在开始每个步骤前,通过 goal_update_progress(milestone_id, "in_progress") 标记其为进行中。\n` +
53
- `3. 第三步:完成某一步骤后,通过 goal_update_progress(milestone_id, "completed", "阶段简要成果") 标记为已完成。\n` +
54
- `4. 第四步:当所有里程碑全部完成后,调用 goal_finish(summary) 工具并附带详尽的最终成果总结。`;
55
- }
56
-
57
- return `🎯 Goal Mode activated: "${goalText}"\n\n` +
58
- `STRICT AUTONOMOUS CONTRACT:\n` +
59
- `1. MANDATORY STEP 1: Your FIRST action BEFORE doing any other work or reply MUST be calling tool goal_set_milestones with an array of 3-7 concrete, sequential milestones. The user sees this plan in real time.\n` +
60
- `2. STEP 2: After establishing the plan, proceed to execute milestones. Before starting each milestone, mark it as in_progress via goal_update_progress(milestone_id, "in_progress").\n` +
61
- `3. STEP 3: Upon completing a milestone, mark it as completed via goal_update_progress(milestone_id, "completed", "brief milestone outcome").\n` +
62
- `4. STEP 4: When all milestones are completed, call tool goal_finish(summary) with a comprehensive summary of achieved results.`;
63
- }
64
-
65
- /**
66
- * Format response for UI and manage agent task execution
67
- * @param {import('./goal-engine.js').GoalEngine} engine
68
- * @param {{ action: string, text?: string }} parsed
69
- * @param {Object} [config]
70
- * @param {Object} [agent]
71
- * @param {string} [sessionId='default']
72
- * @returns {{ kind: string, text: string }}
73
- */
74
- export function executeGoalSlashCommand(engine, parsed, config = {}, agent = null, sessionId = 'default') {
75
- const sid = sessionId || 'default';
76
- const snap = engine.getGoalSnapshot(sid);
77
- const lang = snap.lang || (parsed.text ? detectLanguage(parsed.text) : 'en');
78
- const isZh = lang === 'zh';
79
- const usage = isZh ? USAGE_ZH : USAGE_EN;
80
-
81
- switch (parsed.action) {
82
- case 'show': {
83
- if (!snap.hasActiveGoal) {
84
- return {
85
- kind: 'success',
86
- text: isZh ? `目标模式: 当前未设置活动目标。\n${usage}` : `Goal Mode: no active goal set.\n${usage}`,
87
- };
88
- }
89
-
90
- const milestonesInfo = snap.milestones && snap.milestones.length > 0
91
- ? `\n\n${isZh ? '工作计划' : 'Work Plan'}:\n` +
92
- snap.milestones
93
- .map((m, i) => ` ${i + 1}. [${m.status.toUpperCase()}] ${m.title}${m.notes ? ` (${m.notes})` : ''}`)
94
- .join('\n')
95
- : '';
96
-
97
- const etaText = snap.formattedETA ? ` (ETA: ${snap.formattedETA})` : '';
98
- const tokensInfo = (snap.tokensUsage && snap.tokensUsage.totalTokens > 0)
99
- ? (isZh
100
- ? `\nToken: ${snap.tokensUsage.totalTokens.toLocaleString()} (输入: ${snap.tokensUsage.promptTokens.toLocaleString()}, 输出: ${snap.tokensUsage.completionTokens.toLocaleString()})`
101
- : `\nTokens: ${snap.tokensUsage.totalTokens.toLocaleString()} (in: ${snap.tokensUsage.promptTokens.toLocaleString()}, out: ${snap.tokensUsage.completionTokens.toLocaleString()})`)
102
- : '';
103
-
104
- return {
105
- kind: 'success',
106
- text: isZh
107
- ? `🎯 目标: "${snap.title}"\n` +
108
- `状态: ${snap.state}\n` +
109
- `耗时: ${snap.formattedElapsed}${etaText}\n` +
110
- `迭代轮次: ${snap.iterationsCount}/${snap.maxIterations}` +
111
- tokensInfo +
112
- milestonesInfo +
113
- `\n\n快捷指令: /goal pause, /goal resume, /goal clear, /goal <新目标>`
114
- : `🎯 Goal: "${snap.title}"\n` +
115
- `Status: ${snap.state}\n` +
116
- `Duration: ${snap.formattedElapsed}${etaText}\n` +
117
- `Iterations: ${snap.iterationsCount}/${snap.maxIterations}` +
118
- tokensInfo +
119
- milestonesInfo +
120
- `\n\nCommands: /goal pause, /goal resume, /goal clear, /goal <new goal>`,
121
- };
122
- }
123
-
124
- case 'clear': {
125
- if (!snap.hasActiveGoal) {
126
- return {
127
- kind: 'success',
128
- text: isZh ? '当前未设置目标。' : 'No active goal set.',
129
- };
130
- }
131
- engine.clear(sid);
132
- if (agent && typeof agent.cancel === 'function') {
133
- try {
134
- agent.cancel({ kind: 'user', reason: 'Goal cleared by user' });
135
- } catch (err) {
136
- // agent cancellation error handled defensively
137
- }
138
- }
139
- return {
140
- kind: 'success',
141
- text: isZh ? '🎯 目标已清除。' : '🎯 Goal cleared.',
142
- };
143
- }
144
-
145
- case 'pause': {
146
- if (!snap.hasActiveGoal) {
147
- return {
148
- kind: 'error',
149
- text: isZh
150
- ? `无法暂停:未设置活动目标。\n${usage}`
151
- : `Cannot pause: no active goal.\n${usage}`,
152
- };
153
- }
154
- if (snap.state === GoalState.PAUSED) {
155
- return {
156
- kind: 'success',
157
- text: isZh ? '⏸ 目标当前已处于暂停状态。' : '⏸ Goal is already paused.',
158
- };
159
- }
160
- engine.pause(isZh ? '用户命令暂停 (/goal pause)' : 'Paused by user command (/goal pause)', sid);
161
- if (agent && typeof agent.cancel === 'function') {
162
- try {
163
- agent.cancel({ kind: 'user', reason: 'Goal paused by user' });
164
- } catch (err) {
165
- // agent cancellation error handled defensively
166
- }
167
- }
168
- return {
169
- kind: 'success',
170
- text: isZh
171
- ? `⏸ 目标已暂停: "${snap.title}"。智能体已停止,使用 /goal resume 或 ▶️ 按钮继续。`
172
- : `⏸ Goal paused: "${snap.title}". Agent stopped. Use /goal resume or ▶️ button to continue.`,
173
- };
174
- }
175
-
176
- case 'resume': {
177
- if (!snap.hasActiveGoal) {
178
- return {
179
- kind: 'error',
180
- text: isZh
181
- ? `无法恢复:未设置活动目标。\n${usage}`
182
- : `Cannot resume: no active goal.\n${usage}`,
183
- };
184
- }
185
- if (snap.state === GoalState.RUNNING) {
186
- return {
187
- kind: 'success',
188
- text: isZh ? '▶️ 目标已在运行中。' : '▶️ Goal is already running.',
189
- };
190
- }
191
- engine.resume(sid);
192
- if (agent && typeof agent.followup === 'function') {
193
- try {
194
- const resumePrompt = isZh
195
- ? `▶️ 用户已恢复目标运行。请继续推进工作计划中尚未完成的步骤。通过 goal_update_progress 实时更新进度。`
196
- : `▶️ Goal resumed by user. Continue executing the work plan from where you stopped. Update steps via goal_update_progress.`;
197
- agent.followup(createGoalUserMessage(resumePrompt));
198
- } catch (err) {
199
- // agent followup error handled defensively
200
- }
201
- }
202
- return {
203
- kind: 'success',
204
- text: isZh
205
- ? `▶️ 目标已恢复: "${snap.title}"。智能体继续工作中。`
206
- : `▶️ Goal resumed: "${snap.title}". Agent continues working.`,
207
- };
208
- }
209
-
210
- case 'start': {
211
- const goalLang = detectLanguage(parsed.text);
212
- const isZhStart = goalLang === 'zh';
213
- const maxIterations = config.maxIterations ?? snap.maxIterations ?? 25;
214
- const cwd = config.cwd;
215
- const newSnap = engine.startGoal(parsed.text, { maxIterations, lang: goalLang, cwd }, sid);
216
-
217
- if (agent && typeof agent.followup === 'function') {
218
- try {
219
- const userMsg = createGoalUserMessage(formatGoalStartPrompt(parsed.text, goalLang));
220
- agent.followup(userMsg);
221
- } catch (err) {
222
- // agent dispatch error handled defensively
223
- }
224
- }
225
-
226
- return {
227
- kind: 'success',
228
- text: isZhStart
229
- ? `🎯 目标已激活: "${newSnap.title}" (上限 ${newSnap.maxIterations} 轮迭代)。\n` +
230
- `暂停: /goal pause | 清除: /goal clear`
231
- : `🎯 Goal activated: "${newSnap.title}" (max ${newSnap.maxIterations} iterations).\n` +
232
- `To pause: /goal pause | To clear: /goal clear`,
233
- };
234
- }
235
-
236
- default:
237
- return {
238
- kind: 'error',
239
- text: isZh ? `未知指令。${usage}` : `Unknown command. ${usage}`,
240
- };
241
- }
242
- }