@goodandready/dsh-goal 0.2.30 → 0.2.32
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 +502 -5
- package/lib/engine-compaction.js +126 -0
- package/lib/engine-diff.js +147 -0
- package/lib/engine-kanban-sync.js +242 -0
- package/lib/engine-prompt.js +37 -5
- package/lib/engine-subgoals.js +207 -0
- package/lib/goal-engine-constants.js +12 -0
- package/lib/goal-engine.js +38 -47
- package/lib/index.js +10 -7
- package/lib/routes.js +71 -1
- package/lib/tools.js +69 -0
- package/package.json +1 -1
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { getSafeCwd } from './goal-engine-constants.js';
|
|
3
|
+
import { isGitRepository, isValidGitRef } from './engine-git-branch.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse git diff --numstat output into structured file statistics
|
|
7
|
+
* @param {string} output
|
|
8
|
+
* @returns {Array<{ path: string, insertions: number, deletions: number, binary: boolean }>}
|
|
9
|
+
*/
|
|
10
|
+
export function parseNumstat(output) {
|
|
11
|
+
if (!output || typeof output !== 'string') return [];
|
|
12
|
+
const lines = output.trim().split('\n');
|
|
13
|
+
const files = [];
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
if (!line.trim()) continue;
|
|
16
|
+
const parts = line.split('\t');
|
|
17
|
+
if (parts.length < 3) continue;
|
|
18
|
+
const [rawIns, rawDel, filePath] = parts;
|
|
19
|
+
const isBinary = rawIns === '-' || rawDel === '-';
|
|
20
|
+
const ins = isBinary ? 0 : parseInt(rawIns, 10) || 0;
|
|
21
|
+
const del = isBinary ? 0 : parseInt(rawDel, 10) || 0;
|
|
22
|
+
files.push({
|
|
23
|
+
path: filePath,
|
|
24
|
+
insertions: ins,
|
|
25
|
+
deletions: del,
|
|
26
|
+
binary: isBinary,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return files;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get git diff between refs or current working tree
|
|
34
|
+
* @param {object} options
|
|
35
|
+
* @returns {object} diff result payload
|
|
36
|
+
*/
|
|
37
|
+
export function getGitDiff({
|
|
38
|
+
cwd = process.cwd(),
|
|
39
|
+
base,
|
|
40
|
+
target,
|
|
41
|
+
file,
|
|
42
|
+
allowedRoots = [],
|
|
43
|
+
maxBytes = 256 * 1024,
|
|
44
|
+
} = {}) {
|
|
45
|
+
const safeCwd = getSafeCwd(cwd, allowedRoots);
|
|
46
|
+
if (!safeCwd || !isGitRepository(safeCwd, allowedRoots)) {
|
|
47
|
+
return { ok: false, error: 'Not a git repository or invalid path' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Ref validation: disallow flag injection and special chars
|
|
51
|
+
if (base && !isValidGitRef(base)) {
|
|
52
|
+
return { ok: false, error: 'Invalid base git ref' };
|
|
53
|
+
}
|
|
54
|
+
if (target && !isValidGitRef(target)) {
|
|
55
|
+
return { ok: false, error: 'Invalid target git ref' };
|
|
56
|
+
}
|
|
57
|
+
if (file && (typeof file !== 'string' || file.startsWith('-') || /[\0\r\n]/.test(file))) {
|
|
58
|
+
return { ok: false, error: 'Invalid file parameter' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const rangeArgs = [];
|
|
63
|
+
if (base && target) {
|
|
64
|
+
rangeArgs.push(`${base}..${target}`);
|
|
65
|
+
} else if (base) {
|
|
66
|
+
rangeArgs.push(base);
|
|
67
|
+
} else if (target) {
|
|
68
|
+
rangeArgs.push(target);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 1. Fetch numstat for file summary
|
|
72
|
+
const numstatArgs = ['diff', '--numstat', ...rangeArgs];
|
|
73
|
+
if (file) {
|
|
74
|
+
numstatArgs.push('--', file);
|
|
75
|
+
}
|
|
76
|
+
const numstatOut = execFileSync('git', numstatArgs, {
|
|
77
|
+
cwd: safeCwd,
|
|
78
|
+
encoding: 'utf8',
|
|
79
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
80
|
+
timeout: 5000,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const files = parseNumstat(numstatOut);
|
|
84
|
+
const stats = {
|
|
85
|
+
filesChanged: files.length,
|
|
86
|
+
insertions: files.reduce((acc, f) => acc + f.insertions, 0),
|
|
87
|
+
deletions: files.reduce((acc, f) => acc + f.deletions, 0),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// 2. Fetch patch
|
|
91
|
+
const patchArgs = ['diff', '-p', '--no-color', ...rangeArgs];
|
|
92
|
+
if (file) {
|
|
93
|
+
patchArgs.push('--', file);
|
|
94
|
+
}
|
|
95
|
+
let patchOut = execFileSync('git', patchArgs, {
|
|
96
|
+
cwd: safeCwd,
|
|
97
|
+
encoding: 'utf8',
|
|
98
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
99
|
+
timeout: 5000,
|
|
100
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
let truncated = false;
|
|
104
|
+
if (patchOut.length > maxBytes) {
|
|
105
|
+
patchOut = patchOut.slice(0, maxBytes);
|
|
106
|
+
truncated = true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
ok: true,
|
|
111
|
+
cwd: safeCwd,
|
|
112
|
+
base: base || null,
|
|
113
|
+
target: target || 'HEAD',
|
|
114
|
+
stats,
|
|
115
|
+
files,
|
|
116
|
+
diff: patchOut,
|
|
117
|
+
truncated,
|
|
118
|
+
};
|
|
119
|
+
} catch (err) {
|
|
120
|
+
return { ok: false, error: err.message || 'git diff execution failed' };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* GoalEngine helper to fetch diff for active or specified session
|
|
126
|
+
* @param {object} engine
|
|
127
|
+
* @param {string} sessionId
|
|
128
|
+
* @param {object} options
|
|
129
|
+
* @returns {object} diff result
|
|
130
|
+
*/
|
|
131
|
+
export function getEngineGoalDiff(engine, sessionId = 'default', options = {}) {
|
|
132
|
+
const sid = sessionId || 'default';
|
|
133
|
+
const goal = engine.goals?.get(sid);
|
|
134
|
+
const allowedRoots = options.allowedRoots || engine.config?.allowedRoots || (goal?.cwd ? [goal.cwd, process.cwd()] : [process.cwd()]);
|
|
135
|
+
const cwd = options.cwd || goal?.cwd || engine.config?.cwd || process.cwd();
|
|
136
|
+
const base = options.base || goal?.gitStartCommit || null;
|
|
137
|
+
const target = options.target || goal?.gitBranch || goal?.branchName || 'HEAD';
|
|
138
|
+
|
|
139
|
+
return getGitDiff({
|
|
140
|
+
cwd,
|
|
141
|
+
base,
|
|
142
|
+
target,
|
|
143
|
+
file: options.file,
|
|
144
|
+
allowedRoots,
|
|
145
|
+
maxBytes: options.maxBytes,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { MilestoneStatus } from './goal-engine-constants.js';
|
|
2
|
+
|
|
3
|
+
export const MILESTONE_TO_KANBAN_COLUMN = {
|
|
4
|
+
[MilestoneStatus.PENDING]: 'todo',
|
|
5
|
+
[MilestoneStatus.IN_PROGRESS]: 'in_progress',
|
|
6
|
+
[MilestoneStatus.COMPLETED]: 'done',
|
|
7
|
+
pending: 'todo',
|
|
8
|
+
in_progress: 'in_progress',
|
|
9
|
+
active: 'in_progress',
|
|
10
|
+
completed: 'done',
|
|
11
|
+
PENDING: 'todo',
|
|
12
|
+
ACTIVE: 'in_progress',
|
|
13
|
+
COMPLETED: 'done',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const KANBAN_COLUMN_TO_MILESTONE_STATUS = {
|
|
17
|
+
backlog: MilestoneStatus.PENDING,
|
|
18
|
+
todo: MilestoneStatus.PENDING,
|
|
19
|
+
in_progress: MilestoneStatus.IN_PROGRESS,
|
|
20
|
+
doing: MilestoneStatus.IN_PROGRESS,
|
|
21
|
+
active: MilestoneStatus.IN_PROGRESS,
|
|
22
|
+
review: MilestoneStatus.COMPLETED,
|
|
23
|
+
done: MilestoneStatus.COMPLETED,
|
|
24
|
+
completed: MilestoneStatus.COMPLETED,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Format externalRef string for a goal milestone
|
|
29
|
+
* @param {string} goalId
|
|
30
|
+
* @param {string} milestoneId
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function formatMilestoneExternalRef(goalId, milestoneId) {
|
|
34
|
+
return `dsh-goal:${goalId || 'default'}:${milestoneId}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parse milestone externalRef
|
|
39
|
+
* @param {string} externalRef
|
|
40
|
+
* @returns {{ goalId: string, milestoneId: string } | null}
|
|
41
|
+
*/
|
|
42
|
+
export function parseMilestoneExternalRef(externalRef) {
|
|
43
|
+
if (typeof externalRef !== 'string' || !externalRef.startsWith('dsh-goal:')) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const parts = externalRef.split(':');
|
|
47
|
+
if (parts.length < 3) return null;
|
|
48
|
+
return {
|
|
49
|
+
goalId: parts[1],
|
|
50
|
+
milestoneId: parts.slice(2).join(':'),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolve active kanban service or store from context/options
|
|
56
|
+
* @param {object} options
|
|
57
|
+
* @returns {{ service: object | null, store: object | null }}
|
|
58
|
+
*/
|
|
59
|
+
export function resolveKanbanServices(options = {}) {
|
|
60
|
+
const ctx = options.ctx;
|
|
61
|
+
let service = options.kanbanService || null;
|
|
62
|
+
let store = options.store || null;
|
|
63
|
+
|
|
64
|
+
if (!service && ctx) {
|
|
65
|
+
service = ctx.dshKanban || (typeof ctx.get === 'function' ? ctx.get('dshKanban') : null);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!store) {
|
|
69
|
+
if (service && typeof service.getStore === 'function') {
|
|
70
|
+
store = service.getStore();
|
|
71
|
+
} else if (ctx?.kanbanStore) {
|
|
72
|
+
store = ctx.kanbanStore;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { service, store };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Synchronize all milestones of a goal with dsh-kanban
|
|
81
|
+
* Safe degradation if kanban is not available
|
|
82
|
+
* @param {object} engine
|
|
83
|
+
* @param {string} sessionId
|
|
84
|
+
* @param {object} options
|
|
85
|
+
* @returns {Promise<object>} sync summary
|
|
86
|
+
*/
|
|
87
|
+
export async function syncGoalToKanban(engine, sessionId = 'default', options = {}) {
|
|
88
|
+
const sid = sessionId || 'default';
|
|
89
|
+
const goal = engine.goals?.get(sid);
|
|
90
|
+
if (!goal) {
|
|
91
|
+
return { ok: false, error: 'Goal not found' };
|
|
92
|
+
}
|
|
93
|
+
if (!goal.milestones || goal.milestones.length === 0) {
|
|
94
|
+
return { ok: true, synced: 0, reason: 'No milestones in goal' };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const { service, store } = resolveKanbanServices({
|
|
98
|
+
kanbanService: options.kanbanService || engine.kanbanService,
|
|
99
|
+
store: options.store || engine.kanbanStore,
|
|
100
|
+
ctx: options.ctx || engine.ctx,
|
|
101
|
+
...options,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (!service && !store) {
|
|
105
|
+
return { ok: false, skipped: true, reason: 'kanban-unavailable' };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const results = [];
|
|
109
|
+
for (const ms of goal.milestones) {
|
|
110
|
+
const externalRef = formatMilestoneExternalRef(goal.id, ms.id);
|
|
111
|
+
const col = MILESTONE_TO_KANBAN_COLUMN[ms.status] || 'todo';
|
|
112
|
+
const title = `[Goal] ${ms.title}`;
|
|
113
|
+
const body = ms.notes || `Milestone ${ms.id} for goal: ${goal.title}`;
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
if (service && typeof service.createTask === 'function') {
|
|
117
|
+
const res = await service.createTask({
|
|
118
|
+
title,
|
|
119
|
+
body,
|
|
120
|
+
column: col,
|
|
121
|
+
externalRef,
|
|
122
|
+
labels: ['dsh-goal', String(goal.id)],
|
|
123
|
+
});
|
|
124
|
+
results.push({ milestoneId: ms.id, taskId: res.taskId || res.task?.id, ok: res.ok, alreadyExists: res.alreadyExists });
|
|
125
|
+
} else if (store && typeof store.createTask === 'function') {
|
|
126
|
+
const existing = typeof store.findTaskByExternalRef === 'function' ? store.findTaskByExternalRef(externalRef) : null;
|
|
127
|
+
if (!existing) {
|
|
128
|
+
const task = store.createTask({
|
|
129
|
+
title,
|
|
130
|
+
body,
|
|
131
|
+
column: col,
|
|
132
|
+
externalRef,
|
|
133
|
+
labels: ['dsh-goal', String(goal.id)],
|
|
134
|
+
});
|
|
135
|
+
results.push({ milestoneId: ms.id, taskId: task.id, ok: true, alreadyExists: false });
|
|
136
|
+
} else {
|
|
137
|
+
results.push({ milestoneId: ms.id, taskId: existing.id, ok: true, alreadyExists: true });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch (err) {
|
|
141
|
+
engine.logger?.warn?.(`[dsh-goal] Kanban milestone sync failed for ${ms.id}:`, err?.message || err);
|
|
142
|
+
results.push({ milestoneId: ms.id, ok: false, error: err?.message || String(err) });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { ok: true, synced: results.filter((r) => r.ok).length, total: goal.milestones.length, results };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Update corresponding kanban card column when milestone status changes
|
|
151
|
+
* @param {object} milestone
|
|
152
|
+
* @param {object} options
|
|
153
|
+
* @returns {Promise<object>}
|
|
154
|
+
*/
|
|
155
|
+
export async function syncMilestoneStatusToKanban(milestone, { goal, ctx, store, kanbanService } = {}) {
|
|
156
|
+
if (!milestone || !goal) return { ok: false, error: 'Invalid milestone or goal' };
|
|
157
|
+
|
|
158
|
+
const { store: activeStore } = resolveKanbanServices({
|
|
159
|
+
store,
|
|
160
|
+
kanbanService,
|
|
161
|
+
ctx,
|
|
162
|
+
});
|
|
163
|
+
if (!activeStore || typeof activeStore.findTaskByExternalRef !== 'function') {
|
|
164
|
+
return { ok: false, skipped: true, reason: 'kanban-store-unavailable' };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const externalRef = formatMilestoneExternalRef(goal.id, milestone.id);
|
|
168
|
+
const targetCol = MILESTONE_TO_KANBAN_COLUMN[milestone.status] || 'todo';
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const task = activeStore.findTaskByExternalRef(externalRef);
|
|
172
|
+
if (!task) {
|
|
173
|
+
return { ok: false, skipped: true, reason: 'kanban-task-not-found' };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (task.column !== targetCol && typeof activeStore.moveTask === 'function') {
|
|
177
|
+
activeStore.moveTask(task.id, { column: targetCol });
|
|
178
|
+
return { ok: true, synced: true, taskId: task.id, column: targetCol };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { ok: true, synced: true, taskId: task.id, column: task.column, unchanged: true };
|
|
182
|
+
} catch (err) {
|
|
183
|
+
return { ok: false, error: err?.message || String(err) };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Handle external card movement from dsh-kanban board
|
|
189
|
+
* @param {object} engine
|
|
190
|
+
* @param {object} payload
|
|
191
|
+
* @param {string} sessionId
|
|
192
|
+
* @returns {object}
|
|
193
|
+
*/
|
|
194
|
+
export function handleKanbanCardMoved(engine, payload = {}, sessionId = 'default') {
|
|
195
|
+
const externalRef = payload.externalRef;
|
|
196
|
+
const column = payload.column;
|
|
197
|
+
const targetStatus = payload.status || (column ? KANBAN_COLUMN_TO_MILESTONE_STATUS[column] : null);
|
|
198
|
+
|
|
199
|
+
if (!targetStatus) {
|
|
200
|
+
return { ok: false, error: 'Invalid or missing target milestone status/column' };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let goalId = null;
|
|
204
|
+
let milestoneId = payload.milestoneId || null;
|
|
205
|
+
|
|
206
|
+
if (externalRef) {
|
|
207
|
+
const parsed = parseMilestoneExternalRef(externalRef);
|
|
208
|
+
if (parsed) {
|
|
209
|
+
goalId = parsed.goalId;
|
|
210
|
+
milestoneId = parsed.milestoneId;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const candidateSessions = sessionId && sessionId !== 'default'
|
|
215
|
+
? [sessionId, 'default']
|
|
216
|
+
: ['default', ...engine.goals.keys()];
|
|
217
|
+
|
|
218
|
+
for (const sid of candidateSessions) {
|
|
219
|
+
const goal = engine.goals?.get(sid);
|
|
220
|
+
if (!goal || (goalId && String(goal.id) !== String(goalId))) continue;
|
|
221
|
+
|
|
222
|
+
const ms = goal.milestones?.find((m) => String(m.id) === String(milestoneId) || m.title === payload.title);
|
|
223
|
+
if (ms) {
|
|
224
|
+
const updateRes = engine.updateMilestone(
|
|
225
|
+
ms.id,
|
|
226
|
+
targetStatus,
|
|
227
|
+
`Synced from Kanban card move to column '${column || targetStatus}'`,
|
|
228
|
+
sid
|
|
229
|
+
);
|
|
230
|
+
return {
|
|
231
|
+
ok: true,
|
|
232
|
+
sessionId: sid,
|
|
233
|
+
milestoneId: ms.id,
|
|
234
|
+
prevStatus: ms.status,
|
|
235
|
+
newStatus: targetStatus,
|
|
236
|
+
updateResult: updateRes,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { ok: false, error: 'Matching milestone not found for external reference' };
|
|
242
|
+
}
|
package/lib/engine-prompt.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { detectLanguage } from "./goal-engine.js";
|
|
2
2
|
import { isMilestoneBlocked } from './engine-milestones.js';
|
|
3
|
+
import { buildCompactedMilestonesHistory } from './engine-compaction.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Prompt generation for DSH Goal Mode
|
|
@@ -55,13 +56,28 @@ export function buildStatePromptInjection(snapshot, pendingNudge) {
|
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
let subGoalContextText = '';
|
|
60
|
+
if (snapshot.isSubGoal) {
|
|
61
|
+
if (lang === 'zh') {
|
|
62
|
+
subGoalContextText = `\n\n[子目标隔离执行上下文 / ISOLATED SUB-GOAL EXECUTION CONTEXT]\n` +
|
|
63
|
+
`你当前处于从主目标派生出的隔离子目标中 (主目标 ID: "${snapshot.parentGoalId || 'parent'}")。\n` +
|
|
64
|
+
`请严格专注于当前子目标的独立交付!所有里程碑完成后,立即调用 goal_finish 提交最终成果并将控制权移交回主目标。\n`;
|
|
65
|
+
} else {
|
|
66
|
+
subGoalContextText = `\n\n[ISOLATED SUB-GOAL EXECUTION CONTEXT]\n` +
|
|
67
|
+
`You are running inside an isolated sub-goal spawned by parent goal (Parent Goal ID: "${snapshot.parentGoalId || 'parent'}").\n` +
|
|
68
|
+
`Focus strictly on delivering this specific sub-goal objective! When all sub-milestones are completed, call tool goal_finish immediately to return deliverables and control to the parent goal.\n`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
58
72
|
const allMilestones = snapshot.milestones || [];
|
|
59
73
|
const formatMilestone = (m, i) => {
|
|
74
|
+
const origIdx = allMilestones.indexOf(m);
|
|
75
|
+
const num = origIdx >= 0 ? origIdx + 1 : i + 1;
|
|
60
76
|
const depCheck = isMilestoneBlocked(m, allMilestones);
|
|
61
77
|
const blockedLabel = depCheck.blocked
|
|
62
78
|
? ` (BLOCKED by ${depCheck.missingDependencies.join(', ')})`
|
|
63
79
|
: '';
|
|
64
|
-
let line = ` ${
|
|
80
|
+
let line = ` ${num}. [${m.status.toUpperCase()}] ${m.title}${blockedLabel}${m.notes ? ` (${m.notes})` : ''}`;
|
|
65
81
|
if (Array.isArray(m.checklist) && m.checklist.length > 0) {
|
|
66
82
|
const checkLines = m.checklist
|
|
67
83
|
.map((item) => ` [${item.done ? 'x' : ' '}] ${item.text}`)
|
|
@@ -71,12 +87,26 @@ export function buildStatePromptInjection(snapshot, pendingNudge) {
|
|
|
71
87
|
return line;
|
|
72
88
|
};
|
|
73
89
|
|
|
90
|
+
const enableCompaction = snapshot.enableMilestoneCompaction !== false;
|
|
91
|
+
const completedMilestones = allMilestones.filter((m) => m && m.status === 'completed');
|
|
92
|
+
const activeMilestones = allMilestones.filter((m) => m && m.status !== 'completed');
|
|
93
|
+
const shouldCompact = enableCompaction && completedMilestones.length > 0 && activeMilestones.length > 0;
|
|
94
|
+
|
|
95
|
+
let compactedBlock = '';
|
|
96
|
+
if (shouldCompact) {
|
|
97
|
+
compactedBlock = `${buildCompactedMilestonesHistory(completedMilestones, lang)}\n\n`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const displayMilestones = shouldCompact ? activeMilestones : allMilestones;
|
|
101
|
+
|
|
74
102
|
if (lang === 'zh') {
|
|
103
|
+
const planTitle = shouldCompact ? '当前进行中与后续工作计划:' : '工作计划:';
|
|
75
104
|
const milestonesText = hasMilestones
|
|
76
|
-
?
|
|
105
|
+
? displayMilestones.map(formatMilestone).join('\n')
|
|
77
106
|
: ' (工作计划尚未建立 — 请立即调用 goal_set_milestones 设定初始里程碑!)';
|
|
78
107
|
|
|
79
108
|
return (
|
|
109
|
+
subGoalContextText +
|
|
80
110
|
operatorAnswerText +
|
|
81
111
|
directiveText +
|
|
82
112
|
nudgeText +
|
|
@@ -84,7 +114,7 @@ export function buildStatePromptInjection(snapshot, pendingNudge) {
|
|
|
84
114
|
`\n\n[DSH GOAL MODE ACTIVE]\n` +
|
|
85
115
|
`目标: "${snapshot.title}"\n` +
|
|
86
116
|
`运行时间: ${snapshot.formattedElapsed}${etaText} | 迭代轮次: ${snapshot.iterationsCount}/${snapshot.maxIterations}\n` +
|
|
87
|
-
|
|
117
|
+
`${compactedBlock}${planTitle}\n` +
|
|
88
118
|
`${milestonesText}\n\n` +
|
|
89
119
|
`Goal Mode 执行契约 (必须严格遵循):\n` +
|
|
90
120
|
`1. ${hasMilestones ? '按部就班推进当前进行中的里程碑。注意:标记为 [BLOCKED] 的里程碑在前置依赖完成前严禁推进。' : '第一步核心指令: 立即调用 goal_set_milestones 制定 3-7 个具体里程碑。在完成此工具调用前严禁执行其他操作!'}\n` +
|
|
@@ -93,11 +123,13 @@ export function buildStatePromptInjection(snapshot, pendingNudge) {
|
|
|
93
123
|
);
|
|
94
124
|
}
|
|
95
125
|
|
|
126
|
+
const planTitle = shouldCompact ? 'Active & Upcoming Work Plan:' : 'Work Plan:';
|
|
96
127
|
const milestonesText = hasMilestones
|
|
97
|
-
?
|
|
128
|
+
? displayMilestones.map(formatMilestone).join('\n')
|
|
98
129
|
: ' (Work plan is not yet established — call goal_set_milestones immediately with initial steps!)';
|
|
99
130
|
|
|
100
131
|
return (
|
|
132
|
+
subGoalContextText +
|
|
101
133
|
operatorAnswerText +
|
|
102
134
|
directiveText +
|
|
103
135
|
nudgeText +
|
|
@@ -105,7 +137,7 @@ export function buildStatePromptInjection(snapshot, pendingNudge) {
|
|
|
105
137
|
`\n\n[DSH GOAL MODE ACTIVE]\n` +
|
|
106
138
|
`Goal: "${snapshot.title}"\n` +
|
|
107
139
|
`Elapsed Time: ${snapshot.formattedElapsed}${etaText} | Iteration: ${snapshot.iterationsCount}/${snapshot.maxIterations}\n` +
|
|
108
|
-
|
|
140
|
+
`${compactedBlock}${planTitle}\n` +
|
|
109
141
|
`${milestonesText}\n\n` +
|
|
110
142
|
`Goal Mode Instructions (MANDATORY TO FOLLOW):\n` +
|
|
111
143
|
`1. ${hasMilestones ? 'Execute the current active milestone from the work plan. Note: Milestones marked [BLOCKED] must not be started until prerequisite milestones are COMPLETED.' : '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` +
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hierarchical Recursive Sub-goals Engine Module (Issue #170).
|
|
3
|
+
* Supports isolated context execution, state counters isolation,
|
|
4
|
+
* breadcrumbs hierarchy, and artifact/control synchronization with parent goals.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { GoalState } from './goal-engine-constants.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Spawn a child sub-goal under a parent goal session
|
|
11
|
+
* @param {object} engine - GoalEngine instance
|
|
12
|
+
* @param {string} parentSessionId - Parent goal session ID
|
|
13
|
+
* @param {object} options - Sub-goal options { title, prompt, description, maxIterations, cwd, inheritContext, milestones }
|
|
14
|
+
* @returns {object}
|
|
15
|
+
*/
|
|
16
|
+
export function spawnSubGoal(engine, parentSessionId, options = {}) {
|
|
17
|
+
if (!engine) throw new Error('GoalEngine instance is required');
|
|
18
|
+
|
|
19
|
+
const pSid = parentSessionId || 'default';
|
|
20
|
+
const parentGoal = engine.goals.get(pSid);
|
|
21
|
+
if (!parentGoal) {
|
|
22
|
+
return { ok: false, error: `Parent goal for session "${pSid}" not found` };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const rawTitle = options.title || options.goal;
|
|
26
|
+
if (!rawTitle || typeof rawTitle !== 'string' || !rawTitle.trim()) {
|
|
27
|
+
return { ok: false, error: 'Sub-goal title is required and cannot be empty' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const cleanTitle = rawTitle.trim();
|
|
31
|
+
const subGoalId = options.subGoalId || `sub-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 6)}`;
|
|
32
|
+
const childSessionId = options.sessionId || `${pSid}:subgoal:${subGoalId}`;
|
|
33
|
+
|
|
34
|
+
const inheritContext = options.inheritContext !== false && options.inherit_context !== false;
|
|
35
|
+
let childDesc = (options.prompt || options.description || '').trim();
|
|
36
|
+
if (inheritContext) {
|
|
37
|
+
const parentContextTag = `[Parent Goal: "${parentGoal.title}"]`;
|
|
38
|
+
childDesc = childDesc ? `${parentContextTag} ${childDesc}` : parentContextTag;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const maxIters = typeof options.maxIterations === 'number'
|
|
42
|
+
? options.maxIterations
|
|
43
|
+
: (typeof options.max_iterations === 'number' ? options.max_iterations : 15);
|
|
44
|
+
|
|
45
|
+
const childSnapshot = engine.startGoal(cleanTitle, {
|
|
46
|
+
description: childDesc,
|
|
47
|
+
maxIterations: maxIters,
|
|
48
|
+
cwd: options.cwd || parentGoal.cwd,
|
|
49
|
+
lang: options.lang || parentGoal.lang,
|
|
50
|
+
parentGoalId: parentGoal.id,
|
|
51
|
+
parentSessionId: pSid,
|
|
52
|
+
autoBranch: false,
|
|
53
|
+
milestones: Array.isArray(options.milestones) ? options.milestones : [],
|
|
54
|
+
}, childSessionId);
|
|
55
|
+
|
|
56
|
+
const childGoal = engine.goals.get(childSessionId);
|
|
57
|
+
if (childGoal) {
|
|
58
|
+
childGoal.parentGoalId = parentGoal.id;
|
|
59
|
+
childGoal.parentSessionId = pSid;
|
|
60
|
+
childGoal.isSubGoal = true;
|
|
61
|
+
childGoal.subGoalId = subGoalId;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!Array.isArray(parentGoal.subGoals)) {
|
|
65
|
+
parentGoal.subGoals = [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
parentGoal.subGoals.push({
|
|
69
|
+
id: childGoal ? childGoal.id : childSnapshot.id,
|
|
70
|
+
subGoalId,
|
|
71
|
+
sessionId: childSessionId,
|
|
72
|
+
title: cleanTitle,
|
|
73
|
+
state: childGoal ? childGoal.state : GoalState.RUNNING,
|
|
74
|
+
createdAt: childGoal ? childGoal.startedAt : Date.now(),
|
|
75
|
+
completedAt: null,
|
|
76
|
+
resultSummary: '',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (typeof engine.recordToolActivity === 'function') {
|
|
80
|
+
engine.recordToolActivity('spawnSubGoal', 'success', `Spawned sub-goal [${cleanTitle}]`, pSid);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
engine.emit(pSid, true);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
parentGoalId: parentGoal.id,
|
|
88
|
+
parentSessionId: pSid,
|
|
89
|
+
subGoalId,
|
|
90
|
+
childSessionId,
|
|
91
|
+
childGoal: childSnapshot,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Complete a child sub-goal and synchronize state/deliverables back to parent goal
|
|
97
|
+
* @param {object} engine - GoalEngine instance
|
|
98
|
+
* @param {string} childSessionId - Child sub-goal session ID
|
|
99
|
+
* @param {string} [resultSummary=''] - Deliverables summary
|
|
100
|
+
* @returns {object}
|
|
101
|
+
*/
|
|
102
|
+
export function syncSubGoalToParent(engine, childSessionId, resultSummary = '') {
|
|
103
|
+
if (!engine) return false;
|
|
104
|
+
const childGoal = engine.goals.get(childSessionId);
|
|
105
|
+
if (!childGoal) return false;
|
|
106
|
+
|
|
107
|
+
const pSid = childGoal.parentSessionId;
|
|
108
|
+
const parentGoal = pSid ? engine.goals.get(pSid) : null;
|
|
109
|
+
if (!parentGoal) return false;
|
|
110
|
+
|
|
111
|
+
if (Array.isArray(parentGoal.subGoals)) {
|
|
112
|
+
const entry = parentGoal.subGoals.find(
|
|
113
|
+
(s) => s.sessionId === childSessionId || s.id === childGoal.id || (childGoal.subGoalId && s.subGoalId === childGoal.subGoalId)
|
|
114
|
+
);
|
|
115
|
+
if (entry) {
|
|
116
|
+
entry.state = GoalState.COMPLETED;
|
|
117
|
+
entry.completedAt = Date.now();
|
|
118
|
+
entry.resultSummary = resultSummary;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const note = resultSummary ? `: ${resultSummary}` : '';
|
|
123
|
+
parentGoal.logs.push({
|
|
124
|
+
timestamp: Date.now(),
|
|
125
|
+
type: 'info',
|
|
126
|
+
message: `Sub-goal [${childGoal.title}] completed${note}`,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (typeof engine.recordToolActivity === 'function') {
|
|
130
|
+
engine.recordToolActivity('completeSubGoal', 'success', `Sub-goal [${childGoal.title}] completed`, pSid);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
engine.emit(pSid, true);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function completeSubGoal(engine, childSessionId, resultSummary = '') {
|
|
138
|
+
if (!engine) throw new Error('GoalEngine instance is required');
|
|
139
|
+
if (!childSessionId) return { ok: false, error: 'childSessionId is required' };
|
|
140
|
+
|
|
141
|
+
const childGoal = engine.goals.get(childSessionId);
|
|
142
|
+
if (!childGoal) {
|
|
143
|
+
return { ok: false, error: `Child sub-goal "${childSessionId}" not found` };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const pSid = childGoal.parentSessionId;
|
|
147
|
+
if (childGoal.state !== GoalState.COMPLETED) {
|
|
148
|
+
engine.completeGoal(resultSummary, childSessionId);
|
|
149
|
+
} else {
|
|
150
|
+
syncSubGoalToParent(engine, childSessionId, resultSummary);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
childSessionId,
|
|
156
|
+
parentSessionId: pSid || null,
|
|
157
|
+
resultSummary,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Get hierarchy breadcrumbs chain from root goal down to current session
|
|
163
|
+
* @param {object} engine - GoalEngine instance
|
|
164
|
+
* @param {string} [sessionId='default']
|
|
165
|
+
* @returns {Array<{ sessionId: string, id: string, title: string, state: string, isSubGoal: boolean, isCurrent?: boolean }>}
|
|
166
|
+
*/
|
|
167
|
+
export function getSubGoalBreadcrumbs(engine, sessionId = 'default') {
|
|
168
|
+
if (!engine || !engine.goals) return [];
|
|
169
|
+
|
|
170
|
+
const crumbs = [];
|
|
171
|
+
let currSid = sessionId || 'default';
|
|
172
|
+
const visited = new Set();
|
|
173
|
+
|
|
174
|
+
while (currSid && !visited.has(currSid)) {
|
|
175
|
+
visited.add(currSid);
|
|
176
|
+
const g = engine.goals.get(currSid);
|
|
177
|
+
if (!g) break;
|
|
178
|
+
|
|
179
|
+
crumbs.unshift({
|
|
180
|
+
sessionId: currSid,
|
|
181
|
+
id: g.id,
|
|
182
|
+
title: g.title,
|
|
183
|
+
state: g.state,
|
|
184
|
+
isSubGoal: Boolean(g.parentGoalId || g.isSubGoal),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
currSid = g.parentSessionId;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (crumbs.length > 0) {
|
|
191
|
+
crumbs[crumbs.length - 1].isCurrent = true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return crumbs;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get list of child sub-goals for a given parent session
|
|
199
|
+
* @param {object} engine - GoalEngine instance
|
|
200
|
+
* @param {string} [sessionId='default']
|
|
201
|
+
* @returns {Array<object>}
|
|
202
|
+
*/
|
|
203
|
+
export function getSubGoalsList(engine, sessionId = 'default') {
|
|
204
|
+
if (!engine || !engine.goals) return [];
|
|
205
|
+
const g = engine.goals.get(sessionId || 'default');
|
|
206
|
+
return Array.isArray(g?.subGoals) ? g.subGoals : [];
|
|
207
|
+
}
|