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.
- package/.claude/commands/wogi-self-adversary.md +130 -0
- package/.claude/docs/config-schema.md +219 -0
- package/package.json +2 -2
- package/scripts/flow-defer-auth.js +41 -10
- package/scripts/flow-deferral-classifier-ai.js +3 -1
- package/scripts/flow-impl-question-classifier.js +178 -0
- package/scripts/flow-self-adversary-loop.js +422 -0
- package/scripts/flow-standards-gate.js +3 -1
- package/scripts/hooks/core/deferral-classifier.js +3 -0
- package/scripts/hooks/core/deferral-gate.js +6 -3
- package/scripts/hooks/core/gate-orchestrator.js +26 -1
- package/scripts/hooks/core/pre-tool-deps.js +11 -0
- package/scripts/hooks/core/pre-tool-orchestrator.js +21 -0
- package/scripts/hooks/core/self-adversary-gate.js +295 -0
- package/scripts/hooks/core/session-start-orchestrator.js +269 -0
- package/scripts/hooks/core/stop-orchestrator.js +123 -0
- package/scripts/hooks/core/task-boundary-restart-coordinator.js +84 -0
- package/scripts/hooks/core/user-prompt-orchestrator.js +201 -0
- package/scripts/hooks/core/workspace-stop-gates.js +133 -0
- package/scripts/hooks/core/workspace-stop-notify.js +76 -0
- package/scripts/hooks/entry/claude-code/session-start.js +19 -352
- package/scripts/hooks/entry/claude-code/stop.js +10 -485
- package/scripts/hooks/entry/claude-code/user-prompt-submit.js +9 -277
|
@@ -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 };
|