wogiflow 2.26.0 → 2.26.2

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.
@@ -33,7 +33,7 @@ Reflection: "Have I introduced any bugs or regressions?"
33
33
 
34
34
  1. Reflection: "Does this match what the user asked for?"
35
35
  2. Close out all TodoWrite items for this task
36
- 3. Move task to recentlyCompleted in ready.json
36
+ 3. **Run `node node_modules/wogiflow/scripts/flow-done.js <taskId>`** — this is the ONLY supported way to complete a task. It runs quality gates, moves the task from `inProgress` → `recentlyCompleted`, writes the gate latch, and fires the task-boundary-restart Phase 1 marker. **Do NOT hand-edit `ready.json` to move the task** — that bypasses the CLI and silently disables: quality-gate verification, gate latch, and the task-boundary session restart. If `flow` is not on PATH in this environment, invoke it as `node node_modules/wogiflow/scripts/flow-done.js <taskId>` directly.
37
37
  4. Registry maps auto-updated by `registryUpdate` quality gate (runs `flow registry-manager scan` on all active registries — app-map, function-map, api-map, schema-map, service-map)
38
38
  5. If `config.webmcp.enabled` and UI files created: run `node node_modules/wogiflow/scripts/flow-webmcp-generator.js scan`
39
39
  6. Commit: `feat: Complete wf-XXXXXXXX - [title]`
package/lib/wogi-claude CHANGED
@@ -41,27 +41,68 @@ set -u
41
41
  WOGI_CLAUDE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
42
42
  WOGI_EXPECT_SCRIPT="$WOGI_CLAUDE_DIR/wogi-claude-expect.exp"
43
43
 
44
- # Detect whether to use the expect wrapper (v2.22.4: OPT-IN only).
45
- # Four conditions must all hold:
46
- # 1. WOGI_USE_EXPECT=1 is explicitly set (opt-in)
47
- # 2. WOGI_NO_EXPECT is NOT set (legacy escape hatch still honored)
48
- # 3. `expect` is on PATH and the wogi-claude-expect.exp script exists
49
- # 4. The args include --dangerously-load-development-channels (the only
50
- # flag that triggers the dialog we want to auto-dismiss)
44
+ # Detect whether to use the expect wrapper for auto-dismissing the
45
+ # --dangerously-load-development-channels modal.
51
46
  #
52
- # 2.22.3 tried opt-out by default; in practice, expect's text match can miss
53
- # Ink's ANSI-fragmented output, which deadlocks the dialog (user keystrokes
54
- # get held in expect's buffer instead of reaching claude). 2.22.4 flips to
55
- # opt-in so the default UX is predictable.
47
+ # Precedence (highest to lowest):
48
+ # 1. WOGI_NO_EXPECT=1 always OFF (kill switch)
49
+ # 2. Workspace worker mode ON automatically (headless, cannot Enter by hand)
50
+ # 3. WOGI_USE_EXPECT=1 → ON (explicit opt-in for interactive users)
51
+ # 4. Default → OFF (interactive users get the native Claude Code dialog)
52
+ #
53
+ # Worker auto-enable (v2.26.2): WOGI_WORKSPACE_ROOT + WOGI_REPO_NAME (worker
54
+ # side) are set by `flow workspace start` before spawning this wrapper, so
55
+ # detection here is reliable. Interactive users never set these vars, so their
56
+ # default remains opt-in — the v2.22.3 regression (expect's text match miss on
57
+ # Ink ANSI output) is bounded to users who explicitly asked for expect.
58
+ #
59
+ # The rewritten wogi-claude-expect.exp (v2.26.2) replaces the old brittle
60
+ # per-chunk text match with: rolling buffer + ANSI strip + bounded elapsed-time
61
+ # window. Misses fall back to the same failure mode as running claude without
62
+ # the wrapper (dialog stays up until someone presses Enter) — no unsafe blind
63
+ # keystrokes injected into server-mode Claude.
64
+
65
+ __wogi_is_worker=0
66
+ if [ -n "${WOGI_WORKSPACE_ROOT:-}" ] && [ -n "${WOGI_REPO_NAME:-}" ] && \
67
+ [ "${WOGI_REPO_NAME}" != "manager" ]; then
68
+ __wogi_is_worker=1
69
+ fi
70
+
71
+ __wogi_wants_expect=0
72
+ if [ -z "${WOGI_NO_EXPECT:-}" ]; then
73
+ if [ "$__wogi_is_worker" -eq 1 ] || [ "${WOGI_USE_EXPECT:-}" = "1" ]; then
74
+ __wogi_wants_expect=1
75
+ fi
76
+ fi
77
+
56
78
  __wogi_use_expect=0
