From a266c18ab52c80f8d77cb8ee384008e50efa2291 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 06:15:09 +0000 Subject: [PATCH 1/2] Add consumer-agent investigation HTML handoff. Agents emit a small JSON+HTML report under .pickleball/investigations// after the live isolation loop and one diagnostic rerun. DiagnosticCli emit-investigation and workbench_investigation_emit write the pair, link at most two existing screenshots, and print only the relative report.html path. export-guidance leaves investigations unmanaged. Co-authored-by: ds-code-t --- docs/agent/feature-map.md | 8 +- docs/agent/repository-index.md | 3 + docs/ai-diagnostic-reporting-plan.md | 1 + docs/consumer-agent-guide.md | 12 +- docs/consumer-project.md | 1 + docs/diagnostic-reporting.md | 31 ++ docs/pickleball-workbench.md | 6 + .../InternalFrameworkTestSteps.java | 4 +- .../InvestigationHandoffChecks.java | 146 +++++ .../diagnostic/PickleballGuidanceChecks.java | 46 ++ .../protocol/InvestigationHandoff.java | 527 ++++++++++++++++++ pickleball-workbench/AGENTS.md | 2 +- .../dscode/workbench/WorkbenchController.java | 11 + .../dscode/workbench/WorkbenchServices.java | 3 + .../workbench/mcp/WorkbenchMcpTools.java | 32 ++ .../diagnostics/InvestigationHandoffTest.java | 114 ++++ .../workbench/mcp/WorkbenchMcpServerTest.java | 16 + .../reporting/diagnostic/DiagnosticCli.java | 46 +- .../pickleball/guidance/AGENT-GUIDE.md | 12 +- .../guidance/docs/agent/feature-map.md | 8 +- .../docs/ai-diagnostic-reporting-plan.md | 1 + .../guidance/docs/consumer-agent-guide.md | 12 +- .../guidance/docs/consumer-project.md | 1 + .../guidance/docs/diagnostic-reporting.md | 31 ++ .../guidance/docs/pickleball-workbench.md | 6 + 25 files changed, 1060 insertions(+), 20 deletions(-) create mode 100644 maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/InvestigationHandoffChecks.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/InvestigationHandoff.java create mode 100644 pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/InvestigationHandoffTest.java diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index e4121374..0dd38a0e 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -5,8 +5,8 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Capability | Implementation/search anchors | Consumer/internal coverage | Canonical documentation | |---|---|---|---| | Build, publication, Java compatibility, nested controller distribution | `build.gradle`; `settings.gradle`; `pickleball-control-protocol/build.gradle`; `pickleball-workbench/build.gradle`; `src/main/aspectj`; search `verifyStrictControllerIsolation`, `verifyEmbeddedWorkbench`, `shadowJar`, `JavaLanguageVersion` | root tests; protocol `check`; `:pickleball-workbench:test`; artifact/dependency verification; Maven consumer build | `README.md`; `docs/getting-started.md`; `docs/cucumber-compatibility.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | -| Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records | `verifyProtocolIsolation`; `ControlBridgeClientTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Text-Blocks editor toggle | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `tools.dscode.workbench.player`; `LiveEditorView`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | +| Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records; `InvestigationHandoff` | `verifyProtocolIsolation`; `ControlBridgeClientTest`; `InvestigationHandoffTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `docs/diagnostic-reporting.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Text-Blocks editor toggle | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `workbench_investigation_emit`; `tools.dscode.workbench.player`; `LiveEditorView`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `InvestigationHandoffTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/mappings/*`, `/v1/browser/*`, `/v1/services/call`, `/v1/breakpoints*`, `/v1/step-overrides*` | controller-only fake-server `ControlBridgeClientTest`; `control-bridge.feature` tagged `@control-bridge`; `ControlBridgeTestSteps.java`; Workbench `worker-check` / `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/workbench/controller//` extraction | `PickleballWorkbenchLauncherTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | @@ -19,8 +19,8 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Custom element definitions/catalog context | `ExecutionDictionary.java`; `ElementMatch.java`; consumer `PickleballTests.java`; search `category(`, `inheritsFrom` | `catalog-context.feature`; `forms-dynamic-steps.feature`; `site/catalog.html` | `docs/custom-element-definitions.md`; `docs/config-files-and-resource-mapping.md` | | Mapping, ParsingMap/NodeMap, templates/directives | `MappingSteps.java`; `FileAndDataParsing.java`; `MappingProcessor.java`; `NodeMap.java`; `ParsingMap.java`; `ValueFormatting.java`; `common/dataelements` | `mapping-and-resources.feature`; `mapping-value-type-preservation.feature`; `scenario-data-references.feature`; Data Element features; internal Java checks | `docs/mapping-and-templating.md`; `docs/data-values-and-elements.md`; `docs/data-element-query-runtime.md`; `docs/config-files-and-resource-mapping.md` | | Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_run_profile`, `pkb_configpath` | `configuration-system-properties.feature`; `ProfileConfigurationChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | -| Consumer guidance export/reference snapshot | `DiagnosticCli.java`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; `maven-consumer-project/AGENTS.md`; `maven-consumer-project/.github/copilot-instructions.md`; search `export-guidance`, `GUIDANCE-MANIFEST.json` | `PickleballGuidanceChecks.java`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | -| Diagnostic reporting and controlled reruns | `src/main/java/tools/dscode/common/reporting/diagnostic`; diagnostic aspects; `DiagnosticCli.java`; `VisualFingerprintComparator.java`; `DiagnosticRunComparator.java` | `DiagnosticReportingChecks.java`; `Diagnostic213CompletionChecks.java`; diagnostic features | `docs/diagnostic-reporting.md`; `docs/ai-diagnostic-reporting-plan.md`; `docs/ai-run-configuration.md`; root `AGENTS.md` | +| Consumer guidance export/reference snapshot | `DiagnosticCli.java`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; `maven-consumer-project/AGENTS.md`; `maven-consumer-project/.github/copilot-instructions.md`; search `export-guidance`, `GUIDANCE-MANIFEST.json`, `.pickleball/investigations` | `PickleballGuidanceChecks.java`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | +| Diagnostic reporting and controlled reruns | `src/main/java/tools/dscode/common/reporting/diagnostic`; `InvestigationHandoff`; diagnostic aspects; `DiagnosticCli.java`; `emit-investigation`; `VisualFingerprintComparator.java`; `DiagnosticRunComparator.java` | `DiagnosticReportingChecks.java`; `Diagnostic213CompletionChecks.java`; `InvestigationHandoffChecks.java`; diagnostic features | `docs/diagnostic-reporting.md`; `docs/ai-diagnostic-reporting-plan.md`; `docs/ai-run-configuration.md`; root `AGENTS.md` | | Nested steps/block conditionals | search `Nested`, `Conditional`, `Block`, `Condition` in core implementation | `nested-and-block-conditionals.feature` | `docs/nested-steps.md`; `docs/block-conditionals.md` | | Component scenarios/reusable RUN/selectors/markers | `ModularScenarios.java`; `ScenarioStep.java`; `ScenarioStepData.java`; `StepBase.java`; `StepExtension.java`; `CurrentScenarioState.java`; `CucumberScanUtil.java`; search `finalizerSteps`, `RunSelection` | `component-scenarios.feature`; `reusable-scenario-selection.feature`; `run-step-parameter-variations.feature`; marker features | `docs/component-scenarios.md`; `docs/service-call-scenarios.md`; `docs/data-values-and-elements.md` | | Service-call definitions/execution | `ServiceCallSteps.java`; `ModularScenarios.java`; `StepExtension.java`; `CurrentScenarioState.java`; `RestAssuredUtil.java`; mapping classes; `maven-consumer-project/src/test/resources/calls` | `service-call-execution.feature`; `run-step-parameter-variations.feature`; reusable selection/parameter features; local server support | `docs/service-call-scenarios.md`; `docs/component-scenarios.md`; `docs/mapping-and-templating.md` | diff --git a/docs/agent/repository-index.md b/docs/agent/repository-index.md index f44d9c18..fbf3f914 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -470,6 +470,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeValue.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeValueResult.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlProtocol.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/InvestigationHandoff.java` ## Pickleball Workbench module @@ -542,6 +543,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ScenarioFilterTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigatorTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/InvestigationHandoffTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/lease/WorkbenchControlLeaseTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mapping/MappingValueCodecTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchAttachServerTest.java` @@ -590,6 +592,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `maven-consumer-project/src/test/java/tools/dscode/common/mappings/MappingDataRefactorChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/Diagnostic213CompletionChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/DiagnosticReportingChecks.java` +- `maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/InvestigationHandoffChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/util/datetime/BusinessTemporalDeltaChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/util/datetime/BusinessTimePostModifierChecks.java` diff --git a/docs/ai-diagnostic-reporting-plan.md b/docs/ai-diagnostic-reporting-plan.md index 11b6382e..2d9c4278 100644 --- a/docs/ai-diagnostic-reporting-plan.md +++ b/docs/ai-diagnostic-reporting-plan.md @@ -132,6 +132,7 @@ For a source-only fix, reuse the retained RunVars unchanged and omit `pkb_change ```text DiagnosticCli guidance DiagnosticCli export-guidance [output-directory] +DiagnosticCli emit-investigation DiagnosticCli compare-runs [output-json] DiagnosticCli compare-fingerprints [output-json] DiagnosticCli rebuild diff --git a/docs/consumer-agent-guide.md b/docs/consumer-agent-guide.md index ac261637..55a833d5 100644 --- a/docs/consumer-agent-guide.md +++ b/docs/consumer-agent-guide.md @@ -10,7 +10,8 @@ Use this order. Consumer AI agents for this Pickleball release use headless Work 1. **Live headless MCP** — isolate a failing step in a paused worker. Reuse compilation, rewrite Gherkin in the live buffer, and inspect the page and semantic events in the same browser/Mapping state. 2. **One diagnostic `mvn test`** — after the live loop has isolated the failure, run one bounded confirmation with `pkb_runvars` so an evidence pack is retained. -3. **Edit real consumer source** — change the project's own features/Java only after the live buffer is right. Explicit Save is what writes a `.feature` file. +3. **Emit the human handoff** — write `.pickleball/investigations//` then in chat print only `.pickleball/investigations//report.html`. +4. **Edit real consumer source** — change the project's own features/Java only after the live buffer is right. Explicit Save is what writes a `.feature` file. Do not copy consumer features into `.pickleball` as a sandbox. @@ -25,6 +26,7 @@ From the consumer project, with Pickleball on the test classpath: 5. Isolate with `workbench_execute_step` and/or `workbench_player_replace_document`. 6. Inspect with `workbench_browser_page`, `workbench_element_inspect`, and `workbench_events`. 7. When you need a retained evidence pack, run **one** diagnostic `mvn test` with `pkb_runvars` (below). Read that pack with `workbench_diagnostic_catalog`, `workbench_diagnostic_run`, and `workbench_diagnostic_summary` instead of globbing `reports/diagnostic-runs`. +8. Emit the human handoff with `workbench_investigation_emit` or `DiagnosticCli emit-investigation`. In chat print only `.pickleball/investigations//report.html`. Do not paste the report body, cause/fix essays, or screenshots into the chat panel. `workbench_execute_step` failure does not end the worker. Insert, nest, or retry in the same paused browser/Mapping state. Live buffer edits do not require `workbench_sync` and do not write the original `.feature` until explicit Save (`workbench_request_save`). @@ -34,6 +36,7 @@ Worker restart without rebuild already exists (`workbench_worker_restart`). Step - `.pickleball/maven-consumer-project/` is a version-matched **read-only** reference snapshot of Pickleball's own example consumer. Do not copy, edit, or execute it as the project under test. - `.pickleball/workbench/live/classes` is the compiled overlay for the worker classpath. Do not use it as an editor. +- `.pickleball/investigations/` is unmanaged consumer-agent output. `export-guidance` leaves it alone. - `export-guidance` does **not** copy this consumer's own features into `.pickleball` for testing. It still materializes full `docs/` plus the example-consumer snapshot for on-demand/human use. ## First-read @@ -58,7 +61,7 @@ A successful `export-guidance .pickleball` run: - overwrites the current version's managed guidance files, documentation, and Maven consumer reference snapshot; - writes `.pickleball/GUIDANCE-MANIFEST.json` last, recording the exporting Pickleball version and managed files; -- removes files managed by the previous manifest that are no longer shipped, while leaving unrelated files alone; and +- removes files managed by the previous manifest that are no longer shipped, while leaving unrelated files alone, including `.pickleball/investigations/`; and - best-effort ensures `.pickleball` is ignored by Git, preferring an existing `.gitignore` and then repository-local `.git/info/exclude`. The exporter does not create/commit a new `.gitignore`, alter the Git index, or untrack files that were already committed. If export fails, treat any existing `.pickleball` contents as potentially stale. The manifest records the last completed export; it is not a substitute for rerunning the exporter. @@ -190,6 +193,8 @@ Stop reading as soon as the current layer answers the investigation. Do not recu From headless Workbench MCP, use `workbench_diagnostic_catalog`, `workbench_diagnostic_run`, and `workbench_diagnostic_summary` for layers 1–3 instead of globbing `reports/diagnostic-runs`. Those tools return sparse JSON only and do not dump `events.jsonl`, traces, or screenshot bytes. +After isolation and the diagnostic rerun, emit a small human handoff. JSON is the source of truth; HTML is a local render of that JSON plus at most two screenshots linked from the existing diagnostic pack. Do not copy the diagnostic run into `.pickleball/investigations/`. In chat print only the project-relative `report.html` path. + ## Visual evidence rules - Never open a PNG merely to determine whether two screenshots differ. @@ -208,12 +213,13 @@ From a Maven consumer where Pickleball is on the test classpath: ```text DiagnosticCli guidance DiagnosticCli export-guidance [output-directory] +DiagnosticCli emit-investigation DiagnosticCli compare-runs [output-json] DiagnosticCli compare-fingerprints [output-json] DiagnosticCli rebuild ``` -Use `guidance` to print this guide and `export-guidance` to materialize the complete version-matched documentation plus curated Maven consumer reference. Prefer `DiagnosticCli` over constructing Maven classpaths and JShell scripts for routine diagnostic operations. +Use `guidance` to print this guide and `export-guidance` to materialize the complete version-matched documentation plus curated Maven consumer reference. Prefer `DiagnosticCli` over constructing Maven classpaths and JShell scripts for routine diagnostic operations. `emit-investigation` writes `.pickleball/investigations//investigation.json` and `report.html` and prints the relative HTML path. ## Controlled diagnostic reruns diff --git a/docs/consumer-project.md b/docs/consumer-project.md index ace01d46..35845161 100644 --- a/docs/consumer-project.md +++ b/docs/consumer-project.md @@ -235,6 +235,7 @@ Do not recursively ingest an entire run. ```text DiagnosticCli compare-runs [output-json] DiagnosticCli compare-fingerprints [output-json] +DiagnosticCli emit-investigation DiagnosticCli rebuild ``` diff --git a/docs/diagnostic-reporting.md b/docs/diagnostic-reporting.md index 60e4a50e..f5cabd71 100644 --- a/docs/diagnostic-reporting.md +++ b/docs/diagnostic-reporting.md @@ -119,6 +119,7 @@ Supported command-line operations: ```text DiagnosticCli guidance DiagnosticCli export-guidance [output-directory] +DiagnosticCli emit-investigation DiagnosticCli compare-runs [output-json] DiagnosticCli compare-fingerprints [output-json] DiagnosticCli rebuild @@ -126,6 +127,36 @@ DiagnosticCli rebuild Prefer `DiagnosticCli` over custom Maven-classpath/JShell workflows for routine comparison and recovery. +`emit-investigation` writes a small human handoff under the consumer project: + +```text +.pickleball/investigations// + investigation.json # source of truth + report.html # one-page local render +``` + +Input is investigation JSON from a file or stdin (`-`) plus the consumer project root. The command prints the project-relative `report.html` path. JSON is the source of truth. HTML renders that JSON plus at most two screenshots *linked* from the existing diagnostic pack; extra screenshot paths are ignored, and a missing image becomes a short note rather than a failed emit. The writer does not copy `reports/diagnostic-runs/` and does not change `pkb_diagnostic_output`. Headless Workbench MCP exposes the same emit as `workbench_investigation_emit` and returns only that relative report path. + +Suggested investigation JSON fields, using existing lineage/diagnostic names where they already exist: + +```text +pkb_investigation_id +createdAt +scenario.name / scenario.feature / scenario.scenarioId +outcome # cause-only | cause-and-fix +cause +fix # text, or "not fixed" +category # selector | gherkin | java | data | other +failureSignature +failureSite +runId +runIndexPath # pointer, not a copy +screenshots # at most two project-relative PNG paths +pickleballVersion +``` + +`export-guidance` does not manage or delete `.pickleball/investigations/`. + ## Outcomes and completion Run outcomes: diff --git a/docs/pickleball-workbench.md b/docs/pickleball-workbench.md index 950c8f65..d376c7e2 100644 --- a/docs/pickleball-workbench.md +++ b/docs/pickleball-workbench.md @@ -406,6 +406,12 @@ workbench_diagnostic_run workbench_diagnostic_summary ``` +Human investigation handoff (writes `.pickleball/investigations//` and returns the relative `report.html` path only): + +```text +workbench_investigation_emit +``` + `workbench_step_override_compile` sends the Java source template to the consumer worker. The source must contain `{{CLASS_NAME}}`; worker-side Pickleball remains responsible for compilation, generated classloaders, matching, replacement, captures, and execution. Mutating live tools require the agent control lease. `workbench_request_save` never writes the original feature until the human Allows it in the UI, or until the explicit stdio tool call itself is the headless approval. Deny, Take control, and an unsavable demo buffer leave the file unchanged. diff --git a/maven-consumer-project/src/test/java/com/example/pickleball/InternalFrameworkTestSteps.java b/maven-consumer-project/src/test/java/com/example/pickleball/InternalFrameworkTestSteps.java index 9f0483d7..a7d16c46 100644 --- a/maven-consumer-project/src/test/java/com/example/pickleball/InternalFrameworkTestSteps.java +++ b/maven-consumer-project/src/test/java/com/example/pickleball/InternalFrameworkTestSteps.java @@ -12,6 +12,7 @@ import tools.dscode.common.mappings.MappingDataRefactorChecks; import tools.dscode.common.reporting.diagnostic.Diagnostic213CompletionChecks; import tools.dscode.common.reporting.diagnostic.DiagnosticReportingChecks; +import tools.dscode.common.reporting.diagnostic.InvestigationHandoffChecks; import tools.dscode.common.reporting.diagnostic.PickleballGuidanceChecks; import tools.dscode.common.reporting.diagnostic.ReportRetentionPolicy; import tools.dscode.common.util.datetime.BusinessTemporalDeltaChecks; @@ -55,7 +56,8 @@ public static void runDiagnosticReportingJavaTests() { runAndAssert( DiagnosticReportingChecks.class, Diagnostic213CompletionChecks.class, - PickleballGuidanceChecks.class + PickleballGuidanceChecks.class, + InvestigationHandoffChecks.class ); } finally { ReportRetentionPolicy.clearThreadOverride(); diff --git a/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/InvestigationHandoffChecks.java b/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/InvestigationHandoffChecks.java new file mode 100644 index 00000000..9ff5a12d --- /dev/null +++ b/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/InvestigationHandoffChecks.java @@ -0,0 +1,146 @@ +package tools.dscode.common.reporting.diagnostic; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import tools.dscode.control.protocol.InvestigationHandoff; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class InvestigationHandoffChecks { + private static final ObjectMapper JSON = new ObjectMapper(); + + @Test + void jsonToHtmlEscapesTextCapsScreenshotsAndNotesMissingImages() throws Exception { + Path project = Files.createTempDirectory("pickleball-investigation-html"); + try { + Path shots = project.resolve("reports/diagnostic-runs/run-1/scenarios/s1/screenshots"); + Files.createDirectories(shots); + Files.write(shots.resolve("ok.png"), new byte[]{1, 2, 3}); + + Map raw = new LinkedHashMap<>(); + raw.put("pkb_investigation_id", "form-1"); + raw.put("scenario", Map.of("name", "Click ")); + raw.put("cause", "Looked for & clicked the wrong one."); + raw.put("screenshots", List.of( + "reports/diagnostic-runs/run-1/scenarios/s1/screenshots/ok.png", + "reports/diagnostic-runs/run-1/scenarios/s1/screenshots/gone.png", + "reports/diagnostic-runs/run-1/scenarios/s1/screenshots/third.png" + )); + + InvestigationHandoff.Document document = InvestigationHandoff.normalize(raw, project); + assertEquals(2, document.screenshots().size()); + String html = InvestigationHandoff.renderHtml(document, project); + assertTrue(html.contains("Looked for <input> & clicked the wrong one.")); + assertTrue(html.contains("Click <Go>")); + assertTrue(html.contains("ok.png")); + assertTrue(html.contains("Screenshot missing: reports/diagnostic-runs/run-1/scenarios/s1/screenshots/gone.png")); + assertFalse(html.contains("third.png")); + assertFalse(html.contains("")); + } finally { + deleteTree(project); + } + } + + @Test + void diagnosticCliEmitWritesHandoffPairAndDoesNotCopyTheDiagnosticPack() throws Exception { + Path project = Files.createTempDirectory("pickleball-investigation-cli"); + try { + Path run = project.resolve("reports/diagnostic-runs/run-9/scenarios/s1/screenshots"); + Files.createDirectories(run); + Path png = run.resolve("frame.png"); + Files.write(png, new byte[]{8, 8, 8}); + Path index = project.resolve("reports/diagnostic-runs/run-9/run-index.json"); + Files.writeString(index, "{\"runId\":\"run-9\"}", StandardCharsets.UTF_8); + + Path input = project.resolve("handoff.json"); + Map raw = new LinkedHashMap<>(); + raw.put("pkb_investigation_id", "cli-9"); + raw.put("cause", "The catalog button was stale."); + raw.put("outcome", "cause-only"); + raw.put("runId", "run-9"); + raw.put("failureSignature", "stale-element"); + raw.put("screenshots", List.of( + "reports/diagnostic-runs/run-9/scenarios/s1/screenshots/frame.png" + )); + JSON.writeValue(input.toFile(), raw); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + int status = DiagnosticCli.run( + new String[]{"emit-investigation", input.toString(), project.toString()}, + new PrintStream(output, true, StandardCharsets.UTF_8), + System.err + ); + assertEquals(0, status); + String reportPath = output.toString(StandardCharsets.UTF_8).trim(); + assertEquals(".pickleball/investigations/cli-9/report.html", reportPath); + + Path jsonFile = project.resolve(".pickleball/investigations/cli-9/investigation.json"); + Path htmlFile = project.resolve(".pickleball/investigations/cli-9/report.html"); + assertTrue(Files.isRegularFile(jsonFile)); + assertTrue(Files.isRegularFile(htmlFile)); + assertTrue(Files.isRegularFile(png)); + assertTrue(Files.isRegularFile(index)); + + String json = Files.readString(jsonFile, StandardCharsets.UTF_8); + assertTrue(json.contains("\"pkb_investigation_id\" : \"cli-9\"") + || json.contains("\"pkb_investigation_id\": \"cli-9\"")); + assertTrue(json.contains("stale-element")); + assertFalse(json.contains("iVBORw0KGgo")); + + try (var paths = Files.walk(project.resolve(".pickleball/investigations"))) { + List files = paths.filter(Files::isRegularFile).toList(); + assertEquals(2, files.size()); + } + + String html = Files.readString(htmlFile, StandardCharsets.UTF_8); + assertTrue(html.contains("../../../reports/diagnostic-runs/run-9/scenarios/s1/screenshots/frame.png")); + assertTrue(html.contains("not fixed")); + } finally { + deleteTree(project); + } + } + + @Test + void diagnosticCliReadsInvestigationJsonFromStdin() throws Exception { + Path project = Files.createTempDirectory("pickleball-investigation-stdin"); + try { + String json = """ + {"pkb_investigation_id":"stdin-1","cause":"A mapping key was wrong."} + """; + ByteArrayOutputStream output = new ByteArrayOutputStream(); + int status = DiagnosticCli.run( + new String[]{"emit-investigation", "-", project.toString()}, + new PrintStream(output, true, StandardCharsets.UTF_8), + System.err, + new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)) + ); + assertEquals(0, status); + assertEquals(".pickleball/investigations/stdin-1/report.html", output.toString(StandardCharsets.UTF_8).trim()); + assertTrue(Files.isRegularFile(project.resolve(".pickleball/investigations/stdin-1/investigation.json"))); + } finally { + deleteTree(project); + } + } + + private static void deleteTree(Path root) throws Exception { + if (root == null || !Files.exists(root)) return; + try (var paths = Files.walk(root)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(path); + } + } + } +} diff --git a/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java b/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java index 956968a3..65c6ba1f 100644 --- a/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java +++ b/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java @@ -46,6 +46,8 @@ void dependencyPrintsCanonicalAgentGuide() { assertTrue(guide.contains("workbench_sync")); assertTrue(guide.contains("workbench_execute_step")); assertTrue(guide.contains("workbench_diagnostic_catalog")); + assertTrue(guide.contains("workbench_investigation_emit")); + assertTrue(guide.contains("emit-investigation")); assertTrue(guide.contains("pkb_reportingmode=diagnostic")); assertTrue(guide.contains("pkb_reportretention=failed")); String chooser = guide.substring(0, guide.indexOf("Generated guidance lifecycle")); @@ -136,6 +138,7 @@ void dependencyExportsVersionMatchedGuidanceAndManifest() throws Exception { assertTrue(guide.contains("read-only reference snapshot")); assertTrue(guide.contains("mcp .")); assertTrue(guide.contains("workbench_diagnostic_catalog")); + assertTrue(guide.contains("workbench_investigation_emit")); assertTrue(guide.contains("pkb_reportretention=failed")); assertTrue(guide.contains("Do not copy, modify, or execute files")); @@ -214,6 +217,49 @@ void exportRemovesObsoleteManagedFilesButPreservesUnmanagedFiles() throws Except } } + @Test + void exportDoesNotDeleteUnmanagedInvestigationsDirectory() throws Exception { + Path consumer = Files.createTempDirectory("pickleball-guidance-investigations"); + Path root = consumer.resolve(".pickleball"); + try { + assertEquals(0, DiagnosticCli.run( + new String[]{"export-guidance", root.toString()}, + System.out, + System.err + )); + + Path investigation = root.resolve("investigations/keep-me/investigation.json"); + Path report = root.resolve("investigations/keep-me/report.html"); + Path empty = root.resolve("investigations/empty"); + Files.createDirectories(investigation.getParent()); + Files.createDirectories(empty); + Files.writeString(investigation, "{\"pkb_investigation_id\":\"keep-me\"}", StandardCharsets.UTF_8); + Files.writeString(report, "keep", StandardCharsets.UTF_8); + + Map manifest = readManifest(root); + List managedFiles = new ArrayList<>(asStringList(manifest.get("files"))); + managedFiles.add("investigations/keep-me/investigation.json"); + managedFiles.add("investigations/keep-me/report.html"); + manifest.put("files", managedFiles); + JSON.writeValue(root.resolve("GUIDANCE-MANIFEST.json").toFile(), manifest); + + assertEquals(0, DiagnosticCli.run( + new String[]{"export-guidance", root.toString()}, + System.out, + System.err + )); + + assertTrue(Files.isRegularFile(investigation)); + assertTrue(Files.isRegularFile(report)); + assertTrue(Files.isDirectory(empty)); + assertEquals("{\"pkb_investigation_id\":\"keep-me\"}", Files.readString(investigation, StandardCharsets.UTF_8)); + Map next = readManifest(root); + assertFalse(asStringList(next.get("files")).stream().anyMatch(path -> path.contains("investigations/"))); + } finally { + deleteTree(consumer); + } + } + @Test void exportAddsPickleballToExistingConsumerGitignoreWithoutDuplicates() throws Exception { Path consumer = Files.createTempDirectory("pickleball-guidance-ignore"); diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/InvestigationHandoff.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/InvestigationHandoff.java new file mode 100644 index 00000000..cbd540e7 --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/InvestigationHandoff.java @@ -0,0 +1,527 @@ +package tools.dscode.control.protocol; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Shared JDK-only writer for consumer-agent investigation handoffs. + * + *

