@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,2244 @@
1
+ import { mergeAgentUsage } from './agent-log.js';
2
+ import { safePath } from './direct-edit.js';
3
+ import { approvedBatchLimits, batchLimits } from './batch-limits.js';
4
+ import { withProjectLock } from './project-lock.js';
5
+ import { createHash, randomBytes } from 'node:crypto';
6
+ import { execFile } from 'node:child_process';
7
+ import { cp, mkdir, mkdtemp, readFile, readdir, rename, rm, unlink, writeFile, } from 'node:fs/promises';
8
+ import { tmpdir } from 'node:os';
9
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
10
+ import { isDeepStrictEqual, promisify } from 'node:util';
11
+ import { EDIT_MIN_MAX_TURNS, EDIT_TURNS_PER_PAGE, agentExitMessage, editPrompt, runAuthor, } from './author.js';
12
+ import { DoxloopError } from './errors.js';
13
+ import { computeDrift } from './drift.js';
14
+ import { assertInside, pathExists, readJson } from './fs.js';
15
+ import { matchesGlob } from './globs.js';
16
+ import { recordSourceSyncs, recordSyncRun, syncPageRegistry, } from './history.js';
17
+ import { applyAuthoringPostPass, removeSupersededStarterPages } from './authoring-postpass.js';
18
+ import { loadProject, pageExtensions as documentationPageExtensions, projectDefaultModel, readPage } from './project.js';
19
+ import { listPages as listDocumentationPages, resolveEditScope } from './pages.js';
20
+ import { recordReviewPreference } from './review-learning.js';
21
+ import { SCREENSHOT_MANIFEST_FILE, adoptCapturedImages, collapseDuplicateCaptures, describeScreenshotManifestProgress, embedMissingCaptures, normalizeScreenshotIntent, screenshotPlanSummary, validateScreenshotManifest, } from './screenshot-workflow.js';
22
+ import { changedSourcePaths, collectSourceChanges, formatSourceChanges, sourceSnapshotFingerprints } from './sync.js';
23
+ import { lineHunks, textLines } from './text-diff.js';
24
+ import { formatValidation, isStarterContent, validateProject } from './validation.js';
25
+ export const SYNC_RUNS_DIRECTORY = join('.doxloop', 'runs');
26
+ const RUN_FILE = 'run.json';
27
+ /** The inputs an agent run was started with, kept so the run can be continued. */
28
+ const AUTHORING_FILE = 'authoring.json';
29
+ const WORKSPACE = 'workspace';
30
+ const BEFORE = 'before';
31
+ const OPERATIONAL_BEFORE = 'operational-before';
32
+ const ACCEPTANCE_BEFORE = 'acceptance-before';
33
+ const APPLIED = 'applied';
34
+ /** Content hashes of every file as the run workspace was created. */
35
+ const BASELINE_FILE = 'baseline.json';
36
+ const EXCLUDED_PREFIXES = [
37
+ '.git',
38
+ '.agents',
39
+ '.claude',
40
+ '.codex',
41
+ '.gemini',
42
+ 'node_modules',
43
+ '.doxloop/runs',
44
+ // Plan manifests are workflow state. The real plan advances from generating
45
+ // to generated after its proposal workspace is finalized, so offering that
46
+ // snapshot as a documentation change creates an unavoidable false conflict.
47
+ '.doxloop/plans',
48
+ '.doxloop/cache',
49
+ '.doxloop/last-run.json',
50
+ '.doxloop/sync.log',
51
+ '.doxloop/ui-jobs.json',
52
+ '.doxloop/ui-job-logs',
53
+ '.doxloop/review-preferences.json',
54
+ '.doxloop/screenshot-manifest.json',
55
+ '.doxloop/deliveries',
56
+ '.doxloop/locks',
57
+ '.doxloop/direct-edits',
58
+ '.doxloop/monitor-budget.json',
59
+ '.doxloop/monitor-interval.json',
60
+ '.doxloop/comments.json',
61
+ '.doxloop/sync.log.1',
62
+ // Raw browser captures are run scratch space. Adopted images are placed
63
+ // beside the pages that use them, so the staging folder is never a change.
64
+ '.doxloop/capture-output',
65
+ // Images a downgrade set aside stay with the run for recovery; they are
66
+ // never proposed.
67
+ '.doxloop/quarantine',
68
+ // History is a derived local index. Copying it into a proposal workspace would
69
+ // offer the database back as a binary documentation change.
70
+ '.doxloop/doxloop.db',
71
+ '.doxloop/doxloop.db-wal',
72
+ '.doxloop/doxloop.db-shm',
73
+ 'build',
74
+ 'dist',
75
+ 'out',
76
+ 'site',
77
+ '_build',
78
+ ];
79
+ const INTERNAL_DIFF_PATHS = new Set([
80
+ '.doxloop/last-run.json',
81
+ '.doxloop/sync-state.json',
82
+ '.doxloop/sync.log',
83
+ ]);
84
+ const runCommand = promisify(execFile);
85
+ const BINARY_EXTENSIONS = new Set([
86
+ '.avif',
87
+ '.gif',
88
+ '.ico',
89
+ '.jpeg',
90
+ '.jpg',
91
+ '.pdf',
92
+ '.png',
93
+ '.ttf',
94
+ '.otf',
95
+ '.webp',
96
+ '.woff',
97
+ '.woff2',
98
+ '.zip',
99
+ ]);
100
+ /** Generate and validate a proposal without changing the real documentation. */
101
+ export async function createSyncRun(options) {
102
+ return withProjectLock(options.root, 'authoring', () => createSyncRunLocked(options), 0);
103
+ }
104
+ async function createSyncRunLocked(options) {
105
+ const previous = options.revisionOf ? await readSyncRun(options.root, options.revisionOf) : undefined;
106
+ const inheritedEditRequest = options.editRequest ?? previous?.editRequest;
107
+ const editRequest = inheritedEditRequest
108
+ ? { ...inheritedEditRequest, paths: [...new Set(inheritedEditRequest.paths)] }
109
+ : undefined;
110
+ const revisionScope = previous?.editRequest && editRequest
111
+ ? await resolveEditScope(options.root, options.project, editRequest.paths, editRequest.allowRelated)
112
+ : options.revisionOf
113
+ ? await resolveRevisionScope(options.root, options.revisionOf, options.revisionRequest)
114
+ : editRequest
115
+ ? await resolveEditScope(options.root, options.project, editRequest.paths, editRequest.allowRelated)
116
+ : undefined;
117
+ const id = options.id ?? createRunId();
118
+ assertRunId(id);
119
+ const pageSummaries = editRequest ? await listDocumentationPages(options.root) : [];
120
+ const editPages = editRequest
121
+ ? editRequest.paths.map((path) => ({ path, title: pageSummaries.find((page) => page.path === path)?.title ?? path.split('/').at(-1) ?? path }))
122
+ : [];
123
+ const directory = runDirectory(options.root, id);
124
+ await mkdir(directory, { recursive: true });
125
+ await ensureRunsIgnored(options.root);
126
+ if (options.plan)
127
+ await writeFile(join(directory, 'approved-plan.json'), JSON.stringify(options.plan), { mode: 0o600 });
128
+ const createdAt = new Date().toISOString();
129
+ const sourceSnapshot = await proposalSourceSnapshot(options.root, options.project);
130
+ await snapshotOperationalBefore(options.root, directory);
131
+ let run = {
132
+ schemaVersion: 2,
133
+ id,
134
+ status: 'generating',
135
+ mode: editRequest ? 'propose' : options.project.sync.mode === 'auto' ? 'auto' : 'propose',
136
+ trigger: options.trigger ?? (editRequest ? 'edit' : 'manual'),
137
+ createdAt,
138
+ summary: editRequest
139
+ ? editPages.length === 1 ? `Editing ${editPages[0].title}` : `Editing ${editPages.length} pages`
140
+ : options.plan ? `Writing ${options.plan.pages.filter((page) => ['create', 'update'].includes(page.action)).length} approved pages` : options.authoring ? 'Preparing the requested documentation update' : `Generating a review for ${options.drift.pages.length} stale page${options.drift.pages.length === 1 ? '' : 's'}`,
141
+ sourceSummary: sourceSummary(options.sourceChanges),
142
+ stalePages: options.drift.pages.map((page) => page.page),
143
+ changes: [],
144
+ sourceSnapshot,
145
+ ...(options.authoring?.mode ? { authoringMode: options.authoring.mode } : {}),
146
+ ...(options.plan ? { planId: options.plan.id } : {}),
147
+ ...(editRequest ? { editRequest } : {}),
148
+ ...(options.revisionOf ? { revisionOf: options.revisionOf } : {}),
149
+ retentionUntil: retentionDate(createdAt),
150
+ revisionRequests: options.revisionRequest ? [{
151
+ id: `revision-${randomBytes(3).toString('hex')}`,
152
+ createdAt,
153
+ instruction: options.revisionRequest.instruction,
154
+ changeIds: options.revisionRequest.changeIds,
155
+ hunkIds: options.revisionRequest.hunkIds ?? [],
156
+ }] : [],
157
+ humanEdits: [],
158
+ };
159
+ await writeRun(options.root, run);
160
+ const history = {
161
+ requestText: editRequest?.instruction ?? options.authoring?.historyRequest ?? options.authoring?.request,
162
+ agent: options.authoring?.agent ?? options.project.defaultAgent,
163
+ model: options.authoring?.model ?? projectDefaultModel(options.project, options.authoring?.agent ?? options.project.defaultAgent),
164
+ reasoningEffort: options.authoring?.reasoning ?? options.authoring?.effort,
165
+ runDir: join(SYNC_RUNS_DIRECTORY, id),
166
+ };
167
+ await recordSyncRun(options.root, run, history);
168
+ if (editRequest) {
169
+ const learned = editRequest.followUps.at(-1)?.instruction ?? editRequest.instruction;
170
+ await recordReviewPreference(options.root, { kind: 'edit', paths: editRequest.paths, instruction: learned }).catch(() => undefined);
171
+ }
172
+ const screenshotIntent = normalizeScreenshotIntent(options.authoring?.screenshots ?? (editRequest ? 'disabled' : undefined));
173
+ const expectedScreenshots = options.plan ? screenshotPlanSummary(options.plan) : { guides: 0, captures: 0 };
174
+ const changeSummary = options.sourceChanges.length > 0
175
+ ? formatSourceChanges(options.sourceChanges.map((change) => ({
176
+ ...change,
177
+ path: isAbsolute(change.path)
178
+ ? change.path
179
+ : resolve(options.root, change.path),
180
+ })))
181
+ : undefined;
182
+ // Everything the agent was started with is kept beside the run, so a run
183
+ // that stops part-way can be continued with the same instructions instead
184
+ // of being thrown away and generated again from nothing.
185
+ const persisted = {
186
+ schemaVersion: 1,
187
+ mode: options.authoring?.mode ?? 'update',
188
+ trigger: options.trigger ?? (editRequest ? 'edit' : 'manual'),
189
+ screenshots: screenshotIntent,
190
+ ...(options.authoring?.request ? { request: options.authoring.request } : {}),
191
+ ...(options.authoring?.historyRequest ? { historyRequest: options.authoring.historyRequest } : {}),
192
+ ...(options.authoring?.agent ?? options.project.defaultAgent
193
+ ? { agent: (options.authoring?.agent ?? options.project.defaultAgent) }
194
+ : {}),
195
+ ...(options.authoring?.model ?? projectDefaultModel(options.project, options.authoring?.agent ?? options.project.defaultAgent)
196
+ ? { model: (options.authoring?.model ?? projectDefaultModel(options.project, options.authoring?.agent ?? options.project.defaultAgent)) }
197
+ : {}),
198
+ ...(options.authoring?.reasoning ? { reasoning: options.authoring.reasoning } : {}),
199
+ ...(options.authoring?.effort ? { effort: options.authoring.effort } : {}),
200
+ ...((options.plan || options.project.sync.budget?.maxMinutes) ? { timeoutMinutes: Math.min(options.plan ? batchLimits(options.plan.execution.limits).maxMinutes : 120, options.project.sync.budget?.maxMinutes ?? 120) } : {}),
201
+ ...(changeSummary ? { changeSummary } : {}),
202
+ ...(options.authoringSources ? { authoringSources: options.authoringSources } : {}),
203
+ ...(options.nextSyncState ? { nextSyncState: options.nextSyncState } : {}),
204
+ ...(editRequest ? { editRequest } : {}),
205
+ };
206
+ await writeFile(join(directory, AUTHORING_FILE), `${JSON.stringify(persisted, null, 2)}\n`, 'utf8');
207
+ let workspaceCreated = false;
208
+ try {
209
+ const originalProjectText = await readFile(join(options.root, '.doxloop', 'project.json'), 'utf8');
210
+ const workspace = await createWorkspace(options.root, directory, options.project, options.authoringSources);
211
+ workspaceCreated = true;
212
+ if (options.revisionOf) {
213
+ await seedRevisionWorkspace(options.root, options.revisionOf, workspace);
214
+ await rewriteWorkspaceSources(workspace, options.root, {
215
+ ...options.project,
216
+ sources: options.authoringSources ?? options.project.sources,
217
+ });
218
+ }
219
+ const revisionBaseline = revisionScope ? await collectFiles(workspace) : undefined;
220
+ const scopedEditPrompt = editRequest ? editPrompt({
221
+ pages: editPages,
222
+ instruction: editRequest.instruction,
223
+ allowRelated: editRequest.allowRelated,
224
+ followUps: editRequest.followUps,
225
+ }) : undefined;
226
+ let failureDetail;
227
+ let usage;
228
+ const exitCode = await (options.author ?? runAuthor)({
229
+ onFailure: (detail) => { failureDetail = detail; },
230
+ onUsage: (recorded) => { usage = recorded; },
231
+ root: workspace,
232
+ captureAuthRoot: options.root,
233
+ mode: editRequest ? 'update' : persisted.mode,
234
+ nonInteractive: true,
235
+ recordHistory: false,
236
+ // A plan-first run writes its pages in short batches with Doxloop's
237
+ // repairs and validation between them; edits and revisions are one
238
+ // scoped session.
239
+ ...(options.plan && !editRequest && !options.revisionOf ? { planBatches: true } : {}),
240
+ ...(editRequest ? { recordOperationalState: false } : {}),
241
+ ...(changeSummary ? { changeSummary } : {}),
242
+ ...(options.project.defaultAgent ? { agent: options.project.defaultAgent } : {}),
243
+ ...(options.project.sync.budget?.maxMinutes
244
+ ? { timeoutMinutes: options.project.sync.budget.maxMinutes }
245
+ : {}),
246
+ ...(options.project.sync.budget?.maxUsd ? { maxBudgetUsd: options.project.sync.budget.maxUsd } : {}),
247
+ ...options.authoring,
248
+ ...(persisted.timeoutMinutes ? { timeoutMinutes: persisted.timeoutMinutes } : {}),
249
+ ...(scopedEditPrompt ? { request: scopedEditPrompt } : {}),
250
+ ...(editRequest ? {
251
+ maxTurns: Math.max(EDIT_MIN_MAX_TURNS, 40 + EDIT_TURNS_PER_PAGE * editRequest.paths.length),
252
+ timeoutMinutes: options.project.sync.budget?.maxMinutes ?? 15,
253
+ tolerateValidationErrors: true,
254
+ plannedPages: editRequest.paths.length,
255
+ progressLabel: 'Editing selected pages',
256
+ } : options.revisionOf ? { progressLabel: 'Revising proposed pages' } : {}),
257
+ });
258
+ if (usage)
259
+ run = { ...run, usage };
260
+ if (exitCode !== 0) {
261
+ throw new DoxloopError(agentExitMessage(exitCode, failureDetail));
262
+ }
263
+ if (revisionBaseline && revisionScope) {
264
+ await assertRevisionStayedInScope(workspace, revisionBaseline, revisionScope, Boolean(editRequest));
265
+ }
266
+ const finalized = await finalizeProposalWorkspace({
267
+ context: options,
268
+ directory,
269
+ workspace,
270
+ originalProjectText,
271
+ screenshotIntent,
272
+ label: 'generated',
273
+ });
274
+ run = {
275
+ ...run,
276
+ status: 'awaiting-review',
277
+ completedAt: new Date().toISOString(),
278
+ summary: proposalSummary(finalized.changes),
279
+ changes: finalized.changes,
280
+ validation: finalized.validation,
281
+ screenshots: finalized.screenshots,
282
+ ...withAdvisories(run, finalized.advisories),
283
+ undo: { status: 'unavailable', reason: 'Undo becomes available after the complete proposal is applied.' },
284
+ };
285
+ await writeRun(options.root, run);
286
+ if (options.revisionOf)
287
+ await markSuperseded(options.root, options.revisionOf, run.id);
288
+ await recordSyncRun(options.root, run, history);
289
+ return run;
290
+ }
291
+ catch (error) {
292
+ run = await failedRun(run, error, {
293
+ workspace: runWorkspace(options.root, run.id),
294
+ screenshotIntent,
295
+ expectedScreenshots,
296
+ recovery: { resumable: workspaceCreated && !options.revisionOf, ignorable: workspaceCreated },
297
+ });
298
+ await writeRun(options.root, run);
299
+ await recordSyncRun(options.root, run, history);
300
+ return run;
301
+ }
302
+ }
303
+ async function readPersistedAuthoring(root, id) {
304
+ const path = join(runDirectory(root, id), AUTHORING_FILE);
305
+ if (!(await pathExists(path)))
306
+ return undefined;
307
+ try {
308
+ const value = await readJson(path);
309
+ if (!value || value.schemaVersion !== 1 || (value.mode !== 'create' && value.mode !== 'update'))
310
+ return undefined;
311
+ return value;
312
+ }
313
+ catch {
314
+ return undefined;
315
+ }
316
+ }
317
+ async function failedRun(run, error, details) {
318
+ const message = error instanceof Error ? error.message : String(error);
319
+ let captured = 0;
320
+ let textOnly = 0;
321
+ try {
322
+ captured = (await describeScreenshotManifestProgress(details.workspace, undefined)).verified;
323
+ const manifest = JSON.parse(await readFile(join(details.workspace, SCREENSHOT_MANIFEST_FILE), 'utf8'));
324
+ textOnly = (manifest.guides ?? []).flatMap((guide) => guide.steps ?? []).filter((step) => step.status === 'text-only' && step.textOnlyReason?.trim()).length;
325
+ }
326
+ catch { /* A failure before capture has no saved progress. */ }
327
+ return {
328
+ ...run,
329
+ status: 'failed',
330
+ completedAt: new Date().toISOString(),
331
+ error: message,
332
+ ...(details.expectedScreenshots.guides > 0 && details.screenshotIntent !== 'disabled' ? {
333
+ screenshots: {
334
+ intent: details.screenshotIntent,
335
+ status: 'failed',
336
+ planned: details.expectedScreenshots.captures,
337
+ captured,
338
+ textOnly,
339
+ guides: details.expectedScreenshots.guides,
340
+ message,
341
+ },
342
+ } : {}),
343
+ summary: 'Proposal generation failed',
344
+ recovery: details.recovery,
345
+ };
346
+ }
347
+ /**
348
+ * The post-agent pipeline every proposal goes through before review: restore
349
+ * the portable source bindings, check the screenshot manifest, validate the
350
+ * documentation, and turn the workspace into reviewable changes. Shared by a
351
+ * fresh run, a recovered run, and a resumed run so all three are judged alike.
352
+ */
353
+ async function finalizeProposalWorkspace(input) {
354
+ const advisories = [];
355
+ const { context, workspace, directory } = input;
356
+ if (context.nextSyncState) {
357
+ await writeFile(join(workspace, '.doxloop', 'sync-state.json'), `${JSON.stringify(context.nextSyncState, null, 2)}\n`, 'utf8');
358
+ }
359
+ // The staged project uses absolute sources so it can live away from the real
360
+ // project. Restore the user's portable bindings before calculating changes.
361
+ await restoreWorkspaceSources(workspace, context.project, input.originalProjectText);
362
+ if (input.repairCaptures) {
363
+ // An agent that stopped early may have left real images unrecorded or
364
+ // unplaced; claim them the way a completed run would before judging.
365
+ await adoptCapturedImages(workspace, context.project.generator, context.plan);
366
+ await collapseDuplicateCaptures(workspace, context.plan);
367
+ await embedMissingCaptures(workspace, context.plan);
368
+ }
369
+ // A capture shortfall is never a reason to discard finished pages: every
370
+ // defect becomes a text-only step and is listed on the run for review.
371
+ const screenshotResult = await validateScreenshotManifest(workspace, context.plan, input.screenshotIntent, {
372
+ ...(input.tolerateScreenshotDefects !== false ? { tolerateDefects: true } : {}),
373
+ });
374
+ if (screenshotResult.manifest) {
375
+ await writeFile(join(directory, 'screenshots.json'), `${JSON.stringify(screenshotResult.manifest, null, 2)}\n`, 'utf8');
376
+ }
377
+ let validation = await validateProject(workspace);
378
+ if (validation.errors > 0 && !context.editRequest && context.plan) {
379
+ // The accept check is the same validation; running Doxloop's repairs here
380
+ // means a run that ends "generated" is one the reviewer can apply.
381
+ const repaired = await repairWorkspaceBeforeReview(workspace, context.project, context.plan, validation.issues);
382
+ for (const line of repaired)
383
+ advisories.push(`Repaired before review: ${line}`);
384
+ if (repaired.length > 0)
385
+ validation = await validateProject(workspace);
386
+ }
387
+ if (validation.errors > 0 && !context.editRequest) {
388
+ if (!context.plan) {
389
+ throw new DoxloopError(`The ${input.label} proposal did not pass validation:\n${formatValidation(validation)}`);
390
+ }
391
+ // A plan run already had Doxloop's repairs and fix sessions; what is left
392
+ // is for the reviewer to see and revise, and applying stays blocked until
393
+ // the errors are gone.
394
+ advisories.push(`${validation.errors} validation error${validation.errors === 1 ? '' : 's'} remain in this proposal; it cannot be applied until they are fixed. Use Repair proposal to let Doxloop fix links and navigation without an agent, or ask for a revision of the affected pages: ${[...new Set(validation.issues.filter((issue) => issue.severity === 'error').map((issue) => issue.file ?? 'project'))].slice(0, 12).join(', ')}.`);
395
+ }
396
+ const collected = await collectProposalChanges(context.root, workspace, directory, context.project);
397
+ if (context.plan) {
398
+ const limits = approvedBatchLimits(context.plan);
399
+ const outside = [];
400
+ let counted = 0;
401
+ for (const page of collected.filter((change) => change.category === 'page')) {
402
+ const planned = matchPlanPage(context.plan, page.path);
403
+ if (!planned) {
404
+ // Every brief tells the agent to replace generated starter pages, and
405
+ // the plan may put a starter's replacement at a new path or leave the
406
+ // starter out altogether. Removing the scaffold, or rewriting it in
407
+ // place when the site needs the file (a landing page), is part of the
408
+ // approved work, not an expansion of it.
409
+ if ((page.kind === 'deleted' || page.kind === 'modified') && await isStarterFile(join(directory, BEFORE, page.path))) {
410
+ if (page.kind === 'modified')
411
+ counted += 1;
412
+ continue;
413
+ }
414
+ // An existing page the plan did not name but the writer touched (a
415
+ // landing page gaining links to the new pages, a related guide's
416
+ // cross-reference) is reviewable like any other change; only new or
417
+ // deleted pages expand the batch.
418
+ if (page.kind === 'modified') {
419
+ counted += 1;
420
+ advisories.push(`${page.path} was modified although the plan did not name it; review that change before accepting.`);
421
+ continue;
422
+ }
423
+ outside.push(`${page.path} (${page.kind}, not in the plan)`);
424
+ continue;
425
+ }
426
+ if (planned.priority === 'later')
427
+ outside.push(`${page.path} (deferred to Later)`);
428
+ else if (planned.action === 'preserve')
429
+ outside.push(`${page.path} (planned to preserve)`);
430
+ else if (page.kind === 'deleted' && planned.action !== 'remove')
431
+ outside.push(`${page.path} (deleted, but planned to ${planned.action})`);
432
+ counted += 1;
433
+ }
434
+ // Scope overruns are reviewable, not fatal: the pages are in the
435
+ // proposal with a note, and the reviewer rejects the ones they do not
436
+ // want. Failing here discarded a finished run over one extra page.
437
+ if (outside.length > 0)
438
+ advisories.push(`Outside the approved plan (review before accepting): ${outside.join(', ')}.`);
439
+ if (counted > limits.maxPages)
440
+ advisories.push(`${counted} pages changed; the approved batch allows ${limits.maxPages}. Reject the extra pages if they are not wanted.`);
441
+ if (screenshotResult.summary.captured > limits.maxScreenshots)
442
+ advisories.push(`${screenshotResult.summary.captured} screenshots captured; the approved batch allows ${limits.maxScreenshots}.`);
443
+ }
444
+ advisories.push(...await repairProposalSourceScopes(workspace, collected, context.project));
445
+ const changes = await enrichProposalRationales(context.root, workspace, collected, context, validation);
446
+ if (changes.length === 0) {
447
+ throw new DoxloopError(input.label === 'generated'
448
+ ? 'The agent completed without proposing any documentation changes.'
449
+ : 'The preserved workspace contains no documentation changes to recover.');
450
+ }
451
+ return {
452
+ advisories,
453
+ changes,
454
+ validation: {
455
+ pages: validation.pages.length,
456
+ errors: validation.errors,
457
+ warnings: validation.warnings,
458
+ ...(context.editRequest && validation.issues.length > 0 ? { issues: validation.issues } : {}),
459
+ },
460
+ screenshots: screenshotResult.summary,
461
+ };
462
+ }
463
+ /**
464
+ * Doxloop's deterministic repairs over a finished workspace: navigation and
465
+ * frontmatter, links to pages that exist, embeds of images that do not, and
466
+ * the generated starter pages the plan superseded. No agent session.
467
+ */
468
+ export async function repairWorkspaceBeforeReview(workspace, project, plan, issues) {
469
+ const lines = [];
470
+ try {
471
+ const report = await applyAuthoringPostPass({ workspace, project: await loadProject(workspace).catch(() => project), plan, pages: plan.pages, unlinkUnresolved: true, pruneEmptySpaces: true });
472
+ lines.push(...report.repairs);
473
+ const starters = await removeSupersededStarterPages(workspace, issues, report);
474
+ if (starters.length > 0) {
475
+ // Their navigation entries and links go with them.
476
+ const again = await applyAuthoringPostPass({ workspace, project: await loadProject(workspace).catch(() => project), plan, pages: plan.pages, unlinkUnresolved: true, pruneEmptySpaces: true });
477
+ lines.push(...starters.map((file) => `${file}: removed the starter page the plan superseded.`), ...again.repairs);
478
+ }
479
+ }
480
+ catch (error) {
481
+ lines.push(`post-pass could not run: ${error instanceof Error ? error.message : String(error)}`);
482
+ }
483
+ return lines;
484
+ }
485
+ /**
486
+ * Re-run the post-agent finalization pipeline on a failed run's preserved
487
+ * workspace and promote it to review. This recovers completed agent work when
488
+ * the failure happened after authoring — for example a screenshot-manifest
489
+ * validation defect — without paying for another full agent run.
490
+ */
491
+ export async function recoverSyncRun(root, id, options = {}) {
492
+ return withProjectLock(root, 'authoring', () => recoverSyncRunLocked(root, id, options), 0);
493
+ }
494
+ async function recoverSyncRunLocked(root, id, options) {
495
+ const run = await readSyncRun(root, id);
496
+ if (run.archivedAt)
497
+ throw new DoxloopError(`Proposal ${id} is archived and cannot be recovered.`);
498
+ const repairable = options.repair === true && run.status === 'awaiting-review';
499
+ if (run.status !== 'failed' && run.status !== 'generating' && !repairable) {
500
+ throw new DoxloopError(`Only a failed or interrupted proposal can be recovered; ${id} is ${run.status}.`);
501
+ }
502
+ const workspace = runWorkspace(root, id);
503
+ if (!(await pathExists(workspace))) {
504
+ throw new DoxloopError(`Proposal ${id} no longer has a preserved workspace to recover. Generate a new proposal instead.`);
505
+ }
506
+ const project = await loadProject(root);
507
+ if (options.repair) {
508
+ const approved = await recoveryPlan(root, runDirectory(root, id), run.planId);
509
+ if (approved) {
510
+ const report = await applyAuthoringPostPass({ workspace, project: await loadProject(workspace).catch(() => project), plan: approved, pages: approved.pages, unlinkUnresolved: true, pruneEmptySpaces: true });
511
+ for (const line of report.repairs)
512
+ process.stdout.write(`Repaired: ${line}\n`);
513
+ }
514
+ }
515
+ // Sources that changed while the run sat failed never block continuing it:
516
+ // the work in the workspace is still worth reviewing, and the reviewer is
517
+ // told to read it against the current evidence instead.
518
+ const currentSnapshot = await proposalSourceSnapshot(root, project);
519
+ const sourcesChanged = Boolean(run.sourceSnapshot && currentSnapshot !== run.sourceSnapshot);
520
+ const directory = runDirectory(root, id);
521
+ const plan = await recoveryPlan(root, directory, run.planId);
522
+ const authoring = await readPersistedAuthoring(root, id);
523
+ const screenshotIntent = normalizeScreenshotIntent(authoring?.screenshots ?? run.screenshots?.intent ?? plan?.execution?.screenshots);
524
+ const history = authoring ? historyContext(id, authoring, project) : {};
525
+ try {
526
+ const originalProjectText = await readFile(join(root, '.doxloop', 'project.json'), 'utf8');
527
+ const context = {
528
+ root,
529
+ project,
530
+ drift: await computeDrift(root, project),
531
+ sourceChanges: await collectSourceChanges(root, project.sources),
532
+ ...(run.editRequest ? { editRequest: run.editRequest } : {}),
533
+ ...(plan ? { plan } : {}),
534
+ ...(authoring?.nextSyncState ? { nextSyncState: authoring.nextSyncState } : {}),
535
+ authoring: {
536
+ ...(authoring?.mode ? { mode: authoring.mode } : {}),
537
+ ...(authoring?.request ? { request: authoring.request } : {}),
538
+ ...(authoring?.agent ? { agent: authoring.agent } : {}),
539
+ ...(authoring?.model ? { model: authoring.model } : {}),
540
+ ...(authoring?.reasoning ? { reasoning: authoring.reasoning } : {}),
541
+ ...(authoring?.effort ? { effort: authoring.effort } : {}),
542
+ screenshots: screenshotIntent,
543
+ },
544
+ };
545
+ if (run.editRequest) {
546
+ await restoreWorkspaceSources(workspace, project, originalProjectText);
547
+ await assertRevisionStayedInScope(workspace, await collectFiles(root), await resolveEditScope(root, project, run.editRequest.paths, run.editRequest.allowRelated), true);
548
+ }
549
+ const finalized = await finalizeProposalWorkspace({
550
+ context,
551
+ directory,
552
+ workspace,
553
+ originalProjectText,
554
+ screenshotIntent,
555
+ repairCaptures: true,
556
+ ...(options.ignoreScreenshotProblems ? { tolerateScreenshotDefects: true } : {}),
557
+ label: options.repair ? 'repaired' : 'recovered',
558
+ });
559
+ // Advisories are rebuilt below from the repaired workspace; an empty result must not keep the old list.
560
+ const { error: _previousError, recovery: _recovery, advisories: _previousAdvisories, ...cleanRun } = run;
561
+ const next = {
562
+ ...cleanRun,
563
+ status: 'awaiting-review',
564
+ completedAt: new Date().toISOString(),
565
+ summary: proposalSummary(finalized.changes),
566
+ changes: finalized.changes,
567
+ validation: finalized.validation,
568
+ screenshots: finalized.screenshots,
569
+ sourceSnapshot: currentSnapshot,
570
+ ...withAdvisories(withAdvisoryRun(run, sourcesChanged ? SOURCES_CHANGED_RECOVERED : undefined), finalized.advisories, true),
571
+ undo: { status: 'unavailable', reason: 'Undo becomes available after the complete proposal is applied.' },
572
+ };
573
+ await writeRun(root, next);
574
+ await recordSyncRun(root, next, history);
575
+ return next;
576
+ }
577
+ catch (error) {
578
+ const failed = {
579
+ ...run,
580
+ status: 'failed',
581
+ error: error instanceof Error ? error.message : String(error),
582
+ recovery: { resumable: Boolean(authoring) && !run.revisionOf, ignorable: true },
583
+ };
584
+ await writeRun(root, failed);
585
+ await recordSyncRun(root, failed, history);
586
+ throw error;
587
+ }
588
+ }
589
+ const SOURCES_CHANGED_RECOVERED = 'Configured sources changed after this run stopped. Its pages were kept as generated; review their claims against the current sources before accepting.';
590
+ const SOURCES_CHANGED_RESUMED = 'Configured sources changed after this run stopped. The resumed agent was told to re-check the pages it touched against the current sources.';
591
+ const SOURCES_CHANGED_DURING_REVIEW = 'Configured sources changed after this proposal was generated. The proposal was kept and remains editable and applicable; review its claims against the current sources.';
592
+ const LEGACY_STALE_SOURCE_ERROR = 'Configured source evidence changed after this proposal was generated.';
593
+ /** Add a reviewer note to a run without repeating one it already carries. */
594
+ function withAdvisory(run, advisory) {
595
+ return withAdvisories(run, advisory ? [advisory] : []);
596
+ }
597
+ /**
598
+ * Notes the finalize step writes from the workspace as it is now. A repair
599
+ * recomputes them, so earlier copies are dropped rather than kept as stale
600
+ * claims about a workspace that has since changed.
601
+ */
602
+ function isFinalizeAdvisory(note) {
603
+ return /^Repaired before review: |validation errors? remains? in this proposal|was modified although the plan did not name it|^Outside the approved plan|; the approved batch allows /.test(note);
604
+ }
605
+ function withAdvisories(run, notes, replaceFinalize = false) {
606
+ const advisories = (run.advisories ?? []).filter((note) => !(replaceFinalize && isFinalizeAdvisory(note)));
607
+ for (const note of notes)
608
+ if (note && !advisories.includes(note))
609
+ advisories.push(note);
610
+ return advisories.length > 0 ? { advisories } : {};
611
+ }
612
+ function withAdvisoryRun(run, advisory) {
613
+ return { ...run, ...withAdvisory(run, advisory) };
614
+ }
615
+ function historyContext(id, authoring, project) {
616
+ return {
617
+ requestText: authoring.historyRequest ?? authoring.request,
618
+ agent: authoring.agent ?? project.defaultAgent,
619
+ model: authoring.model ?? projectDefaultModel(project, authoring.agent ?? project.defaultAgent),
620
+ reasoningEffort: authoring.reasoning ?? authoring.effort,
621
+ runDir: join(SYNC_RUNS_DIRECTORY, id),
622
+ };
623
+ }
624
+ /**
625
+ * Continue a failed or interrupted run's agent inside its preserved workspace.
626
+ *
627
+ * The agent is started with the same instructions as before plus a brief of
628
+ * what already exists — verified screenshots, finished guides, the reason the
629
+ * run stopped — so it completes only the unfinished part instead of writing
630
+ * every page and capturing every image again. The run keeps its id, so the
631
+ * plan that started it and its review history stay attached.
632
+ */
633
+ export async function resumeSyncRun(root, id, options = {}) {
634
+ return withProjectLock(root, 'authoring', () => resumeSyncRunLocked(root, id, options), 0);
635
+ }
636
+ async function resumeSyncRunLocked(root, id, options) {
637
+ const run = await readSyncRun(root, id);
638
+ if (run.archivedAt)
639
+ throw new DoxloopError(`Proposal ${id} is archived and cannot be resumed.`);
640
+ if (run.status !== 'failed' && run.status !== 'generating') {
641
+ throw new DoxloopError(`Only a failed or interrupted proposal can be resumed; ${id} is ${run.status}.`);
642
+ }
643
+ if (run.revisionOf) {
644
+ throw new DoxloopError('A targeted revision cannot be resumed. Ask for the revision again from the proposal it revises.');
645
+ }
646
+ const workspace = runWorkspace(root, id);
647
+ if (!(await pathExists(workspace))) {
648
+ throw new DoxloopError(`Proposal ${id} no longer has a preserved workspace to resume. Generate a new proposal instead.`);
649
+ }
650
+ const authoring = (await readPersistedAuthoring(root, id))
651
+ ?? (options.fallbackAuthoring ? { schemaVersion: 1, ...options.fallbackAuthoring } : undefined);
652
+ if (!authoring) {
653
+ throw new DoxloopError(`Proposal ${id} did not record the instructions it was started with, so it cannot be resumed. Recover its output or generate a new proposal instead.`);
654
+ }
655
+ const project = await loadProject(root);
656
+ // A source edit made while the run was stopped is a reason to re-check the
657
+ // pages, not to discard them: the resumed agent reads the current sources.
658
+ const currentSnapshot = await proposalSourceSnapshot(root, project);
659
+ const sourcesChanged = Boolean(run.sourceSnapshot && currentSnapshot !== run.sourceSnapshot);
660
+ const directory = runDirectory(root, id);
661
+ const plan = await recoveryPlan(root, directory, run.planId);
662
+ const screenshotIntent = normalizeScreenshotIntent(authoring.screenshots);
663
+ const expectedScreenshots = plan ? screenshotPlanSummary(plan) : { guides: 0, captures: 0 };
664
+ const history = historyContext(id, authoring, project);
665
+ const { error: _previousError, recovery: _recovery, completedAt: _completedAt, validation: _validation, screenshots: _screenshots, ...cleanRun } = run;
666
+ let next = {
667
+ ...cleanRun,
668
+ status: 'generating',
669
+ resumedAt: new Date().toISOString(),
670
+ summary: 'Continuing the interrupted documentation run',
671
+ sourceSnapshot: currentSnapshot,
672
+ ...withAdvisory(run, sourcesChanged ? SOURCES_CHANGED_RESUMED : undefined),
673
+ };
674
+ await writeRun(root, next);
675
+ await recordSyncRun(root, next, history);
676
+ try {
677
+ const originalProjectText = await readFile(join(root, '.doxloop', 'project.json'), 'utf8');
678
+ const editScope = authoring.editRequest
679
+ ? await resolveEditScope(root, project, authoring.editRequest.paths, authoring.editRequest.allowRelated)
680
+ : undefined;
681
+ const editBaseline = editScope ? await collectFiles(root) : undefined;
682
+ await rewriteWorkspaceSources(workspace, root, {
683
+ ...project,
684
+ sources: authoring.authoringSources ?? project.sources,
685
+ });
686
+ const editPages = authoring.editRequest
687
+ ? (await listDocumentationPages(root)).filter((page) => authoring.editRequest.paths.includes(page.path))
688
+ : [];
689
+ const originalRequest = authoring.editRequest ? editPrompt({
690
+ pages: authoring.editRequest.paths.map((path) => ({ path, title: editPages.find((page) => page.path === path)?.title ?? path })),
691
+ instruction: authoring.editRequest.instruction,
692
+ allowRelated: authoring.editRequest.allowRelated,
693
+ followUps: authoring.editRequest.followUps,
694
+ }) : authoring.request;
695
+ const continuation = await continuationBrief(workspace, plan, run, screenshotIntent, originalRequest, sourcesChanged);
696
+ let failureDetail;
697
+ let usage;
698
+ const exitCode = await (options.author ?? runAuthor)({
699
+ onFailure: (detail) => { failureDetail = detail; },
700
+ onUsage: (recorded) => { usage = recorded; },
701
+ root: workspace,
702
+ captureAuthRoot: root,
703
+ mode: authoring.mode,
704
+ nonInteractive: true,
705
+ recordHistory: false,
706
+ ...(plan && !authoring.editRequest ? { planBatches: true } : {}),
707
+ ...(authoring.editRequest ? { recordOperationalState: false } : {}),
708
+ request: continuation,
709
+ screenshots: screenshotIntent,
710
+ ...(authoring.changeSummary ? { changeSummary: authoring.changeSummary } : {}),
711
+ ...(authoring.agent ? { agent: authoring.agent } : {}),
712
+ ...(authoring.model ? { model: authoring.model } : {}),
713
+ ...(authoring.reasoning ? { reasoning: authoring.reasoning } : {}),
714
+ ...(authoring.effort ? { effort: authoring.effort } : {}),
715
+ ...(authoring.timeoutMinutes ? { timeoutMinutes: authoring.timeoutMinutes } : {}),
716
+ ...(project.sync.budget?.maxUsd ? { maxBudgetUsd: project.sync.budget.maxUsd } : {}),
717
+ progressLabel: 'Continuing the interrupted authoring run',
718
+ ...(authoring.editRequest ? {
719
+ maxTurns: Math.max(EDIT_MIN_MAX_TURNS, 40 + EDIT_TURNS_PER_PAGE * authoring.editRequest.paths.length),
720
+ timeoutMinutes: authoring.timeoutMinutes ?? project.sync.budget?.maxMinutes ?? 15,
721
+ tolerateValidationErrors: true,
722
+ plannedPages: authoring.editRequest.paths.length,
723
+ } : {}),
724
+ });
725
+ if (usage)
726
+ next = { ...next, usage: mergeAgentUsage(run.usage, usage) ?? usage };
727
+ if (exitCode !== 0) {
728
+ throw new DoxloopError(agentExitMessage(exitCode, failureDetail));
729
+ }
730
+ if (editScope && editBaseline) {
731
+ await restoreWorkspaceSources(workspace, project, originalProjectText);
732
+ await assertRevisionStayedInScope(workspace, editBaseline, editScope, true);
733
+ }
734
+ const context = {
735
+ root,
736
+ project,
737
+ drift: await computeDrift(root, project),
738
+ sourceChanges: await collectSourceChanges(root, project.sources),
739
+ trigger: authoring.trigger,
740
+ ...(authoring.editRequest ? { editRequest: authoring.editRequest } : {}),
741
+ ...(plan ? { plan } : {}),
742
+ ...(authoring.authoringSources ? { authoringSources: authoring.authoringSources } : {}),
743
+ ...(authoring.nextSyncState ? { nextSyncState: authoring.nextSyncState } : {}),
744
+ authoring: {
745
+ mode: authoring.mode,
746
+ ...(authoring.request ? { request: authoring.request } : {}),
747
+ ...(authoring.agent ? { agent: authoring.agent } : {}),
748
+ ...(authoring.model ? { model: authoring.model } : {}),
749
+ ...(authoring.reasoning ? { reasoning: authoring.reasoning } : {}),
750
+ ...(authoring.effort ? { effort: authoring.effort } : {}),
751
+ screenshots: screenshotIntent,
752
+ },
753
+ };
754
+ const finalized = await finalizeProposalWorkspace({
755
+ context,
756
+ directory,
757
+ workspace,
758
+ originalProjectText,
759
+ screenshotIntent,
760
+ label: 'resumed',
761
+ });
762
+ next = {
763
+ ...next,
764
+ status: 'awaiting-review',
765
+ completedAt: new Date().toISOString(),
766
+ summary: proposalSummary(finalized.changes),
767
+ changes: finalized.changes,
768
+ validation: finalized.validation,
769
+ screenshots: finalized.screenshots,
770
+ ...withAdvisories(next, finalized.advisories),
771
+ undo: { status: 'unavailable', reason: 'Undo becomes available after the complete proposal is applied.' },
772
+ };
773
+ await writeRun(root, next);
774
+ await recordSyncRun(root, next, history);
775
+ return next;
776
+ }
777
+ catch (error) {
778
+ next = await failedRun(next, error, {
779
+ workspace,
780
+ screenshotIntent,
781
+ expectedScreenshots,
782
+ recovery: { resumable: true, ignorable: true },
783
+ });
784
+ await writeRun(root, next);
785
+ await recordSyncRun(root, next, history);
786
+ return next;
787
+ }
788
+ }
789
+ /**
790
+ * Tell a continuing agent what the workspace already holds and what is left.
791
+ * Verified captures are named so they are kept, unfinished guides are named
792
+ * with their start path, and the previous failure is quoted so the agent works
793
+ * on the actual problem instead of rediscovering it.
794
+ */
795
+ async function continuationBrief(workspace, plan, run, intent, originalRequest, sourcesChanged = false) {
796
+ const progress = await describeScreenshotManifestProgress(workspace, plan);
797
+ let validationText = '';
798
+ let pageProgress = '';
799
+ try {
800
+ const validation = await validateProject(workspace);
801
+ // Warnings travel with the errors: the resumed run that fixed its
802
+ // thin-page warnings produced the documentation the reviewer preferred,
803
+ // and a writer that could not run validation in its sandbox has never
804
+ // seen them.
805
+ if (validation.errors > 0 || validation.warnings > 0)
806
+ validationText = formatValidation(validation);
807
+ const files = await listDocumentationPages(workspace);
808
+ const invalid = new Set(validation.issues.filter((issue) => issue.severity === 'error').map((issue) => issue.file));
809
+ pageProgress = `Existing pages (${files.length}; passing structural validation is not a factual verification):\n${files.map((page) => `${page.path}: ${invalid.has(page.path) ? 'needs validation fixes' : 'present; preserve unless incomplete'}`).join('\n')}`;
810
+ }
811
+ catch {
812
+ // Validation problems are reported again after the agent finishes.
813
+ }
814
+ const sections = [
815
+ 'This run continues an earlier authoring run in this same workspace that stopped before Doxloop could accept its output. The workspace already holds the pages, navigation, evidence map, and application screenshots that run produced. Keep that work: do not rewrite pages that are already complete, do not recapture, rename, or delete screenshots that .doxloop/screenshot-manifest.json records as verified and whose files exist, and do not rebuild that manifest from scratch. Finish only what is listed below as unfinished or wrong, bring any page you touch to full depth, then run validation and finish.',
816
+ pageProgress,
817
+ run.error ? `Why the previous run stopped:\n${run.error}` : '',
818
+ sourcesChanged
819
+ ? 'The configured sources changed after the previous run stopped. Re-check the claims of every page you touch against the current source evidence, and update .doxloop/evidence-map.json for those pages; leave pages you do not touch as they are.'
820
+ : '',
821
+ progress.lines.length > 0
822
+ ? `Screenshot manifest progress (${progress.verified} verified, ${progress.unfinished} unfinished):\n${progress.lines.join('\n')}`
823
+ : '',
824
+ validationText ? `Documentation validation currently reports the following. Fix every error first; then resolve the thin-page, thin-procedure, and other warnings on pages this run wrote, bringing each such page to the depth contract. Leave pages with no finding as they are:\n${validationText}` : '',
825
+ intent === 'enabled'
826
+ ? 'Screenshots are required: every unfinished guide above needs at least one verified, embedded capture, or a specific text-only reason on each step whose state genuinely cannot be reached.'
827
+ : '',
828
+ originalRequest?.trim() ? `The original instructions for this run were:\n${originalRequest.trim()}` : '',
829
+ ];
830
+ return sections.filter(Boolean).join('\n\n');
831
+ }
832
+ /** The workspace's own plan copy, used when re-validating preserved agent output. */
833
+ async function recoveryPlan(root, directory, planId) {
834
+ if (!planId)
835
+ return undefined;
836
+ // Trust the snapshot saved outside the agent workspace, never an agent-edited plan.
837
+ const trusted = join(directory, 'approved-plan.json');
838
+ if (await pathExists(trusted))
839
+ return readJson(trusted);
840
+ const path = join(root, '.doxloop', 'documentation-plan.json');
841
+ if (await pathExists(path)) {
842
+ const plan = await readJson(path);
843
+ if (plan.id === planId && plan.approvedHash)
844
+ return plan;
845
+ }
846
+ throw new DoxloopError('The approved plan snapshot is unavailable. Review and approve the plan again before retrying this run.');
847
+ }
848
+ export async function listSyncRuns(root) {
849
+ const directory = join(root, SYNC_RUNS_DIRECTORY);
850
+ if (!(await pathExists(directory)))
851
+ return [];
852
+ const entries = await readdir(directory, { withFileTypes: true });
853
+ const runs = [];
854
+ for (const entry of entries) {
855
+ if (!entry.isDirectory())
856
+ continue;
857
+ try {
858
+ runs.push(await readSyncRun(root, entry.name));
859
+ }
860
+ catch {
861
+ // An interrupted directory without a valid manifest is not a review run.
862
+ }
863
+ }
864
+ return runs.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
865
+ }
866
+ export async function readSyncRun(root, id) {
867
+ assertRunId(id);
868
+ const raw = await readJson(join(runDirectory(root, id), RUN_FILE));
869
+ const { run, migrated } = normalizeSyncRun(raw, id);
870
+ if (!run || run.id !== id || !Array.isArray(run.changes)) {
871
+ throw new DoxloopError(`Sync run ${id} is invalid.`);
872
+ }
873
+ if (migrated)
874
+ await writeRun(root, run);
875
+ const changes = run.changes.filter((change) => !isExcluded(change.path));
876
+ const excludedRuntimeConflict = run.status === 'conflicted' && Boolean(run.error && EXCLUDED_PREFIXES.some((prefix) => run.error.startsWith(`${prefix} `) || run.error.startsWith(`${prefix}/`)));
877
+ if (changes.length === run.changes.length && !excludedRuntimeConflict)
878
+ return run;
879
+ if (excludedRuntimeConflict) {
880
+ const { error: _excludedError, ...cleanRun } = run;
881
+ return {
882
+ ...cleanRun,
883
+ status: 'awaiting-review',
884
+ changes,
885
+ summary: proposalSummary(changes),
886
+ };
887
+ }
888
+ return {
889
+ ...run,
890
+ changes,
891
+ summary: proposalSummary(changes),
892
+ };
893
+ }
894
+ export async function rejectSyncRun(root, id) {
895
+ return rejectSyncChanges(root, id, undefined, 'Do not repeat this proposal without new evidence or explicit reviewer direction.');
896
+ }
897
+ export async function rejectSyncChanges(root, id, selections, reason = '') {
898
+ return withProjectLock(root, 'write', async () => {
899
+ const run = await readSyncRun(root, id);
900
+ if (run.archivedAt || !['awaiting-review', 'partially-applied', 'conflicted'].includes(run.status))
901
+ throw new DoxloopError('This proposal is not open for review.');
902
+ const requested = selections ?? run.changes.map((change) => ({ changeId: change.id }));
903
+ const now = new Date().toISOString();
904
+ const changes = run.changes.map((change) => ({ ...change, hunks: change.hunks.map((hunk) => ({ ...hunk })) }));
905
+ const paths = [];
906
+ for (const selection of requested) {
907
+ const change = changes.find((item) => item.id === selection.changeId);
908
+ if (!change)
909
+ throw new DoxloopError(`Unknown change ${selection.changeId}.`);
910
+ const ids = selection.hunkIds?.length ? selection.hunkIds : change.hunks.filter((hunk) => !hunk.acceptedAt && !hunk.rejectedAt).map((hunk) => hunk.id);
911
+ for (const id of ids) {
912
+ const hunk = change.hunks.find((item) => item.id === id);
913
+ if (!hunk)
914
+ throw new DoxloopError(`Unknown change hunk ${id}.`);
915
+ if (hunk.acceptedAt)
916
+ throw new DoxloopError('An accepted change cannot be rejected. Use Undo to revert accepted work.');
917
+ hunk.rejectedAt = now;
918
+ if (reason.trim())
919
+ hunk.rejectionReason = reason.trim().slice(0, 2000);
920
+ }
921
+ if (ids.length)
922
+ paths.push(change.path);
923
+ }
924
+ if (!paths.length)
925
+ throw new DoxloopError('Select at least one undecided change.');
926
+ const complete = changes.every((change) => change.hunks.every((hunk) => hunk.acceptedAt || hunk.rejectedAt));
927
+ const accepted = changes.some((change) => change.hunks.some((hunk) => hunk.acceptedAt));
928
+ if (complete && accepted)
929
+ await snapshotChangeSet(root, join(runDirectory(root, id), APPLIED), changes.filter((change) => change.hunks.some((hunk) => hunk.acceptedAt)));
930
+ const next = { ...run, changes, status: complete ? accepted ? 'applied' : 'rejected' : accepted ? 'partially-applied' : 'awaiting-review', ...(complete && accepted ? { appliedAt: now, undo: { status: 'available' } } : {}), ...(complete && !accepted ? { rejectedAt: now } : {}) };
931
+ await writeRun(root, next);
932
+ await recordSyncRun(root, next);
933
+ await recordReviewPreference(root, { kind: 'rejection', paths, instruction: reason.trim() || 'The reviewer rejected these changes. Preserve the current wording unless new evidence or explicit direction calls for a change.' }).catch(() => undefined);
934
+ return next;
935
+ });
936
+ }
937
+ export async function reviseSyncRun(root, id, input) {
938
+ const run = await readSyncRun(root, id);
939
+ if (run.archivedAt)
940
+ throw new DoxloopError(`Proposal ${id} is archived and cannot be revised.`);
941
+ if (!['awaiting-review', 'partially-applied', 'conflicted', 'stale'].includes(run.status)) {
942
+ throw new DoxloopError(`Proposal ${id} cannot be revised from status ${run.status}.`);
943
+ }
944
+ const instruction = input.instruction.trim();
945
+ if (!instruction)
946
+ throw new DoxloopError('Describe how the selected documentation should change.');
947
+ const changeIds = [...new Set(input.changeIds)];
948
+ if (changeIds.length === 0)
949
+ throw new DoxloopError('Select at least one file or change to revise.');
950
+ const selected = changeIds.map((changeId) => {
951
+ const change = run.changes.find((item) => item.id === changeId);
952
+ if (!change)
953
+ throw new DoxloopError(`Unknown proposal change ${changeId}.`);
954
+ return change;
955
+ });
956
+ const hunkIds = [...new Set(input.hunkIds ?? [])];
957
+ for (const hunkId of hunkIds) {
958
+ if (!selected.some((change) => change.hunks.some((hunk) => hunk.id === hunkId))) {
959
+ throw new DoxloopError(`Unknown selected change hunk ${hunkId}.`);
960
+ }
961
+ }
962
+ const project = await loadProject(root);
963
+ const [drift, sourceChanges] = await Promise.all([
964
+ computeDrift(root, project),
965
+ collectSourceChanges(root, project.sources),
966
+ ]);
967
+ const scope = selected.map((change) => `- ${change.path}`).join('\n');
968
+ const hunkScope = hunkIds.length > 0
969
+ ? `\nSelected change hunks: ${hunkIds.join(', ')}. Keep unrelated parts of those files unchanged.`
970
+ : '';
971
+ const plan = await optionalApprovedPlan(root);
972
+ if (!run.editRequest) {
973
+ await recordReviewPreference(root, { kind: 'revision', paths: selected.map((change) => change.path), instruction }).catch(() => undefined);
974
+ }
975
+ const editRequest = run.editRequest ? {
976
+ ...run.editRequest,
977
+ followUps: [...run.editRequest.followUps, {
978
+ id: `follow-up-${randomBytes(3).toString('hex')}`,
979
+ createdAt: new Date().toISOString(),
980
+ instruction,
981
+ }],
982
+ } : undefined;
983
+ return createSyncRun({
984
+ root,
985
+ project,
986
+ drift,
987
+ sourceChanges,
988
+ revisionOf: run.id,
989
+ revisionRequest: { instruction, changeIds, hunkIds },
990
+ trigger: input.trigger ?? (run.editRequest ? 'edit' : run.trigger),
991
+ ...(editRequest ? { editRequest } : {}),
992
+ ...(plan ? { plan } : {}),
993
+ ...(input.author ? { author: input.author } : {}),
994
+ authoring: {
995
+ mode: run.authoringMode ?? 'update',
996
+ ...(editRequest ? { historyRequest: instruction } : {
997
+ request: `Revise only the selected proposal scope below. Preserve every other proposed file exactly as it is.\n\nSelected files:\n${scope}${hunkScope}\n\nReviewer instruction:\n${instruction}`,
998
+ }),
999
+ // The revision runs on the model and reasoning the proposal was written
1000
+ // with. Passing only the agent let Codex fall back to its own default
1001
+ // model, which a real run's installed CLI could not use at all.
1002
+ ...revisionAgent(project, plan?.id === run.planId ? plan : undefined),
1003
+ screenshots: 'disabled',
1004
+ },
1005
+ });
1006
+ }
1007
+ function revisionAgent(project, plan) {
1008
+ const execution = plan?.execution;
1009
+ const agent = execution?.agent ?? project.defaultAgent;
1010
+ if (!agent)
1011
+ return {};
1012
+ if (execution && execution.agent === agent) {
1013
+ return {
1014
+ agent,
1015
+ ...(execution.model ? { model: execution.model } : {}),
1016
+ ...(execution.reasoning ? { reasoning: execution.reasoning } : {}),
1017
+ ...(execution.effort ? { effort: execution.effort } : {}),
1018
+ };
1019
+ }
1020
+ const model = projectDefaultModel(project, agent);
1021
+ return { agent, ...(model ? { model } : {}) };
1022
+ }
1023
+ export async function readSyncRunChangeContent(root, id, changeId) {
1024
+ const run = await readSyncRun(root, id);
1025
+ const change = run.changes.find((item) => item.id === changeId);
1026
+ if (!change)
1027
+ throw new DoxloopError(`Unknown proposal change ${changeId}.`);
1028
+ if (change.binary || change.kind === 'deleted')
1029
+ throw new DoxloopError('Only proposed text files can be edited inline.');
1030
+ const content = await readFile(assertInside(runWorkspace(root, id), resolve(runWorkspace(root, id), change.path)), 'utf8');
1031
+ const edit = [...run.humanEdits].reverse().find((item) => item.path === change.path);
1032
+ return { content, path: change.path, fingerprint: hash(Buffer.from(content)), evidenceDisposition: edit?.evidenceDisposition ?? 'preserved' };
1033
+ }
1034
+ export async function editSyncRunChange(root, id, changeId, content, evidenceDisposition, fingerprint) {
1035
+ return withProjectLock(root, 'write', () => editSyncRunChangeLocked(root, id, changeId, content, evidenceDisposition, fingerprint));
1036
+ }
1037
+ async function editSyncRunChangeLocked(root, id, changeId, content, evidenceDisposition, fingerprint) {
1038
+ let run = await readSyncRun(root, id);
1039
+ if (run.archivedAt)
1040
+ throw new DoxloopError(`Proposal ${id} is archived and cannot be edited.`);
1041
+ if (!['awaiting-review', 'partially-applied'].includes(run.status))
1042
+ throw new DoxloopError('Inline edits require an open proposal.');
1043
+ run = await refreshProposalSourceSnapshot(root, run);
1044
+ const change = run.changes.find((item) => item.id === changeId);
1045
+ if (!change)
1046
+ throw new DoxloopError(`Unknown proposal change ${changeId}.`);
1047
+ if (change.hunks.some((hunk) => hunk.acceptedAt || hunk.rejectedAt))
1048
+ throw new DoxloopError('This file already has review decisions. Edit an undecided file or request a new revision to preserve those decisions.');
1049
+ if (change.binary || change.kind === 'deleted' || change.category !== 'page') {
1050
+ throw new DoxloopError('Inline editing is available only for proposed text pages.');
1051
+ }
1052
+ if (Buffer.byteLength(content, 'utf8') > 1_000_000)
1053
+ throw new DoxloopError('Inline page edits are limited to 1 MB.');
1054
+ const workspace = runWorkspace(root, id);
1055
+ const path = assertInside(workspace, resolve(workspace, change.path));
1056
+ const previous = await readFile(path);
1057
+ if (fingerprint && hash(previous) !== fingerprint)
1058
+ throw new DoxloopError('The proposal changed while you were editing. Your draft is kept; reload and compare before saving.');
1059
+ const evidencePath = join(workspace, '.doxloop', 'evidence-map.json');
1060
+ const previousEvidence = (await pathExists(evidencePath)) ? await readFile(evidencePath) : undefined;
1061
+ try {
1062
+ await writeAtomic(path, Buffer.from(content));
1063
+ if (evidenceDisposition === 'needs-review' && previousEvidence) {
1064
+ const evidence = JSON.parse(previousEvidence.toString('utf8'));
1065
+ const pageEvidence = evidence.pages[change.path];
1066
+ if (pageEvidence) {
1067
+ evidence.pages[change.path] = { ...pageEvidence, confidence: 'needs-human' };
1068
+ await writeAtomic(evidencePath, Buffer.from(`${JSON.stringify(evidence, null, 2)}\n`));
1069
+ }
1070
+ }
1071
+ const validation = await validateProject(workspace);
1072
+ if (validation.errors > 0) {
1073
+ throw new DoxloopError(`The inline edit did not pass validation:\n${formatValidation(validation)}`);
1074
+ }
1075
+ const project = await loadProject(root);
1076
+ const sourceChanges = await collectSourceChanges(root, project.sources);
1077
+ const plan = await optionalApprovedPlan(root);
1078
+ const collected = await collectProposalChanges(root, workspace, runDirectory(root, id), project);
1079
+ for (const note of await repairProposalSourceScopes(workspace, collected, project))
1080
+ process.stdout.write(`${note}\n`);
1081
+ let changes = await enrichProposalRationales(root, workspace, collected, {
1082
+ root,
1083
+ project,
1084
+ drift: await computeDrift(root, project),
1085
+ sourceChanges,
1086
+ ...(plan ? { plan } : {}),
1087
+ authoring: {
1088
+ ...(change.rationale.request ? { request: change.rationale.request } : {}),
1089
+ screenshots: 'disabled',
1090
+ },
1091
+ }, validation);
1092
+ changes = changes.map((item) => {
1093
+ const old = run.changes.find((candidate) => candidate.path === item.path);
1094
+ if (item.path !== change.path)
1095
+ return old ?? item;
1096
+ const evidencePreserved = evidenceDisposition === 'preserved';
1097
+ return {
1098
+ ...item,
1099
+ rationale: {
1100
+ ...(old?.rationale ?? item.rationale),
1101
+ reason: `A reviewer edited “${item.title}” directly before acceptance.`,
1102
+ confidence: evidencePreserved ? (old?.rationale.confidence ?? item.rationale.confidence) : 'needs-human',
1103
+ assumptions: evidencePreserved
1104
+ ? [...new Set([...(old?.rationale.assumptions ?? []), 'A reviewer changed the prose while preserving the recorded evidence associations.'])]
1105
+ : ['The reviewer marked the evidence associations for re-checking after this edit.'],
1106
+ authorship: 'human',
1107
+ },
1108
+ };
1109
+ });
1110
+ const next = {
1111
+ ...run,
1112
+ summary: proposalSummary(changes),
1113
+ changes,
1114
+ humanEdits: [...run.humanEdits, { changeId, path: change.path, editedAt: new Date().toISOString(), evidenceDisposition }],
1115
+ validation: { pages: validation.pages.length, errors: validation.errors, warnings: validation.warnings },
1116
+ };
1117
+ await writeRun(root, next);
1118
+ await recordSyncRun(root, next);
1119
+ await recordReviewPreference(root, { kind: 'inline-edit', paths: [change.path], instruction: evidenceDisposition === 'preserved' ? 'Preserve this reviewer-authored wording while its supporting evidence remains valid.' : 'A reviewer changed this page and requires its evidence to be checked again.' }).catch(() => undefined);
1120
+ return next;
1121
+ }
1122
+ catch (error) {
1123
+ await writeAtomic(path, previous);
1124
+ await writeAtomic(evidencePath, previousEvidence);
1125
+ throw error;
1126
+ }
1127
+ }
1128
+ export async function undoSyncRun(root, id) {
1129
+ return withProjectLock(root, 'write', () => undoSyncRunLocked(root, id));
1130
+ }
1131
+ async function undoSyncRunLocked(root, id) {
1132
+ const run = await readSyncRun(root, id);
1133
+ if (run.status !== 'applied' || run.undo?.status === 'undone') {
1134
+ throw new DoxloopError(`Proposal ${id} does not have an applied change set available to undo.`);
1135
+ }
1136
+ const originals = new Map();
1137
+ const operationalPath = join(root, '.doxloop', 'sync-state.json');
1138
+ const operationalCurrent = (await pathExists(operationalPath)) ? await readFile(operationalPath) : undefined;
1139
+ try {
1140
+ for (const change of run.changes.filter((item) => item.hunks.some((hunk) => hunk.acceptedAt))) {
1141
+ const before = await proposalAcceptanceBefore(root, id, change.path);
1142
+ const actualPath = await safePath(root, change.path);
1143
+ const current = (await pathExists(actualPath)) ? await readFile(actualPath) : undefined;
1144
+ const appliedSnapshot = await readChangeSnapshot(join(runDirectory(root, id), APPLIED), change.path);
1145
+ const expected = appliedSnapshot.found
1146
+ ? appliedSnapshot.content
1147
+ : await materializeChange(await proposalBefore(root, id, change.path), change, acceptedIds(change), runWorkspace(root, id));
1148
+ if (!buffersEqual(current, expected)) {
1149
+ throw new DoxloopError(`${change.path} changed after this proposal was applied. Undo stopped without overwriting the newer edit.`);
1150
+ }
1151
+ originals.set(change.path, current);
1152
+ await writeAtomic(actualPath, before);
1153
+ }
1154
+ if (run.changes.every((item) => item.hunks.every((hunk) => hunk.acceptedAt)))
1155
+ await restoreOperationalBefore(root, id);
1156
+ const validation = await validateProject(root);
1157
+ if (validation.errors > 0)
1158
+ throw new DoxloopError(`Undo would leave invalid documentation:\n${formatValidation(validation)}`);
1159
+ const undoneAt = new Date().toISOString();
1160
+ const next = { ...run, status: 'undone', undo: { status: 'undone', undoneAt } };
1161
+ await writeRun(root, next);
1162
+ await recordSyncRun(root, next);
1163
+ await syncPageRegistry(root, undefined, next.id);
1164
+ return next;
1165
+ }
1166
+ catch (error) {
1167
+ await rollbackAppliedFiles(root, originals, operationalPath, operationalCurrent);
1168
+ throw error;
1169
+ }
1170
+ }
1171
+ export async function archiveSyncRun(root, id) {
1172
+ const run = await readSyncRun(root, id);
1173
+ if (run.status === 'generating')
1174
+ throw new DoxloopError('A generating proposal cannot be archived.');
1175
+ const next = { ...run, archivedAt: new Date().toISOString() };
1176
+ await writeRun(root, next);
1177
+ return next;
1178
+ }
1179
+ export async function pruneSyncRuns(root, now = new Date()) {
1180
+ const removable = new Set(['rejected', 'failed', 'superseded', 'undone']);
1181
+ const removed = [];
1182
+ for (const run of await listSyncRuns(root)) {
1183
+ const expired = run.retentionUntil && new Date(run.retentionUntil) <= now;
1184
+ if (!(run.archivedAt || (expired && removable.has(run.status))))
1185
+ continue;
1186
+ await rm(runDirectory(root, run.id), { recursive: true, force: true });
1187
+ removed.push(run.id);
1188
+ }
1189
+ return removed;
1190
+ }
1191
+ export async function acceptSyncChanges(root, id, selections, options = {}) {
1192
+ return withProjectLock(root, 'write', () => acceptSyncChangesLocked(root, id, selections, options));
1193
+ }
1194
+ /** Project-relative paths of the proposal's changes that still have hunks neither accepted nor rejected. */
1195
+ export function pendingProposalPaths(changes) {
1196
+ return new Set(changes
1197
+ .filter((change) => change.hunks.some((hunk) => !hunk.acceptedAt && !hunk.rejectedAt))
1198
+ .map((change) => change.path.replaceAll('\\', '/')));
1199
+ }
1200
+ const PAGE_FILE = /\.(?:mdx?|rst|ipynb|txt)$/i;
1201
+ /**
1202
+ * Whether a validation error is one the not-yet-accepted part of the same
1203
+ * proposal takes care of: an error on a file the proposal still replaces,
1204
+ * or a page missing from a navigation the proposal still updates. Such an
1205
+ * error is real for the workspace as it stands and gone once the reviewer
1206
+ * accepts the rest, so it must not block accepting one file at a time.
1207
+ */
1208
+ export function resolvedByPendingChanges(issue, pending) {
1209
+ if (pending.size === 0)
1210
+ return false;
1211
+ const file = issue.file?.replaceAll('\\', '/');
1212
+ if (file && pending.has(file))
1213
+ return true;
1214
+ if (issue.code === 'unnavigated-page') {
1215
+ for (const path of pending) {
1216
+ if (!PAGE_FILE.test(path) && !path.startsWith('.doxloop/') && !path.startsWith('assets/'))
1217
+ return true;
1218
+ }
1219
+ }
1220
+ if (issue.code === 'broken-link') {
1221
+ // "Local link target does not exist: /administration/api-keys" — the
1222
+ // target page is one the proposal still adds.
1223
+ const target = (issue.message ?? '').match(/:\s*(\S+)\s*$/)?.[1]?.replace(/^\/+/, '').replace(/[#?].*$/, '').replace(/\/+$/, '');
1224
+ if (target) {
1225
+ const stem = target.replace(PAGE_FILE, '');
1226
+ for (const path of pending) {
1227
+ const pendingStem = path.replace(PAGE_FILE, '');
1228
+ if (pendingStem === stem || pendingStem === `${stem}/index` || pendingStem.endsWith(`/${stem}`))
1229
+ return true;
1230
+ }
1231
+ }
1232
+ }
1233
+ return false;
1234
+ }
1235
+ /** Apply selected hunks after proving the real files still match this proposal. */
1236
+ async function acceptSyncChangesLocked(root, id, selections, options = {}) {
1237
+ let run = await readSyncRun(root, id);
1238
+ if (run.archivedAt)
1239
+ throw new DoxloopError(`Proposal ${id} is archived and cannot be applied.`);
1240
+ if (!['awaiting-review', 'partially-applied', 'conflicted'].includes(run.status)) {
1241
+ throw new DoxloopError(`Sync run ${id} cannot be applied from status ${run.status}.`);
1242
+ }
1243
+ run = await refreshProposalSourceSnapshot(root, run);
1244
+ const requested = new Map();
1245
+ for (const selection of selections) {
1246
+ const change = run.changes.find((candidate) => candidate.id === selection.changeId);
1247
+ if (!change)
1248
+ throw new DoxloopError(`Unknown change ${selection.changeId}.`);
1249
+ const ids = new Set(selection.hunkIds && selection.hunkIds.length > 0
1250
+ ? selection.hunkIds
1251
+ : change.hunks.filter((hunk) => !hunk.acceptedAt && !hunk.rejectedAt).map((hunk) => hunk.id));
1252
+ for (const hunkId of ids) {
1253
+ if (change.hunks.some((hunk) => hunk.id === hunkId && hunk.rejectedAt))
1254
+ throw new DoxloopError('A rejected change cannot be accepted. Request a new revision instead.');
1255
+ if (!change.hunks.some((hunk) => hunk.id === hunkId)) {
1256
+ throw new DoxloopError(`Unknown change hunk ${hunkId}.`);
1257
+ }
1258
+ }
1259
+ if (change.changedDuringRun && !options.confirmChangedDuringRun) {
1260
+ throw new DoxloopError(`${change.path} was edited in the project while the agent ran. Confirm that the proposal should replace that edit before applying it; no file was overwritten.`);
1261
+ }
1262
+ requested.set(change.id, ids);
1263
+ }
1264
+ if (requested.size === 0)
1265
+ throw new DoxloopError('Select at least one documentation change.');
1266
+ if (!run.changes.some((change) => change.hunks.some((hunk) => hunk.acceptedAt))) {
1267
+ await snapshotOperationalBefore(root, runDirectory(root, id), true);
1268
+ await snapshotChangeSet(root, join(runDirectory(root, id), ACCEPTANCE_BEFORE), run.changes);
1269
+ }
1270
+ const originals = new Map();
1271
+ const operationalPath = join(root, '.doxloop', 'sync-state.json');
1272
+ const operationalCurrent = await pathExists(operationalPath) ? await readFile(operationalPath) : undefined;
1273
+ const now = new Date().toISOString();
1274
+ let nextChanges = run.changes.map((change) => ({
1275
+ ...change,
1276
+ hunks: change.hunks.map((hunk) => ({ ...hunk })),
1277
+ }));
1278
+ try {
1279
+ for (const change of nextChanges) {
1280
+ const ids = requested.get(change.id);
1281
+ if (!ids || ids.size === 0)
1282
+ continue;
1283
+ const actualPath = await safePath(root, change.path);
1284
+ const before = await proposalBefore(root, run.id, change.path);
1285
+ const current = (await pathExists(actualPath)) ? await readFile(actualPath) : undefined;
1286
+ const expected = await materializeChange(before, change, acceptedIds(change), runWorkspace(root, run.id));
1287
+ const accepted = new Set([...acceptedIds(change), ...ids]);
1288
+ let result = await materializeChange(before, change, accepted, runWorkspace(root, run.id));
1289
+ if (!buffersEqual(current, expected)) {
1290
+ const merged = change.path === '.doxloop/evidence-map.json' && expected && current && result
1291
+ ? mergeConcurrentJson(expected, current, result)
1292
+ : undefined;
1293
+ if (!merged) {
1294
+ throw new DoxloopError(`${change.path} changed after this proposal was generated. Regenerate or review the conflict; no file was overwritten.`);
1295
+ }
1296
+ result = merged;
1297
+ }
1298
+ originals.set(change.path, current);
1299
+ await writeAtomic(actualPath, result);
1300
+ change.hunks = change.hunks.map((hunk) => ids.has(hunk.id) && !hunk.rejectedAt ? { ...hunk, acceptedAt: now } : hunk);
1301
+ }
1302
+ const validation = await validateProject(root);
1303
+ if (validation.errors > 0) {
1304
+ // Only errors block; listing every depth warning alongside them hid
1305
+ // the one line the reviewer had to act on. Errors that the rest of
1306
+ // this proposal resolves (a starter page it replaces, a navigation
1307
+ // entry its docs.json adds) do not block either: accepting one page
1308
+ // of a new site before its navigation used to fail on exactly those.
1309
+ const pending = pendingProposalPaths(nextChanges);
1310
+ const blocking = {
1311
+ ...validation,
1312
+ issues: validation.issues.filter((issue) => issue.severity === 'error' && !resolvedByPendingChanges(issue, pending)),
1313
+ };
1314
+ if (blocking.issues.length > 0) {
1315
+ throw new DoxloopError(`The selected changes were not applied because they would leave invalid documentation:\n${formatValidation({ ...blocking, errors: blocking.issues.length })}`);
1316
+ }
1317
+ }
1318
+ const complete = nextChanges.every((change) => change.hunks.every((hunk) => hunk.acceptedAt !== undefined || hunk.rejectedAt !== undefined));
1319
+ const allAccepted = nextChanges.every((change) => change.hunks.every((hunk) => hunk.acceptedAt));
1320
+ if (complete && allAccepted && !run.editRequest)
1321
+ await applyStagedSyncState(root, id);
1322
+ if (complete)
1323
+ await snapshotChangeSet(root, join(runDirectory(root, id), APPLIED), nextChanges);
1324
+ const { error: _previousError, ...cleanRun } = run;
1325
+ const next = {
1326
+ ...cleanRun,
1327
+ status: complete ? 'applied' : 'partially-applied',
1328
+ ...(complete ? { appliedAt: now } : {}),
1329
+ undo: complete
1330
+ ? { status: 'available' }
1331
+ : { status: 'unavailable', reason: 'Undo becomes available after the complete proposal is applied.' },
1332
+ changes: nextChanges,
1333
+ validation: {
1334
+ pages: validation.pages.length,
1335
+ errors: validation.errors,
1336
+ warnings: validation.warnings,
1337
+ },
1338
+ };
1339
+ await writeRun(root, next);
1340
+ await recordSyncRun(root, next);
1341
+ // Accepted hunks are now real files. Refresh the registry so page history
1342
+ // and the external-edit check both start from what is actually on disk.
1343
+ await syncPageRegistry(root, undefined, next.id);
1344
+ if (complete) {
1345
+ if (allAccepted && !run.editRequest)
1346
+ await recordSourceSyncs(root, await readOptionalSyncState(root), next.id);
1347
+ await recordAppliedAuthoringReceipt(root, next).catch(() => undefined);
1348
+ }
1349
+ return next;
1350
+ }
1351
+ catch (error) {
1352
+ await rollbackAppliedFiles(root, originals, operationalPath, operationalCurrent);
1353
+ const conflicted = {
1354
+ ...run,
1355
+ status: error instanceof DoxloopError && error.message.includes('changed after')
1356
+ ? 'conflicted'
1357
+ : run.status,
1358
+ error: error instanceof Error ? error.message : String(error),
1359
+ };
1360
+ await writeRun(root, conflicted);
1361
+ await recordSyncRun(root, conflicted);
1362
+ throw error;
1363
+ }
1364
+ }
1365
+ async function rollbackAppliedFiles(root, originals, operationalPath, operationalCurrent) {
1366
+ const results = await Promise.allSettled([
1367
+ ...[...originals].map(async ([path, content]) => writeAtomic(await safePath(root, path), content)),
1368
+ writeAtomic(operationalPath, operationalCurrent),
1369
+ ]);
1370
+ const errors = results.filter((result) => result.status === 'rejected');
1371
+ if (errors.length)
1372
+ throw new AggregateError(errors.map((result) => result.reason), 'Rollback needs recovery. Original page snapshots are preserved in the proposal run directory.');
1373
+ }
1374
+ async function readOptionalSyncState(root) {
1375
+ const path = join(root, '.doxloop', 'sync-state.json');
1376
+ if (!(await pathExists(path)))
1377
+ return undefined;
1378
+ try {
1379
+ return await readJson(path);
1380
+ }
1381
+ catch {
1382
+ return undefined;
1383
+ }
1384
+ }
1385
+ async function recordAppliedAuthoringReceipt(root, run) {
1386
+ if (!run.authoringMode || !run.validation || run.editRequest)
1387
+ return;
1388
+ await writeAtomic(join(root, '.doxloop', 'last-run.json'), Buffer.from(`${JSON.stringify({
1389
+ schemaVersion: 1,
1390
+ mode: run.authoringMode,
1391
+ completedAt: run.appliedAt ?? new Date().toISOString(),
1392
+ proposalId: run.id,
1393
+ ...(run.planId ? { planId: run.planId } : {}),
1394
+ validation: run.validation,
1395
+ }, null, 2)}\n`));
1396
+ }
1397
+ const JSON_MISSING = Symbol('json-missing');
1398
+ function mergeConcurrentJson(base, current, proposed) {
1399
+ try {
1400
+ const merged = mergeJsonValue(JSON.parse(base.toString('utf8')), JSON.parse(current.toString('utf8')), JSON.parse(proposed.toString('utf8')));
1401
+ if (merged === JSON_MISSING)
1402
+ return undefined;
1403
+ return Buffer.from(`${JSON.stringify(merged, null, 2)}\n`);
1404
+ }
1405
+ catch {
1406
+ return undefined;
1407
+ }
1408
+ }
1409
+ function mergeJsonValue(base, current, proposed) {
1410
+ if (isDeepStrictEqual(current, base))
1411
+ return proposed;
1412
+ if (isDeepStrictEqual(proposed, base) || isDeepStrictEqual(current, proposed))
1413
+ return current;
1414
+ if (isJsonRecord(base) && isJsonRecord(current) && isJsonRecord(proposed)) {
1415
+ const result = {};
1416
+ const keys = new Set([...Object.keys(base), ...Object.keys(current), ...Object.keys(proposed)]);
1417
+ for (const key of keys) {
1418
+ const merged = mergeJsonValue(Object.hasOwn(base, key) ? base[key] : JSON_MISSING, Object.hasOwn(current, key) ? current[key] : JSON_MISSING, Object.hasOwn(proposed, key) ? proposed[key] : JSON_MISSING);
1419
+ if (merged !== JSON_MISSING)
1420
+ result[key] = merged;
1421
+ }
1422
+ return result;
1423
+ }
1424
+ // Both sides changed the same scalar or array. Preserve the newer accepted value.
1425
+ return current;
1426
+ }
1427
+ function isJsonRecord(value) {
1428
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
1429
+ }
1430
+ export function formatSyncRunHistory(runs) {
1431
+ if (runs.length === 0)
1432
+ return 'No documentation sync runs yet.';
1433
+ const lines = ['Documentation sync runs', ''];
1434
+ for (const run of runs) {
1435
+ const counts = changeCounts(run.changes);
1436
+ lines.push(` ${statusMark(run.status)} ${run.id} ${run.status.padEnd(18)} ${counts} ${run.createdAt}`, ` ${run.summary}`);
1437
+ }
1438
+ lines.push('', 'Review visually with: doxloop sync review --open');
1439
+ return lines.join('\n');
1440
+ }
1441
+ export function pendingRunCount(runs) {
1442
+ return runs.filter((run) => ['awaiting-review', 'partially-applied', 'conflicted'].includes(run.status)).length;
1443
+ }
1444
+ export function runWorkspace(root, id) {
1445
+ assertRunId(id);
1446
+ return join(runDirectory(root, id), WORKSPACE);
1447
+ }
1448
+ export function runBeforeRoot(root, id) {
1449
+ assertRunId(id);
1450
+ return join(runDirectory(root, id), BEFORE);
1451
+ }
1452
+ async function createWorkspace(root, runRoot, project, authoringSources) {
1453
+ const temporary = await mkdtemp(join(tmpdir(), 'doxloop-sync-review-'));
1454
+ const staged = join(temporary, WORKSPACE);
1455
+ try {
1456
+ await cp(root, staged, {
1457
+ recursive: true,
1458
+ preserveTimestamps: true,
1459
+ filter: (source) => {
1460
+ const rel = portable(relative(root, source));
1461
+ return rel === '' || !isExcluded(rel);
1462
+ },
1463
+ });
1464
+ // What the agent starts from. Review compares its output with this, not
1465
+ // with whatever the project holds by the time the run finishes, so an
1466
+ // edit made in the project during the run is never proposed as a revert.
1467
+ await writeWorkspaceBaseline(runRoot, staged);
1468
+ await rewriteWorkspaceSources(staged, root, {
1469
+ ...project,
1470
+ sources: authoringSources ?? project.sources,
1471
+ });
1472
+ await initializeWorkspaceGit(staged);
1473
+ const target = join(runRoot, WORKSPACE);
1474
+ await rename(staged, target);
1475
+ return target;
1476
+ }
1477
+ finally {
1478
+ await rm(temporary, { recursive: true, force: true });
1479
+ }
1480
+ }
1481
+ async function seedRevisionWorkspace(root, revisionOf, workspace) {
1482
+ const source = runWorkspace(root, revisionOf);
1483
+ if (!(await pathExists(source)))
1484
+ throw new DoxloopError(`Proposal ${revisionOf} no longer has an isolated workspace to revise.`);
1485
+ await cp(source, workspace, {
1486
+ recursive: true,
1487
+ force: true,
1488
+ preserveTimestamps: true,
1489
+ filter: (path) => portable(relative(source, path)) !== '.git' && !portable(relative(source, path)).startsWith('.git/'),
1490
+ });
1491
+ }
1492
+ async function initializeWorkspaceGit(workspace) {
1493
+ await runCommand('git', ['init', '--initial-branch=doxloop-review'], { cwd: workspace });
1494
+ await runCommand('git', ['add', '.'], { cwd: workspace });
1495
+ await runCommand('git', [
1496
+ '-c',
1497
+ 'user.name=Doxloop Review',
1498
+ '-c',
1499
+ 'user.email=review@doxloop.local',
1500
+ 'commit',
1501
+ '-m',
1502
+ 'Doxloop proposal baseline',
1503
+ ], { cwd: workspace });
1504
+ }
1505
+ async function rewriteWorkspaceSources(workspace, root, project) {
1506
+ const path = join(workspace, '.doxloop', 'project.json');
1507
+ const staged = JSON.parse(await readFile(path, 'utf8'));
1508
+ staged.sources = project.sources.map((source) => ({
1509
+ ...source,
1510
+ path: isAbsolute(source.path) ? source.path : resolve(root, source.path),
1511
+ }));
1512
+ await writeFile(path, `${JSON.stringify(staged, null, 2)}\n`, 'utf8');
1513
+ }
1514
+ async function restoreWorkspaceSources(workspace, project, originalText) {
1515
+ const path = join(workspace, '.doxloop', 'project.json');
1516
+ const staged = JSON.parse(await readFile(path, 'utf8'));
1517
+ staged.sources = project.sources;
1518
+ const original = JSON.parse(originalText);
1519
+ await writeFile(path, JSON.stringify(staged) === JSON.stringify(original)
1520
+ ? originalText
1521
+ : `${JSON.stringify(staged, null, 2)}\n`, 'utf8');
1522
+ }
1523
+ async function collectProposalChanges(root, workspace, runRoot, project) {
1524
+ const [beforeFiles, afterFiles, baseline] = await Promise.all([
1525
+ collectFiles(root),
1526
+ collectFiles(workspace),
1527
+ readWorkspaceBaseline(runRoot),
1528
+ ]);
1529
+ const paths = [...new Set([...beforeFiles.keys(), ...afterFiles.keys()])].sort();
1530
+ const pageExtensions = await documentationPageExtensions(root, project);
1531
+ const changes = [];
1532
+ for (const path of paths) {
1533
+ if (INTERNAL_DIFF_PATHS.has(path))
1534
+ continue;
1535
+ const before = beforeFiles.get(path);
1536
+ const after = afterFiles.get(path);
1537
+ if (buffersEqual(before, after))
1538
+ continue;
1539
+ const relation = baseline ? compareWithBaseline(baseline.get(path), before, after) : 'agent-only';
1540
+ // The agent left this file as it found it; only the project moved.
1541
+ if (relation === 'project-only')
1542
+ continue;
1543
+ if (before) {
1544
+ const target = join(runRoot, BEFORE, path);
1545
+ await mkdir(dirname(target), { recursive: true });
1546
+ await writeFile(target, before);
1547
+ }
1548
+ const binary = isBinary(path, before, after);
1549
+ const kind = before === undefined ? 'added' : after === undefined ? 'deleted' : 'modified';
1550
+ const changeId = `change-${changes.length + 1}`;
1551
+ const hunks = binary
1552
+ ? [{ id: `${changeId}-file`, oldStart: 0, oldLines: [], newStart: 0, newLines: [] }]
1553
+ : lineHunks(before?.toString('utf8') ?? '', after?.toString('utf8') ?? '', changeId);
1554
+ changes.push({
1555
+ id: changeId,
1556
+ path,
1557
+ title: await changeTitle(path, before ? join(root, path) : join(workspace, path), project, pageExtensions),
1558
+ kind,
1559
+ category: changeCategory(path, project, pageExtensions),
1560
+ binary,
1561
+ ...(before ? { beforeHash: hash(before) } : {}),
1562
+ ...(after ? { afterHash: hash(after) } : {}),
1563
+ ...(!binary && before ? { beforeEndsWithNewline: before.toString('utf8').endsWith('\n') } : {}),
1564
+ ...(!binary && after ? { afterEndsWithNewline: after.toString('utf8').endsWith('\n') } : {}),
1565
+ hunks,
1566
+ rationale: emptyRationale(kind),
1567
+ ...(relation === 'both' ? { changedDuringRun: true } : {}),
1568
+ });
1569
+ }
1570
+ return changes;
1571
+ }
1572
+ /**
1573
+ * Where a file moved since the run started: only in the agent's workspace,
1574
+ * only in the project, or in both. Hashes are enough — the live content is
1575
+ * the "before" side either way, and a file the agent never touched is not a
1576
+ * proposal change no matter what happened to it in the project.
1577
+ */
1578
+ export function compareWithBaseline(baselineHash, live, proposed) {
1579
+ const liveHash = live ? hash(live) : undefined;
1580
+ const proposedHash = proposed ? hash(proposed) : undefined;
1581
+ if (proposedHash === baselineHash)
1582
+ return 'project-only';
1583
+ return liveHash === baselineHash ? 'agent-only' : 'both';
1584
+ }
1585
+ async function writeWorkspaceBaseline(runRoot, workspace) {
1586
+ const files = await collectFiles(workspace);
1587
+ const hashes = {};
1588
+ for (const [path, content] of [...files].sort(([left], [right]) => left.localeCompare(right)))
1589
+ hashes[path] = hash(content);
1590
+ await mkdir(runRoot, { recursive: true });
1591
+ await writeFile(join(runRoot, BASELINE_FILE), `${JSON.stringify({ schemaVersion: 1, files: hashes }, null, 2)}\n`, 'utf8');
1592
+ }
1593
+ /** Absent for runs created before the baseline was recorded; those fall back to a live comparison. */
1594
+ async function readWorkspaceBaseline(runRoot) {
1595
+ const path = join(runRoot, BASELINE_FILE);
1596
+ if (!(await pathExists(path)))
1597
+ return undefined;
1598
+ try {
1599
+ const value = await readJson(path);
1600
+ if (value.schemaVersion !== 1 || !value.files || typeof value.files !== 'object')
1601
+ return undefined;
1602
+ return new Map(Object.entries(value.files).filter((entry) => typeof entry[1] === 'string'));
1603
+ }
1604
+ catch {
1605
+ return undefined;
1606
+ }
1607
+ }
1608
+ /**
1609
+ * A page attributed to a source scoped to another route is a bookkeeping
1610
+ * slip, not a reason to discard the run: the attribution is removed from the
1611
+ * evidence map and the reviewer is told. Before, one such entry failed the
1612
+ * whole proposal after the pages were written.
1613
+ */
1614
+ async function repairProposalSourceScopes(workspace, changes, project) {
1615
+ const scoped = new Map(project.sources.filter((source) => source.scope?.routePrefix).map((source) => [source.name, source]));
1616
+ if (!scoped.size)
1617
+ return [];
1618
+ let map;
1619
+ try {
1620
+ map = await readJson(join(workspace, '.doxloop', 'evidence-map.json'));
1621
+ }
1622
+ catch {
1623
+ return [];
1624
+ }
1625
+ const prefix = project.contentDir.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '');
1626
+ const notes = [];
1627
+ for (const change of changes.filter((item) => item.category === 'page')) {
1628
+ const route = change.path.replace(/\\/g, '/').replace(new RegExp(`^${escapeRegExp(prefix)}/?`), '').replace(/\.[^.]+$/, '');
1629
+ const entry = map.pages[change.path];
1630
+ if (!entry)
1631
+ continue;
1632
+ entry.sources = entry.sources.filter((evidence) => {
1633
+ const source = scoped.get(evidence.source);
1634
+ if (!source?.scope?.routePrefix)
1635
+ return true;
1636
+ const owned = source.scope.routePrefix.replace(/^\/+|\/+$/g, '');
1637
+ const shared = source.scope.sharedPages?.some((pattern) => matchesGlob(change.path, pattern) || matchesGlob(route, pattern)) ?? false;
1638
+ if (route === owned || route.startsWith(`${owned}/`) || shared)
1639
+ return true;
1640
+ notes.push(`${change.path}: attribution to source "${source.name}" (scoped to "${owned}") was removed from the evidence map; move the page into that route or add it to source.scope.sharedPages if it belongs there.`);
1641
+ return false;
1642
+ });
1643
+ }
1644
+ if (notes.length > 0)
1645
+ await writeFile(join(workspace, '.doxloop', 'evidence-map.json'), `${JSON.stringify(map, null, 2)}\n`, 'utf8');
1646
+ return notes;
1647
+ }
1648
+ function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
1649
+ async function enrichProposalRationales(root, workspace, changes, options, validation) {
1650
+ let evidenceMap;
1651
+ let existingEvidenceMap;
1652
+ try {
1653
+ evidenceMap = await readJson(join(workspace, '.doxloop', 'evidence-map.json'));
1654
+ }
1655
+ catch {
1656
+ evidenceMap = undefined;
1657
+ }
1658
+ try {
1659
+ existingEvidenceMap = await readJson(join(root, '.doxloop', 'evidence-map.json'));
1660
+ }
1661
+ catch {
1662
+ existingEvidenceMap = undefined;
1663
+ }
1664
+ const availability = Object.fromEntries(await Promise.all(options.project.sources.map(async (source) => [
1665
+ source.name,
1666
+ /^https?:\/\//i.test(source.path) || await pathExists(isAbsolute(source.path) ? source.path : resolve(root, source.path)),
1667
+ ])));
1668
+ const revisions = sourceRevisionMap(options.sourceChanges);
1669
+ const changedEvidence = options.sourceChanges.flatMap((change) => sourceChangePaths(change).map((path) => ({
1670
+ source: change.name,
1671
+ path,
1672
+ ...(revisions[change.name] ? { revision: revisions[change.name] } : {}),
1673
+ available: availability[change.name] ?? false,
1674
+ })));
1675
+ return changes.map((change) => {
1676
+ const pageEvidence = evidenceMap?.pages?.[change.path] ?? existingEvidenceMap?.pages?.[change.path];
1677
+ const planPage = matchPlanPage(options.plan, change.path);
1678
+ const capabilities = options.plan?.capabilities.filter((capability) => planPage && capability.pageIds.includes(planPage.id)) ?? [];
1679
+ const preciseEvidence = pageEvidence?.sources.flatMap((source) => [
1680
+ ...(source.paths ?? []).map((path) => ({
1681
+ source: source.source,
1682
+ path,
1683
+ ...((pageEvidence.verifiedAt?.[source.source] ?? revisions[source.source])
1684
+ ? { revision: pageEvidence.verifiedAt?.[source.source] ?? revisions[source.source] }
1685
+ : {}),
1686
+ available: availability[source.source] ?? false,
1687
+ })),
1688
+ ...(source.operations ?? []).map((operation) => ({
1689
+ source: source.source,
1690
+ operation,
1691
+ ...((pageEvidence.verifiedAt?.[source.source] ?? revisions[source.source])
1692
+ ? { revision: pageEvidence.verifiedAt?.[source.source] ?? revisions[source.source] }
1693
+ : {}),
1694
+ available: availability[source.source] ?? false,
1695
+ })),
1696
+ ]) ?? [];
1697
+ const evidence = uniqueRationaleEvidence(preciseEvidence.length > 0 ? preciseEvidence : [
1698
+ ...capabilities.flatMap((capability) => capability.evidence.map((item) => ({
1699
+ source: item.source,
1700
+ path: item.path,
1701
+ ...(revisions[item.source] ? { revision: revisions[item.source] } : {}),
1702
+ available: availability[item.source] ?? false,
1703
+ }))),
1704
+ ...changedEvidence,
1705
+ ]);
1706
+ const claims = pageEvidence?.claims ?? [];
1707
+ const confidence = pageEvidence?.confidence ?? (evidence.length > 0 ? 'inferred' : 'needs-human');
1708
+ const assumptions = confidence === 'needs-human'
1709
+ ? ['The generated claims require human verification against configured evidence.']
1710
+ : [];
1711
+ const fileIssues = validation.issues.filter((issue) => !issue.file || portable(issue.file) === change.path);
1712
+ return {
1713
+ ...change,
1714
+ rationale: {
1715
+ reason: planPage?.rationale || changeReason(change),
1716
+ evidence,
1717
+ affectedInterfaces: [...new Set(capabilities.flatMap((capability) => [capability.title, ...capability.evidence.flatMap((item) => item.label ? [item.label] : [])]))],
1718
+ claims: {
1719
+ added: change.kind === 'added' ? claims : [],
1720
+ changed: change.kind === 'modified' ? claims : [],
1721
+ removed: change.kind === 'deleted' ? claims : [],
1722
+ },
1723
+ validation: {
1724
+ errors: fileIssues.filter((issue) => issue.severity === 'error').length,
1725
+ warnings: fileIssues.filter((issue) => issue.severity === 'warning').length,
1726
+ },
1727
+ confidence,
1728
+ assumptions,
1729
+ ...(options.plan ? { planId: options.plan.id } : {}),
1730
+ ...(planPage ? { planPageId: planPage.id } : {}),
1731
+ ...(options.authoring?.request ? { request: options.authoring.request } : {}),
1732
+ authorship: 'agent',
1733
+ },
1734
+ };
1735
+ });
1736
+ }
1737
+ async function collectFiles(root) {
1738
+ const files = new Map();
1739
+ await walkFiles(root, root, files);
1740
+ return files;
1741
+ }
1742
+ async function walkFiles(root, directory, files) {
1743
+ const entries = await readdir(directory, { withFileTypes: true });
1744
+ for (const entry of entries) {
1745
+ const absolute = resolve(directory, entry.name);
1746
+ const path = portable(relative(root, absolute));
1747
+ if (isExcluded(path))
1748
+ continue;
1749
+ if (entry.isDirectory())
1750
+ await walkFiles(root, absolute, files);
1751
+ else if (entry.isFile())
1752
+ files.set(path, await readFile(absolute));
1753
+ }
1754
+ }
1755
+ function isExcluded(path) {
1756
+ return (path.startsWith('.agents/skills/doxloop-') ||
1757
+ path.startsWith('.claude/skills/doxloop-') ||
1758
+ EXCLUDED_PREFIXES.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)));
1759
+ }
1760
+ async function materializeChange(before, change, accepted, workspace) {
1761
+ if (change.binary) {
1762
+ if (!accepted.has(change.hunks[0].id))
1763
+ return before;
1764
+ if (change.kind === 'deleted')
1765
+ return undefined;
1766
+ return readFile(join(workspace, change.path));
1767
+ }
1768
+ if (accepted.size === 0)
1769
+ return before;
1770
+ const beforeText = before?.toString('utf8') ?? '';
1771
+ const lines = textLines(beforeText);
1772
+ const output = [];
1773
+ let cursor = 0;
1774
+ for (const hunk of change.hunks) {
1775
+ output.push(...lines.slice(cursor, hunk.oldStart));
1776
+ output.push(...(accepted.has(hunk.id) ? hunk.newLines : hunk.oldLines));
1777
+ cursor = hunk.oldStart + hunk.oldLines.length;
1778
+ }
1779
+ output.push(...lines.slice(cursor));
1780
+ if (change.kind === 'deleted' && change.hunks.every((hunk) => accepted.has(hunk.id))) {
1781
+ return undefined;
1782
+ }
1783
+ const allAccepted = change.hunks.every((hunk) => accepted.has(hunk.id));
1784
+ const trailing = allAccepted
1785
+ ? (change.afterEndsWithNewline ?? false)
1786
+ : (change.beforeEndsWithNewline ?? false);
1787
+ const text = `${output.join('\n')}${trailing && output.length > 0 ? '\n' : ''}`;
1788
+ return Buffer.from(text);
1789
+ }
1790
+ async function proposalBefore(root, id, path) {
1791
+ const before = join(runBeforeRoot(root, id), path);
1792
+ return (await pathExists(before)) ? readFile(before) : undefined;
1793
+ }
1794
+ async function proposalAcceptanceBefore(root, id, path) {
1795
+ const snapshot = await readChangeSnapshot(join(runDirectory(root, id), ACCEPTANCE_BEFORE), path);
1796
+ return snapshot.found ? snapshot.content : proposalBefore(root, id, path);
1797
+ }
1798
+ async function snapshotChangeSet(root, directory, changes) {
1799
+ await rm(directory, { recursive: true, force: true });
1800
+ const absent = [];
1801
+ for (const change of changes) {
1802
+ const actual = safeRunPath(root, change.path);
1803
+ if (!(await pathExists(actual))) {
1804
+ absent.push(change.path);
1805
+ continue;
1806
+ }
1807
+ const target = join(directory, 'files', change.path);
1808
+ await mkdir(dirname(target), { recursive: true });
1809
+ await writeFile(target, await readFile(actual));
1810
+ }
1811
+ await mkdir(directory, { recursive: true });
1812
+ await writeFile(join(directory, 'manifest.json'), `${JSON.stringify({ absent }, null, 2)}\n`, 'utf8');
1813
+ }
1814
+ async function readChangeSnapshot(directory, path) {
1815
+ const file = join(directory, 'files', path);
1816
+ if (await pathExists(file))
1817
+ return { found: true, content: await readFile(file) };
1818
+ const manifest = join(directory, 'manifest.json');
1819
+ if (!(await pathExists(manifest)))
1820
+ return { found: false, content: undefined };
1821
+ const value = await readJson(manifest);
1822
+ return Array.isArray(value.absent) && value.absent.includes(path)
1823
+ ? { found: true, content: undefined }
1824
+ : { found: false, content: undefined };
1825
+ }
1826
+ async function applyStagedSyncState(root, id) {
1827
+ const source = join(runWorkspace(root, id), '.doxloop', 'sync-state.json');
1828
+ if (!(await pathExists(source)))
1829
+ return;
1830
+ await writeAtomic(join(root, '.doxloop', 'sync-state.json'), await readFile(source));
1831
+ }
1832
+ async function snapshotOperationalBefore(root, directory, overwrite = false) {
1833
+ const targetRoot = join(directory, OPERATIONAL_BEFORE);
1834
+ const stateTarget = join(targetRoot, 'sync-state.json');
1835
+ const absentTarget = join(targetRoot, 'sync-state.absent');
1836
+ if (!overwrite && ((await pathExists(stateTarget)) || (await pathExists(absentTarget))))
1837
+ return;
1838
+ await mkdir(targetRoot, { recursive: true });
1839
+ await rm(stateTarget, { force: true });
1840
+ await rm(absentTarget, { force: true });
1841
+ const state = join(root, '.doxloop', 'sync-state.json');
1842
+ if (await pathExists(state))
1843
+ await writeFile(stateTarget, await readFile(state));
1844
+ else
1845
+ await writeFile(absentTarget, '', 'utf8');
1846
+ }
1847
+ async function restoreOperationalBefore(root, id) {
1848
+ const snapshotRoot = join(runDirectory(root, id), OPERATIONAL_BEFORE);
1849
+ const state = join(snapshotRoot, 'sync-state.json');
1850
+ const target = join(root, '.doxloop', 'sync-state.json');
1851
+ if (await pathExists(state)) {
1852
+ await writeAtomic(target, await readFile(state));
1853
+ return;
1854
+ }
1855
+ if (await pathExists(join(snapshotRoot, 'sync-state.absent')))
1856
+ await writeAtomic(target, undefined);
1857
+ }
1858
+ async function resolveRevisionScope(root, id, request) {
1859
+ if (!request || request.changeIds.length === 0) {
1860
+ throw new DoxloopError('A targeted proposal revision requires at least one selected change.');
1861
+ }
1862
+ const previous = await readSyncRun(root, id);
1863
+ const selectedPaths = new Set();
1864
+ const hunkRanges = new Map();
1865
+ for (const changeId of request.changeIds) {
1866
+ const change = previous.changes.find((item) => item.id === changeId);
1867
+ if (!change)
1868
+ throw new DoxloopError(`Unknown proposal change ${changeId}.`);
1869
+ selectedPaths.add(change.path);
1870
+ }
1871
+ for (const hunkId of request.hunkIds ?? []) {
1872
+ const change = previous.changes.find((item) => item.hunks.some((hunk) => hunk.id === hunkId));
1873
+ const hunk = change?.hunks.find((item) => item.id === hunkId);
1874
+ if (!change || !hunk)
1875
+ throw new DoxloopError(`Unknown selected change hunk ${hunkId}.`);
1876
+ const ranges = hunkRanges.get(change.path) ?? [];
1877
+ ranges.push({ start: hunk.newStart, end: hunk.newStart + Math.max(1, hunk.newLines.length) });
1878
+ hunkRanges.set(change.path, ranges);
1879
+ }
1880
+ const supportingPaths = new Set(previous.changes
1881
+ .filter((change) => ['navigation', 'configuration', 'evidence'].includes(change.category))
1882
+ .map((change) => change.path));
1883
+ return {
1884
+ selectedPaths,
1885
+ supportingPaths,
1886
+ wholeProposal: selectedPaths.size === previous.changes.length && hunkRanges.size === 0,
1887
+ hunkRanges,
1888
+ };
1889
+ }
1890
+ export async function assertRevisionStayedInScope(workspace, baseline, scope, edit = false) {
1891
+ const after = await collectFiles(workspace);
1892
+ const paths = new Set([...baseline.keys(), ...after.keys()]);
1893
+ const selectedChanged = scope.wholeProposal
1894
+ ? [...paths].some((path) => !buffersEqual(baseline.get(path), after.get(path)))
1895
+ : [...scope.selectedPaths].some((path) => !buffersEqual(baseline.get(path), after.get(path)));
1896
+ if (!selectedChanged) {
1897
+ throw new DoxloopError(edit
1898
+ ? `The agent did not change ${scope.selectedPaths.size === 1 ? 'this page' : 'these pages'}. Nothing was applied.`
1899
+ : 'The agent did not revise the selected proposal scope. The original proposal remains available.');
1900
+ }
1901
+ if (scope.wholeProposal)
1902
+ return;
1903
+ if (edit) {
1904
+ const deleted = [...scope.selectedPaths].filter((path) => baseline.has(path) && !after.has(path));
1905
+ if (deleted.length > 0) {
1906
+ throw new DoxloopError(`The agent deleted ${deleted.join(', ')}. Page creation, deletion, and rename are not available in a scoped edit. Nothing was applied.`);
1907
+ }
1908
+ }
1909
+ const outOfScope = [...paths].filter((path) => !buffersEqual(baseline.get(path), after.get(path))
1910
+ && !scope.selectedPaths.has(path)
1911
+ && !scope.supportingPaths.has(path)
1912
+ && ![...(scope.supportingPrefixes ?? [])].some((prefix) => path.startsWith(prefix) && !scope.pageExtensions?.has(extname(path).toLowerCase())));
1913
+ if (outOfScope.length > 0) {
1914
+ throw new DoxloopError(edit
1915
+ ? `The agent changed files outside ${scope.selectedPaths.size === 1 ? 'this page' : 'these pages'}: ${outOfScope.slice(0, 8).join(', ')}${outOfScope.length > 8 ? ', …' : ''}. Nothing was applied. Turn on "Also allow related changes" if those files should be part of the edit.`
1916
+ : `The revision changed files outside the selected scope: ${outOfScope.slice(0, 8).join(', ')}${outOfScope.length > 8 ? ', …' : ''}. No proposal was replaced.`);
1917
+ }
1918
+ for (const [path, ranges] of scope.hunkRanges) {
1919
+ const before = baseline.get(path);
1920
+ const current = after.get(path);
1921
+ if (!before || !current || isBinary(path, before, current)) {
1922
+ throw new DoxloopError(`The selected hunk in ${path} cannot be safely revised as text.`);
1923
+ }
1924
+ const revisionHunks = lineHunks(before.toString('utf8'), current.toString('utf8'), 'revision-scope');
1925
+ const outsideHunk = revisionHunks.some((hunk) => {
1926
+ const start = hunk.oldStart;
1927
+ const end = start + Math.max(1, hunk.oldLines.length);
1928
+ return !ranges.some((range) => start < range.end + 1 && end > range.start - 1);
1929
+ });
1930
+ if (outsideHunk) {
1931
+ throw new DoxloopError(`The revision changed text outside the selected hunk in ${path}. No proposal was replaced.`);
1932
+ }
1933
+ }
1934
+ }
1935
+ /**
1936
+ * A proposal is a reviewed change set, so source movement while it waits must
1937
+ * not discard or lock that work. Keep its snapshot current and surface the
1938
+ * difference as review context; file-level concurrency checks still protect
1939
+ * the real documentation from being overwritten during acceptance.
1940
+ */
1941
+ async function refreshProposalSourceSnapshot(root, run) {
1942
+ const project = await loadProject(root);
1943
+ const current = await proposalSourceSnapshot(root, project);
1944
+ if (current === run.sourceSnapshot)
1945
+ return run;
1946
+ const sourcesChanged = Boolean(run.sourceSnapshot);
1947
+ return {
1948
+ ...run,
1949
+ sourceSnapshot: current,
1950
+ ...withAdvisory(run, sourcesChanged ? SOURCES_CHANGED_DURING_REVIEW : undefined),
1951
+ };
1952
+ }
1953
+ async function optionalApprovedPlan(root) {
1954
+ const path = join(root, '.doxloop', 'documentation-plan.json');
1955
+ if (!(await pathExists(path)))
1956
+ return undefined;
1957
+ try {
1958
+ const plan = await readJson(path);
1959
+ return plan.schemaVersion === 2 && plan.status === 'approved' ? plan : undefined;
1960
+ }
1961
+ catch {
1962
+ return undefined;
1963
+ }
1964
+ }
1965
+ async function writeAtomic(path, content) {
1966
+ if (content === undefined) {
1967
+ await unlink(path).catch((error) => {
1968
+ if (error.code !== 'ENOENT')
1969
+ throw error;
1970
+ });
1971
+ return;
1972
+ }
1973
+ await mkdir(dirname(path), { recursive: true });
1974
+ const temporary = `${path}.doxloop-${process.pid}-${randomBytes(4).toString('hex')}`;
1975
+ await writeFile(temporary, content);
1976
+ await rename(temporary, path);
1977
+ }
1978
+ function normalizeSyncRun(raw, id) {
1979
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
1980
+ return { migrated: false };
1981
+ const value = raw;
1982
+ if ((value.schemaVersion !== 1 && value.schemaVersion !== 2) || value.id !== id || !Array.isArray(value.changes)) {
1983
+ return { migrated: false };
1984
+ }
1985
+ const changes = value.changes.flatMap((item) => {
1986
+ if (!item || typeof item !== 'object' || Array.isArray(item))
1987
+ return [];
1988
+ const change = item;
1989
+ if (typeof change.id !== 'string' || typeof change.path !== 'string' || !Array.isArray(change.hunks))
1990
+ return [];
1991
+ return [{
1992
+ ...change,
1993
+ rationale: validRationale(change.rationale) ? change.rationale : emptyRationale(change.kind),
1994
+ }];
1995
+ });
1996
+ const { authoringMode: _authoringMode, planId: _planId, ...stored } = value;
1997
+ const run = {
1998
+ ...stored,
1999
+ schemaVersion: 2,
2000
+ ...(value.authoringMode === 'create' || value.authoringMode === 'update' ? { authoringMode: value.authoringMode } : {}),
2001
+ ...(typeof value.planId === 'string' && /^plan-[a-z0-9-]+$/.test(value.planId) ? { planId: value.planId } : {}),
2002
+ changes,
2003
+ revisionRequests: Array.isArray(value.revisionRequests) ? value.revisionRequests : [],
2004
+ humanEdits: Array.isArray(value.humanEdits) ? value.humanEdits : [],
2005
+ };
2006
+ const legacySourceStale = run.status === 'stale' && Boolean(run.error?.includes(LEGACY_STALE_SOURCE_ERROR));
2007
+ if (legacySourceStale) {
2008
+ run.status = 'awaiting-review';
2009
+ delete run.error;
2010
+ Object.assign(run, withAdvisory(run, SOURCES_CHANGED_DURING_REVIEW));
2011
+ }
2012
+ return { run, migrated: value.schemaVersion === 1 || legacySourceStale };
2013
+ }
2014
+ function validRationale(value) {
2015
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value) && typeof value.reason === 'string');
2016
+ }
2017
+ /**
2018
+ * A run left at "generating" by a process that is gone (cancelled, killed, or
2019
+ * the UI server restarted) is marked failed so Review offers Resume instead
2020
+ * of showing it as still being written. Only runs created after `since`
2021
+ * are touched, so an older interrupted run keeps whatever it recorded.
2022
+ */
2023
+ export async function markInterruptedSyncRuns(root, since, message) {
2024
+ const marked = [];
2025
+ const floor = Date.parse(since) - 5_000;
2026
+ for (const run of await listSyncRuns(root)) {
2027
+ if (run.status !== 'generating' || Date.parse(run.createdAt) < floor)
2028
+ continue;
2029
+ const failed = { ...run, status: 'failed', error: message, completedAt: new Date().toISOString() };
2030
+ await writeRun(root, failed);
2031
+ await recordSyncRun(root, failed).catch(() => undefined);
2032
+ marked.push(run.id);
2033
+ }
2034
+ return marked;
2035
+ }
2036
+ async function writeRun(root, run) {
2037
+ const path = join(runDirectory(root, run.id), RUN_FILE);
2038
+ await mkdir(dirname(path), { recursive: true });
2039
+ await writeFile(path, `${JSON.stringify(run, null, 2)}\n`, 'utf8');
2040
+ }
2041
+ async function ensureRunsIgnored(root) {
2042
+ const path = join(root, '.gitignore');
2043
+ const line = '.doxloop/runs/';
2044
+ const existing = (await pathExists(path)) ? await readFile(path, 'utf8') : '';
2045
+ if (existing.split(/\r?\n/).includes(line))
2046
+ return;
2047
+ await writeFile(path, `${existing}${existing && !existing.endsWith('\n') ? '\n' : ''}${line}\n`, 'utf8');
2048
+ }
2049
+ function runDirectory(root, id) {
2050
+ assertRunId(id);
2051
+ return join(root, SYNC_RUNS_DIRECTORY, id);
2052
+ }
2053
+ function safeRunPath(root, path) {
2054
+ if (path === '' || isAbsolute(path) || path.split(/[\\/]/).includes('..')) {
2055
+ throw new DoxloopError(`Unsafe proposal path: ${path}`);
2056
+ }
2057
+ return assertInside(root, resolve(root, path));
2058
+ }
2059
+ function assertRunId(id) {
2060
+ if (!/^[a-z0-9-]+$/.test(id))
2061
+ throw new DoxloopError(`Invalid sync run id: ${id}`);
2062
+ }
2063
+ export function createRunId() {
2064
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
2065
+ return `run-${stamp.toLowerCase()}-${randomBytes(3).toString('hex')}`;
2066
+ }
2067
+ function hash(content) {
2068
+ return createHash('sha256').update(content).digest('hex');
2069
+ }
2070
+ function buffersEqual(left, right) {
2071
+ if (left === undefined || right === undefined)
2072
+ return left === right;
2073
+ return left.equals(right);
2074
+ }
2075
+ function isBinary(path, before, after) {
2076
+ if (BINARY_EXTENSIONS.has(extname(path).toLowerCase()))
2077
+ return true;
2078
+ return [before, after].some((content) => content?.subarray(0, 8_192).includes(0));
2079
+ }
2080
+ function acceptedIds(change) {
2081
+ return new Set(change.hunks.filter((hunk) => hunk.acceptedAt).map((hunk) => hunk.id));
2082
+ }
2083
+ const NAVIGATION_PATTERN = /nav|sidebar|menu|site\.json|docs\.json|mkdocs\.yml|docusaurus\.config/i;
2084
+ function changeCategory(path, project, pageExtensions) {
2085
+ if (path === '.doxloop/evidence-map.json')
2086
+ return 'evidence';
2087
+ const extension = extname(path).toLowerCase();
2088
+ const content = portable(project.contentDir).replace(/\/+$/, '');
2089
+ const insideContent = content === '' || path === content || path.startsWith(`${content}/`) || (project.generator === 'docusaurus' && /^(?:versioned_docs\/version-[\w.-]+\/|i18n\/[\w-]+\/docusaurus-plugin-content-docs\/(?:current|version-[\w.-]+)\/)/.test(path));
2090
+ if (insideContent) {
2091
+ if (pageExtensions.has(extension))
2092
+ return 'page';
2093
+ if (BINARY_EXTENSIONS.has(extension))
2094
+ return 'asset';
2095
+ // Data files inside the content directory — a Doxbrix `docs.json`, an
2096
+ // MkDocs navigation file — configure the site rather than being read as
2097
+ // pages, so they must not enter the page review flow.
2098
+ return NAVIGATION_PATTERN.test(path) ? 'navigation' : 'configuration';
2099
+ }
2100
+ if (NAVIGATION_PATTERN.test(path))
2101
+ return 'navigation';
2102
+ return BINARY_EXTENSIONS.has(extension) ? 'asset' : 'configuration';
2103
+ }
2104
+ async function changeTitle(path, sourcePath, project, pageExtensions) {
2105
+ if (changeCategory(path, project, pageExtensions) === 'page') {
2106
+ try {
2107
+ const page = await readPage(sourcePath);
2108
+ if (page.title)
2109
+ return page.title;
2110
+ }
2111
+ catch {
2112
+ // Deleted or non-Markdown generator pages use their file name.
2113
+ }
2114
+ }
2115
+ return path.split('/').at(-1) ?? path;
2116
+ }
2117
+ function sourceSummary(changes) {
2118
+ const files = changes.flatMap((change) => [
2119
+ ...('changedFiles' in change ? change.changedFiles : []),
2120
+ ...('uncommittedFiles' in change ? change.uncommittedFiles : []),
2121
+ ]);
2122
+ return files.length > 0
2123
+ ? `${files.length} changed source file${files.length === 1 ? '' : 's'}: ${files.slice(0, 12).join(', ')}${files.length > 12 ? ', …' : ''}`
2124
+ : `${changes.length} configured source${changes.length === 1 ? '' : 's'} inspected`;
2125
+ }
2126
+ function emptyRationale(kind) {
2127
+ return {
2128
+ reason: kind === 'added' ? 'This file was added by the documentation request.'
2129
+ : kind === 'deleted' ? 'This file was removed by the documentation request.'
2130
+ : 'This file was changed by the documentation request.',
2131
+ evidence: [],
2132
+ affectedInterfaces: [],
2133
+ claims: { added: [], changed: [], removed: [] },
2134
+ validation: { errors: 0, warnings: 0 },
2135
+ confidence: 'needs-human',
2136
+ assumptions: ['Detailed rationale was not recorded for this legacy proposal.'],
2137
+ authorship: 'agent',
2138
+ };
2139
+ }
2140
+ function changeReason(change) {
2141
+ if (change.category === 'evidence')
2142
+ return 'Update the evidence map for the reader-facing claims in this proposal.';
2143
+ if (change.category === 'navigation')
2144
+ return 'Keep navigation aligned with the proposed documentation structure.';
2145
+ if (change.category === 'configuration')
2146
+ return 'Keep generator configuration aligned with the proposed documentation.';
2147
+ if (change.category === 'asset')
2148
+ return 'Add or update a supporting reader-facing asset.';
2149
+ if (change.kind === 'added')
2150
+ return `Add “${change.title}” to cover an approved reader outcome.`;
2151
+ if (change.kind === 'deleted')
2152
+ return `Remove “${change.title}” because it is no longer part of the approved documentation.`;
2153
+ return `Update “${change.title}” to match the approved request and current evidence.`;
2154
+ }
2155
+ /** Whether the live copy of a page the agent removed was generated starter scaffolding. */
2156
+ async function isStarterFile(path) {
2157
+ try {
2158
+ return isStarterContent(await readFile(path, 'utf8'));
2159
+ }
2160
+ catch {
2161
+ return false;
2162
+ }
2163
+ }
2164
+ function matchPlanPage(plan, path) {
2165
+ if (!plan)
2166
+ return undefined;
2167
+ const portablePath = portable(path).replace(/^\/+/, '');
2168
+ return plan.pages.find((page) => {
2169
+ const planned = portable(page.path).replace(/^\/+|\/+$/g, '');
2170
+ const prefix = plan.target?.contentDir?.replace(/^\/+|\/+$/g, '');
2171
+ const candidates = new Set([planned, ...(prefix && !planned.startsWith(`${prefix}/`) ? [`${prefix}/${planned}`] : [])]);
2172
+ return [...candidates].some((candidate) => portablePath === candidate || portablePath.replace(/\.(mdx?|rst|html?)$/, '') === candidate);
2173
+ });
2174
+ }
2175
+ function sourceRevisionMap(changes) {
2176
+ return Object.fromEntries(changes.flatMap((change) => {
2177
+ if ('head' in change && change.head)
2178
+ return [[change.name, change.head]];
2179
+ if ('baseline' in change && change.baseline)
2180
+ return [[change.name, change.baseline]];
2181
+ return [];
2182
+ }));
2183
+ }
2184
+ function sourceChangePaths(change) {
2185
+ return changedSourcePaths(change);
2186
+ }
2187
+ function uniqueRationaleEvidence(items) {
2188
+ const seen = new Set();
2189
+ return items.flatMap((item) => {
2190
+ const normalized = {
2191
+ source: item.source,
2192
+ ...(item.path ? { path: item.path } : {}),
2193
+ ...(item.operation ? { operation: item.operation } : {}),
2194
+ ...(item.revision ? { revision: item.revision } : {}),
2195
+ available: item.available,
2196
+ };
2197
+ const key = JSON.stringify(normalized);
2198
+ if (seen.has(key))
2199
+ return [];
2200
+ seen.add(key);
2201
+ return [normalized];
2202
+ });
2203
+ }
2204
+ async function proposalSourceSnapshot(root, project) {
2205
+ const fingerprints = await sourceSnapshotFingerprints(root, project.sources);
2206
+ return hash(Buffer.from(JSON.stringify({ sources: project.sources, fingerprints })));
2207
+ }
2208
+ function retentionDate(createdAt) {
2209
+ const date = new Date(createdAt);
2210
+ date.setUTCDate(date.getUTCDate() + 30);
2211
+ return date.toISOString();
2212
+ }
2213
+ async function markSuperseded(root, id, replacementId) {
2214
+ const previous = await readSyncRun(root, id);
2215
+ if (!['awaiting-review', 'partially-applied', 'conflicted', 'stale'].includes(previous.status))
2216
+ return;
2217
+ const next = { ...previous, status: 'superseded', supersededBy: replacementId };
2218
+ await writeRun(root, next);
2219
+ await recordSyncRun(root, next);
2220
+ }
2221
+ function proposalSummary(changes) {
2222
+ const added = changes.filter((change) => change.kind === 'added').length;
2223
+ const modified = changes.filter((change) => change.kind === 'modified').length;
2224
+ const deleted = changes.filter((change) => change.kind === 'deleted').length;
2225
+ return `${changes.length} proposed file change${changes.length === 1 ? '' : 's'} · ${added} added · ${modified} modified · ${deleted} deleted`;
2226
+ }
2227
+ function changeCounts(changes) {
2228
+ const accepted = changes.reduce((count, change) => count + change.hunks.filter((hunk) => hunk.acceptedAt).length, 0);
2229
+ const total = changes.reduce((count, change) => count + change.hunks.length, 0);
2230
+ return `${changes.length} files · ${accepted}/${total} changes accepted`;
2231
+ }
2232
+ function statusMark(status) {
2233
+ if (status === 'applied')
2234
+ return '✓';
2235
+ if (status === 'failed' || status === 'conflicted')
2236
+ return '✗';
2237
+ if (status === 'rejected')
2238
+ return '–';
2239
+ return '●';
2240
+ }
2241
+ function portable(path) {
2242
+ return sep === '/' ? path : path.split(sep).join('/');
2243
+ }
2244
+ //# sourceMappingURL=sync-runs.js.map