@refrakt-md/plan 0.9.5 → 0.9.6

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 (81) hide show
  1. package/dist/cli-plugin.d.ts.map +1 -1
  2. package/dist/cli-plugin.js +54 -0
  3. package/dist/cli-plugin.js.map +1 -1
  4. package/dist/commands/build.d.ts.map +1 -1
  5. package/dist/commands/build.js +13 -0
  6. package/dist/commands/build.js.map +1 -1
  7. package/dist/commands/history.d.ts +21 -0
  8. package/dist/commands/history.d.ts.map +1 -0
  9. package/dist/commands/history.js +261 -0
  10. package/dist/commands/history.js.map +1 -0
  11. package/dist/commands/next.d.ts.map +1 -1
  12. package/dist/commands/next.js +22 -6
  13. package/dist/commands/next.js.map +1 -1
  14. package/dist/commands/plan-behaviors.js +2 -1
  15. package/dist/commands/plan-behaviors.js.map +1 -1
  16. package/dist/commands/render-pipeline.d.ts.map +1 -1
  17. package/dist/commands/render-pipeline.js +148 -128
  18. package/dist/commands/render-pipeline.js.map +1 -1
  19. package/dist/commands/serve.d.ts.map +1 -1
  20. package/dist/commands/serve.js +54 -0
  21. package/dist/commands/serve.js.map +1 -1
  22. package/dist/commands/templates.js +4 -4
  23. package/dist/commands/templates.js.map +1 -1
  24. package/dist/commands/update.d.ts.map +1 -1
  25. package/dist/commands/update.js +39 -11
  26. package/dist/commands/update.js.map +1 -1
  27. package/dist/commands/validate.d.ts.map +1 -1
  28. package/dist/commands/validate.js +119 -1
  29. package/dist/commands/validate.js.map +1 -1
  30. package/dist/config.d.ts.map +1 -1
  31. package/dist/config.js +86 -23
  32. package/dist/config.js.map +1 -1
  33. package/dist/entity-tabs-behavior.d.ts +13 -0
  34. package/dist/entity-tabs-behavior.d.ts.map +1 -0
  35. package/dist/entity-tabs-behavior.js +94 -0
  36. package/dist/entity-tabs-behavior.js.map +1 -0
  37. package/dist/history.d.ts +121 -0
  38. package/dist/history.d.ts.map +1 -0
  39. package/dist/history.js +417 -0
  40. package/dist/history.js.map +1 -0
  41. package/dist/index.d.ts +2 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +11 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/pipeline.d.ts +10 -1
  46. package/dist/pipeline.d.ts.map +1 -1
  47. package/dist/pipeline.js +563 -47
  48. package/dist/pipeline.js.map +1 -1
  49. package/dist/scanner.d.ts +10 -2
  50. package/dist/scanner.d.ts.map +1 -1
  51. package/dist/scanner.js +112 -4
  52. package/dist/scanner.js.map +1 -1
  53. package/dist/tags/bug.d.ts.map +1 -1
  54. package/dist/tags/bug.js +22 -1
  55. package/dist/tags/bug.js.map +1 -1
  56. package/dist/tags/decision.d.ts.map +1 -1
  57. package/dist/tags/decision.js +23 -1
  58. package/dist/tags/decision.js.map +1 -1
  59. package/dist/tags/milestone.d.ts.map +1 -1
  60. package/dist/tags/milestone.js +7 -4
  61. package/dist/tags/milestone.js.map +1 -1
  62. package/dist/tags/plan-history.d.ts +4 -0
  63. package/dist/tags/plan-history.d.ts.map +1 -0
  64. package/dist/tags/plan-history.js +46 -0
  65. package/dist/tags/plan-history.js.map +1 -0
  66. package/dist/tags/spec.d.ts.map +1 -1
  67. package/dist/tags/spec.js +4 -5
  68. package/dist/tags/spec.js.map +1 -1
  69. package/dist/tags/work.d.ts.map +1 -1
  70. package/dist/tags/work.js +28 -7
  71. package/dist/tags/work.js.map +1 -1
  72. package/dist/types.d.ts +20 -0
  73. package/dist/types.d.ts.map +1 -1
  74. package/dist/util.d.ts +2 -0
  75. package/dist/util.d.ts.map +1 -1
  76. package/dist/util.js +10 -0
  77. package/dist/util.js.map +1 -1
  78. package/package.json +10 -9
  79. package/styles/default.css +0 -586
  80. package/styles/minimal.css +0 -386
  81. package/styles/tokens.css +0 -13
package/dist/pipeline.js CHANGED
@@ -3,15 +3,18 @@ import { BACKLOG_SENTINEL } from './tags/backlog.js';
3
3
  import { DECISION_LOG_SENTINEL } from './tags/decision-log.js';
4
4
  import { PLAN_PROGRESS_SENTINEL } from './tags/plan-progress.js';
5
5
  import { PLAN_ACTIVITY_SENTINEL } from './tags/plan-activity.js';
6
+ import { PLAN_HISTORY_SENTINEL } from './tags/plan-history.js';
6
7
  import { parseFilter, matchesFilter, sortEntities, groupEntities } from './filter.js';
8
+ import { execSync } from 'node:child_process';
9
+ import { extractBatchHistory, readHistoryCache, writeHistoryCache, } from './history.js';
7
10
  const { Tag } = Markdoc;
8
11
  const PLAN_RUNE_TYPES = new Set(['spec', 'work', 'bug', 'decision', 'milestone']);