This is not Control Bridge wire protocol. It lives in the protocol module so + * Pickleball {@code DiagnosticCli} and Workbench MCP can emit identical reports + * without giving Workbench a core Pickleball dependency.

+ * + *

{@code investigation.json} is the source of truth. {@code report.html} is a + * local render of that JSON plus at most two screenshots linked from the + * existing diagnostic pack. The writer never copies diagnostic-run files and + * never embeds PNG bytes.

+ */ +public final class InvestigationHandoff { + public static final int MAX_SCREENSHOTS = 2; + public static final int SCHEMA_VERSION = 1; + public static final String INVESTIGATIONS_DIRECTORY = "investigations"; + public static final String RELATIVE_ROOT = ".pickleball/" + INVESTIGATIONS_DIRECTORY; + public static final String NOT_FIXED = "not fixed"; + public static final String OUTCOME_CAUSE_ONLY = "cause-only"; + public static final String OUTCOME_CAUSE_AND_FIX = "cause-and-fix"; + + private static final Pattern INVESTIGATION_ID = + Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,127}"); + private static final int MAX_PATH_CHARS = 1024; + + private InvestigationHandoff() { + } + + public record Document( + String investigationId, + String createdAt, + String scenarioName, + String feature, + String scenarioId, + String outcome, + String cause, + String fix, + String category, + String failureSignature, + Object failureSite, + String runId, + String runIndexPath, + List screenshots, + String pickleballVersion + ) { + public Document { + screenshots = List.copyOf(screenshots == null ? List.of() : screenshots); + } + + public Map toMap() { + Map map = new LinkedHashMap<>(); + map.put("schemaVersion", SCHEMA_VERSION); + map.put("pkb_investigation_id", investigationId); + map.put("createdAt", createdAt); + Map scenario = new LinkedHashMap<>(); + putIfPresent(scenario, "name", scenarioName); + putIfPresent(scenario, "feature", feature); + putIfPresent(scenario, "scenarioId", scenarioId); + if (!scenario.isEmpty()) map.put("scenario", scenario); + map.put("outcome", outcome); + map.put("cause", cause); + map.put("fix", fix); + putIfPresent(map, "category", category); + putIfPresent(map, "failureSignature", failureSignature); + if (failureSite != null) map.put("failureSite", failureSite); + putIfPresent(map, "runId", runId); + putIfPresent(map, "runIndexPath", runIndexPath); + map.put("screenshots", screenshots); + putIfPresent(map, "pickleballVersion", pickleballVersion); + return map; + } + } + + public record EmitResult(String investigationId, String reportPath, Path jsonFile, Path htmlFile) { + public Map sparseResult() { + Map result = new LinkedHashMap<>(); + result.put("reportPath", reportPath); + return Map.copyOf(result); + } + } + + public static EmitResult emit(Path projectRoot, Map raw) throws IOException { + Path root = requireProjectRoot(projectRoot); + if (!Files.isDirectory(root)) { + throw new IllegalArgumentException("Consumer project root not found: " + root); + } + Document document = normalize(raw, root); + Path directory = root.resolve(".pickleball") + .resolve(INVESTIGATIONS_DIRECTORY) + .resolve(document.investigationId()); + Files.createDirectories(directory); + + Path jsonFile = directory.resolve("investigation.json"); + Path htmlFile = directory.resolve("report.html"); + Files.writeString(jsonFile, encodePretty(document.toMap()) + "\n", StandardCharsets.UTF_8); + Files.writeString(htmlFile, renderHtml(document, root, directory), StandardCharsets.UTF_8); + + String reportPath = root.relativize(htmlFile).toString().replace('\\', '/'); + return new EmitResult(document.investigationId(), reportPath, jsonFile, htmlFile); + } + + public static Document normalize(Map raw, Path projectRoot) { + if (raw == null || raw.isEmpty()) { + throw new IllegalArgumentException("Investigation JSON must be an object."); + } + Path root = requireProjectRoot(projectRoot); + + String investigationId = requireInvestigationId(firstText(raw, "pkb_investigation_id", "investigationId")); + String createdAt = firstText(raw, "createdAt"); + if (createdAt.isBlank()) createdAt = Instant.now().toString(); + + ScenarioIdentity scenario = scenarioIdentity(raw); + String outcome = normalizeOutcome(firstText(raw, "outcome")); + String cause = firstText(raw, "cause"); + String fix = firstText(raw, "fix"); + if (fix.isBlank()) fix = NOT_FIXED; + + String runId = firstText(raw, "runId", "diagnosticRunId"); + String runIndexPath = projectRelativePath(root, firstText(raw, "runIndexPath")); + if (runIndexPath == null && !runId.isBlank()) { + runIndexPath = projectRelativePath(root, "reports/diagnostic-runs/" + runId + "/run-index.json"); + } + + return new Document( + investigationId, + createdAt, + scenario.name, + scenario.feature, + scenario.scenarioId, + outcome, + cause, + fix, + normalizeCategory(firstText(raw, "category")), + firstText(raw, "failureSignature"), + normalizeFailureSite(raw.get("failureSite")), + runId, + runIndexPath == null ? "" : runIndexPath, + screenshotPaths(root, raw.get("screenshots")), + firstText(raw, "pickleballVersion") + ); + } + + public static String renderHtml(Document document, Path projectRoot) { + Path root = requireProjectRoot(projectRoot); + Path reportDir = root.resolve(".pickleball") + .resolve(INVESTIGATIONS_DIRECTORY) + .resolve(document.investigationId()); + return renderHtml(document, root, reportDir); + } + + static String renderHtml(Document document, Path projectRoot, Path reportDir) { + String title = document.scenarioName().isBlank() + ? document.investigationId() + : document.scenarioName(); + StringBuilder html = new StringBuilder(); + html.append("\n\n"); + html.append("").append(escape(title)).append("\n"); + html.append("\n\n"); + html.append("

