From 5c94d91b5503d5cc5fef7672931591527a35f5de Mon Sep 17 00:00:00 2001 From: beo3000 Date: Wed, 6 May 2026 20:26:04 +0200 Subject: [PATCH] docs: add meeting-end auto-import implementation plan --- .../2026-05-06-meeting-end-auto-import.md | 1346 +++++++++++++++++ 1 file changed, 1346 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-06-meeting-end-auto-import.md 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".