@litfamily/lithermes 1.0.19 β†’ 1.0.21

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.
@@ -0,0 +1,206 @@
1
+ """One plain-text workflow map row for LIT replies, read from durable state.
2
+
3
+ The reply transform is the plugin's only user-visible channel, and Hermes
4
+ cannot colour it, so the row carries stage emoji and a block bar instead:
5
+
6
+ πŸ”₯ LIT Β· lit-loop β€” πŸ“ plan βœ“ β€Ί πŸ”¨ WORK β–°β–°β–°β–±β–±β–±β–± 3/7 β€Ί πŸ” Β· β€Ί 🏁 Β·
7
+
8
+ The newest durable record in the workspace anchors the row: a plan file under
9
+ plans/, a run state, a bounded-work state, or the litgoal goal (the session's
10
+ own store first, then the legacy one). A stage with no durable signal is left
11
+ out, and no signal at all draws no row. The row never carries a command, a
12
+ path, a plan name, or any argument. Reads are bounded and never write.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from pathlib import Path
18
+
19
+ try:
20
+ from .core_plans import FINAL_VERIFIER_ROW, IMPLEMENTATION_ROW, _outside_fence_lines
21
+ from .litgoal import store as _litgoal_store
22
+ except (ImportError, ModuleNotFoundError):
23
+ from core_plans import FINAL_VERIFIER_ROW, IMPLEMENTATION_ROW, _outside_fence_lines
24
+ from litgoal import store as _litgoal_store
25
+
26
+ MAX_PLAN_BYTES = 1_000_000
27
+ MAX_STATE_BYTES = 2_000_000
28
+ BAR_CELLS = 7
29
+ EMOJI = {"plan": "πŸ“", "work": "πŸ”¨", "review": "πŸ”", "done": "🏁"}
30
+ # Only the plan β†’ work β†’ review chain draws the map; a prose, office, motion or
31
+ # research reply would show an unrelated workflow.
32
+ WORKFLOW_ROUTES = frozenset({
33
+ "litwork", "lit-loop", "lit-plan", "start-work", "litgoal", "review-work",
34
+ "deep-interview", "lit-team", "lit-recap",
35
+ })
36
+
37
+
38
+ def _mtime(path: Path) -> float:
39
+ try:
40
+ return path.stat().st_mtime
41
+ except OSError:
42
+ return -1.0
43
+
44
+
45
+ def _newest(paths) -> Path | None:
46
+ return max(paths, key=_mtime, default=None)
47
+
48
+
49
+ def _json(path: Path | None) -> dict | None:
50
+ try:
51
+ if path is None or not path.is_file() or path.stat().st_size > MAX_STATE_BYTES:
52
+ return None
53
+ data = json.loads(path.read_text(encoding="utf-8"))
54
+ except (OSError, ValueError):
55
+ return None
56
+ return data if isinstance(data, dict) else None
57
+
58
+
59
+ def _resolved(raw: object) -> str:
60
+ try:
61
+ return str(Path(str(raw)).expanduser().resolve()) if raw else ""
62
+ except (OSError, ValueError):
63
+ return ""
64
+
65
+
66
+ def _plan_counts(raw: object) -> tuple[int, int, int, int] | None:
67
+ """(tasks done, tasks, checks done, checks) from a plan's column-zero rows."""
68
+ try:
69
+ path = Path(str(raw or "")).expanduser()
70
+ if not raw or path.suffix.lower() != ".md" or not path.is_file() or path.stat().st_size > MAX_PLAN_BYTES:
71
+ return None
72
+ text = path.read_text(encoding="utf-8")
73
+ except (OSError, ValueError):
74
+ return None
75
+ counts = [0, 0, 0, 0]
76
+ for _, line in _outside_fence_lines(text):
77
+ offset = 0 if IMPLEMENTATION_ROW.match(line) else 2 if FINAL_VERIFIER_ROW.match(line) else None
78
+ if offset is not None:
79
+ counts[offset + 1] += 1
80
+ counts[offset] += line[3] in "xX"
81
+ return tuple(counts)
82
+
83
+
84
+ def _goal(workspace: Path, session_id: str):
85
+ """(goals.json path, active goal) from the session's store, else the legacy one."""
86
+ try:
87
+ scopes = [session_id] if _litgoal_store.normalize_session_id(session_id) else []
88
+ except ValueError:
89
+ scopes = []
90
+ for scope in [*scopes, ""]:
91
+ path = _litgoal_store.goals_path(workspace, scope)
92
+ if not path.is_file():
93
+ continue
94
+ try:
95
+ goal = _litgoal_store.load_or_create(workspace, scope).active_goal()
96
+ except (OSError, ValueError):
97
+ return None
98
+ return (path, goal) if goal is not None else None
99
+ return None
100
+
101
+
102
+ def _bar(done: int, total: int) -> str:
103
+ filled = min(BAR_CELLS, done * BAR_CELLS // total)
104
+ return "β–°" * filled + "β–±" * (BAR_CELLS - filled)
105
+
106
+
107
+ def _stage(name: str, state: str, done: int = 0, total: int = 0) -> str:
108
+ emoji = EMOJI[name]
109
+ if state == "done":
110
+ return f"{emoji} {name} βœ“"
111
+ if state == "pending":
112
+ return f"{emoji} Β·"
113
+ counter = f" {_bar(done, total)} {done}/{total}" if total else ""
114
+ return f"{emoji} {name.upper()}{counter}"
115
+
116
+
117
+ def _progress_stages(plan: tuple | None, work: tuple[int, int] | None, finished: bool) -> list[str]:
118
+ """Stages after a plan exists: work from `work` or plan tasks, review from plan checks."""
119
+ tasks = work if work is not None else (plan[:2] if plan else None)
120
+ checks = plan[2:] if plan else None
121
+ stages: list[str] = []
122
+ work_done = finished
123
+ if tasks and tasks[1]:
124
+ work_done = finished or tasks[0] >= tasks[1]
125
+ stages.append(_stage("work", "done" if work_done else "current", *tasks))
126
+ review_done = work_done
127
+ if checks and checks[1]:
128
+ review_done = finished or (work_done and checks[0] >= checks[1])
129
+ state = "done" if review_done else "current" if work_done else "pending"
130
+ stages.append(_stage("review", state, *checks))
131
+ stages.append(_stage("done", "done" if finished or (work_done and review_done) else "pending"))
132
+ return stages
133
+
134
+
135
+ def _goal_stages(goal) -> list[str]:
136
+ total = len(goal.criteria)
137
+ passed = sum(1 for criterion in goal.criteria if criterion.status == "pass")
138
+ complete = goal.status == "complete"
139
+ blocked = goal.status == "review_blocked" or any(not b.resolved for b in goal.review_blockers)
140
+ stages = []
141
+ work_done = complete or (total > 0 and passed >= total)
142
+ if total:
143
+ stages.append(_stage("work", "done" if work_done else "current", passed, total))
144
+ review = "done" if complete else "current" if blocked or work_done else "pending"
145
+ stages.append(_stage("review", review))
146
+ stages.append(_stage("done", "done" if complete else "pending"))
147
+ return stages
148
+
149
+
150
+ def workflow_stages(workspace: Path, session_id: str = "") -> list[str] | None:
151
+ hermes = Path(workspace) / ".hermes" / "lithermes"
152
+ run_file = _newest((hermes / "runs").glob("*/state.json"))
153
+ work_file = _newest((hermes / "work").glob("*/state.json"))
154
+ # Checking off rows rewrites the plan a run executes; only a plan no run or
155
+ # work state points at can mean the user is planning again.
156
+ executing = {_resolved((_json(path) or {}).get("plan")) for path in (run_file, work_file) if path}
157
+ plan_file = _newest(path for path in (Path(workspace) / "plans").glob("*.md")
158
+ if path.is_file() and _resolved(path) not in executing)
159
+ goal = _goal(Path(workspace), str(session_id or ""))
160
+ candidates = [(path, kind) for path, kind in ((plan_file, "plan"), (run_file, "run"), (work_file, "work")) if path]
161
+ if goal:
162
+ candidates.append((goal[0], "goal"))
163
+ if not candidates:
164
+ return None
165
+ anchor, kind = max(candidates, key=lambda item: _mtime(item[0]))
166
+
167
+ if kind == "plan":
168
+ counts = _plan_counts(anchor)
169
+ stages = [_stage("plan", "current")]
170
+ if counts and counts[1]:
171
+ stages.append(_stage("work", "pending"))
172
+ if counts and counts[3]:
173
+ stages.append(_stage("review", "pending"))
174
+ return stages + [_stage("done", "pending")]
175
+ if kind == "goal":
176
+ run = _json(run_file) or {}
177
+ has_plan = _plan_counts(run.get("plan")) is not None
178
+ return ([_stage("plan", "done")] if has_plan else []) + _goal_stages(goal[1])
179
+ state = _json(anchor)
180
+ if state is None:
181
+ return None
182
+ counts = _plan_counts(state.get("plan"))
183
+ if kind == "run":
184
+ if counts is None or not (counts[1] or counts[3]):
185
+ return None
186
+ return [_stage("plan", "done"), *_progress_stages(counts, None, False)]
187
+ status = state.get("status")
188
+ items = state.get("progress") if isinstance(state.get("progress"), list) else []
189
+ if status not in ("active", "paused", "completed"):
190
+ return None
191
+ work = (sum(1 for item in items if isinstance(item, dict) and item.get("status") == "pass"), len(items)) if items else None
192
+ plan_tasks = (counts[0], counts[1], 0, 0) if counts else None
193
+ return [_stage("plan", "done"), *_progress_stages(plan_tasks, work, status == "completed")]
194
+
195
+
196
+ def workflow_row(route: str, workspace: Path, session_id: str = "") -> str | None:
197
+ """The map row for this LIT reply, or None when no stage has a durable signal."""
198
+ if route not in WORKFLOW_ROUTES:
199
+ return None
200
+ try:
201
+ stages = workflow_stages(workspace, session_id)
202
+ except (OSError, ValueError):
203
+ return None
204
+ if not stages:
205
+ return None
206
+ return f"πŸ”₯ LIT Β· {route} β€” " + " β€Ί ".join(stages)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litfamily/lithermes",
3
- "version": "1.0.19",
3
+ "version": "1.0.21",
4
4
  "description": "Hermes Agent plugin that plans, builds, checks and hands off your work when you add one word, lit, to a request.",
5
5
  "keywords": [
6
6
  "hermes-agent",
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.19"><title>release: 1.0.19</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.19</text></g></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.21"><title>release: 1.0.21</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.21</text></g></svg>
package/src/cli.js CHANGED
@@ -9,6 +9,8 @@ const { listHud, applyHud, clearHud, previewClearHud, previewHud, promptAccent,
9
9
  const { supportsColor, findAccent, skinName } = require("./lib/skins");
10
10
  const {
11
11
  readConfig,
12
+ readBellOnCompleteConfig,
13
+ setBellOnCompleteConfig,
12
14
  clearDisplaySkinConfig,
13
15
  readDisplaySkinConfig,
14
16
  readOutputStyleConfig,
@@ -37,7 +39,7 @@ const COMMAND_SPECS = {
37
39
  flags: {
38
40
  yes: "", "dry-run": "", offline: "", json: "", force: "", "reconfigure-model": "",
39
41
  provider: "ID", model: "ID", effort: "LEVEL", "child-provider": "ID", "child-model": "ID", "child-effort": "LEVEL",
40
- spinner: "", "no-spinner": "", hud: "<accent>", "no-hud": "", style: "ID", "no-style": "",
42
+ spinner: "", "no-spinner": "", hud: "<accent>", "no-hud": "", style: "ID", "no-style": "", bell: "", "no-bell": "",
41
43
  "patch-installed-hermes": "", "no-patch-installed-hermes": "", "no-auto-update": "", ...HOME, "hermes-repo": "PATH",
42
44
  },
43
45
  },
@@ -405,6 +407,57 @@ async function maybePickHudAccent(flags, runtime = {}, { promptForAccent = false
405
407
  if (entry) console.log(applyHud(entry.accent, flags));
406
408
  }
407
409
 
410
+ // Hermes rings the terminal bell (plus a desktop notice) at the end of a turn
411
+ // when display.bell_on_complete is true; its default is off. The installer sets
412
+ // it only on --bell or a "y" to the interactive question, never changes a value
413
+ // the user already chose, and leaves config.yaml alone when the edit is not
414
+ // clean or the file changed while the question was open.
415
+ async function maybeOfferBell(flags, runtime = {}, { prompt = false } = {}) {
416
+ if (flags["dry-run"] || flags["no-bell"]) return;
417
+ const say = flags.json ? console.error : console.log;
418
+ const home = resolveHome(flags);
419
+ const before = readConfig(home);
420
+ const current = readBellOnCompleteConfig(before);
421
+ const byHand = "set display.bell_on_complete: true by hand in config.yaml to hear a bell when a turn finishes.";
422
+ if (current === undefined) {
423
+ if (flags.bell) console.error(`Finish bell: config.yaml does not parse; left unchanged. ${byHand}`);
424
+ return;
425
+ }
426
+ if (current !== null) {
427
+ if (flags.bell) say(`Finish bell: kept your display.bell_on_complete: ${current}.`);
428
+ return;
429
+ }
430
+ let wanted = Boolean(flags.bell);
431
+ if (!wanted && prompt) {
432
+ const env = runtime.env || process.env;
433
+ const streams = runtime.streams || { stdin: process.stdin, stdout: process.stdout };
434
+ const input = streams.stdin || process.stdin;
435
+ const output = streams.stdout || process.stdout;
436
+ const interactive = runtime.isInteractive ?? Boolean(input.isTTY && output.isTTY);
437
+ if (!interactive || "CI" in env) return;
438
+ wanted = await new Promise((resolve) => {
439
+ const rl = readline.createInterface({ input, output, terminal: false });
440
+ rl.on("close", () => resolve(false));
441
+ rl.question("Ring the terminal bell when a Hermes turn finishes? [y/N] ", (answer) => {
442
+ resolve(/^\s*y(es)?\s*$/i.test(answer));
443
+ rl.close();
444
+ });
445
+ });
446
+ }
447
+ if (!wanted) return;
448
+ const after = setBellOnCompleteConfig(before);
449
+ if (after === null) {
450
+ console.error(`Finish bell: config.yaml has a shape the installer will not edit; left unchanged. ${byHand}`);
451
+ return;
452
+ }
453
+ if (readConfig(home) !== before) {
454
+ console.error(`Finish bell: config.yaml changed while asking; left unchanged. ${byHand}`);
455
+ return;
456
+ }
457
+ writeConfig(home, after);
458
+ say("Finish bell on: Hermes rings the terminal when a turn finishes (display.bell_on_complete).");
459
+ }
460
+
408
461
  async function main(argv, runtime = {}) {
409
462
  const parsed = parseArgs(argv);
410
463
  const { command, flags, positionals } = parsed;
@@ -496,6 +549,7 @@ async function main(argv, runtime = {}) {
496
549
  }
497
550
  await maybePickHudAccent(flags, runtime, { promptForAccent: prompted === "chosen" });
498
551
  await maybePickOutputStyle(flags);
552
+ await maybeOfferBell(flags, runtime, { prompt: prompted === "chosen" });
499
553
  scheduleUpdateSafely(command, flags, runtime);
500
554
  return;
501
555
  }
@@ -552,4 +606,4 @@ async function main(argv, runtime = {}) {
552
606
  console.log(helpText());
553
607
  }
554
608
 
555
- module.exports = { COMMAND_SPECS, helpText, main, parseArgs, validateArgs };
609
+ module.exports = { COMMAND_SPECS, helpText, main, maybeOfferBell, parseArgs, validateArgs };
package/src/lib/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const fs = require("node:fs");
2
2
  const path = require("node:path");
3
+ const { isDeepStrictEqual } = require("node:util");
3
4
  const { parseDocument } = require("yaml");
4
5
  const { writeFileAtomic } = require("./files");
5
6
  const modelConfig = require("./modelConfig");
@@ -181,6 +182,56 @@ function setTuiAgentsNudgeConfig(text) {
181
182
  return joinLines(lines, eol, ["display:", " tui_agents_nudge: true"]);
182
183
  }
183
184
 
185
+ // Hermes display.bell_on_complete (default false): BEL + desktop notice when a
186
+ // turn ends. null = unset, undefined = config.yaml does not parse.
187
+ function readBellOnCompleteConfig(text) {
188
+ const str = String(text || "");
189
+ if (!str.trim()) return null;
190
+ try {
191
+ const document = parseDocument(str, { strict: true, uniqueKeys: true });
192
+ if (document.errors.length) return undefined;
193
+ const value = document.toJS()?.display?.bell_on_complete;
194
+ return value === undefined || value === null ? null : value;
195
+ } catch {
196
+ return undefined;
197
+ }
198
+ }
199
+
200
+ // Add `bell_on_complete: true` only when unset. Returns null, and edits
201
+ // nothing, when the result would differ from the input by anything but that
202
+ // one key (an odd indent, a flow map, or YAML that does not parse).
203
+ function setBellOnCompleteConfig(text) {
204
+ const str = String(text || "");
205
+ const current = readBellOnCompleteConfig(str);
206
+ if (current === undefined) return null;
207
+ if (current !== null) return str;
208
+ const eol = eolOf(str);
209
+ const lines = str.split(/\r?\n/);
210
+ const block = findTopBlock(lines, "display");
211
+ let after;
212
+ if (block) {
213
+ openBlock(lines, block);
214
+ lines.splice(block.start + 1, 0, " bell_on_complete: true");
215
+ after = joinLines(lines, eol);
216
+ } else {
217
+ after = joinLines(lines, eol, ["display:", " bell_on_complete: true"]);
218
+ }
219
+ const parse = (value) => {
220
+ const document = parseDocument(value, { strict: true, uniqueKeys: true });
221
+ return document.errors.length ? undefined : document.toJS() || {};
222
+ };
223
+ const was = parse(str);
224
+ const now = parse(after);
225
+ if (!was || !now || now.display?.bell_on_complete !== true) return null;
226
+ delete now.display.bell_on_complete;
227
+ // An absent or empty (null) display block had nothing else in it.
228
+ if (!Object.keys(now.display).length && was.display == null) {
229
+ if (was.display === undefined) delete now.display;
230
+ else now.display = null;
231
+ }
232
+ return isDeepStrictEqual(was, now) ? after : null;
233
+ }
234
+
184
235
  function clearDisplaySkinConfig(text) {
185
236
  const lines = String(text || "").split(/\r?\n/);
186
237
  const block = findTopBlock(lines, "display");
@@ -296,6 +347,7 @@ module.exports = {
296
347
  disableLitHermesConfig,
297
348
  enableLitHermesConfig,
298
349
  readConfig,
350
+ readBellOnCompleteConfig,
299
351
  readDisplaySkinConfig,
300
352
  readExternalSkillDirsConfig,
301
353
  readStatusBarFields,
@@ -303,6 +355,7 @@ module.exports = {
303
355
  suggestStatusBarFields,
304
356
  readOutputStyleConfig,
305
357
  readTuiAgentsNudgeConfig,
358
+ setBellOnCompleteConfig,
306
359
  setDisplaySkinConfig,
307
360
  setOutputStyleConfig,
308
361
  setTuiAgentsNudgeConfig,
@@ -10,30 +10,36 @@ const LEGACY_SOL_MODEL = "gpt-5.6-sol";
10
10
  const LEGACY_CHILD_MODEL = "gpt-5.6-luna";
11
11
  const CHILD_EFFORT = "max";
12
12
  const GPT6_LUNA_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
13
- const LEGACY_LUNA_EFFORTS = new Set(["high", "max"]);
13
+ const LEGACY_LUNA_EFFORTS = GPT6_LUNA_EFFORTS;
14
14
  const LUNA_EFFORTS = LEGACY_LUNA_EFFORTS;
15
15
  const LUNA_MODELS = new Set([CHILD_MODEL, LEGACY_CHILD_MODEL]);
16
16
  const TERRA_MODEL = "gpt-5.6-terra";
17
- const TERRA_EFFORTS = new Set(["high", "xhigh", "max"]);
18
- const LEGACY_SOL_EFFORTS = new Set(["high", "xhigh"]);
19
17
  const MODEL_EFFORTS = new Map([
20
18
  [ASTRA_MODEL, GPT6_LEAD_EFFORTS],
21
19
  [SOL_MODEL, GPT6_LEAD_EFFORTS],
22
20
  [PREVIOUS_SOL_MODEL, GPT6_LEAD_EFFORTS],
23
21
  [CHILD_MODEL, GPT6_LUNA_EFFORTS],
24
- [LEGACY_SOL_MODEL, LEGACY_SOL_EFFORTS],
22
+ [LEGACY_SOL_MODEL, GPT6_LEAD_EFFORTS],
25
23
  [LEGACY_CHILD_MODEL, LEGACY_LUNA_EFFORTS],
26
24
  ["gpt-5.6", new Set(["high"])],
27
- [TERRA_MODEL, TERRA_EFFORTS],
25
+ [TERRA_MODEL, GPT6_LEAD_EFFORTS],
28
26
  ]);
27
+ // When only a model is given, these keep the effort they resolved to before their
28
+ // full effort sets were listed, so widening a menu never moves a default.
29
+ const MODEL_ONLY_EFFORTS = {
30
+ lead: new Map([[LEGACY_CHILD_MODEL, CHILD_EFFORT]]),
31
+ child: new Map([[LEGACY_SOL_MODEL, "high"]]),
32
+ };
29
33
  const CHILD_TRANSPORT_KEYS = ["provider", "base_url", "api_mode"];
30
34
  const { OPENAI_PROVIDER: OPENAI_PROVIDER_ID } = require("./modelConfigBoundary");
31
35
  const XAI_PROVIDER_ID = "xai";
32
36
  // Installer choice catalog (plans/references/installer-choice-contract.md, LitHermes row).
33
- // OpenAI rows follow each model's listed effort set, including selectable legacy
34
- // GPT-5.6 routes. xAI rows use the reasoning-capable models of the Hermes 0.19 catalog
35
- // (hermes_cli/models.py `_XAI_STATIC_FALLBACK`), newest first, with the Hermes
36
- // reasoning_effort vocabulary (hermes_cli/config.py delegation.reasoning_effort).
37
+ // OpenAI rows offer every effort the live model catalog lists for each model
38
+ // (supported_reasoning_levels), including the selectable legacy GPT-5.6 routes. Hermes
39
+ // accepts each of them in config but may send a lower level (ultra as max; max and ultra
40
+ // as xhigh on gpt-6.1-sol, gpt-6-sol and gpt-6-luna). xAI rows use the reasoning-capable models
41
+ // of the Hermes 0.19 catalog (hermes_cli/models.py `_XAI_STATIC_FALLBACK`), newest first,
42
+ // with the efforts Hermes sends to Grok before 4.6 (agent/reasoning_effort.py XAI_LEGACY_EFFORTS).
37
43
  const ROUTE_CATALOG = {
38
44
  [OPENAI_PROVIDER_ID]: {
39
45
  credentialEnv: null,
@@ -67,18 +73,25 @@ const ROUTE_CATALOG = {
67
73
  ? "balanced, lighter effort"
68
74
  : "Luna Responses effort",
69
75
  })),
70
- ...["high", "xhigh"].map((effort) => ({
76
+ ...[...GPT6_LEAD_EFFORTS].map((effort) => ({
71
77
  effort,
72
78
  model: LEGACY_SOL_MODEL,
73
79
  note: "previous-generation Sol lead route",
74
80
  })),
75
- ...["high", "xhigh", "max"].map((effort) => ({
81
+ ...[...GPT6_LEAD_EFFORTS].map((effort) => ({
76
82
  effort,
77
83
  model: TERRA_MODEL,
78
84
  note: "previous-generation Terra route",
79
85
  })),
80
- { effort: CHILD_EFFORT, model: LEGACY_CHILD_MODEL, note: "previous-generation balanced helper" },
81
- { effort: "high", model: LEGACY_CHILD_MODEL, note: "previous-generation lighter helper" },
86
+ ...[...GPT6_LUNA_EFFORTS].map((effort) => ({
87
+ effort,
88
+ model: LEGACY_CHILD_MODEL,
89
+ note: effort === CHILD_EFFORT
90
+ ? "previous-generation balanced helper"
91
+ : effort === "high"
92
+ ? "previous-generation lighter helper"
93
+ : "previous-generation Luna effort",
94
+ })),
82
95
  { effort: "high", model: "gpt-5.6", note: "general model" },
83
96
  ],
84
97
  lead: { effort: PARENT_EFFORT, model: PARENT_MODEL },
@@ -88,11 +101,15 @@ const ROUTE_CATALOG = {
88
101
  credentialEnv: "XAI_API_KEY",
89
102
  label: "xAI Grok",
90
103
  rows: [
91
- { effort: "high", model: "grok-build-0.1", note: "newest coding model (recommended lead)" },
92
- { effort: "high", model: "grok-4.5", note: "flagship reasoning" },
93
- { effort: "high", model: "grok-4.3", note: "fast reasoning (recommended helper)" },
94
- { effort: "high", model: "grok-4.20-0309-reasoning", note: "previous reasoning generation" },
95
- ],
104
+ ["grok-build-0.1", "newest coding model (recommended lead)"],
105
+ ["grok-4.5", "flagship reasoning"],
106
+ ["grok-4.3", "fast reasoning (recommended helper)"],
107
+ ["grok-4.20-0309-reasoning", "previous reasoning generation"],
108
+ ].flatMap(([model, note]) => ["low", "medium", "high"].map((effort) => ({
109
+ effort,
110
+ model,
111
+ note: effort === "high" ? note : "Grok reasoning effort",
112
+ }))),
96
113
  lead: { effort: "high", model: "grok-build-0.1" },
97
114
  helper: { effort: "high", model: "grok-4.3" },
98
115
  },
@@ -137,28 +154,19 @@ function inspectEffectiveRouteSafety(document, expectedProvider) {
137
154
  if (modelEfforts && !modelEfforts.has(route.effort)) {
138
155
  const isAstra = route.model === ASTRA_MODEL;
139
156
  const isLuna = LUNA_MODELS.has(route.model);
140
- const isLegacyLunaXhigh = route.model === LEGACY_CHILD_MODEL && route.effort === "xhigh";
141
157
  return {
142
158
  classificationClass: isAstra
143
159
  ? "astra_effort_unsupported"
144
- : isLegacyLunaXhigh
145
- ? "conflicting_luna_xhigh"
146
- : isLuna
147
- ? "luna_effort_below_high"
148
- : route.model === TERRA_MODEL
149
- ? "terra_effort_below_high"
150
- : "model_effort_unsupported",
160
+ : isLuna
161
+ ? "luna_effort_unsupported"
162
+ : "model_effort_unsupported",
151
163
  code: UNSAFE_ROUTE_CODE,
152
164
  observedSource: `${route.route} model, effort, and provider config`,
153
165
  reason: isAstra
154
166
  ? `${route.route} Astra effort is unsupported or missing; choose one of: ${[...modelEfforts].join(", ")}`
155
- : isLegacyLunaXhigh
156
- ? `${route.route} Luna xhigh is a forbidden pair (legacy Luna runs at high or max)`
157
- : isLuna
158
- ? `${route.route} Luna effort is unsupported; choose one of: ${[...modelEfforts].join(", ")}`
159
- : route.model === TERRA_MODEL
160
- ? `${route.route} TERRA effort is below high or missing`
161
- : `${route.route} ${route.model} effort is unsupported or missing; choose one of: ${[...modelEfforts].join(", ")}`,
167
+ : isLuna
168
+ ? `${route.route} Luna effort is unsupported; choose one of: ${[...modelEfforts].join(", ")}`
169
+ : `${route.route} ${route.model} effort is unsupported or missing; choose one of: ${[...modelEfforts].join(", ")}`,
162
170
  route: route.route,
163
171
  status: "blocked",
164
172
  };
@@ -202,9 +210,6 @@ function resolveRow(provider, role, model, effort) {
202
210
  const hasModel = model !== undefined;
203
211
  const hasEffort = effort !== undefined;
204
212
  const resolvedModel = hasModel ? String(model) : preset.model;
205
- if (resolvedModel === LEGACY_CHILD_MODEL && hasEffort && effort === "xhigh") {
206
- throw new Error(`${role} route ${resolvedModel} xhigh is a forbidden pair; legacy Luna runs at high or max`);
207
- }
208
213
  const rowsForModel = catalog.rows.filter((row) => row.model === resolvedModel);
209
214
  if (!rowsForModel.length) {
210
215
  const known = PROVIDER_IDS.some((id) => ROUTE_CATALOG[id].rows.some((row) => row.model === resolvedModel));
@@ -215,7 +220,9 @@ function resolveRow(provider, role, model, effort) {
215
220
  // An explicitly selected model keeps that model's cataloged preset effort
216
221
  // when no effort flag is supplied. This matters for Astra: its recommended
217
222
  // lead row is xhigh, while its catalog is intentionally listed low-first.
218
- const modelDefault = rowsForModel.find((row) => row.effort === preset.effort)?.effort ?? rowsForModel[0].effort;
223
+ const modelDefault = MODEL_ONLY_EFFORTS[role]?.get(resolvedModel)
224
+ ?? rowsForModel.find((row) => row.effort === preset.effort)?.effort
225
+ ?? rowsForModel[0].effort;
219
226
  const resolvedEffort = hasEffort ? String(effort) : (hasModel ? modelDefault : preset.effort);
220
227
  if (!rowsForModel.some((row) => row.effort === resolvedEffort)) {
221
228
  throw new Error(`${role} effort ${resolvedEffort} is not offered for ${resolvedModel}; choose one of: ${rowsForModel.map((row) => row.effort).join(", ")}`);
package/src/lib/skins.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const fs = require("node:fs");
2
2
  const path = require("node:path");
3
3
  const { colorMode, skinLogo, microLogo } = require("./litMark");
4
+ const { writeFileAtomic } = require("./files");
4
5
 
5
6
  // LitHermes HUD accents β€” 10 presets that drive Hermes' native skin engine.
6
7
  // Each becomes ~/.hermes/skins/lithermes-<accent>.yaml; activated via
@@ -179,7 +180,31 @@ function recolorMarkup(rows, palette) {
179
180
  // get_color path, dark_colors/light_colors are the TUI polarity pair, and
180
181
  // spinner wings must be [left, right] pairs (get_spinner_wings drops single
181
182
  // glyphs, so the brief's flat wing list is shipped as two pairs).
182
- function renderSkinYaml(entry) {
183
+ // Hermes picks one face at random for each thinking step and each tool call,
184
+ // so the four frames read as a flame moving through "lit" across a turn.
185
+ const FLAME_FACES = '["(πŸ”₯ lit)", "( πŸ”₯lit)", "(litπŸ”₯ )", "(lit πŸ”₯)"]';
186
+ const SPINNER_LINES = Object.freeze([
187
+ ` waiting_faces: ${FLAME_FACES}`,
188
+ ` thinking_faces: ${FLAME_FACES}`,
189
+ " thinking_verbs: [igniting, lit-looping, planning, verifying, gathering evidence, checking criteria, reviewing]",
190
+ ]);
191
+ // Spinner blocks earlier releases wrote, newest first. A preset whose spinner
192
+ // block still matches one of them byte for byte gets the flame faces on the
193
+ // next install; everything else in the file, and any edited spinner, stays.
194
+ const SHIPPED_SPINNERS = Object.freeze([
195
+ Object.freeze([
196
+ ' waiting_faces: ["(lit)", "(β—œ)", "(◝)", "(β—ž)", "(β—Ÿ)"]',
197
+ ' thinking_faces: ["(lit)", "(✦)", "(β—‡)", "(⌁)"]',
198
+ " thinking_verbs: [lit-looping, planning, verifying, gathering evidence, checking criteria, reviewing]",
199
+ ]),
200
+ Object.freeze([
201
+ ' waiting_faces: ["πŸ”₯", "πŸ”₯", "πŸ”₯", "πŸ”₯"]',
202
+ " thinking_verbs: [igniting, forging, burning, tempering]",
203
+ ]),
204
+ ]);
205
+ const spinnerBlock = (lines) => ["spinner:", ...lines, " wings:"].join("\n");
206
+
207
+ function renderSkinYaml(entry, { spinner = SPINNER_LINES } = {}) {
183
208
  const h = entry.hex;
184
209
  const theme = entry.palette;
185
210
  const colorBlocks = theme
@@ -200,9 +225,7 @@ function renderSkinYaml(entry) {
200
225
  `description: LitHermes ${entry.label} accent HUD`,
201
226
  ...colorBlocks,
202
227
  "spinner:",
203
- ' waiting_faces: ["(lit)", "(β—œ)", "(◝)", "(β—ž)", "(β—Ÿ)"]',
204
- ' thinking_faces: ["(lit)", "(✦)", "(β—‡)", "(⌁)"]',
205
- " thinking_verbs: [lit-looping, planning, verifying, gathering evidence, checking criteria, reviewing]",
228
+ ...spinner,
206
229
  " wings:",
207
230
  ' - ["β—œ", "◝"]',
208
231
  ' - ["β—ž", "β—Ÿ"]',
@@ -249,11 +272,22 @@ function installSkins(hermesHome) {
249
272
  written.push(file);
250
273
  } catch (error) {
251
274
  if (error.code !== "EEXIST") throw error;
275
+ if (upgradeShippedSpinner(file)) written.push(file);
252
276
  }
253
277
  }
254
278
  return written;
255
279
  }
256
280
 
281
+ function upgradeShippedSpinner(file) {
282
+ const stat = fs.lstatSync(file, { throwIfNoEntry: false });
283
+ if (!stat || !stat.isFile() || stat.size > 64 * 1024) return false;
284
+ const text = fs.readFileSync(file, "utf8");
285
+ const shipped = SHIPPED_SPINNERS.map(spinnerBlock).find((block) => text.split(block).length === 2);
286
+ if (!shipped) return false;
287
+ writeFileAtomic(file, text.replace(shipped, spinnerBlock(SPINNER_LINES)), "utf8");
288
+ return true;
289
+ }
290
+
257
291
  function hexToRgb(hex) {
258
292
  const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(String(hex));
259
293
  if (!m) return null;
@@ -286,6 +320,7 @@ module.exports = {
286
320
  skinName,
287
321
  skinsDir,
288
322
  renderSkinYaml,
323
+ SHIPPED_SPINNERS,
289
324
  findAccent,
290
325
  installSkins,
291
326
  hexToRgb,