@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.
@@ -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
+ }
@@ -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 +
@@ -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