@refrakt-md/plan 0.15.0 → 0.16.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 (48) hide show
  1. package/dist/cli-plugin.js +1 -1
  2. package/dist/cli-plugin.js.map +1 -1
  3. package/dist/commands/build.d.ts.map +1 -1
  4. package/dist/commands/build.js +5 -0
  5. package/dist/commands/build.js.map +1 -1
  6. package/dist/commands/plan-behaviors.js +5 -4
  7. package/dist/commands/plan-behaviors.js.map +1 -1
  8. package/dist/commands/render-pipeline.d.ts.map +1 -1
  9. package/dist/commands/render-pipeline.js +24 -3
  10. package/dist/commands/render-pipeline.js.map +1 -1
  11. package/dist/commands/serve.d.ts.map +1 -1
  12. package/dist/commands/serve.js +5 -0
  13. package/dist/commands/serve.js.map +1 -1
  14. package/dist/filter.d.ts +6 -11
  15. package/dist/filter.d.ts.map +1 -1
  16. package/dist/filter.js +7 -65
  17. package/dist/filter.js.map +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +8 -7
  20. package/dist/index.js.map +1 -1
  21. package/dist/pipeline.d.ts +6 -1
  22. package/dist/pipeline.d.ts.map +1 -1
  23. package/dist/pipeline.js +108 -458
  24. package/dist/pipeline.js.map +1 -1
  25. package/dist/scanner-core.d.ts.map +1 -1
  26. package/dist/scanner-core.js +10 -2
  27. package/dist/scanner-core.js.map +1 -1
  28. package/dist/tags/backlog.d.ts +0 -1
  29. package/dist/tags/backlog.d.ts.map +1 -1
  30. package/dist/tags/backlog.js +62 -39
  31. package/dist/tags/backlog.js.map +1 -1
  32. package/dist/tags/decision-log.d.ts +0 -1
  33. package/dist/tags/decision-log.d.ts.map +1 -1
  34. package/dist/tags/decision-log.js +54 -22
  35. package/dist/tags/decision-log.js.map +1 -1
  36. package/dist/tags/plan-activity.d.ts +0 -1
  37. package/dist/tags/plan-activity.d.ts.map +1 -1
  38. package/dist/tags/plan-activity.js +49 -18
  39. package/dist/tags/plan-activity.js.map +1 -1
  40. package/package.json +8 -8
  41. package/dist/cards.d.ts +0 -23
  42. package/dist/cards.d.ts.map +0 -1
  43. package/dist/cards.js +0 -150
  44. package/dist/cards.js.map +0 -1
  45. package/dist/entity-tabs-behavior.d.ts +0 -13
  46. package/dist/entity-tabs-behavior.d.ts.map +0 -1
  47. package/dist/entity-tabs-behavior.js +0 -94
  48. package/dist/entity-tabs-behavior.js.map +0 -1
package/dist/pipeline.js CHANGED
@@ -1,16 +1,11 @@
1
1
  import Markdoc from '@markdoc/markdoc';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
- import { BACKLOG_SENTINEL } from './tags/backlog.js';
5
- import { DECISION_LOG_SENTINEL } from './tags/decision-log.js';
6
4
  import { PLAN_PROGRESS_SENTINEL } from './tags/plan-progress.js';
7
- import { PLAN_ACTIVITY_SENTINEL } from './tags/plan-activity.js';
8
5
  import { PLAN_HISTORY_SENTINEL } from './tags/plan-history.js';
9
- import { parseFilter, matchesFilter, sortEntities, groupEntities } from './filter.js';
10
6
  import { execSync } from 'node:child_process';
11
7
  import { extractBatchHistory, readHistoryCache, writeHistoryCache, } from './history.js';
12
8
  import { buildRelationships } from './relationships.js';
13
- import { buildEntityCard, buildDecisionEntry } from './cards.js';
14
9
  import { parseFileContent } from './scanner-core.js';
15
10
  const { Tag } = Markdoc;
16
11
  const PLAN_RUNE_TYPES = new Set(['spec', 'work', 'bug', 'decision', 'milestone']);
@@ -271,6 +266,31 @@ function performUnconditionalScan(planDir, projectRoot, registry, ctx) {
271
266
  ctx.warn(`Plan scan: ${sourceFile} has a ${runeType} rune missing ${runeType === 'milestone' ? 'name' : 'id'} attribute`);
272
267
  return;
273
268
  }