57
- if [ "${WOGI_USE_EXPECT:-}" = "1" ] && [ -z "${WOGI_NO_EXPECT:-}" ] && \
58
- command -v expect >/dev/null 2>&1 && [ -x "$WOGI_EXPECT_SCRIPT" ]; then
79
+ if [ "$__wogi_wants_expect" -eq 1 ]; then
80
+ # The dialog only fires when --dangerously-load-development-channels is in
81
+ # argv; skip the expect dance otherwise.
82
+ __wogi_has_flag=0
59
83
  for arg in "$@"; do
60
84
  if [ "$arg" = "--dangerously-load-development-channels" ]; then
61
- __wogi_use_expect=1
85
+ __wogi_has_flag=1
62
86
  break
63
87
  fi
64
88
  done
89
+ if [ "$__wogi_has_flag" -eq 1 ]; then
90
+ if command -v expect >/dev/null 2>&1 && [ -x "$WOGI_EXPECT_SCRIPT" ]; then
91
+ __wogi_use_expect=1
92
+ if [ "$__wogi_is_worker" -eq 1 ]; then
93
+ echo "[wogi-claude] worker mode detected — auto-enabled expect-based dialog dismissal" >&2
94
+ fi
95
+ elif [ "$__wogi_is_worker" -eq 1 ]; then
96
+ # Headless worker + missing expect = the dialog WILL deadlock this
97
+ # worker on restart. Warn loudly so the operator can install expect,
98
+ # but still start claude (better than failing the worker outright).
99
+ echo "[wogi-claude] WARNING: worker mode detected (repo '${WOGI_REPO_NAME}') but 'expect' is not installed." >&2
100
+ echo "[wogi-claude] The --dangerously-load-development-channels dialog will block this worker on the next restart." >&2
101
+ echo "[wogi-claude] Install expect to enable headless auto-dismiss:" >&2
102
+ echo "[wogi-claude] macOS: brew install expect" >&2
103
+ echo "[wogi-claude] Debian/Ubuntu: apt install expect" >&2
104
+ fi
105
+ fi
65
106
  fi
66
107
 
67
108
  # run_claude — invoke claude, routing through expect when we can auto-dismiss
@@ -8,21 +8,38 @@
8
8
  # - The CLI is launched with --dangerously-load-development-channels
9
9
  #
