feat(ms-ai-architect): C3.6 — SessionStart-surfacing av kurs-leads (summarizeCourses) → C3 kurs-spor komplett (TDD)

Siste C3-fase. `summarizeCourses(report)` ren one-liner i detection-schedule.mjs
(«Kurs-signaler: N nye / M endrede kurs i dekkede produkter»; null ved
0/skipped/error/removed-only/malformed — removed er informasjons-signal, aldri
surfacet lead, spec §4.2). Speiler summarizeSkillLifecycle.

Wiring i session-start-context.mjs: read-only, leser
scripts/kb-update/data/course-detection-report.json, foreslår /architect:kb-update,
speiler skill-signaler-blokka. Hooken spawner ingenting for kurs-sporet.

docs/development.md: ny C3-workflow-seksjon (to-stegs: Claude-fri deteksjon →
SessionStart-surfacing → operatør-gate §3c). CLAUDE.md hook-tabell oppdatert.

Tester (+6): summarizeCourses — new+updated one-liner, only-new/only-updated,
zero→null, removed-only→null, skipped/error→null, missing/partial→null.
kb-update 307→313, validate 239/0, kb-eval 100/0, hooks 11/11, discovery 13/13.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-23 15:00:40 +02:00
commit f4dd4d3fb6
5 changed files with 120 additions and 1 deletions

View file

@ -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.

View file

@ -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

View file

@ -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`);
}

View file

@ -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;
}

View file

@ -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
// ===========================================================================