@litfamily/litclaude 1.0.21 → 1.0.23

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 (51) hide show
  1. package/CHANGELOG.md +18 -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 +131 -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 -22
  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 +314 -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/hud-ignition.mjs +7 -37
  24. package/plugins/litclaude/lib/interface-probe-gate.mjs +2 -1
  25. package/plugins/litclaude/lib/jev-skill-hint.mjs +2 -0
  26. package/plugins/litclaude/lib/lit-plan-persistence.mjs +2 -1
  27. package/plugins/litclaude/lib/litgoal/autoloop.mjs +2 -0
  28. package/plugins/litclaude/lib/litgoal/cli.mjs +2 -0
  29. package/plugins/litclaude/lib/litgoal/ledger.mjs +3 -0
  30. package/plugins/litclaude/lib/litgoal/state.mjs +4 -0
  31. package/plugins/litclaude/lib/mod/frame.mjs +454 -0
  32. package/plugins/litclaude/lib/mod/layout.mjs +53 -0
  33. package/plugins/litclaude/lib/mod/motion.mjs +101 -0
  34. package/plugins/litclaude/lib/mod/render.mjs +20 -0
  35. package/plugins/litclaude/lib/mod/settings.mjs +105 -0
  36. package/plugins/litclaude/lib/mod/skills.mjs +81 -0
  37. package/plugins/litclaude/lib/mod/state.mjs +411 -0
  38. package/plugins/litclaude/lib/mod/timeline.mjs +152 -0
  39. package/plugins/litclaude/lib/mod/view.mjs +255 -0
  40. package/plugins/litclaude/lib/motion-render-gate.mjs +2 -1
  41. package/plugins/litclaude/lib/office-runtime.mjs +6 -0
  42. package/plugins/litclaude/lib/project-state-root.mjs +34 -2
  43. package/plugins/litclaude/lib/rules/engine.mjs +2 -1
  44. package/plugins/litclaude/lib/rules/format.mjs +24 -3
  45. package/plugins/litclaude/lib/rules/session-state.mjs +2 -1
  46. package/plugins/litclaude/lib/secure-path-read.mjs +46 -0
  47. package/plugins/litclaude/lib/start-work-lifecycle.mjs +4 -0
  48. package/plugins/litclaude/lib/wikify-knowledge.mjs +2 -1
  49. package/plugins/litclaude/scripts/scaffold-plan.mjs +2 -1
  50. package/plugins/litclaude/skills/lit-team/scripts/team.mjs +2 -1
  51. package/plugins/litclaude/skills/lit-typographic-motion/scripts/motion-runtime.mjs +7 -0
