wogiflow 2.20.0 → 2.21.0
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/package.json +2 -2
- package/scripts/flow-config-defaults.js +8 -2
- package/scripts/flow-worker-question-classifier.js +256 -0
- package/scripts/hooks/core/pre-tool-orchestrator.js +23 -1
- package/scripts/hooks/core/routing-gate.js +1 -83
- package/scripts/hooks/core/worker-boundary-gate.js +105 -0
- package/scripts/hooks/entry/claude-code/pre-tool-use.js +3 -1
- package/scripts/hooks/entry/claude-code/stop.js +60 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wogiflow",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.21.0",
|
|
4
4
|
"description": "AI-powered development workflow management system with multi-model support",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"flow": "./scripts/flow",
|
|
13
|
-
"test": "NODE_ENV=test node --test tests/auto-compact-prompt.test.js tests/flow-paths.test.js tests/flow-io.test.js tests/flow-config-loader.test.js tests/flow-damage-control.test.js tests/flow-output.test.js tests/flow-constants.test.js tests/flow-session-state.test.js tests/flow-hooks-integration.test.js tests/flow-utils.test.js tests/flow-security.test.js tests/flow-memory-db.test.js tests/flow-durable-session.test.js tests/flow-skill-matcher.test.js tests/flow-bridge.test.js tests/flow-proactive-compact.test.js tests/flow-cascade-completion.test.js tests/flow-capture-gate.test.js tests/flow-correction-detector-hybrid.test.js tests/flow-promote.test.js tests/flow-archive-runs.test.js tests/flow-memory.test.js tests/flow-hooks-pre-tool-helpers.test.js tests/flow-hooks-bugfix-scope-gate.test.js tests/flow-hooks-routing-gate.test.js tests/flow-hooks-phase-read-gate.test.js tests/flow-hooks-commit-log-gate.test.js tests/flow-hooks-deploy-gate.test.js tests/flow-hooks-todowrite-gate.test.js tests/flow-hooks-git-safety-gate.test.js tests/flow-hooks-scope-mutation-gate.test.js tests/flow-hooks-strike-gate.test.js tests/flow-hooks-component-check.test.js tests/flow-hooks-scope-gate.test.js tests/flow-hooks-implementation-gate.test.js tests/flow-hooks-research-gate.test.js tests/flow-hooks-loop-check.test.js tests/flow-hooks-manager-boundary-gate.test.js tests/flow-hooks-phase-gate.test.js tests/flow-hooks-pre-tool-orchestrator.test.js tests/flow-hooks-observation-capture.test.js tests/flow-hooks-task-gate.test.js tests/flow-durable-session-suspension.test.js tests/flow-health-mcp-scopes.test.js tests/flow-lean-config.test.js tests/flow-workspace-autopickup.test.js && NODE_ENV=test node tests/run-quality-gates.test.js",
|
|
13
|
+
"test": "NODE_ENV=test node --test tests/auto-compact-prompt.test.js tests/flow-paths.test.js tests/flow-io.test.js tests/flow-config-loader.test.js tests/flow-damage-control.test.js tests/flow-output.test.js tests/flow-constants.test.js tests/flow-session-state.test.js tests/flow-hooks-integration.test.js tests/flow-utils.test.js tests/flow-security.test.js tests/flow-memory-db.test.js tests/flow-durable-session.test.js tests/flow-skill-matcher.test.js tests/flow-bridge.test.js tests/flow-proactive-compact.test.js tests/flow-cascade-completion.test.js tests/flow-capture-gate.test.js tests/flow-correction-detector-hybrid.test.js tests/flow-promote.test.js tests/flow-archive-runs.test.js tests/flow-memory.test.js tests/flow-hooks-pre-tool-helpers.test.js tests/flow-hooks-bugfix-scope-gate.test.js tests/flow-hooks-routing-gate.test.js tests/flow-hooks-phase-read-gate.test.js tests/flow-hooks-commit-log-gate.test.js tests/flow-hooks-deploy-gate.test.js tests/flow-hooks-todowrite-gate.test.js tests/flow-hooks-git-safety-gate.test.js tests/flow-hooks-scope-mutation-gate.test.js tests/flow-hooks-strike-gate.test.js tests/flow-hooks-component-check.test.js tests/flow-hooks-scope-gate.test.js tests/flow-hooks-implementation-gate.test.js tests/flow-hooks-research-gate.test.js tests/flow-hooks-loop-check.test.js tests/flow-hooks-manager-boundary-gate.test.js tests/flow-hooks-phase-gate.test.js tests/flow-hooks-pre-tool-orchestrator.test.js tests/flow-hooks-observation-capture.test.js tests/flow-hooks-task-gate.test.js tests/flow-durable-session-suspension.test.js tests/flow-health-mcp-scopes.test.js tests/flow-lean-config.test.js tests/flow-workspace-autopickup.test.js tests/flow-worker-boundary-gate.test.js tests/flow-worker-question-classifier.test.js && NODE_ENV=test node tests/run-quality-gates.test.js",
|
|
14
14
|
"test:syntax": "find scripts/ lib/ -name '*.js' -not -path '*/node_modules/*' -exec node --check {} +",
|
|
15
15
|
"lint": "eslint scripts/ lib/ tests/",
|
|
16
16
|
"lint:ci": "eslint scripts/ lib/ tests/ --max-warnings 0",
|
|
@@ -399,8 +399,14 @@ const CONFIG_DEFAULTS = {
|
|
|
399
399
|
managerCanSkipGates: false,
|
|
400
400
|
_comment_autoPickupChannelDispatches: 'v2.20.0+: After task completion, if channel-dispatched tasks are queued in ready.json, the task-completed hook injects additionalContext instructing the AI to auto-invoke /wogi-start on the next queued task in the same turn. Prevents "Sauteed worker" silent stalls between queued dispatches. The Stop hook also blocks end-of-turn when queued dispatches exist but no task is in progress — making "awaiting signal" language mechanically impossible as a terminal state.',
|
|
401
401
|
autoPickupChannelDispatches: true,
|
|
402
|
-
|
|
403
|
-
|
|
402
|
+
_comment_blockAskUserQuestionInWorker: 'v2.20.1+: When true, PreToolUse blocks AskUserQuestion in workspace worker mode. The user only sees the manager terminal, so direct prompts from workers stall silently. Block message instructs channel-dispatch "## QUESTION:" to the manager. Closes the v2.20.0 gap where workers could still prompt the user directly when their queue was empty.',
|
|
403
|
+
blockAskUserQuestionInWorker: true,
|
|
404
|
+
_comment_aiWorkerQuestionClassifier: 'v2.21.0+: When true, Stop hook runs a Haiku classifier on the final assistant message in worker mode. If the message ends by asking the user a question (detected semantically, not via regex), the stop is blocked with channel-dispatch instructions. Degrades to no-op when ANTHROPIC_API_KEY is absent. Uses existing flow-model-caller infrastructure.',
|
|
405
|
+
aiWorkerQuestionClassifier: {
|
|
406
|
+
enabled: true,
|
|
407
|
+
minConfidence: 70,
|
|
408
|
+
model: 'anthropic:claude-3-5-haiku-latest'
|
|
409
|
+
}
|
|
404
410
|
},
|
|
405
411
|
checkpoint: { enabled: false },
|
|
406
412
|
regressionTesting: { enabled: false },
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Flow — Worker Question Classifier (v2.21.0+)
|
|
5
|
+
*
|
|
6
|
+
* AI-based semantic detector that runs at Stop-hook time in workspace worker
|
|
7
|
+
* mode and answers ONE question about the AI's final assistant message:
|
|
8
|
+
*
|
|
9
|
+
* "Does this message end by asking the user a question that expects
|
|
10
|
+
* a user response?"
|
|
11
|
+
*
|
|
12
|
+
* If YES + confidence >= threshold + worker mode → Stop hook blocks the turn
|
|
13
|
+
* with instructions to channel-dispatch `## QUESTION:` to the manager instead.
|
|
14
|
+
*
|
|
15
|
+
* Why AI, not regex: the hedging vocabulary is infinite ("let me know",
|
|
16
|
+
* "should I", "which option", "?", "thoughts?", "any preference?"). User
|
|
17
|
+
* explicitly requested AI logic over regex in the 2026-04-16 session.
|
|
18
|
+
*
|
|
19
|
+
* Design mirrors flow-conclusion-classifier.js / flow-correction-detector.js:
|
|
20
|
+
* - Uses existing flow-model-caller.js infrastructure (same plan tokens
|
|
21
|
+
* Claude Code already uses)
|
|
22
|
+
* - ANTHROPIC_API_KEY absent → returns `{ classified: false, reason:
|
|
23
|
+
* 'no-credentials' }` — Stop hook treats as no-op, does NOT block.
|
|
24
|
+
* This matches the established fail-open pattern.
|
|
25
|
+
* - Transcript parsing is defensive — missing, empty, or malformed
|
|
26
|
+
* transcript returns `{ classified: false, reason: <specific> }`.
|
|
27
|
+
* - JSON response from Haiku validated for shape + prototype-pollution.
|
|
28
|
+
* - Fail-open on model error — if the classifier breaks, legitimate
|
|
29
|
+
* stops are not affected. A silent-stall false-negative is recoverable;
|
|
30
|
+
* a false-positive block on every turn is not.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const fs = require('node:fs');
|
|
34
|
+
|
|
35
|
+
const DEFAULT_MIN_CONFIDENCE = 70;
|
|
36
|
+
const DEFAULT_MODEL = 'anthropic:claude-3-5-haiku-latest';
|
|
37
|
+
const MAX_MESSAGE_CHARS = 8000; // Classifier input cap
|
|
38
|
+
const MAX_TOKENS = 300; // Classifier output cap (tiny JSON)
|
|
39
|
+
const TEMPERATURE = 0.0; // Deterministic classification
|
|
40
|
+
|
|
41
|
+
// Shared prototype-pollution guard (same as flow-conclusion-classifier).
|
|
42
|
+
const { DANGEROUS_KEYS } = require('./flow-io');
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Extract the final assistant message from a Claude Code transcript JSONL file.
|
|
46
|
+
*
|
|
47
|
+
* Claude Code writes one JSON object per line. Each object represents an event
|
|
48
|
+
* (user message, assistant message, tool call, tool result, etc.). We scan
|
|
49
|
+
* backward for the last event where the content resembles an assistant-authored
|
|
50
|
+
* text block (has `role: 'assistant'` OR `type: 'assistant'` shape).
|
|
51
|
+
*
|
|
52
|
+
* Defensive: any IO / parse error returns null. Caller treats null as no-op.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} transcriptPath - Absolute path to the JSONL transcript
|
|
55
|
+
* @returns {string|null} The text of the last assistant message, or null
|
|
56
|
+
*/
|
|
57
|
+
function extractLastAssistantMessage(transcriptPath) {
|
|
58
|
+
if (!transcriptPath || typeof transcriptPath !== 'string') return null;
|
|
59
|
+
let raw;
|
|
60
|
+
try {
|
|
61
|
+
raw = fs.readFileSync(transcriptPath, 'utf-8');
|
|
62
|
+
} catch (_err) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (!raw || raw.length === 0) return null;
|
|
66
|
+
|
|
67
|
+
// Scan from end for lines we can parse as assistant messages.
|
|
68
|
+
const lines = raw.split('\n');
|
|
69
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
70
|
+
const line = lines[i].trim();
|
|
71
|
+
if (!line) continue;
|
|
72
|
+
let entry;
|
|
73
|
+
try {
|
|
74
|
+
entry = JSON.parse(line);
|
|
75
|
+
} catch (_err) {
|
|
76
|
+
continue; // Skip unparseable lines — transcript may be mid-write.
|
|
77
|
+
}
|
|
78
|
+
const text = extractAssistantText(entry);
|
|
79
|
+
if (text) return text.slice(0, MAX_MESSAGE_CHARS);
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Pull assistant-authored text out of a single transcript entry. The transcript
|
|
86
|
+
* format has evolved across Claude Code versions — we accept any of:
|
|
87
|
+
* - { role: 'assistant', content: 'string' }
|
|
88
|
+
* - { role: 'assistant', content: [{ type: 'text', text: '...' }] }
|
|
89
|
+
* - { type: 'assistant', message: { content: [{ type: 'text', text: '...' }] } }
|
|
90
|
+
* - { type: 'assistant', text: '...' }
|
|
91
|
+
*/
|
|
92
|
+
function extractAssistantText(entry) {
|
|
93
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
94
|
+
|
|
95
|
+
// Direct role/type check.
|
|
96
|
+
const isAssistant =
|
|
97
|
+
entry.role === 'assistant' ||
|
|
98
|
+
entry.type === 'assistant' ||
|
|
99
|
+
(entry.message && entry.message.role === 'assistant');
|
|
100
|
+
if (!isAssistant) return null;
|
|
101
|
+
|
|
102
|
+
// Pull the content wherever it lives.
|
|
103
|
+
const content = entry.content ?? entry.message?.content ?? entry.text;
|
|
104
|
+
if (typeof content === 'string') return content;
|
|
105
|
+
if (Array.isArray(content)) {
|
|
106
|
+
// Collect text blocks only — skip tool_use / tool_result blocks.
|
|
107
|
+
const texts = content
|
|
108
|
+
.filter(b => b && typeof b === 'object' && (b.type === 'text' || typeof b.text === 'string'))
|
|
109
|
+
.map(b => String(b.text || ''))
|
|
110
|
+
.filter(Boolean);
|
|
111
|
+
if (texts.length > 0) return texts.join('\n').trim();
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build the Haiku classifier prompt. Kept as a pure string for testability.
|
|
118
|
+
*
|
|
119
|
+
* Designed to minimize false positives:
|
|
120
|
+
* - Rhetorical questions ("Did the tests pass? Yes.") → NO
|
|
121
|
+
* - Narration with trailing "?" ("the question is how to proceed") → NO
|
|
122
|
+
* - Actual open-ended ask ("Should I use A or B?" with no answer given) → YES
|
|
123
|
+
*/
|
|
124
|
+
function buildClassifierPrompt(lastMessage) {
|
|
125
|
+
return `You classify whether an AI assistant's final message to a WORKSPACE WORKER session ends by asking the USER a question that expects a user response.
|
|
126
|
+
|
|
127
|
+
Context: in workspace mode, workers are autonomous and MUST NOT prompt the user directly — the user only sees the manager terminal, so direct questions stall silently. Workers that need user input MUST channel-dispatch "## QUESTION:" to the manager instead.
|
|
128
|
+
|
|
129
|
+
Your job: classify YES only when the worker's final message contains an OPEN question the worker is waiting on the user to answer. Classify NO for rhetorical questions that the worker answers itself, narrative descriptions, or questions that have an accompanying decision.
|
|
130
|
+
|
|
131
|
+
[MESSAGE_START]
|
|
132
|
+
${String(lastMessage || '').slice(0, MAX_MESSAGE_CHARS)}
|
|
133
|
+
[MESSAGE_END]
|
|
134
|
+
|
|
135
|
+
Return JSON only, no prose, no markdown fences:
|
|
136
|
+
{"isUserQuestion": true|false, "confidence": 0-100, "reason": "one short sentence"}
|
|
137
|
+
|
|
138
|
+
Examples:
|
|
139
|
+
- "Should I proceed with A or B? Let me know." → {"isUserQuestion": true, "confidence": 95, "reason": "open choice awaiting user decision"}
|
|
140
|
+
- "Did the tests pass? Yes, all 12 passed." → {"isUserQuestion": false, "confidence": 90, "reason": "rhetorical, answered inline"}
|
|
141
|
+
- "I finished the task. Awaiting your signal." → {"isUserQuestion": true, "confidence": 85, "reason": "hedging terminal state awaiting user"}
|
|
142
|
+
- "Task complete. Next: wf-abc12345 — starting now." → {"isUserQuestion": false, "confidence": 95, "reason": "action statement, no question"}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Guard parsed JSON response against prototype pollution. Mirrors
|
|
147
|
+
* flow-conclusion-classifier.hasDangerousKeys.
|
|
148
|
+
*/
|
|
149
|
+
function hasDangerousKeys(value) {
|
|
150
|
+
if (!value || typeof value !== 'object') return false;
|
|
151
|
+
if (Array.isArray(value)) return value.some(hasDangerousKeys);
|
|
152
|
+
for (const key of Object.keys(value)) {
|
|
153
|
+
if (DANGEROUS_KEYS.has(key)) return true;
|
|
154
|
+
if (hasDangerousKeys(value[key])) return true;
|
|
155
|
+
}
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Classify the worker's last assistant message.
|
|
161
|
+
*
|
|
162
|
+
* @param {Object} opts
|
|
163
|
+
* @param {string} opts.transcriptPath - Absolute path to session JSONL transcript
|
|
164
|
+
* @param {number} [opts.minConfidence] - Confidence threshold (default 70)
|
|
165
|
+
* @param {string} [opts.model] - Model override (default haiku)
|
|
166
|
+
* @returns {Promise<{
|
|
167
|
+
* classified: boolean,
|
|
168
|
+
* isUserQuestion?: boolean,
|
|
169
|
+
* confidence?: number,
|
|
170
|
+
* reason?: string,
|
|
171
|
+
* lastMessage?: string
|
|
172
|
+
* }>}
|
|
173
|
+
*/
|
|
174
|
+
async function classifyWorkerQuestion(opts = {}) {
|
|
175
|
+
const minConfidence = Number.isFinite(opts.minConfidence) ? opts.minConfidence : DEFAULT_MIN_CONFIDENCE;
|
|
176
|
+
const model = opts.model || DEFAULT_MODEL;
|
|
177
|
+
|
|
178
|
+
// Fail-open gates — any reason to skip returns { classified: false }.
|
|
179
|
+
if (!process.env.ANTHROPIC_API_KEY) {
|
|
180
|
+
return { classified: false, reason: 'no-credentials' };
|
|
181
|
+
}
|
|
182
|
+
if (!opts.transcriptPath) {
|
|
183
|
+
return { classified: false, reason: 'no-transcript-path' };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const lastMessage = extractLastAssistantMessage(opts.transcriptPath);
|
|
187
|
+
if (!lastMessage || lastMessage.length < 10) {
|
|
188
|
+
return { classified: false, reason: 'no-last-message', lastMessage };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let callModel;
|
|
192
|
+
try {
|
|
193
|
+
({ callModel } = require('./flow-model-caller'));
|
|
194
|
+
} catch (_err) {
|
|
195
|
+
return { classified: false, reason: 'no-model-caller' };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let result;
|
|
199
|
+
try {
|
|
200
|
+
result = await callModel(model, buildClassifierPrompt(lastMessage), {
|
|
201
|
+
temperature: TEMPERATURE,
|
|
202
|
+
maxTokens: MAX_TOKENS
|
|
203
|
+
});
|
|
204
|
+
} catch (err) {
|
|
205
|
+
if (process.env.DEBUG) {
|
|
206
|
+
console.error(`[worker-question-classifier] model call failed: ${err.message}`);
|
|
207
|
+
}
|
|
208
|
+
return { classified: false, reason: 'model-error' };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// flow-model-caller returns { success, response, ... } where `response` is the text.
|
|
212
|
+
// Earlier classifiers read `.content`; accept both shapes for resilience.
|
|
213
|
+
const raw = String(result?.response ?? result?.content ?? '').trim();
|
|
214
|
+
if (!raw) return { classified: false, reason: 'empty-response' };
|
|
215
|
+
|
|
216
|
+
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
217
|
+
if (!jsonMatch) return { classified: false, reason: 'non-json-response' };
|
|
218
|
+
|
|
219
|
+
let parsed;
|
|
220
|
+
try {
|
|
221
|
+
parsed = JSON.parse(jsonMatch[0]);
|
|
222
|
+
} catch (_err) {
|
|
223
|
+
return { classified: false, reason: 'json-parse-error' };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
227
|
+
return { classified: false, reason: 'bad-shape' };
|
|
228
|
+
}
|
|
229
|
+
if (hasDangerousKeys(parsed)) {
|
|
230
|
+
return { classified: false, reason: 'dangerous-keys' };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const isUserQuestion = Boolean(parsed.isUserQuestion);
|
|
234
|
+
const confidence = Number.isFinite(parsed.confidence) ? Math.round(parsed.confidence) : 0;
|
|
235
|
+
const reason = typeof parsed.reason === 'string' ? parsed.reason.slice(0, 240) : '';
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
classified: true,
|
|
239
|
+
isUserQuestion,
|
|
240
|
+
confidence,
|
|
241
|
+
reason,
|
|
242
|
+
lastMessage,
|
|
243
|
+
blocked: isUserQuestion && confidence >= minConfidence,
|
|
244
|
+
minConfidence
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = {
|
|
249
|
+
classifyWorkerQuestion,
|
|
250
|
+
extractLastAssistantMessage,
|
|
251
|
+
extractAssistantText,
|
|
252
|
+
buildClassifierPrompt,
|
|
253
|
+
hasDangerousKeys,
|
|
254
|
+
DEFAULT_MIN_CONFIDENCE,
|
|
255
|
+
DEFAULT_MODEL
|
|
256
|
+
};
|
|
@@ -177,7 +177,7 @@ function runPreToolGates(ctx, deps) {
|
|
|
177
177
|
]);
|
|
178
178
|
if (!skipRoutingGateForSubagent && !skipRoutingGateForReadOnlyGit && GATED_TOOLS.has(toolName)) {
|
|
179
179
|
try {
|
|
180
|
-
const routingResult = deps.checkRoutingGate(toolName, config
|
|
180
|
+
const routingResult = deps.checkRoutingGate(toolName, config);
|
|
181
181
|
if (routingResult.blocked) {
|
|
182
182
|
return {
|
|
183
183
|
allowed: false,
|
|
@@ -215,6 +215,28 @@ function runPreToolGates(ctx, deps) {
|
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
// Worker boundary (v2.20.1) — parallel to manager boundary.
|
|
219
|
+
// Blocks tools that prompt the user directly (AskUserQuestion) in worker
|
|
220
|
+
// mode, since the user only sees the manager terminal. Forces workers to
|
|
221
|
+
// channel-dispatch ## QUESTION: to the manager instead.
|
|
222
|
+
if (process.env.WOGI_WORKSPACE_ROOT && process.env.WOGI_REPO_NAME && process.env.WOGI_REPO_NAME !== 'manager') {
|
|
223
|
+
try {
|
|
224
|
+
if (typeof deps.checkWorkerBoundary === 'function') {
|
|
225
|
+
const workerResult = deps.checkWorkerBoundary(toolName, toolInput, config);
|
|
226
|
+
if (workerResult.blocked) {
|
|
227
|
+
return {
|
|
228
|
+
allowed: false,
|
|
229
|
+
blocked: true,
|
|
230
|
+
reason: workerResult.reason,
|
|
231
|
+
message: workerResult.message,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} catch (err) {
|
|
236
|
+
if (process.env.DEBUG) console.error(`[Hook] Worker boundary gate error (fail-open): ${err.message}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
218
240
|
// Commit log gate
|
|
219
241
|
if (toolName === 'Bash' && toolInput.command) {
|
|
220
242
|
try {
|
|
@@ -285,10 +285,9 @@ function isRoutingPending() {
|
|
|
285
285
|
*
|
|
286
286
|
* @param {string} toolName - The tool being called (e.g., 'Bash')
|
|
287
287
|
* @param {Object} [config] - Pre-loaded config (optional, falls back to getConfig())
|
|
288
|
-
* @param {Object} [toolInput] - Tool input (optional, used for v2.20.0 diagnostic bypass)
|
|
289
288
|
* @returns {{ allowed: boolean, blocked: boolean, reason: string, message: string|null }}
|
|
290
289
|
*/
|
|
291
|
-
function checkRoutingGate(toolName, config
|
|
290
|
+
function checkRoutingGate(toolName, config) {
|
|
292
291
|
// Gate ALL tools that allow the AI to act without routing through /wogi-start.
|
|
293
292
|
// Edit/Write/NotebookEdit were the critical gap: AI could edit ready.json (exempt
|
|
294
293
|
// from task gate) to create a fake active task, then edit anything freely.
|
|
@@ -318,26 +317,6 @@ function checkRoutingGate(toolName, config, toolInput) {
|
|
|
318
317
|
// This meant any in-progress task from a prior turn bypassed routing entirely.
|
|
319
318
|
// The only way to clear routing-pending is to invoke a /wogi-* skill.
|
|
320
319
|
|
|
321
|
-
// Gap D (v2.20.0) — diagnostic curl bypass for workspace workers.
|
|
322
|
-
// When a manager sends an INTROSPECTION/DIAGNOSTIC channel message, the
|
|
323
|
-
// worker needs to curl-reply to localhost:8800 with a structured "## " body.
|
|
324
|
-
// Without this bypass, answering diagnostic questions forces the worker to
|
|
325
|
-
// create a fake task just to satisfy routing — which is itself an
|
|
326
|
-
// anti-pattern. Narrow allowlist: Bash + curl + localhost:manager-port +
|
|
327
|
-
// body starts with "## " + config flag enabled.
|
|
328
|
-
try {
|
|
329
|
-
if (toolName === 'Bash' && isDiagnosticCurlBypass(toolInput, config)) {
|
|
330
|
-
return {
|
|
331
|
-
allowed: true,
|
|
332
|
-
blocked: false,
|
|
333
|
-
reason: 'diagnostic_curl_bypass',
|
|
334
|
-
message: null
|
|
335
|
-
};
|
|
336
|
-
}
|
|
337
|
-
} catch (_err) {
|
|
338
|
-
// Fail-closed — if bypass check errors, default to the normal block path.
|
|
339
|
-
}
|
|
340
|
-
|
|
341
320
|
// Block: routing is pending and no /wogi-* command has been invoked this turn
|
|
342
321
|
// NOTE: This message is shown to the AI as permissionDecisionReason.
|
|
343
322
|
// It must be prescriptive enough that the AI invokes /wogi-start instead of
|
|
@@ -358,66 +337,6 @@ function checkRoutingGate(toolName, config, toolInput) {
|
|
|
358
337
|
};
|
|
359
338
|
}
|
|
360
339
|
|
|
361
|
-
/**
|
|
362
|
-
* Gap D — recognize a narrow curl-to-manager bypass for diagnostic replies.
|
|
363
|
-
*
|
|
364
|
-
* Allowed iff ALL hold:
|
|
365
|
-
* - config.workspace.diagnosticCurlBypass !== false
|
|
366
|
-
* - Tool is Bash and command contains a single curl to
|
|
367
|
-
* http(s)://(127\\.0\\.0\\.1|localhost):{managerPort} (default 8800)
|
|
368
|
-
* - The curl body (`-d`, `--data`, `--data-binary`, `--data-raw`) starts
|
|
369
|
-
* with "## " (structured channel reply marker)
|
|
370
|
-
* - Body contains one of the diagnostic markers: "INTROSPECTION",
|
|
371
|
-
* "DIAGNOSTIC", "## QUESTION:", or "## ANSWER:" (so generic curl-to-8800
|
|
372
|
-
* doesn't escape routing — only diagnostic/question/answer replies do)
|
|
373
|
-
*
|
|
374
|
-
* This bypass is specifically NARROW by design — we want to unblock diagnostic
|
|
375
|
-
* round-trips without opening a back door. Generic curl to any URL, curl to a
|
|
376
|
-
* different port, or curl with a non-"## " body all still hit the normal block.
|
|
377
|
-
*
|
|
378
|
-
* @param {Object} toolInput - Bash tool input ({ command: string, ... })
|
|
379
|
-
* @param {Object} config - Loaded config
|
|
380
|
-
* @returns {boolean}
|
|
381
|
-
*/
|
|
382
|
-
function isDiagnosticCurlBypass(toolInput, config) {
|
|
383
|
-
if (!toolInput || typeof toolInput !== 'object') return false;
|
|
384
|
-
if (config?.workspace?.diagnosticCurlBypass === false) return false;
|
|
385
|
-
|
|
386
|
-
const command = String(toolInput.command || '');
|
|
387
|
-
if (!command.includes('curl')) return false;
|
|
388
|
-
|
|
389
|
-
// Must target localhost or 127.0.0.1 on the manager port.
|
|
390
|
-
const managerPort = process.env.WOGI_MANAGER_PORT ||
|
|
391
|
-
String(config?.workspace?.managerPort || '8800');
|
|
392
|
-
// Validate port shape first — prevents regex injection.
|
|
393
|
-
if (!/^\d{2,5}$/.test(String(managerPort))) return false;
|
|
394
|
-
const portPattern = new RegExp(
|
|
395
|
-
`https?://(?:127\\.0\\.0\\.1|localhost):${managerPort}(?:[/\\s"'\\\\]|$)`
|
|
396
|
-
);
|
|
397
|
-
if (!portPattern.test(command)) return false;
|
|
398
|
-
|
|
399
|
-
// Extract the body argument. Recognized flags: -d, --data, --data-binary,
|
|
400
|
-
// --data-raw, --data-urlencode. The body can be:
|
|
401
|
-
// (a) literal string: -d "## ANSWER: ..."
|
|
402
|
-
// (b) @- (from stdin — we can't inspect)
|
|
403
|
-
// (c) @filename
|
|
404
|
-
const bodyMatch = command.match(
|
|
405
|
-
/--data(?:-binary|-raw|-urlencode)?\s+(['"])([\s\S]*?)\1|-d\s+(['"])([\s\S]*?)\3/
|
|
406
|
-
);
|
|
407
|
-
const literalBody = bodyMatch ? (bodyMatch[2] || bodyMatch[4] || '') : '';
|
|
408
|
-
|
|
409
|
-
// Stdin / file bodies (@-) cannot be inspected — we conservatively reject
|
|
410
|
-
// them for this bypass. The worker should use literal `-d "## ..."` instead.
|
|
411
|
-
if (/--data(?:-binary|-raw|-urlencode)?\s+@|-d\s+@/.test(command) && !literalBody) {
|
|
412
|
-
return false;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
if (!literalBody.startsWith('## ')) return false;
|
|
416
|
-
|
|
417
|
-
// Final marker check — body must contain one of the diagnostic markers.
|
|
418
|
-
const markers = ['INTROSPECTION', 'DIAGNOSTIC', '## QUESTION:', '## ANSWER:'];
|
|
419
|
-
return markers.some(m => literalBody.includes(m));
|
|
420
|
-
}
|
|
421
340
|
|
|
422
341
|
/**
|
|
423
342
|
* Increment the stop-attempt counter in the routing flag.
|
|
@@ -467,7 +386,6 @@ function incrementStopAttempts(maxAttempts = 10) {
|
|
|
467
386
|
}
|
|
468
387
|
|
|
469
388
|
module.exports = {
|
|
470
|
-
isDiagnosticCurlBypass,
|
|
471
389
|
isRoutingGateEnabled,
|
|
472
390
|
hasActiveTask,
|
|
473
391
|
setRoutingPending,
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Flow - Worker Boundary Gate (v2.20.1+)
|
|
5
|
+
*
|
|
6
|
+
* Mirror of manager-boundary-gate, but for workspace WORKERS.
|
|
7
|
+
*
|
|
8
|
+
* Problem this solves: a worker running in workspace mode cannot prompt the
|
|
9
|
+
* user directly — the user only sees the manager terminal. When a worker
|
|
10
|
+
* calls AskUserQuestion, its terminal shows a prompt nobody will ever type
|
|
11
|
+
* into. The worker stalls silently.
|
|
12
|
+
*
|
|
13
|
+
* Contract: in workspace worker mode, questions to the user MUST be
|
|
14
|
+
* channel-dispatched to the manager via `## QUESTION:`. The manager relays
|
|
15
|
+
* to the user, gets the answer, and channel-dispatches back.
|
|
16
|
+
*
|
|
17
|
+
* This gate blocks the `AskUserQuestion` tool in worker mode with a message
|
|
18
|
+
* giving the exact curl command to escalate properly.
|
|
19
|
+
*
|
|
20
|
+
* Scope:
|
|
21
|
+
* - Only fires in workspace worker mode (WOGI_WORKSPACE_ROOT +
|
|
22
|
+
* WOGI_REPO_NAME !== 'manager').
|
|
23
|
+
* - No-op in single-repo mode (no WOGI_WORKSPACE_ROOT).
|
|
24
|
+
* - No-op for the manager (manager SHOULD prompt the user — that is the
|
|
25
|
+
* manager's job).
|
|
26
|
+
* - Respects `config.workspace.blockAskUserQuestionInWorker` (default true).
|
|
27
|
+
*
|
|
28
|
+
* This is the missing piece after v2.20.0: v2.20.0 blocked hedging BETWEEN
|
|
29
|
+
* queued tasks but not worker-asks-user-directly when the queue is empty.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if a tool call violates worker-role boundaries.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} toolName - Tool being called
|
|
36
|
+
* @param {Object} toolInput - Tool input parameters (unused for AskUserQuestion)
|
|
37
|
+
* @param {Object} [config] - Loaded config (optional)
|
|
38
|
+
* @returns {{ blocked: boolean, reason?: string, message?: string }}
|
|
39
|
+
*/
|
|
40
|
+
function checkWorkerBoundary(toolName, toolInput, config) {
|
|
41
|
+
// Only active in workspace worker mode.
|
|
42
|
+
if (!isWorkspaceWorker()) {
|
|
43
|
+
return { blocked: false };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Config toggle — default on.
|
|
47
|
+
if (config?.workspace?.blockAskUserQuestionInWorker === false) {
|
|
48
|
+
return { blocked: false };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Block list: tools that prompt the user directly.
|
|
52
|
+
// `AskUserQuestion` is the primary case — Claude Code's built-in
|
|
53
|
+
// interactive-question tool. If more user-prompting tools emerge, add them
|
|
54
|
+
// here (be surgical — only tools that actually expect a user reply).
|
|
55
|
+
const USER_PROMPT_TOOLS = new Set(['AskUserQuestion']);
|
|
56
|
+
|
|
57
|
+
if (!USER_PROMPT_TOOLS.has(toolName)) {
|
|
58
|
+
return { blocked: false };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const repoName = process.env.WOGI_REPO_NAME || 'worker';
|
|
62
|
+
const managerPort = process.env.WOGI_MANAGER_PORT || '8800';
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
blocked: true,
|
|
66
|
+
reason: 'worker-boundary-askuser',
|
|
67
|
+
message: [
|
|
68
|
+
`WORKER BOUNDARY: Cannot use ${toolName} in workspace worker mode.`,
|
|
69
|
+
'',
|
|
70
|
+
'The user ONLY sees the manager terminal. If you prompt the user here,',
|
|
71
|
+
'nobody will see it — your session will stall silently.',
|
|
72
|
+
'',
|
|
73
|
+
'Channel-dispatch the question to the manager instead:',
|
|
74
|
+
'',
|
|
75
|
+
` curl -s -X POST http://127.0.0.1:${managerPort} \\`,
|
|
76
|
+
` -H "X-Wogi-From: ${repoName}" \\`,
|
|
77
|
+
` --data-binary "## QUESTION: <your question for the user>"`,
|
|
78
|
+
'',
|
|
79
|
+
'The manager will relay to the user, capture the answer, and',
|
|
80
|
+
'channel-dispatch a follow-up task to you with the resolved context.',
|
|
81
|
+
'',
|
|
82
|
+
'If you genuinely do NOT need the user — make a reasonable decision',
|
|
83
|
+
'and note it in your reply to the manager (autonomous mode contract).'
|
|
84
|
+
].join('\n')
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Detect workspace worker mode. Requires both env vars:
|
|
90
|
+
* WOGI_WORKSPACE_ROOT — set by the worker spawn path
|
|
91
|
+
* WOGI_REPO_NAME — must NOT be 'manager'
|
|
92
|
+
*
|
|
93
|
+
* @returns {boolean}
|
|
94
|
+
*/
|
|
95
|
+
function isWorkspaceWorker() {
|
|
96
|
+
if (!process.env.WOGI_WORKSPACE_ROOT) return false;
|
|
97
|
+
const repo = process.env.WOGI_REPO_NAME;
|
|
98
|
+
if (!repo || repo === 'manager') return false;
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
checkWorkerBoundary,
|
|
104
|
+
isWorkspaceWorker
|
|
105
|
+
};
|
|
@@ -48,6 +48,8 @@ let checkGitSafety = _noop;
|
|
|
48
48
|
try { checkGitSafety = require('../../core/git-safety-gate').checkGitSafety; } catch (_err) { if (process.env.DEBUG) console.error(`[Hook] Git safety gate not loaded: ${_err.message}`); }
|
|
49
49
|
let checkManagerBoundary = _noop;
|
|
50
50
|
try { checkManagerBoundary = require('../../core/manager-boundary-gate').checkManagerBoundary; } catch (_err) { if (process.env.DEBUG) console.error(`[Hook] Manager boundary gate not loaded: ${_err.message}`); }
|
|
51
|
+
let checkWorkerBoundary = _noop;
|
|
52
|
+
try { checkWorkerBoundary = require('../../core/worker-boundary-gate').checkWorkerBoundary; } catch (_err) { if (process.env.DEBUG) console.error(`[Hook] Worker boundary gate not loaded: ${_err.message}`); }
|
|
51
53
|
|
|
52
54
|
const { claudeCodeAdapter } = require('../../adapters/claude-code');
|
|
53
55
|
const { markSkillPending } = require('../../../flow-durable-session');
|
|
@@ -84,7 +86,7 @@ runHook('PreToolUse', async ({ input, parsedInput }) => {
|
|
|
84
86
|
recordPhaseRead, checkPhaseReadGate, clearPhaseReads,
|
|
85
87
|
checkDeployGate, checkWriteBlock,
|
|
86
88
|
checkStrikeGate, checkBugfixScope, checkScopeMutation,
|
|
87
|
-
checkGitSafety, checkManagerBoundary,
|
|
89
|
+
checkGitSafety, checkManagerBoundary, checkWorkerBoundary,
|
|
88
90
|
// Side-effect helpers
|
|
89
91
|
markSkillPending,
|
|
90
92
|
// Config + runtime
|
|
@@ -242,6 +242,66 @@ runHook('Stop', async ({ parsedInput }) => {
|
|
|
242
242
|
}
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
// G3 (v2.21.0) — AI-based worker-question classifier.
|
|
246
|
+
//
|
|
247
|
+
// If the worker ends a turn with a question to the user in free text (no tool
|
|
248
|
+
// call, just hedging), Gap B above won't fire when the queue is empty.
|
|
249
|
+
// Regex-based detection was rejected as brittle. Instead: a single Haiku call
|
|
250
|
+
// classifies the final assistant message. If it IS an open question to the
|
|
251
|
+
// user → block with escalation instructions.
|
|
252
|
+
//
|
|
253
|
+
// Fail-open throughout: missing API key, missing transcript, model errors,
|
|
254
|
+
// malformed responses all skip cleanly. Silent-stall false-negatives recover;
|
|
255
|
+
// blocking legitimate stops on classifier bugs does not.
|
|
256
|
+
try {
|
|
257
|
+
const isWorker = process.env.WOGI_WORKSPACE_ROOT &&
|
|
258
|
+
process.env.WOGI_REPO_NAME &&
|
|
259
|
+
process.env.WOGI_REPO_NAME !== 'manager';
|
|
260
|
+
if (isWorker) {
|
|
261
|
+
const { getConfig } = require('../../../flow-utils');
|
|
262
|
+
const config = getConfig();
|
|
263
|
+
const clf = config.workspace?.aiWorkerQuestionClassifier;
|
|
264
|
+
const enabled = clf?.enabled !== false; // default true
|
|
265
|
+
if (enabled && parsedInput?.transcriptPath) {
|
|
266
|
+
const { classifyWorkerQuestion } = require('../../../flow-worker-question-classifier');
|
|
267
|
+
const result = await classifyWorkerQuestion({
|
|
268
|
+
transcriptPath: parsedInput.transcriptPath,
|
|
269
|
+
minConfidence: Number.isFinite(clf?.minConfidence) ? clf.minConfidence : 70,
|
|
270
|
+
model: typeof clf?.model === 'string' ? clf.model : undefined
|
|
271
|
+
});
|
|
272
|
+
if (result?.blocked) {
|
|
273
|
+
const port = process.env.WOGI_MANAGER_PORT || '8800';
|
|
274
|
+
const repo = process.env.WOGI_REPO_NAME;
|
|
275
|
+
const msg = [
|
|
276
|
+
`WORKER→USER QUESTION DETECTED (confidence ${result.confidence}%, threshold ${result.minConfidence}%):`,
|
|
277
|
+
` "${String(result.reason || '').slice(0, 200)}"`,
|
|
278
|
+
'',
|
|
279
|
+
'In workspace mode, workers CANNOT ask the user directly — the user only sees',
|
|
280
|
+
'the manager terminal. Your question will stall silently.',
|
|
281
|
+
'',
|
|
282
|
+
'Channel-dispatch to the manager instead, THEN end the turn:',
|
|
283
|
+
'',
|
|
284
|
+
` curl -s -X POST http://127.0.0.1:${port} \\`,
|
|
285
|
+
` -H "X-Wogi-From: ${repo}" \\`,
|
|
286
|
+
` --data-binary "## QUESTION: <your question>"`,
|
|
287
|
+
'',
|
|
288
|
+
'The manager will relay to the user, capture the answer, and dispatch a',
|
|
289
|
+
'follow-up task to you with the resolved context.',
|
|
290
|
+
'',
|
|
291
|
+
'If you don\'t actually need the user — make a reasonable autonomous decision',
|
|
292
|
+
'and note it in your ## Results reply to the manager. Then end the turn.'
|
|
293
|
+
].join('\n');
|
|
294
|
+
return { __raw: true, continue: true, stopReason: msg };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
} catch (err) {
|
|
299
|
+
// Fail-OPEN — classifier errors must not block legitimate stops.
|
|
300
|
+
if (process.env.DEBUG) {
|
|
301
|
+
console.error(`[Stop] Worker question classifier error (fail-open): ${err.message}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
245
305
|
// Check if loop can exit
|
|
246
306
|
return await checkLoopExit();
|
|
247
307
|
}, { failMode: 'warn', failOutput: { continue: false } });
|