wogiflow 2.20.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wogiflow",
3
- "version": "2.20.1",
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 tests/flow-worker-boundary-gate.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,10 +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
- _comment_diagnosticCurlBypass: 'v2.20.0+: When true, PreToolUse routing gate allows narrow curl-to-manager-port when replying to channel messages tagged INTROSPECTION or DIAGNOSTIC, with body starting "## ". Unblocks diagnostic round-trips without forcing fake task creation. Scope: localhost:8800 only.',
403
- diagnosticCurlBypass: true,
404
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.',
405
- blockAskUserQuestionInWorker: true
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
+ }
406
410
  },
407
411
  checkpoint: { enabled: false },
408
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, toolInput);
180
+ const routingResult = deps.checkRoutingGate(toolName, config);
181
181
  if (routingResult.blocked) {
182
182
  return {
183
183
  allowed: false,
@@ -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, toolInput) {
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,
@@ -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 } });