ashlr-plugin 1.36.4

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 (499) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude-plugin/marketplace.json +39 -0
  3. package/.claude-plugin/plugin.json +36 -0
  4. package/.codex/agents/README.md +9 -0
  5. package/.codex/agents/ashlr-explorer.md +36 -0
  6. package/.codex/agents/ashlr-worker.md +38 -0
  7. package/.codex-plugin/plugin.json +45 -0
  8. package/.mcp.json +14 -0
  9. package/LICENSE +21 -0
  10. package/README.md +311 -0
  11. package/agents/ashlr-code.md +69 -0
  12. package/agents/ashlr-explore.md +44 -0
  13. package/agents/ashlr-plan.md +54 -0
  14. package/bench/fixtures/notebook-large.ipynb +660 -0
  15. package/bench/fixtures/notebook-small.ipynb +39 -0
  16. package/bench/fixtures/tasklist-closed.json +62 -0
  17. package/bench/fixtures/tasklist-large-mixed.json +27 -0
  18. package/bench/fixtures/tasklist-sprint-open.json +79 -0
  19. package/bench/fixtures/websearch-bun-runtime.json +32 -0
  20. package/bench/fixtures/websearch-react-hooks-patterns.json +32 -0
  21. package/bench/fixtures/websearch-typescript-generics.json +38 -0
  22. package/bench/refs/node-sdk/.refrev +1 -0
  23. package/bench/refs/node-sdk/README.md +17 -0
  24. package/bench/refs/node-sdk/src/agent/agent.ts +163 -0
  25. package/bench/refs/node-sdk/src/agent/create-agent-ui-stream.ts +83 -0
  26. package/bench/refs/node-sdk/src/agent/tool-loop-agent.ts +238 -0
  27. package/bench/refs/node-sdk/src/embed/embed-many.ts +409 -0
  28. package/bench/refs/node-sdk/src/embed/embed-result.ts +50 -0
  29. package/bench/refs/node-sdk/src/embed/embed.ts +257 -0
  30. package/bench/refs/node-sdk/src/error/invalid-argument-error.ts +34 -0
  31. package/bench/refs/node-sdk/src/error/invalid-tool-input-error.ts +33 -0
  32. package/bench/refs/node-sdk/src/error/no-object-generated-error.ts +70 -0
  33. package/bench/refs/node-sdk/src/generate-image/generate-image-result.ts +42 -0
  34. package/bench/refs/node-sdk/src/generate-image/generate-image.ts +362 -0
  35. package/bench/refs/node-sdk/src/generate-object/generate-object-result.ts +67 -0
  36. package/bench/refs/node-sdk/src/generate-object/generate-object.ts +501 -0
  37. package/bench/refs/node-sdk/src/generate-object/inject-json-instruction.ts +30 -0
  38. package/bench/refs/node-sdk/src/generate-object/output-strategy.ts +415 -0
  39. package/bench/refs/node-sdk/src/generate-object/repair-text.ts +12 -0
  40. package/bench/refs/node-sdk/src/generate-object/stream-object.ts +960 -0
  41. package/bench/refs/node-sdk/src/generate-speech/generate-speech.ts +191 -0
  42. package/bench/refs/node-sdk/src/generate-text/generate-text.ts +1484 -0
  43. package/bench/refs/node-sdk/src/generate-text/stream-text.ts +2708 -0
  44. package/bench/refs/node-sdk/src/generate-text/tool-call.ts +59 -0
  45. package/bench/refs/node-sdk/src/generate-text/tool-result.ts +36 -0
  46. package/bench/refs/node-sdk/src/telemetry/create-telemetry-dispatcher.ts +146 -0
  47. package/bench/refs/node-sdk/src/telemetry/telemetry-registry.ts +15 -0
  48. package/bench/refs/node-sdk/src/telemetry/telemetry.ts +223 -0
  49. package/bench/refs/node-sdk/src/util/async-iterable-stream.ts +94 -0
  50. package/bench/refs/node-sdk/src/util/consume-stream.ts +31 -0
  51. package/bench/refs/node-sdk/src/util/create-stitchable-stream.ts +112 -0
  52. package/bench/refs/node-sdk/src/util/download/download.ts +74 -0
  53. package/bench/refs/node-sdk/src/util/error-handler.ts +1 -0
  54. package/bench/refs/node-sdk/src/util/fix-json.ts +401 -0
  55. package/bench/refs/node-sdk/src/util/merge-objects.ts +79 -0
  56. package/bench/refs/python-lib/.refrev +1 -0
  57. package/bench/refs/python-lib/README.md +18 -0
  58. package/bench/refs/python-lib/pandas/core/_numba/executor.py +249 -0
  59. package/bench/refs/python-lib/pandas/core/_numba/extensions.py +586 -0
  60. package/bench/refs/python-lib/pandas/core/_numba/kernels/mean_.py +198 -0
  61. package/bench/refs/python-lib/pandas/core/_numba/kernels/min_max_.py +178 -0
  62. package/bench/refs/python-lib/pandas/core/_numba/kernels/sum_.py +255 -0
  63. package/bench/refs/python-lib/pandas/core/_numba/kernels/var_.py +251 -0
  64. package/bench/refs/python-lib/pandas/core/accessor.py +520 -0
  65. package/bench/refs/python-lib/pandas/core/api.py +138 -0
  66. package/bench/refs/python-lib/pandas/core/array_algos/masked_accumulations.py +97 -0
  67. package/bench/refs/python-lib/pandas/core/array_algos/masked_reductions.py +252 -0
  68. package/bench/refs/python-lib/pandas/core/array_algos/putmask.py +150 -0
  69. package/bench/refs/python-lib/pandas/core/array_algos/quantile.py +232 -0
  70. package/bench/refs/python-lib/pandas/core/array_algos/replace.py +159 -0
  71. package/bench/refs/python-lib/pandas/core/array_algos/take.py +609 -0
  72. package/bench/refs/python-lib/pandas/core/arraylike.py +534 -0
  73. package/bench/refs/python-lib/pandas/core/arrays/_arrow_string_mixins.py +434 -0
  74. package/bench/refs/python-lib/pandas/core/arrays/_mixins.py +630 -0
  75. package/bench/refs/python-lib/pandas/core/arrays/_ranges.py +276 -0
  76. package/bench/refs/python-lib/pandas/core/arrays/_utils.py +77 -0
  77. package/bench/refs/python-lib/pandas/core/arrays/arrow/accessors.py +521 -0
  78. package/bench/refs/python-lib/pandas/core/arrays/arrow/extension_types.py +174 -0
  79. package/bench/refs/python-lib/pandas/core/arrays/boolean.py +454 -0
  80. package/bench/refs/python-lib/pandas/core/arrays/floating.py +192 -0
  81. package/bench/refs/python-lib/pandas/core/arrays/integer.py +296 -0
  82. package/bench/refs/python-lib/pandas/core/arrays/masked.py +2140 -0
  83. package/bench/refs/python-lib/pandas/core/arrays/numeric.py +305 -0
  84. package/bench/refs/python-lib/pandas/core/arrays/numpy_.py +701 -0
  85. package/bench/refs/python-lib/pandas/core/arrays/period.py +1586 -0
  86. package/bench/refs/python-lib/pandas/core/arrays/sparse/accessor.py +505 -0
  87. package/bench/refs/python-lib/pandas/core/arrays/sparse/scipy_sparse.py +208 -0
  88. package/bench/refs/rust-project/.refrev +1 -0
  89. package/bench/refs/rust-project/README.md +17 -0
  90. package/bench/refs/rust-project/tokio/src/doc/os.rs +68 -0
  91. package/bench/refs/rust-project/tokio/src/fs/dir_builder.rs +129 -0
  92. package/bench/refs/rust-project/tokio/src/fs/file.rs +1113 -0
  93. package/bench/refs/rust-project/tokio/src/fs/mocks.rs +176 -0
  94. package/bench/refs/rust-project/tokio/src/fs/mod.rs +324 -0
  95. package/bench/refs/rust-project/tokio/src/fs/open_options.rs +856 -0
  96. package/bench/refs/rust-project/tokio/src/fs/read.rs +80 -0
  97. package/bench/refs/rust-project/tokio/src/fs/read_dir.rs +357 -0
  98. package/bench/refs/rust-project/tokio/src/fs/write.rs +99 -0
  99. package/bench/refs/rust-project/tokio/src/future/maybe_done.rs +123 -0
  100. package/bench/refs/rust-project/tokio/src/future/try_join.rs +82 -0
  101. package/bench/refs/rust-project/tokio/src/io/async_buf_read.rs +117 -0
  102. package/bench/refs/rust-project/tokio/src/io/async_read.rs +133 -0
  103. package/bench/refs/rust-project/tokio/src/io/async_seek.rs +96 -0
  104. package/bench/refs/rust-project/tokio/src/io/async_write.rs +402 -0
  105. package/bench/refs/rust-project/tokio/src/io/blocking.rs +339 -0
  106. package/bench/refs/rust-project/tokio/src/io/interest.rs +345 -0
  107. package/bench/refs/rust-project/tokio/src/io/join.rs +130 -0
  108. package/bench/refs/rust-project/tokio/src/io/mod.rs +308 -0
  109. package/bench/refs/rust-project/tokio/src/io/poll_evented.rs +319 -0
  110. package/bench/refs/rust-project/tokio/src/io/read_buf.rs +344 -0
  111. package/bench/refs/rust-project/tokio/src/io/ready.rs +322 -0
  112. package/bench/refs/rust-project/tokio/src/io/split.rs +166 -0
  113. package/bench/refs/rust-project/tokio/src/io/stderr.rs +148 -0
  114. package/bench/refs/rust-project/tokio/src/io/stdin.rs +98 -0
  115. package/bench/refs/rust-project/tokio/src/io/stdio_common.rs +222 -0
  116. package/bench/refs/rust-project/tokio/src/io/stdout.rs +197 -0
  117. package/bench/refs/rust-project/tokio/src/io/util/buf_reader.rs +311 -0
  118. package/bench/refs/rust-project/tokio/src/io/util/buf_stream.rs +207 -0
  119. package/bench/refs/rust-project/tokio/src/io/util/buf_writer.rs +310 -0
  120. package/bench/refs/rust-project/tokio/src/io/util/chain.rs +144 -0
  121. package/commands/_spawn-patterns.json +32 -0
  122. package/commands/ashlr-allow.md +21 -0
  123. package/commands/ashlr-benchmark.md +53 -0
  124. package/commands/ashlr-brief.md +97 -0
  125. package/commands/ashlr-budget.md +76 -0
  126. package/commands/ashlr-compact.md +54 -0
  127. package/commands/ashlr-dashboard.md +37 -0
  128. package/commands/ashlr-doctor.md +56 -0
  129. package/commands/ashlr-eco-mode.md +92 -0
  130. package/commands/ashlr-genome-init.md +24 -0
  131. package/commands/ashlr-genome-keygen.md +36 -0
  132. package/commands/ashlr-genome-loop.md +118 -0
  133. package/commands/ashlr-genome-push.md +36 -0
  134. package/commands/ashlr-genome-rewrap.md +48 -0
  135. package/commands/ashlr-genome-team-init.md +67 -0
  136. package/commands/ashlr-handoff.md +23 -0
  137. package/commands/ashlr-help.md +86 -0
  138. package/commands/ashlr-hook-timings.md +30 -0
  139. package/commands/ashlr-leaderboard.md +50 -0
  140. package/commands/ashlr-ollama-setup.md +39 -0
  141. package/commands/ashlr-orchestrate-status.md +72 -0
  142. package/commands/ashlr-orchestrate.md +83 -0
  143. package/commands/ashlr-parallelize.md +63 -0
  144. package/commands/ashlr-report-crash.md +33 -0
  145. package/commands/ashlr-resume.md +72 -0
  146. package/commands/ashlr-savings.md +24 -0
  147. package/commands/ashlr-settings.md +50 -0
  148. package/commands/ashlr-spawn.md +61 -0
  149. package/commands/ashlr-start.md +60 -0
  150. package/commands/ashlr-status.md +64 -0
  151. package/commands/ashlr-team-invite.md +47 -0
  152. package/commands/ashlr-tier.md +122 -0
  153. package/commands/ashlr-tour.md +60 -0
  154. package/commands/ashlr-update.md +155 -0
  155. package/commands/ashlr-upgrade.md +96 -0
  156. package/commands/goal.md +58 -0
  157. package/commands/loop.md +40 -0
  158. package/docs/CNAME +1 -0
  159. package/docs/PRO_TIER.md +104 -0
  160. package/docs/README.md +21 -0
  161. package/docs/architecture.md +415 -0
  162. package/docs/ashlr-pipe.md +161 -0
  163. package/docs/assets/logo.svg +19 -0
  164. package/docs/assets/og.png +0 -0
  165. package/docs/assets/og.svg +41 -0
  166. package/docs/benchmarks-v2.json +355 -0
  167. package/docs/benchmarks.json +102 -0
  168. package/docs/benchmarks.md +211 -0
  169. package/docs/billing.md +185 -0
  170. package/docs/cloud-genome.md +155 -0
  171. package/docs/competitive-wozcode.md +92 -0
  172. package/docs/deploy.md +325 -0
  173. package/docs/email-templates.md +113 -0
  174. package/docs/embed-tuning.md +103 -0
  175. package/docs/genome.md +120 -0
  176. package/docs/github-oauth-onboarding.md +127 -0
  177. package/docs/hero-video-script.md +129 -0
  178. package/docs/index.html +2262 -0
  179. package/docs/install-prompt.md +78 -0
  180. package/docs/install-windows.md +132 -0
  181. package/docs/install.ps1 +171 -0
  182. package/docs/install.sh +146 -0
  183. package/docs/launch/README.md +114 -0
  184. package/docs/launch/hn.md +49 -0
  185. package/docs/launch/reddit.md +45 -0
  186. package/docs/launch/x.md +65 -0
  187. package/docs/launch-post.md +112 -0
  188. package/docs/legal.md +78 -0
  189. package/docs/llm-providers.md +99 -0
  190. package/docs/multi-host-mcp.md +217 -0
  191. package/docs/multi-turn-architecture.md +108 -0
  192. package/docs/operations/deployment.md +108 -0
  193. package/docs/operations/error-monitoring.md +70 -0
  194. package/docs/operations/weekly-digest.md +70 -0
  195. package/docs/operations.md +494 -0
  196. package/docs/platform-support.md +133 -0
  197. package/docs/policy-packs.md +188 -0
  198. package/docs/press-kit/fact-sheet.md +78 -0
  199. package/docs/press-kit/founder-bio.md +3 -0
  200. package/docs/press-kit/logo.svg +33 -0
  201. package/docs/press-kit/screenshots.md +146 -0
  202. package/docs/pricing.md +251 -0
  203. package/docs/pro-backend-architecture.md +523 -0
  204. package/docs/producthunt.md +52 -0
  205. package/docs/router-migration.md +110 -0
  206. package/docs/session-log-schema.md +68 -0
  207. package/docs/social.md +95 -0
  208. package/docs/team-genome.md +528 -0
  209. package/docs/telemetry.md +194 -0
  210. package/docs/test-isolation.md +100 -0
  211. package/docs/upgrade.md +159 -0
  212. package/hooks/_hook-errors.ts +87 -0
  213. package/hooks/_hook-perf-emit.ts +113 -0
  214. package/hooks/_hook-timing-ledger.ts +1133 -0
  215. package/hooks/_nudge-throttle.ts +127 -0
  216. package/hooks/_recent-blocks.ts +127 -0
  217. package/hooks/audit-upload.ts +86 -0
  218. package/hooks/codex-hooks.json +171 -0
  219. package/hooks/commit-attribution.ts +179 -0
  220. package/hooks/edit-batching-nudge.ts +154 -0
  221. package/hooks/genome-scribe-hook.ts +219 -0
  222. package/hooks/hooks.json +294 -0
  223. package/hooks/orient-nudge-hook.ts +242 -0
  224. package/hooks/policy-enforce.ts +214 -0
  225. package/hooks/post-tool-use-embedding.ts +93 -0
  226. package/hooks/post-tool-use-genome.sh +37 -0
  227. package/hooks/post-tool-use-genome.ts +69 -0
  228. package/hooks/posttooluse-correlate.ts +122 -0
  229. package/hooks/posttooluse-dedupe-output.ts +241 -0
  230. package/hooks/posttooluse-genome-refresh.ts +223 -0
  231. package/hooks/posttooluse-native-nudge.ts +205 -0
  232. package/hooks/posttooluse-stale-result.ts +277 -0
  233. package/hooks/pretooluse-bash.ts +63 -0
  234. package/hooks/pretooluse-budget-guard.ts +318 -0
  235. package/hooks/pretooluse-common.ts +985 -0
  236. package/hooks/pretooluse-eco-router.ts +196 -0
  237. package/hooks/pretooluse-edit.sh +93 -0
  238. package/hooks/pretooluse-edit.ts +197 -0
  239. package/hooks/pretooluse-glob.ts +70 -0
  240. package/hooks/pretooluse-grep.sh +85 -0
  241. package/hooks/pretooluse-grep.ts +96 -0
  242. package/hooks/pretooluse-notebookedit.ts +121 -0
  243. package/hooks/pretooluse-read.sh +118 -0
  244. package/hooks/pretooluse-read.ts +118 -0
  245. package/hooks/pretooluse-task.ts +130 -0
  246. package/hooks/pretooluse-webfetch.ts +66 -0
  247. package/hooks/pretooluse-websearch.ts +85 -0
  248. package/hooks/pretooluse-write.ts +128 -0
  249. package/hooks/pulse-emit.ts +197 -0
  250. package/hooks/session-end-consolidate.sh +35 -0
  251. package/hooks/session-end-consolidate.ts +188 -0
  252. package/hooks/session-end-stats-gc.ts +80 -0
  253. package/hooks/session-log-append.sh +94 -0
  254. package/hooks/session-log-append.ts +116 -0
  255. package/hooks/session-start.ts +993 -0
  256. package/hooks/sessionend-hook-health-nudge.ts +451 -0
  257. package/hooks/sessionend-savings-nudge.ts +318 -0
  258. package/hooks/sessionend-status-line-nudge.ts +296 -0
  259. package/hooks/sessionstart-brief.ts +140 -0
  260. package/hooks/sessionstart-cost-refactor.ts +95 -0
  261. package/hooks/sessionstart-efficient.ts +110 -0
  262. package/hooks/sessionstart-genome-author.ts +93 -0
  263. package/hooks/sessionstart-lean-tools.ts +98 -0
  264. package/hooks/sessionstart-search.ts +97 -0
  265. package/hooks/stop-accounting.ts +152 -0
  266. package/hooks/subagent-stop-rollup.ts +162 -0
  267. package/hooks/userpromptsubmit-brief-trigger.ts +156 -0
  268. package/package.json +56 -0
  269. package/ports/README.md +108 -0
  270. package/ports/cursor/mcp.json +11 -0
  271. package/ports/goose/recipe.yaml +26 -0
  272. package/scripts/ashlr-mcp.ts +40 -0
  273. package/scripts/auto-update.ts +177 -0
  274. package/scripts/baseline-scan.ts +831 -0
  275. package/scripts/bench-orchestrate.ts +377 -0
  276. package/scripts/benchmark-refs.ts +292 -0
  277. package/scripts/bootstrap.mjs +100 -0
  278. package/scripts/brief-eval.ts +234 -0
  279. package/scripts/bump-version.ts +82 -0
  280. package/scripts/bun-resolve.d.mts +11 -0
  281. package/scripts/bun-resolve.mjs +59 -0
  282. package/scripts/calibrate-grep.ts +467 -0
  283. package/scripts/capture-grep-workload.ts +260 -0
  284. package/scripts/check-tier.ts +61 -0
  285. package/scripts/check-version-sync.ts +97 -0
  286. package/scripts/cli-orchestrate-status.ts +386 -0
  287. package/scripts/cli-orchestrate.ts +303 -0
  288. package/scripts/cli.ts +478 -0
  289. package/scripts/cloud-smoke-test.ts +313 -0
  290. package/scripts/coach-report.ts +373 -0
  291. package/scripts/context-db-migrate.ts +137 -0
  292. package/scripts/context-status.ts +43 -0
  293. package/scripts/demo-run.ts +255 -0
  294. package/scripts/deploy-smoke.sh +140 -0
  295. package/scripts/doctor.ts +992 -0
  296. package/scripts/embed-file-worker.ts +104 -0
  297. package/scripts/embed-tune.ts +308 -0
  298. package/scripts/errors-report.ts +428 -0
  299. package/scripts/find-test-leak.ts +69 -0
  300. package/scripts/generate-badge.ts +357 -0
  301. package/scripts/genome-auto-consolidate.ts +533 -0
  302. package/scripts/genome-auto-propose.ts +578 -0
  303. package/scripts/genome-cloud-pull.ts +363 -0
  304. package/scripts/genome-cloud-push.ts +382 -0
  305. package/scripts/genome-cloud-sync.ts +587 -0
  306. package/scripts/genome-commit-watcher.ts +295 -0
  307. package/scripts/genome-init.ts +986 -0
  308. package/scripts/genome-key.ts +233 -0
  309. package/scripts/genome-keygen.ts +168 -0
  310. package/scripts/genome-link.ts +59 -0
  311. package/scripts/genome-refresh-worker.ts +364 -0
  312. package/scripts/genome-rewrap.ts +90 -0
  313. package/scripts/genome-synthesizer.ts +679 -0
  314. package/scripts/genome-team-init.ts +312 -0
  315. package/scripts/handoff-pack.ts +438 -0
  316. package/scripts/hook-bootstrap.mjs +75 -0
  317. package/scripts/hook-timing-reader.ts +371 -0
  318. package/scripts/hook-timings-report.ts +554 -0
  319. package/scripts/install-genome-hooks.ts +196 -0
  320. package/scripts/install-onnx-model.ts +154 -0
  321. package/scripts/install-permissions.ts +276 -0
  322. package/scripts/install-status-line.ts +150 -0
  323. package/scripts/mcp-entrypoint.sh +78 -0
  324. package/scripts/mcp-entrypoint.ts +130 -0
  325. package/scripts/mdx-angle-bracket-lint.ts +152 -0
  326. package/scripts/measure-cold-start.ts +126 -0
  327. package/scripts/migrate-stats-to-sqlite.ts +86 -0
  328. package/scripts/ollama-setup.ts +498 -0
  329. package/scripts/onboarding-wizard.ts +1691 -0
  330. package/scripts/orchestrate-emit-telemetry.ts +177 -0
  331. package/scripts/orchestrate-expand.ts +517 -0
  332. package/scripts/orchestrate-run.ts +744 -0
  333. package/scripts/print-legend.ts +50 -0
  334. package/scripts/product-metadata.ts +38 -0
  335. package/scripts/prune-old-memories.ts +208 -0
  336. package/scripts/publish.sh +88 -0
  337. package/scripts/read-calibration.ts +122 -0
  338. package/scripts/report-crash.ts +226 -0
  339. package/scripts/run-benchmark.ts +750 -0
  340. package/scripts/run-root-tests.ts +28 -0
  341. package/scripts/savings-dashboard.ts +1258 -0
  342. package/scripts/savings-report-extras.ts +447 -0
  343. package/scripts/savings-status-line.ts +873 -0
  344. package/scripts/session-event-emit.ts +266 -0
  345. package/scripts/session-greet.ts +686 -0
  346. package/scripts/session-log-report.ts +551 -0
  347. package/scripts/session-resume.ts +614 -0
  348. package/scripts/set-hook-mode.ts +83 -0
  349. package/scripts/smoke-cross-platform.ts +117 -0
  350. package/scripts/smoke-realtime.ts +257 -0
  351. package/scripts/smoke-tools.ts +21 -0
  352. package/scripts/stats-cloud-pull.ts +213 -0
  353. package/scripts/stats-cloud-sync.ts +261 -0
  354. package/scripts/telemetry-flush.ts +160 -0
  355. package/scripts/telemetry-status.ts +266 -0
  356. package/scripts/timing-budget.ts +35 -0
  357. package/scripts/ui-animation.ts +120 -0
  358. package/scripts/ui.ts +431 -0
  359. package/scripts/upgrade-flow.ts +775 -0
  360. package/scripts/weekly-digest.ts +394 -0
  361. package/servers/_accounting.ts +78 -0
  362. package/servers/_ask-router.ts +84 -0
  363. package/servers/_ast-chunker.ts +434 -0
  364. package/servers/_ast-helpers.ts +183 -0
  365. package/servers/_ast-languages.ts +153 -0
  366. package/servers/_ast-refactor/_shared.ts +221 -0
  367. package/servers/_ast-refactor/cross-file-rename.ts +560 -0
  368. package/servers/_ast-refactor/extract-function.ts +357 -0
  369. package/servers/_ast-refactor/file-local-rename.ts +172 -0
  370. package/servers/_ast-refactor.ts +41 -0
  371. package/servers/_ast-skeleton.ts +107 -0
  372. package/servers/_bash-summarizers-registry.ts +1096 -0
  373. package/servers/_crash-dump.ts +210 -0
  374. package/servers/_cwd-clamp.ts +342 -0
  375. package/servers/_edit-log.ts +28 -0
  376. package/servers/_edit-match.ts +236 -0
  377. package/servers/_edit-validate.ts +196 -0
  378. package/servers/_embed-calibration.ts +123 -0
  379. package/servers/_embedding-cache.ts +401 -0
  380. package/servers/_embedding-model.ts +329 -0
  381. package/servers/_events.ts +113 -0
  382. package/servers/_genome-cache.ts +154 -0
  383. package/servers/_genome-commits.ts +172 -0
  384. package/servers/_genome-crypto-v2.ts +242 -0
  385. package/servers/_genome-crypto.ts +259 -0
  386. package/servers/_genome-discoveries.ts +140 -0
  387. package/servers/_genome-embed-populator.ts +216 -0
  388. package/servers/_genome-freshness.ts +134 -0
  389. package/servers/_genome-issues.ts +171 -0
  390. package/servers/_genome-live.ts +263 -0
  391. package/servers/_genome-prs.ts +188 -0
  392. package/servers/_genome-search.ts +479 -0
  393. package/servers/_genome-sync.ts +297 -0
  394. package/servers/_history-tracker.ts +279 -0
  395. package/servers/_http-helpers.ts +151 -0
  396. package/servers/_identity-hash.ts +151 -0
  397. package/servers/_llm-providers/anthropic.ts +132 -0
  398. package/servers/_llm-providers/cloud.ts +195 -0
  399. package/servers/_llm-providers/index.ts +350 -0
  400. package/servers/_llm-providers/local.ts +127 -0
  401. package/servers/_llm-providers/onnx.ts +393 -0
  402. package/servers/_llm-providers/types.ts +27 -0
  403. package/servers/_manifest-v2.ts +447 -0
  404. package/servers/_mcp-host.ts +93 -0
  405. package/servers/_nudge-events.ts +451 -0
  406. package/servers/_orchestrate-executor.ts +316 -0
  407. package/servers/_prefetch.ts +435 -0
  408. package/servers/_pricing.ts +256 -0
  409. package/servers/_pro.ts +381 -0
  410. package/servers/_read-cache.ts +44 -0
  411. package/servers/_router-handlers.ts +46 -0
  412. package/servers/_router.ts +55 -0
  413. package/servers/_run-with-timeout.ts +173 -0
  414. package/servers/_savings-render.ts +214 -0
  415. package/servers/_stats-sqlite.ts +640 -0
  416. package/servers/_stats.ts +1183 -0
  417. package/servers/_status-line-cells.ts +469 -0
  418. package/servers/_streaks.ts +162 -0
  419. package/servers/_summarize.ts +208 -0
  420. package/servers/_task-graph-render.ts +217 -0
  421. package/servers/_task-graph.ts +545 -0
  422. package/servers/_telemetry.ts +719 -0
  423. package/servers/_test-parsers.ts +530 -0
  424. package/servers/_test-watch.ts +626 -0
  425. package/servers/_text-helpers.ts +37 -0
  426. package/servers/_token-measure.ts +160 -0
  427. package/servers/_tool-base.ts +248 -0
  428. package/servers/_weekly-digest.ts +391 -0
  429. package/servers/ask-server-handlers.ts +49 -0
  430. package/servers/ask-server.ts +174 -0
  431. package/servers/bash-server-handlers.ts +138 -0
  432. package/servers/bash-server.ts +1136 -0
  433. package/servers/bench.ts +186 -0
  434. package/servers/diff-semantic-server-handlers.ts +39 -0
  435. package/servers/diff-semantic-server.ts +566 -0
  436. package/servers/diff-server-handlers.ts +317 -0
  437. package/servers/diff-server.ts +20 -0
  438. package/servers/edit-server-handlers.ts +31 -0
  439. package/servers/edit-server.ts +209 -0
  440. package/servers/edit-structural-server-handlers.ts +322 -0
  441. package/servers/efficiency-server-handlers.ts +161 -0
  442. package/servers/efficiency-server.ts +1135 -0
  443. package/servers/flush-server-handlers.ts +21 -0
  444. package/servers/flush-server.ts +19 -0
  445. package/servers/genome-server-handlers.ts +118 -0
  446. package/servers/genome-server.ts +397 -0
  447. package/servers/github-server-handlers.ts +187 -0
  448. package/servers/github-server.ts +856 -0
  449. package/servers/glob-server-handlers.ts +233 -0
  450. package/servers/glob-server.ts +20 -0
  451. package/servers/grep-server-handlers.ts +61 -0
  452. package/servers/grep-server.ts +564 -0
  453. package/servers/http-server-handlers.ts +36 -0
  454. package/servers/http-server.ts +149 -0
  455. package/servers/logs-server-handlers.ts +42 -0
  456. package/servers/logs-server.ts +359 -0
  457. package/servers/ls-server-handlers.ts +166 -0
  458. package/servers/ls-server.ts +20 -0
  459. package/servers/multi-edit-server-handlers.ts +51 -0
  460. package/servers/multi-edit-server.ts +422 -0
  461. package/servers/notebook-edit-server-handlers.ts +34 -0
  462. package/servers/notebook-edit-server.ts +181 -0
  463. package/servers/orient-server-handlers.ts +52 -0
  464. package/servers/orient-server.ts +436 -0
  465. package/servers/pipe-server-handlers.ts +68 -0
  466. package/servers/pipe-server.ts +300 -0
  467. package/servers/read-server-handlers.ts +42 -0
  468. package/servers/read-server.ts +206 -0
  469. package/servers/rename-file-server-handlers.ts +67 -0
  470. package/servers/rename-file-server.ts +699 -0
  471. package/servers/savings-server-handlers.ts +27 -0
  472. package/servers/savings-server.ts +54 -0
  473. package/servers/search-replace-regex-server-handlers.ts +96 -0
  474. package/servers/search-replace-regex-server.ts +751 -0
  475. package/servers/sql-server-handlers.ts +58 -0
  476. package/servers/sql-server.ts +505 -0
  477. package/servers/task-server-handlers.ts +358 -0
  478. package/servers/task-server.ts +24 -0
  479. package/servers/test-server-handlers.ts +351 -0
  480. package/servers/test-server.ts +20 -0
  481. package/servers/tree-server-handlers.ts +503 -0
  482. package/servers/tree-server.ts +20 -0
  483. package/servers/webfetch-server-handlers.ts +166 -0
  484. package/servers/webfetch-server.ts +20 -0
  485. package/servers/websearch-server-handlers.ts +215 -0
  486. package/servers/websearch-server.ts +19 -0
  487. package/servers/write-server-handlers.ts +27 -0
  488. package/servers/write-server.ts +97 -0
  489. package/skills/ashlr-brief/SKILL.md +80 -0
  490. package/skills/ashlr-codex-doctor/SKILL.md +21 -0
  491. package/skills/ashlr-codex-efficient-tools/SKILL.md +17 -0
  492. package/skills/ashlr-codex-genome-refresh/SKILL.md +20 -0
  493. package/skills/ashlr-codex-parallel-explore/SKILL.md +20 -0
  494. package/skills/ashlr-codex-savings/SKILL.md +17 -0
  495. package/skills/ashlr-cost-refactor/SKILL.md +50 -0
  496. package/skills/ashlr-efficient/SKILL.md +64 -0
  497. package/skills/ashlr-genome-author/SKILL.md +43 -0
  498. package/skills/ashlr-lean-tools/SKILL.md +43 -0
  499. package/skills/ashlr-search/SKILL.md +46 -0
