diff --git a/.agents/agents/adversarial_pr_reviewer/agent.md b/.agents/agents/adversarial_pr_reviewer/agent.md new file mode 100644 index 0000000..cf80fcf --- /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-123]`. + - **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..0435cf0 --- /dev/null +++ b/.agents/agents/ecosystem_contract_investigator/agent.md @@ -0,0 +1,143 @@ +# Ecosystem Contract Investigator Subagent Definition + +Name: ecosystem_contract_investigator +Description: Finds the cross-repository surfaces of `CuraFormulaeEngine` 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 `CuraFormulaeEngine` 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: + +- `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..e1b1383 --- /dev/null +++ b/.agents/bootstrap-observations.md @@ -0,0 +1,182 @@ +# Custom Rule Proposals โ€” CuraFormulaeEngine + +These are observations about how *this* repository actually works, gathered by +the agentic bootstrap. They are **not rules yet**. Each one is a signal plus the +question it raises; an agent (or a human) has to decide whether the observation +deserves to become a rule, and what the rule should actually say. + +**How to act on this document** + +1. Work through the proposals below, highest confidence first. +2. For each, verify the evidence yourself โ€” the detectors are heuristics and can + misread a repository. +3. For the ones worth keeping, write a rule file in `.agents/rules/` numbered + a custom band: `15`โ€“`19` (always-on), `35`โ€“`39` (path-scoped) or `45`โ€“`59` + (model-decision). Managed bands (`01`โ€“`14`, `20`โ€“`34`, `40`โ€“`44`) are + regenerated by the bootstrap and hash-guarded by the rules manifest. +4. Replace every `` in a draft with the concrete answer for this + repository. A rule that still contains a placeholder is worse than no rule. +5. Run `bash scripts/sync_agentic_configs.sh` so the new rule is symlinked into + `.claude/rules/` and `.opencode/rules/`. +6. Delete or annotate proposals you reject, so the next run does not re-litigate + a settled decision. + +**The `ecosystem_contract` category is different.** Those entries describe a +surface this repository shares with something outside it โ€” an interface +definition, a command registry, a language boundary, a published package, a +submodule graph, a deployment topology. The detector can only see that the +surface exists; it deliberately does not claim to know what the contract is. +Answering that is the charter of +`.agents/agents/ecosystem_contract_investigator/agent.md`, which appends its +findings here, and of `.agents/agents/architecture_investigator/agent.md`, which +writes the architecture section of `AGENTS.md`. Both produce proposals and prose +โ€” neither writes into `.agents/rules/`. A contract claim about another +repository is reviewed by a human before it becomes a rule. + +--- + +## 1. This repository's public surface is mounted into other repositories + +**Category:** ecosystem_contract **Confidence:** high + +**Evidence** + +- no entry point starts a process here, yet the repository publishes a surface (`include/`) +- published surface: `include/` + +**Question to reason about** + +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 (needs review and placeholder resolution)** + +> 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. + +--- + +## 2. Tests are collected in a top-level test tree + +**Category:** architecture **Confidence:** medium + +**Evidence** + +- 2 test files: 2 in a central tree, 0 beside sources + +**Question to reason about** + +Should the test-placement convention be stated so agents stop guessing where a new test belongs? + +**Draft rule (needs review and placeholder resolution)** + +> Place new tests in the top-level test tree, mirroring the source directory structure. + +--- + +## 3. Observed file-size distribution (input to the ratchet) + +**Category:** code-pattern **Confidence:** medium + +**Evidence** + +- median 27 lines, 90th percentile 127, largest sampled 2730 +- 2 of 122 sampled files exceed the 400-line budget and will be grandfathered at their current size + +**Question to reason about** + +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 127)? Re-run the bootstrap with `--file-size-budget N` to change it โ€” do not write a competing rule. + +**Draft rule (needs review and placeholder resolution)** + +> No new rule needed: adjust the managed budget instead. Consider whether the largest grandfathered files deserve a decomposition ticket of their own. + +--- + +## 4. Commit messages carry explanatory bodies + +**Category:** commit-style **Confidence:** medium + +**Evidence** + +- 65% of sampled commits have a body +- average body length 1.2 lines + +**Question to reason about** + +Should agents be required to write a body explaining why/how, not just a subject line? + +**Draft rule (needs review and placeholder resolution)** + +> Every commit message MUST include a body explaining why the change was needed and how it works โ€” a subject line alone is not enough. + +--- + +## 5. Commits are small and tightly scoped + +**Category:** commit-style **Confidence:** medium + +**Evidence** + +- median 1 files per commit, 90th percentile 25 + +**Question to reason about** + +Should the scope rule name a concrete file-count expectation derived from this repository's own history? + +**Draft rule (needs review and placeholder resolution)** + +> Keep commits tightly scoped: this repository's median commit touches 1 files. A commit touching more than 25 files needs an explicit justification in the message. + +--- + +## 6. This repository publishes a package whose identity is generated, not committed + +**Category:** ecosystem_contract **Confidence:** medium + +**Evidence** + +- `conanfile.py` +- `.github/workflows/package.yml` + +**Question to reason about** + +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 โ€” the identity has to be traced through the recipe. + +**Draft rule (needs review and placeholder resolution)** + +> 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 โ€” a breaking change to the exported surface requires the paired consumer PR in the same ticket. Relevant catalogue skills: `conan-2`. + +--- + +## 7. Ownership is declared in CODEOWNERS + +**Category:** process **Confidence:** medium + +**Evidence** + +- a CODEOWNERS file is present + +**Question to reason about** + +Should agents check CODEOWNERS before touching a path and name the owning team in the PR description? + +**Draft rule (needs review and placeholder resolution)** + +> Consult CODEOWNERS before modifying a path and mention the owning team in the PR description so review reaches them. + +--- + +## 8. File names are predominantly snake_case + +**Category:** code-pattern **Confidence:** low + +**Evidence** + +- {'snake_case': 90, 'kebab-case': 1} across 91 multi-word file names + +**Question to reason about** + +Does the convention differ per language or per directory, and is it worth stating so new files match? + +**Draft rule (needs review and placeholder resolution)** + +> Name new source files in snake_case, matching the existing tree. diff --git a/.agents/bootstrap-profile.json b/.agents/bootstrap-profile.json new file mode 100644 index 0000000..9cfca20 --- /dev/null +++ b/.agents/bootstrap-profile.json @@ -0,0 +1,367 @@ +{ + "repository": { + "name": "CuraFormulaeEngine", + "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": 60, + "jira_keys": { + "UC": 14 + }, + "allowed_jira_keys": [ + "UC" + ], + "branch_jira_key": "UC", + "history_jira_key": "UC", + "dominant_jira_key": "UC", + "semantic_prefix_ratio": 0.05 + }, + "base_branch": "main", + "submodules": [], + "ci": { + "providers": [ + "github-actions" + ], + "workflows": [ + ".github/workflows/package.yml", + ".github/workflows/unit-test.yml" + ], + "signals": { + "tests": true, + "lint": false, + "build": true + } + }, + "tests": { + "frameworks": [ + "catch2", + "ctest" + ], + "commands": [ + "ctest --test-dir build --output-on-failure" + ] + }, + "linters": { + "ruff": false, + "black": false, + "flake8": false, + "mypy": false, + "eslint": false, + "prettier": false, + "stylelint": false, + "clang_format": false, + "clang_tidy": false, + "pre_commit_config": false + }, + "lint_scripts": [], + "protected_branches": [ + "main" + ], + "cpp": { + "sampled_files": 121, + "expected_hits": 0, + "throw_hits": 7, + "error_style": "exceptions", + "cxx_standard": 20, + "idioms": { + "ranges": 71, + "constexpr": 103, + "concepts": 0, + "auto": 1283, + "unique_ptr": 45, + "shared_ptr": 0, + "raw_new": 0, + "raw_delete": 2, + "optional": 35, + "variant": 2, + "coroutines": 0 + }, + "auto_per_file": 10.6, + "has_wasm": true, + "conan_major": 2, + "conan_version_req": ">=2.7" + }, + "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": 70, + "extensions": ".h, .md" + }, + { + "name": "src", + "count": 50, + "extensions": ".cpp" + }, + { + "name": "apps", + "count": 3, + "extensions": ".txt, .cpp" + }, + { + "name": "tests", + "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": "LGPL-3.0", + "source": "conanfile.py", + "proprietary": false + }, + "has_package_json": false, + "node_package_dirs": [], + "python_packaging_manifests": [], + "file_count": 134, + "complexity": { + "functions_sampled": 317, + "median": 1, + "p90": 3, + "max": 41 + }, + "skills": { + "catalog_root": "~/dev/um/UltiCortex", + "catalog_size": 28, + "matched": [ + "conan-2", + "cmake", + "cpp-pro", + "software-architect", + "ultimaker-cura-development" + ] + }, + "observations": [ + { + "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": "architecture", + "title": "Tests are collected in a top-level test tree", + "evidence": [ + "2 test files: 2 in a central tree, 0 beside sources" + ], + "question": "Should the test-placement convention be stated so agents stop guessing where a new test belongs?", + "draft_rule": "Place new tests in the top-level test tree, mirroring the source directory structure.", + "confidence": "medium", + "data": {} + }, + { + "category": "code-pattern", + "title": "Observed file-size distribution (input to the ratchet)", + "evidence": [ + "median 27 lines, 90th percentile 127, largest sampled 2730", + "2 of 122 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 127)? 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": 27, + "p90": 127, + "largest": 2730, + "over_budget": 2 + } + }, + { + "category": "commit-style", + "title": "Commit messages carry explanatory bodies", + "evidence": [ + "65% of sampled commits have a body", + "average body length 1.2 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": "medium", + "data": {} + }, + { + "category": "commit-style", + "title": "Commits are small and tightly scoped", + "evidence": [ + "median 1 files per commit, 90th percentile 25" + ], + "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 25 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": "process", + "title": "Ownership is declared in CODEOWNERS", + "evidence": [ + "a CODEOWNERS file is present" + ], + "question": "Should agents check CODEOWNERS before touching a path and name the owning team in the PR description?", + "draft_rule": "Consult CODEOWNERS before modifying a path and mention the owning team in the PR description so review reaches them.", + "confidence": "medium", + "data": {} + }, + { + "category": "code-pattern", + "title": "File names are predominantly snake_case", + "evidence": [ + "{'snake_case': 90, 'kebab-case': 1} across 91 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 snake_case, matching the existing tree.", + "confidence": "low", + "data": {} + } + ], + "hook_verification": { + "block-secrets": { + "fired": true, + "blocked": true, + "detail": "staged violation rejected", + "at": "2026-08-19T12:09:15+00:00", + "sha": "6aa5b22" + }, + "block-absolute-paths": { + "fired": true, + "blocked": true, + "detail": "staged violation rejected", + "at": "2026-08-19T12:09:15+00:00", + "sha": "6aa5b22" + }, + "pretool-guard": { + "fired": true, + "blocked": true, + "detail": "secret payload denied", + "at": "2026-08-19T12:09:15+00:00", + "sha": "6aa5b22" + }, + "block-name-matched-kill": { + "fired": true, + "blocked": true, + "detail": "pkill denied", + "at": "2026-08-19T12:09:15+00:00", + "sha": "6aa5b22" + }, + "check-rule-frontmatter": { + "fired": true, + "blocked": null, + "detail": "exit 0 (advisory; executed, not violated)", + "at": "2026-08-19T12:09:15+00:00", + "sha": "6aa5b22" + }, + "audit-quad-agent-parity": { + "fired": true, + "blocked": null, + "detail": "exit 0 (advisory; executed, not violated)", + "at": "2026-08-19T12:09:15+00:00", + "sha": "6aa5b22" + }, + "check-upstream-alignment": { + "fired": true, + "blocked": null, + "detail": "exit 0 (advisory; executed, not violated)", + "at": "2026-08-19T12:09:15+00:00", + "sha": "6aa5b22" + } + } +} diff --git a/.agents/dispatch-invariants.md b/.agents/dispatch-invariants.md new file mode 100644 index 0000000..0a49bd4 --- /dev/null +++ b/.agents/dispatch-invariants.md @@ -0,0 +1,31 @@ +# Dispatch Invariants + + + +1. **Jira key**: every commit title in this session starts with bracketed Jira ticket key `[PROJECT-KEY]` matching the active branch (e.g. `[UC-3697]`). +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. **Scoped formatting**: only format files touched by the task. Run `pre-commit run --all-files` for comprehensive verification before opening PRs. +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..372f445 --- /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/eval.cpp": 657 + }, + "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..a738181 --- /dev/null +++ b/.agents/hooks/block_name_matched_kill.py @@ -0,0 +1,154 @@ +#!/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, a Godot binary and a build cache, +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() + + COMMAND_SEPARATORS = {";", "&&", "||", "|", "&"} + is_command_position = True + + for index, token in enumerate(tokens): + if token in COMMAND_SEPARATORS: + is_command_position = True + continue + + if not is_command_position: + continue + + is_command_position = False + 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 = [] + for t in rest: + if t in COMMAND_SEPARATORS: + break + if not t.startswith("-"): + targets.append(t) + 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 if pkill/killall is invoked inside substitution. + reason = offending_command(command) + if reason is None and SUBSTITUTION.search(command): + for name in BY_NAME: + if re.search(rf"(?:\$\(|\`)\s*(?:/usr/bin/)?{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..f890ba1 --- /dev/null +++ b/.agents/hooks/check_atomic_bisect_history.py @@ -0,0 +1,184 @@ +#!/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, ...] = ( + 'cmake --build --preset conan-release', +) +#: Its test runner, if the investigation found one. +TEST_COMMANDS: tuple[str, ...] = ( + 'ctest --preset conan-release --output-on-failure', +) + +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..15bb894 --- /dev/null +++ b/.agents/hooks/check_complexity.py @@ -0,0 +1,321 @@ +#!/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, ...] = ( + '.cc', + '.cpp', + '.cxx', + '.hpp', + '.py', +) +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 git_base_ref() -> str: + for candidate in ("origin/main", "origin/master", "main", "master"): + res = subprocess.run(["git", "merge-base", "HEAD", candidate], + capture_output=True, text=True) + if res.returncode == 0 and res.stdout.strip(): + return res.stdout.strip() + return "HEAD" + + +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] + base_ref = git_base_ref() + for path in paths: + suffix = Path(path).suffix + before = complexity_of(git_show("HEAD", path), suffix) + before_base = (complexity_of(git_show(base_ref, path), suffix) + if base_ref != "HEAD" else {}) + 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) + was_base = before_base.get(name) + if was_base is None: + short_name = name.split("::")[-1] + for k, v in before_base.items(): + if k.endswith("::" + short_name) or k == short_name: + was_base = v + break + if was is None: + short_name = name.split("::")[-1] + for k, v in before.items(): + if k.endswith("::" + short_name) or k == short_name: + was = v + break + if was_base is not None: + was = max(was or 0, was_base) + + 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..c63de94 --- /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-123 ") + 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..3163b2b --- /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 Exception: + 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..986bc30 --- /dev/null +++ b/.agents/hooks/file_size_scope.py @@ -0,0 +1,241 @@ +#!/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', +) + +# 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..3757c6f --- /dev/null +++ b/.agents/hooks/git-branch-guard.py @@ -0,0 +1,111 @@ +#!/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, ...] = ( + 'include/cura-formulae-engine', +) + +#: 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` or `[KEY]-123_short_description` +BRANCH_NAME_RE = re.compile(r"^(UC|NP|CURA|PP)-\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-123-short-description" + ) + sys.exit(1) + + if branch and not BRANCH_NAME_RE.match(branch): + print( + f"BRANCH NAMING NOTICE: '{branch}' does not match " + f"`-[-_]description` (e.g. UC-3697_AI_DF or NP-1325-fix-eval).\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..a1c3ae3 --- /dev/null +++ b/.agents/hooks/post-edit-linter.sh @@ -0,0 +1,88 @@ +#!/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 + +# No repo-configured linters were detected during bootstrap. +# Re-run bootstrap_agentic_repo.py --update after adding linter configs. + +if [ -f .agents/hooks/check_file_size_budget.py ] && [ -n "$CHANGED" ]; then + # shellcheck disable=SC2086 + python3 .agents/hooks/check_file_size_budget.py --changed $CHANGED || true +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..9eb1b89 --- /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(os.path.realpath(__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..d44619c --- /dev/null +++ b/.agents/hooks/suggest-skills.py @@ -0,0 +1,135 @@ +#!/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')} + +_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 + print("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:", file=sys.stderr) + for skill, (reason, paths) in sorted(hits.items()): + sample = ", ".join(paths[:3]) + (" ..." if len(paths) > 3 else "") + print(" - {}: {}".format(skill, reason), file=sys.stderr) + print(" triggered by: {}".format(sample), file=sys.stderr) + print(" gh skill install Ultimaker/UltiCortex {}".format(skill), 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/pretool_guard.py b/.agents/pretool_guard.py new file mode 120000 index 0000000..fbbd4f0 --- /dev/null +++ b/.agents/pretool_guard.py @@ -0,0 +1 @@ +hooks/pretool_guard.py \ No newline at end of file diff --git a/.agents/rules-manifest.json b/.agents/rules-manifest.json new file mode 100644 index 0000000..7f3f931 --- /dev/null +++ b/.agents/rules-manifest.json @@ -0,0 +1,19 @@ +{ + "rules": { + "01-jira-commit-standards.md": "0fae33088a2063cf57a2b9b7189b6ca70ec16e394b8c152d04ef289bf04e4a5f", + "02-security-and-paths.md": "f18b11b8ff318e969f812803a72c5d45fe84b78a5d6107bb41a447306de80f93", + "04-build-test-and-deployment-rules.md": "71729913dad864bf3530c41e4f1a8d94e6e4546b83b2a56fd9346229d8fa5366", + "05-ultimaker-skill-discovery-rules.md": "100d17568b232801e54b220a8a129cbb26d847718156124a72a228fd4d7a40f1", + "06-pull-request-lifecycle-rules.md": "2893b4c4f969250c6065d27f108c5547d1926314fd8845a01ef88f4545e95664", + "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": "1f9593e42e9e92fffb6ea8cc03622012763f62d8e184dab71fc3868e204c5e3e", + "14-complexity-budget-rules.md": "df07d63b6a43b93d60bcd155232d7ee35bcfff0a2c2ab1ae332415fcfd4735d0", + "21-cpp-core-architecture-rules.md": "2f2663b582d1af304a1a60f06fc4e5380de5f9936916f68744853c611bc167e1", + "34-library-consumer-contract-rules.md": "560e10d03db2626471d3b84692be72e462ebd407aa29bd668150484c2fa9d4c8", + "40-skill-discovery-index-rules.md": "ecdb054eea80cd4e60ff7f8f1e4c9b9b745812898140ede6033ee1ce1d966d4c" + } +} diff --git a/.agents/rules/01-jira-commit-standards.md b/.agents/rules/01-jira-commit-standards.md new file mode 100644 index 0000000..1686199 --- /dev/null +++ b/.agents/rules/01-jira-commit-standards.md @@ -0,0 +1,25 @@ +--- +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` or `NP` (e.g. `UC-3697-short-description` or `NP-1325_fix_eval`). +2. **Commit Title Standard**: + - Every commit title MUST start with bracketed Jira ticket key: `[UC-123] ` or `[NP-123] `. + - Do NOT use semantic commit prefixes (`feat:`, `fix:`, `chore:`, `refactor:`) in commit or PR titles. +3. **Commit Body Requirement**: + - Non-trivial commits MUST include an explanatory commit body detailing why the change was made, architecture trade-offs, and reference the ticket with `Contributes to `. + - Example: + ```text + [UC-3697] Configure pre-commit and agentic enablement + + Setup pre-commit hooks and custom copilot instructions for CuraFormulaeEngine development. + + Contributes to UC-3697 + ``` +4. **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..0167b17 --- /dev/null +++ b/.agents/rules/04-build-test-and-deployment-rules.md @@ -0,0 +1,31 @@ +--- +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**: + - `conan install . --build=missing --update` + - `cmake --preset conan-release` + - `cmake --build --preset conan-release` + - Builds must complete cleanly (zero warnings) before PR creation. +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. **Test Commands**: + - `ctest --preset conan-release --output-on-failure` +4. **Artifact Isolation**: + - Keep generated build outputs, intermediate binaries, and logs out of git (`build/` is gitignored). +5. **Freshness Before Evidence**: + - Rebuild binaries (`cmake --build --preset conan-release`) before treating a test 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-123] ` or `[NP-123] ` standard. +3. **Version Synchronization**: + - Single source of truth for versioning is `conandata.yml` (`version: "..."`). + - `conanfile.py` reads `conandata.yml` and injects `CURA_FORMULA_VERSION_VERSION` into CMake. A release must update `conandata.yml`. 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..a69591f --- /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`, `**/*.h` + +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-curator-development` โ€” always relevant + +Comprehensive guide to working with the Curator C++ configuration engine, the setting stack resolver, fdmprinter specifications, and WebAssembly bindings. + +**Why it applies here:** Curator is the direct consumer of CuraFormulaeEngine, integrating this engine for formula resolution and building the `@ultimaker/curatorjs` WASM package. + +```bash +gh skill install Ultimaker/UltiCortex ultimaker-curator-development +``` + +### `ultimaker-neoprep-development` โ€” always relevant + +Comprehensive guide to developing, building, testing, and operating the Neoprep React applications, including integration with Curator and CuraEngine WASM modules. + +**Why it applies here:** NeoPrep is the interactive web workspace executing CuraFormulaeEngine formulas in-browser via WebAssembly. + +```bash +gh skill install Ultimaker/UltiCortex ultimaker-neoprep-development +``` + +## Other Catalogue Skills + +21 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..907f297 --- /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-123]`), 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..4a1c3df --- /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 **LGPL-3.0** (declared in `conanfile.py`). + + +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 LGPL-3.0 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..4fe4e8d --- /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 3, worst 41). + +## 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/21-cpp-core-architecture-rules.md b/.agents/rules/21-cpp-core-architecture-rules.md new file mode 100644 index 0000000..d29a381 --- /dev/null +++ b/.agents/rules/21-cpp-core-architecture-rules.md @@ -0,0 +1,49 @@ +--- +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,**/CMakePresets.json" +paths: + - "**/*.cpp" + - "**/*.hpp" + - "**/*.cc" + - "**/*.cxx" + - "**/*.h" + - "**/CMakeLists.txt" + - "**/CMakePresets.json" +--- +# 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 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). + +4. **Idiomatic Preferences (mined from this codebase โ€” 121 files sampled)**: + - **Ranges over raw loops**: this codebase uses range-v3 (`ranges::`) extensively (71 usages). Express pipelines with range views/actions instead of hand-written index or iterator loops. + - **Compile-time first**: `constexpr` is used pervasively (103 usages). Mark functions and constants `constexpr` wherever the computation allows. + - **Almost-always-auto**: declare locals with `auto` (observed ~10.6 usages per file). Spell out types only where deduction hurts readability or correctness. + - **Smart-pointer ownership only**: 45 smart-pointer usages, ~zero raw `delete`. Ownership is expressed exclusively via smart pointers (std::unique_ptr dominant); never introduce raw owning `new`/`delete`. + - **std::optional for absent values** (35 usages): never use sentinel values or nullable raw pointers to signal absence. + +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`): + - 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..3e25ba3 --- /dev/null +++ b/.agents/rules/34-library-consumer-contract-rules.md @@ -0,0 +1,39 @@ +--- +name: library-consumer-contract +description: This repository's public surface is consumed by other repositories (Curator, NeoPrep) as a Conan 2 package and WebAssembly module โ€” every exported symbol and binding is a cross-repository contract. +trigger: glob +glob: "include/**,src/eval.cpp,src/cura-formulae-engine/**" +paths: + - "include/**" + - "src/eval.cpp" + - "src/cura-formulae-engine/**" +--- +# Library Consumer Contract + +This repository is distributed as a Conan 2 package (`cura-formulae-engine/@ultimaker/testing`) and consumed by other repositories rather than run solely as an end-user application. + +## Evidence & Architecture + +- **Public Header Surface (`include/cura-formulae-engine/`)**: Exposes AST nodes, parser combinators, environment symbol tables, and value evaluation variants. +- **Direct Downstream Consumer**: **Curator** (`../Curator/conanfile.py`) requires `cura-formulae-engine/@ultimaker/testing` for setting dependency graph evaluation. +- **WASM Consumer**: Curator compiles `CuraFormulaeEngine` into WebAssembly (`@ultimaker/curatorjs`), consumed by **NeoPrep** for client-side evaluation. +- **Standalone Verification**: An interactive REPL CLI is provided in `apps/cmdline_parser/` for local formula parsing and evaluation debugging. + +## What Constitutes a Breaking Change + +Within `include/cura-formulae-engine/` and `src/eval.cpp`, all of the following are contract changes, not isolated internal refactors: + +1. **Renaming or removing any exported symbol** โ€” `ast::ExprPtr`, `parser::parse()`, `eval::Value`, `eval::Result`, `eval::Error`, `env::Environment`. +2. **Modifying the `eval::Value` variant or `eval::Error` enum values**: downstream consumers switch exhaustively on these variants. +3. **Changing Emscripten bindings or conversions (`Value::toEmscripten()`)**: breaks JavaScript/TypeScript interop in `@ultimaker/curatorjs` and NeoPrep. +4. **Changing formula evaluation semantics or operator precedence**: alters setting resolution results across the 3D slicing pipeline. + +## Consumer Coordination Protocol + +1. **Check Consumers**: Search downstream repositories for requirements: + ```bash + gh search code --owner Ultimaker --filename conanfile.py "cura-formulae-engine" + ``` +2. **Additive Evolution**: Where a breaking shape is unavoidable, provide backwards-compatible overloads or aliases. +3. **Package Versioning**: Update `version` in `conandata.yml` following Semantic Versioning rules, and update `conanfile.py` in downstream consumers (`Curator`). +4. **Explanatory Commit Message**: Clearly describe any changes to the public API and AST/evaluation behaviour so downstream maintainers understand the upgrade path. diff --git a/.agents/rules/35-cura-formulae-engine-conventions.md b/.agents/rules/35-cura-formulae-engine-conventions.md new file mode 100644 index 0000000..60ae652 --- /dev/null +++ b/.agents/rules/35-cura-formulae-engine-conventions.md @@ -0,0 +1,35 @@ +--- +name: cura-formulae-engine-conventions +description: Repository-specific architecture conventions, Catch2 test organization, snake_case file naming, AST pointer patterns, and zeus::expected error handling. +trigger: glob +glob: "src/**,include/**,tests/**,apps/**" +paths: + - "src/**" + - "include/**" + - "tests/**" + - "apps/**" +--- +# CuraFormulaeEngine Conventions + +1. **Test Organization & Applications**: + - All unit tests belong in the top-level `tests/` directory and use Catch2 (`Catch2::Catch2WithMain`). + - Tests are discovered via `catch_discover_tests` in `tests/CMakeLists.txt` and executed via `ctest --preset conan-release --output-on-failure`. + - Standalone tools reside in `apps/` (e.g. `apps/cmdline_parser/cmdline_parser.cpp` provides an interactive CLI REPL for expression testing). + - New AST nodes, parser rules, and evaluation primitives MUST include corresponding test cases verifying both valid syntax/evaluation and expected error conditions. + +2. **File Naming & Namespace Conventions**: + - Source files, header files, and test files MUST follow `snake_case` naming (`snake_case.cpp`, `snake_case.h`). + - Namespace hierarchy: + - `CuraFormulaeEngine`: Top-level namespace + - `CuraFormulaeEngine::ast`: Abstract syntax tree node structures and pointer aliases (`ExprPtr`, constructed via `ast::make_expr_ptr(...)`) + - `CuraFormulaeEngine::parser`: Lexy combinator grammar rules and `parse()` entry point + - `CuraFormulaeEngine::env`: Evaluation environment, symbol table bindings, and builtin functions + - `CuraFormulaeEngine::eval`: Value variant, arithmetic/boolean operator overloads, and evaluation result types + +3. **Symbol Table & Builtin Hierarchy**: + - Top-level builtins (`std_env`): `abs`, `all`, `any`, `float`, `int`, `len`, `map`, `max`, `min`, `round`, `str`, `sum`. + - Math namespace object (`math.*`): `sin`, `cos`, `tan`, `atan`, `sqrt`, `floor`, `ceil`, `log`, `degrees`, `radians`, `pi`, `e`, `tau`, `inf`, `nan` are registered as properties on the `math` object. + +4. **Error Handling with `zeus::expected`**: + - Parsing and evaluation functions return `zeus::expected` (`eval::Result` alias) to maintain strict, deterministic error handling without runtime aborts. + - Evaluation errors are categorized via `eval::Error` enum (`TypeMismatch`, `UndefinedVariable`, `DivisionByZero`, `InvalidNumberOfArguments`, `IndexOutOfBounds`, `ValueError`). 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..42c656e --- /dev/null +++ b/.agents/rules/40-skill-discovery-index-rules.md @@ -0,0 +1,38 @@ +--- +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-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/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/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-cura-formulae-engine-conventions.md b/.claude/rules/35-cura-formulae-engine-conventions.md new file mode 120000 index 0000000..ba027fd --- /dev/null +++ b/.claude/rules/35-cura-formulae-engine-conventions.md @@ -0,0 +1 @@ +../../.agents/rules/35-cura-formulae-engine-conventions.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/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..488b309 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,12 @@ +# Global default +* @Ultimaker/cura-core + +# Packaging & Build System +conanfile.py @Ultimaker/cura-core @Ultimaker/devops +conandata.yml @Ultimaker/cura-core @Ultimaker/devops +CMakeLists.txt @Ultimaker/cura-core @Ultimaker/devops +/cmake/ @Ultimaker/cura-core @Ultimaker/devops + +# CI / CD Workflows & Agent Configurations +/.github/ @Ultimaker/cura-core @Ultimaker/devops +/.agents/ @Ultimaker/cura-core 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..4473d5d --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,41 @@ + + +# 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 numbered +rule files in [.agents/rules/](../.agents/rules/). + +Apply, in order: + +1. `AGENTS.md` โ€” operational guide, tech stack, directory layout, PR flow. +2. The numbered rules below (01โ€“14 always apply; 20+ apply to matching files). +3. `DESIGN.md` (if present) โ€” design tokens for any UI work. + +## 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) +- [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-cura-formulae-engine-conventions](../.agents/rules/35-cura-formulae-engine-conventions.md) +- [40-skill-discovery-index](../.agents/rules/40-skill-discovery-index-rules.md) + + +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. 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 (`
`, `