9
12
  /** Fields to extract from each rune type's property meta tags */
10
13
  const RUNE_FIELDS = {
11
14
  spec: ['id', 'status', 'version', 'supersedes', 'tags', 'modified'],
12
- work: ['id', 'status', 'priority', 'complexity', 'assignee', 'milestone', 'tags', 'modified'],
13
- bug: ['id', 'status', 'severity', 'assignee', 'milestone', 'tags', 'modified'],
14
- decision: ['id', 'status', 'date', 'supersedes', 'tags', 'modified'],
15
+ work: ['id', 'status', 'priority', 'complexity', 'assignee', 'milestone', 'source', 'tags', 'modified'],
16
+ bug: ['id', 'status', 'severity', 'assignee', 'milestone', 'source', 'tags', 'modified'],
17
+ decision: ['id', 'status', 'date', 'supersedes', 'source', 'tags', 'modified'],
15
18
  milestone: ['name', 'status', 'target', 'modified'],
16
19
  };
17
20
  function walkTags(node, fn) {
@@ -129,6 +132,15 @@ const PRIORITY_SENTIMENT = {
129
132
  const SEVERITY_SENTIMENT = {
130
133
  critical: 'negative', major: 'caution', minor: 'neutral', trivial: 'neutral',
131
134
  };
135
+ const SPEC_STATUS_SENTIMENT = {
136
+ draft: 'neutral', review: 'caution', accepted: 'positive', superseded: 'caution', deprecated: 'negative',
137
+ };
138
+ const DECISION_STATUS_SENTIMENT = {
139
+ proposed: 'neutral', accepted: 'positive', superseded: 'caution', deprecated: 'negative',
140
+ };
141
+ const MILESTONE_STATUS_SENTIMENT = {
142
+ planning: 'neutral', active: 'positive', complete: 'positive',
143
+ };
132
144
  /** Build a metadata badge matching the dimension system output */
133
145
  function buildMetaBadge(label, value, opts) {
134
146
  const labelAttrs = { 'data-meta-label': '' };
@@ -144,12 +156,12 @@ function buildMetaBadge(label, value, opts) {
144
156
  attrs['data-meta-sentiment'] = opts.sentiment;
145
157
  return new Tag('span', attrs, [labelEl, valueEl]);
146
158
  }
147
- /** Build a compact summary card Tag for a work/bug entity */
159
+ /** Build a compact summary card Tag for any plan entity */
148
160
  function buildEntityCard(entity) {
149
- const id = String(entity.data.id ?? entity.id);
161
+ const type = entity.type;
162
+ const id = String(type === 'milestone' ? (entity.data.name ?? entity.id) : (entity.data.id ?? entity.id));
150
163
  const title = String(entity.data.title ?? '');
151
164
  const status = String(entity.data.status ?? '');
152
- const type = entity.type;
153
165
  // Header: ID on the left, status + progress on the right
154
166
  const headerLeft = [
155
167
  buildMetaBadge('ID:', id, { metaType: 'id', metaRank: 'primary', labelHidden: true }),
@@ -157,7 +169,10 @@ function buildEntityCard(entity) {
157
169
  const headerRight = [];
158
170
  const statusSentiment = type === 'work' ? WORK_STATUS_SENTIMENT[status]
159
171
  : type === 'bug' ? BUG_STATUS_SENTIMENT[status]
160
- : undefined;
172
+ : type === 'spec' ? SPEC_STATUS_SENTIMENT[status]
173
+ : type === 'decision' ? DECISION_STATUS_SENTIMENT[status]
174
+ : type === 'milestone' ? MILESTONE_STATUS_SENTIMENT[status]
175
+ : undefined;
161
176
  headerRight.push(buildMetaBadge('Status:', status, { metaType: 'status', metaRank: 'primary', sentiment: statusSentiment, labelHidden: true }));
162
177
  // Progress in header (no circle indicator)
163
178
  const checkedCount = Number(entity.data.checkedCount ?? 0);
@@ -190,6 +205,21 @@ function buildEntityCard(entity) {
190
205
  if (severity)
191
206
  footerBadges.push(buildMetaBadge('Severity:', severity, { metaType: 'category', metaRank: 'secondary', sentiment: SEVERITY_SENTIMENT[severity] }));
192
207
  }
208
+ else if (type === 'spec') {
209
+ const version = String(entity.data.version ?? '');
210
+ if (version)
211
+ footerBadges.push(buildMetaBadge('Version:', version, { metaType: 'quantity', metaRank: 'secondary' }));
212
+ }
213
+ else if (type === 'decision') {
214
+ const date = String(entity.data.date ?? '');
215
+ if (date)
216
+ footerBadges.push(buildMetaBadge('Date:', date, { metaType: 'temporal', metaRank: 'secondary' }));
217
+ }
218
+ else if (type === 'milestone') {
219
+ const target = String(entity.data.target ?? '');
220
+ if (target)
221
+ footerBadges.push(buildMetaBadge('Target:', target, { metaType: 'temporal', metaRank: 'secondary' }));
222
+ }
193
223
  const milestone = String(entity.data.milestone ?? '');
194
224
  if (milestone)
195
225
  footerBadges.push(buildMetaBadge('Milestone:', milestone, { metaType: 'tag', metaRank: 'secondary', labelHidden: true }));
@@ -207,9 +237,6 @@ function buildEntityCard(entity) {
207
237
  'data-id': id,
208
238
  }, children);
209
239
  }
210
- const DECISION_STATUS_SENTIMENT = {
211
- proposed: 'neutral', accepted: 'positive', superseded: 'caution', deprecated: 'negative',
212
- };
213
240
  /** Build a decision log entry Tag */
214
241
  function buildDecisionEntry(entity) {
215
242
  const id = String(entity.data.id ?? entity.id);
@@ -234,15 +261,61 @@ function buildDecisionEntry(entity) {
234
261
  'data-id': id,
235
262
  }, children);
236
263
  }
264
+ /**
265
+ * Module-level store for dependency refs extracted from ## Dependencies sections.
266
+ * Maps entityId → array of dependency entity IDs.
267
+ * Set by render-pipeline.ts from scanner data before register() runs.
268
+ */
269
+ const _scannerDependencies = new Map();
270
+ /** Set scanner dependency data for the pipeline's aggregate() hook to consume */
271
+ export function setScannerDependencies(deps) {
272
+ _scannerDependencies.clear();
273
+ for (const [k, v] of deps)
274
+ _scannerDependencies.set(k, v);
275
+ }
276
+ /**
277
+ * Module-level store for the plan directory path.
278
+ * Set by render-pipeline.ts before aggregate() runs.
279
+ */
280
+ let _planDir;
281
+ /** Set the plan directory path for the pipeline's aggregate() hook to consume */
282
+ export function setPlanDir(dir) {
283
+ _planDir = dir;
284
+ }
285
+ /** Parse a comma-separated `source` attribute into typed ID references */
286
+ function parseSourceIds(source) {
287
+ if (!source)
288
+ return [];
289
+ const refs = [];
290
+ for (const raw of source.split(',')) {
291
+ const id = raw.trim();
292
+ if (!id)
293
+ continue;
294
+ const match = id.match(/^(WORK|SPEC|BUG|ADR)-\d+$/);
295
+ if (match) {
296
+ const type = ID_PREFIX_TO_TYPE[match[1]];
297
+ if (type)
298
+ refs.push({ id, type });
299
+ }
300
+ }
301
+ return refs;
302
+ }
237
303
  /**
238
304
  * Module-level store for ID references found during registration.
239
305
  * Maps entityId → array of referenced entity IDs (with type).
240
306
  * Populated by register(), consumed by aggregate().
241
307
  */
242
308
  const _idReferences = new Map();
309
+ /**
310
+ * Module-level store for structured source references (from source= attribute).
311
+ * Maps entityId → array of source entity IDs (with type).
312
+ * These produce 'implements' / 'implemented-by' relationships.
313
+ */
314
+ const _sourceReferences = new Map();
243
315
  export const planPipelineHooks = {
244
316
  register(pages, registry, ctx) {
245
317
  _idReferences.clear();
318
+ _sourceReferences.clear();
246
319
  for (const page of pages) {
247
320
  walkTags(page.renderable, (tag) => {
248
321
  const runeType = tag.attributes['data-rune'];
@@ -277,6 +350,12 @@ export const planPipelineHooks = {
277
350
  if (refs.length > 0) {
278
351
  _idReferences.set(entityId, refs);
279
352
  }
353
+ // Extract structured source references from source= attribute
354
+ const sourceVal = String(data.source ?? '');
355
+ const sourceRefs = parseSourceIds(sourceVal).filter(r => r.id !== entityId);
356
+ if (sourceRefs.length > 0) {
357
+ _sourceReferences.set(entityId, sourceRefs);
358
+ }
280
359
  registry.register({
281
360
  type: runeType,
282
361
  id: entityId,
@@ -286,7 +365,7 @@ export const planPipelineHooks = {
286
365
  });
287
366
  }
288
367
  },
289
- aggregate(registry) {
368
+ aggregate(registry, ctx) {
290
369
  // Build bidirectional relationship index from ID references
291
370
  const relationships = new Map();
292
371
  function addRel(id, rel) {
@@ -301,6 +380,63 @@ export const planPipelineHooks = {
301
380
  allEntities.set(entity.id, entity);
302
381
  }
303
382
  }
383
+ // Track IDs already linked via source= to avoid duplicate 'related' edges
384
+ const sourceLinked = new Set();
385
+ // Process structured source= references → implements / implemented-by (or informs / informed-by for decisions)
386
+ for (const [fromId, refs] of _sourceReferences) {
387
+ const fromEntity = allEntities.get(fromId);
388
+ if (!fromEntity)
389
+ continue;
390
+ // Decisions use informs/informed-by; work/bug use implements/implemented-by
391
+ const isDecision = fromEntity.type === 'decision';
392
+ const forwardKind = isDecision ? 'informs' : 'implements';
393
+ const reverseKind = isDecision ? 'informed-by' : 'implemented-by';
394
+ for (const ref of refs) {
395
+ const toEntity = allEntities.get(ref.id);
396
+ if (!toEntity)
397
+ continue;
398
+ sourceLinked.add(`${fromId}→${ref.id}`);
399
+ // A implements/informs B
400
+ addRel(fromId, {
401
+ fromId, fromType: fromEntity.type,
402
+ toId: ref.id, toType: toEntity.type,
403
+ kind: forwardKind,
404
+ });
405
+ // B is implemented-by/informed-by A
406
+ addRel(ref.id, {
407
+ fromId: ref.id, fromType: toEntity.type,
408
+ toId: fromId, toType: fromEntity.type,
409
+ kind: reverseKind,
410
+ });
411
+ }
412
+ }
413
+ // Track IDs already linked via depends-on to avoid duplicate 'related' edges
414
+ const depLinked = new Set();
415
+ // Process scanner dependency data → depends-on / dependency-of
416
+ for (const [fromId, depIds] of _scannerDependencies) {
417
+ const fromEntity = allEntities.get(fromId);
418
+ if (!fromEntity)
419
+ continue;
420
+ for (const depId of depIds) {
421
+ const toEntity = allEntities.get(depId);
422
+ if (!toEntity)
423
+ continue;
424
+ depLinked.add(`${fromId}→${depId}`);
425
+ // A depends-on B
426
+ addRel(fromId, {
427
+ fromId, fromType: fromEntity.type,
428
+ toId: depId, toType: toEntity.type,
429
+ kind: 'depends-on',
430
+ });
431
+ // B is dependency-of A
432
+ addRel(depId, {
433
+ fromId: depId, fromType: toEntity.type,
434
+ toId: fromId, toType: fromEntity.type,
435
+ kind: 'dependency-of',
436
+ });
437
+ }
438
+ }
439
+ // Process text-based ID references → blocks / blocked-by / related
304
440
  for (const [fromId, refs] of _idReferences) {
305
441
  const fromEntity = allEntities.get(fromId);
306
442
  if (!fromEntity)
@@ -309,6 +445,11 @@ export const planPipelineHooks = {
309
445
  const toEntity = allEntities.get(ref.id);
310
446
  if (!toEntity)
311
447
  continue; // Reference to unknown entity — skip
448
+ // Skip if already linked via source= attribute or dependency
449
+ if (sourceLinked.has(`${fromId}→${ref.id}`))
450
+ continue;
451
+ if (depLinked.has(`${fromId}→${ref.id}`))
452
+ continue;
312
453
  // Determine relationship kind
313
454
  // If entity A has status "blocked" and references entity B, A is "blocked-by" B
314
455
  const fromStatus = String(fromEntity.data.status ?? '');
@@ -342,6 +483,37 @@ export const planPipelineHooks = {
342
483
  }
343
484
  }
344
485
  }
486
+ // Extract git history for all entities
487
+ let history = new Map();
488
+ let repositoryUrl;
489
+ try {
490
+ const planDir = _planDir ?? 'plan';
491
+ const cache = readHistoryCache(planDir);
492
+ history = extractBatchHistory(planDir, '.', { cache });
493
+ writeHistoryCache(planDir, cache);
494
+ // Parse repository URL from git remote
495
+ try {
496
+ const remoteUrl = execSync('git remote get-url origin', {
497
+ encoding: 'utf-8',
498
+ stdio: ['pipe', 'pipe', 'pipe'],
499
+ }).trim();
500
+ // Convert SSH URLs to HTTPS
501
+ const sshMatch = remoteUrl.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
502
+ if (sshMatch) {
503
+ repositoryUrl = `https://${sshMatch[1]}/${sshMatch[2]}`;
504
+ }
505
+ else if (remoteUrl.startsWith('https://')) {
506
+ repositoryUrl = remoteUrl.replace(/\.git$/, '');
507
+ }
508
+ }
509
+ catch {
510
+ // No remote configured
511
+ }
512
+ }
513
+ catch (err) {
514
+ // Git not available or not a git repo — history will be empty
515
+ ctx.warn(`Could not extract git history: ${err instanceof Error ? err.message : String(err)}`);
516
+ }
345
517
  return {
346
518
  workEntities: registry.getAll('work'),
347
519
  bugEntities: registry.getAll('bug'),
@@ -349,6 +521,8 @@ export const planPipelineHooks = {
349
521
  specEntities: registry.getAll('spec'),
350
522
  milestoneEntities: registry.getAll('milestone'),
351
523
  relationships,
524
+ history,
525
+ repositoryUrl,
352
526
  };
353
527
  },
354
528
  postProcess(page, aggregated) {
@@ -377,6 +551,11 @@ export const planPipelineHooks = {
377
551
  modified = true;
378
552
  return resolvePlanActivity(tag, planData);
379
553
  }
554
+ // Handle plan-history sentinel
555
+ if (tag.attributes['data-rune'] === 'plan-history' && hasSentinel(tag, PLAN_HISTORY_SENTINEL)) {
556
+ modified = true;
557
+ return resolvePlanHistory(tag, planData);
558
+ }
380
559
  // Inject auto-backlog into milestone rune tags
381
560
  if (tag.attributes['data-rune'] === 'milestone') {
382
561
  const milestoneName = readField(tag, 'name');
@@ -388,7 +567,7 @@ export const planPipelineHooks = {
388
567
  }
389
568
  }
390
569
  }
391
- // Inject relationships section into entity rune tags
570
+ // Wrap entity content in tab-group with Overview / Relationships / History panels
392
571
  if (PLAN_RUNE_TYPES.has(tag.attributes['data-rune'])) {
393
572
  const runeType = tag.attributes['data-rune'];
394
573
  const entityId = runeType === 'milestone'
@@ -396,12 +575,32 @@ export const planPipelineHooks = {
396
575
  : readField(tag, 'id');
397
576
  if (entityId) {
398
577
  const rels = planData.relationships.get(entityId);
399
- if (rels && rels.length > 0) {
400
- const section = buildRelationshipsSection(rels, planData);
401
- if (section) {
402
- modified = true;
403
- return new Tag(tag.name, tag.attributes, [...tag.children, section]);
578
+ const relationshipsSection = (rels && rels.length > 0)
579
+ ? buildRelationshipsSection(rels, planData)
580
+ : null;
581
+ const historySection = buildAutoHistorySection(entityId, planData);
582
+ // Only add tabs if there is content for at least one extra panel
583
+ if (relationshipsSection || historySection) {
584
+ modified = true;
585
+ // Partition children: structural (meta fields, title, blurb) stay at top;
586
+ // body content goes into the Overview tab panel.
587
+ // Note: postProcess runs BEFORE the identity transform, so children
588
+ // have data-name/data-field from createComponentRenderable but no
589
+ // data-section or BEM classes yet.
590
+ const PREAMBLE_NAMES = new Set(['title', 'blurb']);
591
+ const structural = [];
592
+ const bodyContent = [];
593
+ for (const child of tag.children) {
594
+ if (Markdoc.Tag.isTag(child) && (child.attributes['data-field'] != null ||
595
+ PREAMBLE_NAMES.has(child.attributes['data-name']))) {
596
+ structural.push(child);
597
+ }
598
+ else {
599
+ bodyContent.push(child);
600
+ }
404
601
  }
602
+ const tabWrapper = buildEntityTabGroup(bodyContent, relationshipsSection, historySection);
603
+ return new Tag(tag.name, tag.attributes, [...structural, tabWrapper]);
405
604
  }
406
605
  }
407
606
  }
@@ -683,8 +882,289 @@ function resolvePlanActivity(tag, data) {
683
882
  newChildren.push(list);
684
883
  return new Tag(tag.name, tag.attributes, newChildren);
685
884
  }
686
- const KIND_ORDER = { 'blocked-by': 0, 'blocks': 1, 'related': 2 };
687
- const KIND_LABELS = { 'blocked-by': 'Blocked by', 'blocks': 'Blocks', 'related': 'Related' };
885
+ // ─── Plan History Resolution ───
886
+ function formatHistoryDate(isoDate) {
887
+ const d = new Date(isoDate);
888
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
889
+ return `${months[d.getMonth()]} ${d.getDate()}`;
890
+ }
891
+ function buildAttrChangeTag(change) {
892
+ const children = [
893
+ new Tag('span', { class: 'rf-plan-history__field' }, [change.field]),
894
+ ];
895
+ if (change.from !== null) {
896
+ children.push(new Tag('span', { class: 'rf-plan-history__value', 'data-type': 'remove' }, [change.from]));
897
+ }
898
+ if (change.from !== null && change.to !== null) {
899
+ children.push(new Tag('span', { class: 'rf-plan-history__arrow' }, ['→']));
900
+ }
901
+ if (change.to !== null) {
902
+ const prefix = change.from === null ? '+' : '';
903
+ children.push(new Tag('span', { class: 'rf-plan-history__value', 'data-type': 'add' }, [prefix + change.to]));
904
+ }
905
+ if (change.from !== null && change.to === null) {
906
+ // Removed attribute — show as removal only
907
+ }
908
+ return new Tag('span', { class: 'rf-plan-history__change' }, children);
909
+ }
910
+ function buildEventTag(event, repositoryUrl, collapseThreshold = 3) {
911
+ const dateTag = new Tag('time', { class: 'rf-plan-history__date' }, [formatHistoryDate(event.date)]);
912
+ const hashChildren = [event.shortHash];
913
+ const hashAttrs = { class: 'rf-plan-history__hash' };
914
+ if (repositoryUrl) {
915
+ const hashTag = new Tag('a', {
916
+ class: 'rf-plan-history__hash',
917
+ href: `${repositoryUrl}/commit/${event.hash}`,
918
+ }, [event.shortHash]);
919
+ return buildEventTagInner(event, dateTag, hashTag, collapseThreshold);
920
+ }
921
+ const hashTag = new Tag('code', hashAttrs, hashChildren);
922
+ return buildEventTagInner(event, dateTag, hashTag, collapseThreshold);
923
+ }
924
+ function buildEventTagInner(event, dateTag, hashTag, collapseThreshold) {
925
+ const changesChildren = [];
926
+ if (event.kind === 'created') {
927
+ const attrs = event.initialAttributes ?? {};
928
+ const parts = Object.entries(attrs)
929
+ .filter(([k]) => k !== 'id' && k !== 'name')
930
+ .map(([, v]) => v);
931
+ changesChildren.push(new Tag('span', { class: 'rf-plan-history__created' }, [`Created (${parts.join(', ')})`]));
932
+ }
933
+ if (event.attributeChanges) {
934
+ for (const change of event.attributeChanges) {
935
+ changesChildren.push(buildAttrChangeTag(change));
936
+ }
937
+ }
938
+ if (event.criteriaChanges) {
939
+ const items = event.criteriaChanges.map(c => {
940
+ const marker = c.action === 'checked' ? '☑' : c.action === 'unchecked' ? '☐' : c.action === 'added' ? '+' : '−';
941
+ return new Tag('li', { 'data-action': c.action }, [`${marker} ${c.text}`]);
942
+ });
943
+ // Collapse if over threshold
944
+ if (items.length > collapseThreshold) {
945
+ const visible = items.slice(0, collapseThreshold);
946
+ const remaining = items.length - collapseThreshold;
947
+ visible.push(new Tag('li', { class: 'rf-plan-history__more' }, [`+${remaining} more criteria`]));
948
+ changesChildren.push(new Tag('ul', { class: 'rf-plan-history__criteria' }, visible));
949
+ }
950
+ else {
951
+ changesChildren.push(new Tag('ul', { class: 'rf-plan-history__criteria' }, items));
952
+ }
953
+ }
954
+ if (event.kind === 'resolution') {
955
+ changesChildren.push(new Tag('span', { class: 'rf-plan-history__resolution' }, ['Resolution recorded']));
956
+ }
957
+ if (event.kind === 'content') {
958
+ changesChildren.push(new Tag('span', { class: 'rf-plan-history__content-edit' }, ['Content edited']));
959
+ }
960
+ const changesDiv = new Tag('div', { class: 'rf-plan-history__changes' }, changesChildren);
961
+ return new Tag('li', {
962
+ class: 'rf-plan-history__event',
963
+ 'data-kind': event.kind,
964
+ }, [dateTag, hashTag, changesDiv]);
965
+ }
966
+ function resolvePlanHistory(tag, data) {
967
+ const entityId = readField(tag, 'id');
968
+ const limit = parseInt(readField(tag, 'limit') || '20', 10);
969
+ const typeFilter = readField(tag, 'type') || 'all';
970
+ const group = readField(tag, 'group') || 'commit';
971
+ const allEntities = [
972
+ ...data.workEntities,
973
+ ...data.bugEntities,
974
+ ...data.decisionEntities,
975
+ ...data.specEntities,
976
+ ...data.milestoneEntities,
977
+ ];
978
+ let listContent;
979
+ let isGlobal = false;
980
+ if (entityId) {
981
+ // Per-entity mode: find the entity's file path and look up its history
982
+ const entity = allEntities.find(e => e.id === entityId || e.data.name === entityId);
983
+ if (!entity) {
984
+ listContent = new Tag('ol', { 'data-name': 'events', class: 'rf-plan-history__events' }, [
985
+ new Tag('li', { class: 'rf-plan-history__empty' }, [`No history found for ${entityId}`]),
986
+ ]);
987
+ }
988
+ else {
989
+ // Find history by matching entity file path
990
+ let entityEvents = [];
991
+ for (const [file, events] of data.history) {
992
+ // Match by file path containing the entity ID or by checking attributes
993
+ if (events.length > 0 && events[0].initialAttributes) {
994
+ const eventId = events[0].initialAttributes.id ?? events[0].initialAttributes.name;
995
+ if (eventId === entityId) {
996
+ entityEvents = events;
997
+ break;
998
+ }
999
+ }
1000
+ }
1001
+ // Reverse to newest-first, apply limit
1002
+ const limited = [...entityEvents].reverse().slice(0, limit);
1003
+ const items = limited.map(e => buildEventTag(e, data.repositoryUrl));
1004
+ listContent = new Tag('ol', { 'data-name': 'events', class: 'rf-plan-history__events' }, items.length > 0 ? items : [new Tag('li', { class: 'rf-plan-history__empty' }, ['No history available'])]);
1005
+ }
1006
+ }
1007
+ else {
1008
+ // Global feed mode
1009
+ isGlobal = true;
1010
+ const typeSet = typeFilter !== 'all' ? new Set(typeFilter.split(',').map(t => t.trim())) : null;
1011
+ const entityByFile = new Map(allEntities.map(e => {
1012
+ // Try to find the file path from history keys
1013
+ const filePath = e.data.file;
1014
+ return [filePath ?? e.id, e];
1015
+ }));
1016
+ // Group events by commit
1017
+ const commitMap = new Map();
1018
+ for (const [file, events] of data.history) {
1019
+ // Determine entity type for filtering
1020
+ const firstEvent = events[0];
1021
+ const initialType = firstEvent?.initialAttributes?.id?.split('-')[0]?.toLowerCase();
1022
+ const entityTypeMap = { work: 'work', spec: 'spec', bug: 'bug', adr: 'decision' };
1023
+ const entityType = entityTypeMap[initialType ?? ''];
1024
+ if (typeSet && entityType && !typeSet.has(entityType))
1025
+ continue;
1026
+ const entityId = firstEvent?.initialAttributes?.id ?? firstEvent?.initialAttributes?.name ?? file;
1027
+ for (const event of events) {
1028
+ if (event.kind === 'content')
1029
+ continue; // Skip content events in global feed
1030
+ let commitGroup = commitMap.get(event.hash);
1031
+ if (!commitGroup) {
1032
+ commitGroup = {
1033
+ hash: event.hash,
1034
+ shortHash: event.shortHash,
1035
+ date: event.date,
1036
+ message: event.message,
1037
+ entities: [],
1038
+ };
1039
+ commitMap.set(event.hash, commitGroup);
1040
+ }
1041
+ commitGroup.entities.push({ id: String(entityId), event });
1042
+ }
1043
+ }
1044
+ // Sort commits newest-first, apply limit
1045
+ const sortedCommits = [...commitMap.values()]
1046
+ .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
1047
+ .slice(0, limit);
1048
+ const commitItems = sortedCommits.map(commit => {
1049
+ const dateTag = new Tag('time', { class: 'rf-plan-history__date' }, [formatHistoryDate(commit.date)]);
1050
+ const hashAttrs = { class: 'rf-plan-history__hash' };
1051
+ let hashTag;
1052
+ if (data.repositoryUrl) {
1053
+ hashTag = new Tag('a', {
1054
+ class: 'rf-plan-history__hash',
1055
+ href: `${data.repositoryUrl}/commit/${commit.hash}`,
1056
+ }, [commit.shortHash]);
1057
+ }
1058
+ else {
1059
+ hashTag = new Tag('code', hashAttrs, [commit.shortHash]);
1060
+ }
1061
+ const messageTag = new Tag('span', { class: 'rf-plan-history__commit-message' }, [commit.message]);
1062
+ const entitySummaries = commit.entities.map(({ id, event }) => {
1063
+ const parts = [];
1064
+ if (event.kind === 'created') {
1065
+ const attrs = event.initialAttributes ?? {};
1066
+ const vals = Object.entries(attrs).filter(([k]) => k !== 'id' && k !== 'name').map(([, v]) => v);
1067
+ parts.push(`Created (${vals.join(', ')})`);
1068
+ }
1069
+ if (event.attributeChanges) {
1070
+ for (const c of event.attributeChanges) {
1071
+ if (c.from === null)
1072
+ parts.push(`${c.field}: +${c.to}`);
1073
+ else if (c.to === null)
1074
+ parts.push(`${c.field}: -${c.from}`);
1075
+ else
1076
+ parts.push(`${c.field}: ${c.from} → ${c.to}`);
1077
+ }
1078
+ }
1079
+ if (event.criteriaChanges && event.criteriaChanges.length > 0) {
1080
+ const checked = event.criteriaChanges.filter(c => c.action === 'checked').length;
1081
+ const total = event.criteriaChanges.length;
1082
+ parts.push(`☑ ${checked}/${total}`);
1083
+ }
1084
+ if (event.kind === 'resolution')
1085
+ parts.push('Resolution recorded');
1086
+ return new Tag('div', { class: 'rf-plan-history__entity-summary' }, [
1087
+ new Tag('span', { class: 'rf-plan-history__entity-id' }, [id]),
1088
+ new Tag('span', { class: 'rf-plan-history__entity-changes' }, [parts.join(', ')]),
1089
+ ]);
1090
+ });
1091
+ return new Tag('li', { class: 'rf-plan-history__event' }, [
1092
+ dateTag, hashTag, messageTag,
1093
+ new Tag('div', { class: 'rf-plan-history__changes' }, entitySummaries),
1094
+ ]);
1095
+ });
1096
+ listContent = new Tag('ol', { 'data-name': 'events', class: 'rf-plan-history__events' }, commitItems.length > 0 ? commitItems : [new Tag('li', { class: 'rf-plan-history__empty' }, ['No history available'])]);
1097
+ }
1098
+ const attrs = { ...tag.attributes };
1099
+ if (isGlobal) {
1100
+ attrs.class = ((attrs.class ?? '') + ' rf-plan-history--global').trim();
1101
+ }
1102
+ const newChildren = tag.children.filter((c) => !(Markdoc.Tag.isTag(c) && (c.attributes['data-field'] === PLAN_HISTORY_SENTINEL ||
1103
+ c.attributes['data-name'] === 'events' ||
1104
+ c.attributes['data-name'] === 'items')));
1105
+ newChildren.push(listContent);
1106
+ return new Tag(tag.name, attrs, newChildren);
1107
+ }
1108
+ /**
1109
+ * Build a tab-group wrapper for entity pages with Overview, Relationships, and History panels.
1110
+ * Emits the same HTML contract that tabsBehavior expects.
1111
+ */
1112
+ function buildEntityTabGroup(bodyContent, relationshipsSection, historySection) {
1113
+ const tabButtons = [];
1114
+ const tabPanels = [];
1115
+ // Overview tab (always present)
1116
+ tabButtons.push(new Tag('button', {
1117
+ role: 'tab',
1118
+ class: 'rf-plan-entity-tabs__tab',
1119
+ 'data-tab': 'overview',
1120
+ }, ['Overview']));
1121
+ tabPanels.push(new Tag('div', {
1122
+ role: 'tabpanel',
1123
+ class: 'rf-plan-entity-tabs__panel',
1124
+ 'data-tab': 'overview',
1125
+ }, bodyContent));
1126
+ // Relationships tab (only if there are relationships)
1127
+ if (relationshipsSection) {
1128
+ tabButtons.push(new Tag('button', {
1129
+ role: 'tab',
1130
+ class: 'rf-plan-entity-tabs__tab',
1131
+ 'data-tab': 'relationships',
1132
+ }, ['Relationships']));
1133
+ tabPanels.push(new Tag('div', {
1134
+ role: 'tabpanel',
1135
+ class: 'rf-plan-entity-tabs__panel',
1136
+ 'data-tab': 'relationships',
1137
+ }, [relationshipsSection]));
1138
+ }
1139
+ // History tab (only if there is history)
1140
+ if (historySection) {
1141
+ tabButtons.push(new Tag('button', {
1142
+ role: 'tab',
1143
+ class: 'rf-plan-entity-tabs__tab',
1144
+ 'data-tab': 'history',
1145
+ }, ['History']));
1146
+ tabPanels.push(new Tag('div', {
1147
+ role: 'tabpanel',
1148
+ class: 'rf-plan-entity-tabs__panel',
1149
+ 'data-tab': 'history',
1150
+ }, [historySection]));
1151
+ }
1152
+ const tabList = new Tag('div', {
1153
+ 'data-name': 'tabs',
1154
+ role: 'tablist',
1155
+ class: 'rf-plan-entity-tabs__tabs',
1156
+ }, tabButtons);
1157
+ const panels = new Tag('div', {
1158
+ 'data-name': 'panels',
1159
+ class: 'rf-plan-entity-tabs__panels',
1160
+ }, tabPanels);
1161
+ return new Tag('div', {
1162
+ class: 'rf-plan-entity-tabs',
1163
+ 'data-rune': 'plan-entity-tabs',
1164
+ }, [tabList, panels]);
1165
+ }
1166
+ 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 };
1167
+ 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' };
688
1168
  /** Look up an entity across all aggregated type arrays */
689
1169
  function findEntity(id, data) {
690
1170
  const allArrays = [data.workEntities, data.bugEntities, data.decisionEntities, data.specEntities, data.milestoneEntities];
@@ -720,37 +1200,42 @@ function buildRelationshipsSection(rels, data) {
720
1200
  for (const kind of sortedKinds) {
721
1201
  const kindRels = byKind.get(kind);
722
1202
  const label = KIND_LABELS[kind] || kind;
723
- const items = [];
1203
+ // "Informed by" renders decision entry cards
1204
+ if (kind === 'informed-by') {
1205
+ const entries = [];
1206
+ for (const rel of kindRels) {
1207
+ const target = findEntity(rel.toId, data);
1208
+ if (target) {
1209
+ entries.push(buildDecisionEntry(target));
1210
+ }
1211
+ }
1212
+ if (entries.length > 0) {
1213
+ groups.push(new Tag('div', {
1214
+ class: 'rf-plan-relationships__group',
1215
+ 'data-kind': kind,
1216
+ }, [
1217
+ new Tag('h3', { class: 'rf-plan-relationships__group-title' }, [label]),
1218
+ new Tag('ol', { class: 'rf-plan-relationships__decisions' }, entries),
1219
+ ]));
1220
+ }
1221
+ continue;
1222
+ }
1223
+ const cards = [];
724
1224
  for (const rel of kindRels) {
725
- const targetId = rel.toId;
726
- const target = findEntity(targetId, data);
727
- const title = target ? String(target.data.title ?? '') : '';
728
- const status = target ? String(target.data.status ?? '') : '';
729
- const type = target ? target.type : rel.toType;
730
- const innerChildren = [
731
- new Tag('span', { class: 'rf-plan-relationships__id' }, [targetId]),
732
- new Tag('span', {
733
- class: 'rf-plan-relationships__status',
734
- 'data-status': status,
735
- }, [status]),
736
- new Tag('span', { class: 'rf-plan-relationships__type' }, [type]),
737
- ...(title ? [new Tag('span', { class: 'rf-plan-relationships__title' }, [title])] : []),
738
- ];
739
- const children = target?.sourceUrl
740
- ? [new Tag('a', { class: 'rf-plan-relationships__link', href: target.sourceUrl }, innerChildren)]
741
- : innerChildren;
742
- items.push(new Tag('li', {
743
- class: 'rf-plan-relationships__item',
1225
+ const target = findEntity(rel.toId, data);
1226
+ if (target) {
1227
+ cards.push(buildEntityCard(target));
1228
+ }
1229
+ }
1230
+ if (cards.length > 0) {
1231
+ groups.push(new Tag('div', {
1232
+ class: 'rf-plan-relationships__group',
744
1233
  'data-kind': kind,
745
- }, children));
1234
+ }, [
1235
+ new Tag('h3', { class: 'rf-plan-relationships__group-title' }, [label]),
1236
+ new Tag('div', { class: 'rf-plan-relationships__cards' }, cards),
1237
+ ]));
746
1238
  }
747
- groups.push(new Tag('div', {
748
- class: 'rf-plan-relationships__group',
749
- 'data-kind': kind,
750
- }, [
751
- new Tag('h3', { class: 'rf-plan-relationships__group-title' }, [label]),
752
- new Tag('ul', { class: 'rf-plan-relationships__list' }, items),
753
- ]));
754
1239
  }
755
1240
  if (groups.length === 0)
756
1241
  return null;
@@ -762,4 +1247,35 @@ function buildRelationshipsSection(rels, data) {
762
1247
  ...groups,
763
1248
  ]);
764
1249
  }
1250
+ /**
1251
+ * Build an auto-injected History section for an entity page.
1252
+ * Returns null for entities with only a single commit (created and never modified).
1253
+ */
1254
+ function buildAutoHistorySection(entityId, data) {
1255
+ // Find history events by matching the entity ID in the first event's attributes
1256
+ let entityEvents = [];
1257
+ for (const [, events] of data.history) {
1258
+ if (events.length > 0 && events[0].initialAttributes) {
1259
+ const eventId = events[0].initialAttributes.id ?? events[0].initialAttributes.name;
1260
+ if (eventId === entityId) {
1261
+ entityEvents = events;
1262
+ break;
1263
+ }
1264
+ }
1265
+ }
1266
+ // Skip entities with only a single commit (creation only) — no meaningful history
1267
+ if (entityEvents.length <= 1)
1268
+ return null;
1269
+ // Build timeline (newest-first), limit to 20 events
1270
+ const limited = [...entityEvents].reverse().slice(0, 20);
1271
+ const items = limited.map(e => buildEventTag(e, data.repositoryUrl));
1272
+ const list = new Tag('ol', { class: 'rf-plan-history__events' }, items);
1273
+ return new Tag('section', {
1274
+ class: 'rf-plan-history',
1275
+ 'data-name': 'history',
1276
+ }, [
1277
+ new Tag('h2', { class: 'rf-plan-history__heading' }, ['History']),
1278
+ list,
1279
+ ]);
1280
+ }
765
1281
  //# sourceMappingURL=pipeline.js.map