@goodandready/dsh-goal 0.2.26 → 0.2.27

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,11 @@ 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
+ * **Plan Mode (Read-Only Planning Phase)**: The agent explores the codebase, searches the web, and decomposes tasks into structured milestones and checklists. All file mutating tools (`write_to_file`, `replace_file_content`, mutating shell commands) are strictly blocked by `lib/tool-guard.js`.
141
+ * **Act Mode (Implementation Phase)**: The operator reviews and approves the plan directly via the banner or details modal with «Approve & Execute», unlocking mutating capabilities and resuming the agent.
142
+ * **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.
143
+
139
144
  ---
140
145
 
141
146
  ## 📦 Installation
package/README.ru.md CHANGED
@@ -53,6 +53,7 @@
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).
56
57
 
57
58
  ---
58
59
 
package/README.zh.md CHANGED
@@ -54,6 +54,7 @@
54
54
  * 🔄 **DSH 核心目标工具无缝拦截与兼容 (*v0.1.10 新增*)**: 为使用原生 DSH 目标控制指令的模型提供完美向下兼容,透明接管 `update_goal`、`get_goal`、`create_goal`。拦截并处理 `complete`、`pause`、`resume`、`edit`、`blocked` 等操作,彻底解决原生工具抛出的 `complete and blocked require a direct human turn` 权限崩溃错误,所有状态直接同步至 GoalEngine。
55
55
  * 🛡️ **安全防护机制**:支持自定义最大迭代次数(`maxIterations`)以及智能卡顿检测(Smart Progress Guard)。
56
56
  * 🔔 **Web Audio 提示音**:任务完成或失败时,通过 Web Audio API 播放舒缓的合成音效。
