@capagents/forgeqe 0.0.0-stage → 1.0.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 (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +781 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +146 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,189 @@
1
+ import { realpathSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import type { CodeEvent, Usage } from "../types.ts"
5
+ import { findOpencode, openSession, type OpencodeSession } from "../code/runtime.ts"
6
+ import { privateEnvNames } from "../childEnv.ts"
7
+ import type { AppConfig } from "../config.ts"
8
+ import { externalMcp, externalNotes, type ExternalPlan } from "../mcp/external.ts"
9
+ import { MCP_NAME, TOOL_TIMEOUT_MS, localToolServer } from "../mcp/server.ts"
10
+ import { TOOLS, environmentNotes, type ToolContext } from "../mcp/tools.ts"
11
+ import { brainPrompt, BRAIN_SCHEMA, type BrainReport } from "./prompt.ts"
12
+ import { redactSecrets } from "../sessionLogins.ts"
13
+ import { logEvent } from "../sessionLog.ts"
14
+ import { loadSkills } from "../skills/registry.ts"
15
+ import { domainNotes } from "../domain.ts"
16
+
17
+ export const BRAIN_AGENT = "forgeqe"
18
+
19
+ export type BrainAnswer = { reply: string; outcome: "done" | "failed" | "needs_input" | "stopped"; error?: string }
20
+
21
+ export type Brain = {
22
+ model: string
23
+ ask(text: string): Promise<BrainAnswer>
24
+ abort(): Promise<void>
25
+ close(): Promise<void>
26
+ usage: Usage
27
+ }
28
+
29
+ export type BrainOptions = {
30
+ config: AppConfig
31
+ /** The repository or folder the agent works in. */
32
+ cwd: string
33
+ /** The tools' context; read on every call so /profile and /headed apply. */
34
+ context: () => Omit<ToolContext, "config" | "cwd">
35
+ /** Extra lines for the agent about this session, e.g. whether passed runs become tests. */
36
+ notes?: string[]
37
+ signal?: AbortSignal
38
+ onEvent?: (e: CodeEvent) => void
39
+ /** Replaces openSession in tests. */
40
+ open?: (o: Parameters<typeof openSession>[0]) => Promise<OpencodeSession<BrainReport>>
41
+ /** Replaces the MCP servers derived from forgeqe.yaml (tests). */
42
+ external?: ExternalPlan
43
+ }
44
+
45
+ /** Why the brain cannot run with this config, or null when it can. */
46
+ export function brainUnavailable(cfg: AppConfig): string | null {
47
+ if (cfg.llm.provider === "mock" && !cfg.code.model) return "the mock profile has no model"
48
+ if (cfg.llm.provider === "none" && !cfg.code.model) return "no model is set up"
49
+ if (!findOpencode(cfg.code.opencode)) return "the agent runtime is not installed"
50
+ return null
51
+ }
52
+
53
+ /**
54
+ * Where the agent works and whether passed runs become tests: `repo` is the --repo flag (null for --no-code),
55
+ * falling back to code.repo in forgeqe.yaml, then the current folder.
56
+ */
57
+ export function sessionSetup(cfg: AppConfig, repo: string | null | undefined): { cwd: string; notes: string[]; writeTests: boolean } {
58
+ if (repo === null) return { cwd: process.cwd(), notes: ["Do not write tests unless the user asks for one."], writeTests: false }
59
+ return {
60
+ cwd: path.resolve(repo || cfg.code.repo || process.cwd()),
61
+ notes: [
62
+ "Passed browser runs become tests: after a browser_run with purpose test passes and returns a next_step, call write_test with its run_dir before you reply, unless the user says not to. " +
63
+ "A browser_run with purpose task (looking up or linking work items, reading a tool) never gets a test. " +
64
+ "run_test_cases does this itself, case by case: each passed case is coded right after its browser run, so do not call write_test for those. " +
65
+ "In the reply, name the test file ForgeQE's coding agent wrote and whether ForgeQE's own run of it passed.",
66
+ ],
67
+ writeTests: true,
68
+ }
69
+ }
70
+
71
+ /** One line per configured system: its MCP server connected, or ForgeQE's API tools cover it and why. */
72
+ async function reportExternal(session: OpencodeSession<BrainReport>, plan: ExternalPlan, emit: (e: CodeEvent) => void) {
73
+ for (const s of plan.skipped) emit({ type: "code", kind: "info", text: `${s.label} not started: ${s.why}. ForgeQE's API tools cover it.` })
74
+ if (!plan.servers.length || !session.mcpStatus) return
75
+ const status = await session.mcpStatus().catch(() => ({}) as Record<string, { status: string; error?: string }>)
76
+ for (const s of plan.servers) {
77
+ const st = status[s.name]
78
+ if (st?.status === "connected") emit({ type: "code", kind: "test", ok: true, attempt: 0, text: `${s.label} connected` })
79
+ else {
80
+ const why = st?.status === "failed" ? (st.error || "failed").replace(/\s+/g, " ").slice(0, 200) : st?.status === "needs_auth" || st?.status === "needs_client_registration" ? "it needs a browser sign-in, which ForgeQE cannot do" : st?.status || "no status"
81
+ emit({ type: "code", kind: "test", ok: false, attempt: 0, text: `${s.label} did not connect (${why}). ForgeQE's API tools cover it.` })
82
+ }
83
+ }
84
+ }
85
+
86
+ function realpathOr(dir: string): string {
87
+ try {
88
+ return realpathSync(dir)
89
+ } catch {
90
+ return path.resolve(dir)
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Start ForgeQE's agent: an agent runtime session with the ForgeQE prompt, the active profile's model, and ForgeQE's
96
+ * tools served over MCP from this process, so browser runs and suites report to the caller while they run.
97
+ */
98
+ const CARRY_TURNS = 20
99
+ const CARRY_CHARS = 3_000
100
+
101
+ /**
102
+ * The first prompt of a new agent session that continues an earlier conversation (the user switched profiles):
103
+ * the earlier turns, with remembered secrets redacted, then the new message.
104
+ */
105
+ export function carryOver(turns: { user: string; reply: string }[], text: string): string {
106
+ if (!turns.length) return text
107
+ const clip = (s: string) => (s.length > CARRY_CHARS ? `${s.slice(0, CARRY_CHARS)}…` : s)
108
+ const earlier = turns.slice(-CARRY_TURNS).flatMap((t) => [`User: ${clip(redactSecrets(t.user))}`, `You: ${clip(redactSecrets(t.reply || "(no reply)"))}`])
109
+ return [
110
+ "The user switched model profiles, so this is a new session continuing the conversation below. Nothing from those turns is still running; files and saved logins they produced are still there.",
111
+ "",
112
+ ...earlier,
113
+ "",
114
+ `User: ${text}`,
115
+ ].join("\n")
116
+ }
117
+
118
+ export async function openBrain(opts: BrainOptions): Promise<Brain> {
119
+ const { config } = opts
120
+ const cwd = realpathOr(opts.cwd)
121
+ const context = (): ToolContext => ({ ...opts.context(), config, cwd })
122
+ const tools = localToolServer({ tools: TOOLS, context, instructions: () => environmentNotes(context()), timeoutMs: TOOL_TIMEOUT_MS })
123
+ const external = opts.external || externalMcp(config)
124
+ const notes = [environmentNotes(context()), ...externalNotes(external), ...(opts.notes || [])].filter(Boolean).join("\n")
125
+ const readDirs = [...new Set([config.export.outDir, config.memory.dir, cwd].flatMap((d) => [path.resolve(d), realpathOr(d)]))]
126
+ const onEvent = (e: CodeEvent) => {
127
+ logEvent("agent.event", e)
128
+ opts.onEvent?.(e)
129
+ }
130
+ const emit = onEvent
131
+ if (external.servers.length) emit({ type: "code", kind: "info", text: `Connecting to ${external.servers.map((s) => s.label).join(", ")}…` })
132
+
133
+ let session: OpencodeSession<BrainReport>
134
+ try {
135
+ session = await (opts.open || openSession<BrainReport>)({
136
+ repo: cwd,
137
+ profile: config.llm,
138
+ domain: domainNotes(config),
139
+ model: config.code.model,
140
+ bin: config.code.opencode,
141
+ readDirs,
142
+ hide: privateEnvNames(config),
143
+ role: "ForgeQE's agent",
144
+ secrets: Object.assign({}, ...external.servers.map((s) => s.secrets)),
145
+ signal: opts.signal,
146
+ onEvent,
147
+ agent: { name: BRAIN_AGENT, description: "ForgeQE: browser runs, test cases, tests, and CI pipelines", prompt: `${brainPrompt()}\n\nThis session:\n${notes}`, skills: "forgeqe" },
148
+ skills: loadSkills(config.skills, cwd).skills,
149
+ schema: BRAIN_SCHEMA,
150
+ mcp: {
151
+ ...Object.fromEntries(external.servers.map((s) => [s.name, s.config])),
152
+ [MCP_NAME]: tools.entry,
153
+ },
154
+ hideTool: (tool) => tool.startsWith(`${MCP_NAME}_`),
155
+ title: "ForgeQE",
156
+ })
157
+ } catch (err) {
158
+ tools.stop()
159
+ throw err
160
+ }
161
+ await reportExternal(session, external, emit)
162
+
163
+ let stopped = false
164
+ return {
165
+ model: session.model,
166
+ usage: session.usage,
167
+ async ask(text) {
168
+ stopped = false
169
+ logEvent("agent.ask", { model: session.model, characters: text.length })
170
+ const out = await session.prompt(text)
171
+ // Written after the turn, when credentials the message gave are remembered and so redacted.
172
+ logEvent("agent.turn", { ask: text, error: out.error, report: out.report, text: out.text })
173
+ if (stopped || out.error === "stopped" || opts.signal?.aborted) return { reply: "", outcome: "stopped" }
174
+ const reply = out.report?.reply?.trim() || out.text.trim()
175
+ if (out.error && !out.report) return { reply, outcome: "failed", error: out.error }
176
+ return { reply, outcome: out.report?.outcome || (out.error ? "failed" : "done"), error: out.error }
177
+ },
178
+ abort: () => {
179
+ stopped = true
180
+ tools.server.abortAll()
181
+ return session.abort()
182
+ },
183
+ async close() {
184
+ tools.server.abortAll()
185
+ await session.close()
186
+ tools.stop()
187
+ },
188
+ }
189
+ }
@@ -0,0 +1,19 @@
1
+ import { prompt } from "../prompts.ts"
2
+
3
+ /** System prompt of ForgeQE's agent, which reads every request in the shell and `forgeqe run`. */
4
+ export const brainPrompt = () => prompt("brain")
5
+
6
+ export const BRAIN_SCHEMA = {
7
+ type: "object",
8
+ properties: {
9
+ reply: { type: "string", description: "What you did and found, or the question you need answered. Plain text, short." },
10
+ outcome: {
11
+ type: "string",
12
+ enum: ["done", "failed", "needs_input"],
13
+ description: "done: the request was carried out and everything checked passed. failed: a run, test, or pipeline failed or could not be done. needs_input: you asked the user something.",
14
+ },
15
+ },
16
+ required: ["reply", "outcome"],
17
+ } as const
18
+
19
+ export type BrainReport = { reply?: string; outcome?: "done" | "failed" | "needs_input" }
@@ -0,0 +1,101 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import type { DocRef } from "./registry.ts"
5
+
6
+ /**
7
+ * README.md and HOW_TO_USE.md as addressable sections. The agent reads the contents, picks sections by id, and gets
8
+ * their exact text: nothing is ranked or guessed by keyword. Ids are the docs' own GitHub anchors (doc#anchor), so
9
+ * they are stable across releases while a heading keeps its name.
10
+ */
11
+
12
+ export const DOCS: DocRef["doc"][] = ["README.md", "HOW_TO_USE.md"]
13
+
14
+ export type DocSection = {
15
+ id: string
16
+ doc: DocRef["doc"]
17
+ heading: string
18
+ /** 2 for ##, 3 for ###. */
19
+ level: number
20
+ /** The ## section a ### belongs to. */
21
+ parent?: string
22
+ text: string
23
+ }
24
+
25
+ /** GitHub's heading anchor: lowercase, punctuation dropped, spaces to hyphens. */
26
+ export function anchor(heading: string): string {
27
+ return heading
28
+ .trim()
29
+ .toLowerCase()
30
+ .replace(/[^\p{L}\p{N}\s_-]/gu, "")
31
+ .replace(/\s/g, "-")
32
+ }
33
+
34
+ const root = path.resolve(import.meta.dir, "..", "..")
35
+
36
+ export function parseSections(doc: DocRef["doc"], markdown: string): DocSection[] {
37
+ const out: DocSection[] = []
38
+ const seen = new Map<string, number>()
39
+ let parent: DocSection | undefined
40
+ let current: DocSection | undefined
41
+ let fence = false
42
+ for (const line of markdown.split("\n")) {
43
+ if (/^\s*(```|~~~)/.test(line)) fence = !fence
44
+ const h = fence ? null : line.match(/^(#{2,3}) (.+?)\s*$/)
45
+ if (!h) {
46
+ if (current) current.text += `${line}\n`
47
+ continue
48
+ }
49
+ const base = anchor(h[2]!)
50
+ const n = seen.get(base) ?? 0
51
+ seen.set(base, n + 1)
52
+ current = { id: `${doc}#${n ? `${base}-${n}` : base}`, doc, heading: h[2]!, level: h[1]!.length, text: "" }
53
+ if (current.level === 2) parent = current
54
+ else if (parent) current.parent = parent.id
55
+ out.push(current)
56
+ }
57
+ for (const s of out) s.text = s.text.trim()
58
+ return out
59
+ }
60
+
61
+ let cache: DocSection[] | null = null
62
+
63
+ export function docSections(): DocSection[] {
64
+ if (cache) return cache
65
+ cache = DOCS.flatMap((doc) => {
66
+ const file = path.join(root, doc)
67
+ return existsSync(file) ? parseSections(doc, readFileSync(file, "utf8")) : []
68
+ })
69
+ return cache
70
+ }
71
+
72
+ /** The section a DocRef names: the first heading in that doc with exactly that text. */
73
+ export function resolveDoc(ref: DocRef, sections = docSections()): DocSection | undefined {
74
+ return sections.find((s) => s.doc === ref.doc && s.heading === ref.heading)
75
+ }
76
+
77
+ /** The table of contents: every section's id and heading, nested under its ## section. */
78
+ export function docsContents(sections = docSections()): { id: string; heading: string; sections?: { id: string; heading: string }[] }[] {
79
+ return sections
80
+ .filter((s) => s.level === 2)
81
+ .map((s) => {
82
+ const children = sections.filter((c) => c.parent === s.id).map((c) => ({ id: c.id, heading: c.heading }))
83
+ return { id: s.id, heading: s.heading, ...(children.length ? { sections: children } : {}) }
84
+ })
85
+ }
86
+
87
+ /** The sections with these ids, exactly; a ## section also lists its subsections to read next. Unknown ids are named. */
88
+ export function readDocs(ids: string[], sections = docSections()): { sections: { id: string; heading: string; text: string; subsections?: string[] }[]; unknown?: string[] } {
89
+ const found = []
90
+ const unknown = []
91
+ for (const id of ids) {
92
+ const s = sections.find((x) => x.id === id)
93
+ if (!s) {
94
+ unknown.push(id)
95
+ continue
96
+ }
97
+ const children = sections.filter((c) => c.parent === s.id).map((c) => c.id)
98
+ found.push({ id: s.id, heading: s.heading, text: s.text, ...(children.length ? { subsections: children } : {}) })
99
+ }
100
+ return { sections: found, ...(unknown.length ? { unknown } : {}) }
101
+ }