wogiflow 2.18.1 → 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/bin/flow +11 -0
- package/lib/commands/config.js +148 -0
- package/lib/installer.js +62 -8
- package/lib/workspace.js +16 -0
- package/package.json +2 -2
- package/scripts/flow-config-defaults.js +81 -8
- 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/bin/flow
CHANGED
|
@@ -218,6 +218,17 @@ function main() {
|
|
|
218
218
|
return;
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
if (command === 'config') {
|
|
222
|
+
const { configCommand } = require('../lib/commands/config');
|
|
223
|
+
try {
|
|
224
|
+
configCommand(args.slice(1));
|
|
225
|
+
} catch (err) {
|
|
226
|
+
console.error(`Config error: ${err.message}`);
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
221
232
|
// For all other commands, try to find project context
|
|
222
233
|
const projectRoot = findProjectRoot();
|
|
223
234
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `flow config` — inspect and compact the project's .workflow/config.json.
|
|
5
|
+
*
|
|
6
|
+
* Subcommands:
|
|
7
|
+
* flow config show Show the current (on-disk) config as written
|
|
8
|
+
* flow config show --full Show the fully-merged config (defaults + overrides)
|
|
9
|
+
* flow config show --diff Show only the keys this project overrides
|
|
10
|
+
* flow config compact Rewrite config.json to lean form (overrides only)
|
|
11
|
+
* flow config compact --dry Preview the compacted form without writing
|
|
12
|
+
*
|
|
13
|
+
* Added in v2.19.0 alongside the lean-config-on-init change. See
|
|
14
|
+
* `lib/installer.js` `buildLeanInstallConfig()` for the write-time counterpart.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
|
|
20
|
+
function resolveProjectRoot() {
|
|
21
|
+
let dir = process.cwd();
|
|
22
|
+
for (let i = 0; i < 20; i++) {
|
|
23
|
+
if (fs.existsSync(path.join(dir, '.workflow', 'config.json'))) return dir;
|
|
24
|
+
const parent = path.dirname(dir);
|
|
25
|
+
if (parent === dir) break;
|
|
26
|
+
dir = parent;
|
|
27
|
+
}
|
|
28
|
+
throw new Error('Not in a WogiFlow project (no .workflow/config.json found)');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readProjectConfig(projectRoot) {
|
|
32
|
+
const configPath = path.join(projectRoot, '.workflow', 'config.json');
|
|
33
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
34
|
+
return { configPath, raw, parsed: JSON.parse(raw) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function configCommand(args) {
|
|
38
|
+
const sub = args[0];
|
|
39
|
+
const flags = args.slice(1);
|
|
40
|
+
|
|
41
|
+
if (!sub || sub === '--help' || sub === '-h') {
|
|
42
|
+
printHelp();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const projectRoot = resolveProjectRoot();
|
|
47
|
+
|
|
48
|
+
if (sub === 'show') {
|
|
49
|
+
return showConfig(projectRoot, flags);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (sub === 'compact') {
|
|
53
|
+
return compactConfig(projectRoot, flags);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.error(`Unknown config subcommand: ${sub}`);
|
|
57
|
+
printHelp();
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function showConfig(projectRoot, flags) {
|
|
62
|
+
const { configPath, parsed } = readProjectConfig(projectRoot);
|
|
63
|
+
const { mergeWithDefaults, computeLeanConfig } = require('../../scripts/flow-config-defaults');
|
|
64
|
+
|
|
65
|
+
if (flags.includes('--full')) {
|
|
66
|
+
const merged = mergeWithDefaults(parsed);
|
|
67
|
+
console.log(JSON.stringify(merged, null, 2));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (flags.includes('--diff')) {
|
|
72
|
+
const merged = mergeWithDefaults(parsed);
|
|
73
|
+
const lean = computeLeanConfig(merged);
|
|
74
|
+
console.log(JSON.stringify(lean, null, 2));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Default: show what's on disk.
|
|
79
|
+
const relPath = path.relative(projectRoot, configPath);
|
|
80
|
+
console.log(`# ${relPath} (on disk — ${Object.keys(parsed).length} top-level keys)`);
|
|
81
|
+
console.log(JSON.stringify(parsed, null, 2));
|
|
82
|
+
console.log('');
|
|
83
|
+
console.log('# Tips:');
|
|
84
|
+
console.log('# flow config show --full see the fully-merged config (defaults + overrides)');
|
|
85
|
+
console.log('# flow config show --diff see only the keys this project overrides');
|
|
86
|
+
console.log('# flow config compact shrink this file to overrides-only');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function compactConfig(projectRoot, flags) {
|
|
90
|
+
const { configPath, parsed } = readProjectConfig(projectRoot);
|
|
91
|
+
const { mergeWithDefaults, computeLeanConfig } = require('../../scripts/flow-config-defaults');
|
|
92
|
+
|
|
93
|
+
// mergeWithDefaults → computeLeanConfig round-trip produces the canonical
|
|
94
|
+
// lean form. Doing it via merge-first ensures any keys that used to have
|
|
95
|
+
// non-default values but now match a new default get correctly removed.
|
|
96
|
+
const merged = mergeWithDefaults(parsed);
|
|
97
|
+
const lean = computeLeanConfig(merged);
|
|
98
|
+
|
|
99
|
+
const beforeBytes = JSON.stringify(parsed, null, 2).length;
|
|
100
|
+
const afterBytes = JSON.stringify(lean, null, 2).length;
|
|
101
|
+
const savedPct = beforeBytes > 0 ? Math.round(((beforeBytes - afterBytes) / beforeBytes) * 100) : 0;
|
|
102
|
+
|
|
103
|
+
if (flags.includes('--dry') || flags.includes('--dry-run')) {
|
|
104
|
+
console.log('# Preview — would write:');
|
|
105
|
+
console.log(JSON.stringify(lean, null, 2));
|
|
106
|
+
console.log('');
|
|
107
|
+
console.log(`# Before: ${beforeBytes} bytes, ${Object.keys(parsed).length} top-level keys`);
|
|
108
|
+
console.log(`# After: ${afterBytes} bytes, ${Object.keys(lean).length} top-level keys (-${savedPct}%)`);
|
|
109
|
+
console.log('# Run without --dry to apply.');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Backup first — compaction is reversible, but cheap insurance is free.
|
|
114
|
+
const backupPath = `${configPath}.bak-${Date.now()}`;
|
|
115
|
+
fs.copyFileSync(configPath, backupPath);
|
|
116
|
+
|
|
117
|
+
fs.writeFileSync(configPath, JSON.stringify(lean, null, 2) + '\n');
|
|
118
|
+
|
|
119
|
+
console.log(`✓ Compacted ${path.relative(projectRoot, configPath)}`);
|
|
120
|
+
console.log(` Before: ${beforeBytes} bytes, ${Object.keys(parsed).length} top-level keys`);
|
|
121
|
+
console.log(` After: ${afterBytes} bytes, ${Object.keys(lean).length} top-level keys (-${savedPct}%)`);
|
|
122
|
+
console.log(` Backup: ${path.relative(projectRoot, backupPath)}`);
|
|
123
|
+
console.log('');
|
|
124
|
+
console.log('Runtime behavior is unchanged — defaults merge in at read time.');
|
|
125
|
+
console.log('Verify with: flow config show --full');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function printHelp() {
|
|
129
|
+
console.log(`Usage: flow config <subcommand> [flags]
|
|
130
|
+
|
|
131
|
+
Inspect and compact the project's .workflow/config.json.
|
|
132
|
+
|
|
133
|
+
Subcommands:
|
|
134
|
+
show Show the current config as written on disk
|
|
135
|
+
show --full Show the fully-merged config (defaults + overrides)
|
|
136
|
+
show --diff Show only the keys this project overrides
|
|
137
|
+
compact Rewrite config.json to overrides-only form
|
|
138
|
+
compact --dry Preview the compacted form without writing
|
|
139
|
+
|
|
140
|
+
Why "lean" configs (v2.19.0+):
|
|
141
|
+
The runtime config loader merges CONFIG_DEFAULTS on every read, so any key
|
|
142
|
+
matching the default is redundant noise. New installs write lean configs by
|
|
143
|
+
default. Existing fat configs still work identically — run 'flow config
|
|
144
|
+
compact' to shrink them.
|
|
145
|
+
`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { configCommand, showConfig, compactConfig, resolveProjectRoot };
|
package/lib/installer.js
CHANGED
|
@@ -520,21 +520,37 @@ function createWorkflowStructure(projectRoot, config) {
|
|
|
520
520
|
}
|
|
521
521
|
}
|
|
522
522
|
|
|
523
|
-
// Create config.json
|
|
523
|
+
// Create config.json — LEAN by default (v2.19.0+).
|
|
524
|
+
// Only project-specific identity keys + any overrides the installer selected
|
|
525
|
+
// interactively are written. The runtime loader (flow-config-loader.js)
|
|
526
|
+
// merges CONFIG_DEFAULTS on every read via mergeWithDefaults(), so a lean
|
|
527
|
+
// file behaves identically to a fully-specified one.
|
|
528
|
+
//
|
|
529
|
+
// This replaces the prior behavior of dumping the full ~300-line default
|
|
530
|
+
// tree on every `flow init`, which was confusing (users thought they had
|
|
531
|
+
// to maintain all those keys) and brittle (every new default shipped in a
|
|
532
|
+
// release became stale in every existing project's config file).
|
|
524
533
|
const configPath = path.join(workflowDir, 'config.json');
|
|
525
|
-
const
|
|
534
|
+
const fullConfig = getDefaultConfig({
|
|
526
535
|
version: config.version,
|
|
527
536
|
projectName: config.projectName,
|
|
528
537
|
projectType: config.projectType || 'unknown',
|
|
529
538
|
strictMode: config.strictMode
|
|
530
539
|
});
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
//
|
|
540
|
+
const leanConfig = buildLeanInstallConfig(fullConfig, config);
|
|
541
|
+
fs.writeFileSync(configPath, JSON.stringify(leanConfig, null, 2) + '\n');
|
|
542
|
+
// To inspect the fully-merged config (defaults + your overrides), run:
|
|
543
|
+
// flow config show --full
|
|
544
|
+
// To compact an existing fat config back to lean form, run:
|
|
545
|
+
// flow config compact
|
|
546
|
+
|
|
547
|
+
// Sync .gitignore entries for enabled features. Pass the fully-merged
|
|
548
|
+
// config (not the lean on-disk form) so gitignore can see all enabled
|
|
549
|
+
// feature flags, including those inherited from defaults.
|
|
534
550
|
try {
|
|
535
551
|
const { syncGitignore } = require('../scripts/flow-gitignore');
|
|
536
|
-
syncGitignore(
|
|
537
|
-
} catch (
|
|
552
|
+
syncGitignore(fullConfig);
|
|
553
|
+
} catch (_err) {
|
|
538
554
|
// Non-blocking — gitignore sync should never fail installation
|
|
539
555
|
}
|
|
540
556
|
|
|
@@ -1118,4 +1134,42 @@ function walkDirForManifest(dir, baseDir, fileSet) {
|
|
|
1118
1134
|
}
|
|
1119
1135
|
}
|
|
1120
1136
|
|
|
1121
|
-
|
|
1137
|
+
/**
|
|
1138
|
+
* Build the lean config written by `flow init` (v2.19.0+).
|
|
1139
|
+
*
|
|
1140
|
+
* The runtime loader merges CONFIG_DEFAULTS on every read, so we only need to
|
|
1141
|
+
* persist values that (a) identify this project or (b) differ from the default.
|
|
1142
|
+
* We prefer the `computeLeanConfig()` diff over a hand-picked allowlist so any
|
|
1143
|
+
* interactive-install overrides (e.g. strictMode toggled off) are preserved.
|
|
1144
|
+
*
|
|
1145
|
+
* @param {Object} fullConfig - The fully-merged config object from getDefaultConfig()
|
|
1146
|
+
* @param {Object} config - The install-time config selections (projectName, version, etc.)
|
|
1147
|
+
* @returns {Object} Lean config suitable for writing to .workflow/config.json
|
|
1148
|
+
*/
|
|
1149
|
+
function buildLeanInstallConfig(fullConfig, config) {
|
|
1150
|
+
const { computeLeanConfig } = require('../scripts/flow-config-defaults');
|
|
1151
|
+
const lean = computeLeanConfig(fullConfig);
|
|
1152
|
+
|
|
1153
|
+
// Ensure identity keys are always present with the expected shape.
|
|
1154
|
+
const out = {
|
|
1155
|
+
$schema: './config.schema.json',
|
|
1156
|
+
version: config.version,
|
|
1157
|
+
projectName: config.projectName || '',
|
|
1158
|
+
cli: { type: 'claude-code' },
|
|
1159
|
+
...lean
|
|
1160
|
+
};
|
|
1161
|
+
|
|
1162
|
+
// Strip identity keys from the lean block so they aren't duplicated.
|
|
1163
|
+
// (The spread above means lean's values would win; we want OURS to win.)
|
|
1164
|
+
return {
|
|
1165
|
+
$schema: out.$schema,
|
|
1166
|
+
version: out.version,
|
|
1167
|
+
projectName: out.projectName,
|
|
1168
|
+
cli: out.cli,
|
|
1169
|
+
...Object.fromEntries(
|
|
1170
|
+
Object.entries(lean).filter(([k]) => !['$schema', 'version', 'projectName', 'cli'].includes(k))
|
|
1171
|
+
)
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
module.exports = { init, getDefaultConfig, deepMerge, detectProjectType, detectProjectScripts, detectTsConfigMode, getRegistriesForProjectType, buildLeanInstallConfig };
|
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 && 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 },
|
|
@@ -867,14 +871,16 @@ const CONFIG_DEFAULTS = {
|
|
|
867
871
|
// detects the restart flag and relaunches claude. See lib/wogi-claude for
|
|
868
872
|
// the wrapper. See scripts/hooks/core/task-boundary-reset.js for the trigger.
|
|
869
873
|
//
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
//
|
|
874
|
+
// Per-task context reset via wogi-claude wrapper. Validated in v2.17.0 for
|
|
875
|
+
// workspace workers (manager sessions deliberately skip restart to avoid
|
|
876
|
+
// orchestration storms — see resolveClaudeSpawnCommand in lib/workspace.js).
|
|
877
|
+
// Enabled by default as of v2.19.0 after the "Sautéed worker" UX complaint:
|
|
878
|
+
// without it, workers sit idle after task completion instead of restarting
|
|
879
|
+
// fresh and ready for the next dispatch. Users who want the old behavior
|
|
880
|
+
// can set `enabled: false` explicitly.
|
|
874
881
|
taskBoundaryReset: {
|
|
875
|
-
_comment: '
|
|
876
|
-
enabled:
|
|
877
|
-
_comment_enabled: 'Set true ONLY after verifying SIGTERM behavior with real Claude Code in a throwaway session.',
|
|
882
|
+
_comment: 'Per-task context reset via wogi-claude wrapper. See lib/wogi-claude.',
|
|
883
|
+
enabled: true,
|
|
878
884
|
maxRestartsPerSession: 50,
|
|
879
885
|
_comment_maxRestartsPerSession: 'Safety cap. The wrapper also has WOGI_MAX_RESTARTS env override.'
|
|
880
886
|
},
|
|
@@ -1166,6 +1172,72 @@ function mergeWithDefaults(userConfig) {
|
|
|
1166
1172
|
return deepMerge(CONFIG_DEFAULTS, userConfig);
|
|
1167
1173
|
}
|
|
1168
1174
|
|
|
1175
|
+
/**
|
|
1176
|
+
* Compute the "lean" form of a config — the minimal object that, when passed
|
|
1177
|
+
* through mergeWithDefaults(), reproduces the input. Removes every key whose
|
|
1178
|
+
* value equals the default. Nested objects are diffed recursively; arrays and
|
|
1179
|
+
* primitives are compared by JSON equality.
|
|
1180
|
+
*
|
|
1181
|
+
* Always preserves these identity keys even if they match defaults:
|
|
1182
|
+
* - `$schema`, `version`, `projectName`, `cli`, `_configVersion`
|
|
1183
|
+
* These anchor the file's purpose and let tooling (VS Code JSON schema, config
|
|
1184
|
+
* migrations) work correctly on the lean file.
|
|
1185
|
+
*
|
|
1186
|
+
* Round-trip guarantee:
|
|
1187
|
+
* mergeWithDefaults(computeLeanConfig(full)) deep-equals mergeWithDefaults(full)
|
|
1188
|
+
* for any input — verified in tests.
|
|
1189
|
+
*
|
|
1190
|
+
* @param {Object} fullConfig - A fully-merged config (or any config object)
|
|
1191
|
+
* @returns {Object} Lean config containing only overrides + identity keys
|
|
1192
|
+
*/
|
|
1193
|
+
function computeLeanConfig(fullConfig) {
|
|
1194
|
+
if (!fullConfig || typeof fullConfig !== 'object') {
|
|
1195
|
+
return {};
|
|
1196
|
+
}
|
|
1197
|
+
const IDENTITY_KEYS = new Set(['$schema', 'version', 'projectName', 'cli', '_configVersion', 'projectType']);
|
|
1198
|
+
return diffAgainstDefaults(fullConfig, CONFIG_DEFAULTS, IDENTITY_KEYS, true);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function diffAgainstDefaults(user, defaults, identityKeys, isRoot) {
|
|
1202
|
+
const out = {};
|
|
1203
|
+
for (const key of Object.keys(user)) {
|
|
1204
|
+
const uVal = user[key];
|
|
1205
|
+
const dVal = defaults ? defaults[key] : undefined;
|
|
1206
|
+
|
|
1207
|
+
// Preserve identity keys at the root regardless of equality.
|
|
1208
|
+
if (isRoot && identityKeys.has(key)) {
|
|
1209
|
+
out[key] = uVal;
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Comment fields (prefix _comment) are metadata from the defaults file —
|
|
1214
|
+
// don't propagate into user configs, they bloat without adding meaning.
|
|
1215
|
+
if (typeof key === 'string' && key.startsWith('_comment')) {
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
if (dVal === undefined) {
|
|
1220
|
+
// Key doesn't exist in defaults — it's fully user-defined, keep it.
|
|
1221
|
+
out[key] = uVal;
|
|
1222
|
+
continue;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
if (isPlainObject(uVal) && isPlainObject(dVal)) {
|
|
1226
|
+
const nested = diffAgainstDefaults(uVal, dVal, identityKeys, false);
|
|
1227
|
+
if (Object.keys(nested).length > 0) {
|
|
1228
|
+
out[key] = nested;
|
|
1229
|
+
}
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// Primitive / array / null comparison via JSON stringify.
|
|
1234
|
+
if (JSON.stringify(uVal) !== JSON.stringify(dVal)) {
|
|
1235
|
+
out[key] = uVal;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
return out;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1169
1241
|
/**
|
|
1170
1242
|
* Apply project-type-aware defaults to a config.
|
|
1171
1243
|
* Strips/disables irrelevant sections based on detected project type.
|
|
@@ -1249,5 +1321,6 @@ module.exports = {
|
|
|
1249
1321
|
isPlainObject,
|
|
1250
1322
|
getDefaultsForKey,
|
|
1251
1323
|
mergeWithDefaults,
|
|
1324
|
+
computeLeanConfig,
|
|
1252
1325
|
applyProjectTypeDefaults
|
|
1253
1326
|
};
|
|
@@ -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 } });
|