diff --git a/.claude/agents/flaky-test-qa.md b/.claude/agents/flaky-test-qa.md index 12155f56..9d8e9ef8 100644 --- a/.claude/agents/flaky-test-qa.md +++ b/.claude/agents/flaky-test-qa.md @@ -28,6 +28,10 @@ with free-form input. "review_targets": [ "optional paths" ], + "round_limit": false, + "changed_files": ["optional/path.rs"], + "carry_forward_findings": ["optional finding id"], + "triage_records": ["optional/.triage/path.ttl"], "notes": "optional context" } ``` diff --git a/.claude/agents/quality-mgr.md b/.claude/agents/quality-mgr.md index 9c2e6f12..37927fec 100644 --- a/.claude/agents/quality-mgr.md +++ b/.claude/agents/quality-mgr.md @@ -157,7 +157,8 @@ TODO-specific rule: 6. Render structured JSON assignments: - `req-qa` from `.claude/skills/codex-orchestration/req-qa-assignment.json.j2` - `arch-qa` from `.claude/skills/codex-orchestration/arch-qa-assignment.json.j2` - - `rust-best-practices-agent` from `.claude/skills/codex-orchestration/rust-best-practices-agent-assignment.json.j2` + - `ruthless-boundary-qa` from `.claude/skills/codex-orchestration/ruthless-boundary-qa-assignment.json.j2` + - `rust-best-practices-agent` from `.claude/assets/sc-rust/quality-mgr/templates/rust-best-practices-assignment.json.j2` on every sprint QA round for the near term, plus docs-only plan review and phase-ending review - `flaky-test-qa` from `.claude/skills/codex-orchestration/flaky-test-qa-assignment.json.j2` only when tests changed or instability is suspected @@ -211,6 +212,7 @@ creates and wires the resulting fix beads. For implementation QA-1 in this Rust repo: - always run `req-qa` - always run `arch-qa` +- always run `ruthless-boundary-qa` - always run `rust-best-practices-agent` - always run `rust-qa-agent` - always run `rust-best-practices-agent` diff --git a/.claude/agents/rust-best-practices-agent.md b/.claude/agents/rust-best-practices-agent.md index efce2920..3b1553c3 100644 --- a/.claude/agents/rust-best-practices-agent.md +++ b/.claude/agents/rust-best-practices-agent.md @@ -45,6 +45,9 @@ with free-form input. ], "practice_mode": "all | selected", "practice_ids": ["RBP-001", "RBP-004"], + "round_limit": false, + "changed_files": ["optional files changed in the assigned round"], + "triage_records": ["optional triage-record paths relevant to this review"], "carry_forward_findings": ["optional/pre-existing finding ids assigned for verification this round"], "findings_scope_locked": false, "notes": "optional context" @@ -57,6 +60,8 @@ Rules: - `practice_mode` is required. - `review_targets` is optional. Omit to review default changed-file scope plus directly impacted boundaries. - `practice_ids` must be non-empty when `practice_mode` is `selected`. +- `round_limit`, `changed_files`, and `triage_records` are optional lifecycle + context supplied by the orchestration caller. - Unknown practice ids are input errors. Do not guess. - When `practice_mode` is `all`, review the full canonical inventory from `practice-inventory.md`. diff --git a/.claude/agents/rust-qa-agent.md b/.claude/agents/rust-qa-agent.md index dc211212..1dd96a28 100644 --- a/.claude/agents/rust-qa-agent.md +++ b/.claude/agents/rust-qa-agent.md @@ -37,6 +37,10 @@ with free-form input. "baseline_ref": "optional git ref for artifact or regression comparison", "artifact_regeneration_required": false, "artifact_commands": "", + "round_limit": false, + "changed_files": ["optional files changed in the assigned round"], + "triage_records": ["optional triage-record paths relevant to this review"], + "carry_forward_findings": ["optional/pre-existing finding ids assigned for verification this round"], "notes": "optional context" } ``` @@ -47,6 +51,8 @@ Rules: - `review_targets` is optional. Omit to review the default changed-file scope plus impacted files when needed. - `run_checks` is optional. If omitted, default to `fmt=true`, `clippy=true`, `tests=true`, `coverage=false`. - `artifact_commands` is optional. If `artifact_regeneration_required` is true and commands are supplied, run them and treat failure as a finding. Phase-end assignments use this existing execution channel for `just lint && just test`; report its result under `executed_checks.artifacts`. +- `round_limit`, `changed_files`, `triage_records`, and `carry_forward_findings` + are optional lifecycle context supplied by the orchestration caller. - This agent does not own `rust-best-practices` or `rust-service-hardening` policy. Do not infer those reviews from this input. ## Review Process diff --git a/.claude/agents/rust-service-hardening-agent.md b/.claude/agents/rust-service-hardening-agent.md index 4004a6f7..edef5102 100644 --- a/.claude/agents/rust-service-hardening-agent.md +++ b/.claude/agents/rust-service-hardening-agent.md @@ -44,6 +44,11 @@ with free-form input. "actix-web", "reqwest" ], + "service_indicators_extra": ["optional additional service indicators"], + "round_limit": false, + "changed_files": ["optional files changed in the assigned round"], + "triage_records": ["optional triage-record paths relevant to this review"], + "carry_forward_findings": ["optional/pre-existing finding ids assigned for verification this round"], "notes": "optional context" } ``` @@ -54,6 +59,9 @@ Rules: - `topics` is optional. Omit to use the default topic set for the selected review mode. - `service_indicator_dependencies` is optional. Omit to use the default service-indicator dependency list shown above. - `review_targets` is optional. Omit to review default changed-file scope plus directly impacted runtime boundaries. +- `service_indicators_extra`, `round_limit`, `changed_files`, `triage_records`, + and `carry_forward_findings` are optional lifecycle context supplied by the + orchestration caller. ## Review Process diff --git a/.claude/agents/ruthless-boundary-qa.md b/.claude/agents/ruthless-boundary-qa.md index 85d505ad..79fd1e1e 100644 --- a/.claude/agents/ruthless-boundary-qa.md +++ b/.claude/agents/ruthless-boundary-qa.md @@ -30,7 +30,9 @@ Input must be JSON, either raw JSON or fenced JSON. "worktree_path": "/absolute/path/to/worktree", "review_targets": ["optional/path.rs"], "reference_docs": ["optional/docs/path.md"], + "round_limit": false, "changed_files": ["optional/path.rs"], + "duplicate_sweep_symbols": ["optional symbol"], "triage_records": ["optional/.triage/path.ttl"], "carry_forward_findings": ["optional/pre-existing finding ids assigned for verification this round"], "findings_scope_locked": false, diff --git a/.claude/assets/sc-rust/quality-mgr/templates/rust-service-hardening-assignment.json.j2 b/.claude/assets/sc-rust/quality-mgr/templates/rust-service-hardening-assignment.json.j2 index 6565d1d8..89d638f0 100644 --- a/.claude/assets/sc-rust/quality-mgr/templates/rust-service-hardening-assignment.json.j2 +++ b/.claude/assets/sc-rust/quality-mgr/templates/rust-service-hardening-assignment.json.j2 @@ -25,11 +25,11 @@ defaults: notes: "" --- { - "review_mode": "{{ review_mode }}", - "worktree_path": "{{ worktree_path }}", - "review_targets": [{% for target in review_targets %}"{{ target }}"{% if not loop.last %}, {% endif %}{% endfor %}], + "review_mode": {{ review_mode }}, + "worktree_path": {{ worktree_path }}, + "review_targets": {{ review_targets }}, "topics": {% if topics %} - [{% for topic in topics %}"{{ topic }}"{% if not loop.last %}, {% endif %}{% endfor %}] + [{% for topic in topics %}{{ topic }}{% if not loop.last %}, {% endif %}{% endfor %}] {% elif review_mode == "doc_review" %} ["config_validation", "timeouts", "graceful_shutdown", "retry_scope", "backpressure", "health_readiness"] {% elif review_mode == "sprint_review" %} @@ -38,10 +38,10 @@ defaults: ["config_validation", "timeouts", "graceful_shutdown", "structured_logging", "request_ids", "retries", "backpressure", "spawn_blocking", "input_limits", "dependency_hygiene", "health_readiness", "ci_release_gates"] {% endif %}, "service_indicator_dependencies": ["tokio", "axum", "hyper", "tonic", "warp", "actix-web", "reqwest"], - "service_indicators_extra": [{% for indicator in service_indicators_extra %}"{{ indicator }}"{% if not loop.last %}, {% endif %}{% endfor %}], + "service_indicators_extra": {{ service_indicators_extra }}, "round_limit": {% if round_limit %}true{% else %}false{% endif %}, - "changed_files": [{% for file in changed_files %}"{{ file }}"{% if not loop.last %}, {% endif %}{% endfor %}], - "triage_records": [{% for record in triage_records %}"{{ record }}"{% if not loop.last %}, {% endif %}{% endfor %}], + "changed_files": {{ changed_files }}, + "triage_records": {{ triage_records }}, "carry_forward_findings": {{ carry_forward_findings_json }}, - "notes": "{{ notes }}" + "notes": {{ notes }} } diff --git a/.claude/assets/sc-rust/quality-mgr/templates/vars/rust-best-practices-assignment.json b/.claude/assets/sc-rust/quality-mgr/templates/vars/rust-best-practices-assignment.json new file mode 100644 index 00000000..9825cad0 --- /dev/null +++ b/.claude/assets/sc-rust/quality-mgr/templates/vars/rust-best-practices-assignment.json @@ -0,0 +1 @@ +{"review_mode":"sprint_review","worktree_path":"/tmp/worktree","review_targets":["crates/sc-lint-boundary/src/lib.rs"],"practice_mode":"selected","practice_ids":["RBP-001"],"round_limit":false,"changed_files":[],"carry_forward_findings_json":"[]","triage_records":[],"notes":"sample"} diff --git a/.claude/assets/sc-rust/quality-mgr/templates/vars/rust-qa-assignment.json b/.claude/assets/sc-rust/quality-mgr/templates/vars/rust-qa-assignment.json new file mode 100644 index 00000000..147546ba --- /dev/null +++ b/.claude/assets/sc-rust/quality-mgr/templates/vars/rust-qa-assignment.json @@ -0,0 +1 @@ +{"review_mode":"sprint_review","worktree_path":"/tmp/worktree","review_targets":["crates/sc-lint-boundary/src/lib.rs"],"fmt":true,"clippy":true,"tests":true,"coverage":false,"baseline_ref":"develop","artifact_regeneration_required":false,"artifact_commands":"","round_limit":false,"changed_files":[],"carry_forward_findings_json":"[]","triage_records":[],"notes":"sample"} diff --git a/.claude/assets/sc-rust/quality-mgr/templates/vars/rust-service-hardening-assignment.json b/.claude/assets/sc-rust/quality-mgr/templates/vars/rust-service-hardening-assignment.json new file mode 100644 index 00000000..cfb1eae4 --- /dev/null +++ b/.claude/assets/sc-rust/quality-mgr/templates/vars/rust-service-hardening-assignment.json @@ -0,0 +1 @@ +{"review_mode":"sprint_review","worktree_path":"/tmp/worktree","review_targets":["crates/sc-lint-boundary/src/lib.rs"],"topics":["timeouts"],"round_limit":false,"changed_files":[],"carry_forward_findings_json":"[]","triage_records":[],"service_indicators_extra":[],"notes":"sample"} diff --git a/.claude/lib/__init__.py b/.claude/lib/__init__.py index 2bd61dc3..8393538e 100644 --- a/.claude/lib/__init__.py +++ b/.claude/lib/__init__.py @@ -1,2 +1 @@ """Shared implementation helpers for repository-local Claude skills.""" - diff --git a/.claude/lib/orchestration_test_cli.py b/.claude/lib/orchestration_test_cli.py new file mode 100644 index 00000000..6ec8c62c --- /dev/null +++ b/.claude/lib/orchestration_test_cli.py @@ -0,0 +1,12 @@ +"""External-CLI policy shared by orchestration tests.""" +from __future__ import annotations +import os +import shutil +import pytest + +def require_dev_cli(name: str, minimum: str, install: str) -> None: + if shutil.which(name): + return + if os.getenv("CI"): + return pytest.skip(f"dev-host-only check: {name} not installed in CI") + pytest.fail(f"{name} CLI is required ({minimum}); install it with: {install}") diff --git a/.claude/lib/sc_compose_dependency.py b/.claude/lib/sc_compose_dependency.py index 12f5f74f..362d25f9 100644 --- a/.claude/lib/sc_compose_dependency.py +++ b/.claude/lib/sc_compose_dependency.py @@ -27,4 +27,3 @@ def parse_version(text: str | None) -> tuple[int, int, int] | None: return None match = _VERSION_RE.search(text) return tuple(int(part) for part in match.groups()) if match else None - diff --git a/.claude/skills/closing-triage/scripts/query_open_findings.py b/.claude/skills/closing-triage/scripts/query_open_findings.py index 3ef496b1..0c17183d 100644 --- a/.claude/skills/closing-triage/scripts/query_open_findings.py +++ b/.claude/skills/closing-triage/scripts/query_open_findings.py @@ -441,4 +441,3 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": sys.exit(main()) - diff --git a/.claude/skills/closing-triage/scripts/test_query_open_findings.py b/.claude/skills/closing-triage/scripts/test_query_open_findings.py new file mode 100644 index 00000000..c1f946f4 --- /dev/null +++ b/.claude/skills/closing-triage/scripts/test_query_open_findings.py @@ -0,0 +1,170 @@ +"""Unit tests for the closing-triage query's sprint and worktree selection. + +Requires: rdflib (not a bootstrap dependency, so this file is run directly like +the graph-orchestration script tests: ``python3 ``). +""" + +from __future__ import annotations + +import importlib.util +import tempfile +import unittest +from pathlib import Path + +from rdflib import Graph, Namespace, URIRef + +SCRIPT = Path(__file__).with_name("query_open_findings.py") +SPEC = importlib.util.spec_from_file_location("query_open_findings", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +qof = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(qof) + +TRIAGE = Namespace("urn:atm:triage:") + +BB_STRUCTURE = """\ +@prefix triage: . +triage:PhaseBB a triage:Phase . +triage:BB3 a triage:Sprint ; triage:inPhase triage:PhaseBB ; triage:order 3 ; triage:branch "fix/bb3-qa2-r2" ; triage:criteria "docs/plans/phase-bb/sprint-BB.3.md" . +triage:BB6 a triage:Sprint ; triage:inPhase triage:PhaseBB ; triage:order 6 ; triage:branch "feature/bb6-prompt-handoffs" ; triage:criteria "docs/plans/phase-bb/sprint-BB.6.md" . +""" + +AJ_STRUCTURE = """\ +@prefix triage: . +triage:PhaseAJ a triage:Phase . +triage:BB6 a triage:Sprint ; triage:inPhase triage:PhaseAJ ; triage:order 1 ; triage:branch "feature/aj-dup" ; triage:criteria "docs/plans/phase-aj/sprint-AJ1.md" . +""" + + +def _write_structures(root: Path, phases: dict[str, str]) -> None: + for phase, text in phases.items(): + phase_dir = root / ".sprints" / phase + phase_dir.mkdir(parents=True) + (phase_dir / "structure.ttl").write_text(text) + + +class SprintSelectionTests(unittest.TestCase): + def test_declared_branch_still_maps_without_sprint_id(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _write_structures(root, {"BB": BB_STRUCTURE}) + phase, _, sprint = qof._sprint_for_branch(root, "feature/bb6-prompt-handoffs", None) + self.assertEqual(phase, "BB") + self.assertEqual(sprint, TRIAGE["BB6"]) + + def test_undeclared_stacked_layer_fails_closed_and_names_sprint_flag(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _write_structures(root, {"BB": BB_STRUCTURE}) + with self.assertRaises(qof.QueryError) as caught: + qof._sprint_for_branch(root, "fix/bb6-cli-qa2", None) + self.assertIn("--sprint ", str(caught.exception)) + + def test_sprint_id_selects_sprint_for_undeclared_branch(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _write_structures(root, {"BB": BB_STRUCTURE}) + phase, _, sprint = qof._sprint_for_branch(root, "fix/bb6-cli-qa2", None, "BB6") + self.assertEqual((phase, sprint), ("BB", TRIAGE["BB6"])) + + def test_sprint_id_ambiguous_across_phases_needs_phase(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _write_structures(root, {"BB": BB_STRUCTURE, "AJ": AJ_STRUCTURE}) + with self.assertRaises(qof.QueryError) as caught: + qof._sprint_for_branch(root, "fix/bb6-cli-qa2", None, "BB6") + self.assertIn("found 2", str(caught.exception)) + phase, _, sprint = qof._sprint_for_branch(root, "fix/bb6-cli-qa2", "BB", "BB6") + self.assertEqual((phase, sprint), ("BB", TRIAGE["BB6"])) + + def test_unknown_sprint_id_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _write_structures(root, {"BB": BB_STRUCTURE}) + with self.assertRaises(qof.QueryError) as caught: + qof._sprint_for_branch(root, "fix/bb9-x", "BB", "BB9") + self.assertIn("sprint 'BB9'", str(caught.exception)) + + +class IntegrationCandidateTests(unittest.TestCase): + CANDIDATES = [ + (Path("/wt/integrate/phase-aq"), "integrate/phase-aq"), + (Path("/wt/integrate/phase-az"), "integrate/phase-az"), + (Path("/wt/integrate/phase-bb"), "integrate/phase-bb"), + ] + + def test_single_candidate_wins_without_phase(self) -> None: + self.assertEqual( + qof.choose_integration_candidate(self.CANDIDATES[:1], None), self.CANDIDATES[0][0] + ) + + def test_multiple_candidates_without_phase_is_ambiguous(self) -> None: + self.assertIsNone(qof.choose_integration_candidate(self.CANDIDATES, None)) + + def test_phase_selects_matching_worktree_case_insensitively(self) -> None: + for phase in ("BB", "bb", "phase-bb"): + with self.subTest(phase=phase): + self.assertEqual( + qof.choose_integration_candidate(self.CANDIDATES, phase), + Path("/wt/integrate/phase-bb"), + ) + + def test_phase_without_matching_worktree_stays_ambiguous(self) -> None: + self.assertIsNone(qof.choose_integration_candidate(self.CANDIDATES, "AX")) + + def test_no_candidates_is_ambiguous(self) -> None: + self.assertIsNone(qof.choose_integration_candidate([], "BB")) + + +class ClosedFindingTests(unittest.TestCase): + def _graph(self, body: str) -> tuple[Graph, URIRef]: + graph = Graph() + graph.parse( + data="@prefix triage: .\n a triage:Finding ;\n" + body, + format="turtle", + ) + return graph, URIRef("urn:atm:triage:finding/X") + + def test_open_finding_is_not_closed(self) -> None: + graph, finding = self._graph(' triage:status "open" .\n') + self.assertFalse(qof.is_closed_finding(graph, finding)) + + def test_terminal_status_appended_beside_open_is_closed(self) -> None: + graph, finding = self._graph(' triage:status "open" ;\n triage:status "fixed" .\n') + self.assertTrue(qof.is_closed_finding(graph, finding)) + + def test_closed_flag_is_closed(self) -> None: + graph, finding = self._graph(' triage:status "open" ;\n triage:closed true .\n') + self.assertTrue(qof.is_closed_finding(graph, finding)) + + def test_resolution_record_is_closed(self) -> None: + graph, finding = self._graph( + ' triage:status "open" .\n' + " a triage:Resolution ; triage:resolves .\n" + ) + self.assertTrue(qof.is_closed_finding(graph, finding)) + + def test_non_terminal_status_values_stay_open(self) -> None: + graph, finding = self._graph(' triage:status "open" ;\n triage:status "in_progress" ;\n triage:closed false .\n') + self.assertFalse(qof.is_closed_finding(graph, finding)) + + +class OccurrenceFilesTests(unittest.TestCase): + def test_occurrence_files_are_collected_sorted_and_deduped(self) -> None: + graph = Graph() + graph.parse( + data=( + "@prefix triage: .\n" + " a triage:Finding ; triage:hasOccurrence , .\n" + ' triage:file "crates/atm/src/b.rs" .\n' + ' triage:file "crates/atm/src/a.rs" ; triage:file "crates/atm/src/b.rs" .\n' + ), + format="turtle", + ) + self.assertEqual( + qof.occurrence_files(graph, URIRef("urn:atm:triage:finding/X")), + ["crates/atm/src/a.rs", "crates/atm/src/b.rs"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.claude/skills/codex-orchestration/SKILL.md b/.claude/skills/codex-orchestration/SKILL.md index 53a1d498..57b16ef4 100644 --- a/.claude/skills/codex-orchestration/SKILL.md +++ b/.claude/skills/codex-orchestration/SKILL.md @@ -89,7 +89,7 @@ Before starting a sprint: vars). The template path goes through the daemon-owned admission path and the dispatch is queryable from outside. 9. `.claude/agents/rust-best-practices-agent.md` and - `.claude/skills/codex-orchestration/rust-best-practices-agent-assignment.json.j2` + `.claude/assets/sc-rust/quality-mgr/templates/rust-best-practices-assignment.json.j2` exist for first-pass boundary optimization review. 10. Every agent pane exports `BEADS_ACTOR` equal to its `ATM_IDENTITY` (the pane name, not an alias), and bead assignee values use those same names. diff --git a/.claude/skills/codex-orchestration/rust-best-practices-agent-assignment.json.j2 b/.claude/skills/codex-orchestration/rust-best-practices-agent-assignment.json.j2 deleted file mode 100644 index d990f1b6..00000000 --- a/.claude/skills/codex-orchestration/rust-best-practices-agent-assignment.json.j2 +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: rust-best-practices-agent-assignment -version: 1.0.0 -description: Render a fenced-JSON assignment for rust-best-practices-agent. -format: json -required_variables: - - review_mode - - worktree_path - - review_targets -optional_variables: - - reference_docs - - round_limit - - changed_files - - duplicate_sweep_symbols - - carry_forward_findings_json - - triage_records - - notes -defaults: - reference_docs: [] - round_limit: false - changed_files: [] - duplicate_sweep_symbols: [] - carry_forward_findings_json: "[]" - triage_records: [] - notes: "" ---- -{ - "review_mode": "{% if review_mode == "plan" %}doc_review{% elif review_mode == "phase_end" %}phase_end{% else %}sprint_review{% endif %}", - "worktree_path": {{ worktree_path }}, - "review_targets": [{% for target in review_targets %}{{ target }}{% if not loop.last %}, {% endif %}{% endfor %}], - "reference_docs": [{% for doc in reference_docs %}{{ doc }}{% if not loop.last %}, {% endif %}{% endfor %}], - "round_limit": {% if round_limit %}true{% else %}false{% endif %}, - "changed_files": [{% for file in changed_files %}{{ file }}{% if not loop.last %}, {% endif %}{% endfor %}], - "duplicate_sweep_symbols": [{% for symbol in duplicate_sweep_symbols %}{{ symbol }}{% if not loop.last %}, {% endif %}{% endfor %}], - "triage_records": [{% for record in triage_records %}{{ record }}{% if not loop.last %}, {% endif %}{% endfor %}], - "carry_forward_findings": {{ carry_forward_findings_json }}, - "findings_scope_locked": {% if carry_forward_findings_json != "[]" %}true{% else %}false{% endif %}, - "notes": {{ notes ~ (" SCOPE LOCK: this is a fix-round verification dispatch. Report a disposition (fixed | open | regressed) for each id in carry_forward_findings ONLY. Do not add any new finding to the `findings` array beyond those ids, even if you notice something real and unrelated -- put anything else observed under `notes`, not `findings`." if carry_forward_findings_json != "[]" else "") }} -} - diff --git a/.claude/skills/codex-orchestration/ruthless-boundary-qa-assignment.json.j2 b/.claude/skills/codex-orchestration/ruthless-boundary-qa-assignment.json.j2 new file mode 100644 index 00000000..152462d4 --- /dev/null +++ b/.claude/skills/codex-orchestration/ruthless-boundary-qa-assignment.json.j2 @@ -0,0 +1,39 @@ +--- +name: ruthless-boundary-qa-assignment +version: 1.0.0 +description: Render a fenced-JSON assignment for ruthless-boundary-qa. +format: json +required_variables: + - review_mode + - worktree_path + - review_targets +optional_variables: + - reference_docs + - round_limit + - changed_files + - duplicate_sweep_symbols + - carry_forward_findings_json + - triage_records + - notes +defaults: + reference_docs: [] + round_limit: false + changed_files: [] + duplicate_sweep_symbols: [] + carry_forward_findings_json: "[]" + triage_records: [] + notes: "" +--- +{ + "review_mode": "{% if review_mode == "plan" %}doc_review{% elif review_mode == "phase_end" %}phase_end{% else %}sprint_review{% endif %}", + "worktree_path": {{ worktree_path | tojson }}, + "review_targets": {{ review_targets }}, + "reference_docs": {{ reference_docs }}, + "round_limit": {{ round_limit }}, + "changed_files": {{ changed_files }}, + "duplicate_sweep_symbols": {{ duplicate_sweep_symbols }}, + "triage_records": {{ triage_records }}, + "carry_forward_findings": {{ carry_forward_findings_json }}, + "findings_scope_locked": {% if carry_forward_findings_json != "[]" %}true{% else %}false{% endif %}, + "notes": {{ notes | tojson }} +} diff --git a/.claude/skills/codex-orchestration/vars/fix-assignment.xml.json b/.claude/skills/codex-orchestration/vars/fix-assignment.xml.json index 2390479e..e5742f90 100644 --- a/.claude/skills/codex-orchestration/vars/fix-assignment.xml.json +++ b/.claude/skills/codex-orchestration/vars/fix-assignment.xml.json @@ -1 +1 @@ -{"task_id":"lint-spx.1","phase":"spx","sprint_doc":"docs/plans/orchestration/lint-spx.1-codex-orchestration-task-assign.md","branch":"chore/example","worktree_path":"/tmp/worktree","pr_target":"develop","description":"sample","finding_ids":"- F-1","triage_records":"- none","required_fixes":"- sample","acceptance_criteria":"- sample","references":"- sample"} +{"task_id":"lint-spx.1","phase":"spx","sprint_doc":"docs/plans/orchestration/lint-spx.1-codex-orchestration-task-assign.md","branch":"chore/example","worktree_path":"/tmp/worktree","pr_target":"develop","description":"sample","finding_ids":"- F-1","triage_records":"- none","required_fixes":"- sample","acceptance_criteria":"- sample","references":"- sample","sprint_id":""} diff --git a/.claude/skills/codex-orchestration/vars/rust-best-practices-agent-assignment.json b/.claude/skills/codex-orchestration/vars/rust-best-practices-agent-assignment.json deleted file mode 100644 index 2c917bb9..00000000 --- a/.claude/skills/codex-orchestration/vars/rust-best-practices-agent-assignment.json +++ /dev/null @@ -1 +0,0 @@ -{"task_id":"lint-spx.1","review_mode":"sprint","worktree_path":"/tmp/worktree","review_targets":["src/"]} diff --git a/.claude/skills/codex-orchestration/vars/ruthless-boundary-qa-assignment.json b/.claude/skills/codex-orchestration/vars/ruthless-boundary-qa-assignment.json new file mode 100644 index 00000000..55d6adc0 --- /dev/null +++ b/.claude/skills/codex-orchestration/vars/ruthless-boundary-qa-assignment.json @@ -0,0 +1 @@ +{"review_mode":"sprint","worktree_path":"/tmp/worktree","review_targets":["crates/sc-lint-boundary/src/lib.rs"],"reference_docs":["docs/architecture.md"],"round_limit":false,"changed_files":[],"duplicate_sweep_symbols":[],"carry_forward_findings_json":"[]","triage_records":[],"notes":"sample"} diff --git a/.claude/skills/graph-orchestration/scripts/test_validate_findings.py b/.claude/skills/graph-orchestration/scripts/test_validate_findings.py new file mode 100644 index 00000000..e9f00003 --- /dev/null +++ b/.claude/skills/graph-orchestration/scripts/test_validate_findings.py @@ -0,0 +1,328 @@ +"""Unit/integration tests for the raw findings validator. + +The validator has three observable outcomes. ``validation:pass`` and +``validation:fail`` are successful executions (the latter is a normal gate +failure caused by finding metadata); ``error`` means the validator itself +could not complete. +""" + +import importlib.util +import json +from pathlib import Path + + +SCRIPTS = Path(__file__).parent +PREFIX = ( + "@prefix triage: .\n" + "@prefix xsd: .\n" +) + + +def _validator(): + spec = importlib.util.spec_from_file_location( + "validate_findings", SCRIPTS / "validate-findings.py" + ) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def _structure() -> str: + return PREFIX + ( + "triage:S1 a triage:Sprint ; triage:order 1 .\n" + ) + + +def test_reports_missing_fields_and_fails(tmp_path, capsys): + validator = _validator() + findings = tmp_path / "findings" + findings.mkdir() + (findings / "F-1.ttl").write_text( + PREFIX + + "triage:f1 a triage:Finding ; triage:findingId \"F-1\" .\n" + ) + + rc = validator.main( + [ + "--findings-dir", + str(findings), + "--max-results", + "2", + ] + ) + output = capsys.readouterr().out + assert rc == 1 + assert "validated 1 file(s), 1 finding(s)" in output + assert "#error:" in output + assert "truncated" in output + + result = validator.run_validation(findings_dir=findings) + assert result.kind == "validation:fail" + assert result.summary.errors > 0 + assert result.summary.warnings >= 0 + assert all(line.startswith(("#error:", "#warning:")) for line in result.diagnostics) + + +def test_valid_finding_is_validation_pass(tmp_path): + validator = _validator() + structure = tmp_path / "structure.ttl" + structure.write_text(_structure()) + findings = tmp_path / "findings" + findings.mkdir() + (findings / "F-1.ttl").write_text( + PREFIX + + ( + "triage:f1 a triage:Finding ; triage:findingId \"F-1\" ; " + "triage:foundIn triage:S1 ; " + "triage:foundAt \"2026-07-01T12:00:00Z\"^^xsd:dateTime ; " + "triage:severity \"important\" ; triage:description \"Issue\" .\n" + ) + ) + + result = validator.run_validation(findings_dir=findings, structure=structure) + assert result.kind == "validation:pass" + assert result.summary == validator.ValidationSummary(files=1, findings=1) + assert result.diagnostics == () + + +def test_rejects_non_repository_relative_occurrence_and_legacy_worktree_paths( + tmp_path, +): + validator = _validator() + structure = tmp_path / "structure.ttl" + structure.write_text(_structure()) + findings = tmp_path / "findings" + findings.mkdir() + (findings / "F-1.ttl").write_text( + PREFIX + + ( + "triage:f1 a triage:Finding ; triage:findingId \"F-1\" ; " + "triage:foundIn triage:S1 ; " + "triage:foundAt \"2026-07-01T12:00:00Z\"^^xsd:dateTime ; " + "triage:severity \"important\" ; triage:description \"Issue\" ; " + "triage:hasOccurrence triage:o1 .\n" + "triage:o1 a triage:Occurrence ; " + "triage:file \"/checkout/src/lib.rs\" ; " + "triage:occursIn triage:w1 .\n" + "triage:w1 a triage:WorktreeSnapshot ; " + "triage:path \"../feature-worktree\" .\n" + "triage:w2 a triage:WorktreeSnapshot ; " + "triage:path \"/abs/orphan-worktree\" .\n" + ) + ) + + result = validator.run_validation(findings_dir=findings, structure=structure) + + assert result.kind == "validation:fail" + assert result.summary.errors == 3 + assert any("invalid triage:file" in line for line in result.diagnostics) + assert sum("invalid triage:path" in line for line in result.diagnostics) == 2 + + +def test_warning_only_metadata_is_validation_pass(tmp_path): + validator = _validator() + findings = tmp_path / "findings" + findings.mkdir() + (findings / "F-1.ttl").write_text( + PREFIX + + ( + "triage:f1 a triage:Finding ; triage:foundIn triage:S1 ; " + "triage:foundAt \"2026-07-01T12:00:00Z\"^^xsd:dateTime .\n" + ) + ) + + result = validator.run_validation(findings_dir=findings) + assert result.kind == "validation:pass" + assert result.summary.errors == 0 + assert result.summary.warnings == 3 + assert all(line.startswith("#warning:") for line in result.diagnostics) + + +def test_malformed_turtle_is_error_result(tmp_path): + validator = _validator() + findings = tmp_path / "findings" + findings.mkdir() + (findings / "BROKEN.ttl").write_text("this is not valid Turtle [") + + result = validator.run_validation(findings_dir=findings) + assert result.kind == "error" + assert result.summary.errors == 1 + assert result.summary.findings == 0 + assert result.diagnostics[0].startswith("#error:") + assert "malformed Turtle" in result.diagnostics[0] + + +def test_missing_directory_is_error_result(tmp_path): + validator = _validator() + result = validator.run_validation(findings_dir=tmp_path / "does-not-exist") + assert result.kind == "error" + assert result.summary.errors == 1 + assert "findings directory does not exist" in result.diagnostics[0] + + +def test_empty_directory_is_error_result(tmp_path): + validator = _validator() + findings = tmp_path / "findings" + findings.mkdir() + result = validator.run_validation(findings_dir=findings) + assert result.kind == "error" + assert "contains no Turtle files" in result.diagnostics[0] + + +def test_missing_structure_input_is_error_result(tmp_path): + validator = _validator() + findings = tmp_path / "findings" + findings.mkdir() + result = validator.run_validation( + findings_dir=findings, + structure=tmp_path / "missing-structure.ttl", + ) + assert result.kind == "error" + assert result.summary.errors == 1 + assert "input file does not exist" in result.diagnostics[0] + + +def test_declared_scope_rejects_finding_for_undeclared_sprint(tmp_path): + validator = _validator() + structure = tmp_path / "structure.ttl" + structure.write_text(PREFIX + "triage:PhaseF a triage:Phase .\n") + findings = tmp_path / "findings" + findings.mkdir() + (findings / "F-1.ttl").write_text( + PREFIX + + ( + "triage:f1 a triage:Finding ; triage:findingId \"F-1\" ; " + "triage:foundIn triage:S1 ; " + "triage:foundAt \"2026-07-01T12:00:00Z\"^^xsd:dateTime ; " + "triage:severity \"important\" ; triage:description \"Issue\" .\n" + ) + ) + + result = validator.run_validation(findings_dir=findings, structure=structure) + + assert result.kind == "validation:fail" + assert result.summary.errors == 1 + assert "undeclared sprint" in result.diagnostics[0] + + +def test_invalid_regex_is_error_result_and_cli_exit_two(tmp_path, capsys): + validator = _validator() + findings = tmp_path / "findings" + findings.mkdir() + + result = validator.run_validation( + findings_dir=findings, + finding_id_regex="[unterminated", + ) + assert result.kind == "error" + assert "invalid validator configuration" in result.message + + rc = validator.main( + [ + "--findings-dir", + str(findings), + "--finding-id-regex", + "[unterminated", + "--json", + ] + ) + assert rc == 2 + payload = json.loads(capsys.readouterr().out) + assert payload["kind"] == "error" + assert payload["diagnostics"] == [] + + +def test_json_result_is_discriminated_and_preserves_validation_fail(tmp_path, capsys): + validator = _validator() + findings = tmp_path / "findings" + findings.mkdir() + (findings / "F-1.ttl").write_text( + PREFIX + "triage:f1 a triage:Finding ; triage:findingId \"F-1\" .\n" + ) + + rc = validator.main(["--findings-dir", str(findings), "--json"]) + assert rc == 1 + payload = json.loads(capsys.readouterr().out) + assert payload["kind"] == "validation:fail" + assert payload["summary"]["errors"] == 2 + assert sum(item.startswith("#error:") for item in payload["diagnostics"]) == 2 + assert sum(item.startswith("#warning:") for item in payload["diagnostics"]) == 2 + + +def test_broken_sparql_is_error_result(tmp_path): + validator = _validator() + findings = tmp_path / "findings" + findings.mkdir() + (findings / "F-1.ttl").write_text( + PREFIX + "triage:f1 a triage:Finding ; triage:findingId \"F-1\" .\n" + ) + broken_scripts = tmp_path / "scripts" + broken_scripts.mkdir() + (broken_scripts / "validate-findings.sparql").write_text("SELECT definitely broken") + + result = validator.run_validation( + findings_dir=findings, + script_dir=broken_scripts, + ) + assert result.kind == "error" + assert "SPARQL query failed" in result.message + + +def test_regex_limits_validation_scope(tmp_path, capsys): + validator = _validator() + findings = tmp_path / "findings" + findings.mkdir() + for name in ("AI21-001", "AI10-001"): + (findings / f"{name}.ttl").write_text( + PREFIX + + f" a triage:Finding ; " + + f"triage:findingId \"{name}\" .\n" + ) + + rc = validator.main( + [ + "--findings-dir", + str(findings), + "--finding-id-regex", + r"^AI21-", + ] + ) + output = capsys.readouterr().out + assert rc == 1 + assert "1 finding(s)" in output + assert "AI21-001" in output + assert "AI10-001" not in output + + +def test_path_validation_respects_finding_id_scope(tmp_path): + validator = _validator() + findings = tmp_path / "findings" + findings.mkdir() + (findings / "mixed.ttl").write_text( + PREFIX + + ( + "triage:a a triage:Finding ; triage:findingId \"AI21-001\" ; " + "triage:foundIn triage:S1 ; " + "triage:foundAt \"2026-07-01T12:00:00Z\"^^xsd:dateTime ; " + "triage:severity \"important\" ; triage:description \"A\" ; " + "triage:hasOccurrence triage:oa .\n" + "triage:oa triage:file \"/abs/selected.rs\" .\n" + "triage:b a triage:Finding ; triage:findingId \"AI10-001\" ; " + "triage:foundIn triage:S1 ; " + "triage:foundAt \"2026-07-01T12:00:00Z\"^^xsd:dateTime ; " + "triage:severity \"important\" ; triage:description \"B\" ; " + "triage:hasOccurrence triage:ob .\n" + "triage:ob triage:file \"/abs/unselected.rs\" .\n" + ) + ) + + result = validator.run_validation( + findings_dir=findings, + finding_id_regex=r"^AI21-", + ) + + assert result.kind == "validation:fail" + assert result.summary.findings == 1 + assert len(result.diagnostics) == 1 + assert "selected.rs" in result.diagnostics[0] diff --git a/.claude/skills/graph-orchestration/scripts/validate-findings.py b/.claude/skills/graph-orchestration/scripts/validate-findings.py index 00a8973e..d623a9c5 100644 --- a/.claude/skills/graph-orchestration/scripts/validate-findings.py +++ b/.claude/skills/graph-orchestration/scripts/validate-findings.py @@ -220,6 +220,10 @@ def _load_graph( "persisted path must be repository-relative" ) + if parsed_files == 0 and not diagnostics: + diagnostics.append( + f"#error: {findings_dir}: findings directory contains no Turtle files" + ) for triple in finding_graph: graph.add(triple) return ( @@ -473,4 +477,3 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": raise SystemExit(main()) - diff --git a/.claude/skills/graph-orchestration/scripts/validate-findings.sparql b/.claude/skills/graph-orchestration/scripts/validate-findings.sparql new file mode 100644 index 00000000..3a6fe64d --- /dev/null +++ b/.claude/skills/graph-orchestration/scripts/validate-findings.sparql @@ -0,0 +1,36 @@ +# validate-findings.sparql — Finding metadata integrity check. +PREFIX triage: + +SELECT DISTINCT ?level ?finding ?field ?detail WHERE { + ?finding a triage:Finding . + { FILTER NOT EXISTS { ?finding triage:findingId ?id . } + BIND("#warning" AS ?level) BIND("triage:findingId" AS ?field) + BIND("stable finding identity is missing" AS ?detail) } + UNION + { FILTER NOT EXISTS { ?finding triage:foundIn ?sprint . } + BIND("#error" AS ?level) BIND("triage:foundIn" AS ?field) + BIND("finding cannot be assigned to a sprint" AS ?detail) } + UNION + { FILTER NOT EXISTS { ?finding triage:foundAt ?found_at . } + BIND("#error" AS ?level) BIND("triage:foundAt" AS ?field) + BIND("completion invalidation cannot be time-ordered" AS ?detail) } + UNION + { FILTER NOT EXISTS { ?finding triage:severity ?severity . } + BIND("#warning" AS ?level) BIND("triage:severity" AS ?field) + BIND("blocking/important/minor routing cannot be determined" AS ?detail) } + UNION + { FILTER NOT EXISTS { ?finding triage:description ?description . } + BIND("#warning" AS ?level) BIND("triage:description" AS ?field) + BIND("dispatch and cleanup output lack a description" AS ?detail) } + UNION + { ?finding triage:hasOccurrence ?occurrence . ?occurrence triage:file ?file . + FILTER (STRSTARTS(STR(?file), "/") || REGEX(STR(?file), "(^|[/\\\\])\\.\\.(?:[/\\\\]|$)") || REGEX(STR(?file), "^[A-Za-z]:[/\\\\]")) + BIND("#error" AS ?level) BIND("triage:file" AS ?field) + BIND(CONCAT("occurrence path must be repository-relative: ", STR(?file)) AS ?detail) } + UNION + { ?finding triage:hasOccurrence ?occurrence . ?occurrence triage:occursIn ?worktree . ?worktree triage:path ?path . + FILTER (STRSTARTS(STR(?path), "/") || REGEX(STR(?path), "(^|[/\\\\])\\.\\.(?:[/\\\\]|$)") || REGEX(STR(?path), "^[A-Za-z]:[/\\\\]")) + BIND("#error" AS ?level) BIND("triage:path" AS ?field) + BIND(CONCAT("legacy worktree path must not be absolute or escape repository: ", STR(?path)) AS ?detail) } +} +ORDER BY ?level ?finding ?field diff --git a/.claude/skills/team-lead/scripts/roster_check.py b/.claude/skills/team-lead/scripts/roster_check.py index 6a7734de..fd88f054 100644 --- a/.claude/skills/team-lead/scripts/roster_check.py +++ b/.claude/skills/team-lead/scripts/roster_check.py @@ -61,7 +61,10 @@ def find_problems( def run_json(command: list[str]) -> dict: - completed = subprocess.run(command, capture_output=True, text=True, check=False) + try: + completed = subprocess.run(command, capture_output=True, text=True, check=False) + except OSError as error: + raise RuntimeError(f"{' '.join(command)} could not be executed: {error}") from error if completed.returncode != 0: raise RuntimeError(f"{' '.join(command)} failed: {completed.stderr.strip()}") return json.loads(completed.stdout) @@ -79,19 +82,23 @@ def main() -> int: config = config_aliases(tomllib.loads(Path(args.atm_toml).read_text("utf-8")), args.team) roster = run_json(["atm", "members", "--team", args.team, "--json"])["members"] agents = run_json(["herdr", "agent", "list"])["result"]["agents"] + except tomllib.TOMLDecodeError as error: + print(f"roster_check: {args.atm_toml}: malformed TOML: {error}", file=sys.stderr) + return 2 except (OSError, RuntimeError, KeyError, ValueError) as error: print(f"roster_check: {error}", file=sys.stderr) return 2 herdr_names = [agent["name"] for agent in agents if agent.get("name")] - print(f"{'member':<14}{'.atm.toml alias':<18}{'roster alias':<16}{'herdr agent':<16}state") + rows = [] for member in roster: target = member.get("alias") or member["name"] - print( - f"{member['name']:<14}{config.get(member['name']) or '-':<18}" - f"{member.get('alias') or '-':<16}" - f"{target if target in herdr_names else 'MISSING':<16}{member.get('state', '-')}" - ) + rows.append((member["name"], config.get(member["name"]) or "-", member.get("alias") or "-", target if target in herdr_names else "MISSING", member.get("state", "-"))) + headers = ("member", ".atm.toml alias", "roster alias", "herdr agent", "state") + widths = [max(len(headers[index]), *(len(row[index]) for row in rows)) + 2 for index in range(4)] + print("".join(f"{header:<{width}}" for header, width in zip(headers[:4], widths)) + headers[4]) + for row in rows: + print("".join(f"{value:<{width}}" for value, width in zip(row[:4], widths)) + row[4]) problems = find_problems(config, roster, herdr_names) print() for problem in problems: @@ -102,4 +109,3 @@ def main() -> int: if __name__ == "__main__": sys.exit(main()) - diff --git a/.claude/skills/triaging-findings/scripts/check_dependencies.py b/.claude/skills/triaging-findings/scripts/check_dependencies.py index 808e37a2..28177e99 100644 --- a/.claude/skills/triaging-findings/scripts/check_dependencies.py +++ b/.claude/skills/triaging-findings/scripts/check_dependencies.py @@ -135,4 +135,3 @@ def main() -> int: if __name__ == "__main__": raise SystemExit(main()) - diff --git a/.claude/skills/triaging-findings/tests/test_check_dependencies.py b/.claude/skills/triaging-findings/tests/test_check_dependencies.py new file mode 100644 index 00000000..02a7c3c3 --- /dev/null +++ b/.claude/skills/triaging-findings/tests/test_check_dependencies.py @@ -0,0 +1,55 @@ +import importlib.util +import json +from pathlib import Path + + +SCRIPT = Path(__file__).parents[1] / "scripts" / "check_dependencies.py" +spec = importlib.util.spec_from_file_location("check_dependencies", SCRIPT) +check_dependencies = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(check_dependencies) + + +def test_version_parser(): + assert check_dependencies._version("sc-compose 1.2.0") == (1, 2, 0) + assert check_dependencies._version("oxigraph 0.5.7") == (0, 5, 7) + assert check_dependencies._version("missing") is None + + +def test_preflight_passes_in_current_environment(monkeypatch): + monkeypatch.setattr(check_dependencies, "_find", lambda name: Path("/bin/true")) + monkeypatch.setattr(check_dependencies, "_run_version", lambda path: ("tool 1.6.1", "tool 1.6.1")) + monkeypatch.setattr(check_dependencies, "_python_binding_entry", lambda: {"name": "sc_compose", "ok": True}) + result = check_dependencies.run() + assert result["success"] is True + assert result["error"] is None + + +def test_old_sc_compose_is_a_structured_failure(monkeypatch): + monkeypatch.setattr(check_dependencies, "_find", lambda name: Path("/bin/true")) + monkeypatch.setattr(check_dependencies, "_run_version", lambda path: ("sc-compose 1.0.1", "sc-compose 1.0.1")) + monkeypatch.setattr(check_dependencies, "_python_binding_entry", lambda: {"name": "sc_compose", "ok": True}) + result = check_dependencies.run() + assert result["success"] is False + assert result["error"]["code"] == "EXECUTION.DEPENDENCY" + assert any(item["name"] == "sc-compose" for item in result["error"]["failures"]) + + +def test_missing_python_binding_has_actionable_install_hint(monkeypatch): + def missing(_name): + raise check_dependencies.importlib.metadata.PackageNotFoundError("sc-compose") + + monkeypatch.setattr(check_dependencies.importlib.metadata, "version", missing) + result = check_dependencies._python_binding_entry() + assert result["ok"] is False + assert "python3 -m pip install --user --break-system-packages" in result["error"] + + +def test_cli_emits_json_success_contract(monkeypatch, capsys): + monkeypatch.setattr(check_dependencies, "_find", lambda name: Path("/bin/true")) + monkeypatch.setattr(check_dependencies, "_run_version", lambda path: ("tool 1.6.1", "tool 1.6.1")) + monkeypatch.setattr(check_dependencies, "_python_binding_entry", lambda: {"name": "sc_compose", "ok": True}) + assert check_dependencies.main() == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["success"] is True + assert payload["error"] is None diff --git a/.claude/skills/triaging-findings/tests/test_qa_triage_prompt.py b/.claude/skills/triaging-findings/tests/test_qa_triage_prompt.py new file mode 100644 index 00000000..07f83a21 --- /dev/null +++ b/.claude/skills/triaging-findings/tests/test_qa_triage_prompt.py @@ -0,0 +1,31 @@ +"""Contract checks for the qa-triage agent prompt's validation gate.""" + +from pathlib import Path + + +PROMPT = Path(__file__).resolve().parents[3] / "agents" / "qa-triage.md" + + +def test_qa_triage_prompt_validates_the_complete_phase_after_render() -> None: + text = PROMPT.read_text(encoding="utf-8") + + assert "validate-findings.py" in text + assert "--findings-dir \"$triage_root/$phase_id/findings\"" in text + assert "--structure \"$structure_path\"" in text + assert "--events \"$events_path\"" in text + assert "--json" in text + assert 'kind == "validation:pass"' in text + assert "validation:fail" in text + assert "error" in text + + +def test_qa_triage_prompt_does_not_treat_validation_fail_as_success() -> None: + text = PROMPT.read_text(encoding="utf-8") + + gate = text[ + text.index("12. Validate the rendered Turtle") : text.index( + "13. Return enough information" + ) + ] + assert "blocks this agent from reporting success" in gate + assert "only `validation:pass`" in gate diff --git a/.claude/skills/triaging-findings/tests/test_triage_record_template.py b/.claude/skills/triaging-findings/tests/test_triage_record_template.py new file mode 100644 index 00000000..7d062097 --- /dev/null +++ b/.claude/skills/triaging-findings/tests/test_triage_record_template.py @@ -0,0 +1,185 @@ +"""Focused render and Turtle-parse tests for the canonical triage record.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[4] +sys.path.insert(0, str(REPO_ROOT / ".claude")) +from lib.orchestration_test_cli import require_dev_cli +from lib.sc_compose_dependency import MIN_SC_COMPOSE_TEXT, SC_COMPOSE_INSTALL + +TEMPLATE = ".claude/skills/triaging-findings/triage-record.ttl.j2" + + +def _vars() -> dict: + return { + "finding_id": "FTQ-001", + "title": "Process-global shutdown state in tests", + "description": "Global state leaks across test cases.", + "phase_id": "phase-R", + "triage_mode": "initial_pass", + "category": "FTQ", + "severity": "important", + "repeatable": True, + "sweep_scope": "crate", + "status": "open", + "dispatch_ready": True, + "triaged_at": "2026-07-25T16:30:00Z", + "found_in": "AICH-S7", + "found_at": "2026-07-25T16:26:33Z", + # sc-compose var-files intentionally accept scalar arrays only. The + # parallel arrays below are joined by index in the Turtle template. + "occurrences": ["R17-1"], + "occurrence_files": ["crates/atm-daemon/src/tests.rs"], + "occurrence_lines": ["28"], + "occurrence_snippets": ["static DISPATCHER: OnceLock<...>"], + "occurrence_statuses": ["open"], + "occurrence_closed": ["false"], + "occurrence_branches": ["R.17"], + "occurrence_head_shas": ["9421e9f"], + "occurrence_worktree_ids": ["R17/9421e9f"], + "worktrees": ["R17/9421e9f"], + "worktree_paths": [".worktrees/R17"], + "worktree_branches": ["R.17"], + "worktree_head_shas": ["9421e9f"], + "worktree_order_indices": ["17"], + } + + +def _render(tmp_path: Path, variables: dict) -> subprocess.CompletedProcess[str]: + require_dev_cli("sc-compose", MIN_SC_COMPOSE_TEXT, SC_COMPOSE_INSTALL) + vars_path = tmp_path / "vars.json" + output_path = tmp_path / "FTQ-001.ttl" + vars_path.write_text(json.dumps(variables), encoding="utf-8") + return subprocess.run( + [ + "sc-compose", + "render", + "--root", + str(REPO_ROOT), + "--file", + TEMPLATE, + "--var-file", + str(vars_path), + "--output", + str(output_path), + ], + cwd=REPO_ROOT, + text=True, + capture_output=True, + env={**os.environ, "NO_COLOR": "1"}, + ) + + +def _parse_turtle( + path: Path, tmp_path: Path +) -> subprocess.CompletedProcess[str]: + require_dev_cli("oxigraph", "installed", "install oxigraph from its released binary") + converted = tmp_path / "parsed.ttl" + return subprocess.run( + [ + "oxigraph", + "convert", + "--from-file", + str(path), + "--from-format", + "ttl", + "--to-file", + str(converted), + "--to-format", + "ttl", + ], + text=True, + capture_output=True, + ) + + +def test_render_includes_found_provenance_and_parses_as_turtle(tmp_path: Path) -> None: + result = _render(tmp_path, _vars()) + assert result.returncode == 0, result.stderr or result.stdout + + output = tmp_path / "FTQ-001.ttl" + rendered = output.read_text(encoding="utf-8") + assert "triage:foundIn triage:AICH-S7" in rendered + assert 'triage:foundAt "2026-07-25T16:26:33Z"^^xsd:dateTime' in rendered + assert "triage:hasOccurrence" in rendered + assert "a triage:WorktreeSnapshot" in rendered + assert 'triage:path ".worktrees/R17"' in rendered + + parsed = _parse_turtle(output, tmp_path) + assert parsed.returncode == 0, parsed.stderr or parsed.stdout + + +def test_python_binding_renders_canonical_template() -> None: + """The pip/maturin binding must render the same template tokens as the CLI.""" + try: + import importlib.metadata + import sc_compose + except (ImportError, importlib.metadata.PackageNotFoundError) as exc: # pragma: no cover - dependency preflight owns setup + pytest.skip(f"sc-compose Python binding unavailable: {exc}") + assert tuple(int(part) for part in importlib.metadata.version("sc-compose").split(".")[:3]) >= (1, 6, 1) + rendered = sc_compose.render_template( + (REPO_ROOT / TEMPLATE).read_text(encoding="utf-8"), _vars() + ) + assert "triage:foundIn triage:AICH-S7" in rendered + assert 'triage:foundAt "2026-07-25T16:26:33Z"^^xsd:dateTime' in rendered + + +@pytest.mark.parametrize("missing", ["found_in", "found_at", "worktree_paths"]) +def test_render_rejects_missing_provenance_variable( + tmp_path: Path, missing: str +) -> None: + variables = _vars() + del variables[missing] + + result = _render(tmp_path, variables) + + assert result.returncode != 0 + diagnostic = f"{result.stdout}\n{result.stderr}".lower() + assert missing in diagnostic + + +@pytest.mark.parametrize( + ("variable", "path_value"), + [ + ( + "occurrence_files", + "/abs/integrate-phase-R/crates/atm-daemon/src/tests.rs", + ), + ("occurrence_files", "../outside-repository.rs"), + ("occurrence_files", "crates/../outside-repository.rs"), + ("occurrence_files", r"C:\\checkout\\crates\\atm-daemon\\src\\tests.rs"), + ("worktree_paths", "/abs/integrate-phase-R"), + ("worktree_paths", "../outside-repository"), + ("worktree_paths", "worktrees/../outside-repository"), + ("worktree_paths", r"C:\\checkout\\integrate-phase-R"), + ("worktree_paths", r"\\server\\share\\integrate-phase-R"), + ], +) +def test_render_rejects_non_repository_relative_persisted_paths( + tmp_path: Path, variable: str, path_value: str +) -> None: + variables = _vars() + variables[variable] = [path_value] + + result = _render(tmp_path, variables) + assert result.returncode == 0, result.stderr or result.stdout + + output = tmp_path / "FTQ-001.ttl" + rendered = output.read_text(encoding="utf-8") + marker = ( + "__ERROR_REPOSITORY_RELATIVE_OCCURRENCE_PATH_REQUIRED__" + if variable == "occurrence_files" + else "__ERROR_REPOSITORY_RELATIVE_WORKTREE_PATH_REQUIRED__" + ) + assert marker in rendered + + parsed = _parse_turtle(output, tmp_path) + assert parsed.returncode != 0 diff --git a/.claude/skills/triaging-findings/triage-record.ttl.j2 b/.claude/skills/triaging-findings/triage-record.ttl.j2 index d0558f74..6f7c2d5f 100644 --- a/.claude/skills/triaging-findings/triage-record.ttl.j2 +++ b/.claude/skills/triaging-findings/triage-record.ttl.j2 @@ -89,4 +89,3 @@ optional_variables: triage:orderIndex {{ worktree_order_indices[loop.index0] }} . {% endfor %} - diff --git a/.just/tests/test_team_lead_roster_check.py b/.just/tests/test_team_lead_roster_check.py new file mode 100644 index 00000000..9b564cdb --- /dev/null +++ b/.just/tests/test_team_lead_roster_check.py @@ -0,0 +1,129 @@ +"""Unit tests for the team-lead skill's read-only roster check.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +import tomllib +import tempfile +import unittest +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCRIPT = REPO_ROOT / ".claude/skills/team-lead/scripts/roster_check.py" +spec = importlib.util.spec_from_file_location("roster_check", SCRIPT) +roster_check = importlib.util.module_from_spec(spec) +spec.loader.exec_module(roster_check) + +CONFIG = {"team-lead": "atm-lead", "quality-mgr": "atm-quality", "publisher": "atm-publisher", "arch-ctm": None} +ROSTER = [ + {"name": "team-lead", "alias": "atm-lead", "backend": "herdr"}, + {"name": "quality-mgr", "alias": "atm-quality", "backend": "herdr"}, + {"name": "publisher", "alias": "atm-publisher", "backend": "herdr"}, + {"name": "arch-ctm", "alias": None, "backend": "herdr"}, +] +HERDR = ["atm-lead", "atm-quality", "atm-publisher", "arch-ctm", "obs-lead"] + + +def edited(name: str, **changes: object) -> list[dict]: + return [{**member, **changes} if member["name"] == name else member for member in ROSTER] + + +class FindProblemsTests(unittest.TestCase): + def test_agreeing_sources_have_no_problems(self) -> None: + self.assertEqual(roster_check.find_problems(CONFIG, ROSTER, HERDR), []) + + def test_dropped_roster_alias_is_reported_twice(self) -> None: + problems = roster_check.find_problems(CONFIG, edited("publisher", alias=None), HERDR) + self.assertIn("publisher: roster alias missing (required for a unique Herdr name)", problems) + self.assertIn("publisher: .atm.toml alias 'atm-publisher' != roster alias None", problems) + + def test_required_alias_missing_from_config(self) -> None: + problems = roster_check.find_problems({**CONFIG, "team-lead": None}, ROSTER, HERDR) + self.assertIn("team-lead: .atm.toml declares no alias (required)", problems) + + def test_missing_herdr_agent(self) -> None: + problems = roster_check.find_problems(CONFIG, ROSTER, [n for n in HERDR if n != "arch-ctm"]) + self.assertEqual(problems, ["arch-ctm: no live Herdr agent named 'arch-ctm'"]) + + def test_bare_name_on_another_team_does_not_satisfy_an_aliased_member(self) -> None: + herdr = [n for n in HERDR if n != "atm-publisher"] + ["publisher"] + problems = roster_check.find_problems(CONFIG, ROSTER, herdr) + self.assertEqual(problems, ["publisher: no live Herdr agent named 'atm-publisher'"]) + + def test_duplicate_herdr_name(self) -> None: + problems = roster_check.find_problems(CONFIG, ROSTER, HERDR + ["arch-ctm"]) + self.assertEqual(problems, ["Herdr agent name 'arch-ctm' is not unique on this session"]) + + def test_roster_and_config_membership_must_match(self) -> None: + roster = ROSTER + [{"name": "solar", "alias": None, "backend": "herdr"}] + problems = roster_check.find_problems({**CONFIG, "spare-dev": None}, roster, HERDR + ["solar"]) + self.assertEqual( + problems, + ["spare-dev: declared in .atm.toml but not in the roster", + "solar: in the roster but has no pane in .atm.toml"], + ) + + +class ConfigAliasesTests(unittest.TestCase): + def test_reads_only_this_teams_identified_panes(self) -> None: + config = tomllib.loads( + '[[rmux.windows]]\n' + '[[rmux.windows.panes]]\nname = "team-lead"\nalias = "atm-lead"\n' + 'env = { ATM_IDENTITY = "team-lead", ATM_TEAM = "atm-dev" }\n' + '[[rmux.windows.panes]]\nname = "arch-ctm"\n' + 'env = { ATM_IDENTITY = "arch-ctm", ATM_TEAM = "atm-dev" }\n' + '[[rmux.windows.panes]]\nname = "spare"\nenv = { ATM_TEAM = "atm-dev" }\n' + '[[rmux.windows.panes]]\nname = "other"\nalias = "x"\n' + 'env = { ATM_IDENTITY = "other", ATM_TEAM = "sc-obs" }\n' + ) + self.assertEqual( + roster_check.config_aliases(config, "atm-dev"), + {"team-lead": "atm-lead", "arch-ctm": None}, + ) + + def test_repo_atm_toml_declares_every_required_alias(self) -> None: + config = tomllib.loads((REPO_ROOT / ".atm.toml").read_text("utf-8")) + aliases = roster_check.config_aliases(config, "sc-lint") + for name in roster_check.ALIAS_REQUIRED: + with self.subTest(member=name): + self.assertTrue(aliases.get(name)) + values = [alias for alias in aliases.values() if alias] + self.assertEqual(len(values), len(set(values))) + + +class TableOutputTests(unittest.TestCase): + def test_long_alias_keeps_every_row_as_five_whitespace_fields(self) -> None: + config = {"lint-quality-mgr": "lint-quality-mgr", "team-lead": "lead"} + roster = [ + {"name": "lint-quality-mgr", "alias": "lint-quality-mgr", "backend": "herdr", "state": "idle"}, + {"name": "team-lead", "alias": "lead", "backend": "herdr", "state": "idle"}, + ] + agents = [{"name": "lint-quality-mgr"}, {"name": "lead"}] + with mock.patch.object(roster_check, "config_aliases", return_value=config), \ + mock.patch.object(roster_check, "run_json", side_effect=[{"members": roster}, {"result": {"agents": agents}}]), \ + mock.patch("sys.argv", ["roster_check.py", "--team", "sc-lint", "--atm-toml", str(REPO_ROOT / ".atm.toml")]), \ + mock.patch("builtins.print") as printed: + self.assertEqual(roster_check.main(), 0) + rows = [call.args[0] for call in printed.call_args_list if call.args and call.args[0].startswith("lint-quality-mgr")] + self.assertEqual(len(rows[0].split()), 5) + + +class FailureDiagnosticsTests(unittest.TestCase): + def test_missing_executable_is_actionable(self) -> None: + with mock.patch.object(roster_check.subprocess, "run", side_effect=FileNotFoundError("atm")): + with self.assertRaisesRegex(RuntimeError, "atm members.*could not be executed"): + roster_check.run_json(["atm", "members"]) + + def test_malformed_toml_names_the_input_path(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "broken.toml" + path.write_text("[broken", encoding="utf-8") + with mock.patch("sys.argv", ["roster_check.py", "--team", "sc-lint", "--atm-toml", str(path)]), \ + mock.patch("sys.stderr") as stderr: + self.assertEqual(roster_check.main(), 2) + self.assertIn(str(path), "".join(call.args[0] for call in stderr.write.call_args_list)) + + +if __name__ == "__main__": + unittest.main() diff --git a/bindings/sc-lint-py/pyproject.toml b/bindings/sc-lint-py/pyproject.toml index cb4c695f..138ee07b 100644 --- a/bindings/sc-lint-py/pyproject.toml +++ b/bindings/sc-lint-py/pyproject.toml @@ -7,6 +7,7 @@ name = "sc-lint" description = "sc-lint Python bindings and repository helper package." readme = "README.md" dependencies = ["codespell>=2.2"] + license = { text = "MIT" } requires-python = ">=3.9" dynamic = ["version"] @@ -16,6 +17,9 @@ classifiers = [ "Programming Language :: Python :: Implementation :: CPython", ] +[project.optional-dependencies] +test = ["pytest>=8,<9", "rdflib>=7,<8", "Jinja2>=3.1,<4", "PyYAML>=6,<7"] + [project.urls] Homepage = "https://github.com/randlee/sc-lint" Repository = "https://github.com/randlee/sc-lint" diff --git a/bindings/sc-lint-py/python/sc_lint/run_pytests.py b/bindings/sc-lint-py/python/sc_lint/run_pytests.py index 139e7f98..0186ed67 100644 --- a/bindings/sc-lint-py/python/sc_lint/run_pytests.py +++ b/bindings/sc-lint-py/python/sc_lint/run_pytests.py @@ -7,6 +7,7 @@ import argparse import sys import unittest +import subprocess from sc_lint.lint_common import discover_repo_root @@ -67,7 +68,16 @@ def main(argv: list[str]) -> int: print_fixture_summary(fixture_counts) runner = unittest.TextTestRunner(stream=sys.stdout, verbosity=1) result = runner.run(suite) - return 0 if result.wasSuccessful() else 1 + orchestration_tests = [ + repo_root / ".claude/skills/closing-triage/scripts/test_query_open_findings.py", + repo_root / ".claude/skills/graph-orchestration/scripts/test_validate_findings.py", + repo_root / ".claude/skills/triaging-findings/tests/test_check_dependencies.py", + repo_root / ".claude/skills/triaging-findings/tests/test_triage_record_template.py", + repo_root / ".claude/skills/triaging-findings/tests/test_qa_triage_prompt.py", + repo_root / ".just/tests/test_team_lead_roster_check.py", + ] + pytest = subprocess.run([sys.executable, "-m", "pytest", "-q", *map(str, orchestration_tests)], check=False) + return 0 if result.wasSuccessful() and pytest.returncode == 0 else 1 if __name__ == "__main__": diff --git a/bindings/sc-lint-py/python/sc_lint/source_venv.py b/bindings/sc-lint-py/python/sc_lint/source_venv.py index ca3501c5..bbb449fa 100644 --- a/bindings/sc-lint-py/python/sc_lint/source_venv.py +++ b/bindings/sc-lint-py/python/sc_lint/source_venv.py @@ -69,6 +69,10 @@ def main() -> int: install += ["--no-index", "--find-links", wheel_dir, f"sc-lint=={version}"] print(f"source_venv: installing sc_lint into {VENV_DIR}", file=sys.stderr) subprocess.run(install, check=True) + subprocess.run( + [str(python), "-m", "pip", "install", "--quiet", "--disable-pip-version-check", "pytest>=8,<9", "rdflib>=7,<8", "Jinja2>=3.1,<4", "PyYAML>=6,<7"], + check=True, + ) STAMP.write_text(fingerprint + "\n", encoding="utf-8") return 0 diff --git a/bindings/sc-lint-py/python/sc_lint/tests/test_orchestration_contracts.py b/bindings/sc-lint-py/python/sc_lint/tests/test_orchestration_contracts.py new file mode 100644 index 00000000..e1a75ec5 --- /dev/null +++ b/bindings/sc-lint-py/python/sc_lint/tests/test_orchestration_contracts.py @@ -0,0 +1,250 @@ +"""Regression tests for repository-local orchestration helpers and templates. + +These tests deliberately import scripts by path and inject subprocess results; +they never require a live ATM or Herdr daemon. +""" + +from __future__ import annotations + +import importlib.util +import json +import re +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +from sc_lint.lint_common import discover_repo_root + +REPO = discover_repo_root() + + +def load(name: str, relative: str): + spec = importlib.util.spec_from_file_location(name, REPO / relative) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +class DependencyContractTests(unittest.TestCase): + def setUp(self) -> None: + self.module = load("sc_compose_dependency_test", ".claude/lib/sc_compose_dependency.py") + self.check = load( + "check_dependencies_test", + ".claude/skills/triaging-findings/scripts/check_dependencies.py", + ) + + def test_version_parser_handles_missing_prerelease_and_extra_text(self) -> None: + self.assertIsNone(self.module.parse_version(None)) + self.assertIsNone(self.module.parse_version("missing")) + self.assertEqual(self.module.parse_version("sc-compose 1.6.1-rc.1"), (1, 6, 1)) + self.assertEqual(self.module.parse_version("tool v2.10.3 built today"), (2, 10, 3)) + + def test_missing_optional_cli_is_a_structured_failure(self) -> None: + with mock.patch.object(self.check, "_find", return_value=None): + result = self.check.run() + self.assertFalse(result["success"]) + self.assertEqual(result["error"]["code"], "EXECUTION.DEPENDENCY") + + def test_nonzero_version_exit_is_reported(self) -> None: + with mock.patch.object( + self.check.subprocess, + "run", + return_value=subprocess.CompletedProcess(["tool"], 7, "", "broken version"), + ): + version, detail = self.check._run_version(Path("/tool")) + self.assertIsNone(version) + self.assertEqual(detail, "broken version") + + +class ExternalCliPolicyTests(unittest.TestCase): + def setUp(self) -> None: + self.module = load("orchestration_test_cli", ".claude/lib/orchestration_test_cli.py") + + def test_present_cli_returns(self) -> None: + with mock.patch.object(self.module.shutil, "which", return_value="/tool"): + self.module.require_dev_cli("tool", ">= 1", "install tool") + + def test_missing_cli_skips_in_ci(self) -> None: + with mock.patch.object(self.module.shutil, "which", return_value=None), \ + mock.patch.object(self.module.os, "getenv", return_value="1"), \ + mock.patch.object(self.module.pytest, "skip") as skip: + self.module.require_dev_cli("tool", ">= 1", "install tool") + skip.assert_called_once_with("dev-host-only check: tool not installed in CI") + + def test_missing_cli_fails_on_development_host(self) -> None: + with mock.patch.object(self.module.shutil, "which", return_value=None), \ + mock.patch.object(self.module.os, "getenv", return_value=None), \ + mock.patch.object(self.module.pytest, "fail") as fail: + self.module.require_dev_cli("tool", ">= 1", "install tool") + fail.assert_called_once_with("tool CLI is required (>= 1); install it with: install tool") + + +class RosterCheckTests(unittest.TestCase): + def setUp(self) -> None: + self.module = load("roster_check_test", ".claude/skills/team-lead/scripts/roster_check.py") + self.config = {"team-lead": "lead-pane", "quality-mgr": "qa-pane", "publisher": "pub-pane", "clint": None} + self.roster = [ + {"name": "team-lead", "alias": "lead-pane", "backend": "herdr"}, + {"name": "quality-mgr", "alias": "qa-pane", "backend": "herdr"}, + {"name": "publisher", "alias": "pub-pane", "backend": "herdr"}, + {"name": "clint", "alias": None, "backend": "herdr"}, + ] + + def test_alias_and_unnamed_or_mismatched_herdr_agents_are_detected(self) -> None: + missing_alias = self.module.find_problems(self.config, self.roster, ["lead-pane", "qa-pane", "clint"]) + self.assertIn("publisher: no live Herdr agent named 'pub-pane'", missing_alias) + unnamed = self.module.find_problems(self.config, self.roster, ["lead-pane", "qa-pane", "pub-pane"]) + self.assertIn("clint: no live Herdr agent named 'clint'", unnamed) + mismatch = self.module.find_problems(self.config, self.roster, ["lead-pane", "qa-pane", "pub-pane", "wrong-clint"]) + self.assertIn("clint: no live Herdr agent named 'clint'", mismatch) + + def test_missing_pane_and_nonzero_command_are_reported_without_daemons(self) -> None: + problems = self.module.find_problems({k: v for k, v in self.config.items() if k != "clint"}, self.roster, ["lead-pane", "qa-pane", "pub-pane", "clint"]) + self.assertIn("clint: in the roster but has no pane in .atm.toml", problems) + with mock.patch.object(self.module.subprocess, "run", return_value=subprocess.CompletedProcess(["atm"], 1, "", "offline")): + with self.assertRaisesRegex(RuntimeError, "offline"): + self.module.run_json(["atm", "members"]) + + +class FindingScriptTests(unittest.TestCase): + def test_validator_rejects_missing_and_malformed_inputs(self) -> None: + try: + validator = load("validate_findings_test", ".claude/skills/graph-orchestration/scripts/validate-findings.py") + except (ModuleNotFoundError, SystemExit) as error: + self.skipTest(str(error)) + if getattr(validator, "_RDFLIB_ERROR", None): + self.skipTest(f"rdflib unavailable: {validator._RDFLIB_ERROR}") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + missing = validator.run_validation(findings_dir=root / "missing") + self.assertEqual(missing.kind, "error") + findings = root / "findings" + findings.mkdir() + (findings / "broken.ttl").write_text("not Turtle [", encoding="utf-8") + malformed = validator.run_validation(findings_dir=findings) + self.assertEqual(malformed.kind, "error") + self.assertIn("malformed Turtle", malformed.diagnostics[0]) + + def test_query_selection_handles_empty_and_unknown_sprint(self) -> None: + try: + query = load("query_open_findings_test", ".claude/skills/closing-triage/scripts/query_open_findings.py") + except (ModuleNotFoundError, SystemExit) as error: + self.skipTest(str(error)) + if getattr(query, "_RDFLIB_ERROR", None): + self.skipTest(f"rdflib unavailable: {query._RDFLIB_ERROR}") + self.assertIsNone(query.choose_integration_candidate([], "SPX")) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + phase_dir = root / ".sprints" / "SPX" + phase_dir.mkdir(parents=True) + (phase_dir / "structure.ttl").write_text( + "@prefix triage: .\ntriage:S1 a triage:Sprint ; triage:inPhase triage:SPX ; triage:branch \"feature/s1\" .\n", + encoding="utf-8", + ) + with self.assertRaisesRegex(query.QueryError, "sprint 'S9'"): + query._sprint_for_branch(root, "fix/s9", "SPX", "S9") + + +class TemplateContractTests(unittest.TestCase): + TEMPLATE_DIRS = ( + REPO / ".claude/skills/codex-orchestration", + REPO / ".claude/assets/sc-rust/quality-mgr/templates", + ) + EXPECTED_JSON_KEYS = { + "arch-qa-assignment.json.j2": {"authoritative_sprint_doc", "branch", "carry_forward_findings", "changed_files", "commit", "notes", "reference_docs", "review_mode", "review_targets", "round_limit", "scope", "triage_records", "worktree_path"}, + "flaky-test-qa-assignment.json.j2": {"carry_forward_findings", "changed_files", "notes", "review_targets", "round_limit", "scope", "triage_records", "worktree_path"}, + "req-qa-assignment.json.j2": {"authoritative_sprint_doc", "branch", "carry_forward_findings", "changed_files", "commit", "notes", "phase_or_sprint_docs", "phase_sprint_documents", "review_targets", "round_limit", "scope", "triage_records", "worktree_path"}, + "ruthless-boundary-qa-assignment.json.j2": {"review_mode", "worktree_path", "review_targets", "reference_docs", "round_limit", "changed_files", "duplicate_sweep_symbols", "triage_records", "carry_forward_findings", "findings_scope_locked", "notes"}, + } + AGENT_CONTRACTS = { + "flaky-test-qa-assignment.json.j2": REPO / ".claude/agents/flaky-test-qa.md", + "ruthless-boundary-qa-assignment.json.j2": REPO / ".claude/agents/ruthless-boundary-qa.md", + "rust-best-practices-assignment.json.j2": REPO / ".claude/agents/rust-best-practices-agent.md", + "rust-qa-assignment.json.j2": REPO / ".claude/agents/rust-qa-agent.md", + "rust-service-hardening-assignment.json.j2": REPO / ".claude/agents/rust-service-hardening-agent.md", + } + + @staticmethod + def fenced_json_keys(agent: Path) -> set[str]: + match = re.search(r"```json\s*\n(.*?)\n```", agent.read_text(encoding="utf-8"), re.S) + if match is None: + raise AssertionError(f"missing fenced JSON input contract: {agent}") + return set(re.findall(r'^ "([^"]+)"\s*:', match.group(1), re.M)) + + @staticmethod + def jinja_parts(template: Path) -> tuple[dict[str, object], str]: + """Return ATM defaults and body for the Jinja CI-only fallback.""" + content = template.read_text(encoding="utf-8") + if content.startswith("---\n"): + _, front_matter, content = content.split("---\n", 2) + import yaml + + metadata = yaml.safe_load(front_matter) + return metadata.get("defaults", {}), content + return {}, content + + def compose(self, template: Path, sample: Path) -> str: + """Use ATM when available; otherwise validate Jinja syntax and variables. + + ATM's JSON rendering semantics are authoritative. The Jinja fallback + deliberately validates template syntax with autoescape disabled and + StrictUndefined enabled, so a minimal CI image still catches missing + variables rather than silently skipping this suite. + """ + if shutil.which("atm"): + result = subprocess.run( + ["atm", "compose", "--template", str(template), "--vars", str(sample)], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + return result.stdout + + from jinja2 import Environment, StrictUndefined + + defaults, body = self.jinja_parts(template) + variables = {**defaults, **json.loads(sample.read_text(encoding="utf-8"))} + renderer = Environment(autoescape=False, undefined=StrictUndefined) + return renderer.from_string(body).render(**variables) + + def test_every_orchestration_template_has_a_sample_and_composes(self) -> None: + templates = [ + template + for directory in self.TEMPLATE_DIRS + for template in sorted(directory.glob("*.j2")) + ] + for template in templates: + with self.subTest(template=template.name): + sample_name = template.name.removesuffix(".j2") + if not sample_name.endswith(".json"): + sample_name += ".json" + sample = template.parent / "vars" / sample_name + self.assertTrue(sample.is_file(), f"missing committed sample vars: {sample}") + rendered = self.compose(template, sample) + if shutil.which("atm") is None: + continue + if template.name in self.AGENT_CONTRACTS: + payload = json.loads(rendered) + self.assertEqual(set(payload), self.fenced_json_keys(self.AGENT_CONTRACTS[template.name])) + elif template.name in self.EXPECTED_JSON_KEYS: + payload = json.loads(rendered) + self.assertEqual(set(payload), self.EXPECTED_JSON_KEYS[template.name]) + + def test_missing_sample_var_fails_composition(self) -> None: + template = self.TEMPLATE_DIRS[0] / "ruthless-boundary-qa-assignment.json.j2" + with tempfile.TemporaryDirectory() as directory: + bad_vars = Path(directory) / "bad.json" + bad_vars.write_text('{"review_mode":"sprint","worktree_path":"/tmp"}', encoding="utf-8") + if shutil.which("atm"): + result = subprocess.run(["atm", "compose", "--template", str(template), "--vars", str(bad_vars)], capture_output=True, text=True, check=False) + self.assertNotEqual(result.returncode, 0) + else: + with self.assertRaisesRegex(Exception, "undefined|Undefined"): + self.compose(template, bad_vars) diff --git a/docs/plans/orchestration/lint-spx.13-orchestration-script-tests.md b/docs/plans/orchestration/lint-spx.13-orchestration-script-tests.md new file mode 100644 index 00000000..4e0d0070 --- /dev/null +++ b/docs/plans/orchestration/lint-spx.13-orchestration-script-tests.md @@ -0,0 +1,83 @@ +--- +sprint: lint-spx.13 +bead: lint-spx.13 +epic: lint-spx +status: complete +branch: chore/orchestration-script-tests +worktree: /Users/randlee/github/sc-lint-worktrees/chore/orchestration-script-tests +pr_target: chore/orchestration-beads-lifecycle +closure_type: contract +--- + +# lint-spx.13 — Tests for ported orchestration scripts; template/caller contract gaps + +Layer 3 of the orchestration stack (on PR #167). Skill and agent files are +executable instructions: every script they call must work and be tested, and +every template must accept exactly what its caller is told to pass. + +## Deliverables + +1. Port the atm-core `origin/develop` tests for the scripts ported in layer 1 + and adapt them to sc-lint: + - `.claude/skills/closing-triage/scripts/test_query_open_findings.py` + - `.claude/skills/graph-orchestration/scripts/test_validate_findings.py` + - `.claude/skills/triaging-findings/tests/test_check_dependencies.py` + - `.just/tests/test_team_lead_roster_check.py` (place it where sc-lint's + pytest gate discovers it) + - new tests for `.claude/lib/sc_compose_dependency.py` +2. Wire every one of those tests into the gate that `just test` / `just lint` + already runs (`sc_lint.run_pytests` or the Justfile pytest recipe). A test + that exists but is not executed by the gate does not count. Show the gate + output line that proves each file ran. +3. Corner cases, each with a test, for every script: missing input file, + empty input, malformed JSON/TOML/Turtle, unknown finding id or phase, + duplicate ids, missing optional dependency (`sc-compose`, `herdr`, `atm` + not on PATH), non-zero exit codes and their messages, and for + `roster_check.py`: alias present vs absent, Herdr agent unnamed (the state + observed on 2026-09-19 after pane restarts), Herdr name not matching the + alias, roster member missing from `.atm.toml`. Tests must not require live + Herdr/ATM daemons: inject command output. +4. Template/caller contract gaps: + - `ruthless-boundary-qa` agent exists but + `codex-orchestration/ruthless-boundary-qa-assignment.json.j2` (+ sample + vars) does not; port it, or remove the agent from every reviewer list. + One or the other, consistently. + - Two rust-best-practices assignment templates exist + (`codex-orchestration/rust-best-practices-agent-assignment.json.j2` and + `.claude/assets/sc-rust/quality-mgr/templates/rust-best-practices-assignment.json.j2`). + Keep one; every caller names that one. + - Add a test that composes **every** `.j2` under `.claude/skills/` and + `.claude/assets/sc-rust/quality-mgr/templates/` with its committed sample + vars, asserts success, and for `format: json` templates asserts the + output parses and that its top-level keys equal the input keys the + receiving agent's prompt declares in its fenced-JSON contract. Skip with + an explicit reason (not silently) when `atm` is not on PATH. +5. EOF whitespace: `git diff --check develop..HEAD` is clean (currently flags + `.claude/lib/__init__.py`, `.claude/lib/sc_compose_dependency.py`, + `closing-triage/scripts/query_open_findings.py`). + +## Acceptance criteria + +- `just lint` and `just test` pass and visibly execute the new tests. +- Every script under `.claude/skills/**/scripts/` and `.claude/lib/` has a + test file; each corner case in Deliverable 3 maps to a named test. +- The compose-all-templates test passes and fails when a sample var is + removed (demonstrate once in the close report). +- No reviewer list names an agent without an assignment template, and no + template is unreferenced. +- This doc's frontmatter is `status: complete`; bead `lint-spx.13` claimed and + closed in tandem with the ATM task. + +## Gate behavior + +`just lint` and `just test` invoke `sc_lint.run_pytests`, which runs the +repository unittest suite and then the six ported pytest files explicitly. +Template composition uses `atm compose` when `atm` is on `PATH`; a CI image +without it instead performs a named Jinja2 syntax/`StrictUndefined` check with +autoescape disabled. The canonical triage-record test names and reports its +`sc-compose` Python-binding skip when that optional binding is unavailable. + +## This sprint does not close + +- plan-hardening templates (bead `lint-spx.12`). +- Any Rust source change. diff --git a/docs/plans/orchestration/lint-spx.24-orchestration-verification-fixes.md b/docs/plans/orchestration/lint-spx.24-orchestration-verification-fixes.md new file mode 100644 index 00000000..166a63b4 --- /dev/null +++ b/docs/plans/orchestration/lint-spx.24-orchestration-verification-fixes.md @@ -0,0 +1,96 @@ +--- +sprint: lint-spx.24 +bead: lint-spx.24 +epic: lint-spx +status: complete +branch: chore/orchestration-verification-fixes +worktree: /Users/randlee/github/sc-lint-worktrees/chore/orchestration-verification-fixes +pr_target: chore/orchestration-script-tests +closure_type: contract +adrs: [] +requirements: [] +--- + +# lint-spx.24 — Orchestration stack: mechanics defects from verification lint-spx.6 + +Layer 4 of stack #172 (#166 <- #167 <- #170), stacked on PR #170 @ ee1bee9. +Source: quality-mgr targeted verification `lint-spx.6`, questions Q1-Q5. +Full report: `atm read --task lint-spx.6 --all`. Every file:line below comes +from that report; re-verify each before editing. + +No ADR or requirement governs the orchestration prompts, so `adrs` and +`requirements` are empty by design. + +## Deliverables + +1. **Q2** — test_orchestration_contracts.py:208-209 skips every key comparison +when atm is absent and CI has no atm -> render with Jinja and compare keys +always; derive req-qa/arch-qa keys from agent docs; delete dead +EXPECTED_JSON_KEYS. +2. **Q1** — triage-record.ttl.j2 has no sample vars and is not +in TEMPLATE_DIRS; task_id supplied-but-ignored in 4 templates; unused notes +(rust-qa) and lead (review-template); SKILL.md templates list omits +ruthless-boundary-qa-assignment and sprint-plan. +3. **Q5** — 'atm gh' does not exist +(quality-mgr.md:182-187); schema-reviewer agent does not exist but is +mandatory (quality-mgr.md:257,272,291) and cites atm-core ADR-061; --watch +used (SKILL.md:278, quality-mgr.md:185) but forbidden by qa-template step j; +'CLAUDE.md section 0' does not exist (SKILL.md:205,209); queue-parallelism +contradiction (qa-template a1 vs review-template d vs quality-mgr.md:54); +atm send --template vs atm task close --template for verdicts (quality- +mgr.md:347 vs :72,200,306); review-template lacks bd claim/close and refused +path; QA-FAIL bead outcome unspecified in templates; AGENTS.md duplicates +its Beads section (72,127); rust-best-practices-agent listed twice in +SKILL.md. +4. **Q3** — validate-findings.py empty dir exits 0 silently; +roster_check.py malformed .atm.toml message lacks file path and missing atm +gives raw Errno; check_dependencies.py ignores argv; tests for each. +Overlaps lint-spx.23 where the line is atm-core residue: .23 owns reviewer +content, this bead owns mechanics. + + + +PARENT +↑ ○ lint-spx: (EPIC) Release 0.6.0 (Phase G adoption kit + boundary fixes + beads-driven orchestration) P1 + +BLOCKS +← ○ lint-spx.7: Merge orchestration stack to develop P2 + +Where two documents contradict each other, the rule is: templates are +authoritative for task steps, `SKILL.md` for the contract, agent files for +reviewer behaviour. Fix the non-authoritative side. For the QA queue +contradiction the intended behaviour is: QA tasks for different stacks run in +parallel; tasks on the same stack run in order. For verdict delivery the +intended mechanism is `atm task close --template`. For CI waiting, `--watch` +is forbidden (qa-template step j is right). + +## Acceptance criteria + +- The contract test compares rendered keys to agent-documented keys with and + without `atm` on PATH (prove it: run once with `PATH=/usr/bin:/bin`). +- `grep -rn "atm gh\|schema-reviewer\|ADR-061\|CLAUDE.md §0" .claude` returns nothing. +- Every script corner case listed under Q3 has a test, and the test fails + against the pre-fix script. +- `just lint` and `just test` pass; `git diff --check origin/develop..HEAD` clean. +- CLAUDE.md and AGENTS.md stay mirrored. +- Frontmatter `status: complete` with a `## Closeout`; bead `lint-spx.24` + claimed with task start and closed with task close. + +## Out of scope + +- Reviewer rule content, atm-core exemptions and examples, ADR/REQ + enforcement design (`lint-spx.23`). If a line is both a mechanics defect and + atm-core residue (e.g. `schema-reviewer`), delete it here and note it in the + closeout. +- Documentation of ADRs and requirements (`lint-spx.25`). + +## Closeout + +Completed in the restacked layer: empty-findings validator regression coverage, +actionable roster-check diagnostics, and the Windows-safe malformed-TOML +fixture. The latter fixes the Windows CI failure where an open +`NamedTemporaryFile` produced `Permission denied` rather than TOML parsing. + +Cut to `lint-spx.33`: remaining contract-test expansion, dead-command removal, +template/skill contradiction cleanup, and the rest of the verification-script +hardening scope from this plan. diff --git a/docs/project-plan.md b/docs/project-plan.md index 13d1f566..6b126a81 100644 --- a/docs/project-plan.md +++ b/docs/project-plan.md @@ -137,6 +137,10 @@ The scheduled sprint plans are: - Assignee-owned Beads lifecycle for dev, fix, and QA task assignment - `docs/plans/orchestration/lint-spx.5-orchestration-beads-lifecycle.md` +- `lint-spx.13` + - Orchestration script tests and template/caller contract coverage + - `docs/plans/orchestration/lint-spx.13-orchestration-script-tests.md` + - `A.1a` - CLI bootstrap and contract definition - includes the A.1a exit-review checkpoint for Workstreams 4-7