@litfamily/litclaude 1.0.25 → 1.0.26

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.
@@ -1,9 +1,12 @@
1
1
  // LitClaude's Claude Code mod (hooks/hooks.json `modules`). It draws the LIT card above the prompt
2
- // for the whole session (the `band` setting can limit it to active work or turn it off), lights a
3
- // chip for each LitFamily skill that fires, adds the discipline and progress to the spinner, raises
4
- // gate notices, and serves /lit-evidence. It is read-only: it reads LitClaude's own state files and
5
- // the Git HEAD through lib/mod/state.mjs, the settings for the status line's accent, and the host's
6
- // live figures, and never writes, spawns, fetches or calls a model.
2
+ // for the whole session (the `band` setting can limit it to active work or turn it off): where the
3
+ // session stands in the LIT workflow, the tool the main loop is running and for how long, a chip
4
+ // for each LitFamily skill that fires. It adds the discipline and progress to the spinner, raises
5
+ // gate notices, serves /lit-evidence and, when the `notify` option asks, raises a notification
6
+ // when LIT work completes or a long LIT turn ends. It is read-only: it reads LitClaude's own state
7
+ // files and the Git HEAD through lib/mod/state.mjs, the settings for the status line's accent, and
8
+ // the host's live figures, and never writes, spawns, fetches or calls a model. Of a tool call it
9
+ // keeps the tool's name and start time, never its arguments.
7
10
  // Claude Code releases before mods ignore the `modules` key and keep running the settings hooks.
8
11
  //
9
12
  // The mods loader only follows `$` into functions declared at the top of this file, so each helper
@@ -13,6 +16,7 @@ import { composeBand, composeEvidence } from "../lib/mod/frame.mjs";
13
16
  import { TIMINGS } from "../lib/mod/motion.mjs";
14
17
  import { renderBand, renderRows } from "../lib/mod/render.mjs";
15
18
  import { accentFromSettings, normalizeModSettings, resolveGlyphs } from "../lib/mod/settings.mjs";
19
+ import { composeSvgCard } from "../lib/mod/svg-card.mjs";
16
20
  import { chipStates, litSkillId, noteSkill } from "../lib/mod/skills.mjs";
17
21
  import { detectIconFont, readGitBranch, readLedgerTail, readLitSnapshot } from "../lib/mod/state.mjs";
18
22
  import { settledSample } from "../lib/mod/timeline.mjs";