10
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`.
11
+ # (verified via decompiled source 2026-04-17) and no --accept-dev-channels
12
+ # flag exists in `claude --help`.
13
+ #
14
+ # v2.22.x implementation (DEPRECATED): `-re "Loading development channels"`
15
+ # matched per-chunk output. Ink paints the dialog in fragmented writes
16
+ # interleaved with ANSI color codes, so the literal phrase rarely arrived
17
+ # in a single buffer and the regex missed. Workers deadlocked.
18
+ #
19
+ # v2.26.2 rewrite (this file) solves it with three properties:
20
+ # 1. Rolling buffer — accumulate every chunk into one growing string,
21
+ # match against the whole buffer on each iteration, not per-chunk.
22
+ # 2. ANSI strip — remove CSI / OSC escape sequences before matching,
23
+ # so the color-interleaved Ink output normalizes to plain text.
24
+ # 3. Bounded elapsed-time window — stop accumulating after
25
+ # WOGI_EXPECT_TIMEOUT seconds (default 30). Without this, a very
26
+ # chatty claude startup with no dialog would keep exp_continue'ing
27
+ # forever until the per-iteration timeout.
28
+ #
29
+ # NO BLIND FALLBACK. If the window elapses without matching, we hand off
30
+ # to interact unchanged. Sending a speculative \r to claude in server:
31
+ # mode mid-startup is not safe (server-mode input handling is not a
32
+ # standard REPL and could corrupt the handshake). Miss = same failure
33
+ # mode as running claude without this wrapper. Worker retries via
34
+ # wogi-claude's restart loop.
16
35
  #
17
36
  # Usage (invoked from lib/wogi-claude):
18
37
  # expect wogi-claude-expect.exp /absolute/path/to/claude [args...]
19
38
  #
20
39
  # 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).
40
+ # directly and the user sees the dialog as before.
22
41
 
23
42
  set timeout 30
24
-
25
- # Allow WOGI_EXPECT_TIMEOUT override (rarely needed)
26
43
  if {[info exists env(WOGI_EXPECT_TIMEOUT)]} {
27
44
  set timeout $env(WOGI_EXPECT_TIMEOUT)
28
45
  }
@@ -40,34 +57,84 @@ set claude_bin [lindex $argv 0]
40
57
  set claude_args [lrange $argv 1 end]
41
58
 
42
59
  # 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
60
+ # Use {*} list-splice rather than `eval spawn` `eval` reparses its
61
+ # arguments as Tcl script, which lets an argument containing bracket syntax
62
+ # (e.g. `[exec attacker-cmd]`) escape to command execution. The splice form
63
+ # expands the list without reparsing.
64
+ spawn $claude_bin {*}$claude_args
46
65
 
47
- # Watch for the DevChannels dialog title, then press Enter to accept
48
- # the default-highlighted "I am using this for local development" option.
66
+ # --- Dialog dismissal watch ---
49
67
  #
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.
68
+ # dialog_buf accumulates raw stdout chunks. After each chunk we strip ANSI
69
+ # escapes into `plain` and substring-search for the dialog title text. If
70
+ # found, we send Enter (accepts the default-highlighted "I am using this
71
+ # for local development" option). Otherwise we exp_continue until either
72
+ # the total elapsed time exceeds $timeout or EOF.
73
+ set dialog_buf ""
74
+ set start_ts [clock seconds]
75
+
53
76
  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"
77
+ -re "(.+)" {
78
+ append dialog_buf $expect_out(1,string)
79
+
80
+ # Strip ANSI CSI sequences (colors, cursor moves): ESC [ ... letter
81
+ regsub -all {\x1b\[[0-9;?]*[a-zA-Z]} $dialog_buf "" plain
82
+ # Strip 8-bit CSI form (0x9B byte instead of ESC [), same terminator
83
+ regsub -all {\x9b[0-9;?]*[a-zA-Z]} $plain "" plain
84
+ # Strip OSC sequences (titles, hyperlinks): ESC ] ... BEL
85
+ regsub -all {\x1b\][^\x07]*\x07} $plain "" plain
86
+ # Strip ISO 2022 charset-selection sequences: ESC ( B, ESC ) 0, etc.
87
+ regsub -all {\x1b[\(\)\*\+\-\.\/][\x20-\x7e]} $plain "" plain
88
+ # Strip bare ESC that didn't belong to a recognized sequence
89
+ regsub -all {\x1b} $plain "" plain
90
+
91
+ if {[string first "Loading development channels" $plain] >= 0} {
92
+ # Let Ink finish rendering the select-input component before
93
+ # sending Enter — without this the keystroke can land before
94
+ # the keyboard listener binds and gets dropped.
95
+ after 250
96
+ send "\r"
97
+ # Fall through to interact — dialog is dismissed, user drives
98
+ # from here.
99
+ } else {
100
+ # Bound total accumulation by elapsed wall-clock time so we
101
+ # don't exp_continue forever on a chatty startup with no
102
+ # dialog.
103
+ set elapsed [expr {[clock seconds] - $start_ts}]
104
+ if {$elapsed < $timeout} {
105
+ # Cap buffer at 64KB to prevent runaway memory on a very
106
+ # long startup that never shows the dialog. Keep the tail
107
+ # half so any late-arriving title text still matches.
108
+ if {[string length $dialog_buf] > 65536} {
109
+ set dialog_buf [string range $dialog_buf 32768 end]
110
+ }
111
+ exp_continue
112
+ }
113
+ # Elapsed >= timeout: fall through to interact without
114
+ # dismissing. Same failure mode as running claude without
115
+ # this wrapper.
116
+ }
60
117
  }
61
118
  timeout { }
62
119
  eof { exit }
63
120
  }
64
121
 
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
122
+ # Hand off: user's keystrokes flow to claude, claude's output flows to
123
+ # the user's terminal. interact blocks until claude exits.
124
+ #
125
+ # Test hook: set WOGI_EXPECT_NO_INTERACT=1 to substitute `expect eof`.
126
+ # `interact` requires a real TTY on stdin; under node:test / CI with
127
+ # pipe-backed stdin it closes the PTY before our sent \r flushes to the
128
+ # child. The test harness sets this env var so the behavioral tests (dialog
129
+ # dismissal, ANSI fragmentation) can actually observe the child receiving
130
+ # the keystroke. Production callers MUST NOT set this — users need
131
+ # interact to drive claude after the dialog dismisses.
132
+ if {[info exists env(WOGI_EXPECT_NO_INTERACT)] && $env(WOGI_EXPECT_NO_INTERACT) eq "1"} {
133
+ expect eof
134
+ } else {
135
+ interact
136
+ }
68
137
 
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).
138
+ # Pass claude's exit status wrapper cares about the restart flag file,
139
+ # not exit code, so a plain exit suffices.
73
140
  exit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wogiflow",
3
- "version": "2.26.0",
3
+ "version": "2.26.2",
4
4
  "description": "AI-powered development workflow management system with multi-model support",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -50,6 +50,12 @@ const { getConfig, PATHS } = require('../../flow-utils');
50
50
  const { safeJsonParse } = require('../../flow-io');
51
51
 
52
52
  const PENDING_MARKER_FILE = 'task-just-completed';
53
+ const LAST_TRIGGERED_FILE = 'task-boundary-last-triggered';
54
+ // Window during which a recentlyCompleted[0] entry is considered "fresh
55
+ // enough" to retro-mark Phase 1 from the Stop hook. Large enough to cover
56
+ // a slow quality-gate run; small enough that a session opened hours later
57
+ // doesn't trigger a bogus restart.
58
+ const FRESHNESS_WINDOW_MS = 5 * 60 * 1000;
53
59
 
54
60
  /**
55
61
  * Locate the pending-marker file path inside .workflow/state/.
@@ -59,6 +65,26 @@ function getPendingMarkerPath() {
59
65
  return path.join(PATHS.state, PENDING_MARKER_FILE);
60
66
  }
61
67
 
68
+ function getLastTriggeredPath() {
69
+ return path.join(PATHS.state, LAST_TRIGGERED_FILE);
70
+ }
71
+
72
+ function readLastTriggered() {
73
+ try {
74
+ return safeJsonParse(getLastTriggeredPath(), null);
75
+ } catch (_err) {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ function writeLastTriggered(taskId) {
81
+ try {
82
+ const p = getLastTriggeredPath();
83
+ fs.mkdirSync(path.dirname(p), { recursive: true });
84
+ fs.writeFileSync(p, JSON.stringify({ taskId, at: new Date().toISOString() }));
85
+ } catch (_err) { /* best effort — anti-replay is defense-in-depth */ }
86
+ }
87
+
62
88
  /**
63
89
  * Phase 1 — mark that a task just completed and a restart is desired at the
64
90
  * next Stop-hook boundary. Safe to call even when the feature is disabled;
@@ -196,6 +222,13 @@ function consumeAndTriggerRestart() {
196
222
  return { triggered: false, reason: `sigterm-failed: ${err.message}` };
197
223
  }
198
224
 
225
+ // Record anti-replay sentinel so the Stop-hook fallback in the NEW session
226
+ // (post-restart) doesn't retro-mark the same recentlyCompleted[0] and
227
+ // trigger a second restart.
228
+ if (markerPayload?.taskId) {
229
+ writeLastTriggered(markerPayload.taskId);
230
+ }
231
+
199
232
  return {
200
233
  triggered: true,
201
234
  flagPath: pre.flagPath,
@@ -203,6 +236,72 @@ function consumeAndTriggerRestart() {
203
236
  };
204
237
  }
205
238
 
239
+ /**
240
+ * Phase 1 fallback — called from the Stop hook BEFORE
241
+ * consumeAndTriggerRestart. Detects a freshly-completed task in
242
+ * recentlyCompleted and writes the pending marker if neither of the primary
243
+ * Phase 1 paths fired.
244
+ *
245
+ * Why this exists: the primary Phase 1 writers are (a) flow-done.js:604 when
246
+ * `flow done <taskId>` runs, and (b) task-completed.js:522 driven by Claude
247
+ * Code's TaskCompleted hook. Path (b) does not fire for /wogi-start workflow
248
+ * completions (TaskCompleted fires for Task-tool sub-agents only — the reason
249
+ * for the two-phase redesign above). Path (a) only fires if the agent runs
250
+ * `flow done`. Older phase docs quietly encouraged "move task to
251
+ * recentlyCompleted in ready.json" as a substitute for `flow done`, which
252
+ * silently disables the restart. This fallback catches that case: if a fresh
253
+ * completion is visible in ready.json but no marker exists, we write one so
254
+ * Phase 2 can do its job.
255
+ *
256
+ * Anti-replay: recentlyCompleted[0] survives the SIGTERM + wrapper restart
257
+ * cycle, so without a guard the Stop hook in the NEW session would see the
258
+ * same fresh completion and trigger a second restart. The
259
+ * task-boundary-last-triggered sentinel prevents that — it records the last
260
+ * taskId we triggered on, and we skip if the current fresh completion
261
+ * matches.
262
+ *
263
+ * @returns {{ marked: boolean, taskId?: string, reason?: string }}
264
+ */
265
+ function ensurePhase1MarkedIfRecentlyCompleted() {
266
+ try {
267
+ if (hasPendingMarker()) {
268
+ return { marked: false, reason: 'marker-already-present' };
269
+ }
270
+
271
+ const readyPath = path.join(PATHS.state, 'ready.json');
272
+ const ready = safeJsonParse(readyPath, null);
273
+ const recent = ready && Array.isArray(ready.recentlyCompleted)
274
+ ? ready.recentlyCompleted[0]
275
+ : null;
276
+ if (!recent || typeof recent !== 'object' || !recent.id || !recent.completedAt) {
277
+ return { marked: false, reason: 'no-fresh-completion' };
278
+ }
279
+
280
+ const completedTs = new Date(recent.completedAt).getTime();
281
+ if (!Number.isFinite(completedTs)) {
282
+ return { marked: false, reason: 'unparseable-completedAt' };
283
+ }
284
+ const ageMs = Date.now() - completedTs;
285
+ if (ageMs < 0 || ageMs > FRESHNESS_WINDOW_MS) {
286
+ return { marked: false, reason: 'stale-completion' };
287
+ }
288
+
289
+ const lastTriggered = readLastTriggered();
290
+ if (lastTriggered?.taskId === recent.id) {
291
+ return { marked: false, reason: 'already-triggered-for-this-task' };
292
+ }
293
+
294
+ const result = markRestartPending({
295
+ taskId: recent.id,
296
+ taskTitle: recent.title,
297
+ source: 'stop-hook-fallback'
298
+ });
299
+ return { marked: result.marked, taskId: recent.id, reason: result.reason };
300
+ } catch (err) {
301
+ return { marked: false, reason: `fallback-error: ${err.message}` };
302
+ }
303
+ }
304
+
206
305
  /**
207
306
  * Convenience: whether a pending marker currently exists. Diagnostic only.
208
307
  * @returns {boolean}
@@ -219,6 +318,10 @@ module.exports = {
219
318
  // Phase 1 — called from task-completion code paths
220
319
  markRestartPending,
221
320
 
321
+ // Phase 1 fallback — called from the Stop hook entry BEFORE Phase 2,
322
+ // catches the case where flow-done didn't run and TaskCompleted didn't fire
323
+ ensurePhase1MarkedIfRecentlyCompleted,
324
+
222
325
  // Phase 2 — called from the Stop hook entry
223
326
  consumeAndTriggerRestart,
224
327
 
@@ -155,7 +155,33 @@ runHook('Stop', async ({ parsedInput }) => {
155
155
  // No-op unless task-just-completed marker exists AND feature is enabled
156
156
  // AND wogi-claude wrapper env is present.
157
157
  try {
158
- const { consumeAndTriggerRestart, hasPendingMarker } = require('../../core/task-boundary-reset');
158
+ const {
159
+ consumeAndTriggerRestart,
160
+ hasPendingMarker,
161
+ ensurePhase1MarkedIfRecentlyCompleted
162
+ } = require('../../core/task-boundary-reset');
163
+
164
+ // Phase 1 fallback: if the task completed via a path that didn't write the
165
+ // marker (e.g., agent edited ready.json directly instead of running
166
+ // `flow done`, or TaskCompleted hook didn't fire), retro-mark here so
167
+ // Phase 2 below can consume it. Anti-replay sentinel prevents double-firing
168
+ // across the SIGTERM + wrapper restart cycle.
169
+ try {
170
+ const fallback = ensurePhase1MarkedIfRecentlyCompleted();
171
+ if (fallback.marked && process.env.DEBUG) {
172
+ console.error(`[Stop] Phase 1 fallback marked ${fallback.taskId}`);
173
+ } else if (!fallback.marked && fallback.reason !== 'marker-already-present' &&
174
+ fallback.reason !== 'no-fresh-completion' &&
175
+ fallback.reason !== 'stale-completion' &&
176
+ fallback.reason !== 'already-triggered-for-this-task' &&
177
+ process.env.DEBUG) {
178
+ console.error(`[Stop] Phase 1 fallback skipped: ${fallback.reason}`);
179
+ }
180
+ } catch (err) {
181
+ if (process.env.DEBUG) {
182
+ console.error(`[Stop] Phase 1 fallback error (fail-open): ${err.message}`);
183
+ }
184
+ }
159
185
 
160
186
  // If we're about to restart, record the session in history FIRST so the
161
187
  // new session can find the prior session's resume token. Use parsedInput