wogiflow 2.22.0 → 2.22.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.
@@ -39,6 +39,9 @@ try {
39
39
  // Import parallel execution detection
40
40
  const { findParallelizable, getParallelConfig } = require('./flow-parallel');
41
41
 
42
+ // wf-63c0f4cc — specification-quality gates
43
+ const storyGates = require('./flow-story-gates');
44
+
42
45
  const CHANGES_DIR = PATHS.changes;
43
46
  const READY_PATH = PATHS.ready;
44
47
 
@@ -81,6 +84,85 @@ function getProductContextForStory() {
81
84
  }
82
85
  }
83
86
 
87
+ /**
88
+ * Build additional story sections from gate results (wf-63c0f4cc).
89
+ * Returns a markdown string or null if nothing to append.
90
+ *
91
+ * @param {Object} gateResults
92
+ * @returns {string|null}
93
+ */
94
+ function buildGateSections(gateResults) {
95
+ const parts = [];
96
+
97
+ const items = gateResults?.itemReconciliation;
98
+ if (items?.active && items.items.length > 0) {
99
+ parts.push(
100
+ '## Item Manifest (zero-loss)',
101
+ `Detected ${items.count} discrete items in the original request. ALL must map to a criterion or sub-task (anti-deferral rule).`,
102
+ '',
103
+ ...items.items.map((it, i) => `${i + 1}. ${it}`),
104
+ ''
105
+ );
106
+ }
107
+
108
+ const ci = gateResults?.consumerImpact;
109
+ if (ci?.active && Array.isArray(ci.matches)) {
110
+ const byFile = new Map();
111
+ for (const m of ci.matches) {
112
+ if (!byFile.has(m.file)) byFile.set(m.file, { kinds: new Set(), seeds: new Set() });
113
+ byFile.get(m.file).kinds.add(m.kind);
114
+ byFile.get(m.file).seeds.add(m.seed);
115
+ }
116
+ parts.push(
117
+ '## Consumer Impact',
118
+ `Refactoring-keyword detected in input; grepped codebase for consumers of: ${(ci.seeds || []).join(', ') || '(no seeds extracted)'}`,
119
+ '',
120
+ `Files matched: ${byFile.size} (breaking: ${ci.breakingCount || 0})`
121
+ );
122
+ if (byFile.size > 0) {
123
+ parts.push('', '| File | Kind | Via |', '|------|------|-----|');
124
+ for (const [file, info] of byFile) {
125
+ parts.push(`| \`${file}\` | ${[...info.kinds].join(', ')} | ${[...info.seeds].join(', ')} |`);
126
+ }
127
+ }
128
+ if (ci.phasedMigrationRecommended) {
129
+ parts.push(
130
+ '',
131
+ `**Phased migration strategy recommended** — ${ci.breakingCount} breaking consumers exceeds the threshold. Consider Phase 1 (new alongside old) → Phase 2 (migrate consumers) → Phase 3 (remove old) rather than a big-bang rewrite.`
132
+ );
133
+ }
134
+ parts.push('');
135
+ }
136
+
137
+ const sc = gateResults?.scopeConfidence;
138
+ if (sc?.active && sc.assumptions.length > 0) {
139
+ const bad = sc.assumptions.filter(a => a.status === 'UNVERIFIED' || a.status === 'CONTRADICTED');
140
+ if (bad.length > 0) {
141
+ parts.push(
142
+ '## Pending Clarifications',
143
+ 'Scope-confidence audit found assumptions that do not match the codebase. Resolve before implementation (or /wogi-start Step 1.45 will).',
144
+ ''
145
+ );
146
+ for (const a of sc.assumptions) {
147
+ const icon = a.status === 'VERIFIED' ? '✓' : a.status === 'CONTRADICTED' ? '✗' : '?';
148
+ parts.push(`- ${icon} **${a.status}** — "${a.label} ${a.phrase}"`);
149
+ }
150
+ parts.push('');
151
+ }
152
+ }
153
+
154
+ const ib = gateResults?.intentBootstrap;
155
+ if (ib?.scheduled) {
156
+ parts.push(
157
+ '## IGR Bootstrap Scheduled',
158
+ 'Intent-Grounded Reasoning artifacts are missing. Bootstrap has been scheduled for `/wogi-session-end` (Option C [2]). No action needed now.',
159
+ ''
160
+ );
161
+ }
162
+
163
+ return parts.length > 0 ? parts.join('\n') : null;
164
+ }
165
+
84
166
  /**
85
167
  * Generate story template content
86
168
  */
