dotmd-cli 0.69.0 → 0.70.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 (54) hide show
  1. package/README.md +144 -964
  2. package/bin/dotmd.mjs +251 -202
  3. package/dotmd.config.example.mjs +5 -8
  4. package/package.json +6 -10
  5. package/src/agent-context.mjs +132 -0
  6. package/src/atomic-mutation.mjs +1505 -0
  7. package/src/baton.mjs +109 -114
  8. package/src/bulk-tag.mjs +7 -7
  9. package/src/check-collapse.mjs +2 -2
  10. package/src/commands.mjs +326 -12
  11. package/src/completions.mjs +38 -98
  12. package/src/config.mjs +18 -3
  13. package/src/diff.mjs +7 -3
  14. package/src/doctor.mjs +25 -15
  15. package/src/export.mjs +154 -25
  16. package/src/fix-refs.mjs +2 -0
  17. package/src/frontmatter-fix.mjs +9 -7
  18. package/src/frontmatter.mjs +3 -2
  19. package/src/git.mjs +722 -14
  20. package/src/graph.mjs +53 -25
  21. package/src/guard.mjs +163 -60
  22. package/src/hud.mjs +65 -76
  23. package/src/index-file.mjs +28 -16
  24. package/src/index.mjs +21 -13
  25. package/src/init.mjs +1 -1
  26. package/src/journal.mjs +145 -12
  27. package/src/lifecycle.mjs +596 -294
  28. package/src/lint.mjs +117 -56
  29. package/src/managed-path.mjs +192 -0
  30. package/src/migrate-prompts.mjs +2 -0
  31. package/src/migrate-template.mjs +2 -0
  32. package/src/migrate.mjs +7 -1
  33. package/src/new.mjs +135 -54
  34. package/src/output-identity.mjs +106 -0
  35. package/src/pickup-card.mjs +24 -10
  36. package/src/pickup.mjs +457 -0
  37. package/src/prompts.mjs +134 -75
  38. package/src/query.mjs +22 -10
  39. package/src/reference-planner.mjs +292 -0
  40. package/src/rename.mjs +65 -73
  41. package/src/render.mjs +24 -11
  42. package/src/runlist.mjs +109 -71
  43. package/src/section.mjs +2 -1
  44. package/src/ship.mjs +39 -20
  45. package/src/stats.mjs +1 -1
  46. package/src/status-metadata.mjs +87 -0
  47. package/src/statuses.mjs +11 -26
  48. package/src/summary.mjs +14 -3
  49. package/src/update.mjs +38 -10
  50. package/src/use.mjs +4 -1
  51. package/src/util.mjs +1 -0
  52. package/src/validate.mjs +53 -17
  53. package/src/watch.mjs +6 -1
  54. package/src/notion.mjs +0 -528
