@adrkit/mcp 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,583 @@
1
+ /**
2
+ * @adrkit/mcp — the shared tool contract: strict Zod input/output schemas, the
3
+ * fixed annotations, the deterministic text renderer (contracts/tools.md §2.1),
4
+ * and the wire shapes every tool returns. This is the ONE place limits, the text
5
+ * templates, and the envelope live.
6
+ */
7
+
8
+ import { z } from 'zod';
9
+ import { AdrFrontmatter, AdrRef, Status, Scope, type Finding, type FiredMatcher } from '@adrkit/core';
10
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
11
+ import {
12
+ loadCorpusProjection,
13
+ CorpusUnavailableError,
14
+ type CorpusHealth,
15
+ type CorpusProjection,
16
+ type CorpusUnavailableReason,
17
+ } from '../corpus/projection.ts';
18
+ import type { CursorScope, InvalidCursorReason, Page } from '../pagination/cursor.ts';
19
+
20
+ export type { Finding, FiredMatcher } from '@adrkit/core';
21
+ export type { CorpusHealth } from '../corpus/projection.ts';
22
+
23
+ /* ------------------------------------------------------------------ *
24
+ * Fixed limits (contracts/tools.md §8)
25
+ * ------------------------------------------------------------------ */
26
+
27
+ export const LIMITS = {
28
+ query: { min: 1, max: 256 },
29
+ ref: { min: 1, max: 128 },
30
+ status: { min: 1, max: 6 },
31
+ scope: { min: 1, max: 3 },
32
+ tags: { min: 1, max: 32 },
33
+ tag: { min: 1, max: 64 },
34
+ fileLength: { min: 1, max: 1024 },
35
+ files: { min: 1, max: 256 },
36
+ page: { min: 1, max: 100, default: 20 },
37
+ cursorBytes: 4 * 1024,
38
+ textCap: 512,
39
+ } as const;
40
+
41
+ /* ------------------------------------------------------------------ *
42
+ * Fixed annotations (contracts/tools.md §2)
43
+ * ------------------------------------------------------------------ */
44
+
45
+ export const ANNOTATIONS = {
46
+ readOnlyHint: true,
47
+ destructiveHint: false,
48
+ idempotentHint: true,
49
+ openWorldHint: false,
50
+ } as const;
51
+
52
+ /* ------------------------------------------------------------------ *
53
+ * Fixed message tables (contracts/tools.md §2.1)
54
+ * ------------------------------------------------------------------ */
55
+
56
+ export const INVALID_CURSOR_MESSAGES: Record<InvalidCursorReason, string> = {
57
+ 'decode-failed': 'Cursor could not be decoded.',
58
+ 'version-unsupported': 'Cursor version is not supported.',
59
+ 'wrong-channel': 'Cursor does not belong to this tool channel.',
60
+ 'corpus-changed': 'Corpus changed after this cursor was issued.',
61
+ 'query-mismatch': 'Cursor was issued for different request parameters.',
62
+ 'cursor-not-applicable': 'Cursor does not apply to this outcome.',
63
+ 'offset-out-of-range': 'Cursor offset is outside the current result set.',
64
+ };
65
+
66
+ export const CORPUS_UNAVAILABLE_MESSAGES: Record<CorpusUnavailableReason, string> = {
67
+ 'root-not-found': 'Configured repository root was not found.',
68
+ 'root-not-directory': 'Configured repository root is not a directory.',
69
+ 'root-not-readable': 'Configured repository root is not readable.',
70
+ 'root-not-git': 'Configured repository root is not a Git worktree.',
71
+ 'dir-not-found': 'Configured ADR directory was not found.',
72
+ 'dir-not-directory': 'Configured ADR directory is not a directory.',
73
+ 'dir-not-readable': 'Configured ADR directory is not readable.',
74
+ 'dir-outside-root': 'Configured ADR directory resolves outside the repository root.',
75
+ 'corpus-changed-during-load': 'Corpus changed while it was being loaded; retry the call.',
76
+ };
77
+
78
+ const INVALID_CURSOR_REASONS = Object.keys(INVALID_CURSOR_MESSAGES) as [InvalidCursorReason, ...InvalidCursorReason[]];
79
+ const CORPUS_UNAVAILABLE_REASONS = Object.keys(CORPUS_UNAVAILABLE_MESSAGES) as [
80
+ CorpusUnavailableReason,
81
+ ...CorpusUnavailableReason[],
82
+ ];
83
+
84
+ /** `n === 1 ? one : many`. */
85
+ export function plural(n: number, one: string, many: string): string {
86
+ return n === 1 ? one : many;
87
+ }
88
+
89
+ /** Immutable per-server config every tool handler reads to load its projection. */
90
+ export interface ToolConfig {
91
+ readonly configuredCwd: string;
92
+ readonly configuredDir: string;
93
+ readonly expectedCanonicalCwd: string;
94
+ readonly maxSourceBytes: number;
95
+ }
96
+
97
+ /* ------------------------------------------------------------------ *
98
+ * Wire shapes (data-model.md §4, §6)
99
+ * ------------------------------------------------------------------ */
100
+
101
+ export type StatusValue = z.infer<typeof Status>;
102
+ export type ScopeValue = z.infer<typeof Scope>;
103
+
104
+ export interface DecisionSummary {
105
+ readonly id: string;
106
+ readonly title: string;
107
+ readonly status: StatusValue;
108
+ readonly sourcePath: string;
109
+ }
110
+
111
+ export interface RelationRefs {
112
+ readonly supersedes: readonly string[];
113
+ readonly supersededBy: string | null;
114
+ readonly relatesTo: readonly string[];
115
+ readonly conflictsWith: readonly string[];
116
+ }
117
+
118
+ export interface FullDecision {
119
+ readonly requestedRef: string;
120
+ readonly id: string;
121
+ readonly title: string;
122
+ readonly status: StatusValue;
123
+ readonly sourcePath: string;
124
+ readonly frontmatter: z.infer<typeof AdrFrontmatter>;
125
+ readonly body: string;
126
+ }
127
+
128
+ export interface SearchMatch extends DecisionSummary {
129
+ readonly matchedFields: readonly ('id' | 'title' | 'tag' | 'body')[];
130
+ }
131
+
132
+ export interface ContextEntry extends DecisionSummary {
133
+ readonly firedMatchers: readonly FiredMatcher[];
134
+ readonly relations: RelationRefs;
135
+ }
136
+
137
+ export type SupersededByState =
138
+ | { readonly resolved: true; readonly target: DecisionSummary }
139
+ | { readonly resolved: false; readonly targetRef: string; readonly reason: 'dangling' }
140
+ | { readonly resolved: false; readonly targetRef: string; readonly reason: 'ambiguous'; readonly candidateCount: number }
141
+ | {
142
+ readonly resolved: false;
143
+ readonly targetRef: string;
144
+ readonly reason: 'federated-unavailable';
145
+ readonly log: string;
146
+ readonly id: string;
147
+ };
148
+
149
+ export interface SupersededEntry extends DecisionSummary {
150
+ readonly supersededBy: SupersededByState;
151
+ }
152
+
153
+ export type FindingsPage = Page<Finding>;
154
+
155
+ export interface InvalidCursorOutcome {
156
+ readonly outcome: 'invalid-cursor';
157
+ readonly reason: InvalidCursorReason;
158
+ readonly message: string;
159
+ }
160
+
161
+ export interface CorpusUnavailableOutcome {
162
+ readonly outcome: 'corpus-unavailable';
163
+ readonly reason: CorpusUnavailableReason;
164
+ readonly message: string;
165
+ }
166
+
167
+ export type SearchDecisionsResult =
168
+ | { outcome: 'results'; items: readonly SearchMatch[]; cursor: string | null; findings: FindingsPage }
169
+ | InvalidCursorOutcome
170
+ | CorpusUnavailableOutcome;
171
+
172
+ export type GetDecisionResult =
173
+ | { outcome: 'found'; decision: FullDecision; findings: FindingsPage }
174
+ | { outcome: 'not-found'; requestedRef: string; findings: FindingsPage }
175
+ | {
176
+ outcome: 'ambiguous-local-id';
177
+ requestedRef: string;
178
+ candidates: readonly DecisionSummary[];
179
+ cursor: string | null;
180
+ findings: FindingsPage;
181
+ }
182
+ | { outcome: 'federated-log-unavailable'; requestedRef: string; log: string; id: string; findings: FindingsPage }
183
+ | InvalidCursorOutcome
184
+ | CorpusUnavailableOutcome;
185
+
186
+ export type GetDecisionContextResult =
187
+ | {
188
+ outcome: 'matches';
189
+ governing: readonly ContextEntry[];
190
+ activeProposals: readonly ContextEntry[];
191
+ history: readonly ContextEntry[];
192
+ cursor: string | null;
193
+ findings: FindingsPage;
194
+ }
195
+ | InvalidCursorOutcome
196
+ | CorpusUnavailableOutcome;
197
+
198
+ export type ListSupersededResult =
199
+ | { outcome: 'entries'; items: readonly SupersededEntry[]; cursor: string | null; findings: FindingsPage }
200
+ | InvalidCursorOutcome
201
+ | CorpusUnavailableOutcome;
202
+
203
+ /* ------------------------------------------------------------------ *
204
+ * Deterministic text renderer (contracts/tools.md §2.1)
205
+ * ------------------------------------------------------------------ */
206
+
207
+ export type TextSpec =
208
+ | { kind: 'results'; itemsLength: number; findings: number }
209
+ | { kind: 'found'; id: string; findings: number }
210
+ | { kind: 'not-found'; requestedRef: string; findings: number }
211
+ | { kind: 'ambiguous-local-id'; candidatesLength: number; requestedRef: string; findings: number }
212
+ | { kind: 'federated-log-unavailable'; requestedRef: string; findings: number }
213
+ | { kind: 'matches'; governing: number; activeProposals: number; history: number; findings: number }
214
+ | { kind: 'entries'; itemsLength: number; findings: number }
215
+ | { kind: 'invalid-cursor'; reason: InvalidCursorReason }
216
+ | { kind: 'corpus-unavailable'; reason: CorpusUnavailableReason };
217
+
218
+ /** Cap a rendered string at 512 UTF-16 code units. */
219
+ export function cap512(value: string): string {
220
+ return value.length > LIMITS.textCap ? value.slice(0, LIMITS.textCap) : value;
221
+ }
222
+
223
+ function findingsPhrase(findings: number): string {
224
+ return `${findings} ${plural(findings, 'finding', 'findings')} on this page.`;
225
+ }
226
+
227
+ export function renderResponseText(spec: TextSpec): string {
228
+ let text: string;
229
+ switch (spec.kind) {
230
+ case 'results':
231
+ text = `Returned ${spec.itemsLength} decision ${plural(spec.itemsLength, 'result', 'results')}; ${findingsPhrase(spec.findings)}`;
232
+ break;
233
+ case 'found':
234
+ text = `Found decision "${spec.id}"; ${findingsPhrase(spec.findings)}`;
235
+ break;
236
+ case 'not-found':
237
+ text = `No local decision matches "${spec.requestedRef}"; ${findingsPhrase(spec.findings)}`;
238
+ break;
239
+ case 'ambiguous-local-id':
240
+ text = `Returned ${spec.candidatesLength} ${plural(spec.candidatesLength, 'candidate', 'candidates')} for ambiguous local ref "${spec.requestedRef}"; ${findingsPhrase(spec.findings)}`;
241
+ break;
242
+ case 'federated-log-unavailable':
243
+ text = `Named-log federation is unavailable for "${spec.requestedRef}"; ${findingsPhrase(spec.findings)}`;
244
+ break;
245
+ case 'matches': {
246
+ const pageMatchCount = spec.governing + spec.activeProposals + spec.history;
247
+ text = `Returned ${pageMatchCount} context ${plural(pageMatchCount, 'match', 'matches')}: ${spec.governing} governing, ${spec.activeProposals} active ${plural(spec.activeProposals, 'proposal', 'proposals')}, ${spec.history} historical; ${findingsPhrase(spec.findings)}`;
248
+ break;
249
+ }
250
+ case 'entries':
251
+ text = `Returned ${spec.itemsLength} superseded decision ${plural(spec.itemsLength, 'entry', 'entries')}; ${findingsPhrase(spec.findings)}`;
252
+ break;
253
+ case 'invalid-cursor':
254
+ text = INVALID_CURSOR_MESSAGES[spec.reason];
255
+ break;
256
+ case 'corpus-unavailable':
257
+ text = CORPUS_UNAVAILABLE_MESSAGES[spec.reason];
258
+ break;
259
+ }
260
+ return cap512(text);
261
+ }
262
+
263
+ /* ------------------------------------------------------------------ *
264
+ * Schemas
265
+ * ------------------------------------------------------------------ */
266
+
267
+ export type ToolInputSchema = z.ZodType;
268
+ export type ToolOutputSchema = z.ZodRawShape;
269
+
270
+ const uniqueArray = <T>(schema: z.ZodType<T>, min: number, max: number) =>
271
+ z
272
+ .array(schema)
273
+ .min(min)
274
+ .max(max)
275
+ .refine((values) => new Set(values.map((v) => JSON.stringify(v))).size === values.length, {
276
+ message: 'Items must be unique',
277
+ });
278
+
279
+ function paginationShape(): z.ZodRawShape {
280
+ const cursor = z.string().max(LIMITS.cursorBytes).optional();
281
+ const limit = z.number().int().min(LIMITS.page.min).max(LIMITS.page.max).default(LIMITS.page.default);
282
+ return { cursor, limit, findingsCursor: cursor, findingsLimit: limit };
283
+ }
284
+
285
+ function isSafePosixPath(path: string): boolean {
286
+ if (path.includes('\\')) return false;
287
+ if (path.startsWith('/')) return false;
288
+ if (/^[a-zA-Z]:/.test(path)) return false;
289
+ if (path.split('/').includes('..')) return false;
290
+ return path.length > 0;
291
+ }
292
+
293
+ export function searchDecisionsInputSchema(): ToolInputSchema {
294
+ return z.strictObject({
295
+ query: z
296
+ .string()
297
+ .min(LIMITS.query.min)
298
+ .max(LIMITS.query.max)
299
+ .refine((q) => q.trim().length >= 1, { message: 'query must be non-empty after trimming' }),
300
+ status: uniqueArray(Status, LIMITS.status.min, LIMITS.status.max).optional(),
301
+ tags: uniqueArray(z.string().min(LIMITS.tag.min).max(LIMITS.tag.max), LIMITS.tags.min, LIMITS.tags.max).optional(),
302
+ scope: uniqueArray(Scope, LIMITS.scope.min, LIMITS.scope.max).optional(),
303
+ ...paginationShape(),
304
+ });
305
+ }
306
+
307
+ export function getDecisionInputSchema(): ToolInputSchema {
308
+ return z.strictObject({
309
+ ref: AdrRef.max(LIMITS.ref.max),
310
+ ...paginationShape(),
311
+ });
312
+ }
313
+
314
+ export function getDecisionContextInputSchema(): ToolInputSchema {
315
+ return z.strictObject({
316
+ files: z
317
+ .array(
318
+ z
319
+ .string()
320
+ .min(LIMITS.fileLength.min)
321
+ .max(LIMITS.fileLength.max)
322
+ .refine(isSafePosixPath, { message: 'files must be repo-relative POSIX paths' }),
323
+ )
324
+ .min(LIMITS.files.min)
325
+ .max(LIMITS.files.max),
326
+ ...paginationShape(),
327
+ });
328
+ }
329
+
330
+ export function listSupersededInputSchema(): ToolInputSchema {
331
+ return z.strictObject({ ...paginationShape() });
332
+ }
333
+
334
+ export function findingSchema(): z.ZodType {
335
+ return z.object({
336
+ rule: z.string(),
337
+ severity: z.enum(['error', 'warn', 'info']),
338
+ message: z.string(),
339
+ path: z.string().optional(),
340
+ id: z.string().optional(),
341
+ field: z.string().optional(),
342
+ pattern: z.string().optional(),
343
+ });
344
+ }
345
+
346
+ export function corpusHealthSchema(): z.ZodType {
347
+ return z.object({
348
+ fingerprint: z.string(),
349
+ recordCount: z.number().int(),
350
+ excludedCount: z.number().int(),
351
+ });
352
+ }
353
+
354
+ function findingsPageSchema(): z.ZodType {
355
+ return z.object({ items: z.array(findingSchema()), cursor: z.string().nullable() });
356
+ }
357
+
358
+ function decisionSummarySchema(): z.ZodType {
359
+ return z.object({ id: z.string(), title: z.string(), status: Status, sourcePath: z.string() });
360
+ }
361
+
362
+ function relationRefsSchema(): z.ZodType {
363
+ return z.object({
364
+ supersedes: z.array(z.string()),
365
+ supersededBy: z.string().nullable(),
366
+ relatesTo: z.array(z.string()),
367
+ conflictsWith: z.array(z.string()),
368
+ });
369
+ }
370
+
371
+ function invalidCursorSchema() {
372
+ return z.object({
373
+ outcome: z.literal('invalid-cursor'),
374
+ reason: z.enum(INVALID_CURSOR_REASONS),
375
+ message: z.string(),
376
+ });
377
+ }
378
+
379
+ function corpusUnavailableSchema() {
380
+ return z.object({
381
+ outcome: z.literal('corpus-unavailable'),
382
+ reason: z.enum(CORPUS_UNAVAILABLE_REASONS),
383
+ message: z.string(),
384
+ });
385
+ }
386
+
387
+ export function searchDecisionsOutputSchema(): ToolOutputSchema {
388
+ const searchMatch = z.object({
389
+ id: z.string(),
390
+ title: z.string(),
391
+ status: Status,
392
+ sourcePath: z.string(),
393
+ matchedFields: z.array(z.enum(['id', 'title', 'tag', 'body'])),
394
+ });
395
+ return {
396
+ corpusHealth: corpusHealthSchema().optional(),
397
+ result: z.discriminatedUnion('outcome', [
398
+ z.object({
399
+ outcome: z.literal('results'),
400
+ items: z.array(searchMatch),
401
+ cursor: z.string().nullable(),
402
+ findings: findingsPageSchema(),
403
+ }),
404
+ invalidCursorSchema(),
405
+ corpusUnavailableSchema(),
406
+ ]),
407
+ };
408
+ }
409
+
410
+ export function getDecisionOutputSchema(): ToolOutputSchema {
411
+ const fullDecision = z.object({
412
+ requestedRef: z.string(),
413
+ id: z.string(),
414
+ title: z.string(),
415
+ status: Status,
416
+ sourcePath: z.string(),
417
+ frontmatter: AdrFrontmatter,
418
+ body: z.string(),
419
+ });
420
+ return {
421
+ corpusHealth: corpusHealthSchema().optional(),
422
+ result: z.discriminatedUnion('outcome', [
423
+ z.object({ outcome: z.literal('found'), decision: fullDecision, findings: findingsPageSchema() }),
424
+ z.object({ outcome: z.literal('not-found'), requestedRef: z.string(), findings: findingsPageSchema() }),
425
+ z.object({
426
+ outcome: z.literal('ambiguous-local-id'),
427
+ requestedRef: z.string(),
428
+ candidates: z.array(decisionSummarySchema()),
429
+ cursor: z.string().nullable(),
430
+ findings: findingsPageSchema(),
431
+ }),
432
+ z.object({
433
+ outcome: z.literal('federated-log-unavailable'),
434
+ requestedRef: z.string(),
435
+ log: z.string(),
436
+ id: z.string(),
437
+ findings: findingsPageSchema(),
438
+ }),
439
+ invalidCursorSchema(),
440
+ corpusUnavailableSchema(),
441
+ ]),
442
+ };
443
+ }
444
+
445
+ export function getDecisionContextOutputSchema(): ToolOutputSchema {
446
+ const contextEntry = z.object({
447
+ id: z.string(),
448
+ title: z.string(),
449
+ status: Status,
450
+ sourcePath: z.string(),
451
+ firedMatchers: z.array(z.object({ type: z.string(), pattern: z.string() })),
452
+ relations: relationRefsSchema(),
453
+ });
454
+ return {
455
+ corpusHealth: corpusHealthSchema().optional(),
456
+ result: z.discriminatedUnion('outcome', [
457
+ z.object({
458
+ outcome: z.literal('matches'),
459
+ governing: z.array(contextEntry),
460
+ activeProposals: z.array(contextEntry),
461
+ history: z.array(contextEntry),
462
+ cursor: z.string().nullable(),
463
+ findings: findingsPageSchema(),
464
+ }),
465
+ invalidCursorSchema(),
466
+ corpusUnavailableSchema(),
467
+ ]),
468
+ };
469
+ }
470
+
471
+ export function listSupersededOutputSchema(): ToolOutputSchema {
472
+ const supersededBy = z.union([
473
+ z.object({ resolved: z.literal(true), target: decisionSummarySchema() }),
474
+ z.object({ resolved: z.literal(false), targetRef: z.string(), reason: z.literal('dangling') }),
475
+ z.object({
476
+ resolved: z.literal(false),
477
+ targetRef: z.string(),
478
+ reason: z.literal('ambiguous'),
479
+ candidateCount: z.number().int(),
480
+ }),
481
+ z.object({
482
+ resolved: z.literal(false),
483
+ targetRef: z.string(),
484
+ reason: z.literal('federated-unavailable'),
485
+ log: z.string(),
486
+ id: z.string(),
487
+ }),
488
+ ]);
489
+ const supersededEntry = z.object({
490
+ id: z.string(),
491
+ title: z.string(),
492
+ status: Status,
493
+ sourcePath: z.string(),
494
+ supersededBy,
495
+ });
496
+ return {
497
+ corpusHealth: corpusHealthSchema().optional(),
498
+ result: z.discriminatedUnion('outcome', [
499
+ z.object({
500
+ outcome: z.literal('entries'),
501
+ items: z.array(supersededEntry),
502
+ cursor: z.string().nullable(),
503
+ findings: findingsPageSchema(),
504
+ }),
505
+ invalidCursorSchema(),
506
+ corpusUnavailableSchema(),
507
+ ]),
508
+ };
509
+ }
510
+
511
+ /* ------------------------------------------------------------------ *
512
+ * CallToolResult builder
513
+ * ------------------------------------------------------------------ */
514
+
515
+ export type StructuredResult = CallToolResult;
516
+
517
+ export function structuredResult(
518
+ result: unknown,
519
+ text: string,
520
+ corpusHealth: CorpusHealth | undefined,
521
+ ): CallToolResult {
522
+ const structuredContent = corpusHealth === undefined ? { result } : { corpusHealth, result };
523
+ return { structuredContent, content: [{ type: 'text', text }] };
524
+ }
525
+
526
+ /* ------------------------------------------------------------------ *
527
+ * Handler runtime helpers
528
+ * ------------------------------------------------------------------ */
529
+
530
+ export function corpusHealthOf(projection: CorpusProjection): CorpusHealth {
531
+ return {
532
+ fingerprint: projection.fingerprint,
533
+ recordCount: projection.recordCount,
534
+ excludedCount: projection.excludedCount,
535
+ };
536
+ }
537
+
538
+ export function invalidCursor(reason: InvalidCursorReason): InvalidCursorOutcome {
539
+ return { outcome: 'invalid-cursor', reason, message: INVALID_CURSOR_MESSAGES[reason] };
540
+ }
541
+
542
+ export function corpusUnavailable(reason: CorpusUnavailableReason): CorpusUnavailableOutcome {
543
+ return { outcome: 'corpus-unavailable', reason, message: CORPUS_UNAVAILABLE_MESSAGES[reason] };
544
+ }
545
+
546
+ export type LoadResult =
547
+ | { readonly ok: true; readonly projection: CorpusProjection }
548
+ | { readonly ok: false; readonly outcome: CorpusUnavailableOutcome };
549
+
550
+ /** Load the fresh projection, mapping a typed `CorpusUnavailableError` to its outcome. */
551
+ export async function loadProjection(config: ToolConfig): Promise<LoadResult> {
552
+ try {
553
+ const projection = await loadCorpusProjection({
554
+ configuredCwd: config.configuredCwd,
555
+ configuredDir: config.configuredDir,
556
+ expectedCanonicalCwd: config.expectedCanonicalCwd,
557
+ maxSourceBytes: config.maxSourceBytes,
558
+ });
559
+ return { ok: true, projection };
560
+ } catch (error) {
561
+ if (error instanceof CorpusUnavailableError) return { ok: false, outcome: corpusUnavailable(error.reason) };
562
+ throw error;
563
+ }
564
+ }
565
+
566
+ /** Every tool derives its DecisionSummary from an Adr record the same way. */
567
+ export function toSummary(record: { frontmatter: { id: string; title: string; status: StatusValue }; path: string }): DecisionSummary {
568
+ return {
569
+ id: record.frontmatter.id,
570
+ title: record.frontmatter.title,
571
+ status: record.frontmatter.status,
572
+ sourcePath: record.path,
573
+ };
574
+ }
575
+
576
+ export function toRelationRefs(frontmatter: z.infer<typeof AdrFrontmatter>): RelationRefs {
577
+ return {
578
+ supersedes: frontmatter.supersedes,
579
+ supersededBy: frontmatter.supersededBy ?? null,
580
+ relatesTo: frontmatter.relatesTo,
581
+ conflictsWith: frontmatter.conflictsWith,
582
+ };
583
+ }