@turri-dev/cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (315) hide show
  1. package/.pi/themes/turri-dark.json +80 -0
  2. package/.pi/themes/turri-light.json +82 -0
  3. package/LICENSE +21 -0
  4. package/README.md +272 -2
  5. package/assets/jellyfish/dark-00.png +0 -0
  6. package/assets/jellyfish/dark-01.png +0 -0
  7. package/assets/jellyfish/dark-02.png +0 -0
  8. package/assets/jellyfish/dark-03.png +0 -0
  9. package/assets/jellyfish/dark-04.png +0 -0
  10. package/assets/jellyfish/dark-05.png +0 -0
  11. package/assets/jellyfish/dark-06.png +0 -0
  12. package/assets/jellyfish/dark-07.png +0 -0
  13. package/assets/jellyfish/dark-08.png +0 -0
  14. package/assets/jellyfish/dark-09.png +0 -0
  15. package/assets/jellyfish/dark-10.png +0 -0
  16. package/assets/jellyfish/dark-11.png +0 -0
  17. package/assets/jellyfish/dark-12.png +0 -0
  18. package/assets/jellyfish/dark-13.png +0 -0
  19. package/assets/jellyfish/dark-14.png +0 -0
  20. package/assets/jellyfish/fallback.txt +252 -0
  21. package/assets/jellyfish/light-00.png +0 -0
  22. package/assets/jellyfish/light-01.png +0 -0
  23. package/assets/jellyfish/light-02.png +0 -0
  24. package/assets/jellyfish/light-03.png +0 -0
  25. package/assets/jellyfish/light-04.png +0 -0
  26. package/assets/jellyfish/light-05.png +0 -0
  27. package/assets/jellyfish/light-06.png +0 -0
  28. package/assets/jellyfish/light-07.png +0 -0
  29. package/assets/jellyfish/light-08.png +0 -0
  30. package/assets/jellyfish/light-09.png +0 -0
  31. package/assets/jellyfish/light-10.png +0 -0
  32. package/assets/jellyfish/light-11.png +0 -0
  33. package/assets/jellyfish/light-12.png +0 -0
  34. package/assets/jellyfish/light-13.png +0 -0
  35. package/assets/jellyfish/light-14.png +0 -0
  36. package/bin/turri.mjs +29 -0
  37. package/dist/ask.js +149 -0
  38. package/dist/brand.js +50 -0
  39. package/dist/btw.js +174 -0
  40. package/dist/chat-models.js +37 -0
  41. package/dist/chat-skills.js +18 -0
  42. package/dist/cli.js +178 -0
  43. package/dist/config-command.js +197 -0
  44. package/dist/config-edit.js +367 -0
  45. package/dist/config.js +489 -0
  46. package/dist/conform-cli.js +29 -0
  47. package/dist/cultivate-cli.js +110 -0
  48. package/dist/drift-cli.js +25 -0
  49. package/dist/environment.js +70 -0
  50. package/dist/extension.js +2372 -0
  51. package/dist/genome.js +301 -0
  52. package/dist/graph.js +246 -0
  53. package/dist/identity.js +35 -0
  54. package/dist/init-config.js +511 -0
  55. package/dist/judge.js +62 -0
  56. package/dist/lineage-cli.js +13 -0
  57. package/dist/loop-cli.js +132 -0
  58. package/dist/loop-tools.js +225 -0
  59. package/dist/mature-tools.js +193 -0
  60. package/dist/model-check.js +117 -0
  61. package/dist/module-report.js +9 -0
  62. package/dist/pi-launch.js +31 -0
  63. package/dist/pi.js +813 -0
  64. package/dist/plain/check.js +76 -0
  65. package/dist/plain/concepts.js +213 -0
  66. package/dist/plain/errors.js +43 -0
  67. package/dist/plain/files.js +57 -0
  68. package/dist/plain/highlight.js +195 -0
  69. package/dist/plain/index.js +15 -0
  70. package/dist/plain/liquid.js +129 -0
  71. package/dist/plain/markdown.js +208 -0
  72. package/dist/plain/parser.js +752 -0
  73. package/dist/plain/plain.tmLanguage.json +124 -0
  74. package/dist/plain/spec.js +291 -0
  75. package/dist/plain/stdlib.js +44 -0
  76. package/dist/question-dialog.js +125 -0
  77. package/dist/questioning.js +109 -0
  78. package/dist/quiet.js +125 -0
  79. package/dist/regenerate/activity.js +187 -0
  80. package/dist/regenerate/agent-env.js +190 -0
  81. package/dist/regenerate/agent-shell.js +114 -0
  82. package/dist/regenerate/arbiter.js +133 -0
  83. package/dist/regenerate/audit.js +203 -0
  84. package/dist/regenerate/cases.js +83 -0
  85. package/dist/regenerate/change-view.js +102 -0
  86. package/dist/regenerate/choose-conformance.js +117 -0
  87. package/dist/regenerate/chunk-plan.js +625 -0
  88. package/dist/regenerate/codebase.js +75 -0
  89. package/dist/regenerate/command-check.js +312 -0
  90. package/dist/regenerate/compaction.js +20 -0
  91. package/dist/regenerate/confine.js +310 -0
  92. package/dist/regenerate/conform.js +133 -0
  93. package/dist/regenerate/conformance-agents.js +955 -0
  94. package/dist/regenerate/conformance.js +936 -0
  95. package/dist/regenerate/conversation-writes.js +100 -0
  96. package/dist/regenerate/credentials.js +88 -0
  97. package/dist/regenerate/debug-log.js +228 -0
  98. package/dist/regenerate/debugger.js +90 -0
  99. package/dist/regenerate/delivery.js +19 -0
  100. package/dist/regenerate/diagnose-job.js +40 -0
  101. package/dist/regenerate/diagnose.js +81 -0
  102. package/dist/regenerate/diff-view.js +240 -0
  103. package/dist/regenerate/drift.js +194 -0
  104. package/dist/regenerate/engine.js +118 -0
  105. package/dist/regenerate/environment-error.js +220 -0
  106. package/dist/regenerate/job-log.js +102 -0
  107. package/dist/regenerate/job-panel.js +336 -0
  108. package/dist/regenerate/jobs-tree.js +300 -0
  109. package/dist/regenerate/jobs.js +427 -0
  110. package/dist/regenerate/lanes.js +32 -0
  111. package/dist/regenerate/lineage.js +384 -0
  112. package/dist/regenerate/loop.js +1435 -0
  113. package/dist/regenerate/masking.js +95 -0
  114. package/dist/regenerate/notes.js +397 -0
  115. package/dist/regenerate/os-sandbox.js +243 -0
  116. package/dist/regenerate/paths.js +165 -0
  117. package/dist/regenerate/plain-palette.js +83 -0
  118. package/dist/regenerate/prompt.js +704 -0
  119. package/dist/regenerate/records.js +294 -0
  120. package/dist/regenerate/regenerate.js +598 -0
  121. package/dist/regenerate/regression.js +181 -0
  122. package/dist/regenerate/release.js +262 -0
  123. package/dist/regenerate/requires.js +241 -0
  124. package/dist/regenerate/resources.js +164 -0
  125. package/dist/regenerate/self-check.js +111 -0
  126. package/dist/regenerate/services.js +155 -0
  127. package/dist/regenerate/shell.js +319 -0
  128. package/dist/regenerate/side-panel.js +278 -0
  129. package/dist/regenerate/spec-map-view.js +574 -0
  130. package/dist/regenerate/spec-map.js +561 -0
  131. package/dist/regenerate/spec-steps.js +527 -0
  132. package/dist/regenerate/state.js +10 -0
  133. package/dist/regenerate/steer.js +91 -0
  134. package/dist/regenerate/steering.js +119 -0
  135. package/dist/regenerate/stop-report.js +161 -0
  136. package/dist/regenerate/submit-work.js +71 -0
  137. package/dist/regenerate/test-kinds.js +124 -0
  138. package/dist/regenerate/test-scripts.js +695 -0
  139. package/dist/regenerate/testing.js +406 -0
  140. package/dist/regenerate/transcript-view.js +1 -0
  141. package/dist/regenerate/unit-gate.js +20 -0
  142. package/dist/regenerate/unit-review.js +347 -0
  143. package/dist/regenerate/web.js +256 -0
  144. package/dist/regenerate/workspace.js +99 -0
  145. package/dist/regenerate-cli.js +68 -0
  146. package/dist/release-agent.js +114 -0
  147. package/dist/release-cli.js +131 -0
  148. package/dist/release-tools.js +148 -0
  149. package/dist/review/live.js +165 -0
  150. package/dist/rules.js +47 -0
  151. package/dist/self-invocation.js +110 -0
  152. package/dist/setup.js +245 -0
  153. package/dist/skill-progress.js +78 -0
  154. package/dist/skill.js +149 -0
  155. package/dist/skills.js +74 -0
  156. package/dist/spec-diff-cli.js +37 -0
  157. package/dist/spec-tools.js +155 -0
  158. package/dist/stack.js +158 -0
  159. package/dist/stacks.js +97 -0
  160. package/dist/stdlib.js +100 -0
  161. package/dist/terminal-images.js +46 -0
  162. package/dist/tools.js +242 -0
  163. package/dist/validate-cli.js +52 -0
  164. package/docs/README.md +58 -0
  165. package/docs/authoring-plain.md +245 -0
  166. package/docs/config.md +353 -0
  167. package/docs/deploy.md +109 -0
  168. package/docs/development.md +75 -0
  169. package/docs/headless.md +148 -0
  170. package/docs/images/how-turri-works.svg +50 -0
  171. package/docs/images/turri-banner.png +0 -0
  172. package/docs/installation.md +118 -0
  173. package/docs/parser.md +108 -0
  174. package/docs/regenerating.md +1116 -0
  175. package/docs/sandbox.md +233 -0
  176. package/docs/skills-and-gates.md +254 -0
  177. package/docs/theming.md +76 -0
  178. package/docs/using.md +454 -0
  179. package/genome/rules/bullet-continuation.md +69 -0
  180. package/genome/rules/concise-specs-examples.md +431 -0
  181. package/genome/rules/concise-specs-integration-examples.md +270 -0
  182. package/genome/rules/concise-specs.md +80 -0
  183. package/genome/rules/definitions.md +107 -0
  184. package/genome/rules/examples/integration-embedded-testing/run_conformance_tests_java.sh +163 -0
  185. package/genome/rules/examples/integration-embedded-testing/run_unittests_java.sh +51 -0
  186. package/genome/rules/exported-concepts.md +39 -0
  187. package/genome/rules/func-specs.md +129 -0
  188. package/genome/rules/impl-reqs.md +68 -0
  189. package/genome/rules/import-modules.md +48 -0
  190. package/genome/rules/integration-embedded-testing.md +276 -0
  191. package/genome/rules/integration-embedded.md +171 -0
  192. package/genome/rules/integration-standalone.md +135 -0
  193. package/genome/rules/integrations.md +137 -0
  194. package/genome/rules/linked-resources.md +69 -0
  195. package/genome/rules/module-structure.md +105 -0
  196. package/genome/rules/project-model.md +88 -0
  197. package/genome/rules/required-concepts.md +44 -0
  198. package/genome/rules/requires-modules.md +80 -0
  199. package/genome/rules/stdlib.md +73 -0
  200. package/genome/rules/test-reqs.md +57 -0
  201. package/genome/skills/add-acceptance-test/SKILL.md +100 -0
  202. package/genome/skills/add-concept/SKILL.md +71 -0
  203. package/genome/skills/add-functional-spec/SKILL.md +84 -0
  204. package/genome/skills/add-functional-specs/SKILL.md +118 -0
  205. package/genome/skills/add-implementation-requirement/SKILL.md +86 -0
  206. package/genome/skills/add-resource/SKILL.md +105 -0
  207. package/genome/skills/add-template/SKILL.md +64 -0
  208. package/genome/skills/add-test-requirement/SKILL.md +73 -0
  209. package/genome/skills/analyze-2-func-specs/SKILL.md +101 -0
  210. package/genome/skills/analyze-func-specs/SKILL.md +123 -0
  211. package/genome/skills/analyze-if-func-spec-too-complex/SKILL.md +211 -0
  212. package/genome/skills/break-down-func-spec/SKILL.md +205 -0
  213. package/genome/skills/brood/SKILL.md +229 -0
  214. package/genome/skills/brood/references/intent.md +35 -0
  215. package/genome/skills/brood/references/product.md +101 -0
  216. package/genome/skills/brood/references/stack.md +61 -0
  217. package/genome/skills/brood/references/testing.md +56 -0
  218. package/genome/skills/bud/SKILL.md +148 -0
  219. package/genome/skills/bud/references/checklist.md +44 -0
  220. package/genome/skills/bud/references/intent.md +30 -0
  221. package/genome/skills/check-plain-env/SKILL.md +285 -0
  222. package/genome/skills/consolidate-concepts/SKILL.md +192 -0
  223. package/genome/skills/create-import-module/SKILL.md +104 -0
  224. package/genome/skills/create-requires-module/SKILL.md +109 -0
  225. package/genome/skills/debug-specs/SKILL.md +188 -0
  226. package/genome/skills/implement-conformance-testing-script/SKILL.md +238 -0
  227. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_cypress.ps1 +337 -0
  228. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_golang.ps1 +111 -0
  229. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_java.sh +136 -0
  230. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_python.ps1 +103 -0
  231. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_python.sh +150 -0
  232. package/genome/skills/implement-unit-testing-script/SKILL.md +160 -0
  233. package/genome/skills/implement-unit-testing-script/assets/run_unittests_flutter.ps1 +87 -0
  234. package/genome/skills/implement-unit-testing-script/assets/run_unittests_golang.ps1 +73 -0
  235. package/genome/skills/implement-unit-testing-script/assets/run_unittests_java.sh +72 -0
  236. package/genome/skills/implement-unit-testing-script/assets/run_unittests_python.ps1 +81 -0
  237. package/genome/skills/implement-unit-testing-script/assets/run_unittests_python.sh +98 -0
  238. package/genome/skills/implement-unit-testing-script/assets/run_unittests_react.ps1 +87 -0
  239. package/genome/skills/init-plain-project/SKILL.md +117 -0
  240. package/genome/skills/init-plain-project/references/checklist.md +30 -0
  241. package/genome/skills/mature/SKILL.md +126 -0
  242. package/genome/skills/refactor-module/SKILL.md +196 -0
  243. package/genome/skills/resolve-section-ownership/SKILL.md +64 -0
  244. package/genome/skills/resolve-spec-conflict/SKILL.md +96 -0
  245. package/genome/skills/spawn/SKILL.md +163 -0
  246. package/package.json +68 -4
  247. package/stdlib/backend/common.plain +25 -0
  248. package/stdlib/backend/conformance/common.plain +12 -0
  249. package/stdlib/backend/conformance/go-gin.plain +9 -0
  250. package/stdlib/backend/conformance/go-nethttp.plain +9 -0
  251. package/stdlib/backend/conformance/java-springboot.plain +9 -0
  252. package/stdlib/backend/conformance/python-django.plain +9 -0
  253. package/stdlib/backend/conformance/python-fastapi.plain +9 -0
  254. package/stdlib/backend/conformance/python-flask.plain +9 -0
  255. package/stdlib/backend/conformance/rust-axum.plain +9 -0
  256. package/stdlib/backend/conformance/typescript-express.plain +9 -0
  257. package/stdlib/backend/conformance/typescript-fastify.plain +9 -0
  258. package/stdlib/backend/conformance/typescript-nestjs.plain +9 -0
  259. package/stdlib/backend/go-gin.plain +18 -0
  260. package/stdlib/backend/go-nethttp.plain +18 -0
  261. package/stdlib/backend/java-springboot.plain +19 -0
  262. package/stdlib/backend/python-django.plain +19 -0
  263. package/stdlib/backend/python-fastapi.plain +23 -0
  264. package/stdlib/backend/python-flask.plain +23 -0
  265. package/stdlib/backend/rust-axum.plain +17 -0
  266. package/stdlib/backend/typescript-express.plain +19 -0
  267. package/stdlib/backend/typescript-fastify.plain +19 -0
  268. package/stdlib/backend/typescript-nestjs.plain +19 -0
  269. package/stdlib/cli/common.plain +17 -0
  270. package/stdlib/cli/conformance/common.plain +11 -0
  271. package/stdlib/cli/conformance/go-cobra.plain +9 -0
  272. package/stdlib/cli/conformance/go-flag.plain +9 -0
  273. package/stdlib/cli/conformance/java-picocli.plain +9 -0
  274. package/stdlib/cli/conformance/python-argparse.plain +9 -0
  275. package/stdlib/cli/conformance/python-click.plain +9 -0
  276. package/stdlib/cli/conformance/python-typer.plain +9 -0
  277. package/stdlib/cli/conformance/rust-clap.plain +9 -0
  278. package/stdlib/cli/conformance/typescript-commander.plain +9 -0
  279. package/stdlib/cli/go-cobra.plain +14 -0
  280. package/stdlib/cli/go-flag.plain +14 -0
  281. package/stdlib/cli/java-picocli.plain +15 -0
  282. package/stdlib/cli/python-argparse.plain +18 -0
  283. package/stdlib/cli/python-click.plain +16 -0
  284. package/stdlib/cli/python-typer.plain +16 -0
  285. package/stdlib/cli/rust-clap.plain +14 -0
  286. package/stdlib/cli/typescript-commander.plain +17 -0
  287. package/stdlib/frontend/angular.plain +17 -0
  288. package/stdlib/frontend/common.plain +17 -0
  289. package/stdlib/frontend/conformance/angular.plain +5 -0
  290. package/stdlib/frontend/conformance/common.plain +11 -0
  291. package/stdlib/frontend/conformance/react-vite.plain +5 -0
  292. package/stdlib/frontend/conformance/sveltekit-spa.plain +5 -0
  293. package/stdlib/frontend/conformance/vue-vite.plain +5 -0
  294. package/stdlib/frontend/react-vite.plain +16 -0
  295. package/stdlib/frontend/sveltekit-spa.plain +16 -0
  296. package/stdlib/frontend/vue-vite.plain +16 -0
  297. package/stdlib/lang/go.plain +13 -0
  298. package/stdlib/lang/java.plain +12 -0
  299. package/stdlib/lang/python.plain +18 -0
  300. package/stdlib/lang/rust.plain +12 -0
  301. package/stdlib/lang/typescript.plain +13 -0
  302. package/stdlib/library/common.plain +15 -0
  303. package/stdlib/library/conformance/common.plain +9 -0
  304. package/stdlib/library/conformance/go.plain +9 -0
  305. package/stdlib/library/conformance/java-gradle.plain +9 -0
  306. package/stdlib/library/conformance/java-maven.plain +9 -0
  307. package/stdlib/library/conformance/python.plain +9 -0
  308. package/stdlib/library/conformance/rust.plain +9 -0
  309. package/stdlib/library/conformance/typescript.plain +9 -0
  310. package/stdlib/library/go.plain +12 -0
  311. package/stdlib/library/java-gradle.plain +13 -0
  312. package/stdlib/library/java-maven.plain +13 -0
  313. package/stdlib/library/python.plain +13 -0
  314. package/stdlib/library/rust.plain +12 -0
  315. package/stdlib/library/typescript.plain +15 -0
