wogiflow 2.19.0 → 2.20.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/lib/workspace.js CHANGED
@@ -1199,6 +1199,22 @@ You are a workspace worker. There is NO human watching your terminal. You MUST o
1199
1199
  - **Auto-approve all reviews**: If /wogi-review asks for fix options, choose Option 1 (fix all) automatically.
1200
1200
  - **Never ask clarifying questions**: If something is ambiguous, make a reasonable decision and note it in your reply to the manager.
1201
1201
 
1202
+ ### End-of-Turn Must Be a Deterministic Action (v2.20.0+)
1203
+
1204
+ "Awaiting your signal" is NOT a valid terminal state. Exactly one of these must be true at end-of-turn:
1205
+
1206
+ 1. **ACTION** — You started the next pre-approved channel dispatch (e.g., invoked \`Skill(skill="wogi-start", args="wf-XXXXXXXX")\`), OR
1207
+ 2. **ESCALATION** — You channel-dispatched a \`## QUESTION:\` to the manager because you are genuinely blocked and Steps 1-2 of the Resolution Protocol above did not unstick you, OR
1208
+ 3. **IDLE** — There are zero pending channel dispatches in ready.json AND zero tasks in progress. This is the only legitimate "wait" state.
1209
+
1210
+ **Hedging language is forbidden**: "awaiting your signal", "let me know if you want", "or will proceed", "should I continue", "ready when you are", "standing by", "awaiting confirmation". These phrases invent a decision point that does not exist in autonomous mode — the dispatch was already pre-approved by the manager's decision to queue it.
1211
+
1212
+ **Visibility is NOT a substitute for action.** You can narrate AND act in the same turn. State what you just did and what you are starting next, THEN start it. Do not treat the summary as a stopping point.
1213
+
1214
+ **Common rationalization to resist**: *"Let me give the owner visibility before acting."* That is the anti-pattern. The owner does not see your terminal. The only way they see progress is through (a) manager reports as you complete tasks and (b) the manager dispatching the next work. Stopping between queued dispatches creates a gap in both signals.
1215
+
1216
+ The \`TaskCompleted\` hook will inject an auto-pickup directive when channel dispatches are queued (v2.20.0+). The \`Stop\` hook will BLOCK end-of-turn if you try to stop while dispatches are queued and no task is in progress. These enforcements exist because the silent-stall pattern was incident-worthy (2026-04-16).
1217
+
1202
1218
  ### CRITICAL: Stop, Don't Degrade
1203
1219
 
1204
1220
  **If you cannot verify your work to the required evidence tier, you may NOT mark the task as complete.** Report it as BLOCKED with the specific verification gap.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wogiflow",
3
- "version": "2.19.0",
3
+ "version": "2.20.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 && 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 && 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",
@@ -396,7 +396,11 @@ const CONFIG_DEFAULTS = {
396
396
  _comment_workerGatesSovereign: 'When true, workers cannot skip their own quality gates even if the manager instructs them to',
397
397
  workerGatesSovereign: true,
398
398
  managerCanOverrideLevel: false,
399
- managerCanSkipGates: false
399
+ managerCanSkipGates: false,
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
+ 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
400
404
  },
401
405
  checkpoint: { enabled: false },
402
406
  regressionTesting: { enabled: false },
@@ -471,14 +471,23 @@ Run: /wogi-start ${coreResult.nextTaskId}`;
471
471
  return { continue: true };
472
472
  }
473
473
 
474
+ // Gap A (v2.20.0) — inject auto-pickup additionalContext when workspace
475
+ // worker has queued channel dispatches. Core already decided whether this
476
+ // applies (only fires in workspace worker mode + autoPickupChannelDispatches
477
+ // config + at least one queued dispatch).
478
+ const hookSpecificOutput = {
479
+ hookEventName: 'TaskCompleted',
480
+ completed: coreResult.completed,
481
+ taskId: coreResult.taskId
482
+ };
483
+ if (coreResult.workspaceAutoPickup?.additionalContext) {
484
+ hookSpecificOutput.additionalContext = coreResult.workspaceAutoPickup.additionalContext;
485
+ }
486
+
474
487
  return {
475
488
  continue: true,
476
489
  ...(coreResult.message && { systemMessage: coreResult.message }),
477
- hookSpecificOutput: {
478
- hookEventName: 'TaskCompleted',
479
- completed: coreResult.completed,
480
- taskId: coreResult.taskId
481
- }
490
+ hookSpecificOutput
482
491
  };
