@litfamily/litclaude 1.0.21 → 1.0.22

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 (49) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +34 -92
  3. package/README_ko-KR.md +42 -95
  4. package/bin/icon-font.mjs +264 -0
  5. package/bin/litclaude-ai.js +155 -18
  6. package/docs/assets/readme/badge-version.svg +1 -1
  7. package/docs/hooks.md +100 -5
  8. package/docs/privacy.md +2 -2
  9. package/package.json +13 -3
  10. package/plugins/litclaude/.claude-plugin/plugin.json +22 -2
  11. package/plugins/litclaude/bin/litclaude-hook.js +13 -7
  12. package/plugins/litclaude/bin/litclaude-hud.js +36 -19
  13. package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
  14. package/plugins/litclaude/hooks/hooks.json +1 -0
  15. package/plugins/litclaude/hooks/lit-band-client.mjs +44 -0
  16. package/plugins/litclaude/hooks/lit-mod.mjs +269 -0
  17. package/plugins/litclaude/lib/auto-handoff.mjs +2 -0
  18. package/plugins/litclaude/lib/automatic-update-refresh.mjs +4 -0
  19. package/plugins/litclaude/lib/automatic-update.mjs +148 -1
  20. package/plugins/litclaude/lib/cache-measurement.mjs +62 -1
  21. package/plugins/litclaude/lib/canonical-skill-resources.mjs +9 -9
  22. package/plugins/litclaude/lib/hud-accent-palette.mjs +2 -2
  23. package/plugins/litclaude/lib/interface-probe-gate.mjs +2 -1
  24. package/plugins/litclaude/lib/jev-skill-hint.mjs +2 -0
  25. package/plugins/litclaude/lib/lit-plan-persistence.mjs +2 -1
  26. package/plugins/litclaude/lib/litgoal/autoloop.mjs +2 -0
  27. package/plugins/litclaude/lib/litgoal/cli.mjs +2 -0
  28. package/plugins/litclaude/lib/litgoal/ledger.mjs +3 -0
  29. package/plugins/litclaude/lib/litgoal/state.mjs +4 -0
  30. package/plugins/litclaude/lib/mod/frame.mjs +378 -0
  31. package/plugins/litclaude/lib/mod/layout.mjs +76 -0
  32. package/plugins/litclaude/lib/mod/motion.mjs +101 -0
  33. package/plugins/litclaude/lib/mod/render.mjs +24 -0
  34. package/plugins/litclaude/lib/mod/settings.mjs +57 -0
  35. package/plugins/litclaude/lib/mod/state.mjs +374 -0
  36. package/plugins/litclaude/lib/mod/timeline.mjs +124 -0
  37. package/plugins/litclaude/lib/mod/view.mjs +232 -0
  38. package/plugins/litclaude/lib/motion-render-gate.mjs +2 -1
  39. package/plugins/litclaude/lib/office-runtime.mjs +6 -0
  40. package/plugins/litclaude/lib/project-state-root.mjs +34 -2
  41. package/plugins/litclaude/lib/rules/engine.mjs +2 -1
  42. package/plugins/litclaude/lib/rules/format.mjs +24 -3
  43. package/plugins/litclaude/lib/rules/session-state.mjs +2 -1
  44. package/plugins/litclaude/lib/secure-path-read.mjs +46 -0
  45. package/plugins/litclaude/lib/start-work-lifecycle.mjs +4 -0
  46. package/plugins/litclaude/lib/wikify-knowledge.mjs +2 -1
  47. package/plugins/litclaude/scripts/scaffold-plan.mjs +2 -1
  48. package/plugins/litclaude/skills/lit-team/scripts/team.mjs +2 -1
  49. package/plugins/litclaude/skills/lit-typographic-motion/scripts/motion-runtime.mjs +7 -0
