@goodandready/dsh-goal 0.2.29 → 0.2.31
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 +685 -32
- package/lib/engine-diff.js +147 -0
- package/lib/engine-kanban-sync.js +242 -0
- package/lib/engine-operator-query.js +161 -0
- package/lib/engine-prompt.js +12 -0
- package/lib/engine-templates.js +15 -2
- package/lib/goal-engine-constants.js +4 -0
- package/lib/goal-engine.js +52 -49
- package/lib/routes.js +70 -1
- package/lib/tools.js +65 -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
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { GoalState } from './goal-engine-constants.js';
|
|
2
|
+
import { recordActivity } from './engine-intervention.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Ask the human operator a question when encountering ambiguity or critical decisions (#166).
|
|
6
|
+
* Sets the goal into a waiting/paused state and records the query.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} goal
|
|
9
|
+
* @param {object} query
|
|
10
|
+
* @param {string} query.question - Question text
|
|
11
|
+
* @param {string[]} [query.options] - Predefined choices for the operator
|
|
12
|
+
* @param {boolean} [query.isBlocking=true] - Whether to pause execution until answered
|
|
13
|
+
* @returns {object} result
|
|
14
|
+
*/
|
|
15
|
+
export function askOperatorQuery(goal, query = {}) {
|
|
16
|
+
if (!goal) {
|
|
17
|
+
return { ok: false, error: 'No active goal' };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const rawQ = query.question ? String(query.question).trim() : '';
|
|
21
|
+
if (!rawQ) {
|
|
22
|
+
return { ok: false, error: 'Question text is required' };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const cleanOptions = Array.isArray(query.options)
|
|
26
|
+
? query.options.map((o) => String(o).trim()).filter(Boolean)
|
|
27
|
+
: [];
|
|
28
|
+
|
|
29
|
+
const isBlocking = query.isBlocking !== false;
|
|
30
|
+
|
|
31
|
+
const queryId = `opq-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
32
|
+
goal.operatorQuery = {
|
|
33
|
+
id: queryId,
|
|
34
|
+
question: rawQ,
|
|
35
|
+
options: cleanOptions,
|
|
36
|
+
isBlocking,
|
|
37
|
+
askedAt: Date.now(),
|
|
38
|
+
answeredAt: null,
|
|
39
|
+
answer: null,
|
|
40
|
+
optionIndex: null,
|
|
41
|
+
consumed: false,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
goal.waitingForOperator = true;
|
|
45
|
+
|
|
46
|
+
if (isBlocking) {
|
|
47
|
+
if (!goal.previousStateBeforeOperator) {
|
|
48
|
+
goal.previousStateBeforeOperator = goal.state || GoalState.RUNNING;
|
|
49
|
+
}
|
|
50
|
+
goal.state = GoalState.PAUSED;
|
|
51
|
+
if (!goal.pausedAt) {
|
|
52
|
+
goal.pausedAt = Date.now();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
recordActivity(goal, {
|
|
57
|
+
type: 'operator_query',
|
|
58
|
+
tool: 'goal_ask_operator',
|
|
59
|
+
status: 'pending',
|
|
60
|
+
details: `Asked operator: "${rawQ.slice(0, 80)}"`,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (!Array.isArray(goal.logs)) goal.logs = [];
|
|
64
|
+
goal.logs.push({
|
|
65
|
+
timestamp: Date.now(),
|
|
66
|
+
type: 'warning',
|
|
67
|
+
message: `Goal awaiting operator input: "${rawQ}"`,
|
|
68
|
+
});
|
|
69
|
+
if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
|
|
70
|
+
|
|
71
|
+
return { ok: true, queryId, question: rawQ, options: cleanOptions, isBlocking };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Record operator response to a pending query and resume goal execution (#166).
|
|
76
|
+
*
|
|
77
|
+
* @param {object} goal
|
|
78
|
+
* @param {object} payload
|
|
79
|
+
* @param {string} [payload.answer] - Freeform text answer
|
|
80
|
+
* @param {number} [payload.optionIndex] - Index of selected choice in options array
|
|
81
|
+
* @returns {object} result
|
|
82
|
+
*/
|
|
83
|
+
export function answerOperatorQuery(goal, payload = {}) {
|
|
84
|
+
if (!goal || !goal.operatorQuery || !goal.waitingForOperator) {
|
|
85
|
+
return { ok: false, error: 'No pending operator query for this goal' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let finalAnswer = typeof payload.answer === 'string' ? payload.answer.trim() : '';
|
|
89
|
+
const optIdx = typeof payload.optionIndex === 'number' ? payload.optionIndex : null;
|
|
90
|
+
|
|
91
|
+
if (optIdx !== null && Array.isArray(goal.operatorQuery.options) && goal.operatorQuery.options[optIdx]) {
|
|
92
|
+
finalAnswer = goal.operatorQuery.options[optIdx];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!finalAnswer) {
|
|
96
|
+
return { ok: false, error: 'Answer or valid option selection is required' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
goal.operatorQuery.answeredAt = Date.now();
|
|
100
|
+
goal.operatorQuery.answer = finalAnswer;
|
|
101
|
+
goal.operatorQuery.optionIndex = optIdx;
|
|
102
|
+
goal.operatorQuery.consumed = false;
|
|
103
|
+
goal.waitingForOperator = false;
|
|
104
|
+
|
|
105
|
+
// Unpause if goal was paused for operator
|
|
106
|
+
if (goal.pausedAt) {
|
|
107
|
+
const pauseMs = Math.max(0, Date.now() - goal.pausedAt);
|
|
108
|
+
goal.totalPausedDurationMs = (goal.totalPausedDurationMs || 0) + pauseMs;
|
|
109
|
+
goal.pausedAt = null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
goal.state = goal.previousStateBeforeOperator || GoalState.RUNNING;
|
|
113
|
+
delete goal.previousStateBeforeOperator;
|
|
114
|
+
|
|
115
|
+
recordActivity(goal, {
|
|
116
|
+
type: 'operator_answer',
|
|
117
|
+
tool: 'goal_ask_operator',
|
|
118
|
+
status: 'success',
|
|
119
|
+
details: `Operator replied: "${finalAnswer.slice(0, 80)}"`,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (!Array.isArray(goal.logs)) goal.logs = [];
|
|
123
|
+
goal.logs.push({
|
|
124
|
+
timestamp: Date.now(),
|
|
125
|
+
type: 'info',
|
|
126
|
+
message: `Operator answered: "${finalAnswer}"`,
|
|
127
|
+
});
|
|
128
|
+
if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
|
|
129
|
+
|
|
130
|
+
return { ok: true, answer: finalAnswer };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Format prompt injection with operator's answer for the next turn.
|
|
135
|
+
* Marks the answer as consumed after formatting.
|
|
136
|
+
*
|
|
137
|
+
* @param {object} goal
|
|
138
|
+
* @param {string} [lang='en']
|
|
139
|
+
* @returns {string} formatted prompt snippet or empty string
|
|
140
|
+
*/
|
|
141
|
+
export function formatOperatorAnswerPrompt(goal, lang = 'en') {
|
|
142
|
+
if (!goal || !goal.operatorQuery || !goal.operatorQuery.answer || goal.operatorQuery.consumed) {
|
|
143
|
+
return '';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const { question, answer } = goal.operatorQuery;
|
|
147
|
+
// Mark consumed so it is only injected on the resume turn
|
|
148
|
+
goal.operatorQuery.consumed = true;
|
|
149
|
+
|
|
150
|
+
if (lang === 'zh') {
|
|
151
|
+
return `\n\n[操作员答复 / OPERATOR RESPONSE]\n` +
|
|
152
|
+
`你之前提出的问题: "${question}"\n` +
|
|
153
|
+
`操作员给出的明确指令: "${answer}"\n` +
|
|
154
|
+
`请严格根据操作员的答复继续推进任务,切勿再对此问题做无谓猜测!\n`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return `\n\n[OPERATOR RESPONSE TO YOUR QUERY]\n` +
|
|
158
|
+
`Your Question: "${question}"\n` +
|
|
159
|
+
`Operator Answer: "${answer}"\n` +
|
|
160
|
+
`Strictly follow the operator's response and proceed with executing the goal without second-guessing!\n`;
|
|
161
|
+
}
|
package/lib/engine-prompt.js
CHANGED
|
@@ -9,6 +9,16 @@ export function buildStatePromptInjection(snapshot, pendingNudge) {
|
|
|
9
9
|
const hasMilestones = snapshot.milestones && snapshot.milestones.length > 0;
|
|
10
10
|
const etaText = snapshot.formattedETA ? ` (ETA: ${snapshot.formattedETA})` : '';
|
|
11
11
|
|
|
12
|
+
let operatorAnswerText = '';
|
|
13
|
+
if (snapshot.operatorQuery?.answer && !snapshot.operatorQuery.consumed) {
|
|
14
|
+
snapshot.operatorQuery.consumed = true;
|
|
15
|
+
if (lang === 'zh') {
|
|
16
|
+
operatorAnswerText = `\n\n[操作员答复 / OPERATOR RESPONSE]\n你之前提出的问题: "${snapshot.operatorQuery.question}"\n操作员给出的明确指令: "${snapshot.operatorQuery.answer}"\n请严格根据操作员的答复继续推进任务,切勿再对此问题做无谓猜测!\n`;
|
|
17
|
+
} else {
|
|
18
|
+
operatorAnswerText = `\n\n[OPERATOR RESPONSE TO YOUR QUERY]\nYour Question: "${snapshot.operatorQuery.question}"\nOperator Answer: "${snapshot.operatorQuery.answer}"\nStrictly follow the operator's response and proceed with executing the goal without second-guessing!\n`;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
let directiveText = '';
|
|
13
23
|
if (snapshot.userDirective && !snapshot.userDirectiveConsumed) {
|
|
14
24
|
if (lang === 'zh') {
|
|
@@ -67,6 +77,7 @@ export function buildStatePromptInjection(snapshot, pendingNudge) {
|
|
|
67
77
|
: ' (工作计划尚未建立 — 请立即调用 goal_set_milestones 设定初始里程碑!)';
|
|
68
78
|
|
|
69
79
|
return (
|
|
80
|
+
operatorAnswerText +
|
|
70
81
|
directiveText +
|
|
71
82
|
nudgeText +
|
|
72
83
|
budgetWarningText +
|
|
@@ -87,6 +98,7 @@ export function buildStatePromptInjection(snapshot, pendingNudge) {
|
|
|
87
98
|
: ' (Work plan is not yet established — call goal_set_milestones immediately with initial steps!)';
|
|
88
99
|
|
|
89
100
|
return (
|
|
101
|
+
operatorAnswerText +
|
|
90
102
|
directiveText +
|
|
91
103
|
nudgeText +
|
|
92
104
|
budgetWarningText +
|
package/lib/engine-templates.js
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format template string by replacing {{key}} with provided variable value (#165)
|
|
3
|
+
* @param {string} templateStr
|
|
4
|
+
* @param {object} vars
|
|
5
|
+
* @returns {string}
|
|
6
|
+
*/
|
|
7
|
+
export function formatTemplateVariables(templateStr, vars = {}) {
|
|
8
|
+
if (!templateStr || typeof templateStr !== "string") return "";
|
|
9
|
+
return templateStr.replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, (_, key) => {
|
|
10
|
+
return vars && vars[key] != null && vars[key] !== "" ? String(vars[key]) : "";
|
|
11
|
+
}).trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
1
14
|
import { MilestoneStatus } from './goal-engine-constants.js';
|
|
2
15
|
|
|
3
16
|
/**
|
|
@@ -93,8 +106,8 @@ export function instantiateTemplate(templateId, inputValues = {}) {
|
|
|
93
106
|
const tpl = GOAL_TEMPLATES.find((t) => t.id === templateId);
|
|
94
107
|
if (!tpl) return null;
|
|
95
108
|
|
|
96
|
-
let title = tpl.title;
|
|
97
|
-
let description = tpl.description;
|
|
109
|
+
let title = tpl.promptTemplate ? formatTemplateVariables(tpl.promptTemplate, inputValues) : tpl.title;
|
|
110
|
+
let description = tpl.descriptionTemplate ? formatTemplateVariables(tpl.descriptionTemplate, inputValues) : tpl.description;
|
|
98
111
|
if (inputValues.targetFile) {
|
|
99
112
|
title = `Refactor ${inputValues.targetFile}`;
|
|
100
113
|
description = `Decompose ${inputValues.targetFile} to < ${inputValues.lineThreshold || 500} lines`;
|
|
@@ -192,6 +192,8 @@ export function buildSnapshot(goal, sid, engine) {
|
|
|
192
192
|
enableMilestoneDependencies: engine.enableMilestoneDependencies ?? true,
|
|
193
193
|
soundScheme: engine.soundScheme ?? 'default',
|
|
194
194
|
enableVoiceAnnouncements: engine.enableVoiceAnnouncements ?? false,
|
|
195
|
+
operatorQuery: null,
|
|
196
|
+
waitingForOperator: false,
|
|
195
197
|
};
|
|
196
198
|
}
|
|
197
199
|
|
|
@@ -251,6 +253,8 @@ export function buildSnapshot(goal, sid, engine) {
|
|
|
251
253
|
enableMilestoneDependencies: engine.enableMilestoneDependencies ?? true,
|
|
252
254
|
soundScheme: engine.soundScheme ?? 'default',
|
|
253
255
|
enableVoiceAnnouncements: engine.enableVoiceAnnouncements ?? false,
|
|
256
|
+
operatorQuery: goal.operatorQuery || null,
|
|
257
|
+
waitingForOperator: Boolean(goal.waitingForOperator),
|
|
254
258
|
};
|
|
255
259
|
}
|
|
256
260
|
|