brain/docs/superpowers/plans/2026-05-06-meeting-end-auto...

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

// 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 → resolveOnlineMeetingfetchTranscriptVtt + 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 /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
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:

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

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