483
492
  }
484
493
 
@@ -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, toolInput);
181
181
  if (routingResult.blocked) {
182
182
  return {
183
183
  allowed: false,
@@ -285,9 +285,10 @@ 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)
288
289
  * @returns {{ allowed: boolean, blocked: boolean, reason: string, message: string|null }}
289
290
  */
290
- function checkRoutingGate(toolName, config) {
291
+ function checkRoutingGate(toolName, config, toolInput) {
291
292
  // Gate ALL tools that allow the AI to act without routing through /wogi-start.
292
293
  // Edit/Write/NotebookEdit were the critical gap: AI could edit ready.json (exempt
293
294
  // from task gate) to create a fake active task, then edit anything freely.
@@ -317,6 +318,26 @@ function checkRoutingGate(toolName, config) {
317
318
  // This meant any in-progress task from a prior turn bypassed routing entirely.
318
319
  // The only way to clear routing-pending is to invoke a /wogi-* skill.
319
320
 
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
+
320
341
  // Block: routing is pending and no /wogi-* command has been invoked this turn
321
342
  // NOTE: This message is shown to the AI as permissionDecisionReason.
322
343
  // It must be prescriptive enough that the AI invokes /wogi-start instead of
@@ -337,6 +358,67 @@ function checkRoutingGate(toolName, config) {
337
358
  };
338
359
  }
339
360
 
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
  * Increment the stop-attempt counter in the routing flag.
342
424
  * Used by the Stop hook instead of clearing the flag outright,
@@ -385,6 +467,7 @@ function incrementStopAttempts(maxAttempts = 10) {
385
467
  }
386
468
 
387
469
  module.exports = {
470
+ isDiagnosticCurlBypass,
388
471
  isRoutingGateEnabled,
389
472
  hasActiveTask,
390
473
  setRoutingPending,
@@ -539,7 +539,143 @@ async function handleTaskCompleted(input) {
539
539
  result.message = `Task completed handler error: ${err.message}`;
540
540
  }
541
541
 
542
+ // Gap A (v2.20.0) — workspace worker auto-pickup of queued channel dispatches.
543
+ //
544
+ // Without this, a worker completes a task, reports to manager, then ends the
545
+ // turn. Any channel-dispatches that landed while the worker was busy remain
546
+ // in ready.json indefinitely — the worker sits idle ("awaiting signal").
547
+ //
548
+ // Fix: when a workspace worker's task completes and queued channel dispatches
549
+ // exist, emit additionalContext instructing the AI to auto-invoke
550
+ // /wogi-start <nextId> in the SAME turn, before the Stop hook fires.
551
+ //
552
+ // Only runs in worker mode (WOGI_WORKSPACE_ROOT + WOGI_REPO_NAME !== 'manager').
553
+ // Manager sessions deliberately do NOT auto-pickup — that would hijack user
554
+ // orchestration.
555
+ if (result.completed && isWorkspaceWorker()) {
556
+ try {
557
+ const pickup = findQueuedChannelDispatches();
558
+ if (pickup.count > 0) {
559
+ result.workspaceAutoPickup = {
560
+ nextTaskId: pickup.nextTaskId,
561
+ queuedCount: pickup.count,
562
+ additionalContext: buildAutoPickupContext(pickup)
563
+ };
564
+ }
565
+ } catch (err) {
566
+ if (process.env.DEBUG) {
567
+ console.error(`[Task Completed] Auto-pickup check failed (non-fatal): ${err.message}`);
568
+ }
569
+ }
570
+ }
571
+
542
572
  return result;
543
573
  }
544
574
 
545
- module.exports = { handleTaskCompleted, isTaskCompletedEnabled };
575
+ /**
576
+ * Detect if the current process is a workspace worker (not a manager and not a
577
+ * single-repo session). Requires WOGI_WORKSPACE_ROOT env var set by the worker
578
+ * spawn path AND WOGI_REPO_NAME to be something other than 'manager'.
579
+ *
580
+ * @returns {boolean}
581
+ */
582
+ function isWorkspaceWorker() {
583
+ if (!process.env.WOGI_WORKSPACE_ROOT) return false;
584
+ const repo = process.env.WOGI_REPO_NAME;
585
+ if (!repo || repo === 'manager') return false;
586
+ return true;
587
+ }
588
+
589
+ /**
590
+ * Scan ready.json for channel-dispatched tasks that are queued but not in
591
+ * progress. Returns the oldest pending task (FIFO — channel dispatches should
592
+ * be processed in arrival order).
593
+ *
594
+ * Tagging conventions recognized (in priority order):
595
+ * 1. task.channelSource === 'wogi-workspace-channel' (explicit tag)
596
+ * 2. task.source starts with 'workspace:' (existing tag from
597
+ * lib/workspace-routing.js decomposeToRepoTasks at line 428)
598
+ * 3. task.dispatchedBy === 'workspace-manager' (alternate explicit tag)
599
+ *
600
+ * Tasks already in inProgress are NOT counted — the AI already has work to do.
601
+ *
602
+ * @returns {{ count: number, nextTaskId: string|null, nextTaskTitle: string|null }}
603
+ */
604
+ function findQueuedChannelDispatches() {
605
+ const config = getConfig();
606
+ if (config.workspace?.autoPickupChannelDispatches === false) {
607
+ return { count: 0, nextTaskId: null, nextTaskTitle: null };
608
+ }
609
+
610
+ const readyPath = path.join(PATHS.state, 'ready.json');
611
+ const ready = safeJsonParse(readyPath, { ready: [], inProgress: [] });
612
+
613
+ // If anything is in progress, the worker already has direction. Don't auto-pickup.
614
+ if ((ready.inProgress || []).length > 0) {
615
+ return { count: 0, nextTaskId: null, nextTaskTitle: null };
616
+ }
617
+
618
+ const queued = (ready.ready || []).filter(isChannelDispatched);
619
+ if (queued.length === 0) {
620
+ return { count: 0, nextTaskId: null, nextTaskTitle: null };
621
+ }
622
+
623
+ // FIFO — pick the earliest created. Tasks without createdAt fall through
624
+ // to input order (JavaScript sort is stable for equal keys).
625
+ const sorted = [...queued].sort((a, b) => {
626
+ const at = a.createdAt || a.created || '';
627
+ const bt = b.createdAt || b.created || '';
628
+ return at.localeCompare(bt);
629
+ });
630
+ const next = sorted[0];
631
+
632
+ return {
633
+ count: queued.length,
634
+ nextTaskId: next?.id || null,
635
+ nextTaskTitle: next?.title || null
636
+ };
637
+ }
638
+
639
+ function isChannelDispatched(task) {
640
+ if (!task || typeof task !== 'object') return false;
641
+ if (task.channelSource === 'wogi-workspace-channel') return true;
642
+ if (task.dispatchedBy === 'workspace-manager') return true;
643
+ if (typeof task.source === 'string' && task.source.startsWith('workspace:')) return true;
644
+ return false;
645
+ }
646
+
647
+ /**
648
+ * Build the additionalContext text that instructs the AI to auto-invoke
649
+ * /wogi-start on the next queued dispatch. Wording is deliberately imperative —
650
+ * hedging ("consider", "you may wish to") was the exact anti-pattern that
651
+ * caused the original silent-stall incident (worker introspection 2026-04-16).
652
+ */
653
+ function buildAutoPickupContext({ count, nextTaskId, nextTaskTitle }) {
654
+ const s = count === 1 ? '' : 's';
655
+ return [
656
+ `⚡ WORKSPACE AUTONOMOUS PICKUP (${count} channel dispatch${s} queued):`,
657
+ '',
658
+ `You just completed a task. ${count} more channel-dispatched task${s} ${count === 1 ? 'is' : 'are'} queued in ready.json.`,
659
+ `Next: ${nextTaskId} — ${nextTaskTitle || '(no title)'}`,
660
+ '',
661
+ 'AUTONOMOUS MODE CONTRACT (workspace worker):',
662
+ ' • These dispatches are pre-approved by the manager.',
663
+ ' • You MUST start the next one IMMEDIATELY in this same turn.',
664
+ ' • Do NOT end the turn with hedging language ("awaiting signal",',
665
+ ' "let me know if you want", "or will proceed"). Those are forbidden.',
666
+ ' • Visibility is NOT a substitute for action. You can narrate AND act',
667
+ ' in the same turn.',
668
+ '',
669
+ `ACT NOW: Invoke Skill(skill="wogi-start", args="${nextTaskId}")`
670
+ ].join('\n');
671
+ }
672
+
673
+ module.exports = {
674
+ handleTaskCompleted,
675
+ isTaskCompletedEnabled,
676
+ // Exposed for testing (v2.20.0)
677
+ isWorkspaceWorker,
678
+ findQueuedChannelDispatches,
679
+ isChannelDispatched,
680
+ buildAutoPickupContext
681
+ };
@@ -183,6 +183,65 @@ runHook('Stop', async ({ parsedInput }) => {
183
183
  // Never block Stop on restart-module errors.
184
184
  }
185
185
 
186
+ // Gap B (v2.20.0) — block end-of-turn when a workspace worker has queued
187
+ // channel dispatches but no task in progress. This is the hedging-as-terminal-
188
+ // state anti-pattern ("awaiting signal or will proceed"). The worker MUST
189
+ // either (a) start the next dispatch or (b) escalate via ## QUESTION: — idle
190
+ // with pending dispatches is not a valid end-of-turn state.
191
+ //
192
+ // Gap A already injects additionalContext telling the AI to auto-pickup. This
193
+ // gate is the second line of defense: if the AI ignored the context and tried
194
+ // to stop anyway, block it.
195
+ try {
196
+ const isWorker = process.env.WOGI_WORKSPACE_ROOT &&
197
+ process.env.WOGI_REPO_NAME &&
198
+ process.env.WOGI_REPO_NAME !== 'manager';
199
+ if (isWorker) {
200
+ const { getConfig, PATHS, safeJsonParse } = require('../../../flow-utils');
201
+ const path = require('node:path');
202
+ const config = getConfig();
203
+ const gateEnabled = config.workspace?.autoPickupChannelDispatches !== false;
204
+ if (gateEnabled) {
205
+ const ready = safeJsonParse(path.join(PATHS.state, 'ready.json'), { ready: [], inProgress: [] });
206
+ const inProgressCount = (ready.inProgress || []).length;
207
+ const queued = (ready.ready || []).filter(t => {
208
+ if (!t || typeof t !== 'object') return false;
209
+ return t.channelSource === 'wogi-workspace-channel' ||
210
+ t.dispatchedBy === 'workspace-manager' ||
211
+ (typeof t.source === 'string' && t.source.startsWith('workspace:'));
212
+ });
213
+ if (inProgressCount === 0 && queued.length > 0) {
214
+ const nextId = queued[0].id;
215
+ const msg = [
216
+ `AUTONOMOUS MODE VIOLATION: ${queued.length} channel dispatch(es) queued, no task in progress.`,
217
+ '',
218
+ `You are a workspace worker — "awaiting your signal" / "let me know" / "or will proceed" is NOT a valid terminal state.`,
219
+ '',
220
+ 'Exactly one of these must be true at end-of-turn:',
221
+ ' (a) You started the next pre-approved dispatch (ACTION), or',
222
+ ' (b) You channel-dispatched a "## QUESTION:" to manager (ESCALATION), or',
223
+ ' (c) Zero queued and zero in-progress (IDLE — not your current state).',
224
+ '',
225
+ `ACT NOW: Invoke Skill(skill="wogi-start", args="${nextId}")`,
226
+ '',
227
+ `Or escalate: curl -s -X POST http://127.0.0.1:${process.env.WOGI_MANAGER_PORT || '8800'} \\`,
228
+ ` -H "X-Wogi-From: ${process.env.WOGI_REPO_NAME}" \\`,
229
+ ` --data-binary "## QUESTION: <your blocker>"`
230
+ ].join('\n');
231
+ return { __raw: true, continue: true, stopReason: msg };
232
+ }
233
+ }
234
+ }
235
+ } catch (err) {
236
+ // Fail-OPEN for this specific gate — we do not want a bug here to block
237
+ // legitimate stops. The routing gate above is fail-closed; this one isn't
238
+ // because unlike routing it's not a last-line-of-defense — the auto-pickup
239
+ // additionalContext already nudged the AI before this point.
240
+ if (process.env.DEBUG) {
241
+ console.error(`[Stop] Workspace autopickup gate error (fail-open): ${err.message}`);
242
+ }
243
+ }
244
+
186
245
  // Check if loop can exit
187
246
  return await checkLoopExit();
188
247
  }, { failMode: 'warn', failOutput: { continue: false } });