269
+ // Populate the relationship maps so `buildRelationships` produces edges
270
+ // for entities reached via the scan (the standard-load case for sites
271
+ // whose plan/ tree is outside the content dir — e.g. the plan-site
272
+ // dogfood). This runs **before** the early-return registry checks so
273
+ // the cross-page pipeline's "second register pass" (which fires after
274
+ // contributePages and re-clears the maps in `register`) still gets the
275
+ // edges repopulated even when the registry entries already exist.
276
+ const refs = entity.refs
277
+ .map((refId) => {
278
+ const prefix = refId.match(/^([A-Z]+)-/)?.[1];
279
+ const type = prefix ? ID_PREFIX_TO_TYPE[prefix] : undefined;
280
+ return type && refId !== id ? { id: refId, type } : null;
281
+ })
282
+ .filter((r) => r !== null);
283
+ if (refs.length > 0)
284
+ _idReferences.set(id, refs);
285
+ const sourceRefs = parseSourceIds(String(entity.attributes.source ?? '')).filter((r) => r.id !== id);
286
+ if (sourceRefs.length > 0)
287
+ _sourceReferences.set(id, sourceRefs);
288
+ const depRefs = (entity.scopedRefs ?? [])
289
+ .filter((r) => r.section === 'Dependencies')
290
+ .map((r) => r.id)
291
+ .filter((depId) => depId !== id);
292
+ if (depRefs.length > 0)
293
+ _scannerDependencies.set(id, depRefs);
274
294
  // Site-load registration wins if both paths produced the same entity.
275
295
  const existing = registry.getById(runeType, id);
