Spor C / C2.2 (#3, akseptanse K3): fanger et fritt prosa-felt i onboarding og overflater det (kappet) ambient i hver sesjon. - lib/user-data.mjs: FREE_CONTEXT_FILE (utenfor ORG_FILES — valgfri, teller ikke mot fullføring), collapseProse() surfacer fri kontekst som ÉTT felt uansett markdown-struktur (ingen stille drop), capValue() ekstrahert (DRY). - session-start-context.mjs: leser free-context.md valgfritt (ingen count++). - onboarding-agent.md: ny Phase 6 (valgfri); onboard.md: prosess/Task/status. - 11 agenter: uniform 6. bullet (grep 11/11). - Tester FØR kode: user-data 25/25 (+9), test-hooks 11/11 (+1 K3-ambient). Gates: validate 239/0/0 · kb-update 200 · kb-eval 100 · kb-integrity 192/192 (220 baseline-warns) · discovery 13/13 · gitleaks 3 pre-eksisterende. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
168 lines
6.4 KiB
JavaScript
168 lines
6.4 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)
|
|
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<string, string|null>|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');
|
|
}
|