Linje-cappen på 25 var ikke en reduksjon, men en seleksjon etter fast rekkefølge. Målt 2026-08-26 (bake-off arm A2b, flatt org/, 55 korpusdokumenter): 61 filer og 183 felt inn, 24 felt ut — alle fra 8 filer — mens 1 013 av de 4 000 verditegnene sto ubrukt. 53 filer var usynlige, og usynligheten var stille: brukeren tror pluginen er onboardet mens rådet er generisk. Sammendraget bygges ambient ved sesjonsstart, FØR det finnes et spørsmål, så det finnes ingen relevans å rangere mot. Når man ikke kan rangere, er det ærlige alternativet å dekke: - linjebudsjettet utledes fra filantallet (én linje per fil) i stedet for å være konstant, begrenset av MIN_COVERAGE_VALUE_LEN = 60 tegn per linje — forankret i den designede onboardingens egne feltlengder (median 34, snitt 75, maks 278) - seleksjonen er bredde-først på tvers av filer: hver fil gir fra seg sitt første felt før noen fil gir fra seg sitt andre. Emisjonsrekkefølgen er fortsatt filrekkefølgen, så leserekkefølgen er uendret - overflow-markøren oppgir hvor mange FILER som er utelatt, ikke bare felt Målt mot bake-off-harnesset (forhåndsregistrert gullsett, 40 spørsmål x 4 frikontekst-lengder), kontrollmålt mot git stash: n=55 A2b 10/160 -> 160/160 (kostnad 4 443 -> 4 923 tegn) n=25 A2b 10/160 -> 68/160 n=10 A2b 10/160 -> 24/160 A2b treffer nå last-alt-taket (identisk på alle fire korpusstørrelser) til 4 923 tegn mot 33 127. Ved den designede onboardingen (6 filer, 19 felt) er hookens output byte-identisk med før — kostnaden øker kun der den gamle koden skjulte dokumenter. Lesesiden verifisert ende-til-ende: hooken emitterer 62 linjer mot 25, med markøren "0 av 61 filer helt utelatt". Reach-metrikken sier at dokumentet er representert, ikke at svaret er riktig; hver fil bæres av ~65 tegn av sitt første felt. Den harde faktum-metrikken er ugyldig — dens egen kjent-positiv (last-alt) treffer bare 4/40. Tester: 7 nye i test-user-data.test.mjs (3 røde mot HEAD, verifisert med git stash). Sporet suite 1070/1070. [skip-docs]: intern reduksjonsmekanikk. Ingen kommando, agent, skill eller hook endrer grensesnitt; README/CLAUDE.md beskriver ingen av disse konstantene.
375 lines
16 KiB
JavaScript
375 lines
16 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';
|
|
|
|
// 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<number>}
|
|
*/
|
|
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<string, string|null>|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');
|
|
}
|