276
296
  if (existing && existing.sourceUrl) {
@@ -278,7 +298,9 @@ function performUnconditionalScan(planDir, projectRoot, registry, ctx) {
278
298
  return;
279
299
  }
280
300
  if (existing && existing.sourceFile === sourceFile) {
281
- // Already registered via the unconditional scan (idempotency). Skip.
301
+ // Already registered via the unconditional scan (idempotency). Skip
302
+ // the re-register; the maps above still get repopulated for the
303
+ // pipeline's second register pass.
282
304
  return;
283
305
  }
284
306
  if (existing && existing.sourceFile && existing.sourceFile !== sourceFile) {
@@ -291,6 +313,32 @@ function performUnconditionalScan(planDir, projectRoot, registry, ctx) {
291
313
  for (const [key, value] of Object.entries(entity.attributes)) {
292
314
  data[key] = value;
293
315
  }
316
+ // Backfill `data.modified` from file mtime when the rune doesn't set
317
+ // it explicitly. Plan-activity's default template renders this column
318
+ // and would otherwise show blank for every entity that hasn't been
319
+ // hand-stamped — the bespoke `plan build` CLI already does the same
320
+ // (via mtimeMap in commands/render-pipeline.ts), so this brings the
321
+ // standard refrakt pipeline to parity. Falls back silently if stat
322
+ // fails (e.g. file vanished mid-scan).
323
+ if (!data.modified) {
324
+ try {
325
+ const mtimeMs = fs.statSync(absPath).mtimeMs;
326
+ data.modified = new Date(mtimeMs).toISOString().slice(0, 10);
327
+ }
328
+ catch { /* ignore */ }
329
+ }
330
+ // Count acceptance-criteria checkboxes for work + bug items so the
331
+ // milestone progress rollup (aggregate, see below) sees them. The
332
+ // page-walk register loop counts via `countCheckboxes(tag)` on the
333
+ // rendered AST; the unconditional-scan path counts from the
334
+ // `entity.criteria` array `parseFileContent` already produced. Same
335
+ // shape on data — `checkedCount` / `totalCount` — so downstream
336
+ // readers (milestone aggregate, the rune-injected backlog while it
337
+ // still exists) don't care which path populated them.
338
+ if ((runeType === 'work' || runeType === 'bug') && entity.criteria.length > 0) {
339
+ data.checkedCount = entity.criteria.filter((c) => c.checked).length;
340
+ data.totalCount = entity.criteria.length;
341
+ }
294
342
  // Closure-captured extractor — returns the top-level plan rune AST
295
343
  // node from a re-parsed source file, or null if the file's structure
296
344
  // has been edited away from the expected shape. Used by expand
@@ -335,6 +383,10 @@ export const planPipelineHooks = {
335
383
  register(pages, registry, ctx) {
336
384
  _idReferences.clear();
337
385
  _sourceReferences.clear();
386
+ // `_scannerDependencies` is intentionally NOT cleared here — the bespoke
387
+ // `plan build` path seeds it via `setScannerDependencies()` *before*
388
+ // calling `register()` (see render-pipeline.ts). The standard-load path
389
+ // populates it via `performUnconditionalScan` below.
338
390
  for (const page of pages) {
339
391
  walkTags(page.renderable, (tag) => {
340
392
  const runeType = tag.attributes['data-rune'];
@@ -393,6 +445,31 @@ export const planPipelineHooks = {
393
445
  if (_planDir) {
394
446
  performUnconditionalScan(_planDir, _projectRoot, registry, ctx);
395
447
  }
448
+ // SPEC-072 / WORK-281 — roll the criteria-checkbox progress per milestone
449
+ // onto the milestone entity's `data`, so the milestone render-template
450
+ // can drive a generic `{% progress value=... max=... /%}` rune instead
451
+ // of relying on the rune-injected backlog's bespoke progress bar. Runs
452
+ // at the end of `register` (rather than in `aggregate`) so the values
453
+ // land on the entity before `entityRoutes` contributePages snapshots
454
+ // `$item` into the contributed page's variables — the progress rune is
455
+ // identity-transform-only, so its attributes resolve at transform time,
456
+ // not postProcess. Sums `checkedCount` / `totalCount` populated above.
457
+ const workAndBug = [...registry.getAll('work'), ...registry.getAll('bug')];
458
+ for (const milestone of registry.getAll('milestone')) {
459
+ const members = workAndBug.filter((e) => String(e.data.milestone ?? '') === milestone.id);
460
+ let done = 0;
461
+ let total = 0;
462
+ for (const m of members) {
463
+ const c = Number(m.data.checkedCount ?? 0);
464
+ const t = Number(m.data.totalCount ?? 0);
465
+ if (t > 0) {
466
+ done += c;
467
+ total += t;
468
+ }
469
+ }
470
+ milestone.data.progressDone = done;
471
+ milestone.data.progressTotal = total;
472
+ }
396
473
  },
397
474
  aggregate(registry, ctx) {
398
475
  // Build a lookup of all registered entities for relationship building
@@ -404,6 +481,17 @@ export const planPipelineHooks = {
404
481
  }
405
482
  // Build bidirectional relationship index using extracted module
406
483
  const relationships = buildRelationships(allEntities, _sourceReferences, _scannerDependencies, _idReferences);
484
+ // SPEC-072 / WORK-279 — contribute the (already bidirectional) plan edges
485
+ // to the core registry graph so the generic `relationships` rune can query
486
+ // them via getRelated(). The local `relationships` map still drives the
487
+ // plugin's own relationship sections until the ADR-011 consumer work lands.
488
+ if (registry.relate) {
489
+ for (const edges of relationships.values()) {
490
+ for (const e of edges) {
491
+ registry.relate({ fromId: e.fromId, toId: e.toId, kind: e.kind, fromType: e.fromType, toType: e.toType });
492
+ }
493
+ }
494
+ }
407
495
  // Extract git history for all entities
408
496
  let history = new Map();
409
497
  let repositoryUrl;
@@ -452,79 +540,30 @@ export const planPipelineHooks = {
452
540
  return page;
453
541
  let modified = false;
454
542
  const newRenderable = mapTags(page.renderable, (tag) => {
455
- // Handle backlog sentinel
456
- if (tag.attributes['data-rune'] === 'backlog' && hasSentinel(tag, BACKLOG_SENTINEL)) {
457
- modified = true;
458
- return resolveBacklog(tag, planData);
459
- }
460
- // Handle decision-log sentinel
461
- if (tag.attributes['data-rune'] === 'decision-log' && hasSentinel(tag, DECISION_LOG_SENTINEL)) {
462
- modified = true;
463
- return resolveDecisionLog(tag, planData);
464
- }
543
+ // `backlog`, `decision-log`, and `plan-activity` are now thin sugar
544
+ // over `collection` (SPEC-072 / WORK-284): their schemas emit a
545
+ // `data-rune="collection"` renderable with COLLECTION_SENTINEL, so
546
+ // they're picked up by the core `resolveCollections` postProcess —
547
+ // no plan-side branch needed.
465
548
  // Handle plan-progress sentinel
466
549
  if (tag.attributes['data-rune'] === 'plan-progress' && hasSentinel(tag, PLAN_PROGRESS_SENTINEL)) {
467
550
  modified = true;
468
551
  return resolvePlanProgress(tag, planData);
469
552
  }
470
- // Handle plan-activity sentinel
471
- if (tag.attributes['data-rune'] === 'plan-activity' && hasSentinel(tag, PLAN_ACTIVITY_SENTINEL)) {
472
- modified = true;
473
- return resolvePlanActivity(tag, planData);
474
- }
475
553
  // Handle plan-history sentinel
476
554
  if (tag.attributes['data-rune'] === 'plan-history' && hasSentinel(tag, PLAN_HISTORY_SENTINEL)) {
477
555
  modified = true;
478
556
  return resolvePlanHistory(tag, planData);
479
557
  }
480
- // Inject auto-backlog into milestone rune tags
481
- if (tag.attributes['data-rune'] === 'milestone') {
482
- const milestoneName = readField(tag, 'name');
483
- if (milestoneName) {
484
- const backlog = buildMilestoneBacklog(milestoneName, planData);
485
- if (backlog) {
486
- modified = true;
487
- tag = new Tag(tag.name, tag.attributes, [...tag.children, backlog]);
488
- }
489
- }
490
- }
491
- // Wrap entity content in tab-group with Overview / Relationships / History panels
492
- if (PLAN_RUNE_TYPES.has(tag.attributes['data-rune'])) {
493
- const runeType = tag.attributes['data-rune'];
494
- const entityId = runeType === 'milestone'
495
- ? readField(tag, 'name')
496
- : readField(tag, 'id');
497
- if (entityId) {
498
- const rels = planData.relationships.get(entityId);
499
- const relationshipsSection = (rels && rels.length > 0)
500
- ? buildRelationshipsSection(rels, planData)
501
- : null;
502
- const historySection = buildAutoHistorySection(entityId, planData);
503
- // Only add tabs if there is content for at least one extra panel
504
- if (relationshipsSection || historySection) {
505
- modified = true;
506
- // Partition children: structural (meta fields, title, blurb) stay at top;
507
- // body content goes into the Overview tab panel.
508
- // Note: postProcess runs BEFORE the identity transform, so children
509
- // have data-name/data-field from createComponentRenderable but no
510
- // data-section or BEM classes yet.
511
- const PREAMBLE_NAMES = new Set(['title', 'blurb']);
512
- const structural = [];
513
- const bodyContent = [];
514
- for (const child of tag.children) {
515
- if (Markdoc.Tag.isTag(child) && (child.attributes['data-field'] != null ||
516
- PREAMBLE_NAMES.has(child.attributes['data-name']))) {
517
- structural.push(child);
518
- }
519
- else {
520
- bodyContent.push(child);
521
- }
522
- }
523
- const tabWrapper = buildEntityTabGroup(bodyContent, relationshipsSection, historySection);
524
- return new Tag(tag.name, tag.attributes, [...structural, tabWrapper]);
525
- }
526
- }
527
- }
558
+ // SPEC-072 / WORK-282 the plan plugin no longer injects the
559
+ // milestone auto-backlog or the Overview/Relationships/History
560
+ // tab wrapper. Plan-site detail pages compose those panels at
561
+ // the `entityRoutes` render-template level (WORK-280 / WORK-281)
562
+ // using the generic `relationships` + `plan-history` + `collection`
563
+ // + `progress` runes. The plugin's postProcess now only resolves
564
+ // its own rune sentinels (backlog / decision-log / plan-progress /
565
+ // plan-activity / plan-history) no more renderable mutation
566
+ // targeting other runes.
528
567
  return tag;
529
568
  });
530
569
  if (!modified)
@@ -532,168 +571,6 @@ export const planPipelineHooks = {
532
571
  return { ...page, renderable: newRenderable };
533
572
  },
534
573
  };
535
- function resolveBacklog(tag, data) {
536
- const filterExpr = readField(tag, 'filter');
537
- const sortField = readField(tag, 'sort') || 'priority';
538
- const groupField = readField(tag, 'group');
539
- const show = readField(tag, 'show') || 'all';
540
- const limitRaw = readField(tag, 'limit');
541
- // Parse `limit` defensively — Markdoc surfaces the meta as a string.
542
- // Treat 0 / negative / NaN the same as "unset" so a malformed
543
- // authoring value doesn't silently render an empty backlog.
544
- const limit = (() => {
545
- if (!limitRaw)
546
- return undefined;
547
- const n = Number(limitRaw);
548
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
549
- })();
550
- // Collect entities by type
551
- // "all" defaults to work+bug for backward compatibility; other types must be explicit
552
- let entities = [];
553
- if (show === 'all' || show === 'work')
554
- entities.push(...data.workEntities);
555
- if (show === 'all' || show === 'bug')
556
- entities.push(...data.bugEntities);
557
- if (show === 'spec')
558
- entities.push(...data.specEntities);
559
- if (show === 'decision')
560
- entities.push(...data.decisionEntities);
561
- if (show === 'milestone')
562
- entities.push(...data.milestoneEntities);
563
- // Apply filter
564
- const filter = parseFilter(filterExpr);
565
- entities = entities.filter(e => matchesFilter(e, filter));
566
- // Sort
567
- entities = sortEntities(entities, sortField);
568
- // Limit applies post-sort, pre-group so the rendered set is "top N
569
- // by sort order". When grouped, the limit caps the total entity count
570
- // across all groups — callers wanting a per-group cap would need a
571
- // separate attribute; keep it simple until that ask surfaces.
572
- if (limit !== undefined && entities.length > limit) {
573
- entities = entities.slice(0, limit);
574
- }
575
- // Build output
576
- let children;
577
- if (groupField) {
578
- const groups = groupEntities(entities, groupField);
579
- children = [];
580
- for (const [groupName, groupEntities_] of groups) {
581
- const groupTitle = new Tag('h3', { class: 'rf-backlog__group-title' }, [groupName]);
582
- const cards = groupEntities_.map(e => buildEntityCard(e));
583
- const groupDiv = new Tag('div', { class: 'rf-backlog__group', 'data-group': groupName }, [groupTitle, ...cards]);
584
- children.push(groupDiv);
585
- }
586
- }
587
- else {
588
- children = entities.map(e => buildEntityCard(e));
589
- }
590
- const itemsDiv = new Tag('div', { 'data-name': 'items' }, children);
591
- // Rebuild tag, replacing the sentinel and placeholder with resolved content
592
- const newChildren = tag.children.filter((c) => !(Markdoc.Tag.isTag(c) && (c.attributes['data-field'] === BACKLOG_SENTINEL ||
593
- c.attributes['data-name'] === 'items')));
594
- newChildren.push(itemsDiv);
595
- return new Tag(tag.name, tag.attributes, newChildren);
596
- }
597
- /** Build auto-backlog section for a milestone, showing assigned work/bug items grouped by status */
598
- function buildMilestoneBacklog(milestoneName, data) {
599
- // Collect work and bug entities assigned to this milestone
600
- let entities = [
601
- ...data.workEntities,
602
- ...data.bugEntities,
603
- ].filter(e => String(e.data.milestone ?? '') === milestoneName);
604
- if (entities.length === 0)
605
- return null;
606
- // Sort by priority within each group
607
- entities = sortEntities(entities, 'priority');
608
- // Group by status
609
- const groups = groupEntities(entities, 'status');
610
- // Calculate aggregate progress from checklist counts
611
- let totalChecked = 0;
612
- let totalCheckboxes = 0;
613
- for (const e of entities) {
614
- const checked = Number(e.data.checkedCount ?? 0);
615
- const total = Number(e.data.totalCount ?? 0);
616
- if (total > 0) {
617
- totalChecked += checked;
618
- totalCheckboxes += total;
619
- }
620
- }
621
- const children = [];
622
- // Add aggregate progress if any items have checklists
623
- if (totalCheckboxes > 0) {
624
- const fraction = `${totalChecked}/${totalCheckboxes}`;
625
- const pct = Math.round((totalChecked / totalCheckboxes) * 100);
626
- children.push(new Tag('div', {
627
- class: 'rf-milestone__progress',
628
- 'data-progress-checked': String(totalChecked),
629
- 'data-progress-total': String(totalCheckboxes),
630
- 'data-percent': String(pct),
631
- }, [
632
- new Tag('div', { class: 'rf-milestone__progress-header' }, [
633
- new Tag('span', { class: 'rf-milestone__progress-label' }, ['Progress']),
634
- new Tag('span', { class: 'rf-milestone__progress-count' }, [`${fraction} criteria`]),
635
- ]),
636
- new Tag('span', { class: 'rf-milestone__progress-bar', style: `--rf-progress: ${pct}%` }, []),
637
- ]));
638
- }
639
- // Build status-grouped cards as tabs (or flat list for single group)
640
- const groupEntries = [...groups.entries()];
641
- if (groupEntries.length === 1) {
642
- // Single status — no tabs needed, render flat
643
- const [groupName, groupItems] = groupEntries[0];
644
- const cards = groupItems.map(e => buildEntityCard(e));
645
- children.push(new Tag('div', {
646
- class: 'rf-milestone__backlog-group',
647
- 'data-status': groupName,
648
- }, [new Tag('h3', { class: 'rf-milestone__backlog-group-label' }, [groupName]), ...cards]));
649
- }
650
- else {
651
- // Multiple statuses — render as tabs
652
- const tabButtons = [];
653
- const tabPanels = [];
654
- for (const [groupName, groupItems] of groupEntries) {
655
- const label = groupName.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
656
- tabButtons.push(new Tag('button', {
657
- role: 'tab',
658
- class: 'rf-milestone__tab',
659
- 'data-status': groupName,
660
- }, [`${label} (${groupItems.length})`]));
661
- const cards = groupItems.map(e => buildEntityCard(e));
662
- tabPanels.push(new Tag('div', {
663
- role: 'tabpanel',
664
- class: 'rf-milestone__panel',
665
- 'data-status': groupName,
666
- }, cards));
667
- }
668
- children.push(new Tag('div', {
669
- 'data-name': 'tabs',
670
- role: 'tablist',
671
- class: 'rf-milestone__tabs',
672
- }, tabButtons));
673
- children.push(new Tag('div', {
674
- 'data-name': 'panels',
675
- class: 'rf-milestone__panels',
676
- }, tabPanels));
677
- }
678
- return new Tag('div', { class: 'rf-milestone__backlog', 'data-name': 'backlog', 'data-rune': 'milestone-backlog' }, children);
679
- }
680
- function resolveDecisionLog(tag, data) {
681
- const filterExpr = readField(tag, 'filter');
682
- const sortField = readField(tag, 'sort') || 'date';
683
- let entities = [...data.decisionEntities];
684
- // Apply filter
685
- const filter = parseFilter(filterExpr);
686
- entities = entities.filter(e => matchesFilter(e, filter));
687
- // Sort
688
- entities = sortEntities(entities, sortField);
689
- const entries = entities.map(e => buildDecisionEntry(e));
690
- const list = new Tag('ol', { 'data-name': 'items', class: 'rf-decision-log__list' }, entries);
691
- // Rebuild tag
692
- const newChildren = tag.children.filter((c) => !(Markdoc.Tag.isTag(c) && (c.attributes['data-field'] === DECISION_LOG_SENTINEL ||
693
- c.attributes['data-name'] === 'items')));
694
- newChildren.push(list);
695
- return new Tag(tag.name, tag.attributes, newChildren);
696
- }
697
574
  // --- Status labels for display ---
698
575
  const STATUS_LABELS = {
699
576
  work: ['done', 'in-progress', 'review', 'ready', 'blocked', 'draft', 'pending'],
@@ -766,60 +643,6 @@ function resolvePlanProgress(tag, data) {
766
643
  newChildren.push(itemsDiv);
767
644
  return new Tag(tag.name, tag.attributes, newChildren);
768
645
  }
769
- function resolvePlanActivity(tag, data) {
770
- const limit = parseInt(readField(tag, 'limit') || '10', 10);
771
- // Collect all entities with mtime from their source URL registration data
772
- const allEntities = [
773
- ...data.workEntities,
774
- ...data.bugEntities,
775
- ...data.decisionEntities,
776
- ...data.specEntities,
777
- ...data.milestoneEntities,
778
- ];
779
- // Sort by modified date descending (entities with modification data)
780
- const withModified = allEntities
781
- .filter(e => {
782
- const mod = e.data.modified ?? e.data.mtime;
783
- if (mod == null)
784
- return false;
785
- if (typeof mod === 'string')
786
- return mod.length > 0;
787
- return Number(mod) > 0;
788
- })
789
- .sort((a, b) => {
790
- const aDate = parseModifiedDate(a.data.modified ?? a.data.mtime);
791
- const bDate = parseModifiedDate(b.data.modified ?? b.data.mtime);
792
- return bDate - aDate;
793
- })
794
- .slice(0, limit);
795
- const entries = withModified.map(e => {
796
- const id = String(e.data.id ?? e.id);
797
- const title = String(e.data.title ?? '');
798
- const status = String(e.data.status ?? '');
799
- const type = e.type;
800
- const dateStr = formatModifiedDate(e.data.modified ?? e.data.mtime);
801
- const innerChildren = [
802
- new Tag('time', { class: 'rf-plan-activity__date' }, [dateStr]),
803
- new Tag('span', { class: 'rf-plan-activity__type' }, [type]),
804
- new Tag('span', { class: 'rf-plan-activity__id' }, [id]),
805
- new Tag('span', { class: 'rf-plan-activity__status', 'data-status': status }, [status]),
806
- new Tag('span', { class: 'rf-plan-activity__title' }, [title]),
807
- ];
808
- const children = e.sourceUrl
809
- ? [new Tag('a', { class: 'rf-plan-activity__link', href: e.sourceUrl }, innerChildren)]
810
- : innerChildren;
811
- return new Tag('li', {
812
- class: 'rf-plan-activity__entry',
813
- 'data-type': type,
814
- 'data-status': status,
815
- }, children);
816
- });
817
- const list = new Tag('ol', { 'data-name': 'items', class: 'rf-plan-activity__list' }, entries);
818
- const newChildren = tag.children.filter((c) => !(Markdoc.Tag.isTag(c) && (c.attributes['data-field'] === PLAN_ACTIVITY_SENTINEL ||
819
- c.attributes['data-name'] === 'items')));
820
- newChildren.push(list);
821
- return new Tag(tag.name, tag.attributes, newChildren);
822
- }
823
646
  // ─── Plan History Resolution ───
824
647
  function formatHistoryDate(isoDate) {
825
648
  const d = new Date(isoDate);
@@ -1043,177 +866,4 @@ function resolvePlanHistory(tag, data) {
1043
866
  newChildren.push(listContent);
1044
867
  return new Tag(tag.name, attrs, newChildren);
1045
868
  }
1046
- /**
1047
- * Build a tab-group wrapper for entity pages with Overview, Relationships, and History panels.
1048
- * Emits the same HTML contract that tabsBehavior expects.
1049
- */
1050
- function buildEntityTabGroup(bodyContent, relationshipsSection, historySection) {
1051
- const tabButtons = [];
1052
- const tabPanels = [];
1053
- // Overview tab (always present)
1054
- tabButtons.push(new Tag('button', {
1055
- role: 'tab',
1056
- class: 'rf-plan-entity-tabs__tab',
1057
- 'data-tab': 'overview',
1058
- }, ['Overview']));
1059
- tabPanels.push(new Tag('div', {
1060
- role: 'tabpanel',
1061
- class: 'rf-plan-entity-tabs__panel',
1062
- 'data-tab': 'overview',
1063
- }, bodyContent));
1064
- // Relationships tab (only if there are relationships)
1065
- if (relationshipsSection) {
1066
- tabButtons.push(new Tag('button', {
1067
- role: 'tab',
1068
- class: 'rf-plan-entity-tabs__tab',
1069
- 'data-tab': 'relationships',
1070
- }, ['Relationships']));
1071
- tabPanels.push(new Tag('div', {
1072
- role: 'tabpanel',
1073
- class: 'rf-plan-entity-tabs__panel',
1074
- 'data-tab': 'relationships',
1075
- }, [relationshipsSection]));
1076
- }
1077
- // History tab (only if there is history)
1078
- if (historySection) {
1079
- tabButtons.push(new Tag('button', {
1080
- role: 'tab',
1081
- class: 'rf-plan-entity-tabs__tab',
1082
- 'data-tab': 'history',
1083
- }, ['History']));
1084
- tabPanels.push(new Tag('div', {
1085
- role: 'tabpanel',
1086
- class: 'rf-plan-entity-tabs__panel',
1087
- 'data-tab': 'history',
1088
- }, [historySection]));
1089
- }
1090
- const tabList = new Tag('div', {
1091
- 'data-name': 'tabs',
1092
- role: 'tablist',
1093
- class: 'rf-plan-entity-tabs__tabs',
1094
- }, tabButtons);
1095
- const panels = new Tag('div', {
1096
- 'data-name': 'panels',
1097
- class: 'rf-plan-entity-tabs__panels',
1098
- }, tabPanels);
1099
- return new Tag('div', {
1100
- class: 'rf-plan-entity-tabs',
1101
- 'data-rune': 'plan-entity-tabs',
1102
- }, [tabList, panels]);
1103
- }
1104
- const KIND_ORDER = { 'blocked-by': 0, 'blocks': 1, 'depends-on': 2, 'dependency-of': 3, 'implements': 4, 'implemented-by': 5, 'informs': 6, 'informed-by': 7, 'related': 8 };
1105
- const KIND_LABELS = { 'blocked-by': 'Blocked by', 'blocks': 'Blocks', 'depends-on': 'Depends on', 'dependency-of': 'Dependency of', 'implements': 'Implements', 'implemented-by': 'Implemented by', 'informs': 'Informs', 'informed-by': 'Decisions', 'related': 'Related' };
1106
- /** Look up an entity across all aggregated type arrays */
1107
- function findEntity(id, data) {
1108
- const allArrays = [data.workEntities, data.bugEntities, data.decisionEntities, data.specEntities, data.milestoneEntities];
1109
- for (const arr of allArrays) {
1110
- const found = arr.find(e => e.id === id);
1111
- if (found)
1112
- return found;
1113
- }
1114
- return undefined;
1115
- }
1116
- function buildRelationshipsSection(rels, data) {
1117
- // Group by kind
1118
- const byKind = new Map();
1119
- for (const rel of rels) {
1120
- const kind = rel.kind;
1121
- if (!byKind.has(kind))
1122
- byKind.set(kind, []);
1123
- byKind.get(kind).push(rel);
1124
- }
1125
- // Deduplicate: same target ID within a kind group
1126
- for (const [kind, kindRels] of byKind) {
1127
- const seen = new Set();
1128
- byKind.set(kind, kindRels.filter(r => {
1129
- const key = r.toId;
1130
- if (seen.has(key))
1131
- return false;
1132
- seen.add(key);
1133
- return true;
1134
- }));
1135
- }
1136
- const groups = [];
1137
- const sortedKinds = [...byKind.keys()].sort((a, b) => (KIND_ORDER[a] ?? 9) - (KIND_ORDER[b] ?? 9));
1138
- for (const kind of sortedKinds) {
1139
- const kindRels = byKind.get(kind);
1140
- const label = KIND_LABELS[kind] || kind;
1141
- // "Informed by" renders decision entry cards
1142
- if (kind === 'informed-by') {
1143
- const entries = [];
1144
- for (const rel of kindRels) {
1145
- const target = findEntity(rel.toId, data);
1146
- if (target) {
1147
- entries.push(buildDecisionEntry(target));
1148
- }
1149
- }
1150
- if (entries.length > 0) {
1151
- groups.push(new Tag('div', {
1152
- class: 'rf-plan-relationships__group',
1153
- 'data-kind': kind,
1154
- }, [
1155
- new Tag('h3', { class: 'rf-plan-relationships__group-title' }, [label]),
1156
- new Tag('ol', { class: 'rf-plan-relationships__decisions' }, entries),
1157
- ]));
1158
- }
1159
- continue;
1160
- }
1161
- const cards = [];
1162
- for (const rel of kindRels) {
1163
- const target = findEntity(rel.toId, data);
1164
- if (target) {
1165
- cards.push(buildEntityCard(target));
1166
- }
1167
- }
1168
- if (cards.length > 0) {
1169
- groups.push(new Tag('div', {
1170
- class: 'rf-plan-relationships__group',
1171
- 'data-kind': kind,
1172
- }, [
1173
- new Tag('h3', { class: 'rf-plan-relationships__group-title' }, [label]),
1174
- new Tag('div', { class: 'rf-plan-relationships__cards' }, cards),
1175
- ]));
1176
- }
1177
- }
1178
- if (groups.length === 0)
1179
- return null;
1180
- return new Tag('section', {
1181
- class: 'rf-plan-relationships',
1182
- 'data-name': 'relationships',
1183
- }, [
1184
- new Tag('h2', { class: 'rf-plan-relationships__heading' }, ['Relationships']),
1185
- ...groups,
1186
- ]);
1187
- }
1188
- /**
1189
- * Build an auto-injected History section for an entity page.
1190
- * Returns null for entities with only a single commit (created and never modified).
1191
- */
1192
- function buildAutoHistorySection(entityId, data) {
1193
- // Find history events by matching the entity ID in the first event's attributes
1194
- let entityEvents = [];
1195
- for (const [, events] of data.history) {
1196
- if (events.length > 0 && events[0].initialAttributes) {
1197
- const eventId = events[0].initialAttributes.id ?? events[0].initialAttributes.name;
1198
- if (eventId === entityId) {
1199
- entityEvents = events;
1200
- break;
1201
- }
1202
- }
1203
- }
1204
- // Skip entities with only a single commit (creation only) — no meaningful history
1205
- if (entityEvents.length <= 1)
1206
- return null;
1207
- // Build timeline (newest-first), limit to 20 events
1208
- const limited = [...entityEvents].reverse().slice(0, 20);
1209
- const items = limited.map(e => buildEventTag(e, data.repositoryUrl));
1210
- const list = new Tag('ol', { class: 'rf-plan-history__events' }, items);
1211
- return new Tag('section', {
1212
- class: 'rf-plan-history',
1213
- 'data-name': 'history',
1214
- }, [
1215
- new Tag('h2', { class: 'rf-plan-history__heading' }, ['History']),
1216
- list,
1217
- ]);
1218
- }
1219
869
  //# sourceMappingURL=pipeline.js.map