@goodandready/dsh-goal 0.2.28 → 0.2.30

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/lib/client.js CHANGED
@@ -32,28 +32,152 @@ window.__ModuleLoader__.load({
32
32
 
33
33
  // --- 10 QUICK LAUNCH ENGINEERING TEMPLATES ---
34
34
 
35
- const QUICK_LAUNCH_TEMPLATES = [
36
-
37
- { id: 'fix', icon: '🐛', label: { en: 'Fix Bug', zh: '修复缺陷' }, prefix: { en: 'Investigate bug, pinpoint root cause, apply fix, and verify with tests: ', zh: '调查并定位缺陷根源,实施修复并通过测试验证:' } },
38
-
39
- { id: 'refactor', icon: '⚡', label: { en: 'Refactor (YAGNI)', zh: '重构精简' }, prefix: { en: 'Refactor module applying YAGNI principle, delete redundant abstractions, and verify: ', zh: '遵循 YAGNI 原则重构模块,删除冗余抽象并验证:' } },
40
-
41
- { id: 'tests', icon: '📝', label: { en: 'Tests & Coverage', zh: '补充测试' }, prefix: { en: 'Write comprehensive unit tests covering edge cases for: ', zh: '为以下模块编写覆盖边界情况的完整单元测试:' } },
42
-
43
- { id: 'review', icon: '🔍', label: { en: 'Code Review', zh: '代码审计' }, prefix: { en: 'Review codebase for subtle bugs, memory leaks, and over-engineering in: ', zh: '对代码库进行深入审查,排查潜在缺陷与过度设计:' } },
44
-
45
- { id: 'feature', icon: '🚀', label: { en: 'New Feature', zh: '新功能开发' }, prefix: { en: 'Implement new feature according to specification and add tests for: ', zh: '根据规格说明实现新功能并补充测试:' } },
46
-
47
- { id: 'security', icon: '🛡️', label: { en: 'Security Audit', zh: '安全加固' }, prefix: { en: 'Audit security boundaries, sanitize inputs, and prevent injection in: ', zh: '审计安全边界,增加输入过滤与防注入保护:' } },
48
-
49
- { id: 'docs', icon: '📚', label: { en: 'Docs & Contract', zh: '文档规范' }, prefix: { en: 'Update technical documentation, README, and DESIGN.md conforming to standard for: ', zh: '按标准更新技术文档、README 与 DESIGN.md:' } },
50
-
51
- { id: 'upgrade', icon: '📦', label: { en: 'Upgrade Deps', zh: '依赖升级' }, prefix: { en: 'Upgrade dependencies, resolve breaking API changes, and verify build for: ', zh: '安全升级依赖库,解决接口兼容性并确保构建通过:' } },
52
-
53
- { id: 'cleanup', icon: '🧹', label: { en: 'Dead Code', zh: '清理死代码' }, prefix: { en: 'Find and safely remove dead code, unused exports, and stale fixtures in: ', zh: '查找并安全移除死代码、未使用的导出与失效测试用例:' } },
54
-
55
- { id: 'perf', icon: '⚙️', label: { en: 'Performance', zh: '性能优化' }, prefix: { en: 'Profile performance bottlenecks, eliminate unnecessary blocking calls in: ', zh: '分析性能瓶颈,消除阻塞调用并优化响应速度:' } },
35
+ function formatQuickLaunchPrompt(tmpl, vars, lang) {
36
+ if (!tmpl) return "";
37
+ const chosenLang = lang === "zh" ? "zh" : "en";
38
+ const tStr = tmpl.template ? (tmpl.template[chosenLang] || tmpl.template.en) : null;
39
+ if (!tStr) return tmpl.prefix ? (tmpl.prefix[chosenLang] || tmpl.prefix.en) : "";
40
+ return tStr.replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, (_, key) => {
41
+ return (vars && vars[key] != null && vars[key] !== "") ? String(vars[key]) : "";
42
+ }).replace(/\s+/g, " ").trim();
43
+ }
56
44
 
45
+ const QUICK_LAUNCH_TEMPLATES = [
46
+ {
47
+ id: 'fix',
48
+ icon: "🐛",
49
+ label: { en: "Fix Bug", zh: "修复缺陷" },
50
+ prefix: { en: "Investigate bug, pinpoint root cause, apply fix, and verify with tests: ", zh: "调查并定位缺陷根源,实施修复并通过测试验证:" },
51
+ template: {
52
+ en: "Investigate bug {{issueNumber}} in {{targetModule}}, pinpoint root cause, apply targeted fix, and verify with tests: {{description}}",
53
+ zh: "调查缺陷 {{issueNumber}}(位于 {{targetModule}}),定位根因,实施修复并通过测试验证:{{description}}"
54
+ },
55
+ variables: [
56
+ { id: "issueNumber", label: { en: "Issue #", zh: "Issue 编号" }, placeholder: '#issue-123', required: true },
57
+ { id: "targetModule", label: { en: "Target Module", zh: "目标模块" }, placeholder: "lib/tools.js" },
58
+ { id: 'description', label: { en: "Description / Symptom", zh: "症状或描述" }, placeholder: "Describe symptom..." },
59
+ ],
60
+ },
61
+ {
62
+ id: 'refactor',
63
+ icon: "⚡",
64
+ label: { en: "Refactor (YAGNI)", zh: "重构精简" },
65
+ prefix: { en: "Refactor module applying YAGNI principle, delete redundant abstractions, and verify: ", zh: "遵循 YAGNI 原则重构模块,删除冗余抽象并验证:" },
66
+ template: {
67
+ en: "Refactor module {{targetFile}} applying YAGNI principle to < {{maxLines}} lines, delete redundant abstractions, and verify tests.",
68
+ zh: "遵循 YAGNI 原则重构模块 {{targetFile}} 至 < {{maxLines}} 行,删除冗余代码并验证测试。"
69
+ },
70
+ variables: [
71
+ { id: "targetFile", label: { en: "Target File", zh: "目标文件" }, placeholder: "lib/goal-engine.js", required: true },
72
+ { id: "maxLines", label: { en: "Max Lines Threshold", zh: "行数上限" }, placeholder: "500" },
73
+ ],
74
+ },
75
+ {
76
+ id: 'tests',
77
+ icon: "📝",
78
+ label: { en: "Tests & Coverage", zh: "补充测试" },
79
+ prefix: { en: "Write comprehensive unit tests covering edge cases for: ", zh: "为以下模块编写覆盖边界情况的完整单元测试:" },
80
+ template: {
81
+ en: "Write comprehensive unit tests in {{testFile}} covering edge cases for {{targetFile}}.",
82
+ zh: "在 {{testFile}} 中为 {{targetFile}} 编写覆盖边界情况的完整单元测试。"
83
+ },
84
+ variables: [
85
+ { id: "targetFile", label: { en: "Module to Test", zh: "被测模块" }, placeholder: "lib/engine-verification.js", required: true },
86
+ { id: "testFile", label: { en: "Test File Path", zh: "测试文件路径" }, placeholder: "test/acceptance-gate.test.mjs" },
87
+ ],
88
+ },
89
+ {
90
+ id: 'review',
91
+ icon: "🔍",
92
+ label: { en: "Code Review", zh: "代码审计" },
93
+ prefix: { en: "Review codebase for subtle bugs, memory leaks, and over-engineering in: ", zh: "对代码库进行深入审查,排查潜在缺陷与过度设计:" },
94
+ template: {
95
+ en: "Review codebase for subtle bugs, memory leaks, and over-engineering in {{targetScope}}.",
96
+ zh: "对 {{targetScope}} 进行深入审查,排查潜在缺陷与过度设计。"
97
+ },
98
+ variables: [
99
+ { id: "targetScope", label: { en: "Target Scope", zh: "审查范围" }, placeholder: "lib/ and src/client/", required: true },
100
+ ],
101
+ },
102
+ {
103
+ id: 'feature',
104
+ icon: "🚀",
105
+ label: { en: "New Feature", zh: "新功能开发" },
106
+ prefix: { en: "Implement new feature according to specification and add tests for: ", zh: "根据规格说明实现新功能并补充测试:" },
107
+ template: {
108
+ en: "Implement feature {{featureName}} according to specification {{specDetails}} and add comprehensive tests.",
109
+ zh: "根据规格 {{specDetails}} 实现新功能 {{featureName}} 并补充完整测试。"
110
+ },
111
+ variables: [
112
+ { id: "featureName", label: { en: "Feature Name", zh: "功能名称" }, placeholder: "Bi-directional kanban sync", required: true },
113
+ { id: "specDetails", label: { en: "Specification / Issue", zh: "规格或Issue" }, placeholder: '#issue-164' },
114
+ ],
115
+ },
116
+ {
117
+ id: 'security',
118
+ icon: "🛡️",
119
+ label: { en: "Security Audit", zh: "安全加固" },
120
+ prefix: { en: "Audit security boundaries, sanitize inputs, and prevent injection in: ", zh: "审计安全边界,增加输入过滤与防注入保护:" },
121
+ template: {
122
+ en: "Audit security boundaries, sanitize inputs, and prevent injection in {{targetRoute}}.",
123
+ zh: "审计 {{targetRoute}} 的安全边界,增加输入过滤与防注入保护。"
124
+ },
125
+ variables: [
126
+ { id: "targetRoute", label: { en: "Routes / Subsystem", zh: "路由或子系统" }, placeholder: "lib/routes.js", required: true },
127
+ ],
128
+ },
129
+ {
130
+ id: 'docs',
131
+ icon: "📚",
132
+ label: { en: "Docs & Contract", zh: "文档规范" },
133
+ prefix: { en: "Update technical documentation, README, and DESIGN.md conforming to standard for: ", zh: "按标准更新技术文档、README 与 DESIGN.md:" },
134
+ template: {
135
+ en: "Update technical documentation, README, and {{docTarget}} conforming to engineering standards.",
136
+ zh: "按标准更新技术文档、README 与 {{docTarget}}。"
137
+ },
138
+ variables: [
139
+ { id: "docTarget", label: { en: "Doc Scope / Module", zh: "文档范围或模块" }, placeholder: "docs/design/DESIGN.md", required: true },
140
+ ],
141
+ },
142
+ {
143
+ id: 'upgrade',
144
+ icon: "📦",
145
+ label: { en: "Upgrade Deps", zh: "依赖升级" },
146
+ prefix: { en: "Upgrade dependencies, resolve breaking API changes, and verify build for: ", zh: "安全升级依赖库,解决接口兼容性并确保构建通过:" },
147
+ template: {
148
+ en: "Upgrade dependency {{depName}}, resolve breaking API changes, and verify build.",
149
+ zh: "升级依赖库 {{depName}},解决接口兼容性并确保构建通过。"
150
+ },
151
+ variables: [
152
+ { id: "depName", label: { en: "Dependency Name", zh: "依赖包名" }, placeholder: "cordis@^4.0.0", required: true },
153
+ ],
154
+ },
155
+ {
156
+ id: 'cleanup',
157
+ icon: "🧹",
158
+ label: { en: "Dead Code", zh: "清理死代码" },
159
+ prefix: { en: "Find and safely remove dead code, unused exports, and stale fixtures in: ", zh: "查找并安全移除死代码、未使用的导出与失效测试用例:" },
160
+ template: {
161
+ en: "Find and safely remove dead code, unused exports, and stale fixtures in {{cleanupTarget}}.",
162
+ zh: "查找并安全移除 {{cleanupTarget}} 中的死代码、未使用的导出与失效测试用例。"
163
+ },
164
+ variables: [
165
+ { id: "cleanupTarget", label: { en: "Target Scope", zh: "清理目标" }, placeholder: "src/client/ or unused fixtures", required: true },
166
+ ],
167
+ },
168
+ {
169
+ id: 'perf',
170
+ icon: "⚙️",
171
+ label: { en: "Performance", zh: "性能优化" },
172
+ prefix: { en: "Profile performance bottlenecks, eliminate unnecessary blocking calls in: ", zh: "分析性能瓶颈,消除阻塞调用并优化响应速度:" },
173
+ template: {
174
+ en: "Profile performance bottlenecks, eliminate unnecessary blocking calls in {{perfTarget}}.",
175
+ zh: "分析性能瓶颈,消除 {{perfTarget}} 中的阻塞调用并优化响应速度。"
176
+ },
177
+ variables: [
178
+ { id: "perfTarget", label: { en: "Subsystem / Function", zh: "优化目标" }, placeholder: "buildClient or state flush", required: true },
179
+ ],
180
+ },
57
181
  ];
58
182
 
59
183
  // --- SVG ICONS ---
@@ -1653,6 +1777,28 @@ window.__ModuleLoader__.load({
1653
1777
  gap: 3px;
1654
1778
  }
1655
1779
 
1780
+ .dsh-goal-template-chip.active {
1781
+ border-color: var(--dsw-alias-brand-primary);
1782
+ font-weight: 600;
1783
+ background: var(--dsw-alias-bg-layer-3);
1784
+ }
1785
+
1786
+ .dsh-goal-operator-card {
1787
+ background: var(--dsw-alias-bg-sunken);
1788
+ border: 1px solid var(--dsw-alias-state-warning-primary);
1789
+ border-radius: 6px;
1790
+ padding: 10px 12px;
1791
+ margin-top: 8px;
1792
+ }
1793
+
1794
+ .dsh-goal-tpl-vars-box {
1795
+ background: var(--dsw-alias-bg-sunken);
1796
+ border: 1px solid var(--dsw-alias-border-l1);
1797
+ border-radius: 6px;
1798
+ padding: 8px 10px;
1799
+ margin-bottom: 10px;
1800
+ }
1801
+
1656
1802
  .dsh-goal-gate-error {
1657
1803
  font-size: 11px;
1658
1804
  color: var(--dsw-alias-state-danger-primary);
@@ -2736,6 +2882,8 @@ window.__ModuleLoader__.load({
2736
2882
 
2737
2883
  const [title, setTitle] = useState('');
2738
2884
  const [launchMode, setLaunchMode] = useState('act');
2885
+ const [activeQuickTpl, setActiveQuickTpl] = useState(null);
2886
+ const [quickTplVars, setQuickTplVars] = useState({});
2739
2887
  const [showTemplates, setShowTemplates] = useState(false);
2740
2888
  const [backendTemplates, setBackendTemplates] = useState([]);
2741
2889
  const [selectedTpl, setSelectedTpl] = useState(null);
@@ -2925,16 +3073,27 @@ window.__ModuleLoader__.load({
2925
3073
 
2926
3074
  type: 'button',
2927
3075
 
2928
- className: 'dsh-goal-template-chip',
3076
+ className: `dsh-goal-template-chip ${activeQuickTpl?.id === tmpl.id ? 'active' : ''}`,
3077
+ style: activeQuickTpl?.id === tmpl.id ? { borderColor: 'var(--dsw-alias-brand-primary)', fontWeight: 600 } : {},
2929
3078
 
2930
3079
  title: prefix,
2931
3080
 
2932
3081
  onClick: () => {
2933
-
2934
- setTitle(prefix);
2935
-
3082
+ const isSelected = activeQuickTpl?.id === tmpl.id;
3083
+ if (isSelected) {
3084
+ setActiveQuickTpl(null);
3085
+ setQuickTplVars({});
3086
+ } else {
3087
+ setActiveQuickTpl(tmpl);
3088
+ const initial = {};
3089
+ (tmpl.variables || []).forEach((v) => { initial[v.id] = ""; });
3090
+ setQuickTplVars(initial);
3091
+ const generated = typeof formatQuickLaunchPrompt === "function"
3092
+ ? formatQuickLaunchPrompt(tmpl, initial, lang)
3093
+ : prefix;
3094
+ setTitle(generated || prefix);
3095
+ }
2936
3096
  if (inputRef.current) inputRef.current.focus();
2937
-
2938
3097
  },
2939
3098
 
2940
3099
  },
@@ -2947,6 +3106,75 @@ window.__ModuleLoader__.load({
2947
3106
 
2948
3107
  ),
2949
3108
 
3109
+ activeQuickTpl?.variables && activeQuickTpl.variables.length > 0
3110
+ ? React.createElement(
3111
+ 'div',
3112
+ {
3113
+ className: 'dsh-goal-tpl-vars-box',
3114
+ style: {
3115
+ background: 'var(--dsw-alias-bg-sunken)',
3116
+ border: '1px solid var(--dsw-alias-border-l1)',
3117
+ borderRadius: 6,
3118
+ padding: '8px 10px',
3119
+ marginBottom: 10,
3120
+ },
3121
+ },
3122
+ React.createElement(
3123
+ 'div',
3124
+ {
3125
+ style: {
3126
+ fontSize: 11,
3127
+ fontWeight: 600,
3128
+ color: 'var(--dsw-alias-label-secondary)',
3129
+ marginBottom: 6,
3130
+ display: 'flex',
3131
+ alignItems: 'center',
3132
+ justifyContent: 'space-between',
3133
+ },
3134
+ },
3135
+ React.createElement('span', null, `${activeQuickTpl.icon} ${activeQuickTpl.label[lang] || activeQuickTpl.label.en} ${t('quickTplVariablesTitle') || 'Parameters:'}`),
3136
+ React.createElement(
3137
+ 'button',
3138
+ {
3139
+ type: 'button',
3140
+ className: 'dsh-goal-btn icon-only',
3141
+ style: { fontSize: 10, padding: '2px 4px', height: 'auto' },
3142
+ onClick: () => { setActiveQuickTpl(null); setQuickTplVars({}); },
3143
+ },
3144
+ '✕'
3145
+ )
3146
+ ),
3147
+ activeQuickTpl.variables.map((v) => {
3148
+ const vLabel = v.label ? (v.label[lang] || v.label.en) : v.id;
3149
+ return React.createElement(
3150
+ 'div',
3151
+ { key: v.id, style: { marginBottom: 6 } },
3152
+ React.createElement(
3153
+ 'label',
3154
+ { style: { fontSize: 11, display: 'block', color: 'var(--dsw-alias-label-primary)', marginBottom: 2 } },
3155
+ vLabel,
3156
+ v.required ? React.createElement('span', { style: { color: 'var(--dsw-alias-state-danger-primary)', marginLeft: 3 } }, '*') : null
3157
+ ),
3158
+ React.createElement('input', {
3159
+ type: 'text',
3160
+ className: 'dsh-goal-card-input',
3161
+ style: { width: '100%', boxSizing: 'border-box', fontSize: 12, padding: '4px 8px' },
3162
+ placeholder: v.placeholder || '',
3163
+ value: quickTplVars[v.id] || '',
3164
+ onChange: (e) => {
3165
+ const updated = { ...quickTplVars, [v.id]: e.target.value };
3166
+ setQuickTplVars(updated);
3167
+ const generated = typeof formatQuickLaunchPrompt === 'function'
3168
+ ? formatQuickLaunchPrompt(activeQuickTpl, updated, lang)
3169
+ : activeQuickTpl.prefix[lang] || activeQuickTpl.prefix.en;
3170
+ setTitle(generated);
3171
+ },
3172
+ })
3173
+ );
3174
+ })
3175
+ )
3176
+ : null,
3177
+
2950
3178
  React.createElement('input', {
2951
3179
 
2952
3180
  ref: inputRef,
@@ -3177,6 +3405,7 @@ window.__ModuleLoader__.load({
3177
3405
  const [isModalOpen, setIsModalOpen] = useState(false);
3178
3406
 
3179
3407
  const [isQuickLaunchOpen, setIsQuickLaunchOpen] = useState(false);
3408
+ const [operatorAnswerInput, setOperatorAnswerInput] = useState('');
3180
3409
 
3181
3410
  const [isDockCollapsed, setIsDockCollapsed] = useState(() => {
3182
3411
  try {
@@ -3701,6 +3930,7 @@ window.__ModuleLoader__.load({
3701
3930
  const isCompleted = state.state === 'COMPLETED';
3702
3931
 
3703
3932
  const isPlanMode = state.mode === 'plan' || state.state === 'PLANNING';
3933
+ const isWaitingOperator = Boolean(state.waitingForOperator && state.operatorQuery);
3704
3934
 
3705
3935
  const formatElapsed = (totalSeconds) => {
3706
3936
  const s = Math.max(0, Math.floor(Number(totalSeconds) || 0));
@@ -3756,8 +3986,8 @@ window.__ModuleLoader__.load({
3756
3986
  ),
3757
3987
  React.createElement(
3758
3988
  'span',
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')
3989
+ { className: `dsh-goal-badge ${isCompleted ? 'dsh-goal-badge-ok' : isWaitingOperator ? 'dsh-goal-badge-warn' : isPlanMode ? 'dsh-goal-badge-plan' : isPaused ? 'dsh-goal-badge-warn' : ''}` },
3990
+ isCompleted ? (t('goalCompleted') || 'Done') : isWaitingOperator ? ('❓ ' + (t('operatorInputRequired') || 'Input Needed')) : isPlanMode ? (t('planMode') || 'Plan Mode') : isPaused ? (t('paused') || 'Paused') : (t('goalLabel') || 'Goal')
3761
3991
  ),
3762
3992
  React.createElement('span', { className: 'dsh-goal-title', title: state.title }, state.title),
3763
3993
  React.createElement('span', { className: 'dsh-goal-time' }, `⏱ ${liveElapsedStr}`),
@@ -3965,6 +4195,112 @@ window.__ModuleLoader__.load({
3965
4195
 
3966
4196
  ),
3967
4197
 
4198
+ isWaitingOperator
4199
+ ? React.createElement(
4200
+ 'div',
4201
+ {
4202
+ className: 'dsh-goal-operator-card',
4203
+ style: {
4204
+ background: 'var(--dsw-alias-bg-sunken)',
4205
+ border: '1px solid var(--dsw-alias-state-warning-primary)',
4206
+ borderRadius: 6,
4207
+ padding: '10px 12px',
4208
+ marginTop: 8,
4209
+ },
4210
+ },
4211
+ React.createElement(
4212
+ 'div',
4213
+ {
4214
+ style: {
4215
+ display: 'flex',
4216
+ alignItems: 'center',
4217
+ gap: 6,
4218
+ fontSize: 12,
4219
+ fontWeight: 600,
4220
+ color: 'var(--dsw-alias-state-warning-primary)',
4221
+ marginBottom: 4,
4222
+ },
4223
+ },
4224
+ React.createElement('span', null, '❓'),
4225
+ React.createElement('span', null, t('operatorInputRequired') || 'Operator Input Required')
4226
+ ),
4227
+ React.createElement(
4228
+ 'div',
4229
+ {
4230
+ style: {
4231
+ fontSize: 13,
4232
+ fontWeight: 500,
4233
+ color: 'var(--dsw-alias-label-primary)',
4234
+ marginBottom: 8,
4235
+ wordBreak: 'break-word',
4236
+ },
4237
+ },
4238
+ state.operatorQuery.question
4239
+ ),
4240
+ Array.isArray(state.operatorQuery.options) && state.operatorQuery.options.length > 0
4241
+ ? React.createElement(
4242
+ 'div',
4243
+ {
4244
+ className: 'dsh-goal-operator-options',
4245
+ style: { display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 },
4246
+ },
4247
+ state.operatorQuery.options.map((opt, idx) =>
4248
+ React.createElement(
4249
+ 'button',
4250
+ {
4251
+ key: idx,
4252
+ type: 'button',
4253
+ className: 'dsh-goal-btn',
4254
+ style: {
4255
+ fontSize: 12,
4256
+ padding: '4px 10px',
4257
+ borderColor: 'var(--dsw-alias-brand-primary)',
4258
+ color: 'var(--dsw-alias-brand-primary)',
4259
+ },
4260
+ onClick: () => handleAction('answer_operator', { answer: opt, optionIndex: idx }),
4261
+ },
4262
+ opt
4263
+ )
4264
+ )
4265
+ )
4266
+ : null,
4267
+ React.createElement(
4268
+ 'div',
4269
+ { style: { display: 'flex', gap: 6, alignItems: 'center' } },
4270
+ React.createElement('input', {
4271
+ type: 'text',
4272
+ className: 'dsh-goal-card-input',
4273
+ style: { flex: 1, fontSize: 12, padding: '4px 8px' },
4274
+ placeholder: t('answerPlaceholder') || 'Type your answer or select an option above...',
4275
+ value: operatorAnswerInput,
4276
+ onChange: (e) => setOperatorAnswerInput(e.target.value),
4277
+ onKeyDown: (e) => {
4278
+ if (e.key === 'Enter' && operatorAnswerInput.trim()) {
4279
+ e.preventDefault();
4280
+ handleAction('answer_operator', { answer: operatorAnswerInput.trim() });
4281
+ setOperatorAnswerInput('');
4282
+ }
4283
+ },
4284
+ }),
4285
+ React.createElement(
4286
+ 'button',
4287
+ {
4288
+ type: 'button',
4289
+ className: 'dsh-goal-btn dsh-goal-btn-primary',
4290
+ style: { fontSize: 12, padding: '4px 12px', whiteSpace: 'nowrap' },
4291
+ disabled: !operatorAnswerInput.trim(),
4292
+ onClick: () => {
4293
+ if (operatorAnswerInput.trim()) {
4294
+ handleAction('answer_operator', { answer: operatorAnswerInput.trim() });
4295
+ setOperatorAnswerInput('');
4296
+ }
4297
+ },
4298
+ },
4299
+ t('submitAnswer') || 'Submit'
4300
+ )
4301
+ )
4302
+ )
4303
+ : null
3968
4304
  ),
3969
4305
 
3970
4306
  ),
@@ -5081,6 +5417,12 @@ window.__ModuleLoader__.load({
5081
5417
  mergeBranch: 'Merge Branch',
5082
5418
  discardBranch: 'Discard Branch',
5083
5419
  retrospectiveTitle: 'Goal Retrospective',
5420
+ operatorInputRequired: 'Operator Input Required',
5421
+ operatorQuestion: 'Question from agent:',
5422
+ submitAnswer: 'Submit',
5423
+ answerPlaceholder: 'Type your answer or select an option above...',
5424
+ quickTplVariablesTitle: 'Parameters:',
5425
+ requiredField: 'Required',
5084
5426
  verificationGate: 'Acceptance gate',
5085
5427
  verificationPassed: 'Verified',
5086
5428
  verificationFailed: 'Gate failed',
@@ -0,0 +1,161 @@
1
+ import { GoalState } from './goal-engine-constants.js';
2
+ import { recordActivity } from './engine-intervention.js';
3
+
4
+ /**
5
+ * Ask the human operator a question when encountering ambiguity or critical decisions (#166).
6
+ * Sets the goal into a waiting/paused state and records the query.
7
+ *
8
+ * @param {object} goal
9
+ * @param {object} query
10
+ * @param {string} query.question - Question text
11
+ * @param {string[]} [query.options] - Predefined choices for the operator
12
+ * @param {boolean} [query.isBlocking=true] - Whether to pause execution until answered
13
+ * @returns {object} result
14
+ */
15
+ export function askOperatorQuery(goal, query = {}) {
16
+ if (!goal) {
17
+ return { ok: false, error: 'No active goal' };
18
+ }
19
+
20
+ const rawQ = query.question ? String(query.question).trim() : '';
21
+ if (!rawQ) {
22
+ return { ok: false, error: 'Question text is required' };
23
+ }
24
+
25
+ const cleanOptions = Array.isArray(query.options)
26
+ ? query.options.map((o) => String(o).trim()).filter(Boolean)
27
+ : [];
28
+
29
+ const isBlocking = query.isBlocking !== false;
30
+
31
+ const queryId = `opq-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
32
+ goal.operatorQuery = {
33
+ id: queryId,
34
+ question: rawQ,
35
+ options: cleanOptions,
36
+ isBlocking,
37
+ askedAt: Date.now(),
38
+ answeredAt: null,
39
+ answer: null,
40
+ optionIndex: null,
41
+ consumed: false,
42
+ };
43
+
44
+ goal.waitingForOperator = true;
45
+
46
+ if (isBlocking) {
47
+ if (!goal.previousStateBeforeOperator) {
48
+ goal.previousStateBeforeOperator = goal.state || GoalState.RUNNING;
49
+ }
50
+ goal.state = GoalState.PAUSED;
51
+ if (!goal.pausedAt) {
52
+ goal.pausedAt = Date.now();
53
+ }
54
+ }
55
+
56
+ recordActivity(goal, {
57
+ type: 'operator_query',
58
+ tool: 'goal_ask_operator',
59
+ status: 'pending',
60
+ details: `Asked operator: "${rawQ.slice(0, 80)}"`,
61
+ });
62
+
63
+ if (!Array.isArray(goal.logs)) goal.logs = [];
64
+ goal.logs.push({
65
+ timestamp: Date.now(),
66
+ type: 'warning',
67
+ message: `Goal awaiting operator input: "${rawQ}"`,
68
+ });
69
+ if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
70
+
71
+ return { ok: true, queryId, question: rawQ, options: cleanOptions, isBlocking };
72
+ }
73
+
74
+ /**
75
+ * Record operator response to a pending query and resume goal execution (#166).
76
+ *
77
+ * @param {object} goal
78
+ * @param {object} payload
79
+ * @param {string} [payload.answer] - Freeform text answer
80
+ * @param {number} [payload.optionIndex] - Index of selected choice in options array
81
+ * @returns {object} result
82
+ */
83
+ export function answerOperatorQuery(goal, payload = {}) {
84
+ if (!goal || !goal.operatorQuery || !goal.waitingForOperator) {
85
+ return { ok: false, error: 'No pending operator query for this goal' };
86
+ }
87
+
88
+ let finalAnswer = typeof payload.answer === 'string' ? payload.answer.trim() : '';
89
+ const optIdx = typeof payload.optionIndex === 'number' ? payload.optionIndex : null;
90
+
91
+ if (optIdx !== null && Array.isArray(goal.operatorQuery.options) && goal.operatorQuery.options[optIdx]) {
92
+ finalAnswer = goal.operatorQuery.options[optIdx];
93
+ }
94
+
95
+ if (!finalAnswer) {
96
+ return { ok: false, error: 'Answer or valid option selection is required' };
97
+ }
98
+
99
+ goal.operatorQuery.answeredAt = Date.now();
100
+ goal.operatorQuery.answer = finalAnswer;
101
+ goal.operatorQuery.optionIndex = optIdx;
102
+ goal.operatorQuery.consumed = false;
103
+ goal.waitingForOperator = false;
104
+
105
+ // Unpause if goal was paused for operator
106
+ if (goal.pausedAt) {
107
+ const pauseMs = Math.max(0, Date.now() - goal.pausedAt);
108
+ goal.totalPausedDurationMs = (goal.totalPausedDurationMs || 0) + pauseMs;
109
+ goal.pausedAt = null;
110
+ }
111
+
112
+ goal.state = goal.previousStateBeforeOperator || GoalState.RUNNING;
113
+ delete goal.previousStateBeforeOperator;
114
+
115
+ recordActivity(goal, {
116
+ type: 'operator_answer',
117
+ tool: 'goal_ask_operator',
118
+ status: 'success',
119
+ details: `Operator replied: "${finalAnswer.slice(0, 80)}"`,
120
+ });
121
+
122
+ if (!Array.isArray(goal.logs)) goal.logs = [];
123
+ goal.logs.push({
124
+ timestamp: Date.now(),
125
+ type: 'info',
126
+ message: `Operator answered: "${finalAnswer}"`,
127
+ });
128
+ if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
129
+
130
+ return { ok: true, answer: finalAnswer };
131
+ }
132
+
133
+ /**
134
+ * Format prompt injection with operator's answer for the next turn.
135
+ * Marks the answer as consumed after formatting.
136
+ *
137
+ * @param {object} goal
138
+ * @param {string} [lang='en']
139
+ * @returns {string} formatted prompt snippet or empty string
140
+ */
141
+ export function formatOperatorAnswerPrompt(goal, lang = 'en') {
142
+ if (!goal || !goal.operatorQuery || !goal.operatorQuery.answer || goal.operatorQuery.consumed) {
143
+ return '';
144
+ }
145
+
146
+ const { question, answer } = goal.operatorQuery;
147
+ // Mark consumed so it is only injected on the resume turn
148
+ goal.operatorQuery.consumed = true;
149
+
150
+ if (lang === 'zh') {
151
+ return `\n\n[操作员答复 / OPERATOR RESPONSE]\n` +
152
+ `你之前提出的问题: "${question}"\n` +
153
+ `操作员给出的明确指令: "${answer}"\n` +
154
+ `请严格根据操作员的答复继续推进任务,切勿再对此问题做无谓猜测!\n`;
155
+ }
156
+
157
+ return `\n\n[OPERATOR RESPONSE TO YOUR QUERY]\n` +
158
+ `Your Question: "${question}"\n` +
159
+ `Operator Answer: "${answer}"\n` +
160
+ `Strictly follow the operator's response and proceed with executing the goal without second-guessing!\n`;
161
+ }
@@ -9,6 +9,16 @@ export function buildStatePromptInjection(snapshot, pendingNudge) {
9
9
  const hasMilestones = snapshot.milestones && snapshot.milestones.length > 0;
10
10
  const etaText = snapshot.formattedETA ? ` (ETA: ${snapshot.formattedETA})` : '';
11
11
 
12
+ let operatorAnswerText = '';
13
+ if (snapshot.operatorQuery?.answer && !snapshot.operatorQuery.consumed) {
14
+ snapshot.operatorQuery.consumed = true;
15
+ if (lang === 'zh') {
16
+ operatorAnswerText = `\n\n[操作员答复 / OPERATOR RESPONSE]\n你之前提出的问题: "${snapshot.operatorQuery.question}"\n操作员给出的明确指令: "${snapshot.operatorQuery.answer}"\n请严格根据操作员的答复继续推进任务,切勿再对此问题做无谓猜测!\n`;
17
+ } else {
18
+ operatorAnswerText = `\n\n[OPERATOR RESPONSE TO YOUR QUERY]\nYour Question: "${snapshot.operatorQuery.question}"\nOperator Answer: "${snapshot.operatorQuery.answer}"\nStrictly follow the operator's response and proceed with executing the goal without second-guessing!\n`;
19
+ }
20
+ }
21
+
12
22
  let directiveText = '';
13
23
  if (snapshot.userDirective && !snapshot.userDirectiveConsumed) {
14
24
  if (lang === 'zh') {
@@ -67,6 +77,7 @@ export function buildStatePromptInjection(snapshot, pendingNudge) {
67
77
  : ' (工作计划尚未建立 — 请立即调用 goal_set_milestones 设定初始里程碑!)';
68
78
 
69
79
  return (
80
+ operatorAnswerText +
70
81
  directiveText +
71
82
  nudgeText +
72
83
  budgetWarningText +
@@ -87,6 +98,7 @@ export function buildStatePromptInjection(snapshot, pendingNudge) {
87
98
  : ' (Work plan is not yet established — call goal_set_milestones immediately with initial steps!)';
88
99
 
89
100
  return (
101
+ operatorAnswerText +
90
102
  directiveText +
91
103
  nudgeText +
92
104
  budgetWarningText +
@@ -1,3 +1,16 @@
1
+ /**
2
+ * Format template string by replacing {{key}} with provided variable value (#165)
3
+ * @param {string} templateStr
4
+ * @param {object} vars
5
+ * @returns {string}
6
+ */
7
+ export function formatTemplateVariables(templateStr, vars = {}) {
8
+ if (!templateStr || typeof templateStr !== "string") return "";
9
+ return templateStr.replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, (_, key) => {
10
+ return vars && vars[key] != null && vars[key] !== "" ? String(vars[key]) : "";
11
+ }).trim();
12
+ }
13
+
1
14
  import { MilestoneStatus } from './goal-engine-constants.js';
2
15
 
3
16
  /**
@@ -93,8 +106,8 @@ export function instantiateTemplate(templateId, inputValues = {}) {
93
106
  const tpl = GOAL_TEMPLATES.find((t) => t.id === templateId);
94
107
  if (!tpl) return null;
95
108
 
96
- let title = tpl.title;
97
- let description = tpl.description;
109
+ let title = tpl.promptTemplate ? formatTemplateVariables(tpl.promptTemplate, inputValues) : tpl.title;
110
+ let description = tpl.descriptionTemplate ? formatTemplateVariables(tpl.descriptionTemplate, inputValues) : tpl.description;
98
111
  if (inputValues.targetFile) {
99
112
  title = `Refactor ${inputValues.targetFile}`;
100
113
  description = `Decompose ${inputValues.targetFile} to < ${inputValues.lineThreshold || 500} lines`;
@@ -1,39 +1,209 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { getSafeCwd } from './goal-engine-constants.js';
3
3
 
4
+ export const SAFE_ENV_WHITELIST = [
5
+ 'PATH',
6
+ 'HOME',
7
+ 'USER',
8
+ 'LOGNAME',
9
+ 'LANG',
10
+ 'LC_ALL',
11
+ 'LC_CTYPE',
12
+ 'SHELL',
13
+ 'TERM',
14
+ 'TMPDIR',
15
+ 'TMP',
16
+ 'TEMP',
17
+ 'NODE_ENV',
18
+ ];
19
+
20
+ const FORBIDDEN_SECRET_REGEX = /(TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|CREDENTIAL|PRIVATE|BEARER|API)/i;
21
+
22
+ export const DISALLOWED_BINARIES = new Set([
23
+ 'curl', 'wget', 'nc', 'ncat', 'netcat', 'telnet',
24
+ 'ssh', 'scp', 'sftp', 'ftp', 'rsync',
25
+ 'rm', 'dd', 'mkfs', 'shutdown', 'reboot', 'killall', 'kill',
26
+ ]);
27
+
28
+ export const SHELL_OPERATORS = new Set([';', '&&', '||', '|', '&', '>', '>>', '<', '`']);
29
+
30
+ /**
31
+ * Sanitize environment for child verification processes, strictly stripping tokens and secrets
32
+ * @param {object} [userEnv={}]
33
+ * @returns {object}
34
+ */
35
+ export function sanitizeVerificationEnv(userEnv = {}) {
36
+ const clean = {};
37
+ for (const key of SAFE_ENV_WHITELIST) {
38
+ if (process.env[key] !== undefined && !FORBIDDEN_SECRET_REGEX.test(key)) {
39
+ clean[key] = process.env[key];
40
+ }
41
+ }
42
+ if (userEnv && typeof userEnv === 'object') {
43
+ for (const [k, v] of Object.entries(userEnv)) {
44
+ if (k && !FORBIDDEN_SECRET_REGEX.test(k) && v !== undefined && v !== null) {
45
+ clean[k] = String(v);
46
+ }
47
+ }
48
+ }
49
+ if (!clean.NODE_ENV) {
50
+ clean.NODE_ENV = 'test';
51
+ }
52
+ return clean;
53
+ }
54
+
55
+ /**
56
+ * Parse a command string or array into an argv list
57
+ * @param {string|string[]} commandStr
58
+ * @returns {string[]}
59
+ */
60
+ export function parseCommandArgv(commandStr) {
61
+ if (Array.isArray(commandStr)) {
62
+ return commandStr.map(String).filter((s) => s.trim().length > 0);
63
+ }
64
+ if (!commandStr || typeof commandStr !== 'string') return [];
65
+ const trimmed = commandStr.trim();
66
+ if (!trimmed) return [];
67
+
68
+ const regex = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g;
69
+ const args = [];
70
+ let match;
71
+ while ((match = regex.exec(trimmed)) !== null) {
72
+ if (match[1] !== undefined) {
73
+ args.push(match[1].replace(/\\"/g, '"'));
74
+ } else if (match[2] !== undefined) {
75
+ args.push(match[2].replace(/\\'/g, "'"));
76
+ } else if (match[3] !== undefined) {
77
+ args.push(match[3]);
78
+ }
79
+ }
80
+ return args;
81
+ }
82
+
83
+ /**
84
+ * Validate a verification command for dangerous operators, shell chaining, or exfiltration binaries
85
+ * @param {string|string[]} command
86
+ * @returns {{ valid: boolean, error?: string, argv?: string[], binary?: string }}
87
+ */
88
+ export function validateVerificationCommand(command) {
89
+ if (!command) return { valid: false, error: 'Empty or invalid verification command' };
90
+ const argv = parseCommandArgv(command);
91
+ if (argv.length === 0) return { valid: false, error: 'Empty or invalid verification command' };
92
+
93
+ const rawBinary = argv[0].toLowerCase().replace(/^.*[/\\]/, '');
94
+ if (DISALLOWED_BINARIES.has(rawBinary)) {
95
+ return { valid: false, error: `Execution blocked: disallowed command binary "${rawBinary}"` };
96
+ }
97
+
98
+ for (let i = 0; i < argv.length; i++) {
99
+ const arg = argv[i];
100
+ if (SHELL_OPERATORS.has(arg)) {
101
+ return { valid: false, error: `Execution blocked: shell operator "${arg}" is not allowed` };
102
+ }
103
+ if (arg.startsWith('$(') || (arg.startsWith('`') && arg.endsWith('`'))) {
104
+ return { valid: false, error: 'Execution blocked: subshell substitution is not allowed' };
105
+ }
106
+ if (i > 0 && DISALLOWED_BINARIES.has(arg.toLowerCase().replace(/^.*[/\\]/, ''))) {
107
+ return { valid: false, error: `Execution blocked: disallowed binary "${arg}" in arguments` };
108
+ }
109
+ }
110
+
111
+ return { valid: true, argv, binary: rawBinary };
112
+ }
113
+
4
114
  /**
5
115
  * Execute a verification command in a confined working directory with timeout and output capture
6
- * @param {string} command Shell command to execute
116
+ * Uses sanitized environment (no secrets/tokens) and shell: false (no shell injection)
117
+ * @param {string|string[]} command Command to execute
7
118
  * @param {string} [targetCwd] Working directory
8
119
  * @param {object} [options]
9
120
  * @param {number} [options.timeoutMs=30000] Timeout in milliseconds
10
121
  * @param {number} [options.maxBuffer=1048576] Max stdout/stderr buffer in bytes (1MB)
11
- * @param {object} [options.env={}] Additional environment variables
122
+ * @param {object} [options.env={}] Additional environment variables (sanitized)
123
+ * @param {Function} [options.checkApproval] Custom approval check function
124
+ * @param {object} [options.approvalGate] ApprovalGate service instance
12
125
  * @returns {{ ok: boolean, exitCode: number, stdout: string, stderr: string, error?: string }}
13
126
  */
14
127
  export function runMilestoneVerification(command, targetCwd, options = {}) {
15
- if (!command || typeof command !== 'string' || !command.trim()) {
128
+ const validation = validateVerificationCommand(command);
129
+ if (!validation.valid) {
16
130
  return {
17
131
  ok: false,
18
132
  exitCode: 1,
19
133
  stdout: '',
20
134
  stderr: '',
21
- error: 'Empty or invalid verification command',
135
+ error: validation.error || 'Empty or invalid verification command',
22
136
  };
23
137
  }
24
138
 
25
139
  const safeCwd = getSafeCwd(targetCwd) || process.cwd();
26
140
  const timeoutMs = typeof options.timeoutMs === 'number' ? options.timeoutMs : 30000;
27
141
  const maxBuffer = typeof options.maxBuffer === 'number' ? options.maxBuffer : 1024 * 1024;
142
+ const { argv, binary } = validation;
143
+
144
+ // 1. Approval seam check
145
+ if (typeof options.checkApproval === 'function') {
146
+ const decision = options.checkApproval({
147
+ command,
148
+ argv,
149
+ binary,
150
+ cwd: safeCwd,
151
+ });
152
+ if (decision && (decision.kind === 'deny' || decision.deny)) {
153
+ return {
154
+ ok: false,
155
+ exitCode: 1,
156
+ stdout: '',
157
+ stderr: '',
158
+ error: `Verification command denied by policy: ${decision.reason || 'denied'}`,
159
+ };
160
+ }
161
+ if (decision && (decision.kind === 'ask' || decision.ask)) {
162
+ return {
163
+ ok: false,
164
+ exitCode: 1,
165
+ stdout: '',
166
+ stderr: '',
167
+ error: `Verification command requires operator approval: ${decision.reason || 'approval required'}`,
168
+ };
169
+ }
170
+ }
171
+
172
+ if (options.approvalGate && typeof options.approvalGate.checkCommand === 'function') {
173
+ try {
174
+ const decision = options.approvalGate.checkCommand(command, safeCwd);
175
+ if (decision && (decision.verdict === 'deny' || decision.verdict === 'ask')) {
176
+ return {
177
+ ok: false,
178
+ exitCode: 1,
179
+ stdout: '',
180
+ stderr: '',
181
+ error: `Verification command blocked by approval gate: ${decision.reason || decision.verdict}`,
182
+ };
183
+ }
184
+ } catch (err) {
185
+ return {
186
+ ok: false,
187
+ exitCode: 1,
188
+ stdout: '',
189
+ stderr: '',
190
+ error: `Approval check error: ${err?.message || err}`,
191
+ };
192
+ }
193
+ }
194
+
195
+ // 2. Environment sanitization (strictly scrub secrets and tokens)
196
+ const cleanEnv = sanitizeVerificationEnv(options.env);
28
197
 
198
+ // 3. Execution without shell (shell: false)
29
199
  try {
30
- const res = spawnSync(command.trim(), {
31
- shell: true,
200
+ const res = spawnSync(argv[0], argv.slice(1), {
201
+ shell: false,
32
202
  cwd: safeCwd,
33
203
  timeout: timeoutMs,
34
204
  maxBuffer,
35
205
  encoding: 'utf8',
36
- env: { ...process.env, ...options.env },
206
+ env: cleanEnv,
37
207
  });
38
208
 
39
209
  if (res.error) {
@@ -192,6 +192,8 @@ export function buildSnapshot(goal, sid, engine) {
192
192
  enableMilestoneDependencies: engine.enableMilestoneDependencies ?? true,
193
193
  soundScheme: engine.soundScheme ?? 'default',
194
194
  enableVoiceAnnouncements: engine.enableVoiceAnnouncements ?? false,
195
+ operatorQuery: null,
196
+ waitingForOperator: false,
195
197
  };
196
198
  }
197
199
 
@@ -251,6 +253,8 @@ export function buildSnapshot(goal, sid, engine) {
251
253
  enableMilestoneDependencies: engine.enableMilestoneDependencies ?? true,
252
254
  soundScheme: engine.soundScheme ?? 'default',
253
255
  enableVoiceAnnouncements: engine.enableVoiceAnnouncements ?? false,
256
+ operatorQuery: goal.operatorQuery || null,
257
+ waitingForOperator: Boolean(goal.waitingForOperator),
254
258
  };
255
259
  }
256
260
 
@@ -1,4 +1,5 @@
1
1
  import { verifyMilestoneAcceptance } from './engine-verification.js';
2
+ import { askOperatorQuery, answerOperatorQuery } from './engine-operator-query.js';
2
3
  import { GoalMode, GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, calculateElapsedSeconds, calculateRemainingSeconds, buildSnapshot, getSafeCwd } from './goal-engine-constants.js';
3
4
  import { getGitCurrentCommit, exportReportMarkdown, exportReportGitHubPR, createMilestoneCheckpoint, rollbackToCheckpoint, saveGoalArtifact } from './engine-reports.js';
4
5
  import { EngineStore } from './engine-store.js';
@@ -11,8 +12,7 @@ import { generateRetrospectiveData } from './engine-retrospective.js';
11
12
  import { generatePreplanDraft } from './engine-templates.js';
12
13
  import { pruneInactiveSessions, addNudgeToGoal, consumeGoalNudge, extendGoalBudget, setGoalMode, pauseGoal, resumeGoal, cancelGoal } from './engine-sessions.js';
13
14
 
14
- export { GoalMode, GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, matchMilestone, parseIssueChecklist };
15
- export { getGitCurrentCommit, exportReportMarkdown, exportReportGitHubPR, createMilestoneCheckpoint, rollbackToCheckpoint, saveGoalArtifact };
15
+ export { GoalMode, GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, matchMilestone, parseIssueChecklist, getGitCurrentCommit, exportReportMarkdown, exportReportGitHubPR, createMilestoneCheckpoint, rollbackToCheckpoint, saveGoalArtifact };
16
16
 
17
17
  /**
18
18
  * Isolated Goal Management Engine for DeepSeek Harness
@@ -22,23 +22,19 @@ export class GoalEngine {
22
22
  this.logger = options.logger || console;
23
23
  const rawDefIters = options.defaultMaxIterations ?? 25;
24
24
  this.defaultMaxIterations = typeof rawDefIters === "number" ? rawDefIters : (typeof rawDefIters?.value === "number" ? rawDefIters.value : 25);
25
- this.autoDrive = options.autoDrive ?? true;
26
- this.enableSound = options.enableSound ?? true;
27
- this.showQuickLaunchButton = options.showQuickLaunchButton ?? true;
28
- this.consecutiveToolFailureLimit = options.consecutiveToolFailureLimit ?? 3;
29
- this.maxTokenBudget = options.maxTokenBudget ?? 0;
30
- this.budgetWarningThreshold = options.budgetWarningThreshold ?? 80;
31
- this.autoCheckpointOnMilestone = options.autoCheckpointOnMilestone ?? false;
32
- this.enableLiveActivityFeed = options.enableLiveActivityFeed ?? true;
33
- this.enablePreplanning = options.enablePreplanning ?? true;
34
- this.autoBranchOnGoalStart = options.autoBranchOnGoalStart ?? true;
35
- this.enablePostGoalRetrospective = options.enablePostGoalRetrospective ?? true;
36
- this.autoScaleBudgetNearCompletion = options.autoScaleBudgetNearCompletion ?? false;
37
- this.enableTemplatesDrawer = options.enableTemplatesDrawer ?? true;
38
- this.enableMilestoneDependencies = options.enableMilestoneDependencies ?? true;
39
- this.soundScheme = options.soundScheme ?? 'default';
40
- this.enableVoiceAnnouncements = options.enableVoiceAnnouncements ?? false;
41
- this.maxSessions = options.maxSessions ?? 100;
25
+ Object.assign(this, {
26
+ autoDrive: options.autoDrive ?? true, enableSound: options.enableSound ?? true,
27
+ showQuickLaunchButton: options.showQuickLaunchButton ?? true,
28
+ consecutiveToolFailureLimit: options.consecutiveToolFailureLimit ?? 3,
29
+ maxTokenBudget: options.maxTokenBudget ?? 0, budgetWarningThreshold: options.budgetWarningThreshold ?? 80,
30
+ autoCheckpointOnMilestone: options.autoCheckpointOnMilestone ?? false,
31
+ enableLiveActivityFeed: options.enableLiveActivityFeed ?? true, enablePreplanning: options.enablePreplanning ?? true,
32
+ autoBranchOnGoalStart: options.autoBranchOnGoalStart ?? true, enablePostGoalRetrospective: options.enablePostGoalRetrospective ?? true,
33
+ autoScaleBudgetNearCompletion: options.autoScaleBudgetNearCompletion ?? false, enableTemplatesDrawer: options.enableTemplatesDrawer ?? true,
34
+ enableMilestoneDependencies: options.enableMilestoneDependencies ?? true, soundScheme: options.soundScheme ?? 'default',
35
+ enableVoiceAnnouncements: options.enableVoiceAnnouncements ?? false, approvalGate: options.approvalGate || null,
36
+ checkApproval: options.checkApproval || null, maxSessions: options.maxSessions ?? 100,
37
+ });
42
38
  this.goals = new Map();
43
39
  this.listeners = new Set();
44
40
  this.stallCounters = new Map();
@@ -52,10 +48,7 @@ export class GoalEngine {
52
48
  set storagePath(val) { this.store.setStoragePath(val); }
53
49
  get saveTimer() { return this.store.saveTimer; }
54
50
  get currentGoal() { return this.goals.get('default') || null; }
55
- set currentGoal(val) {
56
- if (val) this.goals.set('default', val);
57
- else this.goals.delete('default');
58
- }
51
+ set currentGoal(val) { if (val) this.goals.set('default', val); else this.goals.delete('default'); }
59
52
 
60
53
  loadStateFromDisk() { this.store.loadStateFromDisk(); }
61
54
  scheduleSave(immediate = false) { this.store.scheduleSave(immediate); }
@@ -109,22 +102,12 @@ export class GoalEngine {
109
102
  }
110
103
  }
111
104
  }
112
- if (typeof config.autoDrive === 'boolean') this.autoDrive = config.autoDrive;
113
- if (typeof config.enableSound === 'boolean') this.enableSound = config.enableSound;
114
- if (typeof config.showQuickLaunchButton === 'boolean') this.showQuickLaunchButton = config.showQuickLaunchButton;
105
+ const boolKeys = ['autoDrive', 'enableSound', 'showQuickLaunchButton', 'autoCheckpointOnMilestone', 'enableLiveActivityFeed', 'enablePreplanning', 'autoBranchOnGoalStart', 'enablePostGoalRetrospective', 'autoScaleBudgetNearCompletion', 'enableTemplatesDrawer', 'enableMilestoneDependencies', 'enableVoiceAnnouncements'];
106
+ for (const k of boolKeys) if (typeof config[k] === 'boolean') this[k] = config[k];
115
107
  if (typeof config.consecutiveToolFailureLimit === 'number') this.consecutiveToolFailureLimit = Math.max(0, config.consecutiveToolFailureLimit);
116
108
  if (typeof config.maxTokenBudget === 'number') this.maxTokenBudget = Math.max(0, config.maxTokenBudget);
117
109
  if (typeof config.budgetWarningThreshold === 'number') this.budgetWarningThreshold = Math.max(1, Math.min(100, config.budgetWarningThreshold));
118
- if (typeof config.autoCheckpointOnMilestone === 'boolean') this.autoCheckpointOnMilestone = config.autoCheckpointOnMilestone;
119
- if (typeof config.enableLiveActivityFeed === 'boolean') this.enableLiveActivityFeed = config.enableLiveActivityFeed;
120
- if (typeof config.enablePreplanning === 'boolean') this.enablePreplanning = config.enablePreplanning;
121
- if (typeof config.autoBranchOnGoalStart === 'boolean') this.autoBranchOnGoalStart = config.autoBranchOnGoalStart;
122
- if (typeof config.enablePostGoalRetrospective === 'boolean') this.enablePostGoalRetrospective = config.enablePostGoalRetrospective;
123
- if (typeof config.autoScaleBudgetNearCompletion === 'boolean') this.autoScaleBudgetNearCompletion = config.autoScaleBudgetNearCompletion;
124
- if (typeof config.enableTemplatesDrawer === 'boolean') this.enableTemplatesDrawer = config.enableTemplatesDrawer;
125
- if (typeof config.enableMilestoneDependencies === 'boolean') this.enableMilestoneDependencies = config.enableMilestoneDependencies;
126
110
  if (typeof config.soundScheme === 'string') this.soundScheme = config.soundScheme;
127
- if (typeof config.enableVoiceAnnouncements === 'boolean') this.enableVoiceAnnouncements = config.enableVoiceAnnouncements;
128
111
  this.emit();
129
112
  }
130
113
 
@@ -138,14 +121,8 @@ export class GoalEngine {
138
121
  this.toolFailureCounters.set(sid, next);
139
122
  return next;
140
123
  }
141
-
142
- resetToolFailureCount(sessionId = 'default') {
143
- this.toolFailureCounters.set(sessionId || 'default', 0);
144
- }
145
-
146
- getToolFailureCount(sessionId = 'default') {
147
- return this.toolFailureCounters.get(sessionId || 'default') || 0;
148
- }
124
+ resetToolFailureCount(sessionId = 'default') { this.toolFailureCounters.set(sessionId || 'default', 0); }
125
+ getToolFailureCount(sessionId = 'default') { return this.toolFailureCounters.get(sessionId || 'default') || 0; }
149
126
 
150
127
  nudge(text, sessionId = 'default') {
151
128
  const sid = sessionId || 'default';
@@ -227,6 +204,8 @@ export class GoalEngine {
227
204
  preplan: null,
228
205
  retrospective: null,
229
206
  issueRef: parseIssueRef(cleanTitle),
207
+ operatorQuery: null,
208
+ waitingForOperator: false,
230
209
  tokensUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
231
210
  logs: [{
232
211
  timestamp: now,
@@ -359,7 +338,7 @@ export class GoalEngine {
359
338
  if (!target) return false;
360
339
 
361
340
  if (status === MilestoneStatus.COMPLETED && target.verificationCommand) {
362
- const vRes = verifyMilestoneAcceptance(target, goal.cwd || process.cwd(), goal, recordActivity);
341
+ const vRes = verifyMilestoneAcceptance(target, goal.cwd || process.cwd(), goal, recordActivity, { approvalGate: this.approvalGate, checkApproval: this.checkApproval });
363
342
  if (!vRes.ok) {
364
343
  this.emit(sid);
365
344
  return false;
@@ -582,6 +561,24 @@ export class GoalEngine {
582
561
  return this.goals.get(sessionId || 'default') || null;
583
562
  }
584
563
 
564
+ askOperator(query = {}, sessionId = 'default') {
565
+ const sid = sessionId || 'default';
566
+ const goal = this.goals.get(sid);
567
+ if (!goal) return { ok: false, error: 'Goal not found' };
568
+ const res = askOperatorQuery(goal, query);
569
+ this.emit(sid, true);
570
+ return res;
571
+ }
572
+
573
+ answerOperator(payload = {}, sessionId = 'default') {
574
+ const sid = sessionId || 'default';
575
+ const goal = this.goals.get(sid);
576
+ if (!goal) return { ok: false, error: 'Goal not found' };
577
+ const res = answerOperatorQuery(goal, payload);
578
+ this.emit(sid, true);
579
+ return res;
580
+ }
581
+
585
582
  isPlanMode(sessionId = 'default') {
586
583
  const goal = this.goals.get(sessionId || 'default');
587
584
  return Boolean(goal && (goal.mode === GoalMode.PLAN || goal.state === GoalState.PLANNING));
package/lib/index.js CHANGED
@@ -121,6 +121,7 @@ export function apply(ctx, config = {}) {
121
121
  const milestoneManager = new MilestoneManager(addonStore, {
122
122
  gitSnapshotManager,
123
123
  enforceDependencies: currentSettings.enableMilestoneDependencies,
124
+ approvalGate: safeService(ctx, "approvalGate"),
124
125
  logger,
125
126
  });
126
127
  const tokenBudgetGuard = new TokenBudgetGuard({
@@ -167,6 +168,7 @@ export function apply(ctx, config = {}) {
167
168
  autoScaleBudgetNearCompletion: currentSettings.autoScaleBudgetNearCompletion,
168
169
  enableTemplatesDrawer: currentSettings.enableTemplatesDrawer,
169
170
  enableMilestoneDependencies: currentSettings.enableMilestoneDependencies,
171
+ approvalGate: safeService(ctx, "approvalGate"),
170
172
  logger,
171
173
  });
172
174
  registerPlanModeGuard(ctx, { engine: fallbackEngine, logger });
@@ -1,14 +1,16 @@
1
1
  import { syncMilestoneWithIssue } from './engine-issue-sync.js';
2
- import { runMilestoneVerification } from './engine-verification.js';
2
+ import { runMilestoneVerification, validateVerificationCommand } from './engine-verification.js';
3
3
 
4
4
  /**
5
5
  * Milestone and checklist coordinator for active core goals
6
6
  */
7
7
  export class MilestoneManager {
8
- constructor(addonStore, { gitSnapshotManager = null, enforceDependencies = true, logger = console } = {}) {
8
+ constructor(addonStore, { gitSnapshotManager = null, enforceDependencies = true, approvalGate = null, checkApproval = null, logger = console } = {}) {
9
9
  this.store = addonStore;
10
10
  this.gitSnapshotManager = gitSnapshotManager;
11
11
  this.enforceDependencies = enforceDependencies;
12
+ this.approvalGate = approvalGate;
13
+ this.checkApproval = checkApproval;
12
14
  this.logger = logger;
13
15
  }
14
16
 
@@ -80,7 +82,10 @@ export class MilestoneManager {
80
82
 
81
83
  // Acceptance Gate (Critic Verification) - Issue #163
82
84
  if (m.verificationCommand) {
83
- const vResult = runMilestoneVerification(m.verificationCommand, workspaceRoot);
85
+ const vResult = runMilestoneVerification(m.verificationCommand, workspaceRoot, {
86
+ approvalGate: this.approvalGate,
87
+ checkApproval: this.checkApproval,
88
+ });
84
89
  if (!vResult.ok) {
85
90
  m.verificationError = vResult.error;
86
91
  m.verificationOutput = (vResult.stderr || vResult.stdout || '').trim();
package/lib/routes.js CHANGED
@@ -750,6 +750,30 @@ export function registerRoutes(ctx, {
750
750
  break;
751
751
  }
752
752
 
753
+ case 'answer_operator': {
754
+ const answer = typeof data.answer === 'string' ? data.answer.trim() : '';
755
+ const optionIndex = typeof data.optionIndex === 'number' ? data.optionIndex : (typeof data.option_index === 'number' ? data.option_index : null);
756
+ if (engine && typeof engine.answerOperator === 'function') {
757
+ const ansRes = engine.answerOperator({ answer, optionIndex }, sid);
758
+ if (!ansRes.ok) {
759
+ res.statusCode = 400;
760
+ return res.end(JSON.stringify({ error: ansRes.error || 'Failed to submit operator answer' }));
761
+ }
762
+ result = { ok: true, answer: ansRes.answer, state: engine.getSnapshot(sid) };
763
+ const snap = engine.getSnapshot(sid);
764
+ const detectedLang = snap?.lang || 'en';
765
+ const prompt = detectedLang === 'zh'
766
+ ? `✅ 操作员已答复你的提问: "${ansRes.answer}"。请继续执行任务。`
767
+ : `✅ Operator answered your query: "${ansRes.answer}". Proceed with task execution.`;
768
+ if (typeof resumeActiveAgent === 'function') {
769
+ resumeActiveAgent(prompt, sid);
770
+ }
771
+ } else {
772
+ result = { ok: true, answer };
773
+ }
774
+ break;
775
+ }
776
+
753
777
  case 'approve_plan': {
754
778
  if (engine) {
755
779
  const snap = engine.getSnapshot(sid);
package/lib/tools.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { validateVerificationCommand } from './engine-verification.js';
1
2
  import { MilestoneManager } from './milestone-manager.js';
2
3
  import { AddonStore } from './addon-store.js';
3
4
  import { getSafeCwd } from './goal-engine-constants.js';
@@ -67,16 +68,30 @@ export function registerTools(ctx, { milestoneManager, goalsService, logger = co
67
68
  }
68
69
 
69
70
  if (action === 'set') {
71
+ if (Array.isArray(args.milestones)) {
72
+ for (const m of args.milestones) {
73
+ const vCmd = m && (m.verificationCommand || m.verification_command);
74
+ if (vCmd) {
75
+ const val = validateVerificationCommand(vCmd);
76
+ if (!val.valid) throw new Error(`Invalid verificationCommand: ${val.error}`);
77
+ }
78
+ }
79
+ }
70
80
  const list = mm.setMilestones(goalId, args.milestones);
71
81
  return { ok: true, goalId, count: list.length, milestones: list };
72
82
  }
73
83
 
74
84
  if (action === 'add') {
85
+ const vCmd = args.verificationCommand || args.verification_command;
86
+ if (vCmd) {
87
+ const val = validateVerificationCommand(vCmd);
88
+ if (!val.valid) throw new Error(`Invalid verificationCommand: ${val.error}`);
89
+ }
75
90
  const item = mm.addMilestone(goalId, {
76
91
  title: args.title,
77
92
  checklist: args.checklist,
78
93
  dependsOn: args.dependsOn || args.depends_on,
79
- verificationCommand: args.verificationCommand || args.verification_command,
94
+ verificationCommand: vCmd,
80
95
  });
81
96
  return { ok: true, goalId, milestone: item };
82
97
  }
@@ -196,6 +211,11 @@ export function registerTools(ctx, { milestoneManager, goalsService, logger = co
196
211
  if (typeof item === 'string') {
197
212
  return { id: `m-${idx + 1}`, title: item };
198
213
  }
214
+ const vCmd = item && (item.verificationCommand || item.verification_command);
215
+ if (vCmd) {
216
+ const val = validateVerificationCommand(vCmd);
217
+ if (!val.valid) throw new Error(`Invalid verificationCommand: ${val.error}`);
218
+ }
199
219
  return item;
200
220
  });
201
221
  } else {
@@ -230,6 +250,11 @@ export function registerTools(ctx, { milestoneManager, goalsService, logger = co
230
250
  const status = args.status || 'in_progress';
231
251
  const notes = typeof args.notes === 'string' ? args.notes : '';
232
252
  if (!milestoneId) throw new Error('milestone_id is required');
253
+ const vCmd = args.verificationCommand || args.verification_command;
254
+ if (vCmd) {
255
+ const val = validateVerificationCommand(vCmd);
256
+ if (!val.valid) throw new Error(`Invalid verificationCommand: ${val.error}`);
257
+ }
233
258
  if (status === 'completed') {
234
259
  const rawCwd = toolCtx?.session?.cwd || toolCtx?.cwd || process.cwd();
235
260
  const safeCwd = getSafeCwd(rawCwd);
@@ -286,5 +311,70 @@ export function registerTools(ctx, { milestoneManager, goalsService, logger = co
286
311
  output: JSON_OUTPUT,
287
312
  execute: handleGoalFinish,
288
313
  });
314
+
315
+ // Tool 6: goal_ask_operator (#166)
316
+ const handleGoalAskOperator = async (args = {}, toolCtx = {}) => {
317
+ const goalId = args.goalId || args.goal_id || resolveGoalId(toolCtx);
318
+ const question = args.question || args.prompt;
319
+ if (!question || typeof question !== 'string' || !question.trim()) {
320
+ throw new Error('Field "question" is required and must be a non-empty string');
321
+ }
322
+
323
+ const options = Array.isArray(args.options) ? args.options : [];
324
+ const isBlocking = args.isBlocking !== false && args.is_blocking !== false;
325
+
326
+ const engine = ctx.goalEngine || (ctx.get && ctx.get('goalEngine'));
327
+ if (engine && typeof engine.askOperator === 'function') {
328
+ const res = engine.askOperator({ question, options, isBlocking }, goalId);
329
+ if (!res.ok) {
330
+ throw new Error(`Failed to present question to operator: ${res.error}`);
331
+ }
332
+ return {
333
+ ok: true,
334
+ goalId,
335
+ queryId: res.queryId,
336
+ question: res.question,
337
+ options: res.options,
338
+ isBlocking: res.isBlocking,
339
+ message: isBlocking
340
+ ? 'Question submitted to human operator. Goal is paused awaiting operator input.'
341
+ : 'Question submitted to human operator in non-blocking mode.',
342
+ };
343
+ }
344
+
345
+ return {
346
+ ok: true,
347
+ goalId,
348
+ question,
349
+ options,
350
+ message: 'Question logged.',
351
+ };
352
+ };
353
+
354
+ safeRegister({
355
+ name: 'goal_ask_operator',
356
+ description: 'Ask the human operator a clarification question or present architectural choices when encountering blockers (#166). Pauses autonomous execution without consuming iterations until the operator responds.',
357
+ parameters: {
358
+ type: 'object',
359
+ properties: {
360
+ question: {
361
+ type: 'string',
362
+ description: 'The clarification question, design decision, or blocker to ask the human operator',
363
+ },
364
+ options: {
365
+ type: 'array',
366
+ items: { type: 'string' },
367
+ description: 'Optional list of predefined choices or alternative options for the operator to select with one click',
368
+ },
369
+ is_blocking: {
370
+ type: 'boolean',
371
+ description: 'Whether to pause goal execution awaiting operator response (default: true)',
372
+ },
373
+ },
374
+ required: ['question'],
375
+ },
376
+ output: JSON_OUTPUT,
377
+ execute: handleGoalAskOperator,
378
+ });
289
379
  });
290
380
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-goal",
3
- "version": "0.2.28",
3
+ "version": "0.2.30",
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",