wogiflow 2.33.0 → 2.34.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.
Files changed (58) hide show
  1. package/.workflow/templates/partials/methodology-rules.hbs +3 -1
  2. package/lib/scheduled-mode.js +12 -15
  3. package/lib/skill-export-claude-plugin.js +41 -1
  4. package/lib/skill-portability.js +21 -3
  5. package/lib/workspace-channel-server.js +116 -3
  6. package/lib/workspace-channel-tracking.js +102 -1
  7. package/lib/workspace-dispatch-tracking.js +28 -0
  8. package/lib/workspace-messages.js +32 -4
  9. package/lib/workspace-subtask-state.js +215 -0
  10. package/lib/workspace.js +81 -0
  11. package/package.json +2 -2
  12. package/scripts/flow +17 -0
  13. package/scripts/flow-constants.js +3 -1
  14. package/scripts/flow-io.js +17 -0
  15. package/scripts/flow-paths.js +81 -0
  16. package/scripts/flow-schedule.js +23 -6
  17. package/scripts/flow-scheduled-runner.js +53 -8
  18. package/scripts/flow-standards-checker.js +37 -0
  19. package/scripts/flow-utils.js +2 -0
  20. package/scripts/hooks/adapters/claude-code.js +6 -2
  21. package/scripts/hooks/core/git-safety-gate.js +34 -15
  22. package/scripts/hooks/core/long-input-enforcement.js +49 -39
  23. package/scripts/hooks/core/overdue-dispatches.js +28 -6
  24. package/scripts/hooks/core/phase-gate.js +34 -5
  25. package/scripts/hooks/core/phase-read-gate.js +62 -10
  26. package/scripts/hooks/core/session-start-worker.js +52 -0
  27. package/scripts/hooks/core/stop-orchestrator.js +17 -2
  28. package/scripts/hooks/core/validation.js +8 -0
  29. package/scripts/hooks/core/worker-continuation-gate.js +487 -0
  30. package/scripts/hooks/core/workspace-stop-gates.js +21 -0
  31. package/scripts/hooks/core/workspace-stop-notify.js +174 -59
  32. package/scripts/hooks/entry/claude-code/post-tool-use.js +26 -0
  33. package/.claude/rules/README.md +0 -36
  34. package/.claude/rules/_internal/README.md +0 -64
  35. package/.claude/rules/_internal/document-structure.md +0 -77
  36. package/.claude/rules/_internal/dual-repo-management.md +0 -174
  37. package/.claude/rules/_internal/feature-refactoring-cleanup.md +0 -87
  38. package/.claude/rules/_internal/github-releases.md +0 -71
  39. package/.claude/rules/_internal/model-management.md +0 -35
  40. package/.claude/rules/_internal/self-maintenance.md +0 -87
  41. package/.claude/rules/_internal/worker-tool-first-turn.md +0 -82
  42. package/.claude/rules/alternative-execpolicy-toml-command-policy.md +0 -11
  43. package/.claude/rules/alternative-hand-edit-ready-json-to-register-orpha.md +0 -11
  44. package/.claude/rules/alternative-hook-args-exec-form.md +0 -6
  45. package/.claude/rules/alternative-permission-ruleset-per-phase.md +0 -11
  46. package/.claude/rules/alternative-short-name.md +0 -12
  47. package/.claude/rules/alternative-wogi-flow-as-mcp-client-oauth-manager.md +0 -11
  48. package/.claude/rules/architecture/component-reuse.md +0 -38
  49. package/.claude/rules/architecture/hook-three-layer.md +0 -68
  50. package/.claude/rules/code-style/naming-conventions.md +0 -107
  51. package/.claude/rules/dual-repo-architecture-2026-02-28.md +0 -18
  52. package/.claude/rules/github-release-workflow-2026-01-30.md +0 -16
  53. package/.claude/rules/operations/git-workflows.md +0 -92
  54. package/.claude/rules/operations/scratch-directory.md +0 -54
  55. package/.claude/skills/figma-analyzer/knowledge/learnings.md +0 -11
  56. package/.workflow/specs/architecture.md.template +0 -24
  57. package/.workflow/specs/stack.md.template +0 -33
  58. package/.workflow/specs/testing.md.template +0 -36
