runlist 0.0.0 → 0.94.1

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 (166) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1114 -6
  3. package/assets/app/app.css +283 -0
  4. package/assets/app/app.mjs +381 -0
  5. package/assets/app/block-editor.mjs +182 -0
  6. package/assets/app/block-model.mjs +52 -0
  7. package/assets/app/document-create.mjs +86 -0
  8. package/assets/app/document-lifecycle.mjs +36 -0
  9. package/assets/app/document-stages.mjs +22 -0
  10. package/assets/app/document-views.mjs +33 -0
  11. package/assets/app/document-yardstick.mjs +258 -0
  12. package/assets/app/editor-navigation.mjs +51 -0
  13. package/assets/app/filing-navigation.mjs +138 -0
  14. package/assets/app/git-changes.mjs +80 -0
  15. package/assets/app/git-commit.mjs +46 -0
  16. package/assets/app/index.html +70 -0
  17. package/assets/app/library-navigation.mjs +93 -0
  18. package/assets/app/outline.mjs +18 -0
  19. package/assets/app/quick-navigation.mjs +52 -0
  20. package/assets/app/record-navigation.mjs +78 -0
  21. package/assets/app/recovery-center.mjs +53 -0
  22. package/assets/app/semantic-search.mjs +14 -0
  23. package/assets/app/settings.mjs +15 -0
  24. package/assets/app/shared.mjs +104 -0
  25. package/assets/app/stage-source.mjs +75 -0
  26. package/assets/app/template-editor.mjs +47 -0
  27. package/assets/app/theme.mjs +25 -0
  28. package/assets/app/transport.mjs +43 -0
  29. package/assets/contracts/source-editing-v1.md +232 -0
  30. package/assets/opencode/plugin.js +162 -0
  31. package/assets/schemas/runlist-record-v1.schema.json +912 -0
  32. package/bin/dotmd.mjs +5 -0
  33. package/bin/runlist.mjs +2714 -0
  34. package/package.json +53 -6
  35. package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
  36. package/plugins/runlist-codex/bin/runlist-hook +86 -0
  37. package/plugins/runlist-codex/hooks/hooks.json +16 -0
  38. package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
  39. package/runlist.config.example.mjs +370 -0
  40. package/scripts/postinstall.mjs +56 -0
  41. package/src/agent-context.mjs +201 -0
  42. package/src/ai.mjs +30 -0
  43. package/src/app-create.mjs +169 -0
  44. package/src/app-filing.mjs +139 -0
  45. package/src/app-git-commits.mjs +48 -0
  46. package/src/app-git.mjs +180 -0
  47. package/src/app-library.mjs +152 -0
  48. package/src/app-lifecycle-worker.mjs +12 -0
  49. package/src/app-lifecycle.mjs +79 -0
  50. package/src/app-records.mjs +134 -0
  51. package/src/app-search.mjs +80 -0
  52. package/src/app-semantic-search.mjs +105 -0
  53. package/src/app-service.mjs +208 -0
  54. package/src/app-yardstick.mjs +269 -0
  55. package/src/app.mjs +102 -0
  56. package/src/atomic-mutation.mjs +1736 -0
  57. package/src/baton.mjs +435 -0
  58. package/src/body-link.mjs +48 -0
  59. package/src/bulk-tag.mjs +174 -0
  60. package/src/check-collapse.mjs +75 -0
  61. package/src/claude-commands.mjs +75 -0
  62. package/src/cli-editor.mjs +8 -0
  63. package/src/code-refs.mjs +545 -0
  64. package/src/codex-integration.mjs +81 -0
  65. package/src/color.mjs +14 -0
  66. package/src/commands.mjs +374 -0
  67. package/src/completions.mjs +93 -0
  68. package/src/config-edit.mjs +606 -0
  69. package/src/config.mjs +705 -0
  70. package/src/decision-action.mjs +58 -0
  71. package/src/decision.mjs +220 -0
  72. package/src/decisions.mjs +787 -0
  73. package/src/deps.mjs +317 -0
  74. package/src/desktop-install.mjs +158 -0
  75. package/src/desktop-runtime.mjs +4 -0
  76. package/src/desktop.mjs +25 -0
  77. package/src/diff.mjs +101 -0
  78. package/src/doctor.mjs +591 -0
  79. package/src/durable-rename.mjs +55 -0
  80. package/src/errors-read.mjs +44 -0
  81. package/src/export.mjs +496 -0
  82. package/src/extractors.mjs +101 -0
  83. package/src/filing.mjs +255 -0
  84. package/src/fix-membership.mjs +134 -0
  85. package/src/fix-refs.mjs +174 -0
  86. package/src/flags.mjs +318 -0
  87. package/src/frontmatter-fix.mjs +195 -0
  88. package/src/frontmatter.mjs +252 -0
  89. package/src/git-commit-filters.mjs +32 -0
  90. package/src/git-commit-protocol.mjs +198 -0
  91. package/src/git-commit-signing.mjs +75 -0
  92. package/src/git-commit-store.mjs +26 -0
  93. package/src/git-commit-support.mjs +16 -0
  94. package/src/git-commit-worker.mjs +32 -0
  95. package/src/git.mjs +909 -0
  96. package/src/glossary-check.mjs +35 -0
  97. package/src/glossary.mjs +243 -0
  98. package/src/graph.mjs +298 -0
  99. package/src/guard.mjs +488 -0
  100. package/src/health.mjs +212 -0
  101. package/src/hints.mjs +129 -0
  102. package/src/host-integration.mjs +339 -0
  103. package/src/hub-membership.mjs +284 -0
  104. package/src/hub.mjs +292 -0
  105. package/src/hud.mjs +436 -0
  106. package/src/index-file.mjs +163 -0
  107. package/src/index.mjs +478 -0
  108. package/src/init.mjs +512 -0
  109. package/src/install.mjs +222 -0
  110. package/src/journal-read.mjs +116 -0
  111. package/src/journal.mjs +472 -0
  112. package/src/lifecycle.mjs +1444 -0
  113. package/src/lint.mjs +386 -0
  114. package/src/managed-path.mjs +192 -0
  115. package/src/markdown-code-spans.mjs +58 -0
  116. package/src/migrate-prompts.mjs +171 -0
  117. package/src/migrate-template.mjs +170 -0
  118. package/src/migrate.mjs +90 -0
  119. package/src/misuse-read.mjs +61 -0
  120. package/src/model-request.mjs +33 -0
  121. package/src/model.mjs +523 -0
  122. package/src/modules.mjs +355 -0
  123. package/src/naming.mjs +55 -0
  124. package/src/native-action.mjs +59 -0
  125. package/src/native-record.mjs +244 -0
  126. package/src/new.mjs +1321 -0
  127. package/src/output-identity.mjs +111 -0
  128. package/src/parent-plan.mjs +58 -0
  129. package/src/parse-cache.mjs +99 -0
  130. package/src/path-containment.mjs +15 -0
  131. package/src/pickup-card.mjs +293 -0
  132. package/src/pickup.mjs +671 -0
  133. package/src/prompt.mjs +25 -0
  134. package/src/prompts.mjs +585 -0
  135. package/src/query.mjs +977 -0
  136. package/src/record-action.mjs +11 -0
  137. package/src/record-create.mjs +82 -0
  138. package/src/reference-planner.mjs +388 -0
  139. package/src/reference-values.mjs +11 -0
  140. package/src/rename.mjs +107 -0
  141. package/src/render.mjs +678 -0
  142. package/src/roadmap.mjs +184 -0
  143. package/src/runlist.mjs +944 -0
  144. package/src/section.mjs +225 -0
  145. package/src/semantic-search-client.mjs +58 -0
  146. package/src/ship.mjs +166 -0
  147. package/src/show.mjs +101 -0
  148. package/src/skill-drift.mjs +98 -0
  149. package/src/source-editor.mjs +491 -0
  150. package/src/stages.mjs +73 -0
  151. package/src/state-migration.mjs +123 -0
  152. package/src/stats.mjs +200 -0
  153. package/src/status-metadata.mjs +87 -0
  154. package/src/statuses.mjs +721 -0
  155. package/src/summary.mjs +74 -0
  156. package/src/surfaces.mjs +28 -0
  157. package/src/sync-status.mjs +316 -0
  158. package/src/template-store.mjs +62 -0
  159. package/src/update.mjs +338 -0
  160. package/src/use.mjs +58 -0
  161. package/src/util.mjs +337 -0
  162. package/src/validate.mjs +794 -0
  163. package/src/watch.mjs +56 -0
  164. package/src/xref.mjs +406 -0
  165. package/src/yardstick-command.mjs +178 -0
  166. package/src/yardstick.mjs +112 -0
