thumbgate 1.27.20 → 1.28.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.
Files changed (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +63 -42
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/mcp/server-stdio.js +1 -1
  6. package/adapters/opencode/opencode.json +1 -1
  7. package/bin/cli.js +25 -8
  8. package/bin/postinstall.js +1 -1
  9. package/config/entitlement-public-keys.json +6 -0
  10. package/config/gates/default.json +1 -1
  11. package/config/github-about.json +2 -2
  12. package/config/merge-quality-checks.json +3 -0
  13. package/package.json +12 -4
  14. package/public/about.html +1 -4
  15. package/public/agent-manager.html +6 -6
  16. package/public/agents-cost-savings.html +3 -3
  17. package/public/ai-malpractice-prevention.html +1 -1
  18. package/public/blog.html +12 -9
  19. package/public/chatgpt-app.html +3 -3
  20. package/public/codex-enterprise.html +3 -3
  21. package/public/codex-plugin.html +5 -5
  22. package/public/compare.html +10 -10
  23. package/public/dashboard.html +1 -1
  24. package/public/federal.html +2 -2
  25. package/public/guide.html +14 -14
  26. package/public/index.html +81 -70
  27. package/public/install.html +14 -14
  28. package/public/learn.html +4 -17
  29. package/public/numbers.html +2 -2
  30. package/public/pricing.html +12 -19
  31. package/public/pro.html +2 -2
  32. package/scripts/agent-reward-model.js +13 -0
  33. package/scripts/bayes-optimal-gate.js +6 -1
  34. package/scripts/cli-feedback.js +17 -1
  35. package/scripts/commercial-offer.js +3 -3
  36. package/scripts/entitlement.js +250 -0
  37. package/scripts/export-databricks-bundle.js +5 -0
  38. package/scripts/export-dpo-pairs.js +6 -0
  39. package/scripts/export-hf-dataset.js +5 -0
  40. package/scripts/gates-engine.js +15 -3
  41. package/scripts/imperative-detector.js +85 -0
  42. package/scripts/intervention-policy.js +13 -0
  43. package/scripts/pr-manager.js +9 -22
  44. package/scripts/pro-local-dashboard.js +198 -0
  45. package/scripts/risk-scorer.js +6 -0
  46. package/scripts/seo-gsd.js +2 -2
  47. package/scripts/thompson-sampling.js +11 -2
  48. package/src/api/server.js +81 -10
@@ -0,0 +1,250 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * entitlement.js — signed license entitlements for ThumbGate's paid tier.
5
+ *
6
+ * Replaces the old bypassable `tg_`/`tg_pro_` prefix check in license.js with a
7
+ * cryptographically-verifiable, offline-checkable license token.
8
+ *
9
+ * Token format (compact JWS-like, Ed25519 / "EdDSA"):
10
+ * base64url(header) "." base64url(payload) "." base64url(signature)
11
+ * header = { alg: "EdDSA", kid } // kid selects the public key
12
+ * payload = { tier, features[], exp, iat, customerId, keyId }
13
+ * sig = Ed25519 over `${b64url(header)}.${b64url(payload)}`
14
+ *
15
+ * The PRIVATE signing key lives only in the hosted billing service (or a local
16
+ * gitignored dev path). Only PUBLIC keys ship, in config/entitlement-public-keys.json,
17
+ * so any client can verify a license offline without contacting a server.
18
+ *
19
+ * Enforcement is opt-in via THUMBGATE_ENFORCE_ENTITLEMENTS so paid features can be
20
+ * gated without breaking existing users during rollout:
21
+ * - advisory (default): requireEntitlement returns {entitled:false, reason} and
22
+ * the caller may warn but proceed.
23
+ * - enforced (flag set): requireEntitlement throws EntitlementError when not entitled.
24
+ *
25
+ * LIMITATION — read before overselling this. This gate runs CLIENT-SIDE in an
26
+ * MIT, open-source, un-compiled package. It makes a license UN-FORGEABLE (you
27
+ * cannot mint a valid token without the private key), but it does NOT make the
28
+ * check UN-BYPASSABLE: anyone with the source can monkey-patch requireEntitlement
29
+ * or delete the gate. So this protects against fake keys and honest free-riding,
30
+ * and it is the correct authorization primitive for the HOSTED service — but the
31
+ * only real, un-bypassable protection for the crown-jewel intelligence is to run
32
+ * it SERVER-SIDE (client sends inputs, gets outputs, never sees the code/weights),
33
+ * with these tokens as the auth. Client-side gating is a speed bump, not a wall.
34
+ * See docs/COMMERCIALIZATION_STRATEGY.md.
35
+ */
36
+
37
+ const crypto = require('node:crypto');
38
+ const fs = require('node:fs');
39
+ const os = require('node:os');
40
+ const path = require('node:path');
41
+
42
+ const TIER_FEATURES = {
43
+ free: [],
44
+ pro: ['recall', 'lesson-search', 'unlimited-rules', 'data-export', 'hosted-dashboard', 'hosted-sync', 'learned-models'],
45
+ team: ['recall', 'lesson-search', 'unlimited-rules', 'data-export', 'hosted-dashboard', 'hosted-sync', 'learned-models', 'org-visibility'],
46
+ enterprise: ['recall', 'lesson-search', 'unlimited-rules', 'data-export', 'hosted-dashboard', 'hosted-sync', 'learned-models', 'org-visibility', 'sso', 'audit-log', 'compliance-export'],
47
+ };
48
+
49
+ class EntitlementError extends Error {
50
+ constructor(message, code = 'entitlement_denied') {
51
+ super(message);
52
+ this.name = 'EntitlementError';
53
+ this.code = code;
54
+ }
55
+ }
56
+
57
+ const advisoryWarnings = new Set();
58
+
59
+ function b64urlEncode(buf) {
60
+ return Buffer.from(buf).toString('base64url');
61
+ }
62
+ function b64urlDecodeJson(str) {
63
+ return JSON.parse(Buffer.from(str, 'base64url').toString('utf8'));
64
+ }
65
+
66
+ function isTrueEnv(value) {
67
+ if (!value) return false;
68
+ const v = String(value).trim().toLowerCase();
69
+ return v === '1' || v === 'true' || v === 'yes' || v === 'on';
70
+ }
71
+
72
+ function isEnforced(env = process.env) {
73
+ return isTrueEnv(env.THUMBGATE_ENFORCE_ENTITLEMENTS);
74
+ }
75
+
76
+ /** Load the shipped public keyset ({ activeKid, keys: {kid: pem} }). */
77
+ function loadTrustedKeys(root = path.resolve(__dirname, '..')) {
78
+ try {
79
+ const p = path.join(root, 'config', 'entitlement-public-keys.json');
80
+ const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
81
+ return raw.keys || {};
82
+ } catch {
83
+ return {};
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Verify a license token offline. Returns a normalized result — never throws on
89
+ * bad input (returns { valid:false, reason }).
90
+ * @param {string} token
91
+ * @param {{ trustedKeys?: Record<string,string>, now?: number }} [opts]
92
+ */
93
+ function verifyLicense(token, opts = {}) {
94
+ const trustedKeys = opts.trustedKeys || loadTrustedKeys();
95
+ const now = opts.now || Math.floor(nowMs() / 1000);
96
+ if (typeof token !== 'string' || !token.trim()) {
97
+ return { valid: false, reason: 'missing_token' };
98
+ }
99
+ // Reject the legacy bypassable prefix keys outright.
100
+ if (/^tg_/.test(token.trim())) {
101
+ return { valid: false, reason: 'legacy_prefix_key_not_a_signed_license' };
102
+ }
103
+ const parts = token.trim().split('.');
104
+ if (parts.length !== 3) return { valid: false, reason: 'malformed_token' };
105
+ const [h, p, s] = parts;
106
+ let header;
107
+ let payload;
108
+ try {
109
+ header = b64urlDecodeJson(h);
110
+ payload = b64urlDecodeJson(p);
111
+ } catch {
112
+ return { valid: false, reason: 'undecodable_token' };
113
+ }
114
+ if (header.alg !== 'EdDSA' || !header.kid) return { valid: false, reason: 'bad_header' };
115
+ const pubPem = trustedKeys[header.kid];
116
+ if (!pubPem) return { valid: false, reason: 'unknown_key_id' };
117
+
118
+ let signatureOk = false;
119
+ try {
120
+ signatureOk = crypto.verify(
121
+ null,
122
+ Buffer.from(`${h}.${p}`),
123
+ crypto.createPublicKey(pubPem),
124
+ Buffer.from(s, 'base64url')
125
+ );
126
+ } catch {
127
+ return { valid: false, reason: 'signature_verify_error' };
128
+ }
129
+ if (!signatureOk) return { valid: false, reason: 'bad_signature' };
130
+
131
+ if (typeof payload.exp === 'number' && payload.exp < now) {
132
+ return { valid: false, reason: 'expired', tier: payload.tier };
133
+ }
134
+ const tier = payload.tier || 'free';
135
+ const features = Array.isArray(payload.features) && payload.features.length
136
+ ? payload.features
137
+ : (TIER_FEATURES[tier] || []);
138
+ return {
139
+ valid: true,
140
+ tier,
141
+ features,
142
+ customerId: payload.customerId || null,
143
+ keyId: header.kid,
144
+ exp: payload.exp || null,
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Sign a license token. PRIVATE-key operation — runs in the hosted billing
150
+ * service (or a local dev/setup script), never in the shipped client at runtime.
151
+ * @param {string} privateKeyPem
152
+ * @param {{ tier:string, features?:string[], customerId?:string, kid:string, expSeconds?:number, iat?:number, exp?:number }} claims
153
+ */
154
+ function issueLicense(privateKeyPem, claims) {
155
+ const header = { alg: 'EdDSA', kid: claims.kid };
156
+ const iat = claims.iat || Math.floor(nowMs() / 1000);
157
+ const exp = claims.exp || (claims.expSeconds ? iat + claims.expSeconds : undefined);
158
+ const payload = {
159
+ tier: claims.tier,
160
+ features: claims.features || TIER_FEATURES[claims.tier] || [],
161
+ customerId: claims.customerId || null,
162
+ keyId: claims.kid,
163
+ iat,
164
+ ...(exp ? { exp } : {}),
165
+ };
166
+ const h = b64urlEncode(JSON.stringify(header));
167
+ const p = b64urlEncode(JSON.stringify(payload));
168
+ const sig = crypto.sign(null, Buffer.from(`${h}.${p}`), crypto.createPrivateKey(privateKeyPem));
169
+ return `${h}.${p}.${b64urlEncode(sig)}`;
170
+ }
171
+
172
+ /** Resolve the active license token from env or the local config file. */
173
+ function resolveLicenseToken(env = process.env) {
174
+ if (env.THUMBGATE_LICENSE && env.THUMBGATE_LICENSE.trim()) return env.THUMBGATE_LICENSE.trim();
175
+ try {
176
+ const p = path.join(os.homedir(), '.config', 'thumbgate', 'license.json');
177
+ const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
178
+ if (raw.license || raw.token) return String(raw.license || raw.token).trim();
179
+ } catch { /* no local license */ }
180
+ return null;
181
+ }
182
+
183
+ /**
184
+ * Gate a paid feature. Advisory by default; throws EntitlementError only when
185
+ * THUMBGATE_ENFORCE_ENTITLEMENTS is set. Returns { entitled, tier, reason }.
186
+ * @param {string} feature
187
+ * @param {{ env?: NodeJS.ProcessEnv, trustedKeys?: object, token?: string }} [opts]
188
+ */
189
+ function requireEntitlement(feature, opts = {}) {
190
+ const env = opts.env || process.env;
191
+ const token = opts.token !== undefined ? opts.token : resolveLicenseToken(env);
192
+ const result = verifyLicense(token, { trustedKeys: opts.trustedKeys });
193
+ const entitled = result.valid && result.features.includes(feature);
194
+ const decision = {
195
+ entitled,
196
+ tier: result.valid ? result.tier : 'free',
197
+ feature,
198
+ reason: entitled ? 'entitled' : (result.valid ? 'feature_not_in_tier' : result.reason),
199
+ enforced: isEnforced(env),
200
+ };
201
+ if (!entitled && decision.enforced) {
202
+ throw new EntitlementError(
203
+ `ThumbGate: "${feature}" requires a paid license (current tier: ${decision.tier}, reason: ${decision.reason}). `
204
+ + `Get a license at https://thumbgate.ai/pricing, then set THUMBGATE_LICENSE or ~/.config/thumbgate/license.json.`,
205
+ decision.reason
206
+ );
207
+ }
208
+ return decision;
209
+ }
210
+
211
+ /**
212
+ * Shared paid-feature wrapper for commercial entrypoints. In advisory mode it
213
+ * emits one warning per feature/label and lets the caller continue; in enforced
214
+ * mode `requireEntitlement()` throws before this function returns.
215
+ */
216
+ function requirePaidFeature(feature, opts = {}) {
217
+ const decision = requireEntitlement(feature, opts);
218
+ const label = opts.label || feature;
219
+ const warningKey = `${feature}:${label}`;
220
+ if (!decision.entitled && !opts.silent && !advisoryWarnings.has(warningKey)) {
221
+ advisoryWarnings.add(warningKey);
222
+ console.error(`⚠️ ThumbGate: ${label} requires a paid license (tier: ${decision.tier}). Advisory mode — set THUMBGATE_ENFORCE_ENTITLEMENTS=1 to enforce. License: https://thumbgate.ai/pricing`);
223
+ }
224
+ return decision;
225
+ }
226
+
227
+ function requireLearnedModelsEntitlement(opts = {}) {
228
+ return requirePaidFeature('learned-models', {
229
+ ...opts,
230
+ label: opts.label || 'learned-models intelligence',
231
+ });
232
+ }
233
+
234
+ // Injectable clock (Date.now is fine at runtime; kept in one place for testability).
235
+ function nowMs() {
236
+ return Date.now();
237
+ }
238
+
239
+ module.exports = {
240
+ verifyLicense,
241
+ issueLicense,
242
+ requireEntitlement,
243
+ requirePaidFeature,
244
+ requireLearnedModelsEntitlement,
245
+ resolveLicenseToken,
246
+ loadTrustedKeys,
247
+ isEnforced,
248
+ TIER_FEATURES,
249
+ EntitlementError,
250
+ };
@@ -129,6 +129,11 @@ function timestampSlug() {
129
129
  }
130
130
 
131
131
  function exportDatabricksBundle(feedbackDir = getDefaultFeedbackDir(), outputPath, options = {}) {
132
+ // Paid-feature gate (advisory by default; enforced via THUMBGATE_ENFORCE_ENTITLEMENTS=1).
133
+ const _ent = require('./entitlement').requireEntitlement('data-export');
134
+ if (!_ent.entitled) {
135
+ console.error(`⚠️ ThumbGate: Databricks bundle export is a paid feature (tier: ${_ent.tier}). Advisory mode — set THUMBGATE_ENFORCE_ENTITLEMENTS=1 to enforce. License: https://thumbgate.ai/pricing`);
136
+ }
132
137
  const resolvedFeedbackDir = path.resolve(feedbackDir || getDefaultFeedbackDir());
133
138
  const resolvedProofDir = path.resolve(options.proofDir || DEFAULT_PROOF_DIR);
134
139
  const exportedAt = new Date().toISOString();
@@ -180,6 +180,12 @@ function toJSONL(pairs) {
180
180
  }
181
181
 
182
182
  function exportDpoFromMemories(memories) {
183
+ // Paid-feature gate: DPO export is a commercial (Pro+) capability. Advisory by
184
+ // default; enforced when THUMBGATE_ENFORCE_ENTITLEMENTS=1 (then this throws).
185
+ const _ent = require('./entitlement').requireEntitlement('data-export');
186
+ if (!_ent.entitled) {
187
+ console.error(`⚠️ ThumbGate: DPO export is a paid feature (tier: ${_ent.tier}). Advisory mode — set THUMBGATE_ENFORCE_ENTITLEMENTS=1 to enforce. License: https://thumbgate.ai/pricing`);
188
+ }
183
189
  const errors = memories.filter((m) => m.category === 'error');
184
190
  const learnings = memories.filter((m) => m.category === 'learning');
185
191
  const result = buildDpoPairs(errors, learnings);
@@ -177,6 +177,11 @@ function buildDatasetInfo({ traceCount, preferenceCount, exportedAt }) {
177
177
  * @returns {Object} Export summary
178
178
  */
179
179
  function exportHfDataset(options = {}) {
180
+ // Paid-feature gate (advisory by default; enforced via THUMBGATE_ENFORCE_ENTITLEMENTS=1).
181
+ const _ent = require('./entitlement').requireEntitlement('data-export');
182
+ if (!_ent.entitled) {
183
+ console.error(`⚠️ ThumbGate: HuggingFace dataset export is a paid feature (tier: ${_ent.tier}). Advisory mode — set THUMBGATE_ENFORCE_ENTITLEMENTS=1 to enforce. License: https://thumbgate.ai/pricing`);
184
+ }
180
185
  const feedbackDir = options.feedbackDir || resolveFeedbackDir();
181
186
  const outputDir = options.outputDir || path.join(feedbackDir, 'hf-dataset');
182
187
  const includeProvenance = options.includeProvenance !== false;
@@ -151,10 +151,22 @@ const DESTRUCTIVE_OR_PRIVILEGE_BOUNDARY_PATTERN = /\b(?:rm\s+-rf|chmod\s+(?:\+x|
151
151
  // far better than any single regex). Secret exfiltration and the security-vulnerability
152
152
  // scan hard-deny on their OWN paths before this runs, so irreversible data-leak / supply
153
153
  // chain risks stay blocked regardless of posture.
154
+ // Self-protective gates guard ThumbGate's own guardrails (kill/env/config/hooks). See the
155
+ // self-protect-binds-regardless-of-posture changeset for the full rationale.
156
+ function isSelfProtectGate(gateId) {
157
+ return typeof gateId === 'string' && gateId.startsWith('self-protect');
158
+ }
159
+
154
160
  function applyEnforcementPosture(result) {
155
161
  if (!result || (result.decision !== 'deny' && result.decision !== 'approve')) return result;
156
162
  // Full hard enforcement opt-in: keep every deny.
157
163
  if (process.env.THUMBGATE_STRICT_ENFORCEMENT === '1') return result;
164
+ // Self-protection binds regardless of posture: a warning is worthless once the guardrail is
165
+ // gone. Owner escape: THUMBGATE_SELF_PROTECT_OVERRIDE=1 (break-glass covers .claude/settings*
166
+ // but not this surface, so it needs its own escape — self-lockout, 2026-07-07).
167
+ if (isSelfProtectGate(result.gate) && process.env.THUMBGATE_SELF_PROTECT_OVERRIDE !== '1') {
168
+ return result;
169
+ }
158
170
  // Honor the explicit strict-knowledge-conflict opt-in for that gate.
159
171
  if (process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === '1' && result.gate === 'knowledge-conflict-gate') return result;
160
172
  // Warn-by-default: the gate still fired and is recorded; the action is allowed through
@@ -2672,12 +2684,12 @@ function buildBlockActionProCta() {
2672
2684
  if (totalBlocks < 5) return null; // Too early — let them experience the product
2673
2685
 
2674
2686
  if (totalBlocks < 25) {
2675
- return '\n\n💡 Pro: keep this rule synced across laptops, CI, containers, and agent runtimes → thumbgate.ai/go/pro';
2687
+ return '\n\n💡 Pro: keep this rule searchable, exportable, and visible → thumbgate.ai/go/pro';
2676
2688
  }
2677
2689
  if (totalBlocks < 100) {
2678
- return `\n\n💡 ${totalBlocks} actions blocked. Pro keeps these lessons/rules synced everywhere → thumbgate.ai/go/pro ($19/mo)`;
2690
+ return `\n\n💡 ${totalBlocks} actions blocked. Pro adds recall, dashboard proof, and exports → thumbgate.ai/go/pro ($19/mo)`;
2679
2691
  }
2680
- return `\n\n💡 ${totalBlocks} mistakes caught. Your team could use shared hosted enforcement → thumbgate.ai/go/pro`;
2692
+ return `\n\n💡 ${totalBlocks} mistakes caught. Your team could use Enterprise shared hosted enforcement → thumbgate.ai/go/pro`;
2681
2693
  } catch (_) {
2682
2694
  return null;
2683
2695
  }
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * imperative-detector.js — detect an explicit NEVER / ALWAYS directive in a
5
+ * feedback message so the CLI can OFFER immediate enforcement (force-gate) when
6
+ * the user clearly asked for it.
7
+ *
8
+ * Why this exists: auto-promotion is deliberately occurrence-gated (a rule
9
+ * blocks only after a pattern repeats) — that accumulate-evidence-before-you-
10
+ * enforce default is what makes ThumbGate trustworthy rather than a one-signal
11
+ * lockout. But when a user types "never do X again", that is an explicit intent
12
+ * to guard NOW. This module recognizes that intent and lets the CLI SURFACE the
13
+ * one-shot path (force-gate) as an offer. It never blocks on its own — the user
14
+ * still confirms. Detecting intent ≠ acting on it.
15
+ *
16
+ * Pure and deterministic: no state, no I/O.
17
+ */
18
+
19
+ // A leading (or clause-initial) negative imperative → intent to forbid.
20
+ const NEVER_RE = /(?:^|[.;:,]\s*)(never|do\s*not|don'?t|stop\s+\w|must\s*not|no\s+longer|quit\s+\w|avoid\s+\w)/i;
21
+ // A leading (or clause-initial) positive imperative → intent to always do.
22
+ const ALWAYS_RE = /(?:^|[.;:,]\s*)(always|make\s+sure|ensure\s+\w|be\s+sure\s+to)/i;
23
+
24
+ function firstMeaningfulText(text) {
25
+ // Strip common feedback prefixes/quote marks so "❯ never …" still matches.
26
+ return String(text || '').trim().replace(/^[\s>❯"'`*-]+/, '');
27
+ }
28
+
29
+ /**
30
+ * @param {string} text
31
+ * @returns {{isImperative:boolean, polarity:('never'|'always'|null), directive:string}}
32
+ */
33
+ function detectImperative(text) {
34
+ const t = firstMeaningfulText(text);
35
+ if (!t) return { isImperative: false, polarity: null, directive: '' };
36
+ // Negative wins if both appear — a forbid directive is the actionable one.
37
+ if (NEVER_RE.test(t)) return { isImperative: true, polarity: 'never', directive: t };
38
+ if (ALWAYS_RE.test(t)) return { isImperative: true, polarity: 'always', directive: t };
39
+ return { isImperative: false, polarity: null, directive: '' };
40
+ }
41
+
42
+ function shellQuote(text) {
43
+ return String(text).trim().slice(0, 140).replace(/"/g, "'").replace(/\s+/g, ' ');
44
+ }
45
+
46
+ /**
47
+ * Build a suggestion for the capture confirmation. Returns null when there's no
48
+ * explicit directive to act on.
49
+ * - down + "never …" → OFFER immediate force-gate (the user asked to forbid it).
50
+ * - up + "always …" → clarify it's stored as guidance, not a hard block.
51
+ * @param {{signal?:string, text?:string}} opts
52
+ * @returns {{kind:string, message:string}|null}
53
+ */
54
+ function suggestForceGate({ signal, text } = {}) {
55
+ const isDown = signal === 'down' || signal === 'negative' || signal === 'thumbs_down';
56
+ const det = detectImperative(text);
57
+ if (!det.isImperative) return null;
58
+
59
+ if (isDown && det.polarity === 'never') {
60
+ const ctx = shellQuote(text);
61
+ return {
62
+ kind: 'force-gate-offer',
63
+ message: 'You said "never" — block this immediately instead of waiting for it to recur:\n'
64
+ + ` npx thumbgate force-gate --context="${ctx}" --action=block\n`
65
+ + ' (or run /thumbgate-guard). Left alone, it auto-promotes to a gate after the pattern repeats.',
66
+ };
67
+ }
68
+
69
+ if (!isDown && det.polarity === 'always') {
70
+ return {
71
+ kind: 'always-note',
72
+ message: 'You said "always" — stored as an ALWAYS guidance principle. '
73
+ + 'It is surfaced as context on future actions (guidance, not a hard block; positive patterns are not gate-enforced).',
74
+ };
75
+ }
76
+
77
+ return null;
78
+ }
79
+
80
+ module.exports = {
81
+ detectImperative,
82
+ suggestForceGate,
83
+ NEVER_RE,
84
+ ALWAYS_RE,
85
+ };
@@ -5,6 +5,7 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { resolveFeedbackDir } = require('./feedback-paths');
7
7
  const { getDecisionLogPath, readDecisionLog, collapseDecisionTimeline } = require('./decision-journal');
8
+ const { requireLearnedModelsEntitlement } = require('./entitlement');
8
9
 
9
10
  const LABELS = ['allow', 'recall', 'verify', 'warn', 'deny'];
10
11
  const DAY_MS = 24 * 60 * 60 * 1000;
@@ -551,6 +552,10 @@ function summarizeTopTokens(model, limit = 5) {
551
552
  }
552
553
 
553
554
  function trainInterventionPolicy(examples, options = {}) {
555
+ requireLearnedModelsEntitlement({
556
+ ...(options.entitlement || {}),
557
+ label: 'intervention-policy training',
558
+ });
554
559
  const split = splitExamples(examples);
555
560
  const evaluationModel = fitNaiveBayes(split.train);
556
561
  const deployedModel = fitNaiveBayes(examples);
@@ -631,6 +636,10 @@ function buildRuntimeCandidate(params = {}) {
631
636
  }
632
637
 
633
638
  function getInterventionRecommendation(params = {}, options = {}) {
639
+ requireLearnedModelsEntitlement({
640
+ ...(options.entitlement || {}),
641
+ label: 'intervention-policy recommendation',
642
+ });
634
643
  const resolvedDir = resolveFeedbackDir({ feedbackDir: options.feedbackDir || params.feedbackDir });
635
644
  let model = options.model || loadInterventionPolicy(resolvedDir);
636
645
  const candidate = options.candidate || buildRuntimeCandidate(params);
@@ -693,6 +702,10 @@ function computeDailySeries(examples, dayCount = 14) {
693
702
  }
694
703
 
695
704
  function getInterventionPolicySummary(feedbackDir, options = {}) {
705
+ requireLearnedModelsEntitlement({
706
+ ...(options.entitlement || {}),
707
+ label: 'intervention-policy summary',
708
+ });
696
709
  const resolvedDir = resolveFeedbackDir({ feedbackDir });
697
710
  const { examples, sourceCounts } = buildExamplesFromFeedbackDir(resolvedDir);
698
711
  const model = loadInterventionPolicy(resolvedDir) || trainInterventionPolicy(examples);
@@ -1,11 +1,4 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * PR Manager — High-Throughput Merge & Blocker Diagnosis
4
- *
5
- * Inspired by the 2026 GitHub 'Quick Access' update. Centralizes merge status
6
- * detection and triggers autonomous self-healing for common blockers.
7
- */
8
-
9
2
  'use strict';
10
3
 
11
4
  const fs = require('node:fs');
@@ -34,6 +27,8 @@ const PASSING_BUCKETS = new Set((MERGE_QUALITY_CHECKS.passingBuckets || []).map(
34
27
  const PENDING_BUCKETS = new Set((MERGE_QUALITY_CHECKS.pendingBuckets || []).map((value) => String(value || '').toLowerCase()));
35
28
  const FAILING_BUCKETS = new Set((MERGE_QUALITY_CHECKS.failingBuckets || []).map((value) => String(value || '').toLowerCase()));
36
29
 
30
+ const SELF_REFERENTIAL_CHECKS = new Set(MERGE_QUALITY_CHECKS.selfReferentialChecks || []);
31
+
37
32
  function assertSafeGhArgs(args) {
38
33
  if (!Array.isArray(args) || args.length === 0) {
39
34
  throw new Error('GH CLI args must be a non-empty array.');
@@ -119,9 +114,6 @@ function isMissingCurrentBranchPr(result, prNumber) {
119
114
  || /could not determine current branch:.*not on any branch/i.test(formatGhError(result));
120
115
  }
121
116
 
122
- /**
123
- * Fetch granular PR status using GH CLI
124
- */
125
117
  function getPrStatus(prNumber = '', runner = runGh) {
126
118
  const normalizedPrNumber = normalizePrNumber(prNumber);
127
119
  const args = ['pr', 'view'];
@@ -182,6 +174,9 @@ function summarizeChecks(checks = []) {
182
174
 
183
175
  for (const check of checks) {
184
176
  const name = check.name || 'unknown-check';
177
+
178
+ if (SELF_REFERENTIAL_CHECKS.has(name)) continue;
179
+
185
180
  const bucket = String(check.bucket || '').toLowerCase();
186
181
  if (bucket) {
187
182
  if (FAILING_BUCKETS.has(bucket)) {
@@ -225,9 +220,6 @@ function sleep(ms) {
225
220
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
226
221
  }
227
222
 
228
- /**
229
- * Diagnose and resolve blockers autonomously
230
- */
231
223
  async function resolveBlockers(pr, runner = runGh) {
232
224
  const title = pr.title || 'Untitled PR';
233
225
  const mergeState = pr.mergeStateStatus || 'UNKNOWN';
@@ -241,7 +233,6 @@ async function resolveBlockers(pr, runner = runGh) {
241
233
  return { status: 'skipped', reason: 'draft' };
242
234
  }
243
235
 
244
- // 1. Handle Outdated Branch (BEHIND)
245
236
  if (pr.mergeStateStatus === 'BEHIND') {
246
237
  console.log('[PR Manager] PR is behind main. Triggering auto-update...');
247
238
  const update = runner(['pr', 'update-branch', pr.number.toString()]);
@@ -250,13 +241,11 @@ async function resolveBlockers(pr, runner = runGh) {
250
241
  }
251
242
  }
252
243
 
253
- // 2. Handle Merge Conflicts (DIRTY)
254
244
  if (pr.mergeStateStatus === 'DIRTY' || pr.mergeable === 'CONFLICTING') {
255
245
  console.log('[PR Manager] CRITICAL: Merge conflicts detected. Manual intervention or advanced rebase required.');
256
246
  return { status: 'blocked', reason: 'conflicts' };
257
247
  }
258
248
 
259
- // 3. Handle CI Failures
260
249
  let checks = pr.statusCheckRollup || [];
261
250
  let checkSource = 'statusCheckRollup';
262
251
 
@@ -282,7 +271,6 @@ async function resolveBlockers(pr, runner = runGh) {
282
271
  return { status: 'blocked', reason: 'ci_pending', checks: checkSummary.pending, checkSource };
283
272
  }
284
273
 
285
- // 4. Handle Review Blockers
286
274
  if (pr.reviewDecision === 'CHANGES_REQUESTED') {
287
275
  console.log('[PR Manager] BLOCKED: Changes requested by reviewer.');
288
276
  return { status: 'blocked', reason: 'changes_requested' };
@@ -293,8 +281,10 @@ async function resolveBlockers(pr, runner = runGh) {
293
281
  return { status: 'blocked', reason: 'review_required' };
294
282
  }
295
283
 
296
- // 5. Ready to Merge
297
- if (pr.mergeStateStatus === 'CLEAN' && pr.mergeable === 'MERGEABLE') {
284
+ if (
285
+ (pr.mergeStateStatus === 'CLEAN' || pr.mergeStateStatus === 'UNSTABLE')
286
+ && pr.mergeable === 'MERGEABLE'
287
+ ) {
298
288
  console.log('[PR Manager] SUCCESS: PR is ready for protected autonomous merge.');
299
289
  return { status: 'ready' };
300
290
  }
@@ -368,9 +358,6 @@ function submitTrunkMergeRequest(prNumber, runner = runGh) {
368
358
  };
369
359
  }
370
360
 
371
- /**
372
- * Perform autonomous merge
373
- */
374
361
  function performMerge(prInput, runner = runGh, options = {}) {
375
362
  const pr = (prInput && typeof prInput === 'object')
376
363
  ? prInput