runlist 0.0.0 → 0.94.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1114 -6
  3. package/assets/app/app.css +283 -0
  4. package/assets/app/app.mjs +381 -0
  5. package/assets/app/block-editor.mjs +182 -0
  6. package/assets/app/block-model.mjs +52 -0
  7. package/assets/app/document-create.mjs +86 -0
  8. package/assets/app/document-lifecycle.mjs +36 -0
  9. package/assets/app/document-stages.mjs +22 -0
  10. package/assets/app/document-views.mjs +33 -0
  11. package/assets/app/document-yardstick.mjs +258 -0
  12. package/assets/app/editor-navigation.mjs +51 -0
  13. package/assets/app/filing-navigation.mjs +138 -0
  14. package/assets/app/git-changes.mjs +80 -0
  15. package/assets/app/git-commit.mjs +46 -0
  16. package/assets/app/index.html +70 -0
  17. package/assets/app/library-navigation.mjs +93 -0
  18. package/assets/app/outline.mjs +18 -0
  19. package/assets/app/quick-navigation.mjs +52 -0
  20. package/assets/app/record-navigation.mjs +78 -0
  21. package/assets/app/recovery-center.mjs +53 -0
  22. package/assets/app/semantic-search.mjs +14 -0
  23. package/assets/app/settings.mjs +15 -0
  24. package/assets/app/shared.mjs +104 -0
  25. package/assets/app/stage-source.mjs +75 -0
  26. package/assets/app/template-editor.mjs +47 -0
  27. package/assets/app/theme.mjs +25 -0
  28. package/assets/app/transport.mjs +43 -0
  29. package/assets/contracts/source-editing-v1.md +232 -0
  30. package/assets/opencode/plugin.js +162 -0
  31. package/assets/schemas/runlist-record-v1.schema.json +912 -0
  32. package/bin/dotmd.mjs +5 -0
  33. package/bin/runlist.mjs +2714 -0
  34. package/package.json +53 -6
  35. package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
  36. package/plugins/runlist-codex/bin/runlist-hook +86 -0
  37. package/plugins/runlist-codex/hooks/hooks.json +16 -0
  38. package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
  39. package/runlist.config.example.mjs +370 -0
  40. package/scripts/postinstall.mjs +56 -0
  41. package/src/agent-context.mjs +201 -0
  42. package/src/ai.mjs +30 -0
  43. package/src/app-create.mjs +169 -0
  44. package/src/app-filing.mjs +139 -0
  45. package/src/app-git-commits.mjs +48 -0
  46. package/src/app-git.mjs +180 -0
  47. package/src/app-library.mjs +152 -0
  48. package/src/app-lifecycle-worker.mjs +12 -0
  49. package/src/app-lifecycle.mjs +79 -0
  50. package/src/app-records.mjs +134 -0
  51. package/src/app-search.mjs +80 -0
  52. package/src/app-semantic-search.mjs +105 -0
  53. package/src/app-service.mjs +208 -0
  54. package/src/app-yardstick.mjs +269 -0
  55. package/src/app.mjs +102 -0
  56. package/src/atomic-mutation.mjs +1736 -0
  57. package/src/baton.mjs +435 -0
  58. package/src/body-link.mjs +48 -0
  59. package/src/bulk-tag.mjs +174 -0
  60. package/src/check-collapse.mjs +75 -0
  61. package/src/claude-commands.mjs +75 -0
  62. package/src/cli-editor.mjs +8 -0
  63. package/src/code-refs.mjs +545 -0
  64. package/src/codex-integration.mjs +81 -0
  65. package/src/color.mjs +14 -0
  66. package/src/commands.mjs +374 -0
  67. package/src/completions.mjs +93 -0
  68. package/src/config-edit.mjs +606 -0
  69. package/src/config.mjs +705 -0
  70. package/src/decision-action.mjs +58 -0
  71. package/src/decision.mjs +220 -0
  72. package/src/decisions.mjs +787 -0
  73. package/src/deps.mjs +317 -0
  74. package/src/desktop-install.mjs +158 -0
  75. package/src/desktop-runtime.mjs +4 -0
  76. package/src/desktop.mjs +25 -0
  77. package/src/diff.mjs +101 -0
  78. package/src/doctor.mjs +591 -0
  79. package/src/durable-rename.mjs +55 -0
  80. package/src/errors-read.mjs +44 -0
  81. package/src/export.mjs +496 -0
  82. package/src/extractors.mjs +101 -0
  83. package/src/filing.mjs +255 -0
  84. package/src/fix-membership.mjs +134 -0
  85. package/src/fix-refs.mjs +174 -0
  86. package/src/flags.mjs +318 -0
  87. package/src/frontmatter-fix.mjs +195 -0
  88. package/src/frontmatter.mjs +252 -0
  89. package/src/git-commit-filters.mjs +32 -0
  90. package/src/git-commit-protocol.mjs +198 -0
  91. package/src/git-commit-signing.mjs +75 -0
  92. package/src/git-commit-store.mjs +26 -0
  93. package/src/git-commit-support.mjs +16 -0
  94. package/src/git-commit-worker.mjs +32 -0
  95. package/src/git.mjs +909 -0
  96. package/src/glossary-check.mjs +35 -0
  97. package/src/glossary.mjs +243 -0
  98. package/src/graph.mjs +298 -0
  99. package/src/guard.mjs +488 -0
  100. package/src/health.mjs +212 -0
  101. package/src/hints.mjs +129 -0
  102. package/src/host-integration.mjs +339 -0
  103. package/src/hub-membership.mjs +284 -0
  104. package/src/hub.mjs +292 -0
  105. package/src/hud.mjs +436 -0
  106. package/src/index-file.mjs +163 -0
  107. package/src/index.mjs +478 -0
  108. package/src/init.mjs +512 -0
  109. package/src/install.mjs +222 -0
  110. package/src/journal-read.mjs +116 -0
  111. package/src/journal.mjs +472 -0
  112. package/src/lifecycle.mjs +1444 -0
  113. package/src/lint.mjs +386 -0
  114. package/src/managed-path.mjs +192 -0
  115. package/src/markdown-code-spans.mjs +58 -0
  116. package/src/migrate-prompts.mjs +171 -0
  117. package/src/migrate-template.mjs +170 -0
  118. package/src/migrate.mjs +90 -0
  119. package/src/misuse-read.mjs +61 -0
  120. package/src/model-request.mjs +33 -0
  121. package/src/model.mjs +523 -0
  122. package/src/modules.mjs +355 -0
  123. package/src/naming.mjs +55 -0
  124. package/src/native-action.mjs +59 -0
  125. package/src/native-record.mjs +244 -0
  126. package/src/new.mjs +1321 -0
  127. package/src/output-identity.mjs +111 -0
  128. package/src/parent-plan.mjs +58 -0
  129. package/src/parse-cache.mjs +99 -0
  130. package/src/path-containment.mjs +15 -0
  131. package/src/pickup-card.mjs +293 -0
  132. package/src/pickup.mjs +671 -0
  133. package/src/prompt.mjs +25 -0
  134. package/src/prompts.mjs +585 -0
  135. package/src/query.mjs +977 -0
  136. package/src/record-action.mjs +11 -0
  137. package/src/record-create.mjs +82 -0
  138. package/src/reference-planner.mjs +388 -0
  139. package/src/reference-values.mjs +11 -0
  140. package/src/rename.mjs +107 -0
  141. package/src/render.mjs +678 -0
  142. package/src/roadmap.mjs +184 -0
  143. package/src/runlist.mjs +944 -0
  144. package/src/section.mjs +225 -0
  145. package/src/semantic-search-client.mjs +58 -0
  146. package/src/ship.mjs +166 -0
  147. package/src/show.mjs +101 -0
  148. package/src/skill-drift.mjs +98 -0
  149. package/src/source-editor.mjs +491 -0
  150. package/src/stages.mjs +73 -0
  151. package/src/state-migration.mjs +123 -0
  152. package/src/stats.mjs +200 -0
  153. package/src/status-metadata.mjs +87 -0
  154. package/src/statuses.mjs +721 -0
  155. package/src/summary.mjs +74 -0
  156. package/src/surfaces.mjs +28 -0
  157. package/src/sync-status.mjs +316 -0
  158. package/src/template-store.mjs +62 -0
  159. package/src/update.mjs +338 -0
  160. package/src/use.mjs +58 -0
  161. package/src/util.mjs +337 -0
  162. package/src/validate.mjs +794 -0
  163. package/src/watch.mjs +56 -0
  164. package/src/xref.mjs +406 -0
  165. package/src/yardstick-command.mjs +178 -0
  166. package/src/yardstick.mjs +112 -0
