@goodandready/dsh-goal 0.2.26 → 0.2.28
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 +13 -0
- package/README.ru.md +2 -0
- package/README.zh.md +2 -0
- package/lib/client.js +235 -13
- package/lib/engine-milestones.js +6 -0
- package/lib/engine-sessions.js +78 -1
- package/lib/engine-verification.js +129 -0
- package/lib/goal-engine-constants.js +7 -0
- package/lib/goal-engine.js +44 -38
- package/lib/index.js +4 -0
- package/lib/milestone-manager.js +48 -2
- package/lib/routes.js +62 -5
- package/lib/tool-guard.js +147 -0
- package/lib/tools.js +13 -1
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -136,6 +136,19 @@ Handles `/goal` commands and subcommands:
|
|
|
136
136
|
* **Quick Launch Button**: Floating launcher for rapid goal formulation without typing slash commands.
|
|
137
137
|
* **Plugin Settings Card**: Schemastery-backed settings UI registered via `settings.plugin.item`.
|
|
138
138
|
|
|
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
|
+
|
|
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`.
|
|
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.
|
|
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.
|
|
151
|
+
|
|
139
152
|
---
|
|
140
153
|
|
|
141
154
|
## 📦 Installation
|
package/README.ru.md
CHANGED
|
@@ -53,6 +53,8 @@
|
|
|
53
53
|
* 🤖 **Инструменты агента**: `goal_set_milestones`, `goal_update_progress` и `goal_finish` для самостоятельной работы ИИ.
|
|
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
|
+
* 🛡️ **Двухфазный режим «Plan Mode ⇄ Act Mode» (*#162*)**: Разделение жизненного цикла цели на фазу проектирования и планирования (только чтение и формирование этапов через `goal_milestones`, блокировка записи файлов и мутирующих команд) и фазу реализации (`Act Mode`), активируемую по кнопке «Утвердить план и запустить» (Approve & Execute).
|
|
57
|
+
* 🛡️ **Автоматический приёмочный гейт этапа (Critic Verification) (*#163*)**: Опциональное поле `verificationCommand` на этапе (например, `npm test`, `pytest`). При попытке закрыть этап команда автоматически запускается в изолированном `safeCwd`. В случае сбоя (`exitCode != 0`) этап остаётся открытым, а диагностический вывод возвращается модели в качестве обратной связи. Git-снимки и синхронизация задач создаются только при коде возврата 0.
|
|
56
58
|
|
|
57
59
|
---
|
|
58
60
|
|
package/README.zh.md
CHANGED
|
@@ -54,6 +54,8 @@
|
|
|
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`)以及智能卡顿检测(Smart Progress Guard)。
|
|
56
56
|
* 🔔 **Web Audio 提示音**:任务完成或失败时,通过 Web Audio API 播放舒缓的合成音效。
|
|
57
|
+
* 📋 **两阶段目标模式:Plan Mode ⇄ Act Mode (*#162*)**:分离规划设计与落地执行。只读规划阶段严格限制文件变更与危险命令,待操作者在横幅或弹窗点击“批准并执行 (Approve & Execute)”后切入执行阶段。
|
|
58
|
+
* 🛡️ **自动化验收门禁与评审检验 (Critic Verification) (*#163*)**:里程碑可配置 `verificationCommand` 验证命令(如 `npm test`、`pytest`)。智能体尝试关闭阶段时,在受控 `safeCwd` 中自动执行检查;若失败(`exitCode != 0`)则阻断关闭并向模型反馈错误输出,仅当命令返回 0 时方可进入完成态并创建 Git 检查点。
|
|
57
59
|
|
|
58
60
|
---
|
|
59
61
|
|
package/lib/client.js
CHANGED
|
@@ -730,6 +730,66 @@ window.__ModuleLoader__.load({
|
|
|
730
730
|
|
|
731
731
|
}
|
|
732
732
|
|
|
733
|
+
.dsh-goal-badge-plan {
|
|
734
|
+
|
|
735
|
+
border-color: var(--dsw-alias-brand-primary);
|
|
736
|
+
|
|
737
|
+
color: var(--dsw-alias-brand-primary);
|
|
738
|
+
|
|
739
|
+
background: var(--dsw-alias-bg-hover);
|
|
740
|
+
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
.dsh-goal-badge-act {
|
|
744
|
+
|
|
745
|
+
border-color: var(--dsw-alias-state-success-primary);
|
|
746
|
+
|
|
747
|
+
color: var(--dsw-alias-state-success-primary);
|
|
748
|
+
|
|
749
|
+
background: var(--dsw-alias-bg-hover);
|
|
750
|
+
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
.dsh-goal-btn-approve {
|
|
754
|
+
|
|
755
|
+
background: var(--dsw-alias-state-success-primary) !important;
|
|
756
|
+
|
|
757
|
+
color: var(--dsw-alias-bg-layer-3) !important;
|
|
758
|
+
|
|
759
|
+
font-weight: 600 !important;
|
|
760
|
+
|
|
761
|
+
border: none !important;
|
|
762
|
+
|
|
763
|
+
cursor: pointer;
|
|
764
|
+
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
.dsh-goal-btn-approve:hover {
|
|
768
|
+
|
|
769
|
+
opacity: 0.88;
|
|
770
|
+
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
.dsh-goal-plan-banner {
|
|
774
|
+
|
|
775
|
+
display: flex;
|
|
776
|
+
|
|
777
|
+
align-items: center;
|
|
778
|
+
|
|
779
|
+
justify-content: space-between;
|
|
780
|
+
|
|
781
|
+
padding: 8px 12px;
|
|
782
|
+
|
|
783
|
+
margin-bottom: 12px;
|
|
784
|
+
|
|
785
|
+
border-radius: 6px;
|
|
786
|
+
|
|
787
|
+
background: var(--dsw-alias-bg-hover);
|
|
788
|
+
|
|
789
|
+
border: 1px solid var(--dsw-alias-brand-primary);
|
|
790
|
+
|
|
791
|
+
}
|
|
792
|
+
|
|
733
793
|
.dsh-goal-label {
|
|
734
794
|
|
|
735
795
|
font-weight: 600;
|
|
@@ -1580,6 +1640,29 @@ window.__ModuleLoader__.load({
|
|
|
1580
1640
|
margin-left: 6px;
|
|
1581
1641
|
}
|
|
1582
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
|
+
|
|
1583
1666
|
`;
|
|
1584
1667
|
|
|
1585
1668
|
function injectStylesOnce() {
|
|
@@ -1855,6 +1938,7 @@ window.__ModuleLoader__.load({
|
|
|
1855
1938
|
const milestones = state.milestones || [];
|
|
1856
1939
|
|
|
1857
1940
|
const isCompleted = state.state === 'COMPLETED';
|
|
1941
|
+
const isPlanMode = state.mode === 'plan' || state.state === 'PLANNING';
|
|
1858
1942
|
|
|
1859
1943
|
const [copied, setCopied] = useState(false);
|
|
1860
1944
|
|
|
@@ -1992,6 +2076,36 @@ window.__ModuleLoader__.load({
|
|
|
1992
2076
|
|
|
1993
2077
|
),
|
|
1994
2078
|
|
|
2079
|
+
isPlanMode && !isCompleted
|
|
2080
|
+
? React.createElement(
|
|
2081
|
+
'div',
|
|
2082
|
+
{ className: 'dsh-goal-plan-banner' },
|
|
2083
|
+
React.createElement(
|
|
2084
|
+
'div',
|
|
2085
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: 2 } },
|
|
2086
|
+
React.createElement(
|
|
2087
|
+
'div',
|
|
2088
|
+
{ style: { display: 'flex', alignItems: 'center', gap: 6 } },
|
|
2089
|
+
React.createElement('span', { className: 'dsh-goal-badge dsh-goal-badge-plan' }, t('planMode') || 'Plan Mode'),
|
|
2090
|
+
React.createElement('strong', { style: { fontSize: 13, color: 'var(--dsw-alias-brand-primary)' } }, t('planningPhase') || 'Planning Phase (Read-Only)')
|
|
2091
|
+
),
|
|
2092
|
+
React.createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, t('planPhaseNotice') || 'Agent is formulating the plan. File modifications are guarded until operator approval.')
|
|
2093
|
+
),
|
|
2094
|
+
React.createElement(
|
|
2095
|
+
'button',
|
|
2096
|
+
{
|
|
2097
|
+
type: 'button',
|
|
2098
|
+
className: 'dsh-goal-btn dsh-goal-btn-approve',
|
|
2099
|
+
style: { padding: '6px 14px', fontSize: 12 },
|
|
2100
|
+
onClick: async () => {
|
|
2101
|
+
await onAction('approve_plan');
|
|
2102
|
+
},
|
|
2103
|
+
},
|
|
2104
|
+
'✓ ' + (t('approveAndExecute') || 'Approve & Execute')
|
|
2105
|
+
)
|
|
2106
|
+
)
|
|
2107
|
+
: null,
|
|
2108
|
+
|
|
1995
2109
|
!isCompleted
|
|
1996
2110
|
|
|
1997
2111
|
? React.createElement(
|
|
@@ -2287,16 +2401,33 @@ window.__ModuleLoader__.load({
|
|
|
2287
2401
|
},
|
|
2288
2402
|
|
|
2289
2403
|
`${i + 1}. ${m.title}`,
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
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,
|
|
2296
2420
|
|
|
2297
2421
|
),
|
|
2298
2422
|
|
|
2299
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,
|
|
2300
2431
|
|
|
2301
2432
|
Array.isArray(m.checklist) && m.checklist.length > 0
|
|
2302
2433
|
? React.createElement(
|
|
@@ -2561,6 +2692,20 @@ window.__ModuleLoader__.load({
|
|
|
2561
2692
|
|
|
2562
2693
|
: null,
|
|
2563
2694
|
|
|
2695
|
+
isPlanMode && !isCompleted
|
|
2696
|
+
? React.createElement(
|
|
2697
|
+
'button',
|
|
2698
|
+
{
|
|
2699
|
+
type: 'button',
|
|
2700
|
+
className: 'dsh-goal-btn dsh-goal-btn-approve',
|
|
2701
|
+
onClick: async () => {
|
|
2702
|
+
await onAction('approve_plan');
|
|
2703
|
+
},
|
|
2704
|
+
},
|
|
2705
|
+
'✓ ' + (t('approveAndExecute') || 'Approve & Execute')
|
|
2706
|
+
)
|
|
2707
|
+
: null,
|
|
2708
|
+
|
|
2564
2709
|
React.createElement(
|
|
2565
2710
|
|
|
2566
2711
|
'button',
|
|
@@ -2590,6 +2735,7 @@ window.__ModuleLoader__.load({
|
|
|
2590
2735
|
function QuickLaunchModal({ onClose, onStart, t }) {
|
|
2591
2736
|
|
|
2592
2737
|
const [title, setTitle] = useState('');
|
|
2738
|
+
const [launchMode, setLaunchMode] = useState('act');
|
|
2593
2739
|
const [showTemplates, setShowTemplates] = useState(false);
|
|
2594
2740
|
const [backendTemplates, setBackendTemplates] = useState([]);
|
|
2595
2741
|
const [selectedTpl, setSelectedTpl] = useState(null);
|
|
@@ -2649,7 +2795,7 @@ window.__ModuleLoader__.load({
|
|
|
2649
2795
|
|
|
2650
2796
|
if (trimmed) {
|
|
2651
2797
|
|
|
2652
|
-
onStart(trimmed);
|
|
2798
|
+
onStart(trimmed, { mode: launchMode });
|
|
2653
2799
|
|
|
2654
2800
|
}
|
|
2655
2801
|
|
|
@@ -2727,6 +2873,34 @@ window.__ModuleLoader__.load({
|
|
|
2727
2873
|
|
|
2728
2874
|
),
|
|
2729
2875
|
|
|
2876
|
+
React.createElement(
|
|
2877
|
+
'div',
|
|
2878
|
+
{ style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 } },
|
|
2879
|
+
React.createElement('span', { style: { fontSize: 12, fontWeight: 600, color: 'var(--dsw-alias-label-secondary)' } }, t('launchModeLabel') || 'Mode:'),
|
|
2880
|
+
React.createElement(
|
|
2881
|
+
'button',
|
|
2882
|
+
{
|
|
2883
|
+
type: 'button',
|
|
2884
|
+
className: `dsh-goal-chip-btn ${launchMode === 'act' ? 'active' : ''}`,
|
|
2885
|
+
style: launchMode === 'act' ? { fontWeight: 600, borderColor: 'var(--dsw-alias-state-success-primary)', color: 'var(--dsw-alias-state-success-primary)' } : {},
|
|
2886
|
+
onClick: () => setLaunchMode('act'),
|
|
2887
|
+
title: t('actModeDesc') || 'Direct execution right away',
|
|
2888
|
+
},
|
|
2889
|
+
'⚡ ' + (t('actMode') || 'Act Mode')
|
|
2890
|
+
),
|
|
2891
|
+
React.createElement(
|
|
2892
|
+
'button',
|
|
2893
|
+
{
|
|
2894
|
+
type: 'button',
|
|
2895
|
+
className: `dsh-goal-chip-btn ${launchMode === 'plan' ? 'active' : ''}`,
|
|
2896
|
+
style: launchMode === 'plan' ? { fontWeight: 600, borderColor: 'var(--dsw-alias-brand-primary)', color: 'var(--dsw-alias-brand-primary)' } : {},
|
|
2897
|
+
onClick: () => setLaunchMode('plan'),
|
|
2898
|
+
title: t('planModeDesc') || 'Plan first, review milestones, then execute',
|
|
2899
|
+
},
|
|
2900
|
+
'📋 ' + (t('planMode') || 'Plan Mode')
|
|
2901
|
+
)
|
|
2902
|
+
),
|
|
2903
|
+
|
|
2730
2904
|
React.createElement(
|
|
2731
2905
|
|
|
2732
2906
|
'div',
|
|
@@ -3504,9 +3678,9 @@ window.__ModuleLoader__.load({
|
|
|
3504
3678
|
|
|
3505
3679
|
onClose: () => setIsQuickLaunchOpen(false),
|
|
3506
3680
|
|
|
3507
|
-
onStart: async (title) => {
|
|
3681
|
+
onStart: async (title, opts) => {
|
|
3508
3682
|
|
|
3509
|
-
await handleAction('start', { title });
|
|
3683
|
+
await handleAction('start', { title, mode: opts?.mode });
|
|
3510
3684
|
|
|
3511
3685
|
setIsQuickLaunchOpen(false);
|
|
3512
3686
|
|
|
@@ -3526,6 +3700,8 @@ window.__ModuleLoader__.load({
|
|
|
3526
3700
|
|
|
3527
3701
|
const isCompleted = state.state === 'COMPLETED';
|
|
3528
3702
|
|
|
3703
|
+
const isPlanMode = state.mode === 'plan' || state.state === 'PLANNING';
|
|
3704
|
+
|
|
3529
3705
|
const formatElapsed = (totalSeconds) => {
|
|
3530
3706
|
const s = Math.max(0, Math.floor(Number(totalSeconds) || 0));
|
|
3531
3707
|
if (s < 60) return `${s}s`;
|
|
@@ -3580,11 +3756,21 @@ window.__ModuleLoader__.load({
|
|
|
3580
3756
|
),
|
|
3581
3757
|
React.createElement(
|
|
3582
3758
|
'span',
|
|
3583
|
-
{ className: `dsh-goal-badge ${isCompleted ? 'dsh-goal-badge-ok' : isPaused ? 'dsh-goal-badge-warn' : ''}` },
|
|
3584
|
-
isCompleted ? (t('goalCompleted') || 'Done') : isPaused ? (t('paused') || 'Paused') : (t('goalLabel') || 'Goal')
|
|
3759
|
+
{ className: `dsh-goal-badge ${isCompleted ? 'dsh-goal-badge-ok' : isPlanMode ? 'dsh-goal-badge-plan' : isPaused ? 'dsh-goal-badge-warn' : ''}` },
|
|
3760
|
+
isCompleted ? (t('goalCompleted') || 'Done') : isPlanMode ? (t('planMode') || 'Plan Mode') : isPaused ? (t('paused') || 'Paused') : (t('goalLabel') || 'Goal')
|
|
3585
3761
|
),
|
|
3586
3762
|
React.createElement('span', { className: 'dsh-goal-title', title: state.title }, state.title),
|
|
3587
3763
|
React.createElement('span', { className: 'dsh-goal-time' }, `⏱ ${liveElapsedStr}`),
|
|
3764
|
+
isPlanMode && !isCompleted ? React.createElement(
|
|
3765
|
+
'button',
|
|
3766
|
+
{
|
|
3767
|
+
className: 'dsh-goal-btn dsh-goal-btn-approve',
|
|
3768
|
+
style: { padding: '2px 6px', height: 22, fontSize: 11 },
|
|
3769
|
+
title: t('approveAndExecute') || 'Approve & Execute',
|
|
3770
|
+
onClick: () => handleAction('approve_plan'),
|
|
3771
|
+
},
|
|
3772
|
+
'✓ ' + (t('approveAndExecute') || 'Approve')
|
|
3773
|
+
) : null,
|
|
3588
3774
|
!isCompleted ? React.createElement(
|
|
3589
3775
|
'button',
|
|
3590
3776
|
{
|
|
@@ -3644,11 +3830,11 @@ window.__ModuleLoader__.load({
|
|
|
3644
3830
|
|
|
3645
3831
|
{
|
|
3646
3832
|
|
|
3647
|
-
className: `dsh-goal-badge ${isCompleted ? 'dsh-goal-badge-ok' : isPaused ? 'dsh-goal-badge-warn' : ''}`,
|
|
3833
|
+
className: `dsh-goal-badge ${isCompleted ? 'dsh-goal-badge-ok' : isPlanMode ? 'dsh-goal-badge-plan' : isPaused ? 'dsh-goal-badge-warn' : ''}`,
|
|
3648
3834
|
|
|
3649
3835
|
},
|
|
3650
3836
|
|
|
3651
|
-
isCompleted ? (t('goalCompleted') || 'Goal completed') : isPaused ? (t('paused') || 'Paused') : (t('goalLabel') || 'Current goal'),
|
|
3837
|
+
isCompleted ? (t('goalCompleted') || 'Goal completed') : isPlanMode ? (t('planMode') || 'Plan Mode') : isPaused ? (t('paused') || 'Paused') : (t('goalLabel') || 'Current goal'),
|
|
3652
3838
|
|
|
3653
3839
|
),
|
|
3654
3840
|
|
|
@@ -3672,6 +3858,18 @@ window.__ModuleLoader__.load({
|
|
|
3672
3858
|
|
|
3673
3859
|
),
|
|
3674
3860
|
|
|
3861
|
+
isPlanMode && !isCompleted
|
|
3862
|
+
? React.createElement(
|
|
3863
|
+
'button',
|
|
3864
|
+
{
|
|
3865
|
+
className: 'dsh-goal-btn dsh-goal-btn-approve',
|
|
3866
|
+
style: { fontSize: 12, padding: '2px 8px' },
|
|
3867
|
+
title: t('approveAndExecute') || 'Approve & Execute',
|
|
3868
|
+
onClick: () => handleAction('approve_plan'),
|
|
3869
|
+
},
|
|
3870
|
+
'✓ ' + (t('approveAndExecute') || 'Approve & Execute')
|
|
3871
|
+
)
|
|
3872
|
+
: null,
|
|
3675
3873
|
!isCompleted
|
|
3676
3874
|
|
|
3677
3875
|
? React.createElement(
|
|
@@ -4883,6 +5081,9 @@ window.__ModuleLoader__.load({
|
|
|
4883
5081
|
mergeBranch: 'Merge Branch',
|
|
4884
5082
|
discardBranch: 'Discard Branch',
|
|
4885
5083
|
retrospectiveTitle: 'Goal Retrospective',
|
|
5084
|
+
verificationGate: 'Acceptance gate',
|
|
5085
|
+
verificationPassed: 'Verified',
|
|
5086
|
+
verificationFailed: 'Gate failed',
|
|
4886
5087
|
templatesTitle: 'Goal Templates',
|
|
4887
5088
|
waitingPrereq: 'waiting for prereq',
|
|
4888
5089
|
|
|
@@ -4983,6 +5184,15 @@ window.__ModuleLoader__.load({
|
|
|
4983
5184
|
toolFailHint: 'Auto-pause goal if N consecutive turns hit tool execution errors (0 to disable)',
|
|
4984
5185
|
|
|
4985
5186
|
notifLabel: 'Native browser desktop notifications on completion',
|
|
5187
|
+
planMode: 'Plan Mode',
|
|
5188
|
+
actMode: 'Act Mode',
|
|
5189
|
+
approveAndExecute: 'Approve & Execute',
|
|
5190
|
+
switchToPlan: 'Switch to Plan Mode',
|
|
5191
|
+
planningPhase: 'Planning Phase (Read-Only)',
|
|
5192
|
+
planPhaseNotice: 'Agent is formulating the plan. File modifications are guarded until operator approval.',
|
|
5193
|
+
launchModeLabel: 'Launch Mode:',
|
|
5194
|
+
planModeDesc: 'Plan first, review milestones, then execute',
|
|
5195
|
+
actModeDesc: 'Direct execution right away',
|
|
4986
5196
|
|
|
4987
5197
|
},
|
|
4988
5198
|
|
|
@@ -5022,6 +5232,9 @@ window.__ModuleLoader__.load({
|
|
|
5022
5232
|
mergeBranch: '合并分支',
|
|
5023
5233
|
discardBranch: '放弃分支',
|
|
5024
5234
|
retrospectiveTitle: '目标复盘报告',
|
|
5235
|
+
verificationGate: '验收门禁',
|
|
5236
|
+
verificationPassed: '已验证',
|
|
5237
|
+
verificationFailed: '门禁未通过',
|
|
5025
5238
|
templatesTitle: '目标模板',
|
|
5026
5239
|
waitingPrereq: '等待前置依赖',
|
|
5027
5240
|
|
|
@@ -5122,6 +5335,15 @@ window.__ModuleLoader__.load({
|
|
|
5122
5335
|
toolFailHint: '连续 N 轮遭遇工具调用错误时自动暂停(0 表示禁用)',
|
|
5123
5336
|
|
|
5124
5337
|
notifLabel: '目标完成时发送浏览器桌面原生通知',
|
|
5338
|
+
planMode: '规划模式',
|
|
5339
|
+
actMode: '执行模式',
|
|
5340
|
+
approveAndExecute: '批准并执行',
|
|
5341
|
+
switchToPlan: '切换到规划模式',
|
|
5342
|
+
planningPhase: '规划阶段(只读)',
|
|
5343
|
+
planPhaseNotice: '智能体正在制定方案与里程碑。在操作者批准前,禁止修改文件。',
|
|
5344
|
+
launchModeLabel: '启动模式:',
|
|
5345
|
+
planModeDesc: '先制定里程碑方案,人工确认后再执行',
|
|
5346
|
+
actModeDesc: '立即开始编码与任务执行',
|
|
5125
5347
|
|
|
5126
5348
|
},
|
|
5127
5349
|
|
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 {
|
package/lib/engine-sessions.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GoalState } from './goal-engine-constants.js';
|
|
1
|
+
import { GoalMode, GoalState } from './goal-engine-constants.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Prune inactive sessions when exceeding maxSessions threshold
|
|
@@ -81,3 +81,80 @@ export function extendGoalBudget(goal, addTokens = 50000, defaultBudget = 0) {
|
|
|
81
81
|
if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
|
|
82
82
|
return newBudget;
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Set mode for a goal
|
|
87
|
+
*/
|
|
88
|
+
export function setGoalMode(goal, mode, recordActivity) {
|
|
89
|
+
if (mode !== GoalMode.PLAN && mode !== GoalMode.ACT) {
|
|
90
|
+
throw new Error(`Invalid mode: ${mode}. Must be one of: plan, act`);
|
|
91
|
+
}
|
|
92
|
+
if (!goal) {
|
|
93
|
+
throw new Error('No active goal found');
|
|
94
|
+
}
|
|
95
|
+
const previousMode = goal.mode || GoalMode.ACT;
|
|
96
|
+
goal.mode = mode;
|
|
97
|
+
if (mode === GoalMode.ACT && goal.state === GoalState.PLANNING) {
|
|
98
|
+
goal.state = GoalState.RUNNING;
|
|
99
|
+
} else if (mode === GoalMode.PLAN && goal.state === GoalState.RUNNING) {
|
|
100
|
+
goal.state = GoalState.PLANNING;
|
|
101
|
+
}
|
|
102
|
+
const message = mode === GoalMode.ACT
|
|
103
|
+
? 'Plan approved by operator, switched to Act Mode'
|
|
104
|
+
: 'Switched to Plan Mode (read-only planning phase)';
|
|
105
|
+
goal.logs.push({ timestamp: Date.now(), type: 'info', message });
|
|
106
|
+
if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
|
|
107
|
+
if (typeof recordActivity === 'function') {
|
|
108
|
+
recordActivity(goal, { type: 'control', tool: 'setMode', status: 'info', details: `${previousMode} -> ${mode}` });
|
|
109
|
+
}
|
|
110
|
+
return goal;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Pause active goal
|
|
115
|
+
*/
|
|
116
|
+
export function pauseGoal(goal, reason, recordActivity) {
|
|
117
|
+
if (!goal || goal.state !== GoalState.RUNNING) return false;
|
|
118
|
+
goal.state = GoalState.PAUSED;
|
|
119
|
+
goal.pausedAt = Date.now();
|
|
120
|
+
if (typeof recordActivity === 'function') {
|
|
121
|
+
recordActivity(goal, { type: 'control', tool: 'pause', status: 'warning', details: reason });
|
|
122
|
+
}
|
|
123
|
+
goal.logs.push({ timestamp: Date.now(), type: 'warning', message: `Paused: ${reason}` });
|
|
124
|
+
if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Resume paused goal
|
|
130
|
+
*/
|
|
131
|
+
export function resumeGoal(goal, recordActivity) {
|
|
132
|
+
if (!goal || goal.state !== GoalState.PAUSED) return false;
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
if (goal.pausedAt) {
|
|
135
|
+
goal.totalPausedDurationMs += now - goal.pausedAt;
|
|
136
|
+
goal.pausedAt = null;
|
|
137
|
+
}
|
|
138
|
+
goal.state = GoalState.RUNNING;
|
|
139
|
+
if (typeof recordActivity === 'function') {
|
|
140
|
+
recordActivity(goal, { type: 'control', tool: 'resume', status: 'info', details: 'Resumed by user' });
|
|
141
|
+
}
|
|
142
|
+
goal.logs.push({ timestamp: now, type: 'info', message: 'Goal resumed' });
|
|
143
|
+
if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Cancel active goal
|
|
149
|
+
*/
|
|
150
|
+
export function cancelGoal(goal, reason, recordActivity) {
|
|
151
|
+
if (!goal) return false;
|
|
152
|
+
goal.state = GoalState.CANCELLED;
|
|
153
|
+
goal.completedAt = Date.now();
|
|
154
|
+
if (typeof recordActivity === 'function') {
|
|
155
|
+
recordActivity(goal, { type: 'control', tool: 'cancel', status: 'warning', details: reason });
|
|
156
|
+
}
|
|
157
|
+
goal.logs.push({ timestamp: Date.now(), type: 'warning', message: `Cancelled: ${reason}` });
|
|
158
|
+
if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { getSafeCwd } from './goal-engine-constants.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Execute a verification command in a confined working directory with timeout and output capture
|
|
6
|
+
* @param {string} command Shell command to execute
|
|
7
|
+
* @param {string} [targetCwd] Working directory
|
|
8
|
+
* @param {object} [options]
|
|
9
|
+
* @param {number} [options.timeoutMs=30000] Timeout in milliseconds
|
|
10
|
+
* @param {number} [options.maxBuffer=1048576] Max stdout/stderr buffer in bytes (1MB)
|
|
11
|
+
* @param {object} [options.env={}] Additional environment variables
|
|
12
|
+
* @returns {{ ok: boolean, exitCode: number, stdout: string, stderr: string, error?: string }}
|
|
13
|
+
*/
|
|
14
|
+
export function runMilestoneVerification(command, targetCwd, options = {}) {
|
|
15
|
+
if (!command || typeof command !== 'string' || !command.trim()) {
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
exitCode: 1,
|
|
19
|
+
stdout: '',
|
|
20
|
+
stderr: '',
|
|
21
|
+
error: 'Empty or invalid verification command',
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const safeCwd = getSafeCwd(targetCwd) || process.cwd();
|
|
26
|
+
const timeoutMs = typeof options.timeoutMs === 'number' ? options.timeoutMs : 30000;
|
|
27
|
+
const maxBuffer = typeof options.maxBuffer === 'number' ? options.maxBuffer : 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const res = spawnSync(command.trim(), {
|
|
31
|
+
shell: true,
|
|
32
|
+
cwd: safeCwd,
|
|
33
|
+
timeout: timeoutMs,
|
|
34
|
+
maxBuffer,
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
env: { ...process.env, ...options.env },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (res.error) {
|
|
40
|
+
if (res.error.code === 'ETIMEDOUT') {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
exitCode: 124,
|
|
44
|
+
stdout: res.stdout || '',
|
|
45
|
+
stderr: res.stderr || '',
|
|
46
|
+
error: `Verification command timed out after ${timeoutMs}ms`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
exitCode: res.status ?? 1,
|
|
52
|
+
stdout: res.stdout || '',
|
|
53
|
+
stderr: res.stderr || '',
|
|
54
|
+
error: res.error.message || 'Verification command failed to execute',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (res.status !== 0) {
|
|
59
|
+
const output = (res.stderr || res.stdout || '').trim();
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
exitCode: res.status ?? 1,
|
|
63
|
+
stdout: res.stdout || '',
|
|
64
|
+
stderr: res.stderr || '',
|
|
65
|
+
error: `Verification command failed with exit code ${res.status}${output ? ': ' + output.slice(0, 500) : ''}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
ok: true,
|
|
71
|
+
exitCode: 0,
|
|
72
|
+
stdout: res.stdout || '',
|
|
73
|
+
stderr: res.stderr || '',
|
|
74
|
+
};
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
exitCode: 1,
|
|
79
|
+
stdout: '',
|
|
80
|
+
stderr: '',
|
|
81
|
+
error: err?.message || 'Unexpected verification failure',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Run acceptance gate check on a milestone target and record diagnostic logs/activities
|
|
88
|
+
* @param {object} target Milestone object
|
|
89
|
+
* @param {string} targetCwd Working directory
|
|
90
|
+
* @param {object} [goal] Goal engine state
|
|
91
|
+
* @param {Function} [recordActivity] Activity recorder function
|
|
92
|
+
* @param {object} [options]
|
|
93
|
+
* @returns {{ ok: boolean, verified: boolean, error?: string, output?: string, passedAt?: number }}
|
|
94
|
+
*/
|
|
95
|
+
export function verifyMilestoneAcceptance(target, targetCwd, goal = null, recordActivity = null, options = {}) {
|
|
96
|
+
if (!target || !target.verificationCommand) {
|
|
97
|
+
return { ok: true, verified: false };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const vRes = runMilestoneVerification(target.verificationCommand, targetCwd, options);
|
|
101
|
+
if (!vRes.ok) {
|
|
102
|
+
target.verificationError = vRes.error;
|
|
103
|
+
target.verificationOutput = (vRes.stderr || vRes.stdout || '').trim();
|
|
104
|
+
if (goal) {
|
|
105
|
+
if (typeof recordActivity === 'function') {
|
|
106
|
+
recordActivity(goal, {
|
|
107
|
+
type: 'milestone',
|
|
108
|
+
tool: 'updateMilestone',
|
|
109
|
+
status: 'error',
|
|
110
|
+
details: `Verification failed: ${vRes.error}`,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (Array.isArray(goal.logs)) {
|
|
114
|
+
goal.logs.push({
|
|
115
|
+
timestamp: Date.now(),
|
|
116
|
+
type: 'error',
|
|
117
|
+
message: `Acceptance gate failed for [${target.title}]: ${vRes.error}`,
|
|
118
|
+
});
|
|
119
|
+
if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { ok: false, verified: true, error: vRes.error, output: target.verificationOutput };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
target.verificationError = null;
|
|
126
|
+
target.verificationOutput = null;
|
|
127
|
+
target.verificationPassedAt = Date.now();
|
|
128
|
+
return { ok: true, verified: true, passedAt: target.verificationPassedAt };
|
|
129
|
+
}
|
|
@@ -4,6 +4,11 @@ import path from "node:path";
|
|
|
4
4
|
* Enums and helpers for DSH Goal Engine
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
export const GoalMode = {
|
|
8
|
+
PLAN: 'plan',
|
|
9
|
+
ACT: 'act',
|
|
10
|
+
};
|
|
11
|
+
|
|
7
12
|
export const GoalState = {
|
|
8
13
|
IDLE: 'IDLE',
|
|
9
14
|
PLANNING: 'PLANNING',
|
|
@@ -144,6 +149,7 @@ export function buildSnapshot(goal, sid, engine) {
|
|
|
144
149
|
return {
|
|
145
150
|
sessionId: sid,
|
|
146
151
|
hasActiveGoal: false,
|
|
152
|
+
mode: GoalMode.ACT,
|
|
147
153
|
state: GoalState.IDLE,
|
|
148
154
|
title: '',
|
|
149
155
|
startedAt: null,
|
|
@@ -199,6 +205,7 @@ export function buildSnapshot(goal, sid, engine) {
|
|
|
199
205
|
sessionId: sid,
|
|
200
206
|
hasActiveGoal: true,
|
|
201
207
|
id: goal.id,
|
|
208
|
+
mode: goal.mode || (goal.state === GoalState.PLANNING ? GoalMode.PLAN : GoalMode.ACT),
|
|
202
209
|
state: goal.state,
|
|
203
210
|
title: goal.title,
|
|
204
211
|
description: goal.description,
|
package/lib/goal-engine.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { verifyMilestoneAcceptance } from './engine-verification.js';
|
|
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';
|
|
4
5
|
import { buildStatePromptInjection } from './engine-prompt.js';
|
|
@@ -8,9 +9,9 @@ import { createGoalBranch, sanitizeBranchSlug, getCurrentGitBranch, mergeGoalBra
|
|
|
8
9
|
import { parseIssueRef, parseIssueChecklist, syncMilestoneWithIssue } from './engine-issue-sync.js';
|
|
9
10
|
import { generateRetrospectiveData } from './engine-retrospective.js';
|
|
10
11
|
import { generatePreplanDraft } from './engine-templates.js';
|
|
11
|
-
import { pruneInactiveSessions, addNudgeToGoal, consumeGoalNudge, extendGoalBudget } from './engine-sessions.js';
|
|
12
|
+
import { pruneInactiveSessions, addNudgeToGoal, consumeGoalNudge, extendGoalBudget, setGoalMode, pauseGoal, resumeGoal, cancelGoal } from './engine-sessions.js';
|
|
12
13
|
|
|
13
|
-
export { GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, matchMilestone, parseIssueChecklist };
|
|
14
|
+
export { GoalMode, GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, matchMilestone, parseIssueChecklist };
|
|
14
15
|
export { getGitCurrentCommit, exportReportMarkdown, exportReportGitHubPR, createMilestoneCheckpoint, rollbackToCheckpoint, saveGoalArtifact };
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -193,6 +194,9 @@ export class GoalEngine {
|
|
|
193
194
|
? options.gitStartCommit
|
|
194
195
|
: getGitCurrentCommit(options.cwd);
|
|
195
196
|
|
|
197
|
+
const initialMode = options.mode === GoalMode.PLAN ? GoalMode.PLAN : GoalMode.ACT;
|
|
198
|
+
const initialState = initialMode === GoalMode.PLAN ? GoalState.PLANNING : GoalState.RUNNING;
|
|
199
|
+
|
|
196
200
|
const goal = {
|
|
197
201
|
id: `goal-${now}-${Math.random().toString(36).substring(2, 7)}`,
|
|
198
202
|
sessionId: sid,
|
|
@@ -200,7 +204,8 @@ export class GoalEngine {
|
|
|
200
204
|
description: options.description?.trim() || '',
|
|
201
205
|
lang: options.lang || detectLanguage(cleanTitle),
|
|
202
206
|
cwd: options.cwd ? getSafeCwd(options.cwd) : null,
|
|
203
|
-
|
|
207
|
+
mode: initialMode,
|
|
208
|
+
state: initialState,
|
|
204
209
|
startedAt: now,
|
|
205
210
|
pausedAt: null,
|
|
206
211
|
totalPausedDurationMs: 0,
|
|
@@ -265,13 +270,7 @@ export class GoalEngine {
|
|
|
265
270
|
pause(reason = 'User requested pause', sessionId = 'default') {
|
|
266
271
|
const sid = sessionId || 'default';
|
|
267
272
|
const goal = this.goals.get(sid);
|
|
268
|
-
if (!goal
|
|
269
|
-
|
|
270
|
-
goal.state = GoalState.PAUSED;
|
|
271
|
-
goal.pausedAt = Date.now();
|
|
272
|
-
recordActivity(goal, { type: 'control', tool: 'pause', status: 'warning', details: reason });
|
|
273
|
-
goal.logs.push({ timestamp: Date.now(), type: 'warning', message: `Paused: ${reason}` });
|
|
274
|
-
if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
|
|
273
|
+
if (!pauseGoal(goal, reason, recordActivity)) return this.getSnapshot(sid);
|
|
275
274
|
this.emit(sid, true);
|
|
276
275
|
return this.getSnapshot(sid);
|
|
277
276
|
}
|
|
@@ -279,18 +278,8 @@ export class GoalEngine {
|
|
|
279
278
|
resume(sessionId = 'default') {
|
|
280
279
|
const sid = sessionId || 'default';
|
|
281
280
|
const goal = this.goals.get(sid);
|
|
282
|
-
if (!goal
|
|
283
|
-
|
|
284
|
-
const now = Date.now();
|
|
285
|
-
if (goal.pausedAt) {
|
|
286
|
-
goal.totalPausedDurationMs += now - goal.pausedAt;
|
|
287
|
-
goal.pausedAt = null;
|
|
288
|
-
}
|
|
289
|
-
goal.state = GoalState.RUNNING;
|
|
281
|
+
if (!resumeGoal(goal, recordActivity)) return this.getSnapshot(sid);
|
|
290
282
|
this.stallCounters.set(sid, 0);
|
|
291
|
-
recordActivity(goal, { type: 'control', tool: 'resume', status: 'info', details: 'Resumed by user' });
|
|
292
|
-
goal.logs.push({ timestamp: now, type: 'info', message: 'Goal resumed' });
|
|
293
|
-
if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
|
|
294
283
|
this.emit(sid, true);
|
|
295
284
|
return this.getSnapshot(sid);
|
|
296
285
|
}
|
|
@@ -298,13 +287,7 @@ export class GoalEngine {
|
|
|
298
287
|
cancel(reason = 'Cancelled by user', sessionId = 'default') {
|
|
299
288
|
const sid = sessionId || 'default';
|
|
300
289
|
const goal = this.goals.get(sid);
|
|
301
|
-
if (!goal) return null;
|
|
302
|
-
|
|
303
|
-
goal.state = GoalState.CANCELLED;
|
|
304
|
-
goal.completedAt = Date.now();
|
|
305
|
-
recordActivity(goal, { type: 'control', tool: 'cancel', status: 'warning', details: reason });
|
|
306
|
-
goal.logs.push({ timestamp: Date.now(), type: 'warning', message: `Cancelled: ${reason}` });
|
|
307
|
-
if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
|
|
290
|
+
if (!cancelGoal(goal, reason, recordActivity)) return null;
|
|
308
291
|
this.emit(sid, true);
|
|
309
292
|
return this.getSnapshot(sid);
|
|
310
293
|
}
|
|
@@ -375,21 +358,22 @@ export class GoalEngine {
|
|
|
375
358
|
const target = goal.milestones.find((m) => matchMilestone(m, id));
|
|
376
359
|
if (!target) return false;
|
|
377
360
|
|
|
361
|
+
if (status === MilestoneStatus.COMPLETED && target.verificationCommand) {
|
|
362
|
+
const vRes = verifyMilestoneAcceptance(target, goal.cwd || process.cwd(), goal, recordActivity);
|
|
363
|
+
if (!vRes.ok) {
|
|
364
|
+
this.emit(sid);
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
378
369
|
const res = applyMilestoneUpdate(target, status, notes, checklist, Object.values(MilestoneStatus), {
|
|
379
370
|
enforceDependencies: this.enableMilestoneDependencies,
|
|
380
371
|
allMilestones: goal.milestones,
|
|
381
372
|
});
|
|
382
373
|
if (res.invalidStatus || res.blocked) {
|
|
383
|
-
if (res.invalidStatus) {
|
|
384
|
-
this.logger?.warn?.(`[dsh-goal] Invalid milestone status "${status}" for milestone ${id}`);
|
|
385
|
-
}
|
|
374
|
+
if (res.invalidStatus) this.logger?.warn?.(`[dsh-goal] Invalid milestone status "${status}" for milestone ${id}`);
|
|
386
375
|
if (res.blocked) {
|
|
387
|
-
recordActivity(goal, {
|
|
388
|
-
type: 'milestone',
|
|
389
|
-
tool: 'updateMilestone',
|
|
390
|
-
status: 'error',
|
|
391
|
-
details: `Milestone ${id} blocked by ${res.missingDependencies?.join(', ')}`,
|
|
392
|
-
});
|
|
376
|
+
recordActivity(goal, { type: 'milestone', tool: 'updateMilestone', status: 'error', details: `Milestone ${id} blocked by ${res.missingDependencies?.join(', ')}` });
|
|
393
377
|
}
|
|
394
378
|
return false;
|
|
395
379
|
}
|
|
@@ -580,4 +564,26 @@ export class GoalEngine {
|
|
|
580
564
|
const goal = this.goals.get(sid);
|
|
581
565
|
if (goal) recordActivity(goal, { type: 'tool', tool, status, details });
|
|
582
566
|
}
|
|
567
|
+
|
|
568
|
+
setMode(mode, sessionId = 'default') {
|
|
569
|
+
const sid = sessionId || 'default';
|
|
570
|
+
const goal = this.goals.get(sid);
|
|
571
|
+
if (!goal) throw new Error(`No active goal for session "${sid}"`);
|
|
572
|
+
setGoalMode(goal, mode, recordActivity);
|
|
573
|
+
this.emit(sid, true);
|
|
574
|
+
return this.getSnapshot(sid);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
approvePlan(sessionId = 'default') {
|
|
578
|
+
return this.setMode(GoalMode.ACT, sessionId);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
getActiveGoal(sessionId = 'default') {
|
|
582
|
+
return this.goals.get(sessionId || 'default') || null;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
isPlanMode(sessionId = 'default') {
|
|
586
|
+
const goal = this.goals.get(sessionId || 'default');
|
|
587
|
+
return Boolean(goal && (goal.mode === GoalMode.PLAN || goal.state === GoalState.PLANNING));
|
|
588
|
+
}
|
|
583
589
|
}
|
package/lib/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { GitSnapshotManager } from './git-snapshot-manager.js';
|
|
|
19
19
|
import { MilestoneManager } from './milestone-manager.js';
|
|
20
20
|
import { registerRoutes } from './routes.js';
|
|
21
21
|
import { registerTools } from './tools.js';
|
|
22
|
+
import { registerPlanModeGuard, createPlanModeGuard, isPermittedInPlanMode } from './tool-guard.js';
|
|
22
23
|
import { saveGoalReportFile } from './engine-reports.js';
|
|
23
24
|
import { registerPluginUpdater } from './updater.js';
|
|
24
25
|
import { GoalEngine, GoalState } from './goal-engine.js';
|
|
@@ -168,6 +169,7 @@ export function apply(ctx, config = {}) {
|
|
|
168
169
|
enableMilestoneDependencies: currentSettings.enableMilestoneDependencies,
|
|
169
170
|
logger,
|
|
170
171
|
});
|
|
172
|
+
registerPlanModeGuard(ctx, { engine: fallbackEngine, logger });
|
|
171
173
|
}
|
|
172
174
|
|
|
173
175
|
// 1. One-click auto-updater registration (Issue #99)
|
|
@@ -414,3 +416,5 @@ export function apply(ctx, config = {}) {
|
|
|
414
416
|
engine: fallbackEngine,
|
|
415
417
|
};
|
|
416
418
|
}
|
|
419
|
+
|
|
420
|
+
export { registerPlanModeGuard, createPlanModeGuard, isPermittedInPlanMode };
|
package/lib/milestone-manager.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { syncMilestoneWithIssue } from './engine-issue-sync.js';
|
|
2
|
+
import { runMilestoneVerification } from './engine-verification.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Milestone and checklist coordinator for active core goals
|
|
@@ -24,6 +25,7 @@ export class MilestoneManager {
|
|
|
24
25
|
completed: Boolean(m.completed),
|
|
25
26
|
completedAt: m.completedAt || null,
|
|
26
27
|
notes: m.notes || '',
|
|
28
|
+
verificationCommand: m.verificationCommand || m.verification_command ? String(m.verificationCommand || m.verification_command).trim() : null,
|
|
27
29
|
dependsOn: Array.isArray(m.dependsOn) ? m.dependsOn : [],
|
|
28
30
|
checklist: Array.isArray(m.checklist) ? m.checklist.map((c, cIdx) => ({
|
|
29
31
|
id: c.id || `cl-${idx + 1}-${cIdx + 1}`,
|
|
@@ -35,16 +37,18 @@ export class MilestoneManager {
|
|
|
35
37
|
return sanitized;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
addMilestone(goalId, { title, checklist = [], dependsOn = [] }) {
|
|
40
|
+
addMilestone(goalId, { title, checklist = [], dependsOn = [], verificationCommand = null, verification_command = null }) {
|
|
39
41
|
if (!goalId || !this.store) throw new Error('Missing goalId or store');
|
|
40
42
|
const list = this.getMilestones(goalId);
|
|
41
43
|
const id = `m-${list.length + 1}`;
|
|
44
|
+
const vCmd = verificationCommand || verification_command;
|
|
42
45
|
const newM = {
|
|
43
46
|
id,
|
|
44
47
|
title: title || `Milestone ${list.length + 1}`,
|
|
45
48
|
completed: false,
|
|
46
49
|
completedAt: null,
|
|
47
50
|
notes: '',
|
|
51
|
+
verificationCommand: vCmd ? String(vCmd).trim() : null,
|
|
48
52
|
dependsOn: Array.isArray(dependsOn) ? dependsOn : [],
|
|
49
53
|
checklist: (checklist || []).map((c, cIdx) => ({
|
|
50
54
|
id: `cl-${list.length + 1}-${cIdx + 1}`,
|
|
@@ -74,6 +78,20 @@ export class MilestoneManager {
|
|
|
74
78
|
}
|
|
75
79
|
}
|
|
76
80
|
|
|
81
|
+
// Acceptance Gate (Critic Verification) - Issue #163
|
|
82
|
+
if (m.verificationCommand) {
|
|
83
|
+
const vResult = runMilestoneVerification(m.verificationCommand, workspaceRoot);
|
|
84
|
+
if (!vResult.ok) {
|
|
85
|
+
m.verificationError = vResult.error;
|
|
86
|
+
m.verificationOutput = (vResult.stderr || vResult.stdout || '').trim();
|
|
87
|
+
this.store.setMilestones(goalId, list);
|
|
88
|
+
throw new Error(`Acceptance gate failed for milestone "${milestoneId}" (${m.verificationCommand}): ${vResult.error}`);
|
|
89
|
+
}
|
|
90
|
+
m.verificationError = null;
|
|
91
|
+
m.verificationOutput = null;
|
|
92
|
+
m.verificationPassedAt = Date.now();
|
|
93
|
+
}
|
|
94
|
+
|
|
77
95
|
m.completed = true;
|
|
78
96
|
m.completedAt = Date.now();
|
|
79
97
|
if (notes) m.notes = notes;
|
|
@@ -110,6 +128,33 @@ export class MilestoneManager {
|
|
|
110
128
|
return { milestone: m, snapshot };
|
|
111
129
|
}
|
|
112
130
|
|
|
131
|
+
updateMilestone(goalId, milestoneId, { status, notes = '', checklist = null, verificationCommand = null, cwd = null } = {}) {
|
|
132
|
+
if (!goalId || !milestoneId) throw new Error('goalId and milestoneId are required');
|
|
133
|
+
const list = this.getMilestones(goalId);
|
|
134
|
+
const m = list.find(item => item.id === milestoneId);
|
|
135
|
+
if (!m) return null;
|
|
136
|
+
|
|
137
|
+
if (verificationCommand !== undefined && verificationCommand !== null) {
|
|
138
|
+
m.verificationCommand = String(verificationCommand).trim() || null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (status === 'completed') {
|
|
142
|
+
return this.completeMilestone(goalId, milestoneId, notes, cwd);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (status) m.status = status;
|
|
146
|
+
if (notes) m.notes = notes;
|
|
147
|
+
if (Array.isArray(checklist)) {
|
|
148
|
+
m.checklist = checklist.map((c, cIdx) => ({
|
|
149
|
+
id: typeof c === 'object' && c?.id ? c.id : `cl-${milestoneId}-${cIdx + 1}`,
|
|
150
|
+
text: typeof c === 'string' ? c : (c?.text || ''),
|
|
151
|
+
done: Boolean(c?.done),
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
this.store.setMilestones(goalId, list);
|
|
155
|
+
return m;
|
|
156
|
+
}
|
|
157
|
+
|
|
113
158
|
toggleChecklist(goalId, milestoneId, index, done) {
|
|
114
159
|
if (!goalId || !milestoneId) throw new Error('goalId and milestoneId are required');
|
|
115
160
|
const list = this.getMilestones(goalId);
|
|
@@ -139,7 +184,8 @@ export class MilestoneManager {
|
|
|
139
184
|
if (uncompletedChecklist.length > 0) {
|
|
140
185
|
clText = ` | Next items: ${uncompletedChecklist.map(c => `[ ] ${c.text}`).join(', ')}`;
|
|
141
186
|
}
|
|
187
|
+
const gateText = active.verificationCommand ? ` [Gate: "${active.verificationCommand}"]` : '';
|
|
142
188
|
|
|
143
|
-
return `[Goal Milestones]: Current step: "${active.title}"${clText}`;
|
|
189
|
+
return `[Goal Milestones]: Current step: "${active.title}"${gateText}${clText}`;
|
|
144
190
|
}
|
|
145
191
|
}
|
package/lib/routes.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { MilestoneStatus, detectLanguage, sessionIdOf, isPathInsideOrEqual, getSafeCwd } from './goal-engine-constants.js';
|
|
3
|
+
import { MilestoneStatus, GoalMode, detectLanguage, sessionIdOf, isPathInsideOrEqual, getSafeCwd } from './goal-engine-constants.js';
|
|
4
4
|
import { isValidGitRef } from './engine-git-branch.js';
|
|
5
5
|
import { saveGoalArtifact, rollbackToCheckpoint } from './engine-reports.js';
|
|
6
6
|
import { getTemplatesCatalogue, instantiateTemplate } from './engine-templates.js';
|
|
@@ -467,24 +467,32 @@ export function registerRoutes(ctx, {
|
|
|
467
467
|
lang: detectedLang,
|
|
468
468
|
cwd: safeStartCwd,
|
|
469
469
|
issueBody: data.issueBody,
|
|
470
|
+
mode: data.mode,
|
|
470
471
|
}, sid);
|
|
471
472
|
} else {
|
|
472
473
|
let parsedMilestones = [];
|
|
473
474
|
if (data.issueBody) {
|
|
474
475
|
parsedMilestones = parseIssueChecklist(data.issueBody);
|
|
475
476
|
}
|
|
477
|
+
const initialMode = data.mode === 'plan' ? 'plan' : 'act';
|
|
476
478
|
result = {
|
|
477
479
|
title: cleanTitle,
|
|
478
480
|
sessionId: sid,
|
|
479
481
|
hasActiveGoal: true,
|
|
480
|
-
|
|
482
|
+
mode: initialMode,
|
|
483
|
+
state: initialMode === 'plan' ? 'PLANNING' : 'RUNNING',
|
|
481
484
|
milestones: parsedMilestones,
|
|
482
485
|
};
|
|
483
486
|
}
|
|
484
487
|
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
488
|
+
const isPlan = result?.mode === 'plan' || (engine && typeof engine.isPlanMode === 'function' && engine.isPlanMode(sid));
|
|
489
|
+
const startPrompt = isPlan
|
|
490
|
+
? (detectedLang === 'zh'
|
|
491
|
+
? `🎯 目标已确立(规划模式/Plan Mode):“${cleanTitle}”。请通过 goal_milestones 制定工作计划并列出检查清单。在操作者批准前,请勿进行写文件等变更操作。`
|
|
492
|
+
: `🎯 Goal established in Plan Mode: "${cleanTitle}". Formulate milestones and checklist via goal_milestones and goal_checklist. Do not modify files until the operator approves the plan.`)
|
|
493
|
+
: (detectedLang === 'zh'
|
|
494
|
+
? `🎯 目标已确立:“${cleanTitle}”。请立即通过 goal_milestones 制定工作计划并开始执行。`
|
|
495
|
+
: `🎯 Goal established: "${cleanTitle}". Formulate milestones via goal_milestones and proceed.`);
|
|
488
496
|
|
|
489
497
|
if (typeof resumeActiveAgent === 'function') {
|
|
490
498
|
resumeActiveAgent(startPrompt, sid);
|
|
@@ -714,6 +722,55 @@ export function registerRoutes(ctx, {
|
|
|
714
722
|
break;
|
|
715
723
|
}
|
|
716
724
|
|
|
725
|
+
case 'set_mode': {
|
|
726
|
+
const targetMode = data.mode;
|
|
727
|
+
if (targetMode !== GoalMode.PLAN && targetMode !== GoalMode.ACT) {
|
|
728
|
+
res.statusCode = 400;
|
|
729
|
+
return res.end(JSON.stringify({ error: `Invalid mode "${targetMode}". Must be "plan" or "act".` }));
|
|
730
|
+
}
|
|
731
|
+
if (engine) {
|
|
732
|
+
const snap = engine.getSnapshot(sid);
|
|
733
|
+
if (!snap || !snap.hasActiveGoal) {
|
|
734
|
+
res.statusCode = 404;
|
|
735
|
+
return res.end(JSON.stringify({ error: 'No active goal found to set mode' }));
|
|
736
|
+
}
|
|
737
|
+
result = engine.setMode(targetMode, sid);
|
|
738
|
+
if (targetMode === GoalMode.ACT) {
|
|
739
|
+
const detectedLang = snap.lang || 'en';
|
|
740
|
+
const prompt = detectedLang === 'zh'
|
|
741
|
+
? '⚡ 执行模式(Act Mode)已激活。请继续推进目标任务。'
|
|
742
|
+
: '⚡ Act Mode activated by operator. Proceed with task execution.';
|
|
743
|
+
if (typeof resumeActiveAgent === 'function') {
|
|
744
|
+
resumeActiveAgent(prompt, sid);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
} else {
|
|
748
|
+
result = { mode: targetMode, state: targetMode === GoalMode.PLAN ? 'PLANNING' : 'RUNNING' };
|
|
749
|
+
}
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
case 'approve_plan': {
|
|
754
|
+
if (engine) {
|
|
755
|
+
const snap = engine.getSnapshot(sid);
|
|
756
|
+
if (!snap || !snap.hasActiveGoal) {
|
|
757
|
+
res.statusCode = 404;
|
|
758
|
+
return res.end(JSON.stringify({ error: 'No active goal found to approve plan' }));
|
|
759
|
+
}
|
|
760
|
+
result = engine.approvePlan(sid);
|
|
761
|
+
const detectedLang = snap.lang || 'en';
|
|
762
|
+
const prompt = detectedLang === 'zh'
|
|
763
|
+
? '✅ 操作者已批准计划。执行模式(Act Mode)已激活。请立即开始具体代码与任务实现。'
|
|
764
|
+
: '✅ Plan approved by operator. Act Mode activated. Proceed with implementation.';
|
|
765
|
+
if (typeof resumeActiveAgent === 'function') {
|
|
766
|
+
resumeActiveAgent(prompt, sid);
|
|
767
|
+
}
|
|
768
|
+
} else {
|
|
769
|
+
result = { mode: GoalMode.ACT, state: 'RUNNING', approved: true };
|
|
770
|
+
}
|
|
771
|
+
break;
|
|
772
|
+
}
|
|
773
|
+
|
|
717
774
|
case 'branch_action': {
|
|
718
775
|
const op = String(data.operation || data.branchOperation || 'merge').toLowerCase();
|
|
719
776
|
const targetBranch = String(data.targetBranch || 'main');
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan Mode tool execution guard for @goodandready/dsh-goal.
|
|
3
|
+
* Blocks mutating file operations and commands during the planning phase.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const READ_ONLY_TOOLS = new Set([
|
|
7
|
+
'view_file',
|
|
8
|
+
'read_file',
|
|
9
|
+
'read_url_content',
|
|
10
|
+
'search_web',
|
|
11
|
+
'search',
|
|
12
|
+
'grep',
|
|
13
|
+
'glob',
|
|
14
|
+
'find',
|
|
15
|
+
'list_dir',
|
|
16
|
+
'dir_list',
|
|
17
|
+
'code_search',
|
|
18
|
+
'read_resource',
|
|
19
|
+
'list_resources',
|
|
20
|
+
'brain_recall',
|
|
21
|
+
'brain_list',
|
|
22
|
+
'brain_status',
|
|
23
|
+
'brain_remember',
|
|
24
|
+
'ask_question',
|
|
25
|
+
'send_message',
|
|
26
|
+
'schedule',
|
|
27
|
+
'manage_task',
|
|
28
|
+
'get_goal',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export const FILE_WRITER_TOOLS = new Set([
|
|
32
|
+
'write_to_file',
|
|
33
|
+
'replace_file_content',
|
|
34
|
+
'edit_file',
|
|
35
|
+
'apply_diff',
|
|
36
|
+
'patch',
|
|
37
|
+
'create_file',
|
|
38
|
+
'delete_file',
|
|
39
|
+
'remove_file',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const SAFE_COMMAND_PATTERN = /^(git\s+(status|diff|log|branch|show)|ls|dir|pwd|cat|head|tail|grep|find|wc|echo|which|where)(\s+|$)/i;
|
|
43
|
+
const MUTATING_COMMAND_PATTERN = /(\b(rm|rmdir|mv|cp|mkdir|touch|chmod|chown|sed\s+-i|git\s+(commit|checkout\s+-b|push|merge|rebase|reset|restore))\b|[>|&;])/i;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check whether a tool is permitted in Plan Mode.
|
|
47
|
+
* @param {string} toolName
|
|
48
|
+
* @param {object} [args]
|
|
49
|
+
* @returns {boolean}
|
|
50
|
+
*/
|
|
51
|
+
export function isPermittedInPlanMode(toolName, args = {}) {
|
|
52
|
+
if (!toolName || typeof toolName !== 'string') return false;
|
|
53
|
+
|
|
54
|
+
// Milestone and checklist tools are explicitly permitted in plan mode
|
|
55
|
+
if (toolName === 'goal_milestones' || toolName === 'goal_checklist') {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Explicit file writing tools are strictly blocked
|
|
60
|
+
if (FILE_WRITER_TOOLS.has(toolName)) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check command execution
|
|
65
|
+
if (toolName === 'run_command' || toolName === 'execute_command' || toolName === 'bash') {
|
|
66
|
+
const rawCmd = (args.CommandLine || args.command || args.cmd || '').trim();
|
|
67
|
+
if (!rawCmd) return true;
|
|
68
|
+
if (SAFE_COMMAND_PATTERN.test(rawCmd) && !MUTATING_COMMAND_PATTERN.test(rawCmd)) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Allow known read-only inspection tools
|
|
75
|
+
if (READ_ONLY_TOOLS.has(toolName)) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Default fail-closed for unknown tools in plan mode
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Create a tool guard function for Cordis ctx.tools.guard.
|
|
85
|
+
* @param {import('./goal-engine.js').GoalEngine} engine
|
|
86
|
+
* @param {object} [logger]
|
|
87
|
+
* @returns {(execution: object) => string | undefined}
|
|
88
|
+
*/
|
|
89
|
+
export function createPlanModeGuard(engine, logger = console) {
|
|
90
|
+
return function planModeGuard(execution) {
|
|
91
|
+
if (!engine || typeof engine.isPlanMode !== 'function') return undefined;
|
|
92
|
+
|
|
93
|
+
const sid = execution?.sessionId || execution?.session?.id || 'default';
|
|
94
|
+
if (!engine.isPlanMode(sid)) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const toolName = execution?.name || execution?.tool || '';
|
|
99
|
+
if (isPermittedInPlanMode(toolName, execution?.arguments)) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const rejection = `[Plan Mode Active] Tool "${toolName}" is blocked in Plan Mode. The goal is in the planning phase and file modifications are restricted until the plan is approved by the operator. Use goal_milestones to define the milestones and ask the operator to approve the plan or switch to Act Mode.`;
|
|
104
|
+
logger?.warn?.(`[dsh-goal] Blocked tool execution in Plan Mode: ${toolName}`);
|
|
105
|
+
return rejection;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Register the plan mode guard with Cordis context.
|
|
111
|
+
* @param {any} ctx
|
|
112
|
+
* @param {object} options
|
|
113
|
+
* @param {import('./goal-engine.js').GoalEngine} options.engine
|
|
114
|
+
* @param {object} [options.logger]
|
|
115
|
+
*/
|
|
116
|
+
export function registerPlanModeGuard(ctx, { engine, logger = console } = {}) {
|
|
117
|
+
if (!ctx || !engine) return;
|
|
118
|
+
|
|
119
|
+
const guard = createPlanModeGuard(engine, logger);
|
|
120
|
+
|
|
121
|
+
// 1. Cordis tools service guard
|
|
122
|
+
ctx.inject(['tools'], (tctx) => {
|
|
123
|
+
if (typeof tctx.tools?.guard === 'function') {
|
|
124
|
+
try {
|
|
125
|
+
const unguard = tctx.tools.guard(guard);
|
|
126
|
+
if (typeof unguard === 'function' && typeof tctx.effect === 'function') {
|
|
127
|
+
tctx.effect(() => () => {
|
|
128
|
+
try { unguard(); } catch (e) { /* ignore */ }
|
|
129
|
+
}, 'dsh-goal: plan mode tool guard');
|
|
130
|
+
}
|
|
131
|
+
} catch (err) {
|
|
132
|
+
logger?.warn?.('[dsh-goal] Failed to register tools guard:', err.message);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// 2. Pre-execute event hook if present
|
|
138
|
+
if (typeof ctx.on === 'function') {
|
|
139
|
+
ctx.on('tools/pre-execute', (execution, next) => {
|
|
140
|
+
const decision = guard(execution);
|
|
141
|
+
if (decision !== undefined) {
|
|
142
|
+
return { kind: 'deny', reason: decision };
|
|
143
|
+
}
|
|
144
|
+
return typeof next === 'function' ? next() : undefined;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
package/lib/tools.js
CHANGED
|
@@ -76,6 +76,7 @@ export function registerTools(ctx, { milestoneManager, goalsService, logger = co
|
|
|
76
76
|
title: args.title,
|
|
77
77
|
checklist: args.checklist,
|
|
78
78
|
dependsOn: args.dependsOn || args.depends_on,
|
|
79
|
+
verificationCommand: args.verificationCommand || args.verification_command,
|
|
79
80
|
});
|
|
80
81
|
return { ok: true, goalId, milestone: item };
|
|
81
82
|
}
|
|
@@ -124,6 +125,7 @@ export function registerTools(ctx, { milestoneManager, goalsService, logger = co
|
|
|
124
125
|
},
|
|
125
126
|
},
|
|
126
127
|
dependsOn: { type: 'array', items: { type: 'string' } },
|
|
128
|
+
verificationCommand: { type: 'string', description: 'Optional shell command to verify milestone completion' },
|
|
127
129
|
},
|
|
128
130
|
required: ['title'],
|
|
129
131
|
},
|
|
@@ -136,6 +138,10 @@ export function registerTools(ctx, { milestoneManager, goalsService, logger = co
|
|
|
136
138
|
items: { type: 'string' },
|
|
137
139
|
description: 'Initial checklist items for added milestone',
|
|
138
140
|
},
|
|
141
|
+
verificationCommand: {
|
|
142
|
+
type: 'string',
|
|
143
|
+
description: 'Verification command when action is "add"',
|
|
144
|
+
},
|
|
139
145
|
},
|
|
140
146
|
required: ['action'],
|
|
141
147
|
},
|
|
@@ -230,7 +236,12 @@ export function registerTools(ctx, { milestoneManager, goalsService, logger = co
|
|
|
230
236
|
const completed = await mm.completeMilestone(goalId, milestoneId, notes, safeCwd);
|
|
231
237
|
return { ok: true, goalId, milestoneId, status: 'completed', milestone: completed };
|
|
232
238
|
}
|
|
233
|
-
const updated = mm.updateMilestone(goalId, milestoneId, {
|
|
239
|
+
const updated = mm.updateMilestone(goalId, milestoneId, {
|
|
240
|
+
status,
|
|
241
|
+
notes,
|
|
242
|
+
checklist: args.checklist,
|
|
243
|
+
verificationCommand: args.verificationCommand || args.verification_command,
|
|
244
|
+
});
|
|
234
245
|
return { ok: true, goalId, milestoneId, status, milestone: updated };
|
|
235
246
|
};
|
|
236
247
|
|
|
@@ -244,6 +255,7 @@ export function registerTools(ctx, { milestoneManager, goalsService, logger = co
|
|
|
244
255
|
status: { type: 'string', enum: ['pending', 'in_progress', 'completed', 'blocked'], description: 'Milestone status' },
|
|
245
256
|
notes: { type: 'string', description: 'Brief outcome or status notes' },
|
|
246
257
|
checklist: { type: 'array', items: { type: 'string' }, description: 'Subtask items' },
|
|
258
|
+
verificationCommand: { type: 'string', description: 'Optional verification command to set or update' },
|
|
247
259
|
},
|
|
248
260
|
required: ['milestone_id', 'status'],
|
|
249
261
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-goal",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.28",
|
|
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
|
+
}
|