wogiflow 2.22.0 → 2.22.1

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,159 @@
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
+ * Build the overdue-dispatches additionalContext block, or return null
113
+ * when nothing to surface (non-manager, no workspace root, no overdue).
114
+ *
115
+ * @param {Object} [opts]
116
+ * @param {string} [opts.workspaceRoot] — override (primarily for tests)
117
+ * @param {number} [opts.now=Date.now()]
118
+ * @returns {string|null}
119
+ */
120
+ function buildOverdueContext(opts = {}) {
121
+ const workspaceRoot = opts.workspaceRoot || process.env.WOGI_WORKSPACE_ROOT;
122
+ if (!workspaceRoot) return null;
123
+ if (!opts.workspaceRoot && !isManagerSession()) return null;
124
+
125
+ // Sweep: reconcile any pending records that match incoming messages
126
+ // before computing overdue. This prevents false positives for workers
127
+ // whose completion/stop message arrived while the manager was idle.
128
+ try { sweepAndReconcile(workspaceRoot); }
129
+ catch (_err) { /* fail-open */ }
130
+
131
+ let overdue;
132
+ try {
133
+ const libPath = path.resolve(__dirname, '..', '..', '..', 'lib', 'workspace-dispatch-tracking.js');
134
+ const { getOverdueDispatches } = require(libPath);
135
+ overdue = getOverdueDispatches(workspaceRoot, opts.now);
136
+ } catch (_err) {
137
+ return null; // Fail-open — tracking module missing or IO failure should never block the prompt.
138
+ }
139
+
140
+ if (!Array.isArray(overdue) || overdue.length === 0) return null;
141
+
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');
153
+ }
154
+
155
+ module.exports = {
156
+ isManagerSession,
157
+ buildOverdueContext,
158
+ sweepAndReconcile
159
+ };
@@ -61,57 +61,84 @@ runHook('Stop', async ({ parsedInput }) => {
61
61
  };
62
62
  }
63
63
 
