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.
- package/.claude/commands/wogi-start.md +3 -3
- package/.claude/commands/wogi-story.md +27 -0
- package/.claude/docs/claude-code-compatibility.md +32 -1
- package/lib/workspace-dispatch-tracking.js +175 -0
- package/lib/workspace-messages.js +2 -0
- package/lib/workspace-routing.js +17 -0
- package/lib/workspace-worker-ready.js +190 -0
- package/package.json +2 -2
- package/scripts/flow-config-defaults.js +9 -0
- package/scripts/flow-story-gates.js +504 -0
- package/scripts/flow-story.js +205 -7
- package/scripts/hooks/adapters/claude-code.js +18 -37
- package/scripts/hooks/core/overdue-dispatches.js +291 -0
- package/scripts/hooks/core/session-context.js +17 -0
- package/scripts/hooks/core/session-start-worker.js +114 -0
- package/scripts/hooks/entry/claude-code/session-start.js +22 -0
- package/scripts/hooks/entry/claude-code/stop.js +92 -47
- package/scripts/hooks/entry/claude-code/user-prompt-submit.js +18 -0
package/scripts/flow-story.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
544
|
-
--priority <P>
|
|
545
|
-
--dry-run
|
|
546
|
-
--json
|
|
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
|
-
//
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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`;
|