@doxbrix/doxloop 0.1.5 → 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.
- package/CHANGELOG.md +1145 -0
- package/README.md +422 -227
- package/assets/doxbrix-preview.css +77 -15
- package/contracts/agent-events-v1.schema.json +1 -0
- package/contracts/coverage-v1.schema.json +59 -0
- package/contracts/drift-v1.schema.json +1 -0
- package/contracts/evaluation-v1.schema.json +62 -0
- package/contracts/quality-codes-v1.json +1 -0
- package/contracts/quality-report-v1.schema.json +1 -0
- package/contracts/validation-v1.schema.json +1 -0
- package/dist/agent-failure.d.ts +36 -0
- package/dist/agent-failure.js +98 -0
- package/dist/agent-isolation.d.ts +17 -0
- package/dist/agent-isolation.js +94 -0
- package/dist/agent-log.d.ts +197 -0
- package/dist/agent-log.js +1094 -0
- package/dist/agent-process.d.ts +63 -0
- package/dist/agent-process.js +128 -0
- package/dist/agent-reply.d.ts +47 -0
- package/dist/agent-reply.js +315 -0
- package/dist/agents.d.ts +24 -5
- package/dist/agents.js +60 -9
- package/dist/api-coverage.d.ts +11 -0
- package/dist/api-coverage.js +72 -0
- package/dist/application-probe.d.ts +28 -0
- package/dist/application-probe.js +93 -0
- package/dist/application-url.d.ts +14 -0
- package/dist/application-url.js +26 -0
- package/dist/args.js +15 -0
- package/dist/artifact-deploy.d.ts +9 -0
- package/dist/artifact-deploy.js +28 -8
- package/dist/assets.d.ts +72 -0
- package/dist/assets.js +382 -0
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +38 -9
- package/dist/author.d.ts +206 -4
- package/dist/author.js +1650 -52
- package/dist/authoring-batches.d.ts +190 -0
- package/dist/authoring-batches.js +489 -0
- package/dist/authoring-postpass.d.ts +79 -0
- package/dist/authoring-postpass.js +1289 -0
- package/dist/authoring-progress.d.ts +95 -0
- package/dist/authoring-progress.js +269 -0
- package/dist/autosync.d.ts +56 -0
- package/dist/autosync.js +442 -0
- package/dist/batch-artifacts.d.ts +81 -0
- package/dist/batch-artifacts.js +412 -0
- package/dist/batch-limits.d.ts +37 -0
- package/dist/batch-limits.js +80 -0
- package/dist/branding.d.ts +41 -0
- package/dist/branding.js +229 -0
- package/dist/bundle-upload.d.ts +26 -0
- package/dist/bundle-upload.js +99 -0
- package/dist/capture-auth.d.ts +95 -0
- package/dist/capture-auth.js +204 -0
- package/dist/capture.d.ts +48 -0
- package/dist/capture.js +20 -1
- package/dist/cli.js +839 -32
- package/dist/content-links.d.ts +10 -0
- package/dist/content-links.js +49 -0
- package/dist/contract-validation.d.ts +4 -0
- package/dist/contract-validation.js +25 -0
- package/dist/coverage-actions.d.ts +8 -0
- package/dist/coverage-actions.js +71 -0
- package/dist/coverage-resolutions.d.ts +16 -0
- package/dist/coverage-resolutions.js +58 -0
- package/dist/db.d.ts +37 -0
- package/dist/db.js +288 -0
- package/dist/demo.d.ts +12 -0
- package/dist/demo.js +122 -0
- package/dist/deploy-credentials.d.ts +7 -0
- package/dist/deploy-credentials.js +76 -0
- package/dist/deploy-targets/github-pages.d.ts +3 -0
- package/dist/deploy-targets/github-pages.js +100 -0
- package/dist/deploy-targets/index.d.ts +8 -0
- package/dist/deploy-targets/index.js +78 -0
- package/dist/deploy-targets/netlify.d.ts +3 -0
- package/dist/deploy-targets/netlify.js +49 -0
- package/dist/deploy-targets/types.d.ts +32 -0
- package/dist/deploy-targets/types.js +2 -0
- package/dist/deploy-targets/vercel.d.ts +3 -0
- package/dist/deploy-targets/vercel.js +95 -0
- package/dist/deploy.d.ts +9 -0
- package/dist/deploy.js +87 -13
- package/dist/deterministic-capture.d.ts +90 -0
- package/dist/deterministic-capture.js +435 -0
- package/dist/direct-edit.d.ts +40 -0
- package/dist/direct-edit.js +159 -0
- package/dist/docs-crawl.d.ts +86 -0
- package/dist/docs-crawl.js +536 -0
- package/dist/docs-site.d.ts +49 -0
- package/dist/docs-site.js +233 -0
- package/dist/doctor.js +8 -0
- package/dist/documentation-collections.d.ts +23 -0
- package/dist/documentation-collections.js +191 -0
- package/dist/documentation-plan.d.ts +253 -0
- package/dist/documentation-plan.js +2563 -0
- package/dist/doxbrix-build.d.ts +19 -0
- package/dist/doxbrix-build.js +167 -0
- package/dist/doxbrix-markdown.d.ts +9 -0
- package/dist/doxbrix-markdown.js +115 -14
- package/dist/drift.d.ts +10 -0
- package/dist/drift.js +164 -0
- package/dist/evaluation.d.ts +46 -0
- package/dist/evaluation.js +113 -0
- package/dist/evidence-pack.d.ts +47 -0
- package/dist/evidence-pack.js +358 -0
- package/dist/evidence.d.ts +25 -0
- package/dist/evidence.js +175 -0
- package/dist/fs.d.ts +8 -2
- package/dist/fs.js +41 -11
- package/dist/generator-api.d.ts +89 -0
- package/dist/generator-preflight.d.ts +27 -0
- package/dist/generator-preflight.js +105 -0
- package/dist/generator-runtime.d.ts +7 -0
- package/dist/generator-runtime.js +17 -1
- package/dist/generators.d.ts +24 -2
- package/dist/generators.js +54 -1
- package/dist/git-delivery.d.ts +17 -0
- package/dist/git-delivery.js +123 -0
- package/dist/globs.d.ts +16 -0
- package/dist/globs.js +65 -0
- package/dist/glossary.d.ts +26 -0
- package/dist/glossary.js +179 -0
- package/dist/history.d.ts +106 -0
- package/dist/history.js +600 -0
- package/dist/html-markdown.d.ts +46 -0
- package/dist/html-markdown.js +423 -0
- package/dist/interactive.js +3 -0
- package/dist/job-events.d.ts +74 -0
- package/dist/job-events.js +377 -0
- package/dist/keep-awake.d.ts +50 -0
- package/dist/keep-awake.js +123 -0
- package/dist/local-source-snapshot.d.ts +20 -0
- package/dist/local-source-snapshot.js +61 -0
- package/dist/mintlify-detect.d.ts +3 -0
- package/dist/mintlify-detect.js +18 -0
- package/dist/mintlify-import.d.ts +75 -0
- package/dist/mintlify-import.js +190 -0
- package/dist/navigation.d.ts +98 -0
- package/dist/navigation.js +310 -0
- package/dist/openapi.d.ts +60 -0
- package/dist/openapi.js +439 -0
- package/dist/page-editor-bridge.d.ts +3 -0
- package/dist/page-editor-bridge.js +109 -0
- package/dist/page-editor-preview.d.ts +10 -0
- package/dist/page-editor-preview.js +55 -0
- package/dist/page-extension.d.ts +9 -0
- package/dist/page-extension.js +15 -0
- package/dist/page-metadata.d.ts +28 -0
- package/dist/page-metadata.js +166 -0
- package/dist/page-operations.d.ts +34 -0
- package/dist/page-operations.js +215 -0
- package/dist/page-routes.d.ts +4 -0
- package/dist/page-routes.js +61 -0
- package/dist/pages.d.ts +20 -0
- package/dist/pages.js +184 -0
- package/dist/plan-generator.d.ts +3 -0
- package/dist/plan-generator.js +21 -0
- package/dist/plan-navigation.d.ts +11 -0
- package/dist/plan-navigation.js +30 -0
- package/dist/planning-captures.d.ts +20 -0
- package/dist/planning-captures.js +143 -0
- package/dist/planning-research.d.ts +135 -0
- package/dist/planning-research.js +472 -0
- package/dist/planning-triage.d.ts +23 -0
- package/dist/planning-triage.js +131 -0
- package/dist/preview.d.ts +24 -0
- package/dist/preview.js +280 -29
- package/dist/project-detect.d.ts +36 -0
- package/dist/project-detect.js +251 -0
- package/dist/project-import.d.ts +54 -0
- package/dist/project-import.js +157 -0
- package/dist/project-lock.d.ts +6 -0
- package/dist/project-lock.js +96 -0
- package/dist/project-registry.d.ts +25 -0
- package/dist/project-registry.js +79 -0
- package/dist/project.d.ts +35 -3
- package/dist/project.js +388 -38
- package/dist/prompts.d.ts +9 -0
- package/dist/prompts.js +25 -0
- package/dist/proposal-replay.d.ts +32 -0
- package/dist/proposal-replay.js +99 -0
- package/dist/quality-claims.d.ts +8 -0
- package/dist/quality-claims.js +168 -0
- package/dist/quality-config.d.ts +5 -0
- package/dist/quality-config.js +84 -0
- package/dist/quality-contract.d.ts +37 -0
- package/dist/quality-contract.js +46 -0
- package/dist/quality-examples.d.ts +4 -0
- package/dist/quality-examples.js +233 -0
- package/dist/quality-gates.d.ts +16 -0
- package/dist/quality-gates.js +192 -0
- package/dist/quality-links.d.ts +7 -0
- package/dist/quality-links.js +149 -0
- package/dist/quality-lint.d.ts +6 -0
- package/dist/quality-lint.js +124 -0
- package/dist/quality-rendered.d.ts +11 -0
- package/dist/quality-rendered.js +222 -0
- package/dist/quality-schema.d.ts +3 -0
- package/dist/quality-schema.js +71 -0
- package/dist/release-notes.d.ts +44 -0
- package/dist/release-notes.js +183 -0
- package/dist/remote-monitor.d.ts +16 -0
- package/dist/remote-monitor.js +74 -0
- package/dist/remote-source.d.ts +34 -0
- package/dist/remote-source.js +426 -0
- package/dist/review-diff.d.ts +82 -0
- package/dist/review-diff.js +400 -0
- package/dist/review-learning.d.ts +11 -0
- package/dist/review-learning.js +60 -0
- package/dist/review-render.d.ts +38 -0
- package/dist/review-render.js +224 -0
- package/dist/review-report.d.ts +9 -0
- package/dist/review-report.js +89 -0
- package/dist/review-ui.d.ts +14 -0
- package/dist/review-ui.js +1248 -0
- package/dist/schedule.d.ts +78 -0
- package/dist/schedule.js +480 -0
- package/dist/screen-capture-provider.d.ts +52 -0
- package/dist/screen-capture-provider.js +218 -0
- package/dist/screenshot-workflow.d.ts +167 -0
- package/dist/screenshot-workflow.js +1237 -0
- package/dist/settings.d.ts +1 -1
- package/dist/settings.js +95 -7
- package/dist/site-export.d.ts +18 -0
- package/dist/site-export.js +87 -0
- package/dist/source-connectors.d.ts +33 -0
- package/dist/source-connectors.js +268 -0
- package/dist/source-discovery.d.ts +132 -0
- package/dist/source-discovery.js +823 -0
- package/dist/source-intelligence.d.ts +9 -0
- package/dist/source-intelligence.js +306 -0
- package/dist/sync-review.d.ts +28 -0
- package/dist/sync-review.js +264 -0
- package/dist/sync-runs.d.ts +192 -0
- package/dist/sync-runs.js +2244 -0
- package/dist/sync.d.ts +35 -0
- package/dist/sync.js +298 -32
- package/dist/text-diff.d.ts +9 -0
- package/dist/text-diff.js +59 -0
- package/dist/types.d.ts +946 -1
- package/dist/ui/assets/doxloop-logo-light-De7Nx7j7.png +0 -0
- package/dist/ui/assets/index-BHBYU2aG.css +1 -0
- package/dist/ui/assets/index-Cq3RPQiC.js +33 -0
- package/dist/ui/index.html +18 -0
- package/dist/ui-server.d.ts +83 -0
- package/dist/ui-server.js +3532 -0
- package/dist/usage-budget.d.ts +28 -0
- package/dist/usage-budget.js +90 -0
- package/dist/validation.d.ts +25 -1
- package/dist/validation.js +312 -26
- package/dist/workspace-tools.d.ts +54 -0
- package/dist/workspace-tools.js +123 -0
- package/docs/agent-compatibility.md +49 -32
- package/docs/ci-and-automation.md +105 -66
- package/docs/doxbrix-http-api.md +8 -1
- package/docs/existing-documentation.md +80 -0
- package/docs/generation-performance.md +108 -0
- package/docs/generator-authoring.md +68 -5
- package/docs/generator-selection.md +50 -13
- package/docs/mintlify-import.md +71 -0
- package/docs/openapi-security.md +25 -0
- package/docs/project-format.md +331 -36
- package/docs/release-quality.md +158 -0
- package/docs/releasing.md +72 -0
- package/docs/review-workflows.md +51 -0
- package/docs/security-model.md +102 -39
- package/docs/troubleshooting.md +226 -93
- package/package.json +46 -15
- package/scripts/test-auto-screenshot.mjs +172 -0
- package/skills/doxloop-authoring/SKILL.md +242 -361
- package/skills/doxloop-authoring/references/existing-documentation.md +94 -0
- package/skills/doxloop-authoring/references/navigation-architecture.md +18 -7
- package/skills/doxloop-authoring/references/page-depth.md +169 -0
- package/skills/doxloop-authoring/references/project-format.md +106 -7
- package/skills/doxloop-authoring/references/quality.md +10 -0
- package/skills/doxloop-authoring/references/screenshot-manifest.md +113 -0
- package/skills/doxloop-authoring/references/screenshots.md +149 -235
- package/skills/doxloop-authoring/references/workflows.md +76 -0
- package/skills/doxloop-doxbrix/SKILL.md +38 -22
- package/skills/doxloop-doxbrix/references/api-endpoints.md +15 -14
- package/skills/doxloop-doxbrix/references/components.md +30 -3
- package/skills/doxloop-doxbrix/references/manifest.md +3 -2
- package/vendor/doxbrix-import/LICENSE +202 -0
- package/vendor/doxbrix-import/README.md +17 -0
- package/vendor/doxbrix-import/UPSTREAM.json +47 -0
- package/vendor/doxbrix-import/dist/docs/frontmatter.d.ts +13 -0
- package/vendor/doxbrix-import/dist/docs/frontmatter.js +83 -0
- package/vendor/doxbrix-import/dist/docs/import.d.ts +21 -0
- package/vendor/doxbrix-import/dist/docs/import.js +147 -0
- package/vendor/doxbrix-import/dist/docs/manifest.d.ts +163 -0
- package/vendor/doxbrix-import/dist/docs/manifest.js +64 -0
- package/vendor/doxbrix-import/dist/docs/project.d.ts +25 -0
- package/vendor/doxbrix-import/dist/docs/project.js +77 -0
- package/vendor/doxbrix-import/dist/docs/starter.d.ts +4 -0
- package/vendor/doxbrix-import/dist/docs/starter.js +11 -0
- package/vendor/doxbrix-import/dist/importer.d.ts +227 -0
- package/vendor/doxbrix-import/dist/importer.js +1567 -0
- package/vendor/doxbrix-import/dist/mintlify-openapi.d.ts +37 -0
- package/vendor/doxbrix-import/dist/mintlify-openapi.js +305 -0
- package/vendor/doxbrix-import/dist/safe-path.d.ts +9 -0
- package/vendor/doxbrix-import/dist/safe-path.js +47 -0
- package/dist/agents.d.ts.map +0 -1
- package/dist/agents.js.map +0 -1
- package/dist/args.d.ts.map +0 -1
- package/dist/args.js.map +0 -1
- package/dist/artifact-deploy.d.ts.map +0 -1
- package/dist/artifact-deploy.js.map +0 -1
- package/dist/auth.d.ts.map +0 -1
- package/dist/auth.js.map +0 -1
- package/dist/author.d.ts.map +0 -1
- package/dist/author.js.map +0 -1
- package/dist/capture.d.ts.map +0 -1
- package/dist/capture.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/deploy.d.ts.map +0 -1
- package/dist/deploy.js.map +0 -1
- package/dist/deployment-visibility.d.ts.map +0 -1
- package/dist/deployment-visibility.js.map +0 -1
- package/dist/doctor.d.ts.map +0 -1
- package/dist/doctor.js.map +0 -1
- package/dist/doxbrix-markdown.d.ts.map +0 -1
- package/dist/doxbrix-markdown.js.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/fs.d.ts.map +0 -1
- package/dist/fs.js.map +0 -1
- package/dist/generator-api.d.ts.map +0 -1
- package/dist/generator-api.js.map +0 -1
- package/dist/generator-manager.d.ts.map +0 -1
- package/dist/generator-manager.js.map +0 -1
- package/dist/generator-runtime.d.ts.map +0 -1
- package/dist/generator-runtime.js.map +0 -1
- package/dist/generators.d.ts.map +0 -1
- package/dist/generators.js.map +0 -1
- package/dist/interactive.d.ts.map +0 -1
- package/dist/interactive.js.map +0 -1
- package/dist/preview.d.ts.map +0 -1
- package/dist/preview.js.map +0 -1
- package/dist/progress.d.ts.map +0 -1
- package/dist/progress.js.map +0 -1
- package/dist/project.d.ts.map +0 -1
- package/dist/project.js.map +0 -1
- package/dist/prompts.d.ts.map +0 -1
- package/dist/prompts.js.map +0 -1
- package/dist/settings.d.ts.map +0 -1
- package/dist/settings.js.map +0 -1
- package/dist/sync.d.ts.map +0 -1
- package/dist/sync.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/validation.d.ts.map +0 -1
- package/dist/validation.js.map +0 -1
- package/dist/version.d.ts.map +0 -1
- 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
|