1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages

Compare commits

...

2 commits

23 changed files with 6956 additions and 19 deletions

View file

@ -6,11 +6,14 @@ Goal: Let one project-bound server return compact, task-shaped, explainable cont
In scope: Capability modes; capability-aware bootstrap; versioned retrieval plans and context capsules; task-shaped context; generation diffs; evidence-gap diagnostics; generated client configuration; doctor checks. In scope: Capability modes; capability-aware bootstrap; versioned retrieval plans and context capsules; task-shaped context; generation diffs; evidence-gap diagnostics; generated client configuration; doctor checks.
Out of scope: Independent render-plan packages; adapter SDK expansion; self-hosting; storage replacement; embeddings; WorldForge or ScrapeStation changes; production MCP repointing; tags and releases. Out of scope: Independent render-plan packages; adapter SDK expansion; self-hosting; storage replacement; embeddings; WorldForge or ScrapeStation changes; production MCP repointing; tags and releases.
Done when: Policy and capabilities are explicit; bootstrap recommends only available actions; task context is compact, deterministic, provenance-bearing, and bounded; generation and evidence gaps are explainable; generated configuration and doctor checks are safe and tested; the complete repository gate and Milestone 2 benchmark pass. Done when: Policy and capabilities are explicit; bootstrap recommends only available actions; task context is compact, deterministic, provenance-bearing, and bounded; generation and evidence gaps are explainable; generated configuration and doctor checks are safe and tested; the complete repository gate and Milestone 2 benchmark pass.
Status: Active. Effective policy and versioned task retrieval are committed. The latest-generation Status: Complete. Effective policy, versioned task retrieval, latest-generation diff receipts,
diff receipt is implemented with focused contract, failure, CLI, MCP, legacy-adapter, no-AST, and logarithmic bounded page packing, deterministic client configuration, and the read-only integration
zero-work tests. The full repository gate passes with 176 tests and 113 subtests. Independent doctor are implemented and contract-tested. The complete repository gate passes with 205 tests and
publication, contract, and performance audits approve the hardened tree for commit. Generated 120 subtests. Three independent adversarial audits found no remaining implementation blocker. The
client configuration and doctor checks follow. clean 1,000-node baseline is recorded against candidate commit
`fb0df5e4a1c591c2a84788fd4814d98550f11863`, including task/generation reconstruction,
response-size behavior, zero-hidden-work counters, and isolated memory. No tag or release was
created, no production integration was repointed, and self-hosting remains out of scope.
``` ```
Milestones 35 remain directional context and are not active. Milestones 35 remain directional context and are not active.

View file

@ -523,3 +523,90 @@ fell from 272.06 ms p95 to 56.617 ms p95, while full traversal fell from roughly
203.55 ms p95. Regression tests require one receipt validation and at most 15 response encodes for 203.55 ms p95. Regression tests require one receipt validation and at most 15 response encodes for
1,000 page candidates. Final independent publication, contract, and performance audits approve 1,000 page candidates. Final independent publication, contract, and performance audits approve
the slice for commit. the slice for commit.
### Deterministic client configuration and read-only doctor
Client integration remains an explicit machine-local boundary rather than canonical project
content. `docforge configure {codex,claude,openclaw} --project PATH` previews a deterministic
version-1 fragment by default. An optional output path publishes only a standalone fragment into
an existing real directory. Publication is create-only, private-mode, no-follow, bounded, and
conflict-aware. Existing differing client configuration is never merged, replaced, or silently
overwritten.
Generated commands use the exact current virtual-environment Python executable with isolated
module startup. The binding records explicit read, proposal, or application mode, no-AST policy,
render policy, empty environment, and bounded timeouts. Proposal and application generation fail
closed unless the descriptor declares the required writer and matching applier identity. A generic
CLI cannot reconstruct project-owned adapter composition, so custom adapters return an explicit
unavailable result instead of generating a misleading command.
Codex and OpenClaw fragments include their verified timeout fields. Claude JSON fragment syntax is
supported, while its timeout representation remains an explicit warning. The configuration result
has a strict JSON schema and canonical plan hash. Diagnostics are additive and remain disabled by
default.
`docforge doctor --client CLIENT` performs bounded, non-mutating inspection only. It reads the
project descriptor and selected client file through stable, directory-bound, no-follow handles;
parses at most 1 MiB and 256 server entries; selects at most one exact project binding; validates
the closed server argument set; checks executable, capability, declared authority, no-AST,
timeouts, environment-key names, and tool-filter presence; and performs only a stat-level index
presence check. It never loads canonical sources, opens SQLite, starts MCP, executes the configured
command, synchronizes, builds, renders, starts a viewer, or writes client configuration.
Doctor reports healthy, degraded, or unhealthy with stable process exit codes 0, 1, and 2. Secret
environment values are parsed only to enforce bounded string limits and are never returned.
Unknown or unverified client tool filtering, Claude timeout representation, implicit legacy
capability mode, shadowed authority, and missing disposable indexes are warnings. Unsafe paths,
malformed matching entries, unexpected executables, wrong project roots, invalid authorities, and
missing configuration are failures.
The first benchmark smoke failed for the correct product reason: its disposable doctor fragment
used the shared path `/tmp/doctor-codex.toml`, where a previous run had left different content. The
harness now creates a project subdirectory inside one unique temporary root and places the client
fragment beside it. This preserves create-only conflict safety and makes every run disposable.
The final pre-commit 1,000-node audit sample passes every provisional Milestone 2 gate. Task
context reconstructs 1,000 candidates as 108 cited evidence records and 892 explicit bounded
omissions across 11 pages in 703.808 ms. Generation diff reconstructs 1,000 changed details across
10 pages in 427.450 ms. Maximum pages remain below the 200,000-byte MCP budget; generation diff
uses 199,566 bytes and proves that diagnostics are discarded before the primary result. Isolated
peak RSS is 86,168 KiB.
Configuration preview now includes a bounded real import probe of the exact isolated interpreter,
so its provisional single-sample latency is about 315 ms rather than the earlier sub-millisecond
derivation-only figure. Doctor remains below 1 ms on generated disposable configurations.
Every configuration and doctor hidden-work counter is zero.
The aggregate `make gate` includes the Milestone 2 smoke benchmark. The frozen candidate passes
205 tests and 120 schema subtests, strict warnings, Ruff, formatting, Pyright, web checks,
compilation, lock and dependency checks, package builds, and all three milestone smoke benchmarks.
Three independent final audits approve client publication and policy binding, doctor fail-closed
behavior, and benchmark/contract coverage. Clean-revision benchmark evidence is still required
before closeout.
### Milestone 2 closeout
Candidate commit `fb0df5e4a1c591c2a84788fd4814d98550f11863` passed the clean ten-sample
Milestone 2 benchmark. Task-context complete traversal measured 703.561 ms median and 721.847 ms
p95 across 11 bounded pages. It reconstructed the exact 1,000-candidate collection from 108 cited
evidence records, 891 original token-budget omissions, and one hash-attested response-limit
surrogate. Generation-diff complete traversal measured 418.607 ms median and 425.315 ms p95 across
10 pages.
Read and no-AST bootstrap remained below 10 ms p95. The maximum generation page used 199,566 bytes
of the 200,000-byte budget and correctly discarded diagnostics before primary evidence.
Configuration preview measured about 314 ms median and 365 ms p95 because it proves the real
isolated interpreter import on every invocation. Codex and OpenClaw doctor checks remained below
0.6 ms p95; Claude remained explicitly degraded because its timeout format is unverified.
Isolated-process peak RSS was 86,448 KiB against the 262,144 KiB gate.
All measured configuration and doctor counters were zero. Task-context pages performed one index
check and two cheap generation checks with no loads, parses, synchronization, builds, extraction,
rendering, or viewer work. Generation-diff pages performed two cheap generation checks and no
index check. The canonical machine-readable result is
`benchmarks/milestone2-2026-07-29.json`.
Milestone 2 is complete. Follow-up ideas stay explicitly later-scope: avoid recomputing the
task-shaped capsule for every continuation page, add authenticated continuation when the threat
model requires it, verify a native Claude timeout representation, and introduce adapter-owned
launcher metadata before generating configurations for custom adapters.

View file

@ -5,7 +5,7 @@ NPM := npm
PYTHONPYCACHEPREFIX := /tmp/docforge-quality-pycache PYTHONPYCACHEPREFIX := /tmp/docforge-quality-pycache
PYTEST_BASETEMP := /tmp/docforge-quality-pytest PYTEST_BASETEMP := /tmp/docforge-quality-pytest
.PHONY: benchmark benchmark-m1 benchmark-m1-smoke benchmark-smoke build compile contract dependencies format-check gate lint lock test type .PHONY: benchmark benchmark-m1 benchmark-m1-smoke benchmark-m2 benchmark-m2-smoke benchmark-smoke build compile contract dependencies format-check gate lint lock test type
format-check: format-check:
$(PYTHON) -m ruff format --check src tests tools $(PYTHON) -m ruff format --check src tests tools
@ -24,6 +24,11 @@ contract:
PYTHONPYCACHEPREFIX=$(PYTHONPYCACHEPREFIX) $(PYTHON) -m pytest -q \ PYTHONPYCACHEPREFIX=$(PYTHONPYCACHEPREFIX) $(PYTHON) -m pytest -q \
-p no:cacheprovider --basetemp=$(PYTEST_BASETEMP) \ -p no:cacheprovider --basetemp=$(PYTEST_BASETEMP) \
tests/test_public_contract.py \ tests/test_public_contract.py \
tests/test_policy.py \
tests/test_retrieval.py \
tests/test_generation_diff.py \
tests/test_client_integration.py \
tests/test_observability.py::TelemetryContractTests::test_schema_fixed_names_match_the_implementation \
tests/test_adapter_contract.py::AdapterContractTests::test_no_ast_index_policy_rejects_logic_publication \ tests/test_adapter_contract.py::AdapterContractTests::test_no_ast_index_policy_rejects_logic_publication \
tests/test_adapter_contract.py::AdapterContractTests::test_no_ast_index_accepts_legacy_and_non_logic_incremental_adapters \ tests/test_adapter_contract.py::AdapterContractTests::test_no_ast_index_accepts_legacy_and_non_logic_incremental_adapters \
tests/test_adapter_contract.py::AdapterContractTests::test_no_ast_rejects_preexisting_logic_index_and_viewer_snapshot \ tests/test_adapter_contract.py::AdapterContractTests::test_no_ast_rejects_preexisting_logic_index_and_viewer_snapshot \
@ -56,4 +61,11 @@ benchmark-m1-smoke:
benchmark-m1: benchmark-m1:
$(PYTHON) tools/milestone1_benchmark.py --nodes 1000 --samples 10 $(PYTHON) tools/milestone1_benchmark.py --nodes 1000 --samples 10
gate: format-check lint type compile contract test lock dependencies build benchmark-smoke benchmark-m1-smoke benchmark-m2-smoke:
$(PYTHON) tools/milestone2_benchmark.py --nodes 25 --samples 1 \
--output /tmp/docforge-milestone2-smoke.json > /dev/null
benchmark-m2:
$(PYTHON) tools/milestone2_benchmark.py --nodes 1000 --samples 10
gate: format-check lint type compile contract test lock dependencies build benchmark-smoke benchmark-m1-smoke benchmark-m2-smoke

View file

@ -13,6 +13,10 @@ declared manuals, visualizes project structure, and manages reviewable documenta
continuation. continuation.
- Records one bounded, versioned latest-generation graph transition without creating a history - Records one bounded, versioned latest-generation graph transition without creating a history
database. database.
- Generates deterministic project-bound Codex, Claude, and OpenClaw client fragments without
copying ambient secrets.
- Diagnoses one client binding through bounded read-only checks without starting MCP or rebuilding
project state.
- Automatically synchronizes disposable indexes before MCP work. - Automatically synchronizes disposable indexes before MCP work.
- Creates, validates, diffs, and previews isolated changesets. - Creates, validates, diffs, and previews isolated changesets.
- Registers complete proposals atomically without caller-managed hash chaining. - Registers complete proposals atomically without caller-managed hash chaining.
@ -135,6 +139,16 @@ Start an MCP server for one project:
Add `--canonical-applier project-editor` only when that MCP integration should expose the Add `--canonical-applier project-editor` only when that MCP integration should expose the
hash-bound `docforge_apply_changeset` tool. hash-bound `docforge_apply_changeset` tool.
Preview a read-only Codex fragment and diagnose an installed binding:
```bash
.venv/bin/docforge configure codex --project "$PROJECT"
.venv/bin/docforge doctor --client codex --project "$PROJECT"
```
Pass `--output /absolute/path/docforge.toml` only when creating a standalone fragment. DocForge
never replaces or merges an existing different client file.
For an unconfigured codebase, begin with a read-only language and documentation assessment: For an unconfigured codebase, begin with a read-only language and documentation assessment:
```bash ```bash
@ -156,6 +170,10 @@ DocForge describes them as a source graph.
performance, memory, rendering and response sizes, bottlenecks, and missing coverage. performance, memory, rendering and response sizes, bottlenecks, and missing coverage.
- [Milestone 0 closeout](docs/MILESTONE_0_CLOSEOUT.md) — lineage, migration, security scan, - [Milestone 0 closeout](docs/MILESTONE_0_CLOSEOUT.md) — lineage, migration, security scan,
repository state, and fresh-clone proof. repository state, and fresh-clone proof.
- [Milestone 2 baseline](docs/MILESTONE_2_BASELINE.md) — task context, generation diff, client
configuration, doctor, response-size, counter, and memory measurements.
- [Milestone 2 closeout](docs/MILESTONE_2_CLOSEOUT.md) — implemented contracts, adversarial
validation, exclusions, and exact candidate evidence.
- [MCP contract](docs/MCP_CONTRACT.md) — exact tool and process boundary. - [MCP contract](docs/MCP_CONTRACT.md) — exact tool and process boundary.
- [Viewer manager](docs/VIEWER_MANAGER.md) — native service setup and lifecycle. - [Viewer manager](docs/VIEWER_MANAGER.md) — native service setup and lifecycle.
- [Adapter decision](docs/APPLICATION_DECISION.md) — why custom adapters own canonical - [Adapter decision](docs/APPLICATION_DECISION.md) — why custom adapters own canonical
@ -178,7 +196,7 @@ make gate
Focused entry points are available as `make contract`, `make test`, `make type`, Focused entry points are available as `make contract`, `make test`, `make type`,
`make benchmark-smoke`, `make benchmark`, `make benchmark-m1-smoke`, and `make benchmark-smoke`, `make benchmark`, `make benchmark-m1-smoke`, and
`make benchmark-m1`. `make benchmark-m1`. Milestone 2 adds `make benchmark-m2-smoke` and `make benchmark-m2`.
The committed 1,000-node baseline and its measurement method are under `benchmarks/`. The committed 1,000-node baseline and its measurement method are under `benchmarks/`.

