@goodandready/dsh-goal 0.2.6 → 0.2.7

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
@@ -420,7 +420,7 @@ window.__ModuleLoader__.load({
420
420
  border: 1px solid var(--dsw-alias-border-subtle);
421
421
  border-radius: 9999px;
422
422
  padding: 4px 12px;
423
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
423
+ box-shadow: var(--dsw-alias-shadow-md);
424
424
  backdrop-filter: blur(8px);
425
425
  font-size: 13px;
426
426
  max-width: 100%;
@@ -453,7 +453,7 @@ window.__ModuleLoader__.load({
453
453
  margin: 0;
454
454
  }
455
455
  .dsh-goal-budget-warn {
456
- color: #f59e0b;
456
+ color: var(--dsw-alias-status-warning);
457
457
  font-weight: 600;
458
458
  display: inline-flex;
459
459
  align-items: center;
@@ -2580,7 +2580,9 @@ window.__ModuleLoader__.load({
2580
2580
  const next = !prev;
2581
2581
  try {
2582
2582
  localStorage.setItem('dsh_goal_dock_collapsed', String(next));
2583
- } catch (e) {}
2583
+ } catch (e) {
2584
+ // Handled defensively: localStorage might be unavailable or quota exceeded
2585
+ }
2584
2586
  return next;
2585
2587
  });
2586
2588
  };
@@ -2637,7 +2639,7 @@ window.__ModuleLoader__.load({
2637
2639
 
2638
2640
  const active = resolveLocale();
2639
2641
 
2640
- return LOCALES[active]?.[key] || LOCALES.en?.[key] || LOCALES.ru?.[key] || key;
2642
+ return LOCALES[active]?.[key] || LOCALES.en?.[key] || key;
2641
2643
 
2642
2644
  };
2643
2645
 
@@ -3247,7 +3249,7 @@ window.__ModuleLoader__.load({
3247
3249
  'button',
3248
3250
  {
3249
3251
  className: 'dsh-goal-btn',
3250
- style: { background: '#f59e0b', color: '#000', fontWeight: 600, fontSize: 12, padding: '2px 8px' },
3252
+ style: { background: 'var(--dsw-alias-status-warning)', color: 'var(--dsw-alias-surface-raised)', fontWeight: 600, fontSize: 12, padding: '2px 8px' },
3251
3253
  title: t('extendBudgetBtn') || '+50k Tokens & Resume',
3252
3254
  onClick: () => handleAction('extend_budget', { addTokens: 50000 }),
3253
3255
  },
@@ -4686,146 +4688,158 @@ window.__ModuleLoader__.load({
4686
4688
  module.exports.inject = ['slots', 'locale', 'settingsScope'];
4687
4689
 
4688
4690
  module.exports.apply = function apply(ctx) {
4691
+ if (typeof ctx.effect === 'function') {
4692
+ ctx.effect(() => {
4693
+ try {
4694
+ ctx.locale?.register?.(NS, LOCALES);
4695
+ } catch (err) { /* handled defensively */ }
4696
+
4697
+ const settingsScope =
4698
+ ctx.settingsScope && typeof ctx.settingsScope.bind === 'function'
4699
+ ? ctx.settingsScope.bind({ namespace: NS })
4700
+ : null;
4701
+
4702
+ const disposers = [];
4703
+ const registerSlotSafe = (name, entry, comp) => {
4704
+ try {
4705
+ if (typeof ctx.slots?.inject === 'function') {
4706
+ const unreg = ctx.slots.inject(name, () => {
4707
+ try {
4708
+ return ctx.slots.register(entry, comp);
4709
+ } catch (err) { /* handled defensively */ }
4710
+ });
4711
+ if (typeof unreg === 'function') disposers.push(unreg);
4712
+ } else if (typeof ctx.slots?.register === 'function') {
4713
+ const unreg = ctx.slots.register(entry, comp);
4714
+ if (typeof unreg === 'function') disposers.push(unreg);
4715
+ }
4716
+ } catch (err) { /* handled defensively */ }
4717
+ };
4718
+
4719
+ registerSlotSafe(
4720
+ 'conversation.input.dock',
4721
+ {
4722
+ name: 'conversation.input.dock',
4723
+ id: '@goodandready/dsh-goal',
4724
+ order: 10,
4725
+ locale: NS,
4726
+ inject: (slotProps) => ({ ctx, ...slotProps }),
4727
+ },
4728
+ GoalTopBanner,
4729
+ );
4730
+
4731
+ registerSlotSafe(
4732
+ 'plugins.item',
4733
+ {
4734
+ name: 'plugins.item',
4735
+ id: ROW_ID,
4736
+ order: 60,
4737
+ label: () => 'Goal',
4738
+ locale: NS,
4739
+ inject: () => ({ ctx, scope: settingsScope }),
4740
+ },
4741
+ GoalSettingsCard,
4742
+ );
4743
+
4744
+ registerSlotSafe(
4745
+ 'plugins.row.config',
4746
+ {
4747
+ name: 'plugins.row.config',
4748
+ key: ROW_CONFIG_KEY,
4749
+ locale: NS,
4750
+ inject: () => ({ ctx, scope: settingsScope }),
4751
+ },
4752
+ GoalSettingsCard,
4753
+ );
4754
+
4755
+ registerSlotSafe(
4756
+ 'settings.plugin.item',
4757
+ {
4758
+ name: 'settings.plugin.item',
4759
+ key: NS,
4760
+ locale: NS,
4761
+ inject: () => ({ ctx, scope: settingsScope }),
4762
+ },
4763
+ GoalSettingsCard,
4764
+ );
4689
4765
 
4690
- // Register localization
4691
-
4692
- try {
4693
-
4694
- ctx.locale?.register?.(NS, LOCALES);
4695
-
4696
- } catch (err) { /* handled defensively */ }
4697
-
4698
- // Plugin settings scope
4699
-
4700
- const settingsScope =
4701
-
4702
- ctx.settingsScope && typeof ctx.settingsScope.bind === 'function'
4703
-
4704
- ? ctx.settingsScope.bind({ namespace: NS })
4705
-
4706
- : null;
4707
-
4708
- const registerSlotSafe = (name, entry, comp) => {
4709
-
4766
+ return () => {
4767
+ for (const dispose of disposers) {
4768
+ try { dispose(); } catch (e) { /* handled defensively */ }
4769
+ }
4770
+ };
4771
+ }, 'dsh-goal: client slots & locale');
4772
+ } else {
4710
4773
  try {
4711
-
4712
- if (typeof ctx.slots?.inject === 'function') {
4713
-
4714
- ctx.slots.inject(name, () => {
4715
-
4716
- try {
4717
-
4718
- return ctx.slots.register(entry, comp);
4719
-
4720
- } catch (err) { /* handled defensively */ }
4721
-
4722
- });
4723
-
4724
- } else if (typeof ctx.slots?.register === 'function') {
4725
-
4726
- ctx.slots.register(entry, comp);
4727
-
4728
- }
4729
-
4774
+ ctx.locale?.register?.(NS, LOCALES);
4730
4775
  } catch (err) { /* handled defensively */ }
4731
4776
 
4732
- };
4733
-
4734
- // 1. Register composer dock widget
4735
-
4736
- registerSlotSafe(
4737
-
4738
- 'conversation.input.dock',
4739
-
4740
- {
4741
-
4742
- name: 'conversation.input.dock',
4743
-
4744
- id: '@goodandready/dsh-goal',
4745
-
4746
- order: 10,
4747
-
4748
- locale: NS,
4749
-
4750
- inject: (slotProps) => ({ ctx, ...slotProps }),
4751
-
4752
- },
4753
-
4754
- GoalTopBanner,
4755
-
4756
- );
4757
-
4758
- // 2. Register settings card.
4759
- // List seat (plugins.item): the seat the Plugins page renders as the plugin's own
4760
- // page with its configuration. The label is a static string on purpose — it is
4761
- // resolved while the page renders, and a locale lookup there would take the whole
4762
- // client batch down with it.
4763
- registerSlotSafe(
4764
-
4765
- 'plugins.item',
4766
-
4767
- {
4777
+ const settingsScope =
4778
+ ctx.settingsScope && typeof ctx.settingsScope.bind === 'function'
4779
+ ? ctx.settingsScope.bind({ namespace: NS })
4780
+ : null;
4768
4781
 
4769
- name: 'plugins.item',
4770
-
4771
- id: ROW_ID,
4772
-
4773
- order: 60,
4774
-
4775
- label: () => 'Goal',
4776
-
4777
- locale: NS,
4778
-
4779
- inject: () => ({ ctx, scope: settingsScope }),
4780
-
4781
- },
4782
-
4783
- GoalSettingsCard,
4784
-
4785
- );
4786
-
4787
- // Row seat and the legacy seat kept as fallbacks.
4788
-
4789
- registerSlotSafe(
4790
-
4791
- 'plugins.row.config',
4792
-
4793
- {
4794
-
4795
- name: 'plugins.row.config',
4796
-
4797
- key: ROW_CONFIG_KEY,
4798
-
4799
- locale: NS,
4800
-
4801
- inject: () => ({ ctx, scope: settingsScope }),
4802
-
4803
- },
4804
-
4805
- GoalSettingsCard,
4806
-
4807
- );
4808
-
4809
- registerSlotSafe(
4810
-
4811
- 'settings.plugin.item',
4812
-
4813
- {
4814
-
4815
- name: 'settings.plugin.item',
4816
-
4817
- key: NS,
4818
-
4819
- locale: NS,
4820
-
4821
- inject: () => ({ ctx, scope: settingsScope }),
4782
+ const registerSlotSafe = (name, entry, comp) => {
4783
+ try {
4784
+ if (typeof ctx.slots?.inject === 'function') {
4785
+ ctx.slots.inject(name, () => {
4786
+ try {
4787
+ return ctx.slots.register(entry, comp);
4788
+ } catch (err) { /* handled defensively */ }
4789
+ });
4790
+ } else if (typeof ctx.slots?.register === 'function') {
4791
+ ctx.slots.register(entry, comp);
4792
+ }
4793
+ } catch (err) { /* handled defensively */ }
4794
+ };
4822
4795
 
4823
- },
4796
+ registerSlotSafe(
4797
+ 'conversation.input.dock',
4798
+ {
4799
+ name: 'conversation.input.dock',
4800
+ id: '@goodandready/dsh-goal',
4801
+ order: 10,
4802
+ locale: NS,
4803
+ inject: (slotProps) => ({ ctx, ...slotProps }),
4804
+ },
4805
+ GoalTopBanner,
4806
+ );
4824
4807
 
4825
- GoalSettingsCard,
4808
+ registerSlotSafe(
4809
+ 'plugins.item',
4810
+ {
4811
+ name: 'plugins.item',
4812
+ id: ROW_ID,
4813
+ order: 60,
4814
+ label: () => 'Goal',
4815
+ locale: NS,
4816
+ inject: () => ({ ctx, scope: settingsScope }),
4817
+ },
4818
+ GoalSettingsCard,
4819
+ );
4826
4820
 
4827
- );
4821
+ registerSlotSafe(
4822
+ 'plugins.row.config',
4823
+ {
4824
+ name: 'plugins.row.config',
4825
+ key: ROW_CONFIG_KEY,
4826
+ locale: NS,
4827
+ inject: () => ({ ctx, scope: settingsScope }),
4828
+ },
4829
+ GoalSettingsCard,
4830
+ );
4828
4831
 
4832
+ registerSlotSafe(
4833
+ 'settings.plugin.item',
4834
+ {
4835
+ name: 'settings.plugin.item',
4836
+ key: NS,
4837
+ locale: NS,
4838
+ inject: () => ({ ctx, scope: settingsScope }),
4839
+ },
4840
+ GoalSettingsCard,
4841
+ );
4842
+ }
4829
4843
  };
4830
4844
 
4831
4845
  return module.exports;
@@ -0,0 +1,106 @@
1
+ import { MilestoneStatus } from './goal-engine-constants.js';
2
+
3
+ /**
4
+ * Match a milestone object by exact ID or normalized ID (e.g. m-1 vs m1 vs 1)
5
+ * @param {object} m Milestone object
6
+ * @param {string|number} id Milestone ID to match
7
+ * @returns {boolean}
8
+ */
9
+ export function matchMilestone(m, id) {
10
+ if (!m || id === undefined || id === null) return false;
11
+ if (m.id === String(id)) return true;
12
+ const s1 = String(m.id).toLowerCase().replace(/[^a-z0-9]/g, '');
13
+ const s2 = String(id).toLowerCase().replace(/[^a-z0-9]/g, '');
14
+ if (s1 && s1 === s2) return true;
15
+ const n1 = s1.replace(/^m+/, '');
16
+ const n2 = s2.replace(/^m+/, '');
17
+ return Boolean(n1 && n1 === n2);
18
+ }
19
+
20
+ /**
21
+ * Safely parse checklist items
22
+ * @param {Array} checklist
23
+ * @returns {Array<{ text: string, done: boolean }>}
24
+ */
25
+ export function parseChecklist(checklist) {
26
+ if (!Array.isArray(checklist)) return [];
27
+ return checklist
28
+ .map((item) => ({
29
+ text: String(item?.text || item?.title || '').trim(),
30
+ done: Boolean(item?.done || item?.completed),
31
+ }))
32
+ .filter((item) => item.text.length > 0);
33
+ }
34
+
35
+ /**
36
+ * Build and normalize milestone objects from list
37
+ * @param {Array} milestonesList
38
+ * @param {number} startingCount
39
+ * @returns {Array<object>}
40
+ */
41
+ export function parseMilestoneItems(milestonesList, startingCount = 0) {
42
+ if (!Array.isArray(milestonesList)) return [];
43
+ const result = [];
44
+ let count = startingCount;
45
+
46
+ for (const item of milestonesList) {
47
+ const itemTitle = typeof item === 'string' ? item : item?.title;
48
+ if (!itemTitle || !itemTitle.trim()) continue;
49
+
50
+ count += 1;
51
+ const mId = typeof item === 'object' && item?.id ? item.id : ('m-' + count);
52
+ const mObj = {
53
+ id: String(mId),
54
+ title: itemTitle.trim(),
55
+ status: typeof item === 'object' && item?.status ? item.status : MilestoneStatus.PENDING,
56
+ notes: typeof item === 'object' && item?.notes ? String(item.notes) : '',
57
+ };
58
+ if (typeof item === 'object' && Array.isArray(item?.checklist)) {
59
+ mObj.checklist = parseChecklist(item.checklist);
60
+ }
61
+ result.push(mObj);
62
+ }
63
+
64
+ return result;
65
+ }
66
+
67
+ /**
68
+ * Apply updates to a milestone target
69
+ * @param {object} target Milestone object
70
+ * @param {string} [status] New status
71
+ * @param {string} [notes] New notes
72
+ * @param {Array} [checklist] New checklist
73
+ * @param {Array<string>} [validStatuses] Allowed statuses
74
+ * @returns {{ prevStatus: string, updatedStatus: string }}
75
+ */
76
+ export function applyMilestoneUpdate(target, status, notes = '', checklist = null, validStatuses = []) {
77
+ if (!target) return { prevStatus: null, updatedStatus: null };
78
+ const prevStatus = target.status;
79
+
80
+ if (status && (validStatuses.length === 0 || validStatuses.includes(status))) {
81
+ target.status = status;
82
+ }
83
+ if (notes !== undefined && notes !== null && notes !== '') {
84
+ target.notes = String(notes);
85
+ }
86
+ if (Array.isArray(checklist)) {
87
+ target.checklist = parseChecklist(checklist);
88
+ }
89
+
90
+ return { prevStatus, updatedStatus: target.status };
91
+ }
92
+
93
+ /**
94
+ * Toggle a checklist item in milestone target
95
+ * @param {object} target Milestone object
96
+ * @param {number} itemIndex Index in checklist
97
+ * @param {boolean} [done] Optional explicit done boolean
98
+ * @returns {boolean}
99
+ */
100
+ export function toggleMilestoneChecklistItem(target, itemIndex, done) {
101
+ if (!target || !Array.isArray(target.checklist) || !target.checklist[itemIndex]) {
102
+ return false;
103
+ }
104
+ target.checklist[itemIndex].done = done !== undefined ? Boolean(done) : !target.checklist[itemIndex].done;
105
+ return true;
106
+ }
@@ -1,4 +1,4 @@
1
- import { execSync } from 'node:child_process';
1
+ import { execFileSync } from 'node:child_process';
2
2
 
3
3
  /**
4
4
  * Safely get current short git commit hash
@@ -7,7 +7,7 @@ import { execSync } from 'node:child_process';
7
7
  */
8
8
  export function getGitCurrentCommit(cwd) {
9
9
  try {
10
- return execSync('git rev-parse --short HEAD', {
10
+ return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
11
11
  encoding: 'utf8',
12
12
  stdio: ['ignore', 'pipe', 'ignore'],
13
13
  timeout: 1000,
@@ -126,7 +126,7 @@ import path from 'node:path';
126
126
  export function createMilestoneCheckpoint(milestone, sessionId = 'default', cwd) {
127
127
  if (!milestone || !milestone.id) return null;
128
128
  try {
129
- const status = execSync('git status --porcelain', {
129
+ const status = execFileSync('git', ['status', '--porcelain'], {
130
130
  encoding: 'utf8',
131
131
  stdio: ['ignore', 'pipe', 'ignore'],
132
132
  timeout: 2000,
@@ -135,21 +135,21 @@ export function createMilestoneCheckpoint(milestone, sessionId = 'default', cwd)
135
135
 
136
136
  // If there are changes, auto-commit checkpoint
137
137
  if (status) {
138
- execSync('git add -A', {
138
+ execFileSync('git', ['add', '-A'], {
139
139
  stdio: ['ignore', 'ignore', 'ignore'],
140
140
  timeout: 3000,
141
141
  cwd: cwd || undefined,
142
142
  });
143
143
  const cleanTitle = (milestone.title || '').replace(/[\"\`\$]/g, '');
144
144
  const msg = `checkpoint(goal): [${milestone.id}] ${cleanTitle}`;
145
- execSync(`git commit -m "${msg}" --no-verify`, {
145
+ execFileSync('git', ['commit', '-m', msg, '--no-verify'], {
146
146
  stdio: ['ignore', 'ignore', 'ignore'],
147
147
  timeout: 5000,
148
148
  cwd: cwd || undefined,
149
149
  });
150
150
  }
151
151
 
152
- const hash = execSync('git rev-parse --short HEAD', {
152
+ const hash = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
153
153
  encoding: 'utf8',
154
154
  stdio: ['ignore', 'pipe', 'ignore'],
155
155
  timeout: 1000,
@@ -173,7 +173,7 @@ export function rollbackToCheckpoint(commitHash, cwd) {
173
173
  try {
174
174
  const cleanHash = commitHash.trim().replace(/[^a-zA-Z0-9_-]/g, '');
175
175
  if (!cleanHash) return false;
176
- execSync(`git checkout ${cleanHash} -- .`, {
176
+ execFileSync('git', ['checkout', cleanHash, '--', '.'], {
177
177
  stdio: ['ignore', 'ignore', 'ignore'],
178
178
  timeout: 5000,
179
179
  cwd: cwd || undefined,
@@ -98,3 +98,119 @@ export function sessionIdOf(invocationOrReq, fallback = 'default') {
98
98
  }
99
99
  return fallback;
100
100
  }
101
+
102
+ /**
103
+ * Calculate elapsed seconds from goal timing properties
104
+ * @param {object} goal
105
+ * @returns {number}
106
+ */
107
+ export function calculateElapsedSeconds(goal) {
108
+ if (!goal) return 0;
109
+ const { startedAt, pausedAt, totalPausedDurationMs, completedAt } = goal;
110
+ const endTime = completedAt || (pausedAt || Date.now());
111
+ const elapsedMs = Math.max(0, endTime - startedAt - (totalPausedDurationMs || 0));
112
+ return Math.floor(elapsedMs / 1000);
113
+ }
114
+
115
+ /**
116
+ * Calculate estimated remaining seconds based on completed milestones
117
+ * @param {object} goal
118
+ * @param {number} elapsed
119
+ * @returns {number|null}
120
+ */
121
+ export function calculateRemainingSeconds(goal, elapsed) {
122
+ if (!goal || goal.state !== GoalState.RUNNING) return null;
123
+ const total = goal.milestones?.length || 0;
124
+ if (total === 0) return null;
125
+ const completedCount = goal.milestones.filter((m) => m.status === MilestoneStatus.COMPLETED).length;
126
+ if (completedCount === 0 || completedCount >= total) return null;
127
+ if (elapsed <= 0) return null;
128
+ const avgSecPerMilestone = elapsed / completedCount;
129
+ const remainingCount = total - completedCount;
130
+ return Math.max(1, Math.round(avgSecPerMilestone * remainingCount));
131
+ }
132
+
133
+ /**
134
+ * Build snapshot object for a given goal or idle state
135
+ * @param {object|null} goal
136
+ * @param {string} sid
137
+ * @param {object} engine
138
+ * @returns {object}
139
+ */
140
+ export function buildSnapshot(goal, sid, engine) {
141
+ if (!goal) {
142
+ return {
143
+ sessionId: sid,
144
+ hasActiveGoal: false,
145
+ state: GoalState.IDLE,
146
+ title: '',
147
+ startedAt: null,
148
+ pausedAt: null,
149
+ totalPausedDurationMs: 0,
150
+ completedAt: null,
151
+ elapsedSeconds: 0,
152
+ formattedElapsed: '0s',
153
+ estimatedRemainingSeconds: null,
154
+ formattedETA: null,
155
+ lang: 'en',
156
+ tokensUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
157
+ milestones: [],
158
+ progressPercent: 0,
159
+ iterationsCount: 0,
160
+ maxIterations: engine.defaultMaxIterations,
161
+ autoDrive: engine.autoDrive,
162
+ enableSound: engine.enableSound,
163
+ showQuickLaunchButton: engine.showQuickLaunchButton,
164
+ gitStartCommit: null,
165
+ pendingNudge: null,
166
+ toolFailureCount: 0,
167
+ consecutiveToolFailureLimit: engine.consecutiveToolFailureLimit,
168
+ maxTokenBudget: engine.maxTokenBudget,
169
+ budgetWarningThreshold: engine.budgetWarningThreshold,
170
+ budgetWarningTriggered: false,
171
+ autoCheckpointOnMilestone: engine.autoCheckpointOnMilestone,
172
+ };
173
+ }
174
+
175
+ const elapsed = calculateElapsedSeconds(goal);
176
+ const milestones = goal.milestones || [];
177
+ const completedCount = milestones.filter((m) => m.status === MilestoneStatus.COMPLETED).length;
178
+ const progressPercent = milestones.length > 0 ? Math.round((completedCount / milestones.length) * 100) : 0;
179
+ const estSec = calculateRemainingSeconds(goal, elapsed);
180
+
181
+ return {
182
+ sessionId: sid,
183
+ hasActiveGoal: true,
184
+ id: goal.id,
185
+ state: goal.state,
186
+ title: goal.title,
187
+ description: goal.description,
188
+ lang: goal.lang || detectLanguage(goal.title),
189
+ startedAt: goal.startedAt,
190
+ pausedAt: goal.pausedAt,
191
+ totalPausedDurationMs: goal.totalPausedDurationMs,
192
+ completedAt: goal.completedAt,
193
+ elapsedSeconds: elapsed,
194
+ formattedElapsed: formatElapsed(elapsed),
195
+ estimatedRemainingSeconds: estSec,
196
+ formattedETA: formatETA(estSec),
197
+ tokensUsage: goal.tokensUsage || { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
198
+ iterationsCount: goal.iterationsCount,
199
+ maxIterations: goal.maxIterations,
200
+ milestones,
201
+ progressPercent,
202
+ logs: goal.logs,
203
+ resultSummary: goal.resultSummary,
204
+ autoDrive: engine.autoDrive,
205
+ enableSound: engine.enableSound,
206
+ showQuickLaunchButton: engine.showQuickLaunchButton,
207
+ gitStartCommit: goal.gitStartCommit || null,
208
+ pendingNudge: goal.pendingNudge || null,
209
+ toolFailureCount: engine.getToolFailureCount(sid),
210
+ consecutiveToolFailureLimit: engine.consecutiveToolFailureLimit,
211
+ maxTokenBudget: goal.maxTokenBudget ?? engine.maxTokenBudget,
212
+ budgetWarningThreshold: engine.budgetWarningThreshold,
213
+ budgetWarningTriggered: Boolean(goal.budgetWarningTriggered),
214
+ autoCheckpointOnMilestone: engine.autoCheckpointOnMilestone,
215
+ };
216
+ }
@@ -1,20 +1,10 @@
1
- import { GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf } from './goal-engine-constants.js';
1
+ import { GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, calculateElapsedSeconds, calculateRemainingSeconds, buildSnapshot } from './goal-engine-constants.js';
2
2
  import { getGitCurrentCommit, exportReportMarkdown, exportReportGitHubPR, createMilestoneCheckpoint, rollbackToCheckpoint, saveGoalArtifact } from './engine-reports.js';
3
3
  import { EngineStore } from './engine-store.js';
4
4
  import { buildStatePromptInjection } from './engine-prompt.js';
5
- function matchMilestone(m, id) {
6
- if (!m || id === undefined || id === null) return false;
7
- if (m.id === String(id)) return true;
8
- const s1 = String(m.id).toLowerCase().replace(/[^a-z0-9]/g, '');
9
- const s2 = String(id).toLowerCase().replace(/[^a-z0-9]/g, '');
10
- if (s1 && s1 === s2) return true;
11
- const n1 = s1.replace(/^m+/, '');
12
- const n2 = s2.replace(/^m+/, '');
13
- return Boolean(n1 && n1 === n2);
14
- }
15
-
5
+ import { matchMilestone, parseMilestoneItems, applyMilestoneUpdate, toggleMilestoneChecklistItem } from './engine-milestones.js';
16
6
 
17
- export { GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf };
7
+ export { GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, matchMilestone };
18
8
  export { getGitCurrentCommit, exportReportMarkdown, exportReportGitHubPR, createMilestoneCheckpoint, rollbackToCheckpoint, saveGoalArtifact };
19
9
 
20
10
  /**
@@ -198,6 +188,7 @@ export class GoalEngine {
198
188
  const oldest = inactive.shift();
199
189
  this.goals.delete(oldest.sid);
200
190
  this.stallCounters.delete(oldest.sid);
191
+ this.toolFailureCounters.delete(oldest.sid);
201
192
  }
202
193
  }
203
194
 
@@ -346,6 +337,7 @@ export class GoalEngine {
346
337
  const sid = sessionId || 'default';
347
338
  this.goals.delete(sid);
348
339
  this.stallCounters.delete(sid);
340
+ this.toolFailureCounters.delete(sid);
349
341
  this.emit(sid, true);
350
342
  return this.getSnapshot(sid);
351
343
  }
@@ -376,6 +368,7 @@ export class GoalEngine {
376
368
  }
377
369
 
378
370
  this.stallCounters.delete(sid);
371
+ this.toolFailureCounters.delete(sid);
379
372
  this.emit(sid, true);
380
373
  return this.getSnapshot(sid);
381
374
  }
@@ -385,18 +378,8 @@ export class GoalEngine {
385
378
  const goal = this.goals.get(sid);
386
379
  if (!goal || !Array.isArray(milestonesList)) return;
387
380
 
388
- for (const item of milestonesList) {
389
- const itemTitle = typeof item === 'string' ? item : item.title;
390
- if (!itemTitle || !itemTitle.trim()) continue;
391
-
392
- const mId = typeof item === 'object' && item.id ? item.id : `m-${goal.milestones.length + 1}`;
393
- goal.milestones.push({
394
- id: String(mId),
395
- title: itemTitle.trim(),
396
- status: typeof item === 'object' && item.status ? item.status : MilestoneStatus.PENDING,
397
- notes: typeof item === 'object' && item.notes ? item.notes : '',
398
- });
399
- }
381
+ const newItems = parseMilestoneItems(milestonesList, goal.milestones.length);
382
+ goal.milestones.push(...newItems);
400
383
 
401
384
  this.recordProgress(sid);
402
385
  if (shouldEmit) this.emit(sid);
@@ -410,20 +393,13 @@ export class GoalEngine {
410
393
  const target = goal.milestones.find((m) => matchMilestone(m, id));
411
394
  if (!target) return false;
412
395
 
413
- const prevStatus = target.status;
414
- if (status && Object.values(MilestoneStatus).includes(status)) {
415
- target.status = status;
416
- }
417
- if (notes) {
418
- target.notes = String(notes);
419
- }
420
-
421
- if (Array.isArray(checklist)) {
422
- target.checklist = checklist.map((item) => ({
423
- text: String(item.text || item.title || '').trim(),
424
- done: Boolean(item.done || item.completed),
425
- })).filter((item) => item.text.length > 0);
426
- }
396
+ const { prevStatus } = applyMilestoneUpdate(
397
+ target,
398
+ status,
399
+ notes,
400
+ checklist,
401
+ Object.values(MilestoneStatus)
402
+ );
427
403
 
428
404
  // Auto Git Checkpoint on milestone completion
429
405
  if (target.status === MilestoneStatus.COMPLETED && prevStatus !== MilestoneStatus.COMPLETED && this.autoCheckpointOnMilestone) {
@@ -459,11 +435,9 @@ export class GoalEngine {
459
435
  if (!goal) return false;
460
436
 
461
437
  const target = goal.milestones.find((m) => matchMilestone(m, milestoneId));
462
- if (!target || !Array.isArray(target.checklist) || !target.checklist[itemIndex]) {
463
- return false;
464
- }
438
+ const ok = toggleMilestoneChecklistItem(target, itemIndex, done);
439
+ if (!ok) return false;
465
440
 
466
- target.checklist[itemIndex].done = done !== undefined ? Boolean(done) : !target.checklist[itemIndex].done;
467
441
  this.recordProgress(sid);
468
442
  this.emit(sid);
469
443
  return true;
@@ -525,13 +499,7 @@ export class GoalEngine {
525
499
  }
526
500
 
527
501
  getElapsedSeconds(sessionId = 'default') {
528
- const sid = sessionId || 'default';
529
- const goal = this.goals.get(sid);
530
- if (!goal) return 0;
531
- const { startedAt, pausedAt, totalPausedDurationMs, completedAt } = goal;
532
- const endTime = completedAt || (pausedAt || Date.now());
533
- const elapsedMs = Math.max(0, endTime - startedAt - totalPausedDurationMs);
534
- return Math.floor(elapsedMs / 1000);
502
+ return calculateElapsedSeconds(this.goals.get(sessionId || 'default'));
535
503
  }
536
504
 
537
505
  addTokenUsage(usage, sessionId = 'default') {
@@ -588,100 +556,12 @@ export class GoalEngine {
588
556
  getEstimatedRemainingSeconds(sessionId = 'default') {
589
557
  const sid = sessionId || 'default';
590
558
  const goal = this.goals.get(sid);
591
- if (!goal || goal.state !== GoalState.RUNNING) return null;
592
-
593
- const total = goal.milestones.length;
594
- if (total === 0) return null;
595
-
596
- const completedCount = goal.milestones.filter((m) => m.status === MilestoneStatus.COMPLETED).length;
597
- if (completedCount === 0 || completedCount >= total) return null;
598
-
599
- const elapsed = this.getElapsedSeconds(sid);
600
- if (elapsed <= 0) return null;
601
-
602
- const avgSecPerMilestone = elapsed / completedCount;
603
- const remainingCount = total - completedCount;
604
- return Math.max(1, Math.round(avgSecPerMilestone * remainingCount));
559
+ return calculateRemainingSeconds(goal, this.getElapsedSeconds(sid));
605
560
  }
606
561
 
607
562
  getSnapshot(sessionId = 'default') {
608
563
  const sid = sessionId || 'default';
609
- const goal = this.goals.get(sid);
610
- if (!goal) {
611
- return {
612
- sessionId: sid,
613
- hasActiveGoal: false,
614
- state: GoalState.IDLE,
615
- title: '',
616
- startedAt: null,
617
- pausedAt: null,
618
- totalPausedDurationMs: 0,
619
- completedAt: null,
620
- elapsedSeconds: 0,
621
- formattedElapsed: '0s',
622
- estimatedRemainingSeconds: null,
623
- formattedETA: null,
624
- lang: 'en',
625
- tokensUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
626
- milestones: [],
627
- progressPercent: 0,
628
- iterationsCount: 0,
629
- maxIterations: this.defaultMaxIterations,
630
- autoDrive: this.autoDrive,
631
- enableSound: this.enableSound,
632
- showQuickLaunchButton: this.showQuickLaunchButton,
633
- gitStartCommit: null,
634
- pendingNudge: null,
635
- toolFailureCount: 0,
636
- consecutiveToolFailureLimit: this.consecutiveToolFailureLimit,
637
- maxTokenBudget: this.maxTokenBudget,
638
- budgetWarningThreshold: this.budgetWarningThreshold,
639
- budgetWarningTriggered: false,
640
- autoCheckpointOnMilestone: this.autoCheckpointOnMilestone,
641
- };
642
- }
643
-
644
- const elapsed = this.getElapsedSeconds(sid);
645
- const milestones = goal.milestones;
646
- const completedCount = milestones.filter((m) => m.status === MilestoneStatus.COMPLETED).length;
647
- const progressPercent = milestones.length > 0 ? Math.round((completedCount / milestones.length) * 100) : 0;
648
- const estSec = this.getEstimatedRemainingSeconds(sid);
649
-
650
- return {
651
- sessionId: sid,
652
- hasActiveGoal: true,
653
- id: goal.id,
654
- state: goal.state,
655
- title: goal.title,
656
- description: goal.description,
657
- lang: goal.lang || detectLanguage(goal.title),
658
- startedAt: goal.startedAt,
659
- pausedAt: goal.pausedAt,
660
- totalPausedDurationMs: goal.totalPausedDurationMs,
661
- completedAt: goal.completedAt,
662
- elapsedSeconds: elapsed,
663
- formattedElapsed: formatElapsed(elapsed),
664
- estimatedRemainingSeconds: estSec,
665
- formattedETA: formatETA(estSec),
666
- tokensUsage: goal.tokensUsage || { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
667
- iterationsCount: goal.iterationsCount,
668
- maxIterations: goal.maxIterations,
669
- milestones,
670
- progressPercent,
671
- logs: goal.logs,
672
- resultSummary: goal.resultSummary,
673
- autoDrive: this.autoDrive,
674
- enableSound: this.enableSound,
675
- showQuickLaunchButton: this.showQuickLaunchButton,
676
- gitStartCommit: goal.gitStartCommit || null,
677
- pendingNudge: goal.pendingNudge || null,
678
- toolFailureCount: this.getToolFailureCount(sid),
679
- consecutiveToolFailureLimit: this.consecutiveToolFailureLimit,
680
- maxTokenBudget: goal.maxTokenBudget ?? this.maxTokenBudget,
681
- budgetWarningThreshold: this.budgetWarningThreshold,
682
- budgetWarningTriggered: Boolean(goal.budgetWarningTriggered),
683
- autoCheckpointOnMilestone: this.autoCheckpointOnMilestone,
684
- };
564
+ return buildSnapshot(this.goals.get(sid), sid, this);
685
565
  }
686
566
 
687
567
  getGoalSnapshot(sessionId = 'default') {
package/lib/routes.js CHANGED
@@ -1,6 +1,24 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
1
3
  import { MilestoneStatus, detectLanguage, sessionIdOf } from './goal-engine-constants.js';
2
4
  import { saveGoalArtifact, rollbackToCheckpoint } from './engine-reports.js';
3
5
 
6
+ function getSafeCwd(requestedCwd) {
7
+ if (!requestedCwd || typeof requestedCwd !== 'string') {
8
+ return process.cwd();
9
+ }
10
+ try {
11
+ const resolved = path.resolve(requestedCwd.trim());
12
+ const st = fs.statSync(resolved);
13
+ if (!st.isDirectory()) {
14
+ return null;
15
+ }
16
+ return resolved;
17
+ } catch (err) {
18
+ return null;
19
+ }
20
+ }
21
+
4
22
  /**
5
23
  * Register webServer HTTP routes and SSE stream for DSH Goal
6
24
  * @param {any} ctx Cordis context
@@ -291,7 +309,12 @@ export function registerRoutes(ctx, {
291
309
  res.statusCode = 400;
292
310
  return res.end(JSON.stringify({ error: 'commit hash is required for rollback' }));
293
311
  }
294
- const ok = rollbackToCheckpoint(commit, data.cwd);
312
+ const safeCwd = getSafeCwd(data.cwd);
313
+ if (data.cwd && !safeCwd) {
314
+ res.statusCode = 400;
315
+ return res.end(JSON.stringify({ error: 'Invalid or non-existent working directory' }));
316
+ }
317
+ const ok = rollbackToCheckpoint(commit, safeCwd);
295
318
  if (!ok) {
296
319
  res.statusCode = 500;
297
320
  return res.end(JSON.stringify({ error: `Failed to rollback to checkpoint ${commit}` }));
@@ -306,7 +329,12 @@ export function registerRoutes(ctx, {
306
329
  res.statusCode = 404;
307
330
  return res.end(JSON.stringify({ error: 'No active goal to save artifact for' }));
308
331
  }
309
- const artifact = saveGoalArtifact(snap, data.cwd);
332
+ const safeCwd = getSafeCwd(data.cwd);
333
+ if (data.cwd && !safeCwd) {
334
+ res.statusCode = 400;
335
+ return res.end(JSON.stringify({ error: 'Invalid or non-existent working directory' }));
336
+ }
337
+ const artifact = saveGoalArtifact(snap, safeCwd);
310
338
  if (!artifact) {
311
339
  res.statusCode = 500;
312
340
  return res.end(JSON.stringify({ error: 'Failed to write goal artifact to disk' }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-goal",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
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",