@litfamily/litclaude 1.0.24 → 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.
- package/CHANGELOG.md +18 -0
- package/README.md +8 -8
- package/README_ko-KR.md +8 -8
- package/bin/litclaude-ai.js +98 -2
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/hooks.md +89 -28
- package/package.json +1 -1
- package/plugins/litclaude/.claude-plugin/plugin.json +8 -2
- package/plugins/litclaude/bin/litclaude-hook.js +31 -0
- package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
- package/plugins/litclaude/hooks/hooks.json +1 -1
- package/plugins/litclaude/hooks/lit-band-client.mjs +20 -13
- package/plugins/litclaude/hooks/lit-mod.mjs +87 -25
- package/plugins/litclaude/lib/mod/frame.mjs +262 -33
- package/plugins/litclaude/lib/mod/motion.mjs +13 -5
- package/plugins/litclaude/lib/mod/settings.mjs +10 -6
- package/plugins/litclaude/lib/mod/state.mjs +84 -0
- package/plugins/litclaude/lib/mod/svg-card.mjs +413 -0
- package/plugins/litclaude/lib/mod/timeline.mjs +46 -3
- package/plugins/litclaude/lib/mod/view.mjs +21 -7
- package/plugins/litclaude/lib/mod/workflow.mjs +239 -0
- package/plugins/litclaude/lib/start-work-lifecycle.mjs +126 -1
- package/plugins/litclaude/lib/workflow-stage.mjs +349 -0
- package/plugins/litclaude/skills/lit-crucible/SKILL.md +4 -0
- package/plugins/litclaude/skills/lit-loop/SKILL.md +8 -0
- package/plugins/litclaude/skills/review-work/SKILL.md +10 -0
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// The LIT band's surface module: the `Client` element lit-mod.mjs draws above the prompt. It runs on
|
|
2
2
|
// the drawing thread with no mods API, keeps the band's timeline in its local state, and animates on
|
|
3
|
-
// the surface's own frame clock
|
|
4
|
-
//
|
|
3
|
+
// the surface's own frame clock: fast while a transition plays, slower while the ambient loop says
|
|
4
|
+
// LitClaude is at work, and not at all once the card is idle and settled, when it stays still until
|
|
5
|
+
// the hooks module hands it a new model.
|
|
5
6
|
|
|
6
7
|
import { composeBand } from "../lib/mod/frame.mjs";
|
|
7
8
|
import { TIMINGS, motionProfile } from "../lib/mod/motion.mjs";
|
|
8
9
|
import { renderBand } from "../lib/mod/render.mjs";
|
|
9
|
-
import { advanceTimeline, createTimeline, sampleTimeline, timelineBusy } from "../lib/mod/timeline.mjs";
|
|
10
|
+
import { advanceTimeline, ambientActive, createTimeline, sampleTimeline, timelineBusy } from "../lib/mod/timeline.mjs";
|
|
10
11
|
|
|
11
12
|
// The timeline lives in one holder object that the frame timer and every draw share. The timer
|
|
12
13
|
// never reads `surface.state` back, so a draw that folded in a new model is never overwritten by a
|
|
@@ -17,7 +18,7 @@ export default function LitBand(props, surface) {
|
|
|
17
18
|
const empty = () => surface.elements.Box({ children: [] });
|
|
18
19
|
if (!model) return empty();
|
|
19
20
|
|
|
20
|
-
const holder = surface.state?.live ?? { now: 0, model: null, timeline: null, stop: null };
|
|
21
|
+
const holder = surface.state?.live ?? { now: 0, model: null, timeline: null, stop: null, interval: null };
|
|
21
22
|
if (holder.timeline === null) {
|
|
22
23
|
holder.timeline = createTimeline(model, 0, profile);
|
|
23
24
|
holder.model = model;
|
|
@@ -26,19 +27,25 @@ export default function LitBand(props, surface) {
|
|
|
26
27
|
holder.model = model;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
holder.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
const interval = () => (timelineBusy(holder.timeline, holder.now, profile) ? TIMINGS.frame
|
|
31
|
+
: ambientActive(holder.timeline, profile) ? TIMINGS.ambientFrame : null);
|
|
32
|
+
const schedule = () => {
|
|
33
|
+
const wanted = interval();
|
|
34
|
+
if (wanted === holder.interval) return;
|
|
35
|
+
holder.stop?.();
|
|
36
|
+
holder.stop = null;
|
|
37
|
+
holder.interval = wanted;
|
|
38
|
+
if (wanted === null) return;
|
|
39
|
+
holder.stop = surface.every(wanted, () => {
|
|
40
|
+
holder.now += wanted;
|
|
41
|
+
schedule();
|
|
36
42
|
surface.setState({ live: holder, now: holder.now });
|
|
37
43
|
});
|
|
38
|
-
}
|
|
44
|
+
};
|
|
45
|
+
schedule();
|
|
39
46
|
if (surface.state?.live !== holder) surface.setState({ live: holder, now: holder.now });
|
|
40
47
|
|
|
41
|
-
if (!model.visible &&
|
|
48
|
+
if (!model.visible && interval() === null) return empty();
|
|
42
49
|
const frame = composeBand(model, sampleTimeline(holder.timeline, holder.now, profile), { columns: props.columns, glyphs: props.glyphs, accent: props.accent });
|
|
43
50
|
return renderBand(surface.elements, frame);
|
|
44
51
|
}
|
|
@@ -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)
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 }));
|