View file

@ -27,6 +27,13 @@ Run the maintained 1,000-node Milestone 1 benchmark:
make benchmark-m1 make benchmark-m1
``` ```
Run the Milestone 2 agent-workflow smoke and full gates:
```bash
make benchmark-m2-smoke
make benchmark-m2
```
The benchmark creates canonical sources, derived state, changesets, rendered output, and caches The benchmark creates canonical sources, derived state, changesets, rendered output, and caches
only in a disposable temporary directory. It does not read another project, self-host DocForge, or only in a disposable temporary directory. It does not read another project, self-host DocForge, or
mutate repository content. mutate repository content.
@ -42,6 +49,13 @@ harness enforces operation-specific p95 ceilings and fixed zero-work counter inv
human-readable interpretation is in human-readable interpretation is in
[`docs/MILESTONE_1_BASELINE.md`](../docs/MILESTONE_1_BASELINE.md). [`docs/MILESTONE_1_BASELINE.md`](../docs/MILESTONE_1_BASELINE.md).
`milestone2-2026-07-29.json` is the clean-tree agent-retrieval and client-integration baseline
captured from commit `fb0df5e4a1c591c2a84788fd4814d98550f11863`. It gates every warmup and
sample, reconstructs complete task-context and generation-diff collections across bounded pages,
records whether diagnostics were dropped for response budget, checks all hidden-work counters,
and measures isolated-process peak RSS. Its interpretation is in
[`docs/MILESTONE_2_BASELINE.md`](../docs/MILESTONE_2_BASELINE.md).
The generic fixture exposes whole-source scaling. It does not replace the incremental adapter The generic fixture exposes whole-source scaling. It does not replace the incremental adapter
equivalence tests and does not claim to measure a portable graph renderer, because Milestone 0 has equivalence tests and does not claim to measure a portable graph renderer, because Milestone 0 has
no portable graph-planning or graph-rendering contract. no portable graph-planning or graph-rendering contract.

View file

@ -0,0 +1,457 @@
{
"benchmark": "docforge2_milestone2",
"environment": {
"implementation": "CPython",
"machine": "x86_64",
"platform": "Linux-7.1.3-200.nobara.fc44.x86_64-x86_64-with-glibc2.43",
"python": "3.14.6"
},
"fixture": {
"edge_count": 999,
"kind": "synthetic_generic_focus_fan_in",
"max_tool_output_chars": 200000,
"node_count": 1000,
"source_file_count": 1000
},
"isolated_process_peak_rss_kib": 86448,
"method": {
"clock": "time.perf_counter_ns",
"memory": "isolated child-process resource.getrusage(RUSAGE_SELF).ru_maxrss",
"memory_limit_kib": 262144,
"memory_probe_samples": 10,
"percentile": "nearest-rank",
"response_size": "UTF-8 bytes of compact sorted JSON",
"samples": 10,
"warmups": 1,
"zero_work_counters": [
"project_loads",
"source_files_parsed",
"source_bytes_parsed",
"adapter_projection_loads",
"adapter_source_extractions",
"index_synchronizations",
"index_builds",
"render_prepare_calls",
"render_output_bytes_built",
"render_output_bytes_hashed",
"viewer_manager_requests"
]
},
"operations": {
"bootstrap_no_ast": {
"counter_ranges": {
"adapter_projection_loads": {"maximum": 0, "minimum": 0},
"adapter_source_extractions": {"maximum": 0, "minimum": 0},
"index_builds": {"maximum": 0, "minimum": 0},
"index_checks": {"maximum": 1, "minimum": 1},
"index_synchronizations": {"maximum": 1, "minimum": 1},
"project_loads": {"maximum": 0, "minimum": 0},
"render_output_bytes_built": {"maximum": 0, "minimum": 0},
"render_output_bytes_hashed": {"maximum": 0, "minimum": 0},
"render_prepare_calls": {"maximum": 0, "minimum": 0},
"source_bytes_parsed": {"maximum": 0, "minimum": 0},
"source_files_parsed": {"maximum": 0, "minimum": 0},
"source_generation_checks": {"maximum": 1, "minimum": 1},
"viewer_manager_requests": {"maximum": 0, "minimum": 0}
},
"max_ms": 9.379,
"maximum_response_bytes": 8796,
"median_ms": 9.153,
"min_ms": 9.026,
"p95_limit_ms": 100,
"p95_ms": 9.379,
"response_bytes": 8795,
"response_limit_bytes": 32768,
"samples": 10,
"validated_invocations": 11
},
"bootstrap_read": {
"counter_ranges": {
"adapter_projection_loads": {"maximum": 0, "minimum": 0},
"adapter_source_extractions": {"maximum": 0, "minimum": 0},
"index_builds": {"maximum": 0, "minimum": 0},
"index_checks": {"maximum": 1, "minimum": 1},
"index_synchronizations": {"maximum": 1, "minimum": 1},
"project_loads": {"maximum": 0, "minimum": 0},
"render_output_bytes_built": {"maximum": 0, "minimum": 0},
"render_output_bytes_hashed": {"maximum": 0, "minimum": 0},
"render_prepare_calls": {"maximum": 0, "minimum": 0},
"source_bytes_parsed": {"maximum": 0, "minimum": 0},
"source_files_parsed": {"maximum": 0, "minimum": 0},
"source_generation_checks": {"maximum": 1, "minimum": 1},
"viewer_manager_requests": {"maximum": 0, "minimum": 0}
},
"max_ms": 9.884,
"maximum_response_bytes": 7495,
"median_ms": 9.406,
"min_ms": 9.053,
"p95_limit_ms": 100,
"p95_ms": 9.884,
"response_bytes": 7491,
"response_limit_bytes": 32768,
"samples": 10,
"validated_invocations": 11
},
"configuration_preview": {
"claude": {
"artifact_format": "claude-json-fragment-v1",
"configuration_hash": "4dbb4ed0f38264fdba350de8904cc898493d620194b5105b7889c54bd5913c9c",
"counter_ranges": {
"adapter_projection_loads": {"maximum": 0, "minimum": 0},
"adapter_source_extractions": {"maximum": 0, "minimum": 0},
"index_builds": {"maximum": 0, "minimum": 0},
"index_checks": {"maximum": 0, "minimum": 0},
"index_synchronizations": {"maximum": 0, "minimum": 0},
"project_loads": {"maximum": 0, "minimum": 0},
"render_output_bytes_built": {"maximum": 0, "minimum": 0},
"render_output_bytes_hashed": {"maximum": 0, "minimum": 0},
"render_prepare_calls": {"maximum": 0, "minimum": 0},
"source_bytes_parsed": {"maximum": 0, "minimum": 0},
"source_files_parsed": {"maximum": 0, "minimum": 0},
"source_generation_checks": {"maximum": 0, "minimum": 0},
"viewer_manager_requests": {"maximum": 0, "minimum": 0}
},
"max_ms": 364.365,
"maximum_response_bytes": 2748,
"median_ms": 314.326,
"min_ms": 314.278,
"p95_limit_ms": 500,
"p95_ms": 364.365,
"response_bytes": 2748,
"response_limit_bytes": 32768,
"samples": 10,
"validated_invocations": 11
},
"codex": {
"artifact_format": "codex-toml-fragment-v1",
"configuration_hash": "3e1dd5191021da1778cd1c4f4658768537775e5e16252e42d4c80e328841145b",
"counter_ranges": {
"adapter_projection_loads": {"maximum": 0, "minimum": 0},
"adapter_source_extractions": {"maximum": 0, "minimum": 0},
"index_builds": {"maximum": 0, "minimum": 0},
"index_checks": {"maximum": 0, "minimum": 0},
"index_synchronizations": {"maximum": 0, "minimum": 0},
"project_loads": {"maximum": 0, "minimum": 0},
"render_output_bytes_built": {"maximum": 0, "minimum": 0},
"render_output_bytes_hashed": {"maximum": 0, "minimum": 0},
"render_prepare_calls": {"maximum": 0, "minimum": 0},
"source_bytes_parsed": {"maximum": 0, "minimum": 0},
"source_files_parsed": {"maximum": 0, "minimum": 0},
"source_generation_checks": {"maximum": 0, "minimum": 0},
"viewer_manager_requests": {"maximum": 0, "minimum": 0}
},
"max_ms": 364.383,
"maximum_response_bytes": 2627,
"median_ms": 314.365,
"min_ms": 314.248,
"p95_limit_ms": 500,
"p95_ms": 364.383,
"response_bytes": 2627,
"response_limit_bytes": 32768,
"samples": 10,
"validated_invocations": 11
},
"openclaw": {
"artifact_format": "openclaw-json-fragment-v1",
"configuration_hash": "6f269e90a55088c5d517f91761c53a3b90036d62fd80b5b9d094668267257b99",
"counter_ranges": {
"adapter_projection_loads": {"maximum": 0, "minimum": 0},
"adapter_source_extractions": {"maximum": 0, "minimum": 0},
"index_builds": {"maximum": 0, "minimum": 0},
"index_checks": {"maximum": 0, "minimum": 0},
"index_synchronizations": {"maximum": 0, "minimum": 0},
"project_loads": {"maximum": 0, "minimum": 0},
"render_output_bytes_built": {"maximum": 0, "minimum": 0},
"render_output_bytes_hashed": {"maximum": 0, "minimum": 0},
"render_prepare_calls": {"maximum": 0, "minimum": 0},
"source_bytes_parsed": {"maximum": 0, "minimum": 0},
"source_files_parsed": {"maximum": 0, "minimum": 0},
"source_generation_checks": {"maximum": 0, "minimum": 0},
"viewer_manager_requests": {"maximum": 0, "minimum": 0}
},
"max_ms": 364.532,
"maximum_response_bytes": 2869,
"median_ms": 314.401,
"min_ms": 314.251,
"p95_limit_ms": 500,
"p95_ms": 364.532,
"response_bytes": 2869,
"response_limit_bytes": 32768,
"samples": 10,
"validated_invocations": 11
}
},
"doctor": {
"claude": {
"counter_ranges": {
"adapter_projection_loads": {"maximum": 0, "minimum": 0},
"adapter_source_extractions": {"maximum": 0, "minimum": 0},
"index_builds": {"maximum": 0, "minimum": 0},
"index_checks": {"maximum": 0, "minimum": 0},
"index_synchronizations": {"maximum": 0, "minimum": 0},
"project_loads": {"maximum": 0, "minimum": 0},
"render_output_bytes_built": {"maximum": 0, "minimum": 0},
"render_output_bytes_hashed": {"maximum": 0, "minimum": 0},
"render_prepare_calls": {"maximum": 0, "minimum": 0},
"source_bytes_parsed": {"maximum": 0, "minimum": 0},
"source_files_parsed": {"maximum": 0, "minimum": 0},
"source_generation_checks": {"maximum": 0, "minimum": 0},
"viewer_manager_requests": {"maximum": 0, "minimum": 0}
},
"doctor_state": "degraded",
"max_ms": 0.446,
"maximum_response_bytes": 3669,
"median_ms": 0.364,
"min_ms": 0.352,
"p95_limit_ms": 100,
"p95_ms": 0.446,
"response_bytes": 3669,
"response_limit_bytes": 32768,
"samples": 10,
"summary": {"failed": 0, "passed": 11, "skipped": 1, "warning": 2},
"validated_invocations": 11
},
"codex": {
"counter_ranges": {
"adapter_projection_loads": {"maximum": 0, "minimum": 0},
"adapter_source_extractions": {"maximum": 0, "minimum": 0},
"index_builds": {"maximum": 0, "minimum": 0},
"index_checks": {"maximum": 0, "minimum": 0},
"index_synchronizations": {"maximum": 0, "minimum": 0},
"project_loads": {"maximum": 0, "minimum": 0},
"render_output_bytes_built": {"maximum": 0, "minimum": 0},
"render_output_bytes_hashed": {"maximum": 0, "minimum": 0},
"render_prepare_calls": {"maximum": 0, "minimum": 0},
"source_bytes_parsed": {"maximum": 0, "minimum": 0},
"source_files_parsed": {"maximum": 0, "minimum": 0},
"source_generation_checks": {"maximum": 0, "minimum": 0},
"viewer_manager_requests": {"maximum": 0, "minimum": 0}
},
"doctor_state": "healthy",
"max_ms": 0.556,
"maximum_response_bytes": 3595,
"median_ms": 0.421,
"min_ms": 0.404,
"p95_limit_ms": 100,
"p95_ms": 0.556,
"response_bytes": 3595,
"response_limit_bytes": 32768,
"samples": 10,
"summary": {"failed": 0, "passed": 13, "skipped": 1, "warning": 0},
"validated_invocations": 11
},
"openclaw": {
"counter_ranges": {
"adapter_projection_loads": {"maximum": 0, "minimum": 0},
"adapter_source_extractions": {"maximum": 0, "minimum": 0},
"index_builds": {"maximum": 0, "minimum": 0},
"index_checks": {"maximum": 0, "minimum": 0},
"index_synchronizations": {"maximum": 0, "minimum": 0},
"project_loads": {"maximum": 0, "minimum": 0},
"render_output_bytes_built": {"maximum": 0, "minimum": 0},
"render_output_bytes_hashed": {"maximum": 0, "minimum": 0},
"render_prepare_calls": {"maximum": 0, "minimum": 0},
"source_bytes_parsed": {"maximum": 0, "minimum": 0},
"source_files_parsed": {"maximum": 0, "minimum": 0},
"source_generation_checks": {"maximum": 0, "minimum": 0},
"viewer_manager_requests": {"maximum": 0, "minimum": 0}
},
"doctor_state": "healthy",
"max_ms": 0.484,
"maximum_response_bytes": 3602,
"median_ms": 0.384,
"min_ms": 0.353,
"p95_limit_ms": 100,
"p95_ms": 0.484,
"response_bytes": 3602,
"response_limit_bytes": 32768,
"samples": 10,
"summary": {"failed": 0, "passed": 13, "skipped": 1, "warning": 0},
"validated_invocations": 11
}
},
"generation_diff_complete": {
"max_ms": 425.315,
"maximum_response_bytes": 984,
"median_ms": 418.607,
"min_ms": 410.974,
"p95_limit_ms": 500,
"p95_ms": 425.315,
"response_bytes": 983,
"response_limit_bytes": 32768,
"result_summary": {
"aggregate_page_bytes": 664715,
"counter_ranges": {
"adapter_projection_loads": {"maximum": 0, "minimum": 0},
"adapter_source_extractions": {"maximum": 0, "minimum": 0},
"index_builds": {"maximum": 0, "minimum": 0},
"index_checks": {"maximum": 0, "minimum": 0},
"index_synchronizations": {"maximum": 0, "minimum": 0},
"project_loads": {"maximum": 0, "minimum": 0},
"render_output_bytes_built": {"maximum": 0, "minimum": 0},
"render_output_bytes_hashed": {"maximum": 0, "minimum": 0},
"render_prepare_calls": {"maximum": 0, "minimum": 0},
"source_bytes_parsed": {"maximum": 0, "minimum": 0},
"source_files_parsed": {"maximum": 0, "minimum": 0},
"source_generation_checks": {"maximum": 2, "minimum": 2},
"viewer_manager_requests": {"maximum": 0, "minimum": 0}
},
"elapsed_ms": 419.04,
"item_count": 1000,
"maximum_cursor_bytes": 448,
"maximum_page_bytes": 66516,
"ordered_item_hash": "1ac48cc72532809ef5d3e949756e536eec819f348eaf06338c9b39b14e63b2c7",
"page_count": 10,
"receipt_hash": "6913c962972d8255f56966e5cfab5ac8293e41bdfb5f39ef91a4f33a3b092f88",
"status": "ok"
},
"samples": 10,
"validated_invocations": 11
},
"generation_diff_diagnostic_page": {
"counter_ranges": {
"adapter_projection_loads": {"maximum": 0, "minimum": 0},
"adapter_source_extractions": {"maximum": 0, "minimum": 0},
"index_builds": {"maximum": 0, "minimum": 0},
"index_checks": {"maximum": 0, "minimum": 0},
"index_synchronizations": {"maximum": 0, "minimum": 0},
"project_loads": {"maximum": 0, "minimum": 0},
"render_output_bytes_built": {"maximum": 0, "minimum": 0},
"render_output_bytes_hashed": {"maximum": 0, "minimum": 0},
"render_prepare_calls": {"maximum": 0, "minimum": 0},
"source_bytes_parsed": {"maximum": 0, "minimum": 0},
"source_files_parsed": {"maximum": 0, "minimum": 0},
"source_generation_checks": {"maximum": 2, "minimum": 2},
"viewer_manager_requests": {"maximum": 0, "minimum": 0}
},
"max_ms": 41.65,
"maximum_response_bytes": 66516,
"median_ms": 40.961,
"min_ms": 40.239,
"p95_limit_ms": 100,
"p95_ms": 41.65,
"response_bytes": 66516,
"response_limit_bytes": 200000,
"samples": 10,
"validated_invocations": 11
},
"generation_diff_maximum_page": {
"diagnostics_dropped_for_budget": true,
"max_ms": 59.184,
"maximum_response_bytes": 199566,
"median_ms": 55.565,
"min_ms": 54.68,
"p95_limit_ms": 100,
"p95_ms": 59.184,
"response_bytes": 199566,
"response_limit_bytes": 200000,
"samples": 10,
"validated_invocations": 11
},
"task_context_complete": {
"max_ms": 721.847,
"maximum_response_bytes": 1325,
"median_ms": 703.561,
"min_ms": 688.975,
"p95_limit_ms": 2500,
"p95_ms": 721.847,
"response_bytes": 1324,
"response_limit_bytes": 32768,
"result_summary": {
"aggregate_page_bytes": 348845,
"capsule_hash": "20663ed685a255f7cb8a0e8d78262bf7bf26863d728f0159e0b29000f6a52b0a",
"collection_hash": "9dbc46eb5b1ac8c8340bb149205d3599fffaf67e3b363de2d035475da274857c",
"collection_hash_reconstructed": true,
"counter_ranges": {
"adapter_projection_loads": {"maximum": 0, "minimum": 0},
"adapter_source_extractions": {"maximum": 0, "minimum": 0},
"index_builds": {"maximum": 0, "minimum": 0},
"index_checks": {"maximum": 1, "minimum": 1},
"index_synchronizations": {"maximum": 0, "minimum": 0},
"project_loads": {"maximum": 0, "minimum": 0},
"render_output_bytes_built": {"maximum": 0, "minimum": 0},
"render_output_bytes_hashed": {"maximum": 0, "minimum": 0},
"render_prepare_calls": {"maximum": 0, "minimum": 0},
"source_bytes_parsed": {"maximum": 0, "minimum": 0},
"source_files_parsed": {"maximum": 0, "minimum": 0},
"source_generation_checks": {"maximum": 2, "minimum": 2},
"viewer_manager_requests": {"maximum": 0, "minimum": 0}
},
"elapsed_ms": 719.55,
"evidence_count": 108,
"item_count": 1000,
"maximum_cursor_bytes": 1066,
"maximum_page_bytes": 151172,
"omission_count": 892,
"ordered_candidate_hash": "bdeb3a8f4018000f72a5ff1891aa800b6edf98070b9814f443c6bac4e52c38f3",
"ordered_evidence_hash": "ec07bac7f528f5a3afbc083ea5ad60541335c8789d42980fe7bae0476e0a5331",
"page_count": 11,
"plan_hash": "84dbe60267e5f8359adcf30d4d395f1ac5d5bea957beaa3888d2938120860a9c",
"status": "ok"
},
"samples": 10,
"validated_invocations": 11
},
"task_context_diagnostic_page": {
"counter_ranges": {
"adapter_projection_loads": {"maximum": 0, "minimum": 0},
"adapter_source_extractions": {"maximum": 0, "minimum": 0},
"index_builds": {"maximum": 0, "minimum": 0},
"index_checks": {"maximum": 1, "minimum": 1},
"index_synchronizations": {"maximum": 0, "minimum": 0},
"project_loads": {"maximum": 0, "minimum": 0},
"render_output_bytes_built": {"maximum": 0, "minimum": 0},
"render_output_bytes_hashed": {"maximum": 0, "minimum": 0},
"render_prepare_calls": {"maximum": 0, "minimum": 0},
"source_bytes_parsed": {"maximum": 0, "minimum": 0},
"source_files_parsed": {"maximum": 0, "minimum": 0},
"source_generation_checks": {"maximum": 2, "minimum": 2},
"viewer_manager_requests": {"maximum": 0, "minimum": 0}
},
"max_ms": 93.115,
"maximum_response_bytes": 7190,
"median_ms": 73.326,
"min_ms": 72.264,
"p95_limit_ms": 500,
"p95_ms": 93.115,
"response_bytes": 7190,
"response_limit_bytes": 200000,
"samples": 10,
"validated_invocations": 11
},
"task_context_maximum_page": {
"counter_ranges": {
"adapter_projection_loads": {"maximum": 0, "minimum": 0},
"adapter_source_extractions": {"maximum": 0, "minimum": 0},
"index_builds": {"maximum": 0, "minimum": 0},
"index_checks": {"maximum": 1, "minimum": 1},
"index_synchronizations": {"maximum": 0, "minimum": 0},
"project_loads": {"maximum": 0, "minimum": 0},
"render_output_bytes_built": {"maximum": 0, "minimum": 0},
"render_output_bytes_hashed": {"maximum": 0, "minimum": 0},
"render_prepare_calls": {"maximum": 0, "minimum": 0},
"source_bytes_parsed": {"maximum": 0, "minimum": 0},
"source_files_parsed": {"maximum": 0, "minimum": 0},
"source_generation_checks": {"maximum": 2, "minimum": 2},
"viewer_manager_requests": {"maximum": 0, "minimum": 0}
},
"diagnostics_dropped_for_budget": false,
"max_ms": 87.372,
"maximum_response_bytes": 7192,
"median_ms": 84.933,
"min_ms": 83.652,
"p95_limit_ms": 500,
"p95_ms": 87.372,
"response_bytes": 7192,
"response_limit_bytes": 200000,
"samples": 10,
"validated_invocations": 11
}
},
"process_peak_rss_kib": 86072,
"schema_version": 1,
"source": {
"dirty": false,
"revision": "fb0df5e4a1c591c2a84788fd4814d98550f11863"
}
}

