coaia-visualizer 1.6.4 → 1.6.5

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,673 @@
1
+ import type {
2
+ Chart,
3
+ EntityRecord,
4
+ FoundationMetadata,
5
+ FoundationProjection,
6
+ MetadataWarning,
7
+ ParsedIssueReference,
8
+ SessionContextMetadata,
9
+ SessionContextProjection,
10
+ SessionLineageBranchProjection,
11
+ SessionLineageGroupProjection,
12
+ SessionLineageMetadata,
13
+ SessionLineageProjection,
14
+ } from "./types.ts"
15
+
16
+ const FOUNDATION_KEYS: Array<keyof FoundationMetadata> = [
17
+ "packetRoot",
18
+ "foundationType",
19
+ "parentIssue",
20
+ "baselineIssue",
21
+ "inquiryIssue",
22
+ "protocolIssue",
23
+ "schemaIssue",
24
+ "visualizerIssue",
25
+ "expectedArtifacts",
26
+ "producedArtifacts",
27
+ "evaluationStatus",
28
+ "privacyClass",
29
+ "publicationStatus",
30
+ "commitHandles",
31
+ ]
32
+
33
+ const FOUNDATION_REQUIRED_FIELDS: Array<keyof FoundationMetadata> = [
34
+ "packetRoot",
35
+ "foundationType",
36
+ "parentIssue",
37
+ "baselineIssue",
38
+ "inquiryIssue",
39
+ "protocolIssue",
40
+ "schemaIssue",
41
+ "visualizerIssue",
42
+ "expectedArtifacts",
43
+ "evaluationStatus",
44
+ "privacyClass",
45
+ "publicationStatus",
46
+ ]
47
+
48
+ const SESSION_LINEAGE_KEYS: Array<keyof SessionLineageMetadata> = [
49
+ "platform",
50
+ "parentChartId",
51
+ "sourceBeat",
52
+ "originalSessionId",
53
+ "branchSessionId",
54
+ "branchIndex",
55
+ "copiedMessageCount",
56
+ "branchPurpose",
57
+ "relatedIssues",
58
+ "handoffState",
59
+ ]
60
+
61
+ const SESSION_LINEAGE_REQUIRED_FIELDS: Array<keyof SessionLineageMetadata> = [
62
+ "platform",
63
+ "parentChartId",
64
+ "sourceBeat",
65
+ "originalSessionId",
66
+ "branchSessionId",
67
+ "branchIndex",
68
+ "copiedMessageCount",
69
+ "branchPurpose",
70
+ "handoffState",
71
+ ]
72
+
73
+ const SESSION_CONTEXT_KEYS: Array<keyof SessionContextMetadata> = [
74
+ "mode",
75
+ "setting",
76
+ "captureQuality",
77
+ "continuationKind",
78
+ "privateChroniclePath",
79
+ "handle",
80
+ "publicSummaryAllowed",
81
+ ]
82
+
83
+ const SESSION_CONTEXT_REQUIRED_FIELDS: Array<keyof SessionContextMetadata> = [
84
+ "mode",
85
+ "setting",
86
+ "captureQuality",
87
+ "continuationKind",
88
+ "publicSummaryAllowed",
89
+ ]
90
+
91
+ const FOUNDATION_EVALUATION_STATUSES = ["expected", "delegated", "produced", "evaluated"] as const
92
+ const FOUNDATION_PRIVACY_CLASSES = ["public-safe", "private", "mixed"] as const
93
+ const FOUNDATION_PUBLICATION_STATUSES = ["planned", "draft", "reviewed", "published"] as const
94
+ const SESSION_HANDOFF_STATES = [
95
+ "requirements-created",
96
+ "implementation-ready",
97
+ "returned-to-parent",
98
+ ] as const
99
+
100
+ const SESSION_CAPTURE_QUALITIES = ["complete", "partial", "noisy", "transcript-only"] as const
101
+ const SESSION_CONTINUATION_KINDS = ["new", "resumption", "branch-return", "daily-chronicle"] as const
102
+
103
+ function isObject(value: unknown): value is Record<string, any> {
104
+ return typeof value === "object" && value !== null && !Array.isArray(value)
105
+ }
106
+
107
+ function asTrimmedString(value: unknown): string | undefined {
108
+ if (typeof value !== "string") return undefined
109
+ const trimmed = value.trim()
110
+ return trimmed ? trimmed : undefined
111
+ }
112
+
113
+ function normalizeList(value: unknown): string[] {
114
+ if (Array.isArray(value)) {
115
+ return value
116
+ .map((item) => asTrimmedString(typeof item === "string" ? item : JSON.stringify(item)))
117
+ .filter((item): item is string => Boolean(item))
118
+ }
119
+
120
+ if (typeof value === "string") {
121
+ return value
122
+ .split(/[\n,]/)
123
+ .map((item) => item.trim())
124
+ .filter(Boolean)
125
+ }
126
+
127
+ return []
128
+ }
129
+
130
+ function asNumber(value: unknown): number | undefined {
131
+ if (typeof value === "number" && Number.isFinite(value)) return value
132
+ if (typeof value === "string" && value.trim()) {
133
+ const parsed = Number(value)
134
+ return Number.isFinite(parsed) ? parsed : undefined
135
+ }
136
+ return undefined
137
+ }
138
+
139
+ function hasAnyValue(record: Record<string, any>, keys: readonly string[]): boolean {
140
+ return keys.some((key) => record[key] !== undefined && record[key] !== null && record[key] !== "")
141
+ }
142
+
143
+ function toLookupKey(value: string): string {
144
+ return value.trim().toLowerCase()
145
+ }
146
+
147
+ function slugify(value: string): string {
148
+ return value
149
+ .trim()
150
+ .toLowerCase()
151
+ .replace(/[^a-z0-9]+/g, "-")
152
+ .replace(/^-+|-+$/g, "") || "beat"
153
+ }
154
+
155
+ function pushPayloadCandidate(candidates: Record<string, any>[], candidate: unknown, keys: readonly string[]): void {
156
+ if (isObject(candidate) && hasAnyValue(candidate, keys)) {
157
+ candidates.push(candidate)
158
+ }
159
+ }
160
+
161
+ function foundationPayloads(metadata: Record<string, any>): Record<string, any>[] {
162
+ const candidates: Record<string, any>[] = []
163
+ pushPayloadCandidate(candidates, metadata.asterion?.foundation, FOUNDATION_KEYS)
164
+ pushPayloadCandidate(candidates, metadata.asterionFoundation, FOUNDATION_KEYS)
165
+ pushPayloadCandidate(candidates, metadata.foundationMetadata, FOUNDATION_KEYS)
166
+ pushPayloadCandidate(candidates, metadata.foundation, FOUNDATION_KEYS)
167
+ pushPayloadCandidate(candidates, metadata.asterionMetadata?.foundation, FOUNDATION_KEYS)
168
+ pushPayloadCandidate(candidates, metadata.asterion, FOUNDATION_KEYS)
169
+ pushPayloadCandidate(candidates, metadata, FOUNDATION_KEYS)
170
+ return candidates
171
+ }
172
+
173
+ function lineagePayloads(metadata: Record<string, any>): Record<string, any>[] {
174
+ const candidates: Record<string, any>[] = []
175
+ pushPayloadCandidate(candidates, metadata.asterion?.sessionLineage, SESSION_LINEAGE_KEYS)
176
+ pushPayloadCandidate(candidates, metadata.sessionLineageMetadata, SESSION_LINEAGE_KEYS)
177
+ pushPayloadCandidate(candidates, metadata.sessionLineage, SESSION_LINEAGE_KEYS)
178
+ pushPayloadCandidate(candidates, metadata.asterionMetadata?.sessionLineage, SESSION_LINEAGE_KEYS)
179
+ pushPayloadCandidate(candidates, metadata.asterion, SESSION_LINEAGE_KEYS)
180
+ pushPayloadCandidate(candidates, metadata, SESSION_LINEAGE_KEYS)
181
+ return candidates
182
+ }
183
+
184
+ function sessionContextPayloads(metadata: Record<string, any>): Record<string, any>[] {
185
+ const candidates: Record<string, any>[] = []
186
+ pushPayloadCandidate(candidates, metadata.asterion?.sessionContext, SESSION_CONTEXT_KEYS)
187
+ pushPayloadCandidate(candidates, metadata.sessionContextMetadata, SESSION_CONTEXT_KEYS)
188
+ pushPayloadCandidate(candidates, metadata.sessionContext, SESSION_CONTEXT_KEYS)
189
+ pushPayloadCandidate(candidates, metadata.asterionMetadata?.sessionContext, SESSION_CONTEXT_KEYS)
190
+ pushPayloadCandidate(candidates, metadata.livedContext, SESSION_CONTEXT_KEYS)
191
+ pushPayloadCandidate(candidates, metadata.asterion, SESSION_CONTEXT_KEYS)
192
+ pushPayloadCandidate(candidates, metadata, SESSION_CONTEXT_KEYS)
193
+ return candidates
194
+ }
195
+
196
+ function buildWarning(message: string, record?: Pick<EntityRecord, "name" | "entityType">): MetadataWarning {
197
+ return {
198
+ severity: "warning",
199
+ recordName: record?.name,
200
+ recordType: record?.entityType,
201
+ message,
202
+ }
203
+ }
204
+
205
+ function dedupeWarnings(warnings: MetadataWarning[]): MetadataWarning[] {
206
+ const seen = new Set<string>()
207
+ return warnings.filter((warning) => {
208
+ const key = `${warning.recordName || ""}|${warning.recordType || ""}|${warning.message}`
209
+ if (seen.has(key)) return false
210
+ seen.add(key)
211
+ return true
212
+ })
213
+ }
214
+
215
+ function normalizeFoundationPayload(payload: Record<string, any>, record?: EntityRecord): {
216
+ foundation: Partial<FoundationMetadata>
217
+ warnings: MetadataWarning[]
218
+ } {
219
+ const warnings: MetadataWarning[] = []
220
+ const foundation: Partial<FoundationMetadata> = {}
221
+
222
+ foundation.packetRoot = asTrimmedString(payload.packetRoot)
223
+ foundation.foundationType = asTrimmedString(payload.foundationType)
224
+ foundation.parentIssue = asTrimmedString(payload.parentIssue)
225
+ foundation.baselineIssue = asTrimmedString(payload.baselineIssue)
226
+ foundation.inquiryIssue = asTrimmedString(payload.inquiryIssue)
227
+ foundation.protocolIssue = asTrimmedString(payload.protocolIssue)
228
+ foundation.schemaIssue = asTrimmedString(payload.schemaIssue)
229
+ foundation.visualizerIssue = asTrimmedString(payload.visualizerIssue)
230
+
231
+ if (payload.expectedArtifacts !== undefined) {
232
+ foundation.expectedArtifacts = normalizeList(payload.expectedArtifacts)
233
+ }
234
+ if (payload.producedArtifacts !== undefined) {
235
+ foundation.producedArtifacts = normalizeList(payload.producedArtifacts)
236
+ }
237
+ if (payload.commitHandles !== undefined) {
238
+ foundation.commitHandles = normalizeList(payload.commitHandles)
239
+ }
240
+
241
+ const evaluationStatus = asTrimmedString(payload.evaluationStatus)
242
+ if (evaluationStatus) {
243
+ if (FOUNDATION_EVALUATION_STATUSES.includes(evaluationStatus as (typeof FOUNDATION_EVALUATION_STATUSES)[number])) {
244
+ foundation.evaluationStatus = evaluationStatus as FoundationMetadata["evaluationStatus"]
245
+ } else {
246
+ warnings.push(buildWarning(`Foundation evaluationStatus \"${evaluationStatus}\" is not recognized.`, record))
247
+ }
248
+ }
249
+
250
+ const privacyClass = asTrimmedString(payload.privacyClass)
251
+ if (privacyClass) {
252
+ if (FOUNDATION_PRIVACY_CLASSES.includes(privacyClass as (typeof FOUNDATION_PRIVACY_CLASSES)[number])) {
253
+ foundation.privacyClass = privacyClass as FoundationMetadata["privacyClass"]
254
+ } else {
255
+ warnings.push(buildWarning(`Foundation privacyClass \"${privacyClass}\" is not recognized.`, record))
256
+ }
257
+ }
258
+
259
+ const publicationStatus = asTrimmedString(payload.publicationStatus)
260
+ if (publicationStatus) {
261
+ if (FOUNDATION_PUBLICATION_STATUSES.includes(publicationStatus as (typeof FOUNDATION_PUBLICATION_STATUSES)[number])) {
262
+ foundation.publicationStatus = publicationStatus as FoundationMetadata["publicationStatus"]
263
+ } else {
264
+ warnings.push(buildWarning(`Foundation publicationStatus \"${publicationStatus}\" is not recognized.`, record))
265
+ }
266
+ }
267
+
268
+ return {
269
+ foundation: Object.fromEntries(Object.entries(foundation).filter(([, value]) => value !== undefined)) as Partial<FoundationMetadata>,
270
+ warnings,
271
+ }
272
+ }
273
+
274
+ function normalizeSessionLineagePayload(payload: Record<string, any>, record?: EntityRecord): {
275
+ lineage: Partial<SessionLineageMetadata>
276
+ warnings: MetadataWarning[]
277
+ } {
278
+ const warnings: MetadataWarning[] = []
279
+ const lineage: Partial<SessionLineageMetadata> = {}
280
+
281
+ lineage.platform = asTrimmedString(payload.platform)
282
+ lineage.parentChartId = asTrimmedString(payload.parentChartId)
283
+ lineage.sourceBeat = asTrimmedString(payload.sourceBeat)
284
+ lineage.originalSessionId = asTrimmedString(payload.originalSessionId)
285
+ lineage.branchSessionId = asTrimmedString(payload.branchSessionId)
286
+ lineage.branchIndex = asNumber(payload.branchIndex)
287
+ lineage.copiedMessageCount = asNumber(payload.copiedMessageCount)
288
+ lineage.branchPurpose = asTrimmedString(payload.branchPurpose)
289
+
290
+ if (payload.relatedIssues !== undefined) {
291
+ lineage.relatedIssues = normalizeList(payload.relatedIssues)
292
+ }
293
+
294
+ const handoffState = asTrimmedString(payload.handoffState)
295
+ if (handoffState) {
296
+ if (SESSION_HANDOFF_STATES.includes(handoffState as (typeof SESSION_HANDOFF_STATES)[number])) {
297
+ lineage.handoffState = handoffState as SessionLineageMetadata["handoffState"]
298
+ } else {
299
+ warnings.push(buildWarning(`Session lineage handoffState \"${handoffState}\" is not recognized.`, record))
300
+ }
301
+ }
302
+
303
+ return {
304
+ lineage: Object.fromEntries(Object.entries(lineage).filter(([, value]) => value !== undefined)) as Partial<SessionLineageMetadata>,
305
+ warnings,
306
+ }
307
+ }
308
+
309
+ function normalizeSessionContextPayload(payload: Record<string, any>, record?: EntityRecord): {
310
+ context: Partial<SessionContextMetadata>
311
+ warnings: MetadataWarning[]
312
+ } {
313
+ const warnings: MetadataWarning[] = []
314
+ const context: Partial<SessionContextMetadata> = {}
315
+
316
+ context.mode = asTrimmedString(payload.mode)
317
+ context.setting = asTrimmedString(payload.setting)
318
+ context.privateChroniclePath = asTrimmedString(payload.privateChroniclePath)
319
+ context.handle = asTrimmedString(payload.handle)
320
+
321
+ const publicSummaryAllowed = payload.publicSummaryAllowed
322
+ if (typeof publicSummaryAllowed === "boolean") {
323
+ context.publicSummaryAllowed = publicSummaryAllowed
324
+ } else if (typeof publicSummaryAllowed === "string") {
325
+ const normalized = publicSummaryAllowed.toLowerCase().trim()
326
+ if (normalized === "true" || normalized === "yes") {
327
+ context.publicSummaryAllowed = true
328
+ } else if (normalized === "false" || normalized === "no") {
329
+ context.publicSummaryAllowed = false
330
+ }
331
+ }
332
+
333
+ const captureQuality = asTrimmedString(payload.captureQuality)
334
+ if (captureQuality) {
335
+ if (SESSION_CAPTURE_QUALITIES.includes(captureQuality as (typeof SESSION_CAPTURE_QUALITIES)[number])) {
336
+ context.captureQuality = captureQuality as SessionContextMetadata["captureQuality"]
337
+ } else {
338
+ warnings.push(buildWarning(`Session context captureQuality "${captureQuality}" is not recognized.`, record))
339
+ }
340
+ }
341
+
342
+ const continuationKind = asTrimmedString(payload.continuationKind)
343
+ if (continuationKind) {
344
+ if (SESSION_CONTINUATION_KINDS.includes(continuationKind as (typeof SESSION_CONTINUATION_KINDS)[number])) {
345
+ context.continuationKind = continuationKind as SessionContextMetadata["continuationKind"]
346
+ } else {
347
+ warnings.push(buildWarning(`Session context continuationKind "${continuationKind}" is not recognized.`, record))
348
+ }
349
+ }
350
+
351
+ return {
352
+ context: Object.fromEntries(Object.entries(context).filter(([, value]) => value !== undefined)) as Partial<SessionContextMetadata>,
353
+ warnings,
354
+ }
355
+ }
356
+
357
+ function parseIssueReference(value: string, key: string): ParsedIssueReference {
358
+ const raw = value.trim()
359
+ const urlMatch = raw.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)/i)
360
+ if (urlMatch) {
361
+ const [, owner, repo, , number] = urlMatch
362
+ return {
363
+ key,
364
+ raw,
365
+ label: `${owner}/${repo}#${number}`,
366
+ href: raw,
367
+ owner,
368
+ repo,
369
+ number: Number(number),
370
+ }
371
+ }
372
+
373
+ const repoMatch = raw.match(/^([^/\s]+)\/([^#\s]+)#(\d+)$/)
374
+ if (repoMatch) {
375
+ const [, owner, repo, number] = repoMatch
376
+ return {
377
+ key,
378
+ raw,
379
+ label: `${owner}/${repo}#${number}`,
380
+ href: `https://github.com/${owner}/${repo}/issues/${number}`,
381
+ owner,
382
+ repo,
383
+ number: Number(number),
384
+ }
385
+ }
386
+
387
+ const shortMatch = raw.match(/^#?(\d+)$/)
388
+ if (shortMatch) {
389
+ const [, number] = shortMatch
390
+ return {
391
+ key,
392
+ raw,
393
+ label: `#${number}`,
394
+ number: Number(number),
395
+ }
396
+ }
397
+
398
+ return {
399
+ key,
400
+ raw,
401
+ label: raw,
402
+ }
403
+ }
404
+
405
+ export function getNarrativeBeatAnchorId(beat: Pick<EntityRecord, "name" | "metadata">, index = 0): string {
406
+ const seed =
407
+ asTrimmedString(beat.metadata?.beatId) ||
408
+ asTrimmedString(beat.metadata?.sourceBeat) ||
409
+ asTrimmedString(beat.metadata?.beatKey) ||
410
+ beat.name ||
411
+ `beat-${index + 1}`
412
+
413
+ return `beat-${slugify(seed)}`
414
+ }
415
+
416
+ function buildBeatAnchorLookup(beats: EntityRecord[]): Map<string, string> {
417
+ const lookup = new Map<string, string>()
418
+
419
+ beats.forEach((beat, index) => {
420
+ const anchor = getNarrativeBeatAnchorId(beat, index)
421
+ const candidates = [
422
+ beat.name,
423
+ asTrimmedString(beat.metadata?.beatId),
424
+ asTrimmedString(beat.metadata?.sourceBeat),
425
+ asTrimmedString(beat.metadata?.beatKey),
426
+ asTrimmedString(beat.observations?.[0]),
427
+ anchor.replace(/^beat-/, ""),
428
+ ].filter((value): value is string => Boolean(value))
429
+
430
+ for (const candidate of candidates) {
431
+ lookup.set(toLookupKey(candidate), anchor)
432
+ lookup.set(slugify(candidate), anchor)
433
+ }
434
+ })
435
+
436
+ return lookup
437
+ }
438
+
439
+ function buildFoundationIssueRefs(foundation: Partial<FoundationMetadata>): ParsedIssueReference[] {
440
+ const issueFields: Array<keyof Pick<
441
+ FoundationMetadata,
442
+ "parentIssue" | "baselineIssue" | "inquiryIssue" | "protocolIssue" | "schemaIssue" | "visualizerIssue"
443
+ >> = ["parentIssue", "baselineIssue", "inquiryIssue", "protocolIssue", "schemaIssue", "visualizerIssue"]
444
+
445
+ return issueFields
446
+ .map((field) => foundation[field] ? parseIssueReference(foundation[field]!, field) : undefined)
447
+ .filter((value): value is ParsedIssueReference => Boolean(value))
448
+ }
449
+
450
+ export function parseFoundationMetadata(records: EntityRecord[]): FoundationProjection | undefined {
451
+ const foundation: Partial<FoundationMetadata> = {}
452
+ const warnings: MetadataWarning[] = []
453
+ const sourceRecords = new Set<string>()
454
+ let foundFoundationMetadata = false
455
+
456
+ for (const record of records) {
457
+ if (!isObject(record.metadata)) continue
458
+
459
+ for (const payload of foundationPayloads(record.metadata)) {
460
+ foundFoundationMetadata = true
461
+ sourceRecords.add(record.name)
462
+ const normalized = normalizeFoundationPayload(payload, record)
463
+ warnings.push(...normalized.warnings)
464
+
465
+ for (const [key, value] of Object.entries(normalized.foundation) as Array<[keyof FoundationMetadata, FoundationMetadata[keyof FoundationMetadata]]>) {
466
+ if (foundation[key] === undefined) {
467
+ foundation[key] = value
468
+ }
469
+ }
470
+ }
471
+ }
472
+
473
+ if (!foundFoundationMetadata) {
474
+ return undefined
475
+ }
476
+
477
+ for (const field of FOUNDATION_REQUIRED_FIELDS) {
478
+ const value = foundation[field]
479
+ const isMissing = value === undefined || (Array.isArray(value) && value.length === 0)
480
+ if (isMissing) {
481
+ warnings.push(buildWarning(`Foundation metadata is missing required field \"${field}\".`))
482
+ }
483
+ }
484
+
485
+ const artifactDiff = Array.from(
486
+ new Set([...(foundation.expectedArtifacts || []), ...(foundation.producedArtifacts || [])]),
487
+ )
488
+ .sort((left, right) => left.localeCompare(right))
489
+ .map((artifact) => ({
490
+ artifact,
491
+ expected: (foundation.expectedArtifacts || []).includes(artifact),
492
+ produced: (foundation.producedArtifacts || []).includes(artifact),
493
+ status: (foundation.expectedArtifacts || []).includes(artifact)
494
+ ? (foundation.producedArtifacts || []).includes(artifact)
495
+ ? "matched"
496
+ : "missing"
497
+ : "unexpected",
498
+ }))
499
+
500
+ for (const artifact of artifactDiff) {
501
+ if (artifact.status === "missing") {
502
+ warnings.push(buildWarning(`Expected artifact \"${artifact.artifact}\" has not been produced yet.`))
503
+ }
504
+ if (artifact.status === "unexpected") {
505
+ warnings.push(buildWarning(`Produced artifact \"${artifact.artifact}\" is not listed in expectedArtifacts.`))
506
+ }
507
+ }
508
+
509
+ return {
510
+ foundation,
511
+ warnings: dedupeWarnings(warnings),
512
+ issueRefs: buildFoundationIssueRefs(foundation),
513
+ artifactDiff,
514
+ sourceRecords: Array.from(sourceRecords),
515
+ }
516
+ }
517
+
518
+ export function parseSessionLineageMetadata(
519
+ records: EntityRecord[],
520
+ beats: EntityRecord[] = [],
521
+ ): SessionLineageProjection | undefined {
522
+ const beatAnchors = buildBeatAnchorLookup(beats)
523
+ const branches: SessionLineageBranchProjection[] = []
524
+
525
+ for (const record of records) {
526
+ if (!isObject(record.metadata)) continue
527
+
528
+ for (const payload of lineagePayloads(record.metadata)) {
529
+ const normalized = normalizeSessionLineagePayload(payload, record)
530
+ if (Object.keys(normalized.lineage).length === 0 && normalized.warnings.length === 0) {
531
+ continue
532
+ }
533
+
534
+ const entryWarnings = [...normalized.warnings]
535
+ for (const field of SESSION_LINEAGE_REQUIRED_FIELDS) {
536
+ const value = normalized.lineage[field]
537
+ const isMissing = value === undefined || (Array.isArray(value) && value.length === 0)
538
+ if (isMissing) {
539
+ entryWarnings.push(buildWarning(`Session lineage metadata is missing required field \"${field}\".`, record))
540
+ }
541
+ }
542
+
543
+ const sourceBeat = normalized.lineage.sourceBeat
544
+ const sourceBeatAnchor = sourceBeat
545
+ ? beatAnchors.get(toLookupKey(sourceBeat)) || beatAnchors.get(slugify(sourceBeat))
546
+ : undefined
547
+
548
+ branches.push({
549
+ metadata: normalized.lineage,
550
+ sourceRecordName: record.name,
551
+ sourceRecordType: record.entityType,
552
+ warnings: dedupeWarnings(entryWarnings),
553
+ relatedIssueRefs: (normalized.lineage.relatedIssues || []).map((issue, index) =>
554
+ parseIssueReference(issue, `relatedIssues.${index}`),
555
+ ),
556
+ sourceBeatAnchor,
557
+ sourceBeatExistsInChart: Boolean(sourceBeatAnchor),
558
+ })
559
+ }
560
+ }
561
+
562
+ if (branches.length === 0) {
563
+ return undefined
564
+ }
565
+
566
+ branches.sort((left, right) => {
567
+ const sourceBeatCompare = (left.metadata.sourceBeat || "").localeCompare(right.metadata.sourceBeat || "")
568
+ if (sourceBeatCompare !== 0) return sourceBeatCompare
569
+
570
+ const indexCompare = (left.metadata.branchIndex ?? Number.MAX_SAFE_INTEGER) - (right.metadata.branchIndex ?? Number.MAX_SAFE_INTEGER)
571
+ if (indexCompare !== 0) return indexCompare
572
+
573
+ return (left.sourceRecordName || "").localeCompare(right.sourceRecordName || "")
574
+ })
575
+
576
+ const grouped = new Map<string, SessionLineageGroupProjection>()
577
+ for (const branch of branches) {
578
+ const sourceBeat = branch.metadata.sourceBeat || "Unassigned source beat"
579
+ const existing = grouped.get(sourceBeat)
580
+ if (existing) {
581
+ existing.branches.push(branch)
582
+ continue
583
+ }
584
+
585
+ grouped.set(sourceBeat, {
586
+ sourceBeat,
587
+ sourceBeatAnchor: branch.sourceBeatAnchor,
588
+ sourceBeatExistsInChart: branch.sourceBeatExistsInChart,
589
+ branches: [branch],
590
+ })
591
+ }
592
+
593
+ return {
594
+ branches,
595
+ groupedBranches: Array.from(grouped.values()),
596
+ warnings: dedupeWarnings(branches.flatMap((branch) => branch.warnings)),
597
+ }
598
+ }
599
+
600
+ export function parseSessionContextMetadata(beat: EntityRecord, beatIndex: number): SessionContextProjection | undefined {
601
+ if (!isObject(beat.metadata)) return undefined
602
+
603
+ let context: Partial<SessionContextMetadata> = {}
604
+ let warnings: MetadataWarning[] = []
605
+ let foundSessionContext = false
606
+
607
+ for (const payload of sessionContextPayloads(beat.metadata)) {
608
+ foundSessionContext = true
609
+ const normalized = normalizeSessionContextPayload(payload, beat)
610
+ warnings.push(...normalized.warnings)
611
+
612
+ for (const [key, value] of Object.entries(normalized.context) as Array<[keyof SessionContextMetadata, SessionContextMetadata[keyof SessionContextMetadata]]>) {
613
+ if (context[key] === undefined) {
614
+ context[key] = value
615
+ }
616
+ }
617
+ }
618
+
619
+ if (!foundSessionContext) {
620
+ return undefined
621
+ }
622
+
623
+ for (const field of SESSION_CONTEXT_REQUIRED_FIELDS) {
624
+ const value = context[field]
625
+ const isMissing = value === undefined || (Array.isArray(value) && value.length === 0)
626
+ if (isMissing) {
627
+ warnings.push(buildWarning(`Session context is missing required field "${field}".`, beat))
628
+ }
629
+ }
630
+
631
+ if (context.captureQuality === "partial" || context.captureQuality === "noisy") {
632
+ warnings.push({
633
+ severity: "warning",
634
+ recordName: beat.name,
635
+ recordType: beat.entityType,
636
+ message: `Capture quality is "${context.captureQuality}" - record may be incomplete or contain transcription errors.`,
637
+ })
638
+ }
639
+
640
+ if (context.privateChroniclePath && !context.handle) {
641
+ warnings.push({
642
+ severity: "warning",
643
+ recordName: beat.name,
644
+ recordType: beat.entityType,
645
+ message: "privateChroniclePath is present but no handle is specified - Chronicle reference may not be navigable.",
646
+ })
647
+ }
648
+
649
+ return {
650
+ context,
651
+ warnings: dedupeWarnings(warnings),
652
+ beatName: beat.name,
653
+ beatIndex,
654
+ }
655
+ }
656
+
657
+ function getAsterionSourceRecords(chart: Chart): EntityRecord[] {
658
+ return [
659
+ chart.chartEntity,
660
+ ...chart.actions,
661
+ ...chart.narrativeBeats,
662
+ chart.desiredOutcome,
663
+ chart.currentReality,
664
+ ].filter((record): record is EntityRecord => Boolean(record))
665
+ }
666
+
667
+ export function extractAsterionMetadata(chart: Chart): Pick<Chart, "foundationProjection" | "sessionLineageProjection"> {
668
+ const records = getAsterionSourceRecords(chart)
669
+ return {
670
+ foundationProjection: parseFoundationMetadata(records),
671
+ sessionLineageProjection: parseSessionLineageMetadata(records, chart.narrativeBeats),
672
+ }
673
+ }
@@ -1,6 +1,7 @@
1
1
  // lib/chart-editor.ts - Chart editing logic
2
2
 
3
3
  import type { EntityRecord, RelationRecord, ParsedData } from "./types.ts"
4
+ import { extractAsterionMetadata } from "./asterion-metadata.ts"
4
5
  import { buildMetadataWarnings, buildRecordProjections, serializeParsedData } from "./jsonl-preservation.ts"
5
6
  import { buildRiseSpecs } from "./jsonl-parser.ts"
6
7
 
@@ -604,6 +605,10 @@ export class ChartEditor {
604
605
  }
605
606
  }
606
607
 
608
+ chartsArray.forEach((chart) => {
609
+ Object.assign(chart, extractAsterionMetadata(chart))
610
+ })
611
+
607
612
  const rootCharts = chartsArray.filter((c) => !c.parentChart)
608
613
 
609
614
  return {
@@ -1,4 +1,5 @@
1
1
  import type { Chart, EntityRecord, JSONLRecord, ParsedData, RawJSONLRecord, RelationRecord, RiseSpecProjection } from "./types.ts"
2
+ import { extractAsterionMetadata } from "./asterion-metadata.ts"
2
3
  import { buildMetadataWarnings, buildRecordProjections, normalizeJsonlRecord } from "./jsonl-preservation.ts"
3
4
 
4
5
  export function parseJSONL(content: string): RawJSONLRecord[] {
@@ -192,6 +193,10 @@ export function organizeData(records: RawJSONLRecord[]): ParsedData {
192
193
  }
193
194
  }
194
195
 
196
+ for (const chart of charts.values()) {
197
+ Object.assign(chart, extractAsterionMetadata(chart))
198
+ }
199
+
195
200
  // Identify root charts (level 0 or no parent)
196
201
  const rootCharts = Array.from(charts.values()).filter((chart) => chart.level === 0 || !chart.parentChart)
197
202