@refrakt-md/plan 0.9.5 → 0.9.7

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 (103) hide show
  1. package/dist/cards.d.ts +23 -0
  2. package/dist/cards.d.ts.map +1 -0
  3. package/dist/cards.js +150 -0
  4. package/dist/cards.js.map +1 -0
  5. package/dist/cli-plugin.d.ts.map +1 -1
  6. package/dist/cli-plugin.js +68 -5
  7. package/dist/cli-plugin.js.map +1 -1
  8. package/dist/commands/build.d.ts.map +1 -1
  9. package/dist/commands/build.js +13 -0
  10. package/dist/commands/build.js.map +1 -1
  11. package/dist/commands/create.js +3 -3
  12. package/dist/commands/create.js.map +1 -1
  13. package/dist/commands/history.d.ts +21 -0
  14. package/dist/commands/history.d.ts.map +1 -0
  15. package/dist/commands/history.js +261 -0
  16. package/dist/commands/history.js.map +1 -0
  17. package/dist/commands/init.d.ts +7 -2
  18. package/dist/commands/init.d.ts.map +1 -1
  19. package/dist/commands/init.js +149 -26
  20. package/dist/commands/init.js.map +1 -1
  21. package/dist/commands/next.d.ts.map +1 -1
  22. package/dist/commands/next.js +22 -6
  23. package/dist/commands/next.js.map +1 -1
  24. package/dist/commands/plan-behaviors.js +2 -1
  25. package/dist/commands/plan-behaviors.js.map +1 -1
  26. package/dist/commands/render-pipeline.d.ts.map +1 -1
  27. package/dist/commands/render-pipeline.js +148 -128
  28. package/dist/commands/render-pipeline.js.map +1 -1
  29. package/dist/commands/serve.d.ts.map +1 -1
  30. package/dist/commands/serve.js +54 -0
  31. package/dist/commands/serve.js.map +1 -1
  32. package/dist/commands/templates.js +13 -13
  33. package/dist/commands/templates.js.map +1 -1
  34. package/dist/commands/update.d.ts.map +1 -1
  35. package/dist/commands/update.js +39 -11
  36. package/dist/commands/update.js.map +1 -1
  37. package/dist/commands/validate.d.ts.map +1 -1
  38. package/dist/commands/validate.js +119 -1
  39. package/dist/commands/validate.js.map +1 -1
  40. package/dist/config.d.ts.map +1 -1
  41. package/dist/config.js +86 -23
  42. package/dist/config.js.map +1 -1
  43. package/dist/diff.d.ts +35 -0
  44. package/dist/diff.d.ts.map +1 -0
  45. package/dist/diff.js +88 -0
  46. package/dist/diff.js.map +1 -0
  47. package/dist/entity-tabs-behavior.d.ts +13 -0
  48. package/dist/entity-tabs-behavior.d.ts.map +1 -0
  49. package/dist/entity-tabs-behavior.js +94 -0
  50. package/dist/entity-tabs-behavior.js.map +1 -0
  51. package/dist/history.d.ts +89 -0
  52. package/dist/history.d.ts.map +1 -0
  53. package/dist/history.js +336 -0
  54. package/dist/history.js.map +1 -0
  55. package/dist/index.d.ts +2 -1
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +11 -1
  58. package/dist/index.js.map +1 -1
  59. package/dist/pipeline.d.ts +11 -11
  60. package/dist/pipeline.d.ts.map +1 -1
  61. package/dist/pipeline.js +468 -202
  62. package/dist/pipeline.js.map +1 -1
  63. package/dist/relationships.d.ts +36 -0
  64. package/dist/relationships.d.ts.map +1 -0
  65. package/dist/relationships.js +128 -0
  66. package/dist/relationships.js.map +1 -0
  67. package/dist/scanner-core.d.ts +10 -0
  68. package/dist/scanner-core.d.ts.map +1 -0
  69. package/dist/scanner-core.js +230 -0
  70. package/dist/scanner-core.js.map +1 -0
  71. package/dist/scanner.d.ts +2 -1
  72. package/dist/scanner.d.ts.map +1 -1
  73. package/dist/scanner.js +7 -124
  74. package/dist/scanner.js.map +1 -1
  75. package/dist/tags/bug.d.ts.map +1 -1
  76. package/dist/tags/bug.js +22 -1
  77. package/dist/tags/bug.js.map +1 -1
  78. package/dist/tags/decision.d.ts.map +1 -1
  79. package/dist/tags/decision.js +23 -1
  80. package/dist/tags/decision.js.map +1 -1
  81. package/dist/tags/milestone.d.ts.map +1 -1
  82. package/dist/tags/milestone.js +7 -4
  83. package/dist/tags/milestone.js.map +1 -1
  84. package/dist/tags/plan-history.d.ts +4 -0
  85. package/dist/tags/plan-history.d.ts.map +1 -0
  86. package/dist/tags/plan-history.js +46 -0
  87. package/dist/tags/plan-history.js.map +1 -0
  88. package/dist/tags/spec.d.ts.map +1 -1
  89. package/dist/tags/spec.js +4 -5
  90. package/dist/tags/spec.js.map +1 -1
  91. package/dist/tags/work.d.ts.map +1 -1
  92. package/dist/tags/work.js +28 -7
  93. package/dist/tags/work.js.map +1 -1
  94. package/dist/types.d.ts +20 -0
  95. package/dist/types.d.ts.map +1 -1
  96. package/dist/util.d.ts +2 -0
  97. package/dist/util.d.ts.map +1 -1
  98. package/dist/util.js +10 -0
  99. package/dist/util.js.map +1 -1
  100. package/package.json +26 -9
  101. package/styles/default.css +0 -586
  102. package/styles/minimal.css +0 -386
  103. package/styles/tokens.css +0 -13
