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 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 using shared defaults with project-specific overrides
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 configContent = getDefaultConfig({
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
- fs.writeFileSync(configPath, JSON.stringify(configContent, null, 2));
532
-
533
- // Sync .gitignore entries for enabled features
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(configContent);
537
- } catch (err) {
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
- module.exports = { init, getDefaultConfig, deepMerge, detectProjectType, detectProjectScripts, detectTsConfigMode, getRegistriesForProjectType };
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.18.1",
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
- // REQUIRES verification against real Claude Code before enabling broadly
871
- // the SIGTERM-to-parent pattern must be confirmed to exit Claude Code
872
- // gracefully. Keep disabled until integration test in a throwaway session
873
- // confirms clean exit + flag consumption + fresh restart.
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: 'Opt-in per-task context reset via wogi-claude wrapper. See lib/wogi-claude.',
876
- enabled: false,
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
- 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 } });