lumira 1.8.0 → 1.8.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.
package/README.md CHANGED
@@ -26,7 +26,7 @@ Interactive wizard — preset, theme, icons — previewed live before write.
26
26
 
27
27
  > 3,400+ monthly downloads, zero marketing. Try it for one session — `npx lumira install`.
28
28
 
29
- > **What's new in v1.8.0:** compaction counter (`⊙ N` on line 2) — tracks how many times the session has been compacted, pairing with the auto-compact proximity glyph ⚠. Togglable via `display.compactionCount`, on by default in `full`/`balanced`. Combined with the added-dirs badge and worktree breadcrumb (v1.7.0), [`lumira stats` CLI](#stats-cli) (v1.5), `API N%` latency widget (v1.4.0), 7-day quota projection warning (v1.3.0), and auto-compact proximity glyph ⚠ (v1.4.1), recent releases add several diagnostic signals to the session.
29
+ > **What's new in v1.8.1:** the GSD widget now mirrors get-shit-done (GSD)'s own statusline phase/milestone lifecycle, a milestone progress bar, and `⬆ /gsd:update` / `⚠ stale hooks` indicators that show in any project. GSD support is on by default and self-gates (no GSD project → nothing renders). Earlier releases added the compaction counter `⊙ N` (v1.8.0), added-dirs badge + worktree breadcrumb (v1.7.0), [`lumira stats` CLI](#stats-cli) (v1.5), `API N%` latency widget (v1.4.0), 7-day quota projection (v1.3.0), and the auto-compact proximity glyph ⚠ (v1.4.1).
30
30
 
31
31
  ## Table of contents
32
32
 
@@ -14,13 +14,15 @@ export function parseStateMd(content) {
14
14
  const state = {};
15
15
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
16
16
  if (fmMatch) {
17
- for (const line of fmMatch[1].split('\n')) {
17
+ const fmText = fmMatch[1];
18
+ // Parse simple scalar fields
19
+ for (const line of fmText.split('\n')) {
18
20
  const m = line.match(/^(\w+):\s*(.+)/);
19
21
  if (!m)
20
22
  continue;
21
23
  const [, key, val] = m;
22
24
  const v = val.trim().replace(/^(["'])(.*)\1$/, '$2');
23
- if (v === 'null')
25
+ if (v === 'null' || v === '')
24
26
  continue;
25
27
  if (key === 'status')
26
28
  state.status = v;
@@ -28,6 +30,51 @@ export function parseStateMd(content) {
28
30
  state.milestone = v;
29
31
  else if (key === 'milestone_name')
30
32
  state.milestoneName = v;
33
+ else if (key === 'active_phase')
34
+ state.activePhase = v;
35
+ else if (key === 'next_action')
36
+ state.nextAction = v;
37
+ }
38
+ // Parse next_phases: flow form [a, b]
39
+ const flowMatch = fmText.match(/^next_phases:\s*\[([^\]]*)\]/m);
40
+ if (flowMatch && flowMatch[1]) {
41
+ const items = flowMatch[1].split(',').map(s => {
42
+ const trimmed = s.trim().replace(/^(["'])(.*)\1$/, '$2');
43
+ return trimmed;
44
+ }).filter(s => s.length > 0);
45
+ if (items.length > 0)
46
+ state.nextPhases = items;
47
+ }
48
+ // Parse next_phases: block-list form
49
+ if (!state.nextPhases) {
50
+ const blockMatch = fmText.match(/^next_phases:\s*\n((?:[ \t]*-[ \t]*[^\n]+\n?)*)/m);
51
+ if (blockMatch && blockMatch[1]) {
52
+ const items = [];
53
+ for (const itemLine of blockMatch[1].split('\n')) {
54
+ const itemM = itemLine.match(/^[ \t]*-[ \t]*(.+)/);
55
+ if (itemM) {
56
+ const itemVal = itemM[1].trim().replace(/^(["'])(.*)\1$/, '$2');
57
+ if (itemVal)
58
+ items.push(itemVal);
59
+ }
60
+ }
61
+ if (items.length > 0)
62
+ state.nextPhases = items;
63
+ }
64
+ }
65
+ // Parse progress block
66
+ const progressMatch = fmText.match(/^progress:\s*\n((?:[ \t]+\w+:.+\n?)+)/m);
67
+ if (progressMatch && progressMatch[1]) {
68
+ const progressText = progressMatch[1];
69
+ const completedM = progressText.match(/completed_phases:\s*(\d+)/);
70
+ if (completedM)
71
+ state.completedPhases = completedM[1];
72
+ const totalM = progressText.match(/total_phases:\s*(\d+)/);
73
+ if (totalM)
74
+ state.totalPhases = totalM[1];
75
+ const percentM = progressText.match(/percent:\s*(\d+)/);
76
+ if (percentM)
77
+ state.percent = percentM[1];
31
78
  }
32
79
  }
33
80
  const phaseMatch = content.match(/^Phase:\s*(\d+)\s+of\s+(\d+)(?:\s+\(([^)]+)\))?/m);
@@ -51,6 +98,24 @@ export function parseStateMd(content) {
51
98
  }
52
99
  return state;
53
100
  }
101
+ /**
102
+ * Render a 10-segment progress bar: `█████░░░░░ 50%`.
103
+ *
104
+ * INTENTIONAL deviation from GSD's own statusline (gsd-statusline.js wraps the
105
+ * bar in brackets — `[█████░░░░░] 50%`). lumira drops the brackets so the
106
+ * milestone bar reads as distinct from the line-2 context bar. Do NOT re-add
107
+ * brackets when re-syncing GSD's format — this is a deliberate design choice.
108
+ */
109
+ function renderProgressBar(percent) {
110
+ if (percent === undefined || percent === null)
111
+ return '';
112
+ const pct = Math.max(0, Math.min(100, parseInt(String(percent), 10)));
113
+ if (isNaN(pct))
114
+ return '';
115
+ const filled = Math.floor(pct / 10);
116
+ const bar = '█'.repeat(filled) + '░'.repeat(10 - filled);
117
+ return `${bar} ${pct}%`;
118
+ }
54
119
  /** Walk up from `cwd` looking for `.planning/STATE.md`; stop at home or filesystem root. */
55
120
  export function findStateMd(cwd) {
56
121
  const home = homedir();
@@ -69,27 +134,64 @@ export function findStateMd(cwd) {
69
134
  /** Format a GSD state into a compact status string: `milestone · status · phase`. */
70
135
  function formatState(s) {
71
136
  const parts = [];
137
+ // Milestone segment with optional progress bar
72
138
  if (s.milestone || s.milestoneName) {
73
139
  const ver = s.milestone ?? '';
74
140
  const name = s.milestoneName && s.milestoneName !== 'milestone' ? s.milestoneName : '';
75
- const ms = [ver, name].filter(Boolean).join(' ');
76
- if (ms)
77
- parts.push(ms);
141
+ const bar = renderProgressBar(s.percent);
142
+ const msParts = [ver, name, bar].filter(Boolean);
143
+ if (msParts.length > 0)
144
+ parts.push(msParts.join(' '));
145
+ }
146
+ // Scene selection: activePhase → nextAction → milestone-complete → default
147
+ const phasesStr = s.nextPhases?.length ? s.nextPhases.join('/') : null;
148
+ if (s.activePhase) {
149
+ // Scene 1: activePhase (with optional status)
150
+ parts.push(s.status ? `Phase ${s.activePhase} ${s.status}` : `Phase ${s.activePhase}`);
78
151
  }
79
- if (s.status)
80
- parts.push(s.status);
81
- if (s.phaseNum && s.phaseTotal) {
82
- const phase = s.phaseName ? `${s.phaseName} (${s.phaseNum}/${s.phaseTotal})` : `ph ${s.phaseNum}/${s.phaseTotal}`;
83
- parts.push(phase);
152
+ else if (s.nextAction && phasesStr) {
153
+ // Scene 2: nextAction + phases when idle
154
+ parts.push(`next ${s.nextAction} ${phasesStr}`);
155
+ }
156
+ else if (Number(s.percent) === 100 || (s.completedPhases && s.totalPhases && s.completedPhases === s.totalPhases)) {
157
+ // Scene 3: milestone complete
158
+ parts.push('milestone complete');
159
+ }
160
+ else {
161
+ // Scene 4 (default): preserve existing behavior
162
+ if (s.status)
163
+ parts.push(s.status);
164
+ if (s.phaseNum && s.phaseTotal) {
165
+ const phase = s.phaseName ? `${s.phaseName} (${s.phaseNum}/${s.phaseTotal})` : `ph ${s.phaseNum}/${s.phaseTotal}`;
166
+ parts.push(phase);
167
+ }
84
168
  }
85
169
  return parts.join(' · ');
86
170
  }
171
+ /** Compare two semver versions. Returns 1 if a > b, -1 if a < b, 0 if equal. */
172
+ function semverCompare(a, b) {
173
+ const parseVer = (v) => {
174
+ const parts = v.replace(/^v/, '').split('.').map(p => parseInt(p, 10));
175
+ return { major: parts[0] ?? 0, minor: parts[1] ?? 0, patch: parts[2] ?? 0 };
176
+ };
177
+ const av = parseVer(a);
178
+ const bv = parseVer(b);
179
+ if (av.major !== bv.major)
180
+ return av.major > bv.major ? 1 : -1;
181
+ if (av.minor !== bv.minor)
182
+ return av.minor > bv.minor ? 1 : -1;
183
+ if (av.patch !== bv.patch)
184
+ return av.patch > bv.patch ? 1 : -1;
185
+ return 0;
186
+ }
87
187
  /**
88
188
  * Read GSD update-check cache. Checks the shared tool-agnostic cache first
89
189
  * (`~/.cache/gsd/`, introduced by GSD #1421), then falls back to the legacy
90
190
  * per-runtime location (`~/.claude/cache/`) for older GSD installs.
191
+ * Returns update status, stale hooks flag, and dev install flag.
91
192
  */
92
193
  function readUpdateCache(sharedCacheFile, legacyCacheFile) {
194
+ const result = { updateAvailable: false, staleHooks: false, devInstall: false };
93
195
  const candidates = [
94
196
  ['shared', sharedCacheFile],
95
197
  ['legacy', legacyCacheFile],
@@ -100,19 +202,33 @@ function readUpdateCache(sharedCacheFile, legacyCacheFile) {
100
202
  try {
101
203
  const parsed = JSON.parse(readFileSync(file, 'utf8'));
102
204
  if (parsed.update_available) {
205
+ result.updateAvailable = true;
103
206
  log('update cache:', source, file);
104
- return true;
105
207
  }
208
+ if (Array.isArray(parsed.stale_hooks) && parsed.stale_hooks.length > 0) {
209
+ result.staleHooks = true;
210
+ }
211
+ // DevInstall: stale_hooks present AND installed is ahead of latest.
212
+ // Guard against an unknown/missing latest explicitly (mirrors GSD's own
213
+ // check) rather than relying on NaN comparison semantics in semverCompare.
214
+ if (result.staleHooks &&
215
+ parsed.installed &&
216
+ parsed.latest &&
217
+ parsed.latest !== 'unknown' &&
218
+ semverCompare(parsed.installed, parsed.latest) > 0) {
219
+ result.devInstall = true;
220
+ }
221
+ return result;
106
222
  }
107
223
  catch { /* ignore malformed */ }
108
224
  }
109
- return false;
225
+ return result;
110
226
  }
111
227
  export function getGsdInfo(cwd, opts = {}) {
112
228
  const claudeDir = opts.claudeDir ?? process.env['CLAUDE_CONFIG_DIR'] ?? join(homedir(), '.claude');
113
229
  const sharedCacheFile = opts.sharedCacheFile ?? join(homedir(), '.cache', 'gsd', 'gsd-update-check.json');
114
230
  const legacyCacheFile = join(claudeDir, 'cache', 'gsd-update-check.json');
115
- const updateAvailable = readUpdateCache(sharedCacheFile, legacyCacheFile);
231
+ const cacheData = readUpdateCache(sharedCacheFile, legacyCacheFile);
116
232
  let currentTask;
117
233
  const stateFile = findStateMd(cwd || process.cwd());
118
234
  if (stateFile) {
@@ -132,10 +248,15 @@ export function getGsdInfo(cwd, opts = {}) {
132
248
  else {
133
249
  log('no STATE.md found walking up from:', cwd || process.cwd());
134
250
  }
135
- if (!updateAvailable && !currentTask) {
136
- log('no gsd signal — update=false, task=none (line4 will be empty)');
251
+ if (!cacheData.updateAvailable && !cacheData.staleHooks && !currentTask) {
252
+ log('no gsd signal — update=false, staleHooks=false, task=none (line4 will be empty)');
137
253
  return null;
138
254
  }
139
- return { updateAvailable, currentTask };
255
+ return {
256
+ updateAvailable: cacheData.updateAvailable || undefined,
257
+ staleHooks: cacheData.staleHooks || undefined,
258
+ devInstall: cacheData.devInstall || undefined,
259
+ currentTask,
260
+ };
140
261
  }
141
262
  //# sourceMappingURL=gsd.js.map
@@ -3,14 +3,23 @@ import { getCustomCommandsForLine, renderCustomCommand } from './shared.js';
3
3
  export function renderLine4(ctx, c) {
4
4
  const { gsd, icons } = ctx;
5
5
  const parts = [];
6
- // GSD widget — only emit when GSD has something to display.
7
- if (gsd && (gsd.currentTask || gsd.updateAvailable)) {
6
+ // GSD widget — only emit when GSD has something to display. Text and glyphs
7
+ // mirror GSD's own statusline (gsd-statusline.js) so the integration reads
8
+ // identically. The update/stale-hooks indicators render even without a
9
+ // current task, so a GSD update is visible in any project (gated only on the
10
+ // update-check cache, not on being inside a GSD project).
11
+ if (gsd && (gsd.currentTask || gsd.updateAvailable || gsd.staleHooks)) {
8
12
  parts.push(c.dim('GSD'));
9
13
  if (gsd.currentTask) {
10
- parts.push(c.bold(`${icons.hammer} ${truncField(gsd.currentTask, 40)}`));
14
+ parts.push(c.bold(`${icons.hammer} ${truncField(gsd.currentTask, 60)}`));
11
15
  }
12
16
  if (gsd.updateAvailable) {
13
- parts.push(c.yellow(`${icons.warning} GSD update available`));
17
+ parts.push(c.yellow('⬆ /gsd:update'));
18
+ }
19
+ if (gsd.staleHooks) {
20
+ parts.push(gsd.devInstall
21
+ ? c.yellow('⚠ dev install — re-run installer to sync hooks')
22
+ : c.red('⚠ stale hooks — run /gsd:update'));
14
23
  }
15
24
  }
16
25
  // Custom commands (issue #143 phase 3) — line 4 is the lowest-priority line
package/dist/types.js CHANGED
@@ -113,7 +113,11 @@ export const DEFAULT_DISPLAY = {
113
113
  };
114
114
  export const DEFAULT_CONFIG = {
115
115
  layout: 'auto',
116
- gsd: false,
116
+ // GSD on by default, mirroring GSD's own always-on statusline. Self-gates to
117
+ // nothing when there's no .planning/STATE.md and no update-check cache, so
118
+ // non-GSD users see no extra line and pay only a few cheap existsSync checks.
119
+ // Minimal/singleline returns early (renderMinimal) and never reaches line 4.
120
+ gsd: true,
117
121
  display: { ...DEFAULT_DISPLAY },
118
122
  colors: { mode: 'auto' },
119
123
  customCommands: { enabled: false, commands: [] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lumira",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "description": "Real-time statusline HUD for Claude Code and Qwen Code. Includes session analytics CLI, API latency overhead widget, 7d quota projection, auto-compact proximity warnings, themes, and powerline. Zero deps.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",