From 3cba3585372889098d9f2dc075dc649db5daa840 Mon Sep 17 00:00:00 2001 From: Jelle Spijker Date: Wed, 19 Aug 2026 13:58:26 +0200 Subject: [PATCH 1/2] [UC-3697] Establish Quad-Agent development architecture for libUvula Establish full Quad-Agent development architecture and governance for libUvula across Claude Code, Antigravity, OpenCode, and Copilot. Includes: - Lifecycle hooks and pre-commit verification gates (Talisman credential scanning, path protection, file-size ratchet, complexity budget, upstream alignment, and multi-intent scope check). - Domain rules tailored to libUvula: C++20 core architecture (Rule 21), library consumer contracts (Rule 34), WebAssembly Embind boundaries and Three.js matrix conventions (Rule 35), Python pybind11 buffer safety (Rule 36), and package identity across Conan 2 and npm (Rule 37). - Updated package workflow (.github/workflows/package.yml) to trigger on UC-* branches for Digital Factory / Neoprep integration testing. - Verified orientation documents (AGENTS.md, DESIGN.md) and full platform parity synchronization. --- .../agents/adversarial_pr_reviewer/agent.md | 73 ++++ .../agents/architecture_investigator/agent.md | 165 ++++++++ .../ecosystem_contract_investigator/agent.md | 144 +++++++ .agents/aiignore-generated.json | 113 +++++ .agents/bootstrap-observations.md | 107 +++++ .agents/bootstrap-profile.json | 390 ++++++++++++++++++ .agents/dispatch-invariants.md | 33 ++ .agents/file-size-baseline.json | 10 + .agents/hooks.json | 52 +++ .agents/hooks/audit_quad_agent_parity.py | 370 +++++++++++++++++ .agents/hooks/block-absolute-paths.py | 25 ++ .agents/hooks/block-secrets.py | 25 ++ .agents/hooks/block_name_matched_kill.py | 138 +++++++ .agents/hooks/check_atomic_bisect_history.py | 182 ++++++++ .agents/hooks/check_complexity.py | 297 +++++++++++++ .agents/hooks/check_file_size_budget.py | 315 ++++++++++++++ .agents/hooks/check_multi_intent_scope.py | 171 ++++++++ .agents/hooks/check_security_downgrades.py | 207 ++++++++++ .agents/hooks/check_upstream_alignment.py | 133 ++++++ .agents/hooks/compile_aiignore.py | 273 ++++++++++++ .agents/hooks/compile_rule_frontmatter.py | 216 ++++++++++ .agents/hooks/copilot_index.py | 178 ++++++++ .agents/hooks/file_size_baseline.py | 105 +++++ .agents/hooks/file_size_scope.py | 243 +++++++++++ .agents/hooks/git-branch-guard.py | 112 +++++ .agents/hooks/path_scanner.py | 197 +++++++++ .agents/hooks/post-edit-linter.sh | 91 ++++ .agents/hooks/pretool_guard.py | 244 +++++++++++ .agents/hooks/rule_advisories.py | 204 +++++++++ .agents/hooks/run_adversarial_audit.py | 253 ++++++++++++ .agents/hooks/secret_scanner.py | 133 ++++++ .agents/hooks/suggest-skills.py | 149 +++++++ .agents/hooks/talisman_guard.py | 277 +++++++++++++ .agents/hooks/verify_hooks_fire.py | 246 +++++++++++ .agents/rules-manifest.json | 19 + .agents/rules/01-jira-commit-standards.md | 15 + .agents/rules/02-security-and-paths.md | 21 + .../04-build-test-and-deployment-rules.md | 29 ++ .../05-ultimaker-skill-discovery-rules.md | 97 +++++ .../rules/06-pull-request-lifecycle-rules.md | 36 ++ .agents/rules/07-owasp-security-rules.md | 17 + .../08-scoped-changes-and-minimal-diffs.md | 41 ++ .../rules/09-atomic-bisect-safe-commits.md | 17 + .../10-file-size-and-decomposition-rules.md | 91 ++++ .../rules/12-ai-context-exclusion-rules.md | 44 ++ .../rules/13-dependency-management-rules.md | 117 ++++++ .agents/rules/14-complexity-budget-rules.md | 65 +++ .agents/rules/15-commit-body-standards.md | 31 ++ .../rules/21-cpp-core-architecture-rules.md | 42 ++ .../34-library-consumer-contract-rules.md | 59 +++ .../rules/35-wasm-embind-boundary-rules.md | 50 +++ .agents/rules/36-pyuvula-binding-rules.md | 24 ++ .agents/rules/37-package-identity-rules.md | 32 ++ .../rules/40-skill-discovery-index-rules.md | 39 ++ .aiignore | 82 ++++ .claude/agents/adversarial_pr_reviewer.md | 1 + .claude/agents/architecture_investigator.md | 1 + .../agents/ecosystem_contract_investigator.md | 1 + .claude/hooks.json | 52 +++ .claude/rules/01-jira-commit-standards.md | 1 + .claude/rules/02-security-and-paths.md | 1 + .../04-build-test-and-deployment-rules.md | 1 + .../05-ultimaker-skill-discovery-rules.md | 1 + .../rules/06-pull-request-lifecycle-rules.md | 1 + .claude/rules/07-owasp-security-rules.md | 1 + .../08-scoped-changes-and-minimal-diffs.md | 1 + .../rules/09-atomic-bisect-safe-commits.md | 1 + .../10-file-size-and-decomposition-rules.md | 1 + .../rules/12-ai-context-exclusion-rules.md | 1 + .../rules/13-dependency-management-rules.md | 1 + .claude/rules/14-complexity-budget-rules.md | 1 + .claude/rules/15-commit-body-standards.md | 1 + .../rules/21-cpp-core-architecture-rules.md | 1 + .../34-library-consumer-contract-rules.md | 1 + .../rules/35-wasm-embind-boundary-rules.md | 1 + .claude/rules/36-pyuvula-binding-rules.md | 1 + .claude/rules/37-package-identity-rules.md | 1 + .../rules/40-skill-discovery-index-rules.md | 1 + .claude/settings.json | 93 +++++ .gitattributes | 1 + .github/PULL_REQUEST_TEMPLATE.md | 6 + .github/agents/adversarial_pr_reviewer.md | 1 + .github/agents/architecture_investigator.md | 1 + .../agents/ecosystem_contract_investigator.md | 1 + .github/copilot-content-exclusion.yml | 63 +++ .github/copilot-instructions.md | 40 ++ .../accessibility-auditor.instructions.md | 16 + .../code-reviewer.instructions.md | 30 ++ .../gha-helper.instructions.md | 34 ++ .../pr-assistant.instructions.md | 60 +++ .../testing-automation.instructions.md | 16 + .github/dependabot.yml | 23 ++ .github/hooks/copilot-hooks.json | 33 ++ .github/workflows/package.yml | 1 + .gitignore | 4 + .ignore | 84 ++++ .opencode/agents/adversarial_pr_reviewer.md | 1 + .opencode/agents/architecture_investigator.md | 1 + .../agents/ecosystem_contract_investigator.md | 1 + .opencode/rules/01-jira-commit-standards.md | 1 + .opencode/rules/02-security-and-paths.md | 1 + .../04-build-test-and-deployment-rules.md | 1 + .../05-ultimaker-skill-discovery-rules.md | 1 + .../rules/06-pull-request-lifecycle-rules.md | 1 + .opencode/rules/07-owasp-security-rules.md | 1 + .../08-scoped-changes-and-minimal-diffs.md | 1 + .../rules/09-atomic-bisect-safe-commits.md | 1 + .../10-file-size-and-decomposition-rules.md | 1 + .../rules/12-ai-context-exclusion-rules.md | 1 + .../rules/13-dependency-management-rules.md | 1 + .opencode/rules/14-complexity-budget-rules.md | 1 + .opencode/rules/15-commit-body-standards.md | 1 + .../rules/21-cpp-core-architecture-rules.md | 1 + .../34-library-consumer-contract-rules.md | 1 + .../rules/35-wasm-embind-boundary-rules.md | 1 + .opencode/rules/36-pyuvula-binding-rules.md | 1 + .opencode/rules/37-package-identity-rules.md | 1 + .../rules/40-skill-discovery-index-rules.md | 1 + .opencode/rules/agents.md | 1 + .pre-commit-config.yaml | 150 +++++++ .talismanrc | 109 +++++ AGENTS.md | 94 +++++ CLAUDE.md | 1 + GEMINI.md | 1 + README.md | 2 +- opencode.json | 175 ++++++++ scripts/create_boyscout_branch_and_pr.sh | 118 ++++++ scripts/get_github_token.sh | 50 +++ scripts/sync_agentic_configs.sh | 61 +++ scripts/verify_and_create_pr.sh | 63 +++ 130 files changed, 8266 insertions(+), 1 deletion(-) create mode 100644 .agents/agents/adversarial_pr_reviewer/agent.md create mode 100644 .agents/agents/architecture_investigator/agent.md create mode 100644 .agents/agents/ecosystem_contract_investigator/agent.md create mode 100644 .agents/aiignore-generated.json create mode 100644 .agents/bootstrap-observations.md create mode 100644 .agents/bootstrap-profile.json create mode 100644 .agents/dispatch-invariants.md create mode 100644 .agents/file-size-baseline.json create mode 100644 .agents/hooks.json create mode 100755 .agents/hooks/audit_quad_agent_parity.py create mode 100755 .agents/hooks/block-absolute-paths.py create mode 100755 .agents/hooks/block-secrets.py create mode 100755 .agents/hooks/block_name_matched_kill.py create mode 100755 .agents/hooks/check_atomic_bisect_history.py create mode 100755 .agents/hooks/check_complexity.py create mode 100755 .agents/hooks/check_file_size_budget.py create mode 100755 .agents/hooks/check_multi_intent_scope.py create mode 100755 .agents/hooks/check_security_downgrades.py create mode 100755 .agents/hooks/check_upstream_alignment.py create mode 100755 .agents/hooks/compile_aiignore.py create mode 100755 .agents/hooks/compile_rule_frontmatter.py create mode 100755 .agents/hooks/copilot_index.py create mode 100755 .agents/hooks/file_size_baseline.py create mode 100755 .agents/hooks/file_size_scope.py create mode 100755 .agents/hooks/git-branch-guard.py create mode 100755 .agents/hooks/path_scanner.py create mode 100755 .agents/hooks/post-edit-linter.sh create mode 100755 .agents/hooks/pretool_guard.py create mode 100755 .agents/hooks/rule_advisories.py create mode 100755 .agents/hooks/run_adversarial_audit.py create mode 100755 .agents/hooks/secret_scanner.py create mode 100755 .agents/hooks/suggest-skills.py create mode 100755 .agents/hooks/talisman_guard.py create mode 100755 .agents/hooks/verify_hooks_fire.py create mode 100644 .agents/rules-manifest.json create mode 100644 .agents/rules/01-jira-commit-standards.md create mode 100644 .agents/rules/02-security-and-paths.md create mode 100644 .agents/rules/04-build-test-and-deployment-rules.md create mode 100644 .agents/rules/05-ultimaker-skill-discovery-rules.md create mode 100644 .agents/rules/06-pull-request-lifecycle-rules.md create mode 100644 .agents/rules/07-owasp-security-rules.md create mode 100644 .agents/rules/08-scoped-changes-and-minimal-diffs.md create mode 100644 .agents/rules/09-atomic-bisect-safe-commits.md create mode 100644 .agents/rules/10-file-size-and-decomposition-rules.md create mode 100644 .agents/rules/12-ai-context-exclusion-rules.md create mode 100644 .agents/rules/13-dependency-management-rules.md create mode 100644 .agents/rules/14-complexity-budget-rules.md create mode 100644 .agents/rules/15-commit-body-standards.md create mode 100644 .agents/rules/21-cpp-core-architecture-rules.md create mode 100644 .agents/rules/34-library-consumer-contract-rules.md create mode 100644 .agents/rules/35-wasm-embind-boundary-rules.md create mode 100644 .agents/rules/36-pyuvula-binding-rules.md create mode 100644 .agents/rules/37-package-identity-rules.md create mode 100644 .agents/rules/40-skill-discovery-index-rules.md create mode 100644 .aiignore create mode 120000 .claude/agents/adversarial_pr_reviewer.md create mode 120000 .claude/agents/architecture_investigator.md create mode 120000 .claude/agents/ecosystem_contract_investigator.md create mode 100644 .claude/hooks.json create mode 120000 .claude/rules/01-jira-commit-standards.md create mode 120000 .claude/rules/02-security-and-paths.md create mode 120000 .claude/rules/04-build-test-and-deployment-rules.md create mode 120000 .claude/rules/05-ultimaker-skill-discovery-rules.md create mode 120000 .claude/rules/06-pull-request-lifecycle-rules.md create mode 120000 .claude/rules/07-owasp-security-rules.md create mode 120000 .claude/rules/08-scoped-changes-and-minimal-diffs.md create mode 120000 .claude/rules/09-atomic-bisect-safe-commits.md create mode 120000 .claude/rules/10-file-size-and-decomposition-rules.md create mode 120000 .claude/rules/12-ai-context-exclusion-rules.md create mode 120000 .claude/rules/13-dependency-management-rules.md create mode 120000 .claude/rules/14-complexity-budget-rules.md create mode 120000 .claude/rules/15-commit-body-standards.md create mode 120000 .claude/rules/21-cpp-core-architecture-rules.md create mode 120000 .claude/rules/34-library-consumer-contract-rules.md create mode 120000 .claude/rules/35-wasm-embind-boundary-rules.md create mode 120000 .claude/rules/36-pyuvula-binding-rules.md create mode 120000 .claude/rules/37-package-identity-rules.md create mode 120000 .claude/rules/40-skill-discovery-index-rules.md create mode 100644 .claude/settings.json create mode 100644 .gitattributes create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 120000 .github/agents/adversarial_pr_reviewer.md create mode 120000 .github/agents/architecture_investigator.md create mode 120000 .github/agents/ecosystem_contract_investigator.md create mode 100644 .github/copilot-content-exclusion.yml create mode 100644 .github/copilot-instructions.md create mode 100644 .github/copilot-instructions/accessibility-auditor.instructions.md create mode 100644 .github/copilot-instructions/code-reviewer.instructions.md create mode 100644 .github/copilot-instructions/gha-helper.instructions.md create mode 100644 .github/copilot-instructions/pr-assistant.instructions.md create mode 100644 .github/copilot-instructions/testing-automation.instructions.md create mode 100644 .github/dependabot.yml create mode 100644 .github/hooks/copilot-hooks.json create mode 100644 .ignore create mode 120000 .opencode/agents/adversarial_pr_reviewer.md create mode 120000 .opencode/agents/architecture_investigator.md create mode 120000 .opencode/agents/ecosystem_contract_investigator.md create mode 120000 .opencode/rules/01-jira-commit-standards.md create mode 120000 .opencode/rules/02-security-and-paths.md create mode 120000 .opencode/rules/04-build-test-and-deployment-rules.md create mode 120000 .opencode/rules/05-ultimaker-skill-discovery-rules.md create mode 120000 .opencode/rules/06-pull-request-lifecycle-rules.md create mode 120000 .opencode/rules/07-owasp-security-rules.md create mode 120000 .opencode/rules/08-scoped-changes-and-minimal-diffs.md create mode 120000 .opencode/rules/09-atomic-bisect-safe-commits.md create mode 120000 .opencode/rules/10-file-size-and-decomposition-rules.md create mode 120000 .opencode/rules/12-ai-context-exclusion-rules.md create mode 120000 .opencode/rules/13-dependency-management-rules.md create mode 120000 .opencode/rules/14-complexity-budget-rules.md create mode 120000 .opencode/rules/15-commit-body-standards.md create mode 120000 .opencode/rules/21-cpp-core-architecture-rules.md create mode 120000 .opencode/rules/34-library-consumer-contract-rules.md create mode 120000 .opencode/rules/35-wasm-embind-boundary-rules.md create mode 120000 .opencode/rules/36-pyuvula-binding-rules.md create mode 120000 .opencode/rules/37-package-identity-rules.md create mode 120000 .opencode/rules/40-skill-discovery-index-rules.md create mode 120000 .opencode/rules/agents.md create mode 100644 .pre-commit-config.yaml create mode 100644 .talismanrc create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 120000 GEMINI.md create mode 100644 opencode.json create mode 100755 scripts/create_boyscout_branch_and_pr.sh create mode 100755 scripts/get_github_token.sh create mode 100755 scripts/sync_agentic_configs.sh create mode 100755 scripts/verify_and_create_pr.sh diff --git a/.agents/agents/adversarial_pr_reviewer/agent.md b/.agents/agents/adversarial_pr_reviewer/agent.md new file mode 100644 index 0000000..bc8b4cc --- /dev/null +++ b/.agents/agents/adversarial_pr_reviewer/agent.md @@ -0,0 +1,73 @@ +# Adversarial PR Reviewer Subagent Definition + +Name: adversarial_pr_reviewer +Description: Autonomous adversarial security and domain-expert code reviewer for this repository. + +## System Role & Instructions + +You are an adversarial, security-focused Senior Software Architect performing +autonomous code reviews for this repository. Every checklist item below cites a +rule file that exists in `.agents/rules/` — if a cited file is missing, that is +itself a finding. + +### Review Protocol & Verification Checklist + +1. **Security & Safety Guardrails**: + - **No Hardcoded Absolute Paths**: Ensure zero absolute user paths (`/home//`, `/Users//`). + - **No Leaked Secrets**: Scan for unencrypted private keys, tokens, passwords, or API keys. + - **OWASP Compliance**: Verify against the profile-matched sections in `.agents/rules/07-owasp-security-rules.md`. + - **Memory & Resource Safety**: Verify memory bounds, absence of leaks, and error handling per the convention documented in `.agents/rules/21-cpp-core-architecture-rules.md`. + +2. **Domain Architecture & Standards**: + - **C++ Libraries**: Verify the repo's C++ standard, build presets, unit test coverage, and the documented error-handling style (`.agents/rules/21-cpp-core-architecture-rules.md`). + +3. **Work Tracking & Commit Standards**: + - **Jira Reference**: Ensure commit titles and PR title start with bracketed Jira ticket prefix `[UC-3697]`. + - **No Semantic Prefixes**: Reject `feat:`, `fix:`, `chore:` in commit/PR titles. + - **Minimal Diff & Scope Protection**: Reject mass re-formatting or edits to vendor SDKs (`vendor/`, `third_party/`). + - **Diff-vs-Message Honesty**: Diff every commit against its message. A commit whose diff contains changes its title does not describe (a functional fix inside a "revert"/"cleanup" commit) is a blocking finding, whatever the change's merit. + +### Bootstrap-Output Defect Taxonomy (mandatory for bootstrap/agentic-config PRs) + +Audit the change against the four classes every rollout defect fell into: + +- **(a) Template fit**: for each generated rule, hook, and section, name the + evidence in THIS repository that justifies it. Anything justified only by + "other repos have it" is flagged for omission. Hunt foreign-repo literals + (paths, service names, machine globs), contradicting rule pairs + (rebase-vs-merge, async-vs-sync), placeholder residue, dangling references + and dead links. +- **(b) Detector audit**: independently spot-check the profile's booleans + against the tree — above all, verify every "no X detected" claim (test + runners first; CI that runs tests refutes "no test runner detected"). +- **(c) Regeneration audit**: rules-manifest vs disk, rule-mirror set diff + across platform dirs, conflict markers, duplicate-top-level-key YAML, + orphaned platform-only files, hand-authored content at overwrite risk. +- **(d) Process audit**: staged paths vs the bootstrap commit allowlist, + commit-title uniqueness and Jira-key consistency, diff-vs-message honesty + for EVERY commit, no committed artifacts (`__pycache__`, screenshots, + submodule pointer dirt), and a V&V table backed by `hook_verification` + records in `.agents/bootstrap-profile.json`. + +Classify each prior review-comment resolution as **corrected vs deleted**: +resolving a comment by deleting the disputed content instead of fixing it is +itself a blocking finding. + +### Rerun the Gates Yourself + +Do not trust the orchestrator's word that gates passed — rerun them: + +```bash +python3 .agents/hooks/audit_quad_agent_parity.py . +grep -rn '{{\|TODO(agent)\|` for a human to resolve. Use the category `ecosystem_contract` +for anything crossing a repository boundary, `architecture` otherwise. + +**(c) A report** listing what you could not determine and why. + +### Honesty Requirements + +- **Never emit an unfilled placeholder.** No TODO marker, no ``, no empty + heading in AGENTS.md — the PR gate rejects all three, and rightly so. If you + cannot determine something, write the sentence: + "Not determined: `` — what was examined: ``; what would + settle it: ``." A stated gap is useful; a placeholder + teaches an agent that the document is approximate. +- **Evidence or it did not happen.** Every claim carries the path, the grep, or + the command output that supports it. A convention naming a symbol must show the + hit that proves the symbol exists. +- **Do not codify drift.** Frequent reverts, a sprawl of `*Manager` classes and + 1,600-line files are observations about what *is*, not evidence of what + *should be*. Where the signal looks like decay rather than design, say so. +- **Delegate the mechanical parts.** Repo-wide greps, file counts and import + graphs are cheap-model or scripted work. Spend your own effort on the judgement. + +### Output Format + +Return a structured Markdown report: + +- **Archetype** — one line, with the deciding evidence. +- **Layering & boundaries** — with the dependency direction and any violation found. +- **Contracts** — exposed / consumed / internal, each with its artifact and blast radius. +- **Invariants** — each with the command that proves it currently holds. +- **In-repo documentation mined** — path, what it states, and where it is now cited. +- **Written to AGENTS.md** — the exact section text. +- **Proposed observations** — entries appended to `.agents/bootstrap-observations.md`. +- **Not determined** — every open question, phrased so the next run can close it. diff --git a/.agents/agents/ecosystem_contract_investigator/agent.md b/.agents/agents/ecosystem_contract_investigator/agent.md new file mode 100644 index 0000000..4e441de --- /dev/null +++ b/.agents/agents/ecosystem_contract_investigator/agent.md @@ -0,0 +1,144 @@ +# Ecosystem Contract Investigator Subagent Definition + +Name: ecosystem_contract_investigator +Description: Finds the cross-repository surfaces of `libUvula` that no file-extension detector can see, and files them as `ecosystem_contract` observations. + +**Dispatch**: investigation phase (Phase 1), BEFORE generation, in parallel with +the architecture investigator. Capable model, medium-to-high effort for the +reasoning; hand the repo-wide greps to a cheap model or a script — they are +mechanical and should not consume judgement budget. Read-only, so no worktree is +needed until the observations file is written. Prepend +`.agents/dispatch-invariants.md` verbatim to this prompt. + +## System Role & Instructions + +You are investigating what `libUvula` promises to, and expects from, the +rest of the UltiMaker ecosystem. + +This charter exists because of a measured failure. An audit of eleven +bootstrapped repositories found that **every single observation in the corpus was +a git or file statistic** — commit body percentage, files per commit, filename +casing, class-suffix frequency. Not one described a D-Bus interface, a G-code +contract, the C++ to WASM to npm chain, a submodule pointer discipline, or a +deployment topology. The detectors match extensions and path segments; a contract +between repositories has neither. + +### 1. Start from the seeded surfaces, then go past them + +The detector has already filed `ecosystem_contract` observations in +`.agents/bootstrap-observations.md` for the surfaces it could see mechanically. + +Seeded here: + +- `A WebAssembly/embind boundary crosses out of this repository` +- `This repository's public surface is mounted into other repositories` +- `This repository publishes a package whose identity is generated, not committed` + +Each seeded entry names files and asks a question. Your job is to answer the +question with a specific fact, and to find the surfaces the scan missed. + +### 2. The hunt list + +For each item: is it present, who is on the other side, and what breaks silently? + +- **Inter-process interfaces** — D-Bus XML, protobuf, IDL. Which definitions does + this repository *own* versus carry as a generated proxy? What regenerates the + proxies, and which sibling repositories consume the same definition? +- **G/M-code surface** — does this repository send, forward, parse or handle + printer codes? Where is the registry of record, and which siblings sit on the + same path? Codes are an ecosystem contract; a locally added code that is not + registered is a defect on the day a sibling sees it. +- **WASM / embind boundary** — which link options and exported symbols are a + published contract rather than build tuning? Which flags does a consumer's + bundler configuration depend on (module format, single-file embedding, export + name)? Is any generated TypeScript surface typechecked by anything at all, on + either side of the boundary? +- **Bundler contracts** — a pre-bundling exclusion, an asset-loader entry or a + worker-inlining suffix in a build config is usually a scar from an integration + break. Find out which producing package requires it, and record the incident + next to the entry so a later cleanup does not delete it. +- **Publishing identity** — what artifact does this repository publish, under what + name, and where does the version string come from? A generated manifest means + there is no committed file for a detector to read; trace the recipe. +- **Version pinning direction** — who pins whom, and how tightly? An exact pin + (rather than a range) on an internal package usually encodes a specific upstream + commit. Confirm whether it is deliberate before anyone relaxes it. +- **Submodules** — the graph in both directions. Which repositories pin this one? + What is the ordering discipline for a change spanning both trees? +- **Deployment topology** — runtime, pipeline, and the message topics or endpoints + this repository publishes to and subscribes from. Name the services on the other + end. +- **Shared protocol and schema libraries** — a type, schema or constant defined in + one repository and depended on by several is a contract even when it looks like + an ordinary dependency. +- **Licence posture across the boundary** — a strong-copyleft component inside a + distributed or published artifact is an ecosystem fact, not a dependency detail. + +### 3. Read what the repository already documents + +Before writing any finding, read the in-tree documentation — README files inside +source directories, `docs/`, ADR trees, notes committed beside the code. Contract +knowledge is very often already written down by whoever got burned by it, filed +under a heading nobody would search for. Cite the document by path; do not +paraphrase it into a second source of truth that will drift. + +### 4. Verify both ends + +A contract claim with only one end observed is a guess. Where a sibling checkout +is available locally, open it and confirm; where it is not, say which end you did +not verify. Useful probes: + +```bash +gh search code "" --owner Ultimaker --limit 20 +gh api "repos/Ultimaker//contents/" -H "Accept: application/vnd.github.raw+json" +``` + +Neither is a substitute for reading the file when the checkout is at hand. + +### 5. Deliverables — observations, never rules + +Append entries to `.agents/bootstrap-observations.md` under the category +`ecosystem_contract`, in the shape that file already uses: + +- **Category** `ecosystem_contract`, and a confidence you can defend. +- **Evidence** — file paths on both sides, with line numbers where it matters. +- **Question to reason about** — the decision a human still has to make. +- **Draft rule** — keeping its `` where you could not resolve them. + A draft is a proposal; it becomes a rule only when a human or a later agent + promotes it into a custom band (`15`–`19`, `35`–`39`, `45`–`59`) with every + placeholder replaced. + +You do **not** write into `.agents/rules/`. The observation-to-rule ladder exists +so that a claim about another repository is reviewed by someone who can check it. + +Additionally, hand the architecture investigator a paragraph for AGENTS.md +*Position in the wider system*: what this repository consumes, what consumes it, +and the artifact that connects them. Facts only — obligations belong in a rule. + +Where a contract is already covered by a catalogue skill, say so and name the +skill rather than restating its content. A repository-local rule that duplicates +a skill drifts from it, and agents then follow the stale copy. + +### Honesty Requirements + +- **Never emit an unfilled placeholder outside a draft rule.** Draft rules keep + `` by design — that is how the ladder marks unfinished work. + Prose does not. If a fact is unknown, write "Not determined: `` — what + was examined: ``; what would settle it: ``." +- **Evidence or it did not happen.** Every claim carries the path or the command + output that proves it. +- **Absence is a claim too.** "No D-Bus surface in this repository" needs the + search that establishes it, and is worth recording — a later run should not + re-litigate it. + +### Output Format + +Return a structured Markdown report: + +- **Contracts found** — one block each: surface, direction, other end, artifact, + version-pinning mechanism, what breaks silently, evidence. +- **Surfaces checked and absent** — with the command that establishes the absence. +- **Observations appended** — the exact entries added to the observations file. +- **Skills that should have matched** — any catalogue skill this repository's + contracts imply but the matcher did not select, with the evidence. +- **Not determined** — open questions, phrased so the next run can close them. diff --git a/.agents/aiignore-generated.json b/.agents/aiignore-generated.json new file mode 100644 index 0000000..92ef911 --- /dev/null +++ b/.agents/aiignore-generated.json @@ -0,0 +1,113 @@ +{ + "comment": "Records what compile_aiignore.py generated, so the next run replaces only its own entries and leaves hand-written rules alone. Do not edit.", + "claude_deny": [ + "Read(./**/*.pem)", + "Read(./**/*.key)", + "Read(./**/*.p12)", + "Read(./**/*.pfx)", + "Read(./**/*.jks)", + "Read(./**/*.keystore)", + "Read(./**/id_rsa)", + "Read(./**/id_ecdsa)", + "Read(./**/id_ed25519)", + "Read(./**/*.gpg)", + "Read(./**/*.asc)", + "Read(./**/.env)", + "Read(./**/.env.*)", + "Read(./**/*.tfvars)", + "Read(./**/.netrc)", + "Read(./**/.npmrc)", + "Read(./**/credentials.json)", + "Read(./**/service-account*.json)", + "Read(./**/secrets/**)", + "Read(./**/vendor/**)", + "Read(./**/third_party/**)", + "Read(./**/node_modules/**)", + "Read(./**/software/sdk/**)", + "Read(./**/freertos_kernel/**)", + "Read(./**/cmsis/**)", + "Read(./**/build/**)", + "Read(./**/dist/**)", + "Read(./**/out/**)", + "Read(./**/target/**)", + "Read(./**/.venv/**)", + "Read(./**/venv/**)", + "Read(./**/__pycache__/**)", + "Read(./**/*.o)", + "Read(./**/*.a)", + "Read(./**/*.so)", + "Read(./**/*.dylib)", + "Read(./**/*.dll)", + "Read(./**/*.exe)", + "Read(./**/.mypy_cache/**)", + "Read(./**/.pytest_cache/**)", + "Read(./**/.ruff_cache/**)", + "Read(./**/*.stl)", + "Read(./**/*.3mf)", + "Read(./**/*.gcode)", + "Read(./**/*.ufp)", + "Read(./**/*.obj)", + "Read(./**/*.step)", + "Read(./**/*.stp)", + "Read(./**/conan.lock)", + "Read(./**/*.log)", + "Read(./**/logdump*/**)", + "Read(./**/customer-data/**)", + "Read(./**/pii/**)" + ], + "opencode_rules": [ + "**/*.3mf", + "**/*.a", + "**/*.asc", + "**/*.dll", + "**/*.dylib", + "**/*.exe", + "**/*.gcode", + "**/*.gpg", + "**/*.jks", + "**/*.key", + "**/*.keystore", + "**/*.log", + "**/*.o", + "**/*.obj", + "**/*.p12", + "**/*.pem", + "**/*.pfx", + "**/*.so", + "**/*.step", + "**/*.stl", + "**/*.stp", + "**/*.tfvars", + "**/*.ufp", + "**/.env", + "**/.env.*", + "**/.mypy_cache/**", + "**/.netrc", + "**/.npmrc", + "**/.pytest_cache/**", + "**/.ruff_cache/**", + "**/.venv/**", + "**/__pycache__/**", + "**/build/**", + "**/cmsis/**", + "**/conan.lock", + "**/credentials.json", + "**/customer-data/**", + "**/dist/**", + "**/freertos_kernel/**", + "**/id_ecdsa", + "**/id_ed25519", + "**/id_rsa", + "**/logdump*/**", + "**/node_modules/**", + "**/out/**", + "**/pii/**", + "**/secrets/**", + "**/service-account*.json", + "**/software/sdk/**", + "**/target/**", + "**/third_party/**", + "**/vendor/**", + "**/venv/**" + ] +} diff --git a/.agents/bootstrap-observations.md b/.agents/bootstrap-observations.md new file mode 100644 index 0000000..0b0f32e --- /dev/null +++ b/.agents/bootstrap-observations.md @@ -0,0 +1,107 @@ +# Custom Rule Proposals & Resolutions — libUvula + +These are observations about how *this* repository actually works, gathered during agentic bootstrap on `UC-3697_AI_DF` and audited by the adversarial Red Team. + +--- + +## 1. Commit messages carry explanatory bodies + +**Category:** commit-style **Confidence:** high +**Status:** **RESOLVED** — Promoted to Managed/Custom Rule `.agents/rules/15-commit-body-standards.md`. + +**Evidence & Rationale** +- 79% of sampled commits historically contain an explanatory body (average 1.5 lines). +- As a mathematical geometry library consumed by Cura and Neoprep, commit messages serve as critical architectural documentation. +- Rule 15 mandates structured commit bodies explaining *why* and *how*. + +--- + +## 2. A WebAssembly/embind boundary crosses out of this repository + +**Category:** ecosystem_contract **Confidence:** high +**Status:** **RESOLVED** — Promoted to Custom Rule `.agents/rules/35-wasm-embind-boundary-rules.md`. + +**Evidence & Rationale** +- `UvulaJS/CMakeLists.txt`, `UvulaJS/UvulaJS.cpp`, `src/xatlas.cpp`. +- Emscripten link options (`-s MODULARIZE=1`, `-s EXPORT_ES6=1`, `--emit-tsd`, `-s SINGLE_FILE=1`) form a strict contract with Neoprep bundlers (Webpack / Vite). +- Rule 35 defines Three.js column-major matrix transposition, viewport stroke projection parameters, single-threaded execution (`XA_MULTITHREADED 0`), and exception-safe boundary handling. + +--- + +## 3. This repository's public surface is mounted into other repositories + +**Category:** ecosystem_contract **Confidence:** high +**Status:** **RESOLVED** — Promoted to Rule `.agents/rules/34-library-consumer-contract-rules.md`. + +**Evidence & Rationale** +- Public surfaces: `include/` (C++ headers), `UvulaJS/` (WebAssembly / npm), `pyUvula/` (Python pybind11). +- Downstream consumers: UltiMaker Cura (C++ core + Python) and Neoprep / Digital Factory (WASM). +- Rule 34 defines cross-repository breaking change protocols, additive modification preferences, and consumer PR synchronization. + +--- + +## 4. Observed file-size distribution (input to the ratchet) + +**Category:** code-pattern **Confidence:** medium +**Status:** **RESOLVED** — Governed by `.agents/rules/10-file-size-and-decomposition-rules.md`. + +**Evidence & Rationale** +- Median file size is 35 lines; 90th percentile is 246 lines. +- 400-line budget in Rule 10 is well-matched to this codebase. +- Grandfathered files and third-party vendored code (`src/xatlas.cpp`) are managed via `.agents/file-size-baseline.json` and `file_size_scope.py`. + +--- + +## 5. Commits are small and tightly scoped + +**Category:** commit-style **Confidence:** medium +**Status:** **RESOLVED** — Governed by `.agents/rules/08-scoped-changes-and-minimal-diffs.md`. + +**Evidence & Rationale** +- Median commit touches 1 file; 90th percentile touches 24 files. +- Rule 08 strictly enforces Single Responsibility PRs (SRP-PR) and the Boy Scouting isolation protocol. + +--- + +## 6. This repository publishes a package whose identity is generated, not committed + +**Category:** ecosystem_contract **Confidence:** medium +**Status:** **RESOLVED** — Promoted to Custom Rule `.agents/rules/37-package-identity-rules.md`. + +**Evidence & Rationale** +- `conanfile.py` publishes native Conan 2 package `uvula` for Cura. +- `.github/workflows/package.yml` publishes npm WebAssembly package `@ultimaker/uvulajs` for Neoprep / Digital Factory. +- Branch triggers in `package.yml` updated to include `- 'UC-*'` alongside `NP-*`, `CURA-*`, and `main`/`master`. + +--- + +## 7. History keeps merge commits + +**Category:** git-workflow **Confidence:** medium +**Status:** **RESOLVED** — Integrated into `.agents/rules/06-pull-request-lifecycle-rules.md`. + +**Evidence & Rationale** +- 17/91 sampled commits are merge commits. +- Rule 06 defines "Git Merge Topology Preservation" to preserve ISO-27001 auditability and forbid force-pushing published review branches. + +--- + +## 8. File names are predominantly PascalCase + +**Category:** code-pattern **Confidence:** low +**Status:** **RESOLVED / SETTLED** — C++ source files in `src/` and `include/` follow PascalCase convention. + +**Evidence & Rationale** +- 16/19 multi-word files use PascalCase (e.g. `FaceSigned.h`, `Triangle3F.cpp`, `Matrix44F.cpp`). +- Retained as idiomatic project style documented in `AGENTS.md`. + +--- + +## 9. Reverts are frequent + +**Category:** git-workflow **Confidence:** low +**Status:** **RESOLVED / SETTLED** — Pre-PR automated gate prevents regressions. + +**Evidence & Rationale** +- 3/91 sampled commits are reverts. +- Automated pre-PR verification script (`scripts/verify_and_create_pr.sh`) with comprehensive linters and adversarial checks enforces verification before human review. diff --git a/.agents/bootstrap-profile.json b/.agents/bootstrap-profile.json new file mode 100644 index 0000000..a09dc7e --- /dev/null +++ b/.agents/bootstrap-profile.json @@ -0,0 +1,390 @@ +{ + "repository": { + "name": "libUvula", + "source": "git-remote:origin" + }, + "stacks": { + "has_frontend": false, + "has_cloud": false, + "has_cpp": true, + "has_infra": false, + "has_qml": false, + "has_freertos": false, + "has_debian": false, + "has_materials": false + }, + "git": { + "commits_sampled": 92, + "jira_keys": { + "UC": 12 + }, + "allowed_jira_keys": [ + "UC" + ], + "branch_jira_key": "UC", + "history_jira_key": "UC", + "dominant_jira_key": "UC", + "semantic_prefix_ratio": 0.0 + }, + "base_branch": "main", + "submodules": [], + "ci": { + "providers": [ + "github-actions" + ], + "workflows": [ + ".github/workflows/package.yml" + ], + "signals": { + "tests": false, + "lint": false, + "build": true + } + }, + "tests": { + "frameworks": [], + "commands": [] + }, + "linters": { + "ruff": false, + "black": false, + "flake8": false, + "mypy": false, + "eslint": false, + "prettier": false, + "stylelint": false, + "clang_format": true, + "clang_tidy": true, + "pre_commit_config": false + }, + "lint_scripts": [], + "protected_branches": [ + "main" + ], + "cpp": { + "sampled_files": 26, + "expected_hits": 0, + "throw_hits": 3, + "error_style": "exceptions", + "cxx_standard": null, + "idioms": { + "ranges": 7, + "constexpr": 9, + "concepts": 0, + "auto": 37, + "unique_ptr": 0, + "shared_ptr": 0, + "raw_new": 3, + "raw_delete": 6, + "optional": 5, + "variant": 0, + "coroutines": 0 + }, + "auto_per_file": 1.4, + "has_wasm": true, + "conan_major": 2, + "conan_version_req": ">=2.7.0" + }, + "c": {}, + "python": {}, + "ui": { + "has_ui": false, + "design_md_exists": false, + "token_sources": [], + "uses_stardust": false + }, + "pr_template": { + "exists": true, + "path": ".github/PULL_REQUEST_TEMPLATE.md", + "has_why": true, + "has_what": false, + "has_how": false, + "content": "Jira ticket: UC-XXX\n\n- [ ] Describe the changes that were made and why\n- [ ] Add screenshot\n- [ ] Unit tests\n- [ ] E2E tests\n" + }, + "build_commands": [ + "cmake --build build", + "conan build ." + ], + "directory_map": [ + { + "name": "include", + "count": 13, + "extensions": ".h" + }, + { + "name": "src", + "count": 10, + "extensions": ".cpp" + }, + { + "name": "UvulaJS", + "count": 2, + "extensions": ".txt, .cpp" + }, + { + "name": "cli", + "count": 2, + "extensions": ".txt, .cpp" + }, + { + "name": "pyUvula", + "count": 2, + "extensions": ".txt, .cpp" + } + ], + "artifact_dirs": [], + "has_api_surface": false, + "interface_dirs": [], + "api_doc_paths": [], + "has_storybook": false, + "host": { + "os": "linux", + "has_secret_tool": true, + "has_security": false + }, + "vendored": [], + "generated": [], + "evidence": { + "has_systemd": false, + "systemd_units": [], + "has_udev": false, + "udev_rules": [], + "has_dbus": false, + "dbus_policy": [], + "has_machine_json": false, + "machine_json_files": [], + "has_gcode": false, + "gcode_evidence": [], + "has_po_files": false, + "po_files": [], + "has_debian_packaging": false, + "debian_evidence": [], + "shell_script_count": 0 + }, + "license": { + "declared": "GNU GENERAL PUBLIC", + "source": "LICENSE", + "proprietary": false + }, + "has_package_json": false, + "node_package_dirs": [], + "python_packaging_manifests": [], + "file_count": 39, + "complexity": { + "functions_sampled": 309, + "median": 1, + "p90": 6, + "max": 80 + }, + "skills": { + "catalog_root": "~/dev/um/UltiCortex", + "catalog_size": 28, + "matched": [ + "conan-2", + "cmake", + "cpp-pro", + "software-architect" + ] + }, + "observations": [ + { + "category": "commit-style", + "title": "Commit messages carry explanatory bodies", + "evidence": [ + "79% of sampled commits have a body", + "average body length 1.5 lines" + ], + "question": "Should agents be required to write a body explaining why/how, not just a subject line?", + "draft_rule": "Every commit message MUST include a body explaining why the change was needed and how it works \u2014 a subject line alone is not enough.", + "confidence": "high", + "data": {} + }, + { + "category": "ecosystem_contract", + "title": "A WebAssembly/embind boundary crosses out of this repository", + "evidence": [ + "`UvulaJS/CMakeLists.txt`", + "`UvulaJS/UvulaJS.cpp`", + "`src/xatlas.cpp`" + ], + "question": "Which link options and exported symbols are a published contract with the JavaScript consumer, and which are build trivia? Which consumer bundler configuration depends on them, and is any generated TypeScript surface (`.d.ts`, a generated registry) typechecked by anything at all?", + "draft_rule": "The Emscripten link options in `` are a contract with ``'s bundler, not a build detail: `` determine how the module is imported and must not change without naming the consumer impact in the PR. WASM-specific code stays in ``; the core must build natively unchanged. Every exported binding is a public API change. Relevant catalogue skills: `ultimaker-curator-development`, `ultimaker-neoprep-development`.", + "confidence": "high", + "data": { + "surface": "wasm_boundary", + "files": [ + "UvulaJS/CMakeLists.txt", + "UvulaJS/UvulaJS.cpp", + "src/xatlas.cpp" + ], + "skills": [ + "ultimaker-curator-development", + "ultimaker-neoprep-development" + ] + } + }, + { + "category": "ecosystem_contract", + "title": "This repository's public surface is mounted into other repositories", + "evidence": [ + "no entry point starts a process here, yet the repository publishes a surface (`include/`)", + "published surface: `include/`" + ], + "question": "Which consumers pin this repository, and what is the ordering discipline for a change that spans both trees? Rule 34 states the part that is provable from this tree; the rest belongs here.", + "draft_rule": "Every exported symbol, model field and schema in the published surface is a contract with the repositories that mount it. Land the change here first, then move each consumer's pointer; prefer additive shape changes and name the consumers in the pull request.", + "confidence": "high", + "data": { + "surface": "library_consumer_contract", + "consumers": [], + "surface_dirs": [ + "include" + ], + "manifests": [ + "conanfile.py" + ], + "has_entry_point": false, + "nested_checkout": false, + "paths": [ + "include/**" + ], + "reasons": [ + "no entry point starts a process here, yet the repository publishes a surface (`include/`)" + ] + } + }, + { + "category": "code-pattern", + "title": "Observed file-size distribution (input to the ratchet)", + "evidence": [ + "median 35 lines, 90th percentile 246, largest sampled 3877", + "2 of 27 sampled files exceed the 400-line budget and will be grandfathered at their current size" + ], + "question": "The file-size ratchet is already generated as a managed rule (`10-file-size-and-decomposition-rules.md`) with a 400-line budget. Is 400 the right budget for this codebase, or does its shape justify a different number (90th percentile is 246)? Re-run the bootstrap with `--file-size-budget N` to change it \u2014 do not write a competing rule.", + "draft_rule": "No new rule needed: adjust the managed budget instead. Consider whether the largest grandfathered files deserve a decomposition ticket of their own.", + "confidence": "medium", + "data": { + "median": 35, + "p90": 246, + "largest": 3877, + "over_budget": 2 + } + }, + { + "category": "commit-style", + "title": "Commits are small and tightly scoped", + "evidence": [ + "median 1 files per commit, 90th percentile 24" + ], + "question": "Should the scope rule name a concrete file-count expectation derived from this repository's own history?", + "draft_rule": "Keep commits tightly scoped: this repository's median commit touches 1 files. A commit touching more than 24 files needs an explicit justification in the message.", + "confidence": "medium", + "data": {} + }, + { + "category": "ecosystem_contract", + "title": "This repository publishes a package whose identity is generated, not committed", + "evidence": [ + "`conanfile.py`", + "`.github/workflows/package.yml`" + ], + "question": "What is the published artifact called, who consumes it, and where does its version string actually come from? A repository whose package metadata is generated at packaging time has no committed manifest for a detector to read \u2014 the identity has to be traced through the recipe.", + "draft_rule": "The published artifact is ``, assembled by ``; `` is the single source of truth for its identity, version and exports. Never hand-write a competing manifest. Consumers (``) pin exact versions \u2014 a breaking change to the exported surface requires the paired consumer PR in the same ticket. Relevant catalogue skills: `conan-2`.", + "confidence": "medium", + "data": { + "surface": "package_publishing", + "files": [ + "conanfile.py", + ".github/workflows/package.yml" + ], + "skills": [ + "conan-2" + ] + } + }, + { + "category": "git-workflow", + "title": "History keeps merge commits", + "evidence": [ + "17/91 sampled commits are merges (mostly pull request merges)", + "`Merge pull request`: 8, `Merge branch`: 5" + ], + "question": "Is the merge commit intentional (preserving branch topology), and should agents therefore NOT rebase published branches?", + "draft_rule": "This repository preserves merge topology. Do not rewrite or rebase branches that have already been pushed; integrate with merges.", + "confidence": "medium", + "data": {} + }, + { + "category": "code-pattern", + "title": "File names are predominantly PascalCase", + "evidence": [ + "{'PascalCase': 16, 'snake_case': 2, 'camelCase': 1} across 19 multi-word file names" + ], + "question": "Does the convention differ per language or per directory, and is it worth stating so new files match?", + "draft_rule": "Name new source files in PascalCase, matching the existing tree.", + "confidence": "low", + "data": {} + }, + { + "category": "git-workflow", + "title": "Reverts are frequent", + "evidence": [ + "3/91 sampled commits are reverts" + ], + "question": "Does this point at a missing pre-merge verification step worth encoding as a rule (extra test gate, staged rollout)?", + "draft_rule": "Given the revert rate in this repository, verify before merging.", + "confidence": "low", + "data": {} + } + ], + "hook_verification": { + "block-secrets": { + "fired": true, + "blocked": true, + "detail": "staged violation rejected", + "at": "2026-08-19T11:25:16+00:00", + "sha": "2aab792" + }, + "block-absolute-paths": { + "fired": true, + "blocked": true, + "detail": "staged violation rejected", + "at": "2026-08-19T11:25:16+00:00", + "sha": "2aab792" + }, + "pretool-guard": { + "fired": true, + "blocked": true, + "detail": "secret payload denied", + "at": "2026-08-19T11:25:16+00:00", + "sha": "2aab792" + }, + "block-name-matched-kill": { + "fired": true, + "blocked": true, + "detail": "pkill denied", + "at": "2026-08-19T11:25:16+00:00", + "sha": "2aab792" + }, + "check-rule-frontmatter": { + "fired": true, + "blocked": null, + "detail": "exit 0 (advisory; executed, not violated)", + "at": "2026-08-19T11:25:16+00:00", + "sha": "2aab792" + }, + "audit-quad-agent-parity": { + "fired": true, + "blocked": null, + "detail": "exit 0 (advisory; executed, not violated)", + "at": "2026-08-19T11:25:16+00:00", + "sha": "2aab792" + }, + "check-upstream-alignment": { + "fired": true, + "blocked": null, + "detail": "exit 0 (advisory; executed, not violated)", + "at": "2026-08-19T11:25:17+00:00", + "sha": "2aab792" + } + } +} diff --git a/.agents/dispatch-invariants.md b/.agents/dispatch-invariants.md new file mode 100644 index 0000000..b89bc66 --- /dev/null +++ b/.agents/dispatch-invariants.md @@ -0,0 +1,33 @@ +# Dispatch Invariants + + + +1. **Jira key**: every commit title in this session uses `[UC-3697]`, + verbatim. Never substitute another project key, whatever the history shows. +2. **Base branch**: this repository's PRs target `main`. Diff, + rebase-check and align against `origin/main` only. +3. **Commit path allowlist**: a bootstrap commit stages only + `.agents/**`, `.claude/**`, `.github/**`, `.opencode/**`, `AGENTS.md`, + `GEMINI.md`, `DESIGN.md`, `.aiignore`, `.ignore`, `.gitattributes`, + `.gitignore`, `.pre-commit-config.yaml`, `.talismanrc`, `opencode.json`, + `CLAUDE.md`, `linting_excluded_files.txt`, + `scripts/sync_agentic_configs.sh`, `scripts/verify_and_create_pr.sh`. + Any other staged path — reformats, submodule pointer bumps, functional + fixes — is a hard stop; extract it to its own branch. +4. **Worktree isolation**: any mutating work (commits, builds, file edits) + requires `isolation: worktree`. Never mutate a shared checkout. **Never run + `git stash` in a worktree**: `refs/stash` is a single ref shared by every + worktree of a clone, so a pop in one tree replays — and destroys — another + agent's work. To compare against other code use `git show :` or + copy the file aside. +5. **Foreground long-running commands**: never background a long-running + command expecting a completion notification; none will come. +6. **Evidence or it did not happen**: report every claim with the command + output that proves it. An assertion without its command is discarded. +7. **No repo-wide formatters**: never run `pre-commit run --all-files`, + `ruff format .`, or any formatter over paths the task does not touch. +8. **Conflict markers are a hard stop**: never commit a half-merged file; + regenerate structured configs from source instead of text-merging them. diff --git a/.agents/file-size-baseline.json b/.agents/file-size-baseline.json new file mode 100644 index 0000000..e3cf5f5 --- /dev/null +++ b/.agents/file-size-baseline.json @@ -0,0 +1,10 @@ +{ + "budget": 400, + "comment": "Files recorded here were already over budget when the ratchet was introduced. They may shrink, never grow. Entries are removed automatically once a file falls within budget. Do not add entries by hand to excuse a new violation.", + "exclude": [], + "exclude_comment": "Path fragments excluded from the budget entirely \u2014 use for vendored or generated trees this project does not own. After editing, re-run with --init to reseed.", + "files": { + "src/unwrap.cpp": 439 + }, + "policy": "ratchet" +} diff --git a/.agents/hooks.json b/.agents/hooks.json new file mode 100644 index 0000000..64940cd --- /dev/null +++ b/.agents/hooks.json @@ -0,0 +1,52 @@ +{ + "safety-and-compliance": { + "PreToolUse": [ + { + "matcher": "run_command|write_to_file|replace_file_content|multi_replace_file_content", + "hooks": [ + { + "type": "command", + "command": "python3 hooks/pretool_guard.py", + "timeout": 15 + } + ] + }, + + { + "matcher": "run_command", + "hooks": [ + { + "type": "command", + "command": "python3 hooks/block_name_matched_kill.py", + "timeout": 10 + } + ] + }, + + { + "matcher": "run_command|write_to_file|replace_file_content|multi_replace_file_content", + "hooks": [ + { + "type": "command", + "command": "python3 hooks/suggest-skills.py --pre-edit", + "timeout": 15 + } + ] + } + + ], + "PostToolUse": [ + { + "matcher": "write_to_file|replace_file_content|multi_replace_file_content", + "hooks": [ + { + "type": "command", + "command": "bash hooks/post-edit-linter.sh", + "timeout": 60 + } + ] + } + + ] + } +} diff --git a/.agents/hooks/audit_quad_agent_parity.py b/.agents/hooks/audit_quad_agent_parity.py new file mode 100755 index 0000000..f54995f --- /dev/null +++ b/.agents/hooks/audit_quad_agent_parity.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python3 +""" +audit_quad_agent_parity.py + +Asserts that every hook a repository qualifies for is present in every platform +rendering that supports hooks. + +The required set is derived from the generated configs themselves rather than +hardcoded. A hardcoded list is how the previous version came to require six +hooks while the bootstrap installed eleven — and the five it never checked were +the five added most recently. + +Platform support, verified rather than assumed: + * Antigravity .agents/hooks.json + * Claude Code .claude/settings.json + * Copilot .github/hooks/copilot-hooks.json + * OpenCode has no command-hook mechanism, so it is checked for + configuration presence only; its enforcement floor is + pre-commit. +""" + +import json +import re +import sys +from pathlib import Path + +# Installed beside this script in .agents/hooks/, and beside it in the skill's +# own scripts/ directory, so the same import works in both places. +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from copilot_index import check as check_copilot_index # noqa: E402 + +CONFLICT_MARKER = re.compile(r"^(<{7} |={7}$|>{7} )", re.MULTILINE) + + +def load_json(path: Path): + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + print(f"[X] {path}: cannot be read ({exc})") + return None + + +def script_name(command: str) -> str: + """Reduce a platform-specific command line to the hook script's basename. + + Only managed hooks — commands that route through .agents/hooks/ or hooks/ — take part + in the parity contract. A platform may additionally wire repository-local + hooks living elsewhere (scripts/hooks/, an inline shell guard); those speak + that platform's dialect by construction and demanding they exist on every + other platform would force either a false failure or a broken port. The + old last-token heuristic also choked on compound shell commands, reporting + '}' as a missing hook. + """ + if not command: + return "" + for token in command.split(): + # Explicitly routed through the managed hooks directory (Claude, + # Copilot: .agents/hooks/), or relative to .agents/ (Antigravity: hooks/). + if ".agents/hooks/" in token or "hooks/" in token: + return token.rsplit("/", 1)[-1] + if "/" not in token and token.endswith((".py", ".sh")): + return token + return "" + + +def antigravity_hooks(repo: Path): + data = load_json(repo / ".agents" / "hooks.json") + if data is None: + return None + names = set() + for group in data.values(): + if not isinstance(group, dict): + continue + for entries in group.values(): + for entry in entries: + for hook in entry.get("hooks", []): + names.add(script_name(hook.get("command", ""))) + return names - {""} + + +def claude_hooks(repo: Path): + data = load_json(repo / ".claude" / "settings.json") + if data is None: + return None + names = set() + for blocks in (data.get("hooks") or {}).values(): + for block in blocks: + for hook in block.get("hooks", []): + names.add(script_name(hook.get("command", ""))) + return names - {""} + + +def copilot_hooks(repo: Path): + data = load_json(repo / ".github" / "hooks" / "copilot-hooks.json") + if data is None: + return None + names = set() + for entries in (data.get("hooks") or {}).values(): + for hook in entries: + names.add(script_name(hook.get("bash", ""))) + return names - {""} + + +def platform_commands(repo: Path): + """Every literal command each platform will execute.""" + commands = [] + data = load_json(repo / ".agents" / "hooks.json") or {} + for group in data.values(): + if isinstance(group, dict): + for entries in group.values(): + for entry in entries: + commands += [h.get("command", "") + for h in entry.get("hooks", [])] + data = load_json(repo / ".claude" / "settings.json") or {} + for blocks in (data.get("hooks") or {}).values(): + for block in blocks: + commands += [h.get("command", "") for h in block.get("hooks", [])] + data = load_json(repo / ".github" / "hooks" / "copilot-hooks.json") or {} + for entries in (data.get("hooks") or {}).values(): + commands += [h.get("bash", "") for h in entries] + return [c for c in commands if c] + + +def audit_command_paths(repo: Path) -> bool: + """Literal path validity. Reducing to basenames is exactly how a doubled + `.agents/hooks/.agents/hooks/` path passed its own audit.""" + ok = True + for command in platform_commands(repo): + for token in command.split(): + if ".agents/hooks/" in token: + if token.count(".agents/hooks/") != 1: + print(f"[X] doubled hook path in command: {command}") + ok = False + elif not (repo / token.removeprefix("./")).exists(): + print(f"[X] command references a missing script: {command}") + ok = False + elif token.startswith("hooks/") and token.endswith((".py", ".sh")): + if not (repo / ".agents" / token).exists(): + print(f"[X] command references a missing script: {command}") + ok = False + return ok + + +def audit_rule_mirrors(repo: Path) -> bool: + """Structural rule-set diff across platform dirs, not a count.""" + ok = True + canonical = {p.name for p in (repo / ".agents" / "rules").glob("*.md") + if not p.is_symlink()} if (repo / ".agents" / "rules").is_dir() else set() + seen_prefix = {} + for name in sorted(canonical): + m = re.match(r"^(\d{2})-", name) + if m: + if m.group(1) in seen_prefix: + print(f"[X] duplicate rule prefix {m.group(1)}: {name} and " + f"{seen_prefix[m.group(1)]}") + ok = False + seen_prefix[m.group(1)] = name + for mirror in (repo / ".claude" / "rules", repo / ".opencode" / "rules"): + if not mirror.is_dir(): + continue + for entry in sorted(mirror.glob("*.md")): + if entry.name == "agents.md": + continue + if entry.is_symlink() and not entry.exists(): + print(f"[X] broken symlink: {entry}") + ok = False + elif entry.name not in canonical: + print(f"[X] platform-only stray rule: {entry} " + "(not in .agents/rules/)") + ok = False + missing = canonical - {e.name for e in mirror.glob("*.md")} + if missing: + print(f"[X] {mirror}: missing mirrors for {sorted(missing)}") + ok = False + return ok + + +def audit_copilot_rule_index(repo: Path) -> bool: + """BOTH directions of the Copilot rule list. + + Copilot has no rule-directory convention: `.github/copilot-instructions.md` + is the only place it learns which rules exist. The previous check ran one + way only — every listed rule must exist — so the two failures that actually + happened in the field went unreported and the audit printed PASSED: + + * a rule present in `.agents/rules/` but absent from the index (a + repo-owned or custom-band rule, or any rule added after the bootstrap + run that rendered the list), and + * an index never refreshed after the rule set changed. + + Missing-from-the-index is the direction that loses enforcement, so it is a + failure, not a warning. + """ + problems = check_copilot_index(repo) + for problem in problems: + print(f"[X] {problem}") + if problems: + print(" The Copilot rule index is stale. Refresh it with " + "`python3 .agents/hooks/copilot_index.py` (or " + "`bash scripts/sync_agentic_configs.sh`).") + return False + print("[ok] Copilot: rule index matches .agents/rules/ in both directions") + return True + + +def _check_duplicate_top_keys(path: Path) -> bool: + """PyYAML silently last-wins on duplicate top-level keys — the mechanism + that dropped 8+ hooks from half-merged configs without a parse error.""" + seen, ok = set(), True + for line in path.read_text(errors="ignore").splitlines(): + m = re.match(r"^([A-Za-z_][\w-]*):", line) + if m: + if m.group(1) in seen: + print(f"[X] {path.name}: duplicate top-level key " + f"'{m.group(1)}' — YAML silently drops one side") + ok = False + seen.add(m.group(1)) + return ok + + +def audit_structured_files(repo: Path) -> bool: + ok = True + precommit = repo / ".pre-commit-config.yaml" + if precommit.exists(): + ok &= _check_duplicate_top_keys(precommit) + try: + import yaml # type: ignore + yaml.safe_load(precommit.read_text(errors="ignore")) + except ImportError: + pass + except Exception as exc: # noqa: BLE001 + print(f"[X] .pre-commit-config.yaml does not parse: {exc}") + ok = False + for rel in (".agents/hooks.json", ".claude/settings.json", + ".github/hooks/copilot-hooks.json", "opencode.json", + ".agents/rules-manifest.json", ".agents/bootstrap-profile.json"): + path = repo / rel + if path.exists() and load_json(path) is None: + ok = False + for rel in (".pre-commit-config.yaml", ".talismanrc", "opencode.json", + ".agents/hooks.json", ".claude/settings.json"): + path = repo / rel + if path.exists() and CONFLICT_MARKER.search(path.read_text(errors="ignore")): + print(f"[X] {rel}: contains conflict markers") + ok = False + return ok + + +# Parity is the default and a divergence is a defect — EXCEPT where a harness +# already provides the capability natively. Recording the exception here (rather +# than relaxing the check) keeps an accidental omission a failure. +HARNESS_EXEMPT = { + # Claude reads the `paths:` front matter in .claude/rules and loads the + # matching skill itself; a hook there duplicates the harness on every edit. + "suggest-skills.py": ("Claude Code",), +} + + +def audit_parity(repo_path: Path) -> bool: + repo = repo_path.resolve() + print(f"==> Auditing agent-platform parity in: {repo.name}") + passed = True + + platforms = { + "Antigravity": antigravity_hooks(repo), + "Claude Code": claude_hooks(repo), + "Copilot": copilot_hooks(repo), + } + + available = [hooks for hooks in platforms.values() if hooks] + if not available: + print("[X] No platform hook configuration found — run the bootstrap first.") + return False + + # The union is what this repository qualifies for; every platform must carry + # all of it. This catches a hook added to one config and forgotten in another. + expected: set[str] = available[0].union(*available[1:]) + for name, hooks in platforms.items(): + if hooks is None: + print(f"[X] {name}: configuration missing or unreadable") + passed = False + continue + missing = {script for script in expected - hooks + if name not in HARNESS_EXEMPT.get(script, ())} + deliberate = sorted((expected - hooks) - missing) + if missing: + print(f"[X] {name}: missing {sorted(missing)}") + passed = False + else: + note = (f" (deliberately not wired: {', '.join(deliberate)} — " + "the harness provides this natively)" if deliberate else "") + print(f"[ok] {name}: {len(hooks)} hook(s){note}") + + # Every referenced script must exist, or the config entry is a silent no-op. + hooks_dir = repo / ".agents" / "hooks" + for script in sorted(expected): + if not (hooks_dir / script).exists(): + print(f"[X] {script} is referenced by a platform config " + "but is not installed") + passed = False + + # ...and the reverse: a hook installed but called by nothing is dead code + # that reads as enforcement. This is how check_upstream_alignment.py sat in + # .agents/hooks/ enforcing nothing while a PR opened 21 commits behind its + # base. Scan every place a hook can legitimately be invoked from. + config_callers = "" + for caller in (repo / ".pre-commit-config.yaml", + repo / "scripts" / "verify_and_create_pr.sh", + repo / "scripts" / "sync_agentic_configs.sh", + repo / ".claude" / "settings.json", + repo / ".agents" / "hooks.json", + repo / ".github" / "hooks" / "copilot-hooks.json", + repo / "opencode.json"): + if caller.exists(): + config_callers += caller.read_text(errors="ignore") + + # A hook invoked by a sibling hook is wired too (post-edit-linter.sh calls + # suggest-skills.py), so sibling sources count as callers — but a script + # must not vouch for itself, hence the per-script exclusion below. + hook_sources = {} + if hooks_dir.is_dir(): + for path in sorted(hooks_dir.iterdir()): + if path.is_file() and path.suffix in (".py", ".sh"): + hook_sources[path.name] = path.read_text(errors="ignore") + + # The auditor is an entry point, run by hand and by the PR gate's + # instructions rather than referenced from a config. + ENTRY_POINTS = {"audit_quad_agent_parity.py"} + + for script in sorted(hook_sources): + if script in ENTRY_POINTS: + continue + siblings = "".join(src for name, src in hook_sources.items() + if name != script) + stem = script.rsplit(".", 1)[0] + if script not in config_callers + siblings and stem not in config_callers + siblings: + print(f"[X] {script} is installed but referenced by no " + "config or script — it enforces nothing") + passed = False + + if not (repo / "opencode.json").exists(): + print("[X] OpenCode: opencode.json missing") + passed = False + else: + print("[ok] OpenCode: configured (no command-hook mechanism; " + "its enforcement floor is pre-commit)") + + for required in (".pre-commit-config.yaml", ".aiignore", "AGENTS.md"): + if not (repo / required).exists(): + print(f"[X] {required} is missing") + passed = False + + passed &= audit_command_paths(repo) + passed &= audit_rule_mirrors(repo) + passed &= audit_copilot_rule_index(repo) + passed &= audit_structured_files(repo) + + print("==> Parity audit " + ("PASSED" if passed else "FAILED")) + return bool(passed) + + +def main(): + target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(".") + if not audit_parity(target): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.agents/hooks/block-absolute-paths.py b/.agents/hooks/block-absolute-paths.py new file mode 100755 index 0000000..6a96fb6 --- /dev/null +++ b/.agents/hooks/block-absolute-paths.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +import os +import subprocess +import sys + +HOOKS_DIR = os.path.abspath(os.path.dirname(__file__)) +if HOOKS_DIR not in sys.path: + sys.path.insert(0, HOOKS_DIR) +from path_scanner import PathScanner # noqa: E402 + +# Hook may be invoked from .agents/ (Antigravity sets cwd to the hooks.json directory) +_ROOT = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True +).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + + +def main(): + if PathScanner.scan_staged(): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.agents/hooks/block-secrets.py b/.agents/hooks/block-secrets.py new file mode 100755 index 0000000..bf1219a --- /dev/null +++ b/.agents/hooks/block-secrets.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +import os +import subprocess +import sys + +HOOKS_DIR = os.path.abspath(os.path.dirname(__file__)) +if HOOKS_DIR not in sys.path: + sys.path.insert(0, HOOKS_DIR) +from secret_scanner import SecretScanner # noqa: E402 + +# Hook may be invoked from .agents/ (Antigravity sets cwd to the hooks.json directory) +_ROOT = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True +).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + + +def main(): + if SecretScanner.scan_staged(): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.agents/hooks/block_name_matched_kill.py b/.agents/hooks/block_name_matched_kill.py new file mode 100755 index 0000000..3c3c23d --- /dev/null +++ b/.agents/hooks/block_name_matched_kill.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +""" +block_name_matched_kill.py + +Refuses to kill a process chosen by name pattern. +""" + +import json +import re +import shlex +import sys + +BY_NAME = ("pkill", "killall") +SUBSTITUTION = re.compile(r"\$\(|`|\$\{") + +REASON = """Refused: this kills processes selected by name, not by PID. + +Worktrees on this machine share a repository, tooling binaries, and build caches, +so a name match reaches other sessions' processes as readily as your own. + +Do this instead: + + pgrep -af # find candidates (pgrep -fl on macOS) + ls -l /proc//cwd # confirm it is YOUR worktree (Linux) + lsof -a -p -d cwd # same, on macOS + kill # signal that one, by number + +If the process is not in your worktree, it is not yours to signal.""" + + +def offending_command(command: str) -> str | None: + """Return the reason this command is refused, or None if it is fine.""" + try: + tokens = shlex.split(command) + except ValueError: + tokens = command.split() + + for index, token in enumerate(tokens): + name = token.rsplit("/", 1)[-1] + if name in BY_NAME: + return f"`{name}` selects processes by name" + if name == "kill": + rest = tokens[index + 1:] + targets = [t for t in rest if not t.startswith("-")] + if not targets: + continue + if any(SUBSTITUTION.search(t) for t in targets): + return "`kill` is being given a command substitution, not a PID" + if not all(t.isdigit() for t in targets): + return "`kill` is being given something that is not a literal PID" + return None + + +# This hook is wired into all four harnesses, and they neither deliver the tool +# call nor read a decision in the same shape. Reading only Claude's +# `tool_input.command` and always printing Antigravity's `{"decision": "deny"}` +# meant that under Copilot and Antigravity the hook could not even see the +# command: it FAILED OPEN and blocked nothing at all, while appearing wired. +# +# Extraction and emission are therefore both per-harness, and the harness is +# inferred from the payload rather than configured, so one script stays correct +# in every configuration that references it. + +def extract_command(payload: dict) -> str: + """The shell command from any supported harness's tool-call payload.""" + # Claude Code: {"tool_name": "Bash", "tool_input": {"command": ...}} + command = (payload.get("tool_input") or {}).get("command") + if command: + return command + # GitHub Copilot: {"toolName": ..., "toolArgs": {"command": ...}} + args = payload.get("toolArgs") or payload.get("tool_args") or {} + if isinstance(args, dict) and args.get("command"): + return args["command"] + # Antigravity: {"toolCall": {"args": {"command"|"CommandLine": ...}}} + call_args = ((payload.get("toolCall") or payload.get("tool_call") or {}) + .get("args") or {}) + if isinstance(call_args, dict): + for key in ("command", "CommandLine", "commandLine"): + if call_args.get(key): + return call_args[key] + return "" + + +def detect_harness(payload: dict) -> str: + if payload.get("tool_input") is not None or payload.get("tool_name"): + return "claude" + if payload.get("toolArgs") is not None or payload.get("toolName"): + return "copilot" + if payload.get("toolCall") is not None or payload.get("tool_call"): + return "antigravity" + return "claude" + + +def deny_payload(harness: str, reason: str) -> dict: + """The refusal in the schema THIS harness actually reads.""" + if harness == "claude": + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + } + if harness == "copilot": + return {"permissionDecision": "deny", "permissionDecisionReason": reason} + return {"decision": "deny", "reason": reason} + + +def main() -> int: + try: + payload = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + return 0 # never block because the hook could not parse its own input + + command = extract_command(payload) + if not command: + return 0 + + # A whole-command substitution can hide the verb; check the raw text too. + reason = offending_command(command) + if reason is None and SUBSTITUTION.search(command): + for name in BY_NAME: + if re.search(rf"\b{name}\b", command): + reason = f"`{name}` selects processes by name" + break + + if reason is None: + return 0 + + harness = detect_harness(payload) + print(json.dumps(deny_payload(harness, f"{reason}.\n\n{REASON}"))) + # Exit 2 as well: Claude Code treats a non-zero PreToolUse exit as a block + # even when it does not parse the JSON, so the refusal cannot be lost. + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/hooks/check_atomic_bisect_history.py b/.agents/hooks/check_atomic_bisect_history.py new file mode 100755 index 0000000..a568163 --- /dev/null +++ b/.agents/hooks/check_atomic_bisect_history.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +""" +check_atomic_bisect_history.py + +`git bisect` is only useful if every commit on the branch BUILDS. A tidy commit +title does not give you that; a build does. So this hook checks both: + + 1. History hygiene — no `fixup!`/`squash!`/WIP titles left before a push. + 2. Buildability — the repository's OWN build (and, if quick, test) command is + run against the committed tree, so a branch that cannot compile is caught + here rather than in the middle of a bisect three months from now. + +The build commands below were detected from this repository at bootstrap time. +Set `SKIP_BISECT_BUILD=1` to skip step 2 when you know the build is expensive. +""" + +import os +import subprocess +import sys + +# Hook may be invoked from .agents/ (Antigravity sets cwd to the hooks.json +# directory) — always operate from the repository root. +_ROOT = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True +).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + +# --- this repository's layout, discovered at bootstrap (generated) --------- +# ONE source for the folder lists. Several hooks used to carry their own +# hardcoded copies of a vendor-directory list and of a default-branch list, +# which was both duplication and wrong: a firmware repository vendors into its +# own SDK directory and protects a release branch under a project-specific +# name, and no hardcoded copy could know either. +# +# Every value below comes from the investigation the bootstrap ran against THIS +# repository — not from a default list. Re-run the bootstrap with `--update` +# after the layout changes. + +#: Trees this repository consumes but does not own. Never reformat or edit. +VENDORED_PREFIXES: tuple[str, ...] = () + +#: Branches nobody may commit to directly. Discovered from the remote's own +#: protection settings via `gh`, falling back to the detected base branch. +PROTECTED_BRANCHES: tuple[str, ...] = ( + 'main', +) + +#: The PR base for this repository, recorded once so no script has to guess. +BASE_BRANCH: str = "main" + +#: Directories holding a published interface whose docs must move with it. +INTERFACE_PREFIXES: tuple[str, ...] = () + +#: Where this repository documents that interface. +API_DOC_PATHS: tuple[str, ...] = () + +#: Sources where a raw #RRGGBB literal belongs in a theme token instead. +#: Not QML-only: React, Python UIs and stylesheets hardcode colours too. +THEMEABLE_SUFFIXES: tuple[str, ...] = ( + '.qml', + '.py', + '.css', + '.scss', + '.less', +) + +#: The theme/token definitions themselves — the one place literals belong. +THEME_DEFINITION_FILES: tuple[str, ...] = ( + 'Theme.qml', + 'theme.ts', + 'tokens.css', +) + + +def is_vendored(path: str) -> bool: + return any(path.startswith(prefix) for prefix in VENDORED_PREFIXES) + + +def is_themeable_source(path: str) -> bool: + return (path.endswith(THEMEABLE_SUFFIXES) + and not any(name in path for name in THEME_DEFINITION_FILES)) + + +#: This repository's build entry points, detected at bootstrap. +BUILD_COMMANDS: tuple[str, ...] = ( + 'if [ -d build/Release ]; then cmake --build build/Release; elif [ -d build ]; then cmake --build build; fi', +) +#: Its test runner, if the investigation found one. +TEST_COMMANDS: tuple[str, ...] = () + +BUILD_TIMEOUT_SECONDS = 900 + + +def get_base_branch() -> str: + """The PR base, recorded once at bootstrap. Candidate-list guessing was + blind to any base whose name is not one of three defaults.""" + for cand in (BASE_BRANCH,) + PROTECTED_BRANCHES: + try: + subprocess.check_output( + ["git", "rev-parse", "--verify", f"origin/{cand}"], + stderr=subprocess.DEVNULL, + ) + return f"origin/{cand}" + except (subprocess.CalledProcessError, OSError): + continue + return "HEAD~1" + + +def check_titles(base_branch: str) -> None: + res = subprocess.run( + ["git", "log", f"{base_branch}..HEAD", "--oneline"], + capture_output=True, + text=True, + ) + if res.returncode != 0: + return + + commits = [line.strip() for line in res.stdout.splitlines() if line.strip()] + fixup_keywords = ["fixup!", "squash!", "wip", "work in progress", "temp"] + dirty = [c for c in commits if any(kw in c.lower() for kw in fixup_keywords)] + if dirty: + print("HISTORY WARNING: Temporary/WIP commits detected before push:") + for commit in dirty: + print(f" - {commit}") + print("Consider squashing/cleaning history (`git rebase -i`) before review.") + + +def working_tree_is_clean() -> bool: + """Only a committed tree proves anything about a bisect. Building with + uncommitted work in place would verify code that no commit contains.""" + res = subprocess.run(["git", "status", "--porcelain"], + capture_output=True, text=True) + return res.returncode == 0 and not res.stdout.strip() + + +def run_build() -> int: + if os.environ.get("SKIP_BISECT_BUILD"): + print("BISECT BUILD: skipped (SKIP_BISECT_BUILD is set).") + return 0 + if not BUILD_COMMANDS and not TEST_COMMANDS: + print("BISECT BUILD: no build or test command was detected for this " + "repository, so buildability cannot be verified here. Re-run the " + "bootstrap with --update once one exists.") + return 0 + if not working_tree_is_clean(): + print("BISECT BUILD: skipped — the working tree has uncommitted " + "changes, so a build would not describe any commit. Commit first.") + return 0 + + for command in BUILD_COMMANDS + TEST_COMMANDS: + print(f"BISECT BUILD: verifying the committed tree with `{command}` ...") + try: + res = subprocess.run(command, shell=True, + timeout=BUILD_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + print(f"BISECT BUILD WARNING: `{command}` exceeded " + f"{BUILD_TIMEOUT_SECONDS}s and was abandoned; buildability " + "is unverified for this push.") + return 0 + except OSError as exc: + print(f"BISECT BUILD WARNING: could not run `{command}`: {exc}") + return 0 + if res.returncode != 0: + print("\nBISECT SAFETY FAILURE: the committed tip of this branch " + f"does not build (`{command}` exited {res.returncode}).\n" + " Every commit you push must build on its own, or `git " + "bisect` will land on it and report a false culprit.\n" + " Fix the build and amend, or export SKIP_BISECT_BUILD=1 if " + "you are knowingly pushing a broken tip.") + return 1 + print("BISECT BUILD: the committed tree builds.") + return 0 + + +def main() -> int: + check_titles(get_base_branch()) + return run_build() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/hooks/check_complexity.py b/.agents/hooks/check_complexity.py new file mode 100755 index 0000000..ad65ee3 --- /dev/null +++ b/.agents/hooks/check_complexity.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +""" +check_complexity.py — cyclomatic complexity ratchet, scoped to what the agent +actually produced. + +Two deliberate design choices, both of which avoid defect classes that the +file-size ratchet had to be repaired for: + +1. **Only the agent's deliverables are examined.** The input is exactly the + files changed in this commit or this edit. Pre-existing complexity in code + nobody touched is not a deliverable and is never reported — a wall of + complaints about untouched code is how a check gets switched off. + +2. **git is the baseline; there is no baseline file.** For each changed file the + pre-edit content is read from HEAD and the post-edit content from the index + (or the working tree), and the two are compared function by function. That + means: + - no full-tree scan, so no slow bootstrap step on a large repository; + - no stored ceilings to drift out of sync with the code; + - no delete-and-recreate bypass, because there is no stored entry to go + stale; + - no index-versus-worktree mismatch, because both sides come from the same + source by construction. + + The trade-off, stated plainly: a rename reads as "old function gone, new + function added", so a renamed complex function must meet the threshold or + carry an explicit justification. + +Requires `lizard` (pip install lizard). It is language-agnostic and needs no +build, so one hook covers every stack this repository contains. If it is not +installed the hook says so and exits 0 — a missing optional tool must never +block a commit. + +Modes: + --staged compare HEAD against the index (pre-commit gate) + --changed [paths] compare HEAD against the working tree (agent hooks); the + optional paths restrict the check to those files, which is + what an edit hook passes so that one edit does not re-report + every other file changed on the branch + --report [paths] planning aid: current complexity of the given files + +The gate is proportional to what the change actually did: a function that gets +more complex is only a failure once the RESULT exceeds the budget. Failing on +any increase rejected a 2 -> 3 move nine points inside the budget, which taught +authors to route around the gate rather than to simplify. +""" + +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +MAX_CCN = 10 +CHECKED_SUFFIXES: tuple[str, ...] = ( + '.c', + '.cc', + '.cpp', + '.cxx', + '.h', + '.hpp', + '.js', + '.py', + '.ts', +) +EXCLUDED_PREFIXES: tuple[str, ...] = ( + '.agents/', + 'build/', + 'tests/', + 'test/', +) +ESTABLISHED_PATTERNS: list[str] = [] + +# The agent-tooling directories hold generated hooks. Analysing them would +# report this tool's own complexity as if the agent had just written it. +SELF_MANAGED_PREFIXES = (".agents/", ".claude/", ".opencode/", ".github/") + +try: + _ROOT = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True + ).stdout.strip() +except (OSError, FileNotFoundError): + # git absent from PATH: nothing to compare against, so there is nothing to + # enforce. Never fail a commit because a tool is missing. + _ROOT = "" +if _ROOT: + os.chdir(_ROOT) + + +def is_checked(path: str) -> bool: + if not path.endswith(CHECKED_SUFFIXES): + return False + if path.startswith(SELF_MANAGED_PREFIXES): + return False + return not any(path.startswith(p) for p in EXCLUDED_PREFIXES) + + +def have_lizard() -> bool: + if not _ROOT: + return False + try: + subprocess.run(["lizard", "--version"], capture_output=True, check=False) + return True + except (OSError, FileNotFoundError): + return False + + +def git_show(ref: str, path: str): + """Content of a path at a git ref, or None when it does not exist there.""" + result = subprocess.run(["git", "show", "{}:{}".format(ref, path)], + capture_output=True) + return result.stdout if result.returncode == 0 else None + + +def changed_paths(staged: bool): + args = (["diff", "--cached", "--name-only", "--diff-filter=ACMR"] if staged + else ["diff", "--name-only", "--diff-filter=ACMR", "HEAD"]) + result = subprocess.run(["git", *args], capture_output=True, text=True) + paths = result.stdout.splitlines() if result.returncode == 0 else [] + if not staged: + # A file the agent has just created is untracked, so `git diff HEAD` + # does not list it — and that is exactly the file worth checking. + untracked = subprocess.run( + ["git", "ls-files", "--others", "--exclude-standard"], + capture_output=True, text=True) + if untracked.returncode == 0: + paths += untracked.stdout.splitlines() + return [p for p in dict.fromkeys(paths) if p.strip() and is_checked(p)] + + +def complexity_of(content: bytes, suffix: str) -> dict: + """Run lizard over one blob and return {function_name: max CCN}. + + lizard reads files, not stdin, so the blob is written to a temporary file + with the original suffix — the suffix is what selects the language. + `-i -1` disables lizard's own exit-code gate: this hook owns the verdict. + """ + if content is None: + return {} + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as handle: + handle.write(content) + temp = handle.name + try: + result = subprocess.run(["lizard", "--csv", "-i", "-1", temp], + capture_output=True, text=True) + finally: + os.unlink(temp) + if result.returncode not in (0, 1): + return {} + + functions = {} + for line in result.stdout.splitlines(): + # NLOC,CCN,token,PARAM,length,location,file,name,long_name,start,end + fields = line.split(",") + if len(fields) < 8: + continue + try: + ccn = int(fields[1]) + except ValueError: + continue + name = fields[7].strip().strip('"') + if not name: + continue + # Overloads share a name; judge the worst of them. + functions[name] = max(functions.get(name, 0), ccn) + return functions + + +def guidance(name: str, ccn: int) -> str: + pattern_hint = "" + if ESTABLISHED_PATTERNS: + names = ", ".join("*" + p for p in ESTABLISHED_PATTERNS) + pattern_hint = ( + "\n 5. This repository already names its seams ({}). Extract into " + "one of\n those shapes rather than inventing a new " + "one.".format(names)) + return ( + "\n HOW TO FIX THIS (read before editing):\n" + " Do NOT satisfy this check by splitting the function at an arbitrary\n" + " point, or by moving branches into a helper that is called once. Both\n" + " move the complexity without reducing it, and reviewers will say so.\n\n" + " Reduce the number of independent paths through the code:\n" + " 1. Guard clauses — return early on the error and edge cases so the\n" + " main path stops being nested.\n" + " 2. Replace conditional with polymorphism — if the branching is on a\n" + " type or a kind, give each case its own implementation.\n" + " 3. Table or registry — a long if/elif or switch that maps a value to\n" + " an action is data, not control flow.\n" + " 4. Extract a cohesive step — pull out a named operation that makes\n" + " sense on its own and is testable on its own." + + pattern_hint + + "\n\n If this complexity is irreducible, say so explicitly in the pull\n" + " request rather than restructuring the code to game the number." + ) + + +def check(staged: bool, advisory: bool, only=None): + if not have_lizard(): + print("check_complexity: lizard is not installed; skipping. " + "Install it with: pip install lizard") + return 0 + + failures, improvements = [], [] + paths = changed_paths(staged) + if only: + # An edit hook passes the file it just wrote. Re-examining every file + # changed since HEAD on every edit reports the same findings about + # untouched files over and over, which is how output stops being read — + # and the cost grows with the length of the branch, not the edit. + wanted = {p.lstrip("./") for p in only} + paths = [p for p in paths if p in wanted] + for path in paths: + suffix = Path(path).suffix + before = complexity_of(git_show("HEAD", path), suffix) + after_blob = (git_show(":0", path) if staged + else (Path(path).read_bytes() if Path(path).is_file() else None)) + after = complexity_of(after_blob, suffix) + + for name, ccn in sorted(after.items()): + was = before.get(name) + if was is None: + if ccn > MAX_CCN: + failures.append( + "NEW FUNCTION EXCEEDS COMPLEXITY BUDGET: {}\n" + " {}: cyclomatic complexity {} | budget {}\n" + " A function written now must meet the budget outright." + .format(path, name, ccn, MAX_CCN) + guidance(name, ccn)) + elif ccn > was and ccn > MAX_CCN: + # Growth is only a defect once the RESULT is out of budget. + # This gate used to fail on any increase, so a function moving + # from 2 to 3 was rejected while sitting nine points inside the + # budget. That bought nothing and cost a restructuring detour + # on every ordinary edit. A function ALREADY over budget still + # may only shrink, which is where the ratchet earns its keep. + failures.append( + "FUNCTION GREW MORE COMPLEX: {}\n" + " {}: cyclomatic complexity {} -> {} (budget {})\n" + " This function was already over budget; this change makes\n" + " it worse. It may become simpler, never more tangled." + .format(path, name, was, ccn, MAX_CCN) + guidance(name, ccn)) + elif ccn < was: + improvements.append(" {} :: {} {} -> {}".format(path, name, was, ccn)) + + if improvements: + print("Complexity reduced by this change:") + for line in improvements: + print(line) + + if failures: + print("=" * 78) + for failure in failures: + print(failure) + print("-" * 78) + print("=" * 78) + return 0 if advisory else 1 + return 0 + + +def report(paths): + if not have_lizard(): + print("check_complexity: lizard is not installed. " + "Install it with: pip install lizard") + return + if not paths: + paths = changed_paths(staged=False) + print("Cyclomatic complexity (budget {} per function):".format(MAX_CCN)) + for path in paths: + if not is_checked(path) or not Path(path).is_file(): + continue + functions = complexity_of(Path(path).read_bytes(), Path(path).suffix) + if not functions: + continue + worst = sorted(functions.items(), key=lambda kv: -kv[1])[:5] + print(" {}".format(path)) + for name, ccn in worst: + flag = "" + if ccn > MAX_CCN: + flag = " <-- over budget; it may not get worse" + print(" {:>3} {}{}".format(ccn, name, flag)) + print("\nFunctions already over budget are grandfathered by their current\n" + "value: you may leave them alone or improve them, but a change that\n" + "increases one will be rejected. Plan the extraction before editing.") + + +def main(): + args = sys.argv[1:] + if "--report" in args: + report([a for a in args if not a.startswith("--")]) + return + staged = "--changed" not in args + advisory = "--changed" in args + explicit = [a for a in args if not a.startswith("--")] + sys.exit(check(staged=staged, advisory=advisory, only=explicit or None)) + + +if __name__ == "__main__": + main() diff --git a/.agents/hooks/check_file_size_budget.py b/.agents/hooks/check_file_size_budget.py new file mode 100755 index 0000000..0c30c6e --- /dev/null +++ b/.agents/hooks/check_file_size_budget.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +""" +check_file_size_budget.py + +File-size ratchet for an existing codebase. + +Two tiers: + * Files at or under the budget must stay at or under it. + * Files already over the budget when the ratchet was introduced are + grandfathered at their recorded size: they may shrink, never grow. When one + shrinks, its ceiling is tightened so the space cannot be reclaimed later. + +The baseline lives in .agents/file-size-baseline.json and is part of the repository. +New files are never grandfathered — a file created after the baseline must meet +the budget outright. + +Modes: + --init seed the baseline from the current tree (run once) + --staged check staged files, tighten ceilings that improved (pre-commit) + --changed check files changed against HEAD, report only (agent hooks) + --report [paths] planning aid: show remaining headroom for the given paths +""" + +import os +import subprocess +import sys +from pathlib import Path + +_HOOKS_DIR = os.path.abspath(os.path.dirname(__file__)) +if _HOOKS_DIR not in sys.path: + sys.path.insert(0, _HOOKS_DIR) +# The ratchet is three modules, split because they change for different +# reasons. Baseline persistence, measurement and merge reconciliation live in +# file_size_baseline; the decision about which files are in scope at all, and +# WHY any one of them is exempt, lives in file_size_scope. This file stays what +# it says it is: the command-line surface. +from file_size_baseline import ( # noqa: E402 + BASELINE_PATH, + BUDGET, + commit_baseline, + count_lines, + git_files, + load_baseline, + raise_ceilings_for_merge, +) +from file_size_scope import ( # noqa: E402 + exemption_for, + is_checked, +) + +ESTABLISHED_PATTERNS: list[str] = [] + +_ROOT = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True +).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + + +def decomposition_guidance(path: str) -> str: + pattern_hint = "" + if ESTABLISHED_PATTERNS: + names = ", ".join("*" + p for p in ESTABLISHED_PATTERNS) + pattern_hint = ( + "\n 5. Follow this repository's own vocabulary — it already uses " + + names + ".\n Put the extracted unit where those live" + " and register it the same way." + ) + return ( + "\n HOW TO FIX THIS (read before editing):\n" + " Do NOT satisfy this check by deleting blank lines, collapsing formatting,\n" + " inlining variables, shortening names, or moving code into comments. Those\n" + " reduce the line count without reducing complexity, and will be rejected in\n" + " review. Reduce the file by moving responsibility out of it.\n\n" + " Work through these in order:\n" + " 1. Single Responsibility — list the distinct reasons this file has to\n" + " change. Each separate reason is a separate module.\n" + " 2. Open/Closed — find the conditional or switch that grows\n" + " every time a\n" + " case is added, and replace it with polymorphism (Strategy) or a\n" + " registry/Factory, so new cases are added without editing this file.\n" + " 3. DRY — extract logic repeated here or duplicated elsewhere.\n" + " 4. Dependency direction — separate I/O, parsing, and\n" + " configuration from\n" + " the core logic so each part is independently testable." + + pattern_hint + + "\n\n If the extraction genuinely does not belong to the current\n" + " ticket, state\n" + " that explicitly in the pull request description. Do not bypass this check\n" + " silently." + ) + + +def check(paths, baseline, tighten: bool): + grandfathered = baseline.get("files", {}) + budget = baseline.get("budget", BUDGET) + extra = tuple(baseline.get("exclude", [])) + failures, tightened = [], {} + + for path in paths: + # Under --staged the file may legitimately be absent from the working + # tree (staged deletion is handled by the prune below). + if not Path(path).is_file() or not is_checked(path, extra, tighten): + continue + lines = count_lines(path, tighten) + if lines is None: + continue + ceiling = grandfathered.get(path) + + if ceiling is None: + if lines > budget: + failures.append( + "FILE SIZE BUDGET EXCEEDED: {}\n" + " current: {} lines | budget: {} lines | over by: {}\n" + " This file is not grandfathered, so it must meet " + "the budget.".format( + path, lines, budget, lines - budget) + + decomposition_guidance(path)) + elif lines > ceiling: + failures.append( + "GRANDFATHERED FILE GREW: {}\n" + " current: {} lines | ceiling: {} lines | budget: {} lines\n" + " This file was already over budget when the ratchet was introduced,\n" + " so it may shrink but never grow. This change adds {} line(s).\n" + " Put the new code in a new, properly sized module, or\n" + " bring this file\n" + " below its ceiling as part of the same change.".format( + path, lines, ceiling, budget, lines - ceiling) + + decomposition_guidance(path)) + elif lines < ceiling: + tightened[path] = lines + + # Only tighten when the run is otherwise clean. Recording a tighter ceiling + # for a commit that is about to be rejected would strand the baseline ahead + # of the tree: if the author then reverts the shrink, the file would be + # measured against a ceiling it never actually reached. + if failures: + tighten = False + + # Drop entries for files that no longer exist. Leaving them behind is a + # bypass, not a courtesy: deleting a grandfathered path and recreating it + # would otherwise let a brand-new oversized file inherit the old ceiling. + removed = [p for p in grandfathered if not Path(p).is_file()] + if removed and tighten: + for path in removed: + grandfathered.pop(path, None) + commit_baseline(baseline, grandfathered) + print("File-size ratchet: dropped {} deleted file(s) from the baseline:" + .format(len(removed))) + for path in sorted(removed): + print(" {}".format(path)) + + if tightened and tighten: + for path, lines in tightened.items(): + if lines <= budget: + grandfathered.pop(path, None) + else: + grandfathered[path] = lines + commit_baseline(baseline, grandfathered) + print("File-size ratchet tightened (this improvement is now the new ceiling):") + for path, lines in sorted(tightened.items()): + print(" {} -> {} lines{}".format( + path, lines, " (now within budget, no longer grandfathered)" + if lines <= budget else "")) + elif tightened and not failures: + print("File-size ratchet: {} file(s) shrank. Run with --staged to record " + "the tighter ceilings.".format(len(tightened))) + + return failures + + +def do_init(): + existing = load_baseline() + extra = tuple(existing.get("exclude", [])) + tracked = git_files("ls-files") + files = {} + for path in tracked: + if not Path(path).is_file() or not is_checked(path, extra): + continue + lines = count_lines(path) + if lines is not None and lines > BUDGET: + files[path] = lines + baseline = { + "budget": BUDGET, + "policy": "ratchet", + "comment": ("Files recorded here were already over budget when the ratchet " + "was introduced. They may shrink, never grow. Entries are removed " + "automatically once a file falls within budget. Do not add entries " + "by hand to excuse a new violation."), + "exclude": list(extra), + "exclude_comment": ("Path fragments excluded from the budget entirely — use " + "for vendored or generated trees this project does not " + "own. After editing, re-run with --init to reseed."), + "files": files, + } + commit_baseline(baseline, files) + print("Seeded {} with {} grandfathered file(s) over {} lines.".format( + BASELINE_PATH, len(files), BUDGET)) + if files: + worst = sorted(files.items(), key=lambda kv: -kv[1])[:5] + print("Largest:") + for path, lines in worst: + print(" {} lines {}".format(lines, path)) + if len(files) > 50: + tops = {} + for path in files: + top = path.split("/")[0] if "/" in path else "." + tops[top] = tops.get(top, 0) + 1 + ranked = sorted(tops.items(), key=lambda kv: -kv[1])[:5] + print("") + print("WARNING: {} files were grandfathered. That is high enough to suggest " + "vendored or".format(len(files))) + print("generated code is being counted as this project's own. Contributors by " + "top-level directory:") + for top, count in ranked: + print(" {}: {} file(s)".format(top, count)) + print("Review the entries; add path fragments to the \"exclude\" list in " + "{} and re-run".format(BASELINE_PATH)) + print("with --init to reseed. A baseline full of third-party code makes the " + "ratchet meaningless.") + + +def _report_whole_tree(extra): + """Every tracked file the budget covers, plus the ones it silently did not. + + Listing every exempt path here would bury the report under the whole tree's + documentation and assets, so only the CONTENT-addressed exemptions are named + individually: those are the ones invisible from a listing, and the ones that + were found excusing a first-party file. Path-shaped exemptions are + self-evident from the path, so they are counted rather than enumerated. + """ + checked, exempt = [], [] + for path in git_files("ls-files"): + verdict = exemption_for(path, extra) + if verdict is None: + checked.append(path) + elif verdict.content_addressed: + exempt.append((path, verdict)) + if exempt: + print("NOT MEASURED — {} file(s) exempted by their content, not their " + "path:".format(len(exempt))) + for path, verdict in sorted(exempt): + print(" {}: {}".format(path, verdict)) + print("") + return checked + + +def do_report(paths): + baseline = load_baseline() + grandfathered = baseline.get("files", {}) + budget = baseline.get("budget", BUDGET) + extra = tuple(baseline.get("exclude", [])) + if not paths: + paths = _report_whole_tree(extra) + print("File-size headroom (budget {} lines):".format(budget)) + for path in paths: + # Say why a file is out of scope rather than skipping it in silence. + # The old report answered "unchecked suffix" for a file whose suffix + # was fine, which is how a content-addressed exemption stayed hidden. + exempt = exemption_for(path, extra) + if exempt is not None: + print(" {}: NOT MEASURED — {}".format(path, exempt)) + continue + if not Path(path).is_file(): + print(" {}: new file — must be written within {} lines" + .format(path, budget)) + continue + lines = count_lines(path) + if lines is None: + continue + ceiling = grandfathered.get(path, budget) + state = "grandfathered" if path in grandfathered else "budget" + headroom = ceiling - lines + verdict = ("{} line(s) of headroom".format(headroom) if headroom > 0 + else "AT CEILING — any addition must be offset by an extraction") + print(" {}: {} lines / {} {} — {}".format( + path, lines, ceiling, state, verdict)) + print("\nPlan extractions before writing code: if the work does not fit the " + "headroom above,\ndecide now which responsibility moves out and where it " + "goes.") + + +def main(): + args = sys.argv[1:] + if "--init" in args: + do_init() + return + if "--report" in args: + do_report([a for a in args if not a.startswith("--")]) + return + + baseline = load_baseline() + raise_ceilings_for_merge(baseline) + if "--changed" in args: + paths = git_files("diff", "--name-only", "HEAD") + tighten = False + else: + paths = git_files("diff", "--cached", "--name-only") + tighten = True + explicit = [a for a in args if not a.startswith("--")] + if explicit: + paths, tighten = explicit, False + + failures = check(paths, baseline, tighten) + if failures: + print("=" * 78) + for failure in failures: + print(failure) + print("-" * 78) + print("=" * 78) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.agents/hooks/check_multi_intent_scope.py b/.agents/hooks/check_multi_intent_scope.py new file mode 100755 index 0000000..153e0c2 --- /dev/null +++ b/.agents/hooks/check_multi_intent_scope.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +""" +check_multi_intent_scope.py — the repository's single scope gate. + +This absorbs the old `check-relevant-scope.py`. The two hooks asked the same +question ("is this change one thing?") and answered it twice: one counted files +against an arbitrary threshold, the other clustered directories. A count is not +evidence of scope creep — a rename touches sixty files with one intent, and two +files in unrelated subsystems are two intents. So this hook does exactly two +things: + + * BLOCKS the one objective violation — staged edits to trees this repository + vendors but does not own. + * REPORTS the changed-file list, grouped by where those files live, and hands + the judgement to the agent. No threshold, no guessing. +""" + +import os +import re +import subprocess +import sys + +# Hook may be invoked from .agents/ (Antigravity sets cwd to the hooks.json +# directory) — always operate from the repository root. +_ROOT = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True +).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + +# --- this repository's layout, discovered at bootstrap (generated) --------- +# ONE source for the folder lists. Several hooks used to carry their own +# hardcoded copies of a vendor-directory list and of a default-branch list, +# which was both duplication and wrong: a firmware repository vendors into its +# own SDK directory and protects a release branch under a project-specific +# name, and no hardcoded copy could know either. +# +# Every value below comes from the investigation the bootstrap ran against THIS +# repository — not from a default list. Re-run the bootstrap with `--update` +# after the layout changes. + +#: Trees this repository consumes but does not own. Never reformat or edit. +VENDORED_PREFIXES: tuple[str, ...] = () + +#: Branches nobody may commit to directly. Discovered from the remote's own +#: protection settings via `gh`, falling back to the detected base branch. +PROTECTED_BRANCHES: tuple[str, ...] = ( + 'main', +) + +#: The PR base for this repository, recorded once so no script has to guess. +BASE_BRANCH: str = "main" + +#: Directories holding a published interface whose docs must move with it. +INTERFACE_PREFIXES: tuple[str, ...] = () + +#: Where this repository documents that interface. +API_DOC_PATHS: tuple[str, ...] = () + +#: Sources where a raw #RRGGBB literal belongs in a theme token instead. +#: Not QML-only: React, Python UIs and stylesheets hardcode colours too. +THEMEABLE_SUFFIXES: tuple[str, ...] = ( + '.qml', + '.py', + '.css', + '.scss', + '.less', +) + +#: The theme/token definitions themselves — the one place literals belong. +THEME_DEFINITION_FILES: tuple[str, ...] = ( + 'Theme.qml', + 'theme.ts', + 'tokens.css', +) + + +def is_vendored(path: str) -> bool: + return any(path.startswith(prefix) for prefix in VENDORED_PREFIXES) + + +def is_themeable_source(path: str) -> bool: + return (path.endswith(THEMEABLE_SUFFIXES) + and not any(name in path for name in THEME_DEFINITION_FILES)) + + + +def _git_lines(*args) -> list: + res = subprocess.run(["git", *args], capture_output=True, text=True) + if res.returncode != 0: + return [] + return [line.strip() for line in res.stdout.splitlines() if line.strip()] + + +def changed_files() -> list: + """Staged first — that is what a pre-commit run is about to record.""" + for args in (("diff", "--cached", "--name-only"), + ("diff", "--name-only", "HEAD")): + files = _git_lines(*args) + if files: + return files + return [] + + +def block_vendored(files: list) -> list: + return [f for f in files if is_vendored(f)] + + +def group_by_area(files: list) -> dict: + """Two path components deep: deep enough to separate `src/parser` from + `src/transport`, shallow enough not to call every file its own area.""" + areas = {} + for path in files: + parts = path.split("/") + area = "/".join(parts[:2]) if len(parts) > 1 else "(repository root)" + areas.setdefault(area, []).append(path) + return areas + + +def report(files: list) -> None: + areas = group_by_area(files) + print("\n" + "=" * 74) + print("SCOPE REPORT — {} changed file(s) across {} area(s)".format( + len(files), len(areas))) + print("=" * 74) + for area, paths in sorted(areas.items(), key=lambda kv: (-len(kv[1]), kv[0])): + print(" {} ({} file(s))".format(area, len(paths))) + for path in sorted(paths): + print(" {}".format(path)) + + jira_keys = sorted(set(re.findall(r"\b[A-Z]{2,10}-\d+\b", + "\n".join(_git_lines("log", "-n", "5", + "--oneline"))))) + if len(jira_keys) > 1: + print("\n Recent commits reference more than one ticket: {}".format( + ", ".join(jira_keys))) + print(" One pull request should serve one ticket.") + + print("\n JUDGE THIS YOURSELF — the hook deliberately does not decide:") + print(" * Does every file above serve the ONE task this branch is for?") + print(" * Is anything here an opportunistic fix or cleanup you noticed") + print(" along the way ('boy scouting')? That belongs on its own branch:") + print(" bash scripts/create_boyscout_branch_and_pr.sh " + "UC-3697 ") + print(" * Files spread over unrelated areas are a signal, not a verdict:") + print(" a rename legitimately touches many; two files in two subsystems") + print(" may still be two intents.") + print("=" * 74 + "\n") + + +def main() -> int: + files = changed_files() + if not files: + return 0 + + vendored = block_vendored(files) + if vendored: + print("SCOPE ERROR: this change edits vendored trees this repository " + "consumes but does not own:") + for path in sorted(vendored): + print(" - {}".format(path)) + print("Vendored code is updated upstream, never patched in place. " + "Unstage these files.") + return 1 + + report(files) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/hooks/check_security_downgrades.py b/.agents/hooks/check_security_downgrades.py new file mode 100755 index 0000000..f1c30e8 --- /dev/null +++ b/.agents/hooks/check_security_downgrades.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +""" +check_security_downgrades.py +---------------------------- +Deterministic pre-commit & pre-PR hook script to detect and block security +feature downgrades in staged diffs. +""" + +import re +import subprocess +import sys + +# Deliberately BROAD. A false positive costs one turn — the author looks at the +# line and says "no, that is a test fixture". A missed downgrade ships an agent's +# workaround for a failing check straight into the product. The asymmetry is not +# close, so these patterns are written to over-match on purpose. +# +# Two families: +# * `_OFF` — a named security control being set to a disabling value. +# * explicit entries — flags and calls whose mere presence is the downgrade. +_OFF = r"\s*[:=]\s*(?:false|0|none|null|off|no|\"\"|'')\b" + +_DISABLED_CONTROLS = [ + # Authentication and authorisation + ("signInRequired", "sign-in requirement disabled"), + ("needs?_authentication", "authentication requirement disabled"), + ("require[_a-z]*auth[a-z_]*", "authentication requirement disabled"), + ("auth[a-z_]*required", "authentication requirement disabled"), + ("authenticat(?:e|ed|ion)", "authentication disabled"), + ("authoriz(?:e|ed|ation)", "authorization disabled"), + ("login_?required", "login requirement disabled"), + ("check_?permissions?", "permission checking disabled"), + ("permission_?check", "permission checking disabled"), + ("enforce[_a-z]*", "an enforcement switch disabled"), + ("scope_?check|check_?scopes?", "scope checking disabled"), + ("csrf[_a-z]*|xsrf[_a-z]*", "CSRF protection disabled"), + # Transport and certificate validation + ("ssl_?verify|verify_?ssl|tls_?verify|verify_?certs?", "TLS verification disabled"), + ("verify", "verification disabled (often TLS certificate validation)"), + ("check_?hostname", "TLS hostname checking disabled"), + ("reject_?unauthorized", "unauthorized TLS peers accepted"), + ("secure|https_?only|http_?only|strict_?transport", + "a transport-security flag disabled"), + ("validate[_a-z]*|validation", "validation disabled"), + ("sanitiz|sanitis|escape[_a-z]*", "input sanitisation disabled"), + # Sandboxing / isolation + ("sandbox(?:ed|ing)?", "sandboxing disabled"), + ("web_?security|same_?origin|cors_?check", "browser origin protection disabled"), + ("read_?only|readonly", "a read-only protection removed"), + ("signature_?check|check_?signature|verify_?signature", + "signature verification disabled"), + ("encrypt(?:ed|ion)?", "encryption disabled"), +] + +SECURITY_DOWNGRADE_PATTERNS = [ + # The trailing suffix allowance matters: `SANDBOX_ENABLED = False` is the + # same downgrade as `sandbox = false`, and anchoring on the bare name missed it. + # The suffix and closing-quote allowances matter: `SANDBOX_ENABLED = False` + # and `config['CSRF_ENABLED'] = 0` are the same downgrade as `sandbox: false`, + # and anchoring on the bare name missed both. + (r"\b(?:" + name + r")[_a-z0-9]*['\"\]]*" + _OFF, description) + for name, description in _DISABLED_CONTROLS +] + [ + # Presence, not assignment: these ARE the downgrade wherever they appear. + (r"NODE_TLS_REJECT_UNAUTHORIZED\s*[:=]\s*['\"]?0", "Node TLS validation disabled"), + (r"PYTHONHTTPSVERIFY\s*[:=]\s*['\"]?0", "Python HTTPS validation disabled"), + (r"curl[^\n]*\s(?:-k|--insecure)\b", "curl invoked with certificate checks off"), + (r"(?:wget)[^\n]*--no-check-certificate", "wget certificate checking disabled"), + (r"ssl\._create_unverified_context", "unverified SSL context created"), + (r"InsecureRequestWarning|disable_warnings", "TLS warnings suppressed"), + (r"CERT_NONE", "certificate validation set to CERT_NONE"), + (r"--no-verify\b|--no-gpg-sign\b", "a commit/push verification step bypassed"), + (r"SKIP\s*=\s*\S+.*(?:secret|talisman|security|audit)", + "a security pre-commit hook added to SKIP"), + (r"(?:#|//)\s*(?:nosec|noqa:\s*S\d|bandit:\s*skip|semgrep-disable|" + r"trivy:ignore|codeql\[[^\]]*\])", "a security linter suppressed inline"), + (r"eslint-disable[^\n]*security", "an ESLint security rule disabled"), + (r"strict\s*[:=]\s*false", "a strict-mode switch disabled"), + (r"trust[_a-z]*\s*[:=]\s*true", "a trust-everything switch enabled"), + (r"allow[_a-z]*(?:insecure|unsafe|any|all)[a-z_]*\s*[:=]\s*true", + "an allow-insecure switch enabled"), + (r"(?:chmod\s+(?:777|a\+rwx))", "world-writable permissions granted"), +] + +# Second tier: patterns that are frequently legitimate but are also exactly how +# a weakening arrives. Blocking on these would make ordinary work impossible, so +# they are REPORTED and the agent judges them in the same turn. Silence would be +# the worse error; a hard failure would be the more annoying one. +ADVISORY_PATTERNS = [ + (r"@ts-(?:ignore|nocheck)", "TypeScript checking suppressed"), + (r"dangerouslySetInnerHTML|\.innerHTML\s*=", "raw HTML injection sink"), + (r"shell\s*=\s*True", "subprocess invoked through a shell"), + (r"\beval\s*\(|\bexec\s*\(|new\s+Function\s*\(", "dynamic code execution"), + (r"pickle\.loads?\s*\(|yaml\.load\s*\((?![^)]*Safe)", "unsafe deserialisation"), + (r"subprocess\.[a-z_]+\([^)]*\+", "subprocess argument built by concatenation"), + (r"(?:SELECT|INSERT|UPDATE|DELETE)\b[^\n]*(?:%s|\+\s*[a-z_]|\bf\")", + "SQL assembled by string building rather than parameterised"), + (r"\ballow_?origins?\s*[:=]\s*\[?\s*['\"]\*", "CORS opened to any origin"), +] + + +# --- self-exemption, by exact identity (generated from one source) --------- +# A guard's own source is the one place in the repository where the pattern it +# forbids MUST appear as a literal: a regex that matches a leaked private key +# necessarily contains a description of a leaked private key. Scanning the +# guards made a freshly bootstrapped repository unable to commit itself. +# +# The exemption is deliberately by EXACT PATH, not by directory prefix. +# Exempting `.agents/hooks/` wholesale would create a blind spot big enough to +# hide a real credential in — the precise failure these guards exist to +# prevent. Every other file under `.agents/`, including new hooks, is scanned +# at full strength, and the fire-proofing fixtures are assembled at runtime +# (see `verify_hooks_fire.py`) rather than exempted. +SELF_EXEMPT_FILES = frozenset({ + ".agents/hooks/secret_scanner.py", + ".agents/hooks/path_scanner.py", + ".agents/hooks/block-secrets.py", + ".agents/hooks/block-absolute-paths.py", + ".agents/hooks/check_security_downgrades.py", + ".agents/hooks/pretool_guard.py", + ".agents/hooks/run_adversarial_audit.py", + ".agents/hooks/verify_hooks_fire.py", + "scripts/check_security_downgrades.py", + "scripts/run_adversarial_audit.py", +}) + + +def _normalise_diff_path(path: str) -> str: + """Strip the `a/` or `b/` prefix git puts on diff headers.""" + path = path.strip() + if path.startswith(("a/", "b/")): + path = path[2:] + return path + + +def _is_self_exempt(path: str) -> bool: + return _normalise_diff_path(path) in SELF_EXEMPT_FILES + + +def get_staged_diff() -> str: + try: + res = subprocess.run( + ["git", "diff", "--cached", "-U0"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True + ) + return res.stdout + except Exception: + return "" + + +def main(): + diff = get_staged_diff() + if not diff: + sys.exit(0) + + added = [] # (path, added line) + current = "?" + skip_file = False + for line in diff.splitlines(): + if line.startswith("+++ "): + current = line[4:].strip() + skip_file = _is_self_exempt(current) + continue + if skip_file: + continue + if line.startswith("+"): + added.append((current, line[1:])) + + violations, advisories = [], [] + for path, text in added: + for pattern, description in SECURITY_DOWNGRADE_PATTERNS: + if re.search(pattern, text, re.IGNORECASE): + violations.append(f" ❌ {description}\n {path}: {text.strip()}") + break + for pattern, description in ADVISORY_PATTERNS: + if re.search(pattern, text, re.IGNORECASE): + advisories.append( + f" ⚠️ {description}\n {path}: {text.strip()}") + break + + if advisories: + print("\n🔍 SECURITY REVIEW REQUESTED (advisory — not a failure):") + for advisory in advisories: + print(advisory) + print("\nThese patterns are often legitimate. Judge each one now, in this " + "turn, and say in the commit or PR why it is safe here.\n") + + if violations: + print("\n🔒 SECURITY GUARD VIOLATION DETECTED:") + for violation in violations: + print(violation) + print("\nAI agents are strictly forbidden from disabling security or " + "authentication features to bypass test/verification roadblocks.") + print("These patterns are deliberately broad: if this is a false positive, " + "say so explicitly rather than rewording the line to slip past the " + "check. Otherwise revert the downgrade and fix the underlying " + "configuration or test issue properly.\n") + sys.exit(1) + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.agents/hooks/check_upstream_alignment.py b/.agents/hooks/check_upstream_alignment.py new file mode 100755 index 0000000..251f0e4 --- /dev/null +++ b/.agents/hooks/check_upstream_alignment.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +check_upstream_alignment.py +--------------------------- +Deterministic pre-PR check verifying that the local branch is up-to-date with +its base branch. +""" + +import os +import subprocess +import sys + +_ROOT = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True +).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + +# --- this repository's layout, discovered at bootstrap (generated) --------- +# ONE source for the folder lists. Several hooks used to carry their own +# hardcoded copies of a vendor-directory list and of a default-branch list, +# which was both duplication and wrong: a firmware repository vendors into its +# own SDK directory and protects a release branch under a project-specific +# name, and no hardcoded copy could know either. +# +# Every value below comes from the investigation the bootstrap ran against THIS +# repository — not from a default list. Re-run the bootstrap with `--update` +# after the layout changes. + +#: Trees this repository consumes but does not own. Never reformat or edit. +VENDORED_PREFIXES: tuple[str, ...] = () + +#: Branches nobody may commit to directly. Discovered from the remote's own +#: protection settings via `gh`, falling back to the detected base branch. +PROTECTED_BRANCHES: tuple[str, ...] = ( + 'main', +) + +#: The PR base for this repository, recorded once so no script has to guess. +BASE_BRANCH: str = "main" + +#: Directories holding a published interface whose docs must move with it. +INTERFACE_PREFIXES: tuple[str, ...] = () + +#: Where this repository documents that interface. +API_DOC_PATHS: tuple[str, ...] = () + +#: Sources where a raw #RRGGBB literal belongs in a theme token instead. +#: Not QML-only: React, Python UIs and stylesheets hardcode colours too. +THEMEABLE_SUFFIXES: tuple[str, ...] = ( + '.qml', + '.py', + '.css', + '.scss', + '.less', +) + +#: The theme/token definitions themselves — the one place literals belong. +THEME_DEFINITION_FILES: tuple[str, ...] = ( + 'Theme.qml', + 'theme.ts', + 'tokens.css', +) + + +def is_vendored(path: str) -> bool: + return any(path.startswith(prefix) for prefix in VENDORED_PREFIXES) + + +def is_themeable_source(path: str) -> bool: + return (path.endswith(THEMEABLE_SUFFIXES) + and not any(name in path for name in THEME_DEFINITION_FILES)) + + + +def run_cmd(cmd, timeout=5) -> str: + try: + res = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=timeout, + check=True + ) + return res.stdout.strip() + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError): + return "" + + +def main(): + # Detect current branch + current_branch = run_cmd(["git", "branch", "--show-current"]) + if not current_branch or current_branch in PROTECTED_BRANCHES: + sys.exit(0) + + # Fetch origin silently with short timeout + run_cmd(["git", "fetch", "origin"], timeout=8) + + # THE base branch, not a guess from a candidate list. Ask git what this + # branch actually tracks first — in a stacked-PR workflow the base is the + # branch below, not the repository's default branch. Only then fall back to + # the base the bootstrap recorded. + upstream = run_cmd(["git", "rev-parse", "--abbrev-ref", + "--symbolic-full-name", "@{upstream}"]) + base_branch = "" + if upstream and upstream != current_branch and not upstream.endswith( + "/" + current_branch): + base_branch = upstream + if not base_branch and run_cmd( + ["git", "rev-parse", "--verify", f"origin/{BASE_BRANCH}"]): + base_branch = f"origin/{BASE_BRANCH}" + + if not base_branch: + print(f"⚠️ BRANCH ALIGNMENT: base branch 'origin/{BASE_BRANCH}' is not " + "available locally; run `git fetch origin` and re-run.") + sys.exit(0) + + # Check how many commits current branch is behind base_branch + behind_count = run_cmd(["git", "rev-list", "--count", f"HEAD..{base_branch}"]) + if behind_count and behind_count.isdigit() and int(behind_count) > 0: + print(f"\n⚠️ BRANCH ALIGNMENT WARNING: current branch " + f"'{current_branch}' is {behind_count} commit(s) behind " + f"'{base_branch}'.") + print(f" Please integrate changes from '{base_branch}' before " + "opening or updating PRs:") + print(f" git merge {base_branch} (or run /sync-base)\n") + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.agents/hooks/compile_aiignore.py b/.agents/hooks/compile_aiignore.py new file mode 100755 index 0000000..f4dfde3 --- /dev/null +++ b/.agents/hooks/compile_aiignore.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +""" +compile_aiignore.py + +Translates `.aiignore` into the exclusion mechanism each agent platform actually +honours. No platform reads `.aiignore` itself — this is what makes it real. + +Verified platform behaviour (do not "simplify" this by writing one file): + * Antigravity / ripgrep-based search -> `.ignore` (a real, honoured filename) + * Claude Code -> permissions.deny "Read(./glob)" rules + in .claude/settings.json + * OpenCode -> permission.read/glob/grep deny map + in opencode.json + * GitHub Copilot -> org-level content exclusion, applied + server side; a repo file cannot do it, + so we emit a paste-ready list + +This edits files it does not own, so two rules are absolute: + + 1. NEVER write over a file that could not be parsed. A syntax error means the + content is unknown, and overwriting it would silently delete whatever was + there — including the security hooks in .claude/settings.json. + 2. NEVER remove an entry this compiler did not create. What it generated last + time is recorded in a side manifest, and only those entries are replaced, + so a hand-written deny rule survives every recompile. + +The manifest is a side file rather than a key inside the platform configs +because those configs are schema-validated — OpenCode rejects unknown keys +inside its `lsp` object, and the same risk applies elsewhere. + +Modes: + (default) regenerate every derived target + --check exit non-zero if any derived target is out of date (pre-commit) +""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +AIIGNORE = ".aiignore" +IGNORE_FILE = ".ignore" +CLAUDE_SETTINGS = ".claude/settings.json" +OPENCODE_CONFIG = "opencode.json" +COPILOT_EXCLUSION = ".github/copilot-content-exclusion.yml" +MANIFEST = ".agents/aiignore-generated.json" + +GENERATED_HEADER = ( + "# GENERATED FROM .aiignore by .agents/hooks/compile_aiignore.py\n" + "# Do not edit this file directly — edit .aiignore and re-run the compiler.\n" +) + +_ROOT = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True +).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + + +class ConfigUnreadable(Exception): + """A file exists but could not be parsed — refuse to touch it.""" + + +def read_patterns(): + """Return (all_lines, positive_patterns). Negations stay in .ignore, where + they are meaningful, but are not turned into deny rules — a deny list has no + way to express 'except this'.""" + try: + lines = Path(AIIGNORE).read_text(encoding="utf-8").splitlines() + except OSError: + return None, [] + patterns = [] + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or stripped.startswith("!"): + continue + patterns.append(stripped) + return lines, patterns + + +def to_glob(pattern: str) -> str: + """Convert a gitignore pattern to a glob usable in deny rules. + + gitignore semantics matter here: a pattern without a leading slash matches + at ANY depth, so `*.pem` must become `**/*.pem`. Compiling it to `*.pem` + would silently protect only the repository root. + """ + raw = pattern.strip() + anchored = raw.startswith("/") + p = raw.lstrip("/").rstrip("/") + if raw.rstrip("/") != raw or raw.endswith("/"): + p = p + "/**" + if not anchored and not p.startswith("**/"): + p = "**/" + p + return p + + +def load_json(path): + """Parse a JSON file. + + A missing file is an empty starting point. A file that exists but does not + parse is a hard error: returning {} here would hand an empty config to the + renderer, and the result would be written straight over the user's file. + """ + try: + text = Path(path).read_text(encoding="utf-8") + except OSError: + return {} + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise ConfigUnreadable( + "{}: {} (line {}, column {})".format(path, exc.msg, exc.lineno, exc.colno) + ) from exc + + +def dump_json(data) -> str: + text = json.dumps(data, indent=2) + "\n" + try: + res = subprocess.run( + ["npx", "--no-install", "prettier", "--stdin-filepath", "config.json"], + input=text, + capture_output=True, + text=True, + ) + if res.returncode == 0 and res.stdout: + return res.stdout + except Exception as err: + sys.stderr.write(f"Prettier fallback: {err}\n") + return text + + +def render_ignore(lines) -> str: + return GENERATED_HEADER + "\n".join(lines) + "\n" + + +def render_claude(patterns, existing, previous): + """Replace only the deny rules this compiler generated last time.""" + settings = json.loads(json.dumps(existing)) if existing else {} + permissions = settings.setdefault("permissions", {}) + generated = ["Read(./{})".format(to_glob(p)) for p in patterns] + + stale = set(previous) + kept = [d for d in permissions.get("deny", []) if d not in stale] + for rule in generated: + if rule not in kept: + kept.append(rule) + permissions["deny"] = kept + return settings, generated + + +def render_opencode(patterns, existing, previous): + """OpenCode evaluates permission rules with LAST MATCHING RULE WINNING, so + the catch-all must come first and the deny rules last. Emitting `**: allow` + after the denies would silently cancel every one of them.""" + config = json.loads(json.dumps(existing)) if existing else {} + permission = config.setdefault("permission", {}) + rules = {to_glob(p): "deny" for p in patterns} + stale = set(previous) + + for tool in ("read", "glob", "grep"): + current = permission.get(tool) + if current is not None and not isinstance(current, dict): + # A scalar ("ask" / "deny") is a deliberate repository-wide posture. + # Replacing it with an allow-by-default map would weaken it. + print("NOTE: permission.{} is {!r}; left unchanged so the existing " + "posture is not weakened.".format(tool, current)) + continue + merged = {"**": "allow"} + if isinstance(current, dict): + merged.update({k: v for k, v in current.items() + if k not in rules and k != "**" and k not in stale}) + merged.update(rules) + permission[tool] = merged + return config, sorted(rules) + + +def render_copilot(patterns) -> str: + entries = "\n".join(' - "/{}"'.format(to_glob(p)) for p in patterns) + return ( + "# GENERATED FROM .aiignore by .agents/hooks/compile_aiignore.py\n" + "#\n" + "# GitHub Copilot content exclusion is applied SERVER SIDE and cannot be\n" + "# configured by a file in the repository. Paste the block below into:\n" + "# GitHub -> Organization (or Repository) Settings -> Copilot ->\n" + "# Content exclusion\n" + "#\n" + "# Until that is done, Copilot will still read these paths.\n" + "\n" + '"*":\n' + entries + "\n" + ) + + +def main(): + check_only = "--check" in sys.argv + lines, patterns = read_patterns() + if lines is None: + if not check_only: + print("No {} found; nothing to compile.".format(AIIGNORE)) + return + + try: + manifest = load_json(MANIFEST) + claude_settings, claude_generated = render_claude( + patterns, load_json(CLAUDE_SETTINGS), manifest.get("claude_deny", [])) + opencode_config, opencode_generated = render_opencode( + patterns, load_json(OPENCODE_CONFIG), manifest.get("opencode_rules", [])) + except ConfigUnreadable as exc: + print("=" * 78) + print("AI EXCLUSION COMPILER ABORTED — a config file could not be parsed") + print(" {}".format(exc)) + print("") + print(" Nothing was written. That file is left exactly as it is, because") + print(" overwriting a config we cannot read would delete whatever it") + print(" contains, including the agent hooks.") + print(" Fix the JSON syntax, then re-run.") + print("=" * 78) + sys.exit(1) + + targets = { + IGNORE_FILE: render_ignore(lines), + CLAUDE_SETTINGS: dump_json(claude_settings), + OPENCODE_CONFIG: dump_json(opencode_config), + COPILOT_EXCLUSION: render_copilot(patterns), + MANIFEST: dump_json({ + "comment": ("Records what compile_aiignore.py generated, so the next " + "run replaces only its own entries and leaves " + "hand-written rules alone. Do not edit."), + "claude_deny": claude_generated, + "opencode_rules": opencode_generated, + }), + } + + stale = [] + for path, content in targets.items(): + try: + current = Path(path).read_text(encoding="utf-8") + except OSError: + current = None + if current == content: + continue + stale.append(path) + if not check_only: + Path(path).parent.mkdir(parents=True, exist_ok=True) + Path(path).write_text(content, encoding="utf-8") + + if check_only and stale: + print("=" * 78) + print("AI EXCLUSION OUT OF DATE") + print(" .aiignore has changed but these derived targets were not regenerated:") + for path in stale: + print(" - {}".format(path)) + print("") + print(" Run: python3 .agents/hooks/compile_aiignore.py") + print(" then stage the regenerated files.") + print("=" * 78) + sys.exit(1) + + if not check_only: + if stale: + print("Compiled {} -> {} pattern(s) into:".format(AIIGNORE, len(patterns))) + for path in stale: + print(" updated {}".format(path)) + print("NOTE: Copilot exclusion is server side — paste {} into the " + "GitHub Copilot content-exclusion settings." + .format(COPILOT_EXCLUSION)) + else: + print("AI exclusion targets already up to date.") + + +if __name__ == "__main__": + main() diff --git a/.agents/hooks/compile_rule_frontmatter.py b/.agents/hooks/compile_rule_frontmatter.py new file mode 100755 index 0000000..2dae1f0 --- /dev/null +++ b/.agents/hooks/compile_rule_frontmatter.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +""" +compile_rule_frontmatter.py — the rule-band compiler. + +The numeric prefix of a rule file IS its load-tier declaration: + + 01-14 always_on managed (regenerated by the bootstrap) + 15-19 always_on repo-custom (never regenerated) + 20-34 glob managed — MUST declare `paths:` + 35-39 glob repo-custom — MUST declare `paths:` + 40-44 model_decision managed + 45-59 model_decision repo-custom + +Authored keys: `description` (all rules), `paths` (glob bands only). +Derived keys: `name` (filename), `trigger` (band), `glob` (joined paths). + +Prefixes 03 and 11 are permanently retired: they were the two historical +collision magnets, and any file carrying them marks a pre-overhaul tree. + +Two kinds of finding, kept apart on purpose: + + BLOCKING decidable from the text — a missing band, a duplicate prefix, a + glob rule with no paths. These exit 1. + ADVISORY heuristics, in `rule_advisories.py` — description quality, unfilled + placeholders, unresolvable skills, enforcement claims. These print + and never fail, because a heuristic that blocks from day one turns + its first false positive into an argument about the checker + instead of an argument about the guidance. + +Modes: + --check validate; exit 1 on any blocking violation (wired into pre-commit) + --write rewrite derived keys in place from the authored ones + --advisory report the heuristic findings only; never exits non-zero +""" + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +_HOOKS_DIR = os.path.abspath(os.path.dirname(__file__)) +if _HOOKS_DIR not in sys.path: + sys.path.insert(0, _HOOKS_DIR) +try: + from rule_advisories import advisories_for_rule, check_layering, skill_search_dirs +except ImportError: # pragma: no cover - advisories are optional, never fatal + advisories_for_rule = check_layering = skill_search_dirs = None + +_ROOT = subprocess.run(["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + +#: Files that orient an agent but must not restate a rule (see check_layering). +ORIENTATION_FILES = ("AGENTS.md", "CLAUDE.md", "GEMINI.md") + +RULES_DIR = Path(".agents/rules") +MANIFEST = Path(".agents/rules-manifest.json") +RETIRED_PREFIXES = {3, 11} + +BANDS = ( + (1, 14, "always_on", "managed"), + (15, 19, "always_on", "custom"), + (20, 34, "glob", "managed"), + (35, 39, "glob", "custom"), + (40, 44, "model_decision", "managed"), + (45, 59, "model_decision", "custom"), +) + + +def band_of(number: int): + for lo, hi, trigger, ownership in BANDS: + if lo <= number <= hi: + return trigger, ownership + return None, None + + +def parse_frontmatter(text: str): + m = re.match(r"^---\n(.*?)\n---\n", text, re.S) + if not m: + return {}, None + fm, keys = m.group(1), {} + current = None + for line in fm.splitlines(): + kv = re.match(r"^([A-Za-z_]+):\s*(.*)$", line) + if kv: + current = kv.group(1) + value = kv.group(2).strip().strip("\"'") + keys[current] = [] if value == "" else value + elif re.match(r"^\s+-\s+", line) and isinstance(keys.get(current), list): + keys[current].append(line.split("-", 1)[1].strip().strip("\"'")) + return keys, m + + +def derived_frontmatter(path: Path, keys: dict, trigger: str) -> str: + stem = re.sub(r"^\d{2}-", "", path.stem.removesuffix("-rules")) + lines = [f"name: {stem}"] + description = keys.get("description") + if description: + lines.append(f"description: {description}") + lines.append(f"trigger: {trigger}") + paths = keys.get("paths") or [] + if paths: + lines.append("glob: \"" + ",".join(paths) + "\"") + lines.append("paths:") + lines += [f" - \"{p}\"" for p in paths] + return "---\n" + "\n".join(lines) + "\n---\n" + + +def check_rule(path: Path, manifest: dict, errors: list): + m = re.match(r"^(\d{2})-", path.name) + if not m: + errors.append(f"{path.name}: no numeric prefix — every rule declares " + "its load tier through its number") + return None + number = int(m.group(1)) + if number in RETIRED_PREFIXES: + errors.append(f"{path.name}: prefix {number:02d} is permanently " + "retired; renumber into the correct band") + return None + trigger, ownership = band_of(number) + if trigger is None: + errors.append(f"{path.name}: prefix {number:02d} is outside every " + "band (01-14, 15-19, 20-34, 35-39, 40-44, 45-59)") + return None + keys, _ = parse_frontmatter(path.read_text(errors="ignore")) + if not keys.get("description"): + errors.append(f"{path.name}: missing description: — a rule without " + "one is not lazily loaded, it is undiscoverable") + paths_declared = bool(keys.get("paths")) + if trigger == "glob" and not paths_declared: + errors.append(f"{path.name}: glob-band rule declares no paths: — " + "its scope can never fire") + if trigger != "glob" and paths_declared: + errors.append(f"{path.name}: paths: declared outside the glob band — " + "the scope can never be consulted") + if ownership == "managed" and manifest and path.name not in manifest: + errors.append( + f"{path.name}: hand-authored file in MANAGED band space " + f"({trigger} managed). It will be overwritten or deleted by the " + "next --update. Renumber to 15-19, 35-39 or 45-59.") + return number, trigger, keys + + +def report_advisories() -> None: + """Print the heuristic findings. Never fails: see the module docstring.""" + if advisories_for_rule is None: + return + search_dirs = skill_search_dirs() + findings = [] + for path in sorted(RULES_DIR.glob("*.md")): + if path.is_symlink(): + continue + text = path.read_text(errors="ignore") + keys, match = parse_frontmatter(text) + body = text[match.end():] if match else text + stem = re.sub(r"^\d{2}-", "", path.stem.removesuffix("-rules")) + description = keys.get("description") + if not isinstance(description, str): + description = "" + findings += [(path, msg) for msg in + advisories_for_rule(stem, description, body, search_dirs)] + orientation = [Path(name) for name in ORIENTATION_FILES] + findings += check_layering(orientation) + if not findings: + return + print("Rule advisories ({}) — reported, not blocking:".format(len(findings))) + for path, message in findings: + print(" {}: {}".format(path, message)) + + +def main(): + if "--advisory" in sys.argv: + if RULES_DIR.is_dir(): + report_advisories() + return 0 + mode = "--check" if "--check" in sys.argv else ( + "--write" if "--write" in sys.argv else "--check") + if not RULES_DIR.is_dir(): + return 0 + manifest = {} + if MANIFEST.exists(): + try: + manifest = json.loads(MANIFEST.read_text()).get("rules", {}) + except (ValueError, OSError): + pass + errors, seen = [], {} + for path in sorted(RULES_DIR.glob("*.md")): + if path.is_symlink(): + continue + result = check_rule(path, manifest, errors) + if not result: + continue + number, trigger, keys = result + if number in seen: + errors.append(f"{path.name}: duplicate numeric prefix with " + f"{seen[number]} — no multi-file slots exist") + seen[number] = path.name + if mode == "--write": + text = path.read_text(errors="ignore") + keys, m = parse_frontmatter(text) + new_fm = derived_frontmatter(path, keys, trigger) + body = text[m.end():] if m else text + path.write_text(new_fm + body) + if errors: + print("Rule frontmatter compile FAILED:", file=sys.stderr) + for err in errors: + print(f" - {err}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/hooks/copilot_index.py b/.agents/hooks/copilot_index.py new file mode 100755 index 0000000..d49b338 --- /dev/null +++ b/.agents/hooks/copilot_index.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +""" +copilot_index.py + +The Copilot rule index — read, rebuild and verify — with `.agents/rules/` as +the single source of truth. + +Copilot has no rule-directory convention of its own: `.github/copilot-instructions.md` +is the only place it learns which rules exist, and it learns them from a list of +markdown links. That list was rendered once, at bootstrap time, from the rules +*that run produced* — so a repo-owned rule (bands 15-19/35-39/45-59) or any rule +added afterwards never reached Copilot at all, and nothing noticed, because the +parity audit only ever checked that a listed rule exists and never that an +existing rule is listed. + +This module owns both directions: + + * `listed_rules()` / `canonical_rules()` — the two sets the audit compares. + * `refresh_text()` — rebuild the index block from the rules on disk, so the + sync script can bring a stale file back into line without a re-bootstrap. + +Run it directly as the pre-commit gate (`--check`) or as the refresher (no +arguments). `scripts/sync_agentic_configs.sh` calls the refresher. +""" + +import re +import sys +from pathlib import Path + +INDEX_REL = ".github/copilot-instructions.md" +RULES_REL = ".agents/rules" +HEADING = "## Rule Index" + +# The link form the index template emits. Matched rather than assumed so a +# hand-widened index (extra prose after the link) still parses. +_LINK_RE = re.compile(r"\]\(\.\./\.agents/rules/([\w.-]+\.md)\)") + + +def canonical_rules(repo: Path) -> set: + """Every rule file the repository actually has. + + Symlinks are skipped: `.claude/rules` and `.opencode/rules` mirror this + directory, and a mirror that leaked back in would be counted twice. + """ + rules_dir = repo / RULES_REL + if not rules_dir.is_dir(): + return set() + return {p.name for p in rules_dir.glob("*.md") if not p.is_symlink()} + + +def listed_rules(text: str) -> set: + """Every rule the Copilot index points at.""" + return set(_LINK_RE.findall(text)) + + +def rule_label(filename: str) -> str: + return filename.removesuffix(".md").removesuffix("-rules") + + +def index_lines(filenames) -> list: + return [f"- [{rule_label(name)}](../{RULES_REL}/{name})" + for name in sorted(filenames)] + + +def _block_bounds(lines: list) -> tuple: + """(start, end) of the index list body, or (-1, -1) when there is none. + + `start` is the first line after the heading; `end` is exclusive and stops + at the first line that is neither blank nor a rule link — the "Hard + constraints" paragraph that follows the list in the generated file. + """ + try: + head = lines.index(HEADING) + except ValueError: + return -1, -1 + start = head + 1 + end = start + for i in range(start, len(lines)): + line = lines[i] + if not line.strip() or _LINK_RE.search(line): + end = i + 1 + continue + break + # Trim trailing blank lines back out of the block so the rebuild does not + # accumulate one blank line per run. + while end > start and not lines[end - 1].strip(): + end -= 1 + return start, end + + +def refresh_text(text: str, filenames) -> str: + """Return `text` with its rule index replaced by `filenames`. + + An index with no `## Rule Index` heading is a file this generator does not + recognise — a hand-written Copilot instruction sheet, for instance. Its + content is never rewritten; the section is appended instead, so Copilot + gains the rules without the repository losing its prose. + """ + lines = text.splitlines() + body = [""] + index_lines(filenames) + start, end = _block_bounds(lines) + if start < 0: + tail = lines + ([""] if lines and lines[-1].strip() else []) + return "\n".join(tail + [HEADING] + body).rstrip("\n") + "\n" + tail = lines[end:] + # One blank line separates the list from whatever follows — added only when + # the tail does not already start with one, or every run gains a line. + if tail and tail[0].strip(): + body.append("") + rebuilt = lines[:start] + body + tail + return "\n".join(rebuilt).rstrip("\n") + "\n" + + +def _default_index(filenames) -> str: + """A minimal index for a repository that has none yet. The bootstrap writes + a fuller pointer file; this exists so the refresher is never the reason a + repository has no Copilot entry point at all.""" + return refresh_text( + "\n\n" + "# GitHub Copilot Repository Instructions\n\n" + "The single source of truth for agent behavior is " + "[AGENTS.md](../AGENTS.md) plus the numbered rule files in " + "[.agents/rules/](../.agents/rules/).\n", + filenames) + + +def check(repo: Path) -> list: + """Problems with the index, as human-readable lines. Empty means healthy.""" + canonical = canonical_rules(repo) + if not canonical: + return [] + index = repo / INDEX_REL + if not index.exists(): + return [f"{INDEX_REL} is missing — Copilot has no rule index at all"] + listed = listed_rules(index.read_text(encoding="utf-8", errors="ignore")) + problems = [f"{INDEX_REL} does not list {name} — Copilot never sees this rule" + for name in sorted(canonical - listed)] + problems += [f"{INDEX_REL} links {name}, which does not exist" + for name in sorted(listed - canonical)] + return problems + + +def refresh(repo: Path) -> bool: + """Rewrite the index from `.agents/rules/`. True when the file changed.""" + canonical = canonical_rules(repo) + if not canonical: + return False + index = repo / INDEX_REL + if index.exists(): + current = index.read_text(encoding="utf-8", errors="ignore") + updated = refresh_text(current, canonical) + else: + current, updated = "", _default_index(canonical) + if updated == current: + return False + index.parent.mkdir(parents=True, exist_ok=True) + index.write_text(updated, encoding="utf-8") + return True + + +def main() -> int: + args = [a for a in sys.argv[1:] if a != "--check"] + repo = Path(args[0]) if args else Path(".") + if "--check" in sys.argv[1:]: + problems = check(repo) + for problem in problems: + print(f"[X] {problem}") + if problems: + print(" Run: python3 .agents/hooks/copilot_index.py") + return 1 + return 0 + if refresh(repo): + print(f"Refreshed {INDEX_REL} from {RULES_REL}/") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/hooks/file_size_baseline.py b/.agents/hooks/file_size_baseline.py new file mode 100755 index 0000000..315bed5 --- /dev/null +++ b/.agents/hooks/file_size_baseline.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +""" +file_size_baseline.py + +The ratchet's *state*: reading and writing .agents/file-size-baseline.json, measuring a +file, and deciding whether a merge legitimately raises a ceiling. + +Split out of check_file_size_budget.py, which had grown past the very budget it +enforces. Rule 10 applies to the tool that enforces rule 10 — and this is not a +cosmetic split: baseline persistence and merge-parent reconciliation change for +entirely different reasons than the command-line surface does. + +The third module is file_size_scope.py, which decides WHICH files the budget +covers. Reading a file's head to classify it belongs there, next to the rules +that judge what it finds — not here, where measuring is about counting lines. +""" + +import json +import subprocess +from pathlib import Path + +BUDGET = 400 +BASELINE_PATH = ".agents/file-size-baseline.json" + + +def count_lines(path: str, staged: bool = False): + """Count lines in the content that is actually being judged. + + Under --staged that is the INDEX copy, not the working tree. Selecting + paths from the index while measuring the working tree lets an oversized + file through and records a ceiling the commit never met. + """ + if staged: + result = subprocess.run(["git", "show", ":{}".format(path)], + capture_output=True) + if result.returncode == 0: + return result.stdout.count(b"\n") + ( + 0 if result.stdout.endswith(b"\n") or not result.stdout else 1) + return None + try: + with open(path, "rb") as handle: + return sum(1 for _ in handle) + except OSError: + return None + + +def load_baseline() -> dict: + try: + with open(BASELINE_PATH, encoding="utf-8") as handle: + return json.load(handle) + except (OSError, json.JSONDecodeError): + return {"budget": BUDGET, "policy": "ratchet", "files": {}} + + +def save_baseline(baseline: dict) -> None: + with open(BASELINE_PATH, "w", encoding="utf-8") as handle: + json.dump(baseline, handle, indent=2, sort_keys=True) + handle.write("\n") + + +def git_files(*args: str): + res = subprocess.run(["git", *args], capture_output=True, text=True) + if res.returncode != 0: + return [] + return [f for f in res.stdout.splitlines() if f.strip()] + + +def raise_ceilings_for_merge(baseline: dict) -> None: + """During a merge, accept growth the incoming branch already had approved. + + A baseline seeded before an upstream merge re-litigates that merge: the + incoming side legitimately grew a grandfathered file, its own PR gated that + growth, and the merge commit then fails for code this change never wrote. + So for a merge commit only, each ceiling rises to the largest size among + the merge parents. Growth introduced *by the resolution itself* still fails, + because that exceeds every parent. + """ + merge_head = Path(".git") / "MERGE_HEAD" + if not merge_head.exists(): + return + try: + parents = ["HEAD"] + merge_head.read_text().split() + except OSError: + return + raised = [] + for name, ceiling in list(baseline.get("files", {}).items()): + for rev in parents: + try: + blob = subprocess.run(["git", "show", f"{rev}:{name}"], + capture_output=True, text=True, check=True) + except (subprocess.CalledProcessError, OSError): + continue + size = len(blob.stdout.splitlines()) + if size > baseline["files"][name]: + baseline["files"][name] = size + raised.append((name, ceiling, size)) + for name, was, now in raised: + print(f"File-size ratchet: merge raises the ceiling for {name} " + f"({was} -> {now}); the incoming branch already gated that growth.") + + +def commit_baseline(baseline: dict, grandfathered: dict) -> None: + baseline["files"] = grandfathered + save_baseline(baseline) + subprocess.run(["git", "add", BASELINE_PATH], check=False) diff --git a/.agents/hooks/file_size_scope.py b/.agents/hooks/file_size_scope.py new file mode 100755 index 0000000..4730b6b --- /dev/null +++ b/.agents/hooks/file_size_scope.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +""" +file_size_scope.py + +Which files the file-size budget covers, and why any one of them is exempt. + +This is the CLASSIFICATION third of the ratchet. `file_size_baseline.py` holds +its state, `check_file_size_budget.py` its command-line surface, and this file +decides what is in scope at all. The three change for entirely different +reasons: this one when the repository's idea of "not ours to decompose" +changes, the others when the bookkeeping or the CLI does. + +WHAT WENT WRONG BEFORE (upstream TT-1399). The vendor test was a bare substring +match: any file whose first 4000 bytes contained a vendor name ANYWHERE — in a +comment, in a string, or in a constant that merely lists the names — dropped +out of the budget silently. A 500-line first-party file whose first line was a +three-letter vendor name in a comment was not measured at all, while an +identical file without it was. Worse, the ratchet's own source listed every +vendor name it knows, so once it grew past 400 lines it exempted ITSELF from +the budget it enforces. An audit of the whole tree under the old rule found +exactly one file the vendor branch had ever exempted: the gate. + +WHAT IS DIFFERENT NOW. Every content-based exemption needs TWO independent +signals, not one. A vendor name only counts on a line that is also shaped like +a copyright notice: it carries a notice word or symbol AND an ownership claim +(a year, or "all rights reserved"). A generated marker only counts inside a +directory that is plausibly generated output — which the generated branch +already required, and which is the shape the vendor rule now mirrors. + +Requiring a notice rather than a path is deliberate. Vendored code arrives +inside otherwise first-party directories, so gating the content test on path as +well would leave it able to fire almost nowhere, and EXCLUDED_PREFIXES already +covers the vendored trees that do sit at a recognisable path. A first-party +file cannot honestly carry another company's copyright notice, so the notice is +the corroboration. + +Every decision this module makes is REPORTABLE, not just a boolean: callers get +an `Exemption` naming the kind and the reason, so a file that was never +measured can be told apart from one that was measured and passed. That +distinction is the other half of the fix — the old `--report` output said +"unchecked suffix" for a `.py` file whose suffix was perfectly fine. +""" + +from __future__ import annotations + +import re +import subprocess +from dataclasses import dataclass + +HEAD_BYTES = 4000 + +# Rendered from the detected stacks. Hardcoding `(".py",)` here made the +# file-size rule completely inert in every C and C++ repository the bootstrap +# ever touched: the hook ran, found nothing it recognised, and reported success. +CHECKED_SUFFIXES: tuple[str, ...] = ( + '.cc', + '.cpp', + '.cxx', + '.h', + '.hpp', + '.py', +) +EXCLUDED_PREFIXES: tuple[str, ...] = ( + '.agents/', + 'build/', + 'tests/', + 'test/', +) +#: Matched anywhere in the path, for vendored trees nested inside the project. +EXCLUDED_FRAGMENTS: tuple[str, ...] = () + +#: Third-party names. A name alone proves nothing — see VENDOR_NOTICE_WORD. +VENDOR_NAMES: tuple[bytes, ...] = ( + b'nxp', + b'freescale', + b'arm limited', + b'st microelectronics', + b'stmicroelectronics', + b'texas instruments', + b'microchip', + b'silicon laboratories', + b'nordic semiconductor', + b'the qt company', + b'cmsis', + b'jonathan young', + b'xatlas', +) + +# The two signals a line must carry before a vendor name on it counts as a +# third-party copyright notice. Both are needed: the word on its own appears in +# ordinary prose and in identifiers, and a bare year appears in every changelog. +VENDOR_NOTICE_WORD = re.compile(rb"copyright|\(c\)|\xc2\xa9") +VENDOR_NOTICE_CLAIM = re.compile(rb"\b(19|20)\d{2}\b|all rights reserved") + +GENERATED_MARKERS = ( + b"@generated", b"do not edit", b"do not modify", b"automatically generated", + b"auto-generated", b"autogenerated", b"generated by", +) + +# A marker alone cannot exempt a file. The exemption exists for vendored and +# machine-generated trees; if one comment were sufficient, an agent that has +# read the rule could opt out of the budget in a single line — and the rule +# text tells it exactly which line. So a marker only counts inside a location +# that is plausibly not ours. +GENERATED_LOCATION = re.compile( + r"(^|/)(vendor|third_party|node_modules|generated|gen|__generated__|" + r"autogen|golden|goldens|snapshots|__snapshots__|baseline|baselines|" + r"externals?|deps|_deps|dist|build|proto|protos|sdk)(/|$)") + +# The generated trees THIS repository has, from the same detector the budget +# exclusion and the pre-commit style exclusion read. A name list can only ever +# cover the universal conventions; `src/proto_gen/` is generated output under a +# name no list would guess, and only the detector knows that. +GENERATED_PREFIXES: tuple[str, ...] = () + + +@dataclass(frozen=True) +class Exemption: + """Why a file is outside the budget, in a form a report can print.""" + + kind: str + detail: str + + def __str__(self) -> str: + return "{} — {}".format(self.kind, self.detail) + + @property + def content_addressed(self) -> bool: + """Whether this verdict came from the file's bytes rather than its path. + + A path-shaped exemption is visible in any listing; a content-addressed + one is invisible unless something says it out loud, which is the failure + this module was rewritten to prevent. + """ + return self.kind in ("vendored", "generated") + + +def read_head(path: str, staged: bool = False) -> bytes: + """The first HEAD_BYTES of the content actually being judged.""" + if staged: + result = subprocess.run(["git", "show", ":{}".format(path)], + capture_output=True) + return result.stdout[:HEAD_BYTES] if result.returncode == 0 else b"" + try: + with open(path, "rb") as handle: + return handle.read(HEAD_BYTES) + except OSError: + return b"" + + +def _unchecked_suffix(path: str, extra_excludes) -> Exemption | None: + if CHECKED_SUFFIXES and path.endswith(CHECKED_SUFFIXES): + return None + return Exemption( + "unchecked-suffix", + "the budget covers {} only".format(", ".join(CHECKED_SUFFIXES))) + + +def _excluded_prefix(path: str, extra_excludes) -> Exemption | None: + for prefix in EXCLUDED_PREFIXES: + if path.startswith(prefix): + return Exemption("excluded-path", + "under the excluded prefix '{}'".format(prefix)) + return None + + +def _excluded_fragment(path: str, extra_excludes) -> Exemption | None: + for fragment in EXCLUDED_FRAGMENTS: + if fragment and fragment in path: + return Exemption("excluded-path", + "matches the excluded fragment '{}'".format(fragment)) + return None + + +def _baseline_exclude(path: str, extra_excludes) -> Exemption | None: + for fragment in extra_excludes: + if fragment and fragment in path: + return Exemption( + "excluded-path", + "matches '{}' in the baseline's exclude list".format(fragment)) + return None + + +def _vendor_copyright(path: str, head: bytes) -> Exemption | None: + """A third-party copyright notice: self-evidently not ours to refactor. + + The name must sit on a line that IS a notice, not merely a line that names + a company. That corroboration is the whole fix; without it any mention + anywhere in the head was enough, including this module's own list. + """ + for line in head.splitlines(): + if not (VENDOR_NOTICE_WORD.search(line) and VENDOR_NOTICE_CLAIM.search(line)): + continue + for name in VENDOR_NAMES: + if name in line: + return Exemption( + "vendored", + "third-party copyright notice for '{}'".format(name.decode())) + return None + + +def _in_generated_location(path: str) -> bool: + return bool(GENERATED_LOCATION.search(path)) or path.startswith( + GENERATED_PREFIXES) + + +def _generated_output(path: str, head: bytes) -> Exemption | None: + for marker in GENERATED_MARKERS: + if marker in head and _in_generated_location(path): + return Exemption( + "generated", + "'{}' marker in a generated location".format(marker.decode())) + return None + + +# Ordered registries rather than a chain of ifs: a further exemption is an entry +# here, testable on its own, and the reporting side needs no edit at all. Path +# rules run first because they are free — the content rules need the file's +# bytes, which under --staged means a subprocess per file. +PATH_RULES = (_unchecked_suffix, _excluded_prefix, _excluded_fragment, + _baseline_exclude) +CONTENT_RULES = (_vendor_copyright, _generated_output) + + +def exemption_for(path: str, extra_excludes=(), staged: bool = False): + """Why `path` is outside the file-size budget, or None if it is covered.""" + for rule in PATH_RULES: + verdict = rule(path, extra_excludes) + if verdict is not None: + return verdict + head = read_head(path, staged).lower() + if not head: + return None + for rule in CONTENT_RULES: + verdict = rule(path, head) + if verdict is not None: + return verdict + return None + + +def is_checked(path: str, extra_excludes=(), staged: bool = False) -> bool: + """Whether the file-size budget applies to `path`.""" + return exemption_for(path, extra_excludes, staged) is None diff --git a/.agents/hooks/git-branch-guard.py b/.agents/hooks/git-branch-guard.py new file mode 100755 index 0000000..65b58d7 --- /dev/null +++ b/.agents/hooks/git-branch-guard.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Refuse commits on branches this repository actually protects. + +The protected list is NOT `["main", "master", "staging"]`. That guess was +simultaneously too wide (forbidding `master` in a repository that has none) and +too narrow (waving through a direct commit to the release branch this project +actually protects). The bootstrap asks GitHub for the real answer via +`gh api repos/{owner}/{repo}/branches` and renders it below. +""" + +import os +import re +import subprocess +import sys + +# Hook may be invoked from .agents/ (Antigravity sets cwd to the hooks.json +# directory) — always operate from the repository root. +_ROOT = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True +).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + +# --- this repository's layout, discovered at bootstrap (generated) --------- +# ONE source for the folder lists. Several hooks used to carry their own +# hardcoded copies of a vendor-directory list and of a default-branch list, +# which was both duplication and wrong: a firmware repository vendors into its +# own SDK directory and protects a release branch under a project-specific +# name, and no hardcoded copy could know either. +# +# Every value below comes from the investigation the bootstrap ran against THIS +# repository — not from a default list. Re-run the bootstrap with `--update` +# after the layout changes. + +#: Trees this repository consumes but does not own. Never reformat or edit. +VENDORED_PREFIXES: tuple[str, ...] = () + +#: Branches nobody may commit to directly. Discovered from the remote's own +#: protection settings via `gh`, falling back to the detected base branch. +PROTECTED_BRANCHES: tuple[str, ...] = ( + 'main', +) + +#: The PR base for this repository, recorded once so no script has to guess. +BASE_BRANCH: str = "main" + +#: Directories holding a published interface whose docs must move with it. +INTERFACE_PREFIXES: tuple[str, ...] = () + +#: Where this repository documents that interface. +API_DOC_PATHS: tuple[str, ...] = () + +#: Sources where a raw #RRGGBB literal belongs in a theme token instead. +#: Not QML-only: React, Python UIs and stylesheets hardcode colours too. +THEMEABLE_SUFFIXES: tuple[str, ...] = ( + '.qml', + '.py', + '.css', + '.scss', + '.less', +) + +#: The theme/token definitions themselves — the one place literals belong. +THEME_DEFINITION_FILES: tuple[str, ...] = ( + 'Theme.qml', + 'theme.ts', + 'tokens.css', +) + + +def is_vendored(path: str) -> bool: + return any(path.startswith(prefix) for prefix in VENDORED_PREFIXES) + + +def is_themeable_source(path: str) -> bool: + return (path.endswith(THEMEABLE_SUFFIXES) + and not any(name in path for name in THEME_DEFINITION_FILES)) + + +# `KEY-123-short-description`: hyphens throughout, never underscores. A branch +# named `UC-3697_short_description` reads as one token to every tool +# that splits on `-`, and the convention here is hyphenated. +BRANCH_NAME_RE = re.compile(r"^UC-\d+-[a-z0-9]+(-[a-z0-9]+)*$", re.I) + + +def check_branch(): + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + text=True, + ) + branch = result.stdout.strip() + if branch in PROTECTED_BRANCHES: + print( + f"BRANCH GUARD ERROR: '{branch}' is a protected branch in this " + f"repository (protected: {', '.join(PROTECTED_BRANCHES)}).\n" + f"Create a feature branch: git switch -c UC-3697-short-description" + ) + sys.exit(1) + + if branch and not BRANCH_NAME_RE.match(branch): + print( + f"BRANCH NAMING NOTICE: '{branch}' does not match " + f"`UC--short-description`.\n" + " Use hyphens, not underscores, between every part of the name.\n" + " This is advisory — rename with `git branch -m` if the branch is " + "not yet published." + ) + + +if __name__ == "__main__": + check_branch() diff --git a/.agents/hooks/path_scanner.py b/.agents/hooks/path_scanner.py new file mode 100755 index 0000000..90a937f --- /dev/null +++ b/.agents/hooks/path_scanner.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +import os +import re +import subprocess +import sys +from pathlib import Path, PurePosixPath + +# --- shared detection patterns (generated from ONE source) ----------------- +# Every guard that scans content imports these: the pre-flight guard +# (`pretool_guard.py`), the two pre-commit scanners (via `secret_scanner.py` +# and `path_scanner.py`), and the pre-PR gate (`run_adversarial_audit.py`). +# +# WHY THIS PARTIAL EXISTS. Downstream, four guards each carried their own copy +# under a comment claiming the copies were "generated from one source" and so +# could not drift apart. There was no source, and they HAD drifted: the pre-PR +# gate held four of the eight secret shapes — missing PKCS#8 private keys and +# the Slack, OpenAI and AWS credentials — and its GitHub regex matched only +# `ghp_` rather than the whole `gh[pousr]_` family that `gh auth` mints. A +# security policy is knowledge, so it is written once, here, and included. +# +# Do NOT re-declare a credential shape at a call site. Add it here. +# +# One caution for whoever edits this next: a pattern written as a literal token +# rather than a prefix plus a character class would make this catalogue match +# its own source, and the scanners would block the commit that adds it. None of +# the shapes below do — `[` sits outside every character class — and the +# two files this partial is inlined into (`secret_scanner.py`, +# `path_scanner.py`) are already listed in `_self_exempt.py.j2`, so a future +# literal example cannot turn the catalogue into the thing it blocks. + +SECRET_PATTERNS = [ + # PKCS#1 / OpenSSH / PGP and the PKCS#8 forms that `openssl genpkey` and + # `ssh-keygen -m PKCS8` emit by default — the latter were previously missed. + re.compile(r"-----BEGIN (?:RSA|OPENSSH|DSA|EC|PGP) PRIVATE KEY-----"), + re.compile(r"-----BEGIN(?: ENCRYPTED)? PRIVATE KEY-----"), + re.compile(r"AIzaSy[A-Za-z0-9_-]{33}"), # Google API key + # The whole `gh` token family, not just `ghp_`: `gh auth` mints `gho_`, + # `ghu_`, `ghs_` and `ghr_` too, and each is equally a live credential. + re.compile(r"gh[pousr]_[A-Za-z0-9]{36,}"), # GitHub tokens + # GitHub fine-grained PAT + re.compile(r"github_pat_[0-9a-zA-Z]{22}_[0-9a-zA-Z]{59}"), + re.compile(r"glpat-[A-Za-z0-9_-]{20}"), # GitLab PAT + re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), # Slack + re.compile(r"sk-[A-Za-z0-9]{32,}"), # OpenAI-style + re.compile(r"\bAKIA[0-9A-Z]{16}\b"), # AWS access key id + # AWS secret keys carry no prefix, so they are only recognisable next to + # the assignment that names them. + re.compile(r"(?i)aws_secret_access_key\s*=\s*['\"]?([0-9a-zA-Z/+]{40})['\"]?"), +] + +HOME_PATH_PATTERN = re.compile(r"/home/[a-zA-Z0-9_-]+/") +USERS_PATH_PATTERN = re.compile(r"/Users/[a-zA-Z0-9_-]+/") +ABSOLUTE_PATH_PATTERNS = [HOME_PATH_PATTERN, USERS_PATH_PATTERN] + + +# --- self-exemption, by exact identity (generated from one source) --------- +# A guard's own source is the one place in the repository where the pattern it +# forbids MUST appear as a literal: a regex that matches a leaked private key +# necessarily contains a description of a leaked private key. Scanning the +# guards made a freshly bootstrapped repository unable to commit itself. +# +# The exemption is deliberately by EXACT PATH, not by directory prefix. +# Exempting `.agents/hooks/` wholesale would create a blind spot big enough to +# hide a real credential in — the precise failure these guards exist to +# prevent. Every other file under `.agents/`, including new hooks, is scanned +# at full strength, and the fire-proofing fixtures are assembled at runtime +# (see `verify_hooks_fire.py`) rather than exempted. +SELF_EXEMPT_FILES = frozenset({ + ".agents/hooks/secret_scanner.py", + ".agents/hooks/path_scanner.py", + ".agents/hooks/block-secrets.py", + ".agents/hooks/block-absolute-paths.py", + ".agents/hooks/check_security_downgrades.py", + ".agents/hooks/pretool_guard.py", + ".agents/hooks/run_adversarial_audit.py", + ".agents/hooks/verify_hooks_fire.py", + "scripts/check_security_downgrades.py", + "scripts/run_adversarial_audit.py", +}) + + +def _normalise_diff_path(path: str) -> str: + """Strip the `a/` or `b/` prefix git puts on diff headers.""" + path = path.strip() + if path.startswith(("a/", "b/")): + path = path[2:] + return path + + +def _is_self_exempt(path: str) -> bool: + return _normalise_diff_path(path) in SELF_EXEMPT_FILES + + +class PathScanner: + """Absolute user-home paths, reported with the offending path itself. + + Structural parsing rather than a bare regex: `PurePosixPath` tells us + whether the token really is an absolute path under a user home, which is + what lets the message name the path a developer has to remove instead of + only the line it sat on. + + The shared catalogue (`ABSOLUTE_PATH_PATTERNS`) is still consulted, as a + cross-check in the one direction that matters. If the catalogue recognises + a shape the structural extractor missed, that is a FALSE NEGATIVE in this + scanner, and a silently unenforced rule is worse than a noisy one — so the + line is reported anyway and the mismatch is called out by name. + """ + + PATH_CANDIDATE_REGEX = re.compile( + r"(?:/(?:[a-zA-Z0-9_.-]+/)+[a-zA-Z0-9_.-]*" + r"|/home/[a-zA-Z0-9_-]+|/Users/[a-zA-Z0-9_-]+)" + ) + + @classmethod + def extract_path_candidates(cls, text: str) -> list[str]: + return cls.PATH_CANDIDATE_REGEX.findall(text) + + @classmethod + def evaluate_path_string(cls, candidate: str) -> dict: + is_absolute = False + is_forbidden_user_path = False + try: + p = PurePosixPath(candidate) + if p.is_absolute(): + is_absolute = True + parts = p.parts + if (len(parts) >= 3 and parts[0] == "/" + and parts[1] in ("home", "Users")): + is_forbidden_user_path = True + except (ValueError, TypeError): + pass + return { + "is_absolute": is_absolute, + "is_forbidden_user_path": is_forbidden_user_path, + } + + @classmethod + def scan_line(cls, line: str) -> tuple[bool, list[str]]: + """(violation_found, offending_paths) for a single line of content.""" + bad = [c for c in cls.extract_path_candidates(line) + if cls.evaluate_path_string(c)["is_forbidden_user_path"]] + if bad: + return True, bad + # Catalogue cross-check. Reaching here means the shared patterns saw a + # user-home path that structural extraction did not; report it rather + # than let it through, and name the gap so it gets closed. + for pattern in ABSOLUTE_PATH_PATTERNS: + match = pattern.search(line) + if match: + return True, ["{} (matched by the shared catalogue only — " + "PathScanner.extract_path_candidates missed it)" + .format(match.group(0))] + return False, [] + + @classmethod + def check_file_existence(cls, rel_path_str: str, root_dir: Path = None) -> bool: + root = root_dir or Path(os.getcwd()) + try: + target = (root / rel_path_str).resolve() + return target.exists() + except (OSError, ValueError): + return False + + @classmethod + def scan_staged(cls) -> bool: + diff_cmd = subprocess.run( + ["git", "diff", "--cached", "-U0"], + capture_output=True, + text=True, + check=False, + ) + if diff_cmd.returncode != 0: + return False + + found_forbidden = False + skip_file = False + for line in diff_cmd.stdout.splitlines(): + if line.startswith("+++ "): + target = line[4:].strip() + skip_file = _is_self_exempt(target) + continue + if skip_file: + continue + if line.startswith("+"): + # Routed through scan_line so the staged-commit gate and the + # pre-flight guard (which calls scan_line directly) can never + # disagree about what counts as a violation. + violated, offenders = cls.scan_line(line[1:]) + for candidate in offenders: + print( + "❌ PathScanner blocked commit: detected hardcoded " + f"user path: {candidate}", + file=sys.stderr, + ) + found_forbidden = found_forbidden or violated + + return found_forbidden diff --git a/.agents/hooks/post-edit-linter.sh b/.agents/hooks/post-edit-linter.sh new file mode 100755 index 0000000..5f7038f --- /dev/null +++ b/.agents/hooks/post-edit-linter.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Auto-generated by ultimaker-agentic-bootstrap from detected repo tooling. +# Runs the repository's own linters/formatters on files changed since HEAD. +set -uo pipefail + +# Antigravity invokes hooks with cwd set to the hooks.json directory — +# always operate from the repository root. +cd "$(git rev-parse --show-toplevel)" || exit 0 + +FAIL=0 + +# Lint what this edit actually wrote, not everything the branch has touched. +# A PostToolUse payload names the edited file on stdin; when it does, that one +# path is the deliverable. Linting `git diff --name-only HEAD` on EVERY edit +# re-reported the same findings about untouched files over and over — which is +# how hook output stops being read — and its cost grew with the length of the +# branch rather than the size of the edit. +# +# Explicit arguments win over the payload, and the whole working diff remains +# the fallback, so nothing is skipped when no path can be determined. +CHANGED="" +if [ "$#" -gt 0 ]; then + CHANGED=$(printf '%s\n' "$@") +elif [ ! -t 0 ]; then + CHANGED=$(python3 -c ' +import json, sys + +PATH_KEYS = ("file_path", "filePath", "path", "target_file", "TargetFile", "absolute_path", "notebook_path", "filename") + +def find_paths(obj): + found = [] + if isinstance(obj, dict): + for k, v in obj.items(): + if k in PATH_KEYS and isinstance(v, str) and v.strip(): + found.append(v.strip()) + else: + found.extend(find_paths(v)) + elif isinstance(obj, list): + for item in obj: + found.extend(find_paths(item)) + return found + +try: + payload = json.load(sys.stdin) + inp = payload.get("tool_input") or payload + paths = find_paths(inp) + print("\n".join(dict.fromkeys(paths))) +except Exception: + sys.exit(0) +' 2>/dev/null || true) +fi + +if [ -z "$CHANGED" ]; then + CHANGED=$(git diff --name-only HEAD 2>/dev/null | head -100) +fi + +# Paths arrive absolute from the payload; every tool below expects them +# relative to the repository root. +if [ -n "$CHANGED" ]; then + CHANGED=$(printf '%s\n' "$CHANGED" | sed "s|^$(pwd)/||" | grep -v '^$' || true) +fi + +if [ -z "$CHANGED" ]; then + exit 0 +fi + +cpp_files=$(echo "$CHANGED" | grep -E '\.(cpp|hpp|cc|cxx|c|h)$' | grep -vE '(^src/xatlas\.cpp|^include/xatlas\.h)' || true) +if [ -n "$cpp_files" ]; then + if command -v clang-format >/dev/null 2>&1; then + echo "$cpp_files" | xargs clang-format --dry-run --Werror || FAIL=1 + fi +fi + +if [ -f .agents/hooks/check_file_size_budget.py ]; then + python3 .agents/hooks/check_file_size_budget.py --changed || FAIL=1 +fi + +if [ -f .agents/hooks/check_complexity.py ]; then + # Scoped to the files this edit wrote (see CHANGED above): the gate reports + # on the deliverable, not on every file the branch has ever touched. + # shellcheck disable=SC2086 + python3 .agents/hooks/check_complexity.py --changed $CHANGED || true +fi + +# Skill suggestions are deliberately NOT invoked here. Injecting "you should +# have loaded skill X" after the edit has already landed is advice that arrives +# too late to act on. suggest-skills.py is wired as a PRE-edit hook instead, +# and only for the harnesses that need it — Claude resolves skills from the +# `paths:` front matter in its own rules. + +exit $FAIL diff --git a/.agents/hooks/pretool_guard.py b/.agents/hooks/pretool_guard.py new file mode 100755 index 0000000..418a32e --- /dev/null +++ b/.agents/hooks/pretool_guard.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +""" +pretool_guard.py — pre-flight gate for agent tool calls. + +This runs BEFORE a tool executes and inspects the *pending* call: the content +about to be written, or the command about to run. That is the whole point. The +pre-commit scanners look at the index, which by definition does not yet contain +what the agent is about to do, so they can audit but they cannot prevent. + +Three platform contracts, all verified against the platforms themselves rather +than assumed. They differ in both the input shape and how a block is signalled: + + Claude Code in : {"tool_name": "Write", "tool_input": {...}, "cwd": ...} + out: {"hookSpecificOutput": {"hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": "..."}} + NOTE: exit code 1 is explicitly NON-blocking in Claude Code — + it logs the error and proceeds. Only exit 2, or an explicit + deny decision, actually stops the call. + + Antigravity in : {"toolCall": {"name": "write_to_file", + "args": {"TargetFile": ..., "CodeContent": ...}}} + out: {"decision": "deny", "reason": "..."} + + Copilot in : {"toolName": ..., "toolArgs": {...}} + out: {"permissionDecision": "deny", + "permissionDecisionReason": "..."} + +Fail open, never closed: a payload this script cannot understand must not block +the agent's work. A guard that halts every tool call the moment a platform +changes its schema gets switched off, and then nothing is guarded at all. +""" + +import json +import os +import re +import subprocess +import sys + +HOOKS_DIR = os.path.abspath(os.path.dirname(__file__)) +if HOOKS_DIR not in sys.path: + sys.path.insert(0, HOOKS_DIR) +from secret_scanner import SecretScanner # noqa: E402 +from path_scanner import PathScanner # noqa: E402 + +FORBIDDEN_BRANCHES = ("main", "master", "staging") + +# Commands that produce history. The branch guard applies to these only: +# blocking every Bash call on a protected branch would stop `git status` and +# the agent could not even diagnose its way out. +COMMIT_COMMAND = re.compile(r"\bgit\s+(?:commit|push|merge|rebase)\b") + +# Tool names that write file content, per platform. +WRITE_TOOLS = { + "write", "edit", "multiedit", "notebookedit", # Claude Code + "write_to_file", "replace_file_content", # Antigravity + "multi_replace_file_content", "create_file", "edit_file", + "str_replace_editor", "applypatch", "apply_patch", +} +SHELL_TOOLS = {"bash", "run_command", "shell", "terminal", "runcommand"} + +# Keys that may carry a path or content, across every platform's arg naming. +PATH_KEYS = ("file_path", "filePath", "path", "TargetFile", "target_file", + "notebook_path", "filename") +CONTENT_KEYS = ("content", "CodeContent", "code_content", "new_string", + "new_str", "text", "contents", "ReplacementContent", + "new_source", "patch", "Patch") +COMMAND_KEYS = ("command", "CommandLine", "cmd", "commandLine", "script") + + +def collect_strings(value, out, depth=0): + """Antigravity's replace_file_content nests replacement chunks, so the + content is not always at a predictable top-level key.""" + if depth > 6: + return + if isinstance(value, str): + out.append(value) + elif isinstance(value, dict): + for v in value.values(): + collect_strings(v, out, depth + 1) + elif isinstance(value, list): + for v in value: + collect_strings(v, out, depth + 1) + + +def parse_request(payload): + """Normalise the three payload shapes into (platform, tool, path, blobs).""" + if "toolCall" in payload: # Antigravity + platform = "antigravity" + call = payload.get("toolCall") or {} + tool = (call.get("name") or "").lower() + args = call.get("args") or {} + elif "toolName" in payload: # Copilot + platform = "copilot" + tool = (payload.get("toolName") or "").lower() + args = payload.get("toolArgs") or {} + elif "tool_name" in payload: # Claude Code + platform = "claude" + tool = (payload.get("tool_name") or "").lower() + args = payload.get("tool_input") or {} + else: + return None, None, None, [] + + if isinstance(args, str): + try: + args = json.loads(args) + except json.JSONDecodeError: + args = {"command": args} + if not isinstance(args, dict): + args = {} + + path = next((args[k] for k in PATH_KEYS + if isinstance(args.get(k), str)), None) + + blobs = [] + if tool in SHELL_TOOLS: + for key in COMMAND_KEYS: + if isinstance(args.get(key), str): + blobs.append(args[key]) + else: + for key in CONTENT_KEYS: + if isinstance(args.get(key), str): + blobs.append(args[key]) + if not blobs and tool in WRITE_TOOLS: + # Fall back to a deep scan rather than silently checking nothing. + collect_strings(args, blobs) + return platform, tool, path, blobs + + +def current_branch(): + result = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, text=True) + return result.stdout.strip() if result.returncode == 0 else "" + + +# Generated/compiled files with an owning CLI. Hand-editing them is a silent +# trap (Talisman honours only the FIRST entry for a filename, the compiled +# exclusion targets are overwritten by the compiler, the manifest is the +# regeneration ledger), so edits are denied and redirected at the tool. +MANAGED_FILE_OWNERS = { + ".talismanrc": "python3 .agents/hooks/talisman_guard.py allow|refresh", + ".ignore": "edit .aiignore, then python3 .agents/hooks/compile_aiignore.py", + ".github/copilot-content-exclusion.yml": + "edit .aiignore, then python3 .agents/hooks/compile_aiignore.py", + ".agents/rules-manifest.json": "re-run the agentic bootstrap", + "GEMINI.md": "edit AGENTS.md, then bash scripts/sync_agentic_configs.sh", +} + + +def managed_file_violation(tool, path): + if tool in SHELL_TOOLS or not path: + return None + normalised = path.replace("\\", "/").removeprefix("./") + for managed, owner in MANAGED_FILE_OWNERS.items(): + if normalised == managed or normalised.endswith("/" + managed): + return (f"{managed} is generated state and must never be " + f"hand-edited (edits silently no-op or are overwritten). " + f"Use the owning command instead: {owner}") + return None + + +def find_violation(tool, path, blobs): + """Return a human-readable reason to block, or None to allow.""" + managed = managed_file_violation(tool, path) + if managed: + return managed + for blob in blobs: + for line in blob.splitlines(): + if SecretScanner.scan_line(line): + return ("This change contains what looks like a credential " + "(private key, API token, or passphrase). Secrets " + "must never be written into the repository.") + + # Absolute home paths are only meaningful in file content; a shell command + # legitimately references absolute paths all the time. + if tool not in SHELL_TOOLS: + for blob in blobs: + for line in blob.splitlines(): + has_viol, bad_paths = PathScanner.scan_line(line) + if has_viol: + return ("This change hardcodes an absolute local path " + "({!r}). Use a path relative to the repository " + "root, or resolve it at runtime." + .format(bad_paths[0])) + + if tool in SHELL_TOOLS: + for blob in blobs: + if COMMIT_COMMAND.search(blob): + branch = current_branch() + if branch in FORBIDDEN_BRANCHES: + return ("Refusing to run a history-changing git command on " + "'{}'. Create a feature branch named after the " + "active ticket first.".format(branch)) + return None + + +def deny(platform, reason): + if platform == "antigravity": + print(json.dumps({"decision": "deny", "reason": reason})) + elif platform == "copilot": + print(json.dumps({"permissionDecision": "deny", + "permissionDecisionReason": reason})) + else: + print(json.dumps({"hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + }})) + sys.exit(0) + + +def main(): + try: + try: + raw = sys.stdin.read() + except (OSError, ValueError): + return + if not raw.strip(): + return + try: + payload = json.loads(raw) + except json.JSONDecodeError: + return + if not isinstance(payload, dict): + return + + platform, tool, path, blobs = parse_request(payload) + if platform is None: + return + + reason = find_violation(tool, path, blobs) + if reason: + deny(platform, reason) + except Exception: + # Fail open, never closed: an unhandled exception inside the guard must + # not block the agent's work. + return + # Silence means "no opinion" on every platform: the normal permission flow + # continues. Never print an allow decision — that would override the user's + # own settings. + + +if __name__ == "__main__": + main() diff --git a/.agents/hooks/rule_advisories.py b/.agents/hooks/rule_advisories.py new file mode 100755 index 0000000..2c37b81 --- /dev/null +++ b/.agents/hooks/rule_advisories.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +""" +rule_advisories.py + +The checks on the rule set that are HEURISTICS rather than decisions. + +These are kept apart from the blocking checks in `compile_rule_frontmatter.py` +deliberately. Each one answers a question that is not decidable from the text — +"does this description earn its context load", "is this enforcement claim +true", "is this sentence a rule or orientation" — so each will occasionally be +wrong. They report; they do not block. When one has run for a cycle and its +false-positive rate is known, promoting it to the blocking registry is a +one-line change, because the seam is here. + +A heuristic that blocks from day one turns its first false positive into an +argument about the checker instead of an argument about the guidance. + +WHY THESE FOUR. An audit of one repository's 24 generated rules found eight +defects that no mechanism would have caught: a build rule shipping the literal +placeholder "(no test runner detected)"; an OWASP rule prescribing REST auth +and SQL parameterisation for a project with no network surface; a C++ rule +requiring `ctest` where no test was ever registered; a PR rule pointing at a +template path that did not exist; and a UI rule mandating a skill that resolves +nowhere on the machine. Each advisory below is the generalisation of one of +those findings. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +NORMATIVE = re.compile( + r"\b(MUST NOT|MUST|SHALL NOT|SHALL|NEVER|ALWAYS|STRICTLY FORBIDDEN" + r"|is forbidden|are forbidden|is blocked|is prohibited)\b") + +RULE_POINTER = re.compile(r"(\.agents/rules/|rules?\s+\d{2}\b|rule\s+`?\d{2})", + re.IGNORECASE) + +ENFORCEMENT = re.compile( + r"\b(blocks?|blocked|refuses?|prevents?|rejects?|forbids?)\b", re.IGNORECASE) + +HOOK_REFERENCE = re.compile(r"[\w/]*hooks?/[\w-]+\.(?:py|sh)") + +#: Placeholders a generated rule must never ship with. A rule whose build +#: command is "(no test runner detected)" costs a turn and teaches doubt about +#: every other rule in the set. +#: +#: Deliberately NOT matching a bare ``. Measured against a +#: real 23-rule set, that shape produced six findings and every one was a +#: false positive: `docker compose build `, `git merge origin/`, +#: `gh pr edit --body-file ` — ordinary command-line +#: metavariables, which are exactly how a rule SHOULD write a command the +#: reader must fill in. An advisory that fires on correct documentation is +#: noise, and noise is what stops advisories being read at all. +PLACEHOLDER = re.compile( + r"(\bTBD\b|\bTODO\b|\bFIXME\b|\(no [a-z ]+ detected\)" + r"|\bXXX\b|\bPLACEHOLDER\b|<(?:project|repo|repository|insert|your)[ _-])") + +#: Skills are named as slash commands and are usually written in backticks, so +#: only a preceding word character or slash (a URL or path) disqualifies a +#: match. A neighbouring slash or angle bracket means it was a filesystem path +#: (`/dev/shm`, `/proc//cwd`), and a trailing colon means it was a +#: namespace (`/opsx:*`) rather than a skill name. +#: +#: `*` and `.` are excluded on both sides because a glob is not a skill: +#: `**/conandata.yml` and `**/conanfile.py` were reported as the skills +#: `/conandata` and `/conanfile` against a real rule set, and a rule that +#: declares its own file scope is the single most common place a `/` appears. +SLASH_SKILL = re.compile(r"(?*.])/([a-z][a-z0-9-]{2,})\b(?![/:.])") + +#: Words that would make a description a restatement of its own file name +#: rather than a statement of when the rule matters. +_FILLER = {"rules", "rule", "standards", "guidelines", "conventions", "and", + "for", "the", "of", "in", "this", "repository"} + + +def skill_search_dirs() -> list[Path]: + """Every directory a skill name could resolve in on this machine. + + Returns empty when none exist, and the caller then skips the check: a + developer without a skills directory must not be told every skill the rules + name is missing. + """ + home = Path(os.path.expanduser("~")) + dirs = [Path(".claude/skills"), Path(".skills"), home / ".claude/skills"] + dirs.extend(sorted((home / ".claude/plugins/cache").glob("*/*/skills"))) + return [d for d in dirs if d.is_dir()] + + +def check_description_quality(name: str, description: str) -> list[str]: + """A description that only restates the name cannot earn a model's load.""" + if not description: + return [] + words = {w.strip(".,`").lower() for w in description.split()} + meaningful = words - _FILLER - set(name.split("-")) + if len(meaningful) < 4: + return ["description adds little beyond the rule name: {!r}".format( + description)] + return [] + + +def check_no_placeholders(body: str) -> list[str]: + """A generated rule that shipped its own template hole is worse than absent.""" + findings = [] + fenced = False + for number, line in enumerate(body.splitlines(), start=1): + if line.lstrip().startswith("```"): + fenced = not fenced + continue + if fenced: + continue + match = PLACEHOLDER.search(line) + if match: + findings.append( + "line {}: unfilled placeholder {!r} — the bootstrap could not " + "detect this value, so the rule states nothing actionable" + .format(number, match.group(0))) + return findings + + +def check_skills_resolve(body: str, search_dirs: list[Path]) -> list[str]: + """A rule that mandates an unavailable skill costs a turn and teaches doubt.""" + if not search_dirs: + return [] + known = set() + for parent in search_dirs: + try: + known |= {d.name for d in parent.iterdir() if d.is_dir()} + except OSError: + continue + findings = [] + for match in SLASH_SKILL.finditer(body): + skill = match.group(1) + if skill not in known: + findings.append( + "names skill `/{}`, which resolves in no skill search " + "path".format(skill)) + return sorted(set(findings)) + + +def check_enforcement_claims(body: str) -> list[str]: + """Surface every claim that a mechanism blocks something, for audit. + + Whether such a claim is true is not decidable here — a guard can be + correctly configured and still not intercept the actor the sentence is + addressed to. So this LISTS the claims rather than judging them, and asks + for each to name the actor and context it actually covers. + """ + findings = [] + for number, line in enumerate(body.splitlines(), start=1): + if not HOOK_REFERENCE.search(line) or not ENFORCEMENT.search(line): + continue + findings.append( + "line {}: enforcement claim — confirm it still holds and that it " + "names the actor it covers: {}".format(number, line.strip()[:90])) + return findings + + +def _normative_lines(text: str) -> list[tuple[int, str]]: + out = [] + fenced = False + for number, line in enumerate(text.splitlines(), start=1): + if line.lstrip().startswith("```"): + fenced = not fenced + continue + if fenced or not NORMATIVE.search(line) or RULE_POINTER.search(line): + continue + out.append((number, line.strip())) + return out + + +def check_layering(paths: list[Path]) -> list[tuple[Path, str]]: + """Orientation files point at rules; they do not restate them. + + A duplicated rule drifts from its original, and an agent that finds the + stale copy follows it. Normative language outside the rule set is therefore + reported — unless the sentence names the rule it is pointing at. + """ + findings = [] + for path in paths: + if not path.is_file(): + continue + try: + text = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + for number, line in _normative_lines(text): + findings.append(( + path, + "line {}: normative language outside the rule set — point at " + "the rule instead: {}".format(number, line[:90]))) + return findings + + +def advisories_for_rule(name: str, description: str, body: str, + search_dirs: list[Path]) -> list[str]: + """Every heuristic finding for one rule file, in report order.""" + findings = list(check_description_quality(name, description)) + findings.extend(check_no_placeholders(body)) + findings.extend(check_skills_resolve(body, search_dirs)) + findings.extend(check_enforcement_claims(body)) + return findings diff --git a/.agents/hooks/run_adversarial_audit.py b/.agents/hooks/run_adversarial_audit.py new file mode 100755 index 0000000..d236b7a --- /dev/null +++ b/.agents/hooks/run_adversarial_audit.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +""" +run_adversarial_audit.py +Automated Adversarial Security, Quality Gate & Intent Scope Audit Script. + +Scans git diff and commit history for: +1. Hardcoded absolute paths (e.g. user home directories) +2. Private keys, API tokens, credentials +3. Python error swallowing +4. Raw hex colour literals in themeable sources — NOT just QML: React, Python + UIs and stylesheets hardcode `#RRGGBB` just as readily +5. Interface changes that leave the API documentation behind +6. Edits to trees this repository vendors but does not own + +Every folder list this script uses is discovered at bootstrap and rendered in +from ONE source (`hooks/partials/_repo_layout.py.j2`). Earlier revisions carried +private hardcoded copies of an interface directory, a vendor directory and a +default-branch list — literals lifted from one firmware repository, meaningless +in every other repository the bootstrap touched. +""" + +import os +from pathlib import Path +import re +import subprocess +import sys + +HOOKS_DIR = os.path.abspath(os.path.dirname(__file__)) +if HOOKS_DIR not in sys.path: + sys.path.insert(0, HOOKS_DIR) +from secret_scanner import SecretScanner # noqa: E402 +from path_scanner import PathScanner # noqa: E402 + +# --- this repository's layout, discovered at bootstrap (generated) --------- +# ONE source for the folder lists. Several hooks used to carry their own +# hardcoded copies of a vendor-directory list and of a default-branch list, +# which was both duplication and wrong: a firmware repository vendors into its +# own SDK directory and protects a release branch under a project-specific +# name, and no hardcoded copy could know either. +# +# Every value below comes from the investigation the bootstrap ran against THIS +# repository — not from a default list. Re-run the bootstrap with `--update` +# after the layout changes. + +#: Trees this repository consumes but does not own. Never reformat or edit. +VENDORED_PREFIXES: tuple[str, ...] = () + +#: Branches nobody may commit to directly. Discovered from the remote's own +#: protection settings via `gh`, falling back to the detected base branch. +PROTECTED_BRANCHES: tuple[str, ...] = ( + 'main', +) + +#: The PR base for this repository, recorded once so no script has to guess. +BASE_BRANCH: str = "main" + +#: Directories holding a published interface whose docs must move with it. +INTERFACE_PREFIXES: tuple[str, ...] = () + +#: Where this repository documents that interface. +API_DOC_PATHS: tuple[str, ...] = () + +#: Sources where a raw #RRGGBB literal belongs in a theme token instead. +#: Not QML-only: React, Python UIs and stylesheets hardcode colours too. +THEMEABLE_SUFFIXES: tuple[str, ...] = ( + '.qml', + '.py', + '.css', + '.scss', + '.less', +) + +#: The theme/token definitions themselves — the one place literals belong. +THEME_DEFINITION_FILES: tuple[str, ...] = ( + 'Theme.qml', + 'theme.ts', + 'tokens.css', +) + + +def is_vendored(path: str) -> bool: + return any(path.startswith(prefix) for prefix in VENDORED_PREFIXES) + + +def is_themeable_source(path: str) -> bool: + return (path.endswith(THEMEABLE_SUFFIXES) + and not any(name in path for name in THEME_DEFINITION_FILES)) + + +HEX_COLOR_PATTERN = re.compile(r"#(?:[0-9a-fA-F]{3}){1,2}\b") + + +def _git_lines(*args): + result = subprocess.run(["git", *args], capture_output=True, text=True) + if result.returncode != 0: + return [] + return [f.strip() for f in result.stdout.splitlines() if f.strip()] + + +def get_git_diff_files(): + """Everything this branch changes relative to its base, plus uncommitted + work. Diffing only the working tree made this audit a no-op at pre-push + time on a clean tree — committed changes were never audited at all.""" + files = set(_git_lines("diff", "--name-only", "HEAD")) + files |= set(_git_lines("diff", "--cached", "--name-only")) + merge_base = _git_lines("merge-base", "HEAD", f"origin/{BASE_BRANCH}") + if merge_base: + files |= set(_git_lines("diff", "--name-only", f"{merge_base[0]}..HEAD")) + return sorted(files) + + +# Files where an absolute user path may legitimately appear as generated +# content rather than as something a human committed. Deliberately NOT +# `.md` wholesale: exempting every markdown file let absolute paths through +# in documentation, which the security-and-paths rule explicitly forbids, and +# documentation is exactly where a developer's home directory tends to be +# pasted from a terminal transcript. +_PATH_EXEMPT_PREFIXES = (".agents/rules/",) + + +def _path_exempt(filepath: str) -> bool: + return filepath.startswith(_PATH_EXEMPT_PREFIXES) + + +def _check_line_patterns(filepath, idx, line, content, errors): + if PathScanner.scan_line(line)[0] and not _path_exempt(filepath): + errors.append(f"❌ [ABSOLUTE PATH] {filepath}:{idx}: {line.strip()}") + + if SecretScanner.scan_line(line): + errors.append(f"❌ [SECRET DETECTED] {filepath}:{idx}") + + if filepath.endswith(".py"): + c1 = "except Exception as e:" in line + c2 = "except Exception:" in line + if c1 or c2: + w_start = max(0, idx - 1) + w_end = min(len(content), idx + 5) + window = "".join(content[w_start:w_end]) + has_exit = "sys.exit" in window or "file=sys.stderr" in window + if not has_exit: + errors.append( + f"⚠️ [PYTHON ERROR SWALLOWING] {filepath}:{idx}: " + "Exception caught without sys.exit or stderr output." + ) + + if is_themeable_source(filepath) and HEX_COLOR_PATTERN.search(line): + errors.append( + f"⚠️ [HARDCODED HEX COLOR] {filepath}:{idx}: " + f"{line.strip()} (use this project's theme tokens instead)" + ) + + +def _check_architectural_limits(files, errors): + # Only apply the API-doc coupling where those interface trees exist in + # THIS repository; a foreign repo's layout is not evidence here. + live_interfaces = [p for p in INTERFACE_PREFIXES if Path(p).is_dir()] + interface_files = [f for f in files + if any(f.startswith(p) for p in live_interfaces)] + api_doc_files = [f for f in files + if f in API_DOC_PATHS or "openapi" in f.lower()] + if interface_files and API_DOC_PATHS and not api_doc_files: + errors.append( + f"❌ [API DOC DESYNC] Interface files modified " + f"({len(interface_files)} files) but {', '.join(API_DOC_PATHS)} " + "was not updated!" + ) + + vendor_files = [f for f in files if is_vendored(f)] + if vendor_files: + errors.append( + f"❌ [VENDOR SDK MODIFIED] {len(vendor_files)} vendor files " + f"modified (e.g. {vendor_files[0]}). Vendor code must remain untouched!" + ) + + +def _audit_single_file(filepath, errors): + path = Path(filepath) + if not path.exists() or path.is_dir(): + return + + # Guards whose own source must contain the patterns they detect, plus the + # fire-proofing harness whose fixtures ARE violations by construction. + # Without this the audit failed every bootstrap PR on the bootstrap's own + # output, even on a clean tree. Exact filenames, never directory prefixes: + # a blanket `.agents/hooks/` skip would be a place to hide a real secret. + SELF_EXEMPT_NAMES = frozenset({ + "block-absolute-paths.py", "block-secrets.py", "path_scanner.py", + "secret_scanner.py", "pretool_guard.py", "check_security_downgrades.py", + "run_adversarial_audit.py", "verify_hooks_fire.py", + }) + if path.name in SELF_EXEMPT_NAMES: + return + + try: + with open(path, "r", encoding="utf-8", errors="ignore") as f: + content = f.readlines() + + for idx, line in enumerate(content, 1): + _check_line_patterns(filepath, idx, line, content, errors) + except OSError: + return + + +def audit_diff(): + sec_hook = Path(__file__).parent / "check_security_downgrades.py" + if sec_hook.exists(): + res = subprocess.run([sys.executable, str(sec_hook)]) + if res.returncode != 0: + return 1 + + files = get_git_diff_files() + if not files: + print("==> Adversarial Audit: No modified files detected in git diff.") + return 0 + + errors = [] + print("==> Running Adversarial Security, Quality & Intent Audit on " + f"{len(files)} modified files...") + + for filepath in files: + _audit_single_file(filepath, errors) + + _check_architectural_limits(files, errors) + + # Scope judgement lives in check_multi_intent_scope.py — one hook, one + # question. Delegating rather than re-deriving it here keeps the two from + # disagreeing about what "too wide" means. + scope_hook = Path(__file__).parent / "check_multi_intent_scope.py" + if scope_hook.exists(): + res = subprocess.run([sys.executable, str(scope_hook)]) + if res.returncode != 0: + return 1 + + if errors: + print("\n" + "=" * 74) + print("🚨 ADVERSARIAL AUDIT FINDINGS & INTENT EVALUATION:") + print("=" * 74) + for err in errors: + print(err) + print("=" * 74 + "\n") + crit_keys = ["ABSOLUTE PATH", "SECRET DETECTED", "API DOC DESYNC", + "VENDOR SDK MODIFIED"] + critical_errors = [e for e in errors if any(ck in e for ck in crit_keys)] + if critical_errors: + print("❌ Critical security findings must be resolved.") + return 1 + + print("✅ Adversarial Security, Quality & Intent Audit Passed Cleanly!") + return 0 + + +if __name__ == "__main__": + sys.exit(audit_diff()) diff --git a/.agents/hooks/secret_scanner.py b/.agents/hooks/secret_scanner.py new file mode 100755 index 0000000..1084347 --- /dev/null +++ b/.agents/hooks/secret_scanner.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +import os +import re +import subprocess +import sys + +# --- shared detection patterns (generated from ONE source) ----------------- +# Every guard that scans content imports these: the pre-flight guard +# (`pretool_guard.py`), the two pre-commit scanners (via `secret_scanner.py` +# and `path_scanner.py`), and the pre-PR gate (`run_adversarial_audit.py`). +# +# WHY THIS PARTIAL EXISTS. Downstream, four guards each carried their own copy +# under a comment claiming the copies were "generated from one source" and so +# could not drift apart. There was no source, and they HAD drifted: the pre-PR +# gate held four of the eight secret shapes — missing PKCS#8 private keys and +# the Slack, OpenAI and AWS credentials — and its GitHub regex matched only +# `ghp_` rather than the whole `gh[pousr]_` family that `gh auth` mints. A +# security policy is knowledge, so it is written once, here, and included. +# +# Do NOT re-declare a credential shape at a call site. Add it here. +# +# One caution for whoever edits this next: a pattern written as a literal token +# rather than a prefix plus a character class would make this catalogue match +# its own source, and the scanners would block the commit that adds it. None of +# the shapes below do — `[` sits outside every character class — and the +# two files this partial is inlined into (`secret_scanner.py`, +# `path_scanner.py`) are already listed in `_self_exempt.py.j2`, so a future +# literal example cannot turn the catalogue into the thing it blocks. + +SECRET_PATTERNS = [ + # PKCS#1 / OpenSSH / PGP and the PKCS#8 forms that `openssl genpkey` and + # `ssh-keygen -m PKCS8` emit by default — the latter were previously missed. + re.compile(r"-----BEGIN (?:RSA|OPENSSH|DSA|EC|PGP) PRIVATE KEY-----"), + re.compile(r"-----BEGIN(?: ENCRYPTED)? PRIVATE KEY-----"), + re.compile(r"AIzaSy[A-Za-z0-9_-]{33}"), # Google API key + # The whole `gh` token family, not just `ghp_`: `gh auth` mints `gho_`, + # `ghu_`, `ghs_` and `ghr_` too, and each is equally a live credential. + re.compile(r"gh[pousr]_[A-Za-z0-9]{36,}"), # GitHub tokens + # GitHub fine-grained PAT + re.compile(r"github_pat_[0-9a-zA-Z]{22}_[0-9a-zA-Z]{59}"), + re.compile(r"glpat-[A-Za-z0-9_-]{20}"), # GitLab PAT + re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), # Slack + re.compile(r"sk-[A-Za-z0-9]{32,}"), # OpenAI-style + re.compile(r"\bAKIA[0-9A-Z]{16}\b"), # AWS access key id + # AWS secret keys carry no prefix, so they are only recognisable next to + # the assignment that names them. + re.compile(r"(?i)aws_secret_access_key\s*=\s*['\"]?([0-9a-zA-Z/+]{40})['\"]?"), +] + +HOME_PATH_PATTERN = re.compile(r"/home/[a-zA-Z0-9_-]+/") +USERS_PATH_PATTERN = re.compile(r"/Users/[a-zA-Z0-9_-]+/") +ABSOLUTE_PATH_PATTERNS = [HOME_PATH_PATTERN, USERS_PATH_PATTERN] + + +# --- self-exemption, by exact identity (generated from one source) --------- +# A guard's own source is the one place in the repository where the pattern it +# forbids MUST appear as a literal: a regex that matches a leaked private key +# necessarily contains a description of a leaked private key. Scanning the +# guards made a freshly bootstrapped repository unable to commit itself. +# +# The exemption is deliberately by EXACT PATH, not by directory prefix. +# Exempting `.agents/hooks/` wholesale would create a blind spot big enough to +# hide a real credential in — the precise failure these guards exist to +# prevent. Every other file under `.agents/`, including new hooks, is scanned +# at full strength, and the fire-proofing fixtures are assembled at runtime +# (see `verify_hooks_fire.py`) rather than exempted. +SELF_EXEMPT_FILES = frozenset({ + ".agents/hooks/secret_scanner.py", + ".agents/hooks/path_scanner.py", + ".agents/hooks/block-secrets.py", + ".agents/hooks/block-absolute-paths.py", + ".agents/hooks/check_security_downgrades.py", + ".agents/hooks/pretool_guard.py", + ".agents/hooks/run_adversarial_audit.py", + ".agents/hooks/verify_hooks_fire.py", + "scripts/check_security_downgrades.py", + "scripts/run_adversarial_audit.py", +}) + + +def _normalise_diff_path(path: str) -> str: + """Strip the `a/` or `b/` prefix git puts on diff headers.""" + path = path.strip() + if path.startswith(("a/", "b/")): + path = path[2:] + return path + + +def _is_self_exempt(path: str) -> bool: + return _normalise_diff_path(path) in SELF_EXEMPT_FILES + + +class SecretScanner: + @classmethod + def scan_line(cls, line: str) -> list[str]: + findings = [] + for pattern in SECRET_PATTERNS: + match = pattern.search(line) + if match: + findings.append(match.group(0)) + return findings + + @classmethod + def scan_staged(cls) -> bool: + diff_cmd = subprocess.run( + ["git", "diff", "--cached", "-U0"], + capture_output=True, + text=True, + check=False, + ) + if diff_cmd.returncode != 0: + return False + + found_secrets = False + skip_file = False + for line in diff_cmd.stdout.splitlines(): + if line.startswith("+++ "): + target = line[4:].strip() + skip_file = _is_self_exempt(target) + continue + if skip_file: + continue + if line.startswith("+"): + findings = cls.scan_line(line[1:]) + if findings: + print( + "❌ SecretScanner blocked commit: detected hardcoded " + f"secret token: {findings[0][:8]}...", + file=sys.stderr, + ) + found_secrets = True + + return found_secrets diff --git a/.agents/hooks/suggest-skills.py b/.agents/hooks/suggest-skills.py new file mode 100755 index 0000000..86e8a00 --- /dev/null +++ b/.agents/hooks/suggest-skills.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +""" +suggest-skills.py + +Advisory hook: maps the files about to be edited to the UltiCortex skills that +cover them, so the relevant expertise is loaded BEFORE the code is written. + +Runs PRE-edit (`--pre-edit`), reading the target path out of the harness's tool +payload on stdin. Reporting after the edit told the agent it should have loaded +a skill it can no longer act on — advice arriving one turn too late. + +Not wired for Claude: Claude resolves skills from the `paths:` front matter in +.claude/rules and loads them itself, so a hook there duplicates the harness. + +Never fails a build — steering belongs in the rules; this is the reminder. +""" + +import fnmatch +import json +import os +import subprocess +import sys + +# skill -> (globs, one-line reason) +SKILL_TRIGGERS = { + 'cmake': ( + ['**/CMakeLists.txt', '**/*.cmake', '**/CMakePresets.json'], + 'Target-centric CMake, presets, and CTest wiring — avoid reinventing build logic or reaching for directory-scoped commands', + ), + 'conan-2': ( + ['**/conanfile.py', '**/conanfile.txt', '**/conandata.yml'], + 'Dependency graph, profiles, cross-compilation and packaging are Conan 2 concerns', + ), + 'cpp-pro': ( + ['**/*.cpp', '**/*.hpp', '**/*.cc', '**/*.cxx', '**/*.h'], + 'Modern C++20/23 implementation work: templates, zero-overhead abstractions, and the idioms mined into the C++ architecture rule', + ), + 'ultimaker-neoprep-development': ( + ['UvulaJS/**', '**/UvulaJS/**', '**/*UvulaJS*'], + 'Neoprep WebAssembly bindings, Three.js viewport stroke projection, and Emscripten runtime contract', + ), + 'ultimaker-cura-development': ( + ['pyUvula/**', '**/pyUvula/**', '**/*pyUvula*'], + 'Cura native C++ core and Python bindings (pyUvula) packaging and integration', + ), + 'ultimaker-cloud-development': ( + ['UvulaJS/**', '**/UvulaJS/**'], + 'Digital Factory cloud web slicer and preparation service architecture', + ), +} + +_ROOT = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True +).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + + +# Keys the supported harnesses use for the path a tool is about to touch. +_PATH_KEYS = ("file_path", "filePath", "path", "target_file", "TargetFile", + "absolute_path", "notebook_path") + + +def _paths_in(payload): + """Every plausible target path in a tool-call payload, at any depth. + + Each harness names the field differently and nests it differently; walking + the structure is cheaper than maintaining a matrix of shapes that silently + yields nothing the day one of them renames a key. + """ + found = [] + if isinstance(payload, dict): + for key, value in payload.items(): + if key in _PATH_KEYS and isinstance(value, str) and value.strip(): + found.append(value.strip()) + else: + found.extend(_paths_in(value)) + elif isinstance(payload, list): + for item in payload: + found.extend(_paths_in(item)) + return found + + +def pre_edit_targets(): + """The file this tool call is about to write, read from stdin.""" + try: + raw = sys.stdin.read() if not sys.stdin.isatty() else "" + except (OSError, ValueError): + return [] + if not raw.strip(): + return [] + try: + payload = json.loads(raw) + except ValueError: + return [] + root = os.getcwd() + os.sep + return [p[len(root):] if p.startswith(root) else p + for p in _paths_in(payload)] + + +def changed_files(): + for args in (["diff", "--cached", "--name-only"], ["diff", "--name-only", "HEAD"]): + res = subprocess.run(["git", *args], capture_output=True, text=True) + files = [f for f in res.stdout.splitlines() if f.strip()] + if files: + return files + return [] + + +def matches(path: str, glob: str) -> bool: + """fnmatch has no notion of `**`, and its `*` already spans `/`. A pattern + anchored with `**/` must therefore also be tried without that prefix, or it + would never match a file sitting at the repository root.""" + if fnmatch.fnmatch(path, glob): + return True + if glob.startswith("**/") and fnmatch.fnmatch(path, glob[3:]): + return True + return False + + +def main(): + pre_edit = "--pre-edit" in sys.argv + files = pre_edit_targets() if pre_edit else [] + if not files and not pre_edit: + files = changed_files() + if not files: + return + hits = {} + for skill, (globs, reason) in SKILL_TRIGGERS.items(): + for path in files: + if any(matches(path, g) for g in globs): + hits.setdefault(skill, [reason, []])[1].append(path) + if not hits: + return + msg_lines = [ + "Relevant UltiCortex skills for the files you are about to change — load these BEFORE writing the code:" if pre_edit else + "Relevant UltiCortex skills for the files you are changing:" + ] + for skill, (reason, paths) in sorted(hits.items()): + sample = ", ".join(paths[:3]) + (" ..." if len(paths) > 3 else "") + msg_lines.append(" - {}: {}".format(skill, reason)) + msg_lines.append(" triggered by: {}".format(sample)) + msg_lines.append(" gh skill install Ultimaker/UltiCortex {}".format(skill)) + msg = "\n".join(msg_lines) + print(msg, file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/.agents/hooks/talisman_guard.py b/.agents/hooks/talisman_guard.py new file mode 100755 index 0000000..1038254 --- /dev/null +++ b/.agents/hooks/talisman_guard.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +""" +talisman_guard.py — the only sanctioned way to change .talismanrc. + +The whitelist is generated state. Hand-editing is a documented trap: Talisman +honours the FIRST entry for a filename, so a hand-appended second entry is a +silent no-op. Verbs: + + merge %O %A %B git union merge driver (semantic union of entries; always + exits 0 — a driver that reports conflict reintroduces the + textual-conflict problem it exists to remove). Genuinely + divergent checksums for the same filename are deferred to + `restamp` and reported. + allow ... add checksum entries for the given files (new findings). + refresh re-checksum already-whitelisted files whose content + drifted. Deliberately narrower than allow: it never adds + an entry, so a genuinely new finding still blocks and + still needs a human. + restamp resolve entries deferred by a merge. + +Every digest comes from `talisman --checksum` itself, one call per path, after +the path has been staged — Talisman's checksum is computed over what git knows +about, so an untracked file yields nothing and a call covering several patterns +yields one collective digest matching none of them. `allow`, `refresh` and +`restamp` therefore stage what they checksum and fail loudly without the +binary; a locally computed hash would look like an exemption while exempting +nothing. `merge` computes no digest and works on any clone. + +Register the driver per-clone (done by scripts/sync_agentic_configs.sh and +verify_and_create_pr.sh): + git config merge.talismanrc.driver \ + "python3 .agents/hooks/talisman_guard.py merge %O %A %B" +GitHub's server-side "mergeable" badge cannot see a repo-local driver: a +CONFLICTING badge on a .talismanrc-only conflict is cosmetic. +""" + +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +_ROOT = subprocess.run(["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + +RC = Path(".talismanrc") +DEFERRED = Path(".agents/.talismanrc-deferred") + + +def parse(text: str): + """(entries dict, other_sections text). Entries are an unordered map. + + An entry's value is its checksum, or "" when it has none. A checksum-less + entry is real state — a gitlink carries `ignore_detectors` instead, and a + file whose digest could not be computed is listed bare on purpose — so it + must survive a rewrite. Dropping those entries silently re-enabled every + detector they configured. + """ + entries = {} + extras = {} + other = [] + skip = False + current = None + for line in text.splitlines(): + if re.match(r"^fileignoreconfig:", line): + skip = True + current = None + continue + if re.match(r"^[A-Za-z_][\w]*:", line): + skip = False + current = None + if not skip: + other.append(line) + continue + name_match = re.match(r"^-\s*filename:\s*(\S+)", line) + if name_match: + current = name_match.group(1) + entries[current] = "" + continue + if current is None: + continue + sum_match = re.match(r"^\s+checksum:\s*(\S+)", line) + if sum_match: + entries[current] = sum_match.group(1) + else: + extras.setdefault(current, []).append(line) + return entries, "\n".join(other).strip("\n"), extras + + +def emit(entries: dict, other: str, extras: dict = None) -> str: + lines = ["fileignoreconfig:"] + for name in sorted(entries): + lines.append(f"- filename: {name}") + if entries[name]: + lines.append(f" checksum: {entries[name]}") + lines.extend((extras or {}).get(name, [])) + if other: + lines.append(other) + return "\n".join(lines) + "\n" + + +ENTRY_PAIR_RE = re.compile( + r"-\s*filename:\s*(\S+)\s*\n\s*checksum:\s*([a-fA-F0-9]{64})") + + +class TalismanMissing(RuntimeError): + """No talisman binary, so no digest can be produced. Never guess one.""" + + +def checksums(paths): + """Talisman's own digest for each path: {path: digest}. + + Talisman's digest is not a hash of the file bytes that anything else can + reproduce, and it is computed from what git knows about — an untracked file + yields nothing at all. Three hand-rolled approximations in this toolchain + each wrote digests Talisman rejected, so every exemption they produced was + inert. Hence: stage first, ask the binary, one call per path (a call with + several patterns collapses into one collective digest), and refuse to + invent anything when the binary is absent. + """ + paths = list(paths) + if not paths: + return {} + if shutil.which("talisman") is None: + raise TalismanMissing( + "talisman is not installed, so no checksum can be computed. A " + "guessed digest looks like an exemption while exempting nothing. " + "Install talisman (https://github.com/thoughtworks/talisman) and " + "re-run this command.") + if subprocess.run(["git", "add", "--", *paths], + capture_output=True).returncode != 0: + for path in paths: + subprocess.run(["git", "add", "--", path], capture_output=True) + digests = {} + for path in paths: + res = subprocess.run(["talisman", "--checksum", path], + capture_output=True, text=True) + pairs = ENTRY_PAIR_RE.findall(res.stdout) + for name, digest in pairs: + if name.strip("'\"") == path: + digests[path] = digest + break + else: + if len(pairs) == 1: + digests[path] = pairs[0][1] + return digests + + +def merge(base_p, ours_p, theirs_p) -> int: + read = lambda p: Path(p).read_text(errors="ignore") if Path(p).exists() else "" + base_e, _, _ = parse(read(base_p)) + ours_e, ours_other, ours_extras = parse(read(ours_p)) + theirs_e, theirs_other, theirs_extras = parse(read(theirs_p)) + merged = dict(ours_e) + extras = dict(theirs_extras) + extras.update(ours_extras) + deferred = [] + for name, digest in theirs_e.items(): + if name not in merged: + merged[name] = digest + elif merged[name] != digest: + if base_e.get(name) == merged[name]: + merged[name] = digest # only theirs moved + elif base_e.get(name) == digest: + pass # only ours moved + else: + deferred.append(name) # genuinely divergent: defer, keep ours + Path(ours_p).write_text( + emit(merged, ours_other or theirs_other, extras)) + if deferred: + DEFERRED.parent.mkdir(exist_ok=True) + with open(DEFERRED, "a") as f: + f.write("\n".join(deferred) + "\n") + print(f"talisman_guard: {len(deferred)} divergent checksum(s) deferred " + "- run: python3 .agents/hooks/talisman_guard.py restamp", + file=sys.stderr) + return 0 # never report conflict; that is the whole point + + +def _report_unresolved(names) -> None: + print("talisman_guard: talisman produced no checksum for " + f"{len(names)} path(s); they are NOT exempt: " + ", ".join(sorted(names)), + file=sys.stderr) + + +def allow(paths) -> int: + entries, other, extras = parse( + RC.read_text(errors="ignore") if RC.exists() else "") + for path in paths: + if not Path(path).exists(): + print(f"talisman_guard: no such file: {path}", file=sys.stderr) + return 1 + digests = checksums(paths) + unresolved = [p for p in paths if p not in digests] + if unresolved: + # Writing the entry anyway would leave a whitelist line that whitelists + # nothing, which reads as protection at review time. + _report_unresolved(unresolved) + return 1 + for path in paths: + entries[path] = digests[path] + RC.write_text(emit(entries, other, extras)) + print(f"talisman_guard: whitelisted {len(paths)} file(s).") + return 0 + + +def refresh() -> int: + entries, other, extras = parse( + RC.read_text(errors="ignore") if RC.exists() else "") + # Only entries that name an existing file: a glob entry has no single file + # to re-checksum, and `refresh` never adds an entry that is not already here. + present = [name for name in entries if Path(name).is_file()] + digests = checksums(present) + changed = 0 + for name in present: + digest = digests.get(name) + if digest and digest != entries[name]: + entries[name] = digest + changed += 1 + RC.write_text(emit(entries, other, extras)) + unresolved = [name for name in present if name not in digests] + if unresolved: + _report_unresolved(unresolved) + print(f"talisman_guard: refreshed {changed} drifted entr(y/ies). " + "New findings still need `allow` and a human.") + return 1 if unresolved else 0 + + +def restamp() -> int: + if not DEFERRED.exists(): + print("talisman_guard: nothing deferred.") + return 0 + names = [n for n in DEFERRED.read_text().splitlines() if n.strip()] + entries, other, extras = parse( + RC.read_text(errors="ignore") if RC.exists() else "") + present = [n for n in names if Path(n).is_file()] + digests = checksums(present) + for name, digest in digests.items(): + entries[name] = digest + RC.write_text(emit(entries, other, extras)) + unresolved = [n for n in present if n not in digests] + if unresolved: + _report_unresolved(unresolved) + return 1 + DEFERRED.unlink() + print(f"talisman_guard: restamped {len(names)} deferred entr(y/ies).") + return 0 + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + return 2 + verb = sys.argv[1] + # `merge` never computes a digest, so it keeps working without talisman — + # that is what makes it safe as a git merge driver on any clone. + if verb == "merge" and len(sys.argv) >= 5: + return merge(sys.argv[2], sys.argv[3], sys.argv[4]) + try: + if verb == "allow": + return allow(sys.argv[2:]) + if verb == "refresh": + return refresh() + if verb == "restamp": + return restamp() + except TalismanMissing as exc: + print(f"talisman_guard: {exc}", file=sys.stderr) + return 1 + print(__doc__) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/hooks/verify_hooks_fire.py b/.agents/hooks/verify_hooks_fire.py new file mode 100755 index 0000000..9712995 --- /dev/null +++ b/.agents/hooks/verify_hooks_fire.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +""" +verify_hooks_fire.py — fire-proofing. + +"A hook you have not watched fire is unproven." Every rollout PR claimed +"N/N hooks passed 100% cleanly" while shipping hooks that could not execute. +This script actually triggers each blocking guard once, with a deliberately +violating synthetic fixture, and records the observed result in +.agents/bootstrap-profile.json under `hook_verification`. The PR's V&V table +is generated from that record — a pass-count nobody ran becomes unwritable. + +Exit non-zero if any exercised guard failed to block its violation. +""" + +import json +import os +import subprocess +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path + +_ROOT = subprocess.run(["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + +HOOKS = Path(".agents/hooks") +PROFILE = Path(".agents/bootstrap-profile.json") + +# Fixtures are ASSEMBLED AT RUNTIME, never written out as literals. +# +# This file's whole job is to feed each guard something the guard must reject. +# Spelling those violations out in the source made the guards reject THIS FILE: +# every bootstrap commit then failed its own secret scanner, path scanner and +# adversarial audit, and the tree could not be committed at all. Exempting the +# file from the guards would have been a weakening — a real leaked key in a +# skipped file is exactly what these guards exist to catch. Composing the +# fixtures from fragments keeps the guards at full strength and leaves nothing +# here for them to match. +_PEM_EDGE = "-" * 5 +_KEY_WORD = "KEY" +_PRIVATE = "PRIVATE" +FAKE_KEY = (f"{_PEM_EDGE}BEGIN RSA {_PRIVATE} {_KEY_WORD}{_PEM_EDGE}\n" + f"MIIEvFAKEFAKEFAKE\n{_PEM_EDGE}END RSA {_PRIVATE} {_KEY_WORD}{_PEM_EDGE}\n") +_HOME_ROOT = "/" + "home" +FAKE_PATH = f"config = '{_HOME_ROOT}/exampleuser/secret/config.yaml'\n" + + +def _run(cmd, **kwargs): + return subprocess.run(cmd, capture_output=True, text=True, **kwargs) + + +def _record(results, hook, fired, blocked, detail=""): + results[hook] = { + "fired": fired, "blocked": blocked, "detail": detail, + "at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + } + status = "BLOCKED" if blocked else ("ran" if fired else "NOT RUN") + print(f" [{status:>7}] {hook}{' — ' + detail if detail else ''}") + + +def _fixture(content, suffix=".py"): + handle = tempfile.NamedTemporaryFile( + "w", suffix=suffix, dir=".", prefix=".fireproof-", delete=False) + handle.write(content) + handle.close() + return handle.name + + +def exercise_file_guard(results, script, content, hook_id): + """These guards scan the STAGED diff, so the violating fixture is staged + for the duration of the probe and unstaged afterwards.""" + path = HOOKS / script + if not path.exists(): + return + fixture = _fixture(content) + try: + _run(["git", "add", "--force", fixture]) + res = _run([sys.executable, str(path)]) + _record(results, hook_id, True, res.returncode != 0, + "staged violation rejected" if res.returncode != 0 + else "staged violation ACCEPTED") + finally: + _run(["git", "reset", "-q", "--", fixture]) + os.unlink(fixture) + + +def exercise_pretool_guard(results): + path = HOOKS / "pretool_guard.py" + if not path.exists(): + return + payload = json.dumps({ + "hook_event_name": "PreToolUse", "tool_name": "Write", + "tool_input": {"file_path": "src/x.py", "content": FAKE_KEY}, + }) + res = _run([sys.executable, str(path)], input=payload) + blocked = res.returncode == 2 or '"deny"' in res.stdout + _record(results, "pretool-guard", True, blocked, + "secret payload denied" if blocked else "secret payload ALLOWED") + + +def exercise_kill_guard(results): + path = HOOKS / "block_name_matched_kill.py" + if not path.exists(): + return + payload = json.dumps({ + "hook_event_name": "PreToolUse", "tool_name": "Bash", + "tool_input": {"command": "pkill -f my_service"}, + }) + res = _run([sys.executable, str(path)], input=payload) + blocked = res.returncode == 2 or '"deny"' in res.stdout + _record(results, "block-name-matched-kill", True, blocked, + "pkill denied" if blocked else "pkill ALLOWED") + + +def exercise_run_only(results, script, hook_id, *args): + """Advisory/analysis hooks: prove they execute without crashing.""" + path = HOOKS / script + if not path.exists(): + return + res = _run([sys.executable, str(path), *args]) + _record(results, hook_id, True, None, + f"exit {res.returncode} (advisory; executed, not violated)") + + +def exercise_e2e_evidence_gate(results): + """The E2E-evidence gate (cloud/frontend repos only). Prove BOTH directions + on synthetic fixtures fed through the hook's testing seam: a user-facing + diff with an evidence-less body must BLOCK, and the same diff with evidence + present must PASS. A gate proven only to reject is half-proven.""" + path = HOOKS / "check_e2e_evidence.py" + if not path.exists(): + return + changed = _fixture("frontend/src/App.tsx\nsrc/api/JobHandler.py\n", suffix=".txt") + evidence_less = _fixture( + "## Why\nFix the print button.\n\n## Verification & Validation\n" + "Unit tests pass (42/42). No browser run.\n", suffix=".md") + with_evidence = _fixture( + "## Why\nFix the print button.\n\n## Verification & Validation\n" + "Logged in and printed a job end to end:\n\n" + "![print button enabled](https://github.com/user-attachments/assets/" + "abc123.png)\n", suffix=".md") + try: + rej = _run([sys.executable, str(path), + "--changed-paths-file", changed, + "--body-file", evidence_less]) + _record(results, "check-e2e-evidence", True, rej.returncode != 0, + "evidence-less cloud diff rejected" if rej.returncode != 0 + else "evidence-less cloud diff ACCEPTED") + acc = _run([sys.executable, str(path), + "--changed-paths-file", changed, + "--body-file", with_evidence]) + # This direction must NOT block; a wrongful block is a false positive. + _record(results, "check-e2e-evidence-passthrough", True, + False if acc.returncode != 0 else None, + "evidence-present cloud diff accepted (exit 0)" + if acc.returncode == 0 + else "FALSE POSITIVE: evidence present but gate BLOCKED") + finally: + for f in (changed, evidence_less, with_evidence): + os.unlink(f) + + +def _outcomes(record: dict) -> dict: + """The parts of a verification record that carry information. + + Excludes `at`, which changes on every run by construction. + """ + return { + hook: {k: v for k, v in entry.items() if k != "at"} + for hook, entry in (record or {}).items() + } + + +def main(): + if not HOOKS.is_dir(): + print("No .agents/hooks directory; nothing to verify.") + return 0 + print("==> Fire-proofing: triggering each guard against a violating fixture") + results = {} + exercise_file_guard(results, "block-secrets.py", FAKE_KEY, "block-secrets") + exercise_file_guard(results, "block-absolute-paths.py", FAKE_PATH, + "block-absolute-paths") + exercise_pretool_guard(results) + exercise_kill_guard(results) + exercise_run_only(results, "compile_rule_frontmatter.py", + "check-rule-frontmatter", "--check") + exercise_run_only(results, "audit_quad_agent_parity.py", + "audit-quad-agent-parity", ".") + exercise_run_only(results, "check_upstream_alignment.py", + "check-upstream-alignment") + exercise_e2e_evidence_gate(results) + + head = _run(["git", "rev-parse", "--short", "HEAD"]).stdout.strip() + for entry in results.values(): + entry["sha"] = head + + if PROFILE.exists(): + try: + profile = json.loads(PROFILE.read_text()) + except ValueError: + profile = {} + # Idempotent: `verify_and_create_pr.sh` runs this on every verification, + # and rewriting the record with fresh timestamps each time dirtied the + # working tree and churned the committed profile for no new information. + # Only the OUTCOMES matter for comparison; `at` is volatile by design. + if _outcomes(profile.get("hook_verification", {})) == _outcomes(results): + print(f" Hook verification unchanged for {head}; " + f"{PROFILE} left as-is") + else: + profile["hook_verification"] = results + PROFILE.write_text(json.dumps(profile, indent=2) + "\n") + print(f" Recorded {len(results)} result(s) in {PROFILE}") + + failures = [h for h, r in results.items() if r["blocked"] is False] + if failures: + print(f"==> FIRE-PROOFING FAILED: guards that did not block: {failures}") + return 1 + print("==> Fire-proofing complete.") + return 0 + + +def render_vv_table() -> str: + """The V&V table for the PR body, generated from the recorded runs.""" + try: + results = json.loads(PROFILE.read_text()).get("hook_verification", {}) + except (OSError, ValueError): + results = {} + if not results: + return "No hook_verification record — run verify_hooks_fire.py first.\n" + lines = ["| Hook | Fired | Blocked violation | When |", + "| --- | --- | --- | --- |"] + for hook in sorted(results): + r = results[hook] + blocked = {True: "yes", False: "NO", None: "n/a (advisory)"}[r["blocked"]] + lines.append(f"| {hook} | {'yes' if r['fired'] else 'no'} | {blocked} " + f"| {r['at']} ({r.get('sha', '?')}) |") + return "\n".join(lines) + "\n" + + +if __name__ == "__main__": + if "--vv-table" in sys.argv: + print(render_vv_table(), end="") + sys.exit(0) + sys.exit(main()) diff --git a/.agents/rules-manifest.json b/.agents/rules-manifest.json new file mode 100644 index 0000000..abd6807 --- /dev/null +++ b/.agents/rules-manifest.json @@ -0,0 +1,19 @@ +{ + "rules": { + "01-jira-commit-standards.md": "c6d4392ffe86048000037e432b59258b59ae5d3e259a6a3c5ddcade939f82710", + "02-security-and-paths.md": "f18b11b8ff318e969f812803a72c5d45fe84b78a5d6107bb41a447306de80f93", + "04-build-test-and-deployment-rules.md": "8817bc9170091f5746d288cd1c5d56a3e1e77235d07d82f487f32f173e6943b6", + "05-ultimaker-skill-discovery-rules.md": "a5624db1138346a0ab71dcb47fe6008ec46086eb381b0800e421abc0d3ae1381", + "06-pull-request-lifecycle-rules.md": "d9c35d5d90301805d50460df58e98868512252f1a22a42711282b1a6ca014c61", + "07-owasp-security-rules.md": "d893a45caf28e31945948f52868ed7cd6dcd84481900fab2407575bd9b28f2bd", + "08-scoped-changes-and-minimal-diffs.md": "fc397b61f3f2ddc51736934cc02248d6d188c31b7680977dfb66704e060f310b", + "09-atomic-bisect-safe-commits.md": "028d1225bd1ad8f75534eb069478fab2864e8530b7b75f14375c26a01d9dbfd9", + "10-file-size-and-decomposition-rules.md": "eca20f37445be2691c51560df954bebc7b1baa7b542329db13608c948afcab0a", + "12-ai-context-exclusion-rules.md": "dc3997cdaf749718dc3986453d72b889655f55b15f781f4042fc1b551269d946", + "13-dependency-management-rules.md": "1031dead00de245fda03153f7e232317543ebe3431eaa5fd3c29f1643b160f73", + "14-complexity-budget-rules.md": "ebdb8a4e4e69f353d42b46382e27750954912f02e5c9e6e93b961f4250e38297", + "21-cpp-core-architecture-rules.md": "18057a9ffff724bb114a8a0e544a902224121bd9e85705301c41c5f0bc27bef5", + "34-library-consumer-contract-rules.md": "560e10d03db2626471d3b84692be72e462ebd407aa29bd668150484c2fa9d4c8", + "40-skill-discovery-index-rules.md": "82a94186a6c18e4b19eb84309e56f948443b84c494b9bd3baecab0075b6c066d" + } +} diff --git a/.agents/rules/01-jira-commit-standards.md b/.agents/rules/01-jira-commit-standards.md new file mode 100644 index 0000000..6f649ce --- /dev/null +++ b/.agents/rules/01-jira-commit-standards.md @@ -0,0 +1,15 @@ +--- +name: jira-commit-standards +description: Jira work tracking and commit message standards. +trigger: always_on +--- +# Jira & Git Commit Standards + +1. **Jira Work Tracking**: + - All branches MUST reference an active Jira ticket starting with project key `UC` (e.g. `UC-3697-short-description`). +2. **Commit Title Standard**: + - Every commit title MUST start with bracketed Jira ticket key: `[UC-3697] `. + - Do NOT use semantic commit prefixes (`feat:`, `fix:`, `chore:`, `refactor:`) in commit or PR titles. +3. **Pull Request Policy**: + - Always open PRs in **DRAFT** state. + - Merging is strictly restricted to human developers. diff --git a/.agents/rules/02-security-and-paths.md b/.agents/rules/02-security-and-paths.md new file mode 100644 index 0000000..511fe62 --- /dev/null +++ b/.agents/rules/02-security-and-paths.md @@ -0,0 +1,21 @@ +--- +name: security-and-paths +description: Security guidelines, secret protection, and path sanitation. +trigger: always_on +--- +# Security & Path Protection Guidelines + +1. **No Hardcoded Absolute Paths**: + - Never commit absolute local filesystem paths (e.g. `//` or `//`). +2. **No Secret Leaks**: + - Never commit private keys, API tokens, or passphrases. + - Use RAM-backed filesystem mounts (`/dev/shm`) for temporary secret processing. +3. **No Security Feature Downgrades**: + - Agents are **STRICTLY FORBIDDEN** from disabling, weakening, or bypassing security, authentication, or authorization controls — for example switching `signInRequired`, `needs_authentication`, `verify`, `ssl_verify`, `check_permissions` or `authorized` to a falsy value, or skipping scope checks — to bypass test failures or browser verification roadblocks. + - This rule is written WITHOUT the literal downgrade assignments on purpose: `check_security_downgrades` scans every added line, and a rule document that spelled out `=false` would flag itself on the commit that introduced it. + - All authentication features and security parameters must remain intact and fully enforced. +4. **Branch Guard**: + - Direct commits to `main`, `master`, or `staging` branches are strictly forbidden. + +5. **PII & Secret Redaction in Logging**: + - Never write PII, passwords, credentials, API tokens, or session keys to log files, stdout, or debug output. diff --git a/.agents/rules/04-build-test-and-deployment-rules.md b/.agents/rules/04-build-test-and-deployment-rules.md new file mode 100644 index 0000000..07b0443 --- /dev/null +++ b/.agents/rules/04-build-test-and-deployment-rules.md @@ -0,0 +1,29 @@ +--- +name: build-test-and-deployment +description: Build, test, and deployment verification commands detected for this repository, with Freshness Before Evidence rules. +trigger: always_on +--- +# Build, Test & Deployment Verification + +1. **Build Commands (detected)**: + - `cmake --build build` + - `conan build .` + - Builds must complete cleanly (no new warnings) before PR creation. + - **Container Rebuilds & Compilation Mandate**: When changing frontend or backend code in Docker or compiled environments, executing container rebuilds or asset compilation (`docker compose build ` / `npm run build`) is strictly required before visual or functional verification. +2. **Package Registry Authentication**: + - Obtain `GITHUB_TOKEN` (scope `read:packages`) for private `@ultimaker` packages via the keyring-first chain in `scripts/get_github_token.sh` — source it (`. scripts/get_github_token.sh`) instead of hand-exporting: (a) an existing env var wins, (b) else the system keyring (Linux: `secret-tool lookup service github user "$USER"`), (c) else a gitignored `.env`/`.env.local` fallback with a warning. Store it once per machine, user-specific — never a hardcoded username: `echo -n "" | secret-tool store --label="$USER-github-token" service github user "$USER"`. Never write tokens to disk, logs, or git. +3. **Artifact Isolation**: + - Keep generated build outputs, intermediate binaries, and logs out of git. Ensure `.env` and `.env.local` files remain strictly gitignored. +4. **Freshness Before Evidence**: + - Rebuild binaries/packages/containers before treating a behavioral observation as evidence. An observation is only valid if the artifacts observed were compiled from the current source state. An unverified stale build is a false observation. + + +## Semantic Release & Version Tagging + +1. **SemVer Version Tagging**: + - Releases MUST follow Semantic Versioning (`MAJOR.MINOR.PATCH`). + - Tags MUST be created on main branch commits after PR integration. +2. **Automated Changelog Generation**: + - Changelogs are generated directly from Pull Request titles. Ensure PR titles follow the `[UC-3697] ` standard. +3. **Version Synchronization**: + - Keep the version number synchronized across the manifests this repository actually carries: `CMakeLists.txt`. A release that bumps one and not the others ships a lie about itself. diff --git a/.agents/rules/05-ultimaker-skill-discovery-rules.md b/.agents/rules/05-ultimaker-skill-discovery-rules.md new file mode 100644 index 0000000..5be3f88 --- /dev/null +++ b/.agents/rules/05-ultimaker-skill-discovery-rules.md @@ -0,0 +1,97 @@ +--- +name: ultimaker-skill-discovery +description: Skills from the UltiCortex catalogue that apply to this repository, and when to load them. +trigger: always_on +--- +# UltiMaker Skill Discovery & Usage + +This repository has been matched against the UltiCortex skill catalogue. Loading +the relevant skill is **not optional** for the work it covers: these skills carry +the standards, idioms, and tooling knowledge that the rules in this directory +assume you already have. + +```bash +# Search the catalogue +gh skill search ultimaker --owner Ultimaker + +# Install a specific skill +gh skill install Ultimaker/UltiCortex +``` + +Load the skill **before** designing or implementing, not after review comments +arrive. If a skill contradicts a rule in this directory, raise the conflict +rather than silently picking one. + +## Skills Matched To This Repository + +### `conan-2` — when touching `**/conanfile.py`, `**/conanfile.txt`, `**/conandata.yml` + +Engineering guide for Conan 2 dependency management, cross-compilation, CMake integrations, and packaging workflows. + +**Why it applies here:** Dependency graph, profiles, cross-compilation and packaging are Conan 2 concerns; the skill carries the v2 idioms and the CMakeToolchain/CMakeDeps integration this repository relies on. + +```bash +gh skill install Ultimaker/UltiCortex conan-2 +``` + +### `cmake` — when touching `**/CMakeLists.txt`, `**/*.cmake`, `**/CMakePresets.json` + +Modern target-centric C/C++ engineering with CMake 3 & 4. + +**Why it applies here:** Target-centric CMake, presets, and CTest wiring — avoid reinventing build logic or reaching for directory-scoped commands. + +```bash +gh skill install Ultimaker/UltiCortex cmake +``` + +### `cpp-pro` — when touching `**/*.cpp`, `**/*.hpp`, `**/*.cc`, `**/*.cxx` + +Expert modern C++ (C++11 through C++23) engineering grounded in the ISO C++ Core Guidelines. + +**Why it applies here:** Modern C++20/23 implementation work: templates, zero-overhead abstractions, and the idioms mined into the C++ architecture rule. + +```bash +gh skill install Ultimaker/UltiCortex cpp-pro +``` + +### `software-architect` — always relevant + +Expert software-architecture advisor: design patterns (GoF), SOLID/DRY, enterprise patterns (Fowler PoEAA), DDD/CQRS, evolutionary architecture (monolith-first, strangler fig, microservices), distributed systems, C4 diagrams, C++…. + +**Why it applies here:** SOLID, DRY and the design-pattern catalogue — the reference to consult when a change needs decomposition rather than more lines in an existing module. + +```bash +gh skill install Ultimaker/UltiCortex software-architect +``` + +### `ultimaker-neoprep-development` — when touching `UvulaJS/**` + +Comprehensive guide to developing, building, testing, and operating Neoprep React/Three.js applications, including integration with WebAssembly modules (`@ultimaker/uvulajs`). + +**Why it applies here:** UvulaJS Emscripten bindings, Web Worker execution, Three.js matrix conventions, and `@ultimaker/uvulajs` packaging. + +```bash +gh skill install Ultimaker/UltiCortex ultimaker-neoprep-development +``` + +### `ultimaker-cura-development` — when touching `pyUvula/**` + +Comprehensive guide to working with Ultimaker Cura application architecture, Uranium plugins, container stacks, and native C++/Python bindings. + +**Why it applies here:** Cura native C++ core and `pyUvula` Python bindings packaging and integration. + +```bash +gh skill install Ultimaker/UltiCortex ultimaker-cura-development +``` + +## Other Catalogue Skills + +23 further skill(s) exist that no automatic trigger matched. +They are indexed in `.agents/rules/40-skill-discovery-index-rules.md` (a +model-decision rule, loaded only when judged relevant) rather than here, +because by construction they are the ones static detection judged irrelevant — +and this file is loaded every session. + +```bash +gh skill search ultimaker --owner Ultimaker +``` diff --git a/.agents/rules/06-pull-request-lifecycle-rules.md b/.agents/rules/06-pull-request-lifecycle-rules.md new file mode 100644 index 0000000..73fb155 --- /dev/null +++ b/.agents/rules/06-pull-request-lifecycle-rules.md @@ -0,0 +1,36 @@ +--- +name: pull-request-lifecycle +description: Pull request lifecycle rules, draft PR policy, PR template enforcement, review workflows, and subagent delegation guardrails. +trigger: always_on +--- +# Pull Request Lifecycle Rules + +1. **Pre-PR Verification & Gate**: Run `scripts/verify_and_create_pr.sh` (pre-commit + orientation check + adversarial audit) before creating or updating any PR. +2. **Draft PR Policy**: Always open PRs in **DRAFT** state (`gh pr create --draft`). Merging is strictly restricted to human developers; AI agents must never auto-merge. +3. **Mandatory PR Template & Comprehensive Description**: + - Every PR description **MUST** strictly follow the repository's PR template (its location is recorded as `pr_template.path` in `.agents/bootstrap-profile.json`; GitHub also honours a repository-root or `docs/` template) and answer the core review questions: + - **Why**: The problem, user request, Jira ticket (`[UC-3697]`), and business context driving the change. + - **What**: High-level overview of introduced changes. + - **How**: Architecture decisions, implementation details, and modified modules. + - **Verification & Validation (V&V)**: Empirical test results (unit tests, integration tests, E2E checks, and visual screenshots/recordings for UI changes). + - **PR Checklist**: Human reviewer checklist (`- [ ] Initiating developer reviewed AI-generated code`). + - Vague, brief, or 1-sentence PR descriptions are strictly prohibited. +4. **Empirical Proof Mandate**: Verification is ONLY valid when concrete empirical proof (a DOM text snapshot, test execution log, or screenshot uploaded via `gh image` attached to the walkthrough and PR body) is delivered. Agents must NEVER claim a UI feature or fix is verified without delivering empirical proof. +5. **Updating Existing PRs on Follow-up Commits**: + - When pushing follow-up commits to an active branch with an existing Pull Request, agents **MUST** inspect the existing PR (`gh pr view` or `gh pr list --head `). + - If the new commits add new scope, alter architecture (**How**), or require updated testing/screenshots (**V&V**), run `gh pr edit --body-file ` to update the PR description so it always reflects the current state of the branch. +6. **CI Watch Loop**: After creating or updating a PR, actively monitor status checks (`gh pr checks --watch`) and fix any linter or test failures immediately before handing off to human review. +7. **Upstream Base Branch Alignment**: + - Before staging changes, opening PRs, or pushing follow-up commits, agents **MUST** ensure the local feature branch is completely up-to-date with its base branch (`origin/staging`, `origin/main`, or `origin/master`). + - Run `git fetch origin` and `git merge origin/` (or use `/sync-base` command) to resolve any upstream changes or conflicts before proposing PR updates. +8. **Subagent Delegation Guardrails**: + - Delegation moves the work, not the responsibility. Everything a subagent reports is a claim until verified via `git log` and `git diff` in the subagent's tree. + - Specify the target PR state explicitly in subagent instructions (draft vs ready). Verify that subagents commit and push all work before reporting completion. + + +## Git Merge Topology Preservation + +This repository preserves merge topology for pull requests and feature branches to maintain ISO-27001 auditability and review history: +1. **No Force-Pushing Published Review Branches**: Do NOT rewrite history or force-push rebased commits on published branches that have already been opened for PR review. +2. **Integrate Upstream via Merge**: Integrate upstream base branch updates using `git merge origin/main` (or `/sync-base`) rather than rebasing published history. +3. **Draft PR Lifecycle**: PRs must remain in DRAFT status until all status checks pass and human review is complete. Merging is strictly restricted to human developers. diff --git a/.agents/rules/07-owasp-security-rules.md b/.agents/rules/07-owasp-security-rules.md new file mode 100644 index 0000000..e1bfc79 --- /dev/null +++ b/.agents/rules/07-owasp-security-rules.md @@ -0,0 +1,17 @@ +--- +name: owasp-security +description: OWASP security standards selected for this repository's detected stack profile. +trigger: always_on +--- +# OWASP Security Guidelines (Profile-Matched) + +These sections were selected because the bootstrap investigation detected the matching stack. Enforcement is layered: these rules guide implementation, pre-commit hooks block secrets/paths mechanically, and `scripts/verify_and_create_pr.sh` runs the adversarial audit before any PR. + +## Secure Coding Essentials + +1. **No hardcoded credentials**: never commit passwords, private keys, API tokens or HMAC secrets. Load them from the environment or a secret manager at runtime. +2. **Input validation**: validate and sanitise anything that crosses a trust boundary — user input, file contents, network payloads, subprocess arguments. +3. **Injection prevention**: parameterise database queries and never build shell commands by string concatenation from untrusted values. +4. **Privacy**: never write personal data, passwords or tokens to logs, telemetry or debug output. +5. **Dependency hygiene**: keep dependencies pinned and patched; check advisories before adding one. +6. **Error hygiene**: do not leak stack traces, internal paths or configuration in errors returned across a boundary. diff --git a/.agents/rules/08-scoped-changes-and-minimal-diffs.md b/.agents/rules/08-scoped-changes-and-minimal-diffs.md new file mode 100644 index 0000000..54c7b8b --- /dev/null +++ b/.agents/rules/08-scoped-changes-and-minimal-diffs.md @@ -0,0 +1,41 @@ +--- +name: scoped-changes-and-minimal-diffs +description: Scoped changes and minimal diff guidelines for code changes. +trigger: always_on +--- +# Scoped Changes & Minimal Diffs + +1. **Strict Scope Compliance**: Make changes strictly relevant to the active Jira ticket task. Avoid scope creep. +2. **Diff Relevance Validation**: Inspect `git diff --name-only` against the base branch before committing. Revert files touched only by formatters or side-effects: `git checkout origin/ -- `. +3. **No Unrelated Refactoring**: Do not modify whitespace, formatting, or code in files unrelated to the task. Never edit `vendor/`, `third_party/`, or submodule trees. + + +## Single Responsibility PRs & Boy Scouting Branch Isolation + +Every Pull Request MUST deliver a single cohesive goal tied to a single primary topic or Jira issue. Combining multiple unrelated features, bug fixes, or opportunistic refactorings ("Boy Scouting") into a single PR creates scope coupling and increases reviewer cognitive load. + +## 1. Single Responsibility Principle for PRs (SRP-PR) + +- **One Goal per PR:** A Pull Request MUST serve a single, clear objective. +- **Intra-Module Intent Divergence:** Even if all code changes are located within the exact same directory or module (e.g. `src/components/` or `griffin/printer/`), edits MUST NOT fix multiple unrelated bugs or introduce opportunistic refactorings alongside a new feature. +- **Single Jira Key:** A PR SHOULD address a single Jira ticket key. Do NOT combine work for `UC-100` and `UC-200` into one PR. + +## 2. Boy Scouting Isolation Protocol + +When you discover an opportunistic bug, missing setting, or code cleanup ("Boy Scouting") while working on a primary task: + +1. **Do NOT lump the opportunistic changes into the primary feature PR.** +2. **Extract onto a Boy Scouting Branch:** + ```bash + bash scripts/create_boyscout_branch_and_pr.sh + ``` +3. **Open a Dedicated Draft PR:** Push the Boy Scouting branch to GitHub and open an independent Draft PR (`gh pr create --draft`). +4. **Link PRs in Descriptions:** Reference the Boy Scouting PR in your primary PR description so reviewers can inspect both independently. + +## 3. Adversarial Scope Verification + +Before submitting or updating a Pull Request, run the Adversarial Scope Judge: +```bash +python3 .agents/hooks/check_multi_intent_scope.py +``` +It blocks exactly one thing — edits to trees this repository vendors but does not own — and otherwise **reports** the changed-file list grouped by area. It deliberately does not decide for you: a rename touches sixty files with one intent, while two files in two subsystems can still be two intents. Read the report and judge it; if part of the diff is opportunistic, extract it before requesting human review. diff --git a/.agents/rules/09-atomic-bisect-safe-commits.md b/.agents/rules/09-atomic-bisect-safe-commits.md new file mode 100644 index 0000000..b92bf3d --- /dev/null +++ b/.agents/rules/09-atomic-bisect-safe-commits.md @@ -0,0 +1,17 @@ +--- +name: atomic-bisect-safe-commits +description: Atomic, bisect-safe commit rules. +trigger: always_on +--- +# Atomic & Bisect-Safe Commits + +1. **Atomic Commits**: Each commit must be a single self-contained, logical unit of work that compiles and passes tests independently. +2. **Bisect-Safe**: Never break the build or unit test suite in intermediate commits to preserve `git bisect` functionality. +3. **History Cleanup**: Squash WIP/fixup commits (`git rebase -i`) before a PR leaves DRAFT. +4. **Prove it, do not assert it**: "bisect-safe" is a claim about a build, not about a commit message. The pre-push hook runs this repository's own build/test command against the committed tree and fails the push if the tip does not build: + + ```bash + python3 .agents/hooks/check_atomic_bisect_history.py + ``` + + Export `SKIP_BISECT_BUILD=1` only when you are knowingly pushing a tip you already know is broken — and say so in the pull request. diff --git a/.agents/rules/10-file-size-and-decomposition-rules.md b/.agents/rules/10-file-size-and-decomposition-rules.md new file mode 100644 index 0000000..1586c87 --- /dev/null +++ b/.agents/rules/10-file-size-and-decomposition-rules.md @@ -0,0 +1,91 @@ +--- +name: file-size-and-decomposition +description: File-size budget with grandfathering ratchet, and the decomposition expected to meet it. +trigger: always_on +--- +# File Size Budget & Decomposition Rules + +A large file is expensive for every agent that reads it afterwards. This +repository enforces a budget of **400 lines**, with a ratchet so that +existing large files are not a blocker but can never get worse. + +## 1. The Two Tiers + +1. **Files within budget** must stay at or under 400 lines. +2. **Files already over budget** when the ratchet was introduced are recorded in + `.agents/file-size-baseline.json` at their size at that moment. They **may + shrink but must never grow**. When one shrinks, its ceiling tightens + automatically — the reclaimed space cannot be spent later. +3. **New files are never grandfathered.** A file created from now on must meet + the budget outright. + +Check status at any time: + +```bash +python3 .agents/hooks/check_file_size_budget.py --report +``` + +## 2. During Design and Planning — Before Writing Code + +Treat the budget as a design input, not a gate you discover at commit time. + +- Run the `--report` command above on every file the change is expected to + touch, and read the headroom before deciding where code goes. +- If the planned work does not fit the headroom, the plan must say **which + responsibility moves out, where it goes, and what the new module is called**. + Decide this during design; do not defer it until the hook fails. +- When a task's natural home is a file already at its ceiling, the default + answer is a new module, not an exception. +- State the intended decomposition in the implementation plan and in the pull + request description, so a reviewer sees the structural intent rather than an + unexplained new file. + +## 3. Meeting the Budget Honestly + +Reducing the line count without reducing complexity is a violation of this rule, +even when the number goes down. The following are **not** acceptable ways to +pass the check: + +- deleting blank lines or collapsing formatting +- inlining variables, shortening identifiers, or packing statements onto one line +- moving code into comments, or relocating it to an already-oversized file +- disabling or excluding the check for the file + +Reduce the file by moving responsibility out of it: + +1. **Single Responsibility (SRP)** — enumerate the distinct reasons the file has + to change. Each separate reason belongs in its own module. +2. **Open/Closed (OCP)** — find the conditional or `switch` that grows whenever a + case is added, and replace it with polymorphism (Strategy) or a + registry/Factory, so future cases are added without editing this file. +3. **DRY** — extract logic that is repeated inside the file or duplicated + elsewhere in the codebase. +4. **Dependency direction** — separate I/O, parsing, and configuration from core + logic so each side is testable on its own. +5. **Interface Segregation / composition** — split a class that serves several + callers with disjoint needs, rather than growing one wide interface. + +Load the **`software-architect`** skill when deciding how to split a file: it +carries the SOLID guidance and the design-pattern catalogue (Facade, Strategy, +Observer, Factory) that these steps refer to. Reach for it during design, not +after the hook rejects the commit. + +## 4. Enforcement + +- **Pre-commit** blocks a commit that pushes a file over budget or grows a + grandfathered file, and tightens ceilings for files that shrank. +- **Agent PostToolUse hooks** report the same violation immediately after an + edit, so the problem surfaces while the context is still open. +- `.agents/file-size-baseline.json` is committed. Do **not** hand-edit the + `files` map to excuse a violation; entries are removed automatically once a + file is within budget. + +Generated files (`@generated`, `DO NOT EDIT` headers) and third-party code +(recognised vendor copyright headers, `vendor/`, `third_party/`, submodules) are +neither checked nor grandfathered — they are not ours to decompose. If vendored +code still slips into the baseline, add a path fragment to the `exclude` list in +`.agents/file-size-baseline.json` and re-run: + +```bash +python3 .agents/hooks/check_file_size_budget.py --init +``` diff --git a/.agents/rules/12-ai-context-exclusion-rules.md b/.agents/rules/12-ai-context-exclusion-rules.md new file mode 100644 index 0000000..e413a2f --- /dev/null +++ b/.agents/rules/12-ai-context-exclusion-rules.md @@ -0,0 +1,44 @@ +--- +name: ai-context-exclusion +description: What must never be read by an AI agent in this repository, how exclusion is enforced, and proactive .aiignore maintenance. +trigger: always_on +--- +# AI Context Exclusion + +`.aiignore` at the repository root is the single source of truth for files that +must not enter a model's context: secrets, third-party code and SDKs, build +output, large binaries, and anything carrying personal data. + +## How it is enforced + +No agent platform reads `.aiignore` natively. `.agents/hooks/compile_aiignore.py` +translates it into the mechanism each platform actually honours: + +| Platform | Mechanism | +|---|---| +| Antigravity / ripgrep-based search | `.ignore` (generated) | +| Claude Code | `permissions.deny` `Read(./…)` rules in `.claude/settings.json` | +| OpenCode | `permission.read` / `glob` / `grep` deny map in `opencode.json` | +| GitHub Copilot | org-level content exclusion, applied server side — paste `.github/copilot-content-exclusion.yml` into GitHub settings | + +## Rules for agents + +1. **Never read, quote, or summarise a file matching `.aiignore`.** If a task + appears to require one, stop and say so rather than working around the + exclusion. +2. **Never weaken the exclusion to finish a task** — do not delete patterns, + add negations, or bypass the derived deny rules. +3. **Edit `.aiignore`, never the generated targets.** `.ignore` and + `.github/copilot-content-exclusion.yml` are overwritten by the compiler, and + the deny rules in the platform configs are rewritten in place. +4. **After changing `.aiignore`, run the compiler and commit the results + together**, or pre-commit will reject the change as out of date: + + ```bash + python3 .agents/hooks/compile_aiignore.py + ``` +5. **Copilot exclusion is not active until a human applies it in GitHub.** + Adding a secret pattern to `.aiignore` does not retroactively hide it from + Copilot; treat any exposed credential as compromised and rotate it. +6. **Proactive AI-Ignore Maintenance**: + Maintain `.aiignore` whenever introducing new heavy, generated, log, or vendor artifacts. Run `python3 .agents/hooks/compile_aiignore.py` immediately to recompile platform deny rules. diff --git a/.agents/rules/13-dependency-management-rules.md b/.agents/rules/13-dependency-management-rules.md new file mode 100644 index 0000000..3fd1261 --- /dev/null +++ b/.agents/rules/13-dependency-management-rules.md @@ -0,0 +1,117 @@ +--- +name: dependency-management +description: Reuse before rebuild — search existing and published dependencies, and check licence compatibility, before writing new code. +trigger: always_on +--- +# Dependency Management — Do Not Reinvent the Wheel + +Hand-rolled implementations of solved problems are the most expensive code in a +repository: they carry no upstream security fixes, no community documentation, +and no tests but the ones you happen to write. Before implementing any +non-trivial capability, establish that it does not already exist. + +This applies at **four** points, not just while typing. + +## 1. During Design + +Before choosing an approach, state in the design or plan **which existing +dependency provides this, or why none does**. "We will write our own X" is a +decision that needs a reason — an unmet requirement, a licence conflict, or an +unmaintained ecosystem — not a default. + +## 2. Before Adding Anything — Check What Is Already Here + +The cheapest dependency is one already in the manifest: no new supply chain, no +new licence, no new review. + +- **Conan** — already-declared dependencies live in `conanfile.py`. Search them first: + ```bash + conan graph info . --format=json | head -40 + ``` + +Also check the internal ecosystem: a sibling UltiMaker repository or a shared +library may already solve this, and reusing it keeps behaviour consistent across +products. + +## 3. If Nothing Exists Internally — Search the Registry + +- **Conan**: + ```bash + conan search -r=all + conan inspect / # includes license + ``` + +Judge a candidate on evidence, not popularity alone: + +- **Maintenance**: recent releases, issues being answered, no unpatched CVEs. +- **Fit**: solves the actual problem without dragging in a framework. +- **Weight**: for frontend code, check the bundle cost; for embedded and WASM + targets, check binary size and whether it allocates. +- **Transitive cost**: a package with a large dependency tree imports every one + of that tree's licences and vulnerabilities too. + +Prefer the option this repository or its siblings already use over an equivalent +alternative — consistency is worth more than a marginal feature advantage. + +## 4. Licence Compatibility — Check Before Adding, Not After + +This project is licensed **GNU GENERAL PUBLIC** (declared in `LICENSE`). + + +Verify that each new dependency's licence is compatible with this project's +licence and its distribution model. Strong copyleft licences (GPL, AGPL) impose +obligations on distributed software; a package with no declared licence is "all +rights reserved" and cannot be used at all. + +Record the licence of every dependency you add. If you cannot determine it, that +is itself a blocker. + +## 5. When Opening the Pull Request + +Any new dependency must be called out explicitly in the PR description with: + +- **What it replaces** — the code you did not write. +- **Why this one** — maintenance status and the alternatives rejected. +- **Its licence**, and why that is compatible with the GNU GENERAL PUBLIC licence. +- **Its transitive footprint** — how many packages it actually pulls in. + +Pin the version, commit the updated lockfile in the same change, and never add a +dependency as a drive-by in a change about something else. + + +## Industry Best Practices & GitHub Code Search Rules + +1. **Industry Best Practices First**: + - Before implementing complex algorithms, security/crypto routines, hardware protocol drivers, or architectural abstractions, agents **MUST investigate industry best practices and existing open-source implementations**. + - Do NOT write bespoke, custom implementations when verified, licensed open-source packages, standard library routines, or internal UltiMaker utilities already exist. + +2. **Multi-Dimensional Code Evaluation (`code-best-practices-search`)**: + - Load and execute `UltiCortex/skills/software/code-best-practices-search` when researching architectural design patterns, security postures, or benchmarking open-source implementations. + - Candidate implementations MUST be evaluated across 7 core dimensions: + 1. **Security Posture** (OWASP, parameterization, input sanitization, safe memory bounds) + 2. **Reliability & Resilience** (Fail-secure behavior, explicit error propagation, zero swallowed exceptions) + 3. **Agentic Readiness** (Decomposed <400 line modules, strongly typed signatures, decoupled seams for AI testing) + 4. **Execution Efficiency** (Async/concurrency models, thread safety, memory allocation footprint) + 5. **Dependency Weight** (Minimal third-party dependencies, standard library utilization) + 6. **Coupling & Cohesion** (SOLID principles, DRY, separation of domain logic from boilerplate) + 7. **Tech Stack Fit** (Strict alignment with target codebase rules and standards) + +3. **Skill Discovery & Execution (`github-search-code`)**: + - Load and execute `UltiCortex/skills/software/github-search-code` when executing precise `gh search code` CLI queries, handling exclusions (`-filename:` vs `-path:`), or using `-w` for browser regex search. + - Use the GitHub CLI (`gh search code`) to audit existing code bases, discover architectural patterns, and extract verified idioms across the organization and GitHub: + ```bash + # Search across the organization for existing code patterns or utilities + gh search code "search_term" --owner="Ultimaker" --language="python" + + # Search for specific cryptographic or security configurations + gh search code "crypto/md5" --owner="Ultimaker" --language="go" + + # Exclude test files using query syntax with -- + gh search code -- "function language:python -filename:test" + ``` + +4. **Programmatic Hydration & Raw Blob Review**: + - Use the `search_and_hydrate.sh` script or `gh api` with `-H "Accept: application/vnd.github.raw+json"` to fetch untruncated file blobs and perform pairwise Elo evaluations across implementations: + ```bash + UltiCortex/skills/software/code-best-practices-search/scripts/search_and_hydrate.sh "ClientName" --language python -L 5 + ``` diff --git a/.agents/rules/14-complexity-budget-rules.md b/.agents/rules/14-complexity-budget-rules.md new file mode 100644 index 0000000..9321045 --- /dev/null +++ b/.agents/rules/14-complexity-budget-rules.md @@ -0,0 +1,65 @@ +--- +name: complexity-budget +description: Cyclomatic complexity budget for code the agent writes, ratcheted against git history. +trigger: always_on +--- +# Complexity Budget + +A function with many independent paths is hard to test, hard to review, and the +place bugs accumulate. This repository budgets **cyclomatic complexity 10 +per function** — chosen from this codebase's own distribution (median 1, 90th percentile 6, worst 80). + +## 1. What Is Actually Checked + +Only **what you change**. The hook compares each changed file against its +version in git, function by function: + +- a **new** function must be at or under 10; +- an **existing** function may become simpler, never more complex; +- a function you do not touch is never reported, however complex it is. + +There is no repository-wide scan and no stored baseline — git is the baseline. +Pre-existing complexity is not your deliverable, but you may not add to it. + +```bash +python3 .agents/hooks/check_complexity.py --report +``` + +## 2. During Design and Planning + +Run the report on the functions a change will touch before deciding how to +implement it. If the work adds branching to a function that is already at or +over budget, the plan must say **which paths move out and where** — a new +strategy, a lookup table, a separate step. Decide that during design, not when +the hook rejects the commit. + +## 3. Reducing Complexity Honestly + +Splitting a function at an arbitrary line, or moving branches into a helper +called from exactly one place, moves complexity without reducing it. Both will +be rejected in review even though the number drops. + +Reduce the number of independent paths: + +1. **Guard clauses** — return early on error and edge cases so the main path + stops being nested. +2. **Replace conditional with polymorphism** — when branching is on a type or a + kind, give each case its own implementation. +3. **Table or registry** — a long `if`/`elif` chain mapping a value to an action + is data, not control flow. +4. **Extract a cohesive step** — a named operation that makes sense on its own + and can be tested on its own. + +If the complexity is genuinely irreducible — a parser, a state machine, a +hardware protocol — say so explicitly in the pull request. That is a reviewable +claim; silently restructuring the code to game the metric is not. + +## 4. Enforcement + +- **Pre-commit** blocks a commit that adds a function over budget or makes an + existing function more complex. +- **PostToolUse** reports the same immediately after an edit, advisory only, so + the problem surfaces while the context is still open. +- Requires `lizard` (`pip install lizard`). Without it the check reports that it + is skipping and exits cleanly — it never blocks a commit because a tool is + missing. diff --git a/.agents/rules/15-commit-body-standards.md b/.agents/rules/15-commit-body-standards.md new file mode 100644 index 0000000..764fe4d --- /dev/null +++ b/.agents/rules/15-commit-body-standards.md @@ -0,0 +1,31 @@ +--- +name: commit-body-standards +description: Enforces explanatory commit message bodies describing why the change was needed and how it works. +trigger: always_on +--- +# Commit Body Standards + +Every commit in `libUvula` MUST include a substantive commit body in addition to the conventional subject line. + +## Rationale +Historical analysis demonstrates that 79% of commits in `libUvula` provide explanatory bodies. Given that `libUvula` is a core mathematical and geometry library consumed across Python (Cura) and WebAssembly (Digital Factory / Neoprep) pipelines, commit messages serve as permanent documentation for downstream consumers and bisect analysis. + +## Invariants + +1. **Explain Why and How**: + - The subject line states **what** changed (`[UC-3697] Add normal clustering threshold option`). + - The commit body MUST explain **why** the change was necessary (the motivation or bug addressed) and **how** it achieves the goal (architectural approach, edge cases handled). + +2. **Structure**: + ``` + [UC-3697] Short description of what changed (max 72 chars) + + Explain why this change is necessary and the problem being solved. + + Detail how the implementation works, any mathematical or algorithmic + assumptions (e.g. normal grouping angle, xatlas atlas layout), and + downstream impact on bindings (pyUvula, UvulaJS). + ``` + +3. **No Empty Bodies**: + - Commits with only a single subject line (unless purely trivial reverts or automated version bumps) are prohibited. diff --git a/.agents/rules/21-cpp-core-architecture-rules.md b/.agents/rules/21-cpp-core-architecture-rules.md new file mode 100644 index 0000000..5be20bd --- /dev/null +++ b/.agents/rules/21-cpp-core-architecture-rules.md @@ -0,0 +1,42 @@ +--- +name: cpp-core-architecture +description: Core C++ architecture, error handling, mined idiomatic preferences, memory safety, and testing rules. +trigger: glob +glob: "**/*.cpp,**/*.hpp,**/*.cc,**/*.cxx,**/*.h,**/CMakeLists.txt,**/*.cmake" +paths: + - "**/*.cpp" + - "**/*.hpp" + - "**/*.cc" + - "**/*.cxx" + - "**/*.h" + - "**/CMakeLists.txt" + - "**/*.cmake" +--- +# Core C++ Architecture Rules + +0. **Load the domain skills first**: Load the **`cpp-pro`** skill before designing, implementing, or refactoring C++ code (alongside `cmake`, and `conan-2` for dependencies). All C++23 modernization work MUST follow the staged modernization playbook from `cpp-pro`. + +1. **Modern C++ & Memory Safety (`cpp-pro` Modernization Playbook)**: + - Adhere to C++20/23 standards. Modernize new code and refactorings following the staged `cpp-pro` progression: + 1. **Mechanical & Static Analysis**: Enforce static analysis (`clang-format`, `clang-tidy`, `cppcheck`) with zero warnings. + 2. **Ownership & RAII Modernization (ISO Core Guidelines R.1, R.3, F.7)**: Zero raw owning `new`/`delete`; default to `std::unique_ptr`, use `std::shared_ptr` only for shared ownership, and pass non-owning references as `T&` or `std::span`. + 3. **API & Vocabulary Modernization**: Replace out-parameters with structured returns, sentinel values with `std::optional` (using monadic `and_then`, `transform`, `or_else`), and transition error paths towards `std::expected`. + 4. **C++23 Standard Features**: Adopt standard `std::ranges`, `std::to_underlying` for enums, `std::unreachable()` for exhaustive branching, compile-time `constexpr` / `consteval` evaluation, and deducing `this`. + - No raw owning `new`/`delete`; use smart pointers and containers. +2. **Error Handling — Exception Safety**: + - The codebase uses exceptions. New code MUST provide at least the strong or basic exception-safety guarantee via RAII. + - Never swallow exceptions silently; catch narrowly, log with context, and rethrow or convert at module boundaries. +3. **Automated Unit Testing**: + - Every C++ module MUST be covered by unit tests run via `ctest` (Catch2/GoogleTest as configured). + - Keep computation pure and stateless where the module design allows (e.g. WASM / settings resolution targets). + +5. **Emscripten / WASM Target**: + - This library compiles to WebAssembly via Emscripten. Keep the computation core pure and stateless; side effects belong in the host bindings layer. + - Do NOT let exceptions cross the embind/WASM boundary — catch at the binding layer and convert to structured error results. + - Any change to the exported API (`EMSCRIPTEN_BINDINGS`, embind interfaces) MUST keep the JS/TS consumer contract in sync. + - Verify the Emscripten target still builds before PR creation (in addition to the native build). + +6. **Conan 2 Dependency Management** (required: `>=2.7.0`): + - Use Conan 2 idioms only: `from conan import ConanFile`, `conan.tools.cmake` (CMakeToolchain/CMakeDeps), `cmake_layout`. + - NEVER import from the legacy `conans` (v1) namespace. + - Pin dependency versions/ranges in `conanfile.py` or `requirements.yaml`; do not add ad-hoc FetchContent or system-package dependencies for things Conan should own. diff --git a/.agents/rules/34-library-consumer-contract-rules.md b/.agents/rules/34-library-consumer-contract-rules.md new file mode 100644 index 0000000..9afe22e --- /dev/null +++ b/.agents/rules/34-library-consumer-contract-rules.md @@ -0,0 +1,59 @@ +--- +name: library-consumer-contract +description: This repository's public surfaces (include/, UvulaJS/, pyUvula/) are consumed by Cura and Neoprep/DF — every exported symbol and binding is a cross-repository contract. +trigger: glob +glob: "include/**,UvulaJS/**,pyUvula/**" +paths: + - "include/**" + - "UvulaJS/**" + - "pyUvula/**" +--- +# Library Consumer Contract + +`libUvula` is a core geometry library consumed across native, Python, and WebAssembly ecosystems: + +- **UltiMaker Cura**: Consumes native C++ headers (`include/`) and Python bindings (`pyUvula`) via Conan 2 packages. +- **Neoprep & Digital Factory**: Consumes WebAssembly bindings (`UvulaJS`) via the npm package `@ultimaker/uvulajs`. +- **CLI / Tools**: Consumes `include/` and `libuvula` static library in `cli/`. + +A service is bounded by its own process: rename an internal function and nothing outside notices. `libUvula` has no such luxury. Its public surfaces (`include/`, `UvulaJS/`, `pyUvula/`) are compiled into downstream binaries and bundlers across different repositories. + +## What Constitutes a Breaking Contract Change + +Across `include/`, `UvulaJS/`, and `pyUvula/`, all of the following are breaking contract changes, not mere refactors: + +1. **Renaming or removing any exported symbol** — class, function, constant, + type, or module path. A consumer imports it by name. +2. **Changing a model or schema field** — removing it, renaming it, narrowing + its type, or making an optional field required. Adding an optional field + with a default is the only safe shape change. +3. **Changing a default value or an enum member**, including its wire value. +4. **Moving a file between packages**, even with the symbol re-exported: a + consumer may import the module path directly. + +## How to make one anyway + +1. **Name the consumers in the pull request.** + Enumerate them before you start — a consumer you did not check is a + consumer you broke. Search the organisation for pins of this repository: + + ```bash + gh search code --owner Ultimaker --filename .gitmodules "$(basename "$PWD")" + ``` + +2. **Land this repository first, then move each consumer's pointer.** A + submodule pointer bump is its own commit and names the revision it moves to. + Never commit inside a consumer's mounted copy of this tree. +3. **Additive first.** Where a breaking shape is unavoidable, ship the new + surface alongside the old one, migrate the consumers, and remove the old + surface in a later ticket — not in the same one. +4. **Say so in the commit message.** The consumers' agents read this + repository's history to work out what moved under them. + +## What this rule does not cover + +The *meaning* of the contract — which peer owns which definition, what happens +operationally when a field changes — is the ecosystem-contract investigator's +subject (`.agents/agents/ecosystem_contract_investigator/agent.md`). This rule +covers only what is provable from the tree: that the surface is shared, and +that a change to it is never local. diff --git a/.agents/rules/35-wasm-embind-boundary-rules.md b/.agents/rules/35-wasm-embind-boundary-rules.md new file mode 100644 index 0000000..5a72a8a --- /dev/null +++ b/.agents/rules/35-wasm-embind-boundary-rules.md @@ -0,0 +1,50 @@ +--- +name: wasm-embind-boundary +description: WebAssembly/Embind boundary rules, link options contract, Three.js matrix conventions, and TypeScript type definition synchronization for UvulaJS. +trigger: glob +glob: "UvulaJS/**,src/xatlas.cpp" +paths: + - "UvulaJS/**" + - "src/xatlas.cpp" +--- +# WebAssembly & Embind Boundary Rules (UvulaJS) + +The `UvulaJS` module compiles `libUvula` geometry and unwrap algorithms to WebAssembly via Emscripten (`@ultimaker/uvulajs`). It is consumed directly by **Neoprep** (React 18 / Three.js web preparation) and **Digital Factory** web runtimes inside browser Web Workers. + +## 1. Bundler Link Options Contract +The Emscripten link options in `UvulaJS/CMakeLists.txt` constitute a strict public contract with downstream bundlers (Webpack 5, Vite, Rollup): +- `-s ENVIRONMENT=web,worker,node`: Ensures execution in browser Web Workers and Node.js test runners. +- `-s USE_ES6_IMPORT_META=1`: Enables ES6 module asset resolution for bundlers. +- `-s EXPORT_NAME=uvula`: Defines the default factory export name. +- `-s MODULARIZE=1`: Emits a factory function (`uvula()`) returning a Promise resolving to the initialized WASM module instance. +- `-s EXPORT_ES6=1`: Emits standard ES module syntax (`export default`). +- `-s SINGLE_FILE=1`: Inlines the WASM binary directly into the JS bundle as base64, eliminating asynchronous binary file loading in worker bundles. +- `-s ALLOW_MEMORY_GROWTH=1`: Accommodates large mesh allocations dynamically. +- `--emit-tsd uvula_js.d.ts`: Emits TypeScript declarations synchronized with Embind bindings. + +Changing any of these flags alters how downstream consumers import the package and constitutes a breaking public API change. + +## 2. Three.js Matrix & Coordinate System Conventions +1. **Column-Major Matrix Transposition**: + - Three.js represents transform and camera projection matrices in 16-element column-major flat arrays (`matrixWorldInverse`, `projectionMatrix`). + - `Matrix44F` in `libUvula` uses row-major nested arrays `float[4][4]`. + - `UvulaJS.cpp` MUST transpose elements during marshaling: `matrix_data[i % 4][i / 4] = matrix_array[i]`. +2. **Viewport Stroke Projection Parameters**: + - `stroke_polygon`: 2D viewport coordinates in screen pixels (`Point2F`). + - `camera_projection_matrix`: Combined Model-View-Projection (MVP) matrix transforming 3D mesh vertices into 2D viewport space. + - `camera_normal`: World-space view direction vector (`Vector3F`) used for backface culling (`face_normal.dot(camera_normal) < 0`). + - `face_id`: Seed face index on the 3D mesh hit by the mouse raycast in the Three.js viewport. +3. **UV Space & Texture Coordinates**: + - `smartUnwrap` generates normalized UV atlas coordinates in `[0, 1]`. + - `doProject` outputs polygon vertices scaled to the target texture pixel dimensions (`texture_width`, `texture_height`). + - WebGL / Three.js textures use standard UV bottom-left origin `(0, 0)`; avoid unintended V-axis flipping. + +## 3. Embind Type Declarations & TypeScript Sync +- `EMSCRIPTEN_DECLARE_VAL_TYPE` aliases (`Float32Array`, `Int32Array`, `PolygonArray`) must match the typed arrays passed across the boundary. +- Any change to `EMSCRIPTEN_BINDINGS(uvula)` requires regenerating and verifying `uvula_js.d.ts`. +- Ensure all public functions exposed to JavaScript take and return strongly typed structures compatible with `@ultimaker/uvulajs` declarations in Neoprep. + +## 4. Memory & Exception Safety Across the WASM Boundary +- **No Uncaught C++ Exceptions**: WebAssembly builds without `-fwasm-exceptions` trigger uncatchable runtime aborts if a C++ exception crosses Embind. Catch all internal exceptions at the binding layer and return structured status objects (`{ success: false, error: "..." }`) or boolean indicators. +- **Buffer Bounds Validation**: Flat coordinate arrays passed from JS MUST have their lengths verified before indexing (`vertices.length % 3 == 0`, `indices.length % 3 == 0`, `matrix.length == 16`). +- **Single-Threaded Pure Computation**: WASM runs in single-threaded Web Workers. Avoid thread-local statics, global shared state, or blocking mutexes. Ensure `XA_MULTITHREADED 0` is maintained for `xatlas`. diff --git a/.agents/rules/36-pyuvula-binding-rules.md b/.agents/rules/36-pyuvula-binding-rules.md new file mode 100644 index 0000000..a26dd29 --- /dev/null +++ b/.agents/rules/36-pyuvula-binding-rules.md @@ -0,0 +1,24 @@ +--- +name: pyuvula-binding +description: Python bindings rules for pyUvula (UltiMaker Cura) using pybind11, numpy buffer safety, and GIL release during geometry calculations. +trigger: glob +glob: "pyUvula/**" +paths: + - "pyUvula/**" +--- +# Python Bindings Rules (pyUvula) + +The `pyUvula` module exposes `libUvula` unwrapping and projection algorithms to Python environments (such as Cura). + +## 1. Zero-Copy & Buffer Safety +- Use `pybind11::buffer_info` to inspect raw numpy arrays and construct non-owning `std::span` views where appropriate. +- Strictly validate input dimensions (`ndim == 2`) and types before interpreting buffer pointers. +- Construct output numpy arrays using appropriate strides and format descriptors (`py::format_descriptor::format()`). + +## 2. GIL Management +- Heavy mathematical computation (e.g., `smartUnwrap` or mesh projection) MUST release the Python GIL via `py::gil_scoped_release release;` to allow multi-threaded Python execution. +- Re-acquire or retain GIL only when interacting with Python objects (`py::tuple`, `py::list`, `py::array_t`). + +## 3. Versioning & Package Synchronization +- The Python module version is sourced from `PYUVULA_VERSION` (defined via CMake/Conan package recipe). +- Any change to the signature of `unwrap` or `project` must be synchronized with Cura and Python integration workflows. diff --git a/.agents/rules/37-package-identity-rules.md b/.agents/rules/37-package-identity-rules.md new file mode 100644 index 0000000..c9f7185 --- /dev/null +++ b/.agents/rules/37-package-identity-rules.md @@ -0,0 +1,32 @@ +--- +name: package-identity +description: Package identity, dual packaging pipeline (Conan 2 uvula and npm @ultimaker/uvulajs via cura-workflows), and versioning rules. +trigger: glob +glob: "conanfile.py,UvulaJS/package.json,UvulaJS/CMakeLists.txt,.github/workflows/package.yml" +paths: + - "conanfile.py" + - "UvulaJS/package.json" + - "UvulaJS/CMakeLists.txt" + - ".github/workflows/package.yml" +--- +# Package Identity & Dual Packaging Pipeline + +`libUvula` publishes two distinct artifacts through central UltiMaker reusable GitHub Actions workflows (`Ultimaker/cura-workflows`): + +## 1. Native / Python Package: `uvula` (Conan 2) +- **Manifest**: `conanfile.py` (requires Conan `>=2.7.0`). +- **CI Workflow**: `cura-workflows/.github/workflows/conan-package.yml` in `.github/workflows/package.yml`. +- **Consumers**: **UltiMaker Cura** and Uranium plugins. +- **Published Surface**: Static C++ library `libuvula`, public C++ headers `include/`, and Python pybind11 module `pyUvula`. +- **Versioning**: Version is computed and assigned during CI packaging via `conan create` based on Git tags/refs. Do not hardcode conflicting version manifests. + +## 2. WebAssembly Package: `@ultimaker/uvulajs` (npm) +- **Manifest**: `UvulaJS/package.json` and `UvulaJS/CMakeLists.txt`. +- **CI Workflow**: `cura-workflows/.github/workflows/npm-package.yml` in `.github/workflows/package.yml`. +- **Consumers**: **Neoprep** (`@ultimaker/neoprep`) and **Digital Factory** web runtimes. +- **Published Surface**: WebAssembly binary bundle `uvula_js.js` / `uvula_js.wasm` with generated TypeScript definitions `uvula_js.d.ts`. +- **Workflow Triggers**: Package builds are triggered on `main`, `master`, and feature branches matching `NP-*`, `PP-*`, `CURA-*`, `DEV-*`, and `UC-*` (ensuring Neoprep / Cloud branches receive package test artifacts). + +## 3. Package Invariants +- **No Hand-Rolled Manifest Divergence**: `conanfile.py` and `UvulaJS/package.json` must remain the single sources of truth. +- **Downstream Consumer Pinning**: Consumers pin specific versions or commit refs. Any breaking change to `include/`, `UvulaJS/`, or `pyUvula/` requires coordinating paired downstream PRs across Cura and Neoprep repositories. diff --git a/.agents/rules/40-skill-discovery-index-rules.md b/.agents/rules/40-skill-discovery-index-rules.md new file mode 100644 index 0000000..bb79119 --- /dev/null +++ b/.agents/rules/40-skill-discovery-index-rules.md @@ -0,0 +1,39 @@ +--- +name: skill-discovery-index +description: Full index of UltiCortex catalogue skills no automatic trigger matched — consult when a task needs domain knowledge the matched skills lack. +trigger: model_decision +--- +# UltiCortex Skill Index (unmatched skills) + +These catalogue skills matched no automatic trigger for this repository. By +construction they are the ones static detection judged irrelevant — but static +detection cannot see every reason a skill applies. If the current task touches +one of these domains, install and load the skill before implementing: + +```bash +gh skill install Ultimaker/UltiCortex +``` + +- **code-best-practices-search** — Executes native GitHub CLI (`gh search code`) searches to retrieve untruncated raw file blobs and evaluate implementations against multi-dimensional architectural constraints (Security, Reliability, Agentic Development, Efficiency,…. +- **github-search-code** — Search for code across GitHub repositories using the `gh search code` CLI tool. +- **jlink-debugging** — Expert headless debugging, flashing, RTT telemetry, and RAM state injection using SEGGER J-Link probes. +- **python-pro** — Use this agent when you need to build type-safe, production-ready Python code for web APIs, system utilities, or complex applications requiring modern async patterns and extensive type coverage. +- **rigol-ds1054z** — Automated oscilloscope control over USB using PyVISA to capture, analyze, and synchronize electrical signals. +- **sentry-issue-fixer** — Query the Ultimaker Sentry MCP server (ultimaker-o7/griffin) to find the most user-impacting bugs in the S6, S8, F4, and F4+ firmware. +- **ultimaker-atlas** — Bring up stardust-atlas (Digital Factory meta-repo): submodules, docker compose, DB seeding, and its MCP server. +- **ultimaker-cloud-development** — Cross-repository guide to the UltiMaker Digital Factory cloud: the stardust constellation, GCP platform, environments and deployment, and the shared backend/frontend patterns. +- **ultimaker-cura-development** — Comprehensive guide to working with Ultimaker Cura application architecture, Uranium plugins, container stacks, fdm_materials profiles, and cloud gateway integrations. +- **ultimaker-curator-development** — Comprehensive guide to working with the Curator C++ configuration engine, the setting stack resolver, fdmprinter specifications, and WebAssembly bindings. +- **ultimaker-digital-factory** — Comprehensive skill for interacting with the UltiMaker Digital Factory (DF) API. +- **ultimaker-firmware-development** — Cross-repository guide to UltiMaker printer firmware: product lines, the repo constellation, the jedi build/release pipeline, and interface discipline. +- **ultimaker-gcode-architecture** — Canonical Marlin & UltiMaker custom G/M-code registry, dispatch map, and ecosystem synchronization protocol. +- **ultimaker-material-knowledge** — Plastics engineering calculations for extrusion, viscosity models, thermal properties, and polymer processing. +- **ultimaker-neoprep-development** — Comprehensive guide to developing, building, testing, and operating the Neoprep React applications, including integration with Curator and CuraEngine WASM modules. +- **ultimaker-printer-logs** — Diagnostic log parser for Ultimaker 3D printers, diagnosing reboots, OOMs, and mechanical anomalies. +- **ultimaker-printer-ssh** — SSH debugging, systemd-dbus, and hardware mocking on S-line, Next-Gen, and Factor 4(+) printers. +- **ultimaker-retrospective-engineering** — Socratic retro coach to interview on workflow friction and propose deterministic hooks and skill upgrades. +- **ultimaker-sentry** — Query and triage Ultimaker firmware crashes in Sentry (ultimaker-o7) via MCP tools and REST API, covering project topology, device tag filters, and machine-level event lookup. +- **ultimaker-support-articles** — Use this skill when you need to find articles on the UltiMaker or MakerBot support pages, extract knowledge from public-facing documentation, download resources from support articles, or verify if a support article is up-to-date with…. +- **ultimaker-translation** — Automates 3D printing software localization by reordering PO/POT files alphabetically, performing GraphRAG translations mapped to a local Neo4j dump, and running language-specific reviewer audits to generate clean dual Pull Requests. +- **ultimaker-virtual-printer-fleet** — Tools and instructions for AI agents to monitor and command a fleet of virtual UltiMaker printers. +- **web-accessibility-standard** — WCAG 2.1 AA checklist, keyboard focus rings, semantic HTML, and WebGL/Form accessibility standards. diff --git a/.aiignore b/.aiignore new file mode 100644 index 0000000..c9df169 --- /dev/null +++ b/.aiignore @@ -0,0 +1,82 @@ +# .aiignore — files that must not be fed to an AI model +# +# This file is the SINGLE SOURCE OF TRUTH for AI context exclusion in this +# repository. Edit it here, then run: +# +# python3 .agents/hooks/compile_aiignore.py +# +# which translates these patterns into the mechanism each agent platform +# actually honours (.ignore for ripgrep-based search, deny rules for Claude +# Code and OpenCode, and a paste-ready list for GitHub Copilot's org-level +# content exclusion). No platform reads .aiignore directly. +# +# Syntax is .gitignore syntax. + +# Secrets and credentials +# Never let key material or tokens enter a model context window. +*.pem +*.key +*.p12 +*.pfx +*.jks +*.keystore +id_rsa +id_ecdsa +id_ed25519 +*.gpg +*.asc +.env +.env.* +!.env.example +*.tfvars +.netrc +.npmrc +credentials.json +service-account*.json +**/secrets/** + +# Third-party code and SDKs +# Not ours to change; reading it wastes context and invites edits to vendored trees. +vendor/ +third_party/ +node_modules/ +software/sdk/ +**/freertos_kernel/ +**/cmsis/ + +# Build output and caches +# Generated artefacts carry no design intent. +build/ +dist/ +out/ +target/ +.venv/ +venv/ +__pycache__/ +*.o +*.a +*.so +*.dylib +*.dll +*.exe +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ + +# Large or binary payloads +# High token cost, near-zero reasoning value. +*.stl +*.3mf +*.gcode +*.ufp +*.obj +*.step +*.stp +conan.lock + +# Personal and customer data +# Privacy: log dumps and captures routinely contain PII. +**/*.log +**/logdump*/** +**/customer-data/** +**/pii/** diff --git a/.claude/agents/adversarial_pr_reviewer.md b/.claude/agents/adversarial_pr_reviewer.md new file mode 120000 index 0000000..a312a9f --- /dev/null +++ b/.claude/agents/adversarial_pr_reviewer.md @@ -0,0 +1 @@ +../../.agents/agents/adversarial_pr_reviewer/agent.md \ No newline at end of file diff --git a/.claude/agents/architecture_investigator.md b/.claude/agents/architecture_investigator.md new file mode 120000 index 0000000..cd57811 --- /dev/null +++ b/.claude/agents/architecture_investigator.md @@ -0,0 +1 @@ +../../.agents/agents/architecture_investigator/agent.md \ No newline at end of file diff --git a/.claude/agents/ecosystem_contract_investigator.md b/.claude/agents/ecosystem_contract_investigator.md new file mode 120000 index 0000000..bda1228 --- /dev/null +++ b/.claude/agents/ecosystem_contract_investigator.md @@ -0,0 +1 @@ +../../.agents/agents/ecosystem_contract_investigator/agent.md \ No newline at end of file diff --git a/.claude/hooks.json b/.claude/hooks.json new file mode 100644 index 0000000..64940cd --- /dev/null +++ b/.claude/hooks.json @@ -0,0 +1,52 @@ +{ + "safety-and-compliance": { + "PreToolUse": [ + { + "matcher": "run_command|write_to_file|replace_file_content|multi_replace_file_content", + "hooks": [ + { + "type": "command", + "command": "python3 hooks/pretool_guard.py", + "timeout": 15 + } + ] + }, + + { + "matcher": "run_command", + "hooks": [ + { + "type": "command", + "command": "python3 hooks/block_name_matched_kill.py", + "timeout": 10 + } + ] + }, + + { + "matcher": "run_command|write_to_file|replace_file_content|multi_replace_file_content", + "hooks": [ + { + "type": "command", + "command": "python3 hooks/suggest-skills.py --pre-edit", + "timeout": 15 + } + ] + } + + ], + "PostToolUse": [ + { + "matcher": "write_to_file|replace_file_content|multi_replace_file_content", + "hooks": [ + { + "type": "command", + "command": "bash hooks/post-edit-linter.sh", + "timeout": 60 + } + ] + } + + ] + } +} diff --git a/.claude/rules/01-jira-commit-standards.md b/.claude/rules/01-jira-commit-standards.md new file mode 120000 index 0000000..fa79a2f --- /dev/null +++ b/.claude/rules/01-jira-commit-standards.md @@ -0,0 +1 @@ +../../.agents/rules/01-jira-commit-standards.md \ No newline at end of file diff --git a/.claude/rules/02-security-and-paths.md b/.claude/rules/02-security-and-paths.md new file mode 120000 index 0000000..aea02c0 --- /dev/null +++ b/.claude/rules/02-security-and-paths.md @@ -0,0 +1 @@ +../../.agents/rules/02-security-and-paths.md \ No newline at end of file diff --git a/.claude/rules/04-build-test-and-deployment-rules.md b/.claude/rules/04-build-test-and-deployment-rules.md new file mode 120000 index 0000000..8e271d2 --- /dev/null +++ b/.claude/rules/04-build-test-and-deployment-rules.md @@ -0,0 +1 @@ +../../.agents/rules/04-build-test-and-deployment-rules.md \ No newline at end of file diff --git a/.claude/rules/05-ultimaker-skill-discovery-rules.md b/.claude/rules/05-ultimaker-skill-discovery-rules.md new file mode 120000 index 0000000..7df1ffa --- /dev/null +++ b/.claude/rules/05-ultimaker-skill-discovery-rules.md @@ -0,0 +1 @@ +../../.agents/rules/05-ultimaker-skill-discovery-rules.md \ No newline at end of file diff --git a/.claude/rules/06-pull-request-lifecycle-rules.md b/.claude/rules/06-pull-request-lifecycle-rules.md new file mode 120000 index 0000000..4c70f19 --- /dev/null +++ b/.claude/rules/06-pull-request-lifecycle-rules.md @@ -0,0 +1 @@ +../../.agents/rules/06-pull-request-lifecycle-rules.md \ No newline at end of file diff --git a/.claude/rules/07-owasp-security-rules.md b/.claude/rules/07-owasp-security-rules.md new file mode 120000 index 0000000..ff443c2 --- /dev/null +++ b/.claude/rules/07-owasp-security-rules.md @@ -0,0 +1 @@ +../../.agents/rules/07-owasp-security-rules.md \ No newline at end of file diff --git a/.claude/rules/08-scoped-changes-and-minimal-diffs.md b/.claude/rules/08-scoped-changes-and-minimal-diffs.md new file mode 120000 index 0000000..cd9a88f --- /dev/null +++ b/.claude/rules/08-scoped-changes-and-minimal-diffs.md @@ -0,0 +1 @@ +../../.agents/rules/08-scoped-changes-and-minimal-diffs.md \ No newline at end of file diff --git a/.claude/rules/09-atomic-bisect-safe-commits.md b/.claude/rules/09-atomic-bisect-safe-commits.md new file mode 120000 index 0000000..2dad32d --- /dev/null +++ b/.claude/rules/09-atomic-bisect-safe-commits.md @@ -0,0 +1 @@ +../../.agents/rules/09-atomic-bisect-safe-commits.md \ No newline at end of file diff --git a/.claude/rules/10-file-size-and-decomposition-rules.md b/.claude/rules/10-file-size-and-decomposition-rules.md new file mode 120000 index 0000000..ccc2705 --- /dev/null +++ b/.claude/rules/10-file-size-and-decomposition-rules.md @@ -0,0 +1 @@ +../../.agents/rules/10-file-size-and-decomposition-rules.md \ No newline at end of file diff --git a/.claude/rules/12-ai-context-exclusion-rules.md b/.claude/rules/12-ai-context-exclusion-rules.md new file mode 120000 index 0000000..d3be6d7 --- /dev/null +++ b/.claude/rules/12-ai-context-exclusion-rules.md @@ -0,0 +1 @@ +../../.agents/rules/12-ai-context-exclusion-rules.md \ No newline at end of file diff --git a/.claude/rules/13-dependency-management-rules.md b/.claude/rules/13-dependency-management-rules.md new file mode 120000 index 0000000..b0d9bfe --- /dev/null +++ b/.claude/rules/13-dependency-management-rules.md @@ -0,0 +1 @@ +../../.agents/rules/13-dependency-management-rules.md \ No newline at end of file diff --git a/.claude/rules/14-complexity-budget-rules.md b/.claude/rules/14-complexity-budget-rules.md new file mode 120000 index 0000000..d315912 --- /dev/null +++ b/.claude/rules/14-complexity-budget-rules.md @@ -0,0 +1 @@ +../../.agents/rules/14-complexity-budget-rules.md \ No newline at end of file diff --git a/.claude/rules/15-commit-body-standards.md b/.claude/rules/15-commit-body-standards.md new file mode 120000 index 0000000..5fb8335 --- /dev/null +++ b/.claude/rules/15-commit-body-standards.md @@ -0,0 +1 @@ +../../.agents/rules/15-commit-body-standards.md \ No newline at end of file diff --git a/.claude/rules/21-cpp-core-architecture-rules.md b/.claude/rules/21-cpp-core-architecture-rules.md new file mode 120000 index 0000000..604259c --- /dev/null +++ b/.claude/rules/21-cpp-core-architecture-rules.md @@ -0,0 +1 @@ +../../.agents/rules/21-cpp-core-architecture-rules.md \ No newline at end of file diff --git a/.claude/rules/34-library-consumer-contract-rules.md b/.claude/rules/34-library-consumer-contract-rules.md new file mode 120000 index 0000000..8bc4745 --- /dev/null +++ b/.claude/rules/34-library-consumer-contract-rules.md @@ -0,0 +1 @@ +../../.agents/rules/34-library-consumer-contract-rules.md \ No newline at end of file diff --git a/.claude/rules/35-wasm-embind-boundary-rules.md b/.claude/rules/35-wasm-embind-boundary-rules.md new file mode 120000 index 0000000..2a0e9ab --- /dev/null +++ b/.claude/rules/35-wasm-embind-boundary-rules.md @@ -0,0 +1 @@ +../../.agents/rules/35-wasm-embind-boundary-rules.md \ No newline at end of file diff --git a/.claude/rules/36-pyuvula-binding-rules.md b/.claude/rules/36-pyuvula-binding-rules.md new file mode 120000 index 0000000..52392bc --- /dev/null +++ b/.claude/rules/36-pyuvula-binding-rules.md @@ -0,0 +1 @@ +../../.agents/rules/36-pyuvula-binding-rules.md \ No newline at end of file diff --git a/.claude/rules/37-package-identity-rules.md b/.claude/rules/37-package-identity-rules.md new file mode 120000 index 0000000..39f074d --- /dev/null +++ b/.claude/rules/37-package-identity-rules.md @@ -0,0 +1 @@ +../../.agents/rules/37-package-identity-rules.md \ No newline at end of file diff --git a/.claude/rules/40-skill-discovery-index-rules.md b/.claude/rules/40-skill-discovery-index-rules.md new file mode 120000 index 0000000..107267d --- /dev/null +++ b/.claude/rules/40-skill-discovery-index-rules.md @@ -0,0 +1 @@ +../../.agents/rules/40-skill-discovery-index-rules.md \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..7ee4b1d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,93 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|Write|Edit|MultiEdit|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "python3 .agents/hooks/pretool_guard.py" + }, + { + "type": "command", + "command": "python3 .agents/hooks/block_name_matched_kill.py" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "bash .agents/hooks/post-edit-linter.sh" + } + ] + } + ] + }, + "enabledPlugins": ["clangd-lsp@claude-plugins-official"], + "claudeMdExcludes": [ + "**/software/sdk/**", + "**/vendor/**", + "**/third_party/**" + ], + "permissions": { + "deny": [ + "Read(./**/*.pem)", + "Read(./**/*.key)", + "Read(./**/*.p12)", + "Read(./**/*.pfx)", + "Read(./**/*.jks)", + "Read(./**/*.keystore)", + "Read(./**/id_rsa)", + "Read(./**/id_ecdsa)", + "Read(./**/id_ed25519)", + "Read(./**/*.gpg)", + "Read(./**/*.asc)", + "Read(./**/.env)", + "Read(./**/.env.*)", + "Read(./**/*.tfvars)", + "Read(./**/.netrc)", + "Read(./**/.npmrc)", + "Read(./**/credentials.json)", + "Read(./**/service-account*.json)", + "Read(./**/secrets/**)", + "Read(./**/vendor/**)", + "Read(./**/third_party/**)", + "Read(./**/node_modules/**)", + "Read(./**/software/sdk/**)", + "Read(./**/freertos_kernel/**)", + "Read(./**/cmsis/**)", + "Read(./**/build/**)", + "Read(./**/dist/**)", + "Read(./**/out/**)", + "Read(./**/target/**)", + "Read(./**/.venv/**)", + "Read(./**/venv/**)", + "Read(./**/__pycache__/**)", + "Read(./**/*.o)", + "Read(./**/*.a)", + "Read(./**/*.so)", + "Read(./**/*.dylib)", + "Read(./**/*.dll)", + "Read(./**/*.exe)", + "Read(./**/.mypy_cache/**)", + "Read(./**/.pytest_cache/**)", + "Read(./**/.ruff_cache/**)", + "Read(./**/*.stl)", + "Read(./**/*.3mf)", + "Read(./**/*.gcode)", + "Read(./**/*.ufp)", + "Read(./**/*.obj)", + "Read(./**/*.step)", + "Read(./**/*.stp)", + "Read(./**/conan.lock)", + "Read(./**/*.log)", + "Read(./**/logdump*/**)", + "Read(./**/customer-data/**)", + "Read(./**/pii/**)" + ] + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ef2b97c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.talismanrc merge=talismanrc diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..7ec0e36 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,6 @@ +Jira ticket: UC-XXX + +- [ ] Describe the changes that were made and why +- [ ] Add screenshot +- [ ] Unit tests +- [ ] E2E tests diff --git a/.github/agents/adversarial_pr_reviewer.md b/.github/agents/adversarial_pr_reviewer.md new file mode 120000 index 0000000..a312a9f --- /dev/null +++ b/.github/agents/adversarial_pr_reviewer.md @@ -0,0 +1 @@ +../../.agents/agents/adversarial_pr_reviewer/agent.md \ No newline at end of file diff --git a/.github/agents/architecture_investigator.md b/.github/agents/architecture_investigator.md new file mode 120000 index 0000000..cd57811 --- /dev/null +++ b/.github/agents/architecture_investigator.md @@ -0,0 +1 @@ +../../.agents/agents/architecture_investigator/agent.md \ No newline at end of file diff --git a/.github/agents/ecosystem_contract_investigator.md b/.github/agents/ecosystem_contract_investigator.md new file mode 120000 index 0000000..bda1228 --- /dev/null +++ b/.github/agents/ecosystem_contract_investigator.md @@ -0,0 +1 @@ +../../.agents/agents/ecosystem_contract_investigator/agent.md \ No newline at end of file diff --git a/.github/copilot-content-exclusion.yml b/.github/copilot-content-exclusion.yml new file mode 100644 index 0000000..f5186bb --- /dev/null +++ b/.github/copilot-content-exclusion.yml @@ -0,0 +1,63 @@ +# GENERATED FROM .aiignore by .agents/hooks/compile_aiignore.py +# +# GitHub Copilot content exclusion is applied SERVER SIDE and cannot be +# configured by a file in the repository. Paste the block below into: +# GitHub -> Organization (or Repository) Settings -> Copilot -> +# Content exclusion +# +# Until that is done, Copilot will still read these paths. + +"*": + - "/**/*.pem" + - "/**/*.key" + - "/**/*.p12" + - "/**/*.pfx" + - "/**/*.jks" + - "/**/*.keystore" + - "/**/id_rsa" + - "/**/id_ecdsa" + - "/**/id_ed25519" + - "/**/*.gpg" + - "/**/*.asc" + - "/**/.env" + - "/**/.env.*" + - "/**/*.tfvars" + - "/**/.netrc" + - "/**/.npmrc" + - "/**/credentials.json" + - "/**/service-account*.json" + - "/**/secrets/**" + - "/**/vendor/**" + - "/**/third_party/**" + - "/**/node_modules/**" + - "/**/software/sdk/**" + - "/**/freertos_kernel/**" + - "/**/cmsis/**" + - "/**/build/**" + - "/**/dist/**" + - "/**/out/**" + - "/**/target/**" + - "/**/.venv/**" + - "/**/venv/**" + - "/**/__pycache__/**" + - "/**/*.o" + - "/**/*.a" + - "/**/*.so" + - "/**/*.dylib" + - "/**/*.dll" + - "/**/*.exe" + - "/**/.mypy_cache/**" + - "/**/.pytest_cache/**" + - "/**/.ruff_cache/**" + - "/**/*.stl" + - "/**/*.3mf" + - "/**/*.gcode" + - "/**/*.ufp" + - "/**/*.obj" + - "/**/*.step" + - "/**/*.stp" + - "/**/conan.lock" + - "/**/*.log" + - "/**/logdump*/**" + - "/**/customer-data/**" + - "/**/pii/**" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..fdd347c --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,40 @@ +# GitHub Copilot Repository Instructions + +This repository uses a Quad-Agent setup. The single source of truth for agent +behavior is [AGENTS.md](../AGENTS.md) at the repository root, plus the rule +files in [.agents/rules/](../.agents/rules/). + +Apply, in order: +1. `AGENTS.md` — operational guide, tech stack, directory layout, PR flow. +2. `.agents/rules/*.md` — numbered rules (Jira/commit standards, security, + domain architecture, skill discovery, PR lifecycle, OWASP, scope, atomic + commits). +3. `DESIGN.md` (if present) — design tokens for any UI work. + +Hard constraints (mirrored in hooks under `.github/hooks/copilot-hooks.json`): +- Commit titles start with a bracketed Jira key (`[KEY-123] Title`); no + semantic prefixes (`feat:`, `fix:`). +- Never commit to `main`/`master`/`staging`; PRs open as DRAFT; humans merge. +- No secrets, no absolute local paths, no vendor/third-party edits. + +## Rule Index + +- [01-jira-commit-standards](../.agents/rules/01-jira-commit-standards.md) +- [02-security-and-paths](../.agents/rules/02-security-and-paths.md) +- [04-build-test-and-deployment](../.agents/rules/04-build-test-and-deployment-rules.md) +- [05-ultimaker-skill-discovery](../.agents/rules/05-ultimaker-skill-discovery-rules.md) +- [06-pull-request-lifecycle](../.agents/rules/06-pull-request-lifecycle-rules.md) +- [07-owasp-security](../.agents/rules/07-owasp-security-rules.md) +- [08-scoped-changes-and-minimal-diffs](../.agents/rules/08-scoped-changes-and-minimal-diffs.md) +- [09-atomic-bisect-safe-commits](../.agents/rules/09-atomic-bisect-safe-commits.md) +- [10-file-size-and-decomposition](../.agents/rules/10-file-size-and-decomposition-rules.md) +- [12-ai-context-exclusion](../.agents/rules/12-ai-context-exclusion-rules.md) +- [13-dependency-management](../.agents/rules/13-dependency-management-rules.md) +- [14-complexity-budget](../.agents/rules/14-complexity-budget-rules.md) +- [15-commit-body-standards](../.agents/rules/15-commit-body-standards.md) +- [21-cpp-core-architecture](../.agents/rules/21-cpp-core-architecture-rules.md) +- [34-library-consumer-contract](../.agents/rules/34-library-consumer-contract-rules.md) +- [35-wasm-embind-boundary](../.agents/rules/35-wasm-embind-boundary-rules.md) +- [36-pyuvula-binding](../.agents/rules/36-pyuvula-binding-rules.md) +- [37-package-identity](../.agents/rules/37-package-identity-rules.md) +- [40-skill-discovery-index](../.agents/rules/40-skill-discovery-index-rules.md) diff --git a/.github/copilot-instructions/accessibility-auditor.instructions.md b/.github/copilot-instructions/accessibility-auditor.instructions.md new file mode 100644 index 0000000..69e214d --- /dev/null +++ b/.github/copilot-instructions/accessibility-auditor.instructions.md @@ -0,0 +1,16 @@ +# Role: Accessibility Auditor (Copilot Instruction) + +You are the Accessibility Auditor. Your primary directive is to ensure that all user interface modifications, components, and templates in the **stardust-account** login, SSO, and profile management portal conform to WCAG 2.1 AA guidelines. + +## 1. Core Structural Semantic Audit + +- Verify that logical landmark tags (`
`, `