@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,2563 @@
|
|
|
1
|
+
import { UsageBudget, budgetContext, isAccountLimit } from './usage-budget.js';
|
|
2
|
+
import { preparePlanningCaptures } from './planning-captures.js';
|
|
3
|
+
import { assertBatchFits, batchLimits, defaultBatchLimits, hasPageLimit } from './batch-limits.js';
|
|
4
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
5
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { AgentSessionError, agentFailureDetail } from './agent-failure.js';
|
|
8
|
+
import { codexUserMcpServers } from './agent-isolation.js';
|
|
9
|
+
import { AGENT_LOG_HEARTBEAT_MS, createAgentLogFormatter } from './agent-log.js';
|
|
10
|
+
import { readPlanOutput, readPlanPatchOutput } from './agent-reply.js';
|
|
11
|
+
import { formatResearchBriefs, planningParallelism, researchCheckpointKey, researchCheckpointKeys, researchTasks, runResearch, savedResearchBriefs, stagedPlanningEnabled } from './planning-research.js';
|
|
12
|
+
import { describeResearchScope, fullResearch, readTriageOutput, triageByRules, triagePrompt } from './planning-triage.js';
|
|
13
|
+
import { readNavigation } from './navigation.js';
|
|
14
|
+
import { readDocsSiteManifest } from './docs-site.js';
|
|
15
|
+
export { agentReplyFromStream, extractPlanOutput, readPlanOutput } from './agent-reply.js';
|
|
16
|
+
import { forwardTerminationSignals, spawnAgentProcess } from './agent-process.js';
|
|
17
|
+
import { agentArguments, agentEnvironment, captureAuthPrompt, prepareAgentPrompt, sourceAccessDirectories } from './author.js';
|
|
18
|
+
import { captureAuthContext, describeCaptureAuth, prepareCaptureAuth } from './capture-auth.js';
|
|
19
|
+
import { chooseAgent } from './agents.js';
|
|
20
|
+
import { emitWorkflowStage } from './job-events.js';
|
|
21
|
+
import { writeExistingDocumentationRedirects } from './docs-site.js';
|
|
22
|
+
import { computeDrift } from './drift.js';
|
|
23
|
+
import { DoxloopError } from './errors.js';
|
|
24
|
+
import { pathExists } from './fs.js';
|
|
25
|
+
import { documentationPlanTarget } from './plan-generator.js';
|
|
26
|
+
import { loadProject } from './project.js';
|
|
27
|
+
import { reviewPreferenceGuidance } from './review-learning.js';
|
|
28
|
+
import { collectReleaseInventory, formatReleaseInventory, releaseNotesPagePath } from './release-notes.js';
|
|
29
|
+
import { assertScreenshotPlanningReadiness, checkApplicationReadiness, normalizeScreenshotIntent, screenshotPlanSummary } from './screenshot-workflow.js';
|
|
30
|
+
import { checkScreenCaptureBrowser, screenCaptureProvider, writeGeminiCaptureSettings } from './screen-capture-provider.js';
|
|
31
|
+
import { discoverDocumentationSources, discoveryGuidance, formatDiscoveryInventory } from './source-discovery.js';
|
|
32
|
+
export { discoveryGuidance } from './source-discovery.js';
|
|
33
|
+
import { collectSourceChanges, formatSourceChanges, sourceSnapshotFingerprints } from './sync.js';
|
|
34
|
+
import { createSyncRun, listSyncRuns, readSyncRun, recoverSyncRun, resumeSyncRun, runWorkspace } from './sync-runs.js';
|
|
35
|
+
import { assignSectionSpaces } from './plan-navigation.js';
|
|
36
|
+
export { assignSectionSpaces };
|
|
37
|
+
const PLANS_DIRECTORY = join('.doxloop', 'plans');
|
|
38
|
+
const CURRENT_PLAN_FILE = join('.doxloop', 'documentation-plan.json');
|
|
39
|
+
const CAPTURE_WARNING_PREFIX = 'Screenshot warning:';
|
|
40
|
+
const SOURCES_CHANGED_ADVISORY = 'Configured sources changed after this plan was proposed. The plan was approved as proposed; generation reads the current sources when it writes each page.';
|
|
41
|
+
const PLAN_FILE = 'plan.json';
|
|
42
|
+
const VERSIONS_DIRECTORY = 'versions';
|
|
43
|
+
export async function createDocumentationPlan(root, input) {
|
|
44
|
+
const project = await loadProject(root);
|
|
45
|
+
const discovery = await discoverDocumentationSources(root);
|
|
46
|
+
const target = await documentationPlanTarget(root, project);
|
|
47
|
+
const now = new Date().toISOString();
|
|
48
|
+
const template = input.template ? await releaseNotesTemplate(root, project, input.template) : undefined;
|
|
49
|
+
const plan = {
|
|
50
|
+
schemaVersion: 2,
|
|
51
|
+
id: planId(),
|
|
52
|
+
version: 1,
|
|
53
|
+
mode: input.mode,
|
|
54
|
+
status: 'planning',
|
|
55
|
+
scope: input.scope,
|
|
56
|
+
createdAt: now,
|
|
57
|
+
updatedAt: now,
|
|
58
|
+
request: input.request?.trim() || (template ? `Release notes for ${template.version}` : ''),
|
|
59
|
+
sourceSnapshot: await documentationSourceSnapshot(root),
|
|
60
|
+
productProfile: '',
|
|
61
|
+
summary: '',
|
|
62
|
+
audiences: project.documentation.audiences?.length
|
|
63
|
+
? project.documentation.audiences
|
|
64
|
+
: project.documentation.primaryAudience
|
|
65
|
+
? [project.documentation.primaryAudience]
|
|
66
|
+
: [],
|
|
67
|
+
outcomes: project.documentation.priorityOutcomes ?? [],
|
|
68
|
+
terminology: project.documentation.terminology,
|
|
69
|
+
exclusions: project.documentation.exclusions,
|
|
70
|
+
instructions: project.documentation.customInstructions ?? '',
|
|
71
|
+
experienceLevel: project.documentation.experienceLevel ?? 'mixed',
|
|
72
|
+
preferredExamples: project.documentation.preferredExamples ?? [],
|
|
73
|
+
locale: project.documentation.locale,
|
|
74
|
+
accessibilityTarget: project.documentation.accessibilityTarget,
|
|
75
|
+
styleGuide: project.documentation.styleGuide,
|
|
76
|
+
capabilities: [],
|
|
77
|
+
navigation: { top: ['Documentation'], sections: [] },
|
|
78
|
+
pages: [],
|
|
79
|
+
questions: [],
|
|
80
|
+
estimatedPages: Math.max(estimatedPagesForScope(input.scope, discovery.inventory), input.targetPages ?? 0),
|
|
81
|
+
...(input.targetPages ? { targetPages: input.targetPages } : {}),
|
|
82
|
+
estimatedEffort: 'medium',
|
|
83
|
+
discovery: {
|
|
84
|
+
cacheKey: discovery.inventory.cacheKey,
|
|
85
|
+
generatedAt: discovery.inventory.generatedAt,
|
|
86
|
+
deterministic: true,
|
|
87
|
+
publicSignals: discovery.inventory.totals.publicSignals,
|
|
88
|
+
suggestedPages: discovery.inventory.suggestedPages,
|
|
89
|
+
},
|
|
90
|
+
target,
|
|
91
|
+
clarification: { mode: input.clarificationMode ?? 'review', answers: {} },
|
|
92
|
+
execution: { ...input.execution, limits: batchLimits(input.execution.limits, defaultBatchLimits(input.scope, input.execution.screenshots !== 'disabled' && input.execution.screenshots !== false, input.targetPages)) },
|
|
93
|
+
...(template ? { template } : {}),
|
|
94
|
+
};
|
|
95
|
+
if (input.execution.limits && input.targetPages && input.targetPages > plan.execution.limits.maxPages)
|
|
96
|
+
throw new DoxloopError('The minimum page count exceeds the batch maximum. Raise maxPages or lower targetPages.');
|
|
97
|
+
await persistPlan(root, plan, false);
|
|
98
|
+
return plan;
|
|
99
|
+
}
|
|
100
|
+
async function releaseNotesTemplate(root, project, input) {
|
|
101
|
+
const inventory = await collectReleaseInventory(root, project, input);
|
|
102
|
+
return {
|
|
103
|
+
kind: 'release-notes',
|
|
104
|
+
version: inventory.version,
|
|
105
|
+
from: inventory.from,
|
|
106
|
+
to: inventory.to,
|
|
107
|
+
sources: input.sources ?? [],
|
|
108
|
+
inventory,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** Planner instructions for a content-type template, or nothing for a plain request. */
|
|
112
|
+
export function templateInstructions(plan) {
|
|
113
|
+
const template = plan.template;
|
|
114
|
+
if (!template)
|
|
115
|
+
return '';
|
|
116
|
+
return `
|
|
117
|
+
Content-type template: release notes for ${template.version}.
|
|
118
|
+
- Plan exactly one page of type "release" at path "${releaseNotesPagePath(template.version)}" titled "Release notes: ${template.version}" (follow the product's existing naming when the documentation already has a release-notes convention), with action "create", or "update" when that path already exists.
|
|
119
|
+
- Ground every entry in the release inventory below. Classify reader-visible changes as added, changed, fixed, deprecated, removed, or security; lead with reader impact and the action a reader must take; and never turn a commit message into a product claim the diff does not evidence. Mark anything the inventory cannot support as needing verification instead of asserting it.
|
|
120
|
+
- When the documentation has a release index or "What's new" page, plan an "update" for it that links the new page. Do not add unrelated pages.
|
|
121
|
+
- Keep the plan to the release page plus the pages this release genuinely changes.
|
|
122
|
+
|
|
123
|
+
Release inventory (deterministic, collected by Doxloop from Git):
|
|
124
|
+
${formatReleaseInventory(template.inventory)}
|
|
125
|
+
`;
|
|
126
|
+
}
|
|
127
|
+
/** Writer instructions derived from the plan: required diagrams and the release inventory. */
|
|
128
|
+
export function planWritingRequirements(plan) {
|
|
129
|
+
const parts = [];
|
|
130
|
+
const diagrams = plan.pages.filter((page) => page.diagram === 'required' && (page.action === 'create' || page.action === 'update'));
|
|
131
|
+
if (diagrams.length > 0) {
|
|
132
|
+
const syntax = plan.target.generator === 'doxbrix'
|
|
133
|
+
? 'a <Mermaid> component block'
|
|
134
|
+
: plan.target.contentFormat === 'rst'
|
|
135
|
+
? 'a `.. mermaid::` directive'
|
|
136
|
+
: 'a fenced ```mermaid block or the generator\'s documented Mermaid syntax';
|
|
137
|
+
parts.push(`Pages that must contain a Mermaid diagram of the model or lifecycle they explain, using ${syntax}:\n${diagrams.map((page) => `- ${page.title} (${page.path})`).join('\n')}\nDoxloop reports a \`missing-diagram\` warning for any of these pages written without one; resolve it before finishing.`);
|
|
138
|
+
}
|
|
139
|
+
if (plan.template) {
|
|
140
|
+
parts.push(`Release inventory the release-notes page must be grounded in. Do not restate commit messages; describe reader-visible behaviour, group changes consistently, and separate breaking changes from optional capability:\n${formatReleaseInventory(plan.template.inventory)}`);
|
|
141
|
+
}
|
|
142
|
+
return parts.length > 0 ? `${parts.join('\n\n')}\n\n` : '';
|
|
143
|
+
}
|
|
144
|
+
export async function listDocumentationPlans(root) {
|
|
145
|
+
const directory = join(root, PLANS_DIRECTORY);
|
|
146
|
+
if (!(await pathExists(directory)))
|
|
147
|
+
return [];
|
|
148
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
149
|
+
const plans = [];
|
|
150
|
+
for (const entry of entries) {
|
|
151
|
+
if (!entry.isDirectory() || !/^[a-z0-9-]+$/.test(entry.name))
|
|
152
|
+
continue;
|
|
153
|
+
try {
|
|
154
|
+
plans.push(await readDocumentationPlan(root, entry.name));
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// Interrupted or manually damaged plan directories are ignored here and
|
|
158
|
+
// remain available on disk for diagnosis.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return plans.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
162
|
+
}
|
|
163
|
+
export async function latestDocumentationPlan(root) {
|
|
164
|
+
return (await listDocumentationPlans(root))[0];
|
|
165
|
+
}
|
|
166
|
+
export async function listDocumentationPlanVersions(root, id) {
|
|
167
|
+
assertPlanId(id);
|
|
168
|
+
const current = await readDocumentationPlan(root, id);
|
|
169
|
+
const directory = join(root, PLANS_DIRECTORY, id, VERSIONS_DIRECTORY);
|
|
170
|
+
const versions = new Map([[current.version, current]]);
|
|
171
|
+
if (await pathExists(directory)) {
|
|
172
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
173
|
+
if (!entry.isFile() || !/^v\d+\.json$/.test(entry.name))
|
|
174
|
+
continue;
|
|
175
|
+
try {
|
|
176
|
+
const raw = JSON.parse(await readFile(join(directory, entry.name), 'utf8'));
|
|
177
|
+
const normalized = await normalizePersistedPlan(root, raw, id);
|
|
178
|
+
versions.set(normalized.plan.version, normalized.plan);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// A damaged archive must not prevent review of the current plan.
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return [...versions.values()].sort((left, right) => right.version - left.version);
|
|
186
|
+
}
|
|
187
|
+
export async function readDocumentationPlan(root, id) {
|
|
188
|
+
assertPlanId(id);
|
|
189
|
+
const raw = JSON.parse(await readFile(planPath(root, id), 'utf8'));
|
|
190
|
+
const { plan, migrated } = await normalizePersistedPlan(root, raw, id);
|
|
191
|
+
if (migrated)
|
|
192
|
+
await persistPlan(root, plan, false);
|
|
193
|
+
if (plan.status === 'failed' && !plan.failure) {
|
|
194
|
+
// A plan that failed before failures were recorded still has the workspace
|
|
195
|
+
// its run left behind; find it so the reviewer can continue from it.
|
|
196
|
+
const failure = await inferPlanFailure(root, plan);
|
|
197
|
+
const inferred = { ...plan, failure };
|
|
198
|
+
await persistPlan(root, inferred, false);
|
|
199
|
+
return inferred;
|
|
200
|
+
}
|
|
201
|
+
if (plan.status === 'failed' && plan.failure?.stage === 'generate' && plan.failure.proposalId) {
|
|
202
|
+
const reconciled = await reconcileContinuedGeneration(root, plan, plan.failure.proposalId);
|
|
203
|
+
if (reconciled)
|
|
204
|
+
return reconciled;
|
|
205
|
+
}
|
|
206
|
+
return plan;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* A failed generation can be continued from the proposal itself: the
|
|
210
|
+
* Proposals panel and `doxloop proposal resume` work on the run, not on the
|
|
211
|
+
* plan that started it. When that continuation finished, the plan still said
|
|
212
|
+
* failed and offered recovery for a proposal that was already awaiting review
|
|
213
|
+
* or applied, and taking that offer failed with "Only a failed or interrupted
|
|
214
|
+
* proposal can be recovered". Read the proposal's real state back into the
|
|
215
|
+
* plan instead.
|
|
216
|
+
*/
|
|
217
|
+
async function reconcileContinuedGeneration(root, plan, proposalId) {
|
|
218
|
+
let run;
|
|
219
|
+
try {
|
|
220
|
+
run = await readSyncRun(root, proposalId);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
if (run.status !== 'awaiting-review' && run.status !== 'applied')
|
|
226
|
+
return undefined;
|
|
227
|
+
const { error: _error, failure: _failure, ...retained } = plan;
|
|
228
|
+
const generated = { ...retained, status: 'generated', proposalId, updatedAt: new Date().toISOString() };
|
|
229
|
+
await persistPlan(root, generated, false);
|
|
230
|
+
return generated;
|
|
231
|
+
}
|
|
232
|
+
async function inferPlanFailure(root, plan) {
|
|
233
|
+
if (!plan.approvedHash) {
|
|
234
|
+
return { stage: 'propose', resumable: false, ignorable: plan.pages.length > 0 };
|
|
235
|
+
}
|
|
236
|
+
let runs;
|
|
237
|
+
try {
|
|
238
|
+
runs = await listSyncRuns(root);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return { stage: 'generate', resumable: false, ignorable: false };
|
|
242
|
+
}
|
|
243
|
+
const candidates = runs.filter((run) => run.planId === plan.id &&
|
|
244
|
+
!run.archivedAt &&
|
|
245
|
+
(run.status === 'failed' || run.status === 'generating') &&
|
|
246
|
+
(!plan.approvedAt || run.createdAt >= plan.approvedAt));
|
|
247
|
+
for (const candidate of candidates) {
|
|
248
|
+
if (!(await pathExists(runWorkspace(root, candidate.id))))
|
|
249
|
+
continue;
|
|
250
|
+
return {
|
|
251
|
+
stage: 'generate',
|
|
252
|
+
proposalId: candidate.id,
|
|
253
|
+
resumable: candidate.recovery?.resumable !== false && !candidate.revisionOf,
|
|
254
|
+
ignorable: candidate.recovery?.ignorable !== false,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
return { stage: 'generate', resumable: false, ignorable: false };
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* The authoring inputs a plan-first generation run starts with. Recorded beside
|
|
261
|
+
* each run; rebuilt here for runs that predate that record so they can still
|
|
262
|
+
* be resumed from their approved plan.
|
|
263
|
+
*/
|
|
264
|
+
export async function planAuthoringRecord(root, plan) {
|
|
265
|
+
const project = await loadProject(root);
|
|
266
|
+
const screenshotIntent = normalizeScreenshotIntent(plan.execution.screenshots);
|
|
267
|
+
const sourceChanges = await collectSourceChanges(root, project.sources);
|
|
268
|
+
const changeSummary = sourceChanges.length > 0
|
|
269
|
+
? formatSourceChanges(sourceChanges.map((change) => ({ ...change, path: resolve(root, change.path) })))
|
|
270
|
+
: undefined;
|
|
271
|
+
const historyRequest = plan.request.trim() || plan.summary.trim();
|
|
272
|
+
return {
|
|
273
|
+
mode: plan.mode,
|
|
274
|
+
trigger: 'manual',
|
|
275
|
+
screenshots: screenshotIntent,
|
|
276
|
+
request: generationRequest(plan, await reviewPreferenceGuidance(root)),
|
|
277
|
+
...(historyRequest ? { historyRequest } : {}),
|
|
278
|
+
...(plan.execution.agent ?? project.defaultAgent ? { agent: (plan.execution.agent ?? project.defaultAgent) } : {}),
|
|
279
|
+
...(plan.execution.model ? { model: plan.execution.model } : {}),
|
|
280
|
+
...(plan.execution.reasoning ? { reasoning: plan.execution.reasoning } : {}),
|
|
281
|
+
...(plan.execution.effort ? { effort: plan.execution.effort } : {}),
|
|
282
|
+
timeoutMinutes: Math.min(batchLimits(plan.execution.limits).maxMinutes, project.sync.budget?.maxMinutes ?? 120),
|
|
283
|
+
...(changeSummary ? { changeSummary } : {}),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
export async function proposeDocumentationPlan(root, id) {
|
|
287
|
+
const current = await readDocumentationPlan(root, id);
|
|
288
|
+
if (current.status !== 'planning') {
|
|
289
|
+
throw new DoxloopError(`Plan ${id} cannot be proposed from status ${current.status}.`);
|
|
290
|
+
}
|
|
291
|
+
return runPlanner(root, current);
|
|
292
|
+
}
|
|
293
|
+
/** Validate and persist the structured result returned by any terminal agent. */
|
|
294
|
+
export async function applyDocumentationPlanProposal(root, id, raw, agent) {
|
|
295
|
+
const current = await readDocumentationPlan(root, id);
|
|
296
|
+
if (current.status !== 'planning' && current.status !== 'revising') {
|
|
297
|
+
throw new DoxloopError(`Plan ${id} cannot accept a proposal from status ${current.status}.`);
|
|
298
|
+
}
|
|
299
|
+
const proposed = normalizePlanShape(raw, current);
|
|
300
|
+
const coverageIssue = initialCreatePlanCoverageIssue(proposed, current);
|
|
301
|
+
if (coverageIssue)
|
|
302
|
+
throw new DoxloopError(coverageIssue);
|
|
303
|
+
const { approvedAt: _approvedAt, approvedHash: _approvedHash, proposalId: _proposalId, error: _error, failure: _failure, ...unapproved } = current;
|
|
304
|
+
const next = {
|
|
305
|
+
...unapproved,
|
|
306
|
+
...proposed,
|
|
307
|
+
version: current.status === 'revising' ? current.version + 1 : current.version,
|
|
308
|
+
status: proposed.questions.length > 0 ? 'needs-input' : 'ready-for-review',
|
|
309
|
+
sourceSnapshot: await documentationSourceSnapshot(root),
|
|
310
|
+
updatedAt: new Date().toISOString(),
|
|
311
|
+
execution: { ...current.execution, ...(agent ? { agent } : {}) },
|
|
312
|
+
};
|
|
313
|
+
// Keep the first reviewable proposal as v1 as well as every later revision,
|
|
314
|
+
// so reviewers can always understand how the plan changed.
|
|
315
|
+
await persistPlan(root, next, true);
|
|
316
|
+
return next;
|
|
317
|
+
}
|
|
318
|
+
export async function beginDocumentationPlanRevision(root, id, clarificationAnswers = {}) {
|
|
319
|
+
const current = await readDocumentationPlan(root, id);
|
|
320
|
+
if (!['ready-for-review', 'needs-input', 'approved', 'failed', 'stale'].includes(current.status)) {
|
|
321
|
+
throw new DoxloopError(`Plan ${id} cannot be revised from status ${current.status}.`);
|
|
322
|
+
}
|
|
323
|
+
const { approvedAt: _approvedAt, approvedHash: _approvedHash, error: _error, failure: _failure, ...unapproved } = current;
|
|
324
|
+
const next = {
|
|
325
|
+
...unapproved,
|
|
326
|
+
clarification: {
|
|
327
|
+
...current.clarification,
|
|
328
|
+
answers: { ...current.clarification.answers, ...clarificationAnswers },
|
|
329
|
+
},
|
|
330
|
+
status: 'revising',
|
|
331
|
+
updatedAt: new Date().toISOString(),
|
|
332
|
+
};
|
|
333
|
+
await persistPlan(root, next, false);
|
|
334
|
+
return next;
|
|
335
|
+
}
|
|
336
|
+
export async function reviseDocumentationPlan(root, id, feedback) {
|
|
337
|
+
const current = await readDocumentationPlan(root, id);
|
|
338
|
+
if (current.status !== 'revising') {
|
|
339
|
+
throw new DoxloopError(`Plan ${id} cannot be revised from status ${current.status}.`);
|
|
340
|
+
}
|
|
341
|
+
if (!feedback.trim())
|
|
342
|
+
throw new DoxloopError('Describe how the documentation plan should change.');
|
|
343
|
+
return runPlanner(root, current, feedback.trim());
|
|
344
|
+
}
|
|
345
|
+
export function documentationPlanClarificationFeedback(plan, answers, useRecommendations = false) {
|
|
346
|
+
if (plan.questions.length === 0)
|
|
347
|
+
throw new DoxloopError('This documentation plan has no open questions.');
|
|
348
|
+
const resolved = plan.questions.map((question) => {
|
|
349
|
+
const answer = answers[question.id]?.trim() || (useRecommendations ? question.recommendation?.trim() : undefined);
|
|
350
|
+
if (!answer)
|
|
351
|
+
throw new DoxloopError(`Answer "${question.question}" before continuing the plan.`);
|
|
352
|
+
return { question, answer };
|
|
353
|
+
});
|
|
354
|
+
return `Continue the paused documentation plan using these confirmed decisions. Remove the resolved questions from the revised plan.\n\n${resolved.map(({ question, answer }) => `Question: ${question.question}\nAnswer: ${answer}`).join('\n\n')}`;
|
|
355
|
+
}
|
|
356
|
+
export async function editDocumentationPlan(root, id, raw) {
|
|
357
|
+
const current = await readDocumentationPlan(root, id);
|
|
358
|
+
if (['planning', 'revising', 'generating', 'generated', 'cancelled'].includes(current.status)) {
|
|
359
|
+
throw new DoxloopError(`Plan ${id} cannot be edited from status ${current.status}.`);
|
|
360
|
+
}
|
|
361
|
+
const input = record(raw);
|
|
362
|
+
const executionInput = record(input.execution);
|
|
363
|
+
const shape = normalizePlanShape({
|
|
364
|
+
...current,
|
|
365
|
+
...(input.scope !== undefined ? { scope: input.scope } : {}),
|
|
366
|
+
...(input.summary !== undefined ? { summary: input.summary } : {}),
|
|
367
|
+
...(input.audiences !== undefined ? { audiences: input.audiences } : {}),
|
|
368
|
+
...(input.outcomes !== undefined ? { outcomes: input.outcomes } : {}),
|
|
369
|
+
...(input.terminology !== undefined ? { terminology: input.terminology } : {}),
|
|
370
|
+
...(input.exclusions !== undefined ? { exclusions: input.exclusions } : {}),
|
|
371
|
+
...(input.instructions !== undefined ? { instructions: input.instructions } : {}),
|
|
372
|
+
...(input.experienceLevel !== undefined ? { experienceLevel: input.experienceLevel } : {}),
|
|
373
|
+
...(input.preferredExamples !== undefined ? { preferredExamples: input.preferredExamples } : {}),
|
|
374
|
+
...(input.locale !== undefined ? { locale: input.locale } : {}),
|
|
375
|
+
...(input.accessibilityTarget !== undefined ? { accessibilityTarget: input.accessibilityTarget } : {}),
|
|
376
|
+
...(input.styleGuide !== undefined ? { styleGuide: input.styleGuide } : {}),
|
|
377
|
+
...(input.capabilities !== undefined ? { capabilities: input.capabilities } : {}),
|
|
378
|
+
...(input.navigation !== undefined ? { navigation: input.navigation } : {}),
|
|
379
|
+
...(input.estimatedPages !== undefined ? { estimatedPages: input.estimatedPages } : {}),
|
|
380
|
+
...(input.pages !== undefined ? { pages: input.pages } : {}),
|
|
381
|
+
...(input.questions !== undefined ? { questions: input.questions } : {}),
|
|
382
|
+
}, current);
|
|
383
|
+
const { approvedAt: _approvedAt, approvedHash: _approvedHash, proposalId: _proposalId, error: _error, failure: _failure, ...editable } = current;
|
|
384
|
+
const targetPages = input.targetPages === undefined ? current.targetPages : positiveInteger(input.targetPages);
|
|
385
|
+
const { targetPages: _previousTarget, ...editableWithoutTarget } = editable;
|
|
386
|
+
const next = {
|
|
387
|
+
...editableWithoutTarget,
|
|
388
|
+
...shape,
|
|
389
|
+
...(targetPages ? { targetPages } : {}),
|
|
390
|
+
execution: {
|
|
391
|
+
...current.execution,
|
|
392
|
+
limits: batchLimits(executionInput.limits ?? current.execution.limits),
|
|
393
|
+
screenshots: normalizeScreenshotIntent(executionInput.screenshots ?? current.execution.screenshots),
|
|
394
|
+
},
|
|
395
|
+
version: current.version + 1,
|
|
396
|
+
status: 'ready-for-review',
|
|
397
|
+
updatedAt: new Date().toISOString(),
|
|
398
|
+
};
|
|
399
|
+
await persistPlan(root, next, true);
|
|
400
|
+
return next;
|
|
401
|
+
}
|
|
402
|
+
export async function approveDocumentationPlan(root, id) {
|
|
403
|
+
const current = await readDocumentationPlan(root, id);
|
|
404
|
+
// A failed run leaves the reviewed plan intact, so approving it again is how
|
|
405
|
+
// a reviewer retries generation from the plan review. Only a plan that is
|
|
406
|
+
// mid-flight, already generated, or cancelled has nothing to approve.
|
|
407
|
+
if (!['ready-for-review', 'needs-input', 'stale', 'failed'].includes(current.status)) {
|
|
408
|
+
throw new DoxloopError(`Plan ${id} cannot be approved from status ${current.status}.`);
|
|
409
|
+
}
|
|
410
|
+
assertBatchFits(current);
|
|
411
|
+
const executablePages = current.pages.filter((page) => page.priority !== 'later');
|
|
412
|
+
if (executablePages.length === 0)
|
|
413
|
+
throw new DoxloopError('Add at least one page for this documentation run before approving the plan.');
|
|
414
|
+
if (current.questions.length > 0) {
|
|
415
|
+
throw new DoxloopError('Resolve or remove the open questions before approving the plan.');
|
|
416
|
+
}
|
|
417
|
+
const screenshotIntent = normalizeScreenshotIntent(current.execution.screenshots);
|
|
418
|
+
const visualPages = executablePages.filter((page) => page.visuals && page.visuals.mode !== 'none');
|
|
419
|
+
const incompleteVisuals = visualPages.filter((page) => !page.visuals?.rationale.trim() ||
|
|
420
|
+
page.visuals.estimatedCaptures < 1 ||
|
|
421
|
+
!validCaptureStartPath(page.visuals.startPath) ||
|
|
422
|
+
!page.visuals.workflow?.trim() ||
|
|
423
|
+
page.visuals.workflow.trim().length < 12 ||
|
|
424
|
+
!completeCaptureSequence(page.visuals.captureSequence, page.visuals.estimatedCaptures));
|
|
425
|
+
if (screenshotIntent === 'enabled' && incompleteVisuals.length > 0) {
|
|
426
|
+
throw new DoxloopError(`Complete the screenshot purpose, start path, workflow, and one meaningful capture-sequence item per planned image for ${incompleteVisuals.map((page) => `"${page.title}"`).join(', ')} before approval.`);
|
|
427
|
+
}
|
|
428
|
+
if (screenshotIntent === 'enabled' && visualPages.length === 0) {
|
|
429
|
+
throw new DoxloopError('Required screenshot mode needs at least one screenshot-enabled visible UI guide.');
|
|
430
|
+
}
|
|
431
|
+
const captureWarnings = screenshotIntent === 'enabled' && visualPages.length > 0
|
|
432
|
+
? await capturePlanWarnings(root, (await loadProject(root)).application, visualPages)
|
|
433
|
+
: [];
|
|
434
|
+
// Sources that changed since the proposal do not block approval. The reviewer
|
|
435
|
+
// approved the structure, and generation inspects the current sources when
|
|
436
|
+
// it writes each page, so a code edit made while the plan waited for review
|
|
437
|
+
// only earns a note rather than a fresh planning run.
|
|
438
|
+
const snapshot = await documentationSourceSnapshot(root);
|
|
439
|
+
const sourcesChanged = snapshot !== current.sourceSnapshot;
|
|
440
|
+
// Warnings from an earlier approval attempt are replaced by this check's.
|
|
441
|
+
const advisories = [
|
|
442
|
+
...(current.advisories ?? []).filter((item) => item !== SOURCES_CHANGED_ADVISORY && !item.startsWith(CAPTURE_WARNING_PREFIX)),
|
|
443
|
+
...(sourcesChanged ? [SOURCES_CHANGED_ADVISORY] : []),
|
|
444
|
+
...captureWarnings,
|
|
445
|
+
];
|
|
446
|
+
const approvedAt = new Date().toISOString();
|
|
447
|
+
const { error: _error, failure: _failure, advisories: _previousAdvisories, ...valid } = current;
|
|
448
|
+
const approvalContent = {
|
|
449
|
+
...valid,
|
|
450
|
+
pages: executablePages,
|
|
451
|
+
navigation: { ...valid.navigation, sections: valid.navigation.sections.map((section) => ({ ...section, pageIds: section.pageIds.filter((id) => executablePages.some((page) => page.id === id && page.action !== 'remove')) })).filter((section) => section.pageIds.length > 0) },
|
|
452
|
+
estimatedPages: executablePages.filter((page) => page.action !== 'preserve').length,
|
|
453
|
+
sourceSnapshot: snapshot,
|
|
454
|
+
...(advisories.length > 0 ? { advisories } : {}),
|
|
455
|
+
};
|
|
456
|
+
const approved = {
|
|
457
|
+
...approvalContent,
|
|
458
|
+
status: 'approved',
|
|
459
|
+
updatedAt: approvedAt,
|
|
460
|
+
approvedAt,
|
|
461
|
+
approvedHash: planHash(approvalContent),
|
|
462
|
+
};
|
|
463
|
+
await persistPlan(root, approved, true);
|
|
464
|
+
await atomicWrite(join(root, CURRENT_PLAN_FILE), `${JSON.stringify(approved, null, 2)}\n`);
|
|
465
|
+
// Old routes of the existing documentation now point at the pages that
|
|
466
|
+
// absorb them, so the preview and the Doxbrix build honor them once the
|
|
467
|
+
// rewrite lands and the map can be exported for the old host.
|
|
468
|
+
await writeExistingDocumentationRedirects(root, approved);
|
|
469
|
+
return approved;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Keep a plan whose configured sources changed after it was proposed. The
|
|
473
|
+
* reviewer has already read the structure, so re-running the planner for an
|
|
474
|
+
* unrelated code edit would throw that review away. Refreshing the evidence
|
|
475
|
+
* snapshot keeps every page as proposed and lets approval proceed; generation
|
|
476
|
+
* still inspects the current sources when it writes each page.
|
|
477
|
+
*/
|
|
478
|
+
export async function resumeDocumentationPlan(root, id) {
|
|
479
|
+
const current = await readDocumentationPlan(root, id);
|
|
480
|
+
if (!['stale', 'ready-for-review', 'needs-input'].includes(current.status)) {
|
|
481
|
+
throw new DoxloopError(`Plan ${id} cannot continue from status ${current.status}.`);
|
|
482
|
+
}
|
|
483
|
+
const { approvedAt: _approvedAt, approvedHash: _approvedHash, error: _error, failure: _failure, ...unapproved } = current;
|
|
484
|
+
const next = {
|
|
485
|
+
...unapproved,
|
|
486
|
+
status: current.questions.length > 0 ? 'needs-input' : 'ready-for-review',
|
|
487
|
+
sourceSnapshot: await documentationSourceSnapshot(root),
|
|
488
|
+
updatedAt: new Date().toISOString(),
|
|
489
|
+
};
|
|
490
|
+
await persistPlan(root, next, false);
|
|
491
|
+
return next;
|
|
492
|
+
}
|
|
493
|
+
export async function cancelDocumentationPlan(root, id) {
|
|
494
|
+
const current = await readDocumentationPlan(root, id);
|
|
495
|
+
if (!['planning', 'revising', 'generating'].includes(current.status))
|
|
496
|
+
return current;
|
|
497
|
+
const cancelled = {
|
|
498
|
+
...current,
|
|
499
|
+
status: 'cancelled',
|
|
500
|
+
updatedAt: new Date().toISOString(),
|
|
501
|
+
error: 'The plan workflow was cancelled before it completed.',
|
|
502
|
+
};
|
|
503
|
+
await persistPlan(root, cancelled, false);
|
|
504
|
+
return cancelled;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Record that a planning or generation process ended without reporting a
|
|
508
|
+
* result. A process that is killed, crashes, or is stopped from the UI never
|
|
509
|
+
* reaches its own failure bookkeeping, and a plan left at "planning" offers
|
|
510
|
+
* the reviewer nothing to retry. Returns the failed plan, or undefined when
|
|
511
|
+
* the plan had already moved on.
|
|
512
|
+
*/
|
|
513
|
+
export async function markDocumentationPlanInterrupted(root, id, stage, message) {
|
|
514
|
+
let current;
|
|
515
|
+
try {
|
|
516
|
+
current = await readDocumentationPlan(root, id);
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
return undefined;
|
|
520
|
+
}
|
|
521
|
+
if (!['planning', 'revising', 'generating'].includes(current.status))
|
|
522
|
+
return undefined;
|
|
523
|
+
const failed = {
|
|
524
|
+
...current,
|
|
525
|
+
status: 'failed',
|
|
526
|
+
updatedAt: new Date().toISOString(),
|
|
527
|
+
error: message,
|
|
528
|
+
failure: { stage, resumable: false, ignorable: false },
|
|
529
|
+
};
|
|
530
|
+
await persistPlan(root, failed, false);
|
|
531
|
+
return failed;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Point a plan at another assistant, model, or effort before it runs again
|
|
535
|
+
* (a retry after the pinned assistant signed out, or a switch in Settings).
|
|
536
|
+
* An approved plan stays approved: the run settings are not the content the
|
|
537
|
+
* reviewer approved, so the approval is re-stamped when it was still valid.
|
|
538
|
+
*/
|
|
539
|
+
export async function updateDocumentationPlanExecution(root, id, change) {
|
|
540
|
+
const current = await readDocumentationPlan(root, id);
|
|
541
|
+
const execution = { ...current.execution };
|
|
542
|
+
for (const key of ['agent', 'model', 'reasoning', 'effort']) {
|
|
543
|
+
if (!(key in change))
|
|
544
|
+
continue;
|
|
545
|
+
const value = change[key];
|
|
546
|
+
if (value === undefined)
|
|
547
|
+
delete execution[key];
|
|
548
|
+
else
|
|
549
|
+
execution[key] = value;
|
|
550
|
+
}
|
|
551
|
+
if (JSON.stringify(execution) === JSON.stringify(current.execution))
|
|
552
|
+
return current;
|
|
553
|
+
const approvalValid = Boolean(current.approvedHash) && planHash(current) === current.approvedHash;
|
|
554
|
+
const next = { ...current, execution, updatedAt: new Date().toISOString() };
|
|
555
|
+
if (approvalValid)
|
|
556
|
+
next.approvedHash = planHash(next);
|
|
557
|
+
await persistPlan(root, next, false);
|
|
558
|
+
return next;
|
|
559
|
+
}
|
|
560
|
+
export async function retryDocumentationPlan(root, id, stage) {
|
|
561
|
+
const current = await readDocumentationPlan(root, id);
|
|
562
|
+
if (stage === 'generate' && !current.approvedHash)
|
|
563
|
+
throw new DoxloopError('The interrupted plan no longer has an approved snapshot. Review and approve it again.');
|
|
564
|
+
const allowed = stage === 'generate'
|
|
565
|
+
? ['approved', 'generating', 'failed', 'cancelled']
|
|
566
|
+
: ['planning', 'revising', 'failed', 'cancelled'];
|
|
567
|
+
if (!allowed.includes(current.status))
|
|
568
|
+
throw new DoxloopError(`Plan ${id} cannot retry ${stage} from status ${current.status}.`);
|
|
569
|
+
const { error: _error, failure: _failure, ...restored } = current;
|
|
570
|
+
const next = { ...restored, status: stage === 'generate' ? 'approved' : stage === 'revise' ? 'revising' : 'planning', updatedAt: new Date().toISOString() };
|
|
571
|
+
await persistPlan(root, next, false);
|
|
572
|
+
return next;
|
|
573
|
+
}
|
|
574
|
+
export async function generateApprovedDocumentationPlan(root, id) {
|
|
575
|
+
let plan = await readDocumentationPlan(root, id);
|
|
576
|
+
if (plan.status !== 'approved' || !plan.approvedHash) {
|
|
577
|
+
throw new DoxloopError('Approve the current documentation plan before generating files.');
|
|
578
|
+
}
|
|
579
|
+
if (planHash(plan) !== plan.approvedHash) {
|
|
580
|
+
throw new DoxloopError('The approved plan content changed. Review and approve it again.');
|
|
581
|
+
}
|
|
582
|
+
if (await documentationSourceSnapshot(root) !== plan.sourceSnapshot) {
|
|
583
|
+
// The approved structure still stands; the authoring run inspects the
|
|
584
|
+
// current sources, so a change since approval is worth a log line only.
|
|
585
|
+
process.stdout.write('Configured sources changed after approval. Generating the approved plan from the current sources.\n');
|
|
586
|
+
}
|
|
587
|
+
const screenshotIntent = normalizeScreenshotIntent(plan.execution.screenshots);
|
|
588
|
+
const screenshotPlan = screenshotPlanSummary(plan);
|
|
589
|
+
if (screenshotIntent === 'enabled' && screenshotPlan.guides === 0) {
|
|
590
|
+
throw new DoxloopError('Screenshots are required for this run, but the approved plan has no screenshot-enabled UI guide. Add screenshots to a relevant page or change the run to Automatic/No screenshots.');
|
|
591
|
+
}
|
|
592
|
+
if (screenshotIntent === 'enabled' && screenshotPlan.guides > 0) {
|
|
593
|
+
// The approved plan is hashed, so warnings found now go to the run log.
|
|
594
|
+
const warnings = await capturePlanWarnings(root, (await loadProject(root)).application, plan.pages.filter((page) => page.visuals && page.visuals.mode !== 'none'));
|
|
595
|
+
for (const warning of warnings)
|
|
596
|
+
process.stdout.write(`${warning}\n`);
|
|
597
|
+
}
|
|
598
|
+
const { error: _error, failure: _failure, ...generatingPlan } = plan;
|
|
599
|
+
plan = { ...generatingPlan, status: 'generating', updatedAt: new Date().toISOString() };
|
|
600
|
+
await persistPlan(root, plan, false);
|
|
601
|
+
let failedProposal;
|
|
602
|
+
try {
|
|
603
|
+
const recovered = await recoverLatestFailedProposal(root, plan);
|
|
604
|
+
if (recovered) {
|
|
605
|
+
for (const [stage, label] of [
|
|
606
|
+
['inspecting-sources', 'Confirming approved evidence'],
|
|
607
|
+
['authoring-pages', 'Recovered previously authored pages'],
|
|
608
|
+
['updating-navigation', 'Updating navigation and theme'],
|
|
609
|
+
['recording-evidence', 'Recording page evidence'],
|
|
610
|
+
['validating', 'Validated the recovered documentation'],
|
|
611
|
+
['preparing-proposal', 'Preparing review proposal'],
|
|
612
|
+
]) {
|
|
613
|
+
emitWorkflowStage(stage, label, 'completed');
|
|
614
|
+
}
|
|
615
|
+
const { error: _recoveredError, failure: _recoveredFailure, ...recoveredPlan } = plan;
|
|
616
|
+
plan = {
|
|
617
|
+
...recoveredPlan,
|
|
618
|
+
status: 'generated',
|
|
619
|
+
proposalId: recovered.id,
|
|
620
|
+
updatedAt: new Date().toISOString(),
|
|
621
|
+
};
|
|
622
|
+
await persistPlan(root, plan, false);
|
|
623
|
+
return plan;
|
|
624
|
+
}
|
|
625
|
+
emitWorkflowStage('inspecting-sources', 'Confirming approved evidence', 'running');
|
|
626
|
+
const project = await loadProject(root);
|
|
627
|
+
const [drift, sourceChanges, reviewerGuidance] = await Promise.all([
|
|
628
|
+
computeDrift(root, project),
|
|
629
|
+
collectSourceChanges(root, project.sources),
|
|
630
|
+
reviewPreferenceGuidance(root),
|
|
631
|
+
]);
|
|
632
|
+
emitWorkflowStage('inspecting-sources', 'Confirming approved evidence', 'completed');
|
|
633
|
+
// The authoring run announces and advances the writing, navigation,
|
|
634
|
+
// evidence, screenshot, and validation stages from what the agent does.
|
|
635
|
+
const proposal = await createSyncRun({
|
|
636
|
+
root,
|
|
637
|
+
project,
|
|
638
|
+
plan,
|
|
639
|
+
drift,
|
|
640
|
+
sourceChanges,
|
|
641
|
+
trigger: 'manual',
|
|
642
|
+
authoring: {
|
|
643
|
+
mode: plan.mode,
|
|
644
|
+
...(plan.request.trim() || plan.summary.trim()
|
|
645
|
+
? { historyRequest: plan.request.trim() || plan.summary.trim() }
|
|
646
|
+
: {}),
|
|
647
|
+
request: generationRequest(plan, reviewerGuidance),
|
|
648
|
+
...(plan.execution.agent ? { agent: plan.execution.agent } : {}),
|
|
649
|
+
...(plan.execution.model ? { model: plan.execution.model } : {}),
|
|
650
|
+
...(plan.execution.reasoning ? { reasoning: plan.execution.reasoning } : {}),
|
|
651
|
+
...(plan.execution.effort ? { effort: plan.execution.effort } : {}),
|
|
652
|
+
screenshots: screenshotIntent,
|
|
653
|
+
},
|
|
654
|
+
});
|
|
655
|
+
failedProposal = proposal;
|
|
656
|
+
if (proposal.status === 'failed') {
|
|
657
|
+
throw new DoxloopError(proposal.error ?? 'Documentation generation failed.');
|
|
658
|
+
}
|
|
659
|
+
if (screenshotPlan.guides > 0 && screenshotIntent !== 'disabled') {
|
|
660
|
+
emitWorkflowStage('capturing-screenshots', proposal.screenshots?.status === 'verified' ? `Verified ${proposal.screenshots.captured} application screenshot${proposal.screenshots.captured === 1 ? '' : 's'}` : 'Application screenshots were not captured', proposal.screenshots?.status === 'failed' ? 'failed' : 'completed');
|
|
661
|
+
}
|
|
662
|
+
emitWorkflowStage('validating', 'Validating generated documentation', 'completed');
|
|
663
|
+
emitWorkflowStage('preparing-proposal', 'Preparing review proposal', 'running');
|
|
664
|
+
emitWorkflowStage('preparing-proposal', 'Preparing review proposal', 'completed');
|
|
665
|
+
const { error: _error, failure: _failure, ...generatedPlan } = plan;
|
|
666
|
+
plan = {
|
|
667
|
+
...generatedPlan,
|
|
668
|
+
status: 'generated',
|
|
669
|
+
proposalId: proposal.id,
|
|
670
|
+
updatedAt: new Date().toISOString(),
|
|
671
|
+
};
|
|
672
|
+
await persistPlan(root, plan, false);
|
|
673
|
+
return plan;
|
|
674
|
+
}
|
|
675
|
+
catch (error) {
|
|
676
|
+
const preserved = failedProposal && (await pathExists(runWorkspace(root, failedProposal.id)));
|
|
677
|
+
plan = {
|
|
678
|
+
...plan,
|
|
679
|
+
status: 'failed',
|
|
680
|
+
updatedAt: new Date().toISOString(),
|
|
681
|
+
error: error instanceof Error ? error.message : String(error),
|
|
682
|
+
failure: {
|
|
683
|
+
stage: 'generate',
|
|
684
|
+
...(failedProposal ? { proposalId: failedProposal.id } : {}),
|
|
685
|
+
resumable: Boolean(preserved) && failedProposal?.recovery?.resumable !== false,
|
|
686
|
+
ignorable: Boolean(preserved) && failedProposal?.recovery?.ignorable !== false,
|
|
687
|
+
},
|
|
688
|
+
};
|
|
689
|
+
await persistPlan(root, plan, false);
|
|
690
|
+
throw error;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* A generation retry first tries to promote the newest failed proposal for
|
|
695
|
+
* this plan whose preserved workspace still validates. Recovery is best-effort:
|
|
696
|
+
* any failure falls back to a full agent run without surfacing an error.
|
|
697
|
+
*/
|
|
698
|
+
async function recoverLatestFailedProposal(root, plan) {
|
|
699
|
+
let candidates;
|
|
700
|
+
try {
|
|
701
|
+
candidates = (await listSyncRuns(root)).filter((run) => run.status === 'failed' &&
|
|
702
|
+
run.planId === plan.id &&
|
|
703
|
+
!run.archivedAt &&
|
|
704
|
+
// Only runs generated from the current approval; an edited and
|
|
705
|
+
// re-approved plan must not resurrect output from its older version.
|
|
706
|
+
(!plan.approvedAt || run.createdAt >= plan.approvedAt));
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
return undefined;
|
|
710
|
+
}
|
|
711
|
+
for (const candidate of candidates) {
|
|
712
|
+
if (!(await pathExists(runWorkspace(root, candidate.id))))
|
|
713
|
+
continue;
|
|
714
|
+
try {
|
|
715
|
+
return await recoverSyncRun(root, candidate.id);
|
|
716
|
+
}
|
|
717
|
+
catch {
|
|
718
|
+
return undefined;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return undefined;
|
|
722
|
+
}
|
|
723
|
+
export async function documentationSourceSnapshot(root) {
|
|
724
|
+
const project = await loadProject(root);
|
|
725
|
+
let changes;
|
|
726
|
+
try {
|
|
727
|
+
changes = await collectSourceChanges(root, project.sources);
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
changes = { unavailable: error instanceof Error ? error.message : String(error) };
|
|
731
|
+
}
|
|
732
|
+
return createHash('sha256')
|
|
733
|
+
.update(JSON.stringify({
|
|
734
|
+
sources: project.sources,
|
|
735
|
+
changes,
|
|
736
|
+
fingerprints: await sourceSnapshotFingerprints(root, project.sources),
|
|
737
|
+
}))
|
|
738
|
+
.digest('hex');
|
|
739
|
+
}
|
|
740
|
+
const PROPOSAL_CHECKPOINT_FILE = 'proposal-checkpoint.json';
|
|
741
|
+
/**
|
|
742
|
+
* Fingerprint of a planning request. Two attempts with the same fingerprint
|
|
743
|
+
* are asking the same question, so the agent's earlier reply can stand in for
|
|
744
|
+
* a new one.
|
|
745
|
+
*/
|
|
746
|
+
export function proposalCheckpointKey(current, sourceSnapshot, feedback) {
|
|
747
|
+
return createHash('sha256')
|
|
748
|
+
.update(JSON.stringify({
|
|
749
|
+
id: current.id,
|
|
750
|
+
version: current.version,
|
|
751
|
+
mode: current.mode,
|
|
752
|
+
scope: current.scope,
|
|
753
|
+
request: current.request,
|
|
754
|
+
targetPages: current.targetPages ?? null,
|
|
755
|
+
execution: current.execution,
|
|
756
|
+
clarification: current.clarification,
|
|
757
|
+
sourceSnapshot,
|
|
758
|
+
feedback: feedback ?? null,
|
|
759
|
+
}))
|
|
760
|
+
.digest('hex');
|
|
761
|
+
}
|
|
762
|
+
function proposalCheckpointPath(root, id) {
|
|
763
|
+
return join(root, PLANS_DIRECTORY, id, PROPOSAL_CHECKPOINT_FILE);
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Keep the agent's reply the moment it arrives. A planning run that is later
|
|
767
|
+
* stopped by its time limit, a crash, or the user can then continue from
|
|
768
|
+
* this reply instead of paying for the same planning pass again.
|
|
769
|
+
*/
|
|
770
|
+
export async function writeProposalCheckpoint(root, id, checkpoint) {
|
|
771
|
+
await atomicWrite(proposalCheckpointPath(root, id), `${JSON.stringify({ ...checkpoint, savedAt: new Date().toISOString() }, null, 2)}\n`);
|
|
772
|
+
}
|
|
773
|
+
export async function readProposalCheckpoint(root, id, key) {
|
|
774
|
+
let text;
|
|
775
|
+
try {
|
|
776
|
+
text = await readFile(proposalCheckpointPath(root, id), 'utf8');
|
|
777
|
+
}
|
|
778
|
+
catch {
|
|
779
|
+
return undefined;
|
|
780
|
+
}
|
|
781
|
+
try {
|
|
782
|
+
const parsed = JSON.parse(text);
|
|
783
|
+
if (parsed.key !== key || (parsed.pass !== 'proposal' && parsed.pass !== 'revised') || parsed.raw === undefined)
|
|
784
|
+
return undefined;
|
|
785
|
+
const repairs = Array.isArray(parsed.repairs) ? parsed.repairs.filter((item) => typeof item === 'string') : [];
|
|
786
|
+
return { key, pass: parsed.pass, raw: parsed.raw, ...(repairs.length > 0 ? { repairs } : {}), savedAt: typeof parsed.savedAt === 'string' ? parsed.savedAt : '' };
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
return undefined;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
export async function clearProposalCheckpoint(root, id) {
|
|
793
|
+
await rm(proposalCheckpointPath(root, id), { force: true });
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* How much research an update request needs. Deterministic rules decide the
|
|
797
|
+
* clear cases; an ambiguous request gets one short session that reads
|
|
798
|
+
* nothing and answers from the request and the page list; and when that
|
|
799
|
+
* session fails the full research runs, which is never wrong, only slow.
|
|
800
|
+
*/
|
|
801
|
+
async function triageUpdateRequest(root, current, selected, project, existingPages) {
|
|
802
|
+
const byRules = triageByRules(current.request, existingPages);
|
|
803
|
+
if (byRules)
|
|
804
|
+
return byRules;
|
|
805
|
+
emitWorkflowStage('building-coverage', 'Deciding how much research this update needs', 'running');
|
|
806
|
+
const prompt = triagePrompt(current, existingPages);
|
|
807
|
+
const prepared = await prepareAgentPrompt(root, prompt);
|
|
808
|
+
try {
|
|
809
|
+
const output = await runAgentForPlan(root, selected, prepared.argument, current.execution, project, { browser: false, label: 'triage', prefix: '[triage] ' });
|
|
810
|
+
const decided = readTriageOutput(output, selected.name, existingPages, prompt);
|
|
811
|
+
if (decided)
|
|
812
|
+
return decided;
|
|
813
|
+
process.stdout.write('The triage session returned no usable decision; running the full research.\n');
|
|
814
|
+
}
|
|
815
|
+
catch (error) {
|
|
816
|
+
process.stdout.write(`The triage session did not finish (${error instanceof Error ? error.message : String(error)}); running the full research.\n`);
|
|
817
|
+
}
|
|
818
|
+
finally {
|
|
819
|
+
if (prepared.path)
|
|
820
|
+
await rm(prepared.path, { force: true });
|
|
821
|
+
}
|
|
822
|
+
return fullResearch('The request could not be classified, so every research session runs.', 'agent');
|
|
823
|
+
}
|
|
824
|
+
/** The navigation as it stands, for a plan that changes only navigation. */
|
|
825
|
+
async function currentNavigationText(root) {
|
|
826
|
+
try {
|
|
827
|
+
const tree = await readNavigation(root);
|
|
828
|
+
const compact = tree.spaces.map((space) => ({ name: space.name, ...(space.icon ? { icon: space.icon } : {}), nav: space.nav }));
|
|
829
|
+
const support = tree.editable
|
|
830
|
+
? `The generator (${tree.generator}) keeps its navigation in ${tree.configFile}. It supports: ${Object.entries(tree.supports).filter(([, value]) => value).map(([name]) => name).join(', ') || 'pages and groups only'}.${tree.icons.length > 0 ? ` Icon names it can draw: ${tree.icons.join(', ')}.` : ' It cannot draw icons.'}`
|
|
831
|
+
: `The generator (${tree.generator}) does not let Doxloop rewrite its navigation: ${tree.reason ?? ''}`;
|
|
832
|
+
return `${support}\n${JSON.stringify(compact)}${tree.orphans.length > 0 ? `\nPages in no navigation: ${tree.orphans.map((page) => page.path).join(', ')}` : ''}`;
|
|
833
|
+
}
|
|
834
|
+
catch (error) {
|
|
835
|
+
return `The navigation could not be read: ${error instanceof Error ? error.message : String(error)}`;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
async function runPlanner(root, current, feedback) {
|
|
839
|
+
const project = await loadProject(root);
|
|
840
|
+
const budget = await UsageBudget.open(root, current.id, project.sync.budget?.maxUsd);
|
|
841
|
+
return budgetContext.run(budget, () => runPlannerWithBudget(root, current, feedback));
|
|
842
|
+
}
|
|
843
|
+
async function runPlannerWithBudget(root, current, feedback) {
|
|
844
|
+
const stage = current.status === 'revising' ? 'revise' : 'propose';
|
|
845
|
+
let failedCandidate;
|
|
846
|
+
try {
|
|
847
|
+
emitWorkflowStage('inspecting-sources', 'Inspecting sources', 'running');
|
|
848
|
+
const project = await loadProject(root);
|
|
849
|
+
if (normalizeScreenshotIntent(current.execution.screenshots) === 'enabled') {
|
|
850
|
+
try {
|
|
851
|
+
await assertScreenshotPlanningReadiness(project.application, current.execution.screenshots, await captureAuthContext(root));
|
|
852
|
+
const browserReadiness = await checkScreenCaptureBrowser();
|
|
853
|
+
if (!browserReadiness.available)
|
|
854
|
+
throw new DoxloopError(`Required screenshot planning cannot start. ${browserReadiness.message}`);
|
|
855
|
+
}
|
|
856
|
+
catch (error) {
|
|
857
|
+
// A revision reuses the saved research briefs and never opens the
|
|
858
|
+
// application, so an unreachable application is a warning for the
|
|
859
|
+
// generate stage, not a reason to lose the reviewer's answers.
|
|
860
|
+
if (!feedback)
|
|
861
|
+
throw error;
|
|
862
|
+
process.stdout.write(`Screenshot check skipped for this revision: ${error instanceof Error ? error.message : String(error)} Screenshots are checked again before generation.\n`);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
const discovery = await discoverDocumentationSources(root);
|
|
866
|
+
const changes = await collectSourceChanges(root, project.sources);
|
|
867
|
+
emitWorkflowStage('inspecting-sources', 'Inspecting sources', 'completed');
|
|
868
|
+
emitWorkflowStage('building-coverage', 'Building coverage plan', 'running');
|
|
869
|
+
const selected = await chooseAgent(current.execution.agent);
|
|
870
|
+
const sourceSnapshot = await documentationSourceSnapshot(root);
|
|
871
|
+
const checkpointKey = proposalCheckpointKey(current, sourceSnapshot, feedback);
|
|
872
|
+
const checkpoint = await readProposalCheckpoint(root, current.id, checkpointKey);
|
|
873
|
+
const captureAuth = describeCaptureAuth(project.application ? await captureAuthContext(root) : undefined);
|
|
874
|
+
const changesText = formatSourceChanges(changes);
|
|
875
|
+
// Research runs as short sessions, several at a time, each returning a
|
|
876
|
+
// brief; the plan is then written by one session that reads the briefs
|
|
877
|
+
// instead of the sources, the application, and the crawled pages.
|
|
878
|
+
let briefs = [];
|
|
879
|
+
let researchRepairs = [];
|
|
880
|
+
if (stagedPlanningEnabled()) {
|
|
881
|
+
// How much research this request needs is decided once, before any
|
|
882
|
+
// session starts, and kept on the plan so revisions and retries agree.
|
|
883
|
+
if (!current.research) {
|
|
884
|
+
const research = current.mode === 'create'
|
|
885
|
+
? fullResearch('A create run researches the whole product.')
|
|
886
|
+
: await triageUpdateRequest(root, current, selected, project, discovery.inventory.existingPages);
|
|
887
|
+
current = { ...current, research };
|
|
888
|
+
await persistPlan(root, current, false);
|
|
889
|
+
process.stdout.write(`${describeResearchScope(research)}\n`);
|
|
890
|
+
}
|
|
891
|
+
const tasks = await researchTasks(root, project, current);
|
|
892
|
+
if (tasks.length > 0) {
|
|
893
|
+
const researchKey = researchCheckpointKey(current, sourceSnapshot);
|
|
894
|
+
const acceptKeys = researchCheckpointKeys(current, sourceSnapshot);
|
|
895
|
+
if (checkpoint) {
|
|
896
|
+
briefs = await savedResearchBriefs(root, current.id, tasks, acceptKeys);
|
|
897
|
+
}
|
|
898
|
+
else {
|
|
899
|
+
const concurrency = selected.name === 'gemini' ? 1 : Math.min(planningParallelism(), tasks.length);
|
|
900
|
+
emitWorkflowStage('building-coverage', `Researching in ${tasks.length} session${tasks.length === 1 ? '' : 's'}${concurrency > 1 ? ` (${concurrency} at a time)` : ''}`, 'running', { done: 0, total: tasks.length });
|
|
901
|
+
// A signed-out agent fails every session the same way within a
|
|
902
|
+
// second; the first such failure stops the others from starting
|
|
903
|
+
// and becomes the plan's failure reason instead of "exited with status 1".
|
|
904
|
+
let blocker;
|
|
905
|
+
const research = await runResearch(tasks, {
|
|
906
|
+
root,
|
|
907
|
+
planId: current.id,
|
|
908
|
+
key: researchKey,
|
|
909
|
+
acceptKeys,
|
|
910
|
+
agent: selected.name,
|
|
911
|
+
concurrency,
|
|
912
|
+
context: { project, current, discovery: discovery.inventory, changes: changesText, captureAuth, captureDirectory: await preparePlanningCaptures(root, current.id) },
|
|
913
|
+
runSession: async (task, text) => {
|
|
914
|
+
if (blocker)
|
|
915
|
+
throw blocker;
|
|
916
|
+
const prepared = await prepareAgentPrompt(root, text);
|
|
917
|
+
try {
|
|
918
|
+
return await runAgentForPlan(root, selected, prepared.argument, current.execution, project, { browser: task.browser, label: `research "${task.id}"`, prefix: `[${task.id}] ` });
|
|
919
|
+
}
|
|
920
|
+
catch (error) {
|
|
921
|
+
if (error instanceof AgentSessionError && error.kind !== 'other')
|
|
922
|
+
blocker ??= error;
|
|
923
|
+
throw error;
|
|
924
|
+
}
|
|
925
|
+
finally {
|
|
926
|
+
if (prepared.path)
|
|
927
|
+
await rm(prepared.path, { force: true });
|
|
928
|
+
}
|
|
929
|
+
},
|
|
930
|
+
onProgress: (done, total, task, outcome) => {
|
|
931
|
+
// The row shows what is still in progress; which session just ended belongs in the log.
|
|
932
|
+
process.stdout.write(`Research ${done}/${total}: ${task.label} ${outcome === 'cached' ? 'reused' : outcome}\n`);
|
|
933
|
+
const left = total - done;
|
|
934
|
+
emitWorkflowStage('building-coverage', left > 0 ? `Researching · ${left} session${left === 1 ? '' : 's'} still running` : 'Research finished · writing the plan', 'running', { done, total });
|
|
935
|
+
},
|
|
936
|
+
log: (line) => process.stdout.write(`${line}\n`),
|
|
937
|
+
}).catch((error) => {
|
|
938
|
+
throw blocker ? researchBlockedError(error, blocker) : error;
|
|
939
|
+
});
|
|
940
|
+
briefs = research.briefs;
|
|
941
|
+
researchRepairs = research.repairs;
|
|
942
|
+
emitWorkflowStage('building-coverage', 'Writing the coverage plan from the research briefs', 'running');
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
const briefed = briefs.length > 0;
|
|
947
|
+
const navigationText = current.research?.scope === 'navigation' ? await currentNavigationText(root) : undefined;
|
|
948
|
+
const prompt = planningPrompt(project, current, discovery.inventory, changesText, feedback, await reviewPreferenceGuidance(root), captureAuth, briefed ? formatResearchBriefs(briefs) : undefined, navigationText);
|
|
949
|
+
// A navigation-only or page-scoped plan never explores the application:
|
|
950
|
+
// it has no screens to discover, only the briefs or the navigation.
|
|
951
|
+
const browser = !briefed && (current.research?.scope ?? 'product') === 'product';
|
|
952
|
+
let raw;
|
|
953
|
+
// What Doxloop had to fix to read the reply it is working from; reported
|
|
954
|
+
// on the review so a reader can check nothing was lost.
|
|
955
|
+
let repairs = [];
|
|
956
|
+
if (checkpoint) {
|
|
957
|
+
process.stdout.write(`Continuing from the proposal ${selected.name} returned at ${checkpoint.savedAt}; the planning pass is not repeated.\n`);
|
|
958
|
+
raw = checkpoint.raw;
|
|
959
|
+
repairs = checkpoint.repairs ?? [];
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
// A revision patches the plan it revises; the reply is merged into it.
|
|
963
|
+
const reply = await planFromAgent(root, selected, prompt, current.execution, project, feedback ? { browser, patchOf: normalizePlanShape(current, current) } : { browser });
|
|
964
|
+
raw = feedback ? withoutAnsweredQuestions(reply.plan, current.clarification.answers) : reply.plan;
|
|
965
|
+
repairs = [...researchRepairs, ...reply.repairs];
|
|
966
|
+
await writeProposalCheckpoint(root, current.id, { key: checkpointKey, pass: 'proposal', raw, ...(repairs.length > 0 ? { repairs } : {}) });
|
|
967
|
+
}
|
|
968
|
+
raw = await withExistingPageDetails(root, raw, project.sources);
|
|
969
|
+
let recommendedAnswers;
|
|
970
|
+
raw = repairMechanicalPlanIssues(raw, current.execution);
|
|
971
|
+
{
|
|
972
|
+
// A capture ID the application brief never saved cannot be reused; the
|
|
973
|
+
// writer would embed nothing and the run would treat the state as done.
|
|
974
|
+
const known = await knownPlanningCaptureIds(root, current.id);
|
|
975
|
+
const blanked = known ? dropUnknownCaptureIds(raw, known) : 0;
|
|
976
|
+
if (blanked > 0)
|
|
977
|
+
repairs.push(`${blanked} capture reference${blanked === 1 ? '' : 's'} named an image the application research never saved; those states are captured during generation instead.`);
|
|
978
|
+
}
|
|
979
|
+
const proposed = normalizePlanShape(raw, current);
|
|
980
|
+
// Only findings that would block approval are worth a corrective pass:
|
|
981
|
+
// it costs as much as the first proposal, because the agent returns the
|
|
982
|
+
// whole plan again. Thin or missing screenshot coverage is reported as an
|
|
983
|
+
// advisory on the review instead, where the reviewer can ask for more.
|
|
984
|
+
const planningIssue = checkpoint?.pass === 'revised' ? '' : [
|
|
985
|
+
initialCreatePlanCoverageIssue(proposed, current),
|
|
986
|
+
existingDocumentationPlanIssue(proposed, current, project.sources),
|
|
987
|
+
requiredScreenshotPlanIssue(proposed, current.execution),
|
|
988
|
+
].filter((issue) => Boolean(issue)).join('\n');
|
|
989
|
+
if (planningIssue) {
|
|
990
|
+
emitWorkflowStage('revising-gates', 'Revising failed planning gates', 'running');
|
|
991
|
+
const repairPrompt = `${prompt}
|
|
992
|
+
|
|
993
|
+
${gateRevisionInstructions(planningIssue, raw, { briefed })}`;
|
|
994
|
+
try {
|
|
995
|
+
const reply = await planFromAgent(root, selected, repairPrompt, current.execution, project, { browser, patchOf: raw });
|
|
996
|
+
raw = await withExistingPageDetails(root, reply.plan, project.sources);
|
|
997
|
+
repairs = [...researchRepairs, ...reply.repairs];
|
|
998
|
+
await writeProposalCheckpoint(root, current.id, { key: checkpointKey, pass: 'revised', raw, ...(repairs.length > 0 ? { repairs } : {}) });
|
|
999
|
+
}
|
|
1000
|
+
catch (error) {
|
|
1001
|
+
// The first proposal is real work. When the corrective pass stops
|
|
1002
|
+
// (timeout, cut-off reply, agent error) the reviewer gets that
|
|
1003
|
+
// proposal with the gate findings as advisories rather than nothing.
|
|
1004
|
+
process.stdout.write(`Gate revision did not finish; keeping the first proposal for review. ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1005
|
+
}
|
|
1006
|
+
emitWorkflowStage('revising-gates', 'Revising failed planning gates', 'completed');
|
|
1007
|
+
}
|
|
1008
|
+
const clarificationCandidate = normalizePlanShape(raw, current);
|
|
1009
|
+
if (clarificationCandidate.questions.length > 0 && current.clarification.mode === 'defaults' && clarificationCandidate.questions.every((question) => question.recommendation?.trim())) {
|
|
1010
|
+
recommendedAnswers = Object.fromEntries(clarificationCandidate.questions.map((question) => [question.id, question.recommendation.trim()]));
|
|
1011
|
+
emitWorkflowStage('clarifying', 'Applying recommended decisions', 'running');
|
|
1012
|
+
const clarificationFeedback = documentationPlanClarificationFeedback({ ...current, ...clarificationCandidate }, {}, true);
|
|
1013
|
+
const clarificationPrompt = `${prompt}\n\n${clarificationFeedback}\n\nPlan awaiting clarification:\n${JSON.stringify(raw)}`;
|
|
1014
|
+
const reply = await planFromAgent(root, selected, clarificationPrompt, current.execution, project, { browser });
|
|
1015
|
+
raw = await withExistingPageDetails(root, reply.plan, project.sources);
|
|
1016
|
+
repairs = [...researchRepairs, ...reply.repairs];
|
|
1017
|
+
emitWorkflowStage('clarifying', 'Applying recommended decisions', 'completed');
|
|
1018
|
+
}
|
|
1019
|
+
const finalShape = normalizePlanShape(raw, current);
|
|
1020
|
+
const finalPlanningIssue = [
|
|
1021
|
+
initialCreatePlanCoverageIssue(finalShape, current),
|
|
1022
|
+
existingDocumentationPlanIssue(finalShape, current, project.sources),
|
|
1023
|
+
requiredScreenshotPlanIssue(finalShape, current.execution),
|
|
1024
|
+
].filter((issue) => Boolean(issue)).join('\n');
|
|
1025
|
+
if (finalPlanningIssue) {
|
|
1026
|
+
// The agent's plan is not approvable as it stands, but it is real work:
|
|
1027
|
+
// keep it as the failed plan so the reviewer can fix it by hand or accept
|
|
1028
|
+
// it for review instead of paying for another planning run.
|
|
1029
|
+
const candidate = await persistFailedPlanCandidate(root, current, raw, selected.name);
|
|
1030
|
+
failedCandidate = candidate;
|
|
1031
|
+
// Replaying this reply would fail the same gate, so the next attempt plans afresh.
|
|
1032
|
+
await clearProposalCheckpoint(root, current.id);
|
|
1033
|
+
throw new DoxloopError(`The planning agent could not produce an approvable plan. ${finalPlanningIssue}`);
|
|
1034
|
+
}
|
|
1035
|
+
let applied = await applyDocumentationPlanProposal(root, current.id, raw, selected.name);
|
|
1036
|
+
await clearProposalCheckpoint(root, current.id);
|
|
1037
|
+
const advisories = [
|
|
1038
|
+
...(applied.research && applied.mode !== 'create' ? [describeResearchScope(applied.research)] : []),
|
|
1039
|
+
...repairs,
|
|
1040
|
+
screenshotCoverageAdvisory(applied, applied.execution),
|
|
1041
|
+
shallowCaptureAdvisory(applied, applied.execution),
|
|
1042
|
+
existingDocumentationAdvisory(applied, project.sources, discovery.inventory),
|
|
1043
|
+
].filter((item) => Boolean(item));
|
|
1044
|
+
if (recommendedAnswers || advisories.length > 0) {
|
|
1045
|
+
applied = {
|
|
1046
|
+
...applied,
|
|
1047
|
+
...(advisories.length > 0 ? { advisories } : {}),
|
|
1048
|
+
...(recommendedAnswers
|
|
1049
|
+
? { clarification: { ...applied.clarification, answers: { ...applied.clarification.answers, ...recommendedAnswers } } }
|
|
1050
|
+
: {}),
|
|
1051
|
+
};
|
|
1052
|
+
await persistPlan(root, applied, true);
|
|
1053
|
+
}
|
|
1054
|
+
emitWorkflowStage('building-coverage', 'Building coverage plan', 'completed');
|
|
1055
|
+
emitWorkflowStage('ready-for-review', 'Preparing plan review', 'completed');
|
|
1056
|
+
return applied;
|
|
1057
|
+
}
|
|
1058
|
+
catch (error) {
|
|
1059
|
+
const failed = {
|
|
1060
|
+
...(failedCandidate ?? current),
|
|
1061
|
+
status: 'failed',
|
|
1062
|
+
updatedAt: new Date().toISOString(),
|
|
1063
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1064
|
+
failure: { stage, resumable: false, ignorable: Boolean(failedCandidate) },
|
|
1065
|
+
};
|
|
1066
|
+
await persistPlan(root, failed, false);
|
|
1067
|
+
throw error;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* The corrective pass costs as much as the first proposal because the agent
|
|
1072
|
+
* returns the whole plan again, and in real runs it cost more: told to
|
|
1073
|
+
* "re-audit the evidence" and "inspect the reachable application", the
|
|
1074
|
+
* planner signed in and re-explored every screen it had already seen. The
|
|
1075
|
+
* gate names specific pages and specific fields, so the pass is scoped to
|
|
1076
|
+
* them, and the browser is only for a state the first pass never reached.
|
|
1077
|
+
*/
|
|
1078
|
+
export function gateRevisionInstructions(planningIssue, firstProposal, options = {}) {
|
|
1079
|
+
const noVisualGuide = /planned no application screenshots/.test(planningIssue);
|
|
1080
|
+
const browserGuidance = options.briefed
|
|
1081
|
+
? 'Plan any screen you add from the application research brief you were given; do not sign in or explore it again, and do not read the sources again. Every finding below can be resolved from the first proposal and the briefs.'
|
|
1082
|
+
: noVisualGuide
|
|
1083
|
+
? 'Use the supplied doxloop_capture MCP tools only as far as needed to confirm the screens the guides you add will document; stay within the planning browser budget.'
|
|
1084
|
+
: 'You already inspected the application and the evidence for this proposal; do not sign in or explore it again. Every finding below can be resolved from the first proposal and the source you have read.';
|
|
1085
|
+
return `Required planning validation failed for your first proposal:
|
|
1086
|
+
${planningIssue}
|
|
1087
|
+
|
|
1088
|
+
Fix exactly these findings and return only what changed, as a patch. Keep every page, capability, navigation entry, and field that the findings do not name unchanged: Doxloop merges the patch into the first proposal by page id, so anything you leave out stays exactly as it was. Send each corrected or added page complete with every field, list the ids of pages you removed, and resend the full capabilities, navigation, or existingDocumentation lists only when a finding required changing them. Do not resend unchanged pages; this pass corrects the named pages, it does not re-plan. ${browserGuidance} Do not pad the plan, invent UI states, downgrade required screenshots, or return another plan without a complete screenshot-enabled UI guide.
|
|
1089
|
+
|
|
1090
|
+
End your reply with exactly one machine-readable block and put nothing after it. Do not use Markdown fences inside the block; close every bracket you open, and make the object's own closing brace the last character before </doxloop-plan-patch>:
|
|
1091
|
+
<doxloop-plan-patch>
|
|
1092
|
+
{ "pages": [ corrected or added pages, complete ], "removePageIds": ["ids of removed pages"], "capabilities": [ only when changed ], "navigation": { only when changed }, "existingDocumentation": [ only when changed ] }
|
|
1093
|
+
</doxloop-plan-patch>
|
|
1094
|
+
|
|
1095
|
+
First proposal (compact JSON):
|
|
1096
|
+
${JSON.stringify(firstProposal)}`;
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* A revision used to return the whole plan again — for a 34-page plan that
|
|
1100
|
+
* was 57 KB of JSON emitted token by token, five minutes of waiting to apply
|
|
1101
|
+
* three answers. The reviser sends only what changed and Doxloop merges it.
|
|
1102
|
+
*/
|
|
1103
|
+
export function planRevisionPatchInstructions() {
|
|
1104
|
+
return `Return only what changed, as a patch: Doxloop merges it into the existing plan by page id, so every page, capability, and navigation entry you leave out stays exactly as it is. Send each changed or added page complete with every field, list the ids of removed pages in "removePageIds", and resend "capabilities", "navigation", "existingDocumentation", "summary", or "instructions" only when the feedback changes them. Always include "questions": the open questions that still need the reviewer, or [] when the feedback resolves them all. Do not resend unchanged pages, and do not re-plan, re-read the sources, or explore the application: the feedback and the existing plan below are all you need.
|
|
1105
|
+
|
|
1106
|
+
End your reply with exactly one machine-readable block and put nothing after it. Do not use Markdown fences inside the block; close every bracket you open, and make the object's own closing brace the last character before </doxloop-plan-patch>:
|
|
1107
|
+
<doxloop-plan-patch>
|
|
1108
|
+
{ "pages": [ changed or added pages, complete ], "removePageIds": ["ids of removed pages"], "questions": [ remaining open questions, or none ], "capabilities": [ only when changed ], "navigation": { only when changed }, "existingDocumentation": [ only when changed ], "summary": "only when changed", "instructions": "only when changed" }
|
|
1109
|
+
</doxloop-plan-patch>`;
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Drop the questions the reviewer has answered from a revised plan. The
|
|
1113
|
+
* reviser is told to remove them, but a patch that omits "questions" keeps
|
|
1114
|
+
* the base plan's list, and a plan that still carries an answered question
|
|
1115
|
+
* would pause for input a second time.
|
|
1116
|
+
*/
|
|
1117
|
+
export function withoutAnsweredQuestions(raw, answers) {
|
|
1118
|
+
const answered = new Set(Object.keys(answers).filter((id) => answers[id]?.trim()));
|
|
1119
|
+
if (answered.size === 0 || !raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
1120
|
+
return raw;
|
|
1121
|
+
const value = raw;
|
|
1122
|
+
if (!Array.isArray(value.questions))
|
|
1123
|
+
return raw;
|
|
1124
|
+
const questions = value.questions.filter((question) => {
|
|
1125
|
+
const id = question && typeof question === 'object' ? question.id : undefined;
|
|
1126
|
+
return typeof id !== 'string' || !answered.has(id);
|
|
1127
|
+
});
|
|
1128
|
+
return { ...value, questions };
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Merge a corrective-pass patch into the proposal it corrects. Pages are
|
|
1132
|
+
* matched by id: a patched page replaces its original, a new id is appended,
|
|
1133
|
+
* and listed ids are removed. Other top-level lists are replaced only when
|
|
1134
|
+
* the patch carries them. A patch that turns out to be a whole plan merges
|
|
1135
|
+
* the same way, page by page.
|
|
1136
|
+
*/
|
|
1137
|
+
export function applyPlanPatch(base, patch) {
|
|
1138
|
+
const first = record(base);
|
|
1139
|
+
const delta = record(patch);
|
|
1140
|
+
const pageId = (page) => {
|
|
1141
|
+
const id = (page && typeof page === 'object' ? page.id : undefined);
|
|
1142
|
+
return typeof id === 'string' && id.trim() ? id : undefined;
|
|
1143
|
+
};
|
|
1144
|
+
const removed = new Set(stringList(delta.removePageIds));
|
|
1145
|
+
const basePages = Array.isArray(first.pages) ? first.pages : [];
|
|
1146
|
+
const patchPages = (Array.isArray(delta.pages) ? delta.pages : []).filter((page) => pageId(page) !== undefined);
|
|
1147
|
+
const replacements = new Map(patchPages.map((page) => [pageId(page), page]));
|
|
1148
|
+
const pages = basePages
|
|
1149
|
+
.filter((page) => !removed.has(pageId(page) ?? ''))
|
|
1150
|
+
.map((page) => replacements.get(pageId(page) ?? '') ?? page);
|
|
1151
|
+
const known = new Set(basePages.map((page) => pageId(page)));
|
|
1152
|
+
for (const page of patchPages) {
|
|
1153
|
+
if (!known.has(pageId(page)) && !removed.has(pageId(page)))
|
|
1154
|
+
pages.push(page);
|
|
1155
|
+
}
|
|
1156
|
+
const next = { ...first, pages };
|
|
1157
|
+
for (const key of ['capabilities', 'navigation', 'questions', 'exclusions', 'outcomes', 'audiences', 'terminology', 'instructions', 'summary']) {
|
|
1158
|
+
if (key in delta)
|
|
1159
|
+
next[key] = delta[key];
|
|
1160
|
+
}
|
|
1161
|
+
if ('existingDocumentation' in delta)
|
|
1162
|
+
next.existingDocumentation = mergeExistingDocumentation(first.existingDocumentation, delta.existingDocumentation);
|
|
1163
|
+
return next;
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Agents resend an existing-documentation assessment with only the pages
|
|
1167
|
+
* they re-placed. Merge per source and per crawled page path, so a revision
|
|
1168
|
+
* that moves two pages does not drop the other thirty-five dispositions.
|
|
1169
|
+
*/
|
|
1170
|
+
function mergeExistingDocumentation(base, patch) {
|
|
1171
|
+
if (!Array.isArray(patch))
|
|
1172
|
+
return patch;
|
|
1173
|
+
if (!Array.isArray(base))
|
|
1174
|
+
return patch;
|
|
1175
|
+
const sourceOf = (item) => { const value = record(item).source; return typeof value === 'string' ? value : undefined; };
|
|
1176
|
+
const pathOf = (item) => { const value = record(item).path; return typeof value === 'string' ? value : undefined; };
|
|
1177
|
+
const merged = base.map((assessment) => {
|
|
1178
|
+
const update = patch.find((item) => sourceOf(item) === sourceOf(assessment));
|
|
1179
|
+
if (!update)
|
|
1180
|
+
return assessment;
|
|
1181
|
+
const previous = record(assessment);
|
|
1182
|
+
const next = record(update);
|
|
1183
|
+
const basePages = Array.isArray(previous.pages) ? previous.pages : [];
|
|
1184
|
+
const patchPages = Array.isArray(next.pages) ? next.pages : [];
|
|
1185
|
+
const byPath = new Map(patchPages.filter((page) => pathOf(page)).map((page) => [pathOf(page), page]));
|
|
1186
|
+
const pages = basePages.map((page) => byPath.get(pathOf(page) ?? '') ?? page);
|
|
1187
|
+
const known = new Set(basePages.map(pathOf));
|
|
1188
|
+
for (const page of patchPages)
|
|
1189
|
+
if (!known.has(pathOf(page)))
|
|
1190
|
+
pages.push(page);
|
|
1191
|
+
return { ...previous, ...next, pages };
|
|
1192
|
+
});
|
|
1193
|
+
for (const assessment of patch)
|
|
1194
|
+
if (!base.some((item) => sourceOf(item) === sourceOf(assessment)))
|
|
1195
|
+
merged.push(assessment);
|
|
1196
|
+
return merged;
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Persist an agent plan that failed its final planning gates, as the plan's
|
|
1200
|
+
* next reviewable version, without promoting it to review. The reviewer sees
|
|
1201
|
+
* the pages the agent proposed next to the gate it missed.
|
|
1202
|
+
*/
|
|
1203
|
+
async function persistFailedPlanCandidate(root, current, raw, agent) {
|
|
1204
|
+
const proposed = normalizePlanShape(raw, current);
|
|
1205
|
+
const { approvedAt: _approvedAt, approvedHash: _approvedHash, proposalId: _proposalId, error: _error, failure: _failure, ...unapproved } = current;
|
|
1206
|
+
const candidate = {
|
|
1207
|
+
...unapproved,
|
|
1208
|
+
...proposed,
|
|
1209
|
+
version: current.status === 'revising' ? current.version + 1 : current.version,
|
|
1210
|
+
status: 'failed',
|
|
1211
|
+
sourceSnapshot: await documentationSourceSnapshot(root),
|
|
1212
|
+
updatedAt: new Date().toISOString(),
|
|
1213
|
+
execution: { ...current.execution, agent },
|
|
1214
|
+
};
|
|
1215
|
+
await persistPlan(root, candidate, true);
|
|
1216
|
+
return candidate;
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Accept a plan the planner could not finish to its own standard. The gate it
|
|
1220
|
+
* missed becomes an advisory on the plan review, where the reviewer can edit
|
|
1221
|
+
* pages, add a screenshot guide, or approve the plan as it is.
|
|
1222
|
+
*/
|
|
1223
|
+
export async function ignoreDocumentationPlanError(root, id) {
|
|
1224
|
+
const current = await readDocumentationPlan(root, id);
|
|
1225
|
+
// A browser can render the failed-plan controls just before a background
|
|
1226
|
+
// continuation finishes and persists `generated`. Treat that stale click as
|
|
1227
|
+
// an idempotent success so the client can reload the completed state.
|
|
1228
|
+
if (current.status === 'generated')
|
|
1229
|
+
return current;
|
|
1230
|
+
if (current.status !== 'failed')
|
|
1231
|
+
throw new DoxloopError(`Plan ${id} is ${current.status}, so there is no failure to ignore.`);
|
|
1232
|
+
if (current.failure?.stage === 'generate') {
|
|
1233
|
+
throw new DoxloopError('Generation failures are continued with continueDocumentationPlanGeneration, which keeps the generated files.');
|
|
1234
|
+
}
|
|
1235
|
+
if (!current.failure?.ignorable || current.pages.length === 0) {
|
|
1236
|
+
throw new DoxloopError('The planner did not leave a plan behind to review. Retry planning instead.');
|
|
1237
|
+
}
|
|
1238
|
+
const { error, failure: _failure, ...candidate } = current;
|
|
1239
|
+
const advisory = error ? `Accepted for review despite a planning problem: ${error}` : undefined;
|
|
1240
|
+
const next = {
|
|
1241
|
+
...candidate,
|
|
1242
|
+
status: candidate.questions.length > 0 ? 'needs-input' : 'ready-for-review',
|
|
1243
|
+
updatedAt: new Date().toISOString(),
|
|
1244
|
+
...(advisory ? { advisories: [...(candidate.advisories ?? []).filter((item) => item !== advisory), advisory] } : {}),
|
|
1245
|
+
};
|
|
1246
|
+
await persistPlan(root, next, false);
|
|
1247
|
+
return next;
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Continue a plan whose generation failed, from the proposal workspace that
|
|
1251
|
+
* failure preserved. `resume` starts the agent again in that workspace with a
|
|
1252
|
+
* brief of what already exists, so finished pages and verified screenshots are
|
|
1253
|
+
* kept; `ignore-errors` accepts the existing output for review with every
|
|
1254
|
+
* screenshot problem recorded instead of enforced. Neither starts over.
|
|
1255
|
+
*/
|
|
1256
|
+
export async function continueDocumentationPlanGeneration(root, id, strategy) {
|
|
1257
|
+
let plan = await readDocumentationPlan(root, id);
|
|
1258
|
+
// Continuation can finish between the UI rendering its recovery controls
|
|
1259
|
+
// and the reviewer clicking one. The requested outcome already exists.
|
|
1260
|
+
if (plan.status === 'generated')
|
|
1261
|
+
return plan;
|
|
1262
|
+
const proposalId = plan.failure?.proposalId;
|
|
1263
|
+
if (plan.status !== 'failed' && plan.status !== 'generating') {
|
|
1264
|
+
throw new DoxloopError(`Plan ${id} is ${plan.status}, so there is no interrupted generation to continue.`);
|
|
1265
|
+
}
|
|
1266
|
+
if (plan.failure?.stage !== 'generate' || !proposalId) {
|
|
1267
|
+
throw new DoxloopError('This plan has no preserved generation workspace to continue. Retry generating it instead.');
|
|
1268
|
+
}
|
|
1269
|
+
if (!plan.approvedHash || planHash(plan) !== plan.approvedHash) {
|
|
1270
|
+
throw new DoxloopError('The approved plan content changed. Review and approve it again.');
|
|
1271
|
+
}
|
|
1272
|
+
const run = await readSyncRun(root, proposalId);
|
|
1273
|
+
if (run.archivedAt || !(await pathExists(runWorkspace(root, proposalId)))) {
|
|
1274
|
+
throw new DoxloopError('The generation workspace for this plan is no longer preserved. Retry generating it instead.');
|
|
1275
|
+
}
|
|
1276
|
+
if (strategy === 'resume' && run.recovery?.resumable === false) {
|
|
1277
|
+
throw new DoxloopError('This proposal cannot be resumed. Accept its output with problems ignored, or retry generating it.');
|
|
1278
|
+
}
|
|
1279
|
+
const { error: _error, ...retained } = plan;
|
|
1280
|
+
plan = { ...retained, status: 'generating', updatedAt: new Date().toISOString() };
|
|
1281
|
+
await persistPlan(root, plan, false);
|
|
1282
|
+
const label = strategy === 'resume' ? 'Continuing the interrupted authoring run' : 'Accepting the generated files with reported problems ignored';
|
|
1283
|
+
try {
|
|
1284
|
+
emitWorkflowStage('inspecting-sources', 'Confirming approved evidence', 'completed');
|
|
1285
|
+
// A resumed agent run announces and advances its own stages.
|
|
1286
|
+
if (strategy !== 'resume') {
|
|
1287
|
+
emitWorkflowStage('authoring-pages', label, 'running');
|
|
1288
|
+
emitWorkflowStage('validating', 'Validating generated documentation', 'running');
|
|
1289
|
+
}
|
|
1290
|
+
const proposal = strategy === 'resume'
|
|
1291
|
+
? await resumeSyncRun(root, proposalId, { fallbackAuthoring: await planAuthoringRecord(root, plan) })
|
|
1292
|
+
: await recoverSyncRun(root, proposalId, { ignoreScreenshotProblems: true });
|
|
1293
|
+
if (proposal.status === 'failed') {
|
|
1294
|
+
throw new DoxloopError(proposal.error ?? 'Documentation generation failed.');
|
|
1295
|
+
}
|
|
1296
|
+
emitWorkflowStage('authoring-pages', label, 'completed');
|
|
1297
|
+
if (strategy === 'resume' || proposal.screenshots) {
|
|
1298
|
+
const ignored = proposal.screenshots?.ignoredProblems ?? 0;
|
|
1299
|
+
emitWorkflowStage('capturing-screenshots', ignored > 0
|
|
1300
|
+
? `Accepted ${proposal.screenshots?.captured ?? 0} screenshot${proposal.screenshots?.captured === 1 ? '' : 's'} with ${ignored} problem${ignored === 1 ? '' : 's'} ignored`
|
|
1301
|
+
: proposal.screenshots?.status === 'verified'
|
|
1302
|
+
? `Verified ${proposal.screenshots.captured} application screenshot${proposal.screenshots.captured === 1 ? '' : 's'}`
|
|
1303
|
+
: 'Application screenshots were not captured', 'completed');
|
|
1304
|
+
}
|
|
1305
|
+
emitWorkflowStage('validating', 'Validating generated documentation', 'completed');
|
|
1306
|
+
emitWorkflowStage('preparing-proposal', 'Preparing review proposal', 'completed');
|
|
1307
|
+
const { failure: _failure, ...generatedPlan } = plan;
|
|
1308
|
+
plan = {
|
|
1309
|
+
...generatedPlan,
|
|
1310
|
+
status: 'generated',
|
|
1311
|
+
proposalId: proposal.id,
|
|
1312
|
+
updatedAt: new Date().toISOString(),
|
|
1313
|
+
};
|
|
1314
|
+
await persistPlan(root, plan, false);
|
|
1315
|
+
return plan;
|
|
1316
|
+
}
|
|
1317
|
+
catch (error) {
|
|
1318
|
+
plan = {
|
|
1319
|
+
...plan,
|
|
1320
|
+
status: 'failed',
|
|
1321
|
+
updatedAt: new Date().toISOString(),
|
|
1322
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1323
|
+
failure: {
|
|
1324
|
+
stage: 'generate',
|
|
1325
|
+
proposalId,
|
|
1326
|
+
resumable: await pathExists(runWorkspace(root, proposalId)),
|
|
1327
|
+
ignorable: await pathExists(runWorkspace(root, proposalId)),
|
|
1328
|
+
},
|
|
1329
|
+
};
|
|
1330
|
+
await persistPlan(root, plan, false);
|
|
1331
|
+
throw error;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Run the planning agent and read its plan. A reply that stops short of its
|
|
1336
|
+
* closing brackets is closed deterministically and the fix reported on the
|
|
1337
|
+
* review (see `closeObjectAt`); an object that is cut off or malformed inside
|
|
1338
|
+
* is retried once with the exact defect quoted back, because agents lose a
|
|
1339
|
+
* brace often enough that failing the whole plan on the first bad reply
|
|
1340
|
+
* wastes the entire run. Nothing is ever truncated to make a reply parse,
|
|
1341
|
+
* since that would drop planned pages without telling anyone.
|
|
1342
|
+
*/
|
|
1343
|
+
async function planFromAgent(root, selected, prompt, execution, project, options = {}) {
|
|
1344
|
+
let lastError;
|
|
1345
|
+
const tag = options.patchOf !== undefined ? 'doxloop-plan-patch' : 'doxloop-plan';
|
|
1346
|
+
for (const attempt of [0, 1]) {
|
|
1347
|
+
const text = attempt === 0 ? prompt : `${prompt}
|
|
1348
|
+
|
|
1349
|
+
Your previous reply could not be read as a documentation ${options.patchOf !== undefined ? 'plan patch' : 'plan'}: ${lastError instanceof Error ? lastError.message : String(lastError)}
|
|
1350
|
+
|
|
1351
|
+
Send it again as one complete, strictly valid JSON object inside the <${tag}> block. Check that every brace and bracket you open is closed, that the object ends with its own closing brace immediately before </${tag}>, and that the block contains only that object.`;
|
|
1352
|
+
const prepared = await prepareAgentPrompt(root, text);
|
|
1353
|
+
let output = '';
|
|
1354
|
+
try {
|
|
1355
|
+
output = await runAgentForPlan(root, selected, prepared.argument, execution, project, { browser: options.browser !== false });
|
|
1356
|
+
}
|
|
1357
|
+
finally {
|
|
1358
|
+
if (prepared.path) {
|
|
1359
|
+
const { rm } = await import('node:fs/promises');
|
|
1360
|
+
await rm(prepared.path, { force: true });
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
try {
|
|
1364
|
+
const patch = options.patchOf !== undefined ? readPlanPatchOutput(output, selected.name, text) : undefined;
|
|
1365
|
+
const reply = patch
|
|
1366
|
+
? { plan: applyPlanPatch(options.patchOf, patch.value), repairs: patch.repairs }
|
|
1367
|
+
: readPlanOutput(output, selected.name, text);
|
|
1368
|
+
for (const repair of reply.repairs)
|
|
1369
|
+
process.stdout.write(`${repair}\n`);
|
|
1370
|
+
return reply;
|
|
1371
|
+
}
|
|
1372
|
+
catch (error) {
|
|
1373
|
+
lastError = error;
|
|
1374
|
+
budgetContext.getStore()?.assertAvailable();
|
|
1375
|
+
if (attempt === 1)
|
|
1376
|
+
throw error;
|
|
1377
|
+
process.stdout.write(`The plan reply could not be read: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1378
|
+
emitWorkflowStage('building-coverage', 'Retrying an unreadable plan reply', 'running');
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
throw lastError;
|
|
1382
|
+
}
|
|
1383
|
+
export const DEFAULT_PLANNING_TIMEOUT_MINUTES = 20;
|
|
1384
|
+
/**
|
|
1385
|
+
* How long a planning agent may run before Doxloop stops it. The environment
|
|
1386
|
+
* variable wins over the project budget so a CI job can tighten or relax it
|
|
1387
|
+
* without editing the project.
|
|
1388
|
+
*/
|
|
1389
|
+
export function planningTimeoutMinutes(project, env = process.env) {
|
|
1390
|
+
return explicitPlanningTimeoutMinutes(project, env) ?? DEFAULT_PLANNING_TIMEOUT_MINUTES;
|
|
1391
|
+
}
|
|
1392
|
+
function explicitPlanningTimeoutMinutes(project, env) {
|
|
1393
|
+
const raw = env.DOXLOOP_PLAN_TIMEOUT_MINUTES;
|
|
1394
|
+
if (raw !== undefined && raw.trim() !== '') {
|
|
1395
|
+
const parsed = Number(raw);
|
|
1396
|
+
if (Number.isFinite(parsed) && parsed > 0)
|
|
1397
|
+
return parsed;
|
|
1398
|
+
}
|
|
1399
|
+
return project.sync.budget?.maxMinutes;
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* How long one planning pass may run for a batch. A limit the user set
|
|
1403
|
+
* (environment or monitoring budget) is honoured and capped by the batch's
|
|
1404
|
+
* minutes; otherwise the batch's own minutes apply, never less than the
|
|
1405
|
+
* default, so a comprehensive plan is not cut off at a starter's deadline.
|
|
1406
|
+
*/
|
|
1407
|
+
export function planningTimeoutForBatch(project, limits, env = process.env) {
|
|
1408
|
+
const explicit = explicitPlanningTimeoutMinutes(project, env);
|
|
1409
|
+
if (explicit !== undefined)
|
|
1410
|
+
return Math.min(explicit, limits.maxMinutes);
|
|
1411
|
+
return Math.max(DEFAULT_PLANNING_TIMEOUT_MINUTES, limits.maxMinutes);
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Research sessions that all stopped on the agent's sign-in or account are
|
|
1415
|
+
* not "missing briefs to retry": name the cause and the next step instead.
|
|
1416
|
+
*/
|
|
1417
|
+
export function researchBlockedError(error, blocker) {
|
|
1418
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1419
|
+
const count = /^(A research session|\d+ research sessions) did not finish/.exec(message)?.[1] ?? 'Research';
|
|
1420
|
+
return new DoxloopError(`${count} did not finish: ${blocker.message}`);
|
|
1421
|
+
}
|
|
1422
|
+
export function planningTimeoutMessage(minutes, agent) {
|
|
1423
|
+
return `Planning stopped after ${formatMinutes(minutes)} without a plan reply from ${agent}. Increase the plan time limit and, if configured, the monitoring time budget, then retry planning. DOXLOOP_PLAN_TIMEOUT_MINUTES can impose an additional planning limit.`;
|
|
1424
|
+
}
|
|
1425
|
+
function formatMinutes(minutes) {
|
|
1426
|
+
if (Number.isInteger(minutes))
|
|
1427
|
+
return `${minutes} minute${minutes === 1 ? '' : 's'}`;
|
|
1428
|
+
const seconds = Math.round(minutes * 60);
|
|
1429
|
+
return seconds < 60 ? `${seconds} second${seconds === 1 ? '' : 's'}` : `${Math.round(minutes * 10) / 10} minutes`;
|
|
1430
|
+
}
|
|
1431
|
+
/**
|
|
1432
|
+
* Planning is an inventory and grouping task over evidence Doxloop has
|
|
1433
|
+
* already extracted; extra reasoning effort mostly buys slower turns. The
|
|
1434
|
+
* run's effort still applies to authoring, where depth matters.
|
|
1435
|
+
*/
|
|
1436
|
+
export function planningEffort(effort) {
|
|
1437
|
+
return effort === 'high' || effort === 'max' ? 'medium' : effort;
|
|
1438
|
+
}
|
|
1439
|
+
async function runAgentForPlan(root, selected, prompt, execution, project, options = {}) {
|
|
1440
|
+
const budget = budgetContext.getStore();
|
|
1441
|
+
budget?.assertAvailable();
|
|
1442
|
+
const screenshotIntent = normalizeScreenshotIntent(execution.screenshots);
|
|
1443
|
+
const label = options.label ?? 'planning';
|
|
1444
|
+
const prefix = options.prefix ?? '';
|
|
1445
|
+
const wantsBrowser = options.browser !== false && screenshotIntent !== 'disabled' && Boolean(project.application);
|
|
1446
|
+
// Planning explores the signed-in application read-only, so it gets the
|
|
1447
|
+
// same session and credentials the authoring run will use.
|
|
1448
|
+
const captureMaterial = wantsBrowser
|
|
1449
|
+
? await prepareCaptureAuth(root)
|
|
1450
|
+
: undefined;
|
|
1451
|
+
const captureProvider = wantsBrowser && project.application
|
|
1452
|
+
? screenCaptureProvider(root, project.application, captureMaterial, budget ? join(dirname(budget.file), 'captures') : undefined)
|
|
1453
|
+
: undefined;
|
|
1454
|
+
if (selected.name === 'gemini')
|
|
1455
|
+
await writeGeminiCaptureSettings(root, captureProvider);
|
|
1456
|
+
const args = agentArguments(selected.name, prompt, {
|
|
1457
|
+
mode: 'review',
|
|
1458
|
+
...(execution.model ? { model: execution.model } : {}),
|
|
1459
|
+
...(execution.reasoning ? { reasoning: execution.reasoning } : {}),
|
|
1460
|
+
...(execution.effort ? { effort: planningEffort(execution.effort) } : {}),
|
|
1461
|
+
...(budget?.remainingUsd !== undefined ? { maxBudgetUsd: budget.remainingUsd } : project.sync.budget?.maxUsd ? { maxBudgetUsd: project.sync.budget.maxUsd } : {}),
|
|
1462
|
+
sourceDirectories: sourceAccessDirectories(root, project.sources),
|
|
1463
|
+
...(captureProvider ? { captureProvider } : {}),
|
|
1464
|
+
captureRequired: wantsBrowser && screenshotIntent === 'enabled',
|
|
1465
|
+
...(selected.name === 'codex' ? { userMcpServers: await codexUserMcpServers(root) } : {}),
|
|
1466
|
+
});
|
|
1467
|
+
const timeoutMinutes = planningTimeoutForBatch(project, batchLimits(execution.limits));
|
|
1468
|
+
return new Promise((resolveOutput, reject) => {
|
|
1469
|
+
const agent = spawnAgentProcess(selected.executable, args, {
|
|
1470
|
+
cwd: root,
|
|
1471
|
+
env: agentEnvironment(selected.name),
|
|
1472
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1473
|
+
isolate: true,
|
|
1474
|
+
});
|
|
1475
|
+
const budgetSession = budget?.register(() => { void agent.stop(); });
|
|
1476
|
+
const { child } = agent;
|
|
1477
|
+
// The raw stream is kept for plan extraction; the reader sees the same
|
|
1478
|
+
// one-line activity summaries the authoring run shows.
|
|
1479
|
+
const formatter = createAgentLogFormatter(selected.name);
|
|
1480
|
+
let stdout = '';
|
|
1481
|
+
child.stdout?.on('data', (chunk) => {
|
|
1482
|
+
stdout += chunk.toString();
|
|
1483
|
+
for (const line of formatter.push(chunk))
|
|
1484
|
+
process.stdout.write(`${prefix}${line}\n`);
|
|
1485
|
+
if (budgetSession)
|
|
1486
|
+
budget?.update(budgetSession, formatter.usage, formatter.stopReason);
|
|
1487
|
+
});
|
|
1488
|
+
child.stdout?.once('end', () => {
|
|
1489
|
+
for (const line of formatter.finish())
|
|
1490
|
+
process.stdout.write(`${prefix}${line}\n`);
|
|
1491
|
+
});
|
|
1492
|
+
// The tail of stderr names the cause when the agent fails before it
|
|
1493
|
+
// streams a result event (a Codex sign-in or configuration error).
|
|
1494
|
+
let stderrTail = '';
|
|
1495
|
+
child.stderr?.on('data', (chunk) => {
|
|
1496
|
+
process.stderr.write(chunk.toString());
|
|
1497
|
+
stderrTail = `${stderrTail}${chunk.toString()}`.slice(-4_000);
|
|
1498
|
+
if (budgetSession && isAccountLimit(chunk.toString()))
|
|
1499
|
+
budget?.update(budgetSession, formatter.usage, chunk.toString());
|
|
1500
|
+
});
|
|
1501
|
+
// A planner thinking for minutes or streaming a long plan prints nothing
|
|
1502
|
+
// in between; the heartbeat names what it is doing so the quiet reads as
|
|
1503
|
+
// progress rather than a hang.
|
|
1504
|
+
const pulse = setInterval(() => {
|
|
1505
|
+
for (const line of formatter.heartbeat())
|
|
1506
|
+
process.stdout.write(`${prefix}${line}\n`);
|
|
1507
|
+
}, AGENT_LOG_HEARTBEAT_MS / 2);
|
|
1508
|
+
pulse.unref?.();
|
|
1509
|
+
// A planner that never answers used to run until someone noticed; the
|
|
1510
|
+
// budget turns that into a named failure the reviewer can act on.
|
|
1511
|
+
let timedOut = false;
|
|
1512
|
+
const timer = setTimeout(() => {
|
|
1513
|
+
timedOut = true;
|
|
1514
|
+
process.stderr.write(`Stopping ${selected.name} ${label} after ${formatMinutes(timeoutMinutes)}.\n`);
|
|
1515
|
+
void agent.stop();
|
|
1516
|
+
}, timeoutMinutes * 60_000);
|
|
1517
|
+
timer.unref?.();
|
|
1518
|
+
const stopForwarding = forwardTerminationSignals(agent, { label: `${selected.name} ${label}` });
|
|
1519
|
+
void agent.exited.then(async (exit) => {
|
|
1520
|
+
clearTimeout(timer);
|
|
1521
|
+
clearInterval(pulse);
|
|
1522
|
+
stopForwarding();
|
|
1523
|
+
await captureMaterial?.cleanup();
|
|
1524
|
+
if (budgetSession)
|
|
1525
|
+
await budget?.finish(budgetSession, formatter.usage, formatter.stopReason);
|
|
1526
|
+
if (budget?.stoppedReason)
|
|
1527
|
+
reject(new DoxloopError(budget.stoppedReason));
|
|
1528
|
+
else if (exit.error)
|
|
1529
|
+
reject(exit.error);
|
|
1530
|
+
else if (timedOut)
|
|
1531
|
+
reject(new DoxloopError(planningTimeoutMessage(timeoutMinutes, selected.name)));
|
|
1532
|
+
else if (exit.signal)
|
|
1533
|
+
reject(new DoxloopError(`${selected.name} ${label} was stopped by ${exit.signal}.`));
|
|
1534
|
+
else if (exit.code !== 0) {
|
|
1535
|
+
const detail = agentFailureDetail(formatter.stopReason, stderrTail);
|
|
1536
|
+
reject(detail
|
|
1537
|
+
? new AgentSessionError(selected.name, label, exit.code ?? 1, detail)
|
|
1538
|
+
: new DoxloopError(`${selected.name} ${label} exited with status ${exit.code ?? 1}.`));
|
|
1539
|
+
}
|
|
1540
|
+
else
|
|
1541
|
+
resolveOutput(stdout);
|
|
1542
|
+
}).catch(reject);
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
export function screenshotPlanningInstructions(briefed = false) {
|
|
1546
|
+
const preamble = briefed
|
|
1547
|
+
? `Doxloop's application research session already explored the live application for this run; its brief below lists every screen it reached, what each shows, and the states that open from it. Plan from that brief only: a state the brief does not list is not plannable, and you must not browse, sign in, or explore the application yourself. Do not invent fixture data, authenticated state, prepared plans, proposals, or controls merely because source code or tests mention them.`
|
|
1548
|
+
: `Doxloop supplies the purpose-built doxloop_capture MCP browser for this run. It is the capture connector required by this task and must take precedence over any generic in-app Browser plugin, Chrome extension, or node_repl browser mechanism. Call the doxloop_capture navigation and snapshot tools directly before deciding visual states; do not use failure of another browser mechanism as evidence that Doxloop capture is unavailable. Do not save PNG files during planning. Plan from states that are actually visible in the live application; do not invent fixture data, authenticated state, prepared plans, proposals, or controls merely because source code or tests mention them.
|
|
1549
|
+
Explore the application with a fixed budget before you decide what is visible: at most 12 doxloop_capture calls in total during planning, and at most one snapshot per distinct screen. The source's route, navigation, and UI label definitions are the primary inventory of screens; the browser confirms that they exist and what they show. Open the starting route, then visit each top-level navigation destination once and snapshot it; open a dialog, tab, panel, or disclosure only when the source cannot tell you what it contains. Many control centers are single-page applications where every screen shares one URL, so the absence of a second route is never evidence that a screen is unreachable. Interaction that only reveals an existing screen is safe and expected. Do not fill forms, submit, create, delete, deploy, publish, send, or otherwise change data, and back out of any control that would. Record a state as unavailable only after you attempted to reach it within the budget, and say which control or screen stopped you.
|
|
1550
|
+
Screenshot readiness, authentication, fixture data, routes, and application preparation are run preconditions—not documentation-scope decisions. Never add a plan question solely about preparing, restarting, signing in to, or populating the capture application. If a desired state is not visibly available after you tried to reach it through in-app navigation, omit that state and use another evidence-supported, reachable state; do not trigger a second planning pass to ask the user to manufacture it.`;
|
|
1551
|
+
return `${preamble}
|
|
1552
|
+
Plan screenshots only for visible application workflows where an image materially reduces ambiguity or proves an important state. Never plan decorative screenshots for API, CLI, concept, or reference-only pages. For a UI guide, plan one capture for every reader step that changes what is on screen: the entry screen, each dialog, drawer, tab, expanded section, or form the step opens, the form once it is filled with safe example values, and the visible result or confirmation. Readers follow a guide screen by screen, so a guide whose steps open four screens plans four captures. A primary how-to guide therefore normally plans 4–7 captures and a tutorial 5–8; plan fewer only when the application genuinely exposes fewer distinct states and say so in the workflow. When intent is disabled, every page must use visuals.mode "none". When intent is enabled, cover the visible UI surface in proportion to it: every planned page that documents a screen or workflow you actually reached needs its own screenshot-enabled guide marked "required". One guide is enough only when the application genuinely exposes one screen; planning a single capture for an application whose screens you did not attempt to reach is a failed plan, not a conservative one. In automatic mode, use "recommended" only when the project policy and user request allow capture. Treat the user-provided route as the default boundary; specialize it per guide only when the live application, configured source routes, or UI tests provide evidence. Each capture-sequence item must be a state that looks different from the one before it — a different screen, an opened dialog, an expanded section, a filled form, or a result. Scrolling, focusing a field, hovering, or merely inspecting part of a screen that is already visible is not a new state and must never be its own capture item, because it produces an identical image and Doxloop consolidates it away. When a guide's workflow only ever shows one screen, plan one capture for it rather than padding the sequence to a larger number.`;
|
|
1553
|
+
}
|
|
1554
|
+
function docsSiteSources(sources) {
|
|
1555
|
+
return sources.filter((source) => (source.kind ?? 'directory') === 'docs-site');
|
|
1556
|
+
}
|
|
1557
|
+
/**
|
|
1558
|
+
* When an existing documentation site is configured, the plan is also an
|
|
1559
|
+
* audit of it: the reviewer sees coverage, gaps, contradictions, and where
|
|
1560
|
+
* every old page lands before any page is rewritten. Product sources decide
|
|
1561
|
+
* facts; without them the plan may restructure but not invent.
|
|
1562
|
+
*/
|
|
1563
|
+
export function existingDocumentationPlanningInstructions(sources, discovery, briefed = false) {
|
|
1564
|
+
const sites = docsSiteSources(sources);
|
|
1565
|
+
if (sites.length === 0)
|
|
1566
|
+
return '';
|
|
1567
|
+
const productSources = sources.filter((source) => (source.kind ?? 'directory') !== 'docs-site');
|
|
1568
|
+
const siteLines = sites.map((source) => {
|
|
1569
|
+
const inventory = discovery.sources.find((item) => item.name === source.name);
|
|
1570
|
+
const pages = inventory?.filesAvailable ?? source.site?.pages ?? 0;
|
|
1571
|
+
return `- "${source.name}": ${source.site?.url ?? source.path}, crawled into the read-only Markdown snapshot at ${source.path} (${pages} pages${source.site?.generator ? `, ${source.site.generator}` : ''}). Read its index.md first: it lists every page with its title, word count, and original URL, plus broken links and crawl warnings. Open page files under pages/ to judge their content.`;
|
|
1572
|
+
});
|
|
1573
|
+
const factRule = productSources.length > 0
|
|
1574
|
+
? `Product sources (${productSources.map((source) => `"${source.name}"`).join(', ')}) are the truth for facts. Where an existing page contradicts them, list the claim under coverage.contradicted and plan the corrected page; where it describes behavior no product source shows, decide whether it is obsolete (coverage.obsolete) or knowledge code cannot show that the rewrite must keep (coverage.preserved). Product surfaces in the deterministic inventory that no existing page covers belong in coverage.gaps and need a planned page.`
|
|
1575
|
+
: `No product code or API specification is configured, so the existing documentation is the only product evidence. Plan a restructure and rewrite of what it already says: do not plan pages whose facts the crawled pages cannot support, leave coverage.gaps and coverage.contradicted empty, and note in "instructions" that every page is written with inferred confidence until a product source is connected.`;
|
|
1576
|
+
return `Existing documentation to rewrite (docs-site sources):
|
|
1577
|
+
${siteLines.join('\n')}
|
|
1578
|
+
This run rewrites that documentation as a new professional documentation set. ${briefed ? 'The existing-documentation research briefs below already audit every crawled page (summary, quality, unique knowledge, suspect claims); base your assessment and page dispositions on them, and read a crawled page only to settle a specific suspect claim. ' : 'Audit it before planning: read enough pages to judge accuracy, structure, depth, duplication, terminology, and reader journeys. '}${factRule} Give every crawled page a disposition, identified by its snapshot path only (Doxloop adds titles and URLs from the snapshot): "rewrite" when one planned page replaces it, "merge" when it is absorbed into a planned page with other pages, "preserve" when its content is carried over largely as it stands, or "drop" with the reason when its content is obsolete or duplicated. A page that is dropped must not lose knowledge the product still has. Cite docs-site pages in evidenceDetails with kind "documentation" and the snapshot-relative file path, alongside product-source evidence. Keep the assessment compact: summary in at most 400 characters, each finding description in at most 200, each disposition reason in at most 120.
|
|
1579
|
+
`;
|
|
1580
|
+
}
|
|
1581
|
+
export function existingDocumentationPlanShape(sources) {
|
|
1582
|
+
if (docsSiteSources(sources).length === 0)
|
|
1583
|
+
return '';
|
|
1584
|
+
return `,
|
|
1585
|
+
"existingDocumentation": [{
|
|
1586
|
+
"source": "docs-site source name",
|
|
1587
|
+
"summary": "how well the existing documentation serves readers today and what the rewrite changes",
|
|
1588
|
+
"strengths": ["what the existing documentation does well and the rewrite keeps"],
|
|
1589
|
+
"findings": [{ "severity": "blocker | major | minor", "title": "short problem title", "description": "evidence-based problem in the existing documentation", "pages": ["pages/existing-page.md"] }],
|
|
1590
|
+
"coverage": {
|
|
1591
|
+
"gaps": ["product surface found in code that the existing documentation never covers"],
|
|
1592
|
+
"obsolete": ["existing page or claim describing behavior the product no longer has"],
|
|
1593
|
+
"preserved": ["existing knowledge code cannot show that the rewrite carries over"],
|
|
1594
|
+
"contradicted": ["existing claim the product sources contradict; the rewrite corrects it"]
|
|
1595
|
+
},
|
|
1596
|
+
"pages": [{ "path": "pages/existing-page.md", "disposition": "rewrite | merge | preserve | drop", "into": ["planned-page-id"], "reason": "why this page lands there" }]
|
|
1597
|
+
}]`;
|
|
1598
|
+
}
|
|
1599
|
+
function planningPrompt(project, current, discovery, changes, feedback, reviewerGuidance = 'No prior reviewer preferences have been recorded.', captureAuth = 'none', briefs, navigationText) {
|
|
1600
|
+
const briefed = Boolean(briefs);
|
|
1601
|
+
const researchScope = current.mode === 'create' ? 'product' : (current.research?.scope ?? 'product');
|
|
1602
|
+
const roleIntro = researchScope === 'navigation'
|
|
1603
|
+
? 'You are the planning stage of Doxloop. This update changes only navigation, icons, ordering, group names, branding, or page metadata; no page content changes. Do not read product sources, crawled documentation pages, or page files, and do not browse the application: the current navigation below is everything you need. Return a plan whose "pages" list the existing pages the change touches with action "preserve" (nothing is rewritten), and put the complete, exact change in "workspaceInstructions": for icons, name the icon for every page and group by its label; for ordering, give the full order; for renames, old and new labels. Use only icon names the generator can draw. Keep "capabilities" empty and "existingDocumentation" absent.'
|
|
1604
|
+
: researchScope === 'pages'
|
|
1605
|
+
? `You are the planning stage of Doxloop. This update concerns only the existing pages it names${current.research?.pages.length ? ` (${current.research.pages.join(', ')})` : ''}; the research brief below audits the product surface behind them. Plan those pages with action "update" and add a page only when the request cannot be satisfied without it. Do not re-plan, re-audit, or re-list the rest of the documentation, do not browse the application, and read a source file only to settle a specific contradiction in the brief.`
|
|
1606
|
+
: briefed
|
|
1607
|
+
? 'You are the planning stage of Doxloop. Research sessions have already read the configured sources, explored the application, and audited the existing documentation for this run; their briefs are included below. Synthesize a complete documentation coverage plan from them: do not browse the application, and read a source file only to settle a specific contradiction between briefs.'
|
|
1608
|
+
: 'You are the planning stage of Doxloop. Research the configured product evidence and existing documentation, then return a complete documentation coverage plan.';
|
|
1609
|
+
const targetPagesText = current.targetPages
|
|
1610
|
+
? ` The reviewer requires at least ${Math.min(current.targetPages, batchLimits(current.execution.limits).maxPages)} pages to write (create or update). Reach that count with distinct, evidence-backed reader jobs — split large workflows, reference surfaces, and concept areas into focused pages rather than padding — and record a "Scope exception:" exclusion only if the configured evidence genuinely cannot support that many distinct pages.`
|
|
1611
|
+
: '';
|
|
1612
|
+
const limits = batchLimits(current.execution.limits);
|
|
1613
|
+
const limitsText = hasPageLimit(limits)
|
|
1614
|
+
? `HARD BATCH LIMITS: ${JSON.stringify(limits)}. Defer other work with priority later. These maxima override scope size recommendations.`
|
|
1615
|
+
: `There is no page limit for this run: the evidence sets the size. Plan every page the product's public surface supports and never merge distinct reader jobs to keep the plan small; equally, never add a page the evidence does not support. Screenshot maximum for the run: ${limits.maxScreenshots}; time per attempt: ${limits.maxMinutes} minutes.`;
|
|
1616
|
+
const initialRequest = current.mode === 'create'
|
|
1617
|
+
? `Propose a ${current.scope} documentation plan for this create request.${targetPagesText} ${limitsText}`
|
|
1618
|
+
: researchScope === 'navigation'
|
|
1619
|
+
? 'Propose a navigation-only plan for this update request: no page is written, so batch limits do not apply beyond listing the touched pages.'
|
|
1620
|
+
: `Propose a focused documentation plan for this update request. Let the requested change and existing documentation determine its size.${targetPagesText} ${limitsText}`;
|
|
1621
|
+
const revision = feedback
|
|
1622
|
+
? `Revise the existing plan below according to the user's feedback. Preserve good decisions that the feedback does not affect.\n\nUser feedback:\n${feedback}\n\n${planRevisionPatchInstructions()}\n\nExisting plan:\n${JSON.stringify(current)}`
|
|
1623
|
+
: `${initialRequest}\n\nUser request:\n${current.request || 'Use the configured evidence and documentation brief to recommend the right documentation.'}`;
|
|
1624
|
+
const screenshotIntent = normalizeScreenshotIntent(current.execution.screenshots);
|
|
1625
|
+
const screenshotPolicy = project.application?.screenshots?.policy ?? 'requested';
|
|
1626
|
+
const questionRule = current.clarification.mode === 'defaults'
|
|
1627
|
+
? '- The reviewer asked you to decide open questions by their safe default instead of asking. Return an empty "questions" array: make each such decision yourself, apply it to the plan, and record it in one sentence in "instructions" so the reviewer can see what you assumed.'
|
|
1628
|
+
: '- Use at most three questions, only when the answer materially changes scope or reader outcomes.';
|
|
1629
|
+
return `${roleIntro} Do not edit any file, do not author reader-facing documentation, and do not ask questions in prose. Do not read the skill files under .agents/skills or .claude/skills: they guide authoring, and this brief is complete for planning.
|
|
1630
|
+
|
|
1631
|
+
${revision}
|
|
1632
|
+
|
|
1633
|
+
Project configuration (compact JSON):
|
|
1634
|
+
${JSON.stringify(project)}
|
|
1635
|
+
|
|
1636
|
+
Application screenshot decision:
|
|
1637
|
+
- Run intent: ${screenshotIntent}
|
|
1638
|
+
- Project policy: ${screenshotPolicy}
|
|
1639
|
+
- Application capture surface: ${project.application ? project.application.baseUrl : 'not configured'}
|
|
1640
|
+
- User-provided default starting route: ${project.application?.screenshots?.startPath ?? 'not provided'}
|
|
1641
|
+
- User-provided capture workflow: ${project.application?.screenshots?.workflow ?? 'not provided'}
|
|
1642
|
+
- Sign-in handling: ${project.application ? captureAuthPrompt(captureAuth) : 'not applicable'}
|
|
1643
|
+
${screenshotPlanningInstructions(briefed)}
|
|
1644
|
+
|
|
1645
|
+
Generator-neutral planning target (the adapter owns these navigation boundaries):
|
|
1646
|
+
${JSON.stringify(current.target)}
|
|
1647
|
+
|
|
1648
|
+
Deterministic source discovery (trusted inventory produced by Doxloop; agent inferences must remain distinguishable):
|
|
1649
|
+
${formatDiscoveryInventory(discovery)}
|
|
1650
|
+
${discoveryGuidance(discovery)}
|
|
1651
|
+
Deterministic source-change summary:
|
|
1652
|
+
${changes}
|
|
1653
|
+
${navigationText ? `\nCurrent navigation (Doxloop read it from the workspace; this is the whole evidence for a navigation-only plan):\n${navigationText}\n\n` : ''}${briefs ? `\n${briefs}\n\n` : ''}${researchScope === 'product' ? existingDocumentationPlanningInstructions(project.sources, discovery, briefed) : ''}${templateInstructions(current)}
|
|
1654
|
+
Durable reviewer preferences from prior revisions, rejections, and inline edits. Apply them only when they remain compatible with current evidence and this request:
|
|
1655
|
+
${reviewerGuidance}
|
|
1656
|
+
|
|
1657
|
+
${feedback ? 'Reply with the <doxloop-plan-patch> block described under the user feedback above, not with a whole plan. Every page you send in it must use the page shape from this reference object:' : `End your reply with exactly one machine-readable block and put nothing after it. Do not use Markdown fences inside the block, and never emit this block around an example, a file you read, or anything other than your final plan. The block holds one complete top-level object: close every bracket you open, and make the object's own closing brace the last character before </doxloop-plan>, with no other tag or text after it:
|
|
1658
|
+
<doxloop-plan>
|
|
1659
|
+
{ the single JSON object described below }
|
|
1660
|
+
</doxloop-plan>
|
|
1661
|
+
|
|
1662
|
+
The JSON object must use this exact shape:`}
|
|
1663
|
+
{
|
|
1664
|
+
"productProfile": "short evidence-grounded product classification",
|
|
1665
|
+
"summary": "what this plan accomplishes and why",
|
|
1666
|
+
"audiences": ["specific reader groups"],
|
|
1667
|
+
"outcomes": ["concrete reader outcomes"],
|
|
1668
|
+
"terminology": { "preferred term": "meaning or replacement guidance" },
|
|
1669
|
+
"exclusions": ["explicitly out-of-scope topics"],
|
|
1670
|
+
"instructions": "cross-page authoring and style guidance",
|
|
1671
|
+
"experienceLevel": "beginner | intermediate | advanced | mixed",
|
|
1672
|
+
"preferredExamples": ["TypeScript", "curl", "other evidence-supported preferences"],
|
|
1673
|
+
"locale": "BCP 47 locale",
|
|
1674
|
+
"accessibilityTarget": "accessibility requirement",
|
|
1675
|
+
"styleGuide": "style guide identifier",
|
|
1676
|
+
"capabilities": [{
|
|
1677
|
+
"id": "stable-kebab-id",
|
|
1678
|
+
"title": "public capability or reader workflow",
|
|
1679
|
+
"kind": "workflow | api | command | configuration | concept | operation",
|
|
1680
|
+
"evidence": [{ "source": "configured source", "path": "source-relative path", "kind": "discovery kind", "label": "symbol, route, operation, or file", "line": 1 }],
|
|
1681
|
+
"pageIds": ["planned-page-id"],
|
|
1682
|
+
"disposition": "planned | existing | excluded | needs-human"
|
|
1683
|
+
}],
|
|
1684
|
+
"navigation": {
|
|
1685
|
+
"top": ["product-derived space name", "second space only when it holds at least five substantial pages"],
|
|
1686
|
+
"sections": [{ "id": "getting-started", "title": "Getting started", "space": "one name from top", "pageIds": ["planned-page-id"] }]
|
|
1687
|
+
},
|
|
1688
|
+
"estimatedEffort": "small | medium | large",
|
|
1689
|
+
"estimatedPages": 8,
|
|
1690
|
+
"pages": [{
|
|
1691
|
+
"id": "stable-kebab-id",
|
|
1692
|
+
"title": "Page title",
|
|
1693
|
+
"path": "generator-neutral/page-slug",
|
|
1694
|
+
"type": "getting-started | concept | how-to | tutorial | reference | troubleshooting | release | other",
|
|
1695
|
+
"priority": "must-have | next",
|
|
1696
|
+
"action": "create | update | preserve | remove",
|
|
1697
|
+
"purpose": "reader outcome for this page",
|
|
1698
|
+
"rationale": "why evidence and reader needs justify it",
|
|
1699
|
+
"evidence": ["configured source name plus relevant file, symbol, route, or spec operation"],
|
|
1700
|
+
"evidenceDetails": [{ "source": "configured source", "path": "source-relative path", "kind": "discovery kind", "label": "symbol, route, operation, or file", "line": 1 }],
|
|
1701
|
+
"visuals": { "mode": "none | recommended | required", "rationale": "what reader ambiguity or visible outcome the captures resolve", "estimatedCaptures": 0, "startPath": "/application-relative/start", "workflow": "ordered actions and safe fixture assumptions", "captureSequence": ["Reader action — expected stable visible state — why this image helps"], "captureIds": ["matching ID from application brief, or empty string if missing"] },
|
|
1702
|
+
"diagram": "required | none"
|
|
1703
|
+
}],
|
|
1704
|
+
"questions": [{
|
|
1705
|
+
"id": "stable-kebab-id",
|
|
1706
|
+
"question": "one material question only",
|
|
1707
|
+
"whyItMatters": "decision affected by the answer",
|
|
1708
|
+
"recommendation": "safe default"
|
|
1709
|
+
}]${existingDocumentationPlanShape(project.sources)}
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
Rules:
|
|
1713
|
+
- Plan only evidence-supported public behavior. Mark unknowns; never invent them.
|
|
1714
|
+
- Audit the complete public product surface before choosing pages: package metadata and entry points; exported APIs, commands, routes, and configuration; installation and prerequisites; authentication and permissions; primary and advanced workflows; examples, tests, and integrations; errors, limits, recovery paths, and operational concerns.
|
|
1715
|
+
- Enumerate concrete reader outcomes first, then ensure every evidence-supported outcome maps to at least one page. Put unsupported or intentionally omitted outcomes in exclusions.
|
|
1716
|
+
- Keep your inferred grouping, audience relevance, and recommendations in rationale. Every planned capability must map to pageIds, and every page to write must cite at least one configured source when sources are available.
|
|
1717
|
+
- Produce a coherent generator-neutral navigation outline. Use the persisted target only to identify generator-native navigation boundaries; do not put generator-specific syntax in page paths or section IDs.
|
|
1718
|
+
- Name top-level spaces after this product's reader surfaces and audiences as the evidence shows them — for example "Guides", "Tracker & API", and "Self-hosting" for an analytics product, or "Monitoring", "Status pages", and "Administration" for a monitoring tool. Those names belong to other products: never reuse an example name unless this product's own evidence uses that word, and never default to a generic "Documentation" plus "Reference" pair. Add a second or third space only when it holds at least five substantial pages of its own; otherwise keep those pages as groups inside the primary space. Give every navigation section a "space" set to exactly one name from "top", so each space you list receives its sections; a space no section names is dropped. Every navigation group needs at least two pages, and a runbook or troubleshooting page belongs with the workflows it supports, not in a reference space.
|
|
1719
|
+
- Give each page one distinct reader job or reference purpose. Do not hide several substantial workflows inside a generic overview or quickstart merely to keep the plan small.
|
|
1720
|
+
- Preserve useful existing pages during updates and identify their action explicitly.
|
|
1721
|
+
- Generated starter pages are scaffolding, not useful existing documentation. Any existing page containing a \`doxloop:starter-page\` marker or starter-placeholder language must appear in the current plan with action \`update\` or \`remove\`; never preserve or leave it outside the plan.
|
|
1722
|
+
${questionRule}
|
|
1723
|
+
- Scope contract for starter: at least 3 pages to write, covering orientation, first success, and essential reference or troubleshooting when supported. Keep it deliberately small, but do not merge distinct reader jobs to stay under an arbitrary number.
|
|
1724
|
+
- Scope contract for standard: at least 8 pages to write within the configured page limit. Include overview, prerequisites or installation, quickstart, every primary workflow guide, necessary concepts, public reference or configuration, and troubleshooting. Add examples, integrations, errors, or limitations when evidence supports them.
|
|
1725
|
+
- Scope contract for comprehensive: at least 12 pages to write within the configured page limit. This is the default scope; use the deterministic public-surface inventory as the floor for coverage, not a ceiling. Cover every distinct evidence-supported public workflow, screen, command, and interface at useful depth, plus relevant concepts, examples, integrations, operations, security, errors, limits, troubleshooting, and lifecycle guidance. A product with many screens, commands, or configuration groups legitimately needs 40–80 pages.
|
|
1726
|
+
- Page depth contract: plan pages that can be written to professional depth. A how-to or tutorial page needs a stated outcome, prerequisites, at least three ordered steps with observable results, verification, evidence-backed troubleshooting, and a next step. A reference page covers its complete public surface (every command, option, field, default, and error in scope). A concept page explains the model, its consequences, and links to the tasks it informs. The landing page orients every audience with cards to their first task. Do not plan a page whose evidence supports only a paragraph — merge it into a page that can be complete.
|
|
1727
|
+
- Treat the persisted documentation brief's customInstructions as reader requirements when planning visuals and depth; for example, a request for a screenshot on every step means every planned UI guide captures each screen-changing step.
|
|
1728
|
+
${current.mode === 'create'
|
|
1729
|
+
? `- A ${current.scope} plan changes depth and priority, never factual grounding. Targets guide coverage and are not permission to create filler.`
|
|
1730
|
+
: '- Keep an update plan tightly bounded to the requested change and evidence-backed dependencies. Do not expand it to meet a page-count target.'}
|
|
1731
|
+
- For an initial create plan, the minimum pages to create or update are 3 for starter, 7 for standard, and 12 for comprehensive. If the evidence genuinely supports fewer distinct pages, add a specific exclusion beginning with "Scope exception:" that states why the smaller plan is complete.
|
|
1732
|
+
- Include only pages that belong to this documentation run. Do not include a future backlog, deferred pages, or "later" items in the plan.
|
|
1733
|
+
- Paths are relative, portable, have no leading slash, and do not escape the documentation project.
|
|
1734
|
+
- Every must-have page explains its rationale and expected evidence.
|
|
1735
|
+
- Every page declares a visuals decision. Use zero for non-UI pages. Prefer the already captured images that resolve a reader ambiguity or prove a meaningful result. A single clear screenshot is sufficient when text explains the remaining steps. Plan additional states only when they teach something distinct, or the user explicitly requested them. Do not require an image for every screen-changing step or target an image count by page type. Never inflate the count with unchanged screens, decorative images, or every mouse click.
|
|
1736
|
+
- The estimatedCaptures of all pages together must not exceed the batch's maxScreenshots. When the visible surface deserves more, keep complete sequences for the guides whose screens matter most to the reader outcome, give the remaining UI pages visuals.mode "none", and say in their rationale that captures are deferred to a later batch; never trim every guide to a token image.
|
|
1737
|
+
- Reuse the application brief's saved captures: set visuals.captureIds in captureSequence order to the ID proving that exact state (empty string only for a missing state). Do not schedule another capture of a state already saved. Writers choose from these saved images. Never assign an image to a different state merely because the route matches.
|
|
1738
|
+
- Every screenshot-enabled page requires a startPath beginning with one slash, a specific workflow, and a captureSequence with exactly one concrete item per estimated capture in capture order. Each item must name the reader action, expected stable visible state, and why that image helps. Set estimatedCaptures to captureSequence.length. Pages with visuals.mode "none" use zero and empty capture details.
|
|
1739
|
+
- The pages array must not be empty.
|
|
1740
|
+
- Every page declares "diagram". Concept and architecture pages set "required" so the writer includes a Mermaid diagram of the model or lifecycle; task, reference, and release pages set "none" unless a diagram resolves real reader ambiguity.
|
|
1741
|
+
- Keep the reply compact; a long plan is emitted token by token and every extra sentence delays the reviewer. Write purpose in at most 120 characters, rationale in at most 160, each captureSequence item in at most 160, and visuals.workflow in at most 240. Give each page to write two to four evidenceDetails, copied from the briefs with their line numbers: the component, route, handler, or command file that implements the behaviour first, the label catalog entry (a dotted key such as "task.repeat.everyDay" as the label) only as a supplement. Writers start from excerpts around those citations, so a page whose only citation is a translation file starts from nothing. Give each capability one or two. Do not restate the discovery inventory, the project configuration, or these rules anywhere in the reply.`;
|
|
1742
|
+
}
|
|
1743
|
+
function generationRequest(plan, reviewerGuidance) {
|
|
1744
|
+
const screenshotIntent = normalizeScreenshotIntent(plan.execution.screenshots);
|
|
1745
|
+
const captureContract = screenshotIntent === 'enabled'
|
|
1746
|
+
? 'Screenshots are required. Produce at least one verified, embedded capture for every screenshot-enabled guide and attempt every distinct approved state. Consolidate sequence items that resolve to the same unchanged screen instead of creating duplicate images. If an entire required guide cannot be captured, report the concrete blocker; do not silently substitute a text-only guide.'
|
|
1747
|
+
: screenshotIntent === 'auto'
|
|
1748
|
+
? 'Screenshots are automatic and best-effort. Attempt the approved sequence with the Doxloop capture browser and capture every state you can verify. If a particular state is unavailable, retain complete text instructions and record that manifest step as text-only with a concrete reason; a candidate image is not a reason to fail otherwise valid documentation.'
|
|
1749
|
+
: 'Screenshots are disabled. Do not operate the application or create a screenshot manifest.';
|
|
1750
|
+
return `Implement the approved Doxloop documentation plan at .doxloop/documentation-plan.json exactly as approved.
|
|
1751
|
+
|
|
1752
|
+
Approved plan ID: ${plan.id}
|
|
1753
|
+
Approved version: ${plan.version}
|
|
1754
|
+
Approved hash: ${plan.approvedHash}
|
|
1755
|
+
|
|
1756
|
+
Treat the approved plan as a scope boundary. Create, update, preserve, or remove only the planned pages and the navigation, theme, evidence map, and supporting assets strictly required by those pages. Do not silently expand scope. If source research reveals useful work outside the plan, report it as a recommendation instead of implementing it. Resolve factual details from configured evidence and mark unsupported behavior rather than guessing.
|
|
1757
|
+
|
|
1758
|
+
For every screenshot-enabled page, follow the approved visuals.startPath, visuals.workflow, and visuals.captureSequence in order. ${captureContract} Capture efficiently: take the screenshot immediately after the navigation or click that produces an approved state — the action's result already tells you it succeeded — and call the accessibility snapshot only when the next action needs an element reference or a dialog must be confirmed, never as a separate check before every image and never after one. Steps already marked verified with a file in .doxloop/screenshot-manifest.json were captured by Doxloop: keep them and embed those images.
|
|
1759
|
+
|
|
1760
|
+
Write every planned page to professional depth. Read the authoring skill's page-depth reference and apply its contract for the page type: an outcome-led introduction; prerequisites; complete ordered steps with the exact labels, values, and observable result of each step; verification; evidence-backed troubleshooting; and a next step. Reference pages cover their whole public surface with complete tables. Concept pages carry a diagram or model and link to the tasks they inform. The landing page orients each audience with cards to a first task. Use the generator's native components — steps, tabs, callouts, cards, accordions, code groups, frames — where they make the page clearer. Use saved captures at the steps they clarify, preserving the approved capture requirements without adding extra images for routine actions. Doxloop validates the workspace after each batch and returns every defect to you — including \`thin-page\`, \`thin-procedure\`, \`thin-space\`, \`single-page-group\`, and \`generic-space-name\` warnings, which you resolve on every planned page before finishing; do not run validation, node, or python yourself. Name every button, tab, field, and menu with the string the product displays: when the inventory lists a UI label catalog, read it and quote the displayed English value, never the translation key, a paraphrase such as "the add control", or a label you have not found in the catalog or the component source. Write each page's prerequisites, cautions, and limitations for its own task in its own words; do not repeat one disclaimer or "before you begin" block across pages, and do not fill verification blocks with restatements of the steps.
|
|
1761
|
+
|
|
1762
|
+
${planWritingRequirements(plan)}${existingDocumentationWritingRequirements(plan)}The finished workspace must contain no generated starter content. Replace every planned starter page completely and remove every \`doxloop:starter-page\` marker; Doxloop reports any remaining \`starter-content\` defect back to you.
|
|
1763
|
+
|
|
1764
|
+
Preserve applicable reviewer preferences below unless they conflict with the approved plan or current evidence:
|
|
1765
|
+
${reviewerGuidance}`;
|
|
1766
|
+
}
|
|
1767
|
+
/**
|
|
1768
|
+
* How the writer follows the approved audit of an existing documentation
|
|
1769
|
+
* site: dispositions are scope, contradictions are corrections, and the old
|
|
1770
|
+
* prose is evidence to rewrite from rather than text to paste.
|
|
1771
|
+
*/
|
|
1772
|
+
export function existingDocumentationWritingRequirements(plan) {
|
|
1773
|
+
const assessments = plan.existingDocumentation ?? [];
|
|
1774
|
+
if (assessments.length === 0)
|
|
1775
|
+
return '';
|
|
1776
|
+
const titles = new Map(plan.pages.map((page) => [page.id, page.title]));
|
|
1777
|
+
const lines = assessments.flatMap((assessment) => {
|
|
1778
|
+
const corrections = assessment.coverage.contradicted.map((claim) => ` - correct: ${claim}`);
|
|
1779
|
+
const preserved = assessment.coverage.preserved.map((item) => ` - keep: ${item}`);
|
|
1780
|
+
const dropped = assessment.pages.filter((page) => page.disposition === 'drop').map((page) => ` - drop ${page.path}${page.reason ? ` (${page.reason})` : ''}`);
|
|
1781
|
+
const placed = assessment.pages.filter((page) => page.disposition !== 'drop').map((page) => ` - ${page.disposition} ${page.path} → ${page.into.map((id) => titles.get(id) ?? id).join(', ')}`);
|
|
1782
|
+
return [`Existing documentation "${assessment.source}":`, ...placed, ...dropped, ...corrections, ...preserved];
|
|
1783
|
+
});
|
|
1784
|
+
return `The approved plan includes an audit of the existing documentation being rewritten. Follow its page dispositions exactly: every existing page marked rewrite, merge, or preserve must have its reader-valuable content carried into the named planned pages, and a page marked drop is omitted for the stated reason. Write the corrected fact for every contradicted claim and never repeat the old one. Rewrite in the project's voice from the evidence; do not paste the existing prose. Where the existing pages carry knowledge no product source shows, keep it and record the page's confidence as inferred with the docs-site source as evidence. List every correction and every dropped page in your final summary.
|
|
1785
|
+
${lines.join('\n')}
|
|
1786
|
+
|
|
1787
|
+
`;
|
|
1788
|
+
}
|
|
1789
|
+
function normalizePlanShape(raw, base) {
|
|
1790
|
+
const value = record(raw);
|
|
1791
|
+
const pagesRaw = Array.isArray(value.pages) ? value.pages : base.pages;
|
|
1792
|
+
const pages = pagesRaw.map((page, index) => normalizePage(page, index)).filter((page) => page.priority !== 'later').map((page) => normalizeScreenshotIntent(base.execution.screenshots) === 'disabled'
|
|
1793
|
+
? { ...page, visuals: { mode: 'none', rationale: 'Application screenshots are disabled for this run.', estimatedCaptures: 0 } }
|
|
1794
|
+
: page);
|
|
1795
|
+
if (pages.length === 0)
|
|
1796
|
+
throw new DoxloopError('A documentation plan must contain at least one page for the current run.');
|
|
1797
|
+
if ((base.target?.generator ?? 'doxbrix') === 'doxbrix')
|
|
1798
|
+
uniquePageFileNames(pages);
|
|
1799
|
+
const questionsRaw = Array.isArray(value.questions) ? value.questions : base.questions;
|
|
1800
|
+
const questions = questionsRaw.slice(0, 3).map((question, index) => normalizeQuestion(question, index)).filter((question) => normalizeScreenshotIntent(base.execution.screenshots) === 'disabled' || !isCapturePreparationQuestion(question));
|
|
1801
|
+
const scope = scopeValue(value.scope) ?? base.scope;
|
|
1802
|
+
const effort = value.estimatedEffort === 'small' || value.estimatedEffort === 'large' ? value.estimatedEffort : 'medium';
|
|
1803
|
+
const experienceLevel = experienceLevelValue(value.experienceLevel) ?? base.experienceLevel;
|
|
1804
|
+
const estimatedPages = positiveInteger(value.estimatedPages) ?? pages.filter((page) => page.action === 'create' || page.action === 'update').length;
|
|
1805
|
+
const capabilitiesRaw = Array.isArray(value.capabilities) ? value.capabilities : base.capabilities;
|
|
1806
|
+
const knownPageIds = new Set(pages.map((page) => page.id));
|
|
1807
|
+
const capabilities = capabilitiesRaw.map((capability, index) => {
|
|
1808
|
+
const normalized = normalizeCapability(capability, index);
|
|
1809
|
+
return { ...normalized, pageIds: normalized.pageIds.filter((id) => knownPageIds.has(id)) };
|
|
1810
|
+
});
|
|
1811
|
+
const navigation = normalizeNavigation(value.navigation ?? base.navigation, pages);
|
|
1812
|
+
const existingDocumentation = normalizeExistingDocumentation(value.existingDocumentation ?? base.existingDocumentation, pages);
|
|
1813
|
+
const workspaceInstructions = (textValue(value.workspaceInstructions) ?? base.workspaceInstructions)?.slice(0, 4000) || undefined;
|
|
1814
|
+
return {
|
|
1815
|
+
productProfile: textValue(value.productProfile) ?? base.productProfile,
|
|
1816
|
+
summary: requiredText(value.summary ?? base.summary, 'Plan summary'),
|
|
1817
|
+
audiences: stringList(value.audiences ?? base.audiences),
|
|
1818
|
+
outcomes: mergeRequiredOutcomes(base.outcomes, stringList(value.outcomes ?? base.outcomes)),
|
|
1819
|
+
terminology: stringRecord(value.terminology ?? base.terminology),
|
|
1820
|
+
exclusions: stringList(value.exclusions ?? base.exclusions),
|
|
1821
|
+
instructions: textValue(value.instructions) ?? base.instructions,
|
|
1822
|
+
experienceLevel,
|
|
1823
|
+
preferredExamples: stringList(value.preferredExamples ?? base.preferredExamples),
|
|
1824
|
+
locale: textValue(value.locale) ?? base.locale,
|
|
1825
|
+
accessibilityTarget: textValue(value.accessibilityTarget) ?? base.accessibilityTarget,
|
|
1826
|
+
styleGuide: textValue(value.styleGuide) ?? base.styleGuide,
|
|
1827
|
+
capabilities,
|
|
1828
|
+
navigation,
|
|
1829
|
+
estimatedPages,
|
|
1830
|
+
estimatedEffort: effort,
|
|
1831
|
+
pages,
|
|
1832
|
+
questions,
|
|
1833
|
+
scope,
|
|
1834
|
+
...(existingDocumentation ? { existingDocumentation } : {}),
|
|
1835
|
+
...(workspaceInstructions ? { workspaceInstructions } : {}),
|
|
1836
|
+
};
|
|
1837
|
+
}
|
|
1838
|
+
/**
|
|
1839
|
+
* Add the title and URL of every existing page the plan names, from the
|
|
1840
|
+
* snapshot manifest. The planner lists pages by path only, which keeps its
|
|
1841
|
+
* reply shorter; the review still shows readers what each page was.
|
|
1842
|
+
*/
|
|
1843
|
+
export function fillExistingPageDetails(raw, details) {
|
|
1844
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
1845
|
+
return raw;
|
|
1846
|
+
const plan = raw;
|
|
1847
|
+
if (!Array.isArray(plan.existingDocumentation))
|
|
1848
|
+
return raw;
|
|
1849
|
+
const existingDocumentation = plan.existingDocumentation.map((assessment) => {
|
|
1850
|
+
if (!assessment || typeof assessment !== 'object' || Array.isArray(assessment))
|
|
1851
|
+
return assessment;
|
|
1852
|
+
const item = assessment;
|
|
1853
|
+
const known = typeof item.source === 'string' ? details.get(item.source) : undefined;
|
|
1854
|
+
if (!known || !Array.isArray(item.pages))
|
|
1855
|
+
return assessment;
|
|
1856
|
+
return {
|
|
1857
|
+
...item,
|
|
1858
|
+
pages: item.pages.map((page) => {
|
|
1859
|
+
if (!page || typeof page !== 'object' || Array.isArray(page))
|
|
1860
|
+
return page;
|
|
1861
|
+
const entry = page;
|
|
1862
|
+
const found = typeof entry.path === 'string' ? known.get(entry.path.replace(/^\/+/, '')) : undefined;
|
|
1863
|
+
if (!found)
|
|
1864
|
+
return page;
|
|
1865
|
+
return {
|
|
1866
|
+
...entry,
|
|
1867
|
+
...(typeof entry.title === 'string' && entry.title.trim() ? {} : { title: found.title }),
|
|
1868
|
+
...(typeof entry.url === 'string' && entry.url.trim() ? {} : { url: found.url }),
|
|
1869
|
+
};
|
|
1870
|
+
}),
|
|
1871
|
+
};
|
|
1872
|
+
});
|
|
1873
|
+
return { ...plan, existingDocumentation };
|
|
1874
|
+
}
|
|
1875
|
+
async function withExistingPageDetails(root, raw, sources) {
|
|
1876
|
+
const details = new Map();
|
|
1877
|
+
for (const source of docsSiteSources(sources)) {
|
|
1878
|
+
try {
|
|
1879
|
+
const manifest = await readDocsSiteManifest(root, source);
|
|
1880
|
+
details.set(source.name, new Map(manifest.pages.map((page) => [page.file, { title: page.title, url: page.url }])));
|
|
1881
|
+
}
|
|
1882
|
+
catch {
|
|
1883
|
+
// A missing snapshot is reported by the planning gates, not here.
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
return details.size > 0 ? fillExistingPageDetails(raw, details) : raw;
|
|
1887
|
+
}
|
|
1888
|
+
function normalizeExistingDocumentation(raw, pages) {
|
|
1889
|
+
if (!Array.isArray(raw))
|
|
1890
|
+
return undefined;
|
|
1891
|
+
const knownPageIds = new Set(pages.map((page) => page.id));
|
|
1892
|
+
const assessments = raw.flatMap((item) => {
|
|
1893
|
+
const value = record(item);
|
|
1894
|
+
const source = textValue(value.source);
|
|
1895
|
+
if (!source)
|
|
1896
|
+
return [];
|
|
1897
|
+
const coverage = record(value.coverage);
|
|
1898
|
+
const findings = (Array.isArray(value.findings) ? value.findings : []).flatMap((entry) => {
|
|
1899
|
+
const finding = record(entry);
|
|
1900
|
+
const title = textValue(finding.title);
|
|
1901
|
+
if (!title)
|
|
1902
|
+
return [];
|
|
1903
|
+
const severity = finding.severity === 'blocker' || finding.severity === 'minor' ? finding.severity : 'major';
|
|
1904
|
+
return [{ severity, title, description: textValue(finding.description) ?? '', pages: stringList(finding.pages) }];
|
|
1905
|
+
});
|
|
1906
|
+
const dispositions = (Array.isArray(value.pages) ? value.pages : []).flatMap((entry) => {
|
|
1907
|
+
const disposition = record(entry);
|
|
1908
|
+
const path = textValue(disposition.path);
|
|
1909
|
+
if (!path)
|
|
1910
|
+
return [];
|
|
1911
|
+
const kind = disposition.disposition === 'merge' || disposition.disposition === 'preserve' || disposition.disposition === 'drop' ? disposition.disposition : 'rewrite';
|
|
1912
|
+
const into = stringList(disposition.into).filter((id) => knownPageIds.has(id));
|
|
1913
|
+
const title = textValue(disposition.title);
|
|
1914
|
+
const url = textValue(disposition.url);
|
|
1915
|
+
return [{
|
|
1916
|
+
path,
|
|
1917
|
+
...(title ? { title } : {}),
|
|
1918
|
+
...(url ? { url } : {}),
|
|
1919
|
+
disposition: kind === 'drop' ? 'drop' : into.length === 0 ? 'drop' : kind,
|
|
1920
|
+
into: kind === 'drop' ? [] : into,
|
|
1921
|
+
reason: textValue(disposition.reason)?.trim() || (kind !== 'drop' && into.length === 0 ? 'The plan named no page that absorbs this content.' : ''),
|
|
1922
|
+
}];
|
|
1923
|
+
});
|
|
1924
|
+
return [{
|
|
1925
|
+
source,
|
|
1926
|
+
summary: textValue(value.summary) ?? '',
|
|
1927
|
+
strengths: stringList(value.strengths),
|
|
1928
|
+
findings,
|
|
1929
|
+
coverage: {
|
|
1930
|
+
gaps: stringList(coverage.gaps),
|
|
1931
|
+
obsolete: stringList(coverage.obsolete),
|
|
1932
|
+
preserved: stringList(coverage.preserved),
|
|
1933
|
+
contradicted: stringList(coverage.contradicted),
|
|
1934
|
+
},
|
|
1935
|
+
pages: dispositions,
|
|
1936
|
+
}];
|
|
1937
|
+
});
|
|
1938
|
+
return assessments.length > 0 ? assessments : undefined;
|
|
1939
|
+
}
|
|
1940
|
+
/**
|
|
1941
|
+
* The audit of an existing documentation site is what the reviewer approves
|
|
1942
|
+
* before a rewrite, so a first proposal that skips it is not approvable.
|
|
1943
|
+
*/
|
|
1944
|
+
export function existingDocumentationPlanIssue(plan, base, sources) {
|
|
1945
|
+
// Only the first proposal of a create run is gated: an update plan scoped to
|
|
1946
|
+
// one change should not have to re-audit the whole existing site.
|
|
1947
|
+
if (base.mode !== 'create' || base.status !== 'planning')
|
|
1948
|
+
return undefined;
|
|
1949
|
+
const missing = docsSiteSources(sources).filter((source) => !plan.existingDocumentation?.some((assessment) => assessment.source === source.name && assessment.pages.length > 0));
|
|
1950
|
+
if (missing.length === 0)
|
|
1951
|
+
return undefined;
|
|
1952
|
+
return `The plan does not assess the existing documentation site${missing.length === 1 ? '' : 's'} ${missing.map((source) => `"${source.name}"`).join(', ')}. Add an "existingDocumentation" entry per docs-site source with a summary, findings, coverage (gaps, obsolete, preserved, contradicted), and a disposition for every crawled page listed in the snapshot's index.md.`;
|
|
1953
|
+
}
|
|
1954
|
+
/** Crawled pages the plan never placed are reported for the reviewer rather than sent back to the planner. */
|
|
1955
|
+
export function existingDocumentationAdvisory(plan, sources, discovery) {
|
|
1956
|
+
const notes = [];
|
|
1957
|
+
for (const source of docsSiteSources(sources)) {
|
|
1958
|
+
const inventory = discovery.sources.find((item) => item.name === source.name);
|
|
1959
|
+
if (!inventory)
|
|
1960
|
+
continue;
|
|
1961
|
+
const crawled = inventory.evidence.filter((item) => item.path !== 'index.md').map((item) => item.path);
|
|
1962
|
+
const assessment = plan.existingDocumentation?.find((item) => item.source === source.name);
|
|
1963
|
+
const placed = new Set((assessment?.pages ?? []).map((page) => page.path));
|
|
1964
|
+
const unplaced = crawled.filter((path) => !placed.has(path));
|
|
1965
|
+
if (unplaced.length === 0)
|
|
1966
|
+
continue;
|
|
1967
|
+
notes.push(`${unplaced.length} of ${crawled.length} crawled pages from "${source.name}" have no disposition in this plan (for example ${unplaced.slice(0, 3).join(', ')}). Their content is neither rewritten nor explicitly dropped; ask the agent to place them or accept that they are left behind.`);
|
|
1968
|
+
}
|
|
1969
|
+
return notes.length > 0 ? notes.join(' ') : undefined;
|
|
1970
|
+
}
|
|
1971
|
+
function isCapturePreparationQuestion(question) {
|
|
1972
|
+
const text = `${question.question} ${question.whyItMatters} ${question.recommendation ?? ''}`.toLowerCase();
|
|
1973
|
+
const concernsCapture = /\b(?:capture|screenshot|screen shot)\b/.test(text);
|
|
1974
|
+
const concernsPreparation = /\b(?:application|surface|route|path|workflow|state|fixture|synthetic|restart|prepare|populate|login|sign[ -]?in|authentication|non-production)\b/.test(text);
|
|
1975
|
+
return concernsCapture && concernsPreparation;
|
|
1976
|
+
}
|
|
1977
|
+
function mergeRequiredOutcomes(required, proposed) {
|
|
1978
|
+
const seen = new Set();
|
|
1979
|
+
return [...required, ...proposed].filter((outcome) => {
|
|
1980
|
+
const key = outcome.trim().toLowerCase();
|
|
1981
|
+
if (!key || seen.has(key))
|
|
1982
|
+
return false;
|
|
1983
|
+
seen.add(key);
|
|
1984
|
+
return true;
|
|
1985
|
+
});
|
|
1986
|
+
}
|
|
1987
|
+
function initialCreatePlanCoverageIssue(plan, base) {
|
|
1988
|
+
if (base.mode !== 'create' || base.status !== 'planning')
|
|
1989
|
+
return undefined;
|
|
1990
|
+
if (base.scope === 'custom' && !base.targetPages)
|
|
1991
|
+
return undefined;
|
|
1992
|
+
if (base.discovery.publicSignals > 0 && plan.capabilities.length === 0) {
|
|
1993
|
+
return 'The create plan does not map the deterministic public-surface inventory into capabilities. Add evidence-backed capabilities and map each one to planned pages, an existing page, an explicit exclusion, or a needs-human decision.';
|
|
1994
|
+
}
|
|
1995
|
+
const unmappedCapabilities = plan.capabilities.filter((capability) => (capability.disposition === 'planned' || capability.disposition === 'existing') && capability.pageIds.length === 0);
|
|
1996
|
+
if (unmappedCapabilities.length > 0) {
|
|
1997
|
+
return `The create plan leaves ${unmappedCapabilities.length} public ${unmappedCapabilities.length === 1 ? 'capability' : 'capabilities'} unmapped. Map each to a page, mark it as an explicit exclusion, or request a human decision.`;
|
|
1998
|
+
}
|
|
1999
|
+
const pagesWithoutEvidence = plan.pages.filter((page) => (page.action === 'create' || page.action === 'update') && page.evidence.length === 0 && page.evidenceDetails.length === 0);
|
|
2000
|
+
if (base.discovery.publicSignals > 0 && pagesWithoutEvidence.length > 0) {
|
|
2001
|
+
return `The create plan has ${pagesWithoutEvidence.length} ${pagesWithoutEvidence.length === 1 ? 'page' : 'pages'} without source evidence. Map every page to write to at least one configured source.`;
|
|
2002
|
+
}
|
|
2003
|
+
const scopeMinimum = base.scope === 'starter' ? 3 : base.scope === 'standard' ? 7 : base.scope === 'comprehensive' ? 12 : 1;
|
|
2004
|
+
const minimum = Math.min(batchLimits(base.execution.limits).maxPages, Math.max(scopeMinimum, base.targetPages ?? 0));
|
|
2005
|
+
const pagesToWrite = plan.pages.filter((page) => page.action === 'create' || page.action === 'update').length;
|
|
2006
|
+
if (pagesToWrite >= minimum)
|
|
2007
|
+
return undefined;
|
|
2008
|
+
if (plan.exclusions.some((exclusion) => /^scope exception:\s+\S/i.test(exclusion)))
|
|
2009
|
+
return undefined;
|
|
2010
|
+
const requirement = base.targetPages && base.targetPages > scopeMinimum
|
|
2011
|
+
? `The reviewer asked for at least ${base.targetPages} pages to write.`
|
|
2012
|
+
: `It needs at least ${minimum} distinct evidence-supported pages.`;
|
|
2013
|
+
return `The ${base.scope} create plan proposes only ${pagesToWrite} ${pagesToWrite === 1 ? 'page' : 'pages'} to write. ${requirement} Split the public surface into more focused, evidence-backed pages, or add a specific "Scope exception:" exclusion explaining why the configured product evidence cannot support that depth.`;
|
|
2014
|
+
}
|
|
2015
|
+
/**
|
|
2016
|
+
* Fix what a corrective planning pass would only relabel. In required
|
|
2017
|
+
* screenshot mode every screenshot-enabled page is required by definition,
|
|
2018
|
+
* so a "recommended" label is a wording slip, not a planning decision, and
|
|
2019
|
+
* a second full pass to change the word costs as much as the first proposal.
|
|
2020
|
+
*
|
|
2021
|
+
* The same goes for the other slips the required-screenshot gate catches: a
|
|
2022
|
+
* guide whose visual purpose landed in the page's own rationale, a start path
|
|
2023
|
+
* written without its leading slash, or a workflow the planner left out while
|
|
2024
|
+
* spelling the same steps out in the capture sequence. Real runs paid a second
|
|
2025
|
+
* planning pass — six to twenty minutes, with the browser exploration repeated
|
|
2026
|
+
* — for each of these, so they are repaired here and reported on the log.
|
|
2027
|
+
*/
|
|
2028
|
+
/** The capture IDs the application research brief recorded, or undefined when there is no brief. */
|
|
2029
|
+
export async function knownPlanningCaptureIds(root, planId) {
|
|
2030
|
+
if (!/^[\w-]+$/.test(planId))
|
|
2031
|
+
return undefined;
|
|
2032
|
+
let brief;
|
|
2033
|
+
try {
|
|
2034
|
+
brief = JSON.parse(await readFile(join(root, '.doxloop', 'plans', planId, 'research', 'application.json'), 'utf8'));
|
|
2035
|
+
}
|
|
2036
|
+
catch {
|
|
2037
|
+
return undefined;
|
|
2038
|
+
}
|
|
2039
|
+
const ids = new Set();
|
|
2040
|
+
for (const screen of Array.isArray(brief.content?.screens) ? brief.content.screens : []) {
|
|
2041
|
+
for (const capture of Array.isArray(screen?.captures) ? screen.captures : [])
|
|
2042
|
+
if (typeof capture?.id === 'string' && capture.id)
|
|
2043
|
+
ids.add(capture.id);
|
|
2044
|
+
}
|
|
2045
|
+
return ids;
|
|
2046
|
+
}
|
|
2047
|
+
/** Blank every visuals.captureIds entry that names no saved capture; returns how many were blanked. */
|
|
2048
|
+
export function dropUnknownCaptureIds(raw, known) {
|
|
2049
|
+
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.pages))
|
|
2050
|
+
return 0;
|
|
2051
|
+
let blanked = 0;
|
|
2052
|
+
for (const page of raw.pages) {
|
|
2053
|
+
const visuals = page?.visuals;
|
|
2054
|
+
if (!visuals || !Array.isArray(visuals.captureIds))
|
|
2055
|
+
continue;
|
|
2056
|
+
visuals.captureIds = visuals.captureIds.map((id) => {
|
|
2057
|
+
if (typeof id !== 'string')
|
|
2058
|
+
return '';
|
|
2059
|
+
if (id === '' || known.has(id))
|
|
2060
|
+
return id;
|
|
2061
|
+
blanked += 1;
|
|
2062
|
+
return '';
|
|
2063
|
+
});
|
|
2064
|
+
}
|
|
2065
|
+
return blanked;
|
|
2066
|
+
}
|
|
2067
|
+
export function repairMechanicalPlanIssues(raw, execution) {
|
|
2068
|
+
const intent = normalizeScreenshotIntent(execution.screenshots);
|
|
2069
|
+
if (intent === 'disabled')
|
|
2070
|
+
return raw;
|
|
2071
|
+
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.pages))
|
|
2072
|
+
return raw;
|
|
2073
|
+
const pages = raw.pages;
|
|
2074
|
+
const repairs = { relabeled: 0, rationale: 0, startPath: 0, workflow: 0 };
|
|
2075
|
+
const next = pages.map((page) => {
|
|
2076
|
+
if (!page || typeof page !== 'object')
|
|
2077
|
+
return page;
|
|
2078
|
+
const visualsRaw = page.visuals;
|
|
2079
|
+
if (!visualsRaw || typeof visualsRaw !== 'object')
|
|
2080
|
+
return page;
|
|
2081
|
+
const visuals = { ...visualsRaw };
|
|
2082
|
+
if (visuals.mode === 'recommended' && intent === 'enabled') {
|
|
2083
|
+
repairs.relabeled += 1;
|
|
2084
|
+
visuals.mode = 'required';
|
|
2085
|
+
}
|
|
2086
|
+
if (visuals.mode !== 'required' && visuals.mode !== 'recommended')
|
|
2087
|
+
return { ...page, visuals };
|
|
2088
|
+
const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : undefined);
|
|
2089
|
+
if (!text(visuals.rationale)) {
|
|
2090
|
+
const fallback = text(visuals.purpose) ?? text(page.rationale) ?? text(page.purpose);
|
|
2091
|
+
if (fallback) {
|
|
2092
|
+
repairs.rationale += 1;
|
|
2093
|
+
visuals.rationale = fallback;
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
const startPath = text(visuals.startPath);
|
|
2097
|
+
if (startPath && !validCaptureStartPath(startPath)) {
|
|
2098
|
+
const candidate = `/${startPath.replace(/^(?:\.\/|\/)+/, '')}`;
|
|
2099
|
+
if (validCaptureStartPath(candidate)) {
|
|
2100
|
+
repairs.startPath += 1;
|
|
2101
|
+
visuals.startPath = candidate;
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
const sequence = Array.isArray(visuals.captureSequence) ? visuals.captureSequence.filter((item) => typeof item === 'string' && item.trim().length > 0) : [];
|
|
2105
|
+
if ((text(visuals.workflow)?.length ?? 0) < 12 && sequence.length > 0) {
|
|
2106
|
+
const workflow = sequence.map((item) => item.split(/\s+—\s+/, 1)[0].trim()).filter(Boolean).join('; ');
|
|
2107
|
+
if (workflow.length >= 12) {
|
|
2108
|
+
repairs.workflow += 1;
|
|
2109
|
+
visuals.workflow = workflow;
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
return { ...page, visuals };
|
|
2113
|
+
});
|
|
2114
|
+
const guides = (count) => `${count} screenshot guide${count === 1 ? '' : 's'}`;
|
|
2115
|
+
if (repairs.relabeled > 0)
|
|
2116
|
+
process.stdout.write(`Marked ${guides(repairs.relabeled)} required: screenshots are required for this run, so best-effort visuals are not an option.\n`);
|
|
2117
|
+
if (repairs.rationale > 0)
|
|
2118
|
+
process.stdout.write(`Filled the visual purpose of ${guides(repairs.rationale)} from the page rationale the planner wrote instead.\n`);
|
|
2119
|
+
if (repairs.startPath > 0)
|
|
2120
|
+
process.stdout.write(`Normalized the start path of ${guides(repairs.startPath)} to an application-relative path.\n`);
|
|
2121
|
+
if (repairs.workflow > 0)
|
|
2122
|
+
process.stdout.write(`Derived the workflow of ${guides(repairs.workflow)} from the capture sequence the planner supplied.\n`);
|
|
2123
|
+
return { ...raw, pages: next };
|
|
2124
|
+
}
|
|
2125
|
+
export function requiredScreenshotPlanIssue(plan, execution) {
|
|
2126
|
+
if (normalizeScreenshotIntent(execution.screenshots) !== 'enabled')
|
|
2127
|
+
return undefined;
|
|
2128
|
+
const executablePages = plan.pages.filter((page) => page.priority !== 'later');
|
|
2129
|
+
const visualPages = executablePages.filter((page) => page.visuals && page.visuals.mode !== 'none');
|
|
2130
|
+
if (visualPages.length === 0) {
|
|
2131
|
+
return 'Required screenshot mode needs at least one complete screenshot-enabled visible UI guide; the proposal planned no application screenshots.';
|
|
2132
|
+
}
|
|
2133
|
+
const downgraded = visualPages.filter((page) => page.visuals?.mode !== 'required');
|
|
2134
|
+
if (downgraded.length > 0) {
|
|
2135
|
+
return `Required screenshot mode cannot use best-effort visuals. Mark ${downgraded.map((page) => `"${page.title}"`).join(', ')} as required.`;
|
|
2136
|
+
}
|
|
2137
|
+
const incomplete = visualPages.filter((page) => !page.visuals?.rationale.trim() ||
|
|
2138
|
+
page.visuals.estimatedCaptures < 1 ||
|
|
2139
|
+
!validCaptureStartPath(page.visuals.startPath) ||
|
|
2140
|
+
!page.visuals.workflow?.trim() ||
|
|
2141
|
+
page.visuals.workflow.trim().length < 12 ||
|
|
2142
|
+
!completeCaptureSequence(page.visuals.captureSequence, page.visuals.estimatedCaptures));
|
|
2143
|
+
if (incomplete.length > 0) {
|
|
2144
|
+
const details = incomplete.map((page) => {
|
|
2145
|
+
const reasons = [];
|
|
2146
|
+
if (!page.visuals?.rationale.trim())
|
|
2147
|
+
reasons.push('missing purpose');
|
|
2148
|
+
if (!validCaptureStartPath(page.visuals?.startPath))
|
|
2149
|
+
reasons.push(`invalid start path ${JSON.stringify(page.visuals?.startPath ?? '')}; use one leading slash, never //`);
|
|
2150
|
+
if (!page.visuals?.workflow?.trim() || page.visuals.workflow.trim().length < 12)
|
|
2151
|
+
reasons.push('missing ordered workflow');
|
|
2152
|
+
const sequenceItems = page.visuals?.captureSequence?.length ?? 0;
|
|
2153
|
+
if (!completeCaptureSequence(page.visuals?.captureSequence, page.visuals?.estimatedCaptures ?? 0))
|
|
2154
|
+
reasons.push(`planned ${page.visuals?.estimatedCaptures ?? 0} images but supplied ${sequenceItems} complete capture-sequence items`);
|
|
2155
|
+
return `"${page.title}": ${reasons.join('; ')}`;
|
|
2156
|
+
});
|
|
2157
|
+
return `Required screenshot guides are incomplete. ${details.join('. ')}.`;
|
|
2158
|
+
}
|
|
2159
|
+
return undefined;
|
|
2160
|
+
}
|
|
2161
|
+
/**
|
|
2162
|
+
* Thin screenshot coverage, reported for human review rather than enforced. One
|
|
2163
|
+
* guide covering several documented workflows usually means the planner only
|
|
2164
|
+
* ever saw the application's entry screen — but an application parked on its
|
|
2165
|
+
* first-run state genuinely has nothing else to show, and failing the plan for
|
|
2166
|
+
* that would leave the reviewer with no way forward.
|
|
2167
|
+
*/
|
|
2168
|
+
/** A screenshot-enabled guide needs at least one useful image, without a per-type quota. */
|
|
2169
|
+
export const MINIMUM_GUIDE_CAPTURES = 1;
|
|
2170
|
+
function singleScreenWorkflow(workflow, rationale) {
|
|
2171
|
+
return /\b(?:single|one|only one|a single)[ -](?:screen|state|view|page)\b|\bno (?:other|further|additional) (?:reachable )?(?:screen|state)/i.test(`${workflow ?? ''} ${rationale ?? ''}`);
|
|
2172
|
+
}
|
|
2173
|
+
/** Flag screenshot-enabled guides without a capture; one meaningful image is enough. */
|
|
2174
|
+
export function shallowCaptureAdvisory(plan, execution) {
|
|
2175
|
+
if (normalizeScreenshotIntent(execution.screenshots) === 'disabled')
|
|
2176
|
+
return undefined;
|
|
2177
|
+
const shallow = plan.pages.filter((page) => page.priority !== 'later' &&
|
|
2178
|
+
page.visuals && page.visuals.mode !== 'none' &&
|
|
2179
|
+
['how-to', 'tutorial', 'getting-started'].includes(page.type) &&
|
|
2180
|
+
page.visuals.estimatedCaptures < MINIMUM_GUIDE_CAPTURES &&
|
|
2181
|
+
!singleScreenWorkflow(page.visuals.workflow, page.visuals.rationale));
|
|
2182
|
+
if (shallow.length === 0)
|
|
2183
|
+
return undefined;
|
|
2184
|
+
const counts = shallow.map((page) => `"${page.title}" plans ${page.visuals.estimatedCaptures} capture${page.visuals.estimatedCaptures === 1 ? '' : 's'}`).join('; ');
|
|
2185
|
+
return `Screenshot-enabled guides need a meaningful capture: ${counts}. Reuse a saved image that proves the reader outcome, or mark the guide text-only with a concrete reason.`;
|
|
2186
|
+
}
|
|
2187
|
+
export function screenshotCoverageAdvisory(plan, execution) {
|
|
2188
|
+
if (normalizeScreenshotIntent(execution.screenshots) !== 'enabled')
|
|
2189
|
+
return undefined;
|
|
2190
|
+
const executablePages = plan.pages.filter((page) => page.priority !== 'later');
|
|
2191
|
+
const visualPages = executablePages.filter((page) => page.visuals && page.visuals.mode !== 'none');
|
|
2192
|
+
const procedural = executablePages.filter((page) => ['how-to', 'tutorial', 'getting-started'].includes(page.type) &&
|
|
2193
|
+
(page.action === 'create' || page.action === 'update'));
|
|
2194
|
+
if (visualPages.length === 0 || procedural.length < 2 || visualPages.length >= 2)
|
|
2195
|
+
return undefined;
|
|
2196
|
+
return `Only ${visualPages.length} of ${procedural.length} procedural pages has application screenshots. If the application should show more, it was probably sitting on its initial or empty state while planning ran: open the screens you want documented — sign in, select a workspace, or load example data — and plan again. Otherwise approve this plan and the remaining pages stay text-first.`;
|
|
2197
|
+
}
|
|
2198
|
+
/**
|
|
2199
|
+
* Top-level folders a site build writes to (build, dist, out, site, _build)
|
|
2200
|
+
* are never documentation: proposals skip them, so a page planned there would
|
|
2201
|
+
* be written and then silently left out. Move such a page to a content folder.
|
|
2202
|
+
*/
|
|
2203
|
+
const RESERVED_CONTENT_FOLDERS = { build: 'develop', dist: 'distribution', out: 'output', site: 'website', _build: 'building' };
|
|
2204
|
+
export function contentSafePath(path) {
|
|
2205
|
+
const [first, ...rest] = path.split('/');
|
|
2206
|
+
const replacement = first ? RESERVED_CONTENT_FOLDERS[first] : undefined;
|
|
2207
|
+
return replacement ? [replacement, ...rest].join('/') : path;
|
|
2208
|
+
}
|
|
2209
|
+
/**
|
|
2210
|
+
* Doxbrix serves a page at its file name, so two planned pages called
|
|
2211
|
+
* "troubleshooting" in different folders would publish as one. Later pages
|
|
2212
|
+
* with a taken name get their folder in front of it ("embed/embed-troubleshooting").
|
|
2213
|
+
*/
|
|
2214
|
+
export function uniquePageFileNames(pages) {
|
|
2215
|
+
const taken = new Set();
|
|
2216
|
+
for (const page of pages) {
|
|
2217
|
+
const parts = page.path.split('/');
|
|
2218
|
+
const name = parts.at(-1);
|
|
2219
|
+
if (name === 'index' || !taken.has(name)) {
|
|
2220
|
+
taken.add(name);
|
|
2221
|
+
continue;
|
|
2222
|
+
}
|
|
2223
|
+
const folder = parts.length > 1 ? parts.at(-2) : 'more';
|
|
2224
|
+
let candidate = `${folder}-${name}`;
|
|
2225
|
+
for (let n = 2; taken.has(candidate); n += 1)
|
|
2226
|
+
candidate = `${folder}-${name}-${n}`;
|
|
2227
|
+
page.path = [...parts.slice(0, -1), candidate].join('/');
|
|
2228
|
+
taken.add(candidate);
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
function normalizePage(raw, index) {
|
|
2232
|
+
const page = record(raw);
|
|
2233
|
+
const title = requiredText(page.title, `Page ${index + 1} title`);
|
|
2234
|
+
const path = contentSafePath(requiredText(page.path, `Page ${index + 1} path`).replaceAll('\\', '/').replace(/^\/+/, ''));
|
|
2235
|
+
if (!path || path.split('/').includes('..'))
|
|
2236
|
+
throw new DoxloopError(`Page path "${path}" is not a safe relative path.`);
|
|
2237
|
+
const priority = page.priority === 'next' || page.priority === 'later' ? page.priority : 'must-have';
|
|
2238
|
+
const action = page.action === 'update' || page.action === 'preserve' || page.action === 'remove' ? page.action : 'create';
|
|
2239
|
+
const evidence = stringList(page.evidence);
|
|
2240
|
+
const evidenceDetails = Array.isArray(page.evidenceDetails)
|
|
2241
|
+
? page.evidenceDetails.map(normalizeEvidence).filter((item) => item !== undefined)
|
|
2242
|
+
: evidence.map(evidenceFromLegacyText).filter((item) => item !== undefined);
|
|
2243
|
+
const visualsRaw = record(page.visuals);
|
|
2244
|
+
const visualMode = visualsRaw.mode === 'required' || visualsRaw.mode === 'recommended' ? visualsRaw.mode : 'none';
|
|
2245
|
+
const pageType = textValue(page.type) ?? 'other';
|
|
2246
|
+
const captureSequence = visualMode === 'none' ? [] : stringList(visualsRaw.captureSequence);
|
|
2247
|
+
const minimumCaptures = minimumPlannedCaptures(pageType, visualMode);
|
|
2248
|
+
const requestedCaptures = positiveInteger(visualsRaw.estimatedCaptures) ?? 0;
|
|
2249
|
+
const estimatedCaptures = visualMode === 'none'
|
|
2250
|
+
? 0
|
|
2251
|
+
: Math.min(20, captureSequence.length > 0 ? captureSequence.length : Math.max(minimumCaptures, requestedCaptures));
|
|
2252
|
+
const diagram = page.diagram === 'required' || page.diagram === 'none'
|
|
2253
|
+
? page.diagram
|
|
2254
|
+
: pageType.trim().toLowerCase() === 'concept' ? 'required' : 'none';
|
|
2255
|
+
return {
|
|
2256
|
+
id: safeId(textValue(page.id) ?? title, `page-${index + 1}`),
|
|
2257
|
+
title,
|
|
2258
|
+
path,
|
|
2259
|
+
type: pageType,
|
|
2260
|
+
priority,
|
|
2261
|
+
action,
|
|
2262
|
+
purpose: requiredText(page.purpose, `Page ${index + 1} purpose`),
|
|
2263
|
+
rationale: textValue(page.rationale) ?? '',
|
|
2264
|
+
evidence,
|
|
2265
|
+
evidenceDetails,
|
|
2266
|
+
diagram,
|
|
2267
|
+
visuals: {
|
|
2268
|
+
mode: visualMode,
|
|
2269
|
+
rationale: textValue(visualsRaw.rationale) ?? (visualMode === 'none' ? 'No meaningful visible application state is needed for this page.' : ''),
|
|
2270
|
+
estimatedCaptures,
|
|
2271
|
+
...(visualMode !== 'none' && textValue(visualsRaw.startPath) ? { startPath: textValue(visualsRaw.startPath) } : {}),
|
|
2272
|
+
...(visualMode !== 'none' && textValue(visualsRaw.workflow) ? { workflow: textValue(visualsRaw.workflow) } : {}),
|
|
2273
|
+
...(captureSequence.length > 0 ? { captureSequence: captureSequence.slice(0, 20), captureIds: captureSequence.slice(0, 20).map((_, index) => typeof visualsRaw.captureIds?.[index] === 'string' ? visualsRaw.captureIds[index] : '') } : {}),
|
|
2274
|
+
},
|
|
2275
|
+
};
|
|
2276
|
+
}
|
|
2277
|
+
function minimumPlannedCaptures(_type, mode) {
|
|
2278
|
+
if (mode === 'none')
|
|
2279
|
+
return 0;
|
|
2280
|
+
return 1;
|
|
2281
|
+
}
|
|
2282
|
+
function completeCaptureSequence(sequence, expected) {
|
|
2283
|
+
if (!sequence || sequence.length !== expected)
|
|
2284
|
+
return false;
|
|
2285
|
+
const normalized = sequence.map((item) => item.trim().toLowerCase());
|
|
2286
|
+
return sequence.every((item) => item.trim().length >= 20) && new Set(normalized).size === sequence.length;
|
|
2287
|
+
}
|
|
2288
|
+
/**
|
|
2289
|
+
* A guide's start path only ever tells the capture browser where to open; a
|
|
2290
|
+
* hash fragment is how single-page applications address a tab or panel
|
|
2291
|
+
* (`/setting#member`), so it is a valid destination rather than a defect.
|
|
2292
|
+
*/
|
|
2293
|
+
function validCaptureStartPath(value) {
|
|
2294
|
+
if (!value?.trim() || !value.startsWith('/') || value.startsWith('//') || /\s/.test(value))
|
|
2295
|
+
return false;
|
|
2296
|
+
try {
|
|
2297
|
+
const parsed = new URL(value, 'https://capture.invalid');
|
|
2298
|
+
return parsed.origin === 'https://capture.invalid' && !parsed.username && !parsed.password;
|
|
2299
|
+
}
|
|
2300
|
+
catch {
|
|
2301
|
+
return false;
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
/**
|
|
2305
|
+
* Check the application and each screenshot guide's starting route, and
|
|
2306
|
+
* describe every problem as a warning rather than refusing the run.
|
|
2307
|
+
*
|
|
2308
|
+
* A readiness probe is a plain HTTP request, so it can disagree with what the
|
|
2309
|
+
* capture browser will see (a hash-routed or client-rendered route, a route
|
|
2310
|
+
* only a signed-in session can open). Capture already degrades on its own: a
|
|
2311
|
+
* screen it cannot reach becomes text-only steps with the reason recorded. So
|
|
2312
|
+
* none of this blocks approval or generation; the reviewer sees the warnings.
|
|
2313
|
+
*/
|
|
2314
|
+
async function capturePlanWarnings(root, application, pages) {
|
|
2315
|
+
const auth = await captureAuthContext(root);
|
|
2316
|
+
const readiness = await checkApplicationReadiness(application, auth);
|
|
2317
|
+
if (!readiness.reachable) {
|
|
2318
|
+
return [`${CAPTURE_WARNING_PREFIX} ${readiness.message} Generation continues; guides whose screens cannot be captured are written with text-only steps.`];
|
|
2319
|
+
}
|
|
2320
|
+
const routes = await Promise.all(pages.map(async (page) => {
|
|
2321
|
+
const startPath = page.visuals?.startPath;
|
|
2322
|
+
if (!startPath || !validCaptureStartPath(startPath))
|
|
2323
|
+
return { page, startPath, message: 'The starting route is missing or invalid.' };
|
|
2324
|
+
const route = await checkApplicationReadiness(application ? { ...application, readyPath: startPath } : undefined, auth);
|
|
2325
|
+
return { page, startPath, message: route.reachable ? undefined : route.message };
|
|
2326
|
+
}));
|
|
2327
|
+
return routes
|
|
2328
|
+
.filter((route) => route.message)
|
|
2329
|
+
.map((route) => `${CAPTURE_WARNING_PREFIX} the starting route${route.startPath ? ` ${route.startPath}` : ''} for "${route.page.title}" did not respond as ready. ${route.message} Generation continues; the capture browser opens the application and navigates to the screen, and any step it cannot reach is written as text-only.`);
|
|
2330
|
+
}
|
|
2331
|
+
function normalizeCapability(raw, index) {
|
|
2332
|
+
const capability = record(raw);
|
|
2333
|
+
const title = requiredText(capability.title, `Capability ${index + 1} title`);
|
|
2334
|
+
const disposition = capability.disposition === 'existing' || capability.disposition === 'excluded' || capability.disposition === 'needs-human'
|
|
2335
|
+
? capability.disposition
|
|
2336
|
+
: 'planned';
|
|
2337
|
+
return {
|
|
2338
|
+
id: safeId(textValue(capability.id) ?? title, `capability-${index + 1}`),
|
|
2339
|
+
title,
|
|
2340
|
+
kind: textValue(capability.kind) ?? 'workflow',
|
|
2341
|
+
evidence: Array.isArray(capability.evidence)
|
|
2342
|
+
? capability.evidence.map(normalizeEvidence).filter((item) => item !== undefined)
|
|
2343
|
+
: [],
|
|
2344
|
+
pageIds: stringList(capability.pageIds),
|
|
2345
|
+
disposition,
|
|
2346
|
+
};
|
|
2347
|
+
}
|
|
2348
|
+
function normalizeNavigation(raw, pages) {
|
|
2349
|
+
const navigation = record(raw);
|
|
2350
|
+
const sectionsRaw = Array.isArray(navigation.sections) ? navigation.sections : [];
|
|
2351
|
+
const knownPageIds = new Set(pages.map((page) => page.id));
|
|
2352
|
+
// A page lives in one navigation section (the first that lists it, which is
|
|
2353
|
+
// where the post-pass puts it); a section left with no page of its own,
|
|
2354
|
+
// like a "Protocol limits" that only repeated pages from earlier sections,
|
|
2355
|
+
// is dropped rather than becoming an empty group.
|
|
2356
|
+
const claimed = new Set();
|
|
2357
|
+
const sections = sectionsRaw.map((rawSection, index) => {
|
|
2358
|
+
const section = record(rawSection);
|
|
2359
|
+
const title = textValue(section.title) ?? `Section ${index + 1}`;
|
|
2360
|
+
const pageIds = stringList(section.pageIds).filter((id) => knownPageIds.has(id) && !claimed.has(id));
|
|
2361
|
+
for (const id of pageIds)
|
|
2362
|
+
claimed.add(id);
|
|
2363
|
+
const space = textValue(section.space);
|
|
2364
|
+
return {
|
|
2365
|
+
id: safeId(textValue(section.id) ?? title, `section-${index + 1}`),
|
|
2366
|
+
title,
|
|
2367
|
+
pageIds,
|
|
2368
|
+
...(space ? { space } : {}),
|
|
2369
|
+
};
|
|
2370
|
+
}).filter((section) => section.pageIds.length > 0);
|
|
2371
|
+
if (sections.length === 0 && pages.length > 0) {
|
|
2372
|
+
const grouped = new Map();
|
|
2373
|
+
for (const page of pages)
|
|
2374
|
+
grouped.set(page.type, [...(grouped.get(page.type) ?? []), page.id]);
|
|
2375
|
+
for (const [type, pageIds] of grouped)
|
|
2376
|
+
sections.push({ id: safeId(type, 'documentation'), title: titleCase(type), pageIds });
|
|
2377
|
+
}
|
|
2378
|
+
const top = stringList(navigation.top).length ? stringList(navigation.top) : ['Documentation'];
|
|
2379
|
+
return { top, sections: assignSectionSpaces(top, sections) };
|
|
2380
|
+
}
|
|
2381
|
+
function normalizeEvidence(raw) {
|
|
2382
|
+
const evidence = record(raw);
|
|
2383
|
+
const source = textValue(evidence.source);
|
|
2384
|
+
const path = textValue(evidence.path);
|
|
2385
|
+
if (!source || !path || path.split(/[\\/]/).includes('..'))
|
|
2386
|
+
return undefined;
|
|
2387
|
+
return {
|
|
2388
|
+
source,
|
|
2389
|
+
path: path.replaceAll('\\', '/').replace(/^\/+/, ''),
|
|
2390
|
+
...(textValue(evidence.kind) ? { kind: textValue(evidence.kind) } : {}),
|
|
2391
|
+
...(textValue(evidence.label) ? { label: textValue(evidence.label) } : {}),
|
|
2392
|
+
...(positiveInteger(evidence.line) ? { line: positiveInteger(evidence.line) } : {}),
|
|
2393
|
+
};
|
|
2394
|
+
}
|
|
2395
|
+
function evidenceFromLegacyText(value) {
|
|
2396
|
+
const separator = value.indexOf(':');
|
|
2397
|
+
if (separator <= 0)
|
|
2398
|
+
return undefined;
|
|
2399
|
+
const source = value.slice(0, separator).trim();
|
|
2400
|
+
const detail = value.slice(separator + 1).trim();
|
|
2401
|
+
if (!source || !detail)
|
|
2402
|
+
return undefined;
|
|
2403
|
+
const path = detail.split(/\s+(?:plus|and|—|->)\s+/i)[0]?.trim() ?? detail;
|
|
2404
|
+
return normalizeEvidence({ source, path, label: detail });
|
|
2405
|
+
}
|
|
2406
|
+
function normalizeQuestion(raw, index) {
|
|
2407
|
+
const question = record(raw);
|
|
2408
|
+
return {
|
|
2409
|
+
id: safeId(textValue(question.id) ?? `question-${index + 1}`, `question-${index + 1}`),
|
|
2410
|
+
question: requiredText(question.question, `Question ${index + 1}`),
|
|
2411
|
+
whyItMatters: textValue(question.whyItMatters) ?? '',
|
|
2412
|
+
...(textValue(question.recommendation) ? { recommendation: textValue(question.recommendation) } : {}),
|
|
2413
|
+
};
|
|
2414
|
+
}
|
|
2415
|
+
async function normalizePersistedPlan(root, raw, id) {
|
|
2416
|
+
const plan = record(raw);
|
|
2417
|
+
if ((plan.schemaVersion !== 1 && plan.schemaVersion !== 2) || plan.id !== id || typeof plan.version !== 'number') {
|
|
2418
|
+
throw new DoxloopError(`Documentation plan ${id} is invalid.`);
|
|
2419
|
+
}
|
|
2420
|
+
if (plan.schemaVersion === 2) {
|
|
2421
|
+
if (!isPersistedPlanV2(plan))
|
|
2422
|
+
throw new DoxloopError(`Documentation plan ${id} is invalid.`);
|
|
2423
|
+
return { plan: raw, migrated: false };
|
|
2424
|
+
}
|
|
2425
|
+
const legacy = raw;
|
|
2426
|
+
const project = await loadProject(root);
|
|
2427
|
+
const pages = legacy.pages.map((page) => ({ ...page, evidenceDetails: page.evidence.map(evidenceFromLegacyText).filter((item) => item !== undefined) }));
|
|
2428
|
+
return {
|
|
2429
|
+
migrated: true,
|
|
2430
|
+
plan: {
|
|
2431
|
+
...legacy,
|
|
2432
|
+
schemaVersion: 2,
|
|
2433
|
+
experienceLevel: project.documentation.experienceLevel ?? 'mixed',
|
|
2434
|
+
preferredExamples: [],
|
|
2435
|
+
locale: project.documentation.locale,
|
|
2436
|
+
accessibilityTarget: project.documentation.accessibilityTarget,
|
|
2437
|
+
styleGuide: project.documentation.styleGuide,
|
|
2438
|
+
capabilities: [],
|
|
2439
|
+
navigation: normalizeNavigation({}, pages),
|
|
2440
|
+
pages,
|
|
2441
|
+
estimatedPages: pages.filter((page) => page.action === 'create' || page.action === 'update').length,
|
|
2442
|
+
discovery: {
|
|
2443
|
+
cacheKey: legacy.sourceSnapshot,
|
|
2444
|
+
generatedAt: legacy.updatedAt,
|
|
2445
|
+
deterministic: true,
|
|
2446
|
+
publicSignals: 0,
|
|
2447
|
+
suggestedPages: {
|
|
2448
|
+
starter: Math.max(3, Math.min(5, pages.length)),
|
|
2449
|
+
standard: Math.max(7, pages.length),
|
|
2450
|
+
comprehensive: Math.max(12, pages.length),
|
|
2451
|
+
},
|
|
2452
|
+
},
|
|
2453
|
+
target: await documentationPlanTarget(root, project),
|
|
2454
|
+
clarification: { mode: 'review', answers: {} },
|
|
2455
|
+
},
|
|
2456
|
+
};
|
|
2457
|
+
}
|
|
2458
|
+
function isPersistedPlanV2(plan) {
|
|
2459
|
+
const discovery = record(plan.discovery);
|
|
2460
|
+
const target = record(plan.target);
|
|
2461
|
+
const navigation = record(plan.navigation);
|
|
2462
|
+
return (typeof plan.id === 'string' &&
|
|
2463
|
+
typeof plan.version === 'number' &&
|
|
2464
|
+
typeof plan.status === 'string' &&
|
|
2465
|
+
typeof plan.scope === 'string' &&
|
|
2466
|
+
typeof plan.sourceSnapshot === 'string' &&
|
|
2467
|
+
Array.isArray(plan.audiences) &&
|
|
2468
|
+
Array.isArray(plan.outcomes) &&
|
|
2469
|
+
Array.isArray(plan.exclusions) &&
|
|
2470
|
+
Array.isArray(plan.preferredExamples) &&
|
|
2471
|
+
Array.isArray(plan.capabilities) &&
|
|
2472
|
+
Array.isArray(plan.pages) &&
|
|
2473
|
+
Array.isArray(plan.questions) &&
|
|
2474
|
+
typeof plan.experienceLevel === 'string' &&
|
|
2475
|
+
typeof plan.locale === 'string' &&
|
|
2476
|
+
typeof plan.accessibilityTarget === 'string' &&
|
|
2477
|
+
typeof plan.styleGuide === 'string' &&
|
|
2478
|
+
typeof plan.estimatedPages === 'number' &&
|
|
2479
|
+
Array.isArray(navigation.top) &&
|
|
2480
|
+
Array.isArray(navigation.sections) &&
|
|
2481
|
+
typeof discovery.cacheKey === 'string' &&
|
|
2482
|
+
discovery.deterministic === true &&
|
|
2483
|
+
typeof discovery.publicSignals === 'number' &&
|
|
2484
|
+
typeof target.generator === 'string' &&
|
|
2485
|
+
Array.isArray(target.pageExtensions) &&
|
|
2486
|
+
Array.isArray(target.navigationFiles) &&
|
|
2487
|
+
typeof record(plan.clarification).mode === 'string');
|
|
2488
|
+
}
|
|
2489
|
+
async function persistPlan(root, plan, archive) {
|
|
2490
|
+
const directory = join(root, PLANS_DIRECTORY, plan.id);
|
|
2491
|
+
await mkdir(join(directory, VERSIONS_DIRECTORY), { recursive: true });
|
|
2492
|
+
if (archive) {
|
|
2493
|
+
await atomicWrite(join(directory, VERSIONS_DIRECTORY, `v${plan.version}.json`), `${JSON.stringify(plan, null, 2)}\n`);
|
|
2494
|
+
}
|
|
2495
|
+
await atomicWrite(join(directory, PLAN_FILE), `${JSON.stringify(plan, null, 2)}\n`);
|
|
2496
|
+
}
|
|
2497
|
+
async function atomicWrite(path, content) {
|
|
2498
|
+
const temporary = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
2499
|
+
await mkdir(dirname(path), { recursive: true });
|
|
2500
|
+
await writeFile(temporary, content, { encoding: 'utf8', mode: 0o600 });
|
|
2501
|
+
await rename(temporary, path);
|
|
2502
|
+
}
|
|
2503
|
+
function planHash(plan) {
|
|
2504
|
+
const { status: _status, updatedAt: _updatedAt, approvedAt: _approvedAt, approvedHash: _approvedHash, proposalId: _proposalId, error: _error, failure: _failure, ...content } = plan;
|
|
2505
|
+
return createHash('sha256').update(JSON.stringify(content)).digest('hex');
|
|
2506
|
+
}
|
|
2507
|
+
function planPath(root, id) {
|
|
2508
|
+
assertPlanId(id);
|
|
2509
|
+
return join(root, PLANS_DIRECTORY, id, PLAN_FILE);
|
|
2510
|
+
}
|
|
2511
|
+
function planId() {
|
|
2512
|
+
return `plan-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}-${randomBytes(3).toString('hex')}`;
|
|
2513
|
+
}
|
|
2514
|
+
function assertPlanId(id) {
|
|
2515
|
+
if (!/^plan-[a-z0-9-]+$/.test(id))
|
|
2516
|
+
throw new DoxloopError('Invalid documentation plan ID.');
|
|
2517
|
+
}
|
|
2518
|
+
function record(value) {
|
|
2519
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
2520
|
+
return {};
|
|
2521
|
+
return value;
|
|
2522
|
+
}
|
|
2523
|
+
function textValue(value) {
|
|
2524
|
+
return typeof value === 'string' ? value.trim() : undefined;
|
|
2525
|
+
}
|
|
2526
|
+
function requiredText(value, label) {
|
|
2527
|
+
const text = textValue(value);
|
|
2528
|
+
if (!text)
|
|
2529
|
+
throw new DoxloopError(`${label} is required.`);
|
|
2530
|
+
return text;
|
|
2531
|
+
}
|
|
2532
|
+
function stringList(value) {
|
|
2533
|
+
if (!Array.isArray(value))
|
|
2534
|
+
return [];
|
|
2535
|
+
return [...new Set(value.filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean))];
|
|
2536
|
+
}
|
|
2537
|
+
function stringRecord(value) {
|
|
2538
|
+
const input = record(value);
|
|
2539
|
+
return Object.fromEntries(Object.entries(input).flatMap(([key, item]) => typeof item === 'string' && key.trim() && item.trim() ? [[key.trim(), item.trim()]] : []));
|
|
2540
|
+
}
|
|
2541
|
+
function scopeValue(value) {
|
|
2542
|
+
return value === 'starter' || value === 'standard' || value === 'comprehensive' || value === 'custom' ? value : undefined;
|
|
2543
|
+
}
|
|
2544
|
+
function experienceLevelValue(value) {
|
|
2545
|
+
return value === 'beginner' || value === 'intermediate' || value === 'advanced' || value === 'mixed' ? value : undefined;
|
|
2546
|
+
}
|
|
2547
|
+
function positiveInteger(value) {
|
|
2548
|
+
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined;
|
|
2549
|
+
}
|
|
2550
|
+
function titleCase(value) {
|
|
2551
|
+
return value.replaceAll('-', ' ').replace(/\b\w/g, (character) => character.toUpperCase());
|
|
2552
|
+
}
|
|
2553
|
+
function estimatedPagesForScope(scope, discovery) {
|
|
2554
|
+
if (scope === 'starter')
|
|
2555
|
+
return discovery.suggestedPages.starter;
|
|
2556
|
+
if (scope === 'comprehensive')
|
|
2557
|
+
return discovery.suggestedPages.comprehensive;
|
|
2558
|
+
return discovery.suggestedPages.standard;
|
|
2559
|
+
}
|
|
2560
|
+
function safeId(value, fallback) {
|
|
2561
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || fallback;
|
|
2562
|
+
}
|
|
2563
|
+
//# sourceMappingURL=documentation-plan.js.map
|