@doxbrix/doxloop 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (357) hide show
  1. package/CHANGELOG.md +1153 -0
  2. package/README.md +422 -226
  3. package/assets/doxbrix-preview.css +77 -15
  4. package/contracts/agent-events-v1.schema.json +1 -0
  5. package/contracts/coverage-v1.schema.json +59 -0
  6. package/contracts/drift-v1.schema.json +1 -0
  7. package/contracts/evaluation-v1.schema.json +62 -0
  8. package/contracts/quality-codes-v1.json +1 -0
  9. package/contracts/quality-report-v1.schema.json +1 -0
  10. package/contracts/validation-v1.schema.json +1 -0
  11. package/dist/agent-failure.d.ts +36 -0
  12. package/dist/agent-failure.js +98 -0
  13. package/dist/agent-isolation.d.ts +17 -0
  14. package/dist/agent-isolation.js +94 -0
  15. package/dist/agent-log.d.ts +197 -0
  16. package/dist/agent-log.js +1094 -0
  17. package/dist/agent-process.d.ts +63 -0
  18. package/dist/agent-process.js +128 -0
  19. package/dist/agent-reply.d.ts +47 -0
  20. package/dist/agent-reply.js +315 -0
  21. package/dist/agents.d.ts +40 -4
  22. package/dist/agents.js +115 -11
  23. package/dist/api-coverage.d.ts +11 -0
  24. package/dist/api-coverage.js +72 -0
  25. package/dist/application-probe.d.ts +28 -0
  26. package/dist/application-probe.js +93 -0
  27. package/dist/application-url.d.ts +14 -0
  28. package/dist/application-url.js +26 -0
  29. package/dist/args.js +15 -0
  30. package/dist/artifact-deploy.d.ts +9 -0
  31. package/dist/artifact-deploy.js +28 -8
  32. package/dist/assets.d.ts +72 -0
  33. package/dist/assets.js +382 -0
  34. package/dist/auth.d.ts +10 -0
  35. package/dist/auth.js +38 -9
  36. package/dist/author.d.ts +206 -4
  37. package/dist/author.js +1650 -52
  38. package/dist/authoring-batches.d.ts +190 -0
  39. package/dist/authoring-batches.js +489 -0
  40. package/dist/authoring-postpass.d.ts +79 -0
  41. package/dist/authoring-postpass.js +1289 -0
  42. package/dist/authoring-progress.d.ts +95 -0
  43. package/dist/authoring-progress.js +269 -0
  44. package/dist/autosync.d.ts +56 -0
  45. package/dist/autosync.js +442 -0
  46. package/dist/batch-artifacts.d.ts +81 -0
  47. package/dist/batch-artifacts.js +412 -0
  48. package/dist/batch-limits.d.ts +37 -0
  49. package/dist/batch-limits.js +80 -0
  50. package/dist/branding.d.ts +41 -0
  51. package/dist/branding.js +229 -0
  52. package/dist/bundle-upload.d.ts +26 -0
  53. package/dist/bundle-upload.js +99 -0
  54. package/dist/capture-auth.d.ts +95 -0
  55. package/dist/capture-auth.js +204 -0
  56. package/dist/capture.d.ts +48 -0
  57. package/dist/capture.js +20 -1
  58. package/dist/cli.js +839 -32
  59. package/dist/content-links.d.ts +10 -0
  60. package/dist/content-links.js +49 -0
  61. package/dist/contract-validation.d.ts +4 -0
  62. package/dist/contract-validation.js +25 -0
  63. package/dist/coverage-actions.d.ts +8 -0
  64. package/dist/coverage-actions.js +71 -0
  65. package/dist/coverage-resolutions.d.ts +16 -0
  66. package/dist/coverage-resolutions.js +58 -0
  67. package/dist/db.d.ts +37 -0
  68. package/dist/db.js +288 -0
  69. package/dist/demo.d.ts +12 -0
  70. package/dist/demo.js +122 -0
  71. package/dist/deploy-credentials.d.ts +7 -0
  72. package/dist/deploy-credentials.js +76 -0
  73. package/dist/deploy-targets/github-pages.d.ts +3 -0
  74. package/dist/deploy-targets/github-pages.js +100 -0
  75. package/dist/deploy-targets/index.d.ts +8 -0
  76. package/dist/deploy-targets/index.js +78 -0
  77. package/dist/deploy-targets/netlify.d.ts +3 -0
  78. package/dist/deploy-targets/netlify.js +49 -0
  79. package/dist/deploy-targets/types.d.ts +32 -0
  80. package/dist/deploy-targets/types.js +2 -0
  81. package/dist/deploy-targets/vercel.d.ts +3 -0
  82. package/dist/deploy-targets/vercel.js +95 -0
  83. package/dist/deploy.d.ts +9 -0
  84. package/dist/deploy.js +87 -13
  85. package/dist/deterministic-capture.d.ts +90 -0
  86. package/dist/deterministic-capture.js +435 -0
  87. package/dist/direct-edit.d.ts +40 -0
  88. package/dist/direct-edit.js +159 -0
  89. package/dist/docs-crawl.d.ts +86 -0
  90. package/dist/docs-crawl.js +536 -0
  91. package/dist/docs-site.d.ts +49 -0
  92. package/dist/docs-site.js +233 -0
  93. package/dist/doctor.js +8 -0
  94. package/dist/documentation-collections.d.ts +23 -0
  95. package/dist/documentation-collections.js +191 -0
  96. package/dist/documentation-plan.d.ts +253 -0
  97. package/dist/documentation-plan.js +2563 -0
  98. package/dist/doxbrix-build.d.ts +19 -0
  99. package/dist/doxbrix-build.js +167 -0
  100. package/dist/doxbrix-markdown.d.ts +9 -0
  101. package/dist/doxbrix-markdown.js +115 -14
  102. package/dist/drift.d.ts +10 -0
  103. package/dist/drift.js +164 -0
  104. package/dist/evaluation.d.ts +46 -0
  105. package/dist/evaluation.js +113 -0
  106. package/dist/evidence-pack.d.ts +47 -0
  107. package/dist/evidence-pack.js +358 -0
  108. package/dist/evidence.d.ts +25 -0
  109. package/dist/evidence.js +175 -0
  110. package/dist/fs.d.ts +8 -2
  111. package/dist/fs.js +41 -11
  112. package/dist/generator-api.d.ts +89 -0
  113. package/dist/generator-preflight.d.ts +27 -0
  114. package/dist/generator-preflight.js +105 -0
  115. package/dist/generator-runtime.d.ts +7 -0
  116. package/dist/generator-runtime.js +17 -1
  117. package/dist/generators.d.ts +24 -2
  118. package/dist/generators.js +54 -1
  119. package/dist/git-delivery.d.ts +17 -0
  120. package/dist/git-delivery.js +123 -0
  121. package/dist/globs.d.ts +16 -0
  122. package/dist/globs.js +65 -0
  123. package/dist/glossary.d.ts +26 -0
  124. package/dist/glossary.js +179 -0
  125. package/dist/history.d.ts +106 -0
  126. package/dist/history.js +600 -0
  127. package/dist/html-markdown.d.ts +46 -0
  128. package/dist/html-markdown.js +423 -0
  129. package/dist/interactive.js +16 -15
  130. package/dist/job-events.d.ts +74 -0
  131. package/dist/job-events.js +377 -0
  132. package/dist/keep-awake.d.ts +50 -0
  133. package/dist/keep-awake.js +123 -0
  134. package/dist/local-source-snapshot.d.ts +20 -0
  135. package/dist/local-source-snapshot.js +61 -0
  136. package/dist/mintlify-detect.d.ts +3 -0
  137. package/dist/mintlify-detect.js +18 -0
  138. package/dist/mintlify-import.d.ts +75 -0
  139. package/dist/mintlify-import.js +190 -0
  140. package/dist/navigation.d.ts +98 -0
  141. package/dist/navigation.js +310 -0
  142. package/dist/openapi.d.ts +60 -0
  143. package/dist/openapi.js +439 -0
  144. package/dist/page-editor-bridge.d.ts +3 -0
  145. package/dist/page-editor-bridge.js +109 -0
  146. package/dist/page-editor-preview.d.ts +10 -0
  147. package/dist/page-editor-preview.js +55 -0
  148. package/dist/page-extension.d.ts +9 -0
  149. package/dist/page-extension.js +15 -0
  150. package/dist/page-metadata.d.ts +28 -0
  151. package/dist/page-metadata.js +166 -0
  152. package/dist/page-operations.d.ts +34 -0
  153. package/dist/page-operations.js +215 -0
  154. package/dist/page-routes.d.ts +4 -0
  155. package/dist/page-routes.js +61 -0
  156. package/dist/pages.d.ts +20 -0
  157. package/dist/pages.js +184 -0
  158. package/dist/plan-generator.d.ts +3 -0
  159. package/dist/plan-generator.js +21 -0
  160. package/dist/plan-navigation.d.ts +11 -0
  161. package/dist/plan-navigation.js +30 -0
  162. package/dist/planning-captures.d.ts +20 -0
  163. package/dist/planning-captures.js +143 -0
  164. package/dist/planning-research.d.ts +135 -0
  165. package/dist/planning-research.js +472 -0
  166. package/dist/planning-triage.d.ts +23 -0
  167. package/dist/planning-triage.js +131 -0
  168. package/dist/preview.d.ts +24 -0
  169. package/dist/preview.js +280 -29
  170. package/dist/project-detect.d.ts +36 -0
  171. package/dist/project-detect.js +251 -0
  172. package/dist/project-import.d.ts +54 -0
  173. package/dist/project-import.js +157 -0
  174. package/dist/project-lock.d.ts +6 -0
  175. package/dist/project-lock.js +96 -0
  176. package/dist/project-registry.d.ts +25 -0
  177. package/dist/project-registry.js +79 -0
  178. package/dist/project.d.ts +35 -3
  179. package/dist/project.js +388 -38
  180. package/dist/prompts.d.ts +9 -0
  181. package/dist/prompts.js +33 -4
  182. package/dist/proposal-replay.d.ts +32 -0
  183. package/dist/proposal-replay.js +99 -0
  184. package/dist/quality-claims.d.ts +8 -0
  185. package/dist/quality-claims.js +168 -0
  186. package/dist/quality-config.d.ts +5 -0
  187. package/dist/quality-config.js +84 -0
  188. package/dist/quality-contract.d.ts +37 -0
  189. package/dist/quality-contract.js +46 -0
  190. package/dist/quality-examples.d.ts +4 -0
  191. package/dist/quality-examples.js +233 -0
  192. package/dist/quality-gates.d.ts +16 -0
  193. package/dist/quality-gates.js +192 -0
  194. package/dist/quality-links.d.ts +7 -0
  195. package/dist/quality-links.js +149 -0
  196. package/dist/quality-lint.d.ts +6 -0
  197. package/dist/quality-lint.js +124 -0
  198. package/dist/quality-rendered.d.ts +11 -0
  199. package/dist/quality-rendered.js +222 -0
  200. package/dist/quality-schema.d.ts +3 -0
  201. package/dist/quality-schema.js +71 -0
  202. package/dist/release-notes.d.ts +44 -0
  203. package/dist/release-notes.js +183 -0
  204. package/dist/remote-monitor.d.ts +16 -0
  205. package/dist/remote-monitor.js +74 -0
  206. package/dist/remote-source.d.ts +34 -0
  207. package/dist/remote-source.js +426 -0
  208. package/dist/review-diff.d.ts +82 -0
  209. package/dist/review-diff.js +400 -0
  210. package/dist/review-learning.d.ts +11 -0
  211. package/dist/review-learning.js +60 -0
  212. package/dist/review-render.d.ts +38 -0
  213. package/dist/review-render.js +224 -0
  214. package/dist/review-report.d.ts +9 -0
  215. package/dist/review-report.js +89 -0
  216. package/dist/review-ui.d.ts +14 -0
  217. package/dist/review-ui.js +1248 -0
  218. package/dist/schedule.d.ts +78 -0
  219. package/dist/schedule.js +480 -0
  220. package/dist/screen-capture-provider.d.ts +52 -0
  221. package/dist/screen-capture-provider.js +218 -0
  222. package/dist/screenshot-workflow.d.ts +167 -0
  223. package/dist/screenshot-workflow.js +1237 -0
  224. package/dist/settings.d.ts +1 -1
  225. package/dist/settings.js +95 -7
  226. package/dist/site-export.d.ts +18 -0
  227. package/dist/site-export.js +87 -0
  228. package/dist/source-connectors.d.ts +33 -0
  229. package/dist/source-connectors.js +268 -0
  230. package/dist/source-discovery.d.ts +132 -0
  231. package/dist/source-discovery.js +823 -0
  232. package/dist/source-intelligence.d.ts +9 -0
  233. package/dist/source-intelligence.js +306 -0
  234. package/dist/sync-review.d.ts +28 -0
  235. package/dist/sync-review.js +264 -0
  236. package/dist/sync-runs.d.ts +192 -0
  237. package/dist/sync-runs.js +2244 -0
  238. package/dist/sync.d.ts +35 -0
  239. package/dist/sync.js +298 -32
  240. package/dist/text-diff.d.ts +9 -0
  241. package/dist/text-diff.js +59 -0
  242. package/dist/types.d.ts +946 -1
  243. package/dist/ui/assets/doxloop-logo-light-De7Nx7j7.png +0 -0
  244. package/dist/ui/assets/index-BHBYU2aG.css +1 -0
  245. package/dist/ui/assets/index-Cq3RPQiC.js +33 -0
  246. package/dist/ui/index.html +18 -0
  247. package/dist/ui-server.d.ts +83 -0
  248. package/dist/ui-server.js +3532 -0
  249. package/dist/usage-budget.d.ts +28 -0
  250. package/dist/usage-budget.js +90 -0
  251. package/dist/validation.d.ts +25 -1
  252. package/dist/validation.js +312 -26
  253. package/dist/workspace-tools.d.ts +54 -0
  254. package/dist/workspace-tools.js +123 -0
  255. package/docs/agent-compatibility.md +50 -28
  256. package/docs/ci-and-automation.md +105 -66
  257. package/docs/doxbrix-http-api.md +8 -1
  258. package/docs/existing-documentation.md +80 -0
  259. package/docs/generation-performance.md +108 -0
  260. package/docs/generator-authoring.md +68 -5
  261. package/docs/generator-selection.md +50 -13
  262. package/docs/mintlify-import.md +71 -0
  263. package/docs/openapi-security.md +25 -0
  264. package/docs/project-format.md +331 -36
  265. package/docs/release-quality.md +158 -0
  266. package/docs/releasing.md +72 -0
  267. package/docs/review-workflows.md +51 -0
  268. package/docs/security-model.md +102 -39
  269. package/docs/troubleshooting.md +226 -93
  270. package/package.json +46 -15
  271. package/scripts/test-auto-screenshot.mjs +172 -0
  272. package/skills/doxloop-authoring/SKILL.md +242 -361
  273. package/skills/doxloop-authoring/references/existing-documentation.md +94 -0
  274. package/skills/doxloop-authoring/references/navigation-architecture.md +18 -7
  275. package/skills/doxloop-authoring/references/page-depth.md +169 -0
  276. package/skills/doxloop-authoring/references/project-format.md +106 -7
  277. package/skills/doxloop-authoring/references/quality.md +10 -0
  278. package/skills/doxloop-authoring/references/screenshot-manifest.md +113 -0
  279. package/skills/doxloop-authoring/references/screenshots.md +149 -235
  280. package/skills/doxloop-authoring/references/workflows.md +76 -0
  281. package/skills/doxloop-doxbrix/SKILL.md +38 -22
  282. package/skills/doxloop-doxbrix/references/api-endpoints.md +15 -14
  283. package/skills/doxloop-doxbrix/references/components.md +30 -3
  284. package/skills/doxloop-doxbrix/references/manifest.md +3 -2
  285. package/vendor/doxbrix-import/LICENSE +202 -0
  286. package/vendor/doxbrix-import/README.md +17 -0
  287. package/vendor/doxbrix-import/UPSTREAM.json +47 -0
  288. package/vendor/doxbrix-import/dist/docs/frontmatter.d.ts +13 -0
  289. package/vendor/doxbrix-import/dist/docs/frontmatter.js +83 -0
  290. package/vendor/doxbrix-import/dist/docs/import.d.ts +21 -0
  291. package/vendor/doxbrix-import/dist/docs/import.js +147 -0
  292. package/vendor/doxbrix-import/dist/docs/manifest.d.ts +163 -0
  293. package/vendor/doxbrix-import/dist/docs/manifest.js +64 -0
  294. package/vendor/doxbrix-import/dist/docs/project.d.ts +25 -0
  295. package/vendor/doxbrix-import/dist/docs/project.js +77 -0
  296. package/vendor/doxbrix-import/dist/docs/starter.d.ts +4 -0
  297. package/vendor/doxbrix-import/dist/docs/starter.js +11 -0
  298. package/vendor/doxbrix-import/dist/importer.d.ts +227 -0
  299. package/vendor/doxbrix-import/dist/importer.js +1567 -0
  300. package/vendor/doxbrix-import/dist/mintlify-openapi.d.ts +37 -0
  301. package/vendor/doxbrix-import/dist/mintlify-openapi.js +305 -0
  302. package/vendor/doxbrix-import/dist/safe-path.d.ts +9 -0
  303. package/vendor/doxbrix-import/dist/safe-path.js +47 -0
  304. package/dist/agents.d.ts.map +0 -1
  305. package/dist/agents.js.map +0 -1
  306. package/dist/args.d.ts.map +0 -1
  307. package/dist/args.js.map +0 -1
  308. package/dist/artifact-deploy.d.ts.map +0 -1
  309. package/dist/artifact-deploy.js.map +0 -1
  310. package/dist/auth.d.ts.map +0 -1
  311. package/dist/auth.js.map +0 -1
  312. package/dist/author.d.ts.map +0 -1
  313. package/dist/author.js.map +0 -1
  314. package/dist/capture.d.ts.map +0 -1
  315. package/dist/capture.js.map +0 -1
  316. package/dist/cli.d.ts.map +0 -1
  317. package/dist/cli.js.map +0 -1
  318. package/dist/deploy.d.ts.map +0 -1
  319. package/dist/deploy.js.map +0 -1
  320. package/dist/deployment-visibility.d.ts.map +0 -1
  321. package/dist/deployment-visibility.js.map +0 -1
  322. package/dist/doctor.d.ts.map +0 -1
  323. package/dist/doctor.js.map +0 -1
  324. package/dist/doxbrix-markdown.d.ts.map +0 -1
  325. package/dist/doxbrix-markdown.js.map +0 -1
  326. package/dist/errors.d.ts.map +0 -1
  327. package/dist/errors.js.map +0 -1
  328. package/dist/fs.d.ts.map +0 -1
  329. package/dist/fs.js.map +0 -1
  330. package/dist/generator-api.d.ts.map +0 -1
  331. package/dist/generator-api.js.map +0 -1
  332. package/dist/generator-manager.d.ts.map +0 -1
  333. package/dist/generator-manager.js.map +0 -1
  334. package/dist/generator-runtime.d.ts.map +0 -1
  335. package/dist/generator-runtime.js.map +0 -1
  336. package/dist/generators.d.ts.map +0 -1
  337. package/dist/generators.js.map +0 -1
  338. package/dist/interactive.d.ts.map +0 -1
  339. package/dist/interactive.js.map +0 -1
  340. package/dist/preview.d.ts.map +0 -1
  341. package/dist/preview.js.map +0 -1
  342. package/dist/progress.d.ts.map +0 -1
  343. package/dist/progress.js.map +0 -1
  344. package/dist/project.d.ts.map +0 -1
  345. package/dist/project.js.map +0 -1
  346. package/dist/prompts.d.ts.map +0 -1
  347. package/dist/prompts.js.map +0 -1
  348. package/dist/settings.d.ts.map +0 -1
  349. package/dist/settings.js.map +0 -1
  350. package/dist/sync.d.ts.map +0 -1
  351. package/dist/sync.js.map +0 -1
  352. package/dist/types.d.ts.map +0 -1
  353. package/dist/types.js.map +0 -1
  354. package/dist/validation.d.ts.map +0 -1
  355. package/dist/validation.js.map +0 -1
  356. package/dist/version.d.ts.map +0 -1
  357. package/dist/version.js.map +0 -1
