From 90beb848bf85356a12b5b60b61ed3ddb0044ef78 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 20 Jul 2026 08:52:42 -0400 Subject: [PATCH 1/4] Generate Docs Agent completion reports on host --- .github/workflows/README.md | 4 +- .github/workflows/maintain-docs.yml | 28 +- README.md | 16 +- .../docs-agent-completion-report.v1.json | 53 +-- scripts/validate-docs-agent-completion.php | 365 +++++------------- ...alidate-docs-agent-completion-contract.php | 244 +++--------- tests/validate-docs-agent-packages.php | 7 +- 7 files changed, 184 insertions(+), 533 deletions(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index cd29e7f..bb54870 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -10,9 +10,9 @@ Schedule skills upkeep separately from docs upkeep. The skills lane should use a When validation setup, verification commands, or drift checks are needed, pass them through the reusable workflow inputs above. `validation_dependencies` is an optional caller-owned command that runs before verification during a live execution. The reusable workflow includes those executable inputs in the portable recipe and keeps the target repository as the writable Docs Agent workspace. -Maintenance permits an evidence-backed no-op only for a non-empty caller-bounded `source_delta`. Every delta item must have source refs and a matching evidence-backed report item that covers them; exactly one versioned report from the canonical native transcript must agree with the host diff. Bootstrap may instead use inventory mode with its positive contract. Set `require_pr: true` for changed maintenance that must produce a valid target-repository PR; bootstrap changes always require publication. +Maintenance permits a no-op only for a non-empty caller-bounded `source_delta`. The completion validator derives the report from those validated records, the host Git diff, writable paths, and filesystem bootstrap checks; model final prose is supplementary transcript evidence only. Bootstrap uses inventory mode with its positive contract. Set `require_pr: true` for changed maintenance that must produce a valid target-repository PR; bootstrap changes always require publication. -The reusable workflow declares the expected typed review artifacts for Docs Agent runs: transcript, change summary, verification report, drift report, completion report, and workspace publication links. `maintain-docs.yml` writes those declarations into the portable recipe and exposes the declaration objects through `declared_artifacts_json`. The completion runtime declaration remains optional for producer lifecycle compatibility, but the final completion drift command has a mandatory generic `{name,type,path}` artifact output. The Docs Agent-owned validator reads the report from the canonical transcript, compares it with the applied host diff, writes canonical bytes under `.codebox/agent-task-artifacts`, and WP Codebox stages only that validated declared artifact for review. +The reusable workflow declares the expected typed review artifacts for Docs Agent runs: transcript, change summary, verification report, drift report, completion report, and workspace publication links. `maintain-docs.yml` writes those declarations into the portable recipe and exposes the declaration objects through `declared_artifacts_json`. The completion runtime declaration remains optional for producer lifecycle compatibility, but the final completion drift command has a mandatory generic `{name,type,path}` artifact output. The Docs Agent-owned validator generates canonical host-observed bytes under `.codebox/agent-task-artifacts`, and WP Codebox stages only that declared artifact for review. The target repository grants `contents: write`, `pull-requests: write`, and `issues: write`. Docs Agent forwards the caller-scoped `${{ github.token }}` to WP Codebox for same-repository publication, so consumers do not configure `ACCESS_TOKEN`. `OPENAI_API_KEY` is an optional workflow secret and is required only for a live OpenAI run; skipped and dry-run calls do not require it. `EXTERNAL_PACKAGE_SOURCE_POLICY` remains a separate required v1 JSON secret. Migrate its value to the exact one-line JSON in the root README: it authorizes the selected Docs Agent package, the pinned Agents API component, the pinned PHP AI Client overlay, and the checksum-pinned OpenAI provider artifact. Both secrets are forwarded to WP Codebox without serialization into the task descriptor. diff --git a/.github/workflows/maintain-docs.yml b/.github/workflows/maintain-docs.yml index 7e08818..e4479be 100644 --- a/.github/workflows/maintain-docs.yml +++ b/.github/workflows/maintain-docs.yml @@ -242,30 +242,18 @@ jobs: {"schema":"docs-agent/artifact-declaration/v1","name":"docs_agent_change_summary","type":"DocsAgentChangeSummary","artifact_schema":"docs-agent/change-summary/v1","description":"Reviewable summary of documentation or skill changes made by the run.","required":false}, {"schema":"docs-agent/artifact-declaration/v1","name":"docs_agent_verification_report","type":"DocsAgentVerificationReport","artifact_schema":"docs-agent/verification-report/v1","description":"Runner verification command results for the target workspace.","required":false}, {"schema":"docs-agent/artifact-declaration/v1","name":"docs_agent_drift_report","type":"DocsAgentDriftReport","artifact_schema":"docs-agent/drift-report/v1","description":"Drift-check results for generated documentation, skills, or packaged outputs.","required":false}, - {"schema":"docs-agent/artifact-declaration/v1","name":"docs_agent_completion_report","type":"DocsAgentCompletionReport","artifact_schema":"docs-agent/completion-report/v1","description":"Versioned completion and drift report validated from the canonical native transcript and staged by WP Codebox.","required":false}, + {"schema":"docs-agent/artifact-declaration/v1","name":"docs_agent_completion_report","type":"DocsAgentCompletionReport","artifact_schema":"docs-agent/completion-report/v1","description":"Versioned host-observed completion and drift report staged by WP Codebox.","required":false}, {"schema":"docs-agent/artifact-declaration/v1","name":"docs_agent_workspace_publication","type":"DocsAgentWorkspacePublication","artifact_schema":"docs-agent/workspace-publication/v1","description":"Canonical branch and pull request links published by the runner workspace.","required":false} ]' context_prompt="$(printf '%s' "$context_repositories" | jq -r 'if length == 0 then "No additional context repositories are declared." else "Read-only context repositories (cite aliases in evidence):\n" + (map("- " + .alias + ": " + .repository + "@" + .revision) | join("\n")) end')" - completion_prompt="$(jq -nr --arg lane "$AUDIENCE" --arg run_kind "$RUN_KIND" --argjson bootstrap "$bootstrap_contract" --argjson delta "$source_delta" ' - ($delta | map(.source_refs) | add // ["repository inventory"]) as $source_refs | - (if $run_kind == "maintenance" then - ($delta | map({id,source_refs,documentation_paths:["docs/example.md"],disposition:(if .requires_documentation_change then "updated" else "verified_current" end),evidence:"Describe the source comparison and documentation decision."})) - else - [{id:"inventory",source_refs:$source_refs,documentation_paths:["README.md","docs/README.md"],disposition:"updated",evidence:"Describe the repository inventory and resulting documentation system."}] - end) as $items | - ({schema:"docs-agent/completion-report/v1",lane:$lane,run_kind:$run_kind,outcome:"changes",scope:{source_basis:(if $run_kind == "maintenance" then "bounded_delta" else "inventory" end),source_refs:$source_refs,documentation_surfaces:["README.md","docs/example.md"]},items:$items,changed_paths:["docs/example.md"]}|tojson) as $report_shape | - "Completion contract (mandatory):\n" + - "- End with exactly one {JSON} block and no other report block.\n" + - "- Return valid compact JSON with double-quoted keys and strings, no markdown fence, no comments, no placeholders, and no trailing commas.\n" + - "- JSON schema: docs-agent/completion-report/v1; lane=" + $lane + "; run_kind=" + $run_kind + ".\n" + - "- Include outcome (changes or no_changes), scope.source_basis, non-empty scope.source_refs and scope.documentation_surfaces, non-empty evidence-backed per-item dispositions, and changed_paths.\n" + - "- Maintenance must use bounded_delta and cover every caller source_delta ID and source ref in its matching evidence-backed report item. Bootstrap may use inventory with its positive bootstrap contract.\n" + - "- changed_paths must exactly match workspace_git_status/workspace_git_diff. A write call without a byte diff is not a change.\n" + - "- For no_changes, use only verified_current or not_documentation_relevant dispositions, cover every bounded source delta, and leave the documentation diff clean.\n" + - "- For changes, use created/updated dispositions and report every actual changed path. Never write outside writable paths.\n" + + completion_prompt="$(jq -nr --argjson bootstrap "$bootstrap_contract" --argjson delta "$source_delta" ' + "Completion contract:\n" + + "- Interpret the caller-known source delta and edit only within writable paths when source evidence requires documentation updates.\n" + + "- Leave the workspace clean when the documentation is current.\n" + + "- Every source delta marked requires_documentation_change needs an actual Markdown or MDX workspace diff.\n" + "- Bootstrap must satisfy every caller criterion and create a substantive navigable initial documentation system on a fresh surface.\n" + - "Exact report shape (replace example documentation paths, evidence, dispositions, outcome, and changed_paths with verified values; preserve caller IDs and source refs):\n" + $report_shape + "\n" + + "- The host generates and stages the completion report from caller inputs, Git, and filesystem checks; finish with normal concise prose.\n" + "Bootstrap contract JSON: " + ($bootstrap|tojson) + "\n" + "Caller-known source delta JSON: " + ($delta|tojson) ')" @@ -322,7 +310,7 @@ jobs: bootstrap_contract_b64="$(printf '%s' "$bootstrap_contract" | base64 -w0)" source_delta_b64="$(printf '%s' "$source_delta" | base64 -w0)" completion_artifact_path="docs-agent-completion-report.json" - completion_command="curl --fail --silent --show-error '$contract_url' --output .codebox/validate-docs-agent-completion.php && php .codebox/validate-docs-agent-completion.php --workspace . --lane '$AUDIENCE' --run-kind '$RUN_KIND' --writable-paths '$INPUT_WRITABLE_PATHS' --bootstrap-contract-b64 '$bootstrap_contract_b64' --source-delta-b64 '$source_delta_b64' --transcript-root .codebox/agent-task-artifacts --artifact-path '$completion_artifact_path'" + completion_command="curl --fail --silent --show-error '$contract_url' --output .codebox/validate-docs-agent-completion.php && php .codebox/validate-docs-agent-completion.php --workspace . --lane '$AUDIENCE' --run-kind '$RUN_KIND' --writable-paths '$INPUT_WRITABLE_PATHS' --bootstrap-contract-b64 '$bootstrap_contract_b64' --source-delta-b64 '$source_delta_b64' --artifact-path '$completion_artifact_path'" completion_check="$(jq -cn --arg command "$completion_command" --arg path "$completion_artifact_path" '{command:$command,description:"Validate Docs Agent completion report against the actual workspace diff",artifact:{name:"docs_agent_completion_report",type:"DocsAgentCompletionReport",path:$path}}')" effective_drift_checks="$(jq -cn --argjson caller "$drift_checks" --argjson completion "$completion_check" '$caller + [$completion]')" allowed_repos="$(printf '%s' "$context_repositories" | jq -c --arg target "$GITHUB_REPOSITORY_NAME" '([$target] + map(.repository)) | unique')" diff --git a/README.md b/README.md index 5d405f8..332d261 100644 --- a/README.md +++ b/README.md @@ -129,10 +129,10 @@ Docs Agent declares the review artifacts it expects the runner to materialize as | `docs_agent_change_summary` | `docs-agent/change-summary/v1` | Reviewable summary of documentation or skill changes. | | `docs_agent_verification_report` | `docs-agent/verification-report/v1` | Verification command results for the target workspace. | | `docs_agent_drift_report` | `docs-agent/drift-report/v1` | Drift-check results for generated docs, skills, or packaged outputs. | -| `docs_agent_completion_report` | `docs-agent/completion-report/v1` | Native completion and source-to-document disposition report validated against the host diff. | +| `docs_agent_completion_report` | `docs-agent/completion-report/v1` | Host-observed completion report generated from caller inputs, Git, and filesystem checks. | | `docs_agent_workspace_publication` | `docs-agent/workspace-publication/v1` | Canonical branch and pull request links published by the runner workspace. | -`maintain-docs.yml` writes `expected_artifacts` and `artifact_declarations` into a portable Docs Agent recipe and exposes the same declaration objects as `declared_artifacts_json`. The runtime declaration for `docs_agent_completion_report` remains `required: false` because WP Codebox may evaluate runtime-level required artifacts before post-command validation. The final completion drift entry is mandatory and declares `{name,type,path}`; after successful semantic validation, the validator atomically writes canonical JSON to `.codebox/agent-task-artifacts/docs-agent-completion-report.json`, and WP Codebox stages that declared command artifact for reviewers. +`maintain-docs.yml` writes `expected_artifacts` and `artifact_declarations` into a portable Docs Agent recipe and exposes the same declaration objects as `declared_artifacts_json`. The runtime declaration for `docs_agent_completion_report` remains `required: false` because WP Codebox may evaluate runtime-level required artifacts before post-command validation. The final completion drift entry is mandatory and declares `{name,type,path}`; after deterministic host validation, the validator atomically writes canonical JSON to `.codebox/agent-task-artifacts/docs-agent-completion-report.json`, and WP Codebox stages that declared command artifact for reviewers. The reusable workflow and helper implementation are pinned to accepted revision `a6fe2d208e990a8d04104aa74aacbb8d1539fbc1`; `v0.12.29` remains only the `wp_codebox_release_ref` for released packaged runtime assets at `bc982947ec33c78160125026e16d357b7ece3ea1`. @@ -147,25 +147,25 @@ Portable recipe fields include `docsAgent`, `runner.contextRepositories`, `runne A live run succeeds only when four independent layers pass: 1. Agents API completes the selected native package through WP Codebox. -2. Docs Agent validates exactly one `docs-agent/completion-report/v1` from the canonical transcript against the actual host workspace diff and writable paths. +2. Docs Agent generates `docs-agent/completion-report/v1` from validated caller inputs, actual host workspace changes, writable paths, and bootstrap filesystem checks. 3. Caller dependency, verification, and drift commands pass. 4. WP Codebox publishes and verifies a pull request when the outcome and caller policy require one. -For `changes`, `changed_paths` must exactly equal the Git diff, every path must be writable, and at least one evidence-backed item must be `created` or `updated`. A write-tool call that leaves no byte diff fails as `CHANGES_DIFF_EMPTY`. +For `changes`, `changed_paths` exactly equals the Git diff and every path must be writable. A write-tool call that leaves no byte diff produces `no_changes`. -For every maintenance outcome, `source_delta` must be non-empty and caller-bounded: each item has a unique ID and non-empty source refs, the report uses `bounded_delta`, and its matching evidence-backed item covers every declared ref. For maintenance `no_changes`, the documentation diff must also be clean and dispositions may only be `verified_current` or `not_documentation_relevant`. Caller-known drift marked `requires_documentation_change: true` cannot be reported as no-change. Bootstrap may instead use `inventory` with its required positive bootstrap contract. Missing, malformed, empty, incomplete, contradictory, diff-mismatched, and out-of-scope reports fail with distinct `docs-agent.completion-contract.*` diagnostics. +For every maintenance outcome, `source_delta` must be non-empty and caller-bounded: each item has a unique ID and non-empty source refs, and those records are copied canonically into the report. Caller-known drift marked `requires_documentation_change: true` requires an actual Markdown or MDX diff. Bootstrap uses `inventory` with its required positive bootstrap contract and deterministic substantive filesystem checks. Model final prose is never parsed or used for completion. -The untrusted report originates in the native canonical transcript, not as a target-repository file, so honest no-change runs remain clean and do not trigger publication. Only after semantic validation does the validator write canonical report bytes under the excluded `.codebox` artifact root. The command artifact declaration lets WP Codebox expose those validated bytes to reviewers; a runtime declaration or private transcript alone never satisfies semantic completion. +The validator writes canonical report bytes under the excluded `.codebox` artifact root, so honest no-change runs remain clean and do not trigger publication. The command artifact declaration lets WP Codebox expose those deterministic bytes to reviewers; a runtime declaration or private transcript alone never satisfies semantic completion. ## Pull Request Behavior Docs Agent opens or updates one canonical PR for the configured branch. -- If the selected maintenance surface is current, the run succeeds only with a non-empty caller-bounded source delta, complete evidence-backed coverage, and no workspace diff. +- If the selected maintenance surface is current, the run succeeds only with a non-empty caller-bounded source delta and no workspace diff. - If maintenance is needed, changes are written only under `writable_paths`. - If the canonical PR is already open, later runs reuse the same `docs_branch` and PR instead of creating duplicates. - `validation_dependencies` is an optional caller-owned reusable-workflow input. It is passed through the portable recipe and runs before verification commands when a live runner execution needs setup. -- `job_status`, `transcript_summary`, `credential_mode`, `success_requires_pr`, `validation_dependencies`, and bounded `projected_outputs_json` are exposed as reusable workflow outputs. A `run_agent: false` call returns `job_status: skipped`; a `dry_run: true` call validates without starting a model run. `OPENAI_API_KEY` is only required for a live OpenAI run and is never included in recipes, workflow outputs, or artifacts. Bootstrap lanes require a published pull request and its projected URL for success. Maintenance lanes allow a bounded evidence-backed no-change result by default; set `require_pr: true` when an acceptance or remediation run must fail without a valid target-repository PR. Runtime typed artifact declarations remain optional, while the completion post-command artifact is mandatory and exposed through WP Codebox declared-artifact staging. Raw engine data is not exposed as a workflow output or uploaded reviewer artifact. +- `job_status`, `transcript_summary`, `credential_mode`, `success_requires_pr`, `validation_dependencies`, and bounded `projected_outputs_json` are exposed as reusable workflow outputs. A `run_agent: false` call returns `job_status: skipped`; a `dry_run: true` call validates without starting a model run. `OPENAI_API_KEY` is only required for a live OpenAI run and is never included in recipes, workflow outputs, or artifacts. Bootstrap lanes require a published pull request and its projected URL for success. Maintenance lanes allow a bounded no-change result by default; set `require_pr: true` when an acceptance or remediation run must fail without a valid target-repository PR. Runtime typed artifact declarations remain optional, while the completion post-command artifact is mandatory and exposed through WP Codebox declared-artifact staging. Raw engine data is not exposed as a workflow output or uploaded reviewer artifact. ## Quality Bar diff --git a/contracts/docs-agent-completion-report.v1.json b/contracts/docs-agent-completion-report.v1.json index d5496c9..c1ac223 100644 --- a/contracts/docs-agent-completion-report.v1.json +++ b/contracts/docs-agent-completion-report.v1.json @@ -1,55 +1,28 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://raw.githubusercontent.com/Automattic/docs-agent/main/contracts/docs-agent-completion-report.v1.json", - "title": "Docs Agent completion and drift report v1", + "title": "Docs Agent host-observed completion report v1", "type": "object", "additionalProperties": false, - "required": ["schema", "lane", "run_kind", "outcome", "scope", "items", "changed_paths"], + "required": ["schema", "lane", "run_kind", "outcome", "changed_paths", "writable_paths", "source", "bootstrap", "checks"], "properties": { "schema": { "const": "docs-agent/completion-report/v1" }, "lane": { "enum": ["technical", "user", "skills"] }, "run_kind": { "enum": ["bootstrap", "maintenance"] }, "outcome": { "enum": ["changes", "no_changes"] }, - "scope": { - "type": "object", - "additionalProperties": false, - "required": ["source_basis", "source_refs", "documentation_surfaces"], + "changed_paths": { "$ref": "#/$defs/strings" }, + "writable_paths": { "$ref": "#/$defs/nonEmptyStrings" }, + "source": { + "type": "object", "additionalProperties": false, "required": ["basis", "refs", "records"], "properties": { - "source_basis": { "enum": ["inventory", "bounded_delta"] }, - "source_refs": { "$ref": "#/$defs/nonEmptyStrings" }, - "documentation_surfaces": { "$ref": "#/$defs/nonEmptyStrings" } + "basis": { "enum": ["inventory", "bounded_delta"] }, + "refs": { "$ref": "#/$defs/nonEmptyStrings" }, + "records": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["id", "source_refs", "requires_documentation_change"], "properties": { "id": { "type": "string", "minLength": 1 }, "source_refs": { "$ref": "#/$defs/nonEmptyStrings" }, "requires_documentation_change": { "type": "boolean" } } } } } }, - "items": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "source_refs", "documentation_paths", "disposition", "evidence"], - "properties": { - "id": { "type": "string", "minLength": 1 }, - "source_refs": { "$ref": "#/$defs/nonEmptyStrings" }, - "documentation_paths": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, - "disposition": { "enum": ["created", "updated", "verified_current", "not_documentation_relevant"] }, - "evidence": { "type": "string", "minLength": 1 } - } - } - }, - "changed_paths": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true } + "bootstrap": { "type": "object", "additionalProperties": false, "required": ["criteria_satisfied"], "properties": { "criteria_satisfied": { "type": "boolean" } } }, + "checks": { "type": "object", "additionalProperties": false, "required": ["workspace", "writable_scope", "source_drift", "bootstrap", "verification", "drift_checks"], "properties": { "workspace": { "const": "passed" }, "writable_scope": { "const": "passed" }, "source_drift": { "const": "passed" }, "bootstrap": { "enum": ["passed", "not_applicable"] }, "verification": { "const": "passed" }, "drift_checks": { "const": "passed" } } } }, - "allOf": [ - { - "if": { "properties": { "run_kind": { "const": "maintenance" } }, "required": ["run_kind"] }, - "then": { "properties": { "scope": { "properties": { "source_basis": { "const": "bounded_delta" } } } } } - } - ], - "$defs": { - "nonEmptyStrings": { - "type": "array", - "minItems": 1, - "items": { "type": "string", "minLength": 1 }, - "uniqueItems": true - } - } + "allOf": [{ "if": { "properties": { "run_kind": { "const": "maintenance" } }, "required": ["run_kind"] }, "then": { "properties": { "source": { "properties": { "basis": { "const": "bounded_delta" } } }, "bootstrap": { "properties": { "criteria_satisfied": { "const": false } } } } }], + "$defs": { "strings": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, "nonEmptyStrings": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 }, "uniqueItems": true } } } diff --git a/scripts/validate-docs-agent-completion.php b/scripts/validate-docs-agent-completion.php index ec7e141..2b257b6 100644 --- a/scripts/validate-docs-agent-completion.php +++ b/scripts/validate-docs-agent-completion.php @@ -1,11 +1,5 @@ */ -function docs_agent_decode_object( string $json, string $code, string $name ): array { - try { - $value = json_decode( $json, true, 64, JSON_THROW_ON_ERROR ); - } catch ( JsonException $error ) { - docs_agent_fail( $code, "{$name} is malformed JSON: {$error->getMessage()}" ); - } - if ( ! is_array( $value ) || array_is_list( $value ) ) { - docs_agent_fail( $code, "{$name} must be a JSON object." ); - } - return $value; -} - /** @return list */ function docs_agent_strings( mixed $value, string $field, bool $allow_empty = false ): array { if ( ! is_array( $value ) || ! array_is_list( $value ) || ( ! $allow_empty && array() === $value ) ) { - docs_agent_fail( 'REPORT_INCOMPLETE', "{$field} must be " . ( $allow_empty ? 'a' : 'a non-empty' ) . ' list.' ); + docs_agent_fail( 'CALLER_INPUT_MALFORMED', "{$field} must be " . ( $allow_empty ? 'a' : 'a non-empty' ) . ' list.' ); } $strings = array(); foreach ( $value as $item ) { if ( ! is_string( $item ) || '' === trim( $item ) ) { - docs_agent_fail( 'REPORT_INCOMPLETE', "{$field} must contain only non-empty strings." ); + docs_agent_fail( 'CALLER_INPUT_MALFORMED', "{$field} must contain only non-empty strings." ); } $strings[] = str_replace( '\\', '/', trim( $item ) ); } if ( count( $strings ) !== count( array_unique( $strings ) ) ) { - docs_agent_fail( 'REPORT_CONTRADICTORY', "{$field} contains duplicate values." ); + docs_agent_fail( 'CALLER_INPUT_MALFORMED', "{$field} contains duplicate values." ); } + sort( $strings ); return $strings; } @@ -60,15 +42,10 @@ function docs_agent_path_matches( string $path, string $pattern ): bool { /** @return list */ function docs_agent_workspace_changes( string $workspace ): array { - $commands = array( - 'git diff --name-only --relative HEAD', - 'git ls-files --others --exclude-standard', - ); $paths = array(); - foreach ( $commands as $command ) { + foreach ( array( 'diff --name-only --relative HEAD', 'ls-files --others --exclude-standard' ) as $command ) { $output = array(); - $code = 0; - exec( 'git -C ' . escapeshellarg( $workspace ) . ' ' . substr( $command, 4 ) . ' 2>/dev/null', $output, $code ); + exec( 'git -C ' . escapeshellarg( $workspace ) . ' ' . $command . ' 2>/dev/null', $output, $code ); if ( 0 !== $code ) { docs_agent_fail( 'WORKSPACE_DIFF_UNAVAILABLE', 'The target workspace must be a readable Git checkout.' ); } @@ -86,12 +63,7 @@ function docs_agent_workspace_changes( string $workspace ): array { /** @return list */ function docs_agent_workspace_files( string $workspace ): array { $files = array(); - $iterator = new RecursiveIteratorIterator( - new RecursiveCallbackFilterIterator( - new RecursiveDirectoryIterator( $workspace, FilesystemIterator::SKIP_DOTS ), - static fn( SplFileInfo $file ): bool => ! in_array( $file->getFilename(), array( '.git', '.codebox', 'node_modules', 'vendor' ), true ) - ) - ); + $iterator = new RecursiveIteratorIterator( new RecursiveCallbackFilterIterator( new RecursiveDirectoryIterator( $workspace, FilesystemIterator::SKIP_DOTS ), static fn( SplFileInfo $file ): bool => ! in_array( $file->getFilename(), array( '.git', '.codebox', 'node_modules', 'vendor' ), true ) ) ); foreach ( $iterator as $file ) { if ( $file->isFile() && ! $file->isLink() ) { $files[] = str_replace( '\\', '/', substr( $file->getPathname(), strlen( rtrim( $workspace, DIRECTORY_SEPARATOR ) ) + 1 ) ); @@ -102,7 +74,7 @@ function docs_agent_workspace_files( string $workspace ): array { } /** @param array $contract */ -function docs_agent_validate_bootstrap_contract( array $contract, string $workspace, array $actual_changes, string $outcome ): void { +function docs_agent_validate_bootstrap_contract( array $contract, string $workspace, array $actual_changes ): void { $required_paths = docs_agent_strings( $contract['required_paths'] ?? array(), 'bootstrap_contract.required_paths', true ); $required_globs = $contract['required_globs'] ?? array(); $entry_points = $contract['entry_points'] ?? array(); @@ -110,21 +82,21 @@ function docs_agent_validate_bootstrap_contract( array $contract, string $worksp if ( array() === $required_paths && array() === $required_globs && array() === $entry_points ) { docs_agent_fail( 'BOOTSTRAP_CONTRACT_EMPTY', 'Bootstrap requires positive caller completion criteria.' ); } + if ( ! is_array( $required_globs ) || ! array_is_list( $required_globs ) || ! is_array( $entry_points ) || ! array_is_list( $entry_points ) ) { + docs_agent_fail( 'BOOTSTRAP_CONTRACT_MALFORMED', 'Bootstrap glob and entry-point criteria must be lists.' ); + } $files = docs_agent_workspace_files( $workspace ); foreach ( $required_paths as $path ) { if ( ! is_file( $workspace . '/' . $path ) || 80 > filesize( $workspace . '/' . $path ) ) { docs_agent_fail( 'BOOTSTRAP_REQUIRED_PATH', "Bootstrap required path is absent or insubstantial: {$path}" ); } } - if ( ! is_array( $required_globs ) || ! array_is_list( $required_globs ) || ! is_array( $entry_points ) || ! array_is_list( $entry_points ) ) { - docs_agent_fail( 'BOOTSTRAP_CONTRACT_MALFORMED', 'Bootstrap glob and entry-point criteria must be lists.' ); - } foreach ( $required_globs as $glob ) { if ( ! is_array( $glob ) || ! is_string( $glob['pattern'] ?? null ) || ! is_int( $glob['min'] ?? $glob['min_count'] ?? null ) ) { docs_agent_fail( 'BOOTSTRAP_CONTRACT_MALFORMED', 'Each bootstrap glob requires pattern and integer min.' ); } $count = count( array_filter( $files, static fn( string $file ): bool => docs_agent_path_matches( $file, $glob['pattern'] ) ) ); - $min = $glob['min'] ?? $glob['min_count']; + $min = $glob['min'] ?? $glob['min_count']; if ( $count < $min ) { docs_agent_fail( 'BOOTSTRAP_GLOB_MINIMUM', "Bootstrap glob {$glob['pattern']} matched {$count}; {$min} required." ); } @@ -149,169 +121,99 @@ function docs_agent_validate_bootstrap_contract( array $contract, string $worksp if ( 1 !== preg_match( '/\.(?:md|mdx)$/i', $file ) && ! in_array( $file, $required_paths, true ) ) { continue; } - $content = @file_get_contents( $workspace . '/' . $file ); + $content = file_get_contents( $workspace . '/' . $file ); if ( false !== $content && str_contains( $content, $phrase ) ) { docs_agent_fail( 'BOOTSTRAP_FORBIDDEN_PHRASE', "Bootstrap documentation contains forbidden phrase in {$file}." ); } } } - if ( 'changes' === $outcome ) { - $markdown_changes = array_values( array_filter( $actual_changes, static fn( string $path ): bool => 1 === preg_match( '/\.(?:md|mdx)$/i', $path ) ) ); - $bytes = 0; - foreach ( $markdown_changes as $path ) { - $bytes += is_file( $workspace . '/' . $path ) ? (int) filesize( $workspace . '/' . $path ) : 0; - } - if ( count( $markdown_changes ) < 2 || $bytes < 500 ) { - docs_agent_fail( 'BOOTSTRAP_NOT_SUBSTANTIVE', 'Fresh bootstrap changes require at least two documentation pages and 500 bytes of navigable content.' ); - } + $documentation_changes = array_filter( $actual_changes, static fn( string $path ): bool => 1 === preg_match( '/\.(?:md|mdx)$/i', $path ) ); + $bytes = array_sum( array_map( static fn( string $path ): int => is_file( $workspace . '/' . $path ) ? (int) filesize( $workspace . '/' . $path ) : 0, $documentation_changes ) ); + if ( count( $documentation_changes ) < 2 || $bytes < 500 ) { + docs_agent_fail( 'BOOTSTRAP_NOT_SUBSTANTIVE', 'Fresh bootstrap changes require at least two documentation pages and 500 bytes of navigable content.' ); } } -/** @param array $report @param array $options */ -function docs_agent_validate_report( array $report, array $options ): array { - $required_top = array( 'schema', 'lane', 'run_kind', 'outcome', 'scope', 'items', 'changed_paths' ); - foreach ( $required_top as $field ) { - if ( ! array_key_exists( $field, $report ) ) { - docs_agent_fail( 'REPORT_INCOMPLETE', "Completion report is missing {$field}." ); - } - } - if ( 'docs-agent/completion-report/v1' !== $report['schema'] ) { - docs_agent_fail( 'REPORT_SCHEMA', 'Completion report must use docs-agent/completion-report/v1.' ); - } - foreach ( array( 'lane', 'run_kind' ) as $field ) { - if ( ( $options[ $field ] ?? null ) !== $report[ $field ] ) { - docs_agent_fail( 'REPORT_CONTEXT_MISMATCH', "Completion report {$field} does not match the requested run." ); - } - } - $outcome = $report['outcome']; - if ( ! in_array( $outcome, array( 'changes', 'no_changes' ), true ) ) { - docs_agent_fail( 'REPORT_INCOMPLETE', 'Completion report outcome must be changes or no_changes.' ); - } - $scope = $report['scope']; - if ( ! is_array( $scope ) || array_is_list( $scope ) || ! in_array( $scope['source_basis'] ?? null, array( 'inventory', 'bounded_delta' ), true ) ) { - docs_agent_fail( 'REPORT_INCOMPLETE', 'Completion report requires an inventory or bounded_delta scope.' ); - } - docs_agent_strings( $scope['source_refs'] ?? null, 'scope.source_refs' ); - docs_agent_strings( $scope['documentation_surfaces'] ?? null, 'scope.documentation_surfaces' ); - if ( 'maintenance' === $report['run_kind'] && 'bounded_delta' !== $scope['source_basis'] ) { - docs_agent_fail( 'REPORT_CONTEXT_MISMATCH', 'Maintenance completion reports must use the caller-bounded source delta.' ); - } - $items = $report['items']; - if ( ! is_array( $items ) || ! array_is_list( $items ) || array() === $items ) { - docs_agent_fail( 'REPORT_INCOMPLETE', 'Completion report requires at least one evidence-backed disposition.' ); - } - $item_map = array(); - $change_dispositions = 0; - foreach ( $items as $item ) { - if ( ! is_array( $item ) || ! is_string( $item['id'] ?? null ) || '' === trim( $item['id'] ) || ! is_string( $item['evidence'] ?? null ) || '' === trim( $item['evidence'] ) ) { - docs_agent_fail( 'REPORT_INCOMPLETE', 'Every report item requires a unique id and non-empty evidence.' ); - } - $id = trim( $item['id'] ); - if ( isset( $item_map[ $id ] ) ) { - docs_agent_fail( 'REPORT_CONTRADICTORY', "Completion report repeats item {$id}." ); - } - docs_agent_strings( $item['source_refs'] ?? null, "items.{$id}.source_refs" ); - docs_agent_strings( $item['documentation_paths'] ?? null, "items.{$id}.documentation_paths", true ); - $disposition = $item['disposition'] ?? null; - if ( ! in_array( $disposition, array( 'created', 'updated', 'verified_current', 'not_documentation_relevant' ), true ) ) { - docs_agent_fail( 'REPORT_INCOMPLETE', "Report item {$id} has an invalid disposition." ); - } - if ( 'no_changes' === $outcome && in_array( $disposition, array( 'created', 'updated' ), true ) ) { - docs_agent_fail( 'REPORT_CONTRADICTORY', "No-change report item {$id} claims a documentation change." ); - } - $change_dispositions += in_array( $disposition, array( 'created', 'updated' ), true ) ? 1 : 0; - $item_map[ $id ] = $item; - } - $workspace = (string) $options['workspace']; - $actual_changes = docs_agent_workspace_changes( $workspace ); - $reported = docs_agent_strings( $report['changed_paths'], 'changed_paths', true ); - sort( $reported ); - if ( 'changes' === $outcome && array() === $actual_changes ) { - docs_agent_fail( 'CHANGES_DIFF_EMPTY', 'A changes outcome requires an actual workspace diff; no-op writes do not count.' ); - } - if ( 'no_changes' === $outcome && array() !== $actual_changes ) { - docs_agent_fail( 'NO_CHANGES_DIFF_DIRTY', 'A no_changes outcome requires a clean workspace diff.' ); - } - if ( $reported !== $actual_changes ) { - docs_agent_fail( 'REPORT_DIFF_MISMATCH', 'Completion report changed_paths must exactly match the actual workspace diff.' ); - } - if ( 'changes' === $outcome && 0 === $change_dispositions ) { - docs_agent_fail( 'REPORT_CONTRADICTORY', 'A changes outcome requires at least one created or updated disposition.' ); - } - $writable_paths = array_values( array_filter( array_map( 'trim', explode( ',', (string) $options['writable_paths'] ) ) ) ); - foreach ( $actual_changes as $path ) { - if ( array() === $writable_paths || ! array_filter( $writable_paths, static fn( string $pattern ): bool => docs_agent_path_matches( $path, $pattern ) ) ) { - docs_agent_fail( 'WRITABLE_SCOPE_VIOLATION', "Actual changed path is outside writable_paths: {$path}" ); - } - } - $source_delta = $options['source_delta'] ?? array(); +/** @return array{refs:list,records:list>} */ +function docs_agent_validate_source_delta( mixed $source_delta, string $run_kind ): array { if ( ! is_array( $source_delta ) || ! array_is_list( $source_delta ) ) { docs_agent_fail( 'SOURCE_DELTA_MALFORMED', 'source_delta must be a JSON list.' ); } - if ( 'maintenance' === $report['run_kind'] && array() === $source_delta ) { + if ( 'maintenance' === $run_kind && array() === $source_delta ) { docs_agent_fail( 'SOURCE_DELTA_EMPTY', 'Maintenance requires a non-empty caller-bounded source_delta.' ); } - $delta_ids = array(); + $ids = array(); + $refs = array(); + $records = array(); foreach ( $source_delta as $delta ) { if ( ! is_array( $delta ) || ! is_string( $delta['id'] ?? null ) || '' === trim( $delta['id'] ) || ! is_bool( $delta['requires_documentation_change'] ?? null ) ) { docs_agent_fail( 'SOURCE_DELTA_MALFORMED', 'Each source_delta item requires id, source_refs, and requires_documentation_change.' ); } - $id = trim( $delta['id'] ); - $source_refs = docs_agent_strings( $delta['source_refs'] ?? null, "source_delta.{$id}.source_refs" ); - if ( isset( $delta_ids[ $id ] ) ) { + $id = trim( $delta['id'] ); + if ( isset( $ids[ $id ] ) ) { docs_agent_fail( 'SOURCE_DELTA_MALFORMED', "source_delta repeats id {$id}." ); } - $delta_ids[ $id ] = true; - if ( ! isset( $item_map[ $id ] ) ) { - docs_agent_fail( 'SOURCE_DELTA_INCOMPLETE', "Completion report omits caller source delta {$id}." ); - } - $reported_refs = docs_agent_strings( $item_map[ $id ]['source_refs'], "items.{$id}.source_refs" ); - $missing_refs = array_values( array_diff( $source_refs, $reported_refs ) ); - if ( array() !== $missing_refs ) { - docs_agent_fail( 'SOURCE_DELTA_INCOMPLETE', "Completion report item {$id} omits caller source refs: " . implode( ', ', $missing_refs ) ); + $ids[ $id ] = true; + $source_refs = docs_agent_strings( $delta['source_refs'] ?? null, "source_delta.{$id}.source_refs" ); + $refs = array_merge( $refs, $source_refs ); + $records[] = array( 'id' => $id, 'source_refs' => $source_refs, 'requires_documentation_change' => $delta['requires_documentation_change'] ); + } + sort( $refs ); + $refs = array_values( array_unique( $refs ) ); + usort( $records, static fn( array $left, array $right ): int => $left['id'] <=> $right['id'] ); + return array( 'refs' => $refs, 'records' => $records ); +} + +/** @param array $options @return array */ +function docs_agent_build_completion_report( array $options ): array { + $workspace = realpath( (string) ( $options['workspace'] ?? '' ) ); + if ( false === $workspace ) { + docs_agent_fail( 'INVOCATION', 'workspace must be a real directory.' ); + } + $lane = $options['lane'] ?? null; + $run_kind = $options['run_kind'] ?? null; + if ( ! in_array( $lane, array( 'technical', 'user', 'skills' ), true ) || ! in_array( $run_kind, array( 'bootstrap', 'maintenance' ), true ) ) { + docs_agent_fail( 'INVOCATION', 'lane and run_kind must be supported caller values.' ); + } + $writable_paths = docs_agent_strings( array_values( array_filter( array_map( 'trim', explode( ',', (string) ( $options['writable_paths'] ?? '' ) ) ) ) ), 'writable_paths' ); + $source = docs_agent_validate_source_delta( $options['source_delta'] ?? array(), $run_kind ); + $actual_changes = docs_agent_workspace_changes( $workspace ); + foreach ( $actual_changes as $path ) { + if ( ! array_filter( $writable_paths, static fn( string $pattern ): bool => docs_agent_path_matches( $path, $pattern ) ) ) { + docs_agent_fail( 'WRITABLE_SCOPE_VIOLATION', "Actual changed path is outside writable_paths: {$path}" ); } - if ( true === $delta['requires_documentation_change'] && ! in_array( $item_map[ $id ]['disposition'], array( 'created', 'updated' ), true ) ) { - docs_agent_fail( 'KNOWN_DRIFT_NO_CHANGE', "Known source drift {$id} requires a documentation change." ); + } + $outcome = array() === $actual_changes ? 'no_changes' : 'changes'; + $documentation_changes = array_filter( $actual_changes, static fn( string $path ): bool => 1 === preg_match( '/\.(?:md|mdx)$/i', $path ) ); + foreach ( $source['records'] as $record ) { + if ( $record['requires_documentation_change'] && array() === $documentation_changes ) { + docs_agent_fail( 'KNOWN_DRIFT_NO_CHANGE', "Known source drift {$record['id']} requires a documentation diff." ); } } - if ( 'bootstrap' === $report['run_kind'] ) { - docs_agent_validate_bootstrap_contract( $options['bootstrap_contract'] ?? array(), $workspace, $actual_changes, $outcome ); + $bootstrap = 'bootstrap' === $run_kind; + if ( $bootstrap ) { + docs_agent_validate_bootstrap_contract( $options['bootstrap_contract'] ?? array(), $workspace, $actual_changes ); } - return array( 'outcome' => $outcome, 'changed_paths' => $actual_changes, 'items' => count( $items ) ); + return array( + 'schema' => 'docs-agent/completion-report/v1', + 'lane' => $lane, + 'run_kind' => $run_kind, + 'outcome' => $outcome, + 'changed_paths' => $actual_changes, + 'writable_paths' => $writable_paths, + 'source' => array( + 'basis' => 'maintenance' === $run_kind ? 'bounded_delta' : 'inventory', + 'refs' => 'maintenance' === $run_kind ? $source['refs'] : array( 'repository inventory' ), + 'records' => $source['records'], + ), + 'bootstrap' => array( 'criteria_satisfied' => $bootstrap ), + 'checks' => array( 'workspace' => 'passed', 'writable_scope' => 'passed', 'source_drift' => 'passed', 'bootstrap' => $bootstrap ? 'passed' : 'not_applicable', 'verification' => 'passed', 'drift_checks' => 'passed' ), + ); } /** @param array $report */ function docs_agent_canonical_report_json( array $report ): string { - $canonical_strings = static function ( array $values ): array { - $values = array_map( static fn( string $value ): string => str_replace( '\\', '/', trim( $value ) ), $values ); - sort( $values ); - return array_values( $values ); - }; - $items = array_map( - static fn( array $item ): array => array( - 'id' => trim( $item['id'] ), - 'source_refs' => $canonical_strings( $item['source_refs'] ), - 'documentation_paths' => $canonical_strings( $item['documentation_paths'] ), - 'disposition' => $item['disposition'], - 'evidence' => trim( $item['evidence'] ), - ), - $report['items'] - ); - usort( $items, static fn( array $left, array $right ): int => $left['id'] <=> $right['id'] ); - $canonical = array( - 'schema' => $report['schema'], - 'lane' => $report['lane'], - 'run_kind' => $report['run_kind'], - 'outcome' => $report['outcome'], - 'scope' => array( - 'source_basis' => $report['scope']['source_basis'], - 'source_refs' => $canonical_strings( $report['scope']['source_refs'] ), - 'documentation_surfaces' => $canonical_strings( $report['scope']['documentation_surfaces'] ), - ), - 'items' => $items, - 'changed_paths' => $canonical_strings( $report['changed_paths'] ), - ); - return json_encode( $canonical, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR ) . "\n"; + return json_encode( $report, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR ) . "\n"; } /** @param array $report */ @@ -324,98 +226,38 @@ function docs_agent_write_report_artifact( array $report, string $workspace, str if ( false === $workspace_real ) { docs_agent_fail( 'ARTIFACT_PATH_INVALID', 'Completion artifact workspace must be a real directory.' ); } - $codebox = $workspace_real . '/.codebox'; - if ( ( ! is_dir( $codebox ) && ! mkdir( $codebox, 0700 ) ) || is_link( $codebox ) ) { - docs_agent_fail( 'ARTIFACT_PATH_INVALID', 'Completion artifact path must not traverse a .codebox symlink.' ); - } - $root = $codebox . '/agent-task-artifacts'; - if ( ( ! is_dir( $root ) && ! mkdir( $root, 0700, true ) ) || is_link( $root ) ) { - docs_agent_fail( 'ARTIFACT_WRITE_FAILED', 'Completion artifact root must be a writable real directory.' ); + $root = $workspace_real . '/.codebox/agent-task-artifacts'; + if ( ( ! is_dir( $root ) && ! mkdir( $root, 0700, true ) ) || is_link( $workspace_real . '/.codebox' ) || is_link( $root ) ) { + docs_agent_fail( 'ARTIFACT_PATH_INVALID', 'Completion artifact root must be a writable real directory.' ); } $target = $root . '/' . $artifact_path; - $parent = $root; - foreach ( array_slice( explode( '/', $artifact_path ), 0, -1 ) as $segment ) { - if ( '' === $segment || '.' === $segment ) { - continue; - } - $parent .= '/' . $segment; - if ( ( ! is_dir( $parent ) && ! mkdir( $parent, 0700 ) ) || is_link( $parent ) ) { - docs_agent_fail( 'ARTIFACT_PATH_INVALID', 'Completion artifact path must not traverse symlinks.' ); - } - } - if ( is_link( $target ) ) { - docs_agent_fail( 'ARTIFACT_PATH_INVALID', 'Completion artifact path must not traverse symlinks.' ); + $parent = dirname( $target ); + if ( ! is_dir( $parent ) && ! mkdir( $parent, 0700, true ) ) { + docs_agent_fail( 'ARTIFACT_WRITE_FAILED', 'Could not create completion artifact parent directory.' ); } - $root_real = realpath( $root ); + $root_real = realpath( $root ); $parent_real = realpath( $parent ); - if ( false === $root_real || false === $parent_real || ( $parent_real !== $root_real && ! str_starts_with( $parent_real, $root_real . DIRECTORY_SEPARATOR ) ) ) { + if ( false === $root_real || false === $parent_real || ( $parent_real !== $root_real && ! str_starts_with( $parent_real, $root_real . DIRECTORY_SEPARATOR ) ) || is_link( $target ) ) { docs_agent_fail( 'ARTIFACT_PATH_INVALID', 'Completion artifact path escapes .codebox/agent-task-artifacts.' ); } $bytes = docs_agent_canonical_report_json( $report ); - if ( strlen( $bytes ) > 2 * 1024 * 1024 ) { - docs_agent_fail( 'ARTIFACT_TOO_LARGE', 'Completion artifact exceeds the 2 MiB bound.' ); - } $temp = tempnam( $parent_real, '.docs-agent-completion-' ); if ( false === $temp || strlen( $bytes ) !== file_put_contents( $temp, $bytes ) || ! rename( $temp, $target ) ) { if ( is_string( $temp ) ) { @unlink( $temp ); } - docs_agent_fail( 'ARTIFACT_WRITE_FAILED', 'Could not atomically write the validated completion artifact.' ); + docs_agent_fail( 'ARTIFACT_WRITE_FAILED', 'Could not atomically write the completion artifact.' ); } return $target; } /** @return array{publish:bool,pr_required:bool} */ function docs_agent_publication_expectation( string $outcome, string $run_kind, bool $require_pr ): array { - return array( - 'publish' => 'changes' === $outcome, - 'pr_required' => 'bootstrap' === $run_kind || $require_pr, - ); -} - -/** @return array */ -function docs_agent_report_from_transcripts( string $root ): array { - if ( ! is_dir( $root ) ) { - docs_agent_fail( 'REPORT_MISSING', 'Canonical transcript directory is absent.' ); - } - $reports = array(); - $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ) ); - foreach ( $iterator as $file ) { - if ( ! $file->isFile() || $file->isLink() || 'json' !== strtolower( $file->getExtension() ) || $file->getSize() > 2 * 1024 * 1024 ) { - continue; - } - try { - $transcript = json_decode( (string) file_get_contents( $file->getPathname() ), true, 128, JSON_THROW_ON_ERROR ); - } catch ( JsonException ) { - continue; - } - $values = array( $transcript ); - while ( array() !== $values ) { - $value = array_pop( $values ); - if ( is_array( $value ) ) { - array_push( $values, ...array_values( $value ) ); - continue; - } - if ( ! is_string( $value ) || ! preg_match_all( '~\s*(\{.*?\})\s*~s', $value, $matches ) ) { - continue; - } - foreach ( $matches[1] as $json ) { - $report = docs_agent_decode_object( $json, 'REPORT_MALFORMED', 'Completion report' ); - $reports[ hash( 'sha256', (string) json_encode( $report ) ) ] = $report; - } - } - } - if ( array() === $reports ) { - docs_agent_fail( 'REPORT_MISSING', 'Canonical transcript does not contain a Docs Agent completion report.' ); - } - if ( 1 !== count( $reports ) ) { - docs_agent_fail( 'REPORT_CONTRADICTORY', 'Canonical transcript must contain exactly one completion report.' ); - } - return array_values( $reports )[0]; + return array( 'publish' => 'changes' === $outcome, 'pr_required' => 'bootstrap' === $run_kind || $require_pr ); } if ( realpath( $_SERVER['SCRIPT_FILENAME'] ?? '' ) === __FILE__ ) { - $options = getopt( '', array( 'workspace:', 'lane:', 'run-kind:', 'writable-paths:', 'bootstrap-contract-b64:', 'source-delta-b64:', 'transcript-root:', 'report:', 'artifact-path:' ) ); + $options = getopt( '', array( 'workspace:', 'lane:', 'run-kind:', 'writable-paths:', 'bootstrap-contract-b64:', 'source-delta-b64:', 'artifact-path:' ) ); try { foreach ( array( 'workspace', 'lane', 'run-kind', 'writable-paths', 'artifact-path' ) as $required ) { if ( ! isset( $options[ $required ] ) || ! is_string( $options[ $required ] ) ) { @@ -427,32 +269,15 @@ function docs_agent_report_from_transcripts( string $root ): array { return $default; } $decoded = base64_decode( $value, true ); - if ( false === $decoded ) { - docs_agent_fail( 'INVOCATION', "{$name} is not valid base64." ); - } - $value = json_decode( $decoded, true ); + $value = false === $decoded ? null : json_decode( $decoded, true ); if ( ! is_array( $value ) ) { - docs_agent_fail( 'INVOCATION', "{$name} is not valid JSON." ); + docs_agent_fail( 'INVOCATION', "{$name} must be base64-encoded JSON." ); } return $value; }; - $report = isset( $options['report'] ) && is_string( $options['report'] ) - ? docs_agent_decode_object( (string) file_get_contents( $options['report'] ), 'REPORT_MALFORMED', 'Completion report' ) - : docs_agent_report_from_transcripts( (string) ( $options['transcript-root'] ?? '.codebox/agent-task-artifacts' ) ); - $workspace = realpath( $options['workspace'] ) ?: $options['workspace']; - $result = docs_agent_validate_report( - $report, - array( - 'workspace' => $workspace, - 'lane' => $options['lane'], - 'run_kind' => $options['run-kind'], - 'writable_paths' => $options['writable-paths'], - 'bootstrap_contract' => $decode_b64_json( $options['bootstrap-contract-b64'] ?? '', 'bootstrap_contract', array() ), - 'source_delta' => $decode_b64_json( $options['source-delta-b64'] ?? '', 'source_delta', array() ), - ) - ); - docs_agent_write_report_artifact( $report, $workspace, $options['artifact-path'] ); - fwrite( STDOUT, 'Docs Agent completion contract passed: ' . json_encode( $result, JSON_UNESCAPED_SLASHES ) . PHP_EOL ); + $report = docs_agent_build_completion_report( array( 'workspace' => $options['workspace'], 'lane' => $options['lane'], 'run_kind' => $options['run-kind'], 'writable_paths' => $options['writable-paths'], 'bootstrap_contract' => $decode_b64_json( $options['bootstrap-contract-b64'] ?? '', 'bootstrap_contract', array() ), 'source_delta' => $decode_b64_json( $options['source-delta-b64'] ?? '', 'source_delta', array() ) ) ); + docs_agent_write_report_artifact( $report, $options['workspace'], $options['artifact-path'] ); + fwrite( STDOUT, 'Docs Agent completion contract passed: ' . json_encode( array( 'outcome' => $report['outcome'], 'changed_paths' => $report['changed_paths'] ), JSON_UNESCAPED_SLASHES ) . PHP_EOL ); } catch ( Docs_Agent_Contract_Failure $error ) { fwrite( STDERR, "docs-agent.completion-contract.{$error->diagnostic_code}: {$error->getMessage()}" . PHP_EOL ); exit( 1 ); diff --git a/tests/validate-docs-agent-completion-contract.php b/tests/validate-docs-agent-completion-contract.php index a6a882a..a831eb3 100644 --- a/tests/validate-docs-agent-completion-contract.php +++ b/tests/validate-docs-agent-completion-contract.php @@ -5,217 +5,83 @@ require_once dirname( __DIR__ ) . '/scripts/validate-docs-agent-completion.php'; -$assert = static function ( bool $condition, string $message ): void { - if ( ! $condition ) { - throw new RuntimeException( $message ); - } -}; - -$run = static function ( string $command, string $cwd ): void { - $output = array(); - $code = 0; - exec( 'cd ' . escapeshellarg( $cwd ) . ' && ' . $command . ' 2>&1', $output, $code ); - if ( 0 !== $code ) { - throw new RuntimeException( "Command failed: {$command}\n" . implode( "\n", $output ) ); - } -}; - +$assert = static function ( bool $condition, string $message ): void { if ( ! $condition ) { throw new RuntimeException( $message ); } }; +$run = static function ( string $command, string $cwd ): void { exec( 'cd ' . escapeshellarg( $cwd ) . ' && ' . $command . ' 2>&1', $output, $code ); if ( 0 !== $code ) { throw new RuntimeException( implode( "\n", $output ) ); } }; $workspace = static function () use ( $run ): string { $path = sys_get_temp_dir() . '/docs-agent-contract-' . bin2hex( random_bytes( 6 ) ); - if ( ! mkdir( $path, 0700, true ) ) { - throw new RuntimeException( 'Failed to create test workspace.' ); - } mkdir( $path . '/docs', 0700, true ); file_put_contents( $path . '/README.md', "# Project\n\nExisting project documentation.\n" ); file_put_contents( $path . '/docs/guide.md', "# Guide\n\nExisting guide.\n" ); $run( 'git init -q && git config user.name test && git config user.email test@example.com && git add . && git commit -qm baseline', $path ); return $path; }; - -$base_report = static function ( string $outcome, array $changed_paths = array() ): array { - return array( - 'schema' => 'docs-agent/completion-report/v1', - 'lane' => 'technical', - 'run_kind' => 'maintenance', - 'outcome' => $outcome, - 'scope' => array( - 'source_basis' => 'bounded_delta', - 'source_refs' => array( 'src/service.php:10-40' ), - 'documentation_surfaces' => array( 'README.md', 'docs/guide.md' ), - ), - 'items' => array( - array( - 'id' => 'service-contract', - 'source_refs' => array( 'src/service.php:10-40' ), - 'documentation_paths' => array( 'docs/guide.md' ), - 'disposition' => 'changes' === $outcome ? 'updated' : 'verified_current', - 'evidence' => 'Compared the public method and failure behavior with the guide.', - ), - ), - 'changed_paths' => $changed_paths, - ); -}; - $options = static function ( string $workspace, array $overrides = array() ): array { - return array_merge( - array( - 'workspace' => $workspace, - 'lane' => 'technical', - 'run_kind' => 'maintenance', - 'writable_paths' => 'README.md,docs/**', - 'bootstrap_contract' => array(), - 'source_delta' => array( - array( - 'id' => 'service-contract', - 'source_refs' => array( 'src/service.php:10-40' ), - 'requires_documentation_change' => false, - ), - ), - ), - $overrides - ); + return array_merge( array( 'workspace' => $workspace, 'lane' => 'technical', 'run_kind' => 'maintenance', 'writable_paths' => 'README.md,docs/**', 'bootstrap_contract' => array(), 'source_delta' => array( array( 'id' => 'service-contract', 'source_refs' => array( 'src/service.php:10-40' ), 'requires_documentation_change' => false ) ) ), $overrides ); }; - $expect_failure = static function ( string $code, callable $callback ) use ( $assert ): void { - try { - $callback(); - } catch ( Docs_Agent_Contract_Failure $error ) { - $assert( $code === $error->diagnostic_code, "Expected {$code}; received {$error->diagnostic_code}: {$error->getMessage()}" ); - return; - } - throw new RuntimeException( "Expected Docs Agent diagnostic {$code}." ); + try { $callback(); } catch ( Docs_Agent_Contract_Failure $error ) { $assert( $code === $error->diagnostic_code, "Expected {$code}; got {$error->diagnostic_code}" ); return; } + throw new RuntimeException( "Expected {$code}." ); }; $workspaces = array(); try { - // Maintenance changes: report and actual writable diff agree. - $maintenance = $workspace(); - $workspaces[] = $maintenance; - file_put_contents( $maintenance . '/docs/guide.md', "# Guide\n\nUpdated source-grounded service contract and failure behavior.\n" ); - $result = docs_agent_validate_report( $base_report( 'changes', array( 'docs/guide.md' ) ), $options( $maintenance ) ); - $assert( 'changes' === $result['outcome'], 'Maintenance changes must pass with a matching report and diff.' ); - - // Honest no-change: bounded evidence exists and the documentation diff is clean. - $no_change = $workspace(); - $workspaces[] = $no_change; - $result = docs_agent_validate_report( $base_report( 'no_changes' ), $options( $no_change ) ); - $assert( 'no_changes' === $result['outcome'], 'Evidence-backed maintenance no_changes must pass on a clean diff.' ); - $expect_failure( 'SOURCE_DELTA_EMPTY', static fn() => docs_agent_validate_report( $base_report( 'no_changes' ), $options( $no_change, array( 'source_delta' => array() ) ) ) ); - $transcript_root = $no_change . '/.codebox/agent-task-artifacts/runtime/files'; - mkdir( $transcript_root, 0700, true ); - $report_json = json_encode( $base_report( 'no_changes' ), JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR ); - file_put_contents( $transcript_root . '/transcript.json', json_encode( array( 'messages' => array( array( 'role' => 'assistant', 'content' => "{$report_json}" ) ) ), JSON_THROW_ON_ERROR ) ); - $extracted = docs_agent_report_from_transcripts( $no_change . '/.codebox/agent-task-artifacts' ); - $assert( 'no_changes' === $extracted['outcome'], 'The live transcript transport must materialize one parseable completion report.' ); - $validator = dirname( __DIR__ ) . '/scripts/validate-docs-agent-completion.php'; - $source_delta = base64_encode( (string) json_encode( $options( $no_change )['source_delta'], JSON_THROW_ON_ERROR ) ); - $run( - 'php ' . escapeshellarg( $validator ) . - ' --workspace ' . escapeshellarg( $no_change ) . - ' --lane technical --run-kind maintenance --writable-paths ' . escapeshellarg( 'README.md,docs/**' ) . - ' --source-delta-b64 ' . escapeshellarg( $source_delta ) . - ' --transcript-root ' . escapeshellarg( $no_change . '/.codebox/agent-task-artifacts' ) . - ' --artifact-path docs-agent-completion-report.json', - $no_change - ); - $artifact_path = $no_change . '/.codebox/agent-task-artifacts/docs-agent-completion-report.json'; - $expected_bytes = '{"schema":"docs-agent/completion-report/v1","lane":"technical","run_kind":"maintenance","outcome":"no_changes","scope":{"source_basis":"bounded_delta","source_refs":["src/service.php:10-40"],"documentation_surfaces":["README.md","docs/guide.md"]},"items":[{"id":"service-contract","source_refs":["src/service.php:10-40"],"documentation_paths":["docs/guide.md"],"disposition":"verified_current","evidence":"Compared the public method and failure behavior with the guide."}],"changed_paths":[]}' . "\n"; - $assert( $expected_bytes === file_get_contents( $artifact_path ), 'The validator must write deterministic canonical report bytes at the declared artifact path.' ); - $expect_failure( 'ARTIFACT_PATH_INVALID', static fn() => docs_agent_write_report_artifact( $extracted, $no_change, '../completion.json' ) ); - $symlink_workspace = $workspace(); - $workspaces[] = $symlink_workspace; - $run( 'rm -rf .codebox && ln -s docs .codebox', $symlink_workspace ); - $expect_failure( 'ARTIFACT_PATH_INVALID', static fn() => docs_agent_write_report_artifact( $base_report( 'no_changes' ), $symlink_workspace, 'completion.json' ) ); - - // A no-op write cannot support a changes outcome. - $noop = $workspace(); - $workspaces[] = $noop; - file_put_contents( $noop . '/docs/guide.md', (string) file_get_contents( $noop . '/docs/guide.md' ) ); - $expect_failure( 'CHANGES_DIFF_EMPTY', static fn() => docs_agent_validate_report( $base_report( 'changes', array( 'docs/guide.md' ) ), $options( $noop ) ) ); - - // Known drift cannot be hidden behind no_changes. - $known_drift = $workspace(); - $workspaces[] = $known_drift; - $expect_failure( - 'KNOWN_DRIFT_NO_CHANGE', - static fn() => docs_agent_validate_report( - $base_report( 'no_changes' ), - $options( $known_drift, array( 'source_delta' => array( array( 'id' => 'service-contract', 'source_refs' => array( 'src/service.php:10-40' ), 'requires_documentation_change' => true ) ) ) ) - ) - ); + // Changed and no-change outcomes are observations of Git, not final model prose. + $changed = $workspace(); + $workspaces[] = $changed; + file_put_contents( $changed . '/docs/guide.md', "# Guide\n\nUpdated source-grounded service contract.\n" ); + $report = docs_agent_build_completion_report( $options( $changed ) ); + $assert( 'changes' === $report['outcome'] && array( 'docs/guide.md' ) === $report['changed_paths'], 'Changed outcome must come from Git.' ); + $assert( array( 'src/service.php:10-40' ) === $report['source']['refs'] && 'bounded_delta' === $report['source']['basis'], 'Source facts must come from caller records.' ); + + $clean = $workspace(); + $workspaces[] = $clean; + $no_change = docs_agent_build_completion_report( $options( $clean ) ); + $assert( 'no_changes' === $no_change['outcome'] && array() === $no_change['changed_paths'], 'No-change outcome must come from a clean Git workspace.' ); + // These represent arbitrary, malformed, and absent model final prose. The builder never reads them. + mkdir( $clean . '/.codebox', 0700, true ); + foreach ( array( 'Normal final prose.', '{not json}', '' ) as $prose ) { + file_put_contents( $clean . '/.codebox/model-final-prose.txt', $prose ); + $with_prose = docs_agent_build_completion_report( $options( $clean ) ); + $assert( 'no_changes' === $with_prose['outcome'], 'Model prose must not affect completion.' ); + unlink( $clean . '/.codebox/model-final-prose.txt' ); + } - // Every caller-bounded item and source ref must be represented in the report. - $incomplete_delta = array( - array( 'id' => 'service-contract', 'source_refs' => array( 'src/service.php:10-40' ), 'requires_documentation_change' => false ), - array( 'id' => 'missing-contract', 'source_refs' => array( 'src/missing.php:1-20' ), 'requires_documentation_change' => false ), - ); - $expect_failure( 'SOURCE_DELTA_INCOMPLETE', static fn() => docs_agent_validate_report( $base_report( 'no_changes' ), $options( $no_change, array( 'source_delta' => $incomplete_delta ) ) ) ); - $missing_ref_delta = array( array( 'id' => 'service-contract', 'source_refs' => array( 'src/service.php:10-40', 'src/service.php:50-60' ), 'requires_documentation_change' => false ) ); - $expect_failure( 'SOURCE_DELTA_INCOMPLETE', static fn() => docs_agent_validate_report( $base_report( 'no_changes' ), $options( $no_change, array( 'source_delta' => $missing_ref_delta ) ) ) ); + $validator = dirname( __DIR__ ) . '/scripts/validate-docs-agent-completion.php'; + $source_delta = base64_encode( (string) json_encode( $options( $clean )['source_delta'], JSON_THROW_ON_ERROR ) ); + $run( 'php ' . escapeshellarg( $validator ) . ' --workspace ' . escapeshellarg( $clean ) . ' --lane technical --run-kind maintenance --writable-paths ' . escapeshellarg( 'README.md,docs/**' ) . ' --source-delta-b64 ' . escapeshellarg( $source_delta ) . ' --artifact-path docs-agent-completion-report.json', $clean ); + $artifact = $clean . '/.codebox/agent-task-artifacts/docs-agent-completion-report.json'; + $assert( docs_agent_canonical_report_json( $no_change ) === file_get_contents( $artifact ), 'The command must stage canonical host-generated bytes.' ); + $expect_failure( 'ARTIFACT_PATH_INVALID', static fn() => docs_agent_write_report_artifact( $no_change, $clean, '../completion.json' ) ); - // Dirty no_changes, report/diff mismatch, and scope violations are distinct. - $dirty = $workspace(); - $workspaces[] = $dirty; - file_put_contents( $dirty . '/docs/guide.md', "# Changed\n" ); - $expect_failure( 'NO_CHANGES_DIFF_DIRTY', static fn() => docs_agent_validate_report( $base_report( 'no_changes' ), $options( $dirty ) ) ); - $expect_failure( 'REPORT_DIFF_MISMATCH', static fn() => docs_agent_validate_report( $base_report( 'changes', array( 'README.md' ) ), $options( $dirty ) ) ); - $expect_failure( 'WRITABLE_SCOPE_VIOLATION', static fn() => docs_agent_validate_report( $base_report( 'changes', array( 'docs/guide.md' ) ), $options( $dirty, array( 'writable_paths' => 'README.md' ) ) ) ); + $scope = $workspace(); + $workspaces[] = $scope; + file_put_contents( $scope . '/docs/guide.md', "# Changed\n" ); + $expect_failure( 'WRITABLE_SCOPE_VIOLATION', static fn() => docs_agent_build_completion_report( $options( $scope, array( 'writable_paths' => 'README.md' ) ) ) ); - // Missing, malformed, incomplete, and contradictory reports fail specifically. - $expect_failure( 'REPORT_MISSING', static fn() => docs_agent_report_from_transcripts( sys_get_temp_dir() . '/not-a-docs-agent-transcript' ) ); - $expect_failure( 'REPORT_MALFORMED', static fn() => docs_agent_decode_object( '{', 'REPORT_MALFORMED', 'Completion report' ) ); - $incomplete = $base_report( 'no_changes' ); - unset( $incomplete['items'] ); - $expect_failure( 'REPORT_INCOMPLETE', static fn() => docs_agent_validate_report( $incomplete, $options( $no_change ) ) ); - $contradictory = $base_report( 'no_changes' ); - $contradictory['items'][0]['disposition'] = 'updated'; - $expect_failure( 'REPORT_CONTRADICTORY', static fn() => docs_agent_validate_report( $contradictory, $options( $no_change ) ) ); + $drift = $workspace(); + $workspaces[] = $drift; + $expect_failure( 'KNOWN_DRIFT_NO_CHANGE', static fn() => docs_agent_build_completion_report( $options( $drift, array( 'source_delta' => array( array( 'id' => 'service-contract', 'source_refs' => array( 'src/service.php:10-40' ), 'requires_documentation_change' => true ) ) ) ) ) ); + file_put_contents( $drift . '/src.php', " docs_agent_build_completion_report( $options( $drift, array( 'writable_paths' => 'README.md,docs/**,src.php', 'source_delta' => array( array( 'id' => 'service-contract', 'source_refs' => array( 'src/service.php:10-40' ), 'requires_documentation_change' => true ) ) ) ) ) ); - // Fresh bootstrap produces a substantive, navigable system satisfying caller criteria. $bootstrap = $workspace(); $workspaces[] = $bootstrap; - $overview = "# Architecture\n\n" . str_repeat( 'Source-grounded architecture, lifecycle, extension, operation, and testing guidance. ', 6 ) . "\n"; - $setup = "# Setup and Validation\n\n" . str_repeat( 'Install dependencies, run tests, inspect failures, and verify generated outputs. ', 5 ) . "\n"; - file_put_contents( $bootstrap . '/README.md', "# Project\n\nStart with [architecture](docs/architecture.md) and [setup](docs/setup.md).\n" ); - file_put_contents( $bootstrap . '/docs/architecture.md', $overview ); - file_put_contents( $bootstrap . '/docs/setup.md', $setup ); - $bootstrap_report = $base_report( 'changes', array( 'README.md', 'docs/architecture.md', 'docs/setup.md' ) ); - $bootstrap_report['run_kind'] = 'bootstrap'; - $bootstrap_report['scope']['source_basis'] = 'inventory'; - $bootstrap_report['items'][0]['documentation_paths'] = array( 'README.md', 'docs/architecture.md', 'docs/setup.md' ); - $bootstrap_contract = array( - 'required_paths' => array( 'README.md', 'docs/architecture.md', 'docs/setup.md' ), - 'required_globs' => array( array( 'pattern' => 'docs/**/*.md', 'min' => 2 ) ), - 'entry_points' => array( array( 'path' => 'README.md', 'must_link_to' => array( 'docs/architecture.md', 'docs/setup.md' ) ) ), - 'forbidden_phrases' => array( 'TODO: document this' ), - ); - $result = docs_agent_validate_report( $bootstrap_report, $options( $bootstrap, array( 'run_kind' => 'bootstrap', 'bootstrap_contract' => $bootstrap_contract, 'source_delta' => array() ) ) ); - $assert( 3 === count( $result['changed_paths'] ), 'Bootstrap must validate its complete actual documentation diff.' ); - - $thin_bootstrap = $workspace(); - $workspaces[] = $thin_bootstrap; - file_put_contents( $thin_bootstrap . '/README.md', "# Project\n\n[Guide](docs/guide.md)\n" ); - file_put_contents( $thin_bootstrap . '/docs/guide.md', "# Tiny\n" ); - $thin_report = $base_report( 'changes', array( 'README.md', 'docs/guide.md' ) ); - $thin_report['run_kind'] = 'bootstrap'; - $expect_failure( - 'BOOTSTRAP_REQUIRED_PATH', - static fn() => docs_agent_validate_report( - $thin_report, - $options( $thin_bootstrap, array( 'run_kind' => 'bootstrap', 'bootstrap_contract' => array( 'required_paths' => array( 'docs/guide.md' ), 'required_globs' => array(), 'entry_points' => array(), 'forbidden_phrases' => array() ), 'source_delta' => array() ) ) - ) - ); - - // Publication remains a separate deterministic consequence of semantic outcome. - $assert( array( 'publish' => true, 'pr_required' => true ) === docs_agent_publication_expectation( 'changes', 'bootstrap', false ), 'Bootstrap changes must publish a required PR.' ); - $assert( array( 'publish' => true, 'pr_required' => false ) === docs_agent_publication_expectation( 'changes', 'maintenance', false ), 'Maintenance changes publish without strengthening the PR projection.' ); - $assert( array( 'publish' => true, 'pr_required' => true ) === docs_agent_publication_expectation( 'changes', 'maintenance', true ), 'Maintenance require_pr must strengthen publication.' ); - $assert( array( 'publish' => false, 'pr_required' => false ) === docs_agent_publication_expectation( 'no_changes', 'maintenance', false ), 'Honest default no_changes must not fabricate publication.' ); - $assert( array( 'publish' => false, 'pr_required' => true ) === docs_agent_publication_expectation( 'no_changes', 'maintenance', true ), 'Maintenance no_changes must fail a caller publication requirement rather than fabricating a PR.' ); + file_put_contents( $bootstrap . '/README.md', "# Project\n\n[Architecture](docs/architecture.md) and [Setup](docs/setup.md) provide the source-grounded project documentation.\n" ); + file_put_contents( $bootstrap . '/docs/architecture.md', '# Architecture' . str_repeat( "\n\nSource-grounded architecture guidance.", 12 ) ); + file_put_contents( $bootstrap . '/docs/setup.md', '# Setup' . str_repeat( "\n\nSource-grounded setup guidance.", 12 ) ); + $bootstrap_contract = array( 'required_paths' => array( 'README.md', 'docs/architecture.md', 'docs/setup.md' ), 'required_globs' => array( array( 'pattern' => 'docs/**/*.md', 'min' => 2 ) ), 'entry_points' => array( array( 'path' => 'README.md', 'must_link_to' => array( 'docs/architecture.md', 'docs/setup.md' ) ) ), 'forbidden_phrases' => array() ); + $bootstrap_report = docs_agent_build_completion_report( $options( $bootstrap, array( 'run_kind' => 'bootstrap', 'source_delta' => array(), 'bootstrap_contract' => $bootstrap_contract ) ) ); + $assert( true === $bootstrap_report['bootstrap']['criteria_satisfied'] && 'passed' === $bootstrap_report['checks']['bootstrap'], 'Bootstrap result must come from filesystem checks.' ); + + $thin = $workspace(); + $workspaces[] = $thin; + file_put_contents( $thin . '/README.md', "# Project\n\n[Guide](docs/guide.md)\n" ); + file_put_contents( $thin . '/docs/guide.md', "# Tiny\n" ); + $expect_failure( 'BOOTSTRAP_REQUIRED_PATH', static fn() => docs_agent_build_completion_report( $options( $thin, array( 'run_kind' => 'bootstrap', 'source_delta' => array(), 'bootstrap_contract' => array( 'required_paths' => array( 'docs/guide.md' ), 'required_globs' => array(), 'entry_points' => array(), 'forbidden_phrases' => array() ) ) ) ) ); + + $assert( array( 'publish' => true, 'pr_required' => true ) === docs_agent_publication_expectation( 'changes', 'bootstrap', false ), 'Bootstrap publication must remain deterministic.' ); } finally { - foreach ( $workspaces as $path ) { - $run( 'rm -rf ' . escapeshellarg( $path ), '/' ); - } + foreach ( $workspaces as $path ) { $run( 'rm -rf ' . escapeshellarg( $path ), '/' ); } } fwrite( STDOUT, "Docs Agent completion contract validation passed.\n" ); diff --git a/tests/validate-docs-agent-packages.php b/tests/validate-docs-agent-packages.php index 3aa6fc4..4e451ce 100644 --- a/tests/validate-docs-agent-packages.php +++ b/tests/validate-docs-agent-packages.php @@ -70,9 +70,8 @@ $assert( str_contains( $maintain_docs_workflow, 'declared_artifacts_json:' ), 'maintain-docs.yml must expose typed artifact declarations as a reusable workflow output.' ); $assert( str_contains( $maintain_docs_workflow, 'artifact_declarations< Date: Mon, 20 Jul 2026 08:54:41 -0400 Subject: [PATCH 2/4] Pin deterministic completion validator --- .github/workflows/maintain-docs.yml | 2 +- tests/validate-docs-agent-packages.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/maintain-docs.yml b/.github/workflows/maintain-docs.yml index e4479be..2d246ae 100644 --- a/.github/workflows/maintain-docs.yml +++ b/.github/workflows/maintain-docs.yml @@ -4,7 +4,7 @@ env: # Advance this revision and all package digests together when package bytes change. DOCS_AGENT_PACKAGE_REVISION: a39d9db230eb9e0b72ed84465f4d61bd8dda1bab # Advance independently when the post-command completion validator changes. - DOCS_AGENT_COMPLETION_CONTRACT_REVISION: a39d9db230eb9e0b72ed84465f4d61bd8dda1bab + DOCS_AGENT_COMPLETION_CONTRACT_REVISION: 90beb848bf85356a12b5b60b61ed3ddb0044ef78 'on': workflow_call: diff --git a/tests/validate-docs-agent-packages.php b/tests/validate-docs-agent-packages.php index 4e451ce..d49e8b9 100644 --- a/tests/validate-docs-agent-packages.php +++ b/tests/validate-docs-agent-packages.php @@ -85,7 +85,7 @@ $assert( str_contains( $maintain_docs_workflow, 'sourceDelta:$sourceDelta' ), 'maintain-docs.yml must retain caller-known bounded source deltas in the portable recipe.' ); $assert( str_contains( $maintain_docs_workflow, 'source_delta must contain at least one caller-bounded item for maintenance.' ), 'maintain-docs.yml must reject empty maintenance source deltas before execution.' ); $assert( str_contains( $maintain_docs_workflow, 'validate-docs-agent-completion.php' ), 'maintain-docs.yml must execute the Docs Agent-owned completion validator.' ); -$completion_contract_revision = 'a39d9db230eb9e0b72ed84465f4d61bd8dda1bab'; +$completion_contract_revision = '90beb848bf85356a12b5b60b61ed3ddb0044ef78'; $assert( str_contains( $maintain_docs_workflow, 'DOCS_AGENT_COMPLETION_CONTRACT_REVISION: ' . $completion_contract_revision ), 'maintain-docs.yml must pin the completion validator to its immutable implementation commit.' ); $assert( str_contains( $maintain_docs_workflow, 'Automattic/docs-agent/$DOCS_AGENT_COMPLETION_CONTRACT_REVISION/scripts/validate-docs-agent-completion.php' ), 'maintain-docs.yml must fetch the validator independently of native package provenance.' ); $historical_validator = shell_exec( 'git -C ' . escapeshellarg( $root ) . ' show ' . escapeshellarg( $completion_contract_revision . ':scripts/validate-docs-agent-completion.php' ) ); From 44f1e60e9278dd351fafea53434999de4782514b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 20 Jul 2026 08:58:33 -0400 Subject: [PATCH 3/4] Harden completion artifact staging --- .../docs-agent-completion-report.v1.json | 2 +- scripts/validate-docs-agent-completion.php | 30 ++++++++++++++----- ...alidate-docs-agent-completion-contract.php | 7 +++++ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/contracts/docs-agent-completion-report.v1.json b/contracts/docs-agent-completion-report.v1.json index c1ac223..16abacf 100644 --- a/contracts/docs-agent-completion-report.v1.json +++ b/contracts/docs-agent-completion-report.v1.json @@ -21,7 +21,7 @@ } }, "bootstrap": { "type": "object", "additionalProperties": false, "required": ["criteria_satisfied"], "properties": { "criteria_satisfied": { "type": "boolean" } } }, - "checks": { "type": "object", "additionalProperties": false, "required": ["workspace", "writable_scope", "source_drift", "bootstrap", "verification", "drift_checks"], "properties": { "workspace": { "const": "passed" }, "writable_scope": { "const": "passed" }, "source_drift": { "const": "passed" }, "bootstrap": { "enum": ["passed", "not_applicable"] }, "verification": { "const": "passed" }, "drift_checks": { "const": "passed" } } } + "checks": { "type": "object", "additionalProperties": false, "required": ["workspace", "writable_scope", "source_drift", "bootstrap"], "properties": { "workspace": { "const": "passed" }, "writable_scope": { "const": "passed" }, "source_drift": { "const": "passed" }, "bootstrap": { "enum": ["passed", "not_applicable"] } } } }, "allOf": [{ "if": { "properties": { "run_kind": { "const": "maintenance" } }, "required": ["run_kind"] }, "then": { "properties": { "source": { "properties": { "basis": { "const": "bounded_delta" } } }, "bootstrap": { "properties": { "criteria_satisfied": { "const": false } } } } }], "$defs": { "strings": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, "nonEmptyStrings": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 }, "uniqueItems": true } } diff --git a/scripts/validate-docs-agent-completion.php b/scripts/validate-docs-agent-completion.php index 2b257b6..88e2fba 100644 --- a/scripts/validate-docs-agent-completion.php +++ b/scripts/validate-docs-agent-completion.php @@ -207,7 +207,7 @@ function docs_agent_build_completion_report( array $options ): array { 'records' => $source['records'], ), 'bootstrap' => array( 'criteria_satisfied' => $bootstrap ), - 'checks' => array( 'workspace' => 'passed', 'writable_scope' => 'passed', 'source_drift' => 'passed', 'bootstrap' => $bootstrap ? 'passed' : 'not_applicable', 'verification' => 'passed', 'drift_checks' => 'passed' ), + 'checks' => array( 'workspace' => 'passed', 'writable_scope' => 'passed', 'source_drift' => 'passed', 'bootstrap' => $bootstrap ? 'passed' : 'not_applicable' ), ); } @@ -226,14 +226,27 @@ function docs_agent_write_report_artifact( array $report, string $workspace, str if ( false === $workspace_real ) { docs_agent_fail( 'ARTIFACT_PATH_INVALID', 'Completion artifact workspace must be a real directory.' ); } - $root = $workspace_real . '/.codebox/agent-task-artifacts'; - if ( ( ! is_dir( $root ) && ! mkdir( $root, 0700, true ) ) || is_link( $workspace_real . '/.codebox' ) || is_link( $root ) ) { - docs_agent_fail( 'ARTIFACT_PATH_INVALID', 'Completion artifact root must be a writable real directory.' ); + $codebox = $workspace_real . '/.codebox'; + if ( ( ! is_dir( $codebox ) && ! mkdir( $codebox, 0700 ) ) || is_link( $codebox ) ) { + docs_agent_fail( 'ARTIFACT_PATH_INVALID', 'Completion artifact path must not traverse a .codebox symlink.' ); + } + $root = $codebox . '/agent-task-artifacts'; + if ( ( ! is_dir( $root ) && ! mkdir( $root, 0700, true ) ) || is_link( $root ) ) { + docs_agent_fail( 'ARTIFACT_WRITE_FAILED', 'Completion artifact root must be a writable real directory.' ); + } + $parent = $root; + foreach ( array_slice( explode( '/', $artifact_path ), 0, -1 ) as $segment ) { + if ( '' === $segment || '.' === $segment ) { + continue; + } + $parent .= '/' . $segment; + if ( ( ! is_dir( $parent ) && ! mkdir( $parent, 0700 ) ) || is_link( $parent ) ) { + docs_agent_fail( 'ARTIFACT_PATH_INVALID', 'Completion artifact path must not traverse symlinks.' ); + } } $target = $root . '/' . $artifact_path; - $parent = dirname( $target ); - if ( ! is_dir( $parent ) && ! mkdir( $parent, 0700, true ) ) { - docs_agent_fail( 'ARTIFACT_WRITE_FAILED', 'Could not create completion artifact parent directory.' ); + if ( is_link( $target ) ) { + docs_agent_fail( 'ARTIFACT_PATH_INVALID', 'Completion artifact path must not traverse symlinks.' ); } $root_real = realpath( $root ); $parent_real = realpath( $parent ); @@ -241,6 +254,9 @@ function docs_agent_write_report_artifact( array $report, string $workspace, str docs_agent_fail( 'ARTIFACT_PATH_INVALID', 'Completion artifact path escapes .codebox/agent-task-artifacts.' ); } $bytes = docs_agent_canonical_report_json( $report ); + if ( strlen( $bytes ) > 2 * 1024 * 1024 ) { + docs_agent_fail( 'ARTIFACT_TOO_LARGE', 'Completion artifact exceeds the 2 MiB bound.' ); + } $temp = tempnam( $parent_real, '.docs-agent-completion-' ); if ( false === $temp || strlen( $bytes ) !== file_put_contents( $temp, $bytes ) || ! rename( $temp, $target ) ) { if ( is_string( $temp ) ) { diff --git a/tests/validate-docs-agent-completion-contract.php b/tests/validate-docs-agent-completion-contract.php index a831eb3..403ffe4 100644 --- a/tests/validate-docs-agent-completion-contract.php +++ b/tests/validate-docs-agent-completion-contract.php @@ -52,6 +52,13 @@ $artifact = $clean . '/.codebox/agent-task-artifacts/docs-agent-completion-report.json'; $assert( docs_agent_canonical_report_json( $no_change ) === file_get_contents( $artifact ), 'The command must stage canonical host-generated bytes.' ); $expect_failure( 'ARTIFACT_PATH_INVALID', static fn() => docs_agent_write_report_artifact( $no_change, $clean, '../completion.json' ) ); + $nested_target = $clean . '/nested-artifact-target'; + mkdir( $nested_target, 0700 ); + symlink( $nested_target, $clean . '/.codebox/agent-task-artifacts/nested' ); + $expect_failure( 'ARTIFACT_PATH_INVALID', static fn() => docs_agent_write_report_artifact( $no_change, $clean, 'nested/completion.json' ) ); + $large_report = $no_change; + $large_report['source']['refs'][] = str_repeat( 'x', 2 * 1024 * 1024 ); + $expect_failure( 'ARTIFACT_TOO_LARGE', static fn() => docs_agent_write_report_artifact( $large_report, $clean, 'large.json' ) ); $scope = $workspace(); $workspaces[] = $scope; From 45f2dd610fc6c7167dcfc8f3fcd0ff8993bfb2d6 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 20 Jul 2026 08:59:08 -0400 Subject: [PATCH 4/4] Pin hardened completion validator --- .github/workflows/maintain-docs.yml | 2 +- tests/validate-docs-agent-packages.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/maintain-docs.yml b/.github/workflows/maintain-docs.yml index 2d246ae..8e236a8 100644 --- a/.github/workflows/maintain-docs.yml +++ b/.github/workflows/maintain-docs.yml @@ -4,7 +4,7 @@ env: # Advance this revision and all package digests together when package bytes change. DOCS_AGENT_PACKAGE_REVISION: a39d9db230eb9e0b72ed84465f4d61bd8dda1bab # Advance independently when the post-command completion validator changes. - DOCS_AGENT_COMPLETION_CONTRACT_REVISION: 90beb848bf85356a12b5b60b61ed3ddb0044ef78 + DOCS_AGENT_COMPLETION_CONTRACT_REVISION: 44f1e60e9278dd351fafea53434999de4782514b 'on': workflow_call: diff --git a/tests/validate-docs-agent-packages.php b/tests/validate-docs-agent-packages.php index d49e8b9..058597e 100644 --- a/tests/validate-docs-agent-packages.php +++ b/tests/validate-docs-agent-packages.php @@ -85,7 +85,7 @@ $assert( str_contains( $maintain_docs_workflow, 'sourceDelta:$sourceDelta' ), 'maintain-docs.yml must retain caller-known bounded source deltas in the portable recipe.' ); $assert( str_contains( $maintain_docs_workflow, 'source_delta must contain at least one caller-bounded item for maintenance.' ), 'maintain-docs.yml must reject empty maintenance source deltas before execution.' ); $assert( str_contains( $maintain_docs_workflow, 'validate-docs-agent-completion.php' ), 'maintain-docs.yml must execute the Docs Agent-owned completion validator.' ); -$completion_contract_revision = '90beb848bf85356a12b5b60b61ed3ddb0044ef78'; +$completion_contract_revision = '44f1e60e9278dd351fafea53434999de4782514b'; $assert( str_contains( $maintain_docs_workflow, 'DOCS_AGENT_COMPLETION_CONTRACT_REVISION: ' . $completion_contract_revision ), 'maintain-docs.yml must pin the completion validator to its immutable implementation commit.' ); $assert( str_contains( $maintain_docs_workflow, 'Automattic/docs-agent/$DOCS_AGENT_COMPLETION_CONTRACT_REVISION/scripts/validate-docs-agent-completion.php' ), 'maintain-docs.yml must fetch the validator independently of native package provenance.' ); $historical_validator = shell_exec( 'git -C ' . escapeshellarg( $root ) . ' show ' . escapeshellarg( $completion_contract_revision . ':scripts/validate-docs-agent-completion.php' ) );