64
- // Workspace worker: send results to manager via HTTP when stopping.
65
- // Uses execFileSync with array args to avoid shell injection (finding-001).
66
- if (process.env.WOGI_MANAGER_PORT && process.env.WOGI_REPO_NAME && process.env.WOGI_REPO_NAME !== 'manager') {
64
+ // Workspace worker: write a structured `worker-stopped` message to the
65
+ // workspace message bus when stopping. This is the graceful-stop half of
66
+ // silent-halt detection (wf-d3e67abe) the manager's overdue check uses
67
+ // this (vs. task-complete vs. nothing) to tell "finished" from "gave up
68
+ // gracefully" from "died silently".
69
+ //
70
+ // Replaces the previous plain-text curl POST to the manager channel — that
71
+ // was fire-and-forget with no structure, so manager-side reconciliation
72
+ // couldn't distinguish graceful stops from silent deaths.
73
+ if (process.env.WOGI_REPO_NAME && process.env.WOGI_REPO_NAME !== 'manager') {
67
74
  try {
68
- const { execFileSync, execSync } = require('node:child_process');
69
- const path = require('node:path');
75
+ const nodePath = require('node:path');
76
+ const childProcess = require('node:child_process');
70
77
  const VALID_NAME = /^[a-zA-Z0-9_-]{1,64}$/;
71
78
  const repoName = process.env.WOGI_REPO_NAME;
72
- const managerPort = parseInt(process.env.WOGI_MANAGER_PORT, 10);
73
79
 
74
- // Validate inputs before using them (finding-001, finding-002)
75
- if (!VALID_NAME.test(repoName) || !Number.isInteger(managerPort) || managerPort < 1024 || managerPort > 65535) {
76
- throw new Error(`Invalid WOGI_REPO_NAME or WOGI_MANAGER_PORT`);
80
+ if (!VALID_NAME.test(repoName)) {
81
+ throw new Error(`Invalid WOGI_REPO_NAME`);
77
82
  }
78
83
 
79
- // Build summary from available state
80
- const summaryParts = [];
81
- const { PATHS, safeJsonParse } = require('../../flow-utils');
84
+ const workspaceRoot = process.env.WOGI_WORKSPACE_ROOT;
85
+ if (workspaceRoot) {
86
+ const { PATHS, safeJsonParse } = require('../../flow-utils');
87
+ const ready = safeJsonParse(nodePath.join(PATHS.state, 'ready.json'), {});
88
+ const recentTask = (ready.recentlyCompleted || [])[0];
89
+ const inProgressTask = (ready.inProgress || [])[0];
90
+ const mostRecent = recentTask || inProgressTask;
82
91
 
83
- const ready = safeJsonParse(path.join(PATHS.state, 'ready.json'), {});
84
- const recentTask = (ready.recentlyCompleted || [])[0];
85
- const inProgressTask = (ready.inProgress || [])[0];
86
- const task = recentTask || inProgressTask;
92
+ // Determine worker state at stop-time
93
+ const hasInProgress = Boolean(inProgressTask);
94
+ const state = hasInProgress ? 'mid-work' : 'idle';
95
+ const taskInProgress = hasInProgress ? inProgressTask.id : null;
87
96
 
88
- if (task) {
89
- summaryParts.push(`**Task**: ${task.title || task.id}`);
90
- if (task.type) summaryParts.push(`**Type**: ${task.type}`);
91
- }
97
+ // Best-effort lastSha
98
+ let lastSha = null;
99
+ try {
100
+ lastSha = childProcess.execSync('git rev-parse --short HEAD 2>/dev/null || true', {
101
+ cwd: PATHS.root,
102
+ encoding: 'utf-8',
103
+ timeout: 2000
104
+ }).trim() || null;
105
+ } catch (_err) { /* non-critical */ }
92
106
 
93
- try {
94
- const diff = execSync('git diff --name-only HEAD 2>/dev/null || true', { cwd: PATHS.root, encoding: 'utf-8' }).trim();
95
- const staged = execSync('git diff --name-only --staged 2>/dev/null || true', { cwd: PATHS.root, encoding: 'utf-8' }).trim();
96
- const allChanged = [...new Set([...diff.split('\n'), ...staged.split('\n')].filter(Boolean))];
97
- if (allChanged.length > 0) {
98
- summaryParts.push(`**Files changed**: ${allChanged.join(', ')}`);
107
+ // Build structured message and persist via the workspace message bus.
108
+ // The worker-stopped type was added to MESSAGE_TYPES in
109
+ // workspace-messages.js (wf-d3e67abe).
110
+ try {
111
+ const libMessages = nodePath.resolve(__dirname, '..', '..', '..', '..', 'lib', 'workspace-messages');
112
+ const { createMessage, saveMessage } = require(libMessages);
113
+ const msg = createMessage({
114
+ from: repoName,
115
+ to: 'manager',
116
+ type: 'worker-stopped',
117
+ subject: hasInProgress
118
+ ? `Worker stopped mid-work on ${taskInProgress}`
119
+ : `Worker stopped (idle)`,
120
+ body: [
121
+ `Worker "${repoName}" is stopping.`,
122
+ `State: ${state}`,
123
+ taskInProgress ? `Task in progress: ${taskInProgress}` : null,
124
+ mostRecent?.title ? `Most recent task: ${mostRecent.title}` : null,
125
+ lastSha ? `Last commit: ${lastSha}` : null
126
+ ].filter(Boolean).join('\n'),
127
+ priority: hasInProgress ? 'high' : 'medium',
128
+ actionRequired: hasInProgress
129
+ });
130
+ // Attach structured fields the manager-side reconciler consumes.
131
+ msg.taskId = taskInProgress;
132
+ msg.reason = 'graceful';
133
+ msg.state = state;
134
+ msg.taskInProgress = taskInProgress;
135
+ msg.lastSha = lastSha;
136
+ saveMessage(workspaceRoot, msg);
137
+ } catch (err) {
138
+ if (process.env.DEBUG) {
139
+ console.error(`[Stop] Workspace message write failed: ${err.message}`);
140
+ }
99
141
  }
100
- } catch (_err) { /* non-critical */ }
101
-
102
- const body = summaryParts.join('\n') || `Work completed by ${repoName}.`;
103
-
104
- // execFileSync with array args — no shell interpretation (finding-001 fix)
105
- try {
106
- execFileSync('curl', [
107
- '-s', '-X', 'POST',
108
- `http://127.0.0.1:${managerPort}`,
109
- '-H', 'Content-Type: text/plain',
110
- '-H', `X-Wogi-From: ${repoName}`,
111
- '--data-binary', '@-'
112
- ], { input: body, timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
113
- } catch (_err) {
114
- // Manager might be offline — that's OK
115
142
  }
116
143
  } catch (err) {
117
144
  if (process.env.DEBUG) {
@@ -168,14 +195,32 @@ runHook('Stop', async ({ parsedInput }) => {
168
195
  }
169
196
 
170
197
  const restartResult = consumeAndTriggerRestart();
171
- if (restartResult.triggered && process.env.DEBUG) {
172
- console.error(`[Stop] Task-boundary restart triggered — claude will exit, wrapper will relaunch`);
173
- } else if (!restartResult.triggered && restartResult.reason !== 'no-pending-marker' && process.env.DEBUG) {
198
+ if (restartResult.triggered) {
199
+ if (process.env.DEBUG) {
200
+ console.error(`[Stop] Task-boundary restart triggered claude will exit, wrapper will relaunch`);
201
+ }
202
+ // CRITICAL: return NOW, short-circuiting subsequent stop-blocking gates.
203
+ //
204
+ // Before this fix (observed 2026-04-17): Phase 2 would SIGTERM claude and
205
+ // write the restart flag, then fall through to the workspace autopickup
206
+ // gate (lines below). For a worker with queued dispatches (the common
207
+ // case), that gate returns `{ continue: true, stopReason: ... }` which
208
+ // Claude Code honours as "don't stop, pick up next dispatch." Result: the
209
+ // SIGTERM + restart flag became a no-op because claude was told to keep
210
+ // running in the SAME session. Symptom: single claude PID survives across
211
+ // N tasks, context accumulates, tokens burn — exactly the complaint this
212
+ // feature was supposed to solve.
213
+ //
214
+ // The restart is our stop path. The next session's SessionStart hook will
215
+ // inject queued-dispatch context, so the worker picks up the next task
216
+ // on RESTART rather than via the autopickup gate's continue-override.
217
+ // __raw skips the adapter transform — we want the literal {continue:false}
218
+ // wire format to reach claude unchanged.
219
+ return { __raw: true, continue: false };
220
+ }
221
+ if (restartResult.reason !== 'no-pending-marker' && process.env.DEBUG) {
174
222
  console.error(`[Stop] Task-boundary restart check: ${restartResult.reason}`);
175
223
  }
176
- // If we SIGTERM'd our parent, the process will begin shutting down. Still
177
- // return the normal Stop-hook result so any in-flight return value flows
178
- // back to claude before the signal is handled.
179
224
  } catch (err) {
180
225
  if (process.env.DEBUG) {
181
226
  console.error(`[Stop] Task-boundary restart module error (fail-open): ${err.message}`);