# 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".