@litfamily/litopencode 1.0.17 → 1.0.19

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 (71) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README-Ko-KR.md +56 -86
  3. package/README.md +57 -86
  4. package/dist/activation-routing.js +3 -1
  5. package/dist/cli/args.js +5 -1
  6. package/dist/cli/auto-update.js +1 -1
  7. package/dist/cli/doctor.js +7 -5
  8. package/dist/cli/host-limits.js +2 -5
  9. package/dist/cli/install-config.d.ts +4 -0
  10. package/dist/cli/install-config.js +27 -1
  11. package/dist/cli/install-report.js +4 -1
  12. package/dist/cli/install.js +89 -28
  13. package/dist/cli/json.d.ts +1 -0
  14. package/dist/cli/json.js +10 -4
  15. package/dist/cli/jsonc.d.ts +17 -0
  16. package/dist/cli/jsonc.js +174 -0
  17. package/dist/cli/loop.d.ts +1 -1
  18. package/dist/cli/loop.js +81 -1
  19. package/dist/cli/motion-runtime.d.ts +13 -1
  20. package/dist/cli/motion-runtime.js +89 -2
  21. package/dist/cli/plugin-mutation.js +11 -6
  22. package/dist/cli/tui-config.d.ts +52 -0
  23. package/dist/cli/tui-config.js +198 -0
  24. package/dist/cli/types.d.ts +4 -0
  25. package/dist/cli.js +8 -3
  26. package/dist/deliverable-hedge-guard.js +5 -3
  27. package/dist/hooks.js +2 -1
  28. package/dist/tui/mission-band.d.ts +29 -0
  29. package/dist/tui/mission-band.js +116 -0
  30. package/dist/tui/mission-model.d.ts +139 -0
  31. package/dist/tui/mission-model.js +318 -0
  32. package/dist/tui/mission-motion.d.ts +66 -0
  33. package/dist/tui/mission-motion.js +147 -0
  34. package/dist/tui/mission-source.d.ts +56 -0
  35. package/dist/tui/mission-source.js +88 -0
  36. package/dist/tui/mission-text.d.ts +103 -0
  37. package/dist/tui/mission-text.js +613 -0
  38. package/dist/tui.d.ts +6 -0
  39. package/dist/tui.js +317 -0
  40. package/docs/assets/readme/badge-version.svg +1 -1
  41. package/docs/reference-Ko-KR.md +72 -1
  42. package/docs/reference.md +82 -1
  43. package/package.json +21 -2
  44. package/skills/lit-docx/scripts/docx_design.py +1 -1
  45. package/skills/lit-docx/templates/tonalities/brief.yaml +0 -1
  46. package/skills/lit-docx/templates/tonalities/journal.yaml +0 -1
  47. package/skills/lit-docx/templates/tonalities/manual.yaml +0 -1
  48. package/skills/lit-docx/templates/tonalities/memo.yaml +0 -1
  49. package/skills/lit-docx/templates/tonalities/proposal.yaml +0 -1
  50. package/skills/lit-docx/templates/tonalities/report.yaml +0 -1
  51. package/skills/lit-pptx/references/tonalities/atlas.md +3 -4
  52. package/skills/lit-pptx/references/tonalities/chalk.md +2 -2
  53. package/skills/lit-pptx/references/tonalities/gazette.md +3 -3
  54. package/skills/lit-pptx/references/tonalities/ledger.md +6 -7
  55. package/skills/lit-pptx/references/tonalities/night.md +3 -3
  56. package/skills/lit-pptx/references/tonalities/paper.md +4 -5
  57. package/skills/lit-pptx/references/tonalities/signal.md +3 -4
  58. package/skills/lit-pptx/references/tonalities/studio.md +3 -3
  59. package/skills/lit-pptx/scripts/lib/template-registry.js +1 -1
  60. package/skills/lit-pptx/scripts/validate_pptx.py +10 -4
  61. package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +0 -12
  62. package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +0 -12
  63. package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +0 -12
  64. package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +2 -14
  65. package/skills/lit-pptx/templates/tonalities/night/pack.yaml +0 -12
  66. package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +0 -12
  67. package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +0 -12
  68. package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +0 -12
  69. package/skills/managed-skill-manifest.json +25 -25
  70. package/tools/check-pack-payload.mjs +20 -5
  71. package/tools/version-manifests.json +18 -6
