feat(ms-ai-architect): C2.1 lib/user-data resolver + ambient org-injeksjon + C1 config bakoverkompat (TDD) [skip-docs]
Bæreren i C2 onboarding-redesign (#26). TDD: test FØR kode. - lib/user-data.mjs (NY, ren, SKRIVER ALDRI; kun node:os/path): resolveUserDataDir/resolveOrgDir/resolveConfigPath peker ~/.claude/ms-ai-architect/ (uavhengig av pluginRoot -> overlever reinstall, K2). CONFIG_FILENAME delt med C1 (en sannhetskilde). buildOrgSummary: deterministisk, lengde-kappet H2-ekstraksjon. - detection-schedule.mjs: loadScheduleConfig(pluginRoot, home=homedir()) leser bruker-sti FORST, fallback plugin-rot. Lokal CONFIG_FILENAME -> importeres fra resolver. Alle 3 kallere bruker ett-arg-kall (uendret oppforsel). - session-start-context.mjs: injiserer buildOrgSummary ambient (K1) som egen Virksomhetskontekst-blokk; status-nudge beholdt nar org tom. Verifisert: user-data 16/16 · detection-schedule 23/23 · test-hooks 10/10 · kb-update 191 · kb-eval 100 · validate PASSED. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4ff60803ce
commit
dc0b627e98
6 changed files with 459 additions and 31 deletions
|
|
@ -8,10 +8,14 @@
|
|||
// manual, in-session, and gated. This module is the pure decision core for the
|
||||
// SessionStart hook + the run-detection.mjs executor.
|
||||
//
|
||||
// Zero dependencies beyond node:fs/path. parseScheduleConfig is pure.
|
||||
// parseScheduleConfig is pure (text in, config out). loadScheduleConfig resolves
|
||||
// paths via lib/user-data.mjs (which itself depends only on node:os/path), so
|
||||
// the user-owned config dir is the single source of truth shared with the hook.
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { resolveConfigPath, CONFIG_FILENAME } from './user-data.mjs';
|
||||
|
||||
// Opt-in: detection is OFF until the user sets enabled:true. "Frivillig å sette
|
||||
// opp — men gjøres det, skal det virke." (operator 2026-06-20)
|
||||
|
|
@ -36,8 +40,6 @@ export const DETECTION_STEPS = Object.freeze([
|
|||
{ name: 'detect-skill-lifecycle', dir: 'kb-eval', script: 'detect-skill-lifecycle.mjs', args: ['--write'], skillLifecycle: true },
|
||||
]);
|
||||
|
||||
const CONFIG_FILENAME = 'ms-ai-architect.local.md';
|
||||
|
||||
/** Coerce a YAML-ish scalar to bool/number/string. Unknown booleans => false. */
|
||||
function coerce(key, raw) {
|
||||
const v = raw.trim();
|
||||
|
|
@ -87,19 +89,26 @@ export function parseScheduleConfig(text) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Load the opt-in schedule config from <pluginRoot>/ms-ai-architect.local.md
|
||||
* (gitignored per the plugin-settings pattern). Absent/unreadable => OFF default.
|
||||
* Load the opt-in schedule config. C2.1: the user-owned path
|
||||
* (~/.claude/ms-ai-architect/ms-ai-architect.local.md) takes precedence so the
|
||||
* config survives a plugin reinstall (acceptance K2); the legacy
|
||||
* <pluginRoot>/ms-ai-architect.local.md remains as a backward-compat fallback
|
||||
* for setups that already wrote it there. Absent/unreadable => OFF default.
|
||||
* @param {string} pluginRoot
|
||||
* @param {string} [home] injectable home dir (defaults to os.homedir())
|
||||
* @returns {{enabled: boolean, interval_days: number, include_skill_lifecycle: boolean}}
|
||||
*/
|
||||
export function loadScheduleConfig(pluginRoot) {
|
||||
const path = join(pluginRoot, CONFIG_FILENAME);
|
||||
if (!existsSync(path)) return { ...DEFAULT_SCHEDULE_CONFIG };
|
||||
try {
|
||||
return parseScheduleConfig(readFileSync(path, 'utf8'));
|
||||
} catch {
|
||||
return { ...DEFAULT_SCHEDULE_CONFIG };
|
||||
export function loadScheduleConfig(pluginRoot, home = homedir()) {
|
||||
const candidates = [resolveConfigPath(home), join(pluginRoot, CONFIG_FILENAME)];
|
||||
for (const path of candidates) {
|
||||
if (!existsSync(path)) continue;
|
||||
try {
|
||||
return parseScheduleConfig(readFileSync(path, 'utf8'));
|
||||
} catch {
|
||||
return { ...DEFAULT_SCHEDULE_CONFIG };
|
||||
}
|
||||
}
|
||||
return { ...DEFAULT_SCHEDULE_CONFIG };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
129
scripts/kb-update/lib/user-data.mjs
Normal file
129
scripts/kb-update/lib/user-data.mjs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// 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',
|
||||
]);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)) {
|
||||
const trimmed =
|
||||
value.length > maxValueLen ? `${value.slice(0, maxValueLen - 1).trimEnd()}…` : value;
|
||||
fields.push(`${header}: ${trimmed}`);
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue