thumbgate 1.19.0 → 1.21.0

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.
@@ -13,6 +13,22 @@ const WARN_THRESHOLD = 1;
13
13
  const BLOCK_THRESHOLD = 3; // 3+ repeated failures hard-block the action
14
14
  const WINDOW_DAYS = 30;
15
15
 
16
+ // Default TTL on auto-promoted gates. Reddit reviewer @MomSausageandPeppers
17
+ // (2026-05-13) flagged that without expiry, "accidental dislikes become policy
18
+ // forever." Gates expire 90 days after promotion UNLESS they keep firing —
19
+ // every fire refreshes lastFiredAt, and expireGates() keeps any gate fired
20
+ // within the last TTL window regardless of original promotion date. Manual
21
+ // force-promote bypasses TTL (operator says "permanent"). Override via
22
+ // THUMBGATE_RULE_TTL_DAYS env var.
23
+ const DEFAULT_RULE_TTL_DAYS = 90;
24
+ function getRuleTtlDays() {
25
+ const raw = Number(process.env.THUMBGATE_RULE_TTL_DAYS);
26
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_RULE_TTL_DAYS;
27
+ }
28
+ function getRuleTtlMs() {
29
+ return getRuleTtlDays() * 24 * 60 * 60 * 1000;
30
+ }
31
+
16
32
  const NEG_SIGNALS = new Set(['negative', 'negative_strong', 'down', 'thumbs_down']);
17
33
 