@@ -0,0 +1,57 @@
1
+ // The LIT band's three userConfig options. The manifest declares them as plain strings without an
2
+ // `options` list, because Claude Code 2.1.270 and older refuse a whole plugin whose userConfig has
3
+ // one; the values are checked here instead, and anything unknown falls back to the default.
4
+
5
+ const CHOICES = Object.freeze({
6
+ motion: ["full", "subtle", "off"],
7
+ glyphs: ["auto", "nerd", "basic"],
8
+ band: ["auto", "always", "off"],
9
+ });
10
+
11
+ const pick = (value, choices) => {
12
+ const normalized = String(value ?? "").trim().toLowerCase();
13
+ return choices.includes(normalized) ? normalized : choices[0];
14
+ };
15
+
16
+ export const normalizeModSettings = (options) => ({
17
+ motion: pick(options?.motion, CHOICES.motion),
18
+ glyphs: pick(options?.glyphs, CHOICES.glyphs),
19
+ band: pick(options?.band, CHOICES.band),
20
+ });
21
+
22
+ // The glyph set one surface draws. Private-use Nerd Font glyphs only reach the terminal, which
23
+ // draws with the person's own fonts; `auto` uses them there when the icon font is installed. The
24
+ // desktop app and the other surfaces always get the basic set.
25
+ export const resolveGlyphs = (preference, surface, iconFontPresent) => {
26
+ if (surface !== "terminal") return "basic";
27
+ if (preference === "auto") return iconFontPresent ? "nerd" : "basic";
28
+ return preference === "nerd" ? "nerd" : "basic";
29
+ };
30
+
31
+ const BASIC = Object.freeze({
32
+ capLeft: "",
33
+ capRight: "",
34
+ done: "✓",
35
+ now: "●",
36
+ todo: "○",
37
+ warn: "⚠",
38
+ link: "─",
39
+ rail: "━",
40
+ next: "▸",
41
+ lane: "●",
42
+ idleLane: "◌",
43
+ loop: "⟳",
44
+ fail: "✗",
45
+ flag: "⚑",
46
+ });
47
+
48
+ const NERD = Object.freeze({
49
+ ...BASIC,
50
+ capLeft: "",
51
+ capRight: "",
52
+ done: "",
53
+ todo: "",
54
+ warn: "",
55
+ });
56
+
57
+ export const glyphSet = (glyphs) => (glyphs === "nerd" ? NERD : BASIC);
@@ -0,0 +1,374 @@
1
+ // Read-only view of LitClaude's own state for the LIT band. Nothing here writes, spawns, fetches or
2
+ // follows a link: every read goes through an injected `io` ({ stat, read }) that the mod backs with
3
+ // `$.fs.stat(path, { resolve: true })` and `$.fs.read`, and the tests back with a map. A file is read
4
+ // only when it is a regular file, under its size cap, not itself a link, and lands exactly where its
5
+ // spelled path says once every link is resolved, so a symlinked `.litclaude` or state file is skipped.
6
+ //
7
+ // The two roots mirror the hook: `.litclaude` sits at the outermost Git root (project-state-root.mjs),
8
+ // while boulder.json is found the way `lifecycleRootFor` in bin/litclaude-hook.js finds it, walking up
9
+ // from the working directory and across a linked Git worktree to its main checkout.
10
+
11
+ export const STATE_CAPS = Object.freeze({
12
+ boulder: 2 * 1024 * 1024,
13
+ goals: 512 * 1024,
14
+ ledger: 512 * 1024,
15
+ small: 64 * 1024,
16
+ gitMarker: 4096,
17
+ });
18
+
19
+ // Mirrors PROJECT_MARKERS in rules/constants.mjs, the fallback when no Git root exists.
20
+ const PROJECT_MARKERS = Object.freeze([".git", "pnpm-workspace.yaml", "package.json", "pyproject.toml", "Cargo.toml", "go.mod", ".venv"]);
21
+ export const AUTOLOOP_LIMITS = Object.freeze({ blocks: 8, ms: 30 * 60 * 1000 });
22
+ export const GATE_LIMITS = Object.freeze({ litPlan: 2, probe: 2, motion: 2 });
23
+
24
+ const splitPath = (path) => String(path).replaceAll("\\", "/").split("/").filter((part, index) => part !== "" || index === 0);
25
+
26
+ export const normalizePath = (path) => {
27
+ const parts = [];
28
+ const raw = String(path).replaceAll("\\", "/");
29
+ const absolute = raw.startsWith("/");
30
+ const drive = /^[A-Za-z]:/u.exec(raw)?.[0] ?? "";
31
+ for (const part of raw.slice(drive.length).split("/")) {
32
+ if (part === "" || part === ".") continue;
33
+ if (part === "..") parts.pop();
34
+ else parts.push(part);
35
+ }
36
+ return `${drive}${absolute || drive ? "/" : ""}${parts.join("/")}`;
37
+ };
38
+
39
+ export const joinPath = (...parts) => normalizePath(parts.filter((part) => part !== "").join("/"));
40
+
41
+ export const parentOf = (path) => {
42
+ const normalized = normalizePath(path);
43
+ const cut = normalized.lastIndexOf("/");
44
+ if (cut <= 0) return normalized.slice(0, cut + 1) || normalized;
45
+ const parent = normalized.slice(0, cut);
46
+ return /^[A-Za-z]:$/u.test(parent) ? `${parent}/` : parent;
47
+ };
48
+
49
+ const isAbsolute = (path) => /^(?:[A-Za-z]:)?[\\/]/u.test(String(path));
50
+ export const resolvePath = (base, path) => (isAbsolute(path) ? normalizePath(path) : joinPath(base, path));
51
+
52
+ const ancestors = (start) => {
53
+ const list = [];
54
+ let current = normalizePath(start);
55
+ for (;;) {
56
+ list.push(current);
57
+ const parent = parentOf(current);
58
+ if (parent === current) return list;
59
+ current = parent;
60
+ }
61
+ };
62
+
63
+ const statOf = async (io, path) => {
64
+ try {
65
+ return (await io.stat(path)) ?? null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ };
70
+
71
+ export const resolveStateRoot = async (io, cwd) => {
72
+ const chain = ancestors(cwd);
73
+ let outermostGit = null;
74
+ for (const directory of chain) if (await statOf(io, joinPath(directory, ".git"))) outermostGit = directory;
75
+ if (outermostGit) return outermostGit;
76
+ for (const directory of chain) {
77
+ for (const marker of PROJECT_MARKERS) if (await statOf(io, joinPath(directory, marker))) return directory;
78
+ }
79
+ return normalizePath(cwd);
80
+ };
81
+
82
+ export const resolveLifecycleRoot = async (io, cwd, stateRoot) => {
83
+ for (const directory of ancestors(cwd)) {
84
+ if (await statOf(io, joinPath(directory, ".litclaude", "boulder.json"))) return directory;
85
+ const marker = joinPath(directory, ".git");
86
+ const stat = await statOf(io, marker);
87
+ if (stat?.kind === "file" && !stat.isLink && stat.size <= STATE_CAPS.gitMarker) {
88
+ let text = "";
89
+ try {
90
+ text = await io.read(marker);
91
+ } catch {
92
+ text = "";
93
+ }
94
+ const match = /^gitdir:\s*(.+?)\s*$/mu.exec(text);
95
+ if (match) {
96
+ const worktreesDir = parentOf(resolvePath(directory, match[1]));
97
+ if (splitPath(worktreesDir).at(-1) === "worktrees") {
98
+ const mainRoot = parentOf(parentOf(worktreesDir));
99
+ if (await statOf(io, joinPath(mainRoot, ".litclaude", "boulder.json"))) return mainRoot;
100
+ }
101
+ }
102
+ }
103
+ }
104
+ return stateRoot;
105
+ };
106
+
107
+ // One guarded read. `root` is the directory the file must stay under; the comparison is made on
108
+ // resolved paths so a link anywhere between the root and the file shows up as a mismatch.
109
+ export const readGuarded = async (io, root, relative, cap) => {
110
+ const path = joinPath(root, relative);
111
+ const stat = await statOf(io, path);
112
+ if (!stat || stat.isLink || stat.kind !== "file" || !(stat.size <= cap)) return null;
113
+ if (stat.realPath !== undefined) {
114
+ const rootStat = await statOf(io, root);
115
+ const realRoot = rootStat?.realPath;
116
+ if (realRoot === undefined || normalizePath(stat.realPath) !== joinPath(realRoot, relative)) return null;
117
+ }
118
+ try {
119
+ const text = await io.read(path);
120
+ return typeof text === "string" && text.length <= cap ? text : null;
121
+ } catch {
122
+ return null;
123
+ }
124
+ };
125
+
126
+ export const readGuardedJson = async (io, root, relative, cap) => {
127
+ const text = await readGuarded(io, root, relative, cap);
128
+ if (text === null) return null;
129
+ try {
130
+ const value = JSON.parse(text);
131
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
132
+ } catch {
133
+ return null;
134
+ }
135
+ };
136
+
137
+ const str = (value, max = 400) => (typeof value === "string" && value.length > 0 ? value.slice(0, max) : null);
138
+ const int = (value) => (Number.isInteger(value) && value >= 0 ? value : 0);
139
+ const num = (value) => (Number.isFinite(value) ? value : null);
140
+ const time = (value) => {
141
+ const parsed = typeof value === "string" ? Date.parse(value) : Number.NaN;
142
+ return Number.isFinite(parsed) ? parsed : null;
143
+ };
144
+
145
+ // Per-session file names, spelled as hud-ignition.mjs (ignition) and auto-handoff.mjs (session) do.
146
+ export const ignitionFileName = (sessionId) => {
147
+ const raw = typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : "unknown-session";
148
+ return `${raw.replace(/[^A-Za-z0-9._-]/gu, "_").replace(/^\.+/u, "_")}.json`;
149
+ };
150
+ const handoffSessionId = (sessionId) => (typeof sessionId === "string" ? sessionId.replace(/[^A-Za-z0-9_-]/gu, "").slice(0, 64) : "");
151
+
152
+ export const ignitionRoot = (env) => {
153
+ const override = env?.LITCLAUDE_HUD_STATE_ROOT?.trim();
154
+ if (override) return normalizePath(override);
155
+ return joinPath(env?.TMPDIR?.trim() || "/tmp", "litclaude-hud");
156
+ };
157
+
158
+ export const parseIgnition = (record, sessionId) => {
159
+ if (!record || (record.sessionId !== null && record.sessionId !== sessionId)) return null;
160
+ const discipline = str(record.discipline, 80);
161
+ return discipline ? { discipline, at: time(record.at) } : null;
162
+ };
163
+
164
+ const workBelongs = (work, sessionId) =>
165
+ work.root_session_id === sessionId
166
+ || work.active_session_id === sessionId
167
+ || (Array.isArray(work.session_ids) && work.session_ids.includes(sessionId));
168
+
169
+ const laneList = (lanes) => Object.values(lanes && typeof lanes === "object" ? lanes : {})
170
+ .filter((lane) => lane && typeof lane === "object")
171
+ .map((lane) => ({
172
+ id: str(lane.agent_id, 120) ?? "lane",
173
+ type: str(lane.agent_type, 80) ?? "agent",
174
+ status: str(lane.status, 40) ?? "unknown",
175
+ startedAt: time(lane.started_at),
176
+ }))
177
+ .slice(0, 32);
178
+
179
+ export const parseBoulder = (state, sessionId) => {
180
+ if (!state || !state.works || typeof state.works !== "object") return { work: null, completed: [] };
181
+ const completed = Object.values(state.works)
182
+ .filter((work) => work && work.status === "completed" && workBelongs(work, sessionId))
183
+ .map((work) => str(work.work_id, 120))
184
+ .filter(Boolean);
185
+ const active = state.active_work_id ? state.works[state.active_work_id] : null;
186
+ if (!active || !["active", "paused"].includes(active.status) || !workBelongs(active, sessionId)) return { work: null, completed };
187
+ const progress = active.progress && typeof active.progress === "object" ? active.progress : {};
188
+ const total = int(progress.total);
189
+ return {
190
+ work: {
191
+ id: str(active.work_id, 120) ?? String(state.active_work_id),
192
+ status: active.status,
193
+ planName: str(active.plan_name, 200),
194
+ total,
195
+ checked: Math.min(int(progress.checked), total),
196
+ nextTask: str(progress.next_task, 400),
197
+ lanes: laneList(active.lanes),
198
+ },
199
+ completed,
200
+ };
201
+ };
202
+
203
+ const CRITERION_STATUSES = new Set(["pass", "fail", "blocked", "pending"]);
204
+
205
+ export const parseGoals = (state, sessionId) => {
206
+ if (!state || typeof state.status !== "string") return null;
207
+ if (typeof state.sessionId === "string" && state.sessionId !== sessionId) return null;
208
+ const criteria = (Array.isArray(state.criteria) ? state.criteria : []).slice(0, 40).map((criterion, index) => {
209
+ const evidence = Array.isArray(criterion?.evidence) ? criterion.evidence : [];
210
+ const status = CRITERION_STATUSES.has(criterion?.status) ? criterion.status : "pending";
211
+ return {
212
+ id: str(criterion?.id, 80) ?? `criterion-${index + 1}`,
213
+ description: str(criterion?.description, 300) ?? "",
214
+ status,
215
+ attempts: evidence.length,
216
+ lastAt: time(evidence.at(-1)?.recordedAt),
217
+ };
218
+ });
219
+ return {
220
+ objective: str(state.objective, 400) ?? "",
221
+ status: state.status,
222
+ autoloop: state.autoloop === true,
223
+ criteria,
224
+ blockers: (Array.isArray(state.blockers) ? state.blockers : []).slice(0, 10)
225
+ .map((blocker) => str(typeof blocker === "string" ? blocker : blocker?.note ?? blocker?.description, 300))
226
+ .filter(Boolean),
227
+ lastCheckpoint: str(Array.isArray(state.checkpoints) ? state.checkpoints.at(-1)?.status : null, 40),
228
+ createdAt: time(state.createdAt),
229
+ };
230
+ };
231
+
232
+ export const parseAutoloop = (state) => {
233
+ if (!state || !Number.isInteger(state.blockCount) || state.blockCount < 0 || !Number.isFinite(state.firstBlockAt)) return null;
234
+ return { blockCount: state.blockCount, firstBlockAt: state.firstBlockAt };
235
+ };
236
+
237
+ const forSession = (state, sessionId) => (state && state.sessionId === sessionId ? state : null);
238
+
239
+ export const parseLitPlanTurn = (state, sessionId) => {
240
+ const turn = forSession(state, sessionId);
241
+ return turn ? { blockCount: int(turn.blockCount), warned: turn.warned === true, promptAt: num(turn.promptAt) } : null;
242
+ };
243
+
244
+ export const parseProbeTurn = (state, sessionId) => {
245
+ const turn = forSession(state, sessionId);
246
+ if (!turn) return null;
247
+ return {
248
+ editAt: num(turn.editAt),
249
+ probeAt: num(turn.probeAt),
250
+ issue: str(turn.issue, 160),
251
+ issueAt: num(turn.issueAt),
252
+ paths: (Array.isArray(turn.paths) ? turn.paths : []).map((path) => str(path, 200)).filter(Boolean).slice(0, 5),
253
+ blockCount: int(turn.blockCount),
254
+ };
255
+ };
256
+
257
+ export const parseMotionTurn = (state, sessionId) => {
258
+ const turn = forSession(state, sessionId);
259
+ return turn ? { blockCount: int(turn.blockCount), runs: Array.isArray(turn.runs) ? turn.runs.length : 0, notified: turn.notified === true } : null;
260
+ };
261
+
262
+ const percentOf = (raw) => {
263
+ const text = typeof raw === "number" ? String(raw) : raw;
264
+ if (typeof text !== "string" || !/^(?:[1-9]\d?)$/u.test(text.trim())) return null;
265
+ return Number(text.trim());
266
+ };
267
+
268
+ // The environment wins over the saved choice, as resolveAutoHandoff in auto-handoff.mjs decides.
269
+ export const parseAutoHandoff = (settings, session, env) => {
270
+ const flag = env?.LITCLAUDE_AUTO_HANDOFF;
271
+ const enabled = flag !== undefined ? flag === "1" : settings?.enabled === true;
272
+ const percent = percentOf(env?.LITCLAUDE_AUTO_HANDOFF_PERCENT !== undefined ? env.LITCLAUDE_AUTO_HANDOFF_PERCENT : settings?.percent ?? null);
273
+ if (!enabled || percent === null) return null;
274
+ return {
275
+ percent,
276
+ firedAt: num(session?.firedAt),
277
+ firedAtPercent: num(session?.firedAtPercent),
278
+ pending: session?.pending === true,
279
+ };
280
+ };
281
+
282
+ export const readLitSnapshot = async (io, { cwd, sessionId, env = {} }) => {
283
+ const stateRoot = await resolveStateRoot(io, cwd);
284
+ const lifecycleRoot = await resolveLifecycleRoot(io, cwd, stateRoot);
285
+ const state = (relative, cap = STATE_CAPS.small) => readGuardedJson(io, stateRoot, joinPath(".litclaude", relative), cap);
286
+ const handoffId = handoffSessionId(sessionId);
287
+ const [ignition, boulder, goals, autoloop, litPlan, probe, motion, handoffSettings, handoffSession] = await Promise.all([
288
+ readGuardedJson(io, ignitionRoot(env), ignitionFileName(sessionId), STATE_CAPS.small),
289
+ readGuardedJson(io, lifecycleRoot, joinPath(".litclaude", "boulder.json"), STATE_CAPS.boulder),
290
+ state("litgoal/goals.json", STATE_CAPS.goals),
291
+ state("litgoal/autoloop.json"),
292
+ state("lit-plan/turn.json"),
293
+ state("interface-probe/turn.json"),
294
+ state("motion/turn.json"),
295
+ state("auto-handoff/settings.json"),
296
+ handoffId ? state(`auto-handoff/session-${handoffId}.json`) : null,
297
+ ]);
298
+ const { work, completed } = parseBoulder(boulder, sessionId);
299
+ return {
300
+ stateRoot,
301
+ lifecycleRoot,
302
+ ignition: parseIgnition(ignition, sessionId),
303
+ work,
304
+ completedWorkIds: completed,
305
+ goal: parseGoals(goals, sessionId),
306
+ autoloop: parseAutoloop(autoloop),
307
+ litPlan: parseLitPlanTurn(litPlan, sessionId),
308
+ probe: parseProbeTurn(probe, sessionId),
309
+ motion: parseMotionTurn(motion, sessionId),
310
+ handoff: parseAutoHandoff(handoffSettings, handoffSession, env),
311
+ };
312
+ };
313
+
314
+ // The icon font the installer's opt-in step writes (bin/icon-font.mjs), or the proportional
315
+ // symbols font a person installed some other way, in each per-user font folder.
316
+ const ICON_FONT_NAMES = Object.freeze(["SymbolsNerdFontMono-Regular.ttf", "SymbolsNerdFont-Regular.ttf"]);
317
+
318
+ export const iconFontCandidates = (env) => {
319
+ const home = env?.HOME?.trim();
320
+ const folders = [];
321
+ if (home) folders.push(joinPath(home, "Library", "Fonts"));
322
+ const data = env?.XDG_DATA_HOME?.trim() || (home ? joinPath(home, ".local", "share") : "");
323
+ if (data) folders.push(joinPath(data, "fonts"));
324
+ const local = env?.LOCALAPPDATA?.trim();
325
+ if (local) folders.push(joinPath(local, "Microsoft", "Windows", "Fonts"));
326
+ return folders.flatMap((folder) => ICON_FONT_NAMES.map((name) => joinPath(folder, name)));
327
+ };
328
+
329
+ /**
330
+ * Whether the icon font is installed, by stat alone (the font is never read). `note` says what
331
+ * the band decided and why, for the evidence pane; a folder that cannot be checked counts as no
332
+ * font, so the band falls back to basic glyphs rather than drawing boxes.
333
+ */
334
+ export const detectIconFont = async (io, env) => {
335
+ const candidates = iconFontCandidates(env);
336
+ if (candidates.length === 0) return { present: false, path: null, note: "basic: no home folder to look in for the icon font" };
337
+ let unreadable = false;
338
+ for (const path of candidates) {
339
+ try {
340
+ const stat = await io.stat(path);
341
+ if (stat?.kind === "file") return { present: true, path, note: `nerd: Symbols Nerd Font found at ${path}` };
342
+ } catch (error) {
343
+ if (!/ENOENT|no such file|not found/iu.test(String(error?.message ?? error))) unreadable = true;
344
+ }
345
+ }
346
+ return unreadable
347
+ ? { present: false, path: null, note: "basic: the font folders could not be checked from the band" }
348
+ : { present: false, path: null, note: "basic: Symbols Nerd Font not found (litclaude-ai install --icon-font)" };
349
+ };
350
+
351
+ // The litgoal ledger's newest entries for the evidence pane, newest first. Lines that are not JSON
352
+ // objects are skipped; the file is read only under its cap.
353
+ export const readLedgerTail = async (io, stateRoot, limit = 8) => {
354
+ const text = await readGuarded(io, stateRoot, ".litclaude/litgoal/ledger.jsonl", STATE_CAPS.ledger);
355
+ if (text === null) return [];
356
+ const entries = [];
357
+ for (const line of text.split("\n").reverse()) {
358
+ if (entries.length >= limit) break;
359
+ if (!line.trim()) continue;
360
+ try {
361
+ const entry = JSON.parse(line);
362
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
363
+ entries.push({
364
+ event: str(entry.event, 60) ?? "event",
365
+ at: time(entry.timestamp),
366
+ detail: str(entry.criterionId ?? entry.note ?? entry.objective ?? entry.status, 120) ?? "",
367
+ status: str(entry.status, 40),
368
+ });
369
+ } catch {
370
+ // A torn final line from a concurrent append; the next refresh reads it whole.
371
+ }
372
+ }
373
+ return entries;
374
+ };
@@ -0,0 +1,124 @@
1
+ // The band's timeline: which animations are running and since when. The surface module keeps one
2
+ // timeline in its local state, folds every new model into it (advanceTimeline), samples it once per
3
+ // frame (sampleTimeline) and stops its frame timer as soon as nothing moves (timelineBusy). Times are
4
+ // the surface module's own frame clock in milliseconds; nothing here reads a wall clock.
5
+
6
+ import { EASE, TIMINGS, isSettled, phase, retarget, sample, still } from "./motion.mjs";
7
+
8
+ const PHASES = ["plan", "work", "review"];
9
+ const fractionOf = (model) => (model.progress && model.progress.total > 0 ? Math.min(1, model.progress.done / model.progress.total) : 0);
10
+
11
+ const entryEnd = (profile) => 3 * profile.duration(TIMINGS.stagger) + profile.duration(TIMINGS.entry);
12
+
13
+ export const createTimeline = (model, now, profile) => {
14
+ const restAt = now + entryEnd(profile);
15
+ return {
16
+ enterAt: now,
17
+ exitAt: null,
18
+ fill: { from: 0, to: fractionOf(model), at: restAt, dur: profile.duration(TIMINGS.fill) },
19
+ done: model.progress?.done ?? null,
20
+ flashAt: null,
21
+ warn: still(model.ctxWarning ? 1 : 0),
22
+ phases: Object.fromEntries(PHASES.map((id) => [id, { from: model.phases[id], to: model.phases[id], at: now }])),
23
+ notice: model.notice ? { data: model.notice, at: restAt } : null,
24
+ leaving: null,
25
+ completion: model.completion ? { data: model.completion, at: now } : null,
26
+ };
27
+ };
28
+
29
+ export const advanceTimeline = (timeline, model, now, profile) => {
30
+ const next = { ...timeline };
31
+ if (!model.visible && timeline.exitAt === null) next.exitAt = now;
32
+ if (model.visible && timeline.exitAt !== null) return createTimeline(model, now, profile);
33
+ next.fill = retarget(timeline.fill, fractionOf(model), now, profile.duration(TIMINGS.fill));
34
+ const done = model.progress?.done ?? null;
35
+ if (done !== timeline.done) {
36
+ next.done = done;
37
+ if (timeline.done !== null && done !== null) next.flashAt = now;
38
+ }
39
+ next.warn = retarget(timeline.warn, model.ctxWarning ? 1 : 0, now, profile.duration(TIMINGS.crossfade));
40
+ next.phases = Object.fromEntries(PHASES.map((id) => {
41
+ const current = timeline.phases[id];
42
+ return [id, current.to === model.phases[id] ? current : { from: current.to, to: model.phases[id], at: now }];
43
+ }));
44
+ const incoming = model.notice?.id ?? null;
45
+ if (incoming !== (timeline.notice?.data.id ?? null)) {
46
+ next.leaving = timeline.notice ? { data: timeline.notice.data, at: now } : timeline.leaving;
47
+ next.notice = model.notice ? { data: model.notice, at: now } : null;
48
+ }
49
+ if ((model.completion?.id ?? null) !== (timeline.completion?.data.id ?? null)) {
50
+ next.completion = model.completion ? { data: model.completion, at: now } : null;
51
+ }
52
+ return next;
53
+ };
54
+
55
+ export const timelineBusy = (timeline, now, profile) => {
56
+ const d = profile.duration;
57
+ if (timeline.exitAt !== null) return now < timeline.exitAt + d(TIMINGS.exit);
58
+ if (now < timeline.enterAt + entryEnd(profile)) return true;
59
+ if (!isSettled(timeline.fill, now) || !isSettled(timeline.warn, now)) return true;
60
+ if (timeline.flashAt !== null && now < timeline.flashAt + d(TIMINGS.flash)) return true;
61
+ if (PHASES.some((id) => timeline.phases[id].from !== timeline.phases[id].to && now < timeline.phases[id].at + d(TIMINGS.crossfade))) return true;
62
+ if (timeline.notice && now < timeline.notice.at + d(TIMINGS.noticeIn)) return true;
63
+ if (timeline.leaving && now < timeline.leaving.at + d(TIMINGS.noticeOut)) return true;
64
+ if (timeline.completion && now < timeline.completion.at + d(TIMINGS.completion)) return true;
65
+ return false;
66
+ };
67
+
68
+ /**
69
+ * Everything one frame needs, as numbers: how far each pill has popped in (ease-out-back, so it may
70
+ * pass 1 briefly), the bar's fill, the count flash, the amber mix, each phase's colour crossfade,
71
+ * the notice row's offset in cells, and the completion shimmer and embers.
72
+ */
73
+ export const sampleTimeline = (timeline, now, profile) => {
74
+ const d = profile.duration;
75
+ const exit = timeline.exitAt === null ? 0 : EASE.inCubic(phase(now, timeline.exitAt, d(TIMINGS.exit)));
76
+ const pills = [0, 1, 2, 3].map((index) => {
77
+ const popped = profile.enterEase(phase(now, timeline.enterAt + index * d(TIMINGS.stagger), d(TIMINGS.entry)));
78
+ return Math.max(0, popped * (1 - exit));
79
+ });
80
+ const rest = exit > 0 ? 0 : phase(now, timeline.enterAt + entryEnd(profile), 1);
81
+ const flash = timeline.flashAt === null ? 0 : Math.sin(Math.PI * phase(now, timeline.flashAt, d(TIMINGS.flash)));
82
+ const phases = Object.fromEntries(PHASES.map((id) => {
83
+ const { from, to, at } = timeline.phases[id];
84
+ return [id, { from, to, mix: from === to ? 1 : EASE.inOutCubic(phase(now, at, d(TIMINGS.crossfade))) }];
85
+ }));
86
+ let notice = null;
87
+ if (timeline.notice) {
88
+ const p = phase(now, timeline.notice.at, d(TIMINGS.noticeIn));
89
+ notice = { data: timeline.notice.data, offset: Math.max(0, Math.round(14 * (1 - profile.noticeEase(p)))) };
90
+ } else if (timeline.leaving && now < timeline.leaving.at + d(TIMINGS.noticeOut)) {
91
+ notice = { data: timeline.leaving.data, offset: Math.round(40 * EASE.inCubic(phase(now, timeline.leaving.at, d(TIMINGS.noticeOut)))) };
92
+ }
93
+ let completion = null;
94
+ if (timeline.completion) {
95
+ const at = timeline.completion.at;
96
+ completion = {
97
+ data: timeline.completion.data,
98
+ shimmer: profile.shimmer && now >= at + d(50) && now < at + d(50) + d(TIMINGS.shimmer)
99
+ ? EASE.outCubic(phase(now, at + d(50), d(TIMINGS.shimmer))) * 1.3 - 0.15
100
+ : null,
101
+ embers: profile.embers ? phase(now, at, d(TIMINGS.embers)) : null,
102
+ title: EASE.outCubic(phase(now, at + d(30), d(200))),
103
+ detail: EASE.outCubic(phase(now, at + d(100), d(200))),
104
+ };
105
+ }
106
+ return {
107
+ pills,
108
+ rest,
109
+ exit,
110
+ fill: sample(timeline.fill, now),
111
+ fillGlow: isSettled(timeline.fill, now) ? 0 : Math.sin(Math.PI * phase(now, timeline.fill.at, timeline.fill.dur)),
112
+ flash,
113
+ warn: sample(timeline.warn, now),
114
+ phases,
115
+ notice,
116
+ completion,
117
+ };
118
+ };
119
+
120
+ /** The final frame of a model, for motion off, surfaces without frame clocks and tests. */
121
+ export const settledSample = (model) => {
122
+ const profile = { duration: () => 0, enterEase: EASE.linear, noticeEase: EASE.linear, shimmer: false, embers: false };
123
+ return sampleTimeline(createTimeline(model, 0, profile), 1, profile);
124
+ };