package/dist/pipeline.js CHANGED
@@ -3,15 +3,20 @@ 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';
10
+ import { buildRelationships } from './relationships.js';
11
+ import { buildEntityCard, buildDecisionEntry } from './cards.js';
7
12
  const { Tag } = Markdoc;
8
13
  const PLAN_RUNE_TYPES = new Set(['spec', 'work', 'bug', 'decision', 'milestone']);
9
14
  /** Fields to extract from each rune type's property meta tags */
10
15
  const RUNE_FIELDS = {
11
16
  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'],
17
+ work: ['id', 'status', 'priority', 'complexity', 'assignee', 'milestone', 'source', 'tags', 'modified'],
18
+ bug: ['id', 'status', 'severity', 'assignee', 'milestone', 'source', 'tags', 'modified'],
19
+ decision: ['id', 'status', 'date', 'supersedes', 'source', 'tags', 'modified'],
15
20
  milestone: ['name', 'status', 'target', 'modified'],
16
21
  };
17
22
  function walkTags(node, fn) {
@@ -114,125 +119,44 @@ function formatModifiedDate(value) {
114
119
  return '';
115
120
  return new Date(ts).toISOString().slice(0, 10);
116
121
  }
117
- // ─── Sentiment maps (matching rune configs in config.ts) ───
118
- const WORK_STATUS_SENTIMENT = {
119
- draft: 'neutral', ready: 'neutral', 'in-progress': 'neutral',
120
- review: 'caution', done: 'positive', blocked: 'negative',
121
- };
122
- const BUG_STATUS_SENTIMENT = {
123
- reported: 'neutral', confirmed: 'caution', 'in-progress': 'neutral',
124
- fixed: 'positive', wontfix: 'neutral', duplicate: 'neutral',
125
- };
126
- const PRIORITY_SENTIMENT = {
127
- critical: 'negative', high: 'caution', medium: 'neutral', low: 'neutral',
128
- };
129
- const SEVERITY_SENTIMENT = {
130
- critical: 'negative', major: 'caution', minor: 'neutral', trivial: 'neutral',
131
- };
132
- /** Build a metadata badge matching the dimension system output */
133
- function buildMetaBadge(label, value, opts) {
134
- const labelAttrs = { 'data-meta-label': '' };
135
- if (opts.labelHidden)
136
- labelAttrs['data-meta-label-hidden'] = '';
137
- const labelEl = new Tag('span', labelAttrs, [label]);
138
- const valueEl = new Tag('span', { 'data-meta-value': '' }, [value]);
139
- const attrs = {
140
- 'data-meta-type': opts.metaType,
141
- 'data-meta-rank': opts.metaRank,
142
- };
143
- if (opts.sentiment)
144
- attrs['data-meta-sentiment'] = opts.sentiment;
145
- return new Tag('span', attrs, [labelEl, valueEl]);
122
+ /**
123
+ * Module-level store for dependency refs extracted from ## Dependencies sections.
124
+ * Maps entityId array of dependency entity IDs.
125
+ * Set by render-pipeline.ts from scanner data before register() runs.
126
+ */
127
+ const _scannerDependencies = new Map();
128
+ /** Set scanner dependency data for the pipeline's aggregate() hook to consume */
129
+ export function setScannerDependencies(deps) {
130
+ _scannerDependencies.clear();
131
+ for (const [k, v] of deps)
132
+ _scannerDependencies.set(k, v);
146
133
  }
147
- /** Build a compact summary card Tag for a work/bug entity */
148
- function buildEntityCard(entity) {
149
- const id = String(entity.data.id ?? entity.id);
150
- const title = String(entity.data.title ?? '');
151
- const status = String(entity.data.status ?? '');
152
- const type = entity.type;
153
- // Header: ID on the left, status + progress on the right
154
- const headerLeft = [
155
- buildMetaBadge('ID:', id, { metaType: 'id', metaRank: 'primary', labelHidden: true }),
156
- ];
157
- const headerRight = [];
158
- const statusSentiment = type === 'work' ? WORK_STATUS_SENTIMENT[status]
159
- : type === 'bug' ? BUG_STATUS_SENTIMENT[status]
160
- : undefined;
161
- headerRight.push(buildMetaBadge('Status:', status, { metaType: 'status', metaRank: 'primary', sentiment: statusSentiment, labelHidden: true }));
162
- // Progress in header (no circle indicator)
163
- const checkedCount = Number(entity.data.checkedCount ?? 0);
164
- const totalCount = Number(entity.data.totalCount ?? 0);
165
- if (totalCount > 0) {
166
- headerRight.push(new Tag('span', {
167
- class: 'rf-backlog__card-progress',
168
- 'data-checked': String(checkedCount),
169
- 'data-total': String(totalCount),
170
- }, [`${checkedCount}/${totalCount}`]));
171
- }
172
- const header = new Tag('div', { 'data-section': 'header' }, [
173
- new Tag('span', { class: 'rf-backlog__card-header-left' }, headerLeft),
174
- new Tag('span', { class: 'rf-backlog__card-header-right' }, headerRight),
175
- ]);
176
- // Body: title
177
- const titleEl = new Tag('div', { 'data-section': 'title' }, [title]);
178
- // Footer: secondary metadata pills
179
- const footerBadges = [];
180
- if (type === 'work') {
181
- const priority = String(entity.data.priority ?? '');
182
- const complexity = String(entity.data.complexity ?? '');
183
- if (priority)
184
- footerBadges.push(buildMetaBadge('Priority:', priority, { metaType: 'category', metaRank: 'secondary', sentiment: PRIORITY_SENTIMENT[priority] }));
185
- if (complexity && complexity !== 'unknown')
186
- footerBadges.push(buildMetaBadge('Complexity:', complexity, { metaType: 'quantity', metaRank: 'secondary' }));
187
- }
188
- else if (type === 'bug') {
189
- const severity = String(entity.data.severity ?? '');
190
- if (severity)
191
- footerBadges.push(buildMetaBadge('Severity:', severity, { metaType: 'category', metaRank: 'secondary', sentiment: SEVERITY_SENTIMENT[severity] }));
192
- }
193
- const milestone = String(entity.data.milestone ?? '');
194
- if (milestone)
195
- footerBadges.push(buildMetaBadge('Milestone:', milestone, { metaType: 'tag', metaRank: 'secondary', labelHidden: true }));
196
- const sections = [header, titleEl];
197
- if (footerBadges.length > 0) {
198
- sections.push(new Tag('div', { 'data-section': 'footer' }, footerBadges));
199
- }
200
- const children = entity.sourceUrl
201
- ? [new Tag('a', { class: 'rf-backlog__card-link', href: entity.sourceUrl }, sections)]
202
- : sections;
203
- return new Tag('article', {
204
- class: 'rf-backlog__card',
205
- 'data-type': type,
206
- 'data-status': status,
207
- 'data-id': id,
208
- }, children);
134
+ /**
135
+ * Module-level store for the plan directory path.
136
+ * Set by render-pipeline.ts before aggregate() runs.
137
+ */
138
+ let _planDir;
139
+ /** Set the plan directory path for the pipeline's aggregate() hook to consume */
140
+ export function setPlanDir(dir) {
141
+ _planDir = dir;
209
142
  }
210
- const DECISION_STATUS_SENTIMENT = {
211
- proposed: 'neutral', accepted: 'positive', superseded: 'caution', deprecated: 'negative',
212
- };
213
- /** Build a decision log entry Tag */
214
- function buildDecisionEntry(entity) {
215
- const id = String(entity.data.id ?? entity.id);
216
- const title = String(entity.data.title ?? '');
217
- const status = String(entity.data.status ?? '');
218
- const date = String(entity.data.date ?? '');
219
- const badges = [
220
- buildMetaBadge('ID:', id, { metaType: 'id', metaRank: 'primary', labelHidden: true }),
221
- buildMetaBadge('Status:', status, { metaType: 'status', metaRank: 'primary', sentiment: DECISION_STATUS_SENTIMENT[status], labelHidden: true }),
222
- ];
223
- if (date)
224
- badges.push(buildMetaBadge('Date:', date, { metaType: 'temporal', metaRank: 'secondary' }));
225
- const header = new Tag('div', { 'data-section': 'header' }, badges);
226
- const titleEl = new Tag('div', { 'data-section': 'title' }, [title]);
227
- const innerChildren = [header, titleEl];
228
- const children = entity.sourceUrl
229
- ? [new Tag('a', { class: 'rf-decision-log__link', href: entity.sourceUrl }, innerChildren)]
230
- : innerChildren;
231
- return new Tag('li', {
232
- class: 'rf-decision-log__entry',
233
- 'data-status': status,
234
- 'data-id': id,
235
- }, children);
143
+ /** Parse a comma-separated `source` attribute into typed ID references */
144
+ function parseSourceIds(source) {
145
+ if (!source)
146
+ return [];
147
+ const refs = [];
148
+ for (const raw of source.split(',')) {
149
+ const id = raw.trim();
150
+ if (!id)
151
+ continue;
152
+ const match = id.match(/^(WORK|SPEC|BUG|ADR)-\d+$/);
153
+ if (match) {
154
+ const type = ID_PREFIX_TO_TYPE[match[1]];
155
+ if (type)
156
+ refs.push({ id, type });
157
+ }
158
+ }
159
+ return refs;
236
160
  }
237
161
  /**
238
162
  * Module-level store for ID references found during registration.
@@ -240,9 +164,16 @@ function buildDecisionEntry(entity) {
240
164
  * Populated by register(), consumed by aggregate().
241
165
  */
242
166
  const _idReferences = new Map();
167
+ /**
168
+ * Module-level store for structured source references (from source= attribute).
169
+ * Maps entityId → array of source entity IDs (with type).
170
+ * These produce 'implements' / 'implemented-by' relationships.
171
+ */
172
+ const _sourceReferences = new Map();
243
173
  export const planPipelineHooks = {
244
174
  register(pages, registry, ctx) {
245
175
  _idReferences.clear();
176
+ _sourceReferences.clear();
246
177
  for (const page of pages) {
247
178
  walkTags(page.renderable, (tag) => {
248
179
  const runeType = tag.attributes['data-rune'];
@@ -277,6 +208,12 @@ export const planPipelineHooks = {
277
208
  if (refs.length > 0) {
278
209
  _idReferences.set(entityId, refs);
279
210
  }
211
+ // Extract structured source references from source= attribute
212
+ const sourceVal = String(data.source ?? '');
213
+ const sourceRefs = parseSourceIds(sourceVal).filter(r => r.id !== entityId);
214
+ if (sourceRefs.length > 0) {
215
+ _sourceReferences.set(entityId, sourceRefs);
216
+ }
280
217
  registry.register({
281
218
  type: runeType,
282
219
  id: entityId,
@@ -286,61 +223,46 @@ export const planPipelineHooks = {
286
223
  });
287
224
  }
288
225
  },
289
- aggregate(registry) {
290
- // Build bidirectional relationship index from ID references
291
- const relationships = new Map();
292
- function addRel(id, rel) {
293
- if (!relationships.has(id))
294
- relationships.set(id, []);
295
- relationships.get(id).push(rel);
296
- }
297
- // Build a lookup of all registered entities for validation
226
+ aggregate(registry, ctx) {
227
+ // Build a lookup of all registered entities for relationship building
298
228
  const allEntities = new Map();
299
229
  for (const type of registry.getTypes()) {
300
230
  for (const entity of registry.getAll(type)) {
301
231
  allEntities.set(entity.id, entity);
302
232
  }
303
233
  }
304
- for (const [fromId, refs] of _idReferences) {
305
- const fromEntity = allEntities.get(fromId);
306
- if (!fromEntity)
307
- continue;
308
- for (const ref of refs) {
309
- const toEntity = allEntities.get(ref.id);
310
- if (!toEntity)
311
- continue; // Reference to unknown entity — skip
312
- // Determine relationship kind
313
- // If entity A has status "blocked" and references entity B, A is "blocked-by" B
314
- const fromStatus = String(fromEntity.data.status ?? '');
315
- const isBlockedBy = fromStatus === 'blocked';
316
- if (isBlockedBy) {
317
- // A is blocked by B
318
- addRel(fromId, {
319
- fromId, fromType: fromEntity.type,
320
- toId: ref.id, toType: toEntity.type,
321
- kind: 'blocked-by',
322
- });
323
- // B blocks A
324
- addRel(ref.id, {
325
- fromId: ref.id, fromType: toEntity.type,
326
- toId: fromId, toType: fromEntity.type,
327
- kind: 'blocks',
328
- });
234
+ // Build bidirectional relationship index using extracted module
235
+ const relationships = buildRelationships(allEntities, _sourceReferences, _scannerDependencies, _idReferences);
236
+ // Extract git history for all entities
237
+ let history = new Map();
238
+ let repositoryUrl;
239
+ try {
240
+ const planDir = _planDir ?? 'plan';
241
+ const cache = readHistoryCache(planDir);
242
+ history = extractBatchHistory(planDir, '.', { cache });
243
+ writeHistoryCache(planDir, cache);
244
+ // Parse repository URL from git remote
245
+ try {
246
+ const remoteUrl = execSync('git remote get-url origin', {
247
+ encoding: 'utf-8',
248
+ stdio: ['pipe', 'pipe', 'pipe'],
249
+ }).trim();
250
+ // Convert SSH URLs to HTTPS
251
+ const sshMatch = remoteUrl.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
252
+ if (sshMatch) {
253
+ repositoryUrl = `https://${sshMatch[1]}/${sshMatch[2]}`;
329
254
  }
330
- else {
331
- // General related reference (bidirectional)
332
- addRel(fromId, {
333
- fromId, fromType: fromEntity.type,
334
- toId: ref.id, toType: toEntity.type,
335
- kind: 'related',
336
- });
337
- addRel(ref.id, {
338
- fromId: ref.id, fromType: toEntity.type,
339
- toId: fromId, toType: fromEntity.type,
340
- kind: 'related',
341
- });
255
+ else if (remoteUrl.startsWith('https://')) {
256
+ repositoryUrl = remoteUrl.replace(/\.git$/, '');
342
257
  }
343
258
  }
259
+ catch {
260
+ // No remote configured
261
+ }
262
+ }
263
+ catch (err) {
264
+ // Git not available or not a git repo — history will be empty
265
+ ctx.warn(`Could not extract git history: ${err instanceof Error ? err.message : String(err)}`);
344
266
  }
345
267
  return {
346
268
  workEntities: registry.getAll('work'),
@@ -349,6 +271,8 @@ export const planPipelineHooks = {
349
271
  specEntities: registry.getAll('spec'),
350
272
  milestoneEntities: registry.getAll('milestone'),
351
273
  relationships,
274
+ history,
275
+ repositoryUrl,
352
276
  };
353
277
  },
354
278
  postProcess(page, aggregated) {
@@ -377,6 +301,11 @@ export const planPipelineHooks = {
377
301
  modified = true;
378
302
  return resolvePlanActivity(tag, planData);
379
303
  }
304
+ // Handle plan-history sentinel
305
+ if (tag.attributes['data-rune'] === 'plan-history' && hasSentinel(tag, PLAN_HISTORY_SENTINEL)) {
306
+ modified = true;
307
+ return resolvePlanHistory(tag, planData);
308
+ }
380
309
  // Inject auto-backlog into milestone rune tags
381
310
  if (tag.attributes['data-rune'] === 'milestone') {
382
311
  const milestoneName = readField(tag, 'name');
@@ -388,7 +317,7 @@ export const planPipelineHooks = {
388
317
  }
389
318
  }
390
319
  }
391
- // Inject relationships section into entity rune tags
320
+ // Wrap entity content in tab-group with Overview / Relationships / History panels
392
321
  if (PLAN_RUNE_TYPES.has(tag.attributes['data-rune'])) {
393
322
  const runeType = tag.attributes['data-rune'];
394
323
  const entityId = runeType === 'milestone'
@@ -396,12 +325,32 @@ export const planPipelineHooks = {
396
325
  : readField(tag, 'id');
397
326
  if (entityId) {
398
327
  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]);
328
+ const relationshipsSection = (rels && rels.length > 0)
329
+ ? buildRelationshipsSection(rels, planData)
330
+ : null;
331
+ const historySection = buildAutoHistorySection(entityId, planData);
332
+ // Only add tabs if there is content for at least one extra panel
333
+ if (relationshipsSection || historySection) {
334
+ modified = true;
335
+ // Partition children: structural (meta fields, title, blurb) stay at top;
336
+ // body content goes into the Overview tab panel.
337
+ // Note: postProcess runs BEFORE the identity transform, so children
338
+ // have data-name/data-field from createComponentRenderable but no
339
+ // data-section or BEM classes yet.
340
+ const PREAMBLE_NAMES = new Set(['title', 'blurb']);
341
+ const structural = [];
342
+ const bodyContent = [];
343
+ for (const child of tag.children) {
344
+ if (Markdoc.Tag.isTag(child) && (child.attributes['data-field'] != null ||
345
+ PREAMBLE_NAMES.has(child.attributes['data-name']))) {
346
+ structural.push(child);
347
+ }
348
+ else {
349
+ bodyContent.push(child);
350
+ }
404
351
  }
352
+ const tabWrapper = buildEntityTabGroup(bodyContent, relationshipsSection, historySection);
353
+ return new Tag(tag.name, tag.attributes, [...structural, tabWrapper]);
405
354
  }
406
355
  }
407
356
  }
@@ -683,8 +632,289 @@ function resolvePlanActivity(tag, data) {
683
632
  newChildren.push(list);
684
633
  return new Tag(tag.name, tag.attributes, newChildren);
685
634
  }
686
- const KIND_ORDER = { 'blocked-by': 0, 'blocks': 1, 'related': 2 };
687
- const KIND_LABELS = { 'blocked-by': 'Blocked by', 'blocks': 'Blocks', 'related': 'Related' };
635
+ // ─── Plan History Resolution ───
636
+ function formatHistoryDate(isoDate) {
637
+ const d = new Date(isoDate);
638
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
639
+ return `${months[d.getMonth()]} ${d.getDate()}`;
640
+ }
641
+ function buildAttrChangeTag(change) {
642
+ const children = [
643
+ new Tag('span', { class: 'rf-plan-history__field' }, [change.field]),
644
+ ];
645
+ if (change.from !== null) {
646
+ children.push(new Tag('span', { class: 'rf-plan-history__value', 'data-type': 'remove' }, [change.from]));
647
+ }
648
+ if (change.from !== null && change.to !== null) {
649
+ children.push(new Tag('span', { class: 'rf-plan-history__arrow' }, ['→']));
650
+ }
651
+ if (change.to !== null) {
652
+ const prefix = change.from === null ? '+' : '';
653
+ children.push(new Tag('span', { class: 'rf-plan-history__value', 'data-type': 'add' }, [prefix + change.to]));
654
+ }
655
+ if (change.from !== null && change.to === null) {
656
+ // Removed attribute — show as removal only
657
+ }
658
+ return new Tag('span', { class: 'rf-plan-history__change' }, children);
659
+ }
660
+ function buildEventTag(event, repositoryUrl, collapseThreshold = 3) {
661
+ const dateTag = new Tag('time', { class: 'rf-plan-history__date' }, [formatHistoryDate(event.date)]);
662
+ const hashChildren = [event.shortHash];
663
+ const hashAttrs = { class: 'rf-plan-history__hash' };
664
+ if (repositoryUrl) {
665
+ const hashTag = new Tag('a', {
666
+ class: 'rf-plan-history__hash',
667
+ href: `${repositoryUrl}/commit/${event.hash}`,
668
+ }, [event.shortHash]);
669
+ return buildEventTagInner(event, dateTag, hashTag, collapseThreshold);
670
+ }
671
+ const hashTag = new Tag('code', hashAttrs, hashChildren);
672
+ return buildEventTagInner(event, dateTag, hashTag, collapseThreshold);
673
+ }
674
+ function buildEventTagInner(event, dateTag, hashTag, collapseThreshold) {
675
+ const changesChildren = [];
676
+ if (event.kind === 'created') {
677
+ const attrs = event.initialAttributes ?? {};
678
+ const parts = Object.entries(attrs)
679
+ .filter(([k]) => k !== 'id' && k !== 'name')
680
+ .map(([, v]) => v);
681
+ changesChildren.push(new Tag('span', { class: 'rf-plan-history__created' }, [`Created (${parts.join(', ')})`]));
682
+ }
683
+ if (event.attributeChanges) {
684
+ for (const change of event.attributeChanges) {
685
+ changesChildren.push(buildAttrChangeTag(change));
686
+ }
687
+ }
688
+ if (event.criteriaChanges) {
689
+ const items = event.criteriaChanges.map(c => {
690
+ const marker = c.action === 'checked' ? '☑' : c.action === 'unchecked' ? '☐' : c.action === 'added' ? '+' : '−';
691
+ return new Tag('li', { 'data-action': c.action }, [`${marker} ${c.text}`]);
692
+ });
693
+ // Collapse if over threshold
694
+ if (items.length > collapseThreshold) {
695
+ const visible = items.slice(0, collapseThreshold);
696
+ const remaining = items.length - collapseThreshold;
697
+ visible.push(new Tag('li', { class: 'rf-plan-history__more' }, [`+${remaining} more criteria`]));
698
+ changesChildren.push(new Tag('ul', { class: 'rf-plan-history__criteria' }, visible));
699
+ }
700
+ else {
701
+ changesChildren.push(new Tag('ul', { class: 'rf-plan-history__criteria' }, items));
702
+ }
703
+ }
704
+ if (event.kind === 'resolution') {
705
+ changesChildren.push(new Tag('span', { class: 'rf-plan-history__resolution' }, ['Resolution recorded']));
706
+ }
707
+ if (event.kind === 'content') {
708
+ changesChildren.push(new Tag('span', { class: 'rf-plan-history__content-edit' }, ['Content edited']));
709
+ }
710
+ const changesDiv = new Tag('div', { class: 'rf-plan-history__changes' }, changesChildren);
711
+ return new Tag('li', {
712
+ class: 'rf-plan-history__event',
713
+ 'data-kind': event.kind,
714
+ }, [dateTag, hashTag, changesDiv]);
715
+ }
716
+ function resolvePlanHistory(tag, data) {
717
+ const entityId = readField(tag, 'id');
718
+ const limit = parseInt(readField(tag, 'limit') || '20', 10);
719
+ const typeFilter = readField(tag, 'type') || 'all';
720
+ const group = readField(tag, 'group') || 'commit';
721
+ const allEntities = [
722
+ ...data.workEntities,
723
+ ...data.bugEntities,
724
+ ...data.decisionEntities,
725
+ ...data.specEntities,
726
+ ...data.milestoneEntities,
727
+ ];
728
+ let listContent;
729
+ let isGlobal = false;
730
+ if (entityId) {
731
+ // Per-entity mode: find the entity's file path and look up its history
732
+ const entity = allEntities.find(e => e.id === entityId || e.data.name === entityId);
733
+ if (!entity) {
734
+ listContent = new Tag('ol', { 'data-name': 'events', class: 'rf-plan-history__events' }, [
735
+ new Tag('li', { class: 'rf-plan-history__empty' }, [`No history found for ${entityId}`]),
736
+ ]);
737
+ }
738
+ else {
739
+ // Find history by matching entity file path
740
+ let entityEvents = [];
741
+ for (const [file, events] of data.history) {
742
+ // Match by file path containing the entity ID or by checking attributes
743
+ if (events.length > 0 && events[0].initialAttributes) {
744
+ const eventId = events[0].initialAttributes.id ?? events[0].initialAttributes.name;
745
+ if (eventId === entityId) {
746
+ entityEvents = events;
747
+ break;
748
+ }
749
+ }
750
+ }
751
+ // Reverse to newest-first, apply limit
752
+ const limited = [...entityEvents].reverse().slice(0, limit);
753
+ const items = limited.map(e => buildEventTag(e, data.repositoryUrl));
754
+ 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'])]);
755
+ }
756
+ }
757
+ else {
758
+ // Global feed mode
759
+ isGlobal = true;
760
+ const typeSet = typeFilter !== 'all' ? new Set(typeFilter.split(',').map(t => t.trim())) : null;
761
+ const entityByFile = new Map(allEntities.map(e => {
762
+ // Try to find the file path from history keys
763
+ const filePath = e.data.file;
764
+ return [filePath ?? e.id, e];
765
+ }));
766
+ // Group events by commit
767
+ const commitMap = new Map();
768
+ for (const [file, events] of data.history) {
769
+ // Determine entity type for filtering
770
+ const firstEvent = events[0];
771
+ const initialType = firstEvent?.initialAttributes?.id?.split('-')[0]?.toLowerCase();
772
+ const entityTypeMap = { work: 'work', spec: 'spec', bug: 'bug', adr: 'decision' };
773
+ const entityType = entityTypeMap[initialType ?? ''];
774
+ if (typeSet && entityType && !typeSet.has(entityType))
775
+ continue;
776
+ const entityId = firstEvent?.initialAttributes?.id ?? firstEvent?.initialAttributes?.name ?? file;
777
+ for (const event of events) {
778
+ if (event.kind === 'content')
779
+ continue; // Skip content events in global feed
780
+ let commitGroup = commitMap.get(event.hash);
781
+ if (!commitGroup) {
782
+ commitGroup = {
783
+ hash: event.hash,
784
+ shortHash: event.shortHash,
785
+ date: event.date,
786
+ message: event.message,
787
+ entities: [],
788
+ };
789
+ commitMap.set(event.hash, commitGroup);
790
+ }
791
+ commitGroup.entities.push({ id: String(entityId), event });
792
+ }
793
+ }
794
+ // Sort commits newest-first, apply limit
795
+ const sortedCommits = [...commitMap.values()]
796
+ .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
797
+ .slice(0, limit);
798
+ const commitItems = sortedCommits.map(commit => {
799
+ const dateTag = new Tag('time', { class: 'rf-plan-history__date' }, [formatHistoryDate(commit.date)]);
800
+ const hashAttrs = { class: 'rf-plan-history__hash' };
801
+ let hashTag;
802
+ if (data.repositoryUrl) {
803
+ hashTag = new Tag('a', {
804
+ class: 'rf-plan-history__hash',
805
+ href: `${data.repositoryUrl}/commit/${commit.hash}`,
806
+ }, [commit.shortHash]);
807
+ }
808
+ else {
809
+ hashTag = new Tag('code', hashAttrs, [commit.shortHash]);
810
+ }
811
+ const messageTag = new Tag('span', { class: 'rf-plan-history__commit-message' }, [commit.message]);
812
+ const entitySummaries = commit.entities.map(({ id, event }) => {
813
+ const parts = [];
814
+ if (event.kind === 'created') {
815
+ const attrs = event.initialAttributes ?? {};
816
+ const vals = Object.entries(attrs).filter(([k]) => k !== 'id' && k !== 'name').map(([, v]) => v);
817
+ parts.push(`Created (${vals.join(', ')})`);
818
+ }
819
+ if (event.attributeChanges) {
820
+ for (const c of event.attributeChanges) {
821
+ if (c.from === null)
822
+ parts.push(`${c.field}: +${c.to}`);
823
+ else if (c.to === null)
824
+ parts.push(`${c.field}: -${c.from}`);
825
+ else
826
+ parts.push(`${c.field}: ${c.from} → ${c.to}`);
827
+ }
828
+ }
829
+ if (event.criteriaChanges && event.criteriaChanges.length > 0) {
830
+ const checked = event.criteriaChanges.filter(c => c.action === 'checked').length;
831
+ const total = event.criteriaChanges.length;
832
+ parts.push(`☑ ${checked}/${total}`);
833
+ }
834
+ if (event.kind === 'resolution')
835
+ parts.push('Resolution recorded');
836
+ return new Tag('div', { class: 'rf-plan-history__entity-summary' }, [
837
+ new Tag('span', { class: 'rf-plan-history__entity-id' }, [id]),
838
+ new Tag('span', { class: 'rf-plan-history__entity-changes' }, [parts.join(', ')]),
839
+ ]);
840
+ });
841
+ return new Tag('li', { class: 'rf-plan-history__event' }, [
842
+ dateTag, hashTag, messageTag,
843
+ new Tag('div', { class: 'rf-plan-history__changes' }, entitySummaries),
844
+ ]);
845
+ });
846
+ 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'])]);
847
+ }
848
+ const attrs = { ...tag.attributes };
849
+ if (isGlobal) {
850
+ attrs.class = ((attrs.class ?? '') + ' rf-plan-history--global').trim();
851
+ }
852
+ const newChildren = tag.children.filter((c) => !(Markdoc.Tag.isTag(c) && (c.attributes['data-field'] === PLAN_HISTORY_SENTINEL ||
853
+ c.attributes['data-name'] === 'events' ||
854
+ c.attributes['data-name'] === 'items')));
855
+ newChildren.push(listContent);
856
+ return new Tag(tag.name, attrs, newChildren);
857
+ }
858
+ /**
859
+ * Build a tab-group wrapper for entity pages with Overview, Relationships, and History panels.
860
+ * Emits the same HTML contract that tabsBehavior expects.
861
+ */
862
+ function buildEntityTabGroup(bodyContent, relationshipsSection, historySection) {
863
+ const tabButtons = [];
864
+ const tabPanels = [];
865
+ // Overview tab (always present)
866
+ tabButtons.push(new Tag('button', {
867
+ role: 'tab',
868
+ class: 'rf-plan-entity-tabs__tab',
869
+ 'data-tab': 'overview',
870
+ }, ['Overview']));
871
+ tabPanels.push(new Tag('div', {
872
+ role: 'tabpanel',
873
+ class: 'rf-plan-entity-tabs__panel',
874
+ 'data-tab': 'overview',
875
+ }, bodyContent));
876
+ // Relationships tab (only if there are relationships)
877
+ if (relationshipsSection) {
878
+ tabButtons.push(new Tag('button', {
879
+ role: 'tab',
880
+ class: 'rf-plan-entity-tabs__tab',
881
+ 'data-tab': 'relationships',
882
+ }, ['Relationships']));
883
+ tabPanels.push(new Tag('div', {
884
+ role: 'tabpanel',
885
+ class: 'rf-plan-entity-tabs__panel',
886
+ 'data-tab': 'relationships',
887
+ }, [relationshipsSection]));
888
+ }
889
+ // History tab (only if there is history)
890
+ if (historySection) {
891
+ tabButtons.push(new Tag('button', {
892
+ role: 'tab',
893
+ class: 'rf-plan-entity-tabs__tab',
894
+ 'data-tab': 'history',
895
+ }, ['History']));
896
+ tabPanels.push(new Tag('div', {
897
+ role: 'tabpanel',
898
+ class: 'rf-plan-entity-tabs__panel',
899
+ 'data-tab': 'history',
900
+ }, [historySection]));
901
+ }
902
+ const tabList = new Tag('div', {
903
+ 'data-name': 'tabs',
904
+ role: 'tablist',
905
+ class: 'rf-plan-entity-tabs__tabs',
906
+ }, tabButtons);
907
+ const panels = new Tag('div', {
908
+ 'data-name': 'panels',
909
+ class: 'rf-plan-entity-tabs__panels',
910
+ }, tabPanels);
911
+ return new Tag('div', {
912
+ class: 'rf-plan-entity-tabs',
913
+ 'data-rune': 'plan-entity-tabs',
914
+ }, [tabList, panels]);
915
+ }
916
+ 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 };
917
+ 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
918
  /** Look up an entity across all aggregated type arrays */
689
919
  function findEntity(id, data) {
690
920
  const allArrays = [data.workEntities, data.bugEntities, data.decisionEntities, data.specEntities, data.milestoneEntities];
@@ -720,37 +950,42 @@ function buildRelationshipsSection(rels, data) {
720
950
  for (const kind of sortedKinds) {
721
951
  const kindRels = byKind.get(kind);
722
952
  const label = KIND_LABELS[kind] || kind;
723
- const items = [];
953
+ // "Informed by" renders decision entry cards
954
+ if (kind === 'informed-by') {
955
+ const entries = [];
956
+ for (const rel of kindRels) {
957
+ const target = findEntity(rel.toId, data);
958
+ if (target) {
959
+ entries.push(buildDecisionEntry(target));
960
+ }
961
+ }
962
+ if (entries.length > 0) {
963
+ groups.push(new Tag('div', {
964
+ class: 'rf-plan-relationships__group',
965
+ 'data-kind': kind,
966
+ }, [
967
+ new Tag('h3', { class: 'rf-plan-relationships__group-title' }, [label]),
968
+ new Tag('ol', { class: 'rf-plan-relationships__decisions' }, entries),
969
+ ]));
970
+ }
971
+ continue;
972
+ }
973
+ const cards = [];
724
974
  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',
975
+ const target = findEntity(rel.toId, data);
976
+ if (target) {
977
+ cards.push(buildEntityCard(target));
978
+ }
979
+ }
980
+ if (cards.length > 0) {
981
+ groups.push(new Tag('div', {
982
+ class: 'rf-plan-relationships__group',
744
983
  'data-kind': kind,
745
- }, children));
984
+ }, [
985
+ new Tag('h3', { class: 'rf-plan-relationships__group-title' }, [label]),
986
+ new Tag('div', { class: 'rf-plan-relationships__cards' }, cards),
987
+ ]));
746
988
  }
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
989
  }
755
990
  if (groups.length === 0)
756
991
  return null;
@@ -762,4 +997,35 @@ function buildRelationshipsSection(rels, data) {
762
997
  ...groups,
763
998
  ]);
764
999
  }
1000
+ /**
1001
+ * Build an auto-injected History section for an entity page.
1002
+ * Returns null for entities with only a single commit (created and never modified).
1003
+ */
1004
+ function buildAutoHistorySection(entityId, data) {
1005
+ // Find history events by matching the entity ID in the first event's attributes
1006
+ let entityEvents = [];
1007
+ for (const [, events] of data.history) {
1008
+ if (events.length > 0 && events[0].initialAttributes) {
1009
+ const eventId = events[0].initialAttributes.id ?? events[0].initialAttributes.name;
1010
+ if (eventId === entityId) {
1011
+ entityEvents = events;
1012
+ break;
1013
+ }
1014
+ }
1015
+ }
1016
+ // Skip entities with only a single commit (creation only) — no meaningful history
1017
+ if (entityEvents.length <= 1)
1018
+ return null;
1019
+ // Build timeline (newest-first), limit to 20 events
1020
+ const limited = [...entityEvents].reverse().slice(0, 20);
1021
+ const items = limited.map(e => buildEventTag(e, data.repositoryUrl));
1022
+ const list = new Tag('ol', { class: 'rf-plan-history__events' }, items);
1023
+ return new Tag('section', {
1024
+ class: 'rf-plan-history',
1025
+ 'data-name': 'history',
1026
+ }, [
1027
+ new Tag('h2', { class: 'rf-plan-history__heading' }, ['History']),
1028
+ list,
1029
+ ]);
1030
+ }
765
1031
  //# sourceMappingURL=pipeline.js.map