mulmoclaude 0.1.2 → 0.4.0

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 (251) hide show
  1. package/bin/mulmoclaude.js +7 -24
  2. package/client/assets/html2canvas-Cx501zZr-Cv5snK9D.js +5 -0
  3. package/client/assets/index-CubzmCVK.css +2 -0
  4. package/client/assets/{index-D8rhwXLq.js → index-DtcyExH9.js} +80 -61
  5. package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-DnizuhIY.js} +5 -5
  6. package/client/index.html +2 -4
  7. package/package.json +13 -13
  8. package/server/agent/attachmentConverter.ts +2 -2
  9. package/server/agent/config.ts +12 -12
  10. package/server/agent/index.ts +9 -3
  11. package/server/agent/mcp-server.ts +19 -19
  12. package/server/agent/mcp-tools/index.ts +6 -6
  13. package/server/agent/mcp-tools/x.ts +7 -6
  14. package/server/agent/prompt.ts +195 -29
  15. package/server/agent/resumeFailover.ts +5 -5
  16. package/server/agent/sandboxMounts.ts +10 -10
  17. package/server/agent/stream.ts +4 -4
  18. package/server/api/auth/bearerAuth.ts +3 -3
  19. package/server/api/auth/token.ts +2 -2
  20. package/server/api/routes/agent.ts +21 -3
  21. package/server/api/routes/config.ts +1 -1
  22. package/server/api/routes/files.ts +22 -21
  23. package/server/api/routes/html.ts +2 -2
  24. package/server/api/routes/image.ts +7 -7
  25. package/server/api/routes/mulmo-script.ts +33 -31
  26. package/server/api/routes/pdf.ts +2 -2
  27. package/server/api/routes/plugins.ts +16 -6
  28. package/server/api/routes/roles.ts +2 -2
  29. package/server/api/routes/scheduler.ts +14 -12
  30. package/server/api/routes/schedulerHandlers.ts +12 -12
  31. package/server/api/routes/schedulerTasks.ts +19 -17
  32. package/server/api/routes/sessions.ts +26 -26
  33. package/server/api/routes/sessionsCursor.ts +4 -4
  34. package/server/api/routes/skills.ts +5 -5
  35. package/server/api/routes/sources.ts +3 -3
  36. package/server/api/routes/todosColumnsHandlers.ts +30 -30
  37. package/server/api/routes/todosHandlers.ts +1 -1
  38. package/server/api/routes/todosItemsHandlers.ts +14 -14
  39. package/server/api/routes/wiki.ts +36 -22
  40. package/server/api/sandboxStatus.ts +1 -1
  41. package/server/events/notifications.ts +6 -6
  42. package/server/events/pub-sub/index.ts +3 -3
  43. package/server/events/relay-client.ts +17 -16
  44. package/server/events/scheduler-adapter.ts +20 -20
  45. package/server/events/session-store/index.ts +10 -10
  46. package/server/events/task-manager/index.ts +7 -7
  47. package/server/index.ts +59 -65
  48. package/server/system/config.ts +5 -5
  49. package/server/system/credentials.ts +7 -5
  50. package/server/system/env.ts +5 -5
  51. package/server/utils/date.ts +18 -18
  52. package/server/utils/files/atomic.ts +16 -16
  53. package/server/utils/files/html-io.ts +5 -5
  54. package/server/utils/files/image-store.ts +19 -8
  55. package/server/utils/files/journal-io.ts +4 -4
  56. package/server/utils/files/json.ts +5 -5
  57. package/server/utils/files/markdown-store.ts +4 -4
  58. package/server/utils/files/naming.ts +2 -2
  59. package/server/utils/files/reference-dirs-io.ts +3 -3
  60. package/server/utils/files/roles-io.ts +12 -12
  61. package/server/utils/files/safe.ts +14 -14
  62. package/server/utils/files/scheduler-io.ts +5 -5
  63. package/server/utils/files/scheduler-overrides-io.ts +2 -2
  64. package/server/utils/files/session-io.ts +35 -35
  65. package/server/utils/files/spreadsheet-store.ts +7 -7
  66. package/server/utils/files/todos-io.ts +9 -9
  67. package/server/utils/files/user-tasks-io.ts +5 -5
  68. package/server/utils/files/workspace-io.ts +12 -12
  69. package/server/utils/gemini.ts +2 -2
  70. package/server/utils/gitignore.ts +9 -9
  71. package/server/utils/json.ts +5 -5
  72. package/server/utils/logBackgroundError.ts +12 -3
  73. package/server/utils/markdown.ts +5 -5
  74. package/server/utils/port.d.mts +6 -0
  75. package/server/utils/port.mjs +48 -0
  76. package/server/utils/request.ts +12 -6
  77. package/server/utils/spawn.ts +1 -1
  78. package/server/utils/types.ts +2 -2
  79. package/server/workspace/chat-index/indexer.ts +15 -15
  80. package/server/workspace/chat-index/summarizer.ts +4 -4
  81. package/server/workspace/custom-dirs.ts +16 -16
  82. package/server/workspace/journal/archivist.ts +35 -35
  83. package/server/workspace/journal/dailyPass.ts +31 -28
  84. package/server/workspace/journal/diff.ts +2 -2
  85. package/server/workspace/journal/index.ts +4 -4
  86. package/server/workspace/journal/indexFile.ts +29 -25
  87. package/server/workspace/journal/optimizationPass.ts +2 -2
  88. package/server/workspace/journal/state.ts +6 -6
  89. package/server/workspace/paths.ts +3 -3
  90. package/server/workspace/reference-dirs.ts +20 -20
  91. package/server/workspace/roles.ts +6 -6
  92. package/server/workspace/skills/discovery.ts +4 -4
  93. package/server/workspace/skills/parser.ts +6 -6
  94. package/server/workspace/skills/scheduler.ts +3 -3
  95. package/server/workspace/skills/user-tasks.ts +34 -34
  96. package/server/workspace/skills/writer.ts +3 -3
  97. package/server/workspace/sources/arxivDiscovery.ts +10 -10
  98. package/server/workspace/sources/classifier.ts +7 -7
  99. package/server/workspace/sources/fetchers/arxiv.ts +7 -7
  100. package/server/workspace/sources/fetchers/githubIssues.ts +7 -7
  101. package/server/workspace/sources/fetchers/githubReleases.ts +7 -7
  102. package/server/workspace/sources/fetchers/rss.ts +5 -5
  103. package/server/workspace/sources/fetchers/rssParser.ts +4 -4
  104. package/server/workspace/sources/interests.ts +12 -12
  105. package/server/workspace/sources/paths.ts +6 -6
  106. package/server/workspace/sources/pipeline/fetch.ts +36 -13
  107. package/server/workspace/sources/pipeline/index.ts +8 -13
  108. package/server/workspace/sources/pipeline/notify.ts +3 -3
  109. package/server/workspace/sources/pipeline/plan.ts +15 -13
  110. package/server/workspace/sources/pipeline/write.ts +5 -5
  111. package/server/workspace/sources/rateLimiter.ts +1 -1
  112. package/server/workspace/sources/registry.ts +16 -16
  113. package/server/workspace/sources/robots.ts +14 -14
  114. package/server/workspace/sources/sourceState.ts +17 -10
  115. package/server/workspace/sources/types.ts +9 -0
  116. package/server/workspace/sources/urls.ts +1 -1
  117. package/server/workspace/tool-trace/classify.ts +4 -4
  118. package/server/workspace/tool-trace/index.ts +1 -1
  119. package/server/workspace/tool-trace/writeSearch.ts +26 -16
  120. package/server/workspace/wiki-backlinks/index.ts +8 -8
  121. package/server/workspace/wiki-backlinks/sessionBacklinks.ts +15 -15
  122. package/server/workspace/workspace.ts +7 -7
  123. package/src/App.vue +315 -141
  124. package/src/components/CanvasViewToggle.vue +10 -7
  125. package/src/components/ChatInput.vue +67 -33
  126. package/src/components/FileContentHeader.vue +7 -4
  127. package/src/components/FileContentRenderer.vue +20 -6
  128. package/src/components/FileTree.vue +6 -3
  129. package/src/components/FileTreePane.vue +11 -8
  130. package/src/components/FilesView.vue +5 -3
  131. package/src/components/LockStatusPopup.vue +17 -14
  132. package/src/components/NotificationBell.vue +14 -5
  133. package/src/components/NotificationToast.vue +6 -3
  134. package/src/components/PluginLauncher.vue +19 -56
  135. package/src/components/RightSidebar.vue +13 -10
  136. package/src/components/RoleSelector.vue +2 -2
  137. package/src/components/SessionHistoryPanel.vue +38 -34
  138. package/src/components/SessionTabBar.vue +8 -10
  139. package/src/components/SettingsMcpTab.vue +49 -36
  140. package/src/components/SettingsModal.vue +24 -22
  141. package/src/components/SettingsReferenceDirsTab.vue +39 -34
  142. package/src/components/SettingsWorkspaceDirsTab.vue +37 -27
  143. package/src/components/SidebarHeader.vue +25 -4
  144. package/src/components/StackView.vue +4 -1
  145. package/src/components/SuggestionsPanel.vue +7 -4
  146. package/src/components/TodoExplorer.vue +26 -15
  147. package/src/components/ToolResultsPanel.vue +27 -13
  148. package/src/components/todo/TodoAddDialog.vue +19 -14
  149. package/src/components/todo/TodoEditDialog.vue +7 -2
  150. package/src/components/todo/TodoEditPanel.vue +17 -12
  151. package/src/components/todo/TodoKanbanView.vue +10 -5
  152. package/src/components/todo/TodoListView.vue +10 -7
  153. package/src/components/todo/TodoTableView.vue +5 -2
  154. package/src/composables/useAppApi.ts +9 -0
  155. package/src/composables/useClickOutside.ts +2 -2
  156. package/src/composables/useDynamicFavicon.ts +172 -37
  157. package/src/composables/useEventListeners.ts +7 -8
  158. package/src/composables/useFaviconState.ts +13 -2
  159. package/src/composables/useFileSelection.ts +24 -6
  160. package/src/composables/useFreshPluginData.ts +3 -3
  161. package/src/composables/useKeyNavigation.ts +11 -11
  162. package/src/composables/useLayoutMode.ts +32 -0
  163. package/src/composables/useMcpTools.ts +2 -2
  164. package/src/composables/useNotifications.ts +3 -3
  165. package/src/composables/usePdfDownload.ts +4 -4
  166. package/src/composables/usePendingCalls.ts +1 -1
  167. package/src/composables/usePubSub.ts +10 -10
  168. package/src/composables/useRoles.ts +1 -1
  169. package/src/composables/useSandboxStatus.ts +1 -1
  170. package/src/composables/useSessionDerived.ts +3 -3
  171. package/src/composables/useSessionHistory.ts +7 -17
  172. package/src/composables/useSessionSync.ts +8 -8
  173. package/src/composables/useViewLayout.ts +20 -34
  174. package/src/config/roles.ts +2 -2
  175. package/src/lang/de.ts +536 -0
  176. package/src/lang/en.ts +558 -0
  177. package/src/lang/es.ts +543 -0
  178. package/src/lang/fr.ts +536 -0
  179. package/src/lang/ja.ts +536 -0
  180. package/src/lang/ko.ts +540 -0
  181. package/src/lang/pt-BR.ts +534 -0
  182. package/src/lang/zh.ts +537 -0
  183. package/src/lib/vue-i18n.ts +97 -0
  184. package/src/main.ts +2 -0
  185. package/src/plugins/canvas/View.vue +102 -186
  186. package/src/plugins/canvas/definition.ts +0 -8
  187. package/src/plugins/chart/Preview.vue +5 -5
  188. package/src/plugins/chart/View.vue +9 -4
  189. package/src/plugins/manageRoles/Preview.vue +4 -1
  190. package/src/plugins/manageRoles/View.vue +59 -43
  191. package/src/plugins/manageSkills/Preview.vue +8 -3
  192. package/src/plugins/manageSkills/View.vue +29 -25
  193. package/src/plugins/manageSource/Preview.vue +2 -2
  194. package/src/plugins/manageSource/View.vue +73 -52
  195. package/src/plugins/markdown/Preview.vue +1 -1
  196. package/src/plugins/markdown/View.vue +26 -36
  197. package/src/plugins/presentHtml/Preview.vue +1 -1
  198. package/src/plugins/presentHtml/View.vue +7 -4
  199. package/src/plugins/presentHtml/helpers.ts +8 -8
  200. package/src/plugins/presentMulmoScript/Preview.vue +1 -1
  201. package/src/plugins/presentMulmoScript/View.vue +40 -30
  202. package/src/plugins/presentMulmoScript/helpers.ts +1 -1
  203. package/src/plugins/scheduler/Preview.vue +13 -10
  204. package/src/plugins/scheduler/TasksTab.vue +57 -28
  205. package/src/plugins/scheduler/View.vue +28 -19
  206. package/src/plugins/scheduler/formatSchedule.ts +93 -0
  207. package/src/plugins/spreadsheet/Preview.vue +8 -3
  208. package/src/plugins/spreadsheet/View.vue +21 -12
  209. package/src/plugins/textResponse/Preview.vue +15 -58
  210. package/src/plugins/textResponse/View.vue +29 -9
  211. package/src/plugins/todo/Preview.vue +13 -8
  212. package/src/plugins/todo/View.vue +38 -24
  213. package/src/plugins/todo/composables/useTodos.ts +5 -5
  214. package/src/plugins/ui-image/ImagePreview.vue +6 -3
  215. package/src/plugins/ui-image/ImageView.vue +7 -4
  216. package/src/plugins/wiki/Preview.vue +10 -7
  217. package/src/plugins/wiki/View.vue +202 -81
  218. package/src/plugins/wiki/helpers.ts +4 -4
  219. package/src/plugins/wiki/route.ts +112 -0
  220. package/src/router/guards.ts +46 -28
  221. package/src/router/index.ts +41 -26
  222. package/src/types/session.ts +4 -3
  223. package/src/types/vue-i18n.d.ts +20 -0
  224. package/src/utils/agent/request.ts +22 -3
  225. package/src/utils/canvas/layoutMode.ts +26 -0
  226. package/src/utils/dom/scrollable.ts +2 -2
  227. package/src/utils/files/expandedDirs.ts +1 -1
  228. package/src/utils/files/sortChildren.ts +6 -6
  229. package/src/utils/format/frontmatter.ts +6 -6
  230. package/src/utils/image/cacheBust.ts +16 -0
  231. package/src/utils/image/resolve.ts +16 -0
  232. package/src/utils/image/rewriteMarkdownImageRefs.ts +5 -5
  233. package/src/utils/markdown/extractFirstH1.ts +2 -2
  234. package/src/utils/path/relativeLink.ts +15 -15
  235. package/src/utils/path/workspaceLinkRouter.ts +81 -0
  236. package/src/utils/role/icon.ts +2 -2
  237. package/src/utils/role/merge.ts +2 -2
  238. package/src/utils/role/plugins.ts +1 -1
  239. package/src/utils/session/sessionFactory.ts +2 -2
  240. package/src/utils/session/sessionHelpers.ts +2 -2
  241. package/src/utils/tools/dedup.ts +4 -4
  242. package/src/utils/tools/result.ts +3 -3
  243. package/src/utils/types.ts +2 -2
  244. package/src/vite-env.d.ts +9 -0
  245. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
  246. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
  247. package/client/assets/index-KNLBjwuh.css +0 -1
  248. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
  249. package/src/composables/useCanvasViewMode.ts +0 -121
  250. package/src/utils/canvas/viewMode.ts +0 -46
  251. /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
