@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 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
- Array.isArray(m.dependsOn) && m.dependsOn.some((depId) => {
2291
- const prereq = milestones.find((x) => x.id === depId || x.title === depId);
2292
- return prereq && prereq.status !== 'completed';
2293
- })
2294
- ? React.createElement('span', { className: 'dsh-goal-lock-badge' }, `🔒 ${t('waitingPrereq') || 'waiting for prereq'}`)
2295
- : null,
2404
+ m.verificationCommand
2405
+ ? React.createElement(
2406
+ 'span',
2407
+ {
2408
+ className: 'dsh-goal-gate-badge',
2409
+ title: `${t('verificationGate') || 'Acceptance gate'}: ${m.verificationCommand}`,
2410
+ },
2411
+ `🛡️ ${m.verificationCommand}`
2412
+ )
2413
+ : null,
2414
+ Array.isArray(m.dependsOn) && m.dependsOn.some((depId) => {
2415
+ const prereq = milestones.find((x) => x.id === depId || x.title === depId);
2416
+ return prereq && prereq.status !== 'completed';
2417
+ })
2418
+ ? React.createElement('span', { className: 'dsh-goal-lock-badge' }, `🔒 ${t('waitingPrereq') || 'waiting for prereq'}`)
2419
+ : null,
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
 
@@ -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 {
@@ -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,
@@ -1,4 +1,5 @@
1
- import { GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, calculateElapsedSeconds, calculateRemainingSeconds, buildSnapshot, getSafeCwd } from './goal-engine-constants.js';
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
- state: GoalState.RUNNING,
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 || goal.state !== GoalState.RUNNING) return this.getSnapshot(sid);
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 || goal.state !== GoalState.PAUSED) return this.getSnapshot(sid);
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 };
@@ -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
- state: 'RUNNING',
482
+ mode: initialMode,
483
+ state: initialMode === 'plan' ? 'PLANNING' : 'RUNNING',
481
484
  milestones: parsedMilestones,
482
485
  };
483
486
  }
484
487
 
485
- const startPrompt = detectedLang === 'zh'
486
- ? `🎯 目标已确立:“${cleanTitle}”。请立即通过 goal_milestones 制定工作计划并开始执行。`
487
- : `🎯 Goal established: "${cleanTitle}". Formulate milestones via goal_milestones and proceed.`;
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, { status, notes, checklist: args.checklist });
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.26",
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
+ }