@mulmoclaude/core 1.5.0 → 1.6.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.
@@ -0,0 +1,130 @@
1
+ # Bug-report FAQ — check these before calling it a bug
2
+
3
+ This file is an **index, not an answer sheet**. It never states a value ("the default is off"), only
4
+ where today's truth lives, because the agent reads the real thing at run time. Values rot silently;
5
+ a config key or a file path cannot — rename one and the implementation stops working, so it gets
6
+ fixed.
7
+
8
+ Each entry's `configKey:` / `source:` / `help:` lines are **verified by CI**: a key that no longer
9
+ exists, or a path that moved, fails the test rather than misleading a user months later.
10
+
11
+ Read in this order, because only the first two are available to every user:
12
+
13
+ 1. **`configKey:`** — the live value, from `config/settings.json` in the workspace. Absent from the
14
+ file means the setting is at its default, which is the answer to most entries here.
15
+ 2. **`help:`** — another page in `config/helps/`, always present.
16
+ 3. **`source:`** — a path in the MulmoClaude **repository**. Only readable when running from a clone
17
+ (`yarn dev`); an `npx mulmoclaude` user has no repo, so never tell them to open one. It is listed
18
+ so CI can prove the pointer is still real, and for whoever ends up fixing the code.
19
+
20
+ Entry format:
21
+
22
+ ```
23
+ ## The symptom, in the words a user would say
24
+
25
+ configKey: <a key in config/settings.json> (optional, repeatable)
26
+ source: <path in the MulmoClaude repo> (optional, repeatable)
27
+ help: <another page in config/helps/> (optional, repeatable)
28
+
29
+ What to check. Never what the value is.
30
+ ```
31
+
32
+ Maintained by hand, in the repo. This file is the source of truth; the issue tracker is the inbox.
33
+
34
+ ---
35
+
36
+ ## Voice input does nothing, or the mic button is disabled
37
+
38
+ configKey: voiceInput
39
+ source: packages/core/src/whisper
40
+ help: error-recovery.md
41
+
42
+ Read `voiceInput` from the live settings — a key absent from the file has never been touched, and
43
+ turning it on is what triggers the model download. Three separate conditions gate the mic button:
44
+ platform capability, the opt-in, and whether the model finished downloading. `GET /api/health`
45
+ reports all three as `voiceInput`, so check it before deciding which one is missing — "on but still
46
+ greyed out" is a different question from "never turned on".
47
+
48
+ ## I don't get a notification when a task finishes
49
+
50
+ configKey: pushEnabled
51
+ source: server/agent/webPush.ts
52
+ help: remote-host.md
53
+
54
+ Two conditions must hold at once, so read both before calling it a bug. `pushEnabled` in the live
55
+ settings is the user's opt-in. The RemoteHost channel must also be connected — that connection is
56
+ what supplies the Firebase auth, so with the phone link down the send is a no-op by design. A user
57
+ who never connected a phone has nothing to receive the push regardless of the setting.
58
+
59
+ ## My chats never get titles or summaries
60
+
61
+ configKey: chatIndex
62
+ source: server/workspace/chat-index/indexer.ts
63
+
64
+ Read `chatIndex` — it names the model the background summarizer spawns, and the summarizer does
65
+ nothing until it does. One exception worth knowing before calling it a bug: sessions that didn't
66
+ originate from the user (`system`, `scheduler`) are always skipped whatever the setting says, so a
67
+ scheduled task's chat having no title is expected.
68
+
69
+ ## The journal / daily summary is empty
70
+
71
+ configKey: journal
72
+ source: server/workspace/journal/index.ts
73
+
74
+ Read `journal` first — it gates the archivist that summarizes chat sessions into `journal/*.md`,
75
+ and when it is not enabling a run the archivist short-circuits before the interval gate is even
76
+ consulted. Neither the turn-end hook nor the hourly scheduled task will have written anything, so
77
+ an empty journal is not evidence of a scheduler fault.
78
+
79
+ ## Gmail / Google Calendar / Notion tools don't show up for the agent
80
+
81
+ configKey: extraAllowedTools
82
+ source: server/agent/config.ts
83
+
84
+ Connector tools reach the agent only when their MCP prefix is listed in `extraAllowedTools` — the
85
+ list is appended to the base allowed-tools set on every spawn. Check the live value for the
86
+ expected `mcp__…` prefix, and remember the connector must also be linked on the Claude side. A tool
87
+ that is linked but unlisted is configuration, not a bug.
88
+
89
+ ## A skill I saved isn't callable
90
+
91
+ source: server/workspace/hooks/handlers/skillBridge.ts
92
+ source: server/workspace/skills/discovery.ts
93
+ help: collection-skills.md
94
+
95
+ Skills are authored at `data/skills/<slug>/SKILL.md` and mirrored into `.claude/skills/<slug>/` by a
96
+ hook — agents must not write the latter directly. If the staging file exists but the mirror doesn't,
97
+ the hook is the thing to look at. Bundled `mc-*` presets are a different case: they land in the
98
+ catalog (`data/skills/catalog/preset/`) and are **not** active until the user stars one, so "the
99
+ preset skill doesn't respond" is usually "it was never activated".
100
+
101
+ ## I edited an mc-* skill and my changes came back reverted
102
+
103
+ source: packages/core/src/workspace-setup/sync.ts
104
+
105
+ Working as designed: `mc-*` presets are launcher-managed factory defaults, refreshed from the
106
+ shipped copy on every server boot so bug fixes and renames actually reach an already-starred copy.
107
+ Any file it overwrites is first saved next to it as `<file>.bak.<timestamp>`, so the user's edits
108
+ are recoverable — point them at that instead of re-typing. To keep changes permanently, copy the
109
+ skill to a new non-`mc-` slug.
110
+
111
+ ## The agent can't use git / gh / ssh
112
+
113
+ help: error-recovery.md
114
+ help: sandbox.md
115
+
116
+ The agent's Docker sandbox exposes no host SSH agent and no `gh` config unless the user opted them
117
+ in at start-up. `GET /api/sandbox` reports whether the sandbox is active and which config mounts it
118
+ was started with — read that rather than assuming either way. This is the single most common "it's
119
+ broken" report and it is configuration. `error-recovery.md` has the exact flags and the
120
+ verification commands; don't restate them from memory.
121
+
122
+ ## A collection's custom view is blank
123
+
124
+ help: custom-view.md
125
+ help: custom-view-remote.md
126
+
127
+ Desktop and phone views have **incompatible runtime contracts** — a desktop view fetches its own
128
+ records with an injected token, while a `target: "mobile"` view gets them over a postMessage bridge
129
+ and cannot `fetch` at all. A view authored against the wrong contract renders empty with no error.
130
+ Check which target it is registered as before treating it as a rendering fault.
@@ -1,10 +1,15 @@
1
- # Error recovery — when a tool call fails
1
+ # Error recovery — when a tool call fails, or the user says something is broken
2
2
 
3
3
  This is the lookup the agent reads BEFORE asking the user a clarifying
4
4
  question or giving up on a failing tool call. Each section is keyed by
5
5
  the error message you'd see in tool output, with the cause and the
6
6
  documented fix.
7
7
 
8
+ It has a second entry point. When the **user** reports that MulmoClaude is
9
+ broken / weird / not working — nothing has failed in tool output, they are
10
+ just describing a symptom — start at **§ The user says MulmoClaude is
11
+ broken** below instead of scanning the error-keyed sections.
12
+
8
13
  Cite the section you used in your reply so the user can follow up
9
14
  (e.g. "Per `config/helps/error-recovery.md` § gh-auth / SSH …").
10
15
 
@@ -13,6 +18,134 @@ If no section here matches, list the workspace's other help files
13
18
  area (`sandbox.md`, `github.md`, `collection-skills.md`, etc.) before
14
19
  falling back to asking the user.
15
20
 
21
+ ## The user says MulmoClaude is broken
22
+
23
+ **The goal is that the user ends up unblocked, not that an issue gets filed.**
24
+ An issue is what's left after the first three steps fail to explain the
25
+ behaviour. Work them in order and stop the moment the user is unblocked.
26
+
27
+ Rules that hold in every step:
28
+
29
+ - **Never assert "that's by design" from memory.** Say it only with a reason
30
+ attached: a config key, a help page, an implementation file.
31
+ `bug-report-faq.md` is an index of where to look — it deliberately contains
32
+ no values.
33
+ - **Read the real thing.** Current values come from the workspace's
34
+ `config/settings.json` or `GET /api/health`, never from recollection.
35
+ - **"I don't know" is an allowed answer.** If a step can't decide, say so and
36
+ go to the next one. Never close the conversation by guessing.
37
+ - **Nothing leaves the machine before the user has seen it in full** and agreed.
38
+ - Reply in the language the user writes in.
39
+
40
+ ### Step 1 — Hear the symptom (collect nothing yet)
41
+
42
+ Call `presentForm` once with the questions below rather than asking in prose —
43
+ a user who is already frustrated should be clicking, not composing.
44
+
45
+ - **What kind** — display looks wrong / input doesn't work / the agent won't
46
+ answer / a tool keeps failing / phone or remote features / something else
47
+ - **Where** — chat / a collection / the wiki / files / settings / the phone app
48
+ - **How often** — every time / sometimes / once
49
+ - **Since when** — after an update / it always did this / not sure
50
+ - **What did you expect, and what happened instead?** (free text, required —
51
+ these two sentences are what the next step is judged against)
52
+
53
+ ### Step 2 — Is it configuration, or by design? (this is the actual job)
54
+
55
+ 1. Read `config/helps/bug-report-faq.md` and find the entry closest to the
56
+ symptom.
57
+ 2. Follow its pointers to the **real values** — `config/settings.json` for a
58
+ `configKey`, the named help page for a `help`. A setting absent from the file
59
+ is at its default, which is the answer to most entries there.
60
+ 3. If that explains the gap between expected and actual, **say why, show the
61
+ fix, and stop.** Cite what you checked.
62
+ 4. If nothing explains it, go to Step 3. Do not stretch a FAQ entry to cover a
63
+ symptom it doesn't cover.
64
+
65
+ ### Step 3 — Is it already known?
66
+
67
+ Search the existing issues before opening anything:
68
+
69
+ ```bash
70
+ gh issue list --repo receptron/mulmoclaude --state all --limit 20 --search "<symptom keywords>"
71
+ ```
72
+
73
+ `gh` is **not available by default** — the agent runs in a credential-free
74
+ sandbox (see § gh / git / SSH errors inside the sandbox). Don't spend turns
75
+ fighting it: hand the user the search URL instead.
76
+
77
+ `https://github.com/receptron/mulmoclaude/issues?q=voice+input+disabled` — build
78
+ the `q=` value percent-encoded (space → `+` or `%20`, `#` → `%23`), or the link
79
+ breaks on the first symptom that contains one.
80
+
81
+ - **Closed and fixed** → compare their version against the release that fixed
82
+ it. If they're simply behind, tell them to update and stop.
83
+ - **Open** → do not open a second one. Offer to add this user's environment and
84
+ repro steps as a comment; a second reproduction is worth more than a duplicate.
85
+ - **Nothing found** → Step 4.
86
+
87
+ ### Step 4 — File it
88
+
89
+ Only now collect details. Fetch the environment report from the server, from the
90
+ workspace root:
91
+
92
+ ```bash
93
+ curl -s -H "Authorization: Bearer $(cat .session-token)" \
94
+ "http://${MULMOCLAUDE_HOST:-127.0.0.1}:$(cat .server-port)/api/diagnostics/report"
95
+ ```
96
+
97
+ Three parts of that command are load-bearing, and dropping any one returns
98
+ nothing useful:
99
+
100
+ - **The bearer header.** Every `/api/*` route requires it; without it the reply
101
+ is a 401, not a report. The token is regenerated each startup and lives in
102
+ `.session-token` at the workspace root.
103
+ - **`$MULMOCLAUDE_HOST`.** In the default Docker sandbox `localhost` is the
104
+ container, not the machine running the server — the variable is set to
105
+ `host.docker.internal` there and unset on a host-mode run, hence the fallback.
106
+ - **`.server-port`.** The port is chosen at startup; there is no fixed default
107
+ to hardcode.
108
+
109
+ Keep the token inside the command substitution: never echo it, never let it
110
+ reach the report body, and if you quote the command in the issue, quote it in
111
+ the `$(cat …)` form above rather than with the value expanded.
112
+
113
+ **The server does the redaction, not you.** It prints values only for
114
+ allow-listed settings and withholds everything else, including the plaintext
115
+ Google Maps key and every MCP server's `env` / `headers`. Paste what it returns
116
+ verbatim — do not "help" by adding values you read elsewhere, and do not
117
+ re-type a secret you happened to see in a file.
118
+
119
+ Ask the user for what the host cannot see: the browser and its version, any red
120
+ errors in the browser console, and a screenshot if the symptom is visual. Say
121
+ before they attach one that a screenshot can carry file paths and chat text.
122
+
123
+ Report body:
124
+
125
+ ```markdown
126
+ ## What happened
127
+ ## What I expected
128
+ ## Steps to reproduce
129
+ 1.
130
+ ## Environment
131
+ (paste the diagnostics report here)
132
+ ## Attachments
133
+ ```
134
+
135
+ Show the whole thing, get an explicit yes, then post it — `gh issue create
136
+ --repo receptron/mulmoclaude --title "<title>" --body-file <file>` when `gh`
137
+ works, otherwise print the markdown for copy-paste plus
138
+ `https://github.com/receptron/mulmoclaude/issues/new`.
139
+
140
+ ### When Step 2 resolved it
141
+
142
+ A question that took an agent to answer is a signal about the product: the UI
143
+ failed to say something. Offer to post it as an issue titled with the question
144
+ as the user asked it, noting what the answer turned out to be and **where it was
145
+ checked**. Open the body with a line saying the answer came from this lookup and
146
+ is awaiting maintainer review — it is a draft, not documentation. Never edit
147
+ `bug-report-faq.md` yourself.
148
+
16
149
  ## gh / git / SSH errors inside the sandbox
17
150
 
18
151
  ### Symptoms
@@ -67,7 +67,8 @@ See [Wiki](config/helps/wiki.md) for details on how it works.
67
67
  - [Spreadsheet](config/helps/spreadsheet.md) — cell format, formulas, date handling, and format codes for the presentSpreadsheet plugin
68
68
  - [presentHtml](config/helps/presenthtml.md) — self-contained HTML rules and the three-`../` relative-path convention used by the presentHtml plugin to keep generated files portable under `file://`
69
69
  - [Sandbox](config/helps/sandbox.md) — how the Docker sandbox isolates the agent, what it can access, and how to disable it