package/src/new.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, writeFileSync, mkdirSync, fstatSync } from 'node:fs';
1
+ import { existsSync, readFileSync, mkdirSync, fstatSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { toRepoPath, die, warn, nowIso, emitFilesFooter } from './util.mjs';
@@ -6,6 +6,8 @@ import { green, dim, bold } from './color.mjs';
6
6
  import { isInteractive, promptText } from './prompt.mjs';
7
7
  import { regenIndex } from './lifecycle.mjs';
8
8
  import { extractFrontmatter, parseSimpleFrontmatter, normalizeEol } from './frontmatter.mjs';
9
+ import { authorizeManagedDestination } from './managed-path.mjs';
10
+ import { createFileExclusive, mutateFileSet, MutationConflictError } from './atomic-mutation.mjs';
9
11
 
10
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
13
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
@@ -281,6 +283,18 @@ export function readBodyInput(source) {
281
283
  return source;
282
284
  }
283
285
 
286
+ export function readPipedBodyInput() {
287
+ try {
288
+ const stat = fstatSync(0);
289
+ const isWindowsPipe = process.platform === 'win32' && !process.stdin.isTTY;
290
+ if (stat.isFIFO() || stat.isFile() || stat.isSocket() || isWindowsPipe) {
291
+ const piped = readFileSync(0, 'utf8');
292
+ return piped.length > 0 ? piped : null;
293
+ }
294
+ } catch { /* stdin not introspectable */ }
295
+ return null;
296
+ }
297
+
284
298
  // Slug/title helpers shared by name resolution and runlist child generation.
285
299
  export function slugify(s) {
286
300
  return s.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
@@ -289,6 +303,48 @@ export function titleize(s) {
289
303
  return s.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
290
304
  }
291
305
 
306
+ export function preparePromptDocument(name, bodyInput, config, { plan = null, dryRun = false } = {}) {
307
+ const template = resolveTemplate('prompt', config);
308
+ const typeStatuses = config.typeStatuses?.get('prompt');
309
+ const status = template.defaultStatus && (!typeStatuses || typeStatuses.has(template.defaultStatus))
310
+ ? template.defaultStatus
311
+ : ([...(typeStatuses ?? [])][0] ?? 'pending');
312
+ if (!bodyInput?.trim()) die('`prompt` template requires a body.');
313
+ const slug = slugify(path.basename(name, '.md'));
314
+ const title = titleize(path.basename(name, '.md'));
315
+ let targetRoot = config.docsRoot;
316
+ let routed = false;
317
+ if (template.targetRoot) {
318
+ const match = (config.docsRoots || [config.docsRoot]).find(root => root.endsWith(template.targetRoot) || path.basename(root) === template.targetRoot);
319
+ if (match) { targetRoot = match; routed = true; }
320
+ }
321
+ const baseDir = template.dir && !routed ? path.join(targetRoot, template.dir) : targetRoot;
322
+ const filePath = path.join(baseDir, `${slug}.md`);
323
+ authorizeManagedDestination(filePath, config, { kind: 'Baton prompt destination' });
324
+ if (dryRun) return { slug, filePath, repoPath: toRepoPath(filePath, config.repoRoot), content: null };
325
+
326
+ const today = nowIso();
327
+ const split = splitBodyFrontmatter(bodyInput);
328
+ const body = split.frontmatter ? split.body : bodyInput;
329
+ const ctx = { status, title, today, bodyInput: body };
330
+ let content;
331
+ if (typeof template === 'function') {
332
+ content = template(name, ctx);
333
+ if (plan) {
334
+ const end = content.indexOf('\n---\n', 4);
335
+ if (!content.startsWith('---\n') || end === -1) throw new Error('Custom prompt template must return frontmatter for baton plan binding.');
336
+ const fm = mergeBodyFrontmatter(content.slice(4, end), { plan }, 'prompt');
337
+ content = `---\n${fm}${content.slice(end)}`;
338
+ }
339
+ } else {
340
+ let fm = template.frontmatter(status, today, ctx);
341
+ if (split.frontmatter) fm = mergeBodyFrontmatter(fm, split.frontmatter, 'prompt');
342
+ if (plan) fm = mergeBodyFrontmatter(fm, { plan }, 'prompt');
343
+ content = `---\n${fm}\n---\n${template.body(title, ctx)}`;
344
+ }
345
+ return { slug, filePath, repoPath: toRepoPath(filePath, config.repoRoot), content };
346
+ }
347
+
292
348
  // Resolve one `--runlist` token to a scaffolded child plan: a bare slug becomes
293
349
  // `<hub>-NN-<slug>.md` (the documented runlist naming convention). `pos` is the
294
350
  // 1-based position used for the zero-padded NN prefix. Tokens must be bare slugs
@@ -642,16 +698,11 @@ export async function runNew(argv, config, opts = {}) {
642
698
  // delivers stdin as an AF_UNIX socket). Probe this even for templates that
643
699
  // don't accept bodies so the fail-fast guard below can reject accidental
644
700
  // heredoc/input instead of silently scaffolding without it.
645
- try {
646
- const stat = fstatSync(0);
647
- if (stat.isFIFO() || stat.isFile() || stat.isSocket()) {
648
- const piped = readFileSync(0, 'utf8');
649
- if (piped.length > 0) {
650
- bodyInput = piped;
651
- bodyInputSource = 'piped stdin';
652
- }
653
- }
654
- } catch { /* stdin not introspectable — skip auto-consume */ }
701
+ const piped = readPipedBodyInput();
702
+ if (piped !== null) {
703
+ bodyInput = piped;
704
+ bodyInputSource = 'piped stdin';
705
+ }
655
706
  }
656
707
 
657
708
  // If the body input has a leading `---…---` frontmatter block, lift its keys
@@ -757,6 +808,7 @@ export async function runNew(argv, config, opts = {}) {
757
808
  const baseDir = nameDir ? path.resolve(config.repoRoot, nameDir) : targetRoot;
758
809
  const filePath = path.join(baseDir, slug + '.md');
759
810
  const repoPath = toRepoPath(filePath, config.repoRoot);
811
+ const destinationAuthorization = authorizeManagedDestination(filePath, config, { kind: 'New document destination' });
760
812
 
761
813
  if (existsSync(filePath)) {
762
814
  die(`File already exists: ${repoPath}`);
@@ -766,40 +818,13 @@ export async function runNew(argv, config, opts = {}) {
766
818
 
767
819
  // Resolve runlist children from the hub slug (e.g. `extract` → hub-01-extract.md).
768
820
  const runlistChildren = runlistTokens.map((tok, i) => planChildFromToken(slug, tok, i + 1));
769
- const childStatus = effective.has('planned') ? 'planned' : status;
770
-
771
- // Generate content
772
- let content;
773
- const validSurfaces = config.raw?.taxonomy?.surfaces ?? (config.validSurfaces ? [...config.validSurfaces] : null);
774
- const validModules = config.raw?.taxonomy?.modules ?? (config.validModules ? [...config.validModules] : null);
775
- const tmplCtx = { status, title: docTitle, today, bodyInput, validSurfaces, validModules };
776
- if (typeof template === 'function') {
777
- content = template(name, tmplCtx);
778
- } else {
779
- let fm = template.frontmatter(status, today, tmplCtx);
780
- if (bodyFrontmatter) fm = mergeBodyFrontmatter(fm, bodyFrontmatter, typeName);
781
- // Inject the hub-shape frontmatter (runlist array / coordination marker)
782
- // on top of the standard plan scaffold, then swap in a purpose-built body.
783
- if (isRunlistHub) fm = mergeBodyFrontmatter(fm, { runlist: runlistChildren.map(c => c.file) }, typeName);
784
- if (isCoordinationHub) fm = mergeBodyFrontmatter(fm, { execution_mode: 'coordination' }, typeName);
785
- if (isRoadmap) fm = mergeBodyFrontmatter(fm, { execution_mode: 'roadmap' }, typeName);
786
- let body;
787
- // A full authored body (own `## Section` headings) wins over every variant
788
- // skeleton too — otherwise the whole document gets nested in the builder's
789
- // single slot and the skeleton is appended below it (duplicate Scope /
790
- // Ranked queue / Version History). The default plan body applies the same
791
- // shortcut inside template.body, so only the variant branches need it here.
792
- const variantBody = isRunlistHub || isCoordinationHub || isRoadmap || isLite || isAudit;
793
- const authored = variantBody ? fullBodyShortcut(docTitle, bodyInput) : null;
794
- if (authored !== null) body = authored;
795
- else if (isRunlistHub) body = runlistHubBody(docTitle, slug, runlistChildren, bodyInput, today);
796
- else if (isCoordinationHub) body = coordinationHubBody(docTitle, bodyInput, today);
797
- else if (isRoadmap) body = roadmapHubBody(docTitle, slug, bodyInput, today);
798
- else if (isLite) body = litePlanBody(docTitle, bodyInput, today);
799
- else if (isAudit) body = auditPlanBody(docTitle, bodyInput, today);
800
- else body = template.body(docTitle, tmplCtx);
801
- content = `---\n${fm}\n---\n${body}`;
821
+ for (const child of runlistChildren) {
822
+ authorizeManagedDestination(path.join(baseDir, child.file), config, {
823
+ root: destinationAuthorization.root,
824
+ kind: 'Runlist child destination',
825
+ });
802
826
  }
827
+ const childStatus = effective.has('planned') ? 'planned' : status;
803
828
 
804
829
  // When the project has >1 root and `--root` was omitted, surface the choice
805
830
  // so agents can see that an alternative root was available. Cheap visibility
@@ -820,34 +845,81 @@ export async function runNew(argv, config, opts = {}) {
820
845
  : isLite ? ' (lite plan)'
821
846
  : isAudit ? ' (audit plan)'
822
847
  : '';
848
+ const isCustomTemplate = Object.prototype.hasOwnProperty.call(config.raw?.templates ?? {}, typeName);
823
849
 
824
850
  if (dryRun) {
825
- process.stdout.write(`${dim('[dry-run]')} Would create: ${repoPath}\n`);
851
+ if (isCustomTemplate) {
852
+ process.stdout.write(`${dim('[dry-run]')} Target: ${repoPath}\n`);
853
+ process.stdout.write(`${dim('[dry-run]')} Custom template rendering skipped; preview cannot confirm creation will succeed.\n`);
854
+ } else {
855
+ process.stdout.write(`${dim('[dry-run]')} Would create: ${repoPath}\n`);
856
+ }
826
857
  process.stdout.write(`${dim('[dry-run]')} Type: ${typeName}${hubKind}\n`);
827
858
  for (const c of runlistChildren) {
828
- process.stdout.write(`${dim('[dry-run]')} Would create child: ${toRepoPath(path.join(baseDir, c.file), config.repoRoot)}\n`);
859
+ const childPath = path.join(baseDir, c.file);
860
+ const action = existsSync(childPath) ? 'Would leave existing child unchanged' : 'Would create child';
861
+ process.stdout.write(`${dim('[dry-run]')} ${action}: ${toRepoPath(childPath, config.repoRoot)}\n`);
829
862
  }
830
863
  if (rootHint) process.stdout.write(`${dim('[dry-run]')} ${rootHint}`);
831
864
  return;
832
865
  }
833
866
 
867
+ // Generate content only for a real create. Custom template functions are
868
+ // user code and may have side effects, so previews stop before invoking them.
869
+ let content;
870
+ const validSurfaces = config.raw?.taxonomy?.surfaces ?? (config.validSurfaces ? [...config.validSurfaces] : null);
871
+ const validModules = config.raw?.taxonomy?.modules ?? (config.validModules ? [...config.validModules] : null);
872
+ const tmplCtx = { status, title: docTitle, today, bodyInput, validSurfaces, validModules };
873
+ if (typeof template === 'function') {
874
+ content = template(name, tmplCtx);
875
+ } else {
876
+ let fm = template.frontmatter(status, today, tmplCtx);
877
+ if (bodyFrontmatter) fm = mergeBodyFrontmatter(fm, bodyFrontmatter, typeName);
878
+ if (isRunlistHub) fm = mergeBodyFrontmatter(fm, { runlist: runlistChildren.map(c => c.file) }, typeName);
879
+ if (isCoordinationHub) fm = mergeBodyFrontmatter(fm, { execution_mode: 'coordination' }, typeName);
880
+ if (isRoadmap) fm = mergeBodyFrontmatter(fm, { execution_mode: 'roadmap' }, typeName);
881
+ let body;
882
+ const variantBody = isRunlistHub || isCoordinationHub || isRoadmap || isLite || isAudit;
883
+ const authored = variantBody ? fullBodyShortcut(docTitle, bodyInput) : null;
884
+ if (authored !== null) body = authored;
885
+ else if (isRunlistHub) body = runlistHubBody(docTitle, slug, runlistChildren, bodyInput, today);
886
+ else if (isCoordinationHub) body = coordinationHubBody(docTitle, bodyInput, today);
887
+ else if (isRoadmap) body = roadmapHubBody(docTitle, slug, bodyInput, today);
888
+ else if (isLite) body = litePlanBody(docTitle, bodyInput, today);
889
+ else if (isAudit) body = auditPlanBody(docTitle, bodyInput, today);
890
+ else body = template.body(docTitle, tmplCtx);
891
+ content = `---\n${fm}\n---\n${body}`;
892
+ }
893
+
834
894
  // Ensure parent dir exists (templates with `dir:` may target a new subdirectory)
835
895
  mkdirSync(path.dirname(filePath), { recursive: true });
836
896
 
837
- writeFileSync(filePath, content, 'utf8');
838
- process.stdout.write(`${green('Created')}: ${repoPath} ${dim(`(${typeName}${hubKind})`)}\n`);
839
- if (rootHint) process.stdout.write(dim(rootHint));
840
-
841
- // Scaffold runlist child stubs. An existing child file is never clobbered.
842
897
  const childPaths = [];
898
+ const creations = [{ path: filePath, content }];
843
899
  for (const c of runlistChildren) {
844
900
  const childPath = path.join(baseDir, c.file);
845
901
  if (existsSync(childPath)) {
846
902
  warn(`Runlist child already exists, left as-is: ${toRepoPath(childPath, config.repoRoot)}`);
847
903
  continue;
848
904
  }
849
- writeFileSync(childPath, runlistChildContent(c.title, slug, docTitle, childStatus, today), 'utf8');
905
+ creations.push({ path: childPath, content: runlistChildContent(c.title, slug, docTitle, childStatus, today) });
850
906
  childPaths.push(childPath);
907
+ }
908
+ try {
909
+ if (runlistChildren.length > 0) mutateFileSet({ creations }, { repoRoot: config.repoRoot, testHooks: opts.testHooks });
910
+ else createFileExclusive(filePath, content, { repoRoot: config.repoRoot, testHooks: opts.testHooks });
911
+ } catch (err) {
912
+ if (err instanceof MutationConflictError) die(`Could not create runlist atomically because a destination already exists: ${err.message}`);
913
+ throw err;
914
+ }
915
+ process.stdout.write(`${green('Created')}: ${repoPath} ${dim(`(${typeName}${hubKind})`)}\n`);
916
+ if (rootHint) process.stdout.write(dim(rootHint));
917
+
918
+ // Existing children discovered before the transaction are consistently left
919
+ // unchanged; absent children and the hub publish or roll back together.
920
+ for (const c of runlistChildren) {
921
+ const childPath = path.join(baseDir, c.file);
922
+ if (!childPaths.includes(childPath)) continue;
851
923
  process.stdout.write(`${green('Created')}: ${toRepoPath(childPath, config.repoRoot)} ${dim(`(plan · runlist child, ${childStatus})`)}\n`);
852
924
  }
853
925
 
@@ -873,15 +945,24 @@ export async function runNew(argv, config, opts = {}) {
873
945
  }
874
946
  } catch { /* git absent / not a repo — skip the note */ }
875
947
 
876
- regenIndex(config);
948
+ const indexRegenerated = !opts.deferIndex ? regenIndex(config) : false;
877
949
 
878
950
  if (showFiles) {
879
951
  const touched = [filePath, ...childPaths];
880
- if (config.indexPath) touched.push(config.indexPath);
952
+ if (config.indexPath && !opts.deferIndex) touched.push(config.indexPath);
881
953
  emitFilesFooter(touched, config);
882
954
  }
883
955
 
884
956
  try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle, type: typeName }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
957
+ const sessionLocal = typeName === 'prompt';
958
+ return {
959
+ operation: 'new',
960
+ repositoryFiles: sessionLocal ? [] : [repoPath, ...childPaths.map(item => toRepoPath(item, config.repoRoot))],
961
+ sessionFiles: sessionLocal ? [repoPath] : [],
962
+ generatedFiles: config.indexPath && indexRegenerated ? [toRepoPath(config.indexPath, config.repoRoot)] : [],
963
+ deferredGeneratedFiles: config.indexPath && opts.deferIndex ? [toRepoPath(config.indexPath, config.repoRoot)] : [],
964
+ path: repoPath,
965
+ };
885
966
  }
886
967
 
887
968
  function resolveTemplate(name, config) {
@@ -0,0 +1,106 @@
1
+ import { createHash } from 'node:crypto';
2
+ import path from 'node:path';
3
+
4
+ export const HTML_FALLBACK_DIR = '__dotmd';
5
+
6
+ function normalizeRelative(value, kind) {
7
+ if (typeof value !== 'string' || value.includes('\0')) {
8
+ throw new Error(`${kind} must be a string without NUL bytes`);
9
+ }
10
+ const normalized = path.posix.normalize(value || '.');
11
+ if (path.posix.isAbsolute(normalized)) throw new Error(`${kind} must be relative: ${value}`);
12
+ return normalized;
13
+ }
14
+
15
+ function isTraversal(value) {
16
+ return value === '..' || value.startsWith('../');
17
+ }
18
+
19
+ function pathsConflict(a, b) {
20
+ const left = a.toLowerCase();
21
+ const right = b.toLowerCase();
22
+ return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
23
+ }
24
+
25
+ function validateHtmlPath(value) {
26
+ const normalized = normalizeRelative(value, 'HTML output path');
27
+ if (normalized === '.' || isTraversal(normalized) || normalized !== value) {
28
+ throw new Error(`Unsafe HTML output path: ${value}`);
29
+ }
30
+ return normalized;
31
+ }
32
+
33
+ export function outputPathToUrl(value) {
34
+ return validateHtmlPath(value).split('/').map(encodeURIComponent).join('/');
35
+ }
36
+
37
+ export function allocateOutputIdentities(docs) {
38
+ const entries = [];
39
+ const logicalIds = new Set();
40
+
41
+ for (const doc of docs) {
42
+ const logicalId = normalizeRelative(doc.path, 'Document path');
43
+ if (logicalIds.has(logicalId)) throw new Error(`Duplicate document path: ${logicalId}`);
44
+ logicalIds.add(logicalId);
45
+
46
+ const root = normalizeRelative(doc.root || '.', 'Document root');
47
+ const relativeSource = path.posix.relative(root, logicalId);
48
+ if (!relativeSource || isTraversal(relativeSource) || path.posix.isAbsolute(relativeSource)) {
49
+ throw new Error(`Document path is not owned by its root: ${logicalId} (root: ${root})`);
50
+ }
51
+ if (!relativeSource.toLowerCase().endsWith('.md')) {
52
+ throw new Error(`Document path must end in .md: ${logicalId}`);
53
+ }
54
+
55
+ const preferred = validateHtmlPath(relativeSource.slice(0, -3) + '.html');
56
+ entries.push({
57
+ logicalId,
58
+ label: path.posix.basename(logicalId, path.posix.extname(logicalId)),
59
+ preferred,
60
+ conflict: preferred.toLowerCase() === 'index.html'
61
+ || preferred.toLowerCase().startsWith('index.html/')
62
+ || preferred.toLowerCase().startsWith(`${HTML_FALLBACK_DIR.toLowerCase()}/`),
63
+ });
64
+ }
65
+
66
+ entries.sort((a, b) => a.logicalId.localeCompare(b.logicalId));
67
+ for (let i = 0; i < entries.length; i++) {
68
+ for (let j = i + 1; j < entries.length; j++) {
69
+ if (!pathsConflict(entries[i].preferred, entries[j].preferred)) continue;
70
+ entries[i].conflict = true;
71
+ entries[j].conflict = true;
72
+ }
73
+ }
74
+
75
+ const digestOwners = new Map();
76
+ for (const entry of entries) {
77
+ if (!entry.conflict) {
78
+ entry.htmlPath = entry.preferred;
79
+ continue;
80
+ }
81
+ const digest = createHash('sha256').update(entry.logicalId).digest('hex');
82
+ const owner = digestOwners.get(digest);
83
+ if (owner && owner !== entry.logicalId) {
84
+ throw new Error(`Output identity hash collision: ${owner} and ${entry.logicalId}`);
85
+ }
86
+ digestOwners.set(digest, entry.logicalId);
87
+ entry.htmlPath = `${HTML_FALLBACK_DIR}/${digest}.html`;
88
+ }
89
+
90
+ const finalEntries = [...entries].sort((a, b) => a.htmlPath.localeCompare(b.htmlPath));
91
+ for (let i = 0; i < finalEntries.length; i++) {
92
+ validateHtmlPath(finalEntries[i].htmlPath);
93
+ for (let j = i + 1; j < finalEntries.length; j++) {
94
+ if (pathsConflict(finalEntries[i].htmlPath, finalEntries[j].htmlPath)) {
95
+ throw new Error(`Conflicting HTML output paths: ${finalEntries[i].htmlPath} and ${finalEntries[j].htmlPath}`);
96
+ }
97
+ }
98
+ }
99
+
100
+ return new Map(entries.map(entry => [entry.logicalId, {
101
+ logicalId: entry.logicalId,
102
+ label: entry.label,
103
+ htmlPath: entry.htmlPath,
104
+ htmlUrl: outputPathToUrl(entry.htmlPath),
105
+ }]));
106
+ }
@@ -70,7 +70,7 @@ function readRelatedSummary(rawList, config, docDir) {
70
70
 
71
71
  // Build the structured card object. Pure: no IO beyond what's passed in.
72
72
  export function buildCard(filePath, raw, config) {
73
- const { frontmatter: fmRaw, body } = extractFrontmatter(raw);
73
+ const { frontmatter: fmRaw, body, bodyLineOffset } = extractFrontmatter(raw);
74
74
  const fm = parseSimpleFrontmatter(fmRaw);
75
75
  const sections = walkSections(body);
76
76
 
@@ -117,13 +117,23 @@ export function buildCard(filePath, raw, config) {
117
117
 
118
118
  // Phases summary + active phase (pointer only, no body)
119
119
  const phaseSummary = summarizePhases(sections);
120
+ const toFileLine = line => line + bodyLineOffset;
121
+ const phaseSummaryForCard = {
122
+ ...phaseSummary,
123
+ phases: phaseSummary.phases.map(phase => ({
124
+ ...phase,
125
+ lineStart: toFileLine(phase.lineStart),
126
+ lineEnd: toFileLine(phase.lineEnd),
127
+ bodyLineStart: toFileLine(phase.bodyLineStart),
128
+ })),
129
+ };
120
130
  const activePhase = findActivePhase(sections);
121
131
  let activePhasePointer = null;
122
132
  if (activePhase) {
123
133
  activePhasePointer = {
124
134
  heading: activePhase.heading,
125
- lineStart: activePhase.lineStart,
126
- lineEnd: activePhase.lineEnd,
135
+ lineStart: toFileLine(activePhase.lineStart),
136
+ lineEnd: toFileLine(activePhase.lineEnd),
127
137
  marker: detectMarker(activePhase.heading),
128
138
  };
129
139
  }
@@ -136,8 +146,8 @@ export function buildCard(filePath, raw, config) {
136
146
  if (lastH2) {
137
147
  fallbackPointer = {
138
148
  heading: lastH2.heading,
139
- lineStart: lastH2.lineStart,
140
- lineEnd: lastH2.lineEnd,
149
+ lineStart: toFileLine(lastH2.lineStart),
150
+ lineEnd: toFileLine(lastH2.lineEnd),
141
151
  };
142
152
  }
143
153
  }
@@ -148,8 +158,8 @@ export function buildCard(filePath, raw, config) {
148
158
  if (oqSection && oqSection.body.trim()) {
149
159
  openQuestions = {
150
160
  heading: oqSection.heading,
151
- lineStart: oqSection.lineStart,
152
- lineEnd: oqSection.lineEnd,
161
+ lineStart: toFileLine(oqSection.lineStart),
162
+ lineEnd: toFileLine(oqSection.lineEnd),
153
163
  count: countBullets(oqSection.body),
154
164
  };
155
165
  }
@@ -165,11 +175,13 @@ export function buildCard(filePath, raw, config) {
165
175
  // Outline = all H2 headings with line ranges; phase summary inline if Phases present
166
176
  const outline = sections.filter(s => s.level === 2).map(s => {
167
177
  const isPhases = /^phases?$/i.test(s.heading.replace(/[^\w\s]+$/, '').trim());
168
- let suffix = `lines ${s.lineStart}-${s.lineEnd}`;
178
+ const lineStart = toFileLine(s.lineStart);
179
+ const lineEnd = toFileLine(s.lineEnd);
180
+ let suffix = `lines ${lineStart}-${lineEnd}`;
169
181
  if (isPhases && phaseSummary.total > 0) {
170
182
  suffix = `(${phaseSummary.total}: ${statusSummary(phaseSummary.counts)}) ${suffix}`;
171
183
  }
172
- return { heading: s.heading, lineStart: s.lineStart, lineEnd: s.lineEnd, suffix };
184
+ return { heading: s.heading, lineStart, lineEnd, suffix };
173
185
  });
174
186
 
175
187
  return {
@@ -181,13 +193,15 @@ export function buildCard(filePath, raw, config) {
181
193
  currentState,
182
194
  nextStep,
183
195
  related,
184
- phases: phaseSummary,
196
+ phases: phaseSummaryForCard,
185
197
  activePhase: activePhasePointer,
186
198
  fallbackContent: fallbackPointer,
187
199
  openQuestions,
188
200
  lastVersion,
189
201
  outline,
190
202
  bodyBytes: body.length,
203
+ bodyLineOffset,
204
+ coordinateSpace: 'file',
191
205
  };
192
206
  }
193
207