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.
- package/.claude/docs/config-schema.md +219 -0
- package/package.json +1 -1
- package/scripts/flow-config-defaults.js +27 -0
- package/scripts/flow-defer-auth.js +41 -10
- package/scripts/flow-deferral-classifier-ai.js +3 -1
- package/scripts/flow-impl-question-classifier.js +3 -1
- package/scripts/flow-self-adversary-loop.js +81 -14
- package/scripts/flow-standards-gate.js +3 -1
- package/scripts/hooks/core/deferral-classifier.js +3 -0
- package/scripts/hooks/core/deferral-gate.js +8 -3
- package/scripts/hooks/core/gate-orchestrator.js +46 -8
- package/scripts/hooks/core/self-adversary-gate.js +4 -1
- package/scripts/hooks/core/session-start-orchestrator.js +269 -0
- package/scripts/hooks/core/stop-orchestrator.js +126 -0
- package/scripts/hooks/core/task-boundary-restart-coordinator.js +84 -0
- package/scripts/hooks/core/user-prompt-orchestrator.js +201 -0
- package/scripts/hooks/core/workspace-stop-gates.js +133 -0
- package/scripts/hooks/core/workspace-stop-notify.js +76 -0
- package/scripts/hooks/entry/claude-code/session-start.js +19 -352
- package/scripts/hooks/entry/claude-code/stop.js +10 -485
- package/scripts/hooks/entry/claude-code/user-prompt-submit.js +9 -277
|
@@ -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
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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 } });
|