@@ -0,0 +1,1691 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * ashlr onboarding wizard — guides first-time users through setup.
4
+ *
5
+ * Designed to be driven by the /ashlr-start skill. Each section emits
6
+ * structured output: plain text blocks + [ASHLR_*] markers the skill
7
+ * uses to drive user Q&A and take action.
8
+ *
9
+ * Usage:
10
+ * bun run scripts/onboarding-wizard.ts # interactive
11
+ * bun run scripts/onboarding-wizard.ts --no-interactive
12
+ * bun run scripts/onboarding-wizard.ts --reset # delete stamp
13
+ *
14
+ * Stdout: the wizard transcript (pipe-safe, 72-char width).
15
+ * Stderr: timing / debug info.
16
+ *
17
+ * Contract: exits 0 on success, 1 only on fatal I/O errors.
18
+ * Never throws to the caller.
19
+ */
20
+
21
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
22
+ import { readFile, unlink } from "fs/promises";
23
+ import { homedir } from "os";
24
+ import { basename, dirname, join } from "path";
25
+ import { createInterface } from "readline";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Constants
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export const STAMP_FILENAME = "installed-at";
32
+ export const WIDTH = 72;
33
+ export const YES_TIMEOUT_MS = 5000;
34
+ /**
35
+ * Permissions prompt timeout is longer than the generic YES_TIMEOUT_MS so
36
+ * the user has time to register that a grant is happening before it
37
+ * auto-accepts. Paired with a once-per-second visible countdown so the
38
+ * "it just auto-approved without asking me" UX bug can't recur.
39
+ */
40
+ export const PERMISSIONS_COUNTDOWN_MS = 30_000;
41
+ const TOTAL_STEPS = 9;
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Paths
45
+ // ---------------------------------------------------------------------------
46
+
47
+ export function stampPath(home: string = homedir()): string {
48
+ return join(home, ".ashlr", STAMP_FILENAME);
49
+ }
50
+
51
+ export function ashlrDir(home: string = homedir()): string {
52
+ return join(home, ".ashlr");
53
+ }
54
+
55
+ /**
56
+ * Path to the restart-required hint file. The wizard writes this when it
57
+ * finishes; the SessionStart hook reads + clears it to detect when a user
58
+ * completed the wizard but didn't actually restart Claude Code (and is
59
+ * therefore about to fall back to built-in Read/Edit/Grep without realizing).
60
+ */
61
+ export function restartRequiredPath(home: string = homedir()): string {
62
+ return join(home, ".ashlr", "restart-required");
63
+ }
64
+
65
+ /**
66
+ * Read this plugin's package.json version. Best-effort — returns "unknown"
67
+ * on any failure so wizard rendering never crashes on a corrupted install.
68
+ */
69
+ export function readPackageVersionSafe(): string {
70
+ try {
71
+ // package.json lives one directory up from scripts/
72
+ const pkgPath = join(import.meta.dir, "..", "package.json");
73
+ const raw = readFileSync(pkgPath, "utf-8");
74
+ const parsed = JSON.parse(raw) as { version?: unknown };
75
+ if (typeof parsed.version === "string" && parsed.version.trim()) {
76
+ return parsed.version.trim();
77
+ }
78
+ } catch {
79
+ /* ignore — best-effort */
80
+ }
81
+ return "unknown";
82
+ }
83
+
84
+ /**
85
+ * Write the restart-required hint file with the current timestamp + pid +
86
+ * wizard version. Best-effort: wrapped in try/catch so a readonly HOME
87
+ * never errors the wizard.
88
+ *
89
+ * Shape: { writtenAt: ISO8601, wizardVersion: string, pid: number }
90
+ *
91
+ * Consumed by hooks/session-start.ts, which compares writtenAt + pid against
92
+ * its own to decide whether the user actually restarted Claude Code.
93
+ */
94
+ export function writeRestartRequired(home: string = homedir()): void {
95
+ try {
96
+ mkdirSync(ashlrDir(home), { recursive: true });
97
+ const payload = {
98
+ writtenAt: new Date().toISOString(),
99
+ wizardVersion: readPackageVersionSafe(),
100
+ pid: process.pid,
101
+ };
102
+ writeFileSync(restartRequiredPath(home), JSON.stringify(payload, null, 2) + "\n");
103
+ } catch {
104
+ /* best-effort — readonly HOME, full disk, etc. must never error wizard */
105
+ }
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Onboarding state machine
110
+ // ---------------------------------------------------------------------------
111
+
112
+ export interface OnboardingState {
113
+ started: boolean;
114
+ completed: boolean;
115
+ completedAt?: string;
116
+ lastStep?: number;
117
+ }
118
+
119
+ export function onboardingStatePath(home: string = homedir()): string {
120
+ return join(home, ".ashlr", "onboarding.json");
121
+ }
122
+
123
+ export function readOnboardingState(home: string = homedir()): OnboardingState | null {
124
+ try {
125
+ const p = onboardingStatePath(home);
126
+ if (!existsSync(p)) return null;
127
+ const raw = readFileSync(p, "utf-8");
128
+ const parsed = JSON.parse(raw);
129
+ if (parsed && typeof parsed === "object") return parsed as OnboardingState;
130
+ } catch {
131
+ /* treat as missing */
132
+ }
133
+ return null;
134
+ }
135
+
136
+ export function writeOnboardingState(state: OnboardingState, home: string = homedir()): void {
137
+ try {
138
+ const dir = ashlrDir(home);
139
+ mkdirSync(dir, { recursive: true });
140
+ writeFileSync(onboardingStatePath(home), JSON.stringify(state, null, 2) + "\n");
141
+ } catch {
142
+ /* best-effort */
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Mark that the wizard has started (idempotent — only writes if not already started).
148
+ * Call at the beginning of runWizard.
149
+ */
150
+ export function markOnboardingStarted(home: string = homedir()): void {
151
+ const existing = readOnboardingState(home) ?? { started: false, completed: false };
152
+ if (!existing.started) {
153
+ writeOnboardingState({ ...existing, started: true }, home);
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Mark progress within the wizard. lastStep is the 1-based step number just completed.
159
+ */
160
+ export function markOnboardingStep(step: number, home: string = homedir()): void {
161
+ const existing = readOnboardingState(home) ?? { started: true, completed: false };
162
+ writeOnboardingState({ ...existing, started: true, lastStep: step }, home);
163
+ }
164
+
165
+ /**
166
+ * Mark the wizard as fully completed.
167
+ */
168
+ export function markOnboardingCompleted(home: string = homedir()): void {
169
+ writeOnboardingState(
170
+ { started: true, completed: true, completedAt: new Date().toISOString() },
171
+ home,
172
+ );
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Stamp helpers
177
+ // ---------------------------------------------------------------------------
178
+
179
+ export function isFirstRun(home: string = homedir()): boolean {
180
+ return !existsSync(stampPath(home));
181
+ }
182
+
183
+ export function writeStamp(home: string = homedir()): void {
184
+ try {
185
+ const dir = ashlrDir(home);
186
+ mkdirSync(dir, { recursive: true });
187
+ writeFileSync(stampPath(home), new Date().toISOString());
188
+ } catch {
189
+ /* best-effort */
190
+ }
191
+ }
192
+
193
+ export async function deleteStamp(home: string = homedir()): Promise<void> {
194
+ try {
195
+ await unlink(stampPath(home));
196
+ } catch {
197
+ /* already gone */
198
+ }
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Formatting
203
+ // ---------------------------------------------------------------------------
204
+
205
+ function divider(step: number, label: string): string {
206
+ const tag = `STEP ${step}/${TOTAL_STEPS}: ${label}`;
207
+ const rem = Math.max(0, WIDTH - 8 - tag.length);
208
+ return `${"▬".repeat(4)} ${tag} ${"▬".repeat(Math.max(4, rem))}`;
209
+ }
210
+
211
+ function wrap(text: string, width: number = WIDTH): string {
212
+ const words = text.split(" ");
213
+ const lines: string[] = [];
214
+ let line = "";
215
+ for (const word of words) {
216
+ if (line.length === 0) {
217
+ line = word;
218
+ } else if (line.length + 1 + word.length <= width) {
219
+ line += " " + word;
220
+ } else {
221
+ lines.push(line);
222
+ line = word;
223
+ }
224
+ }
225
+ if (line) lines.push(line);
226
+ return lines.join("\n");
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Plugin root
231
+ // ---------------------------------------------------------------------------
232
+
233
+ export function resolvePluginRoot(): string {
234
+ const env = process.env.CLAUDE_PLUGIN_ROOT;
235
+ if (env && existsSync(join(env, ".claude-plugin/plugin.json"))) return env;
236
+ // Walk up from this script's location
237
+ let dir = import.meta.dir;
238
+ for (let i = 0; i < 10; i++) {
239
+ if (existsSync(join(dir, ".claude-plugin/plugin.json"))) return dir;
240
+ const parent = join(dir, "..");
241
+ if (parent === dir) break;
242
+ dir = parent;
243
+ }
244
+ return dirname(import.meta.dir);
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Source file counting
249
+ // ---------------------------------------------------------------------------
250
+
251
+ const SRC_EXTS = new Set([
252
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
253
+ ".py", ".rb", ".go", ".rs", ".java", ".kt",
254
+ ".swift", ".c", ".cpp", ".h", ".cs", ".php",
255
+ ]);
256
+
257
+ const SKIP_DIRS = new Set([
258
+ "node_modules", ".git", "dist", "build", "out",
259
+ ".next", ".nuxt", "coverage", ".ashlrcode",
260
+ ]);
261
+
262
+ export function countSourceFiles(dir: string, maxScan = 500): number {
263
+ let count = 0;
264
+ const queue: string[] = [dir];
265
+ while (queue.length > 0 && count <= maxScan) {
266
+ const current = queue.shift()!;
267
+ let names: string[];
268
+ try {
269
+ names = readdirSync(current) as string[];
270
+ } catch {
271
+ continue;
272
+ }
273
+ for (const name of names) {
274
+ if (SKIP_DIRS.has(name)) continue;
275
+ const full = join(current, name);
276
+ let isDir = false;
277
+ try {
278
+ isDir = statSync(full).isDirectory();
279
+ } catch {
280
+ continue;
281
+ }
282
+ if (isDir) {
283
+ queue.push(full);
284
+ } else {
285
+ const ext = "." + name.split(".").pop()!.toLowerCase();
286
+ if (SRC_EXTS.has(ext)) count++;
287
+ if (count > maxScan) break;
288
+ }
289
+ }
290
+ }
291
+ return count;
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Doctor check (lightweight local subset — no MCP probing)
296
+ // ---------------------------------------------------------------------------
297
+
298
+ export interface DoctorResult {
299
+ pluginRoot: string | null;
300
+ hasDeps: boolean;
301
+ allowlistOk: boolean;
302
+ genomePresent: boolean;
303
+ issues: string[];
304
+ }
305
+
306
+ export async function runDoctorCheck(
307
+ opts: { home?: string; cwd?: string; pluginRoot?: string } = {}
308
+ ): Promise<DoctorResult> {
309
+ const home = opts.home ?? homedir();
310
+ const cwd = opts.cwd ?? process.cwd();
311
+ const pluginRoot = opts.pluginRoot ?? resolvePluginRoot();
312
+
313
+ const issues: string[] = [];
314
+
315
+ // Plugin root
316
+ const rootOk = pluginRoot !== null && existsSync(join(pluginRoot, ".claude-plugin/plugin.json"));
317
+ if (!rootOk) issues.push("Plugin root not found — set CLAUDE_PLUGIN_ROOT");
318
+
319
+ // Dependencies
320
+ const hasDeps = existsSync(join(pluginRoot ?? "", "node_modules/@modelcontextprotocol/sdk"));
321
+ if (!hasDeps) issues.push(`Dependencies missing — run: cd "${pluginRoot}" && bun install`);
322
+
323
+ // Allowlist
324
+ const settingsPath = join(home, ".claude/settings.json");
325
+ let allowlistOk = false;
326
+ try {
327
+ if (existsSync(settingsPath)) {
328
+ const raw = await readFile(settingsPath, "utf8");
329
+ const s = JSON.parse(raw) as { permissions?: { allow?: string[] } };
330
+ const allow = s?.permissions?.allow ?? [];
331
+ allowlistOk = allow.some((e: string) => /^mcp__ashlr(-|__)/.test(e) || e === "mcp__ashlr-*");
332
+ }
333
+ } catch {
334
+ /* treat as not present */
335
+ }
336
+
337
+ // Genome
338
+ const genomePresent = existsSync(join(cwd, ".ashlrcode", "genome"));
339
+
340
+ return { pluginRoot, hasDeps, allowlistOk, genomePresent, issues };
341
+ }
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // Live demo: find a readable source file
345
+ // ---------------------------------------------------------------------------
346
+
347
+ export function findDemoFile(cwd: string): string | null {
348
+ const candidates = [
349
+ join(cwd, "scripts/session-greet.ts"),
350
+ join(cwd, "scripts/doctor.ts"),
351
+ join(cwd, "hooks/session-start.ts"),
352
+ ];
353
+ for (const p of candidates) {
354
+ if (existsSync(p)) return p;
355
+ }
356
+ // Fall back: first .ts file found (non-test, non-node_modules)
357
+ const queue: string[] = [cwd];
358
+ while (queue.length > 0) {
359
+ const dir = queue.shift()!;
360
+ let names: string[];
361
+ try {
362
+ names = readdirSync(dir) as string[];
363
+ } catch {
364
+ continue;
365
+ }
366
+ for (const name of names) {
367
+ if (SKIP_DIRS.has(name)) continue;
368
+ if (name.startsWith("__tests__")) continue;
369
+ const full = join(dir, name);
370
+ let isDir = false;
371
+ try {
372
+ isDir = statSync(full).isDirectory();
373
+ } catch {
374
+ continue;
375
+ }
376
+ if (isDir) {
377
+ queue.push(full);
378
+ } else if (name.endsWith(".ts") && !name.endsWith(".test.ts")) {
379
+ return full;
380
+ }
381
+ }
382
+ }
383
+ return null;
384
+ }
385
+
386
+ export function fileSizeBytes(p: string): number {
387
+ try {
388
+ return statSync(p).size;
389
+ } catch {
390
+ return 0;
391
+ }
392
+ }
393
+
394
+ // Approximate read payload: ashlr__read returns head+tail ~25% of original
395
+ // for large files. We model this without actually calling the MCP tool so the
396
+ // wizard script is self-contained and can run without MCP active. The real
397
+ // live-demo path (runRealReadDemo) supersedes this estimate whenever the
398
+ // efficiency-server is reachable; the estimate is the fallback.
399
+ export function estimateReadPayload(sizeBytes: number): number {
400
+ if (sizeBytes <= 4096) return sizeBytes; // small file: full content
401
+ // snipCompact: ~30 head lines + ~20 tail lines ≈ 50 lines * ~60 chars = 3000
402
+ // plus elision marker. Conservative estimate: 40% of original, min 3KB.
403
+ return Math.max(3000, Math.round(sizeBytes * 0.35));
404
+ }
405
+
406
+ /** Result of the real live demo — either a measured payload size or an error. */
407
+ export interface RealReadDemoResult {
408
+ /** Bytes returned by ashlr__read. null when the real call failed. */
409
+ payloadBytes: number | null;
410
+ /** First ~240 chars of the compact payload for display. null on failure. */
411
+ sample: string | null;
412
+ /** Non-fatal failure reason (logged as [ASHLR_WARN], fall back to estimate). */
413
+ error: string | null;
414
+ }
415
+
416
+ /**
417
+ * Invoke the real ashlr__read MCP tool via a subprocess against the
418
+ * efficiency-server. We shell out instead of importing the server module
419
+ * directly because the wizard runs as a plain script and shouldn't pull the
420
+ * full MCP stack into memory. The server is invoked with a throwaway stdio
421
+ * transport, handed exactly one tools/call request, and its stdout parsed.
422
+ *
423
+ * Falls back cleanly when:
424
+ * - The plugin root can't be resolved.
425
+ * - The efficiency-server script is missing.
426
+ * - The spawn times out (12 s ceiling — long enough for cold Bun spawns
427
+ * on slower laptops, still keeps the wizard well under 60 s overall).
428
+ * - The JSON response is malformed.
429
+ *
430
+ * On any failure returns `error` set and the caller renders the fake
431
+ * estimate so onboarding still tells a coherent story.
432
+ */
433
+ export async function runRealReadDemo(
434
+ demoFile: string,
435
+ opts: { pluginRoot?: string; timeoutMs?: number } = {},
436
+ ): Promise<RealReadDemoResult> {
437
+ const timeoutMs = opts.timeoutMs ?? 12000;
438
+ const pluginRoot = opts.pluginRoot ?? resolvePluginRoot();
439
+ const serverPath = join(pluginRoot, "servers/efficiency-server.ts");
440
+ if (!existsSync(serverPath)) {
441
+ return { payloadBytes: null, sample: null, error: "efficiency-server not found" };
442
+ }
443
+
444
+ // Minimal JSON-RPC request: initialize then tools/call → ashlr__read.
445
+ // The server follows the MCP stdio protocol so we write framed lines.
446
+ // We pipe a single request and close stdin; server exits via EOF.
447
+ const initReq = {
448
+ jsonrpc: "2.0", id: 1, method: "initialize",
449
+ params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "ashlr-wizard", version: "0" } },
450
+ };
451
+ const toolReq = {
452
+ jsonrpc: "2.0", id: 2, method: "tools/call",
453
+ params: { name: "ashlr__read", arguments: { path: demoFile } },
454
+ };
455
+ const payload = JSON.stringify(initReq) + "\n" + JSON.stringify(toolReq) + "\n";
456
+
457
+ let childStdout = "";
458
+ try {
459
+ const { spawn } = await import("child_process");
460
+ const child = spawn("bun", ["run", serverPath], {
461
+ stdio: ["pipe", "pipe", "pipe"],
462
+ env: { ...process.env, ASHLR_WIZARD_DEMO: "1" },
463
+ });
464
+ child.stdout.on("data", (chunk: Buffer) => { childStdout += chunk.toString("utf8"); });
465
+ child.stdin.write(payload);
466
+ child.stdin.end();
467
+
468
+ await new Promise<void>((resolve) => {
469
+ const t = setTimeout(() => { try { child.kill("SIGTERM"); } catch { /* ignore */ } resolve(); }, timeoutMs);
470
+ child.on("exit", () => { clearTimeout(t); resolve(); });
471
+ child.on("error", () => { clearTimeout(t); resolve(); });
472
+ });
473
+ } catch (err) {
474
+ return { payloadBytes: null, sample: null, error: err instanceof Error ? err.message : String(err) };
475
+ }
476
+
477
+ // Parse the stream for the id=2 response.
478
+ type ReadResp = { result?: { content?: Array<{ type: string; text?: string }> } };
479
+ let readResponse: ReadResp | null = null;
480
+ for (const raw of childStdout.split("\n")) {
481
+ const line = raw.trim();
482
+ if (!line || line[0] !== "{") continue;
483
+ try {
484
+ const msg = JSON.parse(line) as { id?: number } & ReadResp;
485
+ if (msg.id === 2 && msg.result) {
486
+ readResponse = msg;
487
+ break;
488
+ }
489
+ } catch {
490
+ /* skip non-JSON frame */
491
+ }
492
+ }
493
+ if (!readResponse?.result?.content) {
494
+ return { payloadBytes: null, sample: null, error: "no tool response" };
495
+ }
496
+ const text = (readResponse.result.content[0]?.text) ?? "";
497
+ if (text.length === 0) {
498
+ return { payloadBytes: null, sample: null, error: "empty payload" };
499
+ }
500
+ const sample = text.slice(0, 240);
501
+ return { payloadBytes: Buffer.byteLength(text, "utf8"), sample, error: null };
502
+ }
503
+
504
+ // ---------------------------------------------------------------------------
505
+ // Interactive confirmation
506
+ // ---------------------------------------------------------------------------
507
+
508
+ export async function askYesNo(
509
+ question: string,
510
+ defaultYes: boolean = true,
511
+ timeoutMs: number = YES_TIMEOUT_MS,
512
+ interactive: boolean = true,
513
+ ): Promise<boolean> {
514
+ if (!interactive) return defaultYes;
515
+
516
+ const hint = defaultYes ? "Y/n" : "y/N";
517
+ process.stdout.write(`${question} [${hint}]: `);
518
+
519
+ return new Promise<boolean>((resolve) => {
520
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
521
+ let settled = false;
522
+
523
+ const timer = setTimeout(() => {
524
+ if (!settled) {
525
+ settled = true;
526
+ rl.close();
527
+ process.stdout.write(`(timeout — defaulting to ${defaultYes ? "yes" : "no"})\n`);
528
+ resolve(defaultYes);
529
+ }
530
+ }, timeoutMs);
531
+
532
+ rl.once("line", (line) => {
533
+ if (!settled) {
534
+ settled = true;
535
+ clearTimeout(timer);
536
+ rl.close();
537
+ const trimmed = line.trim().toLowerCase();
538
+ if (trimmed === "") resolve(defaultYes);
539
+ else resolve(trimmed === "y" || trimmed === "yes");
540
+ }
541
+ });
542
+
543
+ rl.once("close", () => {
544
+ if (!settled) {
545
+ settled = true;
546
+ clearTimeout(timer);
547
+ resolve(defaultYes);
548
+ }
549
+ });
550
+ });
551
+ }
552
+
553
+ /**
554
+ * Yes/no prompt with a visible per-second countdown before auto-accept.
555
+ *
556
+ * Used for the permissions grant so the user can't miss that a grant is
557
+ * about to happen — the previous 5-second silent timeout shipped users
558
+ * a "ashlr auto-approved without asking me" experience. Typing y/Enter
559
+ * accepts early, n rejects, timeout = accept.
560
+ *
561
+ * The countdown prints one line per second using a carriage return so the
562
+ * terminal only ever shows the current count, not a vertical stack. When
563
+ * stdin is consumed the line is overwritten with a final status message.
564
+ */
565
+ export async function askYesNoWithCountdown(
566
+ question: string,
567
+ totalMs: number = PERMISSIONS_COUNTDOWN_MS,
568
+ interactive: boolean = true,
569
+ ): Promise<boolean> {
570
+ if (!interactive) return true;
571
+
572
+ const totalSec = Math.max(1, Math.round(totalMs / 1000));
573
+ process.stdout.write(`${question} [Y/n]\n`);
574
+ process.stdout.write(`(Auto-accepting in ${totalSec}... press y/Enter to accept, n to deny)\n`);
575
+
576
+ return new Promise<boolean>((resolve) => {
577
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
578
+ let settled = false;
579
+ let remaining = totalSec;
580
+
581
+ const finish = (accepted: boolean, msg?: string): void => {
582
+ if (settled) return;
583
+ settled = true;
584
+ clearInterval(tick);
585
+ rl.close();
586
+ // Clear the countdown line and print the final disposition so the user
587
+ // sees an unambiguous "what happened" line in the transcript.
588
+ process.stdout.write("\r\x1b[2K");
589
+ if (msg) process.stdout.write(msg + "\n");
590
+ resolve(accepted);
591
+ };
592
+
593
+ const tick = setInterval(() => {
594
+ remaining--;
595
+ if (remaining <= 0) {
596
+ finish(true, "(timeout — auto-accepting permissions)");
597
+ return;
598
+ }
599
+ // \r + CSI 2K clears the current line so counters overwrite cleanly.
600
+ process.stdout.write(`\rAuto-accepting in ${remaining}... `);
601
+ }, 1000);
602
+
603
+ rl.once("line", (line) => {
604
+ const trimmed = line.trim().toLowerCase();
605
+ if (trimmed === "n" || trimmed === "no") {
606
+ finish(false, "(declined)");
607
+ } else {
608
+ finish(true, "(accepted)");
609
+ }
610
+ });
611
+
612
+ rl.once("close", () => {
613
+ if (!settled) finish(true, "(stream closed — auto-accepting)");
614
+ });
615
+ });
616
+ }
617
+
618
+ // ---------------------------------------------------------------------------
619
+ // Section renderers
620
+ // ---------------------------------------------------------------------------
621
+
622
+ function out(s: string): void {
623
+ process.stdout.write(s + "\n");
624
+ }
625
+
626
+ function blank(): void {
627
+ out("");
628
+ }
629
+
630
+ // Step 0: greeting
631
+ export function renderGreeting(): void {
632
+ blank();
633
+ out("▬".repeat(WIDTH));
634
+ out(wrap("You just installed ashlr. Let's show you what it does."));
635
+ out(wrap(
636
+ "This wizard takes about 60 seconds. Press Enter to accept " +
637
+ "defaults at each prompt."
638
+ ));
639
+ out("▬".repeat(WIDTH));
640
+ blank();
641
+ out("ashlr in 60 seconds:");
642
+ out(" 1. /ashlr-doctor — verify install (10s)");
643
+ out(" 2. status line install — see savings as you code (15s, restart required)");
644
+ out(" 3. /ashlr-genome-init — boost grep savings 40% → 84% (30s)");
645
+ blank();
646
+ out(wrap(
647
+ "Core MCP tools (all return compressed output to save tokens):"
648
+ ));
649
+ out(" ashlr__read — smart head+tail file reader");
650
+ out(" ashlr__grep — filtered search with line limits");
651
+ out(" ashlr__edit — compressed edit acknowledgements");
652
+ out(" ashlr__diff — compact diff output");
653
+ out(" ashlr__bash — bash with summarized output");
654
+ blank();
655
+ out(wrap(
656
+ "New in this version:"
657
+ ));
658
+ out(" ashlr__websearch — compressed web search results");
659
+ out(" ashlr__task_list — compressed task list output");
660
+ out(" ashlr__task_get — compressed task detail output");
661
+ out(" ashlr__notebook_edit — compressed notebook cell edits");
662
+ out(" ashlr__write — compressed file write acknowledgements");
663
+ blank();
664
+ }
665
+
666
+ // Step 1: doctor check
667
+ export function renderDoctorOutput(result: DoctorResult): void {
668
+ out(divider(1, "Doctor check"));
669
+ blank();
670
+ out(`Plugin root: ${result.pluginRoot ?? "(not found)"}`);
671
+ out(`Dependencies: ${result.hasDeps ? "installed" : "MISSING"}`);
672
+ out(`Allowlist: ${result.allowlistOk ? "auto-approved" : "not configured"}`);
673
+ out(`Genome: ${result.genomePresent ? "present" : "not initialized"}`);
674
+ blank();
675
+ if (result.issues.length === 0) {
676
+ out("[ASHLR_OK] doctor-passed");
677
+ } else {
678
+ for (const issue of result.issues) {
679
+ out(`[ASHLR_WARN] ${issue}`);
680
+ }
681
+ }
682
+ blank();
683
+ }
684
+
685
+ // Step 2: permissions
686
+ export function renderPermissionsSection(allowlistOk: boolean): void {
687
+ out(divider(2, "Permissions"));
688
+ blank();
689
+ if (allowlistOk) {
690
+ out(wrap(
691
+ "Your ~/.claude/settings.json already auto-approves all ashlr " +
692
+ "tools. No action needed."
693
+ ));
694
+ out("[ASHLR_OK] permissions-ok");
695
+ } else {
696
+ out(wrap(
697
+ "~/.claude/settings.json does not auto-approve ashlr tools. " +
698
+ "Without this, Claude Code prompts you for every ashlr__read, " +
699
+ "ashlr__grep, and ashlr__edit call — dozens of prompts per session."
700
+ ));
701
+ blank();
702
+ out("[ASHLR_PROMPT: Auto-approve all ashlr tools? (y/n, default y)]");
703
+ }
704
+ blank();
705
+ }
706
+
707
+ // Step 3: status-line offer
708
+ export function renderStatusLineSection(statusLineInstalled: boolean): void {
709
+ out(divider(3, "Status line"));
710
+ blank();
711
+ if (statusLineInstalled) {
712
+ out(wrap(
713
+ "Status line already configured — live savings counter is active."
714
+ ));
715
+ out("[ASHLR_OK] status-line-present");
716
+ blank();
717
+ return;
718
+ }
719
+ out(wrap(
720
+ "Show real-time savings in your status bar — recommended. " +
721
+ "The status line displays a live counter of tokens and cost saved " +
722
+ "at the bottom of every Claude Code session."
723
+ ));
724
+ blank();
725
+ out("[ASHLR_PROMPT: Install status line? (Y/n, default y)]");
726
+ blank();
727
+ }
728
+
729
+ /**
730
+ * End-of-wizard "restart enforcement" callout. The LOUD block addresses
731
+ * UX-audit cliff #2: users finished the wizard, ignored the soft callout,
732
+ * ran their next tool call, and saw built-in Read/Edit/Grep fire instead
733
+ * of ashlr's tools — making the plugin look broken when the user just
734
+ * didn't quit Claude Code.
735
+ *
736
+ * Side effect: writes ~/.ashlr/restart-required as a hint file. The
737
+ * SessionStart hook reads it on the next session start; if the pid matches
738
+ * (same shell, no restart) it warns the user, otherwise it silently clears
739
+ * the hint.
740
+ */
741
+ export function renderRestartCallout(home: string = homedir()): void {
742
+ blank();
743
+ out("═══════════════════════════════════════════════════════════════════");
744
+ out(" RESTART REQUIRED — your next tool call will NOT use ashlr until");
745
+ out(" you fully quit and reopen Claude Code (not just close the window).");
746
+ out("");
747
+ out(" Plugin tools register on session start; without a restart, the");
748
+ out(" built-in Read/Edit/Grep run instead and you'll see no savings.");
749
+ out("═══════════════════════════════════════════════════════════════════");
750
+ blank();
751
+ writeRestartRequired(home);
752
+ }
753
+
754
+ // Step 4: live demo
755
+ export function renderLiveDemoSection(
756
+ demoFile: string | null,
757
+ sizeBytes: number,
758
+ payloadBytes: number,
759
+ opts: { real?: boolean; sample?: string | null; error?: string | null } = {},
760
+ ): void {
761
+ out(divider(4, "Live demo"));
762
+ blank();
763
+ if (!demoFile) {
764
+ out(wrap(
765
+ "No source files found in the current directory to demo. " +
766
+ "Skipping read comparison."
767
+ ));
768
+ out("[ASHLR_OK] demo-skipped");
769
+ blank();
770
+ return;
771
+ }
772
+
773
+ const pct = sizeBytes > 0 ? Math.round((payloadBytes / sizeBytes) * 100) : 100;
774
+ const saved = Math.max(0, sizeBytes - payloadBytes);
775
+ const shortName = demoFile.replace(homedir(), "~");
776
+ const readLabel = opts.real ? "ashlr__read:" : "ashlr__read:";
777
+ const realityTag = opts.real ? " (live)" : " (estimate)";
778
+
779
+ out(`File: ${shortName}`);
780
+ out(`Disk size: ${sizeBytes.toLocaleString()} bytes`);
781
+ out(`${readLabel} ${payloadBytes.toLocaleString()} bytes returned (~${pct}% of file)${realityTag}`);
782
+ out(`Saved: ${saved.toLocaleString()} bytes not sent to the model`);
783
+ blank();
784
+ if (opts.real && opts.sample) {
785
+ out(wrap(
786
+ "Live output (first 240 chars of the compact payload):"
787
+ ));
788
+ blank();
789
+ // Display the sample inside a faux fenced-preview block using ▸ so the
790
+ // transcript stays plain-text pipeable.
791
+ for (const line of opts.sample.split("\n").slice(0, 8)) {
792
+ out("▸ " + line.replace(/\s+$/, ""));
793
+ }
794
+ blank();
795
+ } else if (opts.error) {
796
+ out(wrap(
797
+ `Live read failed (${opts.error}); showing an estimated payload size ` +
798
+ "based on the snipCompact model instead."
799
+ ));
800
+ blank();
801
+ }
802
+ out(wrap(
803
+ "ashlr__read returns a snipCompact view: full head + full tail + " +
804
+ "elided middle. The model sees the structure and entry/exit points " +
805
+ "of every file without ingesting the full body."
806
+ ));
807
+ out("[ASHLR_OK] demo-complete");
808
+ blank();
809
+ }
810
+
811
+ // Step 5: genome offer
812
+ //
813
+ // `aha` is an optional inline before/after token demo used to make the value
814
+ // statement concrete: a real file from the user's repo with the
815
+ // "without genome / with genome" token counts side-by-side. Computed in
816
+ // runWizard from data we already have (demoFile + fileSizeBytes) so the
817
+ // render path stays sub-1-second and doesn't fire any new I/O. Skip the demo
818
+ // (pass undefined) when no demo file was found — the one-liner value
819
+ // statement is still shown.
820
+ export interface GenomeAhaDemo {
821
+ filePath: string;
822
+ sizeBytes: number;
823
+ /** Token estimate without genome (raw file, ~4 chars/token). */
824
+ withoutGenomeTokens: number;
825
+ /** Token estimate with genome (snipCompact ~20%, floor at 200 tok). */
826
+ withGenomeTokens: number;
827
+ }
828
+
829
+ /**
830
+ * Precompute the genome-aha before/after demo from numbers the wizard already
831
+ * has. Pure function, sub-millisecond. Returns null when no demo file exists
832
+ * or the file is too small to meaningfully demo against (< 4KB).
833
+ *
834
+ * Token model: ~4 chars per token (industry rule of thumb). snipCompact
835
+ * typically returns ~20% of the original for files > a few KB, so we
836
+ * multiply by 0.2 with a 200-token floor (snipCompact always retains
837
+ * head+tail markers).
838
+ */
839
+ export function computeGenomeAhaDemo(
840
+ demoFile: string | null,
841
+ sizeBytes: number,
842
+ ): GenomeAhaDemo | null {
843
+ if (!demoFile) return null;
844
+ if (sizeBytes < 4096) return null; // too small to be a convincing demo
845
+ const withoutGenomeTokens = Math.round(sizeBytes / 4);
846
+ const withGenomeTokens = Math.max(200, Math.round((sizeBytes * 0.2) / 4));
847
+ return { filePath: demoFile, sizeBytes, withoutGenomeTokens, withGenomeTokens };
848
+ }
849
+
850
+ export function renderGenomeSection(
851
+ srcFileCount: number,
852
+ genomePresent: boolean,
853
+ aha?: GenomeAhaDemo | null,
854
+ ): void {
855
+ out(divider(5, "Genome"));
856
+ blank();
857
+ if (genomePresent) {
858
+ out(wrap("Genome already initialized in this project. You're all set."));
859
+ out("[ASHLR_OK] genome-present");
860
+ blank();
861
+ return;
862
+ }
863
+
864
+ // Value statement — always shown, even on small repos. Users skip genome
865
+ // init because the prompt never explains the magnitude of the win. The
866
+ // "~84%" number is the measured grep token-savings ceiling across our
867
+ // benchmark repos (see CHANGELOG v1.23 "honest headline -57%" → genome
868
+ // path can reach ~84% on warm caches with dense embeddings).
869
+ out(wrap(
870
+ "Genome unlocks ~84% token savings on grep across this codebase by " +
871
+ "pre-indexing symbol definitions — Claude retrieves targeted " +
872
+ "excerpts instead of raw file content."
873
+ ));
874
+ blank();
875
+
876
+ // Inline before/after demo when we have a real file to point at. Numbers
877
+ // are precomputed (see computeGenomeAhaDemo) so render stays sub-1s.
878
+ if (aha) {
879
+ const shortName = aha.filePath.replace(homedir(), "~");
880
+ out(`Example: ${shortName} (${aha.sizeBytes.toLocaleString()} bytes)`);
881
+ out(` Without genome: ~${aha.withoutGenomeTokens.toLocaleString()} tokens`);
882
+ out(` With genome: ~${aha.withGenomeTokens.toLocaleString()} tokens`);
883
+ blank();
884
+ }
885
+
886
+ if (srcFileCount < 10) {
887
+ // Small/greenfield repos used to be silently skipped here, which meant
888
+ // brand-new projects never saw ashlr's strongest feature. Offer it
889
+ // anyway with a soft caveat — the default flips to "no" so the user
890
+ // has to opt in, and the genome is cheap to nuke if abandoned (just
891
+ // delete .ashlrcode/genome/).
892
+ out(
893
+ wrap(
894
+ `Only ${srcFileCount} source file${srcFileCount === 1 ? "" : "s"} found ` +
895
+ "in the current directory. Savings compound with repo size, so the " +
896
+ "win on a small/greenfield project is modest right now — but " +
897
+ "initializing one seeds the index for when the project grows."
898
+ )
899
+ );
900
+ blank();
901
+ out("[ASHLR_PROMPT: Initialize a genome anyway? (y/n, default n)]");
902
+ blank();
903
+ return;
904
+ }
905
+
906
+ out(
907
+ wrap(
908
+ `Found ${srcFileCount} source files. Initializing the genome now is a ` +
909
+ "one-time ~30 s indexing pass; every subsequent grep call benefits."
910
+ )
911
+ );
912
+ blank();
913
+ out("[ASHLR_PROMPT: Initialize a genome for this project? (y/n, default y)]");
914
+ blank();
915
+ }
916
+
917
+ // ---------------------------------------------------------------------------
918
+ // Telemetry opt-in (Step 6) — config: { "telemetry": "opt-in" | "off" }
919
+ //
920
+ // See docs/telemetry.md for the full contract: ASHLR_TELEMETRY=on/off (env)
921
+ // or ~/.ashlr/config.json { "telemetry": "opt-in" | "off" }. Strictly opt-in;
922
+ // default is OFF. We persist via the same ~/.ashlr/config.json file the
923
+ // Ollama step writes to (preserve sibling keys).
924
+ // ---------------------------------------------------------------------------
925
+
926
+ export interface TelemetryOfferState {
927
+ /** Telemetry already set to opt-in via config or env — skip the prompt. */
928
+ alreadyOptedIn: boolean;
929
+ /** Telemetry already set to "off" via config or env — skip the prompt. */
930
+ alreadyOptedOut: boolean;
931
+ /** Config file we'd write to if the user accepts. */
932
+ configPath: string;
933
+ }
934
+
935
+ /**
936
+ * Inspect ~/.ashlr/config.json + ASHLR_TELEMETRY env to decide whether to
937
+ * surface the consent prompt. We respect any prior explicit choice so the
938
+ * wizard never overrides an existing opt-out and never re-prompts an
939
+ * already-opted-in user.
940
+ */
941
+ export function detectTelemetryState(
942
+ home: string = homedir(),
943
+ env: NodeJS.ProcessEnv = process.env,
944
+ ): TelemetryOfferState {
945
+ const envVal = (env.ASHLR_TELEMETRY ?? "").toLowerCase().trim();
946
+ const envOn = envVal === "on" || envVal === "1";
947
+ const envOff = envVal === "off" || envVal === "0";
948
+
949
+ let cfg: Record<string, unknown> = {};
950
+ try {
951
+ const path = join(home, ".ashlr", "config.json");
952
+ if (existsSync(path)) {
953
+ const raw = readFileSync(path, "utf-8");
954
+ const parsed = JSON.parse(raw);
955
+ if (parsed && typeof parsed === "object") cfg = parsed as Record<string, unknown>;
956
+ }
957
+ } catch {
958
+ /* treat as empty */
959
+ }
960
+
961
+ const cfgOptIn = cfg.telemetry === "opt-in";
962
+ const cfgOff = cfg.telemetry === "off";
963
+
964
+ return {
965
+ alreadyOptedIn: envOn || cfgOptIn,
966
+ alreadyOptedOut: envOff || cfgOff,
967
+ configPath: join(home, ".ashlr", "config.json"),
968
+ };
969
+ }
970
+
971
+ /**
972
+ * Persist telemetry consent to ~/.ashlr/config.json — preserving sibling
973
+ * keys (e.g. ASHLR_EMBED_URL from the Ollama step). Best-effort: failures
974
+ * are logged via [ASHLR_WARN] and don't block the wizard.
975
+ */
976
+ export async function writeTelemetryChoice(
977
+ choice: "opt-in" | "off",
978
+ home: string = homedir(),
979
+ ): Promise<{ ok: boolean; path: string; error?: string }> {
980
+ const path = join(home, ".ashlr", "config.json");
981
+ try {
982
+ mkdirSync(ashlrDir(home), { recursive: true });
983
+ let existing: Record<string, unknown> = {};
984
+ if (existsSync(path)) {
985
+ try {
986
+ const raw = await readFile(path, "utf8");
987
+ const parsed = JSON.parse(raw);
988
+ if (parsed && typeof parsed === "object") existing = parsed as Record<string, unknown>;
989
+ } catch {
990
+ /* overwrite corrupt */
991
+ }
992
+ }
993
+ existing["telemetry"] = choice;
994
+ writeFileSync(path, JSON.stringify(existing, null, 2) + "\n");
995
+ return { ok: true, path };
996
+ } catch (err) {
997
+ return { ok: false, path, error: err instanceof Error ? err.message : String(err) };
998
+ }
999
+ }
1000
+
1001
+ // Step 6: telemetry consent
1002
+ export function renderTelemetrySection(state: TelemetryOfferState): void {
1003
+ out(divider(6, "Telemetry"));
1004
+ blank();
1005
+ if (state.alreadyOptedIn) {
1006
+ out(wrap(
1007
+ "Telemetry opt-in already active. To disable: ASHLR_TELEMETRY=off or " +
1008
+ "~/.ashlr/config.json { \"telemetry\": \"off\" }."
1009
+ ));
1010
+ out("[ASHLR_OK] telemetry-already-opted-in");
1011
+ blank();
1012
+ return;
1013
+ }
1014
+ if (state.alreadyOptedOut) {
1015
+ out(wrap(
1016
+ "Telemetry already disabled. Nothing is collected. See " +
1017
+ "docs/telemetry.md to re-enable any time."
1018
+ ));
1019
+ out("[ASHLR_OK] telemetry-already-opted-out");
1020
+ blank();
1021
+ return;
1022
+ }
1023
+ out(wrap(
1024
+ "Share anonymized session stats with the ashlr team to improve the " +
1025
+ "plugin? We collect tool-shape metrics only: tool name, raw/compact " +
1026
+ "byte counts, fall-back flags, duration. Never paths, never content, " +
1027
+ "never identifiers. Full schema: docs/telemetry.md."
1028
+ ));
1029
+ blank();
1030
+ out(wrap(
1031
+ "Default is no. You can opt out anytime by setting ASHLR_TELEMETRY=off " +
1032
+ "or running /ashlr-settings telemetry off."
1033
+ ));
1034
+ blank();
1035
+ out("[ASHLR_PROMPT: Share anonymized session stats? (y/N, default n)]");
1036
+ blank();
1037
+ }
1038
+
1039
+ /**
1040
+ * Outcome of step 5 Ollama detection so the wizard can decide whether to
1041
+ * prompt, skip, or surface an install hint.
1042
+ */
1043
+ export interface OllamaOfferState {
1044
+ /** Already configured via env: offer is skipped. */
1045
+ alreadyConfigured: boolean;
1046
+ /** `which ollama` resolved to a binary on PATH. */
1047
+ installed: boolean;
1048
+ /** Config file we'd write to if the user accepts. */
1049
+ configPath: string;
1050
+ }
1051
+
1052
+ export function detectOllamaState(
1053
+ home: string = homedir(),
1054
+ env: NodeJS.ProcessEnv = process.env,
1055
+ ): OllamaOfferState {
1056
+ const alreadyConfigured =
1057
+ !!(env.ASHLR_EMBED_URL && env.ASHLR_EMBED_URL.trim().length > 0) ||
1058
+ !!(env.OLLAMA_HOST && env.OLLAMA_HOST.trim().length > 0);
1059
+ let installed = false;
1060
+ try {
1061
+ // spawnSync `which` (POSIX) / `where` (Windows) synchronously — cheap and
1062
+ // avoids pulling in `bun`. We don't care about the resolved path, just the
1063
+ // exit code. A 5 s timeout prevents hangs on slow Windows CI runners.
1064
+ const { spawnSync } = require("child_process") as typeof import("child_process");
1065
+ const cmd = process.platform === "win32" ? "where" : "which";
1066
+ const res = spawnSync(cmd, ["ollama"], { stdio: "ignore", timeout: 5_000 });
1067
+ installed = res.status === 0;
1068
+ } catch {
1069
+ installed = false;
1070
+ }
1071
+ return {
1072
+ alreadyConfigured,
1073
+ installed,
1074
+ configPath: join(home, ".ashlr", "config.json"),
1075
+ };
1076
+ }
1077
+
1078
+ /**
1079
+ * Persist ASHLR_EMBED_URL pointing at the local Ollama daemon. We write a
1080
+ * plain JSON blob the CLI bootstrap reads at startup so the flag survives
1081
+ * across sessions without the user editing their shell rc. Best-effort:
1082
+ * failures are logged and skipped so onboarding keeps flowing.
1083
+ */
1084
+ export async function enableOllamaEmbeddings(
1085
+ home: string = homedir(),
1086
+ ): Promise<{ ok: boolean; path: string; error?: string }> {
1087
+ const path = join(home, ".ashlr", "config.json");
1088
+ try {
1089
+ mkdirSync(ashlrDir(home), { recursive: true });
1090
+ let existing: Record<string, unknown> = {};
1091
+ if (existsSync(path)) {
1092
+ try {
1093
+ const raw = await readFile(path, "utf8");
1094
+ const parsed = JSON.parse(raw);
1095
+ if (parsed && typeof parsed === "object") existing = parsed as Record<string, unknown>;
1096
+ } catch {
1097
+ /* overwrite corrupt */
1098
+ }
1099
+ }
1100
+ existing["ASHLR_EMBED_URL"] = "http://localhost:11434/api/embeddings";
1101
+ writeFileSync(path, JSON.stringify(existing, null, 2) + "\n");
1102
+ return { ok: true, path };
1103
+ } catch (err) {
1104
+ return { ok: false, path, error: err instanceof Error ? err.message : String(err) };
1105
+ }
1106
+ }
1107
+
1108
+ /**
1109
+ * Detect whether `gh auth status` succeeds (GitHub CLI is logged in).
1110
+ * Returns true when gh is installed and authenticated, false otherwise.
1111
+ * Never throws.
1112
+ */
1113
+ export function detectGhAuthState(): boolean {
1114
+ try {
1115
+ const { spawnSync } = require("child_process") as typeof import("child_process");
1116
+ // A 10 s timeout prevents hangs on Windows CI when gh is installed but
1117
+ // the auth check blocks on network (no internet access on hosted runners).
1118
+ const res = spawnSync("gh", ["auth", "status"], { stdio: "ignore", timeout: 10_000 });
1119
+ return res.status === 0;
1120
+ } catch {
1121
+ return false;
1122
+ }
1123
+ }
1124
+
1125
+ // Step 6: Ollama offer (dense embeddings)
1126
+ export function renderOllamaSection(state: OllamaOfferState): void {
1127
+ out(divider(7, "Embeddings"));
1128
+ blank();
1129
+ if (state.alreadyConfigured) {
1130
+ out(wrap(
1131
+ "Embeddings endpoint already configured via ASHLR_EMBED_URL or " +
1132
+ "OLLAMA_HOST. Skipping."
1133
+ ));
1134
+ out("[ASHLR_OK] ollama-already-configured");
1135
+ blank();
1136
+ return;
1137
+ }
1138
+ out(wrap(
1139
+ "ashlr can route grep queries through local Ollama for dense " +
1140
+ "embeddings — ~10x better semantic recall than BM25 on larger repos, " +
1141
+ "100% local, zero cost."
1142
+ ));
1143
+ blank();
1144
+ if (state.installed) {
1145
+ out(wrap(
1146
+ "Ollama detected on PATH. We can wire it up by writing " +
1147
+ "ASHLR_EMBED_URL=http://localhost:11434/api/embeddings to " +
1148
+ `${state.configPath}.`
1149
+ ));
1150
+ blank();
1151
+ out("[ASHLR_PROMPT: Enable dense embeddings via Ollama? (y/n, default y)]");
1152
+ } else {
1153
+ out(wrap(
1154
+ "Ollama not detected. Install from https://ollama.com (free, " +
1155
+ "~150MB), then re-run this wizard or execute /ashlr-ollama-setup."
1156
+ ));
1157
+ out("[ASHLR_OK] ollama-not-installed");
1158
+ }
1159
+ blank();
1160
+ }
1161
+
1162
+ // Step 7: pro teaser
1163
+ //
1164
+ // Renders different content based on tier:
1165
+ // - Free users: 30-second team-genome value prop + inline "Try Pro?" prompt.
1166
+ // - Pro/Team users: team-invite tip only.
1167
+ //
1168
+ // Returns the conversion outcome for telemetry: "y" | "n" | "skip".
1169
+ export async function renderProTeaser(
1170
+ opts: { isPro?: boolean; interactive?: boolean } = {},
1171
+ ): Promise<"y" | "n" | "skip"> {
1172
+ out(divider(8, "Pro plan"));
1173
+ blank();
1174
+
1175
+ if (opts.isPro) {
1176
+ // Already Pro — show team-invite tip instead.
1177
+ out(wrap(
1178
+ "You're on Pro. Run /ashlr-team-invite to bring your teammates aboard " +
1179
+ "and share your genome across the whole team."
1180
+ ));
1181
+ blank();
1182
+ return "skip";
1183
+ }
1184
+
1185
+ // Free tier — team-genome value prop.
1186
+ out(wrap(
1187
+ "Free ashlr builds a genome that lives on your machine. Pro lifts it " +
1188
+ "into a shared encrypted layer so every teammate learns from everyone " +
1189
+ "else's sessions — new engineers onboard into context, not silence."
1190
+ ));
1191
+ blank();
1192
+ out("Pro also includes:");
1193
+ out(" - Weekly team digest of genome learnings and streak milestones");
1194
+ out(" - Cross-machine savings sync and /ashlr-dashboard history");
1195
+ out(" - Cloud LLM summarizer (no local Ollama needed)");
1196
+ blank();
1197
+ out("Pro: $12/mo · Pro Team: $24/user/mo (min 3) · 7-day free trial, no card.");
1198
+ blank();
1199
+
1200
+ // Inline conversion: prompt unless non-interactive.
1201
+ if (opts.interactive === false) {
1202
+ out("Run /ashlr-upgrade to start a 7-day free Pro trial in 90 seconds.");
1203
+ blank();
1204
+ return "skip";
1205
+ }
1206
+
1207
+ out("[ASHLR_PROMPT: Try Pro free for 7 days? (y/N)]");
1208
+ const answer = await askYesNo(
1209
+ "Try Pro free for 7 days?",
1210
+ false, // default: no
1211
+ YES_TIMEOUT_MS,
1212
+ true, // interactive
1213
+ );
1214
+
1215
+ if (answer) {
1216
+ blank();
1217
+ out(wrap(
1218
+ "Starting the upgrade flow. Launching /ashlr-upgrade — this takes " +
1219
+ "about 90 seconds."
1220
+ ));
1221
+ blank();
1222
+ out("[ASHLR_ACTION: run /ashlr-upgrade]");
1223
+ blank();
1224
+ return "y";
1225
+ } else {
1226
+ blank();
1227
+ out("No worries. Run /ashlr-upgrade any time to start your free trial.");
1228
+ blank();
1229
+ return "n";
1230
+ }
1231
+ }
1232
+
1233
+ // Step 8: final message
1234
+ export function renderFinalMessage(): void {
1235
+ out(divider(9, "Done"));
1236
+ blank();
1237
+ out("▬".repeat(WIDTH));
1238
+ out(wrap(
1239
+ "Run /ashlr-savings anytime to see running totals. The status " +
1240
+ "line at the bottom of your terminal shows live counters."
1241
+ ));
1242
+ out("Happy coding.");
1243
+ out("▬".repeat(WIDTH));
1244
+ blank();
1245
+ }
1246
+
1247
+ // ---------------------------------------------------------------------------
1248
+ // Main wizard orchestrator
1249
+ // ---------------------------------------------------------------------------
1250
+
1251
+ export interface SkippedStep {
1252
+ step: string;
1253
+ reason: string;
1254
+ /** What to run to activate this feature. */
1255
+ hint: string;
1256
+ }
1257
+
1258
+ export interface WizardOpts {
1259
+ interactive: boolean;
1260
+ home?: string;
1261
+ cwd?: string;
1262
+ pluginRoot?: string;
1263
+ /** Override permission installer call (for tests) */
1264
+ installPermsFn?: () => Promise<void>;
1265
+ /** Override status-line installer call (for tests) */
1266
+ installStatusLineFn?: () => Promise<void>;
1267
+ /** Override genome init call (for tests) */
1268
+ genomeInitFn?: () => Promise<void>;
1269
+ /**
1270
+ * Override the real `ashlr__read` demo subprocess (for tests). When set
1271
+ * the wizard calls this instead of spawning the MCP server.
1272
+ */
1273
+ realReadDemoFn?: (demoFile: string) => Promise<RealReadDemoResult>;
1274
+ /**
1275
+ * Override the Ollama config writer (for tests) so the real HOME isn't
1276
+ * mutated and the test can observe the call.
1277
+ */
1278
+ enableOllamaFn?: () => Promise<{ ok: boolean; path: string; error?: string }>;
1279
+ }
1280
+
1281
+ export async function runWizard(opts: WizardOpts): Promise<void> {
1282
+ const home = opts.home ?? homedir();
1283
+ const cwd = opts.cwd ?? process.cwd();
1284
+ const interactive = opts.interactive;
1285
+
1286
+ // Telemetry helper — respects ASHLR_SESSION_LOG=0 kill switch via logEvent.
1287
+ async function emitWizardStep(
1288
+ step_name: string,
1289
+ outcome: "completed" | "skipped" | "error",
1290
+ ): Promise<void> {
1291
+ try {
1292
+ const { logEvent } = await import("../servers/_events.ts");
1293
+ await logEvent("wizard_step", {
1294
+ tool: "onboarding-wizard",
1295
+ extra: { step_name, outcome, ts: Date.now() },
1296
+ });
1297
+ } catch {
1298
+ /* best-effort — never block the wizard */
1299
+ }
1300
+ }
1301
+
1302
+ // Track wizard steps that were silently skipped so we can surface them
1303
+ // in a summary at the end. Each entry has a step name, a skip reason,
1304
+ // and a one-liner on what to run to activate the feature later.
1305
+ const skipped: SkippedStep[] = [];
1306
+
1307
+ // Record that wizard has started so session-start banner can show "finish setup".
1308
+ markOnboardingStarted(home);
1309
+
1310
+ // --- Greeting ---
1311
+ renderGreeting();
1312
+ await emitWizardStep("intro", "completed");
1313
+
1314
+ // --- Step 1: Doctor ---
1315
+ const doctor = await runDoctorCheck({ home, cwd, pluginRoot: opts.pluginRoot });
1316
+ renderDoctorOutput(doctor);
1317
+ markOnboardingStep(1, home);
1318
+ await emitWizardStep("doctor", doctor.issues.length === 0 ? "completed" : "error");
1319
+
1320
+ // --- Step 2: Permissions ---
1321
+ renderPermissionsSection(doctor.allowlistOk);
1322
+ markOnboardingStep(2, home);
1323
+ if (!doctor.allowlistOk) {
1324
+ // 30-second visible countdown so the grant can't feel silent. Swapped
1325
+ // from the generic 5s askYesNo because users reported missing the fact
1326
+ // that permissions were granted on their behalf.
1327
+ const doInstall = await askYesNoWithCountdown(
1328
+ "Auto-approve all ashlr tools?",
1329
+ PERMISSIONS_COUNTDOWN_MS,
1330
+ interactive,
1331
+ );
1332
+ if (doInstall) {
1333
+ if (opts.installPermsFn) {
1334
+ await opts.installPermsFn();
1335
+ } else {
1336
+ const pluginRoot = opts.pluginRoot ?? resolvePluginRoot();
1337
+ const { installPermissions } = await import("./install-permissions.ts");
1338
+ try {
1339
+ const result = await installPermissions({ pluginRoot });
1340
+ if (result.added.length > 0) {
1341
+ out(
1342
+ wrap(
1343
+ `Added ${result.added.length} permission entr${result.added.length === 1 ? "y" : "ies"}.`
1344
+ )
1345
+ );
1346
+ } else {
1347
+ out("All ashlr permissions already present.");
1348
+ }
1349
+ } catch {
1350
+ out("[ASHLR_WARN] Permission install failed — run /ashlr-allow manually.");
1351
+ }
1352
+ }
1353
+ renderRestartCallout();
1354
+ await emitWizardStep("permissions", "completed");
1355
+ } else {
1356
+ out(wrap(
1357
+ "Skipped. Run /ashlr-allow any time to add permissions."
1358
+ ));
1359
+ skipped.push({
1360
+ step: "Permissions",
1361
+ reason: "ashlr tools require per-call approval (increases friction)",
1362
+ hint: "run /ashlr-allow to auto-approve all ashlr tools",
1363
+ });
1364
+ await emitWizardStep("permissions", "skipped");
1365
+ }
1366
+ blank();
1367
+ } else {
1368
+ await emitWizardStep("permissions", "completed");
1369
+ }
1370
+
1371
+ // --- Step 3: Status line ---
1372
+ // Check if status line is already configured in ~/.claude/settings.json.
1373
+ let statusLineInstalled = false;
1374
+ try {
1375
+ const settingsPath = join(home, ".claude", "settings.json");
1376
+ if (existsSync(settingsPath)) {
1377
+ const raw = readFileSync(settingsPath, "utf-8");
1378
+ const s = JSON.parse(raw) as { statusLine?: unknown };
1379
+ const sl = s?.statusLine as { command?: string } | undefined;
1380
+ statusLineInstalled = !!(sl?.command && sl.command.includes("savings-status-line"));
1381
+ }
1382
+ } catch {
1383
+ /* treat as not installed */
1384
+ }
1385
+
1386
+ renderStatusLineSection(statusLineInstalled);
1387
+ markOnboardingStep(3, home);
1388
+
1389
+ if (!statusLineInstalled) {
1390
+ // Default ON — opt-out with "n". User presses Enter or "y" to install.
1391
+ const doStatusLine = await askYesNo(
1392
+ "Install status line?",
1393
+ true, // default yes
1394
+ YES_TIMEOUT_MS,
1395
+ interactive,
1396
+ );
1397
+ if (doStatusLine) {
1398
+ if (opts.installStatusLineFn) {
1399
+ await opts.installStatusLineFn();
1400
+ } else {
1401
+ const pluginRoot = opts.pluginRoot ?? resolvePluginRoot();
1402
+ const { spawnSync } = await import("child_process");
1403
+ const res = spawnSync(
1404
+ "bun",
1405
+ ["run", join(pluginRoot, "scripts/install-status-line.ts")],
1406
+ { stdio: ["ignore", "pipe", "pipe"], timeout: 15_000 },
1407
+ );
1408
+ if (res.status === 0) {
1409
+ out("Status line installed.");
1410
+ } else {
1411
+ out("[ASHLR_WARN] Status line install failed — run /ashlr-allow manually.");
1412
+ }
1413
+ }
1414
+ renderRestartCallout();
1415
+ await emitWizardStep("status_line", "completed");
1416
+ } else {
1417
+ out(wrap("Skipped. Run /ashlr-status-line any time to install."));
1418
+ skipped.push({
1419
+ step: "Status line",
1420
+ reason: "live savings counter not active",
1421
+ hint: "run bun scripts/install-status-line.ts, then restart Claude Code",
1422
+ });
1423
+ await emitWizardStep("status_line", "skipped");
1424
+ }
1425
+ blank();
1426
+ } else {
1427
+ await emitWizardStep("status_line", "completed");
1428
+ }
1429
+
1430
+ // --- Step 4: Live demo ---
1431
+ // Attempt the real ashlr__read call first so users see actual bytes returned.
1432
+ // Fall back to the snipCompact estimate only when spawn fails or no .ts file.
1433
+ const demoFile = findDemoFile(cwd);
1434
+ const sizeBytes = demoFile ? fileSizeBytes(demoFile) : 0;
1435
+ let payloadBytes = estimateReadPayload(sizeBytes);
1436
+ let demoReal = false;
1437
+ let demoSample: string | null = null;
1438
+ let demoError: string | null = null;
1439
+ if (demoFile) {
1440
+ // Print a status line BEFORE the spawn so users on slow laptops don't
1441
+ // sit in front of a blank terminal while Bun cold-starts the
1442
+ // efficiency-server. Without this hint a 5–10s pause looks like a hang.
1443
+ process.stdout.write("Warming up efficiency-server (cold-start can take a few seconds)...\n");
1444
+ try {
1445
+ const realFn = opts.realReadDemoFn ?? ((p: string) => runRealReadDemo(p, { pluginRoot: opts.pluginRoot }));
1446
+ const real = await realFn(demoFile);
1447
+ if (real.error || real.payloadBytes === null) {
1448
+ demoError = real.error ?? "unknown";
1449
+ } else {
1450
+ payloadBytes = real.payloadBytes;
1451
+ demoReal = true;
1452
+ demoSample = real.sample;
1453
+ }
1454
+ } catch (err) {
1455
+ demoError = err instanceof Error ? err.message : String(err);
1456
+ }
1457
+ }
1458
+ renderLiveDemoSection(demoFile, sizeBytes, payloadBytes, {
1459
+ real: demoReal,
1460
+ sample: demoSample,
1461
+ error: demoError,
1462
+ });
1463
+
1464
+ // --- Step 5: Genome offer ---
1465
+ const srcFileCount = countSourceFiles(cwd);
1466
+ const ahaDemo = computeGenomeAhaDemo(demoFile, sizeBytes);
1467
+ renderGenomeSection(srcFileCount, doctor.genomePresent, ahaDemo);
1468
+
1469
+ if (!doctor.genomePresent) {
1470
+ // Default flips from "yes" on healthy-size repos to "no" on small ones —
1471
+ // small/greenfield projects can opt in but won't get genomes forced on
1472
+ // them. renderGenomeSection() prints a tailored prompt for each case.
1473
+ const defaultYes = srcFileCount >= 10;
1474
+ const doGenome = await askYesNo(
1475
+ defaultYes ? "Initialize a genome?" : "Initialize a genome anyway?",
1476
+ defaultYes,
1477
+ YES_TIMEOUT_MS,
1478
+ interactive,
1479
+ );
1480
+ if (doGenome) {
1481
+ if (opts.genomeInitFn) {
1482
+ await opts.genomeInitFn();
1483
+ } else {
1484
+ const pluginRoot = opts.pluginRoot ?? resolvePluginRoot();
1485
+ out(wrap(
1486
+ "Running /ashlr-genome-init... " +
1487
+ "(this may take 15-30 seconds on large repos)"
1488
+ ));
1489
+ const { spawnSync } = await import("child_process");
1490
+ const res = spawnSync(
1491
+ "bun",
1492
+ ["run", join(pluginRoot, "scripts/genome-init.ts"), "--dir", cwd, "--minimal"],
1493
+ { stdio: ["ignore", "pipe", "pipe"], timeout: 60_000 },
1494
+ );
1495
+ if (res.status === 0) {
1496
+ out("Genome initialized.");
1497
+ } else {
1498
+ out("[ASHLR_WARN] Genome init failed — run /ashlr-genome-init manually.");
1499
+ }
1500
+ }
1501
+ await emitWizardStep("genome_init", "completed");
1502
+ } else {
1503
+ out(wrap("Skipped. Run /ashlr-genome-init any time to index this project."));
1504
+ await emitWizardStep("genome_init", "skipped");
1505
+ }
1506
+ blank();
1507
+ } else {
1508
+ await emitWizardStep("genome_init", "completed");
1509
+ }
1510
+
1511
+ // --- Step 6: Telemetry opt-in ---
1512
+ // Strictly opt-in. Default OFF. Respect any prior explicit choice — never
1513
+ // re-prompt an already-decided user. See docs/telemetry.md for contract.
1514
+ markOnboardingStep(6, home);
1515
+ const telemetryState = detectTelemetryState(home);
1516
+ renderTelemetrySection(telemetryState);
1517
+ if (!telemetryState.alreadyOptedIn && !telemetryState.alreadyOptedOut) {
1518
+ const optIn = await askYesNo(
1519
+ "Share anonymized session stats?",
1520
+ false, // default: NO (be conservative on consent)
1521
+ YES_TIMEOUT_MS,
1522
+ interactive,
1523
+ );
1524
+ const choice: "opt-in" | "off" = optIn ? "opt-in" : "off";
1525
+ const res = await writeTelemetryChoice(choice, home);
1526
+ if (!res.ok) {
1527
+ out(`[ASHLR_WARN] Could not write telemetry choice — ${res.error ?? "unknown error"}`);
1528
+ } else if (optIn) {
1529
+ out(wrap(`Telemetry enabled. Wrote { "telemetry": "opt-in" } to ${res.path}.`));
1530
+ } else {
1531
+ out(wrap("Telemetry stays off. Nothing will be collected."));
1532
+ }
1533
+ await emitWizardStep("telemetry", optIn ? "completed" : "skipped");
1534
+ blank();
1535
+ } else {
1536
+ await emitWizardStep("telemetry", "completed");
1537
+ }
1538
+
1539
+ // --- Step 7: Ollama / dense embeddings offer ---
1540
+ markOnboardingStep(7, home);
1541
+ const ollamaState = detectOllamaState(home);
1542
+ renderOllamaSection(ollamaState);
1543
+ if (!ollamaState.alreadyConfigured && !ollamaState.installed) {
1544
+ // Ollama not installed — track as a skipped step so the summary
1545
+ // surfaces it with an install hint.
1546
+ skipped.push({
1547
+ step: "Dense embeddings (Ollama)",
1548
+ reason: "Ollama not found on PATH",
1549
+ hint: "brew install ollama (or visit https://ollama.com), then re-run /ashlr-start",
1550
+ });
1551
+ }
1552
+ if (!ollamaState.alreadyConfigured && ollamaState.installed) {
1553
+ const doEnable = await askYesNo(
1554
+ "Enable dense embeddings via Ollama?",
1555
+ true,
1556
+ YES_TIMEOUT_MS,
1557
+ interactive,
1558
+ );
1559
+ if (doEnable) {
1560
+ const enableFn = opts.enableOllamaFn ?? (() => enableOllamaEmbeddings(home));
1561
+ const res = await enableFn();
1562
+ if (res.ok) {
1563
+ out(wrap(`Wrote ASHLR_EMBED_URL to ${res.path}. Restart Claude Code to pick it up.`));
1564
+ } else {
1565
+ out(`[ASHLR_WARN] Could not write Ollama config — ${res.error ?? "unknown error"}`);
1566
+ }
1567
+ } else {
1568
+ out(wrap("Skipped. Run /ashlr-ollama-setup any time to revisit."));
1569
+ skipped.push({
1570
+ step: "Dense embeddings (Ollama)",
1571
+ reason: "declined during wizard",
1572
+ hint: "run /ashlr-ollama-setup any time to enable",
1573
+ });
1574
+ }
1575
+ blank();
1576
+ }
1577
+
1578
+ // Check GitHub CLI auth status and track as skipped if not logged in.
1579
+ const ghAuthed = detectGhAuthState();
1580
+ if (!ghAuthed) {
1581
+ skipped.push({
1582
+ step: "GitHub integration",
1583
+ reason: "gh CLI not authenticated",
1584
+ hint: "run: gh auth login",
1585
+ });
1586
+ }
1587
+
1588
+ // --- Step 8: Pro teaser ---
1589
+ markOnboardingStep(8, home);
1590
+ // Detect tier via sync best-effort check (non-blocking; wizard never waits on network).
1591
+ let isPro = false;
1592
+ try {
1593
+ const { isProSync } = await import("../servers/_pro.ts");
1594
+ isPro = isProSync(home);
1595
+ } catch {
1596
+ /* best-effort — treat as free on any error */
1597
+ }
1598
+ const proTeaserOutcome = await renderProTeaser({ isPro, interactive });
1599
+ // Emit conversion telemetry: tool_call with tool_name "wizard_pro_pitch" or
1600
+ // "wizard_pro_team_pitch" depending on whether user is already Pro.
1601
+ try {
1602
+ const { logEvent } = await import("../servers/_events.ts");
1603
+ const toolName = isPro ? "wizard_pro_team_pitch" : "wizard_pro_pitch";
1604
+ await logEvent("tool_call", {
1605
+ tool: toolName,
1606
+ extra: { outcome: proTeaserOutcome, ts: Date.now() },
1607
+ });
1608
+ } catch {
1609
+ /* best-effort */
1610
+ }
1611
+ await emitWizardStep("pro_teaser", "completed");
1612
+
1613
+ // --- Step 9: Final ---
1614
+ // Always print the loud RESTART REQUIRED callout at the end — addresses
1615
+ // UX-audit cliff #2 where users finish the wizard, ignore the soft hint,
1616
+ // and find their next tool call falls through to built-in Read/Edit/Grep.
1617
+ // Also writes ~/.ashlr/restart-required so the next SessionStart hook can
1618
+ // detect a missed restart and re-warn (same shell pid → user did not quit).
1619
+ renderRestartCallout(home);
1620
+ renderFinalMessage();
1621
+ markOnboardingCompleted(home);
1622
+ // WAD-D lead indicator: stamp the onboarding-completion timestamp on
1623
+ // stats.json so the next daily heartbeat can ship it. Best-effort —
1624
+ // never break the wizard.
1625
+ try {
1626
+ const { markOnboardingComplete } = await import("../servers/_stats.ts");
1627
+ await markOnboardingComplete();
1628
+ } catch {
1629
+ /* best-effort */
1630
+ }
1631
+ await emitWizardStep("complete", "completed");
1632
+
1633
+ // Skipped-features summary: print a "Heads up" block whenever any
1634
+ // wizard steps were silently bypassed. Each item gets a one-liner on
1635
+ // what to run later so users aren't left wondering.
1636
+ if (skipped.length > 0) {
1637
+ out("▬".repeat(WIDTH));
1638
+ out("Heads up — these features aren't active yet:");
1639
+ blank();
1640
+ for (const s of skipped) {
1641
+ out(` • ${s.step}: ${s.reason}`);
1642
+ out(` → ${s.hint}`);
1643
+ }
1644
+ blank();
1645
+ out("▬".repeat(WIDTH));
1646
+ blank();
1647
+ }
1648
+ }
1649
+
1650
+ // ---------------------------------------------------------------------------
1651
+ // --reset mode
1652
+ // ---------------------------------------------------------------------------
1653
+
1654
+ async function handleReset(home: string): Promise<void> {
1655
+ await deleteStamp(home);
1656
+ process.stdout.write(
1657
+ `Stamp deleted: ${stampPath(home)}\n` +
1658
+ "Next session will trigger the onboarding wizard again.\n",
1659
+ );
1660
+ }
1661
+
1662
+ // ---------------------------------------------------------------------------
1663
+ // CLI entry
1664
+ // ---------------------------------------------------------------------------
1665
+
1666
+ async function main(): Promise<number> {
1667
+ const args = process.argv.slice(2);
1668
+ const noInteractive = args.includes("--no-interactive");
1669
+ const reset = args.includes("--reset");
1670
+ const home = homedir();
1671
+
1672
+ if (reset) {
1673
+ await handleReset(home);
1674
+ return 0;
1675
+ }
1676
+
1677
+ try {
1678
+ await runWizard({ interactive: !noInteractive, home });
1679
+ return 0;
1680
+ } catch (err) {
1681
+ process.stderr.write(
1682
+ `ashlr onboarding-wizard: fatal error — ${err instanceof Error ? err.message : String(err)}\n`,
1683
+ );
1684
+ return 1;
1685
+ }
1686
+ }
1687
+
1688
+ if (import.meta.main) {
1689
+ const code = await main();
1690
+ process.exit(code);
1691
+ }