diff --git a/docs/superpowers/plans/2026-05-06-meeting-end-auto-import.md b/docs/superpowers/plans/2026-05-06-meeting-end-auto-import.md
new file mode 100644
index 0000000..f5e7a30
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-06-meeting-end-auto-import.md
@@ -0,0 +1,1346 @@
+# Meeting-End Auto-Import Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Automate `/meeting-end` to fetch Teams transcript + Copilot recap via MS Graph, merge with live notes, update meeting note and agenda.
+
+**Architecture:** Stateless Node script `fetch-meeting-artifacts.js` queries Graph for OnlineMeeting → transcript (.vtt) → aiInsights (Copilot recap) → recording URL. Claude in `/meeting-end` consumes JSON output, performs dual-pass synthesis (recap+transcript), merges with live notes, and runs agenda feedback with confidence heuristic.
+
+**Tech Stack:** Node.js (existing), `@azure/msal-node`, `@microsoft/microsoft-graph-client`, `node:test`. Reuses `scripts/lib/o365-calendar.js` auth pattern and `scripts/lib/person-matcher.js` matching pattern.
+
+**Spec:** [[docs/superpowers/specs/2026-05-06-meeting-end-auto-import-design]]
+
+---
+
+## File Structure
+
+**New files:**
+- `scripts/lib/vtt-parser.js` — parse `.vtt` to plain text with speaker tags
+- `scripts/lib/speaker-matcher.js` — match transcript speakers to person notes (wraps person-matcher)
+- `scripts/lib/graph-meetings.js` — Graph helpers for OnlineMeeting / transcripts / aiInsights / recordings
+- `scripts/fetch-meeting-artifacts.js` — CLI orchestrator
+- `scripts/backfill-series-id.js` — one-shot CLI to set `o365_series_id` for given series
+- `scripts/test/vtt-parser.test.js`
+- `scripts/test/speaker-matcher.test.js`
+- `scripts/test/graph-meetings.test.js`
+- `scripts/test/fetch-meeting-artifacts.test.js`
+
+**Modified files:**
+- `.claude/commands/meeting-end.md` — extended workflow with auto-import
+- `scripts/.env.example` — add note about new permissions
+- `03 Bereiche/Jour Fixe/IT Team/Agenda IT Team.md` — add `o365_series_id`
+- `03 Bereiche/Jour Fixe/LANdata/Agenda LANdata.md` — add `o365_series_id`
+
+---
+
+## Task 1: VTT Parser
+
+**Files:**
+- Create: `scripts/lib/vtt-parser.js`
+- Test: `scripts/test/vtt-parser.test.js`
+
+- [ ] **Step 1: Write failing tests**
+
+```javascript
+// scripts/test/vtt-parser.test.js
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+const { parseVtt } = require('../lib/vtt-parser.js');
+
+describe('parseVtt', () => {
+ it('parses single cue with speaker tag', () => {
+ const vtt = `WEBVTT
+
+00:00:01.000 --> 00:00:04.000
+Hallo zusammen.
+`;
+ const result = parseVtt(vtt);
+ assert.deepEqual(result, [
+ { speaker: 'Christian Kauer', text: 'Hallo zusammen.', start: '00:00:01.000', end: '00:00:04.000' }
+ ]);
+ });
+
+ it('merges consecutive cues from same speaker', () => {
+ const vtt = `WEBVTT
+
+00:00:01.000 --> 00:00:04.000
+Wir können das prüfen.
+
+00:00:04.500 --> 00:00:07.000
+Lass uns Borgstedt fragen.
+`;
+ const result = parseVtt(vtt, { mergeConsecutive: true });
+ assert.equal(result.length, 1);
+ assert.equal(result[0].speaker, 'Frank Herberg');
+ assert.equal(result[0].text, 'Wir können das prüfen. Lass uns Borgstedt fragen.');
+ });
+
+ it('handles cues without speaker tag', () => {
+ const vtt = `WEBVTT
+
+00:00:01.000 --> 00:00:02.000
+[Hintergrundgeräusch]
+`;
+ const result = parseVtt(vtt);
+ assert.equal(result[0].speaker, null);
+ assert.equal(result[0].text, '[Hintergrundgeräusch]');
+ });
+
+ it('returns empty array for empty vtt', () => {
+ assert.deepEqual(parseVtt('WEBVTT\n\n'), []);
+ });
+});
+
+describe('formatTranscript', () => {
+ const { formatTranscript } = require('../lib/vtt-parser.js');
+
+ it('formats cues as speaker: text lines', () => {
+ const cues = [
+ { speaker: 'Christian Kauer', text: 'Hallo.' },
+ { speaker: 'Frank Herberg', text: 'Hi.' }
+ ];
+ assert.equal(formatTranscript(cues), 'Christian Kauer: Hallo.\nFrank Herberg: Hi.');
+ });
+
+ it('uses [unknown] for null speaker', () => {
+ const cues = [{ speaker: null, text: '[Lärm]' }];
+ assert.equal(formatTranscript(cues), '[unknown]: [Lärm]');
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+```
+cd scripts && node --test test/vtt-parser.test.js
+```
+Expected: FAIL — `Cannot find module '../lib/vtt-parser.js'`
+
+- [ ] **Step 3: Implement vtt-parser**
+
+```javascript
+// scripts/lib/vtt-parser.js
+function parseVtt(vtt, options = {}) {
+ const { mergeConsecutive = false } = options;
+ const lines = vtt.split(/\r?\n/);
+ const cues = [];
+ let i = 0;
+
+ // skip header
+ while (i < lines.length && !/-->/.test(lines[i])) i++;
+
+ while (i < lines.length) {
+ const timeLine = lines[i];
+ const timeMatch = timeLine.match(/(\d\d:\d\d:\d\d\.\d{3})\s+-->\s+(\d\d:\d\d:\d\d\.\d{3})/);
+ if (!timeMatch) { i++; continue; }
+ const start = timeMatch[1];
+ const end = timeMatch[2];
+ i++;
+
+ const textLines = [];
+ while (i < lines.length && lines[i].trim() !== '' && !/-->/.test(lines[i])) {
+ textLines.push(lines[i]);
+ i++;
+ }
+ const raw = textLines.join(' ').trim();
+ if (!raw) continue;
+
+ const speakerMatch = raw.match(/^]+)>([\s\S]*?)<\/v>$/);
+ if (speakerMatch) {
+ cues.push({ speaker: speakerMatch[1].trim(), text: speakerMatch[2].trim(), start, end });
+ } else {
+ cues.push({ speaker: null, text: raw, start, end });
+ }
+ }
+
+ if (!mergeConsecutive) return cues;
+
+ const merged = [];
+ for (const cue of cues) {
+ const last = merged[merged.length - 1];
+ if (last && last.speaker === cue.speaker) {
+ last.text = `${last.text} ${cue.text}`;
+ last.end = cue.end;
+ } else {
+ merged.push({ ...cue });
+ }
+ }
+ return merged;
+}
+
+function formatTranscript(cues) {
+ return cues.map(c => `${c.speaker || '[unknown]'}: ${c.text}`).join('\n');
+}
+
+module.exports = { parseVtt, formatTranscript };
+```
+
+- [ ] **Step 4: Run tests, verify pass**
+
+```
+cd scripts && node --test test/vtt-parser.test.js
+```
+Expected: all pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add scripts/lib/vtt-parser.js scripts/test/vtt-parser.test.js
+git commit -m "feat(scripts): add vtt parser with speaker tags"
+```
+
+---
+
+## Task 2: Speaker Matcher
+
+**Files:**
+- Create: `scripts/lib/speaker-matcher.js`
+- Test: `scripts/test/speaker-matcher.test.js`
+
+- [ ] **Step 1: Write failing tests**
+
+```javascript
+// scripts/test/speaker-matcher.test.js
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+const { matchSpeakers, replaceSpeakerNames } = require('../lib/speaker-matcher.js');
+
+describe('matchSpeakers', () => {
+ const persons = [
+ { file: 'Christian Kauer (KRAH).md', fm: { vorname: 'Christian', nachname: 'Kauer', email: 'c.kauer@krah-gruppe.de' } },
+ { file: 'Frank Herberg.md', fm: { vorname: 'Frank', nachname: 'Herberg', email: 'f.herberg@krah-gruppe.de' } },
+ { file: 'Stefan Theile-Ochel.md', fm: { vorname: 'Stefan', nachname: 'Theile-Ochel', email: 's.theile-ochel@krah-gruppe.de' } }
+ ];
+
+ it('matches by email when available', () => {
+ const speakers = [{ name: 'Christian Kauer', email: 'c.kauer@krah-gruppe.de' }];
+ const map = matchSpeakers(speakers, persons);
+ assert.equal(map.get('Christian Kauer'), '[[00 Kontext/Personen/Christian Kauer (KRAH)]]');
+ });
+
+ it('matches by full name when email missing', () => {
+ const speakers = [{ name: 'Frank Herberg' }];
+ const map = matchSpeakers(speakers, persons);
+ assert.equal(map.get('Frank Herberg'), '[[00 Kontext/Personen/Frank Herberg]]');
+ });
+
+ it('returns null wikilink for unmatched speaker', () => {
+ const speakers = [{ name: 'Unknown Person' }];
+ const map = matchSpeakers(speakers, persons);
+ assert.equal(map.get('Unknown Person'), null);
+ });
+
+ it('handles multiple speakers in one call', () => {
+ const speakers = [
+ { name: 'Christian Kauer' },
+ { name: 'Frank Herberg' },
+ { name: 'Foo Bar' }
+ ];
+ const map = matchSpeakers(speakers, persons);
+ assert.equal(map.size, 3);
+ assert.ok(map.get('Christian Kauer'));
+ assert.equal(map.get('Foo Bar'), null);
+ });
+});
+
+describe('replaceSpeakerNames', () => {
+ it('replaces speaker names with wikilinks in text', () => {
+ const map = new Map([
+ ['Christian Kauer', '[[00 Kontext/Personen/Christian Kauer (KRAH)]]'],
+ ['Frank Herberg', '[[00 Kontext/Personen/Frank Herberg]]']
+ ]);
+ const text = 'Christian Kauer: Hallo. Frank Herberg: Hi Christian Kauer.';
+ const out = replaceSpeakerNames(text, map);
+ assert.ok(out.includes('[[00 Kontext/Personen/Christian Kauer (KRAH)]]'));
+ assert.ok(out.includes('[[00 Kontext/Personen/Frank Herberg]]'));
+ assert.ok(!out.includes('Christian Kauer:'));
+ });
+
+ it('skips unmatched speakers (null wikilink)', () => {
+ const map = new Map([['Unknown', null]]);
+ const out = replaceSpeakerNames('Unknown: Hello.', map);
+ assert.equal(out, 'Unknown: Hello.');
+ });
+
+ it('does longest-name first to avoid partial overlaps', () => {
+ const map = new Map([
+ ['Christian Kauer', '[[Christian Kauer]]'],
+ ['Christian', '[[Christian Hermann]]']
+ ]);
+ const out = replaceSpeakerNames('Christian Kauer hat gesprochen.', map);
+ assert.ok(out.includes('[[Christian Kauer]] hat gesprochen.'));
+ assert.ok(!out.includes('[[Christian Hermann]]'));
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+```
+cd scripts && node --test test/speaker-matcher.test.js
+```
+Expected: FAIL — module not found.
+
+- [ ] **Step 3: Implement speaker-matcher**
+
+```javascript
+// scripts/lib/speaker-matcher.js
+const { matchAttendeeToPersons, loadPersons } = require('./person-matcher.js');
+
+function matchSpeakers(speakers, personsList) {
+ const persons = personsList || loadPersons();
+ const map = new Map();
+ for (const speaker of speakers) {
+ const attendee = { name: speaker.name, email: speaker.email || '' };
+ const match = matchAttendeeToPersons(attendee, persons);
+ if (match.matched) {
+ const personName = match.file.replace('.md', '');
+ map.set(speaker.name, `[[00 Kontext/Personen/${personName}]]`);
+ } else {
+ map.set(speaker.name, null);
+ }
+ }
+ return map;
+}
+
+function escapeRegex(s) {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function replaceSpeakerNames(text, map) {
+ // sort by name length DESC so "Christian Kauer" replaces before "Christian"
+ const entries = [...map.entries()]
+ .filter(([_, link]) => link !== null)
+ .sort((a, b) => b[0].length - a[0].length);
+
+ let out = text;
+ for (const [name, link] of entries) {
+ const re = new RegExp(escapeRegex(name), 'g');
+ out = out.replace(re, link);
+ }
+ return out;
+}
+
+module.exports = { matchSpeakers, replaceSpeakerNames };
+```
+
+- [ ] **Step 4: Run tests, verify pass**
+
+```
+cd scripts && node --test test/speaker-matcher.test.js
+```
+Expected: all pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add scripts/lib/speaker-matcher.js scripts/test/speaker-matcher.test.js
+git commit -m "feat(scripts): add speaker matcher with wikilink replacement"
+```
+
+---
+
+## Task 3: Graph Meetings Helper — Auth + Resolve OnlineMeeting
+
+**Files:**
+- Create: `scripts/lib/graph-meetings.js`
+- Test: `scripts/test/graph-meetings.test.js`
+
+The Graph client is injectable (so tests can mock it). The auth helper is reused from `o365-calendar.js` pattern.
+
+- [ ] **Step 1: Write failing tests**
+
+```javascript
+// scripts/test/graph-meetings.test.js
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+const {
+ resolveOnlineMeeting,
+ fetchTranscriptVtt,
+ fetchAiInsights,
+ fetchRecordingUrl
+} = require('../lib/graph-meetings.js');
+
+function fakeClient(routes) {
+ return {
+ api(path) {
+ const handler = routes[path];
+ return {
+ query: () => this,
+ select: () => this,
+ get: async () => {
+ if (!handler) throw Object.assign(new Error('Not found'), { statusCode: 404 });
+ if (typeof handler === 'function') return handler();
+ return handler;
+ },
+ getStream: async () => handler?.stream
+ };
+ }
+ };
+}
+
+describe('resolveOnlineMeeting', () => {
+ it('resolves meeting by joinWebUrl', async () => {
+ const client = fakeClient({
+ "/users/u/onlineMeetings": { value: [{ id: 'M1', joinWebUrl: 'https://teams/j' }] }
+ });
+ const m = await resolveOnlineMeeting(client, 'u', 'https://teams/j');
+ assert.equal(m.id, 'M1');
+ });
+
+ it('returns null when no meeting matches', async () => {
+ const client = fakeClient({
+ "/users/u/onlineMeetings": { value: [] }
+ });
+ const m = await resolveOnlineMeeting(client, 'u', 'https://teams/j');
+ assert.equal(m, null);
+ });
+});
+
+describe('fetchTranscriptVtt', () => {
+ it('returns latest transcript content as string', async () => {
+ const client = fakeClient({
+ "/users/u/onlineMeetings/M1/transcripts": {
+ value: [
+ { id: 'T1', createdDateTime: '2026-05-06T09:00:00Z' },
+ { id: 'T2', createdDateTime: '2026-05-06T10:00:00Z' }
+ ]
+ },
+ "/users/u/onlineMeetings/M1/transcripts/T2/content": 'WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nY\n'
+ });
+ const vtt = await fetchTranscriptVtt(client, 'u', 'M1');
+ assert.ok(vtt.startsWith('WEBVTT'));
+ });
+
+ it('returns null when no transcripts exist', async () => {
+ const client = fakeClient({
+ "/users/u/onlineMeetings/M1/transcripts": { value: [] }
+ });
+ const vtt = await fetchTranscriptVtt(client, 'u', 'M1');
+ assert.equal(vtt, null);
+ });
+});
+
+describe('fetchAiInsights', () => {
+ it('returns insights when available', async () => {
+ const client = fakeClient({
+ "/users/u/onlineMeetings/M1/aiInsights": {
+ value: [{ id: 'I1', actionItems: [], meetingNotes: [], mentions: [] }]
+ }
+ });
+ const out = await fetchAiInsights(client, 'u', 'M1');
+ assert.ok(out);
+ assert.equal(out.id, 'I1');
+ });
+
+ it('returns null on 404', async () => {
+ const client = fakeClient({});
+ const out = await fetchAiInsights(client, 'u', 'M1');
+ assert.equal(out, null);
+ });
+});
+
+describe('fetchRecordingUrl', () => {
+ it('returns recordings url metadata when available', async () => {
+ const client = fakeClient({
+ "/users/u/onlineMeetings/M1/recordings": {
+ value: [{ id: 'R1', recordingContentUrl: 'https://teams/play/R1', createdDateTime: '2026-05-06T11:00:00Z' }]
+ }
+ });
+ const out = await fetchRecordingUrl(client, 'u', 'M1');
+ assert.equal(out, 'https://teams/play/R1');
+ });
+
+ it('returns null when no recording', async () => {
+ const client = fakeClient({
+ "/users/u/onlineMeetings/M1/recordings": { value: [] }
+ });
+ const out = await fetchRecordingUrl(client, 'u', 'M1');
+ assert.equal(out, null);
+ });
+
+ it('returns null on permission denied', async () => {
+ const client = fakeClient({});
+ const out = await fetchRecordingUrl(client, 'u', 'M1');
+ assert.equal(out, null);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+```
+cd scripts && node --test test/graph-meetings.test.js
+```
+Expected: FAIL — module not found.
+
+- [ ] **Step 3: Implement graph-meetings**
+
+```javascript
+// scripts/lib/graph-meetings.js
+const { ConfidentialClientApplication } = require('@azure/msal-node');
+const { Client } = require('@microsoft/microsoft-graph-client');
+const { readFileSync } = require('node:fs');
+const { resolve } = require('node:path');
+
+function loadEnv() {
+ const envPath = resolve(__dirname, '..', '.env');
+ const content = readFileSync(envPath, 'utf-8');
+ const vars = {};
+ for (const line of content.split('\n')) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('#')) continue;
+ const [key, ...rest] = trimmed.split('=');
+ vars[key.trim()] = rest.join('=').trim();
+ }
+ return vars;
+}
+
+async function buildGraphClient(env = loadEnv()) {
+ const cca = new ConfidentialClientApplication({
+ auth: {
+ clientId: env.AZURE_CLIENT_ID,
+ clientSecret: env.AZURE_CLIENT_SECRET,
+ authority: `https://login.microsoftonline.com/${env.AZURE_TENANT_ID}`
+ }
+ });
+ const tokenResponse = await cca.acquireTokenByClientCredential({
+ scopes: ['https://graph.microsoft.com/.default']
+ });
+ return Client.init({
+ authProvider: (done) => done(null, tokenResponse.accessToken),
+ defaultVersion: 'v1.0'
+ });
+}
+
+async function safeGet(client, path, options = {}) {
+ try {
+ let req = client.api(path);
+ if (options.version) req = req.version(options.version);
+ if (options.query) req = req.query(options.query);
+ return await req.get();
+ } catch (err) {
+ if (err.statusCode === 404 || err.statusCode === 403) return null;
+ throw err;
+ }
+}
+
+async function resolveOnlineMeeting(client, userId, joinWebUrl) {
+ const path = `/users/${userId}/onlineMeetings`;
+ let req = client.api(path);
+ if (typeof req.query === 'function') {
+ req = req.query({ $filter: `JoinWebUrl eq '${joinWebUrl}'` });
+ }
+ const resp = await req.get();
+ const list = resp?.value || [];
+ return list[0] || null;
+}
+
+async function fetchTranscriptVtt(client, userId, meetingId) {
+ const list = await safeGet(client, `/users/${userId}/onlineMeetings/${meetingId}/transcripts`);
+ const items = list?.value || [];
+ if (items.length === 0) return null;
+ const latest = [...items].sort((a, b) =>
+ new Date(b.createdDateTime) - new Date(a.createdDateTime)
+ )[0];
+ const contentPath = `/users/${userId}/onlineMeetings/${meetingId}/transcripts/${latest.id}/content`;
+ return await safeGet(client, contentPath);
+}
+
+async function fetchAiInsights(client, userId, meetingId) {
+ // beta endpoint
+ const path = `/users/${userId}/onlineMeetings/${meetingId}/aiInsights`;
+ const resp = await safeGet(client, path, { version: 'beta' });
+ const items = resp?.value || [];
+ return items[0] || null;
+}
+
+async function fetchRecordingUrl(client, userId, meetingId) {
+ const list = await safeGet(client, `/users/${userId}/onlineMeetings/${meetingId}/recordings`);
+ const items = list?.value || [];
+ if (items.length === 0) return null;
+ const latest = [...items].sort((a, b) =>
+ new Date(b.createdDateTime) - new Date(a.createdDateTime)
+ )[0];
+ return latest.recordingContentUrl || latest.contentCorrelationId || null;
+}
+
+module.exports = {
+ loadEnv,
+ buildGraphClient,
+ resolveOnlineMeeting,
+ fetchTranscriptVtt,
+ fetchAiInsights,
+ fetchRecordingUrl
+};
+```
+
+- [ ] **Step 4: Run tests, verify pass**
+
+```
+cd scripts && node --test test/graph-meetings.test.js
+```
+Expected: all pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add scripts/lib/graph-meetings.js scripts/test/graph-meetings.test.js
+git commit -m "feat(scripts): add graph meetings helper with safe fallbacks"
+```
+
+---
+
+## Task 4: Calendar Event Fetcher — get event by id
+
+**Files:**
+- Modify: `scripts/lib/o365-calendar.js`
+- Test: `scripts/test/o365-calendar.test.js`
+
+Add ability to fetch a single event by its `o365_id` (we need its `joinWebUrl`).
+
+- [ ] **Step 1: Write failing test**
+
+Append to `scripts/test/o365-calendar.test.js`:
+
+```javascript
+const { extractJoinUrlFromBody } = require('../lib/o365-calendar.js');
+
+describe('extractJoinUrlFromBody', () => {
+ it('extracts teams meeting join URL from body html', () => {
+ const html = 'Join';
+ assert.equal(
+ extractJoinUrlFromBody(html),
+ 'https://teams.microsoft.com/l/meetup-join/19%3aabc/0?context=foo'
+ );
+ });
+
+ it('returns null when no teams url present', () => {
+ assert.equal(extractJoinUrlFromBody('nothing
'), null);
+ });
+});
+```
+
+- [ ] **Step 2: Run test, verify fail**
+
+```
+cd scripts && node --test test/o365-calendar.test.js
+```
+Expected: FAIL — `extractJoinUrlFromBody is not a function`.
+
+- [ ] **Step 3: Implement helper**
+
+Add to `scripts/lib/o365-calendar.js` (before `module.exports`):
+
+```javascript
+function extractJoinUrlFromBody(body) {
+ if (!body) return null;
+ const m = body.match(/https:\/\/teams\.microsoft\.com\/l\/meetup-join\/[^\s"'<>]+/);
+ return m ? m[0] : null;
+}
+
+async function getEventById(eventId) {
+ const env = loadEnv();
+ const cca = new ConfidentialClientApplication({
+ auth: {
+ clientId: env.AZURE_CLIENT_ID,
+ clientSecret: env.AZURE_CLIENT_SECRET,
+ authority: `https://login.microsoftonline.com/${env.AZURE_TENANT_ID}`
+ }
+ });
+ const tokenResponse = await cca.acquireTokenByClientCredential({
+ scopes: ['https://graph.microsoft.com/.default']
+ });
+ const client = Client.init({
+ authProvider: (done) => done(null, tokenResponse.accessToken)
+ });
+
+ const event = await client
+ .api(`/users/${env.AZURE_USER_EMAIL}/events/${eventId}`)
+ .select('id,subject,start,end,body,attendees,seriesMasterId,onlineMeeting')
+ .get();
+ return event;
+}
+```
+
+Update `module.exports`:
+
+```javascript
+module.exports = {
+ isRecurring, parseEventToMeeting, formatEventChoice,
+ getCalendarEvents, getEventById, extractJoinUrlFromBody
+};
+```
+
+- [ ] **Step 4: Run tests, verify pass**
+
+```
+cd scripts && node --test test/o365-calendar.test.js
+```
+Expected: all pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add scripts/lib/o365-calendar.js scripts/test/o365-calendar.test.js
+git commit -m "feat(scripts): add getEventById and join-url extractor"
+```
+
+---
+
+## Task 5: CLI Orchestrator `fetch-meeting-artifacts.js`
+
+**Files:**
+- Create: `scripts/fetch-meeting-artifacts.js`
+- Test: `scripts/test/fetch-meeting-artifacts.test.js`
+
+The CLI:
+- Accepts `--o365-id ` (preferred) or `--join-url `
+- Calls `getEventById` → extracts joinUrl → `resolveOnlineMeeting` → `fetchTranscriptVtt` + `fetchAiInsights` + `fetchRecordingUrl`
+- Output JSON to stdout
+- `--out ` to write file instead
+
+Logic is split into a pure `runFetch(client, userId, opts)` for unit tests, plus a thin CLI wrapper.
+
+- [ ] **Step 1: Write failing test**
+
+```javascript
+// scripts/test/fetch-meeting-artifacts.test.js
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+const { runFetch } = require('../fetch-meeting-artifacts.js');
+
+function makeClient(map) {
+ return {
+ api(path) {
+ const h = map[path];
+ return {
+ query: () => this,
+ select: () => this,
+ version: () => this,
+ get: async () => {
+ if (h === undefined) throw Object.assign(new Error('nf'), { statusCode: 404 });
+ return typeof h === 'function' ? h() : h;
+ }
+ };
+ }
+ };
+}
+
+describe('runFetch', () => {
+ it('returns full artifact bundle for happy path', async () => {
+ const userId = 'u@krah.de';
+ const client = makeClient({
+ [`/users/${userId}/events/E1`]: {
+ id: 'E1', subject: 'Jour Fixe IT Team', seriesMasterId: 'S1',
+ start: { dateTime: '2026-05-06T09:00:00.0000000', timeZone: 'UTC' },
+ end: { dateTime: '2026-05-06T10:00:00.0000000', timeZone: 'UTC' },
+ attendees: [{ emailAddress: { name: 'Christian Kauer', address: 'c.kauer@krah-gruppe.de' } }],
+ body: { contentType: 'html', content: 'Join' }
+ },
+ [`/users/${userId}/onlineMeetings`]: { value: [{ id: 'M1', joinWebUrl: 'https://teams.microsoft.com/l/meetup-join/19%3aXYZ/0' }] },
+ [`/users/${userId}/onlineMeetings/M1/transcripts`]: { value: [{ id: 'T1', createdDateTime: '2026-05-06T10:01:00Z' }] },
+ [`/users/${userId}/onlineMeetings/M1/transcripts/T1/content`]: 'WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nHallo.\n',
+ [`/users/${userId}/onlineMeetings/M1/aiInsights`]: { value: [{ id: 'I1', meetingNotes: [{ title: 'Topic', text: 'Notiz' }] }] },
+ [`/users/${userId}/onlineMeetings/M1/recordings`]: { value: [{ id: 'R1', recordingContentUrl: 'https://teams/play/R1', createdDateTime: '2026-05-06T10:30:00Z' }] }
+ });
+
+ const result = await runFetch(client, userId, { o365Id: 'E1' });
+
+ assert.equal(result.meeting.id, 'E1');
+ assert.equal(result.meeting.seriesMasterId, 'S1');
+ assert.equal(result.meeting.onlineMeetingId, 'M1');
+ assert.ok(result.transcript.includes('Christian Kauer: Hallo.'));
+ assert.equal(result.recap.id, 'I1');
+ assert.equal(result.recordingUrl, 'https://teams/play/R1');
+ });
+
+ it('handles missing transcript gracefully', async () => {
+ const userId = 'u@krah.de';
+ const client = makeClient({
+ [`/users/${userId}/events/E1`]: {
+ id: 'E1', subject: 'X', seriesMasterId: null,
+ start: { dateTime: '2026-05-06T09:00:00.0000000' },
+ end: { dateTime: '2026-05-06T10:00:00.0000000' },
+ attendees: [],
+ body: { contentType: 'html', content: 'J' }
+ },
+ [`/users/${userId}/onlineMeetings`]: { value: [{ id: 'M1', joinWebUrl: 'https://teams.microsoft.com/l/meetup-join/19%3aabc/0' }] },
+ [`/users/${userId}/onlineMeetings/M1/transcripts`]: { value: [] },
+ [`/users/${userId}/onlineMeetings/M1/recordings`]: { value: [] }
+ });
+ const result = await runFetch(client, userId, { o365Id: 'E1' });
+ assert.equal(result.transcript, null);
+ assert.equal(result.recap, null);
+ assert.equal(result.recordingUrl, null);
+ });
+
+ it('returns error info when event has no teams join url', async () => {
+ const userId = 'u@krah.de';
+ const client = makeClient({
+ [`/users/${userId}/events/E1`]: {
+ id: 'E1', subject: 'X', seriesMasterId: null,
+ start: { dateTime: '2026-05-06T09:00:00.0000000' },
+ end: { dateTime: '2026-05-06T10:00:00.0000000' },
+ attendees: [],
+ body: { contentType: 'text', content: 'no join url here' }
+ }
+ });
+ const result = await runFetch(client, userId, { o365Id: 'E1' });
+ assert.equal(result.transcript, null);
+ assert.equal(result.recap, null);
+ assert.ok(result.warnings.some(w => /join url/i.test(w)));
+ });
+});
+```
+
+- [ ] **Step 2: Run test, verify fail**
+
+```
+cd scripts && node --test test/fetch-meeting-artifacts.test.js
+```
+Expected: FAIL — module not found.
+
+- [ ] **Step 3: Implement orchestrator**
+
+```javascript
+// scripts/fetch-meeting-artifacts.js
+const { writeFileSync } = require('node:fs');
+const {
+ loadEnv,
+ buildGraphClient,
+ resolveOnlineMeeting,
+ fetchTranscriptVtt,
+ fetchAiInsights,
+ fetchRecordingUrl
+} = require('./lib/graph-meetings.js');
+const { extractJoinUrlFromBody } = require('./lib/o365-calendar.js');
+const { parseVtt, formatTranscript } = require('./lib/vtt-parser.js');
+
+function parseArgs(argv) {
+ const out = {};
+ for (let i = 2; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === '--o365-id') out.o365Id = argv[++i];
+ else if (a === '--join-url') out.joinUrl = argv[++i];
+ else if (a === '--out') out.out = argv[++i];
+ else if (a === '--user') out.user = argv[++i];
+ }
+ return out;
+}
+
+async function runFetch(client, userId, opts) {
+ const warnings = [];
+ let event = null;
+ let joinUrl = opts.joinUrl || null;
+ let seriesMasterId = null;
+
+ if (opts.o365Id) {
+ event = await client.api(`/users/${userId}/events/${opts.o365Id}`)
+ .select('id,subject,start,end,body,attendees,seriesMasterId,onlineMeeting')
+ .get();
+ seriesMasterId = event.seriesMasterId || null;
+ if (!joinUrl) {
+ joinUrl = event.onlineMeeting?.joinUrl
+ || extractJoinUrlFromBody(event.body?.content || '');
+ }
+ }
+
+ if (!joinUrl) {
+ warnings.push('no teams join url on event');
+ return {
+ meeting: event ? eventSummary(event) : null,
+ transcript: null, recap: null, recordingUrl: null, warnings
+ };
+ }
+
+ const om = await resolveOnlineMeeting(client, userId, joinUrl);
+ if (!om) {
+ warnings.push(`onlineMeeting not found for joinUrl=${joinUrl}`);
+ return {
+ meeting: event ? eventSummary(event) : null,
+ transcript: null, recap: null, recordingUrl: null, warnings
+ };
+ }
+
+ const [vtt, recap, recordingUrl] = await Promise.all([
+ fetchTranscriptVtt(client, userId, om.id).catch(e => { warnings.push(`transcript: ${e.message}`); return null; }),
+ fetchAiInsights(client, userId, om.id).catch(e => { warnings.push(`aiInsights: ${e.message}`); return null; }),
+ fetchRecordingUrl(client, userId, om.id).catch(e => { warnings.push(`recording: ${e.message}`); return null; })
+ ]);
+
+ let transcript = null;
+ if (vtt) {
+ const cues = parseVtt(vtt, { mergeConsecutive: true });
+ transcript = formatTranscript(cues);
+ }
+
+ return {
+ meeting: event ? { ...eventSummary(event), onlineMeetingId: om.id, seriesMasterId } : { onlineMeetingId: om.id },
+ transcript,
+ recap,
+ recordingUrl,
+ warnings
+ };
+}
+
+function eventSummary(event) {
+ return {
+ id: event.id,
+ subject: event.subject,
+ start: event.start?.dateTime,
+ end: event.end?.dateTime,
+ seriesMasterId: event.seriesMasterId || null,
+ attendees: (event.attendees || []).map(a => ({
+ name: a.emailAddress?.name,
+ email: (a.emailAddress?.address || '').toLowerCase()
+ }))
+ };
+}
+
+async function main() {
+ const opts = parseArgs(process.argv);
+ if (!opts.o365Id && !opts.joinUrl) {
+ console.error('Usage: node fetch-meeting-artifacts.js --o365-id [--out file.json]');
+ process.exit(2);
+ }
+ const env = loadEnv();
+ const userId = opts.user || env.AZURE_USER_EMAIL;
+ const client = await buildGraphClient(env);
+ const result = await runFetch(client, userId, opts);
+ const json = JSON.stringify(result, null, 2);
+ if (opts.out) writeFileSync(opts.out, json, 'utf-8');
+ else process.stdout.write(json + '\n');
+}
+
+if (require.main === module) {
+ main().catch(err => { console.error(err.stack || err.message); process.exit(1); });
+}
+
+module.exports = { runFetch, parseArgs };
+```
+
+- [ ] **Step 4: Run tests, verify pass**
+
+```
+cd scripts && node --test test/fetch-meeting-artifacts.test.js
+```
+Expected: all pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add scripts/fetch-meeting-artifacts.js scripts/test/fetch-meeting-artifacts.test.js
+git commit -m "feat(scripts): add fetch-meeting-artifacts orchestrator cli"
+```
+
+---
+
+## Task 6: Backfill `o365_series_id` for IT Team and LANdata
+
+**Files:**
+- Create: `scripts/backfill-series-id.js`
+- Test: `scripts/test/backfill-series-id.test.js`
+- Modify: `03 Bereiche/Jour Fixe/IT Team/Agenda IT Team.md`
+- Modify: `03 Bereiche/Jour Fixe/LANdata/Agenda LANdata.md`
+
+The script reads a future occurrence of a recurring meeting in the calendar (matched by subject regex), extracts `seriesMasterId`, and writes it into the named Agenda file's frontmatter.
+
+- [ ] **Step 1: Write failing test for frontmatter writer**
+
+```javascript
+// scripts/test/backfill-series-id.test.js
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+const { upsertFrontmatterField } = require('../backfill-series-id.js');
+
+describe('upsertFrontmatterField', () => {
+ it('inserts new field when missing', () => {
+ const md = `---\ntags: [jour-fixe]\nserie: IT Team\nrhythmus: wöchentlich\n---\n\n# Agenda\n`;
+ const out = upsertFrontmatterField(md, 'o365_series_id', 'AAMkSeries123');
+ assert.ok(out.includes('o365_series_id: AAMkSeries123'));
+ assert.ok(out.includes('serie: IT Team'));
+ assert.ok(out.endsWith('# Agenda\n'));
+ });
+
+ it('replaces existing field value', () => {
+ const md = `---\nserie: X\no365_series_id: OLD\n---\n\nbody\n`;
+ const out = upsertFrontmatterField(md, 'o365_series_id', 'NEW');
+ assert.ok(out.includes('o365_series_id: NEW'));
+ assert.ok(!out.includes('OLD'));
+ });
+
+ it('returns content unchanged when no frontmatter present', () => {
+ const md = `# No frontmatter\n`;
+ const out = upsertFrontmatterField(md, 'o365_series_id', 'X');
+ assert.equal(out, md);
+ });
+});
+```
+
+- [ ] **Step 2: Run test, verify fail**
+
+```
+cd scripts && node --test test/backfill-series-id.test.js
+```
+Expected: FAIL — module not found.
+
+- [ ] **Step 3: Implement script**
+
+```javascript
+// scripts/backfill-series-id.js
+const { readFileSync, writeFileSync } = require('node:fs');
+const { resolve } = require('node:path');
+const { getCalendarEvents } = require('./lib/o365-calendar.js');
+
+function upsertFrontmatterField(md, key, value) {
+ const fmMatch = md.match(/^---\n([\s\S]*?)\n---/);
+ if (!fmMatch) return md;
+ const fm = fmMatch[1];
+ const lineRegex = new RegExp(`^${key}:.*$`, 'm');
+ let newFm;
+ if (lineRegex.test(fm)) {
+ newFm = fm.replace(lineRegex, `${key}: ${value}`);
+ } else {
+ newFm = `${fm}\n${key}: ${value}`;
+ }
+ return md.replace(/^---\n[\s\S]*?\n---/, `---\n${newFm}\n---`);
+}
+
+async function backfill(agendaPath, titleRegex, daysAhead = 30) {
+ const events = await getCalendarEvents(daysAhead);
+ const re = new RegExp(titleRegex, 'i');
+ const match = events.find(e => re.test(e.title) && e.isRecurring);
+ if (!match) {
+ throw new Error(`no recurring event matching /${titleRegex}/ in next ${daysAhead} days`);
+ }
+ // Need raw seriesMasterId — re-fetch single event by id (parseEventToMeeting drops it)
+ const { getEventById } = require('./lib/o365-calendar.js');
+ const raw = await getEventById(match.id);
+ const seriesId = raw.seriesMasterId;
+ if (!seriesId) {
+ throw new Error(`event ${match.id} has no seriesMasterId`);
+ }
+ const md = readFileSync(agendaPath, 'utf-8');
+ const updated = upsertFrontmatterField(md, 'o365_series_id', seriesId);
+ writeFileSync(agendaPath, updated, 'utf-8');
+ return seriesId;
+}
+
+async function main() {
+ const args = process.argv.slice(2);
+ if (args.length < 2) {
+ console.error('Usage: node backfill-series-id.js ');
+ process.exit(2);
+ }
+ const [agendaPath, titleRegex] = args;
+ const id = await backfill(resolve(agendaPath), titleRegex);
+ console.log(`set o365_series_id=${id} in ${agendaPath}`);
+}
+
+if (require.main === module) {
+ main().catch(e => { console.error(e.stack || e.message); process.exit(1); });
+}
+
+module.exports = { upsertFrontmatterField, backfill };
+```
+
+- [ ] **Step 4: Run tests, verify pass**
+
+```
+cd scripts && node --test test/backfill-series-id.test.js
+```
+Expected: all pass.
+
+- [ ] **Step 5: Run backfill against real Calendar (manual integration step)**
+
+```bash
+cd scripts
+node backfill-series-id.js "../03 Bereiche/Jour Fixe/IT Team/Agenda IT Team.md" "Jour\\s*Fixe.*IT\\s*Team"
+node backfill-series-id.js "../03 Bereiche/Jour Fixe/LANdata/Agenda LANdata.md" "Jour\\s*Fixe.*LANdata"
+```
+
+Expected: each prints `set o365_series_id=AAMk... in ...`. If a run fails (no future occurrence found), increase days-ahead by editing the script call to include a third arg, or fall back to lazy backfill on the next `/meeting`.
+
+- [ ] **Step 6: Verify frontmatter changes**
+
+```bash
+git -C "D:/projects/chrka/brain" diff -- "03 Bereiche/Jour Fixe/IT Team/Agenda IT Team.md" "03 Bereiche/Jour Fixe/LANdata/Agenda LANdata.md"
+```
+Expected: each agenda gains `o365_series_id: AAMk...` line.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add scripts/backfill-series-id.js scripts/test/backfill-series-id.test.js \
+ "03 Bereiche/Jour Fixe/IT Team/Agenda IT Team.md" \
+ "03 Bereiche/Jour Fixe/LANdata/Agenda LANdata.md"
+git commit -m "feat(jour-fixe): add backfill script and seed o365_series_id for IT Team/LANdata"
+```
+
+---
+
+## Task 7: Lazy Backfill on `/meeting`
+
+**Files:**
+- Modify: `.claude/commands/meeting.md` (or `.claude/skills/jour-fixe.md` — wherever `/meeting` is defined)
+
+For series other than IT Team/LANdata, on first `/meeting` we want Claude to write `o365_series_id` to the Agenda automatically when missing.
+
+- [ ] **Step 1: Locate the meeting command**
+
+```bash
+ls -la "D:/projects/chrka/brain/.claude/commands/"
+```
+Expected: `meeting.md`, `meeting-end.md`, `meeting-manual.md` listed.
+
+- [ ] **Step 2: Inspect current `/meeting` instructions**
+
+Read `.claude/commands/meeting.md`. Identify the step where the agenda file is read.
+
+- [ ] **Step 3: Add lazy-backfill instruction**
+
+After the step that reads the agenda, insert an instruction (in German, matching the file's existing language) like:
+
+```markdown
+6a. **Falls die Agenda kein `o365_series_id` im Frontmatter hat** und der gewählte Termin eine Serie ist (`isRecurring: true` und `seriesMasterId` vorhanden):
+ - Trage `o365_series_id: ` ins Frontmatter der Agenda ein (zwischen `serie:` und `rhythmus:`)
+ - Bestätige im Bericht: "o365_series_id für Serie [Name] eingetragen."
+```
+
+(Adapt heading number to the actual flow.)
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add ".claude/commands/meeting.md"
+git commit -m "feat(jour-fixe): lazy-backfill o365_series_id on /meeting"
+```
+
+---
+
+## Task 8: Rewrite `/meeting-end` Command
+
+**Files:**
+- Modify: `.claude/commands/meeting-end.md`
+
+Replace existing content with the auto-import flow.
+
+- [ ] **Step 1: Replace command file**
+
+Write the following exactly to `.claude/commands/meeting-end.md`:
+
+```markdown
+Schließe ein Jour-Fixe-Meeting ab, ziehe automatisch Recap+Transkript aus Teams und führe die Ergebnisse zurück.
+
+## Schritt 1 — Meeting-Notiz identifizieren
+
+- Wenn `current_note` eine Datei unter `03 Bereiche/Meetings/` ist → diese verwenden.
+- Sonst: Serie und Datum aus `$ARGUMENTS` ermitteln. Pfad: `03 Bereiche/Meetings/YYYY-MM-DD .md`.
+- Lies das Frontmatter: `o365_id`, `serie`, `date`.
+- Wenn `o365_id` leer ist: User fragen, ob Auto-Import übersprungen werden soll (dann weiter bei Schritt 4).
+
+## Schritt 2 — Artifacts holen
+
+Führe aus:
+```
+cd scripts
+node fetch-meeting-artifacts.js --o365-id --out /tmp/artifacts.json
+```
+
+Lies das JSON. Erwartete Felder:
+- `meeting.seriesMasterId`
+- `transcript` (string oder null)
+- `recap` (object mit `meetingNotes`, `actionItems`, `mentions` — oder null)
+- `recordingUrl` (string oder null)
+- `warnings` (Liste)
+
+Wenn `transcript === null` und `recap === null`: User fragen, ob er den Recap aus Teams einfügen will, oder ob das Meeting ohne Auto-Import abgeschlossen werden soll.
+
+## Schritt 3 — Sprecher-Mapping
+
+Sammle alle Sprecher-Namen aus `transcript` (Zeilenanfang vor `:`).
+Rufe `scripts/lib/speaker-matcher.js` (matchSpeakers) konzeptionell an: pro Name → Wikilink oder null.
+Notiere ungematchte Sprecher als offene Punkte am Ende des Berichts.
+
+## Schritt 4 — Doppel-Pass-Synthese
+
+Lies die existierende Meeting-Notiz vollständig (Live-Notizen!).
+
+Doppel-Pass:
+- **Aus Recap** (sofern vorhanden): Topics, Entscheidungen, offene Fragen, Tasks extrahieren.
+- **Aus Transkript**: pro Recap-Aussage validieren; ergänze Details, die mehrfach im Transkript bestätigt sind aber im Recap fehlen. Sprecher als Wikilinks ersetzen.
+
+Schreibe Ergebnis in die Meeting-Notiz:
+- `## Notizen` (gegliedert nach Topics, mit Wikilinks zu Sprechern)
+- `## Entscheidungen`
+- `## Offene Fragen`
+- `## Aufgaben` (Owner als Wikilinks)
+
+**Merge-Regel mit Live-Notizen:**
+- Live-Eintrag mit gleichem Aktor + gleicher Kernaussage → Recap-Eintrag SKIP.
+- Live-Eintrag stub (nur Stichwort) → durch Recap-Detail ergänzen.
+- Recap-Eintrag neu → unten anhängen.
+- Reihenfolge: Live-Order bleibt, neue Items hinten.
+
+Wenn `recordingUrl` vorhanden: füge ins Frontmatter `recording_url: ` ein (oder ersetze existierenden Wert).
+
+## Schritt 5 — Agenda-Rückführung mit Konfidenz
+
+Bestimme die zugehörige Agenda:
+- Aus Frontmatter `serie:` der Meeting-Notiz.
+- Pfad: `03 Bereiche/Jour Fixe//Agenda*.md`.
+- Verifiziere: `o365_series_id` der Agenda matched `meeting.seriesMasterId`. Mismatch → User warnen.
+
+Pro Topic der Meeting-Notiz (H3-Überschriften unter `## 📌 Dauerläufer`, `## 🆕 Neu`, `## ⏸️ Postponed`):
+
+| Signal in Notiz/Recap/Transkript | Konfidenz | Aktion |
+|---|---|---|
+| `## ⏸️ Postponed` enthält dieses Topic | HOCH | postpone → in Agenda `## ⏸️ Postponed` verschieben |
+| Topic-Status aus Notiz oder Recap: "erledigt"/"abgeschlossen"/"✅" + alle Tasks done | HOCH | archivieren (mit ganzer Historie) |
+| Topic erwähnt, kein Abschluss-Marker | HOCH | Historie-Eintrag in Agenda: `**YYYY-MM-DD:** Kurzfassung → [[Meeting-Notiz]]` |
+| Topic in Agenda, aber nicht im Notiz-Block | HOCH | unverändert |
+| Mehrdeutig | NIEDRIG | zur Bestätigung listen |
+
+Sammel-Bericht VOR Schreiben:
+```
+Vorgesehene Aktionen:
+ - 5 Topics: Historie-Eintrag (auto)
+ - 2 Topics: archivieren (auto): X, Y
+ - 1 Topic: postponed (auto): Z
+
+Bestätigung nötig:
+ - "NAC-Projekt": Recap mehrdeutig — erledigt oder offen?
+
+Ungematchte Sprecher (Personen-Notiz fehlt): Maria Schmidt
+```
+
+User bestätigt einmal für HOCH-Aktionen, entscheidet pro NIEDRIG-Topic. Dann ausführen.
+
+## Schritt 6 — Neue Topics nach Dauerläufer
+
+Topics, die in `## 🆕 Neu` der Meeting-Notiz standen und nicht archiviert oder postponed wurden, in der Agenda nach `## 📌 Dauerläufer` verschieben (🆕-Emoji aus Titel entfernen).
+
+## Schritt 7 — Status
+
+In der Meeting-Notiz: `status: abgeschlossen` setzen.
+
+## Schritt 8 — Bericht
+
+Gib eine Zusammenfassung aus:
+- Recap aus Graph: ja/nein
+- Transkript: ja/nein
+- Recording-Link: ja/nein
+- X Topics aktualisiert, Y archiviert, Z postponed, W neu nach Dauerläufer
+- Ungematchte Sprecher (falls vorhanden): Hinweis auf fehlende Personen-Notizen
+- Warnings aus dem Fetch-Script (falls vorhanden)
+```
+
+- [ ] **Step 2: Verify file**
+
+```bash
+cat ".claude/commands/meeting-end.md"
+```
+Expected: new content above, no remnants of old version.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add ".claude/commands/meeting-end.md"
+git commit -m "feat(meetings): rewrite /meeting-end with graph auto-import"
+```
+
+---
+
+## Task 9: Update `.env.example` and Add Permissions Note
+
+**Files:**
+- Modify: `scripts/.env.example`
+
+- [ ] **Step 1: Append permission notes**
+
+Replace `scripts/.env.example` with:
+
+```
+# Azure AD App Registration (Application / Client Credentials flow)
+AZURE_TENANT_ID=your-tenant-id
+AZURE_CLIENT_ID=your-client-id
+AZURE_CLIENT_SECRET=your-client-secret
+# User whose calendar/meetings to read
+AZURE_USER_EMAIL=your-email@company.com
+
+# Required Graph application permissions (admin-consent):
+# Calendars.Read
+# OnlineMeetings.Read.All (for transcript/recap/recording access)
+# OnlineMeetingTranscript.Read.All
+# OnlineMeetingArtifact.Read.All
+#
+# In addition, an Application Access Policy must grant the app access to
+# AZURE_USER_EMAIL's online meetings:
+# New-CsApplicationAccessPolicy -Identity "obsidian-graph" -AppIds "" -Description "..."
+# Grant-CsApplicationAccessPolicy -PolicyName "obsidian-graph" -Identity ""
+#
+# Beta endpoint (aiInsights) requires Teams Premium / Copilot license on the tenant.
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add scripts/.env.example
+git commit -m "docs(scripts): document graph permissions for online meetings"
+```
+
+---
+
+## Task 10: End-to-End Smoke Test
+
+This is a manual verification, not automated. Run with a real, recently-completed Jour Fixe meeting.
+
+- [ ] **Step 1: Choose a real meeting**
+
+Pick a Jour Fixe IT Team meeting from this week that already happened (e.g. yesterday's). Open the meeting note in Obsidian.
+
+- [ ] **Step 2: Run fetcher in dry mode**
+
+```bash
+cd scripts
+node fetch-meeting-artifacts.js --o365-id "" --out /tmp/test-fetch.json
+cat /tmp/test-fetch.json
+```
+
+Expected: JSON with `meeting`, `transcript` non-null, `recap` non-null (if Copilot ran), `warnings` empty or minor.
+
+- [ ] **Step 3: Run `/meeting-end` in Claude on the same note**
+
+Trigger the command. Verify:
+- Sammel-Bericht zeigt sinnvolle Konfidenz-Klassifikation
+- Nach Bestätigung: Notiz hat zusätzlich `## Notizen`, `## Entscheidungen`, `## Aufgaben` mit Sprecher-Wikilinks
+- Agenda enthält neuen Historie-Eintrag mit Verweis auf die Meeting-Notiz
+- Status der Meeting-Notiz: `abgeschlossen`
+- `recording_url` im Frontmatter (falls Recording vorhanden)
+
+- [ ] **Step 4: Spot-check archive**
+
+Wenn ein Topic archiviert wurde: prüfe `Archiv*.md` der Serie — Topic vorhanden mit Datums-Heading + Historie?
+
+- [ ] **Step 5: Document any issues**
+
+Gefundene Schwächen → in der Spec unter neuem Abschnitt "Issues from E2E" sammeln, oder Folge-Tickets anlegen.
+
+- [ ] **Step 6: Commit smoke test results (optional)**
+
+Falls die Spec angepasst wurde:
+
+```bash
+git add docs/superpowers/specs/2026-05-06-meeting-end-auto-import-design.md
+git commit -m "docs: e2e findings for meeting-end auto-import"
+```
+
+---
+
+## Self-Review Notes
+
+- Spec coverage: alle Komponenten aus Spec sind als Tasks abgebildet (vtt-parser=Task1, speaker-matcher=Task2, graph-meetings+resolveOnlineMeeting+transcript+aiInsights+recordings=Task3+5, getEventById/joinUrl=Task4, CLI=Task5, Backfill=Task6+7, command=Task8, env doc=Task9, e2e=Task10).
+- Open questions from spec: Backfill scope (IT Team/LANdata) → Task 6; rest lazy → Task 7. Recordings → integriert in Task 3+5.
+- No placeholders. All code blocks complete. Function/method names consistent (`runFetch`, `resolveOnlineMeeting`, `fetchTranscriptVtt`, `fetchAiInsights`, `fetchRecordingUrl`, `upsertFrontmatterField`, `parseVtt`, `formatTranscript`, `matchSpeakers`, `replaceSpeakerNames`, `extractJoinUrlFromBody`, `getEventById`).
+- Confidence heuristic table is concrete, no vague "handle edge cases".