@@ -0,0 +1,35 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { suggestCandidates } from './util.mjs';
4
+
5
+ function sectionHeadingRegex(sectionHeading) {
6
+ return new RegExp(`^##\\s+${sectionHeading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'm');
7
+ }
8
+
9
+ export function checkGlossaryConfig(config) {
10
+ const glossaryConfig = config.raw?.glossary;
11
+ if (!glossaryConfig?.path) return [];
12
+
13
+ const filePath = path.resolve(config.repoRoot, glossaryConfig.path);
14
+ if (!existsSync(filePath)) {
15
+ return [{
16
+ path: glossaryConfig.path,
17
+ level: 'warning',
18
+ message: `Glossary file configured at \`${glossaryConfig.path}\` but the file does not exist.`,
19
+ }];
20
+ }
21
+
22
+ const content = readFileSync(filePath, 'utf8');
23
+ const section = glossaryConfig.section ?? 'Terminology';
24
+ if (sectionHeadingRegex(section).test(content)) return [];
25
+
26
+ const headings = [...content.matchAll(/^##\s+(.+?)\s*$/gm)].map(m => m[1].trim());
27
+ const suggestions = suggestCandidates(section, headings, 3);
28
+ const nearby = suggestions.length ? suggestions : headings.slice(0, 3);
29
+ const hint = nearby.length ? ` Nearby headings: ${nearby.map(s => `\`${s}\``).join(', ')}.` : '';
30
+ return [{
31
+ path: glossaryConfig.path,
32
+ level: 'warning',
33
+ message: `Glossary config points at section \`## ${section}\`, but that heading is missing in \`${glossaryConfig.path}\`.${hint} Update \`glossary.path\` / \`glossary.section\` or restore the heading.`,
34
+ }];
35
+ }
@@ -0,0 +1,243 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { buildIndex } from './index.mjs';
4
+ import { die, warn, suggestCandidates } from './util.mjs';
5
+ import { bold, dim, green, yellow } from './color.mjs';
6
+
7
+ function sectionHeadingRegex(sectionHeading) {
8
+ return new RegExp(`^##\\s+${sectionHeading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'm');
9
+ }
10
+
11
+ function parseGlossaryTable(content, sectionHeading) {
12
+ const headingRegex = sectionHeadingRegex(sectionHeading);
13
+ const match = content.match(headingRegex);
14
+ if (!match) return { found: false, entries: [] };
15
+
16
+ const sectionStart = match.index + match[0].length;
17
+ const nextHeading = content.indexOf('\n## ', sectionStart);
18
+ const section = nextHeading > -1 ? content.slice(sectionStart, nextHeading) : content.slice(sectionStart);
19
+
20
+ const entries = [];
21
+ const lines = section.split('\n');
22
+ let headerParsed = false;
23
+ let columns = [];
24
+
25
+ for (const line of lines) {
26
+ if (!line.startsWith('|')) continue;
27
+ const cells = line.split('|').slice(1, -1).map(c => c.trim());
28
+
29
+ if (!headerParsed) {
30
+ columns = cells.map(c => c.toLowerCase().replace(/\*\*/g, ''));
31
+ headerParsed = true;
32
+ continue;
33
+ }
34
+
35
+ if (cells.every(c => /^[-:]+$/.test(c))) continue;
36
+
37
+ const term = cells[0]?.replace(/\*\*/g, '').trim();
38
+ if (!term) continue;
39
+
40
+ const entry = { term };
41
+ for (let i = 1; i < columns.length; i++) {
42
+ entry[columns[i]] = cells[i] || '';
43
+ }
44
+ entries.push(entry);
45
+ }
46
+
47
+ // Schema→UI mappings
48
+ const mappingRegex = /^-\s+`([^`]+)`\s+→\s+"([^"]+)"\s*(?:\(([^)]+)\))?/gm;
49
+ let m;
50
+ while ((m = mappingRegex.exec(section)) !== null) {
51
+ entries.push({ term: m[1], meaning: `UI label: "${m[2]}"${m[3] ? ` (${m[3]})` : ''}`, tiers: 'schema→UI' });
52
+ }
53
+
54
+ return { found: true, entries };
55
+ }
56
+
57
+ function loadGlossary(config) {
58
+ const glossaryConfig = config.raw?.glossary;
59
+ if (!glossaryConfig?.path) return null;
60
+
61
+ const filePath = path.resolve(config.repoRoot, glossaryConfig.path);
62
+ if (!existsSync(filePath)) {
63
+ warn(`Glossary file not found: ${glossaryConfig.path}`);
64
+ return null;
65
+ }
66
+
67
+ const content = readFileSync(filePath, 'utf8');
68
+ const section = glossaryConfig.section ?? 'Terminology';
69
+ const result = parseGlossaryTable(content, section);
70
+ return { ...result, path: glossaryConfig.path, section };
71
+ }
72
+
73
+ function matchTerm(query, entries) {
74
+ const lower = query.toLowerCase();
75
+
76
+ // Exact match first
77
+ const exact = entries.filter(e => e.term.toLowerCase() === lower);
78
+ if (exact.length > 0) return exact;
79
+
80
+ // Term starts with query
81
+ const startsWith = entries.filter(e => e.term.toLowerCase().startsWith(lower));
82
+ if (startsWith.length > 0) return startsWith;
83
+
84
+ // Substring in term
85
+ const termMatch = entries.filter(e => e.term.toLowerCase().includes(lower));
86
+ if (termMatch.length > 0) return termMatch;
87
+
88
+ // Substring in meaning (broadest)
89
+ return entries.filter(e => e.meaning?.toLowerCase().includes(lower));
90
+ }
91
+
92
+ function findRelatedDocs(entry, index) {
93
+ const termLower = entry.term.toLowerCase();
94
+ // Split compound terms like "Trail / Summit / Expedition"
95
+ const termParts = entry.term.split(/\s*\/\s*/).map(t => t.trim().toLowerCase());
96
+
97
+ return index.docs.filter(d => {
98
+ // Module match (exact)
99
+ if (d.module?.toLowerCase() === termLower) return true;
100
+ if (d.modules?.some(m => m.toLowerCase() === termLower)) return true;
101
+ // Module match on term parts
102
+ if (termParts.some(p => d.module?.toLowerCase() === p || d.modules?.some(m => m.toLowerCase() === p))) return true;
103
+ // Path contains term (for terms like "example-term" that aren't modules)
104
+ if (termParts.some(p => p.length >= 4 && d.path.toLowerCase().includes(p))) return true;
105
+ // Title match
106
+ if (termParts.some(p => p.length >= 4 && d.title?.toLowerCase().includes(p))) return true;
107
+ return false;
108
+ });
109
+ }
110
+
111
+ function findSeeAlso(entry, allEntries) {
112
+ const termLower = entry.term.toLowerCase();
113
+ const parts = entry.term.split(/\s*\/\s*/).map(t => t.trim().toLowerCase());
114
+
115
+ return allEntries.filter(other => {
116
+ if (other.term === entry.term) return false;
117
+ const otherLower = other.term.toLowerCase();
118
+ // Other term contains this term or vice versa
119
+ if (otherLower.includes(termLower) || termLower.includes(otherLower)) return true;
120
+ // Other meaning references this term
121
+ if (other.meaning?.toLowerCase().includes(termLower)) return true;
122
+ // Part match
123
+ if (parts.some(p => p.length >= 4 && (otherLower.includes(p) || other.meaning?.toLowerCase().includes(p)))) return true;
124
+ return false;
125
+ });
126
+ }
127
+
128
+ function renderEntry(entry, index, allEntries) {
129
+ const lines = [];
130
+ lines.push(`${green(bold(entry.term))}`);
131
+ if (entry.meaning) lines.push(` ${entry.meaning}`);
132
+ if (entry.tiers) lines.push(` ${dim(`Tiers: ${entry.tiers}`)}`);
133
+
134
+ const relatedDocs = findRelatedDocs(entry, index);
135
+
136
+ if (relatedDocs.length > 0) {
137
+ lines.push('');
138
+
139
+ // Module entry point (the main module doc, e.g. ledger.md)
140
+ const entryPoint = relatedDocs.find(d =>
141
+ d.root?.includes('modules') && path.basename(d.path, '.md') === entry.term.toLowerCase()
142
+ );
143
+ if (entryPoint) {
144
+ lines.push(` ${bold('Entry point:')} ${entryPoint.path}`);
145
+ }
146
+
147
+ // Other module docs (count, not list)
148
+ const moduleDocs = relatedDocs.filter(d => d.root?.includes('modules') && d !== entryPoint);
149
+ if (moduleDocs.length > 0) {
150
+ lines.push(` ${bold('Module docs:')} ${moduleDocs.length} files in ${dim(path.dirname(moduleDocs[0].path))}`);
151
+ }
152
+
153
+ // Plans grouped by status
154
+ const planGroups = [
155
+ ['Active', 'active'],
156
+ ['Paused', 'paused'],
157
+ ['Ready', 'ready'],
158
+ ['Planned', 'planned'],
159
+ ['Blocked', 'blocked'],
160
+ ['Research', 'research'],
161
+ ];
162
+
163
+ for (const [label, status] of planGroups) {
164
+ const plans = relatedDocs.filter(d => d.type === 'plan' && d.status === status);
165
+ if (plans.length === 0) continue;
166
+ lines.push(` ${bold(`${label}:`)} ${plans.map(d => path.basename(d.path, '.md')).join(', ')}`);
167
+ }
168
+ }
169
+
170
+ // See also: related glossary terms
171
+ const seeAlso = findSeeAlso(entry, allEntries);
172
+ if (seeAlso.length > 0) {
173
+ lines.push(` ${dim('See also:')} ${seeAlso.map(e => e.term).join(', ')}`);
174
+ }
175
+
176
+ lines.push('');
177
+ return lines.join('\n');
178
+ }
179
+
180
+ export function runGlossary(argv, config) {
181
+ const json = argv.includes('--json');
182
+ const listAll = argv.includes('--list');
183
+ const term = argv.find(a => !a.startsWith('-'));
184
+
185
+ const result = loadGlossary(config);
186
+ if (!result) die('No glossary configured. Add glossary: { path, section } to your runlist config.');
187
+ if (!result.found) {
188
+ die(`Glossary section "## ${result.section}" not found in ${result.path}. Add the section, or update glossary.section in runlist.config.mjs.`);
189
+ }
190
+ if (result.entries.length === 0) {
191
+ die(`Glossary section "## ${result.section}" found in ${result.path} but contains no recognizable entries (expected markdown table or schema→UI bullets).`);
192
+ }
193
+ const entries = result.entries;
194
+
195
+ if (json && listAll) {
196
+ const index = buildIndex(config);
197
+ const enriched = entries.map(entry => ({
198
+ ...entry,
199
+ relatedDocs: findRelatedDocs(entry, index).map(d => ({ path: d.path, status: d.status, type: d.type, title: d.title })),
200
+ seeAlso: findSeeAlso(entry, entries).map(e => e.term),
201
+ }));
202
+ process.stdout.write(JSON.stringify(enriched, null, 2) + '\n');
203
+ return;
204
+ }
205
+
206
+ if (listAll) {
207
+ process.stdout.write(bold('Glossary') + dim(` (${entries.length} terms)`) + '\n\n');
208
+ const maxTerm = Math.max(...entries.map(e => e.term.length));
209
+ for (const entry of entries) {
210
+ process.stdout.write(` ${green(entry.term.padEnd(maxTerm + 2))} ${entry.meaning || ''}\n`);
211
+ }
212
+ return;
213
+ }
214
+
215
+ if (!term) die('Usage: runlist glossary <term> | --list | --json');
216
+
217
+ const matches = matchTerm(term, entries);
218
+
219
+ if (json) {
220
+ const index = buildIndex(config);
221
+ const enriched = matches.map(entry => ({
222
+ ...entry,
223
+ relatedDocs: findRelatedDocs(entry, index).map(d => ({ path: d.path, status: d.status, type: d.type, title: d.title })),
224
+ seeAlso: findSeeAlso(entry, entries).map(e => e.term),
225
+ }));
226
+ process.stdout.write(JSON.stringify(enriched, null, 2) + '\n');
227
+ return;
228
+ }
229
+
230
+ if (matches.length === 0) {
231
+ const suggestions = suggestCandidates(term, entries.map(e => e.term));
232
+ const hint = suggestions.length ? ` Did you mean: ${suggestions.join(', ')}?` : '';
233
+ process.stdout.write(dim(`No glossary match for "${term}".${hint}`) + '\n');
234
+ return;
235
+ }
236
+
237
+ const index = buildIndex(config);
238
+ for (const entry of matches) {
239
+ process.stdout.write(renderEntry(entry, index, entries));
240
+ }
241
+ }
242
+
243
+ export { loadGlossary };
package/src/graph.mjs ADDED
@@ -0,0 +1,298 @@
1
+ import path from 'node:path';
2
+ import { toSlug, toRepoPath, warn, resolveRefPath } from './util.mjs';
3
+ import { bold, red, green, dim } from './color.mjs';
4
+
5
+ const STATUS_COLORS = {
6
+ active: '#b3e6b3',
7
+ ready: '#b3d9ff',
8
+ planned: '#ffffb3',
9
+ research: '#e6ccff',
10
+ blocked: '#ffb3b3',
11
+ reference: '#d9d9d9',
12
+ archived: '#e6e6e6',
13
+ };
14
+ const DEFAULT_COLOR = '#f2f2f2';
15
+
16
+ function tupleSetHas(root, values) {
17
+ let current = root;
18
+ for (let i = 0; i < values.length - 1; i++) {
19
+ current = current.get(values[i]);
20
+ if (!current) return false;
21
+ }
22
+ return current.has(values.at(-1));
23
+ }
24
+
25
+ function tupleSetAdd(root, values) {
26
+ let current = root;
27
+ for (let i = 0; i < values.length - 1; i++) {
28
+ if (!current.has(values[i])) current.set(values[i], new Map());
29
+ current = current.get(values[i]);
30
+ }
31
+ current.set(values.at(-1), true);
32
+ }
33
+
34
+ export function buildGraph(index, config, filters = {}) {
35
+ const biFields = new Set(config.referenceFields.bidirectional || []);
36
+ const uniFields = new Set(config.referenceFields.unidirectional || []);
37
+ const allRefFields = [...biFields, ...uniFields];
38
+
39
+ // Filter docs
40
+ let docs = index.docs;
41
+ if (filters.statuses?.length) {
42
+ docs = docs.filter(d => filters.statuses.includes(d.status));
43
+ }
44
+ if (filters.module) {
45
+ const m = filters.module.toLowerCase();
46
+ docs = docs.filter(d => (d.modules ?? []).some(mod => mod.toLowerCase() === m) || (d.module ?? '').toLowerCase() === m);
47
+ }
48
+ if (filters.surface) {
49
+ const s = filters.surface.toLowerCase();
50
+ docs = docs.filter(d => (d.surfaces ?? []).some(sf => sf.toLowerCase() === s) || (d.surface ?? '').toLowerCase() === s);
51
+ }
52
+
53
+ const docPathSet = new Set(docs.map(d => d.path));
54
+ const allDocPaths = new Set(index.docs.map(d => d.path));
55
+ const docByPath = new Map(index.docs.map(d => [d.path, d]));
56
+
57
+ // Build nodes
58
+ const nodes = docs.map(d => ({
59
+ id: d.path,
60
+ slug: toSlug(d),
61
+ title: d.title,
62
+ status: d.status,
63
+ module: d.module,
64
+ modules: d.modules,
65
+ surface: d.surface,
66
+ surfaces: d.surfaces,
67
+ edgeCount: 0,
68
+ }));
69
+ const nodeMap = new Map(nodes.map(n => [n.id, n]));
70
+
71
+ // Build edges
72
+ const edges = [];
73
+ const edgeKeys = new Map();
74
+ const referencedPaths = new Set();
75
+
76
+ for (const doc of docs) {
77
+ const docDir = path.dirname(path.join(config.repoRoot, doc.path));
78
+
79
+ for (const field of allRefFields) {
80
+ for (const relPath of (doc.refFields[field] || [])) {
81
+ const resolved = resolveRefPath(relPath, docDir, config.repoRoot) ?? path.resolve(docDir, relPath);
82
+ const targetPath = toRepoPath(resolved, config.repoRoot);
83
+ const edgeKey = [doc.path, targetPath, field];
84
+ if (tupleSetHas(edgeKeys, edgeKey)) continue;
85
+ tupleSetAdd(edgeKeys, edgeKey);
86
+
87
+ const broken = !allDocPaths.has(targetPath);
88
+ const external = !broken && !docPathSet.has(targetPath);
89
+
90
+ edges.push({
91
+ source: doc.path,
92
+ target: targetPath,
93
+ field,
94
+ type: biFields.has(field) ? 'bidirectional' : 'unidirectional',
95
+ broken,
96
+ external,
97
+ });
98
+
99
+ referencedPaths.add(targetPath);
100
+ referencedPaths.add(doc.path);
101
+ }
102
+ }
103
+ }
104
+
105
+ // Count edges per node
106
+ for (const edge of edges) {
107
+ if (nodeMap.has(edge.source)) nodeMap.get(edge.source).edgeCount++;
108
+ }
109
+
110
+ // Find orphans (no outgoing or incoming edges among filtered docs)
111
+ const connectedPaths = new Set();
112
+ for (const edge of edges) {
113
+ connectedPaths.add(edge.source);
114
+ if (!edge.broken && !edge.external) connectedPaths.add(edge.target);
115
+ }
116
+ const orphans = docs.filter(d => !connectedPaths.has(d.path)).map(d => d.path);
117
+
118
+ const brokenEdges = edges.filter(e => e.broken);
119
+
120
+ return {
121
+ nodes,
122
+ edges,
123
+ orphans,
124
+ brokenEdges,
125
+ stats: {
126
+ nodeCount: nodes.length,
127
+ edgeCount: edges.length,
128
+ orphanCount: orphans.length,
129
+ brokenEdgeCount: brokenEdges.length,
130
+ },
131
+ };
132
+ }
133
+
134
+ // ── Text renderer ──────────────────────────────────────────────────────
135
+
136
+ export function renderGraphText(graph, config) {
137
+ const defaultRenderer = (g) => _renderGraphText(g, config);
138
+ if (!config._execution?.suppressSideEffects && config.hooks.renderGraph) {
139
+ try { return config.hooks.renderGraph(graph, defaultRenderer); }
140
+ catch (err) { warn(`Hook 'renderGraph' threw: ${err.message}`); }
141
+ }
142
+ return defaultRenderer(graph);
143
+ }
144
+
145
+ function _renderGraphText(graph, config) {
146
+ const { nodes, edges, orphans, stats } = graph;
147
+
148
+ if (stats.nodeCount === 0) return 'No documents found.\n';
149
+
150
+ const allRefFields = [
151
+ ...(config.referenceFields.bidirectional || []),
152
+ ...(config.referenceFields.unidirectional || []),
153
+ ];
154
+ if (allRefFields.length === 0) {
155
+ return `Graph — ${stats.nodeCount} docs, no reference fields configured\n\nAdd \`referenceFields\` to your config to enable relationship tracking.\n`;
156
+ }
157
+
158
+ const lines = [];
159
+ const parts = [`${stats.nodeCount} docs`, `${stats.edgeCount} edges`];
160
+ if (stats.orphanCount > 0) parts.push(`${stats.orphanCount} orphans`);
161
+ if (stats.brokenEdgeCount > 0) parts.push(`${stats.brokenEdgeCount} broken`);
162
+ lines.push(bold(`Graph`) + dim(` — ${parts.join(', ')}`));
163
+ lines.push('');
164
+
165
+ // Compute max field name length for alignment
166
+ const fieldNames = [...new Set(edges.map(e => e.field))];
167
+ const maxFieldLen = fieldNames.length > 0 ? Math.max(...fieldNames.map(f => f.length)) : 0;
168
+
169
+ // Group edges by source
170
+ const edgesBySource = new Map();
171
+ for (const edge of edges) {
172
+ if (!edgesBySource.has(edge.source)) edgesBySource.set(edge.source, []);
173
+ edgesBySource.get(edge.source).push(edge);
174
+ }
175
+
176
+ // Build a slug lookup for targets
177
+ const allDocByPath = new Map();
178
+ for (const n of nodes) allDocByPath.set(n.id, n.slug);
179
+
180
+ // Render each node with edges
181
+ const nodesWithEdges = nodes.filter(n => edgesBySource.has(n.id));
182
+ const nodesWithoutEdges = nodes.filter(n => !edgesBySource.has(n.id) && !orphans.includes(n.id));
183
+
184
+ for (const node of nodesWithEdges) {
185
+ lines.push(`${node.slug} ${dim(`(${node.status})`)}`);
186
+ for (const edge of edgesBySource.get(node.id)) {
187
+ const targetSlug = allDocByPath.get(edge.target) ?? path.basename(edge.target, '.md');
188
+ const fieldPad = edge.field.padEnd(maxFieldLen);
189
+ let line = ` ${'──'} ${fieldPad} ${'──'} ${targetSlug}`;
190
+ if (edge.broken) line += ' ' + red('[broken]');
191
+ if (edge.external) line += ' ' + dim('[external]');
192
+ lines.push(line);
193
+ }
194
+ lines.push('');
195
+ }
196
+
197
+ if (orphans.length > 0) {
198
+ const orphanSlugs = orphans.map(p => path.basename(p, '.md'));
199
+ lines.push(`${dim('Orphans')}: ${orphanSlugs.join(', ')}`);
200
+ lines.push('');
201
+ }
202
+
203
+ return `${lines.join('\n').trimEnd()}\n`;
204
+ }
205
+
206
+ // ── DOT renderer ───────────────────────────────────────────────────────
207
+
208
+ export function renderGraphDot(graph, config) {
209
+ const { nodes, edges } = graph;
210
+ const lines = [];
211
+ lines.push('digraph runlist {');
212
+ lines.push(' rankdir=LR;');
213
+ lines.push(' node [shape=box, style="rounded,filled", fontname="Helvetica"];');
214
+ lines.push('');
215
+
216
+ // Nodes
217
+ const nodeSet = new Set(nodes.map(n => n.id));
218
+ for (const node of nodes) {
219
+ const color = STATUS_COLORS[node.status] ?? DEFAULT_COLOR;
220
+ lines.push(` ${dotString(node.id)} [label=${dotString(`${node.slug}\n(${node.status ?? 'unknown'})`)}, fillcolor=${dotString(color)}];`);
221
+ }
222
+
223
+ // Synthesize broken/external target nodes
224
+ const syntheticNodes = new Set();
225
+ for (const edge of edges) {
226
+ const targetSlug = path.basename(edge.target, '.md');
227
+ if (!nodeSet.has(edge.target) && !syntheticNodes.has(edge.target)) {
228
+ syntheticNodes.add(edge.target);
229
+ if (edge.broken) {
230
+ lines.push(` ${dotString(edge.target)} [label=${dotString(`${targetSlug}\n(unknown)`)}, style="rounded,dashed,filled", fillcolor="#ffb3b3"];`);
231
+ } else if (edge.external) {
232
+ lines.push(` ${dotString(edge.target)} [label=${dotString(`${targetSlug}\n(filtered)`)}, style="rounded,dashed,filled", fillcolor="#e6e6e6"];`);
233
+ }
234
+ }
235
+ }
236
+
237
+ lines.push('');
238
+
239
+ // Detect mutual bidirectional edges for dir=both rendering
240
+ const biEdgeIndex = new Map();
241
+ for (const edge of edges) {
242
+ if (edge.type !== 'bidirectional') continue;
243
+ tupleSetAdd(biEdgeIndex, [edge.source, edge.target, edge.field]);
244
+ }
245
+
246
+ const rendered = new Map();
247
+ for (const edge of edges) {
248
+ const [first, second] = edge.source.localeCompare(edge.target) <= 0
249
+ ? [edge.source, edge.target]
250
+ : [edge.target, edge.source];
251
+ const edgeKey = [first, second, edge.field];
252
+
253
+ if (tupleSetHas(rendered, edgeKey)) continue;
254
+ tupleSetAdd(rendered, edgeKey);
255
+
256
+ if (edge.broken) {
257
+ lines.push(` ${dotString(edge.source)} -> ${dotString(edge.target)} [style=dashed, color=red, label=${dotString(edge.field)}];`);
258
+ } else if (edge.type === 'bidirectional') {
259
+ // Check if reverse edge exists
260
+ if (tupleSetHas(biEdgeIndex, [edge.target, edge.source, edge.field])) {
261
+ lines.push(` ${dotString(edge.source)} -> ${dotString(edge.target)} [dir=both, label=${dotString(edge.field)}, color="#666666"];`);
262
+ } else {
263
+ lines.push(` ${dotString(edge.source)} -> ${dotString(edge.target)} [label=${dotString(edge.field)}, color="#666666"];`);
264
+ }
265
+ } else {
266
+ const style = edge.external ? ', style=dashed' : '';
267
+ lines.push(` ${dotString(edge.source)} -> ${dotString(edge.target)} [label=${dotString(edge.field)}, color="#999999"${style}];`);
268
+ }
269
+ }
270
+
271
+ lines.push('}');
272
+ return lines.join('\n') + '\n';
273
+ }
274
+
275
+ function dotString(value) {
276
+ const escaped = String(value).replace(/[\\"\r\n\x00-\x1f\x7f]/g, character => {
277
+ if (character === '\\') return '\\\\';
278
+ if (character === '"') return '\\"';
279
+ if (character === '\r') return '\\r';
280
+ if (character === '\n') return '\\n';
281
+ return `\\x${character.charCodeAt(0).toString(16).padStart(2, '0')}`;
282
+ });
283
+ return `"${escaped}"`;
284
+ }
285
+
286
+ // ── JSON renderer ──────────────────────────────────────────────────────
287
+
288
+ export function renderGraphJson(graph) {
289
+ return JSON.stringify({
290
+ generatedAt: new Date().toISOString(),
291
+ stats: graph.stats,
292
+ nodes: graph.nodes,
293
+ edges: graph.edges.map(({ source, target, field, type, broken }) => ({
294
+ source, target, field, type, broken,
295
+ })),
296
+ orphans: graph.orphans,
297
+ }, null, 2) + '\n';
298
+ }