").append(escape(title)).append("

\n"); + html.append("

Investigation ").append(escape(document.investigationId())); + html.append(" · ").append(escape(document.outcome())); + if (!document.createdAt().isBlank()) { + html.append(" · ").append(escape(document.createdAt())); + } + html.append("

\n"); + + section(html, "Cause", document.cause()); + section(html, "Fix", document.fix()); + + html.append("

Scenario

\n
\n"); + definition(html, "Name", document.scenarioName()); + definition(html, "Feature", document.feature()); + definition(html, "Scenario id", document.scenarioId()); + definition(html, "Category", document.category()); + definition(html, "Failure signature", document.failureSignature()); + if (document.failureSite() != null) { + definition(html, "Failure site", failureSiteText(document.failureSite())); + } + html.append("
\n"); + + html.append("

Diagnostic run

\n"); + if (document.runId().isBlank() && document.runIndexPath().isBlank()) { + html.append("

No diagnostic run pointer.

\n"); + } else { + html.append("
\n"); + definition(html, "Run id", document.runId()); + if (!document.runIndexPath().isBlank()) { + html.append("
run-index
"); + html.append(pathMarkup(projectRoot, reportDir, document.runIndexPath(), false)); + html.append("
\n"); + } + html.append("
\n"); + } + html.append("
\n"); + + html.append("

