@goodandready/dsh-goal 0.2.8 → 0.2.10

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/index.js CHANGED
@@ -22,6 +22,15 @@ export const Config = z.object({
22
22
  maxTokenBudget: z.number().default(0).description('Maximum token budget per goal session (0 to disable)'),
23
23
  budgetWarningThreshold: z.number().default(80).description('Percentage of token budget consumed before model warning injection (e.g. 80)'),
24
24
  autoCheckpointOnMilestone: z.boolean().default(false).description('Automatically create git commit checkpoint upon milestone completion'),
25
+ enableLiveActivityFeed: z.boolean().default(true).description('Display compact recent tool and control activity feed in details modal'),
26
+ enablePreplanning: z.boolean().default(true).description('Generate initial draft plan for approval before launching autonomous turns'),
27
+ autoBranchOnGoalStart: z.boolean().default(true).description('Automatically create dedicated isolated git branch for goal execution'),
28
+ enablePostGoalRetrospective: z.boolean().default(true).description('Generate structured retrospective and analytics card upon goal completion'),
29
+ autoScaleBudgetNearCompletion: z.boolean().default(false).description('Automatically grant token buffer extension if budget reaches 100% near completion'),
30
+ enableTemplatesDrawer: z.boolean().default(true).description('Provide pre-configured engineering goal templates in UI drawer'),
31
+ enableMilestoneDependencies: z.boolean().default(true).description('Enforce milestone dependency ordering and block premature execution'),
32
+ soundScheme: z.string().default('default').description('Audio theme for status and milestone notifications'),
33
+ enableVoiceAnnouncements: z.boolean().default(false).description('Use Web Speech API for voice announcements on milestone events'),
25
34
  storagePath: z.string().default('').description('Custom filesystem path for persistent state storage'),
26
35
  });
27
36
 
@@ -43,6 +52,15 @@ export function apply(ctx, config = {}) {
43
52
  maxTokenBudget: config?.maxTokenBudget ?? 0,
44
53
  budgetWarningThreshold: config?.budgetWarningThreshold ?? 80,
45
54
  autoCheckpointOnMilestone: config?.autoCheckpointOnMilestone ?? false,
55
+ enableLiveActivityFeed: config?.enableLiveActivityFeed ?? true,
56
+ enablePreplanning: config?.enablePreplanning ?? true,
57
+ autoBranchOnGoalStart: config?.autoBranchOnGoalStart ?? true,
58
+ enablePostGoalRetrospective: config?.enablePostGoalRetrospective ?? true,
59
+ autoScaleBudgetNearCompletion: config?.autoScaleBudgetNearCompletion ?? false,
60
+ enableTemplatesDrawer: config?.enableTemplatesDrawer ?? true,
61
+ enableMilestoneDependencies: config?.enableMilestoneDependencies ?? true,
62
+ soundScheme: config?.soundScheme ?? 'default',
63
+ enableVoiceAnnouncements: config?.enableVoiceAnnouncements ?? false,
46
64
  storagePath: config?.storagePath,
47
65
  };
48
66
 
@@ -60,6 +78,15 @@ export function apply(ctx, config = {}) {
60
78
  maxTokenBudget: currentSettings.maxTokenBudget,
61
79
  budgetWarningThreshold: currentSettings.budgetWarningThreshold,
62
80
  autoCheckpointOnMilestone: currentSettings.autoCheckpointOnMilestone,
81
+ enableLiveActivityFeed: currentSettings.enableLiveActivityFeed,
82
+ enablePreplanning: currentSettings.enablePreplanning,
83
+ autoBranchOnGoalStart: currentSettings.autoBranchOnGoalStart,
84
+ enablePostGoalRetrospective: currentSettings.enablePostGoalRetrospective,
85
+ autoScaleBudgetNearCompletion: currentSettings.autoScaleBudgetNearCompletion,
86
+ enableTemplatesDrawer: currentSettings.enableTemplatesDrawer,
87
+ enableMilestoneDependencies: currentSettings.enableMilestoneDependencies,
88
+ soundScheme: currentSettings.soundScheme,
89
+ enableVoiceAnnouncements: currentSettings.enableVoiceAnnouncements,
63
90
  storagePath,
64
91
  });
65
92
 