@@ -0,0 +1,165 @@
1
+ import { ask, BEGIN, createJudge } from "../pi.js";
2
+ import { createVerdictTool } from "../tools.js";
3
+ function livePrompt(opts) {
4
+ return [
5
+ "You are a reviewer reading work that is still being written. You did not write it and",
6
+ "you have no stake in it. Judge what is on disk against the rubric only.",
7
+ "",
8
+ "<rubric>",
9
+ opts.rubric,
10
+ "</rubric>",
11
+ "",
12
+ `<under review>\n${opts.scope}\n</under review>`,
13
+ "",
14
+ opts.cleared.length > 0
15
+ ? `<already cleared>\n${opts.cleared.join("\n")}\n</already cleared>\n\nDo not re-report anything about work listed here unless it has since changed.\n`
16
+ : "",
17
+ opts.replies.length > 0
18
+ ? `<the user has since answered>\n${opts.replies.join("\n")}\n</the user has since answered>\n`
19
+ : "",
20
+ "Read the files yourself with the tools you have. Nothing has been pasted in for you.",
21
+ "",
22
+ "The work is unfinished by design: later parts of it have not been written yet. Judge",
23
+ "only what is claimed to be under review. Never report something as missing because it",
24
+ "has not been reached yet, and never ask for the phase to be finished.",
25
+ "",
26
+ "Call submit_verdict exactly once. Every violation must quote the offending text",
27
+ "verbatim and name one concrete fix. If a violation cannot be settled from the files -",
28
+ "it needs a decision only the user can make - put that single question in 'question'.",
29
+ ]
30
+ .filter((part) => part !== "")
31
+ .join("\n");
32
+ }
33
+ function defaultJudge(opts) {
34
+ return async (prompt) => {
35
+ let captured;
36
+ const { session } = await createJudge({
37
+ cwd: opts.cwd,
38
+ verdictTool: createVerdictTool((v) => {
39
+ captured = v;
40
+ }),
41
+ modelSpec: opts.spec.model ?? opts.defaultModel,
42
+ borrowAuthFrom: opts.executor,
43
+ extraTools: opts.spec.judgeTools,
44
+ task: prompt,
45
+ });
46
+ try {
47
+ await ask(session, BEGIN);
48
+ }
49
+ finally {
50
+ session.dispose();
51
+ }
52
+ return captured;
53
+ };
54
+ }
55
+ export function createLiveReviewer(opts) {
56
+ const judge = opts.judge ??
57
+ defaultJudge({
58
+ cwd: opts.cwd,
59
+ spec: opts.spec,
60
+ executor: opts.executor,
61
+ defaultModel: opts.defaultModel,
62
+ });
63
+ const pending = [];
64
+ const all = [];
65
+ const questions = [];
66
+ /** Every question ever asked, so an answer can arrive after it was handed over. */
67
+ const asked = new Map();
68
+ const replies = [];
69
+ const cleared = [];
70
+ let dirty = null;
71
+ let running = null;
72
+ let passes = 0;
73
+ async function onePass(scope) {
74
+ passes += 1;
75
+ const captured = await judge(livePrompt({ rubric: opts.rubric, scope, cleared, replies: [...replies] }));
76
+ const violations = captured?.verdict === "fail" ? captured.violations : [];
77
+ for (const v of violations) {
78
+ const finding = { scope, criterion: v.criterion, evidence: v.evidence, fix: v.fix };
79
+ pending.push(finding);
80
+ all.push(finding);
81
+ }
82
+ if (violations.length === 0) {
83
+ cleared.push(scope);
84
+ }
85
+ else if (captured?.question) {
86
+ const question = { id: `q${asked.size + 1}`, scope, question: captured.question };
87
+ asked.set(question.id, question);
88
+ questions.push(question);
89
+ }
90
+ opts.onPass?.({ scope, findings: violations.length });
91
+ }
92
+ /**
93
+ * One pass at a time, and only one queued behind it. A pass always reads the file as it
94
+ * is when it starts, so the newest scope supersedes anything queued before it.
95
+ */
96
+ function pump() {
97
+ if (running !== null || dirty === null)
98
+ return;
99
+ const scope = dirty;
100
+ dirty = null;
101
+ running = onePass(scope)
102
+ // A reviewer that fell over must not take the interview down with it. The phase gate
103
+ // still runs its own judges at the end, so nothing is silently skipped.
104
+ .catch(() => { })
105
+ .then(() => {
106
+ running = null;
107
+ pump();
108
+ });
109
+ }
110
+ return {
111
+ notice(scope) {
112
+ dirty = scope;
113
+ pump();
114
+ },
115
+ take() {
116
+ return pending.splice(0, pending.length);
117
+ },
118
+ takeQuestions() {
119
+ return questions.splice(0, questions.length);
120
+ },
121
+ reply(questionId, answer) {
122
+ const question = asked.get(questionId);
123
+ if (question === undefined)
124
+ return false;
125
+ replies.push(`${question.question} -> ${answer}`);
126
+ return true;
127
+ },
128
+ async settle() {
129
+ while (running !== null || dirty !== null) {
130
+ if (running !== null)
131
+ await running;
132
+ pump();
133
+ }
134
+ const findings = [...pending];
135
+ return { clean: findings.length === 0, findings };
136
+ },
137
+ history() {
138
+ return [...all];
139
+ },
140
+ passes() {
141
+ return passes;
142
+ },
143
+ };
144
+ }
145
+ export function describeFindings(findings) {
146
+ const lines = findings.map((f) => `- [${f.scope}] ${f.criterion}: ${f.evidence} -> ${f.fix}`);
147
+ return [
148
+ `The reviewer reading along behind you found ${findings.length === 1 ? "something" : `${findings.length} things`}:`,
149
+ ...lines,
150
+ "",
151
+ "Fix the specification now, before the next question, and re-queue anything already built " +
152
+ "from it with regenerate: true. Do not argue with the review and do not write a reply - " +
153
+ "edit the spec.",
154
+ ].join("\n");
155
+ }
156
+ export function describeQuestions(questions) {
157
+ const lines = questions.map((q) => `- (${q.id}) ${q.question}`);
158
+ return [
159
+ "The reviewer needs the user to settle something it cannot decide from the specs:",
160
+ ...lines,
161
+ "",
162
+ "Ask the user this with ask_user at your next turn, say it comes from the review, then " +
163
+ "write the answer to disk and pass it back with reply_to_review.",
164
+ ].join("\n");
165
+ }
package/dist/rules.js ADDED
@@ -0,0 +1,47 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { stdlibCatalogue } from "./stdlib.js";
4
+ /**
5
+ * The ***plain authoring rules.
6
+ *
7
+ * These are loaded in full into every session's system prompt. They are large - around
8
+ * 40k tokens - and that is the deliberate trade: a rule the model has to decide to go and
9
+ * read is a rule it can skip, and these govern every spec this project writes.
10
+ */
11
+ export function loadRules(rulesDir, off = []) {
12
+ let entries;
13
+ try {
14
+ // A project's config.turri.yaml can leave rules out (rules-off): by name, with or without .md.
15
+ const left = new Set(off.map((name) => name.replace(/\.md$/, "")));
16
+ entries = readdirSync(rulesDir).filter((entry) => entry.endsWith(".md") && !left.has(basename(entry, ".md"))).sort();
17
+ }
18
+ catch {
19
+ return [];
20
+ }
21
+ return entries.map((entry) => {
22
+ const name = basename(entry, ".md");
23
+ const content = readFileSync(join(rulesDir, entry), "utf8").trim();
24
+ // The standard library's rule carries what is in it, read from the templates themselves.
25
+ return { name, content: name === "stdlib" ? [content, stdlibCatalogue()].filter(Boolean).join("\n\n") : content };
26
+ });
27
+ }
28
+ /** The block appended to the system prompt. Identical every turn, so it stays cacheable. */
29
+ export function renderRulesBlock(rules) {
30
+ if (rules.length === 0)
31
+ return "";
32
+ const body = rules
33
+ .map((rule) => `<rule name="${rule.name}">\n${rule.content}\n</rule>`)
34
+ .join("\n\n");
35
+ return [
36
+ "<plain-rules>",
37
+ "The authoring rules for ***plain specifications. They are binding: when writing or",
38
+ "editing a .plain file, follow them exactly rather than inferring from surrounding text.",
39
+ "",
40
+ body,
41
+ "</plain-rules>",
42
+ ].join("\n");
43
+ }
44
+ export function rulesSummary(rules) {
45
+ const chars = rules.reduce((total, rule) => total + rule.content.length, 0);
46
+ return `${rules.length} rules, ~${Math.round(chars / 4000)}k tokens`;
47
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Keeping the conversation's agent on its own tools.
3
+ *
4
+ * The agent has a shell, and turri has a command line - so it can build a module by
5
+ * running `turri regenerate` through bash, or start another pi with `pi -p`. That run is a
6
+ * second, headless turri: outside this conversation's job queue, its panel and
7
+ * `/halt`, on whatever model the command line picks, fighting the conversation's own jobs for
8
+ * the same build directories. It is never the right call unless the person asked for the
9
+ * command line, so the harness refuses it and names the tool to use instead.
10
+ */
11
+ /** The tool that does what a turri subcommand does, from inside the conversation. */
12
+ const TOOL_FOR = {
13
+ regenerate: "regenerate",
14
+ glimpse: "glimpse",
15
+ conform: "conform",
16
+ cultivate: "cultivate",
17
+ release: "release",
18
+ "spec-diff": "regenerate",
19
+ chat: "nothing - you are the conversation already",
20
+ ask: "nothing - you are the conversation already",
21
+ };
22
+ /** The npm scripts in this repo, by the subcommand they run. */
23
+ const NPM_SCRIPT = {
24
+ regenerate: "regenerate",
25
+ conform: "conform",
26
+ cultivate: "cultivate",
27
+ chat: "chat",
28
+ ask: "ask",
29
+ };
30
+ /** The source entry points, by the subcommand they are. */
31
+ const ENTRY = {
32
+ "regenerate-cli": "regenerate",
33
+ "conform-cli": "conform",
34
+ "spec-diff-cli": "spec-diff",
35
+ "cultivate-cli": "cultivate",
36
+ "release-cli": "release",
37
+ };
38
+ /** Where a command starts: the start of the string, or after a separator. */
39
+ const AT_COMMAND = String.raw `(?:^|[\s;&|()\x60$])`;
40
+ /**
41
+ * What a shell command would run of turri or pi itself, if anything: the subcommand,
42
+ * or "pi" for another pi. `turri validate`, `drift` and `lineage` are not in it - they start no
43
+ * agent - and neither are setup and doctor.
44
+ */
45
+ export function selfInvocation(command) {
46
+ const cli = new RegExp(`${AT_COMMAND}(?:npx\\s+)?turri\\s+(regenerate|glimpse|conform|cultivate|release|spec-diff)\\b`).exec(command);
47
+ if (cli)
48
+ return cli[1];
49
+ const launcher = new RegExp(`(?:bin/turri\\.mjs|src/cli\\.ts)\\s+(regenerate|glimpse|conform|cultivate|release|spec-diff)\\b`).exec(command);
50
+ if (launcher)
51
+ return launcher[1];
52
+ // turri alone, or with options, is the conversation - where a command starts, not a word an argument names (`which turri`).
53
+ const bare = /(?:^|[;&|()\x60$]\s*|\bnohup\s+|\bnpx\s+)turri(?=\s+-|\s*$|\s*[;&|)>])(?:\s+(-\S+))?/.exec(command) ??
54
+ /(?:bin\/turri\.mjs|src\/cli\.ts)(?=\s+-|\s*$|\s*[;&|)>])(?:\s+(-\S+))?/.exec(command);
55
+ if (bare && !["-h", "--help"].includes(bare[1] ?? ""))
56
+ return "chat";
57
+ const npm = new RegExp(`${AT_COMMAND}npm\\s+run\\s+(${Object.values(NPM_SCRIPT).join("|")})\\b`).exec(command);
58
+ if (npm)
59
+ return npm[1];
60
+ const entry = new RegExp(`src/(${Object.keys(ENTRY).join("|")})\\.ts\\b`).exec(command);
61
+ if (entry)
62
+ return ENTRY[entry[1]];
63
+ if (/pi-launch\.ts\b/.test(command))
64
+ return "pi";
65
+ // Another pi - but only one that would run an agent. pi configuring or describing itself
66
+ // (installing an extension, listing models, its version) is pi's own business.
67
+ for (const match of command.matchAll(new RegExp(`${AT_COMMAND}(?:npx\\s+)?pi(?:\\s+(\\S+)|\\s*$|\\s*[;&|)])`, "g"))) {
68
+ const first = match[1];
69
+ if (first === undefined || !PI_CONFIGURES.has(first))
70
+ return "pi";
71
+ }
72
+ return undefined;
73
+ }
74
+ /** pi's commands and flags that manage or describe pi rather than start an agent. */
75
+ const PI_CONFIGURES = new Set([
76
+ "install", "remove", "uninstall", "update", "list", "config", "auth",
77
+ "--version", "-v", "--help", "-h", "--list-models",
78
+ ]);
79
+ /**
80
+ * Whether the person's message asks for the command line: it says so, or names the command.
81
+ * Read from their own words only - an agent that decided on its own does not get through.
82
+ */
83
+ export function askedForCommandLine(message) {
84
+ const text = message.toLowerCase();
85
+ return (/\b(headless|command[ -]line|cli|terminal|shell|in bash|via bash|through bash|npm run|nohup)\b/.test(text) ||
86
+ /\bturri\s+(regenerate|glimpse|conform|spec-diff|cultivate|release)\b/.test(text) ||
87
+ /(^|\s)turri\s+-p\b/.test(text) ||
88
+ /(^|\s)pi\s+-p\b/.test(text));
89
+ }
90
+ /** What the agent is told when the call is refused. */
91
+ export function refusal(what) {
92
+ const instead = what === "pi"
93
+ ? "Do not start another agent: do the work here, with this conversation's tools."
94
+ : `Use ${TOOL_FOR[what] ?? "the matching turri tool"} instead.`;
95
+ return (`Not run: that starts turri${what === "pi" ? "'s agent" : ` ${what}`} again from the shell - a separate, headless ` +
96
+ "run outside this conversation, its jobs, the panel and /halt. " +
97
+ `${instead} Run turri from the command line only when the user asks for that.`);
98
+ }
99
+ /** The instruction that goes into the conversation's system prompt. */
100
+ export const TOOLS_NOT_CLI = [
101
+ "<turri-tools>",
102
+ "In this conversation, you do your work with your tools - regenerate, conform, cultivate and the rest - which run as",
103
+ "background jobs the user can watch and halt. To try what is regenerated, release it - never by hand in plain_modules. Never",
104
+ "run your own command line (turri regenerate, glimpse, conform, spec-diff, cultivate, release, turri itself; npm run",
105
+ "regenerate and the like) or start another pi through bash:",
106
+ "that is a separate headless run the user cannot see or stop. Only when the user explicitly asks for the",
107
+ "command line, run it. Configuring pi itself - pi install, update, list, config, auth, or its settings files -",
108
+ "is fine; it starts no agent.",
109
+ "</turri-tools>",
110
+ ].join("\n");
package/dist/setup.js ADDED
@@ -0,0 +1,245 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, rmSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent";
6
+ import { globalExtensionFile, MIN_NODE, nodeVersionOk, PACKAGE_ROOT, turriOnPath } from "./environment.js";
7
+ import { sandboxAvailable } from "./regenerate/os-sandbox.js";
8
+ import { findTestScripts } from "./regenerate/test-scripts.js";
9
+ import { hasProjectConfig, readProjectConfig, stackOf } from "./config.js";
10
+ import { checkModels, modelsInUse, piModelAccess } from "./model-check.js";
11
+ import { brand } from "./brand.js";
12
+ import { readScript, scriptAgainstMachine, stackOnMachine } from "./stack.js";
13
+ const MARK = { ok: "✓", missing: "✗", limited: "!", info: "·" };
14
+ function checkNode() {
15
+ const version = process.versions.node;
16
+ return nodeVersionOk(version)
17
+ ? { level: "ok", name: "Node", detail: version }
18
+ : {
19
+ level: "missing",
20
+ name: "Node",
21
+ detail: `${version} - turri needs ${MIN_NODE.join(".")} or newer`,
22
+ fix: "install a newer Node (https://nodejs.org, or `brew install node`)",
23
+ };
24
+ }
25
+ /** bash: every agent command and every testing script runs with it, on every platform. */
26
+ export function checkShell(found = spawnSync("bash", ["--version"], { stdio: "ignore", windowsHide: true }).status === 0, platform = process.platform) {
27
+ if (found)
28
+ return { level: "ok", name: "bash", detail: "on the PATH - agents' commands and the testing scripts run with it" };
29
+ return {
30
+ level: "missing",
31
+ name: "bash",
32
+ detail: "not on the PATH - agents' commands and the testing scripts run with it",
33
+ fix: platform === "win32" ? "install Git for Windows (Git Bash), and put its bash on the PATH" : "install bash with your system's package manager",
34
+ };
35
+ }
36
+ /** Which wall is up under the agents' commands, and - where none starts - why, and how to give it one. */
37
+ export function checkSandbox(wall = sandboxAvailable(), platform = process.platform) {
38
+ if (wall.kind === "seatbelt")
39
+ return { level: "ok", name: "OS sandbox", detail: "Seatbelt - under the command check and the audit" };
40
+ if (wall.kind === "bubblewrap") {
41
+ return { level: "ok", name: "OS sandbox", detail: `bubblewrap${wall.proc === "bind" ? ", the host's /proc bound (this container masks its own)" : ""} - under the command check and the audit` };
42
+ }
43
+ return {
44
+ level: platform === "win32" ? "info" : "limited",
45
+ name: "OS sandbox",
46
+ detail: `none - ${wall.why}; the command check and the audit hold alone`,
47
+ ...(wall.fix !== undefined ? { fix: wall.fix } : {}),
48
+ };
49
+ }
50
+ /**
51
+ * In a project: each module's toolchains, as its implementation reqs name them, against this machine's PATH; and each
52
+ * testing script against this machine - a program it names that is not here, a toolchain it runs that is not on the
53
+ * PATH. Nothing outside a project.
54
+ */
55
+ export function checkProject(project, machine = {}) {
56
+ const checks = [];
57
+ for (const { module, needs, missing } of stackOnMachine(project, machine.has)) {
58
+ if (needs.length === 0)
59
+ continue;
60
+ checks.push(missing.length === 0
61
+ ? { level: "ok", name: `${module}`, detail: `has ${needs.map((need) => need.toolchain).join(", ")}` }
62
+ : {
63
+ level: "missing",
64
+ name: `${module}`,
65
+ detail: `its implementation reqs need ${missing.map((need) => `${need.toolchain} (${need.words.join(", ")})`).join(", ")}, which this machine lacks`,
66
+ fix: `install ${missing.map((need) => need.toolchain).join(" and ")} on this machine - the agents use its toolchains, and install none`,
67
+ });
68
+ }
69
+ const scripts = findTestScripts(project);
70
+ for (const script of [scripts.unit, scripts.prepare, scripts.conformance]) {
71
+ if (script === undefined)
72
+ continue;
73
+ const text = readScript(script);
74
+ if (text === undefined)
75
+ continue;
76
+ const found = scriptAgainstMachine(text, machine);
77
+ if (found.length === 0)
78
+ continue;
79
+ checks.push({
80
+ level: "limited",
81
+ name: relative(project, script),
82
+ detail: "does not fit this machine - it is yours to fix:",
83
+ more: found,
84
+ });
85
+ }
86
+ return checks;
87
+ }
88
+ async function checkProvider() {
89
+ try {
90
+ const runtime = await ModelRuntime.create();
91
+ const available = new ModelRegistry(runtime).getAvailable();
92
+ const providers = [...new Set(available.map((model) => model.provider))];
93
+ if (providers.length > 0) {
94
+ return { level: "ok", name: "Model provider", detail: `${providers.join(", ")} (${available.length} models)` };
95
+ }
96
+ }
97
+ catch {
98
+ // Reported as missing below.
99
+ }
100
+ return {
101
+ level: "missing",
102
+ name: "Model provider",
103
+ detail: "no model has credentials",
104
+ fix: "run `pi` and use /login, or set an API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, ...)",
105
+ };
106
+ }
107
+ function checkPath() {
108
+ const found = turriOnPath();
109
+ return found
110
+ ? { level: "ok", name: "turri on PATH", detail: found }
111
+ : {
112
+ level: "limited",
113
+ name: "turri on PATH",
114
+ detail: "not found",
115
+ // A clone has its sources; the package from npm has only what was compiled from them.
116
+ fix: existsSync(join(PACKAGE_ROOT, "src")) ? `cd ${PACKAGE_ROOT} && npm link` : "npm install -g @turri-dev/cli",
117
+ };
118
+ }
119
+ /**
120
+ * turri loaded into every `pi` - a file an older `turri setup` wrote into pi's extensions. turri and pi are kept apart
121
+ * now: `turri` starts pi with turri, and a plain `pi` stays pi. Said only when the file is there, for doctor to remove.
122
+ */
123
+ function checkGlobal() {
124
+ const file = globalExtensionFile();
125
+ if (file === undefined)
126
+ return [];
127
+ return [
128
+ {
129
+ level: "limited",
130
+ name: "turri in every pi",
131
+ detail: `${file} loads turri into every plain pi too`,
132
+ fix: "turri doctor offers to remove it, below - turri keeps starting with turri",
133
+ },
134
+ ];
135
+ }
136
+ /**
137
+ * In a project: each model its config names - with the stack chosen, that stack's over it - tried with one short
138
+ * request. A model that answers is ready; one that does not says what the provider said, and what to do about it.
139
+ */
140
+ export async function checkProjectModels(project, access) {
141
+ const used = modelsInUse(readProjectConfig(project, stackOf(project)));
142
+ if (used.length === 0) {
143
+ return [{ level: "missing", name: "Models", detail: "config.turri.yaml names none", fix: "turri init-config writes the two models" }];
144
+ }
145
+ let verdicts;
146
+ try {
147
+ verdicts = await checkModels(used, access ?? (await piModelAccess()));
148
+ }
149
+ catch (error) {
150
+ return [{ level: "missing", name: "Models", detail: `pi's models could not be read: ${error instanceof Error ? error.message : String(error)}` }];
151
+ }
152
+ return verdicts.map((verdict) => {
153
+ const name = verdict.usedBy.join(", ");
154
+ return verdict.ok
155
+ ? { level: "ok", name, detail: `${verdict.spec} answered (${(verdict.ms / 1000).toFixed(1)}s)` }
156
+ : { level: "missing", name, detail: verdict.spec, more: [verdict.problem], fix: verdict.fix };
157
+ });
158
+ }
159
+ async function allChecks(options = {}) {
160
+ const checks = [checkNode(), checkShell(), checkSandbox(), await checkProvider(), checkPath(), ...checkGlobal()];
161
+ // In a project: what it needs of this machine, and whether its models answer.
162
+ const project = process.cwd();
163
+ if (hasProjectConfig(project) || existsSync(join(project, "plain_modules"))) {
164
+ checks.push(...checkProject(project));
165
+ if (options.models !== false && hasProjectConfig(project)) {
166
+ console.log(brand.muted("Asking each model the config names for a word..."));
167
+ checks.push(...(await checkProjectModels(project)));
168
+ }
169
+ }
170
+ return checks;
171
+ }
172
+ function report(checks) {
173
+ const paint = { ok: brand.success, missing: brand.error, limited: brand.warning, info: brand.muted };
174
+ // The names in one column, however long the longest - `judge-model, models.judge` - is.
175
+ const width = Math.max(20, ...checks.map((check) => check.name.length)) + 1;
176
+ const under = " ".repeat(width + 4);
177
+ console.log("");
178
+ for (const check of checks) {
179
+ const name = check.level === "missing" ? brand.bold(check.name.padEnd(width)) : check.name.padEnd(width);
180
+ console.log(` ${paint[check.level](MARK[check.level])} ${name}${check.level === "info" ? brand.muted(check.detail) : check.detail}`);
181
+ for (const line of check.more ?? [])
182
+ console.log(`${under}${brand.muted(line)}`);
183
+ if (check.fix && check.level !== "ok")
184
+ console.log(`${under}${brand.accent(`→ ${check.fix}`)}`);
185
+ }
186
+ const missing = checks.filter((check) => check.level === "missing");
187
+ const limited = checks.filter((check) => check.level === "limited");
188
+ console.log("");
189
+ if (missing.length > 0) {
190
+ console.log(brand.error(`Not ready: ${missing.length} thing${missing.length === 1 ? "" : "s"} to fix (✗ above).`));
191
+ return 1;
192
+ }
193
+ console.log(limited.length > 0 ? brand.warning("Ready, with limits (! above).") : brand.success("Ready."));
194
+ return 0;
195
+ }
196
+ /**
197
+ * Ask a yes/no question. With --yes, yes. With no terminal to ask on, no - nothing is changed
198
+ * without asking - and it says so, so a piped run shows what it would have done.
199
+ */
200
+ async function confirm(question, fallback, yes) {
201
+ if (yes)
202
+ return true;
203
+ if (!process.stdin.isTTY) {
204
+ console.log(`${question} - not asked: no terminal. Run turri doctor in a terminal, or with --yes.`);
205
+ return false;
206
+ }
207
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
208
+ try {
209
+ const answer = (await rl.question(`${question} ${fallback ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
210
+ return answer === "" ? fallback : answer.startsWith("y");
211
+ }
212
+ finally {
213
+ rl.close();
214
+ }
215
+ }
216
+ /**
217
+ * Take turri out of every `pi`: the extension file an older `turri setup` wrote. The themes it copied stay - they only
218
+ * add turri's themes to pi's list.
219
+ */
220
+ function removeGlobalExtension() {
221
+ const file = globalExtensionFile();
222
+ if (file === undefined)
223
+ return;
224
+ rmSync(file, { force: true });
225
+ console.log(` removed ${file}`);
226
+ }
227
+ export async function main(args) {
228
+ if (args.includes("--help") || args.includes("-h")) {
229
+ console.log("usage: turri doctor [--no-models] [--yes]\n\nCheck everything turri needs - in a project, what its modules need of this machine too, and that\n" +
230
+ "each model its config names answers with this machine's credentials (one short request a model;\n--no-models skips it).\n" +
231
+ "It changes nothing but this, asked first: a plain pi that an older turri made load turri too is put back to\n" +
232
+ "plain pi. --yes answers yes.");
233
+ return 0;
234
+ }
235
+ console.log(brand.bold(brand.accent("Checking what turri needs.")));
236
+ if (!nodeVersionOk())
237
+ return report([checkNode()]);
238
+ const code = report(await allChecks({ models: !args.includes("--no-models") }));
239
+ // turri and pi are kept apart: a plain pi does not start turri. An older turri's file that made it do so goes, asked.
240
+ const global = globalExtensionFile();
241
+ if (global !== undefined && (await confirm(`\nA plain \`pi\` loads turri too, through ${global}. Remove it, so pi stays pi?`, true, args.includes("--yes") || args.includes("-y")))) {
242
+ removeGlobalExtension();
243
+ }
244
+ return code;
245
+ }
@@ -0,0 +1,78 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ const PLAIN = { heading: (t) => t, done: (t) => t, now: (t) => t, muted: (t) => t };
3
+ /** The shortest a join between two phases is drawn: ` ━━ `. */
4
+ const JOIN = 2;
5
+ /** How much of the width the phases take, centred in it - stretched across it, the joins read as a ruler. */
6
+ const SHARE = 0.6;
7
+ /** The least room between the phases and the skill's name on the left, or where it is on the right. */
8
+ const GAP = 3;
9
+ /** Below this width the line is drawn compact, from the left: there is no room to spare. */
10
+ const NARROW = 60;
11
+ /**
12
+ * The line across `width`: the skill's name at the left edge, where it is at the right edge, and between them its
13
+ * phases, centred in the whole width across its share of it, the joins stretched to fill it - clear of both ends. Too
14
+ * narrow for that, compact from the left, the phases first, as many as fit.
15
+ */
16
+ export function skillProgressLine(progress, width, style = PLAIN) {
17
+ // One phase or none: nothing to be partway through - the spinner and the skill's name, and the attempt after a gate sent it back.
18
+ if (progress.phases.length <= 1) {
19
+ const again = progress.attempt > 1 ? style.muted(` attempt ${progress.attempt}`) : "";
20
+ return truncateToWidth(`${style.now(progress.frame)} ${style.heading(progress.skill)}${again}`, width, "…");
21
+ }
22
+ const head = style.heading(progress.skill);
23
+ const tail = style.muted(whereItIs(progress));
24
+ const steps = stepsOf(progress, style);
25
+ const shortest = visibleWidth(phasesAcross(progress, steps, 0, style));
26
+ const sides = visibleWidth(head) + visibleWidth(tail) + 2 * GAP;
27
+ if (width < NARROW || sides + shortest > width)
28
+ return compact(progress, steps, width, style);
29
+ // Its share, or as little as the ends leave it - kept centred while that keeps it clear of them.
30
+ const room = width - 2 * (Math.max(visibleWidth(head), visibleWidth(tail)) + GAP);
31
+ const across = Math.max(shortest, Math.min(Math.floor(width * SHARE), room));
32
+ const bar = phasesAcross(progress, steps, across, style);
33
+ const barWidth = visibleWidth(bar);
34
+ const from = Math.min(Math.max(Math.floor((width - barWidth) / 2), visibleWidth(head) + GAP), width - visibleWidth(tail) - GAP - barWidth);
35
+ const after = width - from - barWidth - visibleWidth(tail);
36
+ return `${head}${" ".repeat(from - visibleWidth(head))}${bar}${" ".repeat(Math.max(0, after))}${tail}`;
37
+ }
38
+ /** Where it is: the phase it is in, of how many - and the attempt, after a gate sent it back. */
39
+ function whereItIs(progress) {
40
+ if (progress.current < 0)
41
+ return "starting";
42
+ return `phase ${progress.current + 1} of ${progress.phases.length}${progress.attempt > 1 ? `, attempt ${progress.attempt}` : ""}`;
43
+ }
44
+ /** Each phase as it is drawn: done ticked, the one it is in marked, the rest to come. */
45
+ function stepsOf(progress, style) {
46
+ const { phases, current } = progress;
47
+ return phases.map((phase, at) => at < current ? style.done(`✓ ${phase}`) : at === current ? style.now(`${progress.frame} ${phase}`) : style.muted(`○ ${phase}`));
48
+ }
49
+ /** A join between two phases, `length` long: a stretch walked heavy, in the colour of what is done; one to come, light. */
50
+ function joinOf(at, length, current, style) {
51
+ return at <= current ? style.done(` ${"━".repeat(length)} `) : style.muted(` ${"─".repeat(length)} `);
52
+ }
53
+ /** The phases across `width`, the joins stretched to fill it - at their shortest, when it is less than that takes. */
54
+ function phasesAcross(progress, steps, width, style) {
55
+ const joins = Math.max(0, steps.length - 1);
56
+ const fixed = steps.reduce((sum, step) => sum + visibleWidth(step), 0) + joins * (JOIN + 2);
57
+ const spare = Math.max(0, width - fixed);
58
+ const length = (at) => JOIN + (joins === 0 ? 0 : Math.floor(spare / joins) + (at <= spare % joins ? 1 : 0));
59
+ return steps.map((step, at) => (at === 0 ? step : `${joinOf(at, length(at), progress.current, style)}${step}`)).join("");
60
+ }
61
+ /** The line from the left, every join at its shortest - where it is left out first, then the phases cut, to fit `width`. */
62
+ function compact(progress, steps, width, style) {
63
+ const line = `${style.heading(progress.skill)} ${phasesAcross(progress, steps, 0, style)}`;
64
+ const full = `${line} ${style.muted(whereItIs(progress))}`;
65
+ return visibleWidth(full) <= width ? full : truncateToWidth(line, width, "…");
66
+ }
67
+ /**
68
+ * The line under it: the phase it is in, and what that phase does - centred under the phases, cut short where it
69
+ * does not fit. Nothing before the first phase starts, or for a phase the skill does not describe.
70
+ */
71
+ export function skillAboutLine(progress, width, style = PLAIN) {
72
+ const phase = progress.phases[progress.current];
73
+ if (progress.phases.length <= 1 || phase === undefined || progress.about === undefined || progress.about === "")
74
+ return undefined;
75
+ const text = truncateToWidth(`${phase} - ${progress.about}`, width, "…");
76
+ const left = width < NARROW ? 0 : Math.floor((width - visibleWidth(text)) / 2);
77
+ return `${" ".repeat(left)}${style.muted(text)}`;
78
+ }