@mulmoclaude/core 0.2.0 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/helps/collection-skills.md +52 -4
- package/assets/helps/custom-view.md +32 -11
- package/assets/helps/feeds.md +16 -7
- package/assets/skills-preset/mc-manage-automations/SKILL.md +36 -2
- package/dist/collection/core/schema.d.ts +37 -10
- package/dist/collection/index.cjs +4 -3
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.js +4 -4
- package/dist/collection/index.js.map +1 -1
- package/dist/collection/paths.cjs.map +1 -1
- package/dist/collection/paths.js.map +1 -1
- package/dist/collection/server/discovery.d.ts +14 -2
- package/dist/collection/server/index.cjs +1 -1
- package/dist/collection/server/index.js +1 -1
- package/dist/collection/server/io.d.ts +12 -6
- package/dist/collection-watchers/index.cjs +2 -3
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.js +2 -2
- package/dist/collection-watchers/index.js.map +1 -1
- package/dist/{deriveAll-C15OpM3K.cjs → deriveAll-VRWrs3SF.cjs} +19 -5
- package/dist/deriveAll-VRWrs3SF.cjs.map +1 -0
- package/dist/{deriveAll-C6BYnpBL.js → deriveAll-vzIhhKBK.js} +14 -6
- package/dist/deriveAll-vzIhhKBK.js.map +1 -0
- package/dist/feeds/index.cjs +17 -0
- package/dist/feeds/index.d.ts +2 -0
- package/dist/feeds/index.js +4 -0
- package/dist/feeds/ingestTypes.d.ts +57 -0
- package/dist/feeds/paths.cjs +48 -0
- package/dist/feeds/paths.cjs.map +1 -0
- package/dist/feeds/paths.d.ts +22 -0
- package/dist/feeds/paths.js +38 -0
- package/dist/feeds/paths.js.map +1 -0
- package/dist/feeds/server/agentIngest.d.ts +13 -0
- package/dist/feeds/server/engine.d.ts +12 -0
- package/dist/feeds/server/fetch/httpClient.d.ts +8 -0
- package/dist/feeds/server/fetch/rssParser.d.ts +12 -0
- package/dist/feeds/server/host.d.ts +51 -0
- package/dist/feeds/server/index.cjs +804 -0
- package/dist/feeds/server/index.cjs.map +1 -0
- package/dist/feeds/server/index.d.ts +7 -0
- package/dist/feeds/server/index.js +782 -0
- package/dist/feeds/server/index.js.map +1 -0
- package/dist/feeds/server/pathResolver.d.ts +6 -0
- package/dist/feeds/server/projectItem.d.ts +8 -0
- package/dist/feeds/server/refreshResult.d.ts +17 -0
- package/dist/feeds/server/registry.d.ts +14 -0
- package/dist/feeds/server/retrievers/httpJson.d.ts +3 -0
- package/dist/feeds/server/retrievers/index.d.ts +12 -0
- package/dist/feeds/server/retrievers/registerAll.d.ts +0 -0
- package/dist/feeds/server/retrievers/rss.d.ts +3 -0
- package/dist/feeds/server/state.d.ts +28 -0
- package/dist/file-change/index.cjs +2 -2
- package/dist/file-change/index.cjs.map +1 -1
- package/dist/ingestTypes-B8DaQOTy.js +15 -0
- package/dist/ingestTypes-B8DaQOTy.js.map +1 -0
- package/dist/ingestTypes-DDOVhOYC.cjs +32 -0
- package/dist/ingestTypes-DDOVhOYC.cjs.map +1 -0
- package/dist/notifier/index.cjs +1 -1
- package/dist/notifier/index.js +1 -1
- package/dist/{notifier-6PjsLxLm.js → notifier-ChpY0XrY.js} +1 -1
- package/dist/{notifier-6PjsLxLm.js.map → notifier-ChpY0XrY.js.map} +1 -1
- package/dist/{notifier-lJ4v2Y6B.cjs → notifier-bS8IEeLA.cjs} +1 -2
- package/dist/{notifier-lJ4v2Y6B.cjs.map → notifier-bS8IEeLA.cjs.map} +1 -1
- package/dist/scheduler/index.cjs +2 -2
- package/dist/scheduler/index.cjs.map +1 -1
- package/dist/scheduler/index.js.map +1 -1
- package/dist/{server-BhIdZgqu.js → server-B2YkcCwl.js} +76 -33
- package/dist/server-B2YkcCwl.js.map +1 -0
- package/dist/{server-BjoKk2tR.cjs → server-D27UzXug.cjs} +78 -35
- package/dist/server-D27UzXug.cjs.map +1 -0
- package/dist/skill-bridge/index.cjs +2 -2
- package/dist/skill-bridge/index.cjs.map +1 -1
- package/dist/whisper/client.cjs +1 -1
- package/dist/whisper/client.cjs.map +1 -1
- package/dist/whisper/client.js +1 -1
- package/dist/whisper/client.js.map +1 -1
- package/dist/whisper/index.cjs +4 -4
- package/dist/whisper/index.cjs.map +1 -1
- package/dist/whisper/index.js +2 -2
- package/dist/whisper/index.js.map +1 -1
- package/dist/workspace-setup/index.js +5 -1
- package/dist/workspace-setup/index.js.map +1 -1
- package/package.json +22 -3
- package/dist/deriveAll-C15OpM3K.cjs.map +0 -1
- package/dist/deriveAll-C6BYnpBL.js.map +0 -1
- package/dist/server-BhIdZgqu.js.map +0 -1
- package/dist/server-BjoKk2tR.cjs.map +0 -1
- /package/dist/{chunk-CKQMccvm.cjs → rolldown-runtime-D6vf50IK.cjs} +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../src/workspace-setup/sync.ts","../../src/workspace-setup/assets.ts"],"sourcesContent":["// 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\";\n\n// Inlined (was the host's ../utils/errors.js) so the package has no host coupling.\nfunction errorMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\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\nconst ASSETS_DIR = fileURLToPath(new URL(\"../../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":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/workspace-setup/sync.ts","../../src/workspace-setup/assets.ts"],"sourcesContent":["// 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\";\n\n// Inlined (was the host's ../utils/errors.js) so the package has no host coupling.\nfunction errorMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\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":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mulmoclaude/core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
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 ./whisper/client and ./workspace-setup/slug entries. All host specifics are injected.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -22,6 +22,24 @@
|
|
|
22
22
|
"require": "./dist/collection/paths.cjs",
|
|
23
23
|
"default": "./dist/collection/paths.js"
|
|
24
24
|
},
|
|
25
|
+
"./feeds": {
|
|
26
|
+
"types": "./dist/feeds/index.d.ts",
|
|
27
|
+
"import": "./dist/feeds/index.js",
|
|
28
|
+
"require": "./dist/feeds/index.cjs",
|
|
29
|
+
"default": "./dist/feeds/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./feeds/server": {
|
|
32
|
+
"types": "./dist/feeds/server/index.d.ts",
|
|
33
|
+
"import": "./dist/feeds/server/index.js",
|
|
34
|
+
"require": "./dist/feeds/server/index.cjs",
|
|
35
|
+
"default": "./dist/feeds/server/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./feeds/paths": {
|
|
38
|
+
"types": "./dist/feeds/paths.d.ts",
|
|
39
|
+
"import": "./dist/feeds/paths.js",
|
|
40
|
+
"require": "./dist/feeds/paths.cjs",
|
|
41
|
+
"default": "./dist/feeds/paths.js"
|
|
42
|
+
},
|
|
25
43
|
"./collection-watchers": {
|
|
26
44
|
"types": "./dist/collection-watchers/index.d.ts",
|
|
27
45
|
"import": "./dist/collection-watchers/index.js",
|
|
@@ -88,6 +106,7 @@
|
|
|
88
106
|
"test": "tsx --test test/**/test_*.ts"
|
|
89
107
|
},
|
|
90
108
|
"dependencies": {
|
|
109
|
+
"fast-xml-parser": "^5.9.3",
|
|
91
110
|
"zod": "^4.4.3"
|
|
92
111
|
},
|
|
93
112
|
"peerDependencies": {
|
|
@@ -100,8 +119,8 @@
|
|
|
100
119
|
"gui-chat-protocol": "^0.4.0",
|
|
101
120
|
"tsx": "^4.22.4",
|
|
102
121
|
"typescript": "^6.0.3",
|
|
103
|
-
"vite": "^8.0
|
|
104
|
-
"vite-plugin-dts": "^5.0.
|
|
122
|
+
"vite": "^8.1.0",
|
|
123
|
+
"vite-plugin-dts": "^5.0.3"
|
|
105
124
|
},
|
|
106
125
|
"license": "MIT"
|
|
107
126
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"deriveAll-C15OpM3K.cjs","names":[],"sources":["../src/collection/core/schema.ts","../src/collection/core/derivedFormula.ts","../src/collection/core/deriveAll.ts"],"sourcesContent":["// Schema-driven collection types. A \"collection\" is a skill (under\n// .claude/skills/<slug>/) that also ships a sibling `schema.json`.\n// The host's <CollectionView> reads the schema + records and renders\n// a table/form; Claude reads SKILL.md and CRUDs the records as JSON\n// files.\n//\n// Field types for v0 — keep this list narrow and grow it only when a\n// real collection needs the new type. v0 supports flat records only;\n// nested tables / cross-collection refs / derived fields / actions are\n// deferred to follow-ups (see plans/done/feat-skill-driven-apps.md and\n// plans/done/feat-skill-driven-apps-worklog.md — historical names predate\n// the rename).\n\n/** Minimal \"this collection is a feed\" descriptor carried on the schema.\n * Deliberately narrow — the canonical collection contract stays\n * independent of the host's feeds subsystem. The host's richer retrieval\n * spec (`IngestSpec` in `server/workspace/feeds/ingestTypes.ts`)\n * `extends CollectionIngest`, so feed code reads the extra fields by\n * typing feed schemas with that subtype; collection rendering only needs\n * these three + the presence check. */\nexport interface CollectionIngest {\n kind: string;\n url: string;\n schedule: string;\n}\n\n/** Retriever kinds a Feed's `ingest.kind` may declare. The host's feeds engine\n * dispatches on these; they live here (with the schema contract) so the schema\n * validator can enforce them. The host re-exports these from\n * `server/workspace/feeds/ingestTypes.ts`. */\nexport const INGEST_KINDS = [\"rss\", \"atom\", \"http-json\"] as const;\nexport type IngestKind = (typeof INGEST_KINDS)[number];\n\n/** Refresh cadences a Feed's `ingest.schedule` may declare. */\nexport const FEED_SCHEDULES = [\"hourly\", \"daily\", \"weekly\", \"on-demand\"] as const;\nexport type FeedSchedule = (typeof FEED_SCHEDULES)[number];\n\nexport type CollectionFieldType =\n | \"string\"\n | \"text\"\n | \"email\"\n | \"number\"\n | \"date\"\n | \"datetime\"\n | \"boolean\"\n | \"markdown\"\n | \"ref\"\n | \"money\"\n | \"enum\"\n | \"table\"\n | \"derived\"\n | \"embed\"\n // Holds a workspace-relative image path (e.g. a `data/attachments/...`\n // upload); rendered as an <img> in the detail view (not the list table —\n // a per-row fetch is too expensive at scale). Stored and edited as a\n // plain string.\n | \"image\"\n // Holds a workspace-relative file path as a plain string (e.g. an\n // `artifacts/html/<name>.html` app). Rendered as a clickable link in\n // both the list table and the detail view: HTML / SVG artifacts open\n // their rendered form in a new tab; any other path opens in the File\n // Explorer. Stored and edited as a plain string, like `image`.\n | \"file\"\n // A checkbox that is a pure PROJECTION of an `enum` field — it stores\n // nothing of its own. Checked when the enum equals `onValue`; toggling\n // writes `onValue` / `offValue` back to that enum field. Lets a \"done\"\n // checkbox front a kanban `status` field with the enum as the single\n // source of truth (no separate stored boolean to keep in sync).\n | \"toggle\";\n\n// \"feed\" collections live in the non-skill `<workspace>/feeds/` registry\n// and carry an `ingest` block; they reuse the same storage + rendering\n// as skill-backed collections but are never loaded into the agent prompt.\nexport type CollectionSource = \"user\" | \"project\" | \"feed\";\n\n/** Recurrence unit for a `spawn.every` advance. */\nexport type CollectionRecurUnit = \"day\" | \"week\" | \"month\" | \"year\";\n\n/** How a `spawn` advances the source item's `triggerField` date to\n * produce the successor's. All arithmetic is done on the civil\n * (year, month, day) triple — never by adding milliseconds — so month\n * lengths and leap years are handled correctly. */\nexport interface CollectionEvery {\n unit: CollectionRecurUnit;\n /** Number of `unit`s to advance (≥ 1). `interval: 3` + `unit: \"month\"`\n * = quarterly; `interval: 1` + `unit: \"year\"` = annual. */\n interval: number;\n /** Day-of-month anchor for `month`/`year` units. The CANONICAL day —\n * read from the rule, never re-derived from the prior concrete date,\n * so \"31st of every month\" yields 31 → 28/29 → 31 → 30 … with no\n * drift (it is clamped per-month at compute time, not stored\n * clamped). `\"last\"` always means the last day of the target month.\n * Omitted ⇒ preserve the source date's day (safe for days ≤ 28).\n * Ignored for `day`/`week` units. */\n dayOfMonth?: number | \"last\";\n}\n\n/** Field-driven recurrence: the advance interval is selected PER RECORD by\n * the value of an `enum` field (`fromField`), looked up in `map`. Lets one\n * collection mix daily / weekly / monthly obligations in a single list — the\n * host reads `record[fromField]`, finds the matching `CollectionEvery`, and\n * advances by it. `fromField` must point at a top-level `enum` field whose\n * `values` the `map` keys exactly cover (validated at discovery), and must\n * itself be carried/`set` onto the successor so the chain keeps recurring. */\nexport interface CollectionEveryFieldDriven {\n /** Top-level `enum` field whose value selects the interval. */\n fromField: string;\n /** Interval per enum value. Keys exactly cover `fromField`'s `values`;\n * each value is a literal {@link CollectionEvery}. */\n map: Record<string, CollectionEvery>;\n}\n\n/** The `every` of a `spawn`: either a single literal interval applied to\n * every record, or a per-record interval selected by an `enum` field. The\n * literal arm is what `advanceTriggerDate` consumes — the field-driven arm\n * is resolved down to one of its `map` values before the date math runs. */\nexport type CollectionSpawnEvery = CollectionEvery | CollectionEveryFieldDriven;\n\n/** Narrowing guard: true when `every` is the field-driven arm. */\nexport function isFieldDrivenEvery(every: CollectionSpawnEvery): every is CollectionEveryFieldDriven {\n return \"fromField\" in every;\n}\n\n/** Host-driven recurrence: when a record satisfies `when`, the host\n * creates the next record with a forward-advanced `triggerField` date.\n * The successor's id and contents are a pure function of (source\n * record, this rule); creation is create-if-absent, so the mechanism\n * stays convergent — observing the predicate N times writes one\n * successor. Requires the schema to declare `triggerField`. */\nexport interface CollectionSpawn {\n /** Predicate that fires the spawn (a `CollectionWhen`). Defaults to\n * \"`completionField` value ∈ `completionDoneValues`\" (i.e. spawn the\n * next instance when this one is done). */\n when?: CollectionWhen;\n /** How to advance `triggerField` from the source to the successor —\n * either a single literal interval or a per-record, field-driven map. */\n every: CollectionSpawnEvery;\n /** Record fields copied verbatim onto the successor. Fields not listed\n * here, not in `set`, and not the trigger / primary keys start\n * blank. */\n carry?: string[];\n /** Fields forced to fixed values on the successor (typically resetting\n * the status field to its pending value). */\n set?: Record<string, unknown>;\n}\n\n/** The kind of work an action kicks off. v1 ships only `\"chat\"` —\n * start a new chat in a role with a templated seed prompt. The enum\n * reserves room for a future `\"mutate\"` (status transitions) without\n * another schema-shape change. */\nexport type CollectionActionKind = \"chat\";\n\n/** Optional visibility predicate: the target (an action button or a\n * field) renders only when the open record's `field` (stringified) is\n * one of `in`. Generic and domain-free — the host evaluates it against\n * the record with no knowledge of what the field means. Absent ⇒\n * always shown. */\nexport interface CollectionWhen {\n /** Top-level record field key whose value gates visibility. */\n field: string;\n /** Allowed values; the target shows when `String(record[field])` is\n * one of these. Non-empty. */\n in: string[];\n}\n\n/** @deprecated Name retained for back-compat; use {@link CollectionWhen}.\n * Both actions and fields share the same predicate shape. */\nexport type CollectionActionWhen = CollectionWhen;\n\n/** What a custom view's capability token is allowed to do against the\n * collection's data endpoint. `read` returns enriched records (getItems\n * semantics); `write` validates-and-stores rows (putItems semantics).\n * There is deliberately no `delete` — a view can never do more than the\n * agent's own `manageCollection` tool. */\nexport type CollectionViewCapability = \"read\" | \"write\";\n\n/** A custom (LLM-authored) HTML view for a collection. The host renders\n * `file` in a sandboxed iframe over the collection's records; the view\n * reaches its data only through a slug- and capability-scoped token (see\n * `server/api/auth/viewToken.ts`). Pure data — the host holds no\n * view-specific code; meaning lives in the HTML file + this registration. */\nexport interface CollectionCustomView {\n /** Stable id; the view-mode selector key (`custom:<id>`) and the\n * capability-token clamp key. Must be a valid slug. */\n id: string;\n /** Button label in the view-mode selector (author-authored, like field\n * labels — not run through i18n). */\n label: string;\n /** Optional Material-icon name for the selector button. */\n icon?: string;\n /** Skill-relative path to the HTML file under `views/` (e.g.\n * `views/year.html`). Path-safe, must end in `.html`. */\n file: string;\n /** What the view may do with the data endpoint. Defaults to `[\"read\"]`\n * (least privilege); declare `[\"read\",\"write\"]` only for views that\n * edit records. The mint endpoint clamps any requested caps to this. */\n capabilities?: CollectionViewCapability[];\n}\n\n/** A schema-declared, per-record action rendered as a button in the\n * read-only detail view. Pure UI/behaviour directive — never stored,\n * never validated against record data. All domain specifics (label,\n * role, template) live here in the schema / skill folder, so the host\n * stays generic. */\nexport interface CollectionAction {\n /** Stable id (used in the dispatch route + testids). */\n id: string;\n /** Button text (English, like field labels). */\n label: string;\n /** Material-icon name shown on the button. */\n icon?: string;\n /** What the action does. v1: `\"chat\"`. */\n kind: CollectionActionKind;\n /** `kind: \"chat\"`: the role id the new chat runs in. */\n role: string;\n /** `kind: \"chat\"`: skill-relative path to the template file whose\n * text becomes the seed prompt body (e.g. `templates/invoice.md`). */\n template: string;\n /** Optional visibility predicate; the button renders only when the\n * open record matches (see CollectionWhen). Absent ⇒ always\n * shown. */\n when?: CollectionWhen;\n}\n\nexport interface CollectionFieldSpec {\n type: CollectionFieldType;\n label: string;\n /** True for the field whose value is the record's filename (no\n * separate auto-id). Exactly one field per schema may set this. */\n primary?: boolean;\n required?: boolean;\n /** When `type === \"ref\"` or `type === \"embed\"`: the slug of the\n * target collection. For `ref` the record stores the target\n * item's primary-key slug and the host renders a clickable link\n * + dropdown picker. For `embed` the host pulls a *fixed* record\n * (see `id`) from the target and renders its fields read-only in\n * the detail view. Required for both; ignored on every other\n * type. */\n to?: string;\n /** When `type === \"embed\"`: the primary-key value of the fixed\n * record to pull from the `to` collection (e.g. `me` for the\n * singleton mc-profile). Nothing is stored on this record — the\n * embed is a display-only directive resolved at render time, so\n * it never appears in the list table or the edit form. Required\n * when type is `embed`; ignored on every other type. */\n id?: string;\n /** When `type === \"money\"` (or `type === \"derived\"` with\n * `display: \"money\"`): a literal ISO 4217 currency code passed to\n * `Intl.NumberFormat` for display — fixed for every record. The\n * stored value is always a plain decimal number; currency is\n * presentation only. Mutually substitutable with `currencyField`:\n * a money field must declare at least one of the two. */\n currency?: string;\n /** When `type === \"money\"` (or `type === \"derived\"` with\n * `display: \"money\"`): the name of a sibling record field whose\n * value holds the ISO 4217 code, letting currency vary per record\n * (e.g. an invoice's `currency` enum). The renderer reads\n * `record[currencyField]` and falls back to the literal `currency`\n * (then \"USD\") when the field is absent or empty. Resolved against\n * the top-level record even for money sub-fields inside a table. */\n currencyField?: string;\n /** When `type === \"enum\"`: the closed set of allowed string\n * values. The form renders a `<select>` populated from this\n * list; storage is a plain string. Required when type is\n * `enum`; ignored on every other type. */\n values?: readonly string[];\n /** When `type === \"table\"`: the sub-schema for each row (a flat\n * record of non-table / non-derived field specs). Required when\n * type is `table`. v0 disallows nested tables and derived\n * columns to keep the editor + evaluator simple. */\n of?: Record<string, CollectionFieldSpec>;\n /** When `type === \"derived\"`: a tiny expression evaluated against\n * the record. Supports `+ - * /`, parens, identifier refs to\n * top-level fields, `sum(tableField[].col)`, and\n * `sum(tableField[].col * tableField[].col)`. See\n * `src/utils/collections/derivedFormula.ts`. Required when type\n * is `derived`. */\n formula?: string;\n /** When `type === \"derived\"`: an inner field type the computed\n * value should be rendered as (e.g. `\"money\"` so $1,234.56 is\n * formatted). Defaults to `\"number\"`. */\n display?: CollectionFieldType;\n /** Optional visibility predicate: this field renders only when the\n * record matches (e.g. hide a `rating` field until `visited` is\n * `true` via `{ field: \"visited\", in: [\"true\"] }`). Applies to the\n * list cell (blank when hidden), the edit form (hidden live as the\n * gating field changes), and the detail view. Purely presentational\n * — a hidden field's stored value is never cleared. `when.field`\n * must name another top-level field. Absent ⇒ always shown. Only\n * honoured on top-level fields, not inside a `table`'s `of`. */\n when?: CollectionWhen;\n /** When `type === \"toggle\"`: the name of the top-level `enum` field this\n * checkbox projects. The toggle stores nothing itself — it reads and\n * writes this field. Required when type is `toggle`; ignored otherwise.\n * Must name a real `enum` field. */\n field?: string;\n /** When `type === \"toggle\"`: the enum value that means \"checked\". The\n * box is checked when the projected `field` equals this; checking writes\n * it. Required when type is `toggle`; must be one of the enum's `values`. */\n onValue?: string;\n /** When `type === \"toggle\"`: the enum value written when the box is\n * unchecked. Required when type is `toggle`; must be one of the enum's\n * `values`. */\n offValue?: string;\n}\n\nexport interface CollectionSchema {\n /** Human-facing collection name (sidebar, header). */\n title: string;\n /** Material-icon name shown next to the title. */\n icon: string;\n /** Workspace-relative folder holding one-JSON-per-record. Validated\n * to live under the workspace root at load time. */\n dataPath: string;\n /** Field name whose value doubles as the record's filename. */\n primaryKey: string;\n /** When set, the collection is a singleton: at most one record,\n * whose primary key is fixed to this value (e.g. `me` for the\n * business profile). The host pre-fills + locks the create form's\n * primary key and hides Add once the record exists. */\n singleton?: string;\n /** Ordered map: insertion order = column order in the table view. */\n fields: Record<string, CollectionFieldSpec>;\n /** Optional per-record actions rendered as buttons in the detail\n * view (e.g. \"Generate PDF\"). Order = button order. */\n actions?: CollectionAction[];\n /** Optional collection-level actions rendered as buttons in the\n * collection header (e.g. \"Extend the course\"). Unlike `actions`,\n * these carry no record context: the seed prompt injects a compact\n * progress summary of every record instead. The `when` predicate is\n * not evaluated (there is no record to gate on). Order = button order. */\n collectionActions?: CollectionAction[];\n /** Name of the field whose value marks an item as \"done\". When set,\n * a notification fires on item create (unless the item is born done)\n * and clears when the field's value transitions into\n * `completionDoneValues`. Must name a real field in `fields`. */\n completionField?: string;\n /** The set of values for `completionField` that count as \"done\"\n * (e.g. `[\"Done\"]` for a todo status field, `[\"paid\"]` for an\n * invoice). Non-empty. Compared as strings. */\n completionDoneValues?: readonly string[];\n /** Name of the field whose value is shown as the human-readable\n * label in a completion notification's title (e.g. a `name` field,\n * so the bell reads `Contacts: Jane Doe` instead of the opaque\n * primaryKey). Must name a real field in `fields`. When unset — or\n * when the record's value for it is empty — the title falls back to\n * the record's primaryKey value. Display-only; never stored. */\n displayField?: string;\n /** Name of a `date` field that gates this item's completion\n * notification: the bell is suppressed until the clock reaches that\n * date (compared at day-granularity in the server's local timezone),\n * instead of firing on create. Requires `completionField` /\n * `completionDoneValues` (the bell still clears via the done value).\n * Must name a real `date` field. Absent ⇒ fire on create, as before. */\n triggerField?: string;\n /** Lead time in whole days: fire the bell this many days BEFORE\n * `triggerField` (so `10` shows the reminder 10 days early). The lead\n * is applied at fire time, not stored, so it composes with `spawn` —\n * every recurred cycle fires the same number of days before its own\n * trigger. Non-negative integer; requires `triggerField`. Default 0\n * (fire on the trigger date). */\n triggerLeadDays?: number;\n /** Host-driven recurrence. When set, requires `triggerField`. See\n * {@link CollectionSpawn}. */\n spawn?: CollectionSpawn;\n /** Name of a `date` field that anchors the optional calendar view: a\n * month grid where each record lands on the day cell matching this\n * field's value. When unset, the calendar toggle still appears if the\n * schema has any `date` field (the first one, in declaration order, is\n * used by default and is switchable in-view). Set this to pin a specific\n * anchor. Must name a real `date` field. */\n calendarField?: string;\n /** Name of a second `date` field marking the END of a multi-day span on\n * the calendar: the record renders from `calendarField` through this\n * date inclusive. Requires `calendarField`. Must name a real `date`\n * field. Absent ⇒ single-day placement. */\n calendarEndField?: string;\n /** Name of a string field holding a free-form time or time-range\n * (e.g. \"14:00-17:00\", \"17:00-\", \"16:30\") that places records on the\n * calendar's day (time-allocation) view. Consulted only when the calendar\n * date fields are date-only. Requires `calendarField`. */\n calendarTimeField?: string;\n /** Name of an `enum` field that groups records into columns on the\n * optional Kanban board: each record lands in the column matching its\n * value, with empty/unknown values collected in an \"Uncategorized\"\n * column. When unset, the Kanban toggle still appears if the schema has\n * any `enum` field (the first one, in declaration order, is used by\n * default and is switchable in-view). Set this to pin a specific group\n * field. Must name a real `enum` field. */\n kanbanField?: string;\n /** Optional custom (LLM-authored) HTML views, each rendered in a\n * sandboxed iframe over the records. Absent ⇒ only the built-in\n * field-derived views (table / calendar / kanban / dashboard). See\n * {@link CollectionCustomView}. */\n views?: CollectionCustomView[];\n /** Optional predicate that gates the completion bell: when set, the bell\n * fires only for records whose `String(record[notifyWhen.field])` is one\n * of `notifyWhen.in` (e.g. notify only `high`/`urgent` priority todos).\n * Reuses the `when` predicate shape. Requires `completionField` — it\n * narrows that bell rather than introducing a second one. The bell still\n * clears on done / delete / when the predicate stops matching. Absent ⇒\n * notify for every open record (the prior behaviour). `notifyWhen.field`\n * must name a real top-level field. */\n notifyWhen?: CollectionWhen;\n /** Optional declarative retrieval config. When present, this collection\n * is a \"Feed\": the host periodically fetches `ingest.url`, maps the\n * response into records, and upserts them by `primaryKey`. Only feeds\n * discovered from the `<workspace>/feeds/` registry carry this; skill\n * collections omit it. The host's feeds subsystem narrows this to its\n * richer `IngestSpec` (which `extends CollectionIngest`). */\n ingest?: CollectionIngest;\n}\n\nexport interface CollectionSummary {\n slug: string;\n title: string;\n icon: string;\n source: CollectionSource;\n}\n\nexport interface CollectionDetail extends CollectionSummary {\n schema: CollectionSchema;\n}\n\nexport type CollectionItem = Record<string, unknown>;\n","// Tiny expression evaluator for the `derived` field type on\n// schema-driven collections (see plans/done/feat-mc-invoice.md).\n//\n// Grammar (recursive-descent, no precedence climbing — six\n// non-terminals total):\n//\n// expr := term (('+' | '-') term)*\n// term := factor (('*' | '/') factor)*\n// factor := number | sumCall | refAccess | identifier | '(' expr ')'\n// sumCall:= 'sum' '(' sumArg ')'\n// sumArg := tableCol (('*' | '/') tableCol)* // e.g. lineItems[].quantity * lineItems[].rate\n// tableCol := identifier '[]' '.' identifier\n// refAccess := identifier '.' identifier // e.g. ticker.price — deref a ref field into its target record\n//\n// `identifier` accepts top-level field names (single segment).\n// Inside `sumArg`, identifiers are the `<table>[].col` form.\n// A two-segment `<field>.<col>` at factor level is a *ref deref*:\n// `<field>` must be a `ref`-typed field on this record (its stored\n// value is the target item's slug), and `<col>` is a numeric column\n// read from that target record. The caller resolves the target into\n// `ctx.refs` (it owns the schema + the loaded target collection);\n// the evaluator stays pure and never does I/O.\n//\n// What's deliberately NOT supported (and would parse-error rather\n// than silently misbehave):\n// - String literals, boolean operators, comparisons, conditionals\n// - Nested function calls beyond `sum(...)`\n// - Anything in the record that isn't a number / table-of-objects\n//\n// All evaluation is pure — no eval(), no Function constructor.\n// Returns `null` on any failure (parse error, unbound identifier,\n// non-finite arithmetic). The caller renders `null` as em-dash in\n// the table cell + form display.\n\nexport interface FormulaContext {\n /** The record being evaluated. For derived fields in the form,\n * this is the live draft (text + table both converted via the\n * same `draftToRecord` pipeline). For the main table cell,\n * this is the persisted item. */\n record: Record<string, unknown>;\n /** Resolved ref-target records for THIS row, keyed by the local\n * `ref` field name. The caller (which has the schema + the linked\n * collection's items loaded) maps each ref field's stored slug to\n * the full target record and passes it here, so a `<field>.<col>`\n * formula can read a numeric column off the referenced record\n * (e.g. `shares * ticker.price`). A missing key or `null` value\n * (unknown field / dangling slug) makes that deref evaluate to\n * NaN → the whole formula returns `null` → em-dash, consistent\n * with every other failure mode. Absent ⇒ no refs available. */\n refs?: Record<string, Record<string, unknown> | null>;\n}\n\nexport function evaluateDerived(formula: string, ctx: FormulaContext): number | null {\n let tokens: Token[];\n try {\n tokens = tokenize(formula);\n } catch {\n return null;\n }\n // eslint-disable-next-line @typescript-eslint/no-use-before-define -- Parser class is defined later in the file (grouped with its AST + evaluator); evaluateDerived runs after module init so the TDZ concern doesn't apply.\n const parser = new Parser(tokens);\n let ast: Node;\n try {\n ast = parser.parseExpr();\n if (!parser.atEnd()) return null; // trailing junk\n } catch {\n return null;\n }\n const value = evaluate(ast, ctx);\n return Number.isFinite(value) ? value : null;\n}\n\n// ─── Tokens ────────────────────────────────────────────────\n\ntype TokenKind = \"number\" | \"ident\" | \"(\" | \")\" | \"+\" | \"-\" | \"*\" | \"/\" | \"[]\" | \".\";\n\ninterface Token {\n kind: TokenKind;\n value?: string | number;\n}\n\nconst SINGLE_CHAR_PUNCT = new Set<TokenKind>([\"(\", \")\", \"+\", \"-\", \"*\", \"/\", \".\"]);\n\ninterface Cursor {\n input: string;\n index: number;\n}\n\nfunction consumeWhitespace(cur: Cursor): boolean {\n const char = cur.input[cur.index];\n if (char === \" \" || char === \"\\t\" || char === \"\\n\") {\n cur.index++;\n return true;\n }\n return false;\n}\n\nfunction consumeNumber(cur: Cursor): Token | null {\n const char = cur.input[cur.index] ?? \"\";\n const next = cur.input[cur.index + 1] ?? \"\";\n if (!isDigit(char) && !(char === \".\" && isDigit(next))) return null;\n let raw = \"\";\n while (cur.index < cur.input.length) {\n const here = cur.input[cur.index] ?? \"\";\n if (!isDigit(here) && here !== \".\") break;\n raw += here;\n cur.index++;\n }\n const num = Number(raw);\n if (!Number.isFinite(num)) throw new Error(\"bad number\");\n return { kind: \"number\", value: num };\n}\n\nfunction consumeIdent(cur: Cursor): Token | null {\n const char = cur.input[cur.index] ?? \"\";\n if (!isIdentStart(char)) return null;\n let raw = \"\";\n while (cur.index < cur.input.length && isIdentChar(cur.input[cur.index] ?? \"\")) {\n raw += cur.input[cur.index];\n cur.index++;\n }\n return { kind: \"ident\", value: raw };\n}\n\nfunction consumePunct(cur: Cursor): Token | null {\n const char = cur.input[cur.index] ?? \"\";\n if (char === \"[\" && cur.input[cur.index + 1] === \"]\") {\n cur.index += 2;\n return { kind: \"[]\" };\n }\n if (SINGLE_CHAR_PUNCT.has(char as TokenKind)) {\n cur.index++;\n return { kind: char as TokenKind };\n }\n return null;\n}\n\nfunction tokenize(input: string): Token[] {\n const tokens: Token[] = [];\n const cur: Cursor = { input, index: 0 };\n while (cur.index < input.length) {\n if (consumeWhitespace(cur)) continue;\n // Number FIRST so a leading-dot literal (`.25`) isn't split by\n // the `.` punctuation branch.\n const numTok = consumeNumber(cur);\n if (numTok) {\n tokens.push(numTok);\n continue;\n }\n const punctTok = consumePunct(cur);\n if (punctTok) {\n tokens.push(punctTok);\n continue;\n }\n const identTok = consumeIdent(cur);\n if (identTok) {\n tokens.push(identTok);\n continue;\n }\n throw new Error(`unexpected char ${input[cur.index]}`);\n }\n return tokens;\n}\n\nfunction isDigit(char: string): boolean {\n return char >= \"0\" && char <= \"9\";\n}\nfunction isIdentStart(char: string): boolean {\n return (char >= \"a\" && char <= \"z\") || (char >= \"A\" && char <= \"Z\") || char === \"_\";\n}\nfunction isIdentChar(char: string): boolean {\n return isIdentStart(char) || isDigit(char);\n}\n\n// ─── AST + Parser ───────────────────────────────────────────\n\ntype Node =\n | { kind: \"num\"; value: number }\n | { kind: \"ident\"; name: string }\n | { kind: \"ref\"; field: string; col: string }\n | { kind: \"binop\"; operator: \"+\" | \"-\" | \"*\" | \"/\"; left: Node; right: Node }\n | { kind: \"sum\"; arg: SumArg };\n\ninterface SumArg {\n // factors multiplied/divided together; each is a (tableName, colName) ref into a row.\n factors: { table: string; col: string }[];\n /** Operators between factors: length = factors.length - 1; each\n * is \"*\" or \"/\". For a single-factor sum (`sum(lineItems[].amount)`)\n * this is empty. */\n operators: (\"*\" | \"/\")[];\n}\n\nclass Parser {\n private cursor = 0;\n constructor(private readonly tokens: Token[]) {}\n\n atEnd(): boolean {\n return this.cursor >= this.tokens.length;\n }\n private peek(): Token | undefined {\n return this.tokens[this.cursor];\n }\n private consume(): Token {\n const tok = this.tokens[this.cursor++];\n if (!tok) throw new Error(\"unexpected end of input\");\n return tok;\n }\n private expect(kind: TokenKind): Token {\n const tok = this.consume();\n if (tok.kind !== kind) throw new Error(`expected ${kind}, got ${tok.kind}`);\n return tok;\n }\n\n parseExpr(): Node {\n let left = this.parseTerm();\n while (this.peek()?.kind === \"+\" || this.peek()?.kind === \"-\") {\n const operator = this.consume().kind as \"+\" | \"-\";\n const right = this.parseTerm();\n left = { kind: \"binop\", operator, left, right };\n }\n return left;\n }\n\n private parseTerm(): Node {\n let left = this.parseFactor();\n while (this.peek()?.kind === \"*\" || this.peek()?.kind === \"/\") {\n const operator = this.consume().kind as \"*\" | \"/\";\n const right = this.parseFactor();\n left = { kind: \"binop\", operator, left, right };\n }\n return left;\n }\n\n private parseFactor(): Node {\n const tok = this.peek();\n if (!tok) throw new Error(\"unexpected end in factor\");\n if (tok.kind === \"number\") {\n this.consume();\n return { kind: \"num\", value: tok.value as number };\n }\n if (tok.kind === \"(\") {\n this.consume();\n const inner = this.parseExpr();\n this.expect(\")\");\n return inner;\n }\n if (tok.kind === \"ident\") {\n const name = (tok.value as string) ?? \"\";\n // sum(...) — only function call we support\n if (name === \"sum\" && this.tokens[this.cursor + 1]?.kind === \"(\") {\n this.consume(); // ident\n this.expect(\"(\");\n const arg = this.parseSumArg();\n this.expect(\")\");\n return { kind: \"sum\", arg };\n }\n this.consume(); // ident\n // ref deref: `<field>.<col>` (e.g. ticker.price). The table-row\n // form `<table>[].col` only appears inside sum(), so a `.`\n // immediately after a top-level ident is unambiguously a ref\n // dereference here.\n if (this.peek()?.kind === \".\") {\n this.consume(); // '.'\n const col = this.expect(\"ident\");\n return { kind: \"ref\", field: name, col: col.value as string };\n }\n return { kind: \"ident\", name };\n }\n throw new Error(`unexpected token ${tok.kind} in factor`);\n }\n\n private parseSumArg(): SumArg {\n const factors: { table: string; col: string }[] = [];\n const operators: (\"*\" | \"/\")[] = [];\n factors.push(this.parseTableCol());\n while (this.peek()?.kind === \"*\" || this.peek()?.kind === \"/\") {\n const operator = this.consume().kind as \"*\" | \"/\";\n operators.push(operator);\n factors.push(this.parseTableCol());\n }\n return { factors, operators };\n }\n\n private parseTableCol(): { table: string; col: string } {\n const tableTok = this.expect(\"ident\");\n this.expect(\"[]\");\n this.expect(\".\");\n const colTok = this.expect(\"ident\");\n return { table: tableTok.value as string, col: colTok.value as string };\n }\n}\n\n// ─── Evaluator ──────────────────────────────────────────────\n\nfunction evaluate(node: Node, ctx: FormulaContext): number {\n if (node.kind === \"num\") return node.value;\n if (node.kind === \"ident\") {\n const raw = ctx.record[node.name];\n return toFiniteNumber(raw);\n }\n if (node.kind === \"ref\") {\n // `<field>.<col>`: read `col` off the resolved target record the\n // caller put in ctx.refs. Unknown field / dangling slug → null →\n // NaN, so the whole formula fails soft to an em-dash.\n const target = ctx.refs?.[node.field] ?? null;\n if (!target) return Number.NaN;\n return toFiniteNumber(target[node.col]);\n }\n if (node.kind === \"binop\") {\n const left = evaluate(node.left, ctx);\n const right = evaluate(node.right, ctx);\n return applyBinop(node.operator, left, right);\n }\n if (node.kind === \"sum\") {\n return evaluateSum(node.arg, ctx);\n }\n // Exhaustive — TS narrows above branches but throw keeps runtime honest.\n throw new Error(`unknown node`);\n}\n\nfunction applyBinop(operator: \"+\" | \"-\" | \"*\" | \"/\", left: number, right: number): number {\n if (!Number.isFinite(left) || !Number.isFinite(right)) return Number.NaN;\n if (operator === \"+\") return left + right;\n if (operator === \"-\") return left - right;\n if (operator === \"*\") return left * right;\n // operator === \"/\"\n if (right === 0) return Number.NaN;\n return left / right;\n}\n\nfunction evaluateSum(arg: SumArg, ctx: FormulaContext): number {\n if (arg.factors.length === 0) return 0;\n const tableName = arg.factors[0].table;\n // All factors must reference the SAME table (you can't multiply\n // a row from lineItems against a row from another table — the\n // semantics would be ambiguous). Reject mismatch.\n for (const factor of arg.factors) {\n if (factor.table !== tableName) return Number.NaN;\n }\n const rows = ctx.record[tableName];\n if (!Array.isArray(rows)) return 0;\n let total = 0;\n for (const row of rows) {\n if (!row || typeof row !== \"object\") continue;\n let product = toFiniteNumber((row as Record<string, unknown>)[arg.factors[0].col]);\n if (!Number.isFinite(product)) return Number.NaN;\n for (let i = 1; i < arg.factors.length; i++) {\n const value = toFiniteNumber((row as Record<string, unknown>)[arg.factors[i].col]);\n if (!Number.isFinite(value)) return Number.NaN;\n product = applyBinop(arg.operators[i - 1], product, value);\n }\n total += product;\n }\n return total;\n}\n\nfunction toFiniteNumber(value: unknown): number {\n if (typeof value === \"number\") return Number.isFinite(value) ? value : Number.NaN;\n if (typeof value === \"string\" && value.length > 0) {\n const num = Number(value);\n return Number.isFinite(num) ? num : Number.NaN;\n }\n return Number.NaN;\n}\n","// The derived-field saturation loop for schema-driven collections,\n// extracted from `composables/collections/useCollectionRendering.ts` so\n// the server (manageCollection getItems enrichment) and the client\n// (table cells, form display) evaluate formulas through ONE\n// implementation — if the two ever diverged, the UI and the LLM would\n// disagree on a number. Pure module: no Vue, no I/O.\n//\n// Like `actionVisible.ts`, the input types are minimal structural\n// shapes so both the client `FieldSpec`/`CollectionSchema`\n// (src/components/collectionTypes.ts) and the server\n// `CollectionFieldSpec`/`CollectionSchema`\n// (server/workspace/collections/types.ts) satisfy them as-is.\n\nimport { evaluateDerived, type FormulaContext } from \"./derivedFormula\";\n\n/** Minimal field shape the derive loop needs — accepts both the client\n * FieldSpec and the server CollectionFieldSpec. */\nexport interface DerivableFieldSpec {\n type: string;\n /** When type === \"ref\": slug of the target collection. */\n to?: string;\n /** When type === \"derived\": formula evaluated against the record. */\n formula?: string;\n}\n\n/** Minimal schema shape: just the ordered field map. */\nexport interface DerivableSchema {\n fields: Record<string, DerivableFieldSpec>;\n}\n\nexport type DerivableRecord = Record<string, unknown>;\n\n/** Per-target-collection cache of loaded referenced records:\n * target collection slug → item slug → full record. Mirrors the\n * client's `RefRecordCache` / the server's enrichment loader. */\nexport type DeriveRefRecords = Record<string, Record<string, DerivableRecord>>;\n\n/** Map each `ref` field's stored slug to its loaded target record (or\n * null when dangling / not loaded), keyed by the LOCAL field name —\n * the shape `evaluateDerived` reads for `<field>.<col>` derefs. */\nexport function resolveRowRefs(schema: DerivableSchema, record: DerivableRecord, refRecords: DeriveRefRecords): NonNullable<FormulaContext[\"refs\"]> {\n const refs: NonNullable<FormulaContext[\"refs\"]> = {};\n for (const [key, field] of Object.entries(schema.fields)) {\n if (field.type !== \"ref\" || !field.to) continue;\n const slug = record[key];\n refs[key] = typeof slug === \"string\" ? (refRecords[field.to]?.[slug] ?? null) : null;\n }\n return refs;\n}\n\n/** Evaluate every `derived` field against `base`, saturating so a\n * derived field can read another derived field computed in an earlier\n * pass (`subtotal → tax → total` converges in ≤ field-count passes).\n * Cycles can't loop forever — passes are bounded by the number of\n * derived fields and the loop breaks as soon as a pass changes\n * nothing. Failed formulas stay ABSENT (the UI renders them as\n * em-dash). Returns a copy; `base` is never mutated.\n *\n * Derived keys already present in `base` are stripped before\n * evaluation: computed output is host-truth, never persisted-input\n * fallback. A record JSON can carry a stale (or forged) derived value\n * — raw Write/Edit, legacy data — and without the strip, a failing\n * formula would silently surface that value as if the host computed\n * it. */\nexport function deriveAll(schema: DerivableSchema, base: DerivableRecord, refRecords: DeriveRefRecords): DerivableRecord {\n const derivedKeys = new Set(Object.keys(schema.fields).filter((key) => schema.fields[key]?.type === \"derived\"));\n const enriched: DerivableRecord = Object.fromEntries(Object.entries(base).filter(([key]) => !derivedKeys.has(key)));\n const refs = resolveRowRefs(schema, base, refRecords);\n const maxPasses = Object.values(schema.fields).filter((field) => field.type === \"derived\").length;\n for (let pass = 0; pass < maxPasses; pass++) {\n let mutated = false;\n for (const [key, field] of Object.entries(schema.fields)) {\n if (field.type !== \"derived\" || !field.formula) continue;\n const next = evaluateDerived(field.formula, { record: enriched, refs });\n if (next !== null && enriched[key] !== next) {\n enriched[key] = next;\n mutated = true;\n }\n }\n if (!mutated) break;\n }\n return enriched;\n}\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"deriveAll-C6BYnpBL.js","names":[],"sources":["../src/collection/core/schema.ts","../src/collection/core/derivedFormula.ts","../src/collection/core/deriveAll.ts"],"sourcesContent":["// Schema-driven collection types. A \"collection\" is a skill (under\n// .claude/skills/<slug>/) that also ships a sibling `schema.json`.\n// The host's <CollectionView> reads the schema + records and renders\n// a table/form; Claude reads SKILL.md and CRUDs the records as JSON\n// files.\n//\n// Field types for v0 — keep this list narrow and grow it only when a\n// real collection needs the new type. v0 supports flat records only;\n// nested tables / cross-collection refs / derived fields / actions are\n// deferred to follow-ups (see plans/done/feat-skill-driven-apps.md and\n// plans/done/feat-skill-driven-apps-worklog.md — historical names predate\n// the rename).\n\n/** Minimal \"this collection is a feed\" descriptor carried on the schema.\n * Deliberately narrow — the canonical collection contract stays\n * independent of the host's feeds subsystem. The host's richer retrieval\n * spec (`IngestSpec` in `server/workspace/feeds/ingestTypes.ts`)\n * `extends CollectionIngest`, so feed code reads the extra fields by\n * typing feed schemas with that subtype; collection rendering only needs\n * these three + the presence check. */\nexport interface CollectionIngest {\n kind: string;\n url: string;\n schedule: string;\n}\n\n/** Retriever kinds a Feed's `ingest.kind` may declare. The host's feeds engine\n * dispatches on these; they live here (with the schema contract) so the schema\n * validator can enforce them. The host re-exports these from\n * `server/workspace/feeds/ingestTypes.ts`. */\nexport const INGEST_KINDS = [\"rss\", \"atom\", \"http-json\"] as const;\nexport type IngestKind = (typeof INGEST_KINDS)[number];\n\n/** Refresh cadences a Feed's `ingest.schedule` may declare. */\nexport const FEED_SCHEDULES = [\"hourly\", \"daily\", \"weekly\", \"on-demand\"] as const;\nexport type FeedSchedule = (typeof FEED_SCHEDULES)[number];\n\nexport type CollectionFieldType =\n | \"string\"\n | \"text\"\n | \"email\"\n | \"number\"\n | \"date\"\n | \"datetime\"\n | \"boolean\"\n | \"markdown\"\n | \"ref\"\n | \"money\"\n | \"enum\"\n | \"table\"\n | \"derived\"\n | \"embed\"\n // Holds a workspace-relative image path (e.g. a `data/attachments/...`\n // upload); rendered as an <img> in the detail view (not the list table —\n // a per-row fetch is too expensive at scale). Stored and edited as a\n // plain string.\n | \"image\"\n // Holds a workspace-relative file path as a plain string (e.g. an\n // `artifacts/html/<name>.html` app). Rendered as a clickable link in\n // both the list table and the detail view: HTML / SVG artifacts open\n // their rendered form in a new tab; any other path opens in the File\n // Explorer. Stored and edited as a plain string, like `image`.\n | \"file\"\n // A checkbox that is a pure PROJECTION of an `enum` field — it stores\n // nothing of its own. Checked when the enum equals `onValue`; toggling\n // writes `onValue` / `offValue` back to that enum field. Lets a \"done\"\n // checkbox front a kanban `status` field with the enum as the single\n // source of truth (no separate stored boolean to keep in sync).\n | \"toggle\";\n\n// \"feed\" collections live in the non-skill `<workspace>/feeds/` registry\n// and carry an `ingest` block; they reuse the same storage + rendering\n// as skill-backed collections but are never loaded into the agent prompt.\nexport type CollectionSource = \"user\" | \"project\" | \"feed\";\n\n/** Recurrence unit for a `spawn.every` advance. */\nexport type CollectionRecurUnit = \"day\" | \"week\" | \"month\" | \"year\";\n\n/** How a `spawn` advances the source item's `triggerField` date to\n * produce the successor's. All arithmetic is done on the civil\n * (year, month, day) triple — never by adding milliseconds — so month\n * lengths and leap years are handled correctly. */\nexport interface CollectionEvery {\n unit: CollectionRecurUnit;\n /** Number of `unit`s to advance (≥ 1). `interval: 3` + `unit: \"month\"`\n * = quarterly; `interval: 1` + `unit: \"year\"` = annual. */\n interval: number;\n /** Day-of-month anchor for `month`/`year` units. The CANONICAL day —\n * read from the rule, never re-derived from the prior concrete date,\n * so \"31st of every month\" yields 31 → 28/29 → 31 → 30 … with no\n * drift (it is clamped per-month at compute time, not stored\n * clamped). `\"last\"` always means the last day of the target month.\n * Omitted ⇒ preserve the source date's day (safe for days ≤ 28).\n * Ignored for `day`/`week` units. */\n dayOfMonth?: number | \"last\";\n}\n\n/** Field-driven recurrence: the advance interval is selected PER RECORD by\n * the value of an `enum` field (`fromField`), looked up in `map`. Lets one\n * collection mix daily / weekly / monthly obligations in a single list — the\n * host reads `record[fromField]`, finds the matching `CollectionEvery`, and\n * advances by it. `fromField` must point at a top-level `enum` field whose\n * `values` the `map` keys exactly cover (validated at discovery), and must\n * itself be carried/`set` onto the successor so the chain keeps recurring. */\nexport interface CollectionEveryFieldDriven {\n /** Top-level `enum` field whose value selects the interval. */\n fromField: string;\n /** Interval per enum value. Keys exactly cover `fromField`'s `values`;\n * each value is a literal {@link CollectionEvery}. */\n map: Record<string, CollectionEvery>;\n}\n\n/** The `every` of a `spawn`: either a single literal interval applied to\n * every record, or a per-record interval selected by an `enum` field. The\n * literal arm is what `advanceTriggerDate` consumes — the field-driven arm\n * is resolved down to one of its `map` values before the date math runs. */\nexport type CollectionSpawnEvery = CollectionEvery | CollectionEveryFieldDriven;\n\n/** Narrowing guard: true when `every` is the field-driven arm. */\nexport function isFieldDrivenEvery(every: CollectionSpawnEvery): every is CollectionEveryFieldDriven {\n return \"fromField\" in every;\n}\n\n/** Host-driven recurrence: when a record satisfies `when`, the host\n * creates the next record with a forward-advanced `triggerField` date.\n * The successor's id and contents are a pure function of (source\n * record, this rule); creation is create-if-absent, so the mechanism\n * stays convergent — observing the predicate N times writes one\n * successor. Requires the schema to declare `triggerField`. */\nexport interface CollectionSpawn {\n /** Predicate that fires the spawn (a `CollectionWhen`). Defaults to\n * \"`completionField` value ∈ `completionDoneValues`\" (i.e. spawn the\n * next instance when this one is done). */\n when?: CollectionWhen;\n /** How to advance `triggerField` from the source to the successor —\n * either a single literal interval or a per-record, field-driven map. */\n every: CollectionSpawnEvery;\n /** Record fields copied verbatim onto the successor. Fields not listed\n * here, not in `set`, and not the trigger / primary keys start\n * blank. */\n carry?: string[];\n /** Fields forced to fixed values on the successor (typically resetting\n * the status field to its pending value). */\n set?: Record<string, unknown>;\n}\n\n/** The kind of work an action kicks off. v1 ships only `\"chat\"` —\n * start a new chat in a role with a templated seed prompt. The enum\n * reserves room for a future `\"mutate\"` (status transitions) without\n * another schema-shape change. */\nexport type CollectionActionKind = \"chat\";\n\n/** Optional visibility predicate: the target (an action button or a\n * field) renders only when the open record's `field` (stringified) is\n * one of `in`. Generic and domain-free — the host evaluates it against\n * the record with no knowledge of what the field means. Absent ⇒\n * always shown. */\nexport interface CollectionWhen {\n /** Top-level record field key whose value gates visibility. */\n field: string;\n /** Allowed values; the target shows when `String(record[field])` is\n * one of these. Non-empty. */\n in: string[];\n}\n\n/** @deprecated Name retained for back-compat; use {@link CollectionWhen}.\n * Both actions and fields share the same predicate shape. */\nexport type CollectionActionWhen = CollectionWhen;\n\n/** What a custom view's capability token is allowed to do against the\n * collection's data endpoint. `read` returns enriched records (getItems\n * semantics); `write` validates-and-stores rows (putItems semantics).\n * There is deliberately no `delete` — a view can never do more than the\n * agent's own `manageCollection` tool. */\nexport type CollectionViewCapability = \"read\" | \"write\";\n\n/** A custom (LLM-authored) HTML view for a collection. The host renders\n * `file` in a sandboxed iframe over the collection's records; the view\n * reaches its data only through a slug- and capability-scoped token (see\n * `server/api/auth/viewToken.ts`). Pure data — the host holds no\n * view-specific code; meaning lives in the HTML file + this registration. */\nexport interface CollectionCustomView {\n /** Stable id; the view-mode selector key (`custom:<id>`) and the\n * capability-token clamp key. Must be a valid slug. */\n id: string;\n /** Button label in the view-mode selector (author-authored, like field\n * labels — not run through i18n). */\n label: string;\n /** Optional Material-icon name for the selector button. */\n icon?: string;\n /** Skill-relative path to the HTML file under `views/` (e.g.\n * `views/year.html`). Path-safe, must end in `.html`. */\n file: string;\n /** What the view may do with the data endpoint. Defaults to `[\"read\"]`\n * (least privilege); declare `[\"read\",\"write\"]` only for views that\n * edit records. The mint endpoint clamps any requested caps to this. */\n capabilities?: CollectionViewCapability[];\n}\n\n/** A schema-declared, per-record action rendered as a button in the\n * read-only detail view. Pure UI/behaviour directive — never stored,\n * never validated against record data. All domain specifics (label,\n * role, template) live here in the schema / skill folder, so the host\n * stays generic. */\nexport interface CollectionAction {\n /** Stable id (used in the dispatch route + testids). */\n id: string;\n /** Button text (English, like field labels). */\n label: string;\n /** Material-icon name shown on the button. */\n icon?: string;\n /** What the action does. v1: `\"chat\"`. */\n kind: CollectionActionKind;\n /** `kind: \"chat\"`: the role id the new chat runs in. */\n role: string;\n /** `kind: \"chat\"`: skill-relative path to the template file whose\n * text becomes the seed prompt body (e.g. `templates/invoice.md`). */\n template: string;\n /** Optional visibility predicate; the button renders only when the\n * open record matches (see CollectionWhen). Absent ⇒ always\n * shown. */\n when?: CollectionWhen;\n}\n\nexport interface CollectionFieldSpec {\n type: CollectionFieldType;\n label: string;\n /** True for the field whose value is the record's filename (no\n * separate auto-id). Exactly one field per schema may set this. */\n primary?: boolean;\n required?: boolean;\n /** When `type === \"ref\"` or `type === \"embed\"`: the slug of the\n * target collection. For `ref` the record stores the target\n * item's primary-key slug and the host renders a clickable link\n * + dropdown picker. For `embed` the host pulls a *fixed* record\n * (see `id`) from the target and renders its fields read-only in\n * the detail view. Required for both; ignored on every other\n * type. */\n to?: string;\n /** When `type === \"embed\"`: the primary-key value of the fixed\n * record to pull from the `to` collection (e.g. `me` for the\n * singleton mc-profile). Nothing is stored on this record — the\n * embed is a display-only directive resolved at render time, so\n * it never appears in the list table or the edit form. Required\n * when type is `embed`; ignored on every other type. */\n id?: string;\n /** When `type === \"money\"` (or `type === \"derived\"` with\n * `display: \"money\"`): a literal ISO 4217 currency code passed to\n * `Intl.NumberFormat` for display — fixed for every record. The\n * stored value is always a plain decimal number; currency is\n * presentation only. Mutually substitutable with `currencyField`:\n * a money field must declare at least one of the two. */\n currency?: string;\n /** When `type === \"money\"` (or `type === \"derived\"` with\n * `display: \"money\"`): the name of a sibling record field whose\n * value holds the ISO 4217 code, letting currency vary per record\n * (e.g. an invoice's `currency` enum). The renderer reads\n * `record[currencyField]` and falls back to the literal `currency`\n * (then \"USD\") when the field is absent or empty. Resolved against\n * the top-level record even for money sub-fields inside a table. */\n currencyField?: string;\n /** When `type === \"enum\"`: the closed set of allowed string\n * values. The form renders a `<select>` populated from this\n * list; storage is a plain string. Required when type is\n * `enum`; ignored on every other type. */\n values?: readonly string[];\n /** When `type === \"table\"`: the sub-schema for each row (a flat\n * record of non-table / non-derived field specs). Required when\n * type is `table`. v0 disallows nested tables and derived\n * columns to keep the editor + evaluator simple. */\n of?: Record<string, CollectionFieldSpec>;\n /** When `type === \"derived\"`: a tiny expression evaluated against\n * the record. Supports `+ - * /`, parens, identifier refs to\n * top-level fields, `sum(tableField[].col)`, and\n * `sum(tableField[].col * tableField[].col)`. See\n * `src/utils/collections/derivedFormula.ts`. Required when type\n * is `derived`. */\n formula?: string;\n /** When `type === \"derived\"`: an inner field type the computed\n * value should be rendered as (e.g. `\"money\"` so $1,234.56 is\n * formatted). Defaults to `\"number\"`. */\n display?: CollectionFieldType;\n /** Optional visibility predicate: this field renders only when the\n * record matches (e.g. hide a `rating` field until `visited` is\n * `true` via `{ field: \"visited\", in: [\"true\"] }`). Applies to the\n * list cell (blank when hidden), the edit form (hidden live as the\n * gating field changes), and the detail view. Purely presentational\n * — a hidden field's stored value is never cleared. `when.field`\n * must name another top-level field. Absent ⇒ always shown. Only\n * honoured on top-level fields, not inside a `table`'s `of`. */\n when?: CollectionWhen;\n /** When `type === \"toggle\"`: the name of the top-level `enum` field this\n * checkbox projects. The toggle stores nothing itself — it reads and\n * writes this field. Required when type is `toggle`; ignored otherwise.\n * Must name a real `enum` field. */\n field?: string;\n /** When `type === \"toggle\"`: the enum value that means \"checked\". The\n * box is checked when the projected `field` equals this; checking writes\n * it. Required when type is `toggle`; must be one of the enum's `values`. */\n onValue?: string;\n /** When `type === \"toggle\"`: the enum value written when the box is\n * unchecked. Required when type is `toggle`; must be one of the enum's\n * `values`. */\n offValue?: string;\n}\n\nexport interface CollectionSchema {\n /** Human-facing collection name (sidebar, header). */\n title: string;\n /** Material-icon name shown next to the title. */\n icon: string;\n /** Workspace-relative folder holding one-JSON-per-record. Validated\n * to live under the workspace root at load time. */\n dataPath: string;\n /** Field name whose value doubles as the record's filename. */\n primaryKey: string;\n /** When set, the collection is a singleton: at most one record,\n * whose primary key is fixed to this value (e.g. `me` for the\n * business profile). The host pre-fills + locks the create form's\n * primary key and hides Add once the record exists. */\n singleton?: string;\n /** Ordered map: insertion order = column order in the table view. */\n fields: Record<string, CollectionFieldSpec>;\n /** Optional per-record actions rendered as buttons in the detail\n * view (e.g. \"Generate PDF\"). Order = button order. */\n actions?: CollectionAction[];\n /** Optional collection-level actions rendered as buttons in the\n * collection header (e.g. \"Extend the course\"). Unlike `actions`,\n * these carry no record context: the seed prompt injects a compact\n * progress summary of every record instead. The `when` predicate is\n * not evaluated (there is no record to gate on). Order = button order. */\n collectionActions?: CollectionAction[];\n /** Name of the field whose value marks an item as \"done\". When set,\n * a notification fires on item create (unless the item is born done)\n * and clears when the field's value transitions into\n * `completionDoneValues`. Must name a real field in `fields`. */\n completionField?: string;\n /** The set of values for `completionField` that count as \"done\"\n * (e.g. `[\"Done\"]` for a todo status field, `[\"paid\"]` for an\n * invoice). Non-empty. Compared as strings. */\n completionDoneValues?: readonly string[];\n /** Name of the field whose value is shown as the human-readable\n * label in a completion notification's title (e.g. a `name` field,\n * so the bell reads `Contacts: Jane Doe` instead of the opaque\n * primaryKey). Must name a real field in `fields`. When unset — or\n * when the record's value for it is empty — the title falls back to\n * the record's primaryKey value. Display-only; never stored. */\n displayField?: string;\n /** Name of a `date` field that gates this item's completion\n * notification: the bell is suppressed until the clock reaches that\n * date (compared at day-granularity in the server's local timezone),\n * instead of firing on create. Requires `completionField` /\n * `completionDoneValues` (the bell still clears via the done value).\n * Must name a real `date` field. Absent ⇒ fire on create, as before. */\n triggerField?: string;\n /** Lead time in whole days: fire the bell this many days BEFORE\n * `triggerField` (so `10` shows the reminder 10 days early). The lead\n * is applied at fire time, not stored, so it composes with `spawn` —\n * every recurred cycle fires the same number of days before its own\n * trigger. Non-negative integer; requires `triggerField`. Default 0\n * (fire on the trigger date). */\n triggerLeadDays?: number;\n /** Host-driven recurrence. When set, requires `triggerField`. See\n * {@link CollectionSpawn}. */\n spawn?: CollectionSpawn;\n /** Name of a `date` field that anchors the optional calendar view: a\n * month grid where each record lands on the day cell matching this\n * field's value. When unset, the calendar toggle still appears if the\n * schema has any `date` field (the first one, in declaration order, is\n * used by default and is switchable in-view). Set this to pin a specific\n * anchor. Must name a real `date` field. */\n calendarField?: string;\n /** Name of a second `date` field marking the END of a multi-day span on\n * the calendar: the record renders from `calendarField` through this\n * date inclusive. Requires `calendarField`. Must name a real `date`\n * field. Absent ⇒ single-day placement. */\n calendarEndField?: string;\n /** Name of a string field holding a free-form time or time-range\n * (e.g. \"14:00-17:00\", \"17:00-\", \"16:30\") that places records on the\n * calendar's day (time-allocation) view. Consulted only when the calendar\n * date fields are date-only. Requires `calendarField`. */\n calendarTimeField?: string;\n /** Name of an `enum` field that groups records into columns on the\n * optional Kanban board: each record lands in the column matching its\n * value, with empty/unknown values collected in an \"Uncategorized\"\n * column. When unset, the Kanban toggle still appears if the schema has\n * any `enum` field (the first one, in declaration order, is used by\n * default and is switchable in-view). Set this to pin a specific group\n * field. Must name a real `enum` field. */\n kanbanField?: string;\n /** Optional custom (LLM-authored) HTML views, each rendered in a\n * sandboxed iframe over the records. Absent ⇒ only the built-in\n * field-derived views (table / calendar / kanban / dashboard). See\n * {@link CollectionCustomView}. */\n views?: CollectionCustomView[];\n /** Optional predicate that gates the completion bell: when set, the bell\n * fires only for records whose `String(record[notifyWhen.field])` is one\n * of `notifyWhen.in` (e.g. notify only `high`/`urgent` priority todos).\n * Reuses the `when` predicate shape. Requires `completionField` — it\n * narrows that bell rather than introducing a second one. The bell still\n * clears on done / delete / when the predicate stops matching. Absent ⇒\n * notify for every open record (the prior behaviour). `notifyWhen.field`\n * must name a real top-level field. */\n notifyWhen?: CollectionWhen;\n /** Optional declarative retrieval config. When present, this collection\n * is a \"Feed\": the host periodically fetches `ingest.url`, maps the\n * response into records, and upserts them by `primaryKey`. Only feeds\n * discovered from the `<workspace>/feeds/` registry carry this; skill\n * collections omit it. The host's feeds subsystem narrows this to its\n * richer `IngestSpec` (which `extends CollectionIngest`). */\n ingest?: CollectionIngest;\n}\n\nexport interface CollectionSummary {\n slug: string;\n title: string;\n icon: string;\n source: CollectionSource;\n}\n\nexport interface CollectionDetail extends CollectionSummary {\n schema: CollectionSchema;\n}\n\nexport type CollectionItem = Record<string, unknown>;\n","// Tiny expression evaluator for the `derived` field type on\n// schema-driven collections (see plans/done/feat-mc-invoice.md).\n//\n// Grammar (recursive-descent, no precedence climbing — six\n// non-terminals total):\n//\n// expr := term (('+' | '-') term)*\n// term := factor (('*' | '/') factor)*\n// factor := number | sumCall | refAccess | identifier | '(' expr ')'\n// sumCall:= 'sum' '(' sumArg ')'\n// sumArg := tableCol (('*' | '/') tableCol)* // e.g. lineItems[].quantity * lineItems[].rate\n// tableCol := identifier '[]' '.' identifier\n// refAccess := identifier '.' identifier // e.g. ticker.price — deref a ref field into its target record\n//\n// `identifier` accepts top-level field names (single segment).\n// Inside `sumArg`, identifiers are the `<table>[].col` form.\n// A two-segment `<field>.<col>` at factor level is a *ref deref*:\n// `<field>` must be a `ref`-typed field on this record (its stored\n// value is the target item's slug), and `<col>` is a numeric column\n// read from that target record. The caller resolves the target into\n// `ctx.refs` (it owns the schema + the loaded target collection);\n// the evaluator stays pure and never does I/O.\n//\n// What's deliberately NOT supported (and would parse-error rather\n// than silently misbehave):\n// - String literals, boolean operators, comparisons, conditionals\n// - Nested function calls beyond `sum(...)`\n// - Anything in the record that isn't a number / table-of-objects\n//\n// All evaluation is pure — no eval(), no Function constructor.\n// Returns `null` on any failure (parse error, unbound identifier,\n// non-finite arithmetic). The caller renders `null` as em-dash in\n// the table cell + form display.\n\nexport interface FormulaContext {\n /** The record being evaluated. For derived fields in the form,\n * this is the live draft (text + table both converted via the\n * same `draftToRecord` pipeline). For the main table cell,\n * this is the persisted item. */\n record: Record<string, unknown>;\n /** Resolved ref-target records for THIS row, keyed by the local\n * `ref` field name. The caller (which has the schema + the linked\n * collection's items loaded) maps each ref field's stored slug to\n * the full target record and passes it here, so a `<field>.<col>`\n * formula can read a numeric column off the referenced record\n * (e.g. `shares * ticker.price`). A missing key or `null` value\n * (unknown field / dangling slug) makes that deref evaluate to\n * NaN → the whole formula returns `null` → em-dash, consistent\n * with every other failure mode. Absent ⇒ no refs available. */\n refs?: Record<string, Record<string, unknown> | null>;\n}\n\nexport function evaluateDerived(formula: string, ctx: FormulaContext): number | null {\n let tokens: Token[];\n try {\n tokens = tokenize(formula);\n } catch {\n return null;\n }\n // eslint-disable-next-line @typescript-eslint/no-use-before-define -- Parser class is defined later in the file (grouped with its AST + evaluator); evaluateDerived runs after module init so the TDZ concern doesn't apply.\n const parser = new Parser(tokens);\n let ast: Node;\n try {\n ast = parser.parseExpr();\n if (!parser.atEnd()) return null; // trailing junk\n } catch {\n return null;\n }\n const value = evaluate(ast, ctx);\n return Number.isFinite(value) ? value : null;\n}\n\n// ─── Tokens ────────────────────────────────────────────────\n\ntype TokenKind = \"number\" | \"ident\" | \"(\" | \")\" | \"+\" | \"-\" | \"*\" | \"/\" | \"[]\" | \".\";\n\ninterface Token {\n kind: TokenKind;\n value?: string | number;\n}\n\nconst SINGLE_CHAR_PUNCT = new Set<TokenKind>([\"(\", \")\", \"+\", \"-\", \"*\", \"/\", \".\"]);\n\ninterface Cursor {\n input: string;\n index: number;\n}\n\nfunction consumeWhitespace(cur: Cursor): boolean {\n const char = cur.input[cur.index];\n if (char === \" \" || char === \"\\t\" || char === \"\\n\") {\n cur.index++;\n return true;\n }\n return false;\n}\n\nfunction consumeNumber(cur: Cursor): Token | null {\n const char = cur.input[cur.index] ?? \"\";\n const next = cur.input[cur.index + 1] ?? \"\";\n if (!isDigit(char) && !(char === \".\" && isDigit(next))) return null;\n let raw = \"\";\n while (cur.index < cur.input.length) {\n const here = cur.input[cur.index] ?? \"\";\n if (!isDigit(here) && here !== \".\") break;\n raw += here;\n cur.index++;\n }\n const num = Number(raw);\n if (!Number.isFinite(num)) throw new Error(\"bad number\");\n return { kind: \"number\", value: num };\n}\n\nfunction consumeIdent(cur: Cursor): Token | null {\n const char = cur.input[cur.index] ?? \"\";\n if (!isIdentStart(char)) return null;\n let raw = \"\";\n while (cur.index < cur.input.length && isIdentChar(cur.input[cur.index] ?? \"\")) {\n raw += cur.input[cur.index];\n cur.index++;\n }\n return { kind: \"ident\", value: raw };\n}\n\nfunction consumePunct(cur: Cursor): Token | null {\n const char = cur.input[cur.index] ?? \"\";\n if (char === \"[\" && cur.input[cur.index + 1] === \"]\") {\n cur.index += 2;\n return { kind: \"[]\" };\n }\n if (SINGLE_CHAR_PUNCT.has(char as TokenKind)) {\n cur.index++;\n return { kind: char as TokenKind };\n }\n return null;\n}\n\nfunction tokenize(input: string): Token[] {\n const tokens: Token[] = [];\n const cur: Cursor = { input, index: 0 };\n while (cur.index < input.length) {\n if (consumeWhitespace(cur)) continue;\n // Number FIRST so a leading-dot literal (`.25`) isn't split by\n // the `.` punctuation branch.\n const numTok = consumeNumber(cur);\n if (numTok) {\n tokens.push(numTok);\n continue;\n }\n const punctTok = consumePunct(cur);\n if (punctTok) {\n tokens.push(punctTok);\n continue;\n }\n const identTok = consumeIdent(cur);\n if (identTok) {\n tokens.push(identTok);\n continue;\n }\n throw new Error(`unexpected char ${input[cur.index]}`);\n }\n return tokens;\n}\n\nfunction isDigit(char: string): boolean {\n return char >= \"0\" && char <= \"9\";\n}\nfunction isIdentStart(char: string): boolean {\n return (char >= \"a\" && char <= \"z\") || (char >= \"A\" && char <= \"Z\") || char === \"_\";\n}\nfunction isIdentChar(char: string): boolean {\n return isIdentStart(char) || isDigit(char);\n}\n\n// ─── AST + Parser ───────────────────────────────────────────\n\ntype Node =\n | { kind: \"num\"; value: number }\n | { kind: \"ident\"; name: string }\n | { kind: \"ref\"; field: string; col: string }\n | { kind: \"binop\"; operator: \"+\" | \"-\" | \"*\" | \"/\"; left: Node; right: Node }\n | { kind: \"sum\"; arg: SumArg };\n\ninterface SumArg {\n // factors multiplied/divided together; each is a (tableName, colName) ref into a row.\n factors: { table: string; col: string }[];\n /** Operators between factors: length = factors.length - 1; each\n * is \"*\" or \"/\". For a single-factor sum (`sum(lineItems[].amount)`)\n * this is empty. */\n operators: (\"*\" | \"/\")[];\n}\n\nclass Parser {\n private cursor = 0;\n constructor(private readonly tokens: Token[]) {}\n\n atEnd(): boolean {\n return this.cursor >= this.tokens.length;\n }\n private peek(): Token | undefined {\n return this.tokens[this.cursor];\n }\n private consume(): Token {\n const tok = this.tokens[this.cursor++];\n if (!tok) throw new Error(\"unexpected end of input\");\n return tok;\n }\n private expect(kind: TokenKind): Token {\n const tok = this.consume();\n if (tok.kind !== kind) throw new Error(`expected ${kind}, got ${tok.kind}`);\n return tok;\n }\n\n parseExpr(): Node {\n let left = this.parseTerm();\n while (this.peek()?.kind === \"+\" || this.peek()?.kind === \"-\") {\n const operator = this.consume().kind as \"+\" | \"-\";\n const right = this.parseTerm();\n left = { kind: \"binop\", operator, left, right };\n }\n return left;\n }\n\n private parseTerm(): Node {\n let left = this.parseFactor();\n while (this.peek()?.kind === \"*\" || this.peek()?.kind === \"/\") {\n const operator = this.consume().kind as \"*\" | \"/\";\n const right = this.parseFactor();\n left = { kind: \"binop\", operator, left, right };\n }\n return left;\n }\n\n private parseFactor(): Node {\n const tok = this.peek();\n if (!tok) throw new Error(\"unexpected end in factor\");\n if (tok.kind === \"number\") {\n this.consume();\n return { kind: \"num\", value: tok.value as number };\n }\n if (tok.kind === \"(\") {\n this.consume();\n const inner = this.parseExpr();\n this.expect(\")\");\n return inner;\n }\n if (tok.kind === \"ident\") {\n const name = (tok.value as string) ?? \"\";\n // sum(...) — only function call we support\n if (name === \"sum\" && this.tokens[this.cursor + 1]?.kind === \"(\") {\n this.consume(); // ident\n this.expect(\"(\");\n const arg = this.parseSumArg();\n this.expect(\")\");\n return { kind: \"sum\", arg };\n }\n this.consume(); // ident\n // ref deref: `<field>.<col>` (e.g. ticker.price). The table-row\n // form `<table>[].col` only appears inside sum(), so a `.`\n // immediately after a top-level ident is unambiguously a ref\n // dereference here.\n if (this.peek()?.kind === \".\") {\n this.consume(); // '.'\n const col = this.expect(\"ident\");\n return { kind: \"ref\", field: name, col: col.value as string };\n }\n return { kind: \"ident\", name };\n }\n throw new Error(`unexpected token ${tok.kind} in factor`);\n }\n\n private parseSumArg(): SumArg {\n const factors: { table: string; col: string }[] = [];\n const operators: (\"*\" | \"/\")[] = [];\n factors.push(this.parseTableCol());\n while (this.peek()?.kind === \"*\" || this.peek()?.kind === \"/\") {\n const operator = this.consume().kind as \"*\" | \"/\";\n operators.push(operator);\n factors.push(this.parseTableCol());\n }\n return { factors, operators };\n }\n\n private parseTableCol(): { table: string; col: string } {\n const tableTok = this.expect(\"ident\");\n this.expect(\"[]\");\n this.expect(\".\");\n const colTok = this.expect(\"ident\");\n return { table: tableTok.value as string, col: colTok.value as string };\n }\n}\n\n// ─── Evaluator ──────────────────────────────────────────────\n\nfunction evaluate(node: Node, ctx: FormulaContext): number {\n if (node.kind === \"num\") return node.value;\n if (node.kind === \"ident\") {\n const raw = ctx.record[node.name];\n return toFiniteNumber(raw);\n }\n if (node.kind === \"ref\") {\n // `<field>.<col>`: read `col` off the resolved target record the\n // caller put in ctx.refs. Unknown field / dangling slug → null →\n // NaN, so the whole formula fails soft to an em-dash.\n const target = ctx.refs?.[node.field] ?? null;\n if (!target) return Number.NaN;\n return toFiniteNumber(target[node.col]);\n }\n if (node.kind === \"binop\") {\n const left = evaluate(node.left, ctx);\n const right = evaluate(node.right, ctx);\n return applyBinop(node.operator, left, right);\n }\n if (node.kind === \"sum\") {\n return evaluateSum(node.arg, ctx);\n }\n // Exhaustive — TS narrows above branches but throw keeps runtime honest.\n throw new Error(`unknown node`);\n}\n\nfunction applyBinop(operator: \"+\" | \"-\" | \"*\" | \"/\", left: number, right: number): number {\n if (!Number.isFinite(left) || !Number.isFinite(right)) return Number.NaN;\n if (operator === \"+\") return left + right;\n if (operator === \"-\") return left - right;\n if (operator === \"*\") return left * right;\n // operator === \"/\"\n if (right === 0) return Number.NaN;\n return left / right;\n}\n\nfunction evaluateSum(arg: SumArg, ctx: FormulaContext): number {\n if (arg.factors.length === 0) return 0;\n const tableName = arg.factors[0].table;\n // All factors must reference the SAME table (you can't multiply\n // a row from lineItems against a row from another table — the\n // semantics would be ambiguous). Reject mismatch.\n for (const factor of arg.factors) {\n if (factor.table !== tableName) return Number.NaN;\n }\n const rows = ctx.record[tableName];\n if (!Array.isArray(rows)) return 0;\n let total = 0;\n for (const row of rows) {\n if (!row || typeof row !== \"object\") continue;\n let product = toFiniteNumber((row as Record<string, unknown>)[arg.factors[0].col]);\n if (!Number.isFinite(product)) return Number.NaN;\n for (let i = 1; i < arg.factors.length; i++) {\n const value = toFiniteNumber((row as Record<string, unknown>)[arg.factors[i].col]);\n if (!Number.isFinite(value)) return Number.NaN;\n product = applyBinop(arg.operators[i - 1], product, value);\n }\n total += product;\n }\n return total;\n}\n\nfunction toFiniteNumber(value: unknown): number {\n if (typeof value === \"number\") return Number.isFinite(value) ? value : Number.NaN;\n if (typeof value === \"string\" && value.length > 0) {\n const num = Number(value);\n return Number.isFinite(num) ? num : Number.NaN;\n }\n return Number.NaN;\n}\n","// The derived-field saturation loop for schema-driven collections,\n// extracted from `composables/collections/useCollectionRendering.ts` so\n// the server (manageCollection getItems enrichment) and the client\n// (table cells, form display) evaluate formulas through ONE\n// implementation — if the two ever diverged, the UI and the LLM would\n// disagree on a number. Pure module: no Vue, no I/O.\n//\n// Like `actionVisible.ts`, the input types are minimal structural\n// shapes so both the client `FieldSpec`/`CollectionSchema`\n// (src/components/collectionTypes.ts) and the server\n// `CollectionFieldSpec`/`CollectionSchema`\n// (server/workspace/collections/types.ts) satisfy them as-is.\n\nimport { evaluateDerived, type FormulaContext } from \"./derivedFormula\";\n\n/** Minimal field shape the derive loop needs — accepts both the client\n * FieldSpec and the server CollectionFieldSpec. */\nexport interface DerivableFieldSpec {\n type: string;\n /** When type === \"ref\": slug of the target collection. */\n to?: string;\n /** When type === \"derived\": formula evaluated against the record. */\n formula?: string;\n}\n\n/** Minimal schema shape: just the ordered field map. */\nexport interface DerivableSchema {\n fields: Record<string, DerivableFieldSpec>;\n}\n\nexport type DerivableRecord = Record<string, unknown>;\n\n/** Per-target-collection cache of loaded referenced records:\n * target collection slug → item slug → full record. Mirrors the\n * client's `RefRecordCache` / the server's enrichment loader. */\nexport type DeriveRefRecords = Record<string, Record<string, DerivableRecord>>;\n\n/** Map each `ref` field's stored slug to its loaded target record (or\n * null when dangling / not loaded), keyed by the LOCAL field name —\n * the shape `evaluateDerived` reads for `<field>.<col>` derefs. */\nexport function resolveRowRefs(schema: DerivableSchema, record: DerivableRecord, refRecords: DeriveRefRecords): NonNullable<FormulaContext[\"refs\"]> {\n const refs: NonNullable<FormulaContext[\"refs\"]> = {};\n for (const [key, field] of Object.entries(schema.fields)) {\n if (field.type !== \"ref\" || !field.to) continue;\n const slug = record[key];\n refs[key] = typeof slug === \"string\" ? (refRecords[field.to]?.[slug] ?? null) : null;\n }\n return refs;\n}\n\n/** Evaluate every `derived` field against `base`, saturating so a\n * derived field can read another derived field computed in an earlier\n * pass (`subtotal → tax → total` converges in ≤ field-count passes).\n * Cycles can't loop forever — passes are bounded by the number of\n * derived fields and the loop breaks as soon as a pass changes\n * nothing. Failed formulas stay ABSENT (the UI renders them as\n * em-dash). Returns a copy; `base` is never mutated.\n *\n * Derived keys already present in `base` are stripped before\n * evaluation: computed output is host-truth, never persisted-input\n * fallback. A record JSON can carry a stale (or forged) derived value\n * — raw Write/Edit, legacy data — and without the strip, a failing\n * formula would silently surface that value as if the host computed\n * it. */\nexport function deriveAll(schema: DerivableSchema, base: DerivableRecord, refRecords: DeriveRefRecords): DerivableRecord {\n const derivedKeys = new Set(Object.keys(schema.fields).filter((key) => schema.fields[key]?.type === \"derived\"));\n const enriched: DerivableRecord = Object.fromEntries(Object.entries(base).filter(([key]) => !derivedKeys.has(key)));\n const refs = resolveRowRefs(schema, base, refRecords);\n const maxPasses = Object.values(schema.fields).filter((field) => field.type === \"derived\").length;\n for (let pass = 0; pass < maxPasses; pass++) {\n let mutated = false;\n for (const [key, field] of Object.entries(schema.fields)) {\n if (field.type !== \"derived\" || !field.formula) continue;\n const next = evaluateDerived(field.formula, { record: enriched, refs });\n if (next !== null && enriched[key] !== next) {\n enriched[key] = next;\n mutated = true;\n }\n }\n if (!mutated) break;\n }\n return enriched;\n}\n"],"mappings":"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"}
|