diff --git a/MeetingAssistant.Tests/MeetingNoteStoreTests.cs b/MeetingAssistant.Tests/MeetingNoteStoreTests.cs index 8f76a25..ed4ae50 100644 --- a/MeetingAssistant.Tests/MeetingNoteStoreTests.cs +++ b/MeetingAssistant.Tests/MeetingNoteStoreTests.cs @@ -105,6 +105,36 @@ public sealed class MeetingNoteStoreTests Assert.Equal(userNotes, reloaded.UserNotes); } + [Fact] + public async Task ReadConvertsScalarListFrontmatterToSingleItemLists() + { + var (vaultRoot, store) = CreateStore(); + var notePath = Path.Combine(vaultRoot, "Meetings", "Notes", "manual.md"); + Directory.CreateDirectory(Path.GetDirectoryName(notePath)!); + await File.WriteAllTextAsync( + notePath, + """ + --- + title: Manual frontmatter + start_time: "2026-05-19T10:00:00.0000000+02:00" + end_time: "" + attendees: Ada + projects: Meeting Assistant + transcript: "[[../Transcripts/manual-transcript|Transcript]]" + assistant_context: "[[../Assistant Context/manual-context|Assistant Context]]" + summary: "[[../Summaries/manual-summary|Summary]]" + --- + + User notes. + """); + + var loaded = await store.ReadAsync(notePath, CancellationToken.None); + + Assert.Equal(["Ada"], loaded.Frontmatter.Attendees); + Assert.Equal(["Meeting Assistant"], loaded.Frontmatter.Projects); + Assert.Equal("User notes.", loaded.UserNotes); + } + [Fact] public void ActionLinkEscapesSummaryFileName() { diff --git a/MeetingAssistant.Tests/ProjectKnowledgeToolTests.cs b/MeetingAssistant.Tests/ProjectKnowledgeToolTests.cs index f304aa1..1a439ed 100644 --- a/MeetingAssistant.Tests/ProjectKnowledgeToolTests.cs +++ b/MeetingAssistant.Tests/ProjectKnowledgeToolTests.cs @@ -63,6 +63,34 @@ public sealed class ProjectKnowledgeToolTests await tools.Search("alpha")); } + [Fact] + public async Task ListProjectsAcceptsScalarProjectFrontmatter() + { + var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); + var projectsRoot = Path.Combine(root, "Projects"); + Directory.CreateDirectory(Path.Combine(projectsRoot, "MeetingAssistant")); + var artifacts = CreateArtifacts(root); + Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!); + await File.WriteAllTextAsync( + artifacts.MeetingNotePath, + """ + --- + projects: MeetingAssistant + --- + """); + var tools = new MeetingSummaryTools( + artifacts, + new MeetingAssistantOptions + { + Vault = + { + ProjectsFolder = projectsRoot + } + }); + + Assert.Equal("MeetingAssistant", await tools.ListProjects()); + } + [Fact] public async Task WriteProjectFileSupportsAppendReplaceInsertOverwriteAndCreate() { diff --git a/MeetingAssistant/MeetingNotes/FrontmatterYamlDeserializer.cs b/MeetingAssistant/MeetingNotes/FrontmatterYamlDeserializer.cs new file mode 100644 index 0000000..0c08784 --- /dev/null +++ b/MeetingAssistant/MeetingNotes/FrontmatterYamlDeserializer.cs @@ -0,0 +1,24 @@ +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace MeetingAssistant.MeetingNotes; + +internal static class FrontmatterYamlDeserializer +{ + public static IDeserializer Create() + { + return new DeserializerBuilder() + .WithTypeConverter(new ScalarStringListYamlTypeConverter()) + .IgnoreUnmatchedProperties() + .Build(); + } + + public static IDeserializer CreateWithUnderscoredNaming() + { + return new DeserializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .WithTypeConverter(new ScalarStringListYamlTypeConverter()) + .IgnoreUnmatchedProperties() + .Build(); + } +} diff --git a/MeetingAssistant/MeetingNotes/MarkdownMeetingNoteStore.cs b/MeetingAssistant/MeetingNotes/MarkdownMeetingNoteStore.cs index 835764a..35c9342 100644 --- a/MeetingAssistant/MeetingNotes/MarkdownMeetingNoteStore.cs +++ b/MeetingAssistant/MeetingNotes/MarkdownMeetingNoteStore.cs @@ -16,9 +16,7 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore { this.options = options.Value; this.logger = logger; - yamlDeserializer = new DeserializerBuilder() - .IgnoreUnmatchedProperties() - .Build(); + yamlDeserializer = FrontmatterYamlDeserializer.Create(); } public async Task SaveAsync(MeetingNote note, CancellationToken cancellationToken) diff --git a/MeetingAssistant/MeetingNotes/ScalarStringListYamlTypeConverter.cs b/MeetingAssistant/MeetingNotes/ScalarStringListYamlTypeConverter.cs new file mode 100644 index 0000000..e4758d0 --- /dev/null +++ b/MeetingAssistant/MeetingNotes/ScalarStringListYamlTypeConverter.cs @@ -0,0 +1,38 @@ +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace MeetingAssistant.MeetingNotes; + +internal sealed class ScalarStringListYamlTypeConverter : IYamlTypeConverter +{ + public bool Accepts(Type type) + { + return type == typeof(List); + } + + public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) + { + if (parser.TryConsume(out var scalar)) + { + return string.IsNullOrWhiteSpace(scalar.Value) + ? [] + : new List { scalar.Value }; + } + + var values = new List(); + parser.Consume(); + while (!parser.TryConsume(out _)) + { + var item = parser.Consume(); + values.Add(item.Value); + } + + return values; + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) + { + serializer(value, typeof(IEnumerable)); + } +} diff --git a/MeetingAssistant/Summary/BoundMeetingProjectResolver.cs b/MeetingAssistant/Summary/BoundMeetingProjectResolver.cs index d2177ed..68604b4 100644 --- a/MeetingAssistant/Summary/BoundMeetingProjectResolver.cs +++ b/MeetingAssistant/Summary/BoundMeetingProjectResolver.cs @@ -7,9 +7,7 @@ public sealed record BoundMeetingProject(string Name, string Path); public sealed class BoundMeetingProjectResolver { - private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder() - .IgnoreUnmatchedProperties() - .Build(); + private static readonly IDeserializer YamlDeserializer = FrontmatterYamlDeserializer.Create(); private readonly MeetingAssistantOptions options; diff --git a/MeetingAssistant/Summary/MeetingSummaryFrontmatterFactory.cs b/MeetingAssistant/Summary/MeetingSummaryFrontmatterFactory.cs index 1bb198e..caffdd9 100644 --- a/MeetingAssistant/Summary/MeetingSummaryFrontmatterFactory.cs +++ b/MeetingAssistant/Summary/MeetingSummaryFrontmatterFactory.cs @@ -5,9 +5,7 @@ namespace MeetingAssistant.Summary; internal static class MeetingSummaryFrontmatterFactory { - private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder() - .IgnoreUnmatchedProperties() - .Build(); + private static readonly IDeserializer YamlDeserializer = FrontmatterYamlDeserializer.Create(); public static async Task CreateAsync( MeetingSessionArtifacts artifacts, diff --git a/MeetingAssistant/Summary/MeetingSummaryTools.cs b/MeetingAssistant/Summary/MeetingSummaryTools.cs index 5d7ac68..171b0de 100644 --- a/MeetingAssistant/Summary/MeetingSummaryTools.cs +++ b/MeetingAssistant/Summary/MeetingSummaryTools.cs @@ -7,9 +7,7 @@ namespace MeetingAssistant.Summary; public sealed class MeetingSummaryTools { - private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder() - .IgnoreUnmatchedProperties() - .Build(); + private static readonly IDeserializer YamlDeserializer = FrontmatterYamlDeserializer.Create(); private readonly MeetingSessionArtifacts artifacts; private readonly MeetingAssistantOptions options; diff --git a/MeetingAssistant/Workflow/WorkflowRulesEditorTools.cs b/MeetingAssistant/Workflow/WorkflowRulesEditorTools.cs index bf86504..70405e3 100644 --- a/MeetingAssistant/Workflow/WorkflowRulesEditorTools.cs +++ b/MeetingAssistant/Workflow/WorkflowRulesEditorTools.cs @@ -12,16 +12,12 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using YamlDotNet.Core; using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; namespace MeetingAssistant.Workflow; public sealed class WorkflowRulesEditorTools { - private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder() - .WithNamingConvention(UnderscoredNamingConvention.Instance) - .IgnoreUnmatchedProperties() - .Build(); + private static readonly IDeserializer YamlDeserializer = FrontmatterYamlDeserializer.CreateWithUnderscoredNaming(); private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true diff --git a/openspec/specs/meeting-session/spec.md b/openspec/specs/meeting-session/spec.md index 50515a1..34dd4c3 100644 --- a/openspec/specs/meeting-session/spec.md +++ b/openspec/specs/meeting-session/spec.md @@ -48,6 +48,12 @@ Generated artifact notes SHALL link only to the other notes from the same run an - **WHEN** Meeting Assistant creates a meeting note - **THEN** the note frontmatter links to the configured transcript, assistant context, and summary note locations +#### Scenario: Scalar list frontmatter is normalized +- **GIVEN** a meeting note frontmatter field that Meeting Assistant expects as a list is stored as a scalar string +- **WHEN** Meeting Assistant reads the meeting note +- **THEN** it treats the scalar string as a one-item list +- **AND** continues processing the meeting note + #### Scenario: Generated artifacts do not self-reference - **WHEN** Meeting Assistant writes transcript, assistant context, or summary artifact frontmatter - **THEN** the artifact frontmatter links to the other run notes