@goodandready/dsh-goal 0.2.5 → 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.
@@ -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
+ }
@@ -15,15 +15,37 @@ export function buildStatePromptInjection(snapshot, pendingNudge) {
15
15
  }
16
16
  }
17
17
 
18
+ let budgetWarningText = '';
19
+ if (snapshot.budgetWarningTriggered) {
20
+ const total = snapshot.tokensUsage?.totalTokens || 0;
21
+ const max = snapshot.maxTokenBudget || 0;
22
+ const pct = max > 0 ? Math.round((total / max) * 100) : 0;
23
+ if (lang === 'zh') {
24
+ budgetWarningText = `\n\n⚠️ TOKEN 预算预警 (已消耗 ${pct}%: ${total.toLocaleString('en-US')}/${max.toLocaleString('en-US')}):\nToken 预算即将耗尽!严禁开展额外探索,必须专注于立即完成当前里程碑并调用 goal_finish 提交成果!\n`;
25
+ } else {
26
+ budgetWarningText = `\n\n⚠️ TOKEN BUDGET ALERT (${pct}% consumed: ${total.toLocaleString('en-US')}/${max.toLocaleString('en-US')}):\nToken budget is nearing exhaustion! Avoid exploratory turns and focus strictly on completing active milestones and calling goal_finish immediately!\n`;
27
+ }
28
+ }
29
+
30
+ const formatMilestone = (m, i) => {
31
+ let line = ` ${i + 1}. [${m.status.toUpperCase()}] ${m.title}${m.notes ? ` (${m.notes})` : ''}`;
32
+ if (Array.isArray(m.checklist) && m.checklist.length > 0) {
33
+ const checkLines = m.checklist
34
+ .map((item) => ` [${item.done ? 'x' : ' '}] ${item.text}`)
35
+ .join('\n');
36
+ line += `\n${checkLines}`;
37
+ }
38
+ return line;
39
+ };
40
+
18
41
  if (lang === 'zh') {
19
42
  const milestonesText = hasMilestones
20
- ? snapshot.milestones
21
- .map((m, i) => ` ${i + 1}. [${m.status.toUpperCase()}] ${m.title}${m.notes ? ` (${m.notes})` : ''}`)
22
- .join('\n')
43
+ ? snapshot.milestones.map(formatMilestone).join('\n')
23
44
  : ' (工作计划尚未建立 — 请立即调用 goal_set_milestones 设定初始里程碑!)';
24
45
 
25
46
  return (
26
47
  nudgeText +
48
+ budgetWarningText +
27
49
  `\n\n[DSH GOAL MODE ACTIVE]\n` +
28
50
  `目标: "${snapshot.title}"\n` +
29
51
  `运行时间: ${snapshot.formattedElapsed}${etaText} | 迭代轮次: ${snapshot.iterationsCount}/${snapshot.maxIterations}\n` +
@@ -31,19 +53,18 @@ export function buildStatePromptInjection(snapshot, pendingNudge) {
31
53
  `${milestonesText}\n\n` +
32
54
  `Goal Mode 执行契约 (必须严格遵循):\n` +
33
55
  `1. ${hasMilestones ? '按部就班推进当前进行中的里程碑。' : '第一步核心指令: 立即调用 goal_set_milestones 制定 3-7 个具体里程碑。在完成此工具调用前严禁执行其他操作!'}\n` +
34
- `2. 推进里程碑时,必须通过 goal_update_progress 工具更新状态(开始前标为 in_progress,完成后标为 completed 并附简要说明)。\n` +
56
+ `2. 推进里程碑时,必须通过 goal_update_progress 工具更新状态(开始前标为 in_progress,完成后标为 completed 并附简要说明)。如有细分子任务,可通过 checklist 字段同步进度。\n` +
35
57
  `3. 当所有里程碑全部完成后,调用 goal_finish 工具提交详细成果总结。`
36
58
  );
37
59
  }
38
60
 
39
61
  const milestonesText = hasMilestones
40
- ? snapshot.milestones
41
- .map((m, i) => ` ${i + 1}. [${m.status.toUpperCase()}] ${m.title}${m.notes ? ` (${m.notes})` : ''}`)
42
- .join('\n')
62
+ ? snapshot.milestones.map(formatMilestone).join('\n')
43
63
  : ' (Work plan is not yet established — call goal_set_milestones immediately with initial steps!)';
44
64
 
45
65
  return (
46
66
  nudgeText +
67
+ budgetWarningText +
47
68
  `\n\n[DSH GOAL MODE ACTIVE]\n` +
48
69
  `Goal: "${snapshot.title}"\n` +
49
70
  `Elapsed Time: ${snapshot.formattedElapsed}${etaText} | Iteration: ${snapshot.iterationsCount}/${snapshot.maxIterations}\n` +
@@ -51,7 +72,7 @@ export function buildStatePromptInjection(snapshot, pendingNudge) {
51
72
  `${milestonesText}\n\n` +
52
73
  `Goal Mode Instructions (MANDATORY TO FOLLOW):\n` +
53
74
  `1. ${hasMilestones ? 'Execute the current active milestone from the work plan.' : 'YOUR FIRST STEP: Immediately call tool goal_set_milestones with the list of milestones (3-7 concrete steps). You must not execute work or finish turn without calling goal_set_milestones!'}\n` +
54
- `2. As each milestone progresses, update its status via tool goal_update_progress (status: "in_progress" before starting, status: "completed" upon completion with brief notes).\n` +
75
+ `2. As each milestone progresses, update its status via tool goal_update_progress (status: "in_progress" before starting, status: "completed" upon completion with brief notes). If sub-tasks exist, pass checklist array to track items.\n` +
55
76
  `3. When all milestones are completed, call tool goal_finish with a detailed summary of achieved results.`
56
77
  );
57
78
  }
@@ -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,
@@ -112,3 +112,109 @@ export function exportReportGitHubPR(state) {
112
112
  md += `*Automated by [@goodandready/dsh-goal](https://github.com/GooDAnDReaDY/dsh-goal)*\n`;
113
113
  return md;
114
114
  }
115
+
116
+ import fs from 'node:fs';
117
+ import path from 'node:path';
118
+
119
+ /**
120
+ * Safely create a git milestone checkpoint
121
+ * @param {object} milestone
122
+ * @param {string} sessionId
123
+ * @param {string} [cwd]
124
+ * @returns {string|null} commit hash or tag name
125
+ */
126
+ export function createMilestoneCheckpoint(milestone, sessionId = 'default', cwd) {
127
+ if (!milestone || !milestone.id) return null;
128
+ try {
129
+ const status = execFileSync('git', ['status', '--porcelain'], {
130
+ encoding: 'utf8',
131
+ stdio: ['ignore', 'pipe', 'ignore'],
132
+ timeout: 2000,
133
+ cwd: cwd || undefined,
134
+ }).trim();
135
+
136
+ // If there are changes, auto-commit checkpoint
137
+ if (status) {
138
+ execFileSync('git', ['add', '-A'], {
139
+ stdio: ['ignore', 'ignore', 'ignore'],
140
+ timeout: 3000,
141
+ cwd: cwd || undefined,
142
+ });
143
+ const cleanTitle = (milestone.title || '').replace(/[\"\`\$]/g, '');
144
+ const msg = `checkpoint(goal): [${milestone.id}] ${cleanTitle}`;
145
+ execFileSync('git', ['commit', '-m', msg, '--no-verify'], {
146
+ stdio: ['ignore', 'ignore', 'ignore'],
147
+ timeout: 5000,
148
+ cwd: cwd || undefined,
149
+ });
150
+ }
151
+
152
+ const hash = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
153
+ encoding: 'utf8',
154
+ stdio: ['ignore', 'pipe', 'ignore'],
155
+ timeout: 1000,
156
+ cwd: cwd || undefined,
157
+ }).trim();
158
+
159
+ return hash || null;
160
+ } catch (err) {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Rollback working tree to a specific commit checkpoint
167
+ * @param {string} commitHash
168
+ * @param {string} [cwd]
169
+ * @returns {boolean}
170
+ */
171
+ export function rollbackToCheckpoint(commitHash, cwd) {
172
+ if (!commitHash || typeof commitHash !== 'string') return false;
173
+ try {
174
+ const cleanHash = commitHash.trim().replace(/[^a-zA-Z0-9_-]/g, '');
175
+ if (!cleanHash) return false;
176
+ execFileSync('git', ['checkout', cleanHash, '--', '.'], {
177
+ stdio: ['ignore', 'ignore', 'ignore'],
178
+ timeout: 5000,
179
+ cwd: cwd || undefined,
180
+ });
181
+ return true;
182
+ } catch (err) {
183
+ return false;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Save full goal execution artifact markdown file into .dsh/goals/
189
+ * @param {object} state
190
+ * @param {string} [cwd]
191
+ * @returns {{ relativePath: string, fullPath: string, content: string }|null}
192
+ */
193
+ export function saveGoalArtifact(state, cwd) {
194
+ if (!state || !state.title) return null;
195
+ try {
196
+ const root = cwd || process.cwd();
197
+ const targetDir = path.join(root, '.dsh', 'goals');
198
+ if (!fs.existsSync(targetDir)) {
199
+ fs.mkdirSync(targetDir, { recursive: true });
200
+ }
201
+
202
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
203
+ const safeTitle = (state.title || 'goal')
204
+ .toLowerCase()
205
+ .replace(/[^a-z0-9]+/g, '-')
206
+ .replace(/^-+|-+$/g, '')
207
+ .slice(0, 40) || 'run';
208
+
209
+ const filename = `${timestamp}-${safeTitle}.md`;
210
+ const fullPath = path.join(targetDir, filename);
211
+ const relativePath = path.join('.dsh', 'goals', filename).replace(/\\/g, '/');
212
+
213
+ const content = exportReportMarkdown(state);
214
+ fs.writeFileSync(fullPath, content, 'utf8');
215
+
216
+ return { relativePath, fullPath, content };
217
+ } catch (err) {
218
+ return null;
219
+ }
220
+ }
@@ -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
+ }