Screenshots

\n"); + if (document.screenshots().isEmpty()) { + html.append("

No screenshots selected.

\n"); + } else { + int index = 1; + for (String screenshot : document.screenshots()) { + html.append(pathMarkup(projectRoot, reportDir, screenshot, true)); + if (index < document.screenshots().size()) html.append('\n'); + index++; + } + } + html.append("
\n"); + + if (!document.pickleballVersion().isBlank()) { + html.append("

Pickleball ").append(escape(document.pickleballVersion())).append("

\n"); + } + html.append("\n"); + return html.toString(); + } + + public static Path investigationsRoot(Path pickleballDirectory) { + return pickleballDirectory.resolve(INVESTIGATIONS_DIRECTORY); + } + + public static boolean isInvestigationsPath(Path pickleballDirectory, Path path) { + if (pickleballDirectory == null || path == null) return false; + Path investigations = investigationsRoot(pickleballDirectory).toAbsolutePath().normalize(); + Path resolved = path.toAbsolutePath().normalize(); + return resolved.equals(investigations) || resolved.startsWith(investigations); + } + + private static Path requireProjectRoot(Path projectRoot) { + if (projectRoot == null) { + throw new IllegalArgumentException("Consumer project root is required."); + } + return projectRoot.toAbsolutePath().normalize(); + } + + private static String requireInvestigationId(String value) { + String id = value == null ? "" : value.trim(); + if (!INVESTIGATION_ID.matcher(id).matches()) { + throw new IllegalArgumentException( + "pkb_investigation_id must be a simple directory name " + + "[A-Za-z0-9][A-Za-z0-9._-]* up to 128 characters." + ); + } + return id; + } + + private static ScenarioIdentity scenarioIdentity(Map raw) { + String name = firstText(raw, "scenarioName"); + String feature = firstText(raw, "feature"); + String scenarioId = firstText(raw, "scenarioId"); + Object scenario = raw.get("scenario"); + if (scenario instanceof String text) { + if (name.isBlank()) name = text.trim(); + } else if (scenario instanceof Map map) { + if (name.isBlank()) name = firstText(map, "name", "scenarioName", "title"); + if (feature.isBlank()) feature = firstText(map, "feature", "uri", "featureUri"); + if (scenarioId.isBlank()) scenarioId = firstText(map, "scenarioId", "id"); + } + return new ScenarioIdentity(name, feature, scenarioId); + } + + private static String normalizeOutcome(String value) { + String normalized = value == null ? "" : value.trim().toLowerCase(Locale.ROOT) + .replace('_', '-') + .replace(' ', '-'); + if (normalized.equals(OUTCOME_CAUSE_AND_FIX) || normalized.equals("causeandfix")) { + return OUTCOME_CAUSE_AND_FIX; + } + return OUTCOME_CAUSE_ONLY; + } + + private static String normalizeCategory(String value) { + if (value == null || value.isBlank()) return ""; + String normalized = value.trim().toLowerCase(Locale.ROOT); + return switch (normalized) { + case "selector", "gherkin", "java", "data", "other" -> normalized; + default -> value.trim(); + }; + } + + private static Object normalizeFailureSite(Object value) { + if (value == null) return null; + if (value instanceof String text) return text.trim(); + if (value instanceof Number || value instanceof Boolean) return value; + if (value instanceof Map map) { + Map copy = new LinkedHashMap<>(); + map.forEach((key, child) -> { + if (key instanceof String name && !name.isBlank() && isJsonValue(child)) { + copy.put(name, child); + } + }); + return copy.isEmpty() ? null : Map.copyOf(copy); + } + return String.valueOf(value); + } + + private static List screenshotPaths(Path projectRoot, Object value) { + if (!(value instanceof List entries)) return List.of(); + List paths = new ArrayList<>(); + for (Object entry : entries) { + if (paths.size() >= MAX_SCREENSHOTS) break; + String raw = screenshotEntry(entry); + if (raw == null) continue; + String relative = projectRelativePath(projectRoot, raw); + if (relative != null) paths.add(relative); + } + return List.copyOf(paths); + } + + private static String screenshotEntry(Object entry) { + if (entry instanceof String text) return text; + if (entry instanceof Map map) { + return firstText(map, "path", "file", "src"); + } + return null; + } + + private static String projectRelativePath(Path projectRoot, String raw) { + if (raw == null) return null; + String trimmed = raw.trim(); + if (trimmed.isEmpty() || trimmed.length() > MAX_PATH_CHARS) return null; + if (trimmed.contains("\n") || trimmed.contains("\r") || trimmed.startsWith("data:")) return null; + try { + Path path = Path.of(trimmed); + Path resolved = path.isAbsolute() + ? path.normalize() + : projectRoot.resolve(trimmed).normalize(); + if (!resolved.startsWith(projectRoot)) return null; + String relative = projectRoot.relativize(resolved).toString().replace('\\', '/'); + if (relative.isEmpty() || relative.startsWith("../")) return null; + return relative; + } catch (Exception ignored) { + return null; + } + } + + private static String pathMarkup(Path projectRoot, Path reportDir, String relative, boolean image) { + Path target = projectRoot.resolve(relative).normalize(); + boolean present = Files.isRegularFile(target); + if (image) { + if (!present) { + return "