View file

@ -60,6 +60,17 @@ MCP results retain:
The result schema describes the common envelope. Operation-specific fields are additive and remain The result schema describes the common envelope. Operation-specific fields are additive and remain
bounded by the configured tool-output limit. bounded by the configured tool-output limit.
The following Milestone 2 CLI additions do not change existing command signatures:
- `docforge configure codex|claude|openclaw --project ROOT`
- `docforge doctor --client codex|claude|openclaw`
Configuration output is a new version-1 machine-local contract. It preserves the `docforge`
package and executable names and emits the existing `docforge.mcp_server` module entrypoint.
Existing hand-written client configurations remain valid and are never rewritten automatically.
Doctor is inspection-only and does not become a hidden bootstrap, synchronization, or migration
path.
## Versioned data contracts ## Versioned data contracts
Milestone 0 preserves: Milestone 0 preserves:

View file

@ -22,6 +22,8 @@ commit when Git is available; it cannot change repository state.
- Task context capsule: `schemas/context-capsule.schema.json`, version 1. - Task context capsule: `schemas/context-capsule.schema.json`, version 1.
- Latest generation diff: `schemas/generation-diff.schema.json`, version 1. - Latest generation diff: `schemas/generation-diff.schema.json`, version 1.
- Latest generation-diff page: `schemas/generation-diff-page.schema.json`, version 1. - Latest generation-diff page: `schemas/generation-diff-page.schema.json`, version 1.
- Generated client configuration: `schemas/client-configuration.schema.json`, version 1.
- Client doctor result: `schemas/doctor-result.schema.json`, version 1.
- Index schema: version 3, disposable and reproducible. - Index schema: version 3, disposable and reproducible.
- Index attestation: schema version 1, disposable and reproducible. - Index attestation: schema version 1, disposable and reproducible.
- Core, CLI, and MCP server: version 1.3.0.dev0. - Core, CLI, and MCP server: version 1.3.0.dev0.
@ -75,6 +77,25 @@ not history and contains no Logic details or source text. Public pages carry one
`receipt_header`; its `stored_receipt_hash` identifies the complete persisted receipt rather than `receipt_header`; its `stored_receipt_hash` identifies the complete persisted receipt rather than
the header alone. One top-level pagination object carries the only continuation cursor. the header alone. One top-level pagination object carries the only continuation cursor.
## Machine-local client integration
Generated Codex, Claude, and OpenClaw fragments are machine-local projections. They are not
canonical project content. Version 1 binds the selected project, exact isolated Python
interpreter, canonical argument layout, effective policy, no-AST projection, render policy,
timeouts, artifact bytes, and configuration hash.
Preview is side-effect free. Explicit publication creates only one new private standalone
fragment in an existing real directory. It never merges or replaces different content. Descriptor,
parent, target, content, ownership, permission, and link identities are checked before and after
the directory durability boundary. A failure rolls back when that can be proven and otherwise
returns bounded unconfirmed publication evidence.
Doctor is a bounded read-only inspector with one fixed check inventory. It uses stable no-follow
descriptor and configuration reads plus stat-only derived-index evidence. It never loads a
complete projection, opens SQLite, starts MCP, executes the configured command, synchronizes,
builds, renders, starts a viewer, or writes configuration. Unprovable client behavior is a warning,
not an invented success.
## Isolated proposal model ## Isolated proposal model
Create, update, move, and delete are ordered node operations inside an isolated changeset. Every Create, update, move, and delete are ordered node operations inside an isolated changeset. Every

View file

@ -0,0 +1,85 @@
# Milestone 2 baseline
## Scope and method
This baseline records the agent-retrieval and client-integration behavior added in Milestone 2.
It was captured on 2026-07-29 from clean candidate commit
`fb0df5e4a1c591c2a84788fd4814d98550f11863`.
The maintained command was:
```bash
.venv/bin/python tools/milestone2_benchmark.py \
--nodes 1000 \
--samples 10 \
--output /tmp/docforge-milestone2-final.json
```
The fixture contains 1,000 Markdown nodes and 999 edges in a direct fan-in around one focus node.
The configured MCP response limit is 200,000 characters. Durations use
`time.perf_counter_ns()` and nearest-rank p95. Peak memory uses an isolated child process and
`RUSAGE_SELF`. Every warmup and measured invocation is validated.
Environment:
- Linux 7.1.3-200.nobara.fc44.x86_64.
- CPython 3.14.6.
- x86_64.
- Ten warm samples after one warmup.
- Isolated memory ceiling: 262,144 KiB.
The complete machine-readable result is
[`benchmarks/milestone2-2026-07-29.json`](../benchmarks/milestone2-2026-07-29.json).
## Results
| Operation | Median | p95 | Limit | Maximum response |
|---|---:|---:|---:|---:|
| Read bootstrap | 9.406 ms | 9.884 ms | 100 ms | 7,495 B |
| No-AST bootstrap | 9.153 ms | 9.379 ms | 100 ms | 8,796 B |
| Task diagnostic page | 73.326 ms | 93.115 ms | 500 ms | 7,190 B |
| Task complete traversal | 703.561 ms | 721.847 ms | 2,500 ms | 151,172 B/page |
| Generation diagnostic page | 40.961 ms | 41.650 ms | 100 ms | 66,516 B |
| Generation maximum page | 55.565 ms | 59.184 ms | 100 ms | 199,566 B |
| Generation complete traversal | 418.607 ms | 425.315 ms | 500 ms | 66,516 B/page |
| Codex configuration preview | 314.365 ms | 364.383 ms | 500 ms | 2,627 B |
| Claude configuration preview | 314.326 ms | 364.365 ms | 500 ms | 2,748 B |
| OpenClaw configuration preview | 314.401 ms | 364.532 ms | 500 ms | 2,869 B |
| Codex doctor | 0.421 ms | 0.556 ms | 100 ms | 3,595 B |
| Claude doctor | 0.364 ms | 0.446 ms | 100 ms | 3,669 B |
| OpenClaw doctor | 0.384 ms | 0.484 ms | 100 ms | 3,602 B |
Isolated peak RSS was 86,448 KiB.
Task traversal returned 108 evidence records and 892 explicit omissions across 11 pages. One
individually oversized focus record became a response-limit surrogate bound to the original record
hash. The remaining omissions were token-budget evidence. The benchmark verified every unique
subject, reconstructed the original collection hash, and matched the exact 1,000-node fixture.
Generation traversal returned all 1,000 changed-node details across 10 pages. It reconstructed the
stored retained-collection hash. The maximum generation page approached the response limit and
proved that optional diagnostics were dropped before the primary result.
## Structured-work gates
Configuration preview and doctor performed zero project loads, source parses, adapter projection
loads, adapter extraction, index checks, synchronization, index builds, render preparation,
rendered-byte construction or hashing, and viewer-manager requests.
Task-context pages performed exactly one index check and two cheap source-generation checks. They
performed none of the hidden work above. Generation-diff pages performed exactly two cheap
source-generation checks and no index check or hidden work.
## Measured limits and future notes
- Continuation is stateless and regenerates the task capsule for each page. The complete
11-page traversal remains within its gate, but later work can avoid repeated planning without
weakening generation binding.
- Configuration preview deliberately spends about 314 ms proving that the exact isolated
interpreter can import the MCP module. Discovery-only checks were rejected as unsafe.
- Claude configuration syntax is supported, but its timeout representation remains unverified.
Doctor therefore reports degraded rather than healthy.
- Doctor is a configuration inspector, not an MCP connection or SQLite integrity test.
- Legacy adapters without cheap source-generation identity report unknown for generation-diff
freshness.
- The results do not justify a storage rewrite. SQLite remains fast after one generation is pinned.

