@goodandready/dsh-goal 0.2.27 → 0.2.29

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/README.md CHANGED
@@ -137,6 +137,14 @@ Handles `/goal` commands and subcommands:
137
137
  * **Plugin Settings Card**: Schemastery-backed settings UI registered via `settings.plugin.item`.
138
138
 
139
139
  ### 5. Two-Phase Goal Execution: Plan Mode ⇄ Act Mode (#162)
140
+
141
+ ### 6. Acceptance Gate & Critic Verification (#163)
142
+ * **Automated Phase Verification**: Attach an optional `verificationCommand` (e.g. `npm test`, `pytest`, `cargo test`) to any milestone.
143
+ * **Failure Blocking & Diagnostic Feedback**: When the agent attempts to complete a milestone via `goal_milestones action: 'complete'` or `goal_update_progress status: 'completed'`, the command executes inside confined `safeCwd`. If tests fail (`exitCode !== 0`), completion is blocked, the milestone remains open, and diagnostic failure output is returned as actionable feedback to the agent.
144
+ * **Atomic Completion & Snapshots**: Git checkpoint snapshotting and issue checklist synchronization only trigger once the verification command passes with exit code 0.
145
+ * **UI Visual Badges**: Visual indicator `🛡️ <command>` and failure alerts rendered in the Goal Details modal.
146
+
147
+
140
148
  * **Plan Mode (Read-Only Planning Phase)**: The agent explores the codebase, searches the web, and decomposes tasks into structured milestones and checklists. All file mutating tools (`write_to_file`, `replace_file_content`, mutating shell commands) are strictly blocked by `lib/tool-guard.js`.
141
149
  * **Act Mode (Implementation Phase)**: The operator reviews and approves the plan directly via the banner or details modal with «Approve & Execute», unlocking mutating capabilities and resuming the agent.
142
150
  * **REST API & Quick Launch**: Toggle modes on demand via `POST /dsh-goal/action` (`set_mode`, `approve_plan`), or select initial mode directly in the Quick Launch dialog.
package/README.ru.md CHANGED
@@ -54,6 +54,7 @@
54
54
  * 🔄 **Перехват и замещение инструментов ядра DSH (*Добавлено в v0.1.10*)**: Бесшовная совместимость для моделей, вызывающих стандартные инструменты DSH (`update_goal`, `get_goal`, `create_goal`). Перехватывает действия (`complete`, `pause`, `resume`, `edit`, `blocked`), устраняет падения ядра (`complete and blocked require a direct human turn`) и синхронизирует состояние напрямую с GoalEngine.
55
55
  * 🛡️ **Защитные лимиты**: настраиваемое ограничение `maxIterations` для предотвращения бесконечных циклов.
