@design-ai/cli 4.55.0 → 4.57.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 (301) hide show
  1. package/.claude-plugin/plugin.json +109 -25
  2. package/CHANGELOG.md +75 -0
  3. package/README.ko.md +15 -32
  4. package/README.md +18 -31
  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/search.mjs +94 -1
  16. package/cli/lib/dispatch.mjs +3 -0
  17. package/cli/lib/embedding-index.mjs +199 -0
  18. package/cli/lib/embedding-provider.mjs +121 -0
  19. package/cli/lib/embedding-rerank.mjs +52 -0
  20. package/cli/lib/learn-args.mjs +490 -0
  21. package/cli/lib/learn-backup.mjs +748 -0
  22. package/cli/lib/learn-curation.mjs +612 -0
  23. package/cli/lib/learn-eval.mjs +459 -0
  24. package/cli/lib/learn-profile.mjs +763 -0
  25. package/cli/lib/learn-select.mjs +223 -0
  26. package/cli/lib/learn-shared.mjs +158 -0
  27. package/cli/lib/learn-test-support.mjs +218 -0
  28. package/cli/lib/learn-usage.mjs +360 -0
  29. package/cli/lib/learn.mjs +83 -3694
  30. package/cli/lib/lexical.mjs +137 -0
  31. package/cli/lib/local-config.mjs +110 -0
  32. package/cli/lib/mcp-server.mjs +221 -13
  33. package/cli/lib/pack.mjs +31 -4
  34. package/cli/lib/prompt.mjs +42 -4
  35. package/cli/lib/recall.mjs +193 -0
  36. package/cli/lib/retrieval-index.mjs +0 -0
  37. package/cli/lib/search-ranked.mjs +170 -0
  38. package/cli/lib/search.mjs +11 -1
  39. package/cli/lib/signals-backlog-commands.mjs +673 -0
  40. package/cli/lib/signals-backlog.mjs +361 -0
  41. package/cli/lib/signals-eval.mjs +176 -0
  42. package/cli/lib/signals-readiness.mjs +247 -0
  43. package/cli/lib/signals-registry.mjs +377 -0
  44. package/cli/lib/signals-render.mjs +478 -0
  45. package/cli/lib/signals-shared.mjs +75 -0
  46. package/cli/lib/signals.mjs +16 -2337
  47. package/cli/lib/site-analysis.mjs +297 -0
  48. package/cli/lib/site-args.mjs +433 -0
  49. package/cli/lib/site-bundle-build.mjs +127 -0
  50. package/cli/lib/site-bundle-check.mjs +454 -0
  51. package/cli/lib/site-bundle-commands.mjs +95 -0
  52. package/cli/lib/site-bundle-compare.mjs +157 -0
  53. package/cli/lib/site-bundle-contract.mjs +79 -0
  54. package/cli/lib/site-bundle-files.mjs +87 -0
  55. package/cli/lib/site-bundle-handoff-expected.mjs +173 -0
  56. package/cli/lib/site-bundle-handoff-runbook-action-summary.mjs +332 -0
  57. package/cli/lib/site-bundle-handoff-runbook-actions.mjs +113 -0
  58. package/cli/lib/site-bundle-handoff-runbook-evidence-fields.mjs +164 -0
  59. package/cli/lib/site-bundle-handoff-runbook-evidence.mjs +334 -0
  60. package/cli/lib/site-bundle-handoff-runbook-format.mjs +31 -0
  61. package/cli/lib/site-bundle-handoff-runbook-human-lines.mjs +167 -0
  62. package/cli/lib/site-bundle-handoff-runbook-maps.mjs +238 -0
  63. package/cli/lib/site-bundle-handoff-runbook-next-step.mjs +278 -0
  64. package/cli/lib/site-bundle-handoff-runbook-stage-metadata.mjs +84 -0
  65. package/cli/lib/site-bundle-handoff-runbook.mjs +444 -0
  66. package/cli/lib/site-bundle-handoff-summary.mjs +183 -0
  67. package/cli/lib/site-bundle-handoff.mjs +271 -0
  68. package/cli/lib/site-bundle-readme.mjs +98 -0
  69. package/cli/lib/site-bundle-repair-report.mjs +143 -0
  70. package/cli/lib/site-bundle-repair.mjs +68 -0
  71. package/cli/lib/site-content.mjs +399 -0
  72. package/cli/lib/site-evidence.mjs +35 -0
  73. package/cli/lib/site-mcp-commands.mjs +28 -0
  74. package/cli/lib/site-mcp-probes.mjs +159 -0
  75. package/cli/lib/site-mcp-readiness.mjs +157 -0
  76. package/cli/lib/site-mcp-report.mjs +324 -0
  77. package/cli/lib/site-next-actions.mjs +333 -0
  78. package/cli/lib/site-options.mjs +104 -0
  79. package/cli/lib/site-prompts.mjs +332 -0
  80. package/cli/lib/site-starter.mjs +153 -0
  81. package/cli/lib/site-strings.mjs +23 -0
  82. package/cli/lib/site-tasks.mjs +93 -0
  83. package/cli/lib/site-test-support.mjs +68 -0
  84. package/cli/lib/site-workflow-graph.mjs +309 -0
  85. package/cli/lib/site-workspace.mjs +492 -0
  86. package/cli/lib/site.mjs +108 -6617
  87. package/cli/lib/skill-proposals-apply-commands.mjs +135 -0
  88. package/cli/lib/skill-proposals-apply-contract.mjs +750 -0
  89. package/cli/lib/skill-proposals-apply-plan.mjs +118 -0
  90. package/cli/lib/skill-proposals-generate.mjs +298 -0
  91. package/cli/lib/skill-proposals-render.mjs +532 -0
  92. package/cli/lib/skill-proposals-review.mjs +262 -0
  93. package/cli/lib/skill-proposals.mjs +15 -2046
  94. package/cli/lib/workspace-args.mjs +83 -0
  95. package/cli/lib/workspace-git.mjs +169 -0
  96. package/cli/lib/workspace-learning.mjs +483 -0
  97. package/cli/lib/workspace-repo.mjs +139 -0
  98. package/cli/lib/workspace-report.mjs +283 -0
  99. package/cli/lib/workspace-test-support.mjs +99 -0
  100. package/cli/lib/workspace.mjs +30 -1056
  101. package/docs/AI-LEARNING-PHASE2.md +215 -0
  102. package/docs/DISTRIBUTION.ko.md +35 -6
  103. package/docs/DISTRIBUTION.md +35 -8
  104. package/docs/NEXT-SURFACE-DECISION.md +125 -0
  105. package/docs/PRODUCT-READINESS.md +3 -3
  106. package/docs/RELEASE-CHECKLIST.md +20 -3
  107. package/docs/RELEASE-GATES.ko.md +35 -0
  108. package/docs/RELEASE-GATES.md +234 -0
  109. package/docs/ROADMAP.md +2232 -8
  110. package/docs/external-status.md +20 -7
  111. package/docs/inspection-20260630.md +169 -0
  112. package/docs/integrations/design-ai-mcp-server.md +35 -3
  113. package/docs/integrations/vscode-walkthrough.ko.md +3 -3
  114. package/docs/integrations/vscode-walkthrough.md +3 -3
  115. package/docs/reference/ant-design.md +413 -0
  116. package/docs/reference/awesome-design-md.md +439 -0
  117. package/docs/reference/mui.md +783 -0
  118. package/docs/reference/shadcn-ui.md +298 -0
  119. package/docs/site-overrides/main.html +1 -1
  120. package/examples/component-accordion-actions.md +5 -5
  121. package/examples/component-accordion-details.md +5 -5
  122. package/examples/component-accordion-summary.md +5 -5
  123. package/examples/component-accordion.md +3 -3
  124. package/examples/component-affix.md +1 -1
  125. package/examples/component-alert-dialog.md +1 -1
  126. package/examples/component-alert-title.md +1 -1
  127. package/examples/component-alert.md +3 -3
  128. package/examples/component-anchor.md +1 -1
  129. package/examples/component-app-bar.md +1 -1
  130. package/examples/component-aspect-ratio.md +1 -1
  131. package/examples/component-auto-complete.md +2 -2
  132. package/examples/component-avatar-group.md +5 -5
  133. package/examples/component-avatar.md +3 -3
  134. package/examples/component-back-top.md +1 -1
  135. package/examples/component-backdrop.md +1 -1
  136. package/examples/component-badge.md +3 -3
  137. package/examples/component-border-beam.md +3 -3
  138. package/examples/component-bottom-navigation.md +1 -1
  139. package/examples/component-box.md +1 -1
  140. package/examples/component-breadcrumb.md +3 -3
  141. package/examples/component-button-base.md +3 -3
  142. package/examples/component-button-group.md +2 -2
  143. package/examples/component-button.md +3 -3
  144. package/examples/component-calendar.md +2 -2
  145. package/examples/component-card-actions.md +1 -1
  146. package/examples/component-card-content.md +1 -1
  147. package/examples/component-card-header.md +1 -1
  148. package/examples/component-card-media.md +1 -1
  149. package/examples/component-card.md +3 -3
  150. package/examples/component-carousel.md +2 -2
  151. package/examples/component-cascader.md +1 -1
  152. package/examples/component-chart.md +1 -1
  153. package/examples/component-checkbox.md +3 -3
  154. package/examples/component-click-away-listener.md +1 -1
  155. package/examples/component-code.md +1 -1
  156. package/examples/component-collapsible.md +1 -1
  157. package/examples/component-color-picker.md +1 -1
  158. package/examples/component-combobox.md +1 -1
  159. package/examples/component-command.md +1 -1
  160. package/examples/component-config-provider.md +3 -3
  161. package/examples/component-context-menu.md +1 -1
  162. package/examples/component-css-baseline.md +3 -3
  163. package/examples/component-date-picker.md +2 -2
  164. package/examples/component-descriptions.md +1 -1
  165. package/examples/component-dialog-actions.md +1 -1
  166. package/examples/component-dialog-content-text.md +1 -1
  167. package/examples/component-dialog-content.md +1 -1
  168. package/examples/component-dialog-title.md +1 -1
  169. package/examples/component-dialog.md +2 -2
  170. package/examples/component-divider.md +3 -3
  171. package/examples/component-drawer.md +3 -3
  172. package/examples/component-dropdown.md +3 -3
  173. package/examples/component-empty.md +2 -2
  174. package/examples/component-fade.md +1 -1
  175. package/examples/component-field.md +1 -1
  176. package/examples/component-filled-input.md +1 -1
  177. package/examples/component-flex.md +1 -1
  178. package/examples/component-float-button.md +2 -2
  179. package/examples/component-form-control-label.md +1 -1
  180. package/examples/component-form-control.md +1 -1
  181. package/examples/component-form-controls.md +1 -1
  182. package/examples/component-form-group.md +1 -1
  183. package/examples/component-form-helper-text.md +1 -1
  184. package/examples/component-form-label.md +1 -1
  185. package/examples/component-form.md +2 -2
  186. package/examples/component-grid.md +2 -2
  187. package/examples/component-grow.md +1 -1
  188. package/examples/component-hover-card.md +1 -1
  189. package/examples/component-icon-button.md +1 -1
  190. package/examples/component-icon.md +1 -1
  191. package/examples/component-image-list.md +1 -1
  192. package/examples/component-image.md +1 -1
  193. package/examples/component-input-adornment.md +1 -1
  194. package/examples/component-input-base.md +1 -1
  195. package/examples/component-input-number.md +1 -1
  196. package/examples/component-input-otp.md +2 -2
  197. package/examples/component-input.md +3 -3
  198. package/examples/component-item.md +1 -1
  199. package/examples/component-label.md +1 -1
  200. package/examples/component-layout.md +1 -1
  201. package/examples/component-link.md +1 -1
  202. package/examples/component-list-item-avatar.md +1 -1
  203. package/examples/component-list-item-button.md +1 -1
  204. package/examples/component-list-item-icon.md +1 -1
  205. package/examples/component-list-item-text.md +1 -1
  206. package/examples/component-list-item.md +1 -1
  207. package/examples/component-list-subheader.md +1 -1
  208. package/examples/component-list.md +2 -2
  209. package/examples/component-masonry.md +1 -1
  210. package/examples/component-mentions.md +1 -1
  211. package/examples/component-menu-item.md +1 -1
  212. package/examples/component-menu-list.md +1 -1
  213. package/examples/component-menu.md +1 -1
  214. package/examples/component-menubar.md +1 -1
  215. package/examples/component-message.md +1 -1
  216. package/examples/component-mobile-stepper.md +1 -1
  217. package/examples/component-modal.md +5 -5
  218. package/examples/component-navigation-menu.md +1 -1
  219. package/examples/component-notification.md +1 -1
  220. package/examples/component-outlined-input.md +1 -1
  221. package/examples/component-pagination.md +3 -3
  222. package/examples/component-paper.md +1 -1
  223. package/examples/component-popconfirm.md +1 -1
  224. package/examples/component-popover.md +3 -3
  225. package/examples/component-popper.md +1 -1
  226. package/examples/component-progress.md +2 -2
  227. package/examples/component-qr-code.md +1 -1
  228. package/examples/component-radio.md +3 -3
  229. package/examples/component-rate.md +2 -2
  230. package/examples/component-resizable.md +1 -1
  231. package/examples/component-result.md +1 -1
  232. package/examples/component-scroll-area.md +1 -1
  233. package/examples/component-segmented.md +3 -3
  234. package/examples/component-select.md +3 -3
  235. package/examples/component-separator.md +1 -1
  236. package/examples/component-sheet.md +1 -1
  237. package/examples/component-sidebar.md +1 -1
  238. package/examples/component-skeleton.md +3 -3
  239. package/examples/component-slide.md +1 -1
  240. package/examples/component-slider.md +3 -3
  241. package/examples/component-snackbar-content.md +1 -1
  242. package/examples/component-snackbar.md +1 -1
  243. package/examples/component-sonner.md +1 -1
  244. package/examples/component-space.md +1 -1
  245. package/examples/component-speed-dial-action.md +1 -1
  246. package/examples/component-speed-dial.md +1 -1
  247. package/examples/component-spin.md +2 -2
  248. package/examples/component-spinner.md +1 -1
  249. package/examples/component-splitter.md +2 -2
  250. package/examples/component-stack.md +1 -1
  251. package/examples/component-statistic.md +1 -1
  252. package/examples/component-step-button.md +4 -4
  253. package/examples/component-step-connector.md +4 -4
  254. package/examples/component-step-content.md +1 -1
  255. package/examples/component-step-icon.md +1 -1
  256. package/examples/component-step-label.md +1 -1
  257. package/examples/component-step.md +2 -2
  258. package/examples/component-steps.md +2 -2
  259. package/examples/component-swipeable-drawer.md +1 -1
  260. package/examples/component-switch.md +3 -3
  261. package/examples/component-tab-scroll-button.md +5 -5
  262. package/examples/component-tab.md +1 -1
  263. package/examples/component-table-body.md +1 -1
  264. package/examples/component-table-cell.md +1 -1
  265. package/examples/component-table-container.md +1 -1
  266. package/examples/component-table-footer.md +1 -1
  267. package/examples/component-table-head.md +1 -1
  268. package/examples/component-table-pagination.md +1 -1
  269. package/examples/component-table-row.md +1 -1
  270. package/examples/component-table-sort-label.md +1 -1
  271. package/examples/component-table.md +2 -2
  272. package/examples/component-tabs.md +3 -3
  273. package/examples/component-tag-badge.md +2 -2
  274. package/examples/component-tag.md +1 -1
  275. package/examples/component-textarea-autosize.md +1 -1
  276. package/examples/component-textarea.md +1 -1
  277. package/examples/component-time-picker.md +1 -1
  278. package/examples/component-timeline.md +1 -1
  279. package/examples/component-toast.md +4 -4
  280. package/examples/component-toggle-button.md +1 -1
  281. package/examples/component-toggle.md +1 -1
  282. package/examples/component-toolbar.md +1 -1
  283. package/examples/component-tooltip.md +3 -3
  284. package/examples/component-tour.md +1 -1
  285. package/examples/component-transfer.md +1 -1
  286. package/examples/component-tree-select.md +1 -1
  287. package/examples/component-tree.md +1 -1
  288. package/examples/component-typography.md +2 -2
  289. package/examples/component-upload.md +1 -1
  290. package/examples/component-watermark.md +1 -1
  291. package/examples/component-zoom.md +1 -1
  292. package/knowledge/COVERAGE.md +4 -3
  293. package/knowledge/components/INDEX.md +202 -202
  294. package/knowledge/patterns/brand-references.md +72 -72
  295. package/package.json +1 -1
  296. package/tools/audit/local-ci.py +16 -1
  297. package/tools/audit/package-smoke.py +717 -9
  298. package/tools/audit/registry-smoke.py +912 -10
  299. package/tools/audit/release-metadata.py +47 -1
  300. package/tools/audit/smoke_assertions.py +998 -35
  301. package/tools/migrations/refs-links-to-reference-pages.py +179 -0
