wogiflow 2.31.0 → 2.31.2

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.
@@ -1,496 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Wogi Flow - Claude Code Stop Hook
4
+ * Wogi Flow - Claude Code Stop Hook (thin entry)
5
5
  *
6
- * Called when Claude is about to stop.
7
- * Enforces:
8
- * 1. Loop completion - blocks stop if acceptance criteria incomplete
9
- * 2. Routing enforcement - blocks stop if routing-pending flag is still set
10
- * (catches text-only responses that bypassed /wogi-start routing)
6
+ * All Stop-hook business logic lives in scripts/hooks/core/stop-orchestrator.js.
7
+ * This entry file dispatches.
11
8
  *
12
- * v6.2: Added routing enforcement to catch post-compaction bypass
9
+ * Per .claude/rules/architecture/hook-three-layer.md: entry files 120 LOC,
10
+ * ≤ 2 core/ imports, no inline business logic. wf-6e31850e A-3 extracted
11
+ * the prior 518-LOC body into core/stop-orchestrator.js +
12
+ * core/workspace-stop-notify.js + core/task-boundary-restart-coordinator.js +
13
+ * core/workspace-stop-gates.js.
13
14
  */
14
15
 
15
- const { checkLoopExit } = require('../../core/loop-check');
16
- const { isRoutingPending, incrementStopAttempts } = require('../../core/routing-gate');
16
+ const { orchestrateStop } = require('../../core/stop-orchestrator');
17
17
  const { runHook } = require('../shared/hook-runner');
18
18
 
