wogiflow 2.30.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wogiflow",
3
- "version": "2.30.2",
3
+ "version": "2.30.3",
4
4
  "description": "AI-powered development workflow management system with multi-model support",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -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
+ };
@@ -1,129 +1,148 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Wogi Flow — Deferral Intent Classifier (wf-f9912af6)
4
+ * Wogi Flow — Deferral Intent Classifier — Hook Wrapper (wf-b8839d99)
5
5
  *
6
- * Regex-based detector for explicit user deferral intent in UserPromptSubmit
7
- * messages. Cheap (no Haiku call), deterministic, runs every prompt.
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
- * NEGATIVE intent takes precedence over POSITIVE — if the user says both
10
- * "fix everything" and "skip Y" in the same message, we assume they want
11
- * everything fixed (the defer-everything pattern is the dangerous one this
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
- * Negative match → write `no-defer-pin.json` (HARD block, overrides any auth)
15
- * Positive match write `deferral-authorization.json` (allows specific scope)
16
- * Neither → no-op
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
- * Fail-open: any error in classification falls through silently.
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
- // Negative phrases (HIGH PRIORITY — clear auth, write no-defer pin)
22
- const NEGATIVE_PATTERNS = [
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
- * Classify a user prompt for deferral intent.
35
+ * Apply deferral-intent classification at UserPromptSubmit time.
58
36
  *
59
- * @param {string} prompt - the user's UserPromptSubmit text
60
- * @returns {{ intent: 'negative'|'positive'|'none', match?: string, scope?: string|string[] }}
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 classifyDeferralIntent(prompt) {
63
- if (!prompt || typeof prompt !== 'string') return { intent: 'none' };
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
- // Negative first overrides positive
66
- for (const rx of NEGATIVE_PATTERNS) {
67
- const m = prompt.match(rx);
68
- if (m) return { intent: 'negative', match: m[0] };
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
- // Positive
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
- intent: 'positive',
79
- match: m[0],
80
- scope: findingIds.length > 0 ? findingIds : 'all'
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
- const result = classifyDeferralIntent(prompt);
97
- if (result.intent === 'none') return { applied: false, reason: 'no-match' };
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
- // Lazy-require to avoid load-order coupling
100
- const gate = require('./deferral-gate');
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
- if (result.intent === 'negative') {
103
- gate.writeNoDeferPin({ source: result.match });
104
- return { applied: true, intent: 'negative', match: result.match };
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 === 'positive') {
117
+ if (result.intent === POSITIVE_INTENT) {
108
118
  gate.writeAuth({
109
- scope: result.scope,
110
- source: result.match,
111
- grantedBy: 'user-prompt',
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 { applied: true, intent: 'positive', match: result.match, scope: result.scope };
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: 'unhandled-intent' };
135
+ return { applied: false, intent: result.intent, reason: 'none-intent' };
118
136
  } catch (err) {
119
- if (process.env.DEBUG) console.error(`[deferral-classifier] applyClassification error (fail-open): ${err.message}`);
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
- NEGATIVE_PATTERNS,
128
- POSITIVE_PATTERNS
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
- function writeAuth({ scope = 'all', source = 'unspecified', grantedBy = 'user-prompt', ttlSec, config } = {}) {
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: 1,
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({ source = 'unspecified', ttlSec = 1800 } = {}) {
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: 1,
191
+ version: 2,
152
192
  pinnedAt: new Date(now).toISOString(),
153
- expiresAt: new Date(now + ttlSec * 1000).toISOString(),
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,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
+ };
@@ -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;
@@ -31,12 +31,35 @@ runHook('Stop', async ({ parsedInput }) => {
31
31
  longInputActive = isLongInputPending();
32
32
  } catch (_err) { /* fail-open */ }
33
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
+
34
57
  // v6.2: Routing enforcement check — catches text-only response bypass
35
58
  // If routing-pending flag is still set when the AI tries to stop, it means
36
59
  // the AI responded to the user's message without ever invoking a /wogi-* command.
37
60
  // This is the exact bypass we need to prevent (especially after context compaction).
38
61
  try {
39
- if (isRoutingPending() && !longInputActive) {
62
+ if (isRoutingPending() && !longInputActive && !recoveryGraceActive) {
40
63
  // Use counter-based approach instead of clearing immediately.
41
64
  // This gives the AI multiple chances to comply before giving up.
42
65
  // Gap 4 fix: clearing immediately made this single-shot protection.
@@ -100,18 +100,22 @@ runHook('UserPromptSubmit', async ({ input, parsedInput }) => {
100
100
  }
101
101
  }
102
102
 
103
- // wf-f9912af6: Deferral-intent classifier detect explicit defer/no-defer
104
- // phrases in the user's prompt and write/clear the auth marker accordingly.
105
- // Negative intent ("fix everything", "no deferrals", "I don't want tech debt")
106
- // hard-pins the gate to block all deferrals; positive intent ("defer X",
107
- // "fix critical only", "ship as-is") writes a time-limited auth marker.
108
- // Fail-open throughout.
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}, match="${r.match}"`);
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) {