// 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'; // Minimum line budget: what a small, designed onboarding is allowed to spend // even when it has fewer files than lines. Above this floor the line budget is // derived from the file count (see coverageLineBudget) — because at scale the // binding cost is chars, not lines, and a fixed line count silently decides // WHICH documents exist. const DEFAULT_SUMMARY_CAP = 25; // The shortest value a coverage line may carry before it stops being a summary // and becomes a fragment. It bounds how far the one-line-per-file guarantee can // stretch: at most floor(maxTotalLen / this) lines. // // Anchored to the designed onboarding's own field lengths, measured 2026-08-26 // over the real artefact (~/.claude/ms-ai-architect/org, 18 structured fields): // median 34 chars, mean 75, max 278. 60 keeps the median field whole with // headroom and cuts the mean one by about a fifth; below it the median field // starts breaking mid-sentence. With the 4000-char budget this permits 66 lines. const MIN_COVERAGE_VALUE_LEN = 60; // Total char budget for the field VALUES of one summary, shared across the // fields that survive the line budget (see coverageLineBudget). // // Why a TOTAL budget and not the per-field cap it replaces: the summary is // injected ambiently at EVERY session start, so what costs is the total, not // any single field — and a per-field cap cannot see how much of that total is // unspent. Measured 2026-08-26 by running this function with the hook's // defaults over the designed onboarding (19 fields, ~52 chars each): it spent // ~1000 of the 4000 chars the mechanism already permitted, while still cutting // any field past 160 chars — a 300-char answer lost 47 %, a 2000-char one 92 %. // free-context.md, the one slot where the user writes freely, was cut the same // way through its own code path (collapseProse before capValue). // // Why 4000 specifically: that is exactly what the old form already permitted at // its worst case — DEFAULT_SUMMARY_CAP (25) lines x the old 160-char per-field // cap. The budget is therefore cost-neutral against today's ceiling and // strictly better below it. Raising this number raises the cost of every // session in the repo. // // The per-field floor this comment used to promise (4000/25 = 160, "reached // only at exactly 25 fields") held only while the line cap was fixed at 25. It // is not a property of the budget — it was a property of the defect the line // cap turned out to be. The floor that survives is MIN_COVERAGE_VALUE_LEN, // which is what the line budget is derived FROM rather than what falls out of // it. const DEFAULT_MAX_TOTAL_LEN = 4000; /** 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, Math.max(0, maxValueLen - 1)).trimEnd()}…` : value; } /** * Max-min fair allocation of a total char budget over field values: a field * shorter than its even share is kept whole and leaves its slack to the longer * ones, iterating until nothing more can be settled. This is what a per-field * cap cannot express — one long, important answer surviving because five short * ones did not need their share. Deterministic and pure; returns one allowance * per input length, in input order. * @param {number[]} lengths * @param {number} budget * @returns {number[]} */ function allocateBudget(lengths, budget) { const allowance = new Array(lengths.length).fill(0); let open = lengths.map((_, i) => i); let remaining = budget; while (open.length > 0) { const share = Math.floor(remaining / open.length); const settled = open.filter((i) => lengths[i] <= share); if (settled.length === 0) { // Every field still open wants more than the even share: split the rest, // handing the indivisible remainder to the earliest fields. let rest = remaining - share * open.length; for (const i of open) { allowance[i] = share + (rest > 0 ? 1 : 0); if (rest > 0) rest -= 1; } break; } for (const i of settled) { allowance[i] = lengths[i]; remaining -= lengths[i]; } open = open.filter((i) => lengths[i] > share); } return allowance; } /** * The org files to surface, in deterministic order: the canonical ORG_FILES * first, then any OTHER `.md` file the user has added to org/, sorted by name. * The free-prose file is excluded — callers append it last under its own label. * * Why it exists: the summary and the hook that feeds it used to iterate the * frozen ORG_FILES list, so a seventh file in org/ never reached the model at * all (measured 2026-08-26). Both sides now order their files through here. * * Pure: takes names (a readdir listing, or a content map's keys) and reads * nothing. * @param {string[]} names * @returns {string[]} */ export function orderOrgFiles(names) { if (!Array.isArray(names)) return []; const present = new Set(names.filter((n) => typeof n === 'string' && n.endsWith('.md'))); const extra = [...present] .filter((n) => !ORG_FILES.includes(n) && n !== FREE_CONTEXT_FILE) .sort(); return [...ORG_FILES.filter((n) => present.has(n)), ...extra]; } /** * The line budget for one summary: enough lines to give every file at least one * field, but never so many that a line drops below MIN_COVERAGE_VALUE_LEN, and * never fewer than DEFAULT_SUMMARY_CAP. * * Why the count is derived and not a constant: this summary is injected * ambiently at session start, BEFORE any question exists, so there is no * relevance signal to rank fields against. A fixed line count therefore does * not reduce — it SELECTS, by whatever order the files happen to be in. * Measured 2026-08-26 (bake-off arm A2b, flat org/, 55 corpus documents): 61 * files and 183 fields in, 24 fields out, all from 8 files, with 1013 of the * 4000 value chars unspent. When you cannot rank, the honest substitute is to * cover: spend the budget across documents rather than on the first few. * * Pure. * @param {number} fileCount * @param {number} maxTotalLen * @returns {number} */ function coverageLineBudget(fileCount, maxTotalLen) { const affordable = Math.floor(maxTotalLen / MIN_COVERAGE_VALUE_LEN); return Math.max(DEFAULT_SUMMARY_CAP, Math.min(fileCount + 1, affordable)); } /** * Choose which of the collected fields survive, breadth-first across their * source files: every file gives up its first field before any file gives up * its second. Returns a Set of indices into `fields` — SELECTION only; the * caller still emits in the original file order, so reading order is unchanged. * * Pure. * @param {Array<[string, string, string]>} fields [header, value, sourceFile] * @param {number} room how many fields may be emitted * @returns {Set} */ function pickBreadthFirst(fields, room) { const byFile = new Map(); fields.forEach(([, , file], i) => { if (!byFile.has(file)) byFile.set(file, []); byFile.get(file).push(i); }); const picked = new Set(); const queues = [...byFile.values()]; for (let round = 0; picked.size < room; round++) { let handedOut = false; for (const q of queues) { if (round >= q.length) continue; picked.add(q[round]); handedOut = true; if (picked.size >= room) break; } if (!handedOut) break; // every file exhausted } return picked; } /** * 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 budget-bounded `Header: value` block, one field per line, in * orderOrgFiles 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, maxTotalLen?: number}} [opts] `cap` is a hard line * ceiling; omit it to get the coverage-derived budget, which is the form the * hook uses. * @returns {string} */ export function buildOrgSummary(orgFiles, opts = {}) { if (!orgFiles || typeof orgFiles !== 'object') return ''; const maxTotalLen = opts.maxTotalLen ?? DEFAULT_MAX_TOTAL_LEN; // An explicit cap is a plain ceiling (callers that ask for N lines get N). // The DEFAULT is derived from how many files there are, so growth in org/ // widens the summary instead of silently hiding documents behind it. const cap = opts.cap ?? coverageLineBudget( orderOrgFiles(Object.keys(orgFiles)).length + (orgFiles[FREE_CONTEXT_FILE] ? 1 : 0), maxTotalLen, ); // Collect UNCUT [header, value, sourceFile] triples first: the char budget can // only be spent fairly once it is known which fields the line budget emits. // Provenance is carried because the reduction unit is the DOCUMENT, not the // field — without it the cut cannot tell whether it is dropping a file's // fifth field or its only one. const fields = []; for (const name of orderOrgFiles(Object.keys(orgFiles))) { const content = orgFiles[name]; if (typeof content !== 'string' || !content.trim()) continue; for (const [header, value] of extractSections(content)) fields.push([header, value, name]); } // Free-prose context (C2.2, #3): the optional free-text file, surfaced LAST as // one "Fri kontekst" field regardless of internal markdown structure, so a // plain paragraph is never silently dropped (acceptance K3). const freeContent = orgFiles[FREE_CONTEXT_FILE]; let hasFreeContext = false; if (typeof freeContent === 'string' && freeContent.trim()) { const body = collapseProse(freeContent); if (body) { fields.push([FREE_CONTEXT_LABEL, body, FREE_CONTEXT_FILE]); hasFreeContext = true; } } if (fields.length === 0) return ''; // Budget 1 — lines: over cap, keep (cap-1) fields plus a marker for the rest. // WHICH fields is decided breadth-first across source files, so the cut is a // reduction and not a fixed-order selection (see coverageLineBudget). The // emission order below is still the file order, so reading order is stable. // // "Fri kontekst" is exempt from that cut. It is appended LAST so it reads last, // which under a plain head-slice made it the FIRST field to disappear — and it // is the one slot the user writes freely in, which onboarding-agent.md calls // the single category that most sharpens the advice. Position in the summary // is a reading order, not a priority order; the cut must not conflate them. const overflow = fields.length > cap; let kept = fields; if (overflow) { const room = Math.max(0, cap - 1); // fields emittable alongside the marker const structured = hasFreeContext ? fields.slice(0, -1) : fields; const picked = pickBreadthFirst(structured, hasFreeContext ? Math.max(0, room - 1) : room); kept = structured.filter((_, i) => picked.has(i)); if (hasFreeContext && room >= 1) kept.push(fields[fields.length - 1]); } // Budget 2 — chars: shared max-min fairly over the fields actually emitted. const allowance = allocateBudget(kept.map(([, value]) => value.length), maxTotalLen); const lines = kept.map(([header, value], i) => `${header}: ${capValue(value, allowance[i])}`); if (overflow) { // Report the files left out, not only the fields. A file with nothing // emitted is invisible to the session, and the measured failure mode was // exactly that it was invisible SILENTLY — the user believes the plugin is // onboarded while the advice is generic. const keptFiles = new Set(kept.map(([, , file]) => file)); const allFilesCount = new Set(fields.map(([, , file]) => file)).size; const droppedFiles = allFilesCount - keptFiles.size; lines.push( `… (+${fields.length - kept.length} flere felt; ` + `${droppedFiles} av ${allFilesCount} filer helt utelatt)`, ); } return lines.join('\n'); }