create-project-arch 1.0.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.
Files changed (90) hide show
  1. package/README.md +58 -0
  2. package/dist/cli.js +232 -0
  3. package/dist/cli.test.js +8 -0
  4. package/package.json +29 -0
  5. package/templates/arch-ui/.arch/edges/decision_to_domain.json +4 -0
  6. package/templates/arch-ui/.arch/edges/milestone_to_task.json +4 -0
  7. package/templates/arch-ui/.arch/edges/task_to_decision.json +4 -0
  8. package/templates/arch-ui/.arch/edges/task_to_module.json +4 -0
  9. package/templates/arch-ui/.arch/graph.json +17 -0
  10. package/templates/arch-ui/.arch/nodes/decisions.json +4 -0
  11. package/templates/arch-ui/.arch/nodes/domains.json +4 -0
  12. package/templates/arch-ui/.arch/nodes/milestones.json +4 -0
  13. package/templates/arch-ui/.arch/nodes/modules.json +4 -0
  14. package/templates/arch-ui/.arch/nodes/tasks.json +4 -0
  15. package/templates/arch-ui/app/api/architecture/map/route.ts +13 -0
  16. package/templates/arch-ui/app/api/decisions/route.ts +23 -0
  17. package/templates/arch-ui/app/api/domain-docs/route.ts +89 -0
  18. package/templates/arch-ui/app/api/domains/route.ts +10 -0
  19. package/templates/arch-ui/app/api/graph/route.ts +16 -0
  20. package/templates/arch-ui/app/api/health/route.ts +44 -0
  21. package/templates/arch-ui/app/api/node-files/route.ts +173 -0
  22. package/templates/arch-ui/app/api/phases/route.ts +10 -0
  23. package/templates/arch-ui/app/api/route.ts +22 -0
  24. package/templates/arch-ui/app/api/search/route.ts +56 -0
  25. package/templates/arch-ui/app/api/task-doc/[taskId]/route.ts +60 -0
  26. package/templates/arch-ui/app/api/tasks/route.ts +36 -0
  27. package/templates/arch-ui/app/api/trace/file/route.ts +40 -0
  28. package/templates/arch-ui/app/api/trace/task/[taskId]/route.ts +12 -0
  29. package/templates/arch-ui/app/architecture/page.tsx +5 -0
  30. package/templates/arch-ui/app/globals.css +240 -0
  31. package/templates/arch-ui/app/health/page.tsx +48 -0
  32. package/templates/arch-ui/app/layout.tsx +19 -0
  33. package/templates/arch-ui/app/page.tsx +5 -0
  34. package/templates/arch-ui/app/work/page.tsx +265 -0
  35. package/templates/arch-ui/components/app-shell.tsx +171 -0
  36. package/templates/arch-ui/components/error-boundary.tsx +53 -0
  37. package/templates/arch-ui/components/graph/arch-node.tsx +77 -0
  38. package/templates/arch-ui/components/graph/build-graph-from-dataset.ts +196 -0
  39. package/templates/arch-ui/components/graph/build-initial-graph.ts +245 -0
  40. package/templates/arch-ui/components/graph/graph-context-menu.tsx +84 -0
  41. package/templates/arch-ui/components/graph/graph-doc-panel.tsx +46 -0
  42. package/templates/arch-ui/components/graph/graph-types.ts +82 -0
  43. package/templates/arch-ui/components/graph/use-auto-layout.ts +65 -0
  44. package/templates/arch-ui/components/graph/use-connection-validation.ts +62 -0
  45. package/templates/arch-ui/components/graph/use-flow-persistence.ts +48 -0
  46. package/templates/arch-ui/components/graph-canvas.tsx +670 -0
  47. package/templates/arch-ui/components/health-panel.tsx +49 -0
  48. package/templates/arch-ui/components/inspector-context.tsx +35 -0
  49. package/templates/arch-ui/components/inspector.tsx +895 -0
  50. package/templates/arch-ui/components/markdown-viewer.tsx +74 -0
  51. package/templates/arch-ui/components/sidebar.tsx +531 -0
  52. package/templates/arch-ui/components/topbar.tsx +187 -0
  53. package/templates/arch-ui/components/work-table.tsx +57 -0
  54. package/templates/arch-ui/components/workspace-context.tsx +274 -0
  55. package/templates/arch-ui/eslint.config.js +2 -0
  56. package/templates/arch-ui/global.d.ts +1 -0
  57. package/templates/arch-ui/lib/api.ts +93 -0
  58. package/templates/arch-ui/lib/arch-model.ts +113 -0
  59. package/templates/arch-ui/lib/graph-dataset.ts +756 -0
  60. package/templates/arch-ui/lib/graph-schema.ts +408 -0
  61. package/templates/arch-ui/lib/project-root.ts +52 -0
  62. package/templates/arch-ui/lib/types.ts +116 -0
  63. package/templates/arch-ui/next-env.d.ts +6 -0
  64. package/templates/arch-ui/next.config.js +17 -0
  65. package/templates/arch-ui/package.json +38 -0
  66. package/templates/arch-ui/postcss.config.mjs +6 -0
  67. package/templates/arch-ui/tailwind.config.ts +11 -0
  68. package/templates/arch-ui/tsconfig.json +21 -0
  69. package/templates/ui-package/eslint.config.mjs +4 -0
  70. package/templates/ui-package/package.json +26 -0
  71. package/templates/ui-package/src/accordion.tsx +10 -0
  72. package/templates/ui-package/src/badge.tsx +12 -0
  73. package/templates/ui-package/src/button.tsx +32 -0
  74. package/templates/ui-package/src/card.tsx +22 -0
  75. package/templates/ui-package/src/code.tsx +6 -0
  76. package/templates/ui-package/src/command.tsx +18 -0
  77. package/templates/ui-package/src/dialog.tsx +6 -0
  78. package/templates/ui-package/src/dropdown-menu.tsx +10 -0
  79. package/templates/ui-package/src/input.tsx +6 -0
  80. package/templates/ui-package/src/navigation-menu.tsx +6 -0
  81. package/templates/ui-package/src/scroll-area.tsx +6 -0
  82. package/templates/ui-package/src/select.tsx +6 -0
  83. package/templates/ui-package/src/separator.tsx +6 -0
  84. package/templates/ui-package/src/sheet.tsx +6 -0
  85. package/templates/ui-package/src/skeleton.tsx +6 -0
  86. package/templates/ui-package/src/table.tsx +26 -0
  87. package/templates/ui-package/src/tabs.tsx +14 -0
  88. package/templates/ui-package/src/toggle-group.tsx +10 -0
  89. package/templates/ui-package/src/utils.ts +3 -0
  90. package/templates/ui-package/tsconfig.json +10 -0
