@litfamily/litopencode 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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ are tracked separately from this product history.
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 1.0.23 - 2026-10-13
9
+
10
+ - The README demo on GitHub is a new film of one real OpenCode turn with the workflow map. A numbered label names each part as it appears: the `lit` word, the LIT IGNITED toast, the card's map skipping interview and plan to light WORK, the new test, the running tool and its time beside the map (`bash · 0:00` while `npm test` runs), the passing test run and the card settling to IDLE when the turn ends. It still plays without music.
11
+
12
+ ## 1.0.22 - 2026-10-13
13
+
14
+ - The LIT card, the chip beside the prompt and the sidebar now show where you are in the workflow, not only which skill ran. A map runs `interview › plan › work › review › done` and lights the step you are on with its count, such as `WORK 3/7` while `/start-work` works through a saved plan. A lit-loop goal counts its criteria instead (`CRITERION 1/3`), and a litresearch run follows its own steps from `decompose` through each `wave` to `synthesis`. A step the request went past without doing shows `–`. The sidebar draws the map top to bottom with a line that fills as you move forward.
15
+ - While a tool runs, its name and running time sit beside the map, like `⠹ bash · 0:48`. Only the tool's name and start time are read, never its command or arguments, and the step disappears when the turn ends.
16
+ - A finished mission can now tell you so. Add `{ "notify": true }` to the LitOpenCode entry in `tui.json` for a desktop notification and the `done` sound, or turn either one off with `{ "notify": { "sound": false } }` or `{ "notify": { "desktop": false } }`. It is off until you add it, and OpenCode plays nothing unless its own `"attention": { "enabled": true }` is set too.
17
+ - The README pictures of the status card are new captures from OpenCode showing the workflow map, the live tool step and the card after the turn, in English and Korean.
18
+ - A lit-loop turn shows its criteria even when the agent writes `goals.json` by hand, as one goal at the top or nested under `goal`, with `completed` or `done` counted as passed. A goal marked complete by hand passed no gate, so review stays at `–`; only a goal completed through the ledger, whose gate includes review, marks it done. In the criterion view the card leaves out the separate criteria count, so the live step keeps its room on the row.
19
+
8
20
  ## 1.0.21 - 2026-10-12
9
21
 
10
22
  - The README demo on GitHub is a new film of one real OpenCode turn. It plays without music, and a numbered label names each part LitOpenCode adds as it appears: the `lit` word, the status card switching to WORK, the light mode for a small fix, the reply that reads before editing, the fix with its test, the real test run and the closing summary.
package/README-Ko-KR.md CHANGED
@@ -1,13 +1,13 @@
1
- <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/docs/assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 다섯 로봇 패널이 차례로 켜지고, LitOpenCode 로봇의 눈과 테두리가 빛난 뒤 LITFAMILY와 KEEP THE WORK LIT. 문구가 밝아지는 영상" /></picture></p>
1
+ <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/docs/assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 다섯 로봇 패널이 차례로 켜지고, LitOpenCode 로봇의 눈과 테두리가 빛난 뒤 LITFAMILY와 KEEP THE WORK LIT. 문구가 밝아지는 영상" /></picture></p>
2
2
 
3
3
  # LitOpenCode
4
4
 
5
5
  **LitOpenCode는 OpenCode용 워크플로 플러그인입니다. 프롬프트에 `lit`만 붙이면 계획하고, 만들고, 확인하고, 기록까지 남겨서 다음 세션이 어디서 멈췄는지 바로 압니다.**
6
6
 
7
7
  <p align="center">
8
- <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/docs/assets/readme/badge-version.svg" alt="1.0.21" /></a>
8
+ <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/docs/assets/readme/badge-version.svg" alt="1.0.23" /></a>
9
9
  <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://img.shields.io/npm/dm/@litfamily/litopencode?label=downloads&color=D7F75B" alt="월간 npm 다운로드 수" /></a>
10
- <a href="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/docs/assets/readme/badge-license.svg" alt="MIT 라이선스" /></a>
10
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/docs/assets/readme/badge-license.svg" alt="MIT 라이선스" /></a>
11
11
  <a href="https://github.com/wjgoarxiv/litopencode"><img src="https://img.shields.io/github/stars/wjgoarxiv/litopencode?style=social" alt="GitHub 스타" /></a>
12
12
  </p>
13
13
 
@@ -57,9 +57,9 @@ npm uninstall -g @litfamily/litopencode
57
57
  ## 더 보기
58
58
 
