mulmoclaude 0.1.2 → 0.3.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 (120) hide show
  1. package/bin/mulmoclaude.js +1 -1
  2. package/client/assets/{index-KNLBjwuh.css → index-Bm70FDU2.css} +1 -1
  3. package/client/assets/{index-D8rhwXLq.js → index-eHWB79u5.js} +3 -3
  4. package/client/index.html +2 -2
  5. package/package.json +1 -1
  6. package/server/agent/config.ts +12 -12
  7. package/server/agent/mcp-server.ts +19 -19
  8. package/server/agent/mcp-tools/x.ts +5 -5
  9. package/server/agent/prompt.ts +9 -4
  10. package/server/agent/sandboxMounts.ts +7 -7
  11. package/server/agent/stream.ts +4 -4
  12. package/server/api/routes/files.ts +9 -9
  13. package/server/api/routes/scheduler.ts +8 -8
  14. package/server/api/routes/schedulerHandlers.ts +12 -12
  15. package/server/api/routes/schedulerTasks.ts +14 -14
  16. package/server/api/routes/sessions.ts +24 -24
  17. package/server/api/routes/todosColumnsHandlers.ts +30 -30
  18. package/server/api/routes/wiki.ts +14 -14
  19. package/server/events/scheduler-adapter.ts +20 -20
  20. package/server/events/session-store/index.ts +10 -10
  21. package/server/events/task-manager/index.ts +7 -7
  22. package/server/index.ts +19 -19
  23. package/server/utils/date.ts +18 -18
  24. package/server/utils/files/atomic.ts +9 -9
  25. package/server/utils/files/html-io.ts +5 -5
  26. package/server/utils/files/image-store.ts +2 -2
  27. package/server/utils/files/journal-io.ts +2 -2
  28. package/server/utils/files/naming.ts +2 -2
  29. package/server/utils/files/roles-io.ts +10 -10
  30. package/server/utils/files/scheduler-io.ts +5 -5
  31. package/server/utils/files/session-io.ts +35 -35
  32. package/server/utils/files/spreadsheet-store.ts +2 -2
  33. package/server/utils/files/todos-io.ts +9 -9
  34. package/server/utils/files/user-tasks-io.ts +5 -5
  35. package/server/workspace/chat-index/indexer.ts +15 -15
  36. package/server/workspace/custom-dirs.ts +11 -11
  37. package/server/workspace/journal/archivist.ts +35 -35
  38. package/server/workspace/journal/dailyPass.ts +31 -28
  39. package/server/workspace/journal/indexFile.ts +29 -25
  40. package/server/workspace/reference-dirs.ts +18 -18
  41. package/server/workspace/roles.ts +6 -6
  42. package/server/workspace/skills/discovery.ts +4 -4
  43. package/server/workspace/skills/user-tasks.ts +34 -34
  44. package/server/workspace/sources/arxivDiscovery.ts +8 -8
  45. package/server/workspace/sources/classifier.ts +7 -7
  46. package/server/workspace/sources/fetchers/arxiv.ts +7 -7
  47. package/server/workspace/sources/fetchers/githubIssues.ts +7 -7
  48. package/server/workspace/sources/fetchers/githubReleases.ts +7 -7
  49. package/server/workspace/sources/interests.ts +9 -9
  50. package/server/workspace/sources/pipeline/index.ts +6 -6
  51. package/server/workspace/sources/pipeline/plan.ts +5 -5
  52. package/server/workspace/sources/registry.ts +16 -16
  53. package/server/workspace/sources/robots.ts +14 -14
  54. package/server/workspace/sources/sourceState.ts +11 -9
  55. package/server/workspace/tool-trace/index.ts +1 -1
  56. package/server/workspace/tool-trace/writeSearch.ts +26 -16
  57. package/server/workspace/wiki-backlinks/index.ts +8 -8
  58. package/server/workspace/wiki-backlinks/sessionBacklinks.ts +15 -15
  59. package/src/App.vue +30 -30
  60. package/src/components/ChatInput.vue +7 -7
  61. package/src/components/LockStatusPopup.vue +2 -2
  62. package/src/components/NotificationToast.vue +2 -2
  63. package/src/components/RoleSelector.vue +2 -2
  64. package/src/components/SessionHistoryPanel.vue +6 -6
  65. package/src/components/SettingsMcpTab.vue +7 -7
  66. package/src/components/SettingsModal.vue +3 -3
  67. package/src/components/SettingsReferenceDirsTab.vue +10 -10
  68. package/src/components/SettingsWorkspaceDirsTab.vue +5 -5
  69. package/src/components/SuggestionsPanel.vue +2 -2
  70. package/src/components/todo/TodoAddDialog.vue +2 -2
  71. package/src/components/todo/TodoEditPanel.vue +2 -2
  72. package/src/components/todo/TodoListView.vue +5 -5
  73. package/src/composables/useCanvasViewMode.ts +5 -5
  74. package/src/composables/useClickOutside.ts +2 -2
  75. package/src/composables/useFreshPluginData.ts +3 -3
  76. package/src/composables/useKeyNavigation.ts +11 -11
  77. package/src/composables/useMcpTools.ts +2 -2
  78. package/src/composables/useNotifications.ts +3 -3
  79. package/src/composables/usePdfDownload.ts +4 -4
  80. package/src/composables/usePendingCalls.ts +1 -1
  81. package/src/composables/usePubSub.ts +10 -10
  82. package/src/composables/useRoles.ts +1 -1
  83. package/src/composables/useSandboxStatus.ts +1 -1
  84. package/src/composables/useSessionDerived.ts +3 -3
  85. package/src/composables/useSessionSync.ts +8 -8
  86. package/src/composables/useViewLayout.ts +2 -2
  87. package/src/config/roles.ts +2 -2
  88. package/src/plugins/chart/Preview.vue +4 -4
  89. package/src/plugins/manageSkills/View.vue +3 -3
  90. package/src/plugins/manageSource/Preview.vue +1 -1
  91. package/src/plugins/markdown/View.vue +2 -2
  92. package/src/plugins/presentHtml/helpers.ts +8 -8
  93. package/src/plugins/presentMulmoScript/View.vue +4 -4
  94. package/src/plugins/presentMulmoScript/helpers.ts +1 -1
  95. package/src/plugins/scheduler/Preview.vue +6 -6
  96. package/src/plugins/scheduler/TasksTab.vue +4 -4
  97. package/src/plugins/textResponse/View.vue +2 -2
  98. package/src/plugins/todo/Preview.vue +2 -2
  99. package/src/plugins/todo/View.vue +11 -11
  100. package/src/plugins/todo/composables/useTodos.ts +5 -5
  101. package/src/plugins/wiki/Preview.vue +5 -5
  102. package/src/plugins/wiki/helpers.ts +4 -4
  103. package/src/router/guards.ts +12 -12
  104. package/src/types/session.ts +4 -3
  105. package/src/utils/agent/request.ts +3 -3
  106. package/src/utils/dom/scrollable.ts +2 -2
  107. package/src/utils/files/expandedDirs.ts +1 -1
  108. package/src/utils/files/sortChildren.ts +6 -6
  109. package/src/utils/format/frontmatter.ts +6 -6
  110. package/src/utils/image/rewriteMarkdownImageRefs.ts +5 -5
  111. package/src/utils/markdown/extractFirstH1.ts +2 -2
  112. package/src/utils/path/relativeLink.ts +15 -15
  113. package/src/utils/role/icon.ts +2 -2
  114. package/src/utils/role/merge.ts +2 -2
  115. package/src/utils/role/plugins.ts +1 -1
  116. package/src/utils/session/sessionFactory.ts +2 -2
  117. package/src/utils/session/sessionHelpers.ts +2 -2
  118. package/src/utils/tools/dedup.ts +4 -4
  119. package/src/utils/tools/result.ts +3 -3
  120. package/src/utils/types.ts +2 -2
