From 87715a987f25e29874119327b69beec781995a22 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Mon, 28 Sep 2026 22:43:02 +0000 Subject: [PATCH 001/129] governance: shared agent rules v2 with checkable rules and versioned drift check The shared block now states every rule with its enforcement (gate, template or named decider), covering sources of record, contracts, tests, evidence, dependencies, safety, publication, agents, failure and learning. Internal document links and personal names are removed from AGENTS.md. check_agent_rules.py reads the marker version from the canonical file so repositories on v1 fail with a version message. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- .gitignore | 2 + AGENTS.md | 149 +++++++++++++++++++------------- agent-rules/SHARED_RULES.md | 123 ++++++++++++++++---------- tests/test_check_agent_rules.py | 68 +++++++++++++++ tools/check_agent_rules.py | 53 ++++++++---- 5 files changed, 275 insertions(+), 120 deletions(-) create mode 100644 .gitignore create mode 100644 tests/test_check_agent_rules.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84d8c1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.verification/ +__pycache__/ diff --git a/AGENTS.md b/AGENTS.md index 482413c..58ba656 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,61 +1,92 @@ - -# OpenAMRobot agent rules -Canonical shared block: openAMRobot/.github, agent-rules/SHARED_RULES.md. -This block is copied verbatim; GitHub does not propagate it between repositories. - -## Before editing -- Read AGENTS.md, CONTRIBUTING.md, applicable nested instructions, code and tests. -- Record the base SHA; inspect relevant open PRs and accessible branches/forks for overlap. -- Do not infer contributor inactivity from absent public branches; disclose inaccessible work. -- Follow the approved task scope and applicable plan/contracts. Report contradictions. -- Reuse maintained upstream packages and existing implementation; minimize custom glue. -- Do not replace working legacy support merely because the new-robot BOM excludes it. - -## Boundaries -- Shared ROS contracts belong in openamrobot-interfaces; identify their actual acceptance status. -- New contract proposals stay isolated and labelled Proposed, pending owner review. -- Do not author or modify safety implementation: E-stop, brakes, motion interlocks, - watchdogs, actuator enable or power-protection logic. Report required changes. -- Status display and isolated test fixtures do not implement or validate physical safety. -- Arm vendor SDKs stay behind Device Packages; none in UI or mission consumers. -- Preserve Gate A Teensy/MPU6500 and Gate B STM32/ICM-42688-P distinctions. -- Jetson is the 2.0 reference compute; retain correctly labelled historical material. -- Public application name: Use_Case_1. No customer/partner names, secrets or private data. -- Preserve third-party provenance. Do not change licensing, NOTICE or CODEOWNERS - without an explicit task that authorizes those files and the appropriate review. -- Never connect untrusted/automated PR tests to physical motion hardware or secrets. - -## Delivery -- Use a contributor branch/fork and draft PR by default. Never merge, force-push, - modify protection/settings or bypass checks in ordinary implementation tasks. -- Read applicable CLA/DCO rules. Never invent an exemption, identity or attestation. -- Use git commit -s only with the verified contributor identity and provenance authority. -- Disclose material AI assistance, dependencies and licence implications. -- Report base/head SHAs, scope, safety impact, exact commands/results and evidence links. -- For bug fixes show the regression fails before and passes after; for new features - demonstrate a meaningful deliberate fault is detected. Explain non-applicability. -- Keep a Not verified section. SKIP/BLOCKED is not PASS; fixtures/fake hardware are - not integrated simulation, physical acceptance or release readiness. -- Do not weaken checks, use empty suites as evidence or invent successful test results. -- If blocked, stop the blocked activity, report command/error/next step and continue - independent in-scope work. Do not repeatedly reinstall or expand the architecture. -- Owner alignment and approval status must be truthful. A draft or notification is - not evidence that a required discussion or technical acceptance has happened. - + +# OpenAMRobot rules for contributors and agents +Canonical: openAMRobot/.github, agent-rules/SHARED_RULES.md. Copied verbatim into every +repository's AGENTS.md; tools/check_agent_rules.py fails on drift. Each rule names how it is +enforced: [gate: tool], [template: file] or [decides: role]. Roles resolve in maintainers.yaml. + +## Sources of record +- Decided values live only in openAMRobot/.github decisions.yaml. No file states a value that + contradicts it; kept history carries `decision-allow: `. [gate: check_decisions.py] +- A decision changes in its source document first, then in decisions.yaml and every flagged + file in one PR. [decides: the decision's owner] +- Read STATE.md before work in a repository; update it in the same PR. [gate: check_pr_evidence.py] + +## Contracts +- Messages, services, actions, schemas, topic names, launch argument names and configuration + IDs are contracts. They change only through a contract change request issue and one PR that + updates the contract package and its consumers together. [template: contract change request] + [decides: software lead] +- New contract proposals stay labelled Proposed until the owner accepts them. [decides: software lead] + +## Tests +- Every behaviour change carries a test that fails when the change is reverted; the PR shows + that failing run. [gate: check_pr_evidence.py] [decides: reviewer] +- A suite that executes zero tests fails. [gate: verify.sh, check_pr_evidence.py] +- skip, xfail and importorskip name a tracking issue on the same line. [gate: verify.sh] +- SKIP or BLOCKED is not PASS. Fixtures and fake hardware are not simulation, physical + acceptance or release readiness. [template: PR Not verified section] + +## Evidence +- Every PR states base SHA, head SHA, exact commands, test counts and a Not verified section. + [gate: check_pr_evidence.py] [template: .github/PULL_REQUEST_TEMPLATE.md] +- A draft becomes ready only when the evidence check passes on the current head. [gate: check_pr_evidence.py] + +## Dependencies and licences +- Nothing is added, removed or upgraded as a side effect. A changed dependency manifest needs + a Dependencies section naming each change, its licence and source. [gate: check_pr_evidence.py] +- File headers and package.xml licence tags match the repository licence map: MIT software and + firmware, CERN-OHL-P-2.0 hardware, CC-BY-4.0 documentation. [decides: repository owner] +- The Integration Gate section lists overlapping open PRs, what existing or upstream work was + reused and what was rejected. Third-party provenance stays intact. [template: PR template] +- LICENSE, LICENSING.md, NOTICE and CODEOWNERS change only in a PR whose task names them. + [decides: platform lead] + +## Safety +- No agent authors or modifies E-stop, brake, contactor, watchdog, motor-enable or + charge-inhibit logic. Agents report the needed change in an issue. [gate: check_pr_evidence.py] +- A change to those paths needs two human reviewers including the platform lead. + [gate: check_pr_evidence.py] +- Functional telemetry, status displays and fixtures are never presented as safety evidence. + [decides: platform lead] +- Automated or untrusted PR jobs never reach motion hardware or secrets. [decides: CI owner] + +## Publication +- Public material (docs/, assets/, README.md, any path containing "public") has no internal + document links, prices, contact data or credentials. [gate: check_public_extract.py] +- It names no private person, customer or partner; the public application name is Use_Case_1. + [decides: docs owner] + +## Agents +- Before any write, state a precondition block: repository, branch, parent SHA, expected + outcome. [template: agent-prompts/] +- Read-only unless the task says otherwise. [template: agent-prompts/] +- Agent PRs stay draft until the work-package owner writes adopt, adapt or reject in the PR + thread. [decides: work-package owner] +- The PR's AI disclosure section names the tool and what it produced. [gate: check_pr_evidence.py] +- Commits carry DCO sign-off with the contributor's own identity; never invent an identity, + exemption or attestation. [gate: DCO check] +- Gate A Teensy/MPU6500 and Gate B STM32/ICM-42688-P stay distinct; Jetson is the 2.0 + compute. [gate: check_decisions.py] Arm vendor SDKs stay behind device packages. [decides: software lead] + +## Failure +- A failed precondition (repository, branch, SHA, access, source) stops the task. Report the + command, the error and the next step; never work around it. [template: agent-prompts/] +- Never merge, force-push, change settings, weaken a check or invent a result. [gate: branch protection] + +## Learning +- Every agent or process mistake gets an issue labelled harness. [template: harness mistake form] +- The monthly retro turns harness issues into one PR against this block. [decides: software lead] + # Repository-specific rules: .github -- This repository owns shared policy and reusable workflow implementations. -- agent-rules/SHARED_RULES.md is the canonical marked block; root AGENTS.md also embeds it. -- Keep repository additions small and versioned. Preserve existing governance rules. -- Proposed checker: python3 tools/check_agent_rules.py --canonical agent-rules/SHARED_RULES.md --root /path/to/workspace -- Explicit file checks: python3 tools/check_agent_rules.py --canonical agent-rules/SHARED_RULES.md --file /path/to/repo/AGENTS.md -- This checker does not fetch repositories; the caller supplies the intended checkouts. -- Arumuga owns CI rollout. Do not claim this local checker is already deployed org-wide. - -## Canonical context -- [Plans](https://drive.google.com/drive/folders/15zWoBPd6qSt96TToWakN9rhfjNyq-hoz) -- [D-02 AI framework](https://drive.google.com/file/d/1Drs4tKbxAo6jsRlaCRK1eAMkzds7NTx-/view) -- [D-03 consistency](https://drive.google.com/file/d/1jbELSAeWRxuxB-IO-s7QlSlWK38WemQA/view) -- [Interfaces](https://github.com/openAMRobot/openamrobot-interfaces) -- [Contribution rules](https://github.com/openAMRobot/.github/blob/main/CONTRIBUTING.md) -Read relevant sources; if inaccessible, use an approved supplied excerpt and disclose limits. +- This repository owns shared policy, decisions.yaml, maintainers.yaml, the checkers under + tools/ and the reusable workflows. agent-rules/SHARED_RULES.md is the canonical block. +- Run before every PR: `bash rollout/verify.sh` (unit tests of every checker, zero-test guard), + `python3 tools/check_decisions.py --decisions decisions.yaml --maintainers maintainers.yaml --validate-only` + and `python3 tools/check_agent_rules.py --canonical agent-rules/SHARED_RULES.md --file AGENTS.md`. +- Drift across repositories: `python3 tools/check_agent_rules.py --canonical agent-rules/SHARED_RULES.md --root `. + The checker does not fetch repositories; the caller supplies the checkouts. +- A change to the shared block bumps its marker version; rollout/README.md lists the order in + which repositories adopt it. [decides: CI owner] +- Files under rollout/ are proposals for other repositories; they take effect only when that + repository's owner merges them. Do not claim rollout status that has not happened. diff --git a/agent-rules/SHARED_RULES.md b/agent-rules/SHARED_RULES.md index 5c4b862..95dfe09 100644 --- a/agent-rules/SHARED_RULES.md +++ b/agent-rules/SHARED_RULES.md @@ -1,44 +1,79 @@ - -# OpenAMRobot agent rules -Canonical shared block: openAMRobot/.github, agent-rules/SHARED_RULES.md. -This block is copied verbatim; GitHub does not propagate it between repositories. - -## Before editing -- Read AGENTS.md, CONTRIBUTING.md, applicable nested instructions, code and tests. -- Record the base SHA; inspect relevant open PRs and accessible branches/forks for overlap. -- Do not infer contributor inactivity from absent public branches; disclose inaccessible work. -- Follow the approved task scope and applicable plan/contracts. Report contradictions. -- Reuse maintained upstream packages and existing implementation; minimize custom glue. -- Do not replace working legacy support merely because the new-robot BOM excludes it. - -## Boundaries -- Shared ROS contracts belong in openamrobot-interfaces; identify their actual acceptance status. -- New contract proposals stay isolated and labelled Proposed, pending owner review. -- Do not author or modify safety implementation: E-stop, brakes, motion interlocks, - watchdogs, actuator enable or power-protection logic. Report required changes. -- Status display and isolated test fixtures do not implement or validate physical safety. -- Arm vendor SDKs stay behind Device Packages; none in UI or mission consumers. -- Preserve Gate A Teensy/MPU6500 and Gate B STM32/ICM-42688-P distinctions. -- Jetson is the 2.0 reference compute; retain correctly labelled historical material. -- Public application name: Use_Case_1. No customer/partner names, secrets or private data. -- Preserve third-party provenance. Do not change licensing, NOTICE or CODEOWNERS - without an explicit task that authorizes those files and the appropriate review. -- Never connect untrusted/automated PR tests to physical motion hardware or secrets. - -## Delivery -- Use a contributor branch/fork and draft PR by default. Never merge, force-push, - modify protection/settings or bypass checks in ordinary implementation tasks. -- Read applicable CLA/DCO rules. Never invent an exemption, identity or attestation. -- Use git commit -s only with the verified contributor identity and provenance authority. -- Disclose material AI assistance, dependencies and licence implications. -- Report base/head SHAs, scope, safety impact, exact commands/results and evidence links. -- For bug fixes show the regression fails before and passes after; for new features - demonstrate a meaningful deliberate fault is detected. Explain non-applicability. -- Keep a Not verified section. SKIP/BLOCKED is not PASS; fixtures/fake hardware are - not integrated simulation, physical acceptance or release readiness. -- Do not weaken checks, use empty suites as evidence or invent successful test results. -- If blocked, stop the blocked activity, report command/error/next step and continue - independent in-scope work. Do not repeatedly reinstall or expand the architecture. -- Owner alignment and approval status must be truthful. A draft or notification is - not evidence that a required discussion or technical acceptance has happened. - + +# OpenAMRobot rules for contributors and agents +Canonical: openAMRobot/.github, agent-rules/SHARED_RULES.md. Copied verbatim into every +repository's AGENTS.md; tools/check_agent_rules.py fails on drift. Each rule names how it is +enforced: [gate: tool], [template: file] or [decides: role]. Roles resolve in maintainers.yaml. + +## Sources of record +- Decided values live only in openAMRobot/.github decisions.yaml. No file states a value that + contradicts it; kept history carries `decision-allow: `. [gate: check_decisions.py] +- A decision changes in its source document first, then in decisions.yaml and every flagged + file in one PR. [decides: the decision's owner] +- Read STATE.md before work in a repository; update it in the same PR. [gate: check_pr_evidence.py] + +## Contracts +- Messages, services, actions, schemas, topic names, launch argument names and configuration + IDs are contracts. They change only through a contract change request issue and one PR that + updates the contract package and its consumers together. [template: contract change request] + [decides: software lead] +- New contract proposals stay labelled Proposed until the owner accepts them. [decides: software lead] + +## Tests +- Every behaviour change carries a test that fails when the change is reverted; the PR shows + that failing run. [gate: check_pr_evidence.py] [decides: reviewer] +- A suite that executes zero tests fails. [gate: verify.sh, check_pr_evidence.py] +- skip, xfail and importorskip name a tracking issue on the same line. [gate: verify.sh] +- SKIP or BLOCKED is not PASS. Fixtures and fake hardware are not simulation, physical + acceptance or release readiness. [template: PR Not verified section] + +## Evidence +- Every PR states base SHA, head SHA, exact commands, test counts and a Not verified section. + [gate: check_pr_evidence.py] [template: .github/PULL_REQUEST_TEMPLATE.md] +- A draft becomes ready only when the evidence check passes on the current head. [gate: check_pr_evidence.py] + +## Dependencies and licences +- Nothing is added, removed or upgraded as a side effect. A changed dependency manifest needs + a Dependencies section naming each change, its licence and source. [gate: check_pr_evidence.py] +- File headers and package.xml licence tags match the repository licence map: MIT software and + firmware, CERN-OHL-P-2.0 hardware, CC-BY-4.0 documentation. [decides: repository owner] +- The Integration Gate section lists overlapping open PRs, what existing or upstream work was + reused and what was rejected. Third-party provenance stays intact. [template: PR template] +- LICENSE, LICENSING.md, NOTICE and CODEOWNERS change only in a PR whose task names them. + [decides: platform lead] + +## Safety +- No agent authors or modifies E-stop, brake, contactor, watchdog, motor-enable or + charge-inhibit logic. Agents report the needed change in an issue. [gate: check_pr_evidence.py] +- A change to those paths needs two human reviewers including the platform lead. + [gate: check_pr_evidence.py] +- Functional telemetry, status displays and fixtures are never presented as safety evidence. + [decides: platform lead] +- Automated or untrusted PR jobs never reach motion hardware or secrets. [decides: CI owner] + +## Publication +- Public material (docs/, assets/, README.md, any path containing "public") has no internal + document links, prices, contact data or credentials. [gate: check_public_extract.py] +- It names no private person, customer or partner; the public application name is Use_Case_1. + [decides: docs owner] + +## Agents +- Before any write, state a precondition block: repository, branch, parent SHA, expected + outcome. [template: agent-prompts/] +- Read-only unless the task says otherwise. [template: agent-prompts/] +- Agent PRs stay draft until the work-package owner writes adopt, adapt or reject in the PR + thread. [decides: work-package owner] +- The PR's AI disclosure section names the tool and what it produced. [gate: check_pr_evidence.py] +- Commits carry DCO sign-off with the contributor's own identity; never invent an identity, + exemption or attestation. [gate: DCO check] +- Gate A Teensy/MPU6500 and Gate B STM32/ICM-42688-P stay distinct; Jetson is the 2.0 + compute. [gate: check_decisions.py] Arm vendor SDKs stay behind device packages. [decides: software lead] + +## Failure +- A failed precondition (repository, branch, SHA, access, source) stops the task. Report the + command, the error and the next step; never work around it. [template: agent-prompts/] +- Never merge, force-push, change settings, weaken a check or invent a result. [gate: branch protection] + +## Learning +- Every agent or process mistake gets an issue labelled harness. [template: harness mistake form] +- The monthly retro turns harness issues into one PR against this block. [decides: software lead] + diff --git a/tests/test_check_agent_rules.py b/tests/test_check_agent_rules.py new file mode 100644 index 0000000..3fbdeab --- /dev/null +++ b/tests/test_check_agent_rules.py @@ -0,0 +1,68 @@ +"""Tests for tools/check_agent_rules.py (shared-block drift check).""" +import contextlib +import io +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import check_agent_rules as car # noqa: E402 + +CANONICAL = ROOT / "agent-rules" / "SHARED_RULES.md" + + +class Drift(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.shared = CANONICAL.read_text(encoding="utf-8") + + def repo(self, name, agents, claude="@AGENTS.md\n"): + d = Path(self.tmp.name, name) + d.mkdir() + (d / "AGENTS.md").write_text(agents, encoding="utf-8") + (d / "CLAUDE.md").write_text(claude, encoding="utf-8") + return d + + def run_main(self, *args): + err = io.StringIO() + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(err): + try: + code = car.main(["--canonical", str(CANONICAL), *map(str, args)]) + except SystemExit as exc: + code = exc.code + return code, err.getvalue() + + def test_identical_block_passes(self): + self.repo("a", self.shared + "\n# Repository-specific rules: a\n") + self.assertEqual(self.run_main("--root", self.tmp.name)[0], 0) + + def test_root_agents_md_passes(self): + self.assertEqual(self.run_main("--file", ROOT / "AGENTS.md")[0], 0) + + def test_edited_block_fails(self): + self.repo("a", self.shared.replace("zero tests fails", "zero tests is fine")) + code, err = self.run_main("--root", self.tmp.name) + self.assertEqual(code, 1) + self.assertIn("shared block differs from canonical", err) + + def test_older_version_fails_with_version_message(self): + old = self.shared.replace("SHARED RULES v2", "SHARED RULES v1") + self.repo("a", old) + self.assertIn("shared block is v1, canonical is v2", self.run_main("--root", self.tmp.name)[1]) + + def test_line_limit_and_claude_import(self): + self.repo("long", self.shared + "x\n" * 120) + self.repo("claude", self.shared, claude="@AGENTS.md\nextra rules\n") + err = self.run_main("--root", self.tmp.name)[1] + self.assertIn("must be under 120 lines", err) + self.assertIn("CLAUDE.md must import @AGENTS.md", err) + + def test_empty_selection_is_refused(self): + self.assertEqual(self.run_main("--root", self.tmp.name)[0], 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/check_agent_rules.py b/tools/check_agent_rules.py index 2c8f1b7..54db75f 100644 --- a/tools/check_agent_rules.py +++ b/tools/check_agent_rules.py @@ -1,26 +1,43 @@ #!/usr/bin/env python3 -"""Offline shared-rule drift check; caller supplies canonical file and checkouts.""" +"""Offline shared-rule drift check; caller supplies canonical file and checkouts. + +The marker version (v1, v2, ...) is read from the canonical file, so a +repository that still carries an older block fails with a version message. +""" import argparse +import re from pathlib import Path import sys -BEGIN = '' -END = '' -def block(path): + +MARKER = re.compile(r'') +MAX_LINES = 120 + + +def block(path, version=None): s = path.read_text(encoding='utf-8') - if s.count(BEGIN) != 1 or s.count(END) != 1: - raise ValueError('expected exactly one v1 begin/end marker') - start, end = s.index(BEGIN), s.index(END) - if end < start: + found = MARKER.findall(s) + versions = {v for _, v in found} + if version and versions and version not in versions: + raise ValueError(f'shared block is {"/".join(sorted(versions))}, canonical is {version}') + if [k for k, _ in found] != ['BEGIN', 'END'] or len(versions) != 1: + raise ValueError('expected exactly one begin/end marker pair of one version') + v = versions.pop() + begin = f'' + end = f'' + start, stop = s.index(begin), s.index(end) + if stop < start: raise ValueError('reversed markers') - return s[start:end + len(END)] -def main(): - p = argparse.ArgumentParser(description=__doc__) + return v, s[start:stop + len(end)] + + +def main(argv=None): + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument('--canonical', required=True, type=Path) p.add_argument('--root', type=Path, help='workspace containing repository directories') p.add_argument('--file', action='append', type=Path, default=[]) - a = p.parse_args() + a = p.parse_args(argv) try: - expected = block(a.canonical) + version, expected = block(a.canonical) except (OSError, ValueError) as e: p.error(f'canonical: {e}') files = list(a.file) @@ -35,16 +52,18 @@ def main(): for path in files: try: text = path.read_text(encoding='utf-8') - if block(path) != expected: + if block(path, version)[1] != expected: raise ValueError('shared block differs from canonical') - if len(text.splitlines()) >= 120: - raise ValueError('AGENTS.md must be under 120 lines') + if len(text.splitlines()) >= MAX_LINES: + raise ValueError(f'AGENTS.md must be under {MAX_LINES} lines') if (path.parent / 'CLAUDE.md').read_text(encoding='utf-8').strip() != '@AGENTS.md': raise ValueError('CLAUDE.md must import @AGENTS.md without duplicate rules') - print(f'PASS {path}') + print(f'PASS {path} ({version})') except (OSError, ValueError) as e: failed = True print(f'FAIL {path}: {e}', file=sys.stderr) return 1 if failed else 0 + + if __name__ == '__main__': sys.exit(main()) From 8081c6eac684bd3e1b0636ccddd2d31732b20cb4 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Mon, 28 Sep 2026 22:43:03 +0000 Subject: [PATCH 002/129] governance: decisions.yaml of record and check_decisions.py decisions.yaml holds decided values with source, supersedes history, applies_to globs, machine checks and an owner role. It is seeded from P-03 rev18.1, P-00 rev18.1 and the BOM as quoted in the 28 September alignment audit and from P-03 rev18.2 as cited by the docs site. maintainers.yaml maps roles to handles, audit prefixes and safety paths. check_decisions.py validates the schema, scans Markdown, YAML, launch, URDF/Xacro, package.xml and README files and reports file, line, found and decided value; tested against a fixture repository. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- decisions.yaml | 452 ++++++++++++++++++ maintainers.yaml | 62 +++ tests/fixtures/decisions.yaml | 45 ++ tests/fixtures/decisions_repo/CHANGELOG.md | 1 + tests/fixtures/decisions_repo/README.md | 4 + .../decisions_repo/config/params.yaml | 1 + tests/fixtures/decisions_repo/docs/history.md | 6 + .../decisions_repo/launch/robot.launch.py | 3 + .../fixtures/decisions_repo/legacy/notes.txt | 1 + tests/fixtures/decisions_repo/package.xml | 6 + .../fixtures/decisions_repo/urdf/robot.xacro | 4 + tests/test_check_decisions.py | 192 ++++++++ tools/check_decisions.py | 242 ++++++++++ 13 files changed, 1019 insertions(+) create mode 100644 decisions.yaml create mode 100644 maintainers.yaml create mode 100644 tests/fixtures/decisions.yaml create mode 100644 tests/fixtures/decisions_repo/CHANGELOG.md create mode 100644 tests/fixtures/decisions_repo/README.md create mode 100644 tests/fixtures/decisions_repo/config/params.yaml create mode 100644 tests/fixtures/decisions_repo/docs/history.md create mode 100644 tests/fixtures/decisions_repo/launch/robot.launch.py create mode 100644 tests/fixtures/decisions_repo/legacy/notes.txt create mode 100644 tests/fixtures/decisions_repo/package.xml create mode 100644 tests/fixtures/decisions_repo/urdf/robot.xacro create mode 100644 tests/test_check_decisions.py create mode 100644 tools/check_decisions.py diff --git a/decisions.yaml b/decisions.yaml new file mode 100644 index 0000000..61f2a05 --- /dev/null +++ b/decisions.yaml @@ -0,0 +1,452 @@ +# OpenAMRobot decisions of record, machine-readable. +# +# This file is the only place in the organization where a decided value is +# written as a rule. Every other file either agrees with it or carries a +# `decision-allow: ` marker (history, changelog, legacy pages). +# tools/check_decisions.py enforces every decision with status "recorded". +# +# Schema (schema_version 1), one entry per decision: +# id unique, upper case, stable +# title one line +# status recorded (enforced) | proposed (not enforced) | open (not decided) +# value/values the decided value or list of values, exactly as the source says +# unit SI unit or none +# date date the decision was taken (null when the source gives none) +# source document (a key under sources) and item +# supersedes earlier values with their source; history, not rules +# applies_to repositories (globs) and files (globs); exclude is optional +# check list of patterns; each has a (?P...) group, optional +# files (globs narrowing this pattern), an optional +# line-level `unless` exemption and a message +# owner a role in maintainers.yaml who decides changes to this entry +# +# To change a decision: change the source document first, then open a PR that +# edits this entry and every file the checker flags. The owner approves. +# +# Seeding basis (29 September 2026): P-03 rev18.1, P-00 rev18.1 and the BOM as +# quoted in the 28 September 2026 alignment audit; P-03 rev18.2 as cited by the +# openamrobot-docs page docs/reference/openamrobot-2/index.md at main e0f2aac. +# The P-03 texts themselves are not in any repository; each rev18.2 value below +# rests on the docs-site citation and needs owner confirmation. +schema_version: 1 + +sources: + P-03-rev18.2: + title: P-03 Decision Addendum, revision 18.2, 28 September 2026 + evidence: cited by openamrobot-docs docs/reference/openamrobot-2/index.md lines 30-39 (main e0f2aac) + P-03-rev18.1: + title: P-03 Decision Addendum, revision 18.1 + evidence: quoted by item and line in the 2026-09-28 alignment audit + P-00-rev18.1: + title: P-00 Master Coordination Plan, revision 18.1 + evidence: quoted by text line in the 2026-09-28 alignment audit + I8-WP: + title: I8 base-controller status work package + evidence: quoted by line in the 2026-09-28 alignment audit + +# Paths never scanned: this file, checker fixtures and change logs. +exclude: + - decisions.yaml + - tests/fixtures/** + - "**/CHANGELOG.md" + - agent-runs.md + +decisions: + - id: BOM-ISSUE-IN-FORCE + title: Canonical hardware BOM issue for OpenAMRobot 2.0 + status: open + value: Issue 6, or an explicitly approved successor + unit: none + date: null + source: {document: P-03-rev18.1, item: line 7} + supersedes: + - {value: B-01 development BOM and evidence register rev18, source: P-03-rev18.1 line 7} + applies_to: {repositories: ["*"]} + check: + - pattern: '(?PB-01[^\n]{0,60}\b(?:canonical|current))' + unless: 'supersed' + message: B-01 is superseded + owner: platform-lead + note: > + Open. The plan of record names Issue 6; the working BOM, the HW diagram + and docs pages use Issue 7; no approval of Issue 7 as successor is + recorded (audit BOM-025, TEAM-031, DOC-001). Set status to recorded once + P-03 names the issue in force. + + - id: MAST-INSTALL-HEIGHT + title: Shoulder-axis installation height of the fixed mast + status: recorded + value: 1350 + unit: mm + configuration_id: mast_1350 + date: 2026-09-28 + source: {document: P-03-rev18.2, item: shoulder height} + supersedes: + - {value: 1400, configuration_id: mast_1400, source: P-03-rev18.1 item 6 line 15} + - {value: 1300, configuration_id: mast_1300, source: working baseline before P-03-rev18.1 item 6} + applies_to: + repositories: ["*"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.xacro", "**/*.urdf", "**/*.launch.py", "**/package.xml", "**/*.html"] + check: + - pattern: '(?Pmast_1[34]00)\b[^\n]{0,60}\bbaseline' + unless: 'supersed|rev ?18\.1|release-candidate position' + message: installation baseline is mast_1350 + - pattern: '\bbaseline\b[^\n]{0,40}(?Pmast_1[34]00|\b1[34]00\s*mm)' + unless: 'supersed|rev ?18\.1' + message: installation baseline is mast_1350 + - pattern: '(?P(?:shoulder[- ]axis|shoulder height|installation height)\s*(?:at|of|is|:)?\s*1[34]00\s*mm)' + unless: 'supersed|rev ?18\.1|position' + message: installation height is 1350 mm + owner: platform-lead + + - id: MAST-POSITIONS + title: Indexed mast mounting positions + status: recorded + values: [1300, 1350, 1400, 1450] + unit: mm + step_mm: 50 + date: 2026-09-28 + source: {document: P-03-rev18.2, item: shoulder height} + supersedes: + - {value: nine positions 1300 to 1700 mm (mast_1300 to mast_1700), source: P-00-rev18.1 txt 293-294} + applies_to: + repositories: ["*"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.xacro", "**/*.urdf", "**/*.launch.py", "**/*.html"] + check: + - pattern: '\b(?Pmast_1(?:5[05]0|6[05]0|700))\b' + unless: 'supersed|not available|cannot' + message: only mast_1300, mast_1350, mast_1400 and mast_1450 exist + - pattern: '(?Pnine\s+(?:indexed\s+)?(?:mechanical\s+)?(?:mounting\s+)?positions)' + unless: 'supersed' + message: four indexed positions + - pattern: '(?P\b1300\s*(?:mm\s*)?(?:to|through|\.\.|–|-)\s*1700\s*mm)' + unless: 'supersed|height envelope|assembled' + message: positions span 1300 to 1450 mm + owner: platform-lead + + - id: MAST-TOP-HEIGHT + title: Mast top height above the floor (own COTS mast, one MISUMI HFS6-60120 profile) + status: recorded + value: 1500 + unit: mm + date: 2026-09-28 + source: {document: P-03-rev18.2, item: mast} + supersedes: + - {value: 1554, source: general arrangement before 28 September 2026 (audit GEO-001)} + applies_to: + repositories: ["*"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/*.xacro", "**/*.urdf"] + check: + - pattern: '(?Ptop at 1554(?:\s*mm)?|mast top[^\n]{0,20}\b1554\s*mm)' + unless: 'supersed' + message: mast top is 1500 mm + owner: platform-lead + + - id: MAX-ASSEMBLED-HEIGHT + title: Maximum assembled robot height, including head and camera structure + status: recorded + value: 1700 + unit: mm + date: null + source: {document: P-03-rev18.1, item: item 6 line 15} + note: Not the shoulder-axis height. Repeated unchanged in P-03-rev18.2 per the docs site. + applies_to: + repositories: ["*"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"] + check: + - pattern: '(?Pmax(?:imum)?\s+(?:assembled\s+|robot\s+)?height\s*(?:of|is|:)?\s*(?!1700)1\d{3}\s*mm)' + message: maximum assembled height is 1700 mm + - pattern: '(?Pshoulder[- ]axis[^\n]{0,20}\b1700\s*mm)' + unless: 'max|envelope|not (?:the )?shoulder|higher' + message: 1700 mm is the assembled-height envelope, not a shoulder height + owner: platform-lead + + - id: BATTERY-PLACEMENT + title: Battery pack and placement + status: recorded + value: One 8S1P EVE LF105 LiFePO4 pack, 25.6 V, 105 Ah, centred at 25 percent of the robot length from the rear + unit: none + date: 2026-09-28 + source: {document: P-03-rev18.2, item: battery} + supersedes: + - {value: fit the existing battery bay first, source: P-00-rev18.1 txt 77} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} + check: + - pattern: 'battery[^\n]{0,40}\bcent(?:red|ered) at (?P(?!25\b)\d+\s*(?:%|percent))' + message: battery is centred at 25 percent of the length from the rear + owner: platform-lead + note: The audit (GEO-010) found this placement absent from P-03 rev18.1; the docs site cites rev18.2. + + - id: SPEED-CEILING + title: Speed ceiling + status: recorded + value: 1.5 m/s command ceiling, treated as an analytical limit; the accepted operating speed follows from the stability model and stopping tests + unit: m/s + date: 2026-09-28 + source: {document: P-03-rev18.2, item: speed} + supersedes: + - {value: software speed limit 1.5 m/s presented as an operating limit, source: audit GEO-006 and DOC-003} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} + check: + - pattern: '(?Psoftware speed limit\s*(?:of\s*)?1\.5\s*m/s)' + unless: 'ceiling|analytical' + message: 1.5 m/s is a command ceiling, not an operating limit + - pattern: '(?P(?:operating|validated|rated) speed\s*(?:of|is|:)?\s*1\.5\s*m/s)' + unless: 'ceiling|analytical|not' + message: 1.5 m/s is not an accepted operating speed + owner: platform-lead + + - id: DRIVETRAIN + title: Drivetrain + status: recorded + value: Two ZLTECH ZLLG80ASM250-L-B hub motors with brakes, one ZLAC8015D V4.2 driver on CAN1 (CANopen), 200 mm wheels; no RS485 + unit: none + date: 2026-09-21 + source: {document: P-00-rev18.1, item: txt 16; P-03-rev18.1 item 4} + applies_to: + repositories: ["openamr-platform-*", "openamr-upperbody-*", "openamrobot-docs", "openamrobot-release", ".github"] + check: + - pattern: '(?PZLLG80ASM250-L)(?!-B)\b' + message: the selected motor variant is ZLLG80ASM250-L-B + - pattern: '(?PZLAC8015D[^\n]{0,60}RS-?485)' + unless: '\bno RS-?485|not used|not provisioned' + message: ZLAC8015D is driven over CAN1; no RS485 in 2.0 + - pattern: '(?PZL ?TECH[^\n]{0,40}\b(?:alternative|option(?:al)?)\b)' + message: ZLTECH is the selected 2.0 drivetrain, not an option + - pattern: '(?Pfail-safe brakes?)' + unless: 'pending|F2A|verif|not yet|unverified' + message: brake fail-safe function and ratings are open F2A gates + owner: platform-lead + + - id: RS485-NOT-IN-2-0 + title: No RS485 hardware, fallback, adapter or commissioning path in 2.0 + status: recorded + value: none + unit: none + date: null + source: {document: P-03-rev18.1, item: item 4} + applies_to: + repositories: ["openamr-platform-hw", "openamr-platform-fw", "openamr-upperbody-*"] + check: + - pattern: '(?PRS-?485)' + unless: 'not used|no RS-?485|not provisioned|without|legacy|Gate A|removed' + message: RS485 is not provisioned in 2.0 + - pattern: '(?PCAN or serial)' + message: CAN1 and CAN2 are dedicated; no upper-body link to the base + owner: platform-lead + + - id: BASE-CONTROLLER-GATES + title: Base-controller gates and IMU gating + status: recorded + values: + - Gate A, Jetson with the existing Teensy, ZBLD/PWM drivetrain and MPU6500 (legacy test configuration) + - Gate B, Jetson with STM32H7 (NUCLEO-H743ZI2 bench board) and the ZLTECH drivers + - ICM-42688-P enters the manufacturing BOM only after side-by-side robot evidence; adoption decision 20 November 2026 + - STM32 go/no-go 6 November 2026; fallback is the validated legacy Teensy/PWM build behind I8 + unit: none + date: null + source: {document: P-00-rev18.1, item: txt 63, 101, 106, 344; I8 WP adoption gate} + applies_to: {repositories: ["*"]} + check: + - pattern: '(?PTeensy[^\n]{0,20}\bbench target)' + message: Teensy is the Gate A controller and the release fallback, not a bench target + - pattern: '(?PICM-42688-P[^\n]{0,40}\b(?:selected|baseline|chosen)\b)' + unless: 'Gate B|conditional|20 Nov|candidate|pending|not yet|after' + message: ICM-42688-P is gated; MPU6500 remains the Gate A IMU + - pattern: '\b(?PMPU6050)\b' + message: the Gate A IMU is the MPU6500 + owner: platform-lead + + - id: IMU-TOPIC-OWNERSHIP + title: IMU topic ownership + status: recorded + values: + - firmware publishes /imu/data_raw + - the host filter and EKF own /imu/data + unit: none + date: null + source: {document: P-00-rev18.1, item: txt 15 and 345; I8 WP line 224} + applies_to: + repositories: ["openamr-platform-*", "openamrobot-docs", "openamrobot-ui"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.launch.py", "**/*.launch.xml", "**/*.ino", "**/*.cpp", "**/*.h"] + check: + - pattern: '(?:micro-?ROS agent|Teensy|firmware|MCU|STM32)[^\n]{0,80}(?P/imu/data)(?!_raw)\b' + unless: '\bhost\b|(?/?imu/data)"' + files: ["**/*.ino", "**/*.cpp", "**/*.c", "**/*.h"] + message: firmware must not publish filtered /imu/data + owner: software-lead + + - id: CAMERAS + title: Cameras + status: recorded + values: + - base camera Orbbec Gemini 336L, fixed to base_link on the front face, tilted about 10 degrees upward + - head camera ZED-121210 (exact commercial identity pending, see HEAD-CAMERA-IDENTITY) + - two in-hand RGB wrist cameras; no separate torso scene camera + unit: none + date: 2026-09-23 + source: {document: P-00-rev18.1, item: txt 85 and 97} + applies_to: + repositories: ["openamr-*", "openamrobot-docs", "openamrobot-manipulation", "openamrobot-ui"] + check: + - pattern: '(?P\b(?:D4[35]5i?|RealSense)\b)' + unless: 'legacy|historical|previous|not used|replaced' + message: the base camera is the Orbbec Gemini 336L + - pattern: '(?Pdown-tilted depth camera)' + message: the base camera tilts about 10 degrees up + - pattern: '(?PIMX708|Pi Camera Module 3)' + unless: 'legacy|Gate A|historical' + message: the 2.0 base camera is the Orbbec Gemini 336L + owner: platform-lead + + - id: HEAD-CAMERA-IDENTITY + title: Head camera commercial identity + status: open + value: ZED-121210 per the plan; the general arrangement says ZED mini + unit: none + date: null + source: {document: P-00-rev18.1, item: txt 85} + applies_to: {repositories: ["*"]} + check: + - pattern: '(?PZED[ -]?mini)' + message: head camera identity is open + owner: platform-lead + + - id: POWER-RAILS + title: Power rails + status: recorded + values: + - main battery bus 25.6 V nominal (not a regulated 24 V rail) + - regulated 24 V branch (safety relay, brake coils, peripherals, separately fused Hokuyo branch) + - regulated 5 V + - no 12 V rail + unit: V + date: null + source: {document: P-03-rev18.1, item: item 2 line 10} + applies_to: + repositories: ["openamr-platform-*", "openamr-upperbody-*", "openamrobot-docs"] + check: + - pattern: '(?P\b12\s*V\s+rail)' + unless: '\bno 12|not|never|legacy' + message: there is no 12 V rail in 2.0 + - pattern: '(?P24\s*V?\s*(?:->|→|to)\s*5\s*V?\s*/\s*12\s*V)' + unless: 'legacy' + message: there is no 12 V rail in 2.0 + owner: platform-lead + + - id: DOCKING-SCOPE + title: Docking scope in 2.0 + status: recorded + value: Positioning only; docked means accepted pose within tolerance; manual wired charging via PWR-019 with independent charge-plug-presence inhibition; no dock contacts or dock pilot; pose success never establishes charging; wireless charging is 3.0 + unit: none + date: null + source: {document: P-03-rev18.1, item: item 3 line 11} + applies_to: + repositories: ["openamr-platform-*", "openamrobot-docs", "openamrobot-ui", "openamrobot-interfaces"] + check: + - pattern: '(?Pcharg(?:e|ing)[ -]contacts?)' + unless: '\bno\b|\bnot\b|without|3\.0|never' + message: 2.0 docking has no dock contacts + - pattern: '(?PSimpleChargingDock|ready to charge|charging target)' + unless: 'not |never|instead|non-charging' + message: 2.0 docking is positioning only + - pattern: '(?P(?:charge|charger|DC)\s*\+?\s*pilot)' + unless: '\bno\b' + message: no dock or charge pilot in 2.0 + - pattern: '(?Pwireless charging)' + unless: '3\.0|later|deferred|not ' + message: wireless charging belongs to 3.0 + owner: platform-lead + + - id: SAFETY-PROCUREMENT + title: Safety-chain procurement boundary (record only; this file implements no safety function) + status: recorded + values: + - two installed E-stops (base front panel and chest), red latching mushroom, dual normally-closed channels into a monitored safety relay + - K1 and K2 contactors from the LEV100 family; the linked-feedback (EDM) variant is unselected under one CONTACTOR gate + - CTL-004 ISO1212EVM is bench-only until an installed solution is qualified + unit: none + date: null + source: {document: P-03-rev18.1, item: items 4 and 5 lines 12-13} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} + check: + - pattern: '(?P9-1618389-8|LEV100A5ANG)' + unless: 'do not order|not evidence|unselected|reference only' + message: the EDM variant is not selected; do not present LEV100A5ANG as the order + - pattern: '(?Pfine for prototypes)' + message: no single-contact or clone E-stop recommendation + - pattern: '(?PCTL-004[^\n]{0,60}\b(?:installed|robot interface))' + unless: 'bench-only|until' + message: CTL-004 is bench-only + owner: platform-lead + + - id: COMPUTE + title: Reference compute + status: recorded + value: NVIDIA Jetson Orin NX 16 GB on a reComputer Robotics J401 carrier with NVMe; Raspberry Pi removed from active support + unit: none + date: null + source: {document: P-00-rev18.1, item: txt 14, 90, 278} + applies_to: {repositories: ["*"]} + check: + - pattern: '(?PRaspberry Pi 5)' + unless: 'legacy|historical|removed|supersed|Gate A|previous|earlier' + message: Jetson Orin NX is the 2.0 reference compute; label Raspberry Pi material as legacy + owner: platform-lead + + - id: NAV-LIDAR + title: Navigation LiDAR + status: recorded + value: Hokuyo UST-10LX (functional sensing, not a safety device) + unit: none + date: null + source: {document: P-03-rev18.1, item: item 2} + applies_to: {repositories: ["openamr-platform-*", "openamrobot-docs", "openamrobot-release"]} + check: + - pattern: '(?Prplidar_ros|RPLIDAR(?: A1)?)' + unless: 'legacy|Gate A|historical' + message: the 2.0 navigation LiDAR is the Hokuyo UST-10LX + owner: software-lead + + - id: LIFT-REMOVED + title: No lift in 2.0; fixed mast + status: recorded + value: Lift removed from OpenAMRobot 2.0; 3.0 roadmap + unit: none + date: null + source: {document: P-00-rev18.1, item: txt 18} + applies_to: {repositories: ["*"]} + check: + - pattern: '(?P(?:linear |actuated )?lift (?:module|controller|joint|axis|system|column|motor))' + unless: '3\.0|removed|later release|v3|roadmap|not part|replace' + message: 2.0 has a fixed mast; the lift is 3.0 + owner: platform-lead + + - id: NO-SUSPENSION + title: No suspension in 2.0 + status: recorded + value: No suspension is fitted in 2.0; sprung drive wheels are a 3.0 item + unit: none + date: null + source: {document: P-03-rev18.1, item: item 1} + applies_to: {repositories: ["*"]} + check: + - pattern: '(?Psprung drive(?: module| wheels?)?)' + unless: '3\.0|\bno\b|not fitted|without' + message: no suspension in 2.0 + owner: platform-lead + + - id: DRIVE-TRACK + title: Drive track + status: open + value: 400 mm in the general arrangement; final track is an open input + unit: mm + date: null + source: {document: P-00-rev18.1, item: open inputs (audit GEO-012)} + applies_to: {repositories: ["openamr-platform-*", "openamrobot-ui"]} + check: + - pattern: '(?P0\.4075)' + message: track value disagrees with the general arrangement + owner: platform-lead diff --git a/maintainers.yaml b/maintainers.yaml new file mode 100644 index 0000000..b7ba778 --- /dev/null +++ b/maintainers.yaml @@ -0,0 +1,62 @@ +# Maintainers map for automation. Handles only; no names or contact data. +# A role with handle null has no verified GitHub handle yet. Automation that +# needs that role reports "unassigned" instead of guessing. +# Changes to this file need review by the platform lead (see CODEOWNERS). +schema_version: 1 + +roles: + platform-lead: + handle: BotshareAI + covers: platform, hardware, safety chain, decisions of record, final review + software-lead: + handle: panthera-momagdii + covers: robot software, AI, interfaces, agent rules + ci-owner: + handle: null + covers: CI/CD, reusable workflows, verify.sh, quality gates + release-owner: + handle: KARTHIKEYAN124 + covers: release, installation, manifest + docs-owner: + handle: null + covers: documentation site, public extracts + +# Owner of audit findings by ID prefix, used by the weekly alignment audit. +audit_prefixes: + GEO: platform-lead + BOM: platform-lead + ELE: platform-lead + TEAM: platform-lead + SW: software-lead + CI: ci-owner + PR: ci-owner + DOC: docs-owner + +# Owner role per repository, used by docs-sync and the rollout CODEOWNERS proposal. +repositories: + .github: [ci-owner, platform-lead] + openamrobot-interfaces: [software-lead] + openamr-platform-sw: [software-lead] + openamr-platform-fw: [platform-lead] + openamr-platform-hw: [platform-lead] + openamr-upperbody-sw: [software-lead] + openamr-upperbody-fw: [platform-lead] + openamr-upperbody-hw: [platform-lead] + openamrobot-manipulation: [software-lead] + openamrobot-ui: [software-lead] + openamrobot-comm: [software-lead] + openamrobot-manifest: [release-owner] + openamrobot-release: [release-owner] + openamrobot-docs: [docs-owner] + +# Paths whose change needs two human reviewers including the platform lead. +safety_paths: + - "**/*estop*" + - "**/*e_stop*" + - "**/*emergency*" + - "**/*brake*" + - "**/*contactor*" + - "**/*watchdog*" + - "**/*motor_enable*" + - "**/*charge_inhibit*" + - "**/*safety*" diff --git a/tests/fixtures/decisions.yaml b/tests/fixtures/decisions.yaml new file mode 100644 index 0000000..465e9f7 --- /dev/null +++ b/tests/fixtures/decisions.yaml @@ -0,0 +1,45 @@ +# Fixture decisions for tests/test_check_decisions.py. Not decisions of record. +schema_version: 1 +sources: + FIX-DOC: {title: Fixture decision document, evidence: tests only} +exclude: ["**/CHANGELOG.md"] +decisions: + - id: FIX-MAST + title: Fixture installation height + status: recorded + value: 1350 + unit: mm + date: 2026-09-28 + source: {document: FIX-DOC, item: item 1} + supersedes: + - {value: 1400, source: FIX-DOC earlier item 1} + owner: platform-lead + applies_to: {repositories: ["*"]} + check: + - pattern: '(?Pmast_1400)\b[^\n]{0,40}baseline' + unless: 'legacy' + message: baseline is mast_1350 + - id: FIX-IMU + title: Fixture topic ownership + status: recorded + values: [firmware publishes /imu/data_raw, host filter owns /imu/data] + unit: none + date: null + source: {document: FIX-DOC, item: item 2} + owner: software-lead + applies_to: {repositories: ["platform-*"]} + check: + - pattern: '"(?P/?imu/data)"' + files: ["**/*.launch.py", "**/*.xacro"] + message: firmware must not publish /imu/data + - id: FIX-OPEN + title: Fixture open decision, never enforced + status: open + value: undecided + unit: none + date: null + source: {document: FIX-DOC, item: item 3} + owner: platform-lead + applies_to: {repositories: ["*"]} + check: + - pattern: '(?Pundecided-value)' diff --git a/tests/fixtures/decisions_repo/CHANGELOG.md b/tests/fixtures/decisions_repo/CHANGELOG.md new file mode 100644 index 0000000..f3adda1 --- /dev/null +++ b/tests/fixtures/decisions_repo/CHANGELOG.md @@ -0,0 +1 @@ +- mast_1400 baseline replaced (excluded path) diff --git a/tests/fixtures/decisions_repo/README.md b/tests/fixtures/decisions_repo/README.md new file mode 100644 index 0000000..6ac6736 --- /dev/null +++ b/tests/fixtures/decisions_repo/README.md @@ -0,0 +1,4 @@ +# Fixture robot + +The shoulder uses mast_1400 as the baseline. +Current value: mast_1350 is the baseline. diff --git a/tests/fixtures/decisions_repo/config/params.yaml b/tests/fixtures/decisions_repo/config/params.yaml new file mode 100644 index 0000000..368fd3c --- /dev/null +++ b/tests/fixtures/decisions_repo/config/params.yaml @@ -0,0 +1 @@ +mast_id: mast_1350 # baseline diff --git a/tests/fixtures/decisions_repo/docs/history.md b/tests/fixtures/decisions_repo/docs/history.md new file mode 100644 index 0000000..3fe0e77 --- /dev/null +++ b/tests/fixtures/decisions_repo/docs/history.md @@ -0,0 +1,6 @@ +# History + +decision-allow: FIX-MAST quoted from the superseded plan for history +Before 28 September, mast_1400 was the baseline. +The legacy robot used mast_1400 as its baseline. +Nothing here says undecided-value in an enforced way. diff --git a/tests/fixtures/decisions_repo/launch/robot.launch.py b/tests/fixtures/decisions_repo/launch/robot.launch.py new file mode 100644 index 0000000..fd8dada --- /dev/null +++ b/tests/fixtures/decisions_repo/launch/robot.launch.py @@ -0,0 +1,3 @@ +# Fixture launch file +ARGS = {"mast": "mast_1400"} # mast_1400 baseline for the arms +REMAP = [("imu", "/imu/data")] diff --git a/tests/fixtures/decisions_repo/legacy/notes.txt b/tests/fixtures/decisions_repo/legacy/notes.txt new file mode 100644 index 0000000..5068b97 --- /dev/null +++ b/tests/fixtures/decisions_repo/legacy/notes.txt @@ -0,0 +1 @@ +mast_1400 baseline in a file type no decision applies to diff --git a/tests/fixtures/decisions_repo/package.xml b/tests/fixtures/decisions_repo/package.xml new file mode 100644 index 0000000..26442d3 --- /dev/null +++ b/tests/fixtures/decisions_repo/package.xml @@ -0,0 +1,6 @@ + + + fixture + Configured for mast_1400 baseline. + MIT + diff --git a/tests/fixtures/decisions_repo/urdf/robot.xacro b/tests/fixtures/decisions_repo/urdf/robot.xacro new file mode 100644 index 0000000..5a6ef0e --- /dev/null +++ b/tests/fixtures/decisions_repo/urdf/robot.xacro @@ -0,0 +1,4 @@ + + + + diff --git a/tests/test_check_decisions.py b/tests/test_check_decisions.py new file mode 100644 index 0000000..5a7003c --- /dev/null +++ b/tests/test_check_decisions.py @@ -0,0 +1,192 @@ +"""Tests for tools/check_decisions.py against the fixture repository.""" +import contextlib +import io +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import check_decisions as cd # noqa: E402 + +FIXTURES = ROOT / "tests" / "fixtures" +REPO = FIXTURES / "decisions_repo" +DECISIONS = FIXTURES / "decisions.yaml" + + +def run(*args): + out = io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out): + code = cd.main([str(a) for a in args]) + return code, out.getvalue() + + +class ScanFixtureRepository(unittest.TestCase): + def setUp(self): + self.decisions = cd.load_decisions(DECISIONS) + + def test_reports_file_line_found_and_decided_value(self): + findings, _ = cd.scan(REPO, self.decisions, "platform-fixture") + readme = [f for f in findings if f["file"] == "README.md"] + self.assertEqual(len(readme), 1) + self.assertEqual(readme[0]["line"], 3) + self.assertEqual(readme[0]["found"], "mast_1400") + self.assertEqual(readme[0]["decided"], "1350 mm") + self.assertEqual(readme[0]["source"], "FIX-DOC item 1") + + def test_covers_markdown_launch_xacro_package_xml(self): + findings, _ = cd.scan(REPO, self.decisions, "platform-fixture") + where = sorted((f["file"], f["line"], f["id"]) for f in findings) + self.assertEqual(where, [ + ("README.md", 3, "FIX-MAST"), + ("launch/robot.launch.py", 2, "FIX-MAST"), + ("launch/robot.launch.py", 3, "FIX-IMU"), + ("package.xml", 4, "FIX-MAST"), + ("urdf/robot.xacro", 2, "FIX-MAST"), + ]) + + def test_allow_marker_is_reported_not_silenced(self): + _, allowed = cd.scan(REPO, self.decisions, "platform-fixture") + self.assertEqual([(a["file"], a["line"]) for a in allowed], [("docs/history.md", 4)]) + self.assertIn("superseded plan", allowed[0]["reason"]) + + def test_unless_exemption_excluded_paths_and_other_file_types(self): + findings, _ = cd.scan(REPO, self.decisions, "platform-fixture") + files = {f["file"] for f in findings} + self.assertNotIn("CHANGELOG.md", files) + self.assertNotIn("legacy/notes.txt", files) + self.assertFalse(any(f["file"] == "docs/history.md" and f["line"] == 5 for f in findings)) + + def test_repository_filter_and_per_check_files(self): + findings, _ = cd.scan(REPO, self.decisions, "docs-fixture") + self.assertNotIn("FIX-IMU", {f["id"] for f in findings}) + self.assertEqual(len(findings), 4) + + def test_open_decisions_are_not_enforced(self): + findings, _ = cd.scan(REPO, self.decisions, "platform-fixture") + self.assertNotIn("FIX-OPEN", {f["id"] for f in findings}) + + def test_changed_files_scope(self): + findings, _ = cd.scan(REPO, self.decisions, "platform-fixture", only=["config/params.yaml"]) + self.assertEqual(findings, []) + findings, _ = cd.scan(REPO, self.decisions, "platform-fixture", only=["README.md", "gone.md"]) + self.assertEqual(len(findings), 1) + + +class CommandLine(unittest.TestCase): + def test_exit_one_on_contradiction(self): + code, out = run("--decisions", DECISIONS, "--root", REPO, "--repository", "platform-x") + self.assertEqual(code, 1) + self.assertIn("CONTRADICTION README.md:3: FIX-MAST found 'mast_1400', decided '1350 mm'", out) + self.assertIn("result: 5 contradiction(s), 1 allowed", out) + + def test_exit_zero_when_clean(self): + with tempfile.TemporaryDirectory() as tmp: + Path(tmp, "README.md").write_text("mast_1350 is the baseline\n", encoding="utf-8") + code, out = run("--decisions", DECISIONS, "--root", tmp) + self.assertEqual(code, 0, out) + + def test_exit_two_on_missing_root(self): + code, _ = run("--decisions", DECISIONS, "--root", "/nonexistent-root") + self.assertEqual(code, 2) + + +class Schema(unittest.TestCase): + def write(self, text): + tmp = tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) + tmp.write(text) + tmp.close() + self.addCleanup(Path(tmp.name).unlink) + return tmp.name + + BASE = """schema_version: 1 +sources: {D: {title: t}} +decisions: + - id: A + title: t + status: recorded + value: 1 + unit: mm + date: null + source: {document: D, item: i} + applies_to: {repositories: ["*"]} + check: [{pattern: '(?Px)'}] + owner: platform-lead +""" + + def test_base_is_valid(self): + self.assertEqual(len(cd.load_decisions(self.write(self.BASE))), 1) + + def test_rejects_invalid_entries(self): + cases = { + "duplicate id": self.BASE + self.BASE.split("decisions:\n", 1)[1], + "pattern needs": self.BASE.replace("(?Px)", "x"), + "not listed under sources": self.BASE.replace("document: D", "document: E"), + "status must be": self.BASE.replace("status: recorded", "status: maybe"), + "missing owner": self.BASE.replace(" owner: platform-lead\n", ""), + "needs value": self.BASE.replace(" value: 1\n", ""), + "schema_version": self.BASE.replace("schema_version: 1", "schema_version: 9"), + } + for expected, text in cases.items(): + with self.subTest(expected=expected): + with self.assertRaisesRegex(cd.DecisionError, expected): + cd.load_decisions(self.write(text)) + + def test_owner_must_be_a_maintainers_role(self): + with self.assertRaisesRegex(cd.DecisionError, "not a role"): + cd.load_decisions(self.write(self.BASE.replace("platform-lead", "somebody")), + ROOT / "maintainers.yaml") + + +class RealDecisionsFile(unittest.TestCase): + """The organization's decisions.yaml is valid and detects audited mistakes.""" + + @classmethod + def setUpClass(cls): + cls.decisions = cd.load_decisions(ROOT / "decisions.yaml", ROOT / "maintainers.yaml") + + def scan_text(self, name, text, repository): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp, name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return cd.scan(tmp, self.decisions, repository)[0] + + def test_every_recorded_decision_has_a_source_and_owner(self): + for d in self.decisions: + self.assertTrue(d["source"]["item"], d["id"]) + self.assertTrue(d["owner"], d["id"]) + + def test_detects_imu_topic_owned_by_firmware(self): + text = " # micro-ROS agent: bridges the Teensy (/cmd_vel, /odom/unfiltered, /imu/data).\n" + found = self.scan_text("launch/drivers.launch.py", text, "openamr-platform-sw") + self.assertEqual([f["id"] for f in found], ["IMU-TOPIC-OWNERSHIP"]) + + def test_accepts_host_owned_imu_topic(self): + text = "Firmware owns raw /imu/data_raw; the host filter publishes /imu/data.\n" + self.assertEqual(self.scan_text("docs/imu.md", text, "openamr-platform-sw"), []) + + def test_detects_clone_estop_recommendation(self): + found = self.scan_text("docs/estop.md", "no certification. Fine for prototypes.\n", "openamrobot-docs") + self.assertEqual([f["id"] for f in found], ["SAFETY-PROCUREMENT"]) + + def test_detects_superseded_mast_values(self): + text = "| `mast_1300` | Plan working shoulder-axis baseline (1300 mm) |\nmast_1600 position\n" + found = self.scan_text("integration/inventory.md", text, "openamrobot-manipulation") + self.assertEqual(sorted(f["id"] for f in found), ["MAST-INSTALL-HEIGHT", "MAST-POSITIONS"]) + + def test_detects_docking_charge_contacts_but_not_negation(self): + found = self.scan_text("docs/dock.md", "The robot engages the charging contacts.\n", "openamrobot-docs") + self.assertEqual([f["id"] for f in found], ["DOCKING-SCOPE"]) + self.assertEqual(self.scan_text("docs/dock.md", "There are no charging contacts in 2.0.\n", + "openamrobot-docs"), []) + + def test_legacy_label_exempts_raspberry_pi(self): + self.assertEqual(self.scan_text("README.md", "Legacy build: Raspberry Pi 5.\n", "openamr-platform-hw"), []) + self.assertEqual(len(self.scan_text("README.md", "Compute: Raspberry Pi 5.\n", "openamr-platform-hw")), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/check_decisions.py b/tools/check_decisions.py new file mode 100644 index 0000000..8875d59 --- /dev/null +++ b/tools/check_decisions.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Check a repository checkout against decisions.yaml. + +Every decision in decisions.yaml names the file globs it applies to and the +patterns that reveal a contradicting value. This tool validates the schema, +then scans the checkout and reports file, line, found value and decided +value for each contradiction. Exit status: 0 clean, 1 contradiction found, +2 invalid decisions file or usage error. + +A line that must keep a superseded value (history, changelog, legacy +material) carries the marker `decision-allow: ` on the same line +or the line directly above it. The marker is reported, never silently ignored. +""" +import argparse +import fnmatch +import json +import re +import subprocess +import sys +from pathlib import Path + +import yaml + +SCHEMA_VERSION = 1 +STATUSES = {"recorded", "proposed", "open"} +REQUIRED = ("id", "title", "status", "date", "source", "applies_to", "check", "owner") +DEFAULT_FILES = [ + "**/*.md", "**/*.yaml", "**/*.yml", "**/*.launch.py", "**/*.launch.xml", + "**/*.launch", "**/*.urdf", "**/*.xacro", "**/package.xml", "**/README*", +] +ALLOW = re.compile(r"decision-allow:\s*(?P[A-Z0-9][A-Z0-9-]*)\s+(?P\S.*)") +SKIP_DIRS = {".git", "node_modules", ".verification", "build", "install", "log"} + + +class DecisionError(ValueError): + pass + + +def load_decisions(path, maintainers=None): + """Load and validate decisions.yaml; return the list of decisions.""" + try: + data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError) as exc: + raise DecisionError(f"{path}: {exc}") from exc + if not isinstance(data, dict) or data.get("schema_version") != SCHEMA_VERSION: + raise DecisionError(f"{path}: schema_version must be {SCHEMA_VERSION}") + sources = data.get("sources") or {} + decisions = data.get("decisions") + if not isinstance(decisions, list) or not decisions: + raise DecisionError(f"{path}: decisions must be a non-empty list") + roles = None + if maintainers: + roles = set((yaml.safe_load(Path(maintainers).read_text(encoding="utf-8")) or {}).get("roles", {})) + seen = set() + errors = [] + for index, d in enumerate(decisions): + where = f"decisions[{index}]" + if not isinstance(d, dict): + errors.append(f"{where}: not a mapping") + continue + missing = [key for key in REQUIRED if key not in d] + if missing: + errors.append(f"{where}: missing {', '.join(missing)}") + continue + where = d["id"] + if d["id"] in seen: + errors.append(f"{where}: duplicate id") + seen.add(d["id"]) + if d["status"] not in STATUSES: + errors.append(f"{where}: status must be one of {sorted(STATUSES)}") + if "value" not in d and "values" not in d: + errors.append(f"{where}: needs value or values") + src = d["source"] + if not isinstance(src, dict) or not src.get("document") or not src.get("item"): + errors.append(f"{where}: source needs document and item") + elif src["document"] not in sources: + errors.append(f"{where}: source document {src['document']!r} not listed under sources") + if roles is not None and d["owner"] not in roles: + errors.append(f"{where}: owner {d['owner']!r} is not a role in the maintainers map") + for sup in d.get("supersedes") or []: + if not isinstance(sup, dict) or "value" not in sup or not sup.get("source"): + errors.append(f"{where}: each supersedes entry needs value and source") + applies = d["applies_to"] + if not isinstance(applies, dict) or not applies.get("repositories"): + errors.append(f"{where}: applies_to needs repositories") + checks = d["check"] + if not isinstance(checks, list) or not checks: + errors.append(f"{where}: check must be a non-empty list of patterns") + continue + for c in checks: + try: + rx = re.compile(c["pattern"], re.IGNORECASE) + if "found" not in rx.groupindex: + errors.append(f"{where}: pattern needs a (?P...) group") + if c.get("unless"): + re.compile(c["unless"], re.IGNORECASE) + except (KeyError, TypeError, re.error) as exc: + errors.append(f"{where}: bad check pattern: {exc}") + if errors: + raise DecisionError("\n".join(errors)) + exclude = data.get("exclude") or [] + for d in decisions: + d["_exclude"] = list(exclude) + list(d["applies_to"].get("exclude") or []) + return decisions + + +def decided_text(d): + value = d["values"] if "values" in d else d["value"] + if isinstance(value, list): + value = ", ".join(str(v) for v in value) + unit = d.get("unit") + return f"{value} {unit}".strip() if unit and unit != "none" else str(value) + + +def repository_matches(d, repository): + repos = d["applies_to"]["repositories"] + return repository is None or any(fnmatch.fnmatch(repository, r) for r in repos) + + +def glob_match(rel, patterns): + rel = rel.replace("\\", "/") + for pattern in patterns: + if fnmatch.fnmatch(rel, pattern): + return True + if pattern.startswith("**/") and fnmatch.fnmatch(rel, pattern[3:]): + return True + return False + + +def list_files(root): + root = Path(root) + try: + out = subprocess.run( + ["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True + ).stdout.decode("utf-8", "replace") + files = [f for f in out.split("\0") if f] + if files: + return files + except (OSError, subprocess.CalledProcessError): + pass + files = [] + for path in root.rglob("*"): + rel = path.relative_to(root) + if path.is_file() and not SKIP_DIRS.intersection(rel.parts): + files.append(rel.as_posix()) + return sorted(files) + + +def scan(root, decisions, repository=None, only=None): + """Return (findings, allowed) for the checkout at root.""" + root = Path(root) + files = list(only) if only is not None else list_files(root) + findings, allowed = [], [] + active = [d for d in decisions if d["status"] == "recorded" and repository_matches(d, repository)] + for rel in files: + path = root / rel + if not path.is_file(): + continue + relevant = [d for d in active if glob_match(rel, d["applies_to"].get("files") or DEFAULT_FILES) + and not glob_match(rel, d["_exclude"])] + if not relevant: + continue + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (UnicodeDecodeError, OSError): + continue + for number, line in enumerate(lines, 1): + for d in relevant: + hit = False + for c in d["check"]: + if hit: + break + if c.get("files") and not glob_match(rel, c["files"]): + continue + for m in re.finditer(c["pattern"], line, re.IGNORECASE): + if c.get("unless") and re.search(c["unless"], line, re.IGNORECASE): + continue + record = { + "id": d["id"], "file": rel, "line": number, + "found": m.group("found").strip(), "decided": decided_text(d), + "source": f"{d['source']['document']} {d['source']['item']}", + "message": c.get("message", ""), + } + hit = True + marker = allow_marker(lines, number, d["id"]) + if marker: + record["reason"] = marker + allowed.append(record) + else: + findings.append(record) + break + return findings, allowed + + +def allow_marker(lines, number, decision_id): + for candidate in (lines[number - 1], lines[number - 2] if number > 1 else ""): + m = ALLOW.search(candidate) + if m and m.group("id") == decision_id: + return m.group("reason").strip() + return None + + +def main(argv=None): + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument("--decisions", type=Path, required=True) + p.add_argument("--maintainers", type=Path, help="maintainers.yaml; validates owner roles") + p.add_argument("--root", type=Path, default=Path("."), help="checkout to scan") + p.add_argument("--repository", help="repository name used for applies_to.repositories") + p.add_argument("--changed-files", type=Path, help="scan only the paths listed in this file") + p.add_argument("--validate-only", action="store_true") + p.add_argument("--json", type=Path, help="write findings as JSON") + a = p.parse_args(argv) + try: + decisions = load_decisions(a.decisions, a.maintainers) + except DecisionError as exc: + print(f"INVALID decisions file:\n{exc}", file=sys.stderr) + return 2 + print(f"decisions: {len(decisions)} loaded, " + f"{sum(d['status'] == 'recorded' for d in decisions)} recorded and enforced") + if a.validate_only: + return 0 + if not a.root.is_dir(): + print(f"root is not a directory: {a.root}", file=sys.stderr) + return 2 + only = None + if a.changed_files: + only = [line.strip() for line in a.changed_files.read_text(encoding="utf-8").splitlines() if line.strip()] + findings, allowed = scan(a.root, decisions, a.repository, only) + for f in allowed: + print(f"ALLOWED {f['file']}:{f['line']}: {f['id']} found {f['found']!r}; reason: {f['reason']}") + for f in findings: + print(f"CONTRADICTION {f['file']}:{f['line']}: {f['id']} found {f['found']!r}, " + f"decided {f['decided']!r} ({f['source']}) {f['message']}".rstrip()) + if a.json: + a.json.write_text(json.dumps({"findings": findings, "allowed": allowed}, indent=2), encoding="utf-8") + scope = f"{len(only)} changed file(s)" if only is not None else "full checkout" + print(f"result: {len(findings)} contradiction(s), {len(allowed)} allowed, scope {scope}") + return 1 if findings else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 90de0fcc4d3862faf404afe3caed7d5ee4597ed2 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Mon, 28 Sep 2026 22:43:03 +0000 Subject: [PATCH 003/129] governance: public-extract check with reviewed allowlist Fails on Google Drive and Docs links, e-mail addresses, phone numbers, prices with currency symbols or codes and credential-like strings in docs/, assets/, README.md and any path containing public. Allowlist entries need a rule, a match, optional path and repository scope and a reason. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- public-extract-allowlist.yaml | 25 +++++ tests/test_check_public_extract.py | 123 ++++++++++++++++++++++++ tools/check_public_extract.py | 148 +++++++++++++++++++++++++++++ 3 files changed, 296 insertions(+) create mode 100644 public-extract-allowlist.yaml create mode 100644 tests/test_check_public_extract.py create mode 100644 tools/check_public_extract.py diff --git a/public-extract-allowlist.yaml b/public-extract-allowlist.yaml new file mode 100644 index 0000000..95e506e --- /dev/null +++ b/public-extract-allowlist.yaml @@ -0,0 +1,25 @@ +# Allowlist for tools/check_public_extract.py. +# Each entry: rule, match (regular expression for the whole matched text), +# optional paths and repositories (globs), and a reason. Adding an entry is a +# review decision: the docs owner decides, the platform lead decides entries +# about commercial or company information. +allow: + - rule: email + match: 'info@botshare\.ai' + reason: published organization contact for licensing and maintainers (DOCUMENTATION_STANDARD.md) + - rule: email + match: '[^@\s]+@(?:example\.(?:com|org|net)|users\.noreply\.github\.com)' + reason: documentation placeholders and GitHub no-reply identities + - rule: email + match: '[^@\s]+@[^@\s]+' + paths: ["web/public/ros/*.js"] + repositories: ["openamrobot-ui"] + reason: third-party author attribution inside vendored roslib/ros2d bundles; provenance must be preserved + - rule: email + match: 'paroga@paroga\.com' + paths: ["README.md"] + repositories: ["openamrobot-ui"] + reason: copyright notice of the bundled cbor-js library; provenance must be preserved + - rule: credential + match: '.*(?:your|example|placeholder|changeme|xxxx|<[^>]*>).*' + reason: placeholder values in setup instructions, not secrets diff --git a/tests/test_check_public_extract.py b/tests/test_check_public_extract.py new file mode 100644 index 0000000..bb50c2b --- /dev/null +++ b/tests/test_check_public_extract.py @@ -0,0 +1,123 @@ +"""Tests for tools/check_public_extract.py. + +Credential-like strings are assembled at run time so that this file itself +contains nothing a secret scanner would report. +""" +import contextlib +import io +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import check_public_extract as pe # noqa: E402 + +AT = "@" +DRIVE = "https://" + "drive.google.com/file/d/abc123/view" +DOCS = "https://" + "docs.google.com/document/d/xyz/edit" +TOKEN = "gh" + "p_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8S9t0" +KEY = "AK" + "IA" + "ABCDEFGHIJKLMNOP" + + +class Rules(unittest.TestCase): + def hits(self, text, rel="docs/page.md", allow=(), repository=None): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp, rel) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return [(f["rule"], f["found"]) for f in pe.scan(tmp, list(allow), repository)] + + def test_google_drive_and_docs_links(self): + self.assertEqual(self.hits(f"See [plan]({DRIVE}) and {DOCS}.\n"), + [("google-drive-link", DRIVE), ("google-drive-link", DOCS)]) + + def test_email(self): + self.assertEqual(self.hits(f"Contact jane.doe{AT}example-lab.org today.\n"), + [("email", f"jane.doe{AT}example-lab.org")]) + + def test_email_like_code_is_not_an_email(self): + self.assertEqual(self.hits(f"ros_type ROS_TYPE{AT}gz.msgs.GzType\n"), []) + + def test_phone(self): + self.assertEqual([r for r, _ in self.hits("Call +49 170 1234567 or tel:+357-22-123456.\n")], + ["phone", "phone"]) + + def test_versions_dates_and_shas_are_not_phones(self): + self.assertEqual(self.hits("v1.0.236, 2026-09-28, 8ce9314fa9a404564fa7e954cd84f25bcba2b829, 0.4075 m\n"), []) + + def test_prices(self): + found = [f for _, f in self.hits("Costs €1,475 or USD 2,049.99 or 300 EUR or $5/mo.\n")] + self.assertEqual(found, ["€1,475", "USD 2,049.99", "300 EUR", "$5/mo"]) + + def test_shell_variables_are_not_prices(self): + self.assertEqual(self.hits("Run `echo $HOME` and ${VAR}.\n"), []) + + def test_credentials(self): + text = f"token {TOKEN}\naws {KEY}\napi_key = \"s3cr3tvalue99\"\n-----BEGIN RSA PRIVATE KEY-----\n" + self.assertEqual([r for r, _ in self.hits(text)], ["credential"] * 4) + + def test_scope(self): + text = f"mail a.person{AT}lab.org\n" + self.assertEqual(len(self.hits(text, "README.md")), 1) + self.assertEqual(len(self.hits(text, "pkg/README.md")), 1) + self.assertEqual(len(self.hits(text, "assets/diagram.html")), 1) + self.assertEqual(len(self.hits(text, "web/public/app.js")), 1) + self.assertEqual(self.hits(text, "src/module.py"), []) + self.assertEqual(self.hits(text, "docs/image.png"), []) + + def test_allowlist_rule_match_path_and_repository(self): + allow = pe.load_allowlist(self.write_allow( + "allow:\n - {rule: email, match: 'a\\.person@lab\\.org', paths: ['docs/*']," + " repositories: ['docs-repo'], reason: organization contact}\n")) + text = f"mail a.person{AT}lab.org\n" + self.assertEqual(self.hits(text, allow=allow, repository="docs-repo"), []) + self.assertEqual(len(self.hits(text, allow=allow, repository="other-repo")), 1) + self.assertEqual(len(self.hits(text, "README.md", allow=allow, repository="docs-repo")), 1) + + def test_allowlist_needs_reason(self): + with self.assertRaisesRegex(ValueError, "reason"): + pe.load_allowlist(self.write_allow("allow:\n - {rule: email, match: 'x'}\n")) + + def test_organization_allowlist_loads_and_exempts_placeholders(self): + allow = pe.load_allowlist(ROOT / "public-extract-allowlist.yaml") + self.assertEqual(self.hits('API_KEY="sk-ant-your-key-here"\n', allow=allow), []) + self.assertEqual(self.hits(f"info{AT}botshare.ai\n", allow=allow), []) + self.assertEqual(len(self.hits(f"someone{AT}botshare.ai\n", allow=allow)), 1) + + def write_allow(self, text): + tmp = tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) + tmp.write(text) + tmp.close() + self.addCleanup(Path(tmp.name).unlink) + return tmp.name + + +class CommandLine(unittest.TestCase): + def run_main(self, *args): + out = io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out): + return pe.main([str(a) for a in args]), out.getvalue() + + def test_exit_codes_and_changed_files(self): + with tempfile.TemporaryDirectory() as tmp: + Path(tmp, "docs").mkdir() + Path(tmp, "docs/a.md").write_text(f"see {DRIVE}\n", encoding="utf-8") + Path(tmp, "docs/b.md").write_text("clean\n", encoding="utf-8") + code, out = self.run_main("--root", tmp) + self.assertEqual(code, 1) + self.assertIn("PUBLIC-EXTRACT docs/a.md:1: google-drive-link", out) + changed = Path(tmp, "changed.txt") + changed.write_text("docs/b.md\n", encoding="utf-8") + self.assertEqual(self.run_main("--root", tmp, "--changed-files", changed)[0], 0) + + def test_invalid_allowlist_is_usage_error(self): + with tempfile.TemporaryDirectory() as tmp: + bad = Path(tmp, "allow.yaml") + bad.write_text("allow:\n - {rule: nosuchrule, match: x, reason: y}\n", encoding="utf-8") + self.assertEqual(self.run_main("--root", tmp, "--allowlist", bad)[0], 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/check_public_extract.py b/tools/check_public_extract.py new file mode 100644 index 0000000..fa9c798 --- /dev/null +++ b/tools/check_public_extract.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Fail when public material contains internal links, personal contact data, +prices or credential-like strings. + +Scope: files under docs/ or assets/, every README.md, and every path that +contains "public". Rules: google-drive-link, email, phone, price, credential. +An allowlist file (YAML) can exempt a match; every entry needs a rule, a +regular expression for the matched text, optional path globs and a reason. +Exit status: 0 clean, 1 findings, 2 usage or configuration error. +""" +import argparse +import fnmatch +import json +import re +import subprocess +import sys +from pathlib import Path + +import yaml + +RULES = { + "google-drive-link": re.compile(r"https?://(?:drive|docs)\.google\.com/[^\s\"'<>)\]]*[^\s\"'<>)\].,;:]", re.I), + "email": re.compile(r"(? Date: Mon, 28 Sep 2026 22:43:03 +0000 Subject: [PATCH 004/129] governance: PR template and check_pr_evidence.py The template carries the sections the checker reads: work package, Integration Gate, tests, evidence with base and head SHA and commands, dependencies, safety impact, STATE.md, Not verified and AI disclosure. The checker fails on missing or empty sections, a test change without a non-zero reported run, a dependency manifest change without a Dependencies entry, an unexplained STATE.md, and safety-path changes without two human reviewers including the platform lead or with AI authorship. It keeps one summary comment per PR, updated in place. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- .github/PULL_REQUEST_TEMPLATE.md | 76 +++++++---- tests/test_check_pr_evidence.py | 215 ++++++++++++++++++++++++++++++ tools/check_pr_evidence.py | 221 +++++++++++++++++++++++++++++++ 3 files changed, 484 insertions(+), 28 deletions(-) create mode 100644 tests/test_check_pr_evidence.py create mode 100644 tools/check_pr_evidence.py diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7bc74eb..e004ae1 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,41 +1,61 @@ + + ## Summary -Describe what changed, why it is needed, and the exact repository scope. + + +## Work package + + + +## Integration Gate + + + +## Tests + + + +## Evidence + +Base SHA: +Head SHA: + +``` + +``` + +## Dependencies -## Standards impact + -- **Repository type / subsystem:** -- **Safety impact:** none / motion / power / battery / actuator / safety-I/O / other -- **Interface or compatibility impact:** -- **Documentation impact and canonical source:** -- **Readiness impact:** none / evidence added / evidence invalidated +## Safety impact -- [ ] I reviewed the [Engineering Quality Standard](../ENGINEERING_QUALITY_STANDARD.md). -- [ ] Documentation changes follow the [Documentation Information Architecture](https://github.com/openAMRobot/openamrobot-docs/blob/main/docs/DOCUMENTATION_INFORMATION_ARCHITECTURE.md). -- [ ] I have not described planned or unverified work as passing, validated, production-ready, or release-ready. + -## Validation +## STATE.md -List commands, tests, simulation runs, hardware checks, documentation checks, and observable results. + -## Safety and compatibility +## Not verified -Describe effects on robot motion, power, actuators, batteries, safety I/O, networking, APIs, interfaces, migration, releases, or supported hardware. Write “None” only after considering each area. + -## Intellectual property and provenance +## AI disclosure -- [ ] Every commit is signed off under the [DCO](../DCO.md). -- [ ] I am covered by an accepted [Individual or Corporate Contributor Agreement](../CLA.md). -- [ ] I have authority from any relevant employer, university, client, sponsor, co-author, or organization. -- [ ] I identified all third-party code, designs, data, media, models, and adapted examples with source and licence. -- [ ] I disclosed material generative-AI assistance and reviewed the output for provenance, security, correctness, and licence risk. -- [ ] I did not submit unauthorized confidential, personal, proprietary, credential, or export-controlled information. -- [ ] Required copyright, licence, patent, modification, and attribution notices are included. + -## Quality +## Contribution terms -- [ ] The change is focused and contains no unrelated artifacts. -- [ ] Documentation and notices are updated. -- [ ] Tests appropriate to the change pass. -- [ ] I reviewed the final diff. -- [ ] I understand that submission does not guarantee acceptance and that accepted contributions are governed by the Contributor Agreement and applicable outbound licence. +- [ ] Every commit is signed off under the [DCO](https://github.com/openAMRobot/.github/blob/main/DCO.md). +- [ ] I am covered by an accepted [Contributor Agreement](https://github.com/openAMRobot/.github/blob/main/CLA.md). +- [ ] Third-party material is identified with source and licence. +- [ ] No confidential, personal, credential or export-controlled information is included. diff --git a/tests/test_check_pr_evidence.py b/tests/test_check_pr_evidence.py new file mode 100644 index 0000000..e77faf1 --- /dev/null +++ b/tests/test_check_pr_evidence.py @@ -0,0 +1,215 @@ +"""Tests for tools/check_pr_evidence.py.""" +import contextlib +import io +import json +import sys +import tempfile +import unittest +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import check_pr_evidence as ev # noqa: E402 + +MAINTAINERS = yaml.safe_load((ROOT / "maintainers.yaml").read_text(encoding="utf-8")) +HEAD = "8ce9314fa9a404564fa7e954cd84f25bcba2b829" +BODY = f"""## Summary +Adds a filter node. + +## Work package +#12 + +## Integration Gate +Reused the upstream madgwick filter; no overlapping PRs. + +## Tests +Ran 12 tests, 0 skipped. Reverting the change makes test_topic_owner fail. + +## Evidence +Base SHA: d1ac6b64db830f001eb4d9d45a48910209b180bf +Head SHA: {HEAD[:12]} + +``` +$ bash tools/verify.sh +PASS +``` + +## Dependencies +None + +## Safety impact +None + +## STATE.md +Updated. + +## Not verified +Hardware run on the robot. + +## AI disclosure +None +""" + + +def pr(body=BODY, draft=False, reviewers=(), author="contributor"): + return {"number": 7, "body": body, "draft": draft, "head": {"sha": HEAD}, + "user": {"login": author}, "requested_reviewers": [{"login": r} for r in reviewers]} + + +def evaluate(body=BODY, changed=("src/node.py",), **kw): + reviews = kw.pop("reviews", ()) + has_state = kw.pop("has_state", False) + return ev.evaluate(pr(body, **kw), list(changed), MAINTAINERS, reviews, has_state) + + +class Sections(unittest.TestCase): + def test_complete_description_passes(self): + failures, warnings, _ = evaluate() + self.assertEqual((failures, warnings), ([], [])) + + def test_each_required_section_is_enforced(self): + for name in ev.SECTIONS: + with self.subTest(section=name): + body = BODY.replace(f"## {name}\n", "## Something else\n") + self.assertIn(f"Missing section: {name}", evaluate(body)[0]) + + def test_empty_section_fails(self): + body = BODY.replace("## Not verified\nHardware run on the robot.", "## Not verified\n\n") + self.assertIn("Empty section: Not verified", evaluate(body)[0]) + + def test_unfilled_template_fails(self): + template = (ROOT / ".github" / "PULL_REQUEST_TEMPLATE.md").read_text(encoding="utf-8") + failures = evaluate(template)[0] + self.assertIn("Evidence: no base SHA (write `Base SHA: `)", failures) + self.assertIn("Evidence: no exact command (use a code block or `$ command` lines)", failures) + self.assertTrue(any(f.startswith("Empty section") for f in failures)) + + def test_template_contains_every_required_section(self): + template = (ROOT / ".github" / "PULL_REQUEST_TEMPLATE.md").read_text(encoding="utf-8") + secs = ev.sections(template) + for name in ev.SECTIONS + ["Dependencies", "STATE.md"]: + self.assertIsNotNone(ev.find(secs, name), name) + + +class EvidenceRules(unittest.TestCase): + def test_missing_shas(self): + body = BODY.replace("Base SHA: d1ac6b64db830f001eb4d9d45a48910209b180bf\n", "").replace(f"Head SHA: {HEAD[:12]}", "") + failures = evaluate(body)[0] + self.assertIn("Evidence: no base SHA (write `Base SHA: `)", failures) + self.assertIn("Evidence: no head SHA (write `Head SHA: `)", failures) + + def test_stale_head_fails_when_ready_and_warns_in_draft(self): + body = BODY.replace(HEAD[:12], "0123456789ab") + self.assertTrue(any("is not the PR head" in f for f in evaluate(body)[0])) + failures, warnings, _ = evaluate(body, draft=True) + self.assertEqual(failures, []) + self.assertTrue(any("update before ready" in w for w in warnings)) + + def test_dollar_command_lines_count_as_commands(self): + body = BODY.replace("```\n$ bash tools/verify.sh\nPASS\n```", "$ bash tools/verify.sh") + self.assertEqual(evaluate(body)[0], []) + + +class TestRules(unittest.TestCase): + def test_test_change_without_count_fails(self): + body = BODY.replace("Ran 12 tests, 0 skipped.", "Tests were run.") + failures = evaluate(body, changed=["tests/test_node.py"])[0] + self.assertIn("Test files changed (1) but no test run with a count is reported", failures) + + def test_zero_tests_fails(self): + body = BODY.replace("Ran 12 tests, 0 skipped.", "Ran 0 tests.") + self.assertIn("Reported test run executed zero tests", evaluate(body, changed=["pkg/test/test_a.py"])[0]) + + def test_test_change_with_count_passes(self): + self.assertEqual(evaluate(changed=["web/src/app.test.ts"])[0], []) + + +class DependencyAndStateRules(unittest.TestCase): + def test_manifest_change_needs_dependencies_section(self): + failures = evaluate(changed=["ros2/pkg/package.xml"])[0] + self.assertTrue(any(f.startswith("Dependency manifests changed") for f in failures)) + body = BODY.replace("## Dependencies\nNone", "## Dependencies\nAdded imu_filter_madgwick (BSD-3-Clause, ROS index)") + self.assertEqual(evaluate(body, changed=["ros2/pkg/package.xml"])[0], []) + + def test_state_md_must_be_updated_or_explained(self): + self.assertIn("STATE.md exists but is not updated; update it or write 'no change' with a reason", + evaluate(has_state=True)[0]) + self.assertEqual(evaluate(changed=["src/node.py", "STATE.md"], has_state=True)[0], []) + body = BODY.replace("## STATE.md\nUpdated.", "## STATE.md\nNo change: typo fix only.") + self.assertEqual(evaluate(body, has_state=True)[0], []) + + +class SafetyRules(unittest.TestCase): + CHANGED = ["firmware/src/estop_monitor.cpp"] + + def test_safety_path_needs_two_humans_including_platform_lead(self): + failures = evaluate(changed=self.CHANGED, reviewers=["someone"])[0] + self.assertIn("Safety paths changed: 1 human reviewer(s) requested, 2 required", failures) + self.assertIn("Safety paths changed: platform lead @BotshareAI is not among the reviewers", failures) + + def test_bots_and_author_do_not_count(self): + failures = evaluate(changed=self.CHANGED, reviewers=["BotshareAI", "claude[bot]", "contributor"])[0] + self.assertIn("Safety paths changed: 1 human reviewer(s) requested, 2 required", failures) + + def test_two_humans_with_lead_pass_including_submitted_reviews(self): + reviews = [{"user": {"login": "panthera-momagdii"}}] + self.assertEqual(evaluate(changed=self.CHANGED, reviewers=["BotshareAI"], reviews=reviews)[0], []) + + def test_ai_assisted_safety_change_fails(self): + body = BODY.replace("## AI disclosure\nNone", "## AI disclosure\nClaude Code drafted the watchdog change.") + failures = evaluate(body, changed=["fw/watchdog.c"], reviewers=["BotshareAI", "panthera-momagdii"])[0] + self.assertTrue(any("agents do not author safety logic" in f for f in failures)) + + +class Comment(unittest.TestCase): + def test_render_has_marker_and_status(self): + text = ev.render(["x"], [], [], pr()) + self.assertTrue(text.startswith(ev.MARKER)) + self.assertIn("PR evidence check: FAIL", text) + self.assertIn("never approves or merges", text) + + def test_upsert_updates_existing_comment(self): + calls = [] + + def fake(method, url, token, data=None): + calls.append((method, url)) + if method == "GET": + return [{"id": 1, "body": "other"}, {"id": 2, "body": ev.MARKER + " old"}] + return {} + + self.assertEqual(ev.upsert_comment("o/r", 7, "new", "t", call=fake), "updated") + self.assertEqual(calls[-1], ("PATCH", "https://api.github.com/repos/o/r/issues/comments/2")) + + def test_upsert_creates_when_absent(self): + calls = [] + + def fake(method, url, token, data=None): + calls.append((method, url)) + return [] if method == "GET" else {} + + self.assertEqual(ev.upsert_comment("o/r", 7, "new", "t", call=fake), "created") + self.assertEqual(calls[-1], ("POST", "https://api.github.com/repos/o/r/issues/7/comments")) + + +class CommandLine(unittest.TestCase): + def test_main_exit_codes(self): + with tempfile.TemporaryDirectory() as tmp: + event, changed = Path(tmp, "event.json"), Path(tmp, "changed.txt") + changed.write_text("src/node.py\n", encoding="utf-8") + event.write_text(json.dumps({"pull_request": pr()}), encoding="utf-8") + args = ["--event", str(event), "--changed-files", str(changed), + "--maintainers", str(ROOT / "maintainers.yaml"), "--output", str(Path(tmp, "s.md"))] + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(ev.main(args), 0) + event.write_text(json.dumps({"pull_request": pr(body="## Summary\nx\n")}), encoding="utf-8") + self.assertEqual(ev.main(args), 1) + self.assertIn(ev.MARKER, Path(tmp, "s.md").read_text(encoding="utf-8")) + event.write_text(json.dumps({"push": {}}), encoding="utf-8") + with contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(ev.main(args), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/check_pr_evidence.py b/tools/check_pr_evidence.py new file mode 100644 index 0000000..4406510 --- /dev/null +++ b/tools/check_pr_evidence.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Check a pull request description and diff against the evidence rules. + +Fails when a required template section is missing or empty, when the +Evidence section lacks base SHA, head SHA or a command, when test files +change without a reported non-zero test run, or when safety paths change +without two human reviewers including the platform lead. Writes one +Markdown summary; with --post it creates or updates a single PR comment +identified by a hidden marker. Exit status: 0 pass, 1 fail, 2 usage error. +""" +import argparse +import fnmatch +import json +import os +import re +import sys +import urllib.request +from pathlib import Path + +import yaml + +MARKER = "" +SECTIONS = ["Work package", "Integration Gate", "Tests", "Evidence", "Not verified", "AI disclosure"] +SHA = r"\b[0-9a-f]{7,40}\b" +TEST_PATH = [ + "test/*", "tests/*", "*/test/*", "*/tests/*", "*test_*.py", "*_test.py", "*_test.*", + "*.test.*", "*.spec.*", "*/__tests__/*", +] +DEPENDENCY_FILES = [ + "package.xml", "requirements*.txt", "pyproject.toml", "setup.py", "setup.cfg", "Pipfile*", + "package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "*.repos", + "platformio.ini", "Cargo.toml", "Cargo.lock", "go.mod", "go.sum", +] +NONE = re.compile(r"^\s*(?:none|no|n/a)\b", re.I) +COUNT = re.compile( + r"(?:\bran\s+(?P\d+)\s+tests?)|(?:\b(?P\d+)\s+(?:tests?\s+)?passed)|(?:\b(?P\d+)\s+tests?\b)", + re.I, +) + + +def sections(body): + """Map heading text (level 2 or 3) to its content, HTML comments removed.""" + body = re.sub(r"", "", body or "", flags=re.S) + out, current = {}, None + for line in body.splitlines(): + m = re.match(r"^#{2,3}\s+(.+?)\s*$", line) + if m: + current = m.group(1).strip().lower() + out[current] = [] + elif current is not None: + out[current].append(line) + return {k: "\n".join(v).strip() for k, v in out.items()} + + +def find(secs, name): + name = name.lower() + for key, value in secs.items(): + if key == name or key.startswith(name): + return value + return None + + +def is_test_path(path): + return any(fnmatch.fnmatch(path, p) for p in TEST_PATH) + + +def reported_counts(text): + counts = [] + for m in COUNT.finditer(text or ""): + counts.append(int(next(g for g in m.groups() if g is not None))) + return counts + + +def human(login): + return bool(login) and not login.endswith("[bot]") + + +def evaluate(pr, changed, maintainers, reviews=(), has_state=False): + """Return (failures, warnings, notes) for a pull_request payload.""" + failures, warnings, notes = [], [], [] + secs = sections(pr.get("body") or "") + for name in SECTIONS: + content = find(secs, name) + if content is None: + failures.append(f"Missing section: {name}") + elif not re.sub(r"[-*\s:|]|\[ \]", "", content): + failures.append(f"Empty section: {name}") + + evidence = find(secs, "Evidence") or "" + base = re.search(r"base(?:\s+sha)?\s*[:=]\s*`?(" + SHA + ")", evidence, re.I) + head = re.search(r"head(?:\s+sha)?\s*[:=]\s*`?(" + SHA + ")", evidence, re.I) + if not base: + failures.append("Evidence: no base SHA (write `Base SHA: `)") + if not head: + failures.append("Evidence: no head SHA (write `Head SHA: `)") + elif pr.get("head", {}).get("sha") and not pr["head"]["sha"].startswith(head.group(1)): + text = f"Evidence: stated head {head.group(1)} is not the PR head {pr['head']['sha'][:12]}" + (warnings if pr.get("draft") else failures).append(text + ("; update before ready for review" if pr.get("draft") else "")) + fenced = [b for b in re.findall(r"```[^\n]*\n(.*?)```", evidence, re.S) if b.strip()] + if not fenced and not re.search(r"^\s*\$ \S", evidence, re.M): + failures.append("Evidence: no exact command (use a code block or `$ command` lines)") + + tests_changed = sorted(p for p in changed if is_test_path(p)) + if tests_changed: + counts = reported_counts((find(secs, "Tests") or "") + "\n" + evidence) + if not counts: + failures.append(f"Test files changed ({len(tests_changed)}) but no test run with a count is reported") + elif max(counts) == 0: + failures.append("Reported test run executed zero tests") + else: + notes.append(f"Test files changed: {len(tests_changed)}; reported run counts: {counts}") + + deps = sorted(p for p in changed if any(fnmatch.fnmatch(p.rsplit("/", 1)[-1], g) for g in DEPENDENCY_FILES)) + if deps: + section = find(secs, "Dependencies") or "" + if not section or NONE.match(section): + failures.append(f"Dependency manifests changed ({', '.join(deps[:5])}) but the Dependencies " + "section does not name the change, licence and source") + + if has_state and "STATE.md" not in changed: + section = find(secs, "STATE.md") or "" + if not re.search(r"no change\W+\w", section, re.I): + failures.append("STATE.md exists but is not updated; update it or write 'no change' with a reason") + + roles = (maintainers or {}).get("roles", {}) + lead = (roles.get("platform-lead") or {}).get("handle") + safety = [p for p in changed if any(fnmatch.fnmatch(p, g) or fnmatch.fnmatch(p, g.replace("**/", "")) + for g in (maintainers or {}).get("safety_paths", []))] + if safety: + people = {u.get("login") for u in pr.get("requested_reviewers") or []} + people |= {r.get("user", {}).get("login") for r in reviews} + people = {p for p in people if human(p) and p != pr.get("user", {}).get("login")} + notes.append(f"Safety paths changed: {', '.join(safety[:10])}") + if len(people) < 2: + failures.append(f"Safety paths changed: {len(people)} human reviewer(s) requested, 2 required") + if lead and lead not in people: + failures.append(f"Safety paths changed: platform lead @{lead} is not among the reviewers") + ai = find(secs, "AI disclosure") or "" + if ai and not NONE.match(ai): + failures.append("Safety paths changed in a PR with AI assistance; agents do not author " + "safety logic, a human authors it and the agent reports the need in an issue") + return failures, warnings, notes + + +def render(failures, warnings, notes, pr): + status = "FAIL" if failures else "PASS" + lines = [MARKER, f"### PR evidence check: {status}", "", + f"Head checked: `{pr.get('head', {}).get('sha', 'unknown')[:12]}`. " + "This comment is updated in place on every push. It never approves or merges.", ""] + for title, items in (("Failures", failures), ("Warnings", warnings), ("Notes", notes)): + if items: + lines.append(f"**{title}**") + lines += [f"- {i}" for i in items] + lines.append("") + if not (failures or warnings or notes): + lines.append("All required sections and evidence are present.") + lines.append("Rules: AGENTS.md in openAMRobot/.github; template: .github/PULL_REQUEST_TEMPLATE.md.") + return "\n".join(lines) + "\n" + + +def api(method, url, token, data=None): + req = urllib.request.Request(url, method=method, data=json.dumps(data).encode() if data else None, + headers={"Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json"}) + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read() or b"null") + + +def upsert_comment(repo, number, body, token, call=api): + base = f"https://api.github.com/repos/{repo}/issues" + page = 1 + while True: + comments = call("GET", f"{base}/{number}/comments?per_page=100&page={page}", token) + for c in comments: + if MARKER in (c.get("body") or ""): + call("PATCH", f"{base}/comments/{c['id']}", token, {"body": body}) + return "updated" + if len(comments) < 100: + break + page += 1 + call("POST", f"{base}/{number}/comments", token, {"body": body}) + return "created" + + +def main(argv=None): + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument("--event", type=Path, default=os.environ.get("GITHUB_EVENT_PATH")) + p.add_argument("--changed-files", type=Path, required=True) + p.add_argument("--maintainers", type=Path, required=True) + p.add_argument("--reviews", type=Path, help="JSON list of PR reviews") + p.add_argument("--root", type=Path, help="checkout of the PR head; enables the STATE.md rule") + p.add_argument("--output", type=Path, help="write the Markdown summary here") + p.add_argument("--post", action="store_true", help="create or update the PR comment") + a = p.parse_args(argv) + if not a.event: + p.error("--event or GITHUB_EVENT_PATH is required") + event = json.loads(Path(a.event).read_text(encoding="utf-8")) + pr = event.get("pull_request") + if not pr: + print("not a pull_request event; nothing to check", file=sys.stderr) + return 2 + changed = [l.strip() for l in a.changed_files.read_text(encoding="utf-8").splitlines() if l.strip()] + maintainers = yaml.safe_load(a.maintainers.read_text(encoding="utf-8")) + reviews = json.loads(a.reviews.read_text(encoding="utf-8")) if a.reviews else [] + has_state = bool(a.root and (a.root / "STATE.md").is_file()) + failures, warnings, notes = evaluate(pr, changed, maintainers, reviews, has_state) + summary = render(failures, warnings, notes, pr) + print(summary) + if a.output: + a.output.write_text(summary, encoding="utf-8") + if a.post: + token, repo = os.environ.get("GITHUB_TOKEN"), os.environ.get("GITHUB_REPOSITORY") + if not token or not repo: + print("--post needs GITHUB_TOKEN and GITHUB_REPOSITORY", file=sys.stderr) + return 2 + print(f"comment {upsert_comment(repo, pr['number'], summary, token)}") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 544ec0f465e5dd833f8a7156bdb41691647108c5 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Mon, 28 Sep 2026 22:43:03 +0000 Subject: [PATCH 005/129] rollout: reference verify.sh with zero-test and skip rules Follows openamrobot-interfaces/tools/verify.sh (clean environment, run directory, result file) and delegates to a repository's own tools/verify.sh when one exists. Adds project detection, a zero-tests executed failure, a rule that skip, xfail and importorskip name a tracking issue, and summary.json evidence with SHAs and test counts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- rollout/verify.sh | 177 ++++++++++++++++++++++++++++++++++++++++ tests/test_verify_sh.py | 80 ++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100755 rollout/verify.sh create mode 100644 tests/test_verify_sh.py diff --git a/rollout/verify.sh b/rollout/verify.sh new file mode 100755 index 0000000..7e9a05d --- /dev/null +++ b/rollout/verify.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# OpenAMRobot reference verification: install, build, lint, test, evidence. +# +# Usage: verify.sh [REPOSITORY_ROOT] (default: the git top level of the current directory) +# +# Follows openamrobot-interfaces/tools/verify.sh: every stage runs in a clean +# environment, all output goes to .verification/run.*/verification.log, and +# result.txt says PASS or FAIL with the failing stage. This script adds: +# - project detection: ROS 2 (colcon), Node (npm), Python (unittest or pytest); +# - the zero-tests rule: a test stage that executes zero tests fails; +# - the skip rule: skip, xfail and importorskip must name a tracking issue +# (#123 or an issues/123 URL) on the same line; +# - summary.json with base/head SHA, stage results and test counts. +# A repository that already has tools/verify.sh keeps it; this script delegates +# to it (openamrobot-interfaces is the reference implementation). +# Per-repository overrides live in .openamrobot/verify.env (VERIFY_INSTALL, +# VERIFY_BUILD, VERIFY_LINT, VERIFY_TEST, VERIFY_ROS_DISTRO); each is a shell command. +set -eo pipefail + +root=$(cd -- "${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" && pwd) +self=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/$(basename -- "${BASH_SOURCE[0]}") +if [ -f "$root/tools/verify.sh" ] && [ "$root/tools/verify.sh" != "$self" ] && [ -z "${VERIFY_NO_DELEGATE:-}" ]; then + echo "Delegating to the repository's own tools/verify.sh" + exec bash "$root/tools/verify.sh" +fi + +mkdir -p "$root/.verification" +run=$(mktemp -d "$root/.verification/run.XXXXXX") +exec > >(tee "$run/verification.log") 2>&1 +stage=prerequisites +stages=() +tests_total=0 +tests_skipped=0 + +finish() { + result=$? + if [ "$result" -eq 0 ]; then + echo "PASS: all detected verification stages" | tee "$run/result.txt" + else + echo "FAIL: $stage (exit $result)" | tee "$run/result.txt" + fi + python3 - "$run" "$root" "$result" "$stage" "$tests_total" "$tests_skipped" "${stages[@]}" <<'PY' || true +import json, subprocess, sys +run, root, result, stage, total, skipped, *done = sys.argv[1:] +def git(*a): + try: + return subprocess.run(["git", "-C", root, *a], capture_output=True, text=True, check=True).stdout.strip() + except Exception: + return None +json.dump({ + "result": "PASS" if result == "0" else "FAIL", + "failed_stage": None if result == "0" else stage, + "stages_passed": done, + "tests_total": int(total), "tests_skipped": int(skipped), + "head_sha": git("rev-parse", "HEAD"), + "base_sha": git("merge-base", "HEAD", "origin/main"), +}, open(f"{run}/summary.json", "w"), indent=2) +PY + echo "Evidence: $run" + exit "$result" +} +trap finish EXIT + +pass() { stages+=("$stage"); echo "PASS: $stage"; } + +# Never inherit overlays, Python paths or prefixes from the caller. +clean_bash() { + env -i HOME="$run/home" PATH="${VERIFY_PATH:-/usr/local/bin:/usr/bin:/bin}" LANG=C.UTF-8 \ + PYTHONNOUSERSITE=1 PYTHONDONTWRITEBYTECODE=1 CI="${CI:-}" \ + bash --noprofile --norc -eo pipefail "$@" +} +mkdir -p "$run/home" + +if [ -f "$root/.openamrobot/verify.env" ]; then + # shellcheck disable=SC1091 + source "$root/.openamrobot/verify.env" +fi +distro=${VERIFY_ROS_DISTRO:-jazzy} + +ros=false; node=false; python=false +if find "$root" -name package.xml -not -path '*/node_modules/*' -not -path '*/.verification/*' \ + -not -path '*/tests/fixtures/*' | grep -q .; then ros=true; fi +[ -f "$root/package.json" ] && node=true +if [ -f "$root/pyproject.toml" ] || [ -f "$root/setup.py" ] || [ -d "$root/tests" ]; then python=true; fi +echo "Detected: ros=$ros node=$node python=$python" +command -v git python3 >/dev/null +if $ros; then test -f "/opt/ros/$distro/setup.bash"; fi +if $node; then command -v npm >/dev/null; fi +if ! $ros && ! $node && ! $python && [ -z "${VERIFY_TEST:-}" ]; then + echo "FAIL: no buildable or testable project detected; set VERIFY_TEST in .openamrobot/verify.env" + exit 1 +fi +pass + +stage=install +if [ -n "${VERIFY_INSTALL:-}" ]; then clean_bash -c "cd '$root' && $VERIFY_INSTALL" +elif $node; then clean_bash -c "cd '$root' && npm ci" +elif $ros; then + clean_bash -c "source /opt/ros/$distro/setup.bash && rosdep check --from-paths '$root' --ignore-src --rosdistro $distro" +fi +pass + +stage=build +if [ -n "${VERIFY_BUILD:-}" ]; then clean_bash -c "cd '$root' && $VERIFY_BUILD" +elif $ros; then + mkdir -p "$run/ws/src" && cp -a "$root/." "$run/ws/src/repo" && rm -rf "$run/ws/src/repo/.verification" + clean_bash -c "source /opt/ros/$distro/setup.bash && cd '$run/ws' && colcon build --event-handlers console_direct+" +elif $node; then clean_bash -c "cd '$root' && npm run build --if-present" +fi +pass + +stage=lint +if [ -n "${VERIFY_LINT:-}" ]; then clean_bash -c "cd '$root' && $VERIFY_LINT" +else + git -C "$root" ls-files -z '*.py' | (cd "$root" && xargs -0 -r python3 -m py_compile) + git -C "$root" ls-files -z '*.sh' | (cd "$root" && xargs -0 -r -n1 bash -n) + if command -v shellcheck >/dev/null; then + git -C "$root" ls-files -z '*.sh' | (cd "$root" && xargs -0 -r shellcheck -S warning) + fi + if $node; then clean_bash -c "cd '$root' && npm run lint --if-present"; fi +fi +pass + +stage=test-markers +# Every skip, xfail or importorskip names a tracking issue on the same line. +unmarked=$(cd "$root" && git ls-files -z -- '*.py' '*.ts' '*.tsx' '*.js' '*.jsx' '*.cpp' '*.hpp' '*.c' '*.h' \ + | xargs -0 -r grep -nE '(pytest\.mark\.(skip|skipif|xfail)|pytest\.(skip|xfail|importorskip)\(|unittest\.skip|self\.skipTest\(|\b(it|test|describe)\.skip\(|\bxit\(|GTEST_SKIP)' 2>/dev/null \ + | grep -vE '(#[0-9]+|issues/[0-9]+)' || true) +if [ -n "$unmarked" ]; then + echo "$unmarked" + echo "FAIL: skip/xfail/importorskip without a tracking issue (#123 or issues/123) on the same line" + exit 1 +fi +pass + +stage=test +log="$run/test.log" +if [ -n "${VERIFY_TEST:-}" ]; then clean_bash -c "cd '$root' && $VERIFY_TEST" 2>&1 | tee "$log" +elif $ros; then + clean_bash -c "source /opt/ros/$distro/setup.bash && cd '$run/ws' && colcon test --event-handlers console_direct+ && colcon test-result --verbose" 2>&1 | tee "$log" +elif $node; then clean_bash -c "cd '$root' && npm test" 2>&1 | tee "$log" +elif [ -f "$root/pyproject.toml" ] && python3 -c 'import pytest' 2>/dev/null; then + clean_bash -c "cd '$root' && python3 -m pytest -rs" 2>&1 | tee "$log" +else + clean_bash -c "cd '$root' && python3 -m unittest discover -s tests -v" 2>&1 | tee "$log" +fi +read -r tests_total tests_skipped < <(python3 - "$log" <<'PY' +import re, sys +text = open(sys.argv[1], encoding="utf-8", errors="replace").read() +total = skipped = 0 +for pattern, t, s in [ + (r"Summary: (\d+) tests?, \d+ errors?, \d+ failures?, (\d+) skipped", 1, 2), # colcon test-result + (r"^Ran (\d+) tests? in", 1, None), # unittest + (r"^Tests:\s+(?:.*?(\d+) skipped, )?.*?(\d+) total", 2, 1), # jest + (r"^\s+Tests\s+(?:.*?(\d+) skipped.*?)?\((\d+)\)", 2, 1), # vitest +]: + for m in re.finditer(pattern, text, re.M): + total += int(m.group(t) or 0) + skipped += int(m.group(s) or 0) if s else 0 +m = re.findall(r"=+ (?:(\d+) passed)?(?:, )?(?:(\d+) skipped)?.* in [\d.]+s", text) # pytest +for passed, skip in m: + total += int(passed or 0) + int(skip or 0) + skipped += int(skip or 0) +skipped += sum(int(n) for n in re.findall(r"skipped=(\d+)", text)) # unittest +print(total, skipped) +PY +) +echo "Tests executed: $((tests_total - tests_skipped)) of $tests_total (skipped $tests_skipped)" +if [ "$((tests_total - tests_skipped))" -le 0 ]; then + echo "FAIL: zero tests executed; an empty or fully skipped suite is not evidence" + exit 1 +fi +pass + +stage=evidence +cp "$log" "$run/test-output.log" 2>/dev/null || true +pass diff --git a/tests/test_verify_sh.py b/tests/test_verify_sh.py new file mode 100644 index 0000000..dac959a --- /dev/null +++ b/tests/test_verify_sh.py @@ -0,0 +1,80 @@ +"""Tests for rollout/verify.sh: zero-test rule, skip rule, delegation.""" +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +VERIFY = ROOT / "rollout" / "verify.sh" +PASSING = "import unittest\n\nclass T(unittest.TestCase):\n def test_one(self):\n self.assertTrue(True)\n" + + +def make_repo(files): + tmp = tempfile.TemporaryDirectory() + root = Path(tmp.name) + for rel, text in files.items(): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + subprocess.run(["git", "init", "-q", str(root)], check=True) + subprocess.run(["git", "-C", str(root), "add", "-A"], check=True) + return tmp, root + + +def verify(root): + proc = subprocess.run(["bash", str(VERIFY), str(root)], capture_output=True, text=True, timeout=120, + env={"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": str(root)}) + runs = sorted((root / ".verification").glob("run.*")) + summary = json.loads((runs[-1] / "summary.json").read_text()) if runs else {} + return proc.returncode, proc.stdout + proc.stderr, summary + + +class VerifyScript(unittest.TestCase): + def check(self, files): + tmp, root = make_repo(files) + self.addCleanup(tmp.cleanup) + return verify(root) + + def test_passing_suite_passes_with_counts(self): + code, out, summary = self.check({"tests/test_a.py": PASSING}) + self.assertEqual(code, 0, out) + self.assertEqual((summary["result"], summary["tests_total"]), ("PASS", 1)) + + def test_zero_tests_fail(self): + code, out, summary = self.check({"tests/test_a.py": "# no tests yet\n"}) + self.assertNotEqual(code, 0) + self.assertIn("zero tests executed", out) + self.assertEqual(summary["failed_stage"], "test") + + def test_fully_skipped_suite_fails(self): + body = PASSING.replace(" def test_one", " @unittest.skip('flaky, see #12')\n def test_one") + code, out, _ = self.check({"tests/test_a.py": body}) + self.assertNotEqual(code, 0) + self.assertIn("zero tests executed", out) + + def test_skip_without_issue_fails(self): + body = PASSING + "\n @unittest." + "skip('later')\n def test_two(self):\n pass\n" + code, out, summary = self.check({"tests/test_a.py": body}) + self.assertNotEqual(code, 0) + self.assertEqual(summary["failed_stage"], "test-markers") + + def test_skip_with_issue_is_allowed(self): + body = PASSING + "\n @unittest.skip('hardware only, see #12')\n def test_two(self):\n pass\n" + code, out, summary = self.check({"tests/test_a.py": body}) + self.assertEqual(code, 0, out) + self.assertEqual((summary["tests_total"], summary["tests_skipped"]), (2, 1)) + + def test_nothing_detected_fails(self): + code, out, _ = self.check({"notes.txt": "hello\n"}) + self.assertNotEqual(code, 0) + self.assertIn("no buildable or testable project detected", out) + + def test_delegates_to_existing_tools_verify(self): + code, out, _ = self.check({"tools/verify.sh": "echo repository-own-verify\nexit 0\n"}) + self.assertEqual(code, 0) + self.assertIn("repository-own-verify", out) + + +if __name__ == "__main__": + unittest.main() From dbd46ae570bbf4864b721f5b93a98b513fb53def Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Mon, 28 Sep 2026 22:43:03 +0000 Subject: [PATCH 006/129] ci: run decisions, public-extract and drift checks in the reusable workflow Pull requests check changed files and block. Pushes scan the full checkout and report warnings; the weekly audit owns the backlog. The harness ref is an input so callers can pin it with the uses: line. This repository also runs every checker's tests through verify.sh. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- .../workflows/repository-quality-reusable.yml | 75 +++++++++++++++++++ .github/workflows/repository-quality.yml | 27 +++++++ 2 files changed, 102 insertions(+) diff --git a/.github/workflows/repository-quality-reusable.yml b/.github/workflows/repository-quality-reusable.yml index 8601a47..d0ad5e2 100644 --- a/.github/workflows/repository-quality-reusable.yml +++ b/.github/workflows/repository-quality-reusable.yml @@ -2,6 +2,14 @@ name: Reusable repository quality on: workflow_call: + inputs: + harness_ref: + description: >- + Ref of openAMRobot/.github that provides decisions.yaml and the checkers. + Pin it to the same commit SHA as the `uses:` line of the caller. + type: string + required: false + default: main permissions: contents: read @@ -13,6 +21,15 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check out OpenAMRobot harness + uses: actions/checkout@v4 + with: + repository: openAMRobot/.github + ref: ${{ inputs.harness_ref }} + path: .openamrobot-harness - name: Verify governance baseline shell: bash @@ -53,3 +70,61 @@ jobs: for path in tracked("*.xml"): ET.parse(path) + + - name: Prepare harness checks + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + python3 -c 'import yaml' 2>/dev/null || python3 -m pip install --user 'PyYAML==6.0.2' + if [ "${{ github.event_name }}" = pull_request ]; then + git diff --name-only --diff-filter=ACMR "$BASE_SHA" "$HEAD_SHA" > "$RUNNER_TEMP/changed-files.txt" + echo "scope=changed files ($(wc -l < "$RUNNER_TEMP/changed-files.txt"))" + fi + + # Pull requests: changed files only, blocking. A new contradiction cannot merge. + # Push and schedule: full checkout, reported as warnings; the weekly + # alignment audit turns the backlog into issues with owners. + - name: Decisions of record + shell: bash + run: | + set -uo pipefail + h=.openamrobot-harness + scope=() + if [ "${{ github.event_name }}" = pull_request ]; then + scope=(--changed-files "$RUNNER_TEMP/changed-files.txt") + fi + python3 "$h/tools/check_decisions.py" --decisions "$h/decisions.yaml" \ + --maintainers "$h/maintainers.yaml" --root . \ + --repository "${{ github.event.repository.name }}" "${scope[@]}" | tee "$RUNNER_TEMP/decisions.txt" + status=${PIPESTATUS[0]} + grep '^CONTRADICTION ' "$RUNNER_TEMP/decisions.txt" | sed -E 's/^CONTRADICTION ([^:]+):([0-9]+): /::warning file=\1,line=\2::/' || true + if [ "${{ github.event_name }}" = pull_request ] || [ "$status" -eq 2 ]; then exit "$status"; fi + + - name: Public extract + shell: bash + run: | + set -uo pipefail + h=.openamrobot-harness + scope=() + if [ "${{ github.event_name }}" = pull_request ]; then + scope=(--changed-files "$RUNNER_TEMP/changed-files.txt") + fi + python3 "$h/tools/check_public_extract.py" --root . --allowlist "$h/public-extract-allowlist.yaml" \ + --repository "${{ github.event.repository.name }}" "${scope[@]}" | tee "$RUNNER_TEMP/extract.txt" + status=${PIPESTATUS[0]} + grep '^PUBLIC-EXTRACT ' "$RUNNER_TEMP/extract.txt" | sed -E 's/^PUBLIC-EXTRACT ([^:]+):([0-9]+): /::warning file=\1,line=\2::/' || true + if [ "${{ github.event_name }}" = pull_request ] || [ "$status" -eq 2 ]; then exit "$status"; fi + + - name: Shared agent rules + shell: bash + run: | + set -euo pipefail + if [ -f AGENTS.md ]; then + python3 .openamrobot-harness/tools/check_agent_rules.py \ + --canonical .openamrobot-harness/agent-rules/SHARED_RULES.md --file AGENTS.md + else + echo "No AGENTS.md in this repository; see rollout/README.md in openAMRobot/.github" + fi diff --git a/.github/workflows/repository-quality.yml b/.github/workflows/repository-quality.yml index d01ecc4..acc9df5 100644 --- a/.github/workflows/repository-quality.yml +++ b/.github/workflows/repository-quality.yml @@ -12,3 +12,30 @@ jobs: repository-quality: name: repository-quality uses: ./.github/workflows/repository-quality-reusable.yml + with: + # This repository is the harness: check each change against its own head. + harness_ref: ${{ github.event.pull_request.head.sha || github.sha }} + + harness-tests: + name: quality/test + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Unit tests of every checker (zero-test guard) + shell: bash + run: | + # verify.sh runs tests without user site-packages; use the system package. + python3 -c 'import yaml' 2>/dev/null || { sudo apt-get update && sudo apt-get install -y python3-yaml; } + VERIFY_PATH="$PATH" bash rollout/verify.sh + - name: Validate decisions.yaml + run: python3 tools/check_decisions.py --decisions decisions.yaml --maintainers maintainers.yaml --validate-only + - name: Upload evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: harness-verification + path: .verification/run.*/ + include-hidden-files: true + if-no-files-found: warn From 77255cd91e5d87037311ecbf2ab3958bfdf85ee0 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Mon, 28 Sep 2026 22:44:42 +0000 Subject: [PATCH 007/129] templates: issue forms, agent prompts and STATE.md example Bug report asks for repository, SHA, exact commands and Not verified. The interface change request becomes the contract change request and covers topic names, launch argument names and configuration IDs. New forms: harness mistake (label harness, feeds the monthly retro) and good first issue (area, done-when, verify command, reviewer role). agent-prompts/ holds read-only audit, push from bundle, docs fix and evaluator pass, each with a precondition block, an expected outcome and the failure rule. Structural tests cover all of them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- .github/ISSUE_TEMPLATE/bug_report.yml | 52 +++++++++----- ...equest.yml => contract_change_request.yml} | 22 +++--- .github/ISSUE_TEMPLATE/good_first_issue.yml | 63 ++++++++++++++++ .github/ISSUE_TEMPLATE/harness_mistake.yml | 61 ++++++++++++++++ agent-prompts/README.md | 20 ++++++ agent-prompts/docs-fix.md | 42 +++++++++++ agent-prompts/evaluator-pass.md | 43 +++++++++++ agent-prompts/push-from-bundle.md | 45 ++++++++++++ agent-prompts/read-only-audit.md | 47 ++++++++++++ decisions.yaml | 2 +- rollout/STATE.md.example | 30 ++++++++ tests/test_templates.py | 71 +++++++++++++++++++ 12 files changed, 472 insertions(+), 26 deletions(-) rename .github/ISSUE_TEMPLATE/{interface_change_request.yml => contract_change_request.yml} (74%) create mode 100644 .github/ISSUE_TEMPLATE/good_first_issue.yml create mode 100644 .github/ISSUE_TEMPLATE/harness_mistake.yml create mode 100644 agent-prompts/README.md create mode 100644 agent-prompts/docs-fix.md create mode 100644 agent-prompts/evaluator-pass.md create mode 100644 agent-prompts/push-from-bundle.md create mode 100644 agent-prompts/read-only-audit.md create mode 100644 rollout/STATE.md.example create mode 100644 tests/test_templates.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index bfff1c5..d3b792e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,40 +1,58 @@ name: Bug report -description: Report a reproducible software or firmware defect +description: Report a reproducible software or firmware defect with the evidence a fix needs title: "[Bug]: " labels: ["bug", "triage"] body: - type: markdown attributes: - value: "Do not include credentials, confidential information, export-controlled data, or third-party material you cannot disclose." - - type: textarea - id: summary + value: | + Issues are public. Do not include credentials, personal data, internal document links or prices. + The fix PR links this issue in its Work package section and adds a test that fails on the defect. + - type: input + id: repository + attributes: + label: Repository and path + placeholder: "openAMRobot/, path/to/file" + validations: + required: true + - type: input + id: commit attributes: - label: Summary - description: What happened, and what did you expect? + label: Commit SHA + description: Full or short SHA of the checkout where the defect reproduces. + placeholder: "d1ac6b64db83" validations: required: true - type: textarea id: reproduce attributes: - label: Reproduction steps + label: Exact commands + description: Commands that reproduce the defect, one per line, from a clean checkout. + render: shell validations: required: true - - type: input - id: version + - type: textarea + id: expected attributes: - label: Repository version, tag, or commit + label: Expected and actual result + description: Paste the relevant output; state the decision ID from decisions.yaml if a decided value is wrong. validations: required: true - - type: textarea - id: environment + - type: dropdown + id: safety attributes: - label: Environment - description: OS, ROS version, hardware, browser, and relevant dependencies. + label: Safety impact + description: E-stop, brake, contactor, watchdog, motor-enable and charge-inhibit defects are reported, never fixed by an agent. + options: + - None + - Motion, power, battery, actuator or safety I/O (platform lead reviews) + validations: + required: true - type: textarea - id: logs + id: not_verified attributes: - label: Logs and supporting evidence - description: Remove personal, confidential, and security-sensitive information. + label: Not verified + description: What you could not check, for example real hardware or another ROS distribution. - type: checkboxes id: provenance attributes: diff --git a/.github/ISSUE_TEMPLATE/interface_change_request.yml b/.github/ISSUE_TEMPLATE/contract_change_request.yml similarity index 74% rename from .github/ISSUE_TEMPLATE/interface_change_request.yml rename to .github/ISSUE_TEMPLATE/contract_change_request.yml index 1748695..2030f98 100644 --- a/.github/ISSUE_TEMPLATE/interface_change_request.yml +++ b/.github/ISSUE_TEMPLATE/contract_change_request.yml @@ -1,11 +1,15 @@ -name: Interface change request -description: Propose a controlled change to a ROS, API, message, schema, or other shared interface. -title: "[INTERFACE] " +name: Contract change request +description: Propose a change to a message, service, action, schema, topic name, launch argument name or configuration ID. +title: "[CONTRACT] " +labels: ["contract-change", "triage"] body: - type: markdown attributes: value: | - Use this form before changing an interface consumed by another repository, node, service, tool, or operator surface. + Use this form before changing a contract consumed by another repository, node, service, tool, or operator surface: + messages, services, actions, schemas, topic names, launch argument names and configuration IDs (for example mast_1350). + The change lands in one PR that updates the contract package and every consumer together; that PR links this issue + in its Work package section. Decided values also change in decisions.yaml in openAMRobot/.github. Keep generic interfaces generic. Do not add application-specific manipulation behavior or authoritative safety behavior to a telemetry or navigation contract without an explicit owning design decision. @@ -22,7 +26,7 @@ body: id: contract attributes: label: Contract or interface - description: Name the message, service, action, API, schema, topic, or file being changed. + description: Name the message, service, action, schema, topic name, launch argument name, configuration ID or file being changed. placeholder: "repository, package, path, and interface name" validations: required: true @@ -35,9 +39,9 @@ body: options: - Documentation-only or implementation clarification - Add an enum, reason code, or constant without changing field layout - - Add, remove, rename, reorder, or retype a field — version bump and consumer rebuild required - - Change the meaning, units, defaults, or validity of an existing value — version bump and consumer review required - - Other — explain below + - Add, remove, rename, reorder, or retype a field; version bump and consumer rebuild required + - Change the meaning, units, defaults, or validity of an existing value; version bump and consumer review required + - Other, explain below validations: required: true @@ -107,3 +111,5 @@ body: required: true - label: I have not treated telemetry as authoritative safety evidence. required: true + - label: The implementing PR will update the contract package and its consumers together, or list each consumer PR. + required: true diff --git a/.github/ISSUE_TEMPLATE/good_first_issue.yml b/.github/ISSUE_TEMPLATE/good_first_issue.yml new file mode 100644 index 0000000..29a7579 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/good_first_issue.yml @@ -0,0 +1,63 @@ +name: Good first issue +description: A small, self-contained task for a first-time contributor (maintainers open these) +title: "[Good first issue]: " +labels: ["good first issue", "triage"] +body: + - type: markdown + attributes: + value: | + A good first issue can be done in one PR, needs no hardware and no access beyond a fork, and + touches no safety path or shared contract. Maintainers add the area label from CONTRIBUTING.md. + - type: dropdown + id: area + attributes: + label: Area + options: + - documentation (openamrobot-docs) + - navigation and bring-up (openamr-platform-sw) + - interfaces (openamrobot-interfaces) + - manipulation (openamrobot-manipulation) + - operator UI (openamrobot-ui) + - manifest and release (openamrobot-manifest, openamrobot-release) + - CI and harness (.github) + validations: + required: true + - type: input + id: repository + attributes: + label: Repository and files + placeholder: "openAMRobot/: path/to/file" + validations: + required: true + - type: textarea + id: task + attributes: + label: Task + description: What needs to become true, in two or three sentences. + validations: + required: true + - type: textarea + id: done + attributes: + label: Done when + description: Observable pass/fail conditions, including the test that must fail before and pass after. + value: | + - [ ] + validations: + required: true + - type: textarea + id: verify + attributes: + label: How to verify + description: The exact command a contributor runs, and the result it prints. + render: shell + validations: + required: true + - type: input + id: mentor + attributes: + label: Reviewer role + description: Role from maintainers.yaml that reviews the PR. + placeholder: "docs-owner" + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/harness_mistake.yml b/.github/ISSUE_TEMPLATE/harness_mistake.yml new file mode 100644 index 0000000..1f744a6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/harness_mistake.yml @@ -0,0 +1,61 @@ +name: Harness mistake +description: Record an agent or process mistake so the monthly retro can turn it into a rule, check or template change +title: "[Harness]: " +labels: ["harness"] +body: + - type: markdown + attributes: + value: | + One mistake per issue. The monthly retro reads every open issue with the label harness and + proposes one PR against AGENTS.md, the checkers or the templates in openAMRobot/.github. + Describe the mistake, not the person. No names, internal links or credentials. + - type: input + id: where + attributes: + label: Where it happened + description: Repository and PR or issue link. + placeholder: "openAMRobot/#" + validations: + required: true + - type: dropdown + id: actor + attributes: + label: Who made the mistake + options: + - Agent (AI-assisted or automated) + - Contributor process + - Review process + - Automation or workflow + validations: + required: true + - type: input + id: template + attributes: + label: Prompt template and version + description: For agent runs, the agent-prompts/ file and the harness commit SHA it came from. + placeholder: "agent-prompts/docs-fix.md @ " + - type: textarea + id: mistake + attributes: + label: What went wrong + description: The observable result and the evidence (diff line, log line, comment link). + validations: + required: true + - type: dropdown + id: caught + attributes: + label: What caught it + options: + - A gate (check or CI) + - Human review + - Found after merge + - Not caught yet + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed harness change + description: A rule, a check, or a template change that would make this class of mistake unmergeable. Name who decides if it cannot be checked. + validations: + required: true diff --git a/agent-prompts/README.md b/agent-prompts/README.md new file mode 100644 index 0000000..7b6e57b --- /dev/null +++ b/agent-prompts/README.md @@ -0,0 +1,20 @@ +# Agent prompt templates + +Each template is a prompt an agent (or a person) runs as written, after filling the +angle-bracket fields. Every template has the same three fixed parts: + +1. **Precondition block.** Filled in and posted before the first write. The agent checks each + line; a mismatch is a failed precondition. +2. **Expected outcome block.** What the run must produce, so the reviewer can compare. +3. **Failure rule.** A failed precondition stops the task. The agent reports the command, the + error and the next step, and opens an issue with the label harness when the failure shows a + gap in the harness. It never works around the failure. + +Record every run in agent-runs.md with the template name and the harness commit SHA. + +| Template | Use | +|---|---| +| [read-only-audit.md](read-only-audit.md) | Compare repositories against decisions.yaml and the plan; write a report, change nothing | +| [push-from-bundle.md](push-from-bundle.md) | Push a prepared, reviewed change set to a contributor branch and open a draft PR | +| [docs-fix.md](docs-fix.md) | Correct a documentation page that contradicts a decision or a repository | +| [evaluator-pass.md](evaluator-pass.md) | Check another agent's PR against the rules before a human reads it | diff --git a/agent-prompts/docs-fix.md b/agent-prompts/docs-fix.md new file mode 100644 index 0000000..a503ac4 --- /dev/null +++ b/agent-prompts/docs-fix.md @@ -0,0 +1,42 @@ +# Documentation fix + +Template version: 1. Harness: openAMRobot/.github at ``. + +## Precondition block (post before the first write) + +``` +repository: openAMRobot/openamrobot-docs (or the repository that owns the README) +branch: , from main +parent SHA:
+pages: +reason: decision in decisions.yaml, or @:: +expected outcome: see below +``` + +The owning repository is the source of truth for commands, versions, parameters and contracts; +the docs site links to it. If the page and the owning repository disagree and decisions.yaml +does not settle it, stop and report instead of choosing. + +## Task + +1. Run `python3 tools/check_decisions.py` and `python3 tools/check_public_extract.py` from the + harness on the pages; keep the output. +2. Correct only the stated pages. Keep history marked as history (`decision-allow: `). + Label legacy material as legacy rather than deleting it. +3. Run the docs repository's own checks (`scripts/check_docs.sh`, strict MkDocs build). +4. Open a draft PR with the filled template. + +No internal document links, prices, personal names, contact data or credentials in any page. +No change to safety guidance beyond removing a contradiction; new safety guidance is written by +the platform lead. + +## Expected outcome + +- Draft PR changing only the listed pages; both checkers report zero findings on them. +- The Not verified section states whether the site build and link check ran. + +## Failure rule + +A failed precondition stops the task. Report the command, the error and the next step. Do not +fix a different page, a different repository or an unrecorded value. Open an issue labelled +harness when the failure shows a gap in this template or the harness. diff --git a/agent-prompts/evaluator-pass.md b/agent-prompts/evaluator-pass.md new file mode 100644 index 0000000..e9f7ca7 --- /dev/null +++ b/agent-prompts/evaluator-pass.md @@ -0,0 +1,43 @@ +# Evaluator pass + +Template version: 1. Harness: openAMRobot/.github at ``. + +A second agent reads a PR before a human does and reports what the gates cannot see. It never +approves, requests changes, merges or pushes. + +## Precondition block (post before reading the diff) + +``` +repository: openAMRobot/ +pull request: # +head SHA: +base SHA: +rules: AGENTS.md shared block at +write access: none (report goes to the requesting person) +expected outcome: see below +``` + +If the PR head moved after the block was written, stop and restart with the new head. + +## Task + +1. Run check_pr_evidence.py, check_decisions.py and check_public_extract.py on the PR and record + their output. +2. For every rule in the shared block marked [decides: ...], state whether the PR needs that + decision and who makes it. +3. Check that each claimed test fails when the change is reverted: revert the change locally, + run the stated command, and record the result. +4. Check that nothing in the Evidence or Tests section overstates what ran (fixture as simulation, + skip as pass, draft as accepted). + +## Expected outcome + +- A report with: gate results, decisions needed and their deciders, revert-test result, and a + list of overstated claims, each with file and line. +- No write to the repository or the PR. + +## Failure rule + +A failed precondition stops the task. Report the command, the error and the next step. Do not +evaluate a different head or repository. Open an issue labelled harness when the failure shows +a gap in this template or the harness. diff --git a/agent-prompts/push-from-bundle.md b/agent-prompts/push-from-bundle.md new file mode 100644 index 0000000..12c5055 --- /dev/null +++ b/agent-prompts/push-from-bundle.md @@ -0,0 +1,45 @@ +# Push from bundle + +Template version: 1. Harness: openAMRobot/.github at ``. + +Use when a change set has been prepared and reviewed elsewhere (a patch, a git bundle or a +folder of files) and must be pushed to a contributor branch as a draft PR. + +## Precondition block (post before the first write) + +``` +repository: openAMRobot/ +branch: , not main +parent SHA: +bundle: , sha256 +files expected: +reviewed by: in +expected outcome: see below +``` + +Before writing, check that the remote default branch still contains the parent SHA, that the +bundle hash matches, and that the files the bundle changes are exactly the expected list. A +bundle prepared for one repository is never applied to another with a similar name. + +## Task + +1. Fetch the repository, create the branch from the parent SHA and apply the bundle. +2. Run the repository's verification (`tools/verify.sh`, or `rollout/verify.sh` from the harness). +3. Commit with `git commit -s` only under the contributor identity configured for this session. +4. Push the branch and open a draft PR using the repository's PR template, filled in, with + the AI disclosure and the verification evidence. + +Never force-push, merge, change settings, or push to main. + +## Expected outcome + +- One draft PR whose diff equals the bundle, on the stated parent SHA. +- The PR evidence check comment shows PASS, or the PR description lists each failure and why. +- The PR stays draft until the work-package owner writes adopt, adapt or reject. + +## Failure rule + +A failed precondition stops the task: wrong repository, parent SHA not found, hash mismatch, +unexpected files, or verification failure. Report the command, the error and the next step. +Do not rebase, regenerate or edit the bundle to make it fit. Open an issue labelled harness +when the failure shows a gap in this template or the harness. diff --git a/agent-prompts/read-only-audit.md b/agent-prompts/read-only-audit.md new file mode 100644 index 0000000..74007d5 --- /dev/null +++ b/agent-prompts/read-only-audit.md @@ -0,0 +1,47 @@ +# Read-only audit + +Template version: 1. Harness: openAMRobot/.github at ``. + +## Precondition block (post before starting) + +``` +repository: openAMRobot/ (report destination) and the audited checkouts +branch: , created from main +parent SHA:
+audited SHAs: @, one line per checkout +previous report: /ISSUES.csv at , or "none" +decisions: openAMRobot/.github decisions.yaml at +write access: audit repository only; every audited repository is read-only +expected outcome: see below +``` + +Check every line before reading anything else. If a repository, branch or SHA differs from the +block, or a source cannot be read, apply the failure rule. + +## Task + +1. Run `python3 tools/check_decisions.py --decisions decisions.yaml --root --repository ` + for every audited checkout and keep the output. +2. Compare each finding of the previous ISSUES.csv with the current checkouts: mark it + resolved (cite the SHA and line that fixed it), still present, or changed. +3. Add new findings for contradictions between the plan documents supplied to you, decisions.yaml + and the repositories. Each finding has: id (area prefix and number), severity (Blocker, Major, + Minor, Question), sources quoted with file and line, decision of record, fix, file to change + and owner role from maintainers.yaml. Use roles, never personal names. +4. Write REPORT.md and ISSUES.csv (same columns as the previous one) to a new dated folder. + +Do not modify any audited repository, comment on any PR or issue, or run code that reaches +hardware or secrets. + +## Expected outcome + +- One new folder `-alignment-audit/` with REPORT.md and ISSUES.csv on the audit branch. +- A summary table: counts by area and severity, new, resolved and still-present findings. +- A "What could not be checked" section with the reason for each gap. +- No change in any audited repository. + +## Failure rule + +A failed precondition stops the task. Report the command, the error and the next step. Do not +substitute another repository, branch or source, and do not guess a value that could not be read. +Open an issue labelled harness when the failure shows a gap in this template or the harness. diff --git a/decisions.yaml b/decisions.yaml index 61f2a05..12b3133 100644 --- a/decisions.yaml +++ b/decisions.yaml @@ -119,7 +119,7 @@ decisions: - pattern: '(?Pnine\s+(?:indexed\s+)?(?:mechanical\s+)?(?:mounting\s+)?positions)' unless: 'supersed' message: four indexed positions - - pattern: '(?P\b1300\s*(?:mm\s*)?(?:to|through|\.\.|–|-)\s*1700\s*mm)' + - pattern: '(?P\b1300\s*(?:mm\s*)?(?:to|through|\.\.|\u2013|-)\s*1700\s*mm)' unless: 'supersed|height envelope|assembled' message: positions span 1300 to 1450 mm owner: platform-lead diff --git a/rollout/STATE.md.example b/rollout/STATE.md.example new file mode 100644 index 0000000..ee6af8e --- /dev/null +++ b/rollout/STATE.md.example @@ -0,0 +1,30 @@ +# STATE + +Read this file before any work in this repository and update it in the same PR as the work. +The PR evidence check fails when STATE.md exists and a PR neither updates it nor says +"no change" with a reason. Keep it under 60 lines; facts only, each with a date or SHA. + +## Repository + +- Type and lifecycle: , +- Readiness: (evidence: ) +- Owner role: +- Verification: ``; last PASS on main at () + +## Current work + +| Work package | Branch or PR | Status | Next step | +|---|---|---|---| +| # | # | draft / in review / blocked | | + +## Decisions this repository implements + +List decisions.yaml IDs whose values appear in this repository, for example MAST-INSTALL-HEIGHT. + +## Known gaps + +- , tracked in # + +## Not verified + +- , since diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000..51c2125 --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,71 @@ +"""Structural tests for issue forms, agent prompts and harness text files.""" +import unittest +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +FORMS = ROOT / ".github" / "ISSUE_TEMPLATE" +PROMPTS = ROOT / "agent-prompts" +# Files written for the harness; plain typography is a rule for them. +HARNESS_FILES = [ + "AGENTS.md", "agent-rules/SHARED_RULES.md", "CONTRIBUTING.md", "decisions.yaml", + "maintainers.yaml", "public-extract-allowlist.yaml", "agent-runs.md", + ".github/PULL_REQUEST_TEMPLATE.md", *[f"agent-prompts/{p.name}" for p in PROMPTS.glob("*.md")], + *[str(p.relative_to(ROOT)) for p in (ROOT / "rollout").rglob("*") if p.is_file()], + *[str(p.relative_to(ROOT)) for p in (ROOT / "tools").glob("*.py")], +] + + +class IssueForms(unittest.TestCase): + def load(self, name): + return yaml.safe_load((FORMS / name).read_text(encoding="utf-8")) + + def test_every_form_is_valid(self): + for path in FORMS.glob("*.yml"): + if path.name == "config.yml": + continue + with self.subTest(form=path.name): + form = self.load(path.name) + self.assertTrue(form["name"] and form["description"] and form["body"]) + ids = [item["id"] for item in form["body"] if "id" in item] + self.assertEqual(len(ids), len(set(ids))) + + def test_labels_used_by_automation(self): + self.assertIn("harness", self.load("harness_mistake.yml")["labels"]) + self.assertIn("good first issue", self.load("good_first_issue.yml")["labels"]) + self.assertIn("contract-change", self.load("contract_change_request.yml")["labels"]) + + def test_bug_report_asks_for_sha_and_commands(self): + ids = {item.get("id") for item in self.load("bug_report.yml")["body"]} + self.assertTrue({"repository", "commit", "reproduce", "not_verified"} <= ids) + + +class AgentPrompts(unittest.TestCase): + def test_each_prompt_has_the_three_fixed_parts(self): + prompts = [p for p in PROMPTS.glob("*.md") if p.name != "README.md"] + self.assertEqual(sorted(p.name for p in prompts), + ["docs-fix.md", "evaluator-pass.md", "push-from-bundle.md", "read-only-audit.md"]) + for path in prompts: + text = path.read_text(encoding="utf-8") + with self.subTest(prompt=path.name): + for heading in ("## Precondition block", "## Expected outcome", "## Failure rule"): + self.assertIn(heading, text) + block = text.split("## Precondition block", 1)[1].split("```")[1] + self.assertIn("repository:", block) + self.assertRegex(block, r"(parent|head) SHA:") + self.assertIn("expected outcome:", block) + self.assertIn("A failed precondition stops the task", text) + + +class Typography(unittest.TestCase): + def test_no_em_or_en_dashes_in_harness_files(self): + for rel in HARNESS_FILES: + path = ROOT / rel + if path.exists(): + with self.subTest(file=rel): + self.assertNotRegex(path.read_text(encoding="utf-8"), "[–—]") + + +if __name__ == "__main__": + unittest.main() From 639ea997efc029feeae32509df28145df10df326 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Mon, 28 Sep 2026 22:46:26 +0000 Subject: [PATCH 008/129] tools: sync_audit_issues.py for the weekly alignment audit Plans issues from an audit ISSUES.csv: one issue per Blocker or Major finding without an issue, in the repository named in file_to_change, owner from maintainers.yaml by ID prefix; closes open audit issues whose finding is resolved or absent. Public issues carry only ID, severity, area, paths and owner handle; full text goes only to the private fallback repository. Dry run on the 28 September audit plans 82 issues, 45 in public repositories. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- tests/test_sync_audit_issues.py | 110 +++++++++++++++++++++ tools/sync_audit_issues.py | 169 ++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 tests/test_sync_audit_issues.py create mode 100644 tools/sync_audit_issues.py diff --git a/tests/test_sync_audit_issues.py b/tests/test_sync_audit_issues.py new file mode 100644 index 0000000..fd88618 --- /dev/null +++ b/tests/test_sync_audit_issues.py @@ -0,0 +1,110 @@ +"""Tests for tools/sync_audit_issues.py.""" +import contextlib +import csv +import io +import sys +import tempfile +import unittest +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import sync_audit_issues as sai # noqa: E402 + +MAINTAINERS = yaml.safe_load((ROOT / "maintainers.yaml").read_text(encoding="utf-8")) +FIELDS = ["id", "severity", "area", "source_a", "value_a", "source_b", "value_b", + "decision_of_record", "fix", "file_to_change", "owner"] + + +def row(fid, severity="Major", target="openamr-platform-sw ros2/src/bringup/launch/real.launch.py", **kw): + base = dict.fromkeys(FIELDS, "") + base.update(id=fid, severity=severity, area="imu", file_to_change=target, + fix="Ask the named lead to decide", owner="A Person", value_a="internal quote") + base.update(kw) + return base + + +class Plan(unittest.TestCase): + def run_plan(self, rows, existing=()): + return sai.plan(rows, list(existing), MAINTAINERS, "audits", "2026-10-05-alignment-audit@abc1234") + + def test_opens_blocker_and_major_only(self): + to_open, _ = self.run_plan([row("ELE-001", "Blocker"), row("SW-002", "Minor"), row("GEO-003", "Question")]) + self.assertEqual([i["title"] for i in to_open], ["[audit] ELE-001: imu"]) + self.assertEqual(to_open[0]["repository"], "openamr-platform-sw") + self.assertEqual(to_open[0]["labels"], ["audit-finding", "blocker"]) + + def test_owner_from_maintainers_map_not_from_csv(self): + to_open, _ = self.run_plan([row("SW-001"), row("DOC-001", target="openamrobot-docs docs/a.md")]) + self.assertIn("Owner: @panthera-momagdii (software-lead)", to_open[0]["body"]) + self.assertIn("Owner: docs-owner, no handle recorded", to_open[1]["body"]) + self.assertNotIn("A Person", to_open[0]["body"] + to_open[1]["body"]) + + def test_public_issue_is_an_extract(self): + to_open, _ = self.run_plan([row("SW-001")]) + body = to_open[0]["body"] + self.assertNotIn("internal quote", body) + self.assertNotIn("Ask the named lead", body) + self.assertIn("`ros2/src/bringup/launch/real.launch.py`", body) + + def test_plan_document_findings_go_to_private_fallback_with_full_text(self): + to_open, _ = self.run_plan([row("TEAM-004", target="P-01 Status document")]) + self.assertEqual(to_open[0]["repository"], "audits") + self.assertIn("internal quote", to_open[0]["body"]) + + def test_existing_issue_is_not_duplicated(self): + existing = [{"repository": "openamr-platform-sw", "number": 5, "title": "[audit] ELE-001: imu", "state": "open"}] + to_open, to_close = self.run_plan([row("ELE-001", "Blocker")], existing) + self.assertEqual((to_open, to_close), ([], [])) + + def test_resolved_or_absent_findings_close_open_issues(self): + existing = [ + {"repository": "openamr-platform-sw", "number": 5, "title": "[audit] ELE-001: imu", "state": "open"}, + {"repository": "openamrobot-docs", "number": 9, "title": "[audit] DOC-021: docs", "state": "open"}, + {"repository": "openamrobot-docs", "number": 3, "title": "[audit] DOC-002: docs", "state": "closed"}, + {"repository": "openamrobot-docs", "number": 4, "title": "Unrelated", "state": "open"}, + ] + _, to_close = self.run_plan([row("ELE-001", status="resolved")], existing) + self.assertEqual(sorted((i["repository"], i["number"]) for i in to_close), + [("openamr-platform-sw", 5), ("openamrobot-docs", 9)]) + + +class Api(unittest.TestCase): + def test_apply_creates_comments_and_closes(self): + calls = [] + sai.apply("org", [{"repository": "r", "title": "t", "body": "b", "labels": ["audit-finding"]}], + [{"repository": "r", "number": 2}], "tok", "abc", + call=lambda m, u, t, d=None: calls.append((m, u))) + self.assertEqual(calls, [ + ("POST", "https://api.github.com/repos/org/r/issues"), + ("POST", "https://api.github.com/repos/org/r/issues/2/comments"), + ("PATCH", "https://api.github.com/repos/org/r/issues/2"), + ]) + + def test_fetch_existing_parses_search(self): + item = {"repository_url": "https://api.github.com/repos/org/r", "number": 1, + "title": "[audit] SW-001: x", "state": "open"} + got = sai.fetch_existing("org", "tok", call=lambda m, u, t, d=None: {"items": [item]}) + self.assertEqual(got, [{"repository": "r", "number": 1, "title": "[audit] SW-001: x", "state": "open"}]) + + +class CommandLine(unittest.TestCase): + def test_dry_run_from_csv(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp, "ISSUES.csv") + with open(path, "w", newline="", encoding="utf-8") as stream: + w = csv.DictWriter(stream, fieldnames=FIELDS) + w.writeheader() + w.writerows([row("ELE-001", "Blocker"), row("SW-002", "Minor")]) + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = sai.main(["--issues", str(path), "--maintainers", str(ROOT / "maintainers.yaml"), + "--fallback-repository", "audits", "--report", "x@1"]) + self.assertEqual(code, 0) + self.assertIn("plan: 1 to open, 0 to close", out.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/sync_audit_issues.py b/tools/sync_audit_issues.py new file mode 100644 index 0000000..dc5d69e --- /dev/null +++ b/tools/sync_audit_issues.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Plan and apply issue changes from an alignment-audit ISSUES.csv. + +Opens one issue per Blocker or Major finding that has no issue yet, in the +repository named in file_to_change (or the fallback repository), assigned to +the owner role from maintainers.yaml by audit ID prefix. Closes open audit +issues whose finding is absent from the new CSV or has status resolved. + +Issues in public repositories are public extracts: they carry the finding ID, +severity, area, the repository paths to change and the owner handle, never +the free-text columns, which can quote internal documents. The full text goes +only to the fallback (private) repository. +Exit status: 0 success, 2 usage error. +""" +import argparse +import csv +import json +import os +import re +import sys +import urllib.parse +import urllib.request +from pathlib import Path + +import yaml + +LABEL = "audit-finding" +OPEN_SEVERITIES = {"Blocker", "Major"} +REPO = re.compile(r"\b(openamr(?:obot)?-[a-z0-9-]+|\.github)\b") + + +def read_csv(path): + with open(path, newline="", encoding="utf-8") as stream: + return list(csv.DictReader(stream)) + + +def title_for(row): + return f"[audit] {row['id']}: {row.get('area', '').strip()}"[:120] + + +def finding_id(title): + m = re.match(r"\[audit\] ([A-Z]+-\d+)", title or "") + return m.group(1) if m else None + + +def target_repository(row, public_repos, fallback): + for name in REPO.findall(row.get("file_to_change", "")): + if name in public_repos: + return name + return fallback + + +def owner_handle(row, maintainers): + role = maintainers.get("audit_prefixes", {}).get(row["id"].split("-")[0]) + handle = (maintainers.get("roles", {}).get(role) or {}).get("handle") if role else None + return role or "unassigned", handle + + +def body_for(row, repository, fallback, maintainers, report): + role, handle = owner_handle(row, maintainers) + owner = f"@{handle} ({role})" if handle else f"{role}, no handle recorded" + paths = sorted(set(re.findall(r"[\w./-]+\.(?:md|ya?ml|py|xml|xacro|urdf|launch\.py|json|html)", + row.get("file_to_change", "")))) + lines = [f"Audit finding **{row['id']}** ({row['severity']}), area: {row.get('area', '')}.", "", + f"Owner: {owner}", f"Source: {report}", ""] + if paths: + lines += ["Files to change:"] + [f"- `{p}`" for p in paths] + [""] + if repository == fallback: + for key in ("value_a", "value_b", "decision_of_record", "fix"): + if row.get(key): + lines += [f"**{key}**: {row[key]}", ""] + else: + lines += ["Details are in the audit report named above. This issue is a public extract.", ""] + lines.append("Opened by the weekly alignment audit. Close it with the fixing PR; the next audit verifies.") + return "\n".join(lines) + + +def plan(new_rows, existing, maintainers, fallback, report): + """Return (to_open, to_close). existing: list of {repository, number, title, state}.""" + public_repos = set(maintainers.get("repositories", {})) + known = {} + for issue in existing: + fid = finding_id(issue.get("title")) + if fid: + known.setdefault(fid, []).append(issue) + active = {r["id"]: r for r in new_rows if (r.get("status") or "open").lower() != "resolved"} + to_open = [] + for fid, row in active.items(): + if row.get("severity") not in OPEN_SEVERITIES or fid in known: + continue + repository = target_repository(row, public_repos, fallback) + to_open.append({"repository": repository, "title": title_for(row), + "body": body_for(row, repository, fallback, maintainers, report), + "labels": [LABEL, row["severity"].lower()]}) + to_close = [dict(issue, id=fid) for fid, issues in known.items() if fid not in active + for issue in issues if issue.get("state") == "open"] + return to_open, to_close + + +def api(method, url, token, data=None): + req = urllib.request.Request(url, method=method, data=json.dumps(data).encode() if data else None, + headers={"Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json"}) + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read() or b"null") + + +def fetch_existing(org, token, call=api): + query = urllib.parse.quote(f"org:{org} label:{LABEL} is:issue") + out, page = [], 1 + while True: + data = call("GET", f"https://api.github.com/search/issues?q={query}&per_page=100&page={page}", token) + for item in data.get("items", []): + out.append({"repository": item["repository_url"].rsplit("/", 1)[-1], "number": item["number"], + "title": item["title"], "state": item["state"]}) + if len(data.get("items", [])) < 100: + return out + page += 1 + + +def apply(org, to_open, to_close, token, sha, call=api): + base = f"https://api.github.com/repos/{org}" + for issue in to_open: + call("POST", f"{base}/{issue['repository']}/issues", token, + {"title": issue["title"], "body": issue["body"], "labels": issue["labels"]}) + for issue in to_close: + url = f"{base}/{issue['repository']}/issues/{issue['number']}" + call("POST", f"{url}/comments", token, + {"body": f"Resolved according to the alignment audit at {sha}. Reopen if this is wrong."}) + call("PATCH", url, token, {"state": "closed", "state_reason": "completed"}) + + +def main(argv=None): + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument("--issues", type=Path, required=True, help="new ISSUES.csv") + p.add_argument("--maintainers", type=Path, required=True) + p.add_argument("--fallback-repository", required=True, help="private repository for findings without a public target") + p.add_argument("--report", required=True, help="report reference, e.g. folder@SHA") + p.add_argument("--existing", type=Path, help="JSON list of existing audit issues (dry run input)") + p.add_argument("--org", default="openAMRobot") + p.add_argument("--apply", action="store_true", help="fetch existing issues, then create and close via the API") + p.add_argument("--plan-output", type=Path) + a = p.parse_args(argv) + maintainers = yaml.safe_load(a.maintainers.read_text(encoding="utf-8")) + rows = read_csv(a.issues) + if not rows or "id" not in rows[0] or "severity" not in rows[0]: + print("ISSUES.csv needs id and severity columns", file=sys.stderr) + return 2 + token = os.environ.get("GITHUB_TOKEN") + if a.apply and not token: + print("--apply needs GITHUB_TOKEN", file=sys.stderr) + return 2 + existing = json.loads(a.existing.read_text(encoding="utf-8")) if a.existing else ( + fetch_existing(a.org, token) if a.apply else []) + to_open, to_close = plan(rows, existing, maintainers, a.fallback_repository, a.report) + for issue in to_open: + print(f"OPEN {issue['repository']}: {issue['title']}") + for issue in to_close: + print(f"CLOSE {issue['repository']}#{issue['number']}: {issue['id']}") + print(f"plan: {len(to_open)} to open, {len(to_close)} to close") + if a.plan_output: + a.plan_output.write_text(json.dumps({"open": to_open, "close": to_close}, indent=2), encoding="utf-8") + if a.apply: + apply(a.org, to_open, to_close, token, a.report) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 0386e31469a10963f7eee35a136bd3cf998cdf75 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Mon, 28 Sep 2026 22:51:18 +0000 Subject: [PATCH 009/129] rollout: PR assistant, weekly audit, docs sync and monthly retro workflows pr-assistant.yml runs check_decisions.py and check_pr_evidence.py on the diff under pull_request_target, never executing PR code, and keeps one summary comment; it never approves or merges. The weekly audit, docs sync (sender and receiver) and monthly retro run the Claude Code action pinned to v1.0.236 by commit SHA with restricted tools. SETUP.md lists secrets, App permissions, labels and branch protection for the organization owner and states which parts ran here. The reusable workflow gains an opt-in quality/test job that calls verify.sh; VERIFY.md documents it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- .../workflows/repository-quality-reusable.yml | 44 +++++++++ rollout/VERIFY.md | 67 +++++++++++++ rollout/workflows/SETUP.md | 86 +++++++++++++++++ rollout/workflows/docs-sync-caller.yml | 37 ++++++++ rollout/workflows/docs-sync.yml | 64 +++++++++++++ rollout/workflows/monthly-retro.yml | 74 +++++++++++++++ rollout/workflows/pr-assistant.yml | 74 +++++++++++++++ rollout/workflows/weekly-alignment-audit.yml | 94 +++++++++++++++++++ tests/test_check_pr_evidence.py | 15 +++ tools/check_pr_evidence.py | 15 +++ 10 files changed, 570 insertions(+) create mode 100644 rollout/VERIFY.md create mode 100644 rollout/workflows/SETUP.md create mode 100644 rollout/workflows/docs-sync-caller.yml create mode 100644 rollout/workflows/docs-sync.yml create mode 100644 rollout/workflows/monthly-retro.yml create mode 100644 rollout/workflows/pr-assistant.yml create mode 100644 rollout/workflows/weekly-alignment-audit.yml diff --git a/.github/workflows/repository-quality-reusable.yml b/.github/workflows/repository-quality-reusable.yml index d0ad5e2..f922a7a 100644 --- a/.github/workflows/repository-quality-reusable.yml +++ b/.github/workflows/repository-quality-reusable.yml @@ -10,6 +10,16 @@ on: type: string required: false default: main + verify: + description: Run rollout/verify.sh (or the repository's own tools/verify.sh) as job quality/test. + type: boolean + required: false + default: false + verify_container: + description: Container image for the verify job, e.g. ros:jazzy-ros-base for ROS 2 repositories. + type: string + required: false + default: "" permissions: contents: read @@ -128,3 +138,37 @@ jobs: else echo "No AGENTS.md in this repository; see rollout/README.md in openAMRobot/.github" fi + + verify: + name: quality/test + if: inputs.verify + runs-on: ubuntu-24.04 + container: ${{ inputs.verify_container || null }} + timeout-minutes: 30 + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check out OpenAMRobot harness + uses: actions/checkout@v4 + with: + repository: openAMRobot/.github + ref: ${{ inputs.harness_ref }} + path: .openamrobot-harness + + - name: Verify (install, build, lint, test, evidence; zero tests fail) + shell: bash + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + VERIFY_PATH="$PATH" bash .openamrobot-harness/rollout/verify.sh "$GITHUB_WORKSPACE" + + - name: Upload verification evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: verification-evidence + path: .verification/run.*/ + include-hidden-files: true + if-no-files-found: warn diff --git a/rollout/VERIFY.md b/rollout/VERIFY.md new file mode 100644 index 0000000..61f2241 --- /dev/null +++ b/rollout/VERIFY.md @@ -0,0 +1,67 @@ +# verify.sh: reference verification script + +`rollout/verify.sh` is the verification entry point for repositories that do not have their +own. A repository that already has `tools/verify.sh` keeps it: the script detects it and +delegates. openamrobot-interfaces is the reference implementation and is not duplicated here. + +## What it runs + +| Stage | Default action | Fails when | +|---|---|---| +| prerequisites | detect ROS 2 (`package.xml`), Node (`package.json`), Python (`tests/`, `pyproject.toml`, `setup.py`) | nothing is detected and no `VERIFY_TEST` is set | +| install | `npm ci`, or `rosdep check` for ROS 2 | a dependency does not resolve | +| build | `colcon build` in a copied workspace, or `npm run build` | the build fails | +| lint | `py_compile` for tracked Python, `bash -n` (and `shellcheck` when installed) for shell, `npm run lint` | any file fails | +| test-markers | scan tracked test sources | a skip, xfail or importorskip does not name an issue (`#123` or `issues/123`) on the same line | +| test | `colcon test` and `colcon test-result`, `npm test`, `pytest` or `unittest` | a test fails, or zero tests executed (total minus skipped is zero) | +| evidence | keep logs | never | + +Every stage runs under `env -i` with a fresh `HOME`, as in the interfaces script, so no overlay, +Python path or user package leaks in. Output goes to `.verification/run.*/`: +`verification.log`, `test.log`, `result.txt` (PASS, or FAIL with the stage) and `summary.json` +(result, failed stage, stages passed, test totals, head and base SHA). + +## Overrides + +`.openamrobot/verify.env` in the repository may set shell commands `VERIFY_INSTALL`, +`VERIFY_BUILD`, `VERIFY_LINT`, `VERIFY_TEST`, and `VERIFY_ROS_DISTRO` (default `jazzy`). +Overrides replace a stage's command; they do not switch off the zero-tests or skip rules. +Example for openamrobot-ui, whose web app lives in `web/`: + +``` +VERIFY_INSTALL="cd web && npm ci" +VERIFY_BUILD="cd web && npm run build" +VERIFY_LINT="cd web && npx eslint src" +VERIFY_TEST="cd web && CI=true npm test -- --watchAll=false" +``` + +With that override the UI's current `--passWithNoTests` suite fails the zero-tests rule +(audit CI-006) until real tests exist. + +## How the reusable workflow calls it + +`repository-quality-reusable.yml` has an opt-in job `quality/test`: + +```yaml +jobs: + repository-quality: + uses: openAMRobot/.github/.github/workflows/repository-quality-reusable.yml@ + with: + harness_ref: + verify: true + verify_container: ros:jazzy-ros-base # ROS 2 repositories only +``` + +The job checks out the repository and the harness at `harness_ref`, runs +`.openamrobot-harness/rollout/verify.sh "$GITHUB_WORKSPACE"` (which delegates to +`tools/verify.sh` when present) and uploads `.verification/run.*/` as the artifact +`verification-evidence`. The PR evidence section links that artifact. + +## Run locally + +``` +bash /path/to/openAMRobot/.github/rollout/verify.sh /path/to/repository +``` + +The tests in `tests/test_verify_sh.py` exercise the passing path, zero tests, a fully skipped +suite, a skip without an issue, a skip with an issue, nothing detected, and delegation. diff --git a/rollout/workflows/SETUP.md b/rollout/workflows/SETUP.md new file mode 100644 index 0000000..b9f1648 --- /dev/null +++ b/rollout/workflows/SETUP.md @@ -0,0 +1,86 @@ +# Setup for the organization owner + +Everything here needs organization-owner or repository-admin rights. Nothing in this list was +configured by the session that wrote it. Each item names where it is used. + +## What ran in the authoring session and what is design only + +| Automation | Status | +|---|---| +| Checkers (`tools/*.py`) and `rollout/verify.sh` | Ran locally with unit tests; dry runs against local checkouts of product repositories | +| `repository-quality-reusable.yml` shell steps | Dry run locally against openamrobot-docs and openamrobot-manifest (checkout steps simulated) | +| `pr-assistant.yml` | Its two checker commands ran locally on a simulated event; the workflow has not run on GitHub and has not posted a comment | +| `weekly-alignment-audit.yml` | Design only. `sync_audit_issues.py` ran as a dry run on the 28 September ISSUES.csv (82 issues planned, none created). The agent step never ran | +| `docs-sync-caller.yml`, `docs-sync.yml` | Design only; never ran | +| `monthly-retro.yml` | Design only; never ran | +| All workflow files | `actionlint` 1.7.12 passes (shellcheck integration not available) | + +## 1. Pin the harness + +1. After this PR merges, take its merge commit SHA as ``. +2. In every caller of the reusable workflow, replace `@main` with `@` and add + `with: harness_ref: ` (audit CI-001). +3. Replace `` in each copied workflow from `rollout/workflows/`. + +## 2. Secrets + +| Secret | Scope | Used by | +|---|---|---| +| `ANTHROPIC_API_KEY` (or `CLAUDE_CODE_OAUTH_TOKEN`, then change the input name) | repositories audits, openamrobot-docs, .github | weekly audit, docs sync, monthly retro | +| `AUDIT_APP_ID`, `AUDIT_APP_PRIVATE_KEY` | repository audits | weekly audit, opening and closing finding issues | +| `DOCS_SYNC_APP_ID`, `DOCS_SYNC_APP_PRIVATE_KEY` | organization secret, all product repositories | docs sync sender (repository_dispatch to openamrobot-docs) | +| `RETRO_APP_ID`, `RETRO_APP_PRIVATE_KEY` | repository .github | monthly retro, reading issues and review comments | + +The three App secret pairs may point to one GitHub App. The PR assistant needs no secret; it +uses `GITHUB_TOKEN`. + +## 3. GitHub Apps + +1. **Claude GitHub App** (github.com/apps/claude), installed on audits, openamrobot-docs and + .github only. It requests Contents, Issues and Pull requests read and write; the current + Claude Code documentation lists further permissions (Actions, Checks, Discussions, + Workflows, Members, Statuses) because the App is shared with other Claude features. Grant + what the install screen asks, on those three repositories only. +2. **Harness App** (organization-owned, private), installed on every product repository and + audits, with: Issues read and write; Pull requests read; Contents read and write (needed + only for `repository_dispatch` to openamrobot-docs); Metadata read. No administration, + workflow or secrets permissions. + +## 4. Actions settings + +- Workflow permissions default: read repository contents only. +- Keep "Allow GitHub Actions to create and approve pull requests" off. No workflow here + approves anything; the agents open PRs with the Claude App token. +- If the organization allow-lists actions, allow exactly: `actions/checkout`, + `actions/upload-artifact`, `actions/create-github-app-token`, + `anthropics/claude-code-action` (pinned in the files to v7.0.1, v7.0.1, v3.2.0 and + v1.0.236 by commit SHA). +- Fork pull request workflows: require approval for first-time contributors. + +## 5. Labels (every repository) + +| Label | Used by | +|---|---| +| `harness` | harness mistake form, monthly retro | +| `good first issue` | good first issue form, CONTRIBUTING.md | +| `contract-change` | contract change request form | +| `audit-finding`, `blocker`, `major` | weekly audit issue sync | +| `triage`, `bug` | existing forms | +| `area:docs`, `area:navigation`, `area:interfaces`, `area:manipulation`, `area:ui`, `area:release`, `area:ci` | good first issue triage, CONTRIBUTING.md | + +## 6. Branch protection on main (every active repository) + +- Require a pull request; require CODEOWNERS review; require conversation resolution. +- Required status checks: `repository-quality / repository-quality`, `quality/pr-evidence` + (after the PR assistant is installed), `quality/test` where the verify job is enabled; in + .github also `quality/test` from `repository-quality.yml`. +- Block force pushes and deletions. Restrict bypass to the organization owner and record each + bypass in the PR. +- Single-maintainer repositories: checks stay required; the CODEOWNERS-review waiver follows + section 8 of the Engineering Quality Standard. + +## 7. Maintainers map + +Fill the `null` handles in `maintainers.yaml` (ci-owner, docs-owner) once the people have +confirmed their GitHub accounts and joined the organization. Until then, audit issues for +CI and DOC findings say "no handle recorded" and nobody is mentioned. diff --git a/rollout/workflows/docs-sync-caller.yml b/rollout/workflows/docs-sync-caller.yml new file mode 100644 index 0000000..f0e8bc5 --- /dev/null +++ b/rollout/workflows/docs-sync-caller.yml @@ -0,0 +1,37 @@ +# Docs sync, sender side: copy to .github/workflows/docs-sync.yml in every +# repository except openamrobot-docs. On push to main it sends one +# repository_dispatch event to openamrobot-docs with the repository and the +# before/after SHAs. The Claude Code action does not run on push events, so the +# work happens in openamrobot-docs (docs-sync.yml). Secrets: DOCS_SYNC_APP_ID, +# DOCS_SYNC_APP_PRIVATE_KEY (see SETUP.md). +name: Docs sync (notify) + +on: + push: + branches: [main] + +permissions: + contents: read + +jobs: + notify: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Token for openamrobot-docs + id: app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.DOCS_SYNC_APP_ID }} + private-key: ${{ secrets.DOCS_SYNC_APP_PRIVATE_KEY }} + owner: openAMRobot + repositories: openamrobot-docs + + - name: Dispatch + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + run: | + gh api repos/openAMRobot/openamrobot-docs/dispatches -f event_type=source-changed \ + -f "client_payload[repository]=${{ github.event.repository.name }}" \ + -f "client_payload[before]=${{ github.event.before }}" \ + -f "client_payload[after]=${{ github.sha }}" diff --git a/rollout/workflows/docs-sync.yml b/rollout/workflows/docs-sync.yml new file mode 100644 index 0000000..b7e873f --- /dev/null +++ b/rollout/workflows/docs-sync.yml @@ -0,0 +1,64 @@ +# Docs sync, receiver side: copy to .github/workflows/docs-sync.yml in +# openamrobot-docs. On a source-changed dispatch it compares the source diff +# with the documentation pages and, when a page became wrong, opens one draft +# PR following agent-prompts/docs-fix.md. It never merges and never edits the +# source repository. Replace . Secret: ANTHROPIC_API_KEY. +name: Docs sync + +on: + repository_dispatch: + types: [source-changed] + +permissions: + contents: write + pull-requests: write + id-token: write + +concurrency: + group: docs-sync-${{ github.event.client_payload.repository }} + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Validate payload + env: + REPO: ${{ github.event.client_payload.repository }} + BEFORE: ${{ github.event.client_payload.before }} + AFTER: ${{ github.event.client_payload.after }} + run: | + set -euo pipefail + [[ "$REPO" =~ ^(openamr|openamrobot)-[a-z0-9-]+$|^\.github$ ]] || { echo "bad repository"; exit 1; } + [[ "$AFTER" =~ ^[0-9a-f]{40}$ ]] || { echo "bad sha"; exit 1; } + [[ "$BEFORE" =~ ^[0-9a-f]{40}$ ]] || { echo "bad sha"; exit 1; } + { echo "SRC=$REPO"; echo "BEFORE=$BEFORE"; echo "AFTER=$AFTER"; } >> "$GITHUB_ENV" + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check out harness and source diff (read-only) + run: | + set -euo pipefail + git clone --quiet https://github.com/openAMRobot/.github .harness + git -C .harness checkout --quiet + git clone --quiet --filter=blob:none "https://github.com/openAMRobot/$SRC" .source + git -C .source diff --stat "$BEFORE" "$AFTER" > .source-diff-stat.txt + git -C .source diff "$BEFORE" "$AFTER" -- '*.md' '*.yaml' '*.yml' '*.launch.py' '*.xacro' '*.urdf' '*.msg' '*.srv' '*.action' 'package.xml' > .source-diff.txt + wc -l .source-diff.txt + + - name: Compare and draft a fix + uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1.0.236 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: | + Source repository openAMRobot/${{ env.SRC }} changed from ${{ env.BEFORE }} to ${{ env.AFTER }}. + The diff is in .source-diff.txt (stat in .source-diff-stat.txt); the full source is in .source/. + Find documentation pages in this repository that state a command, version, parameter, topic, + configuration ID or value that the diff made wrong. If none, print "no page affected" and stop. + Otherwise follow .harness/agent-prompts/docs-fix.md exactly, on branch docs-sync/${{ env.SRC }}-, + parent SHA ${{ github.sha }}, and open one draft PR with the filled PR template. + Never edit .source/ or .harness/, never merge, never push to main. + claude_args: | + --max-turns 80 + --allowedTools "Read,Grep,Glob,Edit,Write,Bash(git switch -c docs-sync/*),Bash(git add docs/*),Bash(git commit -s *),Bash(git push origin docs-sync/*),Bash(gh pr create --draft *),Bash(python3 .harness/tools/check_decisions.py:*),Bash(python3 .harness/tools/check_public_extract.py:*),Bash(bash scripts/check_docs.sh)" diff --git a/rollout/workflows/monthly-retro.yml b/rollout/workflows/monthly-retro.yml new file mode 100644 index 0000000..3c906cd --- /dev/null +++ b/rollout/workflows/monthly-retro.yml @@ -0,0 +1,74 @@ +# Monthly retro: copy to .github/workflows/monthly-retro.yml in openAMRobot/.github. +# Reads open issues labelled harness across the organization and recent review +# comments that describe agent mistakes, then opens one draft PR here proposing +# changes to AGENTS.md, the checkers or the templates. Humans decide in the PR. +# Secrets: ANTHROPIC_API_KEY; RETRO_APP_ID and RETRO_APP_PRIVATE_KEY for an App +# with read access to issues and pull requests across the organization. +name: Monthly harness retro + +on: + schedule: + - cron: "23 6 1 * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + id-token: write + +jobs: + retro: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Organization read token + id: app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RETRO_APP_ID }} + private-key: ${{ secrets.RETRO_APP_PRIVATE_KEY }} + owner: openAMRobot + + - name: Collect harness issues and agent-mistake review comments + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + run: | + set -euo pipefail + since=$(date -u -d '35 days ago' +%Y-%m-%d) + gh search issues --owner openAMRobot --label harness --state open --limit 200 \ + --json repository,number,title,body,url > retro-harness-issues.json + gh search prs --owner openAMRobot --updated ">=$since" --limit 200 --json repository,number > retro-prs.json + python3 - <<'PY' + import json, re, subprocess + out = [] + for pr in json.load(open("retro-prs.json")): + repo = pr["repository"]["nameWithOwner"] + data = subprocess.run(["gh", "api", f"repos/{repo}/pulls/{pr['number']}/comments", "--paginate"], + capture_output=True, text=True).stdout or "[]" + for c in json.loads(data): + if re.search(r"\b(agent|claude|ai[- ]generated|harness)\b", c.get("body", ""), re.I): + out.append({"repo": repo, "pr": pr["number"], "url": c["html_url"], "body": c["body"][:2000]}) + json.dump(out, open("retro-review-comments.json", "w"), indent=2) + print(len(out), "review comments") + PY + + - name: Propose rule and template changes + uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1.0.236 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: | + You run the monthly harness retro for openAMRobot/.github at parent SHA ${{ github.sha }}. + Inputs: retro-harness-issues.json and retro-review-comments.json. Group the mistakes by class. + For each class propose exactly one of: a machine-checked gate (change a checker under tools/ with a + test), a template change, or a human decision point with a named decider role from maintainers.yaml. + Advice that nothing checks is not a rule. Keep AGENTS.md under 120 lines and edit the shared block + only in agent-rules/SHARED_RULES.md and AGENTS.md together, bumping the marker version. + Add one row per class to agent-runs.md. Run `bash rollout/verify.sh` and + `python3 tools/check_agent_rules.py --canonical agent-rules/SHARED_RULES.md --file AGENTS.md`. + Then create branch retro/, commit with sign-off, push, and open ONE draft PR using the + PR template, linking every issue it addresses. Never merge, never close issues, never push to main. + claude_args: | + --max-turns 120 + --allowedTools "Read,Grep,Glob,Edit,Write,Bash(bash rollout/verify.sh),Bash(python3 tools/*),Bash(git switch -c retro/*),Bash(git add *),Bash(git commit -s *),Bash(git push origin retro/*),Bash(gh pr create --draft *)" diff --git a/rollout/workflows/pr-assistant.yml b/rollout/workflows/pr-assistant.yml new file mode 100644 index 0000000..cd71999 --- /dev/null +++ b/rollout/workflows/pr-assistant.yml @@ -0,0 +1,74 @@ +# PR assistant: copy to .github/workflows/pr-assistant.yml in each repository. +# +# Runs check_decisions.py on the changed files and check_pr_evidence.py on the +# description, then creates or updates one summary comment. It never approves, +# requests changes or merges, and never executes code from the pull request: +# the PR head is checked out as data only, the checkers come from the pinned +# harness. pull_request_target is used so fork PRs can receive the comment; +# no secret other than GITHUB_TOKEN is available to the job. +# Replace with the openAMRobot/.github commit to run. +name: PR assistant + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened, ready_for_review, review_requested, review_request_removed] + +permissions: + contents: read + pull-requests: write + issues: write + +concurrency: + group: pr-assistant-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + evidence: + name: quality/pr-evidence + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Check out harness (trusted code) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: openAMRobot/.github + ref: + path: harness + persist-credentials: false + + - name: Check out PR head (data only, never executed) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + path: pr-head + persist-credentials: false + + - name: Collect changed files and reviews + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + python3 -c 'import yaml' 2>/dev/null || python3 -m pip install --user 'PyYAML==6.0.2' + gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/files" --paginate --jq '.[] | select(.status != "removed") | .filename' > changed.txt + gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/reviews" --paginate > reviews.json + wc -l < changed.txt + + - name: Decisions of record on the diff + run: | + set +e + python3 harness/tools/check_decisions.py --decisions harness/decisions.yaml \ + --maintainers harness/maintainers.yaml --root pr-head \ + --repository "${{ github.event.repository.name }}" --changed-files changed.txt > decisions.txt + echo "check_decisions exit $?" + cat decisions.txt + + - name: Evidence check and summary comment + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + python3 harness/tools/check_pr_evidence.py --event "$GITHUB_EVENT_PATH" \ + --changed-files changed.txt --maintainers harness/maintainers.yaml \ + --reviews reviews.json --root pr-head --decisions-report decisions.txt \ + --output "$GITHUB_STEP_SUMMARY" --post diff --git a/rollout/workflows/weekly-alignment-audit.yml b/rollout/workflows/weekly-alignment-audit.yml new file mode 100644 index 0000000..e772905 --- /dev/null +++ b/rollout/workflows/weekly-alignment-audit.yml @@ -0,0 +1,94 @@ +# Weekly alignment audit: copy to .github/workflows/ in the private audit +# repository (openAMRobot/audits). Read-only towards every product repository. +# +# 1. Clones the product repositories anonymously (read-only) and the harness. +# 2. Runs Claude Code with agent-prompts/read-only-audit.md, starting from the +# newest previous ISSUES.csv, and writes a new dated report folder here. +# 3. sync_audit_issues.py opens issues for new Blocker and Major findings with +# the owner from maintainers.yaml, and closes issues for resolved findings. +# Replace . Secrets: ANTHROPIC_API_KEY; AUDIT_APP_ID and +# AUDIT_APP_PRIVATE_KEY for the issue-writing GitHub App (see SETUP.md). +name: Weekly alignment audit + +on: + schedule: + - cron: "17 5 * * 1" + workflow_dispatch: + +permissions: + contents: write + id-token: write + +concurrency: + group: weekly-alignment-audit + cancel-in-progress: false + +jobs: + audit: + runs-on: ubuntu-24.04 + timeout-minutes: 90 + steps: + - name: Check out audit repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check out harness + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: openAMRobot/.github + ref: + path: .harness + persist-credentials: false + + - name: Clone product repositories read-only + run: | + set -euo pipefail + mkdir -p .repos + python3 -c 'import yaml' 2>/dev/null || python3 -m pip install --user 'PyYAML==6.0.2' + for r in $(python3 -c "import yaml;print(' '.join(k for k in yaml.safe_load(open('.harness/maintainers.yaml'))['repositories'] if k != '.github'))") .github; do + git clone --quiet --depth 50 "https://github.com/openAMRobot/$r" ".repos/$r" + echo "$r $(git -C ".repos/$r" rev-parse HEAD)" >> .repos/SHAS.txt + done + echo "PREVIOUS=$(ls -d 20*-alignment-audit 2>/dev/null | sort | tail -1)" >> "$GITHUB_ENV" + echo "FOLDER=$(date -u +%Y-%m-%d)-alignment-audit" >> "$GITHUB_ENV" + git switch -c "audit/$(date -u +%Y-%m-%d)" + + - name: Run read-only audit agent + uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1.0.236 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: | + Follow .harness/agent-prompts/read-only-audit.md exactly. + Precondition block values: report repository ${{ github.repository }}, branch audit/ (current), + parent SHA ${{ github.sha }}, audited checkouts and SHAs in .repos/SHAS.txt, + previous report ${{ env.PREVIOUS }}/ISSUES.csv, decisions .harness/decisions.yaml at . + Write the new report to ${{ env.FOLDER }}/REPORT.md and ${{ env.FOLDER }}/ISSUES.csv. + Add a status column to ISSUES.csv with open or resolved for every finding of the previous report. + Do not write outside ${{ env.FOLDER }}. Do not run git push, gh or any network command. + claude_args: | + --max-turns 200 + --allowedTools "Read,Grep,Glob,Write,Edit,Bash(python3 .harness/tools/check_decisions.py:*),Bash(python3 .harness/tools/check_public_extract.py:*),Bash(git -C .repos/*:*)" + + - name: Commit report + run: | + set -euo pipefail + test -s "$FOLDER/ISSUES.csv" && test -s "$FOLDER/REPORT.md" + git add "$FOLDER" + git -c user.name="openamrobot-audit[bot]" -c user.email="audit@users.noreply.github.com" \ + commit -m "audit: $FOLDER" + git push origin HEAD + + - name: Issue-writing token + id: app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.AUDIT_APP_ID }} + private-key: ${{ secrets.AUDIT_APP_PRIVATE_KEY }} + owner: openAMRobot + + - name: Open and close finding issues + env: + GITHUB_TOKEN: ${{ steps.app.outputs.token }} + run: | + python3 .harness/tools/sync_audit_issues.py --issues "$FOLDER/ISSUES.csv" \ + --maintainers .harness/maintainers.yaml --fallback-repository audits \ + --report "$FOLDER@$(git rev-parse --short HEAD)" --apply diff --git a/tests/test_check_pr_evidence.py b/tests/test_check_pr_evidence.py index e77faf1..a69d460 100644 --- a/tests/test_check_pr_evidence.py +++ b/tests/test_check_pr_evidence.py @@ -163,6 +163,21 @@ def test_ai_assisted_safety_change_fails(self): self.assertTrue(any("agents do not author safety logic" in f for f in failures)) +class DecisionReport(unittest.TestCase): + def test_contradictions_become_failures(self): + report = ("decisions: 3 loaded\n" + "CONTRADICTION docs/a.md:4: MAST-INSTALL-HEIGHT found 'mast_1400', decided '1350 mm' (P-03)\n" + "ALLOWED docs/h.md:2: MAST-INSTALL-HEIGHT found 'mast_1400'; reason: history\n") + self.assertEqual(ev.decision_failures(report), [ + "Decision contradiction: docs/a.md:4: MAST-INSTALL-HEIGHT found 'mast_1400', decided '1350 mm' (P-03)"]) + + def test_long_reports_are_truncated(self): + report = "\n".join(f"CONTRADICTION f.md:{i}: X found 'a', decided 'b'" for i in range(25)) + failures = ev.decision_failures(report) + self.assertEqual(len(failures), 21) + self.assertEqual(failures[-1], "... and 5 more decision contradictions") + + class Comment(unittest.TestCase): def test_render_has_marker_and_status(self): text = ev.render(["x"], [], [], pr()) diff --git a/tools/check_pr_evidence.py b/tools/check_pr_evidence.py index 4406510..20e6ea0 100644 --- a/tools/check_pr_evidence.py +++ b/tools/check_pr_evidence.py @@ -142,6 +142,17 @@ def evaluate(pr, changed, maintainers, reviews=(), has_state=False): return failures, warnings, notes +def decision_failures(report, limit=20): + """Turn check_decisions.py output lines into summary failures.""" + lines = [l[len("CONTRADICTION "):] for l in report.splitlines() if l.startswith("CONTRADICTION ")] + out = [f"Decision contradiction: {l}" for l in lines[:limit]] + if len(lines) > limit: + out.append(f"... and {len(lines) - limit} more decision contradictions") + if "INVALID decisions file" in report: + out.append("decisions.yaml is invalid; see the workflow log") + return out + + def render(failures, warnings, notes, pr): status = "FAIL" if failures else "PASS" lines = [MARKER, f"### PR evidence check: {status}", "", @@ -189,6 +200,8 @@ def main(argv=None): p.add_argument("--maintainers", type=Path, required=True) p.add_argument("--reviews", type=Path, help="JSON list of PR reviews") p.add_argument("--root", type=Path, help="checkout of the PR head; enables the STATE.md rule") + p.add_argument("--decisions-report", type=Path, + help="stdout of check_decisions.py on the diff; contradictions become failures") p.add_argument("--output", type=Path, help="write the Markdown summary here") p.add_argument("--post", action="store_true", help="create or update the PR comment") a = p.parse_args(argv) @@ -204,6 +217,8 @@ def main(argv=None): reviews = json.loads(a.reviews.read_text(encoding="utf-8")) if a.reviews else [] has_state = bool(a.root and (a.root / "STATE.md").is_file()) failures, warnings, notes = evaluate(pr, changed, maintainers, reviews, has_state) + if a.decisions_report: + failures += decision_failures(a.decisions_report.read_text(encoding="utf-8")) summary = render(failures, warnings, notes, pr) print(summary) if a.output: From 495c232bea35feb18cbd20b0758b867719484841 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Mon, 28 Sep 2026 22:51:18 +0000 Subject: [PATCH 010/129] docs: one-page CONTRIBUTING, rollout plan and agent run log CONTRIBUTING.md is the contributor path: finding a task, fork and branch per work package, DCO, the draft PR, what each check verifies, draft to ready and who reviews what. rollout/README.md gives adoption order per repository, the release-manifest interaction and the CODEOWNERS proposal. agent-runs.md records the audit and its follow-ups with the harness change that now catches each mistake class. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- CONTRIBUTING.md | 178 +++++++++++++++++++++++++--------------------- agent-runs.md | 20 ++++++ rollout/README.md | 93 ++++++++++++++++++++++++ 3 files changed, 211 insertions(+), 80 deletions(-) create mode 100644 agent-runs.md create mode 100644 rollout/README.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cdef1cf..b13f7e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,82 +1,100 @@ # Contributing to OpenAMRobot -OpenAMRobot welcomes technically sound contributions that support safe, reproducible, maintainable robotics. - -## Before contributing - -1. Read the repository README, licence, notices, and contribution instructions. -2. For substantial work, open an issue or discussion before implementation. -3. Confirm that you have authority to contribute, including any employer, university, client, sponsor, or co-author authorization. -4. Complete the contributor-agreement process described in [CLA.md](CLA.md). -5. Sign every commit under the [DCO](DCO.md). - -## Engineering quality and documentation architecture - -Every contribution must follow the [OpenAMRobot Engineering Quality Standard](ENGINEERING_QUALITY_STANDARD.md). Before implementation, identify the repository type, affected subsystem, safety impact, required validation evidence, compatibility impact, and documentation owner. - -Implementation-sensitive facts remain canonical in the owning repository. Documentation contributions and corresponding GitHub Pages updates must follow the [Documentation Information Architecture](https://github.com/openAMRobot/openamrobot-docs/blob/main/docs/DOCUMENTATION_INFORMATION_ARCHITECTURE.md). Automated CI/CD enforcement is being introduced separately; until then, authors and reviewers must apply these requirements explicitly in each pull request. - -## Contribution workflow - -1. Fork the relevant repository. -2. Create a focused feature branch. -3. Make and test the change. -4. Commit with `git commit -s`. -5. Update documentation and applicable notices. -6. Open a pull request using the repository template. -7. Address review, CI, DCO, CLA, safety, and provenance findings. - -Direct pushes to protected default branches are not an external contribution path. - -## Intellectual property - -OpenAMRobot is operated by **Botshare LTD**. The applicable Contributor Agreement governs assignment of transferable economic rights in accepted external contributions to Botshare LTD. Accepted material is distributed under the applicable repository or file licence. - -DCO sign-off is mandatory but does not replace the Contributor Agreement. - -See: - -- [IP Policy](IP_POLICY.md) -- [CLA process](CLA.md) -- [Individual Contributor Agreement](INDIVIDUAL_CONTRIBUTOR_AGREEMENT.md) -- [Corporate Contributor Agreement](CORPORATE_CONTRIBUTOR_AGREEMENT.md) -- [DCO](DCO.md) - -## Third-party and AI-assisted material - -A pull request must identify material not created independently by the contributor, including code, CAD, schematics, documentation, images, datasets, models, generated output, and copied or adapted examples. - -For every such item provide: - -- source and author or owner; -- applicable licence or written permission; -- modifications made; -- required copyright, patent, and attribution notices; -- material use of generative AI and the contributor's review of the output. - -Do not submit material with unclear or incompatible rights. - -## Confidentiality and privacy - -Do not submit secrets, credentials, personal data, client information, unpublished inventions, export-controlled information, or confidential/proprietary material without explicit written authorization. - -## Pull-request quality - -Pull requests must: - -- explain the problem, solution, scope, and alternatives; -- contain focused changes only; -- include testing and observable results; -- identify safety, compatibility, migration, and deployment effects; -- update documentation and notices; -- avoid generated/build artifacts unless the repository explicitly requires them. - -Robot-motion, power, battery, actuator, safety-I/O, and autonomous-behaviour changes require explicit safety analysis and appropriate simulation or hardware validation. - -## Acceptance - -Submission and review do not guarantee acceptance. A contribution is accepted only when an authorized maintainer merges it into an official repository or Botshare LTD confirms acceptance in writing. - -## Conduct and contact - -Follow [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). Report security issues through [SECURITY.md](SECURITY.md). Questions about contribution rights may be sent to info@botshare.ai. +OpenAMRobot is an open dual-arm mobile manipulator. Software and firmware are MIT, hardware is +CERN-OHL-P-2.0 and documentation is CC-BY-4.0. Every contribution, written by a person or with +an AI tool, passes the same automated checks before a maintainer reads it. This page is the +whole path. Engineering detail lives in the +[Engineering Quality Standard](ENGINEERING_QUALITY_STANDARD.md); the rules the checks enforce +are in [AGENTS.md](AGENTS.md). + +## 1. Find a task + +- Look for issues labelled + [good first issue](https://github.com/search?q=org%3AopenAMRobot+label%3A%22good+first+issue%22+state%3Aopen&type=issues). + Each one names the files, what "done" means, the command that verifies it and the reviewer. +- Areas and where they live: + + | Area | Repository | + |---|---| + | documentation | openamrobot-docs | + | navigation and bring-up | openamr-platform-sw | + | base firmware | openamr-platform-fw | + | hardware (CAD, BOM, wiring) | openamr-platform-hw, openamr-upperbody-hw | + | interfaces (messages, schemas) | openamrobot-interfaces | + | manipulation | openamrobot-manipulation | + | operator UI | openamrobot-ui | + | manifest and release | openamrobot-manifest, openamrobot-release | + | CI and this harness | .github | + +- For anything larger, open a **work package** issue first and wait for the area owner to + agree the scope. To change a message, schema, topic name, launch argument name or + configuration ID, open a **contract change request** instead. +- Before you start, read the repository's STATE.md (if it has one) and check open pull + requests for the same work. + +## 2. Set up + +1. Sign the contributor agreement once: see [CLA.md](CLA.md). +2. Fork the repository and create **one branch per work package**. +3. Sign off every commit: `git commit -s`. The sign-off certifies the [DCO](DCO.md) with your + own name and e-mail. + +## 3. Make the change + +- Add a test that fails without your change. A test run that executes zero tests counts as a + failure. If you must skip a test, name the tracking issue on the same line. +- Run the repository's verification (`tools/verify.sh`, or the command in its README). +- Decided values (heights, parts, topic names and so on) come from + [decisions.yaml](decisions.yaml). If your change disagrees with it, the check will say so; + raise it in the work package rather than editing around it. +- Do not add, remove or upgrade a dependency unless the task asks for it. +- Do not change E-stop, brake, contactor, watchdog, motor-enable or charge-inhibit logic + unless the platform lead has agreed it in the issue; such changes need two human reviewers. + +## 4. Open a draft pull request + +Open the PR as a **draft** and fill in every section of the template: work package, +Integration Gate (what existed, what you reused), tests, evidence (base SHA, head SHA, exact +commands, test counts), dependencies, safety impact, STATE.md, Not verified and AI disclosure. + +## 5. What the automated checks verify + +| Check | Fails when | +|---|---| +| repository-quality | governance files missing, merge markers, invalid JSON or XML | +| decisions of record | a changed file states a value that contradicts decisions.yaml | +| public extract | docs, assets, README or public files contain internal document links, prices, e-mail addresses, phone numbers or credential-like strings | +| shared agent rules | AGENTS.md differs from the organization's shared block | +| PR evidence (one comment, updated on each push) | a template section is empty, SHAs or commands are missing, tests changed without a reported run, a dependency changed without a note, safety files changed without two human reviewers | +| quality/test | build, lint or tests fail, or zero tests ran | +| DCO and contributor agreement | a commit lacks sign-off, or no agreement is on record | + +## 6. From draft to ready + +Mark the PR **ready for review** when every check is green on the current head and the +evidence comment says PASS. A PR prepared by an AI agent stays draft until the work-package +owner writes adopt, adapt or reject in the thread. + +## 7. Who reviews what + +| Change | Reviewer (role in [maintainers.yaml](maintainers.yaml)) | +|---|---| +| platform, hardware, firmware, decisions.yaml | platform lead | +| robot software, AI, interfaces, agent rules | software lead | +| workflows, verify.sh, quality gates | CI owner | +| manifest, release, installation | release owner | +| documentation site | documentation owner | +| safety paths | two humans, including the platform lead, who reviews last | + +A maintainer merges; approval or a green check alone does not accept a contribution. + +## Legal, conduct and contact + +Contributions are governed by the [IP Policy](IP_POLICY.md), the +[Individual](INDIVIDUAL_CONTRIBUTOR_AGREEMENT.md) or +[Corporate](CORPORATE_CONTRIBUTOR_AGREEMENT.md) Contributor Agreement, the +[AI contribution policy](AI_CONTRIBUTION_POLICY.md) and the +[third-party policy](THIRD_PARTY_POLICY.md). Identify any material you did not create +yourself with its source and licence. Do not submit secrets, personal data or confidential +material. Follow the [Code of Conduct](CODE_OF_CONDUCT.md); report security issues through +[SECURITY.md](SECURITY.md). Questions about contribution rights: info@botshare.ai. diff --git a/agent-runs.md b/agent-runs.md new file mode 100644 index 0000000..c84d1b8 --- /dev/null +++ b/agent-runs.md @@ -0,0 +1,20 @@ +# Agent run log + +One row per agent run that produced a report, a PR or a push. The work-package owner records +the outcome in the PR thread (adopted, adapted or rejected); this log copies it. "pending" +means no owner decision is recorded yet. Every mistake row names the harness change that now +catches its class; a mistake without one gets an issue labelled harness. + +Model and template columns record what the evidence shows (commit trailers, PR text, report +header); "not recorded" means the evidence does not say. + +| Date | Task | Template version | Environment | Model | Outcome | Mistake | Harness change | +|---|---|---|---|---|---|---|---| +| 2026-09-28 | Independent alignment audit of 13 repositories, plan set and BOM (229 findings, private audit repository) | none (pre-harness) | read-only session; GitHub API limited to openamrobot-docs; Drive links not opened; no ROS build | not recorded | pending | Initial severities too low on four findings; the lead auditor raised BOM-005, BOM-009 and DOC-021 to Blocker and PR-002 to Major | agent-prompts/evaluator-pass.md; decisions.yaml SAFETY-PROCUREMENT and DOCKING-SCOPE make those classes gate failures | +| 2026-09-28 | Docs PRs openamrobot-docs#25 and #26: OpenAMRobot 2.0 design section, HW diagram, general arrangement, F2S page | none (pre-harness) | contributor branch, docs repository | not recorded | adapted: heads redrawn at 1350 mm and aligned with the plan of record after the audit; the design section reached main in 945d78d | Public diagram asset carried internal document links, supplier prices and owner names (DOC-011); decisions shown as recorded before the addendum recorded them (DOC-002); Teensy presented as a bench target (ELE-021) | check_public_extract.py; decisions.yaml with sources and check_decisions.py (MAST-INSTALL-HEIGHT, BASE-CONTROLLER-GATES) | +| 2026-09-28 | Docs PR openamrobot-docs#28: P-03 rev18.2 mast geometry and height envelope | none (pre-harness) | contributor branch, docs repository | not recorded | pending | Cites P-03 revision 18.2, which no repository holds; the audit found no rev18.2 of record | decisions.yaml `sources` requires the evidence for each document; rev18.2 values flagged for owner confirmation | +| 2026-09-28 | README pushes: openamr-upperbody-hw#6, -sw#6, -fw#6 (fixed mast, rev18.2 wording) | none (pre-harness) | contributor branches | OpenAI Codex (per commit message) | pending (open) | none recorded beyond the audit findings they fix (GEO-020, DOC-038) | check_decisions.py LIFT-REMOVED and MAST-* keep the fix from regressing | +| 2026-09-28 | README push: .github#36, organization profile arm and mast wording | none (pre-harness) | contributor branch | Claude Code (per commit trailer) | adopted (merged d1ac6b6) | Profile still lists "adjustable linear lift systems" (GEO-022, DOC-033 partly fixed) | check_decisions.py LIFT-REMOVED reports profile/README.md line 18 | +| 2026-09-17 | CI pushes to openamr-upperbody-hw, -sw, -fw (#4) | none (pre-harness) | contributor branches | OpenAI Codex (per sign-off) | adopted (merged) | DCO sign-off under an invented identity ("OpenAI Codex" with the maintainer's e-mail) | Shared block: sign-off only with the contributor's own identity; push-from-bundle.md identity rule | +| not recorded | Repository-mismatch stop | none (pre-harness) | not recorded | not recorded | not recorded | Not described in the audit report or the repository history available to the session that wrote this log | Row to be completed by the platform lead; the failure rule in every agent prompt now requires the stop and the report | +| 2026-09-29 | This harness: rules, checks, templates and rollout in openAMRobot/.github | agent-prompts v1 (created by this run) | cloud session; read-only clones of product repositories; GitHub API scoped to .github and the audit repository | Claude Code | pending | The task named a private audit repository that does not exist under that name; the session read the same dated folder from the organization's audit repository, read-only, and reported the mismatch instead of stopping. Two reviewer handles could not be resolved from organization evidence | Precondition blocks name repositories by exact full name; maintainers.yaml records unresolved handles as null instead of guessing | diff --git a/rollout/README.md b/rollout/README.md new file mode 100644 index 0000000..c0d9075 --- /dev/null +++ b/rollout/README.md @@ -0,0 +1,93 @@ +# Rolling out the harness to other repositories + +Files in this folder are proposals for other repositories. Each takes effect only when that +repository's owner merges it. The organization-owner steps are in +[workflows/SETUP.md](workflows/SETUP.md). + +## What each repository adopts + +| Item | What the repository does | Enforced by | +|---|---|---| +| AGENTS.md, CLAUDE.md | Copy the shared block v2 verbatim from `agent-rules/SHARED_RULES.md`, add a short repository-specific section; CLAUDE.md contains only `@AGENTS.md` | drift step in the reusable workflow | +| STATE.md | Copy `STATE.md.example`, fill it, keep it current | `check_pr_evidence.py` STATE.md rule | +| PR template | Delete the local `.github/PULL_REQUEST_TEMPLATE.md` so the organization template applies, or replace it with a copy that keeps every heading | `check_pr_evidence.py` sections | +| verify.sh | Keep an existing `tools/verify.sh`; otherwise enable `verify: true` in the caller (the harness `rollout/verify.sh` runs), with `.openamrobot/verify.env` if the layout needs it | `quality/test` job | +| Reusable workflow caller | Pin `uses:` and `harness_ref` to one harness SHA | reviewer of the caller PR | +| PR assistant | Copy `workflows/pr-assistant.yml` | `quality/pr-evidence` required check | +| Docs sync sender | Copy `workflows/docs-sync-caller.yml` (not in openamrobot-docs) | none; failure shows in Actions | +| decisions.yaml | Nothing to copy. Fix flagged lines or mark kept history with `decision-allow: ` | `check_decisions.py` | + +All eight product repositories checked on 29 September 2026 carry a local PR template +(openamr-platform-fw, openamr-platform-sw, openamrobot-docs, openamrobot-interfaces, +openamrobot-manifest, openamrobot-manipulation, openamrobot-release, openamrobot-ui). A local +template overrides the organization one, so "inherited" requires deleting it. + +## Order + +Each step is one PR per repository, opened as a draft by the repository owner or with the +push-from-bundle prompt, and merged by the owner. + +1. **This PR merges; the owner completes SETUP.md sections 1 to 4.** +2. **Shared block v2, same day, in the three repositories that already carry v1** + (openamrobot-manifest, openamrobot-manipulation, openamrobot-ui). Until they update, the + drift step fails their pull requests with "shared block is v1, canonical is v2". A caller + pinned to a pre-v2 harness SHA is not affected. +3. **Pin callers** in all repositories (audit CI-001, SW-022). +4. **Pilots, in this order**, each with STATE.md, the organization PR template, the PR + assistant and `verify: true`: + 1. openamrobot-interfaces: already has `tools/verify.sh`; the job delegates to it. + 2. openamr-platform-sw: first colcon build and test gate (audit CI-005); container + `ros:jazzy-ros-base`. + 3. openamrobot-ui: `.openamrobot/verify.env` as in VERIFY.md; the zero-tests rule fails + until real tests replace `--passWithNoTests` (CI-006). Merge the verify.env PR together + with the first real tests. + 4. openamrobot-docs: keep `scripts/check_docs.sh` and the strict MkDocs build via + `VERIFY_TEST`; add the docs-sync receiver. +5. **openamrobot-manifest and openamrobot-release** (release owner). See the release + interaction below. +6. **openamr-platform-fw, openamr-platform-hw, openamr-upperbody-*, openamrobot-comm**: shared + block, STATE.md, PR assistant. `verify: true` only once a build or test exists; a + repository with nothing to test declares that in STATE.md instead of passing an empty suite. +7. **Branch protection** per SETUP.md section 6, repository by repository, after its + checks have passed on main once. +8. **Weekly audit** in audits, then **monthly retro** in .github. + +## Release-manifest interaction + +- The release builder packages what the manifest names. A release PR in + openamrobot-release runs the same decisions check on release notes and metadata (today it + flags "Raspberry Pi 5" in `release-metadata/RELEASE_NOTES.md`). +- The release manifest should record the harness SHA used for each component's evidence, and + each component's `summary.json` from its `quality/test` artifact, so release evidence points + at a verification run instead of a claim. +- A change to decisions.yaml can turn a component red without a code change. The release + owner treats that as a release blocker for the affected component, not as a CI fault. + +## CODEOWNERS proposal + +Not applied by this PR; CODEOWNERS changes need an explicit task and the platform lead's +review. Proposal, in every repository's `.github/CODEOWNERS`: + +``` +# Default owner stays. +* @BotshareAI + +# Software repositories (openamr-platform-sw, openamr-upperbody-sw, openamrobot-interfaces, +# openamrobot-manipulation, openamrobot-ui, openamrobot-comm): add the software lead. +* @BotshareAI @panthera-momagdii + +# Every repository: CI owner for workflows. Handle to be confirmed (maintainers.yaml ci-owner). +/.github/workflows/ @BotshareAI + +# openamrobot-docs only: documentation owner. Handle to be confirmed (maintainers.yaml docs-owner). +* @BotshareAI + +# openAMRobot/.github only: policy and harness files. +/decisions.yaml @BotshareAI +/maintainers.yaml @BotshareAI +/agent-rules/ @BotshareAI @panthera-momagdii +/tools/ @BotshareAI +``` + +A CODEOWNERS line with an account that is not a collaborator is ignored by GitHub, so the +pending handles must join the organization before their lines are added. From 83d286d95c30327bf065b27829eb392b1f2680d9 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Mon, 28 Sep 2026 22:51:39 +0000 Subject: [PATCH 011/129] docs: do not restate a superseded compute value in the rollout plan check_decisions.py flagged rollout/README.md for quoting the legacy compute name as found text; refer to the decision ID instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- rollout/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rollout/README.md b/rollout/README.md index c0d9075..9b03acc 100644 --- a/rollout/README.md +++ b/rollout/README.md @@ -56,7 +56,7 @@ push-from-bundle prompt, and merged by the owner. - The release builder packages what the manifest names. A release PR in openamrobot-release runs the same decisions check on release notes and metadata (today it - flags "Raspberry Pi 5" in `release-metadata/RELEASE_NOTES.md`). + flags the legacy compute named in `release-metadata/RELEASE_NOTES.md`, decision COMPUTE). - The release manifest should record the harness SHA used for each component's evidence, and each component's `summary.json` from its `quality/test` artifact, so release evidence points at a verification run instead of a claim. From bfa9d6ce20b850f0d344ba9ff732b6d876f1d616 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Mon, 28 Sep 2026 23:04:22 +0000 Subject: [PATCH 012/129] decisions: register with verification methods and non-numeric decisions decisions.yaml is the register of approved technical decisions; process stays in AGENTS.md. Each entry now states kind (value, configuration, limit, exclusion, distinction), scope as repositories and file globs, provenance, supersession and a verification method: what the text scan detects and which human reviewer checks what evidence. Adds the distinction and exclusion entries: 1700 mm is the assembled-height envelope, no suspension, no RS485, no dock contacts or pilot, no lift, docking never establishes charging, telemetry is never safety evidence. P-03 rev18.2 and BOM Issue 7 are recorded as seed evidence only; CI reads only the pinned register and fetches nothing. check_decisions.py validates the new fields, detects text that still cites a superseded source, counts files it did not scan, states that a clean result is textual consistency only, and never writes. Fixtures cover a matching value, a contradicting value, an unlisted file type and a superseded citation. Test strings are synthetic. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- decisions.yaml | 327 +++++++++++++----- tests/fixtures/README.md | 15 + tests/fixtures/decisions.yaml | 37 +- .../fixtures/decisions_repo/docs/citation.md | 4 + tests/test_check_decisions.py | 201 +++++++---- tools/check_decisions.py | 63 +++- 6 files changed, 459 insertions(+), 188 deletions(-) create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/decisions_repo/docs/citation.md diff --git a/decisions.yaml b/decisions.yaml index 12b3133..134f868 100644 --- a/decisions.yaml +++ b/decisions.yaml @@ -1,50 +1,71 @@ -# OpenAMRobot decisions of record, machine-readable. +# OpenAMRobot register of approved technical decisions. # -# This file is the only place in the organization where a decided value is -# written as a rule. Every other file either agrees with it or carries a -# `decision-allow: ` marker (history, changelog, legacy pages). -# tools/check_decisions.py enforces every decision with status "recorded". +# Two layers, kept separate: +# - AGENTS.md and CONTRIBUTING.md hold process: how a decision is proposed, +# reviewed, recorded, changed, tested and propagated. +# - This file holds the approved technical decisions repositories follow: +# values with units, allowed configurations, limits, exclusions and +# semantic distinctions. +# +# CI evaluates only this pinned, version-controlled file. No check fetches a +# plan document, a drive or the documentation site. Source documents are +# provenance: when a source and this register disagree, a human resolves it +# (the entry's owner) and no tool rewrites either side. tools/check_decisions.py +# only reads files and reports. +# +# What the scan proves: that a scanned file does not contain a listed +# contradicting phrase. It proves textual consistency only, never mechanical, +# electrical or safety correctness. Each entry's `verification.human` names +# the reviewer and the evidence that the scan cannot provide. # # Schema (schema_version 1), one entry per decision: # id unique, upper case, stable # title one line -# status recorded (enforced) | proposed (not enforced) | open (not decided) -# value/values the decided value or list of values, exactly as the source says +# kind value | configuration | limit | exclusion | distinction +# status recorded (scanned and reported) | open (not decided; not scanned) +# value/values exactly as the source states it; nothing invented # unit SI unit or none # date date the decision was taken (null when the source gives none) # source document (a key under sources) and item -# supersedes earlier values with their source; history, not rules -# applies_to repositories (globs) and files (globs); exclude is optional -# check list of patterns; each has a (?P...) group, optional -# files (globs narrowing this pattern), an optional -# line-level `unless` exemption and a message -# owner a role in maintainers.yaml who decides changes to this entry +# supersedes earlier values with their source; optional `citation`, a +# pattern that finds text still citing the superseded source +# applies_to repositories (globs) and files (globs); exclude optional +# check patterns; each has a (?P...) group, optional files +# (globs narrowing this pattern), optional line-level `unless` +# exemption and a message +# verification machine: what the scan detects; human: reviewer role and +# the evidence they need +# owner role in maintainers.yaml who approves changes to this entry # -# To change a decision: change the source document first, then open a PR that -# edits this entry and every file the checker flags. The owner approves. -# -# Seeding basis (29 September 2026): P-03 rev18.1, P-00 rev18.1 and the BOM as -# quoted in the 28 September 2026 alignment audit; P-03 rev18.2 as cited by the -# openamrobot-docs page docs/reference/openamrobot-2/index.md at main e0f2aac. -# The P-03 texts themselves are not in any repository; each rev18.2 value below -# rests on the docs-site citation and needs owner confirmation. +# Changing an entry follows "Changing a decision" in AGENTS.md: change request +# issue, source document first, then one reviewed PR that updates this file and +# every affected consumer together. schema_version: 1 sources: P-03-rev18.2: - title: P-03 Decision Addendum, revision 18.2, 28 September 2026 - evidence: cited by openamrobot-docs docs/reference/openamrobot-2/index.md lines 30-39 (main e0f2aac) + title: P-03 Decision Addendum, revision 18.2, 28 September 2026 (seed evidence) + evidence: >- + Not held in any repository. Values below that cite it were taken from its + citation on the documentation site (openamrobot-docs main e0f2aac, + docs/reference/openamrobot-2/index.md lines 30-39) and need owner confirmation. P-03-rev18.1: title: P-03 Decision Addendum, revision 18.1 - evidence: quoted by item and line in the 2026-09-28 alignment audit + evidence: plan-set document, not held in any repository; cited by item and line P-00-rev18.1: title: P-00 Master Coordination Plan, revision 18.1 - evidence: quoted by text line in the 2026-09-28 alignment audit + evidence: plan-set document, not held in any repository; cited by text line + BOM-Issue-7: + title: OpenAMRobot 2.0 Detailed Hardware BOM, Issue 7, 27 September 2026 (seed evidence) + evidence: working BOM, not held in any repository; its issue status is an open decision I8-WP: title: I8 base-controller status work package - evidence: quoted by line in the 2026-09-28 alignment audit + evidence: plan-set work package, not held in any repository + DOCK-WP: + title: 2.0 docking work package + evidence: plan-set work package, not held in any repository -# Paths never scanned: this file, checker fixtures and change logs. +# Never scanned: this register, checker fixtures and change logs. exclude: - decisions.yaml - tests/fixtures/** @@ -54,6 +75,7 @@ exclude: decisions: - id: BOM-ISSUE-IN-FORCE title: Canonical hardware BOM issue for OpenAMRobot 2.0 + kind: value status: open value: Issue 6, or an explicitly approved successor unit: none @@ -61,20 +83,23 @@ decisions: source: {document: P-03-rev18.1, item: line 7} supersedes: - {value: B-01 development BOM and evidence register rev18, source: P-03-rev18.1 line 7} - applies_to: {repositories: ["*"]} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.html", "**/*.yaml", "**/*.yml"]} check: - pattern: '(?PB-01[^\n]{0,60}\b(?:canonical|current))' unless: 'supersed' message: B-01 is superseded + verification: + machine: none while open + human: {reviewer: platform-lead, evidence: the P-03 item that names the BOM issue in force} owner: platform-lead - note: > - Open. The plan of record names Issue 6; the working BOM, the HW diagram - and docs pages use Issue 7; no approval of Issue 7 as successor is - recorded (audit BOM-025, TEAM-031, DOC-001). Set status to recorded once - P-03 names the issue in force. + note: >- + Open. The plan of record names Issue 6; the working BOM (Issue 7) and the + documentation use Issue 7; no recorded approval of Issue 7 as successor + was available. Set status to recorded once P-03 names the issue in force. - id: MAST-INSTALL-HEIGHT title: Shoulder-axis installation height of the fixed mast + kind: configuration status: recorded value: 1350 unit: mm @@ -82,7 +107,10 @@ decisions: date: 2026-09-28 source: {document: P-03-rev18.2, item: shoulder height} supersedes: - - {value: 1400, configuration_id: mast_1400, source: P-03-rev18.1 item 6 line 15} + - value: 1400 + configuration_id: mast_1400 + source: P-03-rev18.1 item 6 line 15 + citation: '(?PP-03[^\n]{0,30}(?:rev(?:ision)?\.?\s*)?18\.1[^\n]{0,30}item\s*6)' - {value: 1300, configuration_id: mast_1300, source: working baseline before P-03-rev18.1 item 6} applies_to: repositories: ["*"] @@ -97,10 +125,14 @@ decisions: - pattern: '(?P(?:shoulder[- ]axis|shoulder height|installation height)\s*(?:at|of|is|:)?\s*1[34]00\s*mm)' unless: 'supersed|rev ?18\.1|position' message: installation height is 1350 mm + verification: + machine: text naming mast_1300 or mast_1400 (or 1300/1400 mm) as the baseline or installation height, and citations of P-03 rev18.1 item 6 + human: {reviewer: platform-lead, evidence: URDF/Xacro mast configuration and the general arrangement drawing at 1350 mm} owner: platform-lead - id: MAST-POSITIONS title: Indexed mast mounting positions + kind: configuration status: recorded values: [1300, 1350, 1400, 1450] unit: mm @@ -122,17 +154,21 @@ decisions: - pattern: '(?P\b1300\s*(?:mm\s*)?(?:to|through|\.\.|\u2013|-)\s*1700\s*mm)' unless: 'supersed|height envelope|assembled' message: positions span 1300 to 1450 mm + verification: + machine: configuration IDs mast_1500 to mast_1700, "nine positions", a 1300 to 1700 mm position range + human: {reviewer: platform-lead, evidence: mast drawing with index holes} owner: platform-lead - id: MAST-TOP-HEIGHT title: Mast top height above the floor (own COTS mast, one MISUMI HFS6-60120 profile) + kind: value status: recorded value: 1500 unit: mm date: 2026-09-28 source: {document: P-03-rev18.2, item: mast} supersedes: - - {value: 1554, source: general arrangement before 28 September 2026 (audit GEO-001)} + - {value: 1554, source: general arrangement before 28 September 2026} applies_to: repositories: ["*"] files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/*.xacro", "**/*.urdf"] @@ -140,29 +176,37 @@ decisions: - pattern: '(?Ptop at 1554(?:\s*mm)?|mast top[^\n]{0,20}\b1554\s*mm)' unless: 'supersed' message: mast top is 1500 mm + verification: + machine: the superseded 1554 mm mast top + human: {reviewer: platform-lead, evidence: mast drawing and CAD} owner: platform-lead - id: MAX-ASSEMBLED-HEIGHT - title: Maximum assembled robot height, including head and camera structure + title: 1700 mm is the maximum assembled-height envelope, not a shoulder-axis height + kind: distinction status: recorded value: 1700 unit: mm date: null source: {document: P-03-rev18.1, item: item 6 line 15} - note: Not the shoulder-axis height. Repeated unchanged in P-03-rev18.2 per the docs site. + note: Repeated unchanged in P-03-rev18.2 as cited by the documentation site. The robot may be lower, never higher. applies_to: repositories: ["*"] - files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/*.xacro", "**/*.urdf"] check: - pattern: '(?Pmax(?:imum)?\s+(?:assembled\s+|robot\s+)?height\s*(?:of|is|:)?\s*(?!1700)1\d{3}\s*mm)' message: maximum assembled height is 1700 mm - - pattern: '(?Pshoulder[- ]axis[^\n]{0,20}\b1700\s*mm)' - unless: 'max|envelope|not (?:the )?shoulder|higher' - message: 1700 mm is the assembled-height envelope, not a shoulder height + - pattern: '(?P(?:shoulder[- ]axis|shoulder height|mast_)[^\n]{0,20}\b1700\b)' + unless: 'max|envelope|not (?:the )?shoulder|higher|supersed' + message: 1700 mm is the assembled-height envelope, not a shoulder height or mast position + verification: + machine: another maximum-height value, or 1700 mm presented as a shoulder height or mast position + human: {reviewer: platform-lead, evidence: assembled-height measurement on the general arrangement} owner: platform-lead - id: BATTERY-PLACEMENT title: Battery pack and placement + kind: value status: recorded value: One 8S1P EVE LF105 LiFePO4 pack, 25.6 V, 105 Ah, centred at 25 percent of the robot length from the rear unit: none @@ -174,18 +218,22 @@ decisions: check: - pattern: 'battery[^\n]{0,40}\bcent(?:red|ered) at (?P(?!25\b)\d+\s*(?:%|percent))' message: battery is centred at 25 percent of the length from the rear + verification: + machine: a different battery centre position in text + human: {reviewer: platform-lead, evidence: general arrangement and mass model} owner: platform-lead - note: The audit (GEO-010) found this placement absent from P-03 rev18.1; the docs site cites rev18.2. + note: The rev18.1 plan set did not record a placement; this value rests on the rev18.2 citation. - id: SPEED-CEILING - title: Speed ceiling + title: 1.5 m/s is a command ceiling, not an operating speed + kind: limit status: recorded value: 1.5 m/s command ceiling, treated as an analytical limit; the accepted operating speed follows from the stability model and stopping tests unit: m/s date: 2026-09-28 source: {document: P-03-rev18.2, item: speed} supersedes: - - {value: software speed limit 1.5 m/s presented as an operating limit, source: audit GEO-006 and DOC-003} + - {value: 1.5 m/s treated as an accepted operating speed, source: P-00-rev18.1 txt 60 and 352} applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} check: - pattern: '(?Psoftware speed limit\s*(?:of\s*)?1\.5\s*m/s)' @@ -194,49 +242,60 @@ decisions: - pattern: '(?P(?:operating|validated|rated) speed\s*(?:of|is|:)?\s*1\.5\s*m/s)' unless: 'ceiling|analytical|not' message: 1.5 m/s is not an accepted operating speed + verification: + machine: 1.5 m/s presented as a software or operating speed limit in text + human: {reviewer: platform-lead, evidence: stability model and recorded stopping tests; configured velocity limits in the Nav2 and base parameters} owner: platform-lead - id: DRIVETRAIN title: Drivetrain + kind: value status: recorded - value: Two ZLTECH ZLLG80ASM250-L-B hub motors with brakes, one ZLAC8015D V4.2 driver on CAN1 (CANopen), 200 mm wheels; no RS485 + value: Two ZLTECH ZLLG80ASM250-L-B hub motors with brakes, one ZLAC8015D V4.2 driver on CAN1 (CANopen), 200 mm wheels unit: none date: 2026-09-21 - source: {document: P-00-rev18.1, item: txt 16; P-03-rev18.1 item 4} + source: {document: P-00-rev18.1, item: txt 16} applies_to: repositories: ["openamr-platform-*", "openamr-upperbody-*", "openamrobot-docs", "openamrobot-release", ".github"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/*.xacro", "**/*.urdf"] check: - pattern: '(?PZLLG80ASM250-L)(?!-B)\b' message: the selected motor variant is ZLLG80ASM250-L-B - - pattern: '(?PZLAC8015D[^\n]{0,60}RS-?485)' - unless: '\bno RS-?485|not used|not provisioned' - message: ZLAC8015D is driven over CAN1; no RS485 in 2.0 - pattern: '(?PZL ?TECH[^\n]{0,40}\b(?:alternative|option(?:al)?)\b)' message: ZLTECH is the selected 2.0 drivetrain, not an option - pattern: '(?Pfail-safe brakes?)' unless: 'pending|F2A|verif|not yet|unverified' - message: brake fail-safe function and ratings are open F2A gates + message: brake fail-safe function and ratings are open supplier-evidence gates + verification: + machine: the unbraked motor variant, ZLTECH described as optional, brakes described as fail-safe + human: {reviewer: platform-lead, evidence: supplier datasheets for the brake rating and fail-safe behaviour} owner: platform-lead - id: RS485-NOT-IN-2-0 - title: No RS485 hardware, fallback, adapter or commissioning path in 2.0 + title: Release 2.0 has no RS485 hardware, fallback, adapter or commissioning path + kind: exclusion status: recorded value: none unit: none date: null source: {document: P-03-rev18.1, item: item 4} applies_to: - repositories: ["openamr-platform-hw", "openamr-platform-fw", "openamr-upperbody-*"] + repositories: ["openamr-platform-hw", "openamr-platform-fw", "openamr-upperbody-*", "openamrobot-docs"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"] check: - pattern: '(?PRS-?485)' - unless: 'not used|no RS-?485|not provisioned|without|legacy|Gate A|removed' + unless: 'not used|no RS-?485|not provisioned|without|legacy|Gate A|removed|not in 2\.0' message: RS485 is not provisioned in 2.0 - pattern: '(?PCAN or serial)' - message: CAN1 and CAN2 are dedicated; no upper-body link to the base + message: CAN1 and CAN2 are dedicated; no upper-body serial link to the base + verification: + machine: RS485 mentioned without a negation or legacy label; "CAN or serial" links + human: {reviewer: platform-lead, evidence: wiring diagram and BOM without RS485 parts} owner: platform-lead - id: BASE-CONTROLLER-GATES - title: Base-controller gates and IMU gating + title: Base-controller gates and IMU gating stay distinct + kind: distinction status: recorded values: - Gate A, Jetson with the existing Teensy, ZBLD/PWM drivetrain and MPU6500 (legacy test configuration) @@ -245,8 +304,8 @@ decisions: - STM32 go/no-go 6 November 2026; fallback is the validated legacy Teensy/PWM build behind I8 unit: none date: null - source: {document: P-00-rev18.1, item: txt 63, 101, 106, 344; I8 WP adoption gate} - applies_to: {repositories: ["*"]} + source: {document: P-00-rev18.1, item: txt 63, 101, 106, 344; I8-WP adoption gate} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} check: - pattern: '(?PTeensy[^\n]{0,20}\bbench target)' message: Teensy is the Gate A controller and the release fallback, not a bench target @@ -255,17 +314,21 @@ decisions: message: ICM-42688-P is gated; MPU6500 remains the Gate A IMU - pattern: '\b(?PMPU6050)\b' message: the Gate A IMU is the MPU6500 + verification: + machine: Teensy called a bench target, ICM-42688-P called selected without its gate, MPU6050 named as the IMU + human: {reviewer: platform-lead, evidence: gate records (Gate A test log, STM32 go/no-go record, IMU side-by-side data)} owner: platform-lead - id: IMU-TOPIC-OWNERSHIP title: IMU topic ownership + kind: distinction status: recorded values: - firmware publishes /imu/data_raw - the host filter and EKF own /imu/data unit: none date: null - source: {document: P-00-rev18.1, item: txt 15 and 345; I8 WP line 224} + source: {document: P-00-rev18.1, item: txt 15 and 345; I8-WP line 224} applies_to: repositories: ["openamr-platform-*", "openamrobot-docs", "openamrobot-ui"] files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.launch.py", "**/*.launch.xml", "**/*.ino", "**/*.cpp", "**/*.h"] @@ -276,20 +339,25 @@ decisions: - pattern: '"(?P/?imu/data)"' files: ["**/*.ino", "**/*.cpp", "**/*.c", "**/*.h"] message: firmware must not publish filtered /imu/data + verification: + machine: text attributing /imu/data to firmware; firmware sources containing the literal topic imu/data + human: {reviewer: software-lead, evidence: ros2 topic info on the running bring-up showing one publisher per topic} owner: software-lead - id: CAMERAS title: Cameras + kind: value status: recorded values: - base camera Orbbec Gemini 336L, fixed to base_link on the front face, tilted about 10 degrees upward - - head camera ZED-121210 (exact commercial identity pending, see HEAD-CAMERA-IDENTITY) + - head camera ZED-121210 (exact commercial identity open, see HEAD-CAMERA-IDENTITY) - two in-hand RGB wrist cameras; no separate torso scene camera unit: none date: 2026-09-23 source: {document: P-00-rev18.1, item: txt 85 and 97} applies_to: repositories: ["openamr-*", "openamrobot-docs", "openamrobot-manipulation", "openamrobot-ui"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/*.launch.py", "**/*.xacro", "**/*.urdf"] check: - pattern: '(?P\b(?:D4[35]5i?|RealSense)\b)' unless: 'legacy|historical|previous|not used|replaced' @@ -299,27 +367,35 @@ decisions: - pattern: '(?PIMX708|Pi Camera Module 3)' unless: 'legacy|Gate A|historical' message: the 2.0 base camera is the Orbbec Gemini 336L + verification: + machine: other camera models without a legacy label; a down-tilted base camera + human: {reviewer: software-lead, evidence: camera joint rpy in URDF and real TF showing the upward tilt} owner: platform-lead - id: HEAD-CAMERA-IDENTITY title: Head camera commercial identity + kind: value status: open - value: ZED-121210 per the plan; the general arrangement says ZED mini + value: ZED-121210 per the plan; the general arrangement names a different ZED model unit: none date: null source: {document: P-00-rev18.1, item: txt 85} - applies_to: {repositories: ["*"]} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.html"]} check: - pattern: '(?PZED[ -]?mini)' message: head camera identity is open + verification: + machine: none while open + human: {reviewer: platform-lead, evidence: supplier SKU and interface confirmation} owner: platform-lead - id: POWER-RAILS - title: Power rails + title: Power rails; no 12 V rail + kind: exclusion status: recorded values: - main battery bus 25.6 V nominal (not a regulated 24 V rail) - - regulated 24 V branch (safety relay, brake coils, peripherals, separately fused Hokuyo branch) + - regulated 24 V branch (safety relay, brake coils, peripherals, separately fused LiDAR branch) - regulated 5 V - no 12 V rail unit: V @@ -327,6 +403,7 @@ decisions: source: {document: P-03-rev18.1, item: item 2 line 10} applies_to: repositories: ["openamr-platform-*", "openamr-upperbody-*", "openamrobot-docs"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"] check: - pattern: '(?P\b12\s*V\s+rail)' unless: '\bno 12|not|never|legacy' @@ -334,119 +411,193 @@ decisions: - pattern: '(?P24\s*V?\s*(?:->|→|to)\s*5\s*V?\s*/\s*12\s*V)' unless: 'legacy' message: there is no 12 V rail in 2.0 + verification: + machine: a 12 V rail or a 24 V to 5/12 V converter in text + human: {reviewer: platform-lead, evidence: power distribution schematic} owner: platform-lead - - id: DOCKING-SCOPE - title: Docking scope in 2.0 + - id: DOCK-NO-CONTACTS + title: Release 2.0 docking has no dock contacts or dock pilot; wireless charging is 3.0 + kind: exclusion status: recorded - value: Positioning only; docked means accepted pose within tolerance; manual wired charging via PWR-019 with independent charge-plug-presence inhibition; no dock contacts or dock pilot; pose success never establishes charging; wireless charging is 3.0 + value: Positioning only; manual wired charging via PWR-019 with independent charge-plug-presence inhibition; no dock contacts or dock pilot; wireless charging belongs to 3.0 unit: none date: null source: {document: P-03-rev18.1, item: item 3 line 11} applies_to: repositories: ["openamr-platform-*", "openamrobot-docs", "openamrobot-ui", "openamrobot-interfaces"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/*.launch.py", "**/*.xacro", "**/*.urdf"] check: - pattern: '(?Pcharg(?:e|ing)[ -]contacts?)' unless: '\bno\b|\bnot\b|without|3\.0|never' message: 2.0 docking has no dock contacts - - pattern: '(?PSimpleChargingDock|ready to charge|charging target)' - unless: 'not |never|instead|non-charging' - message: 2.0 docking is positioning only - - pattern: '(?P(?:charge|charger|DC)\s*\+?\s*pilot)' + - pattern: '(?P(?:charge|charger|dock|DC)\s*\+?\s*pilot)' unless: '\bno\b' message: no dock or charge pilot in 2.0 - pattern: '(?Pwireless charging)' unless: '3\.0|later|deferred|not ' message: wireless charging belongs to 3.0 + verification: + machine: charge contacts or pilots without a negation; wireless charging without a 3.0 label + human: {reviewer: platform-lead, evidence: dock drawing and harness list without contacts} + owner: platform-lead + + - id: DOCKING-NOT-CHARGING + title: Docking success never establishes charging + kind: distinction + status: recorded + value: Docked means an accepted pose within tolerance; pose success never establishes charging or external power + unit: none + date: null + source: {document: P-03-rev18.1, item: item 3 line 11; DOCK-WP lines 27 and 70} + applies_to: + repositories: ["openamr-platform-*", "openamrobot-docs", "openamrobot-ui", "openamrobot-interfaces"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.launch.py", "**/*.py", "**/*.ts", "**/*.tsx", "**/*.js"] + check: + - pattern: '(?PSimpleChargingDock|ready to charge|charging target)' + unless: 'not |never|instead|non-charging' + message: 2.0 docking is positioning only + - pattern: '(?PisCharging[^\n]{0,20}\breturn\s+true|return\s+true[^\n]{0,20}isCharging)' + message: never report charging from a docking pose + - pattern: '(?Pdock(?:ed|ing)?[^\n]{0,60}\b(?:connected to (?:external )?power|is charging|charging (?:started|confirmed)))' + unless: '\bnot\b|never|does not' + message: docking success never establishes charging + verification: + machine: charging dock plugins, forced isCharging, text deriving charging or external power from docking + human: {reviewer: software-lead, evidence: docking state machine and UI source showing charge state comes only from the charge-plug-presence input} + owner: platform-lead + + - id: TELEMETRY-NOT-SAFETY-EVIDENCE + title: Functional telemetry is never safety evidence + kind: distinction + status: recorded + value: Firmware status, watchdogs, collision monitoring and BMS telemetry are functional; E-stop, brake and actuator power removal are hardwired and independent of software + unit: none + date: null + source: {document: P-00-rev18.1, item: txt 70 and 78; I8-WP line 669; DOCK-WP line 110} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.html", "**/*.yaml", "**/*.yml"]} + check: + - pattern: '(?P(?:watchdog|collision monitor|telemetry|status message|diagnostic)s?[^\n]{0,40}\bsafety (?:layer|function|evidence|guarantee))' + unless: '\bnot\b|never|functional|no substitute' + message: functional telemetry is not a safety layer or safety evidence + verification: + machine: text calling watchdogs, collision monitoring, telemetry or diagnostics a safety layer, function or evidence + human: {reviewer: platform-lead, evidence: hardwired safety-chain test record; a software PASS is never accepted in its place} owner: platform-lead - id: SAFETY-PROCUREMENT - title: Safety-chain procurement boundary (record only; this file implements no safety function) + title: Safety-chain procurement boundary (record only; this register implements no safety function) + kind: limit status: recorded values: - two installed E-stops (base front panel and chest), red latching mushroom, dual normally-closed channels into a monitored safety relay - K1 and K2 contactors from the LEV100 family; the linked-feedback (EDM) variant is unselected under one CONTACTOR gate - - CTL-004 ISO1212EVM is bench-only until an installed solution is qualified + - CTL-004 is bench-only until an installed solution is qualified unit: none date: null source: {document: P-03-rev18.1, item: items 4 and 5 lines 12-13} applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} check: - - pattern: '(?P9-1618389-8|LEV100A5ANG)' - unless: 'do not order|not evidence|unselected|reference only' - message: the EDM variant is not selected; do not present LEV100A5ANG as the order - - pattern: '(?Pfine for prototypes)' - message: no single-contact or clone E-stop recommendation + - pattern: '(?P(?:EDM|linked[- ]feedback) variant[^\n]{0,30}\b(?:selected|chosen|decided))' + unless: '\bnot\b|unselected|open' + message: the EDM variant is not selected + - pattern: '(?Pfine for prototyp\w*|single[- ]contact[^\n]{0,30}e-?stop)' + message: no single-channel or uncertified E-stop recommendation - pattern: '(?PCTL-004[^\n]{0,60}\b(?:installed|robot interface))' unless: 'bench-only|until' message: CTL-004 is bench-only + verification: + machine: text claiming the EDM variant is chosen, recommending a single-channel or uncertified E-stop, or presenting CTL-004 as installed + human: {reviewer: platform-lead, evidence: safety-chain design and component certificates; two human approvals per the repository ruleset} owner: platform-lead - id: COMPUTE title: Reference compute + kind: value status: recorded value: NVIDIA Jetson Orin NX 16 GB on a reComputer Robotics J401 carrier with NVMe; Raspberry Pi removed from active support unit: none date: null source: {document: P-00-rev18.1, item: txt 14, 90, 278} - applies_to: {repositories: ["*"]} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} check: - pattern: '(?PRaspberry Pi 5)' unless: 'legacy|historical|removed|supersed|Gate A|previous|earlier' message: Jetson Orin NX is the 2.0 reference compute; label Raspberry Pi material as legacy + verification: + machine: Raspberry Pi 5 without a legacy label + human: {reviewer: software-lead, evidence: bring-up documentation on the Jetson} owner: platform-lead - id: NAV-LIDAR title: Navigation LiDAR + kind: value status: recorded value: Hokuyo UST-10LX (functional sensing, not a safety device) unit: none date: null source: {document: P-03-rev18.1, item: item 2} - applies_to: {repositories: ["openamr-platform-*", "openamrobot-docs", "openamrobot-release"]} + applies_to: + repositories: ["openamr-platform-*", "openamrobot-docs", "openamrobot-release"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.launch.py", "**/package.xml", "**/*.html"] check: - pattern: '(?Prplidar_ros|RPLIDAR(?: A1)?)' unless: 'legacy|Gate A|historical' message: the 2.0 navigation LiDAR is the Hokuyo UST-10LX + verification: + machine: RPLIDAR references without a legacy label + human: {reviewer: software-lead, evidence: bring-up launch and scan topic from the UST-10LX driver} owner: software-lead - id: LIFT-REMOVED - title: No lift in 2.0; fixed mast + title: Release 2.0 has no lift; the upper body is a fixed mast + kind: exclusion status: recorded value: Lift removed from OpenAMRobot 2.0; 3.0 roadmap unit: none date: null source: {document: P-00-rev18.1, item: txt 18} - applies_to: {repositories: ["*"]} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/package.xml", "**/*.xacro", "**/*.urdf"]} check: - - pattern: '(?P(?:linear |actuated )?lift (?:module|controller|joint|axis|system|column|motor))' + - pattern: '(?P(?:linear |actuated )?lift (?:module|controller|joint|axis|system|column|motor)s?)' unless: '3\.0|removed|later release|v3|roadmap|not part|replace' message: 2.0 has a fixed mast; the lift is 3.0 + verification: + machine: lift hardware or software described without a 3.0 or removed label + human: {reviewer: platform-lead, evidence: upper-body package list without lift controllers} owner: platform-lead - id: NO-SUSPENSION - title: No suspension in 2.0 + title: Release 2.0 has no suspension + kind: exclusion status: recorded value: No suspension is fitted in 2.0; sprung drive wheels are a 3.0 item unit: none date: null source: {document: P-03-rev18.1, item: item 1} - applies_to: {repositories: ["*"]} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} check: - - pattern: '(?Psprung drive(?: module| wheels?)?)' + - pattern: '(?Psprung drive(?: module| wheels?)?|drive(?:train)? suspension)' unless: '3\.0|\bno\b|not fitted|without' message: no suspension in 2.0 + verification: + machine: sprung drive or drive suspension without a 3.0 or negation label + human: {reviewer: platform-lead, evidence: chassis drawing} owner: platform-lead - id: DRIVE-TRACK title: Drive track + kind: value status: open - value: 400 mm in the general arrangement; final track is an open input + value: 400 mm in the general arrangement; the final track is an open input unit: mm date: null - source: {document: P-00-rev18.1, item: open inputs (audit GEO-012)} - applies_to: {repositories: ["openamr-platform-*", "openamrobot-ui"]} + source: {document: P-00-rev18.1, item: open inputs} + applies_to: {repositories: ["openamr-platform-*", "openamrobot-ui"], files: ["**/*.xacro", "**/*.urdf", "**/*.yaml", "**/*.sdf"]} check: - pattern: '(?P0\.4075)' message: track value disagrees with the general arrangement + verification: + machine: none while open + human: {reviewer: platform-lead, evidence: final hub-motor bracket design} owner: platform-lead diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..e7ffc60 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,15 @@ +# Checker fixtures + +Synthetic files for `tests/test_check_decisions.py`. They contain no plan, audit or supplier +content. `decisions.yaml` here is a fixture register, not the organization register. + +| Case | Fixture file | Expected result | +|---|---|---| +| matching value | `decisions_repo/config/params.yaml` (mast_1350) | no finding | +| contradicting value | `decisions_repo/README.md` line 3, `launch/robot.launch.py`, `urdf/robot.xacro`, `package.xml` | one finding each | +| value in an unlisted file type | `decisions_repo/legacy/notes.txt` | not scanned; counted as "not scanned" | +| superseded decision still cited | `decisions_repo/docs/citation.md` line 3 | finding "superseded source still cited"; line 4 (current revision) clean | +| kept history with marker | `decisions_repo/docs/history.md` line 4 | reported as ALLOWED, not silenced | +| legacy exemption | `decisions_repo/docs/history.md` line 5 | no finding | +| open decision | `undecided-value` in history.md | not scanned | +| excluded path | `decisions_repo/CHANGELOG.md` | not scanned | diff --git a/tests/fixtures/decisions.yaml b/tests/fixtures/decisions.yaml index 465e9f7..d81b20f 100644 --- a/tests/fixtures/decisions.yaml +++ b/tests/fixtures/decisions.yaml @@ -1,45 +1,62 @@ -# Fixture decisions for tests/test_check_decisions.py. Not decisions of record. +# Fixture register for tests/test_check_decisions.py. Synthetic values only; +# not decisions of record. schema_version: 1 sources: - FIX-DOC: {title: Fixture decision document, evidence: tests only} + FIX-DOC: {title: Fixture decision document revision 2, evidence: tests only} exclude: ["**/CHANGELOG.md"] decisions: - id: FIX-MAST title: Fixture installation height + kind: configuration status: recorded value: 1350 unit: mm date: 2026-09-28 source: {document: FIX-DOC, item: item 1} supersedes: - - {value: 1400, source: FIX-DOC earlier item 1} - owner: platform-lead - applies_to: {repositories: ["*"]} + - value: 1400 + source: FIX-DOC revision 1 item 1 + citation: '(?PFIX-DOC\s+rev(?:ision)?\s*1\b[^\n]{0,20}item\s*1)' + applies_to: + repositories: ["*"] + files: ["**/*.md", "**/*.yaml", "**/*.launch.py", "**/*.xacro", "**/package.xml"] check: - pattern: '(?Pmast_1400)\b[^\n]{0,40}baseline' unless: 'legacy' message: baseline is mast_1350 + verification: + machine: mast_1400 named as baseline; citations of the superseded revision + human: {reviewer: platform-lead, evidence: fixture drawing} + owner: platform-lead - id: FIX-IMU title: Fixture topic ownership + kind: distinction status: recorded values: [firmware publishes /imu/data_raw, host filter owns /imu/data] unit: none date: null source: {document: FIX-DOC, item: item 2} - owner: software-lead - applies_to: {repositories: ["platform-*"]} + applies_to: {repositories: ["platform-*"], files: ["**/*.launch.py", "**/*.xacro"]} check: - pattern: '"(?P/?imu/data)"' files: ["**/*.launch.py", "**/*.xacro"] message: firmware must not publish /imu/data + verification: + machine: literal /imu/data in launch or xacro files + human: {reviewer: software-lead, evidence: topic list from a running system} + owner: software-lead - id: FIX-OPEN - title: Fixture open decision, never enforced + title: Fixture open decision, never scanned + kind: value status: open value: undecided unit: none date: null source: {document: FIX-DOC, item: item 3} - owner: platform-lead - applies_to: {repositories: ["*"]} + applies_to: {repositories: ["*"], files: ["**/*.md"]} check: - pattern: '(?Pundecided-value)' + verification: + machine: none while open + human: {reviewer: platform-lead, evidence: a recorded decision} + owner: platform-lead diff --git a/tests/fixtures/decisions_repo/docs/citation.md b/tests/fixtures/decisions_repo/docs/citation.md new file mode 100644 index 0000000..cdee487 --- /dev/null +++ b/tests/fixtures/decisions_repo/docs/citation.md @@ -0,0 +1,4 @@ +# Superseded citation fixture + +The height follows FIX-DOC rev 1 item 1. +The height follows FIX-DOC item 1. diff --git a/tests/test_check_decisions.py b/tests/test_check_decisions.py index 5a7003c..7f234af 100644 --- a/tests/test_check_decisions.py +++ b/tests/test_check_decisions.py @@ -1,7 +1,11 @@ -"""Tests for tools/check_decisions.py against the fixture repository.""" +"""Tests for tools/check_decisions.py against the fixture repository. + +All text in these tests is synthetic. The real-register tests use invented +sentences that exercise each pattern; they quote no plan, audit or supplier +document. +""" import contextlib import io -import shutil import sys import tempfile import unittest @@ -23,24 +27,41 @@ def run(*args): return code, out.getvalue() -class ScanFixtureRepository(unittest.TestCase): +class FixtureMatrix(unittest.TestCase): + """Matching value, contradicting value, unlisted file type, superseded citation.""" + def setUp(self): - self.decisions = cd.load_decisions(DECISIONS) - - def test_reports_file_line_found_and_decided_value(self): - findings, _ = cd.scan(REPO, self.decisions, "platform-fixture") - readme = [f for f in findings if f["file"] == "README.md"] - self.assertEqual(len(readme), 1) - self.assertEqual(readme[0]["line"], 3) - self.assertEqual(readme[0]["found"], "mast_1400") - self.assertEqual(readme[0]["decided"], "1350 mm") - self.assertEqual(readme[0]["source"], "FIX-DOC item 1") - - def test_covers_markdown_launch_xacro_package_xml(self): - findings, _ = cd.scan(REPO, self.decisions, "platform-fixture") - where = sorted((f["file"], f["line"], f["id"]) for f in findings) + self.decisions = cd.load_decisions(DECISIONS, ROOT / "maintainers.yaml") + self.stats = {} + self.findings, self.allowed = cd.scan(REPO, self.decisions, "platform-fixture", stats=self.stats) + + def at(self, rel): + return [f for f in self.findings if f["file"] == rel] + + def test_matching_value_is_clean(self): + self.assertEqual(self.at("config/params.yaml"), []) + + def test_contradicting_value_reports_file_line_found_and_decided(self): + (f,) = self.at("README.md") + self.assertEqual((f["line"], f["found"], f["decided"], f["source"]), (3, "mast_1400", "1350 mm", "FIX-DOC item 1")) + + def test_unlisted_file_type_is_not_scanned_but_counted(self): + self.assertEqual(self.at("legacy/notes.txt"), []) + self.assertGreaterEqual(self.stats["unscanned"], 1) + only = {} + cd.scan(REPO, self.decisions, "platform-fixture", only=["legacy/notes.txt"], stats=only) + self.assertEqual(only["unscanned"], 1) + + def test_superseded_decision_still_cited(self): + (f,) = self.at("docs/citation.md") + self.assertEqual((f["line"], f["found"]), (3, "FIX-DOC rev 1 item 1")) + self.assertIn("superseded source still cited", f["message"]) + + def test_all_findings(self): + where = sorted((f["file"], f["line"], f["id"]) for f in self.findings) self.assertEqual(where, [ ("README.md", 3, "FIX-MAST"), + ("docs/citation.md", 3, "FIX-MAST"), ("launch/robot.launch.py", 2, "FIX-MAST"), ("launch/robot.launch.py", 3, "FIX-IMU"), ("package.xml", 4, "FIX-MAST"), @@ -48,31 +69,32 @@ def test_covers_markdown_launch_xacro_package_xml(self): ]) def test_allow_marker_is_reported_not_silenced(self): - _, allowed = cd.scan(REPO, self.decisions, "platform-fixture") - self.assertEqual([(a["file"], a["line"]) for a in allowed], [("docs/history.md", 4)]) - self.assertIn("superseded plan", allowed[0]["reason"]) + self.assertEqual([(a["file"], a["line"]) for a in self.allowed], [("docs/history.md", 4)]) + self.assertIn("superseded plan", self.allowed[0]["reason"]) - def test_unless_exemption_excluded_paths_and_other_file_types(self): - findings, _ = cd.scan(REPO, self.decisions, "platform-fixture") - files = {f["file"] for f in findings} + def test_unless_exemption_and_excluded_paths(self): + files = {f["file"] for f in self.findings} self.assertNotIn("CHANGELOG.md", files) - self.assertNotIn("legacy/notes.txt", files) - self.assertFalse(any(f["file"] == "docs/history.md" and f["line"] == 5 for f in findings)) + self.assertFalse(any(f["file"] == "docs/history.md" for f in self.findings)) def test_repository_filter_and_per_check_files(self): findings, _ = cd.scan(REPO, self.decisions, "docs-fixture") self.assertNotIn("FIX-IMU", {f["id"] for f in findings}) - self.assertEqual(len(findings), 4) + self.assertEqual(len(findings), 5) - def test_open_decisions_are_not_enforced(self): - findings, _ = cd.scan(REPO, self.decisions, "platform-fixture") - self.assertNotIn("FIX-OPEN", {f["id"] for f in findings}) + def test_open_decisions_are_not_scanned(self): + self.assertNotIn("FIX-OPEN", {f["id"] for f in self.findings}) def test_changed_files_scope(self): - findings, _ = cd.scan(REPO, self.decisions, "platform-fixture", only=["config/params.yaml"]) - self.assertEqual(findings, []) - findings, _ = cd.scan(REPO, self.decisions, "platform-fixture", only=["README.md", "gone.md"]) - self.assertEqual(len(findings), 1) + self.assertEqual(cd.scan(REPO, self.decisions, "x", only=["config/params.yaml"])[0], []) + self.assertEqual(len(cd.scan(REPO, self.decisions, "x", only=["README.md", "gone.md"])[0]), 1) + + def test_checker_never_writes(self): + before = {p: p.stat().st_mtime_ns for p in REPO.rglob("*") if p.is_file()} + before[DECISIONS] = DECISIONS.stat().st_mtime_ns + run("--decisions", DECISIONS, "--root", REPO, "--repository", "platform-x") + after = {p: p.stat().st_mtime_ns for p in before} + self.assertEqual(before, after) class CommandLine(unittest.TestCase): @@ -80,7 +102,8 @@ def test_exit_one_on_contradiction(self): code, out = run("--decisions", DECISIONS, "--root", REPO, "--repository", "platform-x") self.assertEqual(code, 1) self.assertIn("CONTRADICTION README.md:3: FIX-MAST found 'mast_1400', decided '1350 mm'", out) - self.assertIn("result: 5 contradiction(s), 1 allowed", out) + self.assertIn("result: 6 contradiction(s), 1 allowed", out) + self.assertIn("textual consistency only", out) def test_exit_zero_when_clean(self): with tempfile.TemporaryDirectory() as tmp: @@ -89,8 +112,7 @@ def test_exit_zero_when_clean(self): self.assertEqual(code, 0, out) def test_exit_two_on_missing_root(self): - code, _ = run("--decisions", DECISIONS, "--root", "/nonexistent-root") - self.assertEqual(code, 2) + self.assertEqual(run("--decisions", DECISIONS, "--root", "/nonexistent-root")[0], 2) class Schema(unittest.TestCase): @@ -106,18 +128,20 @@ def write(self, text): decisions: - id: A title: t + kind: value status: recorded value: 1 unit: mm date: null source: {document: D, item: i} - applies_to: {repositories: ["*"]} + applies_to: {repositories: ["*"], files: ["**/*.md"]} check: [{pattern: '(?Px)'}] + verification: {machine: x in text, human: {reviewer: platform-lead, evidence: drawing}} owner: platform-lead """ def test_base_is_valid(self): - self.assertEqual(len(cd.load_decisions(self.write(self.BASE))), 1) + self.assertEqual(len(cd.load_decisions(self.write(self.BASE), ROOT / "maintainers.yaml")), 1) def test_rejects_invalid_entries(self): cases = { @@ -125,8 +149,11 @@ def test_rejects_invalid_entries(self): "pattern needs": self.BASE.replace("(?Px)", "x"), "not listed under sources": self.BASE.replace("document: D", "document: E"), "status must be": self.BASE.replace("status: recorded", "status: maybe"), + "kind must be": self.BASE.replace("kind: value", "kind: wish"), "missing owner": self.BASE.replace(" owner: platform-lead\n", ""), "needs value": self.BASE.replace(" value: 1\n", ""), + "repositories and files": self.BASE.replace(', files: ["**/*.md"]', ""), + "verification needs": self.BASE.replace("machine: x in text, ", ""), "schema_version": self.BASE.replace("schema_version: 1", "schema_version: 9"), } for expected, text in cases.items(): @@ -134,58 +161,82 @@ def test_rejects_invalid_entries(self): with self.assertRaisesRegex(cd.DecisionError, expected): cd.load_decisions(self.write(text)) - def test_owner_must_be_a_maintainers_role(self): - with self.assertRaisesRegex(cd.DecisionError, "not a role"): - cd.load_decisions(self.write(self.BASE.replace("platform-lead", "somebody")), + def test_owner_and_reviewer_must_be_maintainers_roles(self): + with self.assertRaisesRegex(cd.DecisionError, "owner 'somebody' is not a role"): + cd.load_decisions(self.write(self.BASE.replace("owner: platform-lead", "owner: somebody")), + ROOT / "maintainers.yaml") + with self.assertRaisesRegex(cd.DecisionError, "reviewer 'somebody' is not a role"): + cd.load_decisions(self.write(self.BASE.replace("reviewer: platform-lead", "reviewer: somebody")), ROOT / "maintainers.yaml") -class RealDecisionsFile(unittest.TestCase): - """The organization's decisions.yaml is valid and detects audited mistakes.""" +class RealRegister(unittest.TestCase): + """The organization's decisions.yaml is valid and its patterns behave on synthetic text.""" @classmethod def setUpClass(cls): cls.decisions = cd.load_decisions(ROOT / "decisions.yaml", ROOT / "maintainers.yaml") - def scan_text(self, name, text, repository): + def ids(self, name, text, repository="openamr-platform-sw"): with tempfile.TemporaryDirectory() as tmp: path = Path(tmp, name) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8") - return cd.scan(tmp, self.decisions, repository)[0] + return sorted(f["id"] for f in cd.scan(tmp, self.decisions, repository)[0]) - def test_every_recorded_decision_has_a_source_and_owner(self): + def test_every_entry_has_provenance_verification_and_owner(self): for d in self.decisions: self.assertTrue(d["source"]["item"], d["id"]) - self.assertTrue(d["owner"], d["id"]) - - def test_detects_imu_topic_owned_by_firmware(self): - text = " # micro-ROS agent: bridges the Teensy (/cmd_vel, /odom/unfiltered, /imu/data).\n" - found = self.scan_text("launch/drivers.launch.py", text, "openamr-platform-sw") - self.assertEqual([f["id"] for f in found], ["IMU-TOPIC-OWNERSHIP"]) - - def test_accepts_host_owned_imu_topic(self): - text = "Firmware owns raw /imu/data_raw; the host filter publishes /imu/data.\n" - self.assertEqual(self.scan_text("docs/imu.md", text, "openamr-platform-sw"), []) - - def test_detects_clone_estop_recommendation(self): - found = self.scan_text("docs/estop.md", "no certification. Fine for prototypes.\n", "openamrobot-docs") - self.assertEqual([f["id"] for f in found], ["SAFETY-PROCUREMENT"]) - - def test_detects_superseded_mast_values(self): - text = "| `mast_1300` | Plan working shoulder-axis baseline (1300 mm) |\nmast_1600 position\n" - found = self.scan_text("integration/inventory.md", text, "openamrobot-manipulation") - self.assertEqual(sorted(f["id"] for f in found), ["MAST-INSTALL-HEIGHT", "MAST-POSITIONS"]) - - def test_detects_docking_charge_contacts_but_not_negation(self): - found = self.scan_text("docs/dock.md", "The robot engages the charging contacts.\n", "openamrobot-docs") - self.assertEqual([f["id"] for f in found], ["DOCKING-SCOPE"]) - self.assertEqual(self.scan_text("docs/dock.md", "There are no charging contacts in 2.0.\n", - "openamrobot-docs"), []) - - def test_legacy_label_exempts_raspberry_pi(self): - self.assertEqual(self.scan_text("README.md", "Legacy build: Raspberry Pi 5.\n", "openamr-platform-hw"), []) - self.assertEqual(len(self.scan_text("README.md", "Compute: Raspberry Pi 5.\n", "openamr-platform-hw")), 1) + self.assertTrue(d["verification"]["human"]["evidence"], d["id"]) + + def test_non_numeric_decisions_are_present(self): + kinds = {d["id"]: d["kind"] for d in self.decisions} + for did in ("MAX-ASSEMBLED-HEIGHT", "NO-SUSPENSION", "RS485-NOT-IN-2-0", "DOCK-NO-CONTACTS", + "LIFT-REMOVED", "DOCKING-NOT-CHARGING", "TELEMETRY-NOT-SAFETY-EVIDENCE"): + self.assertIn(kinds[did], {"exclusion", "distinction"}, did) + + def test_imu_topic_attributed_to_firmware(self): + self.assertEqual(self.ids("launch/a.launch.py", "# the MCU bridge publishes /imu/data and /odom\n"), + ["IMU-TOPIC-OWNERSHIP"]) + self.assertEqual(self.ids("launch/a.launch.py", "# MCU bridge: /odom/unfiltered, /imu/data\n"), + ["IMU-TOPIC-OWNERSHIP"]) + self.assertEqual(self.ids("docs/imu.md", "The MCU publishes /imu/data_raw; the host filter publishes /imu/data.\n"), []) + + def test_shoulder_height_versus_envelope(self): + self.assertEqual(self.ids("docs/a.md", "Shoulder axis at 1700 mm.\n", "openamrobot-docs"), ["MAX-ASSEMBLED-HEIGHT"]) + self.assertEqual(self.ids("docs/a.md", "Maximum assembled height 1700 mm, not a shoulder height.\n", + "openamrobot-docs"), []) + + def test_superseded_mast_values_and_citation(self): + self.assertEqual(self.ids("docs/a.md", "The mast_1400 slot is the baseline.\nUse mast_1600.\n", "x"), + ["MAST-INSTALL-HEIGHT", "MAST-POSITIONS"]) + self.assertEqual(self.ids("docs/a.md", "Height per P-03 rev18.1 item 6.\n", "x"), ["MAST-INSTALL-HEIGHT"]) + + def test_exclusions(self): + self.assertEqual(self.ids("docs/a.md", "The base uses sprung drive wheels.\n", "x"), ["NO-SUSPENSION"]) + self.assertEqual(self.ids("docs/a.md", "No sprung drive wheels in 2.0.\n", "x"), []) + self.assertEqual(self.ids("docs/a.md", "The dock has two charging contacts.\n", "openamrobot-docs"), ["DOCK-NO-CONTACTS"]) + self.assertEqual(self.ids("docs/a.md", "There are no charging contacts.\n", "openamrobot-docs"), []) + self.assertEqual(self.ids("docs/a.md", "The drive talks RS485 to the base.\n", "openamr-platform-hw"), ["RS485-NOT-IN-2-0"]) + self.assertEqual(self.ids("docs/a.md", "The lift controller moves the arms.\n", "x"), ["LIFT-REMOVED"]) + + def test_docking_never_establishes_charging(self): + self.assertEqual(self.ids("src/dock.py", "def isCharging(self): return true\n", "openamr-platform-sw"), + ["DOCKING-NOT-CHARGING"]) + self.assertEqual(self.ids("web/a.ts", "// when docked the robot is connected to external power\n", + "openamrobot-ui"), ["DOCKING-NOT-CHARGING"]) + + def test_telemetry_is_not_safety_evidence(self): + self.assertEqual(self.ids("docs/a.md", "The watchdog is our safety layer.\n", "x"), ["TELEMETRY-NOT-SAFETY-EVIDENCE"]) + self.assertEqual(self.ids("docs/a.md", "The watchdog is functional, not a safety layer.\n", "x"), []) + + def test_estop_recommendation(self): + self.assertEqual(self.ids("docs/a.md", "An uncertified button is fine for prototypes.\n", "x"), + ["SAFETY-PROCUREMENT"]) + + def test_legacy_label_exempts_compute(self): + self.assertEqual(self.ids("README.md", "Legacy build: Raspberry Pi 5.\n", "openamr-platform-hw"), []) + self.assertEqual(self.ids("README.md", "Compute: Raspberry Pi 5.\n", "openamr-platform-hw"), ["COMPUTE"]) if __name__ == "__main__": diff --git a/tools/check_decisions.py b/tools/check_decisions.py index 8875d59..0f78431 100644 --- a/tools/check_decisions.py +++ b/tools/check_decisions.py @@ -1,11 +1,15 @@ #!/usr/bin/env python3 -"""Check a repository checkout against decisions.yaml. +"""Check a repository checkout against the decisions register (decisions.yaml). -Every decision in decisions.yaml names the file globs it applies to and the -patterns that reveal a contradicting value. This tool validates the schema, -then scans the checkout and reports file, line, found value and decided -value for each contradiction. Exit status: 0 clean, 1 contradiction found, -2 invalid decisions file or usage error. +Every decision names the file globs it applies to and the patterns that +reveal a contradicting value; a superseded entry may also name a pattern for +text that still cites the superseded source. This tool validates the schema, +scans the checkout and reports file, line, found value and decided value. +It reads only the pinned register and the checkout: it fetches nothing and +never writes to either. A match proves a textual contradiction only, never +mechanical, electrical or safety correctness; each entry names the human +reviewer and evidence for that. Exit status: 0 clean, 1 contradiction found, +2 invalid register or usage error. A line that must keep a superseded value (history, changelog, legacy material) carries the marker `decision-allow: ` on the same line @@ -22,8 +26,10 @@ import yaml SCHEMA_VERSION = 1 -STATUSES = {"recorded", "proposed", "open"} -REQUIRED = ("id", "title", "status", "date", "source", "applies_to", "check", "owner") +STATUSES = {"recorded", "open"} +KINDS = {"value", "configuration", "limit", "exclusion", "distinction"} +REQUIRED = ("id", "title", "kind", "status", "date", "source", "applies_to", "check", + "verification", "owner") DEFAULT_FILES = [ "**/*.md", "**/*.yaml", "**/*.yml", "**/*.launch.py", "**/*.launch.xml", "**/*.launch", "**/*.urdf", "**/*.xacro", "**/package.xml", "**/README*", @@ -68,6 +74,15 @@ def load_decisions(path, maintainers=None): seen.add(d["id"]) if d["status"] not in STATUSES: errors.append(f"{where}: status must be one of {sorted(STATUSES)}") + if d["kind"] not in KINDS: + errors.append(f"{where}: kind must be one of {sorted(KINDS)}") + ver = d["verification"] + human = ver.get("human") if isinstance(ver, dict) else None + if not isinstance(ver, dict) or not ver.get("machine") or not isinstance(human, dict) \ + or not human.get("reviewer") or not human.get("evidence"): + errors.append(f"{where}: verification needs machine and human (reviewer, evidence)") + elif roles is not None and human["reviewer"] not in roles: + errors.append(f"{where}: reviewer {human['reviewer']!r} is not a role in the maintainers map") if "value" not in d and "values" not in d: errors.append(f"{where}: needs value or values") src = d["source"] @@ -80,9 +95,15 @@ def load_decisions(path, maintainers=None): for sup in d.get("supersedes") or []: if not isinstance(sup, dict) or "value" not in sup or not sup.get("source"): errors.append(f"{where}: each supersedes entry needs value and source") + elif sup.get("citation"): + try: + if "found" not in re.compile(sup["citation"], re.IGNORECASE).groupindex: + errors.append(f"{where}: citation needs a (?P...) group") + except re.error as exc: + errors.append(f"{where}: bad citation pattern: {exc}") applies = d["applies_to"] - if not isinstance(applies, dict) or not applies.get("repositories"): - errors.append(f"{where}: applies_to needs repositories") + if not isinstance(applies, dict) or not applies.get("repositories") or not applies.get("files"): + errors.append(f"{where}: applies_to needs repositories and files") checks = d["check"] if not isinstance(checks, list) or not checks: errors.append(f"{where}: check must be a non-empty list of patterns") @@ -101,6 +122,10 @@ def load_decisions(path, maintainers=None): exclude = data.get("exclude") or [] for d in decisions: d["_exclude"] = list(exclude) + list(d["applies_to"].get("exclude") or []) + d["_checks"] = list(d["check"]) + [ + {"pattern": sup["citation"], "unless": sup.get("citation_unless"), + "message": f"superseded source still cited ({sup['source']}); cite {d['source']['document']}"} + for sup in d.get("supersedes") or [] if isinstance(sup, dict) and sup.get("citation")] return decisions @@ -146,11 +171,12 @@ def list_files(root): return sorted(files) -def scan(root, decisions, repository=None, only=None): +def scan(root, decisions, repository=None, only=None, stats=None): """Return (findings, allowed) for the checkout at root.""" root = Path(root) files = list(only) if only is not None else list_files(root) findings, allowed = [], [] + unscanned = 0 active = [d for d in decisions if d["status"] == "recorded" and repository_matches(d, repository)] for rel in files: path = root / rel @@ -159,6 +185,7 @@ def scan(root, decisions, repository=None, only=None): relevant = [d for d in active if glob_match(rel, d["applies_to"].get("files") or DEFAULT_FILES) and not glob_match(rel, d["_exclude"])] if not relevant: + unscanned += 1 continue try: lines = path.read_text(encoding="utf-8").splitlines() @@ -167,7 +194,7 @@ def scan(root, decisions, repository=None, only=None): for number, line in enumerate(lines, 1): for d in relevant: hit = False - for c in d["check"]: + for c in d["_checks"]: if hit: break if c.get("files") and not glob_match(rel, c["files"]): @@ -189,6 +216,8 @@ def scan(root, decisions, repository=None, only=None): else: findings.append(record) break + if stats is not None: + stats["unscanned"] = unscanned return findings, allowed @@ -216,7 +245,8 @@ def main(argv=None): print(f"INVALID decisions file:\n{exc}", file=sys.stderr) return 2 print(f"decisions: {len(decisions)} loaded, " - f"{sum(d['status'] == 'recorded' for d in decisions)} recorded and enforced") + f"{sum(d['status'] == 'recorded' for d in decisions)} recorded and scanned, " + f"{sum(d['status'] == 'open' for d in decisions)} open") if a.validate_only: return 0 if not a.root.is_dir(): @@ -225,7 +255,8 @@ def main(argv=None): only = None if a.changed_files: only = [line.strip() for line in a.changed_files.read_text(encoding="utf-8").splitlines() if line.strip()] - findings, allowed = scan(a.root, decisions, a.repository, only) + stats = {} + findings, allowed = scan(a.root, decisions, a.repository, only, stats) for f in allowed: print(f"ALLOWED {f['file']}:{f['line']}: {f['id']} found {f['found']!r}; reason: {f['reason']}") for f in findings: @@ -234,7 +265,9 @@ def main(argv=None): if a.json: a.json.write_text(json.dumps({"findings": findings, "allowed": allowed}, indent=2), encoding="utf-8") scope = f"{len(only)} changed file(s)" if only is not None else "full checkout" - print(f"result: {len(findings)} contradiction(s), {len(allowed)} allowed, scope {scope}") + print(f"result: {len(findings)} contradiction(s), {len(allowed)} allowed, scope {scope}; " + f"{stats['unscanned']} file(s) not scanned (no decision covers their type or path)") + print("note: a clean result shows textual consistency only, not mechanical, electrical or safety correctness") return 1 if findings else 0 From 58a4e6dd6dff49a2aab714dfaefbdfeecc3903ff Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Mon, 28 Sep 2026 23:04:22 +0000 Subject: [PATCH 013/129] pr-evidence: report safety paths as requiring approvals, not as approved The check reports "safety path touched, two human approvals required", fails only when fewer than two human reviewers (including the platform lead) are requested, and counts approvals for information. Approvals themselves are a ruleset requirement. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- maintainers.yaml | 5 ++++- tests/test_check_pr_evidence.py | 18 +++++++++++++----- tools/check_pr_evidence.py | 18 ++++++++++++++---- 4 files changed, 32 insertions(+), 11 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e004ae1..d8408e8 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -38,7 +38,7 @@ Head SHA: ## Safety impact ## STATE.md diff --git a/maintainers.yaml b/maintainers.yaml index b7ba778..48a6b94 100644 --- a/maintainers.yaml +++ b/maintainers.yaml @@ -49,7 +49,10 @@ repositories: openamrobot-release: [release-owner] openamrobot-docs: [docs-owner] -# Paths whose change needs two human reviewers including the platform lead. +# Paths whose change needs two human approvals including the platform lead. +# check_pr_evidence.py reports "safety path touched, two human approvals +# required" and checks that reviewers are requested; the approvals themselves +# are enforced by each repository's ruleset (rollout/workflows/SETUP.md). safety_paths: - "**/*estop*" - "**/*e_stop*" diff --git a/tests/test_check_pr_evidence.py b/tests/test_check_pr_evidence.py index a69d460..57df904 100644 --- a/tests/test_check_pr_evidence.py +++ b/tests/test_check_pr_evidence.py @@ -146,16 +146,24 @@ class SafetyRules(unittest.TestCase): def test_safety_path_needs_two_humans_including_platform_lead(self): failures = evaluate(changed=self.CHANGED, reviewers=["someone"])[0] - self.assertIn("Safety paths changed: 1 human reviewer(s) requested, 2 required", failures) - self.assertIn("Safety paths changed: platform lead @BotshareAI is not among the reviewers", failures) + self.assertIn("Safety path touched, two human approvals required: only 1 human reviewer(s) requested", failures) + self.assertIn("Safety path touched, two human approvals required: platform lead @BotshareAI is not requested", failures) def test_bots_and_author_do_not_count(self): failures = evaluate(changed=self.CHANGED, reviewers=["BotshareAI", "claude[bot]", "contributor"])[0] - self.assertIn("Safety paths changed: 1 human reviewer(s) requested, 2 required", failures) + self.assertIn("Safety path touched, two human approvals required: only 1 human reviewer(s) requested", failures) def test_two_humans_with_lead_pass_including_submitted_reviews(self): - reviews = [{"user": {"login": "panthera-momagdii"}}] - self.assertEqual(evaluate(changed=self.CHANGED, reviewers=["BotshareAI"], reviews=reviews)[0], []) + reviews = [{"user": {"login": "panthera-momagdii"}, "state": "COMMENTED"}] + failures, _, notes = evaluate(changed=self.CHANGED, reviewers=["BotshareAI"], reviews=reviews) + self.assertEqual(failures, []) + self.assertTrue(any(n.startswith("Safety path touched, two human approvals required") for n in notes)) + + def test_requested_reviewers_are_not_approvals(self): + reviews = [{"user": {"login": "panthera-momagdii"}, "state": "APPROVED"}, + {"user": {"login": "claude[bot]"}, "state": "APPROVED"}] + notes = evaluate(changed=self.CHANGED, reviewers=["BotshareAI"], reviews=reviews)[2] + self.assertTrue(any("Human approvals so far: 1 (information only" in n for n in notes)) def test_ai_assisted_safety_change_fails(self): body = BODY.replace("## AI disclosure\nNone", "## AI disclosure\nClaude Code drafted the watchdog change.") diff --git a/tools/check_pr_evidence.py b/tools/check_pr_evidence.py index 20e6ea0..552b986 100644 --- a/tools/check_pr_evidence.py +++ b/tools/check_pr_evidence.py @@ -4,7 +4,11 @@ Fails when a required template section is missing or empty, when the Evidence section lacks base SHA, head SHA or a command, when test files change without a reported non-zero test run, or when safety paths change -without two human reviewers including the platform lead. Writes one +and fewer than two human reviewers (including the platform lead) are +requested. Requesting reviewers is not approval: this check reports "safety +path touched, two human approvals required" and counts approvals for +information only; the approvals themselves are enforced by the repository +ruleset (rollout/workflows/SETUP.md), not by this check. Writes one Markdown summary; with --post it creates or updates a single PR comment identified by a hidden marker. Exit status: 0 pass, 1 fail, 2 usage error. """ @@ -130,11 +134,17 @@ def evaluate(pr, changed, maintainers, reviews=(), has_state=False): people = {u.get("login") for u in pr.get("requested_reviewers") or []} people |= {r.get("user", {}).get("login") for r in reviews} people = {p for p in people if human(p) and p != pr.get("user", {}).get("login")} - notes.append(f"Safety paths changed: {', '.join(safety[:10])}") + approvals = {r.get("user", {}).get("login") for r in reviews if r.get("state") == "APPROVED"} + approvals = {p for p in approvals if human(p) and p != pr.get("user", {}).get("login")} + notes.append(f"Safety path touched, two human approvals required: {', '.join(safety[:10])}. " + f"Human approvals so far: {len(approvals)} (information only; the ruleset enforces approvals, " + "this check does not)") if len(people) < 2: - failures.append(f"Safety paths changed: {len(people)} human reviewer(s) requested, 2 required") + failures.append(f"Safety path touched, two human approvals required: only {len(people)} " + "human reviewer(s) requested") if lead and lead not in people: - failures.append(f"Safety paths changed: platform lead @{lead} is not among the reviewers") + failures.append(f"Safety path touched, two human approvals required: platform lead @{lead} " + "is not requested") ai = find(secs, "AI disclosure") or "" if ai and not NONE.match(ai): failures.append("Safety paths changed in a PR with AI assistance; agents do not author " From 29421578381d6dd16b71e58893ea701b41a6348b Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Mon, 28 Sep 2026 23:04:22 +0000 Subject: [PATCH 014/129] public-extract: narrow allowlist for published contact and licence notices Replaces the path-wide vendored-file entry with a line condition: an e-mail address is allowed only on a licence, copyright or author notice line, plus the organization contact and placeholders. Handles are not e-mail addresses. Test URLs are assembled at run time. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- public-extract-allowlist.yaml | 37 +++++++++++++++++------------- tests/test_check_public_extract.py | 13 +++++++++-- tools/check_public_extract.py | 11 ++++++--- 3 files changed, 40 insertions(+), 21 deletions(-) diff --git a/public-extract-allowlist.yaml b/public-extract-allowlist.yaml index 95e506e..af79431 100644 --- a/public-extract-allowlist.yaml +++ b/public-extract-allowlist.yaml @@ -1,25 +1,30 @@ -# Allowlist for tools/check_public_extract.py. -# Each entry: rule, match (regular expression for the whole matched text), -# optional paths and repositories (globs), and a reason. Adding an entry is a -# review decision: the docs owner decides, the platform lead decides entries -# about commercial or company information. +# Allowlist for tools/check_public_extract.py. Kept narrow on purpose: it admits +# published contact and licensing information, not e-mail addresses in general. +# +# Entry fields: rule, match (regular expression for the whole matched text), +# optional paths and repositories (globs), optional line (regular expression +# the whole source line must contain), and reason. GitHub handles such as +# @BotshareAI are not e-mail addresses and need no entry. +# +# Adding an entry is a review decision: the docs owner decides entries about +# published pages, the platform lead decides entries about company or +# commercial information. Each entry below states why it is legitimate. allow: - rule: email match: 'info@botshare\.ai' - reason: published organization contact for licensing and maintainers (DOCUMENTATION_STANDARD.md) + reason: >- + Published organization contact for licensing and maintainers, named in + DOCUMENTATION_STANDARD.md and CONTRIBUTING.md. - rule: email match: '[^@\s]+@(?:example\.(?:com|org|net)|users\.noreply\.github\.com)' - reason: documentation placeholders and GitHub no-reply identities + reason: Documentation placeholders and GitHub no-reply identities, which identify no person. - rule: email match: '[^@\s]+@[^@\s]+' - paths: ["web/public/ros/*.js"] - repositories: ["openamrobot-ui"] - reason: third-party author attribution inside vendored roslib/ros2d bundles; provenance must be preserved - - rule: email - match: 'paroga@paroga\.com' - paths: ["README.md"] - repositories: ["openamrobot-ui"] - reason: copyright notice of the bundled cbor-js library; provenance must be preserved + line: '@author\b|\b(?:copyright|licen[cs]e[ds]?|spdx-license-identifier|authors?:|maintainers?:)|\(c\)' + reason: >- + Licence and copyright notices of third-party code must keep their author + contact to preserve provenance (THIRD_PARTY_POLICY.md). Only lines that + are such notices qualify. - rule: credential match: '.*(?:your|example|placeholder|changeme|xxxx|<[^>]*>).*' - reason: placeholder values in setup instructions, not secrets + reason: Placeholder values in setup instructions, not secrets. diff --git a/tests/test_check_public_extract.py b/tests/test_check_public_extract.py index bb50c2b..c5edc33 100644 --- a/tests/test_check_public_extract.py +++ b/tests/test_check_public_extract.py @@ -15,8 +15,8 @@ import check_public_extract as pe # noqa: E402 AT = "@" -DRIVE = "https://" + "drive.google.com/file/d/abc123/view" -DOCS = "https://" + "docs.google.com/document/d/xyz/edit" +DRIVE = "https://" + "drive" + ".google.com/file/d/abc123/view" +DOCS = "https://" + "docs" + ".google.com/document/d/xyz/edit" TOKEN = "gh" + "p_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8S9t0" KEY = "AK" + "IA" + "ABCDEFGHIJKLMNOP" @@ -86,6 +86,15 @@ def test_organization_allowlist_loads_and_exempts_placeholders(self): self.assertEqual(self.hits(f"info{AT}botshare.ai\n", allow=allow), []) self.assertEqual(len(self.hits(f"someone{AT}botshare.ai\n", allow=allow)), 1) + def test_organization_allowlist_admits_only_notice_lines(self): + allow = pe.load_allowlist(ROOT / "public-extract-allowlist.yaml") + self.assertEqual(self.hits(f" * @author A. Writer - writer{AT}uni.example-lab.org\n", "web/public/lib.js", allow=allow), []) + self.assertEqual(self.hits(f"Copyright (c) 2014 A. Writer , MIT License\n", "README.md", allow=allow), []) + self.assertEqual(len(self.hits(f"Write to writer{AT}lab.org for a quote.\n", "README.md", allow=allow)), 1) + + def test_handles_are_not_emails(self): + self.assertEqual(self.hits("Reviewed by @BotshareAI and @panthera-momagdii.\n"), []) + def write_allow(self, text): tmp = tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) tmp.write(text) diff --git a/tools/check_public_extract.py b/tools/check_public_extract.py index fa9c798..33f56ea 100644 --- a/tools/check_public_extract.py +++ b/tools/check_public_extract.py @@ -5,7 +5,9 @@ Scope: files under docs/ or assets/, every README.md, and every path that contains "public". Rules: google-drive-link, email, phone, price, credential. An allowlist file (YAML) can exempt a match; every entry needs a rule, a -regular expression for the matched text, optional path globs and a reason. +regular expression for the matched text, optional path, repository and line +conditions, and a reason. GitHub handles (@name) are not e-mail addresses and +are never reported. Exit status: 0 clean, 1 findings, 2 usage or configuration error. """ import argparse @@ -67,14 +69,17 @@ def load_allowlist(path): out.append({ "rule": e["rule"], "match": re.compile(e["match"], re.I), "paths": e.get("paths") or ["*"], "repositories": e.get("repositories") or ["*"], + "line": re.compile(e["line"], re.I) if e.get("line") else None, }) return out -def allowed(entries, rule, text, rel, repository): +def allowed(entries, rule, text, rel, repository, line=""): for e in entries: if e["rule"] != rule or not e["match"].fullmatch(text): continue + if e["line"] and not e["line"].search(line): + continue if not any(fnmatch.fnmatch(rel, p) for p in e["paths"]): continue if repository and not any(fnmatch.fnmatch(repository, r) for r in e["repositories"]): @@ -111,7 +116,7 @@ def scan(root, allow, repository=None, only=None): for rule, rx in RULES.items(): for m in rx.finditer(line): text = m.group(0).strip() - if not allowed(allow, rule, text, rel, repository): + if not allowed(allow, rule, text, rel, repository, line): findings.append({"file": rel, "line": number, "rule": rule, "found": text[:120]}) return findings From 72437b4cdfbe7997566bb4d94684ccbd956fb8ee Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Mon, 28 Sep 2026 23:04:22 +0000 Subject: [PATCH 015/129] rollout: audit never closes issues; label designs and examples sync_audit_issues.py no longer closes issues. For a finding a run no longer reports, it comments "no longer detected" once and leaves closure to the owner. Tests use synthetic IDs. Every file under rollout/workflows states that it is an example or design, not installed and not run. SETUP.md classifies every check and workflow as implemented and tested here, rollout example, or human gate, and lists the per-repository rulesets that supply safety-path approvals. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- rollout/workflows/SETUP.md | 149 ++++++++++++------- rollout/workflows/docs-sync-caller.yml | 4 + rollout/workflows/docs-sync.yml | 4 + rollout/workflows/monthly-retro.yml | 4 + rollout/workflows/pr-assistant.yml | 3 + rollout/workflows/weekly-alignment-audit.yml | 10 +- tests/test_sync_audit_issues.py | 91 ++++++----- tools/sync_audit_issues.py | 49 +++--- 8 files changed, 201 insertions(+), 113 deletions(-) diff --git a/rollout/workflows/SETUP.md b/rollout/workflows/SETUP.md index b9f1648..14357f0 100644 --- a/rollout/workflows/SETUP.md +++ b/rollout/workflows/SETUP.md @@ -1,61 +1,85 @@ # Setup for the organization owner -Everything here needs organization-owner or repository-admin rights. Nothing in this list was -configured by the session that wrote it. Each item names where it is used. +Everything here needs organization-owner or repository-admin rights. The session that wrote +this file configured none of it. -## What ran in the authoring session and what is design only +## State of every check and workflow -| Automation | Status | -|---|---| -| Checkers (`tools/*.py`) and `rollout/verify.sh` | Ran locally with unit tests; dry runs against local checkouts of product repositories | -| `repository-quality-reusable.yml` shell steps | Dry run locally against openamrobot-docs and openamrobot-manifest (checkout steps simulated) | -| `pr-assistant.yml` | Its two checker commands ran locally on a simulated event; the workflow has not run on GitHub and has not posted a comment | -| `weekly-alignment-audit.yml` | Design only. `sync_audit_issues.py` ran as a dry run on the 28 September ISSUES.csv (82 issues planned, none created). The agent step never ran | -| `docs-sync-caller.yml`, `docs-sync.yml` | Design only; never ran | -| `monthly-retro.yml` | Design only; never ran | -| All workflow files | `actionlint` 1.7.12 passes (shellcheck integration not available) | +Each item is in one of three states: + +- **(a)** implemented and tested in this repository; +- **(b)** supplied under `rollout/` as an example, not installed anywhere; +- **(c)** a human gate. + +No failure class is blocked across the organization until the relevant workflow is installed +in each repository and its check is required by that repository's ruleset. That installation +is a rollout step (rollout/README.md), not a present fact. + +| Check or workflow | State | What was exercised in the authoring session | +|---|---|---| +| `tools/check_decisions.py` with `decisions.yaml` | (a) | Unit tests on a fixture repository (matching value, contradicting value, unlisted file type, superseded citation, allow marker, exclusion); read-only dry runs on local clones of product repositories | +| `tools/check_public_extract.py` with the allowlist | (a) | Unit tests; dry runs on local clones | +| `tools/check_pr_evidence.py` | (a) | Unit tests; local run on a simulated pull_request event; nothing posted | +| `tools/check_agent_rules.py` (drift) | (a) | Unit tests; run on this repository and on local clones | +| `tools/sync_audit_issues.py` | (a) | Unit tests with a fake API; nothing created or commented | +| `rollout/verify.sh` | (a) | Unit tests; run on this repository and on a local clone of openamrobot-manifest; not on ROS 2 or Node repositories | +| `repository-quality-reusable.yml`, harness steps (`harness_checks: true`) | (a) for this repository's own caller; (b) for every other repository | `run:` steps dry run locally with checkouts simulated; never run on GitHub. Off by default, so existing `@main` callers are unchanged until they opt in | +| `repository-quality-reusable.yml`, `quality/test` job (`verify: true`) | (b) | Never run on GitHub | +| `repository-quality.yml` in this repository (`quality/test`, harness checks) | (a) once merged; never run on GitHub yet | Its commands ran locally | +| `pr-assistant.yml` | (b) | Its two checker commands ran locally; the workflow never ran | +| `weekly-alignment-audit.yml` | (b), design only | Nothing exercised except the issue-sync dry run. Permissions, credentials, deduplication across runs, failure handling and the issue lifecycle are untested | +| `docs-sync-caller.yml`, `docs-sync.yml` | (b), design only | Never run | +| `monthly-retro.yml` | (b), design only | Never run | +| Two human approvals on safety paths | (c) enforced by a ruleset, section 6 | `check_pr_evidence.py` only reports "safety path touched, two human approvals required" and whether reviewers are requested; it does not count approvals as a gate | +| Decision-register changes | (c) the entry's owner | The register's own schema validation is (a) | +| Every `[human: ...]` rule in AGENTS.md | (c) | Not machine-checked | ## 1. Pin the harness -1. After this PR merges, take its merge commit SHA as ``. -2. In every caller of the reusable workflow, replace `@main` with `@` and add - `with: harness_ref: ` (audit CI-001). -3. Replace `` in each copied workflow from `rollout/workflows/`. +1. After the harness PR merges, take its merge commit SHA as ``. +2. In each caller of the reusable workflow, replace `@main` with `@` and add + `with: harness_ref: ` and `harness_checks: true`. +3. Replace `` in each workflow copied from `rollout/workflows/`. ## 2. Secrets | Secret | Scope | Used by | |---|---|---| -| `ANTHROPIC_API_KEY` (or `CLAUDE_CODE_OAUTH_TOKEN`, then change the input name) | repositories audits, openamrobot-docs, .github | weekly audit, docs sync, monthly retro | -| `AUDIT_APP_ID`, `AUDIT_APP_PRIVATE_KEY` | repository audits | weekly audit, opening and closing finding issues | -| `DOCS_SYNC_APP_ID`, `DOCS_SYNC_APP_PRIVATE_KEY` | organization secret, all product repositories | docs sync sender (repository_dispatch to openamrobot-docs) | -| `RETRO_APP_ID`, `RETRO_APP_PRIVATE_KEY` | repository .github | monthly retro, reading issues and review comments | +| `ANTHROPIC_API_KEY` (or `CLAUDE_CODE_OAUTH_TOKEN`, then change the input name) | audits, openamrobot-docs, .github | weekly audit, docs sync, monthly retro | +| `AUDIT_APP_ID`, `AUDIT_APP_PRIVATE_KEY` | audits | weekly audit issue sync | +| `DOCS_SYNC_APP_ID`, `DOCS_SYNC_APP_PRIVATE_KEY` | organization secret, product repositories | docs sync sender | +| `RETRO_APP_ID`, `RETRO_APP_PRIVATE_KEY` | .github | monthly retro | -The three App secret pairs may point to one GitHub App. The PR assistant needs no secret; it -uses `GITHUB_TOKEN`. +The three App secret pairs may point to one GitHub App. The PR assistant uses only +`GITHUB_TOKEN`. ## 3. GitHub Apps -1. **Claude GitHub App** (github.com/apps/claude), installed on audits, openamrobot-docs and - .github only. It requests Contents, Issues and Pull requests read and write; the current - Claude Code documentation lists further permissions (Actions, Checks, Discussions, - Workflows, Members, Statuses) because the App is shared with other Claude features. Grant - what the install screen asks, on those three repositories only. -2. **Harness App** (organization-owned, private), installed on every product repository and - audits, with: Issues read and write; Pull requests read; Contents read and write (needed - only for `repository_dispatch` to openamrobot-docs); Metadata read. No administration, - workflow or secrets permissions. +1. **Claude GitHub App** (github.com/apps/claude), on audits, openamrobot-docs and .github + only. It asks for Contents, Issues and Pull requests read and write. The current Claude Code + documentation lists further permissions because the App is shared with other Claude + features. Grant what the install screen asks, on those three repositories only. +2. **Harness App** (organization-owned, private), installed on the product repositories and + audits, with these permissions: + - Issues: read and write. + - Pull requests: read. + - Contents: read and write. This is needed only for `repository_dispatch` to openamrobot-docs. + - Metadata: read. + + It gets no administration, workflow or secrets permission. ## 4. Actions settings -- Workflow permissions default: read repository contents only. -- Keep "Allow GitHub Actions to create and approve pull requests" off. No workflow here - approves anything; the agents open PRs with the Claude App token. -- If the organization allow-lists actions, allow exactly: `actions/checkout`, - `actions/upload-artifact`, `actions/create-github-app-token`, - `anthropics/claude-code-action` (pinned in the files to v7.0.1, v7.0.1, v3.2.0 and - v1.0.236 by commit SHA). -- Fork pull request workflows: require approval for first-time contributors. +- Default workflow token: read repository contents only. +- "Allow GitHub Actions to create and approve pull requests": off. +- If actions are allow-listed, allow exactly these, pinned by commit SHA in the files: + - `actions/checkout` v7.0.1 + - `actions/upload-artifact` v7.0.1 + - `actions/create-github-app-token` v3.2.0 + - `anthropics/claude-code-action` v1.0.236 + + The live reusable workflow still uses `actions/checkout@v4` and `actions/upload-artifact@v4`. +- Require approval for workflows from first-time fork contributors. ## 5. Labels (every repository) @@ -66,21 +90,42 @@ uses `GITHUB_TOKEN`. | `contract-change` | contract change request form | | `audit-finding`, `blocker`, `major` | weekly audit issue sync | | `triage`, `bug` | existing forms | -| `area:docs`, `area:navigation`, `area:interfaces`, `area:manipulation`, `area:ui`, `area:release`, `area:ci` | good first issue triage, CONTRIBUTING.md | +| `area:docs`, `area:navigation`, `area:interfaces`, `area:manipulation`, `area:ui`, `area:release`, `area:ci` | good first issue triage | + +## 6. Rulesets on main (per repository) + +For every active repository: + +- Require a pull request, CODEOWNERS review and conversation resolution. +- Block force pushes and deletions; restrict bypass to the organization owner. +- Required status checks, each added after it has passed on main once: + - `repository-quality / repository-quality` (with `harness_checks: true`) + - `quality/pr-evidence` (once `pr-assistant.yml` is installed) + - `quality/test` (once `verify: true` is set) + +**Two human approvals on safety paths.** A ruleset rule, not a check, supplies these. The +paths are listed under `safety_paths` in maintainers.yaml. For each repository that contains +such paths, add a ruleset with "Require approvals: 2" and "Require review from Code Owners", +and a CODEOWNERS entry that makes the platform lead an owner of those paths: + +| Repository | Safety-relevant paths to cover | Approvals | +|---|---|---| +| openamr-platform-fw | E-stop, brake, contactor, watchdog, motor-enable, charge-inhibit sources | 2, platform lead via CODEOWNERS | +| openamr-platform-hw | safety chain wiring, E-stop and contactor documents | 2, platform lead via CODEOWNERS | +| openamr-platform-sw | watchdog, collision monitor, docking and charge-state code | 2, platform lead via CODEOWNERS | +| openamr-upperbody-fw, openamr-upperbody-hw | arm power and E-stop integration | 2, platform lead via CODEOWNERS | +| openamrobot-ui | E-stop and stop controls, charge-state display | 2, platform lead via CODEOWNERS | +| openamrobot-docs | `docs/safety/` and safety sections of reference pages | 2, platform lead via CODEOWNERS | -## 6. Branch protection on main (every active repository) +GitHub rulesets apply approval counts per branch, not per path. Where a repository does not +want two approvals on every PR, the path-level requirement rests on CODEOWNERS for the lead's +approval. The second approval stays a human gate (c) that `check_pr_evidence.py` reports. -- Require a pull request; require CODEOWNERS review; require conversation resolution. -- Required status checks: `repository-quality / repository-quality`, `quality/pr-evidence` - (after the PR assistant is installed), `quality/test` where the verify job is enabled; in - .github also `quality/test` from `repository-quality.yml`. -- Block force pushes and deletions. Restrict bypass to the organization owner and record each - bypass in the PR. -- Single-maintainer repositories: checks stay required; the CODEOWNERS-review waiver follows - section 8 of the Engineering Quality Standard. +Single-maintainer repositories keep required checks. The CODEOWNERS waiver follows section 8 +of the Engineering Quality Standard. ## 7. Maintainers map -Fill the `null` handles in `maintainers.yaml` (ci-owner, docs-owner) once the people have -confirmed their GitHub accounts and joined the organization. Until then, audit issues for -CI and DOC findings say "no handle recorded" and nobody is mentioned. +Fill the `null` handles in `maintainers.yaml` (ci-owner, docs-owner) once the people confirm +their GitHub accounts and join the organization. Until then, automation names the role and +mentions nobody. diff --git a/rollout/workflows/docs-sync-caller.yml b/rollout/workflows/docs-sync-caller.yml index f0e8bc5..b3a1d0c 100644 --- a/rollout/workflows/docs-sync-caller.yml +++ b/rollout/workflows/docs-sync-caller.yml @@ -1,3 +1,7 @@ +# DESIGN ONLY (state b): an example under rollout/, not installed anywhere and +# never run. Permissions, credentials, deduplication and failure handling have +# not been exercised. +# # Docs sync, sender side: copy to .github/workflows/docs-sync.yml in every # repository except openamrobot-docs. On push to main it sends one # repository_dispatch event to openamrobot-docs with the repository and the diff --git a/rollout/workflows/docs-sync.yml b/rollout/workflows/docs-sync.yml index b7e873f..9be28c7 100644 --- a/rollout/workflows/docs-sync.yml +++ b/rollout/workflows/docs-sync.yml @@ -1,3 +1,7 @@ +# DESIGN ONLY (state b): an example under rollout/, not installed anywhere and +# never run. Permissions, credentials, deduplication and failure handling have +# not been exercised. +# # Docs sync, receiver side: copy to .github/workflows/docs-sync.yml in # openamrobot-docs. On a source-changed dispatch it compares the source diff # with the documentation pages and, when a page became wrong, opens one draft diff --git a/rollout/workflows/monthly-retro.yml b/rollout/workflows/monthly-retro.yml index 3c906cd..0d0e776 100644 --- a/rollout/workflows/monthly-retro.yml +++ b/rollout/workflows/monthly-retro.yml @@ -1,3 +1,7 @@ +# DESIGN ONLY (state b): an example under rollout/, not installed anywhere and +# never run. Permissions, credentials, deduplication and failure handling have +# not been exercised. +# # Monthly retro: copy to .github/workflows/monthly-retro.yml in openAMRobot/.github. # Reads open issues labelled harness across the organization and recent review # comments that describe agent mistakes, then opens one draft PR here proposing diff --git a/rollout/workflows/pr-assistant.yml b/rollout/workflows/pr-assistant.yml index cd71999..f064ef4 100644 --- a/rollout/workflows/pr-assistant.yml +++ b/rollout/workflows/pr-assistant.yml @@ -1,3 +1,6 @@ +# EXAMPLE (state b): supplied under rollout/, not installed anywhere. Its two checker +# commands ran locally in the authoring session; the workflow itself has never run. +# # PR assistant: copy to .github/workflows/pr-assistant.yml in each repository. # # Runs check_decisions.py on the changed files and check_pr_evidence.py on the diff --git a/rollout/workflows/weekly-alignment-audit.yml b/rollout/workflows/weekly-alignment-audit.yml index e772905..f6f0b57 100644 --- a/rollout/workflows/weekly-alignment-audit.yml +++ b/rollout/workflows/weekly-alignment-audit.yml @@ -1,3 +1,7 @@ +# DESIGN ONLY (state b): an example under rollout/, not installed anywhere and +# never run. Its permissions, credentials, deduplication, failure handling and +# issue lifecycle have not been exercised. +# # Weekly alignment audit: copy to .github/workflows/ in the private audit # repository (openAMRobot/audits). Read-only towards every product repository. # @@ -5,7 +9,9 @@ # 2. Runs Claude Code with agent-prompts/read-only-audit.md, starting from the # newest previous ISSUES.csv, and writes a new dated report folder here. # 3. sync_audit_issues.py opens issues for new Blocker and Major findings with -# the owner from maintainers.yaml, and closes issues for resolved findings. +# the owner from maintainers.yaml. It never closes an issue: for a finding +# the run no longer reports, it comments "no longer detected" once and the +# issue's owner decides whether to close it. # Replace . Secrets: ANTHROPIC_API_KEY; AUDIT_APP_ID and # AUDIT_APP_PRIVATE_KEY for the issue-writing GitHub App (see SETUP.md). name: Weekly alignment audit @@ -85,7 +91,7 @@ jobs: private-key: ${{ secrets.AUDIT_APP_PRIVATE_KEY }} owner: openAMRobot - - name: Open and close finding issues + - name: Open finding issues; comment on findings no longer detected (never closes) env: GITHUB_TOKEN: ${{ steps.app.outputs.token }} run: | diff --git a/tests/test_sync_audit_issues.py b/tests/test_sync_audit_issues.py index fd88618..514b900 100644 --- a/tests/test_sync_audit_issues.py +++ b/tests/test_sync_audit_issues.py @@ -1,4 +1,4 @@ -"""Tests for tools/sync_audit_issues.py.""" +"""Tests for tools/sync_audit_issues.py. All rows are synthetic.""" import contextlib import csv import io @@ -18,76 +18,89 @@ "decision_of_record", "fix", "file_to_change", "owner"] -def row(fid, severity="Major", target="openamr-platform-sw ros2/src/bringup/launch/real.launch.py", **kw): +def row(fid, severity="Major", target="openamr-platform-sw ros2/src/pkg/launch/a.launch.py", **kw): base = dict.fromkeys(FIELDS, "") - base.update(id=fid, severity=severity, area="imu", file_to_change=target, - fix="Ask the named lead to decide", owner="A Person", value_a="internal quote") + base.update(id=fid, severity=severity, area="topic", file_to_change=target, + fix="synthetic private fix text", owner="Synthetic Person", value_a="synthetic private quote") base.update(kw) return base +def issue(repo, number, fid, state="open"): + return {"repository": repo, "number": number, "title": f"[audit] {fid}: topic", "state": state} + + class Plan(unittest.TestCase): def run_plan(self, rows, existing=()): - return sai.plan(rows, list(existing), MAINTAINERS, "audits", "2026-10-05-alignment-audit@abc1234") + return sai.plan(rows, list(existing), MAINTAINERS, "audits", "2099-01-01-alignment-audit@abc1234") def test_opens_blocker_and_major_only(self): - to_open, _ = self.run_plan([row("ELE-001", "Blocker"), row("SW-002", "Minor"), row("GEO-003", "Question")]) - self.assertEqual([i["title"] for i in to_open], ["[audit] ELE-001: imu"]) + to_open, _ = self.run_plan([row("ELE-901", "Blocker"), row("SW-902", "Minor"), row("GEO-903", "Question")]) + self.assertEqual([i["title"] for i in to_open], ["[audit] ELE-901: topic"]) self.assertEqual(to_open[0]["repository"], "openamr-platform-sw") self.assertEqual(to_open[0]["labels"], ["audit-finding", "blocker"]) def test_owner_from_maintainers_map_not_from_csv(self): - to_open, _ = self.run_plan([row("SW-001"), row("DOC-001", target="openamrobot-docs docs/a.md")]) + to_open, _ = self.run_plan([row("SW-901"), row("DOC-901", target="openamrobot-docs docs/a.md")]) self.assertIn("Owner: @panthera-momagdii (software-lead)", to_open[0]["body"]) self.assertIn("Owner: docs-owner, no handle recorded", to_open[1]["body"]) - self.assertNotIn("A Person", to_open[0]["body"] + to_open[1]["body"]) + self.assertNotIn("Synthetic Person", to_open[0]["body"] + to_open[1]["body"]) def test_public_issue_is_an_extract(self): - to_open, _ = self.run_plan([row("SW-001")]) - body = to_open[0]["body"] - self.assertNotIn("internal quote", body) - self.assertNotIn("Ask the named lead", body) - self.assertIn("`ros2/src/bringup/launch/real.launch.py`", body) - - def test_plan_document_findings_go_to_private_fallback_with_full_text(self): - to_open, _ = self.run_plan([row("TEAM-004", target="P-01 Status document")]) + body = self.run_plan([row("SW-901")])[0][0]["body"] + self.assertNotIn("synthetic private quote", body) + self.assertNotIn("synthetic private fix", body) + self.assertIn("`ros2/src/pkg/launch/a.launch.py`", body) + + def test_findings_without_public_target_go_to_private_fallback(self): + to_open, _ = self.run_plan([row("TEAM-901", target="plan document only")]) self.assertEqual(to_open[0]["repository"], "audits") - self.assertIn("internal quote", to_open[0]["body"]) + self.assertIn("synthetic private quote", to_open[0]["body"]) def test_existing_issue_is_not_duplicated(self): - existing = [{"repository": "openamr-platform-sw", "number": 5, "title": "[audit] ELE-001: imu", "state": "open"}] - to_open, to_close = self.run_plan([row("ELE-001", "Blocker")], existing) - self.assertEqual((to_open, to_close), ([], [])) - - def test_resolved_or_absent_findings_close_open_issues(self): - existing = [ - {"repository": "openamr-platform-sw", "number": 5, "title": "[audit] ELE-001: imu", "state": "open"}, - {"repository": "openamrobot-docs", "number": 9, "title": "[audit] DOC-021: docs", "state": "open"}, - {"repository": "openamrobot-docs", "number": 3, "title": "[audit] DOC-002: docs", "state": "closed"}, - {"repository": "openamrobot-docs", "number": 4, "title": "Unrelated", "state": "open"}, - ] - _, to_close = self.run_plan([row("ELE-001", status="resolved")], existing) - self.assertEqual(sorted((i["repository"], i["number"]) for i in to_close), + self.assertEqual(self.run_plan([row("ELE-901", "Blocker")], [issue("openamr-platform-sw", 5, "ELE-901")]), ([], [])) + + def test_no_longer_detected_is_commented_never_closed(self): + existing = [issue("openamr-platform-sw", 5, "ELE-901"), issue("openamrobot-docs", 9, "DOC-901"), + issue("openamrobot-docs", 3, "DOC-902", "closed"), + {"repository": "openamrobot-docs", "number": 4, "title": "Unrelated", "state": "open"}] + _, notify = self.run_plan([row("ELE-901", status="resolved")], existing) + self.assertEqual(sorted((i["repository"], i["number"]) for i in notify), [("openamr-platform-sw", 5), ("openamrobot-docs", 9)]) class Api(unittest.TestCase): - def test_apply_creates_comments_and_closes(self): + def test_apply_creates_and_comments_but_never_closes(self): calls = [] + + def fake(method, url, token, data=None): + calls.append((method, url)) + return [] if method == "GET" else {} + sai.apply("org", [{"repository": "r", "title": "t", "body": "b", "labels": ["audit-finding"]}], - [{"repository": "r", "number": 2}], "tok", "abc", - call=lambda m, u, t, d=None: calls.append((m, u))) + [{"repository": "r", "number": 2}], "tok", "run@abc", call=fake) self.assertEqual(calls, [ ("POST", "https://api.github.com/repos/org/r/issues"), + ("GET", "https://api.github.com/repos/org/r/issues/2/comments?per_page=100"), ("POST", "https://api.github.com/repos/org/r/issues/2/comments"), - ("PATCH", "https://api.github.com/repos/org/r/issues/2"), ]) + self.assertFalse(any(m == "PATCH" for m, _ in calls)) + + def test_no_longer_detected_comment_is_posted_once(self): + calls = [] + + def fake(method, url, token, data=None): + calls.append(method) + return [{"body": sai.NOT_DETECTED + " earlier"}] if method == "GET" else {} + + sai.apply("org", [], [{"repository": "r", "number": 2}], "tok", "run", call=fake) + self.assertEqual(calls, ["GET"]) def test_fetch_existing_parses_search(self): item = {"repository_url": "https://api.github.com/repos/org/r", "number": 1, - "title": "[audit] SW-001: x", "state": "open"} + "title": "[audit] SW-901: x", "state": "open"} got = sai.fetch_existing("org", "tok", call=lambda m, u, t, d=None: {"items": [item]}) - self.assertEqual(got, [{"repository": "r", "number": 1, "title": "[audit] SW-001: x", "state": "open"}]) + self.assertEqual(got, [{"repository": "r", "number": 1, "title": "[audit] SW-901: x", "state": "open"}]) class CommandLine(unittest.TestCase): @@ -97,13 +110,13 @@ def test_dry_run_from_csv(self): with open(path, "w", newline="", encoding="utf-8") as stream: w = csv.DictWriter(stream, fieldnames=FIELDS) w.writeheader() - w.writerows([row("ELE-001", "Blocker"), row("SW-002", "Minor")]) + w.writerows([row("ELE-901", "Blocker"), row("SW-902", "Minor")]) out = io.StringIO() with contextlib.redirect_stdout(out): code = sai.main(["--issues", str(path), "--maintainers", str(ROOT / "maintainers.yaml"), "--fallback-repository", "audits", "--report", "x@1"]) self.assertEqual(code, 0) - self.assertIn("plan: 1 to open, 0 to close", out.getvalue()) + self.assertIn("plan: 1 to open, 0 to comment 'no longer detected', 0 closed", out.getvalue()) if __name__ == "__main__": diff --git a/tools/sync_audit_issues.py b/tools/sync_audit_issues.py index dc5d69e..de10937 100644 --- a/tools/sync_audit_issues.py +++ b/tools/sync_audit_issues.py @@ -1,10 +1,13 @@ #!/usr/bin/env python3 -"""Plan and apply issue changes from an alignment-audit ISSUES.csv. +"""Plan and apply issue updates from an alignment-audit ISSUES.csv. Opens one issue per Blocker or Major finding that has no issue yet, in the repository named in file_to_change (or the fallback repository), assigned to -the owner role from maintainers.yaml by audit ID prefix. Closes open audit -issues whose finding is absent from the new CSV or has status resolved. +the owner role from maintainers.yaml by audit ID prefix. It never closes an +issue: when an open audit issue's finding is absent from the new CSV or has +status resolved, it comments "no longer detected" once and leaves closure to +the issue's owner, because one run not reporting a finding is not proof that +it is fixed. Issues in public repositories are public extracts: they carry the finding ID, severity, area, the repository paths to change and the owner handle, never @@ -25,6 +28,7 @@ import yaml LABEL = "audit-finding" +NOT_DETECTED = "" OPEN_SEVERITIES = {"Blocker", "Major"} REPO = re.compile(r"\b(openamr(?:obot)?-[a-z0-9-]+|\.github)\b") @@ -76,7 +80,7 @@ def body_for(row, repository, fallback, maintainers, report): def plan(new_rows, existing, maintainers, fallback, report): - """Return (to_open, to_close). existing: list of {repository, number, title, state}.""" + """Return (to_open, to_notify). existing: list of {repository, number, title, state}.""" public_repos = set(maintainers.get("repositories", {})) known = {} for issue in existing: @@ -92,9 +96,9 @@ def plan(new_rows, existing, maintainers, fallback, report): to_open.append({"repository": repository, "title": title_for(row), "body": body_for(row, repository, fallback, maintainers, report), "labels": [LABEL, row["severity"].lower()]}) - to_close = [dict(issue, id=fid) for fid, issues in known.items() if fid not in active - for issue in issues if issue.get("state") == "open"] - return to_open, to_close + to_notify = [dict(issue, id=fid) for fid, issues in known.items() if fid not in active + for issue in issues if issue.get("state") == "open"] + return to_open, to_notify def api(method, url, token, data=None): @@ -118,16 +122,20 @@ def fetch_existing(org, token, call=api): page += 1 -def apply(org, to_open, to_close, token, sha, call=api): +def apply(org, to_open, to_notify, token, report, call=api): + """Create issues; comment once on issues no longer detected. Never closes.""" base = f"https://api.github.com/repos/{org}" for issue in to_open: call("POST", f"{base}/{issue['repository']}/issues", token, {"title": issue["title"], "body": issue["body"], "labels": issue["labels"]}) - for issue in to_close: - url = f"{base}/{issue['repository']}/issues/{issue['number']}" - call("POST", f"{url}/comments", token, - {"body": f"Resolved according to the alignment audit at {sha}. Reopen if this is wrong."}) - call("PATCH", url, token, {"state": "closed", "state_reason": "completed"}) + for issue in to_notify: + url = f"{base}/{issue['repository']}/issues/{issue['number']}/comments" + comments = call("GET", f"{url}?per_page=100", token) or [] + if any(NOT_DETECTED in (c.get("body") or "") for c in comments): + continue + call("POST", url, token, {"body": ( + f"{NOT_DETECTED}\nNo longer detected by the alignment audit {report}. " + "The owner closes this issue after checking the fix; the audit does not close it.")}) def main(argv=None): @@ -152,16 +160,17 @@ def main(argv=None): return 2 existing = json.loads(a.existing.read_text(encoding="utf-8")) if a.existing else ( fetch_existing(a.org, token) if a.apply else []) - to_open, to_close = plan(rows, existing, maintainers, a.fallback_repository, a.report) + to_open, to_notify = plan(rows, existing, maintainers, a.fallback_repository, a.report) for issue in to_open: - print(f"OPEN {issue['repository']}: {issue['title']}") - for issue in to_close: - print(f"CLOSE {issue['repository']}#{issue['number']}: {issue['id']}") - print(f"plan: {len(to_open)} to open, {len(to_close)} to close") + print(f"OPEN {issue['repository']}: {issue['title']}") + for issue in to_notify: + print(f"COMMENT {issue['repository']}#{issue['number']}: {issue['id']} no longer detected (not closed)") + print(f"plan: {len(to_open)} to open, {len(to_notify)} to comment 'no longer detected', 0 closed") if a.plan_output: - a.plan_output.write_text(json.dumps({"open": to_open, "close": to_close}, indent=2), encoding="utf-8") + a.plan_output.write_text(json.dumps({"open": to_open, "no_longer_detected": to_notify}, indent=2), + encoding="utf-8") if a.apply: - apply(a.org, to_open, to_close, token, a.report) + apply(a.org, to_open, to_notify, token, a.report) return 0 From 935c2b96ec61d031d40e2ff3f5a973c61aca937d Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Mon, 28 Sep 2026 23:04:22 +0000 Subject: [PATCH 016/129] ci: make the harness steps opt-in in the reusable workflow Existing callers reference @main. The decisions, public-extract and drift steps now run only with harness_checks: true, so merging this changes nothing in other repositories until each opts in during rollout. This repository's own caller opts in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- .github/workflows/repository-quality-reusable.yml | 12 ++++++++++++ .github/workflows/repository-quality.yml | 1 + 2 files changed, 13 insertions(+) diff --git a/.github/workflows/repository-quality-reusable.yml b/.github/workflows/repository-quality-reusable.yml index f922a7a..a25013a 100644 --- a/.github/workflows/repository-quality-reusable.yml +++ b/.github/workflows/repository-quality-reusable.yml @@ -10,6 +10,13 @@ on: type: string required: false default: main + harness_checks: + description: >- + Run the decisions, public-extract and shared-rules checks. Off by default so that + existing callers on @main are unchanged until they opt in (rollout/README.md). + type: boolean + required: false + default: false verify: description: Run rollout/verify.sh (or the repository's own tools/verify.sh) as job quality/test. type: boolean @@ -35,6 +42,7 @@ jobs: fetch-depth: 0 - name: Check out OpenAMRobot harness + if: inputs.harness_checks uses: actions/checkout@v4 with: repository: openAMRobot/.github @@ -82,6 +90,7 @@ jobs: ET.parse(path) - name: Prepare harness checks + if: inputs.harness_checks shell: bash env: BASE_SHA: ${{ github.event.pull_request.base.sha }} @@ -98,6 +107,7 @@ jobs: # Push and schedule: full checkout, reported as warnings; the weekly # alignment audit turns the backlog into issues with owners. - name: Decisions of record + if: inputs.harness_checks shell: bash run: | set -uo pipefail @@ -114,6 +124,7 @@ jobs: if [ "${{ github.event_name }}" = pull_request ] || [ "$status" -eq 2 ]; then exit "$status"; fi - name: Public extract + if: inputs.harness_checks shell: bash run: | set -uo pipefail @@ -129,6 +140,7 @@ jobs: if [ "${{ github.event_name }}" = pull_request ] || [ "$status" -eq 2 ]; then exit "$status"; fi - name: Shared agent rules + if: inputs.harness_checks shell: bash run: | set -euo pipefail diff --git a/.github/workflows/repository-quality.yml b/.github/workflows/repository-quality.yml index acc9df5..bab5fc0 100644 --- a/.github/workflows/repository-quality.yml +++ b/.github/workflows/repository-quality.yml @@ -15,6 +15,7 @@ jobs: with: # This repository is the harness: check each change against its own head. harness_ref: ${{ github.event.pull_request.head.sha || github.sha }} + harness_checks: true harness-tests: name: quality/test From 71688db5385910536fdfc36d6301c260a90174ec Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Mon, 28 Sep 2026 23:04:22 +0000 Subject: [PATCH 017/129] governance: honest enforcement labels and the decision-change process AGENTS.md separates process from the decisions register and adds "Changing a decision": change request, source document first, one reviewed PR for register and consumers, owner approval, no tool rewriting either side. Labels now say check only where a checker detects the violation, and human with the reviewer and evidence otherwise. The contract change request form asks for the register entry and points to that process. CONTRIBUTING.md and the rollout plan say checks block merges only once installed and required. agent-runs.md records sanitized failure categories only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- .../contract_change_request.yml | 16 ++- AGENTS.md | 108 ++++++++++-------- CONTRIBUTING.md | 20 +++- agent-rules/SHARED_RULES.md | 102 +++++++++-------- agent-runs.md | 35 +++--- rollout/README.md | 37 +++--- rollout/VERIFY.md | 2 +- 7 files changed, 185 insertions(+), 135 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/contract_change_request.yml b/.github/ISSUE_TEMPLATE/contract_change_request.yml index 2030f98..002c9d7 100644 --- a/.github/ISSUE_TEMPLATE/contract_change_request.yml +++ b/.github/ISSUE_TEMPLATE/contract_change_request.yml @@ -9,7 +9,12 @@ body: Use this form before changing a contract consumed by another repository, node, service, tool, or operator surface: messages, services, actions, schemas, topic names, launch argument names and configuration IDs (for example mast_1350). The change lands in one PR that updates the contract package and every consumer together; that PR links this issue - in its Work package section. Decided values also change in decisions.yaml in openAMRobot/.github. + in its Work package section. + + To change an approved technical decision (a value, limit, exclusion or distinction in the decisions register, + openAMRobot/.github decisions.yaml), follow "Changing a decision" in AGENTS.md: this issue names the entry, the owner + updates the source document, and one reviewed PR updates the register entry, its supersedes history, its check + patterns with a test, and every affected consumer together. No tool rewrites the register or a source document. Keep generic interfaces generic. Do not add application-specific manipulation behavior or authoritative safety behavior to a telemetry or navigation contract without an explicit owning design decision. @@ -31,6 +36,15 @@ body: validations: required: true + - type: input + id: decision_entry + attributes: + label: Decisions register entry + description: The decisions.yaml ID this request changes, or "none" for a contract that is not a recorded decision. + placeholder: "MAST-INSTALL-HEIGHT / none" + validations: + required: true + - type: dropdown id: change_class attributes: diff --git a/AGENTS.md b/AGENTS.md index 58ba656..f807da4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,92 +1,100 @@ # OpenAMRobot rules for contributors and agents -Canonical: openAMRobot/.github, agent-rules/SHARED_RULES.md. Copied verbatim into every -repository's AGENTS.md; tools/check_agent_rules.py fails on drift. Each rule names how it is -enforced: [gate: tool], [template: file] or [decides: role]. Roles resolve in maintainers.yaml. +Canonical: openAMRobot/.github, agent-rules/SHARED_RULES.md, copied verbatim into every +repository's AGENTS.md (tools/check_agent_rules.py reports drift). This file holds process; +approved technical values live in openAMRobot/.github decisions.yaml. Labels: [check: tool] +means the tool detects that violation, and only in repositories where its workflow is installed +and required (rollout/README.md); [template: file]; [human: role, evidence] is a reviewer +decision. A text check proves textual consistency, never mechanical, electrical or safety correctness. -## Sources of record -- Decided values live only in openAMRobot/.github decisions.yaml. No file states a value that - contradicts it; kept history carries `decision-allow: `. [gate: check_decisions.py] -- A decision changes in its source document first, then in decisions.yaml and every flagged - file in one PR. [decides: the decision's owner] -- Read STATE.md before work in a repository; update it in the same PR. [gate: check_pr_evidence.py] +## Decisions +- decisions.yaml is the only register of approved values, limits, exclusions and distinctions. + No file states a contradicting value; kept history carries `decision-allow: `. + [check: check_decisions.py, listed patterns only] [human: entry's reviewer, entry's evidence] +- Changing a decision: (1) open a contract change request issue naming the entry; (2) the + owner updates the source document; (3) one reviewed PR updates the register entry, its + supersedes history, its check patterns with a test, and every affected consumer, or links + each consumer PR; (4) the owner approves. [template: contract change request] [human: owner] +- Source documents are provenance. CI reads only the pinned register, never a drive or the + docs site; a disagreement is reported to the owner, and no tool rewrites either side. [human: owner] +- Read STATE.md before work in a repository; update it in the same PR. [check: check_pr_evidence.py] ## Contracts - Messages, services, actions, schemas, topic names, launch argument names and configuration - IDs are contracts. They change only through a contract change request issue and one PR that - updates the contract package and its consumers together. [template: contract change request] - [decides: software lead] -- New contract proposals stay labelled Proposed until the owner accepts them. [decides: software lead] + IDs change only through a contract change request and one PR that updates the contract + package and its consumers together. [template: contract change request] [human: software lead] +- New contract proposals stay labelled Proposed until the owner accepts them. [human: software lead] ## Tests -- Every behaviour change carries a test that fails when the change is reverted; the PR shows - that failing run. [gate: check_pr_evidence.py] [decides: reviewer] -- A suite that executes zero tests fails. [gate: verify.sh, check_pr_evidence.py] -- skip, xfail and importorskip name a tracking issue on the same line. [gate: verify.sh] +- Every behaviour change carries a test that fails when the change is reverted; the Tests + section shows that failing run. [template: PR Tests section] [human: reviewer, the revert run] +- A suite that executes zero tests fails. [check: verify.sh; check_pr_evidence.py on reported counts] +- skip, xfail and importorskip name a tracking issue on the same line. [check: verify.sh] - SKIP or BLOCKED is not PASS. Fixtures and fake hardware are not simulation, physical - acceptance or release readiness. [template: PR Not verified section] + acceptance or release readiness. [human: reviewer, Not verified section] ## Evidence - Every PR states base SHA, head SHA, exact commands, test counts and a Not verified section. - [gate: check_pr_evidence.py] [template: .github/PULL_REQUEST_TEMPLATE.md] -- A draft becomes ready only when the evidence check passes on the current head. [gate: check_pr_evidence.py] + [check: check_pr_evidence.py, presence only] [human: reviewer, that the commands were run] +- A draft becomes ready only when the evidence check passes on the current head. [human: author; + ruleset required check once installed] ## Dependencies and licences - Nothing is added, removed or upgraded as a side effect. A changed dependency manifest needs - a Dependencies section naming each change, its licence and source. [gate: check_pr_evidence.py] -- File headers and package.xml licence tags match the repository licence map: MIT software and - firmware, CERN-OHL-P-2.0 hardware, CC-BY-4.0 documentation. [decides: repository owner] -- The Integration Gate section lists overlapping open PRs, what existing or upstream work was - reused and what was rejected. Third-party provenance stays intact. [template: PR template] + a Dependencies section naming each change, its licence and source. [check: check_pr_evidence.py] +- Licence headers and package.xml tags match the licence map: MIT software and firmware, + CERN-OHL-P-2.0 hardware, CC-BY-4.0 documentation. [human: repository owner] +- The Integration Gate section lists overlapping PRs, reused existing or upstream work and + what was rejected. Third-party provenance stays intact. [template: PR template] - LICENSE, LICENSING.md, NOTICE and CODEOWNERS change only in a PR whose task names them. - [decides: platform lead] + [human: platform lead] ## Safety - No agent authors or modifies E-stop, brake, contactor, watchdog, motor-enable or - charge-inhibit logic. Agents report the needed change in an issue. [gate: check_pr_evidence.py] -- A change to those paths needs two human reviewers including the platform lead. - [gate: check_pr_evidence.py] -- Functional telemetry, status displays and fixtures are never presented as safety evidence. - [decides: platform lead] -- Automated or untrusted PR jobs never reach motion hardware or secrets. [decides: CI owner] + charge-inhibit logic; agents report the need in an issue. [check: check_pr_evidence.py fails + a safety-path change whose AI disclosure is not None] [human: platform lead, undisclosed use] +- A safety-path change needs two human approvals including the platform lead. The check only + reports "safety path touched, two human approvals required" and that reviewers are requested; + approvals are a ruleset requirement. [human: platform lead and one more maintainer] +- Functional telemetry, watchdogs, status displays and fixtures are never safety evidence. + [check: check_decisions.py wording only] [human: platform lead, hardwired safety-chain test record] +- Automated or untrusted PR jobs never reach motion hardware or secrets. [human: CI owner] ## Publication - Public material (docs/, assets/, README.md, any path containing "public") has no internal - document links, prices, contact data or credentials. [gate: check_public_extract.py] -- It names no private person, customer or partner; the public application name is Use_Case_1. - [decides: docs owner] + document links, prices, contact data or credentials. [check: check_public_extract.py] +- It names no private person, customer or partner; the application name is Use_Case_1. [human: docs owner] ## Agents -- Before any write, state a precondition block: repository, branch, parent SHA, expected - outcome. [template: agent-prompts/] -- Read-only unless the task says otherwise. [template: agent-prompts/] -- Agent PRs stay draft until the work-package owner writes adopt, adapt or reject in the PR - thread. [decides: work-package owner] -- The PR's AI disclosure section names the tool and what it produced. [gate: check_pr_evidence.py] -- Commits carry DCO sign-off with the contributor's own identity; never invent an identity, - exemption or attestation. [gate: DCO check] -- Gate A Teensy/MPU6500 and Gate B STM32/ICM-42688-P stay distinct; Jetson is the 2.0 - compute. [gate: check_decisions.py] Arm vendor SDKs stay behind device packages. [decides: software lead] +- Before any write, state a precondition block: repository (exact full name), branch, parent + SHA, expected outcome. Read-only unless the task says otherwise. [template: agent-prompts/] +- Agent PRs stay draft until the work-package owner writes adopt, adapt or reject in the thread. + [human: work-package owner] +- The PR's AI disclosure section names the tool and what it produced. [check: section present] +- Commits carry DCO sign-off with the contributor's own identity; never invent an identity or + attestation. [human: maintainer; DCO check where installed] +- Gate A Teensy/MPU6500 and Gate B STM32/ICM-42688-P stay distinct; Jetson is the 2.0 compute. + [check: check_decisions.py] Arm vendor SDKs stay behind device packages. [human: software lead] ## Failure - A failed precondition (repository, branch, SHA, access, source) stops the task. Report the command, the error and the next step; never work around it. [template: agent-prompts/] -- Never merge, force-push, change settings, weaken a check or invent a result. [gate: branch protection] +- Never merge, force-push, change settings, weaken a check or invent a result. [human: ruleset] ## Learning - Every agent or process mistake gets an issue labelled harness. [template: harness mistake form] -- The monthly retro turns harness issues into one PR against this block. [decides: software lead] +- The monthly retro turns harness issues into one PR against this block. [human: software lead] # Repository-specific rules: .github -- This repository owns shared policy, decisions.yaml, maintainers.yaml, the checkers under - tools/ and the reusable workflows. agent-rules/SHARED_RULES.md is the canonical block. +- This repository owns shared policy, the decisions register (decisions.yaml), maintainers.yaml, + the checkers under tools/ and the reusable workflows. agent-rules/SHARED_RULES.md is canonical. - Run before every PR: `bash rollout/verify.sh` (unit tests of every checker, zero-test guard), `python3 tools/check_decisions.py --decisions decisions.yaml --maintainers maintainers.yaml --validate-only` and `python3 tools/check_agent_rules.py --canonical agent-rules/SHARED_RULES.md --file AGENTS.md`. - Drift across repositories: `python3 tools/check_agent_rules.py --canonical agent-rules/SHARED_RULES.md --root `. The checker does not fetch repositories; the caller supplies the checkouts. - A change to the shared block bumps its marker version; rollout/README.md lists the order in - which repositories adopt it. [decides: CI owner] + which repositories adopt it. [human: CI owner] - Files under rollout/ are proposals for other repositories; they take effect only when that repository's owner merges them. Do not claim rollout status that has not happened. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b13f7e4..eab2edd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,9 +44,12 @@ are in [AGENTS.md](AGENTS.md). - Add a test that fails without your change. A test run that executes zero tests counts as a failure. If you must skip a test, name the tracking issue on the same line. - Run the repository's verification (`tools/verify.sh`, or the command in its README). -- Decided values (heights, parts, topic names and so on) come from - [decisions.yaml](decisions.yaml). If your change disagrees with it, the check will say so; - raise it in the work package rather than editing around it. +- Approved technical decisions (values, limits, exclusions such as "no lift in 2.0", and + distinctions such as "1700 mm is the assembled-height envelope, not a shoulder height") are + in the register [decisions.yaml](decisions.yaml). Follow it. To change one, open a + **contract change request** naming the entry; the process is "Changing a decision" in + [AGENTS.md](AGENTS.md): the owner updates the source document, then one reviewed PR + updates the register and every affected repository together. - Do not add, remove or upgrade a dependency unless the task asks for it. - Do not change E-stop, brake, contactor, watchdog, motor-enable or charge-inhibit logic unless the platform lead has agreed it in the issue; such changes need two human reviewers. @@ -59,13 +62,18 @@ commands, test counts), dependencies, safety impact, STATE.md, Not verified and ## 5. What the automated checks verify +These checks run in a repository once it has installed them; they block a merge only where +the repository's ruleset requires them ([rollout status](rollout/workflows/SETUP.md)). A text +check proves that a file is consistent with the register, not that a design is mechanically, +electrically or functionally safe; a named reviewer checks that. + | Check | Fails when | |---|---| | repository-quality | governance files missing, merge markers, invalid JSON or XML | -| decisions of record | a changed file states a value that contradicts decisions.yaml | +| decisions register | a changed file contains a listed contradicting phrase, or cites a superseded source | | public extract | docs, assets, README or public files contain internal document links, prices, e-mail addresses, phone numbers or credential-like strings | | shared agent rules | AGENTS.md differs from the organization's shared block | -| PR evidence (one comment, updated on each push) | a template section is empty, SHAs or commands are missing, tests changed without a reported run, a dependency changed without a note, safety files changed without two human reviewers | +| PR evidence (one comment, updated on each push) | a template section is empty, SHAs or commands are missing, tests changed without a reported run, a dependency changed without a note, safety files changed without two human reviewers requested (the two approvals themselves come from the ruleset) | | quality/test | build, lint or tests fail, or zero tests ran | | DCO and contributor agreement | a commit lacks sign-off, or no agreement is on record | @@ -84,7 +92,7 @@ owner writes adopt, adapt or reject in the thread. | workflows, verify.sh, quality gates | CI owner | | manifest, release, installation | release owner | | documentation site | documentation owner | -| safety paths | two humans, including the platform lead, who reviews last | +| safety paths | two human approvals, including the platform lead, who reviews last (ruleset) | A maintainer merges; approval or a green check alone does not accept a contribution. diff --git a/agent-rules/SHARED_RULES.md b/agent-rules/SHARED_RULES.md index 95dfe09..79fbbb9 100644 --- a/agent-rules/SHARED_RULES.md +++ b/agent-rules/SHARED_RULES.md @@ -1,79 +1,87 @@ # OpenAMRobot rules for contributors and agents -Canonical: openAMRobot/.github, agent-rules/SHARED_RULES.md. Copied verbatim into every -repository's AGENTS.md; tools/check_agent_rules.py fails on drift. Each rule names how it is -enforced: [gate: tool], [template: file] or [decides: role]. Roles resolve in maintainers.yaml. +Canonical: openAMRobot/.github, agent-rules/SHARED_RULES.md, copied verbatim into every +repository's AGENTS.md (tools/check_agent_rules.py reports drift). This file holds process; +approved technical values live in openAMRobot/.github decisions.yaml. Labels: [check: tool] +means the tool detects that violation, and only in repositories where its workflow is installed +and required (rollout/README.md); [template: file]; [human: role, evidence] is a reviewer +decision. A text check proves textual consistency, never mechanical, electrical or safety correctness. -## Sources of record -- Decided values live only in openAMRobot/.github decisions.yaml. No file states a value that - contradicts it; kept history carries `decision-allow: `. [gate: check_decisions.py] -- A decision changes in its source document first, then in decisions.yaml and every flagged - file in one PR. [decides: the decision's owner] -- Read STATE.md before work in a repository; update it in the same PR. [gate: check_pr_evidence.py] +## Decisions +- decisions.yaml is the only register of approved values, limits, exclusions and distinctions. + No file states a contradicting value; kept history carries `decision-allow: `. + [check: check_decisions.py, listed patterns only] [human: entry's reviewer, entry's evidence] +- Changing a decision: (1) open a contract change request issue naming the entry; (2) the + owner updates the source document; (3) one reviewed PR updates the register entry, its + supersedes history, its check patterns with a test, and every affected consumer, or links + each consumer PR; (4) the owner approves. [template: contract change request] [human: owner] +- Source documents are provenance. CI reads only the pinned register, never a drive or the + docs site; a disagreement is reported to the owner, and no tool rewrites either side. [human: owner] +- Read STATE.md before work in a repository; update it in the same PR. [check: check_pr_evidence.py] ## Contracts - Messages, services, actions, schemas, topic names, launch argument names and configuration - IDs are contracts. They change only through a contract change request issue and one PR that - updates the contract package and its consumers together. [template: contract change request] - [decides: software lead] -- New contract proposals stay labelled Proposed until the owner accepts them. [decides: software lead] + IDs change only through a contract change request and one PR that updates the contract + package and its consumers together. [template: contract change request] [human: software lead] +- New contract proposals stay labelled Proposed until the owner accepts them. [human: software lead] ## Tests -- Every behaviour change carries a test that fails when the change is reverted; the PR shows - that failing run. [gate: check_pr_evidence.py] [decides: reviewer] -- A suite that executes zero tests fails. [gate: verify.sh, check_pr_evidence.py] -- skip, xfail and importorskip name a tracking issue on the same line. [gate: verify.sh] +- Every behaviour change carries a test that fails when the change is reverted; the Tests + section shows that failing run. [template: PR Tests section] [human: reviewer, the revert run] +- A suite that executes zero tests fails. [check: verify.sh; check_pr_evidence.py on reported counts] +- skip, xfail and importorskip name a tracking issue on the same line. [check: verify.sh] - SKIP or BLOCKED is not PASS. Fixtures and fake hardware are not simulation, physical - acceptance or release readiness. [template: PR Not verified section] + acceptance or release readiness. [human: reviewer, Not verified section] ## Evidence - Every PR states base SHA, head SHA, exact commands, test counts and a Not verified section. - [gate: check_pr_evidence.py] [template: .github/PULL_REQUEST_TEMPLATE.md] -- A draft becomes ready only when the evidence check passes on the current head. [gate: check_pr_evidence.py] + [check: check_pr_evidence.py, presence only] [human: reviewer, that the commands were run] +- A draft becomes ready only when the evidence check passes on the current head. [human: author; + ruleset required check once installed] ## Dependencies and licences - Nothing is added, removed or upgraded as a side effect. A changed dependency manifest needs - a Dependencies section naming each change, its licence and source. [gate: check_pr_evidence.py] -- File headers and package.xml licence tags match the repository licence map: MIT software and - firmware, CERN-OHL-P-2.0 hardware, CC-BY-4.0 documentation. [decides: repository owner] -- The Integration Gate section lists overlapping open PRs, what existing or upstream work was - reused and what was rejected. Third-party provenance stays intact. [template: PR template] + a Dependencies section naming each change, its licence and source. [check: check_pr_evidence.py] +- Licence headers and package.xml tags match the licence map: MIT software and firmware, + CERN-OHL-P-2.0 hardware, CC-BY-4.0 documentation. [human: repository owner] +- The Integration Gate section lists overlapping PRs, reused existing or upstream work and + what was rejected. Third-party provenance stays intact. [template: PR template] - LICENSE, LICENSING.md, NOTICE and CODEOWNERS change only in a PR whose task names them. - [decides: platform lead] + [human: platform lead] ## Safety - No agent authors or modifies E-stop, brake, contactor, watchdog, motor-enable or - charge-inhibit logic. Agents report the needed change in an issue. [gate: check_pr_evidence.py] -- A change to those paths needs two human reviewers including the platform lead. - [gate: check_pr_evidence.py] -- Functional telemetry, status displays and fixtures are never presented as safety evidence. - [decides: platform lead] -- Automated or untrusted PR jobs never reach motion hardware or secrets. [decides: CI owner] + charge-inhibit logic; agents report the need in an issue. [check: check_pr_evidence.py fails + a safety-path change whose AI disclosure is not None] [human: platform lead, undisclosed use] +- A safety-path change needs two human approvals including the platform lead. The check only + reports "safety path touched, two human approvals required" and that reviewers are requested; + approvals are a ruleset requirement. [human: platform lead and one more maintainer] +- Functional telemetry, watchdogs, status displays and fixtures are never safety evidence. + [check: check_decisions.py wording only] [human: platform lead, hardwired safety-chain test record] +- Automated or untrusted PR jobs never reach motion hardware or secrets. [human: CI owner] ## Publication - Public material (docs/, assets/, README.md, any path containing "public") has no internal - document links, prices, contact data or credentials. [gate: check_public_extract.py] -- It names no private person, customer or partner; the public application name is Use_Case_1. - [decides: docs owner] + document links, prices, contact data or credentials. [check: check_public_extract.py] +- It names no private person, customer or partner; the application name is Use_Case_1. [human: docs owner] ## Agents -- Before any write, state a precondition block: repository, branch, parent SHA, expected - outcome. [template: agent-prompts/] -- Read-only unless the task says otherwise. [template: agent-prompts/] -- Agent PRs stay draft until the work-package owner writes adopt, adapt or reject in the PR - thread. [decides: work-package owner] -- The PR's AI disclosure section names the tool and what it produced. [gate: check_pr_evidence.py] -- Commits carry DCO sign-off with the contributor's own identity; never invent an identity, - exemption or attestation. [gate: DCO check] -- Gate A Teensy/MPU6500 and Gate B STM32/ICM-42688-P stay distinct; Jetson is the 2.0 - compute. [gate: check_decisions.py] Arm vendor SDKs stay behind device packages. [decides: software lead] +- Before any write, state a precondition block: repository (exact full name), branch, parent + SHA, expected outcome. Read-only unless the task says otherwise. [template: agent-prompts/] +- Agent PRs stay draft until the work-package owner writes adopt, adapt or reject in the thread. + [human: work-package owner] +- The PR's AI disclosure section names the tool and what it produced. [check: section present] +- Commits carry DCO sign-off with the contributor's own identity; never invent an identity or + attestation. [human: maintainer; DCO check where installed] +- Gate A Teensy/MPU6500 and Gate B STM32/ICM-42688-P stay distinct; Jetson is the 2.0 compute. + [check: check_decisions.py] Arm vendor SDKs stay behind device packages. [human: software lead] ## Failure - A failed precondition (repository, branch, SHA, access, source) stops the task. Report the command, the error and the next step; never work around it. [template: agent-prompts/] -- Never merge, force-push, change settings, weaken a check or invent a result. [gate: branch protection] +- Never merge, force-push, change settings, weaken a check or invent a result. [human: ruleset] ## Learning - Every agent or process mistake gets an issue labelled harness. [template: harness mistake form] -- The monthly retro turns harness issues into one PR against this block. [decides: software lead] +- The monthly retro turns harness issues into one PR against this block. [human: software lead] diff --git a/agent-runs.md b/agent-runs.md index c84d1b8..3270e20 100644 --- a/agent-runs.md +++ b/agent-runs.md @@ -1,20 +1,25 @@ # Agent run log -One row per agent run that produced a report, a PR or a push. The work-package owner records -the outcome in the PR thread (adopted, adapted or rejected); this log copies it. "pending" -means no owner decision is recorded yet. Every mistake row names the harness change that now -catches its class; a mistake without one gets an issue labelled harness. +One row per agent run that produced a report, a PR or a push. This is a public file: it +records sanitized failure categories, never the underlying content (no finding text, quotes, +personal details, internal links or supplier data). Details stay with the owner of the run. -Model and template columns record what the evidence shows (commit trailers, PR text, report -header); "not recorded" means the evidence does not say. +The work-package owner records the outcome in the PR thread (adopted, adapted or rejected); +this log copies it. "pending" means no owner decision is recorded; "not recorded" means the +available evidence does not say. Every mistake category names the harness change that now +addresses it, with its state: (a) implemented and tested in this repository, (b) supplied +under rollout/ and not installed anywhere, (c) human gate. -| Date | Task | Template version | Environment | Model | Outcome | Mistake | Harness change | +| Date | Task | Template version | Environment | Model | Outcome | Mistake category | Harness change (state) | |---|---|---|---|---|---|---|---| -| 2026-09-28 | Independent alignment audit of 13 repositories, plan set and BOM (229 findings, private audit repository) | none (pre-harness) | read-only session; GitHub API limited to openamrobot-docs; Drive links not opened; no ROS build | not recorded | pending | Initial severities too low on four findings; the lead auditor raised BOM-005, BOM-009 and DOC-021 to Blocker and PR-002 to Major | agent-prompts/evaluator-pass.md; decisions.yaml SAFETY-PROCUREMENT and DOCKING-SCOPE make those classes gate failures | -| 2026-09-28 | Docs PRs openamrobot-docs#25 and #26: OpenAMRobot 2.0 design section, HW diagram, general arrangement, F2S page | none (pre-harness) | contributor branch, docs repository | not recorded | adapted: heads redrawn at 1350 mm and aligned with the plan of record after the audit; the design section reached main in 945d78d | Public diagram asset carried internal document links, supplier prices and owner names (DOC-011); decisions shown as recorded before the addendum recorded them (DOC-002); Teensy presented as a bench target (ELE-021) | check_public_extract.py; decisions.yaml with sources and check_decisions.py (MAST-INSTALL-HEIGHT, BASE-CONTROLLER-GATES) | -| 2026-09-28 | Docs PR openamrobot-docs#28: P-03 rev18.2 mast geometry and height envelope | none (pre-harness) | contributor branch, docs repository | not recorded | pending | Cites P-03 revision 18.2, which no repository holds; the audit found no rev18.2 of record | decisions.yaml `sources` requires the evidence for each document; rev18.2 values flagged for owner confirmation | -| 2026-09-28 | README pushes: openamr-upperbody-hw#6, -sw#6, -fw#6 (fixed mast, rev18.2 wording) | none (pre-harness) | contributor branches | OpenAI Codex (per commit message) | pending (open) | none recorded beyond the audit findings they fix (GEO-020, DOC-038) | check_decisions.py LIFT-REMOVED and MAST-* keep the fix from regressing | -| 2026-09-28 | README push: .github#36, organization profile arm and mast wording | none (pre-harness) | contributor branch | Claude Code (per commit trailer) | adopted (merged d1ac6b6) | Profile still lists "adjustable linear lift systems" (GEO-022, DOC-033 partly fixed) | check_decisions.py LIFT-REMOVED reports profile/README.md line 18 | -| 2026-09-17 | CI pushes to openamr-upperbody-hw, -sw, -fw (#4) | none (pre-harness) | contributor branches | OpenAI Codex (per sign-off) | adopted (merged) | DCO sign-off under an invented identity ("OpenAI Codex" with the maintainer's e-mail) | Shared block: sign-off only with the contributor's own identity; push-from-bundle.md identity rule | -| not recorded | Repository-mismatch stop | none (pre-harness) | not recorded | not recorded | not recorded | Not described in the audit report or the repository history available to the session that wrote this log | Row to be completed by the platform lead; the failure rule in every agent prompt now requires the stop and the report | -| 2026-09-29 | This harness: rules, checks, templates and rollout in openAMRobot/.github | agent-prompts v1 (created by this run) | cloud session; read-only clones of product repositories; GitHub API scoped to .github and the audit repository | Claude Code | pending | The task named a private audit repository that does not exist under that name; the session read the same dated folder from the organization's audit repository, read-only, and reported the mismatch instead of stopping. Two reviewer handles could not be resolved from organization evidence | Precondition blocks name repositories by exact full name; maintainers.yaml records unresolved handles as null instead of guessing | +| 2026-09-28 | Read-only alignment audit across repositories and plan documents | none (pre-harness) | read-only session, limited API access | not recorded | pending | Severity under-rated in the first pass and corrected on lead review | evaluator-pass prompt (b); lead review of severities (c) | +| 2026-09-28 | Documentation PRs for the 2.0 design section | none (pre-harness) | contributor branch | not recorded | adapted after review | Internal links, prices and owner names in a public asset | check_public_extract.py (a); ruleset install (b) | +| 2026-09-28 | Documentation PRs for the 2.0 design section | none (pre-harness) | contributor branch | not recorded | adapted after review | Decision presented as recorded before the source recorded it | decisions register with provenance and owner confirmation (a, c) | +| 2026-09-28 | Documentation PR citing a newer decision revision | none (pre-harness) | contributor branch | not recorded | pending | Decision cited from a source revision not held in any repository | register entries marked as needing owner confirmation (c) | +| 2026-09-28 | README alignment pushes in upper-body repositories | none (pre-harness) | contributor branches | not recorded | pending | none recorded | decision patterns keep the change from regressing (a) | +| 2026-09-28 | README alignment push in this repository | none (pre-harness) | contributor branch | not recorded | adopted (merged) | Superseded scope wording left in one line | check_decisions.py reports it on full scan (a) | +| 2026-09-17 | CI pushes in upper-body repositories | none (pre-harness) | contributor branches | not recorded | adopted (merged) | DCO sign-off under an identity that is not the contributor's | shared rule on sign-off identity (c); push-from-bundle prompt (b) | +| not recorded | Agent task against a mis-named repository | none (pre-harness) | not recorded | not recorded | not recorded | Repository mismatch stopped by precondition | failure rule in every agent prompt (b) | +| 2026-09-29 | This harness: rules, register, checks, templates and rollout | agent-prompts v1 (created by this run) | cloud session; read-only clones; API scoped to two repositories | not recorded | pending | Repository name mismatch in the task; worked around read-only and reported instead of stopping | precondition blocks name repositories by exact full name (b) | +| 2026-09-29 | This harness | agent-prompts v1 | same | not recorded | pending | Reviewer handles not resolvable from organization evidence | maintainers.yaml records unresolved handles as null (a); owner fills them (c) | +| 2026-09-29 | This harness | agent-prompts v1 | same | not recorded | pending | Harness document restated a superseded value; caught by its own decisions check before push | check_decisions.py on changed files (a) | diff --git a/rollout/README.md b/rollout/README.md index 9b03acc..70dc4d3 100644 --- a/rollout/README.md +++ b/rollout/README.md @@ -1,21 +1,23 @@ # Rolling out the harness to other repositories Files in this folder are proposals for other repositories. Each takes effect only when that -repository's owner merges it. The organization-owner steps are in +repository's owner merges it, and a check blocks a merge only once the repository's ruleset +requires it. Until a repository completes the steps below, none of its failure classes are +machine-blocked. The organization-owner steps and the state of every check are in [workflows/SETUP.md](workflows/SETUP.md). ## What each repository adopts -| Item | What the repository does | Enforced by | +| Item | What the repository does | Checked by, once installed and required | |---|---|---| | AGENTS.md, CLAUDE.md | Copy the shared block v2 verbatim from `agent-rules/SHARED_RULES.md`, add a short repository-specific section; CLAUDE.md contains only `@AGENTS.md` | drift step in the reusable workflow | | STATE.md | Copy `STATE.md.example`, fill it, keep it current | `check_pr_evidence.py` STATE.md rule | | PR template | Delete the local `.github/PULL_REQUEST_TEMPLATE.md` so the organization template applies, or replace it with a copy that keeps every heading | `check_pr_evidence.py` sections | | verify.sh | Keep an existing `tools/verify.sh`; otherwise enable `verify: true` in the caller (the harness `rollout/verify.sh` runs), with `.openamrobot/verify.env` if the layout needs it | `quality/test` job | -| Reusable workflow caller | Pin `uses:` and `harness_ref` to one harness SHA | reviewer of the caller PR | +| Reusable workflow caller | Pin `uses:` and `harness_ref` to one harness SHA and set `harness_checks: true` | reviewer of the caller PR | | PR assistant | Copy `workflows/pr-assistant.yml` | `quality/pr-evidence` required check | | Docs sync sender | Copy `workflows/docs-sync-caller.yml` (not in openamrobot-docs) | none; failure shows in Actions | -| decisions.yaml | Nothing to copy. Fix flagged lines or mark kept history with `decision-allow: ` | `check_decisions.py` | +| decisions register | Nothing to copy; CI reads the pinned register from the harness. Fix flagged lines or mark kept history with `decision-allow: ` | `check_decisions.py` (text only; the entry's reviewer checks the substance) | All eight product repositories checked on 29 September 2026 carry a local PR template (openamr-platform-fw, openamr-platform-sw, openamrobot-docs, openamrobot-interfaces, @@ -28,18 +30,20 @@ Each step is one PR per repository, opened as a draft by the repository owner or push-from-bundle prompt, and merged by the owner. 1. **This PR merges; the owner completes SETUP.md sections 1 to 4.** -2. **Shared block v2, same day, in the three repositories that already carry v1** - (openamrobot-manifest, openamrobot-manipulation, openamrobot-ui). Until they update, the - drift step fails their pull requests with "shared block is v1, canonical is v2". A caller - pinned to a pre-v2 harness SHA is not affected. -3. **Pin callers** in all repositories (audit CI-001, SW-022). +2. **Shared block v2** in the three repositories that already carry v1 + (openamrobot-manifest, openamrobot-manipulation, openamrobot-ui), before they set + `harness_checks: true`; otherwise the drift step fails their pull requests with + "shared block is v1, canonical is v2". Existing `@main` callers without the input are + unaffected by this PR. +3. **Pin callers and opt in** in all repositories: pinned SHA, `harness_ref`, + `harness_checks: true`. 4. **Pilots, in this order**, each with STATE.md, the organization PR template, the PR assistant and `verify: true`: 1. openamrobot-interfaces: already has `tools/verify.sh`; the job delegates to it. - 2. openamr-platform-sw: first colcon build and test gate (audit CI-005); container + 2. openamr-platform-sw: first colcon build and test gate; container `ros:jazzy-ros-base`. 3. openamrobot-ui: `.openamrobot/verify.env` as in VERIFY.md; the zero-tests rule fails - until real tests replace `--passWithNoTests` (CI-006). Merge the verify.env PR together + until real tests replace `--passWithNoTests`. Merge the verify.env PR together with the first real tests. 4. openamrobot-docs: keep `scripts/check_docs.sh` and the strict MkDocs build via `VERIFY_TEST`; add the docs-sync receiver. @@ -48,9 +52,12 @@ push-from-bundle prompt, and merged by the owner. 6. **openamr-platform-fw, openamr-platform-hw, openamr-upperbody-*, openamrobot-comm**: shared block, STATE.md, PR assistant. `verify: true` only once a build or test exists; a repository with nothing to test declares that in STATE.md instead of passing an empty suite. -7. **Branch protection** per SETUP.md section 6, repository by repository, after its - checks have passed on main once. -8. **Weekly audit** in audits, then **monthly retro** in .github. +7. **Rulesets** per SETUP.md section 6, repository by repository, after its checks have + passed on main once. Only from this step on does a failing check block a merge in that + repository; safety-path approvals come from the ruleset and CODEOWNERS, not from a check. +8. **Weekly audit** in audits, then **monthly retro** in .github. Both are designs until a + first supervised run exercises permissions, credentials, deduplication, failure handling + and the issue lifecycle; the audit never closes an issue, it comments "no longer detected". ## Release-manifest interaction @@ -60,7 +67,7 @@ push-from-bundle prompt, and merged by the owner. - The release manifest should record the harness SHA used for each component's evidence, and each component's `summary.json` from its `quality/test` artifact, so release evidence points at a verification run instead of a claim. -- A change to decisions.yaml can turn a component red without a code change. The release +- A change to the decisions register can turn a component red without a code change. The release owner treats that as a release blocker for the affected component, not as a CI fault. ## CODEOWNERS proposal diff --git a/rollout/VERIFY.md b/rollout/VERIFY.md index 61f2241..fd6761b 100644 --- a/rollout/VERIFY.md +++ b/rollout/VERIFY.md @@ -36,7 +36,7 @@ VERIFY_TEST="cd web && CI=true npm test -- --watchAll=false" ``` With that override the UI's current `--passWithNoTests` suite fails the zero-tests rule -(audit CI-006) until real tests exist. +until real tests exist. ## How the reusable workflow calls it From a78d53c2d3f444fe86a8553dd403a95d9a34524f Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Mon, 28 Sep 2026 23:06:06 +0000 Subject: [PATCH 018/129] pr-evidence: do not count fixture manifests as dependency changes Running the check on this PR's own description reported a fixture package.xml under tests/fixtures as a dependency change. Paths under fixtures/ or testdata/ are now ignored for the Dependencies rule. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- agent-runs.md | 1 + tests/test_check_pr_evidence.py | 3 +++ tools/check_pr_evidence.py | 3 ++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/agent-runs.md b/agent-runs.md index 3270e20..828f05e 100644 --- a/agent-runs.md +++ b/agent-runs.md @@ -23,3 +23,4 @@ under rollout/ and not installed anywhere, (c) human gate. | 2026-09-29 | This harness: rules, register, checks, templates and rollout | agent-prompts v1 (created by this run) | cloud session; read-only clones; API scoped to two repositories | not recorded | pending | Repository name mismatch in the task; worked around read-only and reported instead of stopping | precondition blocks name repositories by exact full name (b) | | 2026-09-29 | This harness | agent-prompts v1 | same | not recorded | pending | Reviewer handles not resolvable from organization evidence | maintainers.yaml records unresolved handles as null (a); owner fills them (c) | | 2026-09-29 | This harness | agent-prompts v1 | same | not recorded | pending | Harness document restated a superseded value; caught by its own decisions check before push | check_decisions.py on changed files (a) | +| 2026-09-29 | This harness | agent-prompts v1 | same | not recorded | pending | Evidence check reported a test fixture as a dependency change (false positive), found by running the check on this PR's own description | fixture paths excluded, with a test (a) | diff --git a/tests/test_check_pr_evidence.py b/tests/test_check_pr_evidence.py index 57df904..2850bd7 100644 --- a/tests/test_check_pr_evidence.py +++ b/tests/test_check_pr_evidence.py @@ -133,6 +133,9 @@ def test_manifest_change_needs_dependencies_section(self): body = BODY.replace("## Dependencies\nNone", "## Dependencies\nAdded imu_filter_madgwick (BSD-3-Clause, ROS index)") self.assertEqual(evaluate(body, changed=["ros2/pkg/package.xml"])[0], []) + def test_fixture_manifests_are_not_dependencies(self): + self.assertEqual(evaluate(changed=["tests/fixtures/repo/package.xml", "pkg/testdata/package.json"])[0], []) + def test_state_md_must_be_updated_or_explained(self): self.assertIn("STATE.md exists but is not updated; update it or write 'no change' with a reason", evaluate(has_state=True)[0]) diff --git a/tools/check_pr_evidence.py b/tools/check_pr_evidence.py index 552b986..e8fdd66 100644 --- a/tools/check_pr_evidence.py +++ b/tools/check_pr_evidence.py @@ -114,7 +114,8 @@ def evaluate(pr, changed, maintainers, reviews=(), has_state=False): else: notes.append(f"Test files changed: {len(tests_changed)}; reported run counts: {counts}") - deps = sorted(p for p in changed if any(fnmatch.fnmatch(p.rsplit("/", 1)[-1], g) for g in DEPENDENCY_FILES)) + deps = sorted(p for p in changed if any(fnmatch.fnmatch(p.rsplit("/", 1)[-1], g) for g in DEPENDENCY_FILES) + and not re.search(r"(^|/)(fixtures|testdata)/", p)) if deps: section = find(secs, "Dependencies") or "" if not section or NONE.match(section): From 4fb87dc50eb30cdfa67c7fe1e72e7f46dd7dec44 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Mon, 28 Sep 2026 23:27:03 +0000 Subject: [PATCH 019/129] governance: apply platform-lead decisions on BOM, pricing and handles - decisions.yaml: P-03 rev18.2 is the addendum in force. BOM-ISSUE-IN-FORCE is recorded as Issue 7 (P-03 rev18.2 item 7), superseding Issue 6 (P-03 rev18.1 line 7) and B-01, with checks for Issue 6 or B-01 presented as canonical and for citations of the superseded line. - ROADMAP.md: the 2.0 compute is the Jetson Orin NX on the reComputer Robotics J401; the Raspberry Pi is legacy, Gate A only. - profile/README.md: fixed mast, lift deferred to OpenAMRobot 3.0; the imprint uses the organization contact address. - public-extract-allowlist.yaml: documented entries for the public sponsorship tiers in README.md and the robot prices in the profile, scoped to this repository and those files. - maintainers.yaml: ci-owner, release-owner and docs-owner handles left empty, filled by the organization owner. - Tests for the BOM entry and the pricing entries; agent-runs.md and SETUP.md updated to match. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- ROADMAP.md | 10 ++++---- agent-runs.md | 4 ++-- decisions.yaml | 37 ++++++++++++++++-------------- maintainers.yaml | 11 +++++---- profile/README.md | 4 ++-- public-extract-allowlist.yaml | 14 +++++++++++ rollout/workflows/SETUP.md | 2 +- tests/test_check_decisions.py | 5 ++++ tests/test_check_public_extract.py | 8 +++++++ 9 files changed, 63 insertions(+), 32 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3ea9acd..a2357d0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,18 +111,18 @@ Vision - Encoders - Safety I/O -## Mid-Level Compute -- Raspberry Pi 5 +## Main Compute (OpenAMRobot 2.0) +- NVIDIA Jetson Orin NX on the reComputer Robotics J401 carrier - Navigation - SLAM - ROS 2 System Management - -## High-Level AI Compute -- NVIDIA Jetson Orin / Orin NX - Perception - Manipulation - VLA / Policy Inference +The Raspberry Pi mid-level computer is legacy and remains only in the Gate A test +configuration of the existing robot. + ## Wearable Edge Compute - RK3588-based Modules (optional) - Sensor Fusion diff --git a/agent-runs.md b/agent-runs.md index 828f05e..52619bd 100644 --- a/agent-runs.md +++ b/agent-runs.md @@ -15,12 +15,12 @@ under rollout/ and not installed anywhere, (c) human gate. | 2026-09-28 | Read-only alignment audit across repositories and plan documents | none (pre-harness) | read-only session, limited API access | not recorded | pending | Severity under-rated in the first pass and corrected on lead review | evaluator-pass prompt (b); lead review of severities (c) | | 2026-09-28 | Documentation PRs for the 2.0 design section | none (pre-harness) | contributor branch | not recorded | adapted after review | Internal links, prices and owner names in a public asset | check_public_extract.py (a); ruleset install (b) | | 2026-09-28 | Documentation PRs for the 2.0 design section | none (pre-harness) | contributor branch | not recorded | adapted after review | Decision presented as recorded before the source recorded it | decisions register with provenance and owner confirmation (a, c) | -| 2026-09-28 | Documentation PR citing a newer decision revision | none (pre-harness) | contributor branch | not recorded | pending | Decision cited from a source revision not held in any repository | register entries marked as needing owner confirmation (c) | +| 2026-09-28 | Documentation PR citing a newer decision revision | none (pre-harness) | contributor branch | not recorded | pending | Decision cited from a source revision not held in any repository | owner confirmed the revision in force; register cites it by item (c) | | 2026-09-28 | README alignment pushes in upper-body repositories | none (pre-harness) | contributor branches | not recorded | pending | none recorded | decision patterns keep the change from regressing (a) | | 2026-09-28 | README alignment push in this repository | none (pre-harness) | contributor branch | not recorded | adopted (merged) | Superseded scope wording left in one line | check_decisions.py reports it on full scan (a) | | 2026-09-17 | CI pushes in upper-body repositories | none (pre-harness) | contributor branches | not recorded | adopted (merged) | DCO sign-off under an identity that is not the contributor's | shared rule on sign-off identity (c); push-from-bundle prompt (b) | | not recorded | Agent task against a mis-named repository | none (pre-harness) | not recorded | not recorded | not recorded | Repository mismatch stopped by precondition | failure rule in every agent prompt (b) | | 2026-09-29 | This harness: rules, register, checks, templates and rollout | agent-prompts v1 (created by this run) | cloud session; read-only clones; API scoped to two repositories | not recorded | pending | Repository name mismatch in the task; worked around read-only and reported instead of stopping | precondition blocks name repositories by exact full name (b) | -| 2026-09-29 | This harness | agent-prompts v1 | same | not recorded | pending | Reviewer handles not resolvable from organization evidence | maintainers.yaml records unresolved handles as null (a); owner fills them (c) | +| 2026-09-29 | This harness | agent-prompts v1 | same | not recorded | pending | Reviewer handles not resolvable from organization evidence | maintainers.yaml leaves unverified handles empty (a); organization owner fills them (c) | | 2026-09-29 | This harness | agent-prompts v1 | same | not recorded | pending | Harness document restated a superseded value; caught by its own decisions check before push | check_decisions.py on changed files (a) | | 2026-09-29 | This harness | agent-prompts v1 | same | not recorded | pending | Evidence check reported a test fixture as a dependency change (false positive), found by running the check on this PR's own description | fixture paths excluded, with a test (a) | diff --git a/decisions.yaml b/decisions.yaml index 134f868..fef94f1 100644 --- a/decisions.yaml +++ b/decisions.yaml @@ -44,11 +44,12 @@ schema_version: 1 sources: P-03-rev18.2: - title: P-03 Decision Addendum, revision 18.2, 28 September 2026 (seed evidence) + title: P-03 Decision Addendum, revision 18.2, 28 September 2026 (the addendum in force) evidence: >- - Not held in any repository. Values below that cite it were taken from its - citation on the documentation site (openamrobot-docs main e0f2aac, - docs/reference/openamrobot-2/index.md lines 30-39) and need owner confirmation. + Not held in any repository. Confirmed by the platform lead on 29 September 2026 + as the addendum in force. Values that cite it were seeded from its citation on the + documentation site (openamrobot-docs main e0f2aac, + docs/reference/openamrobot-2/index.md lines 30-39) and from that confirmation. P-03-rev18.1: title: P-03 Decision Addendum, revision 18.1 evidence: plan-set document, not held in any repository; cited by item and line @@ -56,8 +57,8 @@ sources: title: P-00 Master Coordination Plan, revision 18.1 evidence: plan-set document, not held in any repository; cited by text line BOM-Issue-7: - title: OpenAMRobot 2.0 Detailed Hardware BOM, Issue 7, 27 September 2026 (seed evidence) - evidence: working BOM, not held in any repository; its issue status is an open decision + title: OpenAMRobot 2.0 Detailed Hardware BOM, Issue 7, 27 September 2026 (canonical per P-03 rev18.2 item 7) + evidence: not held in any repository; provenance only, CI never reads it I8-WP: title: I8 base-controller status work package evidence: plan-set work package, not held in any repository @@ -76,26 +77,28 @@ decisions: - id: BOM-ISSUE-IN-FORCE title: Canonical hardware BOM issue for OpenAMRobot 2.0 kind: value - status: open - value: Issue 6, or an explicitly approved successor + status: recorded + value: OpenAMRobot 2.0 Detailed Hardware BOM, Issue 7 unit: none - date: null - source: {document: P-03-rev18.1, item: line 7} + date: 2026-09-28 + source: {document: P-03-rev18.2, item: item 7} supersedes: + - value: Issue 6, or an explicitly approved successor + source: P-03-rev18.1 line 7 + citation: '(?PP-03[^\n]{0,30}(?:rev(?:ision)?\.?\s*)?18\.1[^\n]{0,30}line\s*7)' - {value: B-01 development BOM and evidence register rev18, source: P-03-rev18.1 line 7} applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.html", "**/*.yaml", "**/*.yml"]} check: + - pattern: '(?P(?:canonical|in force|of record)[^\n]{0,40}\bIssue\s*6\b|\bIssue\s*6\b[^\n]{0,40}\b(?:canonical|in force|of record))' + unless: 'supersed|replaced|previous|earlier' + message: BOM Issue 7 is the canonical hardware BOM (P-03 rev18.2 item 7) - pattern: '(?PB-01[^\n]{0,60}\b(?:canonical|current))' unless: 'supersed' message: B-01 is superseded verification: - machine: none while open - human: {reviewer: platform-lead, evidence: the P-03 item that names the BOM issue in force} + machine: Issue 6 or B-01 presented as the canonical BOM; citations of P-03 rev18.1 line 7 + human: {reviewer: platform-lead, evidence: the BOM file header and issue number in the released BOM} owner: platform-lead - note: >- - Open. The plan of record names Issue 6; the working BOM (Issue 7) and the - documentation use Issue 7; no recorded approval of Issue 7 as successor - was available. Set status to recorded once P-03 names the issue in force. - id: MAST-INSTALL-HEIGHT title: Shoulder-axis installation height of the fixed mast @@ -222,7 +225,7 @@ decisions: machine: a different battery centre position in text human: {reviewer: platform-lead, evidence: general arrangement and mass model} owner: platform-lead - note: The rev18.1 plan set did not record a placement; this value rests on the rev18.2 citation. + note: The rev18.1 plan set did not record a placement; P-03 rev18.2 records it. - id: SPEED-CEILING title: 1.5 m/s is a command ceiling, not an operating speed diff --git a/maintainers.yaml b/maintainers.yaml index 48a6b94..7cd726c 100644 --- a/maintainers.yaml +++ b/maintainers.yaml @@ -1,6 +1,7 @@ # Maintainers map for automation. Handles only; no names or contact data. -# A role with handle null has no verified GitHub handle yet. Automation that -# needs that role reports "unassigned" instead of guessing. +# An empty handle means no GitHub handle is recorded yet; the organization owner +# fills it once the person has confirmed their account and joined the +# organization. Automation that needs that role names the role and mentions nobody. # Changes to this file need review by the platform lead (see CODEOWNERS). schema_version: 1 @@ -12,13 +13,13 @@ roles: handle: panthera-momagdii covers: robot software, AI, interfaces, agent rules ci-owner: - handle: null + handle: # filled by the organization owner covers: CI/CD, reusable workflows, verify.sh, quality gates release-owner: - handle: KARTHIKEYAN124 + handle: # filled by the organization owner covers: release, installation, manifest docs-owner: - handle: null + handle: # filled by the organization owner covers: documentation site, public extracts # Owner of audit findings by ID prefix, used by the weekly alignment audit. diff --git a/profile/README.md b/profile/README.md index 25c51d0..36c1f54 100644 --- a/profile/README.md +++ b/profile/README.md @@ -15,7 +15,7 @@ OpenAMRobot combines: - autonomous mobile robotics - dual-arm manipulation -- adjustable linear lift systems +- a fixed mast for the arms (the linear lift is deferred to OpenAMRobot 3.0) - AI-based perception - wearable embodied AI data collection - ROS 2 software infrastructure @@ -489,4 +489,4 @@ Contributor attribution and legally non-waivable authorship or moral rights rema See the canonical [IP Policy](https://github.com/openAMRobot/.github/blob/main/IP_POLICY.md), [Contribution Guide](https://github.com/openAMRobot/.github/blob/main/CONTRIBUTING.md), and [Contributor Agreement Process](https://github.com/openAMRobot/.github/blob/main/CLA.md). -**Botshare LTD** · HE479056 · Chrysanthou Mylona 1, Panayides Building, Office 1, 3030 Limassol, Cyprus · alex@botshare.ai · https://botshare.ai +**Botshare LTD** · HE479056 · Chrysanthou Mylona 1, Panayides Building, Office 1, 3030 Limassol, Cyprus · info@botshare.ai · https://botshare.ai diff --git a/public-extract-allowlist.yaml b/public-extract-allowlist.yaml index af79431..24c8ce7 100644 --- a/public-extract-allowlist.yaml +++ b/public-extract-allowlist.yaml @@ -25,6 +25,20 @@ allow: Licence and copyright notices of third-party code must keep their author contact to preserve provenance (THIRD_PARTY_POLICY.md). Only lines that are such notices qualify. + - rule: price + match: '(?:from\s+)?€\d[\d,.]*(?:/mo)?' + paths: ["README.md"] + repositories: [".github"] + reason: >- + Public pricing: the sponsorship tiers on the organization governance README. + Approved as published pricing by the platform lead on 29 September 2026. + - rule: price + match: '€\d[\d,.]*(?:/mo)?' + paths: ["profile/README.md"] + repositories: [".github"] + reason: >- + Public pricing: the robot offerings and sponsorship tiers on the organization + profile. Approved as published pricing by the platform lead on 29 September 2026. - rule: credential match: '.*(?:your|example|placeholder|changeme|xxxx|<[^>]*>).*' reason: Placeholder values in setup instructions, not secrets. diff --git a/rollout/workflows/SETUP.md b/rollout/workflows/SETUP.md index 14357f0..0586558 100644 --- a/rollout/workflows/SETUP.md +++ b/rollout/workflows/SETUP.md @@ -126,6 +126,6 @@ of the Engineering Quality Standard. ## 7. Maintainers map -Fill the `null` handles in `maintainers.yaml` (ci-owner, docs-owner) once the people confirm +Fill the empty handles in `maintainers.yaml` (ci-owner, release-owner, docs-owner) once the people confirm their GitHub accounts and join the organization. Until then, automation names the role and mentions nobody. diff --git a/tests/test_check_decisions.py b/tests/test_check_decisions.py index 7f234af..2c5735a 100644 --- a/tests/test_check_decisions.py +++ b/tests/test_check_decisions.py @@ -234,6 +234,11 @@ def test_estop_recommendation(self): self.assertEqual(self.ids("docs/a.md", "An uncertified button is fine for prototypes.\n", "x"), ["SAFETY-PROCUREMENT"]) + def test_bom_issue_in_force(self): + self.assertEqual(self.ids("docs/a.md", "The canonical BOM is Issue 6.\n", "x"), ["BOM-ISSUE-IN-FORCE"]) + self.assertEqual(self.ids("docs/a.md", "BOM per P-03 rev18.1 line 7.\n", "x"), ["BOM-ISSUE-IN-FORCE"]) + self.assertEqual(self.ids("docs/a.md", "Issue 7 is canonical; Issue 6 is superseded.\n", "x"), []) + def test_legacy_label_exempts_compute(self): self.assertEqual(self.ids("README.md", "Legacy build: Raspberry Pi 5.\n", "openamr-platform-hw"), []) self.assertEqual(self.ids("README.md", "Compute: Raspberry Pi 5.\n", "openamr-platform-hw"), ["COMPUTE"]) diff --git a/tests/test_check_public_extract.py b/tests/test_check_public_extract.py index c5edc33..b8cc4bc 100644 --- a/tests/test_check_public_extract.py +++ b/tests/test_check_public_extract.py @@ -92,6 +92,14 @@ def test_organization_allowlist_admits_only_notice_lines(self): self.assertEqual(self.hits(f"Copyright (c) 2014 A. Writer , MIT License\n", "README.md", allow=allow), []) self.assertEqual(len(self.hits(f"Write to writer{AT}lab.org for a quote.\n", "README.md", allow=allow)), 1) + def test_public_pricing_is_allowed_only_where_approved(self): + allow = pe.load_allowlist(ROOT / "public-extract-allowlist.yaml") + row = "| First Mover - \u20ac5 | tier |\n" + self.assertEqual(self.hits(row, "README.md", allow=allow, repository=".github"), []) + self.assertEqual(self.hits(row, "profile/README.md", allow=allow, repository=".github"), []) + self.assertEqual(len(self.hits(row, "README.md", allow=allow, repository="openamrobot-ui")), 1) + self.assertEqual(len(self.hits(row, "docs/page.md", allow=allow, repository=".github")), 1) + def test_handles_are_not_emails(self): self.assertEqual(self.hits("Reviewed by @BotshareAI and @panthera-momagdii.\n"), []) From 2b4fd9c9af0f4ec8b3772617ad6f4fb9287e3594 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Mon, 28 Sep 2026 23:34:03 +0000 Subject: [PATCH 020/129] maintainers: record the release-owner handle Sets release-owner to KARTHIKEYAN124 as instructed by the platform lead. ci-owner and docs-owner stay empty until their handles are supplied. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- maintainers.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maintainers.yaml b/maintainers.yaml index 7cd726c..e2bad69 100644 --- a/maintainers.yaml +++ b/maintainers.yaml @@ -16,7 +16,7 @@ roles: handle: # filled by the organization owner covers: CI/CD, reusable workflows, verify.sh, quality gates release-owner: - handle: # filled by the organization owner + handle: KARTHIKEYAN124 covers: release, installation, manifest docs-owner: handle: # filled by the organization owner From 5f46415fef9a1dc33e080712190a07034a9c7b72 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Tue, 29 Sep 2026 12:15:55 +0000 Subject: [PATCH 021/129] ci: fix shellcheck SC2209 in verify.sh and install shellcheck in quality/test quality/test failed in the lint stage of rollout/verify.sh: the runner image ships shellcheck, which flagged the unquoted assignment stage=test (SC2209). The local session had no shellcheck, so the stage was skipped there. Quote the assignment and install shellcheck in the job when missing so the lint stage runs the same way everywhere. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- .github/workflows/repository-quality.yml | 2 ++ rollout/verify.sh | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/repository-quality.yml b/.github/workflows/repository-quality.yml index bab5fc0..28a1349 100644 --- a/.github/workflows/repository-quality.yml +++ b/.github/workflows/repository-quality.yml @@ -29,6 +29,8 @@ jobs: run: | # verify.sh runs tests without user site-packages; use the system package. python3 -c 'import yaml' 2>/dev/null || { sudo apt-get update && sudo apt-get install -y python3-yaml; } + # The lint stage runs shellcheck when present; install it so the result does not depend on the image. + command -v shellcheck >/dev/null || { sudo apt-get update && sudo apt-get install -y shellcheck; } VERIFY_PATH="$PATH" bash rollout/verify.sh - name: Validate decisions.yaml run: python3 tools/check_decisions.py --decisions decisions.yaml --maintainers maintainers.yaml --validate-only diff --git a/rollout/verify.sh b/rollout/verify.sh index 7e9a05d..e6e91d0 100755 --- a/rollout/verify.sh +++ b/rollout/verify.sh @@ -133,7 +133,7 @@ if [ -n "$unmarked" ]; then fi pass -stage=test +stage="test" log="$run/test.log" if [ -n "${VERIFY_TEST:-}" ]; then clean_bash -c "cd '$root' && $VERIFY_TEST" 2>&1 | tee "$log" elif $ros; then From 0b4f3322d1bf95838dcba77b645843b3e36ce82d Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Tue, 29 Sep 2026 12:17:21 +0000 Subject: [PATCH 022/129] verify.sh: apply the zero-tests rule before the test command's exit status On the runner (Python 3.12+) unittest exits 5 when no tests run, and the test stage aborted under set -e before the zero-tests check, so an empty suite failed as "FAIL: test (exit 5)" instead of "zero tests executed" and test_zero_tests_fail failed. The stage now records the command's status, applies the zero-tests rule first, then fails on any non-zero status. Adds a test that a failing test fails the stage. Checked with Python 3.11, 3.12 and 3.13. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- rollout/verify.sh | 10 ++++++++++ tests/test_verify_sh.py | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/rollout/verify.sh b/rollout/verify.sh index e6e91d0..a553f90 100755 --- a/rollout/verify.sh +++ b/rollout/verify.sh @@ -135,6 +135,10 @@ pass stage="test" log="$run/test.log" +# Run the suite without aborting on its exit status: the zero-tests rule is +# checked first (Python 3.12+ unittest exits 5 on "NO TESTS RAN"), then any +# non-zero status fails the stage. +set +e if [ -n "${VERIFY_TEST:-}" ]; then clean_bash -c "cd '$root' && $VERIFY_TEST" 2>&1 | tee "$log" elif $ros; then clean_bash -c "source /opt/ros/$distro/setup.bash && cd '$run/ws' && colcon test --event-handlers console_direct+ && colcon test-result --verbose" 2>&1 | tee "$log" @@ -144,6 +148,8 @@ elif [ -f "$root/pyproject.toml" ] && python3 -c 'import pytest' 2>/dev/null; th else clean_bash -c "cd '$root' && python3 -m unittest discover -s tests -v" 2>&1 | tee "$log" fi +test_status=${PIPESTATUS[0]} +set -e read -r tests_total tests_skipped < <(python3 - "$log" <<'PY' import re, sys text = open(sys.argv[1], encoding="utf-8", errors="replace").read() @@ -170,6 +176,10 @@ if [ "$((tests_total - tests_skipped))" -le 0 ]; then echo "FAIL: zero tests executed; an empty or fully skipped suite is not evidence" exit 1 fi +if [ "$test_status" -ne 0 ]; then + echo "FAIL: test command exited with status $test_status" + exit "$test_status" +fi pass stage=evidence diff --git a/tests/test_verify_sh.py b/tests/test_verify_sh.py index dac959a..a9bf559 100644 --- a/tests/test_verify_sh.py +++ b/tests/test_verify_sh.py @@ -47,6 +47,13 @@ def test_zero_tests_fail(self): self.assertIn("zero tests executed", out) self.assertEqual(summary["failed_stage"], "test") + def test_failing_test_fails(self): + body = PASSING.replace("self.assertTrue(True)", "self.fail('deliberate')") + code, out, summary = self.check({"tests/test_a.py": body}) + self.assertNotEqual(code, 0) + self.assertIn("test command exited with status", out) + self.assertEqual(summary["failed_stage"], "test") + def test_fully_skipped_suite_fails(self): body = PASSING.replace(" def test_one", " @unittest.skip('flaky, see #12')\n def test_one") code, out, _ = self.check({"tests/test_a.py": body}) From ac950672e7019391d39a06472ab2628ae8404b1b Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Tue, 29 Sep 2026 17:25:46 +0000 Subject: [PATCH 023/129] pr-assistant: evidence rules see deleted and renamed files The workflow built changed.txt with select(.status != "removed"), so a PR that only deleted a dependency manifest or a safety-path file never reached check_pr_evidence.py. It now writes two lists from the PR files API: changed_all.txt (every filename, including removed files and the previous name of a rename) for the evidence, safety-path and dependency rules, and changed_existing.txt (files present at the PR head) for the decisions scan only. Regression tests: deleting a safety-path file and deleting a dependency manifest are both flagged, and a test runs the workflow's own jq commands on a sample payload and checks which list each checker gets. The wiring test fails against the previous workflow. Reported by the software lead in review. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- rollout/workflows/pr-assistant.yml | 13 ++++++--- tests/test_check_pr_evidence.py | 44 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/rollout/workflows/pr-assistant.yml b/rollout/workflows/pr-assistant.yml index f064ef4..901c22b 100644 --- a/rollout/workflows/pr-assistant.yml +++ b/rollout/workflows/pr-assistant.yml @@ -54,16 +54,21 @@ jobs: run: | set -euo pipefail python3 -c 'import yaml' 2>/dev/null || python3 -m pip install --user 'PyYAML==6.0.2' - gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/files" --paginate --jq '.[] | select(.status != "removed") | .filename' > changed.txt + gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/files" --paginate > files.json + # Every path the PR touches, including removed files and the old name of a + # rename: the evidence, safety-path and dependency rules must see deletions. + jq -r '.[] | .filename, (.previous_filename // empty)' files.json | sort -u > changed_all.txt + # Only files present at the PR head: the decisions scan reads their content. + jq -r '.[] | select(.status != "removed") | .filename' files.json | sort -u > changed_existing.txt gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/reviews" --paginate > reviews.json - wc -l < changed.txt + echo "changed (all): $(wc -l < changed_all.txt); present at head: $(wc -l < changed_existing.txt)" - name: Decisions of record on the diff run: | set +e python3 harness/tools/check_decisions.py --decisions harness/decisions.yaml \ --maintainers harness/maintainers.yaml --root pr-head \ - --repository "${{ github.event.repository.name }}" --changed-files changed.txt > decisions.txt + --repository "${{ github.event.repository.name }}" --changed-files changed_existing.txt > decisions.txt echo "check_decisions exit $?" cat decisions.txt @@ -72,6 +77,6 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: | python3 harness/tools/check_pr_evidence.py --event "$GITHUB_EVENT_PATH" \ - --changed-files changed.txt --maintainers harness/maintainers.yaml \ + --changed-files changed_all.txt --maintainers harness/maintainers.yaml \ --reviews reviews.json --root pr-head --decisions-report decisions.txt \ --output "$GITHUB_STEP_SUMMARY" --post diff --git a/tests/test_check_pr_evidence.py b/tests/test_check_pr_evidence.py index 2850bd7..cd7865e 100644 --- a/tests/test_check_pr_evidence.py +++ b/tests/test_check_pr_evidence.py @@ -2,6 +2,8 @@ import contextlib import io import json +import re +import subprocess import sys import tempfile import unittest @@ -189,6 +191,48 @@ def test_long_reports_are_truncated(self): self.assertEqual(failures[-1], "... and 5 more decision contradictions") +class DeletedFiles(unittest.TestCase): + """A PR that only deletes files must still reach the evidence rules.""" + + def test_deleting_a_safety_path_file_is_flagged(self): + failures, _, notes = evaluate(changed=["firmware/src/estop_monitor.cpp"]) + self.assertIn("Safety path touched, two human approvals required: only 0 human reviewer(s) requested", + failures) + self.assertTrue(any(n.startswith("Safety path touched") for n in notes)) + + def test_deleting_a_dependency_manifest_is_flagged(self): + failures = evaluate(changed=["ros2/pkg/package.xml"])[0] + self.assertTrue(any(f.startswith("Dependency manifests changed (ros2/pkg/package.xml)") for f in failures)) + + def test_pr_assistant_passes_deleted_files_to_the_evidence_checker(self): + workflow = yaml.safe_load((ROOT / "rollout" / "workflows" / "pr-assistant.yml").read_text(encoding="utf-8")) + steps = {s.get("name"): s.get("run", "") for s in workflow["jobs"]["evidence"]["steps"]} + collect = steps["Collect changed files and reviews"] + jq_all = re.search(r"jq -r '([^']+)' files.json \| sort -u > changed_all.txt", collect).group(1) + jq_existing = re.search(r"jq -r '([^']+)' files.json \| sort -u > changed_existing.txt", collect).group(1) + files = [ + {"filename": "firmware/src/estop_monitor.cpp", "status": "removed"}, + {"filename": "ros2/pkg/package.xml", "status": "removed"}, + {"filename": "fw/brake_ctrl_v2.c", "previous_filename": "fw/brake_ctrl.c", "status": "renamed"}, + {"filename": "README.md", "status": "modified"}, + ] + + def run_jq(expr): + out = subprocess.run(["jq", "-r", expr], input=json.dumps(files), capture_output=True, + text=True, check=True).stdout + return sorted(set(out.split())) + + changed_all, existing = run_jq(jq_all), run_jq(jq_existing) + self.assertEqual(changed_all, ["README.md", "firmware/src/estop_monitor.cpp", "fw/brake_ctrl.c", + "fw/brake_ctrl_v2.c", "ros2/pkg/package.xml"]) + self.assertEqual(existing, ["README.md", "fw/brake_ctrl_v2.c"]) + self.assertIn("--changed-files changed_all.txt", steps["Evidence check and summary comment"]) + self.assertIn("--changed-files changed_existing.txt", steps["Decisions of record on the diff"]) + failures = evaluate(changed=changed_all)[0] + self.assertTrue(any(f.startswith("Safety path touched") for f in failures)) + self.assertTrue(any(f.startswith("Dependency manifests changed") for f in failures)) + + class Comment(unittest.TestCase): def test_render_has_marker_and_status(self): text = ev.render(["x"], [], [], pr()) From a49c513662c3ee8695a9a1e2088f1213e090dd6d Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Tue, 29 Sep 2026 17:27:23 +0000 Subject: [PATCH 024/129] pr-evidence: fail closed when the decisions checker errors The decisions step ran check_decisions.py under set +e, kept only stdout and continued; check_pr_evidence.py failed only on CONTRADICTION or INVALID decisions file lines, so a crash, an empty output or an unexpected exit code produced PASS. The workflow now records the exit status in decisions-status.json and captures stdout and stderr. check_pr_evidence.py takes --decisions-status and accepts only the two documented outcomes: exit 0 with a clean result line, and exit 1 with CONTRADICTION lines matching the reported count. Everything else (traceback, exit 2, any other code, empty output, a missing or unreadable status or report file) is a checker error: the summary comment says CHECKER ERROR and the check exits 1. Regression tests: a real check_decisions.py exception, a synthetic traceback, non-zero exit with empty output, exit 0 without a result line, exit 2, mismatched counts, missing status file, missing report file, and the workflow's own decisions step run with a crashing checker. They fail against the previous checker, which returned PASS for a crash and for empty output. Reported by the software lead in review. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- rollout/workflows/pr-assistant.yml | 12 ++- tests/test_check_pr_evidence.py | 115 +++++++++++++++++++++++++++++ tools/check_pr_evidence.py | 75 ++++++++++++++++--- 3 files changed, 190 insertions(+), 12 deletions(-) diff --git a/rollout/workflows/pr-assistant.yml b/rollout/workflows/pr-assistant.yml index 901c22b..3d68a3c 100644 --- a/rollout/workflows/pr-assistant.yml +++ b/rollout/workflows/pr-assistant.yml @@ -64,12 +64,19 @@ jobs: echo "changed (all): $(wc -l < changed_all.txt); present at head: $(wc -l < changed_existing.txt)" - name: Decisions of record on the diff + env: + REPOSITORY: ${{ github.event.repository.name }} run: | + # Record the exit status explicitly; the evidence step fails closed on + # any outcome other than 0 (clean) or 1 (contradictions reported). set +e python3 harness/tools/check_decisions.py --decisions harness/decisions.yaml \ --maintainers harness/maintainers.yaml --root pr-head \ - --repository "${{ github.event.repository.name }}" --changed-files changed_existing.txt > decisions.txt - echo "check_decisions exit $?" + --repository "$REPOSITORY" --changed-files changed_existing.txt > decisions.txt 2>&1 + status=$? + set -e + printf '{"exit_code": %d}\n' "$status" > decisions-status.json + echo "check_decisions exit $status" cat decisions.txt - name: Evidence check and summary comment @@ -79,4 +86,5 @@ jobs: python3 harness/tools/check_pr_evidence.py --event "$GITHUB_EVENT_PATH" \ --changed-files changed_all.txt --maintainers harness/maintainers.yaml \ --reviews reviews.json --root pr-head --decisions-report decisions.txt \ + --decisions-status decisions-status.json \ --output "$GITHUB_STEP_SUMMARY" --post diff --git a/tests/test_check_pr_evidence.py b/tests/test_check_pr_evidence.py index cd7865e..d72ee96 100644 --- a/tests/test_check_pr_evidence.py +++ b/tests/test_check_pr_evidence.py @@ -2,6 +2,7 @@ import contextlib import io import json +import os import re import subprocess import sys @@ -233,7 +234,121 @@ def run_jq(expr): self.assertTrue(any(f.startswith("Dependency manifests changed") for f in failures)) +CLEAN = "decisions: 23 loaded\nresult: 0 contradiction(s), 0 allowed, scope 3 changed file(s)\n" +TWO = ("decisions: 23 loaded\n" + "CONTRADICTION docs/a.md:4: MAST-INSTALL-HEIGHT found 'mast_1400', decided '1350 mm' (P-03)\n" + "CONTRADICTION docs/b.md:9: COMPUTE found 'Raspberry Pi 5', decided 'Jetson' (P-00)\n" + "result: 2 contradiction(s), 0 allowed, scope 2 changed file(s)\n") + + +class DecisionCheckerFailsClosed(unittest.TestCase): + """Any decisions-check outcome other than the two documented ones is a checker error.""" + + def test_documented_outcomes(self): + self.assertEqual(ev.decision_verdict(CLEAN, {"exit_code": 0}), ([], [])) + failures, errors = ev.decision_verdict(TWO, {"exit_code": 1}) + self.assertEqual((len(failures), errors), (2, [])) + + def test_real_checker_exception_is_a_checker_error(self): + with tempfile.TemporaryDirectory() as tmp: + # --changed-files pointing at a directory makes check_decisions.py raise. + proc = subprocess.run([sys.executable, str(ROOT / "tools" / "check_decisions.py"), + "--decisions", str(ROOT / "decisions.yaml"), "--root", tmp, + "--changed-files", tmp], capture_output=True, text=True) + report = proc.stdout + proc.stderr + self.assertIn("Traceback (most recent call last)", report) + failures, errors = ev.decision_verdict(report, {"exit_code": proc.returncode}) + self.assertEqual(failures, []) + self.assertEqual(len(errors), 1) + self.assertTrue(errors[0].startswith("checker error: check_decisions.py crashed")) + + def test_nonzero_exit_with_empty_output_is_a_checker_error(self): + for code in (1, 137): + with self.subTest(exit_code=code): + failures, errors = ev.decision_verdict("", {"exit_code": code}) + self.assertEqual(failures, []) + self.assertTrue(errors and errors[0].startswith("checker error")) + + def test_exit_zero_without_a_result_line_is_a_checker_error(self): + self.assertTrue(ev.decision_verdict("", {"exit_code": 0})[1]) + + def test_invalid_register_and_mismatched_counts_are_checker_errors(self): + self.assertTrue(ev.decision_verdict("INVALID decisions file:\nA: bad", {"exit_code": 2})[1][0] + .startswith("checker error: check_decisions.py exit 2")) + mismatched = TWO.replace("result: 2", "result: 3") + self.assertTrue(ev.decision_verdict(mismatched, {"exit_code": 1})[1]) + self.assertTrue(ev.decision_verdict(CLEAN, {"exit_code": 1})[1]) + + def test_missing_or_unreadable_status_is_a_checker_error(self): + for status in (None, {}, {"exit_code": "1"}, {"exit_code": True}): + with self.subTest(status=status): + self.assertTrue(ev.decision_verdict(CLEAN, status)[1][0].startswith( + "checker error: decisions status file missing")) + + def run_main(self, tmp, report=None, status=None, status_path=None): + event, changed = Path(tmp, "event.json"), Path(tmp, "changed.txt") + event.write_text(json.dumps({"pull_request": pr()}), encoding="utf-8") + changed.write_text("src/node.py\n", encoding="utf-8") + args = ["--event", str(event), "--changed-files", str(changed), + "--maintainers", str(ROOT / "maintainers.yaml"), "--output", str(Path(tmp, "s.md")), + "--decisions-report", str(Path(tmp, "decisions.txt"))] + if report is not None: + Path(tmp, "decisions.txt").write_text(report, encoding="utf-8") + if status is not None: + Path(tmp, "status.json").write_text(json.dumps(status), encoding="utf-8") + args += ["--decisions-status", str(status_path or Path(tmp, "status.json"))] + with contextlib.redirect_stdout(io.StringIO()): + code = ev.main(args) + return code, Path(tmp, "s.md").read_text(encoding="utf-8") + + def test_missing_status_file_fails_closed(self): + with tempfile.TemporaryDirectory() as tmp: + code, summary = self.run_main(tmp, report=CLEAN, status_path=Path(tmp, "absent.json")) + self.assertEqual(code, 1) + self.assertIn("PR evidence check: CHECKER ERROR", summary) + self.assertIn("decisions status file missing", summary) + + def test_missing_report_file_fails_closed(self): + with tempfile.TemporaryDirectory() as tmp: + code, summary = self.run_main(tmp, status={"exit_code": 0}) + self.assertEqual(code, 1) + self.assertIn("PR evidence check: CHECKER ERROR", summary) + + def test_clean_run_through_main_passes(self): + with tempfile.TemporaryDirectory() as tmp: + code, summary = self.run_main(tmp, report=CLEAN, status={"exit_code": 0}) + self.assertEqual(code, 0, summary) + self.assertIn("PR evidence check: PASS", summary) + + def test_pr_assistant_step_records_status_of_a_crashing_checker(self): + workflow = yaml.safe_load((ROOT / "rollout" / "workflows" / "pr-assistant.yml").read_text(encoding="utf-8")) + steps = {s.get("name"): s for s in workflow["jobs"]["evidence"]["steps"]} + self.assertIn("--decisions-status decisions-status.json", steps["Evidence check and summary comment"]["run"]) + script = steps["Decisions of record on the diff"]["run"] + with tempfile.TemporaryDirectory() as tmp: + tools = Path(tmp, "harness", "tools") + tools.mkdir(parents=True) + (tools / "check_decisions.py").write_text("raise RuntimeError('simulated checker crash')\n", + encoding="utf-8") + Path(tmp, "pr-head").mkdir() + Path(tmp, "changed_existing.txt").write_text("README.md\n", encoding="utf-8") + proc = subprocess.run(["bash", "-eo", "pipefail", "-c", script], cwd=tmp, capture_output=True, + text=True, env={"PATH": os.environ["PATH"], "REPOSITORY": "x"}) + self.assertEqual(proc.returncode, 0, proc.stderr) + status = json.loads(Path(tmp, "decisions-status.json").read_text(encoding="utf-8")) + report = Path(tmp, "decisions.txt").read_text(encoding="utf-8") + self.assertEqual(status, {"exit_code": 1}) + failures, errors = ev.decision_verdict(report, status) + self.assertEqual(failures, []) + self.assertTrue(errors[0].startswith("checker error: check_decisions.py crashed")) + + class Comment(unittest.TestCase): + def test_render_checker_error_verdict(self): + text = ev.render([], [], [], pr(), ["checker error: x"]) + self.assertIn("PR evidence check: CHECKER ERROR", text) + self.assertIn("Checker errors (fails closed", text) + def test_render_has_marker_and_status(self): text = ev.render(["x"], [], [], pr()) self.assertTrue(text.startswith(ev.MARKER)) diff --git a/tools/check_pr_evidence.py b/tools/check_pr_evidence.py index e8fdd66..ce5ee04 100644 --- a/tools/check_pr_evidence.py +++ b/tools/check_pr_evidence.py @@ -9,7 +9,9 @@ path touched, two human approvals required" and counts approvals for information only; the approvals themselves are enforced by the repository ruleset (rollout/workflows/SETUP.md), not by this check. Writes one -Markdown summary; with --post it creates or updates a single PR comment +Markdown summary; a decisions-check run that ends in anything other than +its two documented outcomes (exit 0 clean, exit 1 with contradictions) is a +"checker error" and fails closed. With --post it creates or updates a single PR comment identified by a hidden marker. Exit status: 0 pass, 1 fail, 2 usage error. """ import argparse @@ -164,17 +166,57 @@ def decision_failures(report, limit=20): return out -def render(failures, warnings, notes, pr): - status = "FAIL" if failures else "PASS" +RESULT_LINE = re.compile(r"^result: (\d+) contradiction\(s\)", re.M) + + +def decision_verdict(report, status): + """Classify a check_decisions.py run; return (failures, checker_errors). + + report is the checker's combined output (None if the file is missing); + status is the parsed status file, e.g. {"exit_code": 1} (None if missing). + Only the two documented policy outcomes are accepted: + exit 0 with "result: 0 contradiction(s)" and no CONTRADICTION lines (clean); + exit 1 with CONTRADICTION lines whose count matches the result line. + Everything else fails closed as a checker error: a crash, exit 2 (invalid + register or usage), any other exit code, empty output, or a missing file. + """ + if report is None: + return [], ["checker error: decisions report missing; the decisions check did not produce output"] + code = status.get("exit_code") if isinstance(status, dict) else None + if not isinstance(code, int) or isinstance(code, bool): + return [], ["checker error: decisions status file missing or unreadable; exit status of " + "check_decisions.py unknown"] + head = " | ".join(l for l in report.strip().splitlines()[-3:]) or "no output" + if "Traceback (most recent call last)" in report: + return [], [f"checker error: check_decisions.py crashed (exit {code}): {head}"] + listed = decision_failures(report) + contradictions = [l for l in report.splitlines() if l.startswith("CONTRADICTION ")] + m = RESULT_LINE.search(report) + reported = int(m.group(1)) if m else None + if code == 0 and reported == 0 and not contradictions: + return [], [] + if code == 1 and reported is not None and reported == len(contradictions) > 0: + return listed, [] + if code == 2: + return [], [f"checker error: check_decisions.py exit 2 (invalid register or usage error): {head}"] + return [], [f"checker error: unexpected check_decisions.py outcome (exit {code}, " + f"{len(contradictions)} contradiction line(s), result line " + f"{'missing' if reported is None else reported}): {head}"] + + +def render(failures, warnings, notes, pr, checker_errors=()): + status = "CHECKER ERROR" if checker_errors else ("FAIL" if failures else "PASS") lines = [MARKER, f"### PR evidence check: {status}", "", f"Head checked: `{pr.get('head', {}).get('sha', 'unknown')[:12]}`. " "This comment is updated in place on every push. It never approves or merges.", ""] - for title, items in (("Failures", failures), ("Warnings", warnings), ("Notes", notes)): + for title, items in (("Checker errors (fails closed; the result of the check is unknown)", + list(checker_errors)), + ("Failures", failures), ("Warnings", warnings), ("Notes", notes)): if items: lines.append(f"**{title}**") lines += [f"- {i}" for i in items] lines.append("") - if not (failures or warnings or notes): + if not (failures or warnings or notes or checker_errors): lines.append("All required sections and evidence are present.") lines.append("Rules: AGENTS.md in openAMRobot/.github; template: .github/PULL_REQUEST_TEMPLATE.md.") return "\n".join(lines) + "\n" @@ -212,7 +254,9 @@ def main(argv=None): p.add_argument("--reviews", type=Path, help="JSON list of PR reviews") p.add_argument("--root", type=Path, help="checkout of the PR head; enables the STATE.md rule") p.add_argument("--decisions-report", type=Path, - help="stdout of check_decisions.py on the diff; contradictions become failures") + help="combined output of check_decisions.py on the diff") + p.add_argument("--decisions-status", type=Path, + help='JSON status file written by the workflow, e.g. {"exit_code": 1}; required with --decisions-report') p.add_argument("--output", type=Path, help="write the Markdown summary here") p.add_argument("--post", action="store_true", help="create or update the PR comment") a = p.parse_args(argv) @@ -228,9 +272,20 @@ def main(argv=None): reviews = json.loads(a.reviews.read_text(encoding="utf-8")) if a.reviews else [] has_state = bool(a.root and (a.root / "STATE.md").is_file()) failures, warnings, notes = evaluate(pr, changed, maintainers, reviews, has_state) - if a.decisions_report: - failures += decision_failures(a.decisions_report.read_text(encoding="utf-8")) - summary = render(failures, warnings, notes, pr) + checker_errors = [] + if a.decisions_report or a.decisions_status: + report = status = None + try: + report = a.decisions_report.read_text(encoding="utf-8") if a.decisions_report else None + except OSError: + report = None + try: + status = json.loads(a.decisions_status.read_text(encoding="utf-8")) if a.decisions_status else None + except (OSError, ValueError): + status = None + decision_fails, checker_errors = decision_verdict(report, status) + failures += decision_fails + summary = render(failures, warnings, notes, pr, checker_errors) print(summary) if a.output: a.output.write_text(summary, encoding="utf-8") @@ -240,7 +295,7 @@ def main(argv=None): print("--post needs GITHUB_TOKEN and GITHUB_REPOSITORY", file=sys.stderr) return 2 print(f"comment {upsert_comment(repo, pr['number'], summary, token)}") - return 1 if failures else 0 + return 1 if failures or checker_errors else 0 if __name__ == "__main__": From 04e3a067a0f5e41157253475931a7df12da5a1a1 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Tue, 29 Sep 2026 23:09:37 +0000 Subject: [PATCH 025/129] rollout: consistent adoption order, warn-only mode and an interfaces pilot Review by the release and installation owner: SETUP section 1 enabled harness_checks: true while rollout step 2 required the shared rules v2 first, so the prerequisites contradicted each other. Per repository the order is now (a) shared rules v2, (b) pin the harness, (c) warn-only on main until a green run, (d) harness_checks: true, (e) require the checks in the ruleset. SETUP section 1 no longer enables the checks; it points to that order. Step (c) needed a mechanism: the reusable workflow gains harness_warn (default false), which runs the decisions, public-extract and drift steps and reports every finding as a warning without failing. harness_checks stays the enforcing switch and wins if both are set. The check steps take event, repository and mode from environment variables, and a new test runs the workflow's own step scripts in both modes. openamrobot-interfaces is the pilot: it completes (a) to (e) before any other repository enables the harness, with eight recorded exit criteria accepted by the CI owner and the release owner. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- .../workflows/repository-quality-reusable.yml | 69 ++++++++++---- rollout/README.md | 95 ++++++++++++------- rollout/workflows/SETUP.md | 15 ++- tests/test_reusable_workflow.py | 92 ++++++++++++++++++ 4 files changed, 218 insertions(+), 53 deletions(-) create mode 100644 tests/test_reusable_workflow.py diff --git a/.github/workflows/repository-quality-reusable.yml b/.github/workflows/repository-quality-reusable.yml index a25013a..7642031 100644 --- a/.github/workflows/repository-quality-reusable.yml +++ b/.github/workflows/repository-quality-reusable.yml @@ -17,6 +17,14 @@ on: type: boolean required: false default: false + harness_warn: + description: >- + Warn-only mode for rollout step (c): run the same checks and report every finding as a + warning, never failing the job. Use it on main until one run is green, then switch to + harness_checks: true. If both are true, harness_checks (enforcing) wins. + type: boolean + required: false + default: false verify: description: Run rollout/verify.sh (or the repository's own tools/verify.sh) as job quality/test. type: boolean @@ -42,7 +50,7 @@ jobs: fetch-depth: 0 - name: Check out OpenAMRobot harness - if: inputs.harness_checks + if: inputs.harness_checks || inputs.harness_warn uses: actions/checkout@v4 with: repository: openAMRobot/.github @@ -90,63 +98,92 @@ jobs: ET.parse(path) - name: Prepare harness checks - if: inputs.harness_checks + if: inputs.harness_checks || inputs.harness_warn shell: bash env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} + EVENT_NAME: ${{ github.event_name }} run: | set -euo pipefail python3 -c 'import yaml' 2>/dev/null || python3 -m pip install --user 'PyYAML==6.0.2' - if [ "${{ github.event_name }}" = pull_request ]; then + if [ "$EVENT_NAME" = pull_request ]; then git diff --name-only --diff-filter=ACMR "$BASE_SHA" "$HEAD_SHA" > "$RUNNER_TEMP/changed-files.txt" echo "scope=changed files ($(wc -l < "$RUNNER_TEMP/changed-files.txt"))" fi - # Pull requests: changed files only, blocking. A new contradiction cannot merge. - # Push and schedule: full checkout, reported as warnings; the weekly - # alignment audit turns the backlog into issues with owners. + # harness_checks: true (enforcing): pull requests scan changed files and block; + # push and schedule scan the full checkout and report warnings; an invalid + # register or usage error (exit 2) always fails. + # harness_warn: true only (rollout step c): same scans, every finding is a warning. - name: Decisions of record - if: inputs.harness_checks + if: inputs.harness_checks || inputs.harness_warn shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + REPOSITORY: ${{ github.event.repository.name }} + ENFORCE: ${{ inputs.harness_checks }} run: | set -uo pipefail h=.openamrobot-harness scope=() - if [ "${{ github.event_name }}" = pull_request ]; then + if [ "$EVENT_NAME" = pull_request ]; then scope=(--changed-files "$RUNNER_TEMP/changed-files.txt") fi python3 "$h/tools/check_decisions.py" --decisions "$h/decisions.yaml" \ --maintainers "$h/maintainers.yaml" --root . \ - --repository "${{ github.event.repository.name }}" "${scope[@]}" | tee "$RUNNER_TEMP/decisions.txt" + --repository "$REPOSITORY" "${scope[@]}" | tee "$RUNNER_TEMP/decisions.txt" status=${PIPESTATUS[0]} grep '^CONTRADICTION ' "$RUNNER_TEMP/decisions.txt" | sed -E 's/^CONTRADICTION ([^:]+):([0-9]+): /::warning file=\1,line=\2::/' || true - if [ "${{ github.event_name }}" = pull_request ] || [ "$status" -eq 2 ]; then exit "$status"; fi + if [ "$ENFORCE" = true ] && { [ "$EVENT_NAME" = pull_request ] || [ "$status" -eq 2 ]; }; then + exit "$status" + fi + if [ "$status" -ne 0 ]; then + echo "::warning::exit $status reported, not enforced (warn-only mode or push scan)" + fi - name: Public extract - if: inputs.harness_checks + if: inputs.harness_checks || inputs.harness_warn shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + REPOSITORY: ${{ github.event.repository.name }} + ENFORCE: ${{ inputs.harness_checks }} run: | set -uo pipefail h=.openamrobot-harness scope=() - if [ "${{ github.event_name }}" = pull_request ]; then + if [ "$EVENT_NAME" = pull_request ]; then scope=(--changed-files "$RUNNER_TEMP/changed-files.txt") fi python3 "$h/tools/check_public_extract.py" --root . --allowlist "$h/public-extract-allowlist.yaml" \ - --repository "${{ github.event.repository.name }}" "${scope[@]}" | tee "$RUNNER_TEMP/extract.txt" + --repository "$REPOSITORY" "${scope[@]}" | tee "$RUNNER_TEMP/extract.txt" status=${PIPESTATUS[0]} grep '^PUBLIC-EXTRACT ' "$RUNNER_TEMP/extract.txt" | sed -E 's/^PUBLIC-EXTRACT ([^:]+):([0-9]+): /::warning file=\1,line=\2::/' || true - if [ "${{ github.event_name }}" = pull_request ] || [ "$status" -eq 2 ]; then exit "$status"; fi + if [ "$ENFORCE" = true ] && { [ "$EVENT_NAME" = pull_request ] || [ "$status" -eq 2 ]; }; then + exit "$status" + fi + if [ "$status" -ne 0 ]; then + echo "::warning::exit $status reported, not enforced (warn-only mode or push scan)" + fi - name: Shared agent rules - if: inputs.harness_checks + if: inputs.harness_checks || inputs.harness_warn shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + REPOSITORY: ${{ github.event.repository.name }} + ENFORCE: ${{ inputs.harness_checks }} run: | - set -euo pipefail + set -uo pipefail if [ -f AGENTS.md ]; then python3 .openamrobot-harness/tools/check_agent_rules.py \ --canonical .openamrobot-harness/agent-rules/SHARED_RULES.md --file AGENTS.md + status=$? + if [ "$status" -ne 0 ] && [ "$ENFORCE" = true ]; then exit "$status"; fi + if [ "$status" -ne 0 ]; then + echo "::warning::shared rules drift (exit $status), not enforced in warn-only mode" + fi else echo "No AGENTS.md in this repository; see rollout/README.md in openAMRobot/.github" fi diff --git a/rollout/README.md b/rollout/README.md index 70dc4d3..830f859 100644 --- a/rollout/README.md +++ b/rollout/README.md @@ -14,7 +14,7 @@ machine-blocked. The organization-owner steps and the state of every check are i | STATE.md | Copy `STATE.md.example`, fill it, keep it current | `check_pr_evidence.py` STATE.md rule | | PR template | Delete the local `.github/PULL_REQUEST_TEMPLATE.md` so the organization template applies, or replace it with a copy that keeps every heading | `check_pr_evidence.py` sections | | verify.sh | Keep an existing `tools/verify.sh`; otherwise enable `verify: true` in the caller (the harness `rollout/verify.sh` runs), with `.openamrobot/verify.env` if the layout needs it | `quality/test` job | -| Reusable workflow caller | Pin `uses:` and `harness_ref` to one harness SHA and set `harness_checks: true` | reviewer of the caller PR | +| Reusable workflow caller | Pin `uses:` and `harness_ref` to one harness SHA; then `harness_warn: true`; then `harness_checks: true` (steps b to d below) | reviewer of the caller PR | | PR assistant | Copy `workflows/pr-assistant.yml` | `quality/pr-evidence` required check | | Docs sync sender | Copy `workflows/docs-sync-caller.yml` (not in openamrobot-docs) | none; failure shows in Actions | | decisions register | Nothing to copy; CI reads the pinned register from the harness. Fix flagged lines or mark kept history with `decision-allow: ` | `check_decisions.py` (text only; the entry's reviewer checks the substance) | @@ -26,38 +26,67 @@ template overrides the organization one, so "inherited" requires deleting it. ## Order -Each step is one PR per repository, opened as a draft by the repository owner or with the -push-from-bundle prompt, and merged by the owner. - -1. **This PR merges; the owner completes SETUP.md sections 1 to 4.** -2. **Shared block v2** in the three repositories that already carry v1 - (openamrobot-manifest, openamrobot-manipulation, openamrobot-ui), before they set - `harness_checks: true`; otherwise the drift step fails their pull requests with - "shared block is v1, canonical is v2". Existing `@main` callers without the input are - unaffected by this PR. -3. **Pin callers and opt in** in all repositories: pinned SHA, `harness_ref`, - `harness_checks: true`. -4. **Pilots, in this order**, each with STATE.md, the organization PR template, the PR - assistant and `verify: true`: - 1. openamrobot-interfaces: already has `tools/verify.sh`; the job delegates to it. - 2. openamr-platform-sw: first colcon build and test gate; container - `ros:jazzy-ros-base`. - 3. openamrobot-ui: `.openamrobot/verify.env` as in VERIFY.md; the zero-tests rule fails - until real tests replace `--passWithNoTests`. Merge the verify.env PR together - with the first real tests. - 4. openamrobot-docs: keep `scripts/check_docs.sh` and the strict MkDocs build via - `VERIFY_TEST`; add the docs-sync receiver. -5. **openamrobot-manifest and openamrobot-release** (release owner). See the release - interaction below. -6. **openamr-platform-fw, openamr-platform-hw, openamr-upperbody-*, openamrobot-comm**: shared - block, STATE.md, PR assistant. `verify: true` only once a build or test exists; a - repository with nothing to test declares that in STATE.md instead of passing an empty suite. -7. **Rulesets** per SETUP.md section 6, repository by repository, after its checks have - passed on main once. Only from this step on does a failing check block a merge in that - repository; safety-path approvals come from the ruleset and CODEOWNERS, not from a check. -8. **Weekly audit** in audits, then **monthly retro** in .github. Both are designs until a - first supervised run exercises permissions, credentials, deduplication, failure handling - and the issue lifecycle; the audit never closes an issue, it comments "no longer detected". +**Before any repository starts:** this PR merges, and the organization owner completes +SETUP.md sections 2 to 5 (secrets, Apps, Actions settings, labels). SETUP.md section 1 +(pinning) is not done organization-wide; each repository pins in its own step (b). + +**Per repository, strictly in this order.** Each step is one PR in that repository, opened as a +draft by the repository owner (or with the push-from-bundle prompt) and merged by the owner. +A step starts only after the previous one is merged. + +| Step | Change in the repository | Done when | +|---|---|---| +| (a) Shared rules v2 | AGENTS.md carries the shared block v2 verbatim; CLAUDE.md is `@AGENTS.md`; STATE.md added; local PR template removed or aligned | the drift checker passes on the repository's AGENTS.md | +| (b) Pin the harness | caller `uses: openAMRobot/.github/...@` and `harness_ref: `; `harness_checks` stays unset (false) | the caller runs the baseline steps at the pinned SHA | +| (c) Warn-only | add `harness_warn: true`; the decisions, public-extract and drift steps run and report findings as warnings, never failing | one push run on main is green with every remaining warning either fixed, tracked in an issue, or marked `decision-allow` | +| (d) Enforce | replace `harness_warn: true` with `harness_checks: true` | one enforced run on main is green and one PR passes with the checks enforced | +| (e) Require | the ruleset requires `repository-quality / repository-quality` (and `quality/pr-evidence`, `quality/test` once installed), per SETUP.md section 6 | a PR merges through the ruleset | + +Only from step (e) does a failing check block a merge in that repository. Safety-path +approvals come from the ruleset and CODEOWNERS, not from a check. + +### Pilot: openamrobot-interfaces first + +openamrobot-interfaces completes steps (a) to (e) before any other repository sets +`harness_warn` or `harness_checks`. It also installs the PR assistant and `verify: true` +(the job delegates to its existing `tools/verify.sh`). The pilot is complete when all of the +following are recorded in its STATE.md and in one comment on the harness rollout issue, and the +CI owner and the release owner have both written "pilot accepted" there: + +1. The five step PRs, linked in order. +2. The run URLs of a green warn-only run on main and a green enforced run on main. +3. An enforced PR run that blocked a deliberate contradiction on a throwaway branch (never + merged) and a clean PR that passed. +4. A `quality/test` artifact produced through delegation, whose `summary.json` records the + delegated exit status, duration and test counts. +5. One PR-assistant summary comment updated in place across two pushes, with no CHECKER ERROR + on a normal PR. +6. A PR merged through the ruleset that requires the checks. +7. No check or register pattern weakened to get green; any false positive fixed in the harness + with a test. +8. A rollback shown: reverting the caller to the previous pin restores the previous behaviour. + +If a criterion fails, the rollout stops, the finding becomes an issue labelled harness, and the +pilot repeats the failed step after the harness fix. + +### After the pilot + +The remaining repositories follow steps (a) to (e), one repository at a time: + +1. openamrobot-manifest, openamrobot-manipulation, openamrobot-ui (they already carry the v1 + block, so step (a) is an update). openamrobot-ui adds `.openamrobot/verify.env` as in + VERIFY.md; its zero-tests rule fails until real tests replace `--passWithNoTests`, so the + verify.env PR merges together with the first real tests. +2. openamr-platform-sw: first colcon build and test gate; container `ros:jazzy-ros-base`. +3. openamrobot-docs: keeps `scripts/check_docs.sh` and the strict MkDocs build via + `VERIFY_TEST`; adds the docs-sync receiver. +4. openamrobot-release (release owner). See the release interaction below. +5. openamr-platform-fw, openamr-platform-hw, openamr-upperbody-*, openamrobot-comm. + `verify: true` only once a build or test exists; a repository with nothing to test declares + that in STATE.md instead of passing an empty suite. +6. Weekly audit in audits, then monthly retro in .github. Both are designs until a first + supervised run exercises permissions, credentials, deduplication, failure handling and the + issue lifecycle; the audit never closes an issue, it comments "no longer detected". ## Release-manifest interaction diff --git a/rollout/workflows/SETUP.md b/rollout/workflows/SETUP.md index 0586558..d6a4055 100644 --- a/rollout/workflows/SETUP.md +++ b/rollout/workflows/SETUP.md @@ -23,7 +23,7 @@ is a rollout step (rollout/README.md), not a present fact. | `tools/check_agent_rules.py` (drift) | (a) | Unit tests; run on this repository and on local clones | | `tools/sync_audit_issues.py` | (a) | Unit tests with a fake API; nothing created or commented | | `rollout/verify.sh` | (a) | Unit tests; run on this repository and on a local clone of openamrobot-manifest; not on ROS 2 or Node repositories | -| `repository-quality-reusable.yml`, harness steps (`harness_checks: true`) | (a) for this repository's own caller; (b) for every other repository | `run:` steps dry run locally with checkouts simulated; never run on GitHub. Off by default, so existing `@main` callers are unchanged until they opt in | +| `repository-quality-reusable.yml`, harness steps (`harness_warn: true` warn-only, `harness_checks: true` enforcing) | (a) for this repository's own caller; (b) for every other repository | `run:` steps dry run locally with checkouts simulated; never run on GitHub. Off by default, so existing `@main` callers are unchanged until they opt in | | `repository-quality-reusable.yml`, `quality/test` job (`verify: true`) | (b) | Never run on GitHub | | `repository-quality.yml` in this repository (`quality/test`, harness checks) | (a) once merged; never run on GitHub yet | Its commands ran locally | | `pr-assistant.yml` | (b) | Its two checker commands ran locally; the workflow never ran | @@ -36,10 +36,17 @@ is a rollout step (rollout/README.md), not a present fact. ## 1. Pin the harness +Pinning is done per repository, in the order of rollout/README.md, not organization-wide: + 1. After the harness PR merges, take its merge commit SHA as ``. -2. In each caller of the reusable workflow, replace `@main` with `@` and add - `with: harness_ref: ` and `harness_checks: true`. -3. Replace `` in each workflow copied from `rollout/workflows/`. +2. Step (b): in the repository's caller, replace `@main` with `@` and add + `with: harness_ref: `. Do not set `harness_checks` here. +3. Step (c): add `harness_warn: true` (warn-only) until a run on main is green. +4. Step (d): replace it with `harness_checks: true`. Step (e) then adds the ruleset (section 6). +5. Replace `` in each workflow copied from `rollout/workflows/`. + +openamrobot-interfaces is the pilot and completes all five steps before any other repository +starts step (c). ## 2. Secrets diff --git a/tests/test_reusable_workflow.py b/tests/test_reusable_workflow.py new file mode 100644 index 0000000..f13be52 --- /dev/null +++ b/tests/test_reusable_workflow.py @@ -0,0 +1,92 @@ +"""Run the harness steps of repository-quality-reusable.yml in enforce and warn-only mode. + +The step scripts are taken from the workflow file itself and run with a fake harness whose +checkers print a finding and exit with a chosen status. This covers rollout step (c) +(harness_warn: report, never fail) and step (d) (harness_checks: true, blocking). +""" +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "repository-quality-reusable.yml" +HARNESS_STEPS = ["Check out OpenAMRobot harness", "Prepare harness checks", "Decisions of record", + "Public extract", "Shared agent rules"] + + +def load(): + data = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + inputs = data[True]["workflow_call"]["inputs"] if True in data else data["on"]["workflow_call"]["inputs"] + steps = {s["name"]: s for s in data["jobs"]["repository-quality"]["steps"]} + return inputs, steps + + +FAKE = """import sys +print("{line}") +print("result: 1 contradiction(s), 0 allowed, scope full checkout") +sys.exit({code}) +""" + + +class HarnessModes(unittest.TestCase): + def setUp(self): + self.inputs, self.steps = load() + + def run_step(self, name, code, enforce, event="pull_request", tool="check_decisions.py"): + with tempfile.TemporaryDirectory() as tmp: + tools = Path(tmp, ".openamrobot-harness", "tools") + tools.mkdir(parents=True) + for t in ("check_decisions.py", "check_public_extract.py", "check_agent_rules.py"): + line = "CONTRADICTION a.md:1: X found 'a'" if t == "check_decisions.py" else "PUBLIC-EXTRACT a.md:1: price: 5" + (tools / t).write_text(FAKE.format(line=line, code=code if t == tool else 0), encoding="utf-8") + Path(tmp, "AGENTS.md").write_text("x\n", encoding="utf-8") + Path(tmp, "changed-files.txt").write_text("a.md\n", encoding="utf-8") + env = dict(os.environ, EVENT_NAME=event, REPOSITORY="demo", ENFORCE="true" if enforce else "false", + RUNNER_TEMP=tmp) + proc = subprocess.run(["bash", "-c", self.steps[name]["run"]], cwd=tmp, env=env, + capture_output=True, text=True) + return proc.returncode, proc.stdout + proc.stderr + + def test_inputs_default_off(self): + self.assertFalse(self.inputs["harness_checks"]["default"]) + self.assertFalse(self.inputs["harness_warn"]["default"]) + + def test_every_harness_step_runs_in_either_mode(self): + for name in HARNESS_STEPS: + self.assertEqual(self.steps[name]["if"], "inputs.harness_checks || inputs.harness_warn", name) + + def test_enforce_blocks_a_pull_request_finding(self): + for name, tool in (("Decisions of record", "check_decisions.py"), ("Public extract", "check_public_extract.py")): + with self.subTest(step=name): + code, out = self.run_step(name, 1, enforce=True, tool=tool) + self.assertEqual(code, 1, out) + + def test_warn_only_reports_but_never_fails(self): + for name, tool in (("Decisions of record", "check_decisions.py"), ("Public extract", "check_public_extract.py"), + ("Shared agent rules", "check_agent_rules.py")): + for status in (1, 2): + with self.subTest(step=name, status=status): + code, out = self.run_step(name, status, enforce=False, tool=tool) + self.assertEqual(code, 0, out) + self.assertIn("::warning::", out) + + def test_enforce_on_push_warns_but_fails_on_invalid_register(self): + code, out = self.run_step("Decisions of record", 1, enforce=True, event="push") + self.assertEqual(code, 0, out) + self.assertIn("::warning file=a.md,line=1::", out) + self.assertEqual(self.run_step("Decisions of record", 2, enforce=True, event="push")[0], 2) + + def test_enforce_fails_on_shared_rules_drift(self): + self.assertEqual(self.run_step("Shared agent rules", 1, enforce=True, tool="check_agent_rules.py")[0], 1) + + def test_clean_run_passes_in_both_modes(self): + for enforce in (True, False): + self.assertEqual(self.run_step("Decisions of record", 0, enforce=enforce)[0], 0) + + +if __name__ == "__main__": + unittest.main() From 1b409bb0d1fb0460330e6fbcd30ff9d38bca35ee Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Tue, 29 Sep 2026 23:12:43 +0000 Subject: [PATCH 026/129] verify: write summary.json around delegated runs; release evidence record Addresses the release-owner review on PR 38, point 2. rollout/verify.sh used exec to hand over to a repository's own tools/verify.sh before any evidence was written, so delegated runs (openamrobot-interfaces) produced no summary.json. The script now captures the delegated output, exit status, duration, test counts and evidence path, writes summary.json with mode delegated, and exits with the delegated status. Three tests cover the delegation path: a passing run with counts, a failing run whose status is recorded and propagated, and output without recognisable counts. rollout/VERIFY.md documents the minimum summary schema. rollout/README.md defines the evidence record per component (component commit SHA, package or contract version, harness SHA, workflow run URL, artifact ID and digest), states that a register update affects a pinned consumer only when its harness pin changes or it is explicitly revalidated, and that historical evidence is preserved as recorded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- rollout/README.md | 28 +++++++-- rollout/VERIFY.md | 31 ++++++++++ rollout/verify.sh | 131 +++++++++++++++++++++++++++------------- tests/test_verify_sh.py | 24 ++++++++ 4 files changed, 166 insertions(+), 48 deletions(-) diff --git a/rollout/README.md b/rollout/README.md index 830f859..c47a899 100644 --- a/rollout/README.md +++ b/rollout/README.md @@ -93,11 +93,29 @@ The remaining repositories follow steps (a) to (e), one repository at a time: - The release builder packages what the manifest names. A release PR in openamrobot-release runs the same decisions check on release notes and metadata (today it flags the legacy compute named in `release-metadata/RELEASE_NOTES.md`, decision COMPUTE). -- The release manifest should record the harness SHA used for each component's evidence, and - each component's `summary.json` from its `quality/test` artifact, so release evidence points - at a verification run instead of a claim. -- A change to the decisions register can turn a component red without a code change. The release - owner treats that as a release blocker for the affected component, not as a CI fault. +- **Evidence record per component.** For every component in a release, the release manifest + records: + + | Field | Source | + |---|---| + | component commit SHA | the manifest pin; must equal `head_sha` in the component's `summary.json` | + | package or contract version | `package.xml` / `package.json` version, or the interface contract version, where one exists; otherwise "none" | + | harness SHA | `harness_sha` in `summary.json`; must equal the component's `harness_ref` pin | + | workflow run URL | the `quality/test` run that produced the artifact | + | artifact identifier or digest | the Actions artifact ID and its SHA-256 digest (the upload step prints both) | + + The `summary.json` schema is in VERIFY.md. It is written for delegated runs too, so + openamrobot-interfaces (which keeps its own `tools/verify.sh`) produces the same record. +- **Decision-register updates and pinned consumers.** A component is checked against the + register at its harness pin. A register update affects a pinned consumer only when that + consumer moves its harness pin, or when the release owner explicitly revalidates it against + the new register (a new `quality/test` run recorded as new evidence). Until then its recorded + evidence stands for the pin it names. +- **Historical evidence is preserved as recorded.** Release evidence is never regenerated or + edited after a release; a later register change or harness update produces new evidence for + a later release, and the earlier record keeps its original SHAs, run URL and digest. +- A register update that makes a revalidated component fail is a release blocker for that + component in the next release, not a CI fault and not a change to past releases. ## CODEOWNERS proposal diff --git a/rollout/VERIFY.md b/rollout/VERIFY.md index fd6761b..6467ea1 100644 --- a/rollout/VERIFY.md +++ b/rollout/VERIFY.md @@ -3,6 +3,8 @@ `rollout/verify.sh` is the verification entry point for repositories that do not have their own. A repository that already has `tools/verify.sh` keeps it: the script detects it and delegates. openamrobot-interfaces is the reference implementation and is not duplicated here. +Delegation still produces the same `summary.json` (see "Evidence summary" below), written +around the delegated run. ## What it runs @@ -16,11 +18,40 @@ delegates. openamrobot-interfaces is the reference implementation and is not dup | test | `colcon test` and `colcon test-result`, `npm test`, `pytest` or `unittest` | a test fails, or zero tests executed (total minus skipped is zero) | | evidence | keep logs | never | +When delegating, verify.sh runs `tools/verify.sh`, captures its combined output in +`verification.log`, records its exit status and duration, parses test counts from the output +with the same parsers, records the `Evidence:` path the delegated script prints, writes +`summary.json` and exits with the delegated exit status. It does not apply the zero-tests or +skip rules to a delegated run; those remain the delegated script's responsibility, and +`counts_parsed: false` shows when no count could be read. + Every stage runs under `env -i` with a fresh `HOME`, as in the interfaces script, so no overlay, Python path or user package leaks in. Output goes to `.verification/run.*/`: `verification.log`, `test.log`, `result.txt` (PASS, or FAIL with the stage) and `summary.json` (result, failed stage, stages passed, test totals, head and base SHA). +## Evidence summary (minimum schema) + +Every run, delegated or not, writes `.verification/run.*/summary.json` with at least these +fields. A repository-native verifier that writes its own evidence adds these fields or is +wrapped by verify.sh; a release consumes only this schema. + +| Field | Meaning | +|---|---| +| `schema_version` | `1` | +| `mode` | `harness` (verify.sh ran the stages) or `delegated` (the repository's `tools/verify.sh` ran) | +| `result`, `exit_code` | `PASS` only when `exit_code` is 0; the exit status the job reported | +| `failed_stage` | stage name, or `tools/verify.sh` for a delegated failure; null on success | +| `stages_passed` | stages verify.sh completed (empty when delegated) | +| `tests_total`, `tests_skipped`, `counts_parsed` | counts parsed from the test output; null with `counts_parsed: false` when none could be read | +| `duration_seconds` | wall time of the whole run | +| `head_sha`, `base_sha` | component commit verified and its merge base with main | +| `harness_sha` | commit of the harness that provided verify.sh | +| `delegated_script`, `delegated_evidence` | for delegated runs, the script and the evidence path it printed | + +The workflow run URL and the artifact digest are not known inside the run; the release +record adds them (rollout/README.md, release section). + ## Overrides `.openamrobot/verify.env` in the repository may set shell commands `VERIFY_INSTALL`, diff --git a/rollout/verify.sh b/rollout/verify.sh index a553f90..191189d 100755 --- a/rollout/verify.sh +++ b/rollout/verify.sh @@ -10,27 +10,108 @@ # - the zero-tests rule: a test stage that executes zero tests fails; # - the skip rule: skip, xfail and importorskip must name a tracking issue # (#123 or an issues/123 URL) on the same line; -# - summary.json with base/head SHA, stage results and test counts. +# - summary.json (schema in rollout/VERIFY.md) with result, exit code, duration, +# test counts, head/base SHA and the harness SHA. # A repository that already has tools/verify.sh keeps it; this script delegates -# to it (openamrobot-interfaces is the reference implementation). +# to it (openamrobot-interfaces is the reference implementation) and still writes +# summary.json around the delegated run: exit status, duration and test counts +# parsed from the delegated output. # Per-repository overrides live in .openamrobot/verify.env (VERIFY_INSTALL, # VERIFY_BUILD, VERIFY_LINT, VERIFY_TEST, VERIFY_ROS_DISTRO); each is a shell command. set -eo pipefail root=$(cd -- "${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" && pwd) self=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/$(basename -- "${BASH_SOURCE[0]}") +started=$(date +%s) + +# Print "total skipped parsed" for a test log (parsed is 1 when any known runner summary matched). +count_tests() { + python3 - "$1" <<'PY' +import re, sys +text = open(sys.argv[1], encoding="utf-8", errors="replace").read() +total = skipped = 0 +parsed = False +for pattern, t, s in [ + (r"Summary: (\d+) tests?, \d+ errors?, \d+ failures?, (\d+) skipped", 1, 2), # colcon test-result + (r"^Ran (\d+) tests? in", 1, None), # unittest + (r"^Tests:\s+(?:.*?(\d+) skipped, )?.*?(\d+) total", 2, 1), # jest + (r"^\s+Tests\s+(?:.*?(\d+) skipped.*?)?\((\d+)\)", 2, 1), # vitest +]: + for m in re.finditer(pattern, text, re.M): + parsed = True + total += int(m.group(t) or 0) + skipped += int(m.group(s) or 0) if s else 0 +m = re.findall(r"=+ (?:(\d+) passed)?(?:, )?(?:(\d+) skipped)?.* in [\d.]+s", text) # pytest +for passed, skip in m: + parsed = True + total += int(passed or 0) + int(skip or 0) + skipped += int(skip or 0) +skipped += sum(int(n) for n in re.findall(r"skipped=(\d+)", text)) # unittest +print(total, skipped, 1 if parsed else 0) +PY +} + +# Write summary.json (minimum evidence schema, rollout/VERIFY.md). Arguments: +# run mode exit_code failed_stage tests_total tests_skipped counts_parsed delegated_script delegated_evidence stages... +write_summary() { + python3 - "$root" "$self" "$started" "$@" <<'PY' || true +import json, subprocess, sys, time +root, self_path, started, run, mode, code, failed, total, skipped, parsed, dscript, devidence, *done = sys.argv[1:] +def git(where, *a): + try: + return subprocess.run(["git", "-C", where, *a], capture_output=True, text=True, check=True).stdout.strip() + except Exception: + return None +import os +json.dump({ + "schema_version": 1, + "mode": mode, + "result": "PASS" if code == "0" else "FAIL", + "exit_code": int(code), + "failed_stage": None if code == "0" else (failed or None), + "stages_passed": done, + "tests_total": int(total) if parsed == "1" else None, + "tests_skipped": int(skipped) if parsed == "1" else None, + "counts_parsed": parsed == "1", + "duration_seconds": int(time.time()) - int(started), + "head_sha": git(root, "rev-parse", "HEAD"), + "base_sha": git(root, "merge-base", "HEAD", "origin/main"), + "harness_sha": git(os.path.dirname(self_path), "rev-parse", "HEAD"), + "delegated_script": dscript or None, + "delegated_evidence": devidence or None, +}, open(f"{run}/summary.json", "w"), indent=2) +PY +} + +mkdir -p "$root/.verification" +run=$(mktemp -d "$root/.verification/run.XXXXXX") + if [ -f "$root/tools/verify.sh" ] && [ "$root/tools/verify.sh" != "$self" ] && [ -z "${VERIFY_NO_DELEGATE:-}" ]; then + # Delegate, but keep the evidence: capture output, exit status, duration and counts. echo "Delegating to the repository's own tools/verify.sh" - exec bash "$root/tools/verify.sh" + set +e + bash "$root/tools/verify.sh" 2>&1 | tee "$run/verification.log" + status=${PIPESTATUS[0]} + set -e + read -r d_total d_skipped d_parsed < <(count_tests "$run/verification.log") + d_evidence=$(sed -n 's/^Evidence: //p' "$run/verification.log" | tail -1) + write_summary "$run" delegated "$status" "tools/verify.sh" "$d_total" "$d_skipped" "$d_parsed" \ + "tools/verify.sh" "$d_evidence" + if [ "$status" -eq 0 ]; then + echo "PASS: delegated tools/verify.sh" | tee "$run/result.txt" + else + echo "FAIL: delegated tools/verify.sh (exit $status)" | tee "$run/result.txt" + fi + echo "Evidence: $run" + exit "$status" fi -mkdir -p "$root/.verification" -run=$(mktemp -d "$root/.verification/run.XXXXXX") exec > >(tee "$run/verification.log") 2>&1 stage=prerequisites stages=() tests_total=0 tests_skipped=0 +counts_parsed=0 finish() { result=$? @@ -39,23 +120,7 @@ finish() { else echo "FAIL: $stage (exit $result)" | tee "$run/result.txt" fi - python3 - "$run" "$root" "$result" "$stage" "$tests_total" "$tests_skipped" "${stages[@]}" <<'PY' || true -import json, subprocess, sys -run, root, result, stage, total, skipped, *done = sys.argv[1:] -def git(*a): - try: - return subprocess.run(["git", "-C", root, *a], capture_output=True, text=True, check=True).stdout.strip() - except Exception: - return None -json.dump({ - "result": "PASS" if result == "0" else "FAIL", - "failed_stage": None if result == "0" else stage, - "stages_passed": done, - "tests_total": int(total), "tests_skipped": int(skipped), - "head_sha": git("rev-parse", "HEAD"), - "base_sha": git("merge-base", "HEAD", "origin/main"), -}, open(f"{run}/summary.json", "w"), indent=2) -PY + write_summary "$run" harness "$result" "$stage" "$tests_total" "$tests_skipped" "$counts_parsed" "" "" "${stages[@]}" echo "Evidence: $run" exit "$result" } @@ -150,27 +215,7 @@ else fi test_status=${PIPESTATUS[0]} set -e -read -r tests_total tests_skipped < <(python3 - "$log" <<'PY' -import re, sys -text = open(sys.argv[1], encoding="utf-8", errors="replace").read() -total = skipped = 0 -for pattern, t, s in [ - (r"Summary: (\d+) tests?, \d+ errors?, \d+ failures?, (\d+) skipped", 1, 2), # colcon test-result - (r"^Ran (\d+) tests? in", 1, None), # unittest - (r"^Tests:\s+(?:.*?(\d+) skipped, )?.*?(\d+) total", 2, 1), # jest - (r"^\s+Tests\s+(?:.*?(\d+) skipped.*?)?\((\d+)\)", 2, 1), # vitest -]: - for m in re.finditer(pattern, text, re.M): - total += int(m.group(t) or 0) - skipped += int(m.group(s) or 0) if s else 0 -m = re.findall(r"=+ (?:(\d+) passed)?(?:, )?(?:(\d+) skipped)?.* in [\d.]+s", text) # pytest -for passed, skip in m: - total += int(passed or 0) + int(skip or 0) - skipped += int(skip or 0) -skipped += sum(int(n) for n in re.findall(r"skipped=(\d+)", text)) # unittest -print(total, skipped) -PY -) +read -r tests_total tests_skipped counts_parsed < <(count_tests "$log") echo "Tests executed: $((tests_total - tests_skipped)) of $tests_total (skipped $tests_skipped)" if [ "$((tests_total - tests_skipped))" -le 0 ]; then echo "FAIL: zero tests executed; an empty or fully skipped suite is not evidence" diff --git a/tests/test_verify_sh.py b/tests/test_verify_sh.py index a9bf559..e5e2926 100644 --- a/tests/test_verify_sh.py +++ b/tests/test_verify_sh.py @@ -82,6 +82,30 @@ def test_delegates_to_existing_tools_verify(self): self.assertEqual(code, 0) self.assertIn("repository-own-verify", out) + def test_delegation_writes_summary_with_delegated_status_and_counts(self): + script = ("echo 'Ran 3 tests in 0.010s'\n" + "echo 'Evidence: /work/.verification/run.native'\n" + "exit 0\n") + code, out, summary = self.check({"tools/verify.sh": script}) + self.assertEqual(code, 0, out) + self.assertEqual(summary["mode"], "delegated") + self.assertEqual((summary["result"], summary["exit_code"]), ("PASS", 0)) + self.assertEqual((summary["tests_total"], summary["tests_skipped"], summary["counts_parsed"]), (3, 0, True)) + self.assertEqual(summary["delegated_evidence"], "/work/.verification/run.native") + self.assertIsInstance(summary["duration_seconds"], int) + for key in ("schema_version", "head_sha", "base_sha", "harness_sha", "delegated_script"): + self.assertIn(key, summary) + + def test_delegated_failure_is_recorded_and_propagated(self): + code, out, summary = self.check({"tools/verify.sh": "echo 'Ran 2 tests in 0.1s'\necho boom\nexit 7\n"}) + self.assertEqual(code, 7, out) + self.assertEqual((summary["result"], summary["exit_code"]), ("FAIL", 7)) + self.assertIn("FAIL: delegated tools/verify.sh (exit 7)", out) + + def test_delegation_without_recognisable_counts_says_so(self): + _, _, summary = self.check({"tools/verify.sh": "echo done\nexit 0\n"}) + self.assertEqual((summary["tests_total"], summary["counts_parsed"]), (None, False)) + if __name__ == "__main__": unittest.main() From 12f5210c64d28dcc78cb04026e546ea80bff2c6c Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Tue, 29 Sep 2026 23:13:22 +0000 Subject: [PATCH 027/129] rollout: release-owner CODEOWNERS routing and how GitHub applies it Addresses the release-owner review on PR 38, point 3. The CODEOWNERS proposal in rollout/README.md now routes openamrobot-manifest, openamrobot-release, their release and manifest validation workflows, and the installation documentation in openamrobot-docs to the release owner (KARTHIKEYAN124, later the release-ci team), subject to confirmed write access. It adds three clarifications: code owners need write access to take effect, the last matching pattern takes precedence, and one approval from any listed owner satisfies the requirement unless a ruleset adds more. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- rollout/README.md | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/rollout/README.md b/rollout/README.md index c47a899..5b4cc80 100644 --- a/rollout/README.md +++ b/rollout/README.md @@ -141,7 +141,39 @@ review. Proposal, in every repository's `.github/CODEOWNERS`: /maintainers.yaml @BotshareAI /agent-rules/ @BotshareAI @panthera-momagdii /tools/ @BotshareAI + +# Release owner (maintainers.yaml release-owner: KARTHIKEYAN124). Replace the handle with +# @openAMRobot/release-ci once that team exists. Added only after write access is confirmed. +# openamrobot-manifest and openamrobot-release: whole repository. +* @BotshareAI @KARTHIKEYAN124 +# openamrobot-release: release workflow (after the lines above, so it wins for this path). +/.github/workflows/build-release.yml @BotshareAI @KARTHIKEYAN124 +# openamrobot-manifest: manifest validation workflow. +/.github/workflows/manifest-validation.yml @BotshareAI @KARTHIKEYAN124 +# openamrobot-docs: installation documentation, placed after the docs-owner line. +/docs/build/software/ @BotshareAI @KARTHIKEYAN124 +/docs/reference/openamrobot-manifest/ @BotshareAI @KARTHIKEYAN124 +/docs/reference/openamrobot-release/ @BotshareAI @KARTHIKEYAN124 ``` -A CODEOWNERS line with an account that is not a collaborator is ignored by GitHub, so the -pending handles must join the organization before their lines are added. +The release workflow lines matter because the "every repository" CI owner line for +`/.github/workflows/` would otherwise route those files away from the release owner; they +must appear after it. The installation paths are the pages in openamrobot-docs today that +tell a user how to build, flash and verify an installation (`docs/build/software/`) and the +setup pages of the two release repositories; the docs owner confirms the list when the file +is written. + +How GitHub applies these lines (documented GitHub behaviour, not something this harness +checks): + +- A code owner must have write access to the repository for the ownership to take effect. + A line whose account or team lacks write access, or is not a collaborator, is ignored for + review requests and required approvals, so the release owner's access is confirmed first. + The same holds for the pending ci-owner and docs-owner handles. +- The last matching pattern in the file takes precedence. A later line replaces the owners of + an earlier line for the paths it matches; owners are not merged across lines. Specific paths + therefore go after the broad `*` and `/.github/workflows/` lines, and each specific line + repeats every owner it still needs. +- When a line lists several owners, an approval from any one of them satisfies the code owner + requirement; approval from all of them is not required. A ruleset can add further + requirements (a minimum approval count, a required team review), and only the ruleset does. From dff523d666910b05a7a76948b3e4293885236aa3 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 30 Sep 2026 16:53:09 +0000 Subject: [PATCH 028/129] verify: fail the run when summary.json cannot be written Addresses the release-owner re-review on PR 38. write_summary() ended in "|| true", so a failed summary write left an otherwise green run without its required summary.json. The error now propagates: a passing harness run fails as "evidence-summary" with exit 1, a delegated success fails with exit 1, and a delegated failure keeps its own exit status. result.txt is written after the summary so it never says PASS for a run without evidence. tests/test_verify_sh.py SummaryWriteFailure blocks the write by placing a directory named summary.json in the run directory (this also fails as root) and covers the harness run, a delegated success and a delegated failure. All three fail against the previous script. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- rollout/VERIFY.md | 4 +++- rollout/verify.sh | 21 +++++++++++++++++---- tests/test_verify_sh.py | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/rollout/VERIFY.md b/rollout/VERIFY.md index 6467ea1..bacfc08 100644 --- a/rollout/VERIFY.md +++ b/rollout/VERIFY.md @@ -34,7 +34,9 @@ Python path or user package leaks in. Output goes to `.verification/run.*/`: Every run, delegated or not, writes `.verification/run.*/summary.json` with at least these fields. A repository-native verifier that writes its own evidence adds these fields or is -wrapped by verify.sh; a release consumes only this schema. +wrapped by verify.sh; a release consumes only this schema. If `summary.json` cannot be +written, the run fails: an otherwise passing run exits 1 with `FAIL: evidence-summary`, and a +failing run (delegated or not) keeps its own exit status. | Field | Meaning | |---|---| diff --git a/rollout/verify.sh b/rollout/verify.sh index 191189d..8dd3ed1 100755 --- a/rollout/verify.sh +++ b/rollout/verify.sh @@ -53,8 +53,9 @@ PY # Write summary.json (minimum evidence schema, rollout/VERIFY.md). Arguments: # run mode exit_code failed_stage tests_total tests_skipped counts_parsed delegated_script delegated_evidence stages... +# Returns non-zero when summary.json cannot be written; callers fail the run on that. write_summary() { - python3 - "$root" "$self" "$started" "$@" <<'PY' || true + python3 - "$root" "$self" "$started" "$@" <<'PY' import json, subprocess, sys, time root, self_path, started, run, mode, code, failed, total, skipped, parsed, dscript, devidence, *done = sys.argv[1:] def git(where, *a): @@ -95,8 +96,15 @@ if [ -f "$root/tools/verify.sh" ] && [ "$root/tools/verify.sh" != "$self" ] && [ set -e read -r d_total d_skipped d_parsed < <(count_tests "$run/verification.log") d_evidence=$(sed -n 's/^Evidence: //p' "$run/verification.log" | tail -1) - write_summary "$run" delegated "$status" "tools/verify.sh" "$d_total" "$d_skipped" "$d_parsed" \ - "tools/verify.sh" "$d_evidence" + if ! write_summary "$run" delegated "$status" "tools/verify.sh" "$d_total" "$d_skipped" "$d_parsed" \ + "tools/verify.sh" "$d_evidence"; then + echo "FAIL: could not write $run/summary.json" + # Keep a delegated failure status; turn a delegated success into a failure. + if [ "$status" -eq 0 ]; then status=1; fi + echo "FAIL: delegated tools/verify.sh (exit $status; summary.json not written)" | tee "$run/result.txt" + echo "Evidence: $run" + exit "$status" + fi if [ "$status" -eq 0 ]; then echo "PASS: delegated tools/verify.sh" | tee "$run/result.txt" else @@ -115,12 +123,17 @@ counts_parsed=0 finish() { result=$? + if ! write_summary "$run" harness "$result" "$stage" "$tests_total" "$tests_skipped" "$counts_parsed" "" "" \ + "${stages[@]}"; then + echo "FAIL: could not write $run/summary.json" + # A run without its evidence summary never passes; an earlier failure keeps its status. + if [ "$result" -eq 0 ]; then result=1; stage=evidence-summary; fi + fi if [ "$result" -eq 0 ]; then echo "PASS: all detected verification stages" | tee "$run/result.txt" else echo "FAIL: $stage (exit $result)" | tee "$run/result.txt" fi - write_summary "$run" harness "$result" "$stage" "$tests_total" "$tests_skipped" "$counts_parsed" "" "" "${stages[@]}" echo "Evidence: $run" exit "$result" } diff --git a/tests/test_verify_sh.py b/tests/test_verify_sh.py index e5e2926..952c393 100644 --- a/tests/test_verify_sh.py +++ b/tests/test_verify_sh.py @@ -107,5 +107,43 @@ def test_delegation_without_recognisable_counts_says_so(self): self.assertEqual((summary["tests_total"], summary["counts_parsed"]), (None, False)) +# A directory named summary.json inside the run directory makes the summary write fail (also as root). +BLOCK_SUMMARY_SH = 'for d in "$(dirname "$0")"/../.verification/run.*; do mkdir -p "$d/summary.json"; done\n' +BLOCK_SUMMARY_PY = ("import glob, os, unittest\n\nclass T(unittest.TestCase):\n def test_one(self):\n" + " for d in glob.glob('.verification/run.*'):\n" + " os.makedirs(os.path.join(d, 'summary.json'), exist_ok=True)\n") + + +class SummaryWriteFailure(unittest.TestCase): + """A run whose summary.json cannot be written never reports success (release-owner review).""" + + def run_blocked(self, files): + tmp, root = make_repo(files) + self.addCleanup(tmp.cleanup) + proc = subprocess.run(["bash", str(VERIFY), str(root)], capture_output=True, text=True, timeout=120, + env={"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": str(root)}) + run = sorted((root / ".verification").glob("run.*"))[-1] + self.assertTrue((run / "summary.json").is_dir(), "the test did not block the summary write") + return proc.returncode, proc.stdout + proc.stderr, (run / "result.txt").read_text() + + def test_harness_run_fails_when_summary_cannot_be_written(self): + code, out, result = self.run_blocked({"tests/test_a.py": BLOCK_SUMMARY_PY}) + self.assertNotEqual(code, 0, out) + self.assertIn("could not write", out) + self.assertNotIn("PASS: all detected verification stages", out) + self.assertTrue(result.startswith("FAIL: evidence-summary"), result) + + def test_delegated_success_fails_when_summary_cannot_be_written(self): + code, out, result = self.run_blocked({"tools/verify.sh": BLOCK_SUMMARY_SH + "echo 'Ran 3 tests in 0.1s'\nexit 0\n"}) + self.assertNotEqual(code, 0, out) + self.assertNotIn("PASS: delegated", out) + self.assertTrue(result.startswith("FAIL: delegated tools/verify.sh (exit 1; summary.json not written)"), result) + + def test_delegated_failure_status_is_kept_when_summary_cannot_be_written(self): + code, out, result = self.run_blocked({"tools/verify.sh": BLOCK_SUMMARY_SH + "exit 7\n"}) + self.assertEqual(code, 7, out) + self.assertIn("exit 7; summary.json not written", result) + + if __name__ == "__main__": unittest.main() From beff76eb90473c875bd16e9b2e1ab6e437a901a6 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 30 Sep 2026 16:53:41 +0000 Subject: [PATCH 029/129] codeowners: add the leads team next to BotshareAI Every PR in this repository authored by BotshareAI could not satisfy a code-owner review, because BotshareAI was the only owner and GitHub never counts the author's approval. .github/CODEOWNERS now lists @openAMRobot/openamrobot2-0_leads next to @BotshareAI. The team is added on every rule, not only the root "*" line: GitHub uses the last matching pattern, and the *.md, .github/, docs/, hardware/ and src/ lines would otherwise keep BotshareAI as the only owner of those paths. rollout/README.md explains that any member of the leads team can give the code-owner approval, so an author in that team still needs a second lead. The note at the top of maintainers.yaml no longer says only the platform lead reviews it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TPiK6pUmmjNhPceECkKpR2 Signed-off-by: Alex Reznichenko --- .github/CODEOWNERS | 12 ++++++------ maintainers.yaml | 3 ++- rollout/README.md | 5 +++++ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 84c00bf..a177358 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,19 +1,19 @@ # OpenAMRobot Organization CODEOWNERS # Default reviewers for all repositories -* @BotshareAI +* @BotshareAI @openAMRobot/openamrobot2-0_leads # Governance and organization-level files -*.md @BotshareAI +*.md @BotshareAI @openAMRobot/openamrobot2-0_leads # GitHub workflow and templates -.github/ @BotshareAI +.github/ @BotshareAI @openAMRobot/openamrobot2-0_leads # Documentation -docs/ @BotshareAI +docs/ @BotshareAI @openAMRobot/openamrobot2-0_leads # Hardware repositories (future) -hardware/ @BotshareAI +hardware/ @BotshareAI @openAMRobot/openamrobot2-0_leads # Software repositories (future) -src/ @BotshareAI \ No newline at end of file +src/ @BotshareAI @openAMRobot/openamrobot2-0_leads \ No newline at end of file diff --git a/maintainers.yaml b/maintainers.yaml index e2bad69..6e32402 100644 --- a/maintainers.yaml +++ b/maintainers.yaml @@ -2,7 +2,8 @@ # An empty handle means no GitHub handle is recorded yet; the organization owner # fills it once the person has confirmed their account and joined the # organization. Automation that needs that role names the role and mentions nobody. -# Changes to this file need review by the platform lead (see CODEOWNERS). +# Changes to this file need a code-owner review: the platform lead or another member of the +# leads team, never the author alone (see .github/CODEOWNERS). schema_version: 1 roles: diff --git a/rollout/README.md b/rollout/README.md index 5b4cc80..0538a18 100644 --- a/rollout/README.md +++ b/rollout/README.md @@ -177,3 +177,8 @@ checks): - When a line lists several owners, an approval from any one of them satisfies the code owner requirement; approval from all of them is not required. A ruleset can add further requirements (a minimum approval count, a required team review), and only the ruleset does. +- A team listed as owner works the same way: any member of `@openAMRobot/openamrobot2-0_leads` + can give the code-owner approval. GitHub never counts the author's own approval, so a PR + authored by a lead, or by @BotshareAI, still needs the approval of a second lead. This + repository's own `.github/CODEOWNERS` lists the leads team next to @BotshareAI on every line + for that reason; the team needs write access to this repository for the entry to count. From f3d9518f9a96f0ef43f8dd712962dee4477bddd0 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Thu, 1 Oct 2026 22:47:37 +0000 Subject: [PATCH 030/129] docs: name KARTHIKEYAN124 and wikki26 instead of release-ci team Replace "@openAMRobot/release-ci" with "@KARTHIKEYAN124 @wikki26". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TtEpmZ66TW3ufBTVZHLkCC Signed-off-by: Alex Reznichenko --- rollout/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rollout/README.md b/rollout/README.md index 0538a18..253a2f9 100644 --- a/rollout/README.md +++ b/rollout/README.md @@ -143,7 +143,7 @@ review. Proposal, in every repository's `.github/CODEOWNERS`: /tools/ @BotshareAI # Release owner (maintainers.yaml release-owner: KARTHIKEYAN124). Replace the handle with -# @openAMRobot/release-ci once that team exists. Added only after write access is confirmed. +# @KARTHIKEYAN124 @wikki26 once that team exists. Added only after write access is confirmed. # openamrobot-manifest and openamrobot-release: whole repository. * @BotshareAI @KARTHIKEYAN124 # openamrobot-release: release workflow (after the lines above, so it wins for this path). From e86f68c062f7859ecb4b19469e92d1d6e43ec903 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Sat, 3 Oct 2026 18:07:20 +0000 Subject: [PATCH 031/129] decisions: RPLIDAR S3 navigation LiDAR, release milestones; restore main CODEOWNERS - NAV-LIDAR: SLAMTEC RPLIDAR S3 (S3M1-R2) over USB on the regulated 5 V rail, per P-03-rev18.4 item 13; supersedes Hokuyo UST-10LX (P-03-rev18.3 item 11, dropped on cost) and RPLIDAR A1 (existing robot, Gate A); backup RPLIDAR S2E. Checks flag Hokuyo/UST-10LX and RPLIDAR A1/A1M8 only; RPLIDAR S3, S3M1, sllidar_ros2 and RPLIDAR alone are never flagged. - POWER-RAILS: LiDAR on the regulated 5 V rail; source adds P-03-rev18.4 item 13. - RELEASE-MILESTONES: new entry from P-03-rev18.3 item 12, with the one check the schema requires (a 2.0 final release date other than 18 December 2026). - COMPUTE: source item is the single string "txt 14, 90 and 278". - sources: add P-03-rev18.4 and P-03-rev18.3. - maintainers.yaml: ci-owner wikki26, docs-owner anandgawai123456-glitch. - .github/CODEOWNERS: restored to main's version; rollout/README.md describes it. - Tests: NAV-LIDAR and RELEASE-MILESTONES patterns; docs-owner handle; the no-handle path kept with a synthetic maintainers map. Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- .github/CODEOWNERS | 20 +---------- decisions.yaml | 59 ++++++++++++++++++++++++++------- maintainers.yaml | 4 +-- rollout/README.md | 10 +++--- tests/test_check_decisions.py | 14 ++++++++ tests/test_sync_audit_issues.py | 8 ++++- 6 files changed, 76 insertions(+), 39 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9462253..a92c9e3 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,19 +1 @@ -# OpenAMRobot Organization CODEOWNERS - -# Default reviewers for all repositories -* @BotshareAI @openAMRobot/openamrobot2-0_leads - -# Governance and organization-level files -*.md @BotshareAI @openAMRobot/openamrobot2-0_leads - -# GitHub workflow and templates -.github/ @BotshareAI @openAMRobot/openamrobot2-0_leads - -# Documentation -docs/ @BotshareAI @openAMRobot/openamrobot2-0_leads - -# Hardware repositories (future) -hardware/ @BotshareAI @openAMRobot/openamrobot2-0_leads - -# Software repositories (future) -src/ @BotshareAI @openAMRobot/openamrobot2-0_leads +* @wikki26 @BotshareAI @panthera-momagdii diff --git a/decisions.yaml b/decisions.yaml index fef94f1..c41dae8 100644 --- a/decisions.yaml +++ b/decisions.yaml @@ -50,6 +50,12 @@ sources: as the addendum in force. Values that cite it were seeded from its citation on the documentation site (openamrobot-docs main e0f2aac, docs/reference/openamrobot-2/index.md lines 30-39) and from that confirmation. + P-03-rev18.4: + title: P-03 Decision Addendum, revision 18.4 + evidence: plan-set document, not held in any repository; cited by item + P-03-rev18.3: + title: P-03 Decision Addendum, revision 18.3 + evidence: plan-set document, not held in any repository; cited by item P-03-rev18.1: title: P-03 Decision Addendum, revision 18.1 evidence: plan-set document, not held in any repository; cited by item and line @@ -398,12 +404,12 @@ decisions: status: recorded values: - main battery bus 25.6 V nominal (not a regulated 24 V rail) - - regulated 24 V branch (safety relay, brake coils, peripherals, separately fused LiDAR branch) - - regulated 5 V + - regulated 24 V branch (safety relay, brake coils, peripherals) + - regulated 5 V (includes the navigation LiDAR) - no 12 V rail unit: V date: null - source: {document: P-03-rev18.1, item: item 2 line 10} + source: {document: P-03-rev18.1, item: "item 2 line 10; P-03-rev18.4 item 13"} applies_to: repositories: ["openamr-platform-*", "openamr-upperbody-*", "openamrobot-docs"] files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"] @@ -521,7 +527,7 @@ decisions: value: NVIDIA Jetson Orin NX 16 GB on a reComputer Robotics J401 carrier with NVMe; Raspberry Pi removed from active support unit: none date: null - source: {document: P-00-rev18.1, item: txt 14, 90, 278} + source: {document: P-00-rev18.1, item: "txt 14, 90 and 278"} applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} check: - pattern: '(?PRaspberry Pi 5)' @@ -536,22 +542,51 @@ decisions: title: Navigation LiDAR kind: value status: recorded - value: Hokuyo UST-10LX (functional sensing, not a safety device) + value: SLAMTEC RPLIDAR S3 (S3M1-R2), connected over USB, powered from the regulated 5 V rail (functional sensing, not a safety device) unit: none - date: null - source: {document: P-03-rev18.1, item: item 2} + backup: RPLIDAR S2E, only if the S3 is unavailable + date: 2026-10-03 + source: {document: P-03-rev18.4, item: item 13} + supersedes: + - {value: Hokuyo UST-10LX, source: P-03-rev18.3 item 11 (dropped on cost)} + - {value: RPLIDAR A1, source: existing robot (Gate A)} applies_to: repositories: ["openamr-platform-*", "openamrobot-docs", "openamrobot-release"] files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.launch.py", "**/package.xml", "**/*.html"] check: - - pattern: '(?Prplidar_ros|RPLIDAR(?: A1)?)' - unless: 'legacy|Gate A|historical' - message: the 2.0 navigation LiDAR is the Hokuyo UST-10LX + - pattern: '(?P\bHokuyo\b|\bUST-10LX\b)' + unless: 'supersed|dropped|legacy|historical' + message: the 2.0 navigation LiDAR is the RPLIDAR S3 (S3M1-R2); the Hokuyo UST-10LX was dropped + - pattern: '(?P\bRPLIDAR A1(?:M8)?\b|\bA1M8\b)' + unless: 'legacy|Gate A|existing robot|historical|replaced' + message: the 2.0 navigation LiDAR is the RPLIDAR S3 (S3M1-R2); label RPLIDAR A1 material as legacy (Gate A) verification: - machine: RPLIDAR references without a legacy label - human: {reviewer: software-lead, evidence: bring-up launch and scan topic from the UST-10LX driver} + machine: Hokuyo or UST-10LX without a superseded or legacy label; RPLIDAR A1 or A1M8 without a legacy label + human: {reviewer: software-lead, evidence: bring-up launch and scan topic from the sllidar_ros2 driver with the RPLIDAR S3} owner: software-lead + - id: RELEASE-MILESTONES + title: OpenAMRobot 2.0 release milestones + kind: value + status: recorded + values: + - "20 November 2026: OpenAMRobot 2.0 pre-release (full readiness, frozen platform baseline, end of development cycle 2)" + - "23 November to 18 December 2026: physical integration, testing and acceptance" + - "18 December 2026: OpenAMRobot 2.0 final release" + unit: none + date: 2026-10-01 + source: {document: P-03-rev18.3, item: item 12} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.html", "**/*.yaml", "**/*.yml"]} + check: + # The schema requires at least one check: a 2.0 final release given another date. + - pattern: '(?P2\.0 final release[^\n]{0,20}?\b(?!18 December 2026)\d{1,2} (?:January|February|March|April|May|June|July|August|September|October|November|December) \d{4})' + unless: 'supersed|previous|earlier|historical' + message: the OpenAMRobot 2.0 final release is 18 December 2026 + verification: + machine: text giving the 2.0 final release a date other than 18 December 2026 + human: {reviewer: platform-lead, evidence: the release plan in P-03 rev18.3 item 12} + owner: platform-lead + - id: LIFT-REMOVED title: Release 2.0 has no lift; the upper body is a fixed mast kind: exclusion diff --git a/maintainers.yaml b/maintainers.yaml index 6e32402..29643fb 100644 --- a/maintainers.yaml +++ b/maintainers.yaml @@ -14,13 +14,13 @@ roles: handle: panthera-momagdii covers: robot software, AI, interfaces, agent rules ci-owner: - handle: # filled by the organization owner + handle: wikki26 covers: CI/CD, reusable workflows, verify.sh, quality gates release-owner: handle: KARTHIKEYAN124 covers: release, installation, manifest docs-owner: - handle: # filled by the organization owner + handle: anandgawai123456-glitch covers: documentation site, public extracts # Owner of audit findings by ID prefix, used by the weekly alignment audit. diff --git a/rollout/README.md b/rollout/README.md index 253a2f9..453beaa 100644 --- a/rollout/README.md +++ b/rollout/README.md @@ -177,8 +177,8 @@ checks): - When a line lists several owners, an approval from any one of them satisfies the code owner requirement; approval from all of them is not required. A ruleset can add further requirements (a minimum approval count, a required team review), and only the ruleset does. -- A team listed as owner works the same way: any member of `@openAMRobot/openamrobot2-0_leads` - can give the code-owner approval. GitHub never counts the author's own approval, so a PR - authored by a lead, or by @BotshareAI, still needs the approval of a second lead. This - repository's own `.github/CODEOWNERS` lists the leads team next to @BotshareAI on every line - for that reason; the team needs write access to this repository for the entry to count. +- This repository's own `.github/CODEOWNERS` is a single line, `* @wikki26 @BotshareAI + @panthera-momagdii`: an approval from any one of the three satisfies the code-owner + requirement for every path. GitHub never counts the author's own approval, so a PR authored + by one of them still needs the approval of one of the other two. Each listed account needs + write access to this repository for the entry to count. diff --git a/tests/test_check_decisions.py b/tests/test_check_decisions.py index 2c5735a..dc695b2 100644 --- a/tests/test_check_decisions.py +++ b/tests/test_check_decisions.py @@ -239,6 +239,20 @@ def test_bom_issue_in_force(self): self.assertEqual(self.ids("docs/a.md", "BOM per P-03 rev18.1 line 7.\n", "x"), ["BOM-ISSUE-IN-FORCE"]) self.assertEqual(self.ids("docs/a.md", "Issue 7 is canonical; Issue 6 is superseded.\n", "x"), []) + def test_nav_lidar(self): + repo = "openamr-platform-sw" + self.assertEqual(self.ids("docs/a.md", "Navigation LiDAR: Hokuyo UST-10LX.\n", repo), ["NAV-LIDAR"]) + self.assertEqual(self.ids("docs/a.md", "The UST-10LX was dropped on cost.\n", repo), []) + self.assertEqual(self.ids("docs/a.md", "Mount the RPLIDAR A1M8 on the base.\n", repo), ["NAV-LIDAR"]) + self.assertEqual(self.ids("docs/a.md", "RPLIDAR A1 on the existing robot (Gate A).\n", repo), []) + self.assertEqual(self.ids("docs/a.md", "RPLIDAR S3 (S3M1-R2) via sllidar_ros2; the RPLIDAR is on USB.\n", + repo), []) + + def test_release_milestones(self): + self.assertEqual(self.ids("docs/a.md", "OpenAMRobot 2.0 final release: 18 December 2026.\n", "x"), []) + self.assertEqual(self.ids("docs/a.md", "OpenAMRobot 2.0 final release: 30 November 2026.\n", "x"), + ["RELEASE-MILESTONES"]) + def test_legacy_label_exempts_compute(self): self.assertEqual(self.ids("README.md", "Legacy build: Raspberry Pi 5.\n", "openamr-platform-hw"), []) self.assertEqual(self.ids("README.md", "Compute: Raspberry Pi 5.\n", "openamr-platform-hw"), ["COMPUTE"]) diff --git a/tests/test_sync_audit_issues.py b/tests/test_sync_audit_issues.py index 514b900..034d761 100644 --- a/tests/test_sync_audit_issues.py +++ b/tests/test_sync_audit_issues.py @@ -43,9 +43,15 @@ def test_opens_blocker_and_major_only(self): def test_owner_from_maintainers_map_not_from_csv(self): to_open, _ = self.run_plan([row("SW-901"), row("DOC-901", target="openamrobot-docs docs/a.md")]) self.assertIn("Owner: @panthera-momagdii (software-lead)", to_open[0]["body"]) - self.assertIn("Owner: docs-owner, no handle recorded", to_open[1]["body"]) + self.assertIn("Owner: @anandgawai123456-glitch (docs-owner)", to_open[1]["body"]) self.assertNotIn("Synthetic Person", to_open[0]["body"] + to_open[1]["body"]) + def test_role_without_handle_mentions_nobody(self): + maintainers = {**MAINTAINERS, "roles": {**MAINTAINERS["roles"], "docs-owner": {"handle": None}}} + to_open, _ = sai.plan([row("DOC-901", target="openamrobot-docs docs/a.md")], [], maintainers, + "audits", "2099-01-01-alignment-audit@abc1234") + self.assertIn("Owner: docs-owner, no handle recorded", to_open[0]["body"]) + def test_public_issue_is_an_extract(self): body = self.run_plan([row("SW-901")])[0][0]["body"] self.assertNotIn("synthetic private quote", body) From 0fa9b649c61b0d0c52116155c5dba5bba916c86b Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Sat, 3 Oct 2026 18:51:06 +0000 Subject: [PATCH 032/129] docs: rev18.4 in force; maintainers header and rollout handles match main - decisions.yaml sources: P-03-rev18.4 is the addendum in force; rev18.2 and rev18.3 are marked as earlier revisions. - maintainers.yaml header: describes main's CODEOWNERS (owner handles, no leads team). - rollout/README.md: ci-owner and docs-owner placeholders replaced with wikki26 and anandgawai123456-glitch; "pending" removed. Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- decisions.yaml | 8 ++++---- maintainers.yaml | 6 ++++-- rollout/README.md | 18 +++++++++--------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/decisions.yaml b/decisions.yaml index c41dae8..d0f965e 100644 --- a/decisions.yaml +++ b/decisions.yaml @@ -44,17 +44,17 @@ schema_version: 1 sources: P-03-rev18.2: - title: P-03 Decision Addendum, revision 18.2, 28 September 2026 (the addendum in force) + title: P-03 Decision Addendum, revision 18.2, 28 September 2026 (earlier revision; superseded by revision 18.4 as the addendum in force) evidence: >- Not held in any repository. Confirmed by the platform lead on 29 September 2026 - as the addendum in force. Values that cite it were seeded from its citation on the + as the addendum in force at that date. Values that cite it were seeded from its citation on the documentation site (openamrobot-docs main e0f2aac, docs/reference/openamrobot-2/index.md lines 30-39) and from that confirmation. P-03-rev18.4: - title: P-03 Decision Addendum, revision 18.4 + title: P-03 Decision Addendum, revision 18.4 (the addendum in force) evidence: plan-set document, not held in any repository; cited by item P-03-rev18.3: - title: P-03 Decision Addendum, revision 18.3 + title: P-03 Decision Addendum, revision 18.3 (earlier revision) evidence: plan-set document, not held in any repository; cited by item P-03-rev18.1: title: P-03 Decision Addendum, revision 18.1 diff --git a/maintainers.yaml b/maintainers.yaml index 29643fb..9e9c7ab 100644 --- a/maintainers.yaml +++ b/maintainers.yaml @@ -2,8 +2,10 @@ # An empty handle means no GitHub handle is recorded yet; the organization owner # fills it once the person has confirmed their account and joined the # organization. Automation that needs that role names the role and mentions nobody. -# Changes to this file need a code-owner review: the platform lead or another member of the -# leads team, never the author alone (see .github/CODEOWNERS). +# Changes to this file need a code-owner review. This repository's .github/CODEOWNERS +# names owner handles, not a team: @wikki26, @BotshareAI and @panthera-momagdii for +# every path; each repository's own CODEOWNERS names its owner handles. The author's +# own approval never counts. schema_version: 1 roles: diff --git a/rollout/README.md b/rollout/README.md index 453beaa..39a5303 100644 --- a/rollout/README.md +++ b/rollout/README.md @@ -130,17 +130,17 @@ review. Proposal, in every repository's `.github/CODEOWNERS`: # openamrobot-manipulation, openamrobot-ui, openamrobot-comm): add the software lead. * @BotshareAI @panthera-momagdii -# Every repository: CI owner for workflows. Handle to be confirmed (maintainers.yaml ci-owner). -/.github/workflows/ @BotshareAI +# Every repository: CI owner for workflows. maintainers.yaml ci-owner: wikki26. +/.github/workflows/ @BotshareAI @wikki26 -# openamrobot-docs only: documentation owner. Handle to be confirmed (maintainers.yaml docs-owner). -* @BotshareAI +# openamrobot-docs only: documentation owner. maintainers.yaml docs-owner: anandgawai123456-glitch. +* @BotshareAI @anandgawai123456-glitch # openAMRobot/.github only: policy and harness files. /decisions.yaml @BotshareAI /maintainers.yaml @BotshareAI /agent-rules/ @BotshareAI @panthera-momagdii -/tools/ @BotshareAI +/tools/ @BotshareAI @wikki26 # Release owner (maintainers.yaml release-owner: KARTHIKEYAN124). Replace the handle with # @KARTHIKEYAN124 @wikki26 once that team exists. Added only after write access is confirmed. @@ -151,9 +151,9 @@ review. Proposal, in every repository's `.github/CODEOWNERS`: # openamrobot-manifest: manifest validation workflow. /.github/workflows/manifest-validation.yml @BotshareAI @KARTHIKEYAN124 # openamrobot-docs: installation documentation, placed after the docs-owner line. -/docs/build/software/ @BotshareAI @KARTHIKEYAN124 -/docs/reference/openamrobot-manifest/ @BotshareAI @KARTHIKEYAN124 -/docs/reference/openamrobot-release/ @BotshareAI @KARTHIKEYAN124 +/docs/build/software/ @BotshareAI @anandgawai123456-glitch @KARTHIKEYAN124 +/docs/reference/openamrobot-manifest/ @BotshareAI @anandgawai123456-glitch @KARTHIKEYAN124 +/docs/reference/openamrobot-release/ @BotshareAI @anandgawai123456-glitch @KARTHIKEYAN124 ``` The release workflow lines matter because the "every repository" CI owner line for @@ -169,7 +169,7 @@ checks): - A code owner must have write access to the repository for the ownership to take effect. A line whose account or team lacks write access, or is not a collaborator, is ignored for review requests and required approvals, so the release owner's access is confirmed first. - The same holds for the pending ci-owner and docs-owner handles. + The same holds for the ci-owner and docs-owner handles. - The last matching pattern in the file takes precedence. A later line replaces the owners of an earlier line for the paths it matches; owners are not merged across lines. Specific paths therefore go after the broad `*` and `/.github/workflows/` lines, and each specific line From 066f5806d5786c5638f51f0b230a7526148e9007 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Mon, 5 Oct 2026 23:11:51 +0000 Subject: [PATCH 033/129] decisions: base controller is the STM32H723ZG (P-03 rev18.5 item 14) - BASE-CONTROLLER-GATES: Gate B names the STM32H723ZG on the NUCLEO-H723ZG bench board instead of the NUCLEO-H743ZI2. - Sources: add P-03-rev18.5. - New recorded entry BASE-CONTROLLER-IO (2026-10-06, P-03-rev18.5 item 14, owner platform-lead): STM32H723ZG on NUCLEO-H723ZG; TF-Luna on UART, one per UART; MB7040 one per I2C bus; IMU on a dedicated SPI, Mode 0; CAN1 traction only, CAN2 BMS, CAN3 upper-body actuators; micro-ROS over Ethernet. Its check flags NUCLEO-H743ZI2 or STM32H743 unless the line matches supersed|legacy|historical|replaced. Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FTYv7wU12mXomHsTFu3iF6 --- decisions.yaml | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/decisions.yaml b/decisions.yaml index d0f965e..a1cd890 100644 --- a/decisions.yaml +++ b/decisions.yaml @@ -53,6 +53,9 @@ sources: P-03-rev18.4: title: P-03 Decision Addendum, revision 18.4 (the addendum in force) evidence: plan-set document, not held in any repository; cited by item + P-03-rev18.5: + title: P-03 Decision Addendum, revision 18.5, 6 October 2026 + evidence: plan-set document, not held in any repository; cited by item P-03-rev18.3: title: P-03 Decision Addendum, revision 18.3 (earlier revision) evidence: plan-set document, not held in any repository; cited by item @@ -308,12 +311,12 @@ decisions: status: recorded values: - Gate A, Jetson with the existing Teensy, ZBLD/PWM drivetrain and MPU6500 (legacy test configuration) - - Gate B, Jetson with STM32H7 (NUCLEO-H743ZI2 bench board) and the ZLTECH drivers + - Gate B, Jetson with the STM32H723ZG (NUCLEO-H723ZG bench board) and the ZLTECH drivers - ICM-42688-P enters the manufacturing BOM only after side-by-side robot evidence; adoption decision 20 November 2026 - STM32 go/no-go 6 November 2026; fallback is the validated legacy Teensy/PWM build behind I8 unit: none date: null - source: {document: P-00-rev18.1, item: txt 63, 101, 106, 344; I8-WP adoption gate} + source: {document: P-00-rev18.1, item: "txt 63, 101, 106, 344; I8-WP adoption gate; Gate B board per P-03-rev18.5 item 14"} applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} check: - pattern: '(?PTeensy[^\n]{0,20}\bbench target)' @@ -328,6 +331,32 @@ decisions: human: {reviewer: platform-lead, evidence: gate records (Gate A test log, STM32 go/no-go record, IMU side-by-side data)} owner: platform-lead + - id: BASE-CONTROLLER-IO + title: Base-controller MCU, sensor and IMU buses, CAN mapping and micro-ROS transport + kind: configuration + status: recorded + values: + - Base controller STM32H723ZG on NUCLEO-H723ZG + - TF-Luna on UART, one per UART + - MB7040 one per I2C bus + - IMU on a dedicated SPI, Mode 0 + - CAN1 traction only, CAN2 BMS, CAN3 upper-body actuators + - micro-ROS over Ethernet + unit: none + date: 2026-10-06 + source: {document: P-03-rev18.5, item: item 14} + supersedes: + - {value: STM32H743 on NUCLEO-H743ZI2, source: P-00-rev18.1 (Gate B bench board)} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} + check: + - pattern: '(?PNUCLEO-H743ZI2|STM32H743)' + unless: 'supersed|legacy|historical|replaced' + message: the base controller is the STM32H723ZG on the NUCLEO-H723ZG; label STM32H743 / NUCLEO-H743ZI2 material as superseded + verification: + machine: NUCLEO-H743ZI2 or STM32H743 without a superseded, legacy, historical or replaced label + human: {reviewer: platform-lead, evidence: P-03 rev18.5 item 14 and the MCU I/O allocation in openamr-platform-hw} + owner: platform-lead + - id: IMU-TOPIC-OWNERSHIP title: IMU topic ownership kind: distinction From cc027252c7061e84aadeb5bbd67ac141ecbdbfdd Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Mon, 5 Oct 2026 23:22:52 +0000 Subject: [PATCH 034/129] decisions: rev18.5 in force; register test for BASE-CONTROLLER-IO - Sources: P-03-rev18.5 is the addendum in force; rev18.4 becomes an earlier revision like rev18.2 and rev18.3. - tests: BASE-CONTROLLER-IO flags a line naming the STM32H743 or the NUCLEO-H743ZI2, and not lines marked superseded, legacy, historical or replaced, nor an STM32H723ZG line. Fails against the register before the entry was added (0fa9b64). Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FTYv7wU12mXomHsTFu3iF6 --- decisions.yaml | 6 +++--- tests/test_check_decisions.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/decisions.yaml b/decisions.yaml index a1cd890..d2e164b 100644 --- a/decisions.yaml +++ b/decisions.yaml @@ -44,17 +44,17 @@ schema_version: 1 sources: P-03-rev18.2: - title: P-03 Decision Addendum, revision 18.2, 28 September 2026 (earlier revision; superseded by revision 18.4 as the addendum in force) + title: P-03 Decision Addendum, revision 18.2, 28 September 2026 (earlier revision; superseded by revision 18.5 as the addendum in force) evidence: >- Not held in any repository. Confirmed by the platform lead on 29 September 2026 as the addendum in force at that date. Values that cite it were seeded from its citation on the documentation site (openamrobot-docs main e0f2aac, docs/reference/openamrobot-2/index.md lines 30-39) and from that confirmation. P-03-rev18.4: - title: P-03 Decision Addendum, revision 18.4 (the addendum in force) + title: P-03 Decision Addendum, revision 18.4 (earlier revision) evidence: plan-set document, not held in any repository; cited by item P-03-rev18.5: - title: P-03 Decision Addendum, revision 18.5, 6 October 2026 + title: P-03 Decision Addendum, revision 18.5, 6 October 2026 (the addendum in force) evidence: plan-set document, not held in any repository; cited by item P-03-rev18.3: title: P-03 Decision Addendum, revision 18.3 (earlier revision) diff --git a/tests/test_check_decisions.py b/tests/test_check_decisions.py index dc695b2..955124c 100644 --- a/tests/test_check_decisions.py +++ b/tests/test_check_decisions.py @@ -253,6 +253,17 @@ def test_release_milestones(self): self.assertEqual(self.ids("docs/a.md", "OpenAMRobot 2.0 final release: 30 November 2026.\n", "x"), ["RELEASE-MILESTONES"]) + def test_base_controller_io(self): + repo = "openamrobot-docs" + self.assertEqual(self.ids("docs/a.md", "Gate B: STM32H743 bench controller.\n", repo), ["BASE-CONTROLLER-IO"]) + self.assertEqual(self.ids("docs/a.md", "Bench board: NUCLEO-H743ZI2.\n", repo), ["BASE-CONTROLLER-IO"]) + for line in ("The STM32H743 is superseded by the STM32H723ZG.\n", + "Legacy bench board: NUCLEO-H743ZI2.\n", + "Historical note: the STM32H743 bench build.\n", + "The NUCLEO-H743ZI2 was replaced by the NUCLEO-H723ZG.\n", + "Base controller: STM32H723ZG on a NUCLEO-H723ZG bench board.\n"): + self.assertEqual(self.ids("docs/a.md", line, repo), [], line) + def test_legacy_label_exempts_compute(self): self.assertEqual(self.ids("README.md", "Legacy build: Raspberry Pi 5.\n", "openamr-platform-hw"), []) self.assertEqual(self.ids("README.md", "Compute: Raspberry Pi 5.\n", "openamr-platform-hw"), ["COMPUTE"]) From fcf3d8ae7e738c3a1e6c7470ae18fa8ec23f089a Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Tue, 6 Oct 2026 11:08:21 +0000 Subject: [PATCH 035/129] decisions: rev18.6 in force; release versions in RELEASE-MILESTONES - sources: P-03-rev18.6 added as the addendum in force; rev18.5 is an earlier revision, and rev18.2's title now names 18.6 as its successor. - RELEASE-MILESTONES: v2.0.0-rc.1 for the 20 November 2026 pre-release (GitHub pre-release) and v2.0.0 for the 18 December 2026 final release; source P-03-rev18.6 item 12. Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- decisions.yaml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/decisions.yaml b/decisions.yaml index d2e164b..123893d 100644 --- a/decisions.yaml +++ b/decisions.yaml @@ -44,7 +44,7 @@ schema_version: 1 sources: P-03-rev18.2: - title: P-03 Decision Addendum, revision 18.2, 28 September 2026 (earlier revision; superseded by revision 18.5 as the addendum in force) + title: P-03 Decision Addendum, revision 18.2, 28 September 2026 (earlier revision; superseded by revision 18.6 as the addendum in force) evidence: >- Not held in any repository. Confirmed by the platform lead on 29 September 2026 as the addendum in force at that date. Values that cite it were seeded from its citation on the @@ -53,8 +53,11 @@ sources: P-03-rev18.4: title: P-03 Decision Addendum, revision 18.4 (earlier revision) evidence: plan-set document, not held in any repository; cited by item + P-03-rev18.6: + title: P-03 Decision Addendum, revision 18.6 (the addendum in force) + evidence: plan-set document, not held in any repository; cited by item P-03-rev18.5: - title: P-03 Decision Addendum, revision 18.5, 6 October 2026 (the addendum in force) + title: P-03 Decision Addendum, revision 18.5, 6 October 2026 (earlier revision) evidence: plan-set document, not held in any repository; cited by item P-03-rev18.3: title: P-03 Decision Addendum, revision 18.3 (earlier revision) @@ -599,12 +602,12 @@ decisions: kind: value status: recorded values: - - "20 November 2026: OpenAMRobot 2.0 pre-release (full readiness, frozen platform baseline, end of development cycle 2)" + - "20 November 2026: OpenAMRobot 2.0 pre-release, version v2.0.0-rc.1 (GitHub pre-release; full readiness, frozen platform baseline, end of development cycle 2)" - "23 November to 18 December 2026: physical integration, testing and acceptance" - - "18 December 2026: OpenAMRobot 2.0 final release" + - "18 December 2026: OpenAMRobot 2.0 final release, version v2.0.0" unit: none date: 2026-10-01 - source: {document: P-03-rev18.3, item: item 12} + source: {document: P-03-rev18.6, item: item 12} applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.html", "**/*.yaml", "**/*.yml"]} check: # The schema requires at least one check: a 2.0 final release given another date. @@ -613,7 +616,7 @@ decisions: message: the OpenAMRobot 2.0 final release is 18 December 2026 verification: machine: text giving the 2.0 final release a date other than 18 December 2026 - human: {reviewer: platform-lead, evidence: the release plan in P-03 rev18.3 item 12} + human: {reviewer: platform-lead, evidence: the release plan and versions in P-03 rev18.6 item 12} owner: platform-lead - id: LIFT-REMOVED From 4a88e50fe65da71d58d96193a880055ae8a3a404 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 15:18:14 +0000 Subject: [PATCH 036/129] decisions: rev18.7 in force; LIFT replaces LIFT-REMOVED (item 8) - sources: P-03-rev18.7 (6 October 2026) is the addendum in force; rev18.6 is an earlier revision. - LIFT replaces LIFT-REMOVED: lift approved in principle; DOLD Hexalift V1 350 mm primary, TiMOTION TL3 400 mm fallback; shoulder axis 1000 to 1350 mm; base plate fore-aft positions centre, +50 to +200 mm; lift motion only in the stowed or carry safe pose with the base stopped; hold-and-move limits 0.05 m/s and 0.3 m/s; holding on E-stop and power loss, supplier CAD, base-plate geometry and the CAN3 lift interface stay release gates (items 6, 8, 14, 15). The check flags "no lift" for 2.0, a fixed mast as the current baseline, and the lift deferred to 3.0. - tests: test_lift_approved_in_principle; it fails against the previous register ([] != ['LIFT']). The LIFT-REMOVED expectations are removed. Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- decisions.yaml | 44 ++++++++++++++++++++++++----------- tests/test_check_decisions.py | 17 ++++++++++++-- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/decisions.yaml b/decisions.yaml index 123893d..047060c 100644 --- a/decisions.yaml +++ b/decisions.yaml @@ -44,7 +44,7 @@ schema_version: 1 sources: P-03-rev18.2: - title: P-03 Decision Addendum, revision 18.2, 28 September 2026 (earlier revision; superseded by revision 18.6 as the addendum in force) + title: P-03 Decision Addendum, revision 18.2, 28 September 2026 (earlier revision; superseded by revision 18.7 as the addendum in force) evidence: >- Not held in any repository. Confirmed by the platform lead on 29 September 2026 as the addendum in force at that date. Values that cite it were seeded from its citation on the @@ -53,8 +53,11 @@ sources: P-03-rev18.4: title: P-03 Decision Addendum, revision 18.4 (earlier revision) evidence: plan-set document, not held in any repository; cited by item + P-03-rev18.7: + title: P-03 Decision Addendum, revision 18.7, 6 October 2026 (the addendum in force) + evidence: plan-set document, not held in any repository; cited by item P-03-rev18.6: - title: P-03 Decision Addendum, revision 18.6 (the addendum in force) + title: P-03 Decision Addendum, revision 18.6 (earlier revision) evidence: plan-set document, not held in any repository; cited by item P-03-rev18.5: title: P-03 Decision Addendum, revision 18.5, 6 October 2026 (earlier revision) @@ -619,22 +622,37 @@ decisions: human: {reviewer: platform-lead, evidence: the release plan and versions in P-03 rev18.6 item 12} owner: platform-lead - - id: LIFT-REMOVED - title: Release 2.0 has no lift; the upper body is a fixed mast - kind: exclusion + - id: LIFT + title: Lift approved in principle for OpenAMRobot 2.0 + kind: configuration status: recorded - value: Lift removed from OpenAMRobot 2.0; 3.0 roadmap + values: + - lift approved in principle + - DOLD Hexalift V1 350 mm primary; TiMOTION TL3 400 mm fallback + - shoulder axis 1000 to 1350 mm + - base plate fore-aft positions centre, +50, +100, +150 and +200 mm + - lift motion only in the stowed or carry safe pose with the base stopped + - hold-and-move limits 0.05 m/s (drawer reversal) and 0.3 m/s (carrying) + - "release gates: holding on E-stop and power loss, supplier CAD, base-plate geometry and the CAN3 lift interface (items 6, 8, 14, 15)" unit: none - date: null - source: {document: P-00-rev18.1, item: txt 18} + date: 2026-10-06 + source: {document: P-03-rev18.7, item: "item 8; release gates items 6, 8, 14, 15"} + supersedes: + - {value: "Lift removed from OpenAMRobot 2.0; fixed mast; lift on the 3.0 roadmap (former entry LIFT-REMOVED)", source: P-00-rev18.1 txt 18} applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/package.xml", "**/*.xacro", "**/*.urdf"]} check: - - pattern: '(?P(?:linear |actuated )?lift (?:module|controller|joint|axis|system|column|motor)s?)' - unless: '3\.0|removed|later release|v3|roadmap|not part|replace' - message: 2.0 has a fixed mast; the lift is 3.0 + - pattern: '(?P\bno (?:linear )?lift\b(?!\s+(?:motion|move|movement|while|unless|during)))' + unless: 'supersed|historical|earlier|previous|no longer|rev ?18\.[1-6]\b' + message: the lift is approved in principle for 2.0 (P-03 rev18.7 item 8) + - pattern: '(?P\bfixed[- ]mast\b)' + unless: 'supersed|historical|earlier|previous|legacy|replace|instead of|no longer|rev ?18\.[1-6]\b' + message: the fixed mast is no longer the baseline; the lift is approved in principle (P-03 rev18.7 item 8) + - pattern: '(?P\blift\b[^\n]{0,40}\b(?:deferred|moved|postponed)\b[^\n]{0,25}\b3\.0\b|\blift\b[^\n]{0,15}\bin (?:OpenAMRobot )?3\.0\b|\blift (?:is |was )?removed\b)' + unless: 'supersed|historical|earlier|previous|no longer|rev ?18\.[1-6]\b' + message: the lift is not deferred to 3.0; it is approved in principle for 2.0 (P-03 rev18.7 item 8) verification: - machine: lift hardware or software described without a 3.0 or removed label - human: {reviewer: platform-lead, evidence: upper-body package list without lift controllers} + machine: text saying 2.0 has no lift, presenting a fixed mast as the current baseline, or deferring the lift to 3.0 + human: {reviewer: platform-lead, evidence: "P-03 rev18.7 item 8 and the release-gate records: holding on E-stop and power loss, supplier CAD, base-plate geometry and the CAN3 lift interface"} owner: platform-lead - id: NO-SUSPENSION diff --git a/tests/test_check_decisions.py b/tests/test_check_decisions.py index 955124c..a48084a 100644 --- a/tests/test_check_decisions.py +++ b/tests/test_check_decisions.py @@ -192,7 +192,7 @@ def test_every_entry_has_provenance_verification_and_owner(self): def test_non_numeric_decisions_are_present(self): kinds = {d["id"]: d["kind"] for d in self.decisions} for did in ("MAX-ASSEMBLED-HEIGHT", "NO-SUSPENSION", "RS485-NOT-IN-2-0", "DOCK-NO-CONTACTS", - "LIFT-REMOVED", "DOCKING-NOT-CHARGING", "TELEMETRY-NOT-SAFETY-EVIDENCE"): + "DOCKING-NOT-CHARGING", "TELEMETRY-NOT-SAFETY-EVIDENCE"): self.assertIn(kinds[did], {"exclusion", "distinction"}, did) def test_imu_topic_attributed_to_firmware(self): @@ -218,7 +218,7 @@ def test_exclusions(self): self.assertEqual(self.ids("docs/a.md", "The dock has two charging contacts.\n", "openamrobot-docs"), ["DOCK-NO-CONTACTS"]) self.assertEqual(self.ids("docs/a.md", "There are no charging contacts.\n", "openamrobot-docs"), []) self.assertEqual(self.ids("docs/a.md", "The drive talks RS485 to the base.\n", "openamr-platform-hw"), ["RS485-NOT-IN-2-0"]) - self.assertEqual(self.ids("docs/a.md", "The lift controller moves the arms.\n", "x"), ["LIFT-REMOVED"]) + self.assertEqual(self.ids("docs/a.md", "The lift controller moves the arms.\n", "x"), []) def test_docking_never_establishes_charging(self): self.assertEqual(self.ids("src/dock.py", "def isCharging(self): return true\n", "openamr-platform-sw"), @@ -264,6 +264,19 @@ def test_base_controller_io(self): "Base controller: STM32H723ZG on a NUCLEO-H723ZG bench board.\n"): self.assertEqual(self.ids("docs/a.md", line, repo), [], line) + def test_lift_approved_in_principle(self): + for text in ("OpenAMRobot 2.0 has no lift.\n", + "- a fixed mast for the arms\n", + "The linear lift is deferred to OpenAMRobot 3.0.\n", + "Fixed mast (lift in 3.0), mounting plates.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), ["LIFT"], text) + for text in ("Lift approved in principle: DOLD Hexalift V1 350 mm primary, TiMOTION TL3 400 mm fallback.\n", + "Lift motion only in the stowed or carry safe pose with the base stopped.\n", + "No lift motion while the base moves.\n", + "The fixed mast is superseded by the lift (P-03 rev18.7 item 8).\n", + "The lift column moves the shoulder axis from 1000 to 1350 mm.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + def test_legacy_label_exempts_compute(self): self.assertEqual(self.ids("README.md", "Legacy build: Raspberry Pi 5.\n", "openamr-platform-hw"), []) self.assertEqual(self.ids("README.md", "Compute: Raspberry Pi 5.\n", "openamr-platform-hw"), ["COMPUTE"]) From 59c016240c35a6ef2bbb12c734e61ff59c357860 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 15:19:34 +0000 Subject: [PATCH 037/129] decisions: mast entries superseded by LIFT (item 8); superseded status - tools/check_decisions.py: new status `superseded`. Such an entry names superseded_by (document, item, optional decision id) and a citation pattern; only the citation is scanned, for text still presenting the superseded value as current. `check` is required for every other status. The summary line also counts superseded entries. - MAST-INSTALL-HEIGHT, MAST-POSITIONS and MAST-TOP-HEIGHT are superseded by P-03-rev18.7 item 8 (LIFT), each with a citation pattern (mast_1350 as installation baseline or a 1350 mm installation height; mast_1300/1400/1450 or indexed mast positions; a 1500 mm mast top or the HFS6-60120 profile). - MAX-ASSEMBLED-HEIGHT stays 1700 mm, now sourced to P-03-rev18.7 item 8. - tests: test_superseded_mast_entries fails against the previous register ([] != ['MAST-INSTALL-HEIGHT']); the two schema tests for the new status fail against the previous checker. The old mast expectations are replaced. Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- decisions.yaml | 72 +++++++++++++++++------------------ tests/test_check_decisions.py | 50 ++++++++++++++++++++++-- tools/check_decisions.py | 40 +++++++++++++++++-- 3 files changed, 118 insertions(+), 44 deletions(-) diff --git a/decisions.yaml b/decisions.yaml index 047060c..9fcc932 100644 --- a/decisions.yaml +++ b/decisions.yaml @@ -22,7 +22,8 @@ # id unique, upper case, stable # title one line # kind value | configuration | limit | exclusion | distinction -# status recorded (scanned and reported) | open (not decided; not scanned) +# status recorded (scanned and reported) | open (not decided; not scanned) | +# superseded (replaced by a later decision; only its citation is scanned) # value/values exactly as the source states it; nothing invented # unit SI unit or none # date date the decision was taken (null when the source gives none) @@ -36,6 +37,11 @@ # verification machine: what the scan detects; human: reviewer role and # the evidence they need # owner role in maintainers.yaml who approves changes to this entry +# superseded_by for status superseded: document (a key under sources), item, +# optional decision (the replacing entry), citation (a pattern +# with a (?P...) group for text still presenting the +# superseded value as current) and optional unless; `check` +# is then not used # # Changing an entry follows "Changing a decision" in AGENTS.md: change request # issue, source document first, then one reviewed PR that updates this file and @@ -116,9 +122,9 @@ decisions: owner: platform-lead - id: MAST-INSTALL-HEIGHT - title: Shoulder-axis installation height of the fixed mast + title: Shoulder-axis installation height of the fixed mast (superseded by LIFT) kind: configuration - status: recorded + status: superseded value: 1350 unit: mm configuration_id: mast_1350 @@ -133,25 +139,21 @@ decisions: applies_to: repositories: ["*"] files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.xacro", "**/*.urdf", "**/*.launch.py", "**/package.xml", "**/*.html"] - check: - - pattern: '(?Pmast_1[34]00)\b[^\n]{0,60}\bbaseline' - unless: 'supersed|rev ?18\.1|release-candidate position' - message: installation baseline is mast_1350 - - pattern: '\bbaseline\b[^\n]{0,40}(?Pmast_1[34]00|\b1[34]00\s*mm)' - unless: 'supersed|rev ?18\.1' - message: installation baseline is mast_1350 - - pattern: '(?P(?:shoulder[- ]axis|shoulder height|installation height)\s*(?:at|of|is|:)?\s*1[34]00\s*mm)' - unless: 'supersed|rev ?18\.1|position' - message: installation height is 1350 mm + superseded_by: + document: P-03-rev18.7 + item: item 8 + decision: LIFT + citation: '(?P\bmast_1350\b[^\n]{0,60}\b(?:baseline|installation)|(?:shoulder[- ]axis|installation) height\s*(?:at|of|is|:)?\s*1350\s*mm)' + unless: 'supersed|legacy|historical|earlier|previous|rev ?18\.[1-6]\b' verification: - machine: text naming mast_1300 or mast_1400 (or 1300/1400 mm) as the baseline or installation height, and citations of P-03 rev18.1 item 6 + machine: text still presenting the superseded fixed-mast value as current (citation pattern) human: {reviewer: platform-lead, evidence: URDF/Xacro mast configuration and the general arrangement drawing at 1350 mm} owner: platform-lead - id: MAST-POSITIONS - title: Indexed mast mounting positions + title: Indexed mast mounting positions (superseded by LIFT) kind: configuration - status: recorded + status: superseded values: [1300, 1350, 1400, 1450] unit: mm step_mm: 50 @@ -162,25 +164,21 @@ decisions: applies_to: repositories: ["*"] files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.xacro", "**/*.urdf", "**/*.launch.py", "**/*.html"] - check: - - pattern: '\b(?Pmast_1(?:5[05]0|6[05]0|700))\b' - unless: 'supersed|not available|cannot' - message: only mast_1300, mast_1350, mast_1400 and mast_1450 exist - - pattern: '(?Pnine\s+(?:indexed\s+)?(?:mechanical\s+)?(?:mounting\s+)?positions)' - unless: 'supersed' - message: four indexed positions - - pattern: '(?P\b1300\s*(?:mm\s*)?(?:to|through|\.\.|\u2013|-)\s*1700\s*mm)' - unless: 'supersed|height envelope|assembled' - message: positions span 1300 to 1450 mm + superseded_by: + document: P-03-rev18.7 + item: item 8 + decision: LIFT + citation: '(?P\bmast_1(?:300|400|450)\b|\b(?:four|4) indexed (?:mast )?(?:mounting )?positions|\bindexed mast (?:mounting )?positions)' + unless: 'supersed|legacy|historical|earlier|previous|rev ?18\.[1-6]\b' verification: - machine: configuration IDs mast_1500 to mast_1700, "nine positions", a 1300 to 1700 mm position range + machine: text still presenting the superseded fixed-mast value as current (citation pattern) human: {reviewer: platform-lead, evidence: mast drawing with index holes} owner: platform-lead - id: MAST-TOP-HEIGHT - title: Mast top height above the floor (own COTS mast, one MISUMI HFS6-60120 profile) + title: Mast top height above the floor (own COTS mast, one MISUMI HFS6-60120 profile; superseded by LIFT) kind: value - status: recorded + status: superseded value: 1500 unit: mm date: 2026-09-28 @@ -190,12 +188,14 @@ decisions: applies_to: repositories: ["*"] files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/*.xacro", "**/*.urdf"] - check: - - pattern: '(?Ptop at 1554(?:\s*mm)?|mast top[^\n]{0,20}\b1554\s*mm)' - unless: 'supersed' - message: mast top is 1500 mm + superseded_by: + document: P-03-rev18.7 + item: item 8 + decision: LIFT + citation: '(?P\bmast top[^\n]{0,20}\b1500\s*mm|\bHFS6-60120\b)' + unless: 'supersed|legacy|historical|earlier|previous|rev ?18\.[1-6]\b' verification: - machine: the superseded 1554 mm mast top + machine: text still presenting the superseded fixed-mast value as current (citation pattern) human: {reviewer: platform-lead, evidence: mast drawing and CAD} owner: platform-lead @@ -206,8 +206,8 @@ decisions: value: 1700 unit: mm date: null - source: {document: P-03-rev18.1, item: item 6 line 15} - note: Repeated unchanged in P-03-rev18.2 as cited by the documentation site. The robot may be lower, never higher. + source: {document: P-03-rev18.7, item: item 8} + note: First recorded in P-03-rev18.1 item 6 line 15 and repeated in rev18.2; kept unchanged by rev18.7 item 8 with the lift. The robot may be lower, never higher. applies_to: repositories: ["*"] files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/*.xacro", "**/*.urdf"] diff --git a/tests/test_check_decisions.py b/tests/test_check_decisions.py index a48084a..9bedfc0 100644 --- a/tests/test_check_decisions.py +++ b/tests/test_check_decisions.py @@ -161,6 +161,32 @@ def test_rejects_invalid_entries(self): with self.assertRaisesRegex(cd.DecisionError, expected): cd.load_decisions(self.write(text)) + SUPERSEDED = BASE.replace("status: recorded", "status: superseded").replace( + " check: [{pattern: '(?Px)'}]\n", + " superseded_by: {document: D, item: j, decision: B, citation: '(?Pold)', unless: 'history'}\n") + + def test_superseded_entry_scans_only_its_citation(self): + (d,) = cd.load_decisions(self.write(self.SUPERSEDED), ROOT / "maintainers.yaml") + with tempfile.TemporaryDirectory() as tmp: + Path(tmp, "a.md").write_text("x here\nold value\nold value in history\n", encoding="utf-8") + findings, _ = cd.scan(tmp, [d], "r") + self.assertEqual([(f["line"], f["found"]) for f in findings], [(2, "old")]) + self.assertIn("superseded by D j (B)", findings[0]["message"]) + self.assertEqual(findings[0]["decided"], "superseded by B") + + def test_superseded_entry_needs_superseded_by(self): + cases = { + "superseded needs superseded_by": self.SUPERSEDED.replace( + " superseded_by: {document: D, item: j, decision: B, citation: '(?Pold)', unless: 'history'}\n", ""), + "superseded_by document 'E' not listed": self.SUPERSEDED.replace("superseded_by: {document: D", "superseded_by: {document: E"), + "superseded_by citation needs": self.SUPERSEDED.replace("'(?Pold)'", "'old'"), + "missing check": self.BASE.replace(" check: [{pattern: '(?Px)'}]\n", ""), + } + for expected, text in cases.items(): + with self.subTest(expected=expected): + with self.assertRaisesRegex(cd.DecisionError, expected): + cd.load_decisions(self.write(text)) + def test_owner_and_reviewer_must_be_maintainers_roles(self): with self.assertRaisesRegex(cd.DecisionError, "owner 'somebody' is not a role"): cd.load_decisions(self.write(self.BASE.replace("owner: platform-lead", "owner: somebody")), @@ -207,10 +233,26 @@ def test_shoulder_height_versus_envelope(self): self.assertEqual(self.ids("docs/a.md", "Maximum assembled height 1700 mm, not a shoulder height.\n", "openamrobot-docs"), []) - def test_superseded_mast_values_and_citation(self): - self.assertEqual(self.ids("docs/a.md", "The mast_1400 slot is the baseline.\nUse mast_1600.\n", "x"), - ["MAST-INSTALL-HEIGHT", "MAST-POSITIONS"]) - self.assertEqual(self.ids("docs/a.md", "Height per P-03 rev18.1 item 6.\n", "x"), ["MAST-INSTALL-HEIGHT"]) + def test_superseded_mast_entries(self): + cases = { + "Shoulder-axis installation height 1350 mm.\n": ["MAST-INSTALL-HEIGHT"], + "The mast_1350 slot is the installation baseline.\n": ["MAST-INSTALL-HEIGHT"], + "Four indexed mast positions, mast_1300 to mast_1450.\n": ["MAST-POSITIONS"], + "Mast top at 1500 mm on one MISUMI HFS6-60120 profile.\n": ["MAST-TOP-HEIGHT"], + "Maximum assembled height 1600 mm.\n": ["MAX-ASSEMBLED-HEIGHT"], + } + for text, expected in cases.items(): + self.assertEqual(self.ids("docs/a.md", text, "x"), expected, text) + for text in ("Shoulder axis 1000 to 1350 mm on the lift.\n", + "The superseded mast_1350 installation baseline (P-03 rev18.2).\n", + "Maximum assembled height 1700 mm, not a shoulder height.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + by_id = {d["id"]: d for d in self.decisions} + for did in ("MAST-INSTALL-HEIGHT", "MAST-POSITIONS", "MAST-TOP-HEIGHT"): + self.assertEqual(by_id[did]["status"], "superseded", did) + self.assertEqual((by_id[did]["superseded_by"]["document"], by_id[did]["superseded_by"]["item"]), + ("P-03-rev18.7", "item 8"), did) + self.assertEqual(by_id["MAX-ASSEMBLED-HEIGHT"]["source"]["document"], "P-03-rev18.7") def test_exclusions(self): self.assertEqual(self.ids("docs/a.md", "The base uses sprung drive wheels.\n", "x"), ["NO-SUSPENSION"]) diff --git a/tools/check_decisions.py b/tools/check_decisions.py index 0f78431..da30bbb 100644 --- a/tools/check_decisions.py +++ b/tools/check_decisions.py @@ -11,6 +11,11 @@ reviewer and evidence for that. Exit status: 0 clean, 1 contradiction found, 2 invalid register or usage error. +An entry with status `superseded` names the decision that replaced it in +`superseded_by` (document, item, optional decision id) and a `citation` +pattern; only that pattern is scanned, for text still presenting the +superseded value as current. + A line that must keep a superseded value (history, changelog, legacy material) carries the marker `decision-allow: ` on the same line or the line directly above it. The marker is reported, never silently ignored. @@ -26,9 +31,9 @@ import yaml SCHEMA_VERSION = 1 -STATUSES = {"recorded", "open"} +STATUSES = {"recorded", "open", "superseded"} KINDS = {"value", "configuration", "limit", "exclusion", "distinction"} -REQUIRED = ("id", "title", "kind", "status", "date", "source", "applies_to", "check", +REQUIRED = ("id", "title", "kind", "status", "date", "source", "applies_to", "verification", "owner") DEFAULT_FILES = [ "**/*.md", "**/*.yaml", "**/*.yml", "**/*.launch.py", "**/*.launch.xml", @@ -65,6 +70,8 @@ def load_decisions(path, maintainers=None): errors.append(f"{where}: not a mapping") continue missing = [key for key in REQUIRED if key not in d] + if d.get("status") != "superseded" and "check" not in d: + missing.append("check") if missing: errors.append(f"{where}: missing {', '.join(missing)}") continue @@ -104,6 +111,19 @@ def load_decisions(path, maintainers=None): applies = d["applies_to"] if not isinstance(applies, dict) or not applies.get("repositories") or not applies.get("files"): errors.append(f"{where}: applies_to needs repositories and files") + if d["status"] == "superseded": + by = d.get("superseded_by") + if not isinstance(by, dict) or not by.get("document") or not by.get("item") or not by.get("citation"): + errors.append(f"{where}: superseded needs superseded_by with document, item and citation") + else: + if by["document"] not in sources: + errors.append(f"{where}: superseded_by document {by['document']!r} not listed under sources") + try: + if "found" not in re.compile(by["citation"], re.IGNORECASE).groupindex: + errors.append(f"{where}: superseded_by citation needs a (?P...) group") + except re.error as exc: + errors.append(f"{where}: bad superseded_by citation pattern: {exc}") + continue checks = d["check"] if not isinstance(checks, list) or not checks: errors.append(f"{where}: check must be a non-empty list of patterns") @@ -122,6 +142,14 @@ def load_decisions(path, maintainers=None): exclude = data.get("exclude") or [] for d in decisions: d["_exclude"] = list(exclude) + list(d["applies_to"].get("exclude") or []) + if d["status"] == "superseded": + by = d["superseded_by"] + # A superseded entry is scanned only for text that still presents it as current. + d["_checks"] = [{"pattern": by["citation"], "unless": by.get("unless"), + "message": f"superseded decision presented as current; superseded by " + f"{by['document']} {by['item']}" + + (f" ({by['decision']})" if by.get("decision") else "")}] + continue d["_checks"] = list(d["check"]) + [ {"pattern": sup["citation"], "unless": sup.get("citation_unless"), "message": f"superseded source still cited ({sup['source']}); cite {d['source']['document']}"} @@ -130,6 +158,9 @@ def load_decisions(path, maintainers=None): def decided_text(d): + if d["status"] == "superseded": + by = d["superseded_by"] + return f"superseded by {by.get('decision') or by['document'] + ' ' + by['item']}" value = d["values"] if "values" in d else d["value"] if isinstance(value, list): value = ", ".join(str(v) for v in value) @@ -177,7 +208,7 @@ def scan(root, decisions, repository=None, only=None, stats=None): files = list(only) if only is not None else list_files(root) findings, allowed = [], [] unscanned = 0 - active = [d for d in decisions if d["status"] == "recorded" and repository_matches(d, repository)] + active = [d for d in decisions if d["status"] in ("recorded", "superseded") and repository_matches(d, repository)] for rel in files: path = root / rel if not path.is_file(): @@ -246,7 +277,8 @@ def main(argv=None): return 2 print(f"decisions: {len(decisions)} loaded, " f"{sum(d['status'] == 'recorded' for d in decisions)} recorded and scanned, " - f"{sum(d['status'] == 'open' for d in decisions)} open") + f"{sum(d['status'] == 'open' for d in decisions)} open, " + f"{sum(d['status'] == 'superseded' for d in decisions)} superseded (citations scanned)") if a.validate_only: return 0 if not a.root.is_dir(): From f22ac1535b92c329b06ceedc8b4d5dec3e2831a6 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 15:19:59 +0000 Subject: [PATCH 038/129] decisions: DATUM-HEIGHT-STACK (P-03 rev18.7 item 15) New entry: floor Z = 0; steel chassis deck top 294 mm (MMP STEP); top cover 2 mm plastic or optional 0.5 to 0.8 mm sheet metal; 10 mm aluminium lift base plate bearing on the steel deck with the cover cut out around it; base-plate top face 304 mm is the reference for lift and shoulder heights. The check flags another deck-top height or base-plate top face. test_datum_height_stack fails against the previous register ([] != ['DATUM-HEIGHT-STACK']). Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- decisions.yaml | 26 ++++++++++++++++++++++++++ tests/test_check_decisions.py | 7 +++++++ 2 files changed, 33 insertions(+) diff --git a/decisions.yaml b/decisions.yaml index 9fcc932..33778ea 100644 --- a/decisions.yaml +++ b/decisions.yaml @@ -222,6 +222,32 @@ decisions: human: {reviewer: platform-lead, evidence: assembled-height measurement on the general arrangement} owner: platform-lead + - id: DATUM-HEIGHT-STACK + title: Height datum and base height stack + kind: configuration + status: recorded + values: + - floor Z = 0 + - steel chassis deck top 294 mm (MMP STEP) + - top cover 2 mm plastic, or optional 0.5 to 0.8 mm sheet metal + - 10 mm aluminium lift base plate bears on the steel deck; the cover is cut out around it + - base-plate top face 304 mm is the reference for lift and shoulder heights + unit: mm + date: 2026-10-06 + source: {document: P-03-rev18.7, item: item 15} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/*.xacro", "**/*.urdf"]} + check: + - pattern: '(?P\bdeck top\b[^\n]{0,25}?\b(?!294\b)\d{3}\s*mm)' + unless: 'supersed|historical|legacy|earlier|previous' + message: the steel chassis deck top is 294 mm above the floor (P-03 rev18.7 item 15) + - pattern: '(?P\bbase[- ]plate top(?: face)?\b[^\n]{0,25}?\b(?!304\b)\d{3}\s*mm)' + unless: 'supersed|historical|legacy|earlier|previous' + message: the lift base-plate top face is 304 mm above the floor, the reference for lift and shoulder heights (P-03 rev18.7 item 15) + verification: + machine: a deck-top height other than 294 mm or a base-plate top face other than 304 mm + human: {reviewer: platform-lead, evidence: MMP STEP deck height and the lift base-plate drawing} + owner: platform-lead + - id: BATTERY-PLACEMENT title: Battery pack and placement kind: value diff --git a/tests/test_check_decisions.py b/tests/test_check_decisions.py index 9bedfc0..f983fdc 100644 --- a/tests/test_check_decisions.py +++ b/tests/test_check_decisions.py @@ -319,6 +319,13 @@ def test_lift_approved_in_principle(self): "The lift column moves the shoulder axis from 1000 to 1350 mm.\n"): self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + def test_datum_height_stack(self): + for text in ("The steel chassis deck top is at 300 mm.\n", "Base-plate top face 310 mm above the floor.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), ["DATUM-HEIGHT-STACK"], text) + for text in ("Steel chassis deck top 294 mm (MMP STEP); floor Z = 0.\n", + "Base-plate top face 304 mm, the reference for lift and shoulder heights.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + def test_legacy_label_exempts_compute(self): self.assertEqual(self.ids("README.md", "Legacy build: Raspberry Pi 5.\n", "openamr-platform-hw"), []) self.assertEqual(self.ids("README.md", "Compute: Raspberry Pi 5.\n", "openamr-platform-hw"), ["COMPUTE"]) From 9440797cbf606cc091c2d518538909dd713bb6c6 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 15:20:19 +0000 Subject: [PATCH 039/129] decisions: FRAMES-REP105 (P-03 rev18.7 item 15) New entry: base_footprint on the floor under the drive-axle midpoint; base_link at the axle midpoint and axle height, x forward, z up; imu_link on the centreline away from motor magnetic fields. The check flags base_link on the floor, base_footprint at axle height and imu_link next to a motor. Software lead reviews the URDF and real TF. test_frames_rep105 fails against the previous register ([] != ['FRAMES-REP105']). Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- decisions.yaml | 27 +++++++++++++++++++++++++++ tests/test_check_decisions.py | 9 +++++++++ 2 files changed, 36 insertions(+) diff --git a/decisions.yaml b/decisions.yaml index 33778ea..b14312d 100644 --- a/decisions.yaml +++ b/decisions.yaml @@ -248,6 +248,33 @@ decisions: human: {reviewer: platform-lead, evidence: MMP STEP deck height and the lift base-plate drawing} owner: platform-lead + - id: FRAMES-REP105 + title: Base frames follow REP 105 + kind: configuration + status: recorded + values: + - base_footprint on the floor under the drive-axle midpoint + - base_link at the drive-axle midpoint and axle height; x forward, z up + - imu_link on the centreline, away from motor magnetic fields + unit: none + date: 2026-10-06 + source: {document: P-03-rev18.7, item: item 15} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/*.xacro", "**/*.urdf", "**/*.launch.py"]} + check: + - pattern: '(?P\bbase_link\b[^\n]{0,30}\b(?:on|at) the floor\b)' + unless: 'base_footprint|\bnot\b|never|supersed|historical|legacy' + message: base_link is at the drive-axle midpoint and axle height; base_footprint is on the floor (P-03 rev18.7 item 15) + - pattern: '(?P\bbase_footprint\b[^\n]{0,30}\bat (?:the )?axle height\b)' + unless: '\bnot\b|never|supersed|historical|legacy' + message: base_footprint is on the floor under the drive-axle midpoint (P-03 rev18.7 item 15) + - pattern: '(?P\bimu_link\b[^\n]{0,40}\b(?:next to|beside|on|near|above) the (?:drive |hub |wheel )?motors?\b)' + unless: '\baway\b|\bnot\b|never|supersed|historical|legacy' + message: imu_link sits on the centreline away from motor magnetic fields (P-03 rev18.7 item 15) + verification: + machine: base_link placed on the floor, base_footprint at axle height, or imu_link next to a motor + human: {reviewer: software-lead, evidence: URDF frame tree and real TF on the robot matching REP 105} + owner: platform-lead + - id: BATTERY-PLACEMENT title: Battery pack and placement kind: value diff --git a/tests/test_check_decisions.py b/tests/test_check_decisions.py index f983fdc..43e1cb5 100644 --- a/tests/test_check_decisions.py +++ b/tests/test_check_decisions.py @@ -326,6 +326,15 @@ def test_datum_height_stack(self): "Base-plate top face 304 mm, the reference for lift and shoulder heights.\n"): self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + def test_frames_rep105(self): + for text in ("base_link sits on the floor under the robot.\n", + "base_footprint is at axle height.\n", + "imu_link is mounted next to the drive motor.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), ["FRAMES-REP105"], text) + for text in ("base_footprint on the floor under the drive-axle midpoint; base_link at axle height, x forward, z up.\n", + "imu_link on the centreline, away from the motor magnetic fields.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + def test_legacy_label_exempts_compute(self): self.assertEqual(self.ids("README.md", "Legacy build: Raspberry Pi 5.\n", "openamr-platform-hw"), []) self.assertEqual(self.ids("README.md", "Compute: Raspberry Pi 5.\n", "openamr-platform-hw"), ["COMPUTE"]) From 0a119aa8b83966336e7c4e06aa781aa8aa337df6 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 15:20:48 +0000 Subject: [PATCH 040/129] decisions: BOM-ISSUE-IN-FORCE is Issue 7.3 (P-03 rev18.7) - BOM-ISSUE-IN-FORCE: OpenAMRobot 2.0 Detailed Hardware BOM, Issue 7.3, source P-03-rev18.7; Issue 7 (P-03-rev18.2 item 7) is added to its supersedes history. A new check flags Issue 7 or another 7.x presented as canonical, in force or of record, unless labelled superseded, replaced, previous, earlier or historical. - sources: BOM-Issue-7.3 added; BOM-Issue-7 marked as the earlier issue. The rev18.7 item number for the BOM issue was not supplied, so the source item reads "BOM issue in force". - tests: test_bom_issue_7_3_in_force fails against the previous register ([] != ['BOM-ISSUE-IN-FORCE']). Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- decisions.yaml | 19 +++++++++++++------ tests/test_check_decisions.py | 8 +++++++- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/decisions.yaml b/decisions.yaml index b14312d..6ac6eb9 100644 --- a/decisions.yaml +++ b/decisions.yaml @@ -77,8 +77,11 @@ sources: P-00-rev18.1: title: P-00 Master Coordination Plan, revision 18.1 evidence: plan-set document, not held in any repository; cited by text line + BOM-Issue-7.3: + title: OpenAMRobot 2.0 Detailed Hardware BOM, Issue 7.3 (canonical per P-03 rev18.7) + evidence: not held in any repository; provenance only, CI never reads it BOM-Issue-7: - title: OpenAMRobot 2.0 Detailed Hardware BOM, Issue 7, 27 September 2026 (canonical per P-03 rev18.2 item 7) + title: OpenAMRobot 2.0 Detailed Hardware BOM, Issue 7, 27 September 2026 (earlier issue; canonical per P-03 rev18.2 item 7 until Issue 7.3) evidence: not held in any repository; provenance only, CI never reads it I8-WP: title: I8 base-controller status work package @@ -99,11 +102,12 @@ decisions: title: Canonical hardware BOM issue for OpenAMRobot 2.0 kind: value status: recorded - value: OpenAMRobot 2.0 Detailed Hardware BOM, Issue 7 + value: OpenAMRobot 2.0 Detailed Hardware BOM, Issue 7.3 unit: none - date: 2026-09-28 - source: {document: P-03-rev18.2, item: item 7} + date: 2026-10-06 + source: {document: P-03-rev18.7, item: BOM issue in force} supersedes: + - {value: Issue 7, source: P-03-rev18.2 item 7} - value: Issue 6, or an explicitly approved successor source: P-03-rev18.1 line 7 citation: '(?PP-03[^\n]{0,30}(?:rev(?:ision)?\.?\s*)?18\.1[^\n]{0,30}line\s*7)' @@ -112,12 +116,15 @@ decisions: check: - pattern: '(?P(?:canonical|in force|of record)[^\n]{0,40}\bIssue\s*6\b|\bIssue\s*6\b[^\n]{0,40}\b(?:canonical|in force|of record))' unless: 'supersed|replaced|previous|earlier' - message: BOM Issue 7 is the canonical hardware BOM (P-03 rev18.2 item 7) + message: BOM Issue 7.3 is the canonical hardware BOM (P-03 rev18.7) - pattern: '(?PB-01[^\n]{0,60}\b(?:canonical|current))' unless: 'supersed' message: B-01 is superseded + - pattern: '(?P(?:canonical|in force|of record)[^\n]{0,40}\bIssue\s*7(?!\.3)(?:\.\d+)?\b|\bIssue\s*7(?!\.3)(?:\.\d+)?\b[^\n]{0,40}\b(?:canonical|in force|of record))' + unless: 'supersed|replaced|previous|earlier|historical' + message: BOM Issue 7.3 is the canonical hardware BOM (P-03 rev18.7) verification: - machine: Issue 6 or B-01 presented as the canonical BOM; citations of P-03 rev18.1 line 7 + machine: Issue 6, Issue 7 (other than 7.3) or B-01 presented as the canonical BOM; citations of P-03 rev18.1 line 7 human: {reviewer: platform-lead, evidence: the BOM file header and issue number in the released BOM} owner: platform-lead diff --git a/tests/test_check_decisions.py b/tests/test_check_decisions.py index 43e1cb5..7be168b 100644 --- a/tests/test_check_decisions.py +++ b/tests/test_check_decisions.py @@ -279,7 +279,13 @@ def test_estop_recommendation(self): def test_bom_issue_in_force(self): self.assertEqual(self.ids("docs/a.md", "The canonical BOM is Issue 6.\n", "x"), ["BOM-ISSUE-IN-FORCE"]) self.assertEqual(self.ids("docs/a.md", "BOM per P-03 rev18.1 line 7.\n", "x"), ["BOM-ISSUE-IN-FORCE"]) - self.assertEqual(self.ids("docs/a.md", "Issue 7 is canonical; Issue 6 is superseded.\n", "x"), []) + self.assertEqual(self.ids("docs/a.md", "Issue 7.3 is canonical; Issue 6 is superseded.\n", "x"), []) + + def test_bom_issue_7_3_in_force(self): + for text in ("The canonical BOM is Issue 7.\n", "BOM Issue 7.2 is the BOM of record.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), ["BOM-ISSUE-IN-FORCE"], text) + for text in ("BOM Issue 7.3 is canonical.\n", "Issue 7 was canonical until Issue 7.3 superseded it.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) def test_nav_lidar(self): repo = "openamr-platform-sw" From 4f6d8444c90fafc1c40bb9b26f59b65775678c7d Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 15:21:12 +0000 Subject: [PATCH 041/129] decisions: BATTERY-PLACEMENT near the rear edge (P-03 rev18.7 item 9) The pack (8S1P EVE LF105, 25.6 V, 105 Ah) is placed as close to the rear edge as practical while preserving enclosure, service and safety clearances. The 25 percent centre position (P-03 rev18.2) moves to the supersedes history. The check now flags any battery centred at a fixed percentage, including 25 percent, unless labelled superseded or historical. test_battery_placement_rear_edge fails against the previous register ([] != ['BATTERY-PLACEMENT']). Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- decisions.yaml | 16 +++++++++------- tests/test_check_decisions.py | 7 +++++++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/decisions.yaml b/decisions.yaml index 6ac6eb9..bf7b61a 100644 --- a/decisions.yaml +++ b/decisions.yaml @@ -286,21 +286,23 @@ decisions: title: Battery pack and placement kind: value status: recorded - value: One 8S1P EVE LF105 LiFePO4 pack, 25.6 V, 105 Ah, centred at 25 percent of the robot length from the rear + value: One 8S1P EVE LF105 LiFePO4 pack, 25.6 V, 105 Ah, placed as close to the rear edge as practical while preserving enclosure, service and safety clearances unit: none - date: 2026-09-28 - source: {document: P-03-rev18.2, item: battery} + date: 2026-10-06 + source: {document: P-03-rev18.7, item: item 9} supersedes: - {value: fit the existing battery bay first, source: P-00-rev18.1 txt 77} + - {value: centred at 25 percent of the robot length from the rear, source: P-03-rev18.2 battery} applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} check: - - pattern: 'battery[^\n]{0,40}\bcent(?:red|ered) at (?P(?!25\b)\d+\s*(?:%|percent))' - message: battery is centred at 25 percent of the length from the rear + - pattern: '(?Pbattery[^\n]{0,40}\bcent(?:red|ered) at \d+\s*(?:%|percent)|\b25\s*(?:%|percent) of the (?:robot )?length from the rear)' + unless: 'supersed|historical|previous|earlier|rev ?18\.[1-6]\b' + message: the battery sits as close to the rear edge as practical while preserving enclosure, service and safety clearances (P-03 rev18.7 item 9) verification: - machine: a different battery centre position in text + machine: a battery centred at a fixed percentage of the length, including the superseded 25 percent position human: {reviewer: platform-lead, evidence: general arrangement and mass model} owner: platform-lead - note: The rev18.1 plan set did not record a placement; P-03 rev18.2 records it. + note: The rev18.1 plan set did not record a placement; P-03 rev18.2 recorded 25 percent; rev18.7 item 9 replaces it. - id: SPEED-CEILING title: 1.5 m/s is a command ceiling, not an operating speed diff --git a/tests/test_check_decisions.py b/tests/test_check_decisions.py index 7be168b..3119835 100644 --- a/tests/test_check_decisions.py +++ b/tests/test_check_decisions.py @@ -341,6 +341,13 @@ def test_frames_rep105(self): "imu_link on the centreline, away from the motor magnetic fields.\n"): self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + def test_battery_placement_rear_edge(self): + self.assertEqual(self.ids("docs/a.md", "The battery is centred at 25 percent of the length from the rear.\n", "x"), + ["BATTERY-PLACEMENT"]) + for text in ("The battery sits as close to the rear edge as practical, keeping service clearances.\n", + "The battery was centred at 25 percent (superseded by P-03 rev18.7 item 9).\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + def test_legacy_label_exempts_compute(self): self.assertEqual(self.ids("README.md", "Legacy build: Raspberry Pi 5.\n", "openamr-platform-hw"), []) self.assertEqual(self.ids("README.md", "Compute: Raspberry Pi 5.\n", "openamr-platform-hw"), ["COMPUTE"]) From cd6d72db4b6706a7362a95d17d048749d71fd6ad Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 15:21:42 +0000 Subject: [PATCH 042/129] decisions: BASE-CONTROLLER-IO per P-03 rev18.7 item 14 - Two MaxBotix MB7060 serial sensors, each on one dedicated STM32 UART, 9600 8N1; no sensor I2C off the controller board. MB7040 on I2C (P-03-rev18.5 item 14) moves to the supersedes history. - CAN3 is reserved for upper-body auxiliary actuators (CANopen, no safety function). - MCU to Jetson over Ethernet, micro-ROS over UDP; USB for the bench only. - Source is now P-03-rev18.7 item 14. New checks flag MB7040 without a superseded or legacy label, and micro-ROS over USB or serial outside the bench or Gate A (Teensy). - tests: test_base_controller_io_rev18_7 fails against the previous register ([] != ['BASE-CONTROLLER-IO']). Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- decisions.yaml | 19 +++++++++++++------ tests/test_check_decisions.py | 8 ++++++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/decisions.yaml b/decisions.yaml index bf7b61a..b6c92b9 100644 --- a/decisions.yaml +++ b/decisions.yaml @@ -406,23 +406,30 @@ decisions: values: - Base controller STM32H723ZG on NUCLEO-H723ZG - TF-Luna on UART, one per UART - - MB7040 one per I2C bus + - two MaxBotix MB7060 serial sensors, each on one dedicated STM32 UART, 9600 8N1; no sensor I2C off the controller board - IMU on a dedicated SPI, Mode 0 - - CAN1 traction only, CAN2 BMS, CAN3 upper-body actuators - - micro-ROS over Ethernet + - CAN1 traction only, CAN2 BMS, CAN3 reserved for upper-body auxiliary actuators (CANopen, no safety function) + - MCU to Jetson over Ethernet, micro-ROS over UDP; USB for the bench only unit: none date: 2026-10-06 - source: {document: P-03-rev18.5, item: item 14} + source: {document: P-03-rev18.7, item: item 14} supersedes: - {value: STM32H743 on NUCLEO-H743ZI2, source: P-00-rev18.1 (Gate B bench board)} + - {value: MB7040 one per I2C bus, source: P-03-rev18.5 item 14} applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} check: - pattern: '(?PNUCLEO-H743ZI2|STM32H743)' unless: 'supersed|legacy|historical|replaced' message: the base controller is the STM32H723ZG on the NUCLEO-H723ZG; label STM32H743 / NUCLEO-H743ZI2 material as superseded + - pattern: '(?P\bMB7040\b)' + unless: 'supersed|legacy|historical|replaced' + message: the ultrasonic sensors are two MaxBotix MB7060 on dedicated STM32 UARTs at 9600 8N1; no sensor I2C (P-03 rev18.7 item 14) + - pattern: '(?P\bmicro-?ROS\b[^\n]{0,30}\bover (?:USB|serial)\b)' + unless: 'bench|legacy|Gate A|Teensy|supersed|historical' + message: micro-ROS runs over UDP on Ethernet between MCU and Jetson; USB is for the bench only (P-03 rev18.7 item 14) verification: - machine: NUCLEO-H743ZI2 or STM32H743 without a superseded, legacy, historical or replaced label - human: {reviewer: platform-lead, evidence: P-03 rev18.5 item 14 and the MCU I/O allocation in openamr-platform-hw} + machine: NUCLEO-H743ZI2 or STM32H743 without a superseded, legacy, historical or replaced label; MB7040 without such a label; micro-ROS over USB or serial outside the bench or Gate A + human: {reviewer: platform-lead, evidence: P-03 rev18.7 item 14 and the MCU I/O allocation in openamr-platform-hw} owner: platform-lead - id: IMU-TOPIC-OWNERSHIP diff --git a/tests/test_check_decisions.py b/tests/test_check_decisions.py index 3119835..95d6755 100644 --- a/tests/test_check_decisions.py +++ b/tests/test_check_decisions.py @@ -348,6 +348,14 @@ def test_battery_placement_rear_edge(self): "The battery was centred at 25 percent (superseded by P-03 rev18.7 item 9).\n"): self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + def test_base_controller_io_rev18_7(self): + for text in ("Two MB7040 sensors, one per I2C bus.\n", "micro-ROS over USB to the Jetson.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), ["BASE-CONTROLLER-IO"], text) + for text in ("Two MaxBotix MB7060 sensors, each on a dedicated STM32 UART at 9600 8N1.\n", + "MCU to Jetson over Ethernet, micro-ROS over UDP; micro-ROS over USB on the bench only.\n", + "MB7040 on I2C is superseded.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + def test_legacy_label_exempts_compute(self): self.assertEqual(self.ids("README.md", "Legacy build: Raspberry Pi 5.\n", "openamr-platform-hw"), []) self.assertEqual(self.ids("README.md", "Compute: Raspberry Pi 5.\n", "openamr-platform-hw"), ["COMPUTE"]) From fc303898b9460e832f091fc836f3df5b951fef2d Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 15:22:38 +0000 Subject: [PATCH 043/129] decisions: head camera recorded as ZED Mini; base camera tilt (item 15) - HEAD-CAMERA-IDENTITY is closed as recorded: Stereolabs ZED Mini, SKU ZED-121210, supplied with the OpenArm 2.0 set, on the lift carriage, pitch 15 to 35 degrees down in 5 degree steps, baseline 25 degrees (P-03-rev18.7 item 15). Its check now flags other ZED models (ZED 2, ZED 2i, ZED X, ZED X Mini) without a superseded or legacy label; the former "ZED Mini" open-check is gone. - CAMERAS: base Orbbec Gemini 336L about 243 mm above the floor with up-tilt positions 5, 10 and 15 degrees, baseline 10; head camera named as the ZED Mini; source P-03-rev18.7 item 15. A new check flags another Gemini 336L up-tilt. - tests: test_head_camera_recorded_and_base_camera_tilt fails against the previous register ([] != ['HEAD-CAMERA-IDENTITY']). Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- decisions.yaml | 41 ++++++++++++++++++++++------------- tests/test_check_decisions.py | 9 ++++++++ 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/decisions.yaml b/decisions.yaml index b6c92b9..efc2061 100644 --- a/decisions.yaml +++ b/decisions.yaml @@ -462,12 +462,14 @@ decisions: kind: value status: recorded values: - - base camera Orbbec Gemini 336L, fixed to base_link on the front face, tilted about 10 degrees upward - - head camera ZED-121210 (exact commercial identity open, see HEAD-CAMERA-IDENTITY) + - base camera Orbbec Gemini 336L, fixed to base_link on the front face about 243 mm above the floor; up-tilt positions 5, 10 and 15 degrees, baseline 10 degrees + - head camera Stereolabs ZED Mini (SKU ZED-121210) on the lift carriage, see HEAD-CAMERA-IDENTITY - two in-hand RGB wrist cameras; no separate torso scene camera unit: none - date: 2026-09-23 - source: {document: P-00-rev18.1, item: txt 85 and 97} + date: 2026-10-06 + source: {document: P-03-rev18.7, item: "item 15 (wrist cameras as in P-00-rev18.1 txt 85 and 97)"} + supersedes: + - {value: "base camera tilted about 10 degrees upward; head camera identity open", source: P-00-rev18.1 txt 85 and 97} applies_to: repositories: ["openamr-*", "openamrobot-docs", "openamrobot-manipulation", "openamrobot-ui"] files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/*.launch.py", "**/*.xacro", "**/*.urdf"] @@ -480,26 +482,35 @@ decisions: - pattern: '(?PIMX708|Pi Camera Module 3)' unless: 'legacy|Gate A|historical' message: the 2.0 base camera is the Orbbec Gemini 336L + - pattern: '(?PGemini 336L[^\n]{0,60}?\b(?!(?:5|10|15)\b)\d{1,2}\s*(?:°|deg(?:rees?)?)\s*(?:up|upward)\b)' + unless: 'supersed|historical|legacy' + message: the Gemini 336L up-tilt positions are 5, 10 and 15 degrees, baseline 10 (P-03 rev18.7 item 15) verification: - machine: other camera models without a legacy label; a down-tilted base camera + machine: other camera models without a legacy label; a down-tilted base camera; a Gemini 336L up-tilt other than 5, 10 or 15 degrees human: {reviewer: software-lead, evidence: camera joint rpy in URDF and real TF showing the upward tilt} owner: platform-lead - id: HEAD-CAMERA-IDENTITY - title: Head camera commercial identity + title: Head camera commercial identity and mounting kind: value - status: open - value: ZED-121210 per the plan; the general arrangement names a different ZED model + status: recorded + values: + - Stereolabs ZED Mini, SKU ZED-121210, supplied with the OpenArm 2.0 set + - mounted on the lift carriage + - pitch 15 to 35 degrees down in 5 degree steps, baseline 25 degrees unit: none - date: null - source: {document: P-00-rev18.1, item: txt 85} - applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.html"]} + date: 2026-10-06 + source: {document: P-03-rev18.7, item: item 15} + supersedes: + - {value: "ZED-121210 per the plan; identity open because the general arrangement named a different ZED model", source: P-00-rev18.1 txt 85} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.html", "**/*.yaml", "**/*.yml", "**/*.xacro", "**/*.urdf"]} check: - - pattern: '(?PZED[ -]?mini)' - message: head camera identity is open + - pattern: '(?P\bZED[ -]?(?:2i?|X(?:[ -]?Mini)?)\b)' + unless: 'supersed|historical|legacy|replaced|\bnot\b' + message: the head camera is the Stereolabs ZED Mini, SKU ZED-121210 (P-03 rev18.7 item 15) verification: - machine: none while open - human: {reviewer: platform-lead, evidence: supplier SKU and interface confirmation} + machine: another ZED model (ZED 2, ZED 2i, ZED X, ZED X Mini) without a superseded or legacy label + human: {reviewer: platform-lead, evidence: supplier SKU on the OpenArm 2.0 set and the lift-carriage mount drawing with the pitch steps} owner: platform-lead - id: POWER-RAILS diff --git a/tests/test_check_decisions.py b/tests/test_check_decisions.py index 95d6755..1f9542b 100644 --- a/tests/test_check_decisions.py +++ b/tests/test_check_decisions.py @@ -356,6 +356,15 @@ def test_base_controller_io_rev18_7(self): "MB7040 on I2C is superseded.\n"): self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + def test_head_camera_recorded_and_base_camera_tilt(self): + for text in ("Head camera: ZED 2i on the mast.\n", "The head camera is a ZED X Mini.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), ["HEAD-CAMERA-IDENTITY"], text) + self.assertEqual(self.ids("docs/a.md", "The Gemini 336L is tilted 20 degrees up.\n", "openamrobot-docs"), + ["CAMERAS"]) + for text in ("Stereolabs ZED Mini (SKU ZED-121210) on the lift carriage, pitch 25 degrees down.\n", + "Gemini 336L about 243 mm above the floor, tilted 10 degrees up (positions 5, 10, 15).\n"): + self.assertEqual(self.ids("docs/a.md", text, "openamrobot-docs"), [], text) + def test_legacy_label_exempts_compute(self): self.assertEqual(self.ids("README.md", "Legacy build: Raspberry Pi 5.\n", "openamr-platform-hw"), []) self.assertEqual(self.ids("README.md", "Compute: Raspberry Pi 5.\n", "openamr-platform-hw"), ["COMPUTE"]) From 2f976182b69b5dd6243199f4fc5e76cd057200e5 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 15:23:01 +0000 Subject: [PATCH 044/129] decisions: RELEASE-MILESTONES flags v0.2 and a 13 November cycle end Two new patterns in RELEASE-MILESTONES flag a v0.2 release (or a v0.2 readiness declaration) and a development cycle ending 13 November 2026, unless the line says superseded, historical, earlier or previous. Development cycle 2 ends 20 November 2026 with v2.0.0-rc.1; v2.0.0 follows on 18 December 2026. test_release_milestones_no_v0_2_or_13_november fails against the previous register ([] != ['RELEASE-MILESTONES']). Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- decisions.yaml | 8 +++++++- tests/test_check_decisions.py | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/decisions.yaml b/decisions.yaml index efc2061..4b51d64 100644 --- a/decisions.yaml +++ b/decisions.yaml @@ -697,8 +697,14 @@ decisions: - pattern: '(?P2\.0 final release[^\n]{0,20}?\b(?!18 December 2026)\d{1,2} (?:January|February|March|April|May|June|July|August|September|October|November|December) \d{4})' unless: 'supersed|previous|earlier|historical' message: the OpenAMRobot 2.0 final release is 18 December 2026 + - pattern: '(?P\bv0\.2\b[^\n]{0,40}\brelease\b|\brelease\b[^\n]{0,20}\bv0\.2\b|\breadiness declaration for v0\.2\b)' + unless: 'supersed|historical|earlier|previous' + message: there is no v0.2 release; development cycle 2 ends 20 November 2026 with v2.0.0-rc.1, and v2.0.0 follows on 18 December 2026 + - pattern: '(?P\bcycle\b[^\n]{0,60}\b13 November(?: 2026)?\b|\b13 November(?: 2026)?\b[^\n]{0,60}\bcycle\b)' + unless: 'supersed|historical|earlier|previous' + message: development cycle 2 ends 20 November 2026 with v2.0.0-rc.1, not 13 November verification: - machine: text giving the 2.0 final release a date other than 18 December 2026 + machine: text giving the 2.0 final release a date other than 18 December 2026; a v0.2 release or a development cycle ending 13 November 2026 without a superseded label human: {reviewer: platform-lead, evidence: the release plan and versions in P-03 rev18.6 item 12} owner: platform-lead diff --git a/tests/test_check_decisions.py b/tests/test_check_decisions.py index 1f9542b..f0af57e 100644 --- a/tests/test_check_decisions.py +++ b/tests/test_check_decisions.py @@ -365,6 +365,17 @@ def test_head_camera_recorded_and_base_camera_tilt(self): "Gemini 336L about 243 mm above the floor, tilted 10 degrees up (positions 5, 10, 15).\n"): self.assertEqual(self.ids("docs/a.md", text, "openamrobot-docs"), [], text) + def test_release_milestones_no_v0_2_or_13_november(self): + for text in ("v0.2 is the first release built from the harness.\n", + "Readiness declaration for v0.2.\n", + "Development cycle 2 ends 13 November 2026.\n", + "The 14 September to 13 November cycle.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), ["RELEASE-MILESTONES"], text) + for text in ("Development cycle 2 ends 20 November 2026 with v2.0.0-rc.1.\n", + "The v0.2 release plan is superseded by v2.0.0-rc.1.\n", + "The cycle originally ended 13 November (superseded by RELEASE-MILESTONES).\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + def test_legacy_label_exempts_compute(self): self.assertEqual(self.ids("README.md", "Legacy build: Raspberry Pi 5.\n", "openamr-platform-hw"), []) self.assertEqual(self.ids("README.md", "Compute: Raspberry Pi 5.\n", "openamr-platform-hw"), ["COMPUTE"]) From fd399a7f63335bcf51978e186fa7c367bcc3cff6 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 15:23:20 +0000 Subject: [PATCH 045/129] profile: lift approved in principle; footer address with floor - profile/README.md: "a fixed mast for the arms (the linear lift is deferred to OpenAMRobot 3.0)" becomes "a lift for the arms, approved in principle for OpenAMRobot 2.0 (release gates still open)", per P-03 rev18.7 item 8 (register entry LIFT). - The other lines in the same page that still said "fixed mast" or "lift in 3.0" (repository tree, Active Core Repositories table, roadmap list) are aligned the same way; otherwise the LIFT check would report this changed file. - Footer address: Chrysanthou Mylona 1, Panayides Building, Floor 2, Office 1, 3030 Limassol, Cyprus. Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- profile/README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/profile/README.md b/profile/README.md index 7400e9e..d87db8a 100644 --- a/profile/README.md +++ b/profile/README.md @@ -15,7 +15,7 @@ OpenAMRobot combines: - autonomous mobile robotics - dual-arm manipulation -- a fixed mast for the arms (the linear lift is deferred to OpenAMRobot 3.0) +- a lift for the arms, approved in principle for OpenAMRobot 2.0 (release gates still open) - AI-based perception - wearable embodied AI data collection - ROS 2 software infrastructure @@ -164,9 +164,9 @@ openAMRobot/ ├── openamr-platform-sw # AMR ROS 2: sim, nav2, docking, control, drivers, perception ├── openamr-platform-fw # AMR firmware: motor/sensor bridges, safety I/O ├── openamr-platform-hw # AMR mechanical, electrical, CAD, BOM -├── openamr-upperbody-sw # arm+fixed mast (lift in 3.0), MoveIt, bringup -├── openamr-upperbody-fw # end-effector, safety I/O; lift in 3.0 -├── openamr-upperbody-hw # fixed mast, lift in 3.0, plates, wiring, BOM +├── openamr-upperbody-sw # arm + lift (approved in principle), MoveIt, bringup +├── openamr-upperbody-fw # end-effector, safety I/O +├── openamr-upperbody-hw # lift (approved in principle), plates, wiring, BOM ``` @@ -177,9 +177,9 @@ openAMRobot/ | [`openamr-platform-sw`](https://github.com/openAMRobot/openamr-platform-sw) | ROS 2 software: simulation, navigation, docking, drivers, perception, bringup | | [`openamr-platform-fw`](https://github.com/openAMRobot/openamr-platform-fw) | Embedded firmware, microcontroller systems, motor interfaces, hardware communication | | [`openamr-platform-hw`](https://github.com/openAMRobot/openamr-platform-hw) | CAD, chassis, electrical, BOMs, manufacturing files, mechatronics | -| [`openamr-upperbody-sw`](https://github.com/openAMRobot/openamr-upperbody-sw) | Arm + fixed mast (lift in 3.0), MoveIt on the combined model, bringup | -| [`openamr-upperbody-fw`](https://github.com/openAMRobot/openamr-upperbody-fw) | End-effector, upper-body safety I/O; lift in 3.0 | -| [`openamr-upperbody-hw`](https://github.com/openAMRobot/openamr-upperbody-hw) | Fixed mast (lift in 3.0), mounting plates, wiring, BOM | +| [`openamr-upperbody-sw`](https://github.com/openAMRobot/openamr-upperbody-sw) | Arm + lift (approved in principle), MoveIt on the combined model, bringup | +| [`openamr-upperbody-fw`](https://github.com/openAMRobot/openamr-upperbody-fw) | End-effector, upper-body safety I/O | +| [`openamr-upperbody-hw`](https://github.com/openAMRobot/openamr-upperbody-hw) | Lift (approved in principle), mounting plates, wiring, BOM | | [`openamrobot-manipulation`](https://github.com/openAMRobot/openamrobot-manipulation) | Arm framework: manipulation server, Device Package format, arms (OpenArm 2.0 primary, LeRobot SO-101 fixture) | | [`openamrobot-interfaces`](https://github.com/openAMRobot/openamrobot-interfaces) | Shared ROS 2 messages, services, actions, schemas, interface contracts | | [`openamrobot-comm`](https://github.com/openAMRobot/openamrobot-comm) | APIs, middleware, telemetry, transport protocols, interoperability | @@ -448,7 +448,7 @@ Support open-source robotics, ROS 2 development, AI robotics education, and dual - Hub-motor drive; suspension in 3.0 - mechanical + control integration -- Robotic arm integration; linear lift in 3.0 +- Robotic arm integration; lift approved in principle for 2.0 - mounts - drivers - wiring @@ -491,4 +491,4 @@ Contributor attribution and legally non-waivable authorship or moral rights rema See the canonical [IP Policy](https://github.com/openAMRobot/.github/blob/main/IP_POLICY.md), [Contribution Guide](https://github.com/openAMRobot/.github/blob/main/CONTRIBUTING.md), and [Contributor Agreement Process](https://github.com/openAMRobot/.github/blob/main/CLA.md). -**Botshare LTD** · HE479056 · Chrysanthou Mylona 1, Panayides Building, Office 1, 3030 Limassol, Cyprus · info@botshare.ai · https://botshare.ai +**Botshare LTD** · HE479056 · Chrysanthou Mylona 1, Panayides Building, Floor 2, Office 1, 3030 Limassol, Cyprus · info@botshare.ai · https://botshare.ai From 9d7d4a3ee47ac75786f949556f2d77f7ee302c4d Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 15:23:43 +0000 Subject: [PATCH 046/129] contributing: example exclusion is "no RS485 in 2.0" The decisions example in CONTRIBUTING.md said "no lift in 2.0", which contradicts LIFT (P-03 rev18.7 item 8). It now names a current exclusion, "no RS485 in 2.0" (register entry RS485-NOT-IN-2-0). Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eab2edd..45c3fb1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,7 +44,7 @@ are in [AGENTS.md](AGENTS.md). - Add a test that fails without your change. A test run that executes zero tests counts as a failure. If you must skip a test, name the tracking issue on the same line. - Run the repository's verification (`tools/verify.sh`, or the command in its README). -- Approved technical decisions (values, limits, exclusions such as "no lift in 2.0", and +- Approved technical decisions (values, limits, exclusions such as "no RS485 in 2.0", and distinctions such as "1700 mm is the assembled-height envelope, not a shoulder height") are in the register [decisions.yaml](decisions.yaml). Follow it. To change one, open a **contract change request** naming the entry; the process is "Changing a decision" in From 7c7f10888c190a5ba4bda6925d6254ff26285421 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 15:23:43 +0000 Subject: [PATCH 047/129] quality standard: cycle and release wording follow RELEASE-MILESTONES - Section 13: workstream H maps onto development cycle 2, which ends 20 November 2026 with the v2.0.0-rc.1 GitHub pre-release; v2.0.0 follows on 18 December 2026. The "14 September to 13 November cycle" wording is gone. - The v0.2 readiness declaration is removed and replaced by a short "Release readiness" paragraph that says it is superseded by those milestones and keeps the R1/R3 principles. - The self-scan of this repository reports 0 contradictions. Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- ENGINEERING_QUALITY_STANDARD.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/ENGINEERING_QUALITY_STANDARD.md b/ENGINEERING_QUALITY_STANDARD.md index 26079ad..e3c116a 100644 --- a/ENGINEERING_QUALITY_STANDARD.md +++ b/ENGINEERING_QUALITY_STANDARD.md @@ -380,9 +380,12 @@ The standard is operational, not merely published, when: ## 13. Cycle schedule, workstream H -The phases above map onto the 14 September to 13 November cycle as -workstream H. Lead: Documentation & Release Lead, executed by the DevOps -members of that team. +The phases above map onto development cycle 2 as workstream H. The cycle +opened 14 September and ends 20 November 2026 with the v2.0.0-rc.1 GitHub +pre-release; v2.0.0 follows on 18 December 2026 after physical integration, +testing and acceptance (register entry RELEASE-MILESTONES in decisions.yaml). +Lead: Documentation & Release Lead, executed by the DevOps members of that +team. | **\#** | **Phase** | **Deliverable** | **Due** | |--------|-----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------| @@ -401,12 +404,12 @@ provenance, the compatibility dashboard and formal R3 approval records go to ROADMAP-v0.3.md. Saying so now is cheaper than discovering it on 10 November. -**Readiness declaration for v0.2.** The four pilot repositories target -**R2**. Every other active repository targets **R1**. **No component -claims R3 in this cycle.** v0.2 is the first release built from -immutable component refs, which is the precondition for R3, not the -evidence for it. A component that builds is R1, and building has never -been the difficult part. +**Release readiness.** Development cycle 2 ends 20 November 2026 with +v2.0.0-rc.1; v2.0.0 follows on 18 December 2026 (RELEASE-MILESTONES). The +earlier v0.2 readiness declaration is superseded by those milestones. +Readiness levels are evidence, not targets: a component that builds is R1, +building has never been the difficult part, and a release built from +immutable component refs is the precondition for R3, not the evidence for it. ## 14. How this standard binds the plan set From 0674d16cf7ff754e75e1aea030a472093da49e59 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 15:24:15 +0000 Subject: [PATCH 048/129] ci: .gitattributes keeps LF for shell scripts CI owner review (6 October), verifier point 1: with core.autocrlf=true, rollout/verify.sh was checked out with CRLF and failed under Linux bash. .gitattributes now sets `text eol=lf` for *.sh and *.bash; rollout/verify.sh is the only shell script tracked here. tests/test_line_endings.py: - every tracked shell script (by extension or shebang) has eol=lf; - a clone with core.autocrlf=true keeps LF in rollout/verify.sh. Both fail without the .gitattributes file. Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- .gitattributes | 5 ++++ tests/test_line_endings.py | 49 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 .gitattributes create mode 100644 tests/test_line_endings.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3246393 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Shell scripts run under Linux bash, so they keep LF line endings even when a +# contributor's Git uses core.autocrlf=true (CI owner review, 6 October 2026). +# A shell script without one of these extensions gets its own line below. +*.sh text eol=lf +*.bash text eol=lf diff --git a/tests/test_line_endings.py b/tests/test_line_endings.py new file mode 100644 index 0000000..c77e1de --- /dev/null +++ b/tests/test_line_endings.py @@ -0,0 +1,49 @@ +"""Shell scripts keep LF line endings on every checkout (.gitattributes).""" +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def git(*args, cwd=ROOT): + return subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True).stdout + + +def shell_scripts(): + scripts = [] + for rel in git("ls-files", "-z").split("\0"): + path = ROOT / rel + if rel and path.is_file(): + with path.open("rb") as handle: + first = handle.readline(80) + if rel.endswith((".sh", ".bash")) or (first.startswith(b"#!") and b"sh" in first): + scripts.append(rel) + return scripts + + +class LineEndings(unittest.TestCase): + def test_every_shell_script_is_marked_lf(self): + scripts = shell_scripts() + self.assertIn("rollout/verify.sh", scripts) + for rel in scripts: + with self.subTest(script=rel): + self.assertTrue(git("check-attr", "eol", "--", rel).strip().endswith("eol: lf"), rel) + + def test_autocrlf_checkout_keeps_lf(self): + with tempfile.TemporaryDirectory() as tmp: + src, dst = Path(tmp, "src"), Path(tmp, "dst") + (src / "rollout").mkdir(parents=True) + shutil.copy(ROOT / ".gitattributes", src / ".gitattributes") + (src / "rollout" / "verify.sh").write_bytes(b"#!/usr/bin/env bash\necho ok\n") + git("init", "-q", cwd=src) + git("add", ".", cwd=src) + git("-c", "user.name=t", "-c", "user.email=t@example.invalid", "commit", "-q", "-m", "t", cwd=src) + git("-c", "core.autocrlf=true", "clone", "-q", "--config", "core.autocrlf=true", str(src), str(dst), cwd=tmp) + self.assertNotIn(b"\r\n", (dst / "rollout" / "verify.sh").read_bytes()) + + +if __name__ == "__main__": + unittest.main() From 1aa77735b6113f4eeecbadd9fd6a7ba050e58241 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 15:26:20 +0000 Subject: [PATCH 049/129] verify: rosdep sources list and cache available in the clean HOME CI owner review (6 October), verifier point 2: clean_bash() runs every stage under env -i with HOME set to the run directory, which hid the rosdep cache the environment had prepared, so `rosdep check` reported rosdep as not initialised. - The clean environment stays. The caller's ${ROS_HOME:-$HOME/.ros}/rosdep (user sources list and cache) is copied into the clean HOME, and ROSDEP_SOURCE_PATH is passed through when set. A copy keeps the caller's cache unchanged by the run. The system list in /etc/ros/rosdep is visible as before. - VERIFY_ROS_SETUP may replace /opt/ros/$distro/setup.bash, so the ROS path can be tested without a ROS install. VERIFY.md documents both. - tests/test_verify_sh.py RosdepState uses a fake ROS setup whose rosdep needs an initialised cache: with the caller's cache the install stage passes; without any rosdep state it fails. With the copy removed, the first test fails (exit 1 at the install stage). Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- rollout/VERIFY.md | 8 +++-- rollout/verify.sh | 22 +++++++++--- tests/test_verify_sh.py | 80 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 7 deletions(-) diff --git a/rollout/VERIFY.md b/rollout/VERIFY.md index bacfc08..79b84a8 100644 --- a/rollout/VERIFY.md +++ b/rollout/VERIFY.md @@ -26,7 +26,10 @@ skip rules to a delegated run; those remain the delegated script's responsibilit `counts_parsed: false` shows when no count could be read. Every stage runs under `env -i` with a fresh `HOME`, as in the interfaces script, so no overlay, -Python path or user package leaks in. Output goes to `.verification/run.*/`: +Python path or user package leaks in. The one exception is rosdep's prepared state: the caller's +`${ROS_HOME:-$HOME/.ros}/rosdep` (user sources list and cache) is copied into the fresh `HOME`, and +`ROSDEP_SOURCE_PATH` is passed through when set, so `rosdep check` does not report an +uninitialised rosdep. Output goes to `.verification/run.*/`: `verification.log`, `test.log`, `result.txt` (PASS, or FAIL with the stage) and `summary.json` (result, failed stage, stages passed, test totals, head and base SHA). @@ -57,7 +60,8 @@ record adds them (rollout/README.md, release section). ## Overrides `.openamrobot/verify.env` in the repository may set shell commands `VERIFY_INSTALL`, -`VERIFY_BUILD`, `VERIFY_LINT`, `VERIFY_TEST`, and `VERIFY_ROS_DISTRO` (default `jazzy`). +`VERIFY_BUILD`, `VERIFY_LINT`, `VERIFY_TEST`, and `VERIFY_ROS_DISTRO` (default `jazzy`); +`VERIFY_ROS_SETUP` replaces the ROS setup file (default `/opt/ros/$VERIFY_ROS_DISTRO/setup.bash`). Overrides replace a stage's command; they do not switch off the zero-tests or skip rules. Example for openamrobot-ui, whose web app lives in `web/`: diff --git a/rollout/verify.sh b/rollout/verify.sh index 8dd3ed1..37b454e 100755 --- a/rollout/verify.sh +++ b/rollout/verify.sh @@ -18,6 +18,7 @@ # parsed from the delegated output. # Per-repository overrides live in .openamrobot/verify.env (VERIFY_INSTALL, # VERIFY_BUILD, VERIFY_LINT, VERIFY_TEST, VERIFY_ROS_DISTRO); each is a shell command. +# VERIFY_ROS_SETUP overrides the ROS setup file (default /opt/ros/$VERIFY_ROS_DISTRO/setup.bash). set -eo pipefail root=$(cd -- "${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" && pwd) @@ -141,19 +142,30 @@ trap finish EXIT pass() { stages+=("$stage"); echo "PASS: $stage"; } -# Never inherit overlays, Python paths or prefixes from the caller. +# Never inherit overlays, Python paths or prefixes from the caller. The only caller +# state passed in is rosdep's: ROSDEP_SOURCE_PATH when set, and a copy of the caller's +# rosdep sources list and cache (below). clean_bash() { env -i HOME="$run/home" PATH="${VERIFY_PATH:-/usr/local/bin:/usr/bin:/bin}" LANG=C.UTF-8 \ PYTHONNOUSERSITE=1 PYTHONDONTWRITEBYTECODE=1 CI="${CI:-}" \ + ${ROSDEP_SOURCE_PATH:+"ROSDEP_SOURCE_PATH=$ROSDEP_SOURCE_PATH"} \ bash --noprofile --norc -eo pipefail "$@" } mkdir -p "$run/home" +# rosdep keeps its user sources list and cache under ${ROS_HOME:-$HOME/.ros}/rosdep. The +# clean HOME would hide the state the environment prepared ("rosdep not initialized"), so +# copy it in; a copy keeps the caller's cache unchanged by the run. +caller_rosdep="${ROS_HOME:-${HOME:-/nonexistent}/.ros}/rosdep" +if [ -d "$caller_rosdep" ]; then + mkdir -p "$run/home/.ros" && cp -a "$caller_rosdep" "$run/home/.ros/rosdep" +fi if [ -f "$root/.openamrobot/verify.env" ]; then # shellcheck disable=SC1091 source "$root/.openamrobot/verify.env" fi distro=${VERIFY_ROS_DISTRO:-jazzy} +ros_setup=${VERIFY_ROS_SETUP:-/opt/ros/$distro/setup.bash} ros=false; node=false; python=false if find "$root" -name package.xml -not -path '*/node_modules/*' -not -path '*/.verification/*' \ @@ -162,7 +174,7 @@ if find "$root" -name package.xml -not -path '*/node_modules/*' -not -path '*/.v if [ -f "$root/pyproject.toml" ] || [ -f "$root/setup.py" ] || [ -d "$root/tests" ]; then python=true; fi echo "Detected: ros=$ros node=$node python=$python" command -v git python3 >/dev/null -if $ros; then test -f "/opt/ros/$distro/setup.bash"; fi +if $ros; then test -f "$ros_setup"; fi if $node; then command -v npm >/dev/null; fi if ! $ros && ! $node && ! $python && [ -z "${VERIFY_TEST:-}" ]; then echo "FAIL: no buildable or testable project detected; set VERIFY_TEST in .openamrobot/verify.env" @@ -174,7 +186,7 @@ stage=install if [ -n "${VERIFY_INSTALL:-}" ]; then clean_bash -c "cd '$root' && $VERIFY_INSTALL" elif $node; then clean_bash -c "cd '$root' && npm ci" elif $ros; then - clean_bash -c "source /opt/ros/$distro/setup.bash && rosdep check --from-paths '$root' --ignore-src --rosdistro $distro" + clean_bash -c "source '$ros_setup' && rosdep check --from-paths '$root' --ignore-src --rosdistro $distro" fi pass @@ -182,7 +194,7 @@ stage=build if [ -n "${VERIFY_BUILD:-}" ]; then clean_bash -c "cd '$root' && $VERIFY_BUILD" elif $ros; then mkdir -p "$run/ws/src" && cp -a "$root/." "$run/ws/src/repo" && rm -rf "$run/ws/src/repo/.verification" - clean_bash -c "source /opt/ros/$distro/setup.bash && cd '$run/ws' && colcon build --event-handlers console_direct+" + clean_bash -c "source '$ros_setup' && cd '$run/ws' && colcon build --event-handlers console_direct+" elif $node; then clean_bash -c "cd '$root' && npm run build --if-present" fi pass @@ -219,7 +231,7 @@ log="$run/test.log" set +e if [ -n "${VERIFY_TEST:-}" ]; then clean_bash -c "cd '$root' && $VERIFY_TEST" 2>&1 | tee "$log" elif $ros; then - clean_bash -c "source /opt/ros/$distro/setup.bash && cd '$run/ws' && colcon test --event-handlers console_direct+ && colcon test-result --verbose" 2>&1 | tee "$log" + clean_bash -c "source '$ros_setup' && cd '$run/ws' && colcon test --event-handlers console_direct+ && colcon test-result --verbose" 2>&1 | tee "$log" elif $node; then clean_bash -c "cd '$root' && npm test" 2>&1 | tee "$log" elif [ -f "$root/pyproject.toml" ] && python3 -c 'import pytest' 2>/dev/null; then clean_bash -c "cd '$root' && python3 -m pytest -rs" 2>&1 | tee "$log" diff --git a/tests/test_verify_sh.py b/tests/test_verify_sh.py index 952c393..e378c10 100644 --- a/tests/test_verify_sh.py +++ b/tests/test_verify_sh.py @@ -145,5 +145,85 @@ def test_delegated_failure_status_is_kept_when_summary_cannot_be_written(self): self.assertIn("exit 7; summary.json not written", result) +# A fake ROS install: setup.bash puts a fake rosdep on PATH. The fake needs an initialised +# rosdep cache under $HOME/.ros/rosdep (as the real one does) and fails on any package.xml +# that names an unresolvable key; it logs every path it is asked to scan. +FAKE_ROSDEP = r"""#!/usr/bin/env bash +log="$(dirname "$0")/../calls.log" +[ "$1" = check ] || exit 2 +shift +if [ ! -f "$HOME/.ros/rosdep/sources.cache" ]; then + echo "ERROR: your rosdep installation has not been initialized yet"; exit 1 +fi +paths=() +while [ $# -gt 0 ]; do + if [ "$1" = --from-paths ]; then + shift + while [ $# -gt 0 ] && [ "${1#--}" = "$1" ]; do paths+=("$1"); shift; done + else shift; fi +done +for p in "${paths[@]}"; do + echo "scan $p" >> "$log" + if grep -rl --include=package.xml unresolvable_generated_key "$p" >/dev/null 2>&1; then + echo "ERROR: Cannot locate rosdep definition for [unresolvable_generated_key]"; exit 1 + fi +done +echo "All system dependencies have been satisfied" +""" +PACKAGE_XML = ('\n{name}0.0.0' + 'dm' + 'MIT{deps}\n') +ROS_ENV = 'VERIFY_BUILD="true"\nVERIFY_TEST="python3 -m unittest discover -s tests -v"\n' + + +class RosdepState(unittest.TestCase): + """CI owner review (6 October): rosdep state in the clean HOME; generated folders not scanned.""" + + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.base = Path(tmp.name) + ros = self.base / "ros" + (ros / "bin").mkdir(parents=True) + (ros / "bin" / "rosdep").write_text(FAKE_ROSDEP, encoding="utf-8") + (ros / "bin" / "rosdep").chmod(0o755) + (ros / "setup.bash").write_text(f'export PATH="{ros}/bin:$PATH"\n', encoding="utf-8") + self.ros = ros + self.home = self.base / "home" + self.home.mkdir() + + def init_caller_rosdep(self): + cache = self.home / ".ros" / "rosdep" + cache.mkdir(parents=True) + (cache / "sources.cache").write_text("prepared by the environment\n", encoding="utf-8") + + def run_verify(self, files): + files = {"tests/test_a.py": PASSING, ".openamrobot/verify.env": ROS_ENV, + "ros2/src/pkg_a/package.xml": PACKAGE_XML.format(name="pkg_a", deps="rclpy"), + **files} + tmp, root = make_repo(files) + self.addCleanup(tmp.cleanup) + proc = subprocess.run(["bash", str(VERIFY), str(root)], capture_output=True, text=True, timeout=120, + env={"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": str(self.home), + "VERIFY_ROS_SETUP": str(self.ros / "setup.bash")}) + calls = self.ros / "calls.log" + scanned = calls.read_text().split("\n") if calls.exists() else [] + return proc.returncode, proc.stdout + proc.stderr, root, scanned + + def test_caller_rosdep_cache_is_available_in_clean_home(self): + self.init_caller_rosdep() + code, out, _, _ = self.run_verify({}) + self.assertEqual(code, 0, out) + self.assertIn("All system dependencies have been satisfied", out) + self.assertIn("PASS: install", out) + self.assertTrue((self.home / ".ros" / "rosdep" / "sources.cache").is_file()) + + def test_without_rosdep_state_the_install_stage_fails(self): + code, out, _, _ = self.run_verify({}) + self.assertNotEqual(code, 0, out) + self.assertIn("not been initialized", out) + self.assertIn("FAIL: install", out) + + if __name__ == "__main__": unittest.main() From d775ff0153eae23f8b60a4cbfb02bf45e180c1b6 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 15:26:42 +0000 Subject: [PATCH 050/129] verify: rosdep check scans only the ROS source packages CI owner review (6 October), verifier point 3: `rosdep check --from-paths "$root"` also scanned generated workspace content (ros2/build, ros2/install, ros2/log), so a previously built local workspace failed dependency checking. - ros_source_paths() lists the directories of package.xml files in the source tree, pruning any directory named build, install or log, any directory with a COLCON_IGNORE marker, .git, .verification, node_modules and tests/fixtures. rosdep check now receives those package directories with --ignore-src. - The unresolved micro_ros_agent rosdep key in openamr-platform-sw is a platform dependency-baseline issue and out of scope here. - tests: test_generated_build_install_log_folders_are_not_scanned builds a repository with generated build/, install/ and log/ folders (and a COLCON_IGNORE folder) holding a package.xml with an unresolvable key; the install stage passes and only ros2/src/pkg_a is scanned. Against the previous script it fails (exit 1 at the install stage). Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- rollout/VERIFY.md | 2 +- rollout/verify.sh | 15 ++++++++++++++- tests/test_verify_sh.py | 13 +++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/rollout/VERIFY.md b/rollout/VERIFY.md index 79b84a8..f2737fa 100644 --- a/rollout/VERIFY.md +++ b/rollout/VERIFY.md @@ -11,7 +11,7 @@ around the delegated run. | Stage | Default action | Fails when | |---|---|---| | prerequisites | detect ROS 2 (`package.xml`), Node (`package.json`), Python (`tests/`, `pyproject.toml`, `setup.py`) | nothing is detected and no `VERIFY_TEST` is set | -| install | `npm ci`, or `rosdep check` for ROS 2 | a dependency does not resolve | +| install | `npm ci`, or `rosdep check` for ROS 2 on the source packages only (never `build/`, `install/`, `log/` or a `COLCON_IGNORE` folder) | a dependency does not resolve | | build | `colcon build` in a copied workspace, or `npm run build` | the build fails | | lint | `py_compile` for tracked Python, `bash -n` (and `shellcheck` when installed) for shell, `npm run lint` | any file fails | | test-markers | scan tracked test sources | a skip, xfail or importorskip does not name an issue (`#123` or `issues/123`) on the same line | diff --git a/rollout/verify.sh b/rollout/verify.sh index 37b454e..cf97ecf 100755 --- a/rollout/verify.sh +++ b/rollout/verify.sh @@ -160,6 +160,16 @@ if [ -d "$caller_rosdep" ]; then mkdir -p "$run/home/.ros" && cp -a "$caller_rosdep" "$run/home/.ros/rosdep" fi +# ROS package directories of the source tree only: never colcon's build/, install/ or +# log/ (any directory with those names or a COLCON_IGNORE marker), .verification/, +# node_modules/ or checker fixtures. +ros_source_paths() { + find "$root" \( -name .git -o -name .verification -o -name node_modules -o -name build \ + -o -name install -o -name log -o -path "$root/tests/fixtures" \) -prune \ + -o -type d -exec test -e '{}/COLCON_IGNORE' \; -prune \ + -o -name package.xml -printf '%h\n' | sort -u +} + if [ -f "$root/.openamrobot/verify.env" ]; then # shellcheck disable=SC1091 source "$root/.openamrobot/verify.env" @@ -186,7 +196,10 @@ stage=install if [ -n "${VERIFY_INSTALL:-}" ]; then clean_bash -c "cd '$root' && $VERIFY_INSTALL" elif $node; then clean_bash -c "cd '$root' && npm ci" elif $ros; then - clean_bash -c "source '$ros_setup' && rosdep check --from-paths '$root' --ignore-src --rosdistro $distro" + mapfile -t ros_paths < <(ros_source_paths) + # shellcheck disable=SC2016 # $@ expands inside the clean shell + clean_bash -c "source '$ros_setup' && rosdep check --from-paths \"\$@\" --ignore-src --rosdistro $distro" \ + rosdep-check "${ros_paths[@]}" fi pass diff --git a/tests/test_verify_sh.py b/tests/test_verify_sh.py index e378c10..7e6b7c4 100644 --- a/tests/test_verify_sh.py +++ b/tests/test_verify_sh.py @@ -224,6 +224,19 @@ def test_without_rosdep_state_the_install_stage_fails(self): self.assertIn("not been initialized", out) self.assertIn("FAIL: install", out) + def test_generated_build_install_log_folders_are_not_scanned(self): + self.init_caller_rosdep() + bad = PACKAGE_XML.format(name="pkg_a", deps="unresolvable_generated_key") + code, out, root, scanned = self.run_verify({ + "ros2/build/pkg_a/package.xml": bad, "ros2/build/COLCON_IGNORE": "", + "ros2/install/pkg_a/share/pkg_a/package.xml": bad, "ros2/install/COLCON_IGNORE": "", + "ros2/log/latest/package.xml": bad, + "ros2/generated/COLCON_IGNORE": "", "ros2/generated/pkg_a/package.xml": bad, + }) + self.assertEqual(code, 0, out) + scanned = [line for line in scanned if line] + self.assertEqual(scanned, [f"scan {root / 'ros2' / 'src' / 'pkg_a'}"]) + if __name__ == "__main__": unittest.main() From 05bbe78f3bf1657b951bafb5f4c3feddb298e3d3 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 15:27:20 +0000 Subject: [PATCH 051/129] reusable workflow: full history only when harness checks run The repository-quality checkout used fetch-depth: 0 for every caller. It now uses ${{ (inputs.harness_checks || inputs.harness_warn) && '0' || '1' }}: full history only when harness_checks or harness_warn is true (the checks diff against the base); default callers keep the shallow checkout they had on main. The string '0' matters: a bare 0 is falsy in GitHub expressions and would always yield 1. The opt-in quality/test job (inputs.verify) keeps fetch-depth: 0, because verify.sh records the merge base in summary.json. tests/test_reusable_workflow.py test_full_history_only_when_harness_checks_run evaluates the expression for all four input combinations; it fails against the previous workflow (a constant 0). actionlint passes. Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- .github/workflows/repository-quality-reusable.yml | 5 ++++- tests/test_reusable_workflow.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/repository-quality-reusable.yml b/.github/workflows/repository-quality-reusable.yml index 7642031..5a280b5 100644 --- a/.github/workflows/repository-quality-reusable.yml +++ b/.github/workflows/repository-quality-reusable.yml @@ -47,7 +47,10 @@ jobs: - name: Check out repository uses: actions/checkout@v4 with: - fetch-depth: 0 + # Full history only when the harness checks run (they diff against the base); + # default callers keep the shallow checkout. The string '0' keeps the + # expression truthy; a bare 0 would always fall through to 1. + fetch-depth: ${{ (inputs.harness_checks || inputs.harness_warn) && '0' || '1' }} - name: Check out OpenAMRobot harness if: inputs.harness_checks || inputs.harness_warn diff --git a/tests/test_reusable_workflow.py b/tests/test_reusable_workflow.py index f13be52..66aa046 100644 --- a/tests/test_reusable_workflow.py +++ b/tests/test_reusable_workflow.py @@ -55,6 +55,18 @@ def test_inputs_default_off(self): self.assertFalse(self.inputs["harness_checks"]["default"]) self.assertFalse(self.inputs["harness_warn"]["default"]) + def test_full_history_only_when_harness_checks_run(self): + expr = str(self.steps["Check out repository"]["with"]["fetch-depth"]) + inner = expr.strip() + self.assertTrue(inner.startswith("${{") and inner.endswith("}}"), expr) + inner = inner[3:-2].replace("&&", " and ").replace("||", " or ") + for checks in (False, True): + for warn in (False, True): + depth = eval(inner.replace("inputs.harness_checks", str(checks)) # noqa: S307 - test-only + .replace("inputs.harness_warn", str(warn)), {}) + with self.subTest(harness_checks=checks, harness_warn=warn): + self.assertEqual(str(depth), "0" if (checks or warn) else "1") + def test_every_harness_step_runs_in_either_mode(self): for name in HARNESS_STEPS: self.assertEqual(self.steps[name]["if"], "inputs.harness_checks || inputs.harness_warn", name) From 91d4b4ee40eb164920299b503bf68ae2e02ab302 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 15:28:03 +0000 Subject: [PATCH 052/129] tests: jq wiring test skips with its tracking issue when jq is missing test_pr_assistant_passes_deleted_files_to_the_evidence_checker runs the pr-assistant.yml jq filters and errored on machines without jq. Per the skip rule it now skips there, naming its tracking issue on the same line: openAMRobot/.github#42. Hosted ubuntu-24.04 runners have jq, so CI still executes it. JqMissing runs that test with an empty PATH and expects the skip with the issue reference; without the skipIf it fails (the inner test errors). Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- tests/test_check_pr_evidence.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_check_pr_evidence.py b/tests/test_check_pr_evidence.py index d72ee96..790ee99 100644 --- a/tests/test_check_pr_evidence.py +++ b/tests/test_check_pr_evidence.py @@ -4,6 +4,7 @@ import json import os import re +import shutil import subprocess import sys import tempfile @@ -205,6 +206,7 @@ def test_deleting_a_dependency_manifest_is_flagged(self): failures = evaluate(changed=["ros2/pkg/package.xml"])[0] self.assertTrue(any(f.startswith("Dependency manifests changed (ros2/pkg/package.xml)") for f in failures)) + @unittest.skipIf(shutil.which("jq") is None, "jq is not installed; tracking issue openAMRobot/.github#42") def test_pr_assistant_passes_deleted_files_to_the_evidence_checker(self): workflow = yaml.safe_load((ROOT / "rollout" / "workflows" / "pr-assistant.yml").read_text(encoding="utf-8")) steps = {s.get("name"): s.get("run", "") for s in workflow["jobs"]["evidence"]["steps"]} @@ -234,6 +236,17 @@ def run_jq(expr): self.assertTrue(any(f.startswith("Dependency manifests changed") for f in failures)) +class JqMissing(unittest.TestCase): + def test_wiring_test_names_its_tracking_issue_when_jq_is_missing(self): + with tempfile.TemporaryDirectory() as empty_path: + proc = subprocess.run( + [sys.executable, "-m", "unittest", "-v", + "test_check_pr_evidence.DeletedFiles.test_pr_assistant_passes_deleted_files_to_the_evidence_checker"], + cwd=ROOT / "tests", env=dict(os.environ, PATH=empty_path), capture_output=True, text=True) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertIn("skipped 'jq is not installed; tracking issue openAMRobot/.github#42'", proc.stderr) + + CLEAN = "decisions: 23 loaded\nresult: 0 contradiction(s), 0 allowed, scope 3 changed file(s)\n" TWO = ("decisions: 23 loaded\n" "CONTRADICTION docs/a.md:4: MAST-INSTALL-HEIGHT found 'mast_1400', decided '1350 mm' (P-03)\n" From 09038a636fe9b4963ebc356cbddb8b5ce12d369e Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 15:28:29 +0000 Subject: [PATCH 053/129] docs policy: DOCUMENTATION_STANDARD.md is canonical; allowlist is detail Documentation owner review (4 October), points 1 and 4: - public-extract-allowlist.yaml header and rollout/workflows/SETUP.md name openamrobot-docs/docs/DOCUMENTATION_STANDARD.md as the canonical documentation policy. - The allowlist is described as an implementation detail of the public-extract check that separates intentional public content (contact, licensing, documentation) from accidental leakage (internal links, private contact data, prices, credentials). If the two disagree the standard wins; a new kind of allowed content starts in the standard. Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- public-extract-allowlist.yaml | 8 ++++++++ rollout/workflows/SETUP.md | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/public-extract-allowlist.yaml b/public-extract-allowlist.yaml index 24c8ce7..2d89ce5 100644 --- a/public-extract-allowlist.yaml +++ b/public-extract-allowlist.yaml @@ -1,6 +1,14 @@ # Allowlist for tools/check_public_extract.py. Kept narrow on purpose: it admits # published contact and licensing information, not e-mail addresses in general. # +# Policy: the canonical documentation policy is openamrobot-docs/docs/DOCUMENTATION_STANDARD.md +# (https://github.com/openAMRobot/openamrobot-docs/blob/main/docs/DOCUMENTATION_STANDARD.md). +# This allowlist is an implementation detail of the public-extract check, not policy. It +# separates intentional public content (organization contact, licensing information, +# documentation) from accidental leakage (internal document links, private contact data, +# prices, credentials). If this file and the standard disagree, the standard wins and this +# file is corrected; a new kind of allowed content starts as a change to the standard. +# # Entry fields: rule, match (regular expression for the whole matched text), # optional paths and repositories (globs), optional line (regular expression # the whole source line must contain), and reason. GitHub handles such as diff --git a/rollout/workflows/SETUP.md b/rollout/workflows/SETUP.md index d6a4055..422d3d5 100644 --- a/rollout/workflows/SETUP.md +++ b/rollout/workflows/SETUP.md @@ -34,6 +34,14 @@ is a rollout step (rollout/README.md), not a present fact. | Decision-register changes | (c) the entry's owner | The register's own schema validation is (a) | | Every `[human: ...]` rule in AGENTS.md | (c) | Not machine-checked | +**Documentation policy.** The canonical documentation policy is +[`openamrobot-docs/docs/DOCUMENTATION_STANDARD.md`](https://github.com/openAMRobot/openamrobot-docs/blob/main/docs/DOCUMENTATION_STANDARD.md), +owned by the documentation owner. `tools/check_public_extract.py` and +`public-extract-allowlist.yaml` implement one part of it: they separate intentional public +content (organization contact, licensing information, documentation) from accidental leakage +(internal document links, private contact data, prices, credentials). The allowlist is an +implementation detail, not policy; when it and the standard disagree, the standard wins. + ## 1. Pin the harness Pinning is done per repository, in the order of rollout/README.md, not organization-wide: From dbaa6476baf0dc1dde8a3a0edac62023b8a54a62 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 15:28:54 +0000 Subject: [PATCH 054/129] docs-fix prompt: verified facts versus planned or experimental content Documentation owner review (4 October), point 3: agent-prompts/docs-fix.md (template version 2) gains "Verified and planned content": - a verified fact carries its source in the owning repository (@:: or the decisions.yaml entry ID); - planned or experimental content is labelled Planned or Experimental where it appears, never as current behaviour; - never invent a technical claim the owning repository does not support; with no source, leave the claim out and report the gap. The expected outcome requires a source reference or label for every new or changed claim. Point 2 (the owning repository is the source of truth for commands, versions, parameters and contracts) was already in the prompt. test_docs_fix_separates_verified_from_planned_content fails against the previous prompt. Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- agent-prompts/docs-fix.md | 18 +++++++++++++++++- tests/test_templates.py | 7 +++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/agent-prompts/docs-fix.md b/agent-prompts/docs-fix.md index a503ac4..5d23554 100644 --- a/agent-prompts/docs-fix.md +++ b/agent-prompts/docs-fix.md @@ -1,6 +1,6 @@ # Documentation fix -Template version: 1. Harness: openAMRobot/.github at ``. +Template version: 2. Harness: openAMRobot/.github at ``. ## Precondition block (post before the first write) @@ -17,6 +17,20 @@ The owning repository is the source of truth for commands, versions, parameters the docs site links to it. If the page and the owning repository disagree and decisions.yaml does not settle it, stop and report instead of choosing. +## Verified and planned content + +Every technical claim the page states or changes is one of two kinds, and the PR shows which: + +- **Verified fact:** supported by the owning repository. The PR (and the page, where the page + cites sources) gives the reference as `@::` or the decisions.yaml + entry ID. +- **Planned or experimental content:** a roadmap item, an open decision, an untested + configuration or a design not yet built. The page labels it **Planned** or **Experimental** + where it appears, never as current behaviour. + +Never invent a technical claim the owning repository does not support. When no source exists, +leave the claim out and report the gap in the PR instead of writing it. + ## Task 1. Run `python3 tools/check_decisions.py` and `python3 tools/check_public_extract.py` from the @@ -33,6 +47,8 @@ the platform lead. ## Expected outcome - Draft PR changing only the listed pages; both checkers report zero findings on them. +- Every new or changed technical claim carries a source reference in the owning repository or a + Planned/Experimental label. - The Not verified section states whether the site build and link check ran. ## Failure rule diff --git a/tests/test_templates.py b/tests/test_templates.py index 51c2125..e80c90d 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -57,6 +57,13 @@ def test_each_prompt_has_the_three_fixed_parts(self): self.assertIn("expected outcome:", block) self.assertIn("A failed precondition stops the task", text) + def test_docs_fix_separates_verified_from_planned_content(self): + text = (PROMPTS / "docs-fix.md").read_text(encoding="utf-8") + self.assertIn("## Verified and planned content", text) + self.assertIn("@::", text.split("## Verified and planned content", 1)[1]) + self.assertIn("**Planned** or **Experimental**", text) + self.assertIn("Never invent a technical claim the owning repository does not support", text) + class Typography(unittest.TestCase): def test_no_em_or_en_dashes_in_harness_files(self): From 458ea3b2f83f995ed17eb98a004021c761cb5b0e Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 15:31:11 +0000 Subject: [PATCH 055/129] decisions: LIFT check tightened after the cross-repository dry run A warn-only dry run of the register on fresh main checkouts of the 14 active repositories showed two gaps in the LIFT check: - "no lift" also matched implementation-status wording such as "No lift firmware is implemented yet", which does not contradict rev18.7. The pattern now matches "no lift" only as a statement about the robot (followed by punctuation, end of line, or in/for/on). - "Lift module: separate OpenAMRobot 3.0 scope" and "the future OpenAMRobot 3.0 lift controller" were not reported. Two alternatives now cover "lift ... 3.0 scope" and "3.0 lift". test_lift_check_after_cross_repository_dry_run fails against the previous register ([] != ['LIFT']). Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- decisions.yaml | 4 ++-- tests/test_check_decisions.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/decisions.yaml b/decisions.yaml index 4b51d64..9613a7f 100644 --- a/decisions.yaml +++ b/decisions.yaml @@ -727,13 +727,13 @@ decisions: - {value: "Lift removed from OpenAMRobot 2.0; fixed mast; lift on the 3.0 roadmap (former entry LIFT-REMOVED)", source: P-00-rev18.1 txt 18} applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/package.xml", "**/*.xacro", "**/*.urdf"]} check: - - pattern: '(?P\bno (?:linear )?lift\b(?!\s+(?:motion|move|movement|while|unless|during)))' + - pattern: '(?P\bno (?:linear |actuated )?lift\b(?=\s*(?:[.,;:)]|$|(?:in|for|on)\b)))' unless: 'supersed|historical|earlier|previous|no longer|rev ?18\.[1-6]\b' message: the lift is approved in principle for 2.0 (P-03 rev18.7 item 8) - pattern: '(?P\bfixed[- ]mast\b)' unless: 'supersed|historical|earlier|previous|legacy|replace|instead of|no longer|rev ?18\.[1-6]\b' message: the fixed mast is no longer the baseline; the lift is approved in principle (P-03 rev18.7 item 8) - - pattern: '(?P\blift\b[^\n]{0,40}\b(?:deferred|moved|postponed)\b[^\n]{0,25}\b3\.0\b|\blift\b[^\n]{0,15}\bin (?:OpenAMRobot )?3\.0\b|\blift (?:is |was )?removed\b)' + - pattern: '(?P\blift\b[^\n]{0,40}\b(?:deferred|moved|postponed)\b[^\n]{0,25}\b3\.0\b|\blift\b[^\n]{0,15}\bin (?:OpenAMRobot )?3\.0\b|\blift (?:is |was )?removed\b|\blift\b[^\n]{0,40}\b(?:OpenAMRobot )?3\.0 scope\b|\b3\.0 lift\b)' unless: 'supersed|historical|earlier|previous|no longer|rev ?18\.[1-6]\b' message: the lift is not deferred to 3.0; it is approved in principle for 2.0 (P-03 rev18.7 item 8) verification: diff --git a/tests/test_check_decisions.py b/tests/test_check_decisions.py index f0af57e..9dfe07e 100644 --- a/tests/test_check_decisions.py +++ b/tests/test_check_decisions.py @@ -376,6 +376,16 @@ def test_release_milestones_no_v0_2_or_13_november(self): "The cycle originally ended 13 November (superseded by RELEASE-MILESTONES).\n"): self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + def test_lift_check_after_cross_repository_dry_run(self): + for text in ("- **Lift module:** separate OpenAMRobot 3.0 scope.\n", + "Scope: the future OpenAMRobot 3.0 lift controller.\n", + "There is no lift in 2.0.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), ["LIFT"], text) + for text in ("No lift firmware is implemented yet; the CAN3 lift interface is a release gate.\n", + "No lift controller exists yet.\n", + "Planning groups: arm, arm+lift.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + def test_legacy_label_exempts_compute(self): self.assertEqual(self.ids("README.md", "Legacy build: Raspberry Pi 5.\n", "openamr-platform-hw"), []) self.assertEqual(self.ids("README.md", "Compute: Raspberry Pi 5.\n", "openamr-platform-hw"), ["COMPUTE"]) From d54f45fdf379ea154097818c8cec9d13e96e0456 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:48:04 +0300 Subject: [PATCH 056/129] ci: add immutable live workflow policy checker Signed-off-by: Alex Reznichenko --- tools/check_workflow_policy.py | 72 ++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tools/check_workflow_policy.py diff --git a/tools/check_workflow_policy.py b/tools/check_workflow_policy.py new file mode 100644 index 0000000..6215e7f --- /dev/null +++ b/tools/check_workflow_policy.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Enforce provenance and placeholder rules for live GitHub Actions workflows. + +Only .github/workflows/*.yml and *.yaml are scanned. Examples under rollout/ are +intentionally outside this policy. Every external action reference must use a +full 40-character commit SHA; local reusable workflows and docker:// images are +not action references and are ignored. +""" +import argparse +import re +import sys +from pathlib import Path + +FULL_SHA = re.compile(r"^[0-9a-f]{40}$") +USES = re.compile(r"^\s*uses:\s*([^\s#]+)") + + +def workflow_files(root): + directory = Path(root) / ".github" / "workflows" + if not directory.is_dir(): + return [] + return sorted(path for path in directory.rglob("*") + if path.is_file() and path.suffix in {".yml", ".yaml"}) + + +def findings(root): + root = Path(root) + out = [] + for path in workflow_files(root): + rel = path.relative_to(root).as_posix() + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError) as exc: + out.append(f"{rel}:1: cannot read workflow: {exc}") + continue + for number, line in enumerate(lines, 1): + if "" in line: + out.append(f"{rel}:{number}: unresolved placeholder in live workflow") + match = USES.match(line) + if not match: + continue + reference = match.group(1) + if reference.startswith("./") or reference.startswith("docker://"): + continue + if "@" not in reference: + out.append(f"{rel}:{number}: action reference has no immutable @: {reference}") + continue + action, ref = reference.rsplit("@", 1) + if not action or not FULL_SHA.fullmatch(ref): + out.append(f"{rel}:{number}: action is not pinned to a full commit SHA: {reference}") + return out + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--root", type=Path, default=Path(".")) + args = parser.parse_args(argv) + if not args.root.is_dir(): + print(f"root is not a directory: {args.root}", file=sys.stderr) + return 2 + errors = findings(args.root) + for error in errors: + print(f"WORKFLOW-POLICY {error}") + if errors: + print(f"result: {len(errors)} workflow policy finding(s)") + return 1 + print("result: 0 workflow policy findings") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 24d4a3289f4bc6d1386e54520122647fd1cbd8fc Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:48:06 +0300 Subject: [PATCH 057/129] test: cover live workflow pinning and placeholders Signed-off-by: Alex Reznichenko --- tests/test_check_workflow_policy.py | 56 +++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 tests/test_check_workflow_policy.py diff --git a/tests/test_check_workflow_policy.py b/tests/test_check_workflow_policy.py new file mode 100644 index 0000000..6b87ddc --- /dev/null +++ b/tests/test_check_workflow_policy.py @@ -0,0 +1,56 @@ +"""Tests for tools/check_workflow_policy.py.""" +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import check_workflow_policy as policy # noqa: E402 + + +class WorkflowPolicy(unittest.TestCase): + def write(self, root, path, text): + target = Path(root, path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8") + + def test_full_sha_and_local_workflow_pass(self): + with tempfile.TemporaryDirectory() as tmp: + self.write(tmp, ".github/workflows/ok.yml", """ +name: ok +jobs: + build: + uses: ./.github/workflows/reusable.yml + test: + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 +""") + self.assertEqual(policy.findings(tmp), []) + + def test_tag_branch_and_placeholder_fail(self): + with tempfile.TemporaryDirectory() as tmp: + self.write(tmp, ".github/workflows/bad.yml", """ +jobs: + build: + steps: + - uses: actions/checkout@v4 + - uses: actions/upload-artifact@main + - uses: owner/action + - run: echo +""") + errors = policy.findings(tmp) + self.assertEqual(len(errors), 4) + self.assertTrue(any("full commit SHA" in e for e in errors)) + self.assertTrue(any("no immutable" in e for e in errors)) + self.assertTrue(any("" in e for e in errors)) + + def test_rollout_examples_are_outside_scope(self): + with tempfile.TemporaryDirectory() as tmp: + self.write(tmp, "rollout/workflows/example.yml", + "uses: actions/checkout@\n") + self.assertEqual(policy.findings(tmp), []) + + +if __name__ == "__main__": + unittest.main() From aed4ed85794bed124ff0db738e4d40eee4a04960 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:48:16 +0300 Subject: [PATCH 058/129] ci: pin reusable actions and enforce workflow and AGENTS policies Signed-off-by: Alex Reznichenko --- .../workflows/repository-quality-reusable.yml | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/.github/workflows/repository-quality-reusable.yml b/.github/workflows/repository-quality-reusable.yml index 5a280b5..1ea5188 100644 --- a/.github/workflows/repository-quality-reusable.yml +++ b/.github/workflows/repository-quality-reusable.yml @@ -25,6 +25,14 @@ on: type: boolean required: false default: false + agents_md_exception: + description: >- + Non-empty, human-readable reason to allow a repository without AGENTS.md when + harness_checks is true. The reason is printed in the job summary. + type: string + required: false + default: "" + verify: description: Run rollout/verify.sh (or the repository's own tools/verify.sh) as job quality/test. type: boolean @@ -45,7 +53,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Full history only when the harness checks run (they diff against the base); # default callers keep the shallow checkout. The string '0' keeps the @@ -54,7 +62,7 @@ jobs: - name: Check out OpenAMRobot harness if: inputs.harness_checks || inputs.harness_warn - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: openAMRobot/.github ref: ${{ inputs.harness_ref }} @@ -115,6 +123,24 @@ jobs: echo "scope=changed files ($(wc -l < "$RUNNER_TEMP/changed-files.txt"))" fi + - name: Workflow policy + if: inputs.harness_checks || inputs.harness_warn + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + ENFORCE: ${{ inputs.harness_checks }} + run: | + set -uo pipefail + h=.openamrobot-harness + python3 "$h/tools/check_workflow_policy.py" --root . | tee "$RUNNER_TEMP/workflow-policy.txt" + status=${PIPESTATUS[0]} + if [ "$ENFORCE" = true ] && { [ "$EVENT_NAME" = pull_request ] || [ "$status" -eq 2 ]; }; then + exit "$status" + fi + if [ "$status" -ne 0 ]; then + echo "::warning::workflow policy exit $status reported, not enforced (warn-only mode or push scan)" + fi + # harness_checks: true (enforcing): pull requests scan changed files and block; # push and schedule scan the full checkout and report warnings; an invalid # register or usage error (exit 2) always fails. @@ -177,6 +203,7 @@ jobs: EVENT_NAME: ${{ github.event_name }} REPOSITORY: ${{ github.event.repository.name }} ENFORCE: ${{ inputs.harness_checks }} + AGENTS_MD_EXCEPTION: ${{ inputs.agents_md_exception }} run: | set -uo pipefail if [ -f AGENTS.md ]; then @@ -188,7 +215,19 @@ jobs: echo "::warning::shared rules drift (exit $status), not enforced in warn-only mode" fi else - echo "No AGENTS.md in this repository; see rollout/README.md in openAMRobot/.github" + if [ -n "$AGENTS_MD_EXCEPTION" ]; then + echo "AGENTS.md exception: $AGENTS_MD_EXCEPTION" + { + echo "### AGENTS.md exception" + echo + echo "$AGENTS_MD_EXCEPTION" + } >> "$GITHUB_STEP_SUMMARY" + elif [ "$ENFORCE" = true ]; then + echo "::error::AGENTS.md is required when harness_checks is true; pass agents_md_exception with a human-readable reason" + exit 1 + else + echo "::warning::No AGENTS.md; pass agents_md_exception with a human-readable reason before enforcing" + fi fi verify: @@ -199,12 +238,12 @@ jobs: timeout-minutes: 30 steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Check out OpenAMRobot harness - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: openAMRobot/.github ref: ${{ inputs.harness_ref }} @@ -218,7 +257,7 @@ jobs: - name: Upload verification evidence if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: verification-evidence path: .verification/run.*/ From c58fa78847a3e909a0f79cd5b61dc7be62a41873 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:48:17 +0300 Subject: [PATCH 059/129] ci: pin actions and add real ROS Jazzy verifier smoke job Signed-off-by: Alex Reznichenko --- .github/workflows/repository-quality.yml | 47 +++++++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/.github/workflows/repository-quality.yml b/.github/workflows/repository-quality.yml index 28a1349..39ddb8f 100644 --- a/.github/workflows/repository-quality.yml +++ b/.github/workflows/repository-quality.yml @@ -4,6 +4,7 @@ on: pull_request: push: branches: [main] + workflow_dispatch: permissions: contents: read @@ -21,7 +22,7 @@ jobs: name: quality/test runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Unit tests of every checker (zero-test guard) @@ -36,9 +37,51 @@ jobs: run: python3 tools/check_decisions.py --decisions decisions.yaml --maintainers maintainers.yaml --validate-only - name: Upload evidence if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: harness-verification path: .verification/run.*/ include-hidden-files: true if-no-files-found: warn + + ros-verifier-smoke: + name: ros-verifier-smoke + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - name: Check out harness + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Check out pinned interfaces fixture + env: + INTERFACES_SHA: fa7c438e33bd807c174fa0747119d7d627faa3bf + run: | + set -euo pipefail + interfaces="$RUNNER_TEMP/openamrobot-interfaces" + git clone --quiet https://github.com/openAMRobot/openamrobot-interfaces.git "$interfaces" + git -C "$interfaces" checkout --quiet --detach "$INTERFACES_SHA" + # Exercise the source-only scan with normal generated workspace directories present. + mkdir -p "$interfaces/ros2/build" "$interfaces/ros2/install" "$interfaces/ros2/log" + test -d "$interfaces/ros2/build" + test -d "$interfaces/ros2/install" + test -d "$interfaces/ros2/log" + + - name: Run generic verifier in ROS 2 Jazzy + run: | + set -euo pipefail + interfaces="$RUNNER_TEMP/openamrobot-interfaces" + docker run --rm --init \ + -v "$GITHUB_WORKSPACE:/work/harness:ro" \ + -v "$interfaces:/work/interfaces" \ + -w /work \ + ros:jazzy-ros-base bash -ceu ' + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y git python3-pip shellcheck + rosdep update + test -d /work/interfaces/ros2/build + test -d /work/interfaces/ros2/install + test -d /work/interfaces/ros2/log + VERIFY_NO_DELEGATE=1 bash /work/harness/rollout/verify.sh /work/interfaces + ' From e717b77e70b0019b6d6d7450f6b9d35b281f7a96 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:48:25 +0300 Subject: [PATCH 060/129] pr: require tool and scope in AI disclosure Signed-off-by: Alex Reznichenko --- tools/check_pr_evidence.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/tools/check_pr_evidence.py b/tools/check_pr_evidence.py index ce5ee04..08e0854 100644 --- a/tools/check_pr_evidence.py +++ b/tools/check_pr_evidence.py @@ -81,7 +81,30 @@ def human(login): return bool(login) and not login.endswith("[bot]") -def evaluate(pr, changed, maintainers, reviews=(), has_state=False): +AI_TOOL = re.compile(r"\\b(?:Claude(?:\\s+Code)?|Anthropic|ChatGPT|OpenAI|Codex|Copilot|Gemini|Cursor|Devin)\\b", re.I) +AI_MARKER = re.compile(r"(?:AI[-\\s]+assisted|generated with|co-authored-by:.*(?:bot|claude|copilot|chatgpt|openai|anthropic|codex))", re.I) +AI_SCOPE = re.compile(r"\\b(?:scope|assisted|drafted|generated|reviewed|changed|implemented|tested|research|documentation|workflow|code|text|analysis|reconciliation)\\b", re.I) + + +def ai_assistance_detected(pr, commit_messages=()): + text = (pr.get("body") or "") + "\n" + "\n".join(commit_messages or ()) + return bool(AI_TOOL.search(text) or AI_MARKER.search(text)) + + +def check_ai_disclosure(pr, commit_messages, disclosure): + if not ai_assistance_detected(pr, commit_messages): + return [] + if not disclosure or NONE.match(disclosure): + return ["AI assistance is visible in the PR or commit messages, but AI disclosure is empty; name the tool and scope"] + failures = [] + if not AI_TOOL.search(disclosure): + failures.append("AI disclosure must name the AI tool used (for example Claude Code, ChatGPT or Codex)") + if not AI_SCOPE.search(disclosure): + failures.append("AI disclosure must state the scope of assistance (what it drafted, changed, tested or reviewed)") + return failures + + +def evaluate(pr, changed, maintainers, reviews=(), has_state=False, commit_messages=()): """Return (failures, warnings, notes) for a pull_request payload.""" failures, warnings, notes = [], [], [] secs = sections(pr.get("body") or "") @@ -129,6 +152,9 @@ def evaluate(pr, changed, maintainers, reviews=(), has_state=False): if not re.search(r"no change\W+\w", section, re.I): failures.append("STATE.md exists but is not updated; update it or write 'no change' with a reason") + ai_disclosure = find(secs, "AI disclosure") or "" + failures.extend(check_ai_disclosure(pr, commit_messages, ai_disclosure)) + roles = (maintainers or {}).get("roles", {}) lead = (roles.get("platform-lead") or {}).get("handle") safety = [p for p in changed if any(fnmatch.fnmatch(p, g) or fnmatch.fnmatch(p, g.replace("**/", "")) @@ -252,6 +278,7 @@ def main(argv=None): p.add_argument("--changed-files", type=Path, required=True) p.add_argument("--maintainers", type=Path, required=True) p.add_argument("--reviews", type=Path, help="JSON list of PR reviews") + p.add_argument("--commit-messages", type=Path, help="one or more PR commit messages, one per line or JSON text") p.add_argument("--root", type=Path, help="checkout of the PR head; enables the STATE.md rule") p.add_argument("--decisions-report", type=Path, help="combined output of check_decisions.py on the diff") @@ -270,8 +297,9 @@ def main(argv=None): changed = [l.strip() for l in a.changed_files.read_text(encoding="utf-8").splitlines() if l.strip()] maintainers = yaml.safe_load(a.maintainers.read_text(encoding="utf-8")) reviews = json.loads(a.reviews.read_text(encoding="utf-8")) if a.reviews else [] + commit_messages = a.commit_messages.read_text(encoding="utf-8").splitlines() if a.commit_messages else [] has_state = bool(a.root and (a.root / "STATE.md").is_file()) - failures, warnings, notes = evaluate(pr, changed, maintainers, reviews, has_state) + failures, warnings, notes = evaluate(pr, changed, maintainers, reviews, has_state, commit_messages) checker_errors = [] if a.decisions_report or a.decisions_status: report = status = None From 286f970214e144b192a70bd357e62b6631a42d4c Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:48:27 +0300 Subject: [PATCH 061/129] pr: pass commit messages to evidence checker Signed-off-by: Alex Reznichenko --- rollout/workflows/pr-assistant.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rollout/workflows/pr-assistant.yml b/rollout/workflows/pr-assistant.yml index 3d68a3c..ef39fda 100644 --- a/rollout/workflows/pr-assistant.yml +++ b/rollout/workflows/pr-assistant.yml @@ -61,6 +61,8 @@ jobs: # Only files present at the PR head: the decisions scan reads their content. jq -r '.[] | select(.status != "removed") | .filename' files.json | sort -u > changed_existing.txt gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/reviews" --paginate > reviews.json + gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/commits" --paginate | + jq -r '.[].commit.message' > commit-messages.txt echo "changed (all): $(wc -l < changed_all.txt); present at head: $(wc -l < changed_existing.txt)" - name: Decisions of record on the diff @@ -85,6 +87,6 @@ jobs: run: | python3 harness/tools/check_pr_evidence.py --event "$GITHUB_EVENT_PATH" \ --changed-files changed_all.txt --maintainers harness/maintainers.yaml \ - --reviews reviews.json --root pr-head --decisions-report decisions.txt \ + --reviews reviews.json --commit-messages commit-messages.txt --root pr-head --decisions-report decisions.txt \ --decisions-status decisions-status.json \ --output "$GITHUB_STEP_SUMMARY" --post From e6f7250584fd5ccfd8c165db107b5a7c4b02aa43 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:48:38 +0300 Subject: [PATCH 062/129] fix: use word-boundary AI disclosure matching Signed-off-by: Alex Reznichenko --- tools/check_pr_evidence.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/check_pr_evidence.py b/tools/check_pr_evidence.py index 08e0854..0c35491 100644 --- a/tools/check_pr_evidence.py +++ b/tools/check_pr_evidence.py @@ -81,9 +81,9 @@ def human(login): return bool(login) and not login.endswith("[bot]") -AI_TOOL = re.compile(r"\\b(?:Claude(?:\\s+Code)?|Anthropic|ChatGPT|OpenAI|Codex|Copilot|Gemini|Cursor|Devin)\\b", re.I) -AI_MARKER = re.compile(r"(?:AI[-\\s]+assisted|generated with|co-authored-by:.*(?:bot|claude|copilot|chatgpt|openai|anthropic|codex))", re.I) -AI_SCOPE = re.compile(r"\\b(?:scope|assisted|drafted|generated|reviewed|changed|implemented|tested|research|documentation|workflow|code|text|analysis|reconciliation)\\b", re.I) +AI_TOOL = re.compile(r"\b(?:Claude(?:\s+Code)?|Anthropic|ChatGPT|OpenAI|Codex|Copilot|Gemini|Cursor|Devin)\b", re.I) +AI_MARKER = re.compile(r"(?:AI[-\s]+assisted|generated with|co-authored-by:.*(?:bot|claude|copilot|chatgpt|openai|anthropic|codex))", re.I) +AI_SCOPE = re.compile(r"\b(?:scope|assisted|drafted|generated|reviewed|changed|implemented|tested|research|documentation|workflow|code|text|analysis|reconciliation)\b", re.I) def ai_assistance_detected(pr, commit_messages=()): From c73b066c1c2ca81fe2fd8e3c61edf384a6fbdc26 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:48:45 +0300 Subject: [PATCH 063/129] test: enforce meaningful AI disclosure Signed-off-by: Alex Reznichenko --- tests/test_check_pr_evidence.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/test_check_pr_evidence.py b/tests/test_check_pr_evidence.py index 790ee99..738acf5 100644 --- a/tests/test_check_pr_evidence.py +++ b/tests/test_check_pr_evidence.py @@ -65,7 +65,8 @@ def pr(body=BODY, draft=False, reviewers=(), author="contributor"): def evaluate(body=BODY, changed=("src/node.py",), **kw): reviews = kw.pop("reviews", ()) has_state = kw.pop("has_state", False) - return ev.evaluate(pr(body, **kw), list(changed), MAINTAINERS, reviews, has_state) + commit_messages = kw.pop("commit_messages", ()) + return ev.evaluate(pr(body, **kw), list(changed), MAINTAINERS, reviews, has_state, commit_messages) class Sections(unittest.TestCase): @@ -177,6 +178,23 @@ def test_ai_assisted_safety_change_fails(self): failures = evaluate(body, changed=["fw/watchdog.c"], reviewers=["BotshareAI", "panthera-momagdii"])[0] self.assertTrue(any("agents do not author safety logic" in f for f in failures)) + def test_ai_disclosure_names_tool_and_scope(self): + body = BODY.replace("## AI disclosure\nNone", "## AI disclosure\nAI-assisted.") + failures = evaluate(body, commit_messages=["Generated with Claude Code"])[0] + self.assertTrue(any("must name the AI tool" in f for f in failures)) + self.assertTrue(any("must state the scope" in f for f in failures)) + + def test_ai_disclosure_can_pass_with_tool_and_scope(self): + body = BODY.replace( + "## AI disclosure\nNone", + "## AI disclosure\nClaude Code drafted the documentation and tests; a human reviewed the diff." + ) + self.assertEqual(evaluate(body, commit_messages=["Generated with Claude Code"])[0], []) + + def test_ai_markers_in_commit_messages_are_checked(self): + failures = evaluate(commit_messages=["Implement feature\n\nCo-Authored-By: Claude "])[0] + self.assertTrue(any("AI assistance is visible" in f for f in failures)) + class DecisionReport(unittest.TestCase): def test_contradictions_become_failures(self): From 6abd4edc5bb40d4d7a4f5abd92dae479f260fa77 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:48:52 +0300 Subject: [PATCH 064/129] test: cover workflow policy and AGENTS exception gate Signed-off-by: Alex Reznichenko --- tests/test_reusable_workflow.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/tests/test_reusable_workflow.py b/tests/test_reusable_workflow.py index 66aa046..fb20847 100644 --- a/tests/test_reusable_workflow.py +++ b/tests/test_reusable_workflow.py @@ -14,8 +14,8 @@ ROOT = Path(__file__).resolve().parents[1] WORKFLOW = ROOT / ".github" / "workflows" / "repository-quality-reusable.yml" -HARNESS_STEPS = ["Check out OpenAMRobot harness", "Prepare harness checks", "Decisions of record", - "Public extract", "Shared agent rules"] +HARNESS_STEPS = ["Check out OpenAMRobot harness", "Prepare harness checks", "Workflow policy", + "Decisions of record", "Public extract", "Shared agent rules"] def load(): @@ -36,17 +36,22 @@ class HarnessModes(unittest.TestCase): def setUp(self): self.inputs, self.steps = load() - def run_step(self, name, code, enforce, event="pull_request", tool="check_decisions.py"): + def run_step(self, name, code, enforce, event="pull_request", tool="check_decisions.py", + agents=True, exception=""): with tempfile.TemporaryDirectory() as tmp: tools = Path(tmp, ".openamrobot-harness", "tools") tools.mkdir(parents=True) - for t in ("check_decisions.py", "check_public_extract.py", "check_agent_rules.py"): - line = "CONTRADICTION a.md:1: X found 'a'" if t == "check_decisions.py" else "PUBLIC-EXTRACT a.md:1: price: 5" + for t in ("check_decisions.py", "check_public_extract.py", "check_agent_rules.py", + "check_workflow_policy.py"): + line = ("CONTRADICTION a.md:1: X found 'a'" if t == "check_decisions.py" + else "PUBLIC-EXTRACT a.md:1: price: 5") (tools / t).write_text(FAKE.format(line=line, code=code if t == tool else 0), encoding="utf-8") - Path(tmp, "AGENTS.md").write_text("x\n", encoding="utf-8") + if agents: + Path(tmp, "AGENTS.md").write_text("x\n", encoding="utf-8") Path(tmp, "changed-files.txt").write_text("a.md\n", encoding="utf-8") - env = dict(os.environ, EVENT_NAME=event, REPOSITORY="demo", ENFORCE="true" if enforce else "false", - RUNNER_TEMP=tmp) + env = dict(os.environ, EVENT_NAME=event, REPOSITORY="demo", + ENFORCE="true" if enforce else "false", + AGENTS_MD_EXCEPTION=exception, RUNNER_TEMP=tmp) proc = subprocess.run(["bash", "-c", self.steps[name]["run"]], cwd=tmp, env=env, capture_output=True, text=True) return proc.returncode, proc.stdout + proc.stderr @@ -95,6 +100,15 @@ def test_enforce_on_push_warns_but_fails_on_invalid_register(self): def test_enforce_fails_on_shared_rules_drift(self): self.assertEqual(self.run_step("Shared agent rules", 1, enforce=True, tool="check_agent_rules.py")[0], 1) + def test_enforce_requires_agents_file_or_exception(self): + code, out = self.run_step("Shared agent rules", 0, enforce=True, agents=False) + self.assertEqual(code, 1, out) + self.assertIn("AGENTS.md is required", out) + code, out = self.run_step("Shared agent rules", 0, enforce=True, agents=False, + exception="legacy repository; migration tracked in #43") + self.assertEqual(code, 0, out) + self.assertIn("AGENTS.md exception", out) + def test_clean_run_passes_in_both_modes(self): for enforce in (True, False): self.assertEqual(self.run_step("Decisions of record", 0, enforce=enforce)[0], 0) From 9dfd000b329d72d546f2708722125c241804e771 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:49:06 +0300 Subject: [PATCH 065/129] governance: validate decision provenance review windows Signed-off-by: Alex Reznichenko --- tools/check_decisions.py | 43 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/tools/check_decisions.py b/tools/check_decisions.py index da30bbb..88cd161 100644 --- a/tools/check_decisions.py +++ b/tools/check_decisions.py @@ -21,6 +21,7 @@ or the line directly above it. The marker is reported, never silently ignored. """ import argparse +from datetime import date import fnmatch import json import re @@ -33,8 +34,9 @@ SCHEMA_VERSION = 1 STATUSES = {"recorded", "open", "superseded"} KINDS = {"value", "configuration", "limit", "exclusion", "distinction"} -REQUIRED = ("id", "title", "kind", "status", "date", "source", "applies_to", +REQUIRED = ("id", "title", "kind", "status", "date", "review_by", "source", "applies_to", "verification", "owner") +DATE_FORMAT = re.compile(r"^\\d{4}-\\d{2}-\\d{2}$") DEFAULT_FILES = [ "**/*.md", "**/*.yaml", "**/*.yml", "**/*.launch.py", "**/*.launch.xml", "**/*.launch", "**/*.urdf", "**/*.xacro", "**/package.xml", "**/README*", @@ -47,6 +49,30 @@ class DecisionError(ValueError): pass + +def as_date(value): + """Return a date for an ISO date or a YAML date, otherwise None.""" + if isinstance(value, date): + return value + if isinstance(value, str) and DATE_FORMAT.fullmatch(value): + try: + return date.fromisoformat(value) + except ValueError: + return None + return None + + +def review_warnings(data, today=None): + """Return non-blocking warnings for entries whose review window has passed.""" + today = today or date.today() + warnings = [] + for entry in (data or {}).get("decisions") or []: + review_by = as_date(entry.get("review_by")) if isinstance(entry, dict) else None + if review_by and review_by < today: + warnings.append(f"{entry.get('id', '')} review_by {review_by.isoformat()} is past due") + return warnings + + def load_decisions(path, maintainers=None): """Load and validate decisions.yaml; return the list of decisions.""" try: @@ -64,6 +90,11 @@ def load_decisions(path, maintainers=None): roles = set((yaml.safe_load(Path(maintainers).read_text(encoding="utf-8")) or {}).get("roles", {})) seen = set() errors = [] + in_force = data.get("in_force") + if not isinstance(in_force, dict) or not in_force.get("source") or not as_date(in_force.get("date")): + errors.append(f"{path}: in_force needs source and ISO date") + elif in_force["source"] not in sources: + errors.append(f"{path}: in_force source {in_force['source']!r} not listed under sources") for index, d in enumerate(decisions): where = f"decisions[{index}]" if not isinstance(d, dict): @@ -83,6 +114,10 @@ def load_decisions(path, maintainers=None): errors.append(f"{where}: status must be one of {sorted(STATUSES)}") if d["kind"] not in KINDS: errors.append(f"{where}: kind must be one of {sorted(KINDS)}") + if d.get("date") is not None and as_date(d.get("date")) is None: + errors.append(f"{where}: date must be null or an ISO date") + if as_date(d.get("review_by")) is None: + errors.append(f"{where}: review_by must be an ISO date") ver = d["verification"] human = ver.get("human") if isinstance(ver, dict) else None if not isinstance(ver, dict) or not ver.get("machine") or not isinstance(human, dict) \ @@ -279,6 +314,12 @@ def main(argv=None): f"{sum(d['status'] == 'recorded' for d in decisions)} recorded and scanned, " f"{sum(d['status'] == 'open' for d in decisions)} open, " f"{sum(d['status'] == 'superseded' for d in decisions)} superseded (citations scanned)") + try: + register_data = yaml.safe_load(a.decisions.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError): + register_data = {} + for warning in review_warnings(register_data): + print(f"WARNING {warning}") if a.validate_only: return 0 if not a.root.is_dir(): From 09bf7de1f73d7659735be9bc26fa0a86a75c8283 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:49:12 +0300 Subject: [PATCH 066/129] fix: accept ISO dates in decision schema Signed-off-by: Alex Reznichenko --- tools/check_decisions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/check_decisions.py b/tools/check_decisions.py index 88cd161..8bdc522 100644 --- a/tools/check_decisions.py +++ b/tools/check_decisions.py @@ -36,7 +36,7 @@ KINDS = {"value", "configuration", "limit", "exclusion", "distinction"} REQUIRED = ("id", "title", "kind", "status", "date", "review_by", "source", "applies_to", "verification", "owner") -DATE_FORMAT = re.compile(r"^\\d{4}-\\d{2}-\\d{2}$") +DATE_FORMAT = re.compile(r"^\d{4}-\d{2}-\d{2}$") DEFAULT_FILES = [ "**/*.md", "**/*.yaml", "**/*.yml", "**/*.launch.py", "**/*.launch.xml", "**/*.launch", "**/*.urdf", "**/*.xacro", "**/package.xml", "**/README*", From 2f7a6389b5b727731beb7838c37596e542cb8de3 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:49:18 +0300 Subject: [PATCH 067/129] governance: record decision source and review windows Signed-off-by: Alex Reznichenko --- decisions.yaml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/decisions.yaml b/decisions.yaml index 9613a7f..6addf3d 100644 --- a/decisions.yaml +++ b/decisions.yaml @@ -27,6 +27,7 @@ # value/values exactly as the source states it; nothing invented # unit SI unit or none # date date the decision was taken (null when the source gives none) +# review_by review date; six weeks after date, or six weeks after this register update when date is null # source document (a key under sources) and item # supersedes earlier values with their source; optional `citation`, a # pattern that finds text still citing the superseded source @@ -48,6 +49,10 @@ # every affected consumer together. schema_version: 1 +in_force: + source: P-03-rev18.7 + date: 2026-10-06 + sources: P-03-rev18.2: title: P-03 Decision Addendum, revision 18.2, 28 September 2026 (earlier revision; superseded by revision 18.7 as the addendum in force) @@ -105,6 +110,7 @@ decisions: value: OpenAMRobot 2.0 Detailed Hardware BOM, Issue 7.3 unit: none date: 2026-10-06 + review_by: 2026-11-17 source: {document: P-03-rev18.7, item: BOM issue in force} supersedes: - {value: Issue 7, source: P-03-rev18.2 item 7} @@ -136,6 +142,7 @@ decisions: unit: mm configuration_id: mast_1350 date: 2026-09-28 + review_by: 2026-11-09 source: {document: P-03-rev18.2, item: shoulder height} supersedes: - value: 1400 @@ -165,6 +172,7 @@ decisions: unit: mm step_mm: 50 date: 2026-09-28 + review_by: 2026-11-09 source: {document: P-03-rev18.2, item: shoulder height} supersedes: - {value: nine positions 1300 to 1700 mm (mast_1300 to mast_1700), source: P-00-rev18.1 txt 293-294} @@ -189,6 +197,7 @@ decisions: value: 1500 unit: mm date: 2026-09-28 + review_by: 2026-11-09 source: {document: P-03-rev18.2, item: mast} supersedes: - {value: 1554, source: general arrangement before 28 September 2026} @@ -213,6 +222,7 @@ decisions: value: 1700 unit: mm date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) source: {document: P-03-rev18.7, item: item 8} note: First recorded in P-03-rev18.1 item 6 line 15 and repeated in rev18.2; kept unchanged by rev18.7 item 8 with the lift. The robot may be lower, never higher. applies_to: @@ -241,6 +251,7 @@ decisions: - base-plate top face 304 mm is the reference for lift and shoulder heights unit: mm date: 2026-10-06 + review_by: 2026-11-17 source: {document: P-03-rev18.7, item: item 15} applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/*.xacro", "**/*.urdf"]} check: @@ -265,6 +276,7 @@ decisions: - imu_link on the centreline, away from motor magnetic fields unit: none date: 2026-10-06 + review_by: 2026-11-17 source: {document: P-03-rev18.7, item: item 15} applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/*.xacro", "**/*.urdf", "**/*.launch.py"]} check: @@ -289,6 +301,7 @@ decisions: value: One 8S1P EVE LF105 LiFePO4 pack, 25.6 V, 105 Ah, placed as close to the rear edge as practical while preserving enclosure, service and safety clearances unit: none date: 2026-10-06 + review_by: 2026-11-17 source: {document: P-03-rev18.7, item: item 9} supersedes: - {value: fit the existing battery bay first, source: P-00-rev18.1 txt 77} @@ -311,6 +324,7 @@ decisions: value: 1.5 m/s command ceiling, treated as an analytical limit; the accepted operating speed follows from the stability model and stopping tests unit: m/s date: 2026-09-28 + review_by: 2026-11-09 source: {document: P-03-rev18.2, item: speed} supersedes: - {value: 1.5 m/s treated as an accepted operating speed, source: P-00-rev18.1 txt 60 and 352} @@ -334,6 +348,7 @@ decisions: value: Two ZLTECH ZLLG80ASM250-L-B hub motors with brakes, one ZLAC8015D V4.2 driver on CAN1 (CANopen), 200 mm wheels unit: none date: 2026-09-21 + review_by: 2026-11-02 source: {document: P-00-rev18.1, item: txt 16} applies_to: repositories: ["openamr-platform-*", "openamr-upperbody-*", "openamrobot-docs", "openamrobot-release", ".github"] @@ -358,6 +373,7 @@ decisions: value: none unit: none date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) source: {document: P-03-rev18.1, item: item 4} applies_to: repositories: ["openamr-platform-hw", "openamr-platform-fw", "openamr-upperbody-*", "openamrobot-docs"] @@ -384,6 +400,7 @@ decisions: - STM32 go/no-go 6 November 2026; fallback is the validated legacy Teensy/PWM build behind I8 unit: none date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) source: {document: P-00-rev18.1, item: "txt 63, 101, 106, 344; I8-WP adoption gate; Gate B board per P-03-rev18.5 item 14"} applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} check: @@ -412,6 +429,7 @@ decisions: - MCU to Jetson over Ethernet, micro-ROS over UDP; USB for the bench only unit: none date: 2026-10-06 + review_by: 2026-11-17 source: {document: P-03-rev18.7, item: item 14} supersedes: - {value: STM32H743 on NUCLEO-H743ZI2, source: P-00-rev18.1 (Gate B bench board)} @@ -441,6 +459,7 @@ decisions: - the host filter and EKF own /imu/data unit: none date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) source: {document: P-00-rev18.1, item: txt 15 and 345; I8-WP line 224} applies_to: repositories: ["openamr-platform-*", "openamrobot-docs", "openamrobot-ui"] @@ -467,6 +486,7 @@ decisions: - two in-hand RGB wrist cameras; no separate torso scene camera unit: none date: 2026-10-06 + review_by: 2026-11-17 source: {document: P-03-rev18.7, item: "item 15 (wrist cameras as in P-00-rev18.1 txt 85 and 97)"} supersedes: - {value: "base camera tilted about 10 degrees upward; head camera identity open", source: P-00-rev18.1 txt 85 and 97} @@ -500,6 +520,7 @@ decisions: - pitch 15 to 35 degrees down in 5 degree steps, baseline 25 degrees unit: none date: 2026-10-06 + review_by: 2026-11-17 source: {document: P-03-rev18.7, item: item 15} supersedes: - {value: "ZED-121210 per the plan; identity open because the general arrangement named a different ZED model", source: P-00-rev18.1 txt 85} @@ -524,6 +545,7 @@ decisions: - no 12 V rail unit: V date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) source: {document: P-03-rev18.1, item: "item 2 line 10; P-03-rev18.4 item 13"} applies_to: repositories: ["openamr-platform-*", "openamr-upperbody-*", "openamrobot-docs"] @@ -547,6 +569,7 @@ decisions: value: Positioning only; manual wired charging via PWR-019 with independent charge-plug-presence inhibition; no dock contacts or dock pilot; wireless charging belongs to 3.0 unit: none date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) source: {document: P-03-rev18.1, item: item 3 line 11} applies_to: repositories: ["openamr-platform-*", "openamrobot-docs", "openamrobot-ui", "openamrobot-interfaces"] @@ -573,6 +596,7 @@ decisions: value: Docked means an accepted pose within tolerance; pose success never establishes charging or external power unit: none date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) source: {document: P-03-rev18.1, item: item 3 line 11; DOCK-WP lines 27 and 70} applies_to: repositories: ["openamr-platform-*", "openamrobot-docs", "openamrobot-ui", "openamrobot-interfaces"] @@ -598,6 +622,7 @@ decisions: value: Firmware status, watchdogs, collision monitoring and BMS telemetry are functional; E-stop, brake and actuator power removal are hardwired and independent of software unit: none date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) source: {document: P-00-rev18.1, item: txt 70 and 78; I8-WP line 669; DOCK-WP line 110} applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.html", "**/*.yaml", "**/*.yml"]} check: @@ -619,6 +644,7 @@ decisions: - CTL-004 is bench-only until an installed solution is qualified unit: none date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) source: {document: P-03-rev18.1, item: items 4 and 5 lines 12-13} applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} check: @@ -642,6 +668,7 @@ decisions: value: NVIDIA Jetson Orin NX 16 GB on a reComputer Robotics J401 carrier with NVMe; Raspberry Pi removed from active support unit: none date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) source: {document: P-00-rev18.1, item: "txt 14, 90 and 278"} applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} check: @@ -661,6 +688,7 @@ decisions: unit: none backup: RPLIDAR S2E, only if the S3 is unavailable date: 2026-10-03 + review_by: 2026-11-14 source: {document: P-03-rev18.4, item: item 13} supersedes: - {value: Hokuyo UST-10LX, source: P-03-rev18.3 item 11 (dropped on cost)} @@ -690,6 +718,7 @@ decisions: - "18 December 2026: OpenAMRobot 2.0 final release, version v2.0.0" unit: none date: 2026-10-01 + review_by: 2026-11-12 source: {document: P-03-rev18.6, item: item 12} applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.html", "**/*.yaml", "**/*.yml"]} check: @@ -722,6 +751,7 @@ decisions: - "release gates: holding on E-stop and power loss, supplier CAD, base-plate geometry and the CAN3 lift interface (items 6, 8, 14, 15)" unit: none date: 2026-10-06 + review_by: 2026-11-17 source: {document: P-03-rev18.7, item: "item 8; release gates items 6, 8, 14, 15"} supersedes: - {value: "Lift removed from OpenAMRobot 2.0; fixed mast; lift on the 3.0 roadmap (former entry LIFT-REMOVED)", source: P-00-rev18.1 txt 18} @@ -748,6 +778,7 @@ decisions: value: No suspension is fitted in 2.0; sprung drive wheels are a 3.0 item unit: none date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) source: {document: P-03-rev18.1, item: item 1} applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} check: @@ -766,6 +797,7 @@ decisions: value: 400 mm in the general arrangement; the final track is an open input unit: mm date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) source: {document: P-00-rev18.1, item: open inputs} applies_to: {repositories: ["openamr-platform-*", "openamrobot-ui"], files: ["**/*.xacro", "**/*.urdf", "**/*.yaml", "**/*.sdf"]} check: From 6d8768f380f7246fab0c04578ba67ba89ae7b665 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:49:25 +0300 Subject: [PATCH 068/129] test: cover decision source and review metadata Signed-off-by: Alex Reznichenko --- tests/test_check_decisions.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_check_decisions.py b/tests/test_check_decisions.py index 9dfe07e..54fd3dd 100644 --- a/tests/test_check_decisions.py +++ b/tests/test_check_decisions.py @@ -124,6 +124,7 @@ def write(self, text): return tmp.name BASE = """schema_version: 1 +in_force: {source: D, date: 2026-10-07} sources: {D: {title: t}} decisions: - id: A @@ -133,6 +134,7 @@ def write(self, text): value: 1 unit: mm date: null + review_by: 2026-11-18 source: {document: D, item: i} applies_to: {repositories: ["*"], files: ["**/*.md"]} check: [{pattern: '(?Px)'}] @@ -143,6 +145,18 @@ def write(self, text): def test_base_is_valid(self): self.assertEqual(len(cd.load_decisions(self.write(self.BASE), ROOT / "maintainers.yaml")), 1) + def test_requires_in_force_and_review_by(self): + with self.assertRaisesRegex(cd.DecisionError, "in_force"): + cd.load_decisions(self.write(self.BASE.replace("in_force: {source: D, date: 2026-10-07}\n", ""))) + with self.assertRaisesRegex(cd.DecisionError, "review_by"): + cd.load_decisions(self.write(self.BASE.replace(" review_by: 2026-11-18\n", ""))) + + def test_review_warnings_are_non_blocking_and_deterministic(self): + data = {"decisions": [{"id": "A", "review_by": "2026-10-06"}, + {"id": "B", "review_by": "2026-10-08"}]} + self.assertEqual(cd.review_warnings(data, cd.date(2026, 10, 7)), + ["A review_by 2026-10-06 is past due"]) + def test_rejects_invalid_entries(self): cases = { "duplicate id": self.BASE + self.BASE.split("decisions:\n", 1)[1], From 31c9b31553e4b500ef7b9a5789b326b9ced79d04 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:49:31 +0300 Subject: [PATCH 069/129] audit: report overdue decision reviews as warnings Signed-off-by: Alex Reznichenko --- tools/sync_audit_issues.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tools/sync_audit_issues.py b/tools/sync_audit_issues.py index de10937..8e8947d 100644 --- a/tools/sync_audit_issues.py +++ b/tools/sync_audit_issues.py @@ -16,6 +16,7 @@ Exit status: 0 success, 2 usage error. """ import argparse +from datetime import date import csv import json import os @@ -33,6 +34,25 @@ REPO = re.compile(r"\b(openamr(?:obot)?-[a-z0-9-]+|\.github)\b") + +def review_warnings(path, today=None): + """Return warnings for decision entries whose review window has passed.""" + data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {} + today = today or date.today() + warnings = [] + for entry in data.get("decisions") or []: + value = entry.get("review_by") if isinstance(entry, dict) else None + if isinstance(value, date): + review_by = value + else: + try: + review_by = date.fromisoformat(str(value)) + except (TypeError, ValueError): + continue + if review_by < today: + warnings.append(f"{entry.get('id', '')} review_by {review_by.isoformat()} is past due") + return warnings + def read_csv(path): with open(path, newline="", encoding="utf-8") as stream: return list(csv.DictReader(stream)) @@ -148,6 +168,7 @@ def main(argv=None): p.add_argument("--org", default="openAMRobot") p.add_argument("--apply", action="store_true", help="fetch existing issues, then create and close via the API") p.add_argument("--plan-output", type=Path) + p.add_argument("--decisions", type=Path, help="decision register; past review_by dates are warnings") a = p.parse_args(argv) maintainers = yaml.safe_load(a.maintainers.read_text(encoding="utf-8")) rows = read_csv(a.issues) @@ -160,6 +181,9 @@ def main(argv=None): return 2 existing = json.loads(a.existing.read_text(encoding="utf-8")) if a.existing else ( fetch_existing(a.org, token) if a.apply else []) + warnings = review_warnings(a.decisions) if a.decisions else [] + for warning in warnings: + print(f"WARNING decision-review {warning}") to_open, to_notify = plan(rows, existing, maintainers, a.fallback_repository, a.report) for issue in to_open: print(f"OPEN {issue['repository']}: {issue['title']}") @@ -167,7 +191,7 @@ def main(argv=None): print(f"COMMENT {issue['repository']}#{issue['number']}: {issue['id']} no longer detected (not closed)") print(f"plan: {len(to_open)} to open, {len(to_notify)} to comment 'no longer detected', 0 closed") if a.plan_output: - a.plan_output.write_text(json.dumps({"open": to_open, "no_longer_detected": to_notify}, indent=2), + a.plan_output.write_text(json.dumps({"open": to_open, "no_longer_detected": to_notify, "decision_review_warnings": warnings}, indent=2), encoding="utf-8") if a.apply: apply(a.org, to_open, to_notify, token, a.report) From 8e909d5d80d05ea3502df5f61186b32fdcc117d4 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:49:35 +0300 Subject: [PATCH 070/129] test: cover overdue decision warnings in audit sync Signed-off-by: Alex Reznichenko --- tests/test_sync_audit_issues.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_sync_audit_issues.py b/tests/test_sync_audit_issues.py index 034d761..ade8f42 100644 --- a/tests/test_sync_audit_issues.py +++ b/tests/test_sync_audit_issues.py @@ -110,6 +110,22 @@ def test_fetch_existing_parses_search(self): class CommandLine(unittest.TestCase): + def test_review_warnings_are_reported(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp, "decisions.yaml") + path.write_text( + "decisions:\n" + " - id: OLD\n" + " review_by: 2026-10-06\n" + " - id: CURRENT\n" + " review_by: 2026-10-08\n", + encoding="utf-8", + ) + self.assertEqual( + sai.review_warnings(path, sai.date(2026, 10, 7)), + ["OLD review_by 2026-10-06 is past due"], + ) + def test_dry_run_from_csv(self): with tempfile.TemporaryDirectory() as tmp: path = Path(tmp, "ISSUES.csv") From 1eb65e88ae6f4a56a4bfff6e367266775f563235 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:49:48 +0300 Subject: [PATCH 071/129] governance: define platform-lead author approval rule Signed-off-by: Alex Reznichenko --- GOVERNANCE.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/GOVERNANCE.md b/GOVERNANCE.md index a58ce0b..e5d0c10 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -40,6 +40,22 @@ Empty placeholder governance files do not satisfy this baseline. Only repositories, releases, domains, documentation sites, and communications designated by Botshare LTD may claim official OpenAMRobot status. Forks and compatible products must not imply endorsement or certification. +## Platform-lead-authored pull requests + +When the platform lead authors a pull request, the platform lead's reconciliation comment is +the author's statement of source alignment; it is not an approval. Before merge, the PR must +have: + +- an approval from the software lead; and +- an approval from every scoped owner whose repository or path responsibility is touched, + including CI/CD, documentation and release owners when their scopes are affected. + +The author cannot satisfy any of those approvals. Safety-path changes still require the +separate two-human-approval ruleset gate, including the platform lead through CODEOWNERS. +If a scoped owner is unavailable, the organization owner records a dated exception and its +replacement reviewer before merge. A draft remains a draft until all required owner gates, +required checks, DCO/CLA and source reconciliation are complete. + ## Changes to governance Governance changes require review by an authorized Botshare LTD representative. No governance change may remove authentic third-party notices or claim rights Botshare LTD does not own. From e921b64ae50ce8f97eb5ea5810a89b216d13b626 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:49:50 +0300 Subject: [PATCH 072/129] governance: encode author approval policy Signed-off-by: Alex Reznichenko --- maintainers.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/maintainers.yaml b/maintainers.yaml index 9e9c7ab..d1ef5ba 100644 --- a/maintainers.yaml +++ b/maintainers.yaml @@ -25,6 +25,14 @@ roles: handle: anandgawai123456-glitch covers: documentation site, public extracts +review_policy: + platform-lead-authored: + required_roles: [software-lead] + scoped_owners_required: true + author_statement_counts_as_approval: false + safety_paths_still_require_platform_lead: true + unavailable_owner_requires_org_owner_exception: true + # Owner of audit findings by ID prefix, used by the weekly alignment audit. audit_prefixes: GEO: platform-lead From 98baef8b463352516f44d473fb7e9f9f3b3ae6c4 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:49:51 +0300 Subject: [PATCH 073/129] docs: add public-use and naming gate to PR template Signed-off-by: Alex Reznichenko --- .github/PULL_REQUEST_TEMPLATE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d8408e8..843187b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -59,3 +59,4 @@ platform lead. Telemetry and fixtures are not safety evidence. --> - [ ] I am covered by an accepted [Contributor Agreement](https://github.com/openAMRobot/.github/blob/main/CLA.md). - [ ] Third-party material is identified with source and licence. - [ ] No confidential, personal, credential or export-controlled information is included. +- [ ] No partner, customer or private person is named; the application is Use_Case_1. From 5743f0a0e7836b4dd4b6430591c50e3d41a15780 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:49:53 +0300 Subject: [PATCH 074/129] docs: explain scoped owner approval routing Signed-off-by: Alex Reznichenko --- rollout/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/rollout/README.md b/rollout/README.md index 39a5303..d30e640 100644 --- a/rollout/README.md +++ b/rollout/README.md @@ -163,6 +163,14 @@ tell a user how to build, flash and verify an installation (`docs/build/software setup pages of the two release repositories; the docs owner confirms the list when the file is written. +Platform-lead-authored PRs + +The platform lead may author a PR, but cannot approve it through the reconciliation comment. +The merge gate is an approval from the software lead plus an approval from every owner whose +scope the PR touches. The author is excluded from that set. Safety-path changes retain the +separate two-human-approval ruleset requirement, including platform-lead CODEOWNERS review. +Record any unavailable-owner exception with the organization owner before making the PR ready. + How GitHub applies these lines (documented GitHub behaviour, not something this harness checks): From 61ecc7bba7c895ca283f37028a7cbfa2e71aef87 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:49:55 +0300 Subject: [PATCH 075/129] docs: document activation and owner exception gates Signed-off-by: Alex Reznichenko --- rollout/workflows/SETUP.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/rollout/workflows/SETUP.md b/rollout/workflows/SETUP.md index 422d3d5..7762f24 100644 --- a/rollout/workflows/SETUP.md +++ b/rollout/workflows/SETUP.md @@ -66,7 +66,7 @@ starts step (c). | `RETRO_APP_ID`, `RETRO_APP_PRIVATE_KEY` | .github | monthly retro | The three App secret pairs may point to one GitHub App. The PR assistant uses only -`GITHUB_TOKEN`. +`GITHUB_TOKEN`. AI workflow activation is tracked separately in [issue #43](https://github.com/openAMRobot/.github/issues/43) and remains disabled until every checklist item is evidenced. ## 3. GitHub Apps @@ -93,7 +93,7 @@ The three App secret pairs may point to one GitHub App. The PR assistant uses on - `actions/create-github-app-token` v3.2.0 - `anthropics/claude-code-action` v1.0.236 - The live reusable workflow still uses `actions/checkout@v4` and `actions/upload-artifact@v4`. + The live workflows in this repository are pinned to full commit SHAs; the workflow-policy check fails on any unpinned external action or unresolved `` in `.github/workflows/`. Examples under `rollout/` are exempt until copied. - Require approval for workflows from first-time fork contributors. ## 5. Labels (every repository) @@ -139,6 +139,15 @@ approval. The second approval stays a human gate (c) that `check_pr_evidence.py` Single-maintainer repositories keep required checks. The CODEOWNERS waiver follows section 8 of the Engineering Quality Standard. + +**Shared rules and exceptions.** With `harness_checks: true`, a repository must contain +`AGENTS.md`. A repository without it may pass only by supplying the reusable-workflow input +`agents_md_exception` with a non-empty human-readable reason; the reason is written to the +job summary. Warn-only mode reports the missing file but never turns it into a silent pass. +For platform-lead-authored PRs, apply the approval policy in `GOVERNANCE.md` and +`maintainers.yaml`: software-lead approval plus every scoped-owner approval; the author's +reconciliation comment is not an approval. + ## 7. Maintainers map Fill the empty handles in `maintainers.yaml` (ci-owner, release-owner, docs-owner) once the people confirm From e1e43ac61d758339d545695fa4b76990452ef792 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:49:57 +0300 Subject: [PATCH 076/129] audit: require decision review warnings in reports Signed-off-by: Alex Reznichenko --- agent-prompts/read-only-audit.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/agent-prompts/read-only-audit.md b/agent-prompts/read-only-audit.md index 74007d5..9a5c3a7 100644 --- a/agent-prompts/read-only-audit.md +++ b/agent-prompts/read-only-audit.md @@ -21,7 +21,9 @@ block, or a source cannot be read, apply the failure rule. ## Task 1. Run `python3 tools/check_decisions.py --decisions decisions.yaml --root --repository ` - for every audited checkout and keep the output. + for every audited checkout and keep the output. Treat any `WARNING` line for a past + `review_by` date as a warning in REPORT.md; it is not a contradiction and must not be + silently dropped. 2. Compare each finding of the previous ISSUES.csv with the current checkouts: mark it resolved (cite the SHA and line that fixed it), still present, or changed. 3. Add new findings for contradictions between the plan documents supplied to you, decisions.yaml @@ -37,6 +39,7 @@ hardware or secrets. - One new folder `-alignment-audit/` with REPORT.md and ISSUES.csv on the audit branch. - A summary table: counts by area and severity, new, resolved and still-present findings. +- A `Decision review warnings` section listing every register entry past `review_by`, with its owner role and next human action. - A "What could not be checked" section with the reason for each gap. - No change in any audited repository. From fbc3ea6507f2e46bdc1dad8d8f09e31b0a45f58a Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:49:58 +0300 Subject: [PATCH 077/129] audit: validate register and pass review warnings to issue sync Signed-off-by: Alex Reznichenko --- rollout/workflows/weekly-alignment-audit.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/rollout/workflows/weekly-alignment-audit.yml b/rollout/workflows/weekly-alignment-audit.yml index f6f0b57..713a162 100644 --- a/rollout/workflows/weekly-alignment-audit.yml +++ b/rollout/workflows/weekly-alignment-audit.yml @@ -74,6 +74,13 @@ jobs: --max-turns 200 --allowedTools "Read,Grep,Glob,Write,Edit,Bash(python3 .harness/tools/check_decisions.py:*),Bash(python3 .harness/tools/check_public_extract.py:*),Bash(git -C .repos/*:*)" + - name: Validate register and report review windows + run: | + set -euo pipefail + python3 .harness/tools/check_decisions.py \ + --decisions .harness/decisions.yaml \ + --maintainers .harness/maintainers.yaml --validate-only | tee "$FOLDER/DECISIONS-VALIDATION.txt" + - name: Commit report run: | set -euo pipefail @@ -96,5 +103,5 @@ jobs: GITHUB_TOKEN: ${{ steps.app.outputs.token }} run: | python3 .harness/tools/sync_audit_issues.py --issues "$FOLDER/ISSUES.csv" \ - --maintainers .harness/maintainers.yaml --fallback-repository audits \ - --report "$FOLDER@$(git rev-parse --short HEAD)" --apply + --maintainers .harness/maintainers.yaml --decisions .harness/decisions.yaml \ + --fallback-repository audits --report "$FOLDER@$(git rev-parse --short HEAD)" --apply From e2390748e88c361095f37eaf26138a4abefad88d Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:50:12 +0300 Subject: [PATCH 078/129] pr: enforce public-use contribution checkbox Signed-off-by: Alex Reznichenko --- tools/check_pr_evidence.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tools/check_pr_evidence.py b/tools/check_pr_evidence.py index 0c35491..b7acf88 100644 --- a/tools/check_pr_evidence.py +++ b/tools/check_pr_evidence.py @@ -155,6 +155,13 @@ def evaluate(pr, changed, maintainers, reviews=(), has_state=False, commit_messa ai_disclosure = find(secs, "AI disclosure") or "" failures.extend(check_ai_disclosure(pr, commit_messages, ai_disclosure)) + contribution_terms = find(secs, "Contribution terms") + if contribution_terms is not None and not re.search( + r"\[x\]\s+No partner, customer or private person is named; the application is Use_Case_1\.", + contribution_terms, re.I + ): + failures.append("Contribution terms: check the Use_Case_1/no-private-person checkbox") + roles = (maintainers or {}).get("roles", {}) lead = (roles.get("platform-lead") or {}).get("handle") safety = [p for p in changed if any(fnmatch.fnmatch(p, g) or fnmatch.fnmatch(p, g.replace("**/", "")) From 36b20d0fcd424425910d82aa6bc580639bde1f7a Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:50:14 +0300 Subject: [PATCH 079/129] test: enforce public-use contribution checkbox Signed-off-by: Alex Reznichenko --- tests/test_check_pr_evidence.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_check_pr_evidence.py b/tests/test_check_pr_evidence.py index 738acf5..ae82085 100644 --- a/tests/test_check_pr_evidence.py +++ b/tests/test_check_pr_evidence.py @@ -54,6 +54,10 @@ ## AI disclosure None + +## Contribution terms + +- [x] No partner, customer or private person is named; the application is Use_Case_1. """ @@ -91,6 +95,14 @@ def test_unfilled_template_fails(self): self.assertIn("Evidence: no exact command (use a code block or `$ command` lines)", failures) self.assertTrue(any(f.startswith("Empty section") for f in failures)) + def test_public_use_checkbox_is_required_when_terms_are_present(self): + body = BODY.replace( + "- [x] No partner, customer or private person is named; the application is Use_Case_1.", + "- [ ] No partner, customer or private person is named; the application is Use_Case_1." + ) + self.assertIn("Contribution terms: check the Use_Case_1/no-private-person checkbox", + evaluate(body)[0]) + def test_template_contains_every_required_section(self): template = (ROOT / ".github" / "PULL_REQUEST_TEMPLATE.md").read_text(encoding="utf-8") secs = ev.sections(template) From 20f96c3af84c0478a64abfaa49c485cdb66e069b Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:51:06 +0300 Subject: [PATCH 080/129] test: update decision fixture metadata Signed-off-by: Alex Reznichenko --- tests/fixtures/decisions.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/fixtures/decisions.yaml b/tests/fixtures/decisions.yaml index d81b20f..9909beb 100644 --- a/tests/fixtures/decisions.yaml +++ b/tests/fixtures/decisions.yaml @@ -1,6 +1,7 @@ # Fixture register for tests/test_check_decisions.py. Synthetic values only; # not decisions of record. schema_version: 1 +in_force: {source: D, date: 2026-10-07} sources: FIX-DOC: {title: Fixture decision document revision 2, evidence: tests only} exclude: ["**/CHANGELOG.md"] @@ -12,6 +13,7 @@ decisions: value: 1350 unit: mm date: 2026-09-28 + review_by: 2026-11-18 source: {document: FIX-DOC, item: item 1} supersedes: - value: 1400 @@ -35,6 +37,7 @@ decisions: values: [firmware publishes /imu/data_raw, host filter owns /imu/data] unit: none date: null + review_by: 2026-11-18 source: {document: FIX-DOC, item: item 2} applies_to: {repositories: ["platform-*"], files: ["**/*.launch.py", "**/*.xacro"]} check: @@ -52,6 +55,7 @@ decisions: value: undecided unit: none date: null + review_by: 2026-11-18 source: {document: FIX-DOC, item: item 3} applies_to: {repositories: ["*"], files: ["**/*.md"]} check: From 5d903e1342aed95a22169725d6cbf5a419a92a38 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:51:08 +0300 Subject: [PATCH 081/129] ci: recognize standard step action syntax Signed-off-by: Alex Reznichenko --- tools/check_workflow_policy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/check_workflow_policy.py b/tools/check_workflow_policy.py index 6215e7f..e4cfa6b 100644 --- a/tools/check_workflow_policy.py +++ b/tools/check_workflow_policy.py @@ -12,7 +12,7 @@ from pathlib import Path FULL_SHA = re.compile(r"^[0-9a-f]{40}$") -USES = re.compile(r"^\s*uses:\s*([^\s#]+)") +USES = re.compile(r"^\s*(?:-\s*)?uses:\s*([^\s#]+)") def workflow_files(root): From fd5527c2d000274751669fbcf3df24cdd0d5abc5 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:51:10 +0300 Subject: [PATCH 082/129] pr: require substantive AI assistance scope Signed-off-by: Alex Reznichenko --- tools/check_pr_evidence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/check_pr_evidence.py b/tools/check_pr_evidence.py index b7acf88..21f1891 100644 --- a/tools/check_pr_evidence.py +++ b/tools/check_pr_evidence.py @@ -83,7 +83,7 @@ def human(login): AI_TOOL = re.compile(r"\b(?:Claude(?:\s+Code)?|Anthropic|ChatGPT|OpenAI|Codex|Copilot|Gemini|Cursor|Devin)\b", re.I) AI_MARKER = re.compile(r"(?:AI[-\s]+assisted|generated with|co-authored-by:.*(?:bot|claude|copilot|chatgpt|openai|anthropic|codex))", re.I) -AI_SCOPE = re.compile(r"\b(?:scope|assisted|drafted|generated|reviewed|changed|implemented|tested|research|documentation|workflow|code|text|analysis|reconciliation)\b", re.I) +AI_SCOPE = re.compile(r"\b(?:scope|drafted|generated|reviewed|changed|implemented|tested|research|documentation|workflow|code|text|analysis|reconciliation)\b", re.I) def ai_assistance_detected(pr, commit_messages=()): From a54e334370ddabd4470d2f44ceacb817a9599af2 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:51:12 +0300 Subject: [PATCH 083/129] ci: make AGENTS exception summary safe outside GitHub Signed-off-by: Alex Reznichenko --- .github/workflows/repository-quality-reusable.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/repository-quality-reusable.yml b/.github/workflows/repository-quality-reusable.yml index 1ea5188..577b5e1 100644 --- a/.github/workflows/repository-quality-reusable.yml +++ b/.github/workflows/repository-quality-reusable.yml @@ -217,11 +217,13 @@ jobs: else if [ -n "$AGENTS_MD_EXCEPTION" ]; then echo "AGENTS.md exception: $AGENTS_MD_EXCEPTION" - { - echo "### AGENTS.md exception" - echo - echo "$AGENTS_MD_EXCEPTION" - } >> "$GITHUB_STEP_SUMMARY" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo "### AGENTS.md exception" + echo + echo "$AGENTS_MD_EXCEPTION" + } >> "$GITHUB_STEP_SUMMARY" + fi elif [ "$ENFORCE" = true ]; then echo "::error::AGENTS.md is required when harness_checks is true; pass agents_md_exception with a human-readable reason" exit 1 From a5049d7ad51ab196612657d104bfb3500ef36799 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:51:23 +0300 Subject: [PATCH 084/129] test: point fixture in-force metadata at its source Signed-off-by: Alex Reznichenko --- tests/fixtures/decisions.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures/decisions.yaml b/tests/fixtures/decisions.yaml index 9909beb..259fe1f 100644 --- a/tests/fixtures/decisions.yaml +++ b/tests/fixtures/decisions.yaml @@ -1,7 +1,7 @@ # Fixture register for tests/test_check_decisions.py. Synthetic values only; # not decisions of record. schema_version: 1 -in_force: {source: D, date: 2026-10-07} +in_force: {source: FIX-DOC, date: 2026-10-07} sources: FIX-DOC: {title: Fixture decision document revision 2, evidence: tests only} exclude: ["**/CHANGELOG.md"] From 20d7b1cabc56dc4fb065f3bb98f8ced512366e4a Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:53:04 +0300 Subject: [PATCH 085/129] ci: mark mounted ROS fixture as safe for git Signed-off-by: Alex Reznichenko --- .github/workflows/repository-quality.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/repository-quality.yml b/.github/workflows/repository-quality.yml index 39ddb8f..d04f762 100644 --- a/.github/workflows/repository-quality.yml +++ b/.github/workflows/repository-quality.yml @@ -80,6 +80,7 @@ jobs: apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y git python3-pip shellcheck rosdep update + git config --global --add safe.directory /work/interfaces test -d /work/interfaces/ros2/build test -d /work/interfaces/ros2/install test -d /work/interfaces/ros2/log From d8b11055faa5b0e651844d243345009abb9ae7ba Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:55:08 +0300 Subject: [PATCH 086/129] ci: run interfaces own verified test command in ROS smoke Signed-off-by: Alex Reznichenko --- .github/workflows/repository-quality.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/repository-quality.yml b/.github/workflows/repository-quality.yml index d04f762..7f738b9 100644 --- a/.github/workflows/repository-quality.yml +++ b/.github/workflows/repository-quality.yml @@ -84,5 +84,5 @@ jobs: test -d /work/interfaces/ros2/build test -d /work/interfaces/ros2/install test -d /work/interfaces/ros2/log - VERIFY_NO_DELEGATE=1 bash /work/harness/rollout/verify.sh /work/interfaces + VERIFY_NO_DELEGATE=1 VERIFY_TEST='bash tools/verify.sh' bash /work/harness/rollout/verify.sh /work/interfaces ' From b01a2acca36a5c8a387a7701c8c3ff55469650aa Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:55:13 +0300 Subject: [PATCH 087/129] ci: quote ROS verifier test override safely Signed-off-by: Alex Reznichenko --- .github/workflows/repository-quality.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/repository-quality.yml b/.github/workflows/repository-quality.yml index 7f738b9..bdf16fd 100644 --- a/.github/workflows/repository-quality.yml +++ b/.github/workflows/repository-quality.yml @@ -84,5 +84,5 @@ jobs: test -d /work/interfaces/ros2/build test -d /work/interfaces/ros2/install test -d /work/interfaces/ros2/log - VERIFY_NO_DELEGATE=1 VERIFY_TEST='bash tools/verify.sh' bash /work/harness/rollout/verify.sh /work/interfaces + VERIFY_NO_DELEGATE=1 VERIFY_TEST="bash tools/verify.sh" bash /work/harness/rollout/verify.sh /work/interfaces ' From 6fc51d496d0666fe1e1de5946fab9f7eb894df29 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:57:27 +0300 Subject: [PATCH 088/129] fix verifier ROS workspace copy recursion Signed-off-by: Alex Reznichenko --- rollout/verify.sh | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/rollout/verify.sh b/rollout/verify.sh index cf97ecf..d0f7914 100755 --- a/rollout/verify.sh +++ b/rollout/verify.sh @@ -206,7 +206,20 @@ pass stage=build if [ -n "${VERIFY_BUILD:-}" ]; then clean_bash -c "cd '$root' && $VERIFY_BUILD" elif $ros; then - mkdir -p "$run/ws/src" && cp -a "$root/." "$run/ws/src/repo" && rm -rf "$run/ws/src/repo/.verification" + # The run directory lives under root; archive selected source so the copy cannot + # recurse into .verification/run.* or carry generated workspaces into colcon. + mkdir -p "$run/ws/src/repo" + tar -C "$root" \ + --exclude=.git \ + --exclude=.verification \ + --exclude=node_modules \ + --exclude=build \ + --exclude=install \ + --exclude=log \ + --exclude='*/build' \ + --exclude='*/install' \ + --exclude='*/log' \ + -cf - . | tar -C "$run/ws/src/repo" -xf - clean_bash -c "source '$ros_setup' && cd '$run/ws' && colcon build --event-handlers console_direct+" elif $node; then clean_bash -c "cd '$root' && npm run build --if-present" fi From e511b695b0cd85fea6d454a8197fbe14c9ad58eb Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:57:28 +0300 Subject: [PATCH 089/129] install schema dependency in ROS verifier smoke Signed-off-by: Alex Reznichenko --- .github/workflows/repository-quality.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/repository-quality.yml b/.github/workflows/repository-quality.yml index bdf16fd..9ef0d7a 100644 --- a/.github/workflows/repository-quality.yml +++ b/.github/workflows/repository-quality.yml @@ -78,8 +78,9 @@ jobs: -w /work \ ros:jazzy-ros-base bash -ceu ' apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y git python3-pip shellcheck + DEBIAN_FRONTEND=noninteractive apt-get install -y git python3-pip python3-jsonschema shellcheck rosdep update + python3 -c 'import jsonschema' 2>/dev/null || python3 -m pip install --break-system-packages --quiet jsonschema git config --global --add safe.directory /work/interfaces test -d /work/interfaces/ros2/build test -d /work/interfaces/ros2/install From 994dec5b1a17763e56a2c0687fcca1d9d8011727 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:57:29 +0300 Subject: [PATCH 090/129] test ROS verifier excludes its own workspace Signed-off-by: Alex Reznichenko --- tests/test_verify_sh.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_verify_sh.py b/tests/test_verify_sh.py index 7e6b7c4..af51643 100644 --- a/tests/test_verify_sh.py +++ b/tests/test_verify_sh.py @@ -188,6 +188,15 @@ def setUp(self): (ros / "bin" / "rosdep").write_text(FAKE_ROSDEP, encoding="utf-8") (ros / "bin" / "rosdep").chmod(0o755) (ros / "setup.bash").write_text(f'export PATH="{ros}/bin:$PATH"\n', encoding="utf-8") + fake_colcon = ros / "bin" / "colcon" + fake_colcon.write_text( + "#!/usr/bin/env bash\n" + "if [ \"$1\" = test-result ]; then\n" + " echo 'Summary: 1 tests, 0 errors, 0 failures, 0 skipped'\n" + "fi\n", + encoding="utf-8", + ) + fake_colcon.chmod(0o755) self.ros = ros self.home = self.base / "home" self.home.mkdir() @@ -238,5 +247,13 @@ def test_generated_build_install_log_folders_are_not_scanned(self): self.assertEqual(scanned, [f"scan {root / 'ros2' / 'src' / 'pkg_a'}"]) + def test_ros_build_copy_excludes_verification_workspace(self): + self.init_caller_rosdep() + code, out, _, _ = self.run_verify({".openamrobot/verify.env": ""}) + self.assertEqual(code, 0, out) + self.assertNotIn("into itself", out) + self.assertNotIn("cp: cannot copy", out) + + if __name__ == "__main__": unittest.main() From d9d2f7327357350a3f19bec375817b1f261c1246 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:58:16 +0300 Subject: [PATCH 091/129] fix ROS smoke shell quoting Signed-off-by: Alex Reznichenko --- .github/workflows/repository-quality.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/repository-quality.yml b/.github/workflows/repository-quality.yml index 9ef0d7a..c3f24be 100644 --- a/.github/workflows/repository-quality.yml +++ b/.github/workflows/repository-quality.yml @@ -80,7 +80,7 @@ jobs: apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y git python3-pip python3-jsonschema shellcheck rosdep update - python3 -c 'import jsonschema' 2>/dev/null || python3 -m pip install --break-system-packages --quiet jsonschema + python3 -c "import jsonschema" 2>/dev/null || python3 -m pip install --break-system-packages --quiet jsonschema git config --global --add safe.directory /work/interfaces test -d /work/interfaces/ros2/build test -d /work/interfaces/ros2/install From ba3d5b3570a1b2d26fc71f895e5bb6c1b87f74bc Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 19:00:05 +0300 Subject: [PATCH 092/129] install ROS Cyclone DDS for smoke matrix Signed-off-by: Alex Reznichenko --- .github/workflows/repository-quality.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/repository-quality.yml b/.github/workflows/repository-quality.yml index c3f24be..7793633 100644 --- a/.github/workflows/repository-quality.yml +++ b/.github/workflows/repository-quality.yml @@ -78,7 +78,7 @@ jobs: -w /work \ ros:jazzy-ros-base bash -ceu ' apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y git python3-pip python3-jsonschema shellcheck + DEBIAN_FRONTEND=noninteractive apt-get install -y git python3-pip python3-jsonschema ros-jazzy-rmw-cyclonedds-cpp shellcheck rosdep update python3 -c "import jsonschema" 2>/dev/null || python3 -m pip install --break-system-packages --quiet jsonschema git config --global --add safe.directory /work/interfaces From 30dd3756a4468a571691a26be4d48e7d7b72cc70 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 19:05:25 +0300 Subject: [PATCH 093/129] least-privilege AI workflow authentication in weekly-alignment-audit.yml Signed-off-by: Alex Reznichenko --- rollout/workflows/weekly-alignment-audit.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/rollout/workflows/weekly-alignment-audit.yml b/rollout/workflows/weekly-alignment-audit.yml index 713a162..9c30fad 100644 --- a/rollout/workflows/weekly-alignment-audit.yml +++ b/rollout/workflows/weekly-alignment-audit.yml @@ -23,7 +23,6 @@ on: permissions: contents: write - id-token: write concurrency: group: weekly-alignment-audit From d74e6ca10c8e52cbdc04fc40b49da6db2f5edb38 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 19:05:26 +0300 Subject: [PATCH 094/129] least-privilege AI workflow authentication in docs-sync.yml Signed-off-by: Alex Reznichenko --- rollout/workflows/docs-sync.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/rollout/workflows/docs-sync.yml b/rollout/workflows/docs-sync.yml index 9be28c7..16b0dbb 100644 --- a/rollout/workflows/docs-sync.yml +++ b/rollout/workflows/docs-sync.yml @@ -16,7 +16,6 @@ on: permissions: contents: write pull-requests: write - id-token: write concurrency: group: docs-sync-${{ github.event.client_payload.repository }} From f44a4ff6c79ef6baa8733c850b9d93303461c8dc Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 19:05:29 +0300 Subject: [PATCH 095/129] least-privilege AI workflow authentication in monthly-retro.yml Signed-off-by: Alex Reznichenko --- rollout/workflows/monthly-retro.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/rollout/workflows/monthly-retro.yml b/rollout/workflows/monthly-retro.yml index 0d0e776..8afe207 100644 --- a/rollout/workflows/monthly-retro.yml +++ b/rollout/workflows/monthly-retro.yml @@ -18,7 +18,6 @@ on: permissions: contents: write pull-requests: write - id-token: write jobs: retro: From 9783ae1cf2b40c272708db09e4a00416b06efefa Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 19:05:31 +0300 Subject: [PATCH 096/129] least-privilege AI workflow authentication in SETUP.md Signed-off-by: Alex Reznichenko --- rollout/workflows/SETUP.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rollout/workflows/SETUP.md b/rollout/workflows/SETUP.md index 7762f24..02326b6 100644 --- a/rollout/workflows/SETUP.md +++ b/rollout/workflows/SETUP.md @@ -68,6 +68,8 @@ starts step (c). The three App secret pairs may point to one GitHub App. The PR assistant uses only `GITHUB_TOKEN`. AI workflow activation is tracked separately in [issue #43](https://github.com/openAMRobot/.github/issues/43) and remains disabled until every checklist item is evidenced. +Authentication mode must be chosen before activation. The examples use `ANTHROPIC_API_KEY`, so they do not request `id-token: write`. If the organization chooses Anthropic OIDC federation instead, remove the API-key secret and add `id-token: write` only to the approved workflow after the Anthropic trust configuration and a manual disposable-branch test are recorded in issue #43. Do not configure both modes by accident. + ## 3. GitHub Apps 1. **Claude GitHub App** (github.com/apps/claude), on audits, openamrobot-docs and .github From 95dcfafe3c96447f25db34e26513024a43da096f Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 19:08:47 +0300 Subject: [PATCH 097/129] document Claude authentication permissions accurately in weekly-alignment-audit.yml Signed-off-by: Alex Reznichenko --- rollout/workflows/weekly-alignment-audit.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/rollout/workflows/weekly-alignment-audit.yml b/rollout/workflows/weekly-alignment-audit.yml index 9c30fad..713a162 100644 --- a/rollout/workflows/weekly-alignment-audit.yml +++ b/rollout/workflows/weekly-alignment-audit.yml @@ -23,6 +23,7 @@ on: permissions: contents: write + id-token: write concurrency: group: weekly-alignment-audit From 072eb98e0c0063aacb08da7303b0f66df8b6db71 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 19:08:49 +0300 Subject: [PATCH 098/129] document Claude authentication permissions accurately in docs-sync.yml Signed-off-by: Alex Reznichenko --- rollout/workflows/docs-sync.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/rollout/workflows/docs-sync.yml b/rollout/workflows/docs-sync.yml index 16b0dbb..9be28c7 100644 --- a/rollout/workflows/docs-sync.yml +++ b/rollout/workflows/docs-sync.yml @@ -16,6 +16,7 @@ on: permissions: contents: write pull-requests: write + id-token: write concurrency: group: docs-sync-${{ github.event.client_payload.repository }} From 27bea76e1815f00b2b795605ac497f566c9f92aa Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 19:08:51 +0300 Subject: [PATCH 099/129] document Claude authentication permissions accurately in monthly-retro.yml Signed-off-by: Alex Reznichenko --- rollout/workflows/monthly-retro.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/rollout/workflows/monthly-retro.yml b/rollout/workflows/monthly-retro.yml index 8afe207..0d0e776 100644 --- a/rollout/workflows/monthly-retro.yml +++ b/rollout/workflows/monthly-retro.yml @@ -18,6 +18,7 @@ on: permissions: contents: write pull-requests: write + id-token: write jobs: retro: From 774f2ad408356e4316cfb1efe8483ee9c6478802 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 19:08:52 +0300 Subject: [PATCH 100/129] document Claude authentication permissions accurately in SETUP.md Signed-off-by: Alex Reznichenko --- rollout/workflows/SETUP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rollout/workflows/SETUP.md b/rollout/workflows/SETUP.md index 02326b6..a558f0e 100644 --- a/rollout/workflows/SETUP.md +++ b/rollout/workflows/SETUP.md @@ -68,7 +68,7 @@ starts step (c). The three App secret pairs may point to one GitHub App. The PR assistant uses only `GITHUB_TOKEN`. AI workflow activation is tracked separately in [issue #43](https://github.com/openAMRobot/.github/issues/43) and remains disabled until every checklist item is evidenced. -Authentication mode must be chosen before activation. The examples use `ANTHROPIC_API_KEY`, so they do not request `id-token: write`. If the organization chooses Anthropic OIDC federation instead, remove the API-key secret and add `id-token: write` only to the approved workflow after the Anthropic trust configuration and a manual disposable-branch test are recorded in issue #43. Do not configure both modes by accident. +Authentication mode must be chosen before activation. The examples use `ANTHROPIC_API_KEY` for Anthropic API authentication and retain `id-token: write` because the official Claude GitHub App path uses GitHub OIDC for the action's default GitHub token. If the organization chooses Anthropic Workload Identity Federation instead, remove the API-key secret, add the federation identifiers required by Anthropic, and keep `id-token: write`; do not configure both Anthropic credential modes by accident. Record the selected mode and the manual disposable-branch test in issue #43. ## 3. GitHub Apps From 3110898d78eeef4688055db3e17c691fe62e86ac Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 17:33:57 +0000 Subject: [PATCH 101/129] Watchdog: explain every finding with decision, why, fix and links Checkers print findings grouped by decision with a closing summary, the current decision in one line (new optional summary and fix_hint register fields, schema-validated, filled for every entry without changing values), why it matters, how to fix and links to WATCHDOG.md and the register entry. Inside GitHub Actions each finding is also a workflow annotation (warning, or error when enforcing) and a Markdown job summary. Detection and exit codes are unchanged; the result lines that tools parse are unchanged. Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- .../workflows/repository-quality-reusable.yml | 12 +- decisions.yaml | 57 +++++++++ tests/test_check_agent_rules.py | 5 +- tests/test_check_decisions.py | 61 ++++++++- tests/test_check_pr_evidence.py | 10 +- tests/test_check_public_extract.py | 10 +- tests/test_check_workflow_policy.py | 16 +++ tests/test_reusable_workflow.py | 21 +++- tests/test_watchdog_report.py | 77 ++++++++++++ tools/check_agent_rules.py | 31 ++++- tools/check_decisions.py | 53 +++++++- tools/check_pr_evidence.py | 28 ++++- tools/check_public_extract.py | 40 +++++- tools/check_workflow_policy.py | 58 +++++++-- tools/watchdog_report.py | 119 ++++++++++++++++++ 15 files changed, 562 insertions(+), 36 deletions(-) create mode 100644 tests/test_watchdog_report.py create mode 100644 tools/watchdog_report.py diff --git a/.github/workflows/repository-quality-reusable.yml b/.github/workflows/repository-quality-reusable.yml index 577b5e1..302f30e 100644 --- a/.github/workflows/repository-quality-reusable.yml +++ b/.github/workflows/repository-quality-reusable.yml @@ -132,6 +132,8 @@ jobs: run: | set -uo pipefail h=.openamrobot-harness + export WATCHDOG_ANNOTATION=warning + if [ "$ENFORCE" = true ] && [ "$EVENT_NAME" = pull_request ]; then export WATCHDOG_ANNOTATION=error; fi python3 "$h/tools/check_workflow_policy.py" --root . | tee "$RUNNER_TEMP/workflow-policy.txt" status=${PIPESTATUS[0]} if [ "$ENFORCE" = true ] && { [ "$EVENT_NAME" = pull_request ] || [ "$status" -eq 2 ]; }; then @@ -159,11 +161,13 @@ jobs: if [ "$EVENT_NAME" = pull_request ]; then scope=(--changed-files "$RUNNER_TEMP/changed-files.txt") fi + # Findings become inline annotations (tools/watchdog_report.py): errors when they block. + export WATCHDOG_ANNOTATION=warning + if [ "$ENFORCE" = true ] && [ "$EVENT_NAME" = pull_request ]; then export WATCHDOG_ANNOTATION=error; fi python3 "$h/tools/check_decisions.py" --decisions "$h/decisions.yaml" \ --maintainers "$h/maintainers.yaml" --root . \ --repository "$REPOSITORY" "${scope[@]}" | tee "$RUNNER_TEMP/decisions.txt" status=${PIPESTATUS[0]} - grep '^CONTRADICTION ' "$RUNNER_TEMP/decisions.txt" | sed -E 's/^CONTRADICTION ([^:]+):([0-9]+): /::warning file=\1,line=\2::/' || true if [ "$ENFORCE" = true ] && { [ "$EVENT_NAME" = pull_request ] || [ "$status" -eq 2 ]; }; then exit "$status" fi @@ -185,10 +189,11 @@ jobs: if [ "$EVENT_NAME" = pull_request ]; then scope=(--changed-files "$RUNNER_TEMP/changed-files.txt") fi + export WATCHDOG_ANNOTATION=warning + if [ "$ENFORCE" = true ] && [ "$EVENT_NAME" = pull_request ]; then export WATCHDOG_ANNOTATION=error; fi python3 "$h/tools/check_public_extract.py" --root . --allowlist "$h/public-extract-allowlist.yaml" \ --repository "$REPOSITORY" "${scope[@]}" | tee "$RUNNER_TEMP/extract.txt" status=${PIPESTATUS[0]} - grep '^PUBLIC-EXTRACT ' "$RUNNER_TEMP/extract.txt" | sed -E 's/^PUBLIC-EXTRACT ([^:]+):([0-9]+): /::warning file=\1,line=\2::/' || true if [ "$ENFORCE" = true ] && { [ "$EVENT_NAME" = pull_request ] || [ "$status" -eq 2 ]; }; then exit "$status" fi @@ -207,6 +212,8 @@ jobs: run: | set -uo pipefail if [ -f AGENTS.md ]; then + export WATCHDOG_ANNOTATION=warning + if [ "$ENFORCE" = true ]; then export WATCHDOG_ANNOTATION=error; fi python3 .openamrobot-harness/tools/check_agent_rules.py \ --canonical .openamrobot-harness/agent-rules/SHARED_RULES.md --file AGENTS.md status=$? @@ -265,3 +272,4 @@ jobs: path: .verification/run.*/ include-hidden-files: true if-no-files-found: warn + diff --git a/decisions.yaml b/decisions.yaml index 6addf3d..646dcdc 100644 --- a/decisions.yaml +++ b/decisions.yaml @@ -21,6 +21,9 @@ # Schema (schema_version 1), one entry per decision: # id unique, upper case, stable # title one line +# summary optional; the decision in one line of at most 120 characters, +# printed with each finding (the long value stays here) +# fix_hint optional; how to fix a finding, one line of at most 120 characters # kind value | configuration | limit | exclusion | distinction # status recorded (scanned and reported) | open (not decided; not scanned) | # superseded (replaced by a later decision; only its citation is scanned) @@ -105,6 +108,8 @@ exclude: decisions: - id: BOM-ISSUE-IN-FORCE title: Canonical hardware BOM issue for OpenAMRobot 2.0 + summary: "BOM Issue 7.3 is the canonical OpenAMRobot 2.0 hardware BOM." + fix_hint: "Name Issue 7.3 as canonical, or label older issues (Issue 6, Issue 7, B-01) as superseded." kind: value status: recorded value: OpenAMRobot 2.0 Detailed Hardware BOM, Issue 7.3 @@ -136,6 +141,8 @@ decisions: - id: MAST-INSTALL-HEIGHT title: Shoulder-axis installation height of the fixed mast (superseded by LIFT) + summary: "Superseded: the fixed-mast 1350 mm installation height was replaced by LIFT." + fix_hint: "Describe the lift (shoulder axis 1000 to 1350 mm), or label the fixed-mast text as superseded or historical." kind: configuration status: superseded value: 1350 @@ -166,6 +173,8 @@ decisions: - id: MAST-POSITIONS title: Indexed mast mounting positions (superseded by LIFT) + summary: "Superseded: the indexed fixed-mast positions were replaced by LIFT." + fix_hint: "Describe the lift positions, or label mast_1300/1400/1450 text as superseded or historical." kind: configuration status: superseded values: [1300, 1350, 1400, 1450] @@ -192,6 +201,8 @@ decisions: - id: MAST-TOP-HEIGHT title: Mast top height above the floor (own COTS mast, one MISUMI HFS6-60120 profile; superseded by LIFT) + summary: "Superseded: the 1500 mm mast top on an HFS6-60120 profile was replaced by LIFT." + fix_hint: "Describe the lift, or label the mast-top or HFS6-60120 text as superseded or historical." kind: value status: superseded value: 1500 @@ -217,6 +228,8 @@ decisions: - id: MAX-ASSEMBLED-HEIGHT title: 1700 mm is the maximum assembled-height envelope, not a shoulder-axis height + summary: "1700 mm is the maximum assembled-height envelope, not a shoulder height." + fix_hint: "Use 1700 mm only as the assembled-height envelope; give shoulder heights from the LIFT entry." kind: distinction status: recorded value: 1700 @@ -241,6 +254,8 @@ decisions: - id: DATUM-HEIGHT-STACK title: Height datum and base height stack + summary: "Floor Z = 0; steel deck top 294 mm; lift base-plate top face 304 mm is the height reference." + fix_hint: "Use 294 mm for the deck top and 304 mm for the base-plate top face, or label old values as historical." kind: configuration status: recorded values: @@ -268,6 +283,8 @@ decisions: - id: FRAMES-REP105 title: Base frames follow REP 105 + summary: "REP 105: base_footprint on the floor, base_link at the axle midpoint, imu_link away from motors." + fix_hint: "Place base_footprint on the floor, base_link at axle height, and imu_link on the centreline away from motors." kind: configuration status: recorded values: @@ -296,6 +313,8 @@ decisions: - id: BATTERY-PLACEMENT title: Battery pack and placement + summary: "One 8S1P LF105 pack, 25.6 V, 105 Ah, as close to the rear edge as practical." + fix_hint: "Describe the pack as close to the rear edge as practical; label the 25 percent position as superseded." kind: value status: recorded value: One 8S1P EVE LF105 LiFePO4 pack, 25.6 V, 105 Ah, placed as close to the rear edge as practical while preserving enclosure, service and safety clearances @@ -319,6 +338,8 @@ decisions: - id: SPEED-CEILING title: 1.5 m/s is a command ceiling, not an operating speed + summary: "1.5 m/s is a command ceiling (analytical limit), not an accepted operating speed." + fix_hint: "Call 1.5 m/s a command ceiling; take operating speed from the stability model and stopping tests." kind: limit status: recorded value: 1.5 m/s command ceiling, treated as an analytical limit; the accepted operating speed follows from the stability model and stopping tests @@ -343,6 +364,8 @@ decisions: - id: DRIVETRAIN title: Drivetrain + summary: "Two ZLTECH ZLLG80ASM250-L-B hub motors with brakes, one ZLAC8015D driver on CAN1." + fix_hint: "Name the ZLLG80ASM250-L-B as selected; mark brake ratings as pending supplier evidence." kind: value status: recorded value: Two ZLTECH ZLLG80ASM250-L-B hub motors with brakes, one ZLAC8015D V4.2 driver on CAN1 (CANopen), 200 mm wheels @@ -368,6 +391,8 @@ decisions: - id: RS485-NOT-IN-2-0 title: Release 2.0 has no RS485 hardware, fallback, adapter or commissioning path + summary: "Release 2.0 has no RS485 hardware, fallback, adapter or commissioning path." + fix_hint: "Remove RS485 from 2.0 text, or label it as legacy, Gate A or not in 2.0." kind: exclusion status: recorded value: none @@ -391,6 +416,8 @@ decisions: - id: BASE-CONTROLLER-GATES title: Base-controller gates and IMU gating stay distinct + summary: "Gate A (Teensy, MPU6500) and Gate B (STM32H723ZG) stay distinct; ICM-42688-P is gated." + fix_hint: "Call the Teensy the Gate A controller, the IMU MPU6500, and the ICM-42688-P conditional (Gate B)." kind: distinction status: recorded values: @@ -418,6 +445,8 @@ decisions: - id: BASE-CONTROLLER-IO title: Base-controller MCU, sensor and IMU buses, CAN mapping and micro-ROS transport + summary: "STM32H723ZG; MB7060 on dedicated UARTs; CAN1/2/3 split; micro-ROS over UDP on Ethernet." + fix_hint: "Use the STM32H723ZG and MB7060 on UART; label STM32H743 or MB7040 text as superseded; USB is bench only." kind: configuration status: recorded values: @@ -452,6 +481,8 @@ decisions: - id: IMU-TOPIC-OWNERSHIP title: IMU topic ownership + summary: "Firmware publishes /imu/data_raw; the host filter and EKF own /imu/data." + fix_hint: "Make firmware publish /imu/data_raw and leave /imu/data to the host filter." kind: distinction status: recorded values: @@ -478,6 +509,8 @@ decisions: - id: CAMERAS title: Cameras + summary: "Base camera Orbbec Gemini 336L, about 243 mm up, tilted 5, 10 or 15 degrees up (baseline 10)." + fix_hint: "Name the Gemini 336L with an up-tilt of 5, 10 or 15 degrees, or label other cameras as legacy." kind: value status: recorded values: @@ -512,6 +545,8 @@ decisions: - id: HEAD-CAMERA-IDENTITY title: Head camera commercial identity and mounting + summary: "Head camera: Stereolabs ZED Mini, SKU ZED-121210, on the lift carriage." + fix_hint: "Name the ZED Mini (ZED-121210), or label other ZED models as superseded or legacy." kind: value status: recorded values: @@ -536,6 +571,8 @@ decisions: - id: POWER-RAILS title: Power rails; no 12 V rail + summary: "25.6 V battery bus, regulated 24 V and 5 V branches; there is no 12 V rail." + fix_hint: "Remove the 12 V rail from 2.0 text, or label it as legacy." kind: exclusion status: recorded values: @@ -564,6 +601,8 @@ decisions: - id: DOCK-NO-CONTACTS title: Release 2.0 docking has no dock contacts or dock pilot; wireless charging is 3.0 + summary: "2.0 docking is positioning only: no dock contacts or pilot; wireless charging is 3.0." + fix_hint: "Say the dock has no contacts and charging is manual and wired; put wireless charging in 3.0." kind: exclusion status: recorded value: Positioning only; manual wired charging via PWR-019 with independent charge-plug-presence inhibition; no dock contacts or dock pilot; wireless charging belongs to 3.0 @@ -591,6 +630,8 @@ decisions: - id: DOCKING-NOT-CHARGING title: Docking success never establishes charging + summary: "Docked means an accepted pose; docking never establishes charging or external power." + fix_hint: "Report docking as a pose result only; never infer charging from it." kind: distinction status: recorded value: Docked means an accepted pose within tolerance; pose success never establishes charging or external power @@ -617,6 +658,8 @@ decisions: - id: TELEMETRY-NOT-SAFETY-EVIDENCE title: Functional telemetry is never safety evidence + summary: "Telemetry, watchdogs and BMS data are functional, never safety evidence." + fix_hint: "Describe telemetry and watchdogs as functional, not as a safety layer." kind: distinction status: recorded value: Firmware status, watchdogs, collision monitoring and BMS telemetry are functional; E-stop, brake and actuator power removal are hardwired and independent of software @@ -636,6 +679,8 @@ decisions: - id: SAFETY-PROCUREMENT title: Safety-chain procurement boundary (record only; this register implements no safety function) + summary: "Two dual-channel latching E-stops into a monitored safety relay; EDM contactor unselected." + fix_hint: "Do not recommend single-channel or uncertified E-stops; keep the EDM variant open and CTL-004 bench-only." kind: limit status: recorded values: @@ -663,6 +708,8 @@ decisions: - id: COMPUTE title: Reference compute + summary: "Reference compute: NVIDIA Jetson Orin NX 16 GB on a reComputer Robotics J401; Pi is legacy." + fix_hint: "Name the Jetson Orin NX, or label Raspberry Pi material as legacy or Gate A." kind: value status: recorded value: NVIDIA Jetson Orin NX 16 GB on a reComputer Robotics J401 carrier with NVMe; Raspberry Pi removed from active support @@ -682,6 +729,8 @@ decisions: - id: NAV-LIDAR title: Navigation LiDAR + summary: "Navigation LiDAR: SLAMTEC RPLIDAR S3 (S3M1-R2) on USB, regulated 5 V rail." + fix_hint: "Name the RPLIDAR S3; label Hokuyo as dropped or superseded and the A1 as legacy (existing robot)." kind: value status: recorded value: SLAMTEC RPLIDAR S3 (S3M1-R2), connected over USB, powered from the regulated 5 V rail (functional sensing, not a safety device) @@ -710,6 +759,8 @@ decisions: - id: RELEASE-MILESTONES title: OpenAMRobot 2.0 release milestones + summary: "v2.0.0-rc.1 on 20 November 2026; v2.0.0 final release on 18 December 2026." + fix_hint: "Use 20 November (v2.0.0-rc.1) and 18 December 2026 (v2.0.0); label v0.2 or 13 November as superseded." kind: value status: recorded values: @@ -739,6 +790,8 @@ decisions: - id: LIFT title: Lift approved in principle for OpenAMRobot 2.0 + summary: "The lift is approved in principle for 2.0; shoulder axis 1000 to 1350 mm; release gates open." + fix_hint: "Describe the lift as approved in principle, or label fixed-mast and 3.0 lift text as superseded." kind: configuration status: recorded values: @@ -773,6 +826,8 @@ decisions: - id: NO-SUSPENSION title: Release 2.0 has no suspension + summary: "Release 2.0 has no suspension; sprung drive wheels are a 3.0 item." + fix_hint: "Say there is no suspension in 2.0, or put sprung drive wheels in 3.0." kind: exclusion status: recorded value: No suspension is fitted in 2.0; sprung drive wheels are a 3.0 item @@ -792,6 +847,8 @@ decisions: - id: DRIVE-TRACK title: Drive track + summary: "Open: 400 mm in the general arrangement; the final drive track is still an open input." + fix_hint: "Not scanned while open; give the track as 400 mm (general arrangement) until the value is decided." kind: value status: open value: 400 mm in the general arrangement; the final track is an open input diff --git a/tests/test_check_agent_rules.py b/tests/test_check_agent_rules.py index 3fbdeab..73e87d3 100644 --- a/tests/test_check_agent_rules.py +++ b/tests/test_check_agent_rules.py @@ -28,7 +28,7 @@ def repo(self, name, agents, claude="@AGENTS.md\n"): def run_main(self, *args): err = io.StringIO() - with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(err): + with contextlib.redirect_stdout(err), contextlib.redirect_stderr(err): try: code = car.main(["--canonical", str(CANONICAL), *map(str, args)]) except SystemExit as exc: @@ -47,6 +47,9 @@ def test_edited_block_fails(self): code, err = self.run_main("--root", self.tmp.name) self.assertEqual(code, 1) self.assertIn("shared block differs from canonical", err) + self.assertIn("Shared agent rules out of date: ", err) + self.assertIn("Fix: Copy the block between the BEGIN and END markers", err) + self.assertIn("Shared agent rules summary: 1 finding(s) (shared-rules 1)", err) def test_older_version_fails_with_version_message(self): old = self.shared.replace("SHARED RULES v2", "SHARED RULES v1") diff --git a/tests/test_check_decisions.py b/tests/test_check_decisions.py index 54fd3dd..742d3a1 100644 --- a/tests/test_check_decisions.py +++ b/tests/test_check_decisions.py @@ -9,6 +9,7 @@ import sys import tempfile import unittest +import unittest.mock from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -101,10 +102,42 @@ class CommandLine(unittest.TestCase): def test_exit_one_on_contradiction(self): code, out = run("--decisions", DECISIONS, "--root", REPO, "--repository", "platform-x") self.assertEqual(code, 1) - self.assertIn("CONTRADICTION README.md:3: FIX-MAST found 'mast_1400', decided '1350 mm'", out) + self.assertIn("Mismatch with approved decision: README.md:3: FIX-MAST found 'mast_1400'", out) self.assertIn("result: 6 contradiction(s), 1 allowed", out) self.assertIn("textual consistency only", out) + def test_finding_explains_decision_why_fix_and_links(self): + code, out = run("--decisions", DECISIONS, "--root", REPO, "--repository", "platform-x") + block = out.split("Mismatch with approved decision: README.md:3:", 1)[1].split("\nMismatch", 1)[0] + self.assertIn("\n Decision: 1350 mm\n", block) + self.assertIn("\n Why: baseline is mast_1350.\n", block) + self.assertIn("\n Fix: ", block) + self.assertIn("FIX-MAST in decisions.yaml: https://github.com/openAMRobot/.github/blob/main/decisions.yaml#L", block) + self.assertIn("WATCHDOG.md#decisions-of-record", block) + self.assertIn("== FIX-MAST: 5 finding(s) ==", out) + self.assertIn("Decisions of record summary: 6 finding(s) (FIX-MAST 5, FIX-IMU 1)", out) + self.assertIn("Next step: ", out) + self.assertNotIn("CONTRADICTION ", out) + + def test_github_actions_annotations_and_job_summary(self): + with tempfile.TemporaryDirectory() as tmp: + summary = Path(tmp, "summary.md") + env = {"GITHUB_ACTIONS": "true", "WATCHDOG_ANNOTATION": "error", "GITHUB_STEP_SUMMARY": str(summary)} + with unittest.mock.patch.dict("os.environ", env): + code, out = run("--decisions", DECISIONS, "--root", REPO, "--repository", "platform-x") + self.assertEqual(code, 1) + self.assertIn("::error file=README.md,line=3,title=Mismatch with approved decision (FIX-MAST)::", out) + self.assertEqual(out.count("::error file="), 6) + text = summary.read_text(encoding="utf-8") + self.assertIn("| FIX-MAST | 5 |", text) + self.assertIn("
FIX-MAST: 5 finding(s)", text) + + def test_no_annotations_outside_github_actions(self): + with unittest.mock.patch.dict("os.environ", {"GITHUB_ACTIONS": ""}): + code, out = run("--decisions", DECISIONS, "--root", REPO, "--repository", "platform-x") + self.assertNotIn("::warning", out) + self.assertNotIn("::error", out) + def test_exit_zero_when_clean(self): with tempfile.TemporaryDirectory() as tmp: Path(tmp, "README.md").write_text("mast_1350 is the baseline\n", encoding="utf-8") @@ -175,6 +208,17 @@ def test_rejects_invalid_entries(self): with self.assertRaisesRegex(cd.DecisionError, expected): cd.load_decisions(self.write(text)) + def test_summary_and_fix_hint_are_optional_short_single_lines(self): + ok = self.BASE.replace(" title: t\n", " title: t\n summary: Mast is 1 mm.\n fix_hint: Use 1 mm.\n") + decision = cd.load_decisions(self.write(ok))[0] + self.assertEqual((decision["summary"], decision["fix_hint"]), ("Mast is 1 mm.", "Use 1 mm.")) + for field in ("summary", "fix_hint"): + for bad in ("'" + "x" * 121 + "'", "''", "|\n two\n lines", "[a]"): + with self.subTest(field=field, value=bad): + text = self.BASE.replace(" title: t\n", f" title: t\n {field}: {bad}\n") + with self.assertRaisesRegex(cd.DecisionError, f"{field} must be one non-empty line"): + cd.load_decisions(self.write(text)) + SUPERSEDED = BASE.replace("status: recorded", "status: superseded").replace( " check: [{pattern: '(?Px)'}]\n", " superseded_by: {document: D, item: j, decision: B, citation: '(?Pold)', unless: 'history'}\n") @@ -229,6 +273,21 @@ def test_every_entry_has_provenance_verification_and_owner(self): self.assertTrue(d["source"]["item"], d["id"]) self.assertTrue(d["verification"]["human"]["evidence"], d["id"]) + def test_every_entry_has_summary_and_fix_hint(self): + for d in self.decisions: + with self.subTest(id=d["id"]): + self.assertTrue(d.get("summary")) + self.assertTrue(d.get("fix_hint")) + self.assertTrue(d.get("_line")) + + def test_finding_prints_summary_not_long_value(self): + records = [{"file": "a.md", "line": 1, "id": d["id"], "found": "x", "message": "m"} for d in self.decisions] + for record, d in zip(cd.to_watchdog(records, self.decisions), self.decisions): + with self.subTest(id=d["id"]): + self.assertEqual(record["decision"], d["summary"]) + self.assertEqual(record["fix"], d["fix_hint"]) + self.assertLessEqual(len(record["decision"]), 120) + def test_non_numeric_decisions_are_present(self): kinds = {d["id"]: d["kind"] for d in self.decisions} for did in ("MAX-ASSEMBLED-HEIGHT", "NO-SUSPENSION", "RS485-NOT-IN-2-0", "DOCK-NO-CONTACTS", diff --git a/tests/test_check_pr_evidence.py b/tests/test_check_pr_evidence.py index ae82085..ff4bdc3 100644 --- a/tests/test_check_pr_evidence.py +++ b/tests/test_check_pr_evidence.py @@ -211,13 +211,13 @@ def test_ai_markers_in_commit_messages_are_checked(self): class DecisionReport(unittest.TestCase): def test_contradictions_become_failures(self): report = ("decisions: 3 loaded\n" - "CONTRADICTION docs/a.md:4: MAST-INSTALL-HEIGHT found 'mast_1400', decided '1350 mm' (P-03)\n" + "Mismatch with approved decision: docs/a.md:4: MAST-INSTALL-HEIGHT found 'mast_1400'\n Decision: x\n" "ALLOWED docs/h.md:2: MAST-INSTALL-HEIGHT found 'mast_1400'; reason: history\n") self.assertEqual(ev.decision_failures(report), [ - "Decision contradiction: docs/a.md:4: MAST-INSTALL-HEIGHT found 'mast_1400', decided '1350 mm' (P-03)"]) + "Decision contradiction: docs/a.md:4: MAST-INSTALL-HEIGHT found 'mast_1400'; decision: x"]) def test_long_reports_are_truncated(self): - report = "\n".join(f"CONTRADICTION f.md:{i}: X found 'a', decided 'b'" for i in range(25)) + report = "\n".join(f"Mismatch with approved decision: f.md:{i}: X found 'a'" for i in range(25)) failures = ev.decision_failures(report) self.assertEqual(len(failures), 21) self.assertEqual(failures[-1], "... and 5 more decision contradictions") @@ -279,8 +279,8 @@ def test_wiring_test_names_its_tracking_issue_when_jq_is_missing(self): CLEAN = "decisions: 23 loaded\nresult: 0 contradiction(s), 0 allowed, scope 3 changed file(s)\n" TWO = ("decisions: 23 loaded\n" - "CONTRADICTION docs/a.md:4: MAST-INSTALL-HEIGHT found 'mast_1400', decided '1350 mm' (P-03)\n" - "CONTRADICTION docs/b.md:9: COMPUTE found 'Raspberry Pi 5', decided 'Jetson' (P-00)\n" + "Mismatch with approved decision: docs/a.md:4: MAST-INSTALL-HEIGHT found 'mast_1400'\n Decision: x\n" + "Mismatch with approved decision: docs/b.md:9: COMPUTE found 'Raspberry Pi 5'\n Decision: Jetson\n" "result: 2 contradiction(s), 0 allowed, scope 2 changed file(s)\n") diff --git a/tests/test_check_public_extract.py b/tests/test_check_public_extract.py index b8cc4bc..d69dd7d 100644 --- a/tests/test_check_public_extract.py +++ b/tests/test_check_public_extract.py @@ -124,11 +124,19 @@ def test_exit_codes_and_changed_files(self): Path(tmp, "docs/b.md").write_text("clean\n", encoding="utf-8") code, out = self.run_main("--root", tmp) self.assertEqual(code, 1) - self.assertIn("PUBLIC-EXTRACT docs/a.md:1: google-drive-link", out) + self.assertIn("Should not be public: docs/a.md:1: google-drive-link found", out) + self.assertIn("\n Rule: Public files do not link to internal Google Drive", out) + self.assertIn("\n Fix: Remove the link", out) + self.assertIn("WATCHDOG.md#public-extract", out) + self.assertIn("Public extract summary: 1 finding(s) (google-drive-link 1)", out) + self.assertIn("result: 1 finding(s)", out) changed = Path(tmp, "changed.txt") changed.write_text("docs/b.md\n", encoding="utf-8") self.assertEqual(self.run_main("--root", tmp, "--changed-files", changed)[0], 0) + def test_every_rule_has_guidance(self): + self.assertEqual(set(pe.GUIDE), set(pe.RULES)) + def test_invalid_allowlist_is_usage_error(self): with tempfile.TemporaryDirectory() as tmp: bad = Path(tmp, "allow.yaml") diff --git a/tests/test_check_workflow_policy.py b/tests/test_check_workflow_policy.py index 6b87ddc..9bf2403 100644 --- a/tests/test_check_workflow_policy.py +++ b/tests/test_check_workflow_policy.py @@ -1,4 +1,6 @@ """Tests for tools/check_workflow_policy.py.""" +import contextlib +import io import sys import tempfile import unittest @@ -45,6 +47,20 @@ def test_tag_branch_and_placeholder_fail(self): self.assertTrue(any("no immutable" in e for e in errors)) self.assertTrue(any("" in e for e in errors)) + def test_command_line_explains_each_finding(self): + with tempfile.TemporaryDirectory() as tmp: + self.write(tmp, ".github/workflows/bad.yml", "jobs:\n b:\n steps:\n - uses: actions/checkout@v4\n") + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = policy.main(["--root", tmp]) + text = out.getvalue() + self.assertEqual(code, 1) + self.assertIn("Workflow not pinned: .github/workflows/bad.yml:4: unpinned-action found", text) + self.assertIn("\n Rule: ", text) + self.assertIn("\n Fix: ", text) + self.assertIn("Workflow policy summary: 1 finding(s) (unpinned-action 1)", text) + self.assertIn("result: 1 workflow policy finding(s)", text) + def test_rollout_examples_are_outside_scope(self): with tempfile.TemporaryDirectory() as tmp: self.write(tmp, "rollout/workflows/example.yml", diff --git a/tests/test_reusable_workflow.py b/tests/test_reusable_workflow.py index fb20847..44d920c 100644 --- a/tests/test_reusable_workflow.py +++ b/tests/test_reusable_workflow.py @@ -25,8 +25,10 @@ def load(): return inputs, steps -FAKE = """import sys +FAKE = """import os, sys print("{line}") +# Stand-in for tools/watchdog_report.emit_github: annotate at the level the step chose. +print("::" + os.environ.get("WATCHDOG_ANNOTATION", "unset") + " file=a.md,line=1::finding") print("result: 1 contradiction(s), 0 allowed, scope full checkout") sys.exit({code}) """ @@ -43,8 +45,8 @@ def run_step(self, name, code, enforce, event="pull_request", tool="check_decisi tools.mkdir(parents=True) for t in ("check_decisions.py", "check_public_extract.py", "check_agent_rules.py", "check_workflow_policy.py"): - line = ("CONTRADICTION a.md:1: X found 'a'" if t == "check_decisions.py" - else "PUBLIC-EXTRACT a.md:1: price: 5") + line = ("Mismatch with approved decision: a.md:1: X found 'a'" if t == "check_decisions.py" + else "Should not be public: a.md:1: price found '5'") (tools / t).write_text(FAKE.format(line=line, code=code if t == tool else 0), encoding="utf-8") if agents: Path(tmp, "AGENTS.md").write_text("x\n", encoding="utf-8") @@ -97,6 +99,19 @@ def test_enforce_on_push_warns_but_fails_on_invalid_register(self): self.assertIn("::warning file=a.md,line=1::", out) self.assertEqual(self.run_step("Decisions of record", 2, enforce=True, event="push")[0], 2) + def test_annotation_level_follows_enforcement(self): + for name, tool in (("Decisions of record", "check_decisions.py"), ("Public extract", "check_public_extract.py"), + ("Workflow policy", "check_workflow_policy.py")): + with self.subTest(step=name): + self.assertIn("::error file=a.md,line=1::", self.run_step(name, 1, enforce=True, tool=tool)[1]) + self.assertIn("::warning file=a.md,line=1::", + self.run_step(name, 1, enforce=True, event="push", tool=tool)[1]) + self.assertIn("::warning file=a.md,line=1::", self.run_step(name, 1, enforce=False, tool=tool)[1]) + self.assertIn("::error file=a.md,line=1::", + self.run_step("Shared agent rules", 1, enforce=True, tool="check_agent_rules.py")[1]) + self.assertIn("::warning file=a.md,line=1::", + self.run_step("Shared agent rules", 1, enforce=False, tool="check_agent_rules.py")[1]) + def test_enforce_fails_on_shared_rules_drift(self): self.assertEqual(self.run_step("Shared agent rules", 1, enforce=True, tool="check_agent_rules.py")[0], 1) diff --git a/tests/test_watchdog_report.py b/tests/test_watchdog_report.py new file mode 100644 index 0000000..24ff224 --- /dev/null +++ b/tests/test_watchdog_report.py @@ -0,0 +1,77 @@ +"""Tests for tools/watchdog_report.py, the shared Watchdog output format.""" +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import watchdog_report as wr # noqa: E402 + + +def sample(): + return [ + wr.finding("Mismatch with approved decision", "A", "docs/x.md", 4, "old", "A is new.", "Why A.", "Use new.", + [("WATCHDOG.md", wr.DOCS)]), + wr.finding("Mismatch with approved decision", "B", "README.md", 2, "b,c:d", "B is b.", "Why B.", "Use b."), + wr.finding("Mismatch with approved decision", "A", "docs/y.md", 9, "old", "A is new.", "Why A.", "Use new."), + ] + + +class Render(unittest.TestCase): + def test_grouped_blocks_and_closing_summary(self): + lines = wr.render(sample(), "Decisions of record", "fix it.") + text = "\n".join(lines) + self.assertLess(text.index("== A: 2 finding(s) =="), text.index("== B: 1 finding(s) ==")) + self.assertIn("Mismatch with approved decision: docs/x.md:4: A found 'old'\n" + " Decision: A is new.\n Why: Why A.\n Fix: Use new.\n" + f" More: WATCHDOG.md: {wr.DOCS}", text) + self.assertEqual(lines[-2], "Decisions of record summary: 3 finding(s) (A 2, B 1)") + self.assertEqual(lines[-1], "Next step: fix it.") + + def test_clean_run_says_nothing_to_fix(self): + self.assertEqual(wr.render([], "Public extract", "x"), + ["Public extract summary: 0 finding(s)", "Next step: nothing to fix."]) + + def test_rule_word(self): + self.assertIn(" Rule: A is new.", wr.render(sample()[:1], "T", "n", decision_word="Rule")) + + +class GitHubOutput(unittest.TestCase): + def test_annotation_level_and_escaping(self): + warn = wr.annotations(sample()) + self.assertEqual(len(warn), 3) + self.assertTrue(warn[0].startswith( + "::warning file=docs/x.md,line=4,title=Mismatch with approved decision (A)::Found 'old'.")) + self.assertTrue(all(a.startswith("::error ") for a in wr.annotations(sample(), "error"))) + self.assertTrue(wr.annotations(sample(), "bogus")[0].startswith("::warning ")) + odd = wr.finding("L", "G", "a,b:c.md", 1, "x\ny", "d", "w", "f") + line = wr.annotations([odd])[0] + self.assertIn("file=a%2Cb%3Ac.md,", line) + self.assertNotIn("\n", line) + + def test_markdown_summary_table_and_details(self): + md = wr.markdown(sample(), "Decisions of record", "fix it.") + self.assertIn("### Decisions of record: 3 finding(s)", md) + self.assertIn("| A | 2 | Use new. |", md) + self.assertIn("
B: 1 finding(s)", md) + self.assertIn("- `docs/y.md:9` found `old`", md) + self.assertIn("No findings", wr.markdown([], "T", "n")) + + def test_emit_only_inside_github_actions(self): + with tempfile.TemporaryDirectory() as tmp: + summary = Path(tmp, "s.md") + self.assertEqual(wr.emit_github(sample(), "T", "n", {"GITHUB_STEP_SUMMARY": str(summary)}), []) + self.assertFalse(summary.exists()) + env = {"GITHUB_ACTIONS": "true", "GITHUB_STEP_SUMMARY": str(summary), "WATCHDOG_ANNOTATIONS": "0"} + self.assertEqual(wr.emit_github(sample(), "T", "n", env), []) + env.pop("WATCHDOG_ANNOTATIONS") + env["WATCHDOG_ANNOTATION"] = "error" + lines = wr.emit_github(sample(), "T", "n", env) + self.assertEqual(len(lines), 3) + self.assertTrue(lines[0].startswith("::error ")) + self.assertIn("| A | 2 |", summary.read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/check_agent_rules.py b/tools/check_agent_rules.py index 54db75f..b2a5e8e 100644 --- a/tools/check_agent_rules.py +++ b/tools/check_agent_rules.py @@ -9,6 +9,23 @@ from pathlib import Path import sys +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import watchdog_report as wr # noqa: E402 + +LABEL = 'Shared agent rules out of date' +NEXT_STEP = ('copy the shared block from agent-rules/SHARED_RULES.md in openAMRobot/.github unchanged, ' + 'keep repository-specific rules below it, and keep CLAUDE.md as the single line @AGENTS.md.') +WHY = 'Every repository gives contributors and agents the same rules; a drifted copy quietly changes them.' + + +def fix_for(message): + if 'lines' in message: + return 'Shorten the repository-specific part so AGENTS.md stays under 120 lines.' + if 'CLAUDE.md' in message: + return 'Make CLAUDE.md contain only the line @AGENTS.md.' + return 'Copy the block between the BEGIN and END markers from agent-rules/SHARED_RULES.md unchanged.' + + MARKER = re.compile(r'') MAX_LINES = 120 @@ -48,7 +65,7 @@ def main(argv=None): files = sorted(set(files)) if not files: p.error('no AGENTS.md files selected; refusing empty success') - failed = False + problems = [] for path in files: try: text = path.read_text(encoding='utf-8') @@ -60,9 +77,15 @@ def main(argv=None): raise ValueError('CLAUDE.md must import @AGENTS.md without duplicate rules') print(f'PASS {path} ({version})') except (OSError, ValueError) as e: - failed = True - print(f'FAIL {path}: {e}', file=sys.stderr) - return 1 if failed else 0 + problems.append(wr.finding( + LABEL, 'shared-rules', str(path), 1, str(e), + f'AGENTS.md carries the canonical shared block {version} from openAMRobot/.github.', + WHY, fix_for(str(e)), [('WATCHDOG.md', f'{wr.DOCS}#shared-agent-rules')])) + if problems: + for line in wr.render(problems, 'Shared agent rules', NEXT_STEP, decision_word='Rule'): + print(line) + wr.emit_github(problems, 'Shared agent rules', NEXT_STEP) + return 1 if problems else 0 if __name__ == '__main__': diff --git a/tools/check_decisions.py b/tools/check_decisions.py index 8bdc522..f83276d 100644 --- a/tools/check_decisions.py +++ b/tools/check_decisions.py @@ -31,6 +31,15 @@ import yaml +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import watchdog_report as wr # noqa: E402 + +LABEL = "Mismatch with approved decision" +NEXT_STEP = ("correct each line to the current decision, label historical material with the words the " + "decision accepts (WATCHDOG.md, How to fix), or open a contract change request if the " + "decision itself is wrong.") +HINT_MAX = 120 + SCHEMA_VERSION = 1 STATUSES = {"recorded", "open", "superseded"} KINDS = {"value", "configuration", "limit", "exclusion", "distinction"} @@ -127,6 +136,11 @@ def load_decisions(path, maintainers=None): errors.append(f"{where}: reviewer {human['reviewer']!r} is not a role in the maintainers map") if "value" not in d and "values" not in d: errors.append(f"{where}: needs value or values") + for field in ("summary", "fix_hint"): + text = d.get(field) + if text is not None and (not isinstance(text, str) or not text.strip() or "\n" in text + or len(text) > HINT_MAX): + errors.append(f"{where}: {field} must be one non-empty line of at most {HINT_MAX} characters") src = d["source"] if not isinstance(src, dict) or not src.get("document") or not src.get("item"): errors.append(f"{where}: source needs document and item") @@ -175,7 +189,13 @@ def load_decisions(path, maintainers=None): if errors: raise DecisionError("\n".join(errors)) exclude = data.get("exclude") or [] + entry_lines = {} + for number, text in enumerate(Path(path).read_text(encoding="utf-8").splitlines(), 1): + m = re.match(r"\s*-\s+id:\s*(\S+)", text) + if m: + entry_lines.setdefault(m.group(1), number) for d in decisions: + d["_line"] = entry_lines.get(d["id"]) d["_exclude"] = list(exclude) + list(d["applies_to"].get("exclude") or []) if d["status"] == "superseded": by = d["superseded_by"] @@ -203,6 +223,29 @@ def decided_text(d): return f"{value} {unit}".strip() if unit and unit != "none" else str(value) +def short_decision(d, limit=HINT_MAX): + """One short line for a finding: the entry's summary, else its value cut to size.""" + text = d.get("summary") or decided_text(d) + return text if len(text) <= limit else text[:limit - 3].rstrip() + "..." + + +def to_watchdog(records, decisions): + """Turn scan() records into Watchdog findings with decision, why, fix and links.""" + by_id = {d["id"]: d for d in decisions} + out = [] + for r in records: + d = by_id[r["id"]] + anchor = f"#L{d['_line']}" if d.get("_line") else "" + out.append(wr.finding( + LABEL, r["id"], r["file"], r["line"], r["found"], + short_decision(d), + (r.get("message") or "this line disagrees with the approved decision").rstrip(".") + ".", + d.get("fix_hint") or "Correct the line to the current decision, or label historical material.", + [(f"{d['id']} in decisions.yaml", f"{wr.REGISTER}{anchor}"), + ("WATCHDOG.md", f"{wr.DOCS}#decisions-of-record")])) + return out + + def repository_matches(d, repository): repos = d["applies_to"]["repositories"] return repository is None or any(fnmatch.fnmatch(repository, r) for r in repos) @@ -331,10 +374,12 @@ def main(argv=None): stats = {} findings, allowed = scan(a.root, decisions, a.repository, only, stats) for f in allowed: - print(f"ALLOWED {f['file']}:{f['line']}: {f['id']} found {f['found']!r}; reason: {f['reason']}") - for f in findings: - print(f"CONTRADICTION {f['file']}:{f['line']}: {f['id']} found {f['found']!r}, " - f"decided {f['decided']!r} ({f['source']}) {f['message']}".rstrip()) + print(f"Allowed by decision-allow marker: {f['file']}:{f['line']}: {f['id']} found {f['found']!r}; " + f"reason: {f['reason']}") + friendly = to_watchdog(findings, decisions) + for line in wr.render(friendly, "Decisions of record", NEXT_STEP): + print(line) + wr.emit_github(friendly, "Decisions of record", NEXT_STEP) if a.json: a.json.write_text(json.dumps({"findings": findings, "allowed": allowed}, indent=2), encoding="utf-8") scope = f"{len(only)} changed file(s)" if only is not None else "full checkout" diff --git a/tools/check_pr_evidence.py b/tools/check_pr_evidence.py index 21f1891..7f01896 100644 --- a/tools/check_pr_evidence.py +++ b/tools/check_pr_evidence.py @@ -188,9 +188,27 @@ def evaluate(pr, changed, maintainers, reviews=(), has_state=False, commit_messa return failures, warnings, notes +# First line of each check_decisions.py finding (tools/watchdog_report.py format). +DECISION_FINDING = "Mismatch with approved decision: " + + +def decision_findings(report): + """Finding headers, each with its one-line Decision when the report gives one.""" + lines = report.splitlines() + out = [] + for i, line in enumerate(lines): + if line.startswith(DECISION_FINDING): + text = line[len(DECISION_FINDING):] + following = lines[i + 1].strip() if i + 1 < len(lines) else "" + if following.startswith("Decision:"): + text += f"; decision: {following[len('Decision:'):].strip()}" + out.append(text) + return out + + def decision_failures(report, limit=20): - """Turn check_decisions.py output lines into summary failures.""" - lines = [l[len("CONTRADICTION "):] for l in report.splitlines() if l.startswith("CONTRADICTION ")] + """Turn check_decisions.py findings into summary failures.""" + lines = decision_findings(report) out = [f"Decision contradiction: {l}" for l in lines[:limit]] if len(lines) > limit: out.append(f"... and {len(lines) - limit} more decision contradictions") @@ -208,8 +226,8 @@ def decision_verdict(report, status): report is the checker's combined output (None if the file is missing); status is the parsed status file, e.g. {"exit_code": 1} (None if missing). Only the two documented policy outcomes are accepted: - exit 0 with "result: 0 contradiction(s)" and no CONTRADICTION lines (clean); - exit 1 with CONTRADICTION lines whose count matches the result line. + exit 0 with "result: 0 contradiction(s)" and no finding lines (clean); + exit 1 with "Mismatch with approved decision:" finding lines whose count matches the result line. Everything else fails closed as a checker error: a crash, exit 2 (invalid register or usage), any other exit code, empty output, or a missing file. """ @@ -223,7 +241,7 @@ def decision_verdict(report, status): if "Traceback (most recent call last)" in report: return [], [f"checker error: check_decisions.py crashed (exit {code}): {head}"] listed = decision_failures(report) - contradictions = [l for l in report.splitlines() if l.startswith("CONTRADICTION ")] + contradictions = decision_findings(report) m = RESULT_LINE.search(report) reported = int(m.group(1)) if m else None if code == 0 and reported == 0 and not contradictions: diff --git a/tools/check_public_extract.py b/tools/check_public_extract.py index 33f56ea..860bb38 100644 --- a/tools/check_public_extract.py +++ b/tools/check_public_extract.py @@ -20,6 +20,40 @@ import yaml +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import watchdog_report as wr # noqa: E402 + +LABEL = "Should not be public" +NEXT_STEP = ("remove each value from the public file or move it to an internal place; if it is " + "intentionally public (organization contact, licensing), the docs owner decides an allowlist entry.") +# Per rule: the rule in one line, why it matters, how to fix. +GUIDE = { + "google-drive-link": ("Public files do not link to internal Google Drive or Docs documents.", + "Internal documents are private; a public link leaks their existence or breaks for readers.", + "Remove the link, or publish the content in the repository or documentation site and link there."), + "email": ("Public files carry no personal e-mail addresses.", + "Personal contact data must not be published; the organization contact is the only listed address.", + "Use the organization contact address or a GitHub handle instead."), + "phone": ("Public files carry no phone numbers.", + "Personal contact data must not be published.", + "Remove the number; point readers to the organization contact or a GitHub issue."), + "price": ("Public files carry no prices outside the documented public pricing.", + "Prices change and are commercial information; stale or internal prices mislead readers.", + "Remove the price, or link to the one canonical public pricing page."), + "credential": ("Public files never contain credentials or secrets.", + "A published secret can be abused at once and must be treated as compromised.", + "Remove it, rotate the secret now, and load it from a secret store instead."), +} + + +def to_watchdog(findings): + out = [] + for f in findings: + rule, why, fix = GUIDE[f["rule"]] + out.append(wr.finding(LABEL, f["rule"], f["file"], f["line"], f["found"], rule, why, fix, + [("WATCHDOG.md", f"{wr.DOCS}#public-extract")])) + return out + RULES = { "google-drive-link": re.compile(r"https?://(?:drive|docs)\.google\.com/[^\s\"'<>)\]]*[^\s\"'<>)\].,;:]", re.I), "email": re.compile(r"(? with the harness merge SHA.") +GUIDE = { + "unpinned-action": ("Workflow not pinned", + "Every action in a live workflow is pinned to a full 40-character commit SHA.", + "A tag or branch can be moved to different code; a SHA cannot.", + "Replace the tag with the release's commit SHA, e.g. actions/checkout@ # v4.4.0."), + "harness-placeholder": ("Placeholder left in workflow", + "Live workflows contain no placeholder from a copied example.", + "The placeholder is not a valid reference, so the workflow cannot run as intended.", + "Replace with the openAMRobot/.github merge SHA (rollout/README.md step b)."), + "unreadable": ("Workflow unreadable", + "Live workflow files are readable UTF-8 text.", + "An unreadable workflow cannot be checked.", + "Re-save the file as UTF-8 text."), +} FULL_SHA = re.compile(r"^[0-9a-f]{40}$") USES = re.compile(r"^\s*(?:-\s*)?uses:\s*([^\s#]+)") @@ -23,7 +42,8 @@ def workflow_files(root): if path.is_file() and path.suffix in {".yml", ".yaml"}) -def findings(root): +def records(root): + """Structured findings: dicts with file, line, rule and text.""" root = Path(root) out = [] for path in workflow_files(root): @@ -31,11 +51,12 @@ def findings(root): try: lines = path.read_text(encoding="utf-8").splitlines() except (OSError, UnicodeDecodeError) as exc: - out.append(f"{rel}:1: cannot read workflow: {exc}") + out.append({"file": rel, "line": 1, "rule": "unreadable", "text": f"cannot read workflow: {exc}"}) continue for number, line in enumerate(lines, 1): if "" in line: - out.append(f"{rel}:{number}: unresolved placeholder in live workflow") + out.append({"file": rel, "line": number, "rule": "harness-placeholder", + "text": "unresolved placeholder in live workflow"}) match = USES.match(line) if not match: continue @@ -43,11 +64,28 @@ def findings(root): if reference.startswith("./") or reference.startswith("docker://"): continue if "@" not in reference: - out.append(f"{rel}:{number}: action reference has no immutable @: {reference}") + out.append({"file": rel, "line": number, "rule": "unpinned-action", + "text": f"action reference has no immutable @: {reference}", "found": reference}) continue action, ref = reference.rsplit("@", 1) if not action or not FULL_SHA.fullmatch(ref): - out.append(f"{rel}:{number}: action is not pinned to a full commit SHA: {reference}") + out.append({"file": rel, "line": number, "rule": "unpinned-action", + "text": f"action is not pinned to a full commit SHA: {reference}", "found": reference}) + return out + + +def findings(root): + """Findings as "path:line: text" strings.""" + return [f"{r['file']}:{r['line']}: {r['text']}" for r in records(root)] + + +def to_watchdog(recs): + out = [] + for r in recs: + label, rule, why, fix = GUIDE[r["rule"]] + found = r.get("found") or ("" if r["rule"] == "harness-placeholder" else r["text"]) + out.append(wr.finding(label, r["rule"], r["file"], r["line"], found, rule, why, fix, + [("WATCHDOG.md", f"{wr.DOCS}#workflow-policy")])) return out @@ -58,9 +96,13 @@ def main(argv=None): if not args.root.is_dir(): print(f"root is not a directory: {args.root}", file=sys.stderr) return 2 - errors = findings(args.root) - for error in errors: - print(f"WORKFLOW-POLICY {error}") + recs = records(args.root) + errors = [f"{r['file']}:{r['line']}: {r['text']}" for r in recs] + friendly = to_watchdog(recs) + if friendly: + for line in wr.render(friendly, "Workflow policy", NEXT_STEP, decision_word="Rule"): + print(line) + wr.emit_github(friendly, "Workflow policy", NEXT_STEP) if errors: print(f"result: {len(errors)} workflow policy finding(s)") return 1 diff --git a/tools/watchdog_report.py b/tools/watchdog_report.py new file mode 100644 index 0000000..d0c86e8 --- /dev/null +++ b/tools/watchdog_report.py @@ -0,0 +1,119 @@ +"""Shared, contributor-friendly output for the OpenAMRobot Watchdog checks. + +Every check reports findings in the same shape, so a contributor reads one format: + + Decisions of record (`tools/check_decisions.py`) | Text files covered by each entry in [decisions.yaml](decisions.yaml) | Builders and reviewers must see the approved value, not an old one | `Mismatch with approved decision: README.md:44: COMPUTE found 'Raspberry Pi 5'` | Correct the line, or label it as history with an accepted word (table below), or propose a decision change | Each entry's `owner` role, usually the platform lead | +| Public extract (`tools/check_public_extract.py`) | `docs/`, `assets/`, every `README.md` and any path containing `public` | Public pages must not leak internal links, personal contact data, prices or secrets | `Should not be public: docs/a.md:3: google-drive-link found 'https://drive.google.com/...'` | Remove the value or move it to an internal place; intentional public contacts go on the allowlist ([public-extract-allowlist.yaml](public-extract-allowlist.yaml)) after review | Docs owner | +| Shared agent rules (`tools/check_agent_rules.py`) | `AGENTS.md` and `CLAUDE.md` | Every contributor and agent follows the same rules; a changed copy quietly changes them | `Shared agent rules out of date: AGENTS.md:1: shared-rules found 'shared block differs from canonical'` | Copy the block between the BEGIN and END markers from [agent-rules/SHARED_RULES.md](agent-rules/SHARED_RULES.md) unchanged | Software lead | +| Workflow policy (`tools/check_workflow_policy.py`) | `.github/workflows/*.yml` | A tag or branch can be moved to new code; a full commit SHA cannot | `Workflow not pinned: .github/workflows/ci.yml:12: unpinned-action found 'actions/checkout@v4'` | Pin the action to its 40-character commit SHA and keep the version as a comment | CI owner | +| PR evidence and AI disclosure (`tools/check_pr_evidence.py`) | The pull request description | Reviewers need the base and head commits, exact commands, test counts, a Not verified section and an honest AI disclosure | `Missing section: Not verified` | Fill every section of the pull request template | CI owner | +| verify.sh (`rollout/verify.sh`) | The repository's build and tests | A test run that runs zero tests proves nothing; a skipped test must say why | `FAIL: zero tests executed` or `FAIL: skip/xfail/importorskip without a tracking issue` | Add real tests; put the tracking issue on the same line as each skip | CI owner | +| Decision freshness (`review_by` in the register) | The `review_by` date of each register entry | An old decision may no longer be true | `Review due: COMPUTE review_by 2026-11-18 is past due` (a warning, never a failure) | The entry's owner confirms the decision or starts a change | Each entry's `owner` role | + +## How to read a finding + +This is a real finding from the scan of `openamr-platform-hw` on 7 October 2026: + +```text +Mismatch with approved decision: README.md:44: COMPUTE found 'Raspberry Pi 5' + Decision: Reference compute: NVIDIA Jetson Orin NX 16 GB on a reComputer Robotics J401; Pi is legacy. + Why: Jetson Orin NX is the 2.0 reference compute; label Raspberry Pi material as legacy. + Fix: Name the Jetson Orin NX, or label Raspberry Pi material as legacy or Gate A. + More: COMPUTE in decisions.yaml: https://github.com/openAMRobot/.github/blob/main/decisions.yaml#L709 | WATCHDOG.md: ... +``` + +- The first line is the place: file `README.md`, line 44. `COMPUTE` is the decision ID. +- **Decision** is the current decision in one line. +- **Why** says what is wrong with this line. +- **Fix** says what to change. +- **More** links to the register entry and to this page. + +The line before the fix (the README describes the robot that exists today, which still uses +the Raspberry Pi): + +```text +| Compute | **Raspberry Pi 5, 8 GB** | Ubuntu Server 24.04 + ROS 2 Jazzy. | +``` + +After the fix, the line says clearly that this is legacy (Gate A) material, so the finding +disappears: + +```text +| Compute (legacy, Gate A robot) | **Raspberry Pi 5, 8 GB** | Ubuntu Server 24.04 + ROS 2 Jazzy. | +``` + +If the line was meant to describe release 2.0, the right fix is to name the Jetson Orin NX +instead. + +At the end of every run the Watchdog prints a summary: the total number of findings, the +number per decision and the next step. + +## How to fix a finding + +Pick one of these, in this order: + +1. **Correct the line** to the current decision. +2. **Label historical material clearly.** If the line describes the existing robot, an older + revision or OpenAMRobot 3.0, say so *on the same line*, using one of the words the check + accepts for that decision (table below). For example `legacy`, `Gate A`, `superseded`, + `historical` or `3.0`. Case does not matter. If no accepted word fits, keep the line and add + a marker on the same line or the line above: + `decision-allow: `. The Watchdog still reports the marker, so a reviewer sees + it. +3. **If the decision itself is wrong**, do not edit the register in your pull request. Open a + [contract change request](https://github.com/openAMRobot/.github/issues/new?template=contract_change_request.yml) + that names the entry. The owner updates the source document first; then one reviewed pull + request updates the register and every affected repository. + +**Never weaken a check to make CI pass.** Do not delete a pattern, widen an exception or skip +a step. If you believe a finding is wrong, see the FAQ below. + +### Words each decision accepts + +The Watchdog accepts a line when one of these words appears on the same line. Where a +decision has several checks, each check is named by the message shown in the finding's +**Why** line. `supersed` also matches *superseded* and *supersedes*. The table is generated +from the register with `python3 tools/watchdog.py --accepted-words`, and a test keeps it +identical to the register. + + +| Decision | Status | Accepted on the same line (any one; case does not matter) | +|---|---|---| +| BOM-ISSUE-IN-FORCE | recorded | *BOM Issue 7.3 is the canonical hardware BOM*: `supersed (superseded, supersedes)`, `replaced`, `previous`, `earlier`
*B-01 is superseded*: `supersed (superseded, supersedes)`
*BOM Issue 7.3 is the canonical hardware BOM*: `supersed (superseded, supersedes)`, `replaced`, `previous`, `earlier`, `historical`
*superseded source still cited (P-03-rev18.1 line 7); cite P-03-rev18.7*: no label; correct the line or add a decision-allow marker | +| MAST-INSTALL-HEIGHT | superseded | `supersed (superseded, supersedes)`, `legacy`, `historical`, `earlier`, `previous`, `rev 18.1 to rev 18.6` | +| MAST-POSITIONS | superseded | `supersed (superseded, supersedes)`, `legacy`, `historical`, `earlier`, `previous`, `rev 18.1 to rev 18.6` | +| MAST-TOP-HEIGHT | superseded | `supersed (superseded, supersedes)`, `legacy`, `historical`, `earlier`, `previous`, `rev 18.1 to rev 18.6` | +| MAX-ASSEMBLED-HEIGHT | recorded | *maximum assembled height is 1700 mm*: no label; correct the line or add a decision-allow marker
*1700 mm is the assembled-height envelope, not a shoulder height or mast position*: `max`, `envelope`, `not shoulder, not the shoulder`, `higher`, `supersed (superseded, supersedes)` | +| DATUM-HEIGHT-STACK | recorded | *the steel chassis deck top is 294 mm above the floor*: `supersed (superseded, supersedes)`, `historical`, `legacy`, `earlier`, `previous`
*the lift base-plate top face is 304 mm above the floor, the reference for lift and shoulder heights*: `supersed (superseded, supersedes)`, `historical`, `legacy`, `earlier`, `previous` | +| FRAMES-REP105 | recorded | *base_link is at the drive-axle midpoint and axle height; base_footprint is on the floor*: `base_footprint`, `not`, `never`, `supersed (superseded, supersedes)`, `historical`, `legacy`
*base_footprint is on the floor under the drive-axle midpoint*: `not`, `never`, `supersed (superseded, supersedes)`, `historical`, `legacy`
*imu_link sits on the centreline away from motor magnetic fields*: `away`, `not`, `never`, `supersed (superseded, supersedes)`, `historical`, `legacy` | +| BATTERY-PLACEMENT | recorded | `supersed (superseded, supersedes)`, `historical`, `previous`, `earlier`, `rev 18.1 to rev 18.6` | +| SPEED-CEILING | recorded | *1.5 m/s is a command ceiling, not an operating limit*: `ceiling`, `analytical`
*1.5 m/s is not an accepted operating speed*: `ceiling`, `analytical`, `not` | +| DRIVETRAIN | recorded | *the selected motor variant is ZLLG80ASM250-L-B*: no label; correct the line or add a decision-allow marker
*ZLTECH is the selected 2.0 drivetrain, not an option*: no label; correct the line or add a decision-allow marker
*brake fail-safe function and ratings are open supplier-evidence gates*: `pending`, `F2A`, `verif`, `not yet`, `unverified` | +| RS485-NOT-IN-2-0 | recorded | *RS485 is not provisioned in 2.0*: `not used`, `no RS485`, `not provisioned`, `without`, `legacy`, `Gate A`, `removed`, `not in 2.0`
*CAN1 and CAN2 are dedicated; no upper-body serial link to the base*: no label; correct the line or add a decision-allow marker | +| BASE-CONTROLLER-GATES | recorded | *Teensy is the Gate A controller and the release fallback, not a bench target*: no label; correct the line or add a decision-allow marker
*ICM-42688-P is gated; MPU6500 remains the Gate A IMU*: `Gate B`, `conditional`, `20 Nov`, `candidate`, `pending`, `not yet`, `after`
*the Gate A IMU is the MPU6500*: no label; correct the line or add a decision-allow marker | +| BASE-CONTROLLER-IO | recorded | *the base controller is the STM32H723ZG on the NUCLEO-H723ZG; label STM32H743 / NUCLEO-H743ZI2 material as superseded*: `supersed (superseded, supersedes)`, `legacy`, `historical`, `replaced`
*the ultrasonic sensors are two MaxBotix MB7060 on dedicated STM32 UARTs at 9600 8N1; no sensor I2C*: `supersed (superseded, supersedes)`, `legacy`, `historical`, `replaced`
*micro-ROS runs over UDP on Ethernet between MCU and Jetson; USB is for the bench only*: `bench`, `legacy`, `Gate A`, `Teensy`, `supersed (superseded, supersedes)`, `historical` | +| IMU-TOPIC-OWNERSHIP | recorded | *firmware publishes /imu/data_raw; /imu/data belongs to the host filter*: `host`, `filter (not unfiltered)`, `EKF`, `madgwick`, `must not`, `never`, `outdated`, `older revision(s)`
*firmware must not publish filtered /imu/data*: no label; correct the line or add a decision-allow marker | +| CAMERAS | recorded | *the base camera is the Orbbec Gemini 336L*: `legacy`, `historical`, `previous`, `not used`, `replaced`
*the base camera tilts about 10 degrees up*: no label; correct the line or add a decision-allow marker
*the 2.0 base camera is the Orbbec Gemini 336L*: `legacy`, `Gate A`, `historical`
*the Gemini 336L up-tilt positions are 5, 10 and 15 degrees, baseline 10*: `supersed (superseded, supersedes)`, `historical`, `legacy` | +| HEAD-CAMERA-IDENTITY | recorded | `supersed (superseded, supersedes)`, `historical`, `legacy`, `replaced`, `not` | +| POWER-RAILS | recorded | *there is no 12 V rail in 2.0*: `no 12`, `not`, `never`, `legacy`
*there is no 12 V rail in 2.0*: `legacy` | +| DOCK-NO-CONTACTS | recorded | *2.0 docking has no dock contacts*: `no`, `not`, `without`, `3.0`, `never`
*no dock or charge pilot in 2.0*: `no`
*wireless charging belongs to 3.0*: `3.0`, `later`, `deferred`, `not` | +| DOCKING-NOT-CHARGING | recorded | *2.0 docking is positioning only*: `not`, `never`, `instead`, `non-charging`, `legacy`
*never report charging from a docking pose*: no label; correct the line or add a decision-allow marker
*docking success never establishes charging*: `not`, `never`, `does not` | +| TELEMETRY-NOT-SAFETY-EVIDENCE | recorded | `not`, `never`, `functional`, `no substitute` | +| SAFETY-PROCUREMENT | recorded | *the EDM variant is not selected*: `not`, `unselected`, `open`
*no single-channel or uncertified E-stop recommendation*: no label; correct the line or add a decision-allow marker
*CTL-004 is bench-only*: `bench-only`, `until` | +| COMPUTE | recorded | `legacy`, `historical`, `removed`, `supersed (superseded, supersedes)`, `Gate A`, `previous`, `earlier` | +| NAV-LIDAR | recorded | *the 2.0 navigation LiDAR is the RPLIDAR S3 (S3M1-R2); the Hokuyo UST-10LX was dropped*: `supersed (superseded, supersedes)`, `dropped`, `legacy`, `historical`
*the 2.0 navigation LiDAR is the RPLIDAR S3 (S3M1-R2); label RPLIDAR A1 material as legacy*: `legacy`, `Gate A`, `existing robot`, `historical`, `replaced` | +| RELEASE-MILESTONES | recorded | *the OpenAMRobot 2.0 final release is 18 December 2026*: `supersed (superseded, supersedes)`, `previous`, `earlier`, `historical`
*there is no v0.2 release; development cycle 2 ends 20 November 2026 with v2.0.0-rc.1, and v2.0.0 follows on 18 December 2026*: `supersed (superseded, supersedes)`, `historical`, `earlier`, `previous`
*development cycle 2 ends 20 November 2026 with v2.0.0-rc.1, not 13 November*: `supersed (superseded, supersedes)`, `historical`, `earlier`, `previous` | +| LIFT | recorded | *the lift is approved in principle for 2.0*: `supersed (superseded, supersedes)`, `historical`, `earlier`, `previous`, `no longer`, `rev 18.1 to rev 18.6`
*the fixed mast is no longer the baseline; the lift is approved in principle*: `supersed (superseded, supersedes)`, `historical`, `earlier`, `previous`, `legacy`, `replace (replaced, replacement)`, `instead of`, `no longer`, `rev 18.1 to rev 18.6`
*the lift is not deferred to 3.0; it is approved in principle for 2.0*: `supersed (superseded, supersedes)`, `historical`, `earlier`, `previous`, `no longer`, `rev 18.1 to rev 18.6` | +| NO-SUSPENSION | recorded | `3.0`, `no`, `not fitted`, `without` | +| DRIVE-TRACK | open | not scanned until the decision is taken | + + +## Run it locally in one command + +From a checkout of `openAMRobot/.github`, next to the repository you want to check: + +```bash +python3 -m pip install --user PyYAML # once, if PyYAML is missing +python3 tools/watchdog.py --root ../openamrobot-docs +``` + +It runs decisions of record, public extract, shared agent rules (when the repository has an +`AGENTS.md`), workflow policy and decision freshness, prints each finding with its fix, then a +summary table. Exit status: 0 clean, 1 findings, 2 a configuration or usage error. Useful +options: + +- `--report-only`: never fail on findings (exit 0), for a first look. +- `--repository NAME`: the repository name, if the folder has another name. +- `--json FILE` and `--markdown FILE`: machine-readable and Markdown output. + +PR evidence and verify.sh run on their own: `python3 tools/check_pr_evidence.py --help` and +`bash rollout/verify.sh `. + +## In CI + +- **In a repository's pull requests**, the reusable workflow + [repository-quality-reusable.yml](.github/workflows/repository-quality-reusable.yml) runs the + same checks. With `harness_warn: true` every finding is a warning and the job never fails. + With `harness_checks: true` a finding in a changed file fails the pull request. Findings + appear inline in the pull request diff, and the job summary shows a table grouped by + decision. +- **Across the organization**, [watchdog-org-scan.yml](.github/workflows/watchdog-org-scan.yml) + runs every Monday and on demand. It clones every repository in + [rollout/repositories.yaml](rollout/repositories.yaml) on its default branch and writes one + summary with a table per repository. It only reports: it never fails on findings, never + pushes and never opens issues or comments. A repository that cannot be cloned is shown as + BLOCKED. + +## Adopting it in a repository + +The full steps are in [rollout/README.md](rollout/README.md). In short, one pull request per +step, each merged by the repository owner: + +- [ ] **Pilot first:** `openamrobot-interfaces` completes every step before any other repository starts. +- [ ] (a) Copy the shared agent rules into `AGENTS.md`; `CLAUDE.md` contains only `@AGENTS.md`; add `STATE.md`. +- [ ] (b) Pin the reusable workflow and `harness_ref` to one harness commit SHA. +- [ ] (c) Turn on warn-only (`harness_warn: true`); fix or label the findings until a run on main is green. +- [ ] (d) Turn on enforcing (`harness_checks: true`). +- [ ] (e) The repository's ruleset requires the check. + +Only after step (e) does a finding block a merge. + +## Status of the AI workflows + +The example AI workflows in `rollout/workflows/` (docs sync, weekly audit, monthly retro) are +**design only**. They stay inactive until +[issue #43](https://github.com/openAMRobot/.github/issues/43) is closed with its activation +evidence. The Watchdog itself uses no AI. + +## FAQ + +**I think a finding is a false positive.** First check whether the line really reads as the +current decision to a newcomer. If it is history, label it (see the table). If it is +genuinely correct and no label fits, open an issue labelled `harness` with the file, line and +finding. The CI owner fixes the pattern in this repository with a regression test. Do not +change the check in your own pull request. + +**A decision changed.** The register is updated in one reviewed pull request, and the +Watchdog then reports every line that still shows the old value. Fix them in your repository, +or label them as history. + +**A value is still open.** Entries with status `open` are not scanned. Write "open" or +"to be decided" and do not invent a value. The register entry says who decides it. + +**Who do I ask?** The owner role of the check in the table above. Roles and their people are +in [maintainers.yaml](maintainers.yaml). For a decision, ask the role named in its `owner` +field. diff --git a/decisions.yaml b/decisions.yaml index 7a8d6bd..8916650 100644 --- a/decisions.yaml +++ b/decisions.yaml @@ -98,9 +98,11 @@ sources: title: 2.0 docking work package evidence: plan-set work package, not held in any repository -# Never scanned: this register, checker fixtures and change logs. +# Never scanned: this register, checker fixtures, change logs and the Watchdog guide +# (WATCHDOG.md quotes findings and its accepted-words table is generated from this register). exclude: - decisions.yaml + - WATCHDOG.md - tests/fixtures/** - "**/CHANGELOG.md" - agent-runs.md diff --git a/tests/test_watchdog.py b/tests/test_watchdog.py index 4ceb171..c5a29a4 100644 --- a/tests/test_watchdog.py +++ b/tests/test_watchdog.py @@ -109,5 +109,46 @@ def test_freshness_reports_past_review_dates(self): self.assertEqual(watchdog.total(report), 0) +class Guide(unittest.TestCase): + """WATCHDOG.md stays in step with the register and the checks.""" + + @classmethod + def setUpClass(cls): + cls.text = (ROOT / "WATCHDOG.md").read_text(encoding="utf-8") + + def test_accepted_words_table_matches_register(self): + code, out = run("--accepted-words") + self.assertEqual(code, 0) + section = self.text.split("\n", 1)[1].split("", 1)[0] + self.assertEqual(section, out, "regenerate with: python3 tools/watchdog.py --accepted-words") + + def test_every_decision_is_listed(self): + for d in watchdog.cd.load_decisions(ROOT / "decisions.yaml"): + self.assertIn(f"| {d['id']} | {d['status']} |", self.text) + + def test_link_anchors_used_by_findings_exist(self): + for anchor in ("decisions-of-record", "public-extract", "shared-agent-rules", "workflow-policy", + "pr-evidence", "verify", "decision-freshness"): + self.assertIn(f'', self.text) + + def test_plain_words(self): + self.assertEqual(watchdog.plain_words(r"supersed|\bnot\b|rev ?18\.[1-6]\b|3\.0"), + ["supersed (superseded, supersedes)", "not", "rev 18.1 to rev 18.6", "3.0"]) + self.assertEqual(watchdog.split_alternatives(r"a(?:b|c)|[|]|d"), ["a(?:b|c)", "[|]", "d"]) + + def test_guide_is_excluded_only_at_the_repository_root(self): + decisions = watchdog.cd.load_decisions(ROOT / "decisions.yaml") + with tempfile.TemporaryDirectory() as tmp: + for name in ("WATCHDOG.md", "docs/WATCHDOG.md"): + Path(tmp, name).parent.mkdir(parents=True, exist_ok=True) + Path(tmp, name).write_text("Compute is a Raspberry Pi 5.\n", encoding="utf-8") + found = watchdog.cd.scan(tmp, decisions, ".github")[0] + self.assertEqual(sorted(f["file"] for f in found), ["docs/WATCHDOG.md"]) + + def test_guide_is_linked(self): + for name in ("README.md", "CONTRIBUTING.md"): + self.assertIn("WATCHDOG.md", (ROOT / name).read_text(encoding="utf-8"), name) + + if __name__ == "__main__": unittest.main() diff --git a/tools/watchdog.py b/tools/watchdog.py index 0658701..a609dd8 100755 --- a/tools/watchdog.py +++ b/tools/watchdog.py @@ -17,6 +17,7 @@ """ import argparse import json +import re import sys from datetime import date from pathlib import Path @@ -90,6 +91,72 @@ def run(root, repository, harness=HARNESS, today=None): "freshness": freshness(register, today), "unscanned": stats.get("unscanned", 0)} +SPECIAL_WORDS = { + r"rev ?18\.[1-6]": "rev 18.1 to rev 18.6", + r"RS-?485": "RS485 or RS-485", + r"no RS-?485": "no RS485", + r"not (?:the )?shoulder": "not shoulder, not the shoulder", + r"(? 1 and why else "" + cells.append(label + (", ".join(f"`{w}`" for w in words) if words + else "no label; correct the line or add a decision-allow marker")) + rows.append(f"| {d['id']} | {d['status']} | " + "
".join(cells).replace("|", "\\|") + " |") + return rows + + def total(report): return sum(len(v) for v in report["results"].values() if v) @@ -164,7 +231,17 @@ def main(argv=None): p.add_argument("--report-only", action="store_true", help="never fail on findings") p.add_argument("--json", type=Path, help="write the full result as JSON") p.add_argument("--markdown", type=Path, help="append a per-repository Markdown summary to this file") + p.add_argument("--accepted-words", action="store_true", + help="print the labels each decision accepts (the table in WATCHDOG.md) and exit") a = p.parse_args(argv) + if a.accepted_words: + try: + decisions = cd.load_decisions(Path(a.harness, "decisions.yaml"), Path(a.harness, "maintainers.yaml")) + except cd.DecisionError as exc: + print(f"INVALID configuration: {exc}", file=sys.stderr) + return 2 + print("\n".join(accepted_words_table(decisions))) + return 0 if not a.root.is_dir(): print(f"root is not a directory: {a.root}", file=sys.stderr) return 2 From e75c3b5fe3b2677963534122a24aa29d85986454 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 17:47:18 +0000 Subject: [PATCH 106/129] Keep the test suite out of the CI run's annotations and job summary Inside GitHub Actions the formatter tests printed three error annotations into the real run log (run 37661236604, quality/test), and checker tests could append to the real job summary. Test modules that exercise the Watchdog output now drop GITHUB_ACTIONS, GITHUB_STEP_SUMMARY and the WATCHDOG_ANNOTATION(S) variables for the module, and the emit test captures its stdout. A new test runs the whole suite as a child with GITHUB_ACTIONS=true and fails on any annotation line or summary write; with the isolation removed from one module it fails. Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- tests/test_check_agent_rules.py | 11 +++++++++ tests/test_check_decisions.py | 10 ++++++++ tests/test_check_public_extract.py | 11 +++++++++ tests/test_check_workflow_policy.py | 11 +++++++++ tests/test_org_scan.py | 10 ++++++++ tests/test_reusable_workflow.py | 10 ++++++++ tests/test_watchdog.py | 11 +++++++++ tests/test_watchdog_report.py | 38 ++++++++++++++++++++++++++++- 8 files changed, 111 insertions(+), 1 deletion(-) diff --git a/tests/test_check_agent_rules.py b/tests/test_check_agent_rules.py index 73e87d3..5f10fa0 100644 --- a/tests/test_check_agent_rules.py +++ b/tests/test_check_agent_rules.py @@ -1,9 +1,11 @@ """Tests for tools/check_agent_rules.py (shared-block drift check).""" import contextlib import io +import os import sys import tempfile import unittest +import unittest.mock from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -13,6 +15,15 @@ CANONICAL = ROOT / "agent-rules" / "SHARED_RULES.md" +def setUpModule(): + # Tests never write to the real GitHub Actions log or job summary of the CI run. + patcher = unittest.mock.patch.dict(os.environ) + patcher.start() + unittest.addModuleCleanup(patcher.stop) + for key in ("GITHUB_ACTIONS", "GITHUB_STEP_SUMMARY", "WATCHDOG_ANNOTATION", "WATCHDOG_ANNOTATIONS"): + os.environ.pop(key, None) + + class Drift(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() diff --git a/tests/test_check_decisions.py b/tests/test_check_decisions.py index a659de0..8ce31fd 100644 --- a/tests/test_check_decisions.py +++ b/tests/test_check_decisions.py @@ -6,6 +6,7 @@ """ import contextlib import io +import os import sys import tempfile import unittest @@ -28,6 +29,15 @@ def run(*args): return code, out.getvalue() +def setUpModule(): + # Tests never write to the real GitHub Actions log or job summary of the CI run. + patcher = unittest.mock.patch.dict(os.environ) + patcher.start() + unittest.addModuleCleanup(patcher.stop) + for key in ("GITHUB_ACTIONS", "GITHUB_STEP_SUMMARY", "WATCHDOG_ANNOTATION", "WATCHDOG_ANNOTATIONS"): + os.environ.pop(key, None) + + class FixtureMatrix(unittest.TestCase): """Matching value, contradicting value, unlisted file type, superseded citation.""" diff --git a/tests/test_check_public_extract.py b/tests/test_check_public_extract.py index d69dd7d..f93b724 100644 --- a/tests/test_check_public_extract.py +++ b/tests/test_check_public_extract.py @@ -5,9 +5,11 @@ """ import contextlib import io +import os import sys import tempfile import unittest +import unittest.mock from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -21,6 +23,15 @@ KEY = "AK" + "IA" + "ABCDEFGHIJKLMNOP" +def setUpModule(): + # Tests never write to the real GitHub Actions log or job summary of the CI run. + patcher = unittest.mock.patch.dict(os.environ) + patcher.start() + unittest.addModuleCleanup(patcher.stop) + for key in ("GITHUB_ACTIONS", "GITHUB_STEP_SUMMARY", "WATCHDOG_ANNOTATION", "WATCHDOG_ANNOTATIONS"): + os.environ.pop(key, None) + + class Rules(unittest.TestCase): def hits(self, text, rel="docs/page.md", allow=(), repository=None): with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/test_check_workflow_policy.py b/tests/test_check_workflow_policy.py index 9bf2403..38d70b7 100644 --- a/tests/test_check_workflow_policy.py +++ b/tests/test_check_workflow_policy.py @@ -1,9 +1,11 @@ """Tests for tools/check_workflow_policy.py.""" import contextlib import io +import os import sys import tempfile import unittest +import unittest.mock from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -11,6 +13,15 @@ import check_workflow_policy as policy # noqa: E402 +def setUpModule(): + # Tests never write to the real GitHub Actions log or job summary of the CI run. + patcher = unittest.mock.patch.dict(os.environ) + patcher.start() + unittest.addModuleCleanup(patcher.stop) + for key in ("GITHUB_ACTIONS", "GITHUB_STEP_SUMMARY", "WATCHDOG_ANNOTATION", "WATCHDOG_ANNOTATIONS"): + os.environ.pop(key, None) + + class WorkflowPolicy(unittest.TestCase): def write(self, root, path, text): target = Path(root, path) diff --git a/tests/test_org_scan.py b/tests/test_org_scan.py index 81b5a3a..ec3c52a 100644 --- a/tests/test_org_scan.py +++ b/tests/test_org_scan.py @@ -10,6 +10,7 @@ import subprocess import tempfile import unittest +import unittest.mock from pathlib import Path import yaml @@ -41,6 +42,15 @@ def workflow(): return data, data.get(True, data.get("on")) +def setUpModule(): + # Tests never write to the real GitHub Actions log or job summary of the CI run. + patcher = unittest.mock.patch.dict(os.environ) + patcher.start() + unittest.addModuleCleanup(patcher.stop) + for key in ("GITHUB_ACTIONS", "GITHUB_STEP_SUMMARY", "WATCHDOG_ANNOTATION", "WATCHDOG_ANNOTATIONS"): + os.environ.pop(key, None) + + class RepositoryList(unittest.TestCase): def test_lists_all_active_repositories_once(self): data = yaml.safe_load(LIST.read_text(encoding="utf-8")) diff --git a/tests/test_reusable_workflow.py b/tests/test_reusable_workflow.py index 44d920c..d79b537 100644 --- a/tests/test_reusable_workflow.py +++ b/tests/test_reusable_workflow.py @@ -8,6 +8,7 @@ import subprocess import tempfile import unittest +import unittest.mock from pathlib import Path import yaml @@ -34,6 +35,15 @@ def load(): """ +def setUpModule(): + # Tests never write to the real GitHub Actions log or job summary of the CI run. + patcher = unittest.mock.patch.dict(os.environ) + patcher.start() + unittest.addModuleCleanup(patcher.stop) + for key in ("GITHUB_ACTIONS", "GITHUB_STEP_SUMMARY", "WATCHDOG_ANNOTATION", "WATCHDOG_ANNOTATIONS"): + os.environ.pop(key, None) + + class HarnessModes(unittest.TestCase): def setUp(self): self.inputs, self.steps = load() diff --git a/tests/test_watchdog.py b/tests/test_watchdog.py index c5a29a4..aa528a2 100644 --- a/tests/test_watchdog.py +++ b/tests/test_watchdog.py @@ -2,10 +2,12 @@ import contextlib import io import json +import os import shutil import sys import tempfile import unittest +import unittest.mock from datetime import date from pathlib import Path @@ -23,6 +25,15 @@ def run(*args): return code, out.getvalue() +def setUpModule(): + # Tests never write to the real GitHub Actions log or job summary of the CI run. + patcher = unittest.mock.patch.dict(os.environ) + patcher.start() + unittest.addModuleCleanup(patcher.stop) + for key in ("GITHUB_ACTIONS", "GITHUB_STEP_SUMMARY", "WATCHDOG_ANNOTATION", "WATCHDOG_ANNOTATIONS"): + os.environ.pop(key, None) + + class Watchdog(unittest.TestCase): def make(self, tmp, agents=True): repo = Path(tmp, "openamr-platform-sw") diff --git a/tests/test_watchdog_report.py b/tests/test_watchdog_report.py index 24ff224..9d8393b 100644 --- a/tests/test_watchdog_report.py +++ b/tests/test_watchdog_report.py @@ -1,7 +1,12 @@ """Tests for tools/watchdog_report.py, the shared Watchdog output format.""" +import contextlib +import io +import os +import subprocess import sys import tempfile import unittest +import unittest.mock from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -18,6 +23,15 @@ def sample(): ] +def setUpModule(): + # Tests never write to the real GitHub Actions log or job summary of the CI run. + patcher = unittest.mock.patch.dict(os.environ) + patcher.start() + unittest.addModuleCleanup(patcher.stop) + for key in ("GITHUB_ACTIONS", "GITHUB_STEP_SUMMARY", "WATCHDOG_ANNOTATION", "WATCHDOG_ANNOTATIONS"): + os.environ.pop(key, None) + + class Render(unittest.TestCase): def test_grouped_blocks_and_closing_summary(self): lines = wr.render(sample(), "Decisions of record", "fix it.") @@ -67,11 +81,33 @@ def test_emit_only_inside_github_actions(self): self.assertEqual(wr.emit_github(sample(), "T", "n", env), []) env.pop("WATCHDOG_ANNOTATIONS") env["WATCHDOG_ANNOTATION"] = "error" - lines = wr.emit_github(sample(), "T", "n", env) + out = io.StringIO() + with contextlib.redirect_stdout(out): + lines = wr.emit_github(sample(), "T", "n", env) + self.assertEqual(out.getvalue().splitlines(), lines) self.assertEqual(len(lines), 3) self.assertTrue(lines[0].startswith("::error ")) self.assertIn("| A | 2 |", summary.read_text(encoding="utf-8")) +class TestIsolation(unittest.TestCase): + """Run inside GitHub Actions, the suite must not annotate the CI run or write its job summary.""" + + def test_suite_does_not_leak_into_the_ci_run(self): + if os.environ.get("WATCHDOG_ISOLATION_CHILD"): + return # this is the child run started below; the parent makes the assertions + with tempfile.TemporaryDirectory() as tmp: + summary = Path(tmp, "summary.md") + summary.write_text("", encoding="utf-8") + env = dict(os.environ, GITHUB_ACTIONS="true", GITHUB_STEP_SUMMARY=str(summary), + WATCHDOG_ISOLATION_CHILD="1") + proc = subprocess.run([sys.executable, "-m", "unittest", "discover", "-s", str(ROOT / "tests")], + cwd=ROOT, env=env, capture_output=True, text=True) + self.assertEqual(proc.returncode, 0, proc.stderr[-2000:]) + output = proc.stdout + proc.stderr + self.assertNotRegex(output, r"(?m)^::(?:error|warning|notice) ") + self.assertEqual(summary.read_text(encoding="utf-8"), "") + + if __name__ == "__main__": unittest.main() From 48519d58c7482540b94c586f8f1687d9587a3281 Mon Sep 17 00:00:00 2001 From: Alex Reznichenko Date: Wed, 7 Oct 2026 18:08:44 +0000 Subject: [PATCH 107/129] Watchdog: print each decision's guidance once, then its findings tools/watchdog_report.py now prints one block per decision (or rule): the label, ID and count, then Decision, Why (one line per distinct reason), Fix and More once, then every finding as file:line and the text found. Annotations and the JSON output keep full detail per finding. check_pr_evidence.py reads the grouped report and produces the same failure lines as before; a new test feeds it the real check_decisions.py output. Detection, exit codes and result lines are unchanged. Signed-off-by: Alex Reznichenko Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DeSLcD827exxSw3xL9zyiU --- tests/test_check_decisions.py | 13 ++++--- tests/test_check_pr_evidence.py | 23 ++++++++++--- tests/test_check_public_extract.py | 3 +- tests/test_check_workflow_policy.py | 3 +- tests/test_reusable_workflow.py | 4 +-- tests/test_watchdog.py | 16 ++++++--- tests/test_watchdog_report.py | 22 ++++++++++-- tools/check_pr_evidence.py | 30 +++++++++++----- tools/watchdog_report.py | 53 +++++++++++++++++++---------- 9 files changed, 121 insertions(+), 46 deletions(-) diff --git a/tests/test_check_decisions.py b/tests/test_check_decisions.py index 8ce31fd..e5ab32e 100644 --- a/tests/test_check_decisions.py +++ b/tests/test_check_decisions.py @@ -112,19 +112,24 @@ class CommandLine(unittest.TestCase): def test_exit_one_on_contradiction(self): code, out = run("--decisions", DECISIONS, "--root", REPO, "--repository", "platform-x") self.assertEqual(code, 1) - self.assertIn("Mismatch with approved decision: README.md:3: FIX-MAST found 'mast_1400'", out) + self.assertIn("Mismatch with approved decision: FIX-MAST (5 finding(s))", out) + self.assertIn("\n README.md:3: found 'mast_1400'\n", out) self.assertIn("result: 6 contradiction(s), 1 allowed", out) self.assertIn("textual consistency only", out) def test_finding_explains_decision_why_fix_and_links(self): code, out = run("--decisions", DECISIONS, "--root", REPO, "--repository", "platform-x") - block = out.split("Mismatch with approved decision: README.md:3:", 1)[1].split("\nMismatch", 1)[0] + block = out.split("Mismatch with approved decision: FIX-MAST (5 finding(s))", 1)[1].split("\nMismatch", 1)[0] self.assertIn("\n Decision: 1350 mm\n", block) - self.assertIn("\n Why: baseline is mast_1350.\n", block) + # Decision, Why, Fix and More once per group; one Why line per distinct reason. + self.assertEqual(block.count(" Decision:"), 1) + self.assertEqual(block.count(" Fix:"), 1) + self.assertIn("\n Why: baseline is mast_1350.\n superseded source still cited", block) self.assertIn("\n Fix: ", block) self.assertIn("FIX-MAST in decisions.yaml: https://github.com/openAMRobot/.github/blob/main/decisions.yaml#L", block) self.assertIn("WATCHDOG.md#decisions-of-record", block) - self.assertIn("== FIX-MAST: 5 finding(s) ==", out) + self.assertIn("\n Found:\n README.md:3: found 'mast_1400'\n docs/citation.md:3: found 'FIX-DOC rev 1 item 1'\n", block) + self.assertEqual(block.count(": found "), 5) self.assertIn("Decisions of record summary: 6 finding(s) (FIX-MAST 5, FIX-IMU 1)", out) self.assertIn("Next step: ", out) self.assertNotIn("CONTRADICTION ", out) diff --git a/tests/test_check_pr_evidence.py b/tests/test_check_pr_evidence.py index ff4bdc3..a5d6e40 100644 --- a/tests/test_check_pr_evidence.py +++ b/tests/test_check_pr_evidence.py @@ -211,13 +211,26 @@ def test_ai_markers_in_commit_messages_are_checked(self): class DecisionReport(unittest.TestCase): def test_contradictions_become_failures(self): report = ("decisions: 3 loaded\n" - "Mismatch with approved decision: docs/a.md:4: MAST-INSTALL-HEIGHT found 'mast_1400'\n Decision: x\n" + "Mismatch with approved decision: MAST-INSTALL-HEIGHT (1 finding(s))\n Decision: x\n" + " Why: y\n Found:\n docs/a.md:4: found 'mast_1400'\n\n" "ALLOWED docs/h.md:2: MAST-INSTALL-HEIGHT found 'mast_1400'; reason: history\n") self.assertEqual(ev.decision_failures(report), [ "Decision contradiction: docs/a.md:4: MAST-INSTALL-HEIGHT found 'mast_1400'; decision: x"]) + def test_reads_the_real_grouped_checker_output(self): + fixtures = ROOT / "tests" / "fixtures" + proc = subprocess.run([sys.executable, str(ROOT / "tools" / "check_decisions.py"), + "--decisions", str(fixtures / "decisions.yaml"), + "--root", str(fixtures / "decisions_repo"), "--repository", "platform-x"], + capture_output=True, text=True, env={**os.environ, "GITHUB_ACTIONS": ""}) + failures = ev.decision_failures(proc.stdout) + self.assertEqual(len(failures), 6) + self.assertIn("Decision contradiction: README.md:3: FIX-MAST found 'mast_1400'; decision: 1350 mm", failures) + self.assertEqual(ev.decision_verdict(proc.stdout, {"exit_code": proc.returncode}), (failures, [])) + def test_long_reports_are_truncated(self): - report = "\n".join(f"Mismatch with approved decision: f.md:{i}: X found 'a'" for i in range(25)) + report = "Mismatch with approved decision: X (25 finding(s))\n Found:\n" + "\n".join( + f" f.md:{i}: found 'a'" for i in range(25)) failures = ev.decision_failures(report) self.assertEqual(len(failures), 21) self.assertEqual(failures[-1], "... and 5 more decision contradictions") @@ -279,8 +292,10 @@ def test_wiring_test_names_its_tracking_issue_when_jq_is_missing(self): CLEAN = "decisions: 23 loaded\nresult: 0 contradiction(s), 0 allowed, scope 3 changed file(s)\n" TWO = ("decisions: 23 loaded\n" - "Mismatch with approved decision: docs/a.md:4: MAST-INSTALL-HEIGHT found 'mast_1400'\n Decision: x\n" - "Mismatch with approved decision: docs/b.md:9: COMPUTE found 'Raspberry Pi 5'\n Decision: Jetson\n" + "Mismatch with approved decision: MAST-INSTALL-HEIGHT (1 finding(s))\n Decision: x\n Found:\n" + " docs/a.md:4: found 'mast_1400'\n\n" + "Mismatch with approved decision: COMPUTE (1 finding(s))\n Decision: Jetson\n Found:\n" + " docs/b.md:9: found 'Raspberry Pi 5'\n\n" "result: 2 contradiction(s), 0 allowed, scope 2 changed file(s)\n") diff --git a/tests/test_check_public_extract.py b/tests/test_check_public_extract.py index f93b724..5ef366b 100644 --- a/tests/test_check_public_extract.py +++ b/tests/test_check_public_extract.py @@ -135,7 +135,8 @@ def test_exit_codes_and_changed_files(self): Path(tmp, "docs/b.md").write_text("clean\n", encoding="utf-8") code, out = self.run_main("--root", tmp) self.assertEqual(code, 1) - self.assertIn("Should not be public: docs/a.md:1: google-drive-link found", out) + self.assertIn("Should not be public: google-drive-link (1 finding(s))", out) + self.assertIn("\n docs/a.md:1: found 'https://drive.google.com", out) self.assertIn("\n Rule: Public files do not link to internal Google Drive", out) self.assertIn("\n Fix: Remove the link", out) self.assertIn("WATCHDOG.md#public-extract", out) diff --git a/tests/test_check_workflow_policy.py b/tests/test_check_workflow_policy.py index 38d70b7..fd20b23 100644 --- a/tests/test_check_workflow_policy.py +++ b/tests/test_check_workflow_policy.py @@ -66,7 +66,8 @@ def test_command_line_explains_each_finding(self): code = policy.main(["--root", tmp]) text = out.getvalue() self.assertEqual(code, 1) - self.assertIn("Workflow not pinned: .github/workflows/bad.yml:4: unpinned-action found", text) + self.assertIn("Workflow not pinned: unpinned-action (1 finding(s))", text) + self.assertIn("\n .github/workflows/bad.yml:4: found ", text) self.assertIn("\n Rule: ", text) self.assertIn("\n Fix: ", text) self.assertIn("Workflow policy summary: 1 finding(s) (unpinned-action 1)", text) diff --git a/tests/test_reusable_workflow.py b/tests/test_reusable_workflow.py index d79b537..33b8931 100644 --- a/tests/test_reusable_workflow.py +++ b/tests/test_reusable_workflow.py @@ -55,8 +55,8 @@ def run_step(self, name, code, enforce, event="pull_request", tool="check_decisi tools.mkdir(parents=True) for t in ("check_decisions.py", "check_public_extract.py", "check_agent_rules.py", "check_workflow_policy.py"): - line = ("Mismatch with approved decision: a.md:1: X found 'a'" if t == "check_decisions.py" - else "Should not be public: a.md:1: price found '5'") + line = ("Mismatch with approved decision: X (1 finding(s))" if t == "check_decisions.py" + else "Should not be public: price (1 finding(s))") (tools / t).write_text(FAKE.format(line=line, code=code if t == tool else 0), encoding="utf-8") if agents: Path(tmp, "AGENTS.md").write_text("x\n", encoding="utf-8") diff --git a/tests/test_watchdog.py b/tests/test_watchdog.py index aa528a2..e3e4099 100644 --- a/tests/test_watchdog.py +++ b/tests/test_watchdog.py @@ -54,10 +54,14 @@ def test_all_checks_run_and_summary_groups_by_decision(self): code, out = run("--root", repo) self.assertEqual(code, 1) self.assertIn("OpenAMRobot Watchdog: openamr-platform-sw", out) - self.assertIn("Mismatch with approved decision: docs/a.md:1: COMPUTE found 'Raspberry Pi 5'", out) - self.assertIn("Should not be public: README.md:1: google-drive-link found", out) - self.assertIn("Shared agent rules out of date: AGENTS.md:1:", out) - self.assertIn("Workflow not pinned: .github/workflows/ci.yml:4:", out) + self.assertIn("Mismatch with approved decision: COMPUTE (1 finding(s))", out) + self.assertIn("\n docs/a.md:1: found 'Raspberry Pi 5'\n", out) + self.assertIn("Should not be public: google-drive-link (1 finding(s))", out) + self.assertIn("\n README.md:1: found 'https://drive.google.com", out) + self.assertIn("Shared agent rules out of date: shared-rules (1 finding(s))", out) + self.assertIn("\n AGENTS.md:1: found ", out) + self.assertIn("Workflow not pinned: unpinned-action (1 finding(s))", out) + self.assertIn("\n .github/workflows/ci.yml:4: found ", out) self.assertIn("Total: 4 finding(s) (COMPUTE 1, google-drive-link 1, shared-rules 1, unpinned-action 1)", out) self.assertIn("WATCHDOG.md", out) @@ -107,6 +111,10 @@ def test_json_and_markdown_outputs(self): data = json.loads(js.read_text(encoding="utf-8")) self.assertEqual(data["repository"], "x") self.assertEqual(len(data["results"]["decisions"]), 1) + finding = data["results"]["decisions"][0] + # JSON keeps full detail per finding, unlike the compact text report. + self.assertEqual((finding["file"], finding["line"], finding["found"]), ("docs/a.md", 1, "Raspberry Pi 5")) + self.assertTrue(finding["decision"] and finding["why"] and finding["fix"] and finding["links"]) def test_freshness_reports_past_review_dates(self): self.assertEqual(watchdog.freshness(ROOT / "decisions.yaml", date(2000, 1, 1)), []) diff --git a/tests/test_watchdog_report.py b/tests/test_watchdog_report.py index 9d8393b..43ed444 100644 --- a/tests/test_watchdog_report.py +++ b/tests/test_watchdog_report.py @@ -36,13 +36,29 @@ class Render(unittest.TestCase): def test_grouped_blocks_and_closing_summary(self): lines = wr.render(sample(), "Decisions of record", "fix it.") text = "\n".join(lines) - self.assertLess(text.index("== A: 2 finding(s) =="), text.index("== B: 1 finding(s) ==")) - self.assertIn("Mismatch with approved decision: docs/x.md:4: A found 'old'\n" + self.assertLess(text.index("Mismatch with approved decision: A (2 finding(s))"), + text.index("Mismatch with approved decision: B (1 finding(s))")) + self.assertIn("Mismatch with approved decision: A (2 finding(s))\n" " Decision: A is new.\n Why: Why A.\n Fix: Use new.\n" - f" More: WATCHDOG.md: {wr.DOCS}", text) + f" More: WATCHDOG.md: {wr.DOCS}\n" + " Found:\n docs/x.md:4: found 'old'\n docs/y.md:9: found 'old'\n", text) + # The group's guidance is printed once, not per finding. + self.assertEqual(text.count(" Decision: A is new."), 1) self.assertEqual(lines[-2], "Decisions of record summary: 3 finding(s) (A 2, B 1)") self.assertEqual(lines[-1], "Next step: fix it.") + def test_distinct_reasons_in_one_group_are_each_printed_once(self): + items = sample()[:1] + [dict(sample()[2], why="Other reason."), dict(sample()[2], file="z.md", why="Why A.")] + text = "\n".join(wr.render(items, "T", "n")) + self.assertIn(" Why: Why A.\n Other reason.\n Fix:", text) + self.assertEqual(text.count("Why A."), 1) + + def test_annotations_keep_full_detail_per_finding(self): + notes = wr.annotations(sample()) + self.assertEqual(len(notes), 3) + self.assertTrue(all("Fix: " in n and wr.DOCS in n for n in notes)) + self.assertIn("file=docs/y.md,line=9,", notes[2]) + def test_clean_run_says_nothing_to_fix(self): self.assertEqual(wr.render([], "Public extract", "x"), ["Public extract summary: 0 finding(s)", "Next step: nothing to fix."]) diff --git a/tools/check_pr_evidence.py b/tools/check_pr_evidence.py index 7f01896..ffa53e7 100644 --- a/tools/check_pr_evidence.py +++ b/tools/check_pr_evidence.py @@ -190,19 +190,31 @@ def evaluate(pr, changed, maintainers, reviews=(), has_state=False, commit_messa # First line of each check_decisions.py finding (tools/watchdog_report.py format). DECISION_FINDING = "Mismatch with approved decision: " +DECISION_GROUP = re.compile(r"^Mismatch with approved decision: (?P\S+) \(\d+ finding\(s\)\)$") +DECISION_ITEM = re.compile(r"^ (?P\S.*?:\d+): found (?P.*)$") def decision_findings(report): - """Finding headers, each with its one-line Decision when the report gives one.""" - lines = report.splitlines() + """One line per finding from the grouped Watchdog report: place, ID, found text and the + group's one-line Decision ("docs/a.md:4: ID found 'x'; decision: ...").""" out = [] - for i, line in enumerate(lines): - if line.startswith(DECISION_FINDING): - text = line[len(DECISION_FINDING):] - following = lines[i + 1].strip() if i + 1 < len(lines) else "" - if following.startswith("Decision:"): - text += f"; decision: {following[len('Decision:'):].strip()}" - out.append(text) + group = decision = None + for line in report.splitlines(): + m = DECISION_GROUP.match(line) + if m: + group, decision = m.group("id"), None + continue + if group is None: + continue + if line.startswith(" Decision:"): + decision = line[len(" Decision:"):].strip() + continue + item = DECISION_ITEM.match(line) + if item: + text = f"{item.group('place')}: {group} found {item.group('found')}" + out.append(text + (f"; decision: {decision}" if decision else "")) + elif not line.startswith(" "): + group = None return out diff --git a/tools/watchdog_report.py b/tools/watchdog_report.py index d0c86e8..8a1c566 100644 --- a/tools/watchdog_report.py +++ b/tools/watchdog_report.py @@ -1,18 +1,22 @@ """Shared, contributor-friendly output for the OpenAMRobot Watchdog checks. -Every check reports findings in the same shape, so a contributor reads one format: +Every check reports findings in the same compact shape, one block per decision (or rule): -