whygraph 0.1.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 (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +205 -0
  3. package/dist/cli/commands/config.d.ts +14 -0
  4. package/dist/cli/commands/config.js +123 -0
  5. package/dist/cli/commands/down.d.ts +9 -0
  6. package/dist/cli/commands/down.js +46 -0
  7. package/dist/cli/commands/init.d.ts +17 -0
  8. package/dist/cli/commands/init.js +144 -0
  9. package/dist/cli/commands/issues.d.ts +10 -0
  10. package/dist/cli/commands/issues.js +376 -0
  11. package/dist/cli/commands/mcp.d.ts +2 -0
  12. package/dist/cli/commands/mcp.js +9 -0
  13. package/dist/cli/commands/restart.d.ts +11 -0
  14. package/dist/cli/commands/restart.js +43 -0
  15. package/dist/cli/commands/serve.d.ts +14 -0
  16. package/dist/cli/commands/serve.js +132 -0
  17. package/dist/cli/commands/server-utils.d.ts +6 -0
  18. package/dist/cli/commands/server-utils.js +94 -0
  19. package/dist/cli/commands/status.d.ts +11 -0
  20. package/dist/cli/commands/status.js +97 -0
  21. package/dist/cli/commands/up.d.ts +13 -0
  22. package/dist/cli/commands/up.js +62 -0
  23. package/dist/cli/commands/validate.d.ts +14 -0
  24. package/dist/cli/commands/validate.js +88 -0
  25. package/dist/cli/commands/viz.d.ts +7 -0
  26. package/dist/cli/commands/viz.js +97 -0
  27. package/dist/cli/index.d.ts +2 -0
  28. package/dist/cli/index.js +33 -0
  29. package/dist/entity/id.d.ts +8 -0
  30. package/dist/entity/id.js +48 -0
  31. package/dist/entity/issues.d.ts +12 -0
  32. package/dist/entity/issues.js +68 -0
  33. package/dist/entity/parser.d.ts +6 -0
  34. package/dist/entity/parser.js +166 -0
  35. package/dist/entity/types.d.ts +54 -0
  36. package/dist/entity/types.js +21 -0
  37. package/dist/entity/validate.d.ts +12 -0
  38. package/dist/entity/validate.js +136 -0
  39. package/dist/entity/writer.d.ts +16 -0
  40. package/dist/entity/writer.js +142 -0
  41. package/dist/frontend/assets/index-ByZzPwVe.css +1 -0
  42. package/dist/frontend/assets/index-F9dxfzD_.js +170 -0
  43. package/dist/frontend/index.html +14 -0
  44. package/dist/graph/cascade.d.ts +10 -0
  45. package/dist/graph/cascade.js +49 -0
  46. package/dist/graph/decisions.d.ts +11 -0
  47. package/dist/graph/decisions.js +27 -0
  48. package/dist/graph/gaps.d.ts +10 -0
  49. package/dist/graph/gaps.js +58 -0
  50. package/dist/graph/nodes.d.ts +20 -0
  51. package/dist/graph/nodes.js +33 -0
  52. package/dist/graph/projection.d.ts +6 -0
  53. package/dist/graph/projection.js +44 -0
  54. package/dist/graph/query.d.ts +15 -0
  55. package/dist/graph/query.js +82 -0
  56. package/dist/graph/search.d.ts +2 -0
  57. package/dist/graph/search.js +23 -0
  58. package/dist/graph/supersede.d.ts +7 -0
  59. package/dist/graph/supersede.js +48 -0
  60. package/dist/graph/temporal.d.ts +13 -0
  61. package/dist/graph/temporal.js +28 -0
  62. package/dist/mcp/index.d.ts +2 -0
  63. package/dist/mcp/index.js +10 -0
  64. package/dist/mcp/server.d.ts +3 -0
  65. package/dist/mcp/server.js +340 -0
  66. package/dist/onboarding/interview.d.ts +22 -0
  67. package/dist/onboarding/interview.js +92 -0
  68. package/dist/onboarding/scan.d.ts +17 -0
  69. package/dist/onboarding/scan.js +106 -0
  70. package/dist/platform/rules.d.ts +8 -0
  71. package/dist/platform/rules.js +229 -0
  72. package/dist/server/core.d.ts +26 -0
  73. package/dist/server/core.js +111 -0
  74. package/dist/server/derived.d.ts +8 -0
  75. package/dist/server/derived.js +13 -0
  76. package/dist/server/etag.d.ts +9 -0
  77. package/dist/server/etag.js +25 -0
  78. package/dist/server/http.d.ts +13 -0
  79. package/dist/server/http.js +131 -0
  80. package/dist/server/pubsub.d.ts +12 -0
  81. package/dist/server/pubsub.js +19 -0
  82. package/dist/server/schema.d.ts +2 -0
  83. package/dist/server/schema.js +362 -0
  84. package/dist/server/stale-refs.d.ts +7 -0
  85. package/dist/server/stale-refs.js +23 -0
  86. package/dist/server/watcher.d.ts +21 -0
  87. package/dist/server/watcher.js +98 -0
  88. package/dist/server/worktree-watcher.d.ts +20 -0
  89. package/dist/server/worktree-watcher.js +79 -0
  90. package/dist/server/worktree.d.ts +22 -0
  91. package/dist/server/worktree.js +84 -0
  92. package/package.json +73 -0
@@ -0,0 +1,166 @@
1
+ import matter from "gray-matter";
2
+ const STRUCTURAL_LABELS = new Set(["App", "Feature", "Component"]);
3
+ /**
4
+ * Parse markdown body into named sections keyed by ## headings.
5
+ * Returns a map of heading name (lowercase) -> trimmed content.
6
+ */
7
+ function parseSections(body) {
8
+ const sections = new Map();
9
+ const lines = body.split("\n");
10
+ let currentKey = null;
11
+ let currentLines = [];
12
+ for (const line of lines) {
13
+ const headingMatch = line.match(/^## (.+)$/);
14
+ if (headingMatch) {
15
+ if (currentKey !== null) {
16
+ sections.set(currentKey, currentLines.join("\n").trim());
17
+ }
18
+ currentKey = headingMatch[1].trim().toLowerCase();
19
+ currentLines = [];
20
+ }
21
+ else if (currentKey !== null) {
22
+ currentLines.push(line);
23
+ }
24
+ }
25
+ /* v8 ignore next 2 */
26
+ if (currentKey !== null) {
27
+ sections.set(currentKey, currentLines.join("\n").trim());
28
+ }
29
+ return sections;
30
+ }
31
+ function isValidStructuralLabel(label) {
32
+ return STRUCTURAL_LABELS.has(label);
33
+ }
34
+ function parseStructuralNode(data, body) {
35
+ const { id, label, name, status, created_at, updated_at } = data;
36
+ /* v8 ignore start */
37
+ if (typeof id !== "string" ||
38
+ typeof label !== "string" ||
39
+ typeof name !== "string" ||
40
+ typeof status !== "string" ||
41
+ typeof created_at !== "string" &&
42
+ !(created_at instanceof Date) ||
43
+ typeof updated_at !== "string" &&
44
+ !(updated_at instanceof Date)) {
45
+ return null;
46
+ }
47
+ /* v8 ignore stop */
48
+ /* v8 ignore next 1 */
49
+ if (!isValidStructuralLabel(label))
50
+ return null;
51
+ /* v8 ignore next 1 */
52
+ if (status !== "active" && status !== "deprecated")
53
+ return null;
54
+ const node = {
55
+ id,
56
+ label,
57
+ name,
58
+ status: status,
59
+ created_at: String(created_at),
60
+ updated_at: String(updated_at),
61
+ };
62
+ if (typeof data.parent === "string") {
63
+ node.parent = data.parent;
64
+ }
65
+ if (Array.isArray(data.refs)) {
66
+ const refs = [];
67
+ for (const ref of data.refs) {
68
+ /* v8 ignore next 1 */
69
+ if (typeof ref === "object" && ref !== null && typeof ref.file === "string") {
70
+ const symbolRef = { file: ref.file };
71
+ if (typeof ref.symbol === "string") {
72
+ symbolRef.symbol = ref.symbol;
73
+ }
74
+ refs.push(symbolRef);
75
+ }
76
+ }
77
+ /* v8 ignore next 1 */
78
+ if (refs.length > 0) {
79
+ node.refs = refs;
80
+ }
81
+ }
82
+ if (typeof data.description === "string") {
83
+ node.description = data.description;
84
+ }
85
+ else {
86
+ const trimmed = body.trim();
87
+ if (trimmed.length > 0) {
88
+ node.description = trimmed;
89
+ }
90
+ }
91
+ if (typeof data.removed_at === "string" || data.removed_at instanceof Date) {
92
+ node.removed_at = String(data.removed_at);
93
+ }
94
+ return node;
95
+ }
96
+ function parseDecisionNode(data, body) {
97
+ const { id, title, status, date, affects, tags, created_at, updated_at } = data;
98
+ /* v8 ignore start */
99
+ if (typeof id !== "string" ||
100
+ typeof title !== "string" ||
101
+ typeof status !== "string" ||
102
+ (typeof date !== "string" && !(date instanceof Date)) ||
103
+ !Array.isArray(affects) ||
104
+ !Array.isArray(tags) ||
105
+ (typeof created_at !== "string" && !(created_at instanceof Date)) ||
106
+ (typeof updated_at !== "string" && !(updated_at instanceof Date))) {
107
+ return null;
108
+ }
109
+ /* v8 ignore stop */
110
+ /* v8 ignore next 1 */
111
+ if (status !== "active" && status !== "superseded")
112
+ return null;
113
+ const validAffects = affects.filter((a) => typeof a === "string");
114
+ const rawTags = tags.filter((t) => typeof t === "string");
115
+ const sections = parseSections(body);
116
+ const node = {
117
+ id,
118
+ label: "Decision",
119
+ title,
120
+ status: status,
121
+ date: String(date),
122
+ affects: validAffects,
123
+ tags: rawTags,
124
+ /* v8 ignore start */
125
+ context: sections.get("context") ?? "",
126
+ decision: sections.get("decision") ?? "",
127
+ tradeoffs: sections.get("tradeoffs") ?? "",
128
+ alternatives: sections.get("alternatives") ?? "",
129
+ /* v8 ignore stop */
130
+ created_at: String(created_at),
131
+ updated_at: String(updated_at),
132
+ };
133
+ if (typeof data.supersedes === "string") {
134
+ node.supersedes = data.supersedes;
135
+ }
136
+ if (typeof data.removed_at === "string" || data.removed_at instanceof Date) {
137
+ node.removed_at = String(data.removed_at);
138
+ }
139
+ return node;
140
+ }
141
+ /**
142
+ * Parse a markdown string with YAML front matter into a typed Entity object.
143
+ * Returns null for unparseable content.
144
+ */
145
+ export function parseEntity(content) {
146
+ if (!content || !content.trim())
147
+ return null;
148
+ try {
149
+ const { data, content: body } = matter(content);
150
+ if (!data || typeof data !== "object" || !data.label)
151
+ return null;
152
+ const label = data.label;
153
+ if (label === "Decision") {
154
+ return parseDecisionNode(data, body);
155
+ }
156
+ if (isValidStructuralLabel(label)) {
157
+ return parseStructuralNode(data, body);
158
+ }
159
+ return null;
160
+ /* v8 ignore start */
161
+ }
162
+ catch {
163
+ return null; // unreachable in practice — defensive guard
164
+ }
165
+ /* v8 ignore stop */
166
+ }
@@ -0,0 +1,54 @@
1
+ export type StructuralLabel = "App" | "Feature" | "Component";
2
+ export type NodeLabel = StructuralLabel | "Decision";
3
+ export type StructuralStatus = "active" | "deprecated";
4
+ export type DecisionStatus = "active" | "superseded";
5
+ export type DecisionTag = "arch" | "data" | "security" | "performance" | "integration" | "infra" | "ux";
6
+ export declare const DECISION_TAGS: readonly DecisionTag[];
7
+ export type EdgeLabel = "COMPOSES" | "AFFECTS" | "SUPERSEDES";
8
+ export interface SymbolRef {
9
+ file: string;
10
+ symbol?: string;
11
+ }
12
+ export interface StructuralNode {
13
+ id: string;
14
+ label: StructuralLabel;
15
+ name: string;
16
+ status: StructuralStatus;
17
+ parent?: string;
18
+ refs?: SymbolRef[];
19
+ description?: string;
20
+ created_at: string;
21
+ updated_at: string;
22
+ removed_at?: string;
23
+ }
24
+ export interface DecisionNode {
25
+ id: string;
26
+ label: "Decision";
27
+ title: string;
28
+ status: DecisionStatus;
29
+ date: string;
30
+ affects: string[];
31
+ tags: DecisionTag[];
32
+ supersedes?: string;
33
+ context: string;
34
+ decision: string;
35
+ tradeoffs: string;
36
+ alternatives: string;
37
+ created_at: string;
38
+ updated_at: string;
39
+ removed_at?: string;
40
+ }
41
+ export type Entity = StructuralNode | DecisionNode;
42
+ export declare function isStructuralNode(entity: Entity): entity is StructuralNode;
43
+ export declare function isDecisionNode(entity: Entity): entity is DecisionNode;
44
+ export type Environment = "claude-code" | "cursor" | "copilot" | "other";
45
+ export type McpMode = "default" | "strict";
46
+ export interface WhygraphConfig {
47
+ appName: string;
48
+ environment: Environment;
49
+ prefix: string;
50
+ idLength: number;
51
+ tags: DecisionTag[];
52
+ mcpMode: McpMode;
53
+ serverPort: number;
54
+ }
@@ -0,0 +1,21 @@
1
+ // ============================================================
2
+ // Labels & Status
3
+ // ============================================================
4
+ export const DECISION_TAGS = [
5
+ "arch",
6
+ "data",
7
+ "security",
8
+ "performance",
9
+ "integration",
10
+ "infra",
11
+ "ux",
12
+ ];
13
+ // ============================================================
14
+ // Type Guards
15
+ // ============================================================
16
+ export function isStructuralNode(entity) {
17
+ return entity.label !== "Decision";
18
+ }
19
+ export function isDecisionNode(entity) {
20
+ return entity.label === "Decision";
21
+ }
@@ -0,0 +1,12 @@
1
+ import type { Entity } from "./types.js";
2
+ export declare function validateEntityRefs(entity: Entity, entityMap: Map<string, Entity>): ValidationError[];
3
+ export interface ValidationError {
4
+ field: string;
5
+ message: string;
6
+ severity: "error" | "warning";
7
+ }
8
+ export interface ValidationResult {
9
+ valid: boolean;
10
+ errors: ValidationError[];
11
+ }
12
+ export declare function validateEntity(entity: Entity): ValidationResult;
@@ -0,0 +1,136 @@
1
+ import { DECISION_TAGS, isStructuralNode, isDecisionNode, } from "./types.js";
2
+ // ============================================================
3
+ // Cross-reference Validation
4
+ // ============================================================
5
+ export function validateEntityRefs(entity, entityMap) {
6
+ const errors = [];
7
+ if (isDecisionNode(entity)) {
8
+ for (const ref of entity.affects) {
9
+ if (!entityMap.has(ref)) {
10
+ errors.push({
11
+ field: "affects",
12
+ message: `affects ref "${ref}" does not exist in the graph`,
13
+ severity: "warning",
14
+ });
15
+ }
16
+ }
17
+ if (entity.supersedes && !entityMap.has(entity.supersedes)) {
18
+ errors.push({
19
+ field: "supersedes",
20
+ message: `supersedes ref "${entity.supersedes}" does not exist in the graph`,
21
+ severity: "warning",
22
+ });
23
+ }
24
+ }
25
+ if (isStructuralNode(entity) && entity.parent && !entityMap.has(entity.parent)) {
26
+ errors.push({
27
+ field: "parent",
28
+ message: `parent ref "${entity.parent}" does not exist in the graph`,
29
+ severity: "warning",
30
+ });
31
+ }
32
+ return errors;
33
+ }
34
+ // ============================================================
35
+ // Helpers
36
+ // ============================================================
37
+ const VALID_LABELS = ["App", "Feature", "Component", "Decision"];
38
+ const ISO_8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
39
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
40
+ function isNonEmptyString(value) {
41
+ return typeof value === "string" && value.length > 0;
42
+ }
43
+ function isValidISO8601(value) {
44
+ /* v8 ignore next 1 */
45
+ if (typeof value !== "string")
46
+ return false;
47
+ return ISO_8601_RE.test(value);
48
+ }
49
+ function isValidDate(value) {
50
+ /* v8 ignore next 1 */
51
+ if (typeof value !== "string")
52
+ return false;
53
+ return DATE_RE.test(value);
54
+ }
55
+ // ============================================================
56
+ // Validator
57
+ // ============================================================
58
+ export function validateEntity(entity) {
59
+ const errors = [];
60
+ // Common fields
61
+ if (!isNonEmptyString(entity.id)) {
62
+ errors.push({ field: "id", message: "id is required and must be a non-empty string", severity: "error" });
63
+ }
64
+ if (!VALID_LABELS.includes(entity.label)) {
65
+ errors.push({ field: "label", message: `label must be one of: ${VALID_LABELS.join(", ")}`, severity: "error" });
66
+ }
67
+ if (!isValidISO8601(entity.created_at)) {
68
+ errors.push({ field: "created_at", message: "created_at must be a valid ISO 8601 timestamp", severity: "error" });
69
+ }
70
+ if (!isValidISO8601(entity.updated_at)) {
71
+ errors.push({ field: "updated_at", message: "updated_at must be a valid ISO 8601 timestamp", severity: "error" });
72
+ }
73
+ if (entity.removed_at !== undefined && !isValidISO8601(entity.removed_at)) {
74
+ errors.push({ field: "removed_at", message: "removed_at must be a valid ISO 8601 timestamp", severity: "error" });
75
+ }
76
+ // Structural node validations
77
+ if (isStructuralNode(entity)) {
78
+ if (!isNonEmptyString(entity.name)) {
79
+ errors.push({ field: "name", message: "name is required and must be a non-empty string", severity: "error" });
80
+ }
81
+ if (entity.status !== "active" && entity.status !== "deprecated") {
82
+ errors.push({ field: "status", message: "structural node status must be \"active\" or \"deprecated\"", severity: "error" });
83
+ }
84
+ if (entity.parent !== undefined && !isNonEmptyString(entity.parent)) {
85
+ errors.push({ field: "parent", message: "parent, if present, must be a non-empty string", severity: "error" });
86
+ }
87
+ if (entity.label === "App" && entity.parent !== undefined) {
88
+ errors.push({ field: "parent", message: "App nodes should not have a parent", severity: "warning" });
89
+ }
90
+ if ((entity.label === "Feature" || entity.label === "Component") && entity.parent === undefined) {
91
+ errors.push({ field: "parent", message: `${entity.label} nodes should have a parent`, severity: "warning" });
92
+ }
93
+ }
94
+ // Decision node validations
95
+ if (isDecisionNode(entity)) {
96
+ if (!isNonEmptyString(entity.title)) {
97
+ errors.push({ field: "title", message: "title is required and must be a non-empty string", severity: "error" });
98
+ }
99
+ if (entity.status !== "active" && entity.status !== "superseded") {
100
+ errors.push({ field: "status", message: "decision node status must be \"active\" or \"superseded\"", severity: "error" });
101
+ }
102
+ if (!isValidDate(entity.date)) {
103
+ errors.push({ field: "date", message: "date must match YYYY-MM-DD format", severity: "error" });
104
+ }
105
+ if (!Array.isArray(entity.affects) || entity.affects.length === 0) {
106
+ errors.push({ field: "affects", message: "affects is required and must be a non-empty array", severity: "error" });
107
+ }
108
+ /* v8 ignore next 1 */
109
+ if (Array.isArray(entity.tags)) {
110
+ for (const tag of entity.tags) {
111
+ if (!DECISION_TAGS.includes(tag)) {
112
+ errors.push({ field: "tags", message: `invalid tag "${tag}"; must be one of: ${DECISION_TAGS.join(", ")}`, severity: "error" });
113
+ }
114
+ }
115
+ }
116
+ if (!isNonEmptyString(entity.context)) {
117
+ errors.push({ field: "context", message: "context is required and must be a non-empty string", severity: "error" });
118
+ }
119
+ if (!isNonEmptyString(entity.decision)) {
120
+ errors.push({ field: "decision", message: "decision is required and must be a non-empty string", severity: "error" });
121
+ }
122
+ if (!isNonEmptyString(entity.tradeoffs)) {
123
+ errors.push({ field: "tradeoffs", message: "tradeoffs is required and must be a non-empty string", severity: "error" });
124
+ }
125
+ if (!isNonEmptyString(entity.alternatives)) {
126
+ errors.push({ field: "alternatives", message: "alternatives is required and must be a non-empty string", severity: "error" });
127
+ }
128
+ if (entity.supersedes !== undefined && !isNonEmptyString(entity.supersedes)) {
129
+ errors.push({ field: "supersedes", message: "supersedes, if present, must be a non-empty string", severity: "error" });
130
+ }
131
+ }
132
+ return {
133
+ valid: errors.filter((e) => e.severity === "error").length === 0,
134
+ errors,
135
+ };
136
+ }
@@ -0,0 +1,16 @@
1
+ import type { Entity } from "./types.js";
2
+ import type { ValidationResult } from "./validate.js";
3
+ /**
4
+ * Pure function: render an Entity to a markdown string with YAML front matter.
5
+ */
6
+ export declare function renderEntity(entity: Entity): string;
7
+ export interface WriteEntityResult {
8
+ filePath: string;
9
+ validation: ValidationResult;
10
+ }
11
+ /**
12
+ * Write an entity to disk as a markdown file. Always writes (never loses data),
13
+ * but returns validation results so callers can surface issues.
14
+ * Uses atomic write (temp file + rename). Creates parent directories if needed.
15
+ */
16
+ export declare function writeEntity(dirPath: string, entity: Entity): WriteEntityResult;
@@ -0,0 +1,142 @@
1
+ import { writeFileSync, mkdirSync, renameSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { isDecisionNode } from "./types.js";
4
+ import { toFilename } from "./id.js";
5
+ import { validateEntity } from "./validate.js";
6
+ // ============================================================
7
+ // YAML front matter rendering
8
+ // ============================================================
9
+ /**
10
+ * Quote a string value for YAML — wraps in double quotes if the value
11
+ * contains characters that could cause YAML parsing issues.
12
+ */
13
+ function yamlValue(value) {
14
+ // Always quote timestamps, dates, and values that look ambiguous
15
+ if (/^\d/.test(value) ||
16
+ /[:{}\[\],&*?|>!%#@`]/.test(value) ||
17
+ value === "true" ||
18
+ value === "false" ||
19
+ value === "null" ||
20
+ value === "") {
21
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
22
+ }
23
+ return value;
24
+ }
25
+ function renderFrontMatter(lines) {
26
+ return `---\n${lines.join("\n")}\n---`;
27
+ }
28
+ function renderStructuralFrontMatter(node) {
29
+ const lines = [
30
+ `id: ${node.id}`,
31
+ `label: ${node.label}`,
32
+ `name: ${yamlValue(node.name)}`,
33
+ `status: ${node.status}`,
34
+ ];
35
+ if (node.parent !== undefined) {
36
+ lines.push(`parent: ${node.parent}`);
37
+ }
38
+ if (node.refs !== undefined && node.refs.length > 0) {
39
+ lines.push("refs:");
40
+ for (const ref of node.refs) {
41
+ if (ref.symbol !== undefined) {
42
+ lines.push(` - file: ${yamlValue(ref.file)}`);
43
+ lines.push(` symbol: ${yamlValue(ref.symbol)}`);
44
+ }
45
+ else {
46
+ lines.push(` - file: ${yamlValue(ref.file)}`);
47
+ }
48
+ }
49
+ }
50
+ if (node.description !== undefined) {
51
+ lines.push(`description: ${yamlValue(node.description)}`);
52
+ }
53
+ lines.push(`created_at: ${yamlValue(node.created_at)}`);
54
+ lines.push(`updated_at: ${yamlValue(node.updated_at)}`);
55
+ if (node.removed_at !== undefined) {
56
+ lines.push(`removed_at: ${yamlValue(node.removed_at)}`);
57
+ }
58
+ return lines;
59
+ }
60
+ function renderDecisionFrontMatter(node) {
61
+ const lines = [
62
+ `id: ${node.id}`,
63
+ `label: Decision`,
64
+ `title: ${yamlValue(node.title)}`,
65
+ `status: ${node.status}`,
66
+ `date: ${yamlValue(node.date)}`,
67
+ ];
68
+ lines.push("affects:");
69
+ for (const a of node.affects) {
70
+ lines.push(` - ${a}`);
71
+ }
72
+ lines.push("tags:");
73
+ for (const t of node.tags) {
74
+ lines.push(` - ${t}`);
75
+ }
76
+ if (node.supersedes !== undefined) {
77
+ lines.push(`supersedes: ${node.supersedes}`);
78
+ }
79
+ lines.push(`created_at: ${yamlValue(node.created_at)}`);
80
+ lines.push(`updated_at: ${yamlValue(node.updated_at)}`);
81
+ if (node.removed_at !== undefined) {
82
+ lines.push(`removed_at: ${yamlValue(node.removed_at)}`);
83
+ }
84
+ return lines;
85
+ }
86
+ // ============================================================
87
+ // Body rendering
88
+ // ============================================================
89
+ function renderStructuralBody(node) {
90
+ if (node.description !== undefined) {
91
+ return `\n${node.description}\n`;
92
+ }
93
+ return "\n";
94
+ }
95
+ function renderDecisionBody(node) {
96
+ const sections = [
97
+ { heading: "Context", content: node.context },
98
+ { heading: "Decision", content: node.decision },
99
+ { heading: "Tradeoffs", content: node.tradeoffs },
100
+ { heading: "Alternatives", content: node.alternatives },
101
+ ];
102
+ const parts = [];
103
+ for (const { heading, content } of sections) {
104
+ parts.push(`## ${heading}\n\n${content}`);
105
+ }
106
+ return `\n${parts.join("\n\n")}\n`;
107
+ }
108
+ // ============================================================
109
+ // Public API
110
+ // ============================================================
111
+ /**
112
+ * Pure function: render an Entity to a markdown string with YAML front matter.
113
+ */
114
+ export function renderEntity(entity) {
115
+ if (isDecisionNode(entity)) {
116
+ const fm = renderDecisionFrontMatter(entity);
117
+ return renderFrontMatter(fm) + renderDecisionBody(entity);
118
+ }
119
+ // structural node — description goes in body, not front matter
120
+ const node = entity;
121
+ const fmLines = renderStructuralFrontMatter(node);
122
+ // Remove description from front matter — it goes in the body
123
+ const fmWithoutDesc = fmLines.filter((line) => !line.startsWith("description:"));
124
+ return renderFrontMatter(fmWithoutDesc) + renderStructuralBody(node);
125
+ }
126
+ /**
127
+ * Write an entity to disk as a markdown file. Always writes (never loses data),
128
+ * but returns validation results so callers can surface issues.
129
+ * Uses atomic write (temp file + rename). Creates parent directories if needed.
130
+ */
131
+ export function writeEntity(dirPath, entity) {
132
+ const validation = validateEntity(entity);
133
+ const titleOrName = isDecisionNode(entity) ? entity.title : entity.name;
134
+ const filename = toFilename(entity.id, titleOrName);
135
+ const filePath = join(dirPath, filename);
136
+ const tempPath = filePath + ".tmp";
137
+ mkdirSync(dirPath, { recursive: true });
138
+ const content = renderEntity(entity);
139
+ writeFileSync(tempPath, content, "utf-8");
140
+ renameSync(tempPath, filePath);
141
+ return { filePath, validation };
142
+ }
@@ -0,0 +1 @@
1
+ @import"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;600;700&display=swap";:root{--surface-0: #080F18;--surface-1: #091421;--surface-2: #0B1A2C;--surface-3: #0E2D4D;--surface-1-rgb: 9, 20, 33;--surface-2-rgb: 11, 26, 44;--color-primary: #71D7CD;--color-on-primary: #004944;--color-secondary: #323C4C;--color-tertiary: #FFC87F;--color-error: #EE7D77;--color-success: #4CAF50;--color-surface-tint: #71D7CD;--text-primary: #E8ECF1;--text-secondary: #8FA3B8;--text-muted: #506070;--border-ghost: rgba(51, 73, 102, .2);--shadow-glow: 0 0 40px rgba(113, 215, 205, .08);--font-heading: "Space Grotesk", sans-serif;--font-body: "Inter", sans-serif;--text-xs: .6875rem;--text-sm: .75rem;--text-base: .875rem;--text-lg: 1rem;--text-xl: 1.25rem;--space-1: .25rem;--space-2: .5rem;--space-3: .75rem;--space-4: 1rem;--space-6: 1.5rem;--space-8: 2rem;--space-10: 2.5rem;--radius: 0px;--transition-fast: .15s ease;--transition-normal: .3s ease-in-out}[data-theme=light]{--surface-0: #F4F1EA;--surface-1: #FFFFFF;--surface-2: #EDE8E3;--surface-3: #DDD8D3;--surface-1-rgb: 255, 255, 255;--surface-2-rgb: 237, 232, 227;--color-primary: #71D7CD;--color-on-primary: #004944;--color-secondary: #C8C2BC;--color-tertiary: #E8A54C;--color-error: #D44840;--color-success: #3D8B40;--color-surface-tint: #71D7CD;--text-primary: #1A1F26;--text-secondary: #5A6570;--text-muted: #8A9099;--border-ghost: rgba(0, 0, 0, .1);--shadow-glow: 0 0 40px rgba(113, 215, 205, .06)}*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}html,body,#root{height:100%;overflow:hidden}body{font-family:var(--font-body);font-size:var(--text-base);background:var(--surface-0);color:var(--text-primary);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}button{font-family:inherit;font-size:inherit;border:none;background:none;color:inherit;cursor:pointer;border-radius:var(--radius)}input,select,textarea{font-family:inherit;font-size:inherit;border:none;background:none;color:inherit;border-radius:var(--radius)}.tree-nav{display:flex;flex-direction:row;flex-shrink:0;width:2.5rem;background:var(--surface-1);border-right:1px solid var(--border-ghost);overflow:hidden;transition:width var(--transition-normal);z-index:4}.tree-nav--open{width:264px}.tree-nav__toggle{flex-shrink:0;width:2.5rem;align-self:flex-start;padding:var(--space-3) 0;color:var(--text-muted);font-size:var(--text-base);text-align:center;transition:color var(--transition-fast)}.tree-nav__toggle:hover{color:var(--text-primary)}.tree-nav__body{flex:1;min-width:0;display:flex;flex-direction:column;overflow:hidden}.tree-nav__heading{flex-shrink:0;padding:var(--space-2) var(--space-3) var(--space-2) var(--space-2);font-family:var(--font-body);font-size:var(--text-xs);font-weight:600;text-transform:uppercase;letter-spacing:.07em;color:var(--text-muted);border-bottom:1px solid var(--border-ghost)}.tree-nav__scroll{flex:1;overflow-y:auto;padding:var(--space-2) 0}.tree-section{margin-bottom:var(--space-1)}.tree-section--orphans{margin-top:var(--space-3);padding-top:var(--space-2);border-top:1px solid var(--border-ghost)}.tree-section__label{padding:var(--space-1) var(--space-3);font-size:var(--text-xs);font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted)}.tree-accordion{margin-bottom:1px}.tree-accordion__row{display:flex;align-items:center}.tree-accordion__arrow{flex-shrink:0;width:1.25rem;font-size:10px;color:var(--text-muted);text-align:center;padding:0 var(--space-1);transition:color var(--transition-fast)}.tree-accordion__arrow:hover{color:var(--text-secondary)}.tree-accordion__children{padding-left:1.25rem}.tree-node{display:flex;align-items:center;gap:var(--space-2);width:100%;padding:3px var(--space-3) 3px var(--space-2);font-family:var(--font-body);font-size:var(--text-sm);color:var(--text-secondary);text-align:left;border-radius:0;transition:background var(--transition-fast),color var(--transition-fast);white-space:nowrap;overflow:hidden}.tree-node:hover{background:var(--surface-2);color:var(--text-primary)}.tree-node--selected{background:var(--surface-2);color:var(--text-primary);box-shadow:inset 2px 0 0 var(--color-primary)}.tree-node__name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}.tree-node__icon{flex-shrink:0;width:10px;height:10px;border-radius:50%;display:inline-block}.tree-node__icon--app{width:12px;height:12px;background:#1e3a5f;border:1.5px solid rgba(255,255,255,.3)}.tree-node__icon--feature{background:#2b8a8a;border:1.5px solid rgba(255,255,255,.2)}.tree-node__icon--component{width:8px;height:8px;background:#7ec8e3;border:1px solid rgba(255,255,255,.2)}.tree-node--decision{font-size:var(--text-xs);color:var(--text-muted);padding-left:var(--space-3)}.tree-node__icon--decision{width:auto;height:auto;background:none;border:none;color:#e07020;font-size:8px;border-radius:0}.tree-node__badge{flex-shrink:0;font-size:10px;font-weight:700;width:14px;height:14px;display:flex;align-items:center;justify-content:center;border-radius:50%;line-height:1}.tree-node__badge--error{background:#e74c3ce6;color:#fff}.tree-node__badge--stale{background:#e67e22e6;color:#fff}.gap-toggle{display:inline-flex;align-items:center;gap:var(--space-2)}.gap-toggle__btn{padding:var(--space-1) var(--space-3);background:var(--surface-2);color:var(--text-secondary);font-family:var(--font-body);font-size:var(--text-xs);font-weight:500;border-radius:var(--radius);border:1px solid var(--border-ghost);transition:background var(--transition-fast),color var(--transition-fast)}.gap-toggle__btn:hover{background:var(--surface-3)}.gap-toggle__btn[aria-pressed=true]{background:var(--color-tertiary);color:#1a1a1a;border-color:transparent}.gap-toggle__count{font-size:var(--text-xs);color:var(--text-muted)}.stale-ref-list{display:flex;flex-wrap:wrap;gap:var(--space-2)}.stale-ref-btn{display:inline-flex;align-items:center;gap:var(--space-1);padding:var(--space-1) var(--space-2);background:#ee7d771a;color:var(--color-error);font-size:var(--text-xs);font-weight:500;border-radius:var(--radius);border:1px solid rgba(238,125,119,.3);transition:background var(--transition-fast)}.stale-ref-btn:hover{background:#ee7d7733}.stale-ref-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:#00000080;display:flex;align-items:center;justify-content:center;z-index:100}.stale-ref-modal{background:var(--surface-2);border-radius:var(--radius);padding:var(--space-6);max-width:480px;width:100%;box-shadow:var(--shadow-glow)}.stale-ref-modal h3{font-family:var(--font-heading);font-size:var(--text-lg);font-weight:600;color:var(--text-primary);margin-bottom:var(--space-4)}.stale-ref-modal p{font-size:var(--text-sm);color:var(--text-secondary);margin-bottom:var(--space-2)}.stale-ref-modal code{font-size:var(--text-sm);color:var(--color-primary)}.stale-ref-modal__field{margin:var(--space-4) 0}.stale-ref-modal__field label{display:block;font-size:var(--text-sm);color:var(--text-secondary);margin-bottom:var(--space-1)}.stale-ref-modal__input{width:100%;padding:var(--space-2) var(--space-3);background:var(--surface-0);color:var(--text-primary);border:1px solid var(--border-ghost);font-size:var(--text-base)}.stale-ref-modal__input:focus{outline:1px solid var(--color-primary);outline-offset:-1px}.stale-ref-modal__actions{display:flex;gap:var(--space-2);justify-content:flex-end}.btn-ghost{padding:var(--space-2) var(--space-4);background:transparent;color:var(--text-secondary);border:1px solid var(--border-ghost);font-size:var(--text-sm);font-weight:500;border-radius:var(--radius)}.btn-ghost:hover{background:var(--surface-3);color:var(--text-primary)}.btn-primary{padding:var(--space-2) var(--space-4);background:var(--color-primary);color:var(--color-on-primary);font-size:var(--text-sm);font-weight:600;border-radius:var(--radius)}.btn-primary:hover{opacity:.9}.tag-filter{display:flex;flex-wrap:wrap;gap:var(--space-2);align-items:center}.tag-chip{padding:var(--space-1) var(--space-3);background:var(--surface-2);color:var(--text-secondary);font-family:var(--font-body);font-size:var(--text-xs);font-weight:500;text-transform:uppercase;letter-spacing:.03em;border-radius:var(--radius);border:1px solid var(--border-ghost);transition:background var(--transition-fast),color var(--transition-fast)}.tag-chip:hover{background:var(--surface-3);color:var(--text-primary)}.tag-chip[aria-checked=true]{background:var(--color-tertiary);color:#1a1a1a;border-color:transparent}.tag-chip--clear{padding:var(--space-1) var(--space-3);background:transparent;color:var(--text-muted);font-family:var(--font-body);font-size:var(--text-xs);font-weight:500;border-radius:var(--radius);transition:color var(--transition-fast)}.tag-chip--clear:hover{color:var(--text-primary)}.menu-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:40;background:#0000004d;backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px)}.menu-dropdown{position:absolute;top:100%;right:var(--space-6);z-index:50;min-width:280px;background:rgba(var(--surface-2-rgb),.85);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);box-shadow:var(--shadow-glow);border:1px solid var(--border-ghost);border-radius:var(--radius);padding:var(--space-4)}.menu-section{margin-bottom:var(--space-4)}.menu-section:last-child{margin-bottom:0}.menu-section__title{font-family:var(--font-body);font-size:var(--text-xs);font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text-muted);margin-bottom:var(--space-2)}.theme-toggle{display:flex;align-items:center;gap:var(--space-3)}.theme-toggle__icon{font-size:var(--text-base);color:var(--text-secondary);width:20px;text-align:center}.theme-toggle__track{position:relative;width:40px;height:22px;background:var(--surface-3);border-radius:var(--radius);padding:2px;transition:background var(--transition-fast)}.theme-toggle__track[data-active=true]{background:var(--color-primary)}.theme-toggle__thumb{position:absolute;top:2px;left:2px;width:18px;height:18px;background:var(--text-primary);border-radius:var(--radius);transition:transform var(--transition-fast)}.theme-toggle__track[data-active=true] .theme-toggle__thumb{transform:translate(18px)}.error-banner{position:absolute;top:0;left:0;right:0;z-index:10;display:flex;align-items:flex-start;gap:8px;padding:10px 16px;background:#e74c3c26;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid rgba(231,76,60,.3);color:var(--text-primary, #e0e8f0);font-size:13px}.error-banner__icon{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;background:#e74c3c;color:#fff;font-size:12px;font-weight:700;flex-shrink:0}.error-banner__text{display:flex;flex-direction:column;gap:2px}.error-banner__detail{display:block;font-size:11px;opacity:.7}.timeline{display:flex;align-items:center;gap:var(--space-4);padding:var(--space-3) var(--space-4);background:var(--surface-1);border-radius:var(--radius)}.timeline__label{font-family:var(--font-body);font-size:var(--text-xs);font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);white-space:nowrap}.timeline__slider{flex:1;accent-color:var(--color-primary)}.timeline__value{font-size:var(--text-xs);color:var(--text-muted);min-width:180px;text-align:center}.timeline__play-btn{padding:var(--space-1) var(--space-3);background:var(--surface-2);color:var(--text-secondary);font-size:var(--text-xs);font-weight:500;border-radius:var(--radius);border:1px solid var(--border-ghost);min-width:2rem}.timeline__play-btn:hover{background:var(--surface-3)}.timeline__live-btn{padding:var(--space-1) var(--space-3);background:var(--surface-2);color:var(--text-secondary);font-size:var(--text-xs);font-weight:500;border-radius:var(--radius);border:1px solid var(--border-ghost)}.timeline__live-btn[disabled]{background:#4caf5026;color:var(--color-success);border-color:transparent;cursor:default;font-weight:600}.app-shell{display:grid;grid-template-rows:auto 1fr auto auto;height:100vh;overflow:hidden;background:var(--surface-0)}.app-header{display:flex;align-items:center;gap:var(--space-4);padding:var(--space-3) var(--space-6);background:var(--surface-1);position:relative}.app-header__logo{font-family:var(--font-heading);font-size:var(--text-xl);font-weight:700;color:var(--text-primary);letter-spacing:-.02em}.app-header__subtitle{font-family:var(--font-body);font-size:var(--text-sm);color:var(--text-muted)}.app-header__spacer{flex:1}.app-header__menu-btn{font-family:var(--font-body);font-size:var(--text-sm);font-weight:500;color:var(--text-secondary);padding:var(--space-2) var(--space-3);background:var(--surface-2);border-radius:var(--radius);transition:background var(--transition-fast)}.app-header__menu-btn:hover,.app-header__menu-btn--active{background:var(--surface-3)}.badge{display:inline-block;padding:var(--space-1) var(--space-2);font-family:var(--font-body);font-size:var(--text-xs);font-weight:600;text-transform:uppercase;letter-spacing:.05em;border-radius:var(--radius)}.badge--success{background:#4caf5026;color:var(--color-success)}.badge--error{background:#ee7d7726;color:var(--color-error)}.app-graph-area{display:flex;flex-direction:row;overflow:hidden;background:var(--surface-0)}.graph-area-main{flex:1;display:flex;flex-direction:row;overflow:hidden}.graph-viewport{flex:1;position:relative;overflow:hidden}.graph-container{width:100%;height:100%;position:relative}.graph-svg{width:100%;height:100%;display:block}.focus-blur-overlay{position:absolute;top:0;right:0;bottom:0;left:0;backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);pointer-events:none;opacity:0;transition:opacity .22s ease;z-index:3}.graph-center-btn{position:absolute;bottom:var(--space-4);right:var(--space-4);width:2rem;height:2rem;display:flex;align-items:center;justify-content:center;background:var(--surface-2);color:var(--text-secondary);font-size:var(--text-base);border-radius:var(--radius);border:1px solid var(--border-ghost);opacity:.7;transition:opacity var(--transition-fast),background var(--transition-fast);z-index:5}.graph-center-btn:hover{opacity:1;background:var(--surface-3)}.detail-panel{flex-shrink:0;width:0;min-width:0;overflow:hidden;background:var(--surface-1);border-left:1px solid var(--border-ghost);box-shadow:var(--shadow-glow);transition:width var(--transition-normal)}.detail-panel[data-open=true]{width:360px}.detail-panel__inner{width:360px;height:100%;overflow-y:auto;padding:var(--space-6);position:relative}.detail-panel__close{position:absolute;top:var(--space-4);right:var(--space-4);font-size:var(--text-xl);color:var(--text-secondary);padding:var(--space-1);line-height:1}.detail-panel__close:hover{color:var(--text-primary)}.detail-panel h2{font-family:var(--font-heading);font-size:var(--text-lg);font-weight:600;color:var(--text-primary);margin-bottom:var(--space-4);padding-right:var(--space-8)}.detail-field{margin-bottom:var(--space-4);font-size:var(--text-base);color:var(--text-primary);line-height:1.5}.detail-field strong{display:block;font-family:var(--font-body);font-size:var(--text-xs);font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:var(--space-1)}.detail-field p{margin:var(--space-1) 0 0;color:var(--text-primary)}.detail-field ul{list-style:none;padding:0;margin:var(--space-1) 0 0}.detail-field li{padding:var(--space-1) 0;color:var(--text-secondary);font-size:var(--text-sm)}.detail-field li:before{content:"•";color:var(--color-primary);margin-right:var(--space-2)}.detail-tag{display:inline-block;padding:var(--space-1) var(--space-2);margin:2px;background:var(--surface-2);color:var(--text-secondary);font-size:var(--text-xs);font-weight:500;text-transform:uppercase;letter-spacing:.03em;border-radius:var(--radius)}.detail-errors{margin-top:var(--space-8);margin-bottom:var(--space-4);padding:var(--space-3);background:#e74c3c1a;border:1px solid rgba(231,76,60,.3);border-radius:var(--radius)}.detail-errors__status{font-family:var(--font-body);font-size:var(--text-sm);font-weight:600;color:#e74c3c;margin-bottom:var(--space-2)}.detail-errors__list{list-style:none;padding:0;margin:0}.detail-errors__item{font-size:var(--text-xs);color:var(--text-secondary);padding:var(--space-1) 0}.detail-errors__item strong{display:inline;font-size:inherit;text-transform:none;letter-spacing:normal;color:var(--text-primary)}.detail-status--error{color:#e74c3c;font-weight:600}.app-footer{display:flex;align-items:center;gap:var(--space-6);padding:var(--space-2) var(--space-6);background:var(--surface-1);font-family:var(--font-body);font-size:var(--text-xs);color:var(--text-muted)}.app-footer__stat{display:inline-flex;align-items:center;gap:var(--space-2)}.app-footer__stat-label{text-transform:uppercase;letter-spacing:.05em}.app-footer__stat-value{color:var(--text-secondary);font-weight:600}