wogiflow 2.22.1 → 2.22.3

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/wogi-claude CHANGED
@@ -17,13 +17,52 @@
17
17
  # Opt-out: pass --no-wogi-restart to run claude once without the restart loop
18
18
  #
19
19
  # Environment:
20
- # WOGI_RESTART_FLAG — path to restart-flag file (default: <cwd>/.workflow/state/restart-requested)
21
- # WOGI_MAX_RESTARTS — safety cap, default 50 (prevents runaway restart storms)
22
- # WOGI_WRAPPER_PID — exported to child; hook checks this to confirm wrapper is present
23
- # WOGI_CLAUDE_BIN — override path to claude binary (default: found via PATH)
20
+ # WOGI_RESTART_FLAG — path to restart-flag file (default: <cwd>/.workflow/state/restart-requested)
21
+ # WOGI_MAX_RESTARTS — safety cap, default 50 (prevents runaway restart storms)
22
+ # WOGI_WRAPPER_PID — exported to child; hook checks this to confirm wrapper is present
23
+ # WOGI_CLAUDE_BIN — override path to claude binary (default: found via PATH)
24
+ # WOGI_NO_EXPECT — set to 1 to disable expect-based auto-dismiss of the
25
+ # "Loading development channels" dialog (v2.22.3+).
26
+ # With expect enabled (default when expect is on PATH
27
+ # and --dangerously-load-development-channels is in args),
28
+ # the dialog is auto-accepted so workspace worker
29
+ # restarts require zero manual interaction.
30
+ # WOGI_EXPECT_TIMEOUT — override the expect timeout (default 30s) for watching
31
+ # the dialog. After timeout we hand off to the user
32
+ # unconditionally.
24
33
 
25
34
  set -u
26
35
 
36
+ # --- Resolve helper paths (for expect-based dialog auto-dismiss) ---
37
+ WOGI_CLAUDE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
38
+ WOGI_EXPECT_SCRIPT="$WOGI_CLAUDE_DIR/wogi-claude-expect.exp"
39
+
40
+ # Detect whether to use the expect wrapper. Two conditions must hold:
41
+ # 1. `expect` is on PATH and the wogi-claude-expect.exp script exists
42
+ # 2. The args include --dangerously-load-development-channels (which is the
43
+ # only flag that triggers the "Loading development channels" dialog we
44
+ # want to auto-dismiss)
45
+ # AND WOGI_NO_EXPECT is NOT set (escape hatch).
46
+ __wogi_use_expect=0
47
+ if [ -z "${WOGI_NO_EXPECT:-}" ] && command -v expect >/dev/null 2>&1 && [ -x "$WOGI_EXPECT_SCRIPT" ]; then
48
+ for arg in "$@"; do
49
+ if [ "$arg" = "--dangerously-load-development-channels" ]; then
50
+ __wogi_use_expect=1
51
+ break
52
+ fi
53
+ done
54
+ fi
55
+
56
+ # run_claude — invoke claude, routing through expect when we can auto-dismiss
57
+ # the dev-channels dialog. Preserves stdin/stdout/stderr exactly.
58
+ run_claude() {
59
+ if [ "$__wogi_use_expect" -eq 1 ]; then
60
+ expect "$WOGI_EXPECT_SCRIPT" "$CLAUDE_BIN" "$@"
61
+ else
62
+ "$CLAUDE_BIN" "$@"
63
+ fi
64
+ }
65
+
27
66
  # --- Opt-out path: no restart loop, just exec claude once ---
28
67
  for arg in "$@"; do
29
68
  if [ "$arg" = "--no-wogi-restart" ]; then
@@ -31,7 +70,11 @@ for arg in "$@"; do
31
70
  filtered=()
32
71
  for a in "$@"; do [ "$a" = "--no-wogi-restart" ] || filtered+=("$a"); done
33
72
  CLAUDE_BIN="${WOGI_CLAUDE_BIN:-claude}"
34
- exec "$CLAUDE_BIN" "${filtered[@]}"
73
+ if [ "$__wogi_use_expect" -eq 1 ]; then
74
+ exec expect "$WOGI_EXPECT_SCRIPT" "$CLAUDE_BIN" "${filtered[@]}"
75
+ else
76
+ exec "$CLAUDE_BIN" "${filtered[@]}"
77
+ fi
35
78
  fi
36
79
  done
37
80
 
@@ -66,7 +109,7 @@ while true; do
66
109
  rm -f "$FLAG_FILE"