@@ -14,6 +14,7 @@ from doctor_assertions import EXPECTED_DOCTOR_PASS_LABELS, assert_doctor_report_
14
14
 
15
15
  ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
16
16
  ROOT = Path(__file__).resolve().parents[2]
17
+ PACKAGE_JSON = ROOT / "package.json"
17
18
  PLUGIN_MANIFEST = ROOT / ".claude-plugin" / "plugin.json"
18
19
  PLUGIN_INVENTORY_SECTIONS = (
19
20
  ("skills", "skill"),
@@ -21,6 +22,7 @@ PLUGIN_INVENTORY_SECTIONS = (
21
22
  ("agents", "agent"),
22
23
  )
23
24
  OUTPUT_FORCE_OVERWRITE_SENTINEL = "__design-ai-smoke-force-overwrite-sentinel__"
25
+ EXPECTED_MCP_INVALID_ARGUMENT_MESSAGE = "design_ai_search.limit must be an integer"
24
26
 
25
27
 
26
28
  def load_plugin_manifest() -> dict[str, object]:
@@ -30,11 +32,70 @@ def load_plugin_manifest() -> dict[str, object]:
30
32
  raise SystemExit(f"failed to load plugin manifest for smoke assertions: {PLUGIN_MANIFEST}") from error
31
33
 
32
34
 
35
+ def load_package_json() -> dict[str, object]:
36
+ try:
37
+ return json.loads(PACKAGE_JSON.read_text(encoding="utf-8"))
38
+ except (OSError, json.JSONDecodeError) as error:
39
+ raise SystemExit(f"failed to load package metadata for smoke assertions: {PACKAGE_JSON}") from error
40
+
41
+
33
42
  def count_manifest_section(manifest: dict[str, object], section: str) -> int:
34
43
  items = manifest.get(section)
35
44
  return len(items) if isinstance(items, list) else 0
36
45
 
37
46
 
47
+ EXPECTED_RELEASE_VERSION = str(load_package_json().get("version", ""))
48
+
49
+
50
+ def mcp_smoke_input() -> str:
51
+ messages = [
52
+ {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-11-25"}},
53
+ {"jsonrpc": "2.0", "method": "notifications/initialized"},
54
+ {"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
55
+ {
56
+ "jsonrpc": "2.0",
57
+ "id": 3,
58
+ "method": "tools/call",
59
+ "params": {
60
+ "name": "design_ai_search",
61
+ "arguments": {"query": "Pretendard", "limit": "10"},
62
+ },
63
+ },
64
+ ]
65
+ return "".join(f"{json.dumps(message, separators=(',', ':'))}\n" for message in messages)
66
+
67
+
68
+ def assert_design_ai_mcp_protocol_responses(responses: list[object], *, context: str, cmd: list[str]) -> None:
69
+ by_id = {
70
+ response.get("id"): response
71
+ for response in responses
72
+ if isinstance(response, dict) and "id" in response
73
+ }
74
+ init = by_id.get(1)
75
+ tools = by_id.get(2)
76
+ invalid_call = by_id.get(3)
77
+
78
+ if not (isinstance(init, dict) and init.get("result", {}).get("serverInfo", {}).get("name") == "design-ai"):
79
+ raise SystemExit(f"{context}: MCP initialize response missing design-ai serverInfo")
80
+
81
+ tool_names = [
82
+ item.get("name")
83
+ for item in tools.get("result", {}).get("tools", [])
84
+ ] if isinstance(tools, dict) else []
85
+ if "design_ai_route" not in tool_names or "design_ai_search" not in tool_names:
86
+ raise SystemExit(f"{context}: MCP tools/list response missing design-ai tools")
87
+
88
+ invalid_error = invalid_call.get("error", {}) if isinstance(invalid_call, dict) else {}
89
+ invalid_text = str(invalid_error.get("message", "")) if isinstance(invalid_error, dict) else ""
90
+ if not (
91
+ isinstance(invalid_call, dict)
92
+ and isinstance(invalid_error, dict)
93
+ and invalid_error.get("code") == -32602
94
+ and EXPECTED_MCP_INVALID_ARGUMENT_MESSAGE in invalid_text
95
+ ):
96
+ raise SystemExit(f"{context}: MCP invalid argument response did not preserve invalid params validation")
97
+
98
+
38
99
  def format_inventory_count(count: int, singular: str) -> str:
39
100
  return f"{count} {singular}{'' if count == 1 else 's'}"
40
101
 
@@ -54,6 +115,7 @@ EXPECTED_HELP_TOPICS = (
54
115
  "status",
55
116
  "list",
56
117
  "search",
118
+ "index",
57
119
  "show",
58
120
  "route",
59
121
  "routes",
@@ -98,17 +160,18 @@ EXPECTED_HELP_TOPIC_USAGES = {
98
160
  "uninstall": "design-ai uninstall [--json]",
99
161
  "status": "design-ai status [--json]",
100
162
  "list": "design-ai list [skills|commands|agents] [--json]",
101
- "search": "design-ai search <query> [--dir kind] [--limit N] [--json]",
163
+ "search": "design-ai search <query> [--dir kind] [--limit N] [--ranked] [--embeddings [--provider \"cmd args\"]] [--json]",
164
+ "index": "design-ai index [--build|--status|--verify] [--json] [--embeddings [--provider \"cmd args\"]]",
102
165
  "show": "design-ai show <file[:line]> [--lines N:M] [--context N] [--json]",
103
166
  "route": "design-ai route <brief|--from-file file|--stdin|--list|--eval-template|--eval> [--limit N]",
104
167
  "routes": "design-ai routes [--json]",
105
- "prompt": "design-ai prompt <brief|--from-file file|--stdin|--eval-template|--eval> [--route id] [--with-learning] [--learning-category kind] [--learning-limit N] [--out file]",
106
- "pack": "design-ai pack <brief|--from-file file|--stdin|--eval-template|--eval> [--route id] [--with-learning] [--learning-category kind] [--learning-limit N] [--max-bytes N]",
168
+ "prompt": "design-ai prompt <brief|--from-file file|--stdin|--eval-template|--eval> [--route id] [--with-learning] [--learning-category kind] [--learning-limit N] [--with-recall] [--recall-limit N] [--out file]",
169
+ "pack": "design-ai pack <brief|--from-file file|--stdin|--eval-template|--eval> [--route id] [--with-learning] [--learning-category kind] [--learning-limit N] [--with-recall] [--recall-limit N] [--max-bytes N]",
107
170
  "check": "design-ai check <artifact.md|--stdin|--examples> [--route id|--all-routes] [--learn]",
108
171
  "audit": "design-ai audit [--strict] [--quiet] [--json]",
109
172
  "doctor": "design-ai doctor [--strict] [--json] [--fix]",
110
173
  "examples": "design-ai examples [query] [--route id] [--limit N] [--json]",
111
- "learn": "design-ai learn [--init|--remember text|--feedback text|--list|--export|--query text|--explain|--backup|--redact|--verify|--diff|--restore|--restore-backups [--prune]|--import|--audit [--fix]|--curate|--stats|--usage|--signals [--strict]|--agent-backlog [--strict]|--propose-skills [--min-evidence N] [--review-file path] [--review-check|--apply-plan] [--strict]|--eval-template|--eval [--strict]|--forget id|--clear] [--json|--report|--patch|--review-template] [--out file]",
174
+ "learn": "design-ai learn [--init|--remember text|--feedback text|--list|--export|--query text|--explain|--recall query|--backup|--redact|--verify|--diff|--restore|--restore-backups [--prune]|--import|--audit [--fix]|--curate|--stats|--usage|--signals [--strict]|--agent-backlog [--strict]|--propose-skills [--min-evidence N] [--review-file path] [--review-check|--apply-plan] [--strict]|--eval-template|--eval [--strict]|--forget id|--clear] [--json|--report|--patch|--review-template] [--out file]",
112
175
  "workspace": "design-ai workspace [--root path] [--learning-file path] [--learning-usage path] [--learning-eval path] [--strict] [--json]",
113
176
  "site": "design-ai site <workspace.json|--stdin> [--strict] [--json|--mcp-check [--probes]|--mcp-plan [--probes] [--json]|--next-actions [--json]|--graph|--tasks|--bundle|--report|--prompts|--prompt id [--task id]] [--out file] | site <bundle-dir> --bundle-check [--json] | site <bundle-dir> --bundle-compare other-bundle-dir [--json] | site <bundle-dir> --bundle-handoff [--task id] [--json] | site <bundle-dir> --bundle-repair [--yes] [--json] [--out file] | site --init --name name --live-url url [--next-actions] [--out file] | site --init --name name --live-url url --bundle --out dir | site --from-intake file.md|--stdin [--json|--next-actions [--json]|--tasks|--bundle [--tasks] --out dir] [--out file] | site --intake-template [--language en|ko] [--json] [--out file] | site --sample [--out file] | site --prompt-list [--json]",
114
177
  "mcp": "design-ai mcp",
@@ -121,19 +184,20 @@ EXPECTED_HELP_TOPIC_FRAGMENTS = {
121
184
  "uninstall": ("Usage:", "design-ai uninstall [--json]"),
122
185
  "status": ("Usage:", "design-ai status [--json]"),
123
186
  "list": ("Usage:", "design-ai list [skills|commands|agents]"),
124
- "search": ("Usage:", "design-ai search <query> [--limit N] [--dir kind] [--json]"),
187
+ "search": ("Usage:", "design-ai search <query> [--limit N] [--dir kind] [--ranked] [--embeddings [--provider \"cmd args\"]] [--json]"),
188
+ "index": ("Usage:", "design-ai index [--build|--status|--verify] [--json] [--embeddings [--provider \"cmd args\"]]"),
125
189
  "show": ("Usage:", "design-ai show <file[:line|start-end]> [--lines N:M] [--context N] [--json]"),
126
190
  "route": ("Usage:", "design-ai route <brief>", "design-ai route --list [--json]", "design-ai route --eval-template [--json]"),
127
191
  "routes": ("Usage:", "design-ai routes [--json]", "Equivalent to: design-ai route --list"),
128
192
  "prompt": (
129
193
  "Usage:",
130
- "design-ai prompt <brief> [--route id] [--with-learning] [--learning-category kind] [--learning-limit N] [--json] [--out file] [--force]",
194
+ "design-ai prompt <brief> [--route id] [--with-learning] [--learning-category kind] [--learning-limit N] [--with-recall] [--recall-limit N] [--json] [--out file] [--force]",
131
195
  "design-ai prompt --eval-template [--json] [--out file] [--force]",
132
196
  "design-ai prompt --eval --from-file prompt-eval.json [--strict] [--json] [--out file] [--force]",
133
197
  ),
134
198
  "pack": (
135
199
  "Usage:",
136
- "design-ai pack <brief> [--route id] [--with-learning] [--learning-category kind] [--learning-limit N] [--max-bytes N] [--json] [--out file] [--force]",
200
+ "design-ai pack <brief> [--route id] [--with-learning] [--learning-category kind] [--learning-limit N] [--with-recall] [--recall-limit N] [--max-bytes N] [--json] [--out file] [--force]",
137
201
  "design-ai pack --eval-template [--json] [--out file] [--force]",
138
202
  "design-ai pack --eval --from-file pack-eval.json [--strict] [--json] [--out file] [--force]",
139
203
  ),
@@ -144,6 +208,8 @@ EXPECTED_HELP_TOPIC_FRAGMENTS = {
144
208
  "learn": (
145
209
  "Usage:",
146
210
  "design-ai learn [--list] [--category kind] [--query text] [--explain] [--limit N] [--json] [--out file] [--force]",
211
+ "design-ai learn --recall query [--limit N] [--category kind] [--json]",
212
+ "--recall",
147
213
  "design-ai learn --init [--yes|--dry-run] [--json] [--out file] [--force]",
148
214
  "design-ai learn --feedback text [--outcome keep|improve|avoid] [--category kind] [--json] [--out file] [--force]",
149
215
  "design-ai learn --feedback --from-file notes.md [--outcome keep|improve|avoid] [--category kind] [--json] [--out file] [--force]",
@@ -307,7 +373,7 @@ EXPECTED_MAIN_HELP_FRAGMENTS = (
307
373
  "pack <brief|--from-file file|--stdin|--eval-template|--eval>",
308
374
  "check <artifact.md|--stdin|--examples>",
309
375
  "examples [query]",
310
- "learn [--init|--remember text|--feedback text|--list|--export|--query text|--explain|--backup|--redact|--verify|--diff|--restore|--restore-backups [--prune]|--import|--audit [--fix]|--curate|--stats|--usage|--signals [--strict]|--agent-backlog [--strict]|--propose-skills [--min-evidence N] [--review-file path] [--review-check|--apply-plan] [--strict]|--eval-template|--eval [--strict]|--forget id|--clear] [--json|--report|--patch|--review-template] [--out file]",
376
+ "learn [--init|--remember text|--feedback text|--list|--export|--query text|--explain|--recall query|--backup|--redact|--verify|--diff|--restore|--restore-backups [--prune]|--import|--audit [--fix]|--curate|--stats|--usage|--signals [--strict]|--agent-backlog [--strict]|--propose-skills [--min-evidence N] [--review-file path] [--review-check|--apply-plan] [--strict]|--eval-template|--eval [--strict]|--forget id|--clear] [--json|--report|--patch|--review-template] [--out file]",
311
377
  "workspace [--root path] [--learning-file path] [--learning-usage path] [--learning-eval path] [--strict] [--json]",
312
378
  "site <workspace.json|--stdin>",
313
379
  "site --init",
@@ -327,7 +393,7 @@ EXPECTED_MAIN_HELP_FRAGMENTS = (
327
393
  EXPECTED_VERSION_FRAGMENTS = (
328
394
  "design-ai CLI:",
329
395
  "Plugin / corpus:",
330
- "4.55.0",
396
+ EXPECTED_RELEASE_VERSION,
331
397
  "Source:",
332
398
  )
333
399
  EXPECTED_INSTALL_OUTPUT_FRAGMENTS = (
@@ -367,7 +433,7 @@ EXPECTED_DOCTOR_STRICT_OUTPUT_FRAGMENTS = (
367
433
  "Target:",
368
434
  "Prefix:",
369
435
  "Source layout: complete",
370
- "Version alignment: 4.55.0",
436
+ f"Version alignment: {EXPECTED_RELEASE_VERSION}",
371
437
  "Manifest paths: 41 referenced artifact(s) exist",
372
438
  "Node runtime:",
373
439
  "Python runtime:",
@@ -515,6 +581,39 @@ EXPECTED_ERROR_PREFIX = "\u2717"
515
581
  EXPECTED_CORPUS_SEARCH_QUERY = "Pretendard"
516
582
  EXPECTED_CORPUS_SEARCH_HIT = "knowledge/PRINCIPLES.md"
517
583
  EXPECTED_CORPUS_SEARCH_PREVIEW = "Pretendard for Korean primary"
584
+ EXPECTED_RANKED_SEARCH_LIMIT = 3
585
+ EXPECTED_RANKED_SEARCH_NOT_BUILT_NOTICE = (
586
+ "no corpus index built yet; ranked results come from a live corpus scan (design-ai index --build)"
587
+ )
588
+ EXPECTED_INDEX_BUILD_COMMAND = "design-ai index --build"
589
+ EXPECTED_INDEX_FILE_BASENAMES = {
590
+ "corpus": "corpus-index.json",
591
+ "learning": "learning-index.json",
592
+ }
593
+ EXPECTED_EMBEDDING_INDEX_FILE_BASENAMES = {"embedding-index": "embedding-index.json"}
594
+ EXPECTED_INDEX_STATUS_SECTION_KEYS = [
595
+ "file",
596
+ "present",
597
+ "fresh",
598
+ "sourceMatch",
599
+ "generatedAt",
600
+ "documentCount",
601
+ "storedDigest",
602
+ "currentDigest",
603
+ "error",
604
+ ]
605
+ EXPECTED_INDEX_STATUS_EMBEDDINGS_KEYS = [
606
+ "file",
607
+ "present",
608
+ "fresh",
609
+ "sourceMatch",
610
+ "generatedAt",
611
+ "documentCount",
612
+ "provider",
613
+ "storedDigest",
614
+ "currentDigest",
615
+ "error",
616
+ ]
518
617
  EXPECTED_CORPUS_SHOW_TARGET = "knowledge/PRINCIPLES.md:1"
519
618
  EXPECTED_CORPUS_SHOW_REL_PATH = "knowledge/PRINCIPLES.md"
520
619
  EXPECTED_CORPUS_SHOW_TEXT = "<!-- hand-written -->"
@@ -743,7 +842,8 @@ EXPECTED_CHECK_EXAMPLES_PAYLOAD_KEYS = [
743
842
  "examples",
744
843
  ]
745
844
  EXPECTED_CHECK_EXAMPLE_ENTRY_KEYS = ["example", "report"]
746
- EXPECTED_WORKSPACE_PAYLOAD_KEYS = ["context", "git", "repository", "learning", "learningUsage", "learningEval", "learningRestoreBackups", "release", "nextActions"]
845
+ EXPECTED_WORKSPACE_PAYLOAD_KEYS = ["context", "git", "repository", "learning", "learningUsage", "learningEval", "learningRestoreBackups", "retrievalIndex", "release", "nextActions"]
846
+ EXPECTED_WORKSPACE_RETRIEVAL_INDEX_KEYS = ["indexDir", "corpus", "learning", "fresh", "status", "buildCommand"]
747
847
  EXPECTED_WORKSPACE_CONTEXT_KEYS = ["cwd", "root", "sourceRoot", "packageName", "version"]
748
848
  EXPECTED_WORKSPACE_GIT_KEYS = [
749
849
  "isRepo",
@@ -1508,7 +1608,7 @@ def passing_list_catalog_output(kind: str = "skills") -> str:
1508
1608
  "",
1509
1609
  " design-ai catalog",
1510
1610
  "",
1511
- "Plugin: design-ai v4.55.0",
1611
+ f"Plugin: design-ai v{EXPECTED_RELEASE_VERSION}",
1512
1612
  "",
1513
1613
  "",
1514
1614
  f"{kind} ({len(items)})",
@@ -1540,7 +1640,7 @@ def passing_list_catalog_json(kind: str = "skills") -> str:
1540
1640
  return json.dumps(
1541
1641
  {
1542
1642
  "name": "design-ai",
1543
- "version": "4.55.0",
1643
+ "version": EXPECTED_RELEASE_VERSION,
1544
1644
  "kind": kind,
1545
1645
  "sections": [
1546
1646
  {
@@ -1871,6 +1971,105 @@ def passing_search_human_output() -> str:
1871
1971
  ])
1872
1972
 
1873
1973
 
1974
+ def passing_ranked_search_json(*, backend: str | None = None) -> str:
1975
+ payload = {
1976
+ "query": EXPECTED_CORPUS_SEARCH_QUERY,
1977
+ "ranked": True,
1978
+ "notice": EXPECTED_RANKED_SEARCH_NOT_BUILT_NOTICE,
1979
+ "hits": [
1980
+ {
1981
+ "relPath": EXPECTED_CORPUS_SEARCH_HIT,
1982
+ "file": f"/tmp/design-ai/{EXPECTED_CORPUS_SEARCH_HIT}",
1983
+ "score": 2.886971,
1984
+ "matchedTokens": [EXPECTED_CORPUS_SEARCH_QUERY.lower()],
1985
+ "preview": EXPECTED_CORPUS_SEARCH_PREVIEW,
1986
+ }
1987
+ ],
1988
+ }
1989
+ if backend is not None:
1990
+ payload["backend"] = backend
1991
+ return json.dumps(payload)
1992
+
1993
+
1994
+ def passing_index_build_json(index_dir: str = "/tmp/design-ai-index") -> str:
1995
+ return json.dumps({
1996
+ "action": "build",
1997
+ "indexDir": index_dir,
1998
+ "corpus": {
1999
+ "file": f"{index_dir}/{EXPECTED_INDEX_FILE_BASENAMES['corpus']}",
2000
+ "documentCount": 460,
2001
+ "digest": f"sha256:{'0' * 64}",
2002
+ },
2003
+ "learning": {
2004
+ "file": f"{index_dir}/{EXPECTED_INDEX_FILE_BASENAMES['learning']}",
2005
+ "documentCount": 3,
2006
+ "digest": f"sha256:{'1' * 64}",
2007
+ },
2008
+ })
2009
+
2010
+
2011
+ def passing_index_status_section(index_dir: str, label: str, digest: str) -> dict:
2012
+ return {
2013
+ "file": f"{index_dir}/{EXPECTED_INDEX_FILE_BASENAMES[label]}",
2014
+ "present": True,
2015
+ "fresh": True,
2016
+ "sourceMatch": True,
2017
+ "generatedAt": "2026-05-22T00:00:03.000Z",
2018
+ "documentCount": 460 if label == "corpus" else 3,
2019
+ "storedDigest": digest,
2020
+ "currentDigest": digest,
2021
+ "error": "",
2022
+ }
2023
+
2024
+
2025
+ def passing_index_status_embeddings_section(index_dir: str) -> dict:
2026
+ return {
2027
+ "file": f"{index_dir}/{EXPECTED_EMBEDDING_INDEX_FILE_BASENAMES['embedding-index']}",
2028
+ "present": True,
2029
+ "fresh": True,
2030
+ "sourceMatch": True,
2031
+ "generatedAt": "2026-05-22T00:00:03.000Z",
2032
+ "documentCount": 463,
2033
+ "provider": {"command": "./bin/local-embed", "modelLabel": "stub", "dimensions": 3},
2034
+ "storedDigest": f"sha256:{'0' * 64}",
2035
+ "currentDigest": f"sha256:{'0' * 64}",
2036
+ "error": "",
2037
+ }
2038
+
2039
+
2040
+ def passing_index_status_json(index_dir: str = "/tmp/design-ai-index", *, with_embeddings: bool = False) -> str:
2041
+ return json.dumps({
2042
+ "action": "status",
2043
+ "indexDir": index_dir,
2044
+ "corpus": passing_index_status_section(index_dir, "corpus", f"sha256:{'0' * 64}"),
2045
+ "learning": passing_index_status_section(index_dir, "learning", f"sha256:{'1' * 64}"),
2046
+ "embeddings": passing_index_status_embeddings_section(index_dir) if with_embeddings else None,
2047
+ "fresh": True,
2048
+ "buildCommand": EXPECTED_INDEX_BUILD_COMMAND,
2049
+ })
2050
+
2051
+
2052
+ def passing_index_verify_json(index_dir: str = "/tmp/design-ai-index") -> str:
2053
+ return json.dumps({
2054
+ "action": "verify",
2055
+ "ok": True,
2056
+ "checks": [
2057
+ {
2058
+ "name": "corpus",
2059
+ "file": f"{index_dir}/{EXPECTED_INDEX_FILE_BASENAMES['corpus']}",
2060
+ "matches": True,
2061
+ "reason": "",
2062
+ },
2063
+ {
2064
+ "name": "learning",
2065
+ "file": f"{index_dir}/{EXPECTED_INDEX_FILE_BASENAMES['learning']}",
2066
+ "matches": True,
2067
+ "reason": "",
2068
+ },
2069
+ ],
2070
+ })
2071
+
2072
+
1874
2073
  def passing_show_json() -> str:
1875
2074
  return json.dumps({
1876
2075
  "file": "/tmp/design-ai/knowledge/PRINCIPLES.md",
@@ -1961,7 +2160,7 @@ def passing_examples_human_output() -> str:
1961
2160
  def passing_route_json() -> str:
1962
2161
  return json.dumps({
1963
2162
  "brief": EXPECTED_ROUTE_BRIEF,
1964
- "version": "4.55.0",
2163
+ "version": EXPECTED_RELEASE_VERSION,
1965
2164
  "routes": [
1966
2165
  {
1967
2166
  "id": EXPECTED_ROUTE_ID,
@@ -2035,7 +2234,7 @@ def passing_route_explain_human_output() -> str:
2035
2234
  f" {EXPECTED_ROUTE_BRIEF}",
2036
2235
  "",
2037
2236
  "Source: /tmp/design-ai",
2038
- "Corpus version: 4.55.0",
2237
+ f"Corpus version: {EXPECTED_RELEASE_VERSION}",
2039
2238
  "",
2040
2239
  f"1. {EXPECTED_ROUTE_LABEL} (high, score {len(EXPECTED_ROUTE_MATCHED_KEYWORDS)})",
2041
2240
  f" id: {EXPECTED_ROUTE_ID}",
@@ -2124,7 +2323,7 @@ def passing_route_catalog_json() -> str:
2124
2323
  routes.append(route)
2125
2324
 
2126
2325
  return json.dumps({
2127
- "version": "4.55.0",
2326
+ "version": EXPECTED_RELEASE_VERSION,
2128
2327
  "routes": routes,
2129
2328
  })
2130
2329
 
@@ -2132,7 +2331,7 @@ def passing_route_catalog_json() -> str:
2132
2331
  def passing_prompt_payload() -> dict:
2133
2332
  return {
2134
2333
  "brief": EXPECTED_ROUTE_BRIEF,
2135
- "version": "4.55.0",
2334
+ "version": EXPECTED_RELEASE_VERSION,
2136
2335
  "route": {
2137
2336
  "id": EXPECTED_ROUTE_ID,
2138
2337
  "label": EXPECTED_ROUTE_LABEL,
@@ -2237,7 +2436,7 @@ def passing_prompt_markdown_output() -> str:
2237
2436
  f" {EXPECTED_ROUTE_BRIEF}",
2238
2437
  "",
2239
2438
  "Source: /tmp/design",
2240
- "Corpus version: 4.55.0",
2439
+ f"Corpus version: {EXPECTED_RELEASE_VERSION}",
2241
2440
  "",
2242
2441
  "# design-ai task prompt",
2243
2442
  f"Task: {EXPECTED_ROUTE_BRIEF}",
@@ -2263,7 +2462,7 @@ def passing_prompt_markdown_output() -> str:
2263
2462
  def passing_pack_json() -> str:
2264
2463
  return json.dumps({
2265
2464
  "brief": EXPECTED_ROUTE_BRIEF,
2266
- "version": "4.55.0",
2465
+ "version": EXPECTED_RELEASE_VERSION,
2267
2466
  "maxBytes": EXPECTED_PACK_MAX_BYTES,
2268
2467
  "usedBytes": EXPECTED_PACK_MAX_BYTES,
2269
2468
  "summary": {
@@ -2311,7 +2510,7 @@ def passing_pack_markdown_output() -> str:
2311
2510
  f" {EXPECTED_ROUTE_BRIEF}",
2312
2511
  "",
2313
2512
  "Source: /tmp/design",
2314
- "Corpus version: 4.55.0",
2513
+ f"Corpus version: {EXPECTED_RELEASE_VERSION}",
2315
2514
  f"Context: partial, {EXPECTED_PACK_MAX_BYTES}/{EXPECTED_PACK_MAX_BYTES} bytes, 2 warnings",
2316
2515
  "",
2317
2516
  "# design-ai prompt pack",
@@ -2606,6 +2805,321 @@ def assert_search_human_output(raw: str, *, context: str, cmd: list[str]) -> Non
2606
2805
  )
2607
2806
 
2608
2807
 
2808
+ def is_ranked_search_positive_score(value: object) -> bool:
2809
+ return type(value) in (int, float) and value > 0
2810
+
2811
+
2812
+ def is_index_sha256_digest(value: object) -> bool:
2813
+ if not isinstance(value, str) or not value.startswith("sha256:"):
2814
+ return False
2815
+ digest = value[len("sha256:"):]
2816
+ return len(digest) == 64 and all(char in "0123456789abcdef" for char in digest)
2817
+
2818
+
2819
+ def assert_index_file_path(
2820
+ value: object,
2821
+ *,
2822
+ index_dir: str,
2823
+ label: str,
2824
+ context: str,
2825
+ command_label: str,
2826
+ ) -> None:
2827
+ expected = str(Path(index_dir) / EXPECTED_INDEX_FILE_BASENAMES[label])
2828
+ if value != expected:
2829
+ raise SystemExit(f"{command_label} after {context} {label} file differs from expected index path")
2830
+
2831
+
2832
+ def assert_ranked_search_json(
2833
+ raw: str,
2834
+ *,
2835
+ context: str,
2836
+ cmd: list[str],
2837
+ expected_notice: str | None = None,
2838
+ expected_backend: str | None = None,
2839
+ ) -> None:
2840
+ assert_no_ansi(raw, cmd)
2841
+ try:
2842
+ payload = json.loads(raw)
2843
+ except json.JSONDecodeError as error:
2844
+ raise SystemExit(f"failed to parse ranked search JSON after {context}") from error
2845
+
2846
+ expected_keys = ["query", "ranked", "notice", "hits"]
2847
+ if expected_backend is not None:
2848
+ expected_keys.append("backend")
2849
+ payload = assert_corpus_json_keys(
2850
+ payload,
2851
+ expected_keys,
2852
+ label="top-level",
2853
+ context=context,
2854
+ command_label="ranked search JSON",
2855
+ )
2856
+
2857
+ if payload.get("query") != EXPECTED_CORPUS_SEARCH_QUERY:
2858
+ raise SystemExit(f"ranked search JSON after {context} query differs from expected query")
2859
+ if payload.get("ranked") is not True:
2860
+ raise SystemExit(f"ranked search JSON after {context} ranked flag is not true")
2861
+ notice = payload.get("notice")
2862
+ if not isinstance(notice, str):
2863
+ raise SystemExit(f"ranked search JSON after {context} notice is not a string")
2864
+ if expected_notice is not None and notice != expected_notice:
2865
+ raise SystemExit(f"ranked search JSON after {context} notice differs from expected notice")
2866
+ if expected_backend is not None and payload.get("backend") != expected_backend:
2867
+ raise SystemExit(f"ranked search JSON after {context} backend differs from expected backend")
2868
+
2869
+ hits = payload.get("hits")
2870
+ if not isinstance(hits, list):
2871
+ raise SystemExit(f"ranked search JSON after {context} hits is not a list")
2872
+ if not hits:
2873
+ raise SystemExit(f"ranked search JSON after {context} does not contain any hits")
2874
+ if len(hits) > EXPECTED_RANKED_SEARCH_LIMIT:
2875
+ raise SystemExit(f"ranked search JSON after {context} hit count exceeds the expected limit")
2876
+
2877
+ query_token = EXPECTED_CORPUS_SEARCH_QUERY.lower()
2878
+ query_token_matched = False
2879
+ for hit in hits:
2880
+ hit = assert_corpus_json_keys(
2881
+ hit,
2882
+ ["relPath", "file", "score", "matchedTokens", "preview"],
2883
+ label="hit",
2884
+ context=context,
2885
+ command_label="ranked search JSON",
2886
+ )
2887
+ rel_path = hit.get("relPath")
2888
+ if not isinstance(rel_path, str) or not rel_path.startswith("knowledge/"):
2889
+ raise SystemExit(f"ranked search JSON after {context} hit relPath is outside the knowledge dir")
2890
+ assert_corpus_file_path(
2891
+ hit.get("file"),
2892
+ rel_path,
2893
+ label="hit",
2894
+ context=context,
2895
+ command_label="ranked search JSON",
2896
+ )
2897
+ if not is_ranked_search_positive_score(hit.get("score")):
2898
+ raise SystemExit(f"ranked search JSON after {context} hit score is not a positive number")
2899
+ matched_tokens = hit.get("matchedTokens")
2900
+ if not isinstance(matched_tokens, list) or not matched_tokens:
2901
+ raise SystemExit(f"ranked search JSON after {context} hit matchedTokens is empty")
2902
+ if not all(isinstance(token, str) and token for token in matched_tokens):
2903
+ raise SystemExit(f"ranked search JSON after {context} hit matchedTokens contains an invalid token")
2904
+ if not isinstance(hit.get("preview"), str) or not hit.get("preview"):
2905
+ raise SystemExit(f"ranked search JSON after {context} hit preview is empty")
2906
+ if query_token in matched_tokens:
2907
+ query_token_matched = True
2908
+
2909
+ if not query_token_matched:
2910
+ raise SystemExit(f"ranked search JSON after {context} is missing a hit matching the query token")
2911
+
2912
+
2913
+ def assert_ranked_search_determinism(
2914
+ first_raw: str,
2915
+ second_raw: str,
2916
+ *,
2917
+ context: str,
2918
+ cmd: list[str],
2919
+ ) -> None:
2920
+ assert_no_ansi(first_raw, cmd)
2921
+ assert_no_ansi(second_raw, cmd)
2922
+ if first_raw != second_raw:
2923
+ raise SystemExit(f"ranked search JSON after {context} differs between identical runs")
2924
+
2925
+
2926
+ def assert_index_build_json(raw: str, *, index_dir: str, context: str, cmd: list[str]) -> None:
2927
+ assert_no_ansi(raw, cmd)
2928
+ try:
2929
+ payload = json.loads(raw)
2930
+ except json.JSONDecodeError as error:
2931
+ raise SystemExit(f"failed to parse index build JSON after {context}") from error
2932
+
2933
+ payload = assert_corpus_json_keys(
2934
+ payload,
2935
+ ["action", "indexDir", "corpus", "learning"],
2936
+ label="top-level",
2937
+ context=context,
2938
+ command_label="index build JSON",
2939
+ )
2940
+
2941
+ if payload.get("action") != "build":
2942
+ raise SystemExit(f"index build JSON after {context} action differs from build")
2943
+ if payload.get("indexDir") != str(index_dir):
2944
+ raise SystemExit(f"index build JSON after {context} indexDir differs from expected directory")
2945
+
2946
+ for label in EXPECTED_INDEX_FILE_BASENAMES:
2947
+ section = assert_corpus_json_keys(
2948
+ payload.get(label),
2949
+ ["file", "documentCount", "digest"],
2950
+ label=label,
2951
+ context=context,
2952
+ command_label="index build JSON",
2953
+ )
2954
+ assert_index_file_path(
2955
+ section.get("file"),
2956
+ index_dir=index_dir,
2957
+ label=label,
2958
+ context=context,
2959
+ command_label="index build JSON",
2960
+ )
2961
+ if not is_corpus_json_positive_int(section.get("documentCount")):
2962
+ raise SystemExit(f"index build JSON after {context} {label} documentCount is not a positive integer")
2963
+ if not is_index_sha256_digest(section.get("digest")):
2964
+ raise SystemExit(f"index build JSON after {context} {label} digest is not a sha256 digest")
2965
+
2966
+
2967
+ def assert_index_status_json(
2968
+ raw: str,
2969
+ *,
2970
+ index_dir: str,
2971
+ context: str,
2972
+ cmd: list[str],
2973
+ expect_embeddings: bool = False,
2974
+ ) -> None:
2975
+ assert_no_ansi(raw, cmd)
2976
+ try:
2977
+ payload = json.loads(raw)
2978
+ except json.JSONDecodeError as error:
2979
+ raise SystemExit(f"failed to parse index status JSON after {context}") from error
2980
+
2981
+ payload = assert_corpus_json_keys(
2982
+ payload,
2983
+ ["action", "indexDir", "corpus", "learning", "embeddings", "fresh", "buildCommand"],
2984
+ label="top-level",
2985
+ context=context,
2986
+ command_label="index status JSON",
2987
+ )
2988
+
2989
+ if payload.get("action") != "status":
2990
+ raise SystemExit(f"index status JSON after {context} action differs from status")
2991
+ if payload.get("indexDir") != str(index_dir):
2992
+ raise SystemExit(f"index status JSON after {context} indexDir differs from expected directory")
2993
+ if payload.get("fresh") is not True:
2994
+ raise SystemExit(f"index status JSON after {context} is not fresh")
2995
+ if payload.get("buildCommand") != EXPECTED_INDEX_BUILD_COMMAND:
2996
+ raise SystemExit(f"index status JSON after {context} buildCommand differs from expected command")
2997
+
2998
+ for label in EXPECTED_INDEX_FILE_BASENAMES:
2999
+ section = assert_corpus_json_keys(
3000
+ payload.get(label),
3001
+ EXPECTED_INDEX_STATUS_SECTION_KEYS,
3002
+ label=label,
3003
+ context=context,
3004
+ command_label="index status JSON",
3005
+ )
3006
+ assert_index_file_path(
3007
+ section.get("file"),
3008
+ index_dir=index_dir,
3009
+ label=label,
3010
+ context=context,
3011
+ command_label="index status JSON",
3012
+ )
3013
+ if section.get("present") is not True:
3014
+ raise SystemExit(f"index status JSON after {context} {label} index is not present")
3015
+ if section.get("fresh") is not True:
3016
+ raise SystemExit(f"index status JSON after {context} {label} index is not fresh")
3017
+ generated_at = section.get("generatedAt")
3018
+ if not isinstance(generated_at, str) or not generated_at:
3019
+ raise SystemExit(f"index status JSON after {context} {label} generatedAt is missing")
3020
+ if not is_corpus_json_positive_int(section.get("documentCount")):
3021
+ raise SystemExit(f"index status JSON after {context} {label} documentCount is not a positive integer")
3022
+ if not is_index_sha256_digest(section.get("storedDigest")):
3023
+ raise SystemExit(f"index status JSON after {context} {label} storedDigest is not a sha256 digest")
3024
+ if section.get("storedDigest") != section.get("currentDigest"):
3025
+ raise SystemExit(f"index status JSON after {context} {label} stored digest differs from current digest")
3026
+ if section.get("error") != "":
3027
+ raise SystemExit(f"index status JSON after {context} {label} error is not empty")
3028
+
3029
+ embeddings = payload.get("embeddings")
3030
+ if not expect_embeddings:
3031
+ if embeddings is not None:
3032
+ raise SystemExit(f"index status JSON after {context} embeddings is not null when embeddings were not built")
3033
+ return
3034
+
3035
+ embeddings = assert_corpus_json_keys(
3036
+ embeddings,
3037
+ EXPECTED_INDEX_STATUS_EMBEDDINGS_KEYS,
3038
+ label="embeddings",
3039
+ context=context,
3040
+ command_label="index status JSON",
3041
+ )
3042
+ expected_embeddings_file = str(Path(index_dir) / EXPECTED_EMBEDDING_INDEX_FILE_BASENAMES["embedding-index"])
3043
+ if embeddings.get("file") != expected_embeddings_file:
3044
+ raise SystemExit(f"index status JSON after {context} embeddings file differs from expected index path")
3045
+ if embeddings.get("present") is not True:
3046
+ raise SystemExit(f"index status JSON after {context} embeddings index is not present")
3047
+ if embeddings.get("fresh") is not True:
3048
+ raise SystemExit(f"index status JSON after {context} embeddings index is not fresh")
3049
+ if not is_corpus_json_positive_int(embeddings.get("documentCount")):
3050
+ raise SystemExit(f"index status JSON after {context} embeddings documentCount is not a positive integer")
3051
+ provider = embeddings.get("provider")
3052
+ if not isinstance(provider, dict) or not provider.get("command"):
3053
+ raise SystemExit(f"index status JSON after {context} embeddings provider is missing a command")
3054
+ if embeddings.get("error") != "":
3055
+ raise SystemExit(f"index status JSON after {context} embeddings error is not empty")
3056
+
3057
+
3058
+ def assert_index_verify_json(
3059
+ raw: str,
3060
+ *,
3061
+ index_dir: str,
3062
+ context: str,
3063
+ cmd: list[str],
3064
+ expect_embeddings_check: bool = False,
3065
+ ) -> None:
3066
+ assert_no_ansi(raw, cmd)
3067
+ try:
3068
+ payload = json.loads(raw)
3069
+ except json.JSONDecodeError as error:
3070
+ raise SystemExit(f"failed to parse index verify JSON after {context}") from error
3071
+
3072
+ payload = assert_corpus_json_keys(
3073
+ payload,
3074
+ ["action", "ok", "checks"],
3075
+ label="top-level",
3076
+ context=context,
3077
+ command_label="index verify JSON",
3078
+ )
3079
+
3080
+ if payload.get("action") != "verify":
3081
+ raise SystemExit(f"index verify JSON after {context} action differs from verify")
3082
+ if payload.get("ok") is not True:
3083
+ raise SystemExit(f"index verify JSON after {context} ok is not true")
3084
+
3085
+ checks = payload.get("checks")
3086
+ if not isinstance(checks, list):
3087
+ raise SystemExit(f"index verify JSON after {context} checks is not a list")
3088
+ expected_names = list(EXPECTED_INDEX_FILE_BASENAMES) + (["embeddings"] if expect_embeddings_check else [])
3089
+ if [check.get("name") for check in checks if isinstance(check, dict)] != expected_names:
3090
+ raise SystemExit(f"index verify JSON after {context} check names changed")
3091
+
3092
+ for check in checks:
3093
+ name = check.get("name") if isinstance(check, dict) else None
3094
+ if name == "embeddings":
3095
+ if check.get("matches") is not True:
3096
+ raise SystemExit(f"index verify JSON after {context} embeddings check does not match")
3097
+ if not isinstance(check.get("file"), str) or not check.get("file"):
3098
+ raise SystemExit(f"index verify JSON after {context} embeddings check file is missing")
3099
+ if not isinstance(check.get("reason"), str):
3100
+ raise SystemExit(f"index verify JSON after {context} embeddings check reason is not a string")
3101
+ continue
3102
+
3103
+ check = assert_corpus_json_keys(
3104
+ check,
3105
+ ["name", "file", "matches", "reason"],
3106
+ label="check",
3107
+ context=context,
3108
+ command_label="index verify JSON",
3109
+ )
3110
+ assert_index_file_path(
3111
+ check.get("file"),
3112
+ index_dir=index_dir,
3113
+ label=name,
3114
+ context=context,
3115
+ command_label="index verify JSON",
3116
+ )
3117
+ if check.get("matches") is not True:
3118
+ raise SystemExit(f"index verify JSON after {context} {name} check does not match")
3119
+ if check.get("reason") != "":
3120
+ raise SystemExit(f"index verify JSON after {context} {name} check reason is not empty")
3121
+
3122
+
2609
3123
  def assert_show_json_line(raw: str, *, context: str, cmd: list[str]) -> None:
2610
3124
  assert_no_ansi(raw, cmd)
2611
3125
  try:
@@ -4152,18 +4666,21 @@ def passing_main_help_output() -> str:
4152
4666
  " design-ai help [command|--json]",
4153
4667
  "",
4154
4668
  " install Symlink design-ai into Claude Code (~/.claude)",
4155
- " search <query> [--dir kind] [--limit N] [--json] Search the local markdown corpus",
4669
+ " search <query> [--dir kind] [--limit N] [--ranked] [--embeddings [--provider \"cmd args\"]] [--json]",
4670
+ " Search the local markdown corpus",
4671
+ " index [--build|--status|--verify] [--json] [--embeddings [--provider \"cmd args\"]]",
4672
+ " Build, inspect, and verify the local retrieval index",
4156
4673
  " show <file[:line]> [--lines N:M] [--context N] [--json] Print a corpus file or line range",
4157
4674
  " route <brief|--from-file file|--stdin|--list|--eval-template|--eval> [--limit N]",
4158
4675
  " Recommend commands, skills, knowledge, and route eval checkpoints",
4159
- " prompt <brief|--from-file file|--stdin|--eval-template|--eval> [--route id] [--with-learning] [--learning-category kind] [--learning-limit N] [--out file]",
4676
+ " prompt <brief|--from-file file|--stdin|--eval-template|--eval> [--route id] [--with-learning] [--learning-category kind] [--learning-limit N] [--with-recall] [--recall-limit N] [--out file]",
4160
4677
  " Generate a ready-to-use agent prompt and prompt-plan eval checkpoints",
4161
- " pack <brief|--from-file file|--stdin|--eval-template|--eval> [--route id] [--with-learning] [--learning-category kind] [--learning-limit N] [--max-bytes N]",
4678
+ " pack <brief|--from-file file|--stdin|--eval-template|--eval> [--route id] [--with-learning] [--learning-category kind] [--learning-limit N] [--with-recall] [--recall-limit N] [--max-bytes N]",
4162
4679
  " Generate prompt plus bounded context and prompt-pack eval checkpoints",
4163
4680
  " check <artifact.md|--stdin|--examples> [--route id|--all-routes] [--learn]",
4164
4681
  " Check generated Markdown artifact quality; add --issues-only or --learn",
4165
4682
  " examples [query] [--route id] [--limit N] [--json] Find worked examples for a route or query",
4166
- " learn [--init|--remember text|--feedback text|--list|--export|--query text|--explain|--backup|--redact|--verify|--diff|--restore|--restore-backups [--prune]|--import|--audit [--fix]|--curate|--stats|--usage|--signals [--strict]|--agent-backlog [--strict]|--propose-skills [--min-evidence N] [--review-file path] [--review-check|--apply-plan] [--strict]|--eval-template|--eval [--strict]|--forget id|--clear] [--json|--report|--patch|--review-template] [--out file]",
4683
+ " learn [--init|--remember text|--feedback text|--list|--export|--query text|--explain|--recall query|--backup|--redact|--verify|--diff|--restore|--restore-backups [--prune]|--import|--audit [--fix]|--curate|--stats|--usage|--signals [--strict]|--agent-backlog [--strict]|--propose-skills [--min-evidence N] [--review-file path] [--review-check|--apply-plan] [--strict]|--eval-template|--eval [--strict]|--forget id|--clear] [--json|--report|--patch|--review-template] [--out file]",
4167
4684
  " Manage local learning preferences, usage reports, signal registry, agent backlog, skill proposals, and eval checkpoints for prompt personalization",
4168
4685
  " workspace [--root path] [--learning-file path] [--learning-usage path] [--learning-eval path] [--strict] [--json]",
4169
4686
  " Show read-only local dogfood readiness: git, repository, learning usage, eval checkpoints, and release scripts",
@@ -4181,8 +4698,8 @@ def passing_main_help_output() -> str:
4181
4698
 
4182
4699
  def passing_version_output() -> str:
4183
4700
  return "\n".join([
4184
- "design-ai CLI: 4.55.0",
4185
- "Plugin / corpus: 4.55.0",
4701
+ f"design-ai CLI: {EXPECTED_RELEASE_VERSION}",
4702
+ f"Plugin / corpus: {EXPECTED_RELEASE_VERSION}",
4186
4703
  "Source: /tmp/design-ai",
4187
4704
  "",
4188
4705
  ])
@@ -4195,8 +4712,8 @@ def passing_version_json() -> str:
4195
4712
  "sourceRoot": "/tmp/design-ai",
4196
4713
  },
4197
4714
  "versions": {
4198
- "cli": "4.55.0",
4199
- "plugin": "4.55.0",
4715
+ "cli": EXPECTED_RELEASE_VERSION,
4716
+ "plugin": EXPECTED_RELEASE_VERSION,
4200
4717
  "aligned": True,
4201
4718
  },
4202
4719
  },
@@ -4213,7 +4730,7 @@ def passing_workspace_json() -> str:
4213
4730
  "root": "/tmp/project",
4214
4731
  "sourceRoot": "/tmp/design-ai",
4215
4732
  "packageName": "@design-ai/cli",
4216
- "version": "4.55.0",
4733
+ "version": EXPECTED_RELEASE_VERSION,
4217
4734
  },
4218
4735
  "git": {
4219
4736
  "isRepo": False,
@@ -4265,9 +4782,10 @@ def passing_workspace_json() -> str:
4265
4782
  "learningUsage": None,
4266
4783
  "learningEval": None,
4267
4784
  "learningRestoreBackups": None,
4785
+ "retrievalIndex": None,
4268
4786
  "release": {
4269
4787
  "packageName": "@design-ai/cli",
4270
- "version": "4.55.0",
4788
+ "version": EXPECTED_RELEASE_VERSION,
4271
4789
  "scripts": {
4272
4790
  "test": "node --test cli/lib/*.test.mjs",
4273
4791
  "audit:strict": "python3 -B tools/audit/run-all.py --strict",
@@ -5568,7 +6086,7 @@ def passing_doctor_strict_output() -> str:
5568
6086
  "ℹ Prefix: smoke-design-",
5569
6087
  "",
5570
6088
  "✓ Source layout: complete at /tmp/design-ai",
5571
- "✓ Version alignment: 4.55.0",
6089
+ f"✓ Version alignment: {EXPECTED_RELEASE_VERSION}",
5572
6090
  "✓ Manifest paths: 41 referenced artifact(s) exist",
5573
6091
  "✓ Node runtime: v24.13.1",
5574
6092
  "✓ Python runtime: Python 3.12.12",
@@ -5593,7 +6111,7 @@ def passing_install_output() -> str:
5593
6111
  return "\n".join([
5594
6112
  "",
5595
6113
  " design-ai installer",
5596
- " v4.55.0",
6114
+ f" v{EXPECTED_RELEASE_VERSION}",
5597
6115
  "",
5598
6116
  "Source: /tmp/design-ai",
5599
6117
  "Target: /tmp/claude-home",
@@ -6022,7 +6540,7 @@ def assert_workspace_json(raw: str, *, context: str, cmd: list[str]) -> None:
6022
6540
  )
6023
6541
  if workspace_context.get("packageName") != "@design-ai/cli":
6024
6542
  raise SystemExit(f"workspace JSON after {context} packageName differs from expected package")
6025
- if workspace_context.get("version") != "4.55.0":
6543
+ if workspace_context.get("version") != EXPECTED_RELEASE_VERSION:
6026
6544
  raise SystemExit(f"workspace JSON after {context} version differs from expected release version")
6027
6545
  for key in ("cwd", "root", "sourceRoot"):
6028
6546
  if not isinstance(workspace_context.get(key), str) or not workspace_context[key]:
@@ -6348,6 +6866,44 @@ def assert_workspace_json(raw: str, *, context: str, cmd: list[str]) -> None:
6348
6866
  if privacy.get("mutatesProfile") is not False:
6349
6867
  raise SystemExit(f"workspace JSON after {context} learningRestoreBackups mutation privacy flag changed")
6350
6868
 
6869
+ retrieval_index = payload.get("retrievalIndex")
6870
+ if retrieval_index is not None:
6871
+ retrieval_index = assert_smoke_json_keys(
6872
+ retrieval_index,
6873
+ EXPECTED_WORKSPACE_RETRIEVAL_INDEX_KEYS,
6874
+ label="retrievalIndex",
6875
+ context=context,
6876
+ command_label="workspace JSON",
6877
+ )
6878
+ if not isinstance(retrieval_index.get("indexDir"), str) or not retrieval_index["indexDir"]:
6879
+ raise SystemExit(f"workspace JSON after {context} retrievalIndex indexDir is missing")
6880
+ if type(retrieval_index.get("fresh")) is not bool:
6881
+ raise SystemExit(f"workspace JSON after {context} retrievalIndex fresh is not boolean")
6882
+ if retrieval_index.get("status") not in ("pass", "warn"):
6883
+ raise SystemExit(f"workspace JSON after {context} retrievalIndex status is invalid")
6884
+ if (retrieval_index.get("status") == "pass") is not retrieval_index.get("fresh"):
6885
+ raise SystemExit(f"workspace JSON after {context} retrievalIndex status does not match freshness")
6886
+ if retrieval_index.get("buildCommand") != EXPECTED_INDEX_BUILD_COMMAND:
6887
+ raise SystemExit(f"workspace JSON after {context} retrievalIndex buildCommand differs from expected command")
6888
+ for section_label in EXPECTED_INDEX_FILE_BASENAMES:
6889
+ section = assert_smoke_json_keys(
6890
+ retrieval_index.get(section_label),
6891
+ EXPECTED_INDEX_STATUS_SECTION_KEYS,
6892
+ label=f"retrievalIndex {section_label}",
6893
+ context=context,
6894
+ command_label="workspace JSON",
6895
+ )
6896
+ if not isinstance(section.get("file"), str) or not section["file"]:
6897
+ raise SystemExit(f"workspace JSON after {context} retrievalIndex {section_label} file is missing")
6898
+ for key in ("present", "fresh"):
6899
+ if type(section.get(key)) is not bool:
6900
+ raise SystemExit(f"workspace JSON after {context} retrievalIndex {section_label} {key} is not boolean")
6901
+ if not is_lifecycle_json_non_negative_int(section.get("documentCount")):
6902
+ raise SystemExit(f"workspace JSON after {context} retrievalIndex {section_label} documentCount is invalid")
6903
+ for key in ("generatedAt", "storedDigest", "currentDigest", "error"):
6904
+ if not isinstance(section.get(key), str):
6905
+ raise SystemExit(f"workspace JSON after {context} retrievalIndex {section_label} {key} is not a string")
6906
+
6351
6907
  release = assert_smoke_json_keys(
6352
6908
  payload.get("release"),
6353
6909
  EXPECTED_WORKSPACE_RELEASE_KEYS,
@@ -6355,7 +6911,7 @@ def assert_workspace_json(raw: str, *, context: str, cmd: list[str]) -> None:
6355
6911
  context=context,
6356
6912
  command_label="workspace JSON",
6357
6913
  )
6358
- if release.get("packageName") != "@design-ai/cli" or release.get("version") != "4.55.0":
6914
+ if release.get("packageName") != "@design-ai/cli" or release.get("version") != EXPECTED_RELEASE_VERSION:
6359
6915
  raise SystemExit(f"workspace JSON after {context} release package metadata differs from expected values")
6360
6916
  if not isinstance(release.get("scripts"), dict):
6361
6917
  raise SystemExit(f"workspace JSON after {context} release scripts is not an object")
@@ -9088,6 +9644,364 @@ def run_self_test() -> None:
9088
9644
  scope="smoke assertions",
9089
9645
  )
9090
9646
 
9647
+ ranked_search_cmd = [
9648
+ "design-ai",
9649
+ "search",
9650
+ EXPECTED_CORPUS_SEARCH_QUERY,
9651
+ "--dir",
9652
+ "knowledge",
9653
+ "--limit",
9654
+ str(EXPECTED_RANKED_SEARCH_LIMIT),
9655
+ "--ranked",
9656
+ "--json",
9657
+ ]
9658
+ assert_ranked_search_json(passing_ranked_search_json(), context=context, cmd=ranked_search_cmd)
9659
+ assert_ranked_search_json(
9660
+ passing_ranked_search_json(),
9661
+ context=context,
9662
+ cmd=ranked_search_cmd,
9663
+ expected_notice=EXPECTED_RANKED_SEARCH_NOT_BUILT_NOTICE,
9664
+ )
9665
+ expect_self_test_failure(
9666
+ lambda: assert_ranked_search_json("{", context=context, cmd=ranked_search_cmd),
9667
+ expected="failed to parse ranked search JSON",
9668
+ scope="smoke assertions",
9669
+ )
9670
+ assert_ranked_search_json(
9671
+ passing_ranked_search_json(backend="embeddings"),
9672
+ context=context,
9673
+ cmd=ranked_search_cmd,
9674
+ expected_backend="embeddings",
9675
+ )
9676
+ ranked_wrong_backend = json.loads(passing_ranked_search_json(backend="embeddings"))
9677
+ ranked_wrong_backend["backend"] = "lexical"
9678
+ expect_self_test_failure(
9679
+ lambda: assert_ranked_search_json(
9680
+ json.dumps(ranked_wrong_backend), context=context, cmd=ranked_search_cmd, expected_backend="embeddings"
9681
+ ),
9682
+ expected="backend differs from expected backend",
9683
+ scope="smoke assertions",
9684
+ )
9685
+ ranked_wrong_query = json.loads(passing_ranked_search_json())
9686
+ ranked_wrong_query["query"] = "Inter"
9687
+ expect_self_test_failure(
9688
+ lambda: assert_ranked_search_json(json.dumps(ranked_wrong_query), context=context, cmd=ranked_search_cmd),
9689
+ expected="query differs",
9690
+ scope="smoke assertions",
9691
+ )
9692
+ ranked_flag_false = json.loads(passing_ranked_search_json())
9693
+ ranked_flag_false["ranked"] = False
9694
+ expect_self_test_failure(
9695
+ lambda: assert_ranked_search_json(json.dumps(ranked_flag_false), context=context, cmd=ranked_search_cmd),
9696
+ expected="ranked flag is not true",
9697
+ scope="smoke assertions",
9698
+ )
9699
+ ranked_wrong_notice = json.loads(passing_ranked_search_json())
9700
+ ranked_wrong_notice["notice"] = "corpus index is stale"
9701
+ expect_self_test_failure(
9702
+ lambda: assert_ranked_search_json(
9703
+ json.dumps(ranked_wrong_notice),
9704
+ context=context,
9705
+ cmd=ranked_search_cmd,
9706
+ expected_notice=EXPECTED_RANKED_SEARCH_NOT_BUILT_NOTICE,
9707
+ ),
9708
+ expected="notice differs",
9709
+ scope="smoke assertions",
9710
+ )
9711
+ ranked_no_hits = json.loads(passing_ranked_search_json())
9712
+ ranked_no_hits["hits"] = []
9713
+ expect_self_test_failure(
9714
+ lambda: assert_ranked_search_json(json.dumps(ranked_no_hits), context=context, cmd=ranked_search_cmd),
9715
+ expected="does not contain any hits",
9716
+ scope="smoke assertions",
9717
+ )
9718
+ ranked_extra_hits = json.loads(passing_ranked_search_json())
9719
+ while len(ranked_extra_hits["hits"]) <= EXPECTED_RANKED_SEARCH_LIMIT:
9720
+ ranked_extra_hits["hits"].append(dict(ranked_extra_hits["hits"][0]))
9721
+ expect_self_test_failure(
9722
+ lambda: assert_ranked_search_json(json.dumps(ranked_extra_hits), context=context, cmd=ranked_search_cmd),
9723
+ expected="hit count exceeds",
9724
+ scope="smoke assertions",
9725
+ )
9726
+ ranked_zero_score = json.loads(passing_ranked_search_json())
9727
+ ranked_zero_score["hits"][0]["score"] = 0
9728
+ expect_self_test_failure(
9729
+ lambda: assert_ranked_search_json(json.dumps(ranked_zero_score), context=context, cmd=ranked_search_cmd),
9730
+ expected="score is not a positive number",
9731
+ scope="smoke assertions",
9732
+ )
9733
+ ranked_bool_score = json.loads(passing_ranked_search_json())
9734
+ ranked_bool_score["hits"][0]["score"] = True
9735
+ expect_self_test_failure(
9736
+ lambda: assert_ranked_search_json(json.dumps(ranked_bool_score), context=context, cmd=ranked_search_cmd),
9737
+ expected="score is not a positive number",
9738
+ scope="smoke assertions",
9739
+ )
9740
+ ranked_empty_tokens = json.loads(passing_ranked_search_json())
9741
+ ranked_empty_tokens["hits"][0]["matchedTokens"] = []
9742
+ expect_self_test_failure(
9743
+ lambda: assert_ranked_search_json(json.dumps(ranked_empty_tokens), context=context, cmd=ranked_search_cmd),
9744
+ expected="matchedTokens is empty",
9745
+ scope="smoke assertions",
9746
+ )
9747
+ ranked_unmatched_tokens = json.loads(passing_ranked_search_json())
9748
+ ranked_unmatched_tokens["hits"][0]["matchedTokens"] = ["inter"]
9749
+ expect_self_test_failure(
9750
+ lambda: assert_ranked_search_json(json.dumps(ranked_unmatched_tokens), context=context, cmd=ranked_search_cmd),
9751
+ expected="missing a hit matching the query token",
9752
+ scope="smoke assertions",
9753
+ )
9754
+ ranked_missing_hit_key = json.loads(passing_ranked_search_json())
9755
+ del ranked_missing_hit_key["hits"][0]["preview"]
9756
+ expect_self_test_failure(
9757
+ lambda: assert_ranked_search_json(json.dumps(ranked_missing_hit_key), context=context, cmd=ranked_search_cmd),
9758
+ expected="hit keys changed",
9759
+ scope="smoke assertions",
9760
+ )
9761
+ ranked_relative_file = json.loads(passing_ranked_search_json())
9762
+ ranked_relative_file["hits"][0]["file"] = EXPECTED_CORPUS_SEARCH_HIT
9763
+ expect_self_test_failure(
9764
+ lambda: assert_ranked_search_json(json.dumps(ranked_relative_file), context=context, cmd=ranked_search_cmd),
9765
+ expected="file is not absolute",
9766
+ scope="smoke assertions",
9767
+ )
9768
+ expect_self_test_failure(
9769
+ lambda: assert_ranked_search_json("\x1b[31m{}", context=context, cmd=ranked_search_cmd),
9770
+ expected="ANSI escape",
9771
+ scope="smoke assertions",
9772
+ )
9773
+ assert_ranked_search_determinism(
9774
+ passing_ranked_search_json(),
9775
+ passing_ranked_search_json(),
9776
+ context=context,
9777
+ cmd=ranked_search_cmd,
9778
+ )
9779
+ expect_self_test_failure(
9780
+ lambda: assert_ranked_search_determinism(
9781
+ passing_ranked_search_json(),
9782
+ passing_ranked_search_json() + "\n",
9783
+ context=context,
9784
+ cmd=ranked_search_cmd,
9785
+ ),
9786
+ expected="differs between identical runs",
9787
+ scope="smoke assertions",
9788
+ )
9789
+ expect_self_test_failure(
9790
+ lambda: assert_ranked_search_determinism(
9791
+ "\x1b[31m{}",
9792
+ "\x1b[31m{}",
9793
+ context=context,
9794
+ cmd=ranked_search_cmd,
9795
+ ),
9796
+ expected="ANSI escape",
9797
+ scope="smoke assertions",
9798
+ )
9799
+
9800
+ index_dir = "/tmp/design-ai-index"
9801
+ index_build_cmd = ["design-ai", "index", "--build", "--json"]
9802
+ assert_index_build_json(passing_index_build_json(index_dir), index_dir=index_dir, context=context, cmd=index_build_cmd)
9803
+ expect_self_test_failure(
9804
+ lambda: assert_index_build_json("{", index_dir=index_dir, context=context, cmd=index_build_cmd),
9805
+ expected="failed to parse index build JSON",
9806
+ scope="smoke assertions",
9807
+ )
9808
+ index_build_wrong_action = json.loads(passing_index_build_json(index_dir))
9809
+ index_build_wrong_action["action"] = "status"
9810
+ expect_self_test_failure(
9811
+ lambda: assert_index_build_json(
9812
+ json.dumps(index_build_wrong_action), index_dir=index_dir, context=context, cmd=index_build_cmd
9813
+ ),
9814
+ expected="action differs from build",
9815
+ scope="smoke assertions",
9816
+ )
9817
+ expect_self_test_failure(
9818
+ lambda: assert_index_build_json(
9819
+ passing_index_build_json("/tmp/other-index"), index_dir=index_dir, context=context, cmd=index_build_cmd
9820
+ ),
9821
+ expected="indexDir differs",
9822
+ scope="smoke assertions",
9823
+ )
9824
+ index_build_zero_documents = json.loads(passing_index_build_json(index_dir))
9825
+ index_build_zero_documents["corpus"]["documentCount"] = 0
9826
+ expect_self_test_failure(
9827
+ lambda: assert_index_build_json(
9828
+ json.dumps(index_build_zero_documents), index_dir=index_dir, context=context, cmd=index_build_cmd
9829
+ ),
9830
+ expected="documentCount is not a positive integer",
9831
+ scope="smoke assertions",
9832
+ )
9833
+ index_build_bad_digest = json.loads(passing_index_build_json(index_dir))
9834
+ index_build_bad_digest["learning"]["digest"] = "md5:abc"
9835
+ expect_self_test_failure(
9836
+ lambda: assert_index_build_json(
9837
+ json.dumps(index_build_bad_digest), index_dir=index_dir, context=context, cmd=index_build_cmd
9838
+ ),
9839
+ expected="digest is not a sha256 digest",
9840
+ scope="smoke assertions",
9841
+ )
9842
+ index_build_missing_key = json.loads(passing_index_build_json(index_dir))
9843
+ del index_build_missing_key["corpus"]["file"]
9844
+ expect_self_test_failure(
9845
+ lambda: assert_index_build_json(
9846
+ json.dumps(index_build_missing_key), index_dir=index_dir, context=context, cmd=index_build_cmd
9847
+ ),
9848
+ expected="corpus keys changed",
9849
+ scope="smoke assertions",
9850
+ )
9851
+ expect_self_test_failure(
9852
+ lambda: assert_index_build_json("\x1b[31m{}", index_dir=index_dir, context=context, cmd=index_build_cmd),
9853
+ expected="ANSI escape",
9854
+ scope="smoke assertions",
9855
+ )
9856
+
9857
+ index_status_cmd = ["design-ai", "index", "--status", "--json"]
9858
+ assert_index_status_json(passing_index_status_json(index_dir), index_dir=index_dir, context=context, cmd=index_status_cmd)
9859
+ expect_self_test_failure(
9860
+ lambda: assert_index_status_json("{", index_dir=index_dir, context=context, cmd=index_status_cmd),
9861
+ expected="failed to parse index status JSON",
9862
+ scope="smoke assertions",
9863
+ )
9864
+ index_status_stale = json.loads(passing_index_status_json(index_dir))
9865
+ index_status_stale["fresh"] = False
9866
+ expect_self_test_failure(
9867
+ lambda: assert_index_status_json(
9868
+ json.dumps(index_status_stale), index_dir=index_dir, context=context, cmd=index_status_cmd
9869
+ ),
9870
+ expected="is not fresh",
9871
+ scope="smoke assertions",
9872
+ )
9873
+ index_status_wrong_command = json.loads(passing_index_status_json(index_dir))
9874
+ index_status_wrong_command["buildCommand"] = "design-ai rebuild"
9875
+ expect_self_test_failure(
9876
+ lambda: assert_index_status_json(
9877
+ json.dumps(index_status_wrong_command), index_dir=index_dir, context=context, cmd=index_status_cmd
9878
+ ),
9879
+ expected="buildCommand differs",
9880
+ scope="smoke assertions",
9881
+ )
9882
+ index_status_missing_section = json.loads(passing_index_status_json(index_dir))
9883
+ index_status_missing_section["corpus"]["present"] = False
9884
+ expect_self_test_failure(
9885
+ lambda: assert_index_status_json(
9886
+ json.dumps(index_status_missing_section), index_dir=index_dir, context=context, cmd=index_status_cmd
9887
+ ),
9888
+ expected="corpus index is not present",
9889
+ scope="smoke assertions",
9890
+ )
9891
+ index_status_stale_section = json.loads(passing_index_status_json(index_dir))
9892
+ index_status_stale_section["learning"]["fresh"] = False
9893
+ expect_self_test_failure(
9894
+ lambda: assert_index_status_json(
9895
+ json.dumps(index_status_stale_section), index_dir=index_dir, context=context, cmd=index_status_cmd
9896
+ ),
9897
+ expected="learning index is not fresh",
9898
+ scope="smoke assertions",
9899
+ )
9900
+ index_status_digest_drift = json.loads(passing_index_status_json(index_dir))
9901
+ index_status_digest_drift["corpus"]["currentDigest"] = f"sha256:{'2' * 64}"
9902
+ expect_self_test_failure(
9903
+ lambda: assert_index_status_json(
9904
+ json.dumps(index_status_digest_drift), index_dir=index_dir, context=context, cmd=index_status_cmd
9905
+ ),
9906
+ expected="stored digest differs from current digest",
9907
+ scope="smoke assertions",
9908
+ )
9909
+ index_status_error = json.loads(passing_index_status_json(index_dir))
9910
+ index_status_error["learning"]["error"] = "unreadable"
9911
+ expect_self_test_failure(
9912
+ lambda: assert_index_status_json(
9913
+ json.dumps(index_status_error), index_dir=index_dir, context=context, cmd=index_status_cmd
9914
+ ),
9915
+ expected="error is not empty",
9916
+ scope="smoke assertions",
9917
+ )
9918
+ index_status_embeddings_present_unexpectedly = json.loads(passing_index_status_json(index_dir, with_embeddings=True))
9919
+ expect_self_test_failure(
9920
+ lambda: assert_index_status_json(
9921
+ json.dumps(index_status_embeddings_present_unexpectedly), index_dir=index_dir, context=context, cmd=index_status_cmd
9922
+ ),
9923
+ expected="embeddings is not null when embeddings were not built",
9924
+ scope="smoke assertions",
9925
+ )
9926
+
9927
+ assert_index_status_json(
9928
+ passing_index_status_json(index_dir, with_embeddings=True),
9929
+ index_dir=index_dir,
9930
+ context=context,
9931
+ cmd=index_status_cmd,
9932
+ expect_embeddings=True,
9933
+ )
9934
+ index_status_embeddings_stale = json.loads(passing_index_status_json(index_dir, with_embeddings=True))
9935
+ index_status_embeddings_stale["embeddings"]["fresh"] = False
9936
+ expect_self_test_failure(
9937
+ lambda: assert_index_status_json(
9938
+ json.dumps(index_status_embeddings_stale),
9939
+ index_dir=index_dir,
9940
+ context=context,
9941
+ cmd=index_status_cmd,
9942
+ expect_embeddings=True,
9943
+ ),
9944
+ expected="embeddings index is not fresh",
9945
+ scope="smoke assertions",
9946
+ )
9947
+ index_status_embeddings_no_provider_command = json.loads(passing_index_status_json(index_dir, with_embeddings=True))
9948
+ index_status_embeddings_no_provider_command["embeddings"]["provider"] = {"command": "", "modelLabel": "", "dimensions": 3}
9949
+ expect_self_test_failure(
9950
+ lambda: assert_index_status_json(
9951
+ json.dumps(index_status_embeddings_no_provider_command),
9952
+ index_dir=index_dir,
9953
+ context=context,
9954
+ cmd=index_status_cmd,
9955
+ expect_embeddings=True,
9956
+ ),
9957
+ expected="embeddings provider is missing a command",
9958
+ scope="smoke assertions",
9959
+ )
9960
+
9961
+ index_verify_cmd = ["design-ai", "index", "--verify", "--json"]
9962
+ assert_index_verify_json(passing_index_verify_json(index_dir), index_dir=index_dir, context=context, cmd=index_verify_cmd)
9963
+ expect_self_test_failure(
9964
+ lambda: assert_index_verify_json("{", index_dir=index_dir, context=context, cmd=index_verify_cmd),
9965
+ expected="failed to parse index verify JSON",
9966
+ scope="smoke assertions",
9967
+ )
9968
+ index_verify_not_ok = json.loads(passing_index_verify_json(index_dir))
9969
+ index_verify_not_ok["ok"] = False
9970
+ expect_self_test_failure(
9971
+ lambda: assert_index_verify_json(
9972
+ json.dumps(index_verify_not_ok), index_dir=index_dir, context=context, cmd=index_verify_cmd
9973
+ ),
9974
+ expected="ok is not true",
9975
+ scope="smoke assertions",
9976
+ )
9977
+ index_verify_wrong_names = json.loads(passing_index_verify_json(index_dir))
9978
+ index_verify_wrong_names["checks"] = index_verify_wrong_names["checks"][:1]
9979
+ expect_self_test_failure(
9980
+ lambda: assert_index_verify_json(
9981
+ json.dumps(index_verify_wrong_names), index_dir=index_dir, context=context, cmd=index_verify_cmd
9982
+ ),
9983
+ expected="check names changed",
9984
+ scope="smoke assertions",
9985
+ )
9986
+ index_verify_mismatch = json.loads(passing_index_verify_json(index_dir))
9987
+ index_verify_mismatch["checks"][1]["matches"] = False
9988
+ expect_self_test_failure(
9989
+ lambda: assert_index_verify_json(
9990
+ json.dumps(index_verify_mismatch), index_dir=index_dir, context=context, cmd=index_verify_cmd
9991
+ ),
9992
+ expected="learning check does not match",
9993
+ scope="smoke assertions",
9994
+ )
9995
+ index_verify_reason = json.loads(passing_index_verify_json(index_dir))
9996
+ index_verify_reason["checks"][0]["reason"] = "digest drift"
9997
+ expect_self_test_failure(
9998
+ lambda: assert_index_verify_json(
9999
+ json.dumps(index_verify_reason), index_dir=index_dir, context=context, cmd=index_verify_cmd
10000
+ ),
10001
+ expected="corpus check reason is not empty",
10002
+ scope="smoke assertions",
10003
+ )
10004
+
9091
10005
  show_cmd = ["design-ai", "show", EXPECTED_CORPUS_SHOW_TARGET, "--context", "0", "--json"]
9092
10006
  assert_show_json_line(passing_show_json(), context=context, cmd=show_cmd)
9093
10007
  expect_self_test_failure(
@@ -10780,7 +11694,7 @@ def run_self_test() -> None:
10780
11694
  )
10781
11695
  expect_self_test_failure(
10782
11696
  lambda: assert_version_json(
10783
- passing_version_json().replace('"plugin": "4.55.0"', '"plugin": "unknown"'),
11697
+ passing_version_json().replace(f'"plugin": "{EXPECTED_RELEASE_VERSION}"', '"plugin": "unknown"'),
10784
11698
  context=context,
10785
11699
  cmd=[*version_cmd, "--json"],
10786
11700
  ),
@@ -10951,6 +11865,54 @@ def run_self_test() -> None:
10951
11865
  expected="privacy flags changed",
10952
11866
  scope="smoke assertions",
10953
11867
  )
11868
+ workspace_retrieval_index_payload = json.loads(passing_workspace_json())
11869
+ workspace_retrieval_index_payload["retrievalIndex"] = {
11870
+ "indexDir": "/tmp/design-ai-index",
11871
+ "corpus": json.loads(passing_index_status_json())["corpus"],
11872
+ "learning": json.loads(passing_index_status_json())["learning"],
11873
+ "fresh": True,
11874
+ "status": "pass",
11875
+ "buildCommand": EXPECTED_INDEX_BUILD_COMMAND,
11876
+ }
11877
+ assert_workspace_json(json.dumps(workspace_retrieval_index_payload), context=context, cmd=workspace_cmd)
11878
+ stale_retrieval_index_payload = json.loads(json.dumps(workspace_retrieval_index_payload))
11879
+ stale_retrieval_index_payload["retrievalIndex"]["fresh"] = False
11880
+ stale_retrieval_index_payload["retrievalIndex"]["status"] = "warn"
11881
+ stale_retrieval_index_payload["retrievalIndex"]["corpus"]["fresh"] = False
11882
+ assert_workspace_json(json.dumps(stale_retrieval_index_payload), context=context, cmd=workspace_cmd)
11883
+ mismatched_retrieval_index_payload = json.loads(json.dumps(workspace_retrieval_index_payload))
11884
+ mismatched_retrieval_index_payload["retrievalIndex"]["status"] = "warn"
11885
+ expect_self_test_failure(
11886
+ lambda: assert_workspace_json(
11887
+ json.dumps(mismatched_retrieval_index_payload),
11888
+ context=context,
11889
+ cmd=workspace_cmd,
11890
+ ),
11891
+ expected="retrievalIndex status does not match freshness",
11892
+ scope="smoke assertions",
11893
+ )
11894
+ wrong_build_command_retrieval_index_payload = json.loads(json.dumps(workspace_retrieval_index_payload))
11895
+ wrong_build_command_retrieval_index_payload["retrievalIndex"]["buildCommand"] = "design-ai rebuild"
11896
+ expect_self_test_failure(
11897
+ lambda: assert_workspace_json(
11898
+ json.dumps(wrong_build_command_retrieval_index_payload),
11899
+ context=context,
11900
+ cmd=workspace_cmd,
11901
+ ),
11902
+ expected="retrievalIndex buildCommand differs",
11903
+ scope="smoke assertions",
11904
+ )
11905
+ missing_retrieval_index_key_payload = json.loads(json.dumps(workspace_retrieval_index_payload))
11906
+ del missing_retrieval_index_key_payload["retrievalIndex"]["learning"]["documentCount"]
11907
+ expect_self_test_failure(
11908
+ lambda: assert_workspace_json(
11909
+ json.dumps(missing_retrieval_index_key_payload),
11910
+ context=context,
11911
+ cmd=workspace_cmd,
11912
+ ),
11913
+ expected="retrievalIndex learning keys changed",
11914
+ scope="smoke assertions",
11915
+ )
10954
11916
  assert_workspace_strict_failure_json(
10955
11917
  passing_workspace_json(),
10956
11918
  returncode=1,
@@ -11007,6 +11969,7 @@ def run_self_test() -> None:
11007
11969
  "learningUsage": reordered_workspace_payload["learningUsage"],
11008
11970
  "learningEval": reordered_workspace_payload["learningEval"],
11009
11971
  "learningRestoreBackups": reordered_workspace_payload["learningRestoreBackups"],
11972
+ "retrievalIndex": reordered_workspace_payload["retrievalIndex"],
11010
11973
  "release": reordered_workspace_payload["release"],
11011
11974
  "nextActions": reordered_workspace_payload["nextActions"],
11012
11975
  }