@design-ai/cli 4.56.0 → 4.58.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.
Files changed (271) hide show
  1. package/.claude-plugin/plugin.json +109 -25
  2. package/CHANGELOG.md +68 -0
  3. package/README.ko.md +11 -28
  4. package/README.md +12 -25
  5. package/cli/bin/design-ai.mjs +1 -0
  6. package/cli/commands/help.mjs +7 -4
  7. package/cli/commands/index.mjs +292 -0
  8. package/cli/commands/learn-help.mjs +149 -0
  9. package/cli/commands/learn-print-profile.mjs +373 -0
  10. package/cli/commands/learn-print-restore.mjs +349 -0
  11. package/cli/commands/learn-print-signals.mjs +453 -0
  12. package/cli/commands/learn.mjs +60 -1281
  13. package/cli/commands/pack.mjs +7 -3
  14. package/cli/commands/prompt.mjs +7 -3
  15. package/cli/commands/route.mjs +13 -1
  16. package/cli/commands/search.mjs +94 -1
  17. package/cli/lib/dispatch.mjs +3 -0
  18. package/cli/lib/embedding-index.mjs +199 -0
  19. package/cli/lib/embedding-provider.mjs +121 -0
  20. package/cli/lib/embedding-rerank.mjs +52 -0
  21. package/cli/lib/learn-args.mjs +490 -0
  22. package/cli/lib/learn-backup.mjs +748 -0
  23. package/cli/lib/learn-curation.mjs +612 -0
  24. package/cli/lib/learn-eval.mjs +459 -0
  25. package/cli/lib/learn-profile.mjs +763 -0
  26. package/cli/lib/learn-select.mjs +223 -0
  27. package/cli/lib/learn-shared.mjs +158 -0
  28. package/cli/lib/learn-test-support.mjs +218 -0
  29. package/cli/lib/learn-usage.mjs +360 -0
  30. package/cli/lib/learn.mjs +83 -3694
  31. package/cli/lib/lexical.mjs +137 -0
  32. package/cli/lib/local-config.mjs +110 -0
  33. package/cli/lib/mcp-server.mjs +13 -3
  34. package/cli/lib/pack.mjs +31 -4
  35. package/cli/lib/prompt.mjs +42 -4
  36. package/cli/lib/recall.mjs +193 -0
  37. package/cli/lib/retrieval-index.mjs +0 -0
  38. package/cli/lib/route.mjs +62 -5
  39. package/cli/lib/search-ranked.mjs +202 -0
  40. package/cli/lib/search.mjs +11 -1
  41. package/cli/lib/signals-backlog-commands.mjs +673 -0
  42. package/cli/lib/signals-backlog.mjs +361 -0
  43. package/cli/lib/signals-eval.mjs +176 -0
  44. package/cli/lib/signals-readiness.mjs +247 -0
  45. package/cli/lib/signals-registry.mjs +377 -0
  46. package/cli/lib/signals-render.mjs +478 -0
  47. package/cli/lib/signals-shared.mjs +75 -0
  48. package/cli/lib/signals.mjs +16 -2337
  49. package/cli/lib/site-bundle-handoff-expected.mjs +173 -0
  50. package/cli/lib/site-bundle-handoff-runbook-action-summary.mjs +332 -0
  51. package/cli/lib/site-bundle-handoff-runbook-human-lines.mjs +167 -0
  52. package/cli/lib/site-bundle-handoff-runbook-maps.mjs +238 -0
  53. package/cli/lib/site-bundle-handoff-runbook-next-step.mjs +278 -0
  54. package/cli/lib/site-bundle-handoff-runbook.mjs +44 -931
  55. package/cli/lib/site-test-support.mjs +68 -0
  56. package/cli/lib/skill-proposals-apply-commands.mjs +135 -0
  57. package/cli/lib/skill-proposals-apply-contract.mjs +750 -0
  58. package/cli/lib/skill-proposals-apply-plan.mjs +118 -0
  59. package/cli/lib/skill-proposals-generate.mjs +298 -0
  60. package/cli/lib/skill-proposals-render.mjs +532 -0
  61. package/cli/lib/skill-proposals-review.mjs +262 -0
  62. package/cli/lib/skill-proposals.mjs +15 -2046
  63. package/cli/lib/workspace-args.mjs +83 -0
  64. package/cli/lib/workspace-git.mjs +169 -0
  65. package/cli/lib/workspace-learning.mjs +483 -0
  66. package/cli/lib/workspace-repo.mjs +139 -0
  67. package/cli/lib/workspace-report.mjs +283 -0
  68. package/cli/lib/workspace-test-support.mjs +99 -0
  69. package/cli/lib/workspace.mjs +30 -1056
  70. package/docs/AI-LEARNING-PHASE2.md +215 -0
  71. package/docs/DISTRIBUTION.ko.md +2 -2
  72. package/docs/DISTRIBUTION.md +2 -2
  73. package/docs/NEXT-SURFACE-DECISION.md +125 -0
  74. package/docs/PRODUCT-READINESS.md +3 -3
  75. package/docs/RELEASE-GATES.ko.md +35 -0
  76. package/docs/RELEASE-GATES.md +234 -0
  77. package/docs/ROADMAP.md +82 -8
  78. package/docs/external-status.md +9 -11
  79. package/docs/inspection-20260630.md +169 -0
  80. package/docs/integrations/design-ai-mcp-server.md +4 -4
  81. package/docs/reference/ant-design.md +413 -0
  82. package/docs/reference/awesome-design-md.md +439 -0
  83. package/docs/reference/mui.md +783 -0
  84. package/docs/reference/shadcn-ui.md +298 -0
  85. package/examples/component-accordion-actions.md +5 -5
  86. package/examples/component-accordion-details.md +5 -5
  87. package/examples/component-accordion-summary.md +5 -5
  88. package/examples/component-accordion.md +3 -3
  89. package/examples/component-affix.md +1 -1
  90. package/examples/component-alert-dialog.md +1 -1
  91. package/examples/component-alert-title.md +1 -1
  92. package/examples/component-alert.md +3 -3
  93. package/examples/component-anchor.md +1 -1
  94. package/examples/component-app-bar.md +1 -1
  95. package/examples/component-aspect-ratio.md +1 -1
  96. package/examples/component-auto-complete.md +2 -2
  97. package/examples/component-avatar-group.md +5 -5
  98. package/examples/component-avatar.md +3 -3
  99. package/examples/component-back-top.md +1 -1
  100. package/examples/component-backdrop.md +1 -1
  101. package/examples/component-badge.md +3 -3
  102. package/examples/component-border-beam.md +3 -3
  103. package/examples/component-bottom-navigation.md +1 -1
  104. package/examples/component-box.md +1 -1
  105. package/examples/component-breadcrumb.md +3 -3
  106. package/examples/component-button-base.md +3 -3
  107. package/examples/component-button-group.md +2 -2
  108. package/examples/component-button.md +3 -3
  109. package/examples/component-calendar.md +2 -2
  110. package/examples/component-card-actions.md +1 -1
  111. package/examples/component-card-content.md +1 -1
  112. package/examples/component-card-header.md +1 -1
  113. package/examples/component-card-media.md +1 -1
  114. package/examples/component-card.md +3 -3
  115. package/examples/component-carousel.md +2 -2
  116. package/examples/component-cascader.md +1 -1
  117. package/examples/component-chart.md +1 -1
  118. package/examples/component-checkbox.md +3 -3
  119. package/examples/component-click-away-listener.md +1 -1
  120. package/examples/component-code.md +1 -1
  121. package/examples/component-collapsible.md +1 -1
  122. package/examples/component-color-picker.md +1 -1
  123. package/examples/component-combobox.md +1 -1
  124. package/examples/component-command.md +1 -1
  125. package/examples/component-config-provider.md +3 -3
  126. package/examples/component-context-menu.md +1 -1
  127. package/examples/component-css-baseline.md +3 -3
  128. package/examples/component-date-picker.md +2 -2
  129. package/examples/component-descriptions.md +1 -1
  130. package/examples/component-dialog-actions.md +1 -1
  131. package/examples/component-dialog-content-text.md +1 -1
  132. package/examples/component-dialog-content.md +1 -1
  133. package/examples/component-dialog-title.md +1 -1
  134. package/examples/component-dialog.md +2 -2
  135. package/examples/component-divider.md +3 -3
  136. package/examples/component-drawer.md +3 -3
  137. package/examples/component-dropdown.md +3 -3
  138. package/examples/component-empty.md +2 -2
  139. package/examples/component-fade.md +1 -1
  140. package/examples/component-field.md +1 -1
  141. package/examples/component-filled-input.md +1 -1
  142. package/examples/component-flex.md +1 -1
  143. package/examples/component-float-button.md +2 -2
  144. package/examples/component-form-control-label.md +1 -1
  145. package/examples/component-form-control.md +1 -1
  146. package/examples/component-form-controls.md +1 -1
  147. package/examples/component-form-group.md +1 -1
  148. package/examples/component-form-helper-text.md +1 -1
  149. package/examples/component-form-label.md +1 -1
  150. package/examples/component-form.md +2 -2
  151. package/examples/component-grid.md +2 -2
  152. package/examples/component-grow.md +1 -1
  153. package/examples/component-hover-card.md +1 -1
  154. package/examples/component-icon-button.md +1 -1
  155. package/examples/component-icon.md +1 -1
  156. package/examples/component-image-list.md +1 -1
  157. package/examples/component-image.md +1 -1
  158. package/examples/component-input-adornment.md +1 -1
  159. package/examples/component-input-base.md +1 -1
  160. package/examples/component-input-number.md +1 -1
  161. package/examples/component-input-otp.md +2 -2
  162. package/examples/component-input.md +3 -3
  163. package/examples/component-item.md +1 -1
  164. package/examples/component-label.md +1 -1
  165. package/examples/component-layout.md +1 -1
  166. package/examples/component-link.md +1 -1
  167. package/examples/component-list-item-avatar.md +1 -1
  168. package/examples/component-list-item-button.md +1 -1
  169. package/examples/component-list-item-icon.md +1 -1
  170. package/examples/component-list-item-text.md +1 -1
  171. package/examples/component-list-item.md +1 -1
  172. package/examples/component-list-subheader.md +1 -1
  173. package/examples/component-list.md +2 -2
  174. package/examples/component-masonry.md +1 -1
  175. package/examples/component-mentions.md +1 -1
  176. package/examples/component-menu-item.md +1 -1
  177. package/examples/component-menu-list.md +1 -1
  178. package/examples/component-menu.md +1 -1
  179. package/examples/component-menubar.md +1 -1
  180. package/examples/component-message.md +1 -1
  181. package/examples/component-mobile-stepper.md +1 -1
  182. package/examples/component-modal.md +5 -5
  183. package/examples/component-navigation-menu.md +1 -1
  184. package/examples/component-notification.md +1 -1
  185. package/examples/component-outlined-input.md +1 -1
  186. package/examples/component-pagination.md +3 -3
  187. package/examples/component-paper.md +1 -1
  188. package/examples/component-popconfirm.md +1 -1
  189. package/examples/component-popover.md +3 -3
  190. package/examples/component-popper.md +1 -1
  191. package/examples/component-progress.md +2 -2
  192. package/examples/component-qr-code.md +1 -1
  193. package/examples/component-radio.md +3 -3
  194. package/examples/component-rate.md +2 -2
  195. package/examples/component-resizable.md +1 -1
  196. package/examples/component-result.md +1 -1
  197. package/examples/component-scroll-area.md +1 -1
  198. package/examples/component-segmented.md +3 -3
  199. package/examples/component-select.md +3 -3
  200. package/examples/component-separator.md +1 -1
  201. package/examples/component-sheet.md +1 -1
  202. package/examples/component-sidebar.md +1 -1
  203. package/examples/component-skeleton.md +3 -3
  204. package/examples/component-slide.md +1 -1
  205. package/examples/component-slider.md +3 -3
  206. package/examples/component-snackbar-content.md +1 -1
  207. package/examples/component-snackbar.md +1 -1
  208. package/examples/component-sonner.md +1 -1
  209. package/examples/component-space.md +1 -1
  210. package/examples/component-speed-dial-action.md +1 -1
  211. package/examples/component-speed-dial.md +1 -1
  212. package/examples/component-spin.md +2 -2
  213. package/examples/component-spinner.md +1 -1
  214. package/examples/component-splitter.md +2 -2
  215. package/examples/component-stack.md +1 -1
  216. package/examples/component-statistic.md +1 -1
  217. package/examples/component-step-button.md +4 -4
  218. package/examples/component-step-connector.md +4 -4
  219. package/examples/component-step-content.md +1 -1
  220. package/examples/component-step-icon.md +1 -1
  221. package/examples/component-step-label.md +1 -1
  222. package/examples/component-step.md +2 -2
  223. package/examples/component-steps.md +2 -2
  224. package/examples/component-swipeable-drawer.md +1 -1
  225. package/examples/component-switch.md +3 -3
  226. package/examples/component-tab-scroll-button.md +5 -5
  227. package/examples/component-tab.md +1 -1
  228. package/examples/component-table-body.md +1 -1
  229. package/examples/component-table-cell.md +1 -1
  230. package/examples/component-table-container.md +1 -1
  231. package/examples/component-table-footer.md +1 -1
  232. package/examples/component-table-head.md +1 -1
  233. package/examples/component-table-pagination.md +1 -1
  234. package/examples/component-table-row.md +1 -1
  235. package/examples/component-table-sort-label.md +1 -1
  236. package/examples/component-table.md +2 -2
  237. package/examples/component-tabs.md +3 -3
  238. package/examples/component-tag-badge.md +2 -2
  239. package/examples/component-tag.md +1 -1
  240. package/examples/component-textarea-autosize.md +1 -1
  241. package/examples/component-textarea.md +1 -1
  242. package/examples/component-time-picker.md +1 -1
  243. package/examples/component-timeline.md +1 -1
  244. package/examples/component-toast.md +4 -4
  245. package/examples/component-toggle-button.md +1 -1
  246. package/examples/component-toggle.md +1 -1
  247. package/examples/component-toolbar.md +1 -1
  248. package/examples/component-tooltip.md +3 -3
  249. package/examples/component-tour.md +1 -1
  250. package/examples/component-transfer.md +1 -1
  251. package/examples/component-tree-select.md +1 -1
  252. package/examples/component-tree.md +1 -1
  253. package/examples/component-typography.md +2 -2
  254. package/examples/component-upload.md +1 -1
  255. package/examples/component-watermark.md +1 -1
  256. package/examples/component-zoom.md +1 -1
  257. package/knowledge/COVERAGE.md +12 -9
  258. package/knowledge/components/INDEX.md +202 -202
  259. package/knowledge/i18n/korean-density-conventions.md +108 -0
  260. package/knowledge/i18n/korean-product-conventions.md +2 -0
  261. package/knowledge/patterns/async-control.md +233 -0
  262. package/knowledge/patterns/brand-references.md +72 -72
  263. package/knowledge/patterns/error-states.md +1 -0
  264. package/knowledge/patterns/form-design.md +2 -0
  265. package/package.json +1 -1
  266. package/tools/audit/local-ci.py +16 -1
  267. package/tools/audit/package-smoke.py +611 -9
  268. package/tools/audit/registry-smoke.py +537 -3
  269. package/tools/audit/release-metadata.py +47 -1
  270. package/tools/audit/smoke_assertions.py +918 -13
  271. package/tools/migrations/refs-links-to-reference-pages.py +179 -0
