@goodandready/dsh-goal 0.2.10 → 0.2.11

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.
@@ -1,3 +1,6 @@
1
+ import fs from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+
1
4
  /**
2
5
  * Parse issue identifier from goal title or string
3
6
  * @param {string} input
@@ -64,3 +67,128 @@ export function updateChecklistInMarkdown(markdown, itemText, done) {
64
67
  return line;
65
68
  }).join('\n');
66
69
  }
70
+
71
+ /**
72
+ * Resolve Gitea API credentials from options, environment, or agent config
73
+ * @param {object} [options]
74
+ * @returns {{ token?: string, baseUrl: string }}
75
+ */
76
+ export function resolveGiteaAuth(options = {}) {
77
+ const defaultUrl = options.baseUrl || process.env.GITEA_BASE_URL || 'http://127.0.0.1:3005';
78
+ if (options.token) {
79
+ return { token: options.token, baseUrl: defaultUrl };
80
+ }
81
+ if (process.env.GITEA_TOKEN) {
82
+ return { token: process.env.GITEA_TOKEN, baseUrl: defaultUrl };
83
+ }
84
+
85
+ const credPath = process.env.DSH_GITEA_CREDENTIALS || '/mnt/external/Project/DEV/.gitea-agent-credentials.json';
86
+ try {
87
+ if (fs.existsSync(credPath)) {
88
+ const raw = fs.readFileSync(credPath, 'utf8');
89
+ const creds = JSON.parse(raw);
90
+ const agent = creds?.agents?.['deepseek-harness'] || creds?.agents?.antigravity;
91
+ if (agent?.token) {
92
+ return {
93
+ token: agent.token,
94
+ baseUrl: options.baseUrl || agent.http_remote_base || defaultUrl,
95
+ };
96
+ }
97
+ }
98
+ } catch (err) {
99
+ // Non-fatal credential read error
100
+ }
101
+ return { baseUrl: defaultUrl };
102
+ }
103
+
104
+ /**
105
+ * Resolve repository owner and repo name from git remote or options
106
+ * @param {string} [cwd]
107
+ * @param {object} [options]
108
+ * @returns {{ owner: string, repo: string }}
109
+ */
110
+ export function resolveRepoInfo(cwd = process.cwd(), options = {}) {
111
+ if (options.owner && options.repo) {
112
+ return { owner: options.owner, repo: options.repo };
113
+ }
114
+ try {
115
+ const remote = execFileSync('git', ['remote', 'get-url', 'origin'], {
116
+ cwd,
117
+ encoding: 'utf8',
118
+ stdio: ['ignore', 'pipe', 'ignore'],
119
+ }).trim();
120
+ const match = remote.match(/(?:[:/])([^/]+)\/([^/]+?)(?:\.git)?$/);
121
+ if (match) {
122
+ return { owner: match[1], repo: match[2] };
123
+ }
124
+ } catch (err) {
125
+ // Fallback to default repository
126
+ }
127
+ return { owner: 'goodandready', repo: 'dsh-goal' };
128
+ }
129
+
130
+ /**
131
+ * Actively sync completed milestone checklist item with Gitea issue
132
+ * @param {object} goal
133
+ * @param {object} milestone
134
+ * @param {object} [options]
135
+ * @returns {Promise<{ synced: boolean, updated?: boolean, issueNumber?: number, error?: string }>}
136
+ */
137
+ export async function syncMilestoneWithIssue(goal, milestone, options = {}) {
138
+ if (!goal?.issueRef?.issueNumber || !milestone?.title) {
139
+ return { synced: false, error: 'No issue reference or milestone title' };
140
+ }
141
+
142
+ const auth = resolveGiteaAuth(options);
143
+ if (!auth.token) {
144
+ return { synced: false, error: 'No Gitea API token available' };
145
+ }
146
+
147
+ const repoInfo = resolveRepoInfo(goal.cwd || process.cwd(), options);
148
+ const issueNum = goal.issueRef.issueNumber;
149
+ const issueApiUrl = `${auth.baseUrl}/api/v1/repos/${repoInfo.owner}/${repoInfo.repo}/issues/${issueNum}`;
150
+
151
+ try {
152
+ const fetchFn = options.fetch || globalThis.fetch;
153
+ if (typeof fetchFn !== 'function') {
154
+ return { synced: false, error: 'Fetch API unavailable' };
155
+ }
156
+
157
+ const getRes = await fetchFn(issueApiUrl, {
158
+ headers: {
159
+ 'Authorization': `token ${auth.token}`,
160
+ 'Accept': 'application/json',
161
+ },
162
+ });
163
+
164
+ if (!getRes.ok) {
165
+ return { synced: false, error: `HTTP ${getRes.status} fetching issue #${issueNum}` };
166
+ }
167
+
168
+ const issueData = await getRes.json();
169
+ const currentBody = issueData.body || '';
170
+ const updatedBody = updateChecklistInMarkdown(currentBody, milestone.title, true);
171
+
172
+ if (updatedBody === currentBody) {
173
+ return { synced: true, updated: false, issueNumber: issueNum };
174
+ }
175
+
176
+ const patchRes = await fetchFn(issueApiUrl, {
177
+ method: 'PATCH',
178
+ headers: {
179
+ 'Authorization': `token ${auth.token}`,
180
+ 'Content-Type': 'application/json',
181
+ 'Accept': 'application/json',
182
+ },
183
+ body: JSON.stringify({ body: updatedBody }),
184
+ });
185
+
186
+ if (!patchRes.ok) {
187
+ return { synced: false, error: `HTTP ${patchRes.status} updating issue #${issueNum}` };
188
+ }
189
+
190
+ return { synced: true, updated: true, issueNumber: issueNum };
191
+ } catch (err) {
192
+ return { synced: false, error: err.message };
193
+ }
194
+ }
@@ -5,7 +5,7 @@ import { buildStatePromptInjection } from './engine-prompt.js';
5
5
  import { matchMilestone, parseMilestoneItems, applyMilestoneUpdate, toggleMilestoneChecklistItem } from './engine-milestones.js';
