// user-data.mjs — Spor C fase C2.1: user-owned storage + ambient org context. // // This module is a PURE RESOLVER. It NEVER writes and never spawns anything — // it only resolves user-owned paths and builds a compact, deterministic // summary from file contents it is HANDED (no fs read of its own). All org-/ // config writing happens elsewhere, gated, via lib/atomic-write + lib/backup. // // Why a user-owned dir? Org context + the C1 scheduler config previously lived // inside the plugin directory (gitignored), so a plugin reinstall / marketplace // move blew them away. Resolving them under ~/.claude/ms-ai-architect/ — a // sibling of ~/.claude/plugins/, independent of pluginRoot — makes them survive // reinstall (acceptance K2). Privacy is preserved: this path is in no git repo. // // Zero dependencies beyond node:os/path. Every function is pure. import { homedir } from 'node:os'; import { join } from 'node:path'; // The plugin's folder under ~/.claude/ (sibling of ~/.claude/plugins/). export const USER_DATA_DIRNAME = 'ms-ai-architect'; // The scheduler config filename. Single source of truth shared with // detection-schedule.mjs (C1) so loadScheduleConfig stays backward-compatible. export const CONFIG_FILENAME = 'ms-ai-architect.local.md'; // The five structured onboarding files, in the order they are surfaced. export const ORG_FILES = Object.freeze([ 'organization-profile.md', 'technology-stack.md', 'security-compliance.md', 'architecture-decisions.md', 'business-references.md', ]); // The optional free-prose file (C2.2, #3): "everything else you want the plugin // to know". Deliberately NOT in ORG_FILES — its presence is optional and must // NOT affect the onboarding-completeness count (the SessionStart hook gates that // on `count < ORG_FILES.length`). It is read for the ambient summary only. export const FREE_CONTEXT_FILE = 'free-context.md'; const FREE_CONTEXT_LABEL = 'Fri kontekst'; const DEFAULT_SUMMARY_CAP = 25; // max summary lines (hook-injection budget) const DEFAULT_MAX_VALUE_LEN = 160; // per-field char cap (free-text guard) /** The user-owned data root: ~/.claude/ms-ai-architect/ (survives reinstall). */ export function resolveUserDataDir(home = homedir()) { return join(home, '.claude', USER_DATA_DIRNAME); } /** The user-owned org/ directory (the five structured onboarding files). */ export function resolveOrgDir(home = homedir()) { return join(resolveUserDataDir(home), 'org'); } /** The user-owned scheduler config path (same filename as the C1 plugin-root one). */ export function resolveConfigPath(home = homedir()) { return join(resolveUserDataDir(home), CONFIG_FILENAME); } /** Drop a leading `---`…`---` YAML frontmatter block, if present. */ function stripFrontmatter(text) { if (!text.startsWith('---')) return text; const m = text.match(/^---\n[\s\S]*?\n---\n?/); return m ? text.slice(m[0].length) : text; } /** * Extract `## Header` → collapsed-body pairs from one org file's markdown. * Frontmatter and the leading H1 are ignored; H2 sections with an empty body * are skipped. Body lines are collapsed to a single spaced line. Pure. * @param {string} content * @returns {Array<[string, string]>} */ function extractSections(content) { const lines = stripFrontmatter(content).split('\n'); const out = []; let header = null; let body = []; const flush = () => { if (header !== null) { const value = body.join(' ').replace(/\s+/g, ' ').trim(); if (value) out.push([header, value]); } }; for (const line of lines) { const h2 = line.match(/^##\s+(.+?)\s*$/); if (h2) { flush(); header = h2[1]; body = []; } else if (header !== null && !/^#\s/.test(line)) { body.push(line); } } flush(); return out; } /** * Collapse a markdown document to one prose line: drop frontmatter and every * header line, then squeeze whitespace. Used for the free-context file, which is * ONE free field (not structured H2 sections) and may be plain prose — so it is * surfaced whole rather than per-section, never silently dropped. Pure. * @param {string} content * @returns {string} */ function collapseProse(content) { return stripFrontmatter(content) .split('\n') .filter((line) => !/^#{1,6}\s/.test(line)) .join(' ') .replace(/\s+/g, ' ') .trim(); } /** Truncate a field value to maxValueLen, appending an ellipsis when cut. Pure. */ function capValue(value, maxValueLen) { return value.length > maxValueLen ? `${value.slice(0, maxValueLen - 1).trimEnd()}…` : value; } /** * Build a compact, deterministic org-context summary from org file contents. * PURE — takes a `{ filename: contentString }` map (the caller reads the files) * and returns a length-capped `Header: value` block, one field per line, in * ORG_FILES order then header order. Empty/garbage input => "". * * Robust by design: it extracts whatever H2 sections the onboarding agent * wrote rather than hard-coding field names, so header wording can drift * without silently emptying the summary. * * @param {Record|null|undefined} orgFiles * @param {{cap?: number, maxValueLen?: number}} [opts] * @returns {string} */ export function buildOrgSummary(orgFiles, opts = {}) { if (!orgFiles || typeof orgFiles !== 'object') return ''; const cap = opts.cap ?? DEFAULT_SUMMARY_CAP; const maxValueLen = opts.maxValueLen ?? DEFAULT_MAX_VALUE_LEN; const fields = []; for (const name of ORG_FILES) { const content = orgFiles[name]; if (typeof content !== 'string' || !content.trim()) continue; for (const [header, value] of extractSections(content)) { fields.push(`${header}: ${capValue(value, maxValueLen)}`); } } // Free-prose context (C2.2, #3): the optional sixth file, surfaced LAST as one // "Fri kontekst" field regardless of internal markdown structure, so a plain // paragraph is never silently dropped (acceptance K3). Capped like any field. const freeContent = orgFiles[FREE_CONTEXT_FILE]; if (typeof freeContent === 'string' && freeContent.trim()) { const body = collapseProse(freeContent); if (body) fields.push(`${FREE_CONTEXT_LABEL}: ${capValue(body, maxValueLen)}`); } if (fields.length === 0) return ''; if (fields.length <= cap) return fields.join('\n'); // Over budget: keep (cap-1) fields, then a marker line for the omitted rest. const kept = fields.slice(0, cap - 1); kept.push(`… (+${fields.length - kept.length} flere felt)`); return kept.join('\n'); }