@@ -0,0 +1,411 @@
1
+ // Read-only view of LitClaude's own state (and the Git HEAD it works on) 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
+ const readSmall = async (io, path, cap) => {
315
+ const stat = await statOf(io, path);
316
+ if (!stat || stat.kind !== "file" || !(stat.size <= cap)) return null;
317
+ try {
318
+ const text = await io.read(path);
319
+ return typeof text === "string" && text.length <= cap ? text : null;
320
+ } catch {
321
+ return null;
322
+ }
323
+ };
324
+
325
+ /**
326
+ * The branch the working directory is on, for the card's bottom edge: the nearest `.git` above
327
+ * `cwd`, followed through a linked worktree's `gitdir:` file, and its HEAD read as text. A
328
+ * detached HEAD gives the short commit; anything unreadable gives null.
329
+ */
330
+ export const readGitBranch = async (io, cwd) => {
331
+ for (const directory of ancestors(cwd)) {
332
+ const marker = joinPath(directory, ".git");
333
+ const stat = await statOf(io, marker);
334
+ if (!stat) continue;
335
+ let gitDir = marker;
336
+ if (stat.kind === "file") {
337
+ const pointer = await readSmall(io, marker, STATE_CAPS.gitMarker);
338
+ const match = pointer === null ? null : /^gitdir:\s*(.+?)\s*$/mu.exec(pointer);
339
+ if (!match) return null;
340
+ gitDir = resolvePath(directory, match[1]);
341
+ }
342
+ const head = (await readSmall(io, joinPath(gitDir, "HEAD"), STATE_CAPS.gitMarker))?.trim();
343
+ if (!head) return null;
344
+ const ref = /^ref:\s*refs\/heads\/(.+)$/u.exec(head);
345
+ if (ref) return ref[1].slice(0, 120);
346
+ return /^[0-9a-f]{7,64}$/u.test(head) ? head.slice(0, 7) : null;
347
+ }
348
+ return null;
349
+ };
350
+
351
+ // The icon font the installer's opt-in step writes (bin/icon-font.mjs), or the proportional
352
+ // symbols font a person installed some other way, in each per-user font folder.
353
+ const ICON_FONT_NAMES = Object.freeze(["SymbolsNerdFontMono-Regular.ttf", "SymbolsNerdFont-Regular.ttf"]);
354
+
355
+ export const iconFontCandidates = (env) => {
356
+ const home = env?.HOME?.trim();
357
+ const folders = [];
358
+ if (home) folders.push(joinPath(home, "Library", "Fonts"));
359
+ const data = env?.XDG_DATA_HOME?.trim() || (home ? joinPath(home, ".local", "share") : "");
360
+ if (data) folders.push(joinPath(data, "fonts"));
361
+ const local = env?.LOCALAPPDATA?.trim();
362
+ if (local) folders.push(joinPath(local, "Microsoft", "Windows", "Fonts"));
363
+ return folders.flatMap((folder) => ICON_FONT_NAMES.map((name) => joinPath(folder, name)));
364
+ };
365
+
366
+ /**
367
+ * Whether the icon font is installed, by stat alone (the font is never read). `note` says what
368
+ * the band decided and why, for the evidence pane; a folder that cannot be checked counts as no
369
+ * font, so the band falls back to basic glyphs rather than drawing boxes.
370
+ */
371
+ export const detectIconFont = async (io, env) => {
372
+ const candidates = iconFontCandidates(env);
373
+ if (candidates.length === 0) return { present: false, path: null, note: "basic: no home folder to look in for the icon font" };
374
+ let unreadable = false;
375
+ for (const path of candidates) {
376
+ try {
377
+ const stat = await io.stat(path);
378
+ if (stat?.kind === "file") return { present: true, path, note: `nerd: Symbols Nerd Font found at ${path}` };
379
+ } catch (error) {
380
+ if (!/ENOENT|no such file|not found/iu.test(String(error?.message ?? error))) unreadable = true;
381
+ }
382
+ }
383
+ return unreadable
384
+ ? { present: false, path: null, note: "basic: the font folders could not be checked from the band" }
385
+ : { present: false, path: null, note: "basic: Symbols Nerd Font not found (litclaude-ai install --icon-font)" };
386
+ };
387
+
388
+ // The litgoal ledger's newest entries for the evidence pane, newest first. Lines that are not JSON
389
+ // objects are skipped; the file is read only under its cap.
390
+ export const readLedgerTail = async (io, stateRoot, limit = 8) => {
391
+ const text = await readGuarded(io, stateRoot, ".litclaude/litgoal/ledger.jsonl", STATE_CAPS.ledger);
392
+ if (text === null) return [];
393
+ const entries = [];
394
+ for (const line of text.split("\n").reverse()) {
395
+ if (entries.length >= limit) break;
396
+ if (!line.trim()) continue;
397
+ try {
398
+ const entry = JSON.parse(line);
399
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
400
+ entries.push({
401
+ event: str(entry.event, 60) ?? "event",
402
+ at: time(entry.timestamp),
403
+ detail: str(entry.criterionId ?? entry.note ?? entry.objective ?? entry.status, 120) ?? "",
404
+ status: str(entry.status, 40),
405
+ });
406
+ } catch {
407
+ // A torn final line from a concurrent append; the next refresh reads it whole.
408
+ }
409
+ }
410
+ return entries;
411
+ };
@@ -0,0 +1,152 @@
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
+ // One entry per chip: `at` is the firing the model reported, `arrivedAt` when this timeline saw
14
+ // that firing arrive (null for chips already there when the band appeared, so they do not shimmer),
15
+ // and the state crossfade from `from` to `state` starting at `changedAt`.
16
+ const chipsOf = (model, now, previous = null) => Object.fromEntries((model.skills ?? []).map((chip) => {
17
+ const known = previous?.[chip.id];
18
+ if (!known || known.at !== chip.at) {
19
+ return [chip.id, { at: chip.at, arrivedAt: previous === null ? null : now, state: chip.state, from: chip.state, changedAt: now }];
20
+ }
21
+ if (known.state !== chip.state) return [chip.id, { ...known, from: known.state, state: chip.state, changedAt: now }];
22
+ return [chip.id, known];
23
+ }));
24
+
25
+ export const createTimeline = (model, now, profile) => {
26
+ const restAt = now + entryEnd(profile);
27
+ return {
28
+ enterAt: now,
29
+ exitAt: null,
30
+ fill: { from: 0, to: fractionOf(model), at: restAt, dur: profile.duration(TIMINGS.fill) },
31
+ done: model.progress?.done ?? null,
32
+ flashAt: null,
33
+ warn: still(model.ctxWarning ? 1 : 0),
34
+ phases: Object.fromEntries(PHASES.map((id) => [id, { from: model.phases[id], to: model.phases[id], at: now }])),
35
+ notice: model.notice ? { data: model.notice, at: restAt } : null,
36
+ leaving: null,
37
+ completion: model.completion ? { data: model.completion, at: now } : null,
38
+ chips: chipsOf(model, now),
39
+ };
40
+ };
41
+
42
+ export const advanceTimeline = (timeline, model, now, profile) => {
43
+ const next = { ...timeline };
44
+ if (!model.visible && timeline.exitAt === null) next.exitAt = now;
45
+ if (model.visible && timeline.exitAt !== null) return createTimeline(model, now, profile);
46
+ next.fill = retarget(timeline.fill, fractionOf(model), now, profile.duration(TIMINGS.fill));
47
+ const done = model.progress?.done ?? null;
48
+ if (done !== timeline.done) {
49
+ next.done = done;
50
+ if (timeline.done !== null && done !== null) next.flashAt = now;
51
+ }
52
+ next.warn = retarget(timeline.warn, model.ctxWarning ? 1 : 0, now, profile.duration(TIMINGS.crossfade));
53
+ next.phases = Object.fromEntries(PHASES.map((id) => {
54
+ const current = timeline.phases[id];
55
+ return [id, current.to === model.phases[id] ? current : { from: current.to, to: model.phases[id], at: now }];
56
+ }));
57
+ const incoming = model.notice?.id ?? null;
58
+ if (incoming !== (timeline.notice?.data.id ?? null)) {
59
+ next.leaving = timeline.notice ? { data: timeline.notice.data, at: now } : timeline.leaving;
60
+ next.notice = model.notice ? { data: model.notice, at: now } : null;
61
+ }
62
+ if ((model.completion?.id ?? null) !== (timeline.completion?.data.id ?? null)) {
63
+ next.completion = model.completion ? { data: model.completion, at: now } : null;
64
+ }
65
+ next.chips = chipsOf(model, now, timeline.chips);
66
+ return next;
67
+ };
68
+
69
+ export const timelineBusy = (timeline, now, profile) => {
70
+ const d = profile.duration;
71
+ if (timeline.exitAt !== null) return now < timeline.exitAt + d(TIMINGS.exit);
72
+ if (now < timeline.enterAt + entryEnd(profile)) return true;
73
+ if (!isSettled(timeline.fill, now) || !isSettled(timeline.warn, now)) return true;
74
+ if (timeline.flashAt !== null && now < timeline.flashAt + d(TIMINGS.flash)) return true;
75
+ if (PHASES.some((id) => timeline.phases[id].from !== timeline.phases[id].to && now < timeline.phases[id].at + d(TIMINGS.crossfade))) return true;
76
+ if (timeline.notice && now < timeline.notice.at + d(TIMINGS.noticeIn)) return true;
77
+ if (timeline.leaving && now < timeline.leaving.at + d(TIMINGS.noticeOut)) return true;
78
+ if (timeline.completion && now < timeline.completion.at + d(TIMINGS.completion)) return true;
79
+ return Object.values(timeline.chips).some((chip) => (chip.arrivedAt !== null && now < chip.arrivedAt + d(TIMINGS.shimmer))
80
+ || (chip.from !== chip.state && now < chip.changedAt + d(TIMINGS.crossfade)));
81
+ };
82
+
83
+ /**
84
+ * Everything one frame needs, as numbers: how far each pill has popped in (ease-out-back, so it may
85
+ * pass 1 briefly), the bar's fill, the count flash, the amber mix, each phase's colour crossfade,
86
+ * the notice row's offset in cells, the completion shimmer and embers, and per chip how far its
87
+ * arrival has run (`arrive`, 0 to 1; 1 once settled), where full motion's light stands on its
88
+ * name (`shimmer`, null when none) and its state crossfade.
89
+ */
90
+ export const sampleTimeline = (timeline, now, profile) => {
91
+ const d = profile.duration;
92
+ const exit = timeline.exitAt === null ? 0 : EASE.inCubic(phase(now, timeline.exitAt, d(TIMINGS.exit)));
93
+ const pills = [0, 1, 2, 3].map((index) => {
94
+ const popped = profile.enterEase(phase(now, timeline.enterAt + index * d(TIMINGS.stagger), d(TIMINGS.entry)));
95
+ return Math.max(0, popped * (1 - exit));
96
+ });
97
+ const rest = exit > 0 ? 0 : phase(now, timeline.enterAt + entryEnd(profile), 1);
98
+ const flash = timeline.flashAt === null ? 0 : Math.sin(Math.PI * phase(now, timeline.flashAt, d(TIMINGS.flash)));
99
+ const phases = Object.fromEntries(PHASES.map((id) => {
100
+ const { from, to, at } = timeline.phases[id];
101
+ return [id, { from, to, mix: from === to ? 1 : EASE.inOutCubic(phase(now, at, d(TIMINGS.crossfade))) }];
102
+ }));
103
+ let notice = null;
104
+ if (timeline.notice) {
105
+ const p = phase(now, timeline.notice.at, d(TIMINGS.noticeIn));
106
+ notice = { data: timeline.notice.data, offset: Math.max(0, Math.round(14 * (1 - profile.noticeEase(p)))) };
107
+ } else if (timeline.leaving && now < timeline.leaving.at + d(TIMINGS.noticeOut)) {
108
+ notice = { data: timeline.leaving.data, offset: Math.round(40 * EASE.inCubic(phase(now, timeline.leaving.at, d(TIMINGS.noticeOut)))) };
109
+ }
110
+ let completion = null;
111
+ if (timeline.completion) {
112
+ const at = timeline.completion.at;
113
+ completion = {
114
+ data: timeline.completion.data,
115
+ shimmer: profile.shimmer && now >= at + d(50) && now < at + d(50) + d(TIMINGS.shimmer)
116
+ ? EASE.outCubic(phase(now, at + d(50), d(TIMINGS.shimmer))) * 1.3 - 0.15
117
+ : null,
118
+ embers: profile.embers ? phase(now, at, d(TIMINGS.embers)) : null,
119
+ title: EASE.outCubic(phase(now, at + d(30), d(200))),
120
+ detail: EASE.outCubic(phase(now, at + d(100), d(200))),
121
+ };
122
+ }
123
+ const chips = Object.fromEntries(Object.entries(timeline.chips).map(([id, chip]) => {
124
+ const arrive = chip.arrivedAt === null ? 1 : phase(now, chip.arrivedAt, d(TIMINGS.shimmer));
125
+ return [id, {
126
+ arrive,
127
+ shimmer: profile.shimmer && arrive < 1 ? EASE.outCubic(arrive) * 1.3 - 0.15 : null,
128
+ from: chip.from,
129
+ to: chip.state,
130
+ mix: chip.from === chip.state ? 1 : EASE.inOutCubic(phase(now, chip.changedAt, d(TIMINGS.crossfade))),
131
+ }];
132
+ }));
133
+ return {
134
+ pills,
135
+ rest,
136
+ exit,
137
+ fill: sample(timeline.fill, now),
138
+ fillGlow: isSettled(timeline.fill, now) ? 0 : Math.sin(Math.PI * phase(now, timeline.fill.at, timeline.fill.dur)),
139
+ flash,
140
+ warn: sample(timeline.warn, now),
141
+ phases,
142
+ notice,
143
+ completion,
144
+ chips,
145
+ };
146
+ };
147
+
148
+ /** The final frame of a model, for motion off, surfaces without frame clocks and tests. */
149
+ export const settledSample = (model) => {
150
+ const profile = { duration: () => 0, enterEase: EASE.linear, noticeEase: EASE.linear, shimmer: false, embers: false };
151
+ return sampleTimeline(createTimeline(model, 0, profile), 1, profile);
152
+ };