@@ -22,6 +26,7 @@ import {
22
26
  buildEvidenceModel,
23
27
  detectCompletion,
24
28
  diffNotices,
29
+ formatDuration,
25
30
  mergeSpawned,
26
31
  noteAgentActivity,
27
32
  noteSpawn,
@@ -39,7 +44,9 @@ const TABS = Object.freeze([
39
44
  { id: "lanes", label: "lanes", hotkey: "2" },
40
45
  { id: "gates", label: "gates", hotkey: "3" },
41
46
  ]);
42
- const NO_SNAPSHOT = Object.freeze({ ignition: null, work: null, completedWorkIds: [], goal: null, autoloop: null, litPlan: null, probe: null, motion: null, handoff: null });
47
+ const NO_SNAPSHOT = Object.freeze({ ignition: null, work: null, completedWorkIds: [], goal: null, autoloop: null, litPlan: null, probe: null, motion: null, handoff: null, workflow: null });
48
+ // A LIT turn at least this long notifies when it ends, under `notify`.
49
+ const LONG_TURN_MS = 3 * 60 * 1000;
43
50
 
44
51
  const createLive = (options) => ({
45
52
  band: normalizeModSettings(options).band,
@@ -69,11 +76,16 @@ const createLive = (options) => ({
69
76
  settledAt: 0,
70
77
  ignitionAt: undefined,
71
78
  lastDiscipline: null,
79
+ step: null,
80
+ mainToolCalls: 0,
81
+ turnStartedAt: null,
82
+ turnNotified: false,
72
83
  });
73
84
 
74
85
  const stateIo = ($) => ({
75
86
  stat: (path) => $.fs.stat(path, { resolve: true }),
76
87
  read: (path) => $.fs.read(path),
88
+ list: (path) => $.fs.list(path),
77
89
  });
78
90
 
79
91
  const toastText = (raised) => `🔥 ${raised.title}${raised.detail ? ` · ${raised.detail}` : ""}`;
@@ -94,6 +106,7 @@ async function rebuild($, live) {
94
106
  skills: chipStates(live.fired, live.settledAt),
95
107
  lastDiscipline: live.lastDiscipline,
96
108
  git: live.git,
109
+ step: live.step,
97
110
  });
98
111
  live.model = next;
99
112
  if (previous?.visible && !next.visible && !live.exiting) {
@@ -118,10 +131,18 @@ function showNotice($, live, raised) {
118
131
  });
119
132
  }
120
133
 
121
- function celebrate($, live, finish) {
134
+ // The opt-in finish notification. No sound file ships yet, so `finish+sound` notifies like `finish`.
135
+ function notifyFinish($, options, text) {
136
+ if (normalizeModSettings(options).notify === "off") return;
137
+ $.ui.notify(text, { title: "LitClaude" }).catch(() => undefined);
138
+ }
139
+
140
+ function celebrate($, live, finish, options) {
122
141
  if (live.celebrated.has(finish.id)) return;
123
142
  live.celebrated.add(finish.id);
124
143
  live.completion = finish;
144
+ live.turnNotified = true;
145
+ notifyFinish($, options, `LIT complete · ${finish.detail}`);
125
146
  $.clock.after(TIMINGS.completion + 200, () => {
126
147
  if (live.completion?.id !== finish.id) return;
127
148
  live.completion = null;
@@ -148,7 +169,7 @@ function noteIgnition(live, ignition, now) {
148
169
  if (id) live.fired = noteSkill(live.fired, id, ignition.at ?? now);
149
170
  }
150
171
 
151
- async function refresh($, live) {
172
+ async function refresh($, live, options) {
152
173
  if (live.refreshing) return;
153
174
  live.refreshing = true;
154
175
  try {
@@ -166,7 +187,7 @@ async function refresh($, live) {
166
187
  live.snapshot = fresh;
167
188
  noteIgnition(live, fresh.ignition, now);
168
189
  const finish = detectCompletion(before, fresh);
169
- if (finish) celebrate($, live, finish);
190
+ if (finish) celebrate($, live, finish, options);
170
191
  const next = await rebuild($, live);
171
192
  const raised = diffNotices(before, fresh, previousModel, next);
172
193
  if (raised.length > 0) {
@@ -182,12 +203,12 @@ async function refresh($, live) {
182
203
  }
183
204
  }
184
205
 
185
- function refreshSoon($, live) {
206
+ function refreshSoon($, live, options) {
186
207
  if (live.quickPending) return;
187
208
  live.quickPending = true;
188
209
  $.clock.after(QUICK_REFRESH_MS, () => {
189
210
  live.quickPending = false;
190
- void refresh($, live);
211
+ void refresh($, live, options);
191
212
  });
192
213
  }
193
214
 
@@ -209,16 +230,19 @@ export const register = (on, options) => {
209
230
  live.settledAt = await $.clock.now();
210
231
  live.accent = accentFromSettings(await $.settings.read().catch(() => null), await $.env.get("LITCLAUDE_HUD_ACCENT"));
211
232
  await $.command.register({ name: PANE, description: "Show LitClaude goal criteria, evidence, lanes and gates in a pane" });
212
- await refresh($, live);
233
+ await refresh($, live, options);
213
234
  $.clock.every(POLL_MS, () => {
214
- void refresh($, live);
235
+ void refresh($, live, options);
215
236
  });
216
237
  return next(e);
217
238
  });
218
239
 
219
240
  on("turn.start", async ($, e, next) => {
220
241
  live.isWorking = true;
221
- refreshSoon($, live);
242
+ live.mainToolCalls = 0;
243
+ live.turnStartedAt = await $.clock.now();
244
+ live.turnNotified = false;
245
+ refreshSoon($, live, options);
222
246
  return next(e);
223
247
  });
224
248
 
@@ -226,50 +250,81 @@ export const register = (on, options) => {
226
250
  // A subagent's or teammate's turn ends its run, not the main turn the card follows.
227
251
  if (typeof e.agentId === "string") {
228
252
  live.spawned = noteSpawnEnded(live.spawned, e.agentId);
229
- refreshSoon($, live);
253
+ refreshSoon($, live, options);
230
254
  return next(e);
231
255
  }
232
256
  live.isWorking = false;
257
+ live.step = null;
258
+ live.mainToolCalls = 0;
259
+ const now = await $.clock.now();
260
+ const took = Number.isFinite(e.durationMs) ? e.durationMs : now - (live.turnStartedAt ?? now);
261
+ const lit = live.model && (live.model.discipline || live.model.workflow || live.model.skills.some(({ state }) => state === "current"));
262
+ if (lit && !live.turnNotified && took >= LONG_TURN_MS) {
263
+ live.turnNotified = true;
264
+ notifyFinish($, options, `LIT turn finished · ${live.model.title ?? "LitClaude"} · ${formatDuration(took)}`);
265
+ }
233
266
  // The request is over: its chips dim to the recent list until a skill fires again.
234
- live.settledAt = await $.clock.now();
235
- refreshSoon($, live);
267
+ live.settledAt = now;
268
+ refreshSoon($, live, options);
236
269
  return next(e);
237
270
  });
238
271
 
239
272
  on("session.measure", async ($, e, next) => {
240
273
  live.usage = { context: e.context, rateLimits: e.rateLimits };
241
- if (e.changed.includes("context")) refreshSoon($, live);
274
+ if (e.changed.includes("context")) refreshSoon($, live, options);
242
275
  return next(e);
243
276
  });
244
277
 
245
278
  on("tool.call", async ($, e, next) => {
246
- if (e.tool === "Skill") await noteFiring($, live, e.skill);
279
+ // The card follows the main loop: a skill a subagent loads (a reviewer reading `rules`) neither
280
+ // lights a chip nor retitles the card.
281
+ if (e.tool === "Skill" && typeof e.agentId !== "string") await noteFiring($, live, e.skill);
247
282
  if (typeof e.agentId === "string") live.spawned = noteSpawnActivity(live.spawned, e.agentId, await $.clock.now());
248
- const result = await next(e);
283
+ // The live step: the main loop's tool by name and since when, nothing else of the call.
284
+ const step = typeof e.agentId === "string" ? null : { name: e.tool, at: await $.clock.now() };
285
+ if (step) live.mainToolCalls += 1;
286
+ if (step) {
287
+ live.step = step;
288
+ await rebuild($, live);
289
+ }
290
+ let result;
291
+ try {
292
+ result = await next(e);
293
+ } finally {
294
+ if (step && live.step === step) live.step = live.isWorking ? { name: "thinking", at: await $.clock.now() } : null;
295
+ }
249
296
  if (typeof e.agentId === "string") {
250
297
  const now = await $.clock.now();
251
298
  live.activity = noteAgentActivity(live.activity, e.agentId, now);
252
299
  live.spawned = noteSpawnActivity(live.spawned, e.agentId, now);
253
300
  }
254
- refreshSoon($, live);
301
+ refreshSoon($, live, options);
255
302
  return result;
256
303
  }).catch(($, e, next) => next(e));
257
304
 
305
+ // A model request on the main loop: the step reads "thinking" until a tool call names itself.
306
+ on("turn.step", async function* ($, e, next) {
307
+ if (typeof e.agentId !== "string" && live.isWorking && live.step?.name !== "thinking") live.step = { name: "thinking", at: await $.clock.now() };
308
+ return yield* next(e);
309
+ });
310
+
258
311
  // Every subagent, teammate and workflow agent starts here. A workflow's never shows in
259
312
  // `$.agent.list()`, so the card keeps its own record until the agent's turn.complete.
260
313
  on("agent.spawn", async ($, e, next) => {
261
314
  const result = await next(e);
262
315
  if (typeof result?.agentId === "string") {
263
316
  live.spawned = noteSpawn(live.spawned, { id: result.agentId, description: e.description, type: e.subagentType, at: await $.clock.now() });
264
- refreshSoon($, live);
317
+ refreshSoon($, live, options);
265
318
  }
266
319
  return result;
267
320
  }).catch(($, e, next) => next(e));
268
321
 
269
322
  // A typed `/litclaude:<name>` and the Skill tool both expand the skill's prompt here; the text
270
- // passes through untouched.
323
+ // passes through untouched. So does a subagent's preloaded skill (an agent's `skills:` list),
324
+ // and the event does not say whose it is: a firing counts only before the main loop's first tool
325
+ // call of the turn (a typed command) or inside its Skill call.
271
326
  on("skill.prompt", async ($, e, next) => {
272
- await noteFiring($, live, e.skill);
327
+ if (live.mainToolCalls === 0 || live.step?.name === "Skill") await noteFiring($, live, e.skill);
273
328
  return next(e);
274
329
  }).catch(($, e, next) => next(e));
275
330
 
@@ -286,7 +341,14 @@ export const register = (on, options) => {
286
341
  const elements = $.ui.resolve(e);
287
342
  const columns = Math.max(20, e.props.bodyColumns ?? e.viewport?.columns ?? 80);
288
343
  let drawn = null;
289
- if (elements.Client && !live.clientFailed) {
344
+ if (e.surface !== "terminal" && elements.Svg) {
345
+ // A desktop or editor draws Text in a proportional font, so the cell-built card comes apart
346
+ // there; those surfaces get the card as one drawing instead.
347
+ if (model.visible) {
348
+ const { source, alt, animated } = composeSvgCard(model, { motion: settings.motion, accent: live.accent });
349
+ drawn = elements.Svg({ source, alt, ...(animated ? { isInteractive: true } : {}) });
350
+ }
351
+ } else if (elements.Client && !live.clientFailed) {
290
352
  drawn = elements.Client({ key: "lit-band", module: "./lit-band-client.mjs", props: { model, columns, glyphs, motion: settings.motion, accent: live.accent } });
291
353
  } else if (model.visible) {
292
354
  drawn = renderBand(elements, composeBand(model, settledSample(model), { columns, glyphs, accent: live.accent }));
@@ -1,16 +1,25 @@
1
- // Turns a band model and one sampled frame into the LIT card: four rows of coloured spans. A span is
1
+ // Turns a band model and one sampled frame into the LIT card: five rows of coloured spans. A span is
2
2
  // { text, fg?, bg?, bold? }; the surface module draws each row as a line of Text elements. The card
3
3
  // draws its own rounded border so the top edge can carry the title and the bottom edge the live
4
4
  // figures, and every row is fitted to exactly the card's width, counting Hangul and emoji as two
5
5
  // cells, so nothing wraps and the card keeps its height whether LitClaude is working or idle.
6
6
  //
7
- // ╭─ [🔥 LIT] · lit-loop ─────────────────────────── WORK ● ─╮
8
- // │ ● PLAN ━━ [WORK] ━━ ○ REVIEW goal ██░░░ 2/5 │
9
- // │ skills [⚡ lit-plan] [⚡ litresearch] [↯ lit-docx] │
10
- // ╰─ ctx 42% ▰▰▱▱▱ · 5h 31% · 1w 12% · git main ─────────────╯
7
+ // ╭─ [🔥 LIT] · start-work ───────────────────────────────── WORK ● ─╮
8
+ // │ interview ✓ ━━ plan ✓ ━━ [work 3/7] ━━ review · ── done · 1 lane │
9
+ // │ └ task 3/7 ███░░░░░ · next ▸ 4. README demo - Bash · 0:12 │
10
+ // │ skills [⚡ start-work] [↯ lit-plan] │
11
+ // ╰─ ctx 42% ▰▰▱▱▱ · 5h 31% · 1w 12% · git main ─────────────────────╯
11
12
  //
12
- // Each bracket pair stands for the two end cells of a filled pill: the brand on pink, the active
13
- // phase and this request's skills on the accent with dark text, skills from earlier requests on
13
+ // The second row is the workflow map (workflow.mjs): done stages carry a check in a colour graded
14
+ // along the rail from flame orange through pink to the accent, the current stage is a pill, the
15
+ // stages ahead are dim. The rail leaving the current stage fills with its count and, while
16
+ // LitClaude works, carries a bright head; the rails ahead are dim `─`. A narrow card drops the
17
+ // rails, then shortens the names, then shows only the current stage and its neighbours. With no
18
+ // family to place the session in, the row is the PLAN / WORK / REVIEW track instead. The third row
19
+ // is the stage's detail and, on the right, the tool the main loop is running and for how long.
20
+ //
21
+ // Each bracket pair stands for the two end cells of a filled pill: the brand on pink, the current
22
+ // stage and this request's skills on the accent with dark text, skills from earlier requests on
14
23
  // dark grey. Those end cells are rounded Nerd Font caps in the pill's colour, or one cell of
15
24
  // padding in that colour with basic glyphs, so a pill is as wide in either glyph set.
16
25
  //
@@ -19,10 +28,12 @@
19
28
  // colour of an ASCII cell, repaints. So every change is carried by ASCII: a bar cell is a space on
20
29
  // its colour and the leading cell fades in by colour, embers are ASCII sparks, labels pop in letter
21
30
  // by letter, a notice slides by its leading spaces, a chip's light moves over its ASCII name, and a
22
- // glyph swaps only together with the colour of its words.
31
+ // glyph swaps only together with the colour of its words. The map's rail cells keep their glyph
32
+ // and move by colour alone (a colour change on `─` or `━` repaints), and the live step's spinner
33
+ // and clock are ASCII.
23
34
 
24
35
  import { textWidth, truncateToWidth } from "./layout.mjs";
25
- import { barCells, lerpColor } from "./motion.mjs";
36
+ import { barCells, gradientAt, lerpColor } from "./motion.mjs";
26
37
  import { glyphSet } from "./settings.mjs";
27
38
  import { formatDuration } from "./view.mjs";
28
39
 
@@ -195,6 +206,12 @@ const brandSpans = (sample, glyphs) => {
195
206
  const indicatorSpans = (model, sample, glyphs, accent) => {
196
207
  if (sample.completion) return [span(`DONE ${glyphs.done}`, { fg: PALETTE.green, bold: true })];
197
208
  if (model.idle) return [span("idle", { fg: PALETTE.dim })];
209
+ const flow = model.workflow;
210
+ if (flow && flow.current >= 0) {
211
+ const warn = model.ctxWarning && sample.warn > 0.02;
212
+ return [span(`${flow.stages[flow.current].id.toUpperCase()} ${warn ? glyphs.warn : glyphs.now}`, { fg: warn ? lerpColor(accent, PALETTE.amber, sample.warn) : accent, bold: true })];
213
+ }
214
+ if (flow && model.lanes.count === 0) return [span(`DONE ${glyphs.done}`, { fg: PALETTE.green, bold: true })];
198
215
  const current = PHASES.find((id) => model.phases[id] === "now");
199
216
  if (current) return [span(`${LABELS[current]} ${warned(current, sample) ? glyphs.warn : glyphs.now}`, { fg: phaseColor(current, sample, inkOf(accent)), bold: true })];
200
217
  if (PHASES.every((id) => model.phases[id] === "done") && model.lanes.count === 0) return [span(`DONE ${glyphs.done}`, { fg: PALETTE.green, bold: true })];
@@ -292,6 +309,172 @@ const phaseRow = (model, sample, { inner, glyphs, accent }) => {
292
309
  return fitRow([...track, span(" ".repeat(Math.max(2, inner - rowWidth(track) - rowWidth(tail)))), ...tail], inner);
293
310
  };
294
311
 
312
+ // The colour of a point `position` (0 to 1) of the way along the map: flame orange, pink, accent.
313
+ const mapColor = (accent, position) => gradientAt([PALETTE.orange, PALETTE.pink, accent], position);
314
+
315
+ // A stage name cut to its first three letters, its count kept: `work 3/7` becomes `wor 3/7`.
316
+ const shortLabel = (label) => label.replace(/^[A-Za-z]+/u, (word) => word.slice(0, 3));
317
+
318
+ // The rail from stage `index - 1` to stage `index`, `cells` wide. Behind the current stage it is
319
+ // `━` graded along the map; leaving it, `━` filled by the stage's count with a bright head while
320
+ // LitClaude works; ahead, dim `─`. With no room for rails, one space.
321
+ const railSpans = (model, sample, index, cells, accent) => {
322
+ if (cells === 0) return [span(" ")];
323
+ const { stages } = model.workflow;
324
+ const last = Math.max(1, stages.length - 1);
325
+ const at = (cell) => (index - 1 + (cell + 0.5) / cells) / last;
326
+ const into = stages[index].state;
327
+ const from = stages[index - 1].state;
328
+ let body;
329
+ if (into === "done" || into === "now") {
330
+ body = Array.from({ length: cells }, (_, cell) => span("━", { fg: mapColor(accent, at(cell)) }));
331
+ } else if (from === "now") {
332
+ const filled = model.workflow.count ? sample.fill * cells : 0;
333
+ const flow = model.idle ? null : sample.ambient?.flow ?? null;
334
+ const head = flow === null ? -1 : Math.floor(flow * cells);
335
+ body = Array.from({ length: cells }, (_, cell) => {
336
+ const base = cell < filled ? mapColor(accent, at(cell)) : PILL.rail;
337
+ return span("━", { fg: cell === head ? lerpColor(base, "#ffffff", 0.75) : base });
338
+ });
339
+ } else {
340
+ body = [span("─".repeat(cells), { fg: PILL.rail })];
341
+ }
342
+ return [span(" "), ...body, span(" ")];
343
+ };
344
+
345
+ // interview ✓ ━━ plan ✓ ━━ [work 3/7] ━━ review · ── done ·
346
+ const mapTrack = (model, sample, { glyphs, accent, rail, short = false, window = false }) => {
347
+ const { stages, current } = model.workflow;
348
+ const last = Math.max(1, stages.length - 1);
349
+ const focus = current < 0 ? stages.length - 1 : current;
350
+ const shown = stages.map((_, index) => index).filter((index) => !window || Math.abs(index - focus) <= 1);
351
+ const out = [];
352
+ if (window && shown[0] > 0) out.push(span("… ", { fg: PALETTE.dim }));
353
+ shown.forEach((index) => {
354
+ const stage = stages[index];
355
+ const label = short ? shortLabel(stage.label) : stage.label;
356
+ const text = stage.state === "now" ? label : `${label} ${stage.state === "done" ? glyphs.done : "·"}`;
357
+ const revealed = revealText(text, sample.pills[Math.min(3, 1 + Math.floor(index / 2))]);
358
+ if (revealed === null) return;
359
+ if (out.length && !(window && out.length === 1 && shown[0] > 0)) out.push(...railSpans(model, sample, index, rail, accent));
360
+ if (stage.state === "now") {
361
+ let fill = model.idle ? lerpColor(accent, PILL.muted, 0.5) : accent;
362
+ if (model.ctxWarning) fill = lerpColor(fill, PALETTE.amber, sample.warn);
363
+ if (sample.ambient && !model.idle) fill = lerpColor(fill, "#ffffff", 0.2 * sample.ambient.breath);
364
+ const sweep = model.idle ? null : sample.ambient?.sweep ?? null;
365
+ out.push(...pill(litText(revealed.slice(0, label.length).padEnd(label.length), { fg: PILL.ink, bg: fill, bold: true }, sweep), fill, glyphs));
366
+ } else {
367
+ out.push(span(revealed, { fg: stage.state === "done" ? mapColor(accent, index / last) : PALETTE.dim }));
368
+ }
369
+ });
370
+ if (window && shown.at(-1) < stages.length - 1) out.push(span(" …", { fg: PALETTE.dim }));
371
+ return out;
372
+ };
373
+
374
+ const MAP_VARIANTS = Object.freeze([
375
+ { rail: 2 },
376
+ { rail: 1 },
377
+ { rail: 0 },
378
+ { rail: 0, short: true },
379
+ { rail: 0, short: true, window: true },
380
+ ]);
381
+
382
+ // The map, then the context warning, the lanes and the autoloop budget while there is room.
383
+ const mapRow = (model, sample, { inner, glyphs, accent }) => {
384
+ const tracks = MAP_VARIANTS.map((variant) => mapTrack(model, sample, { glyphs, accent, ...variant }));
385
+ const track = tracks.find((candidate) => rowWidth(candidate) <= inner) ?? tracks.at(-1);
386
+ const right = [];
387
+ if (sample.rest > 0) {
388
+ if (model.ctxWarning && sample.warn > 0.02) {
389
+ right.push(segment("ctx", 1, [span(`ctx ${model.ctxWarning.percent}% → ${model.ctxWarning.threshold}%`, { fg: lerpColor(PALETTE.dim, PALETTE.amber, sample.warn), bold: sample.warn > 0.5 })]));
390
+ }
391
+ if (model.lanes.count > 0) {
392
+ const dots = model.lanes.list.slice(0, 4).map((lane) => span(glyphs.lane, { fg: lane.status === "stalled" ? PALETTE.amber : PALETTE.green }));
393
+ right.push(segment("lanes", 3, [...dots, span(` ${model.lanes.count} ${model.lanes.count === 1 ? "lane" : "lanes"}`, { fg: PALETTE.dim })]));
394
+ }
395
+ if (model.autoloop) {
396
+ right.push(segment("loop", 4, [span(`${glyphs.loop} ${model.autoloop.n}/${model.autoloop.max}`, { fg: PALETTE.violet }), span(` · ${formatDuration(model.autoloop.leftMs)}`, { fg: PALETTE.dim })]));
397
+ }
398
+ }
399
+ const kept = fitJoined(right, inner - rowWidth(track) - 2);
400
+ const tail = joinSegments(kept, PALETTE.faint);
401
+ if (!tail.length) return fitRow(track, inner);
402
+ return fitRow([...track, span(" ".repeat(Math.max(2, inner - rowWidth(track) - rowWidth(tail)))), ...tail], inner);
403
+ };
404
+
405
+ const SPINNER = Object.freeze(["-", "\\", "|", "/"]);
406
+
407
+ // m:ss, or h:mm:ss past the hour.
408
+ export const formatClock = (ms) => {
409
+ const seconds = Math.max(0, Math.floor(ms / 1000));
410
+ const minutes = Math.floor(seconds / 60);
411
+ const ss = String(seconds % 60).padStart(2, "0");
412
+ return minutes >= 60 ? `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}:${ss}` : `${minutes}:${ss}`;
413
+ };
414
+
415
+ // The live step: an ASCII spinner, the tool's name and how long it has run. Nothing else of the call.
416
+ const stepSpans = (sample, accent) => {
417
+ if (!sample.step) return [];
418
+ return [
419
+ span(`${SPINNER[sample.spin ?? 0]} `, { fg: accent, bold: true }),
420
+ span(sample.step.name, { fg: PALETTE.white }),
421
+ span(` · ${formatClock(sample.step.elapsedMs)}`, { fg: PALETTE.dim }),
422
+ ];
423
+ };
424
+
425
+ const VERDICT_STYLE = Object.freeze({
426
+ pass: (glyphs) => [glyphs.done, PALETTE.green],
427
+ fail: (glyphs) => [glyphs.fail, PALETTE.red],
428
+ pending: () => ["·", PALETTE.dim],
429
+ running: (glyphs) => [glyphs.lane, PALETTE.amber],
430
+ });
431
+
432
+ // The review lanes or a crucible verdict: each name in its verdict's colour (a lane under review
433
+ // amber), so a change repaints.
434
+ // `cut` shortens the names to that many letters; 0 drops them and leaves the marks.
435
+ const verdictSpans = (verdicts, glyphs, cut = Infinity) => verdicts.flatMap(({ name, state }, index) => {
436
+ const [mark, color] = VERDICT_STYLE[state](glyphs);
437
+ const text = cut === 0 ? mark : `${name.slice(0, cut)} ${mark}`;
438
+ return [...(index > 0 ? [span(" ")] : []), span(text, { fg: color })];
439
+ });
440
+
441
+ // └ task 3/7 ███░░░░░ · paused · next ▸ <task> - Bash · 0:12
442
+ const detailRow = (model, sample, { inner, glyphs, accent }) => {
443
+ const flow = model.workflow;
444
+ const right = stepSpans(sample, accent);
445
+ const segments = [];
446
+ if (flow?.count) {
447
+ const { done, total, unit } = flow.count;
448
+ const glow = sample.ambient && !model.idle ? 0.5 * sample.ambient.breath : sample.fillGlow ?? 0;
449
+ const bar = barCells(sample.fill, inner >= 70 ? 8 : 5, glow).map(({ glyph, color }) => (glyph === "█" ? span(" ", { bg: color }) : span(glyph, { fg: color })));
450
+ segments.push(segment("count", 1, [
451
+ span(`${unit} `, { fg: PALETTE.dim }),
452
+ span(String(done), { fg: lerpColor(PALETTE.white, PALETTE.cyan, Math.max(sample.flash, sample.fillGlow ?? 0)), bold: true }),
453
+ span(`/${total} `, { fg: PALETTE.dim }),
454
+ ...bar,
455
+ ], " · "));
456
+ }
457
+ const lead = [span("└ ", { fg: PALETTE.dim })];
458
+ const room = inner - rowWidth(lead) - (right.length ? rowWidth(right) + 2 : 0);
459
+ if (flow?.verdicts) {
460
+ const before = segments.reduce((sum, part) => sum + part.width + textWidth(part.joiner), 0);
461
+ const forms = [Infinity, 3, 0].map((cut) => segment("verdicts", 2, verdictSpans(flow.verdicts, glyphs, cut), " · "));
462
+ segments.push(forms.find((form) => before + form.width <= room) ?? forms.at(-1));
463
+ }
464
+ (flow?.items ?? []).forEach((item, index) => segments.push(segment(`item-${index}`, 3 + index, [span(item, { fg: PALETTE.white })], " · ")));
465
+ let left = joinSegments(fitJoined(segments, room), PALETTE.faint);
466
+ if (flow?.next) {
467
+ const gap = left.length ? [span(" · ", { fg: PALETTE.faint })] : [];
468
+ const label = [span("next ", { fg: PALETTE.dim }), span(`${glyphs.next} `, { fg: accent })];
469
+ const avail = room - rowWidth(left) - rowWidth(gap) - rowWidth(label);
470
+ if (avail >= 6) left = [...left, ...gap, ...label, span(truncateToWidth(flow.next, avail), { fg: PALETTE.white })];
471
+ }
472
+ if (!left.length && !right.length) return fitRow([], inner);
473
+ const body = [...lead, ...left];
474
+ if (!right.length) return fitRow(body, inner);
475
+ return fitRow([...body, span(" ".repeat(Math.max(1, inner - rowWidth(body) - rowWidth(right)))), ...right], inner);
476
+ };
477
+
295
478
  const CHIP_TONE = Object.freeze({
296
479
  current: (accent) => ({ fill: accent, text: PILL.ink }),
297
480
  recent: () => ({ fill: PILL.muted, text: PILL.mutedText }),
@@ -320,7 +503,7 @@ const mutedPill = (text, glyphs) => pill([span(text, { fg: PALETTE.dim, bg: PILL
320
503
  const chipsRow = (model, sample, { inner, glyphs, accent }) => {
321
504
  const chips = model.skills;
322
505
  if (chips.length === 0) {
323
- if (!model.idle && model.next) return fitRow([span("▸ ", { fg: accent }), span(truncateToWidth(model.next, inner - 2), { fg: PALETTE.white })], inner);
506
+ if (!model.idle && model.next && !model.workflow?.next) return fitRow([span("▸ ", { fg: accent }), span(truncateToWidth(model.next, inner - 2), { fg: PALETTE.white })], inner);
324
507
  if (!model.idle) return fitRow([span("no LitFamily skill yet in this request", { fg: PALETTE.dim })], inner);
325
508
  const hints = [
326
509
  [span("no LitFamily skill yet · type ", { fg: PALETTE.dim }), span("lit <task>", { fg: accent }), span(" to start one", { fg: PALETTE.dim })],
@@ -416,9 +599,10 @@ const edgeColor = (model, sample, accent) => {
416
599
  };
417
600
 
418
601
  /**
419
- * The card for one frame: `{ mode: "card", border, width, rows }`, always four rows (top edge,
420
- * phase row, chips row, bottom edge), each exactly `width` cells: the terminal's columns up to
421
- * CARD_MAX. `accent` is the status line's accent colour (settings.mjs accentFromSettings).
602
+ * The card for one frame: `{ mode: "card", border, width, rows }`, always five rows (top edge,
603
+ * workflow map or phase track, detail line, chips row, bottom edge), each exactly `width` cells:
604
+ * the terminal's columns up to CARD_MAX. `accent` is the status line's accent colour
605
+ * (settings.mjs accentFromSettings).
422
606
  */
423
607
  export const composeBand = (model, sample, { columns, glyphs: glyphMode = "basic", accent = PALETTE.accent }) => {
424
608
  const glyphs = glyphSet(glyphMode);
@@ -432,7 +616,13 @@ export const composeBand = (model, sample, { columns, glyphs: glyphMode = "basic
432
616
  mode: "card",
433
617
  border: edge,
434
618
  width,
435
- rows: [topEdge(model, sample, options), side(phaseRow(model, sample, options)), side(middle), bottomEdge(model, sample, options)],
619
+ rows: [
620
+ topEdge(model, sample, options),
621
+ side(model.workflow ? mapRow(model, sample, options) : phaseRow(model, sample, options)),
622
+ side(detailRow(model, sample, options)),
623
+ side(middle),
624
+ bottomEdge(model, sample, options),
625
+ ],
436
626
  };
437
627
  };
438
628
 
@@ -30,6 +30,8 @@ export const TIMINGS = Object.freeze({
30
30
  breath: 2400,
31
31
  sweep: 1800,
32
32
  comet: 2600,
33
+ flow: 1400,
34
+ spin: 120,
33
35
  ambientFrame: 66,
34
36
  });
35
37
 
@@ -1,12 +1,15 @@
1
- // The LIT band's three userConfig options. The manifest declares them as plain strings without an
1
+ // The LIT band's four userConfig options. The manifest declares them as plain strings without an
2
2
  // `options` list, because Claude Code 2.1.270 and older refuse a whole plugin whose userConfig has
3
3
  // one; the values are checked here instead, and anything unknown falls back to the default (the
4
- // first choice).
4
+ // first choice). `notify` raises a notification through the person's own channel when LIT work
5
+ // completes or a LIT turn longer than three minutes ends; `finish+sound` adds a sound only where
6
+ // the plugin ships one, and none ships yet, so it notifies like `finish`.
5
7
 
6
8
  export const MOD_CHOICES = Object.freeze({
7
9
  motion: ["full", "subtle", "off"],
8
10
  glyphs: ["auto", "nerd", "basic"],
9
11
  band: ["always", "auto", "off"],
12
+ notify: ["off", "finish", "finish+sound"],
10
13
  });
11
14
 
12
15
  const pick = (value, choices) => {
@@ -18,6 +21,7 @@ export const normalizeModSettings = (options) => ({
18
21
  motion: pick(options?.motion, MOD_CHOICES.motion),
19
22
  glyphs: pick(options?.glyphs, MOD_CHOICES.glyphs),
20
23
  band: pick(options?.band, MOD_CHOICES.band),
24
+ notify: pick(options?.notify, MOD_CHOICES.notify),
21
25
  });
22
26
 
23
27
  // The glyph set one surface draws. Private-use Nerd Font glyphs only reach the terminal, which