@@ -136,6 +218,8 @@ ${productSection}
136
218
  - **Triggered by**: [onClick on table row / button click / route navigation]
137
219
  - **Verification**: [Component is imported AND rendered when trigger fires]
138
220
 
221
+ **Enforcement**: this section is verified during \`/wogi-start\` Step 3.7 (Wiring Check). If wiring is broken at runtime, the task will fail verification.
222
+
139
223
  *Delete this section if no new UI components are created.*
140
224
 
141
225
  ## Technical Notes
@@ -304,6 +388,54 @@ async function createStory(title, options = {}) {
304
388
  const decompositionConfig = config.storyDecomposition || {};
305
389
  const dryRun = options.dryRun || false;
306
390
 
391
+ // wf-63c0f4cc — specification-quality gates.
392
+ // The "input" for gate purposes is either the explicit fullInput option
393
+ // (when a long multi-item payload was passed programmatically) or the
394
+ // title (typical CLI / single-request path).
395
+ const gateInput = options.fullInput || title;
396
+ const gateResults = {};
397
+
398
+ // Gate 1: Long Input — route oversized inputs to /wogi-extract-review.
399
+ // The `bypassLongInput` flag prevents re-routing when /wogi-start already
400
+ // handled long-input detection and forwarded to /wogi-story for a single
401
+ // extracted story.
402
+ if (!options.skipGates) {
403
+ gateResults.longInput = storyGates.checkLongInput(gateInput, {
404
+ bypassLongInput: options.bypassLongInput
405
+ });
406
+ if (gateResults.longInput.route) {
407
+ return {
408
+ taskId: null,
409
+ title,
410
+ gateResults,
411
+ routed: '/wogi-extract-review',
412
+ message: 'Input is large — routing to /wogi-extract-review for zero-loss capture.'
413
+ };
414
+ }
415
+ }
416
+
417
+ // Gate 2: Item Reconciliation — BEFORE decomposition, so enumerated items
418
+ // drive sub-task generation.
419
+ if (!options.skipGates) {
420
+ gateResults.itemReconciliation = storyGates.reconcileItems(gateInput);
421
+ }
422
+
423
+ // Gate 3: Consumer Impact Analysis (refactoring-keyword triggered)
424
+ if (!options.skipGates) {
425
+ gateResults.consumerImpact = storyGates.analyzeConsumerImpact(gateInput);
426
+ }
427
+
428
+ // Gate 4: Scope-Confidence Audit — writes a "Pending Clarifications" block
429
+ // to the story (non-interactive; resolved later by user or /wogi-start).
430
+ if (!options.skipGates) {
431
+ gateResults.scopeConfidence = storyGates.auditScopeConfidence(gateInput);
432
+ }
433
+
434
+ // Gate 5: Intent Bootstrap coordination (no UI prompt — background scheduling)
435
+ if (!options.skipGates) {
436
+ gateResults.intentBootstrap = storyGates.coordinateIntentBootstrap();
437
+ }
438
+
307
439
  // Get priority from options or config
308
440
  const defaultPriority = getConfigValue('priorities.defaultPriority', 'P2');
309
441
  const priority = options.priority || defaultPriority;
@@ -342,7 +474,13 @@ async function createStory(title, options = {}) {
342
474
  }
343
475
 
344
476
  // Create main story file (or just compute path in dry-run mode)
345
- const storyContent = generateStoryTemplate(taskId, title);
477
+ let storyContent = generateStoryTemplate(taskId, title);
478
+
479
+ // Append gate-derived sections (Consumer Impact, Pending Clarifications,
480
+ // Item Manifest). These are additive — the base template is unchanged.
481
+ const gateSections = buildGateSections(gateResults);
482
+ if (gateSections) storyContent += '\n' + gateSections;
483
+
346
484
  const storyFile = path.join(targetDir, `${taskId}.md`);
347
485
  if (!dryRun) {
348
486
  fs.writeFileSync(storyFile, storyContent);
@@ -355,7 +493,8 @@ async function createStory(title, options = {}) {
355
493
  storyFile,
356
494
  featureFolder, // null for flat, folder name for decomposed
357
495
  subTasks: [],
358
- dryRun // Include dry-run flag in result
496
+ dryRun, // Include dry-run flag in result
497
+ gateResults // wf-63c0f4cc — specification-quality gate findings
359
498
  };
360
499
 
361
500
  const shouldSuggest = !options.deep &&
@@ -493,6 +632,22 @@ async function createStory(title, options = {}) {
493
632
 
494
633
  result.decomposed = true;
495
634
 
635
+ // wf-63c0f4cc scenario 4 — item-coverage reconciliation check.
636
+ // After the template is populated, verify each enumerated item appears
637
+ // in at least one criterion / sub-task objective. Unmapped items are
638
+ // surfaced (non-blocking) for the user to address.
639
+ if (gateResults.itemReconciliation?.active) {
640
+ const criteriaTexts = result.subTasks.map(s => s.objective);
641
+ const coverage = storyGates.verifyItemCoverage(
642
+ gateResults.itemReconciliation.items,
643
+ criteriaTexts
644
+ );
645
+ gateResults.itemReconciliation.coverage = coverage;
646
+ if (!coverage.allMapped) {
647
+ result.unmappedItems = coverage.unmapped;
648
+ }
649
+ }
650
+
496
651
  // Check if sub-tasks can run in parallel
497
652
  try {
498
653
  const parallelConfig = getParallelConfig();
@@ -540,10 +695,13 @@ Usage:
540
695
  flow story "<title>" --deep --json All options
541
696
 
542
697
  Options:
543
- --deep Automatically decompose into sub-tasks
544
- --priority <P> Priority P0-P4 (default: from config, usually P2)
545
- --dry-run Preview what would be created without writing files
546
- --json Output JSON instead of human-readable
698
+ --deep Automatically decompose into sub-tasks
699
+ --priority <P> Priority P0-P4 (default: from config, usually P2)
700
+ --dry-run Preview what would be created without writing files
701
+ --json Output JSON instead of human-readable
702
+ --skip-gates Skip all P0 spec-quality gates (wf-63c0f4cc)
703
+ --bypass-long-input Skip Gate 1 (when /wogi-start already handled it)
704
+ --full-input <txt> Full user input for gates (when title is a summary)
547
705
 
548
706
  Storage:
549
707
  - Simple stories: flat in .workflow/changes/
@@ -585,9 +743,22 @@ Examples:
585
743
  const result = await createStory(title, {
586
744
  deep: flags.deep,
587
745
  priority,
588
- dryRun: flags['dry-run'] || flags.dryRun
746
+ dryRun: flags['dry-run'] || flags.dryRun,
747
+ skipGates: flags['skip-gates'] || flags.skipGates,
748
+ bypassLongInput: flags['bypass-long-input'] || flags.bypassLongInput,
749
+ fullInput: flags['full-input'] || flags.fullInput
589
750
  });
590
751
 
752
+ // If Gate 1 routed the input to /wogi-extract-review, print guidance + exit.
753
+ if (result.routed === '/wogi-extract-review') {
754
+ console.log('');
755
+ warn(result.message);
756
+ log('yellow', ` Run: /wogi-extract-review "<your input>"`);
757
+ console.log('');
758
+ if (flags.json) outputJson({ success: true, ...result });
759
+ process.exit(0);
760
+ }
761
+
591
762
  // JSON output
592
763
  if (flags.json) {
593
764
  outputJson({
@@ -646,6 +817,33 @@ Examples:
646
817
  log('dim', ` Run: flow story "${title}" --deep`);
647
818
  }
648
819
 
820
+ // wf-63c0f4cc — gate results summary
821
+ const gr = result.gateResults || {};
822
+ if (gr.itemReconciliation?.active) {
823
+ console.log('');
824
+ const items = gr.itemReconciliation;
825
+ log('cyan', `Item Reconciliation: ${items.count} items detected`);
826
+ if (items.coverage && !items.coverage.allMapped) {
827
+ log('yellow', ` ⚠ Unmapped: ${items.coverage.unmapped.length} — review story for completeness`);
828
+ } else if (result.decomposed) {
829
+ log('green', ` ✓ All ${items.count} items mapped to sub-tasks`);
830
+ }
831
+ }
832
+ if (gr.consumerImpact?.active && gr.consumerImpact.breakingCount > 0) {
833
+ console.log('');
834
+ log('cyan', `Consumer Impact: ${gr.consumerImpact.breakingCount} potentially-breaking consumers found`);
835
+ if (gr.consumerImpact.phasedMigrationRecommended) {
836
+ log('yellow', ` ⚠ Phased migration recommended — see story "Consumer Impact" section`);
837
+ }
838
+ }
839
+ if (gr.scopeConfidence?.active) {
840
+ const bad = gr.scopeConfidence.assumptions.filter(a => a.status !== 'VERIFIED');
841
+ if (bad.length > 0) {
842
+ console.log('');
843
+ log('yellow', `Scope-Confidence: ${bad.length} unverified/contradicted assumptions — see "Pending Clarifications"`);
844
+ }
845
+ }
846
+
649
847
  // Simple (non-decomposed) story ready.json status — wf-b5cff650 fix
650
848
  if (!result.decomposed) {
651
849
  if (result.addedToReady) {
@@ -421,46 +421,27 @@ Run: /wogi-start ${coreResult.nextTaskId}`;
421
421
  };