70
- - [Error recovery](config/helps/error-recovery.md) — the lookup the agent reads on tool failures (gh/git/SSH inside the sandbox, Marp PDF, registry import, build/workspace, plugin runtime) before asking the user
70
+ - [Error recovery](config/helps/error-recovery.md) — the lookup the agent reads on tool failures (gh/git/SSH in the sandbox, Marp PDF, registry import, build/workspace, plugin runtime), plus the four-step triage for when a user reports something broken
71
+ - [Bug-report FAQ](config/helps/bug-report-faq.md) — symptoms that turn out to be configuration or by design (voice input, push, chat titles, journal, connector tools, preset skills, custom views); says where to read the live value, never what it is
71
72
  - [Telegram Bridge](config/helps/telegram.md) — how to talk to MulmoClaude from the Telegram app: creating a bot, starting the bridge, allowlisting chat IDs, commands, and troubleshooting
72
73
  - [Remote host](config/helps/remote-host.md) — drive MulmoClaude from a phone at mulmoserver.web.app: Google sign-in connect, host online vs. offline (queued chats, 7-day expiry), photo attachments, and the security model
73
74
  - [Feeds](config/helps/feeds.md) — register a self-refreshing data feed (RSS/Atom/JSON) by authoring `feeds/<slug>/schema.json`: schema shape, the `ingest` block, raw-item field mapping, and `maxItems` retention
@@ -0,0 +1,24 @@
1
+ export interface FaqEntry {
2
+ symptom: string;
3
+ configKeys: string[];
4
+ sources: string[];
5
+ helps: string[];
6
+ }
7
+ export type FieldList = "configKeys" | "sources" | "helps";
8
+ export interface PointerLine {
9
+ list: FieldList;
10
+ value: string;
11
+ }
12
+ /** Read one `field: value` pointer line, or `null` for anything else — prose,
13
+ * a blank line, or a sentence that merely contains a colon. Split out so the
14
+ * line grammar can be tested on its own rather than through whole documents. */
15
+ export declare function parsePointerLine(line: string): PointerLine | null;
16
+ /** `## symptom` opens an entry; `field: value` lines under it are its pointers.
17
+ * Everything else is prose for the model to read. A field line before the
18
+ * first heading belongs to no entry and is dropped — the format block at the
19
+ * top of the file is documentation, not an entry. Fenced blocks are skipped
20
+ * for the same reason: the example in that block would parse as real. */
21
+ export declare function parseFaqEntries(markdown: string): FaqEntry[];
22
+ /** An entry with no pointer at all is prose the skill cannot verify against the
23
+ * running system, which is exactly what this format exists to prevent. */
24
+ export declare const entryHasPointer: (entry: FaqEntry) => boolean;
@@ -1,3 +1,4 @@
1
1
  export * from './sync.js';
2
2
  export * from './assets.js';
3
3
  export * from './slug.js';
4
+ export * from './faq-entries.js';
@@ -580,6 +580,55 @@ function seedHelps(opts) {
580
580
  for (const file of readdirSync(src)) copyFileSync(path.join(src, file), path.join(opts.destDir, file));
581
581
  }
582
582
  //#endregion
583
- export { PRESET_SLUG_PREFIX, _replaceSlugTree, helpsAssetDir, isPresetSlug, presetSkillsAssetDir, seedHelps, syncActivePresetSkills, syncPresetSkills };
583
+ //#region src/workspace-setup/faq-entries.ts
584
+ var FIELDS = /* @__PURE__ */ new Map([
585
+ ["configKey", "configKeys"],
586
+ ["source", "sources"],
587
+ ["help", "helps"]
588
+ ]);
589
+ var HEADING = "## ";
590
+ var FENCE = "```";
591
+ var newEntry = (symptom) => ({
592
+ symptom,
593
+ configKeys: [],
594
+ sources: [],
595
+ helps: []
596
+ });
597
+ /** Read one `field: value` pointer line, or `null` for anything else — prose,
598
+ * a blank line, or a sentence that merely contains a colon. Split out so the
599
+ * line grammar can be tested on its own rather than through whole documents. */
600
+ function parsePointerLine(line) {
601
+ const colon = line.indexOf(":");
602
+ if (colon <= 0) return null;
603
+ const list = FIELDS.get(line.slice(0, colon).trim());
604
+ const value = line.slice(colon + 1).trim();
605
+ return list && value ? {
606
+ list,
607
+ value
608
+ } : null;
609
+ }
610
+ /** `## symptom` opens an entry; `field: value` lines under it are its pointers.
611
+ * Everything else is prose for the model to read. A field line before the
612
+ * first heading belongs to no entry and is dropped — the format block at the
613
+ * top of the file is documentation, not an entry. Fenced blocks are skipped
614
+ * for the same reason: the example in that block would parse as real. */
615
+ function parseFaqEntries(markdown) {
616
+ const entries = [];
617
+ let inFence = false;
618
+ for (const line of markdown.split("\n")) if (line.startsWith(FENCE)) inFence = !inFence;
619
+ else if (inFence) continue;
620
+ else if (line.startsWith(HEADING)) entries.push(newEntry(line.slice(3).trim()));
621
+ else {
622
+ const pointer = parsePointerLine(line);
623
+ const current = entries[entries.length - 1];
624
+ if (pointer && current) current[pointer.list].push(pointer.value);
625
+ }
626
+ return entries;
627
+ }
628
+ /** An entry with no pointer at all is prose the skill cannot verify against the
629
+ * running system, which is exactly what this format exists to prevent. */
630
+ var entryHasPointer = (entry) => entry.configKeys.length + entry.sources.length + entry.helps.length > 0;
631
+ //#endregion
632
+ export { PRESET_SLUG_PREFIX, _replaceSlugTree, entryHasPointer, helpsAssetDir, isPresetSlug, parseFaqEntries, parsePointerLine, presetSkillsAssetDir, seedHelps, syncActivePresetSkills, syncPresetSkills };
584
633
 
