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.
- 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 +1 -0
- package/lib/workspace-routing.js +17 -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 +159 -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,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:
|
|
65
|
-
//
|
|
66
|
-
|
|
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
|
|
69
|
-
const
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
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}`);
|