View file

@ -0,0 +1,68 @@
# Milestone 2 closeout
## Outcome
Milestone 2 is complete. One project-bound server can expose an explicit effective policy and
return compact, task-shaped, explainable context. Users can generate deterministic client
fragments and inspect their bindings without hidden runtime work.
Implemented contracts:
- Version-1 effective policy and capability-aware bootstrap.
- Version-1 retrieval plans and context capsules.
- Bounded task-context pagination with evidence gaps and explicit omissions.
- One disposable latest-generation transition receipt and paged read surface.
- Deterministic Codex, Claude, and OpenClaw standalone configuration fragments.
- Fixed-inventory read-only doctor results.
- Dedicated configuration and doctor JSON schemas.
- Repository-native Milestone 2 contract, smoke, scale, response-size, counter, and memory gates.
## Candidate evidence
The frozen implementation candidate is
`fb0df5e4a1c591c2a84788fd4814d98550f11863`.
The complete repository gate passed:
- Ruff formatting and lint.
- HTML, rendered-manual HTML, CSS, and JavaScript checks.
- Pyright with zero diagnostics.
- Warning-strict compilation and tests.
- 205 tests and 120 subtests.
- Lock and npm dependency-tree checks.
- Wheel and source-distribution builds.
- Milestone 0, 1, and 2 smoke benchmarks.
Three independent read-only adversarial audits covered client publication and policy binding,
doctor race and malformed-input behavior, and benchmark/contract evidence. Reproduced descriptor,
parent, target, filesystem, policy, secret-redaction, ambiguity, parser, response-size, and hidden
work defects were fixed and regression-tested before the candidate was frozen.
The clean ten-sample 1,000-node benchmark passed every threshold. Exact measurements and counter
ranges are recorded in
[`MILESTONE_2_BASELINE.md`](MILESTONE_2_BASELINE.md) and
[`benchmarks/milestone2-2026-07-29.json`](../benchmarks/milestone2-2026-07-29.json).
## Preserved boundaries
- The `docforge` package, imports, CLI executable, MCP executable, and existing tool names remain.
- Legacy one-method `load_projection()` adapters remain supported.
- The no-AST shorthand and legacy adapter-policy payload remain compatible.
- Project descriptor schema version 1 remains unchanged.
- No storage replacement was introduced.
- No legacy DocForge MCP or DocForge2 self-hosting was used.
- WorldForge and ScrapeStation were not touched.
- No production MCP integration was repointed.
- The legacy Forgejo repository and `legacy` remote were not changed.
- No tag, release, release announcement, or visibility change was created.
## Known follow-up work
The next active milestone may improve projection independence. It must not silently absorb these
separate future ideas:
- Avoid recomputing a complete task capsule for every continuation page.
- Add authenticated cursors only if a stronger threat model requires them.
- Verify Claude's native timeout representation.
- Add versioned adapter-owned launcher metadata before generating custom-adapter configurations.
- Keep doctor read-only; a live connection test must be an explicit separate operation.

View file

@ -508,6 +508,67 @@ visualization-status
visualization-stop visualization-stop
``` ```
### Client configuration and doctor
Preview one deterministic standalone client fragment:
```bash
docforge configure codex --project /absolute/path/MyProject
docforge configure claude --project /absolute/path/MyProject
docforge configure openclaw --project /absolute/path/MyProject
```
Preview is the default. Add `--output /absolute/path/fragment` to create a new private fragment in
an existing real directory. Publication is create-only. DocForge accepts an identical existing
private single-link file as unchanged, but it never merges, replaces, broadens permissions, or
follows a symlink. Descriptor, parent, and target identities are revalidated across the
publication commit.
The generated command uses the exact current Python interpreter with isolated module startup.
Generation first proves that this interpreter can import `docforge.mcp_server`. The result binds
the project root, effective policy, arguments, artifact bytes, and all hashes. It copies no ambient
environment values.
Select authority explicitly:
```bash
docforge configure codex \
--project /absolute/path/MyProject \
--capability-mode proposal \
--proposal-writer project-editor
docforge configure codex \
--project /absolute/path/MyProject \
--capability-mode application \
--proposal-writer project-editor \
--canonical-applier project-editor
```
Read mode is the default. Proposal and application modes fail closed unless the descriptor
declares the named writer, and application requires the same writer/applier identity. Add
`--no-ast` to preserve the no-AST binding. Generic CLI generation refuses project-owned adapters
because it cannot safely reconstruct their composition.
Inspect one configured client binding:
```bash
docforge doctor --client codex --project /absolute/path/MyProject
docforge doctor --client codex \
--project /absolute/path/MyProject \
--config /absolute/path/config.toml \
--server-name my-project-docforge
```
Doctor returns `healthy`, `degraded`, or `unhealthy` with exit codes 0, 1, or 2. Its fixed
version-1 inventory checks project and descriptor binding, the client driver and entry, executable
and arguments, project root, effective policy, no-AST state, timeouts, environment-key names,
tool-filter representation, and stat-only index presence.
Doctor is intentionally not a connection test. It never loads canonical sources, opens SQLite,
starts MCP, executes the configured command, synchronizes, builds, renders, starts a viewer, or
writes configuration. Claude timeout representation and client filtering that cannot be proved
locally remain explicit warnings.
## MCP usage ## MCP usage
Run one MCP server per project with absolute paths: Run one MCP server per project with absolute paths:

View file

