dotmd-cli 0.34.0 → 0.36.0

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.
package/README.md CHANGED
@@ -757,6 +757,12 @@ export const referenceFields = {
757
757
  bidirectional: ['related_plans'], // warn if A→B but B↛A
758
758
  unidirectional: ['supports_plans'], // one-way, no symmetry check
759
759
  };
760
+ // Per-ref opt-out: prefix any value with `>` to mark that specific ref one-way
761
+ // without changing the field's default. Useful for leaf→upstream-parent refs
762
+ // (audits, hub docs) where a back-ref would force editing a stable parent.
763
+ // related_docs:
764
+ // - docs/sibling-design.md # bidirectional (default for the field)
765
+ // - "> docs/audit-beyond-platform.md" # one-way upstream — no back-ref expected
760
766
 
761
767
  export const index = {
762
768
  path: 'docs/docs.md',
package/bin/dotmd.mjs CHANGED
@@ -32,6 +32,8 @@ Analyze:
32
32
  coverage [--json] Metadata coverage report
33
33
  graph [--dot] [--json] Visualize document relationships
34
34
  deps [file] [--json] Dependency tree or overview
35
+ modules [--sort cleanup] [--json] Module dashboard (plans grouped by module)
36
+ module <name> [--json] Plans for one module, grouped by status
35
37
  unblocks <file> [--json] Show what completes when this doc ships
36
38
  diff [file] [--summarize] Show changes since last updated date
37
39
  summary <file> [--json] AI summary of a document
@@ -297,6 +299,43 @@ Options:
297
299
  --depth <n> Max tree depth (default: 5)
298
300
  --json Machine-readable JSON output`,
299
301
 
302
+ modules: `dotmd modules — module dashboard (plans grouped by module)
303
+
304
+ One row per module discovered in plan frontmatter. Dynamic status columns
305
+ (only statuses with ≥1 plan render). Defaults to --type plan; pass --type
306
+ to scope to docs/prompts.
307
+
308
+ Sort modes:
309
+ --sort total Plan count, desc (default)
310
+ --sort stale Stale-plan count, desc
311
+ --sort age Average age in days, desc
312
+ --sort nextstep % of plans with a next_step set, desc
313
+ --sort cleanup Triage score: (stale × avgAge) / max(total, 1)
314
+
315
+ Options:
316
+ --limit <n> Cap rows (default: 20)
317
+ --all Show every module
318
+ --json Machine-readable shape (includes _totalUnique to
319
+ detect modules: [a, b] double-counting)
320
+
321
+ A plan with \`modules: [a, b]\` counts in both rows — intentional, so
322
+ multi-module plans surface in every relevant triage view. \`(none)\` is a
323
+ literal row for plans with no module tag.`,
324
+
325
+ module: `dotmd module <name> — plans for one module, grouped by status
326
+
327
+ Status groups follow config.statusOrder. Stale plans are flagged inline.
328
+
329
+ Sort modes (within each status group):
330
+ --sort status By config.statusOrder, then age (default)
331
+ --sort updated Most-recently-updated first
332
+ --sort age Oldest first
333
+
334
+ Options:
335
+ --json Machine-readable shape
336
+
337
+ Unknown module name suggests close matches (or lists what's available).`,
338
+
300
339
  doctor: `dotmd doctor — auto-fix everything in one pass
301
340
 
302
341
  Runs in sequence: fix broken references, lint --fix, sync dates from
@@ -563,7 +602,10 @@ Examples:
563
602
  stale: `dotmd stale — list stale documents
564
603
 
565
604
  Shows docs that haven't been updated within their staleness threshold.
566
- Supports all query flags (--status, --json, --sort, etc.)`,
605
+ Supports all query flags (--status, --json, --sort, etc.)
606
+
607
+ Examples:
608
+ dotmd stale --group module Stale plans grouped by module (triage view)`,
567
609
 
568
610
  actionable: `dotmd actionable — list docs with next steps
569
611
 
@@ -970,6 +1012,22 @@ async function main() {
970
1012
 
971
1013
  if (command === 'focus') { runFocus(index, restArgs, config); return; }
972
1014
  if (command === 'query') { runQuery(index, restArgs, config); return; }
1015
+ if (command === 'modules' || command === 'module') {
1016
+ // D3: default `--type plan` when the user didn't pass --type explicitly.
1017
+ // applyIndexFilters already narrowed by typeArg if it was set; if not, the
1018
+ // index still spans all types, and the dashboard would mix plans/docs/prompts
1019
+ // into the same module rows. Narrow here so the docs/prompts case stays a
1020
+ // deliberate `--type doc` opt-in (deferred per plan).
1021
+ const scoped = typeArg ? index : { ...index, docs: index.docs.filter(d => d.type === 'plan') };
1022
+ if (command === 'modules') {
1023
+ const { runModulesDashboard } = await import('../src/modules.mjs');
1024
+ runModulesDashboard(scoped, restArgs, config);
1025
+ } else {
1026
+ const { runModuleDetail } = await import('../src/modules.mjs');
1027
+ runModuleDetail(scoped, restArgs, config);
1028
+ }
1029
+ return;
1030
+ }
973
1031
  if (command === 'briefing') {
974
1032
  if (args.includes('--json')) {
975
1033
  const plans = index.docs.filter(d => d.type === 'plan');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/commands.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  export const KNOWN_COMMANDS = [
6
6
  'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'hud',
7
7
  'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'finish', 'status', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor',
8
- 'unblocks', 'health', 'glossary',
8
+ 'unblocks', 'health', 'glossary', 'modules', 'module',
9
9
  'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
10
10
  'watch', 'diff', 'new', 'init', 'completions', 'statuses',
11
11
  ];
package/src/index.mjs CHANGED
@@ -175,10 +175,30 @@ export function parseDocFile(filePath, config) {
175
175
  const bodyLinks = extractBodyLinks(body);
176
176
  const hasCloseout = /^##\s+Closeout/m.test(body);
177
177
 
178
- // Dynamic reference field extraction
178
+ // Dynamic reference field extraction. A leading `>` on a value (e.g.
179
+ // `"> docs/audit-beyond-platform.md"`) marks that single ref as one-way —
180
+ // the prefix is stripped so path resolution still works, and the direction
181
+ // is recorded on a parallel `refFieldDirections[field]` array indexed the
182
+ // same as `refFields[field]`. Bidirectional reciprocity checks consume the
183
+ // directions to skip outbound entries that opted out of expecting a back-ref.
179
184
  const refFields = {};
185
+ const refFieldDirections = {};
180
186
  for (const field of [...(config.referenceFields.bidirectional || []), ...(config.referenceFields.unidirectional || [])]) {
181
- refFields[field] = normalizeStringList(parsedFrontmatter[field]);
187
+ const raw = normalizeStringList(parsedFrontmatter[field]);
188
+ const paths = [];
189
+ const directions = [];
190
+ for (const entry of raw) {
191
+ const oneWay = entry.match(/^>\s*(.+)$/);
192
+ if (oneWay) {
193
+ paths.push(oneWay[1].trim());
194
+ directions.push('one-way');
195
+ } else {
196
+ paths.push(entry);
197
+ directions.push('two-way');
198
+ }
199
+ }
200
+ refFields[field] = paths;
201
+ refFieldDirections[field] = directions;
182
202
  }
183
203
 
184
204
  // Tag doc with its root
@@ -215,6 +235,7 @@ export function parseDocFile(filePath, config) {
215
235
  checklist,
216
236
  bodyLinks,
217
237
  refFields,
238
+ refFieldDirections,
218
239
  checklistCompletionRate: computeChecklistCompletionRate(checklist),
219
240
  hasCloseout,
220
241
  hasNextStep: Boolean(nextStep),
@@ -0,0 +1,355 @@
1
+ // Modules dashboard (F16). Two view-only commands:
2
+ // `dotmd modules` — one row per discovered module, dynamic status columns
3
+ // `dotmd module <name>` — plans for one module, grouped by status, stale flagged inline
4
+ //
5
+ // Aggregation lives in `aggregateModules` (pure data). Both renderers consume
6
+ // the same shape, so JSON output and the table share one source of truth.
7
+ //
8
+ // Constraint cheatsheet (see docs/archived/modules-dashboard.md):
9
+ // M1 dynamic columns — discover statuses from rendered rows, drop empty cols
10
+ // M2 overflow fallback — drop-empty first, then stacked render if still too wide
11
+ // M3 double-counting — `modules: [a, b]` increments both rows (intended)
12
+ // M4 (none) bucket — surface when ≥1 plan has empty modules
13
+ // M6 archived/skipStale — terminal statuses excluded; skipStale don't contribute to `stale`
14
+ import { toSlug, truncate, die, suggestCandidates } from './util.mjs';
15
+ import { bold, dim, yellow, red, green, blue, magenta, cyan, brightYellow } from './color.mjs';
16
+
17
+ const STATUS_COLORS = {
18
+ 'in-session': (s) => bold(red(s)),
19
+ 'active': green,
20
+ 'planned': blue,
21
+ 'blocked': yellow,
22
+ 'partial': (s) => dim(green(s)),
23
+ 'paused': magenta,
24
+ 'awaiting': brightYellow,
25
+ 'queued-after': (s) => dim(cyan(s)),
26
+ 'archived': dim,
27
+ 'pending': bold,
28
+ 'claimed': dim,
29
+ };
30
+
31
+ function colorStatus(status, text) {
32
+ const fn = STATUS_COLORS[status] ?? ((s) => s);
33
+ return fn(text);
34
+ }
35
+
36
+ // Single pass over docs → Map<moduleName, aggregate>. Terminal-status docs are
37
+ // excluded entirely (M6) — they pollute the active-work picture. skipStale
38
+ // statuses are counted toward totals but never contribute to `stale` so they
39
+ // don't lie about staleness when a custom config (e.g. `backlog`) opts out.
40
+ export function aggregateModules(docs, config) {
41
+ const terminal = config.lifecycle.terminalStatuses;
42
+ const skipStale = config.lifecycle.skipStaleFor;
43
+ const liveDocs = docs.filter(d => !terminal.has(d.status));
44
+ const uniqueTotal = liveDocs.length;
45
+
46
+ const modules = new Map();
47
+ function bucket(name) {
48
+ if (!modules.has(name)) {
49
+ modules.set(name, {
50
+ name,
51
+ plans: [],
52
+ byStatus: {},
53
+ stale: 0,
54
+ ageSum: 0,
55
+ ageCount: 0,
56
+ oldest: null,
57
+ nextStepCount: 0,
58
+ });
59
+ }
60
+ return modules.get(name);
61
+ }
62
+
63
+ for (const doc of liveDocs) {
64
+ const names = doc.modules?.length ? doc.modules : ['(none)'];
65
+ for (const name of names) {
66
+ const m = bucket(name);
67
+ m.plans.push(doc);
68
+ m.byStatus[doc.status] = (m.byStatus[doc.status] ?? 0) + 1;
69
+ if (doc.isStale && !skipStale.has(doc.status)) m.stale += 1;
70
+ if (typeof doc.daysSinceUpdate === 'number') {
71
+ m.ageSum += doc.daysSinceUpdate;
72
+ m.ageCount += 1;
73
+ if (!m.oldest || doc.daysSinceUpdate > m.oldest.ageDays) {
74
+ m.oldest = { slug: toSlug(doc), ageDays: doc.daysSinceUpdate };
75
+ }
76
+ }
77
+ if (doc.hasNextStep) m.nextStepCount += 1;
78
+ }
79
+ }
80
+
81
+ const rows = [...modules.values()].map(m => ({
82
+ name: m.name,
83
+ total: m.plans.length,
84
+ byStatus: m.byStatus,
85
+ stale: m.stale,
86
+ avgAgeDays: m.ageCount > 0 ? m.ageSum / m.ageCount : 0,
87
+ oldest: m.oldest,
88
+ nextStepPct: m.plans.length > 0 ? m.nextStepCount / m.plans.length : 0,
89
+ _plans: m.plans,
90
+ }));
91
+
92
+ return { rows, totalUnique: uniqueTotal };
93
+ }
94
+
95
+ // Cleanup-rank formula (R3): high stale share + high average age + lower total
96
+ // floats modules that have rotted; low total in the denominator keeps a single
97
+ // ancient plan from outweighing a 30-plan module with two stale ones. Document
98
+ // in `--help`; iterate after running on real corpora.
99
+ function cleanupScore(row) {
100
+ return (row.stale * row.avgAgeDays) / Math.max(row.total, 1);
101
+ }
102
+
103
+ function buildSorter(sort) {
104
+ if (sort === 'stale') return (a, b) => b.stale - a.stale || b.total - a.total;
105
+ if (sort === 'age') return (a, b) => b.avgAgeDays - a.avgAgeDays || b.total - a.total;
106
+ if (sort === 'nextstep') return (a, b) => b.nextStepPct - a.nextStepPct || b.total - a.total;
107
+ if (sort === 'cleanup') return (a, b) => cleanupScore(b) - cleanupScore(a) || b.stale - a.stale;
108
+ return (a, b) => b.total - a.total || a.name.localeCompare(b.name);
109
+ }
110
+
111
+ function parseFlags(argv) {
112
+ const flags = { sort: 'total', json: false, limit: 20, all: false };
113
+ for (let i = 0; i < argv.length; i++) {
114
+ const arg = argv[i];
115
+ if (arg === '--sort' && argv[i + 1]) { flags.sort = argv[++i]; continue; }
116
+ if (arg === '--json') { flags.json = true; continue; }
117
+ if (arg === '--limit' && argv[i + 1]) { flags.limit = parseInt(argv[++i], 10); continue; }
118
+ if (arg === '--all') { flags.all = true; continue; }
119
+ }
120
+ const validSorts = new Set(['total', 'stale', 'age', 'nextstep', 'cleanup']);
121
+ if (!validSorts.has(flags.sort)) {
122
+ die(`Invalid --sort value: ${flags.sort}. Valid: ${[...validSorts].join(', ')}.`);
123
+ }
124
+ return flags;
125
+ }
126
+
127
+ // Discover which status columns to render. Only statuses with ≥1 non-zero cell
128
+ // across the rendered rows make the cut (M1). Ordered by config.statusOrder
129
+ // first, then alphabetically for any custom statuses the index encountered
130
+ // that aren't in statusOrder (so a Beyond-style `research` column appears).
131
+ function discoverStatusColumns(rows, config) {
132
+ const seen = new Set();
133
+ for (const row of rows) {
134
+ for (const [status, count] of Object.entries(row.byStatus)) {
135
+ if (count > 0) seen.add(status);
136
+ }
137
+ }
138
+ const ordered = config.statusOrder.filter(s => seen.has(s));
139
+ const extras = [...seen].filter(s => !config.statusOrder.includes(s)).sort();
140
+ return [...ordered, ...extras];
141
+ }
142
+
143
+ function fmtAge(days) {
144
+ if (!Number.isFinite(days)) return '-';
145
+ if (days < 1) return '<1d';
146
+ if (days < 10) return `${days.toFixed(1)}d`;
147
+ return `${Math.round(days)}d`;
148
+ }
149
+
150
+ function fmtPct(frac) {
151
+ if (!Number.isFinite(frac)) return '-';
152
+ return `${Math.round(frac * 100)}%`;
153
+ }
154
+
155
+ function fmtOldest(oldest) {
156
+ if (!oldest) return '-';
157
+ const slug = truncate(oldest.slug, 14);
158
+ return `${slug} (${fmtAge(oldest.ageDays)})`;
159
+ }
160
+
161
+ // Heuristic column-width budget. Returns true if the dynamic-columns table
162
+ // will fit within the terminal; caller switches to the stacked render if not.
163
+ function tableFits(rows, statuses, termWidth) {
164
+ const nameCol = Math.max(8, ...rows.map(r => r.name.length));
165
+ const statusCols = statuses.length * 7; // each status header padded
166
+ const tailCols = 6 /*stale*/ + 8 /*age*/ + 22 /*oldest*/ + 6 /*next%*/ + 8 /*gutters*/;
167
+ return nameCol + statusCols + tailCols <= termWidth;
168
+ }
169
+
170
+ function pad(s, width) {
171
+ if (s.length >= width) return s;
172
+ return s + ' '.repeat(width - s.length);
173
+ }
174
+
175
+ function padLeft(s, width) {
176
+ if (s.length >= width) return s;
177
+ return ' '.repeat(width - s.length) + s;
178
+ }
179
+
180
+ function renderTable(rows, statuses, config) {
181
+ const nameCol = Math.max(8, ...rows.map(r => r.name.length));
182
+ const lines = [];
183
+ const headerCells = [
184
+ pad('module', nameCol),
185
+ ...statuses.map(s => padLeft(s.slice(0, 5), 5)),
186
+ padLeft('stale', 5),
187
+ padLeft('avgAge', 7),
188
+ pad('oldest', 22),
189
+ padLeft('next%', 5),
190
+ ];
191
+ lines.push(dim(headerCells.join(' ')));
192
+ for (const row of rows) {
193
+ const cells = [
194
+ pad(row.name, nameCol),
195
+ ...statuses.map(s => {
196
+ const n = row.byStatus[s] ?? 0;
197
+ const text = padLeft(String(n), 5);
198
+ return n > 0 ? colorStatus(s, text) : dim(text);
199
+ }),
200
+ padLeft(String(row.stale), 5),
201
+ padLeft(fmtAge(row.avgAgeDays), 7),
202
+ pad(fmtOldest(row.oldest), 22),
203
+ padLeft(fmtPct(row.nextStepPct), 5),
204
+ ];
205
+ lines.push(cells.join(' '));
206
+ }
207
+ return lines.join('\n') + '\n';
208
+ }
209
+
210
+ // Stacked fallback (M2 step 2): one block per module, status counts indented.
211
+ // Preserves every value — never collapses to "Other".
212
+ function renderStacked(rows, statuses) {
213
+ const lines = [];
214
+ for (const row of rows) {
215
+ lines.push(bold(row.name) + dim(` · ${row.total} total · ${row.stale} stale · avg ${fmtAge(row.avgAgeDays)} · next-step ${fmtPct(row.nextStepPct)}`));
216
+ const cells = statuses
217
+ .filter(s => (row.byStatus[s] ?? 0) > 0)
218
+ .map(s => `${colorStatus(s, s)}: ${row.byStatus[s]}`);
219
+ if (cells.length) lines.push(' ' + cells.join(' '));
220
+ if (row.oldest) lines.push(dim(` oldest: ${row.oldest.slug} (${fmtAge(row.oldest.ageDays)})`));
221
+ lines.push('');
222
+ }
223
+ return lines.join('\n');
224
+ }
225
+
226
+ export function runModulesDashboard(index, argv, config) {
227
+ const flags = parseFlags(argv);
228
+ const { rows, totalUnique } = aggregateModules(index.docs, config);
229
+ rows.sort(buildSorter(flags.sort));
230
+ const totalModules = rows.length;
231
+ const displayRows = flags.all ? rows : rows.slice(0, flags.limit);
232
+
233
+ if (flags.json) {
234
+ const out = {
235
+ type: 'plan',
236
+ sort: flags.sort,
237
+ _totalUnique: totalUnique,
238
+ modules: displayRows.map(r => ({
239
+ name: r.name,
240
+ total: r.total,
241
+ byStatus: r.byStatus,
242
+ stale: r.stale,
243
+ avgAgeDays: Math.round(r.avgAgeDays * 10) / 10,
244
+ oldest: r.oldest,
245
+ nextStepPct: Math.round(r.nextStepPct * 100) / 100,
246
+ })),
247
+ };
248
+ process.stdout.write(JSON.stringify(out, null, 2) + '\n');
249
+ return;
250
+ }
251
+
252
+ if (rows.length === 0) {
253
+ process.stdout.write('No modules found.\n');
254
+ return;
255
+ }
256
+
257
+ const statuses = discoverStatusColumns(displayRows, config);
258
+ const termWidth = process.stdout.columns || 120;
259
+
260
+ const header = `${totalModules} modules · ${totalUnique} plans · sort: ${flags.sort}`;
261
+ process.stdout.write(dim(header) + '\n\n');
262
+
263
+ if (tableFits(displayRows, statuses, termWidth)) {
264
+ process.stdout.write(renderTable(displayRows, statuses, config));
265
+ } else {
266
+ process.stdout.write(renderStacked(displayRows, statuses));
267
+ }
268
+
269
+ if (!flags.all && rows.length > flags.limit) {
270
+ const hidden = rows.length - flags.limit;
271
+ process.stdout.write('\n' + dim(` ${hidden} more module${hidden === 1 ? '' : 's'} · dotmd modules --all\n`));
272
+ }
273
+ }
274
+
275
+ export function runModuleDetail(index, argv, config) {
276
+ const flags = { sort: 'status', json: false };
277
+ const positional = [];
278
+ for (let i = 0; i < argv.length; i++) {
279
+ const arg = argv[i];
280
+ if (arg === '--sort' && argv[i + 1]) { flags.sort = argv[++i]; continue; }
281
+ if (arg === '--json') { flags.json = true; continue; }
282
+ if (arg.startsWith('-')) continue;
283
+ positional.push(arg);
284
+ }
285
+ const name = positional[0];
286
+ if (!name) die('Usage: dotmd module <name>');
287
+
288
+ const { rows } = aggregateModules(index.docs, config);
289
+ const row = rows.find(r => r.name === name);
290
+ if (!row) {
291
+ const candidates = rows.map(r => r.name);
292
+ const suggestions = suggestCandidates(name, candidates);
293
+ const hint = suggestions.length
294
+ ? `Did you mean: ${suggestions.join(', ')}?`
295
+ : `Available: ${candidates.slice(0, 5).join(', ')}${candidates.length > 5 ? ', …' : ''} (run \`dotmd modules\` for the full list).`;
296
+ die(`Module '${name}' not found. ${hint}`);
297
+ }
298
+
299
+ const plans = row._plans.slice();
300
+ if (flags.sort === 'updated' || flags.sort === 'age') {
301
+ plans.sort((a, b) => (b.daysSinceUpdate ?? 0) - (a.daysSinceUpdate ?? 0));
302
+ } else {
303
+ plans.sort((a, b) => {
304
+ const ai = config.statusOrder.indexOf(a.status);
305
+ const bi = config.statusOrder.indexOf(b.status);
306
+ const aIdx = ai === -1 ? Number.MAX_SAFE_INTEGER : ai;
307
+ const bIdx = bi === -1 ? Number.MAX_SAFE_INTEGER : bi;
308
+ if (aIdx !== bIdx) return aIdx - bIdx;
309
+ return (b.daysSinceUpdate ?? 0) - (a.daysSinceUpdate ?? 0);
310
+ });
311
+ }
312
+
313
+ if (flags.json) {
314
+ const out = {
315
+ name: row.name,
316
+ total: row.total,
317
+ plans: plans.map(p => ({
318
+ path: p.path,
319
+ slug: toSlug(p),
320
+ status: p.status,
321
+ daysSinceUpdate: p.daysSinceUpdate,
322
+ isStale: p.isStale,
323
+ hasNextStep: p.hasNextStep,
324
+ summary: p.summary,
325
+ })),
326
+ };
327
+ process.stdout.write(JSON.stringify(out, null, 2) + '\n');
328
+ return;
329
+ }
330
+
331
+ process.stdout.write(dim(`${row.total} plans · ${row.stale} stale · module: ${row.name}`) + '\n');
332
+
333
+ const skipStale = config.lifecycle.skipStaleFor;
334
+ const groups = new Map();
335
+ for (const p of plans) {
336
+ if (!groups.has(p.status)) groups.set(p.status, []);
337
+ groups.get(p.status).push(p);
338
+ }
339
+ const orderedStatuses = [
340
+ ...config.statusOrder.filter(s => groups.has(s)),
341
+ ...[...groups.keys()].filter(s => !config.statusOrder.includes(s)),
342
+ ];
343
+ for (const status of orderedStatuses) {
344
+ const group = groups.get(status);
345
+ process.stdout.write(`\n${bold(colorStatus(status, status))} (${group.length})\n`);
346
+ for (const p of group) {
347
+ const slug = toSlug(p);
348
+ const age = fmtAge(p.daysSinceUpdate);
349
+ const staleTag = p.isStale && !skipStale.has(p.status) ? dim(' [stale]') : '';
350
+ const next = p.nextStep ? ` — ${truncate(p.nextStep, 60)}` : '';
351
+ process.stdout.write(` ${slug} ${dim(age)}${staleTag}${next}\n`);
352
+ }
353
+ }
354
+ process.stdout.write('\n');
355
+ }
package/src/validate.mjs CHANGED
@@ -258,26 +258,42 @@ export function checkBidirectionalReferences(docs, config) {
258
258
  const biFields = config.referenceFields.bidirectional || [];
259
259
  if (!biFields.length) return { warnings, errors: [] };
260
260
 
261
+ // refMap stores `Set<targetPath>` keyed by the source doc path — used to
262
+ // answer "does B reference A?" via .has(). oneWayMap stores the subset of A's
263
+ // outbound refs that opted out of expecting a back-ref (via the `>` prefix in
264
+ // frontmatter, parsed in src/index.mjs:parseDocFile). We split these so the
265
+ // membership check stays cheap (Set.has) while the per-ref directionality
266
+ // filter only consults oneWayMap when iterating outbound edges.
261
267
  const refMap = new Map();
268
+ const oneWayMap = new Map();
262
269
  for (const doc of docs) {
263
270
  const docDir = path.dirname(path.join(config.repoRoot, doc.path));
264
271
  const refs = new Set();
272
+ const oneWay = new Set();
265
273
  for (const field of biFields) {
266
- for (const relPath of (doc.refFields[field] || [])) {
274
+ const entries = doc.refFields[field] || [];
275
+ const dirs = doc.refFieldDirections?.[field] || [];
276
+ for (let i = 0; i < entries.length; i++) {
277
+ const relPath = entries[i];
267
278
  // Use the same doc-relative-then-repo-root fallback as validateDoc so
268
279
  // both styles produce identical refMap keys; otherwise an entry like
269
280
  // `docs/foo.md` (repo-root style) gets keyed as
270
281
  // `<doc-parent>/docs/foo.md` and never matches the target's repo path.
271
282
  const resolved = resolveRefPath(relPath, docDir, config.repoRoot)
272
283
  ?? path.resolve(docDir, relPath);
273
- refs.add(toRepoPath(resolved, config.repoRoot));
284
+ const targetPath = toRepoPath(resolved, config.repoRoot);
285
+ refs.add(targetPath);
286
+ if (dirs[i] === 'one-way') oneWay.add(targetPath);
274
287
  }
275
288
  }
276
289
  refMap.set(doc.path, refs);
290
+ oneWayMap.set(doc.path, oneWay);
277
291
  }
278
292
 
279
293
  for (const [docPath, refs] of refMap) {
294
+ const oneWay = oneWayMap.get(docPath);
280
295
  for (const targetPath of refs) {
296
+ if (oneWay.has(targetPath)) continue;
281
297
  const targetRefs = refMap.get(targetPath);
282
298
  if (targetRefs && !targetRefs.has(docPath)) {
283
299
  warnings.push({ path: docPath, level: 'warning',