Screenshot missing: " + escape(relative) + "

\n"; + } + return "

\""

\n"; + } + if (!present) { + return "" + escape(relative) + " (missing)"; + } + return "" + + escape(relative) + ""; + } + + private static String relativeHref(Path reportDir, Path target) { + Path from = reportDir.toAbsolutePath().normalize(); + Path to = target.toAbsolutePath().normalize(); + return from.relativize(to).toString().replace('\\', '/'); + } + + private static void section(StringBuilder html, String heading, String body) { + html.append("

").append(escape(heading)).append("

\n"); + html.append("
").append(escape(body)).append("
\n
\n"); + } + + private static void definition(StringBuilder html, String term, String value) { + if (value == null || value.isBlank()) return; + html.append("
").append(escape(term)).append("
") + .append(escape(value)).append("
\n"); + } + + private static String failureSiteText(Object failureSite) { + if (failureSite instanceof Map map) { + StringBuilder text = new StringBuilder(); + map.forEach((key, value) -> { + if (!text.isEmpty()) text.append('\n'); + text.append(key).append(": ").append(value); + }); + return text.toString(); + } + return String.valueOf(failureSite); + } + + private static String firstText(Map map, String... keys) { + if (map == null) return ""; + for (String key : keys) { + Object value = map.get(key); + if (value instanceof String text && !text.isBlank()) return text.trim(); + if (value instanceof Number || value instanceof Boolean) return String.valueOf(value); + } + return ""; + } + + private static void putIfPresent(Map map, String key, String value) { + if (value != null && !value.isBlank()) map.put(key, value); + } + + private static boolean isJsonValue(Object value) { + return value == null + || value instanceof String + || value instanceof Number + || value instanceof Boolean + || value instanceof Map + || value instanceof List; + } + + static String encodePretty(Object value) { + StringBuilder json = new StringBuilder(); + encode(json, value, 0); + return json.toString(); + } + + private static void encode(StringBuilder json, Object value, int indent) { + if (value == null) { + json.append("null"); + return; + } + if (value instanceof String text) { + json.append('"').append(escapeJson(text)).append('"'); + return; + } + if (value instanceof Number || value instanceof Boolean) { + json.append(value); + return; + } + if (value instanceof Map map) { + json.append('{'); + if (map.isEmpty()) { + json.append('}'); + return; + } + json.append('\n'); + int index = 0; + for (Map.Entry entry : map.entrySet()) { + pad(json, indent + 1); + json.append('"').append(escapeJson(String.valueOf(entry.getKey()))).append("\": "); + encode(json, entry.getValue(), indent + 1); + index++; + json.append(index < map.size() ? ",\n" : "\n"); + } + pad(json, indent); + json.append('}'); + return; + } + if (value instanceof List list) { + json.append('['); + if (list.isEmpty()) { + json.append(']'); + return; + } + json.append('\n'); + for (int i = 0; i < list.size(); i++) { + pad(json, indent + 1); + encode(json, list.get(i), indent + 1); + json.append(i + 1 < list.size() ? ",\n" : "\n"); + } + pad(json, indent); + json.append(']'); + return; + } + json.append('"').append(escapeJson(String.valueOf(value))).append('"'); + } + + private static void pad(StringBuilder json, int indent) { + json.append(" ".repeat(Math.max(0, indent))); + } + + static String escape(String value) { + if (value == null || value.isEmpty()) return ""; + StringBuilder escaped = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + switch (ch) { + case '&' -> escaped.append("&"); + case '<' -> escaped.append("<"); + case '>' -> escaped.append(">"); + case '"' -> escaped.append("""); + case '\'' -> escaped.append("'"); + default -> escaped.append(ch); + } + } + return escaped.toString(); + } + + private static String escapeJson(String value) { + StringBuilder escaped = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + switch (ch) { + case '"' -> escaped.append("\\\""); + case '\\' -> escaped.append("\\\\"); + case '\n' -> escaped.append("\\n"); + case '\r' -> escaped.append("\\r"); + case '\t' -> escaped.append("\\t"); + default -> { + if (ch < 0x20) { + escaped.append(String.format(Locale.ROOT, "\\u%04x", (int) ch)); + } else { + escaped.append(ch); + } + } + } + } + return escaped.toString(); + } + + private record ScenarioIdentity(String name, String feature, String scenarioId) { } +} diff --git a/pickleball-workbench/AGENTS.md b/pickleball-workbench/AGENTS.md index 718c5e0a..9e72c1be 100644 --- a/pickleball-workbench/AGENTS.md +++ b/pickleball-workbench/AGENTS.md @@ -103,7 +103,7 @@ Workbench provides: java -jar pickleball-workbench-.jar mcp ``` -The MCP adapter is `tools.dscode.workbench.mcp.WorkbenchMcpServer` plus `WorkbenchMcpTools`. It exposes project synchronization/status, interactive worker lifecycle, live Gherkin, Mapping operations, events/evidence, browser/service controls, semantic breakpoints, Step Override authoring, the watched-agent control lease, player-state inspection, gated Save, and sparse diagnostic catalog/run/summary readers through `WorkbenchServices`. Consumer agents use this headless stdio server (`mcp .`), not the Swing GUI. +The MCP adapter is `tools.dscode.workbench.mcp.WorkbenchMcpServer` plus `WorkbenchMcpTools`. It exposes project synchronization/status, interactive worker lifecycle, live Gherkin, Mapping operations, events/evidence, browser/service controls, semantic breakpoints, Step Override authoring, the watched-agent control lease, player-state inspection, gated Save, sparse diagnostic catalog/run/summary readers, and `workbench_investigation_emit` through `WorkbenchServices`. Consumer agents use this headless stdio server (`mcp .`), not the Swing GUI. UI mode cannot share process stdout with stdio MCP. `ui` therefore starts a 127.0.0.1-only JSON attach facade (`WorkbenchAttachServer`) over the same tools and writes `.pickleball/workbench/attach.json` so a Copilot/MCP client can join the visible session. Bind localhost only. Do not launch a second `mcp` process against a running UI. diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java index a43d93c5..95dc025d 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java @@ -21,8 +21,10 @@ import tools.dscode.workbench.worker.WorkbenchLiveSession; import tools.dscode.workbench.worker.WorkbenchWorkerStatus; +import java.io.IOException; import java.nio.file.Path; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Consumer; @@ -376,6 +378,15 @@ public Object diagnosticScenarioSummary(String runId, String scenarioId) { return diagnostics.scenarioSummaryDocument(runId, scenarioId); } + @Override + public Object emitInvestigation(Map investigation) { + try { + return InvestigationHandoff.emit(projectRoot, investigation).sparseResult(); + } catch (IOException failure) { + throw new IllegalStateException("Could not emit investigation handoff.", failure); + } + } + @Override public void close() { lease.detachUi(); diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java index 71b8e97b..ebff8984 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java @@ -25,6 +25,7 @@ import java.nio.file.Path; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.function.Consumer; @@ -149,6 +150,8 @@ ControlBridgeBreakpoint addBreakpoint( Object diagnosticScenarioSummary(String runId, String scenarioId); + Object emitInvestigation(Map investigation); + @Override void close(); } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java index 22bf3bd0..a8b6fa98 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java @@ -209,6 +209,16 @@ private void register() { "scenarioId", stringProperty("Scenario directory name under that run.") ), "runId", "scenarioId"), args -> services.diagnosticScenarioSummary(text(args, "runId"), text(args, "scenarioId"))); + add("workbench_investigation_emit", + "Write .pickleball/investigations//{investigation.json,report.html} from investigation JSON. Returns the relative report.html path only. Does not copy the diagnostic pack or embed PNG bytes.", + schema(Map.of( + "investigation", Map.of( + "type", "object", + "description", "Investigation JSON object. Source of truth written to investigation.json.", + "additionalProperties", true + ) + ), "investigation"), + args -> services.emitInvestigation(investigationObject(args.get("investigation")))); } private void add( @@ -307,6 +317,28 @@ private static boolean bool(Map args, String name, boolean defau return value instanceof Boolean bool ? bool : defaultValue; } + @SuppressWarnings("unchecked") + private Map investigationObject(Object value) { + if (value == null) { + throw new IllegalArgumentException("investigation must be a JSON object."); + } + Object parsed = value; + if (parsed instanceof String text) { + if (text.isBlank()) { + throw new IllegalArgumentException("investigation must be a JSON object."); + } + try { + parsed = json.readValue(text, LinkedHashMap.class); + } catch (Exception failure) { + throw new IllegalArgumentException("investigation must be a JSON object."); + } + } + if (!(parsed instanceof Map)) { + throw new IllegalArgumentException("investigation must be a JSON object."); + } + return json.convertValue(parsed, LinkedHashMap.class); + } + private record ToolBinding( String name, String description, diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/InvestigationHandoffTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/InvestigationHandoffTest.java new file mode 100644 index 00000000..e141d8d3 --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/InvestigationHandoffTest.java @@ -0,0 +1,114 @@ +package tools.dscode.workbench.diagnostics; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import tools.dscode.control.protocol.InvestigationHandoff; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InvestigationHandoffTest { + @TempDir + Path project; + + @Test + void htmlEscapesCauseAndCapsScreenshotsAndNotesMissingImages() throws Exception { + Path run = project.resolve("reports/diagnostic-runs/run-1/scenarios/s1/screenshots"); + Files.createDirectories(run); + Path present = run.resolve("frame-1.png"); + Files.write(present, new byte[]{1, 2, 3}); + + Map raw = new LinkedHashMap<>(); + raw.put("pkb_investigation_id", "checkout-217"); + raw.put("createdAt", "2026-08-25T06:00:00Z"); + raw.put("scenario", Map.of( + "name", "Submit
", + "feature", "features/forms.feature", + "scenarioId", "scenario-1" + )); + raw.put("outcome", "CAUSE_ONLY"); + raw.put("cause", "Selector