@@ -52,8 +52,8 @@ export async function readManifest(workspaceRoot: string): Promise<ChatIndexMani
52
52
 
53
53
  function isManifest(raw: unknown): raw is ChatIndexManifest {
54
54
  if (!isRecord(raw)) return false;
55
- const o = raw as Record<string, unknown>;
56
- return o.version === 1 && Array.isArray(o.entries);
55
+ const manifestRecord = raw as Record<string, unknown>;
56
+ return manifestRecord.version === 1 && Array.isArray(manifestRecord.entries);
57
57
  }
58
58
 
59
59
  // In-process mutex serializing the read-modify-write sequence on
@@ -64,7 +64,7 @@ function isManifest(raw: unknown): raw is ChatIndexManifest {
64
64
  // this module's single-process assumption.
65
65
  let manifestMutex: Promise<void> = Promise.resolve();
66
66
 
67
- async function withManifestLock<T>(fn: () => Promise<T>): Promise<T> {
67
+ async function withManifestLock<T>(lockedFn: () => Promise<T>): Promise<T> {
68
68
  const prev = manifestMutex;
69
69
  let release: () => void = () => {};
70
70
  manifestMutex = new Promise<void>((resolve) => {
@@ -72,7 +72,7 @@ async function withManifestLock<T>(fn: () => Promise<T>): Promise<T> {
72
72
  });
73
73
  try {
74
74
  await prev;
75
- return await fn();
75
+ return await lockedFn();
76
76
  } finally {
77
77
  release();
78
78
  }
@@ -83,12 +83,12 @@ async function withManifestLock<T>(fn: () => Promise<T>): Promise<T> {
83
83
  // already serializes callers within this process, but a unique
84
84
  // name means the rename can't collide even if a stray .tmp file
85
85
  // is left behind by a previous crashed run.
86
- async function writeManifestAtomic(workspaceRoot: string, m: ChatIndexManifest): Promise<void> {
86
+ async function writeManifestAtomic(workspaceRoot: string, manifest: ChatIndexManifest): Promise<void> {
87
87
  // `uniqueTmp` belt-and-suspenders: the in-process mutex above
88
88
  // already serializes callers, but a unique tmp name means the
89
89
  // rename can't collide even if a stray .tmp file is left behind
90
90
  // by a previous crashed run.
91
- await writeJsonAtomic(manifestPathFor(workspaceRoot), m, {
91
+ await writeJsonAtomic(manifestPathFor(workspaceRoot), manifest, {
92
92
  uniqueTmp: true,
93
93
  });
94
94
  }
@@ -117,9 +117,9 @@ export async function isFresh(workspaceRoot: string, sessionId: string, now: num
117
117
  if (!isRecord(entry)) return false;
118
118
  const indexedAt = (entry as Record<string, unknown>).indexedAt;
119
119
  if (typeof indexedAt !== "string") return false;
120
- const ts = Date.parse(indexedAt);
121
- if (Number.isNaN(ts)) return false;
122
- return now - ts < minIntervalMs;
120
+ const indexedTimestamp = Date.parse(indexedAt);
121
+ if (Number.isNaN(indexedTimestamp)) return false;
122
+ return now - indexedTimestamp < minIntervalMs;
123
123
  } catch {
124
124
  return false;
125
125
  }
@@ -137,10 +137,10 @@ async function readSessionMeta(workspaceRoot: string, sessionId: string): Promis
137
137
  const raw = await readFile(sessionMetaPathFor(workspaceRoot, sessionId), "utf-8");
138
138
  const parsed: unknown = JSON.parse(raw);
139
139
  if (!isRecord(parsed)) return {};
140
- const o = parsed as Record<string, unknown>;
140
+ const metaRecord = parsed as Record<string, unknown>;
141
141
  return {
142
- roleId: typeof o.roleId === "string" ? o.roleId : undefined,
143
- startedAt: typeof o.startedAt === "string" ? o.startedAt : undefined,
142
+ roleId: typeof metaRecord.roleId === "string" ? metaRecord.roleId : undefined,
143
+ startedAt: typeof metaRecord.startedAt === "string" ? metaRecord.startedAt : undefined,
144
144
  };
145
145
  } catch {
146
146
  return {};
@@ -152,7 +152,7 @@ async function readSessionMeta(workspaceRoot: string, sessionId: string): Promis
152
152
  export async function listSessionIds(workspaceRoot: string): Promise<string[]> {
153
153
  try {
154
154
  const files = await readdir(chatDirFor(workspaceRoot));
155
- return files.filter((f) => f.endsWith(".jsonl")).map((f) => f.slice(0, -".jsonl".length));
155
+ return files.filter((fileName) => fileName.endsWith(".jsonl")).map((fileName) => fileName.slice(0, -".jsonl".length));
156
156
  } catch {
157
157
  return [];
158
158
  }
@@ -199,9 +199,9 @@ export async function indexSession(workspaceRoot: string, sessionId: string, dep
199
199
  // Upsert into manifest under the in-process lock: replace any
200
200
  // prior entry with the same id, sort newest-first by startedAt.
201
201
  await updateManifest(workspaceRoot, (current) => {
202
- const filtered = current.entries.filter((e) => e.id !== sessionId);
202
+ const filtered = current.entries.filter((entryItem) => entryItem.id !== sessionId);
203
203
  filtered.push(entry);
204
- filtered.sort((a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt));
204
+ filtered.sort((leftEntry, rightEntry) => Date.parse(rightEntry.startedAt) - Date.parse(leftEntry.startedAt));
205
205
  return { version: 1, entries: filtered };
206
206
  });
207
207
 
@@ -152,10 +152,10 @@ export function validateSummaryResult(obj: unknown): SummaryResult {
152
152
  if (!isRecord(obj)) {
153
153
  throw new Error("[chat-index] summary result is not an object");
154
154
  }
155
- const o = obj as Record<string, unknown>;
156
- const title = typeof o.title === "string" ? o.title : "";
157
- const summary = typeof o.summary === "string" ? o.summary : "";
158
- const keywords = Array.isArray(o.keywords) ? o.keywords.filter((k): k is string => typeof k === "string") : [];
155
+ const record = obj as Record<string, unknown>;
156
+ const title = typeof record.title === "string" ? record.title : "";
157
+ const summary = typeof record.summary === "string" ? record.summary : "";
158
+ const keywords = Array.isArray(record.keywords) ? record.keywords.filter((keyword): keyword is string => typeof keyword === "string") : [];
159
159
  return { title, summary, keywords };
160
160
  }
161
161
 
@@ -4,7 +4,7 @@
4
4
  // directories under `data/` and `artifacts/` for organizing files.
5
5
  // Claude sees these in the system prompt and routes saves accordingly.
6
6
 
7
- import fs from "fs";
7
+ import { existsSync, mkdirSync, readFileSync } from "fs";
8
8
  import path from "path";
9
9
  import { workspacePath, WORKSPACE_DIRS } from "./paths.js";
10
10
  import { log } from "../system/logger/index.js";
@@ -45,8 +45,8 @@ const CONTROL_CHAR_RE_G = /[\x00-\x1f]/g;
45
45
 
46
46
  // ── Validation ──────────────────────────────────────────────────
47
47
 
48
- function isValidStructure(v: unknown): v is DirStructure {
49
- return v === DIR_STRUCTURES.flat || v === DIR_STRUCTURES.byName || v === DIR_STRUCTURES.byDate;
48
+ function isValidStructure(value: unknown): value is DirStructure {
49
+ return value === DIR_STRUCTURES.flat || value === DIR_STRUCTURES.byName || value === DIR_STRUCTURES.byDate;
50
50
  }
51
51
 
52
52
  function validatePath(rawPath: string): string | null {
@@ -55,7 +55,7 @@ function validatePath(rawPath: string): string | null {
55
55
  const normalized = path.posix.normalize(rawPath);
56
56
 
57
57
  // Must start with allowed prefix
58
- if (!ALLOWED_PREFIXES.some((p) => normalized.startsWith(p))) return null;
58
+ if (!ALLOWED_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return null;
59
59
 
60
60
  // No path traversal
61
61
  if (normalized.includes("..")) return null;
@@ -64,7 +64,7 @@ function validatePath(rawPath: string): string | null {
64
64
  if (path.isAbsolute(normalized)) return null;
65
65
 
66
66
  // Not a reserved system directory
67
- if (RESERVED_DIRS.some((r) => normalized === r || normalized.startsWith(r + "/"))) {
67
+ if (RESERVED_DIRS.some((reservedDir) => normalized === reservedDir || normalized.startsWith(reservedDir + "/"))) {
68
68
  return null;
69
69
  }
70
70
 
@@ -102,8 +102,8 @@ export function loadCustomDirs(root?: string): CustomDirEntry[] {
102
102
  const base = root ?? workspacePath;
103
103
  const filePath = path.join(base, CONFIG_FILE);
104
104
  try {
105
- if (!fs.existsSync(filePath)) return [];
106
- const raw = fs.readFileSync(filePath, "utf-8");
105
+ if (!existsSync(filePath)) return [];
106
+ const raw = readFileSync(filePath, "utf-8");
107
107
  const parsed: unknown = JSON.parse(raw);
108
108
  if (!Array.isArray(parsed)) {
109
109
  log.warn("custom-dirs", "workspace-dirs.json is not an array");
@@ -112,7 +112,7 @@ export function loadCustomDirs(root?: string): CustomDirEntry[] {
112
112
  const entries = parsed
113
113
  .slice(0, MAX_ENTRIES)
114
114
  .map(validateEntry)
115
- .filter((e): e is CustomDirEntry => e !== null);
115
+ .filter((entry): entry is CustomDirEntry => entry !== null);
116
116
 
117
117
  const skipped = parsed.length - entries.length;
118
118
  if (skipped > 0) {
@@ -132,7 +132,7 @@ export function loadCustomDirs(root?: string): CustomDirEntry[] {
132
132
  export function saveCustomDirs(entries: readonly CustomDirEntry[], root?: string): void {
133
133
  const base = root ?? workspacePath;
134
134
  const filePath = path.join(base, CONFIG_FILE);
135
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
135
+ mkdirSync(path.dirname(filePath), { recursive: true });
136
136
  writeFileAtomicSync(filePath, JSON.stringify(entries, null, 2));
137
137
  invalidateCache();
138
138
  }
@@ -153,8 +153,8 @@ export function validateCustomDirs(raw: unknown): { entries: CustomDirEntry[] }
153
153
  if (entry) {
154
154
  entries.push(entry);
155
155
  } else {
156
- const p = isRecord(item) ? String((item as Record<string, unknown>).path ?? "") : "";
157
- errors.push(`entry ${i}: invalid path "${p}"`);
156
+ const itemPath = isRecord(item) ? String((item as Record<string, unknown>).path ?? "") : "";
157
+ errors.push(`entry ${i}: invalid path "${itemPath}"`);
158
158
  }
159
159
  });
160
160
  if (errors.length > 0) {
@@ -186,7 +186,7 @@ export function ensureCustomDirs(entries: readonly CustomDirEntry[], root?: stri
186
186
  const base = root ?? workspacePath;
187
187
  for (const entry of entries) {
188
188
  const dirPath = path.join(base, entry.path);
189
- fs.mkdirSync(dirPath, { recursive: true });
189
+ mkdirSync(dirPath, { recursive: true });
190
190
  }
191
191
  }
192
192
 
@@ -205,14 +205,14 @@ export function buildCustomDirsPrompt(entries: readonly CustomDirEntry[]): strin
205
205
  "",
206
206
  ];
207
207
 
208
- for (const e of entries) {
208
+ for (const entry of entries) {
209
209
  const structureHint =
210
- e.structure === DIR_STRUCTURES.byName
210
+ entry.structure === DIR_STRUCTURES.byName
211
211
  ? " (organize by name in subfolders)"
212
- : e.structure === DIR_STRUCTURES.byDate
212
+ : entry.structure === DIR_STRUCTURES.byDate
213
213
  ? " (organize by date: YYYY/MM/DD/)"
214
214
  : "";
215
- lines.push(`- \`${e.path}/\`${structureHint} — ${e.description}`);
215
+ lines.push(`- \`${entry.path}/\`${structureHint} — ${entry.description}`);
216
216
  }
217
217
 
218
218
  lines.push("");
@@ -60,11 +60,11 @@ export const runClaudeCli: Summarize = async (systemPrompt, userPrompt) => {
60
60
  child.kill("SIGKILL");
61
61
  }, CLI_TIMEOUT_MS);
62
62
 
63
- child.stdout.on("data", (d: Buffer) => {
64
- stdout += d.toString();
63
+ child.stdout.on("data", (chunk: Buffer) => {
64
+ stdout += chunk.toString();
65
65
  });
66
- child.stderr.on("data", (d: Buffer) => {
67
- stderr += d.toString();
66
+ child.stderr.on("data", (chunk: Buffer) => {
67
+ stderr += chunk.toString();
68
68
  });
69
69
 
70
70
  child.on("error", (err: Error & { code?: string }) => {
@@ -234,8 +234,8 @@ export function buildDailyUserPrompt(input: DailyArchivistInput): string {
234
234
  if (input.existingTopicSummaries.length === 0) {
235
235
  parts.push("(none yet)");
236
236
  } else {
237
- for (const t of input.existingTopicSummaries) {
238
- parts.push(`- ${t.slug}`);
237
+ for (const topicSummary of input.existingTopicSummaries) {
238
+ parts.push(`- ${topicSummary.slug}`);
239
239
  }
240
240
  }
241
241
  parts.push("");
@@ -244,24 +244,24 @@ export function buildDailyUserPrompt(input: DailyArchivistInput): string {
244
244
  // sessions produced, deduped and sorted. Given to the archivist
245
245
  // so it can link to them from the summary text.
246
246
  const allArtifacts = new Set<string>();
247
- for (const s of input.sessionExcerpts) {
248
- for (const p of s.artifactPaths) allArtifacts.add(p);
247
+ for (const sessionExcerpt of input.sessionExcerpts) {
248
+ for (const artifactPath of sessionExcerpt.artifactPaths) allArtifacts.add(artifactPath);
249
249
  }
250
250
  parts.push("ARTIFACTS REFERENCED:");
251
251
  if (allArtifacts.size === 0) {
252
252
  parts.push("(none)");
253
253
  } else {
254
- for (const p of [...allArtifacts].sort()) {
255
- parts.push(`- ${p}`);
254
+ for (const artifactPath of [...allArtifacts].sort()) {
255
+ parts.push(`- ${artifactPath}`);
256
256
  }
257
257
  }
258
258
  parts.push("");
259
259
 
260
260
  parts.push("SESSION EXCERPTS:");
261
- for (const s of input.sessionExcerpts) {
262
- parts.push(`### session ${s.sessionId} (role: ${s.roleId})`);
263
- for (const e of s.events) {
264
- parts.push(`- [${e.source}/${e.type}] ${e.content}`);
261
+ for (const sessionExcerpt of input.sessionExcerpts) {
262
+ parts.push(`### session ${sessionExcerpt.sessionId} (role: ${sessionExcerpt.roleId})`);
263
+ for (const eventExcerpt of sessionExcerpt.events) {
264
+ parts.push(`- [${eventExcerpt.source}/${eventExcerpt.type}] ${eventExcerpt.content}`);
265
265
  }
266
266
  parts.push("");
267
267
  }
@@ -324,10 +324,10 @@ LANGUAGE
324
324
  export function buildOptimizationUserPrompt(input: OptimizationInput): string {
325
325
  const parts: string[] = [];
326
326
  parts.push("CURRENT TOPICS:");
327
- for (const t of input.topics) {
328
- parts.push(`### ${t.slug}`);
327
+ for (const topic of input.topics) {
328
+ parts.push(`### ${topic.slug}`);
329
329
  parts.push("```md");
330
- parts.push(t.headContent);
330
+ parts.push(topic.headContent);
331
331
  parts.push("```");
332
332
  parts.push("");
333
333
  }
@@ -352,35 +352,35 @@ import { isRecord } from "../../utils/types.js";
352
352
  // guards rather than `as` casts per project conventions.
353
353
  export function isDailyArchivistOutput(value: unknown): value is DailyArchivistOutput {
354
354
  if (!isRecord(value)) return false;
355
- const v = value as Record<string, unknown>;
356
- if (typeof v.dailySummaryMarkdown !== "string") return false;
357
- if (!Array.isArray(v.topicUpdates)) return false;
358
- return v.topicUpdates.every(isTopicUpdate);
355
+ const recordValue = value as Record<string, unknown>;
356
+ if (typeof recordValue.dailySummaryMarkdown !== "string") return false;
357
+ if (!Array.isArray(recordValue.topicUpdates)) return false;
358
+ return recordValue.topicUpdates.every(isTopicUpdate);
359
359
  }
360
360
 
361
361
  function isTopicUpdate(value: unknown): value is TopicUpdate {
362
362
  if (!isRecord(value)) return false;
363
- const v = value as Record<string, unknown>;
364
- if (typeof v.slug !== "string") return false;
365
- if (typeof v.content !== "string") return false;
366
- return v.action === "create" || v.action === "append" || v.action === "rewrite";
363
+ const recordValue = value as Record<string, unknown>;
364
+ if (typeof recordValue.slug !== "string") return false;
365
+ if (typeof recordValue.content !== "string") return false;
366
+ return recordValue.action === "create" || recordValue.action === "append" || recordValue.action === "rewrite";
367
367
  }
368
368
 
369
369
  export function isOptimizationOutput(value: unknown): value is OptimizationOutput {
370
370
  if (!isRecord(value)) return false;
371
- const v = value as Record<string, unknown>;
372
- if (!Array.isArray(v.merges)) return false;
373
- if (!Array.isArray(v.archives)) return false;
374
- if (!v.merges.every(isTopicMerge)) return false;
375
- return v.archives.every((a: unknown) => typeof a === "string");
371
+ const recordValue = value as Record<string, unknown>;
372
+ if (!Array.isArray(recordValue.merges)) return false;
373
+ if (!Array.isArray(recordValue.archives)) return false;
374
+ if (!recordValue.merges.every(isTopicMerge)) return false;
375
+ return recordValue.archives.every((archiveSlug: unknown) => typeof archiveSlug === "string");
376
376
  }
377
377
 
378
378
  function isTopicMerge(value: unknown): value is TopicMerge {
379
379
  if (!isRecord(value)) return false;
380
- const v = value as Record<string, unknown>;
381
- if (!Array.isArray(v.from)) return false;
382
- if (!v.from.every((f: unknown) => typeof f === "string")) return false;
383
- if (typeof v.into !== "string") return false;
384
- if (typeof v.newContent !== "string") return false;
380
+ const recordValue = value as Record<string, unknown>;
381
+ if (!Array.isArray(recordValue.from)) return false;
382
+ if (!recordValue.from.every((fromSlug: unknown) => typeof fromSlug === "string")) return false;
383
+ if (typeof recordValue.into !== "string") return false;
384
+ if (typeof recordValue.newContent !== "string") return false;
385
385
  return true;
386
386
  }
@@ -81,7 +81,7 @@ export async function runDailyPass(state: JournalState, deps: DailyPassDeps): Pr
81
81
  };
82
82
 
83
83
  // --- Phase 1: figure out what work there is to do ------------------
84
- const eligible = (await listSessionMetas(chatDir)).filter((m) => !deps.activeSessionIds.has(m.id));
84
+ const eligible = (await listSessionMetas(chatDir)).filter((sessionMeta) => !deps.activeSessionIds.has(sessionMeta.id));
85
85
  const { dirty } = findDirtySessions(eligible, state.processedSessions);
86
86
  if (dirty.length === 0) return { nextState: { ...state }, result };
87
87
 
@@ -110,7 +110,7 @@ export async function runDailyPass(state: JournalState, deps: DailyPassDeps): Pr
110
110
  ...state,
111
111
  knownTopics: [...newTopicsSeen].sort(),
112
112
  };
113
- const dirtyMetaById = new Map(eligible.map((m) => [m.id, m]));
113
+ const dirtyMetaById = new Map(eligible.map((sessionMeta) => [sessionMeta.id, sessionMeta]));
114
114
  // Process days in chronological order so topic state accumulates
115
115
  // naturally: an earlier day's update is visible to the next day.
116
116
  const orderedDays = [...dayBuckets.keys()].sort();
@@ -191,7 +191,7 @@ async function processDayAndAdvance(input: ProcessDayInput): Promise<ProcessDayO
191
191
  }
192
192
 
193
193
  const justCompleted = computeJustCompletedSessions(input.date, excerpts, input.sessionToDays, input.dirtyMetaById);
194
- const sessionsIngested = justCompleted.map((m) => m.id);
194
+ const sessionsIngested = justCompleted.map((sessionMeta) => sessionMeta.id);
195
195
  const nextState = advanceJournalState(input.nextState, justCompleted, input.newTopicsSeen);
196
196
  await persistStateAfterDay(input.workspaceRoot, nextState, input.date);
197
197
 
@@ -218,7 +218,9 @@ async function maybeExtractMemory(
218
218
  const excerptLines: string[] = [];
219
219
  for (const [, byDate] of perSessionExcerpts) {
220
220
  for (const [, excerpt] of byDate) {
221
- const userLines = excerpt.events.filter((e: SessionEventExcerpt) => e.source === "user").map((e: SessionEventExcerpt) => `[user] ${e.content}`);
221
+ const userLines = excerpt.events
222
+ .filter((eventExcerpt: SessionEventExcerpt) => eventExcerpt.source === "user")
223
+ .map((eventExcerpt: SessionEventExcerpt) => `[user] ${eventExcerpt.content}`);
222
224
  if (userLines.length > 0) excerptLines.push(userLines.join("\n"));
223
225
  }
224
226
  }
@@ -362,7 +364,7 @@ async function refreshTopicSnapshot(workspaceRoot: string, slug: string, existin
362
364
  const newBody = await readTopicFile(slug, workspaceRoot);
363
365
  if (newBody === null) return;
364
366
  const snapshot: ExistingTopicSnapshot = { slug, content: newBody };
365
- const idx = existingTopics.findIndex((t) => t.slug === slug);
367
+ const idx = existingTopics.findIndex((topic) => topic.slug === slug);
366
368
  if (idx === -1) existingTopics.push(snapshot);
367
369
  else existingTopics[idx] = snapshot;
368
370
  }
@@ -427,7 +429,7 @@ export function buildDayBuckets(perSessionExcerpts: ReadonlyMap<string, Readonly
427
429
  // to the target topic file's location.
428
430
  export function normalizeTopicAction(update: TopicUpdate, existingTopics: readonly ExistingTopicSnapshot[]): TopicUpdate {
429
431
  const canonicalSlug = slugify(update.slug);
430
- const exists = existingTopics.some((t) => t.slug === canonicalSlug);
432
+ const exists = existingTopics.some((topic) => topic.slug === canonicalSlug);
431
433
  const topicFileWsPath = path.posix.join(WORKSPACE_DIRS.summaries, "topics", `${canonicalSlug}.md`);
432
434
  return {
433
435
  slug: canonicalSlug,
@@ -527,10 +529,10 @@ async function listSessionMetas(chatDir: string): Promise<SessionFileMeta[]> {
527
529
  if (!name.endsWith(".jsonl")) continue;
528
530
  const full = path.join(chatDir, name);
529
531
  try {
530
- const st = await fsp.stat(full);
532
+ const stats = await fsp.stat(full);
531
533
  out.push({
532
534
  id: name.replace(/\.jsonl$/, ""),
533
- mtimeMs: st.mtimeMs,
535
+ mtimeMs: stats.mtimeMs,
534
536
  });
535
537
  } catch {
536
538
  // file vanished between readdir and stat — ignore
@@ -607,8 +609,8 @@ export function bucketParsedEvents(events: readonly ParsedEntry[], sessionId: st
607
609
  buckets.set(fallbackDate, bucket);
608
610
  }
609
611
  bucket.events.push(parsed.excerpt);
610
- for (const p of parsed.artifactPaths) {
611
- if (!bucket.artifactPaths.includes(p)) bucket.artifactPaths.push(p);
612
+ for (const artifactPath of parsed.artifactPaths) {
613
+ if (!bucket.artifactPaths.includes(artifactPath)) bucket.artifactPaths.push(artifactPath);
612
614
  }
613
615
  }
614
616
  return buckets;
@@ -662,9 +664,10 @@ export function entryToExcerpt(entry: Record<string, unknown>): SessionEventExce
662
664
  // to avoid a NullPointerException-style crash when accessing
663
665
  // r.toolName below.
664
666
  if (type === EVENT_TYPES.toolResult && isRecord(entry.result)) {
665
- const r = entry.result as Record<string, unknown>;
666
- const toolName = typeof r.toolName === "string" ? r.toolName : "tool";
667
- const label = (typeof r.title === "string" && r.title) || (typeof r.message === "string" && r.message) || "(no message)";
667
+ const resultRecord = entry.result as Record<string, unknown>;
668
+ const toolName = typeof resultRecord.toolName === "string" ? resultRecord.toolName : "tool";
669
+ const label =
670
+ (typeof resultRecord.title === "string" && resultRecord.title) || (typeof resultRecord.message === "string" && resultRecord.message) || "(no message)";
668
671
  return {
669
672
  source,
670
673
  type,
@@ -682,23 +685,23 @@ export function extractArtifactPaths(entry: Record<string, unknown>): string[] {
682
685
  if (entry.type !== "tool_result") return [];
683
686
  const result = entry.result;
684
687
  if (!isRecord(result)) return [];
685
- const r = result as Record<string, unknown>;
686
- const data = r.data;
688
+ const resultRecord = result as Record<string, unknown>;
689
+ const data = resultRecord.data;
687
690
  if (!isRecord(data)) return [];
688
- const d = data as Record<string, unknown>;
691
+ const dataRecord = data as Record<string, unknown>;
689
692
  const paths: string[] = [];
690
693
 
691
694
  // Direct `filePath: string` — presentMulmoScript, presentHtml.
692
- if (typeof d.filePath === "string" && d.filePath.length > 0) {
693
- paths.push(d.filePath);
695
+ if (typeof dataRecord.filePath === "string" && dataRecord.filePath.length > 0) {
696
+ paths.push(dataRecord.filePath);
694
697
  }
695
698
 
696
699
  // Wiki uses `pageName: string` and stores the page at
697
700
  // `wiki/pages/<pageName>.md`. The plugin itself doesn't surface
698
701
  // the full path in the result, so we synthesise it from the
699
702
  // convention established in server/routes/wiki.ts.
700
- if (r.toolName === "manageWiki" && typeof d.pageName === "string") {
701
- paths.push(`wiki/pages/${d.pageName}.md`);
703
+ if (resultRecord.toolName === "manageWiki" && typeof dataRecord.pageName === "string") {
704
+ paths.push(`wiki/pages/${dataRecord.pageName}.md`);
702
705
  }
703
706
 
704
707
  // Paths must be workspace-relative (not absolute, no escape).
@@ -709,18 +712,18 @@ export function extractArtifactPaths(entry: Record<string, unknown>): string[] {
709
712
  // Defensive: refuse absolute paths, parent-escapes, or scheme-like
710
713
  // strings. Protects against a malformed tool result wedging a
711
714
  // filesystem-absolute path into the archivist prompt.
712
- function isSafeWorkspacePath(p: string): boolean {
713
- if (!p) return false;
714
- if (p.startsWith("/")) return false;
715
- if (p.startsWith("..")) return false;
716
- if (p.includes("://")) return false;
715
+ function isSafeWorkspacePath(candidatePath: string): boolean {
716
+ if (!candidatePath) return false;
717
+ if (candidatePath.startsWith("/")) return false;
718
+ if (candidatePath.startsWith("..")) return false;
719
+ if (candidatePath.includes("://")) return false;
717
720
  return true;
718
721
  }
719
722
 
720
- function truncate(s: string, max: number): string {
723
+ function truncate(text: string, max: number): string {
721
724
  if (max <= 0) return "";
722
- if (s.length <= max) return s;
723
- return `${s.slice(0, max - 1)}…`;
725
+ if (text.length <= max) return text;
726
+ return `${text.slice(0, max - 1)}…`;
724
727
  }
725
728
 
726
729
  async function readAllTopics(workspaceRoot: string): Promise<ExistingTopicSnapshot[]> {
@@ -52,8 +52,8 @@ export function findDirtySessions(current: readonly SessionFileMeta[], processed
52
52
  }
53
53
 
54
54
  const missing: string[] = [];
55
- for (const id of Object.keys(processed)) {
56
- if (!seenNow.has(id)) missing.push(id);
55
+ for (const sessionId of Object.keys(processed)) {
56
+ if (!seenNow.has(sessionId)) missing.push(sessionId);
57
57
  }
58
58
 
59
59
  return { dirty, missing };
@@ -150,17 +150,17 @@ async function runJournalPass(opts: MaybeRunJournalOptions): Promise<void> {
150
150
  async function rebuildIndex(workspaceRoot: string): Promise<void> {
151
151
  const topics = await walkTopics(workspaceRoot);
152
152
  const dailyEntries = await listDailyFilesIO(workspaceRoot);
153
- const days: IndexDailyEntry[] = dailyEntries.map((e) => ({
154
- date: `${e.year}-${e.month}-${e.day}`,
153
+ const days: IndexDailyEntry[] = dailyEntries.map((entry) => ({
154
+ date: `${entry.year}-${entry.month}-${entry.day}`,
155
155
  }));
156
156
  const archivedCount = await countArchivedIO(workspaceRoot);
157
- const md = buildIndexMarkdown({
157
+ const markdown = buildIndexMarkdown({
158
158
  topics,
159
159
  days,
160
160
  archivedTopicCount: archivedCount,
161
161
  builtAtIso: new Date().toISOString(),
162
162
  });
163
- await writeJournalIndex(md, workspaceRoot);
163
+ await writeJournalIndex(markdown, workspaceRoot);
164
164
  }
165
165
 
166
166
  async function walkTopics(workspaceRoot: string): Promise<IndexTopicEntry[]> {
@@ -59,8 +59,8 @@ export function renderTopicsSection(topics: readonly IndexTopicEntry[]): string[
59
59
  // Newest-first by last update (topics with no timestamp sort
60
60
  // last, ordered alphabetically among themselves for stability).
61
61
  const sorted = [...topics].sort(compareTopicsNewestFirst);
62
- for (const t of sorted) {
63
- lines.push(renderTopicRow(t));
62
+ for (const topicEntry of sorted) {
63
+ lines.push(renderTopicRow(topicEntry));
64
64
  }
65
65
  return lines;
66
66
  }
@@ -72,10 +72,14 @@ export function renderRecentDaysSection(days: readonly IndexDailyEntry[], maxRec
72
72
  return lines;
73
73
  }
74
74
  // Newest-first by date string (YYYY-MM-DD sorts lexically).
75
- const sorted = [...days].sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
75
+ const sorted = [...days].sort((leftDay, rightDay) => {
76
+ if (leftDay.date < rightDay.date) return 1;
77
+ if (leftDay.date > rightDay.date) return -1;
78
+ return 0;
79
+ });
76
80
  const head = sorted.slice(0, maxRecent);
77
- for (const d of head) {
78
- lines.push(renderDailyRow(d));
81
+ for (const dayEntry of head) {
82
+ lines.push(renderDailyRow(dayEntry));
79
83
  }
80
84
  const rest = sorted.length - head.length;
81
85
  if (rest > 0) {
@@ -100,37 +104,37 @@ export function renderArchiveSection(archivedTopicCount: number): string[] {
100
104
  // timestamps get -Infinity so they sort to the bottom (oldest).
101
105
  function topicSortKey(entry: IndexTopicEntry): number {
102
106
  if (!entry.lastUpdatedIso) return -Infinity;
103
- const ms = Date.parse(entry.lastUpdatedIso);
104
- return Number.isNaN(ms) ? -Infinity : ms;
107
+ const timestampMs = Date.parse(entry.lastUpdatedIso);
108
+ return Number.isNaN(timestampMs) ? -Infinity : timestampMs;
105
109
  }
106
110
 
107
- function compareBySlug(a: IndexTopicEntry, b: IndexTopicEntry): number {
108
- if (a.slug < b.slug) return -1;
109
- if (a.slug > b.slug) return 1;
111
+ function compareBySlug(leftEntry: IndexTopicEntry, rightEntry: IndexTopicEntry): number {
112
+ if (leftEntry.slug < rightEntry.slug) return -1;
113
+ if (leftEntry.slug > rightEntry.slug) return 1;
110
114
  return 0;
111
115
  }
112
116
 
113
- function compareTopicsNewestFirst(a: IndexTopicEntry, b: IndexTopicEntry): number {
114
- const ak = topicSortKey(a);
115
- const bk = topicSortKey(b);
117
+ function compareTopicsNewestFirst(leftEntry: IndexTopicEntry, rightEntry: IndexTopicEntry): number {
118
+ const leftKey = topicSortKey(leftEntry);
119
+ const rightKey = topicSortKey(rightEntry);
116
120
  // Both valid timestamps → compare numerically.
117
121
  // One or both invalid (-Infinity) → valid wins; if both invalid,
118
122
  // fall through to the slug tie-breaker.
119
- const aValid = Number.isFinite(ak);
120
- const bValid = Number.isFinite(bk);
121
- if (aValid && bValid && bk !== ak) return bk - ak;
122
- if (aValid !== bValid) return aValid ? -1 : 1;
123
+ const leftIsValid = Number.isFinite(leftKey);
124
+ const rightIsValid = Number.isFinite(rightKey);
125
+ if (leftIsValid && rightIsValid && rightKey !== leftKey) return rightKey - leftKey;
126
+ if (leftIsValid !== rightIsValid) return leftIsValid ? -1 : 1;
123
127
  // Tie-break on slug for determinism.
124
- return compareBySlug(a, b);
128
+ return compareBySlug(leftEntry, rightEntry);
125
129
  }
126
130
 
127
- function renderTopicRow(t: IndexTopicEntry): string {
128
- const label = t.title && t.title.trim().length > 0 ? t.title : t.slug;
129
- const stamp = t.lastUpdatedIso ? ` — updated ${isoDateOnly(t.lastUpdatedIso)}` : "";
130
- return `- [${label}](topics/${t.slug}.md)${stamp}`;
131
+ function renderTopicRow(topicEntry: IndexTopicEntry): string {
132
+ const label = topicEntry.title && topicEntry.title.trim().length > 0 ? topicEntry.title : topicEntry.slug;
133
+ const stamp = topicEntry.lastUpdatedIso ? ` — updated ${isoDateOnly(topicEntry.lastUpdatedIso)}` : "";
134
+ return `- [${label}](topics/${topicEntry.slug}.md)${stamp}`;
131
135
  }
132
136
 
133
- function renderDailyRow(d: IndexDailyEntry): string {
134
- const [year, month, day] = d.date.split("-");
135
- return `- [${d.date}](daily/${year}/${month}/${day}.md)`;
137
+ function renderDailyRow(dayEntry: IndexDailyEntry): string {
138
+ const [year, month, day] = dayEntry.date.split("-");
139
+ return `- [${dayEntry.date}](daily/${year}/${month}/${day}.md)`;
136
140
  }
@@ -54,7 +54,7 @@ export function planMerges(merges: readonly RawMerge[]): MergePlanItem[] {
54
54
  const plans: MergePlanItem[] = [];
55
55
  for (const merge of merges) {
56
56
  const intoSlug = slugify(merge.into);
57
- const fromSlugs = merge.from.map(slugify).filter((s) => s !== intoSlug);
57
+ const fromSlugs = merge.from.map(slugify).filter((slug) => slug !== intoSlug);
58
58
  if (fromSlugs.length === 0) continue;
59
59
  plans.push({ intoSlug, fromSlugs, newContent: merge.newContent });
60
60
  }
@@ -66,7 +66,7 @@ export function planMerges(merges: readonly RawMerge[]): MergePlanItem[] {
66
66
  export function applyRemovedTopics(state: JournalState, removed: ReadonlySet<string>): JournalState {
67
67
  return {
68
68
  ...state,
69
- knownTopics: state.knownTopics.filter((t) => !removed.has(t)),
69
+ knownTopics: state.knownTopics.filter((topic) => !removed.has(topic)),
70
70
  };
71
71
  }
72
72