thumbgate 1.3.0 โ†’ 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/.claude-plugin/README.md +25 -0
  2. package/.claude-plugin/marketplace.json +32 -13
  3. package/.claude-plugin/plugin.json +15 -2
  4. package/.well-known/llms.txt +60 -0
  5. package/.well-known/mcp/server-card.json +1 -1
  6. package/README.md +242 -126
  7. package/adapters/README.md +1 -1
  8. package/adapters/chatgpt/INSTALL.md +59 -4
  9. package/adapters/chatgpt/openapi.yaml +168 -0
  10. package/adapters/claude/.mcp.json +2 -2
  11. package/adapters/codex/config.toml +2 -2
  12. package/adapters/mcp/server-stdio.js +84 -1
  13. package/adapters/opencode/opencode.json +1 -1
  14. package/bin/cli.js +204 -13
  15. package/bin/postinstall.js +8 -2
  16. package/config/budget.json +18 -0
  17. package/config/gates/code-edit.json +61 -0
  18. package/config/gates/db-write.json +61 -0
  19. package/config/gates/default.json +154 -3
  20. package/config/gates/deploy.json +61 -0
  21. package/config/github-about.json +2 -1
  22. package/config/merge-quality-checks.json +23 -0
  23. package/openapi/openapi.yaml +168 -0
  24. package/package.json +47 -11
  25. package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
  26. package/plugins/claude-codex-bridge/.mcp.json +1 -1
  27. package/plugins/claude-codex-bridge/scripts/codex-bridge.js +1 -3
  28. package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
  29. package/plugins/codex-profile/.mcp.json +1 -1
  30. package/plugins/codex-profile/INSTALL.md +27 -4
  31. package/plugins/codex-profile/README.md +33 -9
  32. package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
  33. package/plugins/opencode-profile/INSTALL.md +1 -1
  34. package/public/blog.html +73 -0
  35. package/public/compare/mem0.html +189 -0
  36. package/public/compare/speclock.html +180 -0
  37. package/public/compare.html +10 -2
  38. package/public/guide.html +2 -2
  39. package/public/guides/claude-code-prevent-repeated-mistakes.html +161 -0
  40. package/public/guides/codex-cli-guardrails.html +158 -0
  41. package/public/guides/cursor-prevent-repeated-mistakes.html +161 -0
  42. package/public/guides/pre-action-gates.html +162 -0
  43. package/public/guides/stop-repeated-ai-agent-mistakes.html +159 -0
  44. package/public/index.html +172 -65
  45. package/public/lessons.html +33 -24
  46. package/public/llm-context.md +140 -0
  47. package/public/pro.html +24 -22
  48. package/scripts/access-anomaly-detector.js +1 -1
  49. package/scripts/adk-consolidator.js +1 -5
  50. package/scripts/agent-security-hardening.js +4 -6
  51. package/scripts/agentic-data-pipeline.js +1 -3
  52. package/scripts/async-job-runner.js +1 -5
  53. package/scripts/audit-trail.js +1 -5
  54. package/scripts/auto-promote-gates.js +5 -3
  55. package/scripts/background-agent-governance.js +2 -10
  56. package/scripts/billing-setup.js +109 -0
  57. package/scripts/billing.js +2 -16
  58. package/scripts/budget-enforcer.js +173 -0
  59. package/scripts/build-claude-mcpb.js +71 -5
  60. package/scripts/build-codex-plugin.js +152 -0
  61. package/scripts/check-congruence.js +132 -14
  62. package/scripts/commercial-offer.js +5 -7
  63. package/scripts/content-engine/linkedin-content-generator.js +154 -0
  64. package/scripts/content-engine/output/linkedin-memento-validation.md +17 -0
  65. package/scripts/content-engine/output/linkedin-posts-2026-04-09.md +175 -0
  66. package/scripts/content-engine/reddit-thread-finder.js +154 -0
  67. package/scripts/context-engine.js +21 -6
  68. package/scripts/contextfs.js +1 -21
  69. package/scripts/dashboard.js +20 -0
  70. package/scripts/decision-journal.js +341 -0
  71. package/scripts/delegation-runtime.js +1 -5
  72. package/scripts/distribution-surfaces.js +54 -0
  73. package/scripts/document-intake.js +927 -0
  74. package/scripts/ephemeral-agent-store.js +1 -8
  75. package/scripts/evolution-state.js +1 -5
  76. package/scripts/experiment-tracker.js +1 -5
  77. package/scripts/export-databricks-bundle.js +1 -5
  78. package/scripts/export-hf-dataset.js +1 -5
  79. package/scripts/export-training.js +1 -5
  80. package/scripts/feedback-attribution.js +1 -16
  81. package/scripts/feedback-history-distiller.js +1 -16
  82. package/scripts/feedback-loop.js +1 -5
  83. package/scripts/feedback-root-consolidator.js +2 -21
  84. package/scripts/feedback-session.js +49 -0
  85. package/scripts/feedback-to-rules.js +215 -36
  86. package/scripts/filesystem-search.js +1 -9
  87. package/scripts/fs-utils.js +104 -0
  88. package/scripts/gates-engine.js +200 -11
  89. package/scripts/github-about.js +32 -8
  90. package/scripts/gtm-revenue-loop.js +1 -5
  91. package/scripts/harness-selector.js +148 -0
  92. package/scripts/hosted-config.js +2 -0
  93. package/scripts/hosted-job-launcher.js +1 -5
  94. package/scripts/hybrid-feedback-context.js +33 -49
  95. package/scripts/intervention-policy.js +58 -1
  96. package/scripts/lesson-db.js +3 -18
  97. package/scripts/lesson-inference.js +194 -16
  98. package/scripts/lesson-retrieval.js +60 -24
  99. package/scripts/llm-client.js +59 -0
  100. package/scripts/managed-lesson-agent.js +183 -0
  101. package/scripts/marketing-experiment.js +8 -22
  102. package/scripts/meta-agent-loop.js +624 -0
  103. package/scripts/metered-billing.js +1 -1
  104. package/scripts/money-watcher.js +1 -4
  105. package/scripts/obsidian-export.js +1 -5
  106. package/scripts/operational-integrity.js +15 -3
  107. package/scripts/operational-summary.js +41 -5
  108. package/scripts/org-dashboard.js +6 -1
  109. package/scripts/per-step-scoring.js +2 -4
  110. package/scripts/pr-manager.js +201 -19
  111. package/scripts/pro-features.js +3 -2
  112. package/scripts/prompt-dlp.js +3 -3
  113. package/scripts/prove-adapters.js +1 -5
  114. package/scripts/prove-attribution.js +1 -5
  115. package/scripts/prove-automation.js +1 -3
  116. package/scripts/prove-cloudflare-sandbox.js +1 -3
  117. package/scripts/prove-data-pipeline.js +1 -3
  118. package/scripts/prove-intelligence.js +1 -3
  119. package/scripts/prove-lancedb.js +1 -5
  120. package/scripts/prove-local-intelligence.js +1 -3
  121. package/scripts/prove-packaged-runtime.js +75 -9
  122. package/scripts/prove-predictive-insights.js +1 -3
  123. package/scripts/prove-training-export.js +1 -3
  124. package/scripts/prove-workflow-contract.js +1 -5
  125. package/scripts/ralph-loop.js +376 -0
  126. package/scripts/ralph-mode-ci.js +331 -0
  127. package/scripts/rate-limiter.js +3 -1
  128. package/scripts/reddit-dm-outreach.js +14 -4
  129. package/scripts/rotate-stripe-webhook-secret.js +314 -0
  130. package/scripts/schedule-manager.js +3 -5
  131. package/scripts/security-scanner.js +448 -0
  132. package/scripts/self-distill-agent.js +579 -0
  133. package/scripts/semantic-dedup.js +115 -0
  134. package/scripts/skill-exporter.js +1 -3
  135. package/scripts/skill-generator.js +1 -5
  136. package/scripts/social-analytics/engagement-audit.js +1 -18
  137. package/scripts/social-analytics/pollers/linkedin.js +26 -16
  138. package/scripts/social-analytics/publishers/linkedin.js +1 -1
  139. package/scripts/social-analytics/publishers/zernio.js +51 -0
  140. package/scripts/social-pipeline.js +1 -3
  141. package/scripts/social-post-hourly.js +47 -4
  142. package/scripts/statusline-links.js +6 -5
  143. package/scripts/statusline.sh +29 -153
  144. package/scripts/sync-branch-protection.js +340 -0
  145. package/scripts/tessl-export.js +1 -3
  146. package/scripts/thumbgate-search.js +32 -1
  147. package/scripts/tool-kpi-tracker.js +1 -1
  148. package/scripts/tool-registry.js +106 -2
  149. package/scripts/vector-store.js +1 -5
  150. package/scripts/weekly-auto-post.js +1 -1
  151. package/scripts/workflow-sentinel.js +91 -0
  152. package/skills/thumbgate/SKILL.md +1 -1
  153. package/src/api/server.js +296 -7
  154. package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
  155. package/scripts/social-analytics/db/social-analytics.db-shm +0 -0
  156. /package/scripts/social-analytics/db/{social-analytics.db-wal โ†’ analytics.sqlite} +0 -0