422
422
  }
423
423
 
424
- // Research protocol triggered - inject protocol steps as additional context
425
- if (coreResult.systemReminder) {
426
- // Append phase prompt if present
427
- const context = coreResult.phasePrompt
428
- ? `${coreResult.systemReminder}\n\n${coreResult.phasePrompt}`
429
- : coreResult.systemReminder;
430
- return {
431
- hookSpecificOutput: {
432
- hookEventName: 'UserPromptSubmit',
433
- additionalContext: context
434
- }
435
- };
436
- }
424
+ // Compose additionalContext from up to four pieces:
425
+ // 1. systemReminder (research protocol) OR message (warning)
426
+ // 2. phasePrompt (phase-specific context)
427
+ // 3. overduePrompt (wf-d3e67abe — silent-halt surfacing, manager-only)
428
+ const pieces = [];
429
+ if (coreResult.systemReminder) pieces.push(coreResult.systemReminder);
430
+ else if (coreResult.message) pieces.push(coreResult.message);
431
+ if (coreResult.phasePrompt) pieces.push(coreResult.phasePrompt);
432
+ if (coreResult.overduePrompt) pieces.push(coreResult.overduePrompt);
437
433
 
438
- // Warning - allow but inject context with the warning message
439
- if (coreResult.message && !coreResult.blocked) {
440
- // Append phase prompt if present
441
- const context = coreResult.phasePrompt
442
- ? `${coreResult.message}\n\n${coreResult.phasePrompt}`
443
- : coreResult.message;
444
- return {
445
- hookSpecificOutput: {
446
- hookEventName: 'UserPromptSubmit',
447
- additionalContext: context
448
- }
449
- };
434
+ if (pieces.length === 0) {
435
+ // Allowed - empty response means allow
436
+ return {};
450
437
  }
451
438
 
452
- // Phase prompt only (no other context to inject)
453
- if (coreResult.phasePrompt) {
454
- return {
455
- hookSpecificOutput: {
456
- hookEventName: 'UserPromptSubmit',
457
- additionalContext: coreResult.phasePrompt
458
- }
459
- };
460
- }
461
-
462
- // Allowed - empty response means allow
463
- return {};
439
+ return {
440
+ hookSpecificOutput: {
441
+ hookEventName: 'UserPromptSubmit',
442
+ additionalContext: pieces.join('\n\n')
443
+ }
444
+ };
464
445
  }
