thumbgate 1.2.0 → 1.3.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 (54) hide show
  1. package/.claude-plugin/README.md +4 -4
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.well-known/mcp/server-card.json +1 -1
  5. package/README.md +35 -14
  6. package/adapters/README.md +1 -1
  7. package/adapters/claude/.mcp.json +2 -2
  8. package/adapters/codex/config.toml +2 -2
  9. package/adapters/mcp/server-stdio.js +2 -2
  10. package/adapters/opencode/opencode.json +1 -1
  11. package/bin/cli.js +20 -11
  12. package/config/github-about.json +1 -1
  13. package/config/model-tiers.json +11 -0
  14. package/package.json +8 -6
  15. package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
  16. package/plugins/claude-codex-bridge/.mcp.json +1 -1
  17. package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
  18. package/plugins/codex-profile/.mcp.json +1 -1
  19. package/plugins/codex-profile/INSTALL.md +1 -1
  20. package/plugins/codex-profile/README.md +1 -1
  21. package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
  22. package/plugins/cursor-marketplace/README.md +2 -2
  23. package/plugins/cursor-marketplace/commands/capture-feedback.md +2 -2
  24. package/plugins/cursor-marketplace/rules/feedback-capture.mdc +3 -3
  25. package/plugins/cursor-marketplace/skills/capture-feedback/SKILL.md +3 -2
  26. package/plugins/opencode-profile/INSTALL.md +1 -1
  27. package/public/compare.html +4 -4
  28. package/public/guide.html +4 -4
  29. package/public/index.html +51 -38
  30. package/public/learn/ai-agent-persistent-memory.html +1 -0
  31. package/public/lessons.html +325 -17
  32. package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
  33. package/scripts/audit-trail.js +6 -0
  34. package/scripts/capture-railway-diagnostics.sh +97 -0
  35. package/scripts/check-congruence.js +1 -1
  36. package/scripts/claude-feedback-sync.js +320 -0
  37. package/scripts/cli-telemetry.js +4 -1
  38. package/scripts/contextfs.js +32 -23
  39. package/scripts/dashboard.js +84 -0
  40. package/scripts/feedback-loop.js +16 -0
  41. package/scripts/intervention-policy.js +696 -0
  42. package/scripts/local-model-profile.js +18 -2
  43. package/scripts/model-tier-router.js +10 -1
  44. package/scripts/operational-integrity.js +354 -31
  45. package/scripts/prove-adapters.js +1 -0
  46. package/scripts/prove-automation.js +2 -2
  47. package/scripts/prove-packaged-runtime.js +260 -0
  48. package/scripts/prove-runtime.js +13 -0
  49. package/scripts/rate-limiter.js +3 -3
  50. package/scripts/statusline-local-stats.js +2 -0
  51. package/scripts/statusline.sh +166 -11
  52. package/scripts/tool-registry.js +2 -2
  53. package/scripts/workflow-sentinel.js +114 -4
  54. package/skills/thumbgate/SKILL.md +1 -1
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const http = require('http');
8
+ const net = require('net');
9
+ const { execFileSync } = require('child_process');
10
+
11
+ const ROOT = path.join(__dirname, '..');
12
+ const DEFAULT_TIMEOUT_MS = 15000;
13
+ const STATUSLINE_INPUT = JSON.stringify({ context_window: { used_percentage: 12 } });
14
+
15
+ function parseArgs(argv = process.argv.slice(2)) {
16
+ const parsed = {};
17
+ for (let index = 0; index < argv.length; index += 1) {
18
+ const token = argv[index];
19
+ if (!token.startsWith('--')) continue;
20
+ const [rawKey, inlineValue] = token.slice(2).split('=');
21
+ const key = rawKey.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
22
+ const value = inlineValue !== undefined ? inlineValue : argv[index + 1];
23
+ parsed[key] = value;
24
+ if (inlineValue === undefined) index += 1;
25
+ }
26
+ return parsed;
27
+ }
28
+
29
+ function pkgVersion() {
30
+ return require(path.join(ROOT, 'package.json')).version;
31
+ }
32
+
33
+ function packCurrentRepo(packDir) {
34
+ fs.mkdirSync(packDir, { recursive: true });
35
+ const output = execFileSync('npm', ['pack', '--json', '--pack-destination', packDir], {
36
+ cwd: ROOT,
37
+ encoding: 'utf8',
38
+ stdio: ['ignore', 'pipe', 'pipe'],
39
+ });
40
+ const parsed = JSON.parse(output);
41
+ const fileName = parsed && parsed[0] && parsed[0].filename;
42
+ if (!fileName) {
43
+ throw new Error('npm pack did not return a tarball filename');
44
+ }
45
+ return path.join(packDir, fileName);
46
+ }
47
+
48
+ function installPackage(prefixDir, packageSpec) {
49
+ fs.mkdirSync(prefixDir, { recursive: true });
50
+ execFileSync('npm', ['install', '--prefix', prefixDir, '--no-fund', '--no-audit', packageSpec], {
51
+ cwd: ROOT,
52
+ encoding: 'utf8',
53
+ stdio: ['ignore', 'pipe', 'pipe'],
54
+ });
55
+ return path.join(prefixDir, 'node_modules', '.bin', 'thumbgate');
56
+ }
57
+
58
+ function request(url, timeoutMs = 2000) {
59
+ return new Promise((resolve, reject) => {
60
+ const req = http.get(url, (res) => {
61
+ let body = '';
62
+ res.setEncoding('utf8');
63
+ res.on('data', (chunk) => { body += chunk; });
64
+ res.on('end', () => resolve({ statusCode: res.statusCode, body }));
65
+ });
66
+ req.on('error', reject);
67
+ req.setTimeout(timeoutMs, () => {
68
+ req.destroy(new Error(`Timed out requesting ${url}`));
69
+ });
70
+ });
71
+ }
72
+
73
+ function sleep(ms) {
74
+ return new Promise((resolve) => setTimeout(resolve, ms));
75
+ }
76
+
77
+ function getAvailablePort() {
78
+ return new Promise((resolve, reject) => {
79
+ const server = net.createServer();
80
+ server.listen(0, '127.0.0.1', () => {
81
+ const address = server.address();
82
+ const port = address && address.port;
83
+ server.close((error) => {
84
+ if (error) {
85
+ reject(error);
86
+ return;
87
+ }
88
+ resolve(port);
89
+ });
90
+ });
91
+ server.on('error', reject);
92
+ });
93
+ }
94
+
95
+ async function waitForHealthy(origin, expectedVersion, timeoutMs = DEFAULT_TIMEOUT_MS) {
96
+ const startedAt = Date.now();
97
+ while (Date.now() - startedAt < timeoutMs) {
98
+ try {
99
+ const response = await request(`${origin}/health`);
100
+ if (response.statusCode === 200) {
101
+ const body = JSON.parse(response.body);
102
+ if (body.version === expectedVersion) {
103
+ return body;
104
+ }
105
+ }
106
+ } catch {
107
+ // Keep polling until the detached API server comes online.
108
+ }
109
+ await sleep(250);
110
+ }
111
+ throw new Error(`Timed out waiting for packaged runtime health at ${origin}`);
112
+ }
113
+
114
+ function renderStatusline(runtimeBin, projectDir, env) {
115
+ return execFileSync(runtimeBin, ['statusline-render'], {
116
+ cwd: projectDir,
117
+ env,
118
+ encoding: 'utf8',
119
+ input: STATUSLINE_INPUT,
120
+ stdio: ['pipe', 'pipe', 'pipe'],
121
+ timeout: 10000,
122
+ });
123
+ }
124
+
125
+ function runtimeStatePath(homeDir) {
126
+ return path.join(homeDir, '.thumbgate', 'runtime', 'statusline-api.json');
127
+ }
128
+
129
+ async function stopDetachedRuntime(homeDir) {
130
+ try {
131
+ const state = JSON.parse(fs.readFileSync(runtimeStatePath(homeDir), 'utf8'));
132
+ const pid = Number(state && state.pid);
133
+ if (!Number.isInteger(pid) || pid <= 0) return;
134
+ try {
135
+ process.kill(pid, 'SIGTERM');
136
+ } catch {
137
+ return;
138
+ }
139
+ for (let attempt = 0; attempt < 10; attempt += 1) {
140
+ await sleep(100);
141
+ try {
142
+ process.kill(pid, 0);
143
+ } catch {
144
+ return;
145
+ }
146
+ }
147
+ try {
148
+ process.kill(pid, 'SIGKILL');
149
+ } catch {
150
+ // Ignore cleanup races.
151
+ }
152
+ } catch {
153
+ // No runtime state means nothing to clean up.
154
+ }
155
+ }
156
+
157
+ async function runPackagedRuntimeSmoke(options = {}) {
158
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'thumbgate-packaged-runtime-'));
159
+ const homeDir = path.join(tempRoot, 'home');
160
+ const projectDir = path.join(tempRoot, 'project');
161
+ const packDir = path.join(tempRoot, 'pack');
162
+ const runtimeDir = path.join(tempRoot, 'runtime');
163
+ const expectedVersion = options.expectedVersion || pkgVersion();
164
+ const feedbackDir = path.join(projectDir, '.thumbgate');
165
+ fs.mkdirSync(homeDir, { recursive: true });
166
+ fs.mkdirSync(projectDir, { recursive: true });
167
+ fs.mkdirSync(feedbackDir, { recursive: true });
168
+ fs.writeFileSync(
169
+ path.join(feedbackDir, 'feedback-log.jsonl'),
170
+ [
171
+ JSON.stringify({ signal: 'positive', timestamp: '2026-04-08T20:00:00.000Z', context: 'packaged runtime smoke pass' }),
172
+ JSON.stringify({ signal: 'negative', timestamp: '2026-04-08T20:01:00.000Z', context: 'packaged runtime smoke fail path' }),
173
+ ].join('\n') + '\n'
174
+ );
175
+
176
+ try {
177
+ const packageSpec = options.packageSpec || packCurrentRepo(packDir);
178
+ const runtimeBin = installPackage(runtimeDir, packageSpec);
179
+ if (!fs.existsSync(runtimeBin)) {
180
+ throw new Error(`Installed runtime binary is missing: ${runtimeBin}`);
181
+ }
182
+
183
+ const port = await getAvailablePort();
184
+ const origin = `http://127.0.0.1:${port}`;
185
+ const env = {
186
+ ...process.env,
187
+ HOME: homeDir,
188
+ THUMBGATE_PROJECT_DIR: projectDir,
189
+ THUMBGATE_LOCAL_API_ORIGIN: origin,
190
+ THUMBGATE_API_KEY: 'tg_packaged_runtime_smoke',
191
+ NO_COLOR: '1',
192
+ };
193
+
194
+ const initialStatusline = renderStatusline(runtimeBin, projectDir, env);
195
+ if (!initialStatusline.includes(`ThumbGate v${expectedVersion}`)) {
196
+ throw new Error(`Statusline version mismatch before boot: ${initialStatusline.trim()}`);
197
+ }
198
+ if (!/(Dashboard|Dashboard…)/.test(initialStatusline) || !/(Lessons|Lessons…)/.test(initialStatusline)) {
199
+ throw new Error(`Statusline missing dashboard affordances before boot: ${initialStatusline.trim()}`);
200
+ }
201
+
202
+ const health = await waitForHealthy(origin, expectedVersion, Number(options.timeoutMs || DEFAULT_TIMEOUT_MS));
203
+ const dashboard = await request(`${origin}/dashboard`);
204
+ const lessons = await request(`${origin}/lessons`);
205
+ if (dashboard.statusCode !== 200) {
206
+ throw new Error(`Packaged dashboard returned ${dashboard.statusCode}`);
207
+ }
208
+ if (lessons.statusCode !== 200) {
209
+ throw new Error(`Packaged lessons returned ${lessons.statusCode}`);
210
+ }
211
+
212
+ const readyStatusline = renderStatusline(runtimeBin, projectDir, env);
213
+ if (!readyStatusline.includes(`${origin}/dashboard`)) {
214
+ throw new Error(`Ready statusline missing dashboard URL: ${readyStatusline.trim()}`);
215
+ }
216
+ if (!readyStatusline.includes(`${origin}/lessons`)) {
217
+ throw new Error(`Ready statusline missing lessons URL: ${readyStatusline.trim()}`);
218
+ }
219
+ if (!readyStatusline.includes(`${origin}/feedback/quick?signal=up`)) {
220
+ throw new Error(`Ready statusline missing thumbs-up URL: ${readyStatusline.trim()}`);
221
+ }
222
+ if (!readyStatusline.includes(`${origin}/feedback/quick?signal=down`)) {
223
+ throw new Error(`Ready statusline missing thumbs-down URL: ${readyStatusline.trim()}`);
224
+ }
225
+
226
+ return {
227
+ packageSpec,
228
+ expectedVersion,
229
+ origin,
230
+ health,
231
+ };
232
+ } finally {
233
+ await stopDetachedRuntime(homeDir);
234
+ fs.rmSync(tempRoot, { recursive: true, force: true });
235
+ }
236
+ }
237
+
238
+ async function main() {
239
+ const args = parseArgs();
240
+ const result = await runPackagedRuntimeSmoke({
241
+ packageSpec: args.packageSpec,
242
+ expectedVersion: args.expectedVersion,
243
+ timeoutMs: args.timeoutMs,
244
+ });
245
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
246
+ }
247
+
248
+ if (require.main === module) {
249
+ main().catch((error) => {
250
+ process.stderr.write(`${error.message || String(error)}\n`);
251
+ process.exit(1);
252
+ });
253
+ }
254
+
255
+ module.exports = {
256
+ getAvailablePort,
257
+ packCurrentRepo,
258
+ runPackagedRuntimeSmoke,
259
+ waitForHealthy,
260
+ };
@@ -5,6 +5,7 @@ const os = require('os');
5
5
  const path = require('path');
