From b0d71418facff631a251346ec19b44a95a3c1082 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Tue, 14 Jul 2026 13:43:31 +0200 Subject: [PATCH] Add AL code generation skill contracts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bd6344b4-eafd-4a58-a1c9-5a2d96fa2938 --- .claude-plugin/plugin.json | 2 +- .github/scripts/generation_contracts.py | 246 +++++++++ .github/scripts/test_generation_contracts.py | 307 +++++++++++ .github/scripts/validate_frontmatter.py | 66 ++- .github/workflows/validate-frontmatter.yml | 5 +- README.md | 11 +- agent-consumption.md | 36 +- .../skills/generate/al-code-generation.md | 102 ++++ .../generated-files-report-v1.example.json | 67 +++ .../examples/requirement-spec-v1.example.json | 54 ++ schemas/generated-files-report-v1.schema.json | 509 ++++++++++++++++++ schemas/requirement-spec-v1.schema.json | 348 ++++++++++++ skills/README.md | 6 +- skills/bcquality-al-generate/SKILL.md | 44 ++ skills/do.md | 29 +- skills/entry.md | 88 ++- skills/read.md | 4 +- 17 files changed, 1885 insertions(+), 39 deletions(-) create mode 100644 .github/scripts/generation_contracts.py create mode 100644 .github/scripts/test_generation_contracts.py create mode 100644 microsoft/skills/generate/al-code-generation.md create mode 100644 schemas/examples/generated-files-report-v1.example.json create mode 100644 schemas/examples/requirement-spec-v1.example.json create mode 100644 schemas/generated-files-report-v1.schema.json create mode 100644 schemas/requirement-spec-v1.schema.json create mode 100644 skills/bcquality-al-generate/SKILL.md diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index bacea2a..aef2cba 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "bcquality", - "description": "Quality skills and knowledge for Business Central development. Exposes a review bridge skill that drives the BCQuality Entry protocol over the installed knowledge base.", + "description": "Quality skills and knowledge for Business Central development. Exposes separate review and create-only AL generation bridges over the BCQuality Entry protocol.", "version": "0.1.0", "author": { "name": "microsoft/BCQuality", diff --git a/.github/scripts/generation_contracts.py b/.github/scripts/generation_contracts.py new file mode 100644 index 0000000..de18ece --- /dev/null +++ b/.github/scripts/generation_contracts.py @@ -0,0 +1,246 @@ +"""Strict JSON Schema and semantic validation for AL generation contracts.""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator, FormatChecker + + +MAX_REQUIREMENT_BYTES = 1_048_576 +MAX_ARTIFACT_BYTES = 262_144 +MAX_TOTAL_ARTIFACT_BYTES = 4_194_304 + + +class DuplicateKeyError(ValueError): + pass + + +def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise DuplicateKeyError(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def load_bounded_json(path: Path, max_bytes: int = MAX_REQUIREMENT_BYTES) -> Any: + raw = path.read_bytes() + if len(raw) > max_bytes: + raise ValueError(f"JSON file exceeds {max_bytes} bytes") + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError("JSON file is not UTF-8") from exc + try: + return json.loads(text, object_pairs_hook=_reject_duplicate_keys) + except (json.JSONDecodeError, DuplicateKeyError) as exc: + raise ValueError(f"malformed JSON: {exc}") from exc + + +def load_schema(path: Path) -> dict[str, Any]: + schema = load_bounded_json(path, max_bytes=2_097_152) + if not isinstance(schema, dict): + raise ValueError(f"schema must be a JSON object: {path}") + Draft202012Validator.check_schema(schema) + return schema + + +def schema_errors(schema: dict[str, Any], instance: Any) -> list[str]: + validator = Draft202012Validator(schema, format_checker=FormatChecker()) + return [error.message for error in sorted(validator.iter_errors(instance), key=lambda e: list(e.path))] + + +def _canonical_path_error(value: Any, *, allow_dot: bool = False, require_al: bool = False) -> str | None: + if not isinstance(value, str) or not value: + return "must be a non-empty string" + if allow_dot and value == ".": + return None + if value.startswith("/") or "\\" in value or (len(value) >= 2 and value[1] == ":"): + return "must be a project-relative forward-slash path" + if value.endswith("/") or "//" in value: + return "must be canonical without empty path segments" + parts = value.split("/") + if not parts or any(part in {"", ".", ".."} for part in parts): + return "must not contain dot or traversal segments" + if any(part == ".git" for part in parts): + return "must not address .git" + if require_al and not value.endswith(".al"): + return "must end with case-sensitive .al" + return None + + +def _is_under(path: str, root: str) -> bool: + if root == ".": + return True + return path.startswith(f"{root}/") + + +def _in_id_ranges(object_id: int, ranges: list[dict[str, int]]) -> bool: + return any(item["from"] <= object_id <= item["to"] for item in ranges) + + +def validate_requirement_semantics( + document: dict[str, Any], + *, + existing_paths: set[str] | None = None, +) -> list[str]: + errors: list[str] = [] + app_root = document.get("app-root") + if error := _canonical_path_error(app_root, allow_dot=True): + errors.append(f"app-root {error}") + return errors + + requested = document.get("requested-artifacts", []) + allowlist = document.get("related-file-allowlist", []) + requested_paths = [item.get("path") for item in requested if isinstance(item, dict)] + if len(requested_paths) != len(set(requested_paths)): + errors.append("requested artifact paths must be unique") + if len(allowlist) != len(set(allowlist)): + errors.append("related-file-allowlist paths must be unique") + + for path in requested_paths: + if error := _canonical_path_error(path, require_al=True): + errors.append(f"requested artifact path {path!r} {error}") + elif not _is_under(path, app_root): + errors.append(f"requested artifact path {path!r} is outside app-root {app_root!r}") + if existing_paths is not None and path in existing_paths: + errors.append(f"requested artifact path {path!r} already exists") + + for path in allowlist: + if error := _canonical_path_error(path): + errors.append(f"allowlist path {path!r} {error}") + + ranges = document.get("target", {}).get("app", {}).get("id-ranges", []) + ordered = sorted(ranges, key=lambda item: (item.get("from", 0), item.get("to", 0))) + previous_to = 0 + for item in ordered: + start, end = item.get("from"), item.get("to") + if not isinstance(start, int) or not isinstance(end, int): + continue + if start > end: + errors.append(f"ID range {start}-{end} is inverted") + if start <= previous_to: + errors.append(f"ID range {start}-{end} overlaps another range") + previous_to = max(previous_to, end) + + object_ids = [item.get("object-id") for item in requested if isinstance(item, dict) and "object-id" in item] + if len(object_ids) != len(set(object_ids)): + errors.append("requested object IDs must be unique") + for object_id in object_ids: + if isinstance(object_id, int) and not _in_id_ranges(object_id, ranges): + errors.append(f"requested object ID {object_id} is outside target id-ranges") + + dependencies = document.get("target", {}).get("app", {}).get("dependencies", []) + dependency_ids = [item.get("id") for item in dependencies if isinstance(item, dict)] + if len(dependency_ids) != len(set(dependency_ids)): + errors.append("dependency IDs must be unique") + + return errors + + +def validate_report_semantics( + document: dict[str, Any], + *, + requirement: dict[str, Any] | None = None, +) -> list[str]: + errors: list[str] = [] + artifacts = document.get("artifacts", []) + omitted = document.get("omitted-guidance", []) + summary = document.get("summary", {}) + coverage = summary.get("coverage", {}) + + paths = [artifact.get("path") for artifact in artifacts if isinstance(artifact, dict)] + ids = [artifact.get("object-id") for artifact in artifacts if isinstance(artifact, dict)] + if len(paths) != len(set(paths)): + errors.append("artifact paths must be unique") + if len(ids) != len(set(ids)): + errors.append("artifact object IDs must be unique") + + content_sizes: list[int] = [] + for artifact in artifacts: + if not isinstance(artifact, dict): + continue + path = artifact.get("path") + if error := _canonical_path_error(path, require_al=True): + errors.append(f"artifact path {path!r} {error}") + content = artifact.get("content") + if isinstance(content, str): + size = len(content.encode("utf-8")) + content_sizes.append(size) + if size > MAX_ARTIFACT_BYTES: + errors.append(f"artifact {path!r} exceeds {MAX_ARTIFACT_BYTES} UTF-8 bytes") + + total_size = sum(content_sizes) + if total_size > MAX_TOTAL_ARTIFACT_BYTES: + errors.append(f"artifact content exceeds {MAX_TOTAL_ARTIFACT_BYTES} total UTF-8 bytes") + if summary.get("artifact-count") != len(artifacts): + errors.append("summary.artifact-count does not match artifacts") + if summary.get("total-content-bytes") != total_size: + errors.append("summary.total-content-bytes does not match UTF-8 artifact content") + if coverage.get("omitted-count") != len(omitted): + errors.append("summary.coverage.omitted-count does not match omitted-guidance") + if coverage.get("opened-article-count", 0) > coverage.get("worklist-count", 0): + errors.append("opened-article-count exceeds worklist-count") + if coverage.get("worklist-count", 0) > coverage.get("relevant-count", 0): + errors.append("worklist-count exceeds relevant-count") + if coverage.get("relevant-count", 0) > coverage.get("candidate-count", 0): + errors.append("relevant-count exceeds candidate-count") + if coverage.get("opened-article-count") != coverage.get("worklist-count"): + errors.append("opened-article-count must equal worklist-count") + if coverage.get("relevant-count") != coverage.get("worklist-count", 0) + coverage.get("omitted-count", 0): + errors.append("relevant-count must equal worklist-count plus omitted-count") + if omitted and document.get("outcome") != "partial": + errors.append("any omitted guidance requires outcome partial") + + revision = document.get("knowledge-revision", {}).get("commit-sha") + for artifact in artifacts: + if not isinstance(artifact, dict): + continue + for key in ("article-references", "good-sample-references"): + for reference in artifact.get(key, []): + if reference.get("sha") != revision: + errors.append(f"{key} reference SHA must equal knowledge-revision.commit-sha") + for item in document.get("applied-guidance", []) + omitted: + if item.get("reference", {}).get("sha") != revision: + errors.append("guidance reference SHA must equal knowledge-revision.commit-sha") + for item in document.get("suppressed", []): + if item.get("reference", {}).get("sha") != revision: + errors.append("suppressed reference SHA must equal knowledge-revision.commit-sha") + if item.get("superseded-by") and item["superseded-by"].get("sha") != revision: + errors.append("superseding reference SHA must equal knowledge-revision.commit-sha") + + if requirement is not None: + app_root = requirement.get("app-root", "") + requested = { + item["path"]: item + for item in requirement.get("requested-artifacts", []) + if isinstance(item, dict) and "path" in item + } + ranges = requirement.get("target", {}).get("app", {}).get("id-ranges", []) + for artifact in artifacts: + path = artifact.get("path") + request = requested.get(path) + if not _is_under(path, app_root): + errors.append(f"artifact path {path!r} is outside app-root {app_root!r}") + if request is None: + errors.append(f"artifact path {path!r} was not requested") + elif artifact.get("object-type") != request.get("object-type"): + errors.append(f"artifact {path!r} object type differs from request") + elif artifact.get("object-name") != request.get("object-name"): + errors.append(f"artifact {path!r} object name differs from request") + object_id = artifact.get("object-id") + if request is not None and request.get("object-id") is not None and object_id != request["object-id"]: + errors.append(f"artifact {path!r} object ID differs from request") + if isinstance(object_id, int) and not _in_id_ranges(object_id, ranges): + errors.append(f"artifact object ID {object_id} is outside target id-ranges") + + artifact_path_set = set(paths) + for item in document.get("applied-guidance", []): + for path in item.get("artifact-paths", []): + if path not in artifact_path_set: + errors.append(f"applied-guidance references absent artifact {path!r}") + + return errors diff --git a/.github/scripts/test_generation_contracts.py b/.github/scripts/test_generation_contracts.py new file mode 100644 index 0000000..02cb794 --- /dev/null +++ b/.github/scripts/test_generation_contracts.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import copy +import json +import tempfile +import unittest +from pathlib import Path + +import yaml + +from generation_contracts import ( + MAX_ARTIFACT_BYTES, + load_bounded_json, + load_schema, + schema_errors, + validate_report_semantics, + validate_requirement_semantics, +) + + +ROOT = Path(__file__).resolve().parents[2] +REQUIREMENT_SCHEMA = load_schema(ROOT / "schemas/requirement-spec-v1.schema.json") +REPORT_SCHEMA = load_schema(ROOT / "schemas/generated-files-report-v1.schema.json") +VALID_REQUIREMENT = load_bounded_json(ROOT / "schemas/examples/requirement-spec-v1.example.json") +VALID_REPORT = load_bounded_json(ROOT / "schemas/examples/generated-files-report-v1.example.json") +REVIEW_INPUTS = {"pr-diff", "object-list", "file-path", "repository", "telemetry-query"} + + +def frontmatter(path: Path) -> dict: + text = path.read_text(encoding="utf-8") + return yaml.safe_load(text.split("---", 2)[1]) + + +def route(task: dict) -> dict: + inputs = set(task.get("inputs-available", [])) + action = task.get("action") + has_generation = "requirement-spec" in inputs + has_review = bool(inputs & REVIEW_INPUTS) + accepted = {(item["kind"], item["version"]) for item in task.get("accepted-outputs", [])} + + if has_generation and has_review and action is None: + return {"outcome": "failed", "outcome-reason": "ambiguous-action", "dispatch": []} + if action == "generate": + if not has_generation or ("generated-files-report", 1) not in accepted: + return {"outcome": "no-match", "dispatch": []} + return { + "outcome": "routed", + "dispatch": [{ + "skill": { + "id": "al-code-generation", + "version": 1, + "path": "microsoft/skills/generate/al-code-generation.md", + }, + "inputs": ["requirement-spec"], + "output": {"kind": "generated-files-report", "version": 1}, + }], + } + if action == "review" or (action is None and has_review and not has_generation): + if accepted and ("findings-report", 1) not in accepted: + return {"outcome": "no-match", "dispatch": []} + return { + "outcome": "routed", + "dispatch": [{ + "skill": { + "id": "al-code-review", + "version": 1, + "path": "microsoft/skills/review/al-code-review.md", + }, + "inputs": sorted(inputs & {"pr-diff", "file-path"}), + "output": {"kind": "findings-report", "version": 1}, + }], + } + return {"outcome": "failed", "outcome-reason": "explicit-generate-action-required", "dispatch": []} + + +class GenerationContractTests(unittest.TestCase): + def assert_requirement_invalid(self, document: dict) -> None: + self.assertTrue( + schema_errors(REQUIREMENT_SCHEMA, document) or validate_requirement_semantics(document), + "expected invalid requirement", + ) + + def assert_report_invalid(self, document: dict) -> None: + self.assertTrue( + schema_errors(REPORT_SCHEMA, document) + or validate_report_semantics(document, requirement=VALID_REQUIREMENT), + "expected invalid report", + ) + + def test_published_examples_are_strict_and_semantically_valid(self) -> None: + self.assertEqual([], schema_errors(REQUIREMENT_SCHEMA, VALID_REQUIREMENT)) + self.assertEqual([], validate_requirement_semantics(VALID_REQUIREMENT)) + self.assertEqual([], schema_errors(REPORT_SCHEMA, VALID_REPORT)) + self.assertEqual([], validate_report_semantics(VALID_REPORT, requirement=VALID_REQUIREMENT)) + + def test_malformed_and_duplicate_key_json_fail(self) -> None: + with tempfile.TemporaryDirectory() as directory: + malformed = Path(directory) / "malformed.json" + malformed.write_text('{"schema-version":', encoding="utf-8") + with self.assertRaisesRegex(ValueError, "malformed JSON"): + load_bounded_json(malformed) + duplicate = Path(directory) / "duplicate.json" + duplicate.write_text('{"schema-version":1,"schema-version":1}', encoding="utf-8") + with self.assertRaisesRegex(ValueError, "duplicate JSON key"): + load_bounded_json(duplicate) + oversized = Path(directory) / "oversized.json" + oversized.write_bytes(b" " * 1_048_577) + with self.assertRaisesRegex(ValueError, "exceeds 1048576 bytes"): + load_bounded_json(oversized) + invalid_utf8 = Path(directory) / "invalid-utf8.json" + invalid_utf8.write_bytes(b"\xff") + with self.assertRaisesRegex(ValueError, "not UTF-8"): + load_bounded_json(invalid_utf8) + + def test_requirement_unknown_version_and_unbounded_artifacts_fail(self) -> None: + unknown = copy.deepcopy(VALID_REQUIREMENT) + unknown["schema-version"] = 2 + self.assert_requirement_invalid(unknown) + unbounded = copy.deepcopy(VALID_REQUIREMENT) + unbounded["requested-artifacts"] *= 65 + self.assert_requirement_invalid(unbounded) + + def test_unsafe_paths_fail(self) -> None: + for path in ( + "/tmp/Object.al", + "C:/Object.al", + "../Object.al", + "src/../Object.al", + "src/SampleApp/./Object.al", + ".git/Object.al", + "src\\Object.al", + "outside/Object.al", + ): + with self.subTest(path=path): + document = copy.deepcopy(VALID_REQUIREMENT) + document["requested-artifacts"][0]["path"] = path + self.assert_requirement_invalid(document) + + def test_duplicate_overwrite_delete_and_rename_requests_fail(self) -> None: + duplicate = copy.deepcopy(VALID_REQUIREMENT) + duplicate["requested-artifacts"].append(copy.deepcopy(duplicate["requested-artifacts"][0])) + self.assert_requirement_invalid(duplicate) + for operation in ("overwrite", "delete", "rename"): + with self.subTest(operation=operation): + document = copy.deepcopy(VALID_REQUIREMENT) + document["requested-artifacts"][0]["operation"] = operation + self.assert_requirement_invalid(document) + existing = {"src/SampleApp/Setup/CQSampleSetup.Table.al"} + self.assertTrue(validate_requirement_semantics(VALID_REQUIREMENT, existing_paths=existing)) + aliased = copy.deepcopy(VALID_REQUIREMENT) + aliased["requested-artifacts"][0]["path"] = "src/SampleApp/./Setup/CQSampleSetup.Table.al" + self.assert_requirement_invalid(aliased) + self.assertTrue(validate_requirement_semantics(aliased, existing_paths=existing)) + + def test_id_range_failures(self) -> None: + outside = copy.deepcopy(VALID_REQUIREMENT) + outside["requested-artifacts"][0]["object-id"] = 80000 + self.assert_requirement_invalid(outside) + inverted = copy.deepcopy(VALID_REQUIREMENT) + inverted["target"]["app"]["id-ranges"][0] = {"from": 70049, "to": 70000} + self.assert_requirement_invalid(inverted) + overlapping = copy.deepcopy(VALID_REQUIREMENT) + overlapping["target"]["app"]["id-ranges"].append({"from": 70025, "to": 70075}) + self.assert_requirement_invalid(overlapping) + + def test_report_duplicate_overwrite_findings_id_range_and_size_fail(self) -> None: + duplicate = copy.deepcopy(VALID_REPORT) + duplicate["artifacts"].append(copy.deepcopy(duplicate["artifacts"][0])) + duplicate["summary"]["artifact-count"] = 2 + duplicate["summary"]["total-content-bytes"] *= 2 + self.assert_report_invalid(duplicate) + overwrite = copy.deepcopy(VALID_REPORT) + overwrite["artifacts"][0]["operation"] = "overwrite" + self.assert_report_invalid(overwrite) + findings = copy.deepcopy(VALID_REPORT) + findings["findings"] = [] + self.assert_report_invalid(findings) + outside_id = copy.deepcopy(VALID_REPORT) + outside_id["artifacts"][0]["object-id"] = 80000 + self.assert_report_invalid(outside_id) + changed_id = copy.deepcopy(VALID_REPORT) + changed_id["artifacts"][0]["object-id"] = 70001 + self.assert_report_invalid(changed_id) + changed_name = copy.deepcopy(VALID_REPORT) + changed_name["artifacts"][0]["object-name"] = "CQ Renamed Setup" + self.assert_report_invalid(changed_name) + oversized = copy.deepcopy(VALID_REPORT) + oversized["artifacts"][0]["content"] = "é" * (MAX_ARTIFACT_BYTES // 2 + 1) + oversized["summary"]["total-content-bytes"] = len(oversized["artifacts"][0]["content"].encode("utf-8")) + self.assert_report_invalid(oversized) + + def test_any_guidance_omission_forces_partial(self) -> None: + report = copy.deepcopy(VALID_REPORT) + report["omitted-guidance"] = [{ + "reference": { + "path": "microsoft/knowledge/appsource/object-affixes-prevent-collisions.md", + "sha": report["knowledge-revision"]["commit-sha"], + }, + "reason": "context-budget", + "detail": "Ranked after the configured context budget.", + }] + report["summary"]["coverage"]["omitted-count"] = 1 + self.assert_report_invalid(report) + report["outcome"] = "partial" + report["summary"]["coverage"]["relevant-count"] = 2 + self.assertEqual([], schema_errors(REPORT_SCHEMA, report)) + self.assertEqual([], validate_report_semantics(report, requirement=VALID_REQUIREMENT)) + + def test_coverage_cannot_hide_unopened_relevant_guidance(self) -> None: + report = copy.deepcopy(VALID_REPORT) + report["summary"]["coverage"]["relevant-count"] = 2 + report["summary"]["coverage"]["worklist-count"] = 2 + self.assert_report_invalid(report) + + def test_immutable_revision_can_identify_a_fork(self) -> None: + report = copy.deepcopy(VALID_REPORT) + report["knowledge-revision"]["repository"] = "https://github.com/contoso/BCQuality" + self.assertEqual([], schema_errors(REPORT_SCHEMA, report)) + self.assertEqual([], validate_report_semantics(report, requirement=VALID_REQUIREMENT)) + + def test_deterministic_entry_routing_matrix(self) -> None: + cases = { + "generation-only": ( + { + "action": "generate", + "inputs-available": ["requirement-spec"], + "accepted-outputs": [{"kind": "generated-files-report", "version": 1}], + }, + "al-code-generation", + ), + "legacy-review-only": ( + {"inputs-available": ["pr-diff"]}, + "al-code-review", + ), + "both-explicit-generate": ( + { + "action": "generate", + "inputs-available": ["pr-diff", "requirement-spec"], + "accepted-outputs": [{"kind": "generated-files-report", "version": 1}], + }, + "al-code-generation", + ), + "both-explicit-review": ( + { + "action": "review", + "inputs-available": ["pr-diff", "requirement-spec"], + "accepted-outputs": [{"kind": "findings-report", "version": 1}], + }, + "al-code-review", + ), + } + for name, (task, expected) in cases.items(): + with self.subTest(name=name): + result = route(task) + self.assertEqual("routed", result["outcome"]) + self.assertEqual(expected, result["dispatch"][0]["skill"]["id"]) + self.assertIn("output", result["dispatch"][0]) + + ambiguous = route({"inputs-available": ["pr-diff", "requirement-spec"]}) + self.assertEqual("failed", ambiguous["outcome"]) + self.assertEqual("ambiguous-action", ambiguous["outcome-reason"]) + wrong_version = route({ + "action": "generate", + "inputs-available": ["requirement-spec"], + "accepted-outputs": [{"kind": "generated-files-report", "version": 2}], + }) + self.assertEqual("no-match", wrong_version["outcome"]) + + def test_current_non_capability_consumers_remain_generation_ineligible(self) -> None: + result = route({"inputs-available": ["repository"]}) + self.assertEqual("al-code-review", result["dispatch"][0]["skill"]["id"]) + no_action = route({ + "inputs-available": ["requirement-spec"], + "accepted-outputs": [{"kind": "generated-files-report", "version": 1}], + }) + self.assertEqual("failed", no_action["outcome"]) + + def test_generation_skill_shape_and_internal_references(self) -> None: + skill_path = ROOT / "microsoft/skills/generate/al-code-generation.md" + metadata = frontmatter(skill_path) + self.assertEqual("al-code-generation", metadata["id"]) + self.assertEqual(["requirement-spec"], metadata["inputs"]) + self.assertEqual(["generated-files-report"], metadata["outputs"]) + self.assertEqual(1, metadata["output-version"]) + for relative in ( + "schemas/requirement-spec-v1.schema.json", + "schemas/generated-files-report-v1.schema.json", + "schemas/examples/requirement-spec-v1.example.json", + "schemas/examples/generated-files-report-v1.example.json", + "microsoft/knowledge/appsource/object-affixes-prevent-collisions.md", + "microsoft/knowledge/appsource/object-affixes-prevent-collisions.good.al", + ): + self.assertTrue((ROOT / relative).is_file(), relative) + + def test_plugin_bridges_are_intent_isolated(self) -> None: + review = (ROOT / "skills/bcquality-al-review/SKILL.md").read_text(encoding="utf-8") + generate = (ROOT / "skills/bcquality-al-generate/SKILL.md").read_text(encoding="utf-8") + self.assertIn("Do **not** use this skill to *generate* AL code", review) + self.assertNotIn("action: generate", review) + self.assertIn("action: generate", generate) + self.assertIn("generated-files-report", generate) + self.assertNotIn("findings-report", generate) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/.github/scripts/validate_frontmatter.py b/.github/scripts/validate_frontmatter.py index 682a801..b0f52fa 100644 --- a/.github/scripts/validate_frontmatter.py +++ b/.github/scripts/validate_frontmatter.py @@ -21,6 +21,14 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Iterable +from generation_contracts import ( + load_bounded_json, + load_schema, + schema_errors, + validate_report_semantics, + validate_requirement_semantics, +) + try: import yaml except ImportError: @@ -39,14 +47,16 @@ ACTION_SKILL_REQUIRED_KEYS = { } ACTION_SKILL_OPTIONAL_KEYS = { "bc-version", "technologies", "countries", "application-area", "sub-skills", + "output-version", } META_SKILL_REQUIRED_KEYS = {"kind", "id", "version", "title"} ENTRY_SKILL_REQUIRED_KEYS = {"kind", "id", "version", "title"} STANDARD_INPUTS = { "pr-diff", "object-list", "file-path", "repository", "telemetry-query", + "requirement-spec", } -ALLOWED_OUTPUTS = {"findings-report"} +ALLOWED_OUTPUTS = {"findings-report", "generated-files-report"} VALID_SAMPLE_KINDS = {"good", "bad"} ACTION_SKILL_SECTIONS = ["Source", "Relevance", "Worklist", "Action", "Output"] @@ -331,9 +341,30 @@ def validate_action_skill(path: Path, parsed: Parsed, report: Report) -> None: if not is_non_empty_list_of_str(out): report.error(path, "R18", "outputs must be a non-empty list of strings", 1) else: + if len(out) != 1: + report.error(path, "R18", "outputs must contain exactly one output kind", 1) bad = [x for x in out if x not in ALLOWED_OUTPUTS] if bad: - report.error(path, "R18", f"outputs contains non-allowed values {bad}; currently only {sorted(ALLOWED_OUTPUTS)} is defined", 1) + report.error(path, "R18", f"outputs contains non-allowed values {bad}; allowed values are {sorted(ALLOWED_OUTPUTS)}", 1) + if "output-version" in fm: + output_version = fm["output-version"] + if not isinstance(output_version, int) or isinstance(output_version, bool) or output_version <= 0: + report.error(path, "R18", "output-version must be a positive integer", 1) + + # R27 generation skills have one stable, non-composed contract shape. + if fm.get("outputs") == ["generated-files-report"]: + rel = path.as_posix() + expected_suffix = "microsoft/skills/generate/al-code-generation.md" + if not rel.endswith(expected_suffix): + report.error(path, "R27", f"generated-files-report is reserved for {expected_suffix}", 1) + if fm.get("id") != "al-code-generation" or fm.get("version") != 1: + report.error(path, "R27", "generation skill must have id al-code-generation and version 1", 1) + if fm.get("inputs") != ["requirement-spec"]: + report.error(path, "R27", "generation skill must accept only [requirement-spec]", 1) + if fm.get("output-version") != 1: + report.error(path, "R27", "generation skill must declare output-version: 1", 1) + if "sub-skills" in fm: + report.error(path, "R27", "generation skill must be a leaf without sub-skills", 1) # R19 optional filter dimensions, if present if "bc-version" in fm: @@ -611,6 +642,37 @@ def run(root: Path) -> Report: for path, fm in action_skill_fms: validate_sub_skills_registry(path, fm, root, report) + # Fifth pass: R28 published generation schemas and examples are strict and coherent. + requirement_schema_path = root / "schemas" / "requirement-spec-v1.schema.json" + report_schema_path = root / "schemas" / "generated-files-report-v1.schema.json" + requirement_example_path = root / "schemas" / "examples" / "requirement-spec-v1.example.json" + report_example_path = root / "schemas" / "examples" / "generated-files-report-v1.example.json" + contract_paths = ( + requirement_schema_path, + report_schema_path, + requirement_example_path, + report_example_path, + ) + missing_contracts = [path for path in contract_paths if not path.is_file()] + for path in missing_contracts: + report.error(path, "R28", "required generation contract file is missing") + if not missing_contracts: + try: + requirement_schema = load_schema(requirement_schema_path) + generated_report_schema = load_schema(report_schema_path) + requirement_example = load_bounded_json(requirement_example_path) + generated_report_example = load_bounded_json(report_example_path) + for message in schema_errors(requirement_schema, requirement_example): + report.error(requirement_example_path, "R28", message) + for message in validate_requirement_semantics(requirement_example): + report.error(requirement_example_path, "R28", message) + for message in schema_errors(generated_report_schema, generated_report_example): + report.error(report_example_path, "R28", message) + for message in validate_report_semantics(generated_report_example, requirement=requirement_example): + report.error(report_example_path, "R28", message) + except (OSError, ValueError) as exc: + report.error(requirement_schema_path, "R28", f"generation contract validation failed: {exc}") + return report diff --git a/.github/workflows/validate-frontmatter.yml b/.github/workflows/validate-frontmatter.yml index 1a66239..196a1b3 100644 --- a/.github/workflows/validate-frontmatter.yml +++ b/.github/workflows/validate-frontmatter.yml @@ -19,7 +19,10 @@ jobs: python-version: "3.12" - name: Install dependencies - run: pip install pyyaml + run: pip install pyyaml jsonschema - name: Run validator run: python .github/scripts/validate_frontmatter.py --root . + + - name: Test generation contracts and routing + run: python .github/scripts/test_generation_contracts.py diff --git a/README.md b/README.md index 6abf98a..4cb790c 100644 --- a/README.md +++ b/README.md @@ -52,11 +52,11 @@ Skills define how agents consume knowledge. They come in three flavors: READ and DO are read on demand — typically when the first dispatched action skill runs. They are not prerequisites for invoking Entry. WRITE is only used when scaffolding new content. -- **Action skills** — concrete skills that follow the Action Skill template to do real work (review code, audit telemetry, etc.). Action skills live inside the layers that own them (`/microsoft/skills/`, `/community/skills/`, `/custom/skills/`). An action skill is either a **leaf** that evaluates knowledge files directly, or a **super-skill** that composes other action skills (declared via `sub-skills` in frontmatter). The canonical reference is [`microsoft/skills/review/al-code-review.md`](microsoft/skills/review/al-code-review.md) (super-skill), which composes the AL review leaf skills under [`microsoft/skills/review/`](microsoft/skills/review/) — one per knowledge domain. +- **Action skills** — concrete skills that follow the Action Skill template to do real work. Action skills live inside the layers that own them (`/microsoft/skills/`, `/community/skills/`, `/custom/skills/`). Review skills emit `findings-report` v1; the cross-domain generation leaf [`microsoft/skills/generate/al-code-generation.md`](microsoft/skills/generate/al-code-generation.md) emits create-only `generated-files-report` v1. The canonical review super-skill remains [`microsoft/skills/review/al-code-review.md`](microsoft/skills/review/al-code-review.md), which composes the AL review leaves without generation behavior. ### Agent bootstrapping -An orchestrator (such as AL-Go) points the agent at BCQuality's URL and provides a task context. The agent's first call is `/skills/entry.md`, which returns a dispatch record naming the action skill(s) to invoke. The agent then invokes each dispatched skill in turn, reading READ and DO on demand. No prior knowledge of BCQuality's structure is baked into the orchestrator — only the convention *"invoke `/skills/entry.md` first."* +An orchestrator (such as AL-Go) points the agent at BCQuality's URL and provides a task context. The agent's first call is `/skills/entry.md`, which returns a dispatch record naming the action skill(s), input subset, and declared output kind/version. Generation requires explicit `action: generate`, a path-valued `requirement-spec`, and acceptance of `generated-files-report` v1; legacy review contexts remain backward compatible. ## Knowledge file format @@ -110,18 +110,21 @@ Action skills follow a four-step pattern: 3. **Worklist** — narrow from N candidates to the M that apply to the current task 4. **Action** — apply the relevant knowledge and produce structured output -Every action skill produces output in a common format that orchestrators can consume without skill-specific parsing. The format is JSON and includes an `outcome` (so a clean run, a not-applicable skill, and a partial failure are all distinguishable), `findings` (what the skill observed), structured `references` back to the knowledge files that informed each finding, per-finding `confidence`, and a `suppressed` list recording any knowledge files overridden by layer precedence. This contract is defined in the Action Skill meta-skill so that orchestrators and action skills remain independently evolvable. +Every action skill produces one negotiated, versioned JSON output. Review skills use `findings-report` v1. AL generation emits no findings: [`generated-files-report` v1](schemas/generated-files-report-v1.schema.json) contains create-only artifact content, immutable references, assumptions, applied and omitted guidance, suppression, and detailed coverage. Its path-valued input is [`requirement-spec` v1](schemas/requirement-spec-v1.schema.json). BCQuality is an **additive** knowledge layer: it augments the agent's review judgement, it does not replace it. Super-skills (such as `al-code-review`) run a self-review pass alongside their sub-skills and surface concerns the agent identified on its own, marked with `from-sub-skill: "agent"` and an empty `references: []` so consumers can render them distinctly from knowledge-backed findings. See [agent-consumption.md](agent-consumption.md) and [`skills/do.md`](skills/do.md) for the full contract. The meta-skills in `/skills/` define this pattern. Every concrete action skill follows it. +BCQuality owns generation contracts, routing, retrieval, ranking, and report production. Consumers own requirement acquisition and every side effect: atomic validation, staging, compilation, analysis, tests, delivery, approval, and publishing. The staged consumer rollout after this contract PR is strict parser fixtures, a dry-run patch artifact, isolated validation, and finally an environment-approved draft PR. Until a consumer advertises the required action/input/output capabilities, it remains generation-ineligible. + For the end-to-end flow — from orchestrator trigger through to how output reaches developers — see [agent-consumption.md](agent-consumption.md). ## Repository structure ``` ├── /skills/ # Global: entry-point skill + meta-skill contracts (READ, DO, WRITE) +├── /schemas/ # Versioned generation input/output JSON Schemas and examples ├── /.github/ # Actions and workflows ├── /microsoft/ # Microsoft-endorsed layer │ ├── /knowledge/ # Knowledge files by domain @@ -156,7 +159,7 @@ Contributions are welcome. Before submitting a PR: 2. Keep files atomic: one concern per file, under 100 lines. 3. Target your contribution to the right layer — most community contributions go in `/community/knowledge/`. -CI runs validation on every PR. If your knowledge file has schema violations, missing sections, code blocks, or exceeds 100 lines, the check will fail with a clear error message. +CI runs validation on every PR. If a knowledge file, action skill, or published generation contract violates its schema or structural invariants, the check fails with a clear error message. ## License diff --git a/agent-consumption.md b/agent-consumption.md index 0d2e5b4..6beae39 100644 --- a/agent-consumption.md +++ b/agent-consumption.md @@ -19,23 +19,23 @@ flowchart LR O[Orchestrator
AL-Go] -->|1 trigger + task context| A[Agent] A -->|2 invoke entry.md| E[Entry
routing skill] E -->|3 dispatch record| A - A -->|4 invoke dispatched skill| S[Action skill
e.g. al-code-review] + A -->|4 invoke dispatched skill| S[Action skill
review or generation] S -->|5 execute| P[Source → Relevance
→ Worklist → Action
reading READ · DO on demand] - P -->|6 emit| R[Findings · References
· Confidence] + P -->|6 emit negotiated contract| R[Findings report
or generated-files report] R -->|7 integrate| O ``` ### 1. Orchestrator triggers -The orchestrator has a URL setting that points at BCQuality (default: `github.com/microsoft/BCQuality`) and a task to perform. It hands the agent a **task context** — goal, inputs available (`pr-diff`, `file-path`, …), technologies, BC version, enabled layers — and says: *your source of truth lives at that URL; start by invoking `/skills/entry.md`*. +The orchestrator has a URL setting that points at BCQuality (default: `github.com/microsoft/BCQuality`) and a task to perform. It hands the agent a **task context** — goal, optional explicit `action`, inputs available (`pr-diff`, `file-path`, `requirement-spec`, …), accepted output kind/version, technologies, BC version, and enabled layers — and says: *your source of truth lives at that URL; start by invoking `/skills/entry.md`*. ### 2. Agent invokes Entry The agent reads `/skills/entry.md` and runs it against the task context. Entry applies its Source → Relevance → Worklist → Action steps over the action skills under `*/skills/**/*.md` and returns a **dispatch record**: the set of action skills to invoke, plus a list of candidates it skipped (with reasons). Routing is a skill, not orchestrator logic. ### 3. Agent consumes the dispatch record -The dispatch record names one or more action skills and the subset of inputs each should receive. If the outcome is `no-match` or `failed`, the agent returns the record to the orchestrator unchanged. +The dispatch record names one or more action skills, the subset of inputs each should receive, and each skill's declared output kind/version. If the outcome is `no-match` or `failed`, the agent returns the record to the orchestrator unchanged. ### 4. Agent invokes each dispatched action skill -Action skills live inside the layers — `/microsoft/skills/`, `/community/skills/`, `/custom/skills/` — so their authority is carried by their location. For a PR review, Entry typically dispatches `microsoft/skills/review/al-code-review.md`. The agent reads the file and executes it. +Action skills live inside the layers — `/microsoft/skills/`, `/community/skills/`, `/custom/skills/` — so their authority is carried by their location. For a PR review, Entry typically dispatches `microsoft/skills/review/al-code-review.md`. For explicit bounded generation, it dispatches `microsoft/skills/generate/al-code-generation.md`. The agent reads the file and executes it. ### 5. Action skill executes the four-step pattern @@ -54,14 +54,14 @@ At this point the agent reads READ and DO on demand — it needs READ to interpr ### 5a. The knowledge index (Source acceleration) -Discovering candidates at the Source step naively means opening every file under a domain folder just to read its frontmatter `keywords` — on a large corpus that is hundreds of file reads per review. To avoid this, BCQuality maintains a **knowledge index**: a single artifact (`knowledge-index.json`) that lists every article surviving the consumer's layer/allow-deny filtering and carries, per article, the exact inputs the Source/Worklist steps consume — `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint. +Discovering candidates at the Source step naively means opening every file under a domain folder just to read its frontmatter `keywords`. To avoid this, BCQuality maintains a **knowledge index**: a single artifact (`knowledge-index.json`) that lists every article surviving the consumer's layer/allow-deny filtering and carries, per article, the exact inputs the Source/Worklist steps consume — `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint. Review retains its documented fallback; generation is index-only and never blindly walks the repository. The index is **owned and produced by BCQuality**, not by each consumer: its generator (`tools/Build-KnowledgeIndex.ps1`) ships here, next to the skills and knowledge it derives from, so the index schema stays in lockstep with the Source contract and every consumer gets the same faithful index for free instead of re-implementing the parser. The consuming orchestrator does **not** build or invoke the index — it only prunes its clone to policy as it already does. The index is then (re)generated by BCQuality itself: **Entry's preparation step runs `Build-KnowledgeIndex.ps1` over the live, already-pruned clone** at the start of every run (see `skills/entry.md`), and BCQuality CI (`.github/workflows/knowledge-index.yml`) validates that the generator is healthy and deterministic. Building over the *pruned* clone — rather than shipping a committed full-corpus index that consumers trust — keeps the index exact for any consumer policy: it can never list an article the consumer denied, so policy-excluded rules cannot leak into discovery. The index changes only *how candidates are discovered*, never *which are selected*. The Worklist predicate is unchanged — `keywords` still drive selection — and the agent still opens each worklisted article **in full** to read its `## Best Practice` / `## Anti Pattern` rule bodies; the index is discovery metadata only and never substitutes for the article body. When no index is present, skills fall back to path-based discovery (collect by domain folder), so review still works. ### 6. Agent emits structured output -The output contract is defined in the DO meta-skill so that every action skill — today's and next year's — produces the same shape: +Output contracts are defined in DO and published schemas so consumers negotiate the exact kind/version rather than infer it from skill identity. A findings report carries: - **Outcome** — `completed`, `not-applicable`, `no-knowledge`, `partial`, or `failed`. An orchestrator can distinguish a clean run from a no-op from a failure without guessing. - **Findings** — what the skill observed (severity, message, optional location). @@ -69,10 +69,28 @@ The output contract is defined in the DO meta-skill so that every action skill - **Confidence** — per-finding evidence strength. - **Suppressed** — knowledge files that were discarded by layer precedence or configuration, so reviewers can see what was overridden. -The orchestrator parses this **without skill-specific logic**. This is the point of the contract: orchestrators and action skills evolve independently. +The orchestrator parses this **without skill-ID-specific logic**. For generation, it validates [`requirement-spec` v1](schemas/requirement-spec-v1.schema.json) and [`generated-files-report` v1](schemas/generated-files-report-v1.schema.json) atomically and fails closed before materialization. ### 7. Orchestrator integrates -The orchestrator turns findings into PR comments, build gates, or IDE diagnostics, and links the references back to the knowledge files so the PR author — human or agent — can read the guidance. +For review, the orchestrator turns findings into PR comments, build gates, or IDE diagnostics. For generation, BCQuality stops at a create-only report: the consumer independently enforces symlink, destination, size, and ID-range policy before staging, then owns compilation, analysis, tests, delivery, approval, and publishing. + +## Generation capability and staged rollout + +Generation routing is deterministic: + +| Inputs and capability | Result | +| --- | --- | +| `action: generate`, `requirement-spec`, accepts `generated-files-report` v1 | Generation only | +| Legacy `pr-diff` or `file-path`, no action | Review only | +| Both input families with `action: generate` | Generation only | +| Both input families with `action: review` | Review only | +| Both input families without action | Failed as `ambiguous-action` | + +Entry never relies on fuzzy goal text when both families are present. A consumer such as current BCAppsBCQuality that does not advertise `action`, `requirement-spec`, and `generated-files-report` v1 remains generation-ineligible. + +BCQuality owns contracts, routing, index-only retrieval, deterministic ranking, guidance application, and immutable report references. Consumer work is deliberately deferred and staged: first strict parser fixtures, then a dry-run patch artifact, then isolated compilation/analysis/test validation, then an environment-approved draft PR. Consumers continue to own delivery, human approval, and publishing. + +Stable schema paths, the stable `al-code-generation` skill ID, immutable commit-scoped references, and explicit candidate/worklist/omission coverage are designed to let BC-Bench distinguish Haiku baseline routing/retrieval failures from treatment-generation failures. ## Knowledge-backed and agent findings diff --git a/microsoft/skills/generate/al-code-generation.md b/microsoft/skills/generate/al-code-generation.md new file mode 100644 index 0000000..927b65a --- /dev/null +++ b/microsoft/skills/generate/al-code-generation.md @@ -0,0 +1,102 @@ +--- +kind: action-skill +id: al-code-generation +version: 1 +title: AL code generation +description: Generates create-only AL object files from a bounded requirement specification using applicable BCQuality guidance. +inputs: [requirement-spec] +outputs: [generated-files-report] +output-version: 1 +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL code generation + +Generate one cohesive AL feature as one or more new object files. This skill is a cross-domain leaf: it discovers applicable guidance across all enabled AL knowledge domains but does not invoke generation sub-skills. + +The `requirement-spec` input value is only a path to a bounded UTF-8 JSON file of at most 1,048,576 bytes. Never accept the requirement JSON inline, interpolate the file content into routing text, or infer a generation request from fuzzy goal text. Before retrieval, validate the file against [`schemas/requirement-spec-v1.schema.json`](../../../schemas/requirement-spec-v1.schema.json) and enforce all semantic invariants below. Reject the entire request on any failure. + +## Source + +Use the current `knowledge-index.json` rebuilt by Entry only for cross-domain discovery across the enabled Microsoft, Community, and Custom layers. Do not walk the repository to discover knowledge or source context. If the current index is unavailable, fail; generation has no path-walk fallback. + +Read only: + +- the validated requirement specification; +- index rows whose files still exist at the immutable BCQuality revision; +- worklisted normative articles and their sibling samples; and +- existing project files listed in `related-file-allowlist`. + +Never read an existing project file that is absent from the allowlist. The allowlist is complete, not a hint. + +## Relevance + +Filter index rows using READ's matching semantics and the specification's mandatory normalized target metadata: + +- match `target.effective-bc-major` to `bc-version`; +- require `technologies: [al]`; +- match the request's explicit country and application-area context; and +- discard any row whose referenced article is absent at `knowledge-revision.commit-sha`. + +Do not use `app-json-provenance` as target metadata. It is optional provenance only; normalized `target` fields always govern. + +Reject the requirement before retrieval when it is malformed, uses an unknown schema version, exceeds any bound, or contains: + +- an absolute, backslash, traversal, `.git`, non-canonical, or outside-project/app-root path; +- duplicate requested or allowlisted paths after ordinal canonical comparison; +- a requested path outside `app-root` or without a case-sensitive `.al` suffix; +- an operation other than `create`, including overwrite, delete, or rename semantics; +- an object ID outside every normalized app ID range, an inverted/overlapping ID range, or duplicate object IDs; or +- a requested path whose filesystem metadata says it already exists or resolves through a symbolic link. An existence-only metadata check is permitted and does not authorize reading the path; the consumer repeats this check authoritatively before materialization. + +## Worklist + +Rank every relevant index row deterministically. Compare normalized requirement vocabulary and each requested artifact's object type, object name, path, and intent against, in order: + +1. exact `keywords`; +2. exact title tokens; +3. exact object-type and domain cues; +4. lower-confidence description signals. + +Break ties by layer precedence and then article path in ordinal ascending order. Do not apply a per-domain cap. + +The provisional context budget is 24 articles. `generation-settings.context-budget` may override it with an integer from 1 through 64. The default is intentionally benchmark-tunable in a later change; it is not evidence that lower-ranked relevant guidance is unimportant. + +Build the complete ranked relevant set before applying the budget. Report candidate, relevant, worklist, and omitted counts. `worklist-count` is the number of relevant articles successfully opened in full; `opened-article-count` MUST equal it. `relevant-count` MUST equal `worklist-count + omitted-count`. Never silently drop relevant guidance: every relevant article not opened appears in `omitted-guidance` with a revision-scoped reference and reason. Any relevant omission, including a budget omission, forces `outcome: "partial"`. + +## Action + +For each worklist item: + +1. Open the complete article at the immutable knowledge revision. Only `## Best Practice` and `## Anti Pattern` are normative. +2. Open available `.good.al` samples first and adapt their demonstrated pattern. Never copy demonstration object IDs or names. +3. Open a `.bad.al` sample only when the normative article and good sample leave a material ambiguity. Bad samples clarify what to avoid and are never templates. +4. Resolve directly contradictory normative guidance with READ's Custom-over-Community-over-Microsoft precedence. Record every losing article in `suppressed`; do not remove it from coverage accounting. +5. Generate the requested cohesive feature. One requirement may produce multiple files, but every artifact must correspond to a requested create artifact. + +Generation is pure output construction. Do not write to the workspace, compile, test, perform a post-generation review, invoke another generation skill, consult a generic AL reference, change BCQuality knowledge, or change the knowledge-index schema. + +Before emission, verify the report atomically: + +- one strict JSON document, no commentary and no `findings` field; +- 1-64 create-only UTF-8 AL artifacts for `completed` or `partial`; +- canonical forward-slash `.al` paths under the normalized app root; +- no duplicate artifact paths, object IDs, or requested-artifact mappings; +- every artifact preserves the requested object type and name, and preserves the requested object ID when one was supplied; +- every object ID is inside a normalized app ID range; +- every artifact content value is non-empty, at most 262,144 UTF-8 bytes, and total content is at most 4,194,304 UTF-8 bytes; +- every article and good-sample reference carries the exact immutable 40-character knowledge commit SHA; and +- summary and coverage counts exactly match the arrays and opened worklist, including `opened-article-count == worklist-count` and `relevant-count == worklist-count + omitted-count`. + +Fail closed with no artifacts if any invariant cannot be proven. + +## Output + +Emit exactly one `generated-files-report` contract version 1 document conforming to [`schemas/generated-files-report-v1.schema.json`](../../../schemas/generated-files-report-v1.schema.json). A parseable example is [`schemas/examples/generated-files-report-v1.example.json`](../../../schemas/examples/generated-files-report-v1.example.json). + +The report includes the root output discriminator and version, skill ID/version, outcome/reason, summary and detailed coverage, immutable knowledge revision, assumptions, artifacts, applied guidance, omitted guidance, and suppression. It never includes findings or overwrite/delete/rename instructions. + +Consumers MUST validate the whole document against the published schema and semantic invariants before materializing any file. Validation and staging are atomic and fail closed. Consumers MUST also enforce their own filesystem symlink policy, destination non-existence, byte-size limits, and normalized ID-range policy independently of this model-produced report. diff --git a/schemas/examples/generated-files-report-v1.example.json b/schemas/examples/generated-files-report-v1.example.json new file mode 100644 index 0000000..5f39738 --- /dev/null +++ b/schemas/examples/generated-files-report-v1.example.json @@ -0,0 +1,67 @@ +{ + "output-kind": "generated-files-report", + "contract-version": 1, + "skill": { + "id": "al-code-generation", + "version": 1 + }, + "outcome": "completed", + "outcome-reason": "Generated the requested create-only AL object from the complete worklist.", + "summary": { + "artifact-count": 1, + "total-content-bytes": 217, + "coverage": { + "candidate-count": 3, + "relevant-count": 1, + "worklist-count": 1, + "opened-article-count": 1, + "opened-good-sample-count": 1, + "opened-bad-sample-count": 0, + "omitted-count": 0 + } + }, + "knowledge-revision": { + "repository": "https://github.com/microsoft/BCQuality", + "commit-sha": "1111111111111111111111111111111111111111" + }, + "assumptions": [ + "The requested table is a new object and the consumer will verify the destination does not exist." + ], + "artifacts": [ + { + "path": "src/SampleApp/Setup/CQSampleSetup.Table.al", + "encoding": "utf-8", + "object-type": "table", + "object-id": 70000, + "object-name": "CQ Sample Setup", + "content": "table 70000 \"CQ Sample Setup\"\n{\n Caption = 'Sample Setup';\n DataClassification = CustomerContent;\n\n fields\n {\n field(1; Enabled; Boolean)\n {\n Caption = 'Enabled';\n }\n }\n}", + "rationale": "Creates the requested bounded setup table with a reserved CQ affix and explicit classification.", + "article-references": [ + { + "path": "microsoft/knowledge/appsource/object-affixes-prevent-collisions.md", + "sha": "1111111111111111111111111111111111111111" + } + ], + "good-sample-references": [ + { + "path": "microsoft/knowledge/appsource/object-affixes-prevent-collisions.good.al", + "sha": "1111111111111111111111111111111111111111" + } + ] + } + ], + "applied-guidance": [ + { + "reference": { + "path": "microsoft/knowledge/appsource/object-affixes-prevent-collisions.md", + "sha": "1111111111111111111111111111111111111111" + }, + "artifact-paths": [ + "src/SampleApp/Setup/CQSampleSetup.Table.al" + ], + "application": "Applied the CQ object-name affix without copying demonstration object IDs or names." + } + ], + "omitted-guidance": [], + "suppressed": [] +} diff --git a/schemas/examples/requirement-spec-v1.example.json b/schemas/examples/requirement-spec-v1.example.json new file mode 100644 index 0000000..e3f39c4 --- /dev/null +++ b/schemas/examples/requirement-spec-v1.example.json @@ -0,0 +1,54 @@ +{ + "schema-version": 1, + "project-root": ".", + "app-root": "src/SampleApp", + "requested-artifacts": [ + { + "operation": "create", + "path": "src/SampleApp/Setup/CQSampleSetup.Table.al", + "object-type": "table", + "object-id": 70000, + "object-name": "CQ Sample Setup", + "intent": "Store one setup record with an enabled flag and an appropriate data classification." + } + ], + "related-file-allowlist": [ + "src/SampleApp/app.json", + "src/SampleApp/Setup/CQSampleSetup.Page.al" + ], + "context": { + "countries": [ + "w1" + ], + "application-areas": [ + "all" + ] + }, + "target": { + "effective-bc-major": 28, + "runtime": "15.0", + "app": { + "id": "11111111-1111-4111-8111-111111111111", + "name": "CQ Sample App", + "publisher": "Contoso", + "version": "1.0.0.0", + "platform": "28.0.0.0", + "application": "28.0.0.0", + "target": "Cloud", + "dependencies": [], + "id-ranges": [ + { + "from": 70000, + "to": 70049 + } + ] + } + }, + "generation-settings": { + "context-budget": 24 + }, + "app-json-provenance": { + "path": "src/SampleApp/app.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } +} diff --git a/schemas/generated-files-report-v1.schema.json b/schemas/generated-files-report-v1.schema.json new file mode 100644 index 0000000..52f69b3 --- /dev/null +++ b/schemas/generated-files-report-v1.schema.json @@ -0,0 +1,509 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/microsoft/BCQuality/blob/main/schemas/generated-files-report-v1.schema.json", + "title": "BCQuality generated files report v1", + "type": "object", + "additionalProperties": false, + "required": [ + "output-kind", + "contract-version", + "skill", + "outcome", + "outcome-reason", + "summary", + "knowledge-revision", + "assumptions", + "artifacts", + "applied-guidance", + "omitted-guidance", + "suppressed" + ], + "properties": { + "output-kind": { + "const": "generated-files-report" + }, + "contract-version": { + "const": 1 + }, + "skill": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "version" + ], + "properties": { + "id": { + "const": "al-code-generation" + }, + "version": { + "const": 1 + } + } + }, + "outcome": { + "enum": [ + "completed", + "not-applicable", + "no-knowledge", + "partial", + "failed" + ] + }, + "outcome-reason": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "summary": { + "$ref": "#/$defs/summary" + }, + "knowledge-revision": { + "type": "object", + "additionalProperties": false, + "required": [ + "repository", + "commit-sha" + ], + "properties": { + "repository": { + "type": "string", + "format": "uri", + "pattern": "^https://", + "description": "Immutable-revision repository containing every cited enabled layer, including a fork's custom layer." + }, + "commit-sha": { + "$ref": "#/$defs/commitSha" + } + } + }, + "assumptions": { + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + } + }, + "artifacts": { + "type": "array", + "maxItems": 64, + "items": { + "$ref": "#/$defs/artifact" + } + }, + "applied-guidance": { + "type": "array", + "maxItems": 64, + "items": { + "$ref": "#/$defs/appliedGuidance" + } + }, + "omitted-guidance": { + "type": "array", + "maxItems": 64, + "items": { + "$ref": "#/$defs/omittedGuidance" + } + }, + "suppressed": { + "type": "array", + "maxItems": 64, + "items": { + "$ref": "#/$defs/suppression" + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "outcome": { + "const": "completed" + } + }, + "required": [ + "outcome" + ] + }, + "then": { + "properties": { + "artifacts": { + "minItems": 1 + }, + "omitted-guidance": { + "maxItems": 0 + } + } + } + }, + { + "if": { + "properties": { + "outcome": { + "const": "partial" + } + }, + "required": [ + "outcome" + ] + }, + "then": { + "properties": { + "artifacts": { + "minItems": 1 + }, + "omitted-guidance": { + "minItems": 1 + } + } + } + }, + { + "if": { + "properties": { + "omitted-guidance": { + "minItems": 1 + } + }, + "required": [ + "omitted-guidance" + ] + }, + "then": { + "properties": { + "outcome": { + "const": "partial" + } + } + } + }, + { + "if": { + "properties": { + "outcome": { + "enum": [ + "not-applicable", + "no-knowledge", + "failed" + ] + } + }, + "required": [ + "outcome" + ] + }, + "then": { + "properties": { + "artifacts": { + "maxItems": 0 + } + } + } + } + ], + "$defs": { + "commitSha": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "canonicalAlPath": { + "type": "string", + "minLength": 4, + "maxLength": 512, + "pattern": "^(?!/)(?![A-Za-z]:)(?!.*\\\\)(?!.*(?:^|/)\\.(?:/|$))(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*(?:^|/)\\.git(?:/|$))[^/\\u0000]+(?:/[^/\\u0000]+)*\\.al$" + }, + "knowledgePath": { + "type": "string", + "pattern": "^(microsoft|community|custom)/knowledge/[a-z0-9-]+/[a-z0-9-]+\\.md$" + }, + "goodSamplePath": { + "type": "string", + "pattern": "^(microsoft|community|custom)/knowledge/[a-z0-9-]+/[a-z0-9-]+\\.good\\.al$" + }, + "objectType": { + "enum": [ + "codeunit", + "controladdin", + "enum", + "enumextension", + "interface", + "page", + "pagecustomization", + "pageextension", + "permissionset", + "permissionsetextension", + "profile", + "query", + "report", + "reportextension", + "table", + "tableextension", + "xmlport" + ] + }, + "revisionScopedArticleReference": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "sha" + ], + "properties": { + "path": { + "$ref": "#/$defs/knowledgePath" + }, + "sha": { + "$ref": "#/$defs/commitSha" + } + } + }, + "revisionScopedGoodSampleReference": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "sha" + ], + "properties": { + "path": { + "$ref": "#/$defs/goodSamplePath" + }, + "sha": { + "$ref": "#/$defs/commitSha" + } + } + }, + "coverage": { + "type": "object", + "additionalProperties": false, + "required": [ + "candidate-count", + "relevant-count", + "worklist-count", + "opened-article-count", + "opened-good-sample-count", + "opened-bad-sample-count", + "omitted-count" + ], + "properties": { + "candidate-count": { + "type": "integer", + "minimum": 0 + }, + "relevant-count": { + "type": "integer", + "minimum": 0 + }, + "worklist-count": { + "type": "integer", + "minimum": 0, + "maximum": 64 + }, + "opened-article-count": { + "type": "integer", + "minimum": 0, + "maximum": 64 + }, + "opened-good-sample-count": { + "type": "integer", + "minimum": 0, + "maximum": 64 + }, + "opened-bad-sample-count": { + "type": "integer", + "minimum": 0, + "maximum": 64 + }, + "omitted-count": { + "type": "integer", + "minimum": 0, + "maximum": 64 + } + } + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": [ + "artifact-count", + "total-content-bytes", + "coverage" + ], + "properties": { + "artifact-count": { + "type": "integer", + "minimum": 0, + "maximum": 64 + }, + "total-content-bytes": { + "type": "integer", + "minimum": 0, + "maximum": 4194304 + }, + "coverage": { + "$ref": "#/$defs/coverage" + } + } + }, + "artifact": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "encoding", + "object-type", + "object-id", + "object-name", + "content", + "rationale", + "article-references", + "good-sample-references" + ], + "properties": { + "path": { + "$ref": "#/$defs/canonicalAlPath" + }, + "encoding": { + "const": "utf-8" + }, + "object-type": { + "$ref": "#/$defs/objectType" + }, + "object-id": { + "type": "integer", + "minimum": 1, + "maximum": 999999999 + }, + "object-name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "content": { + "type": "string", + "minLength": 1, + "maxLength": 262144 + }, + "rationale": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "article-references": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/revisionScopedArticleReference" + } + }, + "good-sample-references": { + "type": "array", + "maxItems": 16, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/revisionScopedGoodSampleReference" + } + } + } + }, + "appliedGuidance": { + "type": "object", + "additionalProperties": false, + "required": [ + "reference", + "artifact-paths", + "application" + ], + "properties": { + "reference": { + "$ref": "#/$defs/revisionScopedArticleReference" + }, + "artifact-paths": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/canonicalAlPath" + } + }, + "application": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + } + } + }, + "omittedGuidance": { + "type": "object", + "additionalProperties": false, + "required": [ + "reference", + "reason", + "detail" + ], + "properties": { + "reference": { + "$ref": "#/$defs/revisionScopedArticleReference" + }, + "reason": { + "enum": [ + "context-budget", + "article-unavailable", + "article-invalid", + "sample-unavailable", + "allowlist-boundary" + ] + }, + "detail": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + } + } + }, + "suppression": { + "type": "object", + "additionalProperties": false, + "required": [ + "reference", + "reason" + ], + "properties": { + "reference": { + "$ref": "#/$defs/revisionScopedArticleReference" + }, + "reason": { + "const": "layer-precedence" + }, + "superseded-by": { + "$ref": "#/$defs/revisionScopedArticleReference" + } + }, + "allOf": [ + { + "if": { + "properties": { + "reason": { + "const": "layer-precedence" + } + }, + "required": [ + "reason" + ] + }, + "then": { + "required": [ + "superseded-by" + ] + } + } + ] + } + } +} diff --git a/schemas/requirement-spec-v1.schema.json b/schemas/requirement-spec-v1.schema.json new file mode 100644 index 0000000..33025f3 --- /dev/null +++ b/schemas/requirement-spec-v1.schema.json @@ -0,0 +1,348 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/microsoft/BCQuality/blob/main/schemas/requirement-spec-v1.schema.json", + "title": "BCQuality AL code generation requirement specification v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema-version", + "project-root", + "app-root", + "requested-artifacts", + "related-file-allowlist", + "context", + "target" + ], + "properties": { + "schema-version": { + "const": 1 + }, + "project-root": { + "const": ".", + "description": "Canonical root of the bounded project view." + }, + "app-root": { + "anyOf": [ + { + "const": "." + }, + { + "$ref": "#/$defs/canonicalRelativePath" + } + ] + }, + "requested-artifacts": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "items": { + "$ref": "#/$defs/requestedArtifact" + } + }, + "related-file-allowlist": { + "type": "array", + "maxItems": 1024, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/canonicalRelativePath" + }, + "description": "Complete set of existing project-relative files that generation may read." + }, + "context": { + "type": "object", + "additionalProperties": false, + "required": [ + "countries", + "application-areas" + ], + "properties": { + "countries": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^(w1|[a-z]{2})$" + }, + "allOf": [ + { + "if": { + "contains": { + "const": "w1" + } + }, + "then": { + "maxItems": 1 + } + } + ] + }, + "application-areas": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^(all|[a-z0-9]+(?:-[a-z0-9]+)*)$" + }, + "allOf": [ + { + "if": { + "contains": { + "const": "all" + } + }, + "then": { + "maxItems": 1 + } + } + ] + } + }, + "description": "Explicit applicability context used with normalized BC/runtime metadata." + }, + "target": { + "$ref": "#/$defs/target" + }, + "generation-settings": { + "type": "object", + "additionalProperties": false, + "properties": { + "context-budget": { + "type": "integer", + "minimum": 1, + "maximum": 64, + "default": 24 + } + } + }, + "app-json-provenance": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "sha256" + ], + "properties": { + "path": { + "$ref": "#/$defs/canonicalRelativePath" + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + }, + "description": "Optional provenance only. It never substitutes for normalized target metadata." + } + }, + "$defs": { + "canonicalRelativePath": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^(?!/)(?![A-Za-z]:)(?!.*\\\\)(?!.*(?:^|/)\\.(?:/|$))(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*(?:^|/)\\.git(?:/|$))[^/\\u0000]+(?:/[^/\\u0000]+)*$" + }, + "canonicalAlPath": { + "type": "string", + "minLength": 4, + "maxLength": 512, + "pattern": "^(?!/)(?![A-Za-z]:)(?!.*\\\\)(?!.*(?:^|/)\\.(?:/|$))(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*(?:^|/)\\.git(?:/|$))[^/\\u0000]+(?:/[^/\\u0000]+)*\\.al$" + }, + "requestedArtifact": { + "type": "object", + "additionalProperties": false, + "required": [ + "operation", + "path", + "object-type", + "object-name", + "intent" + ], + "properties": { + "operation": { + "const": "create" + }, + "path": { + "$ref": "#/$defs/canonicalAlPath" + }, + "object-type": { + "enum": [ + "codeunit", + "controladdin", + "enum", + "enumextension", + "interface", + "page", + "pagecustomization", + "pageextension", + "permissionset", + "permissionsetextension", + "profile", + "query", + "report", + "reportextension", + "table", + "tableextension", + "xmlport" + ] + }, + "object-id": { + "type": "integer", + "minimum": 1, + "maximum": 999999999 + }, + "object-name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "intent": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + } + } + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+\\.\\d+$" + }, + "runtime": { + "type": "string", + "pattern": "^\\d+\\.\\d+$" + }, + "dependency": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "publisher", + "version" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 250 + }, + "publisher": { + "type": "string", + "minLength": 1, + "maxLength": 250 + }, + "version": { + "$ref": "#/$defs/version" + } + } + }, + "idRange": { + "type": "object", + "additionalProperties": false, + "required": [ + "from", + "to" + ], + "properties": { + "from": { + "type": "integer", + "minimum": 1, + "maximum": 999999999 + }, + "to": { + "type": "integer", + "minimum": 1, + "maximum": 999999999 + } + } + }, + "target": { + "type": "object", + "additionalProperties": false, + "required": [ + "effective-bc-major", + "runtime", + "app" + ], + "properties": { + "effective-bc-major": { + "type": "integer", + "minimum": 1, + "maximum": 999 + }, + "runtime": { + "$ref": "#/$defs/runtime" + }, + "app": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "publisher", + "version", + "platform", + "application", + "target", + "dependencies", + "id-ranges" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 250 + }, + "publisher": { + "type": "string", + "minLength": 1, + "maxLength": 250 + }, + "version": { + "$ref": "#/$defs/version" + }, + "platform": { + "$ref": "#/$defs/version" + }, + "application": { + "$ref": "#/$defs/version" + }, + "target": { + "enum": [ + "Cloud", + "OnPrem" + ] + }, + "dependencies": { + "type": "array", + "maxItems": 128, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/dependency" + } + }, + "id-ranges": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/idRange" + } + } + } + } + } + } + } +} diff --git a/skills/README.md b/skills/README.md index d0500a3..ce40066 100644 --- a/skills/README.md +++ b/skills/README.md @@ -9,16 +9,18 @@ This folder contains the skills that are not owned by any single layer. There ar | File | Role | |---|---| -| [`entry.md`](entry.md) | **ENTRY** — Given a task context, returns a dispatch record naming the action skill(s) to invoke. The agent's first call when pointed at BCQuality. | +| [`entry.md`](entry.md) | **ENTRY** — Given a task context, returns a dispatch record naming the action skill(s), input subset, and exact output kind/version. The agent's first call when pointed at BCQuality. | Routing logic lives in Entry, not in the orchestrator. An agent that knows only "invoke `/skills/entry.md` first" has enough to drive the rest of the repo. +Entry preserves legacy review contexts. Create-only generation is separately capability-gated by explicit `action: generate`, `requirement-spec`, and acceptance of `generated-files-report` version 1; mixed review/generation inputs require explicit action. + ## The meta-skill contracts | # | File | Role | Who reads it | |---|---|---|---| | 1 | [`read.md`](read.md) | **READ** — Schema + Use. How to read a knowledge file: frontmatter fields, section semantics, matching rules, layer precedence, conflict resolution. | Any agent or action skill that consumes knowledge files. | -| 2 | [`do.md`](do.md) | **DO** — Action Skill contract. The Source → Relevance → Worklist → Action template and the structured output every action skill produces. Includes super-skill composition. | Any agent invoking an action skill; every action-skill author. | +| 2 | [`do.md`](do.md) | **DO** — Action Skill contract. The Source → Relevance → Worklist → Action template and negotiated `findings-report` or `generated-files-report` output. Includes review super-skill composition. | Any agent invoking an action skill; every action-skill author. | | 3 | [`write.md`](write.md) | **WRITE** — New Knowledge. Authoring rules for knowledge files. Defers to `read.md` for the schema. | Contributors (human or agent) adding or editing knowledge files. Not used during consumption. | READ and DO are read on demand — typically by the first action skill the agent executes after dispatch. They are not prerequisites for invoking Entry. WRITE is only used when scaffolding new content. diff --git a/skills/bcquality-al-generate/SKILL.md b/skills/bcquality-al-generate/SKILL.md new file mode 100644 index 0000000..2afe7a5 --- /dev/null +++ b/skills/bcquality-al-generate/SKILL.md @@ -0,0 +1,44 @@ +--- +name: bcquality-al-generate +description: Generate new Business Central AL object files from a bounded requirement-spec JSON file using BCQuality guidance. Use only for explicit create-only AL generation, never for review. +--- + +# BCQuality AL generation + +This bridge drives the BCQuality Entry protocol for explicit, create-only AL generation. It is intentionally separate from `bcquality-al-review`; review behavior and legacy review task contexts remain unchanged. + +## When to use + +Use only when the caller supplies a path to a bounded UTF-8 requirement specification that conforms to `schemas/requirement-spec-v1.schema.json` and explicitly requests AL generation. + +Do not use this bridge for PR review, working-tree review, or single-file review. Do not convert inline prompt text into a requirement specification. + +## Plugin root + +Resolve `PLUGIN_ROOT` to the directory containing `.claude-plugin/plugin.json`. This file lives at `PLUGIN_ROOT/skills/bcquality-al-generate/SKILL.md`. + +## Steps + +1. Treat the caller's `requirement-spec` value only as a filesystem path. Do not interpolate the referenced JSON into Entry's `goal`. +2. Read `PLUGIN_ROOT/skills/entry.md` and invoke it with explicit capability negotiation: + + ```yaml + task-context: + goal: "Generate the bounded AL requirement" + action: generate + inputs-available: [requirement-spec] + accepted-outputs: + - kind: generated-files-report + version: 1 + technologies: [al] + enabled-layers: [microsoft, community, custom] + ``` + +3. Pass only the requirement-spec path to the dispatched `al-code-generation` skill. The generation skill validates and reads the bounded JSON file. +4. Return exactly one `generated-files-report` v1 JSON document. Do not write generated files to the workspace. + +If Entry returns `no-match` or `failed`, return the dispatch record unchanged. + +## Consumer boundary + +BCQuality owns the contracts, routing, knowledge retrieval, ranking, and generation report. The consumer owns acquiring the requirement file and, after validating the complete report atomically, staging files, compiling, analyzing, testing, delivering, approving, and publishing them. The consumer must fail closed and independently enforce destination, symlink, size, and ID-range policy before materialization. diff --git a/skills/do.md b/skills/do.md index 777f89f..836a15f 100644 --- a/skills/do.md +++ b/skills/do.md @@ -32,6 +32,7 @@ title: AL code review description: Reviews AL source changes against performance and security guidance. inputs: [pr-diff, object-list] outputs: [findings-report] +output-version: 1 bc-version: [26..28] technologies: [al] countries: [w1] @@ -39,11 +40,20 @@ application-area: [all] --- ``` -`kind`, `id`, `version`, `title`, `description`, `inputs`, `outputs` are required and specific to action skills. +`kind`, `id`, `version`, `title`, `description`, `inputs`, `outputs` are required and specific to action skills. `output-version` is optional for backward compatibility and defaults to 1; new output kinds MUST declare it explicitly. `bc-version`, `technologies`, `countries`, `application-area` are optional filters that let an orchestrator pre-select applicable skills for a task. They follow the same semantics as in READ. -`inputs` is a list of abstract input types the skill **accepts**. Standard values: `pr-diff`, `object-list`, `file-path`, `repository`, `telemetry-query`. Semantics are any-of: the orchestrator supplies whichever listed input types it has, and the skill is invoked with a non-empty subset of its declared `inputs`. A skill that cannot proceed with the supplied subset MUST return `outcome: "not-applicable"`. `outputs` is always a single-element list naming the output kind; today only `findings-report` is defined. +`inputs` is a list of abstract input types the skill **accepts**. Standard values: `pr-diff`, `object-list`, `file-path`, `repository`, `telemetry-query`, `requirement-spec`. Semantics are any-of: the orchestrator supplies whichever listed input types it has, and the skill is invoked with a non-empty subset of its declared `inputs`. A skill that cannot proceed with the supplied subset MUST return `outcome: "not-applicable"`. + +`requirement-spec` is a path-valued input: its value is only a path to a bounded UTF-8 JSON file conforming to the published requirement contract. It is never inline requirement JSON and is never interpolated into prompt or goal text. + +`outputs` MUST be a single-element list naming the output kind. Defined kinds are: + +- `findings-report` version 1 for review and audit skills; and +- `generated-files-report` version 1 for create-only generation skills, defined by [`schemas/generated-files-report-v1.schema.json`](../schemas/generated-files-report-v1.schema.json). + +Entry negotiates the exact kind/version and includes it in every dispatch record. An action skill emits one JSON document of only that negotiated kind. `sub-skills` is an optional field. When present and non-empty, the skill is a **super-skill** that composes other action skills; see *Composition* below. Values are repo-relative paths to action-skill files. @@ -67,9 +77,9 @@ Every action skill MUST contain these five sections, in order: **Action.** Execute the skill's work against the worklist. Evaluate each item in the worklist against the task input and emit findings. The action step is where skill behavior differs; the preceding three steps are uniform. -## Output contract +## Findings-report output contract -Every action skill emits a single JSON document that conforms to this schema: +Every action skill with `outputs: [findings-report]` emits a single JSON document that conforms to this schema: ```json { @@ -219,6 +229,8 @@ A **super-skill** is an action skill whose frontmatter declares a non-empty `sub Composition is flat: a super-skill MAY list only leaf skills (skills without their own `sub-skills`). Nested super-skills are not permitted in v1. +Generation skills are leaves. They MUST NOT declare `sub-skills` or compose other generation skills. + ### Section interpretation for super-skills The five required sections still apply. Their meaning shifts from knowledge files to sub-skills: @@ -285,8 +297,13 @@ For each worklist entry, emit one finding with severity `info`, a message naming Conforms to the DO output contract. ``` +## Generated-files-report output contract + +The `generated-files-report` v1 contract is published as JSON Schema at [`schemas/generated-files-report-v1.schema.json`](../schemas/generated-files-report-v1.schema.json). Its path-valued input contract is [`schemas/requirement-spec-v1.schema.json`](../schemas/requirement-spec-v1.schema.json). The stable generation action skill is [`microsoft/skills/generate/al-code-generation.md`](../microsoft/skills/generate/al-code-generation.md). + +Unlike a findings report, a generated-files report has no `findings` field. It carries create-only UTF-8 AL artifacts, detailed retrieval coverage, immutable revision-scoped guidance references, omissions, and suppression. Consumers MUST validate the complete JSON document atomically and fail closed before materialization, then independently enforce filesystem symlink, destination non-existence, byte-size, and normalized ID-range policy. + ## How orchestrators consume output -An orchestrator invokes an action skill with an input appropriate to the skill's declared `inputs`, receives the JSON output, and maps findings to its delivery surface (PR comments, build gates, IDE diagnostics). The orchestrator MUST NOT interpret skill-specific fields beyond the schema above. Skills that need richer semantics MUST encode them within the schema (for example, by adding structured `message` text) rather than extending the output shape. - +An orchestrator invokes an action skill with an input appropriate to the skill's declared `inputs`, receives the negotiated JSON output kind/version, and validates that published contract without inferring shape from the skill ID. Findings reports map to review delivery surfaces. Generated-files reports are create-only proposals and require consumer-owned validation and staging before any workspace change. Skills that need richer semantics MUST evolve a versioned shared output contract rather than add unversioned skill-specific fields. diff --git a/skills/entry.md b/skills/entry.md index f094aa6..2b19b50 100644 --- a/skills/entry.md +++ b/skills/entry.md @@ -20,9 +20,13 @@ The agent invokes Entry with a **task context** supplied by the orchestrator: ```yaml task-context: goal: string # free-text description of what needs doing + action: review # optional explicit intent: review | generate inputs-available: # values the orchestrator has ready to pass to a chosen skill - pr-diff - file-path + accepted-outputs: # exact output capability negotiation + - kind: findings-report + version: 1 technologies: [al] bc-version: 28 countries: [w1] @@ -31,11 +35,21 @@ task-context: disabled-skills: [] # repo-relative paths the consumer has opted out of ``` -`goal` and `inputs-available` are required. Filter dimensions (`technologies`, `bc-version`, `countries`, `application-area`) are optional; omitting a dimension is equivalent to "unconstrained" — see Relevance for the exact matching rule. `enabled-layers` defaults to all three. `disabled-skills` defaults to empty. +`goal` and `inputs-available` are required. `action` is optional for backward compatibility and, when present, is exactly `review` or `generate`. `accepted-outputs` is optional for legacy review contexts; when supplied, every entry is an exact `{kind, version}` pair. Filter dimensions (`technologies`, `bc-version`, `countries`, `application-area`) are optional; omitting a dimension is equivalent to "unconstrained" — see Relevance for the exact matching rule. `enabled-layers` defaults to all three. `disabled-skills` defaults to empty. + +Generation is capability-gated. It requires all three of: + +1. `action: generate`; +2. `inputs-available` containing `requirement-spec`; and +3. `accepted-outputs` containing exactly `{kind: generated-files-report, version: 1}`. + +The `requirement-spec` value passed after dispatch is only a path to a bounded UTF-8 JSON file; its contents are not part of `goal` or Entry routing. + +Legacy review contexts remain valid without `action` or `accepted-outputs`. For those contexts Entry treats the intended output as `findings-report` version 1. A current consumer that advertises only existing review inputs, and does not advertise `action`, `requirement-spec`, and `generated-files-report` v1 (including BCAppsBCQuality), is generation-ineligible. ## Preparation — knowledge index -Before routing, ensure the knowledge index is current for the **live** clone. The dispatched review skills read `knowledge-index.json` (at the clone root) at their Source step instead of opening every knowledge file — see READ's [Retrieval workflow](read.md). Because a consumer prunes its clone to policy *before* the agent runs, the index MUST be built over the clone as it exists now, so it lists exactly the articles that survived pruning and never an article the consumer denied: +Before routing, ensure the knowledge index is current for the **live** clone. Dispatched review and generation skills read `knowledge-index.json` (at the clone root) at their Source step instead of opening every knowledge file — see READ's [Retrieval workflow](read.md). Because a consumer prunes its clone to policy *before* the agent runs, the index MUST be built over the clone as it exists now, so it lists exactly the articles that survived pruning and never an article the consumer denied: - If `knowledge-index.json` is absent — or you cannot confirm it reflects the current knowledge tree — regenerate it by running, from the checkout root: @@ -57,17 +71,26 @@ All action skills under `*/skills/**/*.md` across the layers named in `enabled-l A candidate is relevant when every condition below holds: 1. Its frontmatter `kind` is `action-skill`. -2. `task-context.inputs-available` intersects its declared `inputs` — the orchestrator has at least one of the input types the skill accepts. A skill is NOT required to accept every input the orchestrator can supply; it is the skill's responsibility to return `outcome: "not-applicable"` if the supplied subset is insufficient. -3. Its frontmatter filter dimensions (`bc-version`, `technologies`, `countries`, `application-area`) match the task context per READ's matching semantics. A dimension omitted from `task-context` is treated as a wildcard and matches any value the skill declares; a dimension explicitly supplied in `task-context` must match the skill's declared values per READ. Conditionally-applicable candidates (any dimension `unknown` per READ) are admitted; they are not filtered out at Entry and are the dispatched skill's concern. -4. Its repo-relative path is not in `task-context.disabled-skills`. +2. It belongs to the selected action family. `generated-files-report` is the `generate` family; `findings-report` is the `review` family. +3. `task-context.inputs-available` intersects its declared `inputs` — the orchestrator has at least one of the input types the skill accepts. A skill is NOT required to accept every input the orchestrator can supply; it is the skill's responsibility to return `outcome: "not-applicable"` if the supplied subset is insufficient. +4. The candidate's single declared output kind and version are accepted exactly. `output-version` defaults to 1 for existing findings-report skills that omit it. +5. Its frontmatter filter dimensions (`bc-version`, `technologies`, `countries`, `application-area`) match the task context per READ's matching semantics. A dimension omitted from `task-context` is treated as a wildcard and matches any value the skill declares; a dimension explicitly supplied in `task-context` must match the skill's declared values per READ. Conditionally-applicable candidates (any dimension `unknown` per READ) are admitted; they are not filtered out at Entry and are the dispatched skill's concern. +6. Its repo-relative path is not in `task-context.disabled-skills`. -Candidates that fail any condition go to `skipped` with the corresponding reason (`inputs-unsatisfied`, `filter-mismatch`, `configuration`). Skills excluded because they are not `kind: action-skill` are not reported in `skipped`. +Determine the action family before fuzzy goal matching: + +- If `action` is explicit, consider only that family even when inputs from both families are present. +- If both `requirement-spec` and any review input (`pr-diff`, `object-list`, `file-path`, `repository`, or `telemetry-query`) are present without `action`, fail with `outcome-reason: "ambiguous-action"`. Do not use `goal` to break the tie. +- If only review inputs are present and `action` is absent, use the legacy review family. +- If `requirement-spec` is present without `action: generate`, fail with `outcome-reason: "explicit-generate-action-required"`. + +Candidates that fail a condition go to `skipped` with the corresponding reason (`action-mismatch`, `inputs-unsatisfied`, `output-negotiation`, `filter-mismatch`, `configuration`). Skills excluded because they are not `kind: action-skill` are not reported in `skipped`. ## Worklist Narrow the relevant set to the skills that will actually be dispatched: -1. **Goal match.** Score each candidate's `description` and `id` against `task-context.goal`. Drop candidates that do not plausibly address the goal; record them in `skipped` with `reason: "goal-mismatch"`. Scoring is implementation-defined; agents MUST prefer exact keyword overlap before fuzzy signals. +1. **Goal match.** Within the already-selected action family, score each candidate's `description` and `id` against `task-context.goal`. Drop candidates that do not plausibly address the goal; record them in `skipped` with `reason: "goal-mismatch"`. Scoring is implementation-defined; agents MUST prefer exact keyword overlap before fuzzy signals. Never use fuzzy goal text to choose between review and generation. 2. **Super-skill precedence.** When a super-skill and any skill listed in its `sub-skills` are both in the remaining set, the super-skill supersedes the sub-skill **only when the goal is a broader match for the super-skill than for the sub-skill**. When the goal specifically names a concern the sub-skill handles (for example, goal = *"performance review"* with `al-code-review` and `al-performance-review` both present), the sub-skill wins and the super-skill is dropped with `reason: "narrower-sub-skill-selected"`. Otherwise the super-skill wins and each listed sub-skill in the set is dropped with `reason: "superseded-by-super-skill"`. The principle is: Entry dispatches the narrowest skill that satisfies the goal. A dropped sub-skill's `skipped` entry MUST carry `superseded-by` naming the super-skill that won; a dropped super-skill's entry MUST carry `superseded-by` naming the winning sub-skill. 3. **Layer precedence.** When two remaining candidates share the same `id` across layers, keep the highest-precedence one. Skill layer precedence is `/custom/` over `/community/` over `/microsoft/` — the same ordering READ defines for knowledge files. Drop the losers with `reason: "layer-precedence"` and `superseded-by` naming the winning path. @@ -94,13 +117,14 @@ Emit a single JSON document conforming to the output contract below. Entry does "path": "microsoft/skills/review/al-code-review.md" }, "rationale": "string", - "inputs": ["pr-diff"] + "inputs": ["pr-diff"], + "output": { "kind": "findings-report", "version": 1 } } ], "skipped": [ { "skill": { "id": "string", "path": "string" }, - "reason": "inputs-unsatisfied | filter-mismatch | goal-mismatch | layer-precedence | superseded-by-super-skill | narrower-sub-skill-selected | configuration", + "reason": "action-mismatch | inputs-unsatisfied | output-negotiation | filter-mismatch | goal-mismatch | layer-precedence | superseded-by-super-skill | narrower-sub-skill-selected | configuration", "superseded-by": { "id": "string", "path": "string", "version": 1 } } ] @@ -121,12 +145,15 @@ Emit a single JSON document conforming to the output contract below. Entry does - `skill.version` — copied from the dispatched skill's frontmatter so the orchestrator can detect drift between dispatch time and execution. - `rationale` — short human-readable string, for logs and traceability. - `inputs` — the intersection of `task-context.inputs-available` and the skill's declared `inputs`. The agent MUST pass exactly this subset when invoking the skill. Sending a strict intersection avoids accidental information leakage between skills. +- `output` — the candidate's single declared output kind and contract version. This field is additive for existing consumers and is present in every dispatch entry. The version is `output-version` from frontmatter, defaulting to 1 for existing findings-report skills. Ordering of `dispatch[]` is not significant. **`skipped[]`** — MUST list every candidate that was considered and dropped. Each dropped candidate appears at most once; the first drop reason wins. Reasons: - `inputs-unsatisfied` — `task-context.inputs-available` did not intersect the skill's declared `inputs`. +- `action-mismatch` — the skill belongs to the action family not selected by explicit intent or legacy review inference. +- `output-negotiation` — the skill's exact output kind/version was not accepted by the consumer. - `filter-mismatch` — one or more frontmatter filter dimensions explicitly did not match. - `goal-mismatch` — Relevance admitted the candidate but it failed the goal-match step. - `layer-precedence` — a higher-precedence skill with the same `id` won. `superseded-by` is required. @@ -158,7 +185,8 @@ Populated example (PR review on a repo where only `al-performance-review` is ena { "skill": { "id": "al-performance-review", "version": 1, "path": "microsoft/skills/review/al-performance-review.md" }, "rationale": "Goal 'review pull request' matched; inputs-available contains pr-diff.", - "inputs": ["pr-diff"] + "inputs": ["pr-diff"], + "output": { "kind": "findings-report", "version": 1 } } ], "skipped": [ @@ -168,11 +196,47 @@ Populated example (PR review on a repo where only `al-performance-review` is ena } ``` +Deterministic generation-only example: + +```yaml +task-context: + goal: "Generate the bounded AL requirement" + action: generate + inputs-available: [requirement-spec] + accepted-outputs: + - kind: generated-files-report + version: 1 + technologies: [al] +``` + +This dispatches only `microsoft/skills/generate/al-code-generation.md`, with `inputs: [requirement-spec]` and `output: {kind: generated-files-report, version: 1}`. + +Deterministic review-only example: + +```yaml +task-context: + goal: "Review the AL changes" + inputs-available: [pr-diff] + technologies: [al] +``` + +This remains backward compatible and routes to the applicable review skill with `output: {kind: findings-report, version: 1}`. + +When both input families are available, `action: generate` routes only generation and `action: review` routes only review, subject to exact accepted-output negotiation. The same context without `action` fails: + +```yaml +task-context: + goal: "Handle these AL inputs" + inputs-available: [pr-diff, requirement-spec] +``` + +The result is `outcome: failed`, `outcome-reason: "ambiguous-action"`, and an empty `dispatch`. Goal text never resolves this ambiguity. + ## How the agent uses the dispatch 1. Invoke Entry with the orchestrator-supplied task context. 2. Receive the dispatch record. -3. For each entry in `dispatch[]`, read the referenced action skill, execute its Source → Relevance → Worklist → Action steps per DO, and produce a findings-report. -4. Return the findings-reports to the orchestrator. When `outcome` is `no-match` or `failed`, return the dispatch record itself so the orchestrator can log the reason. +3. For each entry in `dispatch[]`, read the referenced action skill, execute its Source → Relevance → Worklist → Action steps per DO, and produce exactly the negotiated `output` kind/version. +4. Return the action-skill reports to the orchestrator. When `outcome` is `no-match` or `failed`, return the dispatch record itself so the orchestrator can log the reason. READ and DO are the contracts that govern what the dispatched skills do. An agent that has not yet read READ and DO reads them when it executes the first dispatched skill — they are not prerequisites for invoking Entry. diff --git a/skills/read.md b/skills/read.md index 6a2080d..59b7fe4 100644 --- a/skills/read.md +++ b/skills/read.md @@ -141,9 +141,9 @@ Consumers that surface sample code to an end user or agent SHOULD cite the sampl The standard workflow for finding applicable files: -1. Collect candidates from the knowledge index (`knowledge-index.json`). BCQuality maintains it: Entry's preparation step (see [entry.md](entry.md)) regenerates it over the live, already-filtered clone, so it lists exactly the articles that survived the consumer's layer/allow-deny pruning, each with the frontmatter, `keywords`, `title`, and one-line `description` that steps 2-3 need — candidates are enumerated without opening each file. The index is **discovery metadata only**: it tells you *which* files to open, it does not substitute for them. A finding MUST cite only an article that was opened and read in full; an index row whose file is absent from the clone MUST be discarded *before* ranking or worklisting, and its metadata MUST NOT seed a finding. Absent an index, collect candidates by path (typically by `domain` subfolder, across enabled layers). +1. Collect candidates from the knowledge index (`knowledge-index.json`). BCQuality maintains it: Entry's preparation step (see [entry.md](entry.md)) regenerates it over the live, already-filtered clone, so it lists exactly the articles that survived the consumer's layer/allow-deny pruning, each with the frontmatter, `keywords`, `title`, and one-line `description` that steps 2-3 need — candidates are enumerated without opening each file. The index is **discovery metadata only**: it tells you *which* files to open, it does not substitute for them. A finding or generated artifact MUST cite only an article that was opened and read in full; an index row whose file is absent from the clone MUST be discarded *before* ranking or worklisting, and its metadata MUST NOT seed output. Absent an index, review skills may collect candidates by path (typically by `domain` subfolder, across enabled layers). Generation is stricter: `al-code-generation` is index-only and fails rather than walking the repository. 2. Filter by frontmatter using the matching rules above. Files that are not applicable are discarded. -3. Rank or narrow by `keywords` relevance to the task. +3. Rank or narrow by `keywords` relevance to the task. Cross-domain generation additionally uses exact title, object, and domain cues before lower-confidence description signals, as specified by its action skill. 4. Resolve conflicts via layer precedence. Steps 1–3 are deterministic; step 4 is applied only when conflicts are detected.