6
6
  import { applyIntervention, recordActivity, processTokenBudget } from './engine-intervention.js';
7
7
  import { createGoalBranch, sanitizeBranchSlug, getCurrentGitBranch, mergeGoalBranch, discardGoalBranch } from './engine-git-branch.js';
8
- import { parseIssueRef } from './engine-issue-sync.js';
8
+ import { parseIssueRef, syncMilestoneWithIssue } from './engine-issue-sync.js';
9
9
  import { generateRetrospectiveData } from './engine-retrospective.js';
10
10
  import { generatePreplanDraft } from './engine-templates.js';
11
11
  import { pruneInactiveSessions, addNudgeToGoal, consumeGoalNudge, extendGoalBudget } from './engine-sessions.js';
@@ -378,11 +378,22 @@ export class GoalEngine {
378
378
  const prevStatus = res.prevStatus;
379
379
  recordActivity(goal, { type: 'milestone', tool: 'updateMilestone', status: 'success', details: `${id} -> ${status}` });
380
380
 
381
- if (target.status === MilestoneStatus.COMPLETED && prevStatus !== MilestoneStatus.COMPLETED && this.autoCheckpointOnMilestone) {
382
- const commitHash = createMilestoneCheckpoint(target, sid);
383
- if (commitHash) {
384
- target.checkpointCommit = commitHash;
385
- goal.logs.push({ timestamp: Date.now(), type: 'info', message: `Git checkpoint saved for [${target.title}]: ${commitHash}` });
381
+ if (target.status === MilestoneStatus.COMPLETED && prevStatus !== MilestoneStatus.COMPLETED) {
382
+ if (this.autoCheckpointOnMilestone) {
383
+ const commitHash = createMilestoneCheckpoint(target, sid);
384
+ if (commitHash) {
385
+ target.checkpointCommit = commitHash;
386
+ goal.logs.push({ timestamp: Date.now(), type: 'info', message: `Git checkpoint saved for [${target.title}]: ${commitHash}` });
387
+ }
388
+ }
389
+ if (goal.issueRef) {
390
+ syncMilestoneWithIssue(goal, target).then((syncRes) => {
391
+ if (syncRes?.updated) {
392
+ goal.logs.push({ timestamp: Date.now(), type: 'info', message: `Synced checklist for issue #${syncRes.issueNumber}` });
393
+ recordActivity(goal, { type: 'issue', tool: 'syncIssueChecklist', status: 'success', details: `Issue #${syncRes.issueNumber} updated` });
394
+ this.emit(sid);
395
+ }
396
+ }).catch(() => {});
386
397
  }
387
398
  }
388
399
 
package/lib/index.js CHANGED
@@ -202,35 +202,19 @@ export function apply(ctx, config = {}) {
202
202
  const getConfig = () => {
203
203
  if (settingsScope) {
204
204
  const snap = typeof settingsScope.getSnapshot === 'function' ? settingsScope.getSnapshot() : null;
205
+ let live = null;
205
206
  if (snap) {
206
- if (snap.status === 'ready' || snap.status === undefined) {
207
- const live = snap.value || (typeof settingsScope.get === 'function' ? settingsScope.get() : null);
208
- if (live && typeof live === 'object') {
209
- return {
210
- maxIterations: typeof live.maxIterations === 'number' ? live.maxIterations : currentSettings.maxIterations,
211
- autoDrive: typeof live.autoDrive === 'boolean' ? live.autoDrive : currentSettings.autoDrive,
212
- enableSound: typeof live.enableSound === 'boolean' ? live.enableSound : currentSettings.enableSound,
213
- showQuickLaunchButton: typeof live.showQuickLaunchButton === 'boolean' ? live.showQuickLaunchButton : currentSettings.showQuickLaunchButton,
214
- consecutiveToolFailureLimit: typeof live.consecutiveToolFailureLimit === 'number' ? live.consecutiveToolFailureLimit : currentSettings.consecutiveToolFailureLimit,
215
- enableBrowserNotifications: typeof live.enableBrowserNotifications === 'boolean' ? live.enableBrowserNotifications : currentSettings.enableBrowserNotifications,
216
- maxTokenBudget: typeof live.maxTokenBudget === 'number' ? live.maxTokenBudget : currentSettings.maxTokenBudget,
217
- budgetWarningThreshold: typeof live.budgetWarningThreshold === 'number' ? live.budgetWarningThreshold : currentSettings.budgetWarningThreshold,
218
- autoCheckpointOnMilestone: typeof live.autoCheckpointOnMilestone === 'boolean' ? live.autoCheckpointOnMilestone : currentSettings.autoCheckpointOnMilestone,
219
- storagePath: typeof live.storagePath === 'string' ? live.storagePath : currentSettings.storagePath,
220
- };
221
- }
207
+ if (snap.status === 'ready' || snap.status === 'writable' || snap.status === undefined) {
208
+ live = snap.value || (typeof settingsScope.get === 'function' ? settingsScope.get() : null);
222
209
  }
223
210
  } else if (typeof settingsScope.get === 'function') {
224
- const live = settingsScope.get();
225
- if (live && typeof live === 'object') {
226
- return {
227
- maxIterations: typeof live.maxIterations === 'number' ? live.maxIterations : currentSettings.maxIterations,
228
- autoDrive: typeof live.autoDrive === 'boolean' ? live.autoDrive : currentSettings.autoDrive,
229
- enableSound: typeof live.enableSound === 'boolean' ? live.enableSound : currentSettings.enableSound,
230
- showQuickLaunchButton: typeof live.showQuickLaunchButton === 'boolean' ? live.showQuickLaunchButton : currentSettings.showQuickLaunchButton,
231
- storagePath: typeof live.storagePath === 'string' ? live.storagePath : currentSettings.storagePath,
232
- };
233
- }
211
+ live = settingsScope.get();
212
+ }
213
+ if (live && typeof live === 'object') {
214
+ return {
215
+ ...currentSettings,
216
+ ...live,
217
+ };
234
218
  }
235
219
  }
236
220
  return currentSettings;
@@ -316,7 +300,9 @@ export function apply(ctx, config = {}) {
316
300
  try {
317
301
  if (typeof pctx.systemPrompt?.section === 'function') {
318
302
  const globalSections = pctx.systemPrompt.layers?.global?.sections;
303
+ let displacedPromptSection = null;
319
304
  if (globalSections?.data instanceof Map && globalSections.data.has('tool:goal')) {
305
+ displacedPromptSection = globalSections.data.get('tool:goal');
320
306
  globalSections.data.delete('tool:goal');
321
307
  }
322
308
 
@@ -334,7 +320,12 @@ export function apply(ctx, config = {}) {
334
320
  });
335
321
 
336
322
  if (typeof unregisterSection === 'function') {
337
- pctx.effect(() => () => unregisterSection(), 'dsh-goal: system prompt section');
323
+ pctx.effect(() => () => {
324
+ unregisterSection();
325
+ if (displacedPromptSection && globalSections?.data instanceof Map) {
326
+ globalSections.data.set('tool:goal', displacedPromptSection);
327
+ }
328
+ }, 'dsh-goal: system prompt section');
338
329
  }
339
330
  }
340
331
  } catch (err) {
package/lib/routes.js CHANGED
@@ -21,10 +21,6 @@ export function isTrustedCaller(req) {
21
21
  const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || '';
22
22
  if (isLoopback(remote)) return true;
23
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
24
  const host = String(req.headers?.host || '').toLowerCase();
29
25
  const origin = String(req.headers?.origin || '').trim().toLowerCase();
30
26
  if (origin) {
@@ -167,6 +163,11 @@ export function registerRoutes(ctx, {
167
163
 
168
164
  // GET /dsh-goal/events: Server-Sent Events realtime snapshot stream
169
165
  if (req.method === 'GET' && (pathname === '/dsh-goal/events' || pathname === '/dsh-goal/events/')) {
166
+ if (!isTrustedCaller(req)) {
167
+ res.statusCode = 403;
168
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
169
+ return res.end(JSON.stringify({ error: 'Forbidden: untrusted caller origin' }));
170
+ }
170
171
  const sid = sessionIdOf(req, 'default');
171
172
  res.writeHead(200, {
172
173
  'Content-Type': 'text/event-stream',
@@ -197,12 +198,20 @@ export function registerRoutes(ctx, {
197
198
 
198
199
  // GET /dsh-goal/templates
199
200
  if (req.method === 'GET' && (pathname === '/dsh-goal/templates' || pathname === '/dsh-goal/templates/')) {
201
+ if (!isTrustedCaller(req)) {
202
+ res.statusCode = 403;
203
+ return res.end(JSON.stringify({ error: 'Forbidden: untrusted caller origin' }));
204
+ }
200
205
  res.statusCode = 200;
201
206
  return res.end(JSON.stringify({ ok: true, templates: getTemplatesCatalogue() }));
202
207
  }
203
208
 
204
209
  // GET /dsh-goal/state
205
210
  if (req.method === 'GET' && (pathname === '/dsh-goal/state' || pathname === '/dsh-goal/state/')) {
211
+ if (!isTrustedCaller(req)) {
212
+ res.statusCode = 403;
213
+ return res.end(JSON.stringify({ error: 'Forbidden: untrusted caller origin' }));
214
+ }
206
215
  const sid = sessionIdOf(req, 'default');
207
216
  res.statusCode = 200;
208
217
  return res.end(JSON.stringify(engine.getSnapshot(sid)));
@@ -268,13 +277,26 @@ export function registerRoutes(ctx, {
268
277
  res.statusCode = 400;
269
278
  return res.end(JSON.stringify({ error: 'Goal title cannot be empty' }));
270
279
  }
280
+ const trustedRoots = [process.cwd()];
281
+ if (process.env.DSH_WORKSPACE_ROOT) {
282
+ trustedRoots.push(process.env.DSH_WORKSPACE_ROOT);
283
+ }
284
+ let safeStartCwd = undefined;
285
+ if (data.cwd) {
286
+ const validated = getSafeCwd(data.cwd, trustedRoots);
287
+ if (!validated) {
288
+ res.statusCode = 400;
289
+ return res.end(JSON.stringify({ error: 'Invalid or unauthorized working directory' }));
290
+ }
291
+ safeStartCwd = validated;
292
+ }
271
293
  const detectedLang = data.lang || detectLanguage(cleanTitle);
272
294
  const conf = typeof getConfig === 'function' ? getConfig() : { maxIterations: 25 };
273
295
  result = engine.startGoal(cleanTitle, {
274
296
  description: typeof description === 'string' ? description.trim() : '',
275
297
  maxIterations: conf.maxIterations || 25,
276
298
  lang: detectedLang,
277
- cwd: data.cwd,
299
+ cwd: safeStartCwd,
278
300
  }, sid);
279
301
 
280
302
  const startPrompt = detectedLang === 'zh'
@@ -390,7 +412,15 @@ export function registerRoutes(ctx, {
390
412
  }
391
413
  const sessionGoal = engine.getGoal(sid);
392
414
  const allowedRoots = [process.cwd()];
393
- if (sessionGoal?.cwd) allowedRoots.push(sessionGoal.cwd);
415
+ if (process.env.DSH_WORKSPACE_ROOT) {
416
+ allowedRoots.push(process.env.DSH_WORKSPACE_ROOT);
417
+ }
418
+ if (sessionGoal?.cwd) {
419
+ const isCwdAllowed = allowedRoots.some((root) => isPathInsideOrEqual(root, sessionGoal.cwd));
420
+ if (isCwdAllowed) {
421
+ allowedRoots.push(sessionGoal.cwd);
422
+ }
423
+ }
394
424
  const safeCwd = getSafeCwd(data.cwd, allowedRoots);
395
425
  if (!safeCwd) {
396
426
  res.statusCode = 400;
@@ -413,7 +443,15 @@ export function registerRoutes(ctx, {
413
443
  }
414
444
  const sessionGoal = engine.getGoal(sid);
415
445
  const allowedRoots = [process.cwd()];
416
- if (sessionGoal?.cwd) allowedRoots.push(sessionGoal.cwd);
446
+ if (process.env.DSH_WORKSPACE_ROOT) {
447
+ allowedRoots.push(process.env.DSH_WORKSPACE_ROOT);
448
+ }
449
+ if (sessionGoal?.cwd) {
450
+ const isCwdAllowed = allowedRoots.some((root) => isPathInsideOrEqual(root, sessionGoal.cwd));
451
+ if (isCwdAllowed) {
452
+ allowedRoots.push(sessionGoal.cwd);
453
+ }
454
+ }
417
455
  const safeCwd = getSafeCwd(data.cwd, allowedRoots);
418
456
  if (!safeCwd) {
419
457
  res.statusCode = 400;
package/lib/tools.js CHANGED
@@ -46,16 +46,23 @@ export function registerTools(ctx, { engine, resumeActiveAgent }) {
46
46
  ctx.inject(['tools'], (tctx) => {
47
47
  if (!tctx.tools?.register) return;
48
48
 
49
+ const displacedTools = new Map();
49
50
  const safeRegister = (definition) => {
50
51
  try {
51
52
  const name = definition.name;
52
53
  const globalTools = tctx.tools.layers?.global?.tools;
53
54
  if (globalTools?.data instanceof Map && globalTools.data.has(name)) {
55
+ displacedTools.set(name, globalTools.data.get(name));
54
56
  globalTools.data.delete(name);
55
57
  }
56
58
  const unregister = tctx.tools.register(definition);
57
59
  if (typeof unregister === 'function') {
58
- tctx.effect(() => () => unregister(), `dsh-goal: tool ${name}`);
60
+ tctx.effect(() => () => {
61
+ unregister();
62
+ if (displacedTools.has(name) && globalTools?.data instanceof Map) {
63
+ globalTools.data.set(name, displacedTools.get(name));
64
+ }
65
+ }, `dsh-goal: tool ${name}`);
59
66
  }
60
67
  } catch (err) {
61
68
  console.warn(`[dsh-goal] Tool ${definition.name} registration skipped:`, err.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-goal",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
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",