@@ -0,0 +1,193 @@
1
+ // Corpus knowledge recall (retrieval-augmentation) for `design-ai prompt`/`pack`
2
+ // --with-recall. Injects the most brief-relevant CORPUS KNOWLEDGE FILES, ranked by
3
+ // the SAME shipped deterministic lexical (BM25-style) scorer that powers
4
+ // `design-ai search --ranked` and learning selection (docs/AI-LEARNING-PHASE2.md,
5
+ // Phase A). Sibling of learning selection: learning recalls LOCAL preferences,
6
+ // recall recalls SHIPPED corpus knowledge. Zero-dependency and deterministic.
7
+ //
8
+ // NO embeddings in this path: the pack/prompt recall stays lexical-only. Embedding
9
+ // rerank remains a search-only opt-in for now (docs/AI-LEARNING-PHASE2.md, Phase B);
10
+ // there is deliberately no --embeddings recall variant.
11
+
12
+ import { readFileSync } from "node:fs";
13
+ import path from "node:path";
14
+
15
+ import { rankedSearchCorpus, rankedPreview } from "./search-ranked.mjs";
16
+ import { collectCorpusDocuments } from "./retrieval-index.mjs";
17
+ import { loadLearningProfile } from "./learn-profile.mjs";
18
+ import { selectLearningEntrySet } from "./learn-select.mjs";
19
+ import { defaultLearningFile } from "./learn-shared.mjs";
20
+ import { DESIGN_AI_HOME } from "./paths.mjs";
21
+ import { DEFAULT_SEARCH_DIRS } from "./search.mjs";
22
+
23
+ export const DEFAULT_RECALL_LIMIT = 5;
24
+
25
+ // Mirrors parseLearningLimit but bounded 1-20 (recall injects whole corpus files, so a
26
+ // tighter cap than learning's 1-100 keeps the recall section budget-friendly).
27
+ export function parseRecallLimit(rawLimit) {
28
+ const limit = Number(rawLimit);
29
+ if (!Number.isInteger(limit) || limit < 1 || limit > 20) {
30
+ throw new Error("--recall-limit expects an integer from 1 to 20");
31
+ }
32
+ return limit;
33
+ }
34
+
35
+ // Cap each recalled file's rendered block so the total recall section stays bounded.
36
+ // The pack's takeUtf8(maxBytes) truncation still applies to the WHOLE pack as-is, so
37
+ // recall respects the byte budget by being subject to the same cap; this per-file cap
38
+ // keeps any single file from crowding out the rest of the recall section.
39
+ const RECALL_BLOCK_CHAR_CAP = 400;
40
+
41
+ // First Markdown `# ` heading, else the first non-empty line — a short, deterministic
42
+ // citation label for the recalled file.
43
+ function firstHeadingOrLine(text) {
44
+ const lines = String(text || "").split("\n");
45
+ for (const line of lines) {
46
+ const trimmed = line.trim();
47
+ if (trimmed.startsWith("# ")) return trimmed.slice(2).trim();
48
+ }
49
+ for (const line of lines) {
50
+ const trimmed = line.trim();
51
+ if (trimmed.length > 0) return trimmed;
52
+ }
53
+ return "";
54
+ }
55
+
56
+ function readCorpusText(designAiPath, relPath) {
57
+ try {
58
+ return readFileSync(path.join(designAiPath, relPath), "utf8");
59
+ } catch {
60
+ return "";
61
+ }
62
+ }
63
+
64
+ // Bounded per-file block: relPath citation + heading/first line + matched excerpt.
65
+ // Capped to ~RECALL_BLOCK_CHAR_CAP chars so no single file dominates the section.
66
+ function renderRecallBlock({ id, text, matchedTokens }) {
67
+ const heading = firstHeadingOrLine(text);
68
+ const excerpt = rankedPreview(text, matchedTokens);
69
+ const parts = [`- ${id}`];
70
+ if (heading) parts.push(` - ${heading}`);
71
+ if (excerpt) parts.push(` - ${excerpt}`);
72
+ const block = parts.join("\n");
73
+ return block.length <= RECALL_BLOCK_CHAR_CAP
74
+ ? block
75
+ : `${block.slice(0, RECALL_BLOCK_CHAR_CAP)}...`;
76
+ }
77
+
78
+ function renderRecallMarkdown(selected, textById) {
79
+ const lines = ["## Recalled design knowledge", ""];
80
+ if (selected.length === 0) {
81
+ lines.push("No corpus knowledge files matched this brief.");
82
+ return lines.join("\n");
83
+ }
84
+
85
+ lines.push("Cite these shipped corpus knowledge files when they inform a design decision.");
86
+ lines.push("");
87
+ for (const item of selected) {
88
+ lines.push(renderRecallBlock({
89
+ id: item.id,
90
+ text: textById.get(item.id) || "",
91
+ matchedTokens: item.matchedTokens,
92
+ }));
93
+ }
94
+ return lines.join("\n");
95
+ }
96
+
97
+ // Returns a recall context mirroring learning's selection-item shape (id = corpus
98
+ // relPath). REUSES rankedSearchCorpus for scoring — no reimplemented ranking. The
99
+ // ranker already orders fully by score desc, id asc, so the result is deterministic.
100
+ // Empty brief or no lexical hits -> selectedCount 0 and a "no corpus knowledge"
101
+ // markdown line.
102
+ export function buildRecallContext({
103
+ brief = "",
104
+ recallLimit = DEFAULT_RECALL_LIMIT,
105
+ designAiPath = DESIGN_AI_HOME,
106
+ dirs = DEFAULT_SEARCH_DIRS,
107
+ } = {}) {
108
+ const query = String(brief || "").trim();
109
+ const candidateCount = collectCorpusDocuments({ designAiPath, dirs }).length;
110
+
111
+ const hits = query
112
+ ? rankedSearchCorpus({ query, designAiPath, dirs, limit: recallLimit, excludeGeneratedIndex: true }).hits
113
+ : [];
114
+
115
+ const selected = hits.map((hit) => ({
116
+ id: hit.relPath,
117
+ score: hit.score,
118
+ matchedTokens: hit.matchedTokens,
119
+ }));
120
+
121
+ const textById = new Map(
122
+ selected.map((item) => [item.id, readCorpusText(designAiPath, item.id)]),
123
+ );
124
+
125
+ return {
126
+ query,
127
+ mode: "lexical",
128
+ candidateCount,
129
+ selectedCount: selected.length,
130
+ selected,
131
+ markdown: renderRecallMarkdown(selected, textById),
132
+ };
133
+ }
134
+
135
+ // Read-side companion to `pack --with-recall`: a unified "what does design-ai
136
+ // recall for this query" view. Combines (1) the top corpus knowledge files ranked
137
+ // by rankedSearchCorpus with (2) the top local learning-profile entries ranked by
138
+ // selectLearningEntrySet — BOTH using the same shipped deterministic lexical scorer.
139
+ // Read-only: never writes the profile or any file. Empty query -> zero hits on both
140
+ // sides. `limit` applies to BOTH lists; `category` scopes ONLY the learning list.
141
+ export function buildLearnRecall({
142
+ query = "",
143
+ limit = DEFAULT_RECALL_LIMIT,
144
+ category = "",
145
+ designAiPath = DESIGN_AI_HOME,
146
+ learningFilePath = defaultLearningFile(),
147
+ dirs = DEFAULT_SEARCH_DIRS,
148
+ } = {}) {
149
+ const normalizedQuery = String(query || "").trim();
150
+
151
+ const corpusCandidateCount = collectCorpusDocuments({ designAiPath, dirs }).length;
152
+ const corpusHits = normalizedQuery
153
+ ? rankedSearchCorpus({ query: normalizedQuery, designAiPath, dirs, limit, excludeGeneratedIndex: true }).hits
154
+ : [];
155
+ const corpusSelected = corpusHits.map((hit) => ({
156
+ id: hit.relPath,
157
+ score: hit.score,
158
+ matchedTokens: hit.matchedTokens,
159
+ }));
160
+
161
+ const profile = loadLearningProfile(learningFilePath);
162
+ const textById = new Map(profile.entries.map((entry) => [entry.id, entry.text || ""]));
163
+ const { selection } = selectLearningEntrySet(profile, {
164
+ query: normalizedQuery,
165
+ limit,
166
+ category,
167
+ includeFallback: false,
168
+ });
169
+ const learningSelected = normalizedQuery
170
+ ? selection.selected.map((item) => ({
171
+ id: item.id,
172
+ category: item.category,
173
+ score: item.score,
174
+ matchedTokens: item.matchedTokens,
175
+ text: textById.get(item.id) || "",
176
+ }))
177
+ : [];
178
+
179
+ return {
180
+ query: normalizedQuery,
181
+ corpus: {
182
+ candidateCount: corpusCandidateCount,
183
+ selectedCount: corpusSelected.length,
184
+ selected: corpusSelected,
185
+ },
186
+ learning: {
187
+ mode: selection.mode,
188
+ candidateCount: selection.candidateCount,
189
+ selectedCount: learningSelected.length,
190
+ selected: learningSelected,
191
+ },
192
+ };
193
+ }
Binary file
package/cli/lib/route.mjs CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  import path from "node:path";
8
8
 
