To maalte defekter i lastesiden for brukerkontekst (underlag: designnotatets paragraf 3.1-3.3, maalt ved aa kjoere den ekte mekanismen): 1. DEFAULT_MAX_VALUE_LEN = 160 kappet hvert felt uavhengig av hvor mye av budsjettet som sto ubrukt. Maalt: et 300-tegns svar tapte 47 prosent, et 2000-tegns svar 92 prosent - ogsaa free-context.md, gjennom sin egen kodevei. Erstattet av DEFAULT_MAX_TOTAL_LEN = 4000: et totalbudsjett fordelt max-min-rettferdig over feltene som overlever linje-cappen. Hvorfor 4000: det er noeyaktig hva den gamle formen alt tillot i verste fall (cap 25 linjer x 160 tegn), saa endringen er kostnadsnoeytral mot dagens tak og strengt bedre under det. Fordi budsjettet deles kun over de hoeyst 25 feltene som faktisk skrives ut, faar intet felt mindre enn 4000/25 = 160: den gamle cappen er blitt gulvet. Begrunnelsen staar der konstanten defineres. Per-felt-cap var feil FORM, ikke bare feil tall: den kan ikke uttrykke at ett langt, viktig svar overlever fordi fem korte ikke trengte sin andel. 2. readOrgFiles itererte den frosne ORG_FILES-lista uten readdir, saa en fil brukeren la til i org/ naadde aldri modellen. Begge sider er endret - hooken leser katalogen, og buildOrgSummary ignorerer ikke lenger filnavn utenfor ORG_FILES - ellers ville endringen vaert en no-op som ser ut som en fiks. Bare de fem kanoniske teller fortsatt mot onboarding-fullfoerthet. Ny ren, delt hjelper orderOrgFiles(names) gir begge sider samme deterministiske rekkefoelge: ORG_FILES foerst, saa oevrige .md sortert, free-context sist. Verifisert paa den ekte hooken mot en fixture-org-katalog, med kontrollmaaling mot HEAD: den syvende fila var usynlig foer og gir 43 tegn naa; en 1198-tegns fri kontekst ble kappet til 160 foer og overlever hel naa. TDD: 10 nye tester i tests/kb-update/test-user-data.test.mjs, alle roede foerst (7 assertion-feil mot gammel kode, 3 paa manglende eksport). Suite 1062/1062. [skip-docs]: ren intern mekanikk - ingen ny kommando, agent, skill eller hook, og ingen endring i utoverrettet flate.
253 lines
10 KiB
JavaScript
253 lines
10 KiB
JavaScript
// 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)
|
|
|
|
// Total char budget for the field VALUES of one summary, shared across the
|
|
// fields that survive DEFAULT_SUMMARY_CAP.
|
|
//
|
|
// 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. Because it is shared only over the fields that
|
|
// survive the line cap (at most 25), no field is ever allocated less than
|
|
// 4000/25 = 160: the old cap becomes the new floor, reached only at exactly 25
|
|
// fields. Raising this number raises the cost of every session in the repo.
|
|
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];
|
|
}
|
|
|
|
/**
|
|
* 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<string, string|null>|null|undefined} orgFiles
|
|
* @param {{cap?: number, maxTotalLen?: number}} [opts]
|
|
* @returns {string}
|
|
*/
|
|
export function buildOrgSummary(orgFiles, opts = {}) {
|
|
if (!orgFiles || typeof orgFiles !== 'object') return '';
|
|
const cap = opts.cap ?? DEFAULT_SUMMARY_CAP;
|
|
const maxTotalLen = opts.maxTotalLen ?? DEFAULT_MAX_TOTAL_LEN;
|
|
|
|
// Collect UNCUT [label, value] pairs first: the char budget can only be spent
|
|
// fairly once it is known which fields the line cap actually emits.
|
|
const fields = [];
|
|
for (const name of orderOrgFiles(Object.keys(orgFiles))) {
|
|
const content = orgFiles[name];
|
|
if (typeof content !== 'string' || !content.trim()) continue;
|
|
for (const section of extractSections(content)) fields.push(section);
|
|
}
|
|
|
|
// 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];
|
|
if (typeof freeContent === 'string' && freeContent.trim()) {
|
|
const body = collapseProse(freeContent);
|
|
if (body) fields.push([FREE_CONTEXT_LABEL, body]);
|
|
}
|
|
|
|
if (fields.length === 0) return '';
|
|
|
|
// Budget 1 — lines: over cap, keep (cap-1) fields plus a marker for the rest.
|
|
const overflow = fields.length > cap;
|
|
const kept = overflow ? fields.slice(0, cap - 1) : fields;
|
|
|
|
// 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) lines.push(`… (+${fields.length - kept.length} flere felt)`);
|
|
return lines.join('\n');
|
|
}
|