@goodandready/dsh-goal 0.2.10 → 0.2.12

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/client.js CHANGED
@@ -1609,7 +1609,7 @@ window.__ModuleLoader__.load({
1609
1609
  if (!AudioCtx) return;
1610
1610
  const actx = new AudioCtx();
1611
1611
  if (actx.state === 'suspended') {
1612
- actx.resume().catch(() => {});
1612
+ actx.resume().catch((err) => { console.warn('[dsh-goal] Fetch templates error:', err); });
1613
1613
  }
1614
1614
  const now = actx.currentTime;
1615
1615
  if (scheme === 'retro') {
@@ -2590,9 +2590,51 @@ window.__ModuleLoader__.load({
2590
2590
  function QuickLaunchModal({ onClose, onStart, t }) {
2591
2591
 
2592
2592
  const [title, setTitle] = useState('');
2593
+ const [showTemplates, setShowTemplates] = useState(false);
2594
+ const [backendTemplates, setBackendTemplates] = useState([]);
2595
+ const [selectedTpl, setSelectedTpl] = useState(null);
2596
+ const [tplInputs, setTplInputs] = useState({});
2597
+ const [loadingTpls, setLoadingTpls] = useState(false);
2593
2598
 
2594
2599
  const inputRef = useRef(null);
2595
2600
 
2601
+ useEffect(() => {
2602
+ if (showTemplates && backendTemplates.length === 0) {
2603
+ setLoadingTpls(true);
2604
+ fetch('/dsh-goal/templates')
2605
+ .then((r) => r.json())
2606
+ .then((d) => {
2607
+ if (d && Array.isArray(d.templates)) {
2608
+ setBackendTemplates(d.templates);
2609
+ }
2610
+ })
2611
+ .catch((err) => { console.warn('[dsh-goal] Fetch templates error:', err); })
2612
+ .finally(() => setLoadingTpls(false));
2613
+ }
2614
+ }, [showTemplates, backendTemplates.length]);
2615
+
2616
+ const handleApplyTemplate = async () => {
2617
+ if (!selectedTpl) return;
2618
+ try {
2619
+ const res = await fetch('/dsh-goal/action', {
2620
+ method: 'POST',
2621
+ headers: { 'Content-Type': 'application/json' },
2622
+ body: JSON.stringify({
2623
+ action: 'instantiate_template',
2624
+ templateId: selectedTpl.id,
2625
+ inputs: tplInputs,
2626
+ }),
2627
+ });
2628
+ if (res.ok) {
2629
+ const data = await res.json();
2630
+ if (data.ok && data.template) {
2631
+ setTitle(data.template.title);
2632
+ setShowTemplates(false);
2633
+ }
2634
+ }
2635
+ } catch (err) { console.warn('[dsh-goal] Apply template error:', err); }
2636
+ };
2637
+
2596
2638
  useEffect(() => {
2597
2639
 
2598
2640
  if (inputRef.current) inputRef.current.focus();
@@ -2782,8 +2824,94 @@ window.__ModuleLoader__.load({
2782
2824
  },
2783
2825
  tpl.label
2784
2826
  )
2827
+ ),
2828
+ React.createElement(
2829
+ 'button',
2830
+ {
2831
+ type: 'button',
2832
+ className: 'dsh-goal-chip-btn',
2833
+ style: { fontWeight: 600, borderColor: 'var(--dsw-alias-border-brand, var(--dsw-alias-border-l2))' },
2834
+ onClick: () => setShowTemplates(!showTemplates),
2835
+ },
2836
+ (showTemplates ? '▲ ' : '▼ ') + '📋 ' + (t('templatesTitle') || 'Engineering Templates')
2785
2837
  )
2786
2838
  ),
2839
+ showTemplates
2840
+ ? React.createElement(
2841
+ 'div',
2842
+ {
2843
+ style: {
2844
+ marginTop: 10,
2845
+ padding: '10px 12px',
2846
+ borderRadius: 6,
2847
+ border: '1px solid var(--dsw-alias-border-l1)',
2848
+ background: 'var(--dsw-alias-bg-hover)',
2849
+ },
2850
+ },
2851
+ loadingTpls
2852
+ ? React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, 'Loading templates…')
2853
+ : React.createElement(
2854
+ 'div',
2855
+ null,
2856
+ React.createElement(
2857
+ 'div',
2858
+ { style: { display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 } },
2859
+ backendTemplates.map((bt) =>
2860
+ React.createElement(
2861
+ 'button',
2862
+ {
2863
+ key: bt.id,
2864
+ type: 'button',
2865
+ className: `dsh-goal-chip-btn ${selectedTpl?.id === bt.id ? 'active' : ''}`,
2866
+ style: selectedTpl?.id === bt.id ? { fontWeight: 600, border: '1px solid var(--dsw-alias-accent-primary)' } : {},
2867
+ onClick: () => {
2868
+ setSelectedTpl(bt);
2869
+ const initial = {};
2870
+ (bt.inputs || []).forEach((inp) => {
2871
+ initial[inp.key] = inp.defaultValue || '';
2872
+ });
2873
+ setTplInputs(initial);
2874
+ },
2875
+ },
2876
+ bt.title
2877
+ )
2878
+ )
2879
+ ),
2880
+ selectedTpl
2881
+ ? React.createElement(
2882
+ 'div',
2883
+ { style: { borderTop: '1px solid var(--dsw-alias-border-l1)', paddingTop: 8 } },
2884
+ React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginBottom: 6 } }, selectedTpl.description),
2885
+ (selectedTpl.inputs || []).map((inp) =>
2886
+ React.createElement(
2887
+ 'div',
2888
+ { key: inp.key, style: { marginBottom: 6 } },
2889
+ React.createElement('label', { style: { fontSize: 11, display: 'block', marginBottom: 2 } }, inp.label),
2890
+ React.createElement('input', {
2891
+ type: 'text',
2892
+ className: 'dsh-goal-card-input',
2893
+ style: { width: '100%', boxSizing: 'border-box', fontSize: 12, padding: '4px 8px' },
2894
+ placeholder: inp.placeholder || '',
2895
+ value: tplInputs[inp.key] || '',
2896
+ onChange: (e) => setTplInputs({ ...tplInputs, [inp.key]: e.target.value }),
2897
+ })
2898
+ )
2899
+ ),
2900
+ React.createElement(
2901
+ 'button',
2902
+ {
2903
+ type: 'button',
2904
+ className: 'dsh-goal-btn dsh-goal-btn-primary',
2905
+ style: { marginTop: 4, padding: '4px 10px', fontSize: 12 },
2906
+ onClick: handleApplyTemplate,
2907
+ },
2908
+ 'Apply Template'
2909
+ )
2910
+ )
2911
+ : null
2912
+ )
2913
+ )
2914
+ : null
2787
2915
  ),
2788
2916
 
2789
2917
  React.createElement(
@@ -3398,6 +3526,17 @@ window.__ModuleLoader__.load({
3398
3526
 
3399
3527
  const isCompleted = state.state === 'COMPLETED';
3400
3528
 
3529
+ const formatElapsed = (totalSeconds) => {
3530
+ const s = Math.max(0, Math.floor(Number(totalSeconds) || 0));
3531
+ if (s < 60) return `${s}s`;
3532
+ const m = Math.floor(s / 60);
3533
+ const remS = s % 60;
3534
+ if (m < 60) return remS > 0 ? `${m}m ${remS}s` : `${m}m`;
3535
+ const h = Math.floor(m / 60);
3536
+ const remM = m % 60;
3537
+ return remM > 0 ? `${h}h ${remM}m` : `${h}h`;
3538
+ };
3539
+
3401
3540
  const formatLiveElapsed = () => {
3402
3541
 
3403
3542
  if (!state) return '0s';
@@ -3821,7 +3960,7 @@ window.__ModuleLoader__.load({
3821
3960
  const [updateState, setUpdateState] = useState({
3822
3961
  checking: false,
3823
3962
  updating: false,
3824
- currentVersion: '0.2.3',
3963
+ currentVersion: '',
3825
3964
  latestVersion: '',
3826
3965
  updateAvailable: false,
3827
3966
  notice: '',
@@ -4159,7 +4298,7 @@ window.__ModuleLoader__.load({
4159
4298
  'div',
4160
4299
  { style: { display: 'flex', alignItems: 'center', gap: 8 } },
4161
4300
  React.createElement('span', { style: { fontSize: 13, fontWeight: 500, color: 'var(--dsw-alias-label-primary)' } }, t('version') || 'Version'),
4162
- React.createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, 'v' + (updateState.currentVersion || '0.2.3')),
4301
+ React.createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, updateState.currentVersion ? 'v' + updateState.currentVersion : 'v0.2.12'),
4163
4302
  updateState.updateAvailable
4164
4303
  ? React.createElement('span', { className: 'dsh-goal-badge dsh-goal-badge-warn', style: { fontSize: 11 } }, 'v' + updateState.latestVersion + ' ' + (t('updateAvailable') || 'available'))
4165
4304
  : React.createElement('span', { className: 'dsh-goal-badge dsh-goal-badge-ok', style: { fontSize: 11 } }, '✓ ' + (t('upToDate') || 'Up to date')),
@@ -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
+ }
@@ -6,9 +6,10 @@ import { GoalState } from './goal-engine-constants.js';
6
6
  * Storage manager for goal engine disk state persistence
7
7
  */
8
8
  export class EngineStore {
9
- constructor(engine, storagePath) {
9
+ constructor(engine, storagePath, logger) {
10
10
  this.engine = engine;
11
11
  this.storagePath = storagePath || null;
12
+ this.logger = logger || engine?.logger || console;
12
13
  this.saveTimer = null;
13
14
  }
14
15
 
@@ -77,7 +78,7 @@ export class EngineStore {
77
78
  }
78
79
  } catch (err) {
79
80
  // best-effort load failure logged defensively
80
- console.warn?.('[GoalEngine] Failed to load state from disk:', err?.message || err);
81
+ this.logger?.warn?.('[GoalEngine] Failed to load state from disk:', err?.message || err);
81
82
  }
82
83
  }
83
84
 
@@ -128,7 +129,7 @@ export class EngineStore {
128
129
  fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
129
130
  fs.renameSync(tmp, this.storagePath);
130
131
  } catch (err) {
131
- console.warn?.('[GoalEngine] Failed to write state to disk:', err?.message || err);
132
+ this.logger?.warn?.('[GoalEngine] Failed to write state to disk:', err?.message || err);
132
133
  }
133
134
  }
134
135
 
@@ -5,12 +5,12 @@ 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, parseIssueChecklist, 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';
12
12
 
13
- export { GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, matchMilestone };
13
+ export { GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, matchMilestone, parseIssueChecklist };
14
14
  export { getGitCurrentCommit, exportReportMarkdown, exportReportGitHubPR, createMilestoneCheckpoint, rollbackToCheckpoint, saveGoalArtifact };
15
15
 
16
16
  /**
@@ -73,7 +73,7 @@ export class GoalEngine {
73
73
  try {
74
74
  listener(snapshot, sid);
75
75
  } catch (err) {
76
- console.error('[dsh-goal] Error in GoalEngine listener:', err);
76
+ this.logger?.error?.('[dsh-goal] Error in GoalEngine listener:', err);
77
77
  }
78
78
  }
79
79
  }
@@ -234,6 +234,13 @@ export class GoalEngine {
234
234
  }
235
235
  }
236
236
 
237
+ if (options.issueBody && (!options.milestones || options.milestones.length === 0)) {
238
+ const items = parseIssueChecklist(options.issueBody);
239
+ if (items.length > 0) {
240
+ options.milestones = items.map((it) => ({ title: it.text, status: it.done ? MilestoneStatus.COMPLETED : MilestoneStatus.PENDING }));
241
+ }
242
+ }
243
+
237
244
  if (this.enablePreplanning && options.preplan !== false && (!options.milestones || options.milestones.length === 0)) {
238
245
  goal.preplan = { active: true, draftMilestones: generatePreplanDraft(cleanTitle) };
239
246
  }
@@ -378,11 +385,22 @@ export class GoalEngine {
378
385
  const prevStatus = res.prevStatus;
379
386
  recordActivity(goal, { type: 'milestone', tool: 'updateMilestone', status: 'success', details: `${id} -> ${status}` });
380
387
 
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}` });
388
+ if (target.status === MilestoneStatus.COMPLETED && prevStatus !== MilestoneStatus.COMPLETED) {
389
+ if (this.autoCheckpointOnMilestone) {
390
+ const commitHash = createMilestoneCheckpoint(target, sid);
391
+ if (commitHash) {
392
+ target.checkpointCommit = commitHash;
393
+ goal.logs.push({ timestamp: Date.now(), type: 'info', message: `Git checkpoint saved for [${target.title}]: ${commitHash}` });
394
+ }
395
+ }
396
+ if (goal.issueRef) {
397
+ syncMilestoneWithIssue(goal, target).then((syncRes) => {
398
+ if (syncRes?.updated) {
399
+ goal.logs.push({ timestamp: Date.now(), type: 'info', message: `Synced checklist for issue #${syncRes.issueNumber}` });
400
+ recordActivity(goal, { type: 'issue', tool: 'syncIssueChecklist', status: 'success', details: `Issue #${syncRes.issueNumber} updated` });
401
+ this.emit(sid);
402
+ }
403
+ }).catch(() => {});
386
404
  }
387
405
  }
388
406
 
package/lib/index.js CHANGED
@@ -65,9 +65,12 @@ export function apply(ctx, config = {}) {
65
65
  };
66
66
 
67
67
  const defaultStorageDir = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
68
- const storagePath = currentSettings.storagePath !== undefined && currentSettings.storagePath !== null
69
- ? currentSettings.storagePath
70
- : path.join(defaultStorageDir, 'dsh-goal-state.json');
68
+ const customStorage = typeof currentSettings.storagePath === 'string' && currentSettings.storagePath.trim()
69
+ ? currentSettings.storagePath.trim()
70
+ : null;
71
+ const isTest = process.env.NODE_ENV === 'test' || Boolean(process.env.NODE_TEST_CONTEXT);
72
+ const storagePath = customStorage || (isTest ? null : path.join(defaultStorageDir, 'dsh-goal-state.json'));
73
+ const logger = ctx.logger ? ctx.logger('goal') : console;
71
74
 
72
75
  const engine = new GoalEngine({
73
76
  defaultMaxIterations: currentSettings.maxIterations,
@@ -88,6 +91,7 @@ export function apply(ctx, config = {}) {
88
91
  soundScheme: currentSettings.soundScheme,
89
92
  enableVoiceAnnouncements: currentSettings.enableVoiceAnnouncements,
90
93
  storagePath,
94
+ logger,
91
95
  });
92
96
 
93
97
  // Track running agents through global DSH core event
@@ -122,7 +126,7 @@ export function apply(ctx, config = {}) {
122
126
  try {
123
127
  specificAgent.cancel({ kind: 'user' });
124
128
  } catch (err) {
125
- console.warn('[dsh-goal] Failed to cancel specific agent for session:', err);
129
+ logger.warn('Failed to cancel specific agent for session:', err);
126
130
  }
127
131
  }
128
132
 
@@ -135,7 +139,7 @@ export function apply(ctx, config = {}) {
135
139
  }
136
140
  }
137
141
  } catch (err) {
138
- console.warn('[dsh-goal] Failed to cancel running agent:', err);
142
+ logger.warn('Failed to cancel running agent:', err);
139
143
  }
140
144
  }
141
145
 
@@ -145,7 +149,7 @@ export function apply(ctx, config = {}) {
145
149
  try {
146
150
  lastActiveAgent.cancel({ kind: 'user' });
147
151
  } catch (err) {
148
- console.warn('[dsh-goal] Failed to cancel lastActiveAgent:', err);
152
+ logger.warn('Failed to cancel lastActiveAgent:', err);
149
153
  }
150
154
  }
151
155
  }
@@ -193,7 +197,7 @@ export function apply(ctx, config = {}) {
193
197
  sessionAgents.set(sid, target);
194
198
  return true;
195
199
  } catch (err) {
196
- console.warn('[dsh-goal] Failed to resume agent:', err);
200
+ logger.warn('Failed to resume agent:', err);
197
201
  }
198
202
  }
199
203
  return false;
@@ -202,35 +206,19 @@ export function apply(ctx, config = {}) {
202
206
  const getConfig = () => {
203
207
  if (settingsScope) {
204
208
  const snap = typeof settingsScope.getSnapshot === 'function' ? settingsScope.getSnapshot() : null;
209
+ let live = null;
205
210
  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
- }
211
+ if (snap.status === 'ready' || snap.status === 'writable' || snap.status === undefined) {
212
+ live = snap.value || (typeof settingsScope.get === 'function' ? settingsScope.get() : null);
222
213
  }
223
214
  } 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
- }
215
+ live = settingsScope.get();
216
+ }
217
+ if (live && typeof live === 'object') {
218
+ return {
219
+ ...currentSettings,
220
+ ...live,
221
+ };
234
222
  }
235
223
  }
236
224
  return currentSettings;
@@ -279,7 +267,7 @@ export function apply(ctx, config = {}) {
279
267
  }
280
268
  }
281
269
  } catch (err) {
282
- console.warn('[dsh-goal] Settings register skipped:', err.message);
270
+ logger.warn('Settings register skipped:', err.message);
283
271
  }
284
272
  });
285
273
 
@@ -307,7 +295,7 @@ export function apply(ctx, config = {}) {
307
295
  cctx.effect(() => () => unregister(), 'dsh-goal: /goal command unregister');
308
296
  }
309
297
  } catch (err) {
310
- console.warn('[dsh-goal] Commands register skipped:', err.message);
298
+ logger.warn('Commands register skipped:', err.message);
311
299
  }
312
300
  });
313
301
 
@@ -316,7 +304,9 @@ export function apply(ctx, config = {}) {
316
304
  try {
317
305
  if (typeof pctx.systemPrompt?.section === 'function') {
318
306
  const globalSections = pctx.systemPrompt.layers?.global?.sections;
307
+ let displacedPromptSection = null;
319
308
  if (globalSections?.data instanceof Map && globalSections.data.has('tool:goal')) {
309
+ displacedPromptSection = globalSections.data.get('tool:goal');
320
310
  globalSections.data.delete('tool:goal');
321
311
  }
322
312
 
@@ -334,28 +324,39 @@ export function apply(ctx, config = {}) {
334
324
  });
335
325
 
336
326
  if (typeof unregisterSection === 'function') {
337
- pctx.effect(() => () => unregisterSection(), 'dsh-goal: system prompt section');
327
+ pctx.effect(() => () => {
328
+ unregisterSection();
329
+ if (displacedPromptSection && globalSections?.data instanceof Map) {
330
+ globalSections.data.set('tool:goal', displacedPromptSection);
331
+ }
332
+ }, 'dsh-goal: system prompt section');
338
333
  }
339
334
  }
340
335
  } catch (err) {
341
- console.warn('[dsh-goal] SystemPrompt section register skipped:', err.message);
336
+ logger.warn('SystemPrompt section register skipped:', err.message);
342
337
  }
343
338
  });
344
339
 
345
340
  // 4. Tools registration
346
- registerTools(ctx, { engine, resumeActiveAgent });
341
+ registerTools(ctx, { engine, resumeActiveAgent, logger });
347
342
 
348
- // 5. One-click auto-updater registration
343
+ // 5. One-click auto-updater registration with Cordis effect cleanup (Issue #99)
349
344
  if (ctx.webServer?.register) {
350
- try {
351
- registerPluginUpdater(ctx, {
352
- packageName: '@goodandready/dsh-goal',
353
- endpoint: '/api/dsh-goal/update',
354
- manifestUrl: new URL('../package.json', import.meta.url),
355
- });
356
- } catch (err) {
357
- console.warn('[dsh-goal] Failed to register plugin updater:', err.message);
358
- }
345
+ ctx.effect(() => {
346
+ let unreg = null;
347
+ try {
348
+ unreg = registerPluginUpdater(ctx, {
349
+ packageName: '@goodandready/dsh-goal',
350
+ endpoint: '/api/dsh-goal/update',
351
+ manifestUrl: new URL('../package.json', import.meta.url),
352
+ });
353
+ } catch (err) {
354
+ logger.warn('Failed to register plugin updater:', err.message);
355
+ }
356
+ return () => {
357
+ if (typeof unreg === 'function') unreg();
358
+ };
359
+ }, 'dsh-goal: plugin auto-updater');
359
360
  }
360
361
 
361
362
  // 6. WebServer HTTP & SSE routes
@@ -466,7 +467,7 @@ export function apply(ctx, config = {}) {
466
467
  target.followup(promptMsg);
467
468
  sessionAgents.set(sid, target);
468
469
  } catch (err) {
469
- console.warn('[dsh-goal] Failed auto-drive followup:', err);
470
+ logger.warn('Failed auto-drive followup:', err);
470
471
  }
471
472
  }
472
473
  }
package/lib/routes.js CHANGED
@@ -2,7 +2,7 @@ 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';
5
+ import { getTemplatesCatalogue, instantiateTemplate } from './engine-templates.js';
6
6
 
7
7
  export function isLoopback(value) {
8
8
  if (!value) return false;
@@ -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,27 @@ 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,
300
+ issueBody: data.issueBody,
278
301
  }, sid);
279
302
 
280
303
  const startPrompt = detectedLang === 'zh'
@@ -390,7 +413,15 @@ export function registerRoutes(ctx, {
390
413
  }
391
414
  const sessionGoal = engine.getGoal(sid);
392
415
  const allowedRoots = [process.cwd()];
393
- if (sessionGoal?.cwd) allowedRoots.push(sessionGoal.cwd);
416
+ if (process.env.DSH_WORKSPACE_ROOT) {
417
+ allowedRoots.push(process.env.DSH_WORKSPACE_ROOT);
418
+ }
419
+ if (sessionGoal?.cwd) {
420
+ const isCwdAllowed = allowedRoots.some((root) => isPathInsideOrEqual(root, sessionGoal.cwd));
421
+ if (isCwdAllowed) {
422
+ allowedRoots.push(sessionGoal.cwd);
423
+ }
424
+ }
394
425
  const safeCwd = getSafeCwd(data.cwd, allowedRoots);
395
426
  if (!safeCwd) {
396
427
  res.statusCode = 400;
@@ -413,7 +444,15 @@ export function registerRoutes(ctx, {
413
444
  }
414
445
  const sessionGoal = engine.getGoal(sid);
415
446
  const allowedRoots = [process.cwd()];
416
- if (sessionGoal?.cwd) allowedRoots.push(sessionGoal.cwd);
447
+ if (process.env.DSH_WORKSPACE_ROOT) {
448
+ allowedRoots.push(process.env.DSH_WORKSPACE_ROOT);
449
+ }
450
+ if (sessionGoal?.cwd) {
451
+ const isCwdAllowed = allowedRoots.some((root) => isPathInsideOrEqual(root, sessionGoal.cwd));
452
+ if (isCwdAllowed) {
453
+ allowedRoots.push(sessionGoal.cwd);
454
+ }
455
+ }
417
456
  const safeCwd = getSafeCwd(data.cwd, allowedRoots);
418
457
  if (!safeCwd) {
419
458
  res.statusCode = 400;
@@ -473,6 +512,21 @@ export function registerRoutes(ctx, {
473
512
  res.statusCode = 200;
474
513
  return res.end(JSON.stringify({ ok: true, templates: getTemplatesCatalogue() }));
475
514
 
515
+ case 'instantiate_template': {
516
+ const tplId = data.templateId || data.id;
517
+ if (!tplId) {
518
+ res.statusCode = 400;
519
+ return res.end(JSON.stringify({ error: 'templateId is required' }));
520
+ }
521
+ const instance = instantiateTemplate(tplId, data.inputs || {});
522
+ if (!instance) {
523
+ res.statusCode = 404;
524
+ return res.end(JSON.stringify({ error: `Template "${tplId}" not found` }));
525
+ }
526
+ res.statusCode = 200;
527
+ return res.end(JSON.stringify({ ok: true, template: instance }));
528
+ }
529
+
476
530
  default:
477
531
  res.statusCode = 400;
478
532
  return res.end(JSON.stringify({ error: `Unknown action: ${action}` }));
@@ -488,6 +542,20 @@ export function registerRoutes(ctx, {
488
542
  return;
489
543
  }
490
544
 
545
+ const KNOWN_ROUTES = {
546
+ '/dsh-goal/events': ['GET'],
547
+ '/dsh-goal/templates': ['GET'],
548
+ '/dsh-goal/state': ['GET'],
549
+ '/dsh-goal/action': ['POST'],
550
+ };
551
+ const cleanPath = pathname.replace(/\/+$/, '') || '/';
552
+ const allowedMethods = KNOWN_ROUTES[cleanPath];
553
+ if (allowedMethods && !allowedMethods.includes(req.method)) {
554
+ res.statusCode = 405;
555
+ res.setHeader('Allow', allowedMethods.join(', '));
556
+ return res.end(JSON.stringify({ error: `Method Not Allowed: use ${allowedMethods.join(', ')}` }));
557
+ }
558
+
491
559
  res.statusCode = 404;
492
560
  res.end(JSON.stringify({ error: 'Endpoint not found' }));
493
561
  },
package/lib/tools.js CHANGED
@@ -42,23 +42,30 @@ export function formatGoalValue(snap) {
42
42
  * @param {any} options.engine
43
43
  * @param {Function} [options.resumeActiveAgent]
44
44
  */
45
- export function registerTools(ctx, { engine, resumeActiveAgent }) {
45
+ export function registerTools(ctx, { engine, resumeActiveAgent, logger = console }) {
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
- console.warn(`[dsh-goal] Tool ${definition.name} registration skipped:`, err.message);
68
+ logger.warn(`[dsh-goal] Tool ${definition.name} registration skipped:`, err.message);
62
69
  }
63
70
  };
64
71
 
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.12",
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",