wogiflow 2.30.4 → 2.31.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.
@@ -0,0 +1,295 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Wogi Flow — Self-Adversary PreToolUse Gate (wf-e399bd8d)
5
+ *
6
+ * Intercepts AskUserQuestion tool calls. If the question classifier
7
+ * returns IMPLEMENTATION with high confidence AND no recent self-
8
+ * adversary loop completion marker exists, BLOCK the call with
9
+ * instructions to run the loop first.
10
+ *
11
+ * State markers used:
12
+ * .workflow/state/self-adversary-complete.json
13
+ * Written by the loop when it produces a confident decision.
14
+ * Single-use; cleared on consumption.
15
+ * Shape:
16
+ * {
17
+ * completedAt: ISO timestamp,
18
+ * questionHash: SHA-256-hex (first 16) of the original question,
19
+ * decision, confidence, iterationCount,
20
+ * expiresAt: ISO timestamp (5 min TTL)
21
+ * }
22
+ *
23
+ * .workflow/state/self-adversary-escalation.json
24
+ * Written by the loop when iteration exhausts. Indicates the AI
25
+ * DID iterate but still needs the user. Allows AskUserQuestion to
26
+ * pass through without re-running the loop. Single-use, 5 min TTL.
27
+ *
28
+ * Note: the classifier is async (Haiku call). PreToolUse hooks must
29
+ * return promptly. Two options:
30
+ * A) Block all AskUserQuestion calls if classifier hasn't pre-run,
31
+ * requiring the AI to explicitly invoke the loop first.
32
+ * B) Run classifier inline (async) and block based on result.
33
+ *
34
+ * Approach: (A) primary path, with a synchronous heuristic fallback
35
+ * that catches obvious implementation phrasings. The synchronous
36
+ * heuristic uses keyword presence (NOT user-input parsing — this is
37
+ * AI-authored question text, the "no regex on user answers" rule
38
+ * doesn't apply). The classifier itself is invoked from the user-
39
+ * prompt-submit hook (where async is fine) OR by the AI explicitly
40
+ * via the wogi-self-adversary skill.
41
+ *
42
+ * Fail-open: any error → allow the AskUserQuestion through.
43
+ */
44
+
45
+ const fs = require('node:fs');
46
+ const path = require('node:path');
47
+ const crypto = require('node:crypto');
48
+
49
+ const { PATHS } = require('../../flow-utils');
50
+ const { safeJsonParse } = require('../../flow-io');
51
+
52
+ const COMPLETE_FILE = 'self-adversary-complete.json';
53
+ const ESCALATION_FILE = 'self-adversary-escalation.json';
54
+ const DEFAULT_TTL_SECONDS = 300; // 5 min
55
+
56
+ function getCompletePath() { return path.join(PATHS.state, COMPLETE_FILE); }
57
+ function getEscalationPath() { return path.join(PATHS.state, ESCALATION_FILE); }
58
+
59
+ function hashQuestion(text) {
60
+ if (typeof text !== 'string') return '';
61
+ // wf-6e31850e (S-4): use 32-char (128-bit) instead of 16-char (64-bit).
62
+ // 64-bit was below NIST collision-resistance recommendation; 128-bit is
63
+ // standard. Birthday-bound collision moves from ~2^32 to ~2^64 questions.
64
+ return crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
65
+ }
66
+
67
+ function isGateEnabled(config) {
68
+ const cfg = config?.selfAdversaryGate;
69
+ if (cfg === false) return false;
70
+ if (cfg && typeof cfg === 'object' && cfg.enabled === false) return false;
71
+ return true;
72
+ }
73
+
74
+ function loadMarker(filePath) {
75
+ const data = safeJsonParse(filePath, null);
76
+ if (!data || typeof data !== 'object') return null;
77
+ if (data.expiresAt) {
78
+ const exp = Date.parse(data.expiresAt);
79
+ if (Number.isFinite(exp) && exp < Date.now()) return null;
80
+ }
81
+ return data;
82
+ }
83
+
84
+ function consumeMarker(filePath) {
85
+ try { fs.unlinkSync(filePath); } catch (_err) { /* fine */ }
86
+ }
87
+
88
+ function writeCompletionMarker({ question, decision, confidence, iterationCount, ttlSec }) {
89
+ try {
90
+ const ttl = Number.isFinite(ttlSec) ? ttlSec : DEFAULT_TTL_SECONDS;
91
+ const now = Date.now();
92
+ const payload = {
93
+ version: 1,
94
+ completedAt: new Date(now).toISOString(),
95
+ expiresAt: new Date(now + ttl * 1000).toISOString(),
96
+ questionHash: hashQuestion(question),
97
+ decision: typeof decision === 'string' ? decision.slice(0, 500) : '',
98
+ confidence: Number.isFinite(confidence) ? Math.round(confidence) : 0,
99
+ iterationCount: Number.isFinite(iterationCount) ? iterationCount : 0
100
+ };
101
+ fs.mkdirSync(path.dirname(getCompletePath()), { recursive: true });
102
+ fs.writeFileSync(getCompletePath(), JSON.stringify(payload, null, 2));
103
+ return payload;
104
+ } catch (err) {
105
+ if (process.env.DEBUG) {
106
+ console.error(`[self-adversary-gate] writeCompletionMarker failed: ${err.message}`);
107
+ }
108
+ return null;
109
+ }
110
+ }
111
+
112
+ function writeEscalationMarker({ question, decision, confidence, iterationCount, reason, ttlSec }) {
113
+ try {
114
+ const ttl = Number.isFinite(ttlSec) ? ttlSec : DEFAULT_TTL_SECONDS;
115
+ const now = Date.now();
116
+ const payload = {
117
+ version: 1,
118
+ escalatedAt: new Date(now).toISOString(),
119
+ expiresAt: new Date(now + ttl * 1000).toISOString(),
120
+ questionHash: hashQuestion(question),
121
+ reason: typeof reason === 'string' ? reason : 'unknown',
122
+ bestDecision: typeof decision === 'string' ? decision.slice(0, 500) : '',
123
+ finalConfidence: Number.isFinite(confidence) ? Math.round(confidence) : 0,
124
+ iterationCount: Number.isFinite(iterationCount) ? iterationCount : 0
125
+ };
126
+ fs.mkdirSync(path.dirname(getEscalationPath()), { recursive: true });
127
+ fs.writeFileSync(getEscalationPath(), JSON.stringify(payload, null, 2));
128
+ return payload;
129
+ } catch (err) {
130
+ if (process.env.DEBUG) {
131
+ console.error(`[self-adversary-gate] writeEscalationMarker failed: ${err.message}`);
132
+ }
133
+ return null;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Synchronous heuristic: does this question text LOOK implementation-class?
139
+ * Used as a fallback when the async classifier hasn't run. Conservative —
140
+ * defaults to NOT-implementation when ambiguous so AskUserQuestion passes.
141
+ * This is NOT user-input parsing (the text is AI-authored), so keyword
142
+ * matching is acceptable here. The async classifier provides the
143
+ * authoritative answer; this heuristic just catches the obvious cases.
144
+ */
145
+ const IMPLEMENTATION_HEURISTIC_KEYWORDS = [
146
+ /\bmap\(\)\s+(?:or|vs)\s+for(?:-loop)?/i,
147
+ /\bwhich\s+(?:library|framework|algorithm|approach|pattern)\b/i,
148
+ /\bshould\s+(?:i|we)\s+use\s+\w+\s+or\s+\w+/i,
149
+ /\b(?:naming|name)\s+(?:convention|this|the\s+\w+)/i,
150
+ /\b(?:refactor|extract|inline)\s+(?:this|the)\b/i,
151
+ /\btest\s+(?:framework|library)\b/i,
152
+ /\berror\s+handling\s+(?:approach|pattern|style)/i,
153
+ /\bcode\s+(?:style|organization|structure)\b/i
154
+ ];
155
+
156
+ const PRODUCT_HEURISTIC_KEYWORDS = [
157
+ /\bwhat\s+(?:should|do)\s+(?:users?|customers?)\b/i,
158
+ /\b(?:business|product)\s+(?:rule|decision|requirement)\b/i,
159
+ /\bcounts?\s+as\s+(?:done|complete|valid)\b/i,
160
+ /\bwhich\s+(?:behavior|outcome)\s+(?:do\s+you|should)\s+(?:want|prefer)\b/i,
161
+ /\b(?:delete|drop|truncate|remove|destroy)\b.*\b(?:data|table|migration|user)/i
162
+ ];
163
+
164
+ function heuristicCategory(questionText) {
165
+ if (typeof questionText !== 'string') return 'unknown';
166
+ for (const re of PRODUCT_HEURISTIC_KEYWORDS) {
167
+ if (re.test(questionText)) return 'product';
168
+ }
169
+ for (const re of IMPLEMENTATION_HEURISTIC_KEYWORDS) {
170
+ if (re.test(questionText)) return 'implementation';
171
+ }
172
+ return 'unknown';
173
+ }
174
+
175
+ /**
176
+ * PreToolUse intercept on AskUserQuestion. Returns { blocked: bool, message? }.
177
+ *
178
+ * Decision tree:
179
+ * 1. Gate disabled → allow.
180
+ * 2. Tool is not AskUserQuestion → allow.
181
+ * 3. Escalation marker present for this question → allow (loop already ran).
182
+ * 4. Completion marker present for this question → allow (AI may follow up).
183
+ * 5. Sync heuristic → 'implementation' → block with loop-first instructions.
184
+ * 6. Otherwise → allow.
185
+ *
186
+ * The classifier itself (Haiku call) lives in flow-impl-question-classifier.js
187
+ * and is invoked by the `wogi-self-adversary` skill or by the user-prompt-
188
+ * submit hook for upstream classification — NOT from this synchronous gate.
189
+ */
190
+ function checkSelfAdversaryGate(toolName, toolInput, config) {
191
+ try {
192
+ if (!isGateEnabled(config)) return { blocked: false };
193
+ if (toolName !== 'AskUserQuestion') return { blocked: false };
194
+
195
+ // Extract question text from the tool input shape (Claude Code's
196
+ // AskUserQuestion accepts a `questions` array).
197
+ let questionText = '';
198
+ if (toolInput && Array.isArray(toolInput.questions) && toolInput.questions.length > 0) {
199
+ const parts = [];
200
+ for (const q of toolInput.questions) {
201
+ if (q && typeof q.question === 'string') parts.push(q.question);
202
+ }
203
+ questionText = parts.join('\n');
204
+ } else if (toolInput && typeof toolInput.prompt === 'string') {
205
+ questionText = toolInput.prompt;
206
+ }
207
+ if (!questionText.trim()) return { blocked: false };
208
+
209
+ const qHash = hashQuestion(questionText);
210
+
211
+ // Check escalation marker
212
+ const escalation = loadMarker(getEscalationPath());
213
+ if (escalation && escalation.questionHash === qHash) {
214
+ // Consume and allow — the loop already ran and confirmed user is needed.
215
+ consumeMarker(getEscalationPath());
216
+ return { blocked: false, reason: 'escalation-marker-consumed' };
217
+ }
218
+
219
+ // Check completion marker — AI already decided via loop, this AskUserQuestion
220
+ // is a follow-up (e.g., "I decided X, but did you want Y instead?")
221
+ const complete = loadMarker(getCompletePath());
222
+ if (complete && complete.questionHash === qHash) {
223
+ consumeMarker(getCompletePath());
224
+ return { blocked: false, reason: 'completion-marker-consumed' };
225
+ }
226
+
227
+ // Sync heuristic
228
+ const heuristic = heuristicCategory(questionText);
229
+ if (heuristic !== 'implementation') {
230
+ return { blocked: false, reason: `heuristic-${heuristic}` };
231
+ }
232
+
233
+ // Heuristic says implementation — block and require loop.
234
+ return {
235
+ blocked: true,
236
+ reason: 'implementation-heuristic',
237
+ message: buildBlockMessage(questionText, qHash)
238
+ };
239
+ } catch (err) {
240
+ if (process.env.DEBUG) {
241
+ console.error(`[self-adversary-gate] checkSelfAdversaryGate error (fail-open): ${err.message}`);
242
+ }
243
+ return { blocked: false };
244
+ }
245
+ }
246
+
247
+ function buildBlockMessage(questionText, qHash) {
248
+ const preview = questionText.slice(0, 240);
249
+ return [
250
+ 'BLOCKED: AskUserQuestion looks like an implementation-class question.',
251
+ '',
252
+ 'WogiFlow user directive (wf-e399bd8d): when you have doubt about an',
253
+ 'implementation decision (code structure, library choice, naming,',
254
+ 'refactor mechanics, etc.), self-adversary FIRST — iterate generator',
255
+ 'and adversary on different models until ≥95% confidence. Only escalate',
256
+ 'to the user if confidence stays low after the loop.',
257
+ '',
258
+ `Question intercepted: "${preview}${questionText.length > 240 ? '…' : ''}"`,
259
+ `Question hash: ${qHash}`,
260
+ '',
261
+ 'How to proceed:',
262
+ ' 1. RECOMMENDED — invoke the self-adversary skill:',
263
+ ' Skill(skill="wogi-self-adversary", args="<the question + brief context>")',
264
+ ' The skill runs the loop, writes a completion or escalation marker,',
265
+ ' then either acts on the high-confidence decision or re-issues the',
266
+ ' AskUserQuestion (which will now pass).',
267
+ '',
268
+ ' 2. ESCAPE HATCH — if this is genuinely product / architecture /',
269
+ ' sensitive, the heuristic is wrong. Re-phrase the question to make',
270
+ ' the product-domain nature explicit (e.g., reference the user as',
271
+ ' decision-maker, name business constraints), and try again.',
272
+ '',
273
+ ' 3. OVERRIDE — set the question metadata to bypass (advanced only).',
274
+ '',
275
+ 'See: scripts/hooks/core/self-adversary-gate.js, wf-e399bd8d.'
276
+ ].join('\n');
277
+ }
278
+
279
+ module.exports = {
280
+ checkSelfAdversaryGate,
281
+ hashQuestion,
282
+ isGateEnabled,
283
+ loadMarker,
284
+ consumeMarker,
285
+ writeCompletionMarker,
286
+ writeEscalationMarker,
287
+ heuristicCategory,
288
+ getCompletePath,
289
+ getEscalationPath,
290
+ COMPLETE_FILE,
291
+ ESCALATION_FILE,
292
+ DEFAULT_TTL_SECONDS,
293
+ IMPLEMENTATION_HEURISTIC_KEYWORDS,
294
+ PRODUCT_HEURISTIC_KEYWORDS
295
+ };
@@ -0,0 +1,269 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Wogi Flow — SessionStart Orchestrator (wf-6e31850e A-3)
5
+ *
6
+ * Extracted from scripts/hooks/entry/claude-code/session-start.js to bring
7
+ * that entry file under the 120-LOC budget per
8
+ * .claude/rules/architecture/hook-three-layer.md.
9
+ *
10
+ * Same control flow as before. Entry session-start.js is now a thin
11
+ * pass-through that imports boot-instrumentation helpers + this orchestrator.
12
+ */
13
+
14
+ const { gatherSessionContext } = require('./session-context');
15
+ const { setCliSessionId, clearStaleCurrentTaskAsync, resetSessionTaskCounter } = require('../../flow-session-state');
16
+ const { checkAndResetStalePhase } = require('./phase-gate');
17
+ const { setRoutingPending } = require('./routing-gate');
18
+ const { getConfig } = require('../../flow-utils');
19
+
20
+ let autoSyncBridge = null;
21
+ function getAutoSyncBridge() {
22
+ if (!autoSyncBridge) {
23
+ try {
24
+ autoSyncBridge = require('../../flow-bridge-state').autoSyncBridge;
25
+ } catch (_err) {
26
+ autoSyncBridge = async () => ({ synced: false, reason: 'unavailable' });
27
+ }
28
+ }
29
+ return autoSyncBridge;
30
+ }
31
+
32
+ async function orchestrateSessionStart({ parsedInput, bootMark, bootTime }) {
33
+ bootMark('SessionStart hook entered');
34
+
35
+ const bridgeSyncPromise = bootTime('bridge auto-sync', async () => {
36
+ try {
37
+ const syncFn = getAutoSyncBridge();
38
+ await syncFn('claude-code', { silent: true });
39
+ } catch (err) {
40
+ if (process.env.DEBUG) console.error(`[session-start] Bridge auto-sync failed: ${err.message}`);
41
+ }
42
+ });
43
+ await bridgeSyncPromise;
44
+ bootMark('after bridge sync');
45
+
46
+ // wf-b8839d99: Refresh standing no-defer pin from decisions.md policy.
47
+ try {
48
+ const { refreshFromPolicy } = require('./no-defer-policy');
49
+ const r = refreshFromPolicy();
50
+ if (r.refreshed && process.env.DEBUG) {
51
+ console.error(`[session-start] Refreshed no-defer pin from policy: ${r.header}`);
52
+ }
53
+ } catch (err) {
54
+ if (process.env.DEBUG) console.error(`[session-start] no-defer policy refresh failed: ${err.message}`);
55
+ }
56
+
57
+ // CLAUDE.md drift detection
58
+ let driftDetected = false;
59
+ let driftMarkerMissing = false;
60
+ try {
61
+ const { checkClaudeMdDrift } = require('../../flow-bridge-state');
62
+ const drift = checkClaudeMdDrift();
63
+ if (drift.drifted && drift.reason === 'content-changed') {
64
+ if (process.env.DEBUG) console.error('[session-start] CLAUDE.md drift detected — content changed since last sync');
65
+ driftDetected = true;
66
+ } else if (drift.drifted && drift.reason === 'marker-missing') {
67
+ if (process.env.DEBUG) console.error('[session-start] CLAUDE.md appears manually maintained (no generation marker)');
68
+ driftDetected = true;
69
+ driftMarkerMissing = true;
70
+ }
71
+ } catch (err) {
72
+ if (process.env.DEBUG) console.error(`[session-start] Drift detection failed: ${err.message}`);
73
+ }
74
+
75
+ // Version compatibility checks (parallelized)
76
+ let versionWarning = null;
77
+ let updateWarning = null;
78
+ await bootTime('version checks', async () => {
79
+ try {
80
+ const { checkClaudeCodeVersionOnce, checkWogiFlowUpdateOnce } = require('../../flow-version-check');
81
+ const [vw, uw] = await Promise.all([
82
+ (async () => { try { return await checkClaudeCodeVersionOnce(); } catch (_err) { return null; } })(),
83
+ (async () => { try { return await checkWogiFlowUpdateOnce(); } catch (_err) { return null; } })()
84
+ ]);
85
+ versionWarning = vw;
86
+ updateWarning = uw;
87
+ } catch (err) {
88
+ if (process.env.DEBUG) console.error(`[session-start] Version check failed: ${err.message}`);
89
+ }
90
+ });
91
+ bootMark('after version checks');
92
+
93
+ // Batch 1: Independent pre-context operations
94
+ let scriptWarnings = [];
95
+ try {
96
+ const wasReset = checkAndResetStalePhase();
97
+ if (wasReset && process.env.DEBUG) console.error('[session-start] Reset stale workflow phase to idle');
98
+ } catch (err) {
99
+ if (process.env.DEBUG) console.error(`[session-start] Failed to check stale phase: ${err.message}`);
100
+ }
101
+ try { resetSessionTaskCounter(); } catch (_err) { /* non-blocking */ }
102
+ try {
103
+ const routingResult = setRoutingPending();
104
+ if (process.env.DEBUG) console.error(`[session-start] Set routing-pending: ${routingResult.reason}`);
105
+ } catch (err) {
106
+ if (process.env.DEBUG) console.error(`[session-start] Failed to set routing-pending: ${err.message}`);
107
+ }
108
+ try {
109
+ const { validateScripts } = require('../../flow-script-resolver');
110
+ scriptWarnings = validateScripts();
111
+ } catch (err) {
112
+ if (process.env.DEBUG) console.error(`[session-start] Script validation failed: ${err.message}`);
113
+ }
114
+
115
+ // BUG-005: Create durable-session.json for active tasks on session start.
116
+ try {
117
+ const { getReadyData } = require('../../flow-utils');
118
+ const readyData = getReadyData();
119
+ if (Array.isArray(readyData.inProgress) && readyData.inProgress.length > 0) {
120
+ const task = readyData.inProgress[0];
121
+ const taskId = task && task.id;
122
+ if (taskId) {
123
+ const { loadDurableSession, createDurableSession } = require('../../flow-durable-session');
124
+ const existing = loadDurableSession();
125
+ if (!existing || existing.taskId !== taskId) {
126
+ const criteria = task.acceptanceCriteria || task.scenarios || [];
127
+ const steps = Array.isArray(criteria) ? criteria : [];
128
+ const sessionSteps = steps.length > 0 ? steps : [task.title || taskId];
129
+ createDurableSession(taskId, 'task', sessionSteps);
130
+ if (process.env.DEBUG) console.error(`[session-start] Created durable session for active task ${taskId}`);
131
+ }
132
+ }
133
+ }
134
+ } catch (err) {
135
+ if (process.env.DEBUG) console.error(`[session-start] Durable session init failed: ${err.message}`);
136
+ }
137
+
138
+ // Async pre-ops batched with gatherSessionContext
139
+ const asyncPreOps = [];
140
+ if (parsedInput.sessionId) {
141
+ asyncPreOps.push(setCliSessionId(parsedInput.sessionId).catch(err => {
142
+ if (process.env.DEBUG) console.error(`[session-start] Failed to store session ID: ${err.message}`);
143
+ }));
144
+ }
145
+ asyncPreOps.push(clearStaleCurrentTaskAsync().catch(err => {
146
+ if (process.env.DEBUG) console.error(`[session-start] Failed to clear stale task: ${err.message}`);
147
+ }));
148
+
149
+ bootMark('before gatherSessionContext + asyncPreOps');
150
+ const [, coreResult] = await Promise.all([
151
+ Promise.all(asyncPreOps),
152
+ bootTime('gatherSessionContext', () => gatherSessionContext({
153
+ includeSuspended: true,
154
+ includeDecisions: true,
155
+ includeActivity: true
156
+ }))
157
+ ]);
158
+ bootMark('after gatherSessionContext + asyncPreOps');
159
+
160
+ // Batch 2: Post-context — plugin scan + community pull (parallel, non-blocking)
161
+ await bootTime('postContextOps (plugin-scan + community-pull)', () => Promise.all([
162
+ runPluginAutoScan(coreResult),
163
+ runCommunityPull(coreResult)
164
+ ]));
165
+ bootMark('after postContextOps');
166
+
167
+ // Inject warnings into context
168
+ if (scriptWarnings.length > 0 && coreResult?.context) {
169
+ coreResult.context.scriptWarnings = scriptWarnings.map(w => w.message);
170
+ }
171
+ if (versionWarning && coreResult?.context) coreResult.context.versionWarning = versionWarning;
172
+ if (updateWarning && coreResult?.context) coreResult.context.updateWarning = updateWarning;
173
+ if (driftDetected && coreResult?.context) {
174
+ coreResult.context.driftWarning = driftMarkerMissing
175
+ ? 'CLAUDE.md appears to have been manually edited (generation marker missing). Was this intentional? If yes, WogiFlow will respect your custom CLAUDE.md. If not, run `flow bridge sync` to regenerate from template.'
176
+ : 'CLAUDE.md content has changed since the last bridge sync. Was this intentional? If yes, WogiFlow will preserve your changes. If not, run `flow bridge sync` to regenerate from template.';
177
+ }
178
+
179
+ // State file drift detection
180
+ try {
181
+ const { detectDrift, saveSnapshot, formatDriftReport } = require('../../flow-state-drift-detector');
182
+ const driftResult = detectDrift();
183
+ if (driftResult.hasDrift && coreResult?.context) {
184
+ coreResult.context.stateDriftWarning = formatDriftReport(driftResult);
185
+ }
186
+ saveSnapshot();
187
+ } catch (_err) {
188
+ if (process.env.DEBUG) console.error(`[session-start] State drift detection failed: ${_err.message}`);
189
+ }
190
+
191
+ // Workspace worker restart-handoff
192
+ await bootTime('worker session-start handler', async () => {
193
+ try {
194
+ const { handleWorkerSessionStart } = require('./session-start-worker');
195
+ const workerResult = handleWorkerSessionStart();
196
+ if (workerResult.context && coreResult?.context) {
197
+ if (workerResult.branch === 'auto-resume') {
198
+ coreResult.context.workerAutoResume = workerResult.context;
199
+ } else if (workerResult.branch === 'announce-ready') {
200
+ coreResult.context.workerReadyAnnounce = workerResult.context;
201
+ }
202
+ }
203
+ } catch (err) {
204
+ if (process.env.DEBUG) console.error(`[session-start] Worker session-start handler failed: ${err.message}`);
205
+ }
206
+ });
207
+ bootMark('SessionStart hook returning');
208
+
209
+ return coreResult;
210
+ }
211
+
212
+ async function runPluginAutoScan(coreResult) {
213
+ try {
214
+ const config = getConfig();
215
+ if (!config.plugins?.enabled || !config.plugins?.autoScanOnSessionStart) return;
216
+ const { scanUnregisteredMcpServers, registerPlugin, deactivateStaleMcpPlugins, listPlugins } = require('../../flow-plugin-registry');
217
+
218
+ const unregistered = scanUnregisteredMcpServers();
219
+ for (const server of unregistered) {
220
+ registerPlugin({
221
+ name: server.serverName,
222
+ description: `Auto-discovered MCP server: ${server.serverName}`,
223
+ source: 'auto-scan',
224
+ triggers: [`use ${server.serverName}`, `send to ${server.serverName}`, server.serverName],
225
+ capabilities: [],
226
+ metadata: { mcpServer: server.serverName }
227
+ });
228
+ if (process.env.DEBUG) console.error(`[session-start] Auto-registered plugin: ${server.serverName}`);
229
+ }
230
+ const deactivated = deactivateStaleMcpPlugins();
231
+ if (deactivated.length > 0 && process.env.DEBUG) {
232
+ console.error(`[session-start] Deactivated ${deactivated.length} stale plugin(s): ${deactivated.join(', ')}`);
233
+ }
234
+ if (coreResult?.context) {
235
+ const activePlugins = listPlugins({ activeOnly: true });
236
+ if (unregistered.length > 0 || activePlugins.length > 0) {
237
+ coreResult.context.pluginScan = {
238
+ newlyRegistered: unregistered.map(s => s.serverName),
239
+ activePlugins: activePlugins.map(p => ({ name: p.name, capabilities: (p.capabilities || []).length }))
240
+ };
241
+ }
242
+ }
243
+ } catch (err) {
244
+ if (process.env.DEBUG) console.error(`[session-start] Plugin auto-scan failed: ${err.message}`);
245
+ }
246
+ }
247
+
248
+ async function runCommunityPull(coreResult) {
249
+ try {
250
+ const communityConfig = getConfig();
251
+ if (!communityConfig.community?.enabled) return;
252
+ const community = require('../../flow-community');
253
+ community.retryPendingSuggestions(communityConfig).catch(() => {});
254
+ if (communityConfig.community?.pullOnSessionStart !== false) {
255
+ const knowledge = await community.pullFromServer(communityConfig);
256
+ if (knowledge && coreResult?.context) {
257
+ coreResult.context.communityKnowledge = knowledge;
258
+ try { community.mergeCommunityKnowledge(knowledge, communityConfig); }
259
+ catch (err) {
260
+ if (process.env.DEBUG) console.error(`[session-start] Community merge failed: ${err.message}`);
261
+ }
262
+ }
263
+ }
264
+ } catch (err) {
265
+ if (process.env.DEBUG) console.error(`[session-start] Community pull failed: ${err.message}`);
266
+ }
267
+ }
268
+
269
+ module.exports = { orchestrateSessionStart };
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Wogi Flow — Stop-Hook Orchestrator (wf-6e31850e A-3)
5
+ *
6
+ * Extracted from scripts/hooks/entry/claude-code/stop.js to bring that entry
7
+ * file under the 120-LOC budget per .claude/rules/architecture/hook-three-layer.md.
8
+ *
9
+ * Same control flow as before; just moved here. Entry stop.js is now a thin
10
+ * pass-through that imports and delegates.
11
+ *
12
+ * Returns the Stop-hook result `{ __raw?, continue?, stopReason?, ... }`.
13
+ */
14
+
15
+ const { isRoutingPending, incrementStopAttempts } = require('./routing-gate');
16
+ const { checkLoopExit } = require('./loop-check');
17
+
18
+ async function orchestrateStop({ parsedInput }) {
19
+ const activeGates = {};
20
+ try {
21
+ const { isLongInputPending } = require('./long-input-enforcement');
22
+ activeGates['long-input-pending'] = isLongInputPending();
23
+ } catch (_err) { /* fail-open */ }
24
+
25
+ let recoveryGraceActive = false;
26
+ try {
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+ const { PATHS } = require('../../flow-utils');
30
+ const gracePath = path.join(PATHS.state, 'routing-recovery-grace.json');
31
+ if (fs.existsSync(gracePath)) {
32
+ const raw = fs.readFileSync(gracePath, 'utf-8');
33
+ const data = JSON.parse(raw);
34
+ if (data?.expiresAt && Date.parse(data.expiresAt) > Date.now()) {
35
+ recoveryGraceActive = true;
36
+ } else {
37
+ try { fs.unlinkSync(gracePath); } catch (_err) { /* fine */ }
38
+ }
39
+ }
40
+ } catch (_err) { /* fail-open */ }
41
+
42
+ let orchestratorTopGate = null;
43
+ try {
44
+ const { pickStopHookGate } = require('./gate-orchestrator');
45
+ const { topGateId } = pickStopHookGate({
46
+ 'long-input-pending': activeGates['long-input-pending'] === true
47
+ });
48
+ orchestratorTopGate = topGateId;
49
+ } catch (_err) { /* fail-open */ }
50
+ const longInputActive = orchestratorTopGate === 'long-input-pending';
51
+
52
+ try {
53
+ if (isRoutingPending() && !longInputActive && !recoveryGraceActive) {
54
+ const { cleared, attempts } = incrementStopAttempts(10);
55
+ if (cleared) {
56
+ if (process.env.DEBUG) {
57
+ console.error(`[Stop] Max routing enforcement attempts reached (${attempts}), surfacing to user`);
58
+ }
59
+ return {
60
+ __raw: true,
61
+ continue: false,
62
+ stopReason: `ROUTING VIOLATION (unrecoverable): max ${attempts} attempts exceeded. The AI failed to call Skill(skill="wogi-start") after ${attempts} stop attempts. Manual review required — this may indicate a stuck routing flag or a session that bypassed /wogi-start through context compaction. To unstick: invoke /wogi-start manually, or check .workflow/state/routing-pending.json.`
63
+ };
64
+ } else {
65
+ return {
66
+ __raw: true,
67
+ continue: true,
68
+ stopReason: [
69
+ `ROUTING VIOLATION (attempt ${attempts}/10): You MUST call Skill(skill="wogi-start") before responding.`,
70
+ '',
71
+ 'Call Skill(skill="wogi-start", args="<user\'s message>") NOW. No text. No explanation. Just the Skill tool call.'
72
+ ].join('\n')
73
+ };
74
+ }
75
+ }
76
+ } catch (err) {
77
+ if (process.env.DEBUG) {
78
+ console.error(`[Stop] Routing check error (fail-closed, forcing continue): ${err.message}`);
79
+ }
80
+ return {
81
+ __raw: true,
82
+ continue: true,
83
+ stopReason: 'Routing enforcement check encountered an error. Please invoke /wogi-start with your request.'
84
+ };
85
+ }
86
+
87
+ const workspaceNotify = require('./workspace-stop-notify');
88
+ await workspaceNotify.notifyWorkerStopped();
89
+
90
+ const restartCoordinator = require('./task-boundary-restart-coordinator');
91
+ const restartResult = await restartCoordinator.handleTaskBoundaryRestart({ parsedInput });
92
+ if (restartResult?.shouldReturn) return restartResult.result;
93
+
94
+ // Research-Required Stop-Hook Gate
95
+ try {
96
+ if (longInputActive) {
97
+ // skip — defer to long-input remediation
98
+ } else {
99
+ const { checkResearchRequiredGate } = require('./research-required-gate');
100
+ const { getConfig } = require('../../flow-utils');
101
+ const config = getConfig();
102
+ const result = checkResearchRequiredGate({
103
+ transcriptPath: parsedInput?.transcriptPath,
104
+ config
105
+ });
106
+ if (result.blocked) {
107
+ if (result.hardStop) return { __raw: true, continue: false, stopReason: result.message };
108
+ return { __raw: true, continue: true, stopReason: result.message };
109
+ }
110
+ }
111
+ } catch (err) {
112
+ if (process.env.DEBUG) console.error(`[Stop] Research-required gate error (fail-open): ${err.message}`);
113
+ }
114
+
115
+ // Workspace + worker gates
116
+ const workspaceGates = require('./workspace-stop-gates');
117
+ const wsResult = await workspaceGates.checkWorkspaceStopGates({ parsedInput });
118
+ if (wsResult?.shouldReturn) return wsResult.result;
119
+
120
+ return await checkLoopExit();
121
+ }
122
+
123
+ module.exports = { orchestrateStop };