19
19
  runHook('Stop', async ({ parsedInput }) => {
20
- // wf-35742353 Gate priority: if long-input-pending is active, it is the
21
- // top-priority remediation. The UserPromptSubmit hook already surfaced the
22
- // full long-input message on prompt arrival; firing routing-enforcement
23
- // and research-required gates now would issue conflicting "do this NOW"
24
- // instructions in the same turn. Defer the lower-priority Stop-hook gates
25
- // until long-input-pending is resolved.
26
- //
27
- // Fail-open: any error reading the marker falls through to normal gate flow.
28
- let longInputActive = false;
29
- try {
30
- const { isLongInputPending } = require('../../core/long-input-enforcement');
31
- longInputActive = isLongInputPending();
32
- } catch (_err) { /* fail-open */ }
33
-
34
- // wf-b8839d99 fix #5 — Routing-recovery grace window. If the user just
35
- // corrected a prior AI defer-auth ("I did not authorize..."), the deferral
36
- // classifier wrote a 60-second grace marker. During that window, the AI
37
- // should be able to undo/revoke without bouncing through /wogi-start first.
38
- // Routing-enforcement softens to a single warning instead of hard-blocking.
39
- let recoveryGraceActive = false;
40
- try {
41
- const fs = require('node:fs');
42
- const path = require('node:path');
43
- const { PATHS } = require('../../../flow-utils');
44
- const gracePath = path.join(PATHS.state, 'routing-recovery-grace.json');
45
- if (fs.existsSync(gracePath)) {
46
- const raw = fs.readFileSync(gracePath, 'utf-8');
47
- const data = JSON.parse(raw);
48
- if (data?.expiresAt && Date.parse(data.expiresAt) > Date.now()) {
49
- recoveryGraceActive = true;
50
- } else {
51
- // Expired — clean up
52
- try { fs.unlinkSync(gracePath); } catch (_err) { /* fine */ }
53
- }
54
- }
55
- } catch (_err) { /* fail-open */ }
56
-
57
- // v6.2: Routing enforcement check — catches text-only response bypass
58
- // If routing-pending flag is still set when the AI tries to stop, it means
59
- // the AI responded to the user's message without ever invoking a /wogi-* command.
60
- // This is the exact bypass we need to prevent (especially after context compaction).
61
- try {
62
- if (isRoutingPending() && !longInputActive && !recoveryGraceActive) {
63
- // Use counter-based approach instead of clearing immediately.
64
- // This gives the AI multiple chances to comply before giving up.
65
- // Gap 4 fix: clearing immediately made this single-shot protection.
66
- const { cleared, attempts } = incrementStopAttempts(10);
67
-
68
- if (cleared) {
69
- // Max attempts reached — allow stop to prevent infinite loop
70
- if (process.env.DEBUG) {
71
- console.error(`[Stop] Max routing enforcement attempts reached (${attempts}), allowing stop`);
72
- }
73
- // Fall through to normal stop logic
74
- } else {
75
- // Block the stop — force the AI to route through /wogi-start
76
- const routingMessage = [
77
- `ROUTING VIOLATION (attempt ${attempts}/10): You MUST call Skill(skill="wogi-start") before responding.`,
78
- '',
79
- 'Call Skill(skill="wogi-start", args="<user\'s message>") NOW. No text. No explanation. Just the Skill tool call.'
80
- ].join('\n');
81
-
82
- // Return raw output — skip adapter transform for routing enforcement
83
- // (this needs { continue: true, stopReason } format directly)
84
- return { __raw: true, continue: true, stopReason: routingMessage };
85
- }
86
- }
87
- } catch (err) {
88
- // Fail-CLOSED for routing check — force continuation on errors.
89
- // Gap 5 fix: failing open here disabled the last line of defense.
90
- // Worst case: AI retries and hits the 3-attempt limit, which clears naturally.
91
- if (process.env.DEBUG) {
92
- console.error(`[Stop] Routing check error (fail-closed, forcing continue): ${err.message}`);
93
- }
94
- return {
95
- __raw: true,
96
- continue: true,
97
- stopReason: 'Routing enforcement check encountered an error. Please invoke /wogi-start with your request.'
98
- };
99
- }
100
-
101
- // Workspace worker: write a structured `worker-stopped` message to the
102
- // workspace message bus when stopping. This is the graceful-stop half of
103
- // silent-halt detection (wf-d3e67abe) — the manager's overdue check uses
104
- // this (vs. task-complete vs. nothing) to tell "finished" from "gave up
105
- // gracefully" from "died silently".
106
- //
107
- // Replaces the previous plain-text curl POST to the manager channel — that
108
- // was fire-and-forget with no structure, so manager-side reconciliation
109
- // couldn't distinguish graceful stops from silent deaths.
110
- if (process.env.WOGI_REPO_NAME && process.env.WOGI_REPO_NAME !== 'manager') {
111
- try {
112
- const nodePath = require('node:path');
113
- const childProcess = require('node:child_process');
114
- const VALID_NAME = /^[a-zA-Z0-9_-]{1,64}$/;
115
- const repoName = process.env.WOGI_REPO_NAME;
116
-
117
- if (!VALID_NAME.test(repoName)) {
118
- throw new Error(`Invalid WOGI_REPO_NAME`);
119
- }
120
-
121
- const workspaceRoot = process.env.WOGI_WORKSPACE_ROOT;
122
- if (workspaceRoot) {
123
- const { PATHS, safeJsonParse } = require('../../flow-utils');
124
- const ready = safeJsonParse(nodePath.join(PATHS.state, 'ready.json'), {});
125
- const recentTask = (ready.recentlyCompleted || [])[0];
126
- const inProgressTask = (ready.inProgress || [])[0];
127
- const mostRecent = recentTask || inProgressTask;
128
-
129
- // Determine worker state at stop-time
130
- const hasInProgress = Boolean(inProgressTask);
131
- const state = hasInProgress ? 'mid-work' : 'idle';
132
- const taskInProgress = hasInProgress ? inProgressTask.id : null;
133
-
134
- // Best-effort lastSha
135
- let lastSha = null;
136
- try {
137
- lastSha = childProcess.execSync('git rev-parse --short HEAD 2>/dev/null || true', {
138
- cwd: PATHS.root,
139
- encoding: 'utf-8',
140
- timeout: 2000
141
- }).trim() || null;
142
- } catch (_err) { /* non-critical */ }
143
-
144
- // Build structured message and persist via the workspace message bus.
145
- // The worker-stopped type was added to MESSAGE_TYPES in
146
- // workspace-messages.js (wf-d3e67abe).
147
- try {
148
- const libMessages = nodePath.resolve(__dirname, '..', '..', '..', '..', 'lib', 'workspace-messages');
149
- const { createMessage, saveMessage } = require(libMessages);
150
- const msg = createMessage({
151
- from: repoName,
152
- to: 'manager',
153
- type: 'worker-stopped',
154
- subject: hasInProgress
155
- ? `Worker stopped mid-work on ${taskInProgress}`
156
- : `Worker stopped (idle)`,
157
- body: [
158
- `Worker "${repoName}" is stopping.`,
159
- `State: ${state}`,
160
- taskInProgress ? `Task in progress: ${taskInProgress}` : null,
161
- mostRecent?.title ? `Most recent task: ${mostRecent.title}` : null,
162
- lastSha ? `Last commit: ${lastSha}` : null
163
- ].filter(Boolean).join('\n'),
164
- priority: hasInProgress ? 'high' : 'medium',
165
- actionRequired: hasInProgress
166
- });
167
- // Attach structured fields the manager-side reconciler consumes.
168
- msg.taskId = taskInProgress;
169
- msg.reason = 'graceful';
170
- msg.state = state;
171
- msg.taskInProgress = taskInProgress;
172
- msg.lastSha = lastSha;
173
- saveMessage(workspaceRoot, msg);
174
- } catch (err) {
175
- if (process.env.DEBUG) {
176
- console.error(`[Stop] Workspace message write failed: ${err.message}`);
177
- }
178
- }
179
- }
180
- } catch (err) {
181
- if (process.env.DEBUG) {
182
- console.error(`[Stop] Workspace notification failed: ${err.message}`);
183
- }
184
- }
185
- }
186
-
187
- // Task-boundary session restart (wf-39e9dc09 — Phase 2, Stop-hook pivot).
188
- // Runs BEFORE checkLoopExit so we can SIGTERM cleanly if a task was just
189
- // completed. This is a verified direct child of the claude process (the
190
- // Stop hook fires reliably — directly observed in test run 2026-04-15,
191
- // unlike TaskCompleted which was found not to fire for Task-tool subagents).
192
- // No-op unless task-just-completed marker exists AND feature is enabled
193
- // AND wogi-claude wrapper env is present.
194
- try {
195
- const {
196
- consumeAndTriggerRestart,
197
- hasPendingMarker,
198
- ensurePhase1MarkedIfRecentlyCompleted
199
- } = require('../../core/task-boundary-reset');
200
-
201
- // Phase 1 fallback: if the task completed via a path that didn't write the
202
- // marker (e.g., agent edited ready.json directly instead of running
203
- // `flow done`, or TaskCompleted hook didn't fire), retro-mark here so
204
- // Phase 2 below can consume it. Anti-replay sentinel prevents double-firing
205
- // across the SIGTERM + wrapper restart cycle.
206
- try {
207
- const fallback = ensurePhase1MarkedIfRecentlyCompleted();
208
- if (fallback.marked && process.env.DEBUG) {
209
- console.error(`[Stop] Phase 1 fallback marked ${fallback.taskId}`);
210
- } else if (!fallback.marked && fallback.reason !== 'marker-already-present' &&
211
- fallback.reason !== 'no-fresh-completion' &&
212
- fallback.reason !== 'stale-completion' &&
213
- fallback.reason !== 'already-triggered-for-this-task' &&
214
- process.env.DEBUG) {
215
- console.error(`[Stop] Phase 1 fallback skipped: ${fallback.reason}`);
216
- }
217
- } catch (err) {
218
- if (process.env.DEBUG) {
219
- console.error(`[Stop] Phase 1 fallback error (fail-open): ${err.message}`);
220
- }
221
- }
222
-
223
- // If we're about to restart, record the session in history FIRST so the
224
- // new session can find the prior session's resume token. Use parsedInput
225
- // or session-state for the cliSessionId.
226
- if (hasPendingMarker()) {
227
- try {
228
- const { recordSessionEnd } = require('../../core/session-history');
229
- let cliSessionId = parsedInput?.sessionId || null;
230
- if (!cliSessionId) {
231
- // Fallback: read from session-state.json
232
- const { PATHS, safeJsonParse } = require('../../../flow-utils');
233
- const path = require('node:path');
234
- const ss = safeJsonParse(path.join(PATHS.state, 'session-state.json'), {});
235
- cliSessionId = ss.cliSessionId || null;
236
- }
237
- if (cliSessionId) {
238
- // Collect tasks completed in this session from recentlyCompleted
239
- // (best-effort — not all of these are from THIS session but it's
240
- // a reasonable approximation; in practice the newest entries are ours)
241
- const { PATHS, safeJsonParse } = require('../../../flow-utils');
242
- const path = require('node:path');
243
- const ready = safeJsonParse(path.join(PATHS.state, 'ready.json'), {});
244
- const recent = ready.recentlyCompleted || [];
245
- const lastCompleted = recent[0] || null;
246
- recordSessionEnd({
247
- cliSessionId,
248
- endReason: 'task-boundary-restart',
249
- tasksCompletedInSession: recent.slice(0, 5).map(t => t.id).filter(Boolean),
250
- lastActiveTaskTitle: lastCompleted?.title || null
251
- });
252
- }
253
- } catch (err) {
254
- if (process.env.DEBUG) {
255
- console.error(`[Stop] Session history record failed (non-fatal): ${err.message}`);
256
- }
257
- }
258
- }
259
-
260
- const restartResult = await consumeAndTriggerRestart({
261
- transcriptPath: parsedInput?.transcriptPath
262
- });
263
- if (restartResult.triggered) {
264
- if (process.env.DEBUG) {
265
- console.error(`[Stop] Task-boundary restart triggered — claude will exit, wrapper will relaunch`);
266
- }
267
- // CRITICAL: return NOW, short-circuiting subsequent stop-blocking gates.
268
- //
269
- // Before this fix (observed 2026-04-17): Phase 2 would SIGTERM claude and
270
- // write the restart flag, then fall through to the workspace autopickup
271
- // gate (lines below). For a worker with queued dispatches (the common
272
- // case), that gate returns `{ continue: true, stopReason: ... }` which
273
- // Claude Code honours as "don't stop, pick up next dispatch." Result: the
274
- // SIGTERM + restart flag became a no-op because claude was told to keep
275
- // running in the SAME session. Symptom: single claude PID survives across
276
- // N tasks, context accumulates, tokens burn — exactly the complaint this
277
- // feature was supposed to solve.
278
- //
279
- // The restart is our stop path. The next session's SessionStart hook will
280
- // inject queued-dispatch context, so the worker picks up the next task
281
- // on RESTART rather than via the autopickup gate's continue-override.
282
- // __raw skips the adapter transform — we want the literal {continue:false}
283
- // wire format to reach claude unchanged.
284
- return { __raw: true, continue: false };
285
- }
286
- if (restartResult.reason !== 'no-pending-marker' && process.env.DEBUG) {
287
- console.error(`[Stop] Task-boundary restart check: ${restartResult.reason}`);
288
- }
289
- } catch (err) {
290
- if (process.env.DEBUG) {
291
- console.error(`[Stop] Task-boundary restart module error (fail-open): ${err.message}`);
292
- }
293
- // Never block Stop on restart-module errors.
294
- }
295
-
296
- // wf-5cd71b1f: Research-Required Stop-Hook Gate. If the user's prompt this
297
- // turn was classified as diagnostic (Tier 2/3 from CLAUDE.md), check that
298
- // the AI made enough Read calls against evidence paths before answering.
299
- // If not, re-prompt with a violation message forcing a redo. Fail-open.
300
- //
301
- // wf-35742353 — Skip this gate when long-input-pending is active. The user's
302
- // prompt isn't yet captured, so demanding evidence-reading would issue a
303
- // conflicting remediation. The diagnostic marker will still be present when
304
- // long-input resolves; the gate fires correctly then.
305
- try {
306
- if (longInputActive) {
307
- // skip — defer to long-input remediation
308
- } else {
309
- const { checkResearchRequiredGate } = require('../../core/research-required-gate');
310
- const { getConfig } = require('../../../flow-utils');
311
- const config = getConfig();
312
- const result = checkResearchRequiredGate({
313
- transcriptPath: parsedInput?.transcriptPath,
314
- config
315
- });
316
- if (result.blocked) {
317
- if (result.hardStop) {
318
- // Hard-stop: AI failed N times — surface to user
319
- return { __raw: true, continue: false, stopReason: result.message };
320
- }
321
- // Soft re-prompt: force the AI to redo with reads
322
- return { __raw: true, continue: true, stopReason: result.message };
323
- }
324
- } // end else (wf-35742353 long-input-active skip)
325
- } catch (err) {
326
- if (process.env.DEBUG) {
327
- console.error(`[Stop] Research-required gate error (fail-open): ${err.message}`);
328
- }
329
- }
330
-
331
- // Gap B (v2.20.0) — block end-of-turn when a workspace worker has queued
332
- // channel dispatches but no task in progress. This is the hedging-as-terminal-
333
- // state anti-pattern ("awaiting signal or will proceed"). The worker MUST
334
- // either (a) start the next dispatch or (b) escalate via ## QUESTION: — idle
335
- // with pending dispatches is not a valid end-of-turn state.
336
- //
337
- // Gap A already injects additionalContext telling the AI to auto-pickup. This
338
- // gate is the second line of defense: if the AI ignored the context and tried
339
- // to stop anyway, block it.
340
- try {
341
- const isWorker = process.env.WOGI_WORKSPACE_ROOT &&
342
- process.env.WOGI_REPO_NAME &&
343
- process.env.WOGI_REPO_NAME !== 'manager';
344
- if (isWorker) {
345
- const { getConfig, PATHS, safeJsonParse } = require('../../../flow-utils');
346
- const path = require('node:path');
347
- const config = getConfig();
348
- const gateEnabled = config.workspace?.autoPickupChannelDispatches !== false;
349
- if (gateEnabled) {
350
- const ready = safeJsonParse(path.join(PATHS.state, 'ready.json'), { ready: [], inProgress: [] });
351
- const inProgressCount = (ready.inProgress || []).length;
352
- const queued = (ready.ready || []).filter(t => {
353
- if (!t || typeof t !== 'object') return false;
354
- return t.channelSource === 'wogi-workspace-channel' ||
355
- t.dispatchedBy === 'workspace-manager' ||
356
- (typeof t.source === 'string' && t.source.startsWith('workspace:'));
357
- });
358
- if (inProgressCount === 0 && queued.length > 0) {
359
- const nextId = queued[0].id;
360
- const msg = [
361
- `AUTONOMOUS MODE VIOLATION: ${queued.length} channel dispatch(es) queued, no task in progress.`,
362
- '',
363
- `You are a workspace worker — "awaiting your signal" / "let me know" / "or will proceed" is NOT a valid terminal state.`,
364
- '',
365
- 'Exactly one of these must be true at end-of-turn:',
366
- ' (a) You started the next pre-approved dispatch (ACTION), or',
367
- ' (b) You channel-dispatched a "## QUESTION:" to manager (ESCALATION), or',
368
- ' (c) Zero queued and zero in-progress (IDLE — not your current state).',
369
- '',
370
- `ACT NOW: Invoke Skill(skill="wogi-start", args="${nextId}")`,
371
- '',
372
- `Or escalate: curl -s -X POST http://127.0.0.1:${process.env.WOGI_MANAGER_PORT || '8800'} \\`,
373
- ` -H "X-Wogi-From: ${process.env.WOGI_REPO_NAME}" \\`,
374
- ` --data-binary "## QUESTION: <your blocker>"`
375
- ].join('\n');
376
- return { __raw: true, continue: true, stopReason: msg };
377
- }
378
- }
379
- }
380
- } catch (err) {
381
- // Fail-OPEN for this specific gate — we do not want a bug here to block
382
- // legitimate stops. The routing gate above is fail-closed; this one isn't
383
- // because unlike routing it's not a last-line-of-defense — the auto-pickup
384
- // additionalContext already nudged the AI before this point.
385
- if (process.env.DEBUG) {
386
- console.error(`[Stop] Workspace autopickup gate error (fail-open): ${err.message}`);
387
- }
388
- }
389
-
390
- // Worker Tool-First Turn Gate (G1 + G4 + G6 — epic wf-34290000, Workstream G).
391
- //
392
- // In worker mode, every turn after a UserPromptSubmit (channel dispatch)
393
- // MUST have at least one tool call. Strict mode also requires the first
394
- // assistant content block to be a tool call, not text. Pure-text worker
395
- // responses are invisible to the user and violate the three-state
396
- // end-of-turn contract.
397
- //
398
- // Gates in order: G1 (zero tool_use = silent-halt) → G4 (text-first block =
399
- // text-before-tool-call). Both share the rule name "worker-tool-first-turn"
400
- // (G6). Fail-open — missing transcript / parse errors / config errors
401
- // return no-block.
402
- try {
403
- const { isWorkerMode, checkWorkerToolFirstTurn, renderBlockMessage } =
404
- require('../../core/worker-tool-first-gate');
405
- if (isWorkerMode() && parsedInput?.transcriptPath) {
406
- const { getConfig } = require('../../../flow-utils');
407
- const config = getConfig();
408
- const gateCfg = config.workspace?.toolFirstTurnGate;
409
- const enabled = gateCfg?.enabled !== false; // default true
410
- if (enabled) {
411
- const strict = gateCfg?.strict !== false; // default true
412
- const result = checkWorkerToolFirstTurn({
413
- transcriptPath: parsedInput.transcriptPath,
414
- strict
415
- });
416
- if (result.blocked) {
417
- return {
418
- __raw: true,
419
- continue: true,
420
- stopReason: renderBlockMessage(result)
421
- };
422
- }
423
- }
424
- }
425
- } catch (err) {
426
- // Fail-OPEN — any error in the tool-first gate must not block legitimate
427
- // stops. Silent-halt / text-first false-negatives are recoverable; a
428
- // false-positive block on every turn is not.
429
- if (process.env.DEBUG) {
430
- console.error(`[Stop] Worker tool-first gate error (fail-open): ${err.message}`);
431
- }
432
- }
433
-
434
- // G3 (v2.21.0) — AI-based worker-question classifier.
435
- //
436
- // If the worker ends a turn with a question to the user in free text (no tool
437
- // call, just hedging), Gap B above won't fire when the queue is empty.
438
- // Regex-based detection was rejected as brittle. Instead: a single Haiku call
439
- // classifies the final assistant message. If it IS an open question to the
440
- // user → block with escalation instructions.
441
- //
442
- // Fail-open throughout: missing API key, missing transcript, model errors,
443
- // malformed responses all skip cleanly. Silent-stall false-negatives recover;
444
- // blocking legitimate stops on classifier bugs does not.
445
- try {
446
- const isWorker = process.env.WOGI_WORKSPACE_ROOT &&
447
- process.env.WOGI_REPO_NAME &&
448
- process.env.WOGI_REPO_NAME !== 'manager';
449
- if (isWorker) {
450
- const { getConfig } = require('../../../flow-utils');
451
- const config = getConfig();
452
- const clf = config.workspace?.aiWorkerQuestionClassifier;
453
- const enabled = clf?.enabled !== false; // default true
454
- if (enabled && parsedInput?.transcriptPath) {
455
- const { classifyWorkerQuestion } = require('../../../flow-worker-question-classifier');
456
- const result = await classifyWorkerQuestion({
457
- transcriptPath: parsedInput.transcriptPath,
458
- minConfidence: Number.isFinite(clf?.minConfidence) ? clf.minConfidence : 70,
459
- model: typeof clf?.model === 'string' ? clf.model : undefined
460
- });
461
- if (result?.blocked) {
462
- const port = process.env.WOGI_MANAGER_PORT || '8800';
463
- const repo = process.env.WOGI_REPO_NAME;
464
- const msg = [
465
- `WORKER→USER QUESTION DETECTED (confidence ${result.confidence}%, threshold ${result.minConfidence}%):`,
466
- ` "${String(result.reason || '').slice(0, 200)}"`,
467
- '',
468
- 'In workspace mode, workers CANNOT ask the user directly — the user only sees',
469
- 'the manager terminal. Your question will stall silently.',
470
- '',
471
- 'Channel-dispatch to the manager instead, THEN end the turn:',
472
- '',
473
- ` curl -s -X POST http://127.0.0.1:${port} \\`,
474
- ` -H "X-Wogi-From: ${repo}" \\`,
475
- ` --data-binary "## QUESTION: <your question>"`,
476
- '',
477
- 'The manager will relay to the user, capture the answer, and dispatch a',
478
- 'follow-up task to you with the resolved context.',
479
- '',
480
- 'If you don\'t actually need the user — make a reasonable autonomous decision',
481
- 'and note it in your ## Results reply to the manager. Then end the turn.'
482
- ].join('\n');
483
- return { __raw: true, continue: true, stopReason: msg };
484
- }
485
- }
486
- }
487
- } catch (err) {
488
- // Fail-OPEN — classifier errors must not block legitimate stops.
489
- if (process.env.DEBUG) {
490
- console.error(`[Stop] Worker question classifier error (fail-open): ${err.message}`);
491
- }
492
- }
493
-
494
- // Check if loop can exit
495
- return await checkLoopExit();
20
+ return await orchestrateStop({ parsedInput });
496
21
  }, { failMode: 'warn', failOutput: { continue: false } });