@goodandready/dsh-goal 0.2.12 → 0.2.14
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/addon-store.js +213 -0
- package/lib/engine-reports.js +112 -33
- package/lib/git-snapshot-manager.js +99 -0
- package/lib/index.js +226 -395
- package/lib/milestone-manager.js +141 -0
- package/lib/routes.js +249 -138
- package/lib/token-budget-guard.js +128 -0
- package/lib/tools.js +115 -257
- package/package.json +9 -3
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Lightweight JSON persistence for dsh-goal addon metadata:
|
|
6
|
+
* milestones, checklists, snapshots, and token budget metrics.
|
|
7
|
+
*/
|
|
8
|
+
export class AddonStore {
|
|
9
|
+
constructor(storagePath, logger = console) {
|
|
10
|
+
this.storagePath = storagePath || null;
|
|
11
|
+
this.logger = logger;
|
|
12
|
+
this.goals = new Map(); // goalId -> { milestones, snapshots, budget, issue }
|
|
13
|
+
this.saveTimer = null;
|
|
14
|
+
this.loadFromDisk();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
setStoragePath(storagePath) {
|
|
18
|
+
this.storagePath = storagePath || null;
|
|
19
|
+
this.loadFromDisk();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
getGoalData(goalId) {
|
|
23
|
+
if (!goalId) return null;
|
|
24
|
+
if (!this.goals.has(goalId)) {
|
|
25
|
+
this.goals.set(goalId, {
|
|
26
|
+
milestones: [],
|
|
27
|
+
snapshots: [],
|
|
28
|
+
budget: { maxTokenBudget: 0, warnedAt: null, blockedAt: null },
|
|
29
|
+
issue: null,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return this.goals.get(goalId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
getMilestones(goalId) {
|
|
36
|
+
const data = this.getGoalData(goalId);
|
|
37
|
+
return data ? data.milestones : [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
setMilestones(goalId, milestones) {
|
|
41
|
+
const data = this.getGoalData(goalId);
|
|
42
|
+
if (!data) return;
|
|
43
|
+
data.milestones = Array.isArray(milestones) ? milestones : [];
|
|
44
|
+
this.scheduleSave();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
getSnapshots(goalId) {
|
|
48
|
+
const data = this.getGoalData(goalId);
|
|
49
|
+
return data ? data.snapshots : [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
addSnapshot(goalId, snapshot) {
|
|
53
|
+
const data = this.getGoalData(goalId);
|
|
54
|
+
if (!data) return;
|
|
55
|
+
if (!Array.isArray(data.snapshots)) data.snapshots = [];
|
|
56
|
+
data.snapshots.push(snapshot);
|
|
57
|
+
this.scheduleSave();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
getBudget(goalId) {
|
|
61
|
+
const data = this.getGoalData(goalId);
|
|
62
|
+
return data ? data.budget : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setBudget(goalId, budget) {
|
|
66
|
+
const data = this.getGoalData(goalId);
|
|
67
|
+
if (!data) return;
|
|
68
|
+
data.budget = { ...(data.budget || {}), ...budget };
|
|
69
|
+
this.scheduleSave();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
loadFromDisk() {
|
|
73
|
+
if (!this.storagePath) return;
|
|
74
|
+
try {
|
|
75
|
+
if (fs.existsSync(this.storagePath)) {
|
|
76
|
+
const raw = fs.readFileSync(this.storagePath, 'utf8');
|
|
77
|
+
const parsed = JSON.parse(raw);
|
|
78
|
+
if (parsed && typeof parsed === 'object' && parsed.goals) {
|
|
79
|
+
for (const [gid, val] of Object.entries(parsed.goals)) {
|
|
80
|
+
this.goals.set(gid, {
|
|
81
|
+
milestones: Array.isArray(val.milestones) ? val.milestones : [],
|
|
82
|
+
snapshots: Array.isArray(val.snapshots) ? val.snapshots : [],
|
|
83
|
+
budget: val.budget || { maxTokenBudget: 0, warnedAt: null, blockedAt: null },
|
|
84
|
+
issue: val.issue || null,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
this.logger.warn?.('[AddonStore] Failed to load addon state from disk:', err?.message || err);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
scheduleSave(immediate = false) {
|
|
95
|
+
if (!this.storagePath) return;
|
|
96
|
+
if (immediate) {
|
|
97
|
+
if (this.saveTimer) {
|
|
98
|
+
clearTimeout(this.saveTimer);
|
|
99
|
+
this.saveTimer = null;
|
|
100
|
+
}
|
|
101
|
+
this.writeToDiskSync();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (!this.saveTimer) {
|
|
105
|
+
this.saveTimer = setTimeout(() => {
|
|
106
|
+
this.saveTimer = null;
|
|
107
|
+
this.writeToDiskSync();
|
|
108
|
+
}, 200);
|
|
109
|
+
if (typeof this.saveTimer.unref === 'function') {
|
|
110
|
+
this.saveTimer.unref();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
writeToDiskSync() {
|
|
116
|
+
if (!this.storagePath) return;
|
|
117
|
+
try {
|
|
118
|
+
const goalsObj = {};
|
|
119
|
+
for (const [gid, val] of this.goals.entries()) {
|
|
120
|
+
goalsObj[gid] = val;
|
|
121
|
+
}
|
|
122
|
+
const payload = {
|
|
123
|
+
version: 3,
|
|
124
|
+
goals: goalsObj,
|
|
125
|
+
updatedAt: Date.now(),
|
|
126
|
+
};
|
|
127
|
+
const dir = path.dirname(this.storagePath);
|
|
128
|
+
if (!fs.existsSync(dir)) {
|
|
129
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
130
|
+
}
|
|
131
|
+
const tmp = `${this.storagePath}.tmp.${Date.now()}`;
|
|
132
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
|
|
133
|
+
fs.renameSync(tmp, this.storagePath);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
this.logger.warn?.('[AddonStore] Failed to write addon state to disk:', err?.message || err);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Migrate legacy state from dsh-goal-state.json to addon format and core GoalService
|
|
141
|
+
*/
|
|
142
|
+
migrateLegacyState(legacyPath, coreGoals, agent) {
|
|
143
|
+
if (!legacyPath || !fs.existsSync(legacyPath)) return false;
|
|
144
|
+
try {
|
|
145
|
+
const raw = fs.readFileSync(legacyPath, 'utf8');
|
|
146
|
+
const data = JSON.parse(raw);
|
|
147
|
+
if (!data || typeof data !== 'object' || data._migratedToAddon) return false;
|
|
148
|
+
|
|
149
|
+
let migratedAny = false;
|
|
150
|
+
const candidates = [];
|
|
151
|
+
if (data.sessions && typeof data.sessions === 'object') {
|
|
152
|
+
for (const [sid, goal] of Object.entries(data.sessions)) {
|
|
153
|
+
if (goal?.title && goal?.hasActiveGoal) {
|
|
154
|
+
candidates.push({ sessionId: sid, goal });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} else if (data.title && data.hasActiveGoal) {
|
|
158
|
+
candidates.push({ sessionId: 'default', goal: data });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (const item of candidates) {
|
|
162
|
+
const legacyGoal = item.goal;
|
|
163
|
+
const goalId = legacyGoal.id || `legacy-${Date.now()}`;
|
|
164
|
+
|
|
165
|
+
// Import milestones into addon store
|
|
166
|
+
if (Array.isArray(legacyGoal.milestones) && legacyGoal.milestones.length > 0) {
|
|
167
|
+
this.setMilestones(goalId, legacyGoal.milestones.map((m, idx) => ({
|
|
168
|
+
id: m.id || `m-${idx + 1}`,
|
|
169
|
+
title: m.title || `Milestone ${idx + 1}`,
|
|
170
|
+
completed: m.status === 'completed',
|
|
171
|
+
completedAt: m.completedAt || null,
|
|
172
|
+
notes: m.notes || '',
|
|
173
|
+
dependsOn: Array.isArray(m.dependsOn) ? m.dependsOn : [],
|
|
174
|
+
checklist: Array.isArray(m.checklist) ? m.checklist : [],
|
|
175
|
+
})));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// If coreGoals is available and agent session is active, bridge to core
|
|
179
|
+
if (coreGoals && agent && typeof coreGoals.create === 'function') {
|
|
180
|
+
try {
|
|
181
|
+
const current = coreGoals.get ? coreGoals.get(agent) : null;
|
|
182
|
+
if (!current) {
|
|
183
|
+
coreGoals.create(agent, {
|
|
184
|
+
objective: legacyGoal.title,
|
|
185
|
+
maxGoalRounds: legacyGoal.maxIterations || 25,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
} catch (e) {
|
|
189
|
+
this.logger.warn?.('[AddonStore] Migration: could not create goal in core:', e.message);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
migratedAny = true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
data._migratedToAddon = true;
|
|
196
|
+
data._migratedAt = Date.now();
|
|
197
|
+
fs.writeFileSync(legacyPath, JSON.stringify(data, null, 2), 'utf8');
|
|
198
|
+
this.scheduleSave(true);
|
|
199
|
+
return migratedAny;
|
|
200
|
+
} catch (err) {
|
|
201
|
+
this.logger.warn?.('[AddonStore] Legacy migration failed:', err.message);
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
flushSync() {
|
|
207
|
+
if (this.saveTimer) {
|
|
208
|
+
clearTimeout(this.saveTimer);
|
|
209
|
+
this.saveTimer = null;
|
|
210
|
+
}
|
|
211
|
+
this.writeToDiskSync();
|
|
212
|
+
}
|
|
213
|
+
}
|
package/lib/engine-reports.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
1
4
|
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { generateRetrospectiveData } from './engine-retrospective.js';
|
|
2
6
|
|
|
3
7
|
/**
|
|
4
8
|
* Safely get current short git commit hash
|
|
@@ -19,48 +23,119 @@ export function getGitCurrentCommit(cwd) {
|
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
|
-
*
|
|
23
|
-
* @param {
|
|
26
|
+
* Format elapsed time in human readable format
|
|
27
|
+
* @param {number} seconds
|
|
24
28
|
* @returns {string}
|
|
25
29
|
*/
|
|
26
|
-
export function
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
export function formatElapsedSeconds(seconds) {
|
|
31
|
+
const s = Math.max(0, Math.floor(seconds || 0));
|
|
32
|
+
if (s < 60) return `${s}s`;
|
|
33
|
+
const m = Math.floor(s / 60);
|
|
34
|
+
const remSec = s % 60;
|
|
35
|
+
if (m < 60) return `${m}m ${remSec}s`;
|
|
36
|
+
const h = Math.floor(m / 60);
|
|
37
|
+
const remMin = m % 60;
|
|
38
|
+
return `${h}h ${remMin}m`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Generate Markdown report for goal completion or archival
|
|
43
|
+
* @param {object} goal - core goal view or state
|
|
44
|
+
* @param {object} [addonData] - { milestones, snapshots, tokensUsage }
|
|
45
|
+
* @param {string} [cwd]
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
export function exportReportMarkdown(goal, addonData = {}, cwd = process.cwd()) {
|
|
49
|
+
if (!goal) return '';
|
|
50
|
+
const title = goal.objective || goal.title || 'Goal Report';
|
|
51
|
+
const phase = goal.phase || goal.state || 'COMPLETED';
|
|
52
|
+
const rounds = goal.roundsStarted || goal.iterationsCount || 0;
|
|
53
|
+
const maxRounds = goal.maxGoalRounds || goal.maxIterations || 25;
|
|
54
|
+
|
|
55
|
+
const elapsed = goal.formattedElapsed || formatElapsedSeconds(Math.max(0, Math.round(((goal.updatedAt || goal.completedAt || Date.now()) - (goal.createdAt || goal.startedAt || Date.now())) / 1000)));
|
|
56
|
+
|
|
57
|
+
const totalTokens = addonData?.tokensUsage?.totalTokens || goal?.tokensUsage?.totalTokens || 0;
|
|
58
|
+
const promptTokens = addonData?.tokensUsage?.promptTokens || goal?.tokensUsage?.promptTokens || 0;
|
|
59
|
+
const compTokens = addonData?.tokensUsage?.completionTokens || goal?.tokensUsage?.completionTokens || 0;
|
|
60
|
+
const gitCommit = goal.gitStartCommit ? ` | **Git Start:** \`${goal.gitStartCommit}\`` : '';
|
|
36
61
|
|
|
37
62
|
let md = `# 🎯 Goal Report: ${title}\n\n`;
|
|
38
|
-
md += `**Status:** \`${
|
|
63
|
+
md += `**Status:** \`${phase}\` | **Duration:** \`${elapsed}\` | **Iterations:** \`${rounds}/${maxRounds}\`${gitCommit}\n`;
|
|
39
64
|
if (totalTokens > 0) {
|
|
40
65
|
md += `**Tokens:** \`${totalTokens.toLocaleString('en-US')}\` (Prompt: \`${promptTokens.toLocaleString('en-US')}\`, Completion: \`${compTokens.toLocaleString('en-US')}\`)\n`;
|
|
41
66
|
}
|
|
42
67
|
md += '\n';
|
|
43
68
|
|
|
44
|
-
if (
|
|
45
|
-
md += `### Description\n${
|
|
69
|
+
if (goal.description) {
|
|
70
|
+
md += `### Description\n${goal.description}\n\n`;
|
|
46
71
|
}
|
|
47
72
|
|
|
48
|
-
if (
|
|
49
|
-
md += `### Summary & Deliverables\n${
|
|
73
|
+
if (goal.resultSummary) {
|
|
74
|
+
md += `### Summary & Deliverables\n${goal.resultSummary}\n\n`;
|
|
50
75
|
}
|
|
51
76
|
|
|
52
|
-
|
|
77
|
+
// Milestones & Checklists
|
|
78
|
+
const milestones = addonData?.milestones || goal?.milestones || [];
|
|
53
79
|
if (milestones.length > 0) {
|
|
54
|
-
md += `### Milestones
|
|
80
|
+
md += `### Milestones & Checklists\n`;
|
|
81
|
+
md += `| # | Status | Title | Notes | Checklists |\n`;
|
|
82
|
+
md += `|---|---|---|---|---|\n`;
|
|
55
83
|
milestones.forEach((m, idx) => {
|
|
56
|
-
const
|
|
84
|
+
const isDone = m.status === 'completed' || m.completed === true;
|
|
85
|
+
const isInProg = m.status === 'in_progress';
|
|
86
|
+
const mark = isDone ? '✅ Done' : isInProg ? '🔄 In Progress' : '⏳ Pending';
|
|
57
87
|
const cleanTitle = (m.title || '').replace(/\|/g, '\\|');
|
|
58
|
-
const cleanNotes = (m.notes || '').replace(/\|/g, '\\|');
|
|
59
|
-
|
|
88
|
+
const cleanNotes = (m.notes || '—').replace(/\|/g, '\\|');
|
|
89
|
+
let clSummary = '—';
|
|
90
|
+
if (Array.isArray(m.checklist) && m.checklist.length > 0) {
|
|
91
|
+
const doneCount = m.checklist.filter(c => c.done).length;
|
|
92
|
+
clSummary = `${doneCount}/${m.checklist.length}`;
|
|
93
|
+
}
|
|
94
|
+
md += `| ${idx + 1} | ${mark} | ${cleanTitle} | ${cleanNotes} | ${clSummary} |\n`;
|
|
60
95
|
});
|
|
61
96
|
md += '\n';
|
|
62
97
|
}
|
|
63
98
|
|
|
99
|
+
// Snapshots
|
|
100
|
+
const snapshots = addonData?.snapshots || [];
|
|
101
|
+
if (snapshots.length > 0) {
|
|
102
|
+
md += `### Git Checkpoints & Snapshots\n`;
|
|
103
|
+
md += `| Milestone | Commit | Tag | Timestamp |\n`;
|
|
104
|
+
md += `|---|---|---|---|\n`;
|
|
105
|
+
snapshots.forEach((s) => {
|
|
106
|
+
const dt = new Date(s.timestamp || Date.now()).toISOString();
|
|
107
|
+
md += `| \`${s.milestoneId}\` | \`${s.commit}\` | \`${s.tag || '—'}\` | ${dt} |\n`;
|
|
108
|
+
});
|
|
109
|
+
md += '\n';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Retrospective
|
|
113
|
+
try {
|
|
114
|
+
const retro = generateRetrospectiveData({
|
|
115
|
+
...goal,
|
|
116
|
+
milestones,
|
|
117
|
+
tokensUsage: { totalTokens, promptTokens, completionTokens },
|
|
118
|
+
startedAt: goal.createdAt || goal.startedAt || Date.now(),
|
|
119
|
+
completedAt: goal.updatedAt || goal.completedAt || Date.now(),
|
|
120
|
+
}, cwd);
|
|
121
|
+
if (retro) {
|
|
122
|
+
md += `### Retrospective & Execution Analytics\n`;
|
|
123
|
+
if (retro.estimatedCostUsd) {
|
|
124
|
+
md += `- **Estimated Cost:** ~\$${retro.estimatedCostUsd} USD\n`;
|
|
125
|
+
}
|
|
126
|
+
if (retro.filesChangedCount !== undefined) {
|
|
127
|
+
md += `- **Files Changed:** ${retro.filesChangedCount}\n`;
|
|
128
|
+
}
|
|
129
|
+
if (retro.recommendations?.length > 0) {
|
|
130
|
+
md += `\n**Recommendations:**\n`;
|
|
131
|
+
retro.recommendations.forEach(r => { md += `- ${r}\n`; });
|
|
132
|
+
}
|
|
133
|
+
md += '\n';
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
// Retrospective non-fatal
|
|
137
|
+
}
|
|
138
|
+
|
|
64
139
|
md += `*Generated by DSH Goal Engine at ${new Date().toISOString()}*\n`;
|
|
65
140
|
return md;
|
|
66
141
|
}
|
|
@@ -72,10 +147,10 @@ export function exportReportMarkdown(state) {
|
|
|
72
147
|
*/
|
|
73
148
|
export function exportReportGitHubPR(state) {
|
|
74
149
|
if (!state) return '';
|
|
75
|
-
const title = state.title || 'Goal Report';
|
|
76
|
-
const status = state.
|
|
150
|
+
const title = state.objective || state.title || 'Goal Report';
|
|
151
|
+
const status = state.phase || state.state || 'COMPLETED';
|
|
77
152
|
const elapsed = state.formattedElapsed || '0s';
|
|
78
|
-
const iter = `${state.iterationsCount || 0}/${state.maxIterations || 25}`;
|
|
153
|
+
const iter = `${state.roundsStarted || state.iterationsCount || 0}/${state.maxGoalRounds || state.maxIterations || 25}`;
|
|
79
154
|
const totalTokens = state.tokensUsage?.totalTokens || 0;
|
|
80
155
|
const promptTokens = state.tokensUsage?.promptTokens || 0;
|
|
81
156
|
const compTokens = state.tokensUsage?.completionTokens || 0;
|
|
@@ -97,11 +172,11 @@ export function exportReportGitHubPR(state) {
|
|
|
97
172
|
|
|
98
173
|
const milestones = state.milestones || [];
|
|
99
174
|
if (milestones.length > 0) {
|
|
100
|
-
const completedCount = milestones.filter((m) => m.status === 'completed').length;
|
|
175
|
+
const completedCount = milestones.filter((m) => m.status === 'completed' || m.completed === true).length;
|
|
101
176
|
md += `<details>\n<summary><b>📋 Milestones Breakdown (${completedCount}/${milestones.length} Completed)</b></summary>\n\n`;
|
|
102
177
|
md += `| # | Status | Milestone | Notes |\n|---|---|---|---|\n`;
|
|
103
178
|
milestones.forEach((m, idx) => {
|
|
104
|
-
const mark = m.status === 'completed' ? '✅ Done' : m.status === 'in_progress' ? '🔄 In Progress' : '⏳ Pending';
|
|
179
|
+
const mark = (m.status === 'completed' || m.completed === true) ? '✅ Done' : m.status === 'in_progress' ? '🔄 In Progress' : '⏳ Pending';
|
|
105
180
|
const cleanTitle = (m.title || '').replace(/\|/g, '\\|');
|
|
106
181
|
const cleanNotes = (m.notes || '').replace(/\|/g, '\\|');
|
|
107
182
|
md += `| ${idx + 1} | ${mark} | ${cleanTitle} | ${cleanNotes || '—'} |\n`;
|
|
@@ -113,9 +188,6 @@ export function exportReportGitHubPR(state) {
|
|
|
113
188
|
return md;
|
|
114
189
|
}
|
|
115
190
|
|
|
116
|
-
import fs from 'node:fs';
|
|
117
|
-
import path from 'node:path';
|
|
118
|
-
|
|
119
191
|
/**
|
|
120
192
|
* Safely create a git milestone checkpoint
|
|
121
193
|
* @param {object} milestone
|
|
@@ -133,7 +205,6 @@ export function createMilestoneCheckpoint(milestone, sessionId = 'default', cwd)
|
|
|
133
205
|
cwd: cwd || undefined,
|
|
134
206
|
}).trim();
|
|
135
207
|
|
|
136
|
-
// If there are changes, auto-commit checkpoint
|
|
137
208
|
if (status) {
|
|
138
209
|
execFileSync('git', ['add', '-A'], {
|
|
139
210
|
stdio: ['ignore', 'ignore', 'ignore'],
|
|
@@ -191,7 +262,7 @@ export function rollbackToCheckpoint(commitHash, cwd) {
|
|
|
191
262
|
* @returns {{ relativePath: string, fullPath: string, content: string }|null}
|
|
192
263
|
*/
|
|
193
264
|
export function saveGoalArtifact(state, cwd) {
|
|
194
|
-
if (!state || !state.title) return null;
|
|
265
|
+
if (!state || (!state.title && !state.objective)) return null;
|
|
195
266
|
try {
|
|
196
267
|
const root = cwd || process.cwd();
|
|
197
268
|
const targetDir = path.join(root, '.dsh', 'goals');
|
|
@@ -200,7 +271,7 @@ export function saveGoalArtifact(state, cwd) {
|
|
|
200
271
|
}
|
|
201
272
|
|
|
202
273
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
203
|
-
const safeTitle = (state.title || 'goal')
|
|
274
|
+
const safeTitle = (state.objective || state.title || 'goal')
|
|
204
275
|
.toLowerCase()
|
|
205
276
|
.replace(/[^a-z0-9]+/g, '-')
|
|
206
277
|
.replace(/^-+|-+$/g, '')
|
|
@@ -218,3 +289,11 @@ export function saveGoalArtifact(state, cwd) {
|
|
|
218
289
|
return null;
|
|
219
290
|
}
|
|
220
291
|
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Save goal report to disk
|
|
295
|
+
*/
|
|
296
|
+
export function saveGoalReportFile(goal, addonData = {}, baseDir = null) {
|
|
297
|
+
const root = baseDir || process.cwd();
|
|
298
|
+
return saveGoalArtifact(goal, root)?.fullPath || null;
|
|
299
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Git snapshot and checkpoint manager for goal milestones
|
|
7
|
+
*/
|
|
8
|
+
export class GitSnapshotManager {
|
|
9
|
+
constructor(addonStore, { logger = console } = {}) {
|
|
10
|
+
this.store = addonStore;
|
|
11
|
+
this.logger = logger;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
isGitRepository(workspaceRoot) {
|
|
15
|
+
if (!workspaceRoot || typeof workspaceRoot !== 'string') return false;
|
|
16
|
+
try {
|
|
17
|
+
if (!fs.existsSync(workspaceRoot)) return false;
|
|
18
|
+
execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
19
|
+
cwd: workspaceRoot,
|
|
20
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
21
|
+
timeout: 2000,
|
|
22
|
+
});
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
createSnapshot(goalId, milestoneId, workspaceRoot, message = '') {
|
|
30
|
+
if (!goalId || !milestoneId || !this.isGitRepository(workspaceRoot)) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const commit = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
|
|
36
|
+
cwd: workspaceRoot,
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
39
|
+
timeout: 2000,
|
|
40
|
+
}).trim();
|
|
41
|
+
|
|
42
|
+
const safeGoalId = String(goalId).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
43
|
+
const safeMilestoneId = String(milestoneId).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
44
|
+
const tagName = `dsh-goal-snapshot/${safeGoalId}/${safeMilestoneId}-${commit}`;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
execFileSync('git', ['tag', '-f', tagName, 'HEAD'], {
|
|
48
|
+
cwd: workspaceRoot,
|
|
49
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
50
|
+
timeout: 2000,
|
|
51
|
+
});
|
|
52
|
+
} catch (tagErr) {
|
|
53
|
+
// Tag creation non-fatal
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const snapshot = {
|
|
57
|
+
goalId,
|
|
58
|
+
milestoneId,
|
|
59
|
+
commit,
|
|
60
|
+
tag: tagName,
|
|
61
|
+
timestamp: Date.now(),
|
|
62
|
+
message: message || `Milestone ${milestoneId} checkpoint`,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
if (this.store) {
|
|
66
|
+
this.store.addSnapshot(goalId, snapshot);
|
|
67
|
+
}
|
|
68
|
+
this.logger.info?.(`[GitSnapshotManager] Created snapshot for milestone "${milestoneId}" at commit ${commit}`);
|
|
69
|
+
return snapshot;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
this.logger.warn?.(`[GitSnapshotManager] Failed to create git snapshot: ${err.message}`);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
rollback(goalId, milestoneId, workspaceRoot) {
|
|
77
|
+
if (!this.isGitRepository(workspaceRoot)) {
|
|
78
|
+
throw new Error(`Workspace "${workspaceRoot}" is not a valid git repository`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const snapshots = this.store ? this.store.getSnapshots(goalId) : [];
|
|
82
|
+
const target = snapshots.find(s => s.milestoneId === milestoneId);
|
|
83
|
+
if (!target || !target.commit) {
|
|
84
|
+
throw new Error(`Snapshot for milestone "${milestoneId}" not found in goal "${goalId}"`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
execFileSync('git', ['checkout', target.commit], {
|
|
89
|
+
cwd: workspaceRoot,
|
|
90
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
91
|
+
timeout: 5000,
|
|
92
|
+
});
|
|
93
|
+
this.logger.info?.(`[GitSnapshotManager] Rolled back workspace to commit ${target.commit}`);
|
|
94
|
+
return { success: true, commit: target.commit, milestoneId };
|
|
95
|
+
} catch (err) {
|
|
96
|
+
throw new Error(`Git rollback to commit ${target.commit} failed: ${err.message}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|