18
34
  function getFeedbackLogPath() {
@@ -66,15 +82,54 @@ function isNegative(entry) {
66
82
  return NEG_SIGNALS.has(sig);
67
83
  }
68
84
 
85
+ /**
86
+ * Normalize a captured command/context string so trivial variants collapse
87
+ * to the same gate signature.
88
+ *
89
+ * Reddit critique (MomSausageandPeppers, 2026-05-17): "commands are matched
90
+ * by string equality, so `rm -rf node_modules` and `rm -rf ./node_modules`
91
+ * create separate gates."
92
+ *
93
+ * Conservative — only collapse variants that are *unambiguously* the same
94
+ * intent. Does NOT reorder flags, strip `&&` chains, or canonicalize
95
+ * subcommands (each can change semantics).
96
+ *
97
+ * 1. Lowercase
98
+ * 2. Strip `/Users/<name>` and `/home/<name>` home-dir prefixes (→ `~`)
99
+ * 3. Drop `:LINE` and `:LINE:COL` refs
100
+ * 4. Per-token: strip one layer of matching outer quotes/backticks
101
+ * 5. Per-token: drop leading `./`
102
+ * 6. Collapse whitespace + trim
103
+ */
104
+ function normalizeCommandSignature(input) {
105
+ let text = String(input || '');
106
+ if (!text) return '';
107
+ text = text.toLowerCase();
108
+ text = text.replace(/\/users\/[^\s/]+/g, '~').replace(/\/home\/[^\s/]+/g, '~');
109
+ text = text.replace(/:\d+(?::\d+)?\b/g, '');
110
+ const tokens = text.split(/\s+/).filter(Boolean).map((tok) => {
111
+ let t = tok;
112
+ if (t.length >= 2) {
113
+ const first = t[0];
114
+ const last = t[t.length - 1];
115
+ if ((first === '"' || first === "'" || first === '`') && first === last) {
116
+ t = t.slice(1, -1);
117
+ }
118
+ }
119
+ if (t.startsWith('./')) t = t.slice(2);
120
+ return t;
121
+ }).filter(Boolean);
122
+ return tokens.join(' ').trim();
123
+ }
124
+
69
125
  function extractPatternKey(entry) {
70
126
  // Use tags as primary grouping key; fall back to context normalization
71
127
  const tags = (entry.tags || []).filter((t) => !['feedback', 'negative', 'positive'].includes(t));
72
128
  if (tags.length > 0) return tags.sort().join('+');
73
129
 
74
- const ctx = (entry.context || entry.whatWentWrong || '').toLowerCase().trim();
130
+ const ctx = (entry.context || entry.whatWentWrong || '').trim();
75
131
  if (ctx.length < 10) return null;
76
- // Normalize paths and numbers for grouping
77
- return ctx.replace(/\/Users\/[^\s/]+/g, '~').replace(/:[0-9]+/g, '').replace(/\s+/g, ' ').slice(0, 100);
132
+ return normalizeCommandSignature(ctx).slice(0, 100);
78
133
  }
79
134
 
80
135
  function extractDiagnosticKeys(entry) {
@@ -138,19 +193,26 @@ function patternToGateId(key) {
138
193
  return 'auto-' + key.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').slice(0, 50).toLowerCase();
139
194
  }
140
195
 
141
- function buildGateRule(group) {
142
- const action = group.count === 'MANUAL' ? group.manualAction || 'block' : (group.count >= BLOCK_THRESHOLD ? 'block' : 'warn');
143
- const severity = action === 'block' ? 'critical' : 'medium';
196
+ function buildGateRule(group, actionOverride) {
197
+ const action = actionOverride || (group.count === 'MANUAL' ? group.manualAction || 'block' : (group.count >= BLOCK_THRESHOLD ? 'block' : 'warn'));
198
+ const severity = action === 'block' ? 'critical' : action === 'approve' ? 'high' : 'medium';
144
199
  const context = group.latestContext.slice(0, 120);
145
200
  const kind = group.key.startsWith('diagnosis:')
146
201
  ? 'repeated diagnosis'
147
202
  : group.key.startsWith('constraint:')
148
203
  ? 'repeated constraint violation'
149
204
  : 'repeated pattern';
150
-
205
+
151
206
  const occurrencesText = group.count === 'MANUAL' ? 'manual' : `${group.count} occurrences`;
152
207
  const suggestedMessage = `Auto-promoted ${kind}: "${context}" (${occurrencesText} in ${WINDOW_DAYS} days)`;
153
208
 
209
+ // TTL: auto-promoted rules expire after the configured window unless
210
+ // refreshed by a fresh fire. Manual force-promote bypasses TTL — operator
211
+ // says "permanent" by going through the force path.
212
+ const nowMs = Date.now();
213
+ const isManual = group.count === 'MANUAL';
214
+ const expiresAt = isManual ? null : new Date(nowMs + getRuleTtlMs()).toISOString();
215
+
154
216
  return {
155
217
  id: patternToGateId(group.key),
156
218
  trigger: `auto:${group.key}`,
@@ -160,10 +222,82 @@ function buildGateRule(group) {
160
222
  severity,
161
223
  occurrences: group.count,
162
224
  promotedAt: new Date().toISOString(),
225
+ expiresAt,
226
+ lastFiredAt: null,
163
227
  source: group.source || 'auto-promote',
164
228
  };
165
229
  }
166
230
 
231
+ /**
232
+ * Drop expired gates from the data and return the gates removed.
233
+ *
234
+ * A gate is expired when its `expiresAt` is in the past AND its
235
+ * `lastFiredAt` (if set) is also outside the TTL window — high-signal
236
+ * gates that keep firing get continuously renewed and never expire.
237
+ *
238
+ * `expiresAt: null` is treated as "permanent" (used by force-promote /
239
+ * legacy gates without TTL data).
240
+ */
241
+ function expireGates(data, now = Date.now()) {
242
+ const safeData = data && typeof data === 'object'
243
+ ? { version: data.version || 1, gates: Array.isArray(data.gates) ? data.gates : [], promotionLog: Array.isArray(data.promotionLog) ? data.promotionLog : [] }
244
+ : { version: 1, gates: [], promotionLog: [] };
245
+ const ttlMs = getRuleTtlMs();
246
+ const kept = [];
247
+ const expired = [];
248
+ for (const gate of safeData.gates) {
249
+ if (!gate || typeof gate !== 'object') continue;
250
+ // No expiresAt → treat as permanent (manual force-promote, legacy gates).
251
+ if (gate.expiresAt == null) {
252
+ kept.push(gate);
253
+ continue;
254
+ }
255
+ const expiresMs = Date.parse(gate.expiresAt);
256
+ if (!Number.isFinite(expiresMs)) {
257
+ kept.push(gate);
258
+ continue;
259
+ }
260
+ // If last fire is within TTL window, refresh the gate (extend expiresAt).
261
+ const lastFiredMs = gate.lastFiredAt ? Date.parse(gate.lastFiredAt) : NaN;
262
+ if (Number.isFinite(lastFiredMs) && now - lastFiredMs < ttlMs) {
263
+ kept.push({ ...gate, expiresAt: new Date(lastFiredMs + ttlMs).toISOString() });
264
+ continue;
265
+ }
266
+ if (now < expiresMs) {
267
+ kept.push(gate);
268
+ } else {
269
+ expired.push({ id: gate.id, expiresAt: gate.expiresAt, lastFiredAt: gate.lastFiredAt });
270
+ }
271
+ }
272
+ safeData.gates = kept;
273
+ if (expired.length > 0) {
274
+ safeData.promotionLog.push(
275
+ ...expired.map((e) => ({ type: 'expired', gateId: e.id, expiredAt: e.expiresAt, timestamp: new Date(now).toISOString() }))
276
+ );
277
+ }
278
+ return { data: safeData, expired };
279
+ }
280
+
281
+ /**
282
+ * Mark a gate as fired now. Refreshes lastFiredAt AND extends expiresAt by
283
+ * the full TTL — a gate that keeps catching repeats sharpens, doesn't
284
+ * decay. Caller passes the gate ID; returns the updated gate (or null).
285
+ */
286
+ function recordGateFire(data, gateId, now = Date.now()) {
287
+ if (!data || !Array.isArray(data.gates)) return null;
288
+ const idx = data.gates.findIndex((g) => g && g.id === gateId);
289
+ if (idx === -1) return null;
290
+ const gate = data.gates[idx];
291
+ const lastFiredAtIso = new Date(now).toISOString();
292
+ const updated = {
293
+ ...gate,
294
+ lastFiredAt: lastFiredAtIso,
295
+ expiresAt: gate.expiresAt == null ? null : new Date(now + getRuleTtlMs()).toISOString(),
296
+ };
297
+ data.gates[idx] = updated;
298
+ return updated;
299
+ }
300
+
167
301
  function forcePromote(context, action = 'block') {
168
302
  if (!context) throw new Error('context is required for force-promote');
169
303
  const data = loadAutoGates();
@@ -198,13 +332,20 @@ function forcePromote(context, action = 'block') {
198
332
  return { gateId, action, totalGates: data.gates.length };
199
333
  }
200
334
 
201
- function promote(feedbackLogPath) {
335
+ function promote(feedbackLogPath, options) {
336
+ const opts = options || {};
202
337
  const logPath = feedbackLogPath || getFeedbackLogPath();
203
338
  const entries = readJSONL(logPath);
204
339
  const groups = groupNegativeFeedback(entries, WINDOW_DAYS);
205
- const data = loadAutoGates();
206
- const existingIds = new Set(data.gates.map((g) => g.id));
207
- const promotions = [];
340
+ // Expire stale gates BEFORE running the promotion loop so an expiring
341
+ // gate that's about to be re-promoted gets a fresh TTL via the normal
342
+ // path rather than carrying a near-stale expiresAt.
343
+ const { data: expiredData, expired } = expireGates(loadAutoGates());
344
+ const data = expiredData;
345
+ if (expired.length > 0) {
346
+ saveAutoGates(data);
347
+ }
348
+ const promotions = expired.map((e) => ({ type: 'expired', gateId: e.id, expiredAt: e.expiresAt }));
208
349
 
209
350
  for (const group of Object.values(groups)) {
210
351
  if (group.count < WARN_THRESHOLD) continue;
@@ -226,8 +367,8 @@ function promote(feedbackLogPath) {
226
367
  continue;
227
368
  }
228
369
 
229
- // New gate
230
- const gate = buildGateRule(group);
370
+ // New gate — respect explicit gateAction override (e.g. 'approve' for human-approval rules)
371
+ const gate = buildGateRule(group, opts.gateAction);
231
372
 
232
373
  // Enforce max limit — rotate oldest
233
374
  if (data.gates.length >= MAX_AUTO_GATES) {
@@ -298,9 +439,15 @@ module.exports = {
298
439
  patternToGateId,
299
440
  buildGateRule,
300
441
  extractPatternKey,
442
+ normalizeCommandSignature,
301
443
  isNegative,
444
+ expireGates,
445
+ recordGateFire,
446
+ getRuleTtlDays,
447
+ getRuleTtlMs,
302
448
  MAX_AUTO_GATES,
303
449
  WARN_THRESHOLD,
304
450
  BLOCK_THRESHOLD,
305
451
  WINDOW_DAYS,
452
+ DEFAULT_RULE_TTL_DAYS,
306
453
  };
@@ -186,48 +186,69 @@ function hookAlreadyPresent(hookArray, command) {
186
186
  * (defaults to process.cwd()).
187
187
  * @returns {{ hooks: Array, removedPaths: string[] }}
188
188
  */
189
+ // Shell-style variable expansion limited to the env vars Claude Code
190
+ // documents for hook commands (CLAUDE_PROJECT_DIR), plus other process env
191
+ // vars. Surrounding ASCII quotes are stripped first so tokens like
192
+ // `"$CLAUDE_PROJECT_DIR"/.claude/hooks/x.sh` resolve correctly.
193
+ function expandShellToken(token, resolveBase) {
194
+ let s = token;
195
+ if (s.startsWith('"') && s.includes('"', 1)) {
196
+ s = s.slice(1, s.indexOf('"', 1)) + s.slice(s.indexOf('"', 1) + 1);
197
+ } else if (s.startsWith("'") && s.includes("'", 1)) {
198
+ s = s.slice(1, s.indexOf("'", 1)) + s.slice(s.indexOf("'", 1) + 1);
199
+ }
200
+ const lookup = (name) => (name === 'CLAUDE_PROJECT_DIR'
201
+ ? process.env.CLAUDE_PROJECT_DIR || resolveBase
202
+ : process.env[name]);
203
+ s = s.replace(/\$\{([A-Za-z_]\w*)\}/g, (_, n) => {
204
+ const v = lookup(n);
205
+ return v == null ? `\${${n}}` : v;
206
+ });
207
+ s = s.replace(/\$([A-Za-z_]\w*)/g, (_, n) => {
208
+ const v = lookup(n);
209
+ return v == null ? `$${n}` : v;
210
+ });
211
+ return s;
212
+ }
213
+
214
+ // Returns the raw (unexpanded) script-path token if the command points at a
215
+ // missing script file, else null. Anything that doesn't look like a file
216
+ // reference, or contains unresolved $VAR after expansion, returns null —
217
+ // caller treats null as "keep the hook" (err on the side of NOT pruning).
218
+ function staleHookPath(command, resolveBase) {
219
+ if (!command) return null;
220
+ const rawFirstToken = command.split(/\s+/)[0];
221
+ const firstToken = expandShellToken(rawFirstToken, resolveBase);
222
+ const looksLikePath =
223
+ firstToken.includes('/') ||
224
+ firstToken.includes('\\') ||
225
+ firstToken.endsWith('.sh');
226
+ if (!looksLikePath) return null;
227
+ if (firstToken.includes('$')) return null;
228
+ const resolved = path.isAbsolute(firstToken)
229
+ ? firstToken
230
+ : path.resolve(resolveBase, firstToken);
231
+ return fs.existsSync(resolved) ? null : rawFirstToken;
232
+ }
233
+
189
234
  function pruneStaleFileHooks(hookArray, baseDir) {
190
235
  if (!Array.isArray(hookArray)) {
191
236
  return { hooks: [], removedPaths: [] };
192
237
  }
193
-
194
238
  const resolveBase = baseDir || process.cwd();
195
239
  const removedPaths = [];
196
-
197
240
  const hooks = hookArray.filter((entry) => {
198
241
  const entryHooks = Array.isArray(entry && entry.hooks) ? entry.hooks : [];
199
- let shouldRemove = false;
200
-
201
242
  for (const hook of entryHooks) {
202
243
  const command = hook && typeof hook.command === 'string' ? hook.command : '';
203
- if (!command) continue;
204
-
205
- // Extract the first token as the potential script path.
206
- const firstToken = command.split(/\s+/)[0];
207
-
208
- // Only treat it as a file reference if it looks like a path.
209
- const looksLikePath =
210
- firstToken.includes('/') ||
211
- firstToken.includes('\\') ||
212
- firstToken.endsWith('.sh');
213
-
214
- if (!looksLikePath) continue;
215
-
216
- // Resolve the path (absolute or relative to baseDir).
217
- const resolved = path.isAbsolute(firstToken)
218
- ? firstToken
219
- : path.resolve(resolveBase, firstToken);
220
-
221
- if (!fs.existsSync(resolved)) {
222
- removedPaths.push(firstToken);
223
- shouldRemove = true;
224
- break;
244
+ const stale = staleHookPath(command, resolveBase);
245
+ if (stale !== null) {
246
+ removedPaths.push(stale);
247
+ return false;
225
248
  }
226
249
  }
227
-
228
- return !shouldRemove;
250
+ return true;
229
251
  });
230
-
231
252
  return { hooks, removedPaths };
232
253
  }
233
254
 
@@ -444,6 +444,57 @@ function buildCheckoutProductData({ name, description, appOrigin, planId }) {
444
444
  };
445
445
  }
446
446
 
447
+ /**
448
+ * Verify an ACTIVE Stripe product exists for the given plan name before
449
+ * we let buildSubscriptionPriceData create inline price_data under it.
450
+ *
451
+ * Stripe matches product_data by `name`. If only an archived product
452
+ * matches, new prices created via that path inherit active=false and the
453
+ * generated checkout URL renders "Something went wrong / The page you
454
+ * were looking for could not be found." for the buyer. Stripe Dashboard
455
+ * shows the session as `open` with no email captured — looks like the
456
+ * buyer abandoned, but they were never given a working page.
457
+ *
458
+ * Failing here surfaces the misconfiguration at the first checkout
459
+ * attempt instead of silently breaking every buyer for days.
460
+ *
461
+ * Verified incident: ThumbGate#2188 (May 2026) — 20 sessions abandoned in
462
+ * 7 days, all because the only product named "ThumbGate Pro" matching the
463
+ * inline product_data was archived (prod_UXxOHAfbDsPyRb), while an active
464
+ * product with the same name existed (prod_UW82THPxfNvwKT) that should
465
+ * have been used instead.
466
+ */
467
+ async function verifyActiveProductForPlan(stripe, planId) {
468
+ const expectedName = planId === 'team' ? 'ThumbGate Team' : 'ThumbGate Pro';
469
+ let products;
470
+ try {
471
+ products = await stripe.products.list({ limit: 100 });
472
+ } catch (err) {
473
+ // Network/transient failures shouldn't block checkout creation.
474
+ // The original session.create call will surface real Stripe errors.
475
+ return;
476
+ }
477
+ const matching = (products && products.data ? products.data : [])
478
+ .filter((p) => p && p.name === expectedName);
479
+ if (matching.length === 0) {
480
+ // No product with this name exists; Stripe will create a new one when
481
+ // session.create fires with inline product_data. Safe path.
482
+ return;
483
+ }
484
+ const active = matching.find((p) => p.active === true);
485
+ if (!active) {
486
+ const archived = matching[0];
487
+ throw new Error(
488
+ `Refusing to create checkout session: Stripe product named "${expectedName}" ` +
489
+ `exists only in archived state (id=${archived.id}, active=false). New prices ` +
490
+ `created via inline product_data would inherit active=false, rendering ` +
491
+ `"page not found" on Stripe checkout for every buyer. Fix: reactivate the ` +
492
+ `archived product in Stripe Dashboard, or rename the active product to ` +
493
+ `match "${expectedName}". See ThumbGate#2188 for the May 2026 incident.`
494
+ );
495
+ }
496
+ }
497
+
447
498
  function buildSubscriptionPriceData(checkoutSelection, appOrigin) {
448
499
  const isTeam = checkoutSelection.planId === 'team';
449
500
  const annual = checkoutSelection.billingCycle === 'annual';
@@ -2525,6 +2576,18 @@ async function createCheckoutSession({ successUrl, cancelUrl, customerEmail, ins
2525
2576
  }
2526
2577
 
2527
2578
  const stripe = getStripeClient();
2579
+
2580
+ // Defensive guard against ThumbGate#2188:
2581
+ // When buildSubscriptionPriceData passes inline `product_data` to Stripe,
2582
+ // Stripe name-matches existing products. If the only existing product with
2583
+ // that name is ARCHIVED (active=false), the new price inherits active=false
2584
+ // and every Stripe checkout page renders "page not found" for the buyer.
2585
+ // That bug burnt 20+ silent abandoned sessions in May 2026. Fail fast
2586
+ // instead of letting the broken page ship.
2587
+ if (!packId) {
2588
+ await verifyActiveProductForPlan(stripe, checkoutSelection.planId);
2589
+ }
2590
+
2528
2591
  const sessionPayload = buildCheckoutSessionPayload({
2529
2592
  successUrl,
2530
2593
  cancelUrl,
@@ -3127,6 +3190,7 @@ module.exports = {
3127
3190
  _buildTrialActivationEmail: buildTrialActivationEmail,
3128
3191
  _sendTrialActivationEmail: sendTrialActivationEmail,
3129
3192
  _resolveSubscriptionCheckoutSelection: resolveSubscriptionCheckoutSelection,
3193
+ _verifyActiveProductForPlan: verifyActiveProductForPlan,
3130
3194
  _API_KEYS_PATH: () => CONFIG.API_KEYS_PATH,
3131
3195
  _FUNNEL_LEDGER_PATH: () => CONFIG.FUNNEL_LEDGER_PATH,
3132
3196
  _REVENUE_LEDGER_PATH: () => CONFIG.REVENUE_LEDGER_PATH,
@@ -14,7 +14,15 @@
14
14
  */
15
15
 
16
16
  const { captureFeedback } = require('./feedback-loop');
17
- const { distillFromHistory } = require('./history-distiller');
17
+ const { loadOptionalModule } = require('./private-core-boundary');
18
+ // `history-distiller` is a PRIVATE_CORE_MODULE — present in this checkout and
19
+ // in ThumbGate-Core, but intentionally excluded from the public npm tarball.
20
+ // The hard `require('./history-distiller')` form crashed `hook-auto-capture`
21
+ // in published 1.19.0 with MODULE_NOT_FOUND. Public-shell fallback returns
22
+ // null distillation; caller already handles a null distillResult.
23
+ const { distillFromHistory } = loadOptionalModule('./history-distiller', () => ({
24
+ distillFromHistory: () => null,
25
+ }));
18
26
  const { getRecentLesson, getLessonStats } = require('./lesson-inference');
19
27
 
20
28
  const G = '\x1b[32m';
@@ -111,13 +111,41 @@ function assembleGuards(toolName, toolInput) {
111
111
  }
112
112
  }
113
113
 
114
- function assembleContextPack(query, agentProfile) {
114
+ function assembleContextPack(query, agentProfile, options = {}) {
115
+ const { guards } = options;
115
116
  try {
116
117
  ensureContextFs();
118
+
119
+ // 1. Proactive Governance: Filter what the agent sees based on prevention rules
120
+ let structuredQuery = query;
121
+ if (guards && guards.mode === 'block') {
122
+ structuredQuery = `${query} (Active Block Policy: ${guards.reason})`;
123
+ }
124
+
125
+ // 2. Elevate Thompson Sampling to Architecture Level
126
+ let strategy = null;
127
+ try {
128
+ const ts = require('./thompson-sampling');
129
+ const model = ts.loadModel();
130
+ const bestCategory = ts.argmaxPosteriors(model);
131
+
132
+ // Route between context-building strategies based on TS posterior mean
133
+ if (bestCategory === 'architecture' || bestCategory === 'infra') {
134
+ strategy = 'hierarchical';
135
+ } else if (bestCategory === 'observability' || bestCategory === 'debugging') {
136
+ strategy = 'summarize-then-expand';
137
+ } else {
138
+ strategy = 'semantic';
139
+ }
140
+ } catch (e) {
141
+ // Fallback to default routing
142
+ }
143
+
117
144
  return constructContextPack({
118
- query,
145
+ query: structuredQuery,
119
146
  maxItems: Math.min(8, Math.ceil(agentProfile.contextBudget / 1000)),
120
147
  maxChars: agentProfile.contextBudget,
148
+ strategy
121
149
  });
122
150
  } catch {
123
151
  return null;
@@ -228,6 +256,12 @@ function assembleUnifiedContext(params = {}) {
228
256
  })),
229
257
  visibility: contextPack.visibility || null,
230
258
  cached: !!(contextPack.cache && contextPack.cache.hit),
259
+ layers: {
260
+ localState: session || null,
261
+ graphState: codeGraph || null,
262
+ policyState: guards || null,
263
+ sessionState: contextPack.items ? contextPack.items.filter(i => i.namespace === 'session') : []
264
+ }
231
265
  } : null,
232
266
  codeGraph: codeGraph || null,
233
267
  assembledAt: new Date().toISOString(),
@@ -306,6 +340,12 @@ function formatUnifiedContext(ctx) {
306
340
 
307
341
  // Context pack
308
342
  if (ctx.contextPack) {
343
+ lines.push(`### Context Architecture Layers`);
344
+ lines.push(`- Local State: ${ctx.contextPack.layers.localState ? 'Active' : 'Empty'}`);
345
+ lines.push(`- Graph State: ${ctx.contextPack.layers.graphState ? 'Active' : 'Empty'}`);
346
+ lines.push(`- Policy State: ${ctx.contextPack.layers.policyState ? ctx.contextPack.layers.policyState.mode : 'Empty'}`);
347
+ lines.push(`- Session State: ${ctx.contextPack.layers.sessionState.length} items`);
348
+ lines.push('');
309
349
  lines.push(`### Context Pack (${ctx.contextPack.itemCount} items)`);
310
350
  ctx.contextPack.items.forEach((item) => {
311
351
  lines.push(`- [${item.namespace}] ${item.title} (score: ${item.score})`);
@@ -1059,6 +1059,7 @@ function captureFeedback(params) {
1059
1059
  : null),
1060
1060
  structuredRule: structuredRule || null,
1061
1061
  ...(reflection && { reflection }),
1062
+ gateAction: params.gateAction || null,
1062
1063
  timestamp: now,
1063
1064
  };
1064
1065
 
@@ -1398,7 +1399,7 @@ function captureFeedback(params) {
1398
1399
  if (feedbackEvent.signal === 'negative') {
1399
1400
  try {
1400
1401
  const autoPromote = require('./auto-promote-gates');
1401
- const promoteResult = autoPromote.promote(FEEDBACK_LOG_PATH);
1402
+ const promoteResult = autoPromote.promote(FEEDBACK_LOG_PATH, { gateAction: feedbackEvent.gateAction });
1402
1403
  // First-rule activation telemetry: anonymous ping the first time
1403
1404
  // a prevention rule auto-promotes for this install. Idempotent —
1404
1405
  // see scripts/activation-tracker.js. Critical for activation funnel