@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 +8 -0
- package/README.ru.md +1 -0
- package/README.zh.md +1 -0
- package/lib/client.js +52 -6
- package/lib/engine-milestones.js +6 -0
- package/lib/engine-verification.js +299 -0
- package/lib/goal-engine.js +13 -9
- package/lib/index.js +2 -0
- package/lib/milestone-manager.js +54 -3
- package/lib/tools.js +38 -1
- package/package.json +6 -6
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
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
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
|
|
package/lib/engine-milestones.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/goal-engine.js
CHANGED
|
@@ -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 });
|
package/lib/milestone-manager.js
CHANGED
|
@@ -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, {
|
|
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.
|
|
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
|
+
}
|