44 KiB
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.vttto plain text with speaker tagsscripts/lib/speaker-matcher.js— match transcript speakers to person notes (wraps person-matcher)scripts/lib/graph-meetings.js— Graph helpers for OnlineMeeting / transcripts / aiInsights / recordingsscripts/fetch-meeting-artifacts.js— CLI orchestratorscripts/backfill-series-id.js— one-shot CLI to seto365_series_idfor given seriesscripts/test/vtt-parser.test.jsscripts/test/speaker-matcher.test.jsscripts/test/graph-meetings.test.jsscripts/test/fetch-meeting-artifacts.test.js
Modified files:
.claude/commands/meeting-end.md— extended workflow with auto-importscripts/.env.example— add note about new permissions03 Bereiche/Jour Fixe/IT Team/Agenda IT Team.md— addo365_series_id03 Bereiche/Jour Fixe/LANdata/Agenda LANdata.md— addo365_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
// 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
<v Christian Kauer>Hallo zusammen.</v>
`;
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
<v Frank Herberg>Wir können das prüfen.</v>
00:00:04.500 --> 00:00:07.000
<v Frank Herberg>Lass uns Borgstedt fragen.</v>
`;
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
// 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(/^<v\s+([^>]+)>([\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
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
// 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
// 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
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
// 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\n<v X>Y</v>\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
// 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
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:
const { extractJoinUrlFromBody } = require('../lib/o365-calendar.js');
describe('extractJoinUrlFromBody', () => {
it('extracts teams meeting join URL from body html', () => {
const html = '<a href="https://teams.microsoft.com/l/meetup-join/19%3aabc/0?context=foo">Join</a>';
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('<p>nothing</p>'), 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):
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:
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
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 <id>(preferred) or--join-url <url> - Calls
getEventById→ extracts joinUrl →resolveOnlineMeeting→fetchTranscriptVtt+fetchAiInsights+fetchRecordingUrl - Output JSON to stdout
--out <file>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
// 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: '<a href="https://teams.microsoft.com/l/meetup-join/19%3aXYZ/0">Join</a>' }
},
[`/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\n<v Christian Kauer>Hallo.</v>\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: '<a href="https://teams.microsoft.com/l/meetup-join/19%3aabc/0">J</a>' }
},
[`/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
// 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 <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
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
// 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
// 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 <agenda-path> <title-regex>');
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)
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
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
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/meetingis 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
ls -la "D:/projects/chrka/brain/.claude/commands/"
Expected: meeting.md, meeting-end.md, meeting-manual.md listed.
- Step 2: Inspect current
/meetinginstructions
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:
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: <seriesMasterId>` 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
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:
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 <Titel>.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 <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: <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/<Serie>/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
cat ".claude/commands/meeting-end.md"
Expected: new content above, no remnants of old version.
- Step 3: Commit
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 "<AZURE_CLIENT_ID>" -Description "..."
# Grant-CsApplicationAccessPolicy -PolicyName "obsidian-graph" -Identity "<AZURE_USER_EMAIL>"
#
# Beta endpoint (aiInsights) requires Teams Premium / Copilot license on the tenant.
- Step 2: Commit
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
cd scripts
node fetch-meeting-artifacts.js --o365-id "<id-from-frontmatter>" --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-endin Claude on the same note
Trigger the command. Verify:
-
Sammel-Bericht zeigt sinnvolle Konfidenz-Klassifikation
-
Nach Bestätigung: Notiz hat zusätzlich
## Notizen,## Entscheidungen,## Aufgabenmit Sprecher-Wikilinks -
Agenda enthält neuen Historie-Eintrag mit Verweis auf die Meeting-Notiz
-
Status der Meeting-Notiz:
abgeschlossen -
recording_urlim 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:
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".