@ -0,0 +1,894 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://docforge.local/schema/client-configuration-v1.json",
"title": "DocForge deterministic client configuration plan",
"$defs": {
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"adapter_policy": {
"oneOf": [
{
"type": "object",
"required": [
"mode",
"ast_analysis",
"logic_projection",
"incremental_extraction",
"adapter_rewrite"
],
"properties": {
"mode": { "const": "standard" },
"ast_analysis": { "const": "allowed" },
"logic_projection": { "const": "allowed" },
"incremental_extraction": { "const": "allowed" },
"adapter_rewrite": { "const": "not_requested" }
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"mode",
"ast_analysis",
"logic_projection",
"incremental_extraction",
"adapter_rewrite",
"blocked_tools",
"instruction"
],
"properties": {
"mode": { "const": "preserve-no-ast" },
"ast_analysis": { "const": "forbidden" },
"logic_projection": { "const": "forbidden" },
"incremental_extraction": { "const": "allowed" },
"adapter_rewrite": { "const": "forbidden" },
"blocked_tools": {
"const": ["docforge_get_logic"]
},
"instruction": {
"const": "Preserve the existing adapter extraction strategy. Do not add Python AST, Tree-sitter, compiler-AST, or function-Logic extraction. Non-AST incremental fingerprinting and caching remain allowed."
}
},
"additionalProperties": false
}
]
},
"effective_policy": {
"type": "object",
"required": [
"schema_version",
"capability_mode",
"capability_source",
"adapter_evolution",
"ast_analysis",
"logic_indexing",
"synchronization",
"integrity",
"manual_render",
"graph_render",
"live_viewer",
"profiling",
"blocked_tools",
"prohibitions",
"precedence"
],
"properties": {
"schema_version": { "const": 1 },
"capability_mode": {
"enum": ["read", "proposal", "application"]
},
"capability_source": { "const": "explicit" },
"adapter_evolution": { "enum": ["allowed", "preserve"] },
"ast_analysis": { "enum": ["allowed", "forbidden"] },
"logic_indexing": { "enum": ["full", "off"] },
"synchronization": { "const": "automatic" },
"integrity": { "const": "validated" },
"manual_render": { "enum": ["auto", "explicit", "disabled"] },
"graph_render": { "const": "disabled" },
"live_viewer": { "const": "on-demand" },
"profiling": { "const": "disabled" },
"blocked_tools": {
"type": "array",
"maxItems": 1,
"items": { "const": "docforge_get_logic" },
"uniqueItems": true
},
"prohibitions": {
"type": "array",
"minItems": 7,
"maxItems": 11,
"items": {
"enum": [
"arbitrary_file_access",
"arbitrary_renderer_execution",
"shell_execution",
"git_mutation",
"deployment",
"publication",
"project_switching",
"adapter_ast_upgrade",
"tree_sitter_upgrade",
"compiler_ast_upgrade",
"function_logic_extraction"
]
},
"uniqueItems": true
},
"precedence": {
"const": [
"core_safety",
"explicit_binding",
"no_ast_shorthand",
"resource_availability"
]
}
},
"additionalProperties": false
},
"diagnostics": {
"type": "object",
"required": [
"schema_version",
"operation",
"outcome",
"elapsed_ns",
"stages",
"counters"
],
"properties": {
"schema_version": { "const": 1 },
"operation": { "const": "cli.configure" },
"outcome": { "const": "ok" },
"elapsed_ns": { "type": "integer", "minimum": 0 },
"stages": {
"type": "object",
"maxProperties": 14,
"propertyNames": {
"enum": [
"source.generation",
"source.parse",
"adapter.projection",
"adapter.extract",
"index.check",
"index.synchronize",
"index.build",
"index.read",
"render.status",
"render.prepare",
"render.output_hash",
"visualization.status",
"viewer.manager",
"mcp.runtime_validation"
]
},
"additionalProperties": {
"type": "object",
"required": ["calls", "elapsed_ns"],
"properties": {
"calls": { "type": "integer", "minimum": 1 },
"elapsed_ns": { "type": "integer", "minimum": 0 }
},
"additionalProperties": false
}
},
"counters": {
"type": "object",
"required": [
"project_loads",
"source_files_parsed",
"source_bytes_parsed",
"adapter_projection_loads",
"adapter_source_extractions",
"source_generation_checks",
"index_checks",
"index_synchronizations",
"index_builds",
"render_prepare_calls",
"render_output_bytes_built",
"render_output_bytes_hashed",
"viewer_manager_requests"
],
"additionalProperties": {
"type": "integer",
"minimum": 0
},
"maxProperties": 13
}
},
"additionalProperties": false
}
},
"type": "object",
"required": [
"status",
"schema_version",
"operation",
"action",
"client",
"server_name",
"project",
"binding",
"effective_policy",
"artifact",
"configuration_hash",
"warnings"
],
"properties": {
"status": { "const": "ok" },
"schema_version": { "const": 1 },
"operation": { "const": "client.configure" },
"action": { "enum": ["preview", "write"] },
"client": { "enum": ["codex", "claude", "openclaw"] },
"server_name": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9_-]{0,63}$"
},
"project": {
"type": "object",
"required": [
"project_id",
"project_root",
"project_root_fingerprint",
"adapter"
],
"properties": {
"project_id": { "type": "string", "minLength": 1 },
"project_root": { "type": "string", "minLength": 1 },
"project_root_fingerprint": {
"type": "string",
"pattern": "^[0-9a-f]{16}$"
},
"adapter": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
},
"binding": {
"type": "object",
"required": [
"transport",
"capability_mode",
"adapter_policy",
"render_policy",
"command",
"args",
"environment",
"timeouts"
],
"properties": {
"transport": { "const": "stdio" },
"capability_mode": {
"enum": ["read", "proposal", "application"]
},
"adapter_policy": { "$ref": "#/$defs/adapter_policy" },
"render_policy": {
"type": "object",
"required": ["manual", "graph", "live_viewer"],
"properties": {
"manual": { "enum": ["auto", "explicit", "disabled"] },
"graph": { "const": "disabled" },
"live_viewer": { "const": "on-demand" }
},
"additionalProperties": false
},
"command": { "type": "string", "minLength": 1 },
"args": {
"type": "array",
"minItems": 7,
"maxItems": 64,
"prefixItems": [
{ "const": "-I" },
{ "const": "-m" },
{ "const": "docforge.mcp_server" },
{ "const": "--project-root" },
{ "type": "string", "minLength": 1, "maxLength": 4096 },
{ "const": "--capability-mode" },
{ "enum": ["read", "proposal", "application"] }
],
"items": {
"type": "string",
"minLength": 1,
"maxLength": 4096
}
},
"environment": {
"type": "object",
"maxProperties": 0
},
"timeouts": {
"type": "object",
"required": ["startup_seconds", "tool_seconds"],
"properties": {
"startup_seconds": {
"type": "integer",
"minimum": 1,
"maximum": 3600
},
"tool_seconds": {
"type": "integer",
"minimum": 1,
"maximum": 86400
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"effective_policy": { "$ref": "#/$defs/effective_policy" },
"artifact": {
"type": "object",
"required": [
"format",
"content",
"content_sha256",
"output_path",
"write_state",
"durability"
],
"properties": {
"format": {
"enum": [
"codex-toml-fragment-v1",
"claude-json-fragment-v1",
"openclaw-json-fragment-v1"
]
},
"content": {
"type": "string",
"minLength": 1,
"maxLength": 65536
},
"content_sha256": { "$ref": "#/$defs/sha256" },
"output_path": {
"type": ["string", "null"],
"minLength": 1
},
"write_state": {
"enum": ["not_requested", "created", "unchanged"]
},
"durability": {
"enum": ["not_applicable", "confirmed", "unconfirmed"]
}
},
"additionalProperties": false
},
"configuration_hash": { "$ref": "#/$defs/sha256" },
"warnings": {
"type": "array",
"maxItems": 8,
"items": {
"type": "object",
"required": ["code"],
"properties": {
"code": {
"enum": [
"timeout_format_unverified",
"publication_durability_unconfirmed",
"publication_location_unconfirmed",
"publication_binding_unconfirmed"
]
}
},
"additionalProperties": false
}
},
"diagnostics": { "$ref": "#/$defs/diagnostics" }
},
"allOf": [
{
"if": {
"properties": {
"warnings": {
"contains": {
"properties": {
"code": { "const": "publication_binding_unconfirmed" }
},
"required": ["code"]
}
}
},
"required": ["warnings"]
},
"then": {
"properties": {
"artifact": {
"properties": {
"output_path": { "type": "null" },
"write_state": { "const": "created" },
"durability": { "const": "unconfirmed" }
}
}
}
}
},
{
"if": {
"properties": { "client": { "const": "codex" } },
"required": ["client"]
},
"then": {
"properties": {
"artifact": {
"properties": {
"format": { "const": "codex-toml-fragment-v1" }
}
},
"warnings": {
"not": {
"contains": {
"properties": {
"code": { "const": "timeout_format_unverified" }
},
"required": ["code"]
}
}
}
}
}
},
{
"if": {
"properties": { "client": { "const": "openclaw" } },
"required": ["client"]
},
"then": {
"properties": {
"artifact": {
"properties": {
"format": { "const": "openclaw-json-fragment-v1" }
}
},
"warnings": {
"not": {
"contains": {
"properties": {
"code": { "const": "timeout_format_unverified" }
},
"required": ["code"]
}
}
}
}
}
},
{
"if": {
"properties": { "client": { "const": "claude" } },
"required": ["client"]
},
"then": {
"properties": {
"artifact": {
"properties": {
"format": { "const": "claude-json-fragment-v1" }
}
},
"warnings": {
"contains": {
"properties": {
"code": { "const": "timeout_format_unverified" }
},
"required": ["code"]
}
}
}
}
},
{
"if": {
"properties": { "action": { "const": "preview" } },
"required": ["action"]
},
"then": {
"properties": {
"artifact": {
"properties": {
"output_path": { "type": "null" },
"write_state": { "const": "not_requested" },
"durability": { "const": "not_applicable" }
}
}
}
},
"else": {
"properties": {
"artifact": {
"properties": {
"output_path": {
"type": ["string", "null"],
"minLength": 1
},
"write_state": { "enum": ["created", "unchanged"] }
},
"allOf": [
{
"if": {
"properties": { "write_state": { "const": "created" } },
"required": ["write_state"]
},
"then": {
"properties": {
"durability": { "enum": ["confirmed", "unconfirmed"] }
}
},
"else": {
"properties": {
"durability": { "const": "not_applicable" }
}
}
}
]
}
}
}
},
{
"if": {
"properties": {
"binding": {
"properties": { "capability_mode": { "const": "read" } },
"required": ["capability_mode"]
}
},
"required": ["binding"]
},
"then": {
"properties": {
"binding": {
"properties": {
"args": {
"prefixItems": [{}, {}, {}, {}, {}, {}, { "const": "read" }]
}
}
},
"effective_policy": {
"properties": { "capability_mode": { "const": "read" } }
}
}
}
},
{
"if": {
"properties": {
"binding": {
"properties": { "capability_mode": { "const": "proposal" } },
"required": ["capability_mode"]
}
},
"required": ["binding"]
},
"then": {
"properties": {
"binding": {
"properties": {
"args": {
"prefixItems": [{}, {}, {}, {}, {}, {}, { "const": "proposal" }]
}
}
},
"effective_policy": {
"properties": { "capability_mode": { "const": "proposal" } }
}
}
}
},
{
"if": {
"properties": {
"binding": {
"properties": { "capability_mode": { "const": "application" } },
"required": ["capability_mode"]
}
},
"required": ["binding"]
},
"then": {
"properties": {
"binding": {
"properties": {
"args": {
"prefixItems": [{}, {}, {}, {}, {}, {}, { "const": "application" }]
}
}
},
"effective_policy": {
"properties": { "capability_mode": { "const": "application" } }
}
}
}
},
{
"if": {
"properties": {
"binding": {
"properties": {
"adapter_policy": {
"properties": { "mode": { "const": "preserve-no-ast" } },
"required": ["mode"]
}
},
"required": ["adapter_policy"]
}
},
"required": ["binding"]
},
"then": {
"properties": {
"binding": {
"properties": {
"args": {
"contains": { "const": "--no-ast" },
"minContains": 1,
"maxContains": 1
}
}
},
"effective_policy": {
"properties": {
"adapter_evolution": { "const": "preserve" },
"ast_analysis": { "const": "forbidden" },
"logic_indexing": { "const": "off" },
"blocked_tools": { "const": ["docforge_get_logic"] },
"prohibitions": {
"const": [
"arbitrary_file_access",
"arbitrary_renderer_execution",
"shell_execution",
"git_mutation",
"deployment",
"publication",
"project_switching",
"adapter_ast_upgrade",
"tree_sitter_upgrade",
"compiler_ast_upgrade",
"function_logic_extraction"
]
}
}
}
}
},
"else": {
"properties": {
"binding": {
"properties": {
"args": {
"not": {
"contains": { "const": "--no-ast" }
}
}
}
},
"effective_policy": {
"properties": {
"adapter_evolution": { "const": "allowed" },
"ast_analysis": { "const": "allowed" },
"logic_indexing": { "const": "full" },
"blocked_tools": { "const": [] },
"prohibitions": {
"const": [
"arbitrary_file_access",
"arbitrary_renderer_execution",
"shell_execution",
"git_mutation",
"deployment",
"publication",
"project_switching"
]
}
}
}
}
}
},
{
"if": {
"properties": {
"binding": {
"properties": {
"render_policy": {
"properties": { "manual": { "const": "auto" } },
"required": ["manual"]
}
},
"required": ["render_policy"]
}
},
"required": ["binding"]
},
"then": {
"properties": {
"effective_policy": {
"properties": { "manual_render": { "const": "auto" } }
}
}
}
},
{
"if": {
"properties": {
"binding": {
"properties": {
"render_policy": {
"properties": { "manual": { "const": "explicit" } },
"required": ["manual"]
}
},
"required": ["render_policy"]
}
},
"required": ["binding"]
},
"then": {
"properties": {
"effective_policy": {
"properties": { "manual_render": { "const": "explicit" } }
}
}
}
},
{
"if": {
"properties": {
"binding": {
"properties": {
"render_policy": {
"properties": { "manual": { "const": "disabled" } },
"required": ["manual"]
}
},
"required": ["render_policy"]
}
},
"required": ["binding"]
},
"then": {
"properties": {
"effective_policy": {
"properties": { "manual_render": { "const": "disabled" } }
}
}
}
},
{
"if": {
"properties": {
"artifact": {
"properties": {
"durability": { "const": "unconfirmed" }
},
"required": ["durability"]
}
},
"required": ["artifact"]
},
"then": {
"properties": {
"artifact": {
"properties": {
"write_state": { "const": "created" }
}
},
"warnings": {
"anyOf": [
{
"contains": {
"properties": {
"code": { "const": "publication_durability_unconfirmed" }
},
"required": ["code"]
}
},
{
"contains": {
"properties": {
"code": { "const": "publication_location_unconfirmed" }
},
"required": ["code"]
}
},
{
"contains": {
"properties": {
"code": { "const": "publication_binding_unconfirmed" }
},
"required": ["code"]
}
}
]
}
}
}
},
{
"if": {
"properties": {
"warnings": {
"contains": {
"properties": {
"code": { "const": "publication_location_unconfirmed" }
},
"required": ["code"]
}
}
},
"required": ["warnings"]
},
"then": {
"properties": {
"artifact": {
"properties": {
"output_path": { "type": "null" },
"write_state": { "const": "created" },
"durability": { "const": "unconfirmed" }
}
}
}
}
},
{
"if": {
"properties": {
"action": { "const": "write" },
"artifact": {
"properties": { "output_path": { "type": "null" } },
"required": ["output_path"]
}
},
"required": ["action", "artifact"]
},
"then": {
"properties": {
"warnings": {
"anyOf": [
{
"contains": {
"properties": {
"code": { "const": "publication_location_unconfirmed" }
},
"required": ["code"]
}
},
{
"contains": {
"properties": {
"code": { "const": "publication_binding_unconfirmed" }
},
"required": ["code"]
}
}
]
}
}
}
},
{
"if": {
"properties": {
"warnings": {
"contains": {
"properties": {
"code": { "const": "publication_durability_unconfirmed" }
},
"required": ["code"]
}
}
},
"required": ["warnings"]
},
"then": {
"properties": {
"artifact": {
"properties": {
"write_state": { "const": "created" },
"durability": { "const": "unconfirmed" }
}
}
}
}
}
],
"additionalProperties": false
}

View file

@ -0,0 +1,466 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://docforge.local/schema/doctor-result-v1.json",
"title": "DocForge bounded read-only integration doctor result",
"$defs": {
"diagnostics": {
"type": "object",
"required": [
"schema_version",
"operation",
"outcome",
"elapsed_ns",
"stages",
"counters"
],
"properties": {
"schema_version": { "const": 1 },
"operation": { "const": "cli.doctor" },
"outcome": { "const": "ok" },
"elapsed_ns": { "type": "integer", "minimum": 0 },
"stages": {
"type": "object",
"maxProperties": 14,
"propertyNames": {
"enum": [
"source.generation",
"source.parse",
"adapter.projection",
"adapter.extract",
"index.check",
"index.synchronize",
"index.build",
"index.read",
"render.status",
"render.prepare",
"render.output_hash",
"visualization.status",
"viewer.manager",
"mcp.runtime_validation"
]
},
"additionalProperties": {
"type": "object",
"required": ["calls", "elapsed_ns"],
"properties": {
"calls": { "type": "integer", "minimum": 1 },
"elapsed_ns": { "type": "integer", "minimum": 0 }
},
"additionalProperties": false
}
},
"counters": {
"type": "object",
"required": [
"project_loads",
"source_files_parsed",
"source_bytes_parsed",
"adapter_projection_loads",
"adapter_source_extractions",
"source_generation_checks",
"index_checks",
"index_synchronizations",
"index_builds",
"render_prepare_calls",
"render_output_bytes_built",
"render_output_bytes_hashed",
"viewer_manager_requests"
],
"additionalProperties": {
"type": "integer",
"minimum": 0
},
"maxProperties": 13
}
},
"additionalProperties": false
}
},
"type": "object",
"required": [
"status",
"schema_version",
"doctor_state",
"client",
"project",
"config",
"summary",
"guarantees",
"checks"
],
"properties": {
"status": { "const": "ok" },
"schema_version": { "const": 1 },
"doctor_state": { "enum": ["healthy", "degraded", "unhealthy"] },
"client": { "enum": ["codex", "claude", "openclaw"] },
"project": {
"type": "object",
"required": [
"project_id",
"project_root",
"project_root_fingerprint",
"adapter"
],
"properties": {
"project_id": { "type": "string", "minLength": 1, "maxLength": 128 },
"project_root": { "type": "string", "minLength": 1, "maxLength": 4096 },
"project_root_fingerprint": {
"type": "string",
"pattern": "^[0-9a-f]{16}$"
},
"adapter": { "type": "string", "minLength": 1, "maxLength": 256 }
},
"additionalProperties": false
},
"config": {
"type": "object",
"required": ["path", "server_name"],
"properties": {
"path": { "type": "string", "minLength": 1, "maxLength": 4096 },
"server_name": {
"type": ["string", "null"],
"maxLength": 256
}
},
"additionalProperties": false
},
"summary": {
"type": "object",
"required": ["passed", "warning", "failed", "skipped"],
"properties": {
"passed": { "type": "integer", "minimum": 0, "maximum": 14 },
"warning": { "type": "integer", "minimum": 0, "maximum": 14 },
"failed": { "type": "integer", "minimum": 0, "maximum": 14 },
"skipped": { "type": "integer", "minimum": 0, "maximum": 14 }
},
"additionalProperties": false
},
"guarantees": {
"type": "object",
"required": [
"read_only",
"project_loads",
"adapter_projection_loads",
"adapter_source_extractions",
"sqlite_opens",
"index_checks",
"index_synchronizations",
"index_builds",
"renders",
"viewer_operations",
"client_config_writes",
"configured_command_executions"
],
"properties": {
"read_only": { "const": true },
"project_loads": { "const": 0 },
"adapter_projection_loads": { "const": 0 },
"adapter_source_extractions": { "const": 0 },
"sqlite_opens": { "const": 0 },
"index_checks": { "const": 0 },
"index_synchronizations": { "const": 0 },
"index_builds": { "const": 0 },
"renders": { "const": 0 },
"viewer_operations": { "const": 0 },
"client_config_writes": { "const": 0 },
"configured_command_executions": { "const": 0 }
},
"additionalProperties": false
},
"checks": {
"type": "array",
"minItems": 14,
"maxItems": 14,
"items": {
"type": "object",
"required": ["check_id", "state", "code", "message", "details"],
"properties": {
"check_id": {
"enum": [
"project.binding",
"project.canonical_validation",
"client.driver",
"client.config",
"client.entry",
"server.executable",
"server.arguments",
"server.project_binding",
"policy.effective",
"policy.no_ast",
"client.timeouts",
"client.environment",
"client.tool_filter",
"derived.index"
]
},
"state": {
"enum": ["passed", "warning", "failed", "skipped"]
},
"code": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"message": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"details": {
"type": "object",
"maxProperties": 16,
"propertyNames": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"additionalProperties": {
"oneOf": [
{ "type": "string", "maxLength": 512 },
{ "type": "integer" },
{ "type": "boolean" },
{ "type": "null" },
{
"type": "array",
"maxItems": 16,
"items": {
"oneOf": [
{ "type": "string", "maxLength": 256 },
{ "type": "integer" },
{ "type": "boolean" },
{ "type": "null" }
]
}
}
]
}
}
},
"additionalProperties": false
}
},
"diagnostics": { "$ref": "#/$defs/diagnostics" }
},
"allOf": [
{
"properties": {
"checks": {
"contains": {
"properties": { "check_id": { "const": "project.binding" } },
"required": ["check_id"]
},
"minContains": 1,
"maxContains": 1
}
}
},
{
"properties": {
"checks": {
"contains": {
"properties": {
"check_id": { "const": "project.canonical_validation" }
},
"required": ["check_id"]
},
"minContains": 1,
"maxContains": 1
}
}
},
{
"properties": {
"checks": {
"contains": {
"properties": { "check_id": { "const": "client.driver" } },
"required": ["check_id"]
},
"minContains": 1,
"maxContains": 1
}
}
},
{
"properties": {
"checks": {
"contains": {
"properties": { "check_id": { "const": "client.config" } },
"required": ["check_id"]
},
"minContains": 1,
"maxContains": 1
}
}
},
{
"properties": {
"checks": {
"contains": {
"properties": { "check_id": { "const": "client.entry" } },
"required": ["check_id"]
},
"minContains": 1,
"maxContains": 1
}
}
},
{
"properties": {
"checks": {
"contains": {
"properties": { "check_id": { "const": "server.executable" } },
"required": ["check_id"]
},
"minContains": 1,
"maxContains": 1
}
}
},
{
"properties": {
"checks": {
"contains": {
"properties": { "check_id": { "const": "server.arguments" } },
"required": ["check_id"]
},
"minContains": 1,
"maxContains": 1
}
}
},
{
"properties": {
"checks": {
"contains": {
"properties": {
"check_id": { "const": "server.project_binding" }
},
"required": ["check_id"]
},
"minContains": 1,
"maxContains": 1
}
}
},
{
"properties": {
"checks": {
"contains": {
"properties": { "check_id": { "const": "policy.effective" } },
"required": ["check_id"]
},
"minContains": 1,
"maxContains": 1
}
}
},
{
"properties": {
"checks": {
"contains": {
"properties": { "check_id": { "const": "policy.no_ast" } },
"required": ["check_id"]
},
"minContains": 1,
"maxContains": 1
}
}
},
{
"properties": {
"checks": {
"contains": {
"properties": { "check_id": { "const": "client.timeouts" } },
"required": ["check_id"]
},
"minContains": 1,
"maxContains": 1
}
}
},
{
"properties": {
"checks": {
"contains": {
"properties": { "check_id": { "const": "client.environment" } },
"required": ["check_id"]
},
"minContains": 1,
"maxContains": 1
}
}
},
{
"properties": {
"checks": {
"contains": {
"properties": { "check_id": { "const": "client.tool_filter" } },
"required": ["check_id"]
},
"minContains": 1,
"maxContains": 1
}
}
},
{
"properties": {
"checks": {
"contains": {
"properties": { "check_id": { "const": "derived.index" } },
"required": ["check_id"]
},
"minContains": 1,
"maxContains": 1
}
}
},
{
"if": {
"properties": { "doctor_state": { "const": "healthy" } },
"required": ["doctor_state"]
},
"then": {
"properties": {
"summary": {
"properties": {
"warning": { "const": 0 },
"failed": { "const": 0 }
}
}
}
}
},
{
"if": {
"properties": { "doctor_state": { "const": "degraded" } },
"required": ["doctor_state"]
},
"then": {
"properties": {
"summary": {
"properties": {
"warning": { "minimum": 1 },
"failed": { "const": 0 }
}
}
}
}
},
{
"if": {
"properties": { "doctor_state": { "const": "unhealthy" } },
"required": ["doctor_state"]
},
"then": {
"properties": {
"summary": {
"properties": {
"failed": { "minimum": 1 }
}
}
}
}
}
],
"additionalProperties": false
}

View file

@ -19,6 +19,7 @@
"enum": [ "enum": [
"test", "test",
"benchmark.m1", "benchmark.m1",
"benchmark.m2",
"mcp.invoke", "mcp.invoke",
"mcp.bootstrap", "mcp.bootstrap",
"mcp.sync", "mcp.sync",
@ -57,6 +58,8 @@
"cli.impact", "cli.impact",
"cli.context", "cli.context",
"cli.generation-diff", "cli.generation-diff",
"cli.configure",
"cli.doctor",
"cli.render", "cli.render",
"cli.render-status", "cli.render-status",
"cli.preview", "cli.preview",

View file

@ -9,7 +9,9 @@ import webbrowser
from pathlib import Path from pathlib import Path
from .application import CanonicalApplicationService, GenericCanonicalApplier from .application import CanonicalApplicationService, GenericCanonicalApplier
from .client_config import CLIENT_NAMES, generate_client_configuration
from .context import compile_context from .context import compile_context
from .doctor import run_doctor
from .errors import DocForgeError from .errors import DocForgeError
from .index import ProjectIndex from .index import ProjectIndex
from .onboarding import assess_project, scaffold_project from .onboarding import assess_project, scaffold_project
@ -21,13 +23,33 @@ from .viewer_manager import ViewerManagerClient
def _parser() -> argparse.ArgumentParser: def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="docforge") parser = argparse.ArgumentParser(prog="docforge")
parser.add_argument("--project-root", type=Path, required=True) parser.add_argument("--project-root", type=Path)
parser.add_argument( parser.add_argument(
"--diagnostics", "--diagnostics",
action="store_true", action="store_true",
help="Attach bounded request-local stage timings and counters", help="Attach bounded request-local stage timings and counters",
) )
commands = parser.add_subparsers(dest="command", required=True) commands = parser.add_subparsers(dest="command", required=True)
configure = commands.add_parser("configure")
configure.add_argument("client", choices=CLIENT_NAMES)
configure.add_argument("--project", type=Path, required=True)
configure.add_argument("--name")
configure.add_argument(
"--capability-mode",
choices=("read", "proposal", "application"),
default="read",
)
configure.add_argument("--proposal-writer")
configure.add_argument("--canonical-applier")
configure.add_argument("--no-ast", action="store_true")
configure.add_argument("--startup-timeout", type=int, default=30)
configure.add_argument("--tool-timeout", type=int, default=300)
configure.add_argument("--output", type=Path)
doctor = commands.add_parser("doctor")
doctor.add_argument("--client", choices=CLIENT_NAMES, required=True)
doctor.add_argument("--project", type=Path)
doctor.add_argument("--config", type=Path)
doctor.add_argument("--server-name")
onboard = commands.add_parser("onboard") onboard = commands.add_parser("onboard")
onboard.add_argument("--language", action="append", default=[]) onboard.add_argument("--language", action="append", default=[])
onboard.add_argument("--scaffold", action="store_true") onboard.add_argument("--scaffold", action="store_true")
@ -92,6 +114,33 @@ def _parser() -> argparse.ArgumentParser:
def _run(arguments: argparse.Namespace) -> dict[str, object]: def _run(arguments: argparse.Namespace) -> dict[str, object]:
if arguments.command == "configure":
project = Project.open(arguments.project)
return generate_client_configuration(
project,
arguments.client,
server_name=arguments.name,
capability_mode=arguments.capability_mode,
proposal_writer=arguments.proposal_writer,
canonical_applier=arguments.canonical_applier,
no_ast=arguments.no_ast,
startup_timeout=arguments.startup_timeout,
tool_timeout=arguments.tool_timeout,
output=arguments.output,
)
if arguments.command == "doctor":
root = arguments.project or arguments.project_root or Path.cwd()
return run_doctor(
Project.open(root),
arguments.client,
config_path=arguments.config,
server_name=arguments.server_name,
)
if arguments.project_root is None:
raise DocForgeError(
"missing_project_root",
"This command requires --project-root",
)
if arguments.command == "onboard": if arguments.command == "onboard":
languages = tuple(arguments.language) languages = tuple(arguments.language)
if arguments.scaffold: if arguments.scaffold:
@ -250,13 +299,14 @@ def main(argv: list[str] | None = None) -> int:
) as collector: ) as collector:
try: try:
result = _run(arguments) result = _run(arguments)
code = 0 doctor_state = result.get("doctor_state")
code = 2 if doctor_state == "unhealthy" else (1 if doctor_state == "degraded" else 0)
except DocForgeError as error: except DocForgeError as error:
result = {"status": "error", "error": error.as_dict()} result = {"status": "error", "error": error.as_dict()}
code = 2 code = 2
if collector is not None: if collector is not None:
result["diagnostics"] = collector.as_dict( result["diagnostics"] = collector.as_dict(
outcome="ok" if code == 0 else "error", outcome="ok" if result.get("status") == "ok" else "error",
) )
print(json.dumps(result, sort_keys=True, indent=2)) print(json.dumps(result, sort_keys=True, indent=2))
return code return code

View file

@ -0,0 +1,937 @@
"""Deterministic, explicit client-configuration plans for DocForge MCP."""
from __future__ import annotations
import hashlib
import json
import os
import re
import secrets
import stat
import subprocess
import sys
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Literal, cast
from .changeset_contract import document_hash
from .errors import DocForgeError
from .models import ProjectService
from .policy import CapabilityMode, compose_effective_policy
from .project import project_root_fingerprint, validate_descriptor_binding
ClientName = Literal["codex", "claude", "openclaw"]
CLIENT_NAMES: tuple[ClientName, ...] = ("codex", "claude", "openclaw")
MAX_CLIENT_FRAGMENT_BYTES = 1_000_000
GENERATED_CAPABILITY_MODES: tuple[CapabilityMode, ...] = (
"read",
"proposal",
"application",
)
_SERVER_NAME = re.compile(r"[a-z0-9][a-z0-9_-]{0,63}")
@dataclass(frozen=True)
class _FileIdentity:
device: int
inode: int
mode: int
size: int
mtime_ns: int
ctime_ns: int
uid: int
link_count: int
def _file_identity(status: os.stat_result) -> _FileIdentity:
return _FileIdentity(
device=status.st_dev,
inode=status.st_ino,
mode=status.st_mode,
size=status.st_size,
mtime_ns=status.st_mtime_ns,
ctime_ns=status.st_ctime_ns,
uid=status.st_uid,
link_count=status.st_nlink,
)
def _client_name(value: str) -> ClientName:
if value not in CLIENT_NAMES:
raise DocForgeError(
"unsupported_client",
"Client configuration target is unsupported",
client=value,
allowed=list(CLIENT_NAMES),
)
return value
def _capability_mode(value: str) -> CapabilityMode:
if value not in GENERATED_CAPABILITY_MODES:
raise DocForgeError(
"invalid_capability_mode",
"Generated configuration supports read, proposal, or application mode",
capability_mode=value,
allowed=list(GENERATED_CAPABILITY_MODES),
)
return value
def _bounded_seconds(value: int, *, field: str, maximum: int) -> int:
if type(value) is not int or value < 1 or value > maximum:
raise DocForgeError(
"invalid_timeout",
"Client timeout is outside the supported range",
field=field,
minimum=1,
maximum=maximum,
)
return value
def _default_server_name(project_id: str, fingerprint: str) -> str:
prefix = re.sub(r"[^a-z0-9_-]+", "-", project_id.lower()).strip("-_")
prefix = prefix or "project"
suffix = f"-{fingerprint}"
available = 64 - len("docforge-") - len(suffix)
return f"docforge-{prefix[:available]}{suffix}"
def _validated_server_name(value: str | None, *, project_id: str, fingerprint: str) -> str:
selected = value or _default_server_name(project_id, fingerprint)
if _SERVER_NAME.fullmatch(selected) is None:
raise DocForgeError(
"invalid_server_name",
"Generated server name must be a stable lowercase client identifier",
pattern=_SERVER_NAME.pattern,
maximum_length=64,
)
return selected
def _toml_string(value: str) -> str:
return json.dumps(value, ensure_ascii=False)
def _toml_array(values: list[str]) -> str:
return "[" + ", ".join(_toml_string(value) for value in values) + "]"
def _artifact(
client: ClientName,
*,
server_name: str,
command: str,
arguments: list[str],
startup_timeout: int,
tool_timeout: int,
) -> tuple[str, str, str | None]:
if client == "codex":
content = "\n".join(
(
f'[mcp_servers."{server_name}"]',
f"command = {_toml_string(command)}",
f"args = {_toml_array(arguments)}",
"env = {}",
f"startup_timeout_sec = {startup_timeout}",
f"tool_timeout_sec = {tool_timeout}",
"",
)
)
return "codex-toml-fragment-v1", content, None
if client == "openclaw":
content = (
json.dumps(
{
"mcp": {
"servers": {
server_name: {
"args": arguments,
"command": command,
"connectTimeout": startup_timeout,
"env": {},
"supportsParallelToolCalls": False,
"timeout": tool_timeout,
}
}
}
},
ensure_ascii=False,
indent=2,
sort_keys=True,
)
+ "\n"
)
return "openclaw-json-fragment-v1", content, None
content = (
json.dumps(
{
"mcpServers": {
server_name: {
"args": arguments,
"command": command,
"env": {},
}
}
},
ensure_ascii=False,
indent=2,
sort_keys=True,
)
+ "\n"
)
return (
"claude-json-fragment-v1",
content,
"Claude per-server timeout representation is not yet verified.",
)
def _signature(
directory_fd: int,
name: str,
) -> _FileIdentity | None:
try:
status = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
except FileNotFoundError:
return None
except OSError as error:
raise DocForgeError(
"unsafe_output",
"Configuration output cannot be inspected safely",
) from error
if stat.S_ISLNK(status.st_mode) or not stat.S_ISREG(status.st_mode):
raise DocForgeError(
"unsafe_output",
"Configuration output must be a regular file and not a symbolic link",
)
return _file_identity(status)
def _parent_binding_current(path: Path, directory_fd: int) -> bool:
try:
before = path.lstat()
resolved = path.resolve(strict=True)
after = path.lstat()
opened = os.fstat(directory_fd)
return (
not stat.S_ISLNK(before.st_mode)
and stat.S_ISDIR(before.st_mode)
and resolved == path
and (before.st_dev, before.st_ino, before.st_mode)
== (after.st_dev, after.st_ino, after.st_mode)
== (opened.st_dev, opened.st_ino, opened.st_mode)
)
except OSError:
return False
def _require_parent_binding(path: Path, directory_fd: int) -> None:
if not _parent_binding_current(path, directory_fd):
raise DocForgeError(
"output_changed",
"Configuration output parent changed during publication",
)
def _bound_parent(path: Path) -> tuple[Path, int]:
absolute = Path(os.path.abspath(path.expanduser()))
parent = absolute.parent
try:
parent_status = parent.lstat()
resolved = parent.resolve(strict=True)
except OSError as error:
raise DocForgeError(
"invalid_output",
"Configuration output parent does not exist",
) from error
if (
stat.S_ISLNK(parent_status.st_mode)
or not stat.S_ISDIR(parent_status.st_mode)
or resolved != parent
or absolute.name in {"", ".", ".."}
):
raise DocForgeError(
"unsafe_output",
"Configuration output parent must be one real non-symlinked directory",
)
try:
directory_fd = os.open(
parent,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
)
except OSError as error:
raise DocForgeError(
"unsafe_output",
"Configuration output parent cannot be opened safely",
) from error
opened = os.fstat(directory_fd)
if opened.st_dev != parent_status.st_dev or opened.st_ino != parent_status.st_ino:
with suppress(OSError):
os.close(directory_fd)
raise DocForgeError(
"output_changed",
"Configuration output parent changed while it was opened",
)
return absolute, directory_fd
def _read_existing(
directory_fd: int,
name: str,
signature: _FileIdentity,
) -> bytes:
if signature.size > MAX_CLIENT_FRAGMENT_BYTES:
raise DocForgeError(
"output_oversized",
"Existing configuration output exceeds the bounded fragment limit",
maximum_bytes=MAX_CLIENT_FRAGMENT_BYTES,
)
try:
descriptor = os.open(
name,
os.O_RDONLY | os.O_NOFOLLOW,
dir_fd=directory_fd,
)
except OSError as error:
raise DocForgeError(
"unsafe_output",
"Configuration output cannot be opened safely",
) from error
try:
opened = os.fstat(descriptor)
opened_signature = _file_identity(opened)
if opened_signature != signature:
raise DocForgeError(
"output_changed",
"Configuration output changed while it was opened",
)
remaining = MAX_CLIENT_FRAGMENT_BYTES + 1
chunks: list[bytes] = []
while remaining:
chunk = os.read(descriptor, min(65_536, remaining))
if not chunk:
break
chunks.append(chunk)
remaining -= len(chunk)
raw = b"".join(chunks)
finally:
with suppress(OSError):
os.close(descriptor)
if len(raw) > MAX_CLIENT_FRAGMENT_BYTES or _signature(directory_fd, name) != signature:
raise DocForgeError(
"output_changed",
"Configuration output changed while it was read",
)
return raw
def _private_existing(identity: _FileIdentity) -> bool:
return (
identity.uid == os.geteuid()
and stat.S_IMODE(identity.mode) & 0o077 == 0
and identity.link_count == 1
)
def _rollback_link(
directory_fd: int,
name: str,
expected: _FileIdentity,
) -> bool:
try:
current = _signature(directory_fd, name)
if current is None:
return True
if current.device != expected.device or current.inode != expected.inode:
return True
os.unlink(name, dir_fd=directory_fd)
return True
except (DocForgeError, OSError):
return False
def _rollback_and_sync(
directory_fd: int,
name: str,
expected: _FileIdentity,
) -> bool:
if not _rollback_link(directory_fd, name, expected):
return False
try:
os.fsync(directory_fd)
except OSError:
return False
return True
def _atomic_write(
path: Path,
content: str,
*,
validate_binding: Callable[[], None],
) -> tuple[str, str, str | None, Path | None]:
target, directory_fd = _bound_parent(path)
encoded = content.encode("utf-8")
if len(encoded) > MAX_CLIENT_FRAGMENT_BYTES:
with suppress(OSError):
os.close(directory_fd)
raise DocForgeError(
"output_oversized",
"Generated configuration fragment exceeds the bounded limit",
maximum_bytes=MAX_CLIENT_FRAGMENT_BYTES,
)
temporary_name = f".docforge-client-{secrets.token_hex(12)}"
temporary_created = False
committed = False
linked_identity: _FileIdentity | None = None
try:
_require_parent_binding(target.parent, directory_fd)
before = _signature(directory_fd, target.name)
if before is not None:
if not _private_existing(before):
raise DocForgeError(
"unsafe_output",
(
"Existing configuration fragment must be owned by the current user, "
"private, and singly linked"
),
)
existing = _read_existing(directory_fd, target.name, before)
if existing == encoded:
validate_binding()
_require_parent_binding(target.parent, directory_fd)
current = _signature(directory_fd, target.name)
if (
current != before
or current is None
or not _private_existing(current)
or _read_existing(directory_fd, target.name, current) != encoded
):
raise DocForgeError(
"output_changed",
"Configuration output changed before unchanged publication was confirmed",
)
validate_binding()
_require_parent_binding(target.parent, directory_fd)
return "unchanged", "not_applicable", None, target
raise DocForgeError(
"output_conflict",
"Configuration fragment already exists with different content",
existing_sha256=hashlib.sha256(existing).hexdigest(),
generated_sha256=hashlib.sha256(encoded).hexdigest(),
)
try:
temporary_fd = os.open(
temporary_name,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
0o600,
dir_fd=directory_fd,
)
except OSError as error:
raise DocForgeError(
"output_publication_failed",
"Configuration fragment temporary file could not be created",
) from error
temporary_created = True
try:
with os.fdopen(temporary_fd, "wb", closefd=True) as handle:
handle.write(encoded)
handle.flush()
os.fsync(handle.fileno())
except OSError as error:
raise DocForgeError(
"output_publication_failed",
"Configuration fragment temporary file could not be written durably",
) from error
temporary_identity = _signature(directory_fd, temporary_name)
if temporary_identity is None:
raise DocForgeError(
"output_changed",
"Configuration fragment temporary file disappeared before publication",
)
if _signature(directory_fd, target.name) is not None:
raise DocForgeError(
"output_changed",
"Configuration output appeared before atomic publication",
)
_require_parent_binding(target.parent, directory_fd)
validate_binding()
try:
os.link(
temporary_name,
target.name,
src_dir_fd=directory_fd,
dst_dir_fd=directory_fd,
follow_symlinks=False,
)
except FileExistsError as error:
raise DocForgeError(
"output_changed",
"Configuration output appeared during atomic publication",
) from error
except OSError as error:
raise DocForgeError(
"output_publication_failed",
"Configuration fragment could not be published atomically",
) from error
linked_identity = temporary_identity
try:
validate_binding()
except DocForgeError:
if _rollback_link(directory_fd, target.name, temporary_identity):
raise
committed = True
return (
"created",
"unconfirmed",
"publication_binding_unconfirmed",
None,
)
linked = _signature(directory_fd, target.name)
if (
linked is None
or linked.device != temporary_identity.device
or linked.inode != temporary_identity.inode
or _read_existing(directory_fd, target.name, linked) != encoded
or not _parent_binding_current(target.parent, directory_fd)
):
if _rollback_link(directory_fd, target.name, temporary_identity):
raise DocForgeError(
"output_changed",
"Configuration output changed during atomic publication",
)
committed = True
return (
"created",
"unconfirmed",
"publication_location_unconfirmed",
None,
)
durability = "confirmed"
warning: str | None = None
try:
os.unlink(temporary_name, dir_fd=directory_fd)
temporary_created = False
published = _signature(directory_fd, target.name)
if (
published is None
or not _private_existing(published)
or published.device != temporary_identity.device
or published.inode != temporary_identity.inode
or _read_existing(directory_fd, target.name, published) != encoded
or not _parent_binding_current(target.parent, directory_fd)
):
if _rollback_link(directory_fd, target.name, temporary_identity):
raise DocForgeError(
"output_changed",
"Configuration output changed after atomic publication",
)
committed = True
return (
"created",
"unconfirmed",
"publication_location_unconfirmed",
None,
)
try:
validate_binding()
except DocForgeError:
if _rollback_link(directory_fd, target.name, temporary_identity):
raise
committed = True
return (
"created",
"unconfirmed",
"publication_binding_unconfirmed",
None,
)
os.fsync(directory_fd)
except OSError:
durability = "unconfirmed"
warning = "publication_durability_unconfirmed"
try:
validate_binding()
except DocForgeError:
if _rollback_and_sync(directory_fd, target.name, temporary_identity):
raise
committed = True
return (
"created",
"unconfirmed",
"publication_binding_unconfirmed",
None,
)
try:
published = _signature(directory_fd, target.name)
publication_current = (
published is not None
and _private_existing(published)
and published.device == temporary_identity.device
and published.inode == temporary_identity.inode
and _read_existing(directory_fd, target.name, published) == encoded
and _parent_binding_current(target.parent, directory_fd)
)
except DocForgeError:
publication_current = False
if not publication_current:
if _rollback_and_sync(directory_fd, target.name, temporary_identity):
raise DocForgeError(
"output_changed",
"Configuration output changed before publication was finalized",
)
committed = True
return (
"created",
"unconfirmed",
"publication_location_unconfirmed",
None,
)
committed = True
return "created", durability, warning, target
except Exception:
if not committed and linked_identity is not None:
_rollback_link(directory_fd, target.name, linked_identity)
if not committed and temporary_created:
with suppress(OSError):
os.unlink(temporary_name, dir_fd=directory_fd)
raise
finally:
with suppress(OSError):
os.close(directory_fd)
def _validate_configuration_result(result: dict[str, object]) -> None:
artifact = cast(dict[str, object], result["artifact"])
binding = cast(dict[str, object], result["binding"])
policy = cast(dict[str, object], result["effective_policy"])
project = cast(dict[str, object], result["project"])
content = cast(str, artifact["content"])
if artifact["content_sha256"] != hashlib.sha256(content.encode("utf-8")).hexdigest():
raise AssertionError("Generated client content hash drifted")
timeouts = cast(dict[str, object], binding["timeouts"])
expected_format, expected_content, _ = _artifact(
cast(ClientName, result["client"]),
server_name=cast(str, result["server_name"]),
command=cast(str, binding["command"]),
arguments=cast(list[str], binding["args"]),
startup_timeout=cast(int, timeouts["startup_seconds"]),
tool_timeout=cast(int, timeouts["tool_seconds"]),
)
if artifact["format"] != expected_format or content != expected_content:
raise AssertionError("Generated client artifact drifted from its binding")
adapter_policy = cast(dict[str, object], binding["adapter_policy"])
render_policy = cast(dict[str, object], binding["render_policy"])
arguments = cast(list[str], binding["args"])
prefix = [
"-I",
"-m",
"docforge.mcp_server",
"--project-root",
cast(str, project["project_root"]),
"--capability-mode",
cast(str, binding["capability_mode"]),
]
if arguments[:7] != prefix:
raise AssertionError("Generated client arguments drifted from their binding")
remaining = arguments[7:]
no_ast_argument = "--no-ast" in arguments
if no_ast_argument:
if remaining[-1:] != ["--no-ast"] or arguments.count("--no-ast") != 1:
raise AssertionError("Generated no-AST argument layout drifted")
remaining = remaining[:-1]
mode = binding["capability_mode"]
if (
(mode == "read" and remaining)
or (
mode == "proposal"
and (len(remaining) != 2 or remaining[0] != "--proposal-writer" or not remaining[1])
)
or (
mode == "application"
and (
len(remaining) != 4
or remaining[0] != "--proposal-writer"
or remaining[2] != "--canonical-applier"
or not remaining[1]
or remaining[1] != remaining[3]
)
)
):
raise AssertionError("Generated authority argument layout drifted")
composed_policy = compose_effective_policy(
selected_mode=cast(CapabilityMode, mode),
capability_source="explicit",
no_ast=adapter_policy["mode"] == "preserve-no-ast",
diagnostics=False,
render_configured=render_policy["manual"] != "disabled",
application_enabled=mode == "application",
)
expected_policy = composed_policy.as_dict()
if (
policy != expected_policy
or adapter_policy != composed_policy.adapter_policy()
or binding["capability_mode"] != policy["capability_mode"]
or no_ast_argument != (adapter_policy["mode"] == "preserve-no-ast")
or render_policy["manual"] != policy["manual_render"]
or render_policy["graph"] != policy["graph_render"]
or render_policy["live_viewer"] != policy["live_viewer"]
or (
adapter_policy["mode"] == "preserve-no-ast"
and (
policy["adapter_evolution"] != "preserve"
or policy["ast_analysis"] != "forbidden"
or policy["logic_indexing"] != "off"
)
)
or (
adapter_policy["mode"] == "standard"
and (
policy["adapter_evolution"] != "allowed"
or policy["ast_analysis"] != "allowed"
or policy["logic_indexing"] != "full"
)
)
):
raise AssertionError("Generated client policy drifted from its binding")
expected_hash = document_hash(
{
"schema_version": 1,
"client": result["client"],
"server_name": result["server_name"],
"project": project,
"binding": binding,
"effective_policy": policy,
"artifact_format": artifact["format"],
"artifact_content_sha256": artifact["content_sha256"],
}
)
if result["configuration_hash"] != expected_hash:
raise AssertionError("Generated client configuration hash drifted")
def generate_client_configuration(
project: ProjectService,
client: str,
*,
server_name: str | None = None,
capability_mode: str = "read",
proposal_writer: str | None = None,
canonical_applier: str | None = None,
no_ast: bool = False,
startup_timeout: int = 30,
tool_timeout: int = 300,
output: Path | None = None,
) -> dict[str, object]:
"""Build one deterministic client fragment and optionally publish it explicitly."""
validate_descriptor_binding(project.descriptor)
selected_client = _client_name(client)
selected_mode = _capability_mode(capability_mode)
startup_seconds = _bounded_seconds(
startup_timeout,
field="startup_timeout",
maximum=3_600,
)
tool_seconds = _bounded_seconds(
tool_timeout,
field="tool_timeout",
maximum=86_400,
)
descriptor = project.descriptor
if descriptor.adapter != "generic":
raise DocForgeError(
"client_configuration_unavailable",
"Generic CLI configuration cannot reconstruct a project-owned adapter",
adapter=descriptor.adapter,
)
writer_ids = {writer.writer_id for writer in descriptor.proposal_writers}
if selected_mode == "read":
if proposal_writer is not None or canonical_applier is not None:
raise DocForgeError(
"invalid_capability_binding",
"Read configuration cannot bind proposal or application authority",
)
elif selected_mode == "proposal":
if proposal_writer is None or proposal_writer not in writer_ids:
raise DocForgeError(
"capability_unavailable",
"Proposal configuration requires a descriptor-declared writer",
required="proposal_writer",
)
if canonical_applier is not None:
raise DocForgeError(
"invalid_capability_binding",
"Proposal configuration cannot bind a canonical applier",
)
else:
if (
proposal_writer is None
or canonical_applier is None
or proposal_writer != canonical_applier
or proposal_writer not in writer_ids
):
raise DocForgeError(
"capability_unavailable",
"Application configuration requires one declared writer/applier identity",
required="matching_declared_writer_and_applier",
)
fingerprint = project_root_fingerprint(descriptor.root)
selected_name = _validated_server_name(
server_name,
project_id=descriptor.project_id,
fingerprint=fingerprint,
)
executable = Path(os.path.abspath(sys.executable))
try:
executable_status = executable.stat()
except OSError as error:
raise DocForgeError(
"client_configuration_unavailable",
"Current Python executable cannot be inspected",
) from error
if not stat.S_ISREG(executable_status.st_mode) or not os.access(executable, os.X_OK):
raise DocForgeError(
"client_configuration_unavailable",
"Current Python executable is not a runnable regular file",
)
try:
probe = subprocess.run(
[
str(executable),
"-I",
"-B",
"-c",
"import docforge.mcp_server",
],
check=False,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=10,
env=os.environ.copy(),
)
except (OSError, subprocess.SubprocessError) as error:
raise DocForgeError(
"client_configuration_unavailable",
"Current isolated Python executable could not be probed safely",
) from error
if probe.returncode != 0:
raise DocForgeError(
"client_configuration_unavailable",
"Current isolated Python executable cannot import docforge.mcp_server",
)
arguments = [
"-I",
"-m",
"docforge.mcp_server",
"--project-root",
str(descriptor.root),
"--capability-mode",
selected_mode,
]
if proposal_writer is not None:
arguments.extend(("--proposal-writer", proposal_writer))
if canonical_applier is not None:
arguments.extend(("--canonical-applier", canonical_applier))
if no_ast:
arguments.append("--no-ast")
policy = compose_effective_policy(
selected_mode=selected_mode,
capability_source="explicit",
no_ast=no_ast,
diagnostics=False,
render_configured=descriptor.render is not None,
application_enabled=canonical_applier is not None,
)
artifact_format, content, warning = _artifact(
selected_client,
server_name=selected_name,
command=str(executable),
arguments=arguments,
startup_timeout=startup_seconds,
tool_timeout=tool_seconds,
)
if output is None:
validate_descriptor_binding(descriptor)
write_state = "not_requested"
durability = "not_applicable"
publication_warning = None
output_path = None
else:
validate_descriptor_binding(descriptor)
write_state, durability, publication_warning, published_path = _atomic_write(
output,
content,
validate_binding=lambda: validate_descriptor_binding(descriptor),
)
output_path = str(published_path) if published_path is not None else None
artifact = {
"format": artifact_format,
"content": content,
"content_sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(),
"output_path": output_path,
"write_state": write_state,
"durability": durability,
}
binding = {
"transport": "stdio",
"capability_mode": selected_mode,
"adapter_policy": policy.adapter_policy(),
"render_policy": {
"manual": policy.manual_render,
"graph": policy.graph_render,
"live_viewer": policy.live_viewer,
},
"command": str(executable),
"args": arguments,
"environment": {},
"timeouts": {
"startup_seconds": startup_seconds,
"tool_seconds": tool_seconds,
},
}
project_binding = {
"project_id": descriptor.project_id,
"project_root": str(descriptor.root),
"project_root_fingerprint": fingerprint,
"adapter": descriptor.adapter,
}
policy_payload = policy.as_dict()
plan_hash = document_hash(
{
"schema_version": 1,
"client": selected_client,
"server_name": selected_name,
"project": project_binding,
"binding": binding,
"effective_policy": policy_payload,
"artifact_format": artifact_format,
"artifact_content_sha256": artifact["content_sha256"],
}
)
result: dict[str, object] = {
"status": "ok",
"schema_version": 1,
"operation": "client.configure",
"action": "write" if output is not None else "preview",
"client": selected_client,
"server_name": selected_name,
"project": project_binding,
"binding": binding,
"effective_policy": policy_payload,
"artifact": artifact,
"configuration_hash": plan_hash,
"warnings": [
*([] if warning is None else [{"code": "timeout_format_unverified"}]),
*([] if publication_warning is None else [{"code": publication_warning}]),
],
}
_validate_configuration_result(result)
return result

1287
src/docforge/doctor.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,7 @@ import tempfile
import tomllib import tomllib
from collections import Counter from collections import Counter
from collections.abc import Mapping from collections.abc import Mapping
from contextlib import suppress
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from typing import Any, cast from typing import Any, cast
@ -39,6 +40,7 @@ from .telemetry import increment, stage
SOURCE_GENERATION_SCHEMA_VERSION = 1 SOURCE_GENERATION_SCHEMA_VERSION = 1
GENERIC_SOURCE_CONTRACT = "docforge-core:0.7.1:index:1" GENERIC_SOURCE_CONTRACT = "docforge-core:0.7.1:index:1"
MAX_PROJECT_DESCRIPTOR_BYTES = 1_000_000
_CORE_METADATA = frozenset( _CORE_METADATA = frozenset(
{ {
@ -210,12 +212,167 @@ def _receipt_signature(path: Path) -> tuple[int, int, int, int, int] | None:
) )
def _read_descriptor(descriptor_path: Path) -> bytes:
try:
parent = descriptor_path.parent
parent_status = parent.lstat()
if (
stat.S_ISLNK(parent_status.st_mode)
or not stat.S_ISDIR(parent_status.st_mode)
or parent.resolve(strict=True) != parent
):
raise DocForgeError(
"project_descriptor_unsafe",
"Project descriptor parent must be one real confined directory",
)
directory_fd = os.open(
parent,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
)
except FileNotFoundError as error:
raise DocForgeError("missing_config", "Missing .docforge/project.toml") from error
except DocForgeError:
raise
except OSError as error:
raise DocForgeError(
"project_descriptor_unsafe",
"Project descriptor cannot be inspected safely",
) from error
opened_parent = os.fstat(directory_fd)
if opened_parent.st_dev != parent_status.st_dev or opened_parent.st_ino != parent_status.st_ino:
with suppress(OSError):
os.close(directory_fd)
raise DocForgeError(
"project_descriptor_changed",
"Project descriptor parent changed while it was opened",
)
try:
def parent_current() -> bool:
try:
before = parent.lstat()
resolved = parent.resolve(strict=True)
after = parent.lstat()
opened = os.fstat(directory_fd)
return (
not stat.S_ISLNK(before.st_mode)
and stat.S_ISDIR(before.st_mode)
and resolved == parent
and (before.st_dev, before.st_ino, before.st_mode)
== (after.st_dev, after.st_ino, after.st_mode)
== (opened.st_dev, opened.st_ino, opened.st_mode)
)
except OSError:
return False
if not parent_current():
raise DocForgeError(
"project_descriptor_changed",
"Project descriptor parent changed before it was read",
)
try:
before = os.stat(
descriptor_path.name,
dir_fd=directory_fd,
follow_symlinks=False,
)
except FileNotFoundError as error:
raise DocForgeError("missing_config", "Missing .docforge/project.toml") from error
except OSError as error:
raise DocForgeError(
"project_descriptor_unsafe",
"Project descriptor cannot be inspected safely",
) from error
if stat.S_ISLNK(before.st_mode) or not stat.S_ISREG(before.st_mode):
raise DocForgeError(
"project_descriptor_unsafe",
"Project descriptor must be a regular file and not a symbolic link",
)
if before.st_size > MAX_PROJECT_DESCRIPTOR_BYTES:
raise DocForgeError(
"project_descriptor_oversized",
"Project descriptor exceeds the bounded configuration limit",
maximum_bytes=MAX_PROJECT_DESCRIPTOR_BYTES,
)
try:
descriptor = os.open(
descriptor_path.name,
os.O_RDONLY | os.O_NOFOLLOW,
dir_fd=directory_fd,
)
except OSError as error:
raise DocForgeError(
"project_descriptor_unsafe",
"Project descriptor cannot be opened safely",
) from error
try:
opened = os.fstat(descriptor)
if opened.st_dev != before.st_dev or opened.st_ino != before.st_ino:
raise DocForgeError(
"project_descriptor_changed",
"Project descriptor changed while it was opened",
)
chunks: list[bytes] = []
remaining = MAX_PROJECT_DESCRIPTOR_BYTES + 1
while remaining:
chunk = os.read(descriptor, min(65_536, remaining))
if not chunk:
break
chunks.append(chunk)
remaining -= len(chunk)
raw = b"".join(chunks)
finally:
with suppress(OSError):
os.close(descriptor)
if len(raw) > MAX_PROJECT_DESCRIPTOR_BYTES:
raise DocForgeError(
"project_descriptor_oversized",
"Project descriptor exceeds the bounded configuration limit",
maximum_bytes=MAX_PROJECT_DESCRIPTOR_BYTES,
)
try:
after = os.stat(
descriptor_path.name,
dir_fd=directory_fd,
follow_symlinks=False,
)
except OSError as error:
raise DocForgeError(
"project_descriptor_changed",
"Project descriptor changed while it was read",
) from error
if (
before.st_dev,
before.st_ino,
before.st_size,
before.st_mtime_ns,
before.st_ctime_ns,
) != (
after.st_dev,
after.st_ino,
after.st_size,
after.st_mtime_ns,
after.st_ctime_ns,
):
raise DocForgeError(
"project_descriptor_changed",
"Project descriptor changed while it was read",
)
if not parent_current():
raise DocForgeError(
"project_descriptor_changed",
"Project descriptor parent changed while it was read",
)
return raw
finally:
with suppress(OSError):
os.close(directory_fd)
def _load_descriptor(root: Path) -> ProjectDescriptor: def _load_descriptor(root: Path) -> ProjectDescriptor:
descriptor_path = root / ".docforge" / "project.toml" descriptor_path = root / ".docforge" / "project.toml"
if not descriptor_path.is_file(): descriptor_bytes = _read_descriptor(descriptor_path)
raise DocForgeError("missing_config", "Missing .docforge/project.toml")
try: try:
descriptor_bytes = descriptor_path.read_bytes()
document = cast(dict[str, object], tomllib.loads(descriptor_bytes.decode("utf-8"))) document = cast(dict[str, object], tomllib.loads(descriptor_bytes.decode("utf-8")))
except UnicodeDecodeError as error: except UnicodeDecodeError as error:
raise DocForgeError("invalid_config", "Project descriptor is not UTF-8") from error raise DocForgeError("invalid_config", "Project descriptor is not UTF-8") from error
@ -472,6 +629,17 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
) )
def validate_descriptor_binding(descriptor: ProjectDescriptor) -> None:
"""Require the bounded descriptor bytes to match one opened project binding."""
descriptor_bytes = _read_descriptor(descriptor.descriptor_path)
if hashlib.sha256(descriptor_bytes).hexdigest() != descriptor.descriptor_hash:
raise DocForgeError(
"source_changed",
"Project descriptor changed after the project was opened",
)
def _markdown_record(path: Path, text: str) -> tuple[dict[str, Any], str]: def _markdown_record(path: Path, text: str) -> tuple[dict[str, Any], str]:
lines = text.splitlines() lines = text.splitlines()
if not lines or lines[0] != "+++": if not lines or lines[0] != "+++":
@ -739,11 +907,7 @@ class Project:
def load(self) -> ProjectSnapshot: def load(self) -> ProjectSnapshot:
increment("project_loads") increment("project_loads")
descriptor_bytes = self.descriptor.descriptor_path.read_bytes() validate_descriptor_binding(self.descriptor)
if hashlib.sha256(descriptor_bytes).hexdigest() != self.descriptor.descriptor_hash:
raise DocForgeError(
"source_changed", "Project descriptor changed after the project was opened"
)
ordered_sources, ordered_directories = self._canonical_inventory() ordered_sources, ordered_directories = self._canonical_inventory()
generation_paths = ( generation_paths = (
self.descriptor.descriptor_path, self.descriptor.descriptor_path,

View file

@ -78,6 +78,7 @@ OPERATION_NAMES = frozenset(
{ {
"test", "test",
"benchmark.m1", "benchmark.m1",
"benchmark.m2",
"mcp.invoke", "mcp.invoke",
"mcp.bootstrap", "mcp.bootstrap",
"mcp.sync", "mcp.sync",
@ -116,6 +117,8 @@ OPERATION_NAMES = frozenset(
"cli.impact", "cli.impact",
"cli.context", "cli.context",
"cli.generation-diff", "cli.generation-diff",
"cli.configure",
"cli.doctor",
"cli.render", "cli.render",
"cli.render-status", "cli.render-status",
"cli.preview", "cli.preview",

File diff suppressed because it is too large Load diff

View file

@ -34,6 +34,10 @@ from docforge.viewer_manager import ViewerManagerClient
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
FIXTURES = ROOT / "tests" / "fixtures" FIXTURES = ROOT / "tests" / "fixtures"
RESULT_SCHEMA = json.loads((ROOT / "schemas" / "result.schema.json").read_text()) RESULT_SCHEMA = json.loads((ROOT / "schemas" / "result.schema.json").read_text())
CLIENT_CONFIGURATION_SCHEMA = json.loads(
(ROOT / "schemas" / "client-configuration.schema.json").read_text()
)
DOCTOR_RESULT_SCHEMA = json.loads((ROOT / "schemas" / "doctor-result.schema.json").read_text())
ZERO_WORK_COUNTERS = ( ZERO_WORK_COUNTERS = (
"project_loads", "project_loads",
"source_files_parsed", "source_files_parsed",
@ -134,6 +138,16 @@ class TelemetryContractTests(unittest.TestCase):
set(COUNTER_NAMES), set(COUNTER_NAMES),
set(properties["counters"]["properties"]), set(properties["counters"]["properties"]),
) )
for schema in (CLIENT_CONFIGURATION_SCHEMA, DOCTOR_RESULT_SCHEMA):
dedicated = schema["$defs"]["diagnostics"]["properties"]
self.assertEqual(
set(STAGE_NAMES),
set(dedicated["stages"]["propertyNames"]["enum"]),
)
self.assertEqual(
set(COUNTER_NAMES),
set(dedicated["counters"]["required"]),
)
def test_thread_and_async_request_contexts_are_isolated(self) -> None: def test_thread_and_async_request_contexts_are_isolated(self) -> None:
barrier = threading.Barrier(2) barrier = threading.Barrier(2)

View file

@ -61,6 +61,8 @@ PUBLIC_IMPORTS = {
"CanonicalApplicationService", "CanonicalApplicationService",
"GenericCanonicalApplier", "GenericCanonicalApplier",
), ),
"docforge.client_config": ("generate_client_configuration",),
"docforge.doctor": ("run_doctor",),
"docforge.index": ("ProjectIndex",), "docforge.index": ("ProjectIndex",),
"docforge.mcp_server": ( "docforge.mcp_server": (
"create_project_server", "create_project_server",
@ -108,8 +110,10 @@ EXPECTED_CLI_COMMANDS = {
"backlinks", "backlinks",
"build", "build",
"check", "check",
"configure",
"context", "context",
"dependencies", "dependencies",
"doctor",
"filter", "filter",
"generation-diff", "generation-diff",
"impact", "impact",

File diff suppressed because it is too large Load diff