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..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: a39d9db230eb9e0b72ed84465f4d61bd8dda1bab
+ DOCS_AGENT_COMPLETION_CONTRACT_REVISION: 44f1e60e9278dd351fafea53434999de4782514b
'on':
workflow_call:
@@ -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..16abacf 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"], "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": { "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..88e2fba 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' ),
+ );
}
/** @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 */
@@ -332,7 +234,6 @@ function docs_agent_write_report_artifact( array $report, string $workspace, str
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.' );
}
- $target = $root . '/' . $artifact_path;
$parent = $root;
foreach ( array_slice( explode( '/', $artifact_path ), 0, -1 ) as $segment ) {
if ( '' === $segment || '.' === $segment ) {
@@ -343,12 +244,13 @@ function docs_agent_write_report_artifact( array $report, string $workspace, str
docs_agent_fail( 'ARTIFACT_PATH_INVALID', 'Completion artifact path must not traverse symlinks.' );
}
}
+ $target = $root . '/' . $artifact_path;
if ( is_link( $target ) ) {
docs_agent_fail( 'ARTIFACT_PATH_INVALID', 'Completion artifact path must not traverse symlinks.' );
}
- $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 );
@@ -360,62 +262,18 @@ function docs_agent_write_report_artifact( array $report, string $workspace, str
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 +285,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..403ffe4 100644
--- a/tests/validate-docs-agent-completion-contract.php
+++ b/tests/validate-docs-agent-completion-contract.php
@@ -5,217 +5,90 @@
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 ) ) ) )
- )
- );
-
- // 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 ) ) ) );
-
- // 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' ) ) ) );
+ // 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' );
+ }
- // 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 ) ) );
+ $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' ) );
+ $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;
+ 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' ) ) ) );
+
+ $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..058597e 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<