@@ -0,0 +1,487 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Worker In-Progress Continuation Gate
5
+ * (epic-workspace-sustained-exec / S2, wf-aee4a4fa)
6
+ *
7
+ * THE core fix for "channel-dispatched workers stall after one turn." The
8
+ * existing "Gap B" gate (workspace-stop-gates.js) only forces continuation when
9
+ * a dispatch is queued but NOT started (inProgressCount===0). When a decomposed
10
+ * task is already in-progress and the worker stops mid-way, nothing keeps it
11
+ * going. This gate fills that hole.
12
+ *
13
+ * On a worker Stop, if a task is in-progress in an active-work phase with
14
+ * sub-tasks remaining (durable ledger from S1) and no escalation is pending and
15
+ * the per-task cap isn't exhausted, it returns {continue:true} so Claude Code
16
+ * does NOT stop — keeping the SAME session alive (preserving in-context
17
+ * decomposition, sidestepping the flaky channel wake-up). The proven mechanism
18
+ * is the same one the routing-gate / research-required-gate / tool-first-gate
19
+ * already use.
20
+ *
21
+ * Termination (no infinite loop):
22
+ * - Task leaves inProgress (done) ⇒ remaining()=0 / no inProgress ⇒ gate stops.
23
+ * - Per-task iteration cap = total*perSubtaskTurns + capBuffer (≤ maxContinuations).
24
+ * - No-progress detector: a "progress fingerprint" = hash(git status --porcelain)
25
+ * + remaining-count. If it doesn't change across noProgressK consecutive
26
+ * continuations, the worker isn't doing anything ⇒ escalate ## BLOCKED + stop.
27
+ * Any file edit OR completed sub-task changes the fingerprint, so legitimately
28
+ * long multi-turn refactors (no commits yet) are NOT false-killed, and a
29
+ * failing `flow done` that the worker keeps editing around still counts as
30
+ * progress.
31
+ *
32
+ * Phase-gating: fires only in active-work phases (coding/validating). In
33
+ * spec_review / exploring the worker is legitimately waiting on the manager's
34
+ * approval, so the gate stays out of the way.
35
+ *
36
+ * Worker-mode only. Fail-open: any error returns null (normal Stop flow).
37
+ */
38
+
39
+ const fs = require('node:fs');
40
+ const path = require('node:path');
41
+ const childProcess = require('node:child_process');
42
+ const crypto = require('node:crypto');
43
+
44
+ const COUNTER_FILE = 'worker-continuation.json';
45
+ const PHASE_FILE = 'workflow-phase.json';
46
+
47
+ const DEFAULTS = {
48
+ enabled: true,
49
+ activePhases: ['coding', 'validating'],
50
+ perSubtaskTurns: 6,
51
+ capBuffer: 4,
52
+ maxContinuations: 60,
53
+ noProgressK: 4
54
+ };
55
+
56
+ function getCfg(config) {
57
+ const ws = (config && config.workspace && config.workspace.continuationGate) || {};
58
+ return {
59
+ enabled: ws.enabled !== false,
60
+ activePhases: Array.isArray(ws.activePhases) && ws.activePhases.length ? ws.activePhases : DEFAULTS.activePhases,
61
+ perSubtaskTurns: Number.isFinite(ws.perSubtaskTurns) ? ws.perSubtaskTurns : DEFAULTS.perSubtaskTurns,
62
+ capBuffer: Number.isFinite(ws.capBuffer) ? ws.capBuffer : DEFAULTS.capBuffer,
63
+ maxContinuations: Number.isFinite(ws.maxContinuations) ? ws.maxContinuations : DEFAULTS.maxContinuations,
64
+ noProgressK: Number.isFinite(ws.noProgressK) ? ws.noProgressK : DEFAULTS.noProgressK
65
+ };
66
+ }
67
+
68
+ function isWorkerMode(env = process.env) {
69
+ return Boolean(env.WOGI_WORKSPACE_ROOT && env.WOGI_REPO_NAME && env.WOGI_REPO_NAME !== 'manager');
70
+ }
71
+
72
+ function getCounterPath(stateDir) {
73
+ return path.join(stateDir, COUNTER_FILE);
74
+ }
75
+
76
+ function readCounter(stateDir) {
77
+ try {
78
+ const raw = fs.readFileSync(getCounterPath(stateDir), 'utf-8');
79
+ const data = JSON.parse(raw);
80
+ if (data && typeof data === 'object') return data;
81
+ } catch (_err) { /* absent or unreadable */ }
82
+ return null;
83
+ }
84
+
85
+ function writeCounter(stateDir, state) {
86
+ try {
87
+ const p = getCounterPath(stateDir);
88
+ fs.mkdirSync(path.dirname(p), { recursive: true });
89
+ const tmp = `${p}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 8)}`;
90
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
91
+ fs.renameSync(tmp, p);
92
+ } catch (_err) { /* best effort */ }
93
+ }
94
+
95
+ function clearCounter(stateDir) {
96
+ try { fs.unlinkSync(getCounterPath(stateDir)); } catch (_err) { /* fine */ }
97
+ }
98
+
99
+ function readPhase(stateDir) {
100
+ try {
101
+ const raw = fs.readFileSync(path.join(stateDir, PHASE_FILE), 'utf-8');
102
+ const data = JSON.parse(raw);
103
+ return data && typeof data.phase === 'string' ? data.phase : null;
104
+ } catch (_err) {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Progress fingerprint: working-tree state (uncommitted edits count too) plus the
111
+ * remaining sub-task count. Changes whenever a file is touched OR a sub-task
112
+ * completes. Used to detect "doing nothing" without false-killing long refactors.
113
+ */
114
+ function defaultProgressFingerprint(root, remainingCount) {
115
+ let porcelain = '';
116
+ try {
117
+ porcelain = childProcess.execSync('git status --porcelain 2>/dev/null || true', {
118
+ cwd: root, encoding: 'utf-8', timeout: 3000
119
+ });
120
+ } catch (_err) { /* non-fatal */ }
121
+ let sha = '';
122
+ try {
123
+ sha = childProcess.execSync('git rev-parse --short HEAD 2>/dev/null || true', {
124
+ cwd: root, encoding: 'utf-8', timeout: 2000
125
+ }).trim();
126
+ } catch (_err) { /* non-fatal */ }
127
+ return crypto.createHash('sha1').update(`${sha}\n${remainingCount}\n${porcelain}`).digest('hex');
128
+ }
129
+
130
+ function derivedCap(total, cfg) {
131
+ const base = (total > 0 ? total : 1) * cfg.perSubtaskTurns + cfg.capBuffer;
132
+ return Math.min(base, cfg.maxContinuations);
133
+ }
134
+
135
+ function buildContinueDirective({ taskId, remaining, total, attempt, cap }) {
136
+ return [
137
+ `SUSTAINED EXECUTION — task ${taskId} is in progress with ${remaining} of ${total} sub-task(s) remaining.`,
138
+ `You are a workspace worker. A dispatched task runs to COMPLETION across turns — do NOT stop to "report progress" mid-task.`,
139
+ '',
140
+ `Do the next sub-task NOW (one tool call to start). Keep going until ALL sub-tasks are done.`,
141
+ '',
142
+ 'Exit conditions (only these):',
143
+ ` • DONE → run \`flow done ${taskId}\` (quality gates run). When the task leaves inProgress, this gate stops automatically.`,
144
+ ` • BLOCKED on something only the manager/user can resolve, OR the next step is DESTRUCTIVE / IRREVERSIBLE /`,
145
+ ` touches PRODUCTION / needs external credentials → do NOT proceed. Escalate instead:`,
146
+ ` curl -s -X POST http://127.0.0.1:${process.env.WOGI_MANAGER_PORT || '8800'} \\`,
147
+ ` -H "X-Wogi-From: ${process.env.WOGI_REPO_NAME || 'worker'}" \\`,
148
+ ` --data-binary "## QUESTION: <blocker>" (then end the turn)`,
149
+ '',
150
+ `(continuation ${attempt}/${cap} — make real progress this turn or escalate; idle turns are detected and will be stopped.)`
151
+ ].join('\n');
152
+ }
153
+
154
+ /**
155
+ * Best-effort escalation to the manager when the gate gives up (cap / no-progress).
156
+ * Writes a worker-blocked message to the bus AND POSTs to the manager port.
157
+ */
158
+ function escalateBlocked({ workspaceRoot, repoName, taskId, reason, managerPort }) {
159
+ const summary = `## BLOCKED: ${reason} (task ${taskId})`;
160
+ try {
161
+ if (workspaceRoot) {
162
+ const libMessages = path.resolve(__dirname, '..', '..', '..', 'lib', 'workspace-messages');
163
+ const { createMessage, saveMessage } = require(libMessages);
164
+ const msg = createMessage({
165
+ from: repoName, to: 'manager', type: 'worker-blocked',
166
+ subject: `Worker ${repoName} blocked on ${taskId}`,
167
+ body: summary, priority: 'high', actionRequired: true
168
+ });
169
+ msg.taskId = taskId;
170
+ msg.reason = reason;
171
+ saveMessage(workspaceRoot, msg);
172
+ }
173
+ } catch (_err) { /* best effort */ }
174
+ try {
175
+ if (managerPort) {
176
+ const http = require('node:http');
177
+ const buf = Buffer.from(summary, 'utf-8');
178
+ const req = http.request({
179
+ hostname: '127.0.0.1', port: parseInt(managerPort, 10), path: '/', method: 'POST',
180
+ headers: { 'Content-Type': 'text/plain', 'Content-Length': buf.byteLength, 'X-Wogi-From': repoName }
181
+ });
182
+ req.on('error', () => {});
183
+ req.write(buf); req.end();
184
+ }
185
+ } catch (_err) { /* best effort */ }
186
+ }
187
+
188
+ /**
189
+ * Is autonomous walk-away mode active for this worker? Read from the canonical
190
+ * session-state.json. Tailors the stall directive (pre-approved → proceed) but
191
+ * does NOT change the never-idle guarantee — that holds in all worker mode.
192
+ */
193
+ function isAutonomousActive(stateDir) {
194
+ try {
195
+ const { safeJsonParse } = require('../../flow-utils');
196
+ const ss = safeJsonParse(path.join(stateDir, 'session-state.json'), null);
197
+ return Boolean(ss && ss.autonomousMode && ss.autonomousMode.active);
198
+ } catch (_err) {
199
+ return false;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Directive injected when an in-progress worker is parked at a gate. Tells it to
205
+ * make real progress by SATISFYING the gate legitimately (read the phase doc,
206
+ * decompose, provide evidence) — or to channel-escalate — and EXPLICITLY forbids
207
+ * gate circumvention. This is the integrity half of RC2: never give the worker a
208
+ * reason to reach for a worktree / marker-write.
209
+ */
210
+ function buildStallDirective({ taskId, phase, remaining, total, attempt, k, autonomous, env }) {
211
+ const port = (env && env.WOGI_MANAGER_PORT) || '8800';
212
+ const repo = (env && env.WOGI_REPO_NAME) || 'worker';
213
+ const isParked = phase === 'exploring' || phase === 'spec_review';
214
+ const lines = [
215
+ `SUSTAINED EXECUTION — task ${taskId} is in progress but appears PARKED (phase=${phase}, ${remaining}/${total || 0} sub-tasks).`,
216
+ `You are a workspace worker. A dispatched task runs to COMPLETION across turns. Idling silently while a task is in-progress is NOT a valid end-of-turn state.`,
217
+ '',
218
+ 'Make REAL progress THIS turn by SATISFYING the gate legitimately:'
219
+ ];
220
+ if (isParked) {
221
+ lines.push(
222
+ ` • You are in the ${phase} phase. Read the required phase instruction file (.claude/docs/phases/), finish the ${phase === 'spec_review' ? 'spec' : 'exploration'}, and advance the pipeline.`,
223
+ autonomous
224
+ ? ` • Autonomous mode is ACTIVE → you are PRE-APPROVED. Do NOT wait for spec/architect approval — proceed.`
225
+ : ` • If this needs manager/user approval, channel-escalate (below) instead of waiting silently.`
226
+ );
227
+ } else {
228
+ lines.push(
229
+ ` • The task is in an active phase but has no decomposed sub-task ledger yet. Decompose it (TodoWrite) and START the first sub-task, OR if a gate is blocking you, satisfy it (read the required phase doc / provide the required evidence).`
230
+ );
231
+ }
232
+ lines.push(
233
+ '',
234
+ 'PROHIBITED — gate circumvention is forbidden and pointless (gates resolve phase from the canonical main-repo state, not your cwd):',
235
+ ' ✗ Do NOT create a git worktree to reach an "ungated" context.',
236
+ ' ✗ Do NOT hand-write gate-satisfying markers or edit .workflow/state files to fake gate satisfaction.',
237
+ ' ✗ Do NOT change working directory to dodge a gate.',
238
+ '',
239
+ 'If you genuinely cannot proceed (blocked on the manager/user, or the next step is destructive / needs credentials), ESCALATE then end the turn:',
240
+ ` curl -s -X POST http://127.0.0.1:${port} \\`,
241
+ ` -H "X-Wogi-From: ${repo}" \\`,
242
+ ` --data-binary "## QUESTION: <your blocker>"`,
243
+ '',
244
+ `(stall continuation ${attempt}/${k} — make real progress or escalate; ${k} idle turns in a row will auto-escalate to the manager and stop.)`
245
+ );
246
+ return lines.join('\n');
247
+ }
248
+
249
+ /**
250
+ * Stall handler (RC1). Drives a proceed-or-escalate continuation for an
251
+ * in-progress worker task the happy path won't cover, then escalates to the
252
+ * manager after `noProgressK` consecutive no-progress turns. Shares the per-task
253
+ * counter file but tracks stall progress in dedicated fields so it never
254
+ * conflates with the happy-path continuation count. Never returns a silent stop
255
+ * without having escalated.
256
+ */
257
+ function handleStall({ stateDir, root, env, taskId, phase, remaining, total, fingerprintFn, cfg }) {
258
+ let counter = readCounter(stateDir);
259
+ if (!counter || counter.taskId !== taskId) {
260
+ counter = { taskId, count: 0, noProgressStreak: 0, fingerprint: null, escalated: false };
261
+ }
262
+
263
+ const fp = fingerprintFn(root, remaining);
264
+
265
+ // Use the SAME progress fields as the happy path (fingerprint /
266
+ // noProgressStreak / escalated). The stall and happy paths are mutually
267
+ // exclusive per turn but share one task counter; keeping separate fingerprint
268
+ // fields would break the escalated-resume check when a task transitions
269
+ // between modes after an escalation (the other mode's fingerprint is null →
270
+ // resume never fires → worker stuck "already-escalated"). Only `stallCount`
271
+ // is stall-specific (the attempt display).
272
+
273
+ // Respect an existing escalation — only resume if work moved since.
274
+ if (counter.escalated) {
275
+ if (counter.fingerprint && fp !== counter.fingerprint) {
276
+ counter.escalated = false;
277
+ counter.noProgressStreak = 0;
278
+ } else {
279
+ counter.fingerprint = fp;
280
+ writeCounter(stateDir, counter);
281
+ return { fired: false, decision: 'allow', reason: 'stall-already-escalated', escalated: true };
282
+ }
283
+ }
284
+
285
+ // No-progress streak (shared with happy path — no-progress is no-progress
286
+ // regardless of which mode produced the turn).
287
+ if (counter.fingerprint != null && fp === counter.fingerprint) {
288
+ counter.noProgressStreak = (counter.noProgressStreak || 0) + 1;
289
+ } else {
290
+ counter.noProgressStreak = 0;
291
+ }
292
+ counter.fingerprint = fp;
293
+
294
+ // Escalate after K consecutive no-progress turns, then stop.
295
+ if (counter.noProgressStreak >= cfg.noProgressK) {
296
+ counter.escalated = true;
297
+ writeCounter(stateDir, counter);
298
+ escalateBlocked({
299
+ workspaceRoot: env.WOGI_WORKSPACE_ROOT, repoName: env.WOGI_REPO_NAME,
300
+ taskId, reason: `parked at "${phase}" gate with no progress across ${counter.noProgressStreak} turns`,
301
+ managerPort: env.WOGI_MANAGER_PORT
302
+ });
303
+ return { fired: false, decision: 'allow', reason: 'stall-escalated', escalated: true, phase };
304
+ }
305
+
306
+ // Fire a proceed-or-escalate continuation.
307
+ counter.stallCount = (counter.stallCount || 0) + 1;
308
+ writeCounter(stateDir, counter);
309
+ const directive = buildStallDirective({
310
+ taskId, phase, remaining, total,
311
+ attempt: counter.noProgressStreak + 1, k: cfg.noProgressK,
312
+ autonomous: isAutonomousActive(stateDir), env
313
+ });
314
+ return {
315
+ fired: true, decision: 'continue', reason: 'in-progress-stall',
316
+ taskId, phase, remaining, total, attempt: counter.stallCount, stopReason: directive
317
+ };
318
+ }
319
+
320
+ /**
321
+ * Main gate. Returns one of:
322
+ * { continue: true, stopReason } — force continuation
323
+ * null — allow normal stop (no fire)
324
+ * plus a `decision` field for diagnostics/tests: 'continue' | 'allow' with reason.
325
+ *
326
+ * @param {Object} opts
327
+ * @param {Object} opts.config
328
+ * @param {string} [opts.stateDir] default PATHS.state
329
+ * @param {string} [opts.root] repo root for git probe (default PATHS.root)
330
+ * @param {Object} [opts.env] default process.env
331
+ * @param {Function} [opts.fingerprintFn] (root, remaining) => string (injectable for tests)
332
+ * @param {Object} [opts.subtaskState] injectable S1 module (tests)
333
+ */
334
+ function checkWorkerContinuation(opts = {}) {
335
+ try {
336
+ const env = opts.env || process.env;
337
+ if (!isWorkerMode(env)) return { fired: false, decision: 'allow', reason: 'not-worker' };
338
+
339
+ const cfg = getCfg(opts.config);
340
+ if (!cfg.enabled) return { fired: false, decision: 'allow', reason: 'disabled' };
341
+
342
+ const { PATHS, safeJsonParse } = require('../../flow-utils');
343
+ const stateDir = opts.stateDir || PATHS.state;
344
+ const root = opts.root || PATHS.root;
345
+
346
+ // Active task?
347
+ const ready = safeJsonParse(path.join(stateDir, 'ready.json'), { inProgress: [] });
348
+ const task = (ready.inProgress || [])[0];
349
+ const taskId = task && task.id;
350
+ if (!taskId) {
351
+ clearCounter(stateDir); // nothing in progress — reset for next task
352
+ return { fired: false, decision: 'allow', reason: 'no-in-progress' };
353
+ }
354
+
355
+ const phase = readPhase(stateDir);
356
+ const fingerprintFn = opts.fingerprintFn || defaultProgressFingerprint;
357
+
358
+ // Remaining decomposed work (needed by both happy-path and stall fallback).
359
+ const subtaskState = opts.subtaskState || require('../../../lib/workspace-subtask-state');
360
+ const summary = subtaskState.summary(taskId);
361
+ const remaining = summary.remaining;
362
+ const total = summary.total;
363
+
364
+ const PARKED_PHASES = ['exploring', 'spec_review'];
365
+ const inActivePhase = cfg.activePhases.includes(phase);
366
+
367
+ // Decomposed ledger exists and ALL sub-tasks are complete (total>0,
368
+ // remaining<=0): the task is wrapping up. Allow a clean stop so the worker
369
+ // can run `flow done` and task-complete can fire (preserves S2/S6
370
+ // termination — this is the completion boundary, not a parked stall).
371
+ if (inActivePhase && remaining <= 0 && total > 0) {
372
+ return { fired: false, decision: 'allow', reason: 'subtasks-complete' };
373
+ }
374
+
375
+ // Stall fallback (RC1, wf-e5e57361): an in-progress worker task that the
376
+ // happy path won't cover MUST NOT idle silently. Two shapes:
377
+ // (a) active phase but NO decomposed ledger ever (total<=0) — e.g. parked
378
+ // at an architect / phase-read gate before TodoWrite decomposition.
379
+ // (b) parked in an approval / explore phase (exploring / spec_review).
380
+ // Drive a proceed-or-escalate continuation; escalate to the manager after
381
+ // noProgressK no-progress turns. Never a silent allow-stop.
382
+ if ((inActivePhase && total <= 0) || PARKED_PHASES.includes(phase)) {
383
+ return handleStall({
384
+ stateDir, root, env, taskId, phase,
385
+ remaining, total, fingerprintFn, cfg
386
+ });
387
+ }
388
+
389
+ // Not actionable (idle / routing / completing / unknown) — genuinely allow a
390
+ // normal stop; there is no in-progress work to sustain here.
391
+ if (!inActivePhase) {
392
+ return { fired: false, decision: 'allow', reason: `phase-not-actionable:${phase || 'none'}` };
393
+ }
394
+
395
+ // ── Happy path: active phase + remaining > 0 (unchanged S2 logic) ──
396
+ // Per-task counter (reset when the task changes).
397
+ let counter = readCounter(stateDir);
398
+ if (!counter || counter.taskId !== taskId) {
399
+ counter = { taskId, count: 0, noProgressStreak: 0, fingerprint: null, escalated: false };
400
+ }
401
+
402
+ // Already escalated for this task ⇒ allow stop (don't re-fire). Manager
403
+ // re-dispatch / restart resets the counter (taskId match but escalated flag);
404
+ // we clear the escalation only when progress resumes (fingerprint changes).
405
+ const fingerprint = fingerprintFn(root, remaining);
406
+
407
+ if (counter.escalated) {
408
+ if (counter.fingerprint && fingerprint !== counter.fingerprint) {
409
+ // Work resumed since we escalated — clear and continue.
410
+ counter.escalated = false;
411
+ counter.noProgressStreak = 0;
412
+ } else {
413
+ return { fired: false, decision: 'allow', reason: 'already-escalated' };
414
+ }
415
+ }
416
+
417
+ const cap = derivedCap(summary.total, cfg);
418
+
419
+ // No-progress detection.
420
+ if (counter.fingerprint !== null && fingerprint === counter.fingerprint) {
421
+ counter.noProgressStreak = (counter.noProgressStreak || 0) + 1;
422
+ } else {
423
+ counter.noProgressStreak = 0;
424
+ }
425
+ counter.fingerprint = fingerprint;
426
+
427
+ if (counter.noProgressStreak >= cfg.noProgressK) {
428
+ counter.escalated = true;
429
+ writeCounter(stateDir, counter);
430
+ escalateBlocked({
431
+ workspaceRoot: env.WOGI_WORKSPACE_ROOT, repoName: env.WOGI_REPO_NAME,
432
+ taskId, reason: `no progress across ${counter.noProgressStreak} continuations`,
433
+ managerPort: env.WOGI_MANAGER_PORT
434
+ });
435
+ return { fired: false, decision: 'allow', reason: 'no-progress-escalated', escalated: true };
436
+ }
437
+
438
+ if (counter.count >= cap) {
439
+ counter.escalated = true;
440
+ writeCounter(stateDir, counter);
441
+ escalateBlocked({
442
+ workspaceRoot: env.WOGI_WORKSPACE_ROOT, repoName: env.WOGI_REPO_NAME,
443
+ taskId, reason: `iteration cap (${cap}) reached`,
444
+ managerPort: env.WOGI_MANAGER_PORT
445
+ });
446
+ return { fired: false, decision: 'allow', reason: 'cap-escalated', escalated: true };
447
+ }
448
+
449
+ // Fire continuation.
450
+ counter.count += 1;
451
+ writeCounter(stateDir, counter);
452
+ const directive = buildContinueDirective({
453
+ taskId, remaining, total: summary.total, attempt: counter.count, cap
454
+ });
455
+ return {
456
+ fired: true,
457
+ decision: 'continue',
458
+ reason: 'remaining-subtasks',
459
+ taskId,
460
+ remaining,
461
+ total: summary.total,
462
+ attempt: counter.count,
463
+ cap,
464
+ stopReason: directive
465
+ };
466
+ } catch (err) {
467
+ if (process.env.DEBUG) console.error(`[worker-continuation-gate] fail-open: ${err.message}`);
468
+ return { fired: false, decision: 'allow', reason: `error:${err.message}` };
469
+ }
470
+ }
471
+
472
+ module.exports = {
473
+ checkWorkerContinuation,
474
+ handleStall,
475
+ buildStallDirective,
476
+ isAutonomousActive,
477
+ isWorkerMode,
478
+ getCfg,
479
+ derivedCap,
480
+ buildContinueDirective,
481
+ readCounter,
482
+ writeCounter,
483
+ clearCounter,
484
+ getCounterPath,
485
+ readPhase,
486
+ DEFAULTS
487
+ };
@@ -60,6 +60,27 @@ async function checkWorkspaceStopGates({ parsedInput }) {
60
60
  if (process.env.DEBUG) console.error(`[Stop] Workspace autopickup gate error (fail-open): ${err.message}`);
61
61
  }
62
62
 
63
+ // In-Progress Continuation Gate (S2 / wf-aee4a4fa) — the core sustained-
64
+ // execution fix. Gap B (above) handles NOT-STARTED dispatches; this handles
65
+ // an IN-PROGRESS decomposed task with sub-tasks remaining. Keeps the SAME
66
+ // session grinding via {continue:true} instead of going idle after one turn.
67
+ try {
68
+ const { checkWorkerContinuation } = require('./worker-continuation-gate');
69
+ const { getConfig } = require('../../flow-utils');
70
+ const result = checkWorkerContinuation({ config: getConfig() });
71
+ if (result?.fired && result.stopReason) {
72
+ // S3: emit a worker-progress heartbeat (NOT a terminal stop) so the
73
+ // manager sees ongoing work and refreshes the dispatch deadline.
74
+ try {
75
+ const { notifyWorkerProgress } = require('./workspace-stop-notify');
76
+ await notifyWorkerProgress({ taskId: result.taskId, remaining: result.remaining, total: result.total, attempt: result.attempt });
77
+ } catch (_err) { /* best effort */ }
78
+ return { shouldReturn: true, result: { __raw: true, continue: true, stopReason: result.stopReason } };
79
+ }
80
+ } catch (err) {
81
+ if (process.env.DEBUG) console.error(`[Stop] Worker continuation gate error (fail-open): ${err.message}`);
82
+ }
83
+
63
84
  // Worker Tool-First Turn Gate
64
85
  try {
65
86
  const { isWorkerMode, checkWorkerToolFirstTurn, renderBlockMessage } = require('./worker-tool-first-gate');