@@ -0,0 +1,139 @@
1
+ export type LitMark = {
2
+ readonly at: number;
3
+ readonly mode?: string;
4
+ readonly command?: string;
5
+ readonly operation?: string;
6
+ readonly blocked?: boolean;
7
+ };
8
+ export type TodoInput = {
9
+ readonly content: string;
10
+ readonly status: string;
11
+ };
12
+ export type MissionInput = {
13
+ readonly marks: readonly LitMark[];
14
+ readonly agent?: string;
15
+ readonly todos: readonly TodoInput[];
16
+ readonly ledger?: unknown;
17
+ readonly sessionStatus?: string;
18
+ readonly retryMessage?: string;
19
+ readonly pendingPermissions?: number;
20
+ readonly pendingQuestions?: number;
21
+ readonly contextPercent?: number | null;
22
+ readonly now: number;
23
+ };
24
+ export type StepId = "plan" | "work" | "review";
25
+ export type StepState = "done" | "skip" | "now" | "todo" | "warn";
26
+ export type MissionStep = {
27
+ readonly id: StepId;
28
+ readonly state: StepState;
29
+ };
30
+ export type MissionWarning = {
31
+ readonly kind: "blocker" | "decision" | "retry" | "permission" | "question" | "context" | "plan";
32
+ readonly text: string;
33
+ };
34
+ export type MissionCriterion = {
35
+ readonly id: string;
36
+ readonly status: string;
37
+ };
38
+ export type MissionGoal = {
39
+ readonly title: string;
40
+ readonly status: string;
41
+ readonly passed: number;
42
+ readonly total: number;
43
+ readonly criteria: readonly MissionCriterion[];
44
+ };
45
+ export type MissionEvent = {
46
+ readonly ago: string;
47
+ readonly kind: string;
48
+ readonly label: string;
49
+ };
50
+ export type MissionView = {
51
+ readonly active: boolean;
52
+ readonly discipline: string;
53
+ readonly phase: StepId | "done";
54
+ readonly steps: readonly MissionStep[];
55
+ readonly progress?: {
56
+ readonly done: number;
57
+ readonly total: number;
58
+ };
59
+ readonly next?: string;
60
+ readonly goal?: MissionGoal;
61
+ readonly blockers: readonly {
62
+ readonly id: string;
63
+ readonly detail: string;
64
+ readonly needsUserDecision: boolean;
65
+ }[];
66
+ readonly warnings: readonly MissionWarning[];
67
+ readonly timeline: readonly MissionEvent[];
68
+ readonly planSaved: boolean;
69
+ readonly complete: boolean;
70
+ /** Whole minutes since this session's first lit turn; only an active view carries it. */
71
+ readonly elapsedMinutes?: number;
72
+ };
73
+ export type MissionNotice = {
74
+ readonly variant: "info" | "success" | "warning" | "error";
75
+ readonly title: string;
76
+ readonly message: string;
77
+ readonly celebrate?: boolean;
78
+ };
79
+ export declare const contextWarnPercent = 75;
80
+ export declare const idleMission: MissionView;
81
+ /** Reads one part's `metadata.litopencode` mark. Anything without that shape is not a mark. */
82
+ export declare function readLitMark(metadata: unknown, at: number): LitMark | undefined;
83
+ type LedgerCriterion = {
84
+ readonly id: string;
85
+ readonly scenario: string;
86
+ readonly status: string;
87
+ readonly evidence: readonly {
88
+ readonly kind: string;
89
+ readonly ref: string;
90
+ readonly at: string;
91
+ }[];
92
+ };
93
+ type LedgerGoal = {
94
+ readonly title: string;
95
+ readonly status: string;
96
+ readonly createdAt: string;
97
+ readonly updatedAt: string;
98
+ readonly criteria: readonly LedgerCriterion[];
99
+ readonly checkpoints: readonly {
100
+ readonly summary: string;
101
+ readonly at: string;
102
+ }[];
103
+ readonly reviewBlockers: readonly {
104
+ readonly id: string;
105
+ readonly detail: string;
106
+ readonly resolved: boolean;
107
+ readonly needsUserDecision: boolean;
108
+ }[];
109
+ };
110
+ /** Lenient read of goals.json: the active goal, or undefined when the file is absent or not the ledger shape. */
111
+ export declare function readActiveGoal(raw: unknown): LedgerGoal | undefined;
112
+ export declare function formatAgo(at: string, now: number): string;
113
+ export declare function buildMissionView(input: MissionInput): MissionView;
114
+ /**
115
+ * OpenCode shows one toast at a time, so several gate events from one refresh collapse into the most
116
+ * urgent one (completion first), with the rest counted in its message. The panel still lists them all.
117
+ */
118
+ export declare function leadingNotice(notices: readonly MissionNotice[]): MissionNotice | undefined;
119
+ /** The gate events between two views. A first view never toasts its whole history. */
120
+ export declare function missionNotices(prev: MissionView | undefined, next: MissionView): readonly MissionNotice[];
121
+ /** The short status beside the header: always a glyph plus words, never colour alone. */
122
+ export declare function missionStatus(view: MissionView): {
123
+ readonly glyph: string;
124
+ readonly text: string;
125
+ readonly tone: "info" | "warning" | "success";
126
+ };
127
+ /**
128
+ * Stable keys for the panel rows a view shows. Motion compares two views' keys to decide which rows
129
+ * slide in, so a new goal animates its own rows without replaying the header.
130
+ */
131
+ export declare function missionRowKeys(view: MissionView): readonly string[];
132
+ /** The project's last goal for the home line, or undefined when the ledger holds none. */
133
+ export declare function lastMission(raw: unknown): {
134
+ readonly title: string;
135
+ readonly passed: number;
136
+ readonly total: number;
137
+ readonly complete: boolean;
138
+ } | undefined;
139
+ export {};
@@ -0,0 +1,318 @@
1
+ // The mission view model for the OpenCode TUI. It reads only what LitOpenCode already leaves behind:
2
+ // the `metadata.litopencode` marks the plugin attaches to user-message parts on activation and on
3
+ // commands, the host todo list, and the evidence ledger in .litopencode/litgoal/lit-loop/goals.json.
4
+ // No new state is written for the TUI.
5
+ export const contextWarnPercent = 75;
6
+ const timelineLimit = 4;
7
+ const staleGoalSlackMs = 60_000;
8
+ export const idleMission = Object.freeze({
9
+ active: false,
10
+ discipline: "",
11
+ phase: "plan",
12
+ steps: Object.freeze([
13
+ { id: "plan", state: "todo" },
14
+ { id: "work", state: "todo" },
15
+ { id: "review", state: "todo" }
16
+ ]),
17
+ blockers: Object.freeze([]),
18
+ warnings: Object.freeze([]),
19
+ timeline: Object.freeze([]),
20
+ planSaved: false,
21
+ complete: false
22
+ });
23
+ function isRecord(value) {
24
+ return typeof value === "object" && value !== null && !Array.isArray(value);
25
+ }
26
+ function text(value) {
27
+ return typeof value === "string" ? value : "";
28
+ }
29
+ /** Reads one part's `metadata.litopencode` mark. Anything without that shape is not a mark. */
30
+ export function readLitMark(metadata, at) {
31
+ if (!isRecord(metadata) || !isRecord(metadata.litopencode))
32
+ return undefined;
33
+ const mark = metadata.litopencode;
34
+ const mode = text(mark.mode);
35
+ const command = text(mark.command);
36
+ if (mode === "" && command === "")
37
+ return undefined;
38
+ return {
39
+ at,
40
+ ...(mode === "" ? {} : { mode }),
41
+ ...(command === "" ? {} : { command }),
42
+ ...(text(mark.operation) === "" ? {} : { operation: text(mark.operation) }),
43
+ ...(mark.blocked === true ? { blocked: true } : {})
44
+ };
45
+ }
46
+ function stepOf(name) {
47
+ if (name === "")
48
+ return undefined;
49
+ if (name === "review-work" || name.includes("review"))
50
+ return "review";
51
+ if (name === "lit-plan" || name.endsWith("-plan") || name === "lit-crucible" || name === "deep-interview")
52
+ return "plan";
53
+ return "work";
54
+ }
55
+ /** Lenient read of goals.json: the active goal, or undefined when the file is absent or not the ledger shape. */
56
+ export function readActiveGoal(raw) {
57
+ if (!isRecord(raw) || !Array.isArray(raw.goals))
58
+ return undefined;
59
+ const goal = raw.goals.find((candidate) => isRecord(candidate) && candidate.id === raw.activeGoalId);
60
+ if (!isRecord(goal))
61
+ return undefined;
62
+ const list = (value) => (Array.isArray(value) ? value.filter(isRecord) : []);
63
+ return {
64
+ title: text(goal.title) || text(goal.objective),
65
+ status: text(goal.status),
66
+ createdAt: text(goal.createdAt),
67
+ updatedAt: text(goal.updatedAt),
68
+ criteria: list(goal.criteria).map((criterion) => ({
69
+ id: text(criterion.id),
70
+ scenario: text(criterion.scenario),
71
+ status: text(criterion.status),
72
+ evidence: list(criterion.evidence).map((entry) => ({ kind: text(entry.kind), ref: text(entry.ref), at: text(entry.at) }))
73
+ })),
74
+ checkpoints: list(goal.checkpoints).map((entry) => ({ summary: text(entry.summary), at: text(entry.at) })),
75
+ reviewBlockers: list(goal.reviewBlockers).map((entry) => ({
76
+ id: text(entry.id),
77
+ detail: text(entry.detail),
78
+ resolved: entry.resolved === true,
79
+ needsUserDecision: entry.needsUserDecision === true
80
+ }))
81
+ };
82
+ }
83
+ export function formatAgo(at, now) {
84
+ const time = Date.parse(at);
85
+ if (!Number.isFinite(time))
86
+ return "";
87
+ const seconds = Math.max(0, Math.round((now - time) / 1000));
88
+ if (seconds < 60)
89
+ return "now";
90
+ const minutes = Math.round(seconds / 60);
91
+ if (minutes < 60)
92
+ return `${minutes}m`;
93
+ const hours = Math.round(minutes / 60);
94
+ if (hours < 48)
95
+ return `${hours}h`;
96
+ return `${Math.round(hours / 24)}d`;
97
+ }
98
+ function timeline(goal, now) {
99
+ const events = [];
100
+ for (const criterion of goal.criteria) {
101
+ for (const entry of criterion.evidence) {
102
+ events.push({ at: Date.parse(entry.at), raw: entry.at, kind: entry.kind, label: `${criterion.id} ${entry.ref}`.trim() });
103
+ }
104
+ }
105
+ for (const checkpoint of goal.checkpoints) {
106
+ events.push({ at: Date.parse(checkpoint.at), raw: checkpoint.at, kind: "checkpoint", label: checkpoint.summary });
107
+ }
108
+ if (goal.createdAt !== "")
109
+ events.push({ at: Date.parse(goal.createdAt), raw: goal.createdAt, kind: "goal", label: "created" });
110
+ return events
111
+ .filter((event) => Number.isFinite(event.at))
112
+ .sort((left, right) => right.at - left.at)
113
+ .slice(0, timelineLimit)
114
+ .map((event) => ({ ago: formatAgo(event.raw, now), kind: event.kind, label: event.label }));
115
+ }
116
+ export function buildMissionView(input) {
117
+ const marks = [...input.marks].sort((left, right) => left.at - right.at);
118
+ const agentStep = input.agent?.startsWith("lit-") === true ? stepOf(input.agent) : undefined;
119
+ const latest = marks.at(-1);
120
+ // The ledger is per project. A goal last touched before this session's first lit turn belongs to
121
+ // earlier work, so a fresh session does not open on someone else's finished goal.
122
+ const ledgerGoal = readActiveGoal(input.ledger);
123
+ const touched = ledgerGoal === undefined ? Number.NaN : Date.parse(ledgerGoal.updatedAt || ledgerGoal.createdAt);
124
+ const goal = ledgerGoal !== undefined && marks.length > 0 && !(touched < marks[0].at - staleGoalSlackMs) ? ledgerGoal : undefined;
125
+ // lit-loop is LitOpenCode's default agent, so the agent alone never lights the mission; only a lit
126
+ // activation or a lit command recorded in this session does.
127
+ const active = marks.length > 0;
128
+ if (!active)
129
+ return idleMission;
130
+ const planMark = [...marks].reverse().find((mark) => mark.operation === "save-plan");
131
+ const planSaved = planMark !== undefined && planMark.blocked !== true;
132
+ const planBlocked = planMark?.blocked === true;
133
+ const discipline = latest?.mode && latest.mode !== "bounded-authority-lifecycle" ? latest.mode : latest?.command ?? input.agent ?? "lit";
134
+ const counted = input.todos.filter((todo) => todo.status !== "cancelled");
135
+ const progress = counted.length > 0
136
+ ? { done: counted.filter((todo) => todo.status === "completed").length, total: counted.length }
137
+ : goal !== undefined && goal.criteria.length > 0
138
+ ? { done: goal.criteria.filter((criterion) => criterion.status === "pass").length, total: goal.criteria.length }
139
+ : undefined;
140
+ const nextTodo = counted.find((todo) => todo.status === "in_progress") ?? counted.find((todo) => todo.status === "pending");
141
+ const nextCriterion = goal?.criteria.find((criterion) => criterion.status !== "pass");
142
+ const blockers = (goal?.reviewBlockers ?? []).filter((blocker) => !blocker.resolved);
143
+ const complete = goal?.status === "complete" || (progress !== undefined && progress.total > 0 && progress.done === progress.total && goal === undefined && counted.length > 0);
144
+ let phase = latest === undefined ? agentStep ?? "work" : stepOf(latest.command ?? latest.mode ?? "") ?? "work";
145
+ if (latest?.operation === "save-plan" && !planBlocked)
146
+ phase = "work";
147
+ // A blocker sends finished work back to review; with criteria still open the work phase carries the warning.
148
+ const workComplete = progress === undefined || progress.done === progress.total;
149
+ if (blockers.length > 0 || goal?.status === "review_blocked")
150
+ phase = workComplete ? "review" : phase === "review" ? "review" : "work";
151
+ if (complete)
152
+ phase = "done";
153
+ const warnings = [];
154
+ for (const blocker of blockers) {
155
+ warnings.push({ kind: blocker.needsUserDecision ? "decision" : "blocker", text: `${blocker.id} ${blocker.detail}`.trim() });
156
+ }
157
+ if (goal?.status === "needs_user_decision" && !blockers.some((blocker) => blocker.needsUserDecision)) {
158
+ warnings.push({ kind: "decision", text: "goal needs your decision" });
159
+ }
160
+ if (planBlocked && phase === "plan")
161
+ warnings.push({ kind: "plan", text: "plan not saved yet" });
162
+ if (input.sessionStatus === "retry")
163
+ warnings.push({ kind: "retry", text: input.retryMessage ? `retrying · ${input.retryMessage}` : "retrying" });
164
+ if ((input.pendingPermissions ?? 0) > 0)
165
+ warnings.push({ kind: "permission", text: "waiting for permission" });
166
+ if ((input.pendingQuestions ?? 0) > 0)
167
+ warnings.push({ kind: "question", text: "waiting for your answer" });
168
+ if (typeof input.contextPercent === "number" && input.contextPercent >= contextWarnPercent) {
169
+ warnings.push({ kind: "context", text: `ctx ${Math.round(input.contextPercent)}%` });
170
+ }
171
+ const order = ["plan", "work", "review"];
172
+ const position = phase === "done" ? order.length : order.indexOf(phase);
173
+ const planned = planMark !== undefined || agentStep === "plan" || marks.some((mark) => stepOf(mark.command ?? mark.mode ?? "") === "plan");
174
+ const steps = order.map((id, index) => {
175
+ if (index < position)
176
+ return { id, state: id === "plan" && !planned ? "skip" : "done" };
177
+ if (index > position)
178
+ return { id, state: "todo" };
179
+ return { id, state: warnings.length > 0 ? "warn" : "now" };
180
+ });
181
+ return {
182
+ active: true,
183
+ discipline,
184
+ phase,
185
+ steps,
186
+ ...(progress === undefined ? {} : { progress }),
187
+ ...(nextTodo !== undefined ? { next: nextTodo.content } : nextCriterion !== undefined ? { next: `${nextCriterion.id} ${nextCriterion.scenario}`.trim() } : {}),
188
+ ...(goal === undefined
189
+ ? {}
190
+ : {
191
+ goal: {
192
+ title: goal.title,
193
+ status: goal.status,
194
+ passed: goal.criteria.filter((criterion) => criterion.status === "pass").length,
195
+ total: goal.criteria.length,
196
+ criteria: goal.criteria.map((criterion) => ({ id: criterion.id, status: criterion.status }))
197
+ }
198
+ }),
199
+ blockers: blockers.map((blocker) => ({ id: blocker.id, detail: blocker.detail, needsUserDecision: blocker.needsUserDecision })),
200
+ warnings,
201
+ timeline: goal === undefined ? [] : timeline(goal, input.now),
202
+ planSaved,
203
+ complete,
204
+ elapsedMinutes: Math.max(0, Math.floor((input.now - marks[0].at) / 60_000))
205
+ };
206
+ }
207
+ const noticeRank = { error: 3, warning: 2, success: 1, info: 0 };
208
+ /**
209
+ * OpenCode shows one toast at a time, so several gate events from one refresh collapse into the most
210
+ * urgent one (completion first), with the rest counted in its message. The panel still lists them all.
211
+ */
212
+ export function leadingNotice(notices) {
213
+ if (notices.length <= 1)
214
+ return notices[0];
215
+ const lead = [...notices].sort((left, right) => Number(right.celebrate === true) - Number(left.celebrate === true) || noticeRank[right.variant] - noticeRank[left.variant])[0];
216
+ return { ...lead, message: `${lead.message} · +${notices.length - 1} more` };
217
+ }
218
+ /** The gate events between two views. A first view never toasts its whole history. */
219
+ export function missionNotices(prev, next) {
220
+ if (prev === undefined || !next.active)
221
+ return [];
222
+ const notices = [];
223
+ if (prev.active && !prev.planSaved && next.planSaved) {
224
+ notices.push({ variant: "success", title: "🔥 Plan saved", message: "Work can start from the saved plan." });
225
+ }
226
+ if (!prev.warnings.some((warning) => warning.kind === "plan") && next.warnings.some((warning) => warning.kind === "plan")) {
227
+ notices.push({ variant: "warning", title: "🔥 Plan not saved yet", message: "Save the plan under .litopencode/plans before work starts." });
228
+ }
229
+ if (prev.goal === undefined && next.goal !== undefined) {
230
+ notices.push({ variant: "info", title: "🔥 Goal set", message: `${next.goal.title} · ${next.goal.total} criteria` });
231
+ }
232
+ if (prev.goal !== undefined && next.goal !== undefined) {
233
+ for (const criterion of next.goal.criteria) {
234
+ const before = prev.goal.criteria.find((candidate) => candidate.id === criterion.id);
235
+ if (criterion.status === "pass" && before?.status !== "pass") {
236
+ notices.push({ variant: "success", title: `✓ ${criterion.id} passed`, message: `${next.goal.passed}/${next.goal.total} criteria` });
237
+ }
238
+ }
239
+ }
240
+ for (const blocker of next.blockers) {
241
+ if (!prev.blockers.some((candidate) => candidate.id === blocker.id)) {
242
+ notices.push({
243
+ variant: "warning",
244
+ title: blocker.needsUserDecision ? "⚑ Needs your decision" : "⚑ Review blocker",
245
+ message: `${blocker.id} ${blocker.detail}`.trim()
246
+ });
247
+ }
248
+ }
249
+ for (const blocker of prev.blockers) {
250
+ if (!next.blockers.some((candidate) => candidate.id === blocker.id)) {
251
+ notices.push({ variant: "info", title: `✓ ${blocker.id} resolved`, message: blocker.detail });
252
+ }
253
+ }
254
+ if (!prev.complete && next.complete) {
255
+ const message = next.goal !== undefined
256
+ ? `${next.goal.title} · ${next.goal.passed}/${next.goal.total} criteria`
257
+ : `${next.progress?.done ?? 0}/${next.progress?.total ?? 0} tasks`;
258
+ notices.push({ variant: "success", title: "🔥 Complete", message, celebrate: true });
259
+ }
260
+ return notices;
261
+ }
262
+ const warningStatus = {
263
+ blocker: "review blocked",
264
+ decision: "needs decision",
265
+ retry: "retrying",
266
+ permission: "permission asked",
267
+ question: "question asked",
268
+ context: "context high",
269
+ plan: "plan not saved"
270
+ };
271
+ /** The short status beside the header: always a glyph plus words, never colour alone. */
272
+ export function missionStatus(view) {
273
+ if (view.complete)
274
+ return { glyph: "✓", text: "COMPLETE", tone: "success" };
275
+ const warning = view.warnings[0];
276
+ if (warning !== undefined)
277
+ return { glyph: "⚑", text: warning.kind === "context" ? warning.text : warningStatus[warning.kind], tone: "warning" };
278
+ return { glyph: "●", text: view.goal === undefined ? "working" : "active", tone: "info" };
279
+ }
280
+ /**
281
+ * Stable keys for the panel rows a view shows. Motion compares two views' keys to decide which rows
282
+ * slide in, so a new goal animates its own rows without replaying the header.
283
+ */
284
+ export function missionRowKeys(view) {
285
+ if (!view.active)
286
+ return [];
287
+ const keys = ["h1", "h2", "b1"];
288
+ if (view.goal !== undefined)
289
+ keys.push("title");
290
+ keys.push("steps");
291
+ if (view.progress !== undefined)
292
+ keys.push("bar");
293
+ const detail = (view.next !== undefined && !view.complete) || view.goal !== undefined || view.warnings.length > 0;
294
+ if (detail)
295
+ keys.push("b2");
296
+ if (view.next !== undefined && !view.complete)
297
+ keys.push("next");
298
+ if (view.goal !== undefined)
299
+ keys.push("crit");
300
+ view.warnings.forEach((warning) => keys.push(`warn:${warning.kind}:${warning.text}`));
301
+ if (view.timeline.length > 0) {
302
+ keys.push("b3", "evh");
303
+ view.timeline.forEach((_event, index) => keys.push(`ev${index}`));
304
+ }
305
+ return keys;
306
+ }
307
+ /** The project's last goal for the home line, or undefined when the ledger holds none. */
308
+ export function lastMission(raw) {
309
+ const goal = readActiveGoal(raw);
310
+ if (goal === undefined || goal.title === "")
311
+ return undefined;
312
+ return {
313
+ title: goal.title,
314
+ passed: goal.criteria.filter((criterion) => criterion.status === "pass").length,
315
+ total: goal.criteria.length,
316
+ complete: goal.status === "complete"
317
+ };
318
+ }
@@ -0,0 +1,66 @@
1
+ import { type MissionView } from "./mission-model.ts";
2
+ export declare const motionTiming: Readonly<{
3
+ stagger: 60;
4
+ entry: 160;
5
+ entryLead: 40;
6
+ fill: 300;
7
+ fillDelayOnGoal: 240;
8
+ noticeIn: 220;
9
+ crossfade: 140;
10
+ workDoneAt: 200;
11
+ reviewDoneAt: 260;
12
+ popAt: 300;
13
+ pop: 160;
14
+ shimmerAt: 300;
15
+ shimmer: 400;
16
+ settleAt: 1000;
17
+ complete: 1200;
18
+ frameMs: 33;
19
+ }>;
20
+ export declare function clamp01(value: number): number;
21
+ export declare const ease: Readonly<{
22
+ outCubic: (x: number) => number;
23
+ inCubic: (x: number) => number;
24
+ inOutCubic: (x: number) => number;
25
+ outBack: (x: number) => number;
26
+ }>;
27
+ /** 0..1 progress of a track that starts `start` ms after the change and lasts `duration` ms. */
28
+ export declare function seg(t: number, start: number, duration: number): number;
29
+ /** Cells a row is pushed right while it slides in; the ease-out-back overshoot lands it a cell left first. */
30
+ export declare function slideIn(progress: number, amplitude?: number): number;
31
+ export declare function mixHex(from: string, to: string, t: number): string;
32
+ export type MotionState = {
33
+ /** When the last state change happened; undefined while nothing has animated yet. */
34
+ readonly changeAt?: number;
35
+ /** Panel row keys that are new in this change and slide in, in order. */
36
+ readonly entering: readonly string[];
37
+ readonly chipEnter: boolean;
38
+ readonly barFrom: number;
39
+ readonly barTo: number;
40
+ readonly barDelay: number;
41
+ readonly warnFrom: number;
42
+ readonly warnTo: number;
43
+ readonly celebrate: boolean;
44
+ };
45
+ export type MotionFrame = {
46
+ /** Milliseconds since the last change; Infinity once settled or when motion is off. */
47
+ readonly t: number;
48
+ readonly entering: readonly string[];
49
+ readonly chipEnter: boolean;
50
+ readonly barRatio: number;
51
+ /** 0 = normal colours, 1 = warning colours. */
52
+ readonly warn: number;
53
+ /** Milliseconds into the completion moment while it plays, otherwise undefined. */
54
+ readonly celebrate?: number;
55
+ readonly moving: boolean;
56
+ };
57
+ export declare const idleMotion: MotionState;
58
+ /** Row entry with the approved stagger: rows of a fresh mission wait a short lead; later rows follow 60 ms apart. */
59
+ export declare function rowEntryStart(state: Pick<MotionState, "entering" | "chipEnter">, key: string): number | undefined;
60
+ /**
61
+ * Starts the tracks a state change calls for. A bar or warning already moving continues from where
62
+ * it is, so a burst of updates never makes either jump backwards.
63
+ */
64
+ export declare function planMotion(prev: MissionView | undefined, next: MissionView, now: number, state: MotionState, enabled?: boolean): MotionState;
65
+ export declare function sampleMotion(state: MotionState, now: number): MotionFrame;
66
+ export declare function restingFrameFor(view: MissionView): MotionFrame;
@@ -0,0 +1,147 @@
1
+ // Motion for the mission chip and panel. Everything here is a pure function of a clock value, so the
2
+ // TUI only runs a frame timer while one of these tracks is still moving and stays static otherwise.
3
+ import { missionRowKeys } from "./mission-model.js";
4
+ export const motionTiming = Object.freeze({
5
+ stagger: 60,
6
+ entry: 160,
7
+ entryLead: 40,
8
+ fill: 300,
9
+ fillDelayOnGoal: 240,
10
+ noticeIn: 220,
11
+ crossfade: 140,
12
+ workDoneAt: 200,
13
+ reviewDoneAt: 260,
14
+ popAt: 300,
15
+ pop: 160,
16
+ shimmerAt: 300,
17
+ shimmer: 400,
18
+ settleAt: 1000,
19
+ complete: 1200,
20
+ frameMs: 33
21
+ });
22
+ export function clamp01(value) {
23
+ return value <= 0 ? 0 : value >= 1 ? 1 : value;
24
+ }
25
+ export const ease = Object.freeze({
26
+ outCubic: (x) => 1 - Math.pow(1 - x, 3),
27
+ inCubic: (x) => x * x * x,
28
+ inOutCubic: (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2),
29
+ // Overshoots a little past 1 and settles back: the snap of a pill or row popping in.
30
+ outBack: (x) => {
31
+ const c1 = 1.70158;
32
+ const c3 = c1 + 1;
33
+ return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2);
34
+ }
35
+ });
36
+ /** 0..1 progress of a track that starts `start` ms after the change and lasts `duration` ms. */
37
+ export function seg(t, start, duration) {
38
+ if (duration <= 0)
39
+ return t >= start ? 1 : 0;
40
+ return clamp01((t - start) / duration);
41
+ }
42
+ /** Cells a row is pushed right while it slides in; the ease-out-back overshoot lands it a cell left first. */
43
+ export function slideIn(progress, amplitude = 6) {
44
+ return Math.round(amplitude * (1 - ease.outBack(progress)));
45
+ }
46
+ function hexChannels(hex) {
47
+ const clean = hex.replace(/^#/u, "");
48
+ const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean.slice(0, 6);
49
+ const value = Number.parseInt(full, 16);
50
+ if (!Number.isFinite(value) || full.length !== 6)
51
+ return [0, 0, 0];
52
+ return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
53
+ }
54
+ export function mixHex(from, to, t) {
55
+ const a = hexChannels(from);
56
+ const b = hexChannels(to);
57
+ const k = clamp01(t);
58
+ return "#" + a.map((channel, index) => Math.round(channel + (b[index] - channel) * k).toString(16).padStart(2, "0")).join("");
59
+ }
60
+ export const idleMotion = Object.freeze({
61
+ entering: Object.freeze([]),
62
+ chipEnter: false,
63
+ barFrom: 0,
64
+ barTo: 0,
65
+ barDelay: 0,
66
+ warnFrom: 0,
67
+ warnTo: 0,
68
+ celebrate: false
69
+ });
70
+ function ratio(view) {
71
+ const progress = view?.progress;
72
+ if (progress === undefined || progress.total === 0)
73
+ return 0;
74
+ return clamp01(progress.done / progress.total);
75
+ }
76
+ function warnTarget(view) {
77
+ return view !== undefined && view.warnings.length > 0 ? 1 : 0;
78
+ }
79
+ /** Row entry with the approved stagger: rows of a fresh mission wait a short lead; later rows follow 60 ms apart. */
80
+ export function rowEntryStart(state, key) {
81
+ const index = state.entering.indexOf(key);
82
+ if (index < 0)
83
+ return undefined;
84
+ return (state.chipEnter ? motionTiming.entryLead : 0) + index * motionTiming.stagger;
85
+ }
86
+ function rowDuration(key) {
87
+ return key.startsWith("warn:") ? motionTiming.noticeIn : motionTiming.entry;
88
+ }
89
+ function settleTime(state) {
90
+ let end = motionTiming.crossfade;
91
+ for (const key of state.entering)
92
+ end = Math.max(end, (rowEntryStart(state, key) ?? 0) + rowDuration(key));
93
+ if (state.chipEnter)
94
+ end = Math.max(end, 3 * motionTiming.stagger + motionTiming.entry);
95
+ if (state.barFrom !== state.barTo)
96
+ end = Math.max(end, state.barDelay + motionTiming.fill);
97
+ if (state.celebrate)
98
+ end = Math.max(end, motionTiming.complete);
99
+ return end;
100
+ }
101
+ /**
102
+ * Starts the tracks a state change calls for. A bar or warning already moving continues from where
103
+ * it is, so a burst of updates never makes either jump backwards.
104
+ */
105
+ export function planMotion(prev, next, now, state, enabled = true) {
106
+ const nextRatio = ratio(next);
107
+ const nextWarn = warnTarget(next);
108
+ if (!enabled || !next.active)
109
+ return { ...idleMotion, barFrom: nextRatio, barTo: nextRatio, warnFrom: nextWarn, warnTo: nextWarn };
110
+ const current = sampleMotion(state, now);
111
+ const appearing = prev === undefined || !prev.active;
112
+ const before = new Set(appearing ? [] : missionRowKeys(prev));
113
+ const entering = missionRowKeys(next).filter((key) => !before.has(key));
114
+ const goalArrived = !appearing && prev?.goal === undefined && next.goal !== undefined;
115
+ return {
116
+ changeAt: now,
117
+ entering,
118
+ chipEnter: appearing,
119
+ barFrom: appearing ? 0 : current.barRatio,
120
+ barTo: nextRatio,
121
+ barDelay: goalArrived || appearing ? motionTiming.fillDelayOnGoal : 0,
122
+ warnFrom: appearing ? nextWarn : current.warn,
123
+ warnTo: nextWarn,
124
+ celebrate: !appearing && prev?.complete !== true && next.complete
125
+ };
126
+ }
127
+ export function sampleMotion(state, now) {
128
+ if (state.changeAt === undefined) {
129
+ return { t: Number.POSITIVE_INFINITY, entering: [], chipEnter: false, barRatio: state.barTo, warn: state.warnTo, moving: false };
130
+ }
131
+ const t = now - state.changeAt;
132
+ const moving = t < settleTime(state);
133
+ const fill = ease.inOutCubic(seg(t, state.barDelay, motionTiming.fill));
134
+ const fade = ease.inOutCubic(seg(t, 0, motionTiming.crossfade));
135
+ return {
136
+ t: moving ? t : Number.POSITIVE_INFINITY,
137
+ entering: moving ? state.entering : [],
138
+ chipEnter: moving && state.chipEnter,
139
+ barRatio: clamp01(state.barFrom + (state.barTo - state.barFrom) * fill),
140
+ warn: clamp01(state.warnFrom + (state.warnTo - state.warnFrom) * fade),
141
+ ...(moving && state.celebrate ? { celebrate: t } : {}),
142
+ moving
143
+ };
144
+ }
145
+ export function restingFrameFor(view) {
146
+ return { t: Number.POSITIVE_INFINITY, entering: [], chipEnter: false, barRatio: ratio(view), warn: warnTarget(view), moving: false };
147
+ }