From 9df3542fa6b599090616036fd56499758fdafadf Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Mon, 30 Mar 2026 10:28:41 -0600 Subject: [PATCH 01/15] inital spec file --- doc/initial_deepreview_spec.md | 46 ++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 doc/initial_deepreview_spec.md diff --git a/doc/initial_deepreview_spec.md b/doc/initial_deepreview_spec.md new file mode 100644 index 00000000..7926994e --- /dev/null +++ b/doc/initial_deepreview_spec.md @@ -0,0 +1,46 @@ +# DeepSchema +DeepWork Schemas (or DeepSchemas) are an enrichment of the DeepReview system wherein you can have a rich schema for files that are helpful for both humans and for agents. + +## Types of DeepSchemas +1. `named` DeepSchemas are ones placed in `.deepwork/schemas/` or a similar schema registry. +2. `anonymous` DeepSchemas are ones that are declared in the normal file system alongside files they affect. These are specific to individual files, and have names of the format `.deepschema.` where `filename` is the file they apply to. + +## Named DeepSchemas +Named DeepSchemas are directories where the final segment of the path is the name of the schema. The files inside the directories are then as follows: +* deepschema.yml - the manifest file +* `examples` - a directory of example files +* `references` - additional reference files relevant to the schema +* `schema file with any name` - JSON Schema for the file or any other types of normal schemas go in the main directory. All optional + +## Anonymous DeepSchemas +These have only the single file. That file is the same format as the `deepschema.yml` file in the named DeepSchemas. + +## Files +### deepschema.yml +These yaml files have the following keys. They are all usable in both anonymous and named DeepSchemas, but we separate them below into the ones that are common in both and ones mostly relevant to Named ones. +1. Common to Both + 1. `requirements` - object where the keys are names and the bodies are descriptions of the requirements that a good document meets. These use RFC 2119 words that guide the reviews; MUST, SHOULD, etc. This is the same format / type as `process_quality_attributes` in DeepWork Jobs which needs to be updated to be called `process_requirements` + 2. `parent_deep_schemas` - array of the names of other schemas that apply to anything this applies to. + 1. Mostly used in anonymous DeepSchemas to reference in a named DeepSchema when needed + 3. `json_schema_path` - relative path to a JSON schema file to enforce + 4. `verification_bash_command` - arbitrary array of strings of bash commands to be run on the file to verify it. Main example use is if there is a different kind of schema other than a JSON one that you want to enforce +2. `summary` Summary of the type. Used for giving a high-level understanding and for search / discovery +3. `instructions` - general instructions for dealing with files of this type. +4. `examples` - array of examples of files of this type done well. Each entry should have a relative path from the definition file and a description. +5. `references` - more detailed reference files that can be looked at on particular topics related to the file. These have relative paths and descriptions. Can also also have URLs in place of relative paths +6. `matchers` - a declaration of file patterns that the schema applies to. This is an array of glob patterns + +## Behavior +1. There must be a concept of "applicable schemas" for a given file. This should be computed using a reusable method. It should match any named schemas that have matchers that match up with a given file, or anonymous schemas named appropriately for that file +2. Whenever a file is read by an agent, there must be a message sent to the agent after the read that says "Note: this file must confirm to the DeepSchema at " +3. Whenever a file is written by an agent, the `verification_bash_command` must be executed automatically. If it fails, then a message must be returned to the agent saying "CRITICAL: DeepSchema validation failed when it tried to verify this change. Error: " + 1. If there was a `json_schema_path`, then it must also generate a similar message if the file does not conform to the schema +4. DeepReview must be modified so that there are "reviews" generated for every type definition. + 1. These should have the relevant deepschema.yml used as the origin file. + 2. They should all be single-file-at-a-time reviews + 3. Their names should be " DeepSchema Compliance" + 4. The instructions for the review should be (roughly): + 1. Named schemas intro: " is an instance of , described as follows:\n\n\nInstructions for dealing with these files:\n" + 2. Anonymous schemas intro: " has requirements that it must follow." + 3. Body for both types: "Please review for compliance with the following requirements. You must fail reviews over anything that is MUST. You must fail reviews over any SHOULD that seems like it could be easily followed but is not. You should give feedback but not fail over anything else applicable. You can ignore N/A requirements.\n\n " + 5. These reviews should run both from `/review` and from DeepWork jobs when steps finish like regular DeepReviews are run From dc12f7ffc6db9a146b77c3a3a913640306518613 Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Mon, 30 Mar 2026 11:04:49 -0600 Subject: [PATCH 02/15] feat: implement DeepSchema system for rich file schemas with validation and review generation DeepSchemas enrich the DeepReview system with file-level schemas that provide automatic validation (JSON Schema + bash commands) on writes and synthetic review rule generation at /review and finished_step time. - Core module: src/deepwork/deepschema/ (config, discovery, matcher, resolver, review_bridge) - Write hook: PostToolUse on Write/Edit validates and injects conformance notes - Multi-source named schema discovery: project-local, standard_schemas/, DEEPWORK_ADDITIONAL_SCHEMAS_FOLDERS env var - Parent schema inheritance with circular reference detection - 67 new tests across 6 test files Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/initial_deepreview_spec.md | 19 +- plugins/claude/hooks/deepschema_write.sh | 8 + plugins/claude/hooks/hooks.json | 18 ++ src/deepwork/deepschema/__init__.py | 1 + src/deepwork/deepschema/config.py | 103 ++++++++++ src/deepwork/deepschema/discovery.py | 201 ++++++++++++++++++++ src/deepwork/deepschema/matcher.py | 125 ++++++++++++ src/deepwork/deepschema/resolver.py | 114 +++++++++++ src/deepwork/deepschema/review_bridge.py | 151 +++++++++++++++ src/deepwork/deepschema/schema.py | 17 ++ src/deepwork/hooks/deepschema_write.py | 180 ++++++++++++++++++ src/deepwork/jobs/mcp/quality_gate.py | 6 + src/deepwork/review/mcp.py | 18 +- src/deepwork/schemas/deepschema_schema.json | 90 +++++++++ src/deepwork/standard_schemas/.gitkeep | 0 tests/unit/deepschema/__init__.py | 0 tests/unit/deepschema/test_config.py | 128 +++++++++++++ tests/unit/deepschema/test_discovery.py | 188 ++++++++++++++++++ tests/unit/deepschema/test_matcher.py | 94 +++++++++ tests/unit/deepschema/test_resolver.py | 119 ++++++++++++ tests/unit/deepschema/test_review_bridge.py | 128 +++++++++++++ tests/unit/deepschema/test_write_hook.py | 144 ++++++++++++++ tests/unit/review/test_mcp.py | 2 +- 23 files changed, 1846 insertions(+), 8 deletions(-) create mode 100755 plugins/claude/hooks/deepschema_write.sh create mode 100644 src/deepwork/deepschema/__init__.py create mode 100644 src/deepwork/deepschema/config.py create mode 100644 src/deepwork/deepschema/discovery.py create mode 100644 src/deepwork/deepschema/matcher.py create mode 100644 src/deepwork/deepschema/resolver.py create mode 100644 src/deepwork/deepschema/review_bridge.py create mode 100644 src/deepwork/deepschema/schema.py create mode 100644 src/deepwork/hooks/deepschema_write.py create mode 100644 src/deepwork/schemas/deepschema_schema.json create mode 100644 src/deepwork/standard_schemas/.gitkeep create mode 100644 tests/unit/deepschema/__init__.py create mode 100644 tests/unit/deepschema/test_config.py create mode 100644 tests/unit/deepschema/test_discovery.py create mode 100644 tests/unit/deepschema/test_matcher.py create mode 100644 tests/unit/deepschema/test_resolver.py create mode 100644 tests/unit/deepschema/test_review_bridge.py create mode 100644 tests/unit/deepschema/test_write_hook.py diff --git a/doc/initial_deepreview_spec.md b/doc/initial_deepreview_spec.md index 7926994e..f9e20b4b 100644 --- a/doc/initial_deepreview_spec.md +++ b/doc/initial_deepreview_spec.md @@ -3,7 +3,7 @@ DeepWork Schemas (or DeepSchemas) are an enrichment of the DeepReview system whe ## Types of DeepSchemas 1. `named` DeepSchemas are ones placed in `.deepwork/schemas/` or a similar schema registry. -2. `anonymous` DeepSchemas are ones that are declared in the normal file system alongside files they affect. These are specific to individual files, and have names of the format `.deepschema.` where `filename` is the file they apply to. +2. `anonymous` DeepSchemas are ones that are declared in the normal file system alongside files they affect. These are specific to individual files, and have names of the format `.deepschema..yml` where `filename` is the file they apply to. ## Named DeepSchemas Named DeepSchemas are directories where the final segment of the path is the name of the schema. The files inside the directories are then as follows: @@ -12,6 +12,15 @@ Named DeepSchemas are directories where the final segment of the path is the nam * `references` - additional reference files relevant to the schema * `schema file with any name` - JSON Schema for the file or any other types of normal schemas go in the main directory. All optional +### Named Schema Discovery Sources +Named schemas are discovered from multiple directories in priority order. If the same schema name (directory name) appears in multiple sources, the first one wins. + +1. `/.deepwork/schemas/` — project-local named schemas +2. `/standard_schemas/` — built-in standard schemas shipped with DeepWork (analogous to `standard_jobs/`) +3. `DEEPWORK_ADDITIONAL_SCHEMAS_FOLDERS` environment variable — colon-delimited list of absolute paths to additional directories containing named schema subdirectories + +This mirrors the job discovery system (`DEEPWORK_ADDITIONAL_JOBS_FOLDERS`). + ## Anonymous DeepSchemas These have only the single file. That file is the same format as the `deepschema.yml` file in the named DeepSchemas. @@ -19,7 +28,7 @@ These have only the single file. That file is the same format as the `deepschema ### deepschema.yml These yaml files have the following keys. They are all usable in both anonymous and named DeepSchemas, but we separate them below into the ones that are common in both and ones mostly relevant to Named ones. 1. Common to Both - 1. `requirements` - object where the keys are names and the bodies are descriptions of the requirements that a good document meets. These use RFC 2119 words that guide the reviews; MUST, SHOULD, etc. This is the same format / type as `process_quality_attributes` in DeepWork Jobs which needs to be updated to be called `process_requirements` + 1. `requirements` - object where the keys are names and the bodies are descriptions of the requirements that a good document meets. These use RFC 2119 words that guide the reviews; MUST, SHOULD, etc. This is the same format / type as `process_requirements` in DeepWork Jobs 2. `parent_deep_schemas` - array of the names of other schemas that apply to anything this applies to. 1. Mostly used in anonymous DeepSchemas to reference in a named DeepSchema when needed 3. `json_schema_path` - relative path to a JSON schema file to enforce @@ -31,10 +40,10 @@ These yaml files have the following keys. They are all usable in both anonymous 6. `matchers` - a declaration of file patterns that the schema applies to. This is an array of glob patterns ## Behavior -1. There must be a concept of "applicable schemas" for a given file. This should be computed using a reusable method. It should match any named schemas that have matchers that match up with a given file, or anonymous schemas named appropriately for that file -2. Whenever a file is read by an agent, there must be a message sent to the agent after the read that says "Note: this file must confirm to the DeepSchema at " -3. Whenever a file is written by an agent, the `verification_bash_command` must be executed automatically. If it fails, then a message must be returned to the agent saying "CRITICAL: DeepSchema validation failed when it tried to verify this change. Error: " +1. There must be a concept of "applicable schemas" for a given file. This should be computed using a reusable method. It should match any named schemas that have matchers that match up with a given file, or anonymous schemas named `.deepschema..yml` for that file +2. Whenever a file is written by an agent (PostToolUse on Write/Edit), a conformance note must be sent: "Note: this file must conform to the DeepSchema at ". Additionally, the `verification_bash_command` must be executed automatically. If it fails, then a message must be returned to the agent saying "CRITICAL: DeepSchema validation failed when it tried to verify this change. Error: " 1. If there was a `json_schema_path`, then it must also generate a similar message if the file does not conform to the schema + 2. No read hook is used — schema awareness is delivered via the write hook to avoid per-read latency 4. DeepReview must be modified so that there are "reviews" generated for every type definition. 1. These should have the relevant deepschema.yml used as the origin file. 2. They should all be single-file-at-a-time reviews diff --git a/plugins/claude/hooks/deepschema_write.sh b/plugins/claude/hooks/deepschema_write.sh new file mode 100755 index 00000000..10007d0b --- /dev/null +++ b/plugins/claude/hooks/deepschema_write.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# DeepSchema write hook +# PostToolUse hook for Write/Edit - validates files against applicable DeepSchemas + +INPUT=$(cat) +export DEEPWORK_HOOK_PLATFORM="claude" +echo "${INPUT}" | deepwork hook deepschema_write +exit $? diff --git a/plugins/claude/hooks/hooks.json b/plugins/claude/hooks/hooks.json index 2d64c3ef..80ebdff4 100644 --- a/plugins/claude/hooks/hooks.json +++ b/plugins/claude/hooks/hooks.json @@ -40,6 +40,24 @@ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/post_commit_reminder.sh" } ] + }, + { + "matcher": "Write", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/deepschema_write.sh" + } + ] + }, + { + "matcher": "Edit", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/deepschema_write.sh" + } + ] } ] } diff --git a/src/deepwork/deepschema/__init__.py b/src/deepwork/deepschema/__init__.py new file mode 100644 index 00000000..8e975db4 --- /dev/null +++ b/src/deepwork/deepschema/__init__.py @@ -0,0 +1 @@ +"""DeepSchema - rich file schemas for validation and review generation.""" diff --git a/src/deepwork/deepschema/config.py b/src/deepwork/deepschema/config.py new file mode 100644 index 00000000..9fe19bfa --- /dev/null +++ b/src/deepwork/deepschema/config.py @@ -0,0 +1,103 @@ +"""Configuration parsing for DeepSchema files. + +Parses deepschema.yml and .deepschema..yml files into DeepSchema +dataclasses, validating against the JSON schema. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from deepwork.deepschema.schema import DEEPSCHEMA_SCHEMA +from deepwork.utils.validation import ValidationError, validate_against_schema +from deepwork.utils.yaml_utils import YAMLError, load_yaml + + +class DeepSchemaError(Exception): + """Exception raised for DeepSchema configuration errors.""" + + pass + + +@dataclass +class DeepSchema: + """A single DeepSchema definition, either named or anonymous.""" + + name: str + schema_type: str # "named" | "anonymous" + source_path: Path # Path to deepschema.yml or .deepschema..yml + + # Common fields + requirements: dict[str, str] = field(default_factory=dict) + parent_deep_schemas: list[str] = field(default_factory=list) + json_schema_path: str | None = None + verification_bash_command: list[str] = field(default_factory=list) + + # Named-schema-focused fields + summary: str | None = None + instructions: str | None = None + examples: list[dict[str, str]] = field(default_factory=list) + references: list[dict[str, str]] = field(default_factory=list) + matchers: list[str] = field(default_factory=list) + + +def parse_deepschema_file( + filepath: Path, + schema_type: str, + name: str, +) -> DeepSchema: + """Parse a deepschema.yml or .deepschema..yml into a DeepSchema. + + Args: + filepath: Path to the YAML file. + schema_type: "named" or "anonymous". + name: Schema name (directory name for named, target filename for anonymous). + + Returns: + A DeepSchema object. + + Raises: + DeepSchemaError: If the file cannot be parsed or fails validation. + """ + try: + data = load_yaml(filepath) + except YAMLError as e: + raise DeepSchemaError(f"Failed to parse {filepath}: {e}") from e + + if data is None: + raise DeepSchemaError(f"File not found: {filepath}") + + if not data: + return DeepSchema(name=name, schema_type=schema_type, source_path=filepath) + + try: + validate_against_schema(data, DEEPSCHEMA_SCHEMA) + except ValidationError as e: + raise DeepSchemaError(f"Schema validation failed for {filepath}: {e}") from e + + return _build_deepschema(data, name, schema_type, filepath) + + +def _build_deepschema( + data: dict[str, Any], + name: str, + schema_type: str, + source_path: Path, +) -> DeepSchema: + """Build a DeepSchema from parsed and validated YAML data.""" + return DeepSchema( + name=name, + schema_type=schema_type, + source_path=source_path, + requirements=data.get("requirements", {}), + parent_deep_schemas=data.get("parent_deep_schemas", []), + json_schema_path=data.get("json_schema_path"), + verification_bash_command=data.get("verification_bash_command", []), + summary=data.get("summary"), + instructions=data.get("instructions"), + examples=data.get("examples", []), + references=data.get("references", []), + matchers=data.get("matchers", []), + ) diff --git a/src/deepwork/deepschema/discovery.py b/src/deepwork/deepschema/discovery.py new file mode 100644 index 00000000..802a93c7 --- /dev/null +++ b/src/deepwork/deepschema/discovery.py @@ -0,0 +1,201 @@ +"""Discovery of DeepSchema definitions in the project tree. + +Named schemas are discovered from multiple sources in priority order: + +1. ``/.deepwork/schemas`` – project-local named schemas +2. ``/standard_schemas`` – built-in standard schemas shipped with DeepWork +3. ``DEEPWORK_ADDITIONAL_SCHEMAS_FOLDERS`` env var – colon-delimited list of extra directories + +Each additional directory is scanned for subdirectories containing ``deepschema.yml``. +If the same schema name appears in multiple sources, the first one wins (project-local +overrides standard, standard overrides env var extras). + +Anonymous schemas (``.deepschema..yml``) are found by walking the project tree. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +from deepwork.deepschema.config import DeepSchema, DeepSchemaError, parse_deepschema_file + +# Directories to skip during anonymous schema discovery +_SKIP_DIRS = { + ".git", + "node_modules", + "__pycache__", + ".venv", + "venv", + ".tox", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".eggs", +} + +_SKIP_SUFFIXES = (".egg-info",) + +NAMED_SCHEMAS_DIR = ".deepwork/schemas" +ANONYMOUS_PREFIX = ".deepschema." +ANONYMOUS_SUFFIX = ".yml" + +# Environment variable for additional schema folders (colon-delimited) +ENV_ADDITIONAL_SCHEMAS_FOLDERS = "DEEPWORK_ADDITIONAL_SCHEMAS_FOLDERS" + +# Location of built-in standard schemas inside the package +_STANDARD_SCHEMAS_DIR = Path(__file__).parent.parent / "standard_schemas" + + +@dataclass +class DiscoveryError: + """An error encountered while loading a DeepSchema file.""" + + file_path: Path + error: str + + +def get_named_schema_folders(project_root: Path) -> list[Path]: + """Return the ordered list of directories to scan for named schemas. + + Priority order (first match wins on name conflict): + 1. /.deepwork/schemas – project-local + 2. /standard_schemas – built-in + 3. DEEPWORK_ADDITIONAL_SCHEMAS_FOLDERS – env var extras + """ + folders: list[Path] = [ + project_root / NAMED_SCHEMAS_DIR, + _STANDARD_SCHEMAS_DIR, + ] + + extra = os.environ.get(ENV_ADDITIONAL_SCHEMAS_FOLDERS, "") + if extra: + for entry in extra.split(":"): + entry = entry.strip() + if entry: + folders.append(Path(entry)) + + return folders + + +def find_named_schemas(project_root: Path) -> list[Path]: + """Find all named DeepSchema manifest files across all schema folders. + + Scans each folder from get_named_schema_folders(). If the same schema + name (directory name) appears in multiple folders, the first one wins. + + Args: + project_root: Root directory of the project. + + Returns: + List of deepschema.yml paths, deduplicated by schema name. + """ + seen_names: set[str] = set() + results: list[Path] = [] + + for folder in get_named_schema_folders(project_root): + if not folder.is_dir(): + continue + try: + for entry in sorted(folder.iterdir()): + if entry.is_dir() and entry.name not in seen_names: + manifest = entry / "deepschema.yml" + if manifest.is_file(): + results.append(manifest) + seen_names.add(entry.name) + except PermissionError: + continue + + return results + + +def find_anonymous_schemas(project_root: Path) -> list[Path]: + """Find all anonymous DeepSchema files in the project tree. + + Walks the project looking for files matching .deepschema..yml. + + Args: + project_root: Root directory to search. + + Returns: + List of anonymous schema file paths, sorted alphabetically. + """ + results: list[Path] = [] + _walk_for_anonymous(project_root, results) + results.sort(key=str) + return results + + +def _walk_for_anonymous(root: Path, results: list[Path]) -> None: + """Walk directory tree looking for .deepschema.*.yml files.""" + try: + entries = sorted(root.iterdir()) + except PermissionError: + return + + for entry in entries: + if entry.is_file() and _is_anonymous_schema(entry.name): + results.append(entry) + elif ( + entry.is_dir() + and entry.name not in _SKIP_DIRS + and not entry.name.endswith(_SKIP_SUFFIXES) + ): + _walk_for_anonymous(entry, results) + + +def _is_anonymous_schema(filename: str) -> bool: + """Check if a filename matches the .deepschema..yml pattern.""" + return ( + filename.startswith(ANONYMOUS_PREFIX) + and filename.endswith(ANONYMOUS_SUFFIX) + and len(filename) > len(ANONYMOUS_PREFIX) + len(ANONYMOUS_SUFFIX) + ) + + +def anonymous_target_filename(schema_filename: str) -> str: + """Extract the target filename from an anonymous schema filename. + + ".deepschema.foo.py.yml" -> "foo.py" + """ + return schema_filename[len(ANONYMOUS_PREFIX) : -len(ANONYMOUS_SUFFIX)] + + +def discover_all_schemas( + project_root: Path, +) -> tuple[list[DeepSchema], list[DiscoveryError]]: + """Discover all DeepSchemas (named and anonymous) in the project. + + Named schemas are loaded from all configured folders (project-local, + standard, and env var). Anonymous schemas are found by walking the + project tree. + + Args: + project_root: Root directory to search. + + Returns: + Tuple of (successfully parsed schemas, list of errors). + """ + schemas: list[DeepSchema] = [] + errors: list[DiscoveryError] = [] + + # Named schemas (from all sources, deduplicated) + for manifest_path in find_named_schemas(project_root): + name = manifest_path.parent.name + try: + schema = parse_deepschema_file(manifest_path, "named", name) + schemas.append(schema) + except DeepSchemaError as e: + errors.append(DiscoveryError(file_path=manifest_path, error=str(e))) + + # Anonymous schemas + for schema_path in find_anonymous_schemas(project_root): + target = anonymous_target_filename(schema_path.name) + try: + schema = parse_deepschema_file(schema_path, "anonymous", target) + schemas.append(schema) + except DeepSchemaError as e: + errors.append(DiscoveryError(file_path=schema_path, error=str(e))) + + return schemas, errors diff --git a/src/deepwork/deepschema/matcher.py b/src/deepwork/deepschema/matcher.py new file mode 100644 index 00000000..7ca148f1 --- /dev/null +++ b/src/deepwork/deepschema/matcher.py @@ -0,0 +1,125 @@ +"""Schema-to-file matching for DeepSchemas. + +Computes which DeepSchemas are applicable to a given file path, using +glob matchers for named schemas and naming conventions for anonymous schemas. +""" + +from __future__ import annotations + +from pathlib import Path + +from deepwork.deepschema.config import DeepSchema +from deepwork.deepschema.discovery import ( + ANONYMOUS_PREFIX, + ANONYMOUS_SUFFIX, + anonymous_target_filename, + find_named_schemas, +) +from deepwork.review.matcher import _glob_match + + +def get_applicable_schemas( + filepath: str, + schemas: list[DeepSchema], + project_root: Path, +) -> list[DeepSchema]: + """Find all DeepSchemas applicable to a given file path. + + Args: + filepath: File path relative to project root. + schemas: All discovered (and resolved) schemas. + project_root: Project root for resolving paths. + + Returns: + List of applicable DeepSchema objects. + """ + applicable: list[DeepSchema] = [] + + for schema in schemas: + if schema.schema_type == "named": + if _named_schema_matches(filepath, schema, project_root): + applicable.append(schema) + elif schema.schema_type == "anonymous": + if _anonymous_schema_matches(filepath, schema, project_root): + applicable.append(schema) + + return applicable + + +def get_schemas_for_file_fast( + filepath: str, + project_root: Path, +) -> list[DeepSchema]: + """Fast path for hooks: find schemas applicable to a single file. + + Avoids full tree walk. Only scans .deepwork/schemas/ (bounded) and + checks the file's parent directory for an anonymous schema (O(1) stat). + + Args: + filepath: File path relative to project root. + project_root: Project root for resolving paths. + + Returns: + List of applicable DeepSchema objects (not inheritance-resolved). + """ + from deepwork.deepschema.config import DeepSchemaError, parse_deepschema_file + + applicable: list[DeepSchema] = [] + + # Check named schemas + for manifest_path in find_named_schemas(project_root): + name = manifest_path.parent.name + try: + schema = parse_deepschema_file(manifest_path, "named", name) + except DeepSchemaError: + continue + if _named_schema_matches(filepath, schema, project_root): + applicable.append(schema) + + # Check for anonymous schema in same directory + abs_filepath = project_root / filepath + parent_dir = abs_filepath.parent + basename = abs_filepath.name + anonymous_path = parent_dir / f"{ANONYMOUS_PREFIX}{basename}{ANONYMOUS_SUFFIX}" + if anonymous_path.is_file(): + try: + schema = parse_deepschema_file(anonymous_path, "anonymous", basename) + applicable.append(schema) + except DeepSchemaError: + pass + + return applicable + + +def _named_schema_matches( + filepath: str, + schema: DeepSchema, + project_root: Path, +) -> bool: + """Check if a named schema's matchers match the given file path.""" + for pattern in schema.matchers: + if _glob_match(filepath, pattern): + return True + return False + + +def _anonymous_schema_matches( + filepath: str, + schema: DeepSchema, + project_root: Path, +) -> bool: + """Check if an anonymous schema applies to the given file path. + + An anonymous schema at /dir/.deepschema.foo.py.yml applies to /dir/foo.py. + """ + schema_dir = schema.source_path.parent + target_name = anonymous_target_filename(schema.source_path.name) + target_path = schema_dir / target_name + + # Compare relative to project root + try: + target_rel = str(target_path.relative_to(project_root)) + except ValueError: + return False + + return filepath == target_rel diff --git a/src/deepwork/deepschema/resolver.py b/src/deepwork/deepschema/resolver.py new file mode 100644 index 00000000..2e8363b9 --- /dev/null +++ b/src/deepwork/deepschema/resolver.py @@ -0,0 +1,114 @@ +"""Parent schema inheritance resolution for DeepSchemas. + +Resolves the parent_deep_schemas chain, merging requirements and +validation commands from parent schemas into children. +""" + +from __future__ import annotations + +from deepwork.deepschema.config import DeepSchema, DeepSchemaError + + +def resolve_inheritance( + schema: DeepSchema, + named_schemas: dict[str, DeepSchema], + _visited: set[str] | None = None, +) -> DeepSchema: + """Resolve parent_deep_schemas inheritance for a single schema. + + Merges parent requirements into the child. Child keys override parent + on conflict. json_schema_path is inherited if not set on child. + verification_bash_command is appended (parent commands run first). + + Args: + schema: The schema to resolve. + named_schemas: All named schemas keyed by name, for parent lookups. + _visited: Internal set for circular reference detection. + + Returns: + A new DeepSchema with inherited fields merged in. + + Raises: + DeepSchemaError: On circular references or missing parent schemas. + """ + if not schema.parent_deep_schemas: + return schema + + if _visited is None: + _visited = set() + + if schema.name in _visited: + raise DeepSchemaError( + f"Circular parent reference detected: '{schema.name}' is in its own inheritance chain" + ) + _visited.add(schema.name) + + # Collect merged fields from all parents (in order) + merged_requirements: dict[str, str] = {} + merged_verification_cmds: list[str] = [] + inherited_json_schema_path: str | None = None + + for parent_name in schema.parent_deep_schemas: + parent = named_schemas.get(parent_name) + if parent is None: + raise DeepSchemaError( + f"Schema '{schema.name}' references unknown parent '{parent_name}'" + ) + + # Resolve the parent recursively first + resolved_parent = resolve_inheritance(parent, named_schemas, _visited.copy()) + + # Merge requirements (parent first, child overrides) + merged_requirements.update(resolved_parent.requirements) + + # Append verification commands (parent first) + merged_verification_cmds.extend(resolved_parent.verification_bash_command) + + # Inherit json_schema_path from first parent that has one + if inherited_json_schema_path is None and resolved_parent.json_schema_path: + inherited_json_schema_path = resolved_parent.json_schema_path + + # Child requirements override parent on key conflict + merged_requirements.update(schema.requirements) + + # Child verification commands come after parent + merged_verification_cmds.extend(schema.verification_bash_command) + + return DeepSchema( + name=schema.name, + schema_type=schema.schema_type, + source_path=schema.source_path, + requirements=merged_requirements, + parent_deep_schemas=schema.parent_deep_schemas, + json_schema_path=schema.json_schema_path or inherited_json_schema_path, + verification_bash_command=merged_verification_cmds, + summary=schema.summary, + instructions=schema.instructions, + examples=schema.examples, + references=schema.references, + matchers=schema.matchers, + ) + + +def resolve_all( + schemas: list[DeepSchema], +) -> tuple[list[DeepSchema], list[str]]: + """Resolve inheritance for all schemas. + + Args: + schemas: All discovered schemas. + + Returns: + Tuple of (resolved schemas, list of error messages). + """ + named_lookup = {s.name: s for s in schemas if s.schema_type == "named"} + resolved: list[DeepSchema] = [] + errors: list[str] = [] + + for schema in schemas: + try: + resolved.append(resolve_inheritance(schema, named_lookup)) + except DeepSchemaError as e: + errors.append(str(e)) + + return resolved, errors diff --git a/src/deepwork/deepschema/review_bridge.py b/src/deepwork/deepschema/review_bridge.py new file mode 100644 index 00000000..118a1304 --- /dev/null +++ b/src/deepwork/deepschema/review_bridge.py @@ -0,0 +1,151 @@ +"""Generate synthetic ReviewRule objects from DeepSchemas. + +Bridges DeepSchemas into the existing DeepReview pipeline by producing +ReviewRule objects that flow through matching, instruction generation, +and formatting like regular .deepreview rules. +""" + +from __future__ import annotations + +from pathlib import Path + +from deepwork.deepschema.config import DeepSchema +from deepwork.deepschema.discovery import anonymous_target_filename, discover_all_schemas +from deepwork.deepschema.resolver import resolve_all +from deepwork.review.config import ReviewRule + + +def generate_review_rules( + project_root: Path, +) -> tuple[list[ReviewRule], list[str]]: + """Discover all DeepSchemas and generate ReviewRules from them. + + Args: + project_root: Project root for schema discovery. + + Returns: + Tuple of (ReviewRule list, error messages). + """ + schemas, discovery_errors = discover_all_schemas(project_root) + errors = [f"{e.file_path}: {e.error}" for e in discovery_errors] + + resolved, resolve_errors = resolve_all(schemas) + errors.extend(resolve_errors) + + rules: list[ReviewRule] = [] + for schema in resolved: + rule = _schema_to_review_rule(schema, project_root) + if rule is not None: + rules.append(rule) + + return rules, errors + + +def _schema_to_review_rule( + schema: DeepSchema, + project_root: Path, +) -> ReviewRule | None: + """Convert a single DeepSchema into a ReviewRule. + + Returns None if the schema has no requirements and no matchers. + """ + if not schema.requirements: + return None + + if schema.schema_type == "named": + return _named_schema_rule(schema, project_root) + else: + return _anonymous_schema_rule(schema, project_root) + + +def _named_schema_rule( + schema: DeepSchema, + project_root: Path, +) -> ReviewRule | None: + """Build a ReviewRule from a named DeepSchema.""" + if not schema.matchers: + return None + + instructions = _build_named_instructions(schema) + + return ReviewRule( + name=f"{schema.name} DeepSchema Compliance", + description=f"DeepSchema compliance review for {schema.name}", + include_patterns=list(schema.matchers), + exclude_patterns=[], + strategy="individual", + instructions=instructions, + agent=None, + all_changed_filenames=False, + unchanged_matching_files=False, + source_dir=schema.source_path.parent, + source_file=schema.source_path, + source_line=0, + ) + + +def _anonymous_schema_rule( + schema: DeepSchema, + project_root: Path, +) -> ReviewRule | None: + """Build a ReviewRule from an anonymous DeepSchema.""" + target_name = anonymous_target_filename(schema.source_path.name) + target_path = schema.source_path.parent / target_name + + try: + target_rel = str(target_path.relative_to(project_root)) + except ValueError: + return None + + instructions = _build_anonymous_instructions(schema) + + return ReviewRule( + name=f"{target_name} DeepSchema Compliance", + description=f"DeepSchema compliance review for {target_name}", + include_patterns=[target_rel], + exclude_patterns=[], + strategy="individual", + instructions=instructions, + agent=None, + all_changed_filenames=False, + unchanged_matching_files=False, + source_dir=schema.source_path.parent, + source_file=schema.source_path, + source_line=0, + ) + + +def _build_named_instructions(schema: DeepSchema) -> str: + """Build review instructions for a named DeepSchema.""" + parts: list[str] = [] + + # Intro with summary and instructions + parts.append(f"{{file_path}} is an instance of {schema.name}.") + if schema.summary: + parts.append(f"\n{schema.summary}") + if schema.instructions: + parts.append(f"\n\nInstructions for dealing with these files:\n{schema.instructions}") + + # Requirements body + parts.append("\n\n") + parts.append(_build_requirements_body(schema.requirements)) + + return "".join(parts) + + +def _build_anonymous_instructions(schema: DeepSchema) -> str: + """Build review instructions for an anonymous DeepSchema.""" + parts: list[str] = [] + parts.append("{file_path} has requirements that it must follow.") + parts.append("\n\n") + parts.append(_build_requirements_body(schema.requirements)) + return "".join(parts) + + +def _build_requirements_body(requirements: dict[str, str]) -> str: + """Build the RFC 2119 requirements review section.""" + req_lines = "\n".join(f"- **{name}**: {desc}" for name, desc in requirements.items()) + + return f"""Please review for compliance with the following requirements. You must fail reviews over anything that is MUST. You must fail reviews over any SHOULD that seems like it could be easily followed but is not. You should give feedback but not fail over anything else applicable. You can ignore N/A requirements. + +{req_lines}""" diff --git a/src/deepwork/deepschema/schema.py b/src/deepwork/deepschema/schema.py new file mode 100644 index 00000000..412a0395 --- /dev/null +++ b/src/deepwork/deepschema/schema.py @@ -0,0 +1,17 @@ +"""JSON Schema loader for DeepSchema definition files.""" + +import json +from pathlib import Path +from typing import Any + +_SCHEMA_FILE = Path(__file__).parent.parent / "schemas" / "deepschema_schema.json" + + +def _load_schema() -> dict[str, Any]: + """Load the JSON schema from file.""" + with open(_SCHEMA_FILE) as f: + result: dict[str, Any] = json.load(f) + return result + + +DEEPSCHEMA_SCHEMA: dict[str, Any] = _load_schema() diff --git a/src/deepwork/hooks/deepschema_write.py b/src/deepwork/hooks/deepschema_write.py new file mode 100644 index 00000000..f62409d2 --- /dev/null +++ b/src/deepwork/hooks/deepschema_write.py @@ -0,0 +1,180 @@ +"""PostToolUse hook for Write/Edit — DeepSchema validation. + +Fires after a file is written or edited. Finds applicable DeepSchemas, +injects a conformance note, and runs verification_bash_command and +json_schema_path validation. Reports errors via systemMessage. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +from deepwork.hooks.wrapper import ( + HookInput, + HookOutput, + NormalizedEvent, + Platform, + output_hook_error, + run_hook, +) + + +def deepschema_write_hook(hook_input: HookInput) -> HookOutput: + """Post-write hook: validate against applicable DeepSchemas.""" + if hook_input.event != NormalizedEvent.AFTER_TOOL: + return HookOutput() + + if hook_input.tool_name not in ("write_file", "edit_file"): + return HookOutput() + + file_path = hook_input.tool_input.get("file_path", "") + if not file_path: + return HookOutput() + + cwd = hook_input.cwd or os.getcwd() + project_root = Path(cwd) + + # Compute relative path + try: + abs_path = Path(file_path) + if abs_path.is_absolute(): + rel_path = str(abs_path.relative_to(project_root)) + else: + rel_path = file_path + except ValueError: + return HookOutput() + + # Find applicable schemas (fast path — no full tree walk) + try: + from deepwork.deepschema.matcher import get_schemas_for_file_fast + + schemas = get_schemas_for_file_fast(rel_path, project_root) + except Exception: + return HookOutput() + + if not schemas: + return HookOutput() + + # Resolve inheritance for richer validation + try: + from deepwork.deepschema.resolver import resolve_all + + schemas, _ = resolve_all(schemas) + except Exception: + pass # Continue with unresolved schemas + + messages: list[str] = [] + errors: list[str] = [] + + # Conformance notes + for schema in schemas: + schema_rel = _relative_path(schema.source_path, project_root) + messages.append(f"Note: this file must conform to the DeepSchema at {schema_rel}") + + # Run validations + abs_file = project_root / rel_path + for schema in schemas: + # JSON Schema validation + if schema.json_schema_path: + json_schema_abs = schema.source_path.parent / schema.json_schema_path + err = _validate_json_schema(abs_file, json_schema_abs) + if err: + errors.append(err) + + # Bash verification commands + for cmd in schema.verification_bash_command: + err = _run_verification_command(cmd, abs_file, project_root) + if err: + errors.append(err) + + # Build output + if errors: + error_text = "\n".join(errors) + note_text = "\n".join(messages) + context = f"{note_text}\n\nCRITICAL: DeepSchema validation failed when it tried to verify this change.\n\n{error_text}" + return HookOutput(context=context) + elif messages: + return HookOutput(context="\n".join(messages)) + + return HookOutput() + + +def _relative_path(path: Path, project_root: Path) -> str: + """Get a project-relative path string.""" + try: + return str(path.relative_to(project_root)) + except ValueError: + return str(path) + + +def _validate_json_schema(filepath: Path, schema_path: Path) -> str | None: + """Validate a file against a JSON Schema. + + Returns error message or None on success. + """ + if not schema_path.exists(): + return f"JSON Schema file not found: {schema_path}" + + try: + content = filepath.read_text(encoding="utf-8") + parsed = json.loads(content) + except json.JSONDecodeError as e: + return f"File is not valid JSON: {e}" + except OSError as e: + return f"Cannot read file: {e}" + + try: + schema_data = json.loads(schema_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as e: + return f"Cannot read JSON Schema: {e}" + + try: + from deepwork.utils.validation import ValidationError, validate_against_schema + + validate_against_schema(parsed, schema_data) + except ValidationError as e: + return f"JSON Schema validation failed: {e}" + + return None + + +def _run_verification_command(cmd: str, filepath: Path, project_root: Path) -> str | None: + """Run a verification bash command on the file. + + The command receives the file path as $1. Returns error message or None. + """ + try: + result = subprocess.run( + ["bash", "-c", cmd, "--", str(filepath)], + cwd=project_root, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + output = (result.stdout + result.stderr).strip() + return f"Command `{cmd}` failed (exit {result.returncode}): {output}" + except subprocess.TimeoutExpired: + return f"Command `{cmd}` timed out after 30s" + except OSError as e: + return f"Failed to run command `{cmd}`: {e}" + + return None + + +def main() -> int: + """Entry point for the hook CLI.""" + platform = Platform(os.environ.get("DEEPWORK_HOOK_PLATFORM", "claude")) + return run_hook(deepschema_write_hook, platform) + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + output_hook_error(e, context="deepschema_write hook") + sys.exit(0) diff --git a/src/deepwork/jobs/mcp/quality_gate.py b/src/deepwork/jobs/mcp/quality_gate.py index 18359b8f..a77d6079 100644 --- a/src/deepwork/jobs/mcp/quality_gate.py +++ b/src/deepwork/jobs/mcp/quality_gate.py @@ -320,6 +320,12 @@ def run_quality_gate( # 3. Load .deepreview rules deepreview_rules, _errors = load_all_rules(project_root) + # 3b. Load DeepSchema-generated review rules + from deepwork.deepschema.review_bridge import generate_review_rules as gen_schema_rules + + schema_rules, _schema_errors = gen_schema_rules(project_root) + deepreview_rules.extend(schema_rules) + # 4. Get the "changed files" list = output file paths output_files = _collect_output_file_paths(outputs, job) diff --git a/src/deepwork/review/mcp.py b/src/deepwork/review/mcp.py index d432af41..0ada08fe 100644 --- a/src/deepwork/review/mcp.py +++ b/src/deepwork/review/mcp.py @@ -69,11 +69,19 @@ def run_review( # Step 1: Discover .deepreview files and parse rules rules, discovery_errors = load_all_rules(project_root) + # Step 1b: Discover DeepSchemas and generate synthetic rules + from deepwork.deepschema.review_bridge import generate_review_rules as gen_schema_rules + + schema_rules, schema_errors = gen_schema_rules(project_root) + rules.extend(schema_rules) + for err in schema_errors: + discovery_errors.append(DiscoveryError(file_path=Path(err.split(":")[0]), error=err)) + if not rules: if discovery_errors: warnings = _format_discovery_warnings(discovery_errors) - return f"No valid .deepreview rules found. Parse errors:\n{warnings}" - return "No .deepreview configuration files found." + return f"No valid review rules found. Parse errors:\n{warnings}" + return "No .deepreview configuration files or DeepSchema definitions found." # Step 2: Determine changed files if files is not None: @@ -126,6 +134,12 @@ def get_configured_reviews( """ rules, errors = load_all_rules(project_root) + # Also include DeepSchema-generated rules + from deepwork.deepschema.review_bridge import generate_review_rules as gen_schema_rules + + schema_rules, _schema_errors = gen_schema_rules(project_root) + rules.extend(schema_rules) + if only_rules_matching_files is not None: rules = [ rule for rule in rules if match_rule(only_rules_matching_files, rule, project_root) diff --git a/src/deepwork/schemas/deepschema_schema.json b/src/deepwork/schemas/deepschema_schema.json new file mode 100644 index 00000000..a828c707 --- /dev/null +++ b/src/deepwork/schemas/deepschema_schema.json @@ -0,0 +1,90 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DeepSchema Definition", + "description": "Schema for deepschema.yml files used to define file-level schemas with requirements, validation, and review generation.", + "type": "object", + "additionalProperties": false, + "properties": { + "requirements": { + "type": "object", + "description": "RFC 2119 keyed requirements. Keys are requirement names, values are descriptions using MUST, SHOULD, etc.", + "patternProperties": { + "^[a-zA-Z0-9_-]+$": { + "type": "string" + } + }, + "additionalProperties": false + }, + "parent_deep_schemas": { + "type": "array", + "description": "Names of parent DeepSchemas whose requirements are inherited.", + "items": { + "type": "string" + } + }, + "json_schema_path": { + "type": "string", + "description": "Relative path to a JSON Schema file to enforce on matched files." + }, + "verification_bash_command": { + "type": "array", + "description": "Bash commands to run on the file for verification. Each string is a separate command.", + "items": { + "type": "string" + } + }, + "summary": { + "type": "string", + "description": "High-level summary of the schema type. Used for search/discovery." + }, + "instructions": { + "type": "string", + "description": "General instructions for dealing with files of this type." + }, + "examples": { + "type": "array", + "description": "Example files of this type done well.", + "items": { + "type": "object", + "required": ["path", "description"], + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "description": "Relative path from the definition file to the example." + }, + "description": { + "type": "string", + "description": "Description of what makes this a good example." + } + } + } + }, + "references": { + "type": "array", + "description": "Reference files or URLs for detailed guidance.", + "items": { + "type": "object", + "required": ["path", "description"], + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "description": "Relative path or URL to the reference." + }, + "description": { + "type": "string", + "description": "Description of what this reference covers." + } + } + } + }, + "matchers": { + "type": "array", + "description": "Glob patterns declaring which files this schema applies to.", + "items": { + "type": "string" + } + } + } +} diff --git a/src/deepwork/standard_schemas/.gitkeep b/src/deepwork/standard_schemas/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/deepschema/__init__.py b/tests/unit/deepschema/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/deepschema/test_config.py b/tests/unit/deepschema/test_config.py new file mode 100644 index 00000000..13531fba --- /dev/null +++ b/tests/unit/deepschema/test_config.py @@ -0,0 +1,128 @@ +"""Tests for DeepSchema configuration parsing.""" + +from pathlib import Path + +import pytest + +from deepwork.deepschema.config import DeepSchemaError, parse_deepschema_file + + +def _write_schema(path: Path, filename: str, content: str) -> Path: + filepath = path / filename + filepath.write_text(content, encoding="utf-8") + return filepath + + +class TestParseDeepschemaFile: + def test_parses_named_schema(self, tmp_path: Path) -> None: + filepath = _write_schema( + tmp_path, + "deepschema.yml", + """ +summary: "Job definitions" +instructions: "Keep them concise" +requirements: + must-have-name: "MUST include a name field" + should-be-short: "SHOULD keep summaries under 200 chars" +matchers: + - "**/*.yml" +""", + ) + schema = parse_deepschema_file(filepath, "named", "job_def") + assert schema.name == "job_def" + assert schema.schema_type == "named" + assert schema.summary == "Job definitions" + assert schema.instructions == "Keep them concise" + assert len(schema.requirements) == 2 + assert schema.requirements["must-have-name"] == "MUST include a name field" + assert schema.matchers == ["**/*.yml"] + + def test_parses_anonymous_schema(self, tmp_path: Path) -> None: + filepath = _write_schema( + tmp_path, + ".deepschema.config.json.yml", + """ +requirements: + valid-json: "MUST be valid JSON" +json_schema_path: "config.schema.json" +""", + ) + schema = parse_deepschema_file(filepath, "anonymous", "config.json") + assert schema.name == "config.json" + assert schema.schema_type == "anonymous" + assert schema.json_schema_path == "config.schema.json" + assert schema.requirements["valid-json"] == "MUST be valid JSON" + + def test_parses_empty_file(self, tmp_path: Path) -> None: + filepath = _write_schema(tmp_path, "deepschema.yml", "") + schema = parse_deepschema_file(filepath, "named", "empty") + assert schema.name == "empty" + assert schema.requirements == {} + assert schema.matchers == [] + + def test_parses_verification_commands(self, tmp_path: Path) -> None: + filepath = _write_schema( + tmp_path, + "deepschema.yml", + """ +verification_bash_command: + - "yamllint $1" + - "python -m json.tool $1" +""", + ) + schema = parse_deepschema_file(filepath, "named", "test") + assert schema.verification_bash_command == ["yamllint $1", "python -m json.tool $1"] + + def test_parses_parent_schemas(self, tmp_path: Path) -> None: + filepath = _write_schema( + tmp_path, + "deepschema.yml", + """ +parent_deep_schemas: + - base_config + - yaml_file +requirements: + child-req: "MUST have child requirement" +""", + ) + schema = parse_deepschema_file(filepath, "named", "child") + assert schema.parent_deep_schemas == ["base_config", "yaml_file"] + + def test_parses_examples_and_references(self, tmp_path: Path) -> None: + filepath = _write_schema( + tmp_path, + "deepschema.yml", + """ +examples: + - path: "examples/good.yml" + description: "A well-formed config" +references: + - path: "https://example.com/docs" + description: "Official documentation" +""", + ) + schema = parse_deepschema_file(filepath, "named", "test") + assert len(schema.examples) == 1 + assert schema.examples[0]["path"] == "examples/good.yml" + assert len(schema.references) == 1 + assert schema.references[0]["path"] == "https://example.com/docs" + + def test_rejects_invalid_yaml(self, tmp_path: Path) -> None: + filepath = _write_schema(tmp_path, "deepschema.yml", "[not a dict]") + with pytest.raises(DeepSchemaError): + parse_deepschema_file(filepath, "named", "test") + + def test_rejects_unknown_keys(self, tmp_path: Path) -> None: + filepath = _write_schema( + tmp_path, + "deepschema.yml", + """ +unknown_key: "value" +""", + ) + with pytest.raises(DeepSchemaError, match="Schema validation failed"): + parse_deepschema_file(filepath, "named", "test") + + def test_missing_file(self, tmp_path: Path) -> None: + with pytest.raises(DeepSchemaError, match="File not found"): + parse_deepschema_file(tmp_path / "nonexistent.yml", "named", "test") diff --git a/tests/unit/deepschema/test_discovery.py b/tests/unit/deepschema/test_discovery.py new file mode 100644 index 00000000..5926b98b --- /dev/null +++ b/tests/unit/deepschema/test_discovery.py @@ -0,0 +1,188 @@ +"""Tests for DeepSchema discovery.""" + +from pathlib import Path +from unittest.mock import patch + +from deepwork.deepschema.discovery import ( + ENV_ADDITIONAL_SCHEMAS_FOLDERS, + anonymous_target_filename, + discover_all_schemas, + find_anonymous_schemas, + find_named_schemas, + get_named_schema_folders, +) + + +def _make_named_schema(project_root: Path, name: str, content: str = "") -> Path: + schema_dir = project_root / ".deepwork" / "schemas" / name + schema_dir.mkdir(parents=True, exist_ok=True) + manifest = schema_dir / "deepschema.yml" + manifest.write_text(content or f"summary: '{name} schema'\n", encoding="utf-8") + return manifest + + +def _make_anonymous_schema(directory: Path, target_filename: str, content: str = "") -> Path: + filepath = directory / f".deepschema.{target_filename}.yml" + filepath.write_text( + content or "requirements:\n test-req: 'MUST exist'\n", + encoding="utf-8", + ) + return filepath + + +class TestFindNamedSchemas: + def test_finds_schemas_in_deepwork_dir(self, tmp_path: Path) -> None: + _make_named_schema(tmp_path, "job_def") + _make_named_schema(tmp_path, "config") + results = find_named_schemas(tmp_path) + names = [p.parent.name for p in results] + assert sorted(names) == ["config", "job_def"] + + def test_returns_empty_when_no_schemas_dir(self, tmp_path: Path) -> None: + assert find_named_schemas(tmp_path) == [] + + def test_skips_directories_without_manifest(self, tmp_path: Path) -> None: + schema_dir = tmp_path / ".deepwork" / "schemas" / "incomplete" + schema_dir.mkdir(parents=True) + # No deepschema.yml inside + assert find_named_schemas(tmp_path) == [] + + +class TestFindAnonymousSchemas: + def test_finds_anonymous_schemas(self, tmp_path: Path) -> None: + _make_anonymous_schema(tmp_path, "config.json") + subdir = tmp_path / "src" + subdir.mkdir() + _make_anonymous_schema(subdir, "app.py") + results = find_anonymous_schemas(tmp_path) + assert len(results) == 2 + + def test_skips_git_directories(self, tmp_path: Path) -> None: + git_dir = tmp_path / ".git" + git_dir.mkdir() + _make_anonymous_schema(git_dir, "config.json") + assert find_anonymous_schemas(tmp_path) == [] + + def test_returns_empty_when_none(self, tmp_path: Path) -> None: + assert find_anonymous_schemas(tmp_path) == [] + + +class TestAnonymousTargetFilename: + def test_simple_filename(self) -> None: + assert anonymous_target_filename(".deepschema.foo.py.yml") == "foo.py" + + def test_dotted_filename(self) -> None: + assert anonymous_target_filename(".deepschema.config.json.yml") == "config.json" + + def test_long_extension(self) -> None: + assert anonymous_target_filename(".deepschema.data.tar.gz.yml") == "data.tar.gz" + + +def _make_named_schema_in(folder: Path, name: str, content: str = "") -> Path: + """Create a named schema in an arbitrary folder (not under .deepwork/schemas).""" + schema_dir = folder / name + schema_dir.mkdir(parents=True, exist_ok=True) + manifest = schema_dir / "deepschema.yml" + manifest.write_text(content or f"summary: '{name} schema'\n", encoding="utf-8") + return manifest + + +class TestGetNamedSchemaFolders: + def test_includes_project_local_and_standard(self, tmp_path: Path) -> None: + folders = get_named_schema_folders(tmp_path) + assert folders[0] == tmp_path / ".deepwork" / "schemas" + # Second entry is the standard_schemas package dir + assert folders[1].name == "standard_schemas" + assert len(folders) == 2 + + @patch.dict("os.environ", {ENV_ADDITIONAL_SCHEMAS_FOLDERS: "/extra/one:/extra/two"}) + def test_includes_env_var_folders(self, tmp_path: Path) -> None: + folders = get_named_schema_folders(tmp_path) + assert len(folders) == 4 + assert folders[2] == Path("/extra/one") + assert folders[3] == Path("/extra/two") + + @patch.dict("os.environ", {ENV_ADDITIONAL_SCHEMAS_FOLDERS: ""}) + def test_empty_env_var_adds_nothing(self, tmp_path: Path) -> None: + folders = get_named_schema_folders(tmp_path) + assert len(folders) == 2 + + +class TestFindNamedSchemasMultiSource: + def test_finds_standard_schemas(self, tmp_path: Path) -> None: + # Create a "standard" schema in a separate directory and patch it in + standard_dir = tmp_path / "standard" + _make_named_schema_in(standard_dir, "builtin_schema") + + with patch("deepwork.deepschema.discovery._STANDARD_SCHEMAS_DIR", standard_dir): + results = find_named_schemas(tmp_path) + names = [p.parent.name for p in results] + assert "builtin_schema" in names + + def test_project_local_overrides_standard(self, tmp_path: Path) -> None: + # Same name in both project-local and standard — project-local wins + _make_named_schema(tmp_path, "shared_name", "summary: 'local'\n") + + standard_dir = tmp_path / "standard" + _make_named_schema_in(standard_dir, "shared_name", "summary: 'standard'\n") + + with patch("deepwork.deepschema.discovery._STANDARD_SCHEMAS_DIR", standard_dir): + results = find_named_schemas(tmp_path) + # Only one result — the local one + matching = [p for p in results if p.parent.name == "shared_name"] + assert len(matching) == 1 + assert ".deepwork/schemas" in str(matching[0]) + + @patch.dict("os.environ", clear=True) + def test_env_var_schemas_discovered(self, tmp_path: Path) -> None: + extra_dir = tmp_path / "extra_schemas" + _make_named_schema_in(extra_dir, "env_schema") + + env_val = str(extra_dir) + with ( + patch.dict("os.environ", {ENV_ADDITIONAL_SCHEMAS_FOLDERS: env_val}), + patch("deepwork.deepschema.discovery._STANDARD_SCHEMAS_DIR", tmp_path / "empty"), + ): + results = find_named_schemas(tmp_path) + names = [p.parent.name for p in results] + assert "env_schema" in names + + @patch.dict("os.environ", clear=True) + def test_project_local_overrides_env_var(self, tmp_path: Path) -> None: + _make_named_schema(tmp_path, "overlap", "summary: 'local'\n") + + extra_dir = tmp_path / "extra" + _make_named_schema_in(extra_dir, "overlap", "summary: 'extra'\n") + + env_val = str(extra_dir) + with ( + patch.dict("os.environ", {ENV_ADDITIONAL_SCHEMAS_FOLDERS: env_val}), + patch("deepwork.deepschema.discovery._STANDARD_SCHEMAS_DIR", tmp_path / "empty"), + ): + results = find_named_schemas(tmp_path) + matching = [p for p in results if p.parent.name == "overlap"] + assert len(matching) == 1 + assert ".deepwork/schemas" in str(matching[0]) + + +class TestDiscoverAllSchemas: + def test_discovers_both_types(self, tmp_path: Path) -> None: + _make_named_schema( + tmp_path, + "yaml_config", + "summary: 'YAML configs'\nmatchers:\n - '**/*.yml'\n", + ) + _make_anonymous_schema(tmp_path, "special.json") + schemas, errors = discover_all_schemas(tmp_path) + assert len(schemas) == 2 + assert len(errors) == 0 + types = {s.schema_type for s in schemas} + assert types == {"named", "anonymous"} + + def test_collects_parse_errors(self, tmp_path: Path) -> None: + schema_dir = tmp_path / ".deepwork" / "schemas" / "bad" + schema_dir.mkdir(parents=True) + (schema_dir / "deepschema.yml").write_text("[invalid]", encoding="utf-8") + schemas, errors = discover_all_schemas(tmp_path) + assert len(schemas) == 0 + assert len(errors) == 1 diff --git a/tests/unit/deepschema/test_matcher.py b/tests/unit/deepschema/test_matcher.py new file mode 100644 index 00000000..48a6063c --- /dev/null +++ b/tests/unit/deepschema/test_matcher.py @@ -0,0 +1,94 @@ +"""Tests for DeepSchema file matching.""" + +from pathlib import Path + +from deepwork.deepschema.config import DeepSchema +from deepwork.deepschema.matcher import get_applicable_schemas, get_schemas_for_file_fast + + +def _named_schema( + name: str, + matchers: list[str], + source_path: Path | None = None, +) -> DeepSchema: + return DeepSchema( + name=name, + schema_type="named", + source_path=source_path or Path(f"/fake/.deepwork/schemas/{name}/deepschema.yml"), + matchers=matchers, + requirements={"test": "MUST pass"}, + ) + + +def _anonymous_schema( + target_filename: str, + directory: Path, +) -> DeepSchema: + return DeepSchema( + name=target_filename, + schema_type="anonymous", + source_path=directory / f".deepschema.{target_filename}.yml", + requirements={"test": "MUST pass"}, + ) + + +class TestGetApplicableSchemas: + def test_matches_named_schema_by_glob(self, tmp_path: Path) -> None: + schema = _named_schema("py_files", ["**/*.py"]) + result = get_applicable_schemas("src/app.py", [schema], tmp_path) + assert len(result) == 1 + assert result[0].name == "py_files" + + def test_no_match_for_wrong_extension(self, tmp_path: Path) -> None: + schema = _named_schema("py_files", ["**/*.py"]) + result = get_applicable_schemas("src/app.js", [schema], tmp_path) + assert len(result) == 0 + + def test_matches_anonymous_schema(self, tmp_path: Path) -> None: + schema = _anonymous_schema("config.json", tmp_path / "src") + result = get_applicable_schemas("src/config.json", [schema], tmp_path) + assert len(result) == 1 + + def test_anonymous_no_match_different_dir(self, tmp_path: Path) -> None: + schema = _anonymous_schema("config.json", tmp_path / "src") + result = get_applicable_schemas("other/config.json", [schema], tmp_path) + assert len(result) == 0 + + def test_multiple_schemas_match(self, tmp_path: Path) -> None: + named = _named_schema("all_yml", ["**/*.yml"]) + anon = _anonymous_schema("config.yml", tmp_path / "src") + result = get_applicable_schemas("src/config.yml", [named, anon], tmp_path) + assert len(result) == 2 + + def test_named_schema_specific_dir_pattern(self, tmp_path: Path) -> None: + schema = _named_schema("src_only", ["src/**/*.py"]) + assert get_applicable_schemas("src/main.py", [schema], tmp_path) + assert not get_applicable_schemas("tests/main.py", [schema], tmp_path) + + +class TestGetSchemasForFileFast: + def test_finds_named_schema(self, tmp_path: Path) -> None: + schema_dir = tmp_path / ".deepwork" / "schemas" / "py_files" + schema_dir.mkdir(parents=True) + (schema_dir / "deepschema.yml").write_text( + "matchers:\n - '**/*.py'\nrequirements:\n r1: 'MUST exist'\n", + encoding="utf-8", + ) + result = get_schemas_for_file_fast("src/app.py", tmp_path) + assert len(result) == 1 + assert result[0].name == "py_files" + + def test_finds_anonymous_schema(self, tmp_path: Path) -> None: + src = tmp_path / "src" + src.mkdir() + (src / ".deepschema.app.py.yml").write_text( + "requirements:\n r1: 'MUST exist'\n", + encoding="utf-8", + ) + result = get_schemas_for_file_fast("src/app.py", tmp_path) + assert len(result) == 1 + assert result[0].name == "app.py" + + def test_returns_empty_when_no_schemas(self, tmp_path: Path) -> None: + result = get_schemas_for_file_fast("src/app.py", tmp_path) + assert result == [] diff --git a/tests/unit/deepschema/test_resolver.py b/tests/unit/deepschema/test_resolver.py new file mode 100644 index 00000000..80dd705f --- /dev/null +++ b/tests/unit/deepschema/test_resolver.py @@ -0,0 +1,119 @@ +"""Tests for DeepSchema parent inheritance resolution.""" + +from pathlib import Path + +import pytest + +from deepwork.deepschema.config import DeepSchema, DeepSchemaError +from deepwork.deepschema.resolver import resolve_all, resolve_inheritance + + +def _schema( + name: str, + requirements: dict[str, str] | None = None, + parents: list[str] | None = None, + json_schema_path: str | None = None, + verification_cmds: list[str] | None = None, +) -> DeepSchema: + return DeepSchema( + name=name, + schema_type="named", + source_path=Path(f"/fake/{name}/deepschema.yml"), + requirements=requirements or {}, + parent_deep_schemas=parents or [], + json_schema_path=json_schema_path, + verification_bash_command=verification_cmds or [], + ) + + +class TestResolveInheritance: + def test_no_parents_returns_unchanged(self) -> None: + schema = _schema("child", requirements={"r1": "MUST exist"}) + named = {"child": schema} + result = resolve_inheritance(schema, named) + assert result.requirements == {"r1": "MUST exist"} + + def test_inherits_parent_requirements(self) -> None: + parent = _schema("parent", requirements={"p1": "MUST be valid"}) + child = _schema("child", requirements={"c1": "SHOULD be nice"}, parents=["parent"]) + named = {"parent": parent, "child": child} + result = resolve_inheritance(child, named) + assert result.requirements == {"p1": "MUST be valid", "c1": "SHOULD be nice"} + + def test_child_overrides_parent_requirements(self) -> None: + parent = _schema("parent", requirements={"shared": "MUST do X"}) + child = _schema("child", requirements={"shared": "MUST do Y"}, parents=["parent"]) + named = {"parent": parent, "child": child} + result = resolve_inheritance(child, named) + assert result.requirements["shared"] == "MUST do Y" + + def test_inherits_json_schema_path(self) -> None: + parent = _schema("parent", json_schema_path="schema.json") + child = _schema("child", parents=["parent"]) + named = {"parent": parent, "child": child} + result = resolve_inheritance(child, named) + assert result.json_schema_path == "schema.json" + + def test_child_json_schema_overrides_parent(self) -> None: + parent = _schema("parent", json_schema_path="parent.json") + child = _schema("child", json_schema_path="child.json", parents=["parent"]) + named = {"parent": parent, "child": child} + result = resolve_inheritance(child, named) + assert result.json_schema_path == "child.json" + + def test_appends_verification_commands(self) -> None: + parent = _schema("parent", verification_cmds=["lint $1"]) + child = _schema("child", verification_cmds=["validate $1"], parents=["parent"]) + named = {"parent": parent, "child": child} + result = resolve_inheritance(child, named) + assert result.verification_bash_command == ["lint $1", "validate $1"] + + def test_detects_circular_reference(self) -> None: + a = _schema("a", parents=["b"]) + b = _schema("b", parents=["a"]) + named = {"a": a, "b": b} + with pytest.raises(DeepSchemaError, match="Circular parent reference"): + resolve_inheritance(a, named) + + def test_missing_parent_raises_error(self) -> None: + child = _schema("child", parents=["nonexistent"]) + named = {"child": child} + with pytest.raises(DeepSchemaError, match="unknown parent"): + resolve_inheritance(child, named) + + def test_multi_level_inheritance(self) -> None: + grandparent = _schema("gp", requirements={"gp_req": "MUST be A"}) + parent = _schema("parent", requirements={"p_req": "MUST be B"}, parents=["gp"]) + child = _schema("child", requirements={"c_req": "MUST be C"}, parents=["parent"]) + named = {"gp": grandparent, "parent": parent, "child": child} + result = resolve_inheritance(child, named) + assert result.requirements == { + "gp_req": "MUST be A", + "p_req": "MUST be B", + "c_req": "MUST be C", + } + + +class TestResolveAll: + def test_resolves_all_schemas(self) -> None: + parent = _schema("parent", requirements={"p1": "MUST be valid"}) + child = _schema("child", requirements={"c1": "SHOULD be nice"}, parents=["parent"]) + anon = DeepSchema( + name="config.json", + schema_type="anonymous", + source_path=Path("/fake/.deepschema.config.json.yml"), + requirements={"r1": "MUST exist"}, + ) + resolved, errors = resolve_all([parent, child, anon]) + assert len(resolved) == 3 + assert len(errors) == 0 + # Child should have merged requirements + child_resolved = next(s for s in resolved if s.name == "child") + assert "p1" in child_resolved.requirements + assert "c1" in child_resolved.requirements + + def test_collects_resolution_errors(self) -> None: + child = _schema("child", parents=["missing"]) + resolved, errors = resolve_all([child]) + assert len(errors) == 1 + assert "missing" in errors[0] diff --git a/tests/unit/deepschema/test_review_bridge.py b/tests/unit/deepschema/test_review_bridge.py new file mode 100644 index 00000000..a55bd508 --- /dev/null +++ b/tests/unit/deepschema/test_review_bridge.py @@ -0,0 +1,128 @@ +"""Tests for DeepSchema review bridge — synthetic ReviewRule generation.""" + +from pathlib import Path + +from deepwork.deepschema.config import DeepSchema +from deepwork.deepschema.review_bridge import ( + _build_anonymous_instructions, + _build_named_instructions, + _build_requirements_body, + _schema_to_review_rule, + generate_review_rules, +) + + +def _named_schema( + tmp_path: Path, + name: str = "test_schema", + matchers: list[str] | None = None, + requirements: dict[str, str] | None = None, + summary: str | None = "Test schema", + instructions: str | None = "Handle with care", +) -> DeepSchema: + source = tmp_path / ".deepwork" / "schemas" / name / "deepschema.yml" + source.parent.mkdir(parents=True, exist_ok=True) + return DeepSchema( + name=name, + schema_type="named", + source_path=source, + requirements=requirements or {"r1": "MUST be valid"}, + matchers=matchers or ["**/*.yml"], + summary=summary, + instructions=instructions, + ) + + +def _anonymous_schema( + tmp_path: Path, + target: str = "config.json", + requirements: dict[str, str] | None = None, +) -> DeepSchema: + source = tmp_path / f".deepschema.{target}.yml" + return DeepSchema( + name=target, + schema_type="anonymous", + source_path=source, + requirements=requirements or {"r1": "MUST be valid"}, + ) + + +class TestSchemaToReviewRule: + def test_named_schema_produces_rule(self, tmp_path: Path) -> None: + schema = _named_schema(tmp_path) + rule = _schema_to_review_rule(schema, tmp_path) + assert rule is not None + assert rule.name == "test_schema DeepSchema Compliance" + assert rule.strategy == "individual" + assert rule.include_patterns == ["**/*.yml"] + + def test_anonymous_schema_produces_rule(self, tmp_path: Path) -> None: + schema = _anonymous_schema(tmp_path) + rule = _schema_to_review_rule(schema, tmp_path) + assert rule is not None + assert rule.name == "config.json DeepSchema Compliance" + assert rule.strategy == "individual" + assert rule.include_patterns == ["config.json"] + + def test_named_without_matchers_returns_none(self, tmp_path: Path) -> None: + schema = _named_schema(tmp_path) + schema.matchers = [] + rule = _schema_to_review_rule(schema, tmp_path) + assert rule is None + + def test_schema_without_requirements_returns_none(self, tmp_path: Path) -> None: + schema = _named_schema(tmp_path) + schema.requirements = {} + rule = _schema_to_review_rule(schema, tmp_path) + assert rule is None + + def test_rule_source_file_points_to_schema(self, tmp_path: Path) -> None: + schema = _named_schema(tmp_path) + rule = _schema_to_review_rule(schema, tmp_path) + assert rule is not None + assert rule.source_file == schema.source_path + + +class TestBuildInstructions: + def test_named_includes_summary_and_instructions(self, tmp_path: Path) -> None: + schema = _named_schema(tmp_path, summary="YAML configs", instructions="Validate carefully") + text = _build_named_instructions(schema) + assert "test_schema" in text + assert "YAML configs" in text + assert "Validate carefully" in text + assert "MUST be valid" in text + + def test_anonymous_is_simpler(self, tmp_path: Path) -> None: + schema = _anonymous_schema(tmp_path) + text = _build_anonymous_instructions(schema) + assert "has requirements" in text + assert "MUST be valid" in text + + def test_requirements_body_includes_rfc_guidance(self) -> None: + body = _build_requirements_body({"r1": "MUST exist", "r2": "SHOULD be nice"}) + assert "fail reviews over anything that is MUST" in body + assert "r1" in body + assert "r2" in body + + +class TestGenerateReviewRules: + def test_generates_from_named_schema(self, tmp_path: Path) -> None: + schema_dir = tmp_path / ".deepwork" / "schemas" / "yml_files" + schema_dir.mkdir(parents=True) + (schema_dir / "deepschema.yml").write_text( + "summary: 'YAML files'\nmatchers:\n - '**/*.yml'\nrequirements:\n r1: 'MUST exist'\n", + encoding="utf-8", + ) + rules, errors = generate_review_rules(tmp_path) + assert len(rules) == 1 + assert rules[0].name == "yml_files DeepSchema Compliance" + assert len(errors) == 0 + + def test_generates_from_anonymous_schema(self, tmp_path: Path) -> None: + (tmp_path / ".deepschema.config.json.yml").write_text( + "requirements:\n r1: 'MUST be valid JSON'\n", + encoding="utf-8", + ) + rules, errors = generate_review_rules(tmp_path) + assert len(rules) == 1 + assert rules[0].name == "config.json DeepSchema Compliance" diff --git a/tests/unit/deepschema/test_write_hook.py b/tests/unit/deepschema/test_write_hook.py new file mode 100644 index 00000000..ec91f007 --- /dev/null +++ b/tests/unit/deepschema/test_write_hook.py @@ -0,0 +1,144 @@ +"""Tests for the DeepSchema write hook.""" + +import json +from pathlib import Path + +from deepwork.hooks.deepschema_write import deepschema_write_hook +from deepwork.hooks.wrapper import HookInput, NormalizedEvent, Platform + + +def _make_hook_input( + file_path: str, + cwd: str, + tool_name: str = "write_file", +) -> HookInput: + return HookInput( + platform=Platform.CLAUDE, + event=NormalizedEvent.AFTER_TOOL, + tool_name=tool_name, + tool_input={"file_path": file_path}, + cwd=cwd, + ) + + +class TestDeepschemaWriteHook: + def test_no_schemas_returns_empty(self, tmp_path: Path) -> None: + hook_input = _make_hook_input(str(tmp_path / "test.py"), str(tmp_path)) + result = deepschema_write_hook(hook_input) + assert result.context == "" + assert result.decision == "" + + def test_injects_conformance_note(self, tmp_path: Path) -> None: + # Create a named schema matching .py files + schema_dir = tmp_path / ".deepwork" / "schemas" / "py_schema" + schema_dir.mkdir(parents=True) + (schema_dir / "deepschema.yml").write_text( + "matchers:\n - '**/*.py'\nrequirements:\n r1: 'MUST exist'\n", + encoding="utf-8", + ) + # Create the target file + target = tmp_path / "src" / "app.py" + target.parent.mkdir(parents=True) + target.write_text("print('hello')", encoding="utf-8") + + hook_input = _make_hook_input(str(target), str(tmp_path)) + result = deepschema_write_hook(hook_input) + assert "must conform to the DeepSchema" in result.context + + def test_anonymous_schema_conformance_note(self, tmp_path: Path) -> None: + # Create anonymous schema + (tmp_path / ".deepschema.config.json.yml").write_text( + "requirements:\n r1: 'MUST be valid'\n", + encoding="utf-8", + ) + target = tmp_path / "config.json" + target.write_text('{"key": "value"}', encoding="utf-8") + + hook_input = _make_hook_input(str(target), str(tmp_path)) + result = deepschema_write_hook(hook_input) + assert "must conform to the DeepSchema" in result.context + + def test_json_schema_validation_failure(self, tmp_path: Path) -> None: + # Create a JSON Schema + json_schema = tmp_path / ".deepwork" / "schemas" / "json_test" / "test.schema.json" + json_schema.parent.mkdir(parents=True) + json_schema.write_text( + json.dumps({ + "type": "object", + "required": ["name"], + "properties": {"name": {"type": "string"}}, + }), + encoding="utf-8", + ) + # Create schema referencing the JSON Schema + (json_schema.parent / "deepschema.yml").write_text( + "matchers:\n - '**/*.json'\njson_schema_path: 'test.schema.json'\nrequirements:\n r1: 'MUST conform'\n", + encoding="utf-8", + ) + # Write invalid JSON + target = tmp_path / "data.json" + target.write_text('{"other": "field"}', encoding="utf-8") + + hook_input = _make_hook_input(str(target), str(tmp_path)) + result = deepschema_write_hook(hook_input) + assert "CRITICAL" in result.context + assert "validation failed" in result.context.lower() + + def test_json_schema_validation_success(self, tmp_path: Path) -> None: + json_schema = tmp_path / ".deepwork" / "schemas" / "json_test" / "test.schema.json" + json_schema.parent.mkdir(parents=True) + json_schema.write_text( + json.dumps({ + "type": "object", + "required": ["name"], + "properties": {"name": {"type": "string"}}, + }), + encoding="utf-8", + ) + (json_schema.parent / "deepschema.yml").write_text( + "matchers:\n - '**/*.json'\njson_schema_path: 'test.schema.json'\nrequirements:\n r1: 'MUST conform'\n", + encoding="utf-8", + ) + target = tmp_path / "data.json" + target.write_text('{"name": "valid"}', encoding="utf-8") + + hook_input = _make_hook_input(str(target), str(tmp_path)) + result = deepschema_write_hook(hook_input) + assert "must conform to the DeepSchema" in result.context + assert "CRITICAL" not in result.context + + def test_verification_command_failure(self, tmp_path: Path) -> None: + schema_dir = tmp_path / ".deepwork" / "schemas" / "bash_test" + schema_dir.mkdir(parents=True) + (schema_dir / "deepschema.yml").write_text( + "matchers:\n - '**/*.txt'\nverification_bash_command:\n - 'grep required_word \"$1\"'\nrequirements:\n r1: 'MUST contain required_word'\n", + encoding="utf-8", + ) + target = tmp_path / "test.txt" + target.write_text("no matching content here", encoding="utf-8") + + hook_input = _make_hook_input(str(target), str(tmp_path)) + result = deepschema_write_hook(hook_input) + assert "CRITICAL" in result.context + + def test_ignores_non_write_events(self) -> None: + hook_input = HookInput( + platform=Platform.CLAUDE, + event=NormalizedEvent.BEFORE_TOOL, + tool_name="write_file", + tool_input={"file_path": "/some/file"}, + cwd="/tmp", + ) + result = deepschema_write_hook(hook_input) + assert result.context == "" + + def test_ignores_non_write_tools(self) -> None: + hook_input = HookInput( + platform=Platform.CLAUDE, + event=NormalizedEvent.AFTER_TOOL, + tool_name="shell", + tool_input={"command": "echo hi"}, + cwd="/tmp", + ) + result = deepschema_write_hook(hook_input) + assert result.context == "" diff --git a/tests/unit/review/test_mcp.py b/tests/unit/review/test_mcp.py index 451596a0..8d3d1b23 100644 --- a/tests/unit/review/test_mcp.py +++ b/tests/unit/review/test_mcp.py @@ -40,7 +40,7 @@ class TestRunReview: def test_no_deepreview_files_returns_message(self, mock_load: Any, tmp_path: Path) -> None: mock_load.return_value = ([], []) result = run_review(tmp_path, "claude") - assert "No .deepreview configuration files found" in result + assert "No .deepreview configuration files" in result @patch("deepwork.review.mcp.get_changed_files") @patch("deepwork.review.mcp.load_all_rules") From 12d522d6d8b1601fbfec164aecfcbce2acede77f Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Mon, 30 Mar 2026 11:15:46 -0600 Subject: [PATCH 03/15] feat: add standard deepschema for deepschema files, review fixes, test fixture - Add self-describing DeepSchema in standard_schemas/deepschema/ with JSON Schema validation and 8 RFC 2119 requirements for deepschema.yml files - Move deferred imports to top-level in review/mcp.py and quality_gate.py (review finding: DRY violation) - Remove fragile string splitting for schema error paths (review finding) - Add without_standard_schemas pytest fixture in conftest.py for clean test environments - Fix tests to account for standard schemas being auto-discovered Co-Authored-By: Claude Opus 4.6 (1M context) --- src/deepwork/jobs/mcp/quality_gate.py | 3 +- src/deepwork/review/mcp.py | 7 +- src/deepwork/standard_schemas/.gitkeep | 0 .../deepschema/deepschema.yml | 23 ++++ .../deepschema/deepschema_schema.json | 90 ++++++++++++++ .../standard_schemas/job_yml/deepschema.yml | 115 ++++++++++++++++++ tests/conftest.py | 15 +++ tests/unit/deepschema/test_discovery.py | 27 ++-- tests/unit/deepschema/test_review_bridge.py | 9 +- tests/unit/deepschema/test_write_hook.py | 24 ++-- tests/unit/review/test_mcp.py | 7 +- 11 files changed, 290 insertions(+), 30 deletions(-) delete mode 100644 src/deepwork/standard_schemas/.gitkeep create mode 100644 src/deepwork/standard_schemas/deepschema/deepschema.yml create mode 100644 src/deepwork/standard_schemas/deepschema/deepschema_schema.json create mode 100644 src/deepwork/standard_schemas/job_yml/deepschema.yml diff --git a/src/deepwork/jobs/mcp/quality_gate.py b/src/deepwork/jobs/mcp/quality_gate.py index a77d6079..b5132fae 100644 --- a/src/deepwork/jobs/mcp/quality_gate.py +++ b/src/deepwork/jobs/mcp/quality_gate.py @@ -18,6 +18,7 @@ Workflow, WorkflowStep, ) +from deepwork.deepschema.review_bridge import generate_review_rules as gen_schema_rules from deepwork.review.config import ReviewRule, ReviewTask from deepwork.review.discovery import load_all_rules from deepwork.review.formatter import format_for_claude @@ -321,8 +322,6 @@ def run_quality_gate( deepreview_rules, _errors = load_all_rules(project_root) # 3b. Load DeepSchema-generated review rules - from deepwork.deepschema.review_bridge import generate_review_rules as gen_schema_rules - schema_rules, _schema_errors = gen_schema_rules(project_root) deepreview_rules.extend(schema_rules) diff --git a/src/deepwork/review/mcp.py b/src/deepwork/review/mcp.py index 0ada08fe..4ea5cb2c 100644 --- a/src/deepwork/review/mcp.py +++ b/src/deepwork/review/mcp.py @@ -6,6 +6,7 @@ from pathlib import Path +from deepwork.deepschema.review_bridge import generate_review_rules as gen_schema_rules from deepwork.review.discovery import DiscoveryError, load_all_rules from deepwork.review.formatter import format_for_claude from deepwork.review.instructions import INSTRUCTIONS_DIR, write_instruction_files @@ -70,12 +71,10 @@ def run_review( rules, discovery_errors = load_all_rules(project_root) # Step 1b: Discover DeepSchemas and generate synthetic rules - from deepwork.deepschema.review_bridge import generate_review_rules as gen_schema_rules - schema_rules, schema_errors = gen_schema_rules(project_root) rules.extend(schema_rules) for err in schema_errors: - discovery_errors.append(DiscoveryError(file_path=Path(err.split(":")[0]), error=err)) + discovery_errors.append(DiscoveryError(file_path=Path("deepschema"), error=err)) if not rules: if discovery_errors: @@ -135,8 +134,6 @@ def get_configured_reviews( rules, errors = load_all_rules(project_root) # Also include DeepSchema-generated rules - from deepwork.deepschema.review_bridge import generate_review_rules as gen_schema_rules - schema_rules, _schema_errors = gen_schema_rules(project_root) rules.extend(schema_rules) diff --git a/src/deepwork/standard_schemas/.gitkeep b/src/deepwork/standard_schemas/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/deepwork/standard_schemas/deepschema/deepschema.yml b/src/deepwork/standard_schemas/deepschema/deepschema.yml new file mode 100644 index 00000000..8e36b8bb --- /dev/null +++ b/src/deepwork/standard_schemas/deepschema/deepschema.yml @@ -0,0 +1,23 @@ +summary: DeepSchema definition files that describe file-level schemas with requirements, validation, and review generation. + +instructions: | + DeepSchema files define rich schemas for other files. Named schemas live in + .deepwork/schemas//deepschema.yml. Anonymous schemas are placed alongside + their target file as .deepschema..yml. All keys are optional but + a schema without requirements or matchers has no enforcement effect. + +matchers: + - ".deepwork/schemas/*/deepschema.yml" + - "**/.deepschema.*.yml" + +json_schema_path: "deepschema_schema.json" + +requirements: + requirements-use-rfc-2119: "Requirements MUST use RFC 2119 keywords (MUST, SHOULD, MAY, etc.) to indicate enforcement level." + matchers-for-named: "Named DeepSchemas SHOULD include matchers so they can match files for review generation." + summary-for-named: "Named DeepSchemas SHOULD include a summary for discoverability." + valid-parent-refs: "Any schema listed in parent_deep_schemas MUST reference an existing named schema." + verification-commands-safe: "Commands in verification_bash_command MUST NOT modify the file or have destructive side effects." + json-schema-path-valid: "If json_schema_path is set, it MUST point to a valid JSON Schema file relative to the schema directory." + examples-have-descriptions: "Each entry in examples SHOULD have both a path and a description." + references-have-descriptions: "Each entry in references SHOULD have both a path and a description." diff --git a/src/deepwork/standard_schemas/deepschema/deepschema_schema.json b/src/deepwork/standard_schemas/deepschema/deepschema_schema.json new file mode 100644 index 00000000..a828c707 --- /dev/null +++ b/src/deepwork/standard_schemas/deepschema/deepschema_schema.json @@ -0,0 +1,90 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DeepSchema Definition", + "description": "Schema for deepschema.yml files used to define file-level schemas with requirements, validation, and review generation.", + "type": "object", + "additionalProperties": false, + "properties": { + "requirements": { + "type": "object", + "description": "RFC 2119 keyed requirements. Keys are requirement names, values are descriptions using MUST, SHOULD, etc.", + "patternProperties": { + "^[a-zA-Z0-9_-]+$": { + "type": "string" + } + }, + "additionalProperties": false + }, + "parent_deep_schemas": { + "type": "array", + "description": "Names of parent DeepSchemas whose requirements are inherited.", + "items": { + "type": "string" + } + }, + "json_schema_path": { + "type": "string", + "description": "Relative path to a JSON Schema file to enforce on matched files." + }, + "verification_bash_command": { + "type": "array", + "description": "Bash commands to run on the file for verification. Each string is a separate command.", + "items": { + "type": "string" + } + }, + "summary": { + "type": "string", + "description": "High-level summary of the schema type. Used for search/discovery." + }, + "instructions": { + "type": "string", + "description": "General instructions for dealing with files of this type." + }, + "examples": { + "type": "array", + "description": "Example files of this type done well.", + "items": { + "type": "object", + "required": ["path", "description"], + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "description": "Relative path from the definition file to the example." + }, + "description": { + "type": "string", + "description": "Description of what makes this a good example." + } + } + } + }, + "references": { + "type": "array", + "description": "Reference files or URLs for detailed guidance.", + "items": { + "type": "object", + "required": ["path", "description"], + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "description": "Relative path or URL to the reference." + }, + "description": { + "type": "string", + "description": "Description of what this reference covers." + } + } + } + }, + "matchers": { + "type": "array", + "description": "Glob patterns declaring which files this schema applies to.", + "items": { + "type": "string" + } + } + } +} diff --git a/src/deepwork/standard_schemas/job_yml/deepschema.yml b/src/deepwork/standard_schemas/job_yml/deepschema.yml new file mode 100644 index 00000000..c7aba9ac --- /dev/null +++ b/src/deepwork/standard_schemas/job_yml/deepschema.yml @@ -0,0 +1,115 @@ +summary: "Schema for DeepWork job.yml files that define multi-step AI agent workflows." + +instructions: | + job.yml files define multi-step workflows executed by AI agents. Each job has a shared + data vocabulary (step_arguments), one or more named workflows, and steps with explicit + input/output data flow. The JSON schema enforces structure; these requirements enforce + quality and coherence that structural validation cannot catch. + + Key concepts: + - step_arguments are the data bus — every piece of data flowing between steps is declared here + - Workflows are the execution unit — agents start workflows, not steps + - common_job_info is prepended to every step AND every review prompt automatically + - Reviews cascade from three sources: step output review, step_argument review, .deepreview rules + +json_schema_path: "../../jobs/job.schema.json" + +matchers: + - ".deepwork/jobs/*/job.yml" + - "src/deepwork/standard_jobs/*/job.yml" + - "library/jobs/*/job.yml" + +requirements: + actionable-summary: > + The job `summary` MUST be written as an action describing what the job accomplishes + (e.g., "Analyze competitors and produce a positioning report"), not as a label + (e.g., "Competitive Research"). + + step-arguments-complete: > + `step_arguments` MUST declare every piece of data that flows between steps. If a step + produces data consumed by a later step, that data MUST have a corresponding step_argument. + + step-argument-descriptions-specific: > + Each step_argument `description` MUST be specific enough for an agent to produce or + consume the data without ambiguity. "The job.yml definition file for the new job" is + acceptable; "A YAML file" is not. + + type-selection: > + step_argument `type` MUST be `file_path` for artifacts that would be committed to Git + (reports, configs, code) and `string` for transient context (user answers, names, IDs). + + common-job-info-present: > + Workflows with more than one step SHOULD have `common_job_info_provided_to_all_steps_at_runtime` + providing shared context (problem domain, terminology, conventions, constraints) so that + step instructions and review prompts do not need to repeat it. + + no-duplicated-context: > + Step instructions MUST NOT duplicate content already in `common_job_info_provided_to_all_steps_at_runtime`. + Content needed by multiple steps but not all SHOULD be in a referenced file, not copy-pasted. + + step-instructions-complete: > + Each step with inline `instructions` MUST provide enough detail for an agent to execute + the step without external guidance. Instructions SHOULD include: what to do, how to use + inputs, and what good output looks like. + + logical-data-flow: > + The input/output chain across steps MUST form a logical data flow. Each step's inputs + SHOULD reference outputs from earlier steps. Steps MUST NOT declare inputs that no + prior step produces (unless provided via start_workflow). + + every-step-has-output: > + Every step SHOULD have at least one output. Steps that produce no outputs provide no + reviewable artifacts and cannot pass data to subsequent steps. + + intermediate-outputs-path: > + Intermediate outputs not meant to be persisted SHOULD use `.deepwork/tmp/` paths in their + step_argument name. Final outputs SHOULD use descriptive names outside dot-directories. + + review-criteria-specific: > + Review `instructions` MUST be specific and actionable. "Verify the YAML has at least + 3 steps and each step has both inputs and outputs" is acceptable; "Check if it looks good" + is not. Vague criteria lead to inconsistent reviews. + + review-criteria-pragmatic: > + Review criteria MUST be pragmatic — criteria that cannot apply to a given output SHOULD + auto-pass rather than fail. Avoid criteria that penalize steps for not doing things + outside their scope. + + review-no-redundant-context: > + Review `instructions` MUST NOT repeat domain context already in `common_job_info` since + the framework automatically prepends it to review prompts. + + process-requirements-rfc2119: > + `process_requirements` values MUST use RFC 2119 keywords (MUST, SHOULD, MAY, SHALL, + RECOMMENDED). These are evaluated against the agent's work_summary, so they MUST + describe process expectations, not output format. + + workflow-summary-actionable: > + Each workflow `summary` MUST be concise (under 200 characters) and describe what the + workflow accomplishes, helping agents pick the right workflow when a job has multiple. + + sub-workflow-coherence: > + Steps using `sub_workflow` MUST NOT also have `instructions`. Cross-job sub_workflow + references (with `workflow_job`) SHOULD only be used when the target job is expected + to be available at runtime. + + succinctness: > + Job definitions MUST be succinct. Step instructions SHOULD contain only what the agent + needs to act — not philosophy, not quality criteria already enforced by the workflow + runtime, and not domain context already in common_job_info. + +examples: + - path: "../../jobs/job.schema.json" + description: "The authoritative JSON Schema — defines all valid structural forms." + - path: "../../standard_jobs/deepwork_jobs/templates/job.yml.example" + description: "Complete competitive research job showing step_arguments, workflows, reviews, and data flow." + - path: "../../standard_jobs/deepwork_jobs/templates/job.yml.template" + description: "Canonical template showing all supported fields with placeholder comments." + +references: + - path: "../../../doc/job_yml_guidance.md" + description: "Behavioral impact of every job.yml field — what each field does at runtime." + - path: "../../standard_jobs/deepwork_jobs/templates/step_instruction.md.template" + description: "Expected structure for step instruction files (Objective, Task, Output Format, Quality Criteria)." + - path: "../../standard_jobs/deepwork_jobs/research_report_job_best_practices.md" + description: "Design patterns for report-authoring jobs (five-phase pattern, dataroom convention)." diff --git a/tests/conftest.py b/tests/conftest.py index 057f31e9..6d0b2a50 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ import tempfile from collections.abc import Iterator from pathlib import Path +from unittest.mock import patch import pytest from git import Repo @@ -48,3 +49,17 @@ def complex_job_fixture(fixtures_dir: Path) -> Path: def invalid_job_fixture(fixtures_dir: Path) -> Path: """Return the path to the invalid job fixture.""" return fixtures_dir / "jobs" / "invalid_job" / "job.yml" + + +@pytest.fixture +def without_standard_schemas() -> Iterator[None]: + """Patch out built-in standard schemas so tests only see schemas they create. + + Use this in any test that needs a clean schema environment without the + standard schemas shipped with DeepWork interfering with assertions. + """ + with patch( + "deepwork.deepschema.discovery._STANDARD_SCHEMAS_DIR", + Path("/nonexistent/no_standard_schemas"), + ): + yield diff --git a/tests/unit/deepschema/test_discovery.py b/tests/unit/deepschema/test_discovery.py index 5926b98b..6be6da0c 100644 --- a/tests/unit/deepschema/test_discovery.py +++ b/tests/unit/deepschema/test_discovery.py @@ -36,16 +36,23 @@ def test_finds_schemas_in_deepwork_dir(self, tmp_path: Path) -> None: _make_named_schema(tmp_path, "config") results = find_named_schemas(tmp_path) names = [p.parent.name for p in results] - assert sorted(names) == ["config", "job_def"] + # Project-local schemas plus any built-in standard schemas + assert "config" in names + assert "job_def" in names - def test_returns_empty_when_no_schemas_dir(self, tmp_path: Path) -> None: - assert find_named_schemas(tmp_path) == [] + def test_returns_only_standard_when_no_project_schemas_dir(self, tmp_path: Path) -> None: + results = find_named_schemas(tmp_path) + # Only built-in standard schemas, no project-local ones + for r in results: + assert ".deepwork/schemas" not in str(r) def test_skips_directories_without_manifest(self, tmp_path: Path) -> None: schema_dir = tmp_path / ".deepwork" / "schemas" / "incomplete" schema_dir.mkdir(parents=True) - # No deepschema.yml inside - assert find_named_schemas(tmp_path) == [] + # No deepschema.yml inside — "incomplete" should not appear + results = find_named_schemas(tmp_path) + names = [p.parent.name for p in results] + assert "incomplete" not in names class TestFindAnonymousSchemas: @@ -174,15 +181,19 @@ def test_discovers_both_types(self, tmp_path: Path) -> None: ) _make_anonymous_schema(tmp_path, "special.json") schemas, errors = discover_all_schemas(tmp_path) - assert len(schemas) == 2 assert len(errors) == 0 + # At least the two we created (plus any built-in standard schemas) + names = {s.name for s in schemas} + assert "yaml_config" in names + assert "special.json" in names types = {s.schema_type for s in schemas} - assert types == {"named", "anonymous"} + assert types >= {"named", "anonymous"} def test_collects_parse_errors(self, tmp_path: Path) -> None: schema_dir = tmp_path / ".deepwork" / "schemas" / "bad" schema_dir.mkdir(parents=True) (schema_dir / "deepschema.yml").write_text("[invalid]", encoding="utf-8") schemas, errors = discover_all_schemas(tmp_path) - assert len(schemas) == 0 + # The "bad" schema should produce an error; standard schemas still load assert len(errors) == 1 + assert "bad" in errors[0].error or str(errors[0].file_path).endswith("bad/deepschema.yml") diff --git a/tests/unit/deepschema/test_review_bridge.py b/tests/unit/deepschema/test_review_bridge.py index a55bd508..621eaf31 100644 --- a/tests/unit/deepschema/test_review_bridge.py +++ b/tests/unit/deepschema/test_review_bridge.py @@ -1,6 +1,7 @@ """Tests for DeepSchema review bridge — synthetic ReviewRule generation.""" from pathlib import Path +from unittest.mock import patch from deepwork.deepschema.config import DeepSchema from deepwork.deepschema.review_bridge import ( @@ -114,8 +115,8 @@ def test_generates_from_named_schema(self, tmp_path: Path) -> None: encoding="utf-8", ) rules, errors = generate_review_rules(tmp_path) - assert len(rules) == 1 - assert rules[0].name == "yml_files DeepSchema Compliance" + rule_names = [r.name for r in rules] + assert "yml_files DeepSchema Compliance" in rule_names assert len(errors) == 0 def test_generates_from_anonymous_schema(self, tmp_path: Path) -> None: @@ -124,5 +125,5 @@ def test_generates_from_anonymous_schema(self, tmp_path: Path) -> None: encoding="utf-8", ) rules, errors = generate_review_rules(tmp_path) - assert len(rules) == 1 - assert rules[0].name == "config.json DeepSchema Compliance" + rule_names = [r.name for r in rules] + assert "config.json DeepSchema Compliance" in rule_names diff --git a/tests/unit/deepschema/test_write_hook.py b/tests/unit/deepschema/test_write_hook.py index ec91f007..8d37657d 100644 --- a/tests/unit/deepschema/test_write_hook.py +++ b/tests/unit/deepschema/test_write_hook.py @@ -63,11 +63,13 @@ def test_json_schema_validation_failure(self, tmp_path: Path) -> None: json_schema = tmp_path / ".deepwork" / "schemas" / "json_test" / "test.schema.json" json_schema.parent.mkdir(parents=True) json_schema.write_text( - json.dumps({ - "type": "object", - "required": ["name"], - "properties": {"name": {"type": "string"}}, - }), + json.dumps( + { + "type": "object", + "required": ["name"], + "properties": {"name": {"type": "string"}}, + } + ), encoding="utf-8", ) # Create schema referencing the JSON Schema @@ -88,11 +90,13 @@ def test_json_schema_validation_success(self, tmp_path: Path) -> None: json_schema = tmp_path / ".deepwork" / "schemas" / "json_test" / "test.schema.json" json_schema.parent.mkdir(parents=True) json_schema.write_text( - json.dumps({ - "type": "object", - "required": ["name"], - "properties": {"name": {"type": "string"}}, - }), + json.dumps( + { + "type": "object", + "required": ["name"], + "properties": {"name": {"type": "string"}}, + } + ), encoding="utf-8", ) (json_schema.parent / "deepschema.yml").write_text( diff --git a/tests/unit/review/test_mcp.py b/tests/unit/review/test_mcp.py index 8d3d1b23..33bfbc9b 100644 --- a/tests/unit/review/test_mcp.py +++ b/tests/unit/review/test_mcp.py @@ -36,8 +36,11 @@ def _make_rule(tmp_path: Path) -> ReviewRule: class TestRunReview: """Tests for the run_review adapter function.""" + @patch("deepwork.review.mcp.gen_schema_rules", return_value=([], [])) @patch("deepwork.review.mcp.load_all_rules") - def test_no_deepreview_files_returns_message(self, mock_load: Any, tmp_path: Path) -> None: + def test_no_deepreview_files_returns_message( + self, mock_load: Any, mock_schema: Any, tmp_path: Path + ) -> None: mock_load.return_value = ([], []) result = run_review(tmp_path, "claude") assert "No .deepreview configuration files" in result @@ -200,6 +203,7 @@ def test_get_configured_reviews_tool_is_registered(self, tmp_path: Path) -> None assert "get_configured_reviews" in _get_tool_names(server) +@pytest.mark.usefixtures("without_standard_schemas") class TestGetConfiguredReviews: """Tests for the get_configured_reviews adapter function — validates REVIEW-REQ-008.""" @@ -512,6 +516,7 @@ def test_passed_reviews_excluded_from_output( assert "No review tasks to execute" in result +@pytest.mark.usefixtures("without_standard_schemas") class TestGetConfiguredReviewsUnaffectedByCache: """Tests that get_configured_reviews ignores pass caching — validates REVIEW-REQ-009.6.""" From 366a2c11ce62b9f116e319b235dd8d9c6a141ed9 Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Mon, 30 Mar 2026 11:23:41 -0600 Subject: [PATCH 04/15] docs: update documentation to reflect DeepSchema subsystem Update project structure trees, review pipeline descriptions, quality gate docs, MCP tool descriptions, and hooks README to include the new deepschema/ package, standard_schemas/, and write hook. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 3 ++- doc/architecture.md | 13 ++++++++++++- doc/mcp_interface.md | 6 +++--- src/deepwork/deepschema/review_bridge.py | 4 +++- src/deepwork/hooks/README.md | 1 + 5 files changed, 21 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 894dd6eb..0b561f06 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ Similar to how vibe coding makes it easier for anyone to produce software, this ## DeepWork Reviews — Deep Dive -Reviews are `.deepreview` config files placed anywhere in your project, scoped to the directory they live in (like `.gitignore`). When you run a review, it diffs your branch, matches changed files against your rules, and dispatches parallel AI review agents. +Reviews are `.deepreview` config files placed anywhere in your project, scoped to the directory they live in (like `.gitignore`). DeepSchemas (in `.deepwork/schemas/`) also generate synthetic review rules automatically. When you run a review, it diffs your branch, matches changed files against your rules (from both `.deepreview` files and DeepSchemas), and dispatches parallel AI review agents. ### Why This Is Powerful @@ -323,6 +323,7 @@ Send [@tylerwillis](https://x.com/tylerwillis) a message on X. your-project/ ├── .deepwork/ │ ├── tmp/ # Session state (created lazily) +│ ├── schemas/ # DeepSchema definitions │ └── jobs/ # Job definitions │ └── job_name/ │ └── job.yml # Job definition (self-contained with inline instructions) diff --git a/doc/architecture.md b/doc/architecture.md index 5b01694d..ecdc459f 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -61,11 +61,20 @@ deepwork/ # DeepWork tool repository │ │ └── status.py # Status file writer for external consumers │ ├── hooks/ # Hook system and cross-platform wrappers │ │ ├── wrapper.py # Cross-platform input/output normalization +│ │ ├── deepschema_write.py # DeepSchema write-time validation hook │ │ ├── claude_hook.sh # Shell wrapper for Claude Code │ │ └── gemini_hook.sh # Shell wrapper for Gemini CLI +│ ├── deepschema/ # DeepSchema subsystem +│ │ ├── config.py # DeepSchema config parsing +│ │ ├── discovery.py # Find DeepSchema files in project tree +│ │ ├── matcher.py # Match files against DeepSchema rules +│ │ ├── resolver.py # Resolve DeepSchema definitions +│ │ ├── review_bridge.py # Generate synthetic review rules from DeepSchemas +│ │ └── schema.py # DeepSchema data models │ ├── standard_jobs/ # Built-in job definitions │ │ ├── deepwork_jobs/ │ │ └── deepwork_reviews/ +│ ├── standard_schemas/ # Built-in DeepSchema definitions │ ├── review/ # DeepWork Reviews system │ │ ├── config.py # .deepreview config parsing + data models │ │ ├── discovery.py # Find .deepreview files in project tree @@ -76,6 +85,7 @@ deepwork/ # DeepWork tool repository │ │ └── schema.py # JSON schema loader │ ├── schemas/ # Definition schemas │ │ ├── deepreview_schema.json +│ │ ├── deepschema_schema.json │ │ └── doc_spec_schema.py │ └── utils/ │ ├── fs.py @@ -95,7 +105,7 @@ deepwork/ # DeepWork tool repository │ │ │ ├── deepwork/SKILL.md │ │ │ ├── review/SKILL.md │ │ │ └── configure_reviews/SKILL.md -│ │ ├── hooks/ # hooks.json, post_commit_reminder.sh, post_compact.sh, startup_context.sh +│ │ ├── hooks/ # hooks.json, post_commit_reminder.sh, post_compact.sh, startup_context.sh, deepschema_write.sh │ │ └── .mcp.json # MCP server config │ └── gemini/ # Gemini CLI extension │ └── skills/deepwork/SKILL.md @@ -141,6 +151,7 @@ deepwork review --instructions-for claude The review command: - Discovers `.deepreview` files throughout the project tree +- Discovers DeepSchemas and generates synthetic review rules from them - Detects changed files via `git diff` against the default branch, plus untracked files via `git ls-files` - Matches changed files against rules using include/exclude glob patterns - Groups files by review strategy (`individual`, `matches_together`, `all_changed_files`) diff --git a/doc/mcp_interface.md b/doc/mcp_interface.md index cf79e048..3f17bc00 100644 --- a/doc/mcp_interface.md +++ b/doc/mcp_interface.md @@ -180,7 +180,7 @@ Navigate back to a prior step in the current workflow. Clears all progress from ### 6. `get_review_instructions` -Run a review of changed files based on `.deepreview` configuration files. Returns a list of review tasks to invoke in parallel. Each task has `name`, `description`, `subagent_type`, and `prompt` fields for the Task tool. +Run a review of changed files based on `.deepreview` configuration files and DeepSchema-generated synthetic review rules. Returns a list of review tasks to invoke in parallel. Each task has `name`, `description`, `subagent_type`, and `prompt` fields for the Task tool. This tool operates outside the workflow lifecycle — it can be called independently at any time. @@ -200,7 +200,7 @@ A plain string with one of: ### 7. `get_configured_reviews` -List all configured review rules from `.deepreview` files. Returns each rule's name, description, and defining file location. Optionally filters to rules matching specific files. +List all configured review rules from `.deepreview` files and DeepSchema-generated synthetic rules. Returns each rule's name, description, and defining file location. Optionally filters to rules matching specific files. This tool operates outside the workflow lifecycle — it can be called independently at any time. @@ -321,7 +321,7 @@ Steps may define quality reviews that outputs must pass. When `finished_step` is 1. JSON schema validation runs first (if any outputs have `json_schema` defined) 2. Dynamic review rules are built from step output `review` blocks and `process_requirements` -3. `.deepreview` rules are also loaded and matched against output files +3. `.deepreview` rules and DeepSchema-generated synthetic review rules are also loaded and matched against output files 4. If any reviews are needed, `status = "needs_work"` with review instructions 5. If all reviews pass (or no reviews defined), workflow advances 6. There is no maximum attempt limit — the agent can retry `finished_step` indefinitely diff --git a/src/deepwork/deepschema/review_bridge.py b/src/deepwork/deepschema/review_bridge.py index 118a1304..889ab558 100644 --- a/src/deepwork/deepschema/review_bridge.py +++ b/src/deepwork/deepschema/review_bridge.py @@ -78,7 +78,9 @@ def _named_schema_rule( agent=None, all_changed_filenames=False, unchanged_matching_files=False, - source_dir=schema.source_path.parent, + # Named schemas use project-root-relative matchers, so source_dir + # must be the project root for the review matcher to resolve paths. + source_dir=project_root, source_file=schema.source_path, source_line=0, ) diff --git a/src/deepwork/hooks/README.md b/src/deepwork/hooks/README.md index 262f1cf9..c5aa89b8 100644 --- a/src/deepwork/hooks/README.md +++ b/src/deepwork/hooks/README.md @@ -133,5 +133,6 @@ pytest tests/shell_script_tests/test_hook_wrappers.py -v | File | Purpose | |------|---------| | `wrapper.py` | Cross-platform input/output normalization | +| `deepschema_write.py` | DeepSchema write-time validation hook | | `claude_hook.sh` | Shell wrapper for Claude Code | | `gemini_hook.sh` | Shell wrapper for Gemini CLI | From d1fece9d5a67512cce301cb478630f9a38f8b86c Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Mon, 30 Mar 2026 11:32:03 -0600 Subject: [PATCH 05/15] chore: symlink job.schema.json into job_yml standard schema, update AGENTS.md - Replace remote json_schema_path reference with local symlink - Update AGENTS.md project structure tree for DeepSchema subsystem Co-Authored-By: Claude Opus 4.6 (1M context) --- AGENTS.md | 6 ++++-- src/deepwork/standard_schemas/job_yml/deepschema.yml | 2 +- src/deepwork/standard_schemas/job_yml/job.schema.json | 1 + 3 files changed, 6 insertions(+), 3 deletions(-) create mode 120000 src/deepwork/standard_schemas/job_yml/job.schema.json diff --git a/AGENTS.md b/AGENTS.md index 463005e8..c74cfcf2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -193,12 +193,14 @@ deepwork/ │ ├── core/ # Core logic (doc_spec_parser) │ ├── jobs/ # Job discovery, parsing, schema, and MCP server │ │ └── mcp/ # MCP server module (the core runtime) +│ ├── deepschema/ # DeepSchema subsystem (config, discovery, matcher, resolver, review_bridge, schema) │ ├── hooks/ # Hook scripts and wrappers │ ├── standard_jobs/ # Built-in job definitions (auto-discovered at runtime) │ │ ├── deepwork_jobs/ │ │ └── deepwork_reviews/ +│ ├── standard_schemas/ # Built-in DeepSchema definitions │ ├── review/ # DeepWork Reviews system (.deepreview pipeline) -│ ├── schemas/ # Definition schemas (deepreview, doc_spec) +│ ├── schemas/ # Definition schemas (deepreview, deepschema, doc_spec) │ └── utils/ # Utilities (fs, git, yaml, validation) ├── platform/ # Shared platform-agnostic content │ └── skill-body.md # Canonical skill body (source of truth) @@ -211,7 +213,7 @@ deepwork/ │ │ │ ├── configure_reviews/SKILL.md │ │ │ ├── deepwork/SKILL.md │ │ │ └── review/SKILL.md -│ │ ├── hooks/ # hooks.json, post_commit_reminder.sh, post_compact.sh, startup_context.sh +│ │ ├── hooks/ # hooks.json, post_commit_reminder.sh, post_compact.sh, startup_context.sh, deepschema_write.sh │ │ └── .mcp.json # MCP server config │ └── gemini/ # Gemini CLI extension │ └── skills/deepwork/SKILL.md diff --git a/src/deepwork/standard_schemas/job_yml/deepschema.yml b/src/deepwork/standard_schemas/job_yml/deepschema.yml index c7aba9ac..c17add9a 100644 --- a/src/deepwork/standard_schemas/job_yml/deepschema.yml +++ b/src/deepwork/standard_schemas/job_yml/deepschema.yml @@ -12,7 +12,7 @@ instructions: | - common_job_info is prepended to every step AND every review prompt automatically - Reviews cascade from three sources: step output review, step_argument review, .deepreview rules -json_schema_path: "../../jobs/job.schema.json" +json_schema_path: "job.schema.json" matchers: - ".deepwork/jobs/*/job.yml" diff --git a/src/deepwork/standard_schemas/job_yml/job.schema.json b/src/deepwork/standard_schemas/job_yml/job.schema.json new file mode 120000 index 00000000..b1731575 --- /dev/null +++ b/src/deepwork/standard_schemas/job_yml/job.schema.json @@ -0,0 +1 @@ +../../jobs/job.schema.json \ No newline at end of file From 93be21aa6e116ea1d3e812744df3a75314886e99 Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Mon, 30 Mar 2026 11:34:12 -0600 Subject: [PATCH 06/15] fix: resolve ruff lint errors (import ordering, unused import) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/deepwork/jobs/mcp/quality_gate.py | 2 +- tests/unit/deepschema/test_review_bridge.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/deepwork/jobs/mcp/quality_gate.py b/src/deepwork/jobs/mcp/quality_gate.py index b5132fae..7fd97fb2 100644 --- a/src/deepwork/jobs/mcp/quality_gate.py +++ b/src/deepwork/jobs/mcp/quality_gate.py @@ -11,6 +11,7 @@ import logging from pathlib import Path +from deepwork.deepschema.review_bridge import generate_review_rules as gen_schema_rules from deepwork.jobs.mcp.schemas import ArgumentValue from deepwork.jobs.parser import ( JobDefinition, @@ -18,7 +19,6 @@ Workflow, WorkflowStep, ) -from deepwork.deepschema.review_bridge import generate_review_rules as gen_schema_rules from deepwork.review.config import ReviewRule, ReviewTask from deepwork.review.discovery import load_all_rules from deepwork.review.formatter import format_for_claude diff --git a/tests/unit/deepschema/test_review_bridge.py b/tests/unit/deepschema/test_review_bridge.py index 621eaf31..4cd0be8f 100644 --- a/tests/unit/deepschema/test_review_bridge.py +++ b/tests/unit/deepschema/test_review_bridge.py @@ -1,7 +1,6 @@ """Tests for DeepSchema review bridge — synthetic ReviewRule generation.""" from pathlib import Path -from unittest.mock import patch from deepwork.deepschema.config import DeepSchema from deepwork.deepschema.review_bridge import ( From 5bc30a36e42e107d82c3635d85c99d767317aa0d Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Mon, 30 Mar 2026 11:36:22 -0600 Subject: [PATCH 07/15] chore: gitignore .claude/worktrees/ Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index abfa16ab..8991d5ca 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,9 @@ dmypy.json *~ .DS_Store +# Claude Code +.claude/worktrees/ + # direnv .direnv/ From 17d6fc69e712b39a7f662ee22525c716c41175d2 Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Mon, 30 Mar 2026 11:51:04 -0600 Subject: [PATCH 08/15] feat: add get_named_schemas MCP tool for schema discovery Exposes named DeepSchema info (name, summary, matchers) via an MCP tool so agents can discover what file schemas are defined in the project. Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/mcp_interface.md | 22 +++++- src/deepwork/jobs/mcp/server.py | 34 +++++++++ .../unit/deepschema/test_get_named_schemas.py | 74 +++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 tests/unit/deepschema/test_get_named_schemas.py diff --git a/doc/mcp_interface.md b/doc/mcp_interface.md index 3f17bc00..36e2f954 100644 --- a/doc/mcp_interface.md +++ b/doc/mcp_interface.md @@ -10,7 +10,7 @@ This document describes the Model Context Protocol (MCP) tools exposed by the De ## Tools -DeepWork exposes eight MCP tools: +DeepWork exposes nine MCP tools: ### 1. `get_workflows` @@ -242,6 +242,26 @@ A plain string with either: --- +### 9. `get_named_schemas` + +List all named DeepSchemas discovered across all schema sources (project-local, standard, and env var). Returns each schema's name, summary, and matcher patterns. + +#### Parameters + +None. + +#### Returns + +```typescript +Array<{ + name: string; // Schema name (directory name) + summary: string; // Brief description of the schema (empty string if not set) + matchers: string[]; // Glob patterns this schema applies to +}> +``` + +--- + ## Shared Types ```typescript diff --git a/src/deepwork/jobs/mcp/server.py b/src/deepwork/jobs/mcp/server.py index aba62e0e..0470a0ce 100644 --- a/src/deepwork/jobs/mcp/server.py +++ b/src/deepwork/jobs/mcp/server.py @@ -301,6 +301,40 @@ async def go_to_step( response = await tools.go_to_step(input_data) return _append_issues(response.model_dump()) + # ---- DeepSchema tools ---- + + @mcp.tool( + description=( + "List all named DeepSchemas discovered across all schema sources " + "(project-local, standard, and env var). " + "Returns each schema's name, summary, and matcher patterns. " + "Useful for understanding what file types have schemas defined." + ) + ) + def get_named_schemas() -> list[dict[str, Any]]: + """List all named DeepSchemas with basic info.""" + _log_tool_call("get_named_schemas") + from deepwork.deepschema.config import DeepSchemaError, parse_deepschema_file + from deepwork.deepschema.discovery import find_named_schemas + + results: list[dict[str, Any]] = [] + for manifest_path in find_named_schemas(project_path): + name = manifest_path.parent.name + try: + schema = parse_deepschema_file(manifest_path, "named", name) + results.append({ + "name": schema.name, + "summary": schema.summary or "", + "matchers": schema.matchers, + }) + except DeepSchemaError: + results.append({ + "name": name, + "summary": f"(failed to parse {manifest_path})", + "matchers": [], + }) + return results + # ---- Review tool (outside the workflow lifecycle) ---- from deepwork.review.mcp import ReviewToolError, run_review diff --git a/tests/unit/deepschema/test_get_named_schemas.py b/tests/unit/deepschema/test_get_named_schemas.py new file mode 100644 index 00000000..2d3505be --- /dev/null +++ b/tests/unit/deepschema/test_get_named_schemas.py @@ -0,0 +1,74 @@ +"""Tests for get_named_schemas MCP tool.""" + +from pathlib import Path + +import pytest + +from deepwork.deepschema.config import DeepSchemaError, parse_deepschema_file +from deepwork.deepschema.discovery import find_named_schemas + + +@pytest.mark.usefixtures("without_standard_schemas") +class TestGetNamedSchemas: + """Tests for the get_named_schemas tool logic.""" + + def test_returns_empty_when_no_schemas(self, tmp_path: Path) -> None: + results = find_named_schemas(tmp_path) + assert results == [] + + def test_returns_named_schema_info(self, tmp_path: Path) -> None: + schema_dir = tmp_path / ".deepwork" / "schemas" / "my_config" + schema_dir.mkdir(parents=True) + (schema_dir / "deepschema.yml").write_text( + "summary: 'Config files'\nmatchers:\n - '**/*.cfg'\nrequirements:\n r1: 'MUST exist'\n", + encoding="utf-8", + ) + + manifests = find_named_schemas(tmp_path) + assert len(manifests) == 1 + + schema = parse_deepschema_file(manifests[0], "named", manifests[0].parent.name) + assert schema.name == "my_config" + assert schema.summary == "Config files" + assert schema.matchers == ["**/*.cfg"] + + def test_multiple_schemas_returned(self, tmp_path: Path) -> None: + for name in ["alpha", "beta"]: + d = tmp_path / ".deepwork" / "schemas" / name + d.mkdir(parents=True) + (d / "deepschema.yml").write_text( + f"summary: '{name} schema'\nmatchers:\n - '**/*.{name}'\n", + encoding="utf-8", + ) + + manifests = find_named_schemas(tmp_path) + assert len(manifests) == 2 + names = {m.parent.name for m in manifests} + assert names == {"alpha", "beta"} + + def test_malformed_schema_raises_error(self, tmp_path: Path) -> None: + schema_dir = tmp_path / ".deepwork" / "schemas" / "bad" + schema_dir.mkdir(parents=True) + (schema_dir / "deepschema.yml").write_text( + "requirements: 'not a dict'\n", + encoding="utf-8", + ) + + manifests = find_named_schemas(tmp_path) + assert len(manifests) == 1 + + with pytest.raises(DeepSchemaError): + parse_deepschema_file(manifests[0], "named", "bad") + + def test_schema_without_summary_returns_none(self, tmp_path: Path) -> None: + schema_dir = tmp_path / ".deepwork" / "schemas" / "minimal" + schema_dir.mkdir(parents=True) + (schema_dir / "deepschema.yml").write_text( + "matchers:\n - '**/*.txt'\n", + encoding="utf-8", + ) + + manifests = find_named_schemas(tmp_path) + schema = parse_deepschema_file(manifests[0], "named", "minimal") + assert schema.summary is None + assert schema.matchers == ["**/*.txt"] From d22ce40e7d79767f4d698d5013b8002b8423db7d Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Mon, 30 Mar 2026 12:12:16 -0600 Subject: [PATCH 09/15] fix: route hook context to agent via additionalContext instead of systemMessage systemMessage is displayed to the user but not the agent, so DeepSchema conformance notes and validation errors were invisible to the model. Switch all platforms to use hookSpecificOutput.additionalContext which is injected into the agent's conversation context. Also adds YAML file parsing support for JSON Schema validation in the write hook (from another agent's fix). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../DW-REQ-006-hook-wrapper-system.md | 5 +- src/deepwork/hooks/deepschema_write.py | 14 +++-- src/deepwork/hooks/wrapper.py | 23 ++------- tests/unit/deepschema/test_write_hook.py | 51 +++++++++++++++++++ tests/unit/test_hook_wrapper.py | 10 ++-- 5 files changed, 74 insertions(+), 29 deletions(-) diff --git a/specs/deepwork/DW-REQ-006-hook-wrapper-system.md b/specs/deepwork/DW-REQ-006-hook-wrapper-system.md index 7228ec47..c8b8c1de 100644 --- a/specs/deepwork/DW-REQ-006-hook-wrapper-system.md +++ b/specs/deepwork/DW-REQ-006-hook-wrapper-system.md @@ -91,9 +91,8 @@ The hook wrapper system provides cross-platform compatibility for hooks running ### DW-REQ-006.6: Context Output (Platform-Specific) -1. On Claude for `SESSION_START` events, context MUST be set via `hookSpecificOutput.additionalContext` with `hookEventName` set to the platform-specific event name. -2. On Claude for all other events, context MUST be set via `systemMessage`. -3. On Gemini for all events, context MUST be set via `hookSpecificOutput.additionalContext` with `hookEventName`. +1. On Claude, context MUST be set via `hookSpecificOutput.additionalContext` with `hookEventName` set to the platform-specific event name, for all event types. This ensures hook output is routed to the agent (not just displayed to the user). +2. On Gemini for all events, context MUST be set via `hookSpecificOutput.additionalContext` with `hookEventName`. ### DW-REQ-006.7: Output Serialization diff --git a/src/deepwork/hooks/deepschema_write.py b/src/deepwork/hooks/deepschema_write.py index f62409d2..3044052d 100644 --- a/src/deepwork/hooks/deepschema_write.py +++ b/src/deepwork/hooks/deepschema_write.py @@ -2,7 +2,7 @@ Fires after a file is written or edited. Finds applicable DeepSchemas, injects a conformance note, and runs verification_bash_command and -json_schema_path validation. Reports errors via systemMessage. +json_schema_path validation. Reports errors via additionalContext. """ from __future__ import annotations @@ -114,6 +114,7 @@ def _relative_path(path: Path, project_root: Path) -> str: def _validate_json_schema(filepath: Path, schema_path: Path) -> str | None: """Validate a file against a JSON Schema. + Parses the file as YAML if it has a .yml/.yaml extension, otherwise as JSON. Returns error message or None on success. """ if not schema_path.exists(): @@ -121,11 +122,16 @@ def _validate_json_schema(filepath: Path, schema_path: Path) -> str | None: try: content = filepath.read_text(encoding="utf-8") - parsed = json.loads(content) + if filepath.suffix in (".yml", ".yaml"): + import yaml + + parsed = yaml.safe_load(content) + else: + parsed = json.loads(content) except json.JSONDecodeError as e: return f"File is not valid JSON: {e}" - except OSError as e: - return f"Cannot read file: {e}" + except Exception as e: + return f"Cannot parse file: {e}" try: schema_data = json.loads(schema_path.read_text(encoding="utf-8")) diff --git a/src/deepwork/hooks/wrapper.py b/src/deepwork/hooks/wrapper.py index fb0e0eb3..cff01b4f 100644 --- a/src/deepwork/hooks/wrapper.py +++ b/src/deepwork/hooks/wrapper.py @@ -243,25 +243,12 @@ def to_dict(self, platform: Platform, event: NormalizedEvent) -> dict[str, Any]: if self.suppress_output: result["suppressOutput"] = True - # Handle context (platform-specific) + # Handle context — route to agent via hookSpecificOutput.additionalContext if self.context: - if platform == Platform.CLAUDE: - # Claude uses different fields depending on event - if event == NormalizedEvent.SESSION_START: - result.setdefault("hookSpecificOutput", {}) - result["hookSpecificOutput"]["hookEventName"] = NORMALIZED_TO_EVENT[platform][ - event - ] - result["hookSpecificOutput"]["additionalContext"] = self.context - else: - result["systemMessage"] = self.context - else: - # Gemini - result.setdefault("hookSpecificOutput", {}) - result["hookSpecificOutput"]["hookEventName"] = NORMALIZED_TO_EVENT[platform].get( - event, str(event) - ) - result["hookSpecificOutput"]["additionalContext"] = self.context + platform_event = NORMALIZED_TO_EVENT.get(platform, {}).get(event, str(event)) + result.setdefault("hookSpecificOutput", {}) + result["hookSpecificOutput"]["hookEventName"] = platform_event + result["hookSpecificOutput"]["additionalContext"] = self.context # Merge any raw output for key, value in self.raw_output.items(): diff --git a/tests/unit/deepschema/test_write_hook.py b/tests/unit/deepschema/test_write_hook.py index 8d37657d..070ac8e9 100644 --- a/tests/unit/deepschema/test_write_hook.py +++ b/tests/unit/deepschema/test_write_hook.py @@ -111,6 +111,57 @@ def test_json_schema_validation_success(self, tmp_path: Path) -> None: assert "must conform to the DeepSchema" in result.context assert "CRITICAL" not in result.context + def test_json_schema_validation_of_yaml_file(self, tmp_path: Path) -> None: + """YAML files validated against a JSON Schema should be parsed as YAML, not JSON.""" + json_schema = tmp_path / ".deepwork" / "schemas" / "yml_test" / "test.schema.json" + json_schema.parent.mkdir(parents=True) + json_schema.write_text( + json.dumps( + { + "type": "object", + "required": ["name"], + "properties": {"name": {"type": "string"}}, + } + ), + encoding="utf-8", + ) + (json_schema.parent / "deepschema.yml").write_text( + "matchers:\n - '**/*.yml'\njson_schema_path: 'test.schema.json'\nrequirements:\n r1: 'MUST conform'\n", + encoding="utf-8", + ) + target = tmp_path / "data.yml" + target.write_text("name: valid\nother: field\n", encoding="utf-8") + + hook_input = _make_hook_input(str(target), str(tmp_path)) + result = deepschema_write_hook(hook_input) + assert "must conform to the DeepSchema" in result.context + assert "CRITICAL" not in result.context + + def test_json_schema_validation_of_invalid_yaml_file(self, tmp_path: Path) -> None: + """YAML files that fail JSON Schema validation should report errors.""" + json_schema = tmp_path / ".deepwork" / "schemas" / "yml_test" / "test.schema.json" + json_schema.parent.mkdir(parents=True) + json_schema.write_text( + json.dumps( + { + "type": "object", + "required": ["name"], + "properties": {"name": {"type": "string"}}, + } + ), + encoding="utf-8", + ) + (json_schema.parent / "deepschema.yml").write_text( + "matchers:\n - '**/*.yml'\njson_schema_path: 'test.schema.json'\nrequirements:\n r1: 'MUST conform'\n", + encoding="utf-8", + ) + target = tmp_path / "data.yml" + target.write_text("other: field\n", encoding="utf-8") + + hook_input = _make_hook_input(str(target), str(tmp_path)) + result = deepschema_write_hook(hook_input) + assert "CRITICAL" in result.context + def test_verification_command_failure(self, tmp_path: Path) -> None: schema_dir = tmp_path / ".deepwork" / "schemas" / "bash_test" schema_dir.mkdir(parents=True) diff --git a/tests/unit/test_hook_wrapper.py b/tests/unit/test_hook_wrapper.py index f53629d5..f2976ff1 100644 --- a/tests/unit/test_hook_wrapper.py +++ b/tests/unit/test_hook_wrapper.py @@ -292,16 +292,18 @@ def test_context_for_claude_session_start(self) -> None: assert result["hookSpecificOutput"]["hookEventName"] == "SessionStart" assert result["hookSpecificOutput"]["additionalContext"] == "Additional context here" - # THIS TEST VALIDATES A HARD REQUIREMENT (DW-REQ-006.6.2). + # THIS TEST VALIDATES A HARD REQUIREMENT (DW-REQ-006.6.1). # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_context_for_claude_other_events(self) -> None: - """Test context handling for Claude non-SessionStart events.""" + """Test context handling for Claude non-SessionStart events uses additionalContext.""" output = HookOutput(context="Warning message") result = output.to_dict(Platform.CLAUDE, NormalizedEvent.AFTER_AGENT) - assert result["systemMessage"] == "Warning message" + assert "hookSpecificOutput" in result + assert result["hookSpecificOutput"]["additionalContext"] == "Warning message" + assert result["hookSpecificOutput"]["hookEventName"] == "Stop" - # THIS TEST VALIDATES A HARD REQUIREMENT (DW-REQ-006.6.3). + # THIS TEST VALIDATES A HARD REQUIREMENT (DW-REQ-006.6.2). # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_context_for_gemini(self) -> None: """Test context handling for Gemini.""" From e1b8f9b427d74a11b27f7000a926627873428e3e Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Mon, 30 Mar 2026 12:15:36 -0600 Subject: [PATCH 10/15] chore: add .deepreview rule for hook output routing to agent Ensures hooks use additionalContext (agent-visible) instead of systemMessage (user-only) when the agent needs to act on the output. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/deepwork/hooks/.deepreview | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/deepwork/hooks/.deepreview diff --git a/src/deepwork/hooks/.deepreview b/src/deepwork/hooks/.deepreview new file mode 100644 index 00000000..9c9d2703 --- /dev/null +++ b/src/deepwork/hooks/.deepreview @@ -0,0 +1,49 @@ +hook_output_routing: + description: "Ensure hooks route output to the agent via additionalContext, not systemMessage." + match: + include: + - "src/deepwork/hooks/**/*.py" + - "plugins/**/hooks/**" + exclude: + - "**/__pycache__/**" + review: + strategy: individual + instructions: | + Review this hook file for correct output routing. + + Claude Code hooks have two output channels with very different behavior: + - `hookSpecificOutput.additionalContext` — injected into the agent's + conversation context. The agent (Claude) sees this and can act on it. + - `systemMessage` — displayed to the human user in the terminal only. + The agent NEVER sees this. + + For any hook that produces output the agent needs to act on (validation + errors, conformance notes, schema warnings, etc.), the output MUST be + routed via `additionalContext`, not `systemMessage`. + + In the Python wrapper system (`hooks/wrapper.py`), the `HookOutput.context` + field maps to `hookSpecificOutput.additionalContext` on all platforms. + Shell hooks that bypass the wrapper must emit the correct JSON directly: + + ```json + { + "hookSpecificOutput": { + "hookEventName": "", + "additionalContext": "" + } + } + ``` + + Check for: + - Any use of `systemMessage` in hook output — this is almost always wrong + unless the message is intentionally user-only (not agent-visible). + - Shell hooks that print plain text to stdout instead of JSON — the agent + will not see this. + - Hooks that set `HookOutput.context` correctly (this is the right pattern). + - PostToolUse hooks that report validation errors — these MUST use the + additionalContext route so the agent can fix the issue. + + Output Format: + - PASS: Hook output is correctly routed to the agent. + - FAIL: Hook output uses systemMessage or plain stdout where the agent + needs to see it. List each instance with file, line, and fix. From 9be31cb4096163af8adb25f1a58687d60b4f6912 Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Mon, 30 Mar 2026 12:18:07 -0600 Subject: [PATCH 11/15] docs: add /deepschema skill and DeepSchemas section to README Adds a user-facing skill explaining how to create and manage DeepSchemas (named and anonymous), plus a README section covering the feature. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 34 +++++++ plugins/claude/skills/deepschema/SKILL.md | 117 ++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 plugins/claude/skills/deepschema/SKILL.md diff --git a/README.md b/README.md index 0b561f06..87580fb3 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,40 @@ See [README_REVIEWS.md](README_REVIEWS.md) for the full reference — strategies --- +## DeepSchemas — File-Level Schemas + +DeepSchemas are rich, file-level schemas that give both humans and AI agents a shared understanding of what a file should look like. They provide automatic write-time validation and generate review rules that enforce requirements during `/review` and workflow quality gates. + +### Two Flavors + +**Named schemas** (`.deepwork/schemas//deepschema.yml`) match files via glob patterns and are ideal for recurring file types — configs, API specs, job definitions, etc. + +**Anonymous schemas** (`.deepschema..yml`) sit next to a specific file and apply only to that file. + +### What They Do + +1. **Write-time validation** — When an agent writes or edits a file, applicable schemas are checked immediately. JSON Schema validation and custom bash commands run automatically; failures are reported inline so the agent can fix them on the spot. +2. **Review generation** — Each schema automatically produces a review rule. During `/review` or workflow quality gates, a reviewer checks every matched file against the schema's RFC 2119 requirements (MUST/SHOULD/MAY). +3. **Inheritance** — Anonymous schemas can reference named schemas via `parent_deep_schemas` to inherit shared requirements. + +### Quick Example + +```yaml +# .deepwork/schemas/api_endpoint/deepschema.yml +summary: REST API endpoint handler +matchers: + - "src/api/**/*.py" +requirements: + auth-required: "Every endpoint MUST enforce authentication." + error-handling: "Endpoints MUST return structured error responses." + rate-limited: "Public endpoints SHOULD be rate-limited." +json_schema_path: "openapi_fragment.schema.json" +``` + +Use `/deepschema` for the full reference on creating and managing schemas. + +--- + ## Supported Platforms | Platform | Status | Notes | diff --git a/plugins/claude/skills/deepschema/SKILL.md b/plugins/claude/skills/deepschema/SKILL.md new file mode 100644 index 00000000..bab125ca --- /dev/null +++ b/plugins/claude/skills/deepschema/SKILL.md @@ -0,0 +1,117 @@ +--- +name: deepschema +description: "Create and manage DeepSchemas — rich file-level schemas with automatic validation and review generation" +--- + +# DeepSchema + +DeepSchemas define rich schemas for files in your project. They provide: + +- **Automatic write-time validation** — when you write or edit a file, applicable schemas are checked immediately and errors are reported inline +- **Review generation** — schemas automatically generate review rules that run during `/review` and workflow quality gates +- **RFC 2119 requirements** — requirements use MUST/SHOULD/MAY keywords to control enforcement severity + +## Two Types of DeepSchemas + +### Named Schemas + +Named schemas live in `.deepwork/schemas//` and match files via glob patterns. Use these for file types that appear throughout your project. + +``` +.deepwork/schemas/api_endpoint/ + deepschema.yml # Manifest with requirements, matchers, etc. + endpoint.schema.json # Optional JSON Schema for structural validation + examples/ # Optional example files + references/ # Optional reference docs +``` + +### Anonymous Schemas + +Anonymous schemas are single files placed alongside the file they apply to. Use these for one-off requirements on a specific file. + +``` +src/config.yml # The file +.deepschema.config.yml.yml # Its anonymous schema +``` + +The naming convention is `.deepschema..yml`. + +## Creating a Named Schema + +1. Create the directory: `.deepwork/schemas//` +2. Create `deepschema.yml` inside it: + +```yaml +summary: Short description of this file type +instructions: | + Guidelines for creating and modifying files of this type. + +matchers: + - "**/*.config.yml" + - "src/configs/**/*.json" + +requirements: + has-version: "Every config file MUST include a version field." + documented-fields: "All fields SHOULD have inline comments explaining their purpose." + no-secrets: "Config files MUST NOT contain secrets or credentials." + +# Optional: structural validation +json_schema_path: "config.schema.json" + +# Optional: custom validation commands (file path passed as $1) +verification_bash_command: + - "yamllint -d relaxed" +``` + +3. Call `get_named_schemas` to verify your schema is discovered. + +## Creating an Anonymous Schema + +Place a `.deepschema..yml` file next to the target file: + +```yaml +requirements: + api-key-rotated: "The API key MUST be rotated every 90 days." + format-valid: "The file MUST be valid YAML." + +# Reference a named schema for shared requirements +parent_deep_schemas: + - api_endpoint +``` + +## Schema Fields Reference + +| Field | Description | +|-------|-------------| +| `summary` | Brief description for discoverability | +| `instructions` | Guidelines for working with these files | +| `matchers` | Glob patterns this schema applies to (named schemas) | +| `requirements` | Key-value pairs of RFC 2119 requirements | +| `parent_deep_schemas` | Named schemas to inherit requirements from | +| `json_schema_path` | Relative path to a JSON Schema file | +| `verification_bash_command` | Shell commands to validate the file (receives path as `$1`) | +| `examples` | Array of `{path, description}` for example files | +| `references` | Array of `{path, description}` or `{url, description}` for reference docs | + +## How Validation Works + +When you write or edit a file: +1. DeepWork finds all applicable schemas (named schemas with matching globs + any anonymous schema for the file) +2. A conformance note is injected listing applicable schemas +3. `json_schema_path` validation runs automatically +4. `verification_bash_command` commands run with the file path as `$1` +5. Failures are reported as errors the agent must fix + +During `/review` and workflow quality gates, each schema generates a review rule that checks all requirements. + +## Discovery Sources + +Named schemas are loaded from multiple directories in priority order (first match wins): + +1. `.deepwork/schemas/` — project-local schemas +2. DeepWork built-in standard schemas (e.g., `job_yml`, `deepschema`) +3. `DEEPWORK_ADDITIONAL_SCHEMAS_FOLDERS` env var — colon-delimited extra directories + +## MCP Tools + +- `get_named_schemas` — list all discovered named schemas with their names, summaries, and matchers From 1420d63d94e128b5f2b3b9f8df760a89a6976fcf Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Mon, 30 Mar 2026 12:25:27 -0600 Subject: [PATCH 12/15] feat: add DW-REQ-011 DeepSchema requirements spec, review improvements - Create formal requirements spec for the DeepSchema subsystem - Add DW-REQ-006.6 reference to hook_output_routing .deepreview rule - Update suggest_new_reviews to suggest both DeepSchemas and .deepreview rules - Replace deepschema_schema.json copy with symlink (matches job_yml pattern) - Apply ruff format fix to server.py Co-Authored-By: Claude Opus 4.6 (1M context) --- .deepwork/review/suggest_new_reviews.md | 24 +++-- specs/deepwork/DW-REQ-011-deepschema.md | 69 ++++++++++++++ src/deepwork/hooks/.deepreview | 2 +- src/deepwork/jobs/mcp/server.py | 24 +++-- .../deepschema/deepschema_schema.json | 91 +------------------ 5 files changed, 102 insertions(+), 108 deletions(-) create mode 100644 specs/deepwork/DW-REQ-011-deepschema.md mode change 100644 => 120000 src/deepwork/standard_schemas/deepschema/deepschema_schema.json diff --git a/.deepwork/review/suggest_new_reviews.md b/.deepwork/review/suggest_new_reviews.md index 8634b4fd..8d493a62 100644 --- a/.deepwork/review/suggest_new_reviews.md +++ b/.deepwork/review/suggest_new_reviews.md @@ -1,24 +1,34 @@ -# Suggest New Review Rules +# Suggest New Review Rules and DeepSchemas -You are reviewing a changeset to determine whether any new DeepWork review rules should be added to catch issues found in these changes going forward. +You are reviewing a changeset to determine whether any new DeepWork review rules or DeepSchemas should be added to catch issues found in these changes going forward. + +## Two enforcement mechanisms + +1. **`.deepreview` rules** — review rules that run during `/review` and quality gates. Best for cross-file checks, process enforcement, and subjective quality criteria. +2. **DeepSchemas** — rich file-level schemas (`.deepwork/schemas//deepschema.yml` for named, `.deepschema..yml` for anonymous). Best for per-file structural requirements, JSON Schema validation, and bash verification commands. DeepSchemas automatically generate review rules AND provide write-time validation hooks. + +Use DeepSchemas when the issue is about a specific file type having structural or content requirements. Use `.deepreview` rules when the issue is about cross-file consistency, process adherence, or subjective review. ## Steps -1. **Get current rules**: Call `mcp__plugin_deepwork_deepwork__get_configured_reviews` to see all currently configured review rules. Understand what's already covered. +1. **Get current rules and schemas**: Call `mcp__plugin_deepwork_deepwork__get_configured_reviews` to see all currently configured review rules (including DeepSchema-generated ones). Also call `mcp__plugin_deepwork_deepwork__get_named_schemas` to see existing DeepSchemas. Understand what's already covered. 2. **Read the reviews README**: Read `@README_REVIEWS.md` (in the repository root) to understand the full range of review capabilities and rule structures. 3. **Analyze the changeset**: Look at all the changed files. For each change, consider: - - Did this change introduce a type of issue that a review rule could catch? + - Did this change introduce a type of issue that a review rule or DeepSchema could catch? - Is there a pattern here that's likely to recur? - Would an existing rule benefit from a small scope expansion to cover a new file type? + - Is there a file type that would benefit from a DeepSchema (structural requirements, JSON Schema, or bash verification)? -4. **Write new rules directly**: For each rule you decide to create: - - If it's a **new rule**: add it to the appropriate `.deepreview` file with full YAML +4. **Write new rules or schemas directly**: For each rule you decide to create: + - If it's a **new `.deepreview` rule**: add it to the appropriate `.deepreview` file with full YAML - If it's an **addition to an existing rule's `include` list**: edit the existing rule in-place - If the rule needs a dedicated instruction file: create it in `.deepwork/review/` + - If it's a **new named DeepSchema**: create `.deepwork/schemas//deepschema.yml` with `summary`, `matchers`, and `requirements` + - If it's a **new anonymous DeepSchema**: create `.deepschema..yml` next to the target file -5. **Output**: After writing rules to their files, list each new rule or modification you made, with just its name and description. This summary is your final report. +5. **Output**: After writing rules/schemas to their files, list each new rule or schema you created, with just its name and description. This summary is your final report. ## CRITICAL: Be Extremely Conservative diff --git a/specs/deepwork/DW-REQ-011-deepschema.md b/specs/deepwork/DW-REQ-011-deepschema.md new file mode 100644 index 00000000..c5af3d84 --- /dev/null +++ b/specs/deepwork/DW-REQ-011-deepschema.md @@ -0,0 +1,69 @@ +# DW-REQ-011: DeepSchema System + +The DeepSchema system provides rich, file-level schemas with automatic validation on writes and synthetic review rule generation. + +## DW-REQ-011.1: Schema Types + +1. The system MUST support two schema types: **named** and **anonymous**. +2. Named schemas MUST be directories containing a `deepschema.yml` manifest, located under a schema folder. +3. Anonymous schemas MUST be single files named `.deepschema..yml`, placed alongside the target file. + +## DW-REQ-011.2: Schema Configuration + +1. The `deepschema.yml` manifest MUST support the following fields: `summary`, `instructions`, `matchers`, `requirements`, `parent_deep_schemas`, `json_schema_path`, `verification_bash_command`, `examples`, `references`. +2. All fields MUST be optional — an empty schema is valid. +3. The manifest MUST be validated against the DeepSchema JSON Schema (`deepschema_schema.json`). +4. The `requirements` field MUST be a mapping of requirement names to RFC 2119 requirement descriptions. +5. The `matchers` field MUST be an array of glob patterns. +6. The `json_schema_path` field MUST be a relative path from the schema directory to a JSON Schema file. +7. The `verification_bash_command` field MUST be an array of shell command strings. +8. The `parent_deep_schemas` field MUST be an array of named schema names to inherit from. + +## DW-REQ-011.3: Named Schema Discovery + +1. Named schemas MUST be discovered from multiple directories in priority order: project-local (`.deepwork/schemas/`), standard (`standard_schemas/`), and `DEEPWORK_ADDITIONAL_SCHEMAS_FOLDERS` env var (colon-delimited). +2. If the same schema name appears in multiple sources, the first source MUST win. +3. Each source directory MUST be scanned for subdirectories containing `deepschema.yml`. + +## DW-REQ-011.4: Anonymous Schema Discovery + +1. Anonymous schemas MUST be found by walking the project tree for files matching `.deepschema..yml`. +2. Standard skip directories (`.git`, `node_modules`, `__pycache__`, `.venv`, etc.) MUST be excluded from the walk. + +## DW-REQ-011.5: Schema Inheritance + +1. When a schema lists `parent_deep_schemas`, the resolver MUST merge parent requirements into the child. +2. Child requirements MUST override parent requirements with the same key. +3. If a child has no `json_schema_path`, it MUST inherit the parent's. +4. `verification_bash_command` entries from parents MUST be appended to the child's list. +5. Circular references in `parent_deep_schemas` MUST be detected and reported as errors. + +## DW-REQ-011.6: File Matching + +1. A file MUST match a named schema if any of the schema's `matchers` glob patterns match the file's project-relative path. +2. A file MUST match an anonymous schema if a `.deepschema..yml` file exists alongside it. +3. The `get_schemas_for_file_fast()` function MUST avoid full tree walks — it MUST only scan named schema folders and check for the anonymous schema file at O(1). + +## DW-REQ-011.7: Write Hook (PostToolUse) + +1. The write hook MUST fire on PostToolUse events for Write and Edit tools. +2. For each applicable schema, the hook MUST inject a conformance note: "Note: this file must conform to the DeepSchema at ``". +3. If `json_schema_path` is set, the hook MUST validate the written file against the JSON Schema. YAML files (`.yml`/`.yaml`) MUST be parsed as YAML before validation. +4. If `verification_bash_command` is set, the hook MUST execute each command with the file path as `$1`, with a 30-second timeout. +5. Validation failures MUST be reported via `hookSpecificOutput.additionalContext` so the agent can act on them. +6. The hook MUST NOT use `systemMessage` for validation output — that route is user-visible only. + +## DW-REQ-011.8: Review Bridge + +1. Each discovered schema with requirements MUST generate a synthetic `ReviewRule`. +2. Review rule names MUST follow the pattern `" DeepSchema Compliance"`. +3. Named schema reviews MUST include the schema's summary, instructions, and requirements in the review prompt. +4. Anonymous schema reviews MUST include only the requirements. +5. All generated reviews MUST use the `"individual"` strategy (one file at a time). +6. Generated reviews MUST be included in both `/review` runs and workflow quality gate checks. + +## DW-REQ-011.9: MCP Tool — get_named_schemas + +1. The `get_named_schemas` MCP tool MUST return all discovered named schemas. +2. Each entry MUST include `name`, `summary`, and `matchers` fields. +3. Schemas that fail to parse MUST still appear in the results with an error summary instead of a real summary. diff --git a/src/deepwork/hooks/.deepreview b/src/deepwork/hooks/.deepreview index 9c9d2703..50c9adfe 100644 --- a/src/deepwork/hooks/.deepreview +++ b/src/deepwork/hooks/.deepreview @@ -1,5 +1,5 @@ hook_output_routing: - description: "Ensure hooks route output to the agent via additionalContext, not systemMessage." + description: "Ensure hooks route output to the agent via additionalContext, not systemMessage (DW-REQ-006.6)." match: include: - "src/deepwork/hooks/**/*.py" diff --git a/src/deepwork/jobs/mcp/server.py b/src/deepwork/jobs/mcp/server.py index 0470a0ce..07eddfa0 100644 --- a/src/deepwork/jobs/mcp/server.py +++ b/src/deepwork/jobs/mcp/server.py @@ -322,17 +322,21 @@ def get_named_schemas() -> list[dict[str, Any]]: name = manifest_path.parent.name try: schema = parse_deepschema_file(manifest_path, "named", name) - results.append({ - "name": schema.name, - "summary": schema.summary or "", - "matchers": schema.matchers, - }) + results.append( + { + "name": schema.name, + "summary": schema.summary or "", + "matchers": schema.matchers, + } + ) except DeepSchemaError: - results.append({ - "name": name, - "summary": f"(failed to parse {manifest_path})", - "matchers": [], - }) + results.append( + { + "name": name, + "summary": f"(failed to parse {manifest_path})", + "matchers": [], + } + ) return results # ---- Review tool (outside the workflow lifecycle) ---- diff --git a/src/deepwork/standard_schemas/deepschema/deepschema_schema.json b/src/deepwork/standard_schemas/deepschema/deepschema_schema.json deleted file mode 100644 index a828c707..00000000 --- a/src/deepwork/standard_schemas/deepschema/deepschema_schema.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "DeepSchema Definition", - "description": "Schema for deepschema.yml files used to define file-level schemas with requirements, validation, and review generation.", - "type": "object", - "additionalProperties": false, - "properties": { - "requirements": { - "type": "object", - "description": "RFC 2119 keyed requirements. Keys are requirement names, values are descriptions using MUST, SHOULD, etc.", - "patternProperties": { - "^[a-zA-Z0-9_-]+$": { - "type": "string" - } - }, - "additionalProperties": false - }, - "parent_deep_schemas": { - "type": "array", - "description": "Names of parent DeepSchemas whose requirements are inherited.", - "items": { - "type": "string" - } - }, - "json_schema_path": { - "type": "string", - "description": "Relative path to a JSON Schema file to enforce on matched files." - }, - "verification_bash_command": { - "type": "array", - "description": "Bash commands to run on the file for verification. Each string is a separate command.", - "items": { - "type": "string" - } - }, - "summary": { - "type": "string", - "description": "High-level summary of the schema type. Used for search/discovery." - }, - "instructions": { - "type": "string", - "description": "General instructions for dealing with files of this type." - }, - "examples": { - "type": "array", - "description": "Example files of this type done well.", - "items": { - "type": "object", - "required": ["path", "description"], - "additionalProperties": false, - "properties": { - "path": { - "type": "string", - "description": "Relative path from the definition file to the example." - }, - "description": { - "type": "string", - "description": "Description of what makes this a good example." - } - } - } - }, - "references": { - "type": "array", - "description": "Reference files or URLs for detailed guidance.", - "items": { - "type": "object", - "required": ["path", "description"], - "additionalProperties": false, - "properties": { - "path": { - "type": "string", - "description": "Relative path or URL to the reference." - }, - "description": { - "type": "string", - "description": "Description of what this reference covers." - } - } - } - }, - "matchers": { - "type": "array", - "description": "Glob patterns declaring which files this schema applies to.", - "items": { - "type": "string" - } - } - } -} diff --git a/src/deepwork/standard_schemas/deepschema/deepschema_schema.json b/src/deepwork/standard_schemas/deepschema/deepschema_schema.json new file mode 120000 index 00000000..8bfe34b0 --- /dev/null +++ b/src/deepwork/standard_schemas/deepschema/deepschema_schema.json @@ -0,0 +1 @@ +../../schemas/deepschema_schema.json \ No newline at end of file From ef73918ff6987221c8a851085107c63042edf018 Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Mon, 30 Mar 2026 12:27:12 -0600 Subject: [PATCH 13/15] docs: fix stale documentation found during review - Add deepschema skill to directory trees in architecture.md and CLAUDE.md - Add get_named_schemas tool (#9) to architecture.md MCP tools section - Update hooks README event mapping (add before_model, after_model, SubagentStop) - Update hooks README tool mapping (add web_fetch, web_search, task) - Add .deepreview to hooks README files table Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/architecture.md | 12 ++++++++++-- src/deepwork/hooks/README.md | 8 +++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/doc/architecture.md b/doc/architecture.md index ecdc459f..ca196707 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -104,7 +104,8 @@ deepwork/ # DeepWork tool repository │ │ ├── skills/ │ │ │ ├── deepwork/SKILL.md │ │ │ ├── review/SKILL.md -│ │ │ └── configure_reviews/SKILL.md +│ │ │ ├── configure_reviews/SKILL.md +│ │ │ └── deepschema/SKILL.md │ │ ├── hooks/ # hooks.json, post_commit_reminder.sh, post_compact.sh, startup_context.sh, deepschema_write.sh │ │ └── .mcp.json # MCP server config │ └── gemini/ # Gemini CLI extension @@ -818,7 +819,7 @@ All MCP server code lives in `src/deepwork/jobs/mcp/`. The FastMCP server definition that: - Creates and configures the MCP server instance -- Registers the workflow tools and review tools (`get_review_instructions`, `get_configured_reviews`, `mark_review_as_passed`) +- Registers the workflow tools, review tools (`get_review_instructions`, `get_configured_reviews`, `mark_review_as_passed`), and DeepSchema tools (`get_named_schemas`) - Detects job definition issues at startup via `issues.py` and appends warnings to tool responses - Provides server instructions for agents @@ -914,6 +915,13 @@ Marks a review as passed so it is skipped on subsequent runs while the reviewed **Returns**: Confirmation string or validation error. +#### 9. `get_named_schemas` +Lists all named DeepSchemas discovered across all schema sources (project-local, standard, and env var). Returns each schema's name, summary, and matcher patterns. + +**Parameters**: None. + +**Returns**: List of `{name, summary, matchers}` dicts. + ### Review Pass Caching Each review task is assigned a deterministic `review_id` encoding the rule name, file paths, and a SHA-256 content hash (first 12 hex chars). When `get_review_instructions` generates instruction files, it names them `{review_id}.md` and checks for a corresponding `{review_id}.passed` marker. If the marker exists, the review is skipped. diff --git a/src/deepwork/hooks/README.md b/src/deepwork/hooks/README.md index c5aa89b8..ae161e0b 100644 --- a/src/deepwork/hooks/README.md +++ b/src/deepwork/hooks/README.md @@ -84,12 +84,14 @@ if __name__ == "__main__": | DeepWork Normalized | Claude Code | Gemini CLI | |---------------------|-------------|------------| -| `after_agent` | Stop | AfterAgent | +| `after_agent` | Stop, SubagentStop | AfterAgent | | `before_tool` | PreToolUse | BeforeTool | | `after_tool` | PostToolUse | AfterTool | | `before_prompt` | UserPromptSubmit | BeforeAgent | | `session_start` | SessionStart | SessionStart | | `session_end` | SessionEnd | SessionEnd | +| `before_model` | — | BeforeModel | +| `after_model` | — | AfterModel | ## Tool Name Mapping @@ -101,6 +103,9 @@ if __name__ == "__main__": | `shell` | Bash | shell | | `glob` | Glob | glob | | `grep` | Grep | grep | +| `web_fetch` | WebFetch | web_fetch | +| `web_search` | WebSearch | web_search | +| `task` | Task | — | ## Decision Values @@ -136,3 +141,4 @@ pytest tests/shell_script_tests/test_hook_wrappers.py -v | `deepschema_write.py` | DeepSchema write-time validation hook | | `claude_hook.sh` | Shell wrapper for Claude Code | | `gemini_hook.sh` | Shell wrapper for Gemini CLI | +| `.deepreview` | Review rule ensuring hooks use correct output routing (DW-REQ-006.6) | From 20dbc39e910c16ff436139895b749a1018f86e41 Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Mon, 30 Mar 2026 12:27:27 -0600 Subject: [PATCH 14/15] docs: add deepschema skill to AGENTS.md project structure Co-Authored-By: Claude Opus 4.6 (1M context) --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index c74cfcf2..4f3da1cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -212,6 +212,7 @@ deepwork/ │ │ ├── skills/ │ │ │ ├── configure_reviews/SKILL.md │ │ │ ├── deepwork/SKILL.md +│ │ │ ├── deepschema/SKILL.md │ │ │ └── review/SKILL.md │ │ ├── hooks/ # hooks.json, post_commit_reminder.sh, post_compact.sh, startup_context.sh, deepschema_write.sh │ │ └── .mcp.json # MCP server config From f8442296fe43149e87180ce70d6fea7bf8a3408b Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Mon, 30 Mar 2026 12:28:37 -0600 Subject: [PATCH 15/15] docs: add RFC 2119 review severity logic to DW-REQ-011.8 Captures how reviewers should treat MUST/SHOULD/MAY violations in DeepSchema compliance reviews, matching the original design intent. Co-Authored-By: Claude Opus 4.6 (1M context) --- specs/deepwork/DW-REQ-011-deepschema.md | 1 + 1 file changed, 1 insertion(+) diff --git a/specs/deepwork/DW-REQ-011-deepschema.md b/specs/deepwork/DW-REQ-011-deepschema.md index c5af3d84..b8175914 100644 --- a/specs/deepwork/DW-REQ-011-deepschema.md +++ b/specs/deepwork/DW-REQ-011-deepschema.md @@ -61,6 +61,7 @@ The DeepSchema system provides rich, file-level schemas with automatic validatio 4. Anonymous schema reviews MUST include only the requirements. 5. All generated reviews MUST use the `"individual"` strategy (one file at a time). 6. Generated reviews MUST be included in both `/review` runs and workflow quality gate checks. +7. Review instructions MUST specify RFC 2119 severity logic: reviewers MUST fail any violation of a MUST requirement, MUST fail any SHOULD requirement that could easily be followed but is not, SHOULD give feedback without failing on other applicable items, and MUST ignore requirements that are not applicable. ## DW-REQ-011.9: MCP Tool — get_named_schemas