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 +16 -0
- package/package.json +2 -2
- package/scripts/flow-config-defaults.js +5 -1
- package/scripts/hooks/adapters/claude-code.js +14 -5
- package/scripts/hooks/core/pre-tool-orchestrator.js +1 -1
- package/scripts/hooks/core/routing-gate.js +84 -1
- package/scripts/hooks/core/task-completed.js +137 -1
- package/scripts/hooks/entry/claude-code/stop.js +59 -0
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.
|
|
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
|
-
|
|
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 } });
|