From c8bb51e388611dbfd6321efe587dca139a9aec18 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Fri, 10 Jul 2026 00:31:59 +0300 Subject: [PATCH] Add fallback-free real intake clarification templates --- Makefile | 26 +- .../ProductWorkspacePilots.md | 7 +- ...ree_real_intake_clarification_templates.md | 76 ++++++ ...duct_workspace_graph_versioning_roadmap.md | 13 +- ...ree_real_intake_clarification_templates.md | 76 ++++++ .../answers_ready.json | 11 + .../raw_idea_ready.json | 12 + tests/test_idea_intake_clarification_rerun.py | 6 +- ...est_idea_to_spec_clarification_requests.py | 33 ++- tests/test_real_idea_answer_authoring.py | 256 ++++++++++++++++++ ...test_specspace_real_idea_answer_handoff.py | 2 +- tests/test_user_idea_intake_interview.py | 16 +- tools/README.md | 10 +- tools/idea_to_spec_clarification_requests.py | 48 ++++ tools/proposal_promotion_registry.json | 13 + tools/proposal_runtime_registry.json | 73 +++++ tools/real_idea_answer_authoring.py | 246 ++++++++++++++++- tools/user_idea_intake_session.py | 2 + 18 files changed, 891 insertions(+), 35 deletions(-) create mode 100644 docs/archive/proposal_sources/0210_fallback_free_real_intake_clarification_templates.md create mode 100644 docs/proposals/0210_fallback_free_real_intake_clarification_templates.md diff --git a/Makefile b/Makefile index fe0c211f..5ecf3f11 100644 --- a/Makefile +++ b/Makefile @@ -106,6 +106,7 @@ SPECSPACE_REAL_IDEA_ANSWER_INTAKE_SESSION ?= $(REAL_IDEA_SMOKE_RUN_DIR)/user_ide SPECSPACE_REAL_IDEA_ANSWER_IMPORT_PREVIEW_OUTPUT ?= $(REAL_IDEA_SMOKE_RUN_DIR)/specspace_real_idea_answer_import_preview.json SPECSPACE_REAL_IDEA_VALIDATED_ANSWERS_OUTPUT ?= $(REAL_IDEA_SMOKE_RUN_DIR)/idea_intake_clarification_answers.json REAL_IDEA_ANSWER_CONTINUATION_REPORT_OUTPUT ?= $(REAL_IDEA_SMOKE_RUN_DIR)/real_idea_answer_continuation_report.json +REAL_IDEA_ANSWER_CONTINUATION_WORKSPACE_ID ?= SPECSPACE_REAL_IDEA_ENTRY_REQUESTS ?= $(REAL_IDEA_SMOKE_RUN_DIR)/real_idea_entry_requests.json SPECSPACE_REAL_IDEA_ENTRY_WORKSPACE_ID ?= SPECSPACE_REAL_IDEA_ENTRY_WORKSPACE_ID_ARG := $(if $(strip $(SPECSPACE_REAL_IDEA_ENTRY_WORKSPACE_ID)),--workspace-id "$(SPECSPACE_REAL_IDEA_ENTRY_WORKSPACE_ID)",) @@ -762,7 +763,7 @@ real-idea-smoke-continue: .PHONY: real-idea-smoke-answer-template real-idea-smoke-answer-template: - @$(PYTHON) tools/real_idea_answer_authoring.py template --run-dir "$(REAL_IDEA_SMOKE_RUN_DIR)" --stage "$(REAL_IDEA_ANSWER_AUTHORING_STAGE)" $(REAL_IDEA_ANSWER_AUTHORING_REQUESTS_ARG) --output "$(REAL_IDEA_ANSWER_TEMPLATE_OUTPUT)" --report "$(REAL_IDEA_ANSWER_AUTHORING_REPORT_OUTPUT)" + @$(PYTHON) tools/real_idea_answer_authoring.py template --run-dir "$(REAL_IDEA_SMOKE_RUN_DIR)" --stage "$(REAL_IDEA_ANSWER_AUTHORING_STAGE)" $(REAL_IDEA_ANSWER_AUTHORING_REQUESTS_ARG) --output "$(REAL_IDEA_ANSWER_TEMPLATE_OUTPUT)" --report "$(REAL_IDEA_ANSWER_AUTHORING_REPORT_OUTPUT)" --strict .PHONY: real-idea-smoke-validate-answers real-idea-smoke-validate-answers: @@ -848,6 +849,29 @@ real-idea-intake-continue-from-specspace-answers: IDEA_TO_SPEC_CLARIFICATION_OUTPUT="$(REAL_IDEA_SMOKE_RUN_DIR)/idea_to_spec_clarification_requests.json" \ CANDIDATE_SPEC_MATERIALIZATION_OUTPUT_DIR="$(REAL_IDEA_SMOKE_RUN_DIR)/materialized_candidate_specs" \ CANDIDATE_SPEC_MATERIALIZATION_OUTPUT="$(REAL_IDEA_SMOKE_RUN_DIR)/candidate_spec_materialization_report.json" \ + IDEA_TO_SPEC_PROMOTION_GATE_OUTPUT="$(REAL_IDEA_SMOKE_RUN_DIR)/idea_to_spec_promotion_gate.json" \ + ACTIVE_IDEA_TO_SPEC_CANDIDATE_OUTPUT="$(REAL_IDEA_SMOKE_RUN_DIR)/active_idea_to_spec_candidate.json" + +.PHONY: real-idea-intake-continue-without-answers +real-idea-intake-continue-without-answers: + @$(PYTHON) tools/real_idea_answer_authoring.py verify-no-clarification \ + --run-dir "$(REAL_IDEA_SMOKE_RUN_DIR)" \ + --template "$(REAL_IDEA_SMOKE_RUN_DIR)/real_idea_answer_template.json" \ + --workspace-id "$(REAL_IDEA_ANSWER_CONTINUATION_WORKSPACE_ID)" + @$(MAKE) real-idea-intake-active-candidate \ + USER_IDEA_INTAKE_SESSION_OUTPUT="$(REAL_IDEA_SMOKE_RUN_DIR)/user_idea_intake_session.json" \ + CLARIFIED_USER_IDEA_INTAKE_SESSION_OUTPUT="$(REAL_IDEA_SMOKE_RUN_DIR)/.no_clarified_intake_session.json" \ + USER_IDEA_INTAKE_SESSION_SOURCE_OUTPUT="$(REAL_IDEA_SMOKE_RUN_DIR)/user_idea_intake_source.json" \ + INTAKE_SESSION_CANDIDATE_SOURCE_REPORT_OUTPUT="$(REAL_IDEA_SMOKE_RUN_DIR)/intake_session_candidate_source_report.json" \ + USER_IDEA_EVENT_STORMING_SEED_OUTPUT="$(REAL_IDEA_SMOKE_RUN_DIR)/idea_event_storming_seed.json" \ + IDEA_EVENT_STORMING_INTAKE_OUTPUT="$(REAL_IDEA_SMOKE_RUN_DIR)/idea_event_storming_intake.json" \ + PRODUCT_WORKSPACE_CANDIDATE_SEED_OUTPUT="$(REAL_IDEA_SMOKE_RUN_DIR)/candidate_spec_graph_seed.json" \ + CANDIDATE_SPEC_GRAPH_OUTPUT="$(REAL_IDEA_SMOKE_RUN_DIR)/candidate_spec_graph.json" \ + PRE_SIB_COHERENCE_OUTPUT="$(REAL_IDEA_SMOKE_RUN_DIR)/pre_sib_coherence_report.json" \ + CANDIDATE_REPAIR_LOOP_OUTPUT="$(REAL_IDEA_SMOKE_RUN_DIR)/candidate_repair_loop_report.json" \ + IDEA_TO_SPEC_CLARIFICATION_OUTPUT="$(REAL_IDEA_SMOKE_RUN_DIR)/idea_to_spec_clarification_requests.json" \ + CANDIDATE_SPEC_MATERIALIZATION_OUTPUT_DIR="$(REAL_IDEA_SMOKE_RUN_DIR)/materialized_candidate_specs" \ + CANDIDATE_SPEC_MATERIALIZATION_OUTPUT="$(REAL_IDEA_SMOKE_RUN_DIR)/candidate_spec_materialization_report.json" \ IDEA_TO_SPEC_PROMOTION_GATE_OUTPUT="$(REAL_IDEA_SMOKE_RUN_DIR)/idea_to_spec_promotion_gate.json" \ ACTIVE_IDEA_TO_SPEC_CANDIDATE_OUTPUT="$(REAL_IDEA_SMOKE_RUN_DIR)/active_idea_to_spec_candidate.json" diff --git a/Sources/SpecGraph/Documentation.docc/ProductWorkspacePilots.md b/Sources/SpecGraph/Documentation.docc/ProductWorkspacePilots.md index cffa0f57..1affa6e1 100644 --- a/Sources/SpecGraph/Documentation.docc/ProductWorkspacePilots.md +++ b/Sources/SpecGraph/Documentation.docc/ProductWorkspacePilots.md @@ -732,9 +732,10 @@ authority expansion. The next producer-side slices should keep using Idea Maturity and repair contracts instead of creating a second depth metric: -1. real intake should emit browser-answerable clarification templates, or an - explicit `clarification_not_required` state, so SpecSpace product demos do - not need deterministic answer fallback fixtures; +1. proposal `0210` makes real-intake clarification fallback-free: + workspace-bound templates now publish `answers_required`, + `clarification_not_required`, or `clarification_blocked`, and mandatory + policy context is represented as an ordinary browser-answerable target; 2. candidate graphs should keep stable machine ids while publishing human-friendly display aliases for generated nodes, candidate overview, PR artifacts, and topology/readiness panels; diff --git a/docs/archive/proposal_sources/0210_fallback_free_real_intake_clarification_templates.md b/docs/archive/proposal_sources/0210_fallback_free_real_intake_clarification_templates.md new file mode 100644 index 00000000..4c297c66 --- /dev/null +++ b/docs/archive/proposal_sources/0210_fallback_free_real_intake_clarification_templates.md @@ -0,0 +1,76 @@ +# 0210 Fallback-Free Real Intake Clarification Templates + +## Status + +Draft / producer-side real-intake clarification contract slice. + +## Summary + +The UI-started product demo currently falls back to deterministic clarification +fixtures when SpecGraph's real-intake artifacts are not usable in a non-demo +workspace. The underlying request and answer-template tools already produce +typed targets, but the artifacts do not carry enough workspace identity to be +selected safely, policy context is not part of the mandatory intake questions, +and an empty or unsupported template is not distinguished from a legitimate +"no clarification required" outcome. + +This slice makes the existing answer-template contract authoritative enough for +browser consumers without adding a second clarification protocol. + +## Decision + +The existing `idea_to_spec_clarification_requests` and +`real_idea_answer_template` artifacts carry: + +- `workspace_id`, `candidate_id`, and a sanitized `workspace` identity; +- a stable digest binding the template to its clarification request source; +- one explicit `clarification_outcome`: + - `answers_required`; + - `clarification_not_required`; + - `clarification_blocked`; +- typed browser-answerable targets for every mandatory request; +- public-safe findings when a mandatory request cannot be represented. + +Real intake requires policy context alongside actors, commands, domain events, +and constraints. Missing policy context therefore becomes an ordinary +`event_storming_hints.policies` clarification target. + +A ready complete intake emits `clarification_not_required` with no answer +targets. A mandatory request with an unsupported action or value shape emits +`clarification_blocked` and fails strict template generation. A blocked +generation does not overwrite an existing ready template. + +## Authority Boundary + +This slice does not: + +- execute a prompt agent or infer product answers; +- auto-fill clarification answers; +- mutate canonical specs or accepted candidate artifacts; +- write Ontology packages or lockfiles; +- accept Ontology terms; +- approve candidates or change promotion gates; +- create Git branches, commits, or pull requests; +- publish a read model; +- publish raw idea text, prompts, model output, or operator notes. + +SpecSpace remains an operator-answer surface. Platform remains the controlled +execution boundary. + +## Acceptance Criteria + +- Incomplete real ideas produce workspace-bound browser-answerable answer + templates without deterministic fixture fallback. +- Complete real ideas produce an explicit `clarification_not_required` + outcome and can continue safely without an answer set. +- Unsupported mandatory clarification requests produce + `clarification_blocked`, strict failure, and public-safe findings. +- Template source refs and digests bind to the current workspace/session. +- Stale or cross-workspace artifacts cannot be selected as current intake + evidence. +- Failed generation does not clobber a pre-existing ready template. +- Raw idea text does not appear in request, template, report, or public bundle + artifacts. +- The standard SpecSpace product demo completes with + `clarification_fallback_used=false`. + diff --git a/docs/product_workspace_graph_versioning_roadmap.md b/docs/product_workspace_graph_versioning_roadmap.md index 8bc2093a..35922f2b 100644 --- a/docs/product_workspace_graph_versioning_roadmap.md +++ b/docs/product_workspace_graph_versioning_roadmap.md @@ -1640,13 +1640,12 @@ contracts. Do not add another competing "depth score" or product-quality metric. The next producer-side work should instead make the existing idea-to-spec flow more reliable and easier for SpecSpace to present: -1. **Fallback-free real-intake clarification templates.** Real intake should - emit either a browser-answerable `real_idea_answer_template` or an explicit - `clarification_not_required` state. The current SpecSpace product-demo - fallback remains a temporary local harness policy, not a producer contract. - A strict UI-started demo should be able to pass without deterministic answer - fixtures while still proving that the candidate came from the new workspace - rather than the Team Decision Log pilot. +1. **Fallback-free real-intake clarification templates.** Implemented + producer-side in proposal `0210`. Real intake now emits a workspace-bound + browser-answerable `real_idea_answer_template`, an explicit + `clarification_not_required` outcome, or a strict + `clarification_blocked` finding. SpecSpace can remove its deterministic + product-demo fallback and rely on producer evidence. 2. **Human-friendly candidate display aliases.** Keep stable machine ids for refs, materialization, and promotion paths, but add deterministic display aliases or readable labels for long generated candidate node ids. Candidate diff --git a/docs/proposals/0210_fallback_free_real_intake_clarification_templates.md b/docs/proposals/0210_fallback_free_real_intake_clarification_templates.md new file mode 100644 index 00000000..4c297c66 --- /dev/null +++ b/docs/proposals/0210_fallback_free_real_intake_clarification_templates.md @@ -0,0 +1,76 @@ +# 0210 Fallback-Free Real Intake Clarification Templates + +## Status + +Draft / producer-side real-intake clarification contract slice. + +## Summary + +The UI-started product demo currently falls back to deterministic clarification +fixtures when SpecGraph's real-intake artifacts are not usable in a non-demo +workspace. The underlying request and answer-template tools already produce +typed targets, but the artifacts do not carry enough workspace identity to be +selected safely, policy context is not part of the mandatory intake questions, +and an empty or unsupported template is not distinguished from a legitimate +"no clarification required" outcome. + +This slice makes the existing answer-template contract authoritative enough for +browser consumers without adding a second clarification protocol. + +## Decision + +The existing `idea_to_spec_clarification_requests` and +`real_idea_answer_template` artifacts carry: + +- `workspace_id`, `candidate_id`, and a sanitized `workspace` identity; +- a stable digest binding the template to its clarification request source; +- one explicit `clarification_outcome`: + - `answers_required`; + - `clarification_not_required`; + - `clarification_blocked`; +- typed browser-answerable targets for every mandatory request; +- public-safe findings when a mandatory request cannot be represented. + +Real intake requires policy context alongside actors, commands, domain events, +and constraints. Missing policy context therefore becomes an ordinary +`event_storming_hints.policies` clarification target. + +A ready complete intake emits `clarification_not_required` with no answer +targets. A mandatory request with an unsupported action or value shape emits +`clarification_blocked` and fails strict template generation. A blocked +generation does not overwrite an existing ready template. + +## Authority Boundary + +This slice does not: + +- execute a prompt agent or infer product answers; +- auto-fill clarification answers; +- mutate canonical specs or accepted candidate artifacts; +- write Ontology packages or lockfiles; +- accept Ontology terms; +- approve candidates or change promotion gates; +- create Git branches, commits, or pull requests; +- publish a read model; +- publish raw idea text, prompts, model output, or operator notes. + +SpecSpace remains an operator-answer surface. Platform remains the controlled +execution boundary. + +## Acceptance Criteria + +- Incomplete real ideas produce workspace-bound browser-answerable answer + templates without deterministic fixture fallback. +- Complete real ideas produce an explicit `clarification_not_required` + outcome and can continue safely without an answer set. +- Unsupported mandatory clarification requests produce + `clarification_blocked`, strict failure, and public-safe findings. +- Template source refs and digests bind to the current workspace/session. +- Stale or cross-workspace artifacts cannot be selected as current intake + evidence. +- Failed generation does not clobber a pre-existing ready template. +- Raw idea text does not appear in request, template, report, or public bundle + artifacts. +- The standard SpecSpace product demo completes with + `clarification_fallback_used=false`. + diff --git a/tests/fixtures/idea_intake_clarification/answers_ready.json b/tests/fixtures/idea_intake_clarification/answers_ready.json index 268a4e8b..1155d0fa 100644 --- a/tests/fixtures/idea_intake_clarification/answers_ready.json +++ b/tests/fixtures/idea_intake_clarification/answers_ready.json @@ -90,6 +90,17 @@ ] } }, + { + "answer_kind": "answer_question", + "authority": "operator_approved", + "request_id": "clarification.intake.question-event-storming-policies", + "status": "accepted_for_candidate", + "value": { + "entries": [ + "Decision Review Policy" + ] + } + }, { "answer_kind": "answer_question", "authority": "operator_approved", diff --git a/tests/fixtures/user_idea_intake_session/raw_idea_ready.json b/tests/fixtures/user_idea_intake_session/raw_idea_ready.json index ef949c04..d73b340a 100644 --- a/tests/fixtures/user_idea_intake_session/raw_idea_ready.json +++ b/tests/fixtures/user_idea_intake_session/raw_idea_ready.json @@ -82,6 +82,18 @@ "name": "Case Escalated" } ], + "policies": [ + { + "command_refs": [ + "command.escalate-case" + ], + "id": "policy.escalation-review", + "name": "Escalation Review Policy", + "trigger_event_refs": [ + "event.case-escalated" + ] + } + ], "risks": [ { "id": "risk.unowned-escalation", diff --git a/tests/test_idea_intake_clarification_rerun.py b/tests/test_idea_intake_clarification_rerun.py index a2acdaeb..139ec852 100644 --- a/tests/test_idea_intake_clarification_rerun.py +++ b/tests/test_idea_intake_clarification_rerun.py @@ -122,7 +122,7 @@ def build_intake_requests(tmp_path: Path, session: Path) -> Path: text=True, ) assert result.returncode == 0, result.stderr - assert load_json(requests)["summary"]["blocking_request_count"] == 9 + assert load_json(requests)["summary"]["blocking_request_count"] == 10 return requests @@ -174,7 +174,7 @@ def test_idea_intake_clarification_rerun_materializes_clarified_session( rerun_report = load_json(report) assert rerun["artifact_kind"] == "idea_intake_answer_rerun_input" assert rerun["readiness"]["ready"] is True - assert len(rerun["accepted_answer_targets"]) == 9 + assert len(rerun["accepted_answer_targets"]) == 10 assert session_payload["readiness"]["review_state"] == "ready_for_event_storming_intake" assert not clarified_source.exists() assert rerun_report["summary"]["ready_for_candidate_source"] is True @@ -358,7 +358,7 @@ def test_real_idea_intake_clarification_requests_preserves_existing_session( request_payload = load_json(requests) assert after == before assert after["workspace"]["candidate_id"] == "team-decision-log" - assert request_payload["summary"]["blocking_request_count"] == 9 + assert request_payload["summary"]["blocking_request_count"] == 10 def test_real_idea_intake_active_candidate_target_builds_seed_first( diff --git a/tests/test_idea_to_spec_clarification_requests.py b/tests/test_idea_to_spec_clarification_requests.py index 69ee7c6c..35a23e48 100644 --- a/tests/test_idea_to_spec_clarification_requests.py +++ b/tests/test_idea_to_spec_clarification_requests.py @@ -15,6 +15,7 @@ INTAKE_TOOL_PATH = ROOT / "tools" / "idea_event_storming_intake.py" REPAIR_TOOL_PATH = ROOT / "tools" / "candidate_repair_loop.py" SESSION_FIXTURE = ROOT / "tests" / "fixtures" / "user_idea_intake_session" +READY_FIXTURE = SESSION_FIXTURE / "raw_idea_ready.json" NEEDS_CLARIFICATION_FIXTURE = SESSION_FIXTURE / "raw_idea_needs_clarification.json" INTAKE_REVIEW_REQUIRED = ( ROOT / "tests" / "fixtures" / "idea_event_storming_intake" / "idea_review_required.json" @@ -100,11 +101,16 @@ def test_clarification_requests_collects_intake_session_questions() -> None: assert report["canonical_mutations_allowed"] is False assert report["tracked_artifacts_written"] is False assert report["readiness"]["ready"] is False - assert report["summary"]["request_count"] == 9 - assert report["summary"]["blocking_request_count"] == 9 + assert report["summary"]["request_count"] == 10 + assert report["summary"]["blocking_request_count"] == 10 assert report["summary"]["review_required_request_count"] == 0 + assert report["clarification_outcome"] == "answers_required" + assert report["workspace_id"] == "i-want-a-small-tool-for-team-decisions" + assert report["candidate_id"] == "i-want-a-small-tool-for-team-decisions" + assert "0210" in report["contract_extensions"] assert "clarification.intake.question-active-frame-ontology-refs" in request_ids(report) assert "clarification.intake.question-event-storming-actors" in request_ids(report) + assert "clarification.intake.question-event-storming-policies" in request_ids(report) assert request_kinds(report) == {"missing_context", "missing_event_storming_context"} dumped = json.dumps(report) raw_text = load_json(NEEDS_CLARIFICATION_FIXTURE)["idea"]["text"] @@ -112,6 +118,25 @@ def test_clarification_requests_collects_intake_session_questions() -> None: assert "private prompt trace" not in dumped +def test_clarification_requests_marks_ready_session_not_required() -> None: + session_module = load_module(SESSION_TOOL_PATH, "user_idea_session_without_clarifications") + clarification_module = load_module(TOOL_PATH, "clarification_requests_not_required") + session, source = session_module.build_user_idea_intake_session( + load_json(READY_FIXTURE), + source_path=READY_FIXTURE, + ) + + report = clarification_module.build_idea_to_spec_clarification_requests( + user_idea_intake_session=session, + ) + + assert source is not None + assert report["clarification_outcome"] == "clarification_not_required" + assert report["readiness"]["ready"] is True + assert report["summary"]["request_count"] == 0 + assert report["workspace_id"] == "support-triage-log" + + def test_clarification_requests_project_repair_context_actions() -> None: repair_module = load_module(REPAIR_TOOL_PATH, "repair_loop_for_clarifications") clarification_module = load_module(TOOL_PATH, "clarification_requests_for_repair_loop") @@ -242,7 +267,7 @@ def test_clarification_requests_cli_writes_output(tmp_path: Path) -> None: assert result.returncode == 0 report = load_json(output_path) assert report["artifact_kind"] == "idea_to_spec_clarification_requests" - assert report["summary"]["request_count"] == 14 + assert report["summary"]["request_count"] == 15 assert "clarification_required" in result.stdout repair_request = request_by_id( report, @@ -484,5 +509,5 @@ def test_product_workspace_raw_idea_writes_clarification_before_abort(tmp_path: assert output_path.exists() report = load_json(output_path) assert report["readiness"]["ready"] is False - assert report["summary"]["blocking_request_count"] == 9 + assert report["summary"]["blocking_request_count"] == 10 assert not (tmp_path / "source.json").exists() diff --git a/tests/test_real_idea_answer_authoring.py b/tests/test_real_idea_answer_authoring.py index 95d387ea..71ef047a 100644 --- a/tests/test_real_idea_answer_authoring.py +++ b/tests/test_real_idea_answer_authoring.py @@ -2,6 +2,7 @@ import importlib.util import json +import shutil import subprocess import sys from pathlib import Path @@ -117,6 +118,106 @@ def test_answer_template_exposes_typed_targets(tmp_path: Path) -> None: assert template["authority_boundary"]["may_write_ontology_package"] is False +def test_intake_answer_template_is_workspace_bound_and_answers_required( + tmp_path: Path, +) -> None: + module = load_module() + requests_path = build_intake_requests(tmp_path) + requests = load_json(requests_path) + + template = module.build_template( + clarification_requests=requests, + requests_path=requests_path, + stage="intake", + run_dir=tmp_path, + ) + + assert template["contract_ref"] == "specgraph.idea-to-spec.real-idea-answer-template.v0.2" + assert template["clarification_outcome"] == "answers_required" + assert template["readiness"]["ready"] is True + assert template["workspace_id"] == "team-decision-log" + assert template["candidate_id"] == "team-decision-log" + assert template["summary"]["answerable_target_count"] == 10 + assert template["summary"]["unsupported_target_count"] == 0 + assert template["source_artifacts"]["clarification_requests"]["source_digest"].startswith( + "sha256:" + ) + assert "0210" in template["contract_extensions"] + + +def test_intake_answer_template_marks_clarification_not_required(tmp_path: Path) -> None: + module = load_module() + requests = { + "artifact_kind": "idea_to_spec_clarification_requests", + "schema_version": 1, + "contract_ref": "specgraph.idea-to-spec.clarification-requests.v0.1", + "workspace_id": "complete-idea", + "candidate_id": "complete-idea", + "workspace": { + "workspace_id": "complete-idea", + "candidate_id": "complete-idea", + "display_name": "Complete Idea", + "public_route": "/complete-idea", + }, + "clarification_outcome": "clarification_not_required", + "clarification_requests": [], + "readiness": {"ready": True, "review_state": "clarification_clear"}, + } + requests_path = tmp_path / "clarification_not_required.json" + write_json(requests_path, requests) + + template = module.build_template( + clarification_requests=requests, + requests_path=requests_path, + stage="intake", + run_dir=tmp_path, + ) + + assert template["clarification_outcome"] == "clarification_not_required" + assert template["readiness"]["ready"] is True + assert template["readiness"]["next_artifact"] == "user_idea_intake_source.json" + assert template["answer_targets"] == [] + assert template["findings"] == [] + + +def test_intake_answer_template_blocks_unsupported_mandatory_request(tmp_path: Path) -> None: + module = load_module() + requests = { + "artifact_kind": "idea_to_spec_clarification_requests", + "schema_version": 1, + "contract_ref": "specgraph.idea-to-spec.clarification-requests.v0.1", + "workspace_id": "unsupported-idea", + "candidate_id": "unsupported-idea", + "workspace": {"candidate_id": "unsupported-idea"}, + "clarification_outcome": "answers_required", + "clarification_requests": [ + { + "id": "clarification.unsupported", + "kind": "unsupported", + "severity": "blocking", + "status": "open", + "question": "Provide an unsupported executable answer.", + "target_ref": "unsupported.target", + "suggested_actions": ["execute_custom_resolver"], + "suggested_answer_shape": "custom_binary_payload", + } + ], + } + requests_path = tmp_path / "unsupported_requests.json" + write_json(requests_path, requests) + + template = module.build_template( + clarification_requests=requests, + requests_path=requests_path, + stage="intake", + run_dir=tmp_path, + ) + + assert template["clarification_outcome"] == "clarification_blocked" + assert template["readiness"]["ready"] is False + assert "clarification_request_not_browser_answerable" in finding_ids(template) + + def test_answer_authoring_validate_accepts_filled_template(tmp_path: Path) -> None: module = load_module() template_path = filled_repair_template(tmp_path) @@ -613,6 +714,161 @@ def test_answer_authoring_cli_rejects_outputs_outside_run_dir(tmp_path: Path) -> assert "--output must stay inside REAL_IDEA_SMOKE_RUN_DIR" in result.stderr +def test_blocked_template_does_not_clobber_existing_ready_output(tmp_path: Path) -> None: + run_dir_ref = Path(".pytest_cache") / "answer_template_no_clobber" / tmp_path.name + run_dir = ROOT / run_dir_ref + requests_path = run_dir / "unsupported_requests.json" + output_path = run_dir / "real_idea_answer_template.json" + report_path = run_dir / "real_idea_answer_authoring_report.json" + existing = { + "artifact_kind": "real_idea_answer_template", + "contract_ref": "specgraph.idea-to-spec.real-idea-answer-template.v0.2", + "clarification_outcome": "answers_required", + "sentinel": "preserve-ready-template", + "readiness": {"ready": True, "review_state": "answers_required"}, + } + unsupported = { + "artifact_kind": "idea_to_spec_clarification_requests", + "contract_ref": "specgraph.idea-to-spec.clarification-requests.v0.1", + "workspace_id": "no-clobber", + "candidate_id": "no-clobber", + "workspace": {"candidate_id": "no-clobber"}, + "clarification_outcome": "answers_required", + "clarification_requests": [ + { + "id": "clarification.unsupported", + "kind": "unsupported", + "severity": "blocking", + "status": "open", + "question": "Unsupported question", + "target_ref": "unsupported.target", + "suggested_actions": ["execute_custom_resolver"], + } + ], + } + try: + write_json(requests_path, unsupported) + write_json(output_path, existing) + result = subprocess.run( + [ + sys.executable, + str(TOOL_PATH), + "template", + "--run-dir", + str(run_dir_ref), + "--stage", + "intake", + "--requests", + str(requests_path), + "--output", + str(output_path), + "--report", + str(report_path), + "--strict", + ], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 1 + assert load_json(output_path)["sentinel"] == "preserve-ready-template" + report = load_json(report_path) + assert report["outputs"]["template_written"] is False + assert report["outputs"]["preserved_existing_template"] is True + finally: + shutil.rmtree(run_dir, ignore_errors=True) + + +def test_verify_no_clarification_requires_matching_workspace(tmp_path: Path) -> None: + module = load_module() + run_dir_ref = Path(".pytest_cache") / "no_clarification_verify" / tmp_path.name + run_dir = ROOT / run_dir_ref + template_path = run_dir / "real_idea_answer_template.json" + requests = { + "artifact_kind": "idea_to_spec_clarification_requests", + "contract_ref": "specgraph.idea-to-spec.clarification-requests.v0.1", + "workspace_id": "complete-idea", + "candidate_id": "complete-idea", + "workspace": {"candidate_id": "complete-idea"}, + "clarification_outcome": "clarification_not_required", + "clarification_requests": [], + "readiness": {"ready": True, "review_state": "clarification_clear"}, + } + try: + template = module.build_template( + clarification_requests=requests, + requests_path=run_dir / "idea_intake_clarification_requests.json", + stage="intake", + run_dir=run_dir, + ) + write_json(run_dir / "idea_intake_clarification_requests.json", requests) + write_json(template_path, template) + success = subprocess.run( + [ + sys.executable, + str(TOOL_PATH), + "verify-no-clarification", + "--run-dir", + str(run_dir_ref), + "--template", + str(template_path), + "--workspace-id", + "complete-idea", + ], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + mismatch = subprocess.run( + [ + sys.executable, + str(TOOL_PATH), + "verify-no-clarification", + "--run-dir", + str(run_dir_ref), + "--template", + str(template_path), + "--workspace-id", + "foreign-idea", + ], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + + assert success.returncode == 0 + assert mismatch.returncode == 1 + assert "workspace_id" in mismatch.stdout + + requests["clarification_outcome"] = "answers_required" + write_json(run_dir / "idea_intake_clarification_requests.json", requests) + stale_source = subprocess.run( + [ + sys.executable, + str(TOOL_PATH), + "verify-no-clarification", + "--run-dir", + str(run_dir_ref), + "--template", + str(template_path), + "--workspace-id", + "complete-idea", + ], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + assert stale_source.returncode == 1 + assert "source_digest" in stale_source.stdout + finally: + shutil.rmtree(run_dir, ignore_errors=True) + + def test_answer_authoring_outputs_are_real_idea_smoke_managed_outputs() -> None: spec = importlib.util.spec_from_file_location("real_idea_smoke_under_test", SMOKE_TOOL) assert spec and spec.loader diff --git a/tests/test_specspace_real_idea_answer_handoff.py b/tests/test_specspace_real_idea_answer_handoff.py index 68a4f9ca..18892e32 100644 --- a/tests/test_specspace_real_idea_answer_handoff.py +++ b/tests/test_specspace_real_idea_answer_handoff.py @@ -216,7 +216,7 @@ def test_specspace_real_idea_answer_import_preview_accepts_state() -> None: assert preview["artifact_kind"] == "specspace_real_idea_answer_import_preview" assert preview["readiness"]["ready"] is True - assert preview["summary"]["accepted_answer_count"] == 9 + assert preview["summary"]["accepted_answer_count"] == 10 dumped = json.dumps(preview) assert "I want a small tool for team decisions" not in dumped assert "/Users/" not in dumped diff --git a/tests/test_user_idea_intake_interview.py b/tests/test_user_idea_intake_interview.py index 2bd89289..de135832 100644 --- a/tests/test_user_idea_intake_interview.py +++ b/tests/test_user_idea_intake_interview.py @@ -143,6 +143,8 @@ def test_real_idea_interview_cli_hints_write_ready_source(tmp_path: Path) -> Non "Subscription Recorded", "--command", "Add Subscription", + "--policy", + "Upcoming renewals require owner review.", "--constraint", "Data remains local to the device.", *output_args(tmp_path), @@ -227,6 +229,11 @@ def test_real_idea_interview_applies_clarification_answers(tmp_path: Path) -> No "target_ref": "event_storming_hints.commands", "suggested_actions": ["answer_question"], }, + { + "id": "q.policy", + "target_ref": "event_storming_hints.policies", + "suggested_actions": ["answer_question"], + }, { "id": "q.constraint", "target_ref": "event_storming_hints.constraints", @@ -295,6 +302,13 @@ def test_real_idea_interview_applies_clarification_answers(tmp_path: Path) -> No "authority": "operator_approved", "value": {"entries": ["Record Decision"]}, }, + { + "request_id": "q.policy", + "answer_kind": "answer_question", + "status": "accepted_for_candidate", + "authority": "operator_approved", + "value": {"entries": ["Decision Review Policy"]}, + }, { "request_id": "q.constraint", "answer_kind": "answer_question", @@ -333,7 +347,7 @@ def test_real_idea_interview_applies_clarification_answers(tmp_path: Path) -> No assert result.returncode == 0, result.stderr report = load_json(tmp_path / "user_idea_intake_interview_report.json") source = load_json(tmp_path / "user_idea_intake_source.json") - assert report["clarification_answer_application"]["applied_count"] == 9 + assert report["clarification_answer_application"]["applied_count"] == 10 assert source["active_frame_hints"]["domain_refs"] == ["domain.team_decision_log"] assert source["event_storming_hints"]["domain_events"][0]["name"] == ("Decision Recorded") diff --git a/tools/README.md b/tools/README.md index db3f4e78..6852bd87 100644 --- a/tools/README.md +++ b/tools/README.md @@ -419,6 +419,12 @@ Supervisor modes: evidence, remaining shallow dimensions, and an effect status. This is report-only visibility, not a Metrics schema change, score, gate, or authority expansion. +- Proposal 0210 makes real-intake clarification templates fallback-free. + Clarification requests and answer templates carry workspace identity and a + stable source digest, publish `answers_required`, + `clarification_not_required`, or `clarification_blocked`, and treat + missing policy context as an ordinary typed intake question. Strict blocked + generation preserves an existing ready template. - `tools/user_idea_intake_source.py`: deterministic generic user-idea source builder introduced by proposal 0158. Use `make user-idea-intake-source USER_IDEA_INTAKE_SOURCE=` to normalize product workspace identity, @@ -1363,7 +1369,9 @@ Key derived artifacts: - `runs/real_idea_answer_template.json`: operator-editable, public-safe answer template introduced by proposal 0194. It records typed answer targets, accepted actions, required fields, evidence refs, and false authority flags - for the current real-idea smoke run directory. + for the current real-idea smoke run directory. Proposal 0210 adds workspace + identity, source digest binding, and explicit fallback-free clarification + outcomes. - `runs/real_idea_answer_authoring_report.json`: review-only validation or materialization report for first-class real-idea answer authoring. It records source request refs, answer-set digest, validation status, output refs, and diff --git a/tools/idea_to_spec_clarification_requests.py b/tools/idea_to_spec_clarification_requests.py index 4bf25f67..dca640ac 100644 --- a/tools/idea_to_spec_clarification_requests.py +++ b/tools/idea_to_spec_clarification_requests.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import hashlib import json import re from datetime import datetime, timezone @@ -13,6 +14,7 @@ PROPOSAL_ID = "0163" DEPTH_DRIVEN_PROPOSAL_ID = "0207" WORKFLOW_TOPOLOGY_REPAIR_PROPOSAL_ID = "0208" +FALLBACK_FREE_INTAKE_PROPOSAL_ID = "0210" SCHEMA_VERSION = 1 CONTRACT_REF = "specgraph.idea-to-spec.clarification-requests.v0.1" USER_IDEA_INTAKE_SESSION_CONTRACT_REF = "specgraph.idea-to-spec.user-idea-intake-session.v0.1" @@ -222,6 +224,32 @@ def _public_safe(value: Any) -> Any: return value +def _stable_digest(value: dict[str, Any]) -> str: + stable = _public_safe(value) + if isinstance(stable, dict): + stable = {key: item for key, item in stable.items() if key != "generated_at"} + encoded = json.dumps(stable, sort_keys=True, separators=(",", ":")).encode("utf-8") + return f"sha256:{hashlib.sha256(encoded).hexdigest()}" + + +def _workspace_identity(*artifacts: dict[str, Any] | None) -> dict[str, str]: + for artifact in artifacts: + if not artifact: + continue + workspace = _dict(artifact.get("workspace")) + candidate_id = _text(workspace.get("candidate_id")) or _text(artifact.get("candidate_id")) + workspace_id = _text(artifact.get("workspace_id")) or candidate_id + if not workspace_id: + continue + return { + "workspace_id": workspace_id, + "candidate_id": candidate_id or workspace_id, + "display_name": _text(workspace.get("display_name"), workspace_id), + "public_route": _text(workspace.get("public_route"), f"/{workspace_id}"), + } + return {} + + def load_json(path: Path) -> dict[str, Any]: payload = json.loads(path.read_text(encoding="utf-8")) if not isinstance(payload, dict): @@ -271,6 +299,7 @@ def _source_artifact( "status": status, "artifact_kind": artifact.get("artifact_kind") if artifact else None, "contract_ref": artifact.get("contract_ref") if artifact else None, + "source_digest": _stable_digest(artifact) if artifact else None, "review_state": _dict(artifact.get("readiness")).get("review_state") if artifact else artifact.get("review_state") @@ -1089,15 +1118,34 @@ def build_idea_to_spec_clarification_requests( if blocking_count or findings else "clarification_review_required" ) + workspace = _workspace_identity( + user_idea_intake_session, + idea_event_storming_intake, + candidate_graph, + pre_sib_report, + repair_loop, + ontology_gap_review, + idea_maturity_report, + ) + clarification_outcome = "answers_required" + if findings: + clarification_outcome = "clarification_blocked" + elif not open_requests: + clarification_outcome = "clarification_not_required" return { "artifact_kind": "idea_to_spec_clarification_requests", "schema_version": SCHEMA_VERSION, "proposal_id": PROPOSAL_ID, + "contract_extensions": [FALLBACK_FREE_INTAKE_PROPOSAL_ID], "contract_ref": CONTRACT_REF, "generated_at": _now_iso(), "canonical_mutations_allowed": False, "tracked_artifacts_written": False, "source_artifacts": source_artifacts or [], + "workspace_id": workspace.get("workspace_id"), + "candidate_id": workspace.get("candidate_id"), + "workspace": workspace or None, + "clarification_outcome": clarification_outcome, "request_counts": { "total": len(requests), "by_severity": severity_counts, diff --git a/tools/proposal_promotion_registry.json b/tools/proposal_promotion_registry.json index bc37e724..43b05398 100644 --- a/tools/proposal_promotion_registry.json +++ b/tools/proposal_promotion_registry.json @@ -2702,5 +2702,18 @@ "required_provenance_links": [ "source_draft_ref" ] + }, + { + "proposal_id": "0210", + "source_artifact_class": "working_draft", + "source_refs": [ + "docs/archive/proposal_sources/0210_fallback_free_real_intake_clarification_templates.md" + ], + "motivating_concern": "UI-started real idea demos still require deterministic clarification fixtures because producer artifacts lack workspace binding, explicit no-clarification semantics, and complete browser-answerable policy context.", + "normalized_title": "Fallback-Free Real Intake Clarification Templates", + "bounded_scope": "Emit workspace-bound browser-answerable real-intake clarification templates or explicit clarification_not_required and clarification_blocked outcomes, including policy questions and stable source digest evidence, without executing prompt agents, auto-answering questions, mutating canonical specs, writing Ontology packages or lockfiles, accepting ontology terms, approving candidates, creating Git artifacts, or publishing read models.", + "required_provenance_links": [ + "source_draft_ref" + ] } ] diff --git a/tools/proposal_runtime_registry.json b/tools/proposal_runtime_registry.json index 948a6e5e..73316bf3 100644 --- a/tools/proposal_runtime_registry.json +++ b/tools/proposal_runtime_registry.json @@ -11968,5 +11968,78 @@ } ], "notes": "SpecGraph now emits review-only structural_depth_delta evidence from depth repair reruns so downstream surfaces can show what changed, what was added, and which shallow dimensions remain without adding metrics, scores, gates, or authority." + }, + { + "proposal_id": "0210", + "posture": "bounded_runtime_followup", + "runtime_surfaces": [ + "docs/proposals/0210_fallback_free_real_intake_clarification_templates.md", + "docs/archive/proposal_sources/0210_fallback_free_real_intake_clarification_templates.md", + "tools/user_idea_intake_session.py", + "tools/idea_to_spec_clarification_requests.py", + "tools/real_idea_answer_authoring.py", + "Makefile", + "tests/fixtures/user_idea_intake_session/raw_idea_ready.json", + "tests/fixtures/idea_intake_clarification/answers_ready.json", + "tests/test_user_idea_intake_session.py", + "tests/test_idea_to_spec_clarification_requests.py", + "tests/test_real_idea_answer_authoring.py", + "tests/test_specspace_real_idea_answer_handoff.py", + "tests/test_user_idea_intake_interview.py", + "docs/product_workspace_graph_versioning_roadmap.md", + "Sources/SpecGraph/Documentation.docc/ProductWorkspacePilots.md", + "tools/README.md" + ], + "runtime_markers": [ + { + "path": "tools/idea_to_spec_clarification_requests.py", + "pattern": "FALLBACK_FREE_INTAKE_PROPOSAL_ID = \"0210\"" + }, + { + "path": "tools/real_idea_answer_authoring.py", + "pattern": "TEMPLATE_CONTRACT_REF = \"specgraph.idea-to-spec.real-idea-answer-template.v0.2\"" + }, + { + "path": "tools/user_idea_intake_session.py", + "pattern": "\"policies\": \"Which policies should react to events or constrain commands?\"" + }, + { + "path": "Makefile", + "pattern": "real-idea-intake-continue-without-answers:" + } + ], + "validation_markers": [ + { + "path": "tests/test_idea_to_spec_clarification_requests.py", + "pattern": "test_clarification_requests_marks_ready_session_not_required" + }, + { + "path": "tests/test_real_idea_answer_authoring.py", + "pattern": "test_intake_answer_template_blocks_unsupported_mandatory_request" + }, + { + "path": "tests/test_real_idea_answer_authoring.py", + "pattern": "test_blocked_template_does_not_clobber_existing_ready_output" + }, + { + "path": "tests/test_real_idea_answer_authoring.py", + "pattern": "test_verify_no_clarification_requires_matching_workspace" + } + ], + "observation_markers": [ + { + "path": "docs/product_workspace_graph_versioning_roadmap.md", + "pattern": "Implemented producer-side in proposal `0210`" + }, + { + "path": "Sources/SpecGraph/Documentation.docc/ProductWorkspacePilots.md", + "pattern": "proposal `0210` makes real-intake clarification fallback-free" + }, + { + "path": "tools/README.md", + "pattern": "Proposal 0210 makes real-intake clarification templates fallback-free" + } + ], + "notes": "SpecGraph now emits workspace-bound fallback-free real-intake clarification outcomes and browser-answerable templates, including mandatory policy context, without publishing raw ideas or expanding execution authority." } ] diff --git a/tools/real_idea_answer_authoring.py b/tools/real_idea_answer_authoring.py index 50abe926..716caf2c 100644 --- a/tools/real_idea_answer_authoring.py +++ b/tools/real_idea_answer_authoring.py @@ -17,8 +17,9 @@ ROOT = Path(__file__).resolve().parents[1] PROPOSAL_ID = "0194" +FALLBACK_FREE_INTAKE_PROPOSAL_ID = "0210" SCHEMA_VERSION = 1 -TEMPLATE_CONTRACT_REF = "specgraph.idea-to-spec.real-idea-answer-template.v0.1" +TEMPLATE_CONTRACT_REF = "specgraph.idea-to-spec.real-idea-answer-template.v0.2" REPORT_CONTRACT_REF = "specgraph.idea-to-spec.real-idea-answer-authoring-report.v0.1" ANSWER_SET_CONTRACT_REF = "specgraph.idea-to-spec.clarification-answer-set.v0.1" REQUESTS_CONTRACT_REF = "specgraph.idea-to-spec.clarification-requests.v0.1" @@ -78,6 +79,26 @@ "may_open_pull_request", "may_publish_read_model", } +SUPPORTED_BROWSER_ACTIONS = { + "answer_question", + "provide_candidate_context", + "propose_project_local_term", + "reject", + "reject_candidate", + "defer", + "defer_candidate", +} +SUPPORTED_BROWSER_VALUE_FIELDS = { + "value", + "value.answer", + "value.context", + "value.entries[]", + "value.follow_up", + "value.reason", + "value.refs[]", + "value.relations[]", + "value.terms[]", +} def _now_iso() -> str: @@ -173,6 +194,10 @@ def _digest(payload: dict[str, Any]) -> str: return f"sha256:{hashlib.sha256(encoded).hexdigest()}" +def _source_digest(payload: dict[str, Any]) -> str: + return _digest({key: value for key, value in payload.items() if key != "generated_at"}) + + def _finding( *, finding_id: str, @@ -212,6 +237,84 @@ def _request_index(clarification_requests: dict[str, Any]) -> dict[str, dict[str return index +def _workspace_identity(clarification_requests: dict[str, Any]) -> dict[str, str]: + workspace = _dict(clarification_requests.get("workspace")) + workspace_id = _text(clarification_requests.get("workspace_id")) or _text( + workspace.get("workspace_id") + ) + candidate_id = _text(clarification_requests.get("candidate_id")) or _text( + workspace.get("candidate_id") + ) + workspace_id = workspace_id or candidate_id + if not workspace_id: + return {} + return { + "workspace_id": workspace_id, + "candidate_id": candidate_id or workspace_id, + "display_name": _text(workspace.get("display_name"), workspace_id), + "public_route": _text(workspace.get("public_route"), f"/{workspace_id}"), + } + + +def _clarification_outcome(clarification_requests: dict[str, Any]) -> str: + declared = _text(clarification_requests.get("clarification_outcome")) + if declared in { + "answers_required", + "clarification_not_required", + "clarification_blocked", + }: + return declared + requests = _list(clarification_requests.get("clarification_requests")) + readiness = _dict(clarification_requests.get("readiness")) + if not requests and readiness.get("ready") is True: + return "clarification_not_required" + return "answers_required" if requests else "clarification_blocked" + + +def _browser_answerability_findings( + request: dict[str, Any], *, stage: str, index: int +) -> list[dict[str, Any]]: + if _text(request.get("status"), "open") != "open" or _text(request.get("severity")) not in { + "blocking", + "review_required", + }: + return [] + request_id = _text(request.get("id"), f"request-{index}") + actions = _text_list(request.get("suggested_actions")) + supported_actions = [action for action in actions if action in SUPPORTED_BROWSER_ACTIONS] + fields = {field for action in supported_actions for field in _required_fields(action, request)} + missing = [] + if not _text(request.get("id")): + missing.append("id") + if not _text(request.get("question")): + missing.append("question") + if not _text(request.get("target_ref")): + missing.append("target_ref") + if not supported_actions: + missing.append("supported_action") + if fields and not fields.issubset(SUPPORTED_BROWSER_VALUE_FIELDS): + missing.append("supported_value_shape") + if not missing: + return [] + return [ + _finding( + finding_id="clarification_request_not_browser_answerable", + severity="blocking", + message=( + "A mandatory clarification request cannot be represented by the " + "current browser answer contract." + ), + evidence={ + "request_id": request_id, + "stage": stage, + "missing_or_unsupported": missing, + "suggested_actions": actions, + "suggested_answer_shape": request.get("suggested_answer_shape"), + }, + ) + ] + + def _stage_request_path(stage: str, run_dir: Path) -> Path: if stage == "intake": return run_dir / "idea_intake_clarification_requests.json" @@ -415,6 +518,8 @@ def build_template( targets = [ _answer_target(request, stage=stage, index=index) for index, request in enumerate(requests) ] + workspace = _workspace_identity(clarification_requests) + declared_outcome = _clarification_outcome(clarification_requests) findings: list[dict[str, Any]] = [] if clarification_requests.get("artifact_kind") != "idea_to_spec_clarification_requests": findings.append( @@ -434,28 +539,67 @@ def build_template( evidence={"contract_ref": clarification_requests.get("contract_ref")}, ) ) - if not targets: + if stage == "intake" and not workspace: + findings.append( + _finding( + finding_id="answer_template_workspace_identity_missing", + severity="blocking", + message="Intake answer templates require workspace and candidate identity.", + ) + ) + for index, request in enumerate(requests): + findings.extend(_browser_answerability_findings(request, stage=stage, index=index)) + if declared_outcome == "clarification_not_required" and requests: + findings.append( + _finding( + finding_id="clarification_not_required_has_requests", + severity="blocking", + message="clarification_not_required cannot contain clarification requests.", + ) + ) + if declared_outcome == "answers_required" and not targets: findings.append( _finding( finding_id="answer_targets_missing", - severity="review_required", - message="No clarification requests are available for answer authoring.", + severity="blocking", + message="answers_required must provide browser-answerable targets.", ) ) - ready = not findings + outcome = declared_outcome if not findings else "clarification_blocked" + ready = outcome in {"answers_required", "clarification_not_required"} + next_artifact = ( + "real_idea_answer_set.json" + if outcome == "answers_required" + else "user_idea_intake_source.json" + if outcome == "clarification_not_required" + else None + ) + next_action = ( + "Collect operator answers for the published clarification targets." + if outcome == "answers_required" + else "Continue candidate-source generation from the ready intake session." + if outcome == "clarification_not_required" + else "Inspect blocking template findings before continuation." + ) return { "artifact_kind": "real_idea_answer_template", "schema_version": SCHEMA_VERSION, "proposal_id": PROPOSAL_ID, + "contract_extensions": [FALLBACK_FREE_INTAKE_PROPOSAL_ID], "contract_ref": TEMPLATE_CONTRACT_REF, "generated_at": _now_iso(), "stage": stage, "run_dir": _relative_ref(run_dir), + "workspace_id": workspace.get("workspace_id"), + "candidate_id": workspace.get("candidate_id"), + "workspace": workspace or None, + "clarification_outcome": outcome, "source_artifacts": { "clarification_requests": { "artifact_kind": clarification_requests.get("artifact_kind"), "contract_ref": clarification_requests.get("contract_ref"), "source_ref": _relative_ref(requests_path), + "source_digest": _source_digest(clarification_requests), "request_count": len(requests), } }, @@ -468,19 +612,24 @@ def build_template( }, "readiness": { "ready": ready, - "review_state": ( - "answer_template_ready" if ready else "answer_template_review_required" - ), + "review_state": outcome, "blocked_by": [finding["finding_id"] for finding in findings], - "next_artifact": "real_idea_answer_set.json", + "next_artifact": next_artifact, + "next_action": next_action, }, "authority_boundary": _authority_boundary(), "privacy_boundary": _privacy_boundary(), "findings": findings, "summary": { - "status": ("answer_template_ready" if ready else "answer_template_review_required"), + "status": outcome, "stage": stage, "target_count": len(targets), + "answerable_target_count": (len(targets) if outcome == "answers_required" else 0), + "unsupported_target_count": sum( + 1 + for finding in findings + if finding["finding_id"] == "clarification_request_not_browser_answerable" + ), "blocking_target_count": sum( 1 for target in targets if target["severity"] == "blocking" ), @@ -990,7 +1139,10 @@ def _write_template(args: argparse.Namespace) -> int: stage=stage, run_dir=run_dir, ) - write_json(template, output) + template_ready = _dict(template.get("readiness")).get("ready") is True + template_written = template_ready or not output.exists() + if template_written: + write_json(template, output) report = _authoring_report( operation="template", status=_dict(template.get("summary")).get("status", "answer_template_review_required"), @@ -1002,8 +1154,10 @@ def _write_template(args: argparse.Namespace) -> int: validated_answers=None, findings=[_dict(item) for item in _list(template.get("findings"))], outputs={ - "template": _relative_ref(output), - "next_artifact": _relative_ref(output), + "template": _relative_ref(output) if template_written else None, + "template_written": template_written, + "preserved_existing_template": not template_written, + "next_artifact": _dict(template.get("readiness")).get("next_artifact"), }, ) write_json(report, report_output) @@ -1011,7 +1165,7 @@ def _write_template(args: argparse.Namespace) -> int: f"{template['summary']['status']}: " f"{template['summary']['target_count']} targets -> {_relative_ref(output)}" ) - if args.strict and _dict(template.get("readiness")).get("ready") is not True: + if args.strict and not template_ready: return 1 return 0 @@ -1094,6 +1248,61 @@ def _materialize_answers(args: argparse.Namespace) -> int: return 0 +def _verify_no_clarification(args: argparse.Namespace) -> int: + run_dir_ref, run_dir = _repo_relative_path(args.run_dir, field="REAL_IDEA_SMOKE_RUN_DIR") + _reject_reserved_run_dir(run_dir_ref) + template_path = _guard_output_path(args.template, run_dir=run_dir, field="--template") + template = load_json(template_path) + workspace = _workspace_identity(template) + requests_path = run_dir / "idea_intake_clarification_requests.json" + findings: list[str] = [] + if template.get("artifact_kind") != "real_idea_answer_template": + findings.append("artifact_kind") + if template.get("contract_ref") != TEMPLATE_CONTRACT_REF: + findings.append("contract_ref") + if template.get("clarification_outcome") != "clarification_not_required": + findings.append("clarification_outcome") + if _dict(template.get("readiness")).get("ready") is not True: + findings.append("readiness.ready") + if _list(template.get("answer_targets")): + findings.append("answer_targets") + if args.workspace_id and workspace.get("workspace_id") != args.workspace_id: + findings.append("workspace_id") + requests: dict[str, Any] = {} + if not requests_path.is_file(): + findings.append("source_artifacts.clarification_requests.source_ref") + else: + requests = load_json(requests_path) + request_source = _dict( + _dict(template.get("source_artifacts")).get("clarification_requests") + ) + if request_source.get("source_ref") != _relative_ref(requests_path): + findings.append("source_artifacts.clarification_requests.source_ref") + if request_source.get("source_digest") != _source_digest(requests): + findings.append("source_artifacts.clarification_requests.source_digest") + if requests.get("artifact_kind") != "idea_to_spec_clarification_requests": + findings.append("clarification_requests.artifact_kind") + if requests.get("contract_ref") != REQUESTS_CONTRACT_REF: + findings.append("clarification_requests.contract_ref") + if _clarification_outcome(requests) != "clarification_not_required": + findings.append("clarification_requests.clarification_outcome") + if _list(requests.get("clarification_requests")): + findings.append("clarification_requests.requests") + request_workspace = _workspace_identity(requests) + if request_workspace.get("workspace_id") != workspace.get("workspace_id"): + findings.append("clarification_requests.workspace_id") + if findings: + print( + "clarification_not_required verification failed: " + ", ".join(findings), + ) + return 1 + print( + "clarification_not_required verified for " + f"{workspace.get('workspace_id', 'unknown-workspace')} -> {_relative_ref(template_path)}" + ) + return 0 + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) subparsers = parser.add_subparsers(dest="command", required=True) @@ -1123,6 +1332,15 @@ def build_parser() -> argparse.ArgumentParser: else: sub.add_argument("--validated-answers-output", type=Path) sub.set_defaults(func=_materialize_answers) + verify = subparsers.add_parser("verify-no-clarification") + verify.add_argument("--run-dir", type=Path, default=DEFAULT_RUN_DIR) + verify.add_argument( + "--template", + type=Path, + default=DEFAULT_RUN_DIR / "real_idea_answer_template.json", + ) + verify.add_argument("--workspace-id", default="") + verify.set_defaults(func=_verify_no_clarification) return parser diff --git a/tools/user_idea_intake_session.py b/tools/user_idea_intake_session.py index 588699bc..839a9e89 100644 --- a/tools/user_idea_intake_session.py +++ b/tools/user_idea_intake_session.py @@ -60,6 +60,7 @@ def _load_contracts_module() -> Any: "actors", "domain_events", "commands", + "policies", "constraints", ) EVENT_STORMING_CATEGORIES = ( @@ -535,6 +536,7 @@ def _event_storming( "actors": "Who are the primary actors or roles in this product idea?", "domain_events": "Which important domain events should the specification preserve?", "commands": "Which user or system commands should trigger those events?", + "policies": "Which policies should react to events or constrain commands?", "constraints": "Which constraints, policies, or non-goals bound the first candidate graph?", } invalid_entry_categories: set[str] = set()