6
6
 
7
7
  const { buildManagedScheduleCommand } = require('./schedule-manager');
8
+ const { runPackagedRuntimeSmoke } = require('./prove-packaged-runtime');
8
9
 
9
10
  const ROOT = path.join(__dirname, '..');
10
11
  const RUNNER_PATH = require.resolve('./async-job-runner');
@@ -275,6 +276,18 @@ async function run() {
275
276
  }
276
277
  },
277
278
  },
279
+ {
280
+ id: 'RUNTIME-07',
281
+ desc: 'packaged thumbgate runtime boots local API and serves dashboard affordances',
282
+ fn: async () => {
283
+ const result = await runPackagedRuntimeSmoke({
284
+ expectedVersion: require(path.join(ROOT, 'package.json')).version,
285
+ });
286
+ if (!result.health || result.health.version !== require(path.join(ROOT, 'package.json')).version) {
287
+ throw new Error('Packaged runtime health version did not match package.json');
288
+ }
289
+ },
290
+ },
278
291
  ];
279
292
 
280
293
  console.log('Interruptible Runtime - Proof Gate\n');
@@ -10,9 +10,9 @@ const {
10
10
  const USAGE_FILE = path.join(process.env.HOME || '/tmp', '.thumbgate', 'usage-limits.json');
11
11
 
12
12
  const FREE_TIER_LIMITS = {
13
- capture_feedback: { daily: Infinity, label: 'feedback captures' },
13
+ capture_feedback: { daily: 3, label: 'feedback captures' },
14
14
  search_lessons: { daily: 5, label: 'lesson searches' },
15
- search_thumbgate: { daily: 10, label: 'ThumbGate searches' },
15
+ search_thumbgate: { daily: 5, label: 'ThumbGate searches' },
16
16
  commerce_recall: { daily: 5, label: 'commerce recalls' },
17
17
  export_dpo: { daily: 0, label: 'DPO exports (Pro only)' },
18
18
  export_databricks: { daily: 0, label: 'Databricks exports (Pro only)' },
@@ -83,7 +83,7 @@ function checkLimit(action, authContext) {
83
83
  const current = usage.counts[action] || 0;
84
84
 
85
85
  if (current >= dailyLimit) {
86
- return { allowed: false, message: UPGRADE_MESSAGE, used: current, limit: dailyLimit };
86
+ return { allowed: false, message: `Free tier limit reached. Upgrade to Pro for unlimited: https://thumbgate-production.up.railway.app/pro\n${UPGRADE_MESSAGE}`, used: current, limit: dailyLimit };
87
87
  }
88
88
 
89
89
  // Increment
@@ -3,8 +3,10 @@
3
3
 
4
4
  const { analyzeFeedback } = require('./feedback-loop');
5
5
  const { normalizeStatsPayload } = require('./hook-thumbgate-cache-updater');
6
+ const { syncClaudeHistoryFeedback } = require('./claude-feedback-sync');
6
7
 
7
8
  try {
9
+ syncClaudeHistoryFeedback();
8
10
  const stats = analyzeFeedback();
9
11
  const payload = {
10
12
  ...normalizeStatsPayload(stats),
@@ -11,9 +11,18 @@ LOCAL_API_ORIGIN="${THUMBGATE_LOCAL_API_ORIGIN:-http://localhost:3456}"
11
11
  # ── Parse Claude Code session JSON from stdin ─────────────────────
12
12
  eval "$(cat | jq -r '
13
13
  def n(f): f // 0;
14
- @sh "CTX_PCT=\(n(.context_window.used_percentage) | floor)"
14
+ @sh "CTX_PCT=\(n(.context_window.used_percentage) | floor)",
15
+ @sh "PROJECT_CWD=\(.cwd // .working_directory // "")"
15
16
  ' 2>/dev/null)"
16
17
  CTX_PCT="${CTX_PCT:-0}"
18
+ PROJECT_CWD="${PROJECT_CWD:-}"
19
+
20
+ if [ -n "$PROJECT_CWD" ] && [ -d "$PROJECT_CWD" ]; then
21
+ export THUMBGATE_PROJECT_DIR="$PROJECT_CWD"
22
+ if [ -z "${THUMBGATE_FEEDBACK_DIR:-}" ]; then
23
+ export THUMBGATE_FEEDBACK_DIR="${PROJECT_CWD}/.claude/memory/feedback"
24
+ fi
25
+ fi
17
26
 
18
27
  # ── ThumbGate stats from cache ────────────────────────────────────────
19
28
  THUMBGATE_CACHE=""
@@ -117,6 +126,16 @@ if [ -n "$_TOWER_JSON" ]; then
117
126
  ' 2>/dev/null)"
118
127
  fi
119
128
 
129
+ # ── Latest lesson ──────────────────────────────────────────────────
130
+ LESSON_TEXT=""; LESSON_ID=""
131
+ _LESSON_JSON=$(node "${SCRIPT_DIR}/statusline-lesson.js" 2>/dev/null)
132
+ if [ -n "$_LESSON_JSON" ]; then
133
+ eval "$(echo "$_LESSON_JSON" | jq -r '
134
+ @sh "LESSON_TEXT=\(.text // "")",
135
+ @sh "LESSON_ID=\(.lessonId // "")"
136
+ ' 2>/dev/null)"
137
+ fi
138
+
120
139
  # ── Colors ────────────────────────────────────────────────────────
121
140
  G='\033[32m'; R='\033[31m'; M='\033[35m'; C='\033[36m'; D='\033[90m'; BD='\033[1m'; RST='\033[0m'
122
141
 
@@ -140,19 +159,155 @@ DOWN_ICON="$(osc8_link "$DOWN_URL" "👎")"
140
159
  DASHBOARD_LINK="$(osc8_link "$DASHBOARD_URL" "$DASHBOARD_LABEL")"
141
160
  LESSONS_LINK="$(osc8_link "$LESSONS_URL" "$LESSONS_LABEL")"
142
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"
234
+ 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
252
+ 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
+ }
269
+
143
270
  # ── Output (single line) ─────────────────────────────────────────
144
- LINE="ThumbGate v${TG_VERSION} · ${TG_TIER}"
145
271
  if [ "$UP" = "0" ] && [ "$DOWN" = "0" ]; then
146
- LINE="${D}${LINE} · no feedback yet${RST} · ${C}${DASHBOARD_LINK}${RST} · ${M}${LESSONS_LINK}${RST}"
147
- printf '%b\n' "$LINE"
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
148
278
  else
149
- LINE="${LINE} · ${G}${BD}${UP}${RST}${UP_ICON} ${R}${BD}${DOWN}${RST}${DOWN_ICON} ${ARROW}"
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
150
296
 
151
- # Control Tower alerts (if any)
152
- [ "${SLO_V:-0}" -gt 0 ] && LINE="${LINE} ${R}${SLO_V} SLO${RST}"
153
- [ "${AT_RISK:-0}" -gt 0 ] && LINE="${LINE} ${R}${AT_RISK}⚠${RST}"
154
- [ "${ANOMALIES:-0}" -gt 0 ] && LINE="${LINE} ${R}${ANOMALIES}☠${RST}"
155
- LINE="${LINE} · ${C}${DASHBOARD_LINK}${RST} · ${M}${LESSONS_LINK}${RST}"
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
156
311
 
157
- printf '%b\n' "$LINE"
312
+ render_segments
158
313
  fi
@@ -36,7 +36,7 @@ const TOOLS = [
36
36
  whatWorked: { type: 'string' },
37
37
  chatHistory: {
38
38
  type: 'array',
39
- description: 'Optional recent conversation window used for history-aware lesson distillation.',
39
+ description: 'Optional caller-supplied recent conversation window used for history-aware lesson distillation. The current Claude auto-capture path sends up to 8 prior recorded entries for vague negative inline signals.',
40
40
  items: {
41
41
  type: 'object',
42
42
  properties: {
@@ -59,7 +59,7 @@ const TOOLS = [
59
59
  timestamp: { type: 'string' },
60
60
  },
61
61
  },
62
- description: 'Last 5-10 conversation turns before the feedback signal. Raw messages, not summaries.',
62
+ description: 'Recent conversation turns before the feedback signal. Raw messages, not summaries.',
63
63
  },
64
64
  rubricScores: {
65
65
  type: 'array',