@@ -0,0 +1,1237 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { copyFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { dirname, extname, isAbsolute, join, normalize, relative, resolve } from 'node:path';
4
+ import { PNG } from 'pngjs';
5
+ import { applicationUrl } from './application-url.js';
6
+ import { probeSignInWall } from './application-probe.js';
7
+ import { sessionCookieHeader } from './capture-auth.js';
8
+ import { DoxloopError } from './errors.js';
9
+ import { pathExists } from './fs.js';
10
+ export const SCREENSHOT_MANIFEST_FILE = join('.doxloop', 'screenshot-manifest.json');
11
+ /** The message for a sign-in wall the browser probe found. */
12
+ export function signInWallMessage(signInPath, context = 'project') {
13
+ return context === 'setup'
14
+ ? `The application sends visitors to its sign-in page (${signInPath}). Sign in once below so screenshots show the signed-in product.`
15
+ : `The application sends visitors to its sign-in page (${signInPath}). Sign in with the browser or save sign-in credentials under Settings → Visual evidence so screenshots show the signed-in product.`;
16
+ }
17
+ /** Generator-native committed asset root for guide screenshots. */
18
+ export const GUIDE_ASSET_ROOTS = {
19
+ doxbrix: 'assets/guides',
20
+ docusaurus: 'static/img/guides',
21
+ mkdocs: 'docs/assets/guides',
22
+ sphinx: '_static/guides',
23
+ hugo: 'static/images/guides',
24
+ vitepress: 'docs/public/images/guides',
25
+ markdoc: 'assets/guides',
26
+ nextra: 'public/images/guides',
27
+ starlight: 'public/images/guides',
28
+ jekyll: 'assets/images/guides',
29
+ static: 'site/assets/guides',
30
+ };
31
+ /**
32
+ * Create the guide asset directories before the agent opens a browser.
33
+ *
34
+ * The capture tool resolves its filename against the project root and does not
35
+ * create missing parents: a nested filename whose directory does not exist
36
+ * fails with ENOENT, and the run then reports screenshots it never took. The
37
+ * agent is told to create each directory, but a forgotten `mkdir` silently
38
+ * costs an entire guide, so Doxloop creates the predictable ones itself.
39
+ */
40
+ export async function prepareGuideAssetDirectories(workspace, generator, plan, contentDir = '') {
41
+ const root = GUIDE_ASSET_ROOTS[generator] ?? 'assets/guides';
42
+ const pages = plan?.pages.filter((page) => page.visuals && page.visuals.mode !== 'none') ?? [];
43
+ const names = new Set();
44
+ for (const page of pages) {
45
+ // Agents name the folder after the page id or its last path segment.
46
+ for (const candidate of [page.id, page.path.replaceAll('\\', '/').split('/').filter(Boolean).at(-1)]) {
47
+ const slug = candidate?.trim();
48
+ if (slug && /^[A-Za-z0-9._-]+$/.test(slug))
49
+ names.add(slug);
50
+ }
51
+ }
52
+ const created = [];
53
+ for (const name of names) {
54
+ const relative = join(contentDir || '', root, name);
55
+ try {
56
+ await mkdir(join(workspace, relative), { recursive: true });
57
+ created.push(relative);
58
+ }
59
+ catch {
60
+ // A directory Doxloop cannot create is reported by capture validation.
61
+ }
62
+ }
63
+ return created;
64
+ }
65
+ /**
66
+ * Write the approved guides into the manifest before the agent runs.
67
+ *
68
+ * Left to build the manifest itself, an agent builds it at the end out of what
69
+ * it happened to capture: guides it decided to skip — often without ever
70
+ * opening their startPath — simply do not appear, and the run reports success
71
+ * while quietly dropping half the approved work. Staging every approved guide
72
+ * up front turns capture into filling in a form, and a guide the agent never
73
+ * touched stays visible as an unfinished row instead of vanishing.
74
+ *
75
+ * Existing manifests are never overwritten, so a resumed or recovered run keeps
76
+ * the work it already recorded.
77
+ */
78
+ export async function writeScreenshotManifestSkeleton(workspace, plan) {
79
+ const path = join(workspace, SCREENSHOT_MANIFEST_FILE);
80
+ if (!plan || (await pathExists(path)))
81
+ return 0;
82
+ const pages = plan.pages.filter((page) => page.visuals && page.visuals.mode !== 'none');
83
+ if (pages.length === 0)
84
+ return 0;
85
+ const guides = pages.map((page) => {
86
+ const sequence = page.visuals?.captureSequence ?? [];
87
+ const start = page.visuals?.startPath ?? '/';
88
+ const total = Math.max(1, sequence.length || page.visuals?.estimatedCaptures || 1);
89
+ const steps = Array.from({ length: total }, (_, index) => {
90
+ const [action, expectedState, purpose] = (sequence[index] ?? '').split(/\s+—\s+/, 3);
91
+ return {
92
+ id: String(index + 1).padStart(2, '0'),
93
+ // Approved sequence text can be as terse as "Open /", which the manifest
94
+ // contract rejects; expand it here rather than staging an invalid row.
95
+ action: specificManifestAction(action, undefined) ?? `Open ${start} and reach approved capture ${index + 1}.`,
96
+ expectedState: (expectedState?.trim().length ?? 0) >= 8 ? expectedState.trim() : `The state approved capture ${index + 1} names is visible.`,
97
+ purpose: (purpose?.trim().length ?? 0) >= 8 ? purpose.trim() : 'Prove this state for the reader of this guide.',
98
+ sequenceItem: index + 1,
99
+ capture: true,
100
+ status: 'planned',
101
+ };
102
+ });
103
+ return { page: page.id, steps };
104
+ });
105
+ await mkdir(join(workspace, '.doxloop'), { recursive: true });
106
+ await writeFile(path, JSON.stringify({ schemaVersion: 1, guides }, null, 2), 'utf8');
107
+ return guides.length;
108
+ }
109
+ /**
110
+ * Claim images the agent captured but never recorded as captures.
111
+ *
112
+ * An agent cannot see a PNG file, so any manifest row it fills in is bookkeeping
113
+ * rather than observation. A cautious agent that is asked to inspect the saved
114
+ * image concludes it cannot, and honestly records every step as text-only —
115
+ * discarding a directory of perfectly good screenshots and failing a run that
116
+ * actually worked. The state was already confirmed in the browser before the
117
+ * shutter, and Doxloop checks the file itself during validation, so adopt an
118
+ * unclaimed image into the step its filename names and let that validation
119
+ * judge it. The images are surfaced for human review either way.
120
+ */
121
+ export async function adoptCapturedImages(workspace, generator, plan) {
122
+ const path = join(workspace, SCREENSHOT_MANIFEST_FILE);
123
+ if (!(await pathExists(path)))
124
+ return [];
125
+ let manifest;
126
+ try {
127
+ manifest = JSON.parse(await readFile(path, 'utf8'));
128
+ }
129
+ catch {
130
+ return [];
131
+ }
132
+ if (!Array.isArray(manifest.guides))
133
+ return [];
134
+ const root = GUIDE_ASSET_ROOTS[generator] ?? 'assets/guides';
135
+ const contentDir = plan?.target.contentDir || '';
136
+ const claimed = new Set();
137
+ for (const guide of manifest.guides) {
138
+ for (const step of guide?.steps ?? [])
139
+ if (step?.file)
140
+ claimed.add(step.file.replaceAll('\\', '/'));
141
+ }
142
+ const adopted = [];
143
+ for (const guide of manifest.guides) {
144
+ if (!guide || typeof guide.page !== 'string' || !Array.isArray(guide.steps))
145
+ continue;
146
+ const page = plan?.pages.find((item) => item.id === guide.page || item.path === guide.page);
147
+ const sequence = page?.visuals?.captureSequence ?? [];
148
+ const usedItems = new Set(guide.steps.filter((step) => step?.capture && step.sequenceItem).map((step) => step.sequenceItem));
149
+ // Agents name the guide folder after the page id or a trailing path segment.
150
+ const slugs = new Set();
151
+ for (const candidate of [page?.id, page?.path, guide.page]) {
152
+ const slug = candidate?.replaceAll('\\', '/').split('/').filter(Boolean).at(-1)?.trim();
153
+ if (slug && /^[A-Za-z0-9._-]+$/.test(slug))
154
+ slugs.add(slug);
155
+ }
156
+ for (const slug of slugs) {
157
+ const relative = join(contentDir, root, slug).replaceAll('\\', '/');
158
+ let entries;
159
+ try {
160
+ entries = await readdir(join(workspace, relative));
161
+ }
162
+ catch {
163
+ continue;
164
+ }
165
+ for (const entry of entries.sort()) {
166
+ if (extname(entry).toLowerCase() !== '.png')
167
+ continue;
168
+ const file = `${relative}/${entry}`;
169
+ if (claimed.has(file))
170
+ continue;
171
+ // Captures are named after the step they prove, so the leading number is
172
+ // the only mapping that cannot silently attach an image to a step it
173
+ // does not show. Without one, leave the file for a human to place.
174
+ const ordinal = stepOrdinal(entry);
175
+ const step = guide.steps.find((item) => item && !item.file && item.status !== 'verified' && ordinal !== undefined && stepOrdinal(item.id) === ordinal);
176
+ if (!step)
177
+ continue;
178
+ // An approved capture sequence still has to line up, or validation will
179
+ // reject the very row this repair just wrote.
180
+ if (sequence.length > 0) {
181
+ const item = step.sequenceItem;
182
+ if (!item || item < 1 || item > sequence.length || usedItems.has(item))
183
+ continue;
184
+ usedItems.add(item);
185
+ }
186
+ step.capture = true;
187
+ step.status = 'verified';
188
+ step.file = file;
189
+ step.target = step.target?.trim() || step.action;
190
+ step.alt = step.alt?.trim() || step.expectedState;
191
+ step.checks = { expectedStateConfirmed: true, privacyReviewed: true, legibilityReviewed: true, meaningful: true };
192
+ delete step.textOnlyReason;
193
+ claimed.add(file);
194
+ adopted.push(file);
195
+ }
196
+ }
197
+ }
198
+ if (adopted.length > 0)
199
+ await writeFile(path, JSON.stringify(manifest, null, 2), 'utf8');
200
+ return adopted;
201
+ }
202
+ /** Leading step number of a manifest id or capture filename, if it has one. */
203
+ function stepOrdinal(value) {
204
+ const digits = /^\D*(\d+)/.exec(value)?.[1];
205
+ return digits === undefined ? undefined : Number(digits);
206
+ }
207
+ /**
208
+ * Reduce a guide that photographed one screen several times to a single image.
209
+ *
210
+ * An approved capture sequence can ask for states that are not actually
211
+ * distinct — "scroll to the history area", "focus the request field", "inspect
212
+ * the coverage panel" — and on a screen that already fits the viewport each of
213
+ * those produces a byte-identical file. The agent followed the approved plan
214
+ * and there is no better image to be had, so failing the run punishes it for
215
+ * the plan's optimism. Keep the first image of each screen, and record the rest
216
+ * as text-only exactly as the instructions ask an agent to do by hand.
217
+ *
218
+ * Repeats are collapsed within a guide only. Two guides showing the same screen
219
+ * is ordinary documentation and is left alone.
220
+ */
221
+ /** Whether any other manifest step, in any guide, still uses this image file. */
222
+ function fileUsedByOtherSteps(manifest, self, file) {
223
+ return manifest.guides.some((guide) => Array.isArray(guide?.steps) && guide.steps.some((step) => step !== self && step?.file === file && step.status === 'verified'));
224
+ }
225
+ export async function collapseDuplicateCaptures(workspace, plan) {
226
+ const path = join(workspace, SCREENSHOT_MANIFEST_FILE);
227
+ if (!(await pathExists(path)))
228
+ return [];
229
+ let manifest;
230
+ try {
231
+ manifest = JSON.parse(await readFile(path, 'utf8'));
232
+ }
233
+ catch {
234
+ return [];
235
+ }
236
+ if (!Array.isArray(manifest.guides))
237
+ return [];
238
+ const dropped = [];
239
+ for (const guide of manifest.guides) {
240
+ if (!guide || !Array.isArray(guide.steps))
241
+ continue;
242
+ const page = plan?.pages.find((item) => item.id === guide.page || item.path === guide.page);
243
+ const pagePath = plan && page ? await guidePagePath(workspace, plan, page) : undefined;
244
+ const seen = new Map();
245
+ for (const step of guide.steps) {
246
+ if (!step?.capture || step.status !== 'verified' || !step.file)
247
+ continue;
248
+ let hash;
249
+ try {
250
+ hash = createHash('sha256').update(await readFile(safeWorkspacePath(workspace, step.file))).digest('hex');
251
+ }
252
+ catch {
253
+ continue;
254
+ }
255
+ const original = seen.get(hash);
256
+ if (!original) {
257
+ seen.set(hash, step.file);
258
+ continue;
259
+ }
260
+ const file = step.file;
261
+ // Steps can point at one shared file (a planning capture reused by
262
+ // several steps and guides). Removing or quarantining it would break
263
+ // the step that keeps it, so a same-file repeat only becomes text-only.
264
+ if (file !== original) {
265
+ if (pagePath) {
266
+ const content = await readFile(pagePath, 'utf8');
267
+ const stripped = removeImageReference(content, file);
268
+ if (stripped !== content)
269
+ await writeFile(pagePath, stripped, 'utf8');
270
+ }
271
+ if (!fileUsedByOtherSteps(manifest, step, file))
272
+ await quarantineImage(workspace, file);
273
+ }
274
+ step.capture = false;
275
+ step.status = 'text-only';
276
+ step.textOnlyReason = `This step shows the same screen as "${original}", so it adds no new image.`;
277
+ delete step.file;
278
+ delete step.target;
279
+ delete step.alt;
280
+ delete step.checks;
281
+ dropped.push(file);
282
+ }
283
+ }
284
+ if (dropped.length > 0)
285
+ await writeFile(path, JSON.stringify(manifest, null, 2), 'utf8');
286
+ return dropped;
287
+ }
288
+ /** Drop an image reference, and the frame wrapper it leaves behind. */
289
+ export function removeImageReference(content, file) {
290
+ const basename = file.replaceAll('\\', '/').split('/').at(-1);
291
+ const lines = content.split('\n');
292
+ const kept = [];
293
+ for (let index = 0; index < lines.length; index += 1) {
294
+ const line = lines[index] ?? '';
295
+ if (!line.includes(basename)) {
296
+ kept.push(line);
297
+ continue;
298
+ }
299
+ let previous = kept.length - 1;
300
+ while (previous >= 0 && (kept[previous] ?? '').trim() === '')
301
+ previous -= 1;
302
+ let next = index + 1;
303
+ while (next < lines.length && (lines[next] ?? '').trim() === '')
304
+ next += 1;
305
+ if (previous >= 0 && /^<Frame\b/.test((kept[previous] ?? '').trim()) && (lines[next] ?? '').trim() === '</Frame>') {
306
+ kept.length = previous;
307
+ index = next;
308
+ }
309
+ }
310
+ return kept.join('\n').replace(/\n{3,}/g, '\n\n');
311
+ }
312
+ /**
313
+ * Place verified captures the agent left orphaned.
314
+ *
315
+ * Agents routinely capture every planned state and then embed only the first,
316
+ * because a step component holds the procedure and there is no obvious place to
317
+ * put the rest. The images are real and reviewed, so dropping the run over
318
+ * placement wastes correct work: insert each unused capture into the step it
319
+ * belongs to, matching capture order to step order, and fall back to the end of
320
+ * the page when the guide has no step markup.
321
+ */
322
+ export async function embedMissingCaptures(workspace, plan) {
323
+ const path = join(workspace, SCREENSHOT_MANIFEST_FILE);
324
+ if (!(await pathExists(path)))
325
+ return [];
326
+ let manifest;
327
+ try {
328
+ manifest = JSON.parse(await readFile(path, 'utf8'));
329
+ }
330
+ catch {
331
+ return [];
332
+ }
333
+ if (!Array.isArray(manifest.guides))
334
+ return [];
335
+ const embedded = [];
336
+ for (const guide of manifest.guides) {
337
+ if (!guide || !Array.isArray(guide.steps))
338
+ continue;
339
+ const page = plan?.pages.find((item) => item.id === guide.page || item.path === guide.page);
340
+ const pagePath = plan && page ? await guidePagePath(workspace, plan, page) : undefined;
341
+ if (!pagePath)
342
+ continue;
343
+ let content = await readFile(pagePath, 'utf8');
344
+ const captures = guide.steps.filter((step) => step.capture && step.status === 'verified' && step.file);
345
+ // The same screen saved under another name is already on the page.
346
+ const shown = await pageImageHashes(workspace, content);
347
+ for (const [order, step] of captures.entries()) {
348
+ const file = step.file.replaceAll('\\', '/');
349
+ const basename = file.split('/').at(-1);
350
+ if (content.includes(file) || content.includes(basename))
351
+ continue;
352
+ const absolute = safeWorkspacePath(workspace, file);
353
+ if (!(await pathExists(absolute)))
354
+ continue;
355
+ const hash = await fileHash(absolute);
356
+ if (hash && shown.has(hash))
357
+ continue;
358
+ const reference = imageReference(content, file);
359
+ const image = `![${(step.alt ?? step.expectedState ?? basename).replaceAll(']', ')')}](${reference})`;
360
+ const next = insertIntoStep(content, image, order);
361
+ if (next === undefined)
362
+ continue;
363
+ content = next;
364
+ if (hash)
365
+ shown.add(hash);
366
+ embedded.push(step.file);
367
+ }
368
+ if (embedded.length > 0)
369
+ await writeFile(pagePath, content, 'utf8');
370
+ }
371
+ return embedded;
372
+ }
373
+ /** Match the path style the page already uses for its other captures. */
374
+ function imageReference(content, file) {
375
+ return /\]\(\/assets|\]\(\/img|\]\(\/images|\]\(\/_static|\]\(\//.test(content) ? `/${file}` : file;
376
+ }
377
+ /**
378
+ * Insert inside the nth `<Step>` body, or append when there is no step markup.
379
+ * A step that already shows an image is left alone: a second, unrelated
380
+ * screen under text that describes the first misleads the reader.
381
+ */
382
+ function insertIntoStep(content, image, order) {
383
+ const closes = [...content.matchAll(/\n?[ \t]*<\/Step>/g)];
384
+ const target = closes[order];
385
+ if (!target || target.index === undefined)
386
+ return `${content.trimEnd()}\n\n${image}\n`;
387
+ const opening = content.lastIndexOf('<Step', target.index);
388
+ const body = opening >= 0 ? content.slice(opening, target.index) : '';
389
+ if (/!\[[^\]]*\]\(|<img\b/.test(body))
390
+ return undefined;
391
+ return `${content.slice(0, target.index)}\n\n${image}\n${content.slice(target.index)}`;
392
+ }
393
+ async function fileHash(path) {
394
+ try {
395
+ return createHash('sha256').update(await readFile(path)).digest('hex');
396
+ }
397
+ catch {
398
+ return undefined;
399
+ }
400
+ }
401
+ /** Content hashes of the images a page already shows, resolved inside the workspace. */
402
+ async function pageImageHashes(workspace, content) {
403
+ const hashes = new Set();
404
+ for (const match of content.matchAll(/!\[[^\]]*\]\(([^)\s]+)\)|<img[^>]*\ssrc=["']([^"']+)["']/g)) {
405
+ const target = (match[1] ?? match[2] ?? '').replace(/^\/+/, '');
406
+ if (!target || /^[a-z]+:/i.test(target))
407
+ continue;
408
+ try {
409
+ const hash = await fileHash(safeWorkspacePath(workspace, target));
410
+ if (hash)
411
+ hashes.add(hash);
412
+ }
413
+ catch { /* an unsafe or missing path is simply not counted */ }
414
+ }
415
+ return hashes;
416
+ }
417
+ async function guidePagePath(workspace, plan, page) {
418
+ const root = join(workspace, plan.target.contentDir || '');
419
+ for (const candidate of [
420
+ join(root, page.path),
421
+ ...plan.target.pageExtensions.map((extension) => join(root, `${page.path}${extension.startsWith('.') ? extension : `.${extension}`}`)),
422
+ ...plan.target.pageExtensions.map((extension) => join(root, page.path, `index${extension.startsWith('.') ? extension : `.${extension}`}`)),
423
+ ]) {
424
+ if (await isFilePath(candidate))
425
+ return candidate;
426
+ }
427
+ return undefined;
428
+ }
429
+ export function normalizeScreenshotIntent(value) {
430
+ if (value === true || value === 'enabled')
431
+ return 'enabled';
432
+ if (value === false || value === 'disabled')
433
+ return 'disabled';
434
+ return 'auto';
435
+ }
436
+ export async function assertScreenshotPlanningReadiness(application, rawIntent, auth) {
437
+ if (normalizeScreenshotIntent(rawIntent) !== 'enabled')
438
+ return;
439
+ const readiness = await checkApplicationReadiness(application, auth);
440
+ if (readiness.status === 'ready')
441
+ return;
442
+ throw new DoxloopError(`Screenshots are selected, but capture cannot start. ${readiness.message} Start or configure a safe non-production application page, then check it again before planning.`);
443
+ }
444
+ export function screenshotPlanSummary(plan) {
445
+ const pages = plan.pages.filter((page) => page.visuals && page.visuals.mode !== 'none');
446
+ return {
447
+ guides: pages.length,
448
+ captures: pages.reduce((total, page) => total + Math.max(1, page.visuals?.estimatedCaptures ?? 0), 0),
449
+ };
450
+ }
451
+ const MAX_READINESS_REDIRECTS = 5;
452
+ export async function checkApplicationReadiness(application, auth, options = {}) {
453
+ const readiness = await httpApplicationReadiness(application, auth);
454
+ if (!options.browserProbe || readiness.status !== 'ready' || !readiness.url)
455
+ return readiness;
456
+ // Sign-in material already handles a wall; only an anonymous visit needs the browser.
457
+ if (readiness.authentication !== 'none' || auth?.credentials)
458
+ return readiness;
459
+ const probe = typeof options.browserProbe === 'function' ? options.browserProbe : probeSignInWall;
460
+ let wall;
461
+ try {
462
+ wall = await probe(readiness.url);
463
+ }
464
+ catch {
465
+ return readiness;
466
+ }
467
+ if (!wall)
468
+ return readiness;
469
+ return {
470
+ configured: true,
471
+ reachable: true,
472
+ status: 'authentication-required',
473
+ url: readiness.url,
474
+ authentication: 'none',
475
+ signInPath: wall.signInPath,
476
+ message: signInWallMessage(wall.signInPath, options.context),
477
+ };
478
+ }
479
+ async function httpApplicationReadiness(application, auth) {
480
+ if (!application) {
481
+ return {
482
+ configured: false,
483
+ reachable: false,
484
+ status: 'not-configured',
485
+ message: 'Configure a safe local or test application before capturing screenshots.',
486
+ };
487
+ }
488
+ const readinessPath = application.readyPath ?? application.screenshots?.startPath;
489
+ const url = readinessPath ? applicationUrl(application.baseUrl, readinessPath).toString() : application.baseUrl;
490
+ const cookie = sessionCookieHeader(auth?.session, url);
491
+ const signInRequired = (signInPath) => {
492
+ if (auth?.credentials) {
493
+ return {
494
+ configured: true,
495
+ reachable: true,
496
+ status: 'ready',
497
+ url,
498
+ authentication: 'credentials',
499
+ message: auth.session
500
+ ? 'The saved browser session no longer signs in, so Doxloop will sign in with the saved credentials during capture.'
501
+ : 'The application asks for sign-in. Doxloop will sign in with the saved credentials during capture.',
502
+ };
503
+ }
504
+ return {
505
+ configured: true,
506
+ reachable: true,
507
+ status: 'authentication-required',
508
+ url,
509
+ authentication: auth?.session ? 'expired' : 'none',
510
+ ...(signInPath ? { signInPath } : {}),
511
+ message: auth?.session
512
+ ? 'The saved browser session has expired. Sign in with the browser again under Settings → Visual evidence before capturing.'
513
+ : 'The application is reachable but needs sign-in. Sign in with the browser or save sign-in credentials under Settings → Visual evidence.',
514
+ };
515
+ };
516
+ const origin = new URL(url).origin;
517
+ const ready = (redirectedTo) => {
518
+ const landing = redirectedTo ? ` It redirects to ${redirectedTo}, which is where the capture browser will land.` : '';
519
+ return {
520
+ configured: true,
521
+ reachable: true,
522
+ status: 'ready',
523
+ url,
524
+ authentication: cookie ? 'session' : 'none',
525
+ message: cookie
526
+ ? `The application page is reachable with the saved browser session.${landing} Doxloop will start its capture browser signed in.`
527
+ : `The application page is reachable.${landing} Doxloop will start its capture browser during documentation generation.`,
528
+ };
529
+ };
530
+ try {
531
+ // Follow same-origin redirects: a signed-in session bounced from a
532
+ // sign-up or landing route to the app shell is still a reachable page.
533
+ // Only a sign-in route, a cross-origin destination, or a loop is a problem.
534
+ let current = url;
535
+ let redirectedTo;
536
+ for (let hop = 0; hop < MAX_READINESS_REDIRECTS; hop += 1) {
537
+ const hopCookie = current === url ? cookie : sessionCookieHeader(auth?.session, current);
538
+ const response = await fetch(current, {
539
+ method: 'GET',
540
+ redirect: 'manual',
541
+ signal: AbortSignal.timeout(5_000),
542
+ headers: { accept: 'text/html,application/json;q=0.9,*/*;q=0.1', ...(hopCookie ? { cookie: hopCookie } : {}) },
543
+ });
544
+ if (response.status === 401 || response.status === 403)
545
+ return signInRequired();
546
+ if (response.status >= 300 && response.status < 400) {
547
+ const location = response.headers.get('location');
548
+ let redirect;
549
+ try {
550
+ if (location)
551
+ redirect = new URL(location, current);
552
+ }
553
+ catch { /* Report the invalid redirect below. */ }
554
+ if (redirect && redirect.origin === origin) {
555
+ if (/(?:^|\/)(?:login|signin|sign-in|auth)(?:\/|$|\?)/i.test(`${redirect.pathname}${redirect.search}`))
556
+ return signInRequired(redirect.pathname);
557
+ redirectedTo = `${redirect.pathname}${redirect.search}`;
558
+ current = redirect.toString();
559
+ continue;
560
+ }
561
+ return {
562
+ configured: true,
563
+ reachable: false,
564
+ status: 'unreachable',
565
+ url,
566
+ message: location
567
+ ? `The application readiness check redirected to ${location}, outside the configured application; configure the final safe application URL or an authentication route.`
568
+ : 'The application readiness check returned a redirect without a destination.',
569
+ };
570
+ }
571
+ if (!response.ok) {
572
+ return {
573
+ configured: true,
574
+ reachable: false,
575
+ status: 'unreachable',
576
+ url,
577
+ message: `The application readiness check returned HTTP ${response.status}${redirectedTo ? ` after redirecting to ${redirectedTo}` : ''}.`,
578
+ };
579
+ }
580
+ return ready(redirectedTo);
581
+ }
582
+ return {
583
+ configured: true,
584
+ reachable: false,
585
+ status: 'unreachable',
586
+ url,
587
+ message: `The application readiness check followed ${MAX_READINESS_REDIRECTS} redirects without reaching a page; configure the final safe application URL.`,
588
+ };
589
+ }
590
+ catch (cause) {
591
+ return {
592
+ configured: true,
593
+ reachable: false,
594
+ status: 'unreachable',
595
+ url,
596
+ message: `The application could not be reached: ${cause instanceof Error ? cause.message : String(cause)}`,
597
+ };
598
+ }
599
+ }
600
+ export async function validateScreenshotManifest(workspace, plan, rawIntent, options = {}) {
601
+ const intent = normalizeScreenshotIntent(rawIntent);
602
+ const dryRun = options.dryRun === true;
603
+ const tolerate = options.tolerateDefects === true || dryRun;
604
+ const effectivePlan = plan ?? await workspaceDocumentationPlan(workspace);
605
+ const plannedPages = effectivePlan?.pages.filter((page) => page.visuals && page.visuals.mode !== 'none') ?? [];
606
+ // The run-level choice is authoritative. A user changing the plan from
607
+ // Required to Automatic must make all visual rows best-effort, including
608
+ // rows that were originally proposed with visuals.mode "required".
609
+ const required = intent === 'enabled';
610
+ const expected = effectivePlan ? screenshotPlanSummary(effectivePlan) : { guides: 0, captures: 0 };
611
+ if (intent === 'disabled' || (effectivePlan && plannedPages.length === 0 && intent !== 'enabled')) {
612
+ return {
613
+ summary: { intent, status: 'not-requested', planned: 0, captured: 0, textOnly: 0, guides: 0 },
614
+ defects: [],
615
+ };
616
+ }
617
+ if (effectivePlan && plannedPages.length === 0 && intent === 'enabled') {
618
+ if (!tolerate)
619
+ throw new DoxloopError('Screenshots are required, but the approved plan has no screenshot-enabled UI guide.');
620
+ return {
621
+ summary: { intent, status: 'skipped', planned: 0, captured: 0, textOnly: 0, guides: 0, ignoredProblems: 1, message: 'Screenshots were required, but the approved plan has no screenshot-enabled UI guide. The run was accepted without images.' },
622
+ defects: ['The approved plan has no screenshot-enabled UI guide.'],
623
+ };
624
+ }
625
+ const path = join(workspace, SCREENSHOT_MANIFEST_FILE);
626
+ if (!(await pathExists(path))) {
627
+ if (required && !tolerate) {
628
+ throw new DoxloopError('Screenshots are required, but the agent did not create .doxloop/screenshot-manifest.json. Retry capture or change the run to Automatic/No screenshots.');
629
+ }
630
+ return {
631
+ summary: {
632
+ intent,
633
+ status: effectivePlan ? 'skipped' : 'not-requested',
634
+ planned: expected.captures,
635
+ captured: 0,
636
+ textOnly: 0,
637
+ guides: expected.guides,
638
+ ...(required ? { ignoredProblems: 1 } : {}),
639
+ message: required
640
+ ? 'Screenshots were required, but no capture manifest was produced. The run was accepted without images.'
641
+ : 'Screenshot candidates were planned, but no verified capture manifest was produced.',
642
+ },
643
+ defects: required ? ['No capture manifest was produced.'] : [],
644
+ };
645
+ }
646
+ let manifest;
647
+ try {
648
+ manifest = JSON.parse(await readFile(path, 'utf8'));
649
+ }
650
+ catch {
651
+ if (!tolerate)
652
+ throw new DoxloopError('The application screenshot manifest is not valid JSON.');
653
+ return unusableManifestSummary(intent, expected, 'The application screenshot manifest is not valid JSON.');
654
+ }
655
+ if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.guides)) {
656
+ if (!tolerate)
657
+ throw new DoxloopError('The application screenshot manifest must use schemaVersion 1 and contain a guides array.');
658
+ return unusableManifestSummary(intent, expected, 'The application screenshot manifest does not use schemaVersion 1 with a guides array.');
659
+ }
660
+ const planned = new Map(plannedPages.map((page) => [page.id, page]));
661
+ const plannedByPath = new Map(plannedPages.map((page) => [page.path, page]));
662
+ const seenGuides = new Set();
663
+ const hashes = new Map();
664
+ // Every capture problem is collected and reported together. Failing on the
665
+ // first one hides the rest, so a run that fabricated ten verified rows looks
666
+ // like a single misplaced image and gets "fixed" one retry at a time.
667
+ const defects = [];
668
+ // Reviewable problems: reported on the run, never fatal.
669
+ const warnings = [];
670
+ // Guides that are structurally unusable are dropped from the tolerated manifest.
671
+ const droppedGuides = new Set();
672
+ let captured = 0;
673
+ let textOnly = 0;
674
+ let mutated = false;
675
+ // A problem is fatal in strict mode; in tolerant mode it is recorded and the
676
+ // step it concerns is downgraded so the manifest stays truthful.
677
+ const structural = (message) => {
678
+ if (!tolerate)
679
+ throw new DoxloopError(message);
680
+ defects.push(message);
681
+ };
682
+ const downgrade = async (step, guidePage, reason, removeImage) => {
683
+ if (dryRun)
684
+ return;
685
+ const file = step.file;
686
+ if (file && effectivePlan && guidePage) {
687
+ const pagePath = await guidePagePath(workspace, effectivePlan, guidePage);
688
+ if (pagePath) {
689
+ const content = await readFile(pagePath, 'utf8');
690
+ const stripped = removeImageReference(content, file);
691
+ if (stripped !== content)
692
+ await writeFile(pagePath, stripped, 'utf8');
693
+ }
694
+ }
695
+ if (file && removeImage && !(Array.isArray(manifest.guides) && fileUsedByOtherSteps(manifest, step, file))) {
696
+ try {
697
+ await quarantineImage(workspace, file);
698
+ }
699
+ catch {
700
+ // A file outside the workspace was never a capture of this run.
701
+ }
702
+ }
703
+ step.capture = false;
704
+ step.status = 'text-only';
705
+ step.textOnlyReason = reason;
706
+ delete step.file;
707
+ delete step.target;
708
+ delete step.alt;
709
+ delete step.checks;
710
+ textOnly += 1;
711
+ mutated = true;
712
+ };
713
+ for (const [guideIndex, guide] of manifest.guides.entries()) {
714
+ if (!guide || typeof guide.page !== 'string' || !Array.isArray(guide.steps)) {
715
+ structural(`Screenshot guide ${guideIndex + 1} needs a page and steps array.`);
716
+ droppedGuides.add(guideIndex);
717
+ continue;
718
+ }
719
+ const page = planned.get(guide.page) ?? plannedByPath.get(guide.page);
720
+ if (effectivePlan && !page) {
721
+ structural(`Screenshot guide "${guide.page}" is not a screenshot-enabled page in the approved plan.`);
722
+ droppedGuides.add(guideIndex);
723
+ continue;
724
+ }
725
+ const pageKey = page?.id ?? guide.page;
726
+ if (seenGuides.has(pageKey)) {
727
+ structural(`Screenshot guide "${guide.page}" appears more than once in the manifest.`);
728
+ droppedGuides.add(guideIndex);
729
+ continue;
730
+ }
731
+ seenGuides.add(pageKey);
732
+ if (guide.steps.length === 0) {
733
+ structural(`Screenshot guide "${guide.page}" has no planned steps.`);
734
+ droppedGuides.add(guideIndex);
735
+ continue;
736
+ }
737
+ const captureSequence = page?.visuals?.captureSequence ?? [];
738
+ // Approved capture-sequence items describe captured states only, so map
739
+ // them by capture ordinal: a text-only step must not shift the mapping.
740
+ let captureOrdinal = 0;
741
+ guide.steps = guide.steps.map((step) => {
742
+ const captured = step?.capture === true || step?.capture === 'required' || step?.capture === 'recommended';
743
+ return normalizeManifestStep(step, captureSequence, captured ? captureOrdinal++ : -1);
744
+ });
745
+ let shapeProblem;
746
+ for (const [stepIndex, step] of guide.steps.entries()) {
747
+ try {
748
+ validateStepShape(step, guide.page, stepIndex);
749
+ }
750
+ catch (error) {
751
+ if (!tolerate)
752
+ throw error;
753
+ shapeProblem = error instanceof Error ? error.message : String(error);
754
+ break;
755
+ }
756
+ }
757
+ if (shapeProblem) {
758
+ defects.push(shapeProblem);
759
+ droppedGuides.add(guideIndex);
760
+ continue;
761
+ }
762
+ const guideRequired = intent === 'enabled';
763
+ if (captureSequence.length > 0) {
764
+ const captureSteps = guide.steps.filter((step) => step.capture);
765
+ const sequenceItems = new Set();
766
+ for (const step of captureSteps) {
767
+ // A step numbered past the approved sequence is an extra state the
768
+ // writer found worth showing (a preview dialog after the approved
769
+ // result). It stays as a supplementary capture; the batch's screenshot
770
+ // maximum still bounds the total. Only a missing or repeated item
771
+ // means the approved story was not followed.
772
+ if (!step.sequenceItem || step.sequenceItem < 1 || sequenceItems.has(step.sequenceItem)) {
773
+ const message = `Screenshot step ${step.id} in "${guide.page}" must identify one unique approved capture-sequence item.`;
774
+ if (!tolerate)
775
+ throw new DoxloopError(message);
776
+ defects.push(message);
777
+ await downgrade(step, page, message, true);
778
+ continue;
779
+ }
780
+ sequenceItems.add(step.sequenceItem);
781
+ }
782
+ }
783
+ // A guide still entirely as Doxloop staged it was never attempted. Say that
784
+ // once, naming the guide, instead of repeating it for every step.
785
+ if (guide.steps.every((step) => step.status === 'planned')) {
786
+ const start = page?.visuals?.startPath;
787
+ const message = `Guide "${guide.page}" was never captured — every step is still marked planned.${start ? ` Open ${start} in the capture browser` : ' Open its start path in the capture browser'}, capture the states it names, or record each step as text-only with a specific reason.`;
788
+ defects.push(message);
789
+ if (tolerate) {
790
+ for (const step of guide.steps)
791
+ await downgrade(step, page, 'This state was not captured before the run was accepted with screenshot problems ignored.', false);
792
+ }
793
+ continue;
794
+ }
795
+ const defectsBeforeGuide = defects.length;
796
+ const guideHashes = new Map();
797
+ const guideFilesByHash = new Map();
798
+ let guideCaptured = 0;
799
+ for (const step of guide.steps) {
800
+ if (!step.capture) {
801
+ if (step.status !== 'text-only' || !step.textOnlyReason?.trim()) {
802
+ const message = `${step.id} in "${guide.page}" has no image and no text-only reason.`;
803
+ defects.push(message);
804
+ if (tolerate)
805
+ await downgrade(step, page, 'This state was not captured before the run was accepted with screenshot problems ignored.', false);
806
+ continue;
807
+ }
808
+ textOnly += 1;
809
+ continue;
810
+ }
811
+ if (step.status !== 'verified') {
812
+ const message = `${step.id} in "${guide.page}" is marked ${step.status}, not verified.`;
813
+ defects.push(message);
814
+ if (tolerate)
815
+ await downgrade(step, page, `This capture was recorded as ${step.status} when the run was accepted with screenshot problems ignored.`, true);
816
+ continue;
817
+ }
818
+ // `target` and `alt` are bookkeeping a run can fill in; only the image
819
+ // itself is irreplaceable. Treating a missing target as a defect once
820
+ // sent thirteen good planning captures to a retake and then deleted them.
821
+ if (step.file && !step.target?.trim()) {
822
+ step.target = step.action;
823
+ mutated = true;
824
+ }
825
+ if (step.file && !step.alt?.trim() && step.expectedState?.trim()) {
826
+ step.alt = step.expectedState.trim();
827
+ mutated = true;
828
+ }
829
+ if (!step.file || !step.alt?.trim()) {
830
+ const message = `${step.id} in "${guide.page}" needs a project-relative PNG file and useful alt text.`;
831
+ defects.push(message);
832
+ if (tolerate)
833
+ await downgrade(step, page, message, true);
834
+ continue;
835
+ }
836
+ if (!Object.values(step.checks ?? {}).length || !step.checks?.expectedStateConfirmed || !step.checks.privacyReviewed || !step.checks.legibilityReviewed || !step.checks.meaningful) {
837
+ const message = `${step.id} in "${guide.page}" has not passed expected-state, privacy, legibility, and meaningfulness review.`;
838
+ defects.push(message);
839
+ if (tolerate)
840
+ await downgrade(step, page, message, true);
841
+ continue;
842
+ }
843
+ const image = safeWorkspacePath(workspace, step.file);
844
+ if (extname(image).toLowerCase() !== '.png' || !(await pathExists(image))) {
845
+ const message = `${step.id} in "${guide.page}" is missing PNG file "${step.file}" — it was marked verified but never captured.`;
846
+ defects.push(message);
847
+ if (tolerate)
848
+ await downgrade(step, page, message, false);
849
+ continue;
850
+ }
851
+ const bytes = await readFile(image);
852
+ let parsed;
853
+ try {
854
+ parsed = PNG.sync.read(bytes);
855
+ }
856
+ catch {
857
+ const message = `"${step.file}" is not a readable PNG image.`;
858
+ defects.push(message);
859
+ if (tolerate)
860
+ await downgrade(step, page, message, true);
861
+ continue;
862
+ }
863
+ // Element captures of a toolbar or sidebar group are legitimately short
864
+ // or narrow; only an image too small to read anything in is rejected.
865
+ if (parsed.width < 200 || parsed.height < 100) {
866
+ const message = `"${step.file}" is too small to be useful (${parsed.width}×${parsed.height}).`;
867
+ defects.push(message);
868
+ if (tolerate)
869
+ await downgrade(step, page, message, true);
870
+ continue;
871
+ }
872
+ // A splash or skeleton screen is not visually empty — it has a logo and a
873
+ // line of text — but it is overwhelmingly one background color. Real
874
+ // application screens sit far below this share, so capturing before the
875
+ // page finished rendering is caught here rather than shipped.
876
+ const uniform = dominantColorShare(parsed);
877
+ if (sampledColors(parsed).size < 4 || uniform >= 0.98) {
878
+ const message = `"${step.file}" is ${(uniform * 100).toFixed(1)}% a single color, so it captured a blank, splash, or still-loading screen instead of "${step.expectedState}". Wait until the real content is rendered, confirm it in a snapshot, then capture.`;
879
+ defects.push(message);
880
+ if (tolerate)
881
+ await downgrade(step, page, message, true);
882
+ continue;
883
+ }
884
+ // Collect every duplicate instead of stopping at the first: an agent that
885
+ // could not reach a state usually recaptures the same screen for several
886
+ // steps, and the reviewer needs to see the whole pattern at once. Repeats
887
+ // are counted per guide, because two guides showing the same screen is
888
+ // ordinary documentation — a quickstart tour and the reference guide for
889
+ // that screen share it — while one guide repeating a screen means its
890
+ // workflow never advanced.
891
+ const hash = createHash('sha256').update(bytes).digest('hex');
892
+ hashes.set(hash, [...(hashes.get(hash) ?? []), step.file]);
893
+ guideHashes.set(hash, [...(guideHashes.get(hash) ?? []), step.file]);
894
+ guideFilesByHash.set(hash, [...(guideFilesByHash.get(hash) ?? []), step]);
895
+ // A generator's landing page legitimately lives somewhere other than its
896
+ // planned slug — a Doxbrix overview is written as index.mdx — so a page
897
+ // that is not where the plan predicted is a routing choice, not a missing
898
+ // screenshot. Only an image nothing references at all is a real defect.
899
+ const placement = effectivePlan && page
900
+ ? await guideReferencesImage(workspace, effectivePlan, page, step.file)
901
+ : 'page-missing';
902
+ const referenced = placement === 'embedded' ||
903
+ (placement === 'page-missing' && await workspaceReferencesImage(workspace, step.file));
904
+ if (!referenced) {
905
+ const message = placement === 'not-embedded'
906
+ ? `"${step.file}" is not embedded in its matching guide "${guide.page}". Place the image in that page, immediately after the step it proves.`
907
+ : `"${step.file}" is not referenced by any documentation page. Embed it in the guide it belongs to, or record that step as text-only.`;
908
+ defects.push(message);
909
+ if (tolerate)
910
+ await downgrade(step, page, message, true);
911
+ continue;
912
+ }
913
+ captured += 1;
914
+ guideCaptured += 1;
915
+ }
916
+ // One guide that photographed the same screen several times never advanced
917
+ // its workflow, so the extra images document nothing.
918
+ const guideRepeats = [...guideHashes.values()].map((files) => [...new Set(files)]).filter((files) => files.length > 1);
919
+ if (guideRepeats.length > 0) {
920
+ const groups = guideRepeats.map((files) => files.join(' = ')).join('; ');
921
+ defects.push(`Guide "${guide.page}" photographed the same screen more than once: ${groups}. Keep one image of that screen and record the other steps as text-only, or reach the states those steps name.`);
922
+ if (tolerate) {
923
+ for (const steps of guideFilesByHash.values()) {
924
+ for (const step of steps.slice(1)) {
925
+ if (step.status !== 'verified')
926
+ continue;
927
+ await downgrade(step, page, `This step shows the same screen as "${steps[0]?.file}", so it adds no new image.`, true);
928
+ captured -= 1;
929
+ guideCaptured -= 1;
930
+ }
931
+ }
932
+ }
933
+ }
934
+ // Only report the shortfall when nothing above already explained it, so the
935
+ // report says what is wrong rather than repeating the consequence.
936
+ const minimumCaptures = guideRequired ? 1 : 0;
937
+ if (guideCaptured < minimumCaptures && defects.length === defectsBeforeGuide) {
938
+ defects.push(`Guide "${guide.page}" requires ${minimumCaptures} verified capture${minimumCaptures === 1 ? '' : 's'}, but produced ${guideCaptured}.`);
939
+ }
940
+ }
941
+ // Two guides sharing a screen is normal documentation — a tour page and the
942
+ // reference page for that screen show the same thing — so this is reported
943
+ // for the reviewer rather than failing a run that did the work.
944
+ // One file placed in two guides is one screenshot, not a repeat of itself.
945
+ const duplicates = [...hashes.values()].map((files) => [...new Set(files)]).filter((files) => files.length > 1);
946
+ const repeated = duplicates.reduce((count, files) => count + files.length - 1, 0);
947
+ if (duplicates.length > 0) {
948
+ const groups = duplicates.map((files) => files.join(' = ')).join('; ');
949
+ warnings.push(`${repeated} of ${captured} captured screenshot${captured === 1 ? '' : 's'} show a screen another guide already shows: ${groups}. That is expected when guides overlap; confirm each one earns its place on its page.`);
950
+ }
951
+ if (defects.length > 0 && !tolerate) {
952
+ const shown = defects.slice(0, 12);
953
+ const rest = defects.length - shown.length;
954
+ throw new DoxloopError(`${defects.length} screenshot problem${defects.length === 1 ? '' : 's'} in this run:\n${shown.map((defect) => `- ${defect}`).join('\n')}${rest > 0 ? `\n- …and ${rest} more.` : ''}\nOnly mark a step verified after its image exists, shows the state its name claims, and is embedded in its guide.`);
955
+ }
956
+ const missing = intent === 'enabled'
957
+ ? plannedPages.filter((page) => !seenGuides.has(page.id))
958
+ : [];
959
+ if (missing.length > 0) {
960
+ const message = `The screenshot manifest is missing ${missing.map((page) => `"${page.title}"`).join(', ')}.`;
961
+ if (!tolerate)
962
+ throw new DoxloopError(message);
963
+ defects.push(message);
964
+ }
965
+ if (required && captured === 0 && !tolerate) {
966
+ throw new DoxloopError('Screenshots are required, but the manifest contains no verified captures.');
967
+ }
968
+ if (tolerate && !dryRun && (mutated || droppedGuides.size > 0)) {
969
+ manifest.guides = manifest.guides.filter((_guide, index) => !droppedGuides.has(index));
970
+ await writeFile(path, JSON.stringify(manifest, null, 2), 'utf8');
971
+ }
972
+ const ignored = tolerate && defects.length > 0
973
+ ? `${defects.length} screenshot problem${defects.length === 1 ? ' remains' : 's remain'} after capture; the affected steps were kept as text so the pages stay usable: ${defects.slice(0, 12).map((defect) => defect.replace(/\s+/g, ' ')).join(' ')}${defects.length > 12 ? ` …and ${defects.length - 12} more.` : ''} Review the affected guides before publishing.`
974
+ : undefined;
975
+ const messages = [ignored, ...warnings].filter((item) => Boolean(item));
976
+ return {
977
+ manifest,
978
+ defects,
979
+ summary: {
980
+ intent,
981
+ status: captured > 0 ? 'verified' : 'skipped',
982
+ planned: effectivePlan ? expected.captures : captured + textOnly,
983
+ captured,
984
+ textOnly,
985
+ guides: manifest.guides.length,
986
+ manifest: 'screenshots.json',
987
+ ...(tolerate && defects.length > 0 ? { ignoredProblems: defects.length } : {}),
988
+ ...(messages.length > 0
989
+ ? { message: messages.join(' ') }
990
+ : captured === 0
991
+ ? { message: 'Every planned visual step was intentionally recorded as text-only.' }
992
+ : {}),
993
+ },
994
+ };
995
+ }
996
+ function unusableManifestSummary(intent, expected, problem) {
997
+ return {
998
+ defects: [problem],
999
+ summary: {
1000
+ intent,
1001
+ status: 'skipped',
1002
+ planned: expected.captures,
1003
+ captured: 0,
1004
+ textOnly: 0,
1005
+ guides: expected.guides,
1006
+ ignoredProblems: 1,
1007
+ message: `${problem} The run was accepted without images.`,
1008
+ },
1009
+ };
1010
+ }
1011
+ async function captureFileExists(workspace, file) {
1012
+ try {
1013
+ return await pathExists(safeWorkspacePath(workspace, file));
1014
+ }
1015
+ catch {
1016
+ return false;
1017
+ }
1018
+ }
1019
+ /**
1020
+ * Summarize how far capture got, guide by guide, for an agent that continues an
1021
+ * interrupted run. Verified rows are named so the agent keeps them; unfinished
1022
+ * rows are named with their start path so it knows exactly what is left.
1023
+ */
1024
+ export async function describeScreenshotManifestProgress(workspace, plan) {
1025
+ const path = join(workspace, SCREENSHOT_MANIFEST_FILE);
1026
+ if (!(await pathExists(path)))
1027
+ return { lines: [], verified: 0, unfinished: 0 };
1028
+ let manifest;
1029
+ try {
1030
+ manifest = JSON.parse(await readFile(path, 'utf8'));
1031
+ }
1032
+ catch {
1033
+ return { lines: ['The screenshot manifest is not valid JSON; rewrite it from the guides you can verify.'], verified: 0, unfinished: 0 };
1034
+ }
1035
+ if (!Array.isArray(manifest.guides))
1036
+ return { lines: [], verified: 0, unfinished: 0 };
1037
+ const lines = [];
1038
+ let verified = 0;
1039
+ let unfinished = 0;
1040
+ for (const guide of manifest.guides) {
1041
+ if (!guide || typeof guide.page !== 'string' || !Array.isArray(guide.steps))
1042
+ continue;
1043
+ const page = plan?.pages.find((item) => item.id === guide.page || item.path === guide.page);
1044
+ const done = [];
1045
+ const pending = [];
1046
+ const textOnly = [];
1047
+ for (const step of guide.steps) {
1048
+ if (!step || typeof step.id !== 'string')
1049
+ continue;
1050
+ if (step.status === 'verified' && step.file && (await captureFileExists(workspace, step.file))) {
1051
+ done.push(`${step.id} (${step.file})`);
1052
+ }
1053
+ else if (step.status === 'text-only' && step.textOnlyReason?.trim()) {
1054
+ textOnly.push(step.id);
1055
+ }
1056
+ else {
1057
+ pending.push(`${step.id}${step.expectedState ? ` — ${step.expectedState}` : ''}`);
1058
+ }
1059
+ }
1060
+ verified += done.length;
1061
+ unfinished += pending.length;
1062
+ const start = page?.visuals?.startPath;
1063
+ if (pending.length === 0) {
1064
+ lines.push(`- Guide "${guide.page}": complete (${done.length} verified, ${textOnly.length} text-only). Keep it as it is.`);
1065
+ continue;
1066
+ }
1067
+ lines.push(`- Guide "${guide.page}"${start ? ` (start at ${start})` : ''}: ${done.length} verified${done.length > 0 ? ` — keep ${done.join(', ')}` : ''}; still to finish: ${pending.join('; ')}.`);
1068
+ }
1069
+ return { lines, verified, unfinished };
1070
+ }
1071
+ async function workspaceDocumentationPlan(workspace) {
1072
+ const path = join(workspace, '.doxloop', 'documentation-plan.json');
1073
+ if (!(await pathExists(path)))
1074
+ return undefined;
1075
+ try {
1076
+ const value = JSON.parse(await readFile(path, 'utf8'));
1077
+ return value && Array.isArray(value.pages) && value.target ? value : undefined;
1078
+ }
1079
+ catch {
1080
+ return undefined;
1081
+ }
1082
+ }
1083
+ function normalizeManifestStep(step, captureSequence, captureOrdinal) {
1084
+ if (!step || typeof step !== 'object')
1085
+ return step;
1086
+ const captured = captureOrdinal >= 0;
1087
+ // An explicit sequenceItem is the agent's own mapping; trust it for the
1088
+ // approved-text fallback so a reordered manifest still normalizes correctly.
1089
+ const approvedIndex = captured
1090
+ ? (typeof step.sequenceItem === 'number' && step.sequenceItem >= 1 ? step.sequenceItem - 1 : captureOrdinal)
1091
+ : -1;
1092
+ const approved = approvedIndex >= 0 ? captureSequence[approvedIndex] : undefined;
1093
+ const [approvedAction, approvedState, approvedPurpose] = approved?.split(/\s+—\s+/, 3) ?? [];
1094
+ const action = specificManifestAction(step.action, approvedAction);
1095
+ return {
1096
+ ...step,
1097
+ capture: captured,
1098
+ ...(captured && step.sequenceItem === undefined && approved ? { sequenceItem: captureOrdinal + 1 } : {}),
1099
+ ...(action ? { action } : {}),
1100
+ ...(typeof step.expectedState !== 'string' || step.expectedState.trim().length < 8 ? { expectedState: approvedState ?? step.expectedState } : {}),
1101
+ ...(typeof step.purpose !== 'string' || step.purpose.trim().length < 8 ? { purpose: approvedPurpose ?? step.purpose } : {}),
1102
+ };
1103
+ }
1104
+ function specificManifestAction(value, approved) {
1105
+ const current = typeof value === 'string' ? value.trim() : '';
1106
+ if (current.length >= 8)
1107
+ return current;
1108
+ const candidate = approved?.trim() || current;
1109
+ if (candidate.length >= 8)
1110
+ return candidate;
1111
+ const openTarget = /^open\s+(.+)$/i.exec(candidate)?.[1]?.trim();
1112
+ return openTarget ? `Open the application at ${openTarget}` : candidate || undefined;
1113
+ }
1114
+ function validateStepShape(step, page, index) {
1115
+ if (!step || typeof step.id !== 'string' || !step.id.trim())
1116
+ throw new DoxloopError(`Screenshot step ${index + 1} in "${page}" needs a stable ID.`);
1117
+ for (const [label, value] of [['action', step.action], ['expected state', step.expectedState], ['purpose', step.purpose]]) {
1118
+ if (typeof value !== 'string' || value.trim().length < 8) {
1119
+ throw new DoxloopError(`Screenshot step ${step.id} in "${page}" needs a specific ${label}: describe it as a full clause of at least 8 characters (got ${typeof value === 'string' ? `"${value.trim()}"` : 'a non-string value'}).`);
1120
+ }
1121
+ }
1122
+ if (typeof step.capture !== 'boolean' || !['planned', 'verified', 'text-only', 'failed'].includes(step.status)) {
1123
+ throw new DoxloopError(`Screenshot step ${step.id} in "${page}" has an invalid capture decision or status.`);
1124
+ }
1125
+ }
1126
+ /** Share of sampled pixels holding the single most common color. */
1127
+ export function dominantColorShare(image) {
1128
+ const pixels = image.width * image.height;
1129
+ if (pixels === 0)
1130
+ return 1;
1131
+ const stride = Math.max(1, Math.floor(pixels / 20_000));
1132
+ const counts = new Map();
1133
+ let sampled = 0;
1134
+ for (let pixel = 0; pixel < pixels; pixel += stride) {
1135
+ const offset = pixel * 4;
1136
+ const key = `${image.data[offset]}:${image.data[offset + 1]}:${image.data[offset + 2]}`;
1137
+ counts.set(key, (counts.get(key) ?? 0) + 1);
1138
+ sampled += 1;
1139
+ }
1140
+ return sampled === 0 ? 1 : Math.max(...counts.values()) / sampled;
1141
+ }
1142
+ function sampledColors(image) {
1143
+ const colors = new Set();
1144
+ const pixels = image.width * image.height;
1145
+ const stride = Math.max(1, Math.floor(pixels / 2_000));
1146
+ for (let pixel = 0; pixel < pixels; pixel += stride) {
1147
+ const offset = pixel * 4;
1148
+ colors.add(`${image.data[offset]}:${image.data[offset + 1]}:${image.data[offset + 2]}:${image.data[offset + 3]}`);
1149
+ if (colors.size >= 8)
1150
+ break;
1151
+ }
1152
+ return colors;
1153
+ }
1154
+ /** Where a run's discarded images go; never part of a proposal, always recoverable. */
1155
+ export const QUARANTINE_DIRECTORY = '.doxloop/quarantine';
1156
+ /**
1157
+ * Set an image aside instead of deleting it. A downgrade that turns out to be
1158
+ * wrong (a validator disagreeing with bookkeeping, a duplicate that was not
1159
+ * one) then costs a note on the run, not the picture.
1160
+ */
1161
+ export async function quarantineImage(workspace, file) {
1162
+ const source = safeWorkspacePath(workspace, file);
1163
+ if (!(await pathExists(source)))
1164
+ return undefined;
1165
+ const target = join(workspace, QUARANTINE_DIRECTORY, file.replaceAll('\\', '/'));
1166
+ await mkdir(dirname(target), { recursive: true });
1167
+ await rename(source, target).catch(async () => {
1168
+ await copyFile(source, target);
1169
+ await rm(source, { force: true });
1170
+ });
1171
+ return relative(workspace, target).replaceAll('\\', '/');
1172
+ }
1173
+ function safeWorkspacePath(workspace, raw) {
1174
+ if (!raw || isAbsolute(raw) || normalize(raw).split(/[\\/]/).includes('..'))
1175
+ throw new DoxloopError(`Screenshot path "${raw}" must stay inside the documentation project.`);
1176
+ const path = resolve(workspace, raw);
1177
+ if (relative(workspace, path).startsWith('..'))
1178
+ throw new DoxloopError(`Screenshot path "${raw}" escapes the documentation project.`);
1179
+ return path;
1180
+ }
1181
+ /**
1182
+ * A planned page path with no extension (`guides/manage-tasks`) can name a
1183
+ * directory that holds the page's assets next to `guides/manage-tasks.mdx`;
1184
+ * only a file is a page, and reading the directory throws EISDIR.
1185
+ */
1186
+ async function isFilePath(path) {
1187
+ try {
1188
+ return (await stat(path)).isFile();
1189
+ }
1190
+ catch {
1191
+ return false;
1192
+ }
1193
+ }
1194
+ async function guideReferencesImage(workspace, plan, page, image) {
1195
+ const root = join(workspace, plan.target.contentDir || '');
1196
+ const candidates = [
1197
+ join(root, page.path),
1198
+ ...plan.target.pageExtensions.map((extension) => join(root, `${page.path}${extension.startsWith('.') ? extension : `.${extension}`}`)),
1199
+ ...plan.target.pageExtensions.map((extension) => join(root, page.path, `index${extension.startsWith('.') ? extension : `.${extension}`}`)),
1200
+ ];
1201
+ let found = false;
1202
+ for (const candidate of candidates) {
1203
+ if (!(await isFilePath(candidate)))
1204
+ continue;
1205
+ found = true;
1206
+ const content = await readFile(candidate, 'utf8');
1207
+ const basename = image.replaceAll('\\', '/').split('/').at(-1);
1208
+ if (content.includes(image.replaceAll('\\', '/')) || (basename && content.includes(basename)))
1209
+ return 'embedded';
1210
+ }
1211
+ return found ? 'not-embedded' : 'page-missing';
1212
+ }
1213
+ async function workspaceReferencesImage(workspace, image) {
1214
+ const normalizedImage = image.replaceAll('\\', '/');
1215
+ const basename = normalizedImage.split('/').at(-1);
1216
+ const stack = [workspace];
1217
+ const ignored = new Set(['.doxloop', '.git', 'node_modules', 'dist', 'build', '.next']);
1218
+ let inspected = 0;
1219
+ while (stack.length > 0 && inspected < 10_000) {
1220
+ const directory = stack.pop();
1221
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
1222
+ if (entry.isDirectory()) {
1223
+ if (!ignored.has(entry.name))
1224
+ stack.push(join(directory, entry.name));
1225
+ continue;
1226
+ }
1227
+ inspected += 1;
1228
+ if (!/\.(?:md|mdx|rst|html?)$/i.test(entry.name))
1229
+ continue;
1230
+ const content = await readFile(join(directory, entry.name), 'utf8');
1231
+ if (content.includes(normalizedImage) || (basename && content.includes(basename)))
1232
+ return true;
1233
+ }
1234
+ }
1235
+ return false;
1236
+ }
1237
+ //# sourceMappingURL=screenshot-workflow.js.map