59
59
  - [GitHub의 사용 안내](https://github.com/wjgoarxiv/litopencode/blob/main/docs/guide-Ko-KR.md): 설치 옵션, 자동 인수인계, 문제 해결, 제거
60
- - [워크플로 상세 안내](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/docs/reference-Ko-KR.md) · [English reference](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/docs/reference.md)
61
- - [마이그레이션 안내](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/docs/migration.md) · [Changelog](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/CHANGELOG.md) · [개인정보와 네트워크 동작](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/docs/privacy.md)
62
- - [LitFamily 허브](https://github.com/wjgoarxiv/litfamily) · [MIT 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/LICENSE) · [ASCII 글꼴 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/docs/assets/readme/JetBrainsMono-OFL.txt)
60
+ - [워크플로 상세 안내](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/docs/reference-Ko-KR.md) · [English reference](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/docs/reference.md)
61
+ - [마이그레이션 안내](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/docs/migration.md) · [Changelog](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/CHANGELOG.md) · [개인정보와 네트워크 동작](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/docs/privacy.md)
62
+ - [LitFamily 허브](https://github.com/wjgoarxiv/litfamily) · [MIT 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/LICENSE) · [ASCII 글꼴 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/docs/assets/readme/JetBrainsMono-OFL.txt)
63
63
 
64
64
  <details>
65
65
  <summary>ASCII 로고 복사</summary>
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
- <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/docs/assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: five armored robots power on one by one, the LitOpenCode robot wakes with glowing eyes and a lit frame, then LITFAMILY and KEEP THE WORK LIT. light up." /></picture></p>
1
+ <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/docs/assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: five armored robots power on one by one, the LitOpenCode robot wakes with glowing eyes and a lit frame, then LITFAMILY and KEEP THE WORK LIT. light up." /></picture></p>
2
2
 
3
3
  # LitOpenCode
4
4
 
5
5
  **LitOpenCode is a workflow plugin for OpenCode. Add `lit` to a prompt and the work gets planned, built, checked and written down, so the next session knows where you stopped.**
6
6
 
7
7
  <p align="center">
8
- <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/docs/assets/readme/badge-version.svg" alt="1.0.21" /></a>
8
+ <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/docs/assets/readme/badge-version.svg" alt="1.0.23" /></a>
9
9
  <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://img.shields.io/npm/dm/@litfamily/litopencode?label=downloads&color=D7F75B" alt="npm downloads per month" /></a>
10
- <a href="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/docs/assets/readme/badge-license.svg" alt="MIT license" /></a>
10
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/docs/assets/readme/badge-license.svg" alt="MIT license" /></a>
11
11
  <a href="https://github.com/wjgoarxiv/litopencode"><img src="https://img.shields.io/github/stars/wjgoarxiv/litopencode?style=social" alt="GitHub stars" /></a>
12
12
  </p>
13
13
 
@@ -57,9 +57,9 @@ npm uninstall -g @litfamily/litopencode
57
57
  ## More
58
58
 
59
59
  - [Guide on GitHub](https://github.com/wjgoarxiv/litopencode/blob/main/docs/guide.md): install options, automatic handoff, troubleshooting and removal.
60
- - [Workflow reference](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/docs/reference.md)
61
- - [Migration notes](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/docs/migration.md) · [Changelog](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/CHANGELOG.md) · [Privacy and network behavior](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/docs/privacy.md)
62
- - [LitFamily hub](https://github.com/wjgoarxiv/litfamily) · [MIT license](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/LICENSE) · [ASCII font license](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.21/docs/assets/readme/JetBrainsMono-OFL.txt)
60
+ - [Workflow reference](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/docs/reference.md)
61
+ - [Migration notes](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/docs/migration.md) · [Changelog](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/CHANGELOG.md) · [Privacy and network behavior](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/docs/privacy.md)
62
+ - [LitFamily hub](https://github.com/wjgoarxiv/litfamily) · [MIT license](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/LICENSE) · [ASCII font license](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.23/docs/assets/readme/JetBrainsMono-OFL.txt)
63
63
 
64
64
  <details>
65
65
  <summary>Copy ASCII logo</summary>
@@ -11,6 +11,17 @@ export type BandOptions = {
11
11
  * `band: { card: true }` adds the detail card under the status card. Anything else keeps the defaults.
12
12
  */
13
13
  export declare function readBandOptions(options: unknown): BandOptions;
14
+ export type NotifyOptions = {
15
+ readonly enabled: boolean;
16
+ readonly desktop: boolean;
17
+ readonly sound: boolean;
18
+ };
19
+ /**
20
+ * Reads the finish alert switch from the plugin's tui.json options. It is off unless `notify` is true
21
+ * (desktop notification and the host's `done` sound) or an object that turns either part off with
22
+ * `desktop: false` or `sound: false`.
23
+ */
24
+ export declare function readNotifyOptions(options: unknown): NotifyOptions;
14
25
  /**
15
26
  * The skill chips row, exactly `width` cells: bright accent pills for the current request first, then
16
27
  * dim recent names, and `+N` for the ones that do not fit. The label goes before any chip does.
@@ -4,8 +4,8 @@
4
4
  // It reuses the chip's pills, the panel's bar and the shared motion frame, so the chip, the panel and
5
5
  // the band always move together.
6
6
  import { ease, mixHex, motionTiming, seg } from "./mission-motion.js";
7
- import { contextWarnPercent, missionStatus } from "./mission-model.js";
8
- import { cellWidth, chipSparkCells, chipSparkWidth, fade, lineWidth, missionPanel, progressBar, shimmerAt, shine, statusSegments, stepPill, truncateCells } from "./mission-text.js";
7
+ import { contextWarnPercent, currentStepIndex, missionStatus, stepName } from "./mission-model.js";
8
+ import { cellWidth, chipSparkCells, chipSparkWidth, crumb, fade, lineWidth, linkColor, liveSegments, missionPanel, progressBar, shimmerAt, shine, stageFlash, statusSegments, truncateCells } from "./mission-text.js";
9
9
  /**
10
10
  * Reads the band switches from the plugin's tui.json options: `band: false` turns the band off,
11
11
  * `band: { card: true }` adds the detail card under the status card. Anything else keeps the defaults.
@@ -17,12 +17,31 @@ export function readBandOptions(options) {
17
17
  const card = typeof band === "object" && band !== null && band.card === true;
18
18
  return { enabled: true, card };
19
19
  }
20
+ /**
21
+ * Reads the finish alert switch from the plugin's tui.json options. It is off unless `notify` is true
22
+ * (desktop notification and the host's `done` sound) or an object that turns either part off with
23
+ * `desktop: false` or `sound: false`.
24
+ */
25
+ export function readNotifyOptions(options) {
26
+ const notify = typeof options === "object" && options !== null ? options.notify : undefined;
27
+ if (notify === true)
28
+ return { enabled: true, desktop: true, sound: true };
29
+ if (typeof notify !== "object" || notify === null)
30
+ return { enabled: false, desktop: false, sound: false };
31
+ const record = notify;
32
+ const desktop = record.desktop !== false;
33
+ const sound = record.sound !== false;
34
+ return { enabled: desktop || sound, desktop, sound };
35
+ }
20
36
  const minimumText = 12;
21
37
  function elapsedText(minutes) {
22
38
  const total = Math.max(0, minutes ?? 0);
23
39
  return total < 60 ? `${total}m` : `${Math.floor(total / 60)}h${String(total % 60).padStart(2, "0")}`;
24
40
  }
25
- /** The text after the bar: a warning (⚑ and words) replaces the next task; a finished mission shows COMPLETE. */
41
+ /**
42
+ * The text after the bar: a warning (⚑ and words) replaces the live step and the next task; a finished
43
+ * mission shows COMPLETE. Otherwise the live step, then the next task when it still has room.
44
+ */
26
45
  function trailing(view, frame, palette, room) {
27
46
  if (view.complete) {
28
47
  // Before COMPLETE pops in, the slot stays empty rather than flashing the header's "● active".
@@ -37,9 +56,12 @@ function trailing(view, frame, palette, room) {
37
56
  const color = warning.kind === "retry" ? mixHex(palette.warning, palette.error, 0.5) : palette.warning;
38
57
  return [{ text: " ⚑ ", fg: color, bold: true }, { text: truncateCells(warning.text, room - 4), fg: color }];
39
58
  }
40
- if (view.next === undefined)
41
- return [];
42
- return [{ text: " ▸ ", fg: palette.muted }, { text: truncateCells(view.next, room - 4), fg: palette.text }];
59
+ const live = liveSegments(view, frame, palette, Math.min(28, room - 2));
60
+ const lead = live.length === 0 ? [] : [{ text: " ", fg: palette.muted }, ...live];
61
+ const left = room - lineWidth(lead);
62
+ if (view.next === undefined || left < minimumText)
63
+ return lead;
64
+ return [...lead, { text: " ▸ ", fg: palette.muted }, { text: truncateCells(view.next, left - 4), fg: palette.text }];
43
65
  }
44
66
  /** Cuts or pads a line to exactly `width` cells, keeping wide glyphs whole. */
45
67
  function fitLine(line, width, palette) {
@@ -105,7 +127,7 @@ function phaseLabel(view, frame, palette, rest) {
105
127
  return { text: "✓ COMPLETE", fg: palette.success, bold: true };
106
128
  if (status.tone === "warning")
107
129
  return { text: `⚑ ${status.text}`, fg: mixHex(palette.info, palette.warning, Math.max(frame.warn, 0.5)), bold: true };
108
- return { text: `● ${view.phase.toUpperCase()}`, fg: palette.info, bold: true };
130
+ return { text: `● ${stepName(view.phase, view).toUpperCase()}`, fg: palette.info, bold: true };
109
131
  }
110
132
  function topLine(view, frame, palette, width, border, rest) {
111
133
  const brand = [{ text: "🔥 ", fg: palette.text }, { text: "LIT", fg: shine(palette.primary, palette, 0.5, shimmerAt(frame), 0.6), bold: true }];
@@ -148,17 +170,22 @@ function bottomLine(view, figures, palette, width, border) {
148
170
  }
149
171
  return fitLine([{ text: "╰" + "─".repeat(Math.max(0, width)), fg: border }], width, palette);
150
172
  }
173
+ /** The whole stage map as a breadcrumb, `plan ✓ ━ WORK 1/3 ─ review ─ done`, or only the running stage when tight. */
151
174
  function trackPills(view, frame, palette, tight) {
152
175
  const alpha = (index) => (frame.chipEnter ? ease.outCubic(seg(frame.t, index * motionTiming.stagger, motionTiming.entry)) : 1);
153
- if (tight) {
154
- const current = view.steps.find((step) => step.state === "now" || step.state === "warn") ?? view.steps[view.complete ? 2 : view.active ? 1 : 0];
155
- return [fade(stepPill(current, view, frame, palette), alpha(0), palette)];
156
- }
176
+ const reached = currentStepIndex(view);
177
+ const current = Math.min(view.steps.length - 1, Math.max(0, reached));
178
+ const flash = stageFlash(frame);
179
+ if (tight)
180
+ return crumb(view, current, frame, palette, 0, flash).map((segment) => fade(segment, alpha(0), palette));
157
181
  const out = [];
158
- view.steps.forEach((step, index) => {
159
- if (index > 0)
160
- out.push(fade({ text: " ━ ", fg: view.steps[index - 1].state === "done" ? palette.success : palette.borderSubtle }, alpha(index), palette));
161
- out.push(fade(stepPill(step, view, frame, palette), alpha(index), palette));
182
+ view.steps.forEach((_step, index) => {
183
+ if (index > 0) {
184
+ const color = linkColor(view, index, frame, palette, palette.borderSubtle);
185
+ out.push(fade({ text: color === palette.borderSubtle ? " ─ " : " ━ ", fg: color }, alpha(index), palette));
186
+ }
187
+ for (const segment of crumb(view, index, frame, palette, 0, index === current && reached >= 0 ? flash : -1))
188
+ out.push(fade(segment, alpha(index), palette));
162
189
  });
163
190
  return out;
164
191
  }
@@ -186,7 +213,8 @@ function trackRow(view, frame, palette, width, last) {
186
213
  for (const plan of trackPlans) {
187
214
  const pills = trackPills(view, frame, palette, plan.tight);
188
215
  const bar = plan.bar > 0 && view.progress !== undefined ? [gap, ...progressBar(frame.barRatio, plan.bar, palette, sweep)] : [];
189
- const goal = view.goal;
216
+ // In the criterion loop the stage itself counts criteria, so the count is not repeated.
217
+ const goal = view.family === "loop" ? undefined : view.goal;
190
218
  const criteria = plan.criteria && goal !== undefined ? [gap, { text: `${goal.passed}/${goal.total} criteria`, fg: goal.total > 0 && goal.passed === goal.total ? palette.success : palette.muted }] : [];
191
219
  const fixed = lineWidth(pills) + lineWidth(bar) + lineWidth(criteria);
192
220
  if (fixed > width)
@@ -9,6 +9,34 @@ export type TodoInput = {
9
9
  readonly content: string;
10
10
  readonly status: string;
11
11
  };
12
+ /** The plan file's `- [ ]` / `- [x]` rows: how many are ticked, how many exist, and the first open one. */
13
+ export type PlanChecklist = {
14
+ readonly done: number;
15
+ readonly total: number;
16
+ readonly next?: string;
17
+ };
18
+ /** This session's bounded work from work-schema-3.json: its status, recorded progress turns and plan. */
19
+ export type WorkInput = {
20
+ readonly status: string;
21
+ readonly iterations: number;
22
+ readonly plan?: PlanChecklist;
23
+ };
24
+ export type ResearchStage = "decompose" | "wave" | "expand" | "verify" | "synthesis";
25
+ /** The latest litresearch run folder: what its journal, claim graph and report say so far. */
26
+ export type ResearchInput = {
27
+ readonly stage: ResearchStage;
28
+ readonly waves: number;
29
+ readonly claims: number;
30
+ readonly verified: number;
31
+ readonly reported: boolean;
32
+ /** Last write to the run folder, in ms; a run older than the session's first lit turn is earlier work. */
33
+ readonly touchedAt: number;
34
+ };
35
+ /** The tool the session is running now: its name and start time only, never its input. */
36
+ export type LiveStep = {
37
+ readonly tool: string;
38
+ readonly start: number;
39
+ };
12
40
  export type MissionInput = {
13
41
  readonly marks: readonly LitMark[];
14
42
  readonly agent?: string;
@@ -19,9 +47,17 @@ export type MissionInput = {
19
47
  readonly pendingPermissions?: number;
20
48
  readonly pendingQuestions?: number;
21
49
  readonly contextPercent?: number | null;
50
+ readonly work?: WorkInput;
51
+ readonly research?: ResearchInput;
52
+ readonly live?: LiveStep;
22
53
  readonly now: number;
23
54
  };
24
- export type StepId = "plan" | "work" | "review";
55
+ /**
56
+ * Which stage table the map follows: the main chain, lit-loop's criterion loop (the same stages with
57
+ * the work stage counting criteria), or a litresearch run.
58
+ */
59
+ export type MissionFamily = "main" | "loop" | "research";
60
+ export type StepId = "interview" | "plan" | "work" | "review" | "done" | ResearchStage;
25
61
  export type StepState = "done" | "skip" | "now" | "todo" | "warn";
26
62
  export type MissionStep = {
27
63
  readonly id: StepId;
@@ -50,13 +86,22 @@ export type MissionEvent = {
50
86
  export type MissionView = {
51
87
  readonly active: boolean;
52
88
  readonly discipline: string;
53
- readonly phase: StepId | "done";
89
+ readonly family: MissionFamily;
90
+ readonly phase: StepId;
54
91
  readonly steps: readonly MissionStep[];
55
92
  readonly progress?: {
56
93
  readonly done: number;
57
94
  readonly total: number;
58
95
  };
59
96
  readonly next?: string;
97
+ /** The lead of the detail line: `task 3`, or a research run's claim counts. */
98
+ readonly detail?: string;
99
+ /** Recorded turns of the current loop: bounded work progress, or lit-loop goal checkpoints. */
100
+ readonly iteration?: number;
101
+ /** How many tasks the saved plan holds, shown beside the plan stage. */
102
+ readonly planTasks?: number;
103
+ readonly wave?: number;
104
+ readonly live?: LiveStep;
60
105
  readonly goal?: MissionGoal;
61
106
  readonly blockers: readonly {
62
107
  readonly id: string;
@@ -77,9 +122,28 @@ export type MissionNotice = {
77
122
  readonly celebrate?: boolean;
78
123
  };
79
124
  export declare const contextWarnPercent = 75;
125
+ export declare const familySteps: Readonly<Record<MissionFamily, readonly StepId[]>>;
80
126
  export declare const idleMission: MissionView;
81
127
  /** Reads one part's `metadata.litopencode` mark. Anything without that shape is not a mark. */
82
128
  export declare function readLitMark(metadata: unknown, at: number): LitMark | undefined;
129
+ /** The name a stage shows: the loop counts criteria at its work stage and a research wave carries its number. */
130
+ export declare function stepName(id: StepId, view: Pick<MissionView, "family" | "wave">): string;
131
+ /** Index of the stage that is running (or flagged); the step count once complete; -1 while idle. */
132
+ export declare function currentStepIndex(view: Pick<MissionView, "steps" | "complete" | "active">): number;
133
+ /**
134
+ * Counts a plan's checkbox rows at column zero (`- [ ] 1. title — …`). The first open row, cut at its
135
+ * em dash, becomes the next item. Indented rows are not tasks.
136
+ */
137
+ export declare function planChecklist(text: string): PlanChecklist | undefined;
138
+ /**
139
+ * Lenient read of work-schema-3.json for one session: undefined when it is absent, another session's,
140
+ * cancelled, or not the schema-3 shape. Iterations count the recorded `work.progressed` turns.
141
+ */
142
+ export declare function readWorkState(raw: unknown, sessionID: string): {
143
+ readonly status: string;
144
+ readonly iterations: number;
145
+ readonly planPath: string;
146
+ } | undefined;
83
147
  type LedgerCriterion = {
84
148
  readonly id: string;
85
149
  readonly scenario: string;
@@ -106,8 +170,10 @@ type LedgerGoal = {
106
170
  readonly resolved: boolean;
107
171
  readonly needsUserDecision: boolean;
108
172
  }[];
173
+ /** Written by the ledger CLI, whose completion gate checks evidence; a hand-written goal passed none. */
174
+ readonly gated: boolean;
109
175
  };
110
- /** Lenient read of goals.json: the active goal, or undefined when the file is absent or not the ledger shape. */
176
+ /** Lenient read of goals.json: the goal it names, or undefined when there is none. */
111
177
  export declare function readActiveGoal(raw: unknown): LedgerGoal | undefined;
112
178
  export declare function formatAgo(at: string, now: number): string;
113
179
  export declare function buildMissionView(input: MissionInput): MissionView;
@@ -1,19 +1,22 @@
1
1
  // The mission view model for the OpenCode TUI. It reads only what LitOpenCode already leaves behind:
2
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.
3
+ // commands, the host todo list and running tool, the evidence ledger in
4
+ // .litopencode/litgoal/lit-loop/goals.json, the bounded work record work-schema-3.json with its plan
5
+ // checklist, and litresearch run folders. No new state is written for the TUI.
5
6
  export const contextWarnPercent = 75;
6
7
  const timelineLimit = 4;
7
8
  const staleGoalSlackMs = 60_000;
9
+ export const familySteps = Object.freeze({
10
+ main: Object.freeze(["interview", "plan", "work", "review", "done"]),
11
+ loop: Object.freeze(["interview", "plan", "work", "review", "done"]),
12
+ research: Object.freeze(["decompose", "wave", "expand", "verify", "synthesis"])
13
+ });
8
14
  export const idleMission = Object.freeze({
9
15
  active: false,
10
16
  discipline: "",
17
+ family: "main",
11
18
  phase: "plan",
12
- steps: Object.freeze([
13
- { id: "plan", state: "todo" },
14
- { id: "work", state: "todo" },
15
- { id: "review", state: "todo" }
16
- ]),
19
+ steps: Object.freeze(familySteps.main.map((id) => Object.freeze({ id, state: "todo" }))),
17
20
  blockers: Object.freeze([]),
18
21
  warnings: Object.freeze([]),
19
22
  timeline: Object.freeze([]),
@@ -46,29 +49,108 @@ export function readLitMark(metadata, at) {
46
49
  function stepOf(name) {
47
50
  if (name === "")
48
51
  return undefined;
52
+ if (name === "deep-interview" || name.endsWith("-interview"))
53
+ return "interview";
49
54
  if (name === "review-work" || name.includes("review"))
50
55
  return "review";
51
- if (name === "lit-plan" || name.endsWith("-plan") || name === "lit-crucible" || name === "deep-interview")
56
+ if (name === "lit-plan" || name.endsWith("-plan") || name === "lit-crucible")
52
57
  return "plan";
53
58
  return "work";
54
59
  }
55
- /** Lenient read of goals.json: the active goal, or undefined when the file is absent or not the ledger shape. */
60
+ function isResearchName(name) {
61
+ return name === "litresearch" || name === "lit-research";
62
+ }
63
+ /** The name a stage shows: the loop counts criteria at its work stage and a research wave carries its number. */
64
+ export function stepName(id, view) {
65
+ if (id === "work" && view.family === "loop")
66
+ return "criterion";
67
+ if (id === "wave")
68
+ return `wave ${Math.max(1, view.wave ?? 1)}`;
69
+ return id;
70
+ }
71
+ /** Index of the stage that is running (or flagged); the step count once complete; -1 while idle. */
72
+ export function currentStepIndex(view) {
73
+ if (!view.active)
74
+ return -1;
75
+ const index = view.steps.findIndex((step) => step.state === "now" || step.state === "warn");
76
+ return index >= 0 ? index : view.complete ? view.steps.length : -1;
77
+ }
78
+ /**
79
+ * Counts a plan's checkbox rows at column zero (`- [ ] 1. title — …`). The first open row, cut at its
80
+ * em dash, becomes the next item. Indented rows are not tasks.
81
+ */
82
+ export function planChecklist(text) {
83
+ let done = 0;
84
+ let total = 0;
85
+ let next;
86
+ for (const line of text.split(/\r?\n/u)) {
87
+ const match = /^- \[( |x|X)\] +(.*)$/u.exec(line);
88
+ if (match === null)
89
+ continue;
90
+ total += 1;
91
+ if (match[1] !== " ")
92
+ done += 1;
93
+ else if (next === undefined)
94
+ next = match[2].split(" — ")[0].trim();
95
+ }
96
+ if (total === 0)
97
+ return undefined;
98
+ return { done, total, ...(next === undefined || next === "" ? {} : { next }) };
99
+ }
100
+ /**
101
+ * Lenient read of work-schema-3.json for one session: undefined when it is absent, another session's,
102
+ * cancelled, or not the schema-3 shape. Iterations count the recorded `work.progressed` turns.
103
+ */
104
+ export function readWorkState(raw, sessionID) {
105
+ if (!isRecord(raw) || raw.schemaVersion !== 3 || raw.sessionID !== sessionID)
106
+ return undefined;
107
+ const status = text(raw.status);
108
+ if (status === "" || status === "cancelled")
109
+ return undefined;
110
+ const history = Array.isArray(raw.history) ? raw.history.filter(isRecord) : [];
111
+ const plan = isRecord(raw.plan) ? text(raw.plan.path) : "";
112
+ return { status, iterations: history.filter((entry) => entry.type === "work.progressed").length, planPath: plan };
113
+ }
114
+ // The ledger CLI accepts `passed`, `failed` and `completed` as spellings of its own statuses; a goals.json
115
+ // the agent writes by hand also marks a finished criterion `completed` or `done`.
116
+ const criterionStatusSpellings = { passed: "pass", completed: "pass", complete: "pass", done: "pass", failed: "fail" };
117
+ const goalStatusSpellings = { completed: "complete", done: "complete" };
118
+ /**
119
+ * Picks the goal out of goals.json: the ledger's active goal, else what an agent writes by hand —
120
+ * a goal nested under `goal`, the last entry of a `goals` list, or one goal object at the top.
121
+ */
122
+ function pickGoal(raw) {
123
+ const hasCriteria = (value) => isRecord(value) && Array.isArray(value.criteria);
124
+ if (Array.isArray(raw.goals)) {
125
+ if (raw.activeGoalId !== undefined) {
126
+ const active = raw.goals.find((candidate) => isRecord(candidate) && candidate.id === raw.activeGoalId);
127
+ return isRecord(active) ? active : undefined;
128
+ }
129
+ return raw.goals.filter(hasCriteria).at(-1);
130
+ }
131
+ if (hasCriteria(raw.goal))
132
+ return raw.goal;
133
+ return hasCriteria(raw) ? raw : undefined;
134
+ }
135
+ /** Lenient read of goals.json: the goal it names, or undefined when there is none. */
56
136
  export function readActiveGoal(raw) {
57
- if (!isRecord(raw) || !Array.isArray(raw.goals))
137
+ if (!isRecord(raw))
58
138
  return undefined;
59
- const goal = raw.goals.find((candidate) => isRecord(candidate) && candidate.id === raw.activeGoalId);
60
- if (!isRecord(goal))
139
+ const goal = pickGoal(raw);
140
+ if (goal === undefined)
61
141
  return undefined;
62
142
  const list = (value) => (Array.isArray(value) ? value.filter(isRecord) : []);
143
+ const status = text(goal.status);
63
144
  return {
145
+ gated: goal !== raw && Array.isArray(raw.goals) && raw.activeGoalId !== undefined,
64
146
  title: text(goal.title) || text(goal.objective),
65
- status: text(goal.status),
147
+ status: goalStatusSpellings[status] ?? status,
66
148
  createdAt: text(goal.createdAt),
67
149
  updatedAt: text(goal.updatedAt),
68
150
  criteria: list(goal.criteria).map((criterion) => ({
69
151
  id: text(criterion.id),
70
152
  scenario: text(criterion.scenario),
71
- status: text(criterion.status),
153
+ status: criterionStatusSpellings[text(criterion.status)] ?? text(criterion.status),
72
154
  evidence: list(criterion.evidence).map((entry) => ({ kind: text(entry.kind), ref: text(entry.ref), at: text(entry.at) }))
73
155
  })),
74
156
  checkpoints: list(goal.checkpoints).map((entry) => ({ summary: text(entry.summary), at: text(entry.at) })),
@@ -131,16 +213,27 @@ export function buildMissionView(input) {
131
213
  const planSaved = planMark !== undefined && planMark.blocked !== true;
132
214
  const planBlocked = planMark?.blocked === true;
133
215
  const discipline = latest?.mode && latest.mode !== "bounded-authority-lifecycle" ? latest.mode : latest?.command ?? input.agent ?? "lit";
216
+ // A research run counts when this session started it or wrote to it; an older folder is earlier work.
217
+ const research = input.research !== undefined && !(input.research.touchedAt < marks[0].at - staleGoalSlackMs) ? input.research : undefined;
218
+ const researching = isResearchName(discipline) || (research !== undefined && !research.reported);
219
+ const work = input.work;
134
220
  const counted = input.todos.filter((todo) => todo.status !== "cancelled");
221
+ const planList = work?.plan;
135
222
  const progress = counted.length > 0
136
223
  ? { 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;
224
+ : planList !== undefined
225
+ ? { done: planList.done, total: planList.total }
226
+ : goal !== undefined && goal.criteria.length > 0
227
+ ? { done: goal.criteria.filter((criterion) => criterion.status === "pass").length, total: goal.criteria.length }
228
+ : undefined;
229
+ const countsTasks = counted.length > 0 || planList !== undefined;
140
230
  const nextTodo = counted.find((todo) => todo.status === "in_progress") ?? counted.find((todo) => todo.status === "pending");
141
231
  const nextCriterion = goal?.criteria.find((criterion) => criterion.status !== "pass");
232
+ const family = researching ? "research" : !countsTasks && goal !== undefined && goal.criteria.length > 0 && /lit-?loop|litgoal/u.test(discipline) ? "loop" : "main";
142
233
  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);
234
+ const complete = researching
235
+ ? research?.reported === true
236
+ : goal?.status === "complete" || (progress !== undefined && progress.total > 0 && progress.done === progress.total && goal === undefined && countsTasks);
144
237
  let phase = latest === undefined ? agentStep ?? "work" : stepOf(latest.command ?? latest.mode ?? "") ?? "work";
145
238
  if (latest?.operation === "save-plan" && !planBlocked)
146
239
  phase = "work";
@@ -148,6 +241,8 @@ export function buildMissionView(input) {
148
241
  const workComplete = progress === undefined || progress.done === progress.total;
149
242
  if (blockers.length > 0 || goal?.status === "review_blocked")
150
243
  phase = workComplete ? "review" : phase === "review" ? "review" : "work";
244
+ if (researching)
245
+ phase = research?.stage ?? "decompose";
151
246
  if (complete)
152
247
  phase = "done";
153
248
  const warnings = [];
@@ -168,23 +263,51 @@ export function buildMissionView(input) {
168
263
  if (typeof input.contextPercent === "number" && input.contextPercent >= contextWarnPercent) {
169
264
  warnings.push({ kind: "context", text: `ctx ${Math.round(input.contextPercent)}%` });
170
265
  }
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");
266
+ const order = familySteps[family];
267
+ const position = complete ? order.length : order.indexOf(phase);
268
+ const visited = (id) => {
269
+ if (id === "interview")
270
+ return agentStep === "interview" || marks.some((mark) => stepOf(mark.command ?? mark.mode ?? "") === "interview");
271
+ if (id === "plan")
272
+ return planMark !== undefined || work !== undefined || agentStep === "plan" || marks.some((mark) => stepOf(mark.command ?? mark.mode ?? "") === "plan");
273
+ // Finished todos alone prove no review; a review command, a review blocker or the goal ledger's
274
+ // completion gate does.
275
+ if (id === "review")
276
+ return (goal?.status === "complete" && goal.gated) || blockers.length > 0 || (goal?.reviewBlockers.length ?? 0) > 0 || marks.some((mark) => stepOf(mark.command ?? mark.mode ?? "") === "review");
277
+ return true;
278
+ };
174
279
  const steps = order.map((id, index) => {
175
280
  if (index < position)
176
- return { id, state: id === "plan" && !planned ? "skip" : "done" };
281
+ return { id, state: visited(id) ? "done" : "skip" };
177
282
  if (index > position)
178
283
  return { id, state: "todo" };
179
284
  return { id, state: warnings.length > 0 ? "warn" : "now" };
180
285
  });
286
+ const iteration = work !== undefined && work.iterations > 0 ? work.iterations : family === "loop" && goal !== undefined && goal.checkpoints.length > 0 ? goal.checkpoints.length : undefined;
287
+ const nextPlan = nextTodo === undefined ? planList?.next : undefined;
288
+ const detail = family === "research"
289
+ ? research === undefined ? undefined : `${research.claims} claims · ${research.verified} verified`
290
+ : countsTasks && progress !== undefined && progress.done < progress.total ? `task ${progress.done + 1}` : undefined;
291
+ const live = input.live !== undefined && (input.sessionStatus === "busy" || input.sessionStatus === "retry") ? input.live : undefined;
181
292
  return {
182
293
  active: true,
183
294
  discipline,
295
+ family,
184
296
  phase,
185
297
  steps,
186
- ...(progress === undefined ? {} : { progress }),
187
- ...(nextTodo !== undefined ? { next: nextTodo.content } : nextCriterion !== undefined ? { next: `${nextCriterion.id} ${nextCriterion.scenario}`.trim() } : {}),
298
+ ...(family === "research" || progress === undefined ? {} : { progress }),
299
+ ...(family === "research"
300
+ ? {}
301
+ : nextTodo !== undefined
302
+ ? { next: nextTodo.content }
303
+ : nextPlan !== undefined
304
+ ? { next: nextPlan }
305
+ : nextCriterion !== undefined ? { next: `${nextCriterion.id} ${nextCriterion.scenario}`.trim() } : {}),
306
+ ...(detail === undefined ? {} : { detail }),
307
+ ...(iteration === undefined ? {} : { iteration }),
308
+ ...(planList === undefined ? {} : { planTasks: planList.total }),
309
+ ...(family === "research" && research !== undefined && research.waves > 0 ? { wave: research.waves } : {}),
310
+ ...(live === undefined ? {} : { live }),
188
311
  ...(goal === undefined
189
312
  ? {}
190
313
  : {
@@ -287,14 +410,17 @@ export function missionRowKeys(view) {
287
410
  const keys = ["h1", "h2", "b1"];
288
411
  if (view.goal !== undefined)
289
412
  keys.push("title");
290
- keys.push("steps");
413
+ view.steps.forEach((step, index) => keys.push(...(index === 0 ? [] : [`link:${index}`]), `step:${step.id}`));
291
414
  if (view.progress !== undefined)
292
415
  keys.push("bar");
293
- const detail = (view.next !== undefined && !view.complete) || view.goal !== undefined || view.warnings.length > 0;
294
- if (detail)
416
+ const line = (view.next !== undefined || view.detail !== undefined) && !view.complete;
417
+ const live = view.live !== undefined && !view.complete;
418
+ if (line || live || view.goal !== undefined || view.warnings.length > 0)
295
419
  keys.push("b2");
296
- if (view.next !== undefined && !view.complete)
297
- keys.push("next");
420
+ if (line)
421
+ keys.push("detail");
422
+ if (live)
423
+ keys.push("live");
298
424
  if (view.goal !== undefined)
299
425
  keys.push("crit");
300
426
  view.warnings.forEach((warning) => keys.push(`warn:${warning.kind}:${warning.text}`));
@@ -41,6 +41,10 @@ export type MotionState = {
41
41
  readonly warnFrom: number;
42
42
  readonly warnTo: number;
43
43
  readonly celebrate: boolean;
44
+ /** The stage index the map moved forward from; the connectors below it fill and the chip flashes once. */
45
+ readonly stageFrom?: number;
46
+ /** The stage that was running when the mission completed; it and the stages after it flip to done in turn. */
47
+ readonly celebrateFrom?: number;
44
48
  };
45
49
  export type MotionFrame = {
46
50
  /** Milliseconds since the last change; Infinity once settled or when motion is off. */
@@ -52,6 +56,11 @@ export type MotionFrame = {
52
56
  readonly warn: number;
53
57
  /** Milliseconds into the completion moment while it plays, otherwise undefined. */
54
58
  readonly celebrate?: number;
59
+ readonly celebrateFrom?: number;
60
+ /** While a stage change plays: the stage index the map moved forward from. */
61
+ readonly stageFrom?: number;
62
+ /** The clock this frame was sampled at, for the live step's elapsed time. */
63
+ readonly now?: number;
55
64
  readonly moving: boolean;
56
65
  };
57
66
  export declare const idleMotion: MotionState;
@@ -63,4 +72,4 @@ export declare function rowEntryStart(state: Pick<MotionState, "entering" | "chi
63
72
  */
64
73
  export declare function planMotion(prev: MissionView | undefined, next: MissionView, now: number, state: MotionState, enabled?: boolean): MotionState;
65
74
  export declare function sampleMotion(state: MotionState, now: number): MotionFrame;
66
- export declare function restingFrameFor(view: MissionView): MotionFrame;
75
+ export declare function restingFrameFor(view: MissionView, now?: number): MotionFrame;