56
56
  * 🛡️ **Двухфазный режим «Plan Mode ⇄ Act Mode» (*#162*)**: Разделение жизненного цикла цели на фазу проектирования и планирования (только чтение и формирование этапов через `goal_milestones`, блокировка записи файлов и мутирующих команд) и фазу реализации (`Act Mode`), активируемую по кнопке «Утвердить план и запустить» (Approve & Execute).
57
+ * 🛡️ **Автоматический приёмочный гейт этапа (Critic Verification) (*#163*)**: Опциональное поле `verificationCommand` на этапе (например, `npm test`, `pytest`). При попытке закрыть этап команда автоматически запускается в изолированном `safeCwd`. В случае сбоя (`exitCode != 0`) этап остаётся открытым, а диагностический вывод возвращается модели в качестве обратной связи. Git-снимки и синхронизация задач создаются только при коде возврата 0.
57
58
 
58
59
  ---
59
60
 
package/README.zh.md CHANGED
@@ -55,6 +55,7 @@
55
55
  * 🛡️ **安全防护机制**:支持自定义最大迭代次数(`maxIterations`)以及智能卡顿检测(Smart Progress Guard)。
56
56
  * 🔔 **Web Audio 提示音**:任务完成或失败时,通过 Web Audio API 播放舒缓的合成音效。
57
57
  * 📋 **两阶段目标模式:Plan Mode ⇄ Act Mode (*#162*)**:分离规划设计与落地执行。只读规划阶段严格限制文件变更与危险命令,待操作者在横幅或弹窗点击“批准并执行 (Approve & Execute)”后切入执行阶段。
58
+ * 🛡️ **自动化验收门禁与评审检验 (Critic Verification) (*#163*)**:里程碑可配置 `verificationCommand` 验证命令(如 `npm test`、`pytest`)。智能体尝试关闭阶段时,在受控 `safeCwd` 中自动执行检查;若失败(`exitCode != 0`)则阻断关闭并向模型反馈错误输出,仅当命令返回 0 时方可进入完成态并创建 Git 检查点。
58
59
 
59
60
  ---
60
61
 
package/lib/client.js CHANGED
@@ -1640,6 +1640,29 @@ window.__ModuleLoader__.load({
1640
1640
  margin-left: 6px;
1641
1641
  }
1642
1642
 
1643
+ .dsh-goal-gate-badge {
1644
+ font-size: 10px;
1645
+ padding: 1px 6px;
1646
+ border-radius: 4px;
1647
+ background: var(--dsw-alias-bg-layer-4);
1648
+ color: var(--dsw-alias-brand-primary);
1649
+ border: 1px solid var(--dsw-alias-brand-primary);
1650
+ margin-left: 6px;
1651
+ display: inline-flex;
1652
+ align-items: center;
1653
+ gap: 3px;
1654
+ }
1655
+
1656
+ .dsh-goal-gate-error {
1657
+ font-size: 11px;
1658
+ color: var(--dsw-alias-state-danger-primary);
1659
+ margin-top: 3px;
1660
+ padding: 2px 6px;
1661
+ border-radius: 3px;
1662
+ background: var(--dsw-alias-bg-layer-2);
1663
+ font-family: monospace;
1664
+ }
1665
+
1643
1666
  `;
1644
1667
 
1645
1668
  function injectStylesOnce() {
@@ -2378,16 +2401,33 @@ window.__ModuleLoader__.load({
2378
2401
  },
2379
2402
 
2380
2403
  `${i + 1}. ${m.title}`,
2381
- Array.isArray(m.dependsOn) && m.dependsOn.some((depId) => {
2382
- const prereq = milestones.find((x) => x.id === depId || x.title === depId);
2383
- return prereq && prereq.status !== 'completed';
2384
- })
2385
- ? React.createElement('span', { className: 'dsh-goal-lock-badge' }, `🔒 ${t('waitingPrereq') || 'waiting for prereq'}`)
2386
- : null,
2404
+ m.verificationCommand
2405
+ ? React.createElement(
2406
+ 'span',
2407
+ {
2408
+ className: 'dsh-goal-gate-badge',
2409
+ title: `${t('verificationGate') || 'Acceptance gate'}: ${m.verificationCommand}`,
2410
+ },
2411
+ `🛡️ ${m.verificationCommand}`
2412
+ )
2413
+ : null,
2414
+ Array.isArray(m.dependsOn) && m.dependsOn.some((depId) => {
2415
+ const prereq = milestones.find((x) => x.id === depId || x.title === depId);
2416
+ return prereq && prereq.status !== 'completed';
2417
+ })
2418
+ ? React.createElement('span', { className: 'dsh-goal-lock-badge' }, `🔒 ${t('waitingPrereq') || 'waiting for prereq'}`)
2419
+ : null,
2387
2420
 
2388
2421
  ),
2389
2422
 
2390
2423
  m.notes ? React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)', marginTop: 2 } }, m.notes) : null,
2424
+ m.verificationError
2425
+ ? React.createElement(
2426
+ 'div',
2427
+ { className: 'dsh-goal-gate-error' },
2428
+ `⚠️ ${t('verificationFailed') || 'Gate failed'}: ${m.verificationError}`
2429
+ )
2430
+ : null,
2391
2431
 
2392
2432
  Array.isArray(m.checklist) && m.checklist.length > 0
2393
2433
  ? React.createElement(
@@ -5041,6 +5081,9 @@ window.__ModuleLoader__.load({
5041
5081
  mergeBranch: 'Merge Branch',
5042
5082
  discardBranch: 'Discard Branch',
5043
5083
  retrospectiveTitle: 'Goal Retrospective',
5084
+ verificationGate: 'Acceptance gate',
5085
+ verificationPassed: 'Verified',
5086
+ verificationFailed: 'Gate failed',
5044
5087
  templatesTitle: 'Goal Templates',
5045
5088
  waitingPrereq: 'waiting for prereq',
5046
5089
 
@@ -5189,6 +5232,9 @@ window.__ModuleLoader__.load({
5189
5232
  mergeBranch: '合并分支',
5190
5233
  discardBranch: '放弃分支',
5191
5234
  retrospectiveTitle: '目标复盘报告',
5235
+ verificationGate: '验收门禁',
5236
+ verificationPassed: '已验证',
5237
+ verificationFailed: '门禁未通过',
5192
5238
  templatesTitle: '目标模板',
5193
5239
  waitingPrereq: '等待前置依赖',
5194
5240
 
@@ -74,11 +74,13 @@ export function parseMilestoneItems(milestonesList, startingCount = 0) {
74
74
 
75
75
  count += 1;
76
76
  const mId = typeof item === 'object' && item?.id ? item.id : ('m-' + count);
77
+ const vCmd = typeof item === 'object' && (item?.verificationCommand || item?.verification_command);
77
78
  const mObj = {
78
79
  id: String(mId),
79
80
  title: itemTitle.trim(),
80
81
  status: typeof item === 'object' && item?.status ? item.status : MilestoneStatus.PENDING,
81
82
  notes: typeof item === 'object' && item?.notes ? String(item.notes) : '',
83
+ verificationCommand: vCmd ? String(vCmd).trim() : null,
82
84
  dependsOn: typeof item === 'object' && Array.isArray(item?.dependsOn) ? item.dependsOn.map(String) : [],
83
85
  };
84
86
  if (typeof item === 'object' && Array.isArray(item?.checklist)) {
@@ -116,6 +118,10 @@ export function applyMilestoneUpdate(target, status, notes = '', checklist = nul
116
118
  }
117
119
  }
118
120
 
121
+ if (options.verificationCommand !== undefined) {
122
+ target.verificationCommand = options.verificationCommand ? String(options.verificationCommand).trim() : null;
123
+ }
124
+
119
125
  if (status) {
120
126
  if (validStatuses.length > 0 && !validStatuses.includes(status)) {
121
127
  return {
@@ -0,0 +1,299 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { getSafeCwd } from './goal-engine-constants.js';
3
+
4
+ export const SAFE_ENV_WHITELIST = [
5
+ 'PATH',
6
+ 'HOME',
7
+ 'USER',
8
+ 'LOGNAME',
9
+ 'LANG',
10
+ 'LC_ALL',
11
+ 'LC_CTYPE',
12
+ 'SHELL',
13
+ 'TERM',
14
+ 'TMPDIR',
15
+ 'TMP',
16
+ 'TEMP',
17
+ 'NODE_ENV',
18
+ ];
19
+
20
+ const FORBIDDEN_SECRET_REGEX = /(TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|CREDENTIAL|PRIVATE|BEARER|API)/i;
21
+
22
+ export const DISALLOWED_BINARIES = new Set([
23
+ 'curl', 'wget', 'nc', 'ncat', 'netcat', 'telnet',
24
+ 'ssh', 'scp', 'sftp', 'ftp', 'rsync',
25
+ 'rm', 'dd', 'mkfs', 'shutdown', 'reboot', 'killall', 'kill',
26
+ ]);
27
+
28
+ export const SHELL_OPERATORS = new Set([';', '&&', '||', '|', '&', '>', '>>', '<', '`']);
29
+
30
+ /**
31
+ * Sanitize environment for child verification processes, strictly stripping tokens and secrets
32
+ * @param {object} [userEnv={}]
33
+ * @returns {object}
34
+ */
35
+ export function sanitizeVerificationEnv(userEnv = {}) {
36
+ const clean = {};
37
+ for (const key of SAFE_ENV_WHITELIST) {
38
+ if (process.env[key] !== undefined && !FORBIDDEN_SECRET_REGEX.test(key)) {
39
+ clean[key] = process.env[key];
40
+ }
41
+ }
42
+ if (userEnv && typeof userEnv === 'object') {
43
+ for (const [k, v] of Object.entries(userEnv)) {
44
+ if (k && !FORBIDDEN_SECRET_REGEX.test(k) && v !== undefined && v !== null) {
45
+ clean[k] = String(v);
46
+ }
47
+ }
48
+ }
49
+ if (!clean.NODE_ENV) {
50
+ clean.NODE_ENV = 'test';
51
+ }
52
+ return clean;
53
+ }
54
+
55
+ /**
56
+ * Parse a command string or array into an argv list
57
+ * @param {string|string[]} commandStr
58
+ * @returns {string[]}
59
+ */
60
+ export function parseCommandArgv(commandStr) {
61
+ if (Array.isArray(commandStr)) {
62
+ return commandStr.map(String).filter((s) => s.trim().length > 0);
63
+ }
64
+ if (!commandStr || typeof commandStr !== 'string') return [];
65
+ const trimmed = commandStr.trim();
66
+ if (!trimmed) return [];
67
+
68
+ const regex = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g;
69
+ const args = [];
70
+ let match;
71
+ while ((match = regex.exec(trimmed)) !== null) {
72
+ if (match[1] !== undefined) {
73
+ args.push(match[1].replace(/\\"/g, '"'));
74
+ } else if (match[2] !== undefined) {
75
+ args.push(match[2].replace(/\\'/g, "'"));
76
+ } else if (match[3] !== undefined) {
77
+ args.push(match[3]);
78
+ }
79
+ }
80
+ return args;
81
+ }
82
+
83
+ /**
84
+ * Validate a verification command for dangerous operators, shell chaining, or exfiltration binaries
85
+ * @param {string|string[]} command
86
+ * @returns {{ valid: boolean, error?: string, argv?: string[], binary?: string }}
87
+ */
88
+ export function validateVerificationCommand(command) {
89
+ if (!command) return { valid: false, error: 'Empty or invalid verification command' };
90
+ const argv = parseCommandArgv(command);
91
+ if (argv.length === 0) return { valid: false, error: 'Empty or invalid verification command' };
92
+
93
+ const rawBinary = argv[0].toLowerCase().replace(/^.*[/\\]/, '');
94
+ if (DISALLOWED_BINARIES.has(rawBinary)) {
95
+ return { valid: false, error: `Execution blocked: disallowed command binary "${rawBinary}"` };
96
+ }
97
+
98
+ for (let i = 0; i < argv.length; i++) {
99
+ const arg = argv[i];
100
+ if (SHELL_OPERATORS.has(arg)) {
101
+ return { valid: false, error: `Execution blocked: shell operator "${arg}" is not allowed` };
102
+ }
103
+ if (arg.startsWith('$(') || (arg.startsWith('`') && arg.endsWith('`'))) {
104
+ return { valid: false, error: 'Execution blocked: subshell substitution is not allowed' };
105
+ }
106
+ if (i > 0 && DISALLOWED_BINARIES.has(arg.toLowerCase().replace(/^.*[/\\]/, ''))) {
107
+ return { valid: false, error: `Execution blocked: disallowed binary "${arg}" in arguments` };
108
+ }
109
+ }
110
+
111
+ return { valid: true, argv, binary: rawBinary };
112
+ }
113
+
114
+ /**
115
+ * Execute a verification command in a confined working directory with timeout and output capture
116
+ * Uses sanitized environment (no secrets/tokens) and shell: false (no shell injection)
117
+ * @param {string|string[]} command Command to execute
118
+ * @param {string} [targetCwd] Working directory
119
+ * @param {object} [options]
120
+ * @param {number} [options.timeoutMs=30000] Timeout in milliseconds
121
+ * @param {number} [options.maxBuffer=1048576] Max stdout/stderr buffer in bytes (1MB)
122
+ * @param {object} [options.env={}] Additional environment variables (sanitized)
123
+ * @param {Function} [options.checkApproval] Custom approval check function
124
+ * @param {object} [options.approvalGate] ApprovalGate service instance
125
+ * @returns {{ ok: boolean, exitCode: number, stdout: string, stderr: string, error?: string }}
126
+ */
127
+ export function runMilestoneVerification(command, targetCwd, options = {}) {
128
+ const validation = validateVerificationCommand(command);
129
+ if (!validation.valid) {
130
+ return {
131
+ ok: false,
132
+ exitCode: 1,
133
+ stdout: '',
134
+ stderr: '',
135
+ error: validation.error || 'Empty or invalid verification command',
136
+ };
137
+ }
138
+
139
+ const safeCwd = getSafeCwd(targetCwd) || process.cwd();
140
+ const timeoutMs = typeof options.timeoutMs === 'number' ? options.timeoutMs : 30000;
141
+ const maxBuffer = typeof options.maxBuffer === 'number' ? options.maxBuffer : 1024 * 1024;
142
+ const { argv, binary } = validation;
143
+
144
+ // 1. Approval seam check
145
+ if (typeof options.checkApproval === 'function') {
146
+ const decision = options.checkApproval({
147
+ command,
148
+ argv,
149
+ binary,
150
+ cwd: safeCwd,
151
+ });
152
+ if (decision && (decision.kind === 'deny' || decision.deny)) {
153
+ return {
154
+ ok: false,
155
+ exitCode: 1,
156
+ stdout: '',
157
+ stderr: '',
158
+ error: `Verification command denied by policy: ${decision.reason || 'denied'}`,
159
+ };
160
+ }
161
+ if (decision && (decision.kind === 'ask' || decision.ask)) {
162
+ return {
163
+ ok: false,
164
+ exitCode: 1,
165
+ stdout: '',
166
+ stderr: '',
167
+ error: `Verification command requires operator approval: ${decision.reason || 'approval required'}`,
168
+ };
169
+ }
170
+ }
171
+
172
+ if (options.approvalGate && typeof options.approvalGate.checkCommand === 'function') {
173
+ try {
174
+ const decision = options.approvalGate.checkCommand(command, safeCwd);
175
+ if (decision && (decision.verdict === 'deny' || decision.verdict === 'ask')) {
176
+ return {
177
+ ok: false,
178
+ exitCode: 1,
179
+ stdout: '',
180
+ stderr: '',
181
+ error: `Verification command blocked by approval gate: ${decision.reason || decision.verdict}`,
182
+ };
183
+ }
184
+ } catch (err) {
185
+ return {
186
+ ok: false,
187
+ exitCode: 1,
188
+ stdout: '',
189
+ stderr: '',
190
+ error: `Approval check error: ${err?.message || err}`,
191
+ };
192
+ }
193
+ }
194
+
195
+ // 2. Environment sanitization (strictly scrub secrets and tokens)
196
+ const cleanEnv = sanitizeVerificationEnv(options.env);
197
+
198
+ // 3. Execution without shell (shell: false)
199
+ try {
200
+ const res = spawnSync(argv[0], argv.slice(1), {
201
+ shell: false,
202
+ cwd: safeCwd,
203
+ timeout: timeoutMs,
204
+ maxBuffer,
205
+ encoding: 'utf8',
206
+ env: cleanEnv,
207
+ });
208
+
209
+ if (res.error) {
210
+ if (res.error.code === 'ETIMEDOUT') {
211
+ return {
212
+ ok: false,
213
+ exitCode: 124,
214
+ stdout: res.stdout || '',
215
+ stderr: res.stderr || '',
216
+ error: `Verification command timed out after ${timeoutMs}ms`,
217
+ };
218
+ }
219
+ return {
220
+ ok: false,
221
+ exitCode: res.status ?? 1,
222
+ stdout: res.stdout || '',
223
+ stderr: res.stderr || '',
224
+ error: res.error.message || 'Verification command failed to execute',
225
+ };
226
+ }
227
+
228
+ if (res.status !== 0) {
229
+ const output = (res.stderr || res.stdout || '').trim();
230
+ return {
231
+ ok: false,
232
+ exitCode: res.status ?? 1,
233
+ stdout: res.stdout || '',
234
+ stderr: res.stderr || '',
235
+ error: `Verification command failed with exit code ${res.status}${output ? ': ' + output.slice(0, 500) : ''}`,
236
+ };
237
+ }
238
+
239
+ return {
240
+ ok: true,
241
+ exitCode: 0,
242
+ stdout: res.stdout || '',
243
+ stderr: res.stderr || '',
244
+ };
245
+ } catch (err) {
246
+ return {
247
+ ok: false,
248
+ exitCode: 1,
249
+ stdout: '',
250
+ stderr: '',
251
+ error: err?.message || 'Unexpected verification failure',
252
+ };
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Run acceptance gate check on a milestone target and record diagnostic logs/activities
258
+ * @param {object} target Milestone object
259
+ * @param {string} targetCwd Working directory
260
+ * @param {object} [goal] Goal engine state
261
+ * @param {Function} [recordActivity] Activity recorder function
262
+ * @param {object} [options]
263
+ * @returns {{ ok: boolean, verified: boolean, error?: string, output?: string, passedAt?: number }}
264
+ */
265
+ export function verifyMilestoneAcceptance(target, targetCwd, goal = null, recordActivity = null, options = {}) {
266
+ if (!target || !target.verificationCommand) {
267
+ return { ok: true, verified: false };
268
+ }
269
+
270
+ const vRes = runMilestoneVerification(target.verificationCommand, targetCwd, options);
271
+ if (!vRes.ok) {
272
+ target.verificationError = vRes.error;
273
+ target.verificationOutput = (vRes.stderr || vRes.stdout || '').trim();
274
+ if (goal) {
275
+ if (typeof recordActivity === 'function') {
276
+ recordActivity(goal, {
277
+ type: 'milestone',
278
+ tool: 'updateMilestone',
279
+ status: 'error',
280
+ details: `Verification failed: ${vRes.error}`,
281
+ });
282
+ }
283
+ if (Array.isArray(goal.logs)) {
284
+ goal.logs.push({
285
+ timestamp: Date.now(),
286
+ type: 'error',
287
+ message: `Acceptance gate failed for [${target.title}]: ${vRes.error}`,
288
+ });
289
+ if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
290
+ }
291
+ }
292
+ return { ok: false, verified: true, error: vRes.error, output: target.verificationOutput };
293
+ }
294
+
295
+ target.verificationError = null;
296
+ target.verificationOutput = null;
297
+ target.verificationPassedAt = Date.now();
298
+ return { ok: true, verified: true, passedAt: target.verificationPassedAt };
299
+ }
@@ -1,3 +1,4 @@
1
+ import { verifyMilestoneAcceptance } from './engine-verification.js';
1
2
  import { GoalMode, GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, calculateElapsedSeconds, calculateRemainingSeconds, buildSnapshot, getSafeCwd } from './goal-engine-constants.js';
2
3
  import { getGitCurrentCommit, exportReportMarkdown, exportReportGitHubPR, createMilestoneCheckpoint, rollbackToCheckpoint, saveGoalArtifact } from './engine-reports.js';
3
4
  import { EngineStore } from './engine-store.js';
@@ -37,6 +38,8 @@ export class GoalEngine {
37
38
  this.enableMilestoneDependencies = options.enableMilestoneDependencies ?? true;
38
39
  this.soundScheme = options.soundScheme ?? 'default';
39
40
  this.enableVoiceAnnouncements = options.enableVoiceAnnouncements ?? false;
41
+ this.approvalGate = options.approvalGate || null;
42
+ this.checkApproval = options.checkApproval || null;
40
43
  this.maxSessions = options.maxSessions ?? 100;
41
44
  this.goals = new Map();
42
45
  this.listeners = new Set();
@@ -357,21 +360,22 @@ export class GoalEngine {
357
360
  const target = goal.milestones.find((m) => matchMilestone(m, id));
358
361
  if (!target) return false;
359
362
 
363
+ if (status === MilestoneStatus.COMPLETED && target.verificationCommand) {
364
+ const vRes = verifyMilestoneAcceptance(target, goal.cwd || process.cwd(), goal, recordActivity, { approvalGate: this.approvalGate, checkApproval: this.checkApproval });
365
+ if (!vRes.ok) {
366
+ this.emit(sid);
367
+ return false;
368
+ }
369
+ }
370
+
360
371
  const res = applyMilestoneUpdate(target, status, notes, checklist, Object.values(MilestoneStatus), {
361
372
  enforceDependencies: this.enableMilestoneDependencies,
362
373
  allMilestones: goal.milestones,
363
374
  });
364
375
  if (res.invalidStatus || res.blocked) {
365
- if (res.invalidStatus) {
366
- this.logger?.warn?.(`[dsh-goal] Invalid milestone status "${status}" for milestone ${id}`);
367
- }
376
+ if (res.invalidStatus) this.logger?.warn?.(`[dsh-goal] Invalid milestone status "${status}" for milestone ${id}`);
368
377
  if (res.blocked) {
369
- recordActivity(goal, {
370
- type: 'milestone',
371
- tool: 'updateMilestone',
372
- status: 'error',
373
- details: `Milestone ${id} blocked by ${res.missingDependencies?.join(', ')}`,
374
- });
378
+ recordActivity(goal, { type: 'milestone', tool: 'updateMilestone', status: 'error', details: `Milestone ${id} blocked by ${res.missingDependencies?.join(', ')}` });
375
379
  }
376
380
  return false;
377
381
  }
package/lib/index.js CHANGED
@@ -121,6 +121,7 @@ export function apply(ctx, config = {}) {
121
121
  const milestoneManager = new MilestoneManager(addonStore, {
122
122
  gitSnapshotManager,
123
123
  enforceDependencies: currentSettings.enableMilestoneDependencies,
124
+ approvalGate: safeService(ctx, "approvalGate"),
124
125
  logger,
125
126
  });
126
127
  const tokenBudgetGuard = new TokenBudgetGuard({
@@ -167,6 +168,7 @@ export function apply(ctx, config = {}) {
167
168
  autoScaleBudgetNearCompletion: currentSettings.autoScaleBudgetNearCompletion,
168
169
  enableTemplatesDrawer: currentSettings.enableTemplatesDrawer,
169
170
  enableMilestoneDependencies: currentSettings.enableMilestoneDependencies,
171
+ approvalGate: safeService(ctx, "approvalGate"),
170
172
  logger,
171
173
  });
172
174
  registerPlanModeGuard(ctx, { engine: fallbackEngine, logger });
@@ -1,13 +1,16 @@
1
1
  import { syncMilestoneWithIssue } from './engine-issue-sync.js';
2
+ import { runMilestoneVerification, validateVerificationCommand } from './engine-verification.js';
2
3
 
3
4
  /**
4
5
  * Milestone and checklist coordinator for active core goals
5
6
  */
6
7
  export class MilestoneManager {
7
- constructor(addonStore, { gitSnapshotManager = null, enforceDependencies = true, logger = console } = {}) {
8
+ constructor(addonStore, { gitSnapshotManager = null, enforceDependencies = true, approvalGate = null, checkApproval = null, logger = console } = {}) {
8
9
  this.store = addonStore;
9
10
  this.gitSnapshotManager = gitSnapshotManager;
10
11
  this.enforceDependencies = enforceDependencies;
12
+ this.approvalGate = approvalGate;
13
+ this.checkApproval = checkApproval;
11
14
  this.logger = logger;
12
15
  }
13
16
 
@@ -24,6 +27,7 @@ export class MilestoneManager {
24
27
  completed: Boolean(m.completed),
25
28
  completedAt: m.completedAt || null,
26
29
  notes: m.notes || '',
30
+ verificationCommand: m.verificationCommand || m.verification_command ? String(m.verificationCommand || m.verification_command).trim() : null,
27
31
  dependsOn: Array.isArray(m.dependsOn) ? m.dependsOn : [],
28
32
  checklist: Array.isArray(m.checklist) ? m.checklist.map((c, cIdx) => ({
29
33
  id: c.id || `cl-${idx + 1}-${cIdx + 1}`,
@@ -35,16 +39,18 @@ export class MilestoneManager {
35
39
  return sanitized;
36
40
  }
37
41
 
38
- addMilestone(goalId, { title, checklist = [], dependsOn = [] }) {
42
+ addMilestone(goalId, { title, checklist = [], dependsOn = [], verificationCommand = null, verification_command = null }) {
39
43
  if (!goalId || !this.store) throw new Error('Missing goalId or store');
40
44
  const list = this.getMilestones(goalId);
41
45
  const id = `m-${list.length + 1}`;
46
+ const vCmd = verificationCommand || verification_command;
42
47
  const newM = {
43
48
  id,
44
49
  title: title || `Milestone ${list.length + 1}`,
45
50
  completed: false,
46
51
  completedAt: null,
47
52
  notes: '',
53
+ verificationCommand: vCmd ? String(vCmd).trim() : null,
48
54
  dependsOn: Array.isArray(dependsOn) ? dependsOn : [],
49
55
  checklist: (checklist || []).map((c, cIdx) => ({
50
56
  id: `cl-${list.length + 1}-${cIdx + 1}`,
@@ -74,6 +80,23 @@ export class MilestoneManager {
74
80
  }
75
81
  }
76
82
 
83
+ // Acceptance Gate (Critic Verification) - Issue #163
84
+ if (m.verificationCommand) {
85
+ const vResult = runMilestoneVerification(m.verificationCommand, workspaceRoot, {
86
+ approvalGate: this.approvalGate,
87
+ checkApproval: this.checkApproval,
88
+ });
89
+ if (!vResult.ok) {
90
+ m.verificationError = vResult.error;
91
+ m.verificationOutput = (vResult.stderr || vResult.stdout || '').trim();
92
+ this.store.setMilestones(goalId, list);
93
+ throw new Error(`Acceptance gate failed for milestone "${milestoneId}" (${m.verificationCommand}): ${vResult.error}`);
94
+ }
95
+ m.verificationError = null;
96
+ m.verificationOutput = null;
97
+ m.verificationPassedAt = Date.now();
98
+ }
99
+
77
100
  m.completed = true;
78
101
  m.completedAt = Date.now();
79
102
  if (notes) m.notes = notes;
@@ -110,6 +133,33 @@ export class MilestoneManager {
110
133
  return { milestone: m, snapshot };
111
134
  }
112
135
 
136
+ updateMilestone(goalId, milestoneId, { status, notes = '', checklist = null, verificationCommand = null, cwd = null } = {}) {
137
+ if (!goalId || !milestoneId) throw new Error('goalId and milestoneId are required');
138
+ const list = this.getMilestones(goalId);
139
+ const m = list.find(item => item.id === milestoneId);
140
+ if (!m) return null;
141
+
142
+ if (verificationCommand !== undefined && verificationCommand !== null) {
143
+ m.verificationCommand = String(verificationCommand).trim() || null;
144
+ }
145
+
146
+ if (status === 'completed') {
147
+ return this.completeMilestone(goalId, milestoneId, notes, cwd);
148
+ }
149
+
150
+ if (status) m.status = status;
151
+ if (notes) m.notes = notes;
152
+ if (Array.isArray(checklist)) {
153
+ m.checklist = checklist.map((c, cIdx) => ({
154
+ id: typeof c === 'object' && c?.id ? c.id : `cl-${milestoneId}-${cIdx + 1}`,
155
+ text: typeof c === 'string' ? c : (c?.text || ''),
156
+ done: Boolean(c?.done),
157
+ }));
158
+ }
159
+ this.store.setMilestones(goalId, list);
160
+ return m;
161
+ }
162
+
113
163
  toggleChecklist(goalId, milestoneId, index, done) {
114
164
  if (!goalId || !milestoneId) throw new Error('goalId and milestoneId are required');
115
165
  const list = this.getMilestones(goalId);
@@ -139,7 +189,8 @@ export class MilestoneManager {
139
189
  if (uncompletedChecklist.length > 0) {
140
190
  clText = ` | Next items: ${uncompletedChecklist.map(c => `[ ] ${c.text}`).join(', ')}`;
141
191
  }
192
+ const gateText = active.verificationCommand ? ` [Gate: "${active.verificationCommand}"]` : '';
142
193
 
143
- return `[Goal Milestones]: Current step: "${active.title}"${clText}`;
194
+ return `[Goal Milestones]: Current step: "${active.title}"${gateText}${clText}`;
144
195
  }
145
196
  }
package/lib/tools.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { validateVerificationCommand } from './engine-verification.js';
1
2
  import { MilestoneManager } from './milestone-manager.js';
2
3
  import { AddonStore } from './addon-store.js';
3
4
  import { getSafeCwd } from './goal-engine-constants.js';
@@ -67,15 +68,30 @@ export function registerTools(ctx, { milestoneManager, goalsService, logger = co
67
68
  }
68
69
 
69
70
  if (action === 'set') {
71
+ if (Array.isArray(args.milestones)) {
72
+ for (const m of args.milestones) {
73
+ const vCmd = m && (m.verificationCommand || m.verification_command);
74
+ if (vCmd) {
75
+ const val = validateVerificationCommand(vCmd);
76
+ if (!val.valid) throw new Error(`Invalid verificationCommand: ${val.error}`);
77
+ }
78
+ }
79
+ }
70
80
  const list = mm.setMilestones(goalId, args.milestones);
71
81
  return { ok: true, goalId, count: list.length, milestones: list };
72
82
  }
73
83
 
74
84
  if (action === 'add') {
85
+ const vCmd = args.verificationCommand || args.verification_command;
86
+ if (vCmd) {
87
+ const val = validateVerificationCommand(vCmd);
88
+ if (!val.valid) throw new Error(`Invalid verificationCommand: ${val.error}`);
89
+ }
75
90
  const item = mm.addMilestone(goalId, {
76
91
  title: args.title,
77
92
  checklist: args.checklist,
78
93
  dependsOn: args.dependsOn || args.depends_on,
94
+ verificationCommand: vCmd,
79
95
  });
80
96
  return { ok: true, goalId, milestone: item };
81
97
  }
@@ -124,6 +140,7 @@ export function registerTools(ctx, { milestoneManager, goalsService, logger = co
124
140
  },
125
141
  },
126
142
  dependsOn: { type: 'array', items: { type: 'string' } },
143
+ verificationCommand: { type: 'string', description: 'Optional shell command to verify milestone completion' },
127
144
  },
128
145
  required: ['title'],
129
146
  },
@@ -136,6 +153,10 @@ export function registerTools(ctx, { milestoneManager, goalsService, logger = co
136
153
  items: { type: 'string' },
137
154
  description: 'Initial checklist items for added milestone',
138
155
  },
156
+ verificationCommand: {
157
+ type: 'string',
158
+ description: 'Verification command when action is "add"',
159
+ },
139
160
  },
140
161
  required: ['action'],
141
162
  },
@@ -190,6 +211,11 @@ export function registerTools(ctx, { milestoneManager, goalsService, logger = co
190
211
  if (typeof item === 'string') {
191
212
  return { id: `m-${idx + 1}`, title: item };
192
213
  }
214
+ const vCmd = item && (item.verificationCommand || item.verification_command);
215
+ if (vCmd) {
216
+ const val = validateVerificationCommand(vCmd);
217
+ if (!val.valid) throw new Error(`Invalid verificationCommand: ${val.error}`);
218
+ }
193
219
  return item;
194
220
  });
195
221
  } else {
@@ -224,13 +250,23 @@ export function registerTools(ctx, { milestoneManager, goalsService, logger = co
224
250
  const status = args.status || 'in_progress';
225
251
  const notes = typeof args.notes === 'string' ? args.notes : '';
226
252
  if (!milestoneId) throw new Error('milestone_id is required');
253
+ const vCmd = args.verificationCommand || args.verification_command;
254
+ if (vCmd) {
255
+ const val = validateVerificationCommand(vCmd);
256
+ if (!val.valid) throw new Error(`Invalid verificationCommand: ${val.error}`);
257
+ }
227
258
  if (status === 'completed') {
228
259
  const rawCwd = toolCtx?.session?.cwd || toolCtx?.cwd || process.cwd();
229
260
  const safeCwd = getSafeCwd(rawCwd);
230
261
  const completed = await mm.completeMilestone(goalId, milestoneId, notes, safeCwd);
231
262
  return { ok: true, goalId, milestoneId, status: 'completed', milestone: completed };
232
263
  }
233
- const updated = mm.updateMilestone(goalId, milestoneId, { status, notes, checklist: args.checklist });
264
+ const updated = mm.updateMilestone(goalId, milestoneId, {
265
+ status,
266
+ notes,
267
+ checklist: args.checklist,
268
+ verificationCommand: args.verificationCommand || args.verification_command,
269
+ });
234
270
  return { ok: true, goalId, milestoneId, status, milestone: updated };
235
271
  };
236
272
 
@@ -244,6 +280,7 @@ export function registerTools(ctx, { milestoneManager, goalsService, logger = co
244
280
  status: { type: 'string', enum: ['pending', 'in_progress', 'completed', 'blocked'], description: 'Milestone status' },
245
281
  notes: { type: 'string', description: 'Brief outcome or status notes' },
246
282
  checklist: { type: 'array', items: { type: 'string' }, description: 'Subtask items' },
283
+ verificationCommand: { type: 'string', description: 'Optional verification command to set or update' },
247
284
  },
248
285
  required: ['milestone_id', 'status'],
249
286
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-goal",
3
- "version": "0.2.27",
3
+ "version": "0.2.29",
4
4
  "description": "Core Goal Addon (Token Budget, Milestones, Git Snapshots & Retrospective Reports) for DeepSeek Harness",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -18,10 +18,6 @@
18
18
  "cordis.patch.yml",
19
19
  "LICENSE"
20
20
  ],
21
- "scripts": {
22
- "build:client": "node scripts/build-client.mjs",
23
- "test": "node scripts/build-client.mjs --check && NODE_ENV=test node --test test/*.test.mjs"
24
- },
25
21
  "keywords": [
26
22
  "dsh",
27
23
  "dsh-plugin",
@@ -60,5 +56,9 @@
60
56
  },
61
57
  "devDependencies": {
62
58
  "@deepseek-ai/schemastery": "^3.18.4"
59
+ },
60
+ "scripts": {
61
+ "build:client": "node scripts/build-client.mjs",
62
+ "test": "node scripts/build-client.mjs --check && NODE_ENV=test node --test test/*.test.mjs"
63
63
  }
64
- }
64
+ }