@@ -0,0 +1,895 @@
1
+ "use client";
2
+
3
+ import { Badge } from "@repo/ui/badge";
4
+ import { Button } from "@repo/ui/button";
5
+ import { Code } from "@repo/ui/code";
6
+ import { Separator } from "@repo/ui/separator";
7
+ import { ReactNode, useEffect, useMemo, useState } from "react";
8
+ import { getArchitectureMap, getGraphDataset, getNodeFiles, getTaskTrace } from "../lib/api";
9
+ import { ArchitectureMapData, GraphValidationIssueData } from "../lib/types";
10
+ import { MarkdownViewer } from "./markdown-viewer";
11
+ import { useInspector } from "./inspector-context";
12
+ import { useWorkspace } from "./workspace-context";
13
+
14
+ type TracePayload = {
15
+ task?: string;
16
+ decisionRefs?: string[];
17
+ moduleRefs?: string[];
18
+ files?: string[];
19
+ };
20
+
21
+ type EntityDetails = {
22
+ summary: Array<{ label: string; value: string }>;
23
+ relatedTasks: string[];
24
+ relatedMilestones: string[];
25
+ relatedDecisions: string[];
26
+ relatedModules: string[];
27
+ };
28
+
29
+ type MetadataRecord = {
30
+ label: string;
31
+ value: string;
32
+ };
33
+
34
+ type FrontmatterValue = string | string[] | null;
35
+ type FrontmatterData = Record<string, FrontmatterValue>;
36
+
37
+ function parseFrontmatter(markdown: string): FrontmatterData {
38
+ const normalized = markdown.replace(/\r\n/g, "\n");
39
+ if (!normalized.startsWith("---\n")) return {};
40
+ const end = normalized.indexOf("\n---\n", 4);
41
+ if (end === -1) return {};
42
+
43
+ const body = normalized.slice(4, end);
44
+ const lines = body.split("\n");
45
+ const entries: FrontmatterData = {};
46
+ let currentKey: string | null = null;
47
+ let arrayValues: string[] = [];
48
+
49
+ function flushArray() {
50
+ if (!currentKey) return;
51
+ entries[currentKey] = arrayValues;
52
+ currentKey = null;
53
+ arrayValues = [];
54
+ }
55
+
56
+ function normalizeScalar(value: string): FrontmatterValue {
57
+ const unquoted = value.replace(/^['"]|['"]$/g, "").trim();
58
+ if (unquoted.toLowerCase() === "null") return null;
59
+ if (unquoted === "[]") return [];
60
+ if (/^\[.*\]$/.test(unquoted)) {
61
+ const inner = unquoted.slice(1, -1).trim();
62
+ if (!inner) return [];
63
+ return inner
64
+ .split(",")
65
+ .map((part) => part.trim().replace(/^['"]|['"]$/g, ""))
66
+ .filter(Boolean);
67
+ }
68
+ return unquoted;
69
+ }
70
+
71
+ for (const rawLine of lines) {
72
+ const line = rawLine.trimEnd();
73
+ if (!line.trim()) continue;
74
+
75
+ const arrayMatch = line.match(/^\s*-\s+(.*)$/);
76
+ if (arrayMatch && currentKey) {
77
+ const normalizedItem = normalizeScalar(arrayMatch[1]?.trim() ?? "");
78
+ if (typeof normalizedItem === "string" && normalizedItem.length > 0) {
79
+ arrayValues.push(normalizedItem);
80
+ }
81
+ continue;
82
+ }
83
+
84
+ flushArray();
85
+ const keyValueMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
86
+ if (!keyValueMatch) continue;
87
+
88
+ const key = keyValueMatch[1] ?? "";
89
+ const rawValue = (keyValueMatch[2] ?? "").trim();
90
+ if (rawValue === "") {
91
+ currentKey = key;
92
+ arrayValues = [];
93
+ continue;
94
+ }
95
+ entries[key] = normalizeScalar(rawValue);
96
+ }
97
+
98
+ flushArray();
99
+ return entries;
100
+ }
101
+
102
+ function stripFrontmatter(markdown: string): string {
103
+ const normalized = markdown.replace(/\r\n/g, "\n");
104
+ if (!normalized.startsWith("---\n")) {
105
+ return markdown;
106
+ }
107
+ const end = normalized.indexOf("\n---\n", 4);
108
+ if (end === -1) {
109
+ return markdown;
110
+ }
111
+ return normalized.slice(end + 5).trimStart();
112
+ }
113
+
114
+ function uniqueSorted(values: string[]): string[] {
115
+ return [...new Set(values)].sort((a, b) => a.localeCompare(b));
116
+ }
117
+
118
+ function isTaskLikeType(type: string): type is "task" | "milestone" | "phase" {
119
+ return type === "task" || type === "milestone" || type === "phase";
120
+ }
121
+
122
+ function isFileBackedType(type: string): type is "task" | "milestone" | "phase" | "domain" | "file" {
123
+ return (
124
+ type === "task" ||
125
+ type === "milestone" ||
126
+ type === "phase" ||
127
+ type === "domain" ||
128
+ type === "file"
129
+ );
130
+ }
131
+
132
+ function normalizeKey(label: string): string {
133
+ return label.toLowerCase().replace(/[^a-z0-9]/g, "");
134
+ }
135
+
136
+ function titleCase(value: string): string {
137
+ return value
138
+ .split(/\s+/)
139
+ .filter(Boolean)
140
+ .map((part) => part[0]?.toUpperCase() + part.slice(1).toLowerCase())
141
+ .join(" ");
142
+ }
143
+
144
+ function humanizeSlug(slug: string): string {
145
+ if (!slug) return "n/a";
146
+ const converted = slug
147
+ .replace(/[_-]+/g, " ")
148
+ .replace(/\b(\d+)\b/g, "$1")
149
+ .trim();
150
+ return titleCase(converted);
151
+ }
152
+
153
+ function humanizeLabel(label: string): string {
154
+ const cleaned = label.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ");
155
+ return titleCase(cleaned);
156
+ }
157
+
158
+ function humanizeValue(label: string, value: string): string {
159
+ if (!value) return "n/a";
160
+
161
+ const key = normalizeKey(label);
162
+ if (key === "lane" || key === "status") {
163
+ if (value.toLowerCase() === "todo") return "To Do";
164
+ return humanizeSlug(value);
165
+ }
166
+
167
+ if (key === "domain") {
168
+ return value === "unassigned" ? "Unassigned" : humanizeSlug(value);
169
+ }
170
+
171
+ if (key.includes("count")) {
172
+ return value;
173
+ }
174
+
175
+ if (key === "graphnode") {
176
+ return value;
177
+ }
178
+
179
+ if (value.includes("/")) {
180
+ return value
181
+ .split("/")
182
+ .map((segment) => humanizeSlug(segment))
183
+ .join(" > ");
184
+ }
185
+
186
+ if (/^[a-z0-9-]+$/.test(value)) {
187
+ return humanizeSlug(value);
188
+ }
189
+
190
+ return value;
191
+ }
192
+
193
+ function parseTaskId(taskId: string): { phaseId?: string; milestoneId?: string; taskNumber?: string } {
194
+ const [phaseId, milestoneId, taskNumber] = taskId.split("/");
195
+ return {
196
+ phaseId: phaseId || undefined,
197
+ milestoneId: milestoneId || undefined,
198
+ taskNumber: taskNumber || undefined,
199
+ };
200
+ }
201
+
202
+ function isDateLikeValue(value: string): boolean {
203
+ return /^\d{4}-\d{2}-\d{2}$/.test(value);
204
+ }
205
+
206
+ function formatDateValue(value: string): string {
207
+ if (!isDateLikeValue(value)) return value;
208
+ const parsed = new Date(`${value}T00:00:00`);
209
+ if (Number.isNaN(parsed.getTime())) return value;
210
+ return parsed.toLocaleDateString("en-US", {
211
+ year: "numeric",
212
+ month: "short",
213
+ day: "numeric",
214
+ });
215
+ }
216
+
217
+ function createMetadataMap(entries: MetadataRecord[]): Map<string, MetadataRecord> {
218
+ const map = new Map<string, MetadataRecord>();
219
+ entries.forEach((entry) => {
220
+ const key = normalizeKey(entry.label);
221
+ if (!map.has(key) && entry.value) {
222
+ map.set(key, entry);
223
+ }
224
+ });
225
+ return map;
226
+ }
227
+
228
+ function getMetadataValue(map: Map<string, MetadataRecord>, ...keys: string[]): string | undefined {
229
+ for (const key of keys) {
230
+ const entry = map.get(normalizeKey(key));
231
+ if (entry?.value) return entry.value;
232
+ }
233
+ return undefined;
234
+ }
235
+
236
+ function deriveEntityDetails(
237
+ map: ArchitectureMapData,
238
+ selectionType: "task" | "milestone" | "phase",
239
+ selectionId: string,
240
+ ): EntityDetails {
241
+ if (selectionType === "task") {
242
+ const task = map.nodes.tasks.find((item) => item.id === selectionId);
243
+ const milestone = task ? map.nodes.milestones.find((m) => m.id === task.milestone) : null;
244
+ const decisions = uniqueSorted(
245
+ map.edges.taskToDecision
246
+ .filter((edge) => edge.task === selectionId)
247
+ .map((edge) => edge.decision),
248
+ );
249
+ const modules = uniqueSorted(
250
+ map.edges.taskToModule.filter((edge) => edge.task === selectionId).map((edge) => edge.module),
251
+ );
252
+
253
+ return {
254
+ summary: [
255
+ { label: "Task ID", value: selectionId },
256
+ { label: "Title", value: task?.title ?? "n/a" },
257
+ { label: "Lane", value: task?.lane ?? "n/a" },
258
+ { label: "Status", value: task?.status ?? "n/a" },
259
+ { label: "Domain", value: task?.domain ?? "unassigned" },
260
+ { label: "Milestone", value: task?.milestone ?? "n/a" },
261
+ { label: "Phase", value: milestone?.phaseId ?? "n/a" },
262
+ ],
263
+ relatedTasks: [selectionId],
264
+ relatedMilestones: uniqueSorted(task?.milestone ? [task.milestone] : []),
265
+ relatedDecisions: decisions,
266
+ relatedModules: modules,
267
+ };
268
+ }
269
+
270
+ if (selectionType === "milestone") {
271
+ const milestone = map.nodes.milestones.find((item) => item.id === selectionId);
272
+ const taskIds = uniqueSorted(
273
+ map.edges.milestoneToTask
274
+ .filter((edge) => edge.milestone === selectionId)
275
+ .map((edge) => edge.task),
276
+ );
277
+ const taskIdSet = new Set(taskIds);
278
+ const decisions = uniqueSorted(
279
+ map.edges.taskToDecision
280
+ .filter((edge) => taskIdSet.has(edge.task))
281
+ .map((edge) => edge.decision),
282
+ );
283
+ const modules = uniqueSorted(
284
+ map.edges.taskToModule.filter((edge) => taskIdSet.has(edge.task)).map((edge) => edge.module),
285
+ );
286
+
287
+ return {
288
+ summary: [
289
+ { label: "Milestone ID", value: selectionId },
290
+ { label: "Phase", value: milestone?.phaseId ?? "n/a" },
291
+ { label: "Milestone Slug", value: milestone?.milestoneId ?? "n/a" },
292
+ { label: "Task Count", value: String(taskIds.length) },
293
+ { label: "Decision Count", value: String(decisions.length) },
294
+ { label: "Module Count", value: String(modules.length) },
295
+ ],
296
+ relatedTasks: taskIds,
297
+ relatedMilestones: [selectionId],
298
+ relatedDecisions: decisions,
299
+ relatedModules: modules,
300
+ };
301
+ }
302
+
303
+ const phaseMilestones = uniqueSorted(
304
+ map.nodes.milestones.filter((item) => item.phaseId === selectionId).map((item) => item.id),
305
+ );
306
+ const milestoneSet = new Set(phaseMilestones);
307
+ const taskIds = uniqueSorted(
308
+ map.edges.milestoneToTask
309
+ .filter((edge) => milestoneSet.has(edge.milestone))
310
+ .map((edge) => edge.task),
311
+ );
312
+ const taskIdSet = new Set(taskIds);
313
+ const decisions = uniqueSorted(
314
+ map.edges.taskToDecision
315
+ .filter((edge) => taskIdSet.has(edge.task))
316
+ .map((edge) => edge.decision),
317
+ );
318
+ const modules = uniqueSorted(
319
+ map.edges.taskToModule.filter((edge) => taskIdSet.has(edge.task)).map((edge) => edge.module),
320
+ );
321
+
322
+ return {
323
+ summary: [
324
+ { label: "Phase ID", value: selectionId },
325
+ { label: "Milestone Count", value: String(phaseMilestones.length) },
326
+ { label: "Task Count", value: String(taskIds.length) },
327
+ { label: "Decision Count", value: String(decisions.length) },
328
+ { label: "Module Count", value: String(modules.length) },
329
+ ],
330
+ relatedTasks: taskIds,
331
+ relatedMilestones: phaseMilestones,
332
+ relatedDecisions: decisions,
333
+ relatedModules: modules,
334
+ };
335
+ }
336
+
337
+ function Field({ label, value }: { label: string; value: string }) {
338
+ return (
339
+ <p className="m-0 leading-tight">
340
+ <span className="text-slate-400">{humanizeLabel(label)}</span>
341
+ <br />
342
+ {humanizeValue(label, value)}
343
+ </p>
344
+ );
345
+ }
346
+
347
+ function RelatedList({ label, values }: { label: string; values: string[] }) {
348
+ if (values.length === 0) {
349
+ return (
350
+ <p className="m-0 leading-tight">
351
+ <span className="text-slate-400">{label}</span>
352
+ <br />
353
+ <span className="text-slate-500">None</span>
354
+ </p>
355
+ );
356
+ }
357
+
358
+ return (
359
+ <div className="grid gap-1.5">
360
+ <p className="m-0 text-slate-400">{label}</p>
361
+ <div className="grid gap-1.5">
362
+ {values.map((value) => (
363
+ <div key={`${label}:${value}`} className="grid gap-1">
364
+ <p className="text-sm text-slate-200">{humanizeValue(label, value)}</p>
365
+ <Code>{value}</Code>
366
+ </div>
367
+ ))}
368
+ </div>
369
+ </div>
370
+ );
371
+ }
372
+
373
+ function Section({ title, children }: { title: string; children: ReactNode }) {
374
+ return (
375
+ <section className="grid gap-2 rounded-xl border border-slate-700/80 bg-slate-900/40 p-3">
376
+ <p className="m-0 text-xs uppercase tracking-[0.08em] text-slate-400">{title}</p>
377
+ {children}
378
+ </section>
379
+ );
380
+ }
381
+
382
+ function MetadataDetails({
383
+ title,
384
+ defaultOpen,
385
+ children,
386
+ }: {
387
+ title: string;
388
+ defaultOpen?: boolean;
389
+ children: ReactNode;
390
+ }) {
391
+ return (
392
+ <details
393
+ className="rounded-lg border border-slate-700/70 bg-slate-900/30 p-2 [&_summary::-webkit-details-marker]:hidden"
394
+ open={defaultOpen}
395
+ >
396
+ <summary className="cursor-pointer list-none text-sm font-medium text-slate-200">{title}</summary>
397
+ <div className="mt-2 grid gap-2">{children}</div>
398
+ </details>
399
+ );
400
+ }
401
+
402
+ function MetadataValue({
403
+ label,
404
+ value,
405
+ }: {
406
+ label: string;
407
+ value: FrontmatterValue;
408
+ }) {
409
+ if (value === null) {
410
+ return (
411
+ <div className="grid gap-1">
412
+ <p className="m-0 text-xs uppercase tracking-[0.06em] text-slate-400">{humanizeLabel(label)}</p>
413
+ <p className="m-0 text-sm text-slate-500">None</p>
414
+ </div>
415
+ );
416
+ }
417
+
418
+ if (Array.isArray(value)) {
419
+ if (value.length === 0) {
420
+ return (
421
+ <div className="grid gap-1">
422
+ <p className="m-0 text-xs uppercase tracking-[0.06em] text-slate-400">{humanizeLabel(label)}</p>
423
+ <p className="m-0 text-sm text-slate-500">None</p>
424
+ </div>
425
+ );
426
+ }
427
+
428
+ const listLike = normalizeKey(label).includes("criteria");
429
+ return (
430
+ <div className="grid gap-1.5">
431
+ <p className="m-0 text-xs uppercase tracking-[0.06em] text-slate-400">{humanizeLabel(label)}</p>
432
+ {listLike ? (
433
+ <ol className="m-0 grid list-decimal gap-1 pl-5 text-sm text-slate-200">
434
+ {value.map((item) => (
435
+ <li key={`${label}:${item}`}>{humanizeValue(label, item)}</li>
436
+ ))}
437
+ </ol>
438
+ ) : (
439
+ <div className="flex flex-wrap gap-1.5">
440
+ {value.map((item) => (
441
+ <Badge key={`${label}:${item}`} variant="secondary">
442
+ {humanizeValue(label, item)}
443
+ </Badge>
444
+ ))}
445
+ </div>
446
+ )}
447
+ </div>
448
+ );
449
+ }
450
+
451
+ return <Field label={label} value={formatDateValue(value)} />;
452
+ }
453
+
454
+ export function InspectorPanel() {
455
+ const { selection, setSelection } = useInspector();
456
+ const { rightCollapsed, setRightCollapsed } = useWorkspace();
457
+ const [trace, setTrace] = useState<TracePayload | null>(null);
458
+ const [levelFiles, setLevelFiles] = useState<Array<{ path: string; content: string }>>([]);
459
+ const [selectedLevelFilePath, setSelectedLevelFilePath] = useState<string | null>(null);
460
+ const [entityDetails, setEntityDetails] = useState<EntityDetails | null>(null);
461
+ const [graphWarnings, setGraphWarnings] = useState<GraphValidationIssueData[]>([]);
462
+
463
+ const selectedLevelFile = selectedLevelFilePath
464
+ ? (levelFiles.find((file) => file.path === selectedLevelFilePath) ?? null)
465
+ : null;
466
+ const selectedLevelFileExt = selectedLevelFile
467
+ ? (selectedLevelFile.path.split(".").pop()?.toLowerCase() ?? "")
468
+ : "";
469
+ const selectedLevelFileFrontmatter = selectedLevelFile
470
+ ? parseFrontmatter(selectedLevelFile.content)
471
+ : {};
472
+ const selectedLevelFileFrontmatterEntries = Object.entries(selectedLevelFileFrontmatter);
473
+ const [showEmptyFileMetadata, setShowEmptyFileMetadata] = useState(false);
474
+
475
+ const fileMetadataGroups = useMemo(() => {
476
+ const keyOrder = {
477
+ identity: ["schemaVersion", "id", "slug", "title"],
478
+ workflow: ["lane", "status", "createdAt", "updatedAt", "discoveredFromTask"],
479
+ references: ["tags", "codeTargets", "publicDocs", "decisions"],
480
+ criteria: ["completionCriteria"],
481
+ } as const;
482
+
483
+ const sourceEntries = new Map(selectedLevelFileFrontmatterEntries);
484
+ const pick = (keys: readonly string[]) =>
485
+ keys
486
+ .filter((key) => sourceEntries.has(key))
487
+ .map((key) => [key, sourceEntries.get(key) ?? null] as const)
488
+ .filter(([, value]) => showEmptyFileMetadata || !(value === null || (Array.isArray(value) && value.length === 0)));
489
+
490
+ return {
491
+ identity: pick(keyOrder.identity),
492
+ workflow: pick(keyOrder.workflow),
493
+ references: pick(keyOrder.references),
494
+ criteria: pick(keyOrder.criteria),
495
+ summary: [
496
+ ["id", sourceEntries.get("id") ?? null] as const,
497
+ ["schemaVersion", sourceEntries.get("schemaVersion") ?? null] as const,
498
+ ["createdAt", sourceEntries.get("createdAt") ?? null] as const,
499
+ ["updatedAt", sourceEntries.get("updatedAt") ?? null] as const,
500
+ ].filter(([, value]) => value !== null && !(Array.isArray(value) && value.length === 0)),
501
+ };
502
+ }, [selectedLevelFileFrontmatterEntries, showEmptyFileMetadata]);
503
+
504
+ const taskLikeType = selection?.type && isTaskLikeType(selection.type) ? selection.type : null;
505
+ const fileBackedType =
506
+ selection?.type && isFileBackedType(selection.type) ? selection.type : null;
507
+ const selectionId = selection?.id ?? null;
508
+ const isFileSelection = selection?.type === "file";
509
+ const hasFileDocument = Boolean(selectedLevelFile);
510
+ const fileLaneValue = selectedLevelFileFrontmatter.lane;
511
+ const fileStatusValue = selectedLevelFileFrontmatter.status;
512
+ const filteredSelectionMetadata = (selection?.metadata ?? []).filter((item) => {
513
+ if (!isFileSelection) return true;
514
+ if (!selectedLevelFile?.path) return true;
515
+ return normalizeKey(item.label) !== "path";
516
+ });
517
+
518
+ const rawTaskMetadata = useMemo(() => {
519
+ if (selection?.type !== "task") return [];
520
+ const combined = [...(selection.metadata ?? []), ...(entityDetails?.summary ?? [])];
521
+ const map = createMetadataMap(combined);
522
+
523
+ const id = selection.id ?? getMetadataValue(map, "Task ID", "ID") ?? "n/a";
524
+ const parsed = parseTaskId(id);
525
+ const phase = getMetadataValue(map, "Phase") ?? parsed.phaseId ?? "n/a";
526
+ const milestone = getMetadataValue(map, "Milestone") ?? parsed.milestoneId ?? "n/a";
527
+ const taskNumber = parsed.taskNumber ?? "n/a";
528
+ const title = getMetadataValue(map, "Title") ?? selection.title ?? "n/a";
529
+ const lane = getMetadataValue(map, "Lane") ?? "n/a";
530
+ const status = getMetadataValue(map, "Status") ?? "n/a";
531
+ const domain = getMetadataValue(map, "Domain") ?? "unassigned";
532
+ const graphNode = getMetadataValue(map, "Graph Node");
533
+
534
+ const ordered: MetadataRecord[] = [
535
+ { label: "Phase", value: phase },
536
+ { label: "Milestone", value: milestone },
537
+ { label: "Task Number", value: taskNumber },
538
+ { label: "Task ID", value: id },
539
+ { label: "Title", value: title },
540
+ { label: "Lane", value: lane },
541
+ { label: "Status", value: status },
542
+ { label: "Domain", value: domain },
543
+ ];
544
+
545
+ if (graphNode) {
546
+ ordered.push({ label: "Graph Node", value: graphNode });
547
+ }
548
+
549
+ return ordered;
550
+ }, [entityDetails?.summary, selection]);
551
+
552
+ useEffect(() => {
553
+ if (!selection?.id || selection.type !== "task") {
554
+ setTrace(null);
555
+ return;
556
+ }
557
+
558
+ void getTaskTrace(selection.id)
559
+ .then((traceResult) => {
560
+ setTrace(traceResult as TracePayload);
561
+ })
562
+ .catch(() => {
563
+ setTrace(null);
564
+ });
565
+ }, [selection]);
566
+
567
+ useEffect(() => {
568
+ setEntityDetails(null);
569
+
570
+ if (!selectionId || !taskLikeType) {
571
+ return;
572
+ }
573
+
574
+ void getArchitectureMap()
575
+ .then((map) => {
576
+ setEntityDetails(deriveEntityDetails(map, taskLikeType, selectionId));
577
+ })
578
+ .catch(() => {
579
+ setEntityDetails(null);
580
+ });
581
+ }, [selectionId, taskLikeType]);
582
+
583
+ useEffect(() => {
584
+ setLevelFiles([]);
585
+ setSelectedLevelFilePath(null);
586
+
587
+ if (!selectionId || !fileBackedType) {
588
+ return;
589
+ }
590
+
591
+ void getNodeFiles(fileBackedType, selectionId)
592
+ .then((result) => {
593
+ setLevelFiles(result.files);
594
+ const preferred =
595
+ result.files.find((file) => file.path.endsWith(".md"))?.path ??
596
+ result.files[0]?.path ??
597
+ null;
598
+ setSelectedLevelFilePath(preferred);
599
+ })
600
+ .catch(() => {
601
+ setLevelFiles([]);
602
+ setSelectedLevelFilePath(null);
603
+ });
604
+ }, [selectionId, fileBackedType]);
605
+
606
+ useEffect(() => {
607
+ void getGraphDataset()
608
+ .then((result) => setGraphWarnings(result.validation.warnings))
609
+ .catch(() => setGraphWarnings([]));
610
+ }, []);
611
+
612
+ if (rightCollapsed) {
613
+ return (
614
+ <aside className="grid h-full min-h-0 content-start justify-items-center overflow-y-auto overflow-x-visible border-l border-slate-800 bg-slate-950/85 p-2">
615
+ <Button
616
+ variant="outline"
617
+ type="button"
618
+ onClick={() => setRightCollapsed(false)}
619
+ title="Expand inspector (Ctrl/Cmd+I)"
620
+ >
621
+ Inspector
622
+ </Button>
623
+ </aside>
624
+ );
625
+ }
626
+
627
+ if (!selection) {
628
+ return (
629
+ <aside className="h-full min-h-0 overflow-y-auto overflow-x-visible border-l border-slate-800 bg-slate-950/85 p-4">
630
+ <div className="grid gap-2 rounded-xl border border-slate-700 bg-slate-900/90 p-3">
631
+ <h3 className="m-0 text-base font-semibold">Inspector</h3>
632
+ <p className="text-sm text-slate-400">
633
+ Select a graph node or task row to inspect metadata and traceability.
634
+ </p>
635
+ {graphWarnings.length > 0 ? (
636
+ <div className="mt-2 rounded-lg border border-amber-700/70 bg-amber-950/40 p-2 text-xs text-amber-200">
637
+ <p className="m-0 font-medium">Graph Diagnostics ({graphWarnings.length})</p>
638
+ {graphWarnings.slice(0, 4).map((issue, index) => (
639
+ <p key={`${issue.ruleId}:${index}`} className="m-0">
640
+ [{issue.ruleId}] {issue.message}
641
+ </p>
642
+ ))}
643
+ </div>
644
+ ) : null}
645
+ </div>
646
+ </aside>
647
+ );
648
+ }
649
+
650
+ return (
651
+ <aside className="h-full min-h-0 overflow-y-auto overflow-x-visible border-l border-slate-800 bg-slate-950/85 p-4">
652
+ <div className="grid gap-3">
653
+ <div className="flex flex-wrap items-center gap-2">
654
+ <Badge variant="secondary">{humanizeLabel(hasFileDocument ? "file" : selection.type)}</Badge>
655
+ {typeof fileLaneValue === "string" ? <Badge variant="secondary">{humanizeValue("lane", fileLaneValue)}</Badge> : null}
656
+ {typeof fileStatusValue === "string" ? <Badge variant="secondary">{humanizeValue("status", fileStatusValue)}</Badge> : null}
657
+ {selectedLevelFile?.path ? (
658
+ <Button
659
+ variant="outline"
660
+ type="button"
661
+ onClick={() => {
662
+ void navigator.clipboard?.writeText(selectedLevelFile.path);
663
+ }}
664
+ title="Copy this path, then use Ctrl+P in your editor and paste it."
665
+ >
666
+ Copy Path (Ctrl+P)
667
+ </Button>
668
+ ) : null}
669
+ {selection.type === "task" && !hasFileDocument ? (
670
+ <Button variant="ghost" type="button" onClick={() => setSelection(null)}>
671
+ Close Task
672
+ </Button>
673
+ ) : null}
674
+ {hasFileDocument ? (
675
+ <Button
676
+ variant="ghost"
677
+ type="button"
678
+ onClick={() => setShowEmptyFileMetadata((prev) => !prev)}
679
+ >
680
+ {showEmptyFileMetadata ? "Hide Empty Fields" : "Show Empty Fields"}
681
+ </Button>
682
+ ) : null}
683
+ </div>
684
+
685
+ <h3 className="m-0 text-base font-semibold">{selection.title}</h3>
686
+
687
+ {graphWarnings.length > 0 ? (
688
+ <Section title="Graph Diagnostics">
689
+ {graphWarnings.slice(0, 8).map((issue, index) => (
690
+ <Code key={`${issue.ruleId}:${index}`}>[{issue.ruleId}] {issue.message}</Code>
691
+ ))}
692
+ </Section>
693
+ ) : null}
694
+
695
+ {selection.id && !hasFileDocument ? (
696
+ <p className="m-0 leading-tight">
697
+ <span className="text-slate-400">ID</span>
698
+ <br />
699
+ <Code>{selection.id}</Code>
700
+ </p>
701
+ ) : null}
702
+
703
+ <Separator />
704
+
705
+ {selectedLevelFile ? (
706
+ <>
707
+ <div className="grid gap-3">
708
+ {levelFiles.length > 1 ? (
709
+ <div className="flex flex-wrap gap-1.5 rounded-lg border border-slate-700/80 bg-slate-900/40 p-2">
710
+ {levelFiles.map((file) => (
711
+ <Button
712
+ key={file.path}
713
+ variant={file.path === selectedLevelFilePath ? "secondary" : "ghost"}
714
+ type="button"
715
+ onClick={() => setSelectedLevelFilePath(file.path)}
716
+ title={file.path}
717
+ >
718
+ {file.path.split("/").pop()}
719
+ </Button>
720
+ ))}
721
+ </div>
722
+ ) : null}
723
+ <Section title="Document">
724
+ <div className="flex flex-wrap items-center gap-2">
725
+ <Button
726
+ variant="outline"
727
+ type="button"
728
+ onClick={() => {
729
+ void navigator.clipboard?.writeText(selectedLevelFile.path);
730
+ }}
731
+ title="Copy this path, then use Ctrl+P in your editor and paste it."
732
+ >
733
+ Copy Path (Ctrl+P)
734
+ </Button>
735
+ </div>
736
+ <Field label="Source" value={selectedLevelFile.path} />
737
+ </Section>
738
+
739
+ {selectedLevelFileFrontmatterEntries.length > 0 ? (
740
+ <Section title="Metadata Summary">
741
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
742
+ {fileMetadataGroups.summary.map(([label, value]) => (
743
+ <MetadataValue key={`file-summary:${label}`} label={label} value={value} />
744
+ ))}
745
+ </div>
746
+ </Section>
747
+ ) : null}
748
+
749
+ {selectedLevelFileFrontmatterEntries.length > 0 ? (
750
+ <Section title="File Metadata">
751
+ {fileMetadataGroups.identity.length > 0 ? (
752
+ <MetadataDetails title="Identity">
753
+ {fileMetadataGroups.identity.map(([label, value]) => (
754
+ <MetadataValue key={`file-identity:${label}`} label={label} value={value} />
755
+ ))}
756
+ </MetadataDetails>
757
+ ) : null}
758
+
759
+ {fileMetadataGroups.workflow.length > 0 ? (
760
+ <MetadataDetails title="Workflow" defaultOpen>
761
+ <div className="grid grid-cols-[repeat(auto-fit,minmax(170px,1fr))] gap-2">
762
+ {fileMetadataGroups.workflow.map(([label, value]) => (
763
+ <div
764
+ key={`file-workflow:${label}`}
765
+ className="rounded-lg border border-slate-700/70 bg-slate-950/40 p-2"
766
+ >
767
+ <MetadataValue label={label} value={value} />
768
+ </div>
769
+ ))}
770
+ </div>
771
+ </MetadataDetails>
772
+ ) : null}
773
+
774
+ {fileMetadataGroups.references.length > 0 ? (
775
+ <MetadataDetails title="References">
776
+ {fileMetadataGroups.references.map(([label, value]) => (
777
+ <MetadataValue key={`file-references:${label}`} label={label} value={value} />
778
+ ))}
779
+ </MetadataDetails>
780
+ ) : null}
781
+
782
+ {fileMetadataGroups.criteria.length > 0 ? (
783
+ <MetadataDetails title="Completion Criteria">
784
+ {fileMetadataGroups.criteria.map(([label, value]) => (
785
+ <MetadataValue key={`file-criteria:${label}`} label={label} value={value} />
786
+ ))}
787
+ </MetadataDetails>
788
+ ) : null}
789
+ </Section>
790
+ ) : null}
791
+
792
+ <Section title="Content">
793
+ {selectedLevelFileExt === "md" ? (
794
+ <MarkdownViewer markdown={stripFrontmatter(selectedLevelFile.content)} />
795
+ ) : (
796
+ <pre className="overflow-x-auto rounded-xl border border-slate-700 bg-slate-950 p-3 text-xs text-slate-200">
797
+ {selectedLevelFile.content}
798
+ </pre>
799
+ )}
800
+ </Section>
801
+ </div>
802
+ </>
803
+ ) : selection.type === "task" ? (
804
+ <>
805
+ {rawTaskMetadata.length > 0 ? (
806
+ <div className="grid gap-1.5">
807
+ <p className="text-slate-400">Task Metadata</p>
808
+ {rawTaskMetadata.map((item) => (
809
+ <Field key={`${item.label}:${item.value}`} label={item.label} value={item.value} />
810
+ ))}
811
+ </div>
812
+ ) : null}
813
+
814
+ {entityDetails ? (
815
+ <>
816
+ <Separator />
817
+ <div className="grid gap-2">
818
+ <p className="text-slate-400">Related Data</p>
819
+ <RelatedList label="Tasks" values={entityDetails.relatedTasks} />
820
+ <RelatedList label="Milestones" values={entityDetails.relatedMilestones} />
821
+ <RelatedList label="Decisions" values={entityDetails.relatedDecisions} />
822
+ <RelatedList label="Modules" values={entityDetails.relatedModules} />
823
+ </div>
824
+ </>
825
+ ) : null}
826
+
827
+ <Separator />
828
+ <div className="grid gap-1.5">
829
+ <p className="text-slate-400">Task Trace</p>
830
+ {trace?.decisionRefs?.map((decision) => (
831
+ <Code key={decision}>{decision}</Code>
832
+ ))}
833
+ {trace?.moduleRefs?.map((moduleRef) => (
834
+ <Code key={moduleRef}>{moduleRef}</Code>
835
+ ))}
836
+ {trace?.files?.map((file) => (
837
+ <Code key={file}>{file}</Code>
838
+ ))}
839
+ {!trace?.decisionRefs?.length && !trace?.moduleRefs?.length && !trace?.files?.length ? (
840
+ <p className="text-slate-400">No trace links found.</p>
841
+ ) : null}
842
+ </div>
843
+ <p className="text-slate-400">Task document not available.</p>
844
+ </>
845
+ ) : (
846
+ <>
847
+ {filteredSelectionMetadata.map((item) => (
848
+ <Field key={`${item.label}:${item.value}`} label={item.label} value={item.value} />
849
+ ))}
850
+
851
+ {entityDetails ? (
852
+ <>
853
+ <Separator />
854
+ <div className="grid gap-2">
855
+ <p className="text-slate-400">Entity Data</p>
856
+ {entityDetails.summary.map((item) => (
857
+ <Field key={`${item.label}:${item.value}`} label={item.label} value={item.value} />
858
+ ))}
859
+ <RelatedList label="Tasks" values={entityDetails.relatedTasks} />
860
+ <RelatedList label="Milestones" values={entityDetails.relatedMilestones} />
861
+ <RelatedList label="Decisions" values={entityDetails.relatedDecisions} />
862
+ <RelatedList label="Modules" values={entityDetails.relatedModules} />
863
+ </div>
864
+ </>
865
+ ) : null}
866
+
867
+ {selection.links && selection.links.length > 0 ? (
868
+ <>
869
+ <Separator />
870
+ <div>
871
+ <p className="text-slate-400">Trace Links</p>
872
+ <div className="grid gap-1.5">
873
+ {selection.links.map((link) => (
874
+ <Code key={link}>{link}</Code>
875
+ ))}
876
+ </div>
877
+ </div>
878
+ </>
879
+ ) : null}
880
+
881
+ {selection.markdown ? (
882
+ <>
883
+ <Separator />
884
+ <div className="grid gap-1.5">
885
+ <p className="text-slate-400">Node Notes</p>
886
+ <MarkdownViewer markdown={selection.markdown} />
887
+ </div>
888
+ </>
889
+ ) : null}
890
+ </>
891
+ )}
892
+ </div>
893
+ </aside>
894
+ );
895
+ }