57
+ * 📋 **两阶段目标模式:Plan Mode ⇄ Act Mode (*#162*)**:分离规划设计与落地执行。只读规划阶段严格限制文件变更与危险命令,待操作者在横幅或弹窗点击“批准并执行 (Approve & Execute)”后切入执行阶段。
57
58
 
58
59
  ---
59
60
 
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;
@@ -1855,6 +1915,7 @@ window.__ModuleLoader__.load({
1855
1915
  const milestones = state.milestones || [];
1856
1916
 
1857
1917
  const isCompleted = state.state === 'COMPLETED';
1918
+ const isPlanMode = state.mode === 'plan' || state.state === 'PLANNING';
1858
1919
 
1859
1920
  const [copied, setCopied] = useState(false);
1860
1921
 
@@ -1992,6 +2053,36 @@ window.__ModuleLoader__.load({
1992
2053
 
1993
2054
  ),
1994
2055
 
2056
+ isPlanMode && !isCompleted
2057
+ ? React.createElement(
2058
+ 'div',
2059
+ { className: 'dsh-goal-plan-banner' },
2060
+ React.createElement(
2061
+ 'div',
2062
+ { style: { display: 'flex', flexDirection: 'column', gap: 2 } },
2063
+ React.createElement(
2064
+ 'div',
2065
+ { style: { display: 'flex', alignItems: 'center', gap: 6 } },
2066
+ React.createElement('span', { className: 'dsh-goal-badge dsh-goal-badge-plan' }, t('planMode') || 'Plan Mode'),
2067
+ React.createElement('strong', { style: { fontSize: 13, color: 'var(--dsw-alias-brand-primary)' } }, t('planningPhase') || 'Planning Phase (Read-Only)')
2068
+ ),
2069
+ 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.')
2070
+ ),
2071
+ React.createElement(
2072
+ 'button',
2073
+ {
2074
+ type: 'button',
2075
+ className: 'dsh-goal-btn dsh-goal-btn-approve',
2076
+ style: { padding: '6px 14px', fontSize: 12 },
2077
+ onClick: async () => {
2078
+ await onAction('approve_plan');
2079
+ },
2080
+ },
2081
+ '✓ ' + (t('approveAndExecute') || 'Approve & Execute')
2082
+ )
2083
+ )
2084
+ : null,
2085
+
1995
2086
  !isCompleted
1996
2087
 
1997
2088
  ? React.createElement(
@@ -2561,6 +2652,20 @@ window.__ModuleLoader__.load({
2561
2652
 
2562
2653
  : null,
2563
2654
 
2655
+ isPlanMode && !isCompleted
2656
+ ? React.createElement(
2657
+ 'button',
2658
+ {
2659
+ type: 'button',
2660
+ className: 'dsh-goal-btn dsh-goal-btn-approve',
2661
+ onClick: async () => {
2662
+ await onAction('approve_plan');
2663
+ },
2664
+ },
2665
+ '✓ ' + (t('approveAndExecute') || 'Approve & Execute')
2666
+ )
2667
+ : null,
2668
+
2564
2669
  React.createElement(
2565
2670
 
2566
2671
  'button',
@@ -2590,6 +2695,7 @@ window.__ModuleLoader__.load({
2590
2695
  function QuickLaunchModal({ onClose, onStart, t }) {
2591
2696
 
2592
2697
  const [title, setTitle] = useState('');
2698
+ const [launchMode, setLaunchMode] = useState('act');
2593
2699
  const [showTemplates, setShowTemplates] = useState(false);
2594
2700
  const [backendTemplates, setBackendTemplates] = useState([]);
2595
2701
  const [selectedTpl, setSelectedTpl] = useState(null);
@@ -2649,7 +2755,7 @@ window.__ModuleLoader__.load({
2649
2755
 
2650
2756
  if (trimmed) {
2651
2757
 
2652
- onStart(trimmed);
2758
+ onStart(trimmed, { mode: launchMode });
2653
2759
 
2654
2760
  }
2655
2761
 
@@ -2727,6 +2833,34 @@ window.__ModuleLoader__.load({
2727
2833
 
2728
2834
  ),
2729
2835
 
2836
+ React.createElement(
2837
+ 'div',
2838
+ { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 } },
2839
+ React.createElement('span', { style: { fontSize: 12, fontWeight: 600, color: 'var(--dsw-alias-label-secondary)' } }, t('launchModeLabel') || 'Mode:'),
2840
+ React.createElement(
2841
+ 'button',
2842
+ {
2843
+ type: 'button',
2844
+ className: `dsh-goal-chip-btn ${launchMode === 'act' ? 'active' : ''}`,
2845
+ style: launchMode === 'act' ? { fontWeight: 600, borderColor: 'var(--dsw-alias-state-success-primary)', color: 'var(--dsw-alias-state-success-primary)' } : {},
2846
+ onClick: () => setLaunchMode('act'),
2847
+ title: t('actModeDesc') || 'Direct execution right away',
2848
+ },
2849
+ '⚡ ' + (t('actMode') || 'Act Mode')
2850
+ ),
2851
+ React.createElement(
2852
+ 'button',
2853
+ {
2854
+ type: 'button',
2855
+ className: `dsh-goal-chip-btn ${launchMode === 'plan' ? 'active' : ''}`,
2856
+ style: launchMode === 'plan' ? { fontWeight: 600, borderColor: 'var(--dsw-alias-brand-primary)', color: 'var(--dsw-alias-brand-primary)' } : {},
2857
+ onClick: () => setLaunchMode('plan'),
2858
+ title: t('planModeDesc') || 'Plan first, review milestones, then execute',
2859
+ },
2860
+ '📋 ' + (t('planMode') || 'Plan Mode')
2861
+ )
2862
+ ),
2863
+
2730
2864
  React.createElement(
2731
2865
 
2732
2866
  'div',
@@ -3504,9 +3638,9 @@ window.__ModuleLoader__.load({
3504
3638
 
3505
3639
  onClose: () => setIsQuickLaunchOpen(false),
3506
3640
 
3507
- onStart: async (title) => {
3641
+ onStart: async (title, opts) => {
3508
3642
 
3509
- await handleAction('start', { title });
3643
+ await handleAction('start', { title, mode: opts?.mode });
3510
3644
 
3511
3645
  setIsQuickLaunchOpen(false);
3512
3646
 
@@ -3526,6 +3660,8 @@ window.__ModuleLoader__.load({
3526
3660
 
3527
3661
  const isCompleted = state.state === 'COMPLETED';
3528
3662
 
3663
+ const isPlanMode = state.mode === 'plan' || state.state === 'PLANNING';
3664
+
3529
3665
  const formatElapsed = (totalSeconds) => {
3530
3666
  const s = Math.max(0, Math.floor(Number(totalSeconds) || 0));
3531
3667
  if (s < 60) return `${s}s`;
@@ -3580,11 +3716,21 @@ window.__ModuleLoader__.load({
3580
3716
  ),
3581
3717
  React.createElement(
3582
3718
  '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')
3719
+ { className: `dsh-goal-badge ${isCompleted ? 'dsh-goal-badge-ok' : isPlanMode ? 'dsh-goal-badge-plan' : isPaused ? 'dsh-goal-badge-warn' : ''}` },
3720
+ isCompleted ? (t('goalCompleted') || 'Done') : isPlanMode ? (t('planMode') || 'Plan Mode') : isPaused ? (t('paused') || 'Paused') : (t('goalLabel') || 'Goal')
3585
3721
  ),
3586
3722
  React.createElement('span', { className: 'dsh-goal-title', title: state.title }, state.title),
3587
3723
  React.createElement('span', { className: 'dsh-goal-time' }, `⏱ ${liveElapsedStr}`),
3724
+ isPlanMode && !isCompleted ? React.createElement(
3725
+ 'button',
3726
+ {
3727
+ className: 'dsh-goal-btn dsh-goal-btn-approve',
3728
+ style: { padding: '2px 6px', height: 22, fontSize: 11 },
3729
+ title: t('approveAndExecute') || 'Approve & Execute',
3730
+ onClick: () => handleAction('approve_plan'),
3731
+ },
3732
+ '✓ ' + (t('approveAndExecute') || 'Approve')
3733
+ ) : null,
3588
3734
  !isCompleted ? React.createElement(
3589
3735
  'button',
3590
3736
  {
@@ -3644,11 +3790,11 @@ window.__ModuleLoader__.load({
3644
3790
 
3645
3791
  {
3646
3792
 
3647
- className: `dsh-goal-badge ${isCompleted ? 'dsh-goal-badge-ok' : isPaused ? 'dsh-goal-badge-warn' : ''}`,
3793
+ className: `dsh-goal-badge ${isCompleted ? 'dsh-goal-badge-ok' : isPlanMode ? 'dsh-goal-badge-plan' : isPaused ? 'dsh-goal-badge-warn' : ''}`,
3648
3794
 
3649
3795
  },
3650
3796
 
3651
- isCompleted ? (t('goalCompleted') || 'Goal completed') : isPaused ? (t('paused') || 'Paused') : (t('goalLabel') || 'Current goal'),
3797
+ isCompleted ? (t('goalCompleted') || 'Goal completed') : isPlanMode ? (t('planMode') || 'Plan Mode') : isPaused ? (t('paused') || 'Paused') : (t('goalLabel') || 'Current goal'),
3652
3798
 
3653
3799
  ),
3654
3800
 
@@ -3672,6 +3818,18 @@ window.__ModuleLoader__.load({
3672
3818
 
3673
3819
  ),
3674
3820
 
3821
+ isPlanMode && !isCompleted
3822
+ ? React.createElement(
3823
+ 'button',
3824
+ {
3825
+ className: 'dsh-goal-btn dsh-goal-btn-approve',
3826
+ style: { fontSize: 12, padding: '2px 8px' },
3827
+ title: t('approveAndExecute') || 'Approve & Execute',
3828
+ onClick: () => handleAction('approve_plan'),
3829
+ },
3830
+ '✓ ' + (t('approveAndExecute') || 'Approve & Execute')
3831
+ )
3832
+ : null,
3675
3833
  !isCompleted
3676
3834
 
3677
3835
  ? React.createElement(
@@ -4983,6 +5141,15 @@ window.__ModuleLoader__.load({
4983
5141
  toolFailHint: 'Auto-pause goal if N consecutive turns hit tool execution errors (0 to disable)',
4984
5142
 
4985
5143
  notifLabel: 'Native browser desktop notifications on completion',
5144
+ planMode: 'Plan Mode',
5145
+ actMode: 'Act Mode',
5146
+ approveAndExecute: 'Approve & Execute',
5147
+ switchToPlan: 'Switch to Plan Mode',
5148
+ planningPhase: 'Planning Phase (Read-Only)',
5149
+ planPhaseNotice: 'Agent is formulating the plan. File modifications are guarded until operator approval.',
5150
+ launchModeLabel: 'Launch Mode:',
5151
+ planModeDesc: 'Plan first, review milestones, then execute',
5152
+ actModeDesc: 'Direct execution right away',
4986
5153
 
4987
5154
  },
4988
5155
 
@@ -5122,6 +5289,15 @@ window.__ModuleLoader__.load({
5122
5289
  toolFailHint: '连续 N 轮遭遇工具调用错误时自动暂停(0 表示禁用)',
5123
5290
 
5124
5291
  notifLabel: '目标完成时发送浏览器桌面原生通知',
5292
+ planMode: '规划模式',
5293
+ actMode: '执行模式',
5294
+ approveAndExecute: '批准并执行',
5295
+ switchToPlan: '切换到规划模式',
5296
+ planningPhase: '规划阶段(只读)',
5297
+ planPhaseNotice: '智能体正在制定方案与里程碑。在操作者批准前,禁止修改文件。',
5298
+ launchModeLabel: '启动模式:',
5299
+ planModeDesc: '先制定里程碑方案,人工确认后再执行',
5300
+ actModeDesc: '立即开始编码与任务执行',
5125
5301
 
5126
5302
  },
5127
5303
 
@@ -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
+ }
@@ -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,4 @@
1
- import { GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, calculateElapsedSeconds, calculateRemainingSeconds, buildSnapshot, getSafeCwd } from './goal-engine-constants.js';
1
+ import { GoalMode, GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, calculateElapsedSeconds, calculateRemainingSeconds, buildSnapshot, getSafeCwd } from './goal-engine-constants.js';
2
2
  import { getGitCurrentCommit, exportReportMarkdown, exportReportGitHubPR, createMilestoneCheckpoint, rollbackToCheckpoint, saveGoalArtifact } from './engine-reports.js';
3
3
  import { EngineStore } from './engine-store.js';
4
4
  import { buildStatePromptInjection } from './engine-prompt.js';
@@ -8,9 +8,9 @@ import { createGoalBranch, sanitizeBranchSlug, getCurrentGitBranch, mergeGoalBra
8
8
  import { parseIssueRef, parseIssueChecklist, syncMilestoneWithIssue } from './engine-issue-sync.js';
9
9
  import { generateRetrospectiveData } from './engine-retrospective.js';
10
10
  import { generatePreplanDraft } from './engine-templates.js';
11
- import { pruneInactiveSessions, addNudgeToGoal, consumeGoalNudge, extendGoalBudget } from './engine-sessions.js';
11
+ import { pruneInactiveSessions, addNudgeToGoal, consumeGoalNudge, extendGoalBudget, setGoalMode, pauseGoal, resumeGoal, cancelGoal } from './engine-sessions.js';
12
12
 
13
- export { GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, matchMilestone, parseIssueChecklist };
13
+ export { GoalMode, GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, matchMilestone, parseIssueChecklist };
14
14
  export { getGitCurrentCommit, exportReportMarkdown, exportReportGitHubPR, createMilestoneCheckpoint, rollbackToCheckpoint, saveGoalArtifact };
15
15
 
16
16
  /**
@@ -193,6 +193,9 @@ export class GoalEngine {
193
193
  ? options.gitStartCommit
194
194
  : getGitCurrentCommit(options.cwd);
195
195
 
196
+ const initialMode = options.mode === GoalMode.PLAN ? GoalMode.PLAN : GoalMode.ACT;
197
+ const initialState = initialMode === GoalMode.PLAN ? GoalState.PLANNING : GoalState.RUNNING;
198
+
196
199
  const goal = {
197
200
  id: `goal-${now}-${Math.random().toString(36).substring(2, 7)}`,
198
201
  sessionId: sid,
@@ -200,7 +203,8 @@ export class GoalEngine {
200
203
  description: options.description?.trim() || '',
201
204
  lang: options.lang || detectLanguage(cleanTitle),
202
205
  cwd: options.cwd ? getSafeCwd(options.cwd) : null,
203
- state: GoalState.RUNNING,
206
+ mode: initialMode,
207
+ state: initialState,
204
208
  startedAt: now,
205
209
  pausedAt: null,
206
210
  totalPausedDurationMs: 0,
@@ -265,13 +269,7 @@ export class GoalEngine {
265
269
  pause(reason = 'User requested pause', sessionId = 'default') {
266
270
  const sid = sessionId || 'default';
267
271
  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);
272
+ if (!pauseGoal(goal, reason, recordActivity)) return this.getSnapshot(sid);
275
273
  this.emit(sid, true);
276
274
  return this.getSnapshot(sid);
277
275
  }
@@ -279,18 +277,8 @@ export class GoalEngine {
279
277
  resume(sessionId = 'default') {
280
278
  const sid = sessionId || 'default';
281
279
  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;
280
+ if (!resumeGoal(goal, recordActivity)) return this.getSnapshot(sid);
290
281
  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
282
  this.emit(sid, true);
295
283
  return this.getSnapshot(sid);
296
284
  }
@@ -298,13 +286,7 @@ export class GoalEngine {
298
286
  cancel(reason = 'Cancelled by user', sessionId = 'default') {
299
287
  const sid = sessionId || 'default';
300
288
  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);
289
+ if (!cancelGoal(goal, reason, recordActivity)) return null;
308
290
  this.emit(sid, true);
309
291
  return this.getSnapshot(sid);
310
292
  }
@@ -580,4 +562,26 @@ export class GoalEngine {
580
562
  const goal = this.goals.get(sid);
581
563
  if (goal) recordActivity(goal, { type: 'tool', tool, status, details });
582
564
  }
565
+
566
+ setMode(mode, sessionId = 'default') {
567
+ const sid = sessionId || 'default';
568
+ const goal = this.goals.get(sid);
569
+ if (!goal) throw new Error(`No active goal for session "${sid}"`);
570
+ setGoalMode(goal, mode, recordActivity);
571
+ this.emit(sid, true);
572
+ return this.getSnapshot(sid);
573
+ }
574
+
575
+ approvePlan(sessionId = 'default') {
576
+ return this.setMode(GoalMode.ACT, sessionId);
577
+ }
578
+
579
+ getActiveGoal(sessionId = 'default') {
580
+ return this.goals.get(sessionId || 'default') || null;
581
+ }
582
+
583
+ isPlanMode(sessionId = 'default') {
584
+ const goal = this.goals.get(sessionId || 'default');
585
+ return Boolean(goal && (goal.mode === GoalMode.PLAN || goal.state === GoalState.PLANNING));
586
+ }
583
587
  }
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/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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-goal",
3
- "version": "0.2.26",
3
+ "version": "0.2.27",
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",