@@ -220,6 +247,15 @@ export function apply(ctx, config = {}) {
220
247
  maxTokenBudget: live.maxTokenBudget,
221
248
  budgetWarningThreshold: live.budgetWarningThreshold,
222
249
  autoCheckpointOnMilestone: live.autoCheckpointOnMilestone,
250
+ enableLiveActivityFeed: live.enableLiveActivityFeed,
251
+ enablePreplanning: live.enablePreplanning,
252
+ autoBranchOnGoalStart: live.autoBranchOnGoalStart,
253
+ enablePostGoalRetrospective: live.enablePostGoalRetrospective,
254
+ autoScaleBudgetNearCompletion: live.autoScaleBudgetNearCompletion,
255
+ enableTemplatesDrawer: live.enableTemplatesDrawer,
256
+ enableMilestoneDependencies: live.enableMilestoneDependencies,
257
+ soundScheme: live.soundScheme,
258
+ enableVoiceAnnouncements: live.enableVoiceAnnouncements,
223
259
  });
224
260
  };
225
261
 
package/lib/routes.js CHANGED
@@ -2,17 +2,84 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { MilestoneStatus, detectLanguage, sessionIdOf } from './goal-engine-constants.js';
4
4
  import { saveGoalArtifact, rollbackToCheckpoint } from './engine-reports.js';
5
+ import { getTemplatesCatalogue } from './engine-templates.js';
6
+
7
+ export function isLoopback(value) {
8
+ if (!value) return false;
9
+ const address = String(value).toLowerCase().replace(/^\[|\]$/g, '');
10
+ return Boolean(
11
+ address === 'localhost' ||
12
+ address === 'localhost.' ||
13
+ address === '::1' ||
14
+ address.startsWith('127.') ||
15
+ address.startsWith('::ffff:127.')
16
+ );
17
+ }
18
+
19
+ export function isTrustedCaller(req) {
20
+ if (!req) return false;
21
+ const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || '';
22
+ if (isLoopback(remote)) return true;
23
+
24
+ const secSite = String(req.headers?.['sec-fetch-site'] || '').toLowerCase();
25
+ if (secSite === 'same-origin' || secSite === 'same-site' || secSite === 'none') return true;
26
+ if (secSite === 'cross-site') return false;
27
+
28
+ const host = String(req.headers?.host || '').toLowerCase();
29
+ const origin = String(req.headers?.origin || '').trim().toLowerCase();
30
+ if (origin) {
31
+ try {
32
+ const u = new URL(origin);
33
+ if (u.host === host) return true;
34
+ } catch (err) { /* invalid url */ }
35
+ return false;
36
+ }
37
+
38
+ const referer = String(req.headers?.referer || '').trim().toLowerCase();
39
+ if (referer) {
40
+ try {
41
+ const u = new URL(referer);
42
+ if (u.host === host) return true;
43
+ } catch (err) { /* invalid url */ }
44
+ return false;
45
+ }
46
+
47
+ // Fallback for tests or local CLI without socket where host header targets loopback
48
+ if (!remote && host) {
49
+ const hostName = host.split(':')[0];
50
+ if (isLoopback(hostName)) {
51
+ return true;
52
+ }
53
+ }
54
+
55
+ return false;
56
+ }
57
+
58
+ export function isPathInsideOrEqual(parent, child) {
59
+ if (!parent || !child) return false;
60
+ const rel = path.relative(path.resolve(parent), path.resolve(child));
61
+ return !rel || (!rel.startsWith('..') && !path.isAbsolute(rel));
62
+ }
5
63
 
