wogiflow 2.30.1 → 2.30.3
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/lib/installer.js +30 -0
- package/package.json +1 -1
- package/scripts/flow-defer-auth.js +28 -0
- package/scripts/flow-deferral-classifier-ai.js +229 -0
- package/scripts/flow-phase.js +9 -4
- package/scripts/flow-standards-gate.js +15 -0
- package/scripts/hooks/adapters/claude-code.js +24 -9
- package/scripts/hooks/core/deferral-classifier.js +111 -92
- package/scripts/hooks/core/deferral-gate.js +51 -7
- package/scripts/hooks/core/gate-orchestrator.js +104 -0
- package/scripts/hooks/core/long-input-enforcement.js +49 -0
- package/scripts/hooks/core/no-defer-policy.js +107 -0
- package/scripts/hooks/core/research-required-classifier.js +5 -1
- package/scripts/hooks/core/task-gate.js +77 -1
- package/scripts/hooks/entry/claude-code/session-start.js +14 -0
- package/scripts/hooks/entry/claude-code/stop.js +47 -1
- package/scripts/hooks/entry/claude-code/user-prompt-submit.js +12 -8
package/lib/installer.js
CHANGED
|
@@ -583,6 +583,36 @@ function createWorkflowStructure(projectRoot, config) {
|
|
|
583
583
|
}
|
|
584
584
|
}
|
|
585
585
|
|
|
586
|
+
// wf-d5fcb880 (H2): scaffold forbidden-patterns.json from its template.
|
|
587
|
+
// The template ships in the npm package (per package.json `files` →
|
|
588
|
+
// `.workflow/state/*.template`). Without this step, the standards-checker's
|
|
589
|
+
// forbidden-patterns feature is a silent no-op on fresh installs — the loader
|
|
590
|
+
// returns [] because no rule pack exists on disk. The previous review flagged
|
|
591
|
+
// this as a HIGH finding (H2 in last-review.json).
|
|
592
|
+
const forbiddenPath = path.join(workflowDir, 'state', 'forbidden-patterns.json');
|
|
593
|
+
if (!fs.existsSync(forbiddenPath)) {
|
|
594
|
+
const templatePath = path.join(workflowDir, 'state', 'forbidden-patterns.json.template');
|
|
595
|
+
if (fs.existsSync(templatePath)) {
|
|
596
|
+
try {
|
|
597
|
+
const templateContent = fs.readFileSync(templatePath, 'utf-8');
|
|
598
|
+
fs.writeFileSync(forbiddenPath, templateContent);
|
|
599
|
+
} catch (err) {
|
|
600
|
+
// Fall back to an empty array so the file exists and is parseable.
|
|
601
|
+
// safeJsonParse on this returns [] (valid empty pack); loader behaves
|
|
602
|
+
// identically to the no-file path but the user no longer sees the
|
|
603
|
+
// "forbidden-patterns.json not found" stderr warning.
|
|
604
|
+
if (process.env.DEBUG) {
|
|
605
|
+
console.error(`[installer] forbidden-patterns template copy failed: ${err.message}`);
|
|
606
|
+
}
|
|
607
|
+
fs.writeFileSync(forbiddenPath, '[]\n');
|
|
608
|
+
}
|
|
609
|
+
} else {
|
|
610
|
+
// Template not shipped (unusual install layout) — still scaffold an
|
|
611
|
+
// empty pack so loader is silent.
|
|
612
|
+
fs.writeFileSync(forbiddenPath, '[]\n');
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
586
616
|
// Create registry manifest (dynamic registry discovery)
|
|
587
617
|
const registryManifestPath = path.join(workflowDir, 'state', 'registry-manifest.json');
|
|
588
618
|
if (!fs.existsSync(registryManifestPath)) {
|
package/package.json
CHANGED
|
@@ -32,6 +32,32 @@ function parseArgs(argv) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
function cmdGrant(args) {
|
|
35
|
+
// wf-b8839d99: Refuse to grant when invoked from a non-TTY context (i.e.,
|
|
36
|
+
// from Claude Code's Bash tool or any subprocess pipeline). The AI cannot
|
|
37
|
+
// self-issue deferral authorization — the gate exists precisely to prevent
|
|
38
|
+
// that. A human running this CLI from a terminal has stdin.isTTY === true;
|
|
39
|
+
// an AI subprocess does not.
|
|
40
|
+
//
|
|
41
|
+
// Override: --i-am-human bypasses the TTY check. Provided so out-of-band
|
|
42
|
+
// automation (CI scripts, etc.) can still grant deliberately; the flag
|
|
43
|
+
// signals intent. Caveat: if the AI passes this flag, that's an explicit
|
|
44
|
+
// policy violation that shows up in shell history / commit logs.
|
|
45
|
+
const isHuman = Boolean(process.stdin.isTTY) || args['i-am-human'] === true;
|
|
46
|
+
if (!isHuman) {
|
|
47
|
+
console.error('grant: refused — non-TTY invocation detected.');
|
|
48
|
+
console.error('');
|
|
49
|
+
console.error('Per wf-b8839d99: AI subprocesses cannot self-issue deferral authorization.');
|
|
50
|
+
console.error('The auth marker may only be written by:');
|
|
51
|
+
console.error(' 1. The UserPromptSubmit AI classifier interpreting the user\'s message, OR');
|
|
52
|
+
console.error(' 2. A human running this CLI from a terminal directly.');
|
|
53
|
+
console.error('');
|
|
54
|
+
console.error('If the user authorized deferral, surface it back through the conversation —');
|
|
55
|
+
console.error('the classifier will detect it and write the marker on the next prompt.');
|
|
56
|
+
console.error('');
|
|
57
|
+
console.error('Override (genuine automation): pass --i-am-human (logged in audit trail).');
|
|
58
|
+
process.exit(3);
|
|
59
|
+
}
|
|
60
|
+
|
|
35
61
|
let scope = 'all';
|
|
36
62
|
if (args.findings) {
|
|
37
63
|
scope = String(args.findings)
|
|
@@ -53,6 +79,8 @@ function cmdGrant(args) {
|
|
|
53
79
|
const payload = gate.writeAuth({
|
|
54
80
|
scope,
|
|
55
81
|
source: reason,
|
|
82
|
+
userPromptExcerpt: '(out-of-band CLI grant — no user prompt)',
|
|
83
|
+
confidence: 100,
|
|
56
84
|
grantedBy: 'explicit-cli',
|
|
57
85
|
ttlSec
|
|
58
86
|
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Flow — AI-Based Deferral-Intent Classifier (wf-b8839d99)
|
|
5
|
+
*
|
|
6
|
+
* Replaces the prior regex-based deferral classifier. The user surfaced a
|
|
7
|
+
* critical case on 2026-05-11: regex `/\bfix\s+(everything|all\s+of\s+(them|it)|all\s+findings?)\b/i`
|
|
8
|
+
* did NOT match bare "fix all" — natural English the user actually typed.
|
|
9
|
+
* Result: AI silently deferred findings the user had told it to fix.
|
|
10
|
+
*
|
|
11
|
+
* Why AI, not regex (user instruction, restated 2026-05-11):
|
|
12
|
+
* "Regex is prone to mistakes. I don't want regex or matching when I
|
|
13
|
+
* answer things like that. AI needs to get my responses and analyze them."
|
|
14
|
+
*
|
|
15
|
+
* Design mirrors flow-worker-question-classifier.js:
|
|
16
|
+
* - Single Haiku call per UserPromptSubmit
|
|
17
|
+
* - Returns {intent, confidence, reason, interpretation}
|
|
18
|
+
* - Fail-open: missing API key / model error → {classified: false} → no
|
|
19
|
+
* state change. The gate's default-restrictive behavior holds.
|
|
20
|
+
* - JSON validated for shape + prototype-pollution
|
|
21
|
+
*
|
|
22
|
+
* Three outputs the classifier produces:
|
|
23
|
+
* negative — user wants no deferrals (any phrasing: "fix all", "I don't
|
|
24
|
+
* like tech debt", "no deferrals", "fix everything", etc.).
|
|
25
|
+
* Triggers no-defer-pin write + auth-marker clear.
|
|
26
|
+
* positive — user explicitly authorized deferring specific items.
|
|
27
|
+
* Triggers auth-marker write with scope.
|
|
28
|
+
* none — nothing relevant said. No state change.
|
|
29
|
+
*
|
|
30
|
+
* The classifier ALSO captures `interpretation`: the AI's brief explanation
|
|
31
|
+
* of WHAT it understood the user to mean. This goes into the auth/pin marker
|
|
32
|
+
* `source` field SEPARATELY from any verbatim quote — ending the "false
|
|
33
|
+
* attribution" failure shape where the AI fabricated a "user said X" claim.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const DEFAULT_MIN_CONFIDENCE = 75;
|
|
37
|
+
const DEFAULT_MODEL = 'anthropic:claude-3-5-haiku-latest';
|
|
38
|
+
const MAX_PROMPT_CHARS = 4000;
|
|
39
|
+
const MAX_TOKENS = 400;
|
|
40
|
+
const TEMPERATURE = 0.0;
|
|
41
|
+
|
|
42
|
+
const { DANGEROUS_KEYS } = require('./flow-io');
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the deferral-intent classifier prompt.
|
|
46
|
+
*
|
|
47
|
+
* Designed to distinguish:
|
|
48
|
+
* - Explicit no-defer commands ("fix all", "fix everything", "no
|
|
49
|
+
* deferrals", "I don't like tech debt", "always fix it") → NEGATIVE
|
|
50
|
+
* - Explicit defer commands ("defer F5", "skip the low ones", "option 2",
|
|
51
|
+
* "ship as-is", "fix critical only") → POSITIVE
|
|
52
|
+
* - Everything else (unrelated chatter, ambiguous, conditional) → NONE
|
|
53
|
+
*
|
|
54
|
+
* Critical: the classifier MUST default to NONE on ambiguity. Granting
|
|
55
|
+
* auth when in doubt is the original bug. Failing to detect a no-defer
|
|
56
|
+
* is recoverable (user can repeat); silently granting auth is not.
|
|
57
|
+
*/
|
|
58
|
+
function buildDeferralPrompt(userPrompt) {
|
|
59
|
+
return `You classify whether a user's message to an AI development assistant expresses deferral intent — and if so, which direction.
|
|
60
|
+
|
|
61
|
+
Three categories:
|
|
62
|
+
|
|
63
|
+
NEGATIVE — user wants NO deferrals; everything should be fixed.
|
|
64
|
+
Examples: "fix all", "fix everything", "fix all of them", "no deferrals",
|
|
65
|
+
"I don't like tech debt", "don't defer anything", "ship everything fixed",
|
|
66
|
+
"I always want it all fixed", "fix it all".
|
|
67
|
+
|
|
68
|
+
POSITIVE — user explicitly authorizes deferring specific items.
|
|
69
|
+
Examples: "defer F5", "skip the low-priority ones", "option 2", "option 4",
|
|
70
|
+
"fix critical only", "ship as-is", "good enough for now", "create tasks
|
|
71
|
+
for the rest", "leave that for later".
|
|
72
|
+
|
|
73
|
+
NONE — neither. Includes:
|
|
74
|
+
- Unrelated messages ("looks good", "thanks", "let's discuss X")
|
|
75
|
+
- Ambiguous statements where defer-intent is unclear
|
|
76
|
+
- Conditional / hypothetical ("we could defer X if needed" — that's
|
|
77
|
+
reasoning aloud, not an authorization)
|
|
78
|
+
- Questions about deferring without a directive
|
|
79
|
+
- The word "defer" appearing in technical context (e.g., "defer the
|
|
80
|
+
callback execution")
|
|
81
|
+
|
|
82
|
+
CRITICAL RULES:
|
|
83
|
+
1. When ambiguous, return NONE. The cost of missing a defer signal is low
|
|
84
|
+
(user can repeat); the cost of false-positive auth is high (AI defers
|
|
85
|
+
work the user wanted done).
|
|
86
|
+
2. NEGATIVE takes precedence. If the user says both "fix everything" AND
|
|
87
|
+
"skip Y" in the same message, return NEGATIVE — they want it all.
|
|
88
|
+
3. Standing preferences ("I always", "from now on", "as a rule") about
|
|
89
|
+
deferring are NEGATIVE even if no current finding is in scope.
|
|
90
|
+
4. Confidence: only >= 80 if the message is unambiguous about defer intent.
|
|
91
|
+
Anything that requires reading between the lines is < 80.
|
|
92
|
+
|
|
93
|
+
[USER_MESSAGE_START]
|
|
94
|
+
${String(userPrompt || '').slice(0, MAX_PROMPT_CHARS)}
|
|
95
|
+
[USER_MESSAGE_END]
|
|
96
|
+
|
|
97
|
+
Return JSON only, no prose, no markdown fences:
|
|
98
|
+
{
|
|
99
|
+
"intent": "negative" | "positive" | "none",
|
|
100
|
+
"confidence": 0-100,
|
|
101
|
+
"interpretation": "one short sentence: what you understood the user to mean",
|
|
102
|
+
"scope": "all" | [array of finding IDs like F1, F2, M3] | null,
|
|
103
|
+
"standing": true | false
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
Examples:
|
|
107
|
+
- "fix all" → {"intent":"negative","confidence":95,"interpretation":"user wants every finding fixed, no deferrals","scope":null,"standing":false}
|
|
108
|
+
- "I don't like tech debt" → {"intent":"negative","confidence":90,"interpretation":"standing preference against accumulating deferred work","scope":null,"standing":true}
|
|
109
|
+
- "defer F5 and F6, fix the rest" → {"intent":"positive","confidence":95,"interpretation":"user authorizes deferring F5 and F6 specifically","scope":["F5","F6"],"standing":false}
|
|
110
|
+
- "option 2" → {"intent":"positive","confidence":90,"interpretation":"user picked the fix-critical-only menu option","scope":"all","standing":false}
|
|
111
|
+
- "looks good, let's continue" → {"intent":"none","confidence":85,"interpretation":"acknowledgment, no defer signal","scope":null,"standing":false}
|
|
112
|
+
- "could we defer this?" → {"intent":"none","confidence":80,"interpretation":"question, not an authorization","scope":null,"standing":false}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function hasDangerousKeys(value) {
|
|
116
|
+
if (!value || typeof value !== 'object') return false;
|
|
117
|
+
if (Array.isArray(value)) return value.some(hasDangerousKeys);
|
|
118
|
+
for (const key of Object.keys(value)) {
|
|
119
|
+
if (DANGEROUS_KEYS.has(key)) return true;
|
|
120
|
+
if (hasDangerousKeys(value[key])) return true;
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Classify user-prompt deferral intent.
|
|
127
|
+
*
|
|
128
|
+
* @param {string} userPrompt - The user's message
|
|
129
|
+
* @param {Object} [options]
|
|
130
|
+
* @param {number} [options.minConfidence=75] - Confidence threshold for treating as actionable
|
|
131
|
+
* @param {string} [options.model] - Model override
|
|
132
|
+
* @returns {Promise<{
|
|
133
|
+
* classified: boolean,
|
|
134
|
+
* intent?: 'negative'|'positive'|'none',
|
|
135
|
+
* confidence?: number,
|
|
136
|
+
* interpretation?: string,
|
|
137
|
+
* scope?: string|string[]|null,
|
|
138
|
+
* standing?: boolean,
|
|
139
|
+
* actionable?: boolean,
|
|
140
|
+
* minConfidence?: number,
|
|
141
|
+
* reason?: string
|
|
142
|
+
* }>}
|
|
143
|
+
*/
|
|
144
|
+
async function classifyUserDeferralIntent(userPrompt, options = {}) {
|
|
145
|
+
const minConfidence = Number.isFinite(options.minConfidence) ? options.minConfidence : DEFAULT_MIN_CONFIDENCE;
|
|
146
|
+
const model = options.model || DEFAULT_MODEL;
|
|
147
|
+
|
|
148
|
+
if (typeof userPrompt !== 'string' || userPrompt.trim().length === 0) {
|
|
149
|
+
return { classified: false, reason: 'empty-prompt' };
|
|
150
|
+
}
|
|
151
|
+
if (!process.env.ANTHROPIC_API_KEY) {
|
|
152
|
+
return { classified: false, reason: 'no-credentials' };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let callModel;
|
|
156
|
+
try {
|
|
157
|
+
({ callModel } = require('./flow-model-caller'));
|
|
158
|
+
} catch (_err) {
|
|
159
|
+
return { classified: false, reason: 'no-model-caller' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const prompt = buildDeferralPrompt(userPrompt);
|
|
163
|
+
|
|
164
|
+
let result;
|
|
165
|
+
try {
|
|
166
|
+
result = await callModel(model, prompt, {
|
|
167
|
+
temperature: TEMPERATURE,
|
|
168
|
+
maxTokens: MAX_TOKENS
|
|
169
|
+
});
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (process.env.DEBUG) {
|
|
172
|
+
console.error(`[deferral-classifier-ai] model call failed: ${err.message}`);
|
|
173
|
+
}
|
|
174
|
+
return { classified: false, reason: 'model-error' };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const raw = String(result?.response ?? result?.content ?? '').trim();
|
|
178
|
+
if (!raw) return { classified: false, reason: 'empty-response' };
|
|
179
|
+
|
|
180
|
+
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
181
|
+
if (!jsonMatch) return { classified: false, reason: 'non-json-response' };
|
|
182
|
+
|
|
183
|
+
let parsed;
|
|
184
|
+
try {
|
|
185
|
+
parsed = JSON.parse(jsonMatch[0]);
|
|
186
|
+
} catch (_err) {
|
|
187
|
+
return { classified: false, reason: 'json-parse-error' };
|
|
188
|
+
}
|
|
189
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
190
|
+
return { classified: false, reason: 'bad-shape' };
|
|
191
|
+
}
|
|
192
|
+
if (hasDangerousKeys(parsed)) {
|
|
193
|
+
return { classified: false, reason: 'dangerous-keys' };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const intentRaw = String(parsed.intent || '').toLowerCase();
|
|
197
|
+
const intent = ['negative', 'positive', 'none'].includes(intentRaw) ? intentRaw : 'none';
|
|
198
|
+
const confidence = Number.isFinite(parsed.confidence) ? Math.round(parsed.confidence) : 0;
|
|
199
|
+
const interpretation = typeof parsed.interpretation === 'string'
|
|
200
|
+
? parsed.interpretation.slice(0, 500)
|
|
201
|
+
: '';
|
|
202
|
+
let scope = parsed.scope;
|
|
203
|
+
if (scope === undefined) scope = null;
|
|
204
|
+
if (typeof scope === 'string' && scope !== 'all') scope = null;
|
|
205
|
+
if (Array.isArray(scope)) {
|
|
206
|
+
scope = scope.filter(s => typeof s === 'string' && /^[A-Za-z]\d+$/.test(s.trim())).map(s => s.trim());
|
|
207
|
+
if (scope.length === 0) scope = null;
|
|
208
|
+
}
|
|
209
|
+
const standing = Boolean(parsed.standing);
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
classified: true,
|
|
213
|
+
intent,
|
|
214
|
+
confidence,
|
|
215
|
+
interpretation,
|
|
216
|
+
scope,
|
|
217
|
+
standing,
|
|
218
|
+
actionable: intent !== 'none' && confidence >= minConfidence,
|
|
219
|
+
minConfidence
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = {
|
|
224
|
+
classifyUserDeferralIntent,
|
|
225
|
+
buildDeferralPrompt,
|
|
226
|
+
hasDangerousKeys,
|
|
227
|
+
DEFAULT_MIN_CONFIDENCE,
|
|
228
|
+
DEFAULT_MODEL
|
|
229
|
+
};
|
package/scripts/flow-phase.js
CHANGED
|
@@ -22,12 +22,17 @@ if (command === 'transition') {
|
|
|
22
22
|
console.error('Usage: flow-phase.js transition <from> <to> [taskId]');
|
|
23
23
|
process.exit(1);
|
|
24
24
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
// wf-88a08fd4: previously this exited silently when `phaseGate.enabled` was
|
|
26
|
+
// false, which is the default. The CLI is an explicit caller action — honor
|
|
27
|
+
// it even when gate enforcement is off. State tracking (workflow-phase.json)
|
|
28
|
+
// is independent of gate enforcement (blocking Edit/Write until phase file
|
|
29
|
+
// is read). Callers that depend on phase state always need the write; the
|
|
30
|
+
// gate flag only controls whether PreToolUse blocks tools.
|
|
31
|
+
const gateActive = isPhaseGateEnabled();
|
|
28
32
|
const success = transitionPhase(from, to, taskId || null);
|
|
29
33
|
if (success) {
|
|
30
|
-
|
|
34
|
+
const suffix = gateActive ? '' : ' (gate enforcement disabled — state updated only)';
|
|
35
|
+
console.log(`Phase: ${from} → ${to}${suffix}`);
|
|
31
36
|
// wf-8d635d0e / E1: fire background auto-review on coding → validating.
|
|
32
37
|
// Fails open — any error here must not fail the primary transition.
|
|
33
38
|
try {
|
|
@@ -169,6 +169,21 @@ function inferTaskType(taskType, changedFiles) {
|
|
|
169
169
|
* @returns {Object} Check results with feedback for retry loop
|
|
170
170
|
*/
|
|
171
171
|
function runTaskStandardsCheck(taskContext, files, options = {}) {
|
|
172
|
+
// Defensive normalization: callers (flow-done-gates.js → ctx.getModifiedFiles())
|
|
173
|
+
// pass a string[] of file paths; this function documents Object[] with
|
|
174
|
+
// {path, content}. Pre-fix, `files.map(f => f.path)` returned [undefined,...]
|
|
175
|
+
// which then crashed downstream `.includes()` on undefined. The crash was
|
|
176
|
+
// swallowed by the caller's try/catch and surfaced as "checker error —
|
|
177
|
+
// degraded to manual check" on every `flow done`. Normalize here so both
|
|
178
|
+
// shapes work; content-dependent checks skip strings gracefully (downstream
|
|
179
|
+
// already does `if (!file.content) continue;`).
|
|
180
|
+
if (!Array.isArray(files)) {
|
|
181
|
+
files = [];
|
|
182
|
+
} else {
|
|
183
|
+
files = files.map(f => (typeof f === 'string' ? { path: f, content: undefined } : f))
|
|
184
|
+
.filter(f => f && typeof f.path === 'string');
|
|
185
|
+
}
|
|
186
|
+
|
|
172
187
|
const config = getConfig();
|
|
173
188
|
const standardsConfig = config.standardsCompliance || {};
|
|
174
189
|
const gateStartMs = Date.now();
|
|
@@ -421,16 +421,31 @@ Run: /wogi-start ${coreResult.nextTaskId}`;
|
|
|
421
421
|
};
|
|
422
422
|
}
|
|
423
423
|
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
424
|
+
// wf-35742353 — Gate-orchestrator integration.
|
|
425
|
+
//
|
|
426
|
+
// Previously this concatenated up to five pieces blindly, producing a
|
|
427
|
+
// wall of competing system-reminder text ("invoke /wogi-extract-review"
|
|
428
|
+
// + "research protocol" + phase context, etc.) that the AI/user could
|
|
429
|
+
// not triage. Now: REMEDIATIONS (gates demanding action) go through
|
|
430
|
+
// the gate-orchestrator which picks the highest-priority one and lists
|
|
431
|
+
// the rest as a one-line "queued" footer. INFO pieces (phase context,
|
|
432
|
+
// overdue dispatches) pass through unchanged alongside the top
|
|
433
|
+
// remediation. Fail-open: any error falls back to the prior concat.
|
|
430
434
|
const pieces = [];
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
435
|
+
try {
|
|
436
|
+
const { selectAndRender } = require('../core/gate-orchestrator');
|
|
437
|
+
const remediation = selectAndRender({
|
|
438
|
+
'long-input-pending': coreResult.longInputEnforcement,
|
|
439
|
+
'research-required': coreResult.systemReminder || coreResult.message
|
|
440
|
+
});
|
|
441
|
+
if (remediation) pieces.push(remediation);
|
|
442
|
+
} catch (_err) {
|
|
443
|
+
// Fallback to prior concat shape if orchestrator misfires.
|
|
444
|
+
if (coreResult.longInputEnforcement) pieces.push(coreResult.longInputEnforcement);
|
|
445
|
+
if (coreResult.systemReminder) pieces.push(coreResult.systemReminder);
|
|
446
|
+
else if (coreResult.message) pieces.push(coreResult.message);
|
|
447
|
+
}
|
|
448
|
+
// Info pieces always pass through.
|
|
434
449
|
if (coreResult.phasePrompt) pieces.push(coreResult.phasePrompt);
|
|
435
450
|
if (coreResult.overduePrompt) pieces.push(coreResult.overduePrompt);
|
|
436
451
|
|
|
@@ -1,129 +1,148 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Wogi Flow — Deferral Intent Classifier (wf-
|
|
4
|
+
* Wogi Flow — Deferral Intent Classifier — Hook Wrapper (wf-b8839d99)
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Thin wrapper around the AI-based classifier (scripts/flow-deferral-classifier-ai.js).
|
|
7
|
+
* Originally regex-based; replaced 2026-05-11 after the user surfaced the
|
|
8
|
+
* "fix all" miss + false-attribution incident.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* gate exists to stop).
|
|
10
|
+
* User's instruction (2026-05-11):
|
|
11
|
+
* "Regex is prone to mistakes. I don't want regex or matching when I
|
|
12
|
+
* answer things like that. AI needs to get my responses and analyze them."
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* Flow at UserPromptSubmit:
|
|
15
|
+
* 1. Call AI classifier (Haiku, cheap, ~500ms)
|
|
16
|
+
* 2. If actionable + negative → write no-defer-pin (clear any auth)
|
|
17
|
+
* 3. If actionable + positive → write auth marker
|
|
18
|
+
* 4. None/low-confidence/classifier-error → no state change (fail-open)
|
|
17
19
|
*
|
|
18
|
-
*
|
|
20
|
+
* The marker source field is now the AI's structured interpretation, NOT
|
|
21
|
+
* a free-form string the AI invents. The classifier returns {intent,
|
|
22
|
+
* confidence, interpretation} as a triple; we record all three plus the
|
|
23
|
+
* verbatim user-message excerpt in the marker. Audit trails can then
|
|
24
|
+
* distinguish "user said X" from "AI interpreted Y".
|
|
25
|
+
*
|
|
26
|
+
* The synchronous regex API used to be the entry point. We keep the same
|
|
27
|
+
* name and return shape but the implementation is now an async call to
|
|
28
|
+
* the AI classifier. Callers in user-prompt-submit.js are already async.
|
|
19
29
|
*/
|
|
20
30
|
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
/\bfix\s+(everything|all\s+of\s+(them|it)|all\s+findings?)\b/i,
|
|
24
|
-
/\bno\s+deferr?als?\b/i,
|
|
25
|
-
/\b(don'?t|do\s+not)\s+defer\b/i,
|
|
26
|
-
/\bi\s+don'?t\s+(want|like)\s+(tech\s*-?\s*debt|technical\s*-?\s*debt|deferr?al)/i,
|
|
27
|
-
/\bnever\s+defer\b/i,
|
|
28
|
-
/\balways\s+fix\s+(what'?s\s+broken|what\s+needs?\s+fixing)/i,
|
|
29
|
-
/\bnothing\s+(should\s+be|gets)\s+deferr?ed\b/i,
|
|
30
|
-
];
|
|
31
|
-
|
|
32
|
-
// Positive phrases (MEDIUM PRIORITY — write auth marker)
|
|
33
|
-
// We're conservative: require defer/skip phrasing to be coupled with finding
|
|
34
|
-
// context (this/that/those/it/option N/F\d+/severity word) to avoid catching
|
|
35
|
-
// unrelated mentions like "let's defer the meeting".
|
|
36
|
-
const POSITIVE_PATTERNS = [
|
|
37
|
-
// "defer X" / "skip X" with a referent
|
|
38
|
-
/\b(defer|skip|ignore|drop)\s+(this|that|those|it|them|f\d+|finding\s+\w+)\b/i,
|
|
39
|
-
/\bleave\s+(this|that|those|f\d+|.*?)\s+(for\s+)?later\b/i,
|
|
40
|
-
|
|
41
|
-
// /wogi-review menu options that mean defer
|
|
42
|
-
/\boption\s*[24]\b/i, // option 2 = "fix critical only"; option 4 = "create tasks for all (defer)"
|
|
43
|
-
/\bcreate\s+tasks?\s+for\s+(all|the\s+rest|remaining)\b/i,
|
|
44
|
-
|
|
45
|
-
// Severity-scoped deferrals
|
|
46
|
-
/\bfix\s+(only\s+)?(critical|high)\s*(\s*\/\s*high)?\s+only\b/i,
|
|
47
|
-
/\bfix\s+(critical|high)\s+(only|first)\b/i,
|
|
48
|
-
/\bskip\s+(low|medium|low\s*\/\s*medium)\b/i,
|
|
49
|
-
|
|
50
|
-
// Ship-as-is style
|
|
51
|
-
/\bship\s+(it\s+)?as\s*-?\s*is\b/i,
|
|
52
|
-
/\bgood\s+enough\s+(as\s*-?\s*is|for\s+now)\b/i,
|
|
53
|
-
/\bcall\s+it\s+(done|good)\b/i,
|
|
54
|
-
];
|
|
31
|
+
const NEGATIVE_INTENT = 'negative';
|
|
32
|
+
const POSITIVE_INTENT = 'positive';
|
|
55
33
|
|
|
56
34
|
/**
|
|
57
|
-
*
|
|
35
|
+
* Apply deferral-intent classification at UserPromptSubmit time.
|
|
58
36
|
*
|
|
59
|
-
* @param {string} prompt -
|
|
60
|
-
* @
|
|
37
|
+
* @param {string} prompt - The user's message
|
|
38
|
+
* @param {Object} config - Loaded WogiFlow config
|
|
39
|
+
* @returns {Promise<{
|
|
40
|
+
* applied: boolean,
|
|
41
|
+
* intent?: 'negative'|'positive'|'none',
|
|
42
|
+
* match?: string,
|
|
43
|
+
* reason?: string
|
|
44
|
+
* }>}
|
|
61
45
|
*/
|
|
62
|
-
function
|
|
63
|
-
|
|
46
|
+
async function applyClassification(prompt, config) {
|
|
47
|
+
try {
|
|
48
|
+
if (config?.deferralGate?.classifyUserPrompts === false) {
|
|
49
|
+
return { applied: false, reason: 'classifier-disabled' };
|
|
50
|
+
}
|
|
64
51
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
52
|
+
const { classifyUserDeferralIntent } = require('../../flow-deferral-classifier-ai');
|
|
53
|
+
const result = await classifyUserDeferralIntent(prompt, {
|
|
54
|
+
minConfidence: config?.deferralGate?.minClassifierConfidence
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (!result.classified) {
|
|
58
|
+
// Fail-open — no state change on classifier error. Status quo holds.
|
|
59
|
+
if (process.env.DEBUG) {
|
|
60
|
+
console.error(`[deferral-classifier] classifier skipped: ${result.reason}`);
|
|
61
|
+
}
|
|
62
|
+
return { applied: false, reason: `classifier-skipped: ${result.reason}` };
|
|
63
|
+
}
|
|
70
64
|
|
|
71
|
-
|
|
72
|
-
for (const rx of POSITIVE_PATTERNS) {
|
|
73
|
-
const m = prompt.match(rx);
|
|
74
|
-
if (m) {
|
|
75
|
-
// Try to extract scope — look for F\d+ ids in the prompt
|
|
76
|
-
const findingIds = Array.from(prompt.matchAll(/\bF\d+\b/g)).map(x => x[0]);
|
|
65
|
+
if (!result.actionable) {
|
|
77
66
|
return {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
67
|
+
applied: false,
|
|
68
|
+
intent: result.intent,
|
|
69
|
+
reason: `below-threshold (confidence ${result.confidence} < ${result.minConfidence})`
|
|
81
70
|
};
|
|
82
71
|
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return { intent: 'none' };
|
|
86
|
-
}
|
|
87
72
|
|
|
88
|
-
|
|
89
|
-
* Apply classification result to the gate's state files. Wired into
|
|
90
|
-
* UserPromptSubmit. Fail-open throughout.
|
|
91
|
-
*/
|
|
92
|
-
function applyClassification(prompt, config) {
|
|
93
|
-
try {
|
|
94
|
-
if (config?.deferralGate?.classifyUserPrompts === false) return { applied: false, reason: 'classifier-disabled' };
|
|
73
|
+
const gate = require('./deferral-gate');
|
|
95
74
|
|
|
96
|
-
|
|
97
|
-
|
|
75
|
+
if (result.intent === NEGATIVE_INTENT) {
|
|
76
|
+
// wf-b8839d99 fix #5: if there was a prior auth marker, the user's
|
|
77
|
+
// negative is likely a correction ("I did not authorize"). Write a
|
|
78
|
+
// brief routing-recovery grace window so the AI can act on the
|
|
79
|
+
// correction without re-routing through /wogi-start first.
|
|
80
|
+
let priorAuthExisted = false;
|
|
81
|
+
try { priorAuthExisted = Boolean(gate.loadAuth()); } catch (_err) { /* fine */ }
|
|
82
|
+
|
|
83
|
+
gate.writeNoDeferPin({
|
|
84
|
+
source: result.interpretation,
|
|
85
|
+
userPromptExcerpt: typeof prompt === 'string' ? prompt.slice(0, 300) : '',
|
|
86
|
+
confidence: result.confidence,
|
|
87
|
+
grantedBy: 'ai-classifier',
|
|
88
|
+
standing: result.standing
|
|
89
|
+
});
|
|
98
90
|
|
|
99
|
-
|
|
100
|
-
|
|
91
|
+
if (priorAuthExisted) {
|
|
92
|
+
try {
|
|
93
|
+
const fs = require('node:fs');
|
|
94
|
+
const path = require('node:path');
|
|
95
|
+
const { PATHS } = require('../../flow-utils');
|
|
96
|
+
const gracePath = path.join(PATHS.state, 'routing-recovery-grace.json');
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
fs.writeFileSync(gracePath, JSON.stringify({
|
|
99
|
+
grantedAt: new Date(now).toISOString(),
|
|
100
|
+
expiresAt: new Date(now + 60 * 1000).toISOString(),
|
|
101
|
+
reason: 'user-correction-after-prior-defer-auth',
|
|
102
|
+
userPromptExcerpt: typeof prompt === 'string' ? prompt.slice(0, 300) : ''
|
|
103
|
+
}, null, 2));
|
|
104
|
+
} catch (_err) { /* fail-open */ }
|
|
105
|
+
}
|
|
101
106
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
107
|
+
return {
|
|
108
|
+
applied: true,
|
|
109
|
+
intent: 'negative',
|
|
110
|
+
match: result.interpretation,
|
|
111
|
+
confidence: result.confidence,
|
|
112
|
+
standing: result.standing,
|
|
113
|
+
correctionGrace: priorAuthExisted
|
|
114
|
+
};
|
|
105
115
|
}
|
|
106
116
|
|
|
107
|
-
if (result.intent ===
|
|
117
|
+
if (result.intent === POSITIVE_INTENT) {
|
|
108
118
|
gate.writeAuth({
|
|
109
|
-
scope: result.scope,
|
|
110
|
-
source: result.
|
|
111
|
-
|
|
119
|
+
scope: result.scope || 'all',
|
|
120
|
+
source: result.interpretation,
|
|
121
|
+
userPromptExcerpt: typeof prompt === 'string' ? prompt.slice(0, 300) : '',
|
|
122
|
+
confidence: result.confidence,
|
|
123
|
+
grantedBy: 'ai-classifier',
|
|
112
124
|
config
|
|
113
125
|
});
|
|
114
|
-
return {
|
|
126
|
+
return {
|
|
127
|
+
applied: true,
|
|
128
|
+
intent: 'positive',
|
|
129
|
+
match: result.interpretation,
|
|
130
|
+
scope: result.scope || 'all',
|
|
131
|
+
confidence: result.confidence
|
|
132
|
+
};
|
|
115
133
|
}
|
|
116
134
|
|
|
117
|
-
return { applied: false, reason: '
|
|
135
|
+
return { applied: false, intent: result.intent, reason: 'none-intent' };
|
|
118
136
|
} catch (err) {
|
|
119
|
-
if (process.env.DEBUG)
|
|
137
|
+
if (process.env.DEBUG) {
|
|
138
|
+
console.error(`[deferral-classifier] applyClassification error (fail-open): ${err.message}`);
|
|
139
|
+
}
|
|
120
140
|
return { applied: false, reason: `error: ${err.message}` };
|
|
121
141
|
}
|
|
122
142
|
}
|
|
123
143
|
|
|
124
144
|
module.exports = {
|
|
125
|
-
classifyDeferralIntent,
|
|
126
145
|
applyClassification,
|
|
127
|
-
|
|
128
|
-
|
|
146
|
+
NEGATIVE_INTENT,
|
|
147
|
+
POSITIVE_INTENT
|
|
129
148
|
};
|
|
@@ -121,17 +121,43 @@ function clearNoDeferPin() {
|
|
|
121
121
|
try { fs.unlinkSync(getNoDeferPinPath()); } catch (_err) { /* fine if absent */ }
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
|
|
124
|
+
/**
|
|
125
|
+
* wf-b8839d99 — Marker shape now captures the verbatim user prompt excerpt
|
|
126
|
+
* SEPARATELY from the AI's interpretation. Prior shape had only a single
|
|
127
|
+
* `source` string the AI could fill with anything, enabling the false-
|
|
128
|
+
* attribution failure ("user-authorized" with a fabricated quote).
|
|
129
|
+
*
|
|
130
|
+
* Fields:
|
|
131
|
+
* source — AI's structured interpretation (what it understood)
|
|
132
|
+
* userPromptExcerpt — Verbatim user message excerpt (≤300 chars)
|
|
133
|
+
* confidence — AI classifier confidence (0-100)
|
|
134
|
+
* grantedBy — One of: 'ai-classifier', 'explicit-cli', 'user-prompt' (legacy)
|
|
135
|
+
* standing — true if this represents a standing/permanent rule
|
|
136
|
+
*
|
|
137
|
+
* Auditors can compare `source` (AI claim) against `userPromptExcerpt`
|
|
138
|
+
* (actual user words) to detect over-interpretation.
|
|
139
|
+
*/
|
|
140
|
+
function writeAuth({
|
|
141
|
+
scope = 'all',
|
|
142
|
+
source = 'unspecified',
|
|
143
|
+
userPromptExcerpt = '',
|
|
144
|
+
confidence = 0,
|
|
145
|
+
grantedBy = 'user-prompt',
|
|
146
|
+
ttlSec,
|
|
147
|
+
config
|
|
148
|
+
} = {}) {
|
|
125
149
|
try {
|
|
126
150
|
const ttl = Number.isFinite(ttlSec) ? ttlSec : getAuthTtlSeconds(config);
|
|
127
151
|
const now = Date.now();
|
|
128
152
|
const payload = {
|
|
129
|
-
version:
|
|
153
|
+
version: 2,
|
|
130
154
|
grantedAt: new Date(now).toISOString(),
|
|
131
155
|
expiresAt: new Date(now + ttl * 1000).toISOString(),
|
|
132
156
|
scope,
|
|
133
157
|
grantedBy,
|
|
134
|
-
source: typeof source === 'string' ? source.slice(0, 1000) : 'unspecified'
|
|
158
|
+
source: typeof source === 'string' ? source.slice(0, 1000) : 'unspecified',
|
|
159
|
+
userPromptExcerpt: typeof userPromptExcerpt === 'string' ? userPromptExcerpt.slice(0, 500) : '',
|
|
160
|
+
confidence: Number.isFinite(confidence) ? Math.round(confidence) : 0
|
|
135
161
|
};
|
|
136
162
|
fs.mkdirSync(path.dirname(getAuthPath()), { recursive: true });
|
|
137
163
|
const tmp = `${getAuthPath()}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -144,14 +170,32 @@ function writeAuth({ scope = 'all', source = 'unspecified', grantedBy = 'user-pr
|
|
|
144
170
|
}
|
|
145
171
|
}
|
|
146
172
|
|
|
147
|
-
function writeNoDeferPin({
|
|
173
|
+
function writeNoDeferPin({
|
|
174
|
+
source = 'unspecified',
|
|
175
|
+
userPromptExcerpt = '',
|
|
176
|
+
confidence = 0,
|
|
177
|
+
grantedBy = 'ai-classifier',
|
|
178
|
+
standing = false,
|
|
179
|
+
ttlSec
|
|
180
|
+
} = {}) {
|
|
148
181
|
try {
|
|
182
|
+
// wf-b8839d99: standing pins (e.g., "I don't like tech debt" as a rule)
|
|
183
|
+
// get a much longer TTL — 7 days — so a standing preference doesn't
|
|
184
|
+
// silently expire after 30 min and re-open the deferral door. The pin
|
|
185
|
+
// is also refreshed at SessionStart from decisions.md.
|
|
186
|
+
const effectiveTtl = Number.isFinite(ttlSec)
|
|
187
|
+
? ttlSec
|
|
188
|
+
: (standing ? 7 * 24 * 3600 : 1800);
|
|
149
189
|
const now = Date.now();
|
|
150
190
|
const payload = {
|
|
151
|
-
version:
|
|
191
|
+
version: 2,
|
|
152
192
|
pinnedAt: new Date(now).toISOString(),
|
|
153
|
-
expiresAt: new Date(now +
|
|
154
|
-
source: typeof source === 'string' ? source.slice(0, 1000) : 'unspecified'
|
|
193
|
+
expiresAt: new Date(now + effectiveTtl * 1000).toISOString(),
|
|
194
|
+
source: typeof source === 'string' ? source.slice(0, 1000) : 'unspecified',
|
|
195
|
+
userPromptExcerpt: typeof userPromptExcerpt === 'string' ? userPromptExcerpt.slice(0, 500) : '',
|
|
196
|
+
confidence: Number.isFinite(confidence) ? Math.round(confidence) : 0,
|
|
197
|
+
grantedBy,
|
|
198
|
+
standing: Boolean(standing)
|
|
155
199
|
};
|
|
156
200
|
fs.mkdirSync(path.dirname(getNoDeferPinPath()), { recursive: true });
|
|
157
201
|
const tmp = `${getNoDeferPinPath()}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Flow — Gate Orchestrator (wf-35742353)
|
|
5
|
+
*
|
|
6
|
+
* Cross-gate priority and remediation surfacing. Each gate (long-input-pending,
|
|
7
|
+
* routing, research-required, phase-context, overdue-dispatches, etc.) was
|
|
8
|
+
* designed assuming it was the only voice in the room. At the integration
|
|
9
|
+
* point (UserPromptSubmit additionalContext and Stop hook stopReason) they
|
|
10
|
+
* collide and produce conflicting "do this NOW" instructions in the same turn.
|
|
11
|
+
*
|
|
12
|
+
* This module owns the priority order and the picker. The hook entry files
|
|
13
|
+
* and adapters call it instead of stacking messages.
|
|
14
|
+
*
|
|
15
|
+
* Priority (highest first):
|
|
16
|
+
* 1. long-input-pending — user's prompt isn't captured; downstream is
|
|
17
|
+
* suspect. Resolve before anything else.
|
|
18
|
+
* 2. routing — no task assigned; work would be untracked.
|
|
19
|
+
* 3. research-required — diagnostic prompt needs evidence-reading.
|
|
20
|
+
* 4. workspace-overdue — silent worker death surfacing (manager-only).
|
|
21
|
+
* 5. phase-context — informational phase-prompt injection (not a demand).
|
|
22
|
+
*
|
|
23
|
+
* Categories:
|
|
24
|
+
* - "remediation": the AI must take a specific action (resolve before
|
|
25
|
+
* proceeding). At most ONE remediation is surfaced per turn — the
|
|
26
|
+
* highest-priority active one wins; others get a one-line "queued"
|
|
27
|
+
* footer so the AI knows more work follows.
|
|
28
|
+
* - "info": informational, always passes through alongside the top
|
|
29
|
+
* remediation. Examples: phase-context, dossier injection.
|
|
30
|
+
*
|
|
31
|
+
* Fail-open philosophy: if classification or formatting errors, return
|
|
32
|
+
* the original message stack unchanged (caller fallback).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const REMEDIATION_PRIORITY = Object.freeze([
|
|
36
|
+
'long-input-pending',
|
|
37
|
+
'routing',
|
|
38
|
+
'research-required',
|
|
39
|
+
'workspace-overdue'
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const REMEDIATION_LABELS = Object.freeze({
|
|
43
|
+
'long-input-pending': 'long-input-pending (invoke /wogi-extract-review or `flow long-input-pending dismiss`)',
|
|
44
|
+
'routing': 'routing (invoke /wogi-start)',
|
|
45
|
+
'research-required': 'research-required (read evidence before answering)',
|
|
46
|
+
'workspace-overdue': 'workspace-overdue (a worker dispatch is past its deadline)'
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Pick the top-priority active remediation from a set of (gateId, message) pairs.
|
|
51
|
+
*
|
|
52
|
+
* @param {Array<{id: string, message: string}>} active - gates currently demanding action
|
|
53
|
+
* @returns {{ top: {id, message}|null, queued: string[] }}
|
|
54
|
+
* top: the highest-priority active gate (null if none)
|
|
55
|
+
* queued: gateIds of the others (in priority order), for footer rendering
|
|
56
|
+
*/
|
|
57
|
+
function pickTopRemediation(active) {
|
|
58
|
+
if (!Array.isArray(active) || active.length === 0) {
|
|
59
|
+
return { top: null, queued: [] };
|
|
60
|
+
}
|
|
61
|
+
// Filter to valid entries and sort by priority index.
|
|
62
|
+
const valid = active.filter(g => g && typeof g.id === 'string' && typeof g.message === 'string' && g.message.trim().length > 0);
|
|
63
|
+
if (valid.length === 0) return { top: null, queued: [] };
|
|
64
|
+
|
|
65
|
+
const indexed = valid.map(g => ({ ...g, idx: REMEDIATION_PRIORITY.indexOf(g.id) }))
|
|
66
|
+
.map(g => ({ ...g, idx: g.idx === -1 ? Number.POSITIVE_INFINITY : g.idx }));
|
|
67
|
+
indexed.sort((a, b) => a.idx - b.idx);
|
|
68
|
+
|
|
69
|
+
const top = { id: indexed[0].id, message: indexed[0].message };
|
|
70
|
+
const queued = indexed.slice(1).map(g => g.id);
|
|
71
|
+
return { top, queued };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Render the top remediation message with a one-line footer listing queued
|
|
76
|
+
* remediations. If no others are queued, returns the top message unchanged.
|
|
77
|
+
*/
|
|
78
|
+
function renderRemediation(top, queued) {
|
|
79
|
+
if (!top || typeof top.message !== 'string') return '';
|
|
80
|
+
if (!Array.isArray(queued) || queued.length === 0) return top.message;
|
|
81
|
+
const labels = queued.map(id => REMEDIATION_LABELS[id] || id).join('; ');
|
|
82
|
+
return `${top.message}\n\n[gate-orchestrator] Also queued (resolve after the above): ${labels}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Convenience: take a map of {gateId: message-or-null} and return the rendered
|
|
87
|
+
* top remediation (or empty string when nothing is active).
|
|
88
|
+
*/
|
|
89
|
+
function selectAndRender(gateMap) {
|
|
90
|
+
if (!gateMap || typeof gateMap !== 'object') return '';
|
|
91
|
+
const active = Object.entries(gateMap)
|
|
92
|
+
.filter(([_id, msg]) => typeof msg === 'string' && msg.trim().length > 0)
|
|
93
|
+
.map(([id, message]) => ({ id, message }));
|
|
94
|
+
const { top, queued } = pickTopRemediation(active);
|
|
95
|
+
return renderRemediation(top, queued);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = {
|
|
99
|
+
REMEDIATION_PRIORITY,
|
|
100
|
+
REMEDIATION_LABELS,
|
|
101
|
+
pickTopRemediation,
|
|
102
|
+
renderRemediation,
|
|
103
|
+
selectAndRender
|
|
104
|
+
};
|
|
@@ -94,6 +94,47 @@ function hasSourceLink(text) {
|
|
|
94
94
|
return SOURCE_LINK_PATTERNS.some(re => re.test(text));
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// Known system-content tag prefixes that arrive via UserPromptSubmit but are
|
|
98
|
+
// NOT user-typed input. Sub-agent task-notifications, system reminders, and
|
|
99
|
+
// slash-command framings all flow through the same hook. Treating them as
|
|
100
|
+
// user prompts is the wf-f7d58760 false-positive shape — sub-agent
|
|
101
|
+
// completions tripped the long-input gate and force-blocked the parent
|
|
102
|
+
// session with no recoverable path (catch-22 documented in the bug).
|
|
103
|
+
const SYSTEM_CONTENT_PREFIXES = [
|
|
104
|
+
'<task-notification>',
|
|
105
|
+
'<system-reminder>',
|
|
106
|
+
'<command-message>',
|
|
107
|
+
'<command-name>',
|
|
108
|
+
'<command-args>',
|
|
109
|
+
'<command-output>',
|
|
110
|
+
'<command-stderr>',
|
|
111
|
+
'<local-command-stdout>',
|
|
112
|
+
'<local-command-stderr>',
|
|
113
|
+
'<user-prompt-submit-hook>',
|
|
114
|
+
'<bash-input>',
|
|
115
|
+
'<bash-stdout>',
|
|
116
|
+
'<bash-stderr>'
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Detect content that originates from the system (tool results, sub-agent
|
|
121
|
+
* notifications, slash-command framings) rather than user typing. These
|
|
122
|
+
* arrive via UserPromptSubmit in some Claude Code paths but should never
|
|
123
|
+
* trip the long-input gate — they aren't requests, and the user can't
|
|
124
|
+
* "preserve their verbatim source" because the user didn't author them.
|
|
125
|
+
*
|
|
126
|
+
* Detection: leading non-whitespace begins with a known system tag prefix.
|
|
127
|
+
* Conservative — only matches if the tag is the FIRST thing in the text.
|
|
128
|
+
*/
|
|
129
|
+
function isSystemOriginatedContent(text) {
|
|
130
|
+
if (typeof text !== 'string') return false;
|
|
131
|
+
const lead = text.trimStart().slice(0, 64);
|
|
132
|
+
for (const prefix of SYSTEM_CONTENT_PREFIXES) {
|
|
133
|
+
if (lead.startsWith(prefix)) return true;
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
97
138
|
function hasTaskSignals(text) {
|
|
98
139
|
if (typeof text !== 'string') return false;
|
|
99
140
|
let imperativeHits = 0;
|
|
@@ -129,6 +170,12 @@ function isChannelDispatchInWorker(source, env = process.env) {
|
|
|
129
170
|
* @returns {{forced: boolean, level: 'strict'|'force'|'suggest'|'pass', reason: string}}
|
|
130
171
|
*/
|
|
131
172
|
function shouldForceExtractReview({ text, source, env = process.env } = {}) {
|
|
173
|
+
// wf-f7d58760: system-originated content (sub-agent task-notifications,
|
|
174
|
+
// tool-result echoes, slash-command framings) flows through the same
|
|
175
|
+
// UserPromptSubmit pipe but is NOT a user prompt. Skip the gate entirely.
|
|
176
|
+
if (isSystemOriginatedContent(text)) {
|
|
177
|
+
return { forced: false, level: 'pass', reason: 'system-originated-content' };
|
|
178
|
+
}
|
|
132
179
|
if (!detectLongFormPrompt(text)) {
|
|
133
180
|
return { forced: false, level: 'pass', reason: 'below-long-input-threshold' };
|
|
134
181
|
}
|
|
@@ -297,9 +344,11 @@ module.exports = {
|
|
|
297
344
|
PENDING_PATH,
|
|
298
345
|
LONG_LINE_THRESHOLD,
|
|
299
346
|
LONG_ITEM_THRESHOLD,
|
|
347
|
+
SYSTEM_CONTENT_PREFIXES,
|
|
300
348
|
detectLongFormPrompt,
|
|
301
349
|
hasSourceLink,
|
|
302
350
|
hasTaskSignals,
|
|
351
|
+
isSystemOriginatedContent,
|
|
303
352
|
isChannelDispatchInWorker,
|
|
304
353
|
shouldForceExtractReview,
|
|
305
354
|
buildEnforcementMessage,
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Flow — Standing No-Defer Policy Refresh (wf-b8839d99)
|
|
5
|
+
*
|
|
6
|
+
* Reads `.workflow/state/decisions.md` at SessionStart for an explicit
|
|
7
|
+
* no-defer policy section and refreshes the no-defer-pin if found. This
|
|
8
|
+
* makes a user's standing preference ("I don't like tech debt", written
|
|
9
|
+
* via /wogi-decide into decisions.md) survive session boundaries instead
|
|
10
|
+
* of evaporating with the 7-day pin TTL.
|
|
11
|
+
*
|
|
12
|
+
* Recognized markers in decisions.md (case-insensitive, structured-section
|
|
13
|
+
* scan — NOT user-prompt parsing, so simple string match is acceptable):
|
|
14
|
+
* - "## No-Deferral Policy" (or "### No-Deferral Policy")
|
|
15
|
+
* - "## Anti-Tech-Debt Policy" / "### Anti-Tech-Debt Policy"
|
|
16
|
+
* - Body must contain "active" / "enabled" / "enforced" (any of those)
|
|
17
|
+
*
|
|
18
|
+
* If found → write a fresh standing no-defer-pin with 30-day TTL. The pin
|
|
19
|
+
* carries `grantedBy: 'decisions-policy'` to distinguish it from per-prompt
|
|
20
|
+
* pins written by the AI classifier.
|
|
21
|
+
*
|
|
22
|
+
* Fail-open: any read/parse error → no action. Decisions.md is optional
|
|
23
|
+
* and many projects won't have a policy section.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
const { PATHS } = require('../../flow-utils');
|
|
29
|
+
|
|
30
|
+
const POLICY_HEADER_PATTERNS = [
|
|
31
|
+
/^#{2,3}\s+No-?Deferr?al\s+Policy\b/im,
|
|
32
|
+
/^#{2,3}\s+Anti-?Tech-?Debt\s+Policy\b/im
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const ACTIVE_MARKERS = /\b(active|enabled|enforced)\b/i;
|
|
36
|
+
|
|
37
|
+
const POLICY_PIN_TTL_SEC = 30 * 24 * 3600; // 30 days
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check decisions.md for a no-defer policy section.
|
|
41
|
+
*
|
|
42
|
+
* @returns {{ active: boolean, header?: string, snippet?: string }}
|
|
43
|
+
*/
|
|
44
|
+
function detectPolicy() {
|
|
45
|
+
try {
|
|
46
|
+
const decisionsPath = path.join(PATHS.state, 'decisions.md');
|
|
47
|
+
if (!fs.existsSync(decisionsPath)) return { active: false };
|
|
48
|
+
const content = fs.readFileSync(decisionsPath, 'utf-8');
|
|
49
|
+
if (typeof content !== 'string' || content.length === 0) return { active: false };
|
|
50
|
+
|
|
51
|
+
for (const re of POLICY_HEADER_PATTERNS) {
|
|
52
|
+
const m = content.match(re);
|
|
53
|
+
if (!m) continue;
|
|
54
|
+
// Found a header — check the next ~500 chars for an "active" marker.
|
|
55
|
+
const startIdx = m.index || 0;
|
|
56
|
+
const window = content.slice(startIdx, startIdx + 500);
|
|
57
|
+
if (ACTIVE_MARKERS.test(window)) {
|
|
58
|
+
return {
|
|
59
|
+
active: true,
|
|
60
|
+
header: m[0].trim(),
|
|
61
|
+
snippet: window.slice(0, 200).trim()
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { active: false };
|
|
66
|
+
} catch (_err) {
|
|
67
|
+
return { active: false };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Refresh the no-defer pin if decisions.md has an active policy.
|
|
73
|
+
*
|
|
74
|
+
* @returns {{ refreshed: boolean, reason?: string }}
|
|
75
|
+
*/
|
|
76
|
+
function refreshFromPolicy() {
|
|
77
|
+
try {
|
|
78
|
+
const policy = detectPolicy();
|
|
79
|
+
if (!policy.active) {
|
|
80
|
+
return { refreshed: false, reason: 'no-active-policy' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const gate = require('./deferral-gate');
|
|
84
|
+
gate.writeNoDeferPin({
|
|
85
|
+
source: `Standing policy in decisions.md: ${policy.header}`,
|
|
86
|
+
userPromptExcerpt: policy.snippet || '',
|
|
87
|
+
confidence: 100,
|
|
88
|
+
grantedBy: 'decisions-policy',
|
|
89
|
+
standing: true,
|
|
90
|
+
ttlSec: POLICY_PIN_TTL_SEC
|
|
91
|
+
});
|
|
92
|
+
return { refreshed: true, header: policy.header };
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (process.env.DEBUG) {
|
|
95
|
+
console.error(`[no-defer-policy] refreshFromPolicy error (fail-open): ${err.message}`);
|
|
96
|
+
}
|
|
97
|
+
return { refreshed: false, reason: `error: ${err.message}` };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = {
|
|
102
|
+
detectPolicy,
|
|
103
|
+
refreshFromPolicy,
|
|
104
|
+
POLICY_PIN_TTL_SEC,
|
|
105
|
+
POLICY_HEADER_PATTERNS,
|
|
106
|
+
ACTIVE_MARKERS
|
|
107
|
+
};
|
|
@@ -47,7 +47,11 @@ const DIAGNOSTIC_PATTERNS = [
|
|
|
47
47
|
/\bwhich\s+(approach|option|way|one)\s+(is\s+better|should|do\s+you)\b/i,
|
|
48
48
|
/\bis\s+it\s+better\s+to\b/i,
|
|
49
49
|
/\bwhat'?s?\s+the\s+(right|best|correct)\s+(approach|way)\b/i,
|
|
50
|
-
|
|
50
|
+
// Imperative "recommend" — anchored to prompt start or after sentence end.
|
|
51
|
+
// Prior version `/\brecommend\b/i` matched ANY occurrence and false-fired on
|
|
52
|
+
// "the recommendation system is broken" / "I recommend doing X" (statements,
|
|
53
|
+
// not questions). See wf-12271e82.
|
|
54
|
+
/(?:^|[.?!]\s+)\s*(please\s+|can\s+you\s+|could\s+you\s+|would\s+you\s+)?recommend\b/i,
|
|
51
55
|
/\bdid\s+you\s+(fix|address|verify|check|test|handle)\b/i,
|
|
52
56
|
/\bdo\s+you\s+(think|recommend|suggest)\b/i,
|
|
53
57
|
];
|
|
@@ -7,6 +7,45 @@
|
|
|
7
7
|
* Checks if there's an active task before allowing implementation actions.
|
|
8
8
|
*
|
|
9
9
|
* Returns a standardized result that adapters transform for specific CLIs.
|
|
10
|
+
*
|
|
11
|
+
* --------------------------------------------------------------------------
|
|
12
|
+
* "Active task" state-source contract (wf-c573961f, 2026-05-11)
|
|
13
|
+
* --------------------------------------------------------------------------
|
|
14
|
+
*
|
|
15
|
+
* The task-gate consults FOUR state sources to determine if a task is active.
|
|
16
|
+
* External tooling that mutates task state MUST satisfy this contract or the
|
|
17
|
+
* gate will reject Edits/Writes with `no_active_task` or `task_missing_routing_proof`.
|
|
18
|
+
*
|
|
19
|
+
* 1. `.workflow/state/ready.json` → `inProgress[]`
|
|
20
|
+
* The task object must be the first element of inProgress, with a valid
|
|
21
|
+
* `wf-XXXXXXXX` id (validateTaskId).
|
|
22
|
+
*
|
|
23
|
+
* 2. `.workflow/state/durable-session.json` → `{ taskId, status: 'active' }`
|
|
24
|
+
* Fallback path when inProgress is empty. Also requires routing proof.
|
|
25
|
+
*
|
|
26
|
+
* 3. Task object `routedAt` field (ISO timestamp)
|
|
27
|
+
* Set automatically by `flow start` / `createQuickTask` / `moveTaskAsync`
|
|
28
|
+
* when a task legitimately enters inProgress.
|
|
29
|
+
*
|
|
30
|
+
* 4. `.workflow/state/.routing-receipt-<taskId>` (dotfile, JSON)
|
|
31
|
+
* Anti-bypass receipt written by the same callers that set `routedAt`.
|
|
32
|
+
* The dual mechanism is defense-in-depth: AI editing ready.json directly
|
|
33
|
+
* can spoof `routedAt`, but the receipt file is harder to forge silently.
|
|
34
|
+
*
|
|
35
|
+
* The gate accepts the task if EITHER (3) routedAt is set, OR (4) the
|
|
36
|
+
* receipt file exists, OR the task has a legacy `startedAt` field (pre-
|
|
37
|
+
* routedAt migration). One of these MUST be true; otherwise the task is
|
|
38
|
+
* rejected as "manually inserted".
|
|
39
|
+
*
|
|
40
|
+
* Documented helpers for external tooling:
|
|
41
|
+
* - `writeRoutingReceipt(taskId, options)` — produces a valid receipt for
|
|
42
|
+
* a task that's already in inProgress (e.g., after `flow bug` then a
|
|
43
|
+
* manual move). This is the documented "I know what I'm doing" escape
|
|
44
|
+
* hatch that closes the 2026-05-10 wogiflow-cli bug-report issue.
|
|
45
|
+
*
|
|
46
|
+
* Single source of truth in the future: see the deferred follow-up epic
|
|
47
|
+
* referenced in wf-c573961f's bug spec ("Approach 1 / consolidate to
|
|
48
|
+
* .workflow/state/active-task.json"). That work is out of L2 scope.
|
|
10
49
|
*/
|
|
11
50
|
|
|
12
51
|
const fs = require('node:fs');
|
|
@@ -314,7 +353,7 @@ function checkTaskGate(options = {}, config) {
|
|
|
314
353
|
return {
|
|
315
354
|
allowed: false,
|
|
316
355
|
blocked: true,
|
|
317
|
-
message: `Task ${taskId} is in inProgress but has no routing proof (missing routedAt and no routing receipt file).\n\nThis usually means the task was inserted into ready.json manually instead of through /wogi-start.\n\nTo fix:\n1. Use /wogi-start ${taskId} to properly route this task
|
|
356
|
+
message: `Task ${taskId} is in inProgress but has no routing proof (missing routedAt and no routing receipt file).\n\nThis usually means the task was inserted into ready.json manually instead of through /wogi-start.\n\nTo fix:\n1. RECOMMENDED — Use /wogi-start ${taskId} to properly route this task.\n2. Or remove it from inProgress and start fresh: /wogi-ready\n3. Or, if you know what you're doing (e.g. after \`flow bug\` + manual move), write a routing receipt:\n node -e "require('./scripts/hooks/core/task-gate').writeRoutingReceipt('${taskId}', { via: 'manual' })"\n\nSee scripts/hooks/core/task-gate.js header for the "active task" state-source contract.`,
|
|
318
357
|
reason: 'task_missing_routing_proof'
|
|
319
358
|
};
|
|
320
359
|
}
|
|
@@ -410,6 +449,42 @@ function checkTaskGate(options = {}, config) {
|
|
|
410
449
|
};
|
|
411
450
|
}
|
|
412
451
|
|
|
452
|
+
/**
|
|
453
|
+
* Write a routing receipt for a task. This is the documented external API
|
|
454
|
+
* for satisfying the routing-proof requirement (state source #4) when the
|
|
455
|
+
* task is in inProgress but was placed there by a caller that didn't write
|
|
456
|
+
* a receipt (e.g., manual edit, or `flow bug` then move).
|
|
457
|
+
*
|
|
458
|
+
* Returns `{ ok: true, path }` on success or `{ ok: false, reason }` on
|
|
459
|
+
* failure. Validates taskId format before writing.
|
|
460
|
+
*
|
|
461
|
+
* wf-c573961f: previously this functionality was inlined in createQuickTask
|
|
462
|
+
* and not exported, leaving external tooling no documented way to satisfy
|
|
463
|
+
* the gate. The bug-report author hit exactly this — they updated four
|
|
464
|
+
* state files but couldn't discover the receipt-file requirement.
|
|
465
|
+
*
|
|
466
|
+
* @param {string} taskId - wf-XXXXXXXX format
|
|
467
|
+
* @param {Object} [options]
|
|
468
|
+
* @param {string} [options.via='external'] - audit field stored in the receipt
|
|
469
|
+
* @returns {{ok: boolean, path?: string, reason?: string}}
|
|
470
|
+
*/
|
|
471
|
+
function writeRoutingReceipt(taskId, options = {}) {
|
|
472
|
+
if (typeof taskId !== 'string' || !validateTaskId(taskId).valid) {
|
|
473
|
+
return { ok: false, reason: `invalid taskId format: ${taskId}` };
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
const receiptPath = path.join(PATHS.state, `.routing-receipt-${taskId}`);
|
|
477
|
+
fs.writeFileSync(receiptPath, JSON.stringify({
|
|
478
|
+
taskId,
|
|
479
|
+
routedAt: new Date().toISOString(),
|
|
480
|
+
via: options.via || 'external'
|
|
481
|
+
}, null, 2));
|
|
482
|
+
return { ok: true, path: receiptPath };
|
|
483
|
+
} catch (err) {
|
|
484
|
+
return { ok: false, reason: err.message };
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
413
488
|
/**
|
|
414
489
|
* Generate warning message (when not blocking)
|
|
415
490
|
*/
|
|
@@ -523,6 +598,7 @@ module.exports = {
|
|
|
523
598
|
getActiveTask,
|
|
524
599
|
checkTaskGate,
|
|
525
600
|
createQuickTask,
|
|
601
|
+
writeRoutingReceipt,
|
|
526
602
|
generateBlockMessage,
|
|
527
603
|
generateWarningMessage
|
|
528
604
|
};
|
|
@@ -78,6 +78,20 @@ runHook('SessionStart', async ({ parsedInput }) => {
|
|
|
78
78
|
await bridgeSyncPromise;
|
|
79
79
|
_bootMark('after bridge sync');
|
|
80
80
|
|
|
81
|
+
// wf-b8839d99: Refresh standing no-defer pin from decisions.md if a policy
|
|
82
|
+
// section is present. Fail-open — never blocks session start.
|
|
83
|
+
try {
|
|
84
|
+
const { refreshFromPolicy } = require('../../core/no-defer-policy');
|
|
85
|
+
const r = refreshFromPolicy();
|
|
86
|
+
if (r.refreshed && process.env.DEBUG) {
|
|
87
|
+
console.error(`[session-start] Refreshed no-defer pin from policy: ${r.header}`);
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (process.env.DEBUG) {
|
|
91
|
+
console.error(`[session-start] no-defer policy refresh failed: ${err.message}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
81
95
|
// CLAUDE.md drift detection — check if manually edited since last sync
|
|
82
96
|
let driftDetected = false;
|
|
83
97
|
let driftMarkerMissing = false;
|
|
@@ -17,12 +17,49 @@ const { isRoutingPending, incrementStopAttempts } = require('../../core/routing-
|
|
|
17
17
|
const { runHook } = require('../shared/hook-runner');
|
|
18
18
|
|
|
19
19
|
runHook('Stop', async ({ parsedInput }) => {
|
|
20
|
+
// wf-35742353 — Gate priority: if long-input-pending is active, it is the
|
|
21
|
+
// top-priority remediation. The UserPromptSubmit hook already surfaced the
|
|
22
|
+
// full long-input message on prompt arrival; firing routing-enforcement
|
|
23
|
+
// and research-required gates now would issue conflicting "do this NOW"
|
|
24
|
+
// instructions in the same turn. Defer the lower-priority Stop-hook gates
|
|
25
|
+
// until long-input-pending is resolved.
|
|
26
|
+
//
|
|
27
|
+
// Fail-open: any error reading the marker falls through to normal gate flow.
|
|
28
|
+
let longInputActive = false;
|
|
29
|
+
try {
|
|
30
|
+
const { isLongInputPending } = require('../../core/long-input-enforcement');
|
|
31
|
+
longInputActive = isLongInputPending();
|
|
32
|
+
} catch (_err) { /* fail-open */ }
|
|
33
|
+
|
|
34
|
+
// wf-b8839d99 fix #5 — Routing-recovery grace window. If the user just
|
|
35
|
+
// corrected a prior AI defer-auth ("I did not authorize..."), the deferral
|
|
36
|
+
// classifier wrote a 60-second grace marker. During that window, the AI
|
|
37
|
+
// should be able to undo/revoke without bouncing through /wogi-start first.
|
|
38
|
+
// Routing-enforcement softens to a single warning instead of hard-blocking.
|
|
39
|
+
let recoveryGraceActive = false;
|
|
40
|
+
try {
|
|
41
|
+
const fs = require('node:fs');
|
|
42
|
+
const path = require('node:path');
|
|
43
|
+
const { PATHS } = require('../../../flow-utils');
|
|
44
|
+
const gracePath = path.join(PATHS.state, 'routing-recovery-grace.json');
|
|
45
|
+
if (fs.existsSync(gracePath)) {
|
|
46
|
+
const raw = fs.readFileSync(gracePath, 'utf-8');
|
|
47
|
+
const data = JSON.parse(raw);
|
|
48
|
+
if (data?.expiresAt && Date.parse(data.expiresAt) > Date.now()) {
|
|
49
|
+
recoveryGraceActive = true;
|
|
50
|
+
} else {
|
|
51
|
+
// Expired — clean up
|
|
52
|
+
try { fs.unlinkSync(gracePath); } catch (_err) { /* fine */ }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch (_err) { /* fail-open */ }
|
|
56
|
+
|
|
20
57
|
// v6.2: Routing enforcement check — catches text-only response bypass
|
|
21
58
|
// If routing-pending flag is still set when the AI tries to stop, it means
|
|
22
59
|
// the AI responded to the user's message without ever invoking a /wogi-* command.
|
|
23
60
|
// This is the exact bypass we need to prevent (especially after context compaction).
|
|
24
61
|
try {
|
|
25
|
-
if (isRoutingPending()) {
|
|
62
|
+
if (isRoutingPending() && !longInputActive && !recoveryGraceActive) {
|
|
26
63
|
// Use counter-based approach instead of clearing immediately.
|
|
27
64
|
// This gives the AI multiple chances to comply before giving up.
|
|
28
65
|
// Gap 4 fix: clearing immediately made this single-shot protection.
|
|
@@ -260,7 +297,15 @@ runHook('Stop', async ({ parsedInput }) => {
|
|
|
260
297
|
// turn was classified as diagnostic (Tier 2/3 from CLAUDE.md), check that
|
|
261
298
|
// the AI made enough Read calls against evidence paths before answering.
|
|
262
299
|
// If not, re-prompt with a violation message forcing a redo. Fail-open.
|
|
300
|
+
//
|
|
301
|
+
// wf-35742353 — Skip this gate when long-input-pending is active. The user's
|
|
302
|
+
// prompt isn't yet captured, so demanding evidence-reading would issue a
|
|
303
|
+
// conflicting remediation. The diagnostic marker will still be present when
|
|
304
|
+
// long-input resolves; the gate fires correctly then.
|
|
263
305
|
try {
|
|
306
|
+
if (longInputActive) {
|
|
307
|
+
// skip — defer to long-input remediation
|
|
308
|
+
} else {
|
|
264
309
|
const { checkResearchRequiredGate } = require('../../core/research-required-gate');
|
|
265
310
|
const { getConfig } = require('../../../flow-utils');
|
|
266
311
|
const config = getConfig();
|
|
@@ -276,6 +321,7 @@ runHook('Stop', async ({ parsedInput }) => {
|
|
|
276
321
|
// Soft re-prompt: force the AI to redo with reads
|
|
277
322
|
return { __raw: true, continue: true, stopReason: result.message };
|
|
278
323
|
}
|
|
324
|
+
} // end else (wf-35742353 long-input-active skip)
|
|
279
325
|
} catch (err) {
|
|
280
326
|
if (process.env.DEBUG) {
|
|
281
327
|
console.error(`[Stop] Research-required gate error (fail-open): ${err.message}`);
|
|
@@ -100,18 +100,22 @@ runHook('UserPromptSubmit', async ({ input, parsedInput }) => {
|
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
// wf-
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
103
|
+
// wf-b8839d99 (replaces wf-f9912af6 regex classifier): AI-based deferral-
|
|
104
|
+
// intent classifier. Calls Haiku to interpret the user's prompt. NEGATIVE
|
|
105
|
+
// ("fix all", "I don't like tech debt", any phrasing) writes a no-defer-pin;
|
|
106
|
+
// POSITIVE ("defer F5", "option 2", "ship as-is") writes a scoped auth
|
|
107
|
+
// marker. The marker now captures the verbatim user excerpt SEPARATELY from
|
|
108
|
+
// the AI's interpretation — ending the false-attribution failure shape.
|
|
109
|
+
// Fail-open throughout: classifier errors / missing API key → no state
|
|
110
|
+
// change (status quo holds; gate's default-restrictive behavior preserved).
|
|
109
111
|
if (typeof prompt === 'string' && prompt.trim().length > 0) {
|
|
110
112
|
try {
|
|
111
113
|
const { applyClassification } = require('../../core/deferral-classifier');
|
|
112
|
-
const r = applyClassification(prompt, hookConfig);
|
|
114
|
+
const r = await applyClassification(prompt, hookConfig);
|
|
113
115
|
if (r.applied && process.env.DEBUG) {
|
|
114
|
-
console.error(`[Hook] Deferral classifier: intent=${r.intent},
|
|
116
|
+
console.error(`[Hook] Deferral classifier (AI): intent=${r.intent}, confidence=${r.confidence}, standing=${r.standing}, scope=${JSON.stringify(r.scope)}`);
|
|
117
|
+
} else if (process.env.DEBUG && r.reason) {
|
|
118
|
+
console.error(`[Hook] Deferral classifier (AI): no-op — ${r.reason}`);
|
|
115
119
|
}
|
|
116
120
|
} catch (err) {
|
|
117
121
|
if (process.env.DEBUG) {
|