67
110
  fi
68
111
 
69
- "$CLAUDE_BIN" "$@"
112
+ run_claude "$@"
70
113
  inner_exit=$?
71
114
 
72
115
  # If the user hit Ctrl+C (INT/TERM reached us), don't restart
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env expect
2
+ #
3
+ # wogi-claude-expect.exp — Auto-dismiss Claude Code's "Loading development
4
+ # channels" dialog so workspace worker restarts are truly seamless.
5
+ #
6
+ # Claude Code fires an interactive Ink dialog EVERY session when:
7
+ # - User is OAuth-authenticated to claude.ai
8
+ # - The CLI is launched with --dangerously-load-development-channels
9
+ #
10
+ # There's no Claude Code setting that persists an "accepted" state
11
+ # (verified via decompiled source 2026-04-17). So we intercept the dialog
12
+ # at the wrapper level: spawn claude in an expect-managed PTY, watch for
13
+ # the dialog title text, send Enter to accept the already-highlighted
14
+ # "I am using this for local development" option, then hand off control
15
+ # to the user via `interact`.
16
+ #
17
+ # Usage (invoked from lib/wogi-claude):
18
+ # expect wogi-claude-expect.exp /absolute/path/to/claude [args...]
19
+ #
20
+ # Disable at runtime: set WOGI_NO_EXPECT=1. The wrapper then execs claude
21
+ # directly and the user sees the dialog as before (manual single-click).
22
+
23
+ set timeout 30
24
+
25
+ # Allow WOGI_EXPECT_TIMEOUT override (rarely needed)
26
+ if {[info exists env(WOGI_EXPECT_TIMEOUT)]} {
27
+ set timeout $env(WOGI_EXPECT_TIMEOUT)
28
+ }
29
+
30
+ # Mirror claude's output to the user's terminal during dialog watch
31
+ log_user 1
32
+
33
+ # argv[0] is the claude binary path; rest are claude's args
34
+ if {[llength $argv] < 1} {
35
+ puts stderr "wogi-claude-expect.exp: missing claude binary path"
36
+ exit 2
37
+ }
38
+
39
+ set claude_bin [lindex $argv 0]
40
+ set claude_args [lrange $argv 1 end]
41
+
42
+ # Spawn claude in a pseudo-TTY so its Ink UI renders normally.
43
+ # eval is needed because claude_args is a list and spawn expects a
44
+ # flattened command line.
45
+ eval spawn $claude_bin $claude_args
46
+
47
+ # Watch for the DevChannels dialog title, then press Enter to accept
48
+ # the default-highlighted "I am using this for local development" option.
49
+ #
50
+ # On timeout or EOF: fall through to `interact`. If the dialog appears
51
+ # AFTER our timeout window, the user can still answer it manually —
52
+ # same failure mode as running claude directly.
53
+ expect {
54
+ -re "Loading development channels" {
55
+ # Let Ink finish rendering the dialog before sending Enter.
56
+ # Without this, the select-input component may not have bound
57
+ # its keyboard listener yet and the keystroke is dropped.
58
+ after 250
59
+ send "\r"
60
+ }
61
+ timeout { }
62
+ eof { exit }
63
+ }
64
+
65
+ # Hand off: user's keystrokes flow to claude, claude's output flows
66
+ # to the user's terminal. interact blocks until claude exits.
67
+ interact
68
+
69
+ # After claude exits, let the bash wrapper decide whether to restart.
70
+ # Pass through claude's exit status (expect sets it in $expect_out(-code)
71
+ # after `interact`, but a plain exit is sufficient since the wrapper
72
+ # only cares about the restart flag file, not exit code).
73
+ exit
@@ -23,6 +23,7 @@ const MESSAGE_TYPES = [
23
23
  'bug-report', // "Your endpoint returns 500 when I send Y"
24
24
  'task-complete', // "I finished my side of feature Z"
25
25
  'worker-stopped', // Graceful Stop hook — worker session ending, not necessarily at task completion
26
+ 'worker-ready', // Fresh worker session with empty queue — "got anything for me?" (wf-restart-handoff)
26
27
  'needs-help', // "I'm stuck, can you check X on your side?"
27
28
  'heads-up', // "I'm about to change Y, just FYI"
28
29
  'impact-query', // Pre-dev: "I'm about to change X, will this break you?"
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Workspace — Worker Readiness Announce (restart-handoff protocol)
5
+ *
6
+ * Problem this solves:
7
+ * When a worker session restarts (wogi-claude wrapper relaunches claude
8
+ * after a task-boundary), channel dispatches sent by the manager during
9
+ * the restart window can be lost — they arrive either while the old
10
+ * claude is shutting down or before the new claude has wired up its
11
+ * MCP channel, and the notification never reaches a live session.
12
+ *
13
+ * Previous sessions observed: manager dispatches session N, worker
14
+ * completes N and restarts, manager tries to dispatch N+1 during the
15
+ * restart gap, N+1 is lost, worker comes up fresh with empty queue
16
+ * and sits idle until the user notices and the manager re-dispatches.
17
+ *
18
+ * Design:
19
+ * File-based announce via the workspace-messages bus. When a worker
20
+ * SessionStart fires and the worker has zero in-progress tasks and
21
+ * zero queued channel dispatches, write a structured `worker-ready`
22
+ * message to `.workspace/messages/`. The manager's next turn sweeps
23
+ * the bus, cross-references the dispatched-tasks.json (wf-d3e67abe)
24
+ * to see if anything is owed to this worker, and surfaces lost
25
+ * dispatches for re-dispatch.
26
+ *
27
+ * File-based delivery is durable: no timing games, no buffer TTL,
28
+ * no dependency on the MCP channel server being up during the
29
+ * restart gap. Worker writes → manager reads → reconciles.
30
+ *
31
+ * Dedup:
32
+ * If a pending `worker-ready` message already exists for this repo,
33
+ * we skip — no need to stack announcements while the manager hasn't
34
+ * picked up the first one yet.
35
+ */
36
+
37
+ const fs = require('node:fs');
38
+ const path = require('node:path');
39
+ const { safeReadJson } = require('./utils');
40
+
41
+ /**
42
+ * Detect if the current process is a workspace worker (not a manager and not a
43
+ * single-repo session). Mirrors the isWorkspaceWorker detection used in
44
+ * scripts/hooks/core/task-completed.js.
45
+ *
46
+ * @returns {boolean}
47
+ */
48
+ function isWorker() {
49
+ if (!process.env.WOGI_WORKSPACE_ROOT) return false;
50
+ const repo = process.env.WOGI_REPO_NAME;
51
+ if (!repo || repo === 'manager') return false;
52
+ return true;
53
+ }
54
+
55
+ /**
56
+ * Resolve the workspace root from env (worker mode).
57
+ *
58
+ * @returns {string|null}
59
+ */
60
+ function getWorkspaceRoot() {
61
+ const root = process.env.WOGI_WORKSPACE_ROOT;
62
+ if (!root || !path.isAbsolute(root)) return null;
63
+ return root;
64
+ }
65
+
66
+ /**
67
+ * Check whether a pending worker-ready message already exists for this repo.
68
+ * Used to dedup — we don't need to stack announcements.
69
+ *
70
+ * @param {string} workspaceRoot
71
+ * @param {string} repoName
72
+ * @returns {boolean}
73
+ */
74
+ function hasPendingAnnounce(workspaceRoot, repoName) {
75
+ try {
76
+ const messagesDir = path.join(workspaceRoot, '.workspace', 'messages');
77
+ if (!fs.existsSync(messagesDir)) return false;
78
+ const files = fs.readdirSync(messagesDir).filter(f => f.endsWith('.json'));
79
+ for (const file of files) {
80
+ try {
81
+ const msg = safeReadJson(path.join(messagesDir, file));
82
+ if (!msg) continue;
83
+ if (msg.type === 'worker-ready' &&
84
+ msg.from === repoName &&
85
+ msg.status === 'pending') {
86
+ return true;
87
+ }
88
+ } catch (_err) { /* skip malformed */ }
89
+ }
90
+ return false;
91
+ } catch (_err) {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Decide whether this worker should announce ready.
98
+ * Preconditions:
99
+ * - Worker mode (WOGI_WORKSPACE_ROOT + WOGI_REPO_NAME !== 'manager')
100
+ * - ready.json has zero in-progress tasks
101
+ * - ready.json has zero queued channel-dispatched tasks
102
+ * (if it has queued work, the SessionStart hook auto-invokes
103
+ * /wogi-start instead of announcing — different branch)
104
+ * - No pending worker-ready message already exists for this repo
105
+ *
106
+ * @param {Object} [opts] - override knobs for testing
107
+ * @param {string} [opts.workspaceRoot]
108
+ * @param {string} [opts.repoName]
109
+ * @param {Object} [opts.readyData]
110
+ * @returns {{announce: boolean, reason: string, repoName?: string, workspaceRoot?: string}}
111
+ */
112
+ function shouldAnnounceReady(opts = {}) {
113
+ const workspaceRoot = opts.workspaceRoot || getWorkspaceRoot();
114
+ const repoName = opts.repoName || process.env.WOGI_REPO_NAME;
115
+
116
+ if (!workspaceRoot) return { announce: false, reason: 'no-workspace-root' };
117
+ if (!opts.repoName && !isWorker()) return { announce: false, reason: 'not-worker' };
118
+ if (!repoName || repoName === 'manager') return { announce: false, reason: 'not-worker' };
119
+
120
+ let readyData = opts.readyData;
121
+ if (!readyData) {
122
+ try {
123
+ const { PATHS } = require('../scripts/flow-utils');
124
+ const readyPath = path.join(PATHS.state, 'ready.json');
125
+ readyData = safeReadJson(readyPath, { ready: [], inProgress: [] });
126
+ } catch (_err) {
127
+ readyData = { ready: [], inProgress: [] };
128
+ }
129
+ }
130
+
131
+ const inProgress = Array.isArray(readyData.inProgress) ? readyData.inProgress : [];
132
+ if (inProgress.length > 0) {
133
+ return { announce: false, reason: 'in-progress-not-empty' };
134
+ }
135
+
136
+ const queuedChannel = (Array.isArray(readyData.ready) ? readyData.ready : [])
137
+ .filter(t => t && (
138
+ t.channelSource === 'wogi-workspace-channel' ||
139
+ t.dispatchedBy === 'workspace-manager' ||
140
+ (typeof t.source === 'string' && t.source.startsWith('workspace:'))
141
+ ));
142
+ if (queuedChannel.length > 0) {
143
+ return { announce: false, reason: 'queued-channel-work-present' };
144
+ }
145
+
146
+ if (hasPendingAnnounce(workspaceRoot, repoName)) {
147
+ return { announce: false, reason: 'already-announced' };
148
+ }
149
+
150
+ return { announce: true, reason: 'ok', workspaceRoot, repoName };
151
+ }
152
+
153
+ /**
154
+ * Write a worker-ready message to the workspace-messages bus.
155
+ *
156
+ * @param {string} workspaceRoot
157
+ * @param {string} repoName
158
+ * @returns {{written: boolean, messageId?: string, path?: string, reason?: string}}
159
+ */
160
+ function announceWorkerReady(workspaceRoot, repoName) {
161
+ try {
162
+ const { createMessage, saveMessage } = require('./workspace-messages');
163
+ const msg = createMessage({
164
+ from: repoName,
165
+ to: 'manager',
166
+ type: 'worker-ready',
167
+ subject: `Worker ${repoName} ready — queue empty, awaiting dispatch`,
168
+ body: [
169
+ `Worker "${repoName}" has started a fresh session with an empty task queue.`,
170
+ `If you dispatched any tasks to this worker that were lost during the`,
171
+ `restart window, they can be re-dispatched now. No pending work detected`,
172
+ `in ready.json (zero inProgress, zero queued channel dispatches).`
173
+ ].join('\n'),
174
+ priority: 'medium',
175
+ actionRequired: false
176
+ });
177
+ const filePath = saveMessage(workspaceRoot, msg);
178
+ return { written: true, messageId: msg.id, path: filePath };
179
+ } catch (err) {
180
+ return { written: false, reason: `write-failed: ${err.message}` };
181
+ }
182
+ }
183
+
184
+ module.exports = {
185
+ isWorker,
186
+ getWorkspaceRoot,
187
+ hasPendingAnnounce,
188
+ shouldAnnounceReady,
189
+ announceWorkerReady
190
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wogiflow",
3
- "version": "2.22.1",
3
+ "version": "2.22.3",
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 tests/flow-workspace-autopickup.test.js tests/flow-worker-boundary-gate.test.js tests/flow-worker-question-classifier.test.js tests/flow-completion-truth-gate-contradictions.test.js tests/flow-structure-sensor.test.js tests/flow-workspace-dispatch-tracking.test.js tests/flow-story-gates.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 tests/flow-worker-boundary-gate.test.js tests/flow-worker-question-classifier.test.js tests/flow-completion-truth-gate-contradictions.test.js tests/flow-structure-sensor.test.js tests/flow-workspace-dispatch-tracking.test.js tests/flow-story-gates.test.js tests/flow-workspace-restart-handoff.test.js tests/flow-wogi-claude-wrapper.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",
@@ -108,10 +108,122 @@ function sweepAndReconcile(workspaceRoot) {
108
108
  return reconciled;
109
109
  }
110
110
 
111
+ /**
112
+ * Reconcile pending `worker-ready` messages (2.22.2 restart-handoff).
113
+ *
114
+ * A worker-ready message signals that a worker session started with an
115
+ * empty queue — possibly because a prior dispatch was lost during the
116
+ * wrapper's restart window. For each pending worker-ready:
117
+ * - Find pending dispatches to that repo in dispatched-tasks.json
118
+ * - If any found: they're likely the lost dispatches. Collect as
119
+ * `lostDispatches` for surface to the manager.
120
+ * - Mark the worker-ready message as acknowledged regardless — once
121
+ * the manager has seen it, there's nothing more to do with the
122
+ * same message. If another restart happens, a fresh worker-ready
123
+ * will be written.
124
+ *
125
+ * @param {string} workspaceRoot
126
+ * @param {Object} [opts]
127
+ * @param {number} [opts.staleGraceMs=30000] — ignore dispatches newer than this
128
+ * (to avoid flagging just-sent dispatches still in flight).
129
+ * @returns {{acknowledged: number, lostDispatches: Array}}
130
+ */
131
+ function reconcileWorkerReady(workspaceRoot, opts = {}) {
132
+ const staleGraceMs = Number.isFinite(opts.staleGraceMs) ? opts.staleGraceMs : 30000;
133
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
134
+
135
+ let readMessages, updateMessageStatus, readDispatches;
136
+ try {
137
+ const libMessages = path.resolve(__dirname, '..', '..', '..', 'lib', 'workspace-messages.js');
138
+ const libTracking = path.resolve(__dirname, '..', '..', '..', 'lib', 'workspace-dispatch-tracking.js');
139
+ const bus = require(libMessages);
140
+ readMessages = bus.readMessages;
141
+ updateMessageStatus = bus.updateMessageStatus;
142
+ const tracking = require(libTracking);
143
+ readDispatches = tracking.readDispatches;
144
+ } catch (_err) {
145
+ return { acknowledged: 0, lostDispatches: [] };
146
+ }
147
+
148
+ let pendingReady = [];
149
+ try {
150
+ pendingReady = readMessages(workspaceRoot, { type: 'worker-ready', status: 'pending' });
151
+ } catch (_err) {
152
+ return { acknowledged: 0, lostDispatches: [] };
153
+ }
154
+ if (pendingReady.length === 0) return { acknowledged: 0, lostDispatches: [] };
155
+
156
+ let dispatches = [];
157
+ try {
158
+ dispatches = readDispatches(workspaceRoot).filter(r => r && r.status === 'pending');
159
+ } catch (_err) {
160
+ dispatches = [];
161
+ }
162
+
163
+ const lostDispatches = [];
164
+ let acknowledged = 0;
165
+
166
+ for (const msg of pendingReady) {
167
+ const repoName = msg.from;
168
+ if (!repoName) continue;
169
+
170
+ // Find pending dispatches to this repo that are older than the grace
171
+ // period (avoid race conditions with just-sent dispatches).
172
+ const candidates = dispatches.filter(r => {
173
+ if (r.repoName !== repoName) return false;
174
+ const dispatched = Date.parse(r.dispatchedAt || '');
175
+ if (!Number.isFinite(dispatched)) return false;
176
+ return (now - dispatched) > staleGraceMs;
177
+ });
178
+
179
+ for (const c of candidates) {
180
+ lostDispatches.push({ ...c, workerReadyMsgId: msg.id });
181
+ }
182
+
183
+ // Acknowledge the worker-ready message — we've processed it once.
184
+ // If the restart-loss recurs, a fresh worker-ready will be written.
185
+ try {
186
+ if (updateMessageStatus) {
187
+ updateMessageStatus(workspaceRoot, msg.id, 'acknowledged');
188
+ acknowledged++;
189
+ }
190
+ } catch (_err) { /* non-fatal */ }
191
+ }
192
+
193
+ return { acknowledged, lostDispatches };
194
+ }
195
+
196
+ /**
197
+ * Format the lost-dispatches block for manager additionalContext.
198
+ *
199
+ * @param {Array} lost
200
+ * @returns {string|null}
201
+ */
202
+ function formatLostDispatchesContext(lost) {
203
+ if (!Array.isArray(lost) || lost.length === 0) return null;
204
+ const lines = lost.map(r => {
205
+ const dispatchedAt = r.dispatchedAt || '?';
206
+ return `• ${r.taskId} → ${r.repoName} | dispatched ${dispatchedAt} | still pending after worker restart`;
207
+ });
208
+ return [
209
+ `━━━ LOST DISPATCHES — WORKER RESTARTED WITH EMPTY QUEUE (${lost.length}) ━━━`,
210
+ ...lines,
211
+ '',
212
+ 'A worker announced fresh readiness but these dispatches are still',
213
+ 'pending. Likely lost during the wrapper\'s restart window. Re-dispatch',
214
+ 'them now via dispatchToChannel(workspaceRoot, repoName, taskId).',
215
+ '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'
216
+ ].join('\n');
217
+ }
218
+
111
219
  /**
112
220
  * Build the overdue-dispatches additionalContext block, or return null
113
221
  * when nothing to surface (non-manager, no workspace root, no overdue).
114
222
  *
223
+ * Also handles worker-ready reconciliation (2.22.2) — if workers announced
224
+ * readiness and there are matching pending dispatches, include a lost-dispatch
225
+ * section so the manager can re-dispatch.
226
+ *
115
227
  * @param {Object} [opts]
116
228
  * @param {string} [opts.workspaceRoot] — override (primarily for tests)
117
229
  * @param {number} [opts.now=Date.now()]
@@ -128,32 +240,52 @@ function buildOverdueContext(opts = {}) {
128
240
  try { sweepAndReconcile(workspaceRoot); }
129
241
  catch (_err) { /* fail-open */ }
130
242
 
243
+ // Reconcile worker-ready announcements. Surface any lost dispatches
244
+ // the manager should re-send.
245
+ let lostBlock = null;
246
+ try {
247
+ const { lostDispatches } = reconcileWorkerReady(workspaceRoot, { now: opts.now });
248
+ lostBlock = formatLostDispatchesContext(lostDispatches);
249
+ } catch (_err) { /* fail-open */ }
250
+
131
251
  let overdue;
132
252
  try {
133
253
  const libPath = path.resolve(__dirname, '..', '..', '..', 'lib', 'workspace-dispatch-tracking.js');
134
254
  const { getOverdueDispatches } = require(libPath);
135
255
  overdue = getOverdueDispatches(workspaceRoot, opts.now);
136
256
  } catch (_err) {
137
- return null; // Fail-open — tracking module missing or IO failure should never block the prompt.
257
+ // If dispatch-tracking is missing but we have lost-dispatches from
258
+ // worker-ready, still surface those.
259
+ return lostBlock;
138
260
  }
139
261
 
140
- if (!Array.isArray(overdue) || overdue.length === 0) return null;
262
+ if ((!Array.isArray(overdue) || overdue.length === 0) && !lostBlock) return null;
141
263
 
142
- const now = Number.isFinite(opts.now) ? opts.now : Date.now();
143
- const lines = overdue.map(r => formatLine(r, now));
144
- return [
145
- `━━━ OVERDUE WORKSPACE DISPATCHES (${overdue.length}) ━━━`,
146
- ...lines,
147
- '',
148
- 'These workers may have died silently. Check worker terminals;',
149
- 'if dead, re-dispatch or mark failed. Records:',
150
- ' .workspace/state/dispatched-tasks.json',
151
- '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'
152
- ].join('\n');
264
+ const sections = [];
265
+
266
+ if (lostBlock) sections.push(lostBlock);
267
+
268
+ if (Array.isArray(overdue) && overdue.length > 0) {
269
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
270
+ const lines = overdue.map(r => formatLine(r, now));
271
+ sections.push([
272
+ `━━━ OVERDUE WORKSPACE DISPATCHES (${overdue.length}) ━━━`,
273
+ ...lines,
274
+ '',
275
+ 'These workers may have died silently. Check worker terminals;',
276
+ 'if dead, re-dispatch or mark failed. Records:',
277
+ ' .workspace/state/dispatched-tasks.json',
278
+ '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'
279
+ ].join('\n'));
280
+ }
281
+
282
+ return sections.length > 0 ? sections.join('\n\n') : null;
153
283
  }
154
284
 
155
285
  module.exports = {
156
286
  isManagerSession,
157
287
  buildOverdueContext,
158
- sweepAndReconcile
288
+ sweepAndReconcile,
289
+ reconcileWorkerReady,
290
+ formatLostDispatchesContext
159
291
  };
@@ -871,6 +871,23 @@ function formatContextForInjection(context) {
871
871
  // Non-critical — history file may not exist; continue with normal context
872
872
  }
873
873
 
874
+ // Workspace worker auto-resume (wf-restart-handoff / 2.22.2).
875
+ // CRITICAL priority — shown at the top so the model acts on it before
876
+ // anything else. Fires when a worker session starts with queued channel
877
+ // dispatches that were inherited from the prior (restarted) session.
878
+ if (ctx.workerAutoResume) {
879
+ output += `### Workspace Worker Auto-Resume\n`;
880
+ output += ctx.workerAutoResume + '\n\n';
881
+ }
882
+
883
+ // Workspace worker readiness announcement (wf-restart-handoff / 2.22.2).
884
+ // Informational — worker started idle, announced readiness to manager.
885
+ // Manager will reconcile async; no immediate action required from the worker.
886
+ if (ctx.workerReadyAnnounce) {
887
+ output += `### Workspace Worker Ready\n`;
888
+ output += ctx.workerReadyAnnounce + '\n\n';
889
+ }
890
+
874
891
  // CRITICAL: CLAUDE_CODE_SIMPLE mode warning (highest priority)
875
892
  if (ctx.simpleModeWarning && ctx.simpleModeWarning.active) {
876
893
  output += `### CLAUDE_CODE_SIMPLE Mode Detected\n`;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Wogi Flow — Worker SessionStart handler (wf-restart-handoff)
3
+ *
4
+ * Handles the "worker just started" branch of SessionStart:
5
+ *
6
+ * - If worker has queued channel dispatches in ready.json:
7
+ * inject additionalContext telling the model to invoke
8
+ * /wogi-start <nextId> now. Mirrors the existing Stop-hook
9
+ * autopickup flow (task-completed.js::buildAutoPickupContext)
10
+ * but fires at session boundary instead of turn boundary —
11
+ * necessary because restart (wf-d3e67abe/2.22.1) kills the
12
+ * previous claude and the Stop-hook autopickup no longer bridges
13
+ * between tasks.
14
+ *
15
+ * - Else if worker has zero in-progress + zero queued channel
16
+ * dispatches: write a `worker-ready` message to the workspace
17
+ * message bus so the manager can reconcile against its durable
18
+ * dispatched-tasks.json and re-dispatch any work lost during
19
+ * the restart window.
20
+ *
21
+ * Returns a context fragment (or null) that the SessionStart entry
22
+ * merges into the overall hook output.
23
+ *
24
+ * Fail-open: any error returns null and logs in DEBUG mode. Never
25
+ * blocks session startup.
26
+ */
27
+
28
+ const path = require('node:path');
29
+
30
+ const WORKER_READY_LIB = path.join(__dirname, '..', '..', '..', 'lib', 'workspace-worker-ready.js');
31
+ const TASK_COMPLETED_CORE = path.join(__dirname, 'task-completed.js');
32
+
33
+ /**
34
+ * Handle worker SessionStart.
35
+ *
36
+ * @returns {{branch: 'auto-resume'|'announce-ready'|'skip', context?: string, announced?: Object, pickup?: Object}}
37
+ */
38
+ function handleWorkerSessionStart() {
39
+ try {
40
+ const { isWorker, shouldAnnounceReady, announceWorkerReady } = require(WORKER_READY_LIB);
41
+ if (!isWorker()) return { branch: 'skip', reason: 'not-worker' };
42
+
43
+ // Check for queued work first — if any, tell the model to pick it up
44
+ // instead of announcing idle readiness.
45
+ let pickup;
46
+ try {
47
+ const { findQueuedChannelDispatches, buildAutoPickupContext } = require(TASK_COMPLETED_CORE);
48
+ pickup = findQueuedChannelDispatches();
49
+ if (pickup && pickup.count > 0 && pickup.nextTaskId) {
50
+ const base = buildAutoPickupContext(pickup);
51
+ // Adjust the leading line for session-start context — the canonical
52
+ // pickup message starts with "You just completed a task." which isn't
53
+ // true on session start. Re-frame it here.
54
+ const context = [
55
+ `⚡ WORKSPACE SESSION START — ${pickup.count} CHANNEL DISPATCH${pickup.count === 1 ? '' : 'ES'} QUEUED`,
56
+ '',
57
+ `This fresh worker session has ${pickup.count} channel-dispatched task${pickup.count === 1 ? '' : 's'} queued in ready.json.`,
58
+ `The previous session restarted cleanly (wogi-claude wrapper). Pick up the next task now.`,
59
+ '',
60
+ `Next: ${pickup.nextTaskId} — ${pickup.nextTaskTitle || '(no title)'}`,
61
+ '',
62
+ 'AUTONOMOUS MODE CONTRACT (workspace worker):',
63
+ ' • These dispatches are pre-approved by the manager.',
64
+ ' • You MUST start the next one IMMEDIATELY in this same turn.',
65
+ ' • Do NOT hedge ("awaiting signal", "let me know"). Forbidden.',
66
+ '',
67
+ `ACT NOW: Invoke Skill(skill="wogi-start", args="${pickup.nextTaskId}")`
68
+ ].join('\n');
69
+ // base included for logging/telemetry parity if we ever want to diff them
70
+ void base;
71
+ return { branch: 'auto-resume', context, pickup };
72
+ }
73
+ } catch (err) {
74
+ if (process.env.DEBUG) {
75
+ console.error(`[session-start-worker] pickup-check failed (fail-open): ${err.message}`);
76
+ }
77
+ }
78
+
79
+ // No queued work — announce readiness so the manager can reconcile.
80
+ const decision = shouldAnnounceReady();
81
+ if (!decision.announce) {
82
+ return { branch: 'skip', reason: decision.reason };
83
+ }
84
+
85
+ const announced = announceWorkerReady(decision.workspaceRoot, decision.repoName);
86
+ if (!announced.written) {
87
+ if (process.env.DEBUG) {
88
+ console.error(`[session-start-worker] announce failed: ${announced.reason}`);
89
+ }
90
+ return { branch: 'skip', reason: announced.reason };
91
+ }
92
+
93
+ // Optional context surface — not strictly needed since the manager
94
+ // handles reconciliation asynchronously, but a one-line note helps
95
+ // humans reading worker transcripts understand why the worker is idle.
96
+ const context = [
97
+ `Worker session started with empty queue.`,
98
+ `Announced readiness to manager (msg ${announced.messageId}) —`,
99
+ `manager will reconcile against its dispatch log and re-dispatch`,
100
+ `any work lost during the restart window.`
101
+ ].join(' ');
102
+
103
+ return { branch: 'announce-ready', context, announced };
104
+ } catch (err) {
105
+ if (process.env.DEBUG) {
106
+ console.error(`[session-start-worker] unexpected error (fail-open): ${err.message}`);
107
+ }
108
+ return { branch: 'skip', reason: `error: ${err.message}` };
109
+ }
110
+ }
111
+
112
+ module.exports = {
113
+ handleWorkerSessionStart
114
+ };
@@ -303,5 +303,27 @@ runHook('SessionStart', async ({ parsedInput }) => {
303
303
  }
304
304
  }
305
305
 
306
+ // Workspace worker restart-handoff (wf-restart-handoff / 2.22.2).
307
+ // When the wogi-claude wrapper restarts a worker (via task-boundary-reset),
308
+ // queued dispatches from the PRIOR session are picked up by auto-resume;
309
+ // if the queue is truly empty, announce worker-ready so the manager can
310
+ // reconcile against its dispatch log and re-dispatch anything lost during
311
+ // the restart window. See scripts/hooks/core/session-start-worker.js.
312
+ try {
313
+ const { handleWorkerSessionStart } = require('../../core/session-start-worker');
314
+ const workerResult = handleWorkerSessionStart();
315
+ if (workerResult.context && coreResult && coreResult.context) {
316
+ if (workerResult.branch === 'auto-resume') {
317
+ coreResult.context.workerAutoResume = workerResult.context;
318
+ } else if (workerResult.branch === 'announce-ready') {
319
+ coreResult.context.workerReadyAnnounce = workerResult.context;
320
+ }
321
+ }
322
+ } catch (err) {
323
+ if (process.env.DEBUG) {
324
+ console.error(`[session-start] Worker session-start handler failed: ${err.message}`);
325
+ }
326
+ }
327
+
306
328
  return coreResult;
307
329
  }, { failMode: 'warn' });