6
- function getSafeCwd(requestedCwd) {
7
- if (!requestedCwd || typeof requestedCwd !== 'string') {
8
- return process.cwd();
64
+ export function getSafeCwd(requestedCwd, allowedRoots = []) {
65
+ const defaultRoot = allowedRoots[0] || process.cwd();
66
+ if (!requestedCwd || typeof requestedCwd !== 'string' || !requestedCwd.trim()) {
67
+ return defaultRoot;
9
68
  }
69
+
10
70
  try {
11
71
  const resolved = path.resolve(requestedCwd.trim());
12
72
  const st = fs.statSync(resolved);
13
73
  if (!st.isDirectory()) {
14
74
  return null;
15
75
  }
76
+
77
+ const roots = allowedRoots.length > 0 ? allowedRoots : [process.cwd()];
78
+ const isAllowed = roots.some((root) => isPathInsideOrEqual(root, resolved));
79
+ if (!isAllowed) {
80
+ return null;
81
+ }
82
+
16
83
  return resolved;
17
84
  } catch (err) {
18
85
  return null;
@@ -128,6 +195,12 @@ export function registerRoutes(ctx, {
128
195
 
129
196
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
130
197
 
198
+ // GET /dsh-goal/templates
199
+ if (req.method === 'GET' && (pathname === '/dsh-goal/templates' || pathname === '/dsh-goal/templates/')) {
200
+ res.statusCode = 200;
201
+ return res.end(JSON.stringify({ ok: true, templates: getTemplatesCatalogue() }));
202
+ }
203
+
131
204
  // GET /dsh-goal/state
132
205
  if (req.method === 'GET' && (pathname === '/dsh-goal/state' || pathname === '/dsh-goal/state/')) {
133
206
  const sid = sessionIdOf(req, 'default');
@@ -144,8 +217,8 @@ export function registerRoutes(ctx, {
144
217
  return res.end(JSON.stringify({ error: 'Forbidden: cross-site requests are rejected' }));
145
218
  }
146
219
 
147
- const origin = req.headers.origin;
148
- const host = req.headers.host;
220
+ const origin = req.headers?.origin;
221
+ const host = req.headers?.host;
149
222
  if (origin && host) {
150
223
  try {
151
224
  const originHost = new URL(origin).host;
@@ -159,6 +232,11 @@ export function registerRoutes(ctx, {
159
232
  }
160
233
  }
161
234
 
235
+ if (!isTrustedCaller(req)) {
236
+ res.statusCode = 403;
237
+ return res.end(JSON.stringify({ error: 'Forbidden: untrusted caller origin' }));
238
+ }
239
+
162
240
  let body = '';
163
241
  let bodySize = 0;
164
242
  const MAX_PAYLOAD_BYTES = 256 * 1024;
@@ -196,6 +274,7 @@ export function registerRoutes(ctx, {
196
274
  description: typeof description === 'string' ? description.trim() : '',
197
275
  maxIterations: conf.maxIterations || 25,
198
276
  lang: detectedLang,
277
+ cwd: data.cwd,
199
278
  }, sid);
200
279
 
201
280
  const startPrompt = detectedLang === 'zh'
@@ -309,8 +388,11 @@ export function registerRoutes(ctx, {
309
388
  res.statusCode = 400;
310
389
  return res.end(JSON.stringify({ error: 'commit hash is required for rollback' }));
311
390
  }
312
- const safeCwd = getSafeCwd(data.cwd);
313
- if (data.cwd && !safeCwd) {
391
+ const sessionGoal = engine.getGoal(sid);
392
+ const allowedRoots = [process.cwd()];
393
+ if (sessionGoal?.cwd) allowedRoots.push(sessionGoal.cwd);
394
+ const safeCwd = getSafeCwd(data.cwd, allowedRoots);
395
+ if (!safeCwd) {
314
396
  res.statusCode = 400;
315
397
  return res.end(JSON.stringify({ error: 'Invalid or non-existent working directory' }));
316
398
  }
@@ -329,8 +411,11 @@ export function registerRoutes(ctx, {
329
411
  res.statusCode = 404;
330
412
  return res.end(JSON.stringify({ error: 'No active goal to save artifact for' }));
331
413
  }
332
- const safeCwd = getSafeCwd(data.cwd);
333
- if (data.cwd && !safeCwd) {
414
+ const sessionGoal = engine.getGoal(sid);
415
+ const allowedRoots = [process.cwd()];
416
+ if (sessionGoal?.cwd) allowedRoots.push(sessionGoal.cwd);
417
+ const safeCwd = getSafeCwd(data.cwd, allowedRoots);
418
+ if (!safeCwd) {
334
419
  res.statusCode = 400;
335
420
  return res.end(JSON.stringify({ error: 'Invalid or non-existent working directory' }));
336
421
  }
@@ -343,6 +428,51 @@ export function registerRoutes(ctx, {
343
428
  return res.end(JSON.stringify({ ok: true, artifact, state: snap }));
344
429
  }
345
430
 
431
+ case 'intervene': {
432
+ result = engine.intervene(data, sid);
433
+ if (data.resume) {
434
+ engine.resume(sid);
435
+ const prompt = engine.getStatePromptInjection(sid);
436
+ if (typeof resumeActiveAgent === 'function') {
437
+ resumeActiveAgent(prompt, sid);
438
+ }
439
+ }
440
+ break;
441
+ }
442
+
443
+ case 'preplan_approve': {
444
+ result = engine.approvePreplan(sid);
445
+ const prompt = engine.getStatePromptInjection(sid);
446
+ if (typeof resumeActiveAgent === 'function') {
447
+ resumeActiveAgent(prompt, sid);
448
+ }
449
+ break;
450
+ }
451
+
452
+ case 'branch_action': {
453
+ const op = String(data.operation || data.branchOperation || 'merge').toLowerCase();
454
+ const targetBranch = String(data.targetBranch || 'main');
455
+ if (op === 'merge') {
456
+ const ok = engine.mergeGoalBranch(targetBranch, sid);
457
+ if (!ok) {
458
+ res.statusCode = 500;
459
+ return res.end(JSON.stringify({ error: `Failed to merge goal branch into ${targetBranch}` }));
460
+ }
461
+ } else if (op === 'discard') {
462
+ const ok = engine.discardGoalBranch(targetBranch, sid);
463
+ if (!ok) {
464
+ res.statusCode = 500;
465
+ return res.end(JSON.stringify({ error: 'Failed to discard goal branch' }));
466
+ }
467
+ }
468
+ result = engine.getSnapshot(sid);
469
+ break;
470
+ }
471
+
472
+ case 'templates':
473
+ res.statusCode = 200;
474
+ return res.end(JSON.stringify({ ok: true, templates: getTemplatesCatalogue() }));
475
+
346
476
  default:
347
477
  res.statusCode = 400;
348
478
  return res.end(JSON.stringify({ error: `Unknown action: ${action}` }));
package/lib/tools.js CHANGED
@@ -195,6 +195,9 @@ export function registerTools(ctx, { engine, resumeActiveAgent }) {
195
195
  const handleSetMilestones = async ({ milestones }, toolCtx) => {
196
196
  const sid = sessionIdOf(toolCtx, 'default');
197
197
  const snap = engine.setMilestones(milestones, sid);
198
+ if (typeof engine.recordToolActivity === 'function') {
199
+ engine.recordToolActivity('goal_set_milestones', 'success', 'Defined milestones', sid);
200
+ }
198
201
  return {
199
202
  success: true,
200
203
  count: snap.milestones?.length || 0,
@@ -215,6 +218,7 @@ export function registerTools(ctx, { engine, resumeActiveAgent }) {
215
218
  properties: {
216
219
  id: { type: 'string', description: 'Short identifier, e.g. M1, step-1' },
217
220
  title: { type: 'string', description: 'Concise actionable title' },
221
+ dependsOn: { type: 'array', items: { type: 'string' }, description: 'Prerequisite milestone IDs' },
218
222
  },
219
223
  required: ['id', 'title'],
220
224
  },
@@ -231,7 +235,10 @@ export function registerTools(ctx, { engine, resumeActiveAgent }) {
231
235
  // Tool 5: goal_update_progress
232
236
  const handleUpdateProgress = async ({ milestone_id, status, notes, checklist }, toolCtx) => {
233
237
  const sid = sessionIdOf(toolCtx, 'default');
234
- engine.updateMilestone(milestone_id, status, notes, sid, checklist);
238
+ const ok = engine.updateMilestone(milestone_id, status, notes, sid, checklist);
239
+ if (typeof engine.recordToolActivity === 'function') {
240
+ engine.recordToolActivity('goal_update_progress', ok ? 'success' : 'error', String(milestone_id) + ' -> ' + String(status), sid);
241
+ }
235
242
  const snap = engine.getSnapshot(sid);
236
243
  const targetMilestone = snap.milestones?.find((m) => m.id === String(milestone_id) || m.id.toLowerCase().replace(/[^a-z0-9]/g, '') === String(milestone_id).toLowerCase().replace(/[^a-z0-9]/g, '') || m.id.toLowerCase().replace(/[^0-9]/g, '') === String(milestone_id).toLowerCase().replace(/[^0-9]/g, ''));
237
244
  return {
@@ -282,6 +289,9 @@ export function registerTools(ctx, { engine, resumeActiveAgent }) {
282
289
  const handleGoalFinish = async ({ summary }, toolCtx) => {
283
290
  const sid = sessionIdOf(toolCtx, 'default');
284
291
  const snap = engine.completeGoal(summary, sid);
292
+ if (typeof engine.recordToolActivity === 'function') {
293
+ engine.recordToolActivity('goal_finish', 'success', summary ? summary.slice(0, 80) : 'Goal finished', sid);
294
+ }
285
295
  return {
286
296
  success: true,
287
297
  completed: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-goal",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "Autonomous Goal Execution & Multi-Turn Task Tracking Engine with Sticky Header for DeepSeek Harness",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",