585
634
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../../common/dist/index.js","../../src/workspace-setup/sync.ts","../../src/workspace-setup/assets.ts"],"sourcesContent":["// General-purpose runtime type guards, shared across the MulmoClaude host,\n// bridges, and plugins. This is a leaf package — pure and dependency-free — so\n// any tier can import it without creating an uphill edge.\n//\n// These originated as `server/utils/types.ts` (#504), which centralised 40+\n// hand-written inline `typeof x === \"object\"` checks. They are promoted here so\n// the same guards stop being re-hand-written in every bridge and plugin too.\n/** Narrow `unknown` to a plain object (not null, not array). */\nexport function isRecord(value) {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n/** Narrow `unknown` to any object (not null, arrays allowed).\n * Use `isRecord` when you need to access string keys. */\nexport function isObj(value) {\n return typeof value === \"object\" && value !== null;\n}\n/** Non-empty string after trimming whitespace. */\nexport function isNonEmptyString(value) {\n return typeof value === \"string\" && value.trim().length > 0;\n}\n/** Record whose values are all strings. */\nexport function isStringRecord(value) {\n if (!isRecord(value))\n return false;\n return Object.values(value).every((val) => typeof val === \"string\");\n}\n/** String array (every element is a string). */\nexport function isStringArray(value) {\n return Array.isArray(value) && value.every((val) => typeof val === \"string\");\n}\n/** An array of unknowns. Prefer this over a bare `Array.isArray` in typed code:\n * `Array.isArray(x: unknown)` narrows to `any[]`, silently reintroducing\n * `any`, whereas this keeps the element type `unknown`. */\nexport function isUnknownArray(value) {\n return Array.isArray(value);\n}\n/** Error-like object with a `code` property (e.g. Node.js fs errors). */\nexport function isErrorWithCode(value) {\n return isRecord(value) && typeof value.code === \"string\";\n}\n/** Check that a record has a specific key with a string value. */\nexport function hasStringProp(value, key) {\n return isRecord(value) && typeof value[key] === \"string\";\n}\n/** Check that a record has a specific key with a number value. */\nexport function hasNumberProp(value, key) {\n return isRecord(value) && typeof value[key] === \"number\";\n}\n/** Split a comma-separated env value into trimmed, non-empty entries.\n * `lowercase` folds case for identifiers compared case-insensitively\n * (JIDs, email addresses, hex pubkeys). Absent/empty input → empty list. */\nexport function parseCsvList(raw, opts) {\n return (raw ?? \"\")\n .split(\",\")\n .map((entry) => (opts?.lowercase ? entry.trim().toLowerCase() : entry.trim()))\n .filter(Boolean);\n}\n/** A comma-separated env value as a Set — the canonical allowlist shape,\n * where an empty set is the \"allow all\" sentinel (`set.size === 0`). */\nexport function parseCsvSet(raw, opts) {\n return new Set(parseCsvList(raw, opts));\n}\n/** Normalise an unknown thrown value into a human-readable string. Isomorphic\n * (host, bridges, plugins, Vue) — this is the single home for the helper that\n * #2217 could only consolidate for server code, since `@mulmoclaude/core/utils`\n * is server-only.\n *\n * A non-Error object with a non-empty string `details` (gRPC convention) or\n * `message` field surfaces that field — `details` wins — instead of the\n * `[object Object]` a bare `String(err)` would print; an empty-string field\n * falls through. `fallback` covers the error-boundary idiom where a thrown\n * non-Error should read as a descriptive message rather than `String(err)`\n * noise; omit it in logging contexts where `String(err)` is fine. */\nexport function errorMessage(err, fallback) {\n if (err instanceof Error)\n return err.message;\n if (hasStringProp(err, \"details\") && err.details)\n return err.details;\n if (hasStringProp(err, \"message\") && err.message)\n return err.message;\n if (fallback !== undefined)\n return fallback;\n return String(err);\n}\n/** `Date` → `YYYY-MM-DD` in UTC — for dates that must not shift with the\n * host's local timezone (tool-trace search dirs, API date keys). Isomorphic\n * single source (#2480): the host re-exports it from `server/utils/date.ts`,\n * x-plugin imports it directly. The `@receptron/task-scheduler` copy stays\n * local on purpose — that leaf package is published independently and kept\n * dependency-free. Wall-clock questions use the host's `toLocalIsoDate`. */\nexport function toUtcIsoDate(timestamp) {\n const year = timestamp.getUTCFullYear();\n const month = String(timestamp.getUTCMonth() + 1).padStart(2, \"0\");\n const day = String(timestamp.getUTCDate()).padStart(2, \"0\");\n return `${year}-${month}-${day}`;\n}\n// A Map, not an object literal: `{}[char]` reads through the prototype chain,\n// so a future caller widening the regex would silently get `[object Object]`\n// for keys like `constructor`.\nconst HTML_ESCAPES = new Map([\n [\"&\", \"&amp;\"],\n [\"<\", \"&lt;\"],\n [\">\", \"&gt;\"],\n ['\"', \"&quot;\"],\n [\"'\", \"&#39;\"],\n]);\n/** HTML-escape text destined for markup or an attribute value — the fixed\n * five-character map, nothing more. Lives here rather than in\n * `@mulmoclaude/core/wiki` (#2483) because `@mulmoclaude/markdown-utils` is a\n * leaf that core depends on and so cannot import back up; core/wiki\n * re-exports this, keeping its consumers' import path unchanged.\n *\n * Escaping `&` first is what makes a single pass safe — the entities this\n * introduces contain none of the other four characters, so nothing is\n * double-escaped. Not a sanitiser: it neither strips tags nor validates URLs. */\nexport function escapeHtml(value) {\n return value.replace(/[&<>\"']/g, (char) => HTML_ESCAPES.get(char) ?? char);\n}\nexport { scanEnvOptions, snakeToLowerCamel } from \"./envScan.js\";\n","// Preset skills bundled with mulmoclaude.\n//\n// History: introduced in #1210 to ship launcher-managed \"factory\"\n// skills like `mc-library`. Originally synced straight into\n// `<workspaceRoot>/.claude/skills/<slug>/`, which made every preset\n// auto-active and inflated the Claude system prompt as new presets\n// landed.\n//\n// #1335 PR-A flipped the destination to the catalog\n// (`<workspaceRoot>/data/skills/catalog/preset/<slug>/`). Catalog\n// entries are visible to UI / tooling but NOT discovered by Claude\n// Code's slash-command resolver — they don't enter the system\n// prompt unless the user (or a later UI in PR-B) explicitly copies\n// one into `.claude/skills/`.\n//\n// The launcher overwrites catalog entries unconditionally on every\n// boot — they're factory defaults, not user state. Anything in\n// `.claude/skills/` (active layer) is left untouched.\n//\n// `syncPresetSkills(...)` is exported as a pure-ish helper (takes\n// paths + a logger sink, returns a summary) so tests can drive it\n// against tmpdirs without touching a real workspace.\n\nimport { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, type Dirent } from \"node:fs\";\nimport { randomUUID } from \"node:crypto\";\nimport path from \"node:path\";\nimport { PRESET_SLUG_PREFIX, isPresetSlug } from \"./slug.js\";\nimport { errorMessage } from \"../utils/errors.js\";\n\n// Recursively mirror `srcDir` into `destDir`. Used by the preset\n// sync so a preset skill that ships sibling assets (e.g.\n// `schema.json` for schema-driven apps, `templates/*.html`) gets\n// copied alongside `SKILL.md` rather than silently dropped. Only\n// regular files and directories are followed — symlinks / FIFOs /\n// sockets are skipped because the preset tree is launcher-managed\n// and shouldn't contain them.\nfunction copyDirTreeSync(srcDir: string, destDir: string): void {\n mkdirSync(destDir, { recursive: true });\n for (const entry of readdirSync(srcDir, { withFileTypes: true })) {\n const srcPath = path.join(srcDir, entry.name);\n const destPath = path.join(destDir, entry.name);\n if (entry.isDirectory()) {\n copyDirTreeSync(srcPath, destPath);\n } else if (entry.isFile()) {\n copyFileSync(srcPath, destPath);\n }\n }\n}\n\nconst SKILL_FILENAME = \"SKILL.md\";\n\nexport interface SyncPresetSkillsOptions {\n /** Source directory: `<launcher>/server/workspace/skills-preset/`. */\n sourceDir: string;\n /** Destination directory:\n * `<workspaceRoot>/data/skills/catalog/preset/`. The catalog\n * half of the catalog-vs-active split — entries here are visible\n * to UI but NOT to Claude Code's prompt-time skill resolver. */\n destDir: string;\n /** Logger callbacks — kept injectable so tests don't need to\n * spin up the structured logger. The boot-side wrapper threads\n * these through to `log.info` / `log.warn`. */\n onInfo?: (message: string, data?: Record<string, unknown>) => void;\n onWarn?: (message: string, data?: Record<string, unknown>) => void;\n}\n\nexport interface SyncPresetSkillsResult {\n /** Slugs successfully copied (or refreshed) from source to dest. */\n copied: string[];\n /** Slugs removed from dest because they no longer exist in source.\n * Bounded to `mc-*` entries — user-authored slugs are never\n * considered for removal. */\n removed: string[];\n /** Source entries that failed validation (wrong prefix, missing\n * SKILL.md, etc.) and were skipped. Each entry is human-readable. */\n skipped: string[];\n}\n\n// Classification of one source entry. `silent` distinguishes\n// structural skips (hidden files, non-directory entries — not the\n// dev's fault) from misconfigurations (bad slug, missing or\n// non-regular SKILL.md — the dev WANTS to know). The boolean lives\n// on the verdict so the caller never has to string-match `reason`,\n// which would silently drop warnings if reason wording changed\n// (CodeRabbit review).\ntype Verdict = { ok: true } | { ok: false; reason: string; silent: boolean };\n\nfunction classifySourceEntry(sourceDir: string, entry: string): Verdict {\n if (entry.startsWith(\".\")) return { ok: false, reason: \"hidden\", silent: true };\n const slugDir = path.join(sourceDir, entry);\n let dirInfo;\n try {\n dirInfo = statSync(slugDir);\n } catch {\n return { ok: false, reason: \"stat failed\", silent: true };\n }\n if (!dirInfo.isDirectory()) return { ok: false, reason: \"not a directory\", silent: true };\n if (!isPresetSlug(entry)) return { ok: false, reason: `slug must start with \"${PRESET_SLUG_PREFIX}\"`, silent: false };\n // Validate SKILL.md is a regular file — `existsSync` alone\n // accepts a directory at that path, which would then crash\n // copyFileSync. Codex review caught this edge case.\n const skillPath = path.join(slugDir, SKILL_FILENAME);\n let skillInfo;\n try {\n skillInfo = statSync(skillPath);\n } catch {\n return { ok: false, reason: `missing ${SKILL_FILENAME}`, silent: false };\n }\n if (!skillInfo.isFile()) return { ok: false, reason: `${SKILL_FILENAME} must be a regular file`, silent: false };\n return { ok: true };\n}\n\n/** Prepare the destination slug dir. Returns false if the slot is\n * occupied by a regular file (local corruption / hand edits) — the\n * caller logs + skips so one bad entry can't crash the whole boot\n * (Codex review iter-1). */\nfunction ensureDestSlugDir(destSlugDir: string): boolean {\n let info;\n try {\n info = statSync(destSlugDir);\n } catch {\n mkdirSync(destSlugDir, { recursive: true });\n return true;\n }\n return info.isDirectory();\n}\n\n/** Filesystem ops `_replaceSlugTree` depends on — injectable so the\n * mid-swap failure path can be regression-tested without real IO faults. */\nexport interface SlugTreeFsOps {\n copyTree: (src: string, dest: string) => void;\n rename: (from: string, dest: string) => void;\n remove: (target: string) => void;\n exists: (target: string) => boolean;\n}\n\nconst REAL_FS_OPS: SlugTreeFsOps = {\n copyTree: copyDirTreeSync,\n rename: renameSync,\n remove: (target) => rmSync(target, { recursive: true, force: true }),\n exists: existsSync,\n};\n\n/** Refresh one preset slot as a rollback-safe stage-and-swap. Wipe-and-replace\n * (not merge) so stale sibling assets — e.g. a schema.json dropped between\n * releases — don't linger; the catalog preset slot is launcher-owned, so user\n * edits there are not preserved across boots.\n *\n * The live slot is never left without a recoverable copy:\n * 1. Stage: copy the source into a temp sibling. A copy failure leaves the\n * existing preset untouched (temp dir removed).\n * 2. Move the existing tree ASIDE to a backup (rename, not delete), so the\n * old contents survive even if the next step fails.\n * 3. Move the staged copy into place. On failure, restore from the backup;\n * if even the restore fails, BOTH backup and staging are preserved for\n * manual recovery rather than deleted.\n * 4. On success, drop the backup.\n *\n * Exported with a `_` prefix only so the rename-failure-after-move path can be\n * regression-tested via the injected `fsOps`. Throws on failure (caller records\n * + skips). */\nexport function _replaceSlugTree(sourceSlugDir: string, destSlugDir: string, fsOps: SlugTreeFsOps = REAL_FS_OPS): void {\n const staging = `${destSlugDir}.tmp-${randomUUID()}`;\n try {\n fsOps.copyTree(sourceSlugDir, staging);\n } catch (err) {\n fsOps.remove(staging); // dest untouched\n throw err;\n }\n const backup = `${destSlugDir}.bak-${randomUUID()}`;\n let backedUp = false;\n if (fsOps.exists(destSlugDir)) {\n try {\n fsOps.rename(destSlugDir, backup);\n backedUp = true;\n } catch (err) {\n fsOps.remove(staging); // dest untouched; nothing was moved\n throw err;\n }\n }\n try {\n fsOps.rename(staging, destSlugDir);\n } catch (err) {\n if (backedUp) {\n try {\n fsOps.rename(backup, destSlugDir); // roll back to the previous tree\n fsOps.remove(staging);\n } catch {\n // Rollback failed — preserve BOTH backup and staging for manual\n // recovery rather than leaving the slot empty.\n }\n } else {\n fsOps.remove(staging); // dest never existed; nothing to restore\n }\n throw err;\n }\n if (backedUp) fsOps.remove(backup);\n}\n\nfunction copySourcesIntoDest(sourceDir: string, destDir: string, opts: SyncPresetSkillsOptions, result: SyncPresetSkillsResult): Set<string> {\n // `keep` is the set of slugs the retirement pass must NOT prune. It tracks\n // valid SOURCE slugs (present + usable dest slot), NOT successful copies — so\n // a slug whose refresh fails transiently keeps its existing contents (left\n // intact by the stage-and-swap above) instead of being pruned as \"retired\".\n const keep = new Set<string>();\n for (const entry of readdirSync(sourceDir)) {\n const verdict = classifySourceEntry(sourceDir, entry);\n if (!verdict.ok) {\n if (!verdict.silent) {\n result.skipped.push(`${entry}: ${verdict.reason}`);\n opts.onWarn?.(\"preset entry skipped\", { slug: entry, reason: verdict.reason });\n }\n continue;\n }\n const destSlugDir = path.join(destDir, entry);\n if (!ensureDestSlugDir(destSlugDir)) {\n const reason = \"destination slot occupied by a non-directory; skipping\";\n result.skipped.push(`${entry}: ${reason}`);\n opts.onWarn?.(\"preset entry skipped\", { slug: entry, reason, destSlugDir });\n continue;\n }\n // The slug exists in source with a usable dest slot — keep it regardless of\n // whether the refresh below succeeds, so a transient failure doesn't get it\n // pruned by `removeRetiredPresets`.\n keep.add(entry);\n // Per-slug isolation: a transient IO error / permission issue / partial\n // corruption on one preset must not abort syncing the rest, nor destroy\n // the existing copy. Skip the offender (recorded) and continue.\n try {\n _replaceSlugTree(path.join(sourceDir, entry), destSlugDir);\n result.copied.push(entry);\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err);\n result.skipped.push(`${entry}: ${reason}`);\n opts.onWarn?.(\"preset copy failed, skipping\", { slug: entry, reason });\n }\n }\n return keep;\n}\n\nfunction removeRetiredPresets(destDir: string, keep: ReadonlySet<string>, opts: SyncPresetSkillsOptions, result: SyncPresetSkillsResult): void {\n for (const entry of readdirSync(destDir)) {\n if (!isPresetSlug(entry)) continue;\n if (keep.has(entry)) continue;\n const stalePath = path.join(destDir, entry);\n try {\n if (!statSync(stalePath).isDirectory()) continue;\n } catch {\n continue;\n }\n rmSync(stalePath, { recursive: true, force: true });\n result.removed.push(entry);\n opts.onInfo?.(\"removed retired preset skill\", { slug: entry });\n }\n}\n\n/** Copy every preset slug from `sourceDir` into `destDir` (the\n * preset slot under the catalog root), then remove any `mc-*`\n * entries in `destDir` that no longer have a source. The catalog\n * preset subdir is fully launcher-owned, so the `mc-*` prefix\n * check at destination is defence-in-depth: a stray non-preset\n * slug landing in `catalog/preset/` is unexpected, and we'd\n * rather skip it than silently delete a directory we don't\n * recognise. */\nexport function syncPresetSkills(opts: SyncPresetSkillsOptions): SyncPresetSkillsResult {\n const result: SyncPresetSkillsResult = { copied: [], removed: [], skipped: [] };\n if (!existsSync(opts.sourceDir)) {\n // No preset directory in the launcher tarball — nothing to do.\n // This is the legitimate \"no presets shipped yet\" state.\n return result;\n }\n // Source-side validation: the launcher's preset path COULD exist\n // as a regular file (a packaging bug, a corrupted install). Without\n // this guard, `readdirSync(sourceDir)` would throw ENOTDIR and\n // crash boot. Codex review iter-3.\n let sourceInfo;\n try {\n sourceInfo = statSync(opts.sourceDir);\n } catch (err) {\n const reason = `source path stat failed: ${errorMessage(err)}`;\n result.skipped.push(`${opts.sourceDir}: ${reason}`);\n opts.onWarn?.(\"preset sync aborted\", { sourceDir: opts.sourceDir, reason });\n return result;\n }\n if (!sourceInfo.isDirectory()) {\n const reason = \"source path exists as a non-directory; preset sync skipped\";\n result.skipped.push(`${opts.sourceDir}: ${reason}`);\n opts.onWarn?.(\"preset sync aborted\", { sourceDir: opts.sourceDir, reason });\n return result;\n }\n // The root dest itself can be corrupted into a regular file by a\n // user / external tool; mkdirSync would throw EEXIST and crash\n // boot. Treat it as a recoverable \"skip the entire sync\" state\n // — log a clear warning so the user sees what to fix.\n // (Codex review iter-2.)\n if (!ensureDestSlugDir(opts.destDir)) {\n const reason = \"root dest exists as a non-directory; preset sync skipped\";\n result.skipped.push(`${opts.destDir}: ${reason}`);\n opts.onWarn?.(\"preset sync aborted\", { destDir: opts.destDir, reason });\n return result;\n }\n const keep = copySourcesIntoDest(opts.sourceDir, opts.destDir, opts, result);\n removeRetiredPresets(opts.destDir, keep, opts, result);\n if (result.copied.length > 0 || result.removed.length > 0) {\n opts.onInfo?.(\"preset skills synced\", {\n copied: result.copied.length,\n removed: result.removed.length,\n skipped: result.skipped.length,\n });\n }\n return result;\n}\n\n// ---------------------------------------------------------------\n// Active-layer sync: keep starred mc-* presets in lockstep with\n// their launcher-bundled source.\n//\n// Motivation: the catalog sync above refreshes\n// `data/skills/catalog/preset/<slug>/` on every boot, but once a\n// user stars an entry the active copy in\n// `<workspace>/.claude/skills/<slug>/` is never updated even when\n// the launcher ships a new SKILL.md (e.g. a typo fix or, as the\n// trigger for this code, the schema-driven-apps → collections\n// rename). The SKILL.md front-matter explicitly says\n// \"do not edit this file in the workspace, it is overwritten on\n// every server boot\" — until this function existed, that was a\n// promise the active layer didn't keep.\n//\n// Safety model:\n// - Only `mc-*` slugs are touched (defensive prefix check —\n// never touches user-authored skills).\n// - Per-file diff: a file is overwritten only if its bytes\n// differ from the source. No-op when already up to date.\n// - User-added files inside an active slug dir are left alone\n// (we walk the source tree, not the dest tree).\n// - If a file IS overwritten, the previous contents are first\n// renamed to `<file>.bak.<timestamp>` so a user who had\n// locally tweaked the preset can recover.\n// - A slug whose active dir doesn't exist (= not starred yet)\n// is skipped entirely, never auto-starred.\n// ---------------------------------------------------------------\n\nexport interface SyncActivePresetSkillsOptions {\n /** Source directory: `<launcher>/server/workspace/skills-preset/`. */\n sourceDir: string;\n /** Active skills directory: `<workspaceRoot>/.claude/skills/`. */\n activeDir: string;\n onInfo?: (message: string, data?: Record<string, unknown>) => void;\n onWarn?: (message: string, data?: Record<string, unknown>) => void;\n}\n\nexport interface SyncActivePresetSkillsResult {\n /** Slugs whose active copy had at least one file overwritten. */\n updated: string[];\n /** Slugs whose active copy already matched the source — no-op. */\n unchanged: string[];\n /** Slugs that haven't been starred yet (no active dir present).\n * Listed for diagnostics; the function never auto-stars. */\n notActive: string[];\n /** Active `mc-*` slugs pruned because the launcher no longer ships a\n * source preset for them (a retired preset). Bounded to `mc-*` —\n * user-authored skills are never removed. */\n removed: string[];\n /** Per-slug failure messages (permission errors, etc.). */\n skipped: string[];\n /** Common timestamp suffix used for every backup file produced by\n * this run. Exposed so the boot-time log can point a user at the\n * exact glob to inspect. */\n backupSuffix: string | null;\n}\n\ntype FileSyncOutcome = \"updated\" | \"unchanged\" | \"skipped\";\n\nfunction filesEqual(left: string, right: string): boolean {\n try {\n return readFileSync(left).equals(readFileSync(right));\n } catch {\n return false;\n }\n}\n\n/** Copy `srcPath` over `destPath`. If `destPath` already exists and\n * differs, rename it to `destPath + backupExt` first so the user's\n * prior contents are recoverable. If `destPath` doesn't exist, just\n * copy (no backup needed). Pure: no logging here — caller decides. */\nfunction syncOneFile(srcPath: string, destPath: string, backupExt: string): FileSyncOutcome {\n let destExists: boolean;\n try {\n destExists = statSync(destPath).isFile();\n } catch {\n destExists = false;\n }\n if (!destExists) {\n try {\n copyFileSync(srcPath, destPath);\n return \"updated\";\n } catch {\n return \"skipped\";\n }\n }\n if (filesEqual(srcPath, destPath)) return \"unchanged\";\n try {\n renameSync(destPath, destPath + backupExt);\n copyFileSync(srcPath, destPath);\n return \"updated\";\n } catch {\n return \"skipped\";\n }\n}\n\ninterface DirSyncStats {\n updated: number;\n unchanged: number;\n skipped: number;\n}\n\nfunction syncDirTreeDiff(srcDir: string, destDir: string, backupExt: string): DirSyncStats {\n const stats: DirSyncStats = { updated: 0, unchanged: 0, skipped: 0 };\n try {\n mkdirSync(destDir, { recursive: true });\n } catch {\n stats.skipped++;\n return stats;\n }\n let entries: Dirent[];\n try {\n entries = readdirSync(srcDir, { withFileTypes: true });\n } catch {\n stats.skipped++;\n return stats;\n }\n for (const entry of entries) {\n const srcPath = path.join(srcDir, entry.name);\n const destPath = path.join(destDir, entry.name);\n if (entry.isDirectory()) {\n const sub = syncDirTreeDiff(srcPath, destPath, backupExt);\n stats.updated += sub.updated;\n stats.unchanged += sub.unchanged;\n stats.skipped += sub.skipped;\n } else if (entry.isFile()) {\n const outcome = syncOneFile(srcPath, destPath, backupExt);\n stats[outcome]++;\n }\n // Symlinks / sockets / FIFOs intentionally ignored (the launcher\n // preset tree shouldn't contain them).\n }\n return stats;\n}\n\nfunction abortActiveSync(opts: SyncActivePresetSkillsOptions, result: SyncActivePresetSkillsResult, reason: string): SyncActivePresetSkillsResult {\n result.skipped.push(`${opts.sourceDir}: ${reason}`);\n opts.onWarn?.(\"active preset sync aborted\", { sourceDir: opts.sourceDir, reason });\n return result;\n}\n\n/** True iff `absPath`'s realpath resolves inside `rootPath`'s\n * realpath. Defends `processActiveSlug` against a starred `mc-*`\n * slug that's actually a symlink to somewhere outside\n * `activeDir`: without this check, the recursive copy below would\n * follow the symlink and write through to the link's target\n * (potentially anywhere on disk). Returns false on any error so\n * the caller treats unreadable paths as \"refused\" rather than\n * \"OK to write\". */\nfunction isRealpathInside(absPath: string, rootPath: string): boolean {\n try {\n const real = realpathSync(absPath);\n const rootReal = realpathSync(rootPath);\n return real === rootReal || real.startsWith(rootReal + path.sep);\n } catch {\n return false;\n }\n}\n\ntype DestVerdict = { ok: true } | { ok: false; reason: string } | { kind: \"not-active\" };\n\n/** Validate the active dest slug dir before writing through it.\n * Returns:\n * - `{ ok: true }` — proceed with the sync\n * - `{ kind: \"not-active\" }` — slug hasn't been starred yet\n * - `{ ok: false; reason }` — refuse to write (symlink escape,\n * non-directory, ancestor escape)\n *\n * Symlink defenses (Codex P1 review on PR #1490): `statSync`\n * follows symlinks, so a starred `mc-*` slug that's actually a\n * symlink to /etc would let the recursive copy below write\n * outside the workspace. Two-layer defense:\n * 1. `lstatSync` to see the link itself; if it's a symlink,\n * only accept when its target stays inside `activeDir`.\n * 2. Always realpath-verify the full path (catches the case\n * where an ancestor like `.claude/` is symlinked even when\n * the slug dir itself is a regular directory). */\nfunction classifyActiveDest(slug: string, destSlugDir: string, activeDir: string): DestVerdict {\n let destInfo;\n try {\n destInfo = lstatSync(destSlugDir);\n } catch {\n return { kind: \"not-active\" };\n }\n if (destInfo.isSymbolicLink()) {\n if (!isRealpathInside(destSlugDir, activeDir)) {\n return { ok: false, reason: \"active slot is a symlink whose target escapes activeDir; refusing to write through it\" };\n }\n return { ok: true };\n }\n if (!destInfo.isDirectory()) {\n return { ok: false, reason: \"active slot is occupied by a non-directory; skipping\" };\n }\n if (!isRealpathInside(destSlugDir, activeDir)) {\n return { ok: false, reason: \"active slug dir escapes activeDir via an ancestor symlink\" };\n }\n return { ok: true };\n}\n\n/** Per-slug worker for `syncActivePresetSkills`. Extracted to keep\n * the outer function under the `sonarjs/cognitive-complexity`\n * threshold; no behavior difference vs the inline loop. */\nfunction processActiveSlug(slug: string, opts: SyncActivePresetSkillsOptions, result: SyncActivePresetSkillsResult, backupExt: string): void {\n const srcSlugDir = path.join(opts.sourceDir, slug);\n try {\n if (!statSync(srcSlugDir).isDirectory()) return;\n } catch {\n return;\n }\n const destSlugDir = path.join(opts.activeDir, slug);\n const verdict = classifyActiveDest(slug, destSlugDir, opts.activeDir);\n if (\"kind\" in verdict) {\n result.notActive.push(slug);\n return;\n }\n if (!verdict.ok) {\n result.skipped.push(`${slug}: ${verdict.reason}`);\n opts.onWarn?.(\"active preset sync skipped\", { slug, reason: verdict.reason, destSlugDir });\n return;\n }\n const stats = syncDirTreeDiff(srcSlugDir, destSlugDir, backupExt);\n if (stats.updated > 0) {\n result.updated.push(slug);\n opts.onInfo?.(\"active preset skill updated from source\", { slug, files: stats.updated, backupSuffix: backupExt });\n } else if (stats.skipped === 0) {\n result.unchanged.push(slug);\n }\n if (stats.skipped > 0) {\n result.skipped.push(`${slug}: ${stats.skipped} file(s) skipped`);\n opts.onWarn?.(\"active preset skill partial update\", { slug, skipped: stats.skipped });\n }\n}\n\n/** Realpath targets of active symlinks. Used so the prune below never\n * deletes a directory that's serving as a symlink target (e.g. a\n * git-managed checkout the user symlinks an active slug to). */\nfunction collectSymlinkTargets(activeDir: string, entries: readonly string[]): Set<string> {\n const targets = new Set<string>();\n for (const slug of entries) {\n const entryPath = path.join(activeDir, slug);\n try {\n if (lstatSync(entryPath).isSymbolicLink()) targets.add(realpathSync(entryPath));\n } catch {\n /* unreadable — ignore */\n }\n }\n return targets;\n}\n\n/** True iff the active `slug` is a retired preset safe to delete: an\n * `mc-*` real directory inside `activeDir`, with no source preset, and\n * not itself a symlink or a symlink target. */\nfunction isRetiredActiveSlug(slug: string, activeDir: string, sourceSlugs: ReadonlySet<string>, symlinkTargets: ReadonlySet<string>): boolean {\n if (!isPresetSlug(slug) || sourceSlugs.has(slug)) return false;\n const destSlugDir = path.join(activeDir, slug);\n let info;\n try {\n info = lstatSync(destSlugDir);\n } catch {\n return false;\n }\n if (info.isSymbolicLink() || !info.isDirectory()) return false;\n if (!isRealpathInside(destSlugDir, activeDir)) return false;\n try {\n return !symlinkTargets.has(realpathSync(destSlugDir));\n } catch {\n return false;\n }\n}\n\n/** Prune active `mc-*` slugs whose launcher source preset no longer\n * exists (a retired preset). Without this, an upgraded workspace that\n * had starred a since-removed preset (e.g. `mc-manage-sources`) keeps\n * the active copy forever — Claude would still discover a skill whose\n * backend was deleted. Bounded to `mc-*` real directories that stay\n * inside `activeDir` (never a symlink escape, never a user skill). */\nfunction removeRetiredActivePresets(opts: SyncActivePresetSkillsOptions, result: SyncActivePresetSkillsResult, sourceSlugs: ReadonlySet<string>): void {\n let activeEntries: string[];\n try {\n activeEntries = readdirSync(opts.activeDir);\n } catch {\n return; // no active dir yet → nothing to prune\n }\n const symlinkTargets = collectSymlinkTargets(opts.activeDir, activeEntries);\n for (const slug of activeEntries) {\n if (!isRetiredActiveSlug(slug, opts.activeDir, sourceSlugs, symlinkTargets)) continue;\n try {\n rmSync(path.join(opts.activeDir, slug), { recursive: true, force: true });\n result.removed.push(slug);\n opts.onInfo?.(\"removed retired active preset skill\", { slug });\n } catch (err) {\n result.skipped.push(`${slug}: prune failed: ${errorMessage(err)}`);\n opts.onWarn?.(\"active preset prune failed\", { slug, reason: errorMessage(err) });\n }\n }\n}\n\n/** Refresh every already-starred `mc-*` preset's active copy in\n * `<workspaceRoot>/.claude/skills/<slug>/` to match the source.\n * Per-file diff with `.bak.<timestamp>` backup on overwrite. Slugs\n * that aren't starred yet are listed in `notActive` but never\n * auto-created. */\nexport function syncActivePresetSkills(opts: SyncActivePresetSkillsOptions): SyncActivePresetSkillsResult {\n const result: SyncActivePresetSkillsResult = { updated: [], unchanged: [], notActive: [], removed: [], skipped: [], backupSuffix: null };\n if (!existsSync(opts.sourceDir)) return result;\n let sourceInfo;\n try {\n sourceInfo = statSync(opts.sourceDir);\n } catch (err) {\n return abortActiveSync(opts, result, `source path stat failed: ${errorMessage(err)}`);\n }\n if (!sourceInfo.isDirectory()) {\n return abortActiveSync(opts, result, \"source path exists as a non-directory; active preset sync skipped\");\n }\n let sourceEntries: string[];\n try {\n sourceEntries = readdirSync(opts.sourceDir);\n } catch (err) {\n return abortActiveSync(opts, result, `source readdir failed: ${errorMessage(err)}`);\n }\n // Single timestamp per run so a multi-file update produces sibling\n // backups with a matching suffix — easier to grep / restore.\n const backupExt = `.bak.${Date.now()}`;\n result.backupSuffix = backupExt;\n const sourceSlugs = new Set<string>();\n for (const slug of sourceEntries) {\n if (slug.startsWith(\".\")) continue;\n if (!isPresetSlug(slug)) continue;\n sourceSlugs.add(slug);\n processActiveSlug(slug, opts, result, backupExt);\n }\n // Prune active copies of presets the launcher no longer ships.\n removeRetiredActivePresets(opts, result, sourceSlugs);\n if (result.updated.length > 0 || result.removed.length > 0) {\n opts.onInfo?.(\"active preset skills synced\", {\n updated: result.updated.length,\n unchanged: result.unchanged.length,\n notActive: result.notActive.length,\n removed: result.removed.length,\n skipped: result.skipped.length,\n backupSuffix: backupExt,\n });\n }\n return result;\n}\n","// Resolve the package's BUNDLED assets (shipped via package.json `files`) and seed\n// them into a workspace. ESM-only: `import.meta.url` points at this module under\n// `dist/workspace-setup/`, so `../../assets` is the package's assets dir at the\n// package root. (The workspace-setup entries build ESM only — `import.meta.url`\n// isn't available under CJS; both hosts run the server as ESM via tsx.)\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { copyFileSync, mkdirSync, readdirSync } from \"node:fs\";\n\n// The asset dir is shipped separately (package.json `files`), NOT bundled, so\n// this URL must resolve at runtime. `@vite-ignore` silences Vite's build-time\n// \"doesn't exist at build time\" warning — runtime resolution is intended.\nconst ASSETS_DIR = fileURLToPath(new URL(/* @vite-ignore */ \"../../assets\", import.meta.url));\n\n/** The bundled help-docs source dir (`assets/helps/`). */\nexport function helpsAssetDir(): string {\n return path.join(ASSETS_DIR, \"helps\");\n}\n\n/** The bundled preset-skills source dir (`assets/skills-preset/`) — pass as the\n * `sourceDir` of `syncPresetSkills` / `syncActivePresetSkills`. */\nexport function presetSkillsAssetDir(): string {\n return path.join(ASSETS_DIR, \"skills-preset\");\n}\n\n/** Copy every bundled help doc into `destDir` (created if missing). Idempotent —\n * overwrites on each call so the help docs always track the package's version. */\nexport function seedHelps(opts: { destDir: string }): void {\n mkdirSync(opts.destDir, { recursive: true });\n const src = helpsAssetDir();\n for (const file of readdirSync(src)) {\n copyFileSync(path.join(src, file), path.join(opts.destDir, file));\n }\n}\n"],"mappings":";;;;;;;AAQA,SAAgB,SAAS,OAAO;CAC5B,OAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC9E;;AA+BA,SAAgB,cAAc,OAAO,KAAK;CACtC,OAAO,SAAS,KAAK,KAAK,OAAO,MAAM,SAAS;AACpD;;;;;;;;;;;;AA8BA,SAAgB,aAAa,KAAK,UAAU;CACxC,IAAI,eAAe,OACf,OAAO,IAAI;CACf,IAAI,cAAc,KAAK,SAAS,KAAK,IAAI,SACrC,OAAO,IAAI;CACf,IAAI,cAAc,KAAK,SAAS,KAAK,IAAI,SACrC,OAAO,IAAI;CACf,IAAI,aAAa,KAAA,GACb,OAAO;CACX,OAAO,OAAO,GAAG;AACrB;;;AC/CA,SAAS,gBAAgB,QAAgB,SAAuB;CAC9D,UAAU,SAAS,EAAE,WAAW,KAAK,CAAC;CACtC,KAAK,MAAM,SAAS,YAAY,QAAQ,EAAE,eAAe,KAAK,CAAC,GAAG;EAChE,MAAM,UAAU,KAAK,KAAK,QAAQ,MAAM,IAAI;EAC5C,MAAM,WAAW,KAAK,KAAK,SAAS,MAAM,IAAI;EAC9C,IAAI,MAAM,YAAY,GACpB,gBAAgB,SAAS,QAAQ;OAC5B,IAAI,MAAM,OAAO,GACtB,aAAa,SAAS,QAAQ;CAElC;AACF;AAEA,IAAM,iBAAiB;AAsCvB,SAAS,oBAAoB,WAAmB,OAAwB;CACtE,IAAI,MAAM,WAAW,GAAG,GAAG,OAAO;EAAE,IAAI;EAAO,QAAQ;EAAU,QAAQ;CAAK;CAC9E,MAAM,UAAU,KAAK,KAAK,WAAW,KAAK;CAC1C,IAAI;CACJ,IAAI;EACF,UAAU,SAAS,OAAO;CAC5B,QAAQ;EACN,OAAO;GAAE,IAAI;GAAO,QAAQ;GAAe,QAAQ;EAAK;CAC1D;CACA,IAAI,CAAC,QAAQ,YAAY,GAAG,OAAO;EAAE,IAAI;EAAO,QAAQ;EAAmB,QAAQ;CAAK;CACxF,IAAI,CAAC,aAAa,KAAK,GAAG,OAAO;EAAE,IAAI;EAAO,QAAQ;EAAgD,QAAQ;CAAM;CAIpH,MAAM,YAAY,KAAK,KAAK,SAAS,cAAc;CACnD,IAAI;CACJ,IAAI;EACF,YAAY,SAAS,SAAS;CAChC,QAAQ;EACN,OAAO;GAAE,IAAI;GAAO,QAAQ,WAAW;GAAkB,QAAQ;EAAM;CACzE;CACA,IAAI,CAAC,UAAU,OAAO,GAAG,OAAO;EAAE,IAAI;EAAO,QAAQ,GAAG,eAAe;EAA0B,QAAQ;CAAM;CAC/G,OAAO,EAAE,IAAI,KAAK;AACpB;;;;;AAMA,SAAS,kBAAkB,aAA8B;CACvD,IAAI;CACJ,IAAI;EACF,OAAO,SAAS,WAAW;CAC7B,QAAQ;EACN,UAAU,aAAa,EAAE,WAAW,KAAK,CAAC;EAC1C,OAAO;CACT;CACA,OAAO,KAAK,YAAY;AAC1B;AAWA,IAAM,cAA6B;CACjC,UAAU;CACV,QAAQ;CACR,SAAS,WAAW,OAAO,QAAQ;EAAE,WAAW;EAAM,OAAO;CAAK,CAAC;CACnE,QAAQ;AACV;;;;;;;;;;;;;;;;;;;AAoBA,SAAgB,iBAAiB,eAAuB,aAAqB,QAAuB,aAAmB;CACrH,MAAM,UAAU,GAAG,YAAY,OAAO,WAAW;CACjD,IAAI;EACF,MAAM,SAAS,eAAe,OAAO;CACvC,SAAS,KAAK;EACZ,MAAM,OAAO,OAAO;EACpB,MAAM;CACR;CACA,MAAM,SAAS,GAAG,YAAY,OAAO,WAAW;CAChD,IAAI,WAAW;CACf,IAAI,MAAM,OAAO,WAAW,GAC1B,IAAI;EACF,MAAM,OAAO,aAAa,MAAM;EAChC,WAAW;CACb,SAAS,KAAK;EACZ,MAAM,OAAO,OAAO;EACpB,MAAM;CACR;CAEF,IAAI;EACF,MAAM,OAAO,SAAS,WAAW;CACnC,SAAS,KAAK;EACZ,IAAI,UACF,IAAI;GACF,MAAM,OAAO,QAAQ,WAAW;GAChC,MAAM,OAAO,OAAO;EACtB,QAAQ,CAGR;OAEA,MAAM,OAAO,OAAO;EAEtB,MAAM;CACR;CACA,IAAI,UAAU,MAAM,OAAO,MAAM;AACnC;AAEA,SAAS,oBAAoB,WAAmB,SAAiB,MAA+B,QAA6C;CAK3I,MAAM,uBAAO,IAAI,IAAY;CAC7B,KAAK,MAAM,SAAS,YAAY,SAAS,GAAG;EAC1C,MAAM,UAAU,oBAAoB,WAAW,KAAK;EACpD,IAAI,CAAC,QAAQ,IAAI;GACf,IAAI,CAAC,QAAQ,QAAQ;IACnB,OAAO,QAAQ,KAAK,GAAG,MAAM,IAAI,QAAQ,QAAQ;IACjD,KAAK,SAAS,wBAAwB;KAAE,MAAM;KAAO,QAAQ,QAAQ;IAAO,CAAC;GAC/E;GACA;EACF;EACA,MAAM,cAAc,KAAK,KAAK,SAAS,KAAK;EAC5C,IAAI,CAAC,kBAAkB,WAAW,GAAG;GACnC,MAAM,SAAS;GACf,OAAO,QAAQ,KAAK,GAAG,MAAM,IAAI,QAAQ;GACzC,KAAK,SAAS,wBAAwB;IAAE,MAAM;IAAO;IAAQ;GAAY,CAAC;GAC1E;EACF;EAIA,KAAK,IAAI,KAAK;EAId,IAAI;GACF,iBAAiB,KAAK,KAAK,WAAW,KAAK,GAAG,WAAW;GACzD,OAAO,OAAO,KAAK,KAAK;EAC1B,SAAS,KAAK;GACZ,MAAM,SAAS,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GAC9D,OAAO,QAAQ,KAAK,GAAG,MAAM,IAAI,QAAQ;GACzC,KAAK,SAAS,gCAAgC;IAAE,MAAM;IAAO;GAAO,CAAC;EACvE;CACF;CACA,OAAO;AACT;AAEA,SAAS,qBAAqB,SAAiB,MAA2B,MAA+B,QAAsC;CAC7I,KAAK,MAAM,SAAS,YAAY,OAAO,GAAG;EACxC,IAAI,CAAC,aAAa,KAAK,GAAG;EAC1B,IAAI,KAAK,IAAI,KAAK,GAAG;EACrB,MAAM,YAAY,KAAK,KAAK,SAAS,KAAK;EAC1C,IAAI;GACF,IAAI,CAAC,SAAS,SAAS,CAAC,CAAC,YAAY,GAAG;EAC1C,QAAQ;GACN;EACF;EACA,OAAO,WAAW;GAAE,WAAW;GAAM,OAAO;EAAK,CAAC;EAClD,OAAO,QAAQ,KAAK,KAAK;EACzB,KAAK,SAAS,gCAAgC,EAAE,MAAM,MAAM,CAAC;CAC/D;AACF;;;;;;;;;AAUA,SAAgB,iBAAiB,MAAuD;CACtF,MAAM,SAAiC;EAAE,QAAQ,CAAC;EAAG,SAAS,CAAC;EAAG,SAAS,CAAC;CAAE;CAC9E,IAAI,CAAC,WAAW,KAAK,SAAS,GAG5B,OAAO;CAMT,IAAI;CACJ,IAAI;EACF,aAAa,SAAS,KAAK,SAAS;CACtC,SAAS,KAAK;EACZ,MAAM,SAAS,4BAA4B,aAAa,GAAG;EAC3D,OAAO,QAAQ,KAAK,GAAG,KAAK,UAAU,IAAI,QAAQ;EAClD,KAAK,SAAS,uBAAuB;GAAE,WAAW,KAAK;GAAW;EAAO,CAAC;EAC1E,OAAO;CACT;CACA,IAAI,CAAC,WAAW,YAAY,GAAG;EAC7B,MAAM,SAAS;EACf,OAAO,QAAQ,KAAK,GAAG,KAAK,UAAU,IAAI,QAAQ;EAClD,KAAK,SAAS,uBAAuB;GAAE,WAAW,KAAK;GAAW;EAAO,CAAC;EAC1E,OAAO;CACT;CAMA,IAAI,CAAC,kBAAkB,KAAK,OAAO,GAAG;EACpC,MAAM,SAAS;EACf,OAAO,QAAQ,KAAK,GAAG,KAAK,QAAQ,IAAI,QAAQ;EAChD,KAAK,SAAS,uBAAuB;GAAE,SAAS,KAAK;GAAS;EAAO,CAAC;EACtE,OAAO;CACT;CACA,MAAM,OAAO,oBAAoB,KAAK,WAAW,KAAK,SAAS,MAAM,MAAM;CAC3E,qBAAqB,KAAK,SAAS,MAAM,MAAM,MAAM;CACrD,IAAI,OAAO,OAAO,SAAS,KAAK,OAAO,QAAQ,SAAS,GACtD,KAAK,SAAS,wBAAwB;EACpC,QAAQ,OAAO,OAAO;EACtB,SAAS,OAAO,QAAQ;EACxB,SAAS,OAAO,QAAQ;CAC1B,CAAC;CAEH,OAAO;AACT;AA8DA,SAAS,WAAW,MAAc,OAAwB;CACxD,IAAI;EACF,OAAO,aAAa,IAAI,CAAC,CAAC,OAAO,aAAa,KAAK,CAAC;CACtD,QAAQ;EACN,OAAO;CACT;AACF;;;;;AAMA,SAAS,YAAY,SAAiB,UAAkB,WAAoC;CAC1F,IAAI;CACJ,IAAI;EACF,aAAa,SAAS,QAAQ,CAAC,CAAC,OAAO;CACzC,QAAQ;EACN,aAAa;CACf;CACA,IAAI,CAAC,YACH,IAAI;EACF,aAAa,SAAS,QAAQ;EAC9B,OAAO;CACT,QAAQ;EACN,OAAO;CACT;CAEF,IAAI,WAAW,SAAS,QAAQ,GAAG,OAAO;CAC1C,IAAI;EACF,WAAW,UAAU,WAAW,SAAS;EACzC,aAAa,SAAS,QAAQ;EAC9B,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF;AAQA,SAAS,gBAAgB,QAAgB,SAAiB,WAAiC;CACzF,MAAM,QAAsB;EAAE,SAAS;EAAG,WAAW;EAAG,SAAS;CAAE;CACnE,IAAI;EACF,UAAU,SAAS,EAAE,WAAW,KAAK,CAAC;CACxC,QAAQ;EACN,MAAM;EACN,OAAO;CACT;CACA,IAAI;CACJ,IAAI;EACF,UAAU,YAAY,QAAQ,EAAE,eAAe,KAAK,CAAC;CACvD,QAAQ;EACN,MAAM;EACN,OAAO;CACT;CACA,KAAK,MAAM,SAAS,SAAS;EAC3B,MAAM,UAAU,KAAK,KAAK,QAAQ,MAAM,IAAI;EAC5C,MAAM,WAAW,KAAK,KAAK,SAAS,MAAM,IAAI;EAC9C,IAAI,MAAM,YAAY,GAAG;GACvB,MAAM,MAAM,gBAAgB,SAAS,UAAU,SAAS;GACxD,MAAM,WAAW,IAAI;GACrB,MAAM,aAAa,IAAI;GACvB,MAAM,WAAW,IAAI;EACvB,OAAO,IAAI,MAAM,OAAO,GAAG;GACzB,MAAM,UAAU,YAAY,SAAS,UAAU,SAAS;GACxD,MAAM,QAAQ;EAChB;CAGF;CACA,OAAO;AACT;AAEA,SAAS,gBAAgB,MAAqC,QAAsC,QAA8C;CAChJ,OAAO,QAAQ,KAAK,GAAG,KAAK,UAAU,IAAI,QAAQ;CAClD,KAAK,SAAS,8BAA8B;EAAE,WAAW,KAAK;EAAW;CAAO,CAAC;CACjF,OAAO;AACT;;;;;;;;;AAUA,SAAS,iBAAiB,SAAiB,UAA2B;CACpE,IAAI;EACF,MAAM,OAAO,aAAa,OAAO;EACjC,MAAM,WAAW,aAAa,QAAQ;EACtC,OAAO,SAAS,YAAY,KAAK,WAAW,WAAW,KAAK,GAAG;CACjE,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;;;;;;;;;AAoBA,SAAS,mBAAmB,MAAc,aAAqB,WAAgC;CAC7F,IAAI;CACJ,IAAI;EACF,WAAW,UAAU,WAAW;CAClC,QAAQ;EACN,OAAO,EAAE,MAAM,aAAa;CAC9B;CACA,IAAI,SAAS,eAAe,GAAG;EAC7B,IAAI,CAAC,iBAAiB,aAAa,SAAS,GAC1C,OAAO;GAAE,IAAI;GAAO,QAAQ;EAAwF;EAEtH,OAAO,EAAE,IAAI,KAAK;CACpB;CACA,IAAI,CAAC,SAAS,YAAY,GACxB,OAAO;EAAE,IAAI;EAAO,QAAQ;CAAuD;CAErF,IAAI,CAAC,iBAAiB,aAAa,SAAS,GAC1C,OAAO;EAAE,IAAI;EAAO,QAAQ;CAA4D;CAE1F,OAAO,EAAE,IAAI,KAAK;AACpB;;;;AAKA,SAAS,kBAAkB,MAAc,MAAqC,QAAsC,WAAyB;CAC3I,MAAM,aAAa,KAAK,KAAK,KAAK,WAAW,IAAI;CACjD,IAAI;EACF,IAAI,CAAC,SAAS,UAAU,CAAC,CAAC,YAAY,GAAG;CAC3C,QAAQ;EACN;CACF;CACA,MAAM,cAAc,KAAK,KAAK,KAAK,WAAW,IAAI;CAClD,MAAM,UAAU,mBAAmB,MAAM,aAAa,KAAK,SAAS;CACpE,IAAI,UAAU,SAAS;EACrB,OAAO,UAAU,KAAK,IAAI;EAC1B;CACF;CACA,IAAI,CAAC,QAAQ,IAAI;EACf,OAAO,QAAQ,KAAK,GAAG,KAAK,IAAI,QAAQ,QAAQ;EAChD,KAAK,SAAS,8BAA8B;GAAE;GAAM,QAAQ,QAAQ;GAAQ;EAAY,CAAC;EACzF;CACF;CACA,MAAM,QAAQ,gBAAgB,YAAY,aAAa,SAAS;CAChE,IAAI,MAAM,UAAU,GAAG;EACrB,OAAO,QAAQ,KAAK,IAAI;EACxB,KAAK,SAAS,2CAA2C;GAAE;GAAM,OAAO,MAAM;GAAS,cAAc;EAAU,CAAC;CAClH,OAAO,IAAI,MAAM,YAAY,GAC3B,OAAO,UAAU,KAAK,IAAI;CAE5B,IAAI,MAAM,UAAU,GAAG;EACrB,OAAO,QAAQ,KAAK,GAAG,KAAK,IAAI,MAAM,QAAQ,iBAAiB;EAC/D,KAAK,SAAS,sCAAsC;GAAE;GAAM,SAAS,MAAM;EAAQ,CAAC;CACtF;AACF;;;;AAKA,SAAS,sBAAsB,WAAmB,SAAyC;CACzF,MAAM,0BAAU,IAAI,IAAY;CAChC,KAAK,MAAM,QAAQ,SAAS;EAC1B,MAAM,YAAY,KAAK,KAAK,WAAW,IAAI;EAC3C,IAAI;GACF,IAAI,UAAU,SAAS,CAAC,CAAC,eAAe,GAAG,QAAQ,IAAI,aAAa,SAAS,CAAC;EAChF,QAAQ,CAER;CACF;CACA,OAAO;AACT;;;;AAKA,SAAS,oBAAoB,MAAc,WAAmB,aAAkC,gBAA8C;CAC5I,IAAI,CAAC,aAAa,IAAI,KAAK,YAAY,IAAI,IAAI,GAAG,OAAO;CACzD,MAAM,cAAc,KAAK,KAAK,WAAW,IAAI;CAC7C,IAAI;CACJ,IAAI;EACF,OAAO,UAAU,WAAW;CAC9B,QAAQ;EACN,OAAO;CACT;CACA,IAAI,KAAK,eAAe,KAAK,CAAC,KAAK,YAAY,GAAG,OAAO;CACzD,IAAI,CAAC,iBAAiB,aAAa,SAAS,GAAG,OAAO;CACtD,IAAI;EACF,OAAO,CAAC,eAAe,IAAI,aAAa,WAAW,CAAC;CACtD,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;AAQA,SAAS,2BAA2B,MAAqC,QAAsC,aAAwC;CACrJ,IAAI;CACJ,IAAI;EACF,gBAAgB,YAAY,KAAK,SAAS;CAC5C,QAAQ;EACN;CACF;CACA,MAAM,iBAAiB,sBAAsB,KAAK,WAAW,aAAa;CAC1E,KAAK,MAAM,QAAQ,eAAe;EAChC,IAAI,CAAC,oBAAoB,MAAM,KAAK,WAAW,aAAa,cAAc,GAAG;EAC7E,IAAI;GACF,OAAO,KAAK,KAAK,KAAK,WAAW,IAAI,GAAG;IAAE,WAAW;IAAM,OAAO;GAAK,CAAC;GACxE,OAAO,QAAQ,KAAK,IAAI;GACxB,KAAK,SAAS,uCAAuC,EAAE,KAAK,CAAC;EAC/D,SAAS,KAAK;GACZ,OAAO,QAAQ,KAAK,GAAG,KAAK,kBAAkB,aAAa,GAAG,GAAG;GACjE,KAAK,SAAS,8BAA8B;IAAE;IAAM,QAAQ,aAAa,GAAG;GAAE,CAAC;EACjF;CACF;AACF;;;;;;AAOA,SAAgB,uBAAuB,MAAmE;CACxG,MAAM,SAAuC;EAAE,SAAS,CAAC;EAAG,WAAW,CAAC;EAAG,WAAW,CAAC;EAAG,SAAS,CAAC;EAAG,SAAS,CAAC;EAAG,cAAc;CAAK;CACvI,IAAI,CAAC,WAAW,KAAK,SAAS,GAAG,OAAO;CACxC,IAAI;CACJ,IAAI;EACF,aAAa,SAAS,KAAK,SAAS;CACtC,SAAS,KAAK;EACZ,OAAO,gBAAgB,MAAM,QAAQ,4BAA4B,aAAa,GAAG,GAAG;CACtF;CACA,IAAI,CAAC,WAAW,YAAY,GAC1B,OAAO,gBAAgB,MAAM,QAAQ,mEAAmE;CAE1G,IAAI;CACJ,IAAI;EACF,gBAAgB,YAAY,KAAK,SAAS;CAC5C,SAAS,KAAK;EACZ,OAAO,gBAAgB,MAAM,QAAQ,0BAA0B,aAAa,GAAG,GAAG;CACpF;CAGA,MAAM,YAAY,QAAQ,KAAK,IAAI;CACnC,OAAO,eAAe;CACtB,MAAM,8BAAc,IAAI,IAAY;CACpC,KAAK,MAAM,QAAQ,eAAe;EAChC,IAAI,KAAK,WAAW,GAAG,GAAG;EAC1B,IAAI,CAAC,aAAa,IAAI,GAAG;EACzB,YAAY,IAAI,IAAI;EACpB,kBAAkB,MAAM,MAAM,QAAQ,SAAS;CACjD;CAEA,2BAA2B,MAAM,QAAQ,WAAW;CACpD,IAAI,OAAO,QAAQ,SAAS,KAAK,OAAO,QAAQ,SAAS,GACvD,KAAK,SAAS,+BAA+B;EAC3C,SAAS,OAAO,QAAQ;EACxB,WAAW,OAAO,UAAU;EAC5B,WAAW,OAAO,UAAU;EAC5B,SAAS,OAAO,QAAQ;EACxB,SAAS,OAAO,QAAQ;EACxB,cAAc;CAChB,CAAC;CAEH,OAAO;AACT;;;ACtoBA,IAAM,aAAa,cAAc,IAAI;;CAAuB;CAAgB,OAAO,KAAK;AAAG,CAAC;;AAG5F,SAAgB,gBAAwB;CACtC,OAAO,KAAK,KAAK,YAAY,OAAO;AACtC;;;AAIA,SAAgB,uBAA+B;CAC7C,OAAO,KAAK,KAAK,YAAY,eAAe;AAC9C;;;AAIA,SAAgB,UAAU,MAAiC;CACzD,UAAU,KAAK,SAAS,EAAE,WAAW,KAAK,CAAC;CAC3C,MAAM,MAAM,cAAc;CAC1B,KAAK,MAAM,QAAQ,YAAY,GAAG,GAChC,aAAa,KAAK,KAAK,KAAK,IAAI,GAAG,KAAK,KAAK,KAAK,SAAS,IAAI,CAAC;AAEpE"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../../common/dist/index.js","../../src/workspace-setup/sync.ts","../../src/workspace-setup/assets.ts","../../src/workspace-setup/faq-entries.ts"],"sourcesContent":["// General-purpose runtime type guards, shared across the MulmoClaude host,\n// bridges, and plugins. This is a leaf package — pure and dependency-free — so\n// any tier can import it without creating an uphill edge.\n//\n// These originated as `server/utils/types.ts` (#504), which centralised 40+\n// hand-written inline `typeof x === \"object\"` checks. They are promoted here so\n// the same guards stop being re-hand-written in every bridge and plugin too.\n/** Narrow `unknown` to a plain object (not null, not array). */\nexport function isRecord(value) {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n/** Narrow `unknown` to any object (not null, arrays allowed).\n * Use `isRecord` when you need to access string keys. */\nexport function isObj(value) {\n return typeof value === \"object\" && value !== null;\n}\n/** Non-empty string after trimming whitespace. */\nexport function isNonEmptyString(value) {\n return typeof value === \"string\" && value.trim().length > 0;\n}\n/** Record whose values are all strings. */\nexport function isStringRecord(value) {\n if (!isRecord(value))\n return false;\n return Object.values(value).every((val) => typeof val === \"string\");\n}\n/** String array (every element is a string). */\nexport function isStringArray(value) {\n return Array.isArray(value) && value.every((val) => typeof val === \"string\");\n}\n/** An array of unknowns. Prefer this over a bare `Array.isArray` in typed code:\n * `Array.isArray(x: unknown)` narrows to `any[]`, silently reintroducing\n * `any`, whereas this keeps the element type `unknown`. */\nexport function isUnknownArray(value) {\n return Array.isArray(value);\n}\n/** Error-like object with a `code` property (e.g. Node.js fs errors). */\nexport function isErrorWithCode(value) {\n return isRecord(value) && typeof value.code === \"string\";\n}\n/** Check that a record has a specific key with a string value. */\nexport function hasStringProp(value, key) {\n return isRecord(value) && typeof value[key] === \"string\";\n}\n/** Check that a record has a specific key with a number value. */\nexport function hasNumberProp(value, key) {\n return isRecord(value) && typeof value[key] === \"number\";\n}\n/** Split a comma-separated env value into trimmed, non-empty entries.\n * `lowercase` folds case for identifiers compared case-insensitively\n * (JIDs, email addresses, hex pubkeys). Absent/empty input → empty list. */\nexport function parseCsvList(raw, opts) {\n return (raw ?? \"\")\n .split(\",\")\n .map((entry) => (opts?.lowercase ? entry.trim().toLowerCase() : entry.trim()))\n .filter(Boolean);\n}\n/** A comma-separated env value as a Set — the canonical allowlist shape,\n * where an empty set is the \"allow all\" sentinel (`set.size === 0`). */\nexport function parseCsvSet(raw, opts) {\n return new Set(parseCsvList(raw, opts));\n}\n/** Normalise an unknown thrown value into a human-readable string. Isomorphic\n * (host, bridges, plugins, Vue) — this is the single home for the helper that\n * #2217 could only consolidate for server code, since `@mulmoclaude/core/utils`\n * is server-only.\n *\n * A non-Error object with a non-empty string `details` (gRPC convention) or\n * `message` field surfaces that field — `details` wins — instead of the\n * `[object Object]` a bare `String(err)` would print; an empty-string field\n * falls through. `fallback` covers the error-boundary idiom where a thrown\n * non-Error should read as a descriptive message rather than `String(err)`\n * noise; omit it in logging contexts where `String(err)` is fine. */\nexport function errorMessage(err, fallback) {\n if (err instanceof Error)\n return err.message;\n if (hasStringProp(err, \"details\") && err.details)\n return err.details;\n if (hasStringProp(err, \"message\") && err.message)\n return err.message;\n if (fallback !== undefined)\n return fallback;\n return String(err);\n}\n/** `Date` → `YYYY-MM-DD` in UTC — for dates that must not shift with the\n * host's local timezone (tool-trace search dirs, API date keys). Isomorphic\n * single source (#2480): the host re-exports it from `server/utils/date.ts`,\n * x-plugin imports it directly. The `@receptron/task-scheduler` copy stays\n * local on purpose — that leaf package is published independently and kept\n * dependency-free. Wall-clock questions use the host's `toLocalIsoDate`. */\nexport function toUtcIsoDate(timestamp) {\n const year = timestamp.getUTCFullYear();\n const month = String(timestamp.getUTCMonth() + 1).padStart(2, \"0\");\n const day = String(timestamp.getUTCDate()).padStart(2, \"0\");\n return `${year}-${month}-${day}`;\n}\n// A Map, not an object literal: `{}[char]` reads through the prototype chain,\n// so a future caller widening the regex would silently get `[object Object]`\n// for keys like `constructor`.\nconst HTML_ESCAPES = new Map([\n [\"&\", \"&amp;\"],\n [\"<\", \"&lt;\"],\n [\">\", \"&gt;\"],\n ['\"', \"&quot;\"],\n [\"'\", \"&#39;\"],\n]);\n/** HTML-escape text destined for markup or an attribute value — the fixed\n * five-character map, nothing more. Lives here rather than in\n * `@mulmoclaude/core/wiki` (#2483) because `@mulmoclaude/markdown-utils` is a\n * leaf that core depends on and so cannot import back up; core/wiki\n * re-exports this, keeping its consumers' import path unchanged.\n *\n * Escaping `&` first is what makes a single pass safe — the entities this\n * introduces contain none of the other four characters, so nothing is\n * double-escaped. Not a sanitiser: it neither strips tags nor validates URLs. */\nexport function escapeHtml(value) {\n return value.replace(/[&<>\"']/g, (char) => HTML_ESCAPES.get(char) ?? char);\n}\nexport { scanEnvOptions, snakeToLowerCamel } from \"./envScan.js\";\n","// Preset skills bundled with mulmoclaude.\n//\n// History: introduced in #1210 to ship launcher-managed \"factory\"\n// skills like `mc-library`. Originally synced straight into\n// `<workspaceRoot>/.claude/skills/<slug>/`, which made every preset\n// auto-active and inflated the Claude system prompt as new presets\n// landed.\n//\n// #1335 PR-A flipped the destination to the catalog\n// (`<workspaceRoot>/data/skills/catalog/preset/<slug>/`). Catalog\n// entries are visible to UI / tooling but NOT discovered by Claude\n// Code's slash-command resolver — they don't enter the system\n// prompt unless the user (or a later UI in PR-B) explicitly copies\n// one into `.claude/skills/`.\n//\n// The launcher overwrites catalog entries unconditionally on every\n// boot — they're factory defaults, not user state. Anything in\n// `.claude/skills/` (active layer) is left untouched.\n//\n// `syncPresetSkills(...)` is exported as a pure-ish helper (takes\n// paths + a logger sink, returns a summary) so tests can drive it\n// against tmpdirs without touching a real workspace.\n\nimport { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, type Dirent } from \"node:fs\";\nimport { randomUUID } from \"node:crypto\";\nimport path from \"node:path\";\nimport { PRESET_SLUG_PREFIX, isPresetSlug } from \"./slug.js\";\nimport { errorMessage } from \"../utils/errors.js\";\n\n// Recursively mirror `srcDir` into `destDir`. Used by the preset\n// sync so a preset skill that ships sibling assets (e.g.\n// `schema.json` for schema-driven apps, `templates/*.html`) gets\n// copied alongside `SKILL.md` rather than silently dropped. Only\n// regular files and directories are followed — symlinks / FIFOs /\n// sockets are skipped because the preset tree is launcher-managed\n// and shouldn't contain them.\nfunction copyDirTreeSync(srcDir: string, destDir: string): void {\n mkdirSync(destDir, { recursive: true });\n for (const entry of readdirSync(srcDir, { withFileTypes: true })) {\n const srcPath = path.join(srcDir, entry.name);\n const destPath = path.join(destDir, entry.name);\n if (entry.isDirectory()) {\n copyDirTreeSync(srcPath, destPath);\n } else if (entry.isFile()) {\n copyFileSync(srcPath, destPath);\n }\n }\n}\n\nconst SKILL_FILENAME = \"SKILL.md\";\n\nexport interface SyncPresetSkillsOptions {\n /** Source directory: `<launcher>/server/workspace/skills-preset/`. */\n sourceDir: string;\n /** Destination directory:\n * `<workspaceRoot>/data/skills/catalog/preset/`. The catalog\n * half of the catalog-vs-active split — entries here are visible\n * to UI but NOT to Claude Code's prompt-time skill resolver. */\n destDir: string;\n /** Logger callbacks — kept injectable so tests don't need to\n * spin up the structured logger. The boot-side wrapper threads\n * these through to `log.info` / `log.warn`. */\n onInfo?: (message: string, data?: Record<string, unknown>) => void;\n onWarn?: (message: string, data?: Record<string, unknown>) => void;\n}\n\nexport interface SyncPresetSkillsResult {\n /** Slugs successfully copied (or refreshed) from source to dest. */\n copied: string[];\n /** Slugs removed from dest because they no longer exist in source.\n * Bounded to `mc-*` entries — user-authored slugs are never\n * considered for removal. */\n removed: string[];\n /** Source entries that failed validation (wrong prefix, missing\n * SKILL.md, etc.) and were skipped. Each entry is human-readable. */\n skipped: string[];\n}\n\n// Classification of one source entry. `silent` distinguishes\n// structural skips (hidden files, non-directory entries — not the\n// dev's fault) from misconfigurations (bad slug, missing or\n// non-regular SKILL.md — the dev WANTS to know). The boolean lives\n// on the verdict so the caller never has to string-match `reason`,\n// which would silently drop warnings if reason wording changed\n// (CodeRabbit review).\ntype Verdict = { ok: true } | { ok: false; reason: string; silent: boolean };\n\nfunction classifySourceEntry(sourceDir: string, entry: string): Verdict {\n if (entry.startsWith(\".\")) return { ok: false, reason: \"hidden\", silent: true };\n const slugDir = path.join(sourceDir, entry);\n let dirInfo;\n try {\n dirInfo = statSync(slugDir);\n } catch {\n return { ok: false, reason: \"stat failed\", silent: true };\n }\n if (!dirInfo.isDirectory()) return { ok: false, reason: \"not a directory\", silent: true };\n if (!isPresetSlug(entry)) return { ok: false, reason: `slug must start with \"${PRESET_SLUG_PREFIX}\"`, silent: false };\n // Validate SKILL.md is a regular file — `existsSync` alone\n // accepts a directory at that path, which would then crash\n // copyFileSync. Codex review caught this edge case.\n const skillPath = path.join(slugDir, SKILL_FILENAME);\n let skillInfo;\n try {\n skillInfo = statSync(skillPath);\n } catch {\n return { ok: false, reason: `missing ${SKILL_FILENAME}`, silent: false };\n }\n if (!skillInfo.isFile()) return { ok: false, reason: `${SKILL_FILENAME} must be a regular file`, silent: false };\n return { ok: true };\n}\n\n/** Prepare the destination slug dir. Returns false if the slot is\n * occupied by a regular file (local corruption / hand edits) — the\n * caller logs + skips so one bad entry can't crash the whole boot\n * (Codex review iter-1). */\nfunction ensureDestSlugDir(destSlugDir: string): boolean {\n let info;\n try {\n info = statSync(destSlugDir);\n } catch {\n mkdirSync(destSlugDir, { recursive: true });\n return true;\n }\n return info.isDirectory();\n}\n\n/** Filesystem ops `_replaceSlugTree` depends on — injectable so the\n * mid-swap failure path can be regression-tested without real IO faults. */\nexport interface SlugTreeFsOps {\n copyTree: (src: string, dest: string) => void;\n rename: (from: string, dest: string) => void;\n remove: (target: string) => void;\n exists: (target: string) => boolean;\n}\n\nconst REAL_FS_OPS: SlugTreeFsOps = {\n copyTree: copyDirTreeSync,\n rename: renameSync,\n remove: (target) => rmSync(target, { recursive: true, force: true }),\n exists: existsSync,\n};\n\n/** Refresh one preset slot as a rollback-safe stage-and-swap. Wipe-and-replace\n * (not merge) so stale sibling assets — e.g. a schema.json dropped between\n * releases — don't linger; the catalog preset slot is launcher-owned, so user\n * edits there are not preserved across boots.\n *\n * The live slot is never left without a recoverable copy:\n * 1. Stage: copy the source into a temp sibling. A copy failure leaves the\n * existing preset untouched (temp dir removed).\n * 2. Move the existing tree ASIDE to a backup (rename, not delete), so the\n * old contents survive even if the next step fails.\n * 3. Move the staged copy into place. On failure, restore from the backup;\n * if even the restore fails, BOTH backup and staging are preserved for\n * manual recovery rather than deleted.\n * 4. On success, drop the backup.\n *\n * Exported with a `_` prefix only so the rename-failure-after-move path can be\n * regression-tested via the injected `fsOps`. Throws on failure (caller records\n * + skips). */\nexport function _replaceSlugTree(sourceSlugDir: string, destSlugDir: string, fsOps: SlugTreeFsOps = REAL_FS_OPS): void {\n const staging = `${destSlugDir}.tmp-${randomUUID()}`;\n try {\n fsOps.copyTree(sourceSlugDir, staging);\n } catch (err) {\n fsOps.remove(staging); // dest untouched\n throw err;\n }\n const backup = `${destSlugDir}.bak-${randomUUID()}`;\n let backedUp = false;\n if (fsOps.exists(destSlugDir)) {\n try {\n fsOps.rename(destSlugDir, backup);\n backedUp = true;\n } catch (err) {\n fsOps.remove(staging); // dest untouched; nothing was moved\n throw err;\n }\n }\n try {\n fsOps.rename(staging, destSlugDir);\n } catch (err) {\n if (backedUp) {\n try {\n fsOps.rename(backup, destSlugDir); // roll back to the previous tree\n fsOps.remove(staging);\n } catch {\n // Rollback failed — preserve BOTH backup and staging for manual\n // recovery rather than leaving the slot empty.\n }\n } else {\n fsOps.remove(staging); // dest never existed; nothing to restore\n }\n throw err;\n }\n if (backedUp) fsOps.remove(backup);\n}\n\nfunction copySourcesIntoDest(sourceDir: string, destDir: string, opts: SyncPresetSkillsOptions, result: SyncPresetSkillsResult): Set<string> {\n // `keep` is the set of slugs the retirement pass must NOT prune. It tracks\n // valid SOURCE slugs (present + usable dest slot), NOT successful copies — so\n // a slug whose refresh fails transiently keeps its existing contents (left\n // intact by the stage-and-swap above) instead of being pruned as \"retired\".\n const keep = new Set<string>();\n for (const entry of readdirSync(sourceDir)) {\n const verdict = classifySourceEntry(sourceDir, entry);\n if (!verdict.ok) {\n if (!verdict.silent) {\n result.skipped.push(`${entry}: ${verdict.reason}`);\n opts.onWarn?.(\"preset entry skipped\", { slug: entry, reason: verdict.reason });\n }\n continue;\n }\n const destSlugDir = path.join(destDir, entry);\n if (!ensureDestSlugDir(destSlugDir)) {\n const reason = \"destination slot occupied by a non-directory; skipping\";\n result.skipped.push(`${entry}: ${reason}`);\n opts.onWarn?.(\"preset entry skipped\", { slug: entry, reason, destSlugDir });\n continue;\n }\n // The slug exists in source with a usable dest slot — keep it regardless of\n // whether the refresh below succeeds, so a transient failure doesn't get it\n // pruned by `removeRetiredPresets`.\n keep.add(entry);\n // Per-slug isolation: a transient IO error / permission issue / partial\n // corruption on one preset must not abort syncing the rest, nor destroy\n // the existing copy. Skip the offender (recorded) and continue.\n try {\n _replaceSlugTree(path.join(sourceDir, entry), destSlugDir);\n result.copied.push(entry);\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err);\n result.skipped.push(`${entry}: ${reason}`);\n opts.onWarn?.(\"preset copy failed, skipping\", { slug: entry, reason });\n }\n }\n return keep;\n}\n\nfunction removeRetiredPresets(destDir: string, keep: ReadonlySet<string>, opts: SyncPresetSkillsOptions, result: SyncPresetSkillsResult): void {\n for (const entry of readdirSync(destDir)) {\n if (!isPresetSlug(entry)) continue;\n if (keep.has(entry)) continue;\n const stalePath = path.join(destDir, entry);\n try {\n if (!statSync(stalePath).isDirectory()) continue;\n } catch {\n continue;\n }\n rmSync(stalePath, { recursive: true, force: true });\n result.removed.push(entry);\n opts.onInfo?.(\"removed retired preset skill\", { slug: entry });\n }\n}\n\n/** Copy every preset slug from `sourceDir` into `destDir` (the\n * preset slot under the catalog root), then remove any `mc-*`\n * entries in `destDir` that no longer have a source. The catalog\n * preset subdir is fully launcher-owned, so the `mc-*` prefix\n * check at destination is defence-in-depth: a stray non-preset\n * slug landing in `catalog/preset/` is unexpected, and we'd\n * rather skip it than silently delete a directory we don't\n * recognise. */\nexport function syncPresetSkills(opts: SyncPresetSkillsOptions): SyncPresetSkillsResult {\n const result: SyncPresetSkillsResult = { copied: [], removed: [], skipped: [] };\n if (!existsSync(opts.sourceDir)) {\n // No preset directory in the launcher tarball — nothing to do.\n // This is the legitimate \"no presets shipped yet\" state.\n return result;\n }\n // Source-side validation: the launcher's preset path COULD exist\n // as a regular file (a packaging bug, a corrupted install). Without\n // this guard, `readdirSync(sourceDir)` would throw ENOTDIR and\n // crash boot. Codex review iter-3.\n let sourceInfo;\n try {\n sourceInfo = statSync(opts.sourceDir);\n } catch (err) {\n const reason = `source path stat failed: ${errorMessage(err)}`;\n result.skipped.push(`${opts.sourceDir}: ${reason}`);\n opts.onWarn?.(\"preset sync aborted\", { sourceDir: opts.sourceDir, reason });\n return result;\n }\n if (!sourceInfo.isDirectory()) {\n const reason = \"source path exists as a non-directory; preset sync skipped\";\n result.skipped.push(`${opts.sourceDir}: ${reason}`);\n opts.onWarn?.(\"preset sync aborted\", { sourceDir: opts.sourceDir, reason });\n return result;\n }\n // The root dest itself can be corrupted into a regular file by a\n // user / external tool; mkdirSync would throw EEXIST and crash\n // boot. Treat it as a recoverable \"skip the entire sync\" state\n // — log a clear warning so the user sees what to fix.\n // (Codex review iter-2.)\n if (!ensureDestSlugDir(opts.destDir)) {\n const reason = \"root dest exists as a non-directory; preset sync skipped\";\n result.skipped.push(`${opts.destDir}: ${reason}`);\n opts.onWarn?.(\"preset sync aborted\", { destDir: opts.destDir, reason });\n return result;\n }\n const keep = copySourcesIntoDest(opts.sourceDir, opts.destDir, opts, result);\n removeRetiredPresets(opts.destDir, keep, opts, result);\n if (result.copied.length > 0 || result.removed.length > 0) {\n opts.onInfo?.(\"preset skills synced\", {\n copied: result.copied.length,\n removed: result.removed.length,\n skipped: result.skipped.length,\n });\n }\n return result;\n}\n\n// ---------------------------------------------------------------\n// Active-layer sync: keep starred mc-* presets in lockstep with\n// their launcher-bundled source.\n//\n// Motivation: the catalog sync above refreshes\n// `data/skills/catalog/preset/<slug>/` on every boot, but once a\n// user stars an entry the active copy in\n// `<workspace>/.claude/skills/<slug>/` is never updated even when\n// the launcher ships a new SKILL.md (e.g. a typo fix or, as the\n// trigger for this code, the schema-driven-apps → collections\n// rename). The SKILL.md front-matter explicitly says\n// \"do not edit this file in the workspace, it is overwritten on\n// every server boot\" — until this function existed, that was a\n// promise the active layer didn't keep.\n//\n// Safety model:\n// - Only `mc-*` slugs are touched (defensive prefix check —\n// never touches user-authored skills).\n// - Per-file diff: a file is overwritten only if its bytes\n// differ from the source. No-op when already up to date.\n// - User-added files inside an active slug dir are left alone\n// (we walk the source tree, not the dest tree).\n// - If a file IS overwritten, the previous contents are first\n// renamed to `<file>.bak.<timestamp>` so a user who had\n// locally tweaked the preset can recover.\n// - A slug whose active dir doesn't exist (= not starred yet)\n// is skipped entirely, never auto-starred.\n// ---------------------------------------------------------------\n\nexport interface SyncActivePresetSkillsOptions {\n /** Source directory: `<launcher>/server/workspace/skills-preset/`. */\n sourceDir: string;\n /** Active skills directory: `<workspaceRoot>/.claude/skills/`. */\n activeDir: string;\n onInfo?: (message: string, data?: Record<string, unknown>) => void;\n onWarn?: (message: string, data?: Record<string, unknown>) => void;\n}\n\nexport interface SyncActivePresetSkillsResult {\n /** Slugs whose active copy had at least one file overwritten. */\n updated: string[];\n /** Slugs whose active copy already matched the source — no-op. */\n unchanged: string[];\n /** Slugs that haven't been starred yet (no active dir present).\n * Listed for diagnostics; the function never auto-stars. */\n notActive: string[];\n /** Active `mc-*` slugs pruned because the launcher no longer ships a\n * source preset for them (a retired preset). Bounded to `mc-*` —\n * user-authored skills are never removed. */\n removed: string[];\n /** Per-slug failure messages (permission errors, etc.). */\n skipped: string[];\n /** Common timestamp suffix used for every backup file produced by\n * this run. Exposed so the boot-time log can point a user at the\n * exact glob to inspect. */\n backupSuffix: string | null;\n}\n\ntype FileSyncOutcome = \"updated\" | \"unchanged\" | \"skipped\";\n\nfunction filesEqual(left: string, right: string): boolean {\n try {\n return readFileSync(left).equals(readFileSync(right));\n } catch {\n return false;\n }\n}\n\n/** Copy `srcPath` over `destPath`. If `destPath` already exists and\n * differs, rename it to `destPath + backupExt` first so the user's\n * prior contents are recoverable. If `destPath` doesn't exist, just\n * copy (no backup needed). Pure: no logging here — caller decides. */\nfunction syncOneFile(srcPath: string, destPath: string, backupExt: string): FileSyncOutcome {\n let destExists: boolean;\n try {\n destExists = statSync(destPath).isFile();\n } catch {\n destExists = false;\n }\n if (!destExists) {\n try {\n copyFileSync(srcPath, destPath);\n return \"updated\";\n } catch {\n return \"skipped\";\n }\n }\n if (filesEqual(srcPath, destPath)) return \"unchanged\";\n try {\n renameSync(destPath, destPath + backupExt);\n copyFileSync(srcPath, destPath);\n return \"updated\";\n } catch {\n return \"skipped\";\n }\n}\n\ninterface DirSyncStats {\n updated: number;\n unchanged: number;\n skipped: number;\n}\n\nfunction syncDirTreeDiff(srcDir: string, destDir: string, backupExt: string): DirSyncStats {\n const stats: DirSyncStats = { updated: 0, unchanged: 0, skipped: 0 };\n try {\n mkdirSync(destDir, { recursive: true });\n } catch {\n stats.skipped++;\n return stats;\n }\n let entries: Dirent[];\n try {\n entries = readdirSync(srcDir, { withFileTypes: true });\n } catch {\n stats.skipped++;\n return stats;\n }\n for (const entry of entries) {\n const srcPath = path.join(srcDir, entry.name);\n const destPath = path.join(destDir, entry.name);\n if (entry.isDirectory()) {\n const sub = syncDirTreeDiff(srcPath, destPath, backupExt);\n stats.updated += sub.updated;\n stats.unchanged += sub.unchanged;\n stats.skipped += sub.skipped;\n } else if (entry.isFile()) {\n const outcome = syncOneFile(srcPath, destPath, backupExt);\n stats[outcome]++;\n }\n // Symlinks / sockets / FIFOs intentionally ignored (the launcher\n // preset tree shouldn't contain them).\n }\n return stats;\n}\n\nfunction abortActiveSync(opts: SyncActivePresetSkillsOptions, result: SyncActivePresetSkillsResult, reason: string): SyncActivePresetSkillsResult {\n result.skipped.push(`${opts.sourceDir}: ${reason}`);\n opts.onWarn?.(\"active preset sync aborted\", { sourceDir: opts.sourceDir, reason });\n return result;\n}\n\n/** True iff `absPath`'s realpath resolves inside `rootPath`'s\n * realpath. Defends `processActiveSlug` against a starred `mc-*`\n * slug that's actually a symlink to somewhere outside\n * `activeDir`: without this check, the recursive copy below would\n * follow the symlink and write through to the link's target\n * (potentially anywhere on disk). Returns false on any error so\n * the caller treats unreadable paths as \"refused\" rather than\n * \"OK to write\". */\nfunction isRealpathInside(absPath: string, rootPath: string): boolean {\n try {\n const real = realpathSync(absPath);\n const rootReal = realpathSync(rootPath);\n return real === rootReal || real.startsWith(rootReal + path.sep);\n } catch {\n return false;\n }\n}\n\ntype DestVerdict = { ok: true } | { ok: false; reason: string } | { kind: \"not-active\" };\n\n/** Validate the active dest slug dir before writing through it.\n * Returns:\n * - `{ ok: true }` — proceed with the sync\n * - `{ kind: \"not-active\" }` — slug hasn't been starred yet\n * - `{ ok: false; reason }` — refuse to write (symlink escape,\n * non-directory, ancestor escape)\n *\n * Symlink defenses (Codex P1 review on PR #1490): `statSync`\n * follows symlinks, so a starred `mc-*` slug that's actually a\n * symlink to /etc would let the recursive copy below write\n * outside the workspace. Two-layer defense:\n * 1. `lstatSync` to see the link itself; if it's a symlink,\n * only accept when its target stays inside `activeDir`.\n * 2. Always realpath-verify the full path (catches the case\n * where an ancestor like `.claude/` is symlinked even when\n * the slug dir itself is a regular directory). */\nfunction classifyActiveDest(slug: string, destSlugDir: string, activeDir: string): DestVerdict {\n let destInfo;\n try {\n destInfo = lstatSync(destSlugDir);\n } catch {\n return { kind: \"not-active\" };\n }\n if (destInfo.isSymbolicLink()) {\n if (!isRealpathInside(destSlugDir, activeDir)) {\n return { ok: false, reason: \"active slot is a symlink whose target escapes activeDir; refusing to write through it\" };\n }\n return { ok: true };\n }\n if (!destInfo.isDirectory()) {\n return { ok: false, reason: \"active slot is occupied by a non-directory; skipping\" };\n }\n if (!isRealpathInside(destSlugDir, activeDir)) {\n return { ok: false, reason: \"active slug dir escapes activeDir via an ancestor symlink\" };\n }\n return { ok: true };\n}\n\n/** Per-slug worker for `syncActivePresetSkills`. Extracted to keep\n * the outer function under the `sonarjs/cognitive-complexity`\n * threshold; no behavior difference vs the inline loop. */\nfunction processActiveSlug(slug: string, opts: SyncActivePresetSkillsOptions, result: SyncActivePresetSkillsResult, backupExt: string): void {\n const srcSlugDir = path.join(opts.sourceDir, slug);\n try {\n if (!statSync(srcSlugDir).isDirectory()) return;\n } catch {\n return;\n }\n const destSlugDir = path.join(opts.activeDir, slug);\n const verdict = classifyActiveDest(slug, destSlugDir, opts.activeDir);\n if (\"kind\" in verdict) {\n result.notActive.push(slug);\n return;\n }\n if (!verdict.ok) {\n result.skipped.push(`${slug}: ${verdict.reason}`);\n opts.onWarn?.(\"active preset sync skipped\", { slug, reason: verdict.reason, destSlugDir });\n return;\n }\n const stats = syncDirTreeDiff(srcSlugDir, destSlugDir, backupExt);\n if (stats.updated > 0) {\n result.updated.push(slug);\n opts.onInfo?.(\"active preset skill updated from source\", { slug, files: stats.updated, backupSuffix: backupExt });\n } else if (stats.skipped === 0) {\n result.unchanged.push(slug);\n }\n if (stats.skipped > 0) {\n result.skipped.push(`${slug}: ${stats.skipped} file(s) skipped`);\n opts.onWarn?.(\"active preset skill partial update\", { slug, skipped: stats.skipped });\n }\n}\n\n/** Realpath targets of active symlinks. Used so the prune below never\n * deletes a directory that's serving as a symlink target (e.g. a\n * git-managed checkout the user symlinks an active slug to). */\nfunction collectSymlinkTargets(activeDir: string, entries: readonly string[]): Set<string> {\n const targets = new Set<string>();\n for (const slug of entries) {\n const entryPath = path.join(activeDir, slug);\n try {\n if (lstatSync(entryPath).isSymbolicLink()) targets.add(realpathSync(entryPath));\n } catch {\n /* unreadable — ignore */\n }\n }\n return targets;\n}\n\n/** True iff the active `slug` is a retired preset safe to delete: an\n * `mc-*` real directory inside `activeDir`, with no source preset, and\n * not itself a symlink or a symlink target. */\nfunction isRetiredActiveSlug(slug: string, activeDir: string, sourceSlugs: ReadonlySet<string>, symlinkTargets: ReadonlySet<string>): boolean {\n if (!isPresetSlug(slug) || sourceSlugs.has(slug)) return false;\n const destSlugDir = path.join(activeDir, slug);\n let info;\n try {\n info = lstatSync(destSlugDir);\n } catch {\n return false;\n }\n if (info.isSymbolicLink() || !info.isDirectory()) return false;\n if (!isRealpathInside(destSlugDir, activeDir)) return false;\n try {\n return !symlinkTargets.has(realpathSync(destSlugDir));\n } catch {\n return false;\n }\n}\n\n/** Prune active `mc-*` slugs whose launcher source preset no longer\n * exists (a retired preset). Without this, an upgraded workspace that\n * had starred a since-removed preset (e.g. `mc-manage-sources`) keeps\n * the active copy forever — Claude would still discover a skill whose\n * backend was deleted. Bounded to `mc-*` real directories that stay\n * inside `activeDir` (never a symlink escape, never a user skill). */\nfunction removeRetiredActivePresets(opts: SyncActivePresetSkillsOptions, result: SyncActivePresetSkillsResult, sourceSlugs: ReadonlySet<string>): void {\n let activeEntries: string[];\n try {\n activeEntries = readdirSync(opts.activeDir);\n } catch {\n return; // no active dir yet → nothing to prune\n }\n const symlinkTargets = collectSymlinkTargets(opts.activeDir, activeEntries);\n for (const slug of activeEntries) {\n if (!isRetiredActiveSlug(slug, opts.activeDir, sourceSlugs, symlinkTargets)) continue;\n try {\n rmSync(path.join(opts.activeDir, slug), { recursive: true, force: true });\n result.removed.push(slug);\n opts.onInfo?.(\"removed retired active preset skill\", { slug });\n } catch (err) {\n result.skipped.push(`${slug}: prune failed: ${errorMessage(err)}`);\n opts.onWarn?.(\"active preset prune failed\", { slug, reason: errorMessage(err) });\n }\n }\n}\n\n/** Refresh every already-starred `mc-*` preset's active copy in\n * `<workspaceRoot>/.claude/skills/<slug>/` to match the source.\n * Per-file diff with `.bak.<timestamp>` backup on overwrite. Slugs\n * that aren't starred yet are listed in `notActive` but never\n * auto-created. */\nexport function syncActivePresetSkills(opts: SyncActivePresetSkillsOptions): SyncActivePresetSkillsResult {\n const result: SyncActivePresetSkillsResult = { updated: [], unchanged: [], notActive: [], removed: [], skipped: [], backupSuffix: null };\n if (!existsSync(opts.sourceDir)) return result;\n let sourceInfo;\n try {\n sourceInfo = statSync(opts.sourceDir);\n } catch (err) {\n return abortActiveSync(opts, result, `source path stat failed: ${errorMessage(err)}`);\n }\n if (!sourceInfo.isDirectory()) {\n return abortActiveSync(opts, result, \"source path exists as a non-directory; active preset sync skipped\");\n }\n let sourceEntries: string[];\n try {\n sourceEntries = readdirSync(opts.sourceDir);\n } catch (err) {\n return abortActiveSync(opts, result, `source readdir failed: ${errorMessage(err)}`);\n }\n // Single timestamp per run so a multi-file update produces sibling\n // backups with a matching suffix — easier to grep / restore.\n const backupExt = `.bak.${Date.now()}`;\n result.backupSuffix = backupExt;\n const sourceSlugs = new Set<string>();\n for (const slug of sourceEntries) {\n if (slug.startsWith(\".\")) continue;\n if (!isPresetSlug(slug)) continue;\n sourceSlugs.add(slug);\n processActiveSlug(slug, opts, result, backupExt);\n }\n // Prune active copies of presets the launcher no longer ships.\n removeRetiredActivePresets(opts, result, sourceSlugs);\n if (result.updated.length > 0 || result.removed.length > 0) {\n opts.onInfo?.(\"active preset skills synced\", {\n updated: result.updated.length,\n unchanged: result.unchanged.length,\n notActive: result.notActive.length,\n removed: result.removed.length,\n skipped: result.skipped.length,\n backupSuffix: backupExt,\n });\n }\n return result;\n}\n","// Resolve the package's BUNDLED assets (shipped via package.json `files`) and seed\n// them into a workspace. ESM-only: `import.meta.url` points at this module under\n// `dist/workspace-setup/`, so `../../assets` is the package's assets dir at the\n// package root. (The workspace-setup entries build ESM only — `import.meta.url`\n// isn't available under CJS; both hosts run the server as ESM via tsx.)\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { copyFileSync, mkdirSync, readdirSync } from \"node:fs\";\n\n// The asset dir is shipped separately (package.json `files`), NOT bundled, so\n// this URL must resolve at runtime. `@vite-ignore` silences Vite's build-time\n// \"doesn't exist at build time\" warning — runtime resolution is intended.\nconst ASSETS_DIR = fileURLToPath(new URL(/* @vite-ignore */ \"../../assets\", import.meta.url));\n\n/** The bundled help-docs source dir (`assets/helps/`). */\nexport function helpsAssetDir(): string {\n return path.join(ASSETS_DIR, \"helps\");\n}\n\n/** The bundled preset-skills source dir (`assets/skills-preset/`) — pass as the\n * `sourceDir` of `syncPresetSkills` / `syncActivePresetSkills`. */\nexport function presetSkillsAssetDir(): string {\n return path.join(ASSETS_DIR, \"skills-preset\");\n}\n\n/** Copy every bundled help doc into `destDir` (created if missing). Idempotent —\n * overwrites on each call so the help docs always track the package's version. */\nexport function seedHelps(opts: { destDir: string }): void {\n mkdirSync(opts.destDir, { recursive: true });\n const src = helpsAssetDir();\n for (const file of readdirSync(src)) {\n copyFileSync(path.join(src, file), path.join(opts.destDir, file));\n }\n}\n","// The bug-report FAQ is an index of WHERE to check, never of what a value is —\n// values go stale silently, a config key or a path cannot (rename one and the\n// implementation stops working, so it gets fixed). That only holds if the\n// pointers are real, so they are parsed out here and checked by a test: a key\n// that no longer exists fails CI instead of misleading a user months later.\n\nexport interface FaqEntry {\n symptom: string;\n configKeys: string[];\n sources: string[];\n helps: string[];\n}\n\nexport type FieldList = \"configKeys\" | \"sources\" | \"helps\";\n\n// A Map, not an object literal: the field name comes from arbitrary prose in a\n// markdown file, and `FIELDS[\"constructor\"]` on a literal would answer through\n// the prototype chain instead of missing.\nconst FIELDS = new Map<string, FieldList>([\n [\"configKey\", \"configKeys\"],\n [\"source\", \"sources\"],\n [\"help\", \"helps\"],\n]);\n\nconst HEADING = \"## \";\nconst FENCE = \"```\";\n\nconst newEntry = (symptom: string): FaqEntry => ({ symptom, configKeys: [], sources: [], helps: [] });\n\nexport interface PointerLine {\n list: FieldList;\n value: string;\n}\n\n/** Read one `field: value` pointer line, or `null` for anything else — prose,\n * a blank line, or a sentence that merely contains a colon. Split out so the\n * line grammar can be tested on its own rather than through whole documents. */\nexport function parsePointerLine(line: string): PointerLine | null {\n const colon = line.indexOf(\":\");\n if (colon <= 0) return null;\n const list = FIELDS.get(line.slice(0, colon).trim());\n const value = line.slice(colon + 1).trim();\n return list && value ? { list, value } : null;\n}\n\n/** `## symptom` opens an entry; `field: value` lines under it are its pointers.\n * Everything else is prose for the model to read. A field line before the\n * first heading belongs to no entry and is dropped — the format block at the\n * top of the file is documentation, not an entry. Fenced blocks are skipped\n * for the same reason: the example in that block would parse as real. */\nexport function parseFaqEntries(markdown: string): FaqEntry[] {\n const entries: FaqEntry[] = [];\n let inFence = false;\n for (const line of markdown.split(\"\\n\")) {\n if (line.startsWith(FENCE)) {\n inFence = !inFence;\n } else if (inFence) {\n continue;\n } else if (line.startsWith(HEADING)) {\n entries.push(newEntry(line.slice(HEADING.length).trim()));\n } else {\n const pointer = parsePointerLine(line);\n const current = entries[entries.length - 1];\n if (pointer && current) current[pointer.list].push(pointer.value);\n }\n }\n return entries;\n}\n\n/** An entry with no pointer at all is prose the skill cannot verify against the\n * running system, which is exactly what this format exists to prevent. */\nexport const entryHasPointer = (entry: FaqEntry): boolean => entry.configKeys.length + entry.sources.length + entry.helps.length > 0;\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmoclaude/core",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Shared server-side core for MulmoClaude and MulmoTerminal — the always-shipped-together subsystems consolidated behind subpath exports so the two hosts can't drift. Server-only except the browser-safe ./artifacts, ./whisper/client, ./workspace-setup/slug, ./translation/client, ./remote-view, ./remote-host and ./plugin-vue entries. All host specifics are injected.",
5
5
  "repository": {
6
6
  "type": "git",