runlist 0.0.0 → 0.94.2

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 (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1112 -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 +296 -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/public-inspection.mjs +19 -0
  136. package/src/query.mjs +977 -0
  137. package/src/record-action.mjs +11 -0
  138. package/src/record-create.mjs +82 -0
  139. package/src/reference-planner.mjs +388 -0
  140. package/src/reference-values.mjs +11 -0
  141. package/src/rename.mjs +107 -0
  142. package/src/render.mjs +678 -0
  143. package/src/roadmap.mjs +184 -0
  144. package/src/runlist.mjs +944 -0
  145. package/src/section.mjs +225 -0
  146. package/src/semantic-search-client.mjs +58 -0
  147. package/src/ship.mjs +166 -0
  148. package/src/show.mjs +101 -0
  149. package/src/skill-drift.mjs +98 -0
  150. package/src/source-editor.mjs +491 -0
  151. package/src/stages.mjs +73 -0
  152. package/src/state-migration.mjs +123 -0
  153. package/src/stats.mjs +200 -0
  154. package/src/status-metadata.mjs +87 -0
  155. package/src/statuses.mjs +721 -0
  156. package/src/summary.mjs +74 -0
  157. package/src/surfaces.mjs +28 -0
  158. package/src/sync-status.mjs +316 -0
  159. package/src/template-store.mjs +62 -0
  160. package/src/update.mjs +338 -0
  161. package/src/use.mjs +58 -0
  162. package/src/util.mjs +337 -0
  163. package/src/validate.mjs +795 -0
  164. package/src/watch.mjs +56 -0
  165. package/src/xref.mjs +406 -0
  166. package/src/yardstick-command.mjs +178 -0
  167. package/src/yardstick.mjs +112 -0
@@ -0,0 +1,44 @@
1
+ import { readGlobalErrors, globalErrorLogPath } from './journal.mjs';
2
+ import { die } from './util.mjs';
3
+ import { dim, red } from './color.mjs';
4
+
5
+ // `runlist errors` — the newest failed runlist commands, from the cross-repo
6
+ // error log every failing invocation appends to.
7
+ function parseArgs(argv) {
8
+ const opts = { limit: null, repo: null, asJson: false, byFamily: false };
9
+ for (let i = 0; i < argv.length; i++) {
10
+ const a = argv[i];
11
+ if (a === '--limit' || a === '--tail') {
12
+ const n = Number(argv[++i]);
13
+ if (!Number.isInteger(n) || n < 1) die(`${a} takes a whole number of entries, 1 or more.`);
14
+ opts.limit = n;
15
+ } else if (a === '--repo' && argv[i + 1]) opts.repo = argv[++i];
16
+ else if (a === '--json') opts.asJson = true;
17
+ else if (a === '--by-family') opts.byFamily = true;
18
+ }
19
+ if (opts.limit === null && !opts.byFamily) opts.limit = 20;
20
+ return opts;
21
+ }
22
+
23
+ export function runErrors(argv) {
24
+ const opts = parseArgs(argv);
25
+ const entries = readGlobalErrors({ limit: opts.limit, repo: opts.repo, includeFamily: opts.byFamily });
26
+ if (opts.byFamily) {
27
+ const groups = [...new Set(entries.map(e => e.family))].sort().map(family => {
28
+ const rows = entries.filter(e => e.family === family);
29
+ return { family, total: rows.length, sessions: new Set(rows.map(e => e.session).filter(Boolean)).size, latest: rows[0] };
30
+ });
31
+ if (opts.asJson) process.stdout.write(JSON.stringify({ total: entries.length, groups }, null, 2) + '\n');
32
+ else {
33
+ process.stdout.write(`${entries.length} retained failures (${opts.limit ? `newest ${opts.limit} at most` : 'all retained'}).\n`);
34
+ for (const row of groups) process.stdout.write(`${row.family}: ${row.total}, ${row.sessions} sessions\n ${row.latest.message}\n`);
35
+ }
36
+ return;
37
+ }
38
+ if (opts.asJson) { process.stdout.write(`${JSON.stringify(entries, null, 2)}\n`); return; }
39
+ if (!entries.length) { process.stdout.write(`No failed runlist commands recorded in ${globalErrorLogPath()}.\n`); return; }
40
+ for (const e of entries) {
41
+ const repo = e.repo ? dim(` ${e.repo.split('/').pop()}`) : '';
42
+ process.stdout.write(`[${e.at}]${repo} ${e.command}\n ${red(e.message ?? '(no message)')}\n`);
43
+ }
44
+ }
package/src/export.mjs ADDED
@@ -0,0 +1,496 @@
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter } from './frontmatter.mjs';
4
+ import { buildIndex } from './index.mjs';
5
+ import { buildGraph } from './graph.mjs';
6
+ import { resolveDocPath, resolveRefPath, toRepoPath, capitalize, die } from './util.mjs';
7
+ import { allocateOutputIdentities } from './output-identity.mjs';
8
+
9
+ export function runExport(argv, config, opts = {}) {
10
+ const positional = [];
11
+ let format = 'md';
12
+ let output = null;
13
+ let statusFilter = null;
14
+ let moduleFilter = null;
15
+ let rootFilter = opts.root ?? null;
16
+ let typeFilter = opts.type ?? null;
17
+ const dryRun = opts.dryRun;
18
+
19
+ for (let i = 0; i < argv.length; i++) {
20
+ if (argv[i] === '--format' && argv[i + 1]) { format = argv[++i]; continue; }
21
+ if (argv[i] === '--output' && argv[i + 1]) { output = argv[++i]; continue; }
22
+ if (argv[i] === '--status' && argv[i + 1]) { statusFilter = argv[++i]; continue; }
23
+ if (argv[i] === '--module' && argv[i + 1]) { moduleFilter = argv[++i]; continue; }
24
+ if (argv[i] === '--root' && argv[i + 1]) { rootFilter = argv[++i]; continue; }
25
+ if (argv[i] === '--type' && argv[i + 1]) { typeFilter = argv[++i]; continue; }
26
+ if (argv[i] === '--config') { i++; continue; }
27
+ if (argv[i].startsWith('-')) continue;
28
+ positional.push(argv[i]);
29
+ }
30
+
31
+ if (!['md', 'html', 'json'].includes(format)) {
32
+ die(`Invalid format: ${format}\nValid: md, html, json`);
33
+ }
34
+
35
+ const index = buildIndex(config);
36
+ let docs;
37
+
38
+ if (positional[0]) {
39
+ // Single doc + deps mode
40
+ const filePath = resolveDocPath(positional[0], config);
41
+ if (!filePath) die(`File not found: ${positional[0]}`);
42
+ const repoPath = toRepoPath(filePath, config.repoRoot);
43
+ const graph = buildGraph(index, config);
44
+ const depPaths = collectDeps(repoPath, graph);
45
+ docs = index.docs.filter(d => depPaths.has(d.path));
46
+ } else {
47
+ // All docs, filtered
48
+ docs = index.docs;
49
+ if (statusFilter) {
50
+ const statuses = statusFilter.split(',').map(s => s.trim());
51
+ docs = docs.filter(d => statuses.includes(d.status));
52
+ }
53
+ if (moduleFilter) {
54
+ const m = moduleFilter.toLowerCase();
55
+ docs = docs.filter(d => (d.module ?? '').toLowerCase() === m || (d.modules ?? []).some(mod => mod.toLowerCase() === m));
56
+ }
57
+ if (rootFilter) {
58
+ docs = docs.filter(d => d.root === rootFilter || d.root?.endsWith('/' + rootFilter) || d.root?.split('/').pop() === rootFilter);
59
+ }
60
+ if (typeFilter) {
61
+ const types = typeFilter.split(',').map(t => t.trim()).filter(Boolean);
62
+ docs = docs.filter(d => types.includes(d.type));
63
+ }
64
+ }
65
+
66
+ if (docs.length === 0) {
67
+ die('No docs to export.');
68
+ }
69
+
70
+ // Load bodies
71
+ const docsWithBody = docs.map(d => loadDocWithBody(d, config));
72
+
73
+ const prefix = dryRun ? '[dry-run] ' : '';
74
+
75
+ if (format === 'md') {
76
+ if (dryRun) {
77
+ process.stdout.write(`${prefix}Would export ${docs.length} docs as markdown`);
78
+ process.stdout.write(output ? ` to ${output}\n` : ' to stdout\n');
79
+ } else {
80
+ const content = exportMarkdown(docsWithBody, config);
81
+ if (output) {
82
+ writeFileSync(output, content, 'utf8');
83
+ process.stdout.write(`Exported ${docs.length} docs to ${output}\n`);
84
+ } else {
85
+ process.stdout.write(content);
86
+ }
87
+ }
88
+ } else if (format === 'json') {
89
+ if (dryRun) {
90
+ process.stdout.write(`${prefix}Would export ${docs.length} docs as JSON`);
91
+ process.stdout.write(output ? ` to ${output}\n` : ' to stdout\n');
92
+ } else {
93
+ const content = exportJson(docsWithBody);
94
+ if (output) {
95
+ writeFileSync(output, content, 'utf8');
96
+ process.stdout.write(`Exported ${docs.length} docs to ${output}\n`);
97
+ } else {
98
+ process.stdout.write(content);
99
+ }
100
+ }
101
+ } else if (format === 'html') {
102
+ const outDir = output ?? (existsSync('dotmd-export') ? 'dotmd-export' : 'runlist-export');
103
+ const identities = allocateOutputIdentities(index.docs);
104
+ exportHtml(docsWithBody, config, outDir, identities, { dryRun });
105
+ if (dryRun) {
106
+ process.stdout.write(`${prefix}Would export ${docs.length} docs as HTML to ${outDir}/\n`);
107
+ } else {
108
+ process.stdout.write(`Exported ${docs.length} docs to ${outDir}/\n`);
109
+ }
110
+ }
111
+ }
112
+
113
+ function loadDocWithBody(doc, config) {
114
+ const raw = readFileSync(path.join(config.repoRoot, doc.path), 'utf8');
115
+ const { body } = extractFrontmatter(raw);
116
+ return { ...doc, body: body ?? '' };
117
+ }
118
+
119
+ function collectDeps(docPath, graph) {
120
+ const visited = new Set();
121
+ const queue = [docPath];
122
+ while (queue.length) {
123
+ const p = queue.shift();
124
+ if (visited.has(p)) continue;
125
+ visited.add(p);
126
+ for (const e of graph.edges) {
127
+ if (e.source === p && !e.broken && !visited.has(e.target)) {
128
+ queue.push(e.target);
129
+ }
130
+ }
131
+ }
132
+ return visited;
133
+ }
134
+
135
+ // ── Markdown export ────────────────────────────────────────────────────
136
+
137
+ function exportMarkdown(docs, config) {
138
+ const today = new Date().toISOString().slice(0, 10);
139
+ const lines = [`# Docs Export (${today})`, '', `${docs.length} documents`, ''];
140
+
141
+ const byStatus = {};
142
+ for (const d of docs) {
143
+ const s = d.status ?? 'unknown';
144
+ if (!byStatus[s]) byStatus[s] = [];
145
+ byStatus[s].push(d);
146
+ }
147
+
148
+ for (const status of config.statusOrder) {
149
+ const group = byStatus[status];
150
+ if (!group?.length) continue;
151
+ lines.push(`## ${capitalize(status)} (${group.length})`, '');
152
+ for (const doc of group) {
153
+ lines.push(`### ${doc.title}`);
154
+ const meta = [`Status: ${doc.status}`];
155
+ if (doc.updated) meta.push(`Updated: ${doc.updated}`);
156
+ if (doc.modules?.length) meta.push(`Module: ${doc.modules.join(', ')}`);
157
+ if (doc.surfaces?.length) meta.push(`Surface: ${doc.surfaces.join(', ')}`);
158
+ if (doc.owner) meta.push(`Owner: ${doc.owner}`);
159
+ lines.push(`> ${meta.join(' | ')}`, '');
160
+ if (doc.body.trim()) lines.push(doc.body.trim());
161
+ lines.push('', '---', '');
162
+ }
163
+ }
164
+
165
+ // Statuses not in config order
166
+ for (const [status, group] of Object.entries(byStatus)) {
167
+ if (config.statusOrder.includes(status)) continue;
168
+ lines.push(`## ${capitalize(status)} (${group.length})`, '');
169
+ for (const doc of group) {
170
+ lines.push(`### ${doc.title}`);
171
+ lines.push(`> Status: ${doc.status}`, '');
172
+ if (doc.body.trim()) lines.push(doc.body.trim());
173
+ lines.push('', '---', '');
174
+ }
175
+ }
176
+
177
+ return lines.join('\n').trimEnd() + '\n';
178
+ }
179
+
180
+ // ── JSON export ────────────────────────────────────────────────────────
181
+
182
+ function exportJson(docs) {
183
+ return JSON.stringify({
184
+ exportedAt: new Date().toISOString(),
185
+ count: docs.length,
186
+ docs: docs.map(d => ({
187
+ path: d.path,
188
+ root: d.root,
189
+ title: d.title,
190
+ status: d.status,
191
+ updated: d.updated,
192
+ created: d.created,
193
+ owner: d.owner,
194
+ module: d.module,
195
+ modules: d.modules,
196
+ surface: d.surface,
197
+ surfaces: d.surfaces,
198
+ domain: d.domain,
199
+ summary: d.summary,
200
+ currentState: d.currentState,
201
+ nextStep: d.nextStep,
202
+ blockers: d.blockers,
203
+ body: d.body,
204
+ })),
205
+ }, null, 2) + '\n';
206
+ }
207
+
208
+ // ── HTML export ────────────────────────────────────────────────────────
209
+
210
+ const CSS = `
211
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 2rem 1rem; color: #1a1a1a; line-height: 1.6; }
212
+ nav { margin-bottom: 2rem; font-size: 0.9rem; }
213
+ nav a { color: #0066cc; text-decoration: none; }
214
+ nav a:hover { text-decoration: underline; }
215
+ h1 { border-bottom: 2px solid #e0e0e0; padding-bottom: 0.5rem; }
216
+ h2 { color: #333; margin-top: 2rem; }
217
+ h3 { color: #555; }
218
+ table.meta { border-collapse: collapse; margin: 1rem 0; font-size: 0.9rem; }
219
+ table.meta td { padding: 0.25rem 1rem 0.25rem 0; }
220
+ table.meta td:first-child { font-weight: 600; color: #666; }
221
+ .badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 3px; font-size: 0.8rem; font-weight: 600; }
222
+ .badge-active { background: #d4edda; color: #155724; }
223
+ .badge-ready { background: #cce5ff; color: #004085; }
224
+ .badge-planned { background: #fff3cd; color: #856404; }
225
+ .badge-blocked { background: #f8d7da; color: #721c24; }
226
+ .badge-research { background: #e2d5f1; color: #4a2572; }
227
+ .badge-archived { background: #e9ecef; color: #495057; }
228
+ article { margin-top: 1rem; }
229
+ pre { background: #f5f5f5; padding: 1rem; border-radius: 4px; overflow-x: auto; }
230
+ code { background: #f0f0f0; padding: 0.15rem 0.3rem; border-radius: 3px; font-size: 0.9em; }
231
+ pre code { background: none; padding: 0; }
232
+ blockquote { border-left: 3px solid #ddd; margin-left: 0; padding-left: 1rem; color: #666; }
233
+ hr { border: none; border-top: 1px solid #e0e0e0; margin: 2rem 0; }
234
+ a { color: #0066cc; }
235
+ ul.toc { list-style: none; padding-left: 0; }
236
+ ul.toc li { padding: 0.3rem 0; }
237
+ ul.toc .status-group { font-weight: 600; margin-top: 1rem; }
238
+ `.trim();
239
+
240
+ function exportHtml(docs, config, outDir, identities, { dryRun = false } = {}) {
241
+ const emittedPaths = new Set(docs.map(doc => doc.path));
242
+ const pages = [{ htmlPath: 'index.html', content: buildIndexPage(docs, config, identities) }];
243
+ for (const doc of docs) {
244
+ const identity = identities.get(doc.path);
245
+ if (!identity) throw new Error(`Missing output identity for ${doc.path}`);
246
+ pages.push({
247
+ htmlPath: identity.htmlPath,
248
+ content: buildDocPage(doc, identity, identities, emittedPaths, config),
249
+ });
250
+ }
251
+
252
+ const guard = createOutputGuard(outDir);
253
+ for (const page of pages) guard.validate(page.htmlPath);
254
+ if (dryRun) return;
255
+
256
+ for (const page of pages) {
257
+ guard.createParent(page.htmlPath);
258
+ guard.validate(page.htmlPath);
259
+ writeFileSync(path.join(guard.outputRoot, ...page.htmlPath.split('/')), page.content, 'utf8');
260
+ }
261
+ }
262
+
263
+ function buildIndexPage(docs, config, identities) {
264
+ const today = new Date().toISOString().slice(0, 10);
265
+ const byStatus = {};
266
+ for (const d of docs) {
267
+ const s = d.status ?? 'unknown';
268
+ if (!byStatus[s]) byStatus[s] = [];
269
+ byStatus[s].push(d);
270
+ }
271
+
272
+ let toc = '';
273
+ for (const status of [...config.statusOrder, ...Object.keys(byStatus).filter(s => !config.statusOrder.includes(s))]) {
274
+ const group = byStatus[status];
275
+ if (!group?.length) continue;
276
+ toc += `<li class="status-group">${capitalize(status)} (${group.length})</li>\n`;
277
+ for (const doc of group) {
278
+ const identity = identities.get(doc.path);
279
+ toc += `<li><a href="${escHtml(identity.htmlUrl)}">${escHtml(doc.title)}</a></li>\n`;
280
+ }
281
+ }
282
+
283
+ return `<!DOCTYPE html>
284
+ <html lang="en"><head>
285
+ <meta charset="utf-8">
286
+ <meta name="viewport" content="width=device-width, initial-scale=1">
287
+ <title>Docs Export</title>
288
+ <style>${CSS}</style>
289
+ </head><body>
290
+ <h1>Docs Export</h1>
291
+ <p>${docs.length} documents &middot; ${today}</p>
292
+ <ul class="toc">
293
+ ${toc}</ul>
294
+ </body></html>
295
+ `;
296
+ }
297
+
298
+ function buildDocPage(doc, identity, identities, emittedPaths, config) {
299
+ const status = doc.status ?? 'unknown';
300
+ const badgeClass = `badge-${status.replace(/[^a-z0-9_-]/gi, '-')}`;
301
+
302
+ let meta = `<table class="meta">`;
303
+ meta += `<tr><td>Status</td><td><span class="badge ${badgeClass}">${escHtml(status)}</span></td></tr>`;
304
+ if (doc.updated) meta += `<tr><td>Updated</td><td>${escHtml(doc.updated)}</td></tr>`;
305
+ if (doc.modules?.length) meta += `<tr><td>Module</td><td>${escHtml(doc.modules.join(', '))}</td></tr>`;
306
+ if (doc.surfaces?.length) meta += `<tr><td>Surface</td><td>${escHtml(doc.surfaces.join(', '))}</td></tr>`;
307
+ if (doc.owner) meta += `<tr><td>Owner</td><td>${escHtml(doc.owner)}</td></tr>`;
308
+ meta += `<tr><td>Path</td><td><code>${escHtml(doc.path)}</code></td></tr>`;
309
+ meta += `</table>`;
310
+
311
+ const bodyHtml = mdToHtml(doc.body, href => resolveHtmlLink(href, doc, identity, identities, emittedPaths, config));
312
+ const indexHref = relativeHtmlUrl(identity.htmlPath, 'index.html');
313
+
314
+ return `<!DOCTYPE html>
315
+ <html lang="en"><head>
316
+ <meta charset="utf-8">
317
+ <meta name="viewport" content="width=device-width, initial-scale=1">
318
+ <title>${escHtml(doc.title)}</title>
319
+ <style>${CSS}</style>
320
+ </head><body>
321
+ <nav><a href="${escHtml(indexHref)}">&larr; Index</a></nav>
322
+ <article>
323
+ <h1>${escHtml(doc.title)}</h1>
324
+ ${meta}
325
+ ${bodyHtml}
326
+ </article>
327
+ </body></html>
328
+ `;
329
+ }
330
+
331
+ function mdToHtml(body, resolveLink = href => href) {
332
+ if (!body?.trim()) return '';
333
+ let html = escHtml(body);
334
+
335
+ // Fenced code blocks (must be before other replacements)
336
+ html = html.replace(/^```(\w*)\n([\s\S]*?)^```/gm, (_, lang, code) =>
337
+ `<pre><code>${code.trimEnd()}</code></pre>`
338
+ );
339
+
340
+ // Headings
341
+ html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
342
+ html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
343
+ html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
344
+ html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
345
+
346
+ // Bold and italic
347
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
348
+ html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
349
+
350
+ // Inline code (skip already-processed pre/code blocks)
351
+ html = html.replace(/(?<!<code>)`([^`]+)`/g, '<code>$1</code>');
352
+
353
+ // Links
354
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, escapedHref) => {
355
+ const href = resolveLink(unescapeHtml(escapedHref));
356
+ return href == null ? label : `<a href="${escHtml(href)}">${label}</a>`;
357
+ });
358
+
359
+ // Blockquotes
360
+ html = html.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
361
+
362
+ // Horizontal rules
363
+ html = html.replace(/^---$/gm, '<hr>');
364
+
365
+ // Lists
366
+ html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
367
+ html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
368
+
369
+ // Checklists
370
+ html = html.replace(/<li>\[x\] /gi, '<li>&#9745; ');
371
+ html = html.replace(/<li>\[ \] /g, '<li>&#9744; ');
372
+
373
+ // Paragraphs (lines not already wrapped in HTML)
374
+ html = html.replace(/^(?!<[hublopa]|<pre|<li|<hr|<block|$)(.+)$/gm, '<p>$1</p>');
375
+
376
+ return html;
377
+ }
378
+
379
+ function escHtml(text) {
380
+ return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
381
+ }
382
+
383
+ function unescapeHtml(text) {
384
+ return text.replace(/&quot;/g, '"').replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/g, '&');
385
+ }
386
+
387
+ function encodeRelativeUrl(value) {
388
+ return value.split('/').map(segment => segment === '..' || segment === '.' ? segment : encodeURIComponent(segment)).join('/');
389
+ }
390
+
391
+ function relativeHtmlUrl(from, to) {
392
+ const relative = path.posix.relative(path.posix.dirname(from), to) || path.posix.basename(to);
393
+ return encodeRelativeUrl(relative);
394
+ }
395
+
396
+ function resolveHtmlLink(href, doc, identity, identities, emittedPaths, config) {
397
+ if (!href || href.startsWith('#') || href.startsWith('/') || href.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(href)) {
398
+ return href;
399
+ }
400
+
401
+ const hashAt = href.indexOf('#');
402
+ const targetPart = hashAt === -1 ? href : href.slice(0, hashAt);
403
+ const fragment = hashAt === -1 ? '' : href.slice(hashAt);
404
+ if (!targetPart.toLowerCase().endsWith('.md')) return href;
405
+
406
+ const docDir = path.dirname(path.join(config.repoRoot, doc.path));
407
+ const resolved = resolveRefPath(targetPart, docDir, config.repoRoot);
408
+ const targetPath = resolved ? toRepoPath(resolved, config.repoRoot) : null;
409
+ if (!targetPath || !emittedPaths.has(targetPath)) return null;
410
+ return relativeHtmlUrl(identity.htmlPath, identities.get(targetPath).htmlPath) + fragment;
411
+ }
412
+
413
+ function nearestExistingAncestor(value) {
414
+ let current = value;
415
+ while (true) {
416
+ try {
417
+ lstatSync(current);
418
+ return current;
419
+ } catch (err) {
420
+ if (err.code !== 'ENOENT') throw err;
421
+ }
422
+ const parent = path.dirname(current);
423
+ if (parent === current) throw new Error(`Cannot resolve output ancestor: ${value}`);
424
+ current = parent;
425
+ }
426
+ }
427
+
428
+ function createOutputGuard(outDir) {
429
+ const outputRoot = path.resolve(outDir);
430
+ const initialAncestor = nearestExistingAncestor(outputRoot);
431
+ const initialAncestorPhysical = realpathSync(initialAncestor);
432
+ const outputRootPhysical = existsSync(outputRoot)
433
+ ? realpathSync(outputRoot)
434
+ : path.resolve(initialAncestorPhysical, path.relative(initialAncestor, outputRoot));
435
+
436
+ if (existsSync(outputRoot) && !statSync(outputRoot).isDirectory()) {
437
+ throw new Error(`HTML output root is not a directory: ${outputRoot}`);
438
+ }
439
+
440
+ function validate(htmlPath) {
441
+ const destination = path.resolve(outputRoot, ...htmlPath.split('/'));
442
+ if (destination === outputRoot || !destination.startsWith(outputRoot + path.sep)) {
443
+ throw new Error(`HTML output escapes destination: ${htmlPath}`);
444
+ }
445
+
446
+ const currentAncestor = nearestExistingAncestor(outputRoot);
447
+ const currentPhysical = realpathSync(currentAncestor);
448
+ const currentRootPhysical = existsSync(outputRoot)
449
+ ? realpathSync(outputRoot)
450
+ : path.resolve(currentPhysical, path.relative(currentAncestor, outputRoot));
451
+ if (currentRootPhysical !== outputRootPhysical) {
452
+ throw new Error(`HTML output ancestry changed during export: ${outputRoot}`);
453
+ }
454
+
455
+ let current = outputRoot;
456
+ const components = htmlPath.split('/');
457
+ for (let i = 0; i < components.length; i++) {
458
+ const component = components[i];
459
+ current = path.join(current, component);
460
+ let entry;
461
+ try { entry = lstatSync(current); } catch (err) {
462
+ if (err.code === 'ENOENT') break;
463
+ throw err;
464
+ }
465
+ if (entry.isSymbolicLink()) {
466
+ throw new Error(`HTML output path contains a descendant symlink: ${current}`);
467
+ }
468
+ const isDestination = i === components.length - 1;
469
+ if ((!isDestination && !entry.isDirectory()) || (isDestination && entry.isDirectory())) {
470
+ throw new Error(`HTML output path has an incompatible existing entry: ${current}`);
471
+ }
472
+ }
473
+ }
474
+
475
+ function createParent(htmlPath) {
476
+ if (!existsSync(outputRoot)) {
477
+ const ancestor = nearestExistingAncestor(outputRoot);
478
+ let current = ancestor;
479
+ for (const component of path.relative(ancestor, outputRoot).split(path.sep).filter(Boolean)) {
480
+ validate(htmlPath);
481
+ current = path.join(current, component);
482
+ if (!existsSync(current)) mkdirSync(current);
483
+ }
484
+ }
485
+ let current = outputRoot;
486
+ const components = htmlPath.split('/').slice(0, -1);
487
+ for (const component of components) {
488
+ validate(htmlPath);
489
+ current = path.join(current, component);
490
+ if (!existsSync(current)) mkdirSync(current);
491
+ if (!statSync(current).isDirectory()) throw new Error(`HTML output parent is not a directory: ${current}`);
492
+ }
493
+ }
494
+
495
+ return { outputRoot, validate, createParent };
496
+ }
@@ -0,0 +1,101 @@
1
+ import { maskInlineCodeSpans } from './markdown-code-spans.mjs';
2
+
3
+ export function extractFirstHeading(body) {
4
+ return body.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? null;
5
+ }
6
+
7
+ export function extractSummary(body) {
8
+ const blockquoteLines = body
9
+ .split('\n')
10
+ .filter(line => line.startsWith('> '))
11
+ .map(line => line.slice(2).trim())
12
+ .filter(Boolean);
13
+
14
+ const nonStatusLine = blockquoteLines.find(line => !/^Status note\b/i.test(line));
15
+ return nonStatusLine ?? blockquoteLines[0] ?? null;
16
+ }
17
+
18
+ export function extractStatusSnapshot(body) {
19
+ const statusNoteMatch = body.match(/^>\s+Status note(?:\s+\([^)]+\))?:\s*(.+)$/m);
20
+ if (statusNoteMatch) return statusNoteMatch[1].trim();
21
+
22
+ const boldStatusMatch = body.match(/^\*\*Status:\*\*\s*(.+)$/m);
23
+ if (boldStatusMatch) return boldStatusMatch[1].trim();
24
+
25
+ const plainStatusMatch = body.match(/^-\s+Status:\s*(.+)$/m);
26
+ if (plainStatusMatch) return plainStatusMatch[1].trim();
27
+
28
+ return null;
29
+ }
30
+
31
+ export function extractNextStep(body) {
32
+ const match = body.match(/^##+\s+(?:Suggested\s+)?Next Step\s*$([\s\S]*?)(?=^##+\s|\Z)/m);
33
+ if (!match) return null;
34
+
35
+ const lines = match[1]
36
+ .split('\n')
37
+ .map(line => line.trim())
38
+ .filter(Boolean)
39
+ .map(line => line.replace(/^[-*]\s+/, ''));
40
+
41
+ return lines[0] ?? null;
42
+ }
43
+
44
+ export function extractBodyLinks(body) {
45
+ // Every inline link contains `](`, and masking never creates one.
46
+ if (!body || !body.includes('](')) return [];
47
+ // Strip fenced code blocks, then MASK inline code rather than delete it.
48
+ // Deleting it ate the commonest link idiom in a plan hub: [`plan.md`](plan.md)
49
+ // has its link TEXT as a code span, so removing the span left `[](plan.md)`,
50
+ // which the regex below rejects for having empty text. A hub with hundreds of
51
+ // such links reported one — and since this list is what validates body links,
52
+ // every one of them was also never checked for breakage. Masking to same-length
53
+ // filler keeps the link matchable while still neutralizing a link that is
54
+ // itself inside code (`[fake](x.md)` stays unmatched), and preserves offsets.
55
+ const stripped = maskInlineCodeSpans(body.replace(/^```[\s\S]*?^```/gm, ''));
56
+ const links = [];
57
+ // Supported inline destinations are a whitespace-free token (with Markdown
58
+ // backslash escapes) or an angle-bracket destination, plus an optional
59
+ // quoted/parenthesized title. Reference links, HTML links, and nested
60
+ // unescaped parentheses remain deliberately outside this lightweight parser.
61
+ const regex = /(?<!!)\[([^\]\n]+)\]\(\s*(<[^>\n]+>|(?:\\.|[^\s()])+)(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^\n)]*\)))?\s*\)/g;
62
+ let match;
63
+ while ((match = regex.exec(stripped)) !== null) {
64
+ const destination = match[2];
65
+ const angle = destination.startsWith('<') && destination.endsWith('>');
66
+ const rawHref = angle ? destination.slice(1, -1) : destination;
67
+ // These destinations are meaningful to a renderer or another application,
68
+ // not portable repo-local filesystem targets.
69
+ if (/^(?:#|\/|[a-z][a-z\d+.-]*:)/i.test(rawHref)) continue;
70
+
71
+ const rawPath = rawHref.replace(/[?#].*$/, '');
72
+ if (!rawPath) continue;
73
+ const unescaped = rawPath.replace(/\\([\s()[\]<>])/g, '$1');
74
+ let href;
75
+ try { href = decodeURIComponent(unescaped); }
76
+ catch { href = unescaped; }
77
+ const targetKind = /\.md$/i.test(href) ? 'document' : 'file';
78
+ links.push({ text: match[1], rawHref, href, targetKind, angle });
79
+ }
80
+ return links;
81
+ }
82
+
83
+ export function extractChecklistCounts(body) {
84
+ const matches = [...body.matchAll(/^\s*[-*]\s+\[([ xX])\]\s+/gm)];
85
+ let completed = 0;
86
+ let open = 0;
87
+
88
+ for (const match of matches) {
89
+ if (match[1].toLowerCase() === 'x') {
90
+ completed += 1;
91
+ } else {
92
+ open += 1;
93
+ }
94
+ }
95
+
96
+ return {
97
+ completed,
98
+ open,
99
+ total: completed + open,
100
+ };
101
+ }