feat(ms-ai-architect): erstatt per-felt-cap med totalbudsjett og aapne det frosne filsettet [skip-docs]

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.
This commit is contained in:
Kjell Tore Guttormsen 2026-08-26 10:15:05 +02:00
commit a7098e0fdf
3 changed files with 216 additions and 33 deletions

View file

@ -19,6 +19,7 @@ import {
ORG_FILES, ORG_FILES,
FREE_CONTEXT_FILE, FREE_CONTEXT_FILE,
buildOrgSummary, buildOrgSummary,
orderOrgFiles,
} from '../../scripts/kb-update/lib/user-data.mjs'; } from '../../scripts/kb-update/lib/user-data.mjs';
import { loadAiActDeadlines } from '../../scripts/kb-update/lib/ai-act-deadlines.mjs'; import { loadAiActDeadlines } from '../../scripts/kb-update/lib/ai-act-deadlines.mjs';
@ -116,17 +117,24 @@ function readOrgFiles(dir) {
const files = {}; const files = {};
let count = 0; let count = 0;
if (existsSync(dir)) { if (existsSync(dir)) {
for (const f of ORG_FILES) { let entries = [];
const p = join(dir, f);
if (existsSync(p)) {
try { try {
files[f] = readFileSync(p, 'utf8'); entries = readdirSync(dir);
count++; } catch {
// Unreadable dir — skip; advisory only
}
// orderOrgFiles, not the frozen ORG_FILES list: this loop used to iterate
// the five canonical names, so a sixth structured file added to org/ (the
// seventh file there) never reached the summary — nor any consumer relying on it. Only the
// canonical five count towards onboarding completeness.
for (const f of orderOrgFiles(entries)) {
try {
files[f] = readFileSync(join(dir, f), 'utf8');
if (ORG_FILES.includes(f)) count++;
} catch { } catch {
// Unreadable file — skip; advisory only // Unreadable file — skip; advisory only
} }
} }
}
// Optional free-prose context (C2.2): read it for the ambient summary, but // Optional free-prose context (C2.2): read it for the ambient summary, but
// do NOT count it — it is optional and must not affect onboarding completeness. // do NOT count it — it is optional and must not affect onboarding completeness.
const fp = join(dir, FREE_CONTEXT_FILE); const fp = join(dir, FREE_CONTEXT_FILE);

View file

@ -41,7 +41,28 @@ export const FREE_CONTEXT_FILE = 'free-context.md';
const FREE_CONTEXT_LABEL = 'Fri kontekst'; const FREE_CONTEXT_LABEL = 'Fri kontekst';
const DEFAULT_SUMMARY_CAP = 25; // max summary lines (hook-injection budget) const DEFAULT_SUMMARY_CAP = 25; // max summary lines (hook-injection budget)
const DEFAULT_MAX_VALUE_LEN = 160; // per-field char cap (free-text guard)
// 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). */ /** The user-owned data root: ~/.claude/ms-ai-architect/ (survives reinstall). */
export function resolveUserDataDir(home = homedir()) { export function resolveUserDataDir(home = homedir()) {
@ -117,52 +138,116 @@ function collapseProse(content) {
/** Truncate a field value to maxValueLen, appending an ellipsis when cut. Pure. */ /** Truncate a field value to maxValueLen, appending an ellipsis when cut. Pure. */
function capValue(value, maxValueLen) { function capValue(value, maxValueLen) {
return value.length > maxValueLen return value.length > maxValueLen
? `${value.slice(0, maxValueLen - 1).trimEnd()}` ? `${value.slice(0, Math.max(0, maxValueLen - 1)).trimEnd()}`
: value; : 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. * Build a compact, deterministic org-context summary from org file contents.
* PURE takes a `{ filename: contentString }` map (the caller reads the files) * PURE takes a `{ filename: contentString }` map (the caller reads the files)
* and returns a length-capped `Header: value` block, one field per line, in * and returns a budget-bounded `Header: value` block, one field per line, in
* ORG_FILES order then header order. Empty/garbage input => "". * orderOrgFiles order then header order. Empty/garbage input => "".
* *
* Robust by design: it extracts whatever H2 sections the onboarding agent * Robust by design: it extracts whatever H2 sections the onboarding agent
* wrote rather than hard-coding field names, so header wording can drift * wrote rather than hard-coding field names, so header wording can drift
* without silently emptying the summary. * without silently emptying the summary.
* *
* @param {Record<string, string|null>|null|undefined} orgFiles * @param {Record<string, string|null>|null|undefined} orgFiles
* @param {{cap?: number, maxValueLen?: number}} [opts] * @param {{cap?: number, maxTotalLen?: number}} [opts]
* @returns {string} * @returns {string}
*/ */
export function buildOrgSummary(orgFiles, opts = {}) { export function buildOrgSummary(orgFiles, opts = {}) {
if (!orgFiles || typeof orgFiles !== 'object') return ''; if (!orgFiles || typeof orgFiles !== 'object') return '';
const cap = opts.cap ?? DEFAULT_SUMMARY_CAP; const cap = opts.cap ?? DEFAULT_SUMMARY_CAP;
const maxValueLen = opts.maxValueLen ?? DEFAULT_MAX_VALUE_LEN; 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 = []; const fields = [];
for (const name of ORG_FILES) { for (const name of orderOrgFiles(Object.keys(orgFiles))) {
const content = orgFiles[name]; const content = orgFiles[name];
if (typeof content !== 'string' || !content.trim()) continue; if (typeof content !== 'string' || !content.trim()) continue;
for (const [header, value] of extractSections(content)) { for (const section of extractSections(content)) fields.push(section);
fields.push(`${header}: ${capValue(value, maxValueLen)}`);
}
} }
// Free-prose context (C2.2, #3): the optional sixth file, surfaced LAST as one // Free-prose context (C2.2, #3): the optional free-text file, surfaced LAST as
// "Fri kontekst" field regardless of internal markdown structure, so a plain // one "Fri kontekst" field regardless of internal markdown structure, so a
// paragraph is never silently dropped (acceptance K3). Capped like any field. // plain paragraph is never silently dropped (acceptance K3).
const freeContent = orgFiles[FREE_CONTEXT_FILE]; const freeContent = orgFiles[FREE_CONTEXT_FILE];
if (typeof freeContent === 'string' && freeContent.trim()) { if (typeof freeContent === 'string' && freeContent.trim()) {
const body = collapseProse(freeContent); const body = collapseProse(freeContent);
if (body) fields.push(`${FREE_CONTEXT_LABEL}: ${capValue(body, maxValueLen)}`); if (body) fields.push([FREE_CONTEXT_LABEL, body]);
} }
if (fields.length === 0) return ''; 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. // Budget 1 — lines: over cap, keep (cap-1) fields plus a marker for the rest.
const kept = fields.slice(0, cap - 1); const overflow = fields.length > cap;
kept.push(`… (+${fields.length - kept.length} flere felt)`); const kept = overflow ? fields.slice(0, cap - 1) : fields;
return kept.join('\n');
// 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');
} }

View file

@ -24,6 +24,7 @@ import {
resolveOrgDir, resolveOrgDir,
resolveConfigPath, resolveConfigPath,
buildOrgSummary, buildOrgSummary,
orderOrgFiles,
} from '../../scripts/kb-update/lib/user-data.mjs'; } from '../../scripts/kb-update/lib/user-data.mjs';
// =========================================================================== // ===========================================================================
@ -126,9 +127,9 @@ test('buildOrgSummary — deterministic order follows ORG_FILES regardless of in
); );
}); });
test('buildOrgSummary — unknown filenames are ignored (only ORG_FILES contribute)', () => { test('buildOrgSummary — non-.md keys and non-string values contribute nothing', () => {
const s = buildOrgSummary({ 'random.md': '## Hemmelig\nverdi' }); assert.equal(buildOrgSummary({ 'notater.txt': '## Hemmelig\nverdi' }), '');
assert.equal(s, ''); assert.equal(buildOrgSummary({ 'ekstra.md': 12345 }), '');
}); });
test('buildOrgSummary — skips empty sections (header with no body)', () => { test('buildOrgSummary — skips empty sections (header with no body)', () => {
@ -148,7 +149,7 @@ test('buildOrgSummary — caps a long free-text value with an ellipsis (budget g
const long = 'x'.repeat(500); const long = 'x'.repeat(500);
const s = buildOrgSummary( const s = buildOrgSummary(
{ 'business-references.md': `## Referansearkitektur\n${long}\n` }, { 'business-references.md': `## Referansearkitektur\n${long}\n` },
{ maxValueLen: 80 }, { maxTotalLen: 80 },
); );
const line = s.split('\n').find((l) => l.includes('Referansearkitektur')); const line = s.split('\n').find((l) => l.includes('Referansearkitektur'));
assert.ok(line, 'value line present'); assert.ok(line, 'value line present');
@ -235,7 +236,7 @@ test('buildOrgSummary — free-context appears AFTER the structured fields', ()
}); });
test('buildOrgSummary — long free-context is capped with an ellipsis (budget guard)', () => { test('buildOrgSummary — long free-context is capped with an ellipsis (budget guard)', () => {
const s = buildOrgSummary({ 'free-context.md': 'y'.repeat(500) }, { maxValueLen: 80 }); const s = buildOrgSummary({ 'free-context.md': 'y'.repeat(500) }, { maxTotalLen: 80 });
const line = s.split('\n').find((l) => l.startsWith('Fri kontekst:')); const line = s.split('\n').find((l) => l.startsWith('Fri kontekst:'));
assert.ok(line, 'free-context line present'); assert.ok(line, 'free-context line present');
assert.match(line, /…$/, 'truncation marker appended'); assert.match(line, /…$/, 'truncation marker appended');
@ -263,3 +264,92 @@ test('buildOrgSummary — realistic full onboarding keeps free-context visible (
const s = buildOrgSummary(all); // default cap 25 const s = buildOrgSummary(all); // default cap 25
assert.match(s, /Fri kontekst: Viktig tilleggskontekst/, 'free-context visible within default budget'); assert.match(s, /Fri kontekst: Viktig tilleggskontekst/, 'free-context visible within default budget');
}); });
// ===========================================================================
// (D) Reduksjon — the total value budget replaces the per-field cap, and the
// file set is no longer frozen to ORG_FILES (measured 2026-08-26: a 160-
// char per-field cap threw away 47 % of a 300-char answer and 92 % of a
// 2000-char one while most of the budget stood unspent, and a seventh file
// in org/ never reached the model at all).
// ===========================================================================
test('buildOrgSummary — a long field survives whole while the total budget is unspent', () => {
const long = 'x'.repeat(1500);
const s = buildOrgSummary({
'organization-profile.md': '## Sektor\noffentlig',
'free-context.md': long,
});
assert.ok(s.includes(long), 'a single long answer must survive uncut when the budget allows it');
});
test('buildOrgSummary — unspent budget is redistributed, so every field beats the old 160 cap', () => {
const all = {};
for (const f of ORG_FILES) {
all[f] = Array.from({ length: 4 }, (_, i) => `## ${f}-Felt${i}\n${'x'.repeat(1000)}`).join('\n\n');
}
const s = buildOrgSummary(all); // 20 fields, default budget 4000 => 200 each
const values = s.split('\n').map((l) => l.slice(l.indexOf(': ') + 2));
for (const v of values) {
assert.ok(v.length >= 200, `fair share of 4000 over 20 fields is 200, got ${v.length}`);
}
});
test('buildOrgSummary — the total value budget is enforced across all fields', () => {
const all = {};
for (const f of ORG_FILES) {
all[f] = Array.from({ length: 4 }, (_, i) => `## ${f}-Felt${i}\n${'x'.repeat(5000)}`).join('\n\n');
}
const s = buildOrgSummary(all);
const total = s
.split('\n')
.reduce((sum, l) => sum + l.slice(l.indexOf(': ') + 2).length, 0);
assert.ok(total <= 4000, `total value chars must stay within the 4000 budget, got ${total}`);
});
test('buildOrgSummary — a short field is never padded away from its own length', () => {
const s = buildOrgSummary({
'organization-profile.md': '## Sektor\noffentlig',
'technology-stack.md': `## Stack\n${'x'.repeat(9000)}`,
});
assert.match(s, /^Sektor: offentlig$/m, 'a short field is kept whole, never trimmed to its share');
});
test('buildOrgSummary — maxTotalLen is overridable', () => {
const s = buildOrgSummary({ 'organization-profile.md': `## Sektor\n${'x'.repeat(500)}` }, { maxTotalLen: 50 });
const value = s.slice(s.indexOf(': ') + 2);
assert.equal(value.length, 50, `explicit budget must bind, got ${value.length}`);
});
test('buildOrgSummary — an extra .md file beyond ORG_FILES contributes its sections', () => {
const s = buildOrgSummary({ 'kundeportefolje.md': '## Kunder\nStore offentlige etater' });
assert.match(s, /Kunder: Store offentlige etater/);
});
test('buildOrgSummary — extra files come after ORG_FILES, before free-context, sorted by name', () => {
const s = buildOrgSummary({
'zeta.md': '## Zeta\nz',
'alfa.md': '## Alfa\na',
'organization-profile.md': '## Sektor\noffentlig',
'free-context.md': 'fritekst',
});
assert.deepEqual(
s.split('\n').map((l) => l.slice(0, l.indexOf(':'))),
['Sektor', 'Alfa', 'Zeta', 'Fri kontekst'],
);
});
test('orderOrgFiles — ORG_FILES first in canonical order, then extra .md sorted by name', () => {
assert.deepEqual(
orderOrgFiles(['zeta.md', 'technology-stack.md', 'alfa.md', 'organization-profile.md']),
['organization-profile.md', 'technology-stack.md', 'alfa.md', 'zeta.md'],
);
});
test('orderOrgFiles — free-context is excluded (callers append it last, labelled)', () => {
assert.deepEqual(orderOrgFiles([FREE_CONTEXT_FILE, 'alfa.md']), ['alfa.md']);
});
test('orderOrgFiles — non-.md entries and garbage input are dropped', () => {
assert.deepEqual(orderOrgFiles(['notater.txt', '.DS_Store', 'alfa.md']), ['alfa.md']);
assert.deepEqual(orderOrgFiles(null), []);
});