465
446
 
466
447
  /**
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Wogi Flow — Overdue Dispatch Detection (wf-d3e67abe)
3
+ *
4
+ * Computes overdue workspace dispatches for manager sessions and
5
+ * returns an additionalContext string to inject into UserPromptSubmit,
6
+ * surfacing silent worker deaths to the model before it processes the
7
+ * next prompt.
8
+ *
9
+ * Manager-scoped: returns null for worker sessions and when
10
+ * WOGI_WORKSPACE_ROOT is unset.
11
+ */
12
+
13
+ const path = require('node:path');
14
+
15
+ /**
16
+ * Returns true when this process is a workspace manager session
17
+ * (i.e., NOT a worker). A session counts as manager when:
18
+ * - WOGI_WORKSPACE_ROOT is set (we have a workspace to inspect), AND
19
+ * - WOGI_REPO_NAME is 'manager' OR unset (not a worker name).
20
+ */
21
+ function isManagerSession() {
22
+ if (!process.env.WOGI_WORKSPACE_ROOT) return false;
23
+ const repo = process.env.WOGI_REPO_NAME;
24
+ return !repo || repo === 'manager';
25
+ }
26
+
27
+ function formatDuration(ms) {
28
+ const totalMin = Math.max(0, Math.floor(ms / 60000));
29
+ const hours = Math.floor(totalMin / 60);
30
+ const min = totalMin % 60;
31
+ if (hours === 0) return `${min}m`;
32
+ return `${hours}h${min}m`;
33
+ }
34
+
35
+ function formatLine(record, now) {
36
+ const dispatched = Date.parse(record.dispatchedAt || '');
37
+ const deadline = Date.parse(record.expectedDeadline || '');
38
+ const sinceDispatch = Number.isFinite(dispatched) ? formatDuration(now - dispatched) : '?';
39
+ const pastDeadline = Number.isFinite(deadline) ? formatDuration(now - deadline) : '?';
40
+ const budget = record.expectedDurationMs
41
+ ? formatDuration(record.expectedDurationMs)
42
+ : '?';
43
+ return `• ${record.taskId} → ${record.repoName} | dispatched ${sinceDispatch} ago (${pastDeadline} past ${budget} deadline) | no task-complete / worker-stopped message`;
44
+ }
45
+
46
+ /**
47
+ * Reconcile any pending dispatches against `task-complete` or `worker-stopped`
48
+ * messages in the workspace message bus. Called before overdue computation so
49
+ * records that matched an incoming message don't get flagged as silent deaths.
50
+ *
51
+ * @param {string} workspaceRoot
52
+ * @returns {number} count of reconciled records
53
+ */
54
+ function sweepAndReconcile(workspaceRoot) {
55
+ let reconciled = 0;
56
+ let readMessages, reconcileDispatch, readDispatches;
57
+ try {
58
+ const libMessages = path.resolve(__dirname, '..', '..', '..', 'lib', 'workspace-messages.js');
59
+ const libTracking = path.resolve(__dirname, '..', '..', '..', 'lib', 'workspace-dispatch-tracking.js');
60
+ readMessages = require(libMessages).readMessages;
61
+ const tracking = require(libTracking);
62
+ reconcileDispatch = tracking.reconcileDispatch;
63
+ readDispatches = tracking.readDispatches;
64
+ } catch (_err) {
65
+ return 0; // Fail-open
66
+ }
67
+
68
+ let dispatches;
69
+ try {
70
+ dispatches = readDispatches(workspaceRoot).filter(r => r && r.status === 'pending');
71
+ } catch (_err) {
72
+ return 0;
73
+ }
74
+ if (dispatches.length === 0) return 0;
75
+
76
+ const byTaskId = new Map();
77
+ for (const r of dispatches) {
78
+ if (r.taskId && !byTaskId.has(r.taskId)) byTaskId.set(r.taskId, r);
79
+ }
80
+
81
+ // Pull both message types. readMessages throws on missing dir internally
82
+ // but guards with existsSync, so it's safe.
83
+ let messages = [];
84
+ try {
85
+ const completes = readMessages(workspaceRoot, { type: 'task-complete' });
86
+ const stops = readMessages(workspaceRoot, { type: 'worker-stopped' });
87
+ messages = completes.concat(stops);
88
+ } catch (_err) {
89
+ return 0;
90
+ }
91
+
92
+ for (const msg of messages) {
93
+ const taskId = msg.taskId || (msg.type === 'task-complete' ? msg.subject : null);
94
+ if (!taskId || !byTaskId.has(taskId)) continue;
95
+ try {
96
+ const status = msg.type === 'worker-stopped' ? 'graceful-stop' : 'completed';
97
+ const reason = msg.type === 'worker-stopped' ? (msg.reason || 'graceful') : null;
98
+ const result = reconcileDispatch(workspaceRoot, taskId, status, reason);
99
+ if (result) {
100
+ reconciled++;
101
+ byTaskId.delete(taskId); // Don't double-reconcile
102
+ }
103
+ } catch (_err) {
104
+ // Per-record failure must not poison the sweep.
105
+ }
106
+ }
107
+
108
+ return reconciled;
109
+ }
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
+
219
+ /**
220
+ * Build the overdue-dispatches additionalContext block, or return null
221
+ * when nothing to surface (non-manager, no workspace root, no overdue).
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
+ *
227
+ * @param {Object} [opts]
228
+ * @param {string} [opts.workspaceRoot] — override (primarily for tests)
229
+ * @param {number} [opts.now=Date.now()]
230
+ * @returns {string|null}
231
+ */
232
+ function buildOverdueContext(opts = {}) {
233
+ const workspaceRoot = opts.workspaceRoot || process.env.WOGI_WORKSPACE_ROOT;
234
+ if (!workspaceRoot) return null;
235
+ if (!opts.workspaceRoot && !isManagerSession()) return null;
236
+
237
+ // Sweep: reconcile any pending records that match incoming messages
238
+ // before computing overdue. This prevents false positives for workers
239
+ // whose completion/stop message arrived while the manager was idle.
240
+ try { sweepAndReconcile(workspaceRoot); }
241
+ catch (_err) { /* fail-open */ }
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
+
251
+ let overdue;
252
+ try {
253
+ const libPath = path.resolve(__dirname, '..', '..', '..', 'lib', 'workspace-dispatch-tracking.js');
254
+ const { getOverdueDispatches } = require(libPath);
255
+ overdue = getOverdueDispatches(workspaceRoot, opts.now);
256
+ } catch (_err) {
257
+ // If dispatch-tracking is missing but we have lost-dispatches from
258
+ // worker-ready, still surface those.
259
+ return lostBlock;
260
+ }
261
+
262
+ if ((!Array.isArray(overdue) || overdue.length === 0) && !lostBlock) return null;
263
+
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;
283
+ }
284
+
285
+ module.exports = {
286
+ isManagerSession,
287
+ buildOverdueContext,
288
+ sweepAndReconcile,
289
+ reconcileWorkerReady,
290
+ formatLostDispatchesContext
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`;