@@ -4,6 +4,7 @@
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { resolveFeedbackDir } = require('./feedback-paths');
7
+ const { ensureDir } = require('./fs-utils');
7
8
 
8
9
  const NEG = new Set(['negative', 'negative_strong', 'down', 'thumbs_down']);
9
10
  const POS = new Set(['positive', 'positive_strong', 'up', 'thumbs_up']);
@@ -38,11 +39,6 @@ function discoverFeedbackDir() {
38
39
  * Ensure a directory exists, creating it recursively if needed.
39
40
  * @param {string} dirPath
40
41
  */
41
- function ensureDir(dirPath) {
42
- if (!fs.existsSync(dirPath)) {
43
- fs.mkdirSync(dirPath, { recursive: true });
44
- }
45
- }
46
42
 
47
43
  /**
48
44
  * Append a JSON record as a single line to a JSONL file.
@@ -3,6 +3,7 @@
3
3
 
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
+ const { readJsonl } = require('../fs-utils');
6
7
 
7
8
  const REPO_ROOT = path.resolve(__dirname, '..', '..');
8
9
  const DEFAULT_REPLY_STATE_PATH = path.join(REPO_ROOT, '.thumbgate', 'reply-monitor-state.json');
@@ -59,24 +60,6 @@ function readJson(filePath, fallback) {
59
60
  }
60
61
  }
61
62
 
62
- function readJsonl(filePath) {
63
- if (!fs.existsSync(filePath)) {
64
- return [];
65
- }
66
- return fs.readFileSync(filePath, 'utf8')
67
- .split('\n')
68
- .map((line) => line.trim())
69
- .filter(Boolean)
70
- .map((line) => {
71
- try {
72
- return JSON.parse(line);
73
- } catch {
74
- return null;
75
- }
76
- })
77
- .filter(Boolean);
78
- }
79
-
80
63
  function formatDateInTimezone(date, timezone = DEFAULT_TIMEZONE) {
81
64
  const formatter = new Intl.DateTimeFormat('en-CA', {
82
65
  timeZone: timezone,
@@ -22,7 +22,7 @@ const LI_V2_BASE = 'https://api.linkedin.com/v2';
22
22
  function buildRestHeaders(token) {
23
23
  return {
24
24
  Authorization: `Bearer ${token}`,
25
- 'LinkedIn-Version': '202401',
25
+ 'LinkedIn-Version': '202601',
26
26
  'X-Restli-Protocol-Version': '2.0.0',
27
27
  };
28
28
  }
@@ -155,20 +155,22 @@ async function fetchPostAnalytics(token, postUrn) {
155
155
  async function fetchLinkedInProfile(token) {
156
156
  if (!token) throw new Error('fetchLinkedInProfile: token is required');
157
157
 
158
- const url = `${LI_V2_BASE}/me`;
159
-
160
- const res = await fetch(url, { headers: buildV2Headers(token) });
161
- if (!res.ok) {
162
- const body = await res.text().catch(() => '');
163
- throw new Error(`fetchLinkedInProfile HTTP ${res.status}: ${body}`);
164
- }
165
-
166
- const json = await res.json();
167
- if (json.serviceErrorCode) {
168
- throw new Error(`fetchLinkedInProfile API error: ${JSON.stringify(json)}`);
158
+ // Try /v2/userinfo first (works with openid+profile scopes), fall back to /v2/me.
159
+ for (const url of [`${LI_V2_BASE}/userinfo`, `${LI_V2_BASE}/me`]) {
160
+ const res = await fetch(url, { headers: buildV2Headers(token) });
161
+ if (!res.ok) {
162
+ const body = await res.text().catch(() => '');
163
+ console.warn(`[linkedin] ${url} HTTP ${res.status}: ${body.slice(0, 120)}`);
164
+ continue;
165
+ }
166
+ const json = await res.json();
167
+ if (json.serviceErrorCode) {
168
+ console.warn(`[linkedin] ${url} API error: ${JSON.stringify(json).slice(0, 120)}`);
169
+ continue;
170
+ }
171
+ return json;
169
172
  }
170
-
171
- return json;
173
+ throw new Error('fetchLinkedInProfile: all endpoints failed');
172
174
  }
173
175
 
174
176
  /**
@@ -237,8 +239,16 @@ async function pollLinkedIn(db) {
237
239
 
238
240
  console.log(`[linkedin] Starting poll for ${personUrn}`);
239
241
 
240
- const posts = await fetchLinkedInPosts(token, personUrn);
241
- console.log(`[linkedin] Got ${posts.length} posts`);
242
+ let posts = [];
243
+ try {
244
+ posts = await fetchLinkedInPosts(token, personUrn);
245
+ console.log(`[linkedin] Got ${posts.length} posts`);
246
+ } catch (err) {
247
+ // 403 = token lacks r_member_social scope; 426 = version expired.
248
+ // Either way, skip posts but still collect follower count.
249
+ console.warn(`[linkedin] Posts fetch failed (non-fatal): ${err.message}`);
250
+ console.warn('[linkedin] To read posts, re-authorize with r_member_social scope.');
251
+ }
242
252
 
243
253
  for (const post of posts) {
244
254
  // Post URN is at post.id for the Posts API (urn:li:share:... or urn:li:ugcPost:...).
@@ -22,7 +22,7 @@ const LI_REST_BASE = 'https://api.linkedin.com/rest';
22
22
  function buildHeaders(token) {
23
23
  return {
24
24
  Authorization: `Bearer ${token}`,
25
- 'LinkedIn-Version': '202401',
25
+ 'LinkedIn-Version': '202601',
26
26
  'X-Restli-Protocol-Version': '2.0.0',
27
27
  'Content-Type': 'application/json',
28
28
  };
@@ -80,6 +80,43 @@ function requireApiKey() {
80
80
  return key;
81
81
  }
82
82
 
83
+ class ZernioQuotaError extends Error {
84
+ constructor(message, details = {}) {
85
+ super(message);
86
+ this.name = 'ZernioQuotaError';
87
+ this.code = 'ZERNIO_POST_LIMIT_REACHED';
88
+ this.billingPeriod = details.billingPeriod || null;
89
+ this.current = Number.isFinite(details.current) ? details.current : null;
90
+ this.endpoint = details.endpoint || null;
91
+ this.limit = Number.isFinite(details.limit) ? details.limit : null;
92
+ this.method = details.method || null;
93
+ this.planName = details.planName || null;
94
+ this.status = details.status || null;
95
+ }
96
+ }
97
+
98
+ function parseZernioErrorText(errorText) {
99
+ if (!errorText || typeof errorText !== 'string') return null;
100
+ try {
101
+ const parsed = JSON.parse(errorText);
102
+ return parsed && typeof parsed === 'object' ? parsed : null;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ function isZernioQuotaError(error) {
109
+ return Boolean(
110
+ error &&
111
+ (error instanceof ZernioQuotaError || error.code === 'ZERNIO_POST_LIMIT_REACHED')
112
+ );
113
+ }
114
+
115
+ function isZernioQuotaPayload(status, payload, errorText) {
116
+ const message = String(payload?.error || payload?.message || errorText || '');
117
+ return status === 403 && /post limit reached/i.test(message);
118
+ }
119
+
83
120
  function resolveAccountId(account) {
84
121
  if (!account || typeof account !== 'object') {
85
122
  return '';
@@ -176,6 +213,18 @@ async function zernioFetch(method, endpoint, body = null) {
176
213
 
177
214
  if (!res.ok) {
178
215
  const errorText = await res.text().catch(() => '');
216
+ const payload = parseZernioErrorText(errorText);
217
+ if (isZernioQuotaPayload(res.status, payload, errorText)) {
218
+ throw new ZernioQuotaError(payload?.error || 'Zernio post limit reached', {
219
+ billingPeriod: payload?.billingPeriod,
220
+ current: Number(payload?.current),
221
+ endpoint,
222
+ limit: Number(payload?.limit),
223
+ method,
224
+ planName: payload?.planName,
225
+ status: res.status,
226
+ });
227
+ }
179
228
  throw new Error(`Zernio API ${res.status} for ${method} ${endpoint}: ${errorText}`);
180
229
  }
181
230
 
@@ -434,7 +483,9 @@ module.exports = {
434
483
  buildDedupKey,
435
484
  deletePost,
436
485
  isDuplicate,
486
+ isZernioQuotaError,
437
487
  listPosts,
488
+ ZernioQuotaError,
438
489
  publishPost,
439
490
  recordPost,
440
491
  schedulePost,
@@ -8,6 +8,7 @@ const net = require('net');
8
8
  const os = require('os');
9
9
  const path = require('path');
10
10
  const { pathToFileURL } = require('url');
11
+ const { ensureDir } = require('./fs-utils');
11
12
 
12
13
  const REPO_ROOT = path.resolve(__dirname, '..');
13
14
  const DEFAULT_ASSET_HTML = path.join(
@@ -77,9 +78,6 @@ function parseArgs(argv) {
77
78
  return args;
78
79
  }
79
80
 
80
- function ensureDir(dirPath) {
81
- fs.mkdirSync(dirPath, { recursive: true });
82
- }
83
81
 
84
82
  function readText(filePath) {
85
83
  return fs.readFileSync(filePath, 'utf8');
@@ -25,7 +25,11 @@
25
25
  require('dotenv').config();
26
26
 
27
27
  const { generateWeeklyStatsPost } = require('./daily-digest');
28
- const { publishPost, getConnectedAccounts } = require('./social-analytics/publishers/zernio');
28
+ const {
29
+ getConnectedAccounts,
30
+ isZernioQuotaError,
31
+ publishPost,
32
+ } = require('./social-analytics/publishers/zernio');
29
33
 
30
34
  // Platforms that support text-only posts.
31
35
  // Reddit EXCLUDED โ€” engagement only via reply-monitor, not auto-posting.
@@ -179,7 +183,46 @@ async function main() {
179
183
  }
180
184
  }
181
185
 
182
- main().catch(err => {
186
+ function isNonFatalPostFailure(err) {
187
+ return isZernioQuotaError(err);
188
+ }
189
+
190
+ function handlePostFailure(err) {
191
+ if (isNonFatalPostFailure(err)) {
192
+ console.warn(`[daily-post] Skipped: ${err.message}`);
193
+ console.warn('[daily-post] Zernio monthly post quota reached; treating as a controlled skip.');
194
+ return 0;
195
+ }
196
+
183
197
  console.error('[daily-post] Fatal:', err.message);
184
- process.exit(1);
185
- });
198
+ return 1;
199
+ }
200
+
201
+ function runCli({ run = main, exit = process.exit } = {}) {
202
+ return run().catch(err => {
203
+ const exitCode = handlePostFailure(err);
204
+ if (exitCode !== 0) {
205
+ exit(exitCode);
206
+ }
207
+ });
208
+ }
209
+
210
+ function isCliEntrypoint(entryModule = require.main) {
211
+ return Boolean(entryModule && entryModule.filename === __filename);
212
+ }
213
+
214
+ if (isCliEntrypoint()) {
215
+ void runCli();
216
+ }
217
+
218
+ module.exports = {
219
+ DAILY_ANGLES,
220
+ TEXT_PLATFORMS,
221
+ generatePost,
222
+ getTodayAngle,
223
+ handlePostFailure,
224
+ isCliEntrypoint,
225
+ isNonFatalPostFailure,
226
+ main,
227
+ runCli,
228
+ };
@@ -11,6 +11,7 @@ const { getHomeDir, getRuntimeDir, resolveProjectDir } = require('./feedback-pat
11
11
  const { resolveProKey } = require('./pro-local-dashboard');
12
12
 
13
13
  const DEFAULT_ORIGIN = 'http://localhost:3456';
14
+ const PROD_ORIGIN = 'https://thumbgate-production.up.railway.app';
14
15
  const DEFAULT_TIMEOUT_MS = 150;
15
16
  const DEFAULT_BOOT_GRACE_MS = 5000;
16
17
  const PKG_ROOT = path.join(__dirname, '..');
@@ -153,8 +154,8 @@ function buildLinkState({
153
154
  lessonsLabel: 'Lessonsโ€ฆ',
154
155
  upLabel: '๐Ÿ‘',
155
156
  downLabel: '๐Ÿ‘Ž',
156
- dashboardUrl: '',
157
- lessonsUrl: '',
157
+ dashboardUrl: `${PROD_ORIGIN}/dashboard`,
158
+ lessonsUrl: `${PROD_ORIGIN}/lessons`,
158
159
  upUrl: '',
159
160
  downUrl: '',
160
161
  };
@@ -163,11 +164,11 @@ function buildLinkState({
163
164
  return {
164
165
  state: canBootstrap ? 'offline' : 'unavailable',
165
166
  dashboardLabel: canBootstrap ? 'Dash: thumbgate pro' : 'Dashboard',
166
- lessonsLabel: 'Learn: thumbgate lessons',
167
+ lessonsLabel: 'Lessons',
167
168
  upLabel: '๐Ÿ‘',
168
169
  downLabel: '๐Ÿ‘Ž',
169
- dashboardUrl: '',
170
- lessonsUrl: '',
170
+ dashboardUrl: `${PROD_ORIGIN}/dashboard`,
171
+ lessonsUrl: `${PROD_ORIGIN}/lessons`,
171
172
  upUrl: '',
172
173
  downUrl: '',
173
174
  };
@@ -126,13 +126,15 @@ if [ -n "$_TOWER_JSON" ]; then
126
126
  ' 2>/dev/null)"
127
127
  fi
128
128
 
129
- # โ”€โ”€ Latest lesson โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
130
- LESSON_TEXT=""; LESSON_ID=""
129
+ # โ”€โ”€ Latest lesson (data available for extensions; not rendered in statusbar) โ”€โ”€
130
+ LESSON_TEXT=""; LESSON_ID=""; LESSON_LABEL=""; LESSON_LINK=""
131
131
  _LESSON_JSON=$(node "${SCRIPT_DIR}/statusline-lesson.js" 2>/dev/null)
132
132
  if [ -n "$_LESSON_JSON" ]; then
133
133
  eval "$(echo "$_LESSON_JSON" | jq -r '
134
134
  @sh "LESSON_TEXT=\(.text // "")",
135
- @sh "LESSON_ID=\(.lessonId // "")"
135
+ @sh "LESSON_ID=\(.lessonId // "")",
136
+ @sh "LESSON_LABEL=\(.label // "")",
137
+ @sh "LESSON_LINK=\(.link // "")"
136
138
  ' 2>/dev/null)"
137
139
  fi
138
140
 
@@ -144,170 +146,44 @@ case "${TREND}" in
144
146
  improving) ARROW="โ†—" ;; degrading) ARROW="โ†˜" ;; stable) ARROW="โ†’" ;; *) ARROW="?" ;;
145
147
  esac
146
148
 
147
- osc8_link() {
149
+ inline_link() {
148
150
  local url="$1"
149
151
  local label="$2"
150
152
  if [ -n "$url" ]; then
151
- printf '\033]8;;%s\a%s\033]8;;\a' "$url" "$label"
153
+ printf '%s (%s)' "$label" "$url"
152
154
  else
153
155
  printf '%s' "$label"
154
156
  fi
155
157
  }
156
158
 
157
- UP_ICON="$(osc8_link "$UP_URL" "๐Ÿ‘")"
158
- DOWN_ICON="$(osc8_link "$DOWN_URL" "๐Ÿ‘Ž")"
159
- DASHBOARD_LINK="$(osc8_link "$DASHBOARD_URL" "$DASHBOARD_LABEL")"
160
- LESSONS_LINK="$(osc8_link "$LESSONS_URL" "$LESSONS_LABEL")"
161
-
162
- is_numeric() {
163
- case "$1" in
164
- ''|*[!0-9]*) return 1 ;;
165
- *) return 0 ;;
166
- esac
167
- }
168
-
169
- # Keep ThumbGate within a conservative left-side budget so Claude's own
170
- # right-side notices do not visually collide with our line.
171
- STATUSLINE_DEFAULT_MAX_CHARS="${THUMBGATE_STATUSLINE_DEFAULT_MAX_CHARS:-96}"
172
- STATUSLINE_RIGHT_RESERVE="${THUMBGATE_STATUSLINE_RIGHT_RESERVE:-28}"
173
- if ! is_numeric "$STATUSLINE_DEFAULT_MAX_CHARS"; then STATUSLINE_DEFAULT_MAX_CHARS=96; fi
174
- if ! is_numeric "$STATUSLINE_RIGHT_RESERVE"; then STATUSLINE_RIGHT_RESERVE=28; fi
175
-
176
- if is_numeric "${THUMBGATE_STATUSLINE_MAX_CHARS:-}"; then
177
- STATUSLINE_MAX_CHARS="$THUMBGATE_STATUSLINE_MAX_CHARS"
178
- else
179
- STATUSLINE_MAX_CHARS="$STATUSLINE_DEFAULT_MAX_CHARS"
180
- if is_numeric "${COLUMNS:-}"; then
181
- _AVAILABLE_CHARS=$(( COLUMNS - STATUSLINE_RIGHT_RESERVE ))
182
- if [ "$_AVAILABLE_CHARS" -gt 0 ] && [ "$_AVAILABLE_CHARS" -lt "$STATUSLINE_MAX_CHARS" ]; then
183
- STATUSLINE_MAX_CHARS="$_AVAILABLE_CHARS"
184
- fi
185
- fi
186
- fi
187
- if [ "$STATUSLINE_MAX_CHARS" -lt 48 ]; then STATUSLINE_MAX_CHARS=48; fi
188
-
189
- PLAIN_SEGMENTS=()
190
- RENDERED_SEGMENTS=()
191
-
192
- current_plain_length() {
193
- local total=0
194
- local i
195
- for ((i = 0; i < ${#PLAIN_SEGMENTS[@]}; i++)); do
196
- if [ "$i" -gt 0 ]; then
197
- total=$((total + 3))
198
- fi
199
- total=$((total + ${#PLAIN_SEGMENTS[$i]}))
200
- done
201
- printf '%s' "$total"
202
- }
203
-
204
- push_segment() {
205
- PLAIN_SEGMENTS+=("$1")
206
- RENDERED_SEGMENTS+=("$2")
207
- }
208
-
209
- add_segment_if_fit() {
210
- local plain="$1"
211
- local rendered="$2"
212
- local current extra
213
- current=$(current_plain_length)
214
- extra=${#plain}
215
- if [ "${#PLAIN_SEGMENTS[@]}" -gt 0 ]; then
216
- extra=$((extra + 3))
217
- fi
218
- if [ $((current + extra)) -le "$STATUSLINE_MAX_CHARS" ]; then
219
- push_segment "$plain" "$rendered"
220
- return 0
221
- fi
222
- return 1
223
- }
224
-
225
- truncate_plain_text() {
226
- local text="$1"
227
- local max_chars="$2"
228
- if [ "$max_chars" -le 0 ]; then
229
- printf ''
230
- elif [ "${#text}" -le "$max_chars" ]; then
231
- printf '%s' "$text"
232
- elif [ "$max_chars" -le 3 ]; then
233
- printf '%.*s' "$max_chars" "$text"
159
+ UP_ICON="๐Ÿ‘"
160
+ DOWN_ICON="๐Ÿ‘Ž"
161
+ DASHBOARD_LINK="$DASHBOARD_LABEL"
162
+ LESSONS_LINK="$LESSONS_LABEL"
163
+ LATEST_LESSON_LINK=""
164
+ if [ -n "$LESSON_LABEL" ]; then
165
+ if [ -n "$LESSON_TEXT" ]; then
166
+ LATEST_LESSON_LINK="$(inline_link "$LESSON_LINK" "${LESSON_LABEL}: ${LESSON_TEXT}")"
234
167
  else
235
- printf '%s...' "${text:0:$((max_chars - 3))}"
236
- fi
237
- }
238
-
239
- add_truncated_segment_if_fit() {
240
- local plain="$1"
241
- local color="$2"
242
- local min_chars="${3:-14}"
243
- local current sep remaining truncated
244
- current=$(current_plain_length)
245
- sep=0
246
- if [ "${#PLAIN_SEGMENTS[@]}" -gt 0 ]; then
247
- sep=3
248
- fi
249
- remaining=$((STATUSLINE_MAX_CHARS - current - sep))
250
- if [ "$remaining" -lt "$min_chars" ]; then
251
- return 1
168
+ LATEST_LESSON_LINK="$(inline_link "$LESSON_LINK" "$LESSON_LABEL")"
252
169
  fi
253
- truncated=$(truncate_plain_text "$plain" "$remaining")
254
- push_segment "$truncated" "${color}${truncated}${RST}"
255
- return 0
256
- }
257
-
258
- render_segments() {
259
- local line=''
260
- local i
261
- for ((i = 0; i < ${#RENDERED_SEGMENTS[@]}; i++)); do
262
- if [ "$i" -gt 0 ]; then
263
- line="${line} ยท "
264
- fi
265
- line="${line}${RENDERED_SEGMENTS[$i]}"
266
- done
267
- printf '%b\n' "$line"
268
- }
170
+ fi
269
171
 
270
172
  # โ”€โ”€ Output (single line) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
173
+ LINE="ThumbGate v${TG_VERSION} ยท ${TG_TIER}"
271
174
  if [ "$UP" = "0" ] && [ "$DOWN" = "0" ]; then
272
- push_segment "ThumbGate v${TG_VERSION}" "${D}ThumbGate v${TG_VERSION}${RST}"
273
- push_segment "${TG_TIER}" "${D}${TG_TIER}${RST}"
274
- push_segment "no feedback yet" "${D}no feedback yet${RST}"
275
- add_segment_if_fit "${DASHBOARD_LABEL}" "${C}${DASHBOARD_LINK}${RST}"
276
- add_segment_if_fit "${LESSONS_LABEL}" "${M}${LESSONS_LINK}${RST}"
277
- render_segments
175
+ LINE="${D}${LINE} ยท no feedback yet${RST} ยท ${C}${DASHBOARD_LINK}${RST} ยท ${M}${LESSONS_LINK}${RST}"
176
+ [ -n "$LATEST_LESSON_LINK" ] && LINE="${LINE} ยท ${D}${LATEST_LESSON_LINK}${RST}"
177
+ printf '%b\n' "$LINE"
278
178
  else
279
- STATS_PLAIN="${UP}๐Ÿ‘ ${DOWN}๐Ÿ‘Ž ${ARROW}"
280
- STATS_RENDERED="${G}${BD}${UP}${RST}${UP_ICON} ${R}${BD}${DOWN}${RST}${DOWN_ICON} ${ARROW}"
281
- ALERTS_PLAIN=''
282
- ALERTS_RENDERED=''
283
-
284
- if [ "${SLO_V:-0}" -gt 0 ]; then
285
- ALERTS_PLAIN="${ALERTS_PLAIN}${ALERTS_PLAIN:+ }${SLO_V} SLO"
286
- ALERTS_RENDERED="${ALERTS_RENDERED}${ALERTS_RENDERED:+ }${R}${SLO_V} SLO${RST}"
287
- fi
288
- if [ "${AT_RISK:-0}" -gt 0 ]; then
289
- ALERTS_PLAIN="${ALERTS_PLAIN}${ALERTS_PLAIN:+ }${AT_RISK}โš "
290
- ALERTS_RENDERED="${ALERTS_RENDERED}${ALERTS_RENDERED:+ }${R}${AT_RISK}โš ${RST}"
291
- fi
292
- if [ "${ANOMALIES:-0}" -gt 0 ]; then
293
- ALERTS_PLAIN="${ALERTS_PLAIN}${ALERTS_PLAIN:+ }${ANOMALIES}โ˜ "
294
- ALERTS_RENDERED="${ALERTS_RENDERED}${ALERTS_RENDERED:+ }${R}${ANOMALIES}โ˜ ${RST}"
295
- fi
179
+ LINE="${LINE} ยท ${G}${BD}${UP}${RST}${UP_ICON} ${R}${BD}${DOWN}${RST}${DOWN_ICON} ${ARROW}"
296
180
 
297
- push_segment "ThumbGate v${TG_VERSION}" "ThumbGate v${TG_VERSION}"
298
- push_segment "${TG_TIER}" "${TG_TIER}"
299
- push_segment "${STATS_PLAIN}" "${STATS_RENDERED}"
300
- add_segment_if_fit "${DASHBOARD_LABEL}" "${C}${DASHBOARD_LINK}${RST}"
301
- add_segment_if_fit "${LESSONS_LABEL}" "${M}${LESSONS_LINK}${RST}"
302
- if [ "${LESSONS:-0}" -gt 0 ]; then
303
- add_segment_if_fit "${LESSONS} lessons" "${M}${BD}${LESSONS}${RST} lessons"
304
- fi
305
- if [ -n "${ALERTS_PLAIN}" ]; then
306
- add_segment_if_fit "${ALERTS_PLAIN}" "${ALERTS_RENDERED}"
307
- fi
308
- if [ -n "${LESSON_TEXT}" ]; then
309
- add_truncated_segment_if_fit "${LESSON_TEXT}" "${D}" 14
310
- fi
181
+ # Control Tower alerts (if any)
182
+ [ "${SLO_V:-0}" -gt 0 ] && LINE="${LINE} ${R}${SLO_V} SLO${RST}"
183
+ [ "${AT_RISK:-0}" -gt 0 ] && LINE="${LINE} ${R}${AT_RISK}โš ${RST}"
184
+ [ "${ANOMALIES:-0}" -gt 0 ] && LINE="${LINE} ${R}${ANOMALIES}โ˜ ${RST}"
185
+ LINE="${LINE} ยท ${C}${DASHBOARD_LINK}${RST} ยท ${M}${LESSONS_LINK}${RST}"
186
+ [ -n "$LATEST_LESSON_LINK" ] && LINE="${LINE} ยท ${D}${LATEST_LESSON_LINK}${RST}"
311
187
 
312
- render_segments
188
+ printf '%b\n' "$LINE"
313
189
  fi