@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.
- package/CHANGELOG.md +10 -0
- package/README.md +8 -8
- package/README_ko-KR.md +8 -8
- package/bin/litclaude-ai.js +10 -3
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/hooks.md +76 -24
- package/package.json +1 -1
- package/plugins/litclaude/.claude-plugin/plugin.json +7 -1
- 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-mod.mjs +87 -25
- package/plugins/litclaude/lib/mod/frame.mjs +204 -14
- package/plugins/litclaude/lib/mod/motion.mjs +2 -0
- package/plugins/litclaude/lib/mod/settings.mjs +6 -2
- 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 +20 -5
- package/plugins/litclaude/lib/mod/view.mjs +14 -2
- 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
|
@@ -4,10 +4,16 @@
|
|
|
4
4
|
// only when it is a regular file, under its size cap, not itself a link, and lands exactly where its
|
|
5
5
|
// spelled path says once every link is resolved, so a symlinked `.litclaude` or state file is skipped.
|
|
6
6
|
//
|
|
7
|
+
// The workflow map's sources are read the same way: the newest deep-interview state, the newest
|
|
8
|
+
// lit-plan draft, the newest litresearch session folder and the stage record. A folder is listed
|
|
9
|
+
// through `io.list` when the host offers it; without it the map falls back to the other sources.
|
|
10
|
+
//
|
|
7
11
|
// The two roots mirror the hook: `.litclaude` sits at the outermost Git root (project-state-root.mjs),
|
|
8
12
|
// while boulder.json is found the way `lifecycleRootFor` in bin/litclaude-hook.js finds it, walking up
|
|
9
13
|
// from the working directory and across a linked Git worktree to its main checkout.
|
|
10
14
|
|
|
15
|
+
import { parseStageRecord } from "./workflow.mjs";
|
|
16
|
+
|
|
11
17
|
export const STATE_CAPS = Object.freeze({
|
|
12
18
|
boulder: 2 * 1024 * 1024,
|
|
13
19
|
goals: 512 * 1024,
|
|
@@ -195,6 +201,7 @@ export const parseBoulder = (state, sessionId) => {
|
|
|
195
201
|
checked: Math.min(int(progress.checked), total),
|
|
196
202
|
nextTask: str(progress.next_task, 400),
|
|
197
203
|
lanes: laneList(active.lanes),
|
|
204
|
+
lastEvent: str(Array.isArray(active.events) ? active.events.at(-1)?.event : null, 60),
|
|
198
205
|
},
|
|
199
206
|
completed,
|
|
200
207
|
};
|
|
@@ -279,6 +286,81 @@ export const parseAutoHandoff = (settings, session, env) => {
|
|
|
279
286
|
};
|
|
280
287
|
};
|
|
281
288
|
|
|
289
|
+
const listOf = async (io, path) => {
|
|
290
|
+
if (typeof io.list !== "function") return [];
|
|
291
|
+
try {
|
|
292
|
+
const entries = await io.list(path);
|
|
293
|
+
return Array.isArray(entries) ? entries.slice(0, 400) : [];
|
|
294
|
+
} catch {
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const newestFile = (entries, pattern) => entries
|
|
300
|
+
.filter((entry) => entry?.kind === "file" && !entry.isLink && pattern.test(entry.name))
|
|
301
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)[0] ?? null;
|
|
302
|
+
|
|
303
|
+
export const parseInterviewState = (state, at) => {
|
|
304
|
+
const inner = state?.state && typeof state.state === "object" ? state.state : null;
|
|
305
|
+
if (!inner) return null;
|
|
306
|
+
return {
|
|
307
|
+
active: state.active === true,
|
|
308
|
+
round: Array.isArray(inner.rounds) ? inner.rounds.length : null,
|
|
309
|
+
max: Number.isInteger(inner.max_rounds) && inner.max_rounds > 0 ? inner.max_rounds : null,
|
|
310
|
+
ambiguity: num(inner.current_ambiguity),
|
|
311
|
+
threshold: num(inner.threshold),
|
|
312
|
+
at,
|
|
313
|
+
};
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// The draft's resume gate: the last `status:` line wins, as the approval gate sits below the header.
|
|
317
|
+
export const parseDraftStatus = (text, slug, at) => {
|
|
318
|
+
const statuses = [...String(text).matchAll(/^status:\s*([a-z-]+)\s*$/gmu)].map((match) => match[1]);
|
|
319
|
+
return statuses.length ? { slug, status: statuses.at(-1), at } : null;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
export const summarizeResearchFiles = (entries, slug) => {
|
|
323
|
+
const files = entries.filter((entry) => entry?.kind === "file" && !entry.isLink);
|
|
324
|
+
const waves = files.map(({ name }) => /^wave-(\d{1,3})-.*\.md$/u.exec(name)).filter(Boolean).map((match) => Number(match[1]));
|
|
325
|
+
return {
|
|
326
|
+
slug,
|
|
327
|
+
wave: waves.length ? Math.max(...waves) : 0,
|
|
328
|
+
digests: waves.length,
|
|
329
|
+
verified: files.filter(({ name }) => /^verify-.*\.md$/u.test(name)).length,
|
|
330
|
+
synthesis: files.some(({ name }) => name === "SYNTHESIS.md"),
|
|
331
|
+
at: files.reduce((latest, { mtimeMs }) => Math.max(latest, Number.isFinite(mtimeMs) ? mtimeMs : 0), 0) || null,
|
|
332
|
+
};
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const readWorkflowSources = async (io, stateRoot, sessionId) => {
|
|
336
|
+
const dir = (name) => joinPath(stateRoot, ".litclaude", name);
|
|
337
|
+
const [interviews, drafts, researches, record] = await Promise.all([
|
|
338
|
+
listOf(io, dir("deep-interview")),
|
|
339
|
+
listOf(io, dir("drafts")),
|
|
340
|
+
listOf(io, dir("litresearch")),
|
|
341
|
+
readGuardedJson(io, stateRoot, ".litclaude/workflow-stage.json", STATE_CAPS.small),
|
|
342
|
+
]);
|
|
343
|
+
const interviewFile = newestFile(interviews, /^[A-Za-z0-9._-]+-state\.json$/u);
|
|
344
|
+
const draftFile = newestFile(drafts, /^[A-Za-z0-9._-]+\.md$/u);
|
|
345
|
+
let research = null;
|
|
346
|
+
const sessions = researches.filter((entry) => entry?.kind === "dir" && !entry.isLink && /^[A-Za-z0-9._-]+$/u.test(entry.name)).slice(0, 30);
|
|
347
|
+
if (sessions.length) {
|
|
348
|
+
const stamped = await Promise.all(sessions.map(async ({ name }) => ({ name, at: (await statOf(io, joinPath(dir("litresearch"), name)))?.mtimeMs ?? 0 })));
|
|
349
|
+
const newest = stamped.sort((a, b) => b.at - a.at)[0];
|
|
350
|
+
research = summarizeResearchFiles(await listOf(io, joinPath(dir("litresearch"), newest.name)), newest.name);
|
|
351
|
+
}
|
|
352
|
+
const [interview, draft] = await Promise.all([
|
|
353
|
+
interviewFile ? readGuardedJson(io, stateRoot, `.litclaude/deep-interview/${interviewFile.name}`, STATE_CAPS.small) : null,
|
|
354
|
+
draftFile ? readGuarded(io, stateRoot, `.litclaude/drafts/${draftFile.name}`, STATE_CAPS.goals) : null,
|
|
355
|
+
]);
|
|
356
|
+
return {
|
|
357
|
+
record: parseStageRecord(record, sessionId),
|
|
358
|
+
interview: interview ? parseInterviewState(interview, interviewFile.mtimeMs) : null,
|
|
359
|
+
draft: draft === null ? null : parseDraftStatus(draft, draftFile.name.replace(/\.md$/u, ""), draftFile.mtimeMs),
|
|
360
|
+
research,
|
|
361
|
+
};
|
|
362
|
+
};
|
|
363
|
+
|
|
282
364
|
export const readLitSnapshot = async (io, { cwd, sessionId, env = {} }) => {
|
|
283
365
|
const stateRoot = await resolveStateRoot(io, cwd);
|
|
284
366
|
const lifecycleRoot = await resolveLifecycleRoot(io, cwd, stateRoot);
|
|
@@ -296,6 +378,7 @@ export const readLitSnapshot = async (io, { cwd, sessionId, env = {} }) => {
|
|
|
296
378
|
handoffId ? state(`auto-handoff/session-${handoffId}.json`) : null,
|
|
297
379
|
]);
|
|
298
380
|
const { work, completed } = parseBoulder(boulder, sessionId);
|
|
381
|
+
const workflow = await readWorkflowSources(io, stateRoot, sessionId);
|
|
299
382
|
return {
|
|
300
383
|
stateRoot,
|
|
301
384
|
lifecycleRoot,
|
|
@@ -308,6 +391,7 @@ export const readLitSnapshot = async (io, { cwd, sessionId, env = {} }) => {
|
|
|
308
391
|
probe: parseProbeTurn(probe, sessionId),
|
|
309
392
|
motion: parseMotionTurn(motion, sessionId),
|
|
310
393
|
handoff: parseAutoHandoff(handoffSettings, handoffSession, env),
|
|
394
|
+
workflow,
|
|
311
395
|
};
|
|
312
396
|
};
|
|
313
397
|
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// The LIT card as one SVG drawing, for the surfaces that are not a terminal grid. The Claude desktop
|
|
2
|
+
// app draws a mod's Text in a proportional font and cuts each span to the cells it was given, so the
|
|
3
|
+
// terminal card's text border and its padded pills come apart there. Those surfaces carry an `Svg`
|
|
4
|
+
// element instead, and this module draws the same card into it: the brand pill, the discipline and
|
|
5
|
+
// the stage indicator on top, the workflow map (or the phase track and progress when no family
|
|
6
|
+
// places the session), the detail line with the live step, the skill chips (or the notice or the
|
|
7
|
+
// finish), and the live figures at the bottom.
|
|
8
|
+
//
|
|
9
|
+
// It draws the settled state of the model; the terminal's entrances and crossfades stay terminal
|
|
10
|
+
// only. While LitClaude works and motion allows, SMIL animation in the drawing keeps it moving: the
|
|
11
|
+
// brand pill and the border breathe (full and subtle), a light crosses the current stage and this
|
|
12
|
+
// request's chips, a bright head runs along the rail leaving the current stage, the count bar's
|
|
13
|
+
// head shimmers, the step spinner turns and a comet runs around the border (full only). Idle, it is
|
|
14
|
+
// still. The step's clock moves with each new drawing the mod hands the surface.
|
|
15
|
+
//
|
|
16
|
+
// Text is set in a monospace face and placed by counted cells (Hangul and emoji as two), so the
|
|
17
|
+
// layout does not depend on measuring a font the drawing cannot see.
|
|
18
|
+
|
|
19
|
+
import { formatClock } from "./frame.mjs";
|
|
20
|
+
import { textWidth, truncateToWidth } from "./layout.mjs";
|
|
21
|
+
import { TIMINGS, barCells, gradientAt } from "./motion.mjs";
|
|
22
|
+
import { formatDuration } from "./view.mjs";
|
|
23
|
+
|
|
24
|
+
const C = Object.freeze({
|
|
25
|
+
bg: "#0d1117",
|
|
26
|
+
pink: "#ff2d95",
|
|
27
|
+
orange: "#ff6337",
|
|
28
|
+
green: "#6bdb8f",
|
|
29
|
+
amber: "#ffaf3c",
|
|
30
|
+
red: "#ff5f5f",
|
|
31
|
+
violet: "#b48cff",
|
|
32
|
+
white: "#e6edf3",
|
|
33
|
+
dim: "#7d8590",
|
|
34
|
+
faint: "#30363d",
|
|
35
|
+
rail: "#46505c",
|
|
36
|
+
ink: "#0b0f14",
|
|
37
|
+
muted: "#2d333b",
|
|
38
|
+
mutedText: "#adb5bf",
|
|
39
|
+
empty: "#3a3f47",
|
|
40
|
+
hudGreen: "#5faf5f",
|
|
41
|
+
hudYellow: "#d7af00",
|
|
42
|
+
hudRed: "#d75f5f",
|
|
43
|
+
});
|
|
44
|
+
const LABELS = Object.freeze({ plan: "PLAN", work: "WORK", review: "REVIEW" });
|
|
45
|
+
const PHASES = Object.freeze(["plan", "work", "review"]);
|
|
46
|
+
|
|
47
|
+
// Drawing units: CSS pixels at the card's natural size; the surface draws it at that width, up to its slot.
|
|
48
|
+
export const SVG_CARD = Object.freeze({ width: 880, height: 160, font: 13, cell: 7.8, pad: 18 });
|
|
49
|
+
const ROW_Y = Object.freeze([28, 58, 86, 114, 142]);
|
|
50
|
+
const PILL_H = 20;
|
|
51
|
+
const FONT = "ui-monospace, 'SF Mono', SFMono-Regular, Menlo, 'JetBrains Mono', Consolas, monospace";
|
|
52
|
+
|
|
53
|
+
const esc = (text) => String(text).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
54
|
+
const widthOf = (text) => textWidth(text) * SVG_CARD.cell;
|
|
55
|
+
const levelColor = (percent) => (percent >= 90 ? C.hudRed : percent >= 70 ? C.hudYellow : C.hudGreen);
|
|
56
|
+
|
|
57
|
+
// Edge spaces become offsets: an SVG text element drops leading and trailing whitespace.
|
|
58
|
+
const text = (x, y, body, { fill = C.white, bold = false, anchor = "start" } = {}) => {
|
|
59
|
+
const lead = String(body).length - String(body).trimStart().length;
|
|
60
|
+
const trimmed = String(body).trim();
|
|
61
|
+
if (!trimmed) return "";
|
|
62
|
+
const at = anchor === "start" ? x + lead * SVG_CARD.cell : x;
|
|
63
|
+
return `<text x="${at.toFixed(1)}" y="${y}" fill="${fill}"${bold ? ' font-weight="700"' : ""}${anchor === "start" ? "" : ` text-anchor="${anchor}"`}>${esc(trimmed)}</text>`;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// A run of coloured pieces on one baseline, left to right from `x`; returns the markup and where it ends.
|
|
67
|
+
const run = (x, y, pieces) => {
|
|
68
|
+
let at = x;
|
|
69
|
+
const out = pieces.map(([body, style]) => {
|
|
70
|
+
const markup = text(at, y, body, style);
|
|
71
|
+
at += widthOf(body);
|
|
72
|
+
return markup;
|
|
73
|
+
});
|
|
74
|
+
return { markup: out.join(""), end: at };
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// A filled pill holding `label`, its left edge at `x`. `sweep` lays the moving light over it.
|
|
78
|
+
const pill = (x, y, label, { fill, ink, bold = true, sweep = false, breath = null, breathPeak = 0.85 }) => {
|
|
79
|
+
const w = widthOf(label) + 16;
|
|
80
|
+
const top = y - PILL_H + 6;
|
|
81
|
+
const parts = [`<rect x="${x.toFixed(1)}" y="${top}" width="${w.toFixed(1)}" height="${PILL_H}" rx="${PILL_H / 2}" fill="${fill}"/>`];
|
|
82
|
+
if (breath) parts.push(`<rect x="${x.toFixed(1)}" y="${top}" width="${w.toFixed(1)}" height="${PILL_H}" rx="${PILL_H / 2}" fill="${breath}" opacity="0"><animate attributeName="opacity" values="0;${breathPeak};0" dur="${TIMINGS.breath}ms" repeatCount="indefinite"/></rect>`);
|
|
83
|
+
if (sweep) parts.push(`<rect x="${x.toFixed(1)}" y="${top}" width="${w.toFixed(1)}" height="${PILL_H}" rx="${PILL_H / 2}" fill="url(#lit-sweep)"/>`);
|
|
84
|
+
parts.push(text(x + 8, y, label, { fill: ink, bold }));
|
|
85
|
+
return { markup: parts.join(""), end: x + w };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const indicatorOf = (model, accent) => {
|
|
89
|
+
if (model.completion) return { label: "DONE ✓", fill: C.green };
|
|
90
|
+
if (model.idle) return { label: "idle", fill: C.dim, plain: true };
|
|
91
|
+
const flow = model.workflow;
|
|
92
|
+
if (flow && flow.current >= 0) return { label: `${flow.stages[flow.current].id.toUpperCase()} ●`, fill: model.ctxWarning ? C.amber : accent };
|
|
93
|
+
if (flow && model.lanes.count === 0) return { label: "DONE ✓", fill: C.green };
|
|
94
|
+
const current = PHASES.find((id) => model.phases[id] === "now");
|
|
95
|
+
if (current) return { label: `${LABELS[current]} ●`, fill: model.ctxWarning && current === "work" ? C.amber : accent };
|
|
96
|
+
if (PHASES.every((id) => model.phases[id] === "done") && model.lanes.count === 0) return { label: "DONE ✓", fill: C.green };
|
|
97
|
+
return { label: "LIVE ●", fill: accent };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const topRow = (model, accent, animate) => {
|
|
101
|
+
const { width, pad } = SVG_CARD;
|
|
102
|
+
const y = ROW_Y[0];
|
|
103
|
+
const brand = pill(pad, y, "🔥 LIT", { fill: C.pink, ink: C.ink, breath: animate.breath ? C.orange : null });
|
|
104
|
+
const indicator = indicatorOf(model, accent);
|
|
105
|
+
const right = text(width - pad, y, indicator.label, { fill: indicator.fill, bold: !indicator.plain, anchor: "end" });
|
|
106
|
+
const room = width - pad - widthOf(indicator.label) - 16 - brand.end - widthOf(" · ");
|
|
107
|
+
const name = truncateToWidth(model.title ?? "ready", Math.max(4, Math.floor(room / SVG_CARD.cell)));
|
|
108
|
+
const title = run(brand.end + 4, y, [[" · ", { fill: C.dim }], [name, model.idle ? { fill: C.dim } : { fill: accent, bold: true }]]);
|
|
109
|
+
return brand.markup + title.markup + right;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const rightOfPhases = (model, withProgress = true) => {
|
|
113
|
+
const pieces = [];
|
|
114
|
+
if (model.ctxWarning) pieces.push([[`ctx ${model.ctxWarning.percent}% → ${model.ctxWarning.threshold}%`, { fill: C.amber, bold: true }]]);
|
|
115
|
+
if (model.lanes.count > 0) pieces.push([[`● ${model.lanes.count} ${model.lanes.count === 1 ? "lane" : "lanes"}`, { fill: C.green }]]);
|
|
116
|
+
if (model.autoloop) pieces.push([[`↻ ${model.autoloop.n}/${model.autoloop.max}`, { fill: C.violet }], [` · ${formatDuration(model.autoloop.leftMs)}`, { fill: C.dim }]]);
|
|
117
|
+
if (!withProgress) return pieces;
|
|
118
|
+
if (model.progress) {
|
|
119
|
+
pieces.push([[`${model.progress.unit === "tasks" ? "tasks" : "goal"} `, { fill: C.dim }], [`${model.progress.done}`, { fill: C.white, bold: true }], [`/${model.progress.total}`, { fill: C.dim }]]);
|
|
120
|
+
} else if (model.goal) {
|
|
121
|
+
const met = model.goal.status === "complete";
|
|
122
|
+
pieces.push([["goal ", { fill: C.dim }], [`${met ? "✓ " : ""}${model.goal.passed}/${model.goal.total}`, { fill: met ? C.green : C.white, bold: met }]]);
|
|
123
|
+
}
|
|
124
|
+
return pieces;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// ● PLAN ── [WORK] ── ○ REVIEW on the left, the lanes, loop and progress figures on the right.
|
|
128
|
+
const phaseRow = (model, accent, animate) => {
|
|
129
|
+
const { width, pad } = SVG_CARD;
|
|
130
|
+
const y = ROW_Y[1];
|
|
131
|
+
const out = [];
|
|
132
|
+
let x = pad;
|
|
133
|
+
PHASES.forEach((id, index) => {
|
|
134
|
+
const state = model.phases[id];
|
|
135
|
+
if (index > 0) {
|
|
136
|
+
const done = model.phases[PHASES[index - 1]] === "done";
|
|
137
|
+
out.push(`<line x1="${(x + 6).toFixed(1)}" y1="${y - 4}" x2="${(x + 34).toFixed(1)}" y2="${y - 4}" stroke="${done ? accent : C.rail}" stroke-width="2" stroke-linecap="round"${done ? ' stroke-opacity="0.6"' : ""}/>`);
|
|
138
|
+
x += 40;
|
|
139
|
+
}
|
|
140
|
+
if (state === "now") {
|
|
141
|
+
const fill = model.idle ? C.muted : model.ctxWarning && id === "work" ? C.amber : accent;
|
|
142
|
+
const drawn = pill(x, y, LABELS[id], { fill, ink: model.idle ? C.mutedText : C.ink, sweep: animate.sweep });
|
|
143
|
+
out.push(drawn.markup);
|
|
144
|
+
x = drawn.end;
|
|
145
|
+
} else {
|
|
146
|
+
const color = state === "done" ? accent : C.dim;
|
|
147
|
+
out.push(`<circle cx="${(x + 5).toFixed(1)}" cy="${y - 4}" r="4.5" ${state === "done" ? `fill="${color}" fill-opacity="0.7"` : `fill="none" stroke="${color}" stroke-width="1.5"`}/>`);
|
|
148
|
+
out.push(text(x + 15, y, LABELS[id], { fill: color }));
|
|
149
|
+
x += 15 + widthOf(LABELS[id]);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
// Joined right-aligned; the leftmost group goes first when the row is short of room.
|
|
153
|
+
let groups = rightOfPhases(model);
|
|
154
|
+
const joined = (list) => list.flatMap((pieces, index) => (index > 0 ? [[" · ", { fill: C.faint }], ...pieces] : pieces));
|
|
155
|
+
const spanOf = (pieces) => pieces.reduce((sum, [body]) => sum + widthOf(body), 0);
|
|
156
|
+
while (groups.length && x + 24 + spanOf(joined(groups)) > width - pad) groups = groups.slice(1);
|
|
157
|
+
if (groups.length) out.push(run(width - pad - spanOf(joined(groups)), y, joined(groups)).markup);
|
|
158
|
+
return out.join("");
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const mapColor = (accent, position) => gradientAt([C.orange, C.pink, accent], position);
|
|
162
|
+
const shortLabel = (label) => label.replace(/^[A-Za-z]+/u, (word) => word.slice(0, 3));
|
|
163
|
+
|
|
164
|
+
// Right-aligned groups after `x`, the leftmost dropped first when the row is short of room.
|
|
165
|
+
const rightGroups = (groups, x, y) => {
|
|
166
|
+
const { width, pad } = SVG_CARD;
|
|
167
|
+
const joined = (list) => list.flatMap((pieces, index) => (index > 0 ? [[" · ", { fill: C.faint }], ...pieces] : pieces));
|
|
168
|
+
const spanOf = (pieces) => pieces.reduce((sum, [body]) => sum + widthOf(body), 0);
|
|
169
|
+
let kept = groups;
|
|
170
|
+
while (kept.length && x + 24 + spanOf(joined(kept)) > width - pad) kept = kept.slice(1);
|
|
171
|
+
return kept.length ? run(width - pad - spanOf(joined(kept)), y, joined(kept)).markup : "";
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// interview ✓ ━ plan ✓ ━ [work 3/7] ━ review · ─ done ·, with the lanes and budgets on the right.
|
|
175
|
+
const mapRowSvg = (model, accent, animate) => {
|
|
176
|
+
const { width, pad } = SVG_CARD;
|
|
177
|
+
const y = ROW_Y[1];
|
|
178
|
+
const { stages, current, count } = model.workflow;
|
|
179
|
+
const last = Math.max(1, stages.length - 1);
|
|
180
|
+
const labelOf = (stage, short) => {
|
|
181
|
+
const label = short ? shortLabel(stage.label) : stage.label;
|
|
182
|
+
return stage.state === "now" ? label : `${label} ${stage.state === "done" ? "✓" : "·"}`;
|
|
183
|
+
};
|
|
184
|
+
const extent = ({ rail, short }) => stages.reduce((sum, stage) => sum + widthOf(labelOf(stage, short)) + (stage.state === "now" ? 16 : 0), 0) + (stages.length - 1) * (rail + 12);
|
|
185
|
+
const layouts = [{ rail: 30, short: false }, { rail: 16, short: false }, { rail: 16, short: true }, { rail: 6, short: true }];
|
|
186
|
+
const layout = layouts.find((candidate) => pad + extent(candidate) <= width - pad - 160) ?? layouts.at(-1);
|
|
187
|
+
const out = [];
|
|
188
|
+
let x = pad;
|
|
189
|
+
stages.forEach((stage, index) => {
|
|
190
|
+
if (index > 0) {
|
|
191
|
+
const x1 = x + 6;
|
|
192
|
+
const x2 = x + 6 + layout.rail;
|
|
193
|
+
const mid = (index - 0.5) / last;
|
|
194
|
+
if (stage.state !== "todo") {
|
|
195
|
+
out.push(`<line x1="${x1.toFixed(1)}" y1="${y - 4}" x2="${x2.toFixed(1)}" y2="${y - 4}" stroke="${mapColor(accent, mid)}" stroke-width="2.5" stroke-linecap="round"/>`);
|
|
196
|
+
} else if (stages[index - 1].state === "now") {
|
|
197
|
+
out.push(`<line x1="${x1.toFixed(1)}" y1="${y - 4}" x2="${x2.toFixed(1)}" y2="${y - 4}" stroke="${C.rail}" stroke-width="2.5" stroke-linecap="round"/>`);
|
|
198
|
+
const filled = count && count.total > 0 ? Math.min(1, count.done / count.total) : 0;
|
|
199
|
+
if (filled > 0) out.push(`<line x1="${x1.toFixed(1)}" y1="${y - 4}" x2="${(x1 + filled * layout.rail).toFixed(1)}" y2="${y - 4}" stroke="${mapColor(accent, mid)}" stroke-width="2.5" stroke-linecap="round"/>`);
|
|
200
|
+
if (animate.sweep) out.push(`<circle cx="${x1.toFixed(1)}" cy="${y - 4}" r="2.5" fill="#ffffff"><animate attributeName="cx" values="${x1.toFixed(1)};${x2.toFixed(1)}" dur="${TIMINGS.flow}ms" repeatCount="indefinite"/></circle>`);
|
|
201
|
+
} else {
|
|
202
|
+
out.push(`<line x1="${x1.toFixed(1)}" y1="${y - 4}" x2="${x2.toFixed(1)}" y2="${y - 4}" stroke="${C.rail}" stroke-width="1.5" stroke-linecap="round" stroke-opacity="0.7"/>`);
|
|
203
|
+
}
|
|
204
|
+
x = x2 + 6;
|
|
205
|
+
}
|
|
206
|
+
const label = labelOf(stage, layout.short);
|
|
207
|
+
if (stage.state === "now") {
|
|
208
|
+
const fill = model.idle ? C.muted : model.ctxWarning ? C.amber : accent;
|
|
209
|
+
const drawn = pill(x, y, label, { fill, ink: model.idle ? C.mutedText : C.ink, sweep: animate.sweep, breath: animate.breath ? "#ffffff" : null, breathPeak: 0.2 });
|
|
210
|
+
out.push(drawn.markup);
|
|
211
|
+
x = drawn.end;
|
|
212
|
+
} else {
|
|
213
|
+
out.push(text(x, y, label, { fill: stage.state === "done" ? mapColor(accent, index / last) : C.dim }));
|
|
214
|
+
x += widthOf(label);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
out.push(rightGroups(rightOfPhases(model, false), x, y));
|
|
218
|
+
return out.join("");
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const VERDICT_FILL = Object.freeze({ pass: ["✓", C.green], fail: ["✗", C.red], pending: ["·", C.dim], running: ["●", C.amber] });
|
|
222
|
+
|
|
223
|
+
// └ task 3/7 ▮▮▮▯▯▯▯▯ · next ▸ <task> on the left, the spinner, tool and clock on the right.
|
|
224
|
+
const detailRowSvg = (model, accent, animate) => {
|
|
225
|
+
const { width, pad, cell } = SVG_CARD;
|
|
226
|
+
const y = ROW_Y[2];
|
|
227
|
+
const flow = model.workflow;
|
|
228
|
+
const out = [];
|
|
229
|
+
let right = pad;
|
|
230
|
+
if (model.step) {
|
|
231
|
+
const clock = ` · ${formatClock(model.step.elapsedMs)}`;
|
|
232
|
+
const end = width - pad;
|
|
233
|
+
out.push(text(end, y, clock, { fill: C.dim, anchor: "end" }));
|
|
234
|
+
const nameEnd = end - widthOf(clock);
|
|
235
|
+
out.push(text(nameEnd, y, model.step.name, { fill: C.white, anchor: "end" }));
|
|
236
|
+
const cx = nameEnd - widthOf(model.step.name) - 10;
|
|
237
|
+
out.push(`<circle cx="${cx.toFixed(1)}" cy="${y - 4}" r="4.5" fill="none" stroke="${accent}" stroke-width="1.8" stroke-dasharray="18 10.3">`
|
|
238
|
+
+ (animate.breath ? `<animateTransform attributeName="transform" type="rotate" values="0 ${cx.toFixed(1)} ${y - 4};360 ${cx.toFixed(1)} ${y - 4}" dur="${TIMINGS.spin * 8}ms" repeatCount="indefinite"/>` : "")
|
|
239
|
+
+ "</circle>");
|
|
240
|
+
right = width - cx + 10;
|
|
241
|
+
}
|
|
242
|
+
if (!flow) return out.join("");
|
|
243
|
+
const room = width - pad - right - 16;
|
|
244
|
+
out.push(text(pad, y, "└", { fill: C.dim }));
|
|
245
|
+
let x = pad + widthOf("└ ");
|
|
246
|
+
const sep = () => {
|
|
247
|
+
out.push(text(x + 6, y, "·", { fill: C.faint }));
|
|
248
|
+
x += 22;
|
|
249
|
+
};
|
|
250
|
+
if (flow.count) {
|
|
251
|
+
const { done, total, unit } = flow.count;
|
|
252
|
+
const drawn = run(x, y, [[`${unit} `, { fill: C.dim }], [String(done), { fill: C.white, bold: true }], [`/${total} `, { fill: C.dim }]]);
|
|
253
|
+
out.push(drawn.markup);
|
|
254
|
+
x = drawn.end;
|
|
255
|
+
const cells = barCells(total > 0 ? done / total : 0, 8);
|
|
256
|
+
cells.forEach(({ glyph, color }, index) => out.push(`<rect x="${(x + index * 9).toFixed(1)}" y="${y - 10}" width="7" height="10" rx="1.5" fill="${glyph === "█" ? color : C.empty}"/>`));
|
|
257
|
+
const head = cells.findLastIndex(({ glyph }) => glyph === "█");
|
|
258
|
+
if (head >= 0 && animate.sweep) out.push(`<rect x="${(x + head * 9).toFixed(1)}" y="${y - 10}" width="7" height="10" rx="1.5" fill="#ffffff" opacity="0"><animate attributeName="opacity" values="0;0.6;0" dur="${TIMINGS.breath}ms" repeatCount="indefinite"/></rect>`);
|
|
259
|
+
x += 8 * 9 + 4;
|
|
260
|
+
}
|
|
261
|
+
const pieces = [];
|
|
262
|
+
if (flow.verdicts) pieces.push(flow.verdicts.map(({ name, state }) => [`${name} ${VERDICT_FILL[state][0]} `, { fill: VERDICT_FILL[state][1] }]));
|
|
263
|
+
for (const item of flow.items) pieces.push([[item, { fill: C.white }]]);
|
|
264
|
+
for (const group of pieces) {
|
|
265
|
+
const span = group.reduce((sum, [body]) => sum + widthOf(body), 0);
|
|
266
|
+
if (x + 22 + span > pad + room) break;
|
|
267
|
+
if (x > pad + widthOf("└ ")) sep();
|
|
268
|
+
const drawn = run(x, y, group);
|
|
269
|
+
out.push(drawn.markup);
|
|
270
|
+
x = drawn.end;
|
|
271
|
+
}
|
|
272
|
+
if (flow.next) {
|
|
273
|
+
const avail = Math.floor((pad + room - x - 22) / cell) - textWidth("next ▸ ");
|
|
274
|
+
if (avail >= 6) {
|
|
275
|
+
if (x > pad + widthOf("└ ")) sep();
|
|
276
|
+
out.push(run(x, y, [["next ", { fill: C.dim }], ["▸ ", { fill: accent }], [truncateToWidth(flow.next, avail), { fill: C.white }]]).markup);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return out.join("");
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const TONES = Object.freeze({ pink: [C.pink, "#ff6fae", "🔥"], amber: [C.amber, C.amber, "⚠"], red: [C.red, C.red, "✗"] });
|
|
283
|
+
|
|
284
|
+
// Skill chips, or the notice or the finish while one is up, or the hint while nothing has fired.
|
|
285
|
+
const middleRow = (model, accent, animate) => {
|
|
286
|
+
const { width, pad } = SVG_CARD;
|
|
287
|
+
const y = ROW_Y[3];
|
|
288
|
+
const room = width - pad;
|
|
289
|
+
if (model.completion) {
|
|
290
|
+
return run(pad, y, [["✦ ", { fill: C.pink }], ["COMPLETE", { fill: C.white, bold: true }], [` · ${model.completion.detail ?? ""}`, { fill: C.green }]]).markup;
|
|
291
|
+
}
|
|
292
|
+
if (model.notice) {
|
|
293
|
+
const [bar, title, icon] = TONES[model.notice.tone] ?? TONES.pink;
|
|
294
|
+
return `<rect x="${pad}" y="${y - 14}" width="3" height="18" rx="1.5" fill="${bar}"/>`
|
|
295
|
+
+ run(pad + 10, y, [[`${icon} ${model.notice.title}`, { fill: title, bold: true }], ...(model.notice.detail ? [[` · ${model.notice.detail}`, { fill: C.dim }]] : [])]).markup;
|
|
296
|
+
}
|
|
297
|
+
if (model.skills.length === 0) {
|
|
298
|
+
if (!model.idle && model.next && !model.workflow?.next) return run(pad, y, [["▸ ", { fill: accent }], [truncateToWidth(model.next, Math.floor((room - pad) / SVG_CARD.cell) - 2), { fill: C.white }]]).markup;
|
|
299
|
+
if (!model.idle) return text(pad, y, "no LitFamily skill yet in this request", { fill: C.dim });
|
|
300
|
+
return run(pad, y, [["no LitFamily skill yet · type ", { fill: C.dim }], ["lit <task>", { fill: accent }], [" to start one", { fill: C.dim }]]).markup;
|
|
301
|
+
}
|
|
302
|
+
const out = [text(pad, y, "skills", { fill: C.dim })];
|
|
303
|
+
let x = pad + widthOf("skills") + 10;
|
|
304
|
+
model.skills.forEach((chip, index) => {
|
|
305
|
+
if (x === null) return;
|
|
306
|
+
const current = chip.state === "current";
|
|
307
|
+
const label = `${current ? "⚡" : "↯"} ${chip.id}`;
|
|
308
|
+
const rest = model.skills.length - index - 1;
|
|
309
|
+
const need = widthOf(label) + 16 + (rest > 0 ? widthOf(`+${rest}`) + 26 : 0);
|
|
310
|
+
if (x + need > room) {
|
|
311
|
+
out.push(pill(x, y, `+${model.skills.length - index}`, { fill: C.muted, ink: C.dim, bold: false }).markup);
|
|
312
|
+
x = null;
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const drawn = pill(x, y, label, { fill: current ? accent : C.muted, ink: current ? C.ink : C.mutedText, bold: current, sweep: current && animate.sweep });
|
|
316
|
+
out.push(drawn.markup);
|
|
317
|
+
x = drawn.end + 8;
|
|
318
|
+
});
|
|
319
|
+
return out.join("");
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
// ctx 42% ▰▰▱▱▱ · 5h 31% · 1w 12% · git main, after a warning when one is up.
|
|
323
|
+
const bottomRow = (model, accent) => {
|
|
324
|
+
const { width, pad } = SVG_CARD;
|
|
325
|
+
const y = ROW_Y[4];
|
|
326
|
+
const groups = [];
|
|
327
|
+
const warning = model.warnings[0];
|
|
328
|
+
if (warning) {
|
|
329
|
+
const color = warning.tone === "red" ? C.red : C.amber;
|
|
330
|
+
groups.push({ pieces: [[`${warning.tone === "red" ? "✗" : "⚠"} ${warning.subject}`, { fill: color, bold: true }], [` ${warning.text}`, { fill: color }]] });
|
|
331
|
+
}
|
|
332
|
+
const { context, rates, git } = model.figures;
|
|
333
|
+
if (context !== null) groups.push({ pieces: [["ctx ", { fill: C.dim }], [`${context}%`, { fill: accent }]], bar: context });
|
|
334
|
+
for (const rate of rates) groups.push({ pieces: [[`${rate.label} `, { fill: C.dim }], [`${rate.percent}%`, { fill: levelColor(rate.percent) }]] });
|
|
335
|
+
if (git) groups.push({ pieces: [["git ", { fill: C.dim }], [git, { fill: accent }]] });
|
|
336
|
+
const out = [];
|
|
337
|
+
let x = pad;
|
|
338
|
+
groups.forEach((group, index) => {
|
|
339
|
+
const span = group.pieces.reduce((sum, [body]) => sum + widthOf(body), 0) + (group.bar !== undefined ? 60 : 0);
|
|
340
|
+
if (x + span + (index > 0 ? 24 : 0) > width - pad) return;
|
|
341
|
+
if (index > 0) {
|
|
342
|
+
out.push(text(x + 8, y, "·", { fill: C.faint }));
|
|
343
|
+
x += 24;
|
|
344
|
+
}
|
|
345
|
+
const drawn = run(x, y, group.pieces);
|
|
346
|
+
out.push(drawn.markup);
|
|
347
|
+
x = drawn.end;
|
|
348
|
+
if (group.bar !== undefined) {
|
|
349
|
+
const filled = Math.max(0, Math.min(5, Math.round(group.bar / 20)));
|
|
350
|
+
for (let i = 0; i < 5; i += 1) out.push(`<rect x="${(x + 8 + i * 10).toFixed(1)}" y="${y - 9}" width="7" height="8" rx="1.5" fill="${i < filled ? accent : C.empty}"/>`);
|
|
351
|
+
x += 60;
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
return out.join("");
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const borderColor = (model, accent) => {
|
|
358
|
+
if (model.warnings.some(({ tone }) => tone === "red")) return C.red;
|
|
359
|
+
if (model.warnings.length) return C.amber;
|
|
360
|
+
return model.idle ? C.faint : accent;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
/** What the card says, for a reader that cannot see the drawing. */
|
|
364
|
+
export const svgCardAlt = (model) => {
|
|
365
|
+
const parts = [`LIT ${model.idle ? "idle" : "working"}`];
|
|
366
|
+
if (model.title) parts.push(model.title);
|
|
367
|
+
const flow = model.workflow;
|
|
368
|
+
const current = PHASES.find((id) => model.phases[id] === "now");
|
|
369
|
+
if (flow) {
|
|
370
|
+
const stage = flow.stages[flow.current];
|
|
371
|
+
parts.push(stage ? `stage ${stage.label} of ${flow.stages.map(({ id }) => id).join(", ")}` : "all stages done");
|
|
372
|
+
if (flow.verdicts) parts.push(flow.verdicts.map(({ name, state }) => `${name} ${state}`).join(", "));
|
|
373
|
+
} else if (current && !model.idle) {
|
|
374
|
+
parts.push(`phase ${LABELS[current]}`);
|
|
375
|
+
}
|
|
376
|
+
if (model.step) parts.push(`running ${model.step.name} ${formatClock(model.step.elapsedMs)}`);
|
|
377
|
+
if (model.skills.length) parts.push(`skills ${model.skills.map(({ id }) => id).join(", ")}`);
|
|
378
|
+
const { context, rates } = model.figures;
|
|
379
|
+
if (context !== null) parts.push(`context ${context}%`);
|
|
380
|
+
for (const rate of rates) parts.push(`${rate.label} ${rate.percent}%`);
|
|
381
|
+
return parts.join(" · ");
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The card as `{ source, alt, animated }`: an SVG document and its description. `motion` is the
|
|
386
|
+
* mod's setting; the drawing moves only while the model is not idle and motion is not off.
|
|
387
|
+
*/
|
|
388
|
+
export const composeSvgCard = (model, { motion = "full", accent = "#5fd7ff" } = {}) => {
|
|
389
|
+
const { width, height } = SVG_CARD;
|
|
390
|
+
const working = !model.idle && motion !== "off";
|
|
391
|
+
const animate = { breath: working, sweep: working && motion === "full", comet: working && motion === "full" };
|
|
392
|
+
const edge = borderColor(model, accent);
|
|
393
|
+
const perimeter = 1000;
|
|
394
|
+
const defs = animate.sweep
|
|
395
|
+
? `<defs><linearGradient id="lit-sweep" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="0.5" stop-color="#fff" stop-opacity="0.55"/><stop offset="1" stop-color="#fff" stop-opacity="0"/><animateTransform attributeName="gradientTransform" type="translate" values="-1.2 0;1.2 0" dur="${TIMINGS.sweep}ms" repeatCount="indefinite"/></linearGradient></defs>`
|
|
396
|
+
: "";
|
|
397
|
+
const frame = `<rect x="1" y="1" width="${width - 2}" height="${height - 2}" rx="14" fill="${C.bg}" stroke="${edge}" stroke-width="1.5"${model.idle ? ' stroke-opacity="0.8"' : ""}>`
|
|
398
|
+
+ (animate.breath && !model.warnings.length ? `<animate attributeName="stroke-opacity" values="0.45;1;0.45" dur="${TIMINGS.breath}ms" repeatCount="indefinite"/>` : "")
|
|
399
|
+
+ "</rect>";
|
|
400
|
+
const comet = animate.comet
|
|
401
|
+
? `<rect x="1" y="1" width="${width - 2}" height="${height - 2}" rx="14" fill="none" stroke="url(#lit-comet)" stroke-width="2.5" stroke-linecap="round" pathLength="${perimeter}" stroke-dasharray="70 ${perimeter - 70}"><animate attributeName="stroke-dashoffset" values="0;-${perimeter}" dur="${TIMINGS.comet * 2}ms" repeatCount="indefinite"/></rect>`
|
|
402
|
+
+ `<defs><linearGradient id="lit-comet" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="${C.orange}"/><stop offset="0.6" stop-color="${C.pink}"/><stop offset="1" stop-color="#ffffff"/></linearGradient></defs>`
|
|
403
|
+
: "";
|
|
404
|
+
const body = [
|
|
405
|
+
topRow(model, accent, animate),
|
|
406
|
+
model.workflow ? mapRowSvg(model, accent, animate) : phaseRow(model, accent, animate),
|
|
407
|
+
detailRowSvg(model, accent, animate),
|
|
408
|
+
middleRow(model, accent, animate),
|
|
409
|
+
bottomRow(model, accent),
|
|
410
|
+
].join("");
|
|
411
|
+
const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="${FONT}" font-size="${SVG_CARD.font}">${defs}${frame}${comet}${body}</svg>`;
|
|
412
|
+
return { source, alt: svgCardAlt(model), animated: working };
|
|
413
|
+
};
|
|
@@ -6,7 +6,16 @@
|
|
|
6
6
|
import { EASE, TIMINGS, isSettled, phase, retarget, sample, still } from "./motion.mjs";
|
|
7
7
|
|
|
8
8
|
const PHASES = ["plan", "work", "review"];
|
|
9
|
-
|
|
9
|
+
// The count the bar follows: the workflow stage's n/m, else the plan or goal progress.
|
|
10
|
+
const countOf = (model) => model.workflow?.count ?? model.progress ?? null;
|
|
11
|
+
const fractionOf = (model) => {
|
|
12
|
+
const count = countOf(model);
|
|
13
|
+
return count && count.total > 0 ? Math.min(1, count.done / count.total) : 0;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// The live step's clock: the elapsed time the model carried and the frame it arrived on, so the
|
|
17
|
+
// surface counts on between models.
|
|
18
|
+
const stepOf = (model, now) => (model.step ? { name: model.step.name, base: model.step.elapsedMs, at: now } : null);
|
|
10
19
|
|
|
11
20
|
const entryEnd = (profile) => 3 * profile.duration(TIMINGS.stagger) + profile.duration(TIMINGS.entry);
|
|
12
21
|
|
|
@@ -36,8 +45,9 @@ export const createTimeline = (model, now, profile) => {
|
|
|
36
45
|
enterAt: now,
|
|
37
46
|
exitAt: null,
|
|
38
47
|
fill: { from: 0, to: fractionOf(model), at: restAt, dur: profile.duration(TIMINGS.fill) },
|
|
39
|
-
done: model
|
|
48
|
+
done: countOf(model)?.done ?? null,
|
|
40
49
|
flashAt: null,
|
|
50
|
+
step: stepOf(model, now),
|
|
41
51
|
warn: still(model.ctxWarning ? 1 : 0),
|
|
42
52
|
phases: Object.fromEntries(PHASES.map((id) => [id, { from: model.phases[id], to: model.phases[id], at: now }])),
|
|
43
53
|
notice: model.notice ? { data: model.notice, at: restAt } : null,
|
|
@@ -52,7 +62,7 @@ export const advanceTimeline = (timeline, model, now, profile) => {
|
|
|
52
62
|
if (!model.visible && timeline.exitAt === null) next.exitAt = now;
|
|
53
63
|
if (model.visible && timeline.exitAt !== null) return createTimeline(model, now, profile);
|
|
54
64
|
next.fill = retarget(timeline.fill, fractionOf(model), now, profile.duration(TIMINGS.fill));
|
|
55
|
-
const done = model
|
|
65
|
+
const done = countOf(model)?.done ?? null;
|
|
56
66
|
if (done !== timeline.done) {
|
|
57
67
|
next.done = done;
|
|
58
68
|
if (timeline.done !== null && done !== null) next.flashAt = now;
|
|
@@ -72,6 +82,7 @@ export const advanceTimeline = (timeline, model, now, profile) => {
|
|
|
72
82
|
}
|
|
73
83
|
next.chips = chipsOf(model, now, timeline.chips);
|
|
74
84
|
next.ambient = ambientOf(model, now, profile, timeline.ambient);
|
|
85
|
+
next.step = stepOf(model, now);
|
|
75
86
|
return next;
|
|
76
87
|
};
|
|
77
88
|
|
|
@@ -79,8 +90,9 @@ export const advanceTimeline = (timeline, model, now, profile) => {
|
|
|
79
90
|
export const ambientActive = (timeline, profile) => Boolean(profile.ambient) && timeline.exitAt === null && timeline.ambient.on;
|
|
80
91
|
|
|
81
92
|
// One frame of the ambient loop: `breath` (0 to 1 and back over TIMINGS.breath), where the light
|
|
82
|
-
// stands on the active phase (`sweep`, null between passes)
|
|
83
|
-
// the card (`comet`, 0 to 1: the top rule left to right, then the bottom rule right to left)
|
|
93
|
+
// stands on the active phase (`sweep`, null between passes), how far the comet has gone around
|
|
94
|
+
// the card (`comet`, 0 to 1: the top rule left to right, then the bottom rule right to left) and
|
|
95
|
+
// where the bright head stands on the rail leaving the current stage (`flow`, 0 to 1).
|
|
84
96
|
const ambientSample = (timeline, now, profile) => {
|
|
85
97
|
if (!ambientActive(timeline, profile)) return null;
|
|
86
98
|
const elapsed = Math.max(0, now - timeline.ambient.at);
|
|
@@ -90,6 +102,7 @@ const ambientSample = (timeline, now, profile) => {
|
|
|
90
102
|
breath,
|
|
91
103
|
sweep: profile.ambient.sweep && pass >= -0.2 && pass <= 1.2 ? pass : null,
|
|
92
104
|
comet: profile.ambient.comet ? (elapsed % TIMINGS.comet) / TIMINGS.comet : null,
|
|
105
|
+
flow: profile.ambient.sweep ? (elapsed % TIMINGS.flow) / TIMINGS.flow : null,
|
|
93
106
|
};
|
|
94
107
|
};
|
|
95
108
|
|
|
@@ -170,6 +183,8 @@ export const sampleTimeline = (timeline, now, profile) => {
|
|
|
170
183
|
completion,
|
|
171
184
|
chips,
|
|
172
185
|
ambient: ambientSample(timeline, now, profile),
|
|
186
|
+
step: timeline.step ? { name: timeline.step.name, elapsedMs: timeline.step.base + Math.max(0, now - timeline.step.at) } : null,
|
|
187
|
+
spin: Math.floor(Math.max(0, now) / TIMINGS.spin) % 4,
|
|
173
188
|
};
|
|
174
189
|
};
|
|
175
190
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// band's surface module, which only draws and animates it.
|
|
4
4
|
|
|
5
5
|
import { AUTOLOOP_LIMITS, GATE_LIMITS } from "./state.mjs";
|
|
6
|
+
import { buildWorkflow } from "./workflow.mjs";
|
|
6
7
|
|
|
7
8
|
export const STALL_MS = 10 * 60 * 1000;
|
|
8
9
|
// An agent quiet for this long no longer counts as running: a crashed loop or a lost report must not
|
|
@@ -149,13 +150,22 @@ const figuresOf = (usage, git) => {
|
|
|
149
150
|
return { context, rates, git: typeof git === "string" && git ? git : null };
|
|
150
151
|
};
|
|
151
152
|
|
|
153
|
+
// The tool the main loop is running, by name only: an MCP tool by its own last segment. Never its
|
|
154
|
+
// arguments, its command or the files it touches.
|
|
155
|
+
export const stepName = (tool) => {
|
|
156
|
+
const name = String(tool ?? "").trim();
|
|
157
|
+
const mcp = /^mcp__.+__([^_].*)$/u.exec(name);
|
|
158
|
+
return (mcp ? mcp[1] : name).replace(/[^\w .:-]/gu, "").slice(0, 32) || null;
|
|
159
|
+
};
|
|
160
|
+
|
|
152
161
|
/**
|
|
153
162
|
* The band's model. `agents` is `$.agent.list()`, `activity` the per-agent last-seen map, `usage`
|
|
154
163
|
* `$.session.usage()`, `notice` and `completion` whatever the mod is currently playing, `skills`
|
|
155
164
|
* the chips (skills.mjs chipStates), `lastDiscipline` the discipline or skill the session last
|
|
156
|
-
* lit (the idle title) and `
|
|
165
|
+
* lit (the idle title), `git` the branch and `step` the live step (`{ name, at }`, the tool the
|
|
166
|
+
* main loop runs and since when).
|
|
157
167
|
*/
|
|
158
|
-
export const buildBandModel = ({ snapshot, agents = [], activity = EMPTY_ACTIVITY, usage = null, now, band = "always", isWorking = false, notice = null, completion = null, skills = [], lastDiscipline = null, git = null }) => {
|
|
168
|
+
export const buildBandModel = ({ snapshot, agents = [], activity = EMPTY_ACTIVITY, usage = null, now, band = "always", isWorking = false, notice = null, completion = null, skills = [], lastDiscipline = null, git = null, step = null }) => {
|
|
159
169
|
const discipline = snapshot.ignition?.discipline ?? null;
|
|
160
170
|
const progress = progressOf(snapshot);
|
|
161
171
|
const completing = completion !== null;
|
|
@@ -198,6 +208,8 @@ export const buildBandModel = ({ snapshot, agents = [], activity = EMPTY_ACTIVIT
|
|
|
198
208
|
goal: goalSummaryOf(goal),
|
|
199
209
|
skills: skills.map(({ id, at, state }) => ({ id, at, state })),
|
|
200
210
|
figures: figuresOf(usage, git),
|
|
211
|
+
workflow: buildWorkflow({ snapshot, skills, completion, now }),
|
|
212
|
+
step: step && isWorking && stepName(step.name) ? { name: stepName(step.name), elapsedMs: Math.max(0, now - step.at) } : null,
|
|
201
213
|
notice,
|
|
202
214
|
completion,
|
|
203
215
|
};
|