diff --git a/CLAUDE.md b/CLAUDE.md index 06c5427..f888db1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,7 +104,7 @@ Se `references/architecture/recommended-mcp-servers.md` for detaljer. | Event | Script | Formål | |-------|--------|--------| -| SessionStart | `session-start-context.mjs` | Viser aktive utredninger, KB-ferskhet, onboarding-status + AI Act-frister | +| SessionStart | `session-start-context.mjs` | Viser aktive utredninger, KB-ferskhet, onboarding-status, skill- + kurs-signaler (Spor C) + AI Act-frister | | Stop | `stop-assessment-reminder.mjs` | Påminnelse om ucommittede vurderinger, neste steg | > Secrets scanning consolidated to llm-security plugin. diff --git a/docs/development.md b/docs/development.md index 9e4754a..abfbae4 100644 --- a/docs/development.md +++ b/docs/development.md @@ -66,6 +66,27 @@ Legacy (deprecated): bash scripts/kb-staleness-check.sh # mtime-basert, upålitelig etter git clone ``` +### Kurs-deteksjon (Spor C / C3 — Learn Platform API, opt-in) + +Et parallelt, uavhengig spor fra sitemap-discoveren: oppdage at Microsoft har publisert **nye** eller **endrede** treningskurs (modules + learning paths) i produktene KB-en dekker, slik at operatøren kan vurdere om temaet bør dekkes. Et kurs er et *signal*, ikke en doc-side — det auto-ingestes aldri. Full spec: [`c3-course-detection-plan.md`](c3-course-detection-plan.md). + +**To-stegs-mønster (samme invariant som `discover-new-urls`):** + +1. **Deteksjon (Claude-fri, opt-in, Node-script).** `detect-courses.mjs` leser tre Entra-creds fra macOS Keychain, henter token, paginerer Platform API (`/api/v1/modules` + `/learning-paths`) med produkt- og `updatedAt.gt`-filter, diff-er mot `data/course-registry.json` (detektorens egen sporings-state), og skriver **kun** to private filer: `data/course-detection-report.json` (kandidater) + oppdatert `course-registry.json`. Importerer **aldri** `saveDecisions` — skriver hverken `skills/` eller `decisions.json`. + + ```bash + node scripts/kb-update/detect-courses.mjs # default data-dir + node scripts/kb-update/detect-courses.mjs --data-dir DIR # omdiriger IO (tester) + ``` + + Fail-soft: mangler creds → `status:"skipped"` + exit 0; vedvarende nettverksfeil → `status:"error"` + exit 0. Opt-in inni opt-in: steget kjøres kun av scheduleren når `include_course_detection: true` (default `false`) — sett nøkkelen manuelt i `~/.claude/ms-ai-architect/ms-ai-architect.local.md`. + +2. **SessionStart-surfacing (read-only).** `session-start-context.mjs` leser `course-detection-report.json` og viser en one-liner via `summarizeCourses` («Kurs-signaler: N nye / M endrede kurs i dekkede produkter. Kjør /architect:kb-update») — speiler skill-signaler-blokka. `removed` er kun et informasjonssignal i rapporten, aldri en surfacet lead (spec §4.2). Hooken spawner ingenting for kurs-sporet. + +3. **Gate (operatør, Claude-i-loop).** `/architect:kb-update` §3c leser rapporten, presenterer nye/endrede leads, og skriver godkjente leads til den UID-nøklede `courses`-kolleksjonen i `decisions.json` (via `recordCourseLead`). `--dry-run` viser leads uten å skrive. **Strukturell invariant:** ingen apply-path leser `courses` → et godkjent kurs-lead trigger aldri fetch/transform/KB-skriving. + +Slug→skill-mapping er config i `domain-taxonomy.json` (`course_products`), ikke hardkoding. `last_full_enum`-kadens (≥30 d) styrer når `removed` beregnes (kun full-enumerering — aldri inkrementelt). + ### E2E-regresjonstester ```bash # Kjør alle E2E-suiter diff --git a/hooks/scripts/session-start-context.mjs b/hooks/scripts/session-start-context.mjs index 153c007..63f8d9a 100644 --- a/hooks/scripts/session-start-context.mjs +++ b/hooks/scripts/session-start-context.mjs @@ -10,6 +10,7 @@ import { loadScheduleConfig, shouldRunDetection, summarizeSkillLifecycle, + summarizeCourses, } from '../../scripts/kb-update/lib/detection-schedule.mjs'; import { resolveOrgDir, @@ -188,6 +189,20 @@ if (existsSync(skillReportPath)) { } } +// Course-detection signals (Spor C / C3.6) — read-only one-liner; never spawns. +// A lead is a signal that a topic exists, not auto-ingest: surfacing it here +// invites the operator to run /architect:kb-update §3c, which gates the lead +// into the courses ledger. Mirrors the skill-signaler block above. +const courseReportPath = join(pluginRoot, 'scripts', 'kb-update', 'data', 'course-detection-report.json'); +if (existsSync(courseReportPath)) { + try { + const courseSummary = summarizeCourses(JSON.parse(readFileSync(courseReportPath, 'utf8'))); + if (courseSummary) parts.push(`${courseSummary}. Kjør /architect:kb-update`); + } catch { + // Ignore — advisory only + } +} + if (nearestDeadline) { parts.push(`EU AI Act: ${nearestDeadline.daysLeft} dager til ${nearestDeadline.label}. Kjør /architect:classify`); } diff --git a/scripts/kb-update/lib/detection-schedule.mjs b/scripts/kb-update/lib/detection-schedule.mjs index f517485..b1876af 100644 --- a/scripts/kb-update/lib/detection-schedule.mjs +++ b/scripts/kb-update/lib/detection-schedule.mjs @@ -233,3 +233,26 @@ export function summarizeSkillLifecycle(report, opts = {}) { if (stale > 0) parts.push(`${stale} stale`); return parts.length ? `Skill-signaler: ${parts.join(', ')}` : null; } + +/** + * Build a compact one-liner summarizing course-detection leads (Spor C / C3.6) + * for the SessionStart hook. Pure; tolerant of partial/missing reports. Mirrors + * summarizeSkillLifecycle. Surfaces only NEW + UPDATED leads in covered + * products; `removed` is an informational rapport-signal (spec §4.2), never a + * surfaced lead. Returns null when there is nothing worth surfacing (0 new + 0 + * updated, a skipped/error report, or a malformed shape). + * @param {object|null} report parsed course-detection-report.json + * @returns {string|null} + */ +export function summarizeCourses(report) { + if (!report || typeof report !== 'object') return null; + if (report.status && report.status !== 'ok') return null; + const counts = report.counts ?? {}; + const fresh = Number(counts.new) || 0; + const changed = Number(counts.updated) || 0; + + const parts = []; + if (fresh > 0) parts.push(`${fresh} nye`); + if (changed > 0) parts.push(`${changed} endrede`); + return parts.length ? `Kurs-signaler: ${parts.join(' / ')} kurs i dekkede produkter` : null; +} diff --git a/tests/kb-update/test-detection-schedule.test.mjs b/tests/kb-update/test-detection-schedule.test.mjs index 296b2d0..cb2552b 100644 --- a/tests/kb-update/test-detection-schedule.test.mjs +++ b/tests/kb-update/test-detection-schedule.test.mjs @@ -25,6 +25,7 @@ import { shouldRunDetection, selectDetectionSteps, summarizeSkillLifecycle, + summarizeCourses, daysSinceLastPoll, } from '../../scripts/kb-update/lib/detection-schedule.mjs'; import { writeScheduleConfig } from '../../scripts/kb-update/write-schedule-config.mjs'; @@ -369,6 +370,65 @@ test('summarizeSkillLifecycle — tolerates a missing/partial report shape', () assert.equal(summarizeSkillLifecycle(null, { threshold: 7.0 }), null); }); +// =========================================================================== +// (C2) summarizeCourses — one-liner surfaced at session start (Spor C / C3.6) +// =========================================================================== +// Mirrors summarizeSkillLifecycle: a pure one-liner over a parsed +// course-detection-report.json, surfacing NEW + UPDATED course leads in +// covered products. `removed` is an informational rapport-signal (spec §4.2), +// NOT a surfaced lead, so it must never make this return non-null on its own. + +const COURSE_REPORT_OK = { + generated_at: '2026-06-23T12:00:00Z', + status: 'ok', + skipped_reason: null, + new: [{ uid: 'm1' }, { uid: 'm2' }], + updated: [{ uid: 'm3' }], + removed: [], + counts: { new: 2, updated: 1, removed: 0 }, +}; + +test('summarizeCourses — surfaces new + updated leads in the canonical one-liner', () => { + const s = summarizeCourses(COURSE_REPORT_OK); + assert.match(s, /Kurs-signaler:/); + assert.match(s, /2 nye/); + assert.match(s, /1 endrede/); + assert.match(s, /kurs i dekkede produkter/); +}); + +test('summarizeCourses — only-new and only-updated each render alone', () => { + const onlyNew = summarizeCourses({ status: 'ok', counts: { new: 3, updated: 0, removed: 0 } }); + assert.match(onlyNew, /3 nye kurs i dekkede produkter/); + assert.doesNotMatch(onlyNew, /endrede/); + const onlyUpdated = summarizeCourses({ status: 'ok', counts: { new: 0, updated: 4, removed: 0 } }); + assert.match(onlyUpdated, /4 endrede kurs i dekkede produkter/); + assert.doesNotMatch(onlyUpdated, /nye/); +}); + +test('summarizeCourses — zero new + zero updated returns null (nothing to surface)', () => { + assert.equal(summarizeCourses({ status: 'ok', counts: { new: 0, updated: 0, removed: 0 } }), null); +}); + +test('summarizeCourses — removed-only is informational, not a surfaced lead (spec §4.2)', () => { + assert.equal( + summarizeCourses({ status: 'ok', counts: { new: 0, updated: 0, removed: 5 } }), + null, + ); +}); + +test('summarizeCourses — skipped/error reports surface nothing', () => { + const skipped = { status: 'skipped', skipped_reason: 'creds', counts: { new: 0, updated: 0, removed: 0 } }; + const errored = { status: 'error', skipped_reason: 'network', counts: { new: 0, updated: 0, removed: 0 } }; + assert.equal(summarizeCourses(skipped), null); + assert.equal(summarizeCourses(errored), null); +}); + +test('summarizeCourses — tolerates a missing/partial report shape', () => { + assert.equal(summarizeCourses({}, {}), null); + assert.equal(summarizeCourses(null), null); + assert.equal(summarizeCourses({ status: 'ok' }), null); // no counts +}); + // =========================================================================== // (D) STRUCTURAL ToS GUARANTEE — detection is Claude-free // ===========================================================================