9
9
  import { parseBriefSourceFlag } from "./brief.mjs";
10
+ import { rankedSearchCorpus } from "./search-ranked.mjs";
10
11
  import { suggestNearest, unknownOptionMessage } from "./suggest.mjs";
11
12
 
12
13
  function exists(p) {
@@ -35,6 +36,17 @@ const ROUTE_OPTIONS = [
35
36
  ];
36
37
  const ROUTE_EVAL_VERSION = 1;
37
38
  const ROUTE_EVAL_DEFAULT_LIMIT = 3;
39
+ // Advisory "Related knowledge" recall (docs/AI-LEARNING-PHASE2.md, "Phase A
40
+ // implementation review" Q3): under `--explain` only, surface the top corpus
41
+ // knowledge/ files recalled by the shipped deterministic lexical scorer that the
42
+ // route's curated `knowledge` list does NOT already point to. Purely additive —
43
+ // it never changes route selection, ids, scores, or the curated list. Restricted
44
+ // to knowledge/ so it surfaces design knowledge, not docs/QUICKSTART. The recall
45
+ // pulls RELATED_KNOWLEDGE_RECALL_LIMIT candidates then keeps the top
46
+ // RELATED_KNOWLEDGE_KEEP after excluding the curated set.
47
+ const RELATED_KNOWLEDGE_DIRS = ["knowledge"];
48
+ const RELATED_KNOWLEDGE_RECALL_LIMIT = 10;
49
+ const RELATED_KNOWLEDGE_KEEP = 3;
38
50
  const CONFIDENCE_ORDER = {
39
51
  low: 1,
40
52
  medium: 2,
@@ -456,6 +468,35 @@ function routeExplanation({ hits, command, skills, agents, knowledge, forced = f
456
468
  };
457
469
  }
458
470
 
471
+ // Advisory related-knowledge recall for a single route. REUSES rankedSearchCorpus
472
+ // (the shipped deterministic lexical scorer — score desc, id asc) scoped to
473
+ // knowledge/, excludes the route's curated knowledge relPaths, and keeps the top
474
+ // RELATED_KNOWLEDGE_KEEP remaining. Returns [] on empty brief or no hits.
475
+ function relatedKnowledgeFor({ brief, sourceRoot, curatedRelPaths }) {
476
+ const query = String(brief || "").trim();
477
+ if (!query) return [];
478
+
479
+ const curated = new Set(curatedRelPaths);
480
+ const { hits } = rankedSearchCorpus({
481
+ query,
482
+ dirs: RELATED_KNOWLEDGE_DIRS,
483
+ limit: RELATED_KNOWLEDGE_RECALL_LIMIT,
484
+ designAiPath: sourceRoot,
485
+ // Recall/injection surface: keep generated index/meta docs (COVERAGE.md,
486
+ // INDEX.md, docs/reference/*) out of the advisory related-knowledge list.
487
+ excludeGeneratedIndex: true,
488
+ });
489
+
490
+ return hits
491
+ .filter((hit) => !curated.has(hit.relPath))
492
+ .slice(0, RELATED_KNOWLEDGE_KEEP)
493
+ .map((hit) => ({
494
+ id: hit.relPath,
495
+ score: hit.score,
496
+ matchedTokens: hit.matchedTokens,
497
+ }));
498
+ }
499
+
459
500
  function routeToResult(route, sourceRoot, hits, options = {}) {
460
501
  const command = route.command
461
502
  ? { path: route.command, exists: exists(path.join(sourceRoot, route.command)) }
@@ -467,6 +508,17 @@ function routeToResult(route, sourceRoot, hits, options = {}) {
467
508
  const fallback = Boolean(options.fallback);
468
509
  const catalog = Boolean(options.catalog);
469
510
 
511
+ // Advisory related-knowledge is attached ONLY when explain is requested, so the
512
+ // default `route` JSON stays byte-unchanged. `knowledge` already merges
513
+ // COMMON_KNOWLEDGE + route.knowledge, so its relPaths are the dedupe set.
514
+ const relatedKnowledge = options.explain
515
+ ? relatedKnowledgeFor({
516
+ brief: options.brief || "",
517
+ sourceRoot,
518
+ curatedRelPaths: knowledge.map((entry) => entry.path),
519
+ })
520
+ : null;
521
+
470
522
  return {
471
523
  id: route.id,
472
524
  label: route.label,
@@ -479,6 +531,7 @@ function routeToResult(route, sourceRoot, hits, options = {}) {
479
531
  knowledge,
480
532
  keywords: route.keywords,
481
533
  explanation: routeExplanation({ hits, command, skills, agents, knowledge, forced, fallback, catalog }),
534
+ ...(relatedKnowledge ? { relatedKnowledge } : {}),
482
535
  ...(forced ? { forced: true } : {}),
483
536
  ...(fallback ? { fallback: true } : {}),
484
537
  };
@@ -501,18 +554,22 @@ export function routeById({ routeId, sourceRoot }) {
501
554
  };
502
555
  }
503
556
 
504
- function fallbackResult(sourceRoot) {
557
+ function fallbackResult(sourceRoot, options = {}) {
505
558
  const route = ROUTES.find((item) => item.id === "design-from-brief");
506
559
  return {
507
- ...routeToResult(route, sourceRoot, [], { fallback: true }),
560
+ ...routeToResult(route, sourceRoot, [], { fallback: true, ...options }),
508
561
  confidence: "low",
509
562
  };
510
563
  }
511
564
 
512
- export function routeBrief({ brief, sourceRoot, limit = 3 }) {
565
+ export function routeBrief({ brief, sourceRoot, limit = 3, explain = false }) {
513
566
  const normalized = brief.trim();
514
567
  if (!normalized) return [];
515
568
 
569
+ // Only compute advisory related-knowledge under --explain; keep the default
570
+ // routing (keyword scoring, ordering, selection) completely unchanged.
571
+ const resultOptions = explain ? { explain: true, brief: normalized } : {};
572
+
516
573
  const scored = ROUTES
517
574
  .map((route) => ({ route, hits: keywordHits(normalized, route.keywords) }))
518
575
  .filter((item) => item.hits.length > 0)
@@ -521,10 +578,10 @@ export function routeBrief({ brief, sourceRoot, limit = 3 }) {
521
578
  return a.route.label.localeCompare(b.route.label);
522
579
  })
523
580
  .slice(0, limit)
524
- .map((item) => routeToResult(item.route, sourceRoot, item.hits));
581
+ .map((item) => routeToResult(item.route, sourceRoot, item.hits, resultOptions));
525
582
 
526
583
  if (scored.length > 0) return scored;
527
- return [fallbackResult(sourceRoot)];
584
+ return [fallbackResult(sourceRoot, resultOptions)];
528
585
  }
529
586
 
530
587
  function isoTimestamp(now = new Date()) {
@@ -0,0 +1,202 @@
1
+ // Ranked (BM25-style) corpus search for `design-ai search --ranked`, plus the
2
+ // opt-in Phase B embedding rerank (docs/AI-LEARNING-PHASE2.md, Phase B).
3
+ // Deterministic: ranks a live corpus scan, then reports retrieval-index staleness
4
+ // instead of ever serving stale index text (docs/AI-LEARNING-PHASE2.md, Phase A).
5
+
6
+ import { readFileSync } from "node:fs";
7
+ import path from "node:path";
8
+
9
+ import { embeddingIndexFile, loadEmbeddingIndexFile, vectorsById as embeddingVectorsById } from "./embedding-index.mjs";
10
+ import { embedDocuments } from "./embedding-provider.mjs";
11
+ import { cosineRerank } from "./embedding-rerank.mjs";
12
+ import { buildLexicalStats, rankLexical } from "./lexical.mjs";
13
+ import { PACKAGE_ROOT } from "./paths.mjs";
14
+ import {
15
+ collectCorpusDocuments,
16
+ computeCorpusDigest,
17
+ computeLearningDigest,
18
+ corpusIndexFile,
19
+ defaultIndexDir,
20
+ loadIndexFile,
21
+ } from "./retrieval-index.mjs";
22
+ import { defaultLearningFile } from "./learn-shared.mjs";
23
+ import { DEFAULT_SEARCH_DIRS } from "./search.mjs";
24
+
25
+ const RANKED_PREVIEW_LEN = 120;
26
+
27
+ // Predicate for GENERATED index/meta docs that must be kept out of the RECALL /
28
+ // context-injection layer (pack/prompt --with-recall, learn --recall corpus side,
29
+ // route --explain related knowledge). These files rank on keyword density but are
30
+ // meta/index tables, not design knowledge worth injecting into an agent's context.
31
+ // Rule: true when the basename is `COVERAGE.md` or `INDEX.md` (generated coverage
32
+ // table / component index), OR the forward-slash-normalized path starts with
33
+ // `docs/reference/` (generated upstream-reference pages). Raw `search --ranked`
34
+ // does NOT apply this — a user explicitly searching may legitimately want an index.
35
+ export function isGeneratedIndexDoc(relPath) {
36
+ const normalized = String(relPath || "").replace(/\\/g, "/");
37
+ const basename = normalized.slice(normalized.lastIndexOf("/") + 1);
38
+ return basename === "COVERAGE.md"
39
+ || basename === "INDEX.md"
40
+ || normalized.startsWith("docs/reference/");
41
+ }
42
+
43
+ // N = max(limit*5, 25): the number of top lexical candidates handed to the embedding
44
+ // reranker. Documented constant (docs/AI-LEARNING-PHASE2.md, Phase B CLI wiring).
45
+ export function embeddingCandidateCount(limit) {
46
+ return Math.max(limit * 5, 25);
47
+ }
48
+
49
+ export function rankedPreview(text, matchedTokens) {
50
+ const lines = text.split("\n");
51
+ for (const token of matchedTokens) {
52
+ for (const line of lines) {
53
+ const trimmed = line.trim();
54
+ if (trimmed && trimmed.toLowerCase().includes(token)) {
55
+ return trimmed.length <= RANKED_PREVIEW_LEN
56
+ ? trimmed
57
+ : `${trimmed.slice(0, RANKED_PREVIEW_LEN)}...`;
58
+ }
59
+ }
60
+ }
61
+ const firstLine = lines.map((line) => line.trim()).find((line) => line.length > 0) || "";
62
+ return firstLine.length <= RANKED_PREVIEW_LEN ? firstLine : `${firstLine.slice(0, RANKED_PREVIEW_LEN)}...`;
63
+ }
64
+
65
+ function corpusIndexNotice({ indexDir, currentDigest }) {
66
+ const loaded = loadIndexFile(corpusIndexFile(indexDir));
67
+ if (!loaded.present) {
68
+ return "no corpus index built yet; ranked results come from a live corpus scan (design-ai index --build)";
69
+ }
70
+ if (loaded.error) {
71
+ return `corpus index is unreadable (${loaded.error}); ranked results come from a live corpus scan (design-ai index --build)`;
72
+ }
73
+ if ((loaded.payload.source?.corpusDigest || "") !== currentDigest) {
74
+ return "corpus index is stale; ranked results come from a live corpus scan (design-ai index --build)";
75
+ }
76
+ return "";
77
+ }
78
+
79
+ export function rankedSearchCorpus({
80
+ query,
81
+ designAiPath = PACKAGE_ROOT,
82
+ dirs = DEFAULT_SEARCH_DIRS,
83
+ limit = 20,
84
+ indexDir = defaultIndexDir(),
85
+ // Opt-in RECALL filter: when true, drop generated index/meta docs
86
+ // (isGeneratedIndexDoc) BEFORE applying `limit`, so the limit fills with real
87
+ // knowledge instead of index files. Default false keeps raw `search --ranked`
88
+ // byte-unchanged. Filtering before the limit preserves determinism (score desc,
89
+ // id asc) — the rank pass already orders fully, we only remove excluded ids.
90
+ excludeGeneratedIndex = false,
91
+ } = {}) {
92
+ const documents = collectCorpusDocuments({ designAiPath, dirs });
93
+ const stats = buildLexicalStats(documents.map(({ id, text }) => ({ id, text })));
94
+ const textById = new Map(documents.map((doc) => [doc.id, doc.text]));
95
+
96
+ // When excluding, rank the FULL candidate pool first (limit = documents.length)
97
+ // so that filtering out index docs cannot let a real-knowledge hit fall off the
98
+ // pre-limit cutoff; then re-apply `limit`. Ordering is unchanged (score desc, id asc).
99
+ const ranked = rankLexical(query, stats, {
100
+ limit: excludeGeneratedIndex ? documents.length : limit,
101
+ });
102
+ const filtered = excludeGeneratedIndex
103
+ ? ranked.filter((hit) => !isGeneratedIndexDoc(hit.id)).slice(0, limit)
104
+ : ranked;
105
+
106
+ const hits = filtered.map((hit) => ({
107
+ relPath: hit.id,
108
+ file: path.join(designAiPath, hit.id),
109
+ score: hit.score,
110
+ matchedTokens: hit.matchedTokens,
111
+ preview: rankedPreview(textById.get(hit.id) || "", hit.matchedTokens),
112
+ }));
113
+
114
+ return {
115
+ hits,
116
+ notice: corpusIndexNotice({ indexDir, currentDigest: computeCorpusDigest(documents) }),
117
+ };
118
+ }
119
+
120
+ function embeddingSidecarFreshness({ indexDir, designAiPath, learningFile, corpusDigest }) {
121
+ const file = embeddingIndexFile(indexDir);
122
+ const loaded = loadEmbeddingIndexFile(file);
123
+ if (!loaded.present) return { ok: false, reason: "no embedding index built yet (design-ai index --build --embeddings)" };
124
+ if (loaded.error) return { ok: false, reason: `embedding index is unreadable (${loaded.error})` };
125
+
126
+ const source = loaded.payload.source || {};
127
+ const sourceMatch =
128
+ source.designAiPath === path.resolve(designAiPath) && source.learningFile === path.resolve(learningFile);
129
+ if (!sourceMatch) {
130
+ return { ok: false, reason: "embedding index was built from a different checkout or learning file" };
131
+ }
132
+
133
+ const { digest: currentLearningDigest } = computeLearningDigest(learningFile);
134
+ const digestMatch = source.corpusDigest === corpusDigest && source.learningDigest === currentLearningDigest;
135
+ if (!digestMatch) {
136
+ return { ok: false, reason: "embedding index is stale (design-ai index --build --embeddings)" };
137
+ }
138
+
139
+ return { ok: true, payload: loaded.payload };
140
+ }
141
+
142
+ // Opt-in Phase B rerank: take the top-N lexical candidates, embed the QUERY via the
143
+ // provider, cosine-rerank against the stored sidecar vectors, return the top `limit`.
144
+ // ANY failure (no provider, no sidecar, stale sidecar, provider error) is reported
145
+ // via `fallback: true` + a specific `notice` — callers must fall back to the lexical
146
+ // path and exit 0; a stale sidecar must never silently serve embedding results.
147
+ export function embeddingRerankSearch({
148
+ query,
149
+ provider,
150
+ designAiPath = PACKAGE_ROOT,
151
+ dirs = DEFAULT_SEARCH_DIRS,
152
+ limit = 20,
153
+ indexDir = defaultIndexDir(),
154
+ learningFile = defaultLearningFile(),
155
+ spawnRunner,
156
+ } = {}) {
157
+ if (!provider || typeof provider.command !== "string" || provider.command.trim() === "") {
158
+ return { fallback: true, notice: "no embedding provider configured (pass --provider or configure ~/.design-ai/config.json)" };
159
+ }
160
+
161
+ // Freshness is checked against the FULL corpus (the same dirs `index --build
162
+ // --embeddings` always embeds), independent of this search's --dir filter — a
163
+ // scoped `--dir knowledge` search must not be compared against a digest of only
164
+ // the knowledge dir, or every scoped search would spuriously report the sidecar
165
+ // stale. Candidate generation below still respects the caller's `dirs` filter.
166
+ const fullCorpusDocuments = collectCorpusDocuments({ designAiPath, dirs: DEFAULT_SEARCH_DIRS });
167
+ const corpusDigest = computeCorpusDigest(fullCorpusDocuments);
168
+ const freshness = embeddingSidecarFreshness({ indexDir, designAiPath, learningFile, corpusDigest });
169
+ if (!freshness.ok) return { fallback: true, notice: freshness.reason };
170
+
171
+ const documents = dirs === DEFAULT_SEARCH_DIRS ? fullCorpusDocuments : collectCorpusDocuments({ designAiPath, dirs });
172
+ const candidateCount = embeddingCandidateCount(limit);
173
+ const stats = buildLexicalStats(documents.map(({ id, text }) => ({ id, text })));
174
+ const textById = new Map(documents.map((doc) => [doc.id, doc.text]));
175
+ const candidates = rankLexical(query, stats, { limit: candidateCount });
176
+ if (candidates.length === 0) {
177
+ return { fallback: true, notice: "no lexical candidates matched the query; nothing to rerank" };
178
+ }
179
+
180
+ const embedArgs = { provider, documents: [{ id: "__query__", text: query }] };
181
+ if (spawnRunner) embedArgs.spawnRunner = spawnRunner;
182
+ const embedded = embedDocuments(embedArgs);
183
+ if (!embedded.ok) return { fallback: true, notice: `embedding provider failed: ${embedded.error}` };
184
+
185
+ const queryVector = embedded.vectorsById.get("__query__");
186
+ const vectors = embeddingVectorsById(freshness.payload);
187
+ const reranked = cosineRerank({ queryVector, candidates, vectorsById: vectors, limit });
188
+
189
+ const hits = reranked.map((hit) => ({
190
+ relPath: hit.id,
191
+ file: path.join(designAiPath, hit.id),
192
+ // `score` is a cosine similarity for embedded candidates; a candidate the
193
+ // provider had no vector for (placed after all embedded ones, see
194
+ // embedding-rerank.mjs) keeps its lexical BM25 score here as a documented,
195
+ // deterministic fallback value rather than an undefined/null field.
196
+ score: hit.score,
197
+ matchedTokens: hit.matchedTokens,
198
+ preview: rankedPreview(textById.get(hit.id) || "", hit.matchedTokens),
199
+ }));
200
+
201
+ return { fallback: false, hits };
202
+ }
@@ -20,7 +20,7 @@ export const DEFAULT_SEARCH_DIRS = [
20
20
 
21
21
  const PREVIEW_LEN = 120;
22
22
  const PREVIEW_BEFORE = 50;
23
- const SEARCH_OPTIONS = ["-h", "--help", "--json", "--limit", "--dir"];
23
+ const SEARCH_OPTIONS = ["-h", "--help", "--json", "--limit", "--dir", "--ranked", "--embeddings", "--provider"];
24
24
 
25
25
  function exists(p) {
26
26
  try {
@@ -115,6 +115,9 @@ export function parseSearchArgs(args) {
115
115
  dirs: [],
116
116
  limit: 20,
117
117
  json: false,
118
+ ranked: false,
119
+ embeddings: false,
120
+ provider: "",
118
121
  help: false,
119
122
  };
120
123
 
@@ -124,6 +127,13 @@ export function parseSearchArgs(args) {
124
127
  out.help = true;
125
128
  } else if (arg === "--json") {
126
129
  out.json = true;
130
+ } else if (arg === "--ranked") {
131
+ out.ranked = true;
132
+ } else if (arg === "--embeddings") {
133
+ out.embeddings = true;
134
+ } else if (arg === "--provider") {
135
+ out.provider = args[i + 1] || "";
136
+ i += 1;
127
137
  } else if (arg === "--limit") {
128
138
  const next = args[i + 1];
129
139
  const limit = Number(next);