@@ -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[]> {
@@ -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
  }
@@ -39,11 +39,11 @@ const CONTROL_CHAR_RE_G = /[\x00-\x1f]/g;
39
39
 
40
40
  // ── Validation ──────────────────────────────────────────────────
41
41
 
42
- function expandHome(p: string): string {
43
- if (p.startsWith("~/")) {
44
- return path.join(os.homedir(), p.slice(2));
42
+ function expandHome(inputPath: string): string {
43
+ if (inputPath.startsWith("~/")) {
44
+ return path.join(os.homedir(), inputPath.slice(2));
45
45
  }
46
- return p;
46
+ return inputPath;
47
47
  }
48
48
 
49
49
  function isSensitivePath(absPath: string): boolean {
@@ -59,8 +59,8 @@ function isSensitivePath(absPath: string): boolean {
59
59
 
60
60
  // Block home-relative sensitive dirs
61
61
  if (
62
- HOME_RELATIVE_BLOCKED.some((bp) => {
63
- const full = path.join(home, bp);
62
+ HOME_RELATIVE_BLOCKED.some((blockedPath) => {
63
+ const full = path.join(home, blockedPath);
64
64
  return normalized === full || normalized.startsWith(full + path.sep);
65
65
  })
66
66
  ) {
@@ -68,7 +68,7 @@ function isSensitivePath(absPath: string): boolean {
68
68
  }
69
69
 
70
70
  // Block system directories
71
- return SYSTEM_BLOCKED_PREFIXES.some((p) => normalized === p || normalized.startsWith(p + path.sep));
71
+ return SYSTEM_BLOCKED_PREFIXES.some((blockedPrefix) => normalized === blockedPrefix || normalized.startsWith(blockedPrefix + path.sep));
72
72
  }
73
73
 
74
74
  function sanitizeLabel(raw: string): string {
@@ -76,8 +76,8 @@ function sanitizeLabel(raw: string): string {
76
76
  return raw.replace(CONTROL_CHAR_RE_G, " ").trim().slice(0, MAX_LABEL_LENGTH);
77
77
  }
78
78
 
79
- function hasTraversalSegment(p: string): boolean {
80
- return p.split(path.sep).some((seg) => seg === "..");
79
+ function hasTraversalSegment(inputPath: string): boolean {
80
+ return inputPath.split(path.sep).some((segment) => segment === "..");
81
81
  }
82
82
 
83
83
  function validateEntry(raw: unknown): ReferenceDirEntry | null {
@@ -117,11 +117,11 @@ export function loadReferenceDirs(root?: string): ReferenceDirEntry[] {
117
117
  const entries = parsed
118
118
  .slice(0, MAX_ENTRIES)
119
119
  .map(validateEntry)
120
- .filter((e): e is ReferenceDirEntry => {
121
- if (!e) return false;
120
+ .filter((entry): entry is ReferenceDirEntry => {
121
+ if (!entry) return false;
122
122
  // Deduplicate labels — first entry wins
123
- if (seenLabels.has(e.label)) return false;
124
- seenLabels.add(e.label);
123
+ if (seenLabels.has(entry.label)) return false;
124
+ seenLabels.add(entry.label);
125
125
  return true;
126
126
  });
127
127
 
@@ -155,8 +155,8 @@ export function validateReferenceDirs(raw: unknown): { entries: ReferenceDirEntr
155
155
  if (entry) {
156
156
  entries.push(entry);
157
157
  } else {
158
- const p = isRecord(item) ? String((item as Record<string, unknown>).hostPath ?? "") : "";
159
- errors.push(`entry ${i}: invalid or blocked path "${p}"`);
158
+ const hostPath = isRecord(item) ? String((item as Record<string, unknown>).hostPath ?? "") : "";
159
+ errors.push(`entry ${i}: invalid or blocked path "${hostPath}"`);
160
160
  }
161
161
  });
162
162
  if (errors.length > 0) {
@@ -233,9 +233,9 @@ export function buildReferenceDirsPrompt(entries: readonly ReferenceDirEntry[],
233
233
  "",
234
234
  ];
235
235
 
236
- for (const e of entries) {
237
- const mountPath = useDocker ? containerPath(e) : e.hostPath;
238
- lines.push(`- \`${mountPath}\` — ${e.label}`);
236
+ for (const entry of entries) {
237
+ const mountPath = useDocker ? containerPath(entry) : entry.hostPath;
238
+ lines.push(`- \`${mountPath}\` — ${entry.label}`);
239
239
  }
240
240
 
241
241
  if (!useDocker) {
@@ -14,10 +14,10 @@ function withSwitchRole(role: Role): Role {
14
14
 
15
15
  export function loadCustomRoles(): Role[] {
16
16
  return readdirUnderSync(workspacePath, WORKSPACE_DIRS.roles)
17
- .filter((f) => f.endsWith(".json"))
18
- .flatMap((f) => {
17
+ .filter((fileName) => fileName.endsWith(".json"))
18
+ .flatMap((fileName) => {
19
19
  try {
20
- const raw = readTextUnderSync(workspacePath, path.posix.join(WORKSPACE_DIRS.roles, f));
20
+ const raw = readTextUnderSync(workspacePath, path.posix.join(WORKSPACE_DIRS.roles, fileName));
21
21
  if (!raw) return [];
22
22
  return [withSwitchRole(RoleSchema.parse(JSON.parse(raw)))];
23
23
  } catch {
@@ -28,10 +28,10 @@ export function loadCustomRoles(): Role[] {
28
28
 
29
29
  export function loadAllRoles(): Role[] {
30
30
  const custom = loadCustomRoles();
31
- const builtIn = BUILTIN_ROLES.filter((r) => !custom.find((c) => c.id === r.id));
31
+ const builtIn = BUILTIN_ROLES.filter((role) => !custom.find((customRole) => customRole.id === role.id));
32
32
  return [...builtIn, ...custom];
33
33
  }
34
34
 
35
- export function getRole(id: string): Role {
36
- return loadAllRoles().find((r) => r.id === id) ?? BUILTIN_ROLES[0];
35
+ export function getRole(roleId: string): Role {
36
+ return loadAllRoles().find((role) => role.id === roleId) ?? BUILTIN_ROLES[0];
37
37
  }
@@ -90,7 +90,7 @@ export async function collectSkillsFromDir(root: string, source: SkillSource): P
90
90
  if (skill) results.push(skill);
91
91
  }
92
92
  // Stable alphabetical order for the UI.
93
- results.sort((a, b) => a.name.localeCompare(b.name));
93
+ results.sort((leftSkill, rightSkill) => leftSkill.name.localeCompare(rightSkill.name));
94
94
  return results;
95
95
  }
96
96
 
@@ -118,8 +118,8 @@ export async function discoverSkills(opts: DiscoverSkillsOptions = {}): Promise<
118
118
  // Project overrides user on name collision. Merge by building a
119
119
  // map keyed by name, starting with user, overwriting with project.
120
120
  const merged = new Map<string, Skill>();
121
- for (const s of userSkills) merged.set(s.name, s);
122
- for (const s of projectSkills) merged.set(s.name, s);
121
+ for (const skill of userSkills) merged.set(skill.name, skill);
122
+ for (const skill of projectSkills) merged.set(skill.name, skill);
123
123
 
124
- return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
124
+ return [...merged.values()].sort((leftSkill, rightSkill) => leftSkill.name.localeCompare(rightSkill.name));
125
125
  }
@@ -32,8 +32,8 @@ export interface PersistedUserTask {
32
32
  updatedAt: string;
33
33
  }
34
34
 
35
- export function loadUserTasks(r?: string): PersistedUserTask[] {
36
- return loadRaw<PersistedUserTask>(r);
35
+ export function loadUserTasks(workspaceRoot?: string): PersistedUserTask[] {
36
+ return loadRaw<PersistedUserTask>(workspaceRoot);
37
37
  }
38
38
 
39
39
  // ── Validation ──────────────────────────────────────────────────
@@ -42,20 +42,20 @@ function isValidDailyTime(value: string): boolean {
42
42
  return /^([01]\d|2[0-3]):([0-5]\d)$/.test(value);
43
43
  }
44
44
 
45
- function isValidSchedule(s: unknown): s is LocalTaskSchedule {
46
- if (!isRecord(s)) return false;
47
- const obj = s as Record<string, unknown>;
48
- if (obj.type === SCHEDULE_TYPES.interval) {
49
- return typeof obj.intervalMs === "number" && obj.intervalMs > 0;
45
+ function isValidSchedule(scheduleValue: unknown): scheduleValue is LocalTaskSchedule {
46
+ if (!isRecord(scheduleValue)) return false;
47
+ const scheduleRecord = scheduleValue as Record<string, unknown>;
48
+ if (scheduleRecord.type === SCHEDULE_TYPES.interval) {
49
+ return typeof scheduleRecord.intervalMs === "number" && scheduleRecord.intervalMs > 0;
50
50
  }
51
- if (obj.type === SCHEDULE_TYPES.daily) {
52
- return typeof obj.time === "string" && isValidDailyTime(obj.time);
51
+ if (scheduleRecord.type === SCHEDULE_TYPES.daily) {
52
+ return typeof scheduleRecord.time === "string" && isValidDailyTime(scheduleRecord.time);
53
53
  }
54
54
  return false;
55
55
  }
56
56
 
57
- function isValidMissedRunPolicy(p: unknown): p is MissedRunPolicy {
58
- return p === MISSED_RUN_POLICIES.skip || p === MISSED_RUN_POLICIES.runOnce || p === MISSED_RUN_POLICIES.runAll;
57
+ function isValidMissedRunPolicy(policy: unknown): policy is MissedRunPolicy {
58
+ return policy === MISSED_RUN_POLICIES.skip || policy === MISSED_RUN_POLICIES.runOnce || policy === MISSED_RUN_POLICIES.runAll;
59
59
  }
60
60
 
61
61
  export type ValidateResult = { kind: "ok"; task: PersistedUserTask } | { kind: "error"; error: string };
@@ -96,44 +96,44 @@ export function validateAndCreate(input: unknown): ValidateResult {
96
96
 
97
97
  export type UpdateResult = { kind: "ok"; tasks: PersistedUserTask[] } | { kind: "error"; error: string };
98
98
 
99
- export function applyUpdate(tasks: PersistedUserTask[], id: string, patch: unknown): UpdateResult {
99
+ export function applyUpdate(tasks: PersistedUserTask[], taskId: string, patch: unknown): UpdateResult {
100
100
  if (!isRecord(patch)) {
101
101
  return { kind: "error", error: "request body required" };
102
102
  }
103
- const idx = tasks.findIndex((t) => t.id === id);
104
- if (idx === -1) {
105
- return { kind: "error", error: `task not found: ${id}` };
103
+ const index = tasks.findIndex((task) => task.id === taskId);
104
+ if (index === -1) {
105
+ return { kind: "error", error: `task not found: ${taskId}` };
106
106
  }
107
- const existing = tasks[idx];
107
+ const existing = tasks[index];
108
108
  const updated: PersistedUserTask = { ...existing };
109
109
  // patch is validated as non-null object above; spread into Record
110
- const p: Record<string, unknown> = { ...patch };
110
+ const patchRecord: Record<string, unknown> = { ...patch };
111
111
 
112
- if (typeof p.name === "string" && p.name.trim().length > 0) {
113
- updated.name = p.name.trim();
112
+ if (typeof patchRecord.name === "string" && patchRecord.name.trim().length > 0) {
113
+ updated.name = patchRecord.name.trim();
114
114
  }
115
- if (typeof p.description === "string") {
116
- updated.description = p.description.trim();
115
+ if (typeof patchRecord.description === "string") {
116
+ updated.description = patchRecord.description.trim();
117
117
  }
118
- if (isValidSchedule(p.schedule)) {
119
- updated.schedule = p.schedule;
118
+ if (isValidSchedule(patchRecord.schedule)) {
119
+ updated.schedule = patchRecord.schedule;
120
120
  }
121
- if (isValidMissedRunPolicy(p.missedRunPolicy)) {
122
- updated.missedRunPolicy = p.missedRunPolicy;
121
+ if (isValidMissedRunPolicy(patchRecord.missedRunPolicy)) {
122
+ updated.missedRunPolicy = patchRecord.missedRunPolicy;
123
123
  }
124
- if (typeof p.enabled === "boolean") {
125
- updated.enabled = p.enabled;
124
+ if (typeof patchRecord.enabled === "boolean") {
125
+ updated.enabled = patchRecord.enabled;
126
126
  }
127
- if (typeof p.roleId === "string") {
128
- updated.roleId = p.roleId;
127
+ if (typeof patchRecord.roleId === "string") {
128
+ updated.roleId = patchRecord.roleId;
129
129
  }
130
- if (typeof p.prompt === "string" && p.prompt.trim().length > 0) {
131
- updated.prompt = p.prompt.trim();
130
+ if (typeof patchRecord.prompt === "string" && patchRecord.prompt.trim().length > 0) {
131
+ updated.prompt = patchRecord.prompt.trim();
132
132
  }
133
133
  updated.updatedAt = new Date().toISOString();
134
134
 
135
135
  const next = [...tasks];
136
- next[idx] = updated;
136
+ next[index] = updated;
137
137
  return { kind: "ok", tasks: next };
138
138
  }
139
139
 
@@ -144,7 +144,7 @@ export function applyUpdate(tasks: PersistedUserTask[], id: string, patch: unkno
144
144
  let crudMutex: Promise<void> = Promise.resolve();
145
145
 
146
146
  export async function withUserTaskLock<T>(
147
- fn: (tasks: PersistedUserTask[]) => Promise<{
147
+ lockFn: (tasks: PersistedUserTask[]) => Promise<{
148
148
  tasks: PersistedUserTask[];
149
149
  result: T;
150
150
  }>,
@@ -157,7 +157,7 @@ export async function withUserTaskLock<T>(
157
157
  try {
158
158
  await prev;
159
159
  const current = loadUserTasks();
160
- const { tasks: next, result } = await fn(current);
160
+ const { tasks: next, result } = await lockFn(current);
161
161
  await saveUserTasks(next);
162
162
  await refreshUserTasks();
163
163
  return result;