package/src/hub.mjs ADDED
@@ -0,0 +1,292 @@
1
+ // Hub primitives: what counts as a hub, and how to read a hub's body tables.
2
+ //
3
+ // This module is deliberately a LEAF — it imports nothing from runlist. The hub
4
+ // predicates used to live in `runlist.mjs`, but `runlist.mjs` imports
5
+ // `index.mjs` (for `resolveDocArg`), and the hub-status check has to run FROM
6
+ // `index.mjs`. Keeping the predicates here is what lets both sides share one
7
+ // definition of "hub" instead of re-deriving it — the exact duplication the
8
+ // hub-status-drift plan exists to avoid. `runlist.mjs` re-exports them so every
9
+ // existing importer is unaffected.
10
+
11
+ // A *coordination hub* is a prose-first plan that sits above a cluster of other
12
+ // plans — a "runlist" in the platform sense (master-runlist, ai-runlist, …)
13
+ // rather than a strictly-ordered frontmatter `runlist:` sprint. The signal is
14
+ // already in frontmatter (`execution_mode: coordination`), with the
15
+ // `*-runlist` / `runlist` naming convention as a fallback for the few hubs that
16
+ // predate the field. These plans aren't units of executable work — they're
17
+ // navigation maps — so the triage view tags them and lifts them out of the
18
+ // leaf-plan flow rather than treating them as one more active plan.
19
+ export function isCoordinationHub(doc) {
20
+ if (!doc) return false;
21
+ if (doc.type && doc.type !== 'plan') return false;
22
+ // Broad "held-out navigational hub" predicate: both coordination hubs and the
23
+ // tier-3 roadmap (`execution_mode: roadmap`) are lifted out of the active count
24
+ // and into a hub section. `isRoadmapHub` is the finer split the tier-3 views
25
+ // use to promote a roadmap above the Runlists section; here a roadmap counts as
26
+ // a coordination hub so all the existing held-out plumbing covers it for free.
27
+ if (doc.executionMode === 'coordination' || doc.executionMode === 'roadmap') return true;
28
+ const base = (doc.path.split('/').pop() || '').replace(/\.md$/, '');
29
+ return base === 'runlist' || base.endsWith('-runlist');
30
+ }
31
+
32
+ // A *roadmap* is the tier-3 hub: a coordination hub whose children are themselves
33
+ // hubs (runlists / coordination hubs), with progress rolled up across them. The
34
+ // signal is explicit — `execution_mode: roadmap` — with NO slug-convention
35
+ // fallback (unlike coordination hubs' `*-runlist`): there's no naming convention
36
+ // for roadmaps, and `runlist check` nudges the structural case (a coordination hub
37
+ // that points at other hubs) toward the explicit field rather than auto-promoting.
38
+ export function isRoadmapHub(doc) {
39
+ if (!doc) return false;
40
+ if (doc.type && doc.type !== 'plan') return false;
41
+ return doc.executionMode === 'roadmap';
42
+ }
43
+
44
+ // A *sprint hub* declares its ordered children in frontmatter (`runlist:`).
45
+ export function isSprintHub(doc) {
46
+ return (doc?.refFields?.runlist ?? []).length > 0;
47
+ }
48
+
49
+ // Every shape of hub: a frontmatter sprint, a coordination map, or a roadmap.
50
+ // This is the set whose body tables the hub-status guard reads.
51
+ export function isHubDoc(doc) {
52
+ return isSprintHub(doc) || isCoordinationHub(doc);
53
+ }
54
+
55
+ // The row→target anchor: the first `.md` link in a table row is the plan that
56
+ // row is ABOUT. Shared by next-pickup detection (`detectBodyRunlistRefs`) and
57
+ // the hub-status guard, so the two can never disagree about which plan a given
58
+ // row names.
59
+ const ROW_LINK_RE = /\[[^\]]+\]\(([^)]+\.md(?:#[^)]+)?)\)/;
60
+
61
+ export function firstRowLink(line) {
62
+ const match = ROW_LINK_RE.exec(line);
63
+ return match ? match[1] : null;
64
+ }
65
+
66
+ // ─── Reading the status word out of a hub row ──────────────────────────────
67
+ //
68
+ // A hub row prints its child's status by hand. Two ways to find that word:
69
+ //
70
+ // position — strip HTML comments, take the cell's LEADING token, match it
71
+ // against the vocabulary the child's own type declares. No marker
72
+ // needed, so this works on tables that already exist. Measured on
73
+ // 472 real rows: agrees with an explicitly marked span 96% of the
74
+ // time and picks a wrong token zero times. Every miss leaves a
75
+ // leading word outside the vocabulary, so it declines (and the row
76
+ // is reported as unreadable) rather than rewriting confidently.
77
+ // marker — `<!--s-->active<!--/s-->` pins the span when position can't find
78
+ // it (the measured 4%: a status sitting behind a bolded headline).
79
+ //
80
+ // The marker is 19 characters, shorter than runlist's block grammar
81
+ // (`<!-- GENERATED:runlist:start -->`). That difference only matters because this
82
+ // one recurs hundreds of times per estate. It makes four comment grammars in
83
+ // runlist; the other three are block-level and correct at block level. Do NOT
84
+ // "unify" them, and do not let the count grow again.
85
+ export const MARKER_OPEN = '<!--s-->';
86
+ export const MARKER_CLOSE = '<!--/s-->';
87
+
88
+ const MARKED_SPAN_RE = /<!--\s*s\s*-->([\s\S]*?)<!--\s*\/s\s*-->/;
89
+ const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
90
+ const FENCE_RE = /^\s{0,3}(`{3,}|~{3,})/;
91
+ // Statuses are hyphenated single words (`in-session`, `queued-after`). The
92
+ // optional emphasis prefix lets `**active**` read as a leading token while
93
+ // `**Phase 1 gate** — active` still declines: the leading word there is `Phase`.
94
+ const LEADING_TOKEN_RE = /^(\s*)(\*\*|__|\*|_|`)?([A-Za-z][A-Za-z0-9-]*)/;
95
+
96
+ // Split a markdown table row into cells, keeping each cell's offsets into the
97
+ // line so a fix can rewrite one word without touching the rest of the row.
98
+ // Honors `\|` escapes and pipes inside inline code.
99
+ export function splitRowCells(line) {
100
+ const cells = [];
101
+ let start = 0;
102
+ let inCode = false;
103
+ for (let i = 0; i < line.length; i++) {
104
+ const ch = line[i];
105
+ if (ch === '\\') { i++; continue; }
106
+ if (ch === '`') { inCode = !inCode; continue; }
107
+ if (ch === '|' && !inCode) {
108
+ cells.push({ raw: line.slice(start, i), start, end: i });
109
+ start = i + 1;
110
+ }
111
+ }
112
+ cells.push({ raw: line.slice(start), start, end: line.length });
113
+ // Rows are conventionally pipe-delimited on both ends; drop the empty edge
114
+ // segments those produce so column indexes line up with the header's.
115
+ if (cells.length && cells[0].raw.trim() === '') cells.shift();
116
+ if (cells.length && cells[cells.length - 1].raw.trim() === '') cells.pop();
117
+ return cells;
118
+ }
119
+
120
+ function isDelimiterRow(cells) {
121
+ return cells.length > 0 && cells.every(cell => /^\s*:?-+:?\s*$/.test(cell.raw));
122
+ }
123
+
124
+ // A table declares a status column when a header cell IS "status"/"state".
125
+ // Deliberately exact (after stripping emphasis): a header that only mentions
126
+ // status in passing is not a promise that the column holds one.
127
+ function isStatusHeader(raw) {
128
+ const normalized = raw.replace(/[*_`]/g, '').trim().toLowerCase();
129
+ return normalized === 'status' || normalized === 'state';
130
+ }
131
+
132
+ export function findMarkedSpan(line) {
133
+ const match = MARKED_SPAN_RE.exec(line);
134
+ if (!match) return null;
135
+ const innerStart = match.index + match[0].indexOf('-->') + 3;
136
+ const inner = match[1];
137
+ const lead = inner.length - inner.trimStart().length;
138
+ const text = inner.trim();
139
+ if (!text) return null;
140
+ return { start: innerStart + lead, end: innerStart + lead + text.length, text, marked: true };
141
+ }
142
+
143
+ // Positional read of a cell: leading token, comments stripped, vocabulary-gated.
144
+ // `isKnownStatus` is the caller's vocabulary test — it depends on the CHILD's
145
+ // type, which only the caller can resolve, so it is injected rather than
146
+ // guessed here. Returns line-absolute offsets, like `findMarkedSpan`.
147
+ export function readPositionalToken(cell, isKnownStatus) {
148
+ if (!cell) return null;
149
+ // Blank out comments instead of deleting them so offsets stay line-absolute.
150
+ const masked = cell.raw.replace(HTML_COMMENT_RE, comment => ' '.repeat(comment.length));
151
+ const match = LEADING_TOKEN_RE.exec(masked);
152
+ if (!match) return null;
153
+ const word = match[3];
154
+ if (!isKnownStatus(word)) return null;
155
+ const start = cell.start + match[1].length + (match[2]?.length ?? 0);
156
+ return { start, end: start + word.length, text: word, marked: false };
157
+ }
158
+
159
+ // Walk a hub body's markdown tables and return one entry per row that names a
160
+ // child (`.md` link). Pure: no vocabulary, no resolution, no IO — the caller
161
+ // resolves the target and then reads the status word, because the vocabulary
162
+ // depends on the child's own type.
163
+ //
164
+ // Returns [{ lineIndex, ref, hasStatusColumn, statusCell, marked }] where
165
+ // `lineIndex` is 0-based into `body.split('\n')` and `statusCell`/`marked`
166
+ // carry line-absolute offsets.
167
+ export function scanHubStatusRows(body) {
168
+ if (!body) return [];
169
+ const lines = body.split('\n');
170
+ const rows = [];
171
+ let fence = null;
172
+ let table = null;
173
+
174
+ for (let i = 0; i < lines.length; i++) {
175
+ const line = lines[i];
176
+ const fenceMatch = FENCE_RE.exec(line);
177
+ if (fence) {
178
+ if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) fence = null;
179
+ continue;
180
+ }
181
+ // Fenced examples routinely contain sample hub rows (this plan's own body
182
+ // does). Reading them would report drift against a fictional child.
183
+ if (fenceMatch) { fence = fenceMatch[1]; table = null; continue; }
184
+
185
+ if (!line.trim().startsWith('|')) { table = null; continue; }
186
+ const cells = splitRowCells(line);
187
+
188
+ if (!table) {
189
+ // A pipe line is a table header only when a delimiter row follows it.
190
+ const next = i + 1 < lines.length ? lines[i + 1] : '';
191
+ if (next.trim().startsWith('|') && isDelimiterRow(splitRowCells(next))) {
192
+ table = { statusColumn: cells.findIndex(cell => isStatusHeader(cell.raw)) };
193
+ }
194
+ continue;
195
+ }
196
+ if (isDelimiterRow(cells)) continue;
197
+
198
+ const ref = firstRowLink(line);
199
+ if (!ref) continue;
200
+ rows.push({
201
+ lineIndex: i,
202
+ ref,
203
+ hasStatusColumn: table.statusColumn >= 0,
204
+ statusCell: table.statusColumn >= 0 ? cells[table.statusColumn] ?? null : null,
205
+ marked: findMarkedSpan(line),
206
+ });
207
+ }
208
+ return rows;
209
+ }
210
+
211
+ // Write `replacement` in the case style the author used, so a fix never
212
+ // restyles a table (`Active` stays capitalized, `ACTIVE` stays shouted).
213
+ export function applyStatusCase(sample, replacement) {
214
+ if (/[A-Z]/.test(sample) && sample === sample.toUpperCase()) return replacement.toUpperCase();
215
+ if (/^[A-Z]/.test(sample)) return replacement.charAt(0).toUpperCase() + replacement.slice(1);
216
+ return replacement;
217
+ }
218
+
219
+ // Extract ordered plan refs from a hub's body prose. Two shapes:
220
+ // - link-list sections (`## Order of operations`, `## Runlist`, …) — every
221
+ // `.md` link or checklist item, in document order.
222
+ // - ranked-queue tables (`## Ranked queue`, …) — the first `.md` link in each
223
+ // table row (the ranked plan); header/separator rows contribute none.
224
+ // Coordination hubs encode their next-pickup order in the table shape; sprint-
225
+ // ish hubs use the link list. Deduped, first occurrence wins, order preserved.
226
+ //
227
+ // This is a hub's BODY MEMBERSHIP claim, not merely a set of links: it is the
228
+ // order `runlist runlist <hub>` / `runlist next <hub>` walk, so a plan listed here
229
+ // is one this hub would hand a session. The membership guard leans on exactly
230
+ // that — a plan in some other table is a pointer, a plan in this order is a claim.
231
+ export function detectBodyRunlistRefs(body) {
232
+ if (!body) return [];
233
+ const refs = [];
234
+ const linkRe = /\[[^\]]+\]\(([^)]+\.md(?:#[^)]+)?)\)/;
235
+ const sliceSection = (start) => {
236
+ const rest = body.slice(start);
237
+ const next = rest.search(/^##\s+/m);
238
+ return next >= 0 ? rest.slice(0, next) : rest;
239
+ };
240
+
241
+ const linkSectionRe = /^##\s+(?:Order of operations|Runlist|Execution order|Implementation order|Plan order)\b.*$/gim;
242
+ let match;
243
+ while ((match = linkSectionRe.exec(body)) !== null) {
244
+ const section = sliceSection(match.index + match[0].length);
245
+ // Line by line, because the SHAPE decides what a link means, not the
246
+ // heading's name. A section titled `## Runlist index — by category` matches
247
+ // this heading list but is written as tables, and taking every link in it
248
+ // made each row's descriptive prose a membership claim: a row about plan A
249
+ // whose prose says "spawned children B, C" ranked B and C as the hub's own,
250
+ // then warned B and C for not naming the hub as parent. In a table, only the
251
+ // row's first link is the ranked plan — the same rule the ranked-queue
252
+ // branch below already applies.
253
+ const allLinks = new RegExp(linkRe.source, 'g');
254
+ for (const rawLine of section.split('\n')) {
255
+ const line = rawLine.trim();
256
+ if (line.startsWith('|')) {
257
+ const link = firstRowLink(line);
258
+ if (link) refs.push(link);
259
+ continue;
260
+ }
261
+ // One ranked item per line, first link wins — the same rule as a table
262
+ // row. A ranked item routinely carries commentary that links elsewhere
263
+ // ("Phase 7's open item closes on a route decision in [other-plan.md]"),
264
+ // and counting those made the hub claim plans it was only citing. A hub
265
+ // that means to rank two plans lists them on two lines.
266
+ allLinks.lastIndex = 0;
267
+ const link = allLinks.exec(line);
268
+ if (link) refs.push(link[1]);
269
+ }
270
+
271
+ const checklistRe = /^\s*[-*]\s+\[[ xX]\]\s+([^\s)]+\.md(?:#[^\s)]+)?)/gm;
272
+ let item;
273
+ while ((item = checklistRe.exec(section)) !== null) refs.push(item[1]);
274
+ }
275
+
276
+ // Ranked-queue tables: the first `.md` link per row is the ranked plan. A
277
+ // header (`| Rank | Plan | … |`) and separator (`|---|`) carry no link and are
278
+ // skipped naturally. Heading may carry trailing text (`## Ranked queue (next
279
+ // pickup)`), so match the leading words, not an exact line.
280
+ const queueSectionRe = /^##\s+(?:Ranked queue|Queue|Pickup order|Heads)\b.*$/gim;
281
+ while ((match = queueSectionRe.exec(body)) !== null) {
282
+ const section = sliceSection(match.index + match[0].length);
283
+ for (const rawLine of section.split('\n')) {
284
+ const line = rawLine.trim();
285
+ if (!line.startsWith('|')) continue;
286
+ const link = firstRowLink(line);
287
+ if (link) refs.push(link